Files
ely-eye/backend/src/ely_eye/cli_transactive.py
T
ZacharyZhang-NY dfc6cd4c28 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.
2026-10-07 17:20:45 -04:00

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