Rename project: ump-recall -> self-directed-learning

- package.json: name + description updated
- README.md: full rewrite framing as Hermes/Krystie self-improvement loop
- scripts/judge.py: triadic review (Claude + Codex via Hermes OAuth) — replaces broken OpenAI API path
- src/ump-recall-mcp.js: MCP initialize handshake fix — pre-init tools/call no longer times out
- New Gitea repo: sami7777/self-directed-learning (old ump-recall retained for reference)
This commit is contained in:
Krystie
2026-07-12 23:13:55 -07:00
parent dc5dc94d79
commit 9d8a90b895
4 changed files with 417 additions and 10 deletions
+45 -2
View File
@@ -1,3 +1,46 @@
# ump-recall # self-directed-learning
Adaptive Recall sidecar for UMP — multi-channel retrieval (RRF + graph + ACT-R) over UMP memory 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.
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "ump-recall", "name": "self-directed-learning",
"version": "0.1.0", "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", "type": "module",
"main": "src/server.js", "main": "src/server.js",
"private": true, "private": true,
+297
View File
@@ -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/<model>` 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/<model> -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()
+73 -6
View File
@@ -130,12 +130,14 @@ function spawnUmpChild() {
umpChild.on("exit", (code) => { umpChild.on("exit", (code) => {
LOG(`UMP subprocess exited code=${code}`); LOG(`UMP subprocess exited code=${code}`);
umpChild = null; umpChild = null;
umpChildInitPromise = null; // force re-initialize on next call
}); });
return umpChild; return umpChild;
} }
let nextReqId = 1; let nextReqId = 1;
const pendingRequests = new Map(); const pendingRequests = new Map();
let umpServerInfo = null; // populated by initialize handshake
function setupUmpStdio(child) { function setupUmpStdio(child) {
let buf = ""; let buf = "";
@@ -161,13 +163,78 @@ function setupUmpStdio(child) {
}); });
} }
function umpCall(method, params) { // Send the MCP `initialize` handshake to the canonical UMP child. This MUST
return new Promise((resolve, reject) => { // happen before any `tools/call` — the MCP spec requires it, and the SDK
const child = spawnUmpChild(); // rejects tools/call from a not-yet-initialized client. We do this exactly
if (!umpChildInitPromise) { // once per child lifetime, lazily on the first umpCall().
setupUmpStdio(child); async function ensureUmpInitialized(child) {
umpChildInitPromise = Promise.resolve(); 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++; const id = nextReqId++;
pendingRequests.set(id, { resolve, reject }); pendingRequests.set(id, { resolve, reject });
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params: params || {} }); const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params: params || {} });