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"]
|
||||
Reference in new issue
Block a user