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.
101 lines
3.6 KiB
Python
101 lines
3.6 KiB
Python
"""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
|