Give the write path a CLI and an MCP face

remember / supersede / forget now reach the one Python writer from a
terminal, from MCP, and from HTTP. The server, not the caller, decides
who owns a memory.

- cli: remember, supersede, forget. --holder falls back to
  settings.default_holder until the holder registry lands. Timestamps are
  parsed once with one clear message.
- ingestion: import pymupdf as fitz. The deprecated fitz import printed a
  warning to stdout, so every CLI command's JSON answer was unparseable.
- mcp: remember, supersede_memory and forget_memory forward to the Python
  writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite
  stays read-only to the Go side, so the write rules live in one place.
- mcp reads follow schema v2: atoms.time became ingested_at, and reads go
  through v_atoms_current, so a superseded memory stops being findable.
  holder, origin, scenario and event_time now show up in fetch_atom and
  search_atoms.
- scripts/mcp_write_smoke.sh proves the loop end to end: remember, search,
  supersede, forget, then not found.

Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean
on the 10 touched source files with a fresh cache; ruff clean except a
pre-existing B008 at cli.py:292, identical at HEAD.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 15:09:17 -04:00
1 parent 181f6bf57e
commit 3d792417cf
20 files changed
+1425 -33

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
"""The HTTP face stamps provenance itself: a client cannot say who owns a memory."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import ely_eye.api as api_module
from ely_eye.config import Settings
KEYS = "我们的钥匙放在玄关抽屉里"
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
settings = Settings(home=tmp_path, default_holder="wife")
monkeypatch.setattr(api_module, "get_settings", lambda: settings)
return TestClient(api_module.create_app())
def test_post_memory_stamps_the_holder_the_server_knows(client: TestClient) -> None:
response = client.post("/api/memory", json={"text": KEYS, "scenario": "home"})
assert response.status_code == 200
atom = response.json()
assert atom["holder_id"] == "wife"
assert atom["origin"] == "self"
assert atom["scenario"] == "home"
assert atom["ingested_at"], "ingestion time is stamped, never supplied"
def test_a_client_cannot_claim_another_holder(client: TestClient) -> None:
response = client.post(
"/api/memory",
json={"text": KEYS, "holder_id": "attacker", "origin": "third_party", "ingested_at": "2020-01-01T00:00:00+00:00"},
)
assert response.status_code == 200
atom = response.json()
assert atom["holder_id"] == "wife", "the request body has no holder field to forge"
assert atom["origin"] == "self"
def test_an_empty_memory_is_rejected_with_its_reason(client: TestClient) -> None:
response = client.post("/api/memory", json={"text": " "})
assert response.status_code == 422
assert "needs text" in response.json()["detail"]
def test_supersede_then_forget_reports_what_it_removed(client: TestClient) -> None:
old = client.post("/api/memory", json={"text": KEYS, "scenario": "home"}).json()
new = client.post(
"/api/memory/supersede",
json={"atom_id": old["atom_id"], "text": "钥匙现在在书房第二个抽屉", "scenario": "home"},
).json()
assert new["supersedes"] == [old["atom_id"]]
forgotten = client.post("/api/memory/forget", json={"atom_id": old["atom_id"], "reason": "moved"})
assert forgotten.status_code == 200
report = forgotten.json()
assert report["atoms_deleted"] == 1
assert report["atom_ids"] == [old["atom_id"]]
assert report["reason"] == "moved"
def test_a_forget_that_names_nothing_is_rejected(client: TestClient) -> None:
response = client.post("/api/memory/forget", json={"reason": "vague"})
assert response.status_code == 422
assert "exactly one" in response.json()["detail"]
+108
View File
@@ -0,0 +1,108 @@
"""The CLI face of the write path: a person at a terminal can hand a memory to
a named holder, replace it, and take it back, and every answer is JSON."""
from __future__ import annotations
import json
from typing import Any
import pytest
from typer.testing import CliRunner
from ely_eye import cli as cli_module
from ely_eye.cli import app
from ely_eye.config import Settings
from ely_eye.db import Database
runner = CliRunner()
@pytest.fixture()
def db(tmp_path, monkeypatch) -> Database:
settings = Settings(home=tmp_path, default_holder="husband")
monkeypatch.setattr(cli_module, "get_settings", lambda: settings)
return Database(settings)
def run(*args: str) -> Any:
result = runner.invoke(app, list(args))
assert result.exit_code == 0, result.output
return json.loads(result.output)
def test_remember_stamps_the_default_holder_when_none_is_given(db: Database) -> None:
atom = run("remember", "钥匙在玄关抽屉")
assert atom["holder_id"] == "husband"
assert atom["origin"] == "self"
assert atom["source"] == "memory://husband/general"
assert db.atom_count() == 1
def test_remember_carries_the_holder_scenario_and_event_time(db: Database) -> None:
atom = run(
"remember",
"结婚纪念日是五月一日",
"--holder",
"wife",
"--scenario",
"home",
"--event-time",
"2026-05-01T00:00:00+08:00",
"--precision",
"day",
)
assert atom["holder_id"] == "wife"
assert atom["scenario"] == "home"
assert atom["event_time"].startswith("2026-05-01T00:00:00+08:00")
assert atom["event_time_precision"] == "day"
def test_remember_refuses_an_event_time_without_an_offset(db: Database) -> None:
result = runner.invoke(
app,
["remember", "昨天下的雨", "--event-time", "2026-05-01T12:00:00", "--precision", "day"],
)
assert result.exit_code != 0
assert "memory timestamps need an explicit offset" in str(result.exception)
def test_remember_refuses_a_malformed_timestamp(db: Database) -> None:
result = runner.invoke(app, ["remember", "昨天下的雨", "--event-time", "yesterday"])
assert result.exit_code != 0
assert "--event-time 'yesterday' is not an ISO timestamp" in str(result.exception)
def test_supersede_links_the_replacement_and_closes_the_original(db: Database) -> None:
old = run("remember", "钥匙在玄关抽屉", "--holder", "wife")
new = run("supersede", old["atom_id"], "钥匙在客厅茶几", "--holder", "wife")
assert new["supersedes"] == [old["atom_id"]]
assert new["valid_from"] is not None
stored = db.get_atoms([old["atom_id"]])[0]
assert stored.valid_to is not None
assert db.atom_count() == 2
def test_supersede_names_the_missing_atom(db: Database) -> None:
result = runner.invoke(app, ["supersede", "nope:text", "新的说法"])
assert result.exit_code != 0
assert "cannot supersede nope:text" in str(result.exception)
def test_forget_reports_what_it_removed(db: Database) -> None:
atom = run("remember", "钥匙在玄关抽屉", "--holder", "wife")
report = run("forget", "--source-id", atom["source_id"], "--reason", "moved house")
assert report["atoms_deleted"] == 1
assert report["reason"] == "moved house"
assert db.atom_count() == 0
def test_forget_refuses_to_guess_between_two_ids(db: Database) -> None:
result = runner.invoke(app, ["forget", "--atom-id", "a:text", "--source-id", "b"])
assert result.exit_code != 0
assert "exactly one of atom_id or source_id" in str(result.exception)
def test_the_cli_answers_in_json_a_machine_can_read(db: Database) -> None:
result = runner.invoke(app, ["remember", "钥匙在玄关抽屉", "--holder", "wife"])
assert result.exit_code == 0
assert json.loads(result.output)["text"] == "钥匙在玄关抽屉"
+182
View File
@@ -0,0 +1,182 @@
"""remember / supersede / forget are one writer, and forgetting has to reach what it derived."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from ely_eye.config import Settings
from ely_eye.db import Database
from ely_eye.memory import MemoryService
from ely_eye.schemas import (
EvidenceAtom,
MemoryOrigin,
Modality,
SourceRecord,
TimePrecision,
TrustScores,
)
KEYS = "我们的钥匙放在玄关抽屉里"
NOW = datetime(2026, 5, 1, 12, 0, tzinfo=UTC)
@pytest.fixture
def service(tmp_path: Path) -> MemoryService:
return MemoryService(settings=Settings(home=tmp_path))
def _seed(database: Database, atom: EvidenceAtom) -> None:
database.upsert_sources(
[
SourceRecord(
source_id=atom.source_id,
path="/tmp/seed.txt",
kind="text",
mime="text/plain",
sha256="0" * 64,
size_bytes=4,
created_at=NOW,
)
]
)
database.upsert_atoms([atom])
def _seed_atom(atom_id: str, source_id: str) -> EvidenceAtom:
return EvidenceAtom(
atom_id=atom_id,
modality=Modality.text,
source_id=source_id,
source="seed",
ingested_at=NOW,
text=KEYS,
trust=TrustScores(parser=1.0),
token_equivalent=6,
holder_id="wife",
)
def test_remember_stamps_the_holder_and_refuses_an_ownerless_memory(service: MemoryService) -> None:
atom = service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
assert atom.holder_id == "wife"
assert atom.origin is MemoryOrigin.self_
assert atom.source == "memory://wife/home"
assert atom.atom_id.startswith("mem-")
assert atom.ingested_at <= datetime.now(UTC)
assert atom.valid_from is None, "when the statement became true is unknown, not 'now'"
with pytest.raises(ValueError, match="needs text"):
service.remember(" ", holder_id="wife", origin=MemoryOrigin.self_)
with pytest.raises(ValueError, match="needs a holder"):
service.remember(KEYS, holder_id=" ", origin=MemoryOrigin.self_)
def test_the_same_holder_remembering_the_same_sentence_updates_one_memory(service: MemoryService) -> None:
service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
service.remember(KEYS, holder_id="husband", origin=MemoryOrigin.self_, scenario="home")
assert service.db.atom_count() == 2, "two holders holding one sentence is two memories"
def test_an_event_time_without_its_precision_is_refused(service: MemoryService) -> None:
with pytest.raises(ValueError, match="needs its precision"):
service.remember(
"去年三月我们搬到这里",
holder_id="wife",
origin=MemoryOrigin.self_,
event_time=datetime(2025, 3, 1, tzinfo=UTC),
)
def test_supersede_closes_the_old_memory_and_keeps_both_rows(service: MemoryService) -> None:
old = service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
new = service.supersede(
old.atom_id,
text="钥匙现在在书房第二个抽屉",
holder_id="wife",
scenario="home",
event_time=NOW,
event_time_precision=TimePrecision.day,
event_time_source="declared",
)
assert new.supersedes == [old.atom_id]
assert service.db.atom_count() == 2, "the closed memory stays so time travel can answer"
stored_old = service.db.get_atoms([old.atom_id])[0]
assert stored_old.valid_to is not None
with service.db.connect() as connection:
current = {row["atom_id"] for row in connection.execute("SELECT atom_id FROM v_atoms_current")}
assert current == {new.atom_id}
with pytest.raises(ValueError, match="already closed"):
service.supersede(old.atom_id, text="again", holder_id="wife")
with pytest.raises(ValueError, match="no such atom"):
service.supersede("mem-missing:text", text="anything", holder_id="wife")
def test_time_travel_reads_the_memory_that_was_true_then(service: MemoryService) -> None:
old = service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
before = datetime.now(UTC) - timedelta(seconds=1)
new = service.supersede(old.atom_id, text="钥匙现在在书房第二个抽屉", holder_id="wife")
assert {atom.atom_id for atom in service.db.atoms_as_of(before)} == {old.atom_id}
assert {atom.atom_id for atom in service.db.atoms_as_of(datetime.now(UTC) + timedelta(seconds=1))} == {
new.atom_id
}
def test_forget_reaches_the_row_the_index_the_edges_the_cache_and_the_vectors(
service: MemoryService,
) -> None:
atom = _seed_atom("seed-1:text", "seed-1")
_seed(service.db, atom)
service.db.write_evidence_edges(
[{"from_atom_id": "seed-1:text", "to_atom_id": "other", "relation": "cites", "confidence": 1.0}]
)
service.db.log_cache_event(layer="compiler", cache_key="k", event="hit", bytes_estimate=10)
vectors = service.settings.cache_dir / "dense_index.npy"
vectors.write_bytes(b"stale")
(service.settings.cache_dir / "dense_index_atoms.json").write_text("[]", encoding="utf-8")
report = service.forget(atom_id="seed-1:text", reason="the drawer is empty")
assert report.atoms_deleted == 1
assert report.edges_deleted == 1
assert report.cache_entries_cleared == 1
assert report.vector_index_dropped is True
assert service.db.atom_count() == 0
with service.db.connect() as connection:
assert connection.execute("SELECT COUNT(*) FROM atom_fts").fetchone()[0] == 0
assert connection.execute("SELECT COUNT(*) FROM evidence_edges").fetchone()[0] == 0
trace = connection.execute("SELECT layer, event FROM cache_events").fetchall()
assert [(row["layer"], row["event"]) for row in trace] == [("memory", "invalidated")]
assert not vectors.exists()
def test_forget_names_the_cartridge_that_exported_the_source(service: MemoryService) -> None:
atom = _seed_atom("seed-1:text", "seed-1")
_seed(service.db, atom)
cartridge_root = service.settings.cartridge_dir / "cart-1"
cartridge_root.mkdir(parents=True)
(cartridge_root / "sources.json").write_text(
'[{"source_id": "seed-1"}, {"source_id": "elsewhere"}]', encoding="utf-8"
)
service.db.write_cartridge(
cartridge_id="cart-1",
name="one",
root_path=cartridge_root,
manifest_json="{}",
dna="dna",
token_equivalent=6,
created_at=NOW,
)
report = service.forget(source_id="seed-1")
assert report.affected_cartridges == [{"cartridge_id": "cart-1", "contains_source": True}]
def test_forget_refuses_a_request_that_names_nothing(service: MemoryService) -> None:
with pytest.raises(ValueError, match="exactly one"):
service.forget()
with pytest.raises(ValueError, match="exactly one"):
service.forget(atom_id="a", source_id="b")
with pytest.raises(ValueError, match="nothing to forget"):
service.forget(atom_id="nope:text")