memorix 1.9.4 → 1.9.6

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 (90) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +12 -6
  3. package/README.zh-CN.md +12 -6
  4. package/dist/cli/index.js +2533 -585
  5. package/dist/index.d.ts +15 -8
  6. package/dist/index.js +1201 -284
  7. package/dist/maintenance-runner.js +387 -216
  8. package/dist/media-audio-runner.js +300 -129
  9. package/dist/media-video-runner.js +300 -129
  10. package/dist/memcode-runtime/CHANGELOG.md +75 -1
  11. package/dist/sdk.d.ts +1 -0
  12. package/dist/sdk.js +1882 -998
  13. package/dist/types.d.ts +1 -1
  14. package/dist/vector-backfill-runner.js +300 -129
  15. package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
  16. package/docs/API_REFERENCE.md +39 -1
  17. package/docs/CONFIGURATION.md +58 -1
  18. package/docs/DEVELOPMENT.md +1 -1
  19. package/docs/DOCKER.md +23 -3
  20. package/docs/INTEGRATIONS.md +1 -1
  21. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +61 -1
  22. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
  23. package/docs/PERFORMANCE.md +46 -2
  24. package/docs/README.md +2 -1
  25. package/docs/SETUP.md +56 -1
  26. package/package.json +3 -2
  27. package/plugins/antigravity/memorix/plugin.json +1 -1
  28. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  29. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  30. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  31. package/plugins/copilot/memorix/plugin.json +1 -1
  32. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  33. package/plugins/hermes/memorix/plugin.yaml +1 -1
  34. package/plugins/omp/memorix/package.json +1 -1
  35. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  36. package/plugins/pi/memorix/package.json +1 -1
  37. package/server.json +2 -2
  38. package/src/cli/capability-map.ts +1 -0
  39. package/src/cli/command-guide.ts +15 -3
  40. package/src/cli/commands/agent-integrations.ts +57 -0
  41. package/src/cli/commands/background.ts +11 -2
  42. package/src/cli/commands/context.ts +2 -0
  43. package/src/cli/commands/continuity.ts +155 -0
  44. package/src/cli/commands/init.ts +5 -2
  45. package/src/cli/commands/serve-http.ts +435 -84
  46. package/src/cli/commands/setup.ts +77 -10
  47. package/src/cli/commands/sync-store.ts +47 -12
  48. package/src/cli/commands/sync.ts +26 -0
  49. package/src/cli/index.ts +3 -1
  50. package/src/cli/tui/ConfigureView.tsx +2 -0
  51. package/src/codegraph/auto-context.ts +17 -0
  52. package/src/config/legacy-loader.ts +2 -2
  53. package/src/config/resolved-config.ts +8 -5
  54. package/src/config/yaml-loader.ts +20 -10
  55. package/src/dashboard/server.ts +10 -12
  56. package/src/embedding/api-provider.ts +4 -2
  57. package/src/embedding/fastembed-provider.ts +47 -14
  58. package/src/embedding/transformers-provider.ts +29 -7
  59. package/src/evaluation/memory-quality-gate.ts +90 -0
  60. package/src/hooks/official-skills.ts +2 -0
  61. package/src/integrations/registry.ts +1 -1
  62. package/src/knowledge/context-assembly.ts +1 -0
  63. package/src/knowledge/context-receipt.ts +15 -0
  64. package/src/knowledge/task-continuity.ts +413 -0
  65. package/src/knowledge/workset.ts +77 -0
  66. package/src/llm/provider.ts +2 -1
  67. package/src/memory/graph.ts +10 -8
  68. package/src/memory/session.ts +1 -1
  69. package/src/orchestrate/coordinator.ts +60 -0
  70. package/src/orchestrate/effect-ledger.ts +160 -0
  71. package/src/orchestrate/pipeline-trace.ts +40 -6
  72. package/src/runtime/maintenance-jobs.ts +12 -6
  73. package/src/sdk.ts +24 -0
  74. package/src/server/mcp-binding-store.ts +14 -3
  75. package/src/server/modern-mcp-bridge.ts +153 -27
  76. package/src/server/tool-profile.ts +3 -2
  77. package/src/server.ts +131 -7
  78. package/src/store/file-lock.ts +28 -3
  79. package/src/store/graph-store.ts +178 -46
  80. package/src/store/persistence.ts +14 -9
  81. package/src/store/sqlite-db.ts +150 -7
  82. package/src/store/sqlite-store.ts +32 -27
  83. package/src/sync/engine.ts +9 -7
  84. package/src/sync/namespace.ts +23 -0
  85. package/src/sync/policy.ts +19 -4
  86. package/src/sync/store-port.ts +43 -13
  87. package/src/sync/types.ts +2 -0
  88. package/src/team/team-store.ts +62 -27
  89. package/src/types.ts +1 -1
  90. package/src/workspace/mcp-adapters/grok.ts +32 -0
@@ -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
 
@@ -818,6 +821,45 @@ CREATE TABLE IF NOT EXISTS memory_feedback_events (
818
821
  );
819
822
  `;
820
823
 
824
+ const CREATE_TASK_CONTINUITY_EVENTS_TABLE = `
825
+ CREATE TABLE IF NOT EXISTS task_continuity_events (
826
+ id TEXT PRIMARY KEY,
827
+ project_id TEXT NOT NULL,
828
+ task_id TEXT NOT NULL,
829
+ task TEXT NOT NULL,
830
+ kind TEXT NOT NULL,
831
+ content TEXT NOT NULL,
832
+ item_status TEXT,
833
+ source_ref TEXT,
834
+ evidence_json TEXT NOT NULL DEFAULT '[]',
835
+ actor TEXT,
836
+ created_at TEXT NOT NULL,
837
+ event_key TEXT,
838
+ item_key TEXT,
839
+ supersedes_id TEXT
840
+ );
841
+ `;
842
+
843
+ const CREATE_AGENT_EFFECTS_TABLE = `
844
+ CREATE TABLE IF NOT EXISTS agent_effects (
845
+ id TEXT PRIMARY KEY,
846
+ pipeline_id TEXT NOT NULL,
847
+ task_id TEXT NOT NULL,
848
+ attempt INTEGER NOT NULL,
849
+ effect_key TEXT NOT NULL,
850
+ tool TEXT NOT NULL,
851
+ risk_tier TEXT NOT NULL,
852
+ replay_policy TEXT NOT NULL,
853
+ status TEXT NOT NULL,
854
+ call_id TEXT,
855
+ idempotency_key TEXT NOT NULL,
856
+ input_fingerprint TEXT,
857
+ detail TEXT,
858
+ started_at INTEGER NOT NULL,
859
+ finished_at INTEGER
860
+ );
861
+ `;
862
+
821
863
  const CREATE_MCP_BINDINGS_TABLE = `
822
864
  CREATE TABLE IF NOT EXISTS mcp_bindings (
823
865
  handle_id TEXT PRIMARY KEY,
@@ -896,7 +938,18 @@ CREATE INDEX IF NOT EXISTS idx_evidence_cards_candidate ON evidence_cards(projec
896
938
  CREATE INDEX IF NOT EXISTS idx_evidence_card_events_card ON evidence_card_events(card_id, created_at);
897
939
  CREATE INDEX IF NOT EXISTS idx_memory_feedback_states_project ON memory_feedback_states(project_id, candidate_kind, updated_at DESC);
898
940
  CREATE INDEX IF NOT EXISTS idx_memory_feedback_events_candidate ON memory_feedback_events(project_id, candidate_kind, candidate_id, observed_at DESC);
941
+ CREATE INDEX IF NOT EXISTS idx_task_continuity_project_task ON task_continuity_events(project_id, task_id, created_at ASC);
942
+ CREATE INDEX IF NOT EXISTS idx_task_continuity_project_recent ON task_continuity_events(project_id, created_at DESC);
943
+ CREATE INDEX IF NOT EXISTS idx_task_continuity_item ON task_continuity_events(project_id, task_id, kind, item_key, created_at DESC);
944
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_continuity_event_key
945
+ ON task_continuity_events(project_id, task_id, event_key)
946
+ WHERE event_key IS NOT NULL;
947
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_effect_identity
948
+ ON agent_effects(pipeline_id, task_id, attempt, effect_key);
949
+ CREATE INDEX IF NOT EXISTS idx_agent_effect_pipeline ON agent_effects(pipeline_id, started_at ASC);
899
950
  CREATE INDEX IF NOT EXISTS idx_mcp_bindings_project ON mcp_bindings(project_id, last_used_at DESC);
951
+ CREATE INDEX IF NOT EXISTS idx_mcp_bindings_root ON mcp_bindings(project_root, last_used_at DESC);
952
+ CREATE INDEX IF NOT EXISTS idx_mcp_bindings_expiry ON mcp_bindings(expires_at);
900
953
  CREATE UNIQUE INDEX IF NOT EXISTS idx_maintenance_jobs_active_dedupe
901
954
  ON maintenance_jobs(project_id, kind, dedupe_key)
902
955
  WHERE status IN ('pending', 'running', 'retry');
@@ -1075,6 +1128,8 @@ const SCHEMA_MIGRATIONS: SchemaMigration[] = [
1075
1128
  db.exec('CREATE INDEX IF NOT EXISTS idx_memory_feedback_states_project ON memory_feedback_states(project_id, candidate_kind, updated_at DESC)');
1076
1129
  db.exec('CREATE INDEX IF NOT EXISTS idx_memory_feedback_events_candidate ON memory_feedback_events(project_id, candidate_kind, candidate_id, observed_at DESC)');
1077
1130
  db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_bindings_project ON mcp_bindings(project_id, last_used_at DESC)');
1131
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_bindings_root ON mcp_bindings(project_root, last_used_at DESC)');
1132
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_bindings_expiry ON mcp_bindings(expires_at)');
1078
1133
  },
1079
1134
  },
1080
1135
  {
@@ -1086,6 +1141,18 @@ const SCHEMA_MIGRATIONS: SchemaMigration[] = [
1086
1141
  addColumnIfMissing(db, 'code_edges', 'source', 'source TEXT');
1087
1142
  },
1088
1143
  },
1144
+ {
1145
+ id: '1.9-continuity-v2',
1146
+ apply: (db) => {
1147
+ addColumnIfMissing(db, 'task_continuity_events', 'event_key', 'event_key TEXT');
1148
+ addColumnIfMissing(db, 'task_continuity_events', 'item_key', 'item_key TEXT');
1149
+ addColumnIfMissing(db, 'task_continuity_events', 'supersedes_id', 'supersedes_id TEXT');
1150
+ db.exec('CREATE INDEX IF NOT EXISTS idx_task_continuity_item ON task_continuity_events(project_id, task_id, kind, item_key, created_at DESC)');
1151
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_task_continuity_event_key
1152
+ ON task_continuity_events(project_id, task_id, event_key)
1153
+ WHERE event_key IS NOT NULL`);
1154
+ },
1155
+ },
1089
1156
  ];
1090
1157
 
1091
1158
  function applySchemaMigrations(db: any): void {
@@ -1107,6 +1174,28 @@ function applySchemaMigrations(db: any): void {
1107
1174
  // ── Singleton cache ─────────────────────────────────────────────────
1108
1175
 
1109
1176
  const _dbCache = new Map<string, any>();
1177
+ const _dbLeases = new Map<string, number>();
1178
+
1179
+ export interface DatabaseLease {
1180
+ dataDir: string;
1181
+ db: any;
1182
+ release: () => void;
1183
+ }
1184
+
1185
+ export interface DatabaseStats {
1186
+ cached: number;
1187
+ leased: number;
1188
+ leasedDataDirs: number;
1189
+ }
1190
+
1191
+ function normalizeDataDir(dataDir: string): string {
1192
+ return path.resolve(dataDir);
1193
+ }
1194
+
1195
+ function touchDatabase(normalized: string, db: any): void {
1196
+ _dbCache.delete(normalized);
1197
+ _dbCache.set(normalized, db);
1198
+ }
1110
1199
 
1111
1200
  /**
1112
1201
  * Get or create a shared SQLite database handle for the given data directory.
@@ -1117,10 +1206,13 @@ const _dbCache = new Map<string, any>();
1117
1206
  * Callers must NOT close the returned handle directly — use closeDatabase().
1118
1207
  */
1119
1208
  export function getDatabase(dataDir: string): any {
1120
- const normalized = path.resolve(dataDir);
1209
+ const normalized = normalizeDataDir(dataDir);
1121
1210
  assertNotHomeDataDir(normalized);
1122
1211
  const existing = _dbCache.get(normalized);
1123
- if (existing) return existing;
1212
+ if (existing) {
1213
+ touchDatabase(normalized, existing);
1214
+ return existing;
1215
+ }
1124
1216
 
1125
1217
  loadBetterSqlite3();
1126
1218
  fs.mkdirSync(dataDir, { recursive: true });
@@ -1158,6 +1250,8 @@ export function getDatabase(dataDir: string): any {
1158
1250
  db.exec(CREATE_MAINTENANCE_JOBS_TABLE);
1159
1251
  db.exec(CREATE_MAINTENANCE_TARGETS_TABLE);
1160
1252
  db.exec(CREATE_COMPACTION_CHECKPOINTS_TABLE);
1253
+ db.exec(CREATE_TASK_CONTINUITY_EVENTS_TABLE);
1254
+ db.exec(CREATE_AGENT_EFFECTS_TABLE);
1161
1255
 
1162
1256
  // Phase 3a migration: add sourceSnapshot + updatedAt to mini_skills
1163
1257
  // Idempotent — ALTER TABLE ADD COLUMN throws if column already exists
@@ -1191,7 +1285,7 @@ export function getDatabase(dataDir: string): any {
1191
1285
  db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('next_id', '1')`).run();
1192
1286
  db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('mini_skills_generation', '0')`).run();
1193
1287
 
1194
- _dbCache.set(normalized, db);
1288
+ touchDatabase(normalized, db);
1195
1289
  return db;
1196
1290
  } catch (error) {
1197
1291
  // A schema/migration/FTS failure must not leave an uncached connection
@@ -1201,12 +1295,60 @@ export function getDatabase(dataDir: string): any {
1201
1295
  }
1202
1296
  }
1203
1297
 
1298
+ /**
1299
+ * Hold a database handle across an HTTP/session lifetime. Idle eviction only
1300
+ * closes handles with no active leases, so cache pressure cannot invalidate a
1301
+ * store that is still serving a request.
1302
+ */
1303
+ export function acquireDatabase(dataDir: string): DatabaseLease {
1304
+ const normalized = normalizeDataDir(dataDir);
1305
+ const db = getDatabase(normalized);
1306
+ _dbLeases.set(normalized, (_dbLeases.get(normalized) ?? 0) + 1);
1307
+ let released = false;
1308
+ return {
1309
+ dataDir: normalized,
1310
+ db,
1311
+ release: () => {
1312
+ if (released) return;
1313
+ released = true;
1314
+ const count = (_dbLeases.get(normalized) ?? 0) - 1;
1315
+ if (count > 0) _dbLeases.set(normalized, count);
1316
+ else _dbLeases.delete(normalized);
1317
+ },
1318
+ };
1319
+ }
1320
+
1321
+ /** Return bounded resource information for health and diagnostics. */
1322
+ export function getDatabaseStats(): DatabaseStats {
1323
+ let leased = 0;
1324
+ for (const count of _dbLeases.values()) leased += count;
1325
+ return { cached: _dbCache.size, leased, leasedDataDirs: _dbLeases.size };
1326
+ }
1327
+
1328
+ /**
1329
+ * Evict least-recently-used unleased handles until the cache is within its
1330
+ * target. Stores that need a handle across awaits must hold a lease first.
1331
+ */
1332
+ export function evictIdleDatabases(maxCached: number): number {
1333
+ const target = Math.max(0, Math.floor(maxCached));
1334
+ let evicted = 0;
1335
+ for (const [normalized, db] of [..._dbCache]) {
1336
+ if (_dbCache.size <= target) break;
1337
+ if ((_dbLeases.get(normalized) ?? 0) > 0) continue;
1338
+ try { db.close(); } catch { /* best-effort */ }
1339
+ _dbCache.delete(normalized);
1340
+ evicted++;
1341
+ }
1342
+ return evicted;
1343
+ }
1344
+
1204
1345
  /**
1205
1346
  * Close and remove a cached database handle for the given data directory.
1206
1347
  * Safe to call even if no handle exists.
1207
1348
  */
1208
1349
  export function closeDatabase(dataDir: string): void {
1209
- const normalized = path.resolve(dataDir);
1350
+ const normalized = normalizeDataDir(dataDir);
1351
+ if ((_dbLeases.get(normalized) ?? 0) > 0) return;
1210
1352
  const db = _dbCache.get(normalized);
1211
1353
  if (db) {
1212
1354
  try { db.close(); } catch { /* best-effort */ }
@@ -1222,6 +1364,7 @@ export function closeAllDatabases(): void {
1222
1364
  try { db.close(); } catch { /* best-effort */ }
1223
1365
  _dbCache.delete(key);
1224
1366
  }
1367
+ _dbLeases.clear();
1225
1368
  }
1226
1369
 
1227
1370
  /**