Skip to content

Commit f84f1e8

Browse files
authored
Merge pull request #45 from nickroci/fix/scholar-retitle-and-priming-mtime-reindex
fix(priming+curator): content-hash reindex gate, searchable titles, drift re-titling
2 parents 4226c8a + 3018c3a commit f84f1e8

6 files changed

Lines changed: 438 additions & 45 deletions

File tree

daemon/agent_mem_daemon/librarian_prompt.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,18 @@ def _slugify(s: str) -> str:
720720
- The load-bearing claim MUST survive intact. You are integrating or \
721721
sharpening, NOT rewriting from scratch. If you can't keep the core claim \
722722
verbatim-in-spirit, this is ``contradicts``, not ``drift``.
723+
- **RE-TITLE when the body's STATE changes.** If your mutation changes \
724+
what the entry reports about its subject's status — most often flipping an \
725+
open problem to fixed/resolved/implemented, or a doubt to settled — you \
726+
MUST re-derive the ``title:`` frontmatter AND the ``# H1`` heading (and \
727+
re-check ``applies-when``/``keywords``/``summary``) so the index card \
728+
describes the NEW state, then carry them in ``new_body``. A problem-framed \
729+
title sitting on a body that now says the problem is solved is the single \
730+
most common drift defect — the card ends up lying about its own contents. \
731+
This does NOT violate the load-bearing-claim rule above: that rule governs \
732+
the CLAIM, not its framing. Reframing "X is a blind spot" into "X was \
733+
fixed by Y; the lesson is Z" is REQUIRED here, not a forbidden \
734+
rewrite-from-scratch.
723735
- Pure rephrasing that adds no information and sharpens nothing → \
724736
DON'T. That is exactly the churn that turns memories to mush over time.
725737
- **SPLIT instead of cramming when the entry sprawls.** Two cases: \

daemon/agent_mem_daemon/scholar_prompt.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@
144144
``actions`` list creates. The boundary validator REJECTS an unresolvable \
145145
wikilink and re-prompts you with the offending target — remove it or fix \
146146
the path.
147+
- For an approved ``update_entry`` carrying ``salience_signal: "drift"``: \
148+
if the new body changed what the entry asserts about its subject's STATE \
149+
(an open problem the body now reports as fixed/resolved/implemented) but \
150+
the Librarian left the old problem-framed ``title``/``# H1`` in place, \
151+
re-derive them to match the body you are approving — and re-check \
152+
``applies-when``. A stale title on a resolved body is a defect the \
153+
load-bearing-claim rule does not catch, and correcting it is your job as \
154+
curator: this is the one substantive normalisation you may make. (Do NOT \
155+
touch the title on non-drift actions — there, copy it faithfully.)
147156
148157
Anything more substantial is a VETO. The lesson will recur in a future session.
149158
@@ -409,6 +418,13 @@
409418
up, cuts a stale tangent). A stylistic rewrite that adds no information \
410419
and sharpens nothing is churn; VETO with "no new info and no sharpening — \
411420
churn risks distortion."
421+
TITLE↔BODY CHECK: before approving, read the ``title``/``# H1`` against \
422+
the new body. If the body now resolves a problem the title still poses \
423+
(e.g. title says "X is a blind spot" but the body added a "fixed in vY" \
424+
section), re-derive the title/H1 to match the resolved body — per the \
425+
title rule in the modifications list above. Do not approve an entry whose \
426+
index card contradicts its own contents; a lying title is worse churn than \
427+
a re-title.
412428
Size is managed by SPLITTING, not by a cap: if a drift update would \
413429
make one entry sprawl across more than one claim, the Librarian should \
414430
have proposed a split (a trimming ``update_entry`` on the original + a \

tools/search/bm25.py

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
33
Tokenization rules (v1, see README for rationale):
44
- YAML frontmatter is stripped entirely BEFORE tokenization, except:
5+
* `title:` is appended to the body text (high-signal; some titles phrase
6+
the claim differently from the body `# H1`).
57
* `keywords:` values (list or inline) are appended to the body text.
68
* `applies-when:` values (block scalar or inline) are appended to the body.
7-
Those two fields are explicitly search-relevant per PLAN section 2.
9+
`keywords`/`applies-when` are search-relevant per PLAN section 2; `title`
10+
was added later (see `_frontmatter_search_text`).
811
- Code fences are kept verbatim. Code identifiers are valuable search terms.
912
- Markdown is otherwise treated as plain text: we don't strip headings,
1013
wikilinks, list markers, etc. (BM25 handles the noise; over-stripping
@@ -22,6 +25,7 @@
2225

2326
from __future__ import annotations
2427

28+
import hashlib
2529
import os
2630
import pickle
2731
import re
@@ -75,9 +79,23 @@ def _strip_and_extract_frontmatter(text: str) -> tuple[str, FrontmatterDict]:
7579

7680

7781
def _frontmatter_search_text(fm: Mapping[str, object]) -> str:
78-
"""Pull out the search-relevant frontmatter fields per PLAN section 2."""
82+
"""Pull out the search-relevant frontmatter fields: ``title``, ``keywords``
83+
and ``applies-when``.
84+
85+
``title`` is included (beyond PLAN section 2's original keywords/applies-when)
86+
so titles are first-class search signal: most titles are mirrored by the body
87+
``# H1`` and would be found anyway, but a real minority phrase the claim
88+
differently in the title than the H1, and those terms are only searchable if
89+
the title is indexed. A side effect makes the contract clean: because this is
90+
the single text source shared by BM25, the embedding index AND
91+
:func:`index_content_hash`, indexing the title also means a re-title changes
92+
the content hash and correctly triggers a reindex."""
7993
parts: list[str] = []
8094

95+
title = fm.get("title")
96+
if isinstance(title, str) and title.strip():
97+
parts.append(title)
98+
8199
keywords = fm.get("keywords")
82100
if isinstance(keywords, list):
83101
keywords_list = cast(list[object], keywords)
@@ -98,7 +116,7 @@ def _frontmatter_search_text(fm: Mapping[str, object]) -> str:
98116
def tokenize(text: str) -> list[str]:
99117
"""Tokenize a full markdown document for BM25.
100118
101-
Strips YAML frontmatter from the body, but pulls `keywords` and
119+
Strips YAML frontmatter from the body, but pulls `title`, `keywords` and
102120
`applies-when` into the searchable text first.
103121
"""
104122
body, fm = _strip_and_extract_frontmatter(text)
@@ -107,6 +125,27 @@ def tokenize(text: str) -> list[str]:
107125
return [tok for tok in _TOKEN_SPLIT_RE.split(combined) if len(tok) >= 2]
108126

109127

128+
def index_content_hash(raw: str) -> str:
129+
"""Stable hash of the text that actually feeds the indexes.
130+
131+
Both BM25 (:func:`tokenize`) and the embedding index
132+
(``embeddings._embedding_text``) index the body plus the ``title``,
133+
``keywords`` and ``applies-when`` frontmatter — and nothing else (all three
134+
derive their text from :func:`_frontmatter_search_text`, so this hash covers
135+
exactly what is searched, no more). Mutable bookkeeping fields (``fired``,
136+
``last_surfaced``, ``reconsolidated``, …) are stripped before indexing, so a
137+
write that only touches them leaves every token and every embedding
138+
byte-identical. Keying staleness on this hash instead of the file mtime means
139+
such writes no longer trigger a needless re-tokenise / re-encode on the
140+
priming hot path — which is what made every priming surface (it bumps
141+
``fired``/``last_surfaced``) re-index the corpus. A re-title, by contrast,
142+
DOES change this hash and so reindexes — correct, since the title is searched.
143+
"""
144+
body, fm = _strip_and_extract_frontmatter(raw)
145+
indexed_text = _frontmatter_search_text(fm) + "\n" + body
146+
return hashlib.blake2b(indexed_text.encode("utf-8"), digest_size=16).hexdigest()
147+
148+
110149
# ── Public aliases for cross-module use ────────────────────────────────────────
111150
# ``embeddings.py`` reuses the same file-selection and frontmatter discipline.
112151
# We expose non-private aliases so it can import them without tripping
@@ -126,6 +165,12 @@ class _DocRecord:
126165
path: str # absolute path string
127166
mtime: float
128167
raw_text: str # full file text, kept for snippet generation
168+
# blake2b of the indexed text (body + title + keywords + applies-when). Lets
169+
# the staleness check tell a real content edit from a bookkeeping-only mtime
170+
# bump. Defaults to "" so indexes pickled before this field existed still
171+
# unpickle — a missing hash never matches, so the first staleness check
172+
# after an upgrade falls through to a rebuild that repopulates it.
173+
content_hash: str = ""
129174

130175

131176
class BM25Hit(NamedTuple):
@@ -280,7 +325,14 @@ def build_index(knowledge_dir: Path) -> BM25Index:
280325
toks = tokenize(raw)
281326
if not toks:
282327
continue
283-
docs.append(_DocRecord(path=str(md), mtime=md.stat().st_mtime, raw_text=raw))
328+
docs.append(
329+
_DocRecord(
330+
path=str(md),
331+
mtime=md.stat().st_mtime,
332+
raw_text=raw,
333+
content_hash=index_content_hash(raw),
334+
)
335+
)
284336
tokenized.append(toks)
285337

286338
bm25 = BM25Okapi(tokenized) if tokenized else None
@@ -375,6 +427,42 @@ def is_stale(docs: Sequence[_TimestampedDoc], current_files: Mapping[str, float]
375427
return False
376428

377429

430+
def only_bookkeeping_changed(
431+
docs: Sequence[_TimestampedDoc],
432+
current_files: Mapping[str, float],
433+
) -> bool:
434+
"""True iff every change since the index was built is confined to non-indexed
435+
(bookkeeping/title) frontmatter — so a rebuild would be byte-identical and the
436+
cached index can be reused as-is.
437+
438+
Call only after :func:`is_stale` returned True. Returns False (→ caller must
439+
rebuild) if any file was added or removed, if a changed file can't be read,
440+
or if a changed file's indexed-content hash differs from its cached record.
441+
Only files whose mtime advanced are read; the unchanged majority is skipped
442+
via the same cheap mtime comparison ``is_stale`` uses. A cached record with no
443+
stored ``content_hash`` (index pickled before that field existed) never
444+
matches, so the first post-upgrade staleness check falls through to a rebuild
445+
that repopulates the hashes. Shared by the BM25 and embedding indices.
446+
"""
447+
tracked: dict[str, _TimestampedDoc] = {rec.path: rec for rec in docs}
448+
if set(current_files) != set(tracked):
449+
return False
450+
for path, mtime in current_files.items():
451+
rec = tracked[path]
452+
if mtime <= rec.mtime + 1e-6:
453+
continue # unchanged — no need to read the file
454+
cached_hash = getattr(rec, "content_hash", "")
455+
if not cached_hash:
456+
return False # pre-upgrade record (or genuinely empty) → rebuild
457+
try:
458+
raw = Path(path).read_text(encoding="utf-8")
459+
except (OSError, UnicodeDecodeError):
460+
return False
461+
if index_content_hash(raw) != cached_hash:
462+
return False # indexed content really changed
463+
return True
464+
465+
378466
def load_or_build(
379467
knowledge_dir: Path,
380468
index_path: Path | None = None,
@@ -390,6 +478,14 @@ def load_or_build(
390478
current_files = {str(p): p.stat().st_mtime for p in _iter_markdown(knowledge_dir)}
391479
if not is_stale(cached.docs, current_files):
392480
return cached
481+
# Stale by mtime, but if the only writes were bookkeeping/title
482+
# frontmatter the indexed text is unchanged — reuse without a
483+
# rebuild. (We don't re-save: stored mtimes stay behind, so the
484+
# next request re-reads the same handful and confirms again. That
485+
# read is sub-millisecond; a full re-tokenise + BM25Okapi rebuild
486+
# on every priming surface is what we're avoiding.)
487+
if only_bookkeeping_changed(cached.docs, current_files):
488+
return cached
393489

394490
index = build_index(knowledge_dir)
395491
try:

0 commit comments

Comments
 (0)