ump-recall: Hermes migration + get route fix (2026-07-14)
After moving the canonical ump store from /root/.openclaw/.../state/ump-local
to /root/.hermes/state/ump-local, two related fixes:
1. src/ump-recall-mcp.js (the MCP shim):
- UMP_DIR fallback updated: /root/.openclaw/... -> /root/.hermes/state/ump-local
(parent env always provides UMP_DIR explicitly, but the fallback
was a footgun if anyone unset it.)
- tools/call ump.get now routes to the sidecar's GET /get/{urn}
endpoint via HTTP, with fallback to the stdio child for resilience.
This replaces the silent-drop pattern where the npx child held
a stale UMP_DIR and returned "not_found: no record" for everything.
2. scripts/ump_verify.py (new):
File-layer verifier for ump-write verification. Replaces the
manual SOP with an executable that has deterministic exit codes:
0 verified, 2 silent drop, 3 malformed, 4 no store, 5 corrupted
Reads memory.ump.json directly (independent of any HTTP route),
supports --exists, --wait N, --list-last N.
3. .gitignore: ignore state/ (runtime cache: access_log.json, graph.json)
and __pycache__ (eval python tools).
This commit is contained in:
+10
@@ -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
|
||||
|
||||
Executable
+196
@@ -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 <urn> # verify one id
|
||||
ump_verify.py --list-last <N> # last N records (for debugging)
|
||||
ump_verify.py --exists <urn> # exit 0 if present, 3 if missing
|
||||
ump_verify.py --wait <urn> --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 <urn>)`
|
||||
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())
|
||||
+48
-14
@@ -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) }] };
|
||||
|
||||
Reference in New Issue
Block a user