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.
This commit is contained in:
1 parent
9fe13fffb3
commit
dfc6cd4c28
19 files changed
+1403
-19
No files matched your search
@@ -25,8 +25,10 @@ printf '{"metadata": {}, "weight_map": {"model-00001.safetensors": "0"}}' > "$mo
|
||||
export ELY_EYE_MODEL_DIR="$model_dir"
|
||||
|
||||
api=""
|
||||
wife_api=""
|
||||
cleanup() {
|
||||
if [ -n "$api" ]; then kill "$api" 2>/dev/null || true; fi
|
||||
if [ -n "$wife_api" ]; then kill "$wife_api" 2>/dev/null || true; fi
|
||||
rm -rf "$home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -47,6 +49,27 @@ fi
|
||||
|
||||
(cd "$root/ely-eye-mcp" && go build -o "$home/ely-eye-mcp" ./cmd/ely-eye-mcp)
|
||||
|
||||
# A second principal on the same SQLite file. The server, not the client, decides who a memory
|
||||
# belongs to, so the only way to write as the wife is to ask a server whose principal is the wife.
|
||||
wife_port=$((port + 1))
|
||||
ELY_EYE_DEFAULT_HOLDER=wife "$python_bin" -m ely_eye.cli serve --port "$wife_port" >"$home/wife-api.log" 2>&1 &
|
||||
wife_api=$!
|
||||
ready=0
|
||||
for _ in $(seq 1 80); do
|
||||
if curl -sf "http://127.0.0.1:${wife_port}/api/status" >/dev/null 2>&1; then ready=1; break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "FAIL: the wife's Ely-Eye API did not start"
|
||||
cat "$home/wife-api.log"
|
||||
exit 1
|
||||
fi
|
||||
secret="护照在书房第二个抽屉里"
|
||||
curl -sf -X POST "http://127.0.0.1:${wife_port}/api/memory" -H 'content-type: application/json' \
|
||||
-d "{\"text\":\"${secret}\",\"scenario\":\"home\"}" >/dev/null
|
||||
curl -sf -X POST "http://127.0.0.1:${wife_port}/api/holders/wife" -H 'content-type: application/json' \
|
||||
-d '{"name":"小美","kind":"person","knows_about":["护照"],"contact":{"channel":"signal","address":"+86-555"}}' >/dev/null
|
||||
|
||||
printf 'the pantry keeps flour\n' > "$home/pantry.txt"
|
||||
uploaded="$(curl -sf -X POST "http://127.0.0.1:${port}/api/ingest/upload?cartridge_name=home" \
|
||||
-F "files=@${home}/pantry.txt;type=text/plain")"
|
||||
@@ -56,7 +79,7 @@ if [ -z "$cartridge_id" ] || [ "$cartridge_id" = "None" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$python_bin" - "$home/ely-eye-mcp" "$cartridge_id" "$model_dir" <<'PY'
|
||||
"$python_bin" - "$home/ely-eye-mcp" "$cartridge_id" "$model_dir" "$secret" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
@@ -65,6 +88,7 @@ import sys
|
||||
binary = sys.argv[1]
|
||||
cartridge_id = sys.argv[2]
|
||||
model_dir = pathlib.Path(sys.argv[3])
|
||||
secret = sys.argv[4]
|
||||
proc = subprocess.Popen(
|
||||
[binary, "server"],
|
||||
stdin=subprocess.PIPE,
|
||||
@@ -108,7 +132,8 @@ send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
|
||||
names = [item["name"] for item in call(2, "tools/list", {})["tools"]]
|
||||
for required in ("remember", "supersede_memory", "forget_memory", "mounted_cartridges",
|
||||
"mount_cartridge", "switch_cartridge", "unmount_cartridge", "append_to_cartridge"):
|
||||
"mount_cartridge", "switch_cartridge", "unmount_cartridge", "append_to_cartridge",
|
||||
"who_knows", "ask", "list_holders"):
|
||||
if required not in names:
|
||||
raise SystemExit(f"FAIL: {required} is not registered; tools are {names}")
|
||||
|
||||
@@ -182,5 +207,38 @@ refused = call(3, "tools/call", {"name": "mount_cartridge", "arguments": {"cartr
|
||||
if not refused.get("isError") or "rope" not in refused["content"][0]["text"]:
|
||||
raise SystemExit(f"FAIL: a cartridge built for RoPE factor 4.0 mounted on a factor 2.0 machine: {refused}")
|
||||
|
||||
print("PASS: remember, supersede, search, forget and cartridge mounting work through MCP")
|
||||
# The transactive proof. The asker is this machine's principal; the wife's memory is private to
|
||||
# her, so the directory may say she knows without showing what she knows.
|
||||
holders = tool("list_holders", {})["holders"]
|
||||
if not any(item["holder_id"] == "wife" for item in holders):
|
||||
raise SystemExit(f"FAIL: the registry does not list the wife: {holders}")
|
||||
|
||||
directory = tool("who_knows", {"query": "护照", "as_holder": "local"})["report"]
|
||||
if directory["status"] != "found" or not directory["hits"]:
|
||||
raise SystemExit(f"FAIL: the directory found nobody who knows: {directory}")
|
||||
top = directory["hits"][0]
|
||||
if top["holder_id"] != "wife" or top["contact"].get("channel") != "signal":
|
||||
raise SystemExit(f"FAIL: the directory did not name the wife and how to reach her: {top}")
|
||||
if not any("declared topics" in line for line in top["basis"]):
|
||||
raise SystemExit(f"FAIL: her declared topic did not raise the confidence: {top['basis']}")
|
||||
|
||||
referred = tool("ask", {"question": "护照在哪里", "as_holder": "local", "include_text": True})["answer"]
|
||||
if referred["status"] != "referred" or referred["answered_by"] != "wife":
|
||||
raise SystemExit(f"FAIL: the question was not referred to the wife: {referred}")
|
||||
if referred.get("answer"):
|
||||
raise SystemExit(f"FAIL: a private memory leaked into the referral: {referred['answer']}")
|
||||
if secret in json.dumps(referred, ensure_ascii=False):
|
||||
raise SystemExit(f"FAIL: the referral carried the private text: {referred}")
|
||||
if "wife" not in referred["asked"]:
|
||||
raise SystemExit(f"FAIL: the referral never asked the wife: {referred}")
|
||||
|
||||
mine = tool("ask", {"question": "pantry flour", "as_holder": "local", "include_text": True})["answer"]
|
||||
if mine["status"] != "answered" or not mine["atom_ids"]:
|
||||
raise SystemExit(f"FAIL: the asker's own memory did not answer: {mine}")
|
||||
|
||||
nobody = tool("ask", {"question": "潜水镜", "as_holder": "local"})["answer"]
|
||||
if nobody["status"] != "no_holder_knows" or nobody.get("answer"):
|
||||
raise SystemExit(f"FAIL: nobody knowing was reported as an answer: {nobody}")
|
||||
|
||||
print("PASS: remember, supersede, search, forget, cartridge mounting and the transactive referral work through MCP")
|
||||
PY
|
||||
Reference in new issue
Block a user