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