@synth-coder/memhub 0.1.6 → 0.2.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.
- package/AGENTS.md +26 -0
- package/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/dist/src/contracts/mcp.d.ts +2 -5
- package/dist/src/contracts/mcp.d.ts.map +1 -1
- package/dist/src/contracts/mcp.js +77 -23
- package/dist/src/contracts/mcp.js.map +1 -1
- package/dist/src/contracts/schemas.d.ts +67 -76
- package/dist/src/contracts/schemas.d.ts.map +1 -1
- package/dist/src/contracts/schemas.js +4 -8
- package/dist/src/contracts/schemas.js.map +1 -1
- package/dist/src/contracts/types.d.ts +1 -4
- package/dist/src/contracts/types.d.ts.map +1 -1
- package/dist/src/contracts/types.js.map +1 -1
- package/dist/src/server/mcp-server.d.ts.map +1 -1
- package/dist/src/server/mcp-server.js +21 -4
- package/dist/src/server/mcp-server.js.map +1 -1
- package/dist/src/services/embedding-service.d.ts +43 -0
- package/dist/src/services/embedding-service.d.ts.map +1 -0
- package/dist/src/services/embedding-service.js +80 -0
- package/dist/src/services/embedding-service.js.map +1 -0
- package/dist/src/services/memory-service.d.ts +30 -44
- package/dist/src/services/memory-service.d.ts.map +1 -1
- package/dist/src/services/memory-service.js +212 -161
- package/dist/src/services/memory-service.js.map +1 -1
- package/dist/src/storage/vector-index.d.ts +62 -0
- package/dist/src/storage/vector-index.d.ts.map +1 -0
- package/dist/src/storage/vector-index.js +123 -0
- package/dist/src/storage/vector-index.js.map +1 -0
- package/package.json +17 -13
- package/src/contracts/mcp.ts +84 -30
- package/src/contracts/schemas.ts +4 -8
- package/src/contracts/types.ts +4 -8
- package/src/server/mcp-server.ts +23 -7
- package/src/services/embedding-service.ts +114 -0
- package/src/services/memory-service.ts +252 -179
- package/src/storage/vector-index.ts +160 -0
- package/test/server/mcp-server.test.ts +11 -9
- package/test/services/memory-service-edge.test.ts +1 -1
- package/test/services/memory-service.test.ts +1 -1
- package/test/storage/vector-index.test.ts +153 -0
- package/vitest.config.ts +3 -1
- /package/docs/{proposal-close-gates.md → proposals/proposal-close-gates.md} +0 -0
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Memory Service - Business logic for memory operations
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
import { randomUUID } from 'crypto';
|
|
6
2
|
import type {
|
|
7
3
|
Memory,
|
|
@@ -28,6 +24,19 @@ import type {
|
|
|
28
24
|
import { ErrorCode } from '../contracts/types.js';
|
|
29
25
|
import { MarkdownStorage, StorageError } from '../storage/markdown-storage.js';
|
|
30
26
|
|
|
27
|
+
/** Minimal interface required from VectorIndex (avoids static import of native module) */
|
|
28
|
+
interface IVectorIndex {
|
|
29
|
+
upsert(memory: Memory, vector: number[]): Promise<void>;
|
|
30
|
+
delete(id: string): Promise<void>;
|
|
31
|
+
search(vector: number[], limit?: number): Promise<Array<{ id: string; _distance: number }>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Minimal interface required from EmbeddingService */
|
|
35
|
+
interface IEmbeddingService {
|
|
36
|
+
embedMemory(title: string, content: string): Promise<number[]>;
|
|
37
|
+
embed(text: string): Promise<number[]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
/**
|
|
32
41
|
* Custom error for service operations
|
|
33
42
|
*/
|
|
@@ -47,6 +56,12 @@ export class ServiceError extends Error {
|
|
|
47
56
|
*/
|
|
48
57
|
export interface MemoryServiceConfig {
|
|
49
58
|
storagePath: string;
|
|
59
|
+
/**
|
|
60
|
+
* Enable vector semantic search via LanceDB + local ONNX model.
|
|
61
|
+
* Set to false in unit tests to avoid loading the model.
|
|
62
|
+
* @default true
|
|
63
|
+
*/
|
|
64
|
+
vectorSearch?: boolean;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
/**
|
|
@@ -54,17 +69,107 @@ export interface MemoryServiceConfig {
|
|
|
54
69
|
*/
|
|
55
70
|
export class MemoryService {
|
|
56
71
|
private readonly storage: MarkdownStorage;
|
|
72
|
+
private readonly vectorIndex: IVectorIndex | null;
|
|
73
|
+
private readonly embedding: IEmbeddingService | null;
|
|
74
|
+
private readonly vectorSearchEnabled: boolean;
|
|
57
75
|
|
|
58
76
|
constructor(config: MemoryServiceConfig) {
|
|
59
77
|
this.storage = new MarkdownStorage({ storagePath: config.storagePath });
|
|
78
|
+
this.vectorSearchEnabled = config.vectorSearch !== false;
|
|
79
|
+
|
|
80
|
+
if (this.vectorSearchEnabled) {
|
|
81
|
+
// Lazily resolved at runtime — do not use top-level static imports so that
|
|
82
|
+
// native modules (onnxruntime-node, sharp) are never loaded when vectorSearch=false.
|
|
83
|
+
let resolvedVectorIndex: IVectorIndex | null = null;
|
|
84
|
+
let resolvedEmbedding: IEmbeddingService | null = null;
|
|
85
|
+
|
|
86
|
+
// Kick off async initialisation without blocking the constructor.
|
|
87
|
+
// The proxy objects below delegate to the real instances once ready.
|
|
88
|
+
const storagePath = config.storagePath;
|
|
89
|
+
const initPromise = (async () => {
|
|
90
|
+
const [{ VectorIndex }, { EmbeddingService }] = await Promise.all([
|
|
91
|
+
import('../storage/vector-index.js'),
|
|
92
|
+
import('./embedding-service.js'),
|
|
93
|
+
]);
|
|
94
|
+
resolvedVectorIndex = new VectorIndex(storagePath);
|
|
95
|
+
resolvedEmbedding = EmbeddingService.getInstance();
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
// Lightweight proxy that waits for init before delegating
|
|
99
|
+
this.vectorIndex = {
|
|
100
|
+
upsert: async (memory, vector) => {
|
|
101
|
+
await initPromise;
|
|
102
|
+
return resolvedVectorIndex!.upsert(memory, vector);
|
|
103
|
+
},
|
|
104
|
+
delete: async (id) => {
|
|
105
|
+
await initPromise;
|
|
106
|
+
return resolvedVectorIndex!.delete(id);
|
|
107
|
+
},
|
|
108
|
+
search: async (vector, limit) => {
|
|
109
|
+
await initPromise;
|
|
110
|
+
return resolvedVectorIndex!.search(vector, limit);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.embedding = {
|
|
115
|
+
embedMemory: async (title, content) => {
|
|
116
|
+
await initPromise;
|
|
117
|
+
return resolvedEmbedding!.embedMemory(title, content);
|
|
118
|
+
},
|
|
119
|
+
embed: async (text) => {
|
|
120
|
+
await initPromise;
|
|
121
|
+
return resolvedEmbedding!.embed(text);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
} else {
|
|
125
|
+
this.vectorIndex = null;
|
|
126
|
+
this.embedding = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Internal helpers
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Asynchronously embeds a memory and upserts it into the vector index.
|
|
136
|
+
* Fire-and-forget: failures are logged but do not propagate.
|
|
137
|
+
*/
|
|
138
|
+
private scheduleVectorUpsert(memory: Memory): void {
|
|
139
|
+
if (!this.vectorIndex || !this.embedding) return;
|
|
140
|
+
|
|
141
|
+
const vectorIndex = this.vectorIndex;
|
|
142
|
+
const embedding = this.embedding;
|
|
143
|
+
|
|
144
|
+
// Intentionally not awaited
|
|
145
|
+
embedding
|
|
146
|
+
.embedMemory(memory.title, memory.content)
|
|
147
|
+
.then(vec => vectorIndex.upsert(memory, vec))
|
|
148
|
+
.catch(err => {
|
|
149
|
+
// Non-fatal: Markdown file is the source of truth
|
|
150
|
+
console.error('[MemHub] Vector upsert failed (non-fatal):', err);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Removes a memory from the vector index.
|
|
156
|
+
* Called synchronously (awaited) on delete.
|
|
157
|
+
*/
|
|
158
|
+
private async removeFromVectorIndex(id: string): Promise<void> {
|
|
159
|
+
if (!this.vectorIndex) return;
|
|
160
|
+
try {
|
|
161
|
+
await this.vectorIndex.delete(id);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('[MemHub] Vector delete failed (non-fatal):', err);
|
|
164
|
+
}
|
|
60
165
|
}
|
|
61
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// CRUD operations
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
62
171
|
/**
|
|
63
172
|
* Creates a new memory entry
|
|
64
|
-
*
|
|
65
|
-
* @param input - Create memory input
|
|
66
|
-
* @returns Create result with ID, file path, and memory object
|
|
67
|
-
* @throws ServiceError if creation fails
|
|
68
173
|
*/
|
|
69
174
|
async create(input: CreateMemoryInput): Promise<CreateResult> {
|
|
70
175
|
const now = new Date().toISOString();
|
|
@@ -83,11 +188,8 @@ export class MemoryService {
|
|
|
83
188
|
|
|
84
189
|
try {
|
|
85
190
|
const filePath = await this.storage.write(memory);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
filePath,
|
|
89
|
-
memory,
|
|
90
|
-
};
|
|
191
|
+
this.scheduleVectorUpsert(memory);
|
|
192
|
+
return { id, filePath, memory };
|
|
91
193
|
} catch (error) {
|
|
92
194
|
throw new ServiceError(
|
|
93
195
|
`Failed to create memory: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
@@ -98,10 +200,6 @@ export class MemoryService {
|
|
|
98
200
|
|
|
99
201
|
/**
|
|
100
202
|
* Reads a memory by ID
|
|
101
|
-
*
|
|
102
|
-
* @param input - Read memory input
|
|
103
|
-
* @returns Memory object
|
|
104
|
-
* @throws ServiceError if memory not found
|
|
105
203
|
*/
|
|
106
204
|
async read(input: ReadMemoryInput): Promise<{ memory: Memory }> {
|
|
107
205
|
try {
|
|
@@ -120,13 +218,8 @@ export class MemoryService {
|
|
|
120
218
|
|
|
121
219
|
/**
|
|
122
220
|
* Updates an existing memory
|
|
123
|
-
*
|
|
124
|
-
* @param input - Update memory input
|
|
125
|
-
* @returns Updated memory object
|
|
126
|
-
* @throws ServiceError if memory not found
|
|
127
221
|
*/
|
|
128
222
|
async update(input: UpdateMemoryInput): Promise<UpdateResult> {
|
|
129
|
-
// First read the existing memory
|
|
130
223
|
let existing: Memory;
|
|
131
224
|
try {
|
|
132
225
|
existing = await this.storage.read(input.id);
|
|
@@ -140,7 +233,6 @@ export class MemoryService {
|
|
|
140
233
|
);
|
|
141
234
|
}
|
|
142
235
|
|
|
143
|
-
// Apply updates
|
|
144
236
|
const updated: Memory = {
|
|
145
237
|
...existing,
|
|
146
238
|
updatedAt: new Date().toISOString(),
|
|
@@ -153,6 +245,7 @@ export class MemoryService {
|
|
|
153
245
|
|
|
154
246
|
try {
|
|
155
247
|
await this.storage.write(updated);
|
|
248
|
+
this.scheduleVectorUpsert(updated);
|
|
156
249
|
return { memory: updated };
|
|
157
250
|
} catch (error) {
|
|
158
251
|
throw new ServiceError(
|
|
@@ -164,18 +257,12 @@ export class MemoryService {
|
|
|
164
257
|
|
|
165
258
|
/**
|
|
166
259
|
* Deletes a memory by ID
|
|
167
|
-
*
|
|
168
|
-
* @param input - Delete memory input
|
|
169
|
-
* @returns Delete result
|
|
170
|
-
* @throws ServiceError if memory not found
|
|
171
260
|
*/
|
|
172
261
|
async delete(input: DeleteMemoryInput): Promise<DeleteResult> {
|
|
173
262
|
try {
|
|
174
263
|
const filePath = await this.storage.delete(input.id);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
filePath,
|
|
178
|
-
};
|
|
264
|
+
await this.removeFromVectorIndex(input.id);
|
|
265
|
+
return { success: true, filePath };
|
|
179
266
|
} catch (error) {
|
|
180
267
|
if (error instanceof StorageError && error.message.includes('not found')) {
|
|
181
268
|
throw new ServiceError(`Memory not found: ${input.id}`, ErrorCode.NOT_FOUND);
|
|
@@ -187,17 +274,17 @@ export class MemoryService {
|
|
|
187
274
|
}
|
|
188
275
|
}
|
|
189
276
|
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// List / Search
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
190
281
|
/**
|
|
191
282
|
* Lists memories with filtering and pagination
|
|
192
|
-
*
|
|
193
|
-
* @param input - List memory input
|
|
194
|
-
* @returns List result with memories, total count, and hasMore flag
|
|
195
283
|
*/
|
|
196
284
|
async list(input: ListMemoryInput): Promise<ListResult> {
|
|
197
285
|
try {
|
|
198
286
|
const files = await this.storage.list();
|
|
199
287
|
|
|
200
|
-
// Parse all files into memories
|
|
201
288
|
let memories: Memory[] = [];
|
|
202
289
|
for (const file of files) {
|
|
203
290
|
try {
|
|
@@ -206,31 +293,25 @@ export class MemoryService {
|
|
|
206
293
|
);
|
|
207
294
|
memories.push(memory);
|
|
208
295
|
} catch {
|
|
209
|
-
// Skip invalid files
|
|
210
296
|
continue;
|
|
211
297
|
}
|
|
212
298
|
}
|
|
213
299
|
|
|
214
|
-
// Apply filters
|
|
215
300
|
if (input.category) {
|
|
216
301
|
memories = memories.filter(m => m.category === input.category);
|
|
217
302
|
}
|
|
218
|
-
|
|
219
303
|
if (input.tags && input.tags.length > 0) {
|
|
220
304
|
memories = memories.filter(m =>
|
|
221
305
|
input.tags!.every(tag => m.tags.includes(tag))
|
|
222
306
|
);
|
|
223
307
|
}
|
|
224
|
-
|
|
225
308
|
if (input.fromDate) {
|
|
226
309
|
memories = memories.filter(m => m.createdAt >= input.fromDate!);
|
|
227
310
|
}
|
|
228
|
-
|
|
229
311
|
if (input.toDate) {
|
|
230
312
|
memories = memories.filter(m => m.createdAt <= input.toDate!);
|
|
231
313
|
}
|
|
232
314
|
|
|
233
|
-
// Sort
|
|
234
315
|
const sortBy: SortField = input.sortBy ?? 'createdAt';
|
|
235
316
|
const sortOrder: SortOrder = input.sortOrder ?? 'desc';
|
|
236
317
|
|
|
@@ -253,18 +334,15 @@ export class MemoryService {
|
|
|
253
334
|
return sortOrder === 'asc' ? comparison : -comparison;
|
|
254
335
|
});
|
|
255
336
|
|
|
256
|
-
// Apply pagination
|
|
257
337
|
const total = memories.length;
|
|
258
338
|
const limit = input.limit ?? 20;
|
|
259
339
|
const offset = input.offset ?? 0;
|
|
260
|
-
|
|
261
340
|
const paginatedMemories = memories.slice(offset, offset + limit);
|
|
262
|
-
const hasMore = offset + limit < total;
|
|
263
341
|
|
|
264
342
|
return {
|
|
265
343
|
memories: paginatedMemories,
|
|
266
344
|
total,
|
|
267
|
-
hasMore,
|
|
345
|
+
hasMore: offset + limit < total,
|
|
268
346
|
};
|
|
269
347
|
} catch (error) {
|
|
270
348
|
throw new ServiceError(
|
|
@@ -275,116 +353,148 @@ export class MemoryService {
|
|
|
275
353
|
}
|
|
276
354
|
|
|
277
355
|
/**
|
|
278
|
-
* Searches memories by query
|
|
279
|
-
*
|
|
280
|
-
* @param input - Search memory input
|
|
281
|
-
* @returns Search results with scores and matches
|
|
356
|
+
* Searches memories by query.
|
|
357
|
+
* Uses vector semantic search when available, falls back to keyword search.
|
|
282
358
|
*/
|
|
283
359
|
async search(input: SearchMemoryInput): Promise<{ results: SearchResult[]; total: number }> {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const keywords = query.split(/\s+/).filter(k => k.length > 0);
|
|
293
|
-
|
|
294
|
-
const results: SearchResult[] = [];
|
|
360
|
+
// --- Vector semantic search path ---
|
|
361
|
+
if (this.vectorSearchEnabled && this.vectorIndex && this.embedding) {
|
|
362
|
+
try {
|
|
363
|
+
const queryVec = await this.embedding.embed(input.query);
|
|
364
|
+
const vectorResults = await this.vectorIndex.search(
|
|
365
|
+
queryVec,
|
|
366
|
+
input.limit ?? 10
|
|
367
|
+
);
|
|
295
368
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
score += 5;
|
|
310
|
-
if (!matches.includes(memory.title)) {
|
|
311
|
-
matches.push(memory.title);
|
|
312
|
-
}
|
|
369
|
+
const results: SearchResult[] = [];
|
|
370
|
+
for (const vr of vectorResults) {
|
|
371
|
+
try {
|
|
372
|
+
const { memory } = await this.read({ id: vr.id });
|
|
373
|
+
|
|
374
|
+
// Apply metadata filters
|
|
375
|
+
if (input.category && memory.category !== input.category) continue;
|
|
376
|
+
if (
|
|
377
|
+
input.tags &&
|
|
378
|
+
input.tags.length > 0 &&
|
|
379
|
+
!input.tags.every(t => memory.tags.includes(t))
|
|
380
|
+
) {
|
|
381
|
+
continue;
|
|
313
382
|
}
|
|
383
|
+
|
|
384
|
+
// Convert cosine distance (0‥2) → similarity score (0‥1)
|
|
385
|
+
const score = Math.max(0, 1 - vr._distance / 2);
|
|
386
|
+
results.push({ memory, score, matches: [memory.title] });
|
|
387
|
+
} catch {
|
|
388
|
+
// Memory in index but missing on disk — skip
|
|
314
389
|
}
|
|
315
390
|
}
|
|
316
391
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
392
|
+
return { results, total: results.length };
|
|
393
|
+
} catch (err) {
|
|
394
|
+
// Fall through to keyword search on vector failure
|
|
395
|
+
console.error('[MemHub] Vector search failed, falling back to keyword search:', err);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- Keyword search fallback ---
|
|
400
|
+
return this.keywordSearch(input);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Legacy keyword-based search (used as fallback when vector search is unavailable).
|
|
405
|
+
*/
|
|
406
|
+
private async keywordSearch(
|
|
407
|
+
input: SearchMemoryInput
|
|
408
|
+
): Promise<{ results: SearchResult[]; total: number }> {
|
|
409
|
+
const listResult = await this.list({
|
|
410
|
+
category: input.category,
|
|
411
|
+
tags: input.tags,
|
|
412
|
+
limit: 1000,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const query = input.query.toLowerCase();
|
|
416
|
+
const keywords = query.split(/\s+/).filter(k => k.length > 0);
|
|
417
|
+
const results: SearchResult[] = [];
|
|
418
|
+
|
|
419
|
+
for (const memory of listResult.memories) {
|
|
420
|
+
let score = 0;
|
|
421
|
+
const matches: string[] = [];
|
|
422
|
+
|
|
423
|
+
const titleLower = memory.title.toLowerCase();
|
|
424
|
+
if (titleLower.includes(query)) {
|
|
425
|
+
score += 10;
|
|
426
|
+
matches.push(memory.title);
|
|
427
|
+
} else {
|
|
428
|
+
for (const keyword of keywords) {
|
|
429
|
+
if (titleLower.includes(keyword)) {
|
|
430
|
+
score += 5;
|
|
431
|
+
if (!matches.includes(memory.title)) matches.push(memory.title);
|
|
340
432
|
}
|
|
341
433
|
}
|
|
434
|
+
}
|
|
342
435
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
436
|
+
const contentLower = memory.content.toLowerCase();
|
|
437
|
+
if (contentLower.includes(query)) {
|
|
438
|
+
score += 3;
|
|
439
|
+
const index = contentLower.indexOf(query);
|
|
440
|
+
const start = Math.max(0, index - 50);
|
|
441
|
+
const end = Math.min(contentLower.length, index + query.length + 50);
|
|
442
|
+
matches.push(memory.content.slice(start, end));
|
|
443
|
+
} else {
|
|
444
|
+
for (const keyword of keywords) {
|
|
445
|
+
if (contentLower.includes(keyword)) {
|
|
446
|
+
score += 1;
|
|
447
|
+
const index = contentLower.indexOf(keyword);
|
|
448
|
+
const start = Math.max(0, index - 30);
|
|
449
|
+
const end = Math.min(contentLower.length, index + keyword.length + 30);
|
|
450
|
+
const snippet = memory.content.slice(start, end);
|
|
451
|
+
if (!matches.some(m => m.includes(snippet))) matches.push(snippet);
|
|
348
452
|
}
|
|
349
453
|
}
|
|
454
|
+
}
|
|
350
455
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
456
|
+
for (const tag of memory.tags) {
|
|
457
|
+
if (
|
|
458
|
+
tag.toLowerCase().includes(query) ||
|
|
459
|
+
keywords.some(k => tag.toLowerCase().includes(k))
|
|
460
|
+
) {
|
|
461
|
+
score += 2;
|
|
462
|
+
matches.push(`Tag: ${tag}`);
|
|
357
463
|
}
|
|
358
464
|
}
|
|
359
465
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return {
|
|
368
|
-
results: limitedResults,
|
|
369
|
-
total: results.length,
|
|
370
|
-
};
|
|
371
|
-
} catch (error) {
|
|
372
|
-
throw new ServiceError(
|
|
373
|
-
`Failed to search memories: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
374
|
-
ErrorCode.STORAGE_ERROR
|
|
375
|
-
);
|
|
466
|
+
if (score > 0) {
|
|
467
|
+
results.push({
|
|
468
|
+
memory,
|
|
469
|
+
score: Math.min(score / 20, 1),
|
|
470
|
+
matches: matches.slice(0, 3),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
376
473
|
}
|
|
474
|
+
|
|
475
|
+
results.sort((a, b) => b.score - a.score);
|
|
476
|
+
const limit = input.limit ?? 10;
|
|
477
|
+
return { results: results.slice(0, limit), total: results.length };
|
|
377
478
|
}
|
|
378
479
|
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// MCP unified tools
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
379
484
|
/**
|
|
380
|
-
* memory_load unified read API
|
|
485
|
+
* memory_load — unified read API.
|
|
486
|
+
*
|
|
487
|
+
* Requires either `id` (exact lookup) or `query` (semantic search).
|
|
488
|
+
* Calling without either returns an empty result.
|
|
381
489
|
*/
|
|
382
490
|
async memoryLoad(input: MemoryLoadInput): Promise<MemoryLoadOutput> {
|
|
491
|
+
// By-ID lookup
|
|
383
492
|
if (input.id) {
|
|
384
493
|
const { memory } = await this.read({ id: input.id });
|
|
385
494
|
return { items: [memory], total: 1 };
|
|
386
495
|
}
|
|
387
496
|
|
|
497
|
+
// Semantic / keyword search
|
|
388
498
|
if (input.query) {
|
|
389
499
|
const searched = await this.search({
|
|
390
500
|
query: input.query,
|
|
@@ -392,37 +502,16 @@ export class MemoryService {
|
|
|
392
502
|
tags: input.tags,
|
|
393
503
|
limit: input.limit,
|
|
394
504
|
});
|
|
395
|
-
|
|
396
|
-
if (input.sessionId) {
|
|
397
|
-
items = items.filter(m => m.sessionId === input.sessionId);
|
|
398
|
-
}
|
|
505
|
+
const items = searched.results.map(r => r.memory);
|
|
399
506
|
return { items, total: items.length };
|
|
400
507
|
}
|
|
401
508
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
tags: input.tags,
|
|
405
|
-
limit: input.limit ?? 20,
|
|
406
|
-
sortBy: 'updatedAt',
|
|
407
|
-
sortOrder: 'desc',
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
let items = [...listResult.memories];
|
|
411
|
-
|
|
412
|
-
if (input.sessionId) {
|
|
413
|
-
items = items.filter(m => m.sessionId === input.sessionId);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (input.date) {
|
|
417
|
-
const date = input.date;
|
|
418
|
-
items = items.filter(m => m.createdAt.startsWith(date));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
return { items, total: items.length };
|
|
509
|
+
// No id and no query — return empty (not supported)
|
|
510
|
+
return { items: [], total: 0 };
|
|
422
511
|
}
|
|
423
512
|
|
|
424
513
|
/**
|
|
425
|
-
* memory_update unified write API (append/upsert)
|
|
514
|
+
* memory_update — unified write API (append/upsert)
|
|
426
515
|
*/
|
|
427
516
|
async memoryUpdate(input: MemoryUpdateInput): Promise<MemoryUpdateOutput> {
|
|
428
517
|
const now = new Date().toISOString();
|
|
@@ -445,6 +534,8 @@ export class MemoryService {
|
|
|
445
534
|
};
|
|
446
535
|
|
|
447
536
|
const filePath = await this.storage.write(updatedMemory);
|
|
537
|
+
this.scheduleVectorUpsert(updatedMemory);
|
|
538
|
+
|
|
448
539
|
return {
|
|
449
540
|
id: updatedMemory.id,
|
|
450
541
|
sessionId,
|
|
@@ -470,6 +561,8 @@ export class MemoryService {
|
|
|
470
561
|
};
|
|
471
562
|
|
|
472
563
|
const filePath = await this.storage.write(createdMemory);
|
|
564
|
+
this.scheduleVectorUpsert(createdMemory);
|
|
565
|
+
|
|
473
566
|
return {
|
|
474
567
|
id,
|
|
475
568
|
sessionId,
|
|
@@ -480,23 +573,18 @@ export class MemoryService {
|
|
|
480
573
|
};
|
|
481
574
|
}
|
|
482
575
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
*/
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// Metadata helpers
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
488
580
|
async getCategories(): Promise<GetCategoriesOutput> {
|
|
489
581
|
try {
|
|
490
582
|
const listResult = await this.list({ limit: 1000 });
|
|
491
583
|
const categories = new Set<string>();
|
|
492
|
-
|
|
493
584
|
for (const memory of listResult.memories) {
|
|
494
585
|
categories.add(memory.category);
|
|
495
586
|
}
|
|
496
|
-
|
|
497
|
-
return {
|
|
498
|
-
categories: Array.from(categories).sort(),
|
|
499
|
-
};
|
|
587
|
+
return { categories: Array.from(categories).sort() };
|
|
500
588
|
} catch (error) {
|
|
501
589
|
throw new ServiceError(
|
|
502
590
|
`Failed to get categories: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
@@ -505,25 +593,16 @@ export class MemoryService {
|
|
|
505
593
|
}
|
|
506
594
|
}
|
|
507
595
|
|
|
508
|
-
/**
|
|
509
|
-
* Gets all unique tags
|
|
510
|
-
*
|
|
511
|
-
* @returns Array of tag names
|
|
512
|
-
*/
|
|
513
596
|
async getTags(): Promise<GetTagsOutput> {
|
|
514
597
|
try {
|
|
515
598
|
const listResult = await this.list({ limit: 1000 });
|
|
516
599
|
const tags = new Set<string>();
|
|
517
|
-
|
|
518
600
|
for (const memory of listResult.memories) {
|
|
519
601
|
for (const tag of memory.tags) {
|
|
520
602
|
tags.add(tag);
|
|
521
603
|
}
|
|
522
604
|
}
|
|
523
|
-
|
|
524
|
-
return {
|
|
525
|
-
tags: Array.from(tags).sort(),
|
|
526
|
-
};
|
|
605
|
+
return { tags: Array.from(tags).sort() };
|
|
527
606
|
} catch (error) {
|
|
528
607
|
throw new ServiceError(
|
|
529
608
|
`Failed to get tags: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
@@ -532,12 +611,6 @@ export class MemoryService {
|
|
|
532
611
|
}
|
|
533
612
|
}
|
|
534
613
|
|
|
535
|
-
/**
|
|
536
|
-
* Extracts ID from file content
|
|
537
|
-
*
|
|
538
|
-
* @param content - File content
|
|
539
|
-
* @returns ID string
|
|
540
|
-
*/
|
|
541
614
|
private extractIdFromContent(content: string): string {
|
|
542
615
|
const match = content.match(/id:\s*"?([^"\n]+)"?/);
|
|
543
616
|
if (!match) {
|