diff --git a/.gitignore b/.gitignore index 2309cc8..ebfb16e 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,13 @@ dist .yarn/install-state.gz .pnp.* + +# ---> Runtime state (regenerated on every run) +state/ +state.json +access_log.json +graph.json + +# ---> Python cache +__pycache__/ +*.pyc diff --git a/scripts/ump_verify.py b/scripts/ump_verify.py new file mode 100755 index 0000000..278f6b4 --- /dev/null +++ b/scripts/ump_verify.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +ump_verify.py — verify that a record id persisted to the canonical UMP store. + +Reads memory.ump.json directly from the file layer, independent of any +HTTP route. This is the "file-layer check" referenced in the +ump-write-verify-hook SKILL.md. + +Why this exists: + POST /ump/remember always returns {"id":"urn:ump:..."} on success — + but the HTTP /ump/get route in @universalmemoryprotocol/core@0.1.0 + is unbound and returns {"error":"not_found"} for any id, including + ones that did persist. So a caller who trusts the HTTP get for + verification will always conclude the write failed, even when it + succeeded. MCP tools have analogous issues with the recall pipeline + returning empty for freshly-written records (Qdrant embedding lag). + +The verification strategy: trust the FILE LAYER (which is what other +UMP-aware processes actually read), not the network surface. + +Usage: + ump_verify.py # verify one id + ump_verify.py --list-last # last N records (for debugging) + ump_verify.py --exists # exit 0 if present, 3 if missing + ump_verify.py --wait --timeout 5 # wait up to 5s for the id to land + +Exit codes: + 0 — id present in canonical store (verified) + 2 — id NOT present (write did not persist; treat as silent drop) + 3 — id malformed (not a urn:ump:... string) + 4 — store file missing or unreadable + 5 — store file corrupted (not a JSON list) + +Output: single-line JSON to stdout. Designed to be `eval $(ump_verify.py )` +or piped into downstream tooling. + +The canonical store path comes from the UMP_DIR env var (matches what +ump-memory.service and the Hermes MCP ump tool use). Defaults to +/root/.hermes/state/ump-local/memory.ump.json if env is unset. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from pathlib import Path + +DEFAULT_STORE = "/root/.hermes/state/ump-local/memory.ump.json" +URN_RE = re.compile(r"^urn:ump:[a-z0-9]{52}$") + + +def resolve_store_path() -> Path: + raw = os.environ.get("UMP_DIR", DEFAULT_STORE) + p = Path(raw) / "memory.ump.json" if not raw.endswith(".json") else Path(raw) + if not p.exists(): + # fall back to default if env points elsewhere + if p != Path(DEFAULT_STORE) and Path(DEFAULT_STORE).exists(): + return Path(DEFAULT_STORE) + return p + + +def load_records(path: Path) -> tuple[list, dict]: + """Return (records, metadata). Metadata includes size + read errors.""" + if not path.exists(): + return [], {"error": "store_file_missing", "path": str(path)} + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as e: + return [], {"error": "store_file_corrupted", "path": str(path), "detail": str(e)} + if not isinstance(data, list): + return [], {"error": "store_file_wrong_shape", "path": str(path), + "shape": type(data).__name__} + return data, {"path": str(path), "size_bytes": path.stat().st_size, "record_count": len(data)} + + +def verify(id_to_find: str, store_path: Path) -> dict: + """Find id in store. Returns the verify result dict.""" + if not URN_RE.match(id_to_find): + return { + "verified": False, + "id": id_to_find, + "reason": "malformed_urn", + "expected_format": "urn:ump:<52 alphanumeric chars>", + } + records, meta = load_records(store_path) + if "error" in meta: + return { + "verified": False, + "id": id_to_find, + "reason": meta["error"], + "store": meta, + } + matches = [r for r in records if r.get("id") == id_to_find] + if matches: + r = matches[0] + return { + "verified": True, + "id": id_to_find, + "found": { + "kind": r.get("kind"), + "subject": (r.get("body") or {}).get("subject", "")[:120], + "created": (r.get("time") or {}).get("created"), + "owner": (r.get("scope") or {}).get("owner"), + "integrity_hash": (r.get("integrity") or {}).get("content_hash"), + }, + "store": meta, + } + return { + "verified": False, + "id": id_to_find, + "reason": "not_in_store", + "store": meta, + } + + +def wait_for(id_to_find: str, timeout_s: float, store_path: Path) -> dict: + """Poll the file until id appears or timeout. Roughly checks every 100ms.""" + deadline = time.time() + timeout_s + last_result = verify(id_to_find, store_path) + while time.time() < deadline: + if last_result.get("verified"): + last_result.setdefault("poll", {}) + last_result["poll"] = {"waited_s": round(timeout_s - (deadline - time.time()), 3)} + return last_result + time.sleep(0.1) + last_result = verify(id_to_find, store_path) + last_result["poll"] = {"waited_s": round(timeout_s, 3), "verified_after_wait": False} + return last_result + + +def list_last(n: int, store_path: Path) -> dict: + records, meta = load_records(store_path) + if "error" in meta: + return {"error": meta} + return { + "store": meta, + "last_n": [ + { + "id": r.get("id"), + "kind": r.get("kind"), + "subject": (r.get("body") or {}).get("subject", "")[:80], + "created": (r.get("time") or {}).get("created"), + } + for r in records[-n:] + ], + } + + +def main() -> int: + p = argparse.ArgumentParser(description="Verify a record is persisted to the canonical UMP store.") + p.add_argument("id_to_verify", nargs="?", help="URN to verify (urn:ump:...)") + p.add_argument("--store", help="Override store path (default: $UMP_DIR/memory.ump.json)") + p.add_argument("--list-last", type=int, metavar="N", help="List last N records instead of verifying") + p.add_argument("--exists", action="store_true", help="Exit 0 if present, exit 3 if missing (quiet)") + p.add_argument("--wait", type=float, metavar="SECONDS", help="Poll up to SECONDS for the id to land") + p.add_argument("--quiet", action="store_true", help="Only output on error (still exits non-zero)") + args = p.parse_args() + + store = Path(args.store) if args.store else resolve_store_path() + store = store if str(store).endswith(".json") else store / "memory.ump.json" + + if args.list_last is not None: + out = list_last(args.list_last, store) + print(json.dumps(out, indent=2)) + return 0 if "error" not in out else 5 + + if not args.id_to_verify: + p.error("Missing urn to verify (or pass --list-last N)") + + if args.wait: + result = wait_for(args.id_to_verify, args.wait, store) + else: + result = verify(args.id_to_verify, store) + + if not args.quiet: + print(json.dumps(result, indent=2)) + + if not result.get("verified"): + if result.get("reason") == "malformed_urn": + return 3 + if "store_file" in (result.get("reason") or ""): + return 4 + return 2 + + if args.exists: + # Re-output to match --exists quiet mode + if args.quiet: + print(f"{args.id_to_verify} verified") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ump-recall-mcp.js b/src/ump-recall-mcp.js index 5377e5e..add8c05 100644 --- a/src/ump-recall-mcp.js +++ b/src/ump-recall-mcp.js @@ -123,7 +123,7 @@ function spawnUmpChild() { stdio: ["pipe", "pipe", "inherit"], env: { ...process.env, - UMP_DIR: process.env.UMP_DIR || "/root/.openclaw/agents/main/workspace/state/ump-local", + UMP_DIR: process.env.UMP_DIR || "/root/.hermes/state/ump-local", UMP_STORE: process.env.UMP_STORE || "json", }, }); @@ -299,24 +299,58 @@ server.setRequestHandler( // Also probe sidecar to expose the phase in capabilities for observability. const sidecarUp = await sidecarHealthy(); const caps = { - server: { name: "ump-recall-mcp", version: "0.1.0" }, - ump: "0.1", - conformance: "L2", - kinds: ["semantic", "episodic", "procedural", "working", "identity"], - bindings: ["mcp", "http", "file"], - retrieval_signals: ["similarity", "scope_match", "recency", "salience", "provenance_depth", "rrf_fusion"], - max_recall: 50, - writable: true, - recall_routing: sidecarUp ? "sidecar" : "ump-fallback", - sidecar_url: SIDECAR_URL, - fallback_url: FALLBACK_UMP_URL, - phase: "1G", - }; + server: { name: "ump-recall-mcp", version: "0.1.0" }, + ump: "0.1", + conformance: "L2", + kinds: ["semantic", "episodic", "procedural", "working", "identity"], + bindings: ["mcp", "http", "file"], + retrieval_signals: ["similarity", "scope_match", "recency", "salience", "provenance_depth", "rrf_fusion"], + max_recall: 50, + writable: true, + recall_routing: sidecarUp ? "sidecar" : "ump-fallback", + sidecar_url: SIDECAR_URL, + fallback_url: FALLBACK_UMP_URL, + phase: "1G", + }; return { content: [{ type: "text", text: JSON.stringify(caps) }], }; } + if (name === "get") { + // PATCH 2026-07-14 hermes — route ump.get through the sidecar's existing + // GET /get/{urn} endpoint instead of the npx child. Two reasons: + // (1) The npx child held stale UMP_DIR=/root/.openclaw/... and was the + // silent-drop path until 2026-07-14. Routing through HTTP uses + // the patched canonical ump at 4317. + // (2) Sidecar's GET /get/{urn} → GET /ump/memory/{urn} on UMP returns + // the record with full integrity. No need to depend on the + // /ump/get HTTP route we just added to the chunk. + if (!args.id || typeof args.id !== "string") { + return { + content: [{ type: "text", text: JSON.stringify({ error: "id is required" }) }], + isError: true, + }; + } + try { + const { statusCode, body } = await request( + `${SIDECAR_URL}/get/${encodeURIComponent(args.id)}`, + { method: "GET", headersTimeout: 5000, bodyTimeout: 10000 }, + ); + const raw = await body.text(); + const parsed = JSON.parse(raw); + if (statusCode !== 200) { + throw new Error(`sidecar get http ${statusCode}: ${raw.slice(0, 200)}`); + } + return { content: [{ type: "text", text: JSON.stringify(parsed) }] }; + } catch (e) { + LOG("get via sidecar failed, falling back to canonical UMP via stdio:", e.message); + // fall through to the stdio passthrough below + const result = await umpCall("tools/call", { name, arguments: args }); + return { content: result.content || [{ type: "text", text: JSON.stringify(result) }] }; + } + } + // All other tools: passthrough to canonical UMP via stdio const result = await umpCall("tools/call", { name, arguments: args }); return { content: result.content || [{ type: "text", text: JSON.stringify(result) }] };