@synth-coder/memhub 0.1.0

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 (104) hide show
  1. package/.eslintrc.cjs +46 -0
  2. package/.github/workflows/ci.yml +74 -0
  3. package/.iflow/commands/opsx-apply.md +152 -0
  4. package/.iflow/commands/opsx-archive.md +157 -0
  5. package/.iflow/commands/opsx-explore.md +173 -0
  6. package/.iflow/commands/opsx-propose.md +106 -0
  7. package/.iflow/skills/openspec-apply-change/SKILL.md +156 -0
  8. package/.iflow/skills/openspec-archive-change/SKILL.md +114 -0
  9. package/.iflow/skills/openspec-explore/SKILL.md +288 -0
  10. package/.iflow/skills/openspec-propose/SKILL.md +110 -0
  11. package/.prettierrc +11 -0
  12. package/README.md +171 -0
  13. package/README.zh-CN.md +169 -0
  14. package/dist/src/contracts/index.d.ts +7 -0
  15. package/dist/src/contracts/index.d.ts.map +1 -0
  16. package/dist/src/contracts/index.js +10 -0
  17. package/dist/src/contracts/index.js.map +1 -0
  18. package/dist/src/contracts/mcp.d.ts +194 -0
  19. package/dist/src/contracts/mcp.d.ts.map +1 -0
  20. package/dist/src/contracts/mcp.js +112 -0
  21. package/dist/src/contracts/mcp.js.map +1 -0
  22. package/dist/src/contracts/schemas.d.ts +1153 -0
  23. package/dist/src/contracts/schemas.d.ts.map +1 -0
  24. package/dist/src/contracts/schemas.js +246 -0
  25. package/dist/src/contracts/schemas.js.map +1 -0
  26. package/dist/src/contracts/types.d.ts +328 -0
  27. package/dist/src/contracts/types.d.ts.map +1 -0
  28. package/dist/src/contracts/types.js +30 -0
  29. package/dist/src/contracts/types.js.map +1 -0
  30. package/dist/src/index.d.ts +8 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +8 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/server/index.d.ts +5 -0
  35. package/dist/src/server/index.d.ts.map +1 -0
  36. package/dist/src/server/index.js +5 -0
  37. package/dist/src/server/index.js.map +1 -0
  38. package/dist/src/server/mcp-server.d.ts +80 -0
  39. package/dist/src/server/mcp-server.d.ts.map +1 -0
  40. package/dist/src/server/mcp-server.js +263 -0
  41. package/dist/src/server/mcp-server.js.map +1 -0
  42. package/dist/src/services/index.d.ts +5 -0
  43. package/dist/src/services/index.d.ts.map +1 -0
  44. package/dist/src/services/index.js +5 -0
  45. package/dist/src/services/index.js.map +1 -0
  46. package/dist/src/services/memory-service.d.ts +105 -0
  47. package/dist/src/services/memory-service.d.ts.map +1 -0
  48. package/dist/src/services/memory-service.js +447 -0
  49. package/dist/src/services/memory-service.js.map +1 -0
  50. package/dist/src/storage/frontmatter-parser.d.ts +69 -0
  51. package/dist/src/storage/frontmatter-parser.d.ts.map +1 -0
  52. package/dist/src/storage/frontmatter-parser.js +207 -0
  53. package/dist/src/storage/frontmatter-parser.js.map +1 -0
  54. package/dist/src/storage/index.d.ts +6 -0
  55. package/dist/src/storage/index.d.ts.map +1 -0
  56. package/dist/src/storage/index.js +6 -0
  57. package/dist/src/storage/index.js.map +1 -0
  58. package/dist/src/storage/markdown-storage.d.ts +76 -0
  59. package/dist/src/storage/markdown-storage.d.ts.map +1 -0
  60. package/dist/src/storage/markdown-storage.js +193 -0
  61. package/dist/src/storage/markdown-storage.js.map +1 -0
  62. package/dist/src/utils/index.d.ts +5 -0
  63. package/dist/src/utils/index.d.ts.map +1 -0
  64. package/dist/src/utils/index.js +5 -0
  65. package/dist/src/utils/index.js.map +1 -0
  66. package/dist/src/utils/slugify.d.ts +24 -0
  67. package/dist/src/utils/slugify.d.ts.map +1 -0
  68. package/dist/src/utils/slugify.js +56 -0
  69. package/dist/src/utils/slugify.js.map +1 -0
  70. package/docs/architecture.md +349 -0
  71. package/docs/contracts.md +119 -0
  72. package/docs/prompt-template.md +79 -0
  73. package/docs/proposal-close-gates.md +58 -0
  74. package/docs/tool-calling-policy.md +107 -0
  75. package/package.json +53 -0
  76. package/src/contracts/index.ts +12 -0
  77. package/src/contracts/mcp.ts +303 -0
  78. package/src/contracts/schemas.ts +311 -0
  79. package/src/contracts/types.ts +414 -0
  80. package/src/index.ts +8 -0
  81. package/src/server/index.ts +5 -0
  82. package/src/server/mcp-server.ts +352 -0
  83. package/src/services/index.ts +5 -0
  84. package/src/services/memory-service.ts +548 -0
  85. package/src/storage/frontmatter-parser.ts +243 -0
  86. package/src/storage/index.ts +6 -0
  87. package/src/storage/markdown-storage.ts +236 -0
  88. package/src/utils/index.ts +5 -0
  89. package/src/utils/slugify.ts +63 -0
  90. package/test/contracts/schemas.test.ts +313 -0
  91. package/test/contracts/types.test.ts +21 -0
  92. package/test/frontmatter-parser-more.test.ts +94 -0
  93. package/test/server/mcp-server-internals.test.ts +257 -0
  94. package/test/server/mcp-server.test.ts +97 -0
  95. package/test/services/memory-service-edge.test.ts +248 -0
  96. package/test/services/memory-service.test.ts +279 -0
  97. package/test/storage/frontmatter-parser.test.ts +223 -0
  98. package/test/storage/markdown-storage.test.ts +217 -0
  99. package/test/storage/storage-edge.test.ts +238 -0
  100. package/test/utils/slugify-edge.test.ts +94 -0
  101. package/test/utils/slugify.test.ts +68 -0
  102. package/tsconfig.json +26 -0
  103. package/tsconfig.test.json +8 -0
  104. package/vitest.config.ts +27 -0
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Memory Service - Business logic for memory operations
3
+ */
4
+
5
+ import { randomUUID } from 'crypto';
6
+ import type {
7
+ Memory,
8
+ CreateMemoryInput,
9
+ ReadMemoryInput,
10
+ UpdateMemoryInput,
11
+ DeleteMemoryInput,
12
+ ListMemoryInput,
13
+ SearchMemoryInput,
14
+ CreateResult,
15
+ UpdateResult,
16
+ DeleteResult,
17
+ ListResult,
18
+ SearchResult,
19
+ GetCategoriesOutput,
20
+ GetTagsOutput,
21
+ SortField,
22
+ SortOrder,
23
+ MemoryLoadInput,
24
+ MemoryUpdateInput,
25
+ MemoryLoadOutput,
26
+ MemoryUpdateOutput,
27
+ } from '../contracts/types.js';
28
+ import { ErrorCode } from '../contracts/types.js';
29
+ import { MarkdownStorage, StorageError } from '../storage/markdown-storage.js';
30
+
31
+ /**
32
+ * Custom error for service operations
33
+ */
34
+ export class ServiceError extends Error {
35
+ constructor(
36
+ message: string,
37
+ public readonly code: ErrorCode,
38
+ public readonly data?: Record<string, unknown>
39
+ ) {
40
+ super(message);
41
+ this.name = 'ServiceError';
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Memory service configuration
47
+ */
48
+ export interface MemoryServiceConfig {
49
+ storagePath: string;
50
+ }
51
+
52
+ /**
53
+ * Memory service implementation
54
+ */
55
+ export class MemoryService {
56
+ private readonly storage: MarkdownStorage;
57
+
58
+ constructor(config: MemoryServiceConfig) {
59
+ this.storage = new MarkdownStorage({ storagePath: config.storagePath });
60
+ }
61
+
62
+ /**
63
+ * Creates a new memory entry
64
+ *
65
+ * @param input - Create memory input
66
+ * @returns Create result with ID, file path, and memory object
67
+ * @throws ServiceError if creation fails
68
+ */
69
+ async create(input: CreateMemoryInput): Promise<CreateResult> {
70
+ const now = new Date().toISOString();
71
+ const id = randomUUID();
72
+
73
+ const memory: Memory = {
74
+ id,
75
+ createdAt: now,
76
+ updatedAt: now,
77
+ tags: input.tags ?? [],
78
+ category: input.category ?? 'general',
79
+ importance: input.importance ?? 3,
80
+ title: input.title,
81
+ content: input.content,
82
+ };
83
+
84
+ try {
85
+ const filePath = await this.storage.write(memory);
86
+ return {
87
+ id,
88
+ filePath,
89
+ memory,
90
+ };
91
+ } catch (error) {
92
+ throw new ServiceError(
93
+ `Failed to create memory: ${error instanceof Error ? error.message : 'Unknown error'}`,
94
+ ErrorCode.STORAGE_ERROR
95
+ );
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Reads a memory by ID
101
+ *
102
+ * @param input - Read memory input
103
+ * @returns Memory object
104
+ * @throws ServiceError if memory not found
105
+ */
106
+ async read(input: ReadMemoryInput): Promise<{ memory: Memory }> {
107
+ try {
108
+ const memory = await this.storage.read(input.id);
109
+ return { memory };
110
+ } catch (error) {
111
+ if (error instanceof StorageError && error.message.includes('not found')) {
112
+ throw new ServiceError(`Memory not found: ${input.id}`, ErrorCode.NOT_FOUND);
113
+ }
114
+ throw new ServiceError(
115
+ `Failed to read memory: ${error instanceof Error ? error.message : 'Unknown error'}`,
116
+ ErrorCode.STORAGE_ERROR
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Updates an existing memory
123
+ *
124
+ * @param input - Update memory input
125
+ * @returns Updated memory object
126
+ * @throws ServiceError if memory not found
127
+ */
128
+ async update(input: UpdateMemoryInput): Promise<UpdateResult> {
129
+ // First read the existing memory
130
+ let existing: Memory;
131
+ try {
132
+ existing = await this.storage.read(input.id);
133
+ } catch (error) {
134
+ if (error instanceof StorageError && error.message.includes('not found')) {
135
+ throw new ServiceError(`Memory not found: ${input.id}`, ErrorCode.NOT_FOUND);
136
+ }
137
+ throw new ServiceError(
138
+ `Failed to read memory for update: ${error instanceof Error ? error.message : 'Unknown error'}`,
139
+ ErrorCode.STORAGE_ERROR
140
+ );
141
+ }
142
+
143
+ // Apply updates
144
+ const updated: Memory = {
145
+ ...existing,
146
+ updatedAt: new Date().toISOString(),
147
+ ...(input.title !== undefined && { title: input.title }),
148
+ ...(input.content !== undefined && { content: input.content }),
149
+ ...(input.tags !== undefined && { tags: input.tags }),
150
+ ...(input.category !== undefined && { category: input.category }),
151
+ ...(input.importance !== undefined && { importance: input.importance }),
152
+ };
153
+
154
+ try {
155
+ await this.storage.write(updated);
156
+ return { memory: updated };
157
+ } catch (error) {
158
+ throw new ServiceError(
159
+ `Failed to update memory: ${error instanceof Error ? error.message : 'Unknown error'}`,
160
+ ErrorCode.STORAGE_ERROR
161
+ );
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Deletes a memory by ID
167
+ *
168
+ * @param input - Delete memory input
169
+ * @returns Delete result
170
+ * @throws ServiceError if memory not found
171
+ */
172
+ async delete(input: DeleteMemoryInput): Promise<DeleteResult> {
173
+ try {
174
+ const filePath = await this.storage.delete(input.id);
175
+ return {
176
+ success: true,
177
+ filePath,
178
+ };
179
+ } catch (error) {
180
+ if (error instanceof StorageError && error.message.includes('not found')) {
181
+ throw new ServiceError(`Memory not found: ${input.id}`, ErrorCode.NOT_FOUND);
182
+ }
183
+ throw new ServiceError(
184
+ `Failed to delete memory: ${error instanceof Error ? error.message : 'Unknown error'}`,
185
+ ErrorCode.STORAGE_ERROR
186
+ );
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Lists memories with filtering and pagination
192
+ *
193
+ * @param input - List memory input
194
+ * @returns List result with memories, total count, and hasMore flag
195
+ */
196
+ async list(input: ListMemoryInput): Promise<ListResult> {
197
+ try {
198
+ const files = await this.storage.list();
199
+
200
+ // Parse all files into memories
201
+ let memories: Memory[] = [];
202
+ for (const file of files) {
203
+ try {
204
+ const memory = await this.storage.read(
205
+ this.extractIdFromContent(file.content)
206
+ );
207
+ memories.push(memory);
208
+ } catch {
209
+ // Skip invalid files
210
+ continue;
211
+ }
212
+ }
213
+
214
+ // Apply filters
215
+ if (input.category) {
216
+ memories = memories.filter(m => m.category === input.category);
217
+ }
218
+
219
+ if (input.tags && input.tags.length > 0) {
220
+ memories = memories.filter(m =>
221
+ input.tags!.every(tag => m.tags.includes(tag))
222
+ );
223
+ }
224
+
225
+ if (input.fromDate) {
226
+ memories = memories.filter(m => m.createdAt >= input.fromDate!);
227
+ }
228
+
229
+ if (input.toDate) {
230
+ memories = memories.filter(m => m.createdAt <= input.toDate!);
231
+ }
232
+
233
+ // Sort
234
+ const sortBy: SortField = input.sortBy ?? 'createdAt';
235
+ const sortOrder: SortOrder = input.sortOrder ?? 'desc';
236
+
237
+ memories.sort((a, b) => {
238
+ let comparison = 0;
239
+ switch (sortBy) {
240
+ case 'createdAt':
241
+ comparison = a.createdAt.localeCompare(b.createdAt);
242
+ break;
243
+ case 'updatedAt':
244
+ comparison = a.updatedAt.localeCompare(b.updatedAt);
245
+ break;
246
+ case 'title':
247
+ comparison = a.title.localeCompare(b.title);
248
+ break;
249
+ case 'importance':
250
+ comparison = a.importance - b.importance;
251
+ break;
252
+ }
253
+ return sortOrder === 'asc' ? comparison : -comparison;
254
+ });
255
+
256
+ // Apply pagination
257
+ const total = memories.length;
258
+ const limit = input.limit ?? 20;
259
+ const offset = input.offset ?? 0;
260
+
261
+ const paginatedMemories = memories.slice(offset, offset + limit);
262
+ const hasMore = offset + limit < total;
263
+
264
+ return {
265
+ memories: paginatedMemories,
266
+ total,
267
+ hasMore,
268
+ };
269
+ } catch (error) {
270
+ throw new ServiceError(
271
+ `Failed to list memories: ${error instanceof Error ? error.message : 'Unknown error'}`,
272
+ ErrorCode.STORAGE_ERROR
273
+ );
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Searches memories by query
279
+ *
280
+ * @param input - Search memory input
281
+ * @returns Search results with scores and matches
282
+ */
283
+ async search(input: SearchMemoryInput): Promise<{ results: SearchResult[]; total: number }> {
284
+ try {
285
+ const listResult = await this.list({
286
+ category: input.category,
287
+ tags: input.tags,
288
+ limit: 1000, // Get all for search
289
+ });
290
+
291
+ const query = input.query.toLowerCase();
292
+ const keywords = query.split(/\s+/).filter(k => k.length > 0);
293
+
294
+ const results: SearchResult[] = [];
295
+
296
+ for (const memory of listResult.memories) {
297
+ let score = 0;
298
+ const matches: string[] = [];
299
+
300
+ // Search in title (higher weight)
301
+ const titleLower = memory.title.toLowerCase();
302
+ if (titleLower.includes(query)) {
303
+ score += 10;
304
+ matches.push(memory.title);
305
+ } else {
306
+ // Check individual keywords in title
307
+ for (const keyword of keywords) {
308
+ if (titleLower.includes(keyword)) {
309
+ score += 5;
310
+ if (!matches.includes(memory.title)) {
311
+ matches.push(memory.title);
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ // Search in content
318
+ const contentLower = memory.content.toLowerCase();
319
+ if (contentLower.includes(query)) {
320
+ score += 3;
321
+ // Extract matching snippet
322
+ const index = contentLower.indexOf(query);
323
+ const start = Math.max(0, index - 50);
324
+ const end = Math.min(contentLower.length, index + query.length + 50);
325
+ const snippet = memory.content.slice(start, end);
326
+ matches.push(snippet);
327
+ } else {
328
+ // Check individual keywords in content
329
+ for (const keyword of keywords) {
330
+ if (contentLower.includes(keyword)) {
331
+ score += 1;
332
+ const index = contentLower.indexOf(keyword);
333
+ const start = Math.max(0, index - 30);
334
+ const end = Math.min(contentLower.length, index + keyword.length + 30);
335
+ const snippet = memory.content.slice(start, end);
336
+ if (!matches.some(m => m.includes(snippet))) {
337
+ matches.push(snippet);
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ // Search in tags
344
+ for (const tag of memory.tags) {
345
+ if (tag.toLowerCase().includes(query) || keywords.some(k => tag.toLowerCase().includes(k))) {
346
+ score += 2;
347
+ matches.push(`Tag: ${tag}`);
348
+ }
349
+ }
350
+
351
+ if (score > 0) {
352
+ results.push({
353
+ memory,
354
+ score: Math.min(score / 20, 1), // Normalize to 0-1
355
+ matches: matches.slice(0, 3), // Limit matches
356
+ });
357
+ }
358
+ }
359
+
360
+ // Sort by score descending
361
+ results.sort((a, b) => b.score - a.score);
362
+
363
+ // Apply limit
364
+ const limit = input.limit ?? 10;
365
+ const limitedResults = results.slice(0, limit);
366
+
367
+ return {
368
+ results: limitedResults,
369
+ total: results.length,
370
+ };
371
+ } catch (error) {
372
+ throw new ServiceError(
373
+ `Failed to search memories: ${error instanceof Error ? error.message : 'Unknown error'}`,
374
+ ErrorCode.STORAGE_ERROR
375
+ );
376
+ }
377
+ }
378
+
379
+ /**
380
+ * memory_load unified read API (STM-first)
381
+ */
382
+ async memoryLoad(input: MemoryLoadInput): Promise<MemoryLoadOutput> {
383
+ if (input.id) {
384
+ const { memory } = await this.read({ id: input.id });
385
+ return { items: [memory], total: 1 };
386
+ }
387
+
388
+ if (input.query) {
389
+ const searched = await this.search({
390
+ query: input.query,
391
+ category: input.category,
392
+ tags: input.tags,
393
+ limit: input.limit,
394
+ });
395
+ let items = searched.results.map(r => r.memory);
396
+ if (input.sessionId) {
397
+ items = items.filter(m => m.sessionId === input.sessionId);
398
+ }
399
+ return { items, total: items.length };
400
+ }
401
+
402
+ const listResult = await this.list({
403
+ category: input.category,
404
+ tags: input.tags,
405
+ limit: input.limit ?? 20,
406
+ sortBy: 'updatedAt',
407
+ sortOrder: 'desc',
408
+ });
409
+
410
+ let items = [...listResult.memories];
411
+
412
+ if (input.sessionId) {
413
+ items = items.filter(m => m.sessionId === input.sessionId);
414
+ }
415
+
416
+ if (input.date) {
417
+ const date = input.date;
418
+ items = items.filter(m => m.createdAt.startsWith(date));
419
+ }
420
+
421
+ return { items, total: items.length };
422
+ }
423
+
424
+ /**
425
+ * memory_update unified write API (append/upsert)
426
+ */
427
+ async memoryUpdate(input: MemoryUpdateInput): Promise<MemoryUpdateOutput> {
428
+ const now = new Date().toISOString();
429
+ const sessionId = input.sessionId ?? randomUUID();
430
+
431
+ if (input.id) {
432
+ const updatedResult = await this.update({
433
+ id: input.id,
434
+ title: input.title,
435
+ content: input.content,
436
+ tags: input.tags,
437
+ category: input.category,
438
+ importance: input.importance,
439
+ });
440
+
441
+ const updatedMemory: Memory = {
442
+ ...updatedResult.memory,
443
+ sessionId,
444
+ entryType: input.entryType,
445
+ };
446
+
447
+ const filePath = await this.storage.write(updatedMemory);
448
+ return {
449
+ id: updatedMemory.id,
450
+ sessionId,
451
+ filePath,
452
+ created: false,
453
+ updated: true,
454
+ memory: updatedMemory,
455
+ };
456
+ }
457
+
458
+ const id = randomUUID();
459
+ const createdMemory: Memory = {
460
+ id,
461
+ createdAt: now,
462
+ updatedAt: now,
463
+ sessionId,
464
+ entryType: input.entryType,
465
+ tags: input.tags ?? [],
466
+ category: input.category ?? 'general',
467
+ importance: input.importance ?? 3,
468
+ title: input.title ?? 'memory note',
469
+ content: input.content,
470
+ };
471
+
472
+ const filePath = await this.storage.write(createdMemory);
473
+ return {
474
+ id,
475
+ sessionId,
476
+ filePath,
477
+ created: true,
478
+ updated: false,
479
+ memory: createdMemory,
480
+ };
481
+ }
482
+
483
+ /**
484
+ * Gets all unique categories
485
+ *
486
+ * @returns Array of category names
487
+ */
488
+ async getCategories(): Promise<GetCategoriesOutput> {
489
+ try {
490
+ const listResult = await this.list({ limit: 1000 });
491
+ const categories = new Set<string>();
492
+
493
+ for (const memory of listResult.memories) {
494
+ categories.add(memory.category);
495
+ }
496
+
497
+ return {
498
+ categories: Array.from(categories).sort(),
499
+ };
500
+ } catch (error) {
501
+ throw new ServiceError(
502
+ `Failed to get categories: ${error instanceof Error ? error.message : 'Unknown error'}`,
503
+ ErrorCode.STORAGE_ERROR
504
+ );
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Gets all unique tags
510
+ *
511
+ * @returns Array of tag names
512
+ */
513
+ async getTags(): Promise<GetTagsOutput> {
514
+ try {
515
+ const listResult = await this.list({ limit: 1000 });
516
+ const tags = new Set<string>();
517
+
518
+ for (const memory of listResult.memories) {
519
+ for (const tag of memory.tags) {
520
+ tags.add(tag);
521
+ }
522
+ }
523
+
524
+ return {
525
+ tags: Array.from(tags).sort(),
526
+ };
527
+ } catch (error) {
528
+ throw new ServiceError(
529
+ `Failed to get tags: ${error instanceof Error ? error.message : 'Unknown error'}`,
530
+ ErrorCode.STORAGE_ERROR
531
+ );
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Extracts ID from file content
537
+ *
538
+ * @param content - File content
539
+ * @returns ID string
540
+ */
541
+ private extractIdFromContent(content: string): string {
542
+ const match = content.match(/id:\s*"?([^"\n]+)"?/);
543
+ if (!match) {
544
+ throw new Error('Could not extract ID from content');
545
+ }
546
+ return match[1].trim();
547
+ }
548
+ }