Files
Krystie dc5dc94d79 Initial commit: Adaptive Recall sidecar for UMP (Phase 5)
Multi-channel retrieval sidecar over Universal Memory Protocol:
- 3-channel RRF (UMP FTS5 + Qdrant vector + knowledge graph)
- ACT-R re-ranking (Anderson 1983) with access tracking
- Co-occurrence graph edges (Phase 6) for dense traversal
- Memory lifecycle decay (Phase 4) with per-kind confidence
- MCP shim routes recall through sidecar, falls back to canonical UMP

Architecture:
- src/server.js      HTTP sidecar on port 4380
- src/graph.js       2592-node / 111-edge graph from UMP (or +cooccur: 13k+)
- src/actr.js        A_i = -d*ln(age) + beta*log1p(freq) + epsilon*conf
- src/access_log.js  per-URN counter + last_accessed_at
- src/ump-recall-mcp.js  MCP shim (recall via sidecar, others passthrough)

Eval results (851-record UMP corpus):
- 2ch RRF over baseline: +50pp recall@10
- 3ch RRF (+graph): +60pp, 12 unique wins
- ACT-R re-rank: 4/20 #1 changes, 84% top-5 retention

Tests: 76/76 passing across graph (27), actr (27), access_log (28),
decay (20), mcp-shim (sidecar + fallback). Run with: npm test

Inspired by AIAppsAPI/adaptive-recall but built from scratch against
existing DNS2 infrastructure (UMP at :4317, Qdrant at :6333,
Ollama at :11434). No paid SaaS, MIT-licensed.
2026-07-12 19:24:47 -07:00

297 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""
ump-watcher: tail `memory.ump.json` on disk and push new/modified records into
the Qdrant `memories_ump` collection via the ump-recall sidecar.
Why file-tail and not SSE:
- The ump-memory SSE endpoint at /ump/subscribe was confirmed to not emit
events during live writes in our 2026-07-12 testing.
- The on-disk JSON is the canonical store (JsonFileStore flushes per write).
- File-tail is also how we'd backfill 839 historical records — same code path.
Architecture:
- Bootstrap: on startup, scan memory.ump.json, build id→mtime index.
- Steady state: stat() the file every poll_interval (default 5s); if mtime
changed, re-read fully, diff against index, push new/changed records.
- Embed: POST {id, text} to http://127.0.0.1:4380/embed (sidecar handles
Ollama → Qdrant). Idempotent: same urn re-embed gets a new Qdrant pid but
the urn in payload is the canonical key.
- Backfill mode: --backfill flag processes ALL records and exits, useful for
Phase 1D initial bulk ingest.
Configuration via env (matches ump-recall sidecar):
UMP_DIR /root/.openclaw/agents/main/workspace/state/ump-local
UMP_FILE memory.ump.json (default)
SIDECAR_URL http://127.0.0.1:4380
POLL_INTERVAL 5 (seconds)
BATCH_SIZE 16 (concurrent embeds)
LOG_LEVEL info (debug|info|warn|error)
Failure handling:
- If sidecar is down, log warn and continue (next poll will retry).
- If a single record fails to embed, log and skip (don't halt the batch).
- If JSON file is being written (truncated), retry next poll.
Usage:
python3 ump-watcher.py # daemon mode (default)
python3 ump-watcher.py --backfill # one-shot, process all + exit
python3 ump-watcher.py --once # one poll cycle + exit (smoke test)
"""
import argparse
import json
import logging
import os
import signal
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import requests
UMP_DIR = Path(os.getenv("UMP_DIR", "/root/.openclaw/agents/main/workspace/state/ump-local"))
UMP_FILE = os.getenv("UMP_FILE", "memory.ump.json")
SIDECAR_URL = os.getenv("SIDECAR_URL", "http://127.0.0.1:4380").rstrip("/")
POLL_INTERVAL = float(os.getenv("POLL_INTERVAL", "5"))
BATCH_SIZE = int(os.getenv("BATCH_SIZE", "4"))
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "120"))
LOG_LEVEL = os.getenv("LOG_LEVEL", "info").upper()
# State
running = True
ump_path = UMP_DIR / UMP_FILE
state_path = UMP_DIR / ".ump-watcher-state.json" # tracks last seen per urn
def signal_handler(signum, _frame):
global running
log.info("received signal %d, shutting down", signum)
running = False
def setup_logging():
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
return logging.getLogger("ump-watcher")
log = setup_logging()
def load_records(path: Path) -> list[dict]:
"""Read memory.ump.json and return the records list. Robust to mid-write."""
try:
with open(path, "r") as f:
data = json.load(f)
if isinstance(data, list):
return data
log.error("unexpected JSON shape at %s: %s", path, type(data).__name__)
return []
except json.JSONDecodeError as e:
log.warning("JSON decode error at %s (mid-write?): %s", path, e)
return []
except FileNotFoundError:
log.warning("ump store not found at %s", path)
return []
def load_state() -> dict:
"""Load {urn: last_seen_epoch_seconds} map."""
try:
if state_path.exists():
with open(state_path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
log.warning("could not load state at %s: %s", state_path, e)
return {}
def save_state(state: dict):
"""Atomic write of state file."""
tmp = state_path.with_suffix(".json.tmp")
try:
with open(tmp, "w") as f:
json.dump(state, f, indent=2, sort_keys=True)
os.replace(str(tmp), str(state_path))
except OSError as e:
log.warning("could not save state at %s: %s", state_path, e)
def text_for_record(rec: dict) -> str:
"""Build the text string we'll embed. Concatenates subject + topic + body.text
so the embedding captures the semantic content without being just a label.
Truncates to MAX_EMBED_CHARS (default 4000) because very long texts slow
Ollama CPU inference dramatically (>60s for SOUL.md-class documents) and
don't improve embedding quality much past ~2K tokens.
"""
body = rec.get("body") or {}
subject = (body.get("subject") or "").strip()
topic = (body.get("topic") or "").strip()
text = (body.get("text") or "").strip()
parts = []
if subject:
parts.append(subject)
if topic and topic not in subject:
parts.append(f"[{topic}]")
if text:
parts.append(text)
combined = "\n".join(parts) or "(empty record)"
max_chars = int(os.getenv("MAX_EMBED_CHARS", "4000"))
if len(combined) > max_chars:
combined = combined[:max_chars] + "... [truncated]"
return combined
def embed_one(urn: str, text: str, retries: int = 3) -> tuple[str, bool, str]:
"""POST to sidecar /embed. Returns (urn, ok, error_msg)."""
for attempt in range(retries + 1):
try:
r = requests.post(
f"{SIDECAR_URL}/embed",
json={"id": urn, "text": text},
timeout=REQUEST_TIMEOUT,
)
if r.status_code == 200:
body = r.json()
if body.get("status") == "ok":
return urn, True, ""
return urn, False, f"sidecar status={body.get('status')}: {body}"
return urn, False, f"HTTP {r.status_code}: {r.text[:200]}"
except (requests.RequestException, json.JSONDecodeError) as e:
if attempt < retries:
time.sleep(0.5 * (attempt + 1))
continue
return urn, False, f"exception: {e!r}"
def push_batch(records: list[dict]) -> dict:
"""Push a batch of records to the sidecar. Returns summary stats."""
if not records:
return {"pushed": 0, "failed": 0, "errors": []}
started = time.time()
pushed = 0
failed = 0
errors = []
with ThreadPoolExecutor(max_workers=BATCH_SIZE) as pool:
futures = {
pool.submit(embed_one, r["id"], text_for_record(r)): r for r in records
}
for fut in as_completed(futures):
urn, ok, err = fut.result()
if ok:
pushed += 1
else:
failed += 1
errors.append((urn, err))
log.warning("embed failed for %s: %s", urn, err)
elapsed_ms = (time.time() - started) * 1000
log.info(
"batch complete: %d ok, %d failed in %.0fms (%.0fms/record)",
pushed, failed, elapsed_ms, elapsed_ms / max(pushed + failed, 1),
)
return {"pushed": pushed, "failed": failed, "errors": errors}
def diff_records(records: list[dict], state: dict) -> list[dict]:
"""Return records whose urn is new or whose `time.valid_to` is unset
(revised) since last seen. We use record validity as the change signal
because ump.json doesn't carry a per-record mtime."""
out = []
for r in records:
urn = r.get("id")
if not urn or not isinstance(urn, str):
continue
time_obj = r.get("time") or {}
valid_to = time_obj.get("valid_to")
# Use (urn, valid_to) tuple as change marker. valid_to=null means active.
marker = json.dumps({"urn": urn, "valid_to": valid_to}, sort_keys=True)
if state.get(urn) != marker:
out.append(r)
return out
def update_state(records: list[dict], state: dict):
for r in records:
urn = r.get("id")
if not urn:
continue
time_obj = r.get("time") or {}
valid_to = time_obj.get("valid_to")
state[urn] = json.dumps({"urn": urn, "valid_to": valid_to}, sort_keys=True)
def sidecar_healthy() -> bool:
try:
r = requests.get(f"{SIDECAR_URL}/health", timeout=3)
return r.status_code == 200 and r.json().get("status") == "ok"
except requests.RequestException:
return False
def main():
parser = argparse.ArgumentParser(description="Tail ump store → push to ump-recall sidecar")
parser.add_argument("--backfill", action="store_true", help="process all records and exit")
parser.add_argument("--once", action="store_true", help="run one poll cycle and exit")
args = parser.parse_args()
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
log.info("ump-watcher starting: ump_path=%s sidecar=%s poll=%.1fs",
ump_path, SIDECAR_URL, POLL_INTERVAL)
log.info("mode: %s", "backfill" if args.backfill else "once" if args.once else "daemon")
if not sidecar_healthy():
log.error("sidecar at %s is not healthy; aborting", SIDECAR_URL)
sys.exit(1)
state = load_state()
log.info("loaded state: %d urns already tracked", len(state))
if args.backfill:
# Backfill mode: ignore state, push ALL records, exit.
records = load_records(ump_path)
log.info("backfill: %d total records in store", len(records))
result = push_batch(records)
log.info("backfill complete: pushed=%d failed=%d", result["pushed"], result["failed"])
# Don't update state on backfill — let daemon mode track normally from here.
sys.exit(0 if result["failed"] == 0 else 2)
# Daemon / once mode
while running:
records = load_records(ump_path)
if records:
new_or_changed = diff_records(records, state)
if new_or_changed:
log.info("detected %d new/changed records (of %d total)",
len(new_or_changed), len(records))
result = push_batch(new_or_changed)
if result["pushed"] > 0:
update_state(records, state)
save_state(state)
log.info("state updated: %d urns tracked", len(state))
else:
log.debug("no changes (records=%d)", len(records))
if args.once:
break
# Interruptible sleep
slept = 0.0
while running and slept < POLL_INTERVAL:
time.sleep(min(0.5, POLL_INTERVAL - slept))
slept += 0.5
log.info("ump-watcher exiting cleanly")
if __name__ == "__main__":
main()