cerebro-code 0.1.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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/cli.js +769 -0
- package/dist/web/assets/index-DgAd48_S.css +1 -0
- package/dist/web/assets/index-u0QaGLGb.js +40 -0
- package/dist/web/index.html +13 -0
- package/package.json +44 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { serve } from "@hono/node-server";
|
|
5
|
+
|
|
6
|
+
// src/ingest/index.ts
|
|
7
|
+
import { statSync as statSync2 } from "fs";
|
|
8
|
+
import { homedir as homedir2 } from "os";
|
|
9
|
+
|
|
10
|
+
// src/cost/pricing.ts
|
|
11
|
+
var RATES = {
|
|
12
|
+
opus: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
13
|
+
sonnet: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
14
|
+
haiku: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }
|
|
15
|
+
};
|
|
16
|
+
function classifyModel(name) {
|
|
17
|
+
const n = name.toLowerCase();
|
|
18
|
+
if (n.includes("haiku")) return "haiku";
|
|
19
|
+
if (n.includes("sonnet")) return "sonnet";
|
|
20
|
+
if (n.includes("opus")) return "opus";
|
|
21
|
+
return "opus";
|
|
22
|
+
}
|
|
23
|
+
function dominantClass(models) {
|
|
24
|
+
if (models.length === 0) return "opus";
|
|
25
|
+
const counts = { opus: 0, sonnet: 0, haiku: 0 };
|
|
26
|
+
for (const m of models) counts[classifyModel(m)] += 1;
|
|
27
|
+
let best = classifyModel(models[0]);
|
|
28
|
+
let bestCount = counts[best];
|
|
29
|
+
for (const cls of ["opus", "sonnet", "haiku"]) {
|
|
30
|
+
if (counts[cls] > bestCount) {
|
|
31
|
+
best = cls;
|
|
32
|
+
bestCount = counts[cls];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return best;
|
|
36
|
+
}
|
|
37
|
+
function computeCost(usage, models) {
|
|
38
|
+
const r = RATES[dominantClass(models)];
|
|
39
|
+
return (usage.input * r.input + usage.output * r.output + usage.cacheRead * r.cacheRead + usage.cacheWrite * r.cacheWrite) / 1e6;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/ingest/paths.ts
|
|
43
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
44
|
+
import { homedir } from "os";
|
|
45
|
+
import { basename, join } from "path";
|
|
46
|
+
function projectsRoot() {
|
|
47
|
+
return join(homedir(), ".claude", "projects");
|
|
48
|
+
}
|
|
49
|
+
function decodeRepoKey(repoKey) {
|
|
50
|
+
const homePrefix = /^-Users-[^-]+-/;
|
|
51
|
+
let rest = repoKey;
|
|
52
|
+
if (homePrefix.test(repoKey)) {
|
|
53
|
+
rest = repoKey.replace(homePrefix, "");
|
|
54
|
+
} else if (rest.startsWith("-")) {
|
|
55
|
+
rest = rest.slice(1);
|
|
56
|
+
}
|
|
57
|
+
const readable = rest.replace(/-/g, "/").trim();
|
|
58
|
+
return readable.length > 0 ? readable : repoKey;
|
|
59
|
+
}
|
|
60
|
+
function listSessionFiles() {
|
|
61
|
+
const root = projectsRoot();
|
|
62
|
+
if (!existsSync(root)) return [];
|
|
63
|
+
const out = [];
|
|
64
|
+
let projectDirs;
|
|
65
|
+
try {
|
|
66
|
+
projectDirs = readdirSync(root);
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
for (const repoKey of projectDirs) {
|
|
71
|
+
const repoDir = join(root, repoKey);
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
if (!statSync(repoDir).isDirectory()) continue;
|
|
75
|
+
entries = readdirSync(repoDir);
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const repo = decodeRepoKey(repoKey);
|
|
80
|
+
for (const name of entries) {
|
|
81
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
82
|
+
const filePath = join(repoDir, name);
|
|
83
|
+
try {
|
|
84
|
+
if (!statSync(filePath).isFile()) continue;
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
out.push({
|
|
89
|
+
id: basename(name, ".jsonl"),
|
|
90
|
+
repoKey,
|
|
91
|
+
repo,
|
|
92
|
+
filePath
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function hasSubagents(repoKey, id) {
|
|
99
|
+
const dir = join(projectsRoot(), repoKey, id, "subagents");
|
|
100
|
+
try {
|
|
101
|
+
return statSync(dir).isDirectory();
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/ingest/parser.ts
|
|
108
|
+
import { readFileSync } from "fs";
|
|
109
|
+
var TITLE_MAX = 80;
|
|
110
|
+
var PREVIEW_MAX = 280;
|
|
111
|
+
function emptyUsage() {
|
|
112
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
113
|
+
}
|
|
114
|
+
function num(v) {
|
|
115
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
116
|
+
}
|
|
117
|
+
function collapse(s) {
|
|
118
|
+
return s.replace(/\s+/g, " ").trim();
|
|
119
|
+
}
|
|
120
|
+
function truncate(s, max) {
|
|
121
|
+
const c = collapse(s);
|
|
122
|
+
return c.length > max ? c.slice(0, max - 1) + "\u2026" : c;
|
|
123
|
+
}
|
|
124
|
+
function textFromContent(content) {
|
|
125
|
+
if (typeof content === "string") return content;
|
|
126
|
+
if (!Array.isArray(content)) return "";
|
|
127
|
+
const parts = [];
|
|
128
|
+
for (const item of content) {
|
|
129
|
+
if (typeof item === "string") {
|
|
130
|
+
parts.push(item);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (item && typeof item === "object") {
|
|
134
|
+
const it = item;
|
|
135
|
+
if (typeof it.text === "string") parts.push(it.text);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return parts.join(" ");
|
|
139
|
+
}
|
|
140
|
+
function toolUsePreview(block) {
|
|
141
|
+
const name = typeof block.name === "string" ? block.name : "tool";
|
|
142
|
+
const input = block.input;
|
|
143
|
+
if (input && typeof input === "object") {
|
|
144
|
+
const obj = input;
|
|
145
|
+
const hintKey = ["command", "file_path", "path", "query", "skill", "prompt", "pattern", "url"].find(
|
|
146
|
+
(k) => typeof obj[k] === "string" && obj[k].length > 0
|
|
147
|
+
);
|
|
148
|
+
if (hintKey) return truncate(`${name}: ${String(obj[hintKey])}`, PREVIEW_MAX);
|
|
149
|
+
const keys = Object.keys(obj);
|
|
150
|
+
if (keys.length > 0) return truncate(`${name}(${keys.join(", ")})`, PREVIEW_MAX);
|
|
151
|
+
}
|
|
152
|
+
return name;
|
|
153
|
+
}
|
|
154
|
+
function isRealUserMessage(rec) {
|
|
155
|
+
if (rec.isMeta === true || rec.isCompactSummary === true) return false;
|
|
156
|
+
const content = rec.message?.content;
|
|
157
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
158
|
+
if (Array.isArray(content)) {
|
|
159
|
+
return content.some(
|
|
160
|
+
(item) => item && typeof item === "object" && item.type === "text"
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
function parseLine(line) {
|
|
166
|
+
const trimmed = line.trim();
|
|
167
|
+
if (trimmed.length === 0) return null;
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(trimmed);
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function* sessionRecords(filePath) {
|
|
175
|
+
let raw;
|
|
176
|
+
try {
|
|
177
|
+
raw = readFileSync(filePath, "utf8");
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let start = 0;
|
|
182
|
+
while (start <= raw.length) {
|
|
183
|
+
let nl = raw.indexOf("\n", start);
|
|
184
|
+
if (nl === -1) nl = raw.length;
|
|
185
|
+
const rec = parseLine(raw.slice(start, nl));
|
|
186
|
+
if (rec) yield rec;
|
|
187
|
+
start = nl + 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function parseSession(filePath) {
|
|
191
|
+
const usage = emptyUsage();
|
|
192
|
+
const modelSet = /* @__PURE__ */ new Set();
|
|
193
|
+
let firstTs = null;
|
|
194
|
+
let lastTs = null;
|
|
195
|
+
let userMsgs = 0;
|
|
196
|
+
let assistantMsgs = 0;
|
|
197
|
+
let gitBranch = null;
|
|
198
|
+
let cwd = null;
|
|
199
|
+
let aiTitle = null;
|
|
200
|
+
let firstUserText = null;
|
|
201
|
+
for (const rec of sessionRecords(filePath)) {
|
|
202
|
+
const ts = rec.timestamp;
|
|
203
|
+
if (typeof ts === "string" && ts.length > 0) {
|
|
204
|
+
if (firstTs === null || ts < firstTs) firstTs = ts;
|
|
205
|
+
if (lastTs === null || ts > lastTs) lastTs = ts;
|
|
206
|
+
}
|
|
207
|
+
if (typeof rec.gitBranch === "string" && rec.gitBranch.length > 0) {
|
|
208
|
+
gitBranch = rec.gitBranch;
|
|
209
|
+
}
|
|
210
|
+
if (typeof rec.cwd === "string" && rec.cwd.length > 0) {
|
|
211
|
+
cwd = rec.cwd;
|
|
212
|
+
}
|
|
213
|
+
if (typeof rec.aiTitle === "string" && rec.aiTitle.trim().length > 0) {
|
|
214
|
+
aiTitle = rec.aiTitle.trim();
|
|
215
|
+
}
|
|
216
|
+
const type = rec.type;
|
|
217
|
+
if (type === "user" && isRealUserMessage(rec)) {
|
|
218
|
+
userMsgs += 1;
|
|
219
|
+
if (firstUserText === null) {
|
|
220
|
+
const t = collapse(textFromContent(rec.message?.content));
|
|
221
|
+
if (t.length > 0) firstUserText = t;
|
|
222
|
+
}
|
|
223
|
+
} else if (type === "assistant") {
|
|
224
|
+
assistantMsgs += 1;
|
|
225
|
+
const model = rec.message?.model;
|
|
226
|
+
if (typeof model === "string" && model.length > 0) modelSet.add(model);
|
|
227
|
+
const u = rec.message?.usage;
|
|
228
|
+
if (u && typeof u === "object") {
|
|
229
|
+
const uu = u;
|
|
230
|
+
usage.input += num(uu.input_tokens);
|
|
231
|
+
usage.output += num(uu.output_tokens);
|
|
232
|
+
usage.cacheRead += num(uu.cache_read_input_tokens);
|
|
233
|
+
usage.cacheWrite += num(uu.cache_creation_input_tokens);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const title = aiTitle ?? (firstUserText ? truncate(firstUserText, TITLE_MAX) : "(untitled)");
|
|
238
|
+
return {
|
|
239
|
+
firstTs,
|
|
240
|
+
lastTs,
|
|
241
|
+
userMsgs,
|
|
242
|
+
assistantMsgs,
|
|
243
|
+
models: [...modelSet],
|
|
244
|
+
usage,
|
|
245
|
+
gitBranch,
|
|
246
|
+
cwd,
|
|
247
|
+
title
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function appendTimelineItems(rec, items) {
|
|
251
|
+
const type = rec.type;
|
|
252
|
+
const ts = typeof rec.timestamp === "string" ? rec.timestamp : null;
|
|
253
|
+
const content = rec.message?.content;
|
|
254
|
+
if (type === "user") {
|
|
255
|
+
if (rec.isMeta === true) return;
|
|
256
|
+
if (Array.isArray(content)) {
|
|
257
|
+
for (const block of content) {
|
|
258
|
+
if (!block || typeof block !== "object") continue;
|
|
259
|
+
const b = block;
|
|
260
|
+
if (b.type === "tool_result") {
|
|
261
|
+
items.push({
|
|
262
|
+
ts,
|
|
263
|
+
role: "user",
|
|
264
|
+
kind: "tool_result",
|
|
265
|
+
preview: truncate(textFromContent(b.content) || "(tool result)", PREVIEW_MAX)
|
|
266
|
+
});
|
|
267
|
+
} else if (b.type === "text" && typeof b.text === "string") {
|
|
268
|
+
items.push({ ts, role: "user", kind: "text", preview: truncate(b.text, PREVIEW_MAX) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else if (typeof content === "string" && content.trim().length > 0) {
|
|
272
|
+
items.push({ ts, role: "user", kind: "text", preview: truncate(content, PREVIEW_MAX) });
|
|
273
|
+
}
|
|
274
|
+
} else if (type === "assistant") {
|
|
275
|
+
if (!Array.isArray(content)) return;
|
|
276
|
+
for (const block of content) {
|
|
277
|
+
if (!block || typeof block !== "object") continue;
|
|
278
|
+
const b = block;
|
|
279
|
+
const bt = b.type;
|
|
280
|
+
if (bt === "text" && typeof b.text === "string") {
|
|
281
|
+
if (b.text.trim().length === 0) continue;
|
|
282
|
+
items.push({ ts, role: "assistant", kind: "text", preview: truncate(b.text, PREVIEW_MAX) });
|
|
283
|
+
} else if (bt === "thinking" && typeof b.thinking === "string") {
|
|
284
|
+
if (b.thinking.trim().length === 0) continue;
|
|
285
|
+
items.push({ ts, role: "assistant", kind: "thinking", preview: truncate(b.thinking, PREVIEW_MAX) });
|
|
286
|
+
} else if (bt === "tool_use") {
|
|
287
|
+
items.push({ ts, role: "assistant", kind: "tool_use", preview: toolUsePreview(b) });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function buildTimeline(filePath) {
|
|
293
|
+
const items = [];
|
|
294
|
+
for (const rec of sessionRecords(filePath)) {
|
|
295
|
+
appendTimelineItems(rec, items);
|
|
296
|
+
}
|
|
297
|
+
return items;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/ingest/index.ts
|
|
301
|
+
var MS_PER_MIN = 6e4;
|
|
302
|
+
var MS_PER_DAY = 864e5;
|
|
303
|
+
var UNCLOSED_DAYS = 2;
|
|
304
|
+
var STALE_DAYS = 21;
|
|
305
|
+
function daysAgo(mtimeMs, now) {
|
|
306
|
+
return Math.max(0, (now - mtimeMs) / MS_PER_DAY);
|
|
307
|
+
}
|
|
308
|
+
function durationMinutes(firstTs, lastTs) {
|
|
309
|
+
if (!firstTs || !lastTs) return 0;
|
|
310
|
+
const a = Date.parse(firstTs);
|
|
311
|
+
const b = Date.parse(lastTs);
|
|
312
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
|
|
313
|
+
return Math.max(0, Math.round((b - a) / MS_PER_MIN));
|
|
314
|
+
}
|
|
315
|
+
function compareSessions(a, b, sort) {
|
|
316
|
+
switch (sort) {
|
|
317
|
+
case "cost":
|
|
318
|
+
return b.costUsd - a.costUsd;
|
|
319
|
+
case "duration":
|
|
320
|
+
return b.durationMin - a.durationMin;
|
|
321
|
+
case "messages":
|
|
322
|
+
return b.userMsgs + b.assistantMsgs - (a.userMsgs + a.assistantMsgs);
|
|
323
|
+
case "repo":
|
|
324
|
+
return a.repo.localeCompare(b.repo);
|
|
325
|
+
case "lastTs":
|
|
326
|
+
default: {
|
|
327
|
+
const at = a.lastTs ?? "";
|
|
328
|
+
const bt = b.lastTs ?? "";
|
|
329
|
+
return bt.localeCompare(at);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function repoFromCwd(cwd) {
|
|
334
|
+
if (!cwd) return null;
|
|
335
|
+
const home = homedir2();
|
|
336
|
+
if (cwd === home) return "~";
|
|
337
|
+
const rel = cwd.startsWith(home + "/") ? cwd.slice(home.length + 1) : cwd;
|
|
338
|
+
return rel.length > 0 ? rel : null;
|
|
339
|
+
}
|
|
340
|
+
async function buildIndex() {
|
|
341
|
+
const files = listSessionFiles();
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
const metas = [];
|
|
344
|
+
for (const f of files) {
|
|
345
|
+
let mtimeMs;
|
|
346
|
+
try {
|
|
347
|
+
mtimeMs = statSync2(f.filePath).mtimeMs;
|
|
348
|
+
} catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
let parsed;
|
|
352
|
+
try {
|
|
353
|
+
parsed = parseSession(f.filePath);
|
|
354
|
+
} catch {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const lastActiveDaysAgo = daysAgo(mtimeMs, now);
|
|
358
|
+
metas.push({
|
|
359
|
+
id: f.id,
|
|
360
|
+
repo: repoFromCwd(parsed.cwd) ?? f.repo,
|
|
361
|
+
repoKey: f.repoKey,
|
|
362
|
+
cwd: parsed.cwd,
|
|
363
|
+
title: parsed.title,
|
|
364
|
+
filePath: f.filePath,
|
|
365
|
+
models: parsed.models,
|
|
366
|
+
firstTs: parsed.firstTs,
|
|
367
|
+
lastTs: parsed.lastTs,
|
|
368
|
+
durationMin: durationMinutes(parsed.firstTs, parsed.lastTs),
|
|
369
|
+
userMsgs: parsed.userMsgs,
|
|
370
|
+
assistantMsgs: parsed.assistantMsgs,
|
|
371
|
+
usage: parsed.usage,
|
|
372
|
+
costUsd: computeCost(parsed.usage, parsed.models),
|
|
373
|
+
gitBranch: parsed.gitBranch,
|
|
374
|
+
hasSubagents: hasSubagents(f.repoKey, f.id),
|
|
375
|
+
unclosed: lastActiveDaysAgo < UNCLOSED_DAYS,
|
|
376
|
+
lastActiveDaysAgo: Math.round(lastActiveDaysAgo * 10) / 10,
|
|
377
|
+
activityType: null,
|
|
378
|
+
clusterId: null,
|
|
379
|
+
summary: null
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const byId = /* @__PURE__ */ new Map();
|
|
383
|
+
for (const m of metas) byId.set(m.id, m);
|
|
384
|
+
return {
|
|
385
|
+
overview() {
|
|
386
|
+
const byModel = {};
|
|
387
|
+
const repoSet = /* @__PURE__ */ new Set();
|
|
388
|
+
let totalCostUsd = 0;
|
|
389
|
+
let unclosed = 0;
|
|
390
|
+
let staleOver21d = 0;
|
|
391
|
+
let rangeStart = null;
|
|
392
|
+
let rangeEnd = null;
|
|
393
|
+
for (const m of metas) {
|
|
394
|
+
totalCostUsd += m.costUsd;
|
|
395
|
+
if (m.unclosed) unclosed += 1;
|
|
396
|
+
if (m.lastActiveDaysAgo > STALE_DAYS) staleOver21d += 1;
|
|
397
|
+
repoSet.add(m.repo);
|
|
398
|
+
for (const model of m.models) {
|
|
399
|
+
const cls = classifyModel(model);
|
|
400
|
+
byModel[cls] = (byModel[cls] ?? 0) + 1;
|
|
401
|
+
}
|
|
402
|
+
if (m.firstTs && (rangeStart === null || m.firstTs < rangeStart)) rangeStart = m.firstTs;
|
|
403
|
+
if (m.lastTs && (rangeEnd === null || m.lastTs > rangeEnd)) rangeEnd = m.lastTs;
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
sessions: metas.length,
|
|
407
|
+
repos: repoSet.size,
|
|
408
|
+
unclosed,
|
|
409
|
+
staleOver21d,
|
|
410
|
+
totalCostUsd: Math.round(totalCostUsd * 100) / 100,
|
|
411
|
+
rangeStart,
|
|
412
|
+
rangeEnd,
|
|
413
|
+
byModel
|
|
414
|
+
};
|
|
415
|
+
},
|
|
416
|
+
list(f) {
|
|
417
|
+
let rows = metas;
|
|
418
|
+
if (f.repo) rows = rows.filter((m) => m.repo === f.repo);
|
|
419
|
+
if (f.type) rows = rows.filter((m) => m.activityType === f.type);
|
|
420
|
+
if (f.unclosed === true) rows = rows.filter((m) => m.unclosed);
|
|
421
|
+
if (f.q && f.q.trim().length > 0) {
|
|
422
|
+
const q = f.q.toLowerCase();
|
|
423
|
+
rows = rows.filter(
|
|
424
|
+
(m) => m.title.toLowerCase().includes(q) || m.repo.toLowerCase().includes(q)
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const sorted = [...rows];
|
|
428
|
+
sorted.sort((a, b) => compareSessions(a, b, f.sort ?? "lastTs"));
|
|
429
|
+
return { sessions: sorted, total: sorted.length };
|
|
430
|
+
},
|
|
431
|
+
get(id) {
|
|
432
|
+
const meta = byId.get(id);
|
|
433
|
+
if (!meta) return null;
|
|
434
|
+
return { meta, timeline: buildTimeline(meta.filePath) };
|
|
435
|
+
},
|
|
436
|
+
repos() {
|
|
437
|
+
const set = /* @__PURE__ */ new Set();
|
|
438
|
+
for (const m of metas) set.add(m.repo);
|
|
439
|
+
return [...set].sort((a, b) => a.localeCompare(b));
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/server.ts
|
|
445
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
446
|
+
import { fileURLToPath } from "url";
|
|
447
|
+
import { Hono } from "hono";
|
|
448
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
449
|
+
|
|
450
|
+
// src/privacy/redact.ts
|
|
451
|
+
var RULES = [
|
|
452
|
+
{
|
|
453
|
+
kind: "private-key",
|
|
454
|
+
re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
|
|
455
|
+
},
|
|
456
|
+
{ kind: "aws-key", re: /AKIA[0-9A-Z]{16}/g },
|
|
457
|
+
{ kind: "github-token", re: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
458
|
+
// sk-ant-... must be tried before bare sk-... but both share one kind.
|
|
459
|
+
{ kind: "api-key", re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
460
|
+
{ kind: "api-key", re: /sk-[A-Za-z0-9]{20,}/g },
|
|
461
|
+
{ kind: "google-key", re: /AIza[0-9A-Za-z_-]{35}/g },
|
|
462
|
+
{ kind: "slack-token", re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
463
|
+
{
|
|
464
|
+
kind: "jwt",
|
|
465
|
+
re: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g
|
|
466
|
+
},
|
|
467
|
+
{ kind: "bearer", re: /Bearer\s+[A-Za-z0-9._-]{20,}/g },
|
|
468
|
+
// dotenv-style assignment: mask only the value (group 2), keep the key visible.
|
|
469
|
+
{
|
|
470
|
+
kind: "env-secret",
|
|
471
|
+
re: /([A-Za-z0-9_]*(?:SECRET|TOKEN|API[_-]?KEY|PASSWORD|PASSWD|PWD|CREDENTIAL|PRIVATE)[A-Za-z0-9_]*)(\s*=\s*)(['"]?)([^\s'"]+)\3/gi,
|
|
472
|
+
group: 4
|
|
473
|
+
}
|
|
474
|
+
];
|
|
475
|
+
function shannonEntropy(s) {
|
|
476
|
+
const freq = /* @__PURE__ */ new Map();
|
|
477
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
478
|
+
let h = 0;
|
|
479
|
+
const len = s.length;
|
|
480
|
+
for (const count of freq.values()) {
|
|
481
|
+
const p = count / len;
|
|
482
|
+
h -= p * Math.log2(p);
|
|
483
|
+
}
|
|
484
|
+
return h;
|
|
485
|
+
}
|
|
486
|
+
var HIGH_ENTROPY_RE = /[A-Za-z0-9+/=_-]{32,}/g;
|
|
487
|
+
function collect(text, rule, taken) {
|
|
488
|
+
rule.re.lastIndex = 0;
|
|
489
|
+
let m;
|
|
490
|
+
while ((m = rule.re.exec(text)) !== null) {
|
|
491
|
+
let start = m.index;
|
|
492
|
+
let end = m.index + m[0].length;
|
|
493
|
+
if (rule.group !== void 0) {
|
|
494
|
+
const captured = m[rule.group];
|
|
495
|
+
if (captured === void 0 || captured.length === 0) continue;
|
|
496
|
+
const rel = m[0].lastIndexOf(captured);
|
|
497
|
+
start = m.index + rel;
|
|
498
|
+
end = start + captured.length;
|
|
499
|
+
}
|
|
500
|
+
pushIfFree(taken, start, end, rule.kind);
|
|
501
|
+
if (m[0].length === 0) rule.re.lastIndex += 1;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function overlaps(taken, start, end) {
|
|
505
|
+
for (const s of taken) {
|
|
506
|
+
if (start < s.end && end > s.start) return true;
|
|
507
|
+
}
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
function pushIfFree(taken, start, end, kind) {
|
|
511
|
+
if (!overlaps(taken, start, end)) taken.push({ start, end, kind });
|
|
512
|
+
}
|
|
513
|
+
function redact(text) {
|
|
514
|
+
const taken = [];
|
|
515
|
+
for (const rule of RULES) collect(text, rule, taken);
|
|
516
|
+
HIGH_ENTROPY_RE.lastIndex = 0;
|
|
517
|
+
let hm;
|
|
518
|
+
while ((hm = HIGH_ENTROPY_RE.exec(text)) !== null) {
|
|
519
|
+
const token = hm[0];
|
|
520
|
+
const start = hm.index;
|
|
521
|
+
const end = start + token.length;
|
|
522
|
+
if (overlaps(taken, start, end)) continue;
|
|
523
|
+
if (shannonEntropy(token) > 4) {
|
|
524
|
+
taken.push({ start, end, kind: "high-entropy" });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (taken.length === 0) {
|
|
528
|
+
return { text, redactions: 0, kinds: [] };
|
|
529
|
+
}
|
|
530
|
+
taken.sort((a, b) => a.start - b.start);
|
|
531
|
+
let out = "";
|
|
532
|
+
let cursor = 0;
|
|
533
|
+
const kindSet = /* @__PURE__ */ new Set();
|
|
534
|
+
for (const span of taken) {
|
|
535
|
+
out += text.slice(cursor, span.start);
|
|
536
|
+
out += `[REDACTED:${span.kind}]`;
|
|
537
|
+
kindSet.add(span.kind);
|
|
538
|
+
cursor = span.end;
|
|
539
|
+
}
|
|
540
|
+
out += text.slice(cursor);
|
|
541
|
+
return { text: out, redactions: taken.length, kinds: [...kindSet] };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/analyze/bridge.ts
|
|
545
|
+
import { spawn } from "child_process";
|
|
546
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
547
|
+
var STDERR_SLICE = 500;
|
|
548
|
+
async function runClaude(prompt, opts) {
|
|
549
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
550
|
+
const args = [
|
|
551
|
+
"-p",
|
|
552
|
+
prompt,
|
|
553
|
+
"--output-format",
|
|
554
|
+
"json",
|
|
555
|
+
"--permission-mode",
|
|
556
|
+
"bypassPermissions",
|
|
557
|
+
"--max-turns",
|
|
558
|
+
"1"
|
|
559
|
+
];
|
|
560
|
+
if (opts?.model) {
|
|
561
|
+
args.push("--model", opts.model);
|
|
562
|
+
}
|
|
563
|
+
return new Promise((resolve) => {
|
|
564
|
+
let stdout = "";
|
|
565
|
+
let stderr = "";
|
|
566
|
+
let settled = false;
|
|
567
|
+
let timer;
|
|
568
|
+
const finish = (result) => {
|
|
569
|
+
if (settled) return;
|
|
570
|
+
settled = true;
|
|
571
|
+
if (timer) clearTimeout(timer);
|
|
572
|
+
resolve(result);
|
|
573
|
+
};
|
|
574
|
+
const child = spawn("claude", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
575
|
+
timer = setTimeout(() => {
|
|
576
|
+
child.kill("SIGKILL");
|
|
577
|
+
finish({ ok: false, text: "", error: "timeout" });
|
|
578
|
+
}, timeoutMs);
|
|
579
|
+
child.stdout.on("data", (chunk) => {
|
|
580
|
+
stdout += chunk.toString();
|
|
581
|
+
});
|
|
582
|
+
child.stderr.on("data", (chunk) => {
|
|
583
|
+
stderr += chunk.toString();
|
|
584
|
+
});
|
|
585
|
+
child.on("error", (err) => {
|
|
586
|
+
if (err.code === "ENOENT") {
|
|
587
|
+
finish({
|
|
588
|
+
ok: false,
|
|
589
|
+
text: "",
|
|
590
|
+
error: "claude CLI not found \u2014 is Claude Code installed?"
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
finish({ ok: false, text: "", error: err.message });
|
|
595
|
+
});
|
|
596
|
+
child.on("close", () => {
|
|
597
|
+
if (settled) return;
|
|
598
|
+
try {
|
|
599
|
+
const parsed = JSON.parse(stdout);
|
|
600
|
+
if (typeof parsed.result === "string") {
|
|
601
|
+
finish({ ok: true, text: parsed.result });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
finish({
|
|
605
|
+
ok: false,
|
|
606
|
+
text: "",
|
|
607
|
+
error: (stderr || stdout).slice(0, STDERR_SLICE)
|
|
608
|
+
});
|
|
609
|
+
} catch {
|
|
610
|
+
finish({
|
|
611
|
+
ok: false,
|
|
612
|
+
text: "",
|
|
613
|
+
error: (stderr || stdout).slice(0, STDERR_SLICE)
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/analyze/summarize.ts
|
|
621
|
+
var EXCERPT_CAP = 12e3;
|
|
622
|
+
var PREVIEW_CHARS = 600;
|
|
623
|
+
async function summarizeSession(meta, rawText) {
|
|
624
|
+
const { text: redacted, redactions } = redact(rawText);
|
|
625
|
+
const egressPreview = redacted.slice(0, PREVIEW_CHARS) + "\n[" + redactions + " secrets redacted before sending]";
|
|
626
|
+
const excerpt = redacted.slice(0, EXCERPT_CAP);
|
|
627
|
+
const prompt = [
|
|
628
|
+
"You are reviewing a Claude Code session transcript excerpt.",
|
|
629
|
+
"The session title is: " + meta.title,
|
|
630
|
+
"",
|
|
631
|
+
"Write a concise 2-3 sentence summary that covers:",
|
|
632
|
+
"1. What the session accomplished.",
|
|
633
|
+
"2. Whether the task looked resolved (or was left unfinished).",
|
|
634
|
+
"3. One suggested next step.",
|
|
635
|
+
"",
|
|
636
|
+
"Respond with the summary only \u2014 no preamble, no headings.",
|
|
637
|
+
"",
|
|
638
|
+
"Transcript excerpt (secrets already redacted):",
|
|
639
|
+
"---",
|
|
640
|
+
excerpt,
|
|
641
|
+
"---"
|
|
642
|
+
].join("\n");
|
|
643
|
+
const result = await runClaude(prompt);
|
|
644
|
+
return {
|
|
645
|
+
summary: result.ok ? result.text : "Could not summarize: " + result.error,
|
|
646
|
+
egressPreview
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/server.ts
|
|
651
|
+
var MAX_FILE_CHARS = 2e5;
|
|
652
|
+
var WEB_ROOT = fileURLToPath(new URL("./web", import.meta.url));
|
|
653
|
+
var MISSING_BUILD_HTML = `<!doctype html>
|
|
654
|
+
<html lang="en">
|
|
655
|
+
<head>
|
|
656
|
+
<meta charset="utf-8" />
|
|
657
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
658
|
+
<title>Cerebro \u2014 build required</title>
|
|
659
|
+
<style>
|
|
660
|
+
body { background: #0a0f1e; color: #e7ecf6; font: 15px/1.6 ui-sans-serif, system-ui, sans-serif; margin: 0; display: grid; place-items: center; min-height: 100vh; }
|
|
661
|
+
.card { background: #121a2e; border: 1px solid #243049; border-radius: 12px; padding: 2rem 2.5rem; max-width: 28rem; }
|
|
662
|
+
h1 { margin: 0 0 .5rem; font-size: 1.25rem; color: #5891ff; }
|
|
663
|
+
code { background: #0a0f1e; border: 1px solid #243049; border-radius: 6px; padding: .15rem .4rem; font-family: ui-monospace, monospace; color: #5be1e6; }
|
|
664
|
+
p { color: #8a97b2; }
|
|
665
|
+
</style>
|
|
666
|
+
</head>
|
|
667
|
+
<body>
|
|
668
|
+
<div class="card">
|
|
669
|
+
<h1>Web bundle not built</h1>
|
|
670
|
+
<p>The dashboard hasn't been compiled yet. Run <code>npm run build</code> and restart, then refresh this page.</p>
|
|
671
|
+
<p>The API is live at <code>/api/health</code>.</p>
|
|
672
|
+
</div>
|
|
673
|
+
</body>
|
|
674
|
+
</html>`;
|
|
675
|
+
function createServer(index) {
|
|
676
|
+
const app = new Hono();
|
|
677
|
+
app.get("/api/health", (c) => c.json({ ok: true }));
|
|
678
|
+
app.get("/api/overview", (c) => c.json(index.overview()));
|
|
679
|
+
app.get("/api/repos", (c) => c.json(index.repos()));
|
|
680
|
+
app.get("/api/sessions", (c) => {
|
|
681
|
+
const q = c.req.query("q");
|
|
682
|
+
const repo = c.req.query("repo");
|
|
683
|
+
const type = c.req.query("type");
|
|
684
|
+
const sort = c.req.query("sort");
|
|
685
|
+
const unclosedRaw = c.req.query("unclosed");
|
|
686
|
+
const filter = {};
|
|
687
|
+
if (q) filter.q = q;
|
|
688
|
+
if (repo) filter.repo = repo;
|
|
689
|
+
if (type) filter.type = type;
|
|
690
|
+
if (sort) filter.sort = sort;
|
|
691
|
+
if (unclosedRaw === "1" || unclosedRaw === "true") filter.unclosed = true;
|
|
692
|
+
return c.json(index.list(filter));
|
|
693
|
+
});
|
|
694
|
+
app.get("/api/sessions/:id", (c) => {
|
|
695
|
+
const detail = index.get(c.req.param("id"));
|
|
696
|
+
return detail ? c.json(detail) : c.json({ error: "not found" }, 404);
|
|
697
|
+
});
|
|
698
|
+
app.post("/api/sessions/:id/summarize", async (c) => {
|
|
699
|
+
const detail = index.get(c.req.param("id"));
|
|
700
|
+
if (!detail) return c.json({ error: "not found" }, 404);
|
|
701
|
+
let text = "";
|
|
702
|
+
try {
|
|
703
|
+
text = readFileSync2(detail.meta.filePath, "utf8").slice(0, MAX_FILE_CHARS);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
706
|
+
return c.json({ error: `could not read session file: ${message}` }, 500);
|
|
707
|
+
}
|
|
708
|
+
const result = await summarizeSession(detail.meta, text);
|
|
709
|
+
return c.json(result);
|
|
710
|
+
});
|
|
711
|
+
if (existsSync2(WEB_ROOT)) {
|
|
712
|
+
const spaFallback = serveStatic({ root: WEB_ROOT, path: "index.html" });
|
|
713
|
+
app.use(
|
|
714
|
+
"/*",
|
|
715
|
+
serveStatic({
|
|
716
|
+
root: WEB_ROOT,
|
|
717
|
+
onNotFound: (_path, c) => {
|
|
718
|
+
c.header("Cache-Control", "no-cache");
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
);
|
|
722
|
+
app.get("/*", spaFallback);
|
|
723
|
+
} else {
|
|
724
|
+
app.get("/*", (c) => c.html(MISSING_BUILD_HTML));
|
|
725
|
+
}
|
|
726
|
+
return app;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/cli.ts
|
|
730
|
+
function parseArgs(argv) {
|
|
731
|
+
const opts = { port: 4317, open: true };
|
|
732
|
+
for (let i = 0; i < argv.length; i++) {
|
|
733
|
+
const arg = argv[i];
|
|
734
|
+
if (arg === "--no-open") {
|
|
735
|
+
opts.open = false;
|
|
736
|
+
} else if (arg === "--port") {
|
|
737
|
+
const value = argv[++i];
|
|
738
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
739
|
+
if (Number.isFinite(parsed) && parsed > 0) opts.port = parsed;
|
|
740
|
+
} else if (arg.startsWith("--port=")) {
|
|
741
|
+
const parsed = Number.parseInt(arg.slice("--port=".length), 10);
|
|
742
|
+
if (Number.isFinite(parsed) && parsed > 0) opts.port = parsed;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return opts;
|
|
746
|
+
}
|
|
747
|
+
async function main() {
|
|
748
|
+
const { port, open: shouldOpen } = parseArgs(process.argv.slice(2));
|
|
749
|
+
console.log("Cerebro \u2014 indexing your Claude Code sessions\u2026");
|
|
750
|
+
const index = await buildIndex();
|
|
751
|
+
const app = createServer(index);
|
|
752
|
+
const url = `http://localhost:${port}`;
|
|
753
|
+
serve({ fetch: app.fetch, port });
|
|
754
|
+
console.log(`\u2192 ${url}`);
|
|
755
|
+
if (shouldOpen) {
|
|
756
|
+
try {
|
|
757
|
+
const { default: open } = await import("open");
|
|
758
|
+
await open(url);
|
|
759
|
+
} catch {
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
main().catch((err) => {
|
|
764
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
765
|
+
console.error(`
|
|
766
|
+
Cerebro failed to start: ${message}`);
|
|
767
|
+
console.error("If this looks like a build problem, try: npm run build");
|
|
768
|
+
process.exitCode = 1;
|
|
769
|
+
});
|