leopold-driver 0.4.5 → 0.5.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 CHANGED
@@ -1 +1 @@
1
- 0.4.5
1
+ 0.5.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: $([ -f "$OVMEM_DIR/ovmem.py" ] && echo "$OVMEM_DIR/ovmem.py" || echo missing)"
59
- echo "cleanup: $([ -f "$OVMEM_DIR/ovmem-cleanup.py" ] && echo present || echo missing)"
60
- echo "server: $(server_up && echo "up (127.0.0.1:1933)" || echo "down")"
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: $local_hooks/4 wired in settings.json"
71
+ echo "hooks: $local_hooks/4 wired in settings.json"
64
72
  else
65
- echo "hooks: settings.json not found"
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: $prov"
73
- echo "chat: $chat"
74
- echo "embed: $emb"
75
- echo "ov.conf: present (lang=$lang)"
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: present"
85
+ echo "ov.conf: present"
78
86
  else
79
- echo "ov.conf: missing"
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 &middot; 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=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[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()
@@ -77,7 +77,8 @@ main_menu() {
77
77
  "$C_BOLD" "$i" "$C_RESET" "$C_BOLD" "$title" "$C_RESET" "$st" "$C_DIM" "$summary" "$C_RESET"
78
78
  i=$((i + 1))
79
79
  done < <(list_exts)
80
- printf " %sd%s) Doctor all %sq%s) Quit\n\n" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
80
+ printf " %sd%s) Doctor all %su%s) Uninstall %sq%s) Quit\n\n" \
81
+ "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
81
82
  }
82
83
 
83
84
  component_menu() {
@@ -89,13 +90,19 @@ component_menu() {
89
90
  ext_installed "$d" && st="installed${C_RESET} ${C_DIM}($(ext_status "$d"))"
90
91
  printf " %s%s%s\n status: %s%s\n\n" "$C_BOLD" "$title" "$C_RESET" "$C_GREEN" "$st"
91
92
  printf " %s%s%s\n\n" "$C_DIM" "$(_jget "$d/extension.json" summary)" "$C_RESET"
92
- printf " 1) Install 2) Update 3) Remove 4) Doctor b) Back\n\n"
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
93
99
  printf "select: "; read -r a || a="b"
94
100
  case "$a" in
95
101
  1) ext_run "$d" install || echo "${C_YELLOW}install returned non-zero${C_RESET}"; pause ;;
96
102
  2) ext_run "$d" update || echo "${C_YELLOW}update returned non-zero${C_RESET}"; pause ;;
97
103
  3) ext_run "$d" remove || echo "${C_YELLOW}remove returned non-zero${C_RESET}"; pause ;;
98
104
  4) ext_run "$d" doctor || true; pause ;;
105
+ w|W) [ -n "$has_dash" ] && { ext_run "$d" watch || true; }; pause ;;
99
106
  b|B|"") return ;;
100
107
  *) ;;
101
108
  esac
@@ -113,6 +120,92 @@ doctor_all() {
113
120
  pause
114
121
  }
115
122
 
123
+ # ---- uninstall (granular, data-safe) ---------------------------------------
124
+
125
+ confirm() { # prompt -> 0 if yes
126
+ printf "%s [y/N] " "$1"
127
+ local a; read -r a || a=""
128
+ case "$a" in [yY]*) return 0 ;; *) echo " skipped." ; return 1 ;; esac
129
+ }
130
+ ext_path() { [ -d "$EXT_DIR/$1" ] && printf "%s" "$EXT_DIR/$1"; }
131
+ CLAUDE="${CLAUDE_HOME:-$HOME/.claude}"
132
+
133
+ remove_core() {
134
+ local settings="$CLAUDE/settings.json" tmp d
135
+ if [ -f "$settings" ] && command -v jq >/dev/null 2>&1; then
136
+ cp "$settings" "$settings.leopold.bak"
137
+ tmp="$(mktemp)"
138
+ jq 'if .hooks then .hooks |= ( to_entries
139
+ | map(.value |= ( map(.hooks |= map(select((.command // "") | test("leopold/hooks/") | not)))
140
+ | map(select((.hooks | length) > 0)) ))
141
+ | from_entries ) else . end' "$settings" > "$tmp" && mv "$tmp" "$settings"
142
+ echo " unwired Leopold's Stop/PreToolUse hooks (backup at $settings.leopold.bak)"
143
+ fi
144
+ for d in "$CLAUDE"/skills/leopold-*; do [ -d "$d" ] && rm -rf "$d"; done
145
+ rm -rf "$CLAUDE/leopold" 2>/dev/null || true
146
+ echo " removed the leopold-* skills and $CLAUDE/leopold"
147
+ }
148
+
149
+ remove_cli() {
150
+ if command -v npm >/dev/null 2>&1; then
151
+ npm uninstall -g leopold-driver >/dev/null 2>&1 \
152
+ && echo " uninstalled the leopold CLI (npm)" \
153
+ || echo " (leopold CLI not installed via npm, or it needs sudo)"
154
+ else
155
+ echo " npm not found — nothing to do for the CLI"
156
+ fi
157
+ }
158
+
159
+ remove_ext() { # name
160
+ local d; d="$(ext_path "$1")"
161
+ [ -n "$d" ] && ext_run "$d" remove || echo " ($1 extension not found in this build)"
162
+ }
163
+
164
+ remove_ovmem_data() {
165
+ printf "%s This DELETES ~/.openviking — your ENTIRE long-term memory — and uninstalls\n" "$C_YELLOW"
166
+ printf " the OpenViking server. There is no undo.%s\n" "$C_RESET"
167
+ printf " Type %sDELETE%s to confirm: " "$C_BOLD" "$C_RESET"
168
+ local a; read -r a || a=""
169
+ [ "$a" = "DELETE" ] || { echo " skipped (not confirmed)."; return; }
170
+ pkill -f "openviking-server" 2>/dev/null || true
171
+ command -v uv >/dev/null 2>&1 && uv tool uninstall openviking >/dev/null 2>&1 && echo " uninstalled the openviking server" || true
172
+ rm -rf "$HOME/.openviking" 2>/dev/null && echo " deleted ~/.openviking (long-term memory)" || true
173
+ }
174
+
175
+ uninstall_menu() {
176
+ header
177
+ printf " %sUninstall Leopold%s — pick exactly what to remove.\n" "$C_BOLD" "$C_RESET"
178
+ printf " %sYour data is KEPT unless you pick a DATA item; each pick is confirmed.%s\n\n" "$C_DIM" "$C_RESET"
179
+ printf " 1) Leopold core skills + hooks + %s/leopold (the harness)\n" "$CLAUDE"
180
+ printf " 2) leopold CLI npm uninstall -g leopold-driver\n"
181
+ printf " 3) serena unregister MCP + unwire hooks (keeps the serena CLI)\n"
182
+ printf " 4) gstack remove the skill suite\n"
183
+ printf " 5) ovmem unwire hooks + remove engine %s(keeps your memory)%s\n" "$C_DIM" "$C_RESET"
184
+ printf " 6) %sovmem DATA + server ~/.openviking + OpenViking — DELETES memory!%s\n\n" "$C_YELLOW" "$C_RESET"
185
+ printf " a) everything except DATA (1-5) q) cancel\n\n"
186
+ printf "pick (space-separated, e.g. \"1 2 5\"): "
187
+ local picks p; read -r picks || picks="q"
188
+ case "$picks" in q|Q|"") return ;; a|A) picks="1 2 3 4 5" ;; esac
189
+ echo
190
+ printf "%sSelected: %s%s\n" "$C_BOLD" "$picks" "$C_RESET"
191
+ confirm "Proceed with the removals above?" || { pause; return; }
192
+ echo
193
+ for p in $picks; do
194
+ case "$p" in
195
+ 1) confirm "Remove Leopold core (skills + hooks)?" && remove_core ;;
196
+ 2) confirm "Uninstall the leopold CLI (npm -g)?" && remove_cli ;;
197
+ 3) confirm "Remove serena (MCP + hooks)?" && remove_ext serena ;;
198
+ 4) confirm "Remove gstack?" && remove_ext gstack ;;
199
+ 5) confirm "Remove ovmem engine (keeps your memory)?" && remove_ext ovmem ;;
200
+ 6) remove_ovmem_data ;;
201
+ *) echo " ignored: '$p'" ;;
202
+ esac
203
+ done
204
+ echo
205
+ printf "%sUninstall done.%s\n" "$C_GREEN" "$C_RESET"
206
+ pause
207
+ }
208
+
116
209
  # ---- main loop --------------------------------------------------------------
117
210
 
118
211
  while true; do
@@ -121,6 +214,7 @@ while true; do
121
214
  case "$choice" in
122
215
  q|Q) echo; exit 0 ;;
123
216
  d|D) doctor_all ;;
217
+ u|U) uninstall_menu ;;
124
218
  ''|*[!0-9]*) ;; # ignore non-numeric
125
219
  *)
126
220
  idx=$((choice - 1))
@@ -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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leopold-driver",
3
- "version": "0.4.5",
3
+ "version": "0.5.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": {