@vaur94/agz-memory 0.4.0-beta.1
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/ARCHITECTURE.md +173 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/README.tr.md +240 -0
- package/dist/admin.js +1494 -0
- package/dist/core.js +2434 -0
- package/dist/server.js +1790 -0
- package/dist/types/admin/doctor.d.ts +10 -0
- package/dist/types/admin/index.d.ts +2 -0
- package/dist/types/capture/contract.d.ts +102 -0
- package/dist/types/capture/identity.d.ts +25 -0
- package/dist/types/capture/policy.d.ts +9 -0
- package/dist/types/capture/projection.d.ts +17 -0
- package/dist/types/capture/redact.d.ts +12 -0
- package/dist/types/config.d.ts +4 -0
- package/dist/types/context.d.ts +1 -0
- package/dist/types/core.d.ts +30 -0
- package/dist/types/db/backup.d.ts +25 -0
- package/dist/types/db/health.d.ts +11 -0
- package/dist/types/db/migration-lock.d.ts +17 -0
- package/dist/types/db/migrations/v009.d.ts +3 -0
- package/dist/types/db/schema.d.ts +5 -0
- package/dist/types/db.d.ts +6 -0
- package/dist/types/identity.d.ts +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/project.d.ts +4 -0
- package/dist/types/retrieval/backends/none.d.ts +9 -0
- package/dist/types/retrieval/contract.d.ts +42 -0
- package/dist/types/retrieval/derived.d.ts +11 -0
- package/dist/types/retrieval/formatter.d.ts +5 -0
- package/dist/types/retrieval/fusion.d.ts +8 -0
- package/dist/types/server.d.ts +5 -0
- package/dist/types/store/capture.d.ts +46 -0
- package/dist/types/store/outbox.d.ts +14 -0
- package/dist/types/store/retrieval.d.ts +18 -0
- package/dist/types/store.d.ts +95 -0
- package/dist/types/tools.d.ts +3 -0
- package/dist/types/types.d.ts +54 -0
- package/docs/backup-restore-runbook.md +73 -0
- package/package.json +63 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1790 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3 } from "fs";
|
|
6
|
+
import { dirname as dirname2 } from "path";
|
|
7
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
8
|
+
|
|
9
|
+
// src/config.ts
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
function resolveConfig(environment = process.env) {
|
|
13
|
+
const databasePath = environment.OPENCODE_MEMORY_DATABASE_PATH?.trim() || join(environment.HOME ?? homedir(), ".local", "share", "opencode-memory", "memory.sqlite");
|
|
14
|
+
return { databasePath };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/db.ts
|
|
18
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
19
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
20
|
+
import { chmodSync as chmodSync2, copyFileSync as copyFileSync2, existsSync as existsSync3 } from "fs";
|
|
21
|
+
|
|
22
|
+
// src/identity.ts
|
|
23
|
+
import { createHash } from "crypto";
|
|
24
|
+
function hashRoot(directory) {
|
|
25
|
+
return createHash("sha256").update(directory).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/project.ts
|
|
29
|
+
var MAX_PROJECT_NAME_LENGTH = 120;
|
|
30
|
+
function cleanProjectName(value) {
|
|
31
|
+
return value.trim().replace(/\s+/g, " ");
|
|
32
|
+
}
|
|
33
|
+
function normalizeProjectName(value) {
|
|
34
|
+
return cleanProjectName(value).normalize("NFKC").toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
function validateProjectName(value) {
|
|
37
|
+
const name = cleanProjectName(value);
|
|
38
|
+
if (!name)
|
|
39
|
+
return "project name is required";
|
|
40
|
+
if (name.length > MAX_PROJECT_NAME_LENGTH) {
|
|
41
|
+
return `project name exceeds ${MAX_PROJECT_NAME_LENGTH} characters`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/types.ts
|
|
46
|
+
var SCHEMA_VERSION = 9;
|
|
47
|
+
var INLINE_LIMIT = 1200;
|
|
48
|
+
var KINDS = ["decision", "fact", "procedure", "context", "research", "preference", "task"];
|
|
49
|
+
var PREDICATES = ["SUPPORTS", "DERIVED_FROM", "PART_OF", "ABOUT", "PRECEDES", "SUPERSEDES"];
|
|
50
|
+
|
|
51
|
+
// src/db/backup.ts
|
|
52
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
53
|
+
import { Database } from "bun:sqlite";
|
|
54
|
+
import {
|
|
55
|
+
chmodSync,
|
|
56
|
+
closeSync,
|
|
57
|
+
copyFileSync,
|
|
58
|
+
existsSync,
|
|
59
|
+
fsyncSync,
|
|
60
|
+
lstatSync,
|
|
61
|
+
mkdirSync,
|
|
62
|
+
openSync,
|
|
63
|
+
readFileSync,
|
|
64
|
+
renameSync,
|
|
65
|
+
rmSync,
|
|
66
|
+
writeFileSync
|
|
67
|
+
} from "fs";
|
|
68
|
+
import { basename, dirname, join as join2, resolve } from "path";
|
|
69
|
+
|
|
70
|
+
// src/db/health.ts
|
|
71
|
+
function inspectDatabase(db) {
|
|
72
|
+
const integrity = db.query("PRAGMA integrity_check").get().integrity_check;
|
|
73
|
+
const foreignKeyViolations = db.query("PRAGMA foreign_key_check").all();
|
|
74
|
+
const schemaVersion = hasTable(db, "schema_state") ? db.query("SELECT MAX(version) AS version FROM schema_state").get().version ?? undefined : undefined;
|
|
75
|
+
const counts = {};
|
|
76
|
+
for (const table of [
|
|
77
|
+
"projects",
|
|
78
|
+
"notes",
|
|
79
|
+
"note_edges",
|
|
80
|
+
"notes_fts",
|
|
81
|
+
"project_bindings",
|
|
82
|
+
"capture_events",
|
|
83
|
+
"capture_checkpoints",
|
|
84
|
+
"note_provenance",
|
|
85
|
+
"note_revisions",
|
|
86
|
+
"index_outbox"
|
|
87
|
+
]) {
|
|
88
|
+
if (!hasTable(db, table))
|
|
89
|
+
continue;
|
|
90
|
+
counts[table] = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
|
|
91
|
+
}
|
|
92
|
+
return { integrity, foreignKeyViolations, schemaVersion, counts };
|
|
93
|
+
}
|
|
94
|
+
function assertHealthyDatabase(db) {
|
|
95
|
+
const health = inspectDatabase(db);
|
|
96
|
+
if (health.integrity !== "ok") {
|
|
97
|
+
throw new Error(`database integrity check failed: ${health.integrity}`);
|
|
98
|
+
}
|
|
99
|
+
if (health.foreignKeyViolations.length > 0) {
|
|
100
|
+
throw new Error(`database foreign key check failed: ${health.foreignKeyViolations.length} violation(s)`);
|
|
101
|
+
}
|
|
102
|
+
return health;
|
|
103
|
+
}
|
|
104
|
+
function hasTable(db, table) {
|
|
105
|
+
const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type IN ('table','view') AND name = ?").get(table);
|
|
106
|
+
return row.count > 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/db/backup.ts
|
|
110
|
+
var BACKUP_FORMAT = "opencode2-memory-backup/1";
|
|
111
|
+
function createVerifiedBackup(db, databasePath, sourceSchema, targetSchema, productVersion) {
|
|
112
|
+
const sourceHealth = assertHealthyDatabase(db);
|
|
113
|
+
const checkpoint = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
114
|
+
if (checkpoint.busy !== 0)
|
|
115
|
+
throw new Error("database WAL checkpoint is busy");
|
|
116
|
+
const backupDirectory = `${databasePath}.backup`;
|
|
117
|
+
mkdirSync(backupDirectory, { recursive: true, mode: 448 });
|
|
118
|
+
chmodSync(backupDirectory, 448);
|
|
119
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
120
|
+
const stem = `schema-v${sourceSchema}-${stamp}-${randomUUID()}`;
|
|
121
|
+
const finalDatabasePath = join2(backupDirectory, `${stem}.sqlite`);
|
|
122
|
+
const finalManifestPath = join2(backupDirectory, `${stem}.manifest.json`);
|
|
123
|
+
const temporaryDatabasePath = `${finalDatabasePath}.tmp`;
|
|
124
|
+
const temporaryManifestPath = `${finalManifestPath}.tmp`;
|
|
125
|
+
try {
|
|
126
|
+
db.exec(`VACUUM INTO '${escapeSql(temporaryDatabasePath)}'`);
|
|
127
|
+
chmodSync(temporaryDatabasePath, 384);
|
|
128
|
+
const verification = new Database(temporaryDatabasePath, { readonly: true });
|
|
129
|
+
let backupHealth;
|
|
130
|
+
let sqliteVersion;
|
|
131
|
+
try {
|
|
132
|
+
backupHealth = assertHealthyDatabase(verification);
|
|
133
|
+
sqliteVersion = verification.query("SELECT sqlite_version() AS version").get().version;
|
|
134
|
+
} finally {
|
|
135
|
+
verification.close();
|
|
136
|
+
}
|
|
137
|
+
if (JSON.stringify(backupHealth.counts) !== JSON.stringify(sourceHealth.counts)) {
|
|
138
|
+
throw new Error("backup row counts differ from source database");
|
|
139
|
+
}
|
|
140
|
+
const bytes = readFileSync(temporaryDatabasePath);
|
|
141
|
+
const manifest = {
|
|
142
|
+
format: BACKUP_FORMAT,
|
|
143
|
+
productVersion,
|
|
144
|
+
sourceSchema,
|
|
145
|
+
targetSchema,
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
sqliteVersion,
|
|
148
|
+
databaseFile: basename(finalDatabasePath),
|
|
149
|
+
sha256: createHash2("sha256").update(bytes).digest("hex"),
|
|
150
|
+
size: bytes.byteLength,
|
|
151
|
+
counts: backupHealth.counts,
|
|
152
|
+
integrity: "ok",
|
|
153
|
+
foreignKeyViolations: 0
|
|
154
|
+
};
|
|
155
|
+
writeFileSync(temporaryManifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
156
|
+
`, {
|
|
157
|
+
mode: 384
|
|
158
|
+
});
|
|
159
|
+
fsyncPath(temporaryDatabasePath);
|
|
160
|
+
fsyncPath(temporaryManifestPath);
|
|
161
|
+
renameSync(temporaryDatabasePath, finalDatabasePath);
|
|
162
|
+
renameSync(temporaryManifestPath, finalManifestPath);
|
|
163
|
+
fsyncPath(backupDirectory);
|
|
164
|
+
return { databasePath: finalDatabasePath, manifestPath: finalManifestPath, manifest };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
rmSync(temporaryDatabasePath, { force: true });
|
|
167
|
+
rmSync(temporaryManifestPath, { force: true });
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function verifyBackupManifest(manifestPath) {
|
|
172
|
+
const resolvedManifestPath = resolve(manifestPath);
|
|
173
|
+
const manifestStat = lstatSync(resolvedManifestPath);
|
|
174
|
+
if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) {
|
|
175
|
+
throw new Error("backup manifest must be a regular file");
|
|
176
|
+
}
|
|
177
|
+
const manifest = JSON.parse(readFileSync(resolvedManifestPath, "utf8"));
|
|
178
|
+
if (manifest.format !== BACKUP_FORMAT) {
|
|
179
|
+
throw new Error("unsupported backup manifest format");
|
|
180
|
+
}
|
|
181
|
+
if (typeof manifest.databaseFile !== "string" || !manifest.databaseFile || basename(manifest.databaseFile) !== manifest.databaseFile) {
|
|
182
|
+
throw new Error("backup databaseFile must be a basename");
|
|
183
|
+
}
|
|
184
|
+
if (typeof manifest.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(manifest.sha256)) {
|
|
185
|
+
throw new Error("backup manifest sha256 is invalid");
|
|
186
|
+
}
|
|
187
|
+
if (!Number.isSafeInteger(manifest.size) || manifest.size < 0) {
|
|
188
|
+
throw new Error("backup manifest size is invalid");
|
|
189
|
+
}
|
|
190
|
+
const manifestDirectory = dirname(resolvedManifestPath);
|
|
191
|
+
const databasePath = resolve(manifestDirectory, manifest.databaseFile);
|
|
192
|
+
if (dirname(databasePath) !== manifestDirectory) {
|
|
193
|
+
throw new Error("backup database file must stay inside the manifest directory");
|
|
194
|
+
}
|
|
195
|
+
if (!existsSync(databasePath))
|
|
196
|
+
throw new Error("backup database file is missing");
|
|
197
|
+
const databaseStat = lstatSync(databasePath);
|
|
198
|
+
if (!databaseStat.isFile() || databaseStat.isSymbolicLink()) {
|
|
199
|
+
throw new Error("backup database must be a regular file");
|
|
200
|
+
}
|
|
201
|
+
const bytes = readFileSync(databasePath);
|
|
202
|
+
const hash = createHash2("sha256").update(bytes).digest("hex");
|
|
203
|
+
if (hash !== manifest.sha256 || databaseStat.size !== manifest.size) {
|
|
204
|
+
throw new Error("backup hash or size mismatch");
|
|
205
|
+
}
|
|
206
|
+
const db = new Database(databasePath, { readonly: true });
|
|
207
|
+
try {
|
|
208
|
+
const health = assertHealthyDatabase(db);
|
|
209
|
+
if (JSON.stringify(health.counts) !== JSON.stringify(manifest.counts)) {
|
|
210
|
+
throw new Error("backup manifest row counts do not match");
|
|
211
|
+
}
|
|
212
|
+
} finally {
|
|
213
|
+
db.close();
|
|
214
|
+
}
|
|
215
|
+
return { databasePath, manifestPath: resolvedManifestPath, manifest };
|
|
216
|
+
}
|
|
217
|
+
function restoreVerifiedBackup(manifestPath, targetPath, confirmation) {
|
|
218
|
+
if (confirmation !== "RESTORE_DATABASE_FROM_VERIFIED_BACKUP") {
|
|
219
|
+
throw new Error("invalid restore confirmation");
|
|
220
|
+
}
|
|
221
|
+
const verified = verifyBackupManifest(manifestPath);
|
|
222
|
+
mkdirSync(dirname(targetPath), { recursive: true, mode: 448 });
|
|
223
|
+
const temporary = `${targetPath}.restore-${randomUUID()}.tmp`;
|
|
224
|
+
const preserved = `${targetPath}.failed-restore-source-${Date.now()}-${randomUUID()}`;
|
|
225
|
+
const movedSidecars = [];
|
|
226
|
+
let hasPreservedSource = false;
|
|
227
|
+
let preservedSourceHealthy = false;
|
|
228
|
+
let targetInstalled = false;
|
|
229
|
+
try {
|
|
230
|
+
copyFileSync(verified.databasePath, temporary);
|
|
231
|
+
chmodSync(temporary, 384);
|
|
232
|
+
fsyncPath(temporary);
|
|
233
|
+
if (existsSync(targetPath)) {
|
|
234
|
+
preservedSourceHealthy = checkpointSource(targetPath);
|
|
235
|
+
copyFileSync(targetPath, preserved);
|
|
236
|
+
chmodSync(preserved, 384);
|
|
237
|
+
fsyncPath(preserved);
|
|
238
|
+
if (preservedSourceHealthy)
|
|
239
|
+
verifyDatabaseFile(preserved);
|
|
240
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
241
|
+
const source = `${targetPath}${suffix}`;
|
|
242
|
+
if (!existsSync(source))
|
|
243
|
+
continue;
|
|
244
|
+
const preservedSidecar = `${preserved}${suffix}`;
|
|
245
|
+
copyFileSync(source, preservedSidecar);
|
|
246
|
+
chmodSync(preservedSidecar, 384);
|
|
247
|
+
fsyncPath(preservedSidecar);
|
|
248
|
+
}
|
|
249
|
+
fsyncPath(dirname(targetPath));
|
|
250
|
+
hasPreservedSource = true;
|
|
251
|
+
}
|
|
252
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
253
|
+
const source = `${targetPath}${suffix}`;
|
|
254
|
+
if (!existsSync(source))
|
|
255
|
+
continue;
|
|
256
|
+
const quarantine = `${source}.quarantine-${randomUUID()}`;
|
|
257
|
+
renameSync(source, quarantine);
|
|
258
|
+
movedSidecars.push({ source, quarantine });
|
|
259
|
+
}
|
|
260
|
+
renameSync(temporary, targetPath);
|
|
261
|
+
targetInstalled = true;
|
|
262
|
+
fsyncPath(dirname(targetPath));
|
|
263
|
+
verifyDatabaseFile(targetPath);
|
|
264
|
+
for (const { quarantine } of movedSidecars) {
|
|
265
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const rollbackErrors = [];
|
|
269
|
+
rmSync(temporary, { force: true });
|
|
270
|
+
if (targetInstalled) {
|
|
271
|
+
try {
|
|
272
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
273
|
+
rmSync(`${targetPath}${suffix}`, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
if (hasPreservedSource) {
|
|
276
|
+
const rollback = `${targetPath}.rollback-${randomUUID()}.tmp`;
|
|
277
|
+
copyFileSync(preserved, rollback);
|
|
278
|
+
chmodSync(rollback, 384);
|
|
279
|
+
fsyncPath(rollback);
|
|
280
|
+
rmSync(targetPath, { force: true });
|
|
281
|
+
renameSync(rollback, targetPath);
|
|
282
|
+
} else {
|
|
283
|
+
rmSync(targetPath, { force: true });
|
|
284
|
+
}
|
|
285
|
+
} catch (rollbackError) {
|
|
286
|
+
rollbackErrors.push(rollbackError);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const { source, quarantine } of movedSidecars.reverse()) {
|
|
290
|
+
if (!existsSync(quarantine))
|
|
291
|
+
continue;
|
|
292
|
+
try {
|
|
293
|
+
rmSync(source, { recursive: true, force: true });
|
|
294
|
+
renameSync(quarantine, source);
|
|
295
|
+
} catch (rollbackError) {
|
|
296
|
+
rollbackErrors.push(rollbackError);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (targetInstalled && hasPreservedSource && preservedSourceHealthy && rollbackErrors.length === 0) {
|
|
300
|
+
try {
|
|
301
|
+
fsyncPath(dirname(targetPath));
|
|
302
|
+
verifyDatabaseFile(targetPath);
|
|
303
|
+
} catch (rollbackError) {
|
|
304
|
+
rollbackErrors.push(rollbackError);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (rollbackErrors.length > 0) {
|
|
308
|
+
throw new AggregateError([error, ...rollbackErrors], "restore failed and rollback was incomplete");
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
return preserved;
|
|
313
|
+
}
|
|
314
|
+
function escapeSql(value) {
|
|
315
|
+
return value.replaceAll("'", "''");
|
|
316
|
+
}
|
|
317
|
+
function fsyncPath(path) {
|
|
318
|
+
const descriptor = openSync(path, "r");
|
|
319
|
+
try {
|
|
320
|
+
fsyncSync(descriptor);
|
|
321
|
+
} finally {
|
|
322
|
+
closeSync(descriptor);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function checkpointSource(path) {
|
|
326
|
+
let db;
|
|
327
|
+
try {
|
|
328
|
+
db = new Database(path);
|
|
329
|
+
const checkpoint = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
330
|
+
if (checkpoint.busy !== 0)
|
|
331
|
+
throw new Error("source database WAL checkpoint is busy");
|
|
332
|
+
try {
|
|
333
|
+
assertHealthyDatabase(db);
|
|
334
|
+
return true;
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (isBusyError(error))
|
|
340
|
+
throw error;
|
|
341
|
+
return false;
|
|
342
|
+
} finally {
|
|
343
|
+
db?.close();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function verifyDatabaseFile(path) {
|
|
347
|
+
const db = new Database(path, { readonly: true });
|
|
348
|
+
try {
|
|
349
|
+
assertHealthyDatabase(db);
|
|
350
|
+
} finally {
|
|
351
|
+
db.close();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function isBusyError(error) {
|
|
355
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
356
|
+
const code = String(error.code);
|
|
357
|
+
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
return error instanceof Error && /\b(?:busy|locked)\b/i.test(error.message);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/db/migration-lock.ts
|
|
364
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
365
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
366
|
+
import { hostname } from "os";
|
|
367
|
+
function migrationLockPath(databasePath) {
|
|
368
|
+
return `${databasePath}.migration.lock`;
|
|
369
|
+
}
|
|
370
|
+
function acquireMigrationLock(databasePath, targetSchema, timeoutMs = 30000) {
|
|
371
|
+
const path = migrationLockPath(databasePath);
|
|
372
|
+
const owner = {
|
|
373
|
+
ownerID: randomUUID2(),
|
|
374
|
+
pid: process.pid,
|
|
375
|
+
processStartMarker: processStartMarker(process.pid) ?? "unavailable",
|
|
376
|
+
hostname: hostname(),
|
|
377
|
+
startedAt: Date.now(),
|
|
378
|
+
targetSchema
|
|
379
|
+
};
|
|
380
|
+
const deadline = Date.now() + timeoutMs;
|
|
381
|
+
const stagedOwner = `${path}.owner-${owner.ownerID}.tmp`;
|
|
382
|
+
writeFileSync2(stagedOwner, `${JSON.stringify(owner, null, 2)}
|
|
383
|
+
`, { mode: 384 });
|
|
384
|
+
try {
|
|
385
|
+
while (true) {
|
|
386
|
+
let created = false;
|
|
387
|
+
try {
|
|
388
|
+
mkdirSync2(path, { mode: 448 });
|
|
389
|
+
created = true;
|
|
390
|
+
renameSync2(stagedOwner, `${path}/owner.json`);
|
|
391
|
+
break;
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (created) {
|
|
394
|
+
rmSync2(path, { recursive: true, force: true });
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
if (!existsSync2(path))
|
|
398
|
+
throw error;
|
|
399
|
+
if (Date.now() >= deadline) {
|
|
400
|
+
const current = readMigrationLockOwner(path);
|
|
401
|
+
throw new Error(`migration lock is held${current ? ` by ${current.ownerID} (pid ${current.pid})` : ""}`);
|
|
402
|
+
}
|
|
403
|
+
Bun.sleepSync(Math.min(250, Math.max(25, deadline - Date.now())));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} finally {
|
|
407
|
+
rmSync2(stagedOwner, { force: true });
|
|
408
|
+
}
|
|
409
|
+
let released = false;
|
|
410
|
+
return {
|
|
411
|
+
path,
|
|
412
|
+
owner,
|
|
413
|
+
release() {
|
|
414
|
+
if (released)
|
|
415
|
+
return;
|
|
416
|
+
const current = readMigrationLockOwner(path);
|
|
417
|
+
if (current?.ownerID !== owner.ownerID) {
|
|
418
|
+
throw new Error("migration lock ownership changed before release");
|
|
419
|
+
}
|
|
420
|
+
rmSync2(path, { recursive: true, force: true });
|
|
421
|
+
released = true;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function readMigrationLockOwner(path) {
|
|
426
|
+
try {
|
|
427
|
+
return JSON.parse(readFileSync2(`${path}/owner.json`, "utf8"));
|
|
428
|
+
} catch {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function processStartMarker(pid) {
|
|
433
|
+
try {
|
|
434
|
+
const fields = readFileSync2(`/proc/${pid}/stat`, "utf8").trim().split(/\s+/);
|
|
435
|
+
return fields[21];
|
|
436
|
+
} catch {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/db/migrations/v009.ts
|
|
442
|
+
import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
|
|
443
|
+
|
|
444
|
+
// src/db/schema.ts
|
|
445
|
+
var SCHEMA_V9_TABLES = `
|
|
446
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
447
|
+
id TEXT PRIMARY KEY,
|
|
448
|
+
name TEXT NOT NULL,
|
|
449
|
+
normalized_name TEXT NOT NULL UNIQUE,
|
|
450
|
+
created_at INTEGER NOT NULL,
|
|
451
|
+
updated_at INTEGER NOT NULL
|
|
452
|
+
);
|
|
453
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
454
|
+
id TEXT PRIMARY KEY,
|
|
455
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
456
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
457
|
+
title TEXT NOT NULL,
|
|
458
|
+
summary TEXT NOT NULL,
|
|
459
|
+
content TEXT NOT NULL,
|
|
460
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
461
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
462
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
463
|
+
supersedes_id TEXT,
|
|
464
|
+
current_revision INTEGER NOT NULL DEFAULT 1 CHECK (current_revision >= 1),
|
|
465
|
+
subject_key TEXT,
|
|
466
|
+
content_hash TEXT NOT NULL CHECK (length(content_hash) = 64),
|
|
467
|
+
created_at INTEGER NOT NULL,
|
|
468
|
+
updated_at INTEGER NOT NULL,
|
|
469
|
+
UNIQUE(project_id, id)
|
|
470
|
+
);
|
|
471
|
+
CREATE INDEX IF NOT EXISTS notes_project_idx ON notes(project_id, status);
|
|
472
|
+
CREATE UNIQUE INDEX IF NOT EXISTS notes_active_subject_idx
|
|
473
|
+
ON notes(project_id, kind, subject_key)
|
|
474
|
+
WHERE status = 'active' AND subject_key IS NOT NULL;
|
|
475
|
+
CREATE TABLE IF NOT EXISTS note_edges (
|
|
476
|
+
id TEXT PRIMARY KEY,
|
|
477
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
478
|
+
source_id TEXT NOT NULL,
|
|
479
|
+
target_id TEXT NOT NULL,
|
|
480
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
481
|
+
created_at INTEGER NOT NULL,
|
|
482
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
483
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes(project_id, id) ON DELETE CASCADE,
|
|
484
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes(project_id, id) ON DELETE CASCADE
|
|
485
|
+
);
|
|
486
|
+
CREATE INDEX IF NOT EXISTS note_edges_source_idx ON note_edges(project_id, source_id);
|
|
487
|
+
CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, target_id);
|
|
488
|
+
CREATE TABLE IF NOT EXISTS project_bindings (
|
|
489
|
+
binding_key TEXT PRIMARY KEY,
|
|
490
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
491
|
+
source TEXT NOT NULL CHECK (source = 'opencode-v2'),
|
|
492
|
+
source_project_id TEXT NOT NULL,
|
|
493
|
+
workspace_id TEXT NOT NULL,
|
|
494
|
+
canonical_path_hash TEXT NOT NULL CHECK (length(canonical_path_hash) = 64),
|
|
495
|
+
created_at INTEGER NOT NULL,
|
|
496
|
+
updated_at INTEGER NOT NULL,
|
|
497
|
+
UNIQUE(source, source_project_id, workspace_id)
|
|
498
|
+
);
|
|
499
|
+
CREATE TABLE IF NOT EXISTS capture_checkpoints (
|
|
500
|
+
session_id TEXT PRIMARY KEY,
|
|
501
|
+
binding_key TEXT NOT NULL REFERENCES project_bindings(binding_key) ON DELETE CASCADE,
|
|
502
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
503
|
+
state TEXT NOT NULL CHECK (state IN ('active','idle','unavailable','closed')),
|
|
504
|
+
last_message_id TEXT,
|
|
505
|
+
last_reconciled_at INTEGER,
|
|
506
|
+
next_reconcile_at INTEGER NOT NULL,
|
|
507
|
+
failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0),
|
|
508
|
+
lease_owner TEXT,
|
|
509
|
+
lease_expires_at INTEGER,
|
|
510
|
+
created_at INTEGER NOT NULL,
|
|
511
|
+
updated_at INTEGER NOT NULL
|
|
512
|
+
);
|
|
513
|
+
CREATE INDEX IF NOT EXISTS capture_checkpoints_due_idx
|
|
514
|
+
ON capture_checkpoints(state, next_reconcile_at);
|
|
515
|
+
CREATE TABLE IF NOT EXISTS capture_events (
|
|
516
|
+
idempotency_key TEXT PRIMARY KEY,
|
|
517
|
+
contract TEXT NOT NULL CHECK (contract = 'opencode2-memory.capture/1'),
|
|
518
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
519
|
+
binding_key TEXT NOT NULL REFERENCES project_bindings(binding_key) ON DELETE CASCADE,
|
|
520
|
+
event_kind TEXT NOT NULL CHECK (event_kind IN ('user-candidate','assistant-candidate','session-summary','tool-signal')),
|
|
521
|
+
source_session_id TEXT NOT NULL,
|
|
522
|
+
source_message_id TEXT,
|
|
523
|
+
source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
|
|
524
|
+
source_tool_call_id TEXT,
|
|
525
|
+
payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
|
|
526
|
+
payload_hash TEXT,
|
|
527
|
+
redaction_version TEXT NOT NULL,
|
|
528
|
+
state TEXT NOT NULL CHECK (state IN ('pending','shadowed','review','materialized','duplicate','ignored','rejected','quarantined','failed','dead')),
|
|
529
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
530
|
+
note_id TEXT,
|
|
531
|
+
last_error_code TEXT,
|
|
532
|
+
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0),
|
|
533
|
+
created_at INTEGER NOT NULL,
|
|
534
|
+
updated_at INTEGER NOT NULL,
|
|
535
|
+
processed_at INTEGER
|
|
536
|
+
);
|
|
537
|
+
CREATE INDEX IF NOT EXISTS capture_events_state_idx ON capture_events(state, updated_at);
|
|
538
|
+
CREATE INDEX IF NOT EXISTS capture_events_session_idx ON capture_events(project_id, source_session_id);
|
|
539
|
+
CREATE INDEX IF NOT EXISTS capture_events_note_idx ON capture_events(project_id, note_id);
|
|
540
|
+
CREATE TABLE IF NOT EXISTS note_provenance (
|
|
541
|
+
id TEXT PRIMARY KEY,
|
|
542
|
+
project_id TEXT NOT NULL,
|
|
543
|
+
note_id TEXT NOT NULL,
|
|
544
|
+
source_type TEXT NOT NULL CHECK (source_type IN ('mcp-manual','opencode-capture','migration','legacy-import','admin')),
|
|
545
|
+
capture_event_id TEXT,
|
|
546
|
+
source_session_id TEXT,
|
|
547
|
+
source_message_id TEXT,
|
|
548
|
+
source_ordinal INTEGER,
|
|
549
|
+
source_tool_call_id TEXT,
|
|
550
|
+
redaction_version TEXT,
|
|
551
|
+
extractor_version TEXT,
|
|
552
|
+
confidence REAL CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1)),
|
|
553
|
+
created_at INTEGER NOT NULL,
|
|
554
|
+
UNIQUE(project_id, id),
|
|
555
|
+
FOREIGN KEY (project_id, note_id) REFERENCES notes(project_id, id) ON DELETE CASCADE
|
|
556
|
+
);
|
|
557
|
+
CREATE TABLE IF NOT EXISTS note_revisions (
|
|
558
|
+
project_id TEXT NOT NULL,
|
|
559
|
+
note_id TEXT NOT NULL,
|
|
560
|
+
revision INTEGER NOT NULL CHECK (revision >= 1),
|
|
561
|
+
kind TEXT NOT NULL,
|
|
562
|
+
title TEXT NOT NULL,
|
|
563
|
+
summary TEXT NOT NULL,
|
|
564
|
+
content TEXT NOT NULL,
|
|
565
|
+
size_class TEXT NOT NULL,
|
|
566
|
+
pinned INTEGER NOT NULL CHECK (pinned IN (0,1)),
|
|
567
|
+
status TEXT NOT NULL CHECK (status IN ('active','superseded','archived')),
|
|
568
|
+
supersedes_id TEXT,
|
|
569
|
+
subject_key TEXT,
|
|
570
|
+
content_hash TEXT NOT NULL,
|
|
571
|
+
provenance_id TEXT NOT NULL,
|
|
572
|
+
created_at INTEGER NOT NULL,
|
|
573
|
+
PRIMARY KEY(project_id, note_id, revision),
|
|
574
|
+
FOREIGN KEY (project_id, note_id) REFERENCES notes(project_id, id) ON DELETE CASCADE,
|
|
575
|
+
FOREIGN KEY (project_id, provenance_id) REFERENCES note_provenance(project_id, id)
|
|
576
|
+
);
|
|
577
|
+
CREATE TABLE IF NOT EXISTS index_outbox (
|
|
578
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
579
|
+
backend TEXT NOT NULL,
|
|
580
|
+
operation TEXT NOT NULL CHECK (operation IN ('upsert-note','delete-note','purge-project')),
|
|
581
|
+
project_id TEXT NOT NULL,
|
|
582
|
+
note_id TEXT,
|
|
583
|
+
revision INTEGER,
|
|
584
|
+
content_hash TEXT,
|
|
585
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','leased','succeeded','dead')),
|
|
586
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
587
|
+
available_at INTEGER NOT NULL,
|
|
588
|
+
lease_owner TEXT,
|
|
589
|
+
lease_expires_at INTEGER,
|
|
590
|
+
last_error_code TEXT,
|
|
591
|
+
created_at INTEGER NOT NULL,
|
|
592
|
+
completed_at INTEGER,
|
|
593
|
+
UNIQUE(backend, operation, project_id, note_id, revision)
|
|
594
|
+
);
|
|
595
|
+
CREATE INDEX IF NOT EXISTS index_outbox_due_idx
|
|
596
|
+
ON index_outbox(backend, project_id, state, available_at, id);
|
|
597
|
+
CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
|
|
598
|
+
`;
|
|
599
|
+
var FTS_V9 = `
|
|
600
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
601
|
+
title, summary, content,
|
|
602
|
+
content='notes', content_rowid='rowid',
|
|
603
|
+
tokenize='unicode61'
|
|
604
|
+
);
|
|
605
|
+
CREATE TRIGGER IF NOT EXISTS notes_fts_ai AFTER INSERT ON notes BEGIN
|
|
606
|
+
INSERT INTO notes_fts(rowid, title, summary, content)
|
|
607
|
+
VALUES (new.rowid, new.title, new.summary, new.content);
|
|
608
|
+
END;
|
|
609
|
+
CREATE TRIGGER IF NOT EXISTS notes_fts_ad AFTER DELETE ON notes BEGIN
|
|
610
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, summary, content)
|
|
611
|
+
VALUES ('delete', old.rowid, old.title, old.summary, old.content);
|
|
612
|
+
END;
|
|
613
|
+
CREATE TRIGGER IF NOT EXISTS notes_fts_au AFTER UPDATE OF title, summary, content ON notes BEGIN
|
|
614
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, summary, content)
|
|
615
|
+
VALUES ('delete', old.rowid, old.title, old.summary, old.content);
|
|
616
|
+
INSERT INTO notes_fts(rowid, title, summary, content)
|
|
617
|
+
VALUES (new.rowid, new.title, new.summary, new.content);
|
|
618
|
+
END;
|
|
619
|
+
`;
|
|
620
|
+
function createSchemaV9(db) {
|
|
621
|
+
db.exec(SCHEMA_V9_TABLES);
|
|
622
|
+
db.exec(FTS_V9);
|
|
623
|
+
db.query("DELETE FROM schema_state").run();
|
|
624
|
+
db.query("INSERT INTO schema_state(version) VALUES (9)").run();
|
|
625
|
+
}
|
|
626
|
+
function rebuildFts(db) {
|
|
627
|
+
db.query("INSERT INTO notes_fts(notes_fts) VALUES ('rebuild')").run();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/db/migrations/v009.ts
|
|
631
|
+
function migrateV8ToV9(db) {
|
|
632
|
+
const notes = db.query("SELECT * FROM notes ORDER BY rowid").all();
|
|
633
|
+
db.exec(`
|
|
634
|
+
DROP TABLE IF EXISTS capture_checkpoints;
|
|
635
|
+
DROP TABLE IF EXISTS capture_events;
|
|
636
|
+
DROP TABLE IF EXISTS project_bindings;
|
|
637
|
+
DROP TABLE IF EXISTS note_revisions;
|
|
638
|
+
DROP TABLE IF EXISTS note_provenance;
|
|
639
|
+
DROP TABLE IF EXISTS index_outbox;
|
|
640
|
+
DROP TABLE IF EXISTS note_edges_v9;
|
|
641
|
+
DROP TABLE IF EXISTS notes_v9;
|
|
642
|
+
`);
|
|
643
|
+
db.exec(`
|
|
644
|
+
CREATE TABLE notes_v9 (
|
|
645
|
+
id TEXT PRIMARY KEY,
|
|
646
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
647
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
648
|
+
title TEXT NOT NULL,
|
|
649
|
+
summary TEXT NOT NULL,
|
|
650
|
+
content TEXT NOT NULL,
|
|
651
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
652
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
653
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
654
|
+
supersedes_id TEXT,
|
|
655
|
+
current_revision INTEGER NOT NULL DEFAULT 1 CHECK (current_revision >= 1),
|
|
656
|
+
subject_key TEXT,
|
|
657
|
+
content_hash TEXT NOT NULL CHECK (length(content_hash) = 64),
|
|
658
|
+
created_at INTEGER NOT NULL,
|
|
659
|
+
updated_at INTEGER NOT NULL,
|
|
660
|
+
UNIQUE(project_id, id)
|
|
661
|
+
);
|
|
662
|
+
CREATE TABLE note_edges_v9 (
|
|
663
|
+
id TEXT PRIMARY KEY,
|
|
664
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
665
|
+
source_id TEXT NOT NULL,
|
|
666
|
+
target_id TEXT NOT NULL,
|
|
667
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
668
|
+
created_at INTEGER NOT NULL,
|
|
669
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
670
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes_v9(project_id, id) ON DELETE CASCADE,
|
|
671
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes_v9(project_id, id) ON DELETE CASCADE
|
|
672
|
+
);
|
|
673
|
+
INSERT INTO note_edges_v9 SELECT * FROM note_edges;
|
|
674
|
+
`);
|
|
675
|
+
const insert = db.query(`
|
|
676
|
+
INSERT INTO notes_v9
|
|
677
|
+
(id, project_id, kind, title, summary, content, size_class, pinned, status,
|
|
678
|
+
supersedes_id, current_revision, subject_key, content_hash, created_at, updated_at)
|
|
679
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, ?, ?, ?)
|
|
680
|
+
`);
|
|
681
|
+
const hashes = new Map;
|
|
682
|
+
for (const note of notes) {
|
|
683
|
+
const hash = noteContentHash(note.kind, note.title, note.summary, note.content);
|
|
684
|
+
hashes.set(note.id, hash);
|
|
685
|
+
insert.run(note.id, note.project_id, note.kind, note.title, note.summary, note.content, note.size_class, note.pinned, note.status, note.supersedes_id, hash, note.created_at, note.updated_at);
|
|
686
|
+
}
|
|
687
|
+
db.exec(`
|
|
688
|
+
DROP TRIGGER IF EXISTS notes_fts_ai;
|
|
689
|
+
DROP TRIGGER IF EXISTS notes_fts_ad;
|
|
690
|
+
DROP TRIGGER IF EXISTS notes_fts_au;
|
|
691
|
+
DROP TABLE IF EXISTS notes_fts;
|
|
692
|
+
DROP TABLE note_edges;
|
|
693
|
+
DROP TABLE notes;
|
|
694
|
+
ALTER TABLE notes_v9 RENAME TO notes;
|
|
695
|
+
ALTER TABLE note_edges_v9 RENAME TO note_edges;
|
|
696
|
+
`);
|
|
697
|
+
db.exec(SCHEMA_V9_TABLES);
|
|
698
|
+
for (const note of notes) {
|
|
699
|
+
const provenanceID = randomUUID3();
|
|
700
|
+
db.query(`
|
|
701
|
+
INSERT INTO note_provenance
|
|
702
|
+
(id, project_id, note_id, source_type, created_at)
|
|
703
|
+
VALUES (?, ?, ?, 'migration', ?)
|
|
704
|
+
`).run(provenanceID, note.project_id, note.id, note.updated_at);
|
|
705
|
+
db.query(`
|
|
706
|
+
INSERT INTO note_revisions
|
|
707
|
+
(project_id, note_id, revision, kind, title, summary, content, size_class,
|
|
708
|
+
pinned, status, supersedes_id, subject_key, content_hash, provenance_id, created_at)
|
|
709
|
+
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
|
|
710
|
+
`).run(note.project_id, note.id, note.kind, note.title, note.summary, note.content, note.size_class, note.pinned, note.status, note.supersedes_id, hashes.get(note.id), provenanceID, note.updated_at);
|
|
711
|
+
}
|
|
712
|
+
db.exec(FTS_V9);
|
|
713
|
+
rebuildFts(db);
|
|
714
|
+
db.query("DELETE FROM schema_state").run();
|
|
715
|
+
db.query("INSERT INTO schema_state(version) VALUES (9)").run();
|
|
716
|
+
}
|
|
717
|
+
function noteContentHash(kind, title, summary, content) {
|
|
718
|
+
return createHash3("sha256").update(`${kind}\x00${title}\x00${summary}\x00${content}`, "utf8").digest("hex");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// src/db.ts
|
|
722
|
+
var DDL = `
|
|
723
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
724
|
+
id TEXT PRIMARY KEY,
|
|
725
|
+
name TEXT NOT NULL,
|
|
726
|
+
normalized_name TEXT NOT NULL UNIQUE,
|
|
727
|
+
created_at INTEGER NOT NULL,
|
|
728
|
+
updated_at INTEGER NOT NULL
|
|
729
|
+
);
|
|
730
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
731
|
+
id TEXT PRIMARY KEY,
|
|
732
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
733
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
734
|
+
title TEXT NOT NULL,
|
|
735
|
+
summary TEXT NOT NULL,
|
|
736
|
+
content TEXT NOT NULL,
|
|
737
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
738
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
739
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
740
|
+
supersedes_id TEXT,
|
|
741
|
+
created_at INTEGER NOT NULL,
|
|
742
|
+
updated_at INTEGER NOT NULL,
|
|
743
|
+
UNIQUE(project_id, id)
|
|
744
|
+
);
|
|
745
|
+
CREATE INDEX IF NOT EXISTS notes_project_idx ON notes(project_id, status);
|
|
746
|
+
CREATE TABLE IF NOT EXISTS note_edges (
|
|
747
|
+
id TEXT PRIMARY KEY,
|
|
748
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
749
|
+
source_id TEXT NOT NULL,
|
|
750
|
+
target_id TEXT NOT NULL,
|
|
751
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
752
|
+
created_at INTEGER NOT NULL,
|
|
753
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
754
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes(project_id, id) ON DELETE CASCADE,
|
|
755
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes(project_id, id) ON DELETE CASCADE
|
|
756
|
+
);
|
|
757
|
+
CREATE INDEX IF NOT EXISTS note_edges_source_idx ON note_edges(project_id, source_id);
|
|
758
|
+
CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, target_id);
|
|
759
|
+
CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
|
|
760
|
+
`;
|
|
761
|
+
function openMemoryDatabase(path) {
|
|
762
|
+
const db = new Database2(path, { create: true });
|
|
763
|
+
chmodSync2(path, 384);
|
|
764
|
+
let lock;
|
|
765
|
+
let backup;
|
|
766
|
+
try {
|
|
767
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
768
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
769
|
+
const existingVersion = getSchemaVersion(db);
|
|
770
|
+
if (existingVersion && existingVersion.version > SCHEMA_VERSION) {
|
|
771
|
+
throw new Error(`database schema v${existingVersion.version} is newer than supported v${SCHEMA_VERSION}`);
|
|
772
|
+
}
|
|
773
|
+
const hasExistingData = hasLegacyV2(db) || hasTable2(db, "notes") || Boolean(existingVersion);
|
|
774
|
+
if (!hasExistingData) {
|
|
775
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
776
|
+
db.transaction(() => createSchemaV9(db))();
|
|
777
|
+
assertHealthyDatabase(db);
|
|
778
|
+
return { db, close: () => db.close() };
|
|
779
|
+
}
|
|
780
|
+
if ((existingVersion?.version ?? 0) < SCHEMA_VERSION) {
|
|
781
|
+
lock = acquireMigrationLock(path, SCHEMA_VERSION);
|
|
782
|
+
backup = createVerifiedBackup(db, path, existingVersion?.version ?? 2, SCHEMA_VERSION, "0.4.0-beta.1");
|
|
783
|
+
db.exec("PRAGMA foreign_keys=OFF");
|
|
784
|
+
if (!existingVersion && hasLegacyV2(db)) {
|
|
785
|
+
db.exec(DDL);
|
|
786
|
+
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
787
|
+
migrateFromV2(db, path);
|
|
788
|
+
} else if (!existingVersion) {
|
|
789
|
+
db.exec(DDL);
|
|
790
|
+
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
791
|
+
db.transaction(() => {
|
|
792
|
+
adoptLegacyProjectIDs(db);
|
|
793
|
+
db.query("DELETE FROM schema_state").run();
|
|
794
|
+
db.query("INSERT INTO schema_state (version) VALUES (8)").run();
|
|
795
|
+
})();
|
|
796
|
+
} else if (existingVersion.version < 8) {
|
|
797
|
+
db.exec(DDL);
|
|
798
|
+
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
799
|
+
migrateToV8(db);
|
|
800
|
+
}
|
|
801
|
+
const version = getSchemaVersion(db)?.version ?? 8;
|
|
802
|
+
if (version < 9)
|
|
803
|
+
db.transaction(() => migrateV8ToV9(db))();
|
|
804
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
805
|
+
if (db.query("PRAGMA foreign_keys").get().foreign_keys !== 1) {
|
|
806
|
+
throw new Error("failed to enable database foreign keys");
|
|
807
|
+
}
|
|
808
|
+
assertHealthyDatabase(db);
|
|
809
|
+
console.warn(`[agz-memory] migrated to v${SCHEMA_VERSION} (backup: ${backup.manifestPath})`);
|
|
810
|
+
lock.release();
|
|
811
|
+
lock = undefined;
|
|
812
|
+
return { db, close: () => db.close() };
|
|
813
|
+
}
|
|
814
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
815
|
+
db.exec(SCHEMA_V9_TABLES);
|
|
816
|
+
db.exec(FTS_V9);
|
|
817
|
+
assertHealthyDatabase(db);
|
|
818
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
819
|
+
return { db, close: () => db.close() };
|
|
820
|
+
} catch (error) {
|
|
821
|
+
db.close();
|
|
822
|
+
if (backup) {
|
|
823
|
+
try {
|
|
824
|
+
restoreVerifiedBackup(backup.manifestPath, path, "RESTORE_DATABASE_FROM_VERIFIED_BACKUP");
|
|
825
|
+
} catch (restoreError) {
|
|
826
|
+
throw new AggregateError([error, restoreError], "migration and automatic restore failed");
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
throw error;
|
|
830
|
+
} finally {
|
|
831
|
+
lock?.release();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function getSchemaVersion(db) {
|
|
835
|
+
if (!hasTable2(db, "schema_state"))
|
|
836
|
+
return;
|
|
837
|
+
return db.query("SELECT version FROM schema_state ORDER BY version DESC LIMIT 1").get();
|
|
838
|
+
}
|
|
839
|
+
function migrateToV8(db) {
|
|
840
|
+
db.transaction(() => {
|
|
841
|
+
adoptLegacyProjectIDs(db);
|
|
842
|
+
const pinned = hasColumn(db, "notes", "pinned") ? "pinned" : "0";
|
|
843
|
+
db.exec(`
|
|
844
|
+
CREATE TABLE notes_v7 (
|
|
845
|
+
id TEXT PRIMARY KEY,
|
|
846
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
847
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
848
|
+
title TEXT NOT NULL,
|
|
849
|
+
summary TEXT NOT NULL,
|
|
850
|
+
content TEXT NOT NULL,
|
|
851
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
852
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
853
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
854
|
+
supersedes_id TEXT,
|
|
855
|
+
created_at INTEGER NOT NULL,
|
|
856
|
+
updated_at INTEGER NOT NULL,
|
|
857
|
+
UNIQUE(project_id, id)
|
|
858
|
+
);
|
|
859
|
+
INSERT INTO notes_v7
|
|
860
|
+
(id, project_id, kind, title, summary, content, size_class, pinned, status, supersedes_id, created_at, updated_at)
|
|
861
|
+
SELECT id, project_id, kind, title, summary, content, size_class, ${pinned}, status, supersedes_id, created_at, updated_at
|
|
862
|
+
FROM notes;
|
|
863
|
+
CREATE TABLE note_edges_v7 (
|
|
864
|
+
id TEXT PRIMARY KEY,
|
|
865
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
866
|
+
source_id TEXT NOT NULL,
|
|
867
|
+
target_id TEXT NOT NULL,
|
|
868
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
869
|
+
created_at INTEGER NOT NULL,
|
|
870
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
871
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes_v7(project_id, id) ON DELETE CASCADE,
|
|
872
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes_v7(project_id, id) ON DELETE CASCADE
|
|
873
|
+
);
|
|
874
|
+
INSERT OR IGNORE INTO note_edges_v7
|
|
875
|
+
(id, project_id, source_id, target_id, predicate, created_at)
|
|
876
|
+
SELECT e.id, source.project_id, e.source_id, e.target_id, e.predicate, e.created_at
|
|
877
|
+
FROM note_edges e
|
|
878
|
+
JOIN notes source ON source.id = e.source_id
|
|
879
|
+
JOIN notes target ON target.id = e.target_id
|
|
880
|
+
WHERE source.project_id = target.project_id;
|
|
881
|
+
DROP TABLE note_edges;
|
|
882
|
+
DROP TABLE notes;
|
|
883
|
+
ALTER TABLE notes_v7 RENAME TO notes;
|
|
884
|
+
ALTER TABLE note_edges_v7 RENAME TO note_edges;
|
|
885
|
+
`);
|
|
886
|
+
importLegacyAssociations(db);
|
|
887
|
+
db.query("DELETE FROM schema_state").run();
|
|
888
|
+
db.query("INSERT INTO schema_state (version) VALUES (8)").run();
|
|
889
|
+
})();
|
|
890
|
+
}
|
|
891
|
+
function adoptLegacyProjectIDs(db) {
|
|
892
|
+
const existingProjects = db.query("SELECT id FROM projects").all();
|
|
893
|
+
for (const { id: legacyID } of existingProjects) {
|
|
894
|
+
if (isUUID(legacyID))
|
|
895
|
+
continue;
|
|
896
|
+
const id = randomUUID4();
|
|
897
|
+
db.query("UPDATE projects SET id = ? WHERE id = ?").run(id, legacyID);
|
|
898
|
+
db.query("UPDATE notes SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
899
|
+
db.query("UPDATE note_edges SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
900
|
+
}
|
|
901
|
+
const rows = db.query("SELECT DISTINCT project_id FROM notes").all();
|
|
902
|
+
for (const { project_id: legacyID } of rows) {
|
|
903
|
+
if (db.query("SELECT id FROM projects WHERE id = ?").get(legacyID))
|
|
904
|
+
continue;
|
|
905
|
+
const id = randomUUID4();
|
|
906
|
+
const name = uniqueLegacyProjectName(db, legacyID);
|
|
907
|
+
const now = Date.now();
|
|
908
|
+
db.query("INSERT INTO projects (id, name, normalized_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(id, name, normalizeProjectName(name), now, now);
|
|
909
|
+
db.query("UPDATE notes SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
910
|
+
db.query("UPDATE note_edges SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function isUUID(value) {
|
|
914
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
915
|
+
}
|
|
916
|
+
function uniqueLegacyProjectName(db, legacyID) {
|
|
917
|
+
const base = legacyID === "global" ? "Legacy Global" : legacyID === "legacy" ? "Legacy" : `Legacy ${legacyID.slice(0, 12)}`;
|
|
918
|
+
let name = base;
|
|
919
|
+
let suffix = 2;
|
|
920
|
+
while (db.query("SELECT id FROM projects WHERE normalized_name = ?").get(normalizeProjectName(name))) {
|
|
921
|
+
name = `${base} ${suffix++}`;
|
|
922
|
+
}
|
|
923
|
+
return name;
|
|
924
|
+
}
|
|
925
|
+
function hasColumn(db, table, column) {
|
|
926
|
+
const rows = db.query(`PRAGMA table_info(${table})`).all();
|
|
927
|
+
return rows.some((row) => row.name === column);
|
|
928
|
+
}
|
|
929
|
+
function hasLegacyV2(db) {
|
|
930
|
+
return hasTable2(db, "memory_items");
|
|
931
|
+
}
|
|
932
|
+
function hasTable2(db, table) {
|
|
933
|
+
const row = db.query("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name = ?").get(table);
|
|
934
|
+
return (row?.n ?? 0) > 0;
|
|
935
|
+
}
|
|
936
|
+
var KIND_MAP = {
|
|
937
|
+
decision: "decision",
|
|
938
|
+
fact: "fact",
|
|
939
|
+
observation: "fact",
|
|
940
|
+
experiment: "fact",
|
|
941
|
+
hypothesis: "fact",
|
|
942
|
+
open_question: "fact",
|
|
943
|
+
rule: "fact",
|
|
944
|
+
direction: "fact",
|
|
945
|
+
constraint: "fact",
|
|
946
|
+
procedure: "procedure",
|
|
947
|
+
failure_remedy: "procedure",
|
|
948
|
+
agent_behavior: "procedure",
|
|
949
|
+
context: "context",
|
|
950
|
+
preference: "preference"
|
|
951
|
+
};
|
|
952
|
+
function migrateFromV2(db, path) {
|
|
953
|
+
const requiredTables = ["memory_items", "memory_versions", "memory_identities"];
|
|
954
|
+
const missingTables = requiredTables.filter((table) => !hasTable2(db, table));
|
|
955
|
+
if (missingTables.length > 0) {
|
|
956
|
+
throw new Error(`unsupported legacy schema; missing tables: ${missingTables.join(", ")}`);
|
|
957
|
+
}
|
|
958
|
+
const backup = `${path}.v2-backup`;
|
|
959
|
+
if (!existsSync3(backup)) {
|
|
960
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
961
|
+
copyFileSync2(path, backup);
|
|
962
|
+
}
|
|
963
|
+
db.transaction(() => {
|
|
964
|
+
migrateFromV2Data(db, backup, {
|
|
965
|
+
documents: ["document_sources", "document_chunks", "memories"].every((table) => hasTable2(db, table)),
|
|
966
|
+
links: hasTable2(db, "memory_links"),
|
|
967
|
+
edges: hasTable2(db, "memory_edges")
|
|
968
|
+
});
|
|
969
|
+
adoptLegacyProjectIDs(db);
|
|
970
|
+
db.query("DELETE FROM schema_state").run();
|
|
971
|
+
db.query("INSERT INTO schema_state (version) VALUES (8)").run();
|
|
972
|
+
})();
|
|
973
|
+
}
|
|
974
|
+
function migrateFromV2Data(db, backup, options) {
|
|
975
|
+
const now = Date.now();
|
|
976
|
+
db.query("DELETE FROM notes_fts").run();
|
|
977
|
+
db.query("DELETE FROM note_edges").run();
|
|
978
|
+
db.query("DELETE FROM notes").run();
|
|
979
|
+
db.query("DELETE FROM projects").run();
|
|
980
|
+
const items = db.query(`SELECT i.id AS item_id, i.subject_key, i.kind, i.created_at, i.updated_at,
|
|
981
|
+
i.identity_id, v.summary, v.content
|
|
982
|
+
FROM memory_items i
|
|
983
|
+
LEFT JOIN memory_versions v ON v.id = i.current_version_id
|
|
984
|
+
WHERE i.lifecycle_state = 'active'`).all();
|
|
985
|
+
const identities = new Map;
|
|
986
|
+
for (const row of db.query("SELECT id, project_id FROM memory_identities").all()) {
|
|
987
|
+
if (row.project_id)
|
|
988
|
+
identities.set(row.id, row.project_id);
|
|
989
|
+
}
|
|
990
|
+
let migratedNotes = 0;
|
|
991
|
+
for (const item of items) {
|
|
992
|
+
const projectID = identities.get(item.identity_id) ?? "legacy";
|
|
993
|
+
const content = item.content ?? item.summary ?? "";
|
|
994
|
+
const summary = item.summary ?? content.slice(0, 200);
|
|
995
|
+
const title = item.subject_key;
|
|
996
|
+
const kind = KIND_MAP[item.kind] ?? "fact";
|
|
997
|
+
const sizeClass = content.length <= 1200 ? "inline" : "indexed";
|
|
998
|
+
db.query(`INSERT INTO notes (id, project_id, kind, title, summary, content, size_class, status, supersedes_id, created_at, updated_at)
|
|
999
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', NULL, ?, ?)`).run(item.item_id, projectID, kind, title, summary, content, sizeClass, item.created_at, item.updated_at);
|
|
1000
|
+
db.query("INSERT INTO notes_fts (id, title, summary, content) VALUES (?, ?, ?, ?)").run(item.item_id, title, summary, content);
|
|
1001
|
+
migratedNotes++;
|
|
1002
|
+
}
|
|
1003
|
+
const sources = options.documents ? db.query(`SELECT s.id, s.project_root, s.title, s.created_at, s.updated_at,
|
|
1004
|
+
GROUP_CONCAT(m.content, '
|
|
1005
|
+
|
|
1006
|
+
') AS body
|
|
1007
|
+
FROM document_sources s
|
|
1008
|
+
JOIN document_chunks c ON c.source_id = s.id
|
|
1009
|
+
JOIN memories m ON m.id = c.memory_id
|
|
1010
|
+
WHERE s.status = 'active'
|
|
1011
|
+
GROUP BY s.id
|
|
1012
|
+
ORDER BY s.created_at`).all() : [];
|
|
1013
|
+
for (const source of sources) {
|
|
1014
|
+
const content = source.body ?? "";
|
|
1015
|
+
if (!content.trim())
|
|
1016
|
+
continue;
|
|
1017
|
+
const id = randomUUID4();
|
|
1018
|
+
db.query(`INSERT INTO notes (id, project_id, kind, title, summary, content, size_class, status, supersedes_id, created_at, updated_at)
|
|
1019
|
+
VALUES (?, ?, 'research', ?, ?, ?, 'indexed', 'active', NULL, ?, ?)`).run(id, source.project_root ? hashRoot(source.project_root) : "legacy", source.title, content.slice(0, 200), content, source.created_at, source.updated_at);
|
|
1020
|
+
db.query("INSERT INTO notes_fts (id, title, summary, content) VALUES (?, ?, ?, ?)").run(id, source.title, content.slice(0, 200), content);
|
|
1021
|
+
migratedNotes++;
|
|
1022
|
+
}
|
|
1023
|
+
const noteIDs = new Set(db.query("SELECT id FROM notes").all().map((r) => r.id));
|
|
1024
|
+
let migratedEdges = 0;
|
|
1025
|
+
const edges = options.edges ? db.query(`SELECT id, source_item_id, target_item_id, predicate, recorded_at
|
|
1026
|
+
FROM memory_edges
|
|
1027
|
+
WHERE lifecycle_state = 'active'`).all() : [];
|
|
1028
|
+
for (const edge of edges) {
|
|
1029
|
+
if (!noteIDs.has(edge.source_item_id) || !noteIDs.has(edge.target_item_id))
|
|
1030
|
+
continue;
|
|
1031
|
+
if (edge.source_item_id === edge.target_item_id)
|
|
1032
|
+
continue;
|
|
1033
|
+
const sourceProject = noteProjectID(db, edge.source_item_id);
|
|
1034
|
+
if (sourceProject !== noteProjectID(db, edge.target_item_id))
|
|
1035
|
+
continue;
|
|
1036
|
+
const predicate = PREDICATES.includes(edge.predicate) ? edge.predicate : "ABOUT";
|
|
1037
|
+
const result = db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(`edge-${edge.id}`, sourceProject, edge.source_item_id, edge.target_item_id, predicate, edge.recorded_at);
|
|
1038
|
+
migratedEdges += result.changes;
|
|
1039
|
+
}
|
|
1040
|
+
const links = options.links ? db.query("SELECT source_memory_id, target_memory_id FROM memory_links WHERE status = 'active'").all() : [];
|
|
1041
|
+
for (const link of links) {
|
|
1042
|
+
if (!noteIDs.has(link.source_memory_id) || !noteIDs.has(link.target_memory_id))
|
|
1043
|
+
continue;
|
|
1044
|
+
if (link.source_memory_id === link.target_memory_id)
|
|
1045
|
+
continue;
|
|
1046
|
+
const sourceProject = noteProjectID(db, link.source_memory_id);
|
|
1047
|
+
if (sourceProject !== noteProjectID(db, link.target_memory_id))
|
|
1048
|
+
continue;
|
|
1049
|
+
const result = db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, 'ABOUT', ?)").run(`edge-${link.source_memory_id}-${link.target_memory_id}`, sourceProject, link.source_memory_id, link.target_memory_id, now);
|
|
1050
|
+
migratedEdges += result.changes;
|
|
1051
|
+
}
|
|
1052
|
+
migratedEdges += importLegacyAssociations(db);
|
|
1053
|
+
console.warn(`[agz-memory] v2\u2192v3 migration complete: ${migratedNotes} notes, ${migratedEdges} edges (backup: ${backup})`);
|
|
1054
|
+
}
|
|
1055
|
+
function importLegacyAssociations(db) {
|
|
1056
|
+
if (!hasTable2(db, "memory_associations"))
|
|
1057
|
+
return 0;
|
|
1058
|
+
const associations = db.query(`SELECT id, left_item_id, right_item_id, kind, created_at
|
|
1059
|
+
FROM memory_associations
|
|
1060
|
+
WHERE lifecycle_state = 'active'`).all();
|
|
1061
|
+
let imported = 0;
|
|
1062
|
+
for (const association of associations) {
|
|
1063
|
+
const source = db.query("SELECT project_id FROM notes WHERE id = ?").get(association.left_item_id);
|
|
1064
|
+
const target = db.query("SELECT project_id FROM notes WHERE id = ?").get(association.right_item_id);
|
|
1065
|
+
if (!source || !target || source.project_id !== target.project_id)
|
|
1066
|
+
continue;
|
|
1067
|
+
if (association.left_item_id === association.right_item_id)
|
|
1068
|
+
continue;
|
|
1069
|
+
const candidate = association.kind.toUpperCase();
|
|
1070
|
+
const predicate = PREDICATES.includes(candidate) ? candidate : "ABOUT";
|
|
1071
|
+
const result = db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(`association-${association.id}`, source.project_id, association.left_item_id, association.right_item_id, predicate, association.created_at);
|
|
1072
|
+
imported += result.changes;
|
|
1073
|
+
}
|
|
1074
|
+
return imported;
|
|
1075
|
+
}
|
|
1076
|
+
function noteProjectID(db, noteID) {
|
|
1077
|
+
return db.query("SELECT project_id FROM notes WHERE id = ?").get(noteID).project_id;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// src/server.ts
|
|
1081
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
1082
|
+
|
|
1083
|
+
// src/context.ts
|
|
1084
|
+
var MEMORY_GUIDANCE = `Use project-scoped memory for durable facts across sessions.
|
|
1085
|
+
- Start with project_list. Use the immutable projectID for stable references; projectName is a convenient unique lookup.
|
|
1086
|
+
- Create a project with project_create before storing its first note. Renaming a project never changes its ID.
|
|
1087
|
+
- Every memory_recall, memory_read, memory_update, memory_link, and memory_pin call must select exactly one project by projectID or projectName.
|
|
1088
|
+
- No notes are injected automatically; use memory_recall for relevant project history.
|
|
1089
|
+
- Read indexed note bodies and graph neighbors with memory_read.
|
|
1090
|
+
- Store only durable verified facts, decisions, procedures, research, preferences, or substantial completed work.
|
|
1091
|
+
- Use memory_pin to prioritize important matching notes inside their project. Pinning never moves notes between projects.
|
|
1092
|
+
- Never save transcripts, guesses, secrets, or routine progress.
|
|
1093
|
+
- project_delete permanently destroys the project and all of its memory. Call project_list first and provide the immutable ID, exact current name, and required confirmation phrase only when deletion is explicitly intended.`;
|
|
1094
|
+
|
|
1095
|
+
// src/tools.ts
|
|
1096
|
+
import * as z from "zod/v4";
|
|
1097
|
+
var MAX_BATCH = 10;
|
|
1098
|
+
var projectID = z.uuid().describe("The immutable project UUID returned by project_create or project_list.");
|
|
1099
|
+
var projectName = z.string().min(1).max(MAX_PROJECT_NAME_LENGTH).describe("The project's unique current name. Prefer projectID when retaining a long-lived reference.");
|
|
1100
|
+
var createUpdateSchema = z.object({
|
|
1101
|
+
kind: z.enum(KINDS),
|
|
1102
|
+
title: z.string(),
|
|
1103
|
+
summary: z.string(),
|
|
1104
|
+
content: z.string().optional()
|
|
1105
|
+
}).strict();
|
|
1106
|
+
var patchUpdateSchema = z.object({
|
|
1107
|
+
id: z.string(),
|
|
1108
|
+
kind: z.enum(KINDS).optional(),
|
|
1109
|
+
title: z.string().optional(),
|
|
1110
|
+
summary: z.string().optional(),
|
|
1111
|
+
content: z.string().optional(),
|
|
1112
|
+
delete: z.boolean().optional()
|
|
1113
|
+
}).strict();
|
|
1114
|
+
var updateSchema = z.union([createUpdateSchema, patchUpdateSchema]);
|
|
1115
|
+
var linkSchema = z.object({
|
|
1116
|
+
sourceID: z.string(),
|
|
1117
|
+
targetID: z.string(),
|
|
1118
|
+
predicate: z.enum(PREDICATES)
|
|
1119
|
+
}).strict();
|
|
1120
|
+
function textResult(value) {
|
|
1121
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
1122
|
+
}
|
|
1123
|
+
function resolveProject(store, selector) {
|
|
1124
|
+
const resolved = store.resolveProject(selector);
|
|
1125
|
+
return resolved.project ? { project: resolved.project } : { error: { ok: false, reason: resolved.reason ?? "project not found" } };
|
|
1126
|
+
}
|
|
1127
|
+
function registerTools(server, store) {
|
|
1128
|
+
server.registerTool("project_list", {
|
|
1129
|
+
title: "List memory projects",
|
|
1130
|
+
description: "List all memory projects with their immutable IDs, current names, note counts, and pinned-note counts. Use this before selecting a project by ID.",
|
|
1131
|
+
inputSchema: z.object({}).strict(),
|
|
1132
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
|
|
1133
|
+
}, async () => textResult({ projects: store.listProjects() }));
|
|
1134
|
+
server.registerTool("project_create", {
|
|
1135
|
+
title: "Create a memory project",
|
|
1136
|
+
description: "Create an empty memory project. The returned projectID is immutable; the unique project name may be changed later.",
|
|
1137
|
+
inputSchema: z.object({ projectName }).strict(),
|
|
1138
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
|
|
1139
|
+
}, async ({ projectName: projectName2 }) => textResult({ results: [store.createProject(projectName2)] }));
|
|
1140
|
+
server.registerTool("project_update", {
|
|
1141
|
+
title: "Rename a memory project",
|
|
1142
|
+
description: "Rename one project by its immutable projectID. Renaming does not change the ID or detach any notes.",
|
|
1143
|
+
inputSchema: z.object({ projectID, projectName }).strict(),
|
|
1144
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
1145
|
+
}, async ({ projectID: projectID2, projectName: projectName2 }) => textResult({ results: [store.updateProject(projectID2, projectName2)] }));
|
|
1146
|
+
server.registerTool("project_delete", {
|
|
1147
|
+
title: "Permanently delete a memory project",
|
|
1148
|
+
description: "DANGER: Permanently deletes the selected project and every note, pinned note, graph edge, and search record owned by it. This cannot be undone. First call project_list, verify the immutable projectID and current name, then provide both confirmation fields exactly.",
|
|
1149
|
+
inputSchema: z.object({
|
|
1150
|
+
projectID,
|
|
1151
|
+
confirmProjectName: projectName.describe("Must exactly match the project's current case-sensitive name. This prevents deletion after an unnoticed rename or wrong-ID selection."),
|
|
1152
|
+
confirmation: z.literal("DELETE_PROJECT_AND_ALL_MEMORY").describe("Required destructive-action confirmation phrase.")
|
|
1153
|
+
}).strict(),
|
|
1154
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
|
|
1155
|
+
}, async ({ projectID: projectID2, confirmProjectName }) => textResult({ results: [store.deleteProject(projectID2, confirmProjectName)] }));
|
|
1156
|
+
server.registerTool("memory_recall", {
|
|
1157
|
+
title: "Search project memory",
|
|
1158
|
+
description: "Search memory only inside one project selected by immutable projectID or unique projectName. Pass one query or up to 10 queries. Indexed cards require memory_read for full content.",
|
|
1159
|
+
inputSchema: z.union([
|
|
1160
|
+
z.object({ projectID, query: z.string() }).strict(),
|
|
1161
|
+
z.object({ projectID, queries: z.array(z.string()).min(1).max(MAX_BATCH) }).strict(),
|
|
1162
|
+
z.object({ projectName, query: z.string() }).strict(),
|
|
1163
|
+
z.object({ projectName, queries: z.array(z.string()).min(1).max(MAX_BATCH) }).strict()
|
|
1164
|
+
]),
|
|
1165
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
|
|
1166
|
+
}, async (raw) => {
|
|
1167
|
+
const resolved = resolveProject(store, raw);
|
|
1168
|
+
if ("error" in resolved)
|
|
1169
|
+
return textResult({ results: [resolved.error] });
|
|
1170
|
+
const queries = "query" in raw ? [raw.query] : raw.queries;
|
|
1171
|
+
return textResult({
|
|
1172
|
+
project: resolved.project,
|
|
1173
|
+
results: queries.map((query) => ({
|
|
1174
|
+
query,
|
|
1175
|
+
cards: store.recall(resolved.project.projectID, query)
|
|
1176
|
+
}))
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
server.registerTool("memory_update", {
|
|
1180
|
+
title: "Create, patch, or delete project memory",
|
|
1181
|
+
description: "Create or patch notes only inside one selected project. Setting delete:true permanently deletes only the specified note from that project; verify the note ID before using delete. A batch contains up to 10 ordered, non-atomic updates: inspect every result because earlier items remain applied if a later item fails. Do not batch destructive deletes unless partial completion is acceptable. Pin state is changed only through memory_pin.",
|
|
1182
|
+
inputSchema: z.union([
|
|
1183
|
+
createUpdateSchema.extend({ projectID }),
|
|
1184
|
+
patchUpdateSchema.extend({ projectID }),
|
|
1185
|
+
z.object({ projectID, updates: z.array(updateSchema).min(1).max(MAX_BATCH) }).strict(),
|
|
1186
|
+
createUpdateSchema.extend({ projectName }),
|
|
1187
|
+
patchUpdateSchema.extend({ projectName }),
|
|
1188
|
+
z.object({ projectName, updates: z.array(updateSchema).min(1).max(MAX_BATCH) }).strict()
|
|
1189
|
+
]),
|
|
1190
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
|
|
1191
|
+
}, async (raw) => {
|
|
1192
|
+
const resolved = resolveProject(store, raw);
|
|
1193
|
+
if ("error" in resolved)
|
|
1194
|
+
return textResult({ results: [resolved.error] });
|
|
1195
|
+
const updates = "updates" in raw ? raw.updates : [raw];
|
|
1196
|
+
return textResult({
|
|
1197
|
+
project: resolved.project,
|
|
1198
|
+
results: updates.map((update) => store.update(resolved.project.projectID, update))
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
server.registerTool("memory_pin", {
|
|
1202
|
+
title: "Pin or unpin project memory",
|
|
1203
|
+
description: "Set the pinned state of one active note inside one selected project. Pinned matching notes are prioritized in recall results. This tool never deletes content.",
|
|
1204
|
+
inputSchema: z.union([
|
|
1205
|
+
z.object({ projectID, id: z.string(), pinned: z.boolean() }).strict(),
|
|
1206
|
+
z.object({ projectName, id: z.string(), pinned: z.boolean() }).strict()
|
|
1207
|
+
]),
|
|
1208
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
1209
|
+
}, async (raw) => {
|
|
1210
|
+
const resolved = resolveProject(store, raw);
|
|
1211
|
+
if ("error" in resolved)
|
|
1212
|
+
return textResult({ results: [resolved.error] });
|
|
1213
|
+
return textResult({
|
|
1214
|
+
project: resolved.project,
|
|
1215
|
+
results: [store.pin(resolved.project.projectID, raw.id, raw.pinned)]
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
server.registerTool("memory_link", {
|
|
1219
|
+
title: "Link project memories",
|
|
1220
|
+
description: `Create one graph edge or up to 10 ordered, non-atomic edge operations between notes in the same selected project; inspect every result because earlier links remain applied if a later item fails. Cross-project links are rejected. Predicates: ${PREDICATES.join(", ")}.`,
|
|
1221
|
+
inputSchema: z.union([
|
|
1222
|
+
linkSchema.extend({ projectID }),
|
|
1223
|
+
z.object({ projectID, links: z.array(linkSchema).min(1).max(MAX_BATCH) }).strict(),
|
|
1224
|
+
linkSchema.extend({ projectName }),
|
|
1225
|
+
z.object({ projectName, links: z.array(linkSchema).min(1).max(MAX_BATCH) }).strict()
|
|
1226
|
+
]),
|
|
1227
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
1228
|
+
}, async (raw) => {
|
|
1229
|
+
const resolved = resolveProject(store, raw);
|
|
1230
|
+
if ("error" in resolved)
|
|
1231
|
+
return textResult({ results: [resolved.error] });
|
|
1232
|
+
const links = "links" in raw ? raw.links : [raw];
|
|
1233
|
+
return textResult({
|
|
1234
|
+
project: resolved.project,
|
|
1235
|
+
results: links.map((link) => store.link(resolved.project.projectID, link.sourceID, link.targetID, link.predicate))
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
server.registerTool("memory_read", {
|
|
1239
|
+
title: "Read project memory",
|
|
1240
|
+
description: "Read one note or up to 10 notes from one selected project, including full content, pin state, project identity, and same-project graph edges.",
|
|
1241
|
+
inputSchema: z.union([
|
|
1242
|
+
z.object({ projectID, id: z.string() }).strict(),
|
|
1243
|
+
z.object({ projectID, ids: z.array(z.string()).min(1).max(MAX_BATCH) }).strict(),
|
|
1244
|
+
z.object({ projectName, id: z.string() }).strict(),
|
|
1245
|
+
z.object({ projectName, ids: z.array(z.string()).min(1).max(MAX_BATCH) }).strict()
|
|
1246
|
+
]),
|
|
1247
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
|
|
1248
|
+
}, async (raw) => {
|
|
1249
|
+
const resolved = resolveProject(store, raw);
|
|
1250
|
+
if ("error" in resolved)
|
|
1251
|
+
return textResult({ results: [resolved.error] });
|
|
1252
|
+
const ids = "id" in raw ? [raw.id] : raw.ids;
|
|
1253
|
+
return textResult({
|
|
1254
|
+
project: resolved.project,
|
|
1255
|
+
results: ids.map((id) => ({
|
|
1256
|
+
id,
|
|
1257
|
+
result: store.read(resolved.project.projectID, id)
|
|
1258
|
+
}))
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/server.ts
|
|
1264
|
+
var SERVER_NAME = "agz-memory";
|
|
1265
|
+
var SERVER_VERSION = "0.4.0-beta.1";
|
|
1266
|
+
function createMemoryServer(store) {
|
|
1267
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: MEMORY_GUIDANCE });
|
|
1268
|
+
registerTools(server, store);
|
|
1269
|
+
return server;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// src/store.ts
|
|
1273
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1274
|
+
|
|
1275
|
+
// src/retrieval/derived.ts
|
|
1276
|
+
import { createHash as createHash4 } from "crypto";
|
|
1277
|
+
|
|
1278
|
+
// src/capture/redact.ts
|
|
1279
|
+
var RULES = [
|
|
1280
|
+
{
|
|
1281
|
+
name: "private-key",
|
|
1282
|
+
pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/gi,
|
|
1283
|
+
highRisk: true
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
name: "credential-uri",
|
|
1287
|
+
pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@[^\s]+/gi,
|
|
1288
|
+
highRisk: true
|
|
1289
|
+
},
|
|
1290
|
+
{ name: "bearer", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi },
|
|
1291
|
+
{ name: "basic-auth", pattern: /\bBasic\s+[A-Za-z0-9+/=]{12,}/gi },
|
|
1292
|
+
{ name: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g },
|
|
1293
|
+
{ name: "gitlab-token", pattern: /\bglpat-[A-Za-z0-9_-]{16,}\b/g },
|
|
1294
|
+
{ name: "aws-access-key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
1295
|
+
{ name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
1296
|
+
{
|
|
1297
|
+
name: "secret-assignment",
|
|
1298
|
+
pattern: /\b(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|PRIVATE_KEY)\s*[:=]\s*["']?[^\s,"']{8,}["']?/gi
|
|
1299
|
+
}
|
|
1300
|
+
];
|
|
1301
|
+
function redactText(value, options = {}) {
|
|
1302
|
+
const maxCharacters = options.maxCharacters ?? Number.MAX_SAFE_INTEGER;
|
|
1303
|
+
let text = value;
|
|
1304
|
+
let replacements = 0;
|
|
1305
|
+
let highRisk = 0;
|
|
1306
|
+
const classes = {};
|
|
1307
|
+
for (const literal2 of options.denylist ?? []) {
|
|
1308
|
+
if (!literal2)
|
|
1309
|
+
continue;
|
|
1310
|
+
const count = text.split(literal2).length - 1;
|
|
1311
|
+
if (count === 0)
|
|
1312
|
+
continue;
|
|
1313
|
+
replacements += count;
|
|
1314
|
+
classes.denylist = (classes.denylist ?? 0) + count;
|
|
1315
|
+
text = text.replaceAll(literal2, "[REDACTED:denylist]");
|
|
1316
|
+
}
|
|
1317
|
+
for (const rule of RULES) {
|
|
1318
|
+
text = text.replace(rule.pattern, () => {
|
|
1319
|
+
replacements++;
|
|
1320
|
+
classes[rule.name] = (classes[rule.name] ?? 0) + 1;
|
|
1321
|
+
if (rule.highRisk)
|
|
1322
|
+
highRisk++;
|
|
1323
|
+
return `[REDACTED:${rule.name}]`;
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
text = text.replace(/\b[A-Za-z0-9+/=_-]{32,}\b/g, (candidate) => {
|
|
1327
|
+
if (!looksHighEntropy(candidate))
|
|
1328
|
+
return candidate;
|
|
1329
|
+
replacements++;
|
|
1330
|
+
classes.entropy = (classes.entropy ?? 0) + 1;
|
|
1331
|
+
return "[REDACTED:entropy]";
|
|
1332
|
+
});
|
|
1333
|
+
const truncated = text.length > maxCharacters;
|
|
1334
|
+
if (truncated)
|
|
1335
|
+
text = text.slice(0, maxCharacters);
|
|
1336
|
+
return {
|
|
1337
|
+
text,
|
|
1338
|
+
replacements,
|
|
1339
|
+
classes,
|
|
1340
|
+
truncated,
|
|
1341
|
+
quarantined: highRisk > 0 || replacements >= 3
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
function looksHighEntropy(value) {
|
|
1345
|
+
if (!/[A-Za-z]/.test(value) || !/\d/.test(value))
|
|
1346
|
+
return false;
|
|
1347
|
+
const counts = new Map;
|
|
1348
|
+
for (const character of value)
|
|
1349
|
+
counts.set(character, (counts.get(character) ?? 0) + 1);
|
|
1350
|
+
let entropy = 0;
|
|
1351
|
+
for (const count of counts.values()) {
|
|
1352
|
+
const probability = count / value.length;
|
|
1353
|
+
entropy -= probability * Math.log2(probability);
|
|
1354
|
+
}
|
|
1355
|
+
return entropy >= 4.1;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// src/retrieval/derived.ts
|
|
1359
|
+
function deriveDocument(source) {
|
|
1360
|
+
const title = redactText(source.title);
|
|
1361
|
+
const summary = redactText(source.summary);
|
|
1362
|
+
const content = redactText(source.content);
|
|
1363
|
+
if (title.quarantined || summary.quarantined || content.quarantined)
|
|
1364
|
+
return;
|
|
1365
|
+
const contentHash = createHash4("sha256").update(`${source.kind}\x00${title.text}\x00${summary.text}\x00${content.text}`, "utf8").digest("hex");
|
|
1366
|
+
return {
|
|
1367
|
+
projectID: source.projectID,
|
|
1368
|
+
noteID: source.noteID,
|
|
1369
|
+
revision: source.revision,
|
|
1370
|
+
kind: source.kind,
|
|
1371
|
+
title: title.text,
|
|
1372
|
+
summary: summary.text,
|
|
1373
|
+
content: content.text,
|
|
1374
|
+
contentHash
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/store.ts
|
|
1379
|
+
class MemoryStore {
|
|
1380
|
+
db;
|
|
1381
|
+
indexBackends;
|
|
1382
|
+
constructor(db, indexBackends = []) {
|
|
1383
|
+
this.db = db;
|
|
1384
|
+
this.indexBackends = indexBackends;
|
|
1385
|
+
}
|
|
1386
|
+
resolveProject(selector) {
|
|
1387
|
+
const row = selector.projectID ? this.getProjectRow(selector.projectID) : selector.projectName ? this.db.query("SELECT * FROM projects WHERE normalized_name = ?").get(normalizeProjectName(selector.projectName)) : undefined;
|
|
1388
|
+
if (!row) {
|
|
1389
|
+
const reference = selector.projectID ?? selector.projectName ?? "missing selector";
|
|
1390
|
+
return { ok: false, reason: `project ${reference} not found` };
|
|
1391
|
+
}
|
|
1392
|
+
return { ok: true, project: rowToProject(row) };
|
|
1393
|
+
}
|
|
1394
|
+
listProjects() {
|
|
1395
|
+
const rows = this.db.query(`SELECT p.*,
|
|
1396
|
+
COUNT(n.id) AS note_count,
|
|
1397
|
+
COALESCE(SUM(CASE WHEN n.pinned = 1 THEN 1 ELSE 0 END), 0) AS pinned_count
|
|
1398
|
+
FROM projects p
|
|
1399
|
+
LEFT JOIN notes n ON n.project_id = p.id
|
|
1400
|
+
GROUP BY p.id
|
|
1401
|
+
ORDER BY p.normalized_name`).all();
|
|
1402
|
+
return rows.map((row) => ({
|
|
1403
|
+
...rowToProject(row),
|
|
1404
|
+
noteCount: row.note_count,
|
|
1405
|
+
pinnedCount: row.pinned_count
|
|
1406
|
+
}));
|
|
1407
|
+
}
|
|
1408
|
+
createProject(nameValue) {
|
|
1409
|
+
const reason = validateProjectName(nameValue);
|
|
1410
|
+
if (reason)
|
|
1411
|
+
return { ok: false, reason };
|
|
1412
|
+
const name = cleanProjectName(nameValue);
|
|
1413
|
+
const normalizedName = normalizeProjectName(name);
|
|
1414
|
+
if (this.projectNameExists(normalizedName)) {
|
|
1415
|
+
return { ok: false, reason: `project name already exists: ${name}` };
|
|
1416
|
+
}
|
|
1417
|
+
const id = randomUUID5();
|
|
1418
|
+
const now = Date.now();
|
|
1419
|
+
this.db.query("INSERT INTO projects (id, name, normalized_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(id, name, normalizedName, now, now);
|
|
1420
|
+
return { ok: true, project: { projectID: id, projectName: name, createdAt: now, updatedAt: now } };
|
|
1421
|
+
}
|
|
1422
|
+
updateProject(projectID2, nameValue) {
|
|
1423
|
+
const existing = this.getProjectRow(projectID2);
|
|
1424
|
+
if (!existing)
|
|
1425
|
+
return { ok: false, reason: `project ${projectID2} not found` };
|
|
1426
|
+
const reason = validateProjectName(nameValue);
|
|
1427
|
+
if (reason)
|
|
1428
|
+
return { ok: false, reason };
|
|
1429
|
+
const name = cleanProjectName(nameValue);
|
|
1430
|
+
const normalizedName = normalizeProjectName(name);
|
|
1431
|
+
if (existing.name === name) {
|
|
1432
|
+
return { ok: true, project: rowToProject(existing) };
|
|
1433
|
+
}
|
|
1434
|
+
if (this.projectNameExists(normalizedName, projectID2)) {
|
|
1435
|
+
return { ok: false, reason: `project name already exists: ${name}` };
|
|
1436
|
+
}
|
|
1437
|
+
const now = Date.now();
|
|
1438
|
+
this.db.query("UPDATE projects SET name = ?, normalized_name = ?, updated_at = ? WHERE id = ?").run(name, normalizedName, now, projectID2);
|
|
1439
|
+
return {
|
|
1440
|
+
ok: true,
|
|
1441
|
+
project: { projectID: projectID2, projectName: name, createdAt: existing.created_at, updatedAt: now }
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
deleteProject(projectID2, confirmProjectName) {
|
|
1445
|
+
const project = this.getProjectRow(projectID2);
|
|
1446
|
+
if (!project)
|
|
1447
|
+
return { ok: false, reason: `project ${projectID2} not found` };
|
|
1448
|
+
if (confirmProjectName !== project.name) {
|
|
1449
|
+
return { ok: false, reason: "confirmProjectName must exactly match the current project name" };
|
|
1450
|
+
}
|
|
1451
|
+
const counts = this.db.query(`SELECT
|
|
1452
|
+
(SELECT COUNT(*) FROM notes WHERE project_id = ?) AS notes,
|
|
1453
|
+
(SELECT COUNT(*) FROM note_edges WHERE project_id = ?) AS edges,
|
|
1454
|
+
(SELECT COUNT(*) FROM notes WHERE project_id = ? AND pinned = 1) AS pinned`).get(projectID2, projectID2, projectID2);
|
|
1455
|
+
this.db.transaction(() => {
|
|
1456
|
+
for (const backend of this.indexBackends) {
|
|
1457
|
+
this.enqueueOutbox(backend, "purge-project", projectID2, null, null, null);
|
|
1458
|
+
}
|
|
1459
|
+
this.db.query("DELETE FROM projects WHERE id = ?").run(projectID2);
|
|
1460
|
+
})();
|
|
1461
|
+
return {
|
|
1462
|
+
ok: true,
|
|
1463
|
+
deleted: true,
|
|
1464
|
+
projectID: projectID2,
|
|
1465
|
+
projectName: project.name,
|
|
1466
|
+
deletedCounts: counts
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
update(projectID2, input) {
|
|
1470
|
+
const project = this.getProjectRow(projectID2);
|
|
1471
|
+
if (!project)
|
|
1472
|
+
return { ok: false, reason: `project ${projectID2} not found` };
|
|
1473
|
+
if (input.delete) {
|
|
1474
|
+
if (!input.id)
|
|
1475
|
+
return { ok: false, reason: "id is required for delete" };
|
|
1476
|
+
const id2 = input.id;
|
|
1477
|
+
const existing2 = this.getNoteRow(projectID2, id2);
|
|
1478
|
+
if (!existing2)
|
|
1479
|
+
return { ok: false, reason: `note ${id2} not found in project ${project.name}` };
|
|
1480
|
+
this.db.transaction(() => {
|
|
1481
|
+
for (const backend of this.indexBackends) {
|
|
1482
|
+
this.enqueueOutbox(backend, "delete-note", projectID2, id2, existing2.current_revision, existing2.content_hash);
|
|
1483
|
+
}
|
|
1484
|
+
this.db.query("DELETE FROM notes WHERE project_id = ? AND id = ?").run(projectID2, id2);
|
|
1485
|
+
})();
|
|
1486
|
+
return { ok: true, id: id2, projectID: projectID2, projectName: project.name, deleted: true };
|
|
1487
|
+
}
|
|
1488
|
+
const existing = input.id ? this.getNoteRow(projectID2, input.id) : undefined;
|
|
1489
|
+
if (input.id && !existing) {
|
|
1490
|
+
return { ok: false, reason: `note ${input.id} not found in project ${project.name}` };
|
|
1491
|
+
}
|
|
1492
|
+
if (existing && existing.status !== "active") {
|
|
1493
|
+
return { ok: false, reason: `note is ${existing.status}` };
|
|
1494
|
+
}
|
|
1495
|
+
const kindValue = input.kind ?? existing?.kind;
|
|
1496
|
+
const kind = KINDS.includes(kindValue ?? "") ? kindValue : null;
|
|
1497
|
+
if (!kind)
|
|
1498
|
+
return { ok: false, reason: `kind must be one of: ${KINDS.join(", ")}` };
|
|
1499
|
+
const title = (input.title ?? existing?.title ?? "").trim();
|
|
1500
|
+
const summary = (input.summary ?? existing?.summary ?? "").trim();
|
|
1501
|
+
const content = (input.content ?? existing?.content ?? summary).trim();
|
|
1502
|
+
if (!title)
|
|
1503
|
+
return { ok: false, reason: "title is required" };
|
|
1504
|
+
if (title.length > 240)
|
|
1505
|
+
return { ok: false, reason: "title exceeds 240 characters" };
|
|
1506
|
+
if (!summary)
|
|
1507
|
+
return { ok: false, reason: "summary is required" };
|
|
1508
|
+
if (!content)
|
|
1509
|
+
return { ok: false, reason: "content is empty" };
|
|
1510
|
+
const now = Date.now();
|
|
1511
|
+
const sizeClass = content.length <= INLINE_LIMIT ? "inline" : "indexed";
|
|
1512
|
+
const contentHash = noteContentHash(kind, title, summary, content);
|
|
1513
|
+
if (existing) {
|
|
1514
|
+
if (existing.kind === kind && existing.title === title && existing.summary === summary && existing.content === content && existing.size_class === sizeClass) {
|
|
1515
|
+
return { ok: true, id: existing.id, projectID: projectID2, projectName: project.name, sizeClass };
|
|
1516
|
+
}
|
|
1517
|
+
this.db.transaction(() => {
|
|
1518
|
+
this.db.query(`UPDATE notes
|
|
1519
|
+
SET kind = ?, title = ?, summary = ?, content = ?, size_class = ?,
|
|
1520
|
+
current_revision = current_revision + 1, content_hash = ?, updated_at = ?
|
|
1521
|
+
WHERE project_id = ? AND id = ?`).run(kind, title, summary, content, sizeClass, contentHash, now, projectID2, existing.id);
|
|
1522
|
+
this.recordCurrentRevision(projectID2, existing.id, "mcp-manual", now);
|
|
1523
|
+
const revision = existing.current_revision + 1;
|
|
1524
|
+
const derivedHash = deriveDocument({
|
|
1525
|
+
projectID: projectID2,
|
|
1526
|
+
noteID: existing.id,
|
|
1527
|
+
revision,
|
|
1528
|
+
kind,
|
|
1529
|
+
title,
|
|
1530
|
+
summary,
|
|
1531
|
+
content
|
|
1532
|
+
})?.contentHash ?? null;
|
|
1533
|
+
for (const backend of this.indexBackends) {
|
|
1534
|
+
this.enqueueOutbox(backend, "upsert-note", projectID2, existing.id, revision, derivedHash);
|
|
1535
|
+
}
|
|
1536
|
+
})();
|
|
1537
|
+
return { ok: true, id: existing.id, projectID: projectID2, projectName: project.name, sizeClass };
|
|
1538
|
+
}
|
|
1539
|
+
const id = randomUUID5();
|
|
1540
|
+
this.insertNote(id, projectID2, kind, title, summary, content, sizeClass, null, null, now);
|
|
1541
|
+
return { ok: true, id, projectID: projectID2, projectName: project.name, sizeClass };
|
|
1542
|
+
}
|
|
1543
|
+
pin(projectID2, id, pinned) {
|
|
1544
|
+
const project = this.getProjectRow(projectID2);
|
|
1545
|
+
if (!project)
|
|
1546
|
+
return { ok: false, reason: `project ${projectID2} not found` };
|
|
1547
|
+
const note = this.getNoteRow(projectID2, id);
|
|
1548
|
+
if (!note)
|
|
1549
|
+
return { ok: false, reason: `note ${id} not found in project ${project.name}` };
|
|
1550
|
+
if (note.status !== "active")
|
|
1551
|
+
return { ok: false, reason: `note is ${note.status}` };
|
|
1552
|
+
if (note.pinned === (pinned ? 1 : 0)) {
|
|
1553
|
+
return { ok: true, id, projectID: projectID2, projectName: project.name, pinned };
|
|
1554
|
+
}
|
|
1555
|
+
const now = Date.now();
|
|
1556
|
+
this.db.transaction(() => {
|
|
1557
|
+
this.db.query(`UPDATE notes
|
|
1558
|
+
SET pinned = ?, current_revision = current_revision + 1, updated_at = ?
|
|
1559
|
+
WHERE project_id = ? AND id = ?`).run(pinned ? 1 : 0, now, projectID2, id);
|
|
1560
|
+
this.recordCurrentRevision(projectID2, id, "mcp-manual", now);
|
|
1561
|
+
const derivedHash = deriveDocument({
|
|
1562
|
+
projectID: projectID2,
|
|
1563
|
+
noteID: id,
|
|
1564
|
+
revision: note.current_revision + 1,
|
|
1565
|
+
kind: note.kind,
|
|
1566
|
+
title: note.title,
|
|
1567
|
+
summary: note.summary,
|
|
1568
|
+
content: note.content
|
|
1569
|
+
})?.contentHash ?? null;
|
|
1570
|
+
for (const backend of this.indexBackends) {
|
|
1571
|
+
this.enqueueOutbox(backend, "upsert-note", projectID2, id, note.current_revision + 1, derivedHash);
|
|
1572
|
+
}
|
|
1573
|
+
})();
|
|
1574
|
+
return { ok: true, id, projectID: projectID2, projectName: project.name, pinned };
|
|
1575
|
+
}
|
|
1576
|
+
insertNote(id, projectID2, kind, title, summary, content, sizeClass, supersedesID, subjectKey, now) {
|
|
1577
|
+
const contentHash = noteContentHash(kind, title, summary, content);
|
|
1578
|
+
this.db.transaction(() => {
|
|
1579
|
+
this.db.query(`INSERT INTO notes
|
|
1580
|
+
(id, project_id, kind, title, summary, content, size_class, pinned, status,
|
|
1581
|
+
supersedes_id, current_revision, subject_key, content_hash, created_at, updated_at)
|
|
1582
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, 1, ?, ?, ?, ?)`).run(id, projectID2, kind, title, summary, content, sizeClass, supersedesID, subjectKey, contentHash, now, now);
|
|
1583
|
+
this.recordCurrentRevision(projectID2, id, "mcp-manual", now);
|
|
1584
|
+
const derivedHash = deriveDocument({
|
|
1585
|
+
projectID: projectID2,
|
|
1586
|
+
noteID: id,
|
|
1587
|
+
revision: 1,
|
|
1588
|
+
kind,
|
|
1589
|
+
title,
|
|
1590
|
+
summary,
|
|
1591
|
+
content
|
|
1592
|
+
})?.contentHash ?? null;
|
|
1593
|
+
for (const backend of this.indexBackends) {
|
|
1594
|
+
this.enqueueOutbox(backend, "upsert-note", projectID2, id, 1, derivedHash);
|
|
1595
|
+
}
|
|
1596
|
+
})();
|
|
1597
|
+
}
|
|
1598
|
+
read(projectID2, id) {
|
|
1599
|
+
const row = this.getNoteRow(projectID2, id);
|
|
1600
|
+
if (!row)
|
|
1601
|
+
return { reason: `note ${id} not found in project ${projectID2}` };
|
|
1602
|
+
const edges = this.db.query(`SELECT e.id, e.project_id, p.name AS project_name, e.source_id, e.target_id, e.predicate, e.created_at
|
|
1603
|
+
FROM note_edges e
|
|
1604
|
+
JOIN projects p ON p.id = e.project_id
|
|
1605
|
+
WHERE e.project_id = ? AND (e.source_id = ? OR e.target_id = ?)`).all(projectID2, id, id);
|
|
1606
|
+
return {
|
|
1607
|
+
note: rowToNote(row),
|
|
1608
|
+
edges: edges.map(rowToEdge)
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
link(projectID2, sourceID, targetID, predicate) {
|
|
1612
|
+
const project = this.getProjectRow(projectID2);
|
|
1613
|
+
if (!project)
|
|
1614
|
+
return { ok: false, reason: `project ${projectID2} not found` };
|
|
1615
|
+
if (!PREDICATES.includes(predicate)) {
|
|
1616
|
+
return { ok: false, reason: `predicate must be one of: ${PREDICATES.join(", ")}` };
|
|
1617
|
+
}
|
|
1618
|
+
if (sourceID === targetID)
|
|
1619
|
+
return { ok: false, reason: "cannot link a note to itself" };
|
|
1620
|
+
for (const id of [sourceID, targetID]) {
|
|
1621
|
+
const row = this.getNoteRow(projectID2, id);
|
|
1622
|
+
if (!row)
|
|
1623
|
+
return { ok: false, reason: `note ${id} not found in project ${project.name}` };
|
|
1624
|
+
if (row.status !== "active")
|
|
1625
|
+
return { ok: false, reason: `note ${id} is ${row.status}` };
|
|
1626
|
+
}
|
|
1627
|
+
this.db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(randomUUID5(), projectID2, sourceID, targetID, predicate, Date.now());
|
|
1628
|
+
return { ok: true, projectID: projectID2, projectName: project.name };
|
|
1629
|
+
}
|
|
1630
|
+
recall(projectID2, query, limit = 10) {
|
|
1631
|
+
const tokens = query.split(/\s+/).map((token) => token.trim()).filter(Boolean).slice(0, 12).map((token) => `"${token.replace(/"/g, '""')}"`);
|
|
1632
|
+
if (tokens.length === 0)
|
|
1633
|
+
return [];
|
|
1634
|
+
const matches = this.db.query(`SELECT n.*, p.name AS project_name, bm25(notes_fts) AS rank
|
|
1635
|
+
FROM notes_fts
|
|
1636
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
1637
|
+
JOIN projects p ON p.id = n.project_id
|
|
1638
|
+
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'
|
|
1639
|
+
ORDER BY n.pinned DESC, rank
|
|
1640
|
+
LIMIT ?`).all(tokens.join(" OR "), projectID2, limit);
|
|
1641
|
+
const cards = matches.map((row) => toCard(rowToNote(row), "match"));
|
|
1642
|
+
const seen = new Set(cards.map((card) => card.id));
|
|
1643
|
+
for (const match of matches.slice(0, 5)) {
|
|
1644
|
+
const neighbors = this.db.query(`SELECT e.predicate, n.*, p.name AS project_name
|
|
1645
|
+
FROM note_edges e
|
|
1646
|
+
JOIN notes n ON n.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END
|
|
1647
|
+
JOIN projects p ON p.id = n.project_id
|
|
1648
|
+
WHERE e.project_id = ?
|
|
1649
|
+
AND (e.source_id = ? OR e.target_id = ?)
|
|
1650
|
+
AND n.project_id = ?
|
|
1651
|
+
AND n.status = 'active'
|
|
1652
|
+
ORDER BY n.pinned DESC
|
|
1653
|
+
LIMIT 6`).all(match.id, projectID2, match.id, match.id, projectID2);
|
|
1654
|
+
for (const neighbor of neighbors) {
|
|
1655
|
+
if (seen.has(neighbor.id))
|
|
1656
|
+
continue;
|
|
1657
|
+
seen.add(neighbor.id);
|
|
1658
|
+
const card = toCard(rowToNote(neighbor), "neighbor");
|
|
1659
|
+
card.predicates = [neighbor.predicate];
|
|
1660
|
+
cards.push(card);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return cards.slice(0, limit + 5);
|
|
1664
|
+
}
|
|
1665
|
+
getProjectRow(id) {
|
|
1666
|
+
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id);
|
|
1667
|
+
}
|
|
1668
|
+
projectNameExists(normalizedName, excludingID) {
|
|
1669
|
+
const row = excludingID ? this.db.query("SELECT id FROM projects WHERE normalized_name = ? AND id != ?").get(normalizedName, excludingID) : this.db.query("SELECT id FROM projects WHERE normalized_name = ?").get(normalizedName);
|
|
1670
|
+
return Boolean(row);
|
|
1671
|
+
}
|
|
1672
|
+
getNoteRow(projectID2, id) {
|
|
1673
|
+
return this.db.query(`SELECT n.*, p.name AS project_name
|
|
1674
|
+
FROM notes n
|
|
1675
|
+
JOIN projects p ON p.id = n.project_id
|
|
1676
|
+
WHERE n.project_id = ? AND n.id = ?`).get(projectID2, id);
|
|
1677
|
+
}
|
|
1678
|
+
recordCurrentRevision(projectID2, noteID, sourceType, now, capture) {
|
|
1679
|
+
const note = this.db.query("SELECT * FROM notes WHERE project_id = ? AND id = ?").get(projectID2, noteID);
|
|
1680
|
+
if (!note)
|
|
1681
|
+
throw new Error(`note ${noteID} not found while recording revision`);
|
|
1682
|
+
const provenanceID = randomUUID5();
|
|
1683
|
+
this.db.query(`
|
|
1684
|
+
INSERT INTO note_provenance
|
|
1685
|
+
(id, project_id, note_id, source_type, capture_event_id, source_session_id,
|
|
1686
|
+
source_message_id, source_ordinal, source_tool_call_id, redaction_version,
|
|
1687
|
+
extractor_version, confidence, created_at)
|
|
1688
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1689
|
+
`).run(provenanceID, projectID2, noteID, sourceType, capture?.eventID ?? null, capture?.sessionID ?? null, capture?.messageID ?? null, capture?.ordinal ?? null, capture?.toolCallID ?? null, capture?.redactionVersion ?? null, capture?.extractorVersion ?? null, capture?.confidence ?? null, now);
|
|
1690
|
+
this.db.query(`
|
|
1691
|
+
INSERT INTO note_revisions
|
|
1692
|
+
(project_id, note_id, revision, kind, title, summary, content, size_class,
|
|
1693
|
+
pinned, status, supersedes_id, subject_key, content_hash, provenance_id, created_at)
|
|
1694
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1695
|
+
`).run(projectID2, noteID, note.current_revision, note.kind, note.title, note.summary, note.content, note.size_class, note.pinned, note.status, note.supersedes_id, note.subject_key, note.content_hash, provenanceID, now);
|
|
1696
|
+
return provenanceID;
|
|
1697
|
+
}
|
|
1698
|
+
enqueueOutbox(backend, operation, projectID2, noteID, revision, contentHash) {
|
|
1699
|
+
const now = Date.now();
|
|
1700
|
+
this.db.query(`
|
|
1701
|
+
INSERT OR IGNORE INTO index_outbox
|
|
1702
|
+
(backend, operation, project_id, note_id, revision, content_hash, state,
|
|
1703
|
+
attempt_count, available_at, created_at)
|
|
1704
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
|
|
1705
|
+
`).run(backend, operation, projectID2, noteID, revision, contentHash, now, now);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
function rowToProject(row) {
|
|
1709
|
+
return {
|
|
1710
|
+
projectID: row.id,
|
|
1711
|
+
projectName: row.name,
|
|
1712
|
+
createdAt: row.created_at,
|
|
1713
|
+
updatedAt: row.updated_at
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
function rowToNote(row) {
|
|
1717
|
+
return {
|
|
1718
|
+
id: row.id,
|
|
1719
|
+
projectID: row.project_id,
|
|
1720
|
+
projectName: row.project_name,
|
|
1721
|
+
kind: row.kind,
|
|
1722
|
+
title: row.title,
|
|
1723
|
+
summary: row.summary,
|
|
1724
|
+
content: row.content,
|
|
1725
|
+
sizeClass: row.size_class,
|
|
1726
|
+
pinned: row.pinned === 1,
|
|
1727
|
+
status: row.status,
|
|
1728
|
+
supersedesID: row.supersedes_id,
|
|
1729
|
+
createdAt: row.created_at,
|
|
1730
|
+
updatedAt: row.updated_at
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
function rowToEdge(row) {
|
|
1734
|
+
return {
|
|
1735
|
+
id: row.id,
|
|
1736
|
+
projectID: row.project_id,
|
|
1737
|
+
projectName: row.project_name,
|
|
1738
|
+
sourceID: row.source_id,
|
|
1739
|
+
targetID: row.target_id,
|
|
1740
|
+
predicate: row.predicate,
|
|
1741
|
+
createdAt: row.created_at
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
function toCard(note, via) {
|
|
1745
|
+
return {
|
|
1746
|
+
id: note.id,
|
|
1747
|
+
projectID: note.projectID,
|
|
1748
|
+
projectName: note.projectName,
|
|
1749
|
+
kind: note.kind,
|
|
1750
|
+
title: note.title,
|
|
1751
|
+
summary: note.summary,
|
|
1752
|
+
content: note.sizeClass === "inline" ? note.content : undefined,
|
|
1753
|
+
sizeClass: note.sizeClass,
|
|
1754
|
+
pinned: note.pinned,
|
|
1755
|
+
via
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/index.ts
|
|
1760
|
+
function main() {
|
|
1761
|
+
const { databasePath } = resolveConfig();
|
|
1762
|
+
const directory = dirname2(databasePath);
|
|
1763
|
+
if (!existsSync4(directory))
|
|
1764
|
+
mkdirSync3(directory, { recursive: true });
|
|
1765
|
+
const opened = openMemoryDatabase(databasePath);
|
|
1766
|
+
const store = new MemoryStore(opened.db);
|
|
1767
|
+
const handle = serveStdio(() => createMemoryServer(store), {
|
|
1768
|
+
onerror: (error) => console.error(`[agz-memory] ${error.message}`)
|
|
1769
|
+
});
|
|
1770
|
+
let closing = false;
|
|
1771
|
+
const close = async () => {
|
|
1772
|
+
if (closing)
|
|
1773
|
+
return;
|
|
1774
|
+
closing = true;
|
|
1775
|
+
try {
|
|
1776
|
+
await handle.close();
|
|
1777
|
+
} finally {
|
|
1778
|
+
opened.close();
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
process.once("SIGINT", () => void close());
|
|
1782
|
+
process.once("SIGTERM", () => void close());
|
|
1783
|
+
process.stdin.once("end", () => void close());
|
|
1784
|
+
}
|
|
1785
|
+
try {
|
|
1786
|
+
main();
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
console.error(`[agz-memory] failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
1789
|
+
process.exitCode = 1;
|
|
1790
|
+
}
|