context-mode 1.0.80 → 1.0.82
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/cli.js +57 -0
- package/build/server.js +94 -1
- package/cli.bundle.mjs +106 -99
- package/insight/components.json +25 -0
- package/insight/index.html +13 -0
- package/insight/package.json +54 -0
- package/insight/server.mjs +624 -0
- package/insight/src/components/analytics.tsx +112 -0
- package/insight/src/components/ui/badge.tsx +52 -0
- package/insight/src/components/ui/button.tsx +58 -0
- package/insight/src/components/ui/card.tsx +103 -0
- package/insight/src/components/ui/chart.tsx +371 -0
- package/insight/src/components/ui/collapsible.tsx +19 -0
- package/insight/src/components/ui/input.tsx +20 -0
- package/insight/src/components/ui/progress.tsx +83 -0
- package/insight/src/components/ui/scroll-area.tsx +55 -0
- package/insight/src/components/ui/separator.tsx +23 -0
- package/insight/src/components/ui/table.tsx +114 -0
- package/insight/src/components/ui/tabs.tsx +82 -0
- package/insight/src/components/ui/tooltip.tsx +64 -0
- package/insight/src/lib/api.ts +71 -0
- package/insight/src/lib/utils.ts +6 -0
- package/insight/src/main.tsx +22 -0
- package/insight/src/routeTree.gen.ts +189 -0
- package/insight/src/router.tsx +19 -0
- package/insight/src/routes/__root.tsx +55 -0
- package/insight/src/routes/enterprise.tsx +316 -0
- package/insight/src/routes/index.tsx +914 -0
- package/insight/src/routes/knowledge.tsx +221 -0
- package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +137 -0
- package/insight/src/routes/search.tsx +97 -0
- package/insight/src/routes/sessions.tsx +179 -0
- package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +181 -0
- package/insight/src/styles.css +104 -0
- package/insight/tsconfig.json +29 -0
- package/insight/vite.config.ts +19 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- 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`);
|