@synth-coder/memhub 0.2.3 → 0.2.4

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 (143) hide show
  1. package/.eslintrc.cjs +45 -45
  2. package/.factory/commands/opsx-apply.md +150 -150
  3. package/.factory/commands/opsx-archive.md +155 -155
  4. package/.factory/commands/opsx-explore.md +171 -171
  5. package/.factory/commands/opsx-propose.md +104 -104
  6. package/.factory/skills/openspec-apply-change/SKILL.md +156 -156
  7. package/.factory/skills/openspec-archive-change/SKILL.md +114 -114
  8. package/.factory/skills/openspec-explore/SKILL.md +288 -288
  9. package/.factory/skills/openspec-propose/SKILL.md +110 -110
  10. package/.github/workflows/ci.yml +110 -74
  11. package/.github/workflows/release.yml +67 -0
  12. package/.iflow/commands/opsx-apply.md +152 -152
  13. package/.iflow/commands/opsx-archive.md +157 -157
  14. package/.iflow/commands/opsx-explore.md +173 -173
  15. package/.iflow/commands/opsx-propose.md +106 -106
  16. package/.iflow/skills/openspec-apply-change/SKILL.md +156 -156
  17. package/.iflow/skills/openspec-archive-change/SKILL.md +114 -114
  18. package/.iflow/skills/openspec-explore/SKILL.md +288 -288
  19. package/.iflow/skills/openspec-propose/SKILL.md +110 -110
  20. package/.prettierrc +11 -11
  21. package/AGENTS.md +167 -169
  22. package/README.md +276 -195
  23. package/README.zh-CN.md +245 -193
  24. package/dist/src/cli/agents/claude-code.d.ts +5 -0
  25. package/dist/src/cli/agents/claude-code.d.ts.map +1 -0
  26. package/dist/src/cli/agents/claude-code.js +14 -0
  27. package/dist/src/cli/agents/claude-code.js.map +1 -0
  28. package/dist/src/cli/agents/cline.d.ts +5 -0
  29. package/dist/src/cli/agents/cline.d.ts.map +1 -0
  30. package/dist/src/cli/agents/cline.js +14 -0
  31. package/dist/src/cli/agents/cline.js.map +1 -0
  32. package/dist/src/cli/agents/codex.d.ts +5 -0
  33. package/dist/src/cli/agents/codex.d.ts.map +1 -0
  34. package/dist/src/cli/agents/codex.js +14 -0
  35. package/dist/src/cli/agents/codex.js.map +1 -0
  36. package/dist/src/cli/agents/cursor.d.ts +5 -0
  37. package/dist/src/cli/agents/cursor.d.ts.map +1 -0
  38. package/dist/src/cli/agents/cursor.js +14 -0
  39. package/dist/src/cli/agents/cursor.js.map +1 -0
  40. package/dist/src/cli/agents/factory-droid.d.ts +5 -0
  41. package/dist/src/cli/agents/factory-droid.d.ts.map +1 -0
  42. package/dist/src/cli/agents/factory-droid.js +14 -0
  43. package/dist/src/cli/agents/factory-droid.js.map +1 -0
  44. package/dist/src/cli/agents/gemini-cli.d.ts +5 -0
  45. package/dist/src/cli/agents/gemini-cli.d.ts.map +1 -0
  46. package/dist/src/cli/agents/gemini-cli.js +14 -0
  47. package/dist/src/cli/agents/gemini-cli.js.map +1 -0
  48. package/dist/src/cli/agents/index.d.ts +14 -0
  49. package/dist/src/cli/agents/index.d.ts.map +1 -0
  50. package/dist/src/cli/agents/index.js +30 -0
  51. package/dist/src/cli/agents/index.js.map +1 -0
  52. package/dist/src/cli/agents/windsurf.d.ts +5 -0
  53. package/dist/src/cli/agents/windsurf.d.ts.map +1 -0
  54. package/dist/src/cli/agents/windsurf.js +14 -0
  55. package/dist/src/cli/agents/windsurf.js.map +1 -0
  56. package/dist/src/cli/index.d.ts +8 -0
  57. package/dist/src/cli/index.d.ts.map +1 -0
  58. package/dist/src/cli/index.js +168 -0
  59. package/dist/src/cli/index.js.map +1 -0
  60. package/dist/src/cli/init.d.ts +34 -0
  61. package/dist/src/cli/init.d.ts.map +1 -0
  62. package/dist/src/cli/init.js +160 -0
  63. package/dist/src/cli/init.js.map +1 -0
  64. package/dist/src/cli/instructions.d.ts +29 -0
  65. package/dist/src/cli/instructions.d.ts.map +1 -0
  66. package/dist/src/cli/instructions.js +141 -0
  67. package/dist/src/cli/instructions.js.map +1 -0
  68. package/dist/src/cli/types.d.ts +22 -0
  69. package/dist/src/cli/types.d.ts.map +1 -0
  70. package/dist/src/cli/types.js +86 -0
  71. package/dist/src/cli/types.js.map +1 -0
  72. package/dist/src/contracts/mcp.js +34 -34
  73. package/dist/src/contracts/schemas.js.map +1 -1
  74. package/dist/src/server/mcp-server.d.ts.map +1 -1
  75. package/dist/src/server/mcp-server.js +7 -14
  76. package/dist/src/server/mcp-server.js.map +1 -1
  77. package/dist/src/services/embedding-service.d.ts.map +1 -1
  78. package/dist/src/services/embedding-service.js +1 -1
  79. package/dist/src/services/embedding-service.js.map +1 -1
  80. package/dist/src/services/memory-service.d.ts.map +1 -1
  81. package/dist/src/services/memory-service.js.map +1 -1
  82. package/dist/src/storage/markdown-storage.d.ts.map +1 -1
  83. package/dist/src/storage/markdown-storage.js +1 -1
  84. package/dist/src/storage/markdown-storage.js.map +1 -1
  85. package/dist/src/storage/vector-index.d.ts.map +1 -1
  86. package/dist/src/storage/vector-index.js +4 -5
  87. package/dist/src/storage/vector-index.js.map +1 -1
  88. package/docs/README.md +21 -0
  89. package/docs/mcp-tools.md +136 -0
  90. package/docs/user-guide.md +182 -0
  91. package/package.json +61 -59
  92. package/src/cli/agents/claude-code.ts +14 -0
  93. package/src/cli/agents/cline.ts +14 -0
  94. package/src/cli/agents/codex.ts +14 -0
  95. package/src/cli/agents/cursor.ts +14 -0
  96. package/src/cli/agents/factory-droid.ts +14 -0
  97. package/src/cli/agents/gemini-cli.ts +14 -0
  98. package/src/cli/agents/index.ts +36 -0
  99. package/src/cli/agents/windsurf.ts +14 -0
  100. package/src/cli/index.ts +192 -0
  101. package/src/cli/init.ts +218 -0
  102. package/src/cli/instructions.ts +156 -0
  103. package/src/cli/types.ts +112 -0
  104. package/src/contracts/index.ts +12 -12
  105. package/src/contracts/mcp.ts +223 -223
  106. package/src/contracts/schemas.ts +307 -307
  107. package/src/contracts/types.ts +410 -410
  108. package/src/index.ts +8 -8
  109. package/src/server/index.ts +5 -5
  110. package/src/server/mcp-server.ts +169 -186
  111. package/src/services/embedding-service.ts +114 -114
  112. package/src/services/index.ts +5 -5
  113. package/src/services/memory-service.ts +656 -663
  114. package/src/storage/frontmatter-parser.ts +243 -243
  115. package/src/storage/index.ts +6 -6
  116. package/src/storage/markdown-storage.ts +228 -236
  117. package/src/storage/vector-index.ts +159 -160
  118. package/src/utils/index.ts +5 -5
  119. package/src/utils/slugify.ts +63 -63
  120. package/test/cli/init.test.ts +380 -0
  121. package/test/contracts/schemas.test.ts +313 -313
  122. package/test/contracts/types.test.ts +21 -21
  123. package/test/frontmatter-parser-more.test.ts +94 -94
  124. package/test/server/mcp-server.test.ts +211 -210
  125. package/test/services/memory-service-edge.test.ts +248 -248
  126. package/test/services/memory-service.test.ts +291 -279
  127. package/test/storage/frontmatter-parser.test.ts +223 -223
  128. package/test/storage/markdown-storage.test.ts +226 -217
  129. package/test/storage/storage-edge.test.ts +238 -238
  130. package/test/storage/vector-index.test.ts +149 -153
  131. package/test/utils/slugify-edge.test.ts +94 -94
  132. package/test/utils/slugify.test.ts +72 -68
  133. package/tsconfig.json +25 -25
  134. package/tsconfig.test.json +8 -8
  135. package/vitest.config.ts +29 -29
  136. package/docs/architecture-diagrams.md +0 -368
  137. package/docs/architecture.md +0 -381
  138. package/docs/contracts.md +0 -190
  139. package/docs/prompt-template.md +0 -33
  140. package/docs/proposals/mcp-typescript-sdk-refactor.md +0 -568
  141. package/docs/proposals/proposal-close-gates.md +0 -58
  142. package/docs/tool-calling-policy.md +0 -101
  143. package/docs/vector-search.md +0 -306
@@ -1,663 +1,656 @@
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_load — unified 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
- }
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(this.extractIdFromContent(file.content));
316
+ memories.push(memory);
317
+ } catch {
318
+ continue;
319
+ }
320
+ }
321
+
322
+ if (input.category) {
323
+ memories = memories.filter(m => m.category === input.category);
324
+ }
325
+ if (input.tags && input.tags.length > 0) {
326
+ memories = memories.filter(m => input.tags!.every(tag => m.tags.includes(tag)));
327
+ }
328
+ if (input.fromDate) {
329
+ memories = memories.filter(m => m.createdAt >= input.fromDate!);
330
+ }
331
+ if (input.toDate) {
332
+ memories = memories.filter(m => m.createdAt <= input.toDate!);
333
+ }
334
+
335
+ const sortBy: SortField = input.sortBy ?? 'createdAt';
336
+ const sortOrder: SortOrder = input.sortOrder ?? 'desc';
337
+
338
+ memories.sort((a, b) => {
339
+ let comparison = 0;
340
+ switch (sortBy) {
341
+ case 'createdAt':
342
+ comparison = a.createdAt.localeCompare(b.createdAt);
343
+ break;
344
+ case 'updatedAt':
345
+ comparison = a.updatedAt.localeCompare(b.updatedAt);
346
+ break;
347
+ case 'title':
348
+ comparison = a.title.localeCompare(b.title);
349
+ break;
350
+ case 'importance':
351
+ comparison = a.importance - b.importance;
352
+ break;
353
+ }
354
+ return sortOrder === 'asc' ? comparison : -comparison;
355
+ });
356
+
357
+ const total = memories.length;
358
+ const limit = input.limit ?? 20;
359
+ const offset = input.offset ?? 0;
360
+ const paginatedMemories = memories.slice(offset, offset + limit);
361
+
362
+ return {
363
+ memories: paginatedMemories,
364
+ total,
365
+ hasMore: offset + limit < total,
366
+ };
367
+ } catch (error) {
368
+ throw new ServiceError(
369
+ `Failed to list memories: ${error instanceof Error ? error.message : 'Unknown error'}`,
370
+ ErrorCode.STORAGE_ERROR
371
+ );
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Searches memories by query.
377
+ * Uses vector semantic search when available, falls back to keyword search.
378
+ */
379
+ async search(input: SearchMemoryInput): Promise<{ results: SearchResult[]; total: number }> {
380
+ // --- Vector semantic search path ---
381
+ if (this.vectorSearchEnabled && this.vectorIndex && this.embedding) {
382
+ try {
383
+ const queryVec = await this.embedding.embed(input.query);
384
+ const vectorResults = await this.vectorIndex.search(queryVec, input.limit ?? 10);
385
+
386
+ const results: SearchResult[] = [];
387
+ for (const vr of vectorResults) {
388
+ try {
389
+ const { memory } = await this.read({ id: vr.id });
390
+
391
+ // Apply metadata filters
392
+ if (input.category && memory.category !== input.category) continue;
393
+ if (
394
+ input.tags &&
395
+ input.tags.length > 0 &&
396
+ !input.tags.every(t => memory.tags.includes(t))
397
+ ) {
398
+ continue;
399
+ }
400
+
401
+ // Convert cosine distance (0‥2) → similarity score (0‥1)
402
+ const score = Math.max(0, 1 - vr._distance / 2);
403
+ results.push({ memory, score, matches: [memory.title] });
404
+ } catch {
405
+ // Memory in index but missing on disk — skip
406
+ }
407
+ }
408
+
409
+ return { results, total: results.length };
410
+ } catch (err) {
411
+ // Fall through to keyword search on vector failure
412
+ console.error('[MemHub] Vector search failed, falling back to keyword search:', err);
413
+ }
414
+ }
415
+
416
+ // --- Keyword search fallback ---
417
+ return this.keywordSearch(input);
418
+ }
419
+
420
+ /**
421
+ * Legacy keyword-based search (used as fallback when vector search is unavailable).
422
+ */
423
+ private async keywordSearch(
424
+ input: SearchMemoryInput
425
+ ): Promise<{ results: SearchResult[]; total: number }> {
426
+ const listResult = await this.list({
427
+ category: input.category,
428
+ tags: input.tags,
429
+ limit: 1000,
430
+ });
431
+
432
+ const query = input.query.toLowerCase();
433
+ const keywords = query.split(/\s+/).filter(k => k.length > 0);
434
+ const results: SearchResult[] = [];
435
+
436
+ for (const memory of listResult.memories) {
437
+ let score = 0;
438
+ const matches: string[] = [];
439
+
440
+ const titleLower = memory.title.toLowerCase();
441
+ if (titleLower.includes(query)) {
442
+ score += 10;
443
+ matches.push(memory.title);
444
+ } else {
445
+ for (const keyword of keywords) {
446
+ if (titleLower.includes(keyword)) {
447
+ score += 5;
448
+ if (!matches.includes(memory.title)) matches.push(memory.title);
449
+ }
450
+ }
451
+ }
452
+
453
+ const contentLower = memory.content.toLowerCase();
454
+ if (contentLower.includes(query)) {
455
+ score += 3;
456
+ const index = contentLower.indexOf(query);
457
+ const start = Math.max(0, index - 50);
458
+ const end = Math.min(contentLower.length, index + query.length + 50);
459
+ matches.push(memory.content.slice(start, end));
460
+ } else {
461
+ for (const keyword of keywords) {
462
+ if (contentLower.includes(keyword)) {
463
+ score += 1;
464
+ const index = contentLower.indexOf(keyword);
465
+ const start = Math.max(0, index - 30);
466
+ const end = Math.min(contentLower.length, index + keyword.length + 30);
467
+ const snippet = memory.content.slice(start, end);
468
+ if (!matches.some(m => m.includes(snippet))) matches.push(snippet);
469
+ }
470
+ }
471
+ }
472
+
473
+ for (const tag of memory.tags) {
474
+ if (
475
+ tag.toLowerCase().includes(query) ||
476
+ keywords.some(k => tag.toLowerCase().includes(k))
477
+ ) {
478
+ score += 2;
479
+ matches.push(`Tag: ${tag}`);
480
+ }
481
+ }
482
+
483
+ if (score > 0) {
484
+ results.push({
485
+ memory,
486
+ score: Math.min(score / 20, 1),
487
+ matches: matches.slice(0, 3),
488
+ });
489
+ }
490
+ }
491
+
492
+ results.sort((a, b) => b.score - a.score);
493
+ const limit = input.limit ?? 10;
494
+ return { results: results.slice(0, limit), total: results.length };
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // MCP unified tools
499
+ // ---------------------------------------------------------------------------
500
+
501
+ /**
502
+ * memory_load — unified read API.
503
+ *
504
+ * Requires either `id` (exact lookup) or `query` (semantic search).
505
+ * Calling without either returns an empty result.
506
+ */
507
+ async memoryLoad(input: MemoryLoadInput): Promise<MemoryLoadOutput> {
508
+ // By-ID lookup
509
+ if (input.id) {
510
+ const { memory } = await this.read({ id: input.id });
511
+ return { items: [memory], total: 1 };
512
+ }
513
+
514
+ // Semantic / keyword search
515
+ if (input.query) {
516
+ const searched = await this.search({
517
+ query: input.query,
518
+ category: input.category,
519
+ tags: input.tags,
520
+ limit: input.limit,
521
+ });
522
+ const items = searched.results.map(r => r.memory);
523
+ return { items, total: items.length };
524
+ }
525
+
526
+ // No id and no query — return empty (not supported)
527
+ return { items: [], total: 0 };
528
+ }
529
+
530
+ /**
531
+ * memory_update — unified write API (append/upsert)
532
+ */
533
+ async memoryUpdate(input: MemoryUpdateInput): Promise<MemoryUpdateOutput> {
534
+ const release = await lockfile.lock(this.storagePath, {
535
+ retries: { retries: 100, minTimeout: 50, maxTimeout: LOCK_TIMEOUT / 100 },
536
+ });
537
+ try {
538
+ const now = new Date().toISOString();
539
+ const sessionId = input.sessionId ?? randomUUID();
540
+
541
+ if (input.id) {
542
+ // Inline update logic to avoid nested lock
543
+ let existing: Memory;
544
+ try {
545
+ existing = await this.storage.read(input.id);
546
+ } catch (error) {
547
+ if (error instanceof StorageError && error.message.includes('not found')) {
548
+ throw new ServiceError(`Memory not found: ${input.id}`, ErrorCode.NOT_FOUND);
549
+ }
550
+ throw new ServiceError(
551
+ `Failed to read memory for update: ${error instanceof Error ? error.message : 'Unknown error'}`,
552
+ ErrorCode.STORAGE_ERROR
553
+ );
554
+ }
555
+
556
+ const updatedMemory: Memory = {
557
+ ...existing,
558
+ updatedAt: now,
559
+ sessionId,
560
+ entryType: input.entryType,
561
+ ...(input.title !== undefined && { title: input.title }),
562
+ ...(input.content !== undefined && { content: input.content }),
563
+ ...(input.tags !== undefined && { tags: input.tags }),
564
+ ...(input.category !== undefined && { category: input.category }),
565
+ ...(input.importance !== undefined && { importance: input.importance }),
566
+ };
567
+
568
+ const filePath = await this.storage.write(updatedMemory);
569
+ this.scheduleVectorUpsert(updatedMemory);
570
+
571
+ return {
572
+ id: updatedMemory.id,
573
+ sessionId,
574
+ filePath,
575
+ created: false,
576
+ updated: true,
577
+ memory: updatedMemory,
578
+ };
579
+ }
580
+
581
+ const id = randomUUID();
582
+ const createdMemory: Memory = {
583
+ id,
584
+ createdAt: now,
585
+ updatedAt: now,
586
+ sessionId,
587
+ entryType: input.entryType,
588
+ tags: input.tags ?? [],
589
+ category: input.category ?? 'general',
590
+ importance: input.importance ?? 3,
591
+ title: input.title ?? 'memory note',
592
+ content: input.content,
593
+ };
594
+
595
+ const filePath = await this.storage.write(createdMemory);
596
+ this.scheduleVectorUpsert(createdMemory);
597
+
598
+ return {
599
+ id,
600
+ sessionId,
601
+ filePath,
602
+ created: true,
603
+ updated: false,
604
+ memory: createdMemory,
605
+ };
606
+ } finally {
607
+ await release();
608
+ }
609
+ }
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Metadata helpers
613
+ // ---------------------------------------------------------------------------
614
+
615
+ async getCategories(): Promise<GetCategoriesOutput> {
616
+ try {
617
+ const listResult = await this.list({ limit: 1000 });
618
+ const categories = new Set<string>();
619
+ for (const memory of listResult.memories) {
620
+ categories.add(memory.category);
621
+ }
622
+ return { categories: Array.from(categories).sort() };
623
+ } catch (error) {
624
+ throw new ServiceError(
625
+ `Failed to get categories: ${error instanceof Error ? error.message : 'Unknown error'}`,
626
+ ErrorCode.STORAGE_ERROR
627
+ );
628
+ }
629
+ }
630
+
631
+ async getTags(): Promise<GetTagsOutput> {
632
+ try {
633
+ const listResult = await this.list({ limit: 1000 });
634
+ const tags = new Set<string>();
635
+ for (const memory of listResult.memories) {
636
+ for (const tag of memory.tags) {
637
+ tags.add(tag);
638
+ }
639
+ }
640
+ return { tags: Array.from(tags).sort() };
641
+ } catch (error) {
642
+ throw new ServiceError(
643
+ `Failed to get tags: ${error instanceof Error ? error.message : 'Unknown error'}`,
644
+ ErrorCode.STORAGE_ERROR
645
+ );
646
+ }
647
+ }
648
+
649
+ private extractIdFromContent(content: string): string {
650
+ const match = content.match(/id:\s*"?([^"\n]+)"?/);
651
+ if (!match) {
652
+ throw new Error('Could not extract ID from content');
653
+ }
654
+ return match[1].trim();
655
+ }
656
+ }