leopold-driver 0.1.0 → 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.
- package/README.md +19 -5
- package/assets/VERSION +1 -0
- package/assets/extensions/README.md +52 -0
- package/assets/extensions/gstack/extension.json +8 -0
- package/assets/extensions/gstack/manage.sh +68 -0
- package/assets/extensions/leopold/extension.json +8 -0
- package/assets/extensions/leopold/manage.sh +59 -0
- package/assets/extensions/ovmem/README.md +101 -0
- package/assets/extensions/ovmem/extension.json +8 -0
- package/assets/extensions/ovmem/install.sh +330 -0
- package/assets/extensions/ovmem/manage.sh +87 -0
- package/assets/extensions/ovmem/models.json +24 -0
- package/assets/extensions/ovmem/payload/RUNTIME.md +121 -0
- package/assets/extensions/ovmem/payload/ovmem-cleanup.py +148 -0
- package/assets/extensions/ovmem/payload/ovmem.py +421 -0
- package/assets/extensions/serena/README.md +50 -0
- package/assets/extensions/serena/extension.json +8 -0
- package/assets/extensions/serena/manage.sh +119 -0
- package/assets/hooks/guard-irreversible.sh +185 -0
- package/assets/hooks/hooks.json +20 -0
- package/assets/hooks/stop-continuity.sh +132 -0
- package/assets/install.sh +150 -0
- package/assets/scripts/__pycache__/leopold-watch.cpython-312.pyc +0 -0
- package/assets/scripts/leopold-doctor.sh +53 -0
- package/assets/scripts/leopold-menu.sh +132 -0
- package/assets/scripts/leopold-update-check.sh +23 -0
- package/assets/scripts/leopold-update.sh +13 -0
- package/assets/scripts/leopold-watch.py +585 -0
- package/assets/scripts/record-demo.sh +61 -0
- package/assets/scripts/test-guard.sh +76 -0
- package/assets/scripts/test-hooks.sh +121 -0
- package/assets/settings.template.json +23 -0
- package/assets/skills/leopold-brief/SKILL.md +121 -0
- package/assets/skills/leopold-doctor/SKILL.md +23 -0
- package/assets/skills/leopold-run/SKILL.md +171 -0
- package/assets/skills/leopold-status/SKILL.md +34 -0
- package/assets/skills/leopold-stop/SKILL.md +36 -0
- package/assets/skills/leopold-update/SKILL.md +27 -0
- package/assets/skills/leopold-watch/SKILL.md +48 -0
- package/assets/templates/CHARTER.md +32 -0
- package/assets/templates/DECISIONS.md +15 -0
- package/assets/templates/GUARDRAILS.md +38 -0
- package/assets/templates/MISSION.md +22 -0
- package/assets/templates/PLAN.md +9 -0
- package/dist/guard.js +82 -23
- package/dist/harness.js +71 -0
- package/dist/index.js +53 -23
- package/package.json +18 -6
|
@@ -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
|
+
}
|