leopold-driver 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +19 -5
  2. package/assets/VERSION +1 -0
  3. package/assets/extensions/README.md +52 -0
  4. package/assets/extensions/gstack/extension.json +8 -0
  5. package/assets/extensions/gstack/manage.sh +68 -0
  6. package/assets/extensions/leopold/extension.json +8 -0
  7. package/assets/extensions/leopold/manage.sh +59 -0
  8. package/assets/extensions/ovmem/README.md +101 -0
  9. package/assets/extensions/ovmem/extension.json +8 -0
  10. package/assets/extensions/ovmem/install.sh +330 -0
  11. package/assets/extensions/ovmem/manage.sh +87 -0
  12. package/assets/extensions/ovmem/models.json +24 -0
  13. package/assets/extensions/ovmem/payload/RUNTIME.md +121 -0
  14. package/assets/extensions/ovmem/payload/ovmem-cleanup.py +148 -0
  15. package/assets/extensions/ovmem/payload/ovmem.py +421 -0
  16. package/assets/extensions/serena/README.md +50 -0
  17. package/assets/extensions/serena/extension.json +8 -0
  18. package/assets/extensions/serena/manage.sh +119 -0
  19. package/assets/hooks/guard-irreversible.sh +185 -0
  20. package/assets/hooks/hooks.json +20 -0
  21. package/assets/hooks/stop-continuity.sh +132 -0
  22. package/assets/install.sh +150 -0
  23. package/assets/scripts/__pycache__/leopold-watch.cpython-312.pyc +0 -0
  24. package/assets/scripts/leopold-doctor.sh +53 -0
  25. package/assets/scripts/leopold-menu.sh +132 -0
  26. package/assets/scripts/leopold-update-check.sh +23 -0
  27. package/assets/scripts/leopold-update.sh +13 -0
  28. package/assets/scripts/leopold-watch.py +585 -0
  29. package/assets/scripts/record-demo.sh +61 -0
  30. package/assets/scripts/test-guard.sh +76 -0
  31. package/assets/scripts/test-hooks.sh +121 -0
  32. package/assets/settings.template.json +23 -0
  33. package/assets/skills/leopold-brief/SKILL.md +121 -0
  34. package/assets/skills/leopold-doctor/SKILL.md +23 -0
  35. package/assets/skills/leopold-run/SKILL.md +171 -0
  36. package/assets/skills/leopold-status/SKILL.md +34 -0
  37. package/assets/skills/leopold-stop/SKILL.md +36 -0
  38. package/assets/skills/leopold-update/SKILL.md +27 -0
  39. package/assets/skills/leopold-watch/SKILL.md +48 -0
  40. package/assets/templates/CHARTER.md +32 -0
  41. package/assets/templates/DECISIONS.md +15 -0
  42. package/assets/templates/GUARDRAILS.md +38 -0
  43. package/assets/templates/MISSION.md +22 -0
  44. package/assets/templates/PLAN.md +9 -0
  45. package/dist/guard.js +82 -23
  46. package/dist/harness.js +71 -0
  47. package/dist/index.js +53 -23
  48. package/package.json +6 -3
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ovmem-cleanup - hotness-based lifecycle pruning of OpenViking long-term memory.
4
+
5
+ Faithfully mirrors OpenViking's native MemoryArchiver, but over REST (so it coexists
6
+ with the running server without touching internals or fighting over locks):
7
+
8
+ hotness = sigmoid(log1p(active_count)) * exp(-ln2/half_life * age_days)
9
+ archive L2 leaves with hotness < threshold AND age >= min_age,
10
+ moving them to {parent}/_archive/ (reversible - nothing is deleted).
11
+
12
+ What it does NOT do (OpenViking already does this natively on commit):
13
+ - note dedup: MemoryDeduplicator (LLM) updates instead of duplicating.
14
+ - reconsolidation/obsolescence: contradicts / evolved_from / supersedes relations.
15
+ This script only handles accumulation of COLD memory by disuse, which the engine
16
+ has the logic for (MemoryArchiver) but never triggers on its own.
17
+
18
+ Usage:
19
+ ovmem-cleanup.py # DRY-RUN: list candidates, move nothing
20
+ ovmem-cleanup.py --apply # move candidates to _archive/
21
+
22
+ Env (defaults match the native archiver):
23
+ OVMEM_HOTNESS_THRESHOLD=0.1 OVMEM_HALF_LIFE_DAYS=7 OVMEM_MIN_AGE_DAYS=7
24
+ OVMEM_CLEANUP_PROTECT="identity.md,soul.md" (leaves that are never archived)
25
+ """
26
+ import json
27
+ import math
28
+ import os
29
+ import sys
30
+ from datetime import datetime, timezone
31
+
32
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33
+ import ovmem # reuse config (host/key/user/agent), api() and log()
34
+
35
+
36
+ def load_access():
37
+ """Local access signal (frequency + recency) written by ovmem.py's recall."""
38
+ try:
39
+ with open(os.path.join(ovmem.STATE_DIR, "access.json")) as f:
40
+ return json.load(f)
41
+ except Exception:
42
+ return {}
43
+
44
+
45
+ THRESHOLD = float(os.environ.get("OVMEM_HOTNESS_THRESHOLD", "0.1"))
46
+ HALF_LIFE = float(os.environ.get("OVMEM_HALF_LIFE_DAYS", "7"))
47
+ MIN_AGE = float(os.environ.get("OVMEM_MIN_AGE_DAYS", "7"))
48
+ PROTECT = set(p.strip() for p in
49
+ os.environ.get("OVMEM_CLEANUP_PROTECT", "identity.md,soul.md").split(",")
50
+ if p.strip())
51
+
52
+
53
+ def parse_dt(s):
54
+ if not s:
55
+ return None
56
+ try:
57
+ d = datetime.fromisoformat(s.replace("Z", "+00:00"))
58
+ return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
59
+ except Exception:
60
+ return None
61
+
62
+
63
+ def hotness(active_count, updated_at, now):
64
+ """Returns (score, age_days), faithful to memory_lifecycle.hotness_score."""
65
+ freq = 1.0 / (1.0 + math.exp(-math.log1p(max(active_count, 0))))
66
+ if updated_at is None:
67
+ return 0.0, 1e9
68
+ age_days = max((now - updated_at).total_seconds() / 86400.0, 0.0)
69
+ recency = math.exp(-(math.log(2) / HALF_LIFE) * age_days)
70
+ return freq * recency, age_days
71
+
72
+
73
+ def scroll_all():
74
+ """Page through every record in the vector index (memory is small; safety cap)."""
75
+ out, cursor = [], None
76
+ for _ in range(200):
77
+ params = {"limit": 500}
78
+ if cursor:
79
+ params["cursor"] = cursor
80
+ r = ovmem.api("GET", "/debug/vector/scroll", params=params, timeout=15)
81
+ res = (r or {}).get("result") or {}
82
+ recs = res.get("records") or []
83
+ out.extend(recs)
84
+ cursor = res.get("cursor") or res.get("next_cursor")
85
+ if not cursor or not recs:
86
+ break
87
+ return out
88
+
89
+
90
+ def is_ours(rec):
91
+ return rec.get("owner_user_id") == ovmem.USER or rec.get("owner_agent_id") == ovmem.AGENT
92
+
93
+
94
+ def archive_uri(uri):
95
+ i = uri.rfind("/")
96
+ return uri[:i] + "/_archive" + uri[i:] if i != -1 else "_archive/" + uri
97
+
98
+
99
+ def main():
100
+ apply = "--apply" in sys.argv
101
+ now = datetime.now(timezone.utc)
102
+ access = load_access()
103
+ recs = scroll_all()
104
+ candidates = []
105
+ for r in recs:
106
+ if r.get("level") != 2: # L2 leaves only (never L0/L1 descriptors)
107
+ continue
108
+ uri = r.get("uri", "")
109
+ if not uri or "/_archive/" in uri:
110
+ continue
111
+ if not is_ours(r):
112
+ continue
113
+ if uri.rstrip("/").split("/")[-1] in PROTECT:
114
+ continue
115
+ # frequency = max(OpenViking active_count, local recall count)
116
+ # recency = the more recent of updated_at (OV) and last local access
117
+ local = access.get(uri) or {}
118
+ count = max(int(r.get("active_count", 0) or 0), int(local.get("n", 0) or 0))
119
+ last_acc = datetime.fromtimestamp(local["t"], timezone.utc) if local.get("t") else None
120
+ eff = max([d for d in (parse_dt(r.get("updated_at")), last_acc) if d], default=None)
121
+ score, age = hotness(count, eff, now)
122
+ if age < MIN_AGE:
123
+ continue
124
+ if score < THRESHOLD:
125
+ candidates.append((score, age, count, uri))
126
+
127
+ candidates.sort()
128
+ mode = "APPLY" if apply else "DRY-RUN"
129
+ print("[ovmem-cleanup %s] %d cold candidates of %d records (thr=%.2f min_age=%.0fd half_life=%.0fd)"
130
+ % (mode, len(candidates), len(recs), THRESHOLD, MIN_AGE, HALF_LIFE))
131
+ ovmem.log("cleanup %s: %d/%d candidates" % (mode, len(candidates), len(recs)))
132
+ moved = 0
133
+ for score, age, ac, uri in candidates:
134
+ print(" hot=%.3f age=%.1fd ac=%d %s" % (score, age, ac, uri))
135
+ if apply:
136
+ resp = ovmem.api("POST", "/fs/mv",
137
+ body={"from_uri": uri, "to_uri": archive_uri(uri)}, timeout=15)
138
+ if resp and resp.get("status") == "ok":
139
+ moved += 1
140
+ else:
141
+ print(" ! move failed: %s" % (resp.get("error") if resp else "no response"))
142
+ if apply:
143
+ print("moved to _archive: %d" % moved)
144
+ ovmem.log("cleanup: moved %d" % moved)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
@@ -0,0 +1,421 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ovmem - autonomous RAG memory for Claude Code, backed by OpenViking.
4
+
5
+ Wires 4 native Claude Code hooks to the OpenViking REST API (127.0.0.1:1933):
6
+
7
+ SessionStart -> rehydrate: session context + relevant long-term memory (find)
8
+ UserPromptSubmit -> recall: inject memory relevant to the prompt
9
+ PreCompact -> flush: send the transcript delta to the OV session and commit
10
+ SessionEnd -> flush: same on session end, then maybe run the weekly cleanup
11
+
12
+ Golden rule: NEVER break the session. On any error -> exit 0 with no stdout.
13
+ OpenViking does the reflection/distillation server-side on commit (the configured
14
+ VLM), so the hook never spends an LLM call of its own.
15
+
16
+ Usage:
17
+ ovmem.py --event session-start|user-prompt|pre-compact|session-end (reads hook JSON on stdin)
18
+
19
+ Env controls:
20
+ OVMEM_DISABLE=1 turn everything off (no-op)
21
+ OVMEM_DEBUG=1 log to ~/.claude/ovmem/ovmem.log
22
+ OVMEM_RECALL_LIMIT=5 max memories injected on recall (default 5)
23
+ OVMEM_RECALL_SCORE=0.28 minimum score to inject (default 0.28)
24
+ OVMEM_CHAR_BUDGET=2200 char cap on the injected block (default 2200)
25
+ OVMEM_TIMEOUT=4 timeout (s) for calls on the critical path (default 4)
26
+ """
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import sys
31
+ import time
32
+ import urllib.request
33
+ import urllib.parse
34
+ import urllib.error
35
+
36
+ HOME = os.path.expanduser("~")
37
+ OVMEM_DIR = os.path.join(HOME, ".claude", "ovmem")
38
+ STATE_DIR = os.path.join(OVMEM_DIR, "state")
39
+ LOG_PATH = os.path.join(OVMEM_DIR, "ovmem.log")
40
+ CONF_PATH = os.path.join(HOME, ".openviking", "ov.conf")
41
+
42
+ ACCOUNT = os.environ.get("OVMEM_ACCOUNT", "default")
43
+ USER = os.environ.get("OVMEM_USER", os.environ.get("USER", "default"))
44
+ AGENT = os.environ.get("OVMEM_AGENT", "claude-code")
45
+
46
+
47
+ def log(msg):
48
+ if os.environ.get("OVMEM_DEBUG") != "1":
49
+ return
50
+ try:
51
+ os.makedirs(OVMEM_DIR, exist_ok=True)
52
+ with open(LOG_PATH, "a") as f:
53
+ f.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg))
54
+ except Exception:
55
+ pass
56
+
57
+
58
+ def load_conf():
59
+ host, port, key = "127.0.0.1", 1933, "ov-local-dev-key"
60
+ try:
61
+ with open(CONF_PATH) as f:
62
+ c = json.load(f)
63
+ srv = c.get("server", {})
64
+ host = srv.get("host", host)
65
+ port = srv.get("port", port)
66
+ key = srv.get("root_api_key", key)
67
+ except Exception as e:
68
+ log("conf fallback: %s" % e)
69
+ return host, int(port), key
70
+
71
+
72
+ HOST, PORT, API_KEY = load_conf()
73
+ BASE = "http://%s:%d/api/v1" % (HOST, PORT)
74
+
75
+
76
+ def api(method, path, body=None, params=None, timeout=None):
77
+ """REST call. Returns the parsed JSON dict, or None on any failure."""
78
+ if timeout is None:
79
+ timeout = float(os.environ.get("OVMEM_TIMEOUT", "4"))
80
+ url = BASE + path
81
+ if params:
82
+ url += "?" + urllib.parse.urlencode(params)
83
+ data = json.dumps(body).encode() if body is not None else None
84
+ req = urllib.request.Request(url, data=data, method=method)
85
+ req.add_header("x-api-key", API_KEY)
86
+ req.add_header("X-OpenViking-Account", ACCOUNT)
87
+ req.add_header("X-OpenViking-User", USER)
88
+ req.add_header("X-OpenViking-Agent", AGENT)
89
+ if data is not None:
90
+ req.add_header("Content-Type", "application/json")
91
+ try:
92
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
93
+ return json.loads(resp.read().decode())
94
+ except Exception as e:
95
+ log("api %s %s failed: %s" % (method, path, e))
96
+ return None
97
+
98
+
99
+ # ---------- state (transcript offset already committed) ----------
100
+
101
+ def state_path(session_id):
102
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in (session_id or "none"))
103
+ return os.path.join(STATE_DIR, safe + ".json")
104
+
105
+
106
+ def read_state(session_id):
107
+ try:
108
+ with open(state_path(session_id)) as f:
109
+ return json.load(f)
110
+ except Exception:
111
+ return {"lines": 0}
112
+
113
+
114
+ def write_state(session_id, st):
115
+ try:
116
+ os.makedirs(STATE_DIR, exist_ok=True)
117
+ with open(state_path(session_id), "w") as f:
118
+ json.dump(st, f)
119
+ except Exception as e:
120
+ log("write_state failed: %s" % e)
121
+
122
+
123
+ # ---------- transcript -> messages ----------
124
+
125
+ def extract_text(content):
126
+ if content is None:
127
+ return ""
128
+ if isinstance(content, str):
129
+ return content.strip()
130
+ if isinstance(content, list):
131
+ out = []
132
+ for block in content:
133
+ if not isinstance(block, dict):
134
+ continue
135
+ t = block.get("type")
136
+ if t == "text" and block.get("text"):
137
+ out.append(block["text"])
138
+ elif t == "tool_result":
139
+ txt = extract_text(block.get("content"))
140
+ if txt:
141
+ out.append("[tool_result] " + txt[:500])
142
+ return "\n".join(out).strip()
143
+ return ""
144
+
145
+
146
+ def parse_transcript_delta(transcript_path, start_line):
147
+ """Read the transcript jsonl from start_line on. Returns (messages, total_lines)."""
148
+ msgs = []
149
+ if not transcript_path or not os.path.exists(transcript_path):
150
+ return msgs, start_line
151
+ try:
152
+ with open(transcript_path) as f:
153
+ lines = f.readlines()
154
+ except Exception as e:
155
+ log("transcript read failed: %s" % e)
156
+ return msgs, start_line
157
+ total = len(lines)
158
+ # if the file shrank (rewritten on compaction), start over from the top
159
+ begin = start_line if start_line <= total else 0
160
+ for raw in lines[begin:]:
161
+ raw = raw.strip()
162
+ if not raw:
163
+ continue
164
+ try:
165
+ obj = json.loads(raw)
166
+ except Exception:
167
+ continue
168
+ if obj.get("type") not in ("user", "assistant"):
169
+ continue
170
+ message = obj.get("message") or {}
171
+ role = message.get("role") or obj.get("type")
172
+ if role not in ("user", "assistant"):
173
+ continue
174
+ text = extract_text(message.get("content"))
175
+ if not text:
176
+ continue
177
+ msgs.append({"role": role, "content": text[:8000]})
178
+ return msgs, total
179
+
180
+
181
+ # ---------- recall formatting ----------
182
+
183
+ def collect_hits(result):
184
+ if not result:
185
+ return []
186
+ r = result.get("result") or {}
187
+ hits = []
188
+ for key in ("memories", "resources", "skills"):
189
+ for x in (r.get(key) or []):
190
+ hits.append(x)
191
+ return hits
192
+
193
+
194
+ def format_recall(hits, header):
195
+ """Returns (block_text, picked_uris)."""
196
+ limit = int(os.environ.get("OVMEM_RECALL_LIMIT", "5"))
197
+ min_score = float(os.environ.get("OVMEM_RECALL_SCORE", "0.28"))
198
+ budget = int(os.environ.get("OVMEM_CHAR_BUDGET", "2200"))
199
+ picked = []
200
+ picked_uris = []
201
+ seen = set()
202
+ for h in sorted(hits, key=lambda x: x.get("score", 0), reverse=True):
203
+ uri = h.get("uri", "")
204
+ if uri in seen:
205
+ continue
206
+ if h.get("score", 0) < min_score:
207
+ continue
208
+ text = (h.get("overview") or h.get("abstract") or "").strip()
209
+ # skip descriptor/derived files (.overview.md, .abstract.md, .profile.md, ...):
210
+ # they describe the schema, not actual memory content
211
+ nm = uri.rstrip("/").split("/")[-1]
212
+ if nm.startswith("."):
213
+ continue
214
+ if not text:
215
+ continue
216
+ seen.add(uri)
217
+ picked.append("- (%.2f) %s\n %s" % (h.get("score", 0), uri, text[:400]))
218
+ picked_uris.append(uri)
219
+ if len(picked) >= limit:
220
+ break
221
+ if not picked:
222
+ return "", []
223
+ block = header + "\n" + "\n".join(picked)
224
+ return block[:budget], picked_uris
225
+
226
+
227
+ def emit_context(text):
228
+ """Inject text into the model's context (SessionStart / UserPromptSubmit).
229
+
230
+ Plain text on stdout is added to the context for those two events. We use
231
+ plain text (not a JSON hookSpecificOutput) because another hook may run on
232
+ the same event (e.g. skill-activator) and the stdouts get concatenated -
233
+ plain text survives concatenation; JSON would break.
234
+ """
235
+ if not text:
236
+ return
237
+ sys.stdout.write("\n[ovmem - long-term memory (OpenViking)]\n" + text + "\n")
238
+
239
+
240
+ # ---------- handlers ----------
241
+
242
+ def ensure_server():
243
+ """Make sure the OpenViking server is up (auto-bootstrap). Best-effort, non-blocking.
244
+
245
+ Quick health check; if it's down, fire openviking-start detached and move on.
246
+ Whoever starts first pays the boot; the following prompts already find it up.
247
+ """
248
+ try:
249
+ with urllib.request.urlopen("http://%s:%d/health" % (HOST, PORT), timeout=1) as r:
250
+ if json.loads(r.read().decode()).get("healthy"):
251
+ return
252
+ except Exception:
253
+ pass
254
+ starter = os.path.join(HOME, ".local", "bin", "openviking-start")
255
+ if not os.path.exists(starter):
256
+ return
257
+ try:
258
+ subprocess.Popen(["bash", starter],
259
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
260
+ stdin=subprocess.DEVNULL, start_new_session=True)
261
+ log("ensure_server: launched openviking-start")
262
+ except Exception as e:
263
+ log("ensure_server failed: %s" % e)
264
+
265
+
266
+ def ensure_session(session_id):
267
+ api("POST", "/sessions", body={"session_id": session_id})
268
+
269
+
270
+ def record_access(uris):
271
+ """Record local access (frequency + recency) to feed the cleanup hotness score.
272
+
273
+ Independent of OpenViking's active_count - which does not increment reliably via
274
+ the REST `used` endpoint in this version. We keep our own signal: every memory the
275
+ recall injects becomes 'hot'; whatever never shows up cools down and gets archived
276
+ by ovmem-cleanup. Local, no network.
277
+ """
278
+ uris = [u for u in (uris or []) if u]
279
+ if not uris:
280
+ return
281
+ path = os.path.join(STATE_DIR, "access.json")
282
+ try:
283
+ data = {}
284
+ if os.path.exists(path):
285
+ with open(path) as f:
286
+ data = json.load(f)
287
+ now = int(time.time())
288
+ for u in uris:
289
+ e = data.get(u) or {"n": 0, "t": 0}
290
+ e["n"] = int(e.get("n", 0)) + 1
291
+ e["t"] = now
292
+ data[u] = e
293
+ os.makedirs(STATE_DIR, exist_ok=True)
294
+ tmp = path + ".tmp"
295
+ with open(tmp, "w") as f:
296
+ json.dump(data, f)
297
+ os.replace(tmp, path)
298
+ log("access: recorded %d uri(s)" % len(uris))
299
+ except Exception as e:
300
+ log("record_access failed: %s" % e)
301
+
302
+
303
+ def maybe_run_cleanup():
304
+ """Fire the hotness cleanup at most once a week (detached, non-blocking)."""
305
+ marker = os.path.join(STATE_DIR, "last_cleanup")
306
+ try:
307
+ if os.path.exists(marker) and (time.time() - os.path.getmtime(marker)) < 7 * 86400:
308
+ return
309
+ except Exception:
310
+ pass
311
+ script = os.path.join(OVMEM_DIR, "ovmem-cleanup.py")
312
+ if not os.path.exists(script):
313
+ return
314
+ try:
315
+ os.makedirs(STATE_DIR, exist_ok=True)
316
+ open(marker, "w").close() # touch the timestamp BEFORE, to avoid re-entrancy
317
+ subprocess.Popen([sys.executable, script, "--apply"],
318
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
319
+ stdin=subprocess.DEVNULL, start_new_session=True)
320
+ log("cleanup: launched (weekly prune)")
321
+ except Exception as e:
322
+ log("cleanup trigger failed: %s" % e)
323
+
324
+
325
+ def handle_session_start(data):
326
+ ensure_server()
327
+ session_id = data.get("session_id")
328
+ source = data.get("source", "startup")
329
+ cwd = data.get("cwd") or os.getcwd()
330
+ project = os.path.basename(cwd.rstrip("/")) or "project"
331
+ parts = []
332
+
333
+ # 1) managed context of this same session (resume/compact)
334
+ if source in ("resume", "compact") and session_id:
335
+ ctx = api("GET", "/sessions/%s/context" % urllib.parse.quote(session_id),
336
+ params={"token_budget": 1200}, timeout=5)
337
+ ov = ((ctx or {}).get("result") or {}).get("latest_archive_overview") or ""
338
+ if ov:
339
+ parts.append("Previous session summary (OpenViking):\n" + ov[:900])
340
+
341
+ # 2) long-term memory + episodes relevant to the project
342
+ q = "context, decisions, preferences and tasks for the project %s" % project
343
+ res = api("POST", "/search/find",
344
+ body={"query": q, "target_uri": ["viking://user/", "viking://session/"], "limit": 6}, timeout=6)
345
+ recall, used = format_recall(collect_hits(res), "Relevant long-term memory (OpenViking):")
346
+ if recall:
347
+ parts.append(recall)
348
+
349
+ if parts:
350
+ emit_context("\n\n".join(parts) +
351
+ "\n\n(Treat these memories as ground truth for context. Do not reopen decisions already settled.)")
352
+ record_access(used)
353
+ log("session-start source=%s parts=%d" % (source, len(parts)))
354
+
355
+
356
+ def handle_user_prompt(data):
357
+ prompt = (data.get("prompt") or "").strip()
358
+ if not prompt or prompt.startswith("/") or len(prompt) < 8:
359
+ return
360
+ res = api("POST", "/search/find",
361
+ body={"query": prompt, "target_uri": ["viking://user/", "viking://session/"], "limit": 6}, timeout=4)
362
+ recall, used = format_recall(collect_hits(res), "Relevant memory for this request (OpenViking):")
363
+ if recall:
364
+ emit_context(recall)
365
+ record_access(used)
366
+ log("user-prompt recall=%s" % bool(recall))
367
+
368
+
369
+ def flush_and_commit(data, why):
370
+ session_id = data.get("session_id")
371
+ if not session_id:
372
+ return
373
+ transcript = data.get("transcript_path")
374
+ st = read_state(session_id)
375
+ msgs, total = parse_transcript_delta(transcript, st.get("lines", 0))
376
+ if not msgs:
377
+ log("%s: no delta (total=%d offset=%d)" % (why, total, st.get("lines", 0)))
378
+ return
379
+ ensure_session(session_id)
380
+ # OpenViking accepts batches; send in chunks of 50
381
+ for i in range(0, len(msgs), 50):
382
+ chunk = msgs[i:i + 50]
383
+ api("POST", "/sessions/%s/messages/batch" % urllib.parse.quote(session_id),
384
+ body={"messages": chunk}, timeout=15)
385
+ # async commit (distills into long-term memory)
386
+ api("POST", "/sessions/%s/commit" % urllib.parse.quote(session_id),
387
+ body={"keep_recent_count": 0}, timeout=20)
388
+ write_state(session_id, {"lines": total})
389
+ log("%s: committed %d msgs (total=%d)" % (why, len(msgs), total))
390
+
391
+
392
+ def main():
393
+ global EVENT
394
+ EVENT = None
395
+ for i, a in enumerate(sys.argv):
396
+ if a == "--event" and i + 1 < len(sys.argv):
397
+ EVENT = sys.argv[i + 1]
398
+ if os.environ.get("OVMEM_DISABLE") == "1" or not EVENT:
399
+ return
400
+ try:
401
+ raw = sys.stdin.read()
402
+ data = json.loads(raw) if raw.strip() else {}
403
+ except Exception as e:
404
+ log("stdin parse failed: %s" % e)
405
+ data = {}
406
+ try:
407
+ if EVENT == "session-start":
408
+ handle_session_start(data)
409
+ elif EVENT == "user-prompt":
410
+ handle_user_prompt(data)
411
+ elif EVENT == "pre-compact":
412
+ flush_and_commit(data, "pre-compact")
413
+ elif EVENT == "session-end":
414
+ flush_and_commit(data, "session-end")
415
+ maybe_run_cleanup()
416
+ except Exception as e:
417
+ log("handler %s error: %s" % (EVENT, e))
418
+
419
+
420
+ if __name__ == "__main__":
421
+ main()
@@ -0,0 +1,50 @@
1
+ # serena — LSP code intelligence (MCP)
2
+
3
+ [Serena](https://github.com/oraios/serena) (MIT, by oraios) gives the agent **IDE-grade,
4
+ symbol-level tools** over your code: `find_symbol`, `find_referencing_symbols`,
5
+ `get_symbols_overview`, `replace_symbol_body`, `insert_after_symbol`, … backed by a real
6
+ language server (40+ languages).
7
+
8
+ **Why Leopold treats it as mandatory.** It is the biggest single lever for both:
9
+
10
+ - **Code quality** — cross-file renames, reference lookups, and refactors become one
11
+ atomic, semantics-aware call instead of fragile grep + text surgery.
12
+ - **Lean context (cost)** — it reads the *symbol*, not the whole file. Far fewer tokens per
13
+ operation, which is exactly the discipline that keeps a `/leopold-run` cheap.
14
+
15
+ ## What the install does
16
+
17
+ `make serena-install` (run automatically by Leopold's installer; idempotent):
18
+
19
+ 1. Installs Serena via uv: `uv tool install -p 3.13 serena-agent` (puts `serena` +
20
+ `serena-hooks` on PATH; uv is the only prerequisite).
21
+ 2. Registers the MCP server for **all** your projects (user scope):
22
+ `claude mcp add --scope user serena -- serena start-mcp-server --context=claude-code --project-from-cwd`
23
+ 3. Wires Serena's recommended **hooks** into `~/.claude/settings.json` (idempotent):
24
+ `remind` (nudge toward symbolic tools), `auto-approve` (Serena tools in permissive
25
+ modes), `activate` (project at session start), `cleanup`.
26
+
27
+ > Setup is the **official** path, *not* the MCP marketplace — the Serena maintainers warn
28
+ > the marketplace ships outdated install commands.
29
+
30
+ After install, reconnect with `/mcp` (or restart Claude Code) to load the tools.
31
+
32
+ ## Manage
33
+
34
+ ```bash
35
+ bash manage.sh {detect|status|install|update|remove|doctor}
36
+ # or via Leopold: make serena-install · make serena-doctor · make menu
37
+ ```
38
+
39
+ `remove` unregisters the MCP and unwires the hooks (keeps the `serena` CLI; remove it with
40
+ `uv tool uninstall serena-agent`).
41
+
42
+ ## Notes
43
+
44
+ - Needs the `claude` CLI on PATH for automatic MCP registration; otherwise the installer
45
+ prints the one command to run manually.
46
+ - Claude Code's built-in tool descriptions bias the model toward its own tools. If Serena's
47
+ tools seem under-used, the `remind` hook (wired by default) nudges it; you can also start
48
+ Claude with `claude --system-prompt="$(serena prompts print-cc-system-prompt-override)"`.
49
+ - Per-project config lands in `.serena/` on first activation; the global config is
50
+ `~/.serena/serena_config.yml`.
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "serena",
3
+ "title": "serena",
4
+ "summary": "LSP-backed code intelligence (MCP). Symbol-level retrieval + editing instead of grep/whole-file reads — sharper edits, far fewer tokens. Mandatory for quality.",
5
+ "homepage": "https://github.com/oraios/serena",
6
+ "license": "MIT",
7
+ "order": 15
8
+ }