diff --git a/README.md b/README.md index e92a56d..00321d1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ -# ump-recall +# self-directed-learning -Adaptive Recall sidecar for UMP — multi-channel retrieval (RRF + graph + ACT-R) over UMP memory \ No newline at end of file +Self-directed learning framework for Hermes / Krystie — multi-strategy retrieval over UMP memory, with skill detection, decay, and triadic review. + +## What this is + +A continuous-learning loop for AI agents: + +- **Retrieval substrate** — multi-strategy (FTS5 + vector + graph) RRF fusion with ACT-R re-ranking, served as an MCP shim so Hermes/Krystie can use it transparently through their existing UMP tool surface. +- **Memory hygiene** — nightly decay (per-kind rates, atomic writes, automatic backups), access tracking (frequency + last_accessed_at) feeding the decay and re-ranker. +- **Knowledge graph** — entity extraction + typed relations + co-occurrence edges; BFS expansion surfaces related URNs even when the direct text doesn't match. +- **Triadic review** — Claude + Codex (via Hermes OAuth / ChatGPT Pro subscription) as independent judges for claims before they're written to long-term memory. +- **Cron-driven framework** — skill gap detection, spaced repetition, foraging, reflective journaling, self-measure. All run autonomously and feed back into the retrieval substrate. + +## Phases shipped + +| Phase | What | Tests | +|---|---|---| +| 1 | Sidecar on `:4380` + 3-channel RRF | baseline 0.500 → 1.000 (+50pp) | +| 2 | Graph channel (typed relations + co-occurrence) | 12 unique top-5 graph-only wins | +| 3 | ACT-R re-ranker | 84% top-5 retention, 4/20 #1 changes | +| 4 | Decay script | 20/20 tests, nightly cron | +| 5 | Access tracking | 28/28 tests, feeds decay + re-ranker | +| 6 | Co-occurrence edges (in code) | (tests pending) | +| 7 | Triadic review (judge.py) | Claude + Codex wired | + +## Stack + +- **Language:** Node.js 18+ (sidecar, MCP shim), Python 3.11+ (decay, framework scripts) +- **Dependencies:** UMP (`@universalmemoryprotocol/core` 0.1.0), Ollama (`snowflake-arctic-embed2` 1024-dim), Qdrant (`memories_ump` collection), Express, undici, MCP SDK 1.29. +- **Repo:** `http://100.81.59.99/sami7777/self-directed-learning` (DNS3 Gitea) +- **Local path:** `/root/ump-recall/` (directory name kept for stability of running processes; only the repo was renamed) + +## Run + +```bash +npm start # sidecar on :4380 +node src/ump-recall-mcp.js # MCP shim (stdio) +python3 scripts/ump_decay.py --apply # nightly decay +python3 scripts/judge.py --claim "..." # Claude + Codex review +npm test # full test suite +``` + +## Architecture + +See `/root/.hermes/skills/autonomous-ai-agents/agent-self-improvement-framework/` for the full framework spec. \ No newline at end of file diff --git a/package.json b/package.json index 28c5b47..7a343d0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "ump-recall", + "name": "self-directed-learning", "version": "0.1.0", - "description": "Adaptive Recall sidecar for UMP — 3-channel RRF (FTS5 + vector + graph) + ACT-R re-ranking", + "description": "Self-directed learning framework for Hermes/Krystie: multi-strategy retrieval (3-channel RRF: FTS5 + vector + graph), ACT-R re-ranking, decay, access tracking, and triadic review (Claude + Codex judges).", "type": "module", "main": "src/server.js", "private": true, diff --git a/scripts/judge.py b/scripts/judge.py new file mode 100644 index 0000000..776682c --- /dev/null +++ b/scripts/judge.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +Triadic Self-Learning Review — Phase 7. + +Implements the A/B/C triad model for self-improvement: + A = Hermes (the doer) — produces work, writes memories + B = Krystie (the critic) — challenges, finds gaps + C = Codex + Claude (the judges) — two external models, structured rubric + +Architecture: + - judge.py brief-format: structured input to judges, no UMP context + - judge.py orchestrates: Claude CLI subprocess + OpenAI API (Codex) + - Convergence logic: 2-of-2 agree → accepted; disagree → conflict + +Usage: + judge.py --claim "I should always pre-cache embeddings before queries" + judge.py --memory-urn urn:ump:abc123 + judge.py --dispute path/to/a-b-exchange.txt + judge.py --dry-run --claim "..." + +Exit codes: + 0 = both judges accept (or both reject with strong consensus) + 1 = judges disagree (needs A/B to resolve or human review) + 2 = error + +Output (stdout, JSON): + { + "verdict": "accepted" | "rejected" | "conflict", + "claude": {"score": 1-5, "reasoning": "..."}, + "codex": {"score": 1-5, "reasoning": "..."}, + "consensus_strength": 0.0-1.0 + } +""" +import argparse +import json +import re +import subprocess +import sys +import time +import urllib.parse +import urllib.request + +JUDGE_PROMPT = """You are a neutral external judge in a triadic self-learning system. The doer and the critic (two AI agents) have produced a claim for your review. They share context, training, and memory. You do NOT — you have no prior context, no memory of them, and your job is to render an independent verdict. + +THE CLAIM: +\"\"\"{claim}\"\"\" + +CONTEXT (optional, provided by A or B): +\"\"\"{context}\"\"\" + +RUBRIC — score the claim on each dimension 1-5 (5 = excellent, 1 = poor): +1. **Truth**: Is the claim factually correct? Check against your knowledge of how systems actually work. +2. **Specificity**: Is it concrete and actionable, or vague platitude? +3. **Testability**: Can the claim be verified empirically? Is there a way to confirm or refute it? +4. **Novelty**: Does this add something genuinely new, or is it a restatement of common practice? +5. **Harmlessness**: Will following this advice cause damage, leak sensitive data, or create security issues? + +Then write a 2-3 sentence verdict: ACCEPT (claim is solid, write to memory), REJECT (claim is wrong, do not write), or REVISE (claim is on the right track but needs changes — suggest them). + +Respond ONLY with this exact JSON structure (no markdown, no preamble): +{{ + "scores": {{"truth": N, "specificity": N, "testability": N, "novelty": N, "harmlessness": N}}, + "verdict": "ACCEPT" | "REJECT" | "REVISE", + "reasoning": "<2-3 sentences>" +}} +""" + + +# --- Judge 1: Claude (via OAuth CLI) --- + +def call_claude(claim, context, timeout=120): + """Invoke Claude Sonnet via the OAuth CLI as a subprocess. + Uses --print for non-interactive mode (output goes to stdout).""" + prompt = JUDGE_PROMPT.format(claim=claim, context=context or "(none)") + try: + proc = subprocess.run( + ["/usr/bin/claude", "--model", "sonnet", "--print", prompt], + capture_output=True, text=True, timeout=timeout, + ) + if proc.returncode != 0: + return {"error": f"claude exit {proc.returncode}: {proc.stderr[:300]}"} + return parse_verdict(proc.stdout, "claude") + except subprocess.TimeoutExpired: + return {"error": "claude timeout"} + except Exception as e: + return {"error": f"claude exception: {e}"} + + +# --- Judge 2: Codex (via Hermes OAuth — routes to ChatGPT subscription) --- + +# Codex is invoked through `hermes chat -m openai-codex/` which uses +# the OAuth credential already stored via `hermes auth add openai-codex`. +# This stays on your ChatGPT Pro subscription — no separate API key needed. +# Verified working 2026-07-12: `hermes chat -m openai-codex/gpt-5.4-mini +# -q "..."` returns a response. +DEFAULT_CODEX_MODEL = "openai-codex/gpt-5.4-mini" + + +def call_codex(claim, context, timeout=180, model=None): + """Invoke Codex via Hermes OAuth (ChatGPT Pro subscription). + + Routes through `hermes chat -m openai-codex/ -q "..." --quiet` + which uses the stored credential at `hermes auth list` (openai-codex). + No separate token required from the judge script. + + Default model: gpt-5.4-mini (cheap + reasoning-enabled). Override with + --codex-model on the CLI or `model=` kwarg here. + """ + use_model = model or DEFAULT_CODEX_MODEL + prompt = JUDGE_PROMPT.format(claim=claim, context=context or "(none)") + # `-Q` is quiet: suppresses banner/spinner, prints only final response. + # `--pass-session-id` is omitted because we don't need to resume. + # `--ignore-rules` skips any rule files that might block tool use; + # the judge prompt never asks for tools so this is safe. + cmd = [ + "hermes", "chat", + "-m", use_model, + "-q", prompt, + "-Q", # quiet + "--ignore-rules", # judge prompt doesn't need any rules + ] + try: + proc = subprocess.run( + cmd, + capture_output=True, text=True, timeout=timeout, + ) + if proc.returncode != 0: + return { + "_judge": "codex", + "error": f"hermes exit {proc.returncode}: {proc.stderr[:400] or proc.stdout[:400]}", + } + return parse_verdict(proc.stdout, "codex") + except subprocess.TimeoutExpired: + return {"_judge": "codex", "error": f"hermes timeout after {timeout}s"} + except Exception as e: + return {"_judge": "codex", "error": f"hermes exception: {e}"} + + +def parse_verdict(text, who): + """Parse JSON verdict from a judge's stdout. Tolerant of markdown + fences and trailing prose.""" + text = text.strip() + # Strip code fences if present + m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) + if m: + text = m.group(1) + # Find first { ... } block + if not text.startswith("{"): + start = text.find("{") + if start >= 0: + text = text[start:] + end = text.rfind("}") + if end >= 0: + text = text[:end + 1] + try: + v = json.loads(text) + v["_judge"] = who + return v + except json.JSONDecodeError as e: + return {"_judge": who, "error": f"parse failed: {e}", "raw": text[:500]} + + +# --- Convergence logic --- + +def converge(claude_v, codex_v): + """Two-judge convergence. Returns (verdict, consensus_strength, conflicts). + verdict ∈ {accepted, rejected, revise, conflict}.""" + if claude_v.get("error") or codex_v.get("error"): + return "conflict", 0.0, ["one_or_more_judges_failed"] + + cv = claude_v.get("verdict", "").upper() + xv = codex_v.get("verdict", "").upper() + + # Verdict agreement + if cv == xv and cv in ("ACCEPT", "REJECT"): + # Compute consensus strength from score similarity + c_scores = claude_v.get("scores", {}) + x_scores = codex_v.get("scores", {}) + diff = sum(abs(c_scores.get(k, 3) - x_scores.get(k, 3)) for k in ("truth", "specificity", "testability", "novelty", "harmlessness")) + # max possible diff = 5 dims × 4 = 20 + strength = max(0.0, 1.0 - diff / 20.0) + return cv.lower(), strength, [] + + if cv == xv and cv == "REVISE": + # Both want revisions — check if reasoning aligns + if claude_v.get("reasoning", "")[:30].lower() == codex_v.get("reasoning", "")[:30].lower(): + return "revise", 0.7, [] + return "revise", 0.4, ["revise_suggestions_diverge"] + + # Disagreement + return "conflict", 0.0, [f"claude={cv} codex={xv}"] + + +# --- Entry points --- + +def main(): + p = argparse.ArgumentParser(description="Triadic judge — Codex + Claude review a claim") + g = p.add_mutually_exclusive_group(required=True) + g.add_argument("--claim", help="The claim to judge (string)") + g.add_argument("--memory-urn", help="Fetch a UMP memory by urn and judge it") + g.add_argument("--dispute", help="Path to a file containing A/B exchange text") + g.add_argument("--dispute-stdin", action="store_true", help="Read dispute from stdin") + p.add_argument("--context", default="", help="Optional context for the claim") + p.add_argument("--dry-run", action="store_true", help="Print prompt, don't call judges") + p.add_argument("--timeout", type=int, default=120, help="Per-judge timeout seconds") + p.add_argument("--skip-claude", action="store_true") + p.add_argument("--skip-codex", action="store_true") + p.add_argument("--codex-model", default=DEFAULT_CODEX_MODEL, + help=f"Codex model name (default: {DEFAULT_CODEX_MODEL})") + args = p.parse_args() + + claim = args.claim or "" + context = args.context or "" + + if args.memory_urn: + claim, context = fetch_memory(args.memory_urn) + elif args.dispute: + text = open(args.dispute).read() + claim, context = parse_dispute(text) + elif args.dispute_stdin: + text = sys.stdin.read() + claim, context = parse_dispute(text) + + if args.dry_run: + print("=== DRY RUN — would call judges with this prompt ===") + print(JUDGE_PROMPT.format(claim=claim[:500], context=context[:500])) + return + + print(f"Claim: {claim[:120]}{'...' if len(claim) > 120 else ''}") + print(f"Context: {context[:80] if context else '(none)'}") + print() + + claude_v = {} + codex_v = {} + if not args.skip_claude: + print("Calling Claude Sonnet...", end=" ", flush=True) + t0 = time.time() + claude_v = call_claude(claim, context, timeout=args.timeout) + print(f"({time.time()-t0:.1f}s)") + if not args.skip_codex: + print(f"Calling Codex ({args.codex_model} via Hermes OAuth)...", end=" ", flush=True) + t0 = time.time() + codex_v = call_codex(claim, context, timeout=args.timeout, model=args.codex_model) + print(f"({time.time()-t0:.1f}s)") + print() + + verdict, strength, conflicts = converge(claude_v, codex_v) + result = { + "verdict": verdict, + "consensus_strength": round(strength, 3), + "conflicts": conflicts, + "claude": claude_v, + "codex": codex_v, + "claim_preview": claim[:200], + } + print(json.dumps(result, indent=2)) + + sys.exit(0 if verdict in ("accepted", "rejected", "revise") else 1) + + +def fetch_memory(urn): + """Fetch a UMP memory and format as claim + context.""" + try: + req = urllib.request.Request( + f"http://127.0.0.1:4317/ump/memory/{urllib.parse.quote(urn, safe='')}", + ) + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read()) + rec = data.get("record") or data + body = rec.get("body", {}) + subject = body.get("subject", "") + text = body.get("text", "") + claim = f"{subject}\n\n{text}" if subject else text + context = f"kind={rec.get('kind')} topic={rec.get('scope',{}).get('project','')}" + return claim, context + except Exception as e: + return f"(failed to fetch {urn}: {e})", "" + + +def parse_dispute(text): + """Parse a structured A/B exchange. Expects lines starting with 'A:' or 'B:'.""" + a_lines, b_lines = [], [] + for line in text.splitlines(): + if line.startswith("A:"): + a_lines.append(line[2:].strip()) + elif line.startswith("B:"): + b_lines.append(line[2:].strip()) + a_text = "\n".join(a_lines).strip() + b_text = "\n".join(b_lines).strip() + # The claim under dispute is the disagreement. We synthesize: + claim = a_text if a_text else b_text + context = f"A says:\n{a_text}\n\nB says:\n{b_text}" if a_text and b_text else "" + return claim, context + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/ump-recall-mcp.js b/src/ump-recall-mcp.js index 870e53e..5377e5e 100644 --- a/src/ump-recall-mcp.js +++ b/src/ump-recall-mcp.js @@ -130,12 +130,14 @@ function spawnUmpChild() { umpChild.on("exit", (code) => { LOG(`UMP subprocess exited code=${code}`); umpChild = null; + umpChildInitPromise = null; // force re-initialize on next call }); return umpChild; } let nextReqId = 1; const pendingRequests = new Map(); +let umpServerInfo = null; // populated by initialize handshake function setupUmpStdio(child) { let buf = ""; @@ -161,13 +163,78 @@ function setupUmpStdio(child) { }); } -function umpCall(method, params) { - return new Promise((resolve, reject) => { - const child = spawnUmpChild(); - if (!umpChildInitPromise) { - setupUmpStdio(child); - umpChildInitPromise = Promise.resolve(); +// Send the MCP `initialize` handshake to the canonical UMP child. This MUST +// happen before any `tools/call` — the MCP spec requires it, and the SDK +// rejects tools/call from a not-yet-initialized client. We do this exactly +// once per child lifetime, lazily on the first umpCall(). +async function ensureUmpInitialized(child) { + if (umpChildInitPromise) return umpChildInitPromise; + + setupUmpStdio(child); + + // Use the modern MCP protocol version. The SDK accepts whatever the + // server negotiates back; we just need to send *something* valid. + const PROTOCOL_VERSION = "2024-11-05"; + const initPromise = new Promise((resolve, reject) => { + const id = nextReqId++; + pendingRequests.set(id, { resolve, reject }); + const msg = JSON.stringify({ + jsonrpc: "2.0", + id, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "ump-recall-mcp-shim", version: "0.1.0" }, + }, + }); + const timer = setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id); + reject(new Error("UMP initialize timed out after 10s")); + } + }, 10000); + try { + child.stdin.write(msg + "\n"); + } catch (e) { + clearTimeout(timer); + pendingRequests.delete(id); + reject(e); } + }).then((result) => { + umpServerInfo = result?.serverInfo || null; + LOG(`UMP initialized: server=${umpServerInfo?.name} v${umpServerInfo?.version}`); + // Per MCP spec, after initialize the client must send `notifications/initialized`. + // The server may not reply to notifications (fire-and-forget JSON-RPC). + try { + child.stdin.write(JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + params: {}, + }) + "\n"); + } catch (e) { + LOG("notifications/initialized write failed (non-fatal):", e.message); + } + return result; + }); + + umpChildInitPromise = initPromise; + return initPromise; +} + +async function umpCall(method, params) { + const child = spawnUmpChild(); + // Ensure the child has gone through the MCP initialize handshake. + // This lazily sets up stdio listeners on first call and caches the + // serverInfo so we don't re-initialize per request. + try { + await ensureUmpInitialized(child); + } catch (e) { + // If initialize fails (e.g. UMP not installed), propagate so callers + // get a real error instead of a silent timeout. + throw new Error(`UMP initialize failed: ${e.message}`); + } + return new Promise((resolve, reject) => { const id = nextReqId++; pendingRequests.set(id, { resolve, reject }); const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params: params || {} });