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.
- package/CHANGELOG.md +33 -1
- package/README.md +6 -5
- package/README.zh-CN.md +6 -5
- package/dist/cli/index.js +890 -325
- package/dist/index.d.ts +3 -1
- package/dist/index.js +251 -104
- package/dist/maintenance-runner.js +144 -113
- package/dist/media-audio-runner.js +57 -26
- package/dist/media-video-runner.js +57 -26
- package/dist/memcode-runtime/CHANGELOG.md +33 -1
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +1012 -842
- package/dist/types.d.ts +1 -1
- package/dist/vector-backfill-runner.js +57 -26
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
- package/docs/API_REFERENCE.md +3 -1
- package/docs/CONFIGURATION.md +15 -1
- package/docs/DOCKER.md +23 -3
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/KNOWN_ISSUES_AND_ROADMAP.md +26 -1
- package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
- package/docs/SETUP.md +55 -0
- package/package.json +2 -2
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/command-guide.ts +2 -2
- package/src/cli/commands/agent-integrations.ts +57 -0
- package/src/cli/commands/background.ts +11 -2
- package/src/cli/commands/serve-http.ts +193 -42
- package/src/cli/commands/setup.ts +77 -10
- package/src/cli/commands/sync-store.ts +47 -12
- package/src/cli/commands/sync.ts +26 -0
- package/src/dashboard/server.ts +10 -12
- package/src/integrations/registry.ts +1 -1
- package/src/memory/graph.ts +10 -8
- package/src/memory/session.ts +1 -1
- package/src/sdk.ts +24 -0
- package/src/server/mcp-binding-store.ts +14 -3
- package/src/server.ts +2 -2
- package/src/store/file-lock.ts +28 -3
- package/src/store/graph-store.ts +178 -46
- package/src/store/persistence.ts +14 -9
- package/src/store/sqlite-db.ts +6 -3
- package/src/store/sqlite-store.ts +32 -27
- package/src/sync/engine.ts +9 -7
- package/src/sync/namespace.ts +23 -0
- package/src/sync/policy.ts +19 -4
- package/src/sync/store-port.ts +43 -13
- package/src/sync/types.ts +2 -0
- package/src/types.ts +1 -1
- package/src/workspace/mcp-adapters/grok.ts +32 -0
package/src/store/file-lock.ts
CHANGED
|
@@ -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.
|
|
43
|
-
|
|
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:
|
|
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');
|
package/src/store/graph-store.ts
CHANGED
|
@@ -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
|
|
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
|
|
110
|
-
this.stmtInsertEntity.run(
|
|
222
|
+
for (const entity of entityRows) {
|
|
223
|
+
this.stmtInsertEntity.run(entity);
|
|
111
224
|
}
|
|
112
|
-
for (const
|
|
113
|
-
this.stmtInsertRelation.run(
|
|
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
|
-
|
|
228
|
-
let
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
try {
|
|
376
|
+
for (const store of _graphStores.values()) {
|
|
377
|
+
try { store.close(); } catch { /* best-effort */ }
|
|
246
378
|
}
|
|
247
|
-
|
|
248
|
-
|
|
379
|
+
_graphStores.clear();
|
|
380
|
+
_activeGraphKey = null;
|
|
249
381
|
}
|
package/src/store/persistence.ts
CHANGED
|
@@ -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
|
|
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
|
|
216
|
-
await
|
|
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
|
|
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
|
|
251
|
+
await atomicWriteFile(path.join(base, 'sessions.json'), JSON.stringify(allSessions, null, 2));
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
// Move subdirectories to backup
|
package/src/store/sqlite-db.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
this.rawSaveIdCounter(Math.max(
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
}
|
|
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 ──────────────────────────────────────────────────
|