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 +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/scripts/leopold-menu.sh +96 -2
- package/assets/scripts/leopold-watch.py +222 -0
- package/package.json +1 -1
package/assets/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
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:
|
|
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()
|
|
@@ -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"
|
|
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
|
-
|
|
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.
|
|
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": {
|