memorix 1.9.4 → 1.9.5

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 (60) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +6 -5
  3. package/README.zh-CN.md +6 -5
  4. package/dist/cli/index.js +890 -325
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.js +251 -104
  7. package/dist/maintenance-runner.js +144 -113
  8. package/dist/media-audio-runner.js +57 -26
  9. package/dist/media-video-runner.js +57 -26
  10. package/dist/memcode-runtime/CHANGELOG.md +33 -1
  11. package/dist/sdk.d.ts +1 -0
  12. package/dist/sdk.js +1012 -842
  13. package/dist/types.d.ts +1 -1
  14. package/dist/vector-backfill-runner.js +57 -26
  15. package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
  16. package/docs/API_REFERENCE.md +3 -1
  17. package/docs/CONFIGURATION.md +15 -1
  18. package/docs/DOCKER.md +23 -3
  19. package/docs/INTEGRATIONS.md +1 -1
  20. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +26 -1
  21. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
  22. package/docs/SETUP.md +55 -0
  23. package/package.json +2 -2
  24. package/plugins/antigravity/memorix/plugin.json +1 -1
  25. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  26. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  27. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  28. package/plugins/copilot/memorix/plugin.json +1 -1
  29. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  30. package/plugins/hermes/memorix/plugin.yaml +1 -1
  31. package/plugins/omp/memorix/package.json +1 -1
  32. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  33. package/plugins/pi/memorix/package.json +1 -1
  34. package/server.json +2 -2
  35. package/src/cli/command-guide.ts +2 -2
  36. package/src/cli/commands/agent-integrations.ts +57 -0
  37. package/src/cli/commands/background.ts +11 -2
  38. package/src/cli/commands/serve-http.ts +193 -42
  39. package/src/cli/commands/setup.ts +77 -10
  40. package/src/cli/commands/sync-store.ts +47 -12
  41. package/src/cli/commands/sync.ts +26 -0
  42. package/src/dashboard/server.ts +10 -12
  43. package/src/integrations/registry.ts +1 -1
  44. package/src/memory/graph.ts +10 -8
  45. package/src/memory/session.ts +1 -1
  46. package/src/sdk.ts +24 -0
  47. package/src/server/mcp-binding-store.ts +14 -3
  48. package/src/server.ts +2 -2
  49. package/src/store/file-lock.ts +28 -3
  50. package/src/store/graph-store.ts +178 -46
  51. package/src/store/persistence.ts +14 -9
  52. package/src/store/sqlite-db.ts +6 -3
  53. package/src/store/sqlite-store.ts +32 -27
  54. package/src/sync/engine.ts +9 -7
  55. package/src/sync/namespace.ts +23 -0
  56. package/src/sync/policy.ts +19 -4
  57. package/src/sync/store-port.ts +43 -13
  58. package/src/sync/types.ts +2 -0
  59. package/src/types.ts +1 -1
  60. package/src/workspace/mcp-adapters/grok.ts +32 -0
@@ -20,6 +20,22 @@ const RETRY_INTERVAL_MS = 50;
20
20
  const MAX_RETRIES = 60;
21
21
  const atomicWriteTails = new Map<string, Promise<void>>();
22
22
 
23
+ function canReclaimStaleLock(raw: string): boolean {
24
+ try {
25
+ const parsed = JSON.parse(raw) as { pid?: unknown };
26
+ const pid = Number(parsed.pid);
27
+ if (!Number.isSafeInteger(pid) || pid < 1) return true;
28
+ try {
29
+ process.kill(pid, 0);
30
+ return false;
31
+ } catch (error) {
32
+ return (error as NodeJS.ErrnoException).code !== 'EPERM';
33
+ }
34
+ } catch {
35
+ return true;
36
+ }
37
+ }
38
+
23
39
  /**
24
40
  * Acquire a lock file atomically.
25
41
  * Uses O_WRONLY | O_CREAT | O_EXCL — fails if file already exists.
@@ -39,8 +55,11 @@ export async function acquireLock(lockPath: string): Promise<void> {
39
55
  try {
40
56
  const stat = await fs.stat(lockPath);
41
57
  if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
42
- await fs.unlink(lockPath).catch(() => {});
43
- continue;
58
+ const raw = await fs.readFile(lockPath, 'utf8').catch(() => '');
59
+ if (canReclaimStaleLock(raw)) {
60
+ await fs.unlink(lockPath).catch(() => {});
61
+ continue;
62
+ }
44
63
  }
45
64
  } catch {
46
65
  continue; // Lock disappeared — retry immediately
@@ -51,7 +70,13 @@ export async function acquireLock(lockPath: string): Promise<void> {
51
70
  }
52
71
  }
53
72
  }
54
- // Last resort: force-remove stale lock and try once more
73
+ // Last resort: reclaim only a stale lock whose recorded owner is no longer
74
+ // alive. Never delete a long-running live writer's lock just because the
75
+ // operation exceeded the retry window.
76
+ const raw = await fs.readFile(lockPath, 'utf8').catch(() => '');
77
+ if (!canReclaimStaleLock(raw)) {
78
+ throw new Error(`Failed to acquire lock: ${lockPath} (owner is still running)`);
79
+ }
55
80
  await fs.unlink(lockPath).catch(() => {});
56
81
  try {
57
82
  const fd = await fs.open(lockPath, 'wx');
@@ -11,8 +11,12 @@ import { loadGraphJsonl } from './persistence.js';
11
11
  import path from 'node:path';
12
12
  import fs from 'node:fs';
13
13
 
14
+ export const DEFAULT_GRAPH_PROJECT_ID = '__default__';
15
+ const LEGACY_GRAPH_PROJECT_ID = '__legacy__';
16
+ const MAX_GRAPH_STORE_CACHE = 32;
17
+
14
18
  export interface GraphStore {
15
- init(dataDir: string): Promise<void>;
19
+ init(dataDir: string, projectId?: string): Promise<void>;
16
20
  loadEntities(): Entity[];
17
21
  loadRelations(): Relation[];
18
22
  insertEntities(entities: Entity[]): void;
@@ -46,9 +50,61 @@ function entityToRow(entity: Entity): Record<string, unknown> {
46
50
  };
47
51
  }
48
52
 
53
+ const CREATE_SCOPED_GRAPH_ENTITIES = `
54
+ CREATE TABLE IF NOT EXISTS graph_entities (
55
+ projectId TEXT NOT NULL,
56
+ name TEXT NOT NULL,
57
+ entityType TEXT NOT NULL DEFAULT '',
58
+ observations TEXT NOT NULL DEFAULT '[]',
59
+ PRIMARY KEY (projectId, name)
60
+ );`;
61
+
62
+ const CREATE_SCOPED_GRAPH_RELATIONS = `
63
+ CREATE TABLE IF NOT EXISTS graph_relations (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ projectId TEXT NOT NULL,
66
+ from_entity TEXT NOT NULL,
67
+ to_entity TEXT NOT NULL,
68
+ relationType TEXT NOT NULL DEFAULT '',
69
+ UNIQUE(projectId, from_entity, to_entity, relationType)
70
+ );`;
71
+
72
+ function ensureScopedGraphSchema(db: any): void {
73
+ const entityColumns = db.prepare('PRAGMA table_info(graph_entities)').all() as Array<{ name: string }>;
74
+ if (!entityColumns.some((column) => column.name === 'projectId')) {
75
+ const migrate = db.transaction(() => {
76
+ db.exec('ALTER TABLE graph_entities RENAME TO graph_entities_legacy_unscoped');
77
+ db.exec(CREATE_SCOPED_GRAPH_ENTITIES);
78
+ db.exec(`INSERT INTO graph_entities (projectId, name, entityType, observations)
79
+ SELECT '${LEGACY_GRAPH_PROJECT_ID}', name, entityType, observations
80
+ FROM graph_entities_legacy_unscoped`);
81
+ db.exec('DROP TABLE graph_entities_legacy_unscoped');
82
+ });
83
+ migrate();
84
+ } else {
85
+ db.exec(CREATE_SCOPED_GRAPH_ENTITIES);
86
+ }
87
+
88
+ const relationColumns = db.prepare('PRAGMA table_info(graph_relations)').all() as Array<{ name: string }>;
89
+ if (!relationColumns.some((column) => column.name === 'projectId')) {
90
+ const migrate = db.transaction(() => {
91
+ db.exec('ALTER TABLE graph_relations RENAME TO graph_relations_legacy_unscoped');
92
+ db.exec(CREATE_SCOPED_GRAPH_RELATIONS);
93
+ db.exec(`INSERT INTO graph_relations (projectId, from_entity, to_entity, relationType)
94
+ SELECT '${LEGACY_GRAPH_PROJECT_ID}', from_entity, to_entity, relationType
95
+ FROM graph_relations_legacy_unscoped`);
96
+ db.exec('DROP TABLE graph_relations_legacy_unscoped');
97
+ });
98
+ migrate();
99
+ } else {
100
+ db.exec(CREATE_SCOPED_GRAPH_RELATIONS);
101
+ }
102
+ }
103
+
49
104
  export class GraphSqliteStore implements GraphStore {
50
105
  private db: any = null;
51
106
  private dataDir: string = '';
107
+ private projectId = DEFAULT_GRAPH_PROJECT_ID;
52
108
 
53
109
  private stmtInsertEntity: any = null;
54
110
  private stmtUpdateEntityObs: any = null;
@@ -61,40 +117,44 @@ export class GraphSqliteStore implements GraphStore {
61
117
  private stmtDeleteRelationsByEntity: any = null;
62
118
  private stmtSelectAllRelations: any = null;
63
119
 
64
- async init(dataDir: string): Promise<void> {
120
+ async init(dataDir: string, projectId = DEFAULT_GRAPH_PROJECT_ID): Promise<void> {
65
121
  this.dataDir = dataDir;
122
+ this.projectId = projectId;
66
123
  this.db = getDatabase(dataDir);
124
+ ensureScopedGraphSchema(this.db);
67
125
 
68
126
  // Prepare entity statements
69
127
  this.stmtInsertEntity = this.db.prepare(
70
- `INSERT OR REPLACE INTO graph_entities (name, entityType, observations) VALUES (@name, @entityType, @observations)`
128
+ `INSERT OR REPLACE INTO graph_entities (projectId, name, entityType, observations) VALUES (@projectId, @name, @entityType, @observations)`
71
129
  );
72
130
  this.stmtUpdateEntityObs = this.db.prepare(
73
- `UPDATE graph_entities SET observations = @observations WHERE name = @name`
131
+ `UPDATE graph_entities SET observations = @observations WHERE projectId = @projectId AND name = @name`
74
132
  );
75
- this.stmtDeleteEntity = this.db.prepare(`DELETE FROM graph_entities WHERE name = ?`);
76
- this.stmtSelectAllEntities = this.db.prepare(`SELECT * FROM graph_entities`);
77
- this.stmtSelectEntityByName = this.db.prepare(`SELECT * FROM graph_entities WHERE name = ?`);
133
+ this.stmtDeleteEntity = this.db.prepare(`DELETE FROM graph_entities WHERE projectId = ? AND name = ?`);
134
+ this.stmtSelectAllEntities = this.db.prepare(`SELECT * FROM graph_entities WHERE projectId = ? ORDER BY name`);
135
+ this.stmtSelectEntityByName = this.db.prepare(`SELECT * FROM graph_entities WHERE projectId = ? AND name = ?`);
78
136
 
79
137
  // Prepare relation statements
80
138
  this.stmtInsertRelation = this.db.prepare(
81
- `INSERT OR IGNORE INTO graph_relations (from_entity, to_entity, relationType) VALUES (@from, @to, @relationType)`
139
+ `INSERT OR IGNORE INTO graph_relations (projectId, from_entity, to_entity, relationType) VALUES (@projectId, @from, @to, @relationType)`
82
140
  );
83
141
  this.stmtDeleteRelation = this.db.prepare(
84
- `DELETE FROM graph_relations WHERE from_entity = ? AND to_entity = ? AND relationType = ?`
142
+ `DELETE FROM graph_relations WHERE projectId = ? AND from_entity = ? AND to_entity = ? AND relationType = ?`
85
143
  );
86
144
  this.stmtDeleteRelationsByEntity = this.db.prepare(
87
- `DELETE FROM graph_relations WHERE from_entity = ? OR to_entity = ?`
145
+ `DELETE FROM graph_relations WHERE projectId = ? AND (from_entity = ? OR to_entity = ?)`
88
146
  );
89
- this.stmtSelectAllRelations = this.db.prepare(`SELECT * FROM graph_relations`);
147
+ this.stmtSelectAllRelations = this.db.prepare(`SELECT * FROM graph_relations WHERE projectId = ? ORDER BY id`);
90
148
 
91
149
  // One-time migration from graph.jsonl
92
150
  await this.migrateFromJsonlIfNeeded();
93
151
  }
94
152
 
95
153
  private async migrateFromJsonlIfNeeded(): Promise<void> {
96
- const count = this.db.prepare(`SELECT COUNT(*) AS cnt FROM graph_entities`).get();
154
+ const count = this.db.prepare(`SELECT COUNT(*) AS cnt FROM graph_entities WHERE projectId = ?`).get(this.projectId);
155
+ const totalCount = this.db.prepare(`SELECT COUNT(*) AS cnt FROM graph_entities`).get();
97
156
  if (count.cnt > 0) return;
157
+ if (totalCount.cnt > 0) return;
98
158
 
99
159
  const jsonlPath = path.join(this.dataDir, 'graph.jsonl');
100
160
  if (!fs.existsSync(jsonlPath)) return;
@@ -105,12 +165,65 @@ export class GraphSqliteStore implements GraphStore {
105
165
 
106
166
  console.error(`[memorix] Migrating graph from JSONL to SQLite (${data.entities.length} entities, ${data.relations.length} relations)...`);
107
167
 
168
+ const observations = this.db.prepare('SELECT id, projectId FROM observations').all() as Array<{ id: number; projectId: string }>;
169
+ const projectByObservationId = new Map(observations.map((row) => [row.id, row.projectId]));
170
+ const projectsByEntity = new Map<string, Set<string>>();
171
+ for (const entity of data.entities) {
172
+ const projects = new Set<string>();
173
+ for (const content of entity.observations ?? []) {
174
+ const match = /^\[#(\d+)\]/.exec(content);
175
+ const projectId = match ? projectByObservationId.get(Number(match[1])) : undefined;
176
+ if (projectId) projects.add(projectId);
177
+ }
178
+ projectsByEntity.set(entity.name, projects);
179
+ }
180
+
181
+ // Legacy graph.jsonl had no project column. Recover the ownership that
182
+ // can be proven from observation references, then propagate it through
183
+ // unambiguous relation endpoints. A relation crossing two projects is
184
+ // intentionally quarantined instead of leaking into both views.
185
+ let changed = true;
186
+ while (changed) {
187
+ changed = false;
188
+ for (const relation of data.relations) {
189
+ const from = projectsByEntity.get(relation.from) ?? new Set<string>();
190
+ const to = projectsByEntity.get(relation.to) ?? new Set<string>();
191
+ if (from.size > 0 && to.size === 0) {
192
+ projectsByEntity.set(relation.to, new Set(from));
193
+ changed = true;
194
+ } else if (to.size > 0 && from.size === 0) {
195
+ projectsByEntity.set(relation.from, new Set(to));
196
+ changed = true;
197
+ }
198
+ }
199
+ }
200
+
201
+ const entityRows = data.entities.flatMap((entity) => {
202
+ const projects = projectsByEntity.get(entity.name) ?? new Set<string>();
203
+ const owners = projects.size > 0 ? [...projects] : [this.projectId];
204
+ return owners.map((projectId) => ({ ...entityToRow(entity), projectId }));
205
+ });
206
+ const projectSet = new Set(entityRows.map((row) => String(row.projectId)));
207
+ const relationRows = data.relations.flatMap((relation) => {
208
+ const from = projectsByEntity.get(relation.from) ?? new Set<string>();
209
+ const to = projectsByEntity.get(relation.to) ?? new Set<string>();
210
+ const shared = [...from].filter((projectId) => to.has(projectId));
211
+ const owners = shared.length > 0
212
+ ? shared
213
+ : from.size === 0 && to.size === 0
214
+ ? [this.projectId]
215
+ : [];
216
+ return owners
217
+ .filter((projectId) => projectSet.has(projectId))
218
+ .map((projectId) => ({ projectId, from: relation.from, to: relation.to, relationType: relation.relationType }));
219
+ });
220
+
108
221
  const insertAll = this.db.transaction(() => {
109
- for (const entity of data.entities) {
110
- this.stmtInsertEntity.run(entityToRow(entity));
222
+ for (const entity of entityRows) {
223
+ this.stmtInsertEntity.run(entity);
111
224
  }
112
- for (const rel of data.relations) {
113
- this.stmtInsertRelation.run({ from: rel.from, to: rel.to, relationType: rel.relationType });
225
+ for (const relation of relationRows) {
226
+ this.stmtInsertRelation.run(relation);
114
227
  }
115
228
  });
116
229
  insertAll();
@@ -122,11 +235,11 @@ export class GraphSqliteStore implements GraphStore {
122
235
  }
123
236
 
124
237
  loadEntities(): Entity[] {
125
- return this.stmtSelectAllEntities.all().map(rowToEntity);
238
+ return this.stmtSelectAllEntities.all(this.projectId).map(rowToEntity);
126
239
  }
127
240
 
128
241
  loadRelations(): Relation[] {
129
- return this.stmtSelectAllRelations.all().map((row: any) => ({
242
+ return this.stmtSelectAllRelations.all(this.projectId).map((row: any) => ({
130
243
  from: row.from_entity,
131
244
  to: row.to_entity,
132
245
  relationType: row.relationType,
@@ -136,7 +249,7 @@ export class GraphSqliteStore implements GraphStore {
136
249
  insertEntities(entities: Entity[]): void {
137
250
  const insertAll = this.db.transaction(() => {
138
251
  for (const entity of entities) {
139
- this.stmtInsertEntity.run(entityToRow(entity));
252
+ this.stmtInsertEntity.run({ ...entityToRow(entity), projectId: this.projectId });
140
253
  }
141
254
  });
142
255
  insertAll();
@@ -145,7 +258,7 @@ export class GraphSqliteStore implements GraphStore {
145
258
  insertRelations(relations: Relation[]): void {
146
259
  const insertAll = this.db.transaction(() => {
147
260
  for (const rel of relations) {
148
- this.stmtInsertRelation.run({ from: rel.from, to: rel.to, relationType: rel.relationType });
261
+ this.stmtInsertRelation.run({ projectId: this.projectId, from: rel.from, to: rel.to, relationType: rel.relationType });
149
262
  }
150
263
  });
151
264
  insertAll();
@@ -154,8 +267,8 @@ export class GraphSqliteStore implements GraphStore {
154
267
  deleteEntities(names: string[]): void {
155
268
  const deleteAll = this.db.transaction(() => {
156
269
  for (const name of names) {
157
- this.stmtDeleteRelationsByEntity.run(name, name);
158
- this.stmtDeleteEntity.run(name);
270
+ this.stmtDeleteRelationsByEntity.run(this.projectId, name, name);
271
+ this.stmtDeleteEntity.run(this.projectId, name);
159
272
  }
160
273
  });
161
274
  deleteAll();
@@ -164,7 +277,7 @@ export class GraphSqliteStore implements GraphStore {
164
277
  deleteRelations(relations: Relation[]): void {
165
278
  const deleteAll = this.db.transaction(() => {
166
279
  for (const rel of relations) {
167
- this.stmtDeleteRelation.run(rel.from, rel.to, rel.relationType);
280
+ this.stmtDeleteRelation.run(this.projectId, rel.from, rel.to, rel.relationType);
168
281
  }
169
282
  });
170
283
  deleteAll();
@@ -173,13 +286,13 @@ export class GraphSqliteStore implements GraphStore {
173
286
  addObservations(updates: { entityName: string; contents: string[] }[]): void {
174
287
  const updateAll = this.db.transaction(() => {
175
288
  for (const u of updates) {
176
- const row = this.stmtSelectEntityByName.get(u.entityName);
289
+ const row = this.stmtSelectEntityByName.get(this.projectId, u.entityName);
177
290
  if (!row) continue;
178
291
  const existing: string[] = safeJsonParse(row.observations, []);
179
292
  const newObs = u.contents.filter(c => !existing.includes(c));
180
293
  if (newObs.length > 0) {
181
294
  existing.push(...newObs);
182
- this.stmtUpdateEntityObs.run({ name: u.entityName, observations: JSON.stringify(existing) });
295
+ this.stmtUpdateEntityObs.run({ projectId: this.projectId, name: u.entityName, observations: JSON.stringify(existing) });
183
296
  }
184
297
  }
185
298
  });
@@ -189,11 +302,11 @@ export class GraphSqliteStore implements GraphStore {
189
302
  deleteObservations(deletions: { entityName: string; observations: string[] }[]): void {
190
303
  const deleteAll = this.db.transaction(() => {
191
304
  for (const d of deletions) {
192
- const row = this.stmtSelectEntityByName.get(d.entityName);
305
+ const row = this.stmtSelectEntityByName.get(this.projectId, d.entityName);
193
306
  if (!row) continue;
194
307
  const existing: string[] = safeJsonParse(row.observations, []);
195
308
  const filtered = existing.filter(o => !d.observations.includes(o));
196
- this.stmtUpdateEntityObs.run({ name: d.entityName, observations: JSON.stringify(filtered) });
309
+ this.stmtUpdateEntityObs.run({ projectId: this.projectId, name: d.entityName, observations: JSON.stringify(filtered) });
197
310
  }
198
311
  });
199
312
  deleteAll();
@@ -205,13 +318,13 @@ export class GraphSqliteStore implements GraphStore {
205
318
  */
206
319
  replaceAll(entities: Entity[], relations: Relation[]): void {
207
320
  const replaceAll = this.db.transaction(() => {
208
- this.db.prepare('DELETE FROM graph_relations').run();
209
- this.db.prepare('DELETE FROM graph_entities').run();
321
+ this.db.prepare('DELETE FROM graph_relations WHERE projectId = ?').run(this.projectId);
322
+ this.db.prepare('DELETE FROM graph_entities WHERE projectId = ?').run(this.projectId);
210
323
  for (const entity of entities) {
211
- this.stmtInsertEntity.run(entityToRow(entity));
324
+ this.stmtInsertEntity.run({ ...entityToRow(entity), projectId: this.projectId });
212
325
  }
213
326
  for (const rel of relations) {
214
- this.stmtInsertRelation.run({ from: rel.from, to: rel.to, relationType: rel.relationType });
327
+ this.stmtInsertRelation.run({ projectId: this.projectId, from: rel.from, to: rel.to, relationType: rel.relationType });
215
328
  }
216
329
  });
217
330
  replaceAll();
@@ -224,26 +337,45 @@ export class GraphSqliteStore implements GraphStore {
224
337
 
225
338
  // ── Singleton ──────────────────────────────────────────────────────
226
339
 
227
- let _graphStore: GraphSqliteStore | null = null;
228
- let _graphDataDir: string | null = null;
340
+ const _graphStores = new Map<string, GraphSqliteStore>();
341
+ let _activeGraphKey: string | null = null;
342
+
343
+ function graphStoreKey(dataDir: string, projectId: string): string {
344
+ return `${path.resolve(dataDir)}\0${projectId}`;
345
+ }
229
346
 
230
- export async function initGraphStore(dataDir: string): Promise<GraphSqliteStore> {
231
- if (_graphStore && _graphDataDir === dataDir) return _graphStore;
232
- _graphStore = new GraphSqliteStore();
233
- await _graphStore.init(dataDir);
234
- _graphDataDir = dataDir;
235
- return _graphStore;
347
+ export async function initGraphStore(dataDir: string, projectId = DEFAULT_GRAPH_PROJECT_ID): Promise<GraphSqliteStore> {
348
+ const key = graphStoreKey(dataDir, projectId);
349
+ const existing = _graphStores.get(key);
350
+ if (existing) {
351
+ _graphStores.delete(key);
352
+ _graphStores.set(key, existing);
353
+ _activeGraphKey = key;
354
+ return existing;
355
+ }
356
+ const store = new GraphSqliteStore();
357
+ await store.init(dataDir, projectId);
358
+ _graphStores.set(key, store);
359
+ while (_graphStores.size > MAX_GRAPH_STORE_CACHE) {
360
+ const oldest = _graphStores.keys().next().value as string | undefined;
361
+ if (!oldest) break;
362
+ _graphStores.delete(oldest);
363
+ }
364
+ _activeGraphKey = key;
365
+ return store;
236
366
  }
237
367
 
238
- export function getGraphStore(): GraphSqliteStore {
239
- if (!_graphStore) throw new Error('[memorix] GraphStore not initialized — call initGraphStore() first');
240
- return _graphStore;
368
+ export function getGraphStore(dataDir?: string, projectId?: string): GraphSqliteStore {
369
+ const key = dataDir && projectId ? graphStoreKey(dataDir, projectId) : _activeGraphKey;
370
+ const store = key ? _graphStores.get(key) : undefined;
371
+ if (!store) throw new Error('[memorix] GraphStore not initialized — call initGraphStore() first');
372
+ return store;
241
373
  }
242
374
 
243
375
  export function resetGraphStore(): void {
244
- if (_graphStore) {
245
- try { _graphStore.close(); } catch { /* best-effort */ }
376
+ for (const store of _graphStores.values()) {
377
+ try { store.close(); } catch { /* best-effort */ }
246
378
  }
247
- _graphStore = null;
248
- _graphDataDir = null;
379
+ _graphStores.clear();
380
+ _activeGraphKey = null;
249
381
  }
@@ -16,6 +16,7 @@
16
16
  import { promises as fs } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import os from 'node:os';
19
+ import { atomicWriteFile, withFileLock } from './file-lock.js';
19
20
 
20
21
  // ── Re-export JSON helpers for backward compat ────────────────────
21
22
  export {
@@ -105,6 +106,12 @@ export async function listProjectDirs(baseDir?: string): Promise<string[]> {
105
106
  export async function migrateSubdirsToFlat(baseDir?: string): Promise<boolean> {
106
107
  const base = baseDir ?? resolveDefaultDataDir();
107
108
  await fs.mkdir(base, { recursive: true });
109
+ return withFileLock(base, () => migrateSubdirsToFlatUnlocked(base));
110
+ }
111
+
112
+ async function migrateSubdirsToFlatUnlocked(baseDir?: string): Promise<boolean> {
113
+ const base = baseDir ?? resolveDefaultDataDir();
114
+ await fs.mkdir(base, { recursive: true });
108
115
 
109
116
  // Find all subdirectories that contain observations.json
110
117
  let entries: import('node:fs').Dirent[];
@@ -171,7 +178,9 @@ export async function migrateSubdirsToFlat(baseDir?: string): Promise<boolean> {
171
178
  for (const o of obs) {
172
179
  // Deduplicate by title+createdAt+projectId (same observation from migration overlap)
173
180
  const isDuplicate = allObs.some(
174
- (existing) => existing.title === o.title && existing.createdAt === o.createdAt,
181
+ (existing) => existing.title === o.title
182
+ && existing.createdAt === o.createdAt
183
+ && existing.projectId === o.projectId,
175
184
  );
176
185
  if (!isDuplicate) {
177
186
  allObs.push(o);
@@ -212,12 +221,8 @@ export async function migrateSubdirsToFlat(baseDir?: string): Promise<boolean> {
212
221
  }
213
222
 
214
223
  // Write merged data to base directory
215
- await fs.writeFile(path.join(base, 'observations.json'), JSON.stringify(allObs, null, 2), 'utf-8');
216
- await fs.writeFile(
217
- path.join(base, 'counter.json'),
218
- JSON.stringify({ nextId: allObs.length + 1 }),
219
- 'utf-8',
220
- );
224
+ await atomicWriteFile(path.join(base, 'observations.json'), JSON.stringify(allObs, null, 2));
225
+ await atomicWriteFile(path.join(base, 'counter.json'), JSON.stringify({ nextId: allObs.length + 1 }));
221
226
 
222
227
  // Write merged graph
223
228
  const graphLines = [
@@ -225,7 +230,7 @@ export async function migrateSubdirsToFlat(baseDir?: string): Promise<boolean> {
225
230
  ...mergedRelations.map((r) => JSON.stringify({ type: 'relation', from: r.from, to: r.to, relationType: r.relationType })),
226
231
  ];
227
232
  if (graphLines.length > 0) {
228
- await fs.writeFile(path.join(base, 'graph.jsonl'), graphLines.join('\n'), 'utf-8');
233
+ await atomicWriteFile(path.join(base, 'graph.jsonl'), graphLines.join('\n'));
229
234
  }
230
235
 
231
236
  // Also merge sessions if present
@@ -243,7 +248,7 @@ export async function migrateSubdirsToFlat(baseDir?: string): Promise<boolean> {
243
248
  } catch { /* no sessions */ }
244
249
  }
245
250
  if (allSessions.length > 0) {
246
- await fs.writeFile(path.join(base, 'sessions.json'), JSON.stringify(allSessions, null, 2), 'utf-8');
251
+ await atomicWriteFile(path.join(base, 'sessions.json'), JSON.stringify(allSessions, null, 2));
247
252
  }
248
253
 
249
254
  // Move subdirectories to backup
@@ -217,19 +217,22 @@ CREATE TABLE IF NOT EXISTS chat_transcript (
217
217
 
218
218
  const CREATE_GRAPH_ENTITIES_TABLE = `
219
219
  CREATE TABLE IF NOT EXISTS graph_entities (
220
- name TEXT PRIMARY KEY,
220
+ projectId TEXT NOT NULL,
221
+ name TEXT NOT NULL,
221
222
  entityType TEXT NOT NULL DEFAULT '',
222
- observations TEXT NOT NULL DEFAULT '[]'
223
+ observations TEXT NOT NULL DEFAULT '[]',
224
+ PRIMARY KEY (projectId, name)
223
225
  );
224
226
  `;
225
227
 
226
228
  const CREATE_GRAPH_RELATIONS_TABLE = `
227
229
  CREATE TABLE IF NOT EXISTS graph_relations (
228
230
  id INTEGER PRIMARY KEY AUTOINCREMENT,
231
+ projectId TEXT NOT NULL,
229
232
  from_entity TEXT NOT NULL,
230
233
  to_entity TEXT NOT NULL,
231
234
  relationType TEXT NOT NULL DEFAULT '',
232
- UNIQUE(from_entity, to_entity, relationType)
235
+ UNIQUE(projectId, from_entity, to_entity, relationType)
233
236
  );
234
237
  `;
235
238
 
@@ -28,6 +28,7 @@ import {
28
28
  import type { LexicalSearchHit } from './obs-store.js';
29
29
  import path from 'node:path';
30
30
  import fs from 'node:fs';
31
+ import { withFileLock } from './file-lock.js';
31
32
 
32
33
  // ── Row ↔ Observation serialization ────────────────────────────────
33
34
 
@@ -294,49 +295,53 @@ export class SqliteBackend implements ObservationStore {
294
295
  // ── Migration ────────────────────────────────────────────────────
295
296
 
296
297
  private async migrateFromJsonIfNeeded(): Promise<void> {
297
- // Only migrate if table is empty
298
- const count = this.db.prepare(`SELECT COUNT(*) AS cnt FROM observations`).get();
299
- if (count.cnt > 0) return;
300
-
301
298
  const jsonPath = path.join(this.dataDir, 'observations.json');
302
299
  if (!fs.existsSync(jsonPath)) return;
303
300
 
304
- try {
305
- const raw = fs.readFileSync(jsonPath, 'utf-8');
306
- const observations: Observation[] = JSON.parse(raw);
301
+ await withFileLock(this.dataDir, async () => {
302
+ // Re-check under the cross-process lock. Another initializer may have
303
+ // completed the migration while this process was waiting.
304
+ const count = this.db.prepare(`SELECT COUNT(*) AS cnt FROM observations`).get();
305
+ if (count.cnt > 0) return;
307
306
 
307
+ const raw = fs.readFileSync(jsonPath, 'utf-8');
308
+ const parsed: unknown = JSON.parse(raw);
309
+ if (!Array.isArray(parsed)) {
310
+ throw new Error('[memorix] observations.json migration refused: expected a JSON array');
311
+ }
312
+ const observations = parsed as Observation[];
308
313
  if (observations.length === 0) return;
314
+ if (observations.some((obs) => !obs || !Number.isSafeInteger(obs.id) || obs.id < 1 || typeof obs.projectId !== 'string')) {
315
+ throw new Error('[memorix] observations.json migration refused: invalid observation record');
316
+ }
309
317
 
310
318
  console.error(`[memorix] Migrating ${observations.length} observations from JSON to SQLite...`);
311
319
 
320
+ // The SQLite transaction makes the migration all-or-nothing. The JSON
321
+ // source remains intact until an operator explicitly removes it.
312
322
  const insertMany = this.db.transaction((obsList: Observation[]) => {
313
323
  for (const obs of obsList) {
314
324
  this.stmtInsert.run(obsToRow(obs));
315
325
  }
316
- });
317
- insertMany(observations);
318
326
 
319
- // Migrate counter
320
- const counterPath = path.join(this.dataDir, 'counter.json');
321
- if (fs.existsSync(counterPath)) {
322
- try {
323
- const counterData = JSON.parse(fs.readFileSync(counterPath, 'utf-8'));
324
- const nextId = counterData.nextId ?? counterData.next_id ?? (Math.max(...observations.map(o => o.id)) + 1);
325
- this.rawSaveIdCounter(nextId);
326
- } catch {
327
- // Fallback: derive from max observation ID
328
- this.rawSaveIdCounter(Math.max(...observations.map(o => o.id)) + 1);
327
+ const counterPath = path.join(this.dataDir, 'counter.json');
328
+ let counterNextId: number | undefined;
329
+ if (fs.existsSync(counterPath)) {
330
+ const counterData = JSON.parse(fs.readFileSync(counterPath, 'utf-8')) as { nextId?: unknown; next_id?: unknown };
331
+ const candidate = counterData.nextId ?? counterData.next_id;
332
+ if (candidate !== undefined && (!Number.isSafeInteger(candidate) || Number(candidate) < 1)) {
333
+ throw new Error('[memorix] counter.json migration refused: nextId must be a positive integer');
334
+ }
335
+ counterNextId = candidate === undefined ? undefined : Number(candidate);
329
336
  }
330
- } else {
331
- this.rawSaveIdCounter(Math.max(...observations.map(o => o.id)) + 1);
332
- }
333
-
334
- this.bumpGeneration();
337
+ const maxId = Math.max(...obsList.map((obs) => obs.id));
338
+ this.rawSaveIdCounter(Math.max(counterNextId ?? 1, maxId + 1));
339
+ this.bumpGeneration();
340
+ });
341
+ insertMany(observations);
335
342
 
336
343
  console.error(`[memorix] Migration complete. ${observations.length} observations now in SQLite.`);
337
- } catch (err) {
338
- console.error(`[memorix] JSON→SQLite migration failed (non-fatal, data preserved in JSON): ${err}`);
339
- }
344
+ });
340
345
  }
341
346
 
342
347
  // ── Public read ──────────────────────────────────────────────────