@aeriondyseti/vector-memory-mcp 1.1.0-dev.2 → 1.1.0-dev.3
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 +1 -1
- package/dist/src/config/index.d.ts +17 -10
- package/dist/src/config/index.d.ts.map +1 -1
- package/dist/src/config/index.js +25 -11
- package/dist/src/config/index.js.map +1 -1
- package/dist/src/db/conversation.repository.d.ts +26 -0
- package/dist/src/db/conversation.repository.d.ts.map +1 -0
- package/dist/src/db/conversation.repository.js +72 -0
- package/dist/src/db/conversation.repository.js.map +1 -0
- package/dist/src/db/conversation.schema.d.ts +4 -0
- package/dist/src/db/conversation.schema.d.ts.map +1 -0
- package/dist/src/db/conversation.schema.js +15 -0
- package/dist/src/db/conversation.schema.js.map +1 -0
- package/dist/src/db/lancedb-utils.d.ts +13 -3
- package/dist/src/db/lancedb-utils.d.ts.map +1 -1
- package/dist/src/db/lancedb-utils.js +36 -7
- package/dist/src/db/lancedb-utils.js.map +1 -1
- package/dist/src/db/memory.repository.js +7 -7
- package/dist/src/db/memory.repository.js.map +1 -1
- package/dist/src/http/server.d.ts.map +1 -1
- package/dist/src/http/server.js +26 -7
- package/dist/src/http/server.js.map +1 -1
- package/dist/src/index.js +7 -6
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/handlers.d.ts +1 -1
- package/dist/src/mcp/handlers.d.ts.map +1 -1
- package/dist/src/mcp/handlers.js +106 -117
- package/dist/src/mcp/handlers.js.map +1 -1
- package/dist/src/mcp/tools.d.ts.map +1 -1
- package/dist/src/mcp/tools.js +43 -14
- package/dist/src/mcp/tools.js.map +1 -1
- package/dist/src/services/conversation.service.d.ts +38 -0
- package/dist/src/services/conversation.service.d.ts.map +1 -0
- package/dist/src/services/conversation.service.js +252 -0
- package/dist/src/services/conversation.service.js.map +1 -0
- package/dist/src/services/memory.service.d.ts +7 -25
- package/dist/src/services/memory.service.d.ts.map +1 -1
- package/dist/src/services/memory.service.js +66 -80
- package/dist/src/services/memory.service.js.map +1 -1
- package/dist/src/services/parsers/claude-code.parser.d.ts +8 -0
- package/dist/src/services/parsers/claude-code.parser.d.ts.map +1 -0
- package/dist/src/services/parsers/claude-code.parser.js +191 -0
- package/dist/src/services/parsers/claude-code.parser.js.map +1 -0
- package/dist/src/services/parsers/types.d.ts +9 -0
- package/dist/src/services/parsers/types.d.ts.map +1 -0
- package/dist/src/services/parsers/types.js +2 -0
- package/dist/src/services/parsers/types.js.map +1 -0
- package/dist/src/types/conversation.d.ts +99 -0
- package/dist/src/types/conversation.d.ts.map +1 -0
- package/dist/src/types/conversation.js +2 -0
- package/dist/src/types/conversation.js.map +1 -0
- package/hooks/session-start.ts +60 -42
- package/package.json +1 -1
- package/src/config/index.ts +39 -21
- package/src/db/conversation.repository.ts +120 -0
- package/src/db/conversation.schema.ts +33 -0
- package/src/db/lancedb-utils.ts +35 -7
- package/src/db/memory.repository.ts +7 -7
- package/src/http/server.ts +31 -7
- package/src/index.ts +10 -11
- package/src/mcp/handlers.ts +121 -123
- package/src/mcp/tools.ts +44 -15
- package/src/services/conversation.service.ts +354 -0
- package/src/services/memory.service.ts +101 -105
- package/src/services/parsers/claude-code.parser.ts +242 -0
- package/src/services/parsers/types.ts +14 -0
- package/src/types/conversation.ts +108 -0
- package/dist/src/db/conversation-history.repository.d.ts +0 -24
- package/dist/src/db/conversation-history.repository.d.ts.map +0 -1
- package/dist/src/db/conversation-history.repository.js +0 -184
- package/dist/src/db/conversation-history.repository.js.map +0 -1
- package/dist/src/db/conversation-history.schema.d.ts +0 -10
- package/dist/src/db/conversation-history.schema.d.ts.map +0 -1
- package/dist/src/db/conversation-history.schema.js +0 -31
- package/dist/src/db/conversation-history.schema.js.map +0 -1
- package/dist/src/services/conversation-history.service.d.ts +0 -64
- package/dist/src/services/conversation-history.service.d.ts.map +0 -1
- package/dist/src/services/conversation-history.service.js +0 -244
- package/dist/src/services/conversation-history.service.js.map +0 -1
- package/dist/src/services/session-parser.d.ts +0 -59
- package/dist/src/services/session-parser.d.ts.map +0 -1
- package/dist/src/services/session-parser.js +0 -147
- package/dist/src/services/session-parser.js.map +0 -1
- package/dist/src/types/conversation-history.d.ts +0 -74
- package/dist/src/types/conversation-history.d.ts.map +0 -1
- package/dist/src/types/conversation-history.js +0 -2
- package/dist/src/types/conversation-history.js.map +0 -1
- package/src/db/conversation-history.repository.ts +0 -255
- package/src/db/conversation-history.schema.ts +0 -40
- package/src/services/conversation-history.service.ts +0 -320
- package/src/services/session-parser.ts +0 -232
- package/src/types/conversation-history.ts +0 -82
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import type { ConversationRepository } from "../db/conversation.repository.js";
|
|
5
|
+
import type {
|
|
6
|
+
ConversationChunk,
|
|
7
|
+
ConversationHybridRow,
|
|
8
|
+
HistoryFilters,
|
|
9
|
+
IndexedSession,
|
|
10
|
+
ParsedMessage,
|
|
11
|
+
SessionFileInfo,
|
|
12
|
+
} from "../types/conversation.js";
|
|
13
|
+
import type { ConversationHistoryConfig } from "../config/index.js";
|
|
14
|
+
import { resolveSessionLogPath } from "../config/index.js";
|
|
15
|
+
import type { EmbeddingsService } from "./embeddings.service.js";
|
|
16
|
+
import type { SessionLogParser } from "./parsers/types.js";
|
|
17
|
+
import { ClaudeCodeSessionParser } from "./parsers/claude-code.parser.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a deterministic chunk ID from session ID and message indices.
|
|
21
|
+
*/
|
|
22
|
+
function chunkId(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
startIdx: number,
|
|
25
|
+
endIdx: number
|
|
26
|
+
): string {
|
|
27
|
+
return createHash("sha256")
|
|
28
|
+
.update(`${sessionId}:${startIdx}:${endIdx}`)
|
|
29
|
+
.digest("hex")
|
|
30
|
+
.slice(0, 32);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Group parsed messages into embeddable chunks.
|
|
35
|
+
*/
|
|
36
|
+
export function chunkMessages(
|
|
37
|
+
messages: ParsedMessage[],
|
|
38
|
+
maxChunkMessages: number,
|
|
39
|
+
overlap: number
|
|
40
|
+
): ConversationChunk[] {
|
|
41
|
+
if (messages.length === 0) return [];
|
|
42
|
+
|
|
43
|
+
const chunks: ConversationChunk[] = [];
|
|
44
|
+
let startIdx = 0;
|
|
45
|
+
|
|
46
|
+
while (startIdx < messages.length) {
|
|
47
|
+
const endIdx = Math.min(startIdx + maxChunkMessages, messages.length);
|
|
48
|
+
const chunkMsgs = messages.slice(startIdx, endIdx);
|
|
49
|
+
|
|
50
|
+
const firstRole = chunkMsgs[0].role;
|
|
51
|
+
const role = chunkMsgs.every((m) => m.role === firstRole)
|
|
52
|
+
? firstRole
|
|
53
|
+
: "mixed";
|
|
54
|
+
|
|
55
|
+
const content = chunkMsgs
|
|
56
|
+
.map(
|
|
57
|
+
(m) =>
|
|
58
|
+
`[${m.role} @ ${m.timestamp.toISOString()}]: ${m.content}`
|
|
59
|
+
)
|
|
60
|
+
.join("\n\n");
|
|
61
|
+
|
|
62
|
+
const firstMsg = chunkMsgs[0];
|
|
63
|
+
const lastMsg = chunkMsgs[chunkMsgs.length - 1];
|
|
64
|
+
|
|
65
|
+
chunks.push({
|
|
66
|
+
id: chunkId(
|
|
67
|
+
firstMsg.sessionId,
|
|
68
|
+
firstMsg.messageIndex,
|
|
69
|
+
lastMsg.messageIndex
|
|
70
|
+
),
|
|
71
|
+
content,
|
|
72
|
+
sessionId: firstMsg.sessionId,
|
|
73
|
+
timestamp: firstMsg.timestamp,
|
|
74
|
+
endTimestamp: lastMsg.timestamp,
|
|
75
|
+
role,
|
|
76
|
+
messageIndexStart: firstMsg.messageIndex,
|
|
77
|
+
messageIndexEnd: lastMsg.messageIndex,
|
|
78
|
+
project: firstMsg.project,
|
|
79
|
+
metadata: {
|
|
80
|
+
session_id: firstMsg.sessionId,
|
|
81
|
+
timestamp: firstMsg.timestamp.toISOString(),
|
|
82
|
+
role,
|
|
83
|
+
message_index_start: firstMsg.messageIndex,
|
|
84
|
+
message_index_end: lastMsg.messageIndex,
|
|
85
|
+
project: firstMsg.project,
|
|
86
|
+
git_branch: firstMsg.gitBranch,
|
|
87
|
+
is_subagent: firstMsg.isSubagent,
|
|
88
|
+
agent_id: firstMsg.agentId,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Advance by (chunkSize - overlap), but always advance at least 1
|
|
93
|
+
const advance = Math.max(1, endIdx - startIdx - overlap);
|
|
94
|
+
startIdx += advance;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return chunks;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Serializable index state format */
|
|
101
|
+
interface IndexStateEntry {
|
|
102
|
+
sessionId: string;
|
|
103
|
+
filePath: string;
|
|
104
|
+
project: string;
|
|
105
|
+
lastModified: number;
|
|
106
|
+
chunkCount: number;
|
|
107
|
+
messageCount: number;
|
|
108
|
+
indexedAt: string;
|
|
109
|
+
firstMessageAt: string;
|
|
110
|
+
lastMessageAt: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class ConversationHistoryService {
|
|
114
|
+
private indexStatePath: string;
|
|
115
|
+
private indexStateCache: Map<string, IndexedSession> | null = null;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
private repository: ConversationRepository,
|
|
119
|
+
private embeddings: EmbeddingsService,
|
|
120
|
+
public readonly config: ConversationHistoryConfig,
|
|
121
|
+
private dbPath: string,
|
|
122
|
+
private parser: SessionLogParser = new ClaudeCodeSessionParser()
|
|
123
|
+
) {
|
|
124
|
+
this.indexStatePath = join(
|
|
125
|
+
dirname(dbPath),
|
|
126
|
+
"conversation_index_state.json"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async loadIndexState(): Promise<Map<string, IndexedSession>> {
|
|
131
|
+
if (this.indexStateCache) return this.indexStateCache;
|
|
132
|
+
try {
|
|
133
|
+
const raw = await readFile(this.indexStatePath, "utf-8");
|
|
134
|
+
const entries: IndexStateEntry[] = JSON.parse(raw);
|
|
135
|
+
const map = new Map<string, IndexedSession>();
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
map.set(e.sessionId, {
|
|
138
|
+
sessionId: e.sessionId,
|
|
139
|
+
filePath: e.filePath,
|
|
140
|
+
project: e.project,
|
|
141
|
+
lastModified: e.lastModified,
|
|
142
|
+
chunkCount: e.chunkCount,
|
|
143
|
+
messageCount: e.messageCount,
|
|
144
|
+
indexedAt: new Date(e.indexedAt),
|
|
145
|
+
firstMessageAt: new Date(e.firstMessageAt),
|
|
146
|
+
lastMessageAt: new Date(e.lastMessageAt),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
this.indexStateCache = map;
|
|
150
|
+
return map;
|
|
151
|
+
} catch {
|
|
152
|
+
const map = new Map<string, IndexedSession>();
|
|
153
|
+
this.indexStateCache = map;
|
|
154
|
+
return map;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async saveIndexState(state: Map<string, IndexedSession>): Promise<void> {
|
|
159
|
+
const entries: IndexStateEntry[] = [...state.values()].map((s) => ({
|
|
160
|
+
sessionId: s.sessionId,
|
|
161
|
+
filePath: s.filePath,
|
|
162
|
+
project: s.project,
|
|
163
|
+
lastModified: s.lastModified,
|
|
164
|
+
chunkCount: s.chunkCount,
|
|
165
|
+
messageCount: s.messageCount,
|
|
166
|
+
indexedAt: s.indexedAt.toISOString(),
|
|
167
|
+
firstMessageAt: s.firstMessageAt.toISOString(),
|
|
168
|
+
lastMessageAt: s.lastMessageAt.toISOString(),
|
|
169
|
+
}));
|
|
170
|
+
await mkdir(dirname(this.indexStatePath), { recursive: true });
|
|
171
|
+
await writeFile(this.indexStatePath, JSON.stringify(entries, null, 2));
|
|
172
|
+
this.indexStateCache = state;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async indexConversations(
|
|
176
|
+
path?: string,
|
|
177
|
+
since?: Date
|
|
178
|
+
): Promise<{ indexed: number; skipped: number; errors: string[] }> {
|
|
179
|
+
if (!this.config.enabled) {
|
|
180
|
+
return {
|
|
181
|
+
indexed: 0,
|
|
182
|
+
skipped: 0,
|
|
183
|
+
errors: ["Conversation history indexing is not enabled"],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const logPath = path ?? resolveSessionLogPath(this.config);
|
|
188
|
+
if (!logPath) {
|
|
189
|
+
return {
|
|
190
|
+
indexed: 0,
|
|
191
|
+
skipped: 0,
|
|
192
|
+
errors: ["No session log path configured or detected"],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const sessionFiles = await this.parser.findSessionFiles(
|
|
197
|
+
logPath,
|
|
198
|
+
since,
|
|
199
|
+
this.config.indexSubagents
|
|
200
|
+
);
|
|
201
|
+
const indexState = await this.loadIndexState();
|
|
202
|
+
|
|
203
|
+
let indexed = 0;
|
|
204
|
+
let skipped = 0;
|
|
205
|
+
const errors: string[] = [];
|
|
206
|
+
|
|
207
|
+
for (const file of sessionFiles) {
|
|
208
|
+
const existing = indexState.get(file.sessionId);
|
|
209
|
+
if (existing && existing.lastModified >= file.lastModified.getTime()) {
|
|
210
|
+
skipped++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await this.indexSession(file, indexState);
|
|
216
|
+
indexed++;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
errors.push(
|
|
219
|
+
`${file.sessionId}: ${err instanceof Error ? err.message : String(err)}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await this.saveIndexState(indexState);
|
|
225
|
+
return { indexed, skipped, errors };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async indexSession(
|
|
229
|
+
file: SessionFileInfo,
|
|
230
|
+
indexState: Map<string, IndexedSession>
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
const messages = await this.parser.parse(
|
|
233
|
+
file.filePath,
|
|
234
|
+
this.config.indexSubagents
|
|
235
|
+
);
|
|
236
|
+
if (messages.length === 0) {
|
|
237
|
+
// Still track it so we don't re-attempt
|
|
238
|
+
indexState.set(file.sessionId, {
|
|
239
|
+
sessionId: file.sessionId,
|
|
240
|
+
filePath: file.filePath,
|
|
241
|
+
project: file.project,
|
|
242
|
+
lastModified: file.lastModified.getTime(),
|
|
243
|
+
chunkCount: 0,
|
|
244
|
+
messageCount: 0,
|
|
245
|
+
indexedAt: new Date(),
|
|
246
|
+
firstMessageAt: file.lastModified,
|
|
247
|
+
lastMessageAt: file.lastModified,
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const chunks = chunkMessages(
|
|
253
|
+
messages,
|
|
254
|
+
this.config.maxChunkMessages,
|
|
255
|
+
this.config.chunkOverlap
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Delete existing chunks for re-indexing
|
|
259
|
+
await this.repository.deleteBySessionId(file.sessionId);
|
|
260
|
+
|
|
261
|
+
// Embed all chunks
|
|
262
|
+
const embeddings = await this.embeddings.embedBatch(
|
|
263
|
+
chunks.map((c) => c.content)
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Insert all chunks
|
|
267
|
+
const rows = chunks.map((chunk, i) => ({
|
|
268
|
+
id: chunk.id,
|
|
269
|
+
vector: embeddings[i],
|
|
270
|
+
content: chunk.content,
|
|
271
|
+
metadata: JSON.stringify(chunk.metadata),
|
|
272
|
+
created_at: chunk.timestamp.getTime(),
|
|
273
|
+
session_id: chunk.sessionId,
|
|
274
|
+
role: chunk.role,
|
|
275
|
+
message_index_start: chunk.messageIndexStart,
|
|
276
|
+
message_index_end: chunk.messageIndexEnd,
|
|
277
|
+
project: chunk.project,
|
|
278
|
+
}));
|
|
279
|
+
|
|
280
|
+
await this.repository.insertBatch(rows);
|
|
281
|
+
|
|
282
|
+
// Update index state
|
|
283
|
+
indexState.set(file.sessionId, {
|
|
284
|
+
sessionId: file.sessionId,
|
|
285
|
+
filePath: file.filePath,
|
|
286
|
+
project: file.project,
|
|
287
|
+
lastModified: file.lastModified.getTime(),
|
|
288
|
+
chunkCount: chunks.length,
|
|
289
|
+
messageCount: messages.length,
|
|
290
|
+
indexedAt: new Date(),
|
|
291
|
+
firstMessageAt: messages[0].timestamp,
|
|
292
|
+
lastMessageAt: messages[messages.length - 1].timestamp,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async reindexSession(
|
|
297
|
+
sessionId: string
|
|
298
|
+
): Promise<{ success: boolean; chunkCount: number; error?: string }> {
|
|
299
|
+
if (!this.config.enabled) {
|
|
300
|
+
return {
|
|
301
|
+
success: false,
|
|
302
|
+
chunkCount: 0,
|
|
303
|
+
error: "Conversation history indexing is not enabled",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const indexState = await this.loadIndexState();
|
|
308
|
+
const existing = indexState.get(sessionId);
|
|
309
|
+
if (!existing) {
|
|
310
|
+
return {
|
|
311
|
+
success: false,
|
|
312
|
+
chunkCount: 0,
|
|
313
|
+
error: "Session not found in index state",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Construct session info for re-indexing
|
|
318
|
+
const file: SessionFileInfo = {
|
|
319
|
+
filePath: existing.filePath,
|
|
320
|
+
sessionId,
|
|
321
|
+
project: existing.project,
|
|
322
|
+
lastModified: new Date(),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
await this.indexSession(file, indexState);
|
|
326
|
+
await this.saveIndexState(indexState);
|
|
327
|
+
|
|
328
|
+
const updated = indexState.get(sessionId)!;
|
|
329
|
+
return { success: true, chunkCount: updated.chunkCount };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async listIndexedSessions(
|
|
333
|
+
limit: number = 20,
|
|
334
|
+
offset: number = 0
|
|
335
|
+
): Promise<{ sessions: IndexedSession[]; total: number }> {
|
|
336
|
+
const indexState = await this.loadIndexState();
|
|
337
|
+
const sessions = [...indexState.values()].sort(
|
|
338
|
+
(a, b) => b.indexedAt.getTime() - a.indexedAt.getTime()
|
|
339
|
+
);
|
|
340
|
+
return {
|
|
341
|
+
sessions: sessions.slice(offset, offset + limit),
|
|
342
|
+
total: sessions.length,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async searchHistory(
|
|
347
|
+
query: string,
|
|
348
|
+
embedding: number[],
|
|
349
|
+
limit: number,
|
|
350
|
+
filters?: HistoryFilters
|
|
351
|
+
): Promise<ConversationHybridRow[]> {
|
|
352
|
+
return this.repository.findHybrid(embedding, query, limit, filters);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import type { Memory, SearchIntent, IntentProfile } from "../types/memory.js";
|
|
2
|
+
import type { Memory, SearchIntent, IntentProfile, HybridRow } from "../types/memory.js";
|
|
3
3
|
import { isDeleted } from "../types/memory.js";
|
|
4
|
+
import type { SearchResult, SearchOptions } from "../types/conversation.js";
|
|
4
5
|
import type { MemoryRepository } from "../db/memory.repository.js";
|
|
5
6
|
import type { EmbeddingsService } from "./embeddings.service.js";
|
|
6
|
-
import type { ConversationHistoryService } from "./conversation
|
|
7
|
-
import type { SearchResult, MemorySearchResult } from "../types/conversation-history.js";
|
|
7
|
+
import type { ConversationHistoryService } from "./conversation.service.js";
|
|
8
8
|
|
|
9
9
|
const INTENT_PROFILES: Record<SearchIntent, IntentProfile> = {
|
|
10
10
|
continuity: { weights: { relevance: 0.3, recency: 0.5, utility: 0.2 }, jitter: 0.02 },
|
|
@@ -17,29 +17,19 @@ const INTENT_PROFILES: Record<SearchIntent, IntentProfile> = {
|
|
|
17
17
|
const sigmoid = (x: number): number => 1 / (1 + Math.exp(-x));
|
|
18
18
|
|
|
19
19
|
export class MemoryService {
|
|
20
|
-
private
|
|
21
|
-
private historyWeight: number = 0.5;
|
|
20
|
+
private conversationService: ConversationHistoryService | null = null;
|
|
22
21
|
|
|
23
22
|
constructor(
|
|
24
23
|
private repository: MemoryRepository,
|
|
25
24
|
private embeddings: EmbeddingsService
|
|
26
|
-
) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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;
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
setConversationService(service: ConversationHistoryService): void {
|
|
28
|
+
this.conversationService = service;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* Used by MCP handlers for index/list/reindex tools.
|
|
40
|
-
*/
|
|
41
|
-
getConversationHistory(): ConversationHistoryService | null {
|
|
42
|
-
return this.historyService;
|
|
31
|
+
getConversationService(): ConversationHistoryService | null {
|
|
32
|
+
return this.conversationService;
|
|
43
33
|
}
|
|
44
34
|
|
|
45
35
|
async store(
|
|
@@ -144,103 +134,109 @@ export class MemoryService {
|
|
|
144
134
|
return updatedMemory;
|
|
145
135
|
}
|
|
146
136
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const hoursSinceAccess = Math.max(0, (now.getTime() - lastAccessed.getTime()) / (1000 * 60 * 60));
|
|
169
|
-
const recency = Math.pow(0.995, hoursSinceAccess);
|
|
170
|
-
|
|
171
|
-
// Utility: sigmoid of usefulness + log(accessCount)
|
|
172
|
-
const utility = sigmoid((candidate.usefulness + Math.log(candidate.accessCount + 1)) / 5);
|
|
173
|
-
|
|
174
|
-
// Weighted score
|
|
175
|
-
const { weights, jitter } = profile;
|
|
176
|
-
const score =
|
|
177
|
-
weights.relevance * relevance +
|
|
178
|
-
weights.recency * recency +
|
|
179
|
-
weights.utility * utility;
|
|
180
|
-
|
|
181
|
-
// Apply jitter
|
|
182
|
-
const finalScore = score * (1 + (Math.random() * 2 - 1) * jitter);
|
|
183
|
-
|
|
184
|
-
return { memory: candidate as Memory, finalScore };
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// Sort by final score descending
|
|
188
|
-
scored.sort((a, b) => b.finalScore - a.finalScore);
|
|
189
|
-
|
|
190
|
-
// Return top N (read-only - no access tracking)
|
|
191
|
-
return scored.slice(0, limit).map((s) => s.memory);
|
|
137
|
+
private computeMemoryScore(
|
|
138
|
+
candidate: HybridRow,
|
|
139
|
+
profile: IntentProfile,
|
|
140
|
+
now: Date
|
|
141
|
+
): number {
|
|
142
|
+
const relevance = candidate.rrfScore;
|
|
143
|
+
const lastAccessed = candidate.lastAccessed ?? candidate.createdAt;
|
|
144
|
+
const hoursSinceAccess = Math.max(
|
|
145
|
+
0,
|
|
146
|
+
(now.getTime() - lastAccessed.getTime()) / (1000 * 60 * 60)
|
|
147
|
+
);
|
|
148
|
+
const recency = Math.pow(0.995, hoursSinceAccess);
|
|
149
|
+
const utility = sigmoid(
|
|
150
|
+
(candidate.usefulness + Math.log(candidate.accessCount + 1)) / 5
|
|
151
|
+
);
|
|
152
|
+
const { weights, jitter } = profile;
|
|
153
|
+
const score =
|
|
154
|
+
weights.relevance * relevance +
|
|
155
|
+
weights.recency * recency +
|
|
156
|
+
weights.utility * utility;
|
|
157
|
+
return score * (1 + (Math.random() * 2 - 1) * jitter);
|
|
192
158
|
}
|
|
193
159
|
|
|
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(
|
|
160
|
+
async search(
|
|
202
161
|
query: string,
|
|
203
162
|
intent: SearchIntent,
|
|
204
163
|
limit: number = 10,
|
|
205
164
|
includeDeleted: boolean = false,
|
|
165
|
+
options?: SearchOptions
|
|
206
166
|
): Promise<SearchResult[]> {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
167
|
+
const queryEmbedding = await this.embeddings.embed(query);
|
|
168
|
+
const profile = INTENT_PROFILES[intent];
|
|
169
|
+
const now = new Date();
|
|
211
170
|
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
171
|
+
const hasConversationService = this.conversationService !== null;
|
|
172
|
+
const historyOnly = (options?.historyOnly ?? false) && hasConversationService;
|
|
173
|
+
const includeHistory =
|
|
174
|
+
(options?.includeHistory ?? true) && hasConversationService;
|
|
175
|
+
const historyWeight =
|
|
176
|
+
options?.historyWeight ??
|
|
177
|
+
this.conversationService?.config.historyWeight ??
|
|
178
|
+
0.75;
|
|
179
|
+
|
|
180
|
+
// Run memory + history queries in parallel
|
|
181
|
+
const memoryPromise =
|
|
182
|
+
!historyOnly
|
|
183
|
+
? this.repository
|
|
184
|
+
.findHybrid(queryEmbedding, query, limit * 5)
|
|
185
|
+
.then((candidates) =>
|
|
186
|
+
candidates
|
|
187
|
+
.filter((m) => includeDeleted || !isDeleted(m))
|
|
188
|
+
.map((candidate) => ({
|
|
189
|
+
id: candidate.id,
|
|
190
|
+
content: candidate.content,
|
|
191
|
+
metadata: candidate.metadata,
|
|
192
|
+
createdAt: candidate.createdAt,
|
|
193
|
+
updatedAt: candidate.updatedAt,
|
|
194
|
+
source: "memory" as const,
|
|
195
|
+
score: this.computeMemoryScore(candidate, profile, now),
|
|
196
|
+
supersededBy: candidate.supersededBy,
|
|
197
|
+
usefulness: candidate.usefulness,
|
|
198
|
+
accessCount: candidate.accessCount,
|
|
199
|
+
lastAccessed: candidate.lastAccessed,
|
|
200
|
+
}))
|
|
201
|
+
)
|
|
202
|
+
: Promise.resolve([] as SearchResult[]);
|
|
203
|
+
|
|
204
|
+
const historyPromise =
|
|
205
|
+
includeHistory || historyOnly
|
|
206
|
+
? this.conversationService!
|
|
207
|
+
.searchHistory(
|
|
208
|
+
query,
|
|
209
|
+
queryEmbedding,
|
|
210
|
+
historyOnly ? limit * 5 : limit * 3,
|
|
211
|
+
options?.historyFilters
|
|
212
|
+
)
|
|
213
|
+
.then((historyRows) =>
|
|
214
|
+
historyRows.map((row) => ({
|
|
215
|
+
id: row.id,
|
|
216
|
+
content: row.content,
|
|
217
|
+
metadata: row.metadata,
|
|
218
|
+
createdAt: row.createdAt,
|
|
219
|
+
updatedAt: row.createdAt,
|
|
220
|
+
source: "conversation_history" as const,
|
|
221
|
+
score: row.rrfScore * historyWeight,
|
|
222
|
+
sessionId: (row.metadata?.session_id as string) ?? "",
|
|
223
|
+
role: (row.metadata?.role as string) ?? "unknown",
|
|
224
|
+
messageIndexStart: (row.metadata?.message_index_start as number) ?? 0,
|
|
225
|
+
messageIndexEnd: (row.metadata?.message_index_end as number) ?? 0,
|
|
226
|
+
}))
|
|
227
|
+
)
|
|
228
|
+
: Promise.resolve([] as SearchResult[]);
|
|
229
|
+
|
|
230
|
+
const [memoryResults, historyResults] = await Promise.all([
|
|
231
|
+
memoryPromise,
|
|
232
|
+
historyPromise,
|
|
216
233
|
]);
|
|
217
234
|
|
|
218
|
-
|
|
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];
|
|
235
|
+
// Merge and sort by score descending
|
|
236
|
+
const merged = [...memoryResults, ...historyResults];
|
|
228
237
|
merged.sort((a, b) => b.score - a.score);
|
|
229
|
-
return merged.slice(0, limit);
|
|
230
|
-
}
|
|
231
238
|
|
|
232
|
-
|
|
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
|
-
}));
|
|
239
|
+
return merged.slice(0, limit);
|
|
244
240
|
}
|
|
245
241
|
|
|
246
242
|
async trackAccess(ids: string[]): Promise<void> {
|