@threadbase-sh/scanner 0.7.2

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/dist/cli.js ADDED
@@ -0,0 +1,1447 @@
1
+ #!/usr/bin/env node
2
+
3
+ // cli/index.ts
4
+ import { Command } from "commander";
5
+ import pino2 from "pino";
6
+
7
+ // package.json
8
+ var version = "0.7.2";
9
+
10
+ // src/logger.ts
11
+ import pino from "pino";
12
+ var currentLogger = pino({ level: "silent" });
13
+ function setLogger(logger) {
14
+ currentLogger = logger;
15
+ }
16
+ function getLogger() {
17
+ return currentLogger;
18
+ }
19
+
20
+ // src/profiles.ts
21
+ import { mkdir, readFile, writeFile } from "fs/promises";
22
+ import { homedir } from "os";
23
+ import { join } from "path";
24
+ var PROFILES_FILE = "profiles.json";
25
+ function resolveConfigDir(configDir) {
26
+ return configDir.replace(/^~/, homedir());
27
+ }
28
+ function getProjectsDir(profile) {
29
+ return join(resolveConfigDir(profile.configDir), "projects");
30
+ }
31
+ async function detectDefaultProfile() {
32
+ return {
33
+ id: "default",
34
+ label: "Default",
35
+ configDir: join(homedir(), ".claude"),
36
+ enabled: true,
37
+ emoji: "\u{1F916}"
38
+ };
39
+ }
40
+ async function loadProfiles(configPath) {
41
+ const log = getLogger();
42
+ try {
43
+ const resolved = resolveConfigDir(configPath);
44
+ const data = await readFile(join(resolved, PROFILES_FILE), "utf-8");
45
+ const profiles = JSON.parse(data);
46
+ log.debug({ configPath, count: profiles.length }, "profiles: loaded");
47
+ return profiles;
48
+ } catch (err) {
49
+ log.debug({ configPath, err }, "profiles: load failed, using default");
50
+ const defaultProfile = await detectDefaultProfile();
51
+ return [defaultProfile];
52
+ }
53
+ }
54
+ async function saveProfiles(profiles, configPath) {
55
+ const resolved = resolveConfigDir(configPath);
56
+ await mkdir(resolved, { recursive: true });
57
+ await writeFile(join(resolved, PROFILES_FILE), JSON.stringify(profiles, null, 2));
58
+ getLogger().debug({ configPath, count: profiles.length }, "profiles: saved");
59
+ }
60
+
61
+ // src/scanner.ts
62
+ import { statSync } from "fs";
63
+
64
+ // src/cache.ts
65
+ var LRUCache = class {
66
+ map = /* @__PURE__ */ new Map();
67
+ capacity;
68
+ constructor(capacity) {
69
+ this.capacity = capacity;
70
+ }
71
+ get(key) {
72
+ const value = this.map.get(key);
73
+ if (value === void 0) return void 0;
74
+ this.map.delete(key);
75
+ this.map.set(key, value);
76
+ return value;
77
+ }
78
+ set(key, value) {
79
+ this.map.delete(key);
80
+ this.map.set(key, value);
81
+ if (this.map.size > this.capacity) {
82
+ const oldest = this.map.keys().next();
83
+ if (!oldest.done) this.map.delete(oldest.value);
84
+ }
85
+ }
86
+ has(key) {
87
+ return this.map.has(key);
88
+ }
89
+ delete(key) {
90
+ return this.map.delete(key);
91
+ }
92
+ clear() {
93
+ this.map.clear();
94
+ }
95
+ get size() {
96
+ return this.map.size;
97
+ }
98
+ };
99
+
100
+ // src/discovery.ts
101
+ import fg from "fast-glob";
102
+ import { stat } from "fs/promises";
103
+ var EXCLUDED_SEGMENTS = ["/memory/", "/tool-results/"];
104
+ var STAT_CONCURRENCY = 32;
105
+ async function discoverJsonlFiles(dirs, onProgress) {
106
+ const log = getLogger();
107
+ const results = [];
108
+ for (const { projectsDir, account } of dirs) {
109
+ let filePaths;
110
+ try {
111
+ filePaths = await fg("**/*.jsonl", {
112
+ cwd: projectsDir,
113
+ absolute: true,
114
+ dot: false
115
+ });
116
+ } catch (err) {
117
+ log.warn({ projectsDir, account, err }, "discovery: glob failed");
118
+ continue;
119
+ }
120
+ const filtered = filePaths.filter((fp) => !EXCLUDED_SEGMENTS.some((seg) => fp.includes(seg)));
121
+ let kept = 0;
122
+ let skippedEmpty = 0;
123
+ let skippedInaccessible = 0;
124
+ for (let i = 0; i < filtered.length; i += STAT_CONCURRENCY) {
125
+ const chunk = filtered.slice(i, i + STAT_CONCURRENCY);
126
+ const statted = await Promise.all(
127
+ chunk.map(async (filePath) => {
128
+ try {
129
+ const s = await stat(filePath);
130
+ return { filePath, size: s.size };
131
+ } catch (err) {
132
+ log.warn({ filePath, err }, "discovery: stat failed");
133
+ return { filePath, size: -1 };
134
+ }
135
+ })
136
+ );
137
+ for (const { filePath, size } of statted) {
138
+ if (size < 0) {
139
+ skippedInaccessible++;
140
+ } else if (size > 0) {
141
+ results.push({ filePath, account });
142
+ kept++;
143
+ } else {
144
+ skippedEmpty++;
145
+ }
146
+ }
147
+ }
148
+ log.debug(
149
+ {
150
+ projectsDir,
151
+ account,
152
+ globMatches: filePaths.length,
153
+ afterExclusions: filtered.length,
154
+ kept,
155
+ skippedEmpty,
156
+ skippedInaccessible
157
+ },
158
+ "discovery: directory scanned"
159
+ );
160
+ onProgress?.(results.length);
161
+ }
162
+ log.debug({ totalFiles: results.length, dirs: dirs.length }, "discovery: complete");
163
+ return results;
164
+ }
165
+
166
+ // src/filters.ts
167
+ function applySort(metas, order) {
168
+ const out = [...metas];
169
+ switch (order) {
170
+ case "recent":
171
+ out.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
172
+ break;
173
+ case "oldest":
174
+ out.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
175
+ break;
176
+ case "messages-desc":
177
+ out.sort((a, b) => b.messageCount - a.messageCount);
178
+ break;
179
+ case "messages-asc":
180
+ out.sort((a, b) => a.messageCount - b.messageCount);
181
+ break;
182
+ case "alpha":
183
+ out.sort((a, b) => {
184
+ const cmp = a.projectName.localeCompare(b.projectName);
185
+ return cmp !== 0 ? cmp : a.preview.localeCompare(b.preview);
186
+ });
187
+ break;
188
+ }
189
+ return out;
190
+ }
191
+ function applySinceFilter(metas, since) {
192
+ const cutoff = parseSinceCutoff(since);
193
+ return metas.filter((m) => new Date(m.timestamp).getTime() >= cutoff.getTime());
194
+ }
195
+ function applyIncludeFilter(metas, include) {
196
+ switch (include) {
197
+ case "all":
198
+ return metas;
199
+ case "conversations":
200
+ return metas.filter((m) => !m.isSubagent && !m.isTeammate);
201
+ case "subagents":
202
+ return metas.filter((m) => m.isSubagent);
203
+ case "teammates":
204
+ return metas.filter((m) => m.isTeammate);
205
+ }
206
+ }
207
+ function applyProjectFilter(metas, project) {
208
+ const lower = project.toLowerCase();
209
+ return metas.filter(
210
+ (m) => m.projectPath.toLowerCase().includes(lower) || m.projectName.toLowerCase().includes(lower)
211
+ );
212
+ }
213
+ function applyAccountFilter(metas, account) {
214
+ return metas.filter((m) => m.account === account);
215
+ }
216
+ function applyPagination(items, limit, offset) {
217
+ return {
218
+ items: items.slice(offset, offset + limit),
219
+ total: items.length
220
+ };
221
+ }
222
+ function parseSinceCutoff(value) {
223
+ const s = value.trim();
224
+ if (!s) throw new Error("Empty --since value");
225
+ const isoMatch = s.match(/^\d{4}-\d{2}-\d{2}$/);
226
+ if (isoMatch) {
227
+ const d = /* @__PURE__ */ new Date(`${s}T00:00:00Z`);
228
+ if (!Number.isNaN(d.getTime())) return d;
229
+ }
230
+ const durationMatch = s.match(/^(\d+)([hdw])$/);
231
+ if (!durationMatch) {
232
+ throw new Error(
233
+ `Invalid --since value "${s}": expected duration like "7d", "24h", "2w" or ISO date "2006-01-02"`
234
+ );
235
+ }
236
+ const n = parseInt(durationMatch[1], 10);
237
+ const unit = durationMatch[2];
238
+ let ms;
239
+ switch (unit) {
240
+ case "h":
241
+ ms = n * 60 * 60 * 1e3;
242
+ break;
243
+ case "d":
244
+ ms = n * 24 * 60 * 60 * 1e3;
245
+ break;
246
+ case "w":
247
+ ms = n * 7 * 24 * 60 * 60 * 1e3;
248
+ break;
249
+ default:
250
+ throw new Error(`Invalid unit "${unit}"`);
251
+ }
252
+ return new Date(Date.now() - ms);
253
+ }
254
+
255
+ // src/git.ts
256
+ import { readFileSync } from "fs";
257
+ import { dirname, join as join2 } from "path";
258
+ var REF_PREFIX = "ref: refs/heads/";
259
+ var MAX_DEPTH = 6;
260
+ function readGitBranch(projectPath) {
261
+ if (!projectPath) return null;
262
+ const log = getLogger();
263
+ let dir = projectPath;
264
+ let depth = 0;
265
+ while (depth < MAX_DEPTH) {
266
+ const headPath = join2(dir, ".git", "HEAD");
267
+ try {
268
+ const content = readFileSync(headPath, "utf-8").trim();
269
+ if (content.startsWith(REF_PREFIX)) {
270
+ const branch = content.slice(REF_PREFIX.length);
271
+ log.trace({ projectPath, dir, branch }, "git: branch resolved");
272
+ return branch;
273
+ }
274
+ if (content.length >= 7) {
275
+ log.trace({ projectPath, dir }, "git: detached HEAD");
276
+ return "(detached)";
277
+ }
278
+ return null;
279
+ } catch {
280
+ }
281
+ const parent = dirname(dir);
282
+ if (parent === dir) return null;
283
+ dir = parent;
284
+ depth++;
285
+ }
286
+ log.trace({ projectPath }, "git: no .git found within depth");
287
+ return null;
288
+ }
289
+
290
+ // src/indexer.ts
291
+ import FlexSearchModule from "flexsearch";
292
+ var FlexSearch = FlexSearchModule.default ?? FlexSearchModule;
293
+ var SearchIndexer = class {
294
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
295
+ index;
296
+ documents = /* @__PURE__ */ new Map();
297
+ constructor() {
298
+ this.index = this.createIndex();
299
+ }
300
+ createIndex() {
301
+ return new FlexSearch.Document({
302
+ document: {
303
+ id: "id",
304
+ index: [
305
+ "content",
306
+ "projectName",
307
+ "projectPath",
308
+ "sessionId",
309
+ "sessionName",
310
+ "account",
311
+ "model",
312
+ "gitBranch",
313
+ "toolNames"
314
+ ],
315
+ store: ["id"]
316
+ },
317
+ tokenize: "forward",
318
+ resolution: 9,
319
+ cache: 100
320
+ });
321
+ }
322
+ addDocument(meta) {
323
+ this.documents.set(meta.id, meta);
324
+ this.index.add({
325
+ id: meta.id,
326
+ content: meta.contentSnippet,
327
+ projectName: meta.projectName,
328
+ projectPath: meta.projectPath,
329
+ sessionId: meta.sessionId,
330
+ sessionName: meta.sessionName,
331
+ account: meta.account,
332
+ model: meta.model || "",
333
+ gitBranch: meta.gitBranch || "",
334
+ toolNames: meta.toolNames.join(" ")
335
+ });
336
+ }
337
+ buildIndex(metas) {
338
+ this.clear();
339
+ for (const meta of metas) {
340
+ this.addDocument(meta);
341
+ }
342
+ getLogger().debug({ docCount: metas.length }, "indexer: built");
343
+ }
344
+ search(query, options) {
345
+ const limit = options?.limit ?? 50;
346
+ if (!query.trim()) {
347
+ return this.getRecent(limit);
348
+ }
349
+ const results = this.index.search(query, { limit: limit * 2, enrich: true });
350
+ const seen = /* @__PURE__ */ new Set();
351
+ const searchResults = [];
352
+ for (const fieldResult of results) {
353
+ if (!fieldResult.result) continue;
354
+ for (const item of fieldResult.result) {
355
+ const id = typeof item === "object" ? item.id : String(item);
356
+ if (seen.has(id)) continue;
357
+ seen.add(id);
358
+ const meta = this.documents.get(id);
359
+ if (!meta) continue;
360
+ const matches = this.generateMatches(meta, query);
361
+ searchResults.push({ meta, score: 1, matches });
362
+ if (searchResults.length >= limit) break;
363
+ }
364
+ if (searchResults.length >= limit) break;
365
+ }
366
+ return searchResults;
367
+ }
368
+ getRecent(limit) {
369
+ return Array.from(this.documents.values()).sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, limit).map((meta) => ({
370
+ meta,
371
+ score: 1,
372
+ matches: [{ field: "timestamp", snippet: meta.preview }]
373
+ }));
374
+ }
375
+ generateMatches(meta, query) {
376
+ const matches = [];
377
+ const lowerQuery = query.toLowerCase();
378
+ const fields = [
379
+ ["contentSnippet", meta.contentSnippet],
380
+ ["projectName", meta.projectName],
381
+ ["sessionId", meta.sessionId],
382
+ ["sessionName", meta.sessionName],
383
+ ["account", meta.account],
384
+ ["model", meta.model || ""],
385
+ ["gitBranch", meta.gitBranch || ""],
386
+ ["toolNames", meta.toolNames.join(" ")]
387
+ ];
388
+ for (const [field, value] of fields) {
389
+ const idx = value.toLowerCase().indexOf(lowerQuery);
390
+ if (idx !== -1) {
391
+ const start = Math.max(0, idx - 80);
392
+ const end = Math.min(value.length, idx + query.length + 120);
393
+ let snippet = value.slice(start, end);
394
+ if (start > 0) snippet = `...${snippet}`;
395
+ if (end < value.length) snippet = `${snippet}...`;
396
+ matches.push({ field, snippet });
397
+ }
398
+ }
399
+ return matches.length > 0 ? matches : [{ field: "preview", snippet: meta.preview }];
400
+ }
401
+ getDocumentCount() {
402
+ return this.documents.size;
403
+ }
404
+ // Replace an already-indexed document in place. FlexSearch's `add` does not
405
+ // overwrite an existing id, so a single-file refresh must go through
406
+ // `update` to avoid stale matches lingering in the index.
407
+ updateDocument(meta) {
408
+ this.documents.set(meta.id, meta);
409
+ this.index.update({
410
+ id: meta.id,
411
+ content: meta.contentSnippet,
412
+ projectName: meta.projectName,
413
+ projectPath: meta.projectPath,
414
+ sessionId: meta.sessionId,
415
+ sessionName: meta.sessionName,
416
+ account: meta.account,
417
+ model: meta.model || "",
418
+ gitBranch: meta.gitBranch || "",
419
+ toolNames: meta.toolNames.join(" ")
420
+ });
421
+ }
422
+ removeDocument(id) {
423
+ this.documents.delete(id);
424
+ this.index.remove(id);
425
+ }
426
+ clear() {
427
+ this.documents.clear();
428
+ this.index = this.createIndex();
429
+ getLogger().trace("indexer: cleared");
430
+ }
431
+ };
432
+
433
+ // src/parser.ts
434
+ import { createReadStream } from "fs";
435
+ import { basename, dirname as dirname2, join as join3 } from "path";
436
+ import { createInterface } from "readline";
437
+
438
+ // src/tags.ts
439
+ var SYSTEM_TAGS = [
440
+ "system-reminder",
441
+ "command-name",
442
+ "command-message",
443
+ "command-args",
444
+ "ide_selection",
445
+ "ide_opened_file",
446
+ "local-command-stdout",
447
+ "local-command-caveat",
448
+ "retrieval_status",
449
+ "task_id",
450
+ "task_type",
451
+ "task-id",
452
+ "task-notification",
453
+ "fast_mode_info",
454
+ "persisted-output",
455
+ "tool_use_error",
456
+ "user-prompt-submit-hook",
457
+ "thinking",
458
+ "ask_user",
459
+ "teammate-message"
460
+ ];
461
+ var SYSTEM_TAG_RE = new RegExp(`<(${SYSTEM_TAGS.join("|")})[^>]*>[\\s\\S]*?<\\/\\1>`, "g");
462
+ function cleanSystemTags(text) {
463
+ return text.replace(SYSTEM_TAG_RE, "").replace(/[^\S\n]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
464
+ }
465
+
466
+ // src/parser.ts
467
+ async function parseMeta(filePath, account, tier) {
468
+ const log = getLogger();
469
+ log.trace({ filePath, account, tier: tier.name }, "parseMeta: start");
470
+ let sessionId = "";
471
+ let badJsonLines = 0;
472
+ let sessionName = "";
473
+ let latestTimestamp = "";
474
+ let cwd = "";
475
+ let teamName = "";
476
+ let model = null;
477
+ let messageCount = 0;
478
+ let lastMessageSender = "user";
479
+ let isTeammate = false;
480
+ let firstUserSeen = false;
481
+ let firstMessage = null;
482
+ let lastMessage = null;
483
+ let lastPrompt = "";
484
+ const toolNameSet = /* @__PURE__ */ new Set();
485
+ const previewParts = [];
486
+ const snippetParts = [];
487
+ let snippetLength = 0;
488
+ let previewLength = 0;
489
+ const fileStream = createReadStream(filePath);
490
+ const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
491
+ try {
492
+ for await (const line of rl) {
493
+ if (!line.trim()) continue;
494
+ let entry;
495
+ try {
496
+ entry = JSON.parse(line);
497
+ } catch {
498
+ badJsonLines++;
499
+ continue;
500
+ }
501
+ if (entry.cwd && !cwd) cwd = entry.cwd;
502
+ if (entry.sessionId && !sessionId) sessionId = entry.sessionId;
503
+ if (entry.slug && !sessionName) sessionName = entry.slug;
504
+ if (entry.teamName && !teamName) teamName = entry.teamName;
505
+ if (entry.timestamp) {
506
+ const ts = entry.timestamp;
507
+ if (!latestTimestamp || ts > latestTimestamp) latestTimestamp = ts;
508
+ }
509
+ const type = entry.type;
510
+ if (type === "last-prompt") {
511
+ if (entry.lastPrompt && !lastPrompt) lastPrompt = entry.lastPrompt;
512
+ continue;
513
+ }
514
+ if (type !== "user" && type !== "assistant") continue;
515
+ if (entry.isMeta) continue;
516
+ if (model === null) {
517
+ const msg2 = entry.message;
518
+ if (msg2?.model) model = msg2.model;
519
+ }
520
+ if (type === "user" && !firstUserSeen) {
521
+ firstUserSeen = true;
522
+ if (isTeammateContent(entry.message?.content)) {
523
+ isTeammate = true;
524
+ }
525
+ }
526
+ const msg = entry.message;
527
+ const content = extractTextContent(msg?.content);
528
+ const hasToolUseResult = type === "user" && entry.toolUseResult != null;
529
+ const isOnlyToolResult = hasToolUseResult && isOnlyToolResultContent(msg?.content);
530
+ collectToolNames(msg?.content, toolNameSet);
531
+ if (content || isOnlyToolResult) {
532
+ messageCount++;
533
+ lastMessageSender = type;
534
+ if (content) {
535
+ const ts = entry.timestamp || "";
536
+ if (!firstMessage) {
537
+ firstMessage = { text: content.slice(0, 200), timestamp: ts };
538
+ }
539
+ lastMessage = { text: content.slice(0, 200), timestamp: ts };
540
+ if (previewLength < tier.previewMax) {
541
+ previewParts.push(content);
542
+ previewLength += content.length;
543
+ }
544
+ if (snippetLength < tier.snippetMax) {
545
+ const remaining = tier.snippetMax - snippetLength;
546
+ const chunk = content.length > remaining ? content.slice(0, remaining) : content;
547
+ snippetParts.push(chunk);
548
+ snippetLength += chunk.length;
549
+ }
550
+ }
551
+ }
552
+ }
553
+ } catch (err) {
554
+ log.warn({ filePath, err }, "parseMeta: read failed");
555
+ return null;
556
+ }
557
+ if (badJsonLines > 0) {
558
+ log.warn({ filePath, badJsonLines }, "parseMeta: skipped malformed JSON lines");
559
+ }
560
+ if (messageCount === 0) {
561
+ log.trace({ filePath }, "parseMeta: no messages");
562
+ return null;
563
+ }
564
+ const isSubagent = filePath.includes("/subagents/");
565
+ let parentSessionId = null;
566
+ if (isSubagent) {
567
+ const uuidDir = dirname2(dirname2(filePath));
568
+ parentSessionId = join3(dirname2(uuidDir), `${basename(uuidDir)}.jsonl`);
569
+ }
570
+ const projectPath = cwd;
571
+ const preview = previewParts.join(" ").slice(0, tier.previewMax);
572
+ return {
573
+ id: filePath,
574
+ filePath,
575
+ sessionId: sessionId || basename(filePath, ".jsonl"),
576
+ sessionName,
577
+ projectPath,
578
+ projectName: getShortProjectName(projectPath),
579
+ account,
580
+ timestamp: latestTimestamp || (/* @__PURE__ */ new Date()).toISOString(),
581
+ messageCount,
582
+ lastMessageSender,
583
+ preview,
584
+ contentSnippet: snippetParts.join(" "),
585
+ gitBranch: null,
586
+ model,
587
+ isSubagent,
588
+ parentSessionId,
589
+ isTeammate,
590
+ teamName: teamName || null,
591
+ toolNames: Array.from(toolNameSet),
592
+ firstMessage,
593
+ lastMessage,
594
+ lastPrompt: lastPrompt || void 0
595
+ };
596
+ }
597
+ async function parseConversation(filePath, account) {
598
+ const log = getLogger();
599
+ log.trace({ filePath, account }, "parseConversation: start");
600
+ const messages = [];
601
+ let badJsonLines = 0;
602
+ let sessionId = "";
603
+ let sessionName = "";
604
+ let latestTimestamp = "";
605
+ let cwd = "";
606
+ const textParts = [];
607
+ const pendingToolUses = /* @__PURE__ */ new Map();
608
+ const teamInfoMap = /* @__PURE__ */ new Map();
609
+ const turnDurations = [];
610
+ let lastPrompt = "";
611
+ const fileStream = createReadStream(filePath);
612
+ const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
613
+ try {
614
+ for await (const line of rl) {
615
+ if (!line.trim()) continue;
616
+ let entry;
617
+ try {
618
+ entry = JSON.parse(line);
619
+ } catch {
620
+ badJsonLines++;
621
+ continue;
622
+ }
623
+ if (entry.cwd && !cwd) cwd = entry.cwd;
624
+ if (entry.sessionId && !sessionId) sessionId = entry.sessionId;
625
+ if (entry.slug && !sessionName) sessionName = entry.slug;
626
+ if (entry.timestamp) {
627
+ const ts = entry.timestamp;
628
+ if (!latestTimestamp || ts > latestTimestamp) latestTimestamp = ts;
629
+ }
630
+ const type = entry.type;
631
+ if (type === "last-prompt") {
632
+ if (entry.lastPrompt && !lastPrompt) lastPrompt = entry.lastPrompt;
633
+ continue;
634
+ }
635
+ if (type === "system") {
636
+ if (entry.subtype === "turn_duration" && typeof entry.durationMs === "number") {
637
+ turnDurations.push({
638
+ durationMs: entry.durationMs,
639
+ messageCount: entry.messageCount || 0,
640
+ uuid: entry.uuid
641
+ });
642
+ }
643
+ continue;
644
+ }
645
+ if (type !== "user" && type !== "assistant") continue;
646
+ if (entry.isMeta) continue;
647
+ const msg = entry.message;
648
+ const toolUseBlocks = extractToolUseBlocks(msg?.content);
649
+ for (const block of toolUseBlocks) {
650
+ pendingToolUses.set(block.id, block);
651
+ }
652
+ const hasToolUseResult = type === "user" && entry.toolUseResult != null;
653
+ const isToolResultOnly = hasToolUseResult && isOnlyToolResultContent(msg?.content);
654
+ const content = extractTextContent(msg?.content);
655
+ const thinking = type === "assistant" ? extractThinking(msg?.content) : null;
656
+ const hasThinking = !!(thinking?.content || thinking?.signature);
657
+ if (content || isToolResultOnly || toolUseBlocks.length > 0 || hasThinking) {
658
+ const metadata = {};
659
+ if (msg?.model) metadata.model = msg.model;
660
+ if (msg?.stop_reason !== void 0) metadata.stopReason = msg.stop_reason;
661
+ if (entry.gitBranch) metadata.gitBranch = entry.gitBranch;
662
+ if (entry.version) metadata.version = entry.version;
663
+ const usage = msg?.usage;
664
+ if (usage) {
665
+ if (usage.input_tokens) metadata.inputTokens = usage.input_tokens;
666
+ if (usage.output_tokens) metadata.outputTokens = usage.output_tokens;
667
+ if (usage.cache_read_input_tokens)
668
+ metadata.cacheReadTokens = usage.cache_read_input_tokens;
669
+ if (usage.cache_creation_input_tokens)
670
+ metadata.cacheCreationTokens = usage.cache_creation_input_tokens;
671
+ }
672
+ const toolUseNames = extractToolUseNames(msg?.content);
673
+ if (toolUseNames.length > 0) metadata.toolUses = toolUseNames;
674
+ if (toolUseBlocks.length > 0) metadata.toolUseBlocks = toolUseBlocks;
675
+ if (isToolResultOnly) {
676
+ const toolResultBlocks = extractToolResultBlocks(msg?.content, pendingToolUses);
677
+ if (toolResultBlocks.length > 0) metadata.toolResults = toolResultBlocks;
678
+ }
679
+ if (entry.teamName) {
680
+ metadata.teamName = entry.teamName;
681
+ if (!teamInfoMap.has(metadata.teamName) && content) {
682
+ const info = parseTeammateMessageTag(content);
683
+ if (info) teamInfoMap.set(metadata.teamName, info);
684
+ }
685
+ }
686
+ const thinkingContent = thinking?.content || void 0;
687
+ const thinkingSignature = thinking?.signature || void 0;
688
+ const hasMetadata = Object.keys(metadata).length > 0;
689
+ messages.push({
690
+ role: type,
691
+ text: content || "",
692
+ timestamp: entry.timestamp || "",
693
+ uuid: entry.uuid || void 0,
694
+ metadata: hasMetadata ? metadata : void 0,
695
+ isToolResult: isToolResultOnly || void 0,
696
+ isThinking: thinkingContent || thinkingSignature ? true : void 0,
697
+ thinkingContent,
698
+ thinkingSignature,
699
+ parentUuid: entry.parentUuid !== void 0 ? entry.parentUuid : void 0,
700
+ requestId: type === "assistant" ? entry.requestId : void 0,
701
+ promptId: type === "user" ? entry.promptId : void 0,
702
+ isSidechain: typeof entry.isSidechain === "boolean" ? entry.isSidechain : void 0,
703
+ permissionMode: type === "user" ? entry.permissionMode : void 0,
704
+ hasImages: hasImageBlocks(msg?.content) || void 0,
705
+ attachment: entry.attachment !== void 0 ? entry.attachment : void 0
706
+ });
707
+ if (content) textParts.push(content);
708
+ }
709
+ }
710
+ } catch (err) {
711
+ log.warn({ filePath, err }, "parseConversation: read failed");
712
+ return null;
713
+ }
714
+ if (badJsonLines > 0) {
715
+ log.warn({ filePath, badJsonLines }, "parseConversation: skipped malformed JSON lines");
716
+ }
717
+ if (messages.length === 0) {
718
+ log.trace({ filePath }, "parseConversation: no messages");
719
+ return null;
720
+ }
721
+ log.debug({ filePath, messageCount: messages.length }, "parseConversation: complete");
722
+ if (teamInfoMap.size > 0) {
723
+ for (const msg of messages) {
724
+ if (msg.metadata?.teamName) {
725
+ const info = teamInfoMap.get(msg.metadata.teamName);
726
+ if (info) msg.metadata.teamInfo = info;
727
+ }
728
+ }
729
+ }
730
+ return {
731
+ id: filePath,
732
+ filePath,
733
+ projectPath: cwd,
734
+ projectName: getShortProjectName(cwd),
735
+ sessionId: sessionId || basename(filePath, ".jsonl"),
736
+ sessionName,
737
+ messages,
738
+ fullText: textParts.join(" "),
739
+ timestamp: latestTimestamp || (/* @__PURE__ */ new Date()).toISOString(),
740
+ messageCount: messages.length,
741
+ account,
742
+ turnDurations: turnDurations.length > 0 ? turnDurations : void 0,
743
+ lastPrompt: lastPrompt || void 0
744
+ };
745
+ }
746
+ function extractTextContent(content) {
747
+ if (!content) return "";
748
+ if (typeof content === "string") return cleanSystemTags(content);
749
+ if (Array.isArray(content)) {
750
+ return content.map((item) => {
751
+ if (typeof item === "string") return item;
752
+ if (item?.type === "text" && item?.text) return item.text;
753
+ if (item?.type === "tool_result" && typeof item?.content === "string") return item.content;
754
+ return "";
755
+ }).filter(Boolean).map(cleanSystemTags).join(" ");
756
+ }
757
+ return "";
758
+ }
759
+ function extractToolUseNames(content) {
760
+ if (!Array.isArray(content)) return [];
761
+ return content.filter((item) => item?.type === "tool_use" && item?.name).map((item) => item.name);
762
+ }
763
+ function extractToolUseBlocks(content) {
764
+ if (!Array.isArray(content)) return [];
765
+ return content.filter((item) => item?.type === "tool_use" && item?.name && item?.id).map((item) => ({
766
+ id: item.id,
767
+ name: item.name,
768
+ input: item.input || {}
769
+ }));
770
+ }
771
+ var TOOL_NAME_TO_TYPE = {
772
+ Edit: "edit",
773
+ Write: "write",
774
+ Read: "read",
775
+ Bash: "bash",
776
+ Grep: "grep",
777
+ Glob: "glob",
778
+ Agent: "taskAgent",
779
+ TaskCreate: "taskCreate",
780
+ TaskUpdate: "taskUpdate"
781
+ };
782
+ function extractToolResultBlocks(content, pendingToolUses) {
783
+ if (!Array.isArray(content)) return [];
784
+ return content.filter((item) => item?.type === "tool_result" && item?.tool_use_id).map((item) => {
785
+ const toolName = pendingToolUses.get(item.tool_use_id)?.name ?? "";
786
+ return {
787
+ toolUseId: item.tool_use_id,
788
+ type: TOOL_NAME_TO_TYPE[toolName] ?? "generic",
789
+ content: typeof item.content === "string" ? { text: item.content } : item.content ?? {},
790
+ isError: typeof item.is_error === "boolean" ? item.is_error : void 0
791
+ };
792
+ });
793
+ }
794
+ function collectToolNames(content, toolSet) {
795
+ if (!Array.isArray(content)) return;
796
+ for (const item of content) {
797
+ if (item?.type === "tool_use" && item?.name) {
798
+ toolSet.add(item.name);
799
+ }
800
+ }
801
+ }
802
+ function isOnlyToolResultContent(content) {
803
+ if (!Array.isArray(content)) return false;
804
+ return content.length > 0 && content.every((item) => item?.type === "tool_result");
805
+ }
806
+ function isTeammateContent(content) {
807
+ const raw = typeof content === "string" ? content : Array.isArray(content) ? content.map(
808
+ (item) => typeof item === "string" ? item : item?.type === "text" ? item.text ?? "" : ""
809
+ ).join("") : "";
810
+ return raw.includes("<teammate-message");
811
+ }
812
+ function extractThinking(content) {
813
+ if (!Array.isArray(content)) return { content: "", signature: "" };
814
+ const blocks = content.filter((item) => item?.type === "thinking");
815
+ return {
816
+ content: blocks.map((b) => b.thinking).filter(Boolean).join("\n\n"),
817
+ signature: blocks.map((b) => b.signature).filter(Boolean).join("")
818
+ };
819
+ }
820
+ function hasImageBlocks(content) {
821
+ if (!Array.isArray(content)) return false;
822
+ return content.some(
823
+ (item) => item?.type === "image" && (item?.source?.type === "base64" || item?.file?.base64 !== void 0)
824
+ );
825
+ }
826
+ function parseTeammateMessageTag(content) {
827
+ const match = content.match(/<teammate-message\s+([^>]*)>/);
828
+ if (!match) return null;
829
+ const attrs = match[1];
830
+ const id = attrs.match(/teammate_id="([^"]*)"/)?.[1];
831
+ if (!id) return null;
832
+ const summary = attrs.match(/summary="([^"]*)"/)?.[1];
833
+ const color = attrs.match(/color="([^"]*)"/)?.[1];
834
+ return { teammateId: id, summary, color };
835
+ }
836
+ function getShortProjectName(fullPath) {
837
+ const parts = fullPath.split("/").filter(Boolean);
838
+ return parts.slice(-3).join("/");
839
+ }
840
+
841
+ // src/tiers.ts
842
+ var DEFAULT_TIERS = {
843
+ standard: { name: "standard", previewMax: 200, snippetMax: 5e3 },
844
+ full: { name: "full", previewMax: 1200, snippetMax: 5e4 }
845
+ };
846
+ function resolveTier(tierName, customTiers) {
847
+ const tier = customTiers?.[tierName] ?? DEFAULT_TIERS[tierName];
848
+ if (!tier) {
849
+ throw new Error(
850
+ `Unknown tier "${tierName}". Available: ${Object.keys({ ...DEFAULT_TIERS, ...customTiers }).join(", ")}`
851
+ );
852
+ }
853
+ return tier;
854
+ }
855
+
856
+ // src/scanner.ts
857
+ var BATCH_SIZE = 12;
858
+ var DEFAULT_CONFIG_PATH = "~/.config/threadbase-scanner";
859
+ var ConversationScanner = class {
860
+ metadataCache = /* @__PURE__ */ new Map();
861
+ conversationLRU;
862
+ sessionIdIndex = /* @__PURE__ */ new Map();
863
+ projects = /* @__PURE__ */ new Set();
864
+ indexer = new SearchIndexer();
865
+ // Tier the most recent scan() ran with, so refreshFile() re-parses a single
866
+ // file at the same content depth. Defaults to the standard tier.
867
+ lastTier = resolveTier("standard");
868
+ constructor(options) {
869
+ this.conversationLRU = new LRUCache(options?.conversationCacheSize ?? 5);
870
+ }
871
+ async scan(options = {}) {
872
+ const log = getLogger();
873
+ const startedAt = Date.now();
874
+ const profiles = await this.resolveProfiles(options.profiles);
875
+ const activeProfiles = profiles.filter((p) => p.enabled && p.scanHistory !== false);
876
+ const tier = resolveTier(options.tier ?? "standard", options.tiers);
877
+ this.lastTier = tier;
878
+ log.info(
879
+ {
880
+ activeProfiles: activeProfiles.length,
881
+ tier: tier.name,
882
+ sort: options.sort ?? "recent",
883
+ include: options.include ?? "all",
884
+ view: options.view ?? "flat"
885
+ },
886
+ "scan: start"
887
+ );
888
+ this.metadataCache.clear();
889
+ this.conversationLRU.clear();
890
+ this.sessionIdIndex.clear();
891
+ this.projects.clear();
892
+ this.indexer.clear();
893
+ const configDirs = activeProfiles.map((p) => ({
894
+ projectsDir: getProjectsDir(p),
895
+ account: p.id
896
+ }));
897
+ const files = await discoverJsonlFiles(configDirs);
898
+ const totalFiles = files.length;
899
+ let scanned = 0;
900
+ let parseFailures = 0;
901
+ const allMetas = [];
902
+ const gitBranchMemo = /* @__PURE__ */ new Map();
903
+ const resolveGitBranch = (projectPath) => {
904
+ let branch = gitBranchMemo.get(projectPath);
905
+ if (branch === void 0) {
906
+ branch = readGitBranch(projectPath);
907
+ gitBranchMemo.set(projectPath, branch);
908
+ }
909
+ return branch;
910
+ };
911
+ const { statCache } = options;
912
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
913
+ const batch = files.slice(i, i + BATCH_SIZE);
914
+ const results = await Promise.all(
915
+ batch.map(async ({ filePath, account }) => {
916
+ if (statCache) {
917
+ const cached = statCache.get(filePath);
918
+ if (cached) {
919
+ try {
920
+ const s = statSync(filePath);
921
+ if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
922
+ return cached.meta;
923
+ }
924
+ } catch {
925
+ }
926
+ }
927
+ }
928
+ try {
929
+ const meta = await parseMeta(filePath, account, tier);
930
+ if (meta) {
931
+ meta.gitBranch = resolveGitBranch(meta.projectPath);
932
+ }
933
+ return meta;
934
+ } catch (err) {
935
+ parseFailures++;
936
+ log.warn({ filePath, account, err }, "scan: parseMeta threw");
937
+ return null;
938
+ }
939
+ })
940
+ );
941
+ const batchMetas = [];
942
+ for (const meta of results) {
943
+ if (meta && meta.messageCount > 0) {
944
+ this.metadataCache.set(meta.id, meta);
945
+ this.sessionIdIndex.set(meta.sessionId, meta);
946
+ this.projects.add(meta.projectPath);
947
+ allMetas.push(meta);
948
+ batchMetas.push(meta);
949
+ this.indexer.addDocument(meta);
950
+ }
951
+ }
952
+ if (batchMetas.length > 0) {
953
+ options.onBatch?.(batchMetas);
954
+ }
955
+ scanned += batch.length;
956
+ log.debug({ scanned, totalFiles, batchKept: batchMetas.length }, "scan: batch complete");
957
+ options.onProgress?.(scanned, totalFiles);
958
+ }
959
+ let filtered = allMetas;
960
+ if (options.include && options.include !== "all") {
961
+ filtered = applyIncludeFilter(filtered, options.include);
962
+ }
963
+ if (options.project) {
964
+ filtered = applyProjectFilter(filtered, options.project);
965
+ }
966
+ if (options.account) {
967
+ filtered = applyAccountFilter(filtered, options.account);
968
+ }
969
+ if (options.since) {
970
+ filtered = applySinceFilter(filtered, options.since);
971
+ }
972
+ filtered = applySort(filtered, options.sort ?? "recent");
973
+ const total = filtered.length;
974
+ const conversations = this.transformView(filtered, options);
975
+ const elapsedMs = Date.now() - startedAt;
976
+ log.info(
977
+ {
978
+ totalFiles,
979
+ scanned,
980
+ kept: allMetas.length,
981
+ filteredTotal: total,
982
+ parseFailures,
983
+ elapsedMs
984
+ },
985
+ "scan: complete"
986
+ );
987
+ if (Array.isArray(conversations)) {
988
+ const limit = options.limit ?? 50;
989
+ const offset = options.offset ?? 0;
990
+ const paginated = applyPagination(conversations, limit, offset);
991
+ return { conversations: paginated.items, total, scanned };
992
+ }
993
+ return { conversations, total, scanned };
994
+ }
995
+ async search(query, options = {}) {
996
+ const log = getLogger();
997
+ log.debug({ query, indexSize: this.indexer.getDocumentCount() }, "search: start");
998
+ if (this.indexer.getDocumentCount() === 0) {
999
+ log.debug("search: index empty, triggering scan");
1000
+ await this.scan({ ...options, limit: void 0, offset: void 0 });
1001
+ }
1002
+ let results = this.indexer.search(query, {
1003
+ fields: options.fields,
1004
+ limit: (options.limit ?? 50) * 2
1005
+ });
1006
+ if (options.include && options.include !== "all") {
1007
+ results = results.filter((r) => {
1008
+ switch (options.include) {
1009
+ case "conversations":
1010
+ return !r.meta.isSubagent && !r.meta.isTeammate;
1011
+ case "subagents":
1012
+ return r.meta.isSubagent;
1013
+ case "teammates":
1014
+ return r.meta.isTeammate;
1015
+ default:
1016
+ return true;
1017
+ }
1018
+ });
1019
+ }
1020
+ if (options.project) {
1021
+ const lower = options.project.toLowerCase();
1022
+ results = results.filter(
1023
+ (r) => r.meta.projectPath.toLowerCase().includes(lower) || r.meta.projectName.toLowerCase().includes(lower)
1024
+ );
1025
+ }
1026
+ if (options.account) {
1027
+ results = results.filter((r) => r.meta.account === options.account);
1028
+ }
1029
+ if (options.since) {
1030
+ const cutoff = parseSinceCutoff(options.since);
1031
+ results = results.filter((r) => new Date(r.meta.timestamp).getTime() >= cutoff.getTime());
1032
+ }
1033
+ const limit = options.limit ?? 50;
1034
+ const offset = options.offset ?? 0;
1035
+ const sliced = results.slice(offset, offset + limit);
1036
+ log.debug({ query, matched: results.length, returned: sliced.length }, "search: complete");
1037
+ return sliced;
1038
+ }
1039
+ async getConversation(id, _options) {
1040
+ const log = getLogger();
1041
+ const cached = this.conversationLRU.get(id);
1042
+ if (cached) {
1043
+ log.debug({ id }, "getConversation: cache hit");
1044
+ return cached;
1045
+ }
1046
+ const meta = this.metadataCache.get(id) ?? this.sessionIdIndex.get(id);
1047
+ if (!meta) {
1048
+ log.debug({ id }, "getConversation: not found in metadata");
1049
+ return null;
1050
+ }
1051
+ log.debug({ id, filePath: meta.filePath }, "getConversation: cache miss, parsing");
1052
+ try {
1053
+ const conversation = await parseConversation(meta.filePath, meta.account);
1054
+ if (conversation) {
1055
+ this.conversationLRU.set(id, conversation);
1056
+ }
1057
+ return conversation;
1058
+ } catch (err) {
1059
+ log.warn({ id, filePath: meta.filePath, err }, "getConversation: parse failed");
1060
+ return null;
1061
+ }
1062
+ }
1063
+ // Return one bounded window of a conversation's messages plus the total
1064
+ // message count, so a caller can serve the last page and scroll back without
1065
+ // holding the whole conversation itself.
1066
+ //
1067
+ // The window is `[max(0, beforeIndex - limit), beforeIndex)` in chronological
1068
+ // order; `beforeIndex` defaults to `total` (the newest page). `fromIndex` is
1069
+ // the window's start index, so the caller can derive `has_more_older`
1070
+ // (fromIndex > 0). Returns null when the id can't be resolved/parsed — the
1071
+ // same contract as getConversation.
1072
+ //
1073
+ // Strategy: parse-once-then-slice. This delegates to getConversation, which
1074
+ // parses the full conversation and caches it in conversationLRU, then slices
1075
+ // the window. Message indices are therefore identical to a full
1076
+ // parseConversation() by construction (same parse, same messages array).
1077
+ // Repeated page requests for the same id reuse the single cached parse. The
1078
+ // bounded-memory win (not holding all messages) is deferred — see
1079
+ // docs/plans/2026-06-10-paged-conversation-parse.md.
1080
+ async getConversationPage(id, options) {
1081
+ const conversation = await this.getConversation(id);
1082
+ if (!conversation) return null;
1083
+ const { messages } = conversation;
1084
+ const total = messages.length;
1085
+ const { limit } = options;
1086
+ const beforeIndex = options.beforeIndex ?? total;
1087
+ const fromIndex = Math.max(0, beforeIndex - limit);
1088
+ const window = messages.slice(fromIndex, beforeIndex);
1089
+ return { messages: window, total, fromIndex };
1090
+ }
1091
+ // Parse one JSONL file directly and slice a page window — without any prior
1092
+ // scan() or metadata index. This is the cold-start fast path: a single
1093
+ // conversation can be served from one file parse (~ms) instead of waiting
1094
+ // for a full filesystem scan. The window is the same
1095
+ // `[max(0, beforeIndex - limit), beforeIndex)` slice as getConversationPage,
1096
+ // and the parsed Conversation is returned alongside so the caller can build
1097
+ // the response meta (projectPath, timestamp, messageCount, …) without a
1098
+ // second parse. Returns null when the file can't be parsed. `account` only
1099
+ // feeds the conversation's account field; "default" is the single-profile
1100
+ // fallback, matching refreshFile.
1101
+ async parseSingleFilePage(filePath, account, options) {
1102
+ const conversation = await parseConversation(filePath, account ?? "default");
1103
+ if (!conversation) return null;
1104
+ const { messages } = conversation;
1105
+ const total = messages.length;
1106
+ const { limit } = options;
1107
+ const beforeIndex = options.beforeIndex ?? total;
1108
+ const fromIndex = Math.max(0, beforeIndex - limit);
1109
+ const window = messages.slice(fromIndex, beforeIndex);
1110
+ return { messages: window, total, fromIndex, conversation };
1111
+ }
1112
+ // Re-parse a single JSONL file and update every in-memory index in place —
1113
+ // metadata cache, sessionId index, project set, search index — and evict the
1114
+ // file's parsed conversation from the LRU so the next getConversation()
1115
+ // re-reads it. This lets a long-lived scanner stay current with a file that
1116
+ // grew after the initial scan() without paying for a full rescan.
1117
+ //
1118
+ // `account` defaults to the account already recorded for this file (the id
1119
+ // is the file path), falling back to "default" for a file the scanner has
1120
+ // not seen before. Returns the fresh ConversationMeta, or null when the file
1121
+ // no longer parses (missing/empty) — in which case any prior entry for it is
1122
+ // dropped from all indexes.
1123
+ async refreshFile(filePath, account) {
1124
+ const log = getLogger();
1125
+ const previous = this.metadataCache.get(filePath);
1126
+ const resolvedAccount = account ?? previous?.account ?? "default";
1127
+ let meta = null;
1128
+ try {
1129
+ meta = await parseMeta(filePath, resolvedAccount, this.lastTier);
1130
+ } catch (err) {
1131
+ log.warn({ filePath, err }, "refreshFile: parseMeta threw");
1132
+ meta = null;
1133
+ }
1134
+ const evict = (m) => {
1135
+ if (!m) return;
1136
+ this.conversationLRU.delete(m.id);
1137
+ this.conversationLRU.delete(m.sessionId);
1138
+ };
1139
+ evict(previous);
1140
+ evict(meta);
1141
+ if (!meta || meta.messageCount === 0) {
1142
+ if (previous) {
1143
+ this.metadataCache.delete(previous.id);
1144
+ this.sessionIdIndex.delete(previous.sessionId);
1145
+ this.indexer.removeDocument(previous.id);
1146
+ }
1147
+ log.debug({ filePath }, "refreshFile: dropped (no parseable messages)");
1148
+ return null;
1149
+ }
1150
+ meta.gitBranch = readGitBranch(meta.projectPath);
1151
+ if (previous && previous.sessionId !== meta.sessionId) {
1152
+ this.sessionIdIndex.delete(previous.sessionId);
1153
+ }
1154
+ this.metadataCache.set(meta.id, meta);
1155
+ this.sessionIdIndex.set(meta.sessionId, meta);
1156
+ this.projects.add(meta.projectPath);
1157
+ if (previous) {
1158
+ this.indexer.updateDocument(meta);
1159
+ } else {
1160
+ this.indexer.addDocument(meta);
1161
+ }
1162
+ log.debug(
1163
+ { filePath, messageCount: meta.messageCount },
1164
+ "refreshFile: updated in-memory indexes"
1165
+ );
1166
+ return meta;
1167
+ }
1168
+ getMetadataCache() {
1169
+ return this.metadataCache;
1170
+ }
1171
+ getProjects() {
1172
+ const normalized = /* @__PURE__ */ new Set();
1173
+ for (const p of this.projects) {
1174
+ normalized.add(p.replace(/\/+$/, ""));
1175
+ }
1176
+ return Array.from(normalized).sort();
1177
+ }
1178
+ async resolveProfiles(profiles) {
1179
+ if (profiles && profiles.length > 0) return profiles;
1180
+ return loadProfiles(DEFAULT_CONFIG_PATH);
1181
+ }
1182
+ transformView(metas, options) {
1183
+ switch (options.view) {
1184
+ case "tree":
1185
+ return this.toTree(metas);
1186
+ case "grouped":
1187
+ return this.toGrouped(metas);
1188
+ default:
1189
+ return metas;
1190
+ }
1191
+ }
1192
+ toTree(metas) {
1193
+ const parents = [];
1194
+ const subagents = [];
1195
+ for (const meta of metas) {
1196
+ if (meta.isSubagent) {
1197
+ subagents.push(meta);
1198
+ } else {
1199
+ parents.push({ ...meta, subagents: [] });
1200
+ }
1201
+ }
1202
+ const parentById = new Map(parents.map((p) => [p.id, p]));
1203
+ for (const sub of subagents) {
1204
+ const parent = sub.parentSessionId ? parentById.get(sub.parentSessionId) : void 0;
1205
+ if (parent) {
1206
+ parent.subagents.push(sub);
1207
+ } else {
1208
+ parents.push({ ...sub, subagents: [] });
1209
+ }
1210
+ }
1211
+ return parents;
1212
+ }
1213
+ toGrouped(metas) {
1214
+ const groups = {};
1215
+ for (const meta of metas) {
1216
+ const key = meta.teamName || "_default";
1217
+ if (!groups[key]) groups[key] = [];
1218
+ groups[key].push(meta);
1219
+ }
1220
+ return groups;
1221
+ }
1222
+ };
1223
+
1224
+ // cli/commands/list.ts
1225
+ function registerListCommand(program2) {
1226
+ program2.command("list").description("List conversations").option("-l, --limit <n>", "Max results", "20").option("--offset <n>", "Skip N results", "0").option("-s, --sort <order>", "Sort order", "recent").option("--since <value>", "Time filter (7d, 2w, 2024-01-15)").option("-p, --project <name>", "Filter by project").option("-a, --account <name>", "Filter by account").option("--include <type>", "all|conversations|subagents|teammates", "all").option("--tier <name>", "Content tier", "standard").option("--json", "JSON output", false).action(async (opts) => {
1227
+ try {
1228
+ const profiles = await loadProfiles("~/.config/threadbase-scanner");
1229
+ const scanner = new ConversationScanner();
1230
+ const result = await scanner.scan({
1231
+ profiles,
1232
+ limit: parseInt(opts.limit, 10),
1233
+ offset: parseInt(opts.offset, 10),
1234
+ sort: opts.sort,
1235
+ since: opts.since,
1236
+ project: opts.project,
1237
+ account: opts.account,
1238
+ include: opts.include,
1239
+ tier: opts.tier
1240
+ });
1241
+ if (opts.json) {
1242
+ console.log(JSON.stringify(result, null, 2));
1243
+ } else {
1244
+ const convs = result.conversations;
1245
+ console.log(
1246
+ `Showing ${convs.length} of ${result.total} conversations (${result.scanned} files scanned)
1247
+ `
1248
+ );
1249
+ for (const c of convs) {
1250
+ const branch = c.gitBranch ? ` [${c.gitBranch}]` : "";
1251
+ const sub = c.isSubagent ? " (subagent)" : "";
1252
+ const team = c.isTeammate ? ` (team: ${c.teamName})` : "";
1253
+ console.log(` ${c.sessionId.slice(0, 8)} ${c.projectName}${branch}${sub}${team}`);
1254
+ console.log(
1255
+ ` ${c.messageCount} msgs \xB7 ${c.timestamp.slice(0, 16)} \xB7 ${c.preview.slice(0, 80)}`
1256
+ );
1257
+ console.log();
1258
+ }
1259
+ }
1260
+ } catch (err) {
1261
+ console.error("Error:", err.message);
1262
+ process.exit(1);
1263
+ }
1264
+ });
1265
+ }
1266
+
1267
+ // cli/commands/profiles.ts
1268
+ var CONFIG_PATH = "~/.config/threadbase-scanner";
1269
+ function registerProfilesCommand(program2) {
1270
+ const profiles = program2.command("profiles").description("Manage profiles");
1271
+ profiles.command("list").description("List all profiles").action(async () => {
1272
+ const all = await loadProfiles(CONFIG_PATH);
1273
+ console.log(`${all.length} profile(s):
1274
+ `);
1275
+ for (const p of all) {
1276
+ const status = p.enabled ? "enabled" : "disabled";
1277
+ const emoji = p.emoji || "";
1278
+ console.log(` ${emoji} ${p.label} (${p.id}) [${status}]`);
1279
+ console.log(` ${p.configDir}`);
1280
+ console.log();
1281
+ }
1282
+ });
1283
+ profiles.command("add <name> <config-dir>").description("Add a profile").action(async (name, configDir) => {
1284
+ const all = await loadProfiles(CONFIG_PATH);
1285
+ const id = name.toLowerCase().replace(/\s+/g, "-");
1286
+ if (all.find((p) => p.id === id)) {
1287
+ console.error(`Profile "${id}" already exists`);
1288
+ process.exit(1);
1289
+ }
1290
+ all.push({ id, label: name, configDir, enabled: true });
1291
+ await saveProfiles(all, CONFIG_PATH);
1292
+ console.log(`Added profile "${name}" -> ${configDir}`);
1293
+ });
1294
+ profiles.command("remove <name>").description("Remove a profile").action(async (name) => {
1295
+ const all = await loadProfiles(CONFIG_PATH);
1296
+ const filtered = all.filter((p) => p.id !== name);
1297
+ if (filtered.length === all.length) {
1298
+ console.error(`Profile "${name}" not found`);
1299
+ process.exit(1);
1300
+ }
1301
+ await saveProfiles(filtered, CONFIG_PATH);
1302
+ console.log(`Removed profile "${name}"`);
1303
+ });
1304
+ }
1305
+
1306
+ // cli/commands/scan.ts
1307
+ function registerScanCommand(program2) {
1308
+ program2.command("scan").description("Scan all conversations (refresh)").option("--tier <name>", "Content tier", "standard").option("--json", "JSON output", false).action(async (opts) => {
1309
+ try {
1310
+ const profiles = await loadProfiles("~/.config/threadbase-scanner");
1311
+ const scanner = new ConversationScanner();
1312
+ const start = Date.now();
1313
+ const result = await scanner.scan({
1314
+ profiles,
1315
+ tier: opts.tier,
1316
+ limit: void 0,
1317
+ onProgress: (scanned, total) => {
1318
+ if (!opts.json) {
1319
+ process.stdout.write(`\rScanning... ${scanned}/${total} files`);
1320
+ }
1321
+ }
1322
+ });
1323
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
1324
+ if (opts.json) {
1325
+ console.log(JSON.stringify(result, null, 2));
1326
+ } else {
1327
+ const projects = scanner.getProjects();
1328
+ console.log(`
1329
+ Scanned ${result.scanned} files in ${elapsed}s`);
1330
+ console.log(`Found ${result.total} conversations across ${projects.length} projects`);
1331
+ }
1332
+ } catch (err) {
1333
+ console.error("Error:", err.message);
1334
+ process.exit(1);
1335
+ }
1336
+ });
1337
+ }
1338
+
1339
+ // cli/commands/search.ts
1340
+ function registerSearchCommand(program2) {
1341
+ program2.command("search <query>").description("Search conversations").option("-l, --limit <n>", "Max results", "20").option("--offset <n>", "Skip N results", "0").option("-s, --sort <order>", "Sort order", "recent").option("--since <value>", "Time filter").option("-p, --project <name>", "Filter by project").option("-a, --account <name>", "Filter by account").option("--fields <list>", "Comma-separated field list").option("--json", "JSON output", false).action(async (query, opts) => {
1342
+ try {
1343
+ const profiles = await loadProfiles("~/.config/threadbase-scanner");
1344
+ const scanner = new ConversationScanner();
1345
+ const results = await scanner.search(query, {
1346
+ profiles,
1347
+ limit: parseInt(opts.limit, 10),
1348
+ offset: parseInt(opts.offset, 10),
1349
+ sort: opts.sort,
1350
+ since: opts.since,
1351
+ project: opts.project,
1352
+ account: opts.account,
1353
+ fields: opts.fields?.split(",")
1354
+ });
1355
+ if (opts.json) {
1356
+ console.log(JSON.stringify(results, null, 2));
1357
+ } else {
1358
+ console.log(`Found ${results.length} results for "${query}"
1359
+ `);
1360
+ for (const r of results) {
1361
+ console.log(` ${r.meta.sessionId.slice(0, 8)} ${r.meta.projectName}`);
1362
+ if (r.matches.length > 0) {
1363
+ console.log(` Match: ${r.matches[0].snippet.slice(0, 100)}`);
1364
+ }
1365
+ console.log();
1366
+ }
1367
+ }
1368
+ } catch (err) {
1369
+ console.error("Error:", err.message);
1370
+ process.exit(1);
1371
+ }
1372
+ });
1373
+ }
1374
+
1375
+ // cli/commands/show.ts
1376
+ function registerShowCommand(program2) {
1377
+ program2.command("show <session-id>").description("Show a full conversation").option("--json", "JSON output", false).action(async (sessionIdPrefix, opts) => {
1378
+ try {
1379
+ const profiles = await loadProfiles("~/.config/threadbase-scanner");
1380
+ const scanner = new ConversationScanner();
1381
+ await scanner.scan({ profiles });
1382
+ const cache = scanner.getMetadataCache();
1383
+ const matches = Array.from(cache.values()).filter(
1384
+ (m) => m.sessionId.startsWith(sessionIdPrefix)
1385
+ );
1386
+ if (matches.length === 0) {
1387
+ console.error(`No session found matching "${sessionIdPrefix}"`);
1388
+ process.exit(1);
1389
+ }
1390
+ if (matches.length > 1) {
1391
+ console.error(
1392
+ `Ambiguous prefix "${sessionIdPrefix}" \u2014 matches ${matches.length} sessions:`
1393
+ );
1394
+ for (const m of matches.slice(0, 5)) {
1395
+ console.error(` ${m.sessionId} ${m.projectName}`);
1396
+ }
1397
+ process.exit(1);
1398
+ }
1399
+ const conv = await scanner.getConversation(matches[0].id);
1400
+ if (!conv) {
1401
+ console.error("Failed to load conversation");
1402
+ process.exit(1);
1403
+ }
1404
+ if (opts.json) {
1405
+ console.log(JSON.stringify(conv, null, 2));
1406
+ } else {
1407
+ console.log(`Session: ${conv.sessionId}`);
1408
+ console.log(`Project: ${conv.projectName} (${conv.projectPath})`);
1409
+ console.log(`Messages: ${conv.messageCount}
1410
+ `);
1411
+ for (const msg of conv.messages) {
1412
+ const role = msg.role === "user" ? "User" : "Assistant";
1413
+ console.log(`[${msg.timestamp.slice(0, 19)}] ${role}:`);
1414
+ console.log(msg.text.slice(0, 500));
1415
+ console.log();
1416
+ }
1417
+ }
1418
+ } catch (err) {
1419
+ console.error("Error:", err.message);
1420
+ process.exit(1);
1421
+ }
1422
+ });
1423
+ }
1424
+
1425
+ // cli/index.ts
1426
+ setLogger(
1427
+ pino2({
1428
+ level: process.env.LOG_LEVEL ?? "info",
1429
+ transport: {
1430
+ target: "pino-pretty",
1431
+ options: {
1432
+ destination: 2,
1433
+ colorize: true,
1434
+ translateTime: "HH:MM:ss",
1435
+ ignore: "pid,hostname"
1436
+ }
1437
+ }
1438
+ })
1439
+ );
1440
+ var program = new Command().name("threadbase-scanner").description("Unified Claude Code conversation history scanner").version(version);
1441
+ registerListCommand(program);
1442
+ registerSearchCommand(program);
1443
+ registerShowCommand(program);
1444
+ registerScanCommand(program);
1445
+ registerProfilesCommand(program);
1446
+ program.parse();
1447
+ //# sourceMappingURL=cli.js.map