@aeriondyseti/vector-memory-mcp 1.0.2-dev.0 → 1.1.0-dev.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.
Files changed (68) hide show
  1. package/dist/package.json +4 -4
  2. package/dist/src/config/index.d.ts +10 -0
  3. package/dist/src/config/index.d.ts.map +1 -1
  4. package/dist/src/config/index.js +13 -0
  5. package/dist/src/config/index.js.map +1 -1
  6. package/dist/src/db/conversation-history.repository.d.ts +24 -0
  7. package/dist/src/db/conversation-history.repository.d.ts.map +1 -0
  8. package/dist/src/db/conversation-history.repository.js +184 -0
  9. package/dist/src/db/conversation-history.repository.js.map +1 -0
  10. package/dist/src/db/conversation-history.schema.d.ts +10 -0
  11. package/dist/src/db/conversation-history.schema.d.ts.map +1 -0
  12. package/dist/src/db/conversation-history.schema.js +31 -0
  13. package/dist/src/db/conversation-history.schema.js.map +1 -0
  14. package/dist/src/db/lancedb-utils.d.ts +35 -0
  15. package/dist/src/db/lancedb-utils.d.ts.map +1 -0
  16. package/dist/src/db/lancedb-utils.js +77 -0
  17. package/dist/src/db/lancedb-utils.js.map +1 -0
  18. package/dist/src/db/memory.repository.d.ts +2 -12
  19. package/dist/src/db/memory.repository.d.ts.map +1 -1
  20. package/dist/src/db/memory.repository.js +13 -56
  21. package/dist/src/db/memory.repository.js.map +1 -1
  22. package/dist/src/db/schema.d.ts +4 -1
  23. package/dist/src/db/schema.d.ts.map +1 -1
  24. package/dist/src/db/schema.js +8 -4
  25. package/dist/src/db/schema.js.map +1 -1
  26. package/dist/src/index.js +8 -0
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/mcp/handlers.d.ts +3 -0
  29. package/dist/src/mcp/handlers.d.ts.map +1 -1
  30. package/dist/src/mcp/handlers.js +149 -17
  31. package/dist/src/mcp/handlers.js.map +1 -1
  32. package/dist/src/mcp/tools.d.ts +3 -0
  33. package/dist/src/mcp/tools.d.ts.map +1 -1
  34. package/dist/src/mcp/tools.js +54 -3
  35. package/dist/src/mcp/tools.js.map +1 -1
  36. package/dist/src/services/conversation-history.service.d.ts +64 -0
  37. package/dist/src/services/conversation-history.service.d.ts.map +1 -0
  38. package/dist/src/services/conversation-history.service.js +244 -0
  39. package/dist/src/services/conversation-history.service.js.map +1 -0
  40. package/dist/src/services/memory.service.d.ts +24 -0
  41. package/dist/src/services/memory.service.d.ts.map +1 -1
  42. package/dist/src/services/memory.service.js +58 -0
  43. package/dist/src/services/memory.service.js.map +1 -1
  44. package/dist/src/services/session-parser.d.ts +59 -0
  45. package/dist/src/services/session-parser.d.ts.map +1 -0
  46. package/dist/src/services/session-parser.js +147 -0
  47. package/dist/src/services/session-parser.js.map +1 -0
  48. package/dist/src/types/conversation-history.d.ts +74 -0
  49. package/dist/src/types/conversation-history.d.ts.map +1 -0
  50. package/dist/src/types/conversation-history.js +2 -0
  51. package/dist/src/types/conversation-history.js.map +1 -0
  52. package/dist/src/types/memory.d.ts +4 -2
  53. package/dist/src/types/memory.d.ts.map +1 -1
  54. package/package.json +4 -4
  55. package/src/config/index.ts +23 -0
  56. package/src/db/conversation-history.repository.ts +255 -0
  57. package/src/db/conversation-history.schema.ts +40 -0
  58. package/src/db/lancedb-utils.ts +97 -0
  59. package/src/db/memory.repository.ts +18 -67
  60. package/src/db/schema.ts +17 -21
  61. package/src/index.ts +16 -0
  62. package/src/mcp/handlers.ts +178 -22
  63. package/src/mcp/tools.ts +66 -3
  64. package/src/services/conversation-history.service.ts +320 -0
  65. package/src/services/memory.service.ts +74 -0
  66. package/src/services/session-parser.ts +232 -0
  67. package/src/types/conversation-history.ts +82 -0
  68. package/src/types/memory.ts +4 -3
@@ -0,0 +1,320 @@
1
+ import { readdir, stat } from "fs/promises";
2
+ import { join } from "path";
3
+ import type { ConversationHistoryRepository } from "../db/conversation-history.repository.js";
4
+ import type { EmbeddingsService } from "./embeddings.service.js";
5
+ import {
6
+ parseSessionFile,
7
+ discoverSessionFiles,
8
+ detectSessionPath,
9
+ type ParsedMessage,
10
+ type ParseResult,
11
+ type SessionFileInfo,
12
+ } from "./session-parser.js";
13
+ import type {
14
+ ConversationHistoryEntry,
15
+ HistorySearchResult,
16
+ IndexedSession,
17
+ IndexedSessionSummary,
18
+ IndexingSummary,
19
+ } from "../types/conversation-history.js";
20
+
21
+ const EMBED_BATCH_SIZE = 50;
22
+
23
+ export class ConversationHistoryService {
24
+ constructor(
25
+ private repository: ConversationHistoryRepository,
26
+ private embeddings: EmbeddingsService,
27
+ private sessionPath: string | null, // null = auto-detect
28
+ ) {}
29
+
30
+ /**
31
+ * Index all conversation sessions found in the session directory.
32
+ *
33
+ * For each .jsonl file discovered:
34
+ * - New (not tracked): full parse from byte 0
35
+ * - Grown (fileSize increased): incremental parse from last-known size
36
+ * - Shrunk (fileSize decreased — file replaced): delete + full reindex
37
+ * - Unchanged (same fileSize): skip
38
+ */
39
+ async indexConversations(sessionDir?: string): Promise<IndexingSummary> {
40
+ const allFiles = await this.discoverAllFiles(sessionDir);
41
+
42
+ // Bulk-fetch all tracked sessions into a Map to avoid N+1 lookups
43
+ const trackedSessions = await this.buildSessionIndex();
44
+
45
+ const summary: IndexingSummary = {
46
+ sessionsDiscovered: allFiles.length,
47
+ sessionsIndexed: 0,
48
+ sessionsSkipped: 0,
49
+ messagesIndexed: 0,
50
+ };
51
+
52
+ for (const file of allFiles) {
53
+ const indexed = trackedSessions.get(file.sessionId) ?? null;
54
+
55
+ if (indexed && indexed.fileSize === file.fileSize) {
56
+ // Unchanged — skip
57
+ summary.sessionsSkipped++;
58
+ continue;
59
+ }
60
+
61
+ if (indexed && file.fileSize < indexed.fileSize) {
62
+ // Shrunk — file was replaced, full reindex
63
+ await this.repository.deleteBySessionId(file.sessionId);
64
+ await this.repository.deleteIndexedSession(file.sessionId);
65
+ const count = await this.indexFile(file, 0, 0, null);
66
+ summary.sessionsIndexed++;
67
+ summary.messagesIndexed += count;
68
+ continue;
69
+ }
70
+
71
+ if (indexed && file.fileSize > indexed.fileSize) {
72
+ // Grown — incremental parse from where we left off
73
+ const count = await this.indexFile(
74
+ file,
75
+ indexed.fileSize,
76
+ indexed.messageCount,
77
+ indexed,
78
+ );
79
+ summary.sessionsIndexed++;
80
+ summary.messagesIndexed += count;
81
+ continue;
82
+ }
83
+
84
+ // New — full parse
85
+ const count = await this.indexFile(file, 0, 0, null);
86
+ summary.sessionsIndexed++;
87
+ summary.messagesIndexed += count;
88
+ }
89
+
90
+ return summary;
91
+ }
92
+
93
+ /**
94
+ * Search conversation history using hybrid (vector + FTS) search.
95
+ */
96
+ async search(query: string, limit: number): Promise<HistorySearchResult[]> {
97
+ const embedding = await this.embeddings.embed(query);
98
+ const rows = await this.repository.findHybrid(embedding, query, limit);
99
+
100
+ return rows.map((row) => ({
101
+ source: "conversation_history" as const,
102
+ id: row.id,
103
+ content: row.content,
104
+ metadata: row.metadata,
105
+ score: row.rrfScore,
106
+ sessionId: row.sessionId,
107
+ role: row.role,
108
+ messageIndex: row.messageIndex,
109
+ timestamp: row.timestamp,
110
+ }));
111
+ }
112
+
113
+ /**
114
+ * List all indexed sessions (pass-through to repository).
115
+ */
116
+ async listIndexedSessions(): Promise<IndexedSessionSummary[]> {
117
+ return this.repository.listIndexedSessions();
118
+ }
119
+
120
+ /**
121
+ * Force a full reindex of a specific session.
122
+ * Deletes all existing entries and tracking, then re-parses from byte 0.
123
+ */
124
+ async reindexSession(sessionId: string): Promise<IndexingSummary> {
125
+ const indexed = await this.repository.getIndexedSession(sessionId);
126
+
127
+ const summary: IndexingSummary = {
128
+ sessionsDiscovered: 1,
129
+ sessionsIndexed: 0,
130
+ sessionsSkipped: 0,
131
+ messagesIndexed: 0,
132
+ };
133
+
134
+ if (!indexed) {
135
+ // Nothing to reindex — no tracking record means we don't know the file path
136
+ summary.sessionsSkipped = 1;
137
+ return summary;
138
+ }
139
+
140
+ // Delete existing data
141
+ await this.repository.deleteBySessionId(sessionId);
142
+ await this.repository.deleteIndexedSession(sessionId);
143
+
144
+ // Get current file size (file may have changed since last index)
145
+ let fileSize: number;
146
+ try {
147
+ const stats = await stat(indexed.filePath);
148
+ fileSize = stats.size;
149
+ } catch {
150
+ // File no longer exists
151
+ summary.sessionsSkipped = 1;
152
+ return summary;
153
+ }
154
+
155
+ const fileInfo: SessionFileInfo = {
156
+ sessionId,
157
+ filePath: indexed.filePath,
158
+ fileSize,
159
+ };
160
+
161
+ const count = await this.indexFile(fileInfo, 0, 0, null);
162
+ summary.sessionsIndexed = 1;
163
+ summary.messagesIndexed = count;
164
+ return summary;
165
+ }
166
+
167
+ // --- Private helpers ---
168
+
169
+ /**
170
+ * Bulk-fetch all tracked sessions into a Map for O(1) lookups.
171
+ * Uses listIndexedSessions() which returns summaries, but we need full
172
+ * IndexedSession records. We call getIndexedSession() is avoided by using
173
+ * a repository method that returns all sessions with full details.
174
+ *
175
+ * Note: listIndexedSessions returns IndexedSessionSummary (no filePath/fileSize),
176
+ * so we use getIndexedSession per unique session. However, we batch this via
177
+ * the list + individual fetches only when needed. For now, we fetch all as
178
+ * summaries and promote to full records via individual lookups grouped upfront.
179
+ */
180
+ private async buildSessionIndex(): Promise<Map<string, IndexedSession>> {
181
+ const summaries = await this.repository.listIndexedSessions();
182
+ const sessionMap = new Map<string, IndexedSession>();
183
+
184
+ // Fetch full records in parallel for all known sessions
185
+ const fullRecords = await Promise.all(
186
+ summaries.map((s) => this.repository.getIndexedSession(s.sessionId)),
187
+ );
188
+
189
+ for (const record of fullRecords) {
190
+ if (record) {
191
+ sessionMap.set(record.sessionId, record);
192
+ }
193
+ }
194
+
195
+ return sessionMap;
196
+ }
197
+
198
+ /**
199
+ * Discover all .jsonl files across resolved session directories.
200
+ * Resolves dirs and discovers files in one pass to avoid double-scanning.
201
+ */
202
+ private async discoverAllFiles(sessionDir?: string): Promise<SessionFileInfo[]> {
203
+ const base = sessionDir ?? this.sessionPath ?? detectSessionPath();
204
+ if (!base) return [];
205
+
206
+ // Check if base dir itself has .jsonl files
207
+ const rootFiles = await discoverSessionFiles(base);
208
+ if (rootFiles.length > 0) return rootFiles;
209
+
210
+ // Otherwise enumerate subdirectories and discover files in each
211
+ const dirs = await this.listSubdirectories(base);
212
+ const nested = await Promise.all(dirs.map((d) => discoverSessionFiles(d)));
213
+ return nested.flat();
214
+ }
215
+
216
+ /**
217
+ * List immediate subdirectories of a path. Stat calls are parallelized.
218
+ */
219
+ private async listSubdirectories(base: string): Promise<string[]> {
220
+ let entries: string[];
221
+ try {
222
+ entries = await readdir(base);
223
+ } catch {
224
+ return [];
225
+ }
226
+
227
+ const results = await Promise.allSettled(
228
+ entries.map(async (entry) => {
229
+ const fullPath = join(base, entry);
230
+ const stats = await stat(fullPath);
231
+ return stats.isDirectory() ? fullPath : null;
232
+ }),
233
+ );
234
+
235
+ return results
236
+ .filter((r): r is PromiseFulfilledResult<string | null> => r.status === "fulfilled")
237
+ .map((r) => r.value)
238
+ .filter((v): v is string => v != null);
239
+ }
240
+
241
+ /**
242
+ * Parse a session file, embed messages in batches, insert into repository,
243
+ * and upsert the tracking record. Returns count of messages indexed.
244
+ */
245
+ private async indexFile(
246
+ file: SessionFileInfo,
247
+ fromByte: number,
248
+ startIndex: number,
249
+ existing: IndexedSession | null,
250
+ ): Promise<number> {
251
+ const parseResult = await parseSessionFile(
252
+ file.filePath,
253
+ fromByte,
254
+ startIndex,
255
+ file.fileSize,
256
+ );
257
+
258
+ if (parseResult.messages.length === 0) {
259
+ // Still upsert tracking so we don't re-parse an empty/no-new-content file
260
+ await this.upsertTracking(file, parseResult.messages, startIndex, parseResult, existing);
261
+ return 0;
262
+ }
263
+
264
+ // Embed and insert in batches
265
+ for (let i = 0; i < parseResult.messages.length; i += EMBED_BATCH_SIZE) {
266
+ const batch = parseResult.messages.slice(i, i + EMBED_BATCH_SIZE);
267
+ const texts = batch.map((m) => m.content);
268
+ const embeddings = await this.embeddings.embedBatch(texts);
269
+
270
+ const entries: ConversationHistoryEntry[] = batch.map((msg, idx) => ({
271
+ id: msg.id,
272
+ content: msg.content,
273
+ embedding: embeddings[idx],
274
+ sessionId: msg.sessionId,
275
+ role: msg.role,
276
+ messageIndex: msg.messageIndex,
277
+ timestamp: msg.timestamp,
278
+ metadata: msg.metadata,
279
+ createdAt: new Date(),
280
+ }));
281
+
282
+ await this.repository.insert(entries);
283
+ }
284
+
285
+ await this.upsertTracking(file, parseResult.messages, startIndex, parseResult, existing);
286
+ return parseResult.messages.length;
287
+ }
288
+
289
+ /**
290
+ * Upsert the indexed session tracking record.
291
+ * For incremental indexing, merges timestamps with the existing record.
292
+ */
293
+ private async upsertTracking(
294
+ file: SessionFileInfo,
295
+ newMessages: ParsedMessage[],
296
+ startIndex: number,
297
+ parseResult: ParseResult,
298
+ existing: IndexedSession | null,
299
+ ): Promise<void> {
300
+ const totalMessageCount = startIndex + newMessages.length;
301
+ const firstMessageAt =
302
+ existing?.firstMessageAt ?? parseResult.firstMessageAt ?? new Date();
303
+ const lastMessageAt =
304
+ parseResult.lastMessageAt ?? existing?.lastMessageAt ?? new Date();
305
+
306
+ const session: IndexedSession = {
307
+ sessionId: file.sessionId,
308
+ filePath: file.filePath,
309
+ fileSize: file.fileSize,
310
+ messageCount: totalMessageCount,
311
+ firstMessageAt,
312
+ lastMessageAt,
313
+ indexedAt: new Date(),
314
+ ...(parseResult.project ? { project: parseResult.project } : existing?.project ? { project: existing.project } : {}),
315
+ ...(parseResult.gitBranch ? { gitBranch: parseResult.gitBranch } : existing?.gitBranch ? { gitBranch: existing.gitBranch } : {}),
316
+ };
317
+
318
+ await this.repository.upsertIndexedSession(session);
319
+ }
320
+ }
@@ -3,6 +3,8 @@ import type { Memory, SearchIntent, IntentProfile } from "../types/memory.js";
3
3
  import { isDeleted } from "../types/memory.js";
4
4
  import type { MemoryRepository } from "../db/memory.repository.js";
5
5
  import type { EmbeddingsService } from "./embeddings.service.js";
6
+ import type { ConversationHistoryService } from "./conversation-history.service.js";
7
+ import type { SearchResult, MemorySearchResult } from "../types/conversation-history.js";
6
8
 
7
9
  const INTENT_PROFILES: Record<SearchIntent, IntentProfile> = {
8
10
  continuity: { weights: { relevance: 0.3, recency: 0.5, utility: 0.2 }, jitter: 0.02 },
@@ -15,11 +17,31 @@ const INTENT_PROFILES: Record<SearchIntent, IntentProfile> = {
15
17
  const sigmoid = (x: number): number => 1 / (1 + Math.exp(-x));
16
18
 
17
19
  export class MemoryService {
20
+ private historyService: ConversationHistoryService | null = null;
21
+ private historyWeight: number = 0.5;
22
+
18
23
  constructor(
19
24
  private repository: MemoryRepository,
20
25
  private embeddings: EmbeddingsService
21
26
  ) { }
22
27
 
28
+ /**
29
+ * Optionally wire conversation history for unified search.
30
+ * Called from index.ts when conversationHistory.enabled is true.
31
+ */
32
+ setConversationHistory(service: ConversationHistoryService, weight: number): void {
33
+ this.historyService = service;
34
+ this.historyWeight = weight;
35
+ }
36
+
37
+ /**
38
+ * Access the conversation history service (if wired).
39
+ * Used by MCP handlers for index/list/reindex tools.
40
+ */
41
+ getConversationHistory(): ConversationHistoryService | null {
42
+ return this.historyService;
43
+ }
44
+
23
45
  async store(
24
46
  content: string,
25
47
  metadata: Record<string, unknown> = {},
@@ -169,6 +191,58 @@ export class MemoryService {
169
191
  return scored.slice(0, limit).map((s) => s.memory);
170
192
  }
171
193
 
194
+ /**
195
+ * Search across both memories and conversation history (if enabled).
196
+ * Returns a merged, score-normalized list sorted by relevance.
197
+ *
198
+ * If no history service is configured, falls back to memory-only search
199
+ * wrapped as MemorySearchResult[].
200
+ */
201
+ async searchUnified(
202
+ query: string,
203
+ intent: SearchIntent,
204
+ limit: number = 10,
205
+ includeDeleted: boolean = false,
206
+ ): Promise<SearchResult[]> {
207
+ if (!this.historyService) {
208
+ const memories = await this.search(query, intent, limit, includeDeleted);
209
+ return this.toMemoryResults(memories, limit);
210
+ }
211
+
212
+ // Run both searches in parallel
213
+ const [memories, historyResults] = await Promise.all([
214
+ this.search(query, intent, limit, includeDeleted),
215
+ this.historyService.search(query, limit),
216
+ ]);
217
+
218
+ const memoryResults = this.toMemoryResults(memories, limit);
219
+
220
+ // Apply history weight to RRF scores
221
+ const weightedHistory: SearchResult[] = historyResults.map((h) => ({
222
+ ...h,
223
+ score: h.score * this.historyWeight,
224
+ }));
225
+
226
+ // Merge, sort by score descending, take top N
227
+ const merged = [...memoryResults, ...weightedHistory];
228
+ merged.sort((a, b) => b.score - a.score);
229
+ return merged.slice(0, limit);
230
+ }
231
+
232
+ /** Convert pre-sorted Memory[] to MemorySearchResult[] with positional scores. */
233
+ private toMemoryResults(memories: Memory[], limit: number): MemorySearchResult[] {
234
+ return memories.map((m, rank) => ({
235
+ source: "memory" as const,
236
+ id: m.id,
237
+ content: m.content,
238
+ metadata: m.metadata,
239
+ score: (limit - rank) / limit,
240
+ createdAt: m.createdAt,
241
+ updatedAt: m.updatedAt,
242
+ supersededBy: m.supersededBy,
243
+ }));
244
+ }
245
+
172
246
  async trackAccess(ids: string[]): Promise<void> {
173
247
  const now = new Date();
174
248
  for (const id of ids) {
@@ -0,0 +1,232 @@
1
+ import { createReadStream } from "fs";
2
+ import { readdir, stat } from "fs/promises";
3
+ import { createInterface } from "readline";
4
+ import { basename, join } from "path";
5
+ import { randomUUID } from "crypto";
6
+ import type { MessageRole } from "../types/conversation-history.js";
7
+
8
+ /**
9
+ * A parsed message extracted from a Claude Code JSONL session file.
10
+ * Does not include embedding — that's added by the service layer.
11
+ */
12
+ export interface ParsedMessage {
13
+ id: string;
14
+ sessionId: string;
15
+ role: MessageRole;
16
+ messageIndex: number;
17
+ content: string;
18
+ timestamp: Date;
19
+ metadata: Record<string, unknown>;
20
+ }
21
+
22
+ /**
23
+ * Info about a discovered session file (before parsing).
24
+ */
25
+ export interface SessionFileInfo {
26
+ sessionId: string;
27
+ filePath: string;
28
+ fileSize: number;
29
+ }
30
+
31
+ /**
32
+ * Result of parsing a single session file.
33
+ * Structurally mirrors IndexedSession but with nullable timestamps/metadata
34
+ * (a file with 0 messages has no timestamps). The service layer maps this to
35
+ * IndexedSession at write time, supplying messageCount and indexedAt.
36
+ */
37
+ export interface ParseResult {
38
+ sessionId: string;
39
+ filePath: string;
40
+ fileSize: number;
41
+ messages: ParsedMessage[];
42
+ firstMessageAt: Date | null;
43
+ lastMessageAt: Date | null;
44
+ gitBranch: string | null;
45
+ project: string | null;
46
+ }
47
+
48
+ // -- JSONL line shapes (only the fields we need) --
49
+
50
+ interface JournalLine {
51
+ type: string;
52
+ sessionId?: string;
53
+ timestamp?: string;
54
+ gitBranch?: string;
55
+ cwd?: string;
56
+ message?: {
57
+ role?: string;
58
+ content?: string | ContentBlock[];
59
+ };
60
+ }
61
+
62
+ interface ContentBlock {
63
+ type: string;
64
+ text?: string;
65
+ }
66
+
67
+ /**
68
+ * Extract the text content from a JSONL message line.
69
+ * - User messages: content is string OR array (skip tool_result blocks)
70
+ * - Assistant messages: content is array of blocks (keep only type=text)
71
+ * Returns null if no usable text content.
72
+ */
73
+ function extractTextContent(line: JournalLine): string | null {
74
+ const content = line.message?.content;
75
+ if (content == null) return null;
76
+
77
+ // User messages can be a plain string
78
+ if (typeof content === "string") {
79
+ return content.trim() || null;
80
+ }
81
+
82
+ // Array of content blocks — extract text blocks only
83
+ const textParts: string[] = [];
84
+ for (const block of content) {
85
+ if (block.type === "text" && block.text) {
86
+ textParts.push(block.text);
87
+ }
88
+ }
89
+
90
+ const joined = textParts.join("\n").trim();
91
+ return joined || null;
92
+ }
93
+
94
+ /**
95
+ * Parse a single Claude Code JSONL session file.
96
+ *
97
+ * @param filePath - Absolute path to the .jsonl file
98
+ * @param fromByte - Byte offset to start reading from (for incremental parsing).
99
+ * When non-zero, messageIndex starts from startIndex.
100
+ * @param startIndex - Starting message index (for incremental parsing).
101
+ * @param knownFileSize - If already known (e.g. from discoverSessionFiles), avoids a redundant stat().
102
+ */
103
+ export async function parseSessionFile(
104
+ filePath: string,
105
+ fromByte: number = 0,
106
+ startIndex: number = 0,
107
+ knownFileSize?: number,
108
+ ): Promise<ParseResult> {
109
+ const fileSize = knownFileSize ?? (await stat(filePath)).size;
110
+
111
+ // Extract session ID from filename (e.g. "abc-123.jsonl" → "abc-123")
112
+ const sessionId = basename(filePath, ".jsonl");
113
+
114
+ const messages: ParsedMessage[] = [];
115
+ let messageIndex = startIndex;
116
+ let firstMessageAt: Date | null = null;
117
+ let lastMessageAt: Date | null = null;
118
+ let gitBranch: string | null = null;
119
+ let project: string | null = null;
120
+
121
+ const stream = createReadStream(filePath, {
122
+ encoding: "utf-8",
123
+ start: fromByte,
124
+ });
125
+
126
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
127
+
128
+ for await (const rawLine of rl) {
129
+ if (!rawLine.trim()) continue;
130
+
131
+ let line: JournalLine;
132
+ try {
133
+ line = JSON.parse(rawLine);
134
+ } catch {
135
+ // Skip malformed lines
136
+ continue;
137
+ }
138
+
139
+ // Only process user and assistant messages
140
+ if (line.type !== "user" && line.type !== "assistant") continue;
141
+
142
+ // Capture metadata from first line that has it
143
+ if (gitBranch == null && line.gitBranch) gitBranch = line.gitBranch;
144
+ if (project == null && line.cwd) project = line.cwd;
145
+
146
+ const role: MessageRole = line.type === "user" ? "user" : "assistant";
147
+ const text = extractTextContent(line);
148
+ if (!text) continue;
149
+
150
+ const timestamp = line.timestamp ? new Date(line.timestamp) : new Date();
151
+
152
+ if (firstMessageAt == null) firstMessageAt = timestamp;
153
+ lastMessageAt = timestamp;
154
+
155
+ messages.push({
156
+ id: randomUUID(),
157
+ sessionId,
158
+ role,
159
+ messageIndex,
160
+ content: text,
161
+ timestamp,
162
+ metadata: {
163
+ ...(line.gitBranch ? { gitBranch: line.gitBranch } : {}),
164
+ ...(line.cwd ? { cwd: line.cwd } : {}),
165
+ },
166
+ });
167
+
168
+ messageIndex++;
169
+ }
170
+
171
+ return {
172
+ sessionId,
173
+ filePath,
174
+ fileSize,
175
+ messages,
176
+ firstMessageAt,
177
+ lastMessageAt,
178
+ gitBranch,
179
+ project,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Discover all .jsonl session files in a directory.
185
+ * Stat calls are parallelized for efficiency.
186
+ */
187
+ export async function discoverSessionFiles(
188
+ sessionDir: string,
189
+ ): Promise<SessionFileInfo[]> {
190
+ let entries: string[];
191
+ try {
192
+ entries = await readdir(sessionDir);
193
+ } catch {
194
+ return [];
195
+ }
196
+
197
+ const jsonlEntries = entries.filter((e) => e.endsWith(".jsonl"));
198
+
199
+ const settled = await Promise.allSettled(
200
+ jsonlEntries.map(async (entry) => {
201
+ const filePath = join(sessionDir, entry);
202
+ const fileStats = await stat(filePath);
203
+ if (!fileStats.isFile()) return null;
204
+ return {
205
+ sessionId: entry.replace(/\.jsonl$/, ""),
206
+ filePath,
207
+ fileSize: fileStats.size,
208
+ } satisfies SessionFileInfo;
209
+ }),
210
+ );
211
+
212
+ return settled
213
+ .filter(
214
+ (r): r is PromiseFulfilledResult<SessionFileInfo | null> =>
215
+ r.status === "fulfilled",
216
+ )
217
+ .map((r) => r.value)
218
+ .filter((v): v is SessionFileInfo => v != null);
219
+ }
220
+
221
+ /**
222
+ * Auto-detect the Claude Code sessions directory.
223
+ * Returns null if not found.
224
+ */
225
+ export function detectSessionPath(): string | null {
226
+ const home = process.env.HOME ?? process.env.USERPROFILE;
227
+ if (!home) return null;
228
+
229
+ // Claude Code stores sessions at ~/.claude/projects/<project-slug>/<session-id>.jsonl
230
+ // We return the projects dir — the caller iterates project subdirs
231
+ return join(home, ".claude", "projects");
232
+ }