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

+6 -2
View File
@@ -16,6 +16,7 @@ from .benchmarks import (
)
from .cartridge import CartridgeService
from .cartridge_assets import CartridgeAssetService
from .cli_transactive import register as register_transactive_commands
from .config import get_settings
from .db import Database
from .embeddings import EmbeddingService
@@ -38,6 +39,9 @@ def json_dump(value: object) -> str:
return json.dumps(value, ensure_ascii=False, indent=2)
register_transactive_commands(app, lambda: get_settings())
@app.command()
def serve(host: str = "127.0.0.1", port: int = 8765) -> None:
uvicorn.run(create_app(), host=host, port=port)
@@ -202,7 +206,7 @@ def unmount(cartridge_id: str) -> None:
@app.command()
def append(
cartridge_id: str,
atom_id: list[str] = typer.Option(..., "--atom-id"), # noqa: B008 # typer reads option defaults itself
atom_id: list[str] = typer.Option(..., "--atom-id"),
) -> None:
"""Add memories to an existing cartridge; the bundle and its DNA follow."""
service = CartridgeService(get_settings())
@@ -352,7 +356,7 @@ def build_visual_training_data(
prd_path: Path = Path("PRD.md"),
output_path: Path | None = None,
cartridge_id: str | None = None,
image_path: list[Path] | None = typer.Option(None, "--image-path"), # noqa: B008 # typer reads option defaults itself
image_path: list[Path] | None = typer.Option(None, "--image-path"),
) -> None:
path = TrainingService(get_settings()).build_visual_dataset(
prd_path=prd_path,