@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.
- package/dist/package.json +4 -4
- package/dist/src/config/index.d.ts +10 -0
- package/dist/src/config/index.d.ts.map +1 -1
- package/dist/src/config/index.js +13 -0
- package/dist/src/config/index.js.map +1 -1
- package/dist/src/db/conversation-history.repository.d.ts +24 -0
- package/dist/src/db/conversation-history.repository.d.ts.map +1 -0
- package/dist/src/db/conversation-history.repository.js +184 -0
- package/dist/src/db/conversation-history.repository.js.map +1 -0
- package/dist/src/db/conversation-history.schema.d.ts +10 -0
- package/dist/src/db/conversation-history.schema.d.ts.map +1 -0
- package/dist/src/db/conversation-history.schema.js +31 -0
- package/dist/src/db/conversation-history.schema.js.map +1 -0
- package/dist/src/db/lancedb-utils.d.ts +35 -0
- package/dist/src/db/lancedb-utils.d.ts.map +1 -0
- package/dist/src/db/lancedb-utils.js +77 -0
- package/dist/src/db/lancedb-utils.js.map +1 -0
- package/dist/src/db/memory.repository.d.ts +2 -12
- package/dist/src/db/memory.repository.d.ts.map +1 -1
- package/dist/src/db/memory.repository.js +13 -56
- package/dist/src/db/memory.repository.js.map +1 -1
- package/dist/src/db/schema.d.ts +4 -1
- package/dist/src/db/schema.d.ts.map +1 -1
- package/dist/src/db/schema.js +8 -4
- package/dist/src/db/schema.js.map +1 -1
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/handlers.d.ts +3 -0
- package/dist/src/mcp/handlers.d.ts.map +1 -1
- package/dist/src/mcp/handlers.js +149 -17
- package/dist/src/mcp/handlers.js.map +1 -1
- package/dist/src/mcp/tools.d.ts +3 -0
- package/dist/src/mcp/tools.d.ts.map +1 -1
- package/dist/src/mcp/tools.js +54 -3
- package/dist/src/mcp/tools.js.map +1 -1
- package/dist/src/services/conversation-history.service.d.ts +64 -0
- package/dist/src/services/conversation-history.service.d.ts.map +1 -0
- package/dist/src/services/conversation-history.service.js +244 -0
- package/dist/src/services/conversation-history.service.js.map +1 -0
- package/dist/src/services/memory.service.d.ts +24 -0
- package/dist/src/services/memory.service.d.ts.map +1 -1
- package/dist/src/services/memory.service.js +58 -0
- package/dist/src/services/memory.service.js.map +1 -1
- package/dist/src/services/session-parser.d.ts +59 -0
- package/dist/src/services/session-parser.d.ts.map +1 -0
- package/dist/src/services/session-parser.js +147 -0
- package/dist/src/services/session-parser.js.map +1 -0
- package/dist/src/types/conversation-history.d.ts +74 -0
- package/dist/src/types/conversation-history.d.ts.map +1 -0
- package/dist/src/types/conversation-history.js +2 -0
- package/dist/src/types/conversation-history.js.map +1 -0
- package/dist/src/types/memory.d.ts +4 -2
- package/dist/src/types/memory.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/config/index.ts +23 -0
- package/src/db/conversation-history.repository.ts +255 -0
- package/src/db/conversation-history.schema.ts +40 -0
- package/src/db/lancedb-utils.ts +97 -0
- package/src/db/memory.repository.ts +18 -67
- package/src/db/schema.ts +17 -21
- package/src/index.ts +16 -0
- package/src/mcp/handlers.ts +178 -22
- package/src/mcp/tools.ts +66 -3
- package/src/services/conversation-history.service.ts +320 -0
- package/src/services/memory.service.ts +74 -0
- package/src/services/session-parser.ts +232 -0
- package/src/types/conversation-history.ts +82 -0
- 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
|
+
}
|