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:
1 parent
181f6bf57e
commit
3d792417cf
20 files changed
+1425
-33
No files matched your search
@@ -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"]
|
||||
@@ -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"] == "钥匙在玄关抽屉"
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user