@geoql/mdr 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +152 -0
  3. package/USAGE.md +59 -0
  4. package/bin/detect-user.sh +53 -0
  5. package/bin/index-conversations.ts +34 -0
  6. package/bin/macrodata-daemon.ts +28 -0
  7. package/bin/macrodata-hook.sh +277 -0
  8. package/dist/bin/index-conversations.js +31 -0
  9. package/dist/bin/macrodata-daemon.js +30 -0
  10. package/dist/opencode/context.js +210 -0
  11. package/dist/opencode/conversations.js +367 -0
  12. package/dist/opencode/index.js +155 -0
  13. package/dist/opencode/journal.js +108 -0
  14. package/dist/opencode/logger.js +29 -0
  15. package/dist/opencode/search.js +210 -0
  16. package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
  17. package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  18. package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  19. package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  20. package/dist/opencode/tools.js +367 -0
  21. package/dist/src/config.js +55 -0
  22. package/dist/src/conversations.js +513 -0
  23. package/dist/src/daemon.js +582 -0
  24. package/dist/src/detect-user.js +73 -0
  25. package/dist/src/embeddings.js +190 -0
  26. package/dist/src/index.js +413 -0
  27. package/dist/src/indexer.js +286 -0
  28. package/opencode/context.ts +322 -0
  29. package/opencode/conversations.ts +467 -0
  30. package/opencode/index.ts +208 -0
  31. package/opencode/journal.ts +153 -0
  32. package/opencode/logger.ts +32 -0
  33. package/opencode/search.ts +288 -0
  34. package/opencode/skills/macrodata-distill/SKILL.md +171 -0
  35. package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  36. package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  37. package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  38. package/opencode/tools.ts +453 -0
  39. package/package.json +87 -0
  40. package/src/config.ts +66 -0
  41. package/src/conversations.ts +709 -0
  42. package/src/daemon.ts +785 -0
  43. package/src/detect-user.ts +97 -0
  44. package/src/embeddings.ts +262 -0
  45. package/src/index.ts +726 -0
  46. package/src/indexer.ts +394 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Claude Conversation Log Parser and Indexer
3
+ *
4
+ * Indexes conversation "exchanges" from Claude Code's log files for semantic search.
5
+ * Each exchange = user prompt + assistant's first text response.
6
+ *
7
+ * Features:
8
+ * - Project-biased search (current project first, then global)
9
+ * - Time-weighted scoring (recent > old)
10
+ * - Metadata: project, branch, timestamp, session
11
+ */
12
+
13
+ import { readdirSync, readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from "fs";
14
+ import { join, basename } from "path";
15
+ import { homedir } from "os";
16
+ import { embedBatch, embedQuery } from "./embeddings.js";
17
+ import { LocalIndex } from "vectra";
18
+ import { getIndexDir } from "./config.js";
19
+
20
+ // Index state tracking for incremental updates
21
+ interface IndexState {
22
+ files: Record<string, { mtime: number; exchangeIds: string[] }>;
23
+ lastUpdate: string;
24
+ }
25
+
26
+ function getIndexStatePath(): string {
27
+ return join(getIndexDir(), "conversations-state.json");
28
+ }
29
+
30
+ function loadIndexState(): IndexState {
31
+ const statePath = getIndexStatePath();
32
+ if (existsSync(statePath)) {
33
+ try {
34
+ return JSON.parse(readFileSync(statePath, "utf-8"));
35
+ } catch {
36
+ console.warn("[Conversations] Index state corrupted, starting fresh");
37
+ }
38
+ }
39
+ return { files: {}, lastUpdate: "" };
40
+ }
41
+
42
+ function saveIndexState(state: IndexState): void {
43
+ const statePath = getIndexStatePath();
44
+ const dir = getIndexDir();
45
+ if (!existsSync(dir)) {
46
+ mkdirSync(dir, { recursive: true });
47
+ }
48
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
49
+ }
50
+
51
+ // Configuration
52
+ const CLAUDE_DIR = join(homedir(), ".claude");
53
+ const PROJECTS_DIR = join(CLAUDE_DIR, "projects");
54
+
55
+ // Types
56
+ interface ConversationMessage {
57
+ type: "user" | "assistant" | "file-history-snapshot";
58
+ message?: {
59
+ role: string;
60
+ content: string | Array<{ type: string; text?: string; thinking?: string }>;
61
+ };
62
+ uuid?: string;
63
+ parentUuid?: string;
64
+ timestamp?: string;
65
+ sessionId?: string;
66
+ cwd?: string;
67
+ gitBranch?: string;
68
+ }
69
+
70
+ export interface ConversationExchange {
71
+ id: string;
72
+ userPrompt: string;
73
+ assistantSummary: string;
74
+ project: string;
75
+ projectPath: string;
76
+ branch?: string;
77
+ timestamp: string;
78
+ sessionId: string;
79
+ sessionPath: string;
80
+ messageUuid: string;
81
+ }
82
+
83
+ export interface ConversationSearchResult {
84
+ exchange: ConversationExchange;
85
+ score: number;
86
+ adjustedScore: number; // After time weighting and project boost
87
+ }
88
+
89
+ // Cached index with path tracking
90
+ let convIndex: LocalIndex | null = null;
91
+ let convIndexPath: string | null = null;
92
+
93
+ async function getConversationIndex(): Promise<LocalIndex> {
94
+ const currentIndexDir = getIndexDir();
95
+ const currentIndexPath = join(currentIndexDir, "conversations");
96
+
97
+ // Invalidate cache if path changed
98
+ if (convIndex && convIndexPath !== currentIndexPath) {
99
+ convIndex = null;
100
+ convIndexPath = null;
101
+ }
102
+
103
+ if (convIndex) return convIndex;
104
+
105
+ if (!existsSync(currentIndexDir)) {
106
+ const { mkdirSync } = await import("fs");
107
+ mkdirSync(currentIndexDir, { recursive: true });
108
+ }
109
+
110
+ convIndex = new LocalIndex(currentIndexPath);
111
+ convIndexPath = currentIndexPath;
112
+
113
+ if (!(await convIndex.isIndexCreated())) {
114
+ console.log("[Conversations] Creating new conversation index...");
115
+ await convIndex.createIndex();
116
+ }
117
+
118
+ return convIndex;
119
+ }
120
+
121
+ /**
122
+ * Decode project directory name back to path
123
+ * e.g., "-Users-alex-Repos-my-project" -> "/Users/alex/Repos/my-project"
124
+ */
125
+ function decodeProjectPath(encoded: string): string {
126
+ return encoded.replace(/^-/, "/").replace(/-/g, "/");
127
+ }
128
+
129
+ /**
130
+ * Extract project name from path
131
+ */
132
+ function getProjectName(projectPath: string): string {
133
+ return basename(projectPath);
134
+ }
135
+
136
+ /**
137
+ * Extract first text content from assistant message
138
+ */
139
+ function extractAssistantText(content: string | Array<{ type: string; text?: string }>): string {
140
+ if (typeof content === "string") {
141
+ return content.slice(0, 500);
142
+ }
143
+
144
+ for (const block of content) {
145
+ if (block.type === "text" && block.text) {
146
+ return block.text.slice(0, 500);
147
+ }
148
+ }
149
+
150
+ return "";
151
+ }
152
+
153
+ /**
154
+ * Check if user message content is a tool result (not an actual user prompt)
155
+ */
156
+ function isToolResult(content: unknown): boolean {
157
+ if (Array.isArray(content)) {
158
+ // Array content with tool_result or tool_use_id = tool result, not user prompt
159
+ return content.some((item) => item.type === "tool_result" || item.tool_use_id !== undefined);
160
+ }
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Check if content is noise we should skip indexing
166
+ */
167
+ function isNoiseContent(content: string): boolean {
168
+ // Skip compacted session summaries
169
+ if (content.startsWith("This session is being continued from a previous conversation")) {
170
+ return true;
171
+ }
172
+
173
+ // Skip local command outputs
174
+ if (
175
+ content.includes("<local-command-stdout>") ||
176
+ content.includes("<local-command-caveat>") ||
177
+ content.includes("<command-name>")
178
+ ) {
179
+ return true;
180
+ }
181
+
182
+ // Skip hook-injected context (standalone, not part of agent context)
183
+ if (
184
+ content.startsWith("<current_time>") ||
185
+ content.startsWith("<context_status>") ||
186
+ content.startsWith("<state_files>") ||
187
+ content.startsWith("<system-reminder>") ||
188
+ content.startsWith("## Current State Files") ||
189
+ content.startsWith("Base directory for this skill:")
190
+ ) {
191
+ return true;
192
+ }
193
+
194
+ // Skip empty messages
195
+ if (content.trim().length < 1) {
196
+ return true;
197
+ }
198
+
199
+ return false;
200
+ }
201
+
202
+ /**
203
+ * Extract actual user text from message content, filtering out tool results
204
+ */
205
+ function extractUserText(content: string | unknown[]): string {
206
+ let text = "";
207
+
208
+ if (typeof content === "string") {
209
+ text = content;
210
+ } else {
211
+ // Array content - try to find actual text blocks
212
+ for (const block of content) {
213
+ const b = block as Record<string, unknown>;
214
+ /* v8 ignore next 3 -- defensive: callers pre-filter tool-result arrays via
215
+ isToolResult(), so this redundant guard is never the deciding skip. */
216
+ if (b.type === "tool_result" || b.tool_use_id !== undefined) {
217
+ continue;
218
+ }
219
+ // Look for text content
220
+ if (b.type === "text" && typeof b.text === "string") {
221
+ text = b.text;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+
227
+ if (!text) return "";
228
+
229
+ // Extract actual user message from agent context blocks
230
+ // These have format: "# Agent Context\n...\nUser message: <actual message>"
231
+ if (text.startsWith("# Agent Context") || text.includes("\nUser message: ")) {
232
+ const userMsgMatch = text.match(/\nUser message: (.+)$/s);
233
+ if (userMsgMatch) {
234
+ return userMsgMatch[1].trim();
235
+ }
236
+ // No user message found in context block - skip it
237
+ return "";
238
+ }
239
+
240
+ return text;
241
+ }
242
+
243
+ /**
244
+ * Parse a conversation file and extract exchanges
245
+ */
246
+ function parseConversationFile(filePath: string, projectPath: string): ConversationExchange[] {
247
+ const exchanges: ConversationExchange[] = [];
248
+ const projectName = getProjectName(projectPath);
249
+
250
+ try {
251
+ const content = readFileSync(filePath, "utf-8");
252
+ const lines = content.trim().split("\n").filter(Boolean);
253
+
254
+ let currentUser: { msg: ConversationMessage; text: string } | null = null;
255
+ let malformedLines = 0;
256
+
257
+ for (const line of lines) {
258
+ try {
259
+ const msg: ConversationMessage = JSON.parse(line);
260
+
261
+ if (msg.type === "user" && msg.message?.content) {
262
+ // Skip tool results - these aren't actual user prompts
263
+ if (isToolResult(msg.message.content)) {
264
+ continue;
265
+ }
266
+
267
+ // Extract the actual text
268
+ const userText = extractUserText(msg.message.content);
269
+
270
+ // Skip noise (command outputs, compacted summaries, very short messages)
271
+ if (!userText || isNoiseContent(userText)) {
272
+ continue;
273
+ }
274
+
275
+ currentUser = { msg, text: userText };
276
+ } else if (msg.type === "assistant" && currentUser && msg.message?.content) {
277
+ // Found a user-assistant pair. currentUser.text is always non-empty
278
+ // here (only truthy user text is stored above), so it needs no guard.
279
+ const assistantText = extractAssistantText(msg.message.content);
280
+ exchanges.push({
281
+ id: `conv-${currentUser.msg.sessionId}-${currentUser.msg.uuid}`,
282
+ userPrompt: currentUser.text.slice(0, 1000),
283
+ assistantSummary: assistantText,
284
+ project: projectName,
285
+ projectPath: projectPath,
286
+ branch: currentUser.msg.gitBranch,
287
+ timestamp: currentUser.msg.timestamp || new Date().toISOString(),
288
+ sessionId: currentUser.msg.sessionId || basename(filePath, ".jsonl"),
289
+ sessionPath: filePath,
290
+ messageUuid: currentUser.msg.uuid || "",
291
+ });
292
+
293
+ currentUser = null; // Reset for next exchange
294
+ }
295
+ } catch {
296
+ malformedLines++;
297
+ }
298
+ }
299
+
300
+ if (malformedLines > 0) {
301
+ console.warn(`[Conversations] Skipped ${malformedLines} malformed lines in ${filePath}`);
302
+ }
303
+ } catch (err) {
304
+ console.error(`[Conversations] Failed to parse ${filePath}: ${String(err)}`);
305
+ }
306
+
307
+ return exchanges;
308
+ }
309
+
310
+ /**
311
+ * Scan all Claude project directories for conversation files
312
+ */
313
+ function* scanConversationFiles(): Generator<{
314
+ filePath: string;
315
+ projectPath: string;
316
+ mtime: number;
317
+ }> {
318
+ if (!existsSync(PROJECTS_DIR)) {
319
+ return;
320
+ }
321
+
322
+ const projectDirs = readdirSync(PROJECTS_DIR);
323
+
324
+ for (const projectDir of projectDirs) {
325
+ if (projectDir.startsWith(".")) continue;
326
+
327
+ const projectPath = decodeProjectPath(projectDir);
328
+ const projectFullPath = join(PROJECTS_DIR, projectDir);
329
+
330
+ if (!statSync(projectFullPath).isDirectory()) continue;
331
+
332
+ const files = readdirSync(projectFullPath);
333
+
334
+ for (const file of files) {
335
+ // Skip agent files, only process main conversation files
336
+ if (!file.endsWith(".jsonl") || file.startsWith("agent-")) continue;
337
+
338
+ const filePath = join(projectFullPath, file);
339
+ const mtime = statSync(filePath).mtimeMs;
340
+
341
+ yield { filePath, projectPath, mtime };
342
+ }
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Rebuild the conversation index from scratch
348
+ */
349
+ export async function rebuildConversationIndex(): Promise<{ exchangeCount: number }> {
350
+ console.log("[Conversations] Starting full index rebuild...");
351
+ const startTime = Date.now();
352
+
353
+ const allExchanges: ConversationExchange[] = [];
354
+ const newState: IndexState = { files: {}, lastUpdate: new Date().toISOString() };
355
+
356
+ for (const { filePath, projectPath, mtime } of scanConversationFiles()) {
357
+ const exchanges = parseConversationFile(filePath, projectPath);
358
+ allExchanges.push(...exchanges);
359
+ newState.files[filePath] = {
360
+ mtime,
361
+ exchangeIds: exchanges.map((e) => e.id),
362
+ };
363
+ }
364
+
365
+ console.log(`[Conversations] Found ${allExchanges.length} exchanges`);
366
+
367
+ if (allExchanges.length === 0) {
368
+ saveIndexState(newState);
369
+ return { exchangeCount: 0 };
370
+ }
371
+
372
+ // Create embeddings for all exchanges
373
+ const texts = allExchanges.map(
374
+ (e) => `${e.project}${e.branch ? ` (${e.branch})` : ""}: ${e.userPrompt}`,
375
+ );
376
+
377
+ console.log(`[Conversations] Generating embeddings...`);
378
+ const vectors = await embedBatch(texts);
379
+
380
+ const idx = await getConversationIndex();
381
+
382
+ // Index all exchanges in a single update transaction (one index.json write)
383
+ await idx.beginUpdate();
384
+ try {
385
+ for (let i = 0; i < allExchanges.length; i++) {
386
+ const exchange = allExchanges[i];
387
+ await idx.upsertItem({
388
+ id: exchange.id,
389
+ vector: vectors[i],
390
+ metadata: {
391
+ userPrompt: exchange.userPrompt,
392
+ assistantSummary: exchange.assistantSummary,
393
+ project: exchange.project,
394
+ projectPath: exchange.projectPath,
395
+ branch: exchange.branch || "",
396
+ timestamp: exchange.timestamp,
397
+ sessionId: exchange.sessionId,
398
+ sessionPath: exchange.sessionPath,
399
+ messageUuid: exchange.messageUuid,
400
+ },
401
+ });
402
+ }
403
+ await idx.endUpdate();
404
+ } catch (err) {
405
+ idx.cancelUpdate();
406
+ throw err;
407
+ }
408
+
409
+ saveIndexState(newState);
410
+
411
+ const duration = Date.now() - startTime;
412
+ console.log(`[Conversations] Full rebuild complete in ${duration}ms`);
413
+
414
+ return { exchangeCount: allExchanges.length };
415
+ }
416
+
417
+ /**
418
+ * Incrementally update the conversation index (only changed files)
419
+ */
420
+ export async function updateConversationIndex(): Promise<{
421
+ exchangeCount: number;
422
+ filesUpdated: number;
423
+ skipped: number;
424
+ }> {
425
+ console.log("[Conversations] Starting incremental update...");
426
+ const startTime = Date.now();
427
+
428
+ const state = loadIndexState();
429
+ const idx = await getConversationIndex();
430
+
431
+ /* v8 ignore next 5 -- defensive: getConversationIndex() above always creates
432
+ the index, so isIndexCreated() is never false here; this full-rebuild
433
+ fallback only guards a future change to that contract. */
434
+ if (!(await idx.isIndexCreated())) {
435
+ console.log("[Conversations] No existing index, doing full rebuild");
436
+ const result = await rebuildConversationIndex();
437
+ return { exchangeCount: result.exchangeCount, filesUpdated: 0, skipped: 0 };
438
+ }
439
+
440
+ let filesUpdated = 0;
441
+ let skipped = 0;
442
+ let totalExchanges = 0;
443
+ const currentFiles = new Set<string>();
444
+
445
+ for (const { filePath, projectPath, mtime } of scanConversationFiles()) {
446
+ currentFiles.add(filePath);
447
+ const cached = state.files[filePath];
448
+
449
+ // Skip if file hasn't changed
450
+ if (cached && cached.mtime === mtime) {
451
+ skipped++;
452
+ totalExchanges += cached.exchangeIds.length;
453
+ continue;
454
+ }
455
+
456
+ // File is new or modified - parse and index
457
+ const exchanges = parseConversationFile(filePath, projectPath);
458
+
459
+ if (exchanges.length > 0) {
460
+ const texts = exchanges.map(
461
+ (e) => `${e.project}${e.branch ? ` (${e.branch})` : ""}: ${e.userPrompt}`,
462
+ );
463
+ const vectors = await embedBatch(texts);
464
+
465
+ await idx.beginUpdate();
466
+ try {
467
+ for (let i = 0; i < exchanges.length; i++) {
468
+ const exchange = exchanges[i];
469
+ await idx.upsertItem({
470
+ id: exchange.id,
471
+ vector: vectors[i],
472
+ metadata: {
473
+ userPrompt: exchange.userPrompt,
474
+ assistantSummary: exchange.assistantSummary,
475
+ project: exchange.project,
476
+ projectPath: exchange.projectPath,
477
+ branch: exchange.branch || "",
478
+ timestamp: exchange.timestamp,
479
+ sessionId: exchange.sessionId,
480
+ sessionPath: exchange.sessionPath,
481
+ messageUuid: exchange.messageUuid,
482
+ },
483
+ });
484
+ }
485
+ await idx.endUpdate();
486
+ } catch (err) {
487
+ idx.cancelUpdate();
488
+ throw err;
489
+ }
490
+ }
491
+
492
+ state.files[filePath] = {
493
+ mtime,
494
+ exchangeIds: exchanges.map((e) => e.id),
495
+ };
496
+ filesUpdated++;
497
+ totalExchanges += exchanges.length;
498
+ }
499
+
500
+ // Clean up deleted files from state (but don't remove from index - they may still be useful)
501
+ for (const filePath of Object.keys(state.files)) {
502
+ if (!currentFiles.has(filePath)) {
503
+ delete state.files[filePath];
504
+ }
505
+ }
506
+
507
+ state.lastUpdate = new Date().toISOString();
508
+ saveIndexState(state);
509
+
510
+ const duration = Date.now() - startTime;
511
+ console.log(
512
+ `[Conversations] Incremental update complete in ${duration}ms (${filesUpdated} files updated, ${skipped} skipped)`,
513
+ );
514
+
515
+ return { exchangeCount: totalExchanges, filesUpdated, skipped };
516
+ }
517
+
518
+ /**
519
+ * Calculate time-based weight for scoring
520
+ * Recent = higher weight
521
+ */
522
+ function getTimeWeight(timestamp: string): number {
523
+ const age = Date.now() - new Date(timestamp).getTime();
524
+ const dayMs = 24 * 60 * 60 * 1000;
525
+
526
+ if (age < 7 * dayMs) return 1.0; // Last week: full weight
527
+ if (age < 30 * dayMs) return 0.9; // Last month: 90%
528
+ if (age < 90 * dayMs) return 0.7; // Last 3 months: 70%
529
+ if (age < 365 * dayMs) return 0.5; // Last year: 50%
530
+ return 0.3; // Older: 30%
531
+ }
532
+
533
+ /**
534
+ * Search conversations with project bias and time weighting
535
+ */
536
+ export async function searchConversations(
537
+ query: string,
538
+ options: {
539
+ currentProject?: string; // Path to current project for boosting
540
+ limit?: number;
541
+ projectOnly?: boolean; // Only search current project
542
+ } = {},
543
+ ): Promise<ConversationSearchResult[]> {
544
+ const { currentProject, limit = 5, projectOnly = false } = options;
545
+
546
+ const idx = await getConversationIndex();
547
+ const stats = await idx.listItems();
548
+
549
+ if (stats.length === 0) {
550
+ console.log("[Conversations] Index is empty");
551
+ return [];
552
+ }
553
+
554
+ const queryVector = await embedQuery(query);
555
+
556
+ // Get more results than needed for filtering/reranking
557
+ const results = await idx.queryItems(queryVector, query, limit * 3);
558
+
559
+ // Convert to search results with adjusted scoring
560
+ const searchResults: ConversationSearchResult[] = results.map((r) => {
561
+ const meta = r.item.metadata as Record<string, string>;
562
+
563
+ const exchange: ConversationExchange = {
564
+ id: r.item.id,
565
+ userPrompt: meta.userPrompt,
566
+ assistantSummary: meta.assistantSummary,
567
+ project: meta.project,
568
+ projectPath: meta.projectPath,
569
+ branch: meta.branch || undefined,
570
+ timestamp: meta.timestamp,
571
+ sessionId: meta.sessionId,
572
+ sessionPath: meta.sessionPath,
573
+ messageUuid: meta.messageUuid,
574
+ };
575
+
576
+ // Calculate adjusted score
577
+ let adjustedScore = r.score;
578
+
579
+ // Time weighting
580
+ adjustedScore *= getTimeWeight(exchange.timestamp);
581
+
582
+ // Project boost (1.5x for current project)
583
+ if (currentProject && exchange.projectPath === currentProject) {
584
+ adjustedScore *= 1.5;
585
+ }
586
+
587
+ return {
588
+ exchange,
589
+ score: r.score,
590
+ adjustedScore,
591
+ };
592
+ });
593
+
594
+ // Filter to current project if requested
595
+ let filtered = searchResults;
596
+ if (projectOnly && currentProject) {
597
+ filtered = searchResults.filter((r) => r.exchange.projectPath === currentProject);
598
+ }
599
+
600
+ // Sort by adjusted score and limit
601
+ return filtered.sort((a, b) => b.adjustedScore - a.adjustedScore).slice(0, limit);
602
+ }
603
+
604
+ /**
605
+ * Load full conversation context around a specific message
606
+ */
607
+ export async function expandConversation(
608
+ sessionPath: string,
609
+ messageUuid: string,
610
+ contextMessages: number = 10,
611
+ ): Promise<{
612
+ messages: Array<{ role: string; content: string; timestamp?: string }>;
613
+ project: string;
614
+ branch?: string;
615
+ }> {
616
+ if (!existsSync(sessionPath)) {
617
+ throw new Error(`Session file not found: ${sessionPath}`);
618
+ }
619
+
620
+ const content = readFileSync(sessionPath, "utf-8");
621
+ const lines = content.trim().split("\n").filter(Boolean);
622
+
623
+ const messages: Array<{ role: string; content: string; timestamp?: string; uuid?: string }> = [];
624
+ let project = "";
625
+ let branch: string | undefined;
626
+ let malformedLines = 0;
627
+
628
+ // Parse all messages
629
+ for (const line of lines) {
630
+ try {
631
+ const msg: ConversationMessage = JSON.parse(line);
632
+
633
+ if (msg.type === "user" && msg.message?.content) {
634
+ // Skip tool results
635
+ if (isToolResult(msg.message.content)) {
636
+ continue;
637
+ }
638
+
639
+ const text = extractUserText(msg.message.content);
640
+
641
+ // Skip empty or noise content
642
+ if (!text || isNoiseContent(text)) {
643
+ continue;
644
+ }
645
+
646
+ messages.push({
647
+ role: "user",
648
+ content: text,
649
+ timestamp: msg.timestamp,
650
+ uuid: msg.uuid,
651
+ });
652
+
653
+ if (!project && msg.cwd) {
654
+ project = getProjectName(msg.cwd);
655
+ }
656
+ if (!branch && msg.gitBranch) {
657
+ branch = msg.gitBranch;
658
+ }
659
+ } else if (msg.type === "assistant" && msg.message?.content) {
660
+ const text = extractAssistantText(msg.message.content);
661
+ if (text) {
662
+ messages.push({
663
+ role: "assistant",
664
+ content: text,
665
+ timestamp: msg.timestamp,
666
+ uuid: msg.uuid,
667
+ });
668
+ }
669
+ }
670
+ } catch {
671
+ malformedLines++;
672
+ }
673
+ }
674
+
675
+ if (malformedLines > 0) {
676
+ console.warn(`[Conversations] Skipped ${malformedLines} malformed lines in ${sessionPath}`);
677
+ }
678
+
679
+ // Find the target message index
680
+ const targetIdx = messages.findIndex((m) => m.uuid === messageUuid);
681
+
682
+ if (targetIdx === -1) {
683
+ // Return last N messages if target not found
684
+ return {
685
+ messages: messages.slice(-contextMessages).map(({ uuid: _uuid, ...rest }) => rest),
686
+ project,
687
+ branch,
688
+ };
689
+ }
690
+
691
+ // Return context around target
692
+ const start = Math.max(0, targetIdx - Math.floor(contextMessages / 2));
693
+ const end = Math.min(messages.length, start + contextMessages);
694
+
695
+ return {
696
+ messages: messages.slice(start, end).map(({ uuid: _uuid, ...rest }) => rest),
697
+ project,
698
+ branch,
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Get conversation index stats
704
+ */
705
+ export async function getConversationIndexStats(): Promise<{ exchangeCount: number }> {
706
+ const idx = await getConversationIndex();
707
+ const items = await idx.listItems();
708
+ return { exchangeCount: items.length };
709
+ }