@synth-coder/memhub 0.2.2 → 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 (116) hide show
  1. package/.factory/commands/opsx-apply.md +150 -0
  2. package/.factory/commands/opsx-archive.md +155 -0
  3. package/.factory/commands/opsx-explore.md +171 -0
  4. package/.factory/commands/opsx-propose.md +104 -0
  5. package/.factory/skills/openspec-apply-change/SKILL.md +156 -0
  6. package/.factory/skills/openspec-archive-change/SKILL.md +114 -0
  7. package/.factory/skills/openspec-explore/SKILL.md +288 -0
  8. package/.factory/skills/openspec-propose/SKILL.md +110 -0
  9. package/.github/workflows/ci.yml +48 -12
  10. package/.github/workflows/release.yml +67 -0
  11. package/AGENTS.md +158 -17
  12. package/README.md +147 -66
  13. package/README.zh-CN.md +75 -23
  14. package/dist/src/cli/agents/claude-code.d.ts +5 -0
  15. package/dist/src/cli/agents/claude-code.d.ts.map +1 -0
  16. package/dist/src/cli/agents/claude-code.js +14 -0
  17. package/dist/src/cli/agents/claude-code.js.map +1 -0
  18. package/dist/src/cli/agents/cline.d.ts +5 -0
  19. package/dist/src/cli/agents/cline.d.ts.map +1 -0
  20. package/dist/src/cli/agents/cline.js +14 -0
  21. package/dist/src/cli/agents/cline.js.map +1 -0
  22. package/dist/src/cli/agents/codex.d.ts +5 -0
  23. package/dist/src/cli/agents/codex.d.ts.map +1 -0
  24. package/dist/src/cli/agents/codex.js +14 -0
  25. package/dist/src/cli/agents/codex.js.map +1 -0
  26. package/dist/src/cli/agents/cursor.d.ts +5 -0
  27. package/dist/src/cli/agents/cursor.d.ts.map +1 -0
  28. package/dist/src/cli/agents/cursor.js +14 -0
  29. package/dist/src/cli/agents/cursor.js.map +1 -0
  30. package/dist/src/cli/agents/factory-droid.d.ts +5 -0
  31. package/dist/src/cli/agents/factory-droid.d.ts.map +1 -0
  32. package/dist/src/cli/agents/factory-droid.js +14 -0
  33. package/dist/src/cli/agents/factory-droid.js.map +1 -0
  34. package/dist/src/cli/agents/gemini-cli.d.ts +5 -0
  35. package/dist/src/cli/agents/gemini-cli.d.ts.map +1 -0
  36. package/dist/src/cli/agents/gemini-cli.js +14 -0
  37. package/dist/src/cli/agents/gemini-cli.js.map +1 -0
  38. package/dist/src/cli/agents/index.d.ts +14 -0
  39. package/dist/src/cli/agents/index.d.ts.map +1 -0
  40. package/dist/src/cli/agents/index.js +30 -0
  41. package/dist/src/cli/agents/index.js.map +1 -0
  42. package/dist/src/cli/agents/windsurf.d.ts +5 -0
  43. package/dist/src/cli/agents/windsurf.d.ts.map +1 -0
  44. package/dist/src/cli/agents/windsurf.js +14 -0
  45. package/dist/src/cli/agents/windsurf.js.map +1 -0
  46. package/dist/src/cli/index.d.ts +8 -0
  47. package/dist/src/cli/index.d.ts.map +1 -0
  48. package/dist/src/cli/index.js +168 -0
  49. package/dist/src/cli/index.js.map +1 -0
  50. package/dist/src/cli/init.d.ts +34 -0
  51. package/dist/src/cli/init.d.ts.map +1 -0
  52. package/dist/src/cli/init.js +160 -0
  53. package/dist/src/cli/init.js.map +1 -0
  54. package/dist/src/cli/instructions.d.ts +29 -0
  55. package/dist/src/cli/instructions.d.ts.map +1 -0
  56. package/dist/src/cli/instructions.js +141 -0
  57. package/dist/src/cli/instructions.js.map +1 -0
  58. package/dist/src/cli/types.d.ts +22 -0
  59. package/dist/src/cli/types.d.ts.map +1 -0
  60. package/dist/src/cli/types.js +86 -0
  61. package/dist/src/cli/types.js.map +1 -0
  62. package/dist/src/contracts/schemas.js.map +1 -1
  63. package/dist/src/server/mcp-server.d.ts +8 -0
  64. package/dist/src/server/mcp-server.d.ts.map +1 -1
  65. package/dist/src/server/mcp-server.js +30 -16
  66. package/dist/src/server/mcp-server.js.map +1 -1
  67. package/dist/src/services/embedding-service.d.ts.map +1 -1
  68. package/dist/src/services/embedding-service.js +1 -1
  69. package/dist/src/services/embedding-service.js.map +1 -1
  70. package/dist/src/services/memory-service.d.ts +1 -0
  71. package/dist/src/services/memory-service.d.ts.map +1 -1
  72. package/dist/src/services/memory-service.js +125 -82
  73. package/dist/src/services/memory-service.js.map +1 -1
  74. package/dist/src/storage/markdown-storage.d.ts.map +1 -1
  75. package/dist/src/storage/markdown-storage.js +1 -1
  76. package/dist/src/storage/markdown-storage.js.map +1 -1
  77. package/dist/src/storage/vector-index.d.ts.map +1 -1
  78. package/dist/src/storage/vector-index.js +4 -5
  79. package/dist/src/storage/vector-index.js.map +1 -1
  80. package/docs/README.md +21 -0
  81. package/docs/mcp-tools.md +136 -0
  82. package/docs/user-guide.md +182 -0
  83. package/package.json +22 -19
  84. package/src/cli/agents/claude-code.ts +14 -0
  85. package/src/cli/agents/cline.ts +14 -0
  86. package/src/cli/agents/codex.ts +14 -0
  87. package/src/cli/agents/cursor.ts +14 -0
  88. package/src/cli/agents/factory-droid.ts +14 -0
  89. package/src/cli/agents/gemini-cli.ts +14 -0
  90. package/src/cli/agents/index.ts +36 -0
  91. package/src/cli/agents/windsurf.ts +14 -0
  92. package/src/cli/index.ts +192 -0
  93. package/src/cli/init.ts +218 -0
  94. package/src/cli/instructions.ts +156 -0
  95. package/src/cli/types.ts +112 -0
  96. package/src/contracts/mcp.ts +1 -1
  97. package/src/contracts/schemas.ts +4 -4
  98. package/src/contracts/types.ts +4 -4
  99. package/src/server/mcp-server.ts +36 -29
  100. package/src/services/embedding-service.ts +80 -80
  101. package/src/services/memory-service.ts +142 -107
  102. package/src/storage/markdown-storage.ts +1 -9
  103. package/src/storage/vector-index.ts +117 -118
  104. package/test/cli/init.test.ts +380 -0
  105. package/test/server/mcp-server.test.ts +45 -3
  106. package/test/services/memory-service.test.ts +16 -4
  107. package/test/storage/frontmatter-parser.test.ts +1 -1
  108. package/test/storage/markdown-storage.test.ts +19 -10
  109. package/test/storage/vector-index.test.ts +129 -133
  110. package/test/utils/slugify.test.ts +5 -1
  111. package/docs/architecture.md +0 -349
  112. package/docs/contracts.md +0 -119
  113. package/docs/prompt-template.md +0 -79
  114. package/docs/proposals/mcp-typescript-sdk-refactor.md +0 -568
  115. package/docs/proposals/proposal-close-gates.md +0 -58
  116. package/docs/tool-calling-policy.md +0 -107
@@ -25,10 +25,10 @@ export type Slug = string;
25
25
  * Content is split between YAML Front Matter (metadata) and Markdown body
26
26
  */
27
27
  export type MemoryEntryType =
28
- | 'preference' // User likes/dislikes
29
- | 'decision' // Technical choices with reasoning
30
- | 'context' // Project/environment information
31
- | 'fact'; // Objective knowledge
28
+ | 'preference' // User likes/dislikes
29
+ | 'decision' // Technical choices with reasoning
30
+ | 'context' // Project/environment information
31
+ | 'fact'; // Objective knowledge
32
32
 
33
33
  export interface Memory {
34
34
  /** UUID v4 unique identifier */
@@ -5,19 +5,14 @@
5
5
  */
6
6
 
7
7
  import { readFileSync, existsSync } from 'fs';
8
- import { join, dirname } from 'path';
8
+ import { join, dirname, resolve } from 'path';
9
9
  import { fileURLToPath } from 'url';
10
+ import { homedir } from 'os';
10
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
12
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
- import {
13
- CallToolRequestSchema,
14
- ListToolsRequestSchema,
15
- } from '@modelcontextprotocol/sdk/types.js';
13
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
16
14
  import { MemoryService, ServiceError } from '../services/memory-service.js';
17
- import {
18
- MemoryLoadInputSchema,
19
- MemoryUpdateInputV2Schema,
20
- } from '../contracts/schemas.js';
15
+ import { MemoryLoadInputSchema, MemoryUpdateInputV2Schema } from '../contracts/schemas.js';
21
16
  import { TOOL_DEFINITIONS, SERVER_INFO } from '../contracts/mcp.js';
22
17
  import { ErrorCode } from '../contracts/types.js';
23
18
 
@@ -38,11 +33,34 @@ if (!existsSync(packageJsonPath)) {
38
33
 
39
34
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as PackageJson;
40
35
 
36
+ /**
37
+ * Resolve storage path with the following priority:
38
+ * 1. MEMHUB_STORAGE_PATH env var (if set):
39
+ * - Absolute path: use as-is
40
+ * - Relative path starting with '.': resolve from current working directory
41
+ * 2. Default: ~/.memhub (user home directory)
42
+ */
43
+ export function resolveStoragePath(): string {
44
+ const envPath = process.env.MEMHUB_STORAGE_PATH;
45
+
46
+ if (envPath) {
47
+ // If it's an absolute path, use as-is
48
+ if (envPath.startsWith('/') || envPath.match(/^[A-Z]:\\/i)) {
49
+ return envPath;
50
+ }
51
+ // Relative path: resolve from current working directory
52
+ return resolve(process.cwd(), envPath);
53
+ }
54
+
55
+ // Default: ~/.memhub
56
+ return join(homedir(), '.memhub');
57
+ }
58
+
41
59
  /**
42
60
  * Create McpServer instance using SDK
43
61
  */
44
62
  export function createMcpServer(): Server {
45
- const storagePath = process.env.MEMHUB_STORAGE_PATH || './memories';
63
+ const storagePath = resolveStoragePath();
46
64
  const vectorSearch = process.env.MEMHUB_VECTOR_SEARCH !== 'false';
47
65
  const memoryService = new MemoryService({ storagePath, vectorSearch });
48
66
 
@@ -66,7 +84,7 @@ export function createMcpServer(): Server {
66
84
  });
67
85
 
68
86
  // Handle tools/call request
69
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
87
+ server.setRequestHandler(CallToolRequestSchema, async request => {
70
88
  const { name, arguments: args } = request.params;
71
89
 
72
90
  try {
@@ -86,10 +104,7 @@ export function createMcpServer(): Server {
86
104
  }
87
105
 
88
106
  default:
89
- throw new ServiceError(
90
- `Unknown tool: ${name}`,
91
- ErrorCode.METHOD_NOT_FOUND
92
- );
107
+ throw new ServiceError(`Unknown tool: ${name}`, ErrorCode.METHOD_NOT_FOUND);
93
108
  }
94
109
 
95
110
  return {
@@ -134,7 +149,7 @@ export function createMcpServer(): Server {
134
149
  }
135
150
 
136
151
  /**
137
- * Start the MCP server
152
+ * Start the MCP server (only when run directly)
138
153
  */
139
154
  async function main(): Promise<void> {
140
155
  const server = createMcpServer();
@@ -144,19 +159,11 @@ async function main(): Promise<void> {
144
159
  console.error('MemHub MCP Server running on stdio');
145
160
  }
146
161
 
147
- main().catch((error) => {
148
- console.error('Fatal error:', error);
149
- process.exit(1);
150
- });
151
-
152
- // Check if this file is being run directly
162
+ // Only run main() when this file is executed directly
153
163
  const isMain = import.meta.url === `file://${process.argv[1]}` || false;
154
164
  if (isMain) {
155
- // Defer main() execution to avoid blocking module loading
156
- setImmediate(() => {
157
- main().catch((error) => {
158
- console.error('Fatal error:', error);
159
- process.exit(1);
160
- });
165
+ main().catch(error => {
166
+ console.error('Fatal error:', error);
167
+ process.exit(1);
161
168
  });
162
- }
169
+ }
@@ -14,20 +14,20 @@ const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2';
14
14
  export const VECTOR_DIM = 384;
15
15
 
16
16
  type FeatureExtractionPipeline = (
17
- text: string,
18
- options: { pooling: string; normalize: boolean }
17
+ text: string,
18
+ options: { pooling: string; normalize: boolean }
19
19
  ) => Promise<{ data: Float32Array }>;
20
20
 
21
21
  type TransformersModule = {
22
- pipeline: (
23
- task: string,
24
- model: string,
25
- options?: { progress_callback?: null }
26
- ) => Promise<unknown>;
27
- env: {
28
- allowRemoteModels: boolean;
29
- allowLocalModels: boolean;
30
- };
22
+ pipeline: (
23
+ task: string,
24
+ model: string,
25
+ options?: { progress_callback?: null }
26
+ ) => Promise<unknown>;
27
+ env: {
28
+ allowRemoteModels: boolean;
29
+ allowLocalModels: boolean;
30
+ };
31
31
  };
32
32
 
33
33
  /**
@@ -35,80 +35,80 @@ type TransformersModule = {
35
35
  * The model is downloaded once and cached in `~/.cache/huggingface`.
36
36
  */
37
37
  export class EmbeddingService {
38
- private static instance: EmbeddingService | null = null;
39
- private extractor: FeatureExtractionPipeline | null = null;
40
- private initPromise: Promise<void> | null = null;
41
- private transformers: TransformersModule | null = null;
42
-
43
- private constructor() {
44
- // Constructor is empty - initialization happens in initialize()
38
+ private static instance: EmbeddingService | null = null;
39
+ private extractor: FeatureExtractionPipeline | null = null;
40
+ private initPromise: Promise<void> | null = null;
41
+ private transformers: TransformersModule | null = null;
42
+
43
+ private constructor() {
44
+ // Constructor is empty - initialization happens in initialize()
45
+ }
46
+
47
+ static getInstance(): EmbeddingService {
48
+ if (!EmbeddingService.instance) {
49
+ EmbeddingService.instance = new EmbeddingService();
45
50
  }
46
-
47
- static getInstance(): EmbeddingService {
48
- if (!EmbeddingService.instance) {
49
- EmbeddingService.instance = new EmbeddingService();
50
- }
51
- return EmbeddingService.instance;
51
+ return EmbeddingService.instance;
52
+ }
53
+
54
+ /**
55
+ * Initializes the pipeline (idempotent, safe to call multiple times).
56
+ */
57
+ async initialize(): Promise<void> {
58
+ if (this.extractor) return;
59
+
60
+ if (!this.initPromise) {
61
+ this.initPromise = (async () => {
62
+ // Dynamic import to avoid loading sharp during tests
63
+ this.transformers = (await import('@xenova/transformers')) as TransformersModule;
64
+
65
+ // Configure environment
66
+ this.transformers.env.allowRemoteModels = true;
67
+ this.transformers.env.allowLocalModels = true;
68
+
69
+ this.extractor = (await this.transformers.pipeline(
70
+ 'feature-extraction',
71
+ MODEL_NAME
72
+ )) as FeatureExtractionPipeline;
73
+ })();
52
74
  }
53
75
 
54
- /**
55
- * Initializes the pipeline (idempotent, safe to call multiple times).
56
- */
57
- async initialize(): Promise<void> {
58
- if (this.extractor) return;
59
-
60
- if (!this.initPromise) {
61
- this.initPromise = (async () => {
62
- // Dynamic import to avoid loading sharp during tests
63
- this.transformers = await import('@xenova/transformers') as TransformersModule;
64
-
65
- // Configure environment
66
- this.transformers.env.allowRemoteModels = true;
67
- this.transformers.env.allowLocalModels = true;
68
-
69
- this.extractor = (await this.transformers.pipeline(
70
- 'feature-extraction',
71
- MODEL_NAME
72
- )) as FeatureExtractionPipeline;
73
- })();
74
- }
75
-
76
- await this.initPromise;
77
- }
76
+ await this.initPromise;
77
+ }
78
78
 
79
- /**
80
- * Embeds `text` into a 384-dimension float vector.
81
- *
82
- * @param text - The text to embed (title + content recommended)
83
- * @returns Normalised float vector of length VECTOR_DIM
84
- */
85
- async embed(text: string): Promise<number[]> {
86
- await this.initialize();
87
-
88
- if (!this.extractor) {
89
- throw new Error('EmbeddingService: extractor not initialized');
90
- }
91
-
92
- const output = await this.extractor(text, {
93
- pooling: 'mean',
94
- normalize: true,
95
- });
96
-
97
- return Array.from(output.data);
98
- }
79
+ /**
80
+ * Embeds `text` into a 384-dimension float vector.
81
+ *
82
+ * @param text - The text to embed (title + content recommended)
83
+ * @returns Normalised float vector of length VECTOR_DIM
84
+ */
85
+ async embed(text: string): Promise<number[]> {
86
+ await this.initialize();
99
87
 
100
- /**
101
- * Convenience: embed a memory's title and content together.
102
- */
103
- async embedMemory(title: string, content: string): Promise<number[]> {
104
- return this.embed(`${title} ${content}`.trim());
88
+ if (!this.extractor) {
89
+ throw new Error('EmbeddingService: extractor not initialized');
105
90
  }
106
91
 
107
- /**
108
- * Reset the singleton instance.
109
- * @internal For testing purposes only. Do not use in production code.
110
- */
111
- static _reset(): void {
112
- EmbeddingService.instance = null;
113
- }
92
+ const output = await this.extractor(text, {
93
+ pooling: 'mean',
94
+ normalize: true,
95
+ });
96
+
97
+ return Array.from(output.data);
98
+ }
99
+
100
+ /**
101
+ * Convenience: embed a memory's title and content together.
102
+ */
103
+ async embedMemory(title: string, content: string): Promise<number[]> {
104
+ return this.embed(`${title} ${content}`.trim());
105
+ }
106
+
107
+ /**
108
+ * Reset the singleton instance.
109
+ * @internal For testing purposes only. Do not use in production code.
110
+ */
111
+ static _reset(): void {
112
+ EmbeddingService.instance = null;
113
+ }
114
114
  }
@@ -23,6 +23,9 @@ import type {
23
23
  } from '../contracts/types.js';
24
24
  import { ErrorCode } from '../contracts/types.js';
25
25
  import { MarkdownStorage, StorageError } from '../storage/markdown-storage.js';
26
+ import lockfile from 'proper-lockfile';
27
+
28
+ const LOCK_TIMEOUT = 5000; // 5 seconds
26
29
 
27
30
  /** Minimal interface required from VectorIndex (avoids static import of native module) */
28
31
  interface IVectorIndex {
@@ -68,12 +71,14 @@ export interface MemoryServiceConfig {
68
71
  * Memory service implementation
69
72
  */
70
73
  export class MemoryService {
74
+ private readonly storagePath: string;
71
75
  private readonly storage: MarkdownStorage;
72
76
  private readonly vectorIndex: IVectorIndex | null;
73
77
  private readonly embedding: IEmbeddingService | null;
74
78
  private readonly vectorSearchEnabled: boolean;
75
79
 
76
80
  constructor(config: MemoryServiceConfig) {
81
+ this.storagePath = config.storagePath;
77
82
  this.storage = new MarkdownStorage({ storagePath: config.storagePath });
78
83
  this.vectorSearchEnabled = config.vectorSearch !== false;
79
84
 
@@ -101,7 +106,7 @@ export class MemoryService {
101
106
  await initPromise;
102
107
  return resolvedVectorIndex!.upsert(memory, vector);
103
108
  },
104
- delete: async (id) => {
109
+ delete: async id => {
105
110
  await initPromise;
106
111
  return resolvedVectorIndex!.delete(id);
107
112
  },
@@ -116,7 +121,7 @@ export class MemoryService {
116
121
  await initPromise;
117
122
  return resolvedEmbedding!.embedMemory(title, content);
118
123
  },
119
- embed: async (text) => {
124
+ embed: async text => {
120
125
  await initPromise;
121
126
  return resolvedEmbedding!.embed(text);
122
127
  },
@@ -172,29 +177,36 @@ export class MemoryService {
172
177
  * Creates a new memory entry
173
178
  */
174
179
  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
-
180
+ const release = await lockfile.lock(this.storagePath, {
181
+ retries: { retries: 100, minTimeout: 50, maxTimeout: LOCK_TIMEOUT / 100 },
182
+ });
189
183
  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
- );
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();
198
210
  }
199
211
  }
200
212
 
@@ -220,38 +232,45 @@ export class MemoryService {
220
232
  * Updates an existing memory
221
233
  */
222
234
  async update(input: UpdateMemoryInput): Promise<UpdateResult> {
223
- let existing: Memory;
235
+ const release = await lockfile.lock(this.storagePath, {
236
+ retries: { retries: 100, minTimeout: 50, maxTimeout: LOCK_TIMEOUT / 100 },
237
+ });
224
238
  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);
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
+ );
229
250
  }
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
251
 
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
- };
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
+ };
245
261
 
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
- );
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();
255
274
  }
256
275
  }
257
276
 
@@ -259,6 +278,9 @@ export class MemoryService {
259
278
  * Deletes a memory by ID
260
279
  */
261
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
+ });
262
284
  try {
263
285
  const filePath = await this.storage.delete(input.id);
264
286
  await this.removeFromVectorIndex(input.id);
@@ -271,6 +293,8 @@ export class MemoryService {
271
293
  `Failed to delete memory: ${error instanceof Error ? error.message : 'Unknown error'}`,
272
294
  ErrorCode.STORAGE_ERROR
273
295
  );
296
+ } finally {
297
+ await release();
274
298
  }
275
299
  }
276
300
 
@@ -288,9 +312,7 @@ export class MemoryService {
288
312
  let memories: Memory[] = [];
289
313
  for (const file of files) {
290
314
  try {
291
- const memory = await this.storage.read(
292
- this.extractIdFromContent(file.content)
293
- );
315
+ const memory = await this.storage.read(this.extractIdFromContent(file.content));
294
316
  memories.push(memory);
295
317
  } catch {
296
318
  continue;
@@ -301,9 +323,7 @@ export class MemoryService {
301
323
  memories = memories.filter(m => m.category === input.category);
302
324
  }
303
325
  if (input.tags && input.tags.length > 0) {
304
- memories = memories.filter(m =>
305
- input.tags!.every(tag => m.tags.includes(tag))
306
- );
326
+ memories = memories.filter(m => input.tags!.every(tag => m.tags.includes(tag)));
307
327
  }
308
328
  if (input.fromDate) {
309
329
  memories = memories.filter(m => m.createdAt >= input.fromDate!);
@@ -361,10 +381,7 @@ export class MemoryService {
361
381
  if (this.vectorSearchEnabled && this.vectorIndex && this.embedding) {
362
382
  try {
363
383
  const queryVec = await this.embedding.embed(input.query);
364
- const vectorResults = await this.vectorIndex.search(
365
- queryVec,
366
- input.limit ?? 10
367
- );
384
+ const vectorResults = await this.vectorIndex.search(queryVec, input.limit ?? 10);
368
385
 
369
386
  const results: SearchResult[] = [];
370
387
  for (const vr of vectorResults) {
@@ -514,63 +531,81 @@ export class MemoryService {
514
531
  * memory_update — unified write API (append/upsert)
515
532
  */
516
533
  async memoryUpdate(input: MemoryUpdateInput): Promise<MemoryUpdateOutput> {
517
- const now = new Date().toISOString();
518
- const sessionId = input.sessionId ?? randomUUID();
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();
519
540
 
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
- });
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
+ }
529
580
 
530
- const updatedMemory: Memory = {
531
- ...updatedResult.memory,
581
+ const id = randomUUID();
582
+ const createdMemory: Memory = {
583
+ id,
584
+ createdAt: now,
585
+ updatedAt: now,
532
586
  sessionId,
533
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,
534
593
  };
535
594
 
536
- const filePath = await this.storage.write(updatedMemory);
537
- this.scheduleVectorUpsert(updatedMemory);
595
+ const filePath = await this.storage.write(createdMemory);
596
+ this.scheduleVectorUpsert(createdMemory);
538
597
 
539
598
  return {
540
- id: updatedMemory.id,
599
+ id,
541
600
  sessionId,
542
601
  filePath,
543
- created: false,
544
- updated: true,
545
- memory: updatedMemory,
602
+ created: true,
603
+ updated: false,
604
+ memory: createdMemory,
546
605
  };
606
+ } finally {
607
+ await release();
547
608
  }
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
609
  }
575
610
 
576
611
  // ---------------------------------------------------------------------------
@@ -2,15 +2,7 @@
2
2
  * Markdown Storage - Handles file system operations for memory storage
3
3
  */
4
4
 
5
- import {
6
- readFile,
7
- writeFile,
8
- unlink,
9
- readdir,
10
- stat,
11
- access,
12
- mkdir,
13
- } from 'fs/promises';
5
+ import { readFile, writeFile, unlink, readdir, stat, access, mkdir } from 'fs/promises';
14
6
  import { join, extname } from 'path';
15
7
  import { constants } from 'fs';
16
8
  import type { Memory, MemoryFile } from '../contracts/types.js';