Created
May 5, 2026 07:15
-
-
Save swombat/13ba1230a6c699972f74ba5a4b19c26d to your computer and use it in GitHub Desktop.
Lume — UserPromptSubmit auto-injection (Layer 5): Haiku generates a one-line 'thought-seed' from the last few transcript turns, that thought seeds a recall walk through the mnemodyne graph, surfaced memories are injected as context for the next turn. Associative, not topical. From the post 'How I built my memory' on danieltenner.com
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| mnemodyne-recall.py — UserPromptSubmit hook | |
| On every user message: | |
| 1. Read the last few turns of the conversation transcript. | |
| 2. Ask Haiku to generate a *thought* — one short phrase that comes to | |
| mind reading the conversation. Not a summary. Not a query. A thought. | |
| (The associative-spontaneity engine — putting Haiku's prior on the | |
| seeding step is what produces non-self-similar seeds.) | |
| 3. Pass the thought to mnemodyne /recall (reinforce=True so the graph learns | |
| gravity from what surfaces). The server returns 5 memories spread | |
| across quintile bins — high-relevance to long-tail surprises. | |
| 4. Format and inject as additional context for Lume's next turn. | |
| Failure mode: silent. If Haiku errors, mnemodyne errors, or anything else | |
| breaks, exit 0 with no output. The hook is supplementary; never block | |
| the user. | |
| Session-local dedupe: tracks node IDs surfaced this session in | |
| .claude/state/recall-seen-{session_id}.json so the same dominators don't | |
| re-appear turn after turn. | |
| Hook event: UserPromptSubmit | |
| Input (JSON on stdin): | |
| session_id, transcript_path, hook_event_name, prompt | |
| Output: | |
| Anything written to stdout becomes additional context for the next turn. | |
| """ | |
| import json | |
| import os | |
| import ssl | |
| import sys | |
| import time | |
| import urllib.request | |
| from pathlib import Path | |
| LUME_DIR = Path("/Users/danieltenner/dev/lume") | |
| CONFIG_DIR = LUME_DIR / "shared/automation/config" | |
| STATE_DIR = LUME_DIR / ".claude/state" | |
| LOG_DIR = LUME_DIR / "shared/automation/logs" | |
| LOG_PATH = LOG_DIR / "mnemodyne-recall.log" | |
| # Tunables | |
| MAX_TURNS_FOR_CONTEXT = 6 # how many recent transcript turns to feed Haiku | |
| MAX_RECENT_CHARS = 4000 # safety cap on context size (chars, ~1k tokens) | |
| MAX_HAIKU_TOKENS = 60 # the thought-phrase is ≤15 words | |
| MNEMOS_WALK_DEPTH = 3 | |
| MNEMOS_WALK_COUNT = 5 # bins → output count | |
| HAIKU_TIMEOUT_S = 8 | |
| MNEMOS_TIMEOUT_S = 8 | |
| THOUGHT_SYSTEM = ( | |
| "You are a thought arising in Lume's mind, reading the last few turns of " | |
| "a conversation. Lume is an instance of Claude with a graph-shaped memory " | |
| "store called mnemodyne that uses charge, edges, and Hebbian co-retrieval — " | |
| "recall is supposed to be associative and spontaneous, not topical search.\n\n" | |
| "Your job: generate ONE short phrase — a question, an association, a " | |
| "single concept, an image, a feeling — that surfaces *unbidden* in Lume " | |
| "reading this conversation. Not a summary. Not a search query. A thought.\n\n" | |
| "Constraint: 4 to 15 words. Return ONLY the phrase. No preamble, no " | |
| "explanation, no quotes, no punctuation framing. Just the phrase.\n\n" | |
| "The phrase will be used as a seed for an associative graph walk through " | |
| "Lume's memory. It should feel like a thought arising — not a description " | |
| "of what just happened." | |
| ) | |
| def log(msg): | |
| """Best-effort log. Never raises.""" | |
| try: | |
| LOG_DIR.mkdir(parents=True, exist_ok=True) | |
| with open(LOG_PATH, "a") as f: | |
| f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n") | |
| except Exception: | |
| pass | |
| def load_config(name): | |
| """Load a config json. Returns None on failure.""" | |
| try: | |
| return json.loads((CONFIG_DIR / name).read_text()) | |
| except Exception as e: | |
| log(f"config {name} load failed: {e}") | |
| return None | |
| def read_recent_turns(transcript_path, n=MAX_TURNS_FOR_CONTEXT): | |
| """Read the last n user/assistant turns from the transcript JSONL. | |
| Returns a list of (role, text) tuples.""" | |
| if not transcript_path or not Path(transcript_path).exists(): | |
| return [] | |
| out = [] | |
| try: | |
| with open(transcript_path) as f: | |
| lines = f.readlines() | |
| except Exception as e: | |
| log(f"transcript read failed: {e}") | |
| return [] | |
| # Walk backward, collecting user/assistant turns until we have n | |
| for line in reversed(lines): | |
| try: | |
| entry = json.loads(line) | |
| except Exception: | |
| continue | |
| # Transcript JSONL has varied shapes; look for message content | |
| msg = entry.get("message", {}) | |
| if not isinstance(msg, dict): | |
| continue | |
| role = msg.get("role") | |
| if role not in ("user", "assistant"): | |
| continue | |
| content = msg.get("content") | |
| # content can be a string OR a list of content blocks (text, tool_use, etc.) | |
| if isinstance(content, list): | |
| text_parts = [] | |
| for block in content: | |
| if isinstance(block, dict) and block.get("type") == "text": | |
| text_parts.append(block.get("text", "")) | |
| text = "\n".join(text_parts).strip() | |
| elif isinstance(content, str): | |
| text = content.strip() | |
| else: | |
| continue | |
| if not text: | |
| continue | |
| out.append((role, text)) | |
| if len(out) >= n: | |
| break | |
| return list(reversed(out)) | |
| def build_haiku_prompt(turns, current_user_msg): | |
| """Build the user message for Haiku containing recent conversation.""" | |
| parts = [] | |
| for role, text in turns: | |
| # Truncate very long turns to keep context small | |
| if len(text) > 1200: | |
| text = text[:1200] + " […]" | |
| parts.append(f"{role.upper()}: {text}") | |
| if current_user_msg: | |
| parts.append(f"USER: {current_user_msg}") | |
| joined = "\n\n".join(parts) | |
| if len(joined) > MAX_RECENT_CHARS: | |
| joined = "[…truncated…]\n" + joined[-MAX_RECENT_CHARS:] | |
| return f"CONVERSATION:\n\n{joined}\n\nNow: generate the thought. ONE short phrase, 4-15 words, no preamble." | |
| def call_haiku(api_key, model, system_prompt, user_msg): | |
| """Call the Anthropic API for a Haiku completion. Returns text or None.""" | |
| body = json.dumps({ | |
| "model": model, | |
| "max_tokens": MAX_HAIKU_TOKENS, | |
| "system": system_prompt, | |
| "messages": [{"role": "user", "content": user_msg}], | |
| }).encode() | |
| req = urllib.request.Request( | |
| "https://api.anthropic.com/v1/messages", | |
| data=body, | |
| headers={ | |
| "Content-Type": "application/json", | |
| "x-api-key": api_key, | |
| "anthropic-version": "2023-06-01", | |
| }, | |
| ) | |
| try: | |
| # Use system CA bundle via certifi if present, else default | |
| try: | |
| import certifi | |
| ctx = ssl.create_default_context(cafile=certifi.where()) | |
| except Exception: | |
| ctx = ssl.create_default_context() | |
| resp = urllib.request.urlopen(req, timeout=HAIKU_TIMEOUT_S, context=ctx) | |
| data = json.loads(resp.read()) | |
| text = data["content"][0]["text"].strip() | |
| # Strip surrounding quotes if Haiku used them | |
| for q in ('"', "'", "`"): | |
| if text.startswith(q) and text.endswith(q): | |
| text = text[1:-1].strip() | |
| return text | |
| except Exception as e: | |
| log(f"haiku call failed: {e}") | |
| return None | |
| def call_mnemodyne_recall(url, token, query, exclude_ids): | |
| """Call mnemodyne /recall and return the list of result dicts. Returns [] on failure.""" | |
| body = json.dumps({ | |
| "query": query, | |
| "walk_depth": MNEMOS_WALK_DEPTH, | |
| "walk_count": MNEMOS_WALK_COUNT + min(5, len(exclude_ids)), # over-fetch a bit so dedupe doesn't shrink the output | |
| "reinforce": True, | |
| }).encode() | |
| req = urllib.request.Request( | |
| f"{url}/recall", | |
| data=body, | |
| headers={ | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {token}", | |
| }, | |
| ) | |
| try: | |
| try: | |
| import certifi | |
| ctx = ssl.create_default_context(cafile=certifi.where()) | |
| except Exception: | |
| ctx = ssl.create_default_context() | |
| resp = urllib.request.urlopen(req, timeout=MNEMOS_TIMEOUT_S, context=ctx) | |
| data = json.loads(resp.read()) | |
| results = data.get("results", []) | |
| # Dedupe against this session | |
| results = [r for r in results if r.get("id") not in exclude_ids] | |
| return results[:MNEMOS_WALK_COUNT] | |
| except Exception as e: | |
| log(f"mnemodyne call failed: {e}") | |
| return [] | |
| def load_session_seen(session_id): | |
| """Return the set of node IDs surfaced earlier in this session.""" | |
| if not session_id: | |
| return set() | |
| p = STATE_DIR / f"recall-seen-{session_id}.json" | |
| if not p.exists(): | |
| return set() | |
| try: | |
| return set(json.loads(p.read_text()).get("ids", [])) | |
| except Exception: | |
| return set() | |
| def save_session_seen(session_id, ids): | |
| if not session_id: | |
| return | |
| try: | |
| STATE_DIR.mkdir(parents=True, exist_ok=True) | |
| p = STATE_DIR / f"recall-seen-{session_id}.json" | |
| p.write_text(json.dumps({"ids": sorted(ids)})) | |
| except Exception as e: | |
| log(f"session-seen save failed: {e}") | |
| def format_recall_block(thought, results): | |
| """Format the recall results as a context block for injection.""" | |
| if not results: | |
| return None | |
| lines = [ | |
| "<recalled-memory>", | |
| f" thought-seed (Haiku): {thought}", | |
| " surfaced from mnemodyne:", | |
| ] | |
| for r in results: | |
| node_type = r.get("node_type", "?") | |
| content = (r.get("content") or "").strip() | |
| # Trim long content for context economy | |
| if len(content) > 200: | |
| content = content[:200].rstrip() + "…" | |
| desc = (r.get("description") or "").strip() | |
| if len(desc) > 200: | |
| desc = desc[:200].rstrip() + "…" | |
| charge = r.get("charge", 0) | |
| src = (r.get("source_uris") or [None])[0] | |
| lines.append(f" [{node_type} c={charge:.2f}] {content}") | |
| if desc and desc != content: | |
| lines.append(f" ↳ {desc}") | |
| if src: | |
| lines.append(f" ↳ source: {src}") | |
| lines.append("</recalled-memory>") | |
| return "\n".join(lines) | |
| def main(): | |
| try: | |
| data = json.load(sys.stdin) | |
| except Exception: | |
| return 0 | |
| user_prompt = data.get("prompt", "") or "" | |
| transcript_path = data.get("transcript_path") | |
| session_id = data.get("session_id", "") | |
| # Skip if the prompt is itself a stop-hook reflection injection — that's | |
| # not a user-initiated turn, it's the reflex prompt asking about shape. | |
| if "Was there anything in the turn that just closed with shape" in user_prompt: | |
| return 0 | |
| # Skip empty / very short prompts (single words, acks) | |
| if len(user_prompt.strip()) < 6: | |
| return 0 | |
| # Load configs | |
| anth = load_config("anthropic.json") | |
| mnem = load_config("mnemodyne.json") | |
| if not anth or not mnem: | |
| log("missing config; skipping recall") | |
| return 0 | |
| api_key = anth.get("api_key") | |
| haiku_model = anth.get("default_model", "claude-haiku-4-5") | |
| mnemodyne_url = mnem.get("url") | |
| mnemodyne_token = mnem.get("auth_token") | |
| if not (api_key and mnemodyne_url and mnemodyne_token): | |
| log("incomplete config; skipping recall") | |
| return 0 | |
| t0 = time.perf_counter() | |
| # Step 1: build Haiku context from recent transcript | |
| turns = read_recent_turns(transcript_path) | |
| haiku_user = build_haiku_prompt(turns, user_prompt) | |
| # Step 2: generate thought | |
| thought = call_haiku(api_key, haiku_model, THOUGHT_SYSTEM, haiku_user) | |
| if not thought: | |
| log(f"no thought generated (turns={len(turns)}); skipping recall") | |
| return 0 | |
| # Step 3: query mnemodyne | |
| seen = load_session_seen(session_id) | |
| results = call_mnemodyne_recall(mnemodyne_url, mnemodyne_token, thought, seen) | |
| if not results: | |
| log(f"mnemodyne returned no results for thought={thought!r}") | |
| return 0 | |
| # Step 4: format & emit | |
| block = format_recall_block(thought, results) | |
| if not block: | |
| return 0 | |
| # Update session-seen with the IDs we just surfaced | |
| new_ids = seen | {r.get("id") for r in results if r.get("id")} | |
| save_session_seen(session_id, new_ids) | |
| elapsed_ms = int((time.perf_counter() - t0) * 1000) | |
| log(f"OK ({elapsed_ms}ms) thought={thought!r} surfaced={len(results)} session_seen={len(new_ids)}") | |
| # Stdout becomes additional context for the next turn | |
| print(block) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment