leopold-driver 0.4.6 → 0.6.0
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/assets/VERSION +1 -1
- package/assets/extensions/ovmem/extension.json +7 -1
- package/assets/extensions/ovmem/install.sh +1 -0
- package/assets/extensions/ovmem/manage.sh +20 -12
- package/assets/extensions/ovmem/payload/dashboard.py +607 -0
- package/assets/hooks/guard-irreversible.sh +8 -2
- package/assets/scripts/leopold-menu.sh +7 -1
- package/assets/scripts/leopold-watch.py +222 -0
- package/assets/skills/leopold-run/SKILL.md +5 -2
- package/dist/config.js +14 -1
- package/dist/loop.js +22 -1
- package/dist/reaper.js +59 -0
- package/dist/worker.js +1 -1
- package/dist/worktree.js +87 -0
- package/package.json +2 -2
package/assets/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.6.0
|
|
@@ -4,5 +4,11 @@
|
|
|
4
4
|
"summary": "Autonomous RAG long-term memory (OpenViking + 4 hooks). Provider + model picker (OpenAI / AWS Bedrock) with prices.",
|
|
5
5
|
"homepage": "https://github.com/Jonhvmp/leopold",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"order": 30
|
|
7
|
+
"order": 30,
|
|
8
|
+
"dashboard": {
|
|
9
|
+
"label": "Memory",
|
|
10
|
+
"module": "~/.claude/ovmem/dashboard.py",
|
|
11
|
+
"view": "dashboard_view",
|
|
12
|
+
"search": "dashboard_search"
|
|
13
|
+
}
|
|
8
14
|
}
|
|
@@ -293,6 +293,7 @@ fi
|
|
|
293
293
|
say "installing ovmem engine + hooks"
|
|
294
294
|
mkdir -p "$OVMEM_DIR/state"
|
|
295
295
|
cp "$PAYLOAD/ovmem.py" "$OVMEM_DIR/ovmem.py"; cp "$PAYLOAD/ovmem-cleanup.py" "$OVMEM_DIR/ovmem-cleanup.py"
|
|
296
|
+
cp "$PAYLOAD/dashboard.py" "$OVMEM_DIR/dashboard.py" 2>/dev/null || true
|
|
296
297
|
cp "$PAYLOAD/RUNTIME.md" "$OVMEM_DIR/README.md" 2>/dev/null || true
|
|
297
298
|
[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"; cp "$SETTINGS" "$SETTINGS.ovmem.bak"
|
|
298
299
|
SS="python3 $OVMEM_DIR/ovmem.py --event session-start"; UP="python3 $OVMEM_DIR/ovmem.py --event user-prompt"
|
|
@@ -54,34 +54,42 @@ case "${1:-}" in
|
|
|
54
54
|
echo "to purge those too: uv tool uninstall openviking ; rm -rf ~/.openviking"
|
|
55
55
|
;;
|
|
56
56
|
|
|
57
|
+
watch)
|
|
58
|
+
# open the standalone ovmem dashboard (http://127.0.0.1:1934)
|
|
59
|
+
[ -f "$OVMEM_DIR/dashboard.py" ] || { echo "dashboard not installed (run: manage.sh install)" >&2; exit 1; }
|
|
60
|
+
shift 2>/dev/null || true
|
|
61
|
+
exec python3 "$OVMEM_DIR/dashboard.py" "$@"
|
|
62
|
+
;;
|
|
63
|
+
|
|
57
64
|
doctor)
|
|
58
|
-
echo "engine:
|
|
59
|
-
echo "cleanup:
|
|
60
|
-
echo "
|
|
65
|
+
echo "engine: $([ -f "$OVMEM_DIR/ovmem.py" ] && echo "$OVMEM_DIR/ovmem.py" || echo missing)"
|
|
66
|
+
echo "cleanup: $([ -f "$OVMEM_DIR/ovmem-cleanup.py" ] && echo present || echo missing)"
|
|
67
|
+
echo "dashboard: $([ -f "$OVMEM_DIR/dashboard.py" ] && echo "present (127.0.0.1:1934)" || echo missing)"
|
|
68
|
+
echo "server: $(server_up && echo "up (127.0.0.1:1933)" || echo "down")"
|
|
61
69
|
if [ -f "$SETTINGS" ]; then
|
|
62
70
|
local_hooks="$(grep -c 'ovmem.py --event' "$SETTINGS" 2>/dev/null || echo 0)"
|
|
63
|
-
echo "hooks:
|
|
71
|
+
echo "hooks: $local_hooks/4 wired in settings.json"
|
|
64
72
|
else
|
|
65
|
-
echo "hooks:
|
|
73
|
+
echo "hooks: settings.json not found"
|
|
66
74
|
fi
|
|
67
75
|
if [ -f "$HOME/.openviking/ov.conf" ] && command -v jq >/dev/null 2>&1; then
|
|
68
76
|
prov="$(jq -r '.vlm.provider // "?"' "$HOME/.openviking/ov.conf" 2>/dev/null)"
|
|
69
77
|
chat="$(jq -r '.vlm.model // "?"' "$HOME/.openviking/ov.conf" 2>/dev/null)"
|
|
70
78
|
emb="$(jq -r '.embedding.dense.model // "?"' "$HOME/.openviking/ov.conf" 2>/dev/null)"
|
|
71
79
|
lang="$(jq -r '.output_language_override // "auto"' "$HOME/.openviking/ov.conf" 2>/dev/null)"
|
|
72
|
-
echo "provider:
|
|
73
|
-
echo "chat:
|
|
74
|
-
echo "embed:
|
|
75
|
-
echo "ov.conf:
|
|
80
|
+
echo "provider: $prov"
|
|
81
|
+
echo "chat: $chat"
|
|
82
|
+
echo "embed: $emb"
|
|
83
|
+
echo "ov.conf: present (lang=$lang)"
|
|
76
84
|
elif [ -f "$HOME/.openviking/ov.conf" ]; then
|
|
77
|
-
echo "ov.conf:
|
|
85
|
+
echo "ov.conf: present"
|
|
78
86
|
else
|
|
79
|
-
echo "ov.conf:
|
|
87
|
+
echo "ov.conf: missing"
|
|
80
88
|
fi
|
|
81
89
|
;;
|
|
82
90
|
|
|
83
91
|
*)
|
|
84
|
-
echo "usage: manage.sh {detect|status|install|update|remove|doctor}" >&2
|
|
92
|
+
echo "usage: manage.sh {detect|status|install|update|remove|doctor|watch}" >&2
|
|
85
93
|
exit 2
|
|
86
94
|
;;
|
|
87
95
|
esac
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ovmem dashboard - local web panel to observe the RAG memory (OpenViking).
|
|
4
|
+
|
|
5
|
+
Zero dependencies (stdlib only). Serves at http://127.0.0.1:1934:
|
|
6
|
+
/ -> HTML page (auto-refresh)
|
|
7
|
+
/api/stats -> aggregated JSON: server, hooks, hotness, sessions, server-side usage
|
|
8
|
+
/api/search?q= -> live recall (POST /search/find on the OpenViking server)
|
|
9
|
+
|
|
10
|
+
Everything is read-only. SQLite databases are opened mode=ro so they never compete
|
|
11
|
+
with the server.
|
|
12
|
+
|
|
13
|
+
It also exposes a small "dashboard provider" contract so a host dashboard (the
|
|
14
|
+
leopold-watch tab system) can render a Memory tab without a second HTTP server:
|
|
15
|
+
dashboard_view() -> {"cards": [...]} declarative widget view
|
|
16
|
+
dashboard_search(query) -> {"hits": [...]} live recall
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python3 dashboard.py # serve the panel (port 1934, or the next free one)
|
|
20
|
+
python3 dashboard.py --port 9000 # fixed port
|
|
21
|
+
python3 dashboard.py --json # print the aggregate and exit (no server)
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sqlite3
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import urllib.request
|
|
29
|
+
import urllib.parse
|
|
30
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
+
|
|
32
|
+
HOME = os.path.expanduser("~")
|
|
33
|
+
OVMEM_DIR = os.path.join(HOME, ".claude", "ovmem")
|
|
34
|
+
STATE_DIR = os.path.join(OVMEM_DIR, "state")
|
|
35
|
+
LOG_PATH = os.path.join(OVMEM_DIR, "ovmem.log")
|
|
36
|
+
CONF_PATH = os.path.join(HOME, ".openviking", "ov.conf")
|
|
37
|
+
DATA_DIR = os.path.join(HOME, ".openviking", "data")
|
|
38
|
+
AUDIT_DB = os.path.join(DATA_DIR, "_system", "usage_audit", "usage_audit.sqlite3")
|
|
39
|
+
QUEUE_DB = os.path.join(DATA_DIR, "_system", "queue", "queue.db")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------- config ----------
|
|
43
|
+
|
|
44
|
+
def load_conf():
|
|
45
|
+
host, port, key, lang, vlm, emb = "127.0.0.1", 1933, "ov-local-dev-key", "?", "?", "?"
|
|
46
|
+
try:
|
|
47
|
+
with open(CONF_PATH) as f:
|
|
48
|
+
c = json.load(f)
|
|
49
|
+
srv = c.get("server", {})
|
|
50
|
+
host = srv.get("host", host)
|
|
51
|
+
port = int(srv.get("port", port))
|
|
52
|
+
key = srv.get("root_api_key", key)
|
|
53
|
+
lang = c.get("output_language_override", "?")
|
|
54
|
+
vlm = (c.get("vlm") or {}).get("model", "?")
|
|
55
|
+
emb = ((c.get("embedding") or {}).get("dense") or {}).get("model", "?")
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return host, port, key, lang, vlm, emb
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
HOST, PORT, API_KEY, LANG, VLM, EMB = load_conf()
|
|
62
|
+
OV_BASE = "http://%s:%d" % (HOST, PORT)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ov_call(path, body=None, timeout=5):
|
|
66
|
+
url = OV_BASE + path
|
|
67
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
68
|
+
req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
|
|
69
|
+
req.add_header("x-api-key", API_KEY)
|
|
70
|
+
req.add_header("X-OpenViking-Account", "default")
|
|
71
|
+
req.add_header("X-OpenViking-User", os.environ.get("USER", "jonhvmp"))
|
|
72
|
+
req.add_header("X-OpenViking-Agent", "claude-code")
|
|
73
|
+
if data is not None:
|
|
74
|
+
req.add_header("Content-Type", "application/json")
|
|
75
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
76
|
+
return json.loads(resp.read().decode())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------- collectors ----------
|
|
80
|
+
|
|
81
|
+
def server_health():
|
|
82
|
+
out = {"up": False, "version": None, "auth_mode": None, "pid": None}
|
|
83
|
+
try:
|
|
84
|
+
out.update({k: v for k, v in ov_call("/health", timeout=2).items()
|
|
85
|
+
if k in ("version", "auth_mode")})
|
|
86
|
+
out["up"] = True
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
pidf = os.path.join(DATA_DIR, ".openviking.pid")
|
|
90
|
+
try:
|
|
91
|
+
with open(pidf) as f:
|
|
92
|
+
pid = int(f.read().strip())
|
|
93
|
+
os.kill(pid, 0) # does not send a signal, just checks the process exists
|
|
94
|
+
out["pid"] = pid
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def hotness():
|
|
101
|
+
"""Read access.json (local recall signal) -> top memories by frequency/recency."""
|
|
102
|
+
path = os.path.join(STATE_DIR, "access.json")
|
|
103
|
+
out = {"tracked": 0, "total_hits": 0, "last_access": None, "top": []}
|
|
104
|
+
try:
|
|
105
|
+
with open(path) as f:
|
|
106
|
+
d = json.load(f)
|
|
107
|
+
except Exception:
|
|
108
|
+
return out
|
|
109
|
+
out["tracked"] = len(d)
|
|
110
|
+
out["total_hits"] = sum(int(v.get("n", 0)) for v in d.values())
|
|
111
|
+
if d:
|
|
112
|
+
out["last_access"] = max(int(v.get("t", 0)) for v in d.values())
|
|
113
|
+
rows = sorted(d.items(), key=lambda kv: int(kv[1].get("n", 0)), reverse=True)
|
|
114
|
+
for uri, v in rows[:12]:
|
|
115
|
+
out["top"].append({
|
|
116
|
+
"name": uri.rstrip("/").split("/")[-1],
|
|
117
|
+
"uri": uri,
|
|
118
|
+
"n": int(v.get("n", 0)),
|
|
119
|
+
"t": int(v.get("t", 0)),
|
|
120
|
+
})
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def sessions():
|
|
125
|
+
out = {"count": 0, "total_lines": 0, "recent": []}
|
|
126
|
+
try:
|
|
127
|
+
files = [f for f in os.listdir(STATE_DIR)
|
|
128
|
+
if f.endswith(".json") and f != "access.json"]
|
|
129
|
+
except Exception:
|
|
130
|
+
return out
|
|
131
|
+
items = []
|
|
132
|
+
for f in files:
|
|
133
|
+
p = os.path.join(STATE_DIR, f)
|
|
134
|
+
try:
|
|
135
|
+
lines = json.load(open(p)).get("lines", 0)
|
|
136
|
+
items.append((f[:-5], int(lines), os.path.getmtime(p)))
|
|
137
|
+
except Exception:
|
|
138
|
+
continue
|
|
139
|
+
out["count"] = len(items)
|
|
140
|
+
out["total_lines"] = sum(i[1] for i in items)
|
|
141
|
+
for sid, lines, mt in sorted(items, key=lambda x: x[2], reverse=True)[:6]:
|
|
142
|
+
out["recent"].append({"id": sid[:8], "lines": lines, "mtime": int(mt)})
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _conn(db):
|
|
147
|
+
return sqlite3.connect("file:%s?mode=ro" % db, uri=True, timeout=3)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def usage():
|
|
151
|
+
out = {"requests": 0, "by_route": [], "recent": [], "avg_ms": None,
|
|
152
|
+
"tokens": None, "retrievals": None, "errors": 0}
|
|
153
|
+
if not os.path.exists(AUDIT_DB):
|
|
154
|
+
return out
|
|
155
|
+
try:
|
|
156
|
+
c = _conn(AUDIT_DB)
|
|
157
|
+
out["requests"] = c.execute("select count(*) from request_audit").fetchone()[0]
|
|
158
|
+
out["errors"] = c.execute(
|
|
159
|
+
"select count(*) from request_audit where status_code >= 400").fetchone()[0]
|
|
160
|
+
avg = c.execute(
|
|
161
|
+
"select avg(duration_ms) from request_audit where duration_ms is not null"
|
|
162
|
+
).fetchone()[0]
|
|
163
|
+
out["avg_ms"] = round(avg, 1) if avg is not None else None
|
|
164
|
+
for route, n in c.execute(
|
|
165
|
+
"select route, count(*) c from request_audit group by route "
|
|
166
|
+
"order by c desc limit 8"):
|
|
167
|
+
out["by_route"].append({"route": route, "n": n})
|
|
168
|
+
for row in c.execute(
|
|
169
|
+
"select route, status_code, duration_ms, created_at from request_audit "
|
|
170
|
+
"order by id desc limit 12"):
|
|
171
|
+
out["recent"].append({
|
|
172
|
+
"route": row[0], "status": row[1],
|
|
173
|
+
"ms": row[2], "at": str(row[3])[:19],
|
|
174
|
+
})
|
|
175
|
+
# tokens: dynamic sum of any column with 'token' in its name
|
|
176
|
+
try:
|
|
177
|
+
cols = [r[1] for r in c.execute("pragma table_info(usage_token_hourly)")]
|
|
178
|
+
tcols = [x for x in cols if "token" in x.lower()]
|
|
179
|
+
if tcols:
|
|
180
|
+
expr = "+".join("coalesce(sum(%s),0)" % x for x in tcols)
|
|
181
|
+
out["tokens"] = c.execute(
|
|
182
|
+
"select %s from usage_token_hourly" % expr).fetchone()[0]
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
try:
|
|
186
|
+
cols = [r[1] for r in c.execute("pragma table_info(usage_retrieval_hourly)")]
|
|
187
|
+
rcols = [x for x in cols if any(k in x.lower()
|
|
188
|
+
for k in ("count", "retriev", "hits", "queries"))]
|
|
189
|
+
if rcols:
|
|
190
|
+
expr = "+".join("coalesce(sum(%s),0)" % x for x in rcols)
|
|
191
|
+
out["retrievals"] = c.execute(
|
|
192
|
+
"select %s from usage_retrieval_hourly" % expr).fetchone()[0]
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
c.close()
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def hook_log():
|
|
202
|
+
out = {"last_lines": [], "mtime": None}
|
|
203
|
+
try:
|
|
204
|
+
out["mtime"] = int(os.path.getmtime(LOG_PATH))
|
|
205
|
+
with open(LOG_PATH) as f:
|
|
206
|
+
out["last_lines"] = [l.rstrip("\n") for l in f.readlines()[-8:]]
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def collect_stats():
|
|
213
|
+
return {
|
|
214
|
+
"now": int(time.time()),
|
|
215
|
+
"config": {"host": HOST, "port": PORT, "lang": LANG, "vlm": VLM, "embedding": EMB},
|
|
216
|
+
"server": server_health(),
|
|
217
|
+
"hotness": hotness(),
|
|
218
|
+
"sessions": sessions(),
|
|
219
|
+
"usage": usage(),
|
|
220
|
+
"log": hook_log(),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def live_search(q):
|
|
225
|
+
if not q:
|
|
226
|
+
return {"hits": []}
|
|
227
|
+
try:
|
|
228
|
+
r = ov_call("/api/v1/search/find", body={
|
|
229
|
+
"query": q,
|
|
230
|
+
"target_uri": ["viking://user/", "viking://session/"],
|
|
231
|
+
"limit": 8,
|
|
232
|
+
}, timeout=8)
|
|
233
|
+
res = r.get("result", {}) or {}
|
|
234
|
+
hits = []
|
|
235
|
+
for key in ("memories", "resources", "skills"):
|
|
236
|
+
hits += (res.get(key) or [])
|
|
237
|
+
hits.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
238
|
+
return {"hits": [{
|
|
239
|
+
"score": round(h.get("score", 0), 3),
|
|
240
|
+
"uri": h.get("uri", ""),
|
|
241
|
+
"text": (h.get("overview") or h.get("abstract") or "")[:300],
|
|
242
|
+
} for h in hits[:8]]}
|
|
243
|
+
except Exception as e:
|
|
244
|
+
return {"hits": [], "error": str(e)}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------- dashboard provider contract (for a host like leopold-watch) ----------
|
|
248
|
+
|
|
249
|
+
def _fmt_age(now, t):
|
|
250
|
+
if not t:
|
|
251
|
+
return "-"
|
|
252
|
+
d = now - t
|
|
253
|
+
if d < 60:
|
|
254
|
+
return "%ds ago" % d
|
|
255
|
+
if d < 3600:
|
|
256
|
+
return "%dmin ago" % (d // 60)
|
|
257
|
+
if d < 86400:
|
|
258
|
+
return "%dh ago" % (d // 3600)
|
|
259
|
+
return "%dd ago" % (d // 86400)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _tone_age(now, t):
|
|
263
|
+
if not t:
|
|
264
|
+
return "warn"
|
|
265
|
+
return "good" if (now - t) < 86400 else "warn"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def dashboard_view():
|
|
269
|
+
"""Plugin contract: a declarative card/widget view for a host dashboard.
|
|
270
|
+
|
|
271
|
+
The host renders this with its own design system; we only describe data. Widget
|
|
272
|
+
kinds: kpis | bars | table | search | log. tone in good|warn|bad|none.
|
|
273
|
+
"""
|
|
274
|
+
s = collect_stats()
|
|
275
|
+
now = s["now"]
|
|
276
|
+
srv, cfg, h, u, ses, lg = (s["server"], s["config"], s["hotness"],
|
|
277
|
+
s["usage"], s["sessions"], s["log"])
|
|
278
|
+
cards = []
|
|
279
|
+
|
|
280
|
+
cards.append({"title": "OpenViking server", "widgets": [
|
|
281
|
+
{"kind": "kpis", "items": [
|
|
282
|
+
{"label": "status", "value": "online" if srv["up"] else "offline",
|
|
283
|
+
"tone": "good" if srv["up"] else "bad"},
|
|
284
|
+
{"label": "version", "value": srv.get("version") or "-"},
|
|
285
|
+
{"label": "port", "value": cfg["port"]},
|
|
286
|
+
{"label": "pid", "value": srv.get("pid") or "-"},
|
|
287
|
+
{"label": "vlm", "value": cfg["vlm"]},
|
|
288
|
+
{"label": "embedding", "value": cfg["embedding"]},
|
|
289
|
+
]},
|
|
290
|
+
]})
|
|
291
|
+
|
|
292
|
+
maxn = max([x["n"] for x in h["top"]] or [1])
|
|
293
|
+
cards.append({"title": "Recall (local hotness)", "widgets": [
|
|
294
|
+
{"kind": "kpis", "items": [
|
|
295
|
+
{"label": "memories", "value": h["tracked"],
|
|
296
|
+
"tone": "good" if h["tracked"] else "none"},
|
|
297
|
+
{"label": "total hits", "value": h["total_hits"]},
|
|
298
|
+
{"label": "last recall", "value": _fmt_age(now, h["last_access"]),
|
|
299
|
+
"tone": _tone_age(now, h["last_access"])},
|
|
300
|
+
]},
|
|
301
|
+
{"kind": "bars", "items": [
|
|
302
|
+
{"label": x["name"], "value": x["n"], "max": maxn} for x in h["top"][:8]
|
|
303
|
+
]},
|
|
304
|
+
]})
|
|
305
|
+
|
|
306
|
+
cards.append({"title": "Server-side activity", "widgets": [
|
|
307
|
+
{"kind": "kpis", "items": [
|
|
308
|
+
{"label": "requests", "value": u["requests"]},
|
|
309
|
+
{"label": "avg latency",
|
|
310
|
+
"value": ("%sms" % u["avg_ms"]) if u["avg_ms"] is not None else "-"},
|
|
311
|
+
{"label": "errors", "value": u["errors"],
|
|
312
|
+
"tone": "warn" if u["errors"] else "none"},
|
|
313
|
+
{"label": "tokens", "value": u["tokens"] if u["tokens"] is not None else "-"},
|
|
314
|
+
{"label": "retrievals",
|
|
315
|
+
"value": u["retrievals"] if u["retrievals"] is not None else "-"},
|
|
316
|
+
]},
|
|
317
|
+
{"kind": "table", "columns": ["route", "n"],
|
|
318
|
+
"rows": [[r["route"], r["n"]] for r in u["by_route"]]},
|
|
319
|
+
]})
|
|
320
|
+
|
|
321
|
+
cards.append({"title": "Recent requests", "widgets": [
|
|
322
|
+
{"kind": "table", "columns": ["when", "route", "status", "ms"],
|
|
323
|
+
"rows": [[r["at"], r["route"], r["status"], r["ms"] if r["ms"] is not None else "-"]
|
|
324
|
+
for r in u["recent"]]},
|
|
325
|
+
]})
|
|
326
|
+
|
|
327
|
+
cards.append({"title": "Tracked sessions", "widgets": [
|
|
328
|
+
{"kind": "kpis", "items": [
|
|
329
|
+
{"label": "sessions", "value": ses["count"]},
|
|
330
|
+
{"label": "lines committed", "value": ses["total_lines"]},
|
|
331
|
+
]},
|
|
332
|
+
{"kind": "table", "columns": ["id", "lines", "updated"],
|
|
333
|
+
"rows": [[x["id"], x["lines"], _fmt_age(now, x["mtime"])] for x in ses["recent"]]},
|
|
334
|
+
]})
|
|
335
|
+
|
|
336
|
+
cards.append({"title": "Live memory search", "widgets": [
|
|
337
|
+
{"kind": "search", "placeholder": "e.g. ovmem project, user stack, deadline..."},
|
|
338
|
+
]})
|
|
339
|
+
|
|
340
|
+
cards.append({"title": "Hook log", "widgets": [
|
|
341
|
+
{"kind": "log", "lines": lg["last_lines"] or ["no log (OVMEM_DEBUG off)"]},
|
|
342
|
+
]})
|
|
343
|
+
|
|
344
|
+
return {"cards": cards, "ok": srv["up"]}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def dashboard_search(q):
|
|
348
|
+
"""Plugin contract: live recall for the host's search widget."""
|
|
349
|
+
return live_search(q)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------- HTML ----------
|
|
353
|
+
|
|
354
|
+
PAGE = r"""<!doctype html>
|
|
355
|
+
<html lang="en"><head>
|
|
356
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
357
|
+
<title>ovmem dashboard</title>
|
|
358
|
+
<style>
|
|
359
|
+
:root{
|
|
360
|
+
--bg:#0b0d10; --panel:#14181d; --panel2:#1b2027; --line:#262d36;
|
|
361
|
+
--txt:#e6edf3; --dim:#8b97a5; --acc:#5ad1a0; --warn:#e3b341; --bad:#f0746e;
|
|
362
|
+
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
363
|
+
}
|
|
364
|
+
*{box-sizing:border-box}
|
|
365
|
+
body{margin:0;background:var(--bg);color:var(--txt);
|
|
366
|
+
font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
|
367
|
+
header{display:flex;align-items:center;gap:14px;padding:16px 22px;
|
|
368
|
+
border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
|
|
369
|
+
header h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.2px}
|
|
370
|
+
.dot{width:9px;height:9px;border-radius:50%;display:inline-block}
|
|
371
|
+
.up{background:var(--acc);box-shadow:0 0 8px var(--acc)}
|
|
372
|
+
.down{background:var(--bad);box-shadow:0 0 8px var(--bad)}
|
|
373
|
+
.pill{font:12px var(--mono);color:var(--dim);border:1px solid var(--line);
|
|
374
|
+
padding:2px 8px;border-radius:99px}
|
|
375
|
+
.spacer{flex:1}
|
|
376
|
+
#updated{font:11px var(--mono);color:var(--dim)}
|
|
377
|
+
main{padding:18px 22px;display:grid;gap:16px;
|
|
378
|
+
grid-template-columns:repeat(auto-fit,minmax(330px,1fr));max-width:1400px;margin:0 auto}
|
|
379
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;
|
|
380
|
+
padding:16px 18px;min-width:0}
|
|
381
|
+
.card.wide{grid-column:1/-1}
|
|
382
|
+
.card h2{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);
|
|
383
|
+
margin:0 0 12px;font-weight:600}
|
|
384
|
+
.kpis{display:flex;gap:18px;flex-wrap:wrap}
|
|
385
|
+
.kpi .v{font:22px var(--mono);font-weight:600}
|
|
386
|
+
.kpi .l{font-size:11px;color:var(--dim)}
|
|
387
|
+
.v.acc{color:var(--acc)} .v.warn{color:var(--warn)} .v.bad{color:var(--bad)}
|
|
388
|
+
table{width:100%;border-collapse:collapse;font:12px var(--mono)}
|
|
389
|
+
td,th{text-align:left;padding:5px 6px;border-bottom:1px solid var(--line);
|
|
390
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
391
|
+
th{color:var(--dim);font-weight:500} tr:last-child td{border-bottom:none}
|
|
392
|
+
td.num{text-align:right;color:var(--acc)}
|
|
393
|
+
.name{max-width:0;width:100%}
|
|
394
|
+
.bar{height:5px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-top:3px}
|
|
395
|
+
.bar>i{display:block;height:100%;background:var(--acc)}
|
|
396
|
+
.ok{color:var(--acc)} .err{color:var(--bad)} .muted{color:var(--dim)}
|
|
397
|
+
pre{margin:0;font:11px var(--mono);color:var(--dim);white-space:pre-wrap;
|
|
398
|
+
max-height:160px;overflow:auto}
|
|
399
|
+
.search{display:flex;gap:8px;margin-bottom:12px}
|
|
400
|
+
.search input{flex:1;background:var(--panel2);border:1px solid var(--line);color:var(--txt);
|
|
401
|
+
border-radius:8px;padding:8px 12px;font:13px var(--mono)}
|
|
402
|
+
.search button{background:var(--acc);color:#03130c;border:0;border-radius:8px;
|
|
403
|
+
padding:8px 16px;font-weight:600;cursor:pointer}
|
|
404
|
+
.hit{padding:8px 0;border-bottom:1px solid var(--line)}
|
|
405
|
+
.hit:last-child{border:none}
|
|
406
|
+
.hit .top{display:flex;gap:8px;align-items:baseline}
|
|
407
|
+
.hit .sc{font:12px var(--mono);color:var(--acc);font-weight:600}
|
|
408
|
+
.hit .u{font:11px var(--mono);color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
409
|
+
.hit .tx{font-size:12px;color:var(--txt);margin-top:3px;opacity:.85}
|
|
410
|
+
.foot{padding:10px 22px;color:var(--dim);font:11px var(--mono);
|
|
411
|
+
border-top:1px solid var(--line)}
|
|
412
|
+
</style></head>
|
|
413
|
+
<body>
|
|
414
|
+
<header>
|
|
415
|
+
<span id="dot" class="dot down"></span>
|
|
416
|
+
<h1>ovmem · long-term memory</h1>
|
|
417
|
+
<span class="pill" id="srv">openviking ...</span>
|
|
418
|
+
<span class="pill" id="cfg"></span>
|
|
419
|
+
<span class="spacer"></span>
|
|
420
|
+
<span id="updated">connecting...</span>
|
|
421
|
+
</header>
|
|
422
|
+
<main id="grid">
|
|
423
|
+
<section class="card">
|
|
424
|
+
<h2>OpenViking server</h2>
|
|
425
|
+
<div class="kpis" id="k-server"></div>
|
|
426
|
+
</section>
|
|
427
|
+
<section class="card">
|
|
428
|
+
<h2>Recall (local hotness)</h2>
|
|
429
|
+
<div class="kpis" id="k-recall"></div>
|
|
430
|
+
</section>
|
|
431
|
+
<section class="card">
|
|
432
|
+
<h2>Server-side activity</h2>
|
|
433
|
+
<div class="kpis" id="k-usage"></div>
|
|
434
|
+
</section>
|
|
435
|
+
<section class="card wide">
|
|
436
|
+
<h2>Live memory search</h2>
|
|
437
|
+
<div class="search">
|
|
438
|
+
<input id="q" placeholder="e.g. ovmem project, user stack, deadline..." />
|
|
439
|
+
<button id="go">Search</button>
|
|
440
|
+
</div>
|
|
441
|
+
<div id="hits"><span class="muted">type a query and hit Enter</span></div>
|
|
442
|
+
</section>
|
|
443
|
+
<section class="card">
|
|
444
|
+
<h2>Hottest memories</h2>
|
|
445
|
+
<table><tbody id="t-hot"></tbody></table>
|
|
446
|
+
</section>
|
|
447
|
+
<section class="card">
|
|
448
|
+
<h2>Top routes</h2>
|
|
449
|
+
<table><tbody id="t-routes"></tbody></table>
|
|
450
|
+
</section>
|
|
451
|
+
<section class="card wide">
|
|
452
|
+
<h2>Recent requests</h2>
|
|
453
|
+
<table>
|
|
454
|
+
<thead><tr><th>when</th><th>route</th><th>status</th><th class="num">ms</th></tr></thead>
|
|
455
|
+
<tbody id="t-recent"></tbody>
|
|
456
|
+
</table>
|
|
457
|
+
</section>
|
|
458
|
+
<section class="card">
|
|
459
|
+
<h2>Tracked sessions</h2>
|
|
460
|
+
<table>
|
|
461
|
+
<thead><tr><th>id</th><th class="num">lines</th><th>updated</th></tr></thead>
|
|
462
|
+
<tbody id="t-sess"></tbody>
|
|
463
|
+
</table>
|
|
464
|
+
</section>
|
|
465
|
+
<section class="card">
|
|
466
|
+
<h2>Hook log</h2>
|
|
467
|
+
<pre id="log">no log (OVMEM_DEBUG off)</pre>
|
|
468
|
+
</section>
|
|
469
|
+
</main>
|
|
470
|
+
<div class="foot" id="foot"></div>
|
|
471
|
+
<script>
|
|
472
|
+
const $=s=>document.querySelector(s);
|
|
473
|
+
const ago=ts=>{if(!ts)return "-";let d=Math.floor(Date.now()/1000)-ts;
|
|
474
|
+
if(d<60)return d+"s ago";if(d<3600)return Math.floor(d/60)+"min ago";
|
|
475
|
+
if(d<86400)return Math.floor(d/3600)+"h ago";return Math.floor(d/86400)+"d ago";};
|
|
476
|
+
const esc=s=>(s||"").replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c]));
|
|
477
|
+
function kpi(v,l,cls){return `<div class="kpi"><div class="v ${cls||''}">${v}</div><div class="l">${l}</div></div>`;}
|
|
478
|
+
|
|
479
|
+
async function refresh(){
|
|
480
|
+
let s;try{s=await (await fetch('/api/stats')).json();}catch(e){
|
|
481
|
+
$('#updated').textContent='dashboard server offline';return;}
|
|
482
|
+
const srvUp=s.server.up;
|
|
483
|
+
$('#dot').className='dot '+(srvUp?'up':'down');
|
|
484
|
+
$('#srv').textContent='openviking '+(s.server.version?('v'+s.server.version):'down')
|
|
485
|
+
+(s.server.pid?(' · pid '+s.server.pid):'');
|
|
486
|
+
$('#cfg').textContent=`lang:${s.config.lang} · vlm:${s.config.vlm} · emb:${s.config.embedding}`;
|
|
487
|
+
|
|
488
|
+
$('#k-server').innerHTML=
|
|
489
|
+
kpi(srvUp?'online':'offline','status',srvUp?'acc':'bad')
|
|
490
|
+
+kpi(s.config.port,'port')
|
|
491
|
+
+kpi(s.server.auth_mode||'-','auth');
|
|
492
|
+
|
|
493
|
+
const h=s.hotness;
|
|
494
|
+
$('#k-recall').innerHTML=
|
|
495
|
+
kpi(h.tracked,'memories','acc')
|
|
496
|
+
+kpi(h.total_hits,'total hits')
|
|
497
|
+
+kpi(ago(h.last_access),'last recall',(Date.now()/1000-h.last_access<86400)?'acc':'warn');
|
|
498
|
+
|
|
499
|
+
const u=s.usage;
|
|
500
|
+
$('#k-usage').innerHTML=
|
|
501
|
+
kpi(u.requests,'requests','acc')
|
|
502
|
+
+kpi(u.avg_ms!=null?u.avg_ms+'ms':'-','avg latency')
|
|
503
|
+
+kpi(u.errors,'errors 4xx/5xx',u.errors>0?'warn':'')
|
|
504
|
+
+(u.tokens!=null?kpi(u.tokens.toLocaleString(),'tokens (server)'):'');
|
|
505
|
+
|
|
506
|
+
const maxN=Math.max(1,...h.top.map(x=>x.n));
|
|
507
|
+
$('#t-hot').innerHTML=h.top.map(x=>`<tr>
|
|
508
|
+
<td class="name" title="${esc(x.uri)}">${esc(x.name)}
|
|
509
|
+
<div class="bar"><i style="width:${Math.round(100*x.n/maxN)}%"></i></div></td>
|
|
510
|
+
<td class="num">${x.n}</td></tr>`).join('')||'<tr><td class="muted">empty</td></tr>';
|
|
511
|
+
|
|
512
|
+
$('#t-routes').innerHTML=u.by_route.map(r=>`<tr>
|
|
513
|
+
<td class="name">${esc(r.route)}</td><td class="num">${r.n}</td></tr>`).join('')
|
|
514
|
+
||'<tr><td class="muted">empty</td></tr>';
|
|
515
|
+
|
|
516
|
+
$('#t-recent').innerHTML=u.recent.map(r=>`<tr>
|
|
517
|
+
<td>${esc(r.at)}</td><td class="name">${esc(r.route)}</td>
|
|
518
|
+
<td class="${r.status>=400?'err':'ok'}">${r.status}</td>
|
|
519
|
+
<td class="num">${r.ms!=null?r.ms:'-'}</td></tr>`).join('')
|
|
520
|
+
||'<tr><td class="muted">empty</td></tr>';
|
|
521
|
+
|
|
522
|
+
$('#t-sess').innerHTML=s.sessions.recent.map(x=>`<tr>
|
|
523
|
+
<td>${esc(x.id)}</td><td class="num">${x.lines}</td>
|
|
524
|
+
<td class="muted">${ago(x.mtime)}</td></tr>`).join('')
|
|
525
|
+
||'<tr><td class="muted">empty</td></tr>';
|
|
526
|
+
|
|
527
|
+
if(s.log.last_lines.length)$('#log').textContent=s.log.last_lines.join('\n');
|
|
528
|
+
$('#updated').textContent='updated '+new Date().toLocaleTimeString();
|
|
529
|
+
$('#foot').textContent=`${s.sessions.count} sessions · ${s.sessions.total_lines} lines committed · `
|
|
530
|
+
+`retrievals: ${u.retrievals!=null?u.retrievals:'n/a'} · read-only dashboard`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function search(){
|
|
534
|
+
const q=$('#q').value.trim();if(!q)return;
|
|
535
|
+
$('#hits').innerHTML='<span class="muted">searching...</span>';
|
|
536
|
+
let r;try{r=await (await fetch('/api/search?q='+encodeURIComponent(q))).json();}
|
|
537
|
+
catch(e){$('#hits').innerHTML='<span class="err">failed</span>';return;}
|
|
538
|
+
if(r.error){$('#hits').innerHTML='<span class="err">'+esc(r.error)+'</span>';return;}
|
|
539
|
+
if(!r.hits.length){$('#hits').innerHTML='<span class="muted">no results</span>';return;}
|
|
540
|
+
$('#hits').innerHTML=r.hits.map(h=>`<div class="hit">
|
|
541
|
+
<div class="top"><span class="sc">${h.score.toFixed(3)}</span>
|
|
542
|
+
<span class="u" title="${esc(h.uri)}">${esc(h.uri)}</span></div>
|
|
543
|
+
<div class="tx">${esc(h.text)}</div></div>`).join('');
|
|
544
|
+
}
|
|
545
|
+
$('#go').onclick=search;
|
|
546
|
+
$('#q').addEventListener('keydown',e=>{if(e.key==='Enter')search();});
|
|
547
|
+
refresh();setInterval(refresh,5000);
|
|
548
|
+
</script>
|
|
549
|
+
</body></html>"""
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ---------- http server ----------
|
|
553
|
+
|
|
554
|
+
class Handler(BaseHTTPRequestHandler):
|
|
555
|
+
def _send(self, code, body, ctype):
|
|
556
|
+
data = body.encode() if isinstance(body, str) else body
|
|
557
|
+
self.send_response(code)
|
|
558
|
+
self.send_header("Content-Type", ctype)
|
|
559
|
+
self.send_header("Content-Length", str(len(data)))
|
|
560
|
+
self.end_headers()
|
|
561
|
+
self.wfile.write(data)
|
|
562
|
+
|
|
563
|
+
def do_GET(self):
|
|
564
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
565
|
+
if parsed.path == "/":
|
|
566
|
+
self._send(200, PAGE, "text/html; charset=utf-8")
|
|
567
|
+
elif parsed.path == "/api/stats":
|
|
568
|
+
self._send(200, json.dumps(collect_stats()), "application/json")
|
|
569
|
+
elif parsed.path == "/api/search":
|
|
570
|
+
q = urllib.parse.parse_qs(parsed.query).get("q", [""])[0]
|
|
571
|
+
self._send(200, json.dumps(live_search(q)), "application/json")
|
|
572
|
+
else:
|
|
573
|
+
self._send(404, "not found", "text/plain")
|
|
574
|
+
|
|
575
|
+
def log_message(self, *a):
|
|
576
|
+
pass # quiet
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def main():
|
|
580
|
+
if "--json" in sys.argv:
|
|
581
|
+
print(json.dumps(collect_stats(), indent=2, ensure_ascii=False))
|
|
582
|
+
return
|
|
583
|
+
port = 1934
|
|
584
|
+
if "--port" in sys.argv:
|
|
585
|
+
port = int(sys.argv[sys.argv.index("--port") + 1])
|
|
586
|
+
srv = None
|
|
587
|
+
for p in range(port, port + 10):
|
|
588
|
+
try:
|
|
589
|
+
srv = ThreadingHTTPServer(("127.0.0.1", p), Handler)
|
|
590
|
+
port = p
|
|
591
|
+
break
|
|
592
|
+
except OSError:
|
|
593
|
+
continue
|
|
594
|
+
if srv is None:
|
|
595
|
+
print("no free port in %d..%d" % (port, port + 9))
|
|
596
|
+
return
|
|
597
|
+
url = "http://127.0.0.1:%d" % port
|
|
598
|
+
print("ovmem dashboard -> %s" % url)
|
|
599
|
+
print("(Ctrl+C to stop)")
|
|
600
|
+
try:
|
|
601
|
+
srv.serve_forever()
|
|
602
|
+
except KeyboardInterrupt:
|
|
603
|
+
print("\nstopped")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
if __name__ == "__main__":
|
|
607
|
+
main()
|
|
@@ -149,8 +149,14 @@ case "$tool" in
|
|
|
149
149
|
deny "Leopold guard: 'git reset --hard' is forbidden in autonomous mode." ;;
|
|
150
150
|
clean) matches "$norm" '(--force|(^|[[:space:]])-[a-z]*f)' && \
|
|
151
151
|
deny "Leopold guard: 'git clean -f' is forbidden in autonomous mode." ;;
|
|
152
|
-
branch)
|
|
153
|
-
|
|
152
|
+
branch)
|
|
153
|
+
if matches "$norm" '(^|[[:space:]])-D([[:space:]]|$)'; then
|
|
154
|
+
# Exception: Leopold's own throwaway run-worktree branches are deletable
|
|
155
|
+
# (cleanup of `leopold/run-*`); every other forced branch delete stays denied.
|
|
156
|
+
matches "$norm" 'leopold/run-' || \
|
|
157
|
+
deny "Leopold guard: 'git branch -D' is forbidden in autonomous mode."
|
|
158
|
+
fi ;;
|
|
159
|
+
worktree) : ;; # allowed: Leopold isolates a run in a dedicated git worktree
|
|
154
160
|
push)
|
|
155
161
|
matches "$norm" '(--force|--force-with-lease|(^|[[:space:]])-f([[:space:]]|$))' && \
|
|
156
162
|
deny "Leopold guard: force-push is forbidden in autonomous mode."
|
|
@@ -90,13 +90,19 @@ component_menu() {
|
|
|
90
90
|
ext_installed "$d" && st="installed${C_RESET} ${C_DIM}($(ext_status "$d"))"
|
|
91
91
|
printf " %s%s%s\n status: %s%s\n\n" "$C_BOLD" "$title" "$C_RESET" "$C_GREEN" "$st"
|
|
92
92
|
printf " %s%s%s\n\n" "$C_DIM" "$(_jget "$d/extension.json" summary)" "$C_RESET"
|
|
93
|
-
|
|
93
|
+
local has_dash=""; [ -n "$(_jget "$d/extension.json" dashboard)" ] && has_dash=1
|
|
94
|
+
if [ -n "$has_dash" ]; then
|
|
95
|
+
printf " 1) Install 2) Update 3) Remove 4) Doctor w) Watch b) Back\n\n"
|
|
96
|
+
else
|
|
97
|
+
printf " 1) Install 2) Update 3) Remove 4) Doctor b) Back\n\n"
|
|
98
|
+
fi
|
|
94
99
|
printf "select: "; read -r a || a="b"
|
|
95
100
|
case "$a" in
|
|
96
101
|
1) ext_run "$d" install || echo "${C_YELLOW}install returned non-zero${C_RESET}"; pause ;;
|
|
97
102
|
2) ext_run "$d" update || echo "${C_YELLOW}update returned non-zero${C_RESET}"; pause ;;
|
|
98
103
|
3) ext_run "$d" remove || echo "${C_YELLOW}remove returned non-zero${C_RESET}"; pause ;;
|
|
99
104
|
4) ext_run "$d" doctor || true; pause ;;
|
|
105
|
+
w|W) [ -n "$has_dash" ] && { ext_run "$d" watch || true; }; pause ;;
|
|
100
106
|
b|B|"") return ;;
|
|
101
107
|
*) ;;
|
|
102
108
|
esac
|
|
@@ -16,10 +16,12 @@ and uses no web fonts. Usage:
|
|
|
16
16
|
python3 leopold-watch.py [--project DIR] [--port 4179] [--host 127.0.0.1]
|
|
17
17
|
"""
|
|
18
18
|
import argparse
|
|
19
|
+
import importlib.util
|
|
19
20
|
import json
|
|
20
21
|
import os
|
|
21
22
|
import re
|
|
22
23
|
import time
|
|
24
|
+
import urllib.parse
|
|
23
25
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
26
|
|
|
25
27
|
LEO = "" # set in main(): the project's .leopold dir
|
|
@@ -313,6 +315,77 @@ def snapshot():
|
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
|
|
318
|
+
# --------------------------------------------------------------------------- extension dashboards
|
|
319
|
+
# A small plugin system: any installed extension whose extension.json carries a
|
|
320
|
+
# `dashboard` block contributes a tab. The block names a Python module + a `view`
|
|
321
|
+
# callable returning a declarative card/widget view ({"cards":[...]}), and an optional
|
|
322
|
+
# `search` callable. The watch imports the module and renders the view with its own
|
|
323
|
+
# design system. Any failure drops that extension silently — the tab just won't appear.
|
|
324
|
+
_EXT_CACHE = None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _ext_dirs():
|
|
328
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
329
|
+
out = []
|
|
330
|
+
for cand in (os.path.join(here, "..", "extensions"),
|
|
331
|
+
os.path.expanduser("~/.claude/leopold/extensions")):
|
|
332
|
+
cand = os.path.abspath(cand)
|
|
333
|
+
if os.path.isdir(cand) and cand not in out:
|
|
334
|
+
out.append(cand)
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def ext_dashboards():
|
|
339
|
+
"""Discover installed extensions that contribute a dashboard tab (memoized)."""
|
|
340
|
+
global _EXT_CACHE
|
|
341
|
+
if _EXT_CACHE is not None:
|
|
342
|
+
return _EXT_CACHE
|
|
343
|
+
found, seen = [], set()
|
|
344
|
+
for base in _ext_dirs():
|
|
345
|
+
try:
|
|
346
|
+
names = sorted(os.listdir(base))
|
|
347
|
+
except OSError:
|
|
348
|
+
continue
|
|
349
|
+
for name in names:
|
|
350
|
+
meta = os.path.join(base, name, "extension.json")
|
|
351
|
+
if name in seen or not os.path.isfile(meta):
|
|
352
|
+
continue
|
|
353
|
+
try:
|
|
354
|
+
with open(meta, encoding="utf-8") as f:
|
|
355
|
+
cfg = json.load(f)
|
|
356
|
+
dash = cfg.get("dashboard")
|
|
357
|
+
if not isinstance(dash, dict):
|
|
358
|
+
continue
|
|
359
|
+
mod_path = os.path.expanduser(dash.get("module", ""))
|
|
360
|
+
if not mod_path or not os.path.isfile(mod_path):
|
|
361
|
+
continue
|
|
362
|
+
spec = importlib.util.spec_from_file_location("ext_dash_%s" % name, mod_path)
|
|
363
|
+
mod = importlib.util.module_from_spec(spec)
|
|
364
|
+
spec.loader.exec_module(mod)
|
|
365
|
+
view_fn = getattr(mod, dash.get("view", "dashboard_view"), None)
|
|
366
|
+
if not callable(view_fn):
|
|
367
|
+
continue
|
|
368
|
+
search_fn = getattr(mod, dash.get("search", ""), None)
|
|
369
|
+
found.append({
|
|
370
|
+
"name": cfg.get("name", name),
|
|
371
|
+
"label": dash.get("label", cfg.get("title", name)),
|
|
372
|
+
"view": view_fn,
|
|
373
|
+
"search": search_fn if callable(search_fn) else None,
|
|
374
|
+
})
|
|
375
|
+
seen.add(name)
|
|
376
|
+
except Exception:
|
|
377
|
+
continue
|
|
378
|
+
_EXT_CACHE = found
|
|
379
|
+
return found
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def ext_by_name(name):
|
|
383
|
+
for e in ext_dashboards():
|
|
384
|
+
if e["name"] == name:
|
|
385
|
+
return e
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
316
389
|
# --------------------------------------------------------------------------- page
|
|
317
390
|
# Design system: warm cream (light) / near-black (dark), monochrome with semantic green/red
|
|
318
391
|
# + severity tones; Geist / Geist Mono type stack (system fallback, no web fonts -> offline).
|
|
@@ -400,6 +473,31 @@ html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);
|
|
|
400
473
|
.dec{background:var(--secondary);border:1px solid var(--hairline);border-radius:8px;padding:10px 12px;margin-bottom:8px;
|
|
401
474
|
font-family:var(--mono);font-size:12px;white-space:pre-wrap;line-height:1.5}
|
|
402
475
|
.empty{color:var(--muted-fg);padding:6px 0;font-size:12px}
|
|
476
|
+
.tabs{display:flex;gap:6px;margin-bottom:16px;flex-wrap:wrap}
|
|
477
|
+
.tab{background:transparent;border:1px solid var(--border);color:var(--muted-fg);border-radius:9999px;
|
|
478
|
+
height:28px;padding:0 14px;font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;cursor:pointer}
|
|
479
|
+
.tab:hover{color:var(--fg);border-color:var(--muted-fg)}
|
|
480
|
+
.tab.active{color:var(--fg);border-color:var(--fg)}
|
|
481
|
+
.kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px}
|
|
482
|
+
.kv .k{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-fg)}
|
|
483
|
+
.kv .v{font-family:var(--mono);font-size:15px;font-variant-numeric:tabular-nums;margin-top:3px;word-break:break-word}
|
|
484
|
+
.kv .v.good{color:var(--success)}.kv .v.bad{color:var(--destructive)}.kv .v.warn{color:var(--warnbar)}
|
|
485
|
+
.xtab{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12px}
|
|
486
|
+
.xtab th,.xtab td{text-align:left;padding:5px 6px;border-bottom:1px solid var(--hairline);
|
|
487
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px}
|
|
488
|
+
.xtab th{color:var(--muted-fg);font-weight:500;text-transform:uppercase;letter-spacing:.06em;font-size:10px}
|
|
489
|
+
.xtab td.n{text-align:right;font-variant-numeric:tabular-nums}
|
|
490
|
+
.xsearch{display:flex;gap:8px;margin-bottom:10px}
|
|
491
|
+
.xsearch input{flex:1;background:var(--secondary);border:1px solid var(--border);color:var(--fg);
|
|
492
|
+
border-radius:6px;padding:7px 11px;font-family:var(--mono);font-size:12px}
|
|
493
|
+
.xsearch button{background:var(--fg);color:var(--bg);border:0;border-radius:6px;padding:7px 14px;
|
|
494
|
+
font-family:var(--sans);font-weight:500;font-size:12px;cursor:pointer}
|
|
495
|
+
.xhit{padding:7px 0;border-bottom:1px solid var(--hairline)}.xhit:last-child{border:0}
|
|
496
|
+
.xhit .sc{font-family:var(--mono);font-size:11px;color:var(--success);font-weight:600}
|
|
497
|
+
.xhit .u{font-family:var(--mono);font-size:10px;color:var(--muted-fg);margin-left:8px}
|
|
498
|
+
.xhit .tx{font-size:12px;margin-top:3px;color:var(--fg);opacity:.85}
|
|
499
|
+
.xlog{margin:0;font-family:var(--mono);font-size:11px;color:var(--muted-fg);white-space:pre-wrap;max-height:160px;overflow:auto}
|
|
500
|
+
.hide{display:none}
|
|
403
501
|
::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}
|
|
404
502
|
::-webkit-scrollbar-thumb{background:var(--hairline);border:2px solid var(--bg);border-radius:9999px}
|
|
405
503
|
::selection{background:var(--fg);color:var(--bg)}
|
|
@@ -411,6 +509,8 @@ html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);
|
|
|
411
509
|
<span class="proj" id="proj"></span><span class="grow"></span>
|
|
412
510
|
<button class="tgl" id="tgl">theme</button>
|
|
413
511
|
</div>
|
|
512
|
+
<div class="tabs" id="tabs"></div>
|
|
513
|
+
<div id="tab-run">
|
|
414
514
|
<div class="card">
|
|
415
515
|
<div class="row">
|
|
416
516
|
<span class="pill" id="status"><span class="dot" id="dot"></span><span id="stext">—</span></span>
|
|
@@ -424,6 +524,8 @@ html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);
|
|
|
424
524
|
<div class="card"><div class="sectitle">Live events</div><div class="feed" id="feed"></div></div>
|
|
425
525
|
<div class="card"><div class="sectitle">Plan</div><div id="plan" class="plan"></div></div>
|
|
426
526
|
<div class="card"><div class="sectitle">Decisions · newest</div><div id="decisions"></div></div>
|
|
527
|
+
</div><!-- /tab-run -->
|
|
528
|
+
<div id="extviews"></div>
|
|
427
529
|
<script>
|
|
428
530
|
const $=s=>document.querySelector(s);
|
|
429
531
|
function el(t,c,txt){const e=document.createElement(t);if(c)e.className=c;if(txt!=null)e.textContent=txt;return e;}
|
|
@@ -501,6 +603,86 @@ $("#tgl").addEventListener("click",()=>{
|
|
|
501
603
|
fetch("/api/state").then(r=>r.json()).then(render).catch(()=>{});
|
|
502
604
|
const es=new EventSource("/api/events");
|
|
503
605
|
es.onmessage=ev=>{try{render(JSON.parse(ev.data))}catch(_){}};
|
|
606
|
+
|
|
607
|
+
// ---- tabs: Run (SSE) + one per extension dashboard (polled) ----
|
|
608
|
+
let curTab="run",extTimer=null;
|
|
609
|
+
function setTab(name){
|
|
610
|
+
curTab=name;
|
|
611
|
+
document.querySelectorAll(".tab").forEach(b=>b.classList.toggle("active",b.dataset.tab===name));
|
|
612
|
+
$("#tab-run").classList.toggle("hide",name!=="run");
|
|
613
|
+
document.querySelectorAll("[data-extview]").forEach(v=>v.classList.toggle("hide",v.dataset.extview!==name));
|
|
614
|
+
try{localStorage.setItem("leo-tab",name)}catch(e){}
|
|
615
|
+
if(extTimer){clearInterval(extTimer);extTimer=null;}
|
|
616
|
+
if(name!=="run"){loadExt(name);extTimer=setInterval(()=>loadExt(name),5000);}
|
|
617
|
+
}
|
|
618
|
+
async function loadExt(name){
|
|
619
|
+
const host=document.querySelector('[data-extview="'+name+'"]');if(!host)return;
|
|
620
|
+
const inp=host.querySelector(".xsearch input"); // don't clobber an in-progress search
|
|
621
|
+
if(inp&&(inp===document.activeElement||inp.value.trim()))return;
|
|
622
|
+
let v;try{v=await (await fetch("/api/ext/"+encodeURIComponent(name)+"/stats")).json();}catch(e){return;}
|
|
623
|
+
renderView(host,name,v);
|
|
624
|
+
}
|
|
625
|
+
function widgetEl(name,w){
|
|
626
|
+
if(w.kind==="kpis"){
|
|
627
|
+
const g=el("div","kv");
|
|
628
|
+
(w.items||[]).forEach(it=>{const d=el("div");d.append(el("div","k",it.label));
|
|
629
|
+
const v=el("div","v"+(it.tone&&it.tone!=="none"?(" "+it.tone):""));v.textContent=it.value;d.append(v);g.append(d);});
|
|
630
|
+
return g;
|
|
631
|
+
}
|
|
632
|
+
if(w.kind==="bars"){
|
|
633
|
+
const wrap=el("div","meters"),items=w.items||[],mx=Math.max(1,...items.map(x=>x.max||x.value||0));
|
|
634
|
+
items.forEach(it=>{const m=el("div","meter"),top=el("div","top");
|
|
635
|
+
top.append(el("span","lbl",it.label),el("span","val tnum",""+it.value));
|
|
636
|
+
const bar=el("div","bar"),i=el("i");i.style.width=Math.round(100*(it.value||0)/(it.max||mx))+"%";bar.append(i);
|
|
637
|
+
m.append(top,bar);wrap.append(m);});
|
|
638
|
+
return wrap;
|
|
639
|
+
}
|
|
640
|
+
if(w.kind==="table"){
|
|
641
|
+
const t=el("table","xtab"),cols=w.columns||[],rows=w.rows||[];
|
|
642
|
+
if(cols.length){const tr=el("tr");cols.forEach((c,i)=>tr.append(el("th",i>0?"n":null,c)));
|
|
643
|
+
const th=el("thead");th.append(tr);t.append(th);}
|
|
644
|
+
const tb=el("tbody");
|
|
645
|
+
rows.forEach(row=>{const tr=el("tr");row.forEach((cell,i)=>{
|
|
646
|
+
const td=el("td",(i>0&&typeof cell==="number")?"n":null);td.textContent=cell;tr.append(td);});tb.append(tr);});
|
|
647
|
+
if(!rows.length){const tr=el("tr"),td=el("td","empty","empty");td.colSpan=Math.max(1,cols.length);tr.append(td);tb.append(tr);}
|
|
648
|
+
t.append(tb);return t;
|
|
649
|
+
}
|
|
650
|
+
if(w.kind==="search"){
|
|
651
|
+
const box=el("div"),row=el("div","xsearch"),inp=el("input"),btn=el("button",null,"Search"),res=el("div");
|
|
652
|
+
inp.placeholder=w.placeholder||"search…";
|
|
653
|
+
const go=async()=>{const q=inp.value.trim();if(!q)return;res.textContent="searching…";
|
|
654
|
+
let r;try{r=await (await fetch("/api/ext/"+encodeURIComponent(name)+"/search?q="+encodeURIComponent(q))).json();}
|
|
655
|
+
catch(e){res.textContent="failed";return;}
|
|
656
|
+
res.innerHTML="";if(r.error){res.append(el("div","empty",r.error));return;}
|
|
657
|
+
if(!(r.hits||[]).length){res.append(el("div","empty","no results"));return;}
|
|
658
|
+
r.hits.forEach(h=>{const d=el("div","xhit"),t=el("div");
|
|
659
|
+
t.append(el("span","sc",h.score!=null?h.score.toFixed(3):""),el("span","u",h.uri||""));
|
|
660
|
+
d.append(t);if(h.text)d.append(el("div","tx",h.text));res.append(d);});};
|
|
661
|
+
btn.onclick=go;inp.addEventListener("keydown",e=>{if(e.key==="Enter")go();});
|
|
662
|
+
row.append(inp,btn);box.append(row,res);return box;
|
|
663
|
+
}
|
|
664
|
+
if(w.kind==="log"){const pre=el("pre","xlog");pre.textContent=(w.lines||[]).join("\n");return pre;}
|
|
665
|
+
return el("div");
|
|
666
|
+
}
|
|
667
|
+
function renderView(host,name,v){
|
|
668
|
+
host.innerHTML="";
|
|
669
|
+
const cards=(v&&v.cards)||[];
|
|
670
|
+
if(v&&v.error){const c=el("div","card");c.append(el("div","empty","error: "+v.error));host.append(c);}
|
|
671
|
+
cards.forEach(card=>{const c=el("div","card");c.append(el("div","sectitle",card.title||""));
|
|
672
|
+
(card.widgets||[]).forEach(w=>c.append(widgetEl(name,w)));host.append(c);});
|
|
673
|
+
if(!cards.length&&!(v&&v.error)){const c=el("div","card");c.append(el("div","empty","no data"));host.append(c);}
|
|
674
|
+
}
|
|
675
|
+
(async()=>{
|
|
676
|
+
let exts=[];try{exts=await (await fetch("/api/ext")).json();}catch(e){}
|
|
677
|
+
const nav=$("#tabs"),views=$("#extviews");
|
|
678
|
+
const mk=(tab,label)=>{const b=el("button","tab",label);b.dataset.tab=tab;b.onclick=()=>setTab(tab);nav.append(b);};
|
|
679
|
+
mk("run","Run");
|
|
680
|
+
exts.forEach(e=>{mk(e.name,e.label);const v=el("div","hide");v.dataset.extview=e.name;views.append(v);});
|
|
681
|
+
if(!exts.length)nav.classList.add("hide"); // no extensions -> plain single page
|
|
682
|
+
let start="run";try{start=localStorage.getItem("leo-tab")||"run";}catch(e){}
|
|
683
|
+
if(start!=="run"&&!exts.some(e=>e.name===start))start="run";
|
|
684
|
+
setTab(start);
|
|
685
|
+
})();
|
|
504
686
|
</script></body></html>"""
|
|
505
687
|
|
|
506
688
|
|
|
@@ -525,6 +707,43 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
525
707
|
self._send(200, "application/json", json.dumps(snapshot()).encode())
|
|
526
708
|
elif path == "/api/events":
|
|
527
709
|
self._sse()
|
|
710
|
+
elif path == "/api/ext":
|
|
711
|
+
tabs = [{"name": e["name"], "label": e["label"], "search": bool(e["search"])}
|
|
712
|
+
for e in ext_dashboards()]
|
|
713
|
+
self._send(200, "application/json", json.dumps(tabs).encode())
|
|
714
|
+
elif path.startswith("/api/ext/"):
|
|
715
|
+
self._ext_route(path)
|
|
716
|
+
else:
|
|
717
|
+
self._send(404, "text/plain", b"not found")
|
|
718
|
+
|
|
719
|
+
def _ext_route(self, path):
|
|
720
|
+
parts = path.split("/") # ['', 'api', 'ext', '<name>', 'stats'|'search']
|
|
721
|
+
if len(parts) != 5:
|
|
722
|
+
self._send(404, "text/plain", b"not found")
|
|
723
|
+
return
|
|
724
|
+
name, action = urllib.parse.unquote(parts[3]), parts[4]
|
|
725
|
+
e = ext_by_name(name)
|
|
726
|
+
if not e:
|
|
727
|
+
self._send(404, "application/json", b'{"error":"unknown extension"}')
|
|
728
|
+
return
|
|
729
|
+
if action == "stats":
|
|
730
|
+
try:
|
|
731
|
+
body = json.dumps(e["view"]())
|
|
732
|
+
except Exception as ex:
|
|
733
|
+
body = json.dumps({"cards": [], "error": str(ex)})
|
|
734
|
+
self._send(200, "application/json", body.encode())
|
|
735
|
+
elif action == "search":
|
|
736
|
+
if not e["search"]:
|
|
737
|
+
self._send(404, "application/json", b'{"hits":[]}')
|
|
738
|
+
return
|
|
739
|
+
q = ""
|
|
740
|
+
if "?" in self.path:
|
|
741
|
+
q = urllib.parse.parse_qs(self.path.split("?", 1)[1]).get("q", [""])[0]
|
|
742
|
+
try:
|
|
743
|
+
body = json.dumps(e["search"](q))
|
|
744
|
+
except Exception as ex:
|
|
745
|
+
body = json.dumps({"hits": [], "error": str(ex)})
|
|
746
|
+
self._send(200, "application/json", body.encode())
|
|
528
747
|
else:
|
|
529
748
|
self._send(404, "text/plain", b"not found")
|
|
530
749
|
|
|
@@ -575,6 +794,9 @@ def main():
|
|
|
575
794
|
url = "http://%s:%d" % (args.host, args.port)
|
|
576
795
|
print("Leopold watch -> %s (project: %s)" % (url, args.project))
|
|
577
796
|
print("Reading: %s + the session transcript · Ctrl-C to stop" % LEO)
|
|
797
|
+
tabs = ext_dashboards() # warm the cache once (single-threaded) + surface what's wired
|
|
798
|
+
if tabs:
|
|
799
|
+
print("Extension tabs: %s" % ", ".join(e["label"] for e in tabs))
|
|
578
800
|
try:
|
|
579
801
|
httpd.serve_forever()
|
|
580
802
|
except KeyboardInterrupt:
|
|
@@ -65,8 +65,11 @@ in **parallel**, use a separate git worktree (one run per worktree):
|
|
|
65
65
|
|
|
66
66
|
git worktree add ../<proj>-leopold-2 && cd ../<proj>-leopold-2
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
The SDK driver automates this: `leopold-driver run --worktree` isolates the run in
|
|
69
|
+
its own `leopold/run-<id>` worktree and, on the next start, reaps an orphaned prior
|
|
70
|
+
run (a dead process that left `active:true`) and prunes its leftover worktree.
|
|
71
|
+
Otherwise wait for the other run, or `/leopold-stop` it first. A run idle for over
|
|
72
|
+
10 minutes is treated as stale and may be taken over.
|
|
70
73
|
|
|
71
74
|
## Step 1 — Activate the run
|
|
72
75
|
|
package/dist/config.js
CHANGED
|
@@ -43,12 +43,24 @@ export function initState(brief) {
|
|
|
43
43
|
consecutive_failures: 0,
|
|
44
44
|
max_failures: intFrom(brief.guardrails, "max_failures", 3),
|
|
45
45
|
started_at: new Date().toISOString(),
|
|
46
|
+
orchestrator_pid: process.pid,
|
|
46
47
|
};
|
|
47
48
|
writeState(brief.leoDir, state);
|
|
48
49
|
return state;
|
|
49
50
|
}
|
|
51
|
+
/** Persist run state by MERGING over what's already on disk. The bash skill and
|
|
52
|
+
* Stop-hook write fields the driver's RunState doesn't model (session_id,
|
|
53
|
+
* max_subagents, …); a full overwrite would drop them (and they'd drop ours).
|
|
54
|
+
* Read-merge-write keeps both writers' fields intact. */
|
|
50
55
|
export function writeState(leoDir, state) {
|
|
51
|
-
|
|
56
|
+
const p = path.join(leoDir, "state.json");
|
|
57
|
+
let onDisk = {};
|
|
58
|
+
try {
|
|
59
|
+
if (fs.existsSync(p))
|
|
60
|
+
onDisk = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch { /* corrupt/absent — fall back to a clean write */ }
|
|
63
|
+
fs.writeFileSync(p, JSON.stringify({ ...onDisk, ...state }, null, 2));
|
|
52
64
|
}
|
|
53
65
|
export function killSwitch(leoDir) {
|
|
54
66
|
return fs.existsSync(path.join(leoDir, "STOP"));
|
|
@@ -70,5 +82,6 @@ export function loadConfig(argv) {
|
|
|
70
82
|
maxTurnsPerItem: parseInt(process.env.LEOPOLD_MAX_TURNS_PER_ITEM ?? "40", 10),
|
|
71
83
|
webhookUrl: process.env.LEOPOLD_WEBHOOK || undefined,
|
|
72
84
|
dryRun: argv.includes("--dry-run"),
|
|
85
|
+
worktree: argv.includes("--worktree") || process.env.LEOPOLD_WORKTREE === "1",
|
|
73
86
|
};
|
|
74
87
|
}
|
package/dist/loop.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// The orchestration loop: the conductor burns down the plan, one fresh worker
|
|
2
2
|
// per item, deciding from the charter, with git locked, until the plan is done
|
|
3
3
|
// or a stop condition fires. It notifies the human on completion or escalation.
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { loadBrief, initState, writeState, killSwitch, loadConfig, clearRunTokens } from "./config.js";
|
|
5
6
|
import { runItem } from "./worker.js";
|
|
6
7
|
import { decide } from "./conductor.js";
|
|
7
8
|
import { logEvent, logDecision, markItemDone, openItems, nextOpenItem } from "./log.js";
|
|
8
9
|
import { notify } from "./notify.js";
|
|
10
|
+
import { createWorktree, cleanupWorktree } from "./worktree.js";
|
|
11
|
+
import { reapOrphan } from "./reaper.js";
|
|
9
12
|
export async function runDriver(cwd, argv) {
|
|
10
13
|
const cfg = loadConfig(argv);
|
|
11
14
|
const brief = loadBrief(cwd);
|
|
@@ -16,9 +19,25 @@ export async function runDriver(cwd, argv) {
|
|
|
16
19
|
console.log("Next item: " + (nextOpenItem(brief.planPath) ?? "(none)"));
|
|
17
20
|
return;
|
|
18
21
|
}
|
|
22
|
+
// Preflight: reap a prior run that crashed leaving state.active === true.
|
|
23
|
+
reapOrphan(brief.root, brief.leoDir);
|
|
24
|
+
// Optional isolation: run inside a dedicated git worktree (the worker's cwd).
|
|
25
|
+
let worktree = null;
|
|
26
|
+
if (cfg.worktree) {
|
|
27
|
+
worktree = createWorktree(brief.root, brief.leoDir, randomUUID().slice(0, 8));
|
|
28
|
+
if (worktree) {
|
|
29
|
+
brief.worktreeRoot = worktree.path;
|
|
30
|
+
console.log(`Isolated in worktree: ${worktree.path} (branch ${worktree.branch})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
const state = initState(brief);
|
|
34
|
+
if (worktree) {
|
|
35
|
+
state.worktree_path = worktree.path;
|
|
36
|
+
state.worktree_branch = worktree.branch;
|
|
37
|
+
writeState(brief.leoDir, state);
|
|
38
|
+
}
|
|
20
39
|
const recent = [];
|
|
21
|
-
logEvent(brief.leoDir, { event: "run_start", conductor: cfg.conductorModel });
|
|
40
|
+
logEvent(brief.leoDir, { event: "run_start", conductor: cfg.conductorModel, worktree: worktree?.path ?? null });
|
|
22
41
|
console.log(`Leopold is conducting "${brief.root}". Git is locked. touch .leopold/STOP to halt.\n`);
|
|
23
42
|
const stop = (reason) => {
|
|
24
43
|
state.active = false;
|
|
@@ -26,6 +45,8 @@ export async function runDriver(cwd, argv) {
|
|
|
26
45
|
writeState(brief.leoDir, state);
|
|
27
46
|
clearRunTokens(brief.leoDir);
|
|
28
47
|
logEvent(brief.leoDir, { event: "stop", reason });
|
|
48
|
+
if (worktree)
|
|
49
|
+
cleanupWorktree(brief.root, worktree, brief.leoDir);
|
|
29
50
|
};
|
|
30
51
|
for (;;) {
|
|
31
52
|
if (killSwitch(brief.leoDir)) {
|
package/dist/reaper.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Orphan reaper: detect a prior run that crashed leaving state.active === true,
|
|
2
|
+
// using a PID-liveness probe (the file's "active" flag is not proof of life).
|
|
3
|
+
// Ported from paperclip's isZombieRun/reapOrphanedRuns, file-state edition.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { logEvent } from "./log.js";
|
|
7
|
+
import { clearRunTokens } from "./config.js";
|
|
8
|
+
import { cleanupWorktree } from "./worktree.js";
|
|
9
|
+
/** True if a process with this pid is alive. `process.kill(pid, 0)` sends no
|
|
10
|
+
* signal: it throws ESRCH if the pid is dead, EPERM if it's alive but not ours. */
|
|
11
|
+
export function isProcessAlive(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return e.code === "EPERM";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Best-effort preflight before a new run starts: if the previous run is still
|
|
21
|
+
* flagged active but its orchestrator pid is dead, declare it orphaned — flip
|
|
22
|
+
* inactive, log, clean its (clean) worktree, and clear stale run tokens.
|
|
23
|
+
*
|
|
24
|
+
* Conservative on purpose: we only reap when there IS a pid AND it is dead.
|
|
25
|
+
* An active state with no pid (e.g. a live in-session /leopold-run, which does
|
|
26
|
+
* not persist orchestrator_pid) is left untouched — never clobber a live run. */
|
|
27
|
+
export function reapOrphan(repoRoot, leoDir) {
|
|
28
|
+
const p = path.join(leoDir, "state.json");
|
|
29
|
+
if (!fs.existsSync(p))
|
|
30
|
+
return;
|
|
31
|
+
let prev;
|
|
32
|
+
try {
|
|
33
|
+
prev = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (prev.active !== true)
|
|
39
|
+
return;
|
|
40
|
+
const pid = typeof prev.orchestrator_pid === "number" ? prev.orchestrator_pid : undefined;
|
|
41
|
+
if (pid === undefined || isProcessAlive(pid))
|
|
42
|
+
return;
|
|
43
|
+
prev.active = false;
|
|
44
|
+
prev.stopped_reason = "reaped_orphan";
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(p, JSON.stringify(prev, null, 2));
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore */ }
|
|
49
|
+
logEvent(leoDir, {
|
|
50
|
+
event: "run_reaped",
|
|
51
|
+
prior_pid: pid,
|
|
52
|
+
prior_started: prev.started_at ?? null,
|
|
53
|
+
});
|
|
54
|
+
const wtPath = typeof prev.worktree_path === "string" ? prev.worktree_path : undefined;
|
|
55
|
+
const wtBranch = typeof prev.worktree_branch === "string" ? prev.worktree_branch : undefined;
|
|
56
|
+
if (wtPath && wtBranch)
|
|
57
|
+
cleanupWorktree(repoRoot, { path: wtPath, branch: wtBranch }, leoDir);
|
|
58
|
+
clearRunTokens(leoDir);
|
|
59
|
+
}
|
package/dist/worker.js
CHANGED
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Git worktree isolation for a run (Path A / SDK driver). The orchestrator runs
|
|
2
|
+
// git directly here — NOT through the worker's Bash tool — so the worker's git
|
|
3
|
+
// lock is unaffected (the lock constrains the worker, not the orchestrator).
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { logEvent } from "./log.js";
|
|
8
|
+
function git(cwd, args) {
|
|
9
|
+
return execFileSync("git", args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
}).trim();
|
|
14
|
+
}
|
|
15
|
+
export function isGitRepo(dir) {
|
|
16
|
+
try {
|
|
17
|
+
return git(dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A worktree is "dirty" if it has staged, unstaged, or untracked changes.
|
|
24
|
+
* Git is locked, so a run stages (git add) but never commits — dirty means
|
|
25
|
+
* "there is work here the user should review", which we never destroy. */
|
|
26
|
+
export function isDirty(worktreePath) {
|
|
27
|
+
try {
|
|
28
|
+
return git(worktreePath, ["status", "--porcelain"]).length > 0;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Provision an isolated worktree on a throwaway branch `leopold/run-<id>`,
|
|
35
|
+
* as a sibling of the repo (matches the manual flow in docs/guardrails.md).
|
|
36
|
+
* Returns null if the project is not a git repo — caller falls back to root. */
|
|
37
|
+
export function createWorktree(repoRoot, leoDir, runId) {
|
|
38
|
+
if (!isGitRepo(repoRoot)) {
|
|
39
|
+
logEvent(leoDir, { event: "worktree_skipped", reason: "not_a_git_repo" });
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const branch = `leopold/run-${runId}`;
|
|
43
|
+
// sibling of the repo, one level (matches `../<proj>-leopold-2` in the docs)
|
|
44
|
+
const dir = path.join(path.dirname(repoRoot), `${path.basename(repoRoot)}-leopold-${runId}`);
|
|
45
|
+
try {
|
|
46
|
+
git(repoRoot, ["worktree", "add", "-b", branch, dir, "HEAD"]);
|
|
47
|
+
logEvent(leoDir, { event: "worktree_created", path: dir, branch });
|
|
48
|
+
return { path: dir, branch };
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
logEvent(leoDir, { event: "worktree_create_failed", error: String(e.message ?? e) });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Remove a run's worktree — but ONLY if it's clean. A worktree with work is
|
|
56
|
+
* preserved (and logged) for the user to review/merge, since git is locked and
|
|
57
|
+
* the run never committed. A clean worktree (or one already gone) is pruned. */
|
|
58
|
+
export function cleanupWorktree(repoRoot, wt, leoDir) {
|
|
59
|
+
if (!fs.existsSync(wt.path)) {
|
|
60
|
+
try {
|
|
61
|
+
git(repoRoot, ["worktree", "prune"]);
|
|
62
|
+
}
|
|
63
|
+
catch { /* ignore */ }
|
|
64
|
+
try {
|
|
65
|
+
git(repoRoot, ["branch", "-D", wt.branch]);
|
|
66
|
+
}
|
|
67
|
+
catch { /* ignore */ }
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (isDirty(wt.path)) {
|
|
71
|
+
logEvent(leoDir, {
|
|
72
|
+
event: "worktree_preserved",
|
|
73
|
+
path: wt.path,
|
|
74
|
+
branch: wt.branch,
|
|
75
|
+
reason: "uncommitted_changes",
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
git(repoRoot, ["worktree", "remove", "--force", wt.path]);
|
|
81
|
+
git(repoRoot, ["branch", "-D", wt.branch]);
|
|
82
|
+
logEvent(leoDir, { event: "worktree_removed", path: wt.path, branch: wt.branch });
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
logEvent(leoDir, { event: "worktree_remove_failed", path: wt.path, error: String(e.message ?? e) });
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leopold-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Leopold SDK driver: a persistent conductor that orchestrates fresh Claude Code workers per task, decides from your charter, and notifies you. Uses your Claude Code auth. Git stays locked.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs",
|
|
39
39
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
|
-
"test": "node --
|
|
40
|
+
"test": "node --import tsx --test test/protocol.test.ts test/guard.test.ts test/worktree.test.ts test/reaper.test.ts",
|
|
41
41
|
"dev": "tsx src/index.ts",
|
|
42
42
|
"start": "node dist/index.js",
|
|
43
43
|
"prepublishOnly": "npm run build"
|