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:
1 parent
9fe13fffb3
commit
dfc6cd4c28
19 files changed
+1403
-19
No files matched your search
@@ -0,0 +1,100 @@
|
||||
"""CLI face for the directory: register holders, ask who knows, ask and get referred.
|
||||
|
||||
Registered onto the main Typer app so the command names stay flat (`ely-eye who-knows`), while
|
||||
the transactive surface stays in one file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import typer
|
||||
|
||||
from .config import Settings
|
||||
from .db import Database
|
||||
from .holder_store import HolderStore
|
||||
from .schemas import utc_now
|
||||
from .transactive import TransactiveService
|
||||
from .transactive_schemas import Holder, HolderKind
|
||||
|
||||
|
||||
def register(app: typer.Typer, settings: Callable[[], Settings]) -> None:
|
||||
"""Add the holder and referral commands. The caller passes a settings thunk so one patch
|
||||
in a test redirects every command, the same way the cartridge routes do."""
|
||||
|
||||
def store() -> HolderStore:
|
||||
return HolderStore(Database(settings()))
|
||||
|
||||
def asker(as_holder: str) -> str:
|
||||
"""An empty --as-holder means the local principal, the same identity `remember` writes as."""
|
||||
return as_holder or settings().default_holder
|
||||
|
||||
@app.command("holders")
|
||||
def holders() -> None:
|
||||
"""List registered holders: the directory a referral walks."""
|
||||
for entry in store().list_holders():
|
||||
typer.echo(entry.model_dump_json(indent=2))
|
||||
|
||||
@app.command("holder-set")
|
||||
def holder_set(
|
||||
holder_id: str,
|
||||
name: str = "",
|
||||
kind: HolderKind = "person",
|
||||
knows_about: list[str] = typer.Option(None, "--knows-about"),
|
||||
contact: list[str] = typer.Option(None, "--contact", help="key=value, repeatable"),
|
||||
) -> None:
|
||||
"""Create or replace one holder entry."""
|
||||
now = utc_now()
|
||||
entry = Holder(
|
||||
holder_id=holder_id,
|
||||
name=name,
|
||||
kind=kind,
|
||||
knows_about=list(knows_about or []),
|
||||
contact=_pairs(contact),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
typer.echo(store().upsert(entry).model_dump_json(indent=2))
|
||||
|
||||
@app.command("holder-rm")
|
||||
def holder_rm(holder_id: str) -> None:
|
||||
"""Delete a holder. Refuses while that holder still owns memories."""
|
||||
store().delete(holder_id)
|
||||
typer.echo(f'{{"deleted": "{holder_id}"}}')
|
||||
|
||||
@app.command("who-knows")
|
||||
def who_knows(query: str, as_holder: str = "", limit: int = 8) -> None:
|
||||
"""Rank the holders whose memories answer this query."""
|
||||
report = TransactiveService(settings()).who_knows(
|
||||
query, as_holder=asker(as_holder), limit=limit
|
||||
)
|
||||
typer.echo(report.model_dump_json(indent=2))
|
||||
|
||||
@app.command()
|
||||
def ask(
|
||||
question: str,
|
||||
as_holder: str = "",
|
||||
max_hops: int = 3,
|
||||
include_text: bool = typer.Option(
|
||||
False, "--include-text", help="show the memory text when the asker may already read it"
|
||||
),
|
||||
) -> None:
|
||||
"""Ask the store, then whoever knows. An unanswered question says nobody knows."""
|
||||
answer = TransactiveService(settings()).ask(
|
||||
question,
|
||||
as_holder=asker(as_holder),
|
||||
max_hops=max_hops,
|
||||
include_text=include_text,
|
||||
)
|
||||
typer.echo(answer.model_dump_json(indent=2))
|
||||
|
||||
|
||||
def _pairs(values: list[str] | None) -> dict[str, str]:
|
||||
"""Turn repeatable key=value options into a contact map, naming any entry that is not one."""
|
||||
contact: dict[str, str] = {}
|
||||
for value in values or []:
|
||||
key, separator, detail = value.partition("=")
|
||||
if not separator or not key.strip():
|
||||
raise ValueError(f"--contact needs key=value, got {value!r}")
|
||||
contact[key.strip()] = detail
|
||||
return contact
|
||||
Reference in new issue
Block a user