context-mode 1.0.80 → 1.0.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/build/cli.js +57 -0
  6. package/build/server.js +94 -1
  7. package/cli.bundle.mjs +106 -99
  8. package/insight/components.json +25 -0
  9. package/insight/index.html +13 -0
  10. package/insight/package.json +54 -0
  11. package/insight/server.mjs +624 -0
  12. package/insight/src/components/analytics.tsx +112 -0
  13. package/insight/src/components/ui/badge.tsx +52 -0
  14. package/insight/src/components/ui/button.tsx +58 -0
  15. package/insight/src/components/ui/card.tsx +103 -0
  16. package/insight/src/components/ui/chart.tsx +371 -0
  17. package/insight/src/components/ui/collapsible.tsx +19 -0
  18. package/insight/src/components/ui/input.tsx +20 -0
  19. package/insight/src/components/ui/progress.tsx +83 -0
  20. package/insight/src/components/ui/scroll-area.tsx +55 -0
  21. package/insight/src/components/ui/separator.tsx +23 -0
  22. package/insight/src/components/ui/table.tsx +114 -0
  23. package/insight/src/components/ui/tabs.tsx +82 -0
  24. package/insight/src/components/ui/tooltip.tsx +64 -0
  25. package/insight/src/lib/api.ts +71 -0
  26. package/insight/src/lib/utils.ts +6 -0
  27. package/insight/src/main.tsx +22 -0
  28. package/insight/src/routeTree.gen.ts +189 -0
  29. package/insight/src/router.tsx +19 -0
  30. package/insight/src/routes/__root.tsx +55 -0
  31. package/insight/src/routes/enterprise.tsx +316 -0
  32. package/insight/src/routes/index.tsx +914 -0
  33. package/insight/src/routes/knowledge.tsx +221 -0
  34. package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +137 -0
  35. package/insight/src/routes/search.tsx +97 -0
  36. package/insight/src/routes/sessions.tsx +179 -0
  37. package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +181 -0
  38. package/insight/src/styles.css +104 -0
  39. package/insight/tsconfig.json +29 -0
  40. package/insight/vite.config.ts +19 -0
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +2 -1
  43. package/server.bundle.mjs +76 -72
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * context-mode Insight — Local analytics dashboard.
4
+ * Cross-platform: works with Bun (bun:sqlite) or Node.js (better-sqlite3).
5
+ *
6
+ * Usage:
7
+ * bun insight/server.mjs # fast, uses bun:sqlite
8
+ * node insight/server.mjs # fallback, uses better-sqlite3
9
+ */
10
+
11
+ import { readFileSync, readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
12
+ import { join, dirname, extname } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
15
+ import { createServer as createHttpServer } from "node:http";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const PORT = process.env.PORT || 4747;
19
+
20
+ // ── Cross-platform SQLite ────────────────────────────────
21
+ // Detect runtime: Bun has bun:sqlite built-in, Node needs better-sqlite3
22
+
23
+ let Database;
24
+ const isBun = typeof globalThis.Bun !== "undefined";
25
+
26
+ if (isBun) {
27
+ Database = (await import("bun:sqlite")).Database;
28
+ } else {
29
+ try {
30
+ Database = (await import("better-sqlite3")).default;
31
+ } catch {
32
+ console.error("\n Error: better-sqlite3 not found.");
33
+ console.error(" Install it: npm install better-sqlite3");
34
+ console.error(" Or use Bun: bun insight/server.mjs\n");
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ // ── Paths ────────────────────────────────────────────────
40
+ const BASE = join(homedir(), ".claude", "context-mode");
41
+ const CONTENT_DIR = join(BASE, "content");
42
+ const SESSION_DIR = join(BASE, "sessions");
43
+ const DIST_DIR = join(__dirname, "dist");
44
+
45
+ // ── SQLite helpers ───────────────────────────────────────
46
+
47
+ function openDB(path) {
48
+ try {
49
+ return isBun
50
+ ? new Database(path, { readonly: true })
51
+ : new Database(path, { readonly: true, fileMustExist: true });
52
+ } catch { return null; }
53
+ }
54
+
55
+ function safeAll(db, sql, params = []) {
56
+ try { return db.prepare(sql).all(...params); } catch { return []; }
57
+ }
58
+
59
+ function safeGet(db, sql, params = []) {
60
+ try { return db.prepare(sql).get(...params); } catch { return null; }
61
+ }
62
+
63
+ function listDBFiles(dir) {
64
+ if (!existsSync(dir)) return [];
65
+ return readdirSync(dir)
66
+ .filter(f => f.endsWith(".db"))
67
+ .map(f => ({ name: f, path: join(dir, f), size: statSync(join(dir, f)).size }));
68
+ }
69
+
70
+ function formatBytes(b) {
71
+ if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`;
72
+ if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`;
73
+ return `${b} B`;
74
+ }
75
+
76
+ function queryAllSessionDBs(fn) {
77
+ const results = [];
78
+ for (const f of listDBFiles(SESSION_DIR)) {
79
+ const db = openDB(f.path);
80
+ if (!db) continue;
81
+ try { results.push(...fn(db)); } finally { db.close(); }
82
+ }
83
+ return results;
84
+ }
85
+
86
+ function queryAllContentDBs(fn) {
87
+ const results = [];
88
+ for (const f of listDBFiles(CONTENT_DIR)) {
89
+ const db = openDB(f.path);
90
+ if (!db) continue;
91
+ try { results.push(...fn(db)); } finally { db.close(); }
92
+ }
93
+ return results;
94
+ }
95
+
96
+ function mergeByKey(arr, key, mergeFn) {
97
+ const map = new Map();
98
+ for (const item of arr) {
99
+ const k = item[key];
100
+ if (map.has(k)) map.set(k, mergeFn(map.get(k), item));
101
+ else map.set(k, { ...item });
102
+ }
103
+ return [...map.values()];
104
+ }
105
+
106
+ // ── API Handlers ─────────────────────────────────────────
107
+
108
+ function apiOverview() {
109
+ const contentDBs = listDBFiles(CONTENT_DIR);
110
+ const sessionDBs = listDBFiles(SESSION_DIR);
111
+ let totalSources = 0, totalChunks = 0, totalContentSize = 0;
112
+ let totalSessions = 0, totalEvents = 0, totalSessionSize = 0;
113
+
114
+ for (const f of contentDBs) {
115
+ totalContentSize += f.size;
116
+ const db = openDB(f.path);
117
+ if (!db) continue;
118
+ try {
119
+ totalSources += safeGet(db, "SELECT COUNT(*) as c FROM sources")?.c || 0;
120
+ totalChunks += safeGet(db, "SELECT COUNT(*) as c FROM chunks")?.c || 0;
121
+ } finally { db.close(); }
122
+ }
123
+ for (const f of sessionDBs) {
124
+ totalSessionSize += f.size;
125
+ const db = openDB(f.path);
126
+ if (!db) continue;
127
+ try {
128
+ totalSessions += safeGet(db, "SELECT COUNT(*) as c FROM session_meta")?.c || 0;
129
+ totalEvents += safeGet(db, "SELECT COUNT(*) as c FROM session_events")?.c || 0;
130
+ } finally { db.close(); }
131
+ }
132
+ return {
133
+ content: { databases: contentDBs.length, sources: totalSources, chunks: totalChunks,
134
+ totalSize: formatBytes(totalContentSize), totalSizeBytes: totalContentSize },
135
+ sessions: { databases: sessionDBs.length, sessions: totalSessions, events: totalEvents,
136
+ totalSize: formatBytes(totalSessionSize), totalSizeBytes: totalSessionSize },
137
+ };
138
+ }
139
+
140
+ function apiContentDBs() {
141
+ return listDBFiles(CONTENT_DIR).map(f => {
142
+ const db = openDB(f.path);
143
+ if (!db) return { hash: f.name.replace(".db",""), size: formatBytes(f.size), sources: [], sourceCount: 0, chunkCount: 0 };
144
+ try {
145
+ const sources = safeAll(db, "SELECT id, label, chunk_count, code_chunk_count, indexed_at FROM sources ORDER BY indexed_at DESC");
146
+ const chunkCount = safeGet(db, "SELECT COUNT(*) as c FROM chunks")?.c || 0;
147
+ return {
148
+ hash: f.name.replace(".db",""), size: formatBytes(f.size), sizeBytes: f.size,
149
+ sourceCount: sources.length, chunkCount,
150
+ sources: sources.map(s => ({ id: s.id, label: s.label, chunks: s.chunk_count, codeChunks: s.code_chunk_count, indexedAt: s.indexed_at })),
151
+ };
152
+ } finally { db.close(); }
153
+ });
154
+ }
155
+
156
+ function apiSourceChunks(dbHash, sourceId) {
157
+ const db = openDB(join(CONTENT_DIR, `${dbHash}.db`));
158
+ if (!db) return [];
159
+ try {
160
+ return safeAll(db,
161
+ `SELECT c.title, c.content, c.content_type, s.label
162
+ FROM chunks c JOIN sources s ON s.id = c.source_id
163
+ WHERE c.source_id = ? ORDER BY c.rowid`, [sourceId]);
164
+ } finally { db.close(); }
165
+ }
166
+
167
+ function apiSearchAll(query) {
168
+ const results = [];
169
+ for (const f of listDBFiles(CONTENT_DIR)) {
170
+ const db = openDB(f.path);
171
+ if (!db) continue;
172
+ try {
173
+ const rows = safeAll(db,
174
+ `SELECT c.title, c.content, c.content_type, s.label,
175
+ bm25(chunks, 5.0, 1.0) AS rank,
176
+ highlight(chunks, 1, '«', '»') AS highlighted
177
+ FROM chunks c JOIN sources s ON s.id = c.source_id
178
+ WHERE chunks MATCH ?
179
+ ORDER BY rank LIMIT 10`, [query]);
180
+ results.push(...rows.map(r => ({ ...r, dbHash: f.name.replace(".db","") })));
181
+ } finally { db.close(); }
182
+ }
183
+ if (results.length > 0) {
184
+ return results.sort((a, b) => a.rank - b.rank).slice(0, 30);
185
+ }
186
+ // Fallback: LIKE search across content + session events
187
+ const likeResults = [];
188
+ const likePattern = `%${query}%`;
189
+ for (const f of listDBFiles(CONTENT_DIR)) {
190
+ const db = openDB(f.path);
191
+ if (!db) continue;
192
+ try {
193
+ const rows = safeAll(db,
194
+ `SELECT c.title, c.content, c.content_type, s.label
195
+ FROM chunks c JOIN sources s ON s.id = c.source_id
196
+ WHERE c.content LIKE ? LIMIT 10`, [likePattern]);
197
+ likeResults.push(...rows.map(r => ({ ...r, rank: 0, highlighted: null, dbHash: f.name.replace(".db","") })));
198
+ } finally { db.close(); }
199
+ }
200
+ for (const f of listDBFiles(SESSION_DIR)) {
201
+ const db = openDB(f.path);
202
+ if (!db) continue;
203
+ try {
204
+ const rows = safeAll(db,
205
+ `SELECT se.type as title, se.data as content, 'session' as content_type,
206
+ sm.project_dir as label
207
+ FROM session_events se
208
+ LEFT JOIN session_meta sm ON se.session_id = sm.session_id
209
+ WHERE se.data LIKE ? LIMIT 10`, [likePattern]);
210
+ likeResults.push(...rows.map(r => ({ ...r, rank: 0, highlighted: null, dbHash: "session:" + f.name.replace(".db","").slice(0, 8) })));
211
+ } finally { db.close(); }
212
+ }
213
+ return likeResults.slice(0, 20);
214
+ }
215
+
216
+ function apiSessionDBs() {
217
+ return listDBFiles(SESSION_DIR).map(f => {
218
+ const db = openDB(f.path);
219
+ if (!db) return { hash: f.name.replace(".db",""), size: formatBytes(f.size), sessions: [] };
220
+ try {
221
+ const sessions = safeAll(db,
222
+ `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count
223
+ FROM session_meta ORDER BY started_at DESC`);
224
+ return {
225
+ hash: f.name.replace(".db",""), size: formatBytes(f.size), sizeBytes: f.size,
226
+ sessions: sessions.map(s => ({ id: s.session_id, projectDir: s.project_dir,
227
+ startedAt: s.started_at, lastEventAt: s.last_event_at,
228
+ eventCount: s.event_count, compactCount: s.compact_count })),
229
+ };
230
+ } finally { db.close(); }
231
+ });
232
+ }
233
+
234
+ function apiSessionEvents(dbHash, sessionId) {
235
+ const db = openDB(join(SESSION_DIR, `${dbHash}.db`));
236
+ if (!db) return { events: [], resume: null };
237
+ try {
238
+ const events = safeAll(db,
239
+ `SELECT id, type, category, priority, data, source_hook, created_at
240
+ FROM session_events WHERE session_id = ? ORDER BY id ASC LIMIT 500`, [sessionId]);
241
+ const resume = safeGet(db,
242
+ `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`, [sessionId]);
243
+ return { events, resume };
244
+ } finally { db.close(); }
245
+ }
246
+
247
+ function apiDeleteSource(dbHash, sourceId) {
248
+ try {
249
+ const dbPath = join(CONTENT_DIR, `${dbHash}.db`);
250
+ const db = isBun ? new Database(dbPath) : new Database(dbPath);
251
+ db.prepare("DELETE FROM chunks WHERE source_id = ?").run(sourceId);
252
+ try { db.prepare("DELETE FROM chunks_trigram WHERE source_id = ?").run(sourceId); } catch {}
253
+ db.prepare("DELETE FROM sources WHERE id = ?").run(sourceId);
254
+ db.close();
255
+ return { ok: true };
256
+ } catch (e) { return { ok: false, error: e.message }; }
257
+ }
258
+
259
+ function apiAnalytics() {
260
+ const sessionDurations = queryAllSessionDBs(db =>
261
+ safeAll(db, `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count,
262
+ ROUND((julianday(last_event_at) - julianday(started_at)) * 24 * 60, 1) as duration_min
263
+ FROM session_meta WHERE started_at IS NOT NULL AND last_event_at IS NOT NULL
264
+ ORDER BY started_at DESC LIMIT 50`)
265
+ );
266
+ const sessionsByDate = queryAllSessionDBs(db =>
267
+ safeAll(db, `SELECT date(started_at) as date, COUNT(*) as count,
268
+ SUM(event_count) as events, SUM(compact_count) as compacts
269
+ FROM session_meta WHERE started_at IS NOT NULL
270
+ GROUP BY date(started_at) ORDER BY date`)
271
+ );
272
+ const toolUsage = queryAllSessionDBs(db =>
273
+ safeAll(db, `SELECT
274
+ CASE
275
+ WHEN type = 'file_read' THEN 'Read'
276
+ WHEN type = 'file_write' THEN 'Write/Edit'
277
+ WHEN type = 'file_glob' THEN 'Glob'
278
+ WHEN type = 'file_search' THEN 'Grep'
279
+ WHEN type = 'mcp' THEN 'context-mode'
280
+ WHEN type = 'git' THEN 'Git'
281
+ WHEN type = 'subagent' THEN 'Agent'
282
+ WHEN type = 'task' THEN 'Task'
283
+ WHEN type = 'error_tool' THEN 'Error'
284
+ ELSE type
285
+ END as tool, COUNT(*) as count
286
+ FROM session_events
287
+ WHERE type NOT IN ('rule', 'rule_content', 'user_prompt', 'intent', 'data', 'role', 'cwd')
288
+ GROUP BY tool ORDER BY count DESC`)
289
+ );
290
+ const mcpTools = queryAllSessionDBs(db =>
291
+ safeAll(db, `SELECT
292
+ CASE
293
+ WHEN data LIKE 'batch_execute%' THEN 'batch_execute'
294
+ WHEN data LIKE 'execute_file%' THEN 'execute_file'
295
+ WHEN data LIKE 'execute%' THEN 'execute'
296
+ WHEN data LIKE 'search%' THEN 'search'
297
+ WHEN data LIKE 'index%' THEN 'index'
298
+ WHEN data LIKE 'fetch%' THEN 'fetch_and_index'
299
+ WHEN data LIKE 'stats%' THEN 'stats'
300
+ WHEN data LIKE 'purge%' THEN 'purge'
301
+ ELSE substr(data, 1, 20)
302
+ END as tool, COUNT(*) as count
303
+ FROM session_events WHERE type = 'mcp'
304
+ GROUP BY tool ORDER BY count DESC`)
305
+ );
306
+ const readWriteRatio = queryAllSessionDBs(db =>
307
+ safeAll(db, `SELECT
308
+ SUM(CASE WHEN type = 'file_read' THEN 1 ELSE 0 END) as reads,
309
+ SUM(CASE WHEN type = 'file_write' THEN 1 ELSE 0 END) as writes,
310
+ SUM(CASE WHEN type IN ('file_read', 'file_write', 'file', 'file_glob', 'file_search') THEN 1 ELSE 0 END) as total_file_ops
311
+ FROM session_events`)
312
+ );
313
+ const errors = queryAllSessionDBs(db =>
314
+ safeAll(db, `SELECT data as detail, created_at, session_id FROM session_events
315
+ WHERE type = 'error_tool' OR type = 'error' ORDER BY created_at DESC LIMIT 20`)
316
+ );
317
+ const fileActivity = queryAllSessionDBs(db =>
318
+ safeAll(db, `SELECT data as file, type as op, COUNT(*) as count FROM session_events
319
+ WHERE type IN ('file_read', 'file_write', 'file') AND data != ''
320
+ GROUP BY data ORDER BY count DESC LIMIT 20`)
321
+ );
322
+ const workModes = queryAllSessionDBs(db =>
323
+ safeAll(db, `SELECT data as mode, COUNT(*) as count
324
+ FROM session_events WHERE type = 'intent' AND data != ''
325
+ GROUP BY data ORDER BY count DESC`)
326
+ );
327
+ const timeToFirstCommit = queryAllSessionDBs(db =>
328
+ safeAll(db, `SELECT sm.session_id, sm.started_at,
329
+ MIN(se.created_at) as first_commit_at,
330
+ ROUND((julianday(MIN(se.created_at)) - julianday(sm.started_at)) * 24 * 60, 1) as minutes_to_commit
331
+ FROM session_meta sm
332
+ JOIN session_events se ON se.session_id = sm.session_id
333
+ WHERE se.type = 'git' AND se.data = 'commit'
334
+ GROUP BY sm.session_id`)
335
+ );
336
+ const exploreExecRatio = queryAllSessionDBs(db =>
337
+ safeAll(db, `SELECT
338
+ SUM(CASE WHEN type IN ('file_read', 'file_glob', 'file_search') THEN 1 ELSE 0 END) as explore,
339
+ SUM(CASE WHEN type IN ('file_write') THEN 1 ELSE 0 END) as execute,
340
+ COUNT(*) as total
341
+ FROM session_events WHERE type IN ('file_read', 'file_glob', 'file_search', 'file_write')`)
342
+ );
343
+ const reworkData = queryAllSessionDBs(db =>
344
+ safeAll(db, `SELECT se.session_id, se.data as file, COUNT(*) as edit_count
345
+ FROM session_events se
346
+ WHERE se.type IN ('file_write', 'file_read') AND se.data != ''
347
+ GROUP BY se.session_id, se.data HAVING edit_count > 1
348
+ ORDER BY edit_count DESC LIMIT 20`)
349
+ );
350
+ const gitActivity = queryAllSessionDBs(db =>
351
+ safeAll(db, `SELECT se.data as action, se.created_at, se.session_id,
352
+ sm.project_dir, sm.started_at as session_start
353
+ FROM session_events se
354
+ JOIN session_meta sm ON se.session_id = sm.session_id
355
+ WHERE se.type = 'git' ORDER BY se.created_at DESC LIMIT 20`)
356
+ );
357
+ const rawSubagents = queryAllSessionDBs(db =>
358
+ safeAll(db, `SELECT data as task, created_at, session_id FROM session_events
359
+ WHERE type = 'subagent' ORDER BY created_at ASC`)
360
+ );
361
+ const bursts = [];
362
+ let currentBurst = [];
363
+ for (const s of rawSubagents) {
364
+ if (currentBurst.length === 0) { currentBurst.push(s); continue; }
365
+ const last = currentBurst[currentBurst.length - 1];
366
+ const gap = (new Date(s.created_at) - new Date(last.created_at)) / 1000;
367
+ if (gap <= 30) { currentBurst.push(s); }
368
+ else { if (currentBurst.length > 0) bursts.push([...currentBurst]); currentBurst = [s]; }
369
+ }
370
+ if (currentBurst.length > 0) bursts.push(currentBurst);
371
+ const parallelBursts = bursts.filter(b => b.length >= 2);
372
+ const subagents = {
373
+ total: rawSubagents.length,
374
+ bursts: parallelBursts.length,
375
+ maxConcurrent: bursts.reduce((max, b) => Math.max(max, b.length), 0),
376
+ parallelCount: parallelBursts.reduce((a, b) => a + b.length, 0),
377
+ sequentialCount: rawSubagents.length - parallelBursts.reduce((a, b) => a + b.length, 0),
378
+ timeSavedMin: parallelBursts.reduce((a, b) => a + (b.length - 1) * 2, 0),
379
+ burstDetails: parallelBursts.map(b => ({ size: b.length, time: b[0].created_at })),
380
+ };
381
+ const projectActivity = queryAllSessionDBs(db =>
382
+ safeAll(db, `SELECT project_dir, COUNT(*) as sessions, SUM(event_count) as events,
383
+ SUM(compact_count) as compacts
384
+ FROM session_meta WHERE project_dir IS NOT NULL
385
+ GROUP BY project_dir ORDER BY events DESC LIMIT 10`)
386
+ );
387
+ const hourlyPattern = queryAllSessionDBs(db =>
388
+ safeAll(db, `SELECT CAST(strftime('%H', created_at) AS INTEGER) as hour, COUNT(*) as count
389
+ FROM session_events WHERE created_at IS NOT NULL
390
+ GROUP BY hour ORDER BY hour`)
391
+ );
392
+ const weeklyTrend = queryAllSessionDBs(db =>
393
+ safeAll(db, `SELECT strftime('%Y-W%W', started_at) as week, COUNT(*) as sessions,
394
+ SUM(event_count) as events
395
+ FROM session_meta WHERE started_at IS NOT NULL
396
+ GROUP BY week ORDER BY week`)
397
+ );
398
+ const tasks = queryAllSessionDBs(db =>
399
+ safeAll(db, `SELECT substr(data, 1, 100) as task, created_at FROM session_events
400
+ WHERE type = 'task' ORDER BY created_at DESC LIMIT 20`)
401
+ );
402
+ const prompts = queryAllSessionDBs(db =>
403
+ safeAll(db, `SELECT substr(data, 1, 100) as prompt, created_at, session_id FROM session_events
404
+ WHERE type = 'user_prompt' ORDER BY created_at DESC LIMIT 20`)
405
+ );
406
+
407
+ const rw = readWriteRatio.reduce((a, b) => ({
408
+ reads: (a.reads || 0) + (b.reads || 0), writes: (a.writes || 0) + (b.writes || 0),
409
+ total_file_ops: (a.total_file_ops || 0) + (b.total_file_ops || 0),
410
+ }), { reads: 0, writes: 0, total_file_ops: 0 });
411
+ const totalEvents = toolUsage.reduce((a, b) => a + b.count, 0);
412
+ const totalErrors = errors.length;
413
+ const totalCompacts = sessionDurations.reduce((a, b) => a + (b.compact_count || 0), 0);
414
+ const sessionsWithCompact = sessionDurations.filter(s => s.compact_count > 0).length;
415
+
416
+ // ── New metric queries ──────────────────────────────────
417
+
418
+ // 1. Tool Mastery Curve — weekly error rate trend
419
+ const masteryTrend = queryAllSessionDBs(db =>
420
+ safeAll(db, `SELECT strftime('%Y-W%W', created_at) as week,
421
+ SUM(CASE WHEN type = 'error_tool' THEN 1 ELSE 0 END) as errors,
422
+ COUNT(*) as total,
423
+ ROUND(100.0 * SUM(CASE WHEN type = 'error_tool' THEN 1 ELSE 0 END) / COUNT(*), 1) as error_rate
424
+ FROM session_events WHERE created_at IS NOT NULL
425
+ GROUP BY week ORDER BY week`)
426
+ );
427
+
428
+ // 2. Personal Commit Rate — commits per session
429
+ const commitRate = queryAllSessionDBs(db =>
430
+ safeAll(db, `SELECT sm.session_id, sm.project_dir,
431
+ SUM(CASE WHEN se.type = 'git' AND se.data = 'commit' THEN 1 ELSE 0 END) as commits
432
+ FROM session_meta sm
433
+ LEFT JOIN session_events se ON se.session_id = sm.session_id
434
+ GROUP BY sm.session_id`)
435
+ );
436
+
437
+ // 3. Sandbox Adoption — context-mode MCP tool usage vs total
438
+ const sandboxAdoption = queryAllSessionDBs(db =>
439
+ safeAll(db, `SELECT
440
+ SUM(CASE WHEN type = 'mcp' THEN 1 ELSE 0 END) as sandbox_calls,
441
+ COUNT(*) as total_calls
442
+ FROM session_events
443
+ WHERE type NOT IN ('rule', 'rule_content', 'user_prompt', 'intent', 'data', 'role', 'cwd')`)
444
+ );
445
+
446
+ // 4. CLAUDE.md Freshness — rule files loaded, how many distinct
447
+ const rulesFreshness = queryAllSessionDBs(db =>
448
+ safeAll(db, `SELECT data as rule_path, MAX(created_at) as last_seen, COUNT(*) as load_count
449
+ FROM session_events WHERE type = 'rule' AND data != ''
450
+ GROUP BY data ORDER BY last_seen DESC`)
451
+ );
452
+
453
+ // 5. Edit-Test Cycle — write followed by error patterns
454
+ const editTestCycles = queryAllSessionDBs(db =>
455
+ safeAll(db, `SELECT se1.session_id, COUNT(*) as cycles
456
+ FROM session_events se1
457
+ JOIN session_events se2 ON se1.session_id = se2.session_id AND se2.id > se1.id
458
+ AND se2.id = (SELECT MIN(id) FROM session_events WHERE id > se1.id AND session_id = se1.session_id)
459
+ WHERE se1.type = 'file_write' AND se2.type = 'error_tool'
460
+ GROUP BY se1.session_id`)
461
+ );
462
+
463
+ // 6. Bug-Fix Ratio — derived from workModes (already queried above)
464
+
465
+ // ── Derived aggregates for new metrics ──────────────────
466
+ const sandboxAgg = sandboxAdoption.reduce((a, b) => ({
467
+ sandbox_calls: (a.sandbox_calls || 0) + (b.sandbox_calls || 0),
468
+ total_calls: (a.total_calls || 0) + (b.total_calls || 0),
469
+ }), { sandbox_calls: 0, total_calls: 0 });
470
+
471
+ return {
472
+ totals: {
473
+ totalSessions: sessionDurations.length, totalEvents,
474
+ avgSessionMin: sessionDurations.length > 0
475
+ ? Math.round(sessionDurations.reduce((a, b) => a + (b.duration_min || 0), 0) / sessionDurations.length) : 0,
476
+ totalErrors,
477
+ errorRate: totalEvents > 0 ? Math.round(1000 * totalErrors / totalEvents) / 10 : 0,
478
+ totalCompacts,
479
+ compactRate: sessionDurations.length > 0 ? Math.round(100 * sessionsWithCompact / sessionDurations.length) : 0,
480
+ reads: rw.reads, writes: rw.writes,
481
+ readWriteRatio: rw.writes > 0 ? Math.round(10 * rw.reads / rw.writes) / 10 : rw.reads,
482
+ totalFileOps: rw.total_file_ops, totalSubagents: subagents.total,
483
+ totalTasks: tasks.length, totalPrompts: prompts.length,
484
+ promptsPerSession: sessionDurations.length > 0
485
+ ? Math.round(10 * prompts.length / sessionDurations.length) / 10 : 0,
486
+ uniqueProjects: projectActivity.length,
487
+ totalCommits: commitRate.reduce((a, b) => a + (b.commits || 0), 0),
488
+ commitsPerSession: sessionDurations.length > 0
489
+ ? Math.round(10 * commitRate.reduce((a, b) => a + (b.commits || 0), 0) / sessionDurations.length) / 10 : 0,
490
+ sandboxRate: sandboxAgg.total_calls > 0
491
+ ? Math.round(1000 * sandboxAgg.sandbox_calls / sandboxAgg.total_calls) / 10 : 0,
492
+ totalRules: rulesFreshness.length,
493
+ totalEditTestCycles: editTestCycles.reduce((a, b) => a + (b.cycles || 0), 0),
494
+ },
495
+ sessionsByDate: mergeByKey(sessionsByDate, "date", (a, b) => ({
496
+ date: a.date, count: a.count + b.count, events: a.events + b.events, compacts: a.compacts + b.compacts
497
+ })),
498
+ sessionDurations,
499
+ toolUsage: mergeByKey(toolUsage, "tool", (a, b) => ({ tool: a.tool, count: a.count + b.count })).sort((a, b) => b.count - a.count),
500
+ mcpTools: mergeByKey(mcpTools, "tool", (a, b) => ({ tool: a.tool, count: a.count + b.count })).sort((a, b) => b.count - a.count),
501
+ errors, fileActivity: mergeByKey(fileActivity, "file", (a, b) => ({ file: a.file, op: a.op, count: a.count + b.count })).sort((a, b) => b.count - a.count).slice(0, 15),
502
+ workModes: mergeByKey(workModes, "mode", (a, b) => ({ mode: a.mode, count: a.count + b.count })).sort((a, b) => b.count - a.count),
503
+ timeToFirstCommit,
504
+ exploreExecRatio: exploreExecRatio.reduce((a, b) => ({ explore: (a.explore||0)+(b.explore||0), execute: (a.execute||0)+(b.execute||0), total: (a.total||0)+(b.total||0) }), { explore: 0, execute: 0, total: 0 }),
505
+ reworkData, gitActivity, subagents,
506
+ projectActivity: mergeByKey(projectActivity, "project_dir", (a, b) => ({
507
+ project_dir: a.project_dir, sessions: a.sessions + b.sessions, events: a.events + b.events, compacts: (a.compacts||0)+(b.compacts||0)
508
+ })).sort((a, b) => b.events - a.events),
509
+ hourlyPattern: mergeByKey(hourlyPattern, "hour", (a, b) => ({ hour: a.hour, count: a.count + b.count })),
510
+ weeklyTrend: mergeByKey(weeklyTrend, "week", (a, b) => ({ week: a.week, sessions: a.sessions + b.sessions, events: a.events + b.events })),
511
+ tasks, prompts,
512
+ masteryTrend: mergeByKey(masteryTrend, "week", (a, b) => ({
513
+ week: a.week, errors: a.errors + b.errors, total: a.total + b.total,
514
+ error_rate: (a.total + b.total) > 0 ? Math.round(1000 * (a.errors + b.errors) / (a.total + b.total)) / 10 : 0,
515
+ })),
516
+ commitRate,
517
+ sandboxAdoption: sandboxAgg,
518
+ rulesFreshness,
519
+ editTestCycles,
520
+ };
521
+ }
522
+
523
+ // ── Router ───────────────────────────────────────────────
524
+
525
+ function route(method, pathname, params) {
526
+ if (pathname === "/api/overview") return apiOverview();
527
+ if (pathname === "/api/analytics") return apiAnalytics();
528
+ if (pathname === "/api/content") return apiContentDBs();
529
+ if (pathname === "/api/sessions") return apiSessionDBs();
530
+
531
+ if (pathname.startsWith("/api/content/") && pathname.includes("/chunks/")) {
532
+ const parts = pathname.split("/");
533
+ return apiSourceChunks(parts[3], Number(parts[5]));
534
+ }
535
+ if (pathname === "/api/search") {
536
+ const q = params.get("q");
537
+ if (!q) return { error: "missing q param" };
538
+ return apiSearchAll(q);
539
+ }
540
+ if (pathname.startsWith("/api/sessions/") && pathname.includes("/events/")) {
541
+ const parts = pathname.split("/");
542
+ return apiSessionEvents(parts[3], decodeURIComponent(parts[5]));
543
+ }
544
+ if (method === "DELETE" && pathname.startsWith("/api/content/")) {
545
+ const parts = pathname.split("/");
546
+ return apiDeleteSource(parts[3], Number(parts[5]));
547
+ }
548
+ return null;
549
+ }
550
+
551
+ // ── Static file serving ──────────────────────────────────
552
+
553
+ const MIME = {
554
+ ".html": "text/html", ".js": "application/javascript", ".css": "text/css",
555
+ ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
556
+ ".woff2": "font/woff2", ".woff": "font/woff", ".ico": "image/x-icon",
557
+ };
558
+
559
+ function serveStaticFile(pathname) {
560
+ const ext = extname(pathname);
561
+ const filePath = join(DIST_DIR, pathname);
562
+ try {
563
+ const content = readFileSync(filePath);
564
+ return { content, type: MIME[ext] || "application/octet-stream" };
565
+ } catch { return null; }
566
+ }
567
+
568
+ // ── Server (dual runtime) ────────────────────────────────
569
+
570
+ const indexHTML = readFileSync(join(DIST_DIR, "index.html"), "utf8");
571
+
572
+ if (isBun) {
573
+ // Bun: use Bun.serve
574
+ Bun.serve({
575
+ port: PORT,
576
+ hostname: "127.0.0.1",
577
+ fetch(req) {
578
+ const url = new URL(req.url);
579
+ const data = route(req.method, url.pathname, url.searchParams);
580
+ if (data !== null) {
581
+ return new Response(JSON.stringify(data), {
582
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
583
+ });
584
+ }
585
+ if (url.pathname.startsWith("/assets/") || url.pathname.match(/\.\w{2,4}$/)) {
586
+ const file = serveStaticFile(url.pathname);
587
+ if (file) return new Response(file.content, {
588
+ headers: { "Content-Type": file.type, "Cache-Control": "public, max-age=31536000" },
589
+ });
590
+ }
591
+ return new Response(indexHTML, { headers: { "Content-Type": "text/html" } });
592
+ },
593
+ });
594
+ } else {
595
+ // Node: use http.createServer
596
+ const server = createHttpServer((req, res) => {
597
+ const url = new URL(req.url, `http://localhost:${PORT}`);
598
+ res.setHeader("Access-Control-Allow-Origin", "*");
599
+ res.setHeader("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS");
600
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
601
+
602
+ const data = route(req.method, url.pathname, url.searchParams);
603
+ if (data !== null) {
604
+ res.writeHead(200, { "Content-Type": "application/json" });
605
+ res.end(JSON.stringify(data));
606
+ return;
607
+ }
608
+ if (url.pathname.startsWith("/assets/") || url.pathname.match(/\.\w{2,4}$/)) {
609
+ const file = serveStaticFile(url.pathname);
610
+ if (file) {
611
+ res.writeHead(200, { "Content-Type": file.type, "Cache-Control": "public, max-age=31536000" });
612
+ res.end(file.content);
613
+ return;
614
+ }
615
+ }
616
+ res.writeHead(200, { "Content-Type": "text/html" });
617
+ res.end(indexHTML);
618
+ });
619
+ server.listen(PORT, "127.0.0.1");
620
+ }
621
+
622
+ console.log(`\n context-mode Insight`);
623
+ console.log(` http://localhost:${PORT}`);
624
+ console.log(` Runtime: ${isBun ? "Bun" : "Node.js"}\n`);