Give the system a directory: ask who knows, then ask them

A memory system that only answers from its own store is a filing cabinet,
not a partner. The transactive rule the project is named for — not knowing
is fine when you know who does — needed a holder registry, a directory
query, and a referral that never leaks the text it may not read.

- schema v5 adds `holders` (person/agent/service, declared topics, contact).
  A holder with memories exists whether or not anybody registered them; the
  registry adds reachability, it does not grant existence.
- `who_knows` ranks holders by their own memories and is deliberately NOT
  filtered by visibility: "she knows" is the answer. The answer is filtered.
- `ask` searches the asker's visible scope first (hop 0), then walks the
  directory with a visited set and a hop budget. Three terminal states:
  answered, referred, no_holder_knows. Nobody knowing is a result, not an error.
- Confidence is ordinal, not calibrated: relative strength, coverage, and
  declared topics. Every hit carries the raw numbers and the basis lines that
  produced the score, so a ranking can be argued with.
- Faces: HTTP (/api/holders, /api/who_knows, /api/ask), CLI (holders,
  holder-set, holder-rm, who-knows, ask), MCP (who_knows, ask, list_holders).

Four bugs found by running the thing, not by reading it:
- FTS5 refuses bm25() inside MAX(); the aggregate uses FTS5's own `rank` column.
- A CJK question is longer than the sentence that answers it, so the whole-run
  phrase matched nothing. The query now ORs adjacent bigram phrases; adjacency
  is kept, equal length is not. Latin path untouched, baseline unaffected.
- The hop budget counted the asker as a hop, so max_hops=1 asked nobody.
- A second, stricter readability test in _answered() disagreed with the SQL
  visibility clause and hid a memory the asker was allowed to read. The SQL
  clause is the only authority now.

A method named `list` shadowed the builtin inside its own class and made
mypy reject the return annotation; renamed rather than cast around it.

Proof: 155 pytest, 12 Go tests, ruff and mypy clean on touched files, pyright
ledger unchanged at 62, and scripts/mcp_write_smoke.sh drives two principals
on one SQLite file — the husband asks through MCP, gets `referred` with his
wife's contact channel, and her private sentence appears nowhere in the reply.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 17:20:45 -04:00
1 parent 9fe13fffb3
commit dfc6cd4c28
19 files changed
+1403 -19

No files matched your search

+13 -1
View File
@@ -42,7 +42,7 @@ def fts_query(query: str) -> str:
terms = _bare(query).split()
if not terms:
raise ValueError(f"search needs at least one searchable word, got {query!r}")
return " ".join(_phrase(term) if _is_cjk(term) else f"{_quote(term)}*" for term in terms)
return " ".join(_cjk_phrase(term) if _is_cjk(term) else f"{_quote(term)}*" for term in terms)
def _bare(text: str) -> str:
@@ -59,5 +59,17 @@ def _phrase(run: str) -> str:
return '"' + " ".join(run) + '"'
def _cjk_phrase(run: str) -> str:
"""A CJK run stays adjacent, and a long question also reaches the pairs inside it.
The whole run as one phrase is the precise reading, and it is what a two-character word
needs. A question is usually longer than the sentence that answers it: 钥匙在哪里 will not
appear inside 我们的钥匙放在玄关抽屉里. Offering every adjacent pair as an alternative keeps
adjacency — 钥 and 匙 still have to touch — without demanding equal length.
"""
phrases = list(dict.fromkeys([_phrase(run)] + [_phrase(run[start : start + 2]) for start in range(len(run) - 1)]))
return phrases[0] if len(phrases) == 1 else "(" + " OR ".join(phrases) + ")"
def _quote(term: str) -> str:
return '"' + term.replace('"', '""') + '"'