@vaur94/agz-memory 0.5.0 → 0.5.2
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 +7 -4
- package/CHANGELOG.md +32 -0
- package/README.md +24 -16
- package/README.tr.md +24 -16
- package/dist/admin.js +687 -124
- package/dist/core.js +852 -91
- package/dist/server.js +947 -165
- package/dist/types/admin/quarantine.d.ts +15 -0
- package/dist/types/admin/reindex.d.ts +41 -0
- package/dist/types/config.d.ts +1 -0
- package/dist/types/context.d.ts +1 -1
- package/dist/types/contracts/error.d.ts +16 -0
- package/dist/types/contracts/limits.d.ts +18 -0
- package/dist/types/contracts/mutation.d.ts +31 -0
- package/dist/types/contracts/pagination.d.ts +27 -0
- package/dist/types/core.d.ts +4 -0
- package/dist/types/db/health.d.ts +3 -1
- package/dist/types/db/legacy-health.d.ts +3 -1
- package/dist/types/db.d.ts +11 -1
- package/dist/types/security/quarantine-key.d.ts +47 -0
- package/dist/types/server.d.ts +1 -1
- package/dist/types/store/capture.d.ts +5 -1
- package/dist/types/store/outbox.d.ts +10 -1
- package/dist/types/store.d.ts +27 -9
- package/dist/types/version.d.ts +1 -1
- package/docs/backup-restore-runbook.md +16 -16
- package/docs/backup-restore-runbook.tr.md +16 -16
- package/docs/schema-v11.md +1 -1
- package/package.json +3 -3
- package/skills/agz-memory/agz-memory.md +3 -1
- package/skills/index.json +1 -1
package/dist/server.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/index.ts
|
|
5
|
-
import { existsSync as
|
|
6
|
-
import { dirname as
|
|
5
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync5 } from "fs";
|
|
6
|
+
import { dirname as dirname4 } from "path";
|
|
7
7
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
8
8
|
|
|
9
9
|
// src/config.ts
|
|
@@ -11,7 +11,8 @@ import { homedir } from "os";
|
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
function resolveConfig(environment = process.env) {
|
|
13
13
|
const databasePath = environment.OPENCODE_MEMORY_DATABASE_PATH?.trim() || join(environment.HOME ?? homedir(), ".local", "share", "opencode-memory", "memory.sqlite");
|
|
14
|
-
|
|
14
|
+
const quarantineKeyringPath = environment.OPENCODE_MEMORY_QUARANTINE_KEYRING_PATH?.trim() || `${databasePath}.quarantine-keys`;
|
|
15
|
+
return { databasePath, quarantineKeyringPath };
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
// src/db.ts
|
|
@@ -749,7 +750,7 @@ function inspectDatabase(db) {
|
|
|
749
750
|
}
|
|
750
751
|
return { integrity, foreignKeyViolations, schemaVersion, counts };
|
|
751
752
|
}
|
|
752
|
-
function assertHealthyDatabase(db) {
|
|
753
|
+
function assertHealthyDatabase(db, options = {}) {
|
|
753
754
|
const health = inspectDatabase(db);
|
|
754
755
|
if (health.integrity !== "ok") {
|
|
755
756
|
throw new Error(`database integrity check failed: ${health.integrity}`);
|
|
@@ -757,7 +758,7 @@ function assertHealthyDatabase(db) {
|
|
|
757
758
|
if (health.foreignKeyViolations.length > 0) {
|
|
758
759
|
throw new Error(`database foreign key check failed: ${health.foreignKeyViolations.length} violation(s)`);
|
|
759
760
|
}
|
|
760
|
-
if (health.schemaVersion === 11)
|
|
761
|
+
if (options.verifySchema !== false && health.schemaVersion === 11)
|
|
761
762
|
assertSchemaV11(db);
|
|
762
763
|
return health;
|
|
763
764
|
}
|
|
@@ -2184,7 +2185,7 @@ var V9_V10_COLUMNS = {
|
|
|
2184
2185
|
"completed_at"
|
|
2185
2186
|
]
|
|
2186
2187
|
};
|
|
2187
|
-
function assertLegacySchemaIdentity(db, version) {
|
|
2188
|
+
function assertLegacySchemaIdentity(db, version, options = {}) {
|
|
2188
2189
|
if (version < 2 || version > 10)
|
|
2189
2190
|
throw new Error("unrecognized_database");
|
|
2190
2191
|
const applicationID = db.query("PRAGMA application_id").get().application_id;
|
|
@@ -2192,12 +2193,14 @@ function assertLegacySchemaIdentity(db, version) {
|
|
|
2192
2193
|
throw new Error("unrecognized_database");
|
|
2193
2194
|
if (version === 2 && tableExists(db, "memory_items")) {
|
|
2194
2195
|
assertV2Identity(db);
|
|
2195
|
-
|
|
2196
|
+
if (options.verifyHealth)
|
|
2197
|
+
assertHealthyDatabase(db);
|
|
2196
2198
|
return;
|
|
2197
2199
|
}
|
|
2198
2200
|
if (version < 8) {
|
|
2199
2201
|
assertPreV8Identity(db, version);
|
|
2200
|
-
|
|
2202
|
+
if (options.verifyHealth)
|
|
2203
|
+
assertHealthyDatabase(db);
|
|
2201
2204
|
return;
|
|
2202
2205
|
}
|
|
2203
2206
|
const states = db.query("SELECT version FROM schema_state").all();
|
|
@@ -2222,7 +2225,8 @@ function assertLegacySchemaIdentity(db, version) {
|
|
|
2222
2225
|
throw new Error("unrecognized_database");
|
|
2223
2226
|
}
|
|
2224
2227
|
}
|
|
2225
|
-
|
|
2228
|
+
if (options.verifyHealth)
|
|
2229
|
+
assertHealthyDatabase(db);
|
|
2226
2230
|
if (version === 10)
|
|
2227
2231
|
assertV10SourceDatabase(db);
|
|
2228
2232
|
}
|
|
@@ -3390,7 +3394,7 @@ function migrateV9ToV10(db) {
|
|
|
3390
3394
|
}
|
|
3391
3395
|
|
|
3392
3396
|
// src/version.ts
|
|
3393
|
-
var PRODUCT_VERSION = "0.5.
|
|
3397
|
+
var PRODUCT_VERSION = "0.5.2";
|
|
3394
3398
|
|
|
3395
3399
|
// src/db.ts
|
|
3396
3400
|
var DDL = `
|
|
@@ -3433,15 +3437,25 @@ CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, targe
|
|
|
3433
3437
|
CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
|
|
3434
3438
|
`;
|
|
3435
3439
|
var PRE_OPEN_PROBE_TIMEOUT_MS = 5000;
|
|
3436
|
-
function
|
|
3440
|
+
function timeMigrationStage(timing, stage, work) {
|
|
3441
|
+
if (!timing)
|
|
3442
|
+
return work();
|
|
3443
|
+
const started = performance.now();
|
|
3444
|
+
try {
|
|
3445
|
+
return work();
|
|
3446
|
+
} finally {
|
|
3447
|
+
timing.phases.push({ stage, elapsedMs: Math.round((performance.now() - started) * 1000) / 1000 });
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
function openMemoryDatabase(path, options = {}) {
|
|
3437
3451
|
ensureDatabaseParent(path);
|
|
3438
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3452
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3439
3453
|
recoverStaleMaintenanceGate(path, () => assertSupportedDatabaseBeforeOpen(path));
|
|
3440
3454
|
let lock = acquireMigrationLock(path, SCHEMA_VERSION);
|
|
3441
3455
|
let lease = acquireDatabaseLease(path);
|
|
3442
3456
|
let db;
|
|
3443
3457
|
try {
|
|
3444
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3458
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3445
3459
|
db = openDatabase(path);
|
|
3446
3460
|
} catch (error) {
|
|
3447
3461
|
lease.release();
|
|
@@ -3463,7 +3477,7 @@ function openMemoryDatabase(path) {
|
|
|
3463
3477
|
lease.release();
|
|
3464
3478
|
lease = undefined;
|
|
3465
3479
|
lease = acquireDatabaseLease(path);
|
|
3466
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3480
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3467
3481
|
db = openDatabase(path);
|
|
3468
3482
|
dbOpen = true;
|
|
3469
3483
|
if (hasApplicationObjects(db)) {
|
|
@@ -3513,7 +3527,7 @@ function openMemoryDatabase(path) {
|
|
|
3513
3527
|
lease.release();
|
|
3514
3528
|
lease = undefined;
|
|
3515
3529
|
lease = acquireDatabaseLease(path);
|
|
3516
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3530
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3517
3531
|
db = openDatabase(path);
|
|
3518
3532
|
dbOpen = true;
|
|
3519
3533
|
let migrationVersion = getSchemaVersion(db);
|
|
@@ -3541,7 +3555,7 @@ function openMemoryDatabase(path) {
|
|
|
3541
3555
|
lease.release();
|
|
3542
3556
|
lease = undefined;
|
|
3543
3557
|
maintenance = acquireMaintenanceGate(path);
|
|
3544
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3558
|
+
timeMigrationStage(options.timing, "source-validation", () => assertSupportedDatabaseBeforeOpen(path, false));
|
|
3545
3559
|
db = openDatabase(path);
|
|
3546
3560
|
dbOpen = true;
|
|
3547
3561
|
migrationVersion = getSchemaVersion(db);
|
|
@@ -3571,12 +3585,12 @@ function openMemoryDatabase(path) {
|
|
|
3571
3585
|
lease = undefined;
|
|
3572
3586
|
return opened3;
|
|
3573
3587
|
}
|
|
3574
|
-
backup = createVerifiedBackup(db, path, migrationVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION);
|
|
3588
|
+
backup = timeMigrationStage(options.timing, "backup-checkpoint", () => createVerifiedBackup(db, path, migrationVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION));
|
|
3575
3589
|
db.exec("PRAGMA foreign_keys=OFF");
|
|
3576
3590
|
if (!migrationVersion && hasLegacyV2(db)) {
|
|
3577
3591
|
db.exec(DDL);
|
|
3578
3592
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
3579
|
-
migrateFromV2(db, path);
|
|
3593
|
+
timeMigrationStage(options.timing, "v2-import", () => migrateFromV2(db, path));
|
|
3580
3594
|
} else if (!migrationVersion) {
|
|
3581
3595
|
db.exec(DDL);
|
|
3582
3596
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
@@ -3592,24 +3606,25 @@ function openMemoryDatabase(path) {
|
|
|
3592
3606
|
}
|
|
3593
3607
|
let version = getSchemaVersion(db)?.version ?? 8;
|
|
3594
3608
|
if (version < 9) {
|
|
3595
|
-
db.transaction(() => migrateV8ToV9(db))();
|
|
3609
|
+
timeMigrationStage(options.timing, "v8-to-v9", () => db.transaction(() => migrateV8ToV9(db))());
|
|
3596
3610
|
version = 9;
|
|
3597
3611
|
}
|
|
3598
3612
|
if (version < 10) {
|
|
3599
|
-
db.transaction(() => migrateV9ToV10(db))();
|
|
3613
|
+
timeMigrationStage(options.timing, "v9-to-v10", () => db.transaction(() => migrateV9ToV10(db))());
|
|
3600
3614
|
version = 10;
|
|
3601
3615
|
}
|
|
3602
3616
|
if (version < SCHEMA_VERSION) {
|
|
3603
|
-
db.transaction(() => {
|
|
3617
|
+
timeMigrationStage(options.timing, "v10-to-v11", () => db.transaction(() => {
|
|
3604
3618
|
db.exec(`PRAGMA application_id = ${APPLICATION_ID}`);
|
|
3605
3619
|
migrateV10ToV11(db);
|
|
3606
|
-
})();
|
|
3620
|
+
})());
|
|
3607
3621
|
}
|
|
3608
3622
|
db.exec("PRAGMA foreign_keys=ON");
|
|
3609
3623
|
if (db.query("PRAGMA foreign_keys").get().foreign_keys !== 1) {
|
|
3610
3624
|
throw new Error("failed to enable database foreign keys");
|
|
3611
3625
|
}
|
|
3612
|
-
|
|
3626
|
+
timeMigrationStage(options.timing, "fingerprint", () => assertSchemaV11(db));
|
|
3627
|
+
timeMigrationStage(options.timing, "deep-health", () => assertHealthyDatabase(db, { verifySchema: false }));
|
|
3613
3628
|
console.warn(`[agz-memory] migrated to v${SCHEMA_VERSION} (backup: ${backup.manifestPath})`);
|
|
3614
3629
|
backup = undefined;
|
|
3615
3630
|
db.close();
|
|
@@ -3695,11 +3710,11 @@ function openDatabase(path) {
|
|
|
3695
3710
|
throw error;
|
|
3696
3711
|
}
|
|
3697
3712
|
}
|
|
3698
|
-
function assertSupportedDatabaseBeforeOpen(path) {
|
|
3713
|
+
function assertSupportedDatabaseBeforeOpen(path, verifyHealth = true) {
|
|
3699
3714
|
const deadline = Date.now() + PRE_OPEN_PROBE_TIMEOUT_MS;
|
|
3700
3715
|
while (true) {
|
|
3701
3716
|
try {
|
|
3702
|
-
assertSupportedDatabaseBeforeOpenOnce(path);
|
|
3717
|
+
assertSupportedDatabaseBeforeOpenOnce(path, verifyHealth);
|
|
3703
3718
|
return;
|
|
3704
3719
|
} catch (error) {
|
|
3705
3720
|
if (!isSQLiteBusyError(error) || Date.now() >= deadline)
|
|
@@ -3708,19 +3723,19 @@ function assertSupportedDatabaseBeforeOpen(path) {
|
|
|
3708
3723
|
}
|
|
3709
3724
|
}
|
|
3710
3725
|
}
|
|
3711
|
-
function assertSupportedDatabaseBeforeOpenOnce(path) {
|
|
3726
|
+
function assertSupportedDatabaseBeforeOpenOnce(path, verifyHealth) {
|
|
3712
3727
|
assertDatabasePath(path);
|
|
3713
3728
|
if (!existsSync4(path))
|
|
3714
3729
|
return;
|
|
3715
3730
|
const db = new Database3(path, { readonly: true });
|
|
3716
3731
|
try {
|
|
3717
3732
|
assertDatabasePath(path);
|
|
3718
|
-
assertSupportedDatabase(db);
|
|
3733
|
+
assertSupportedDatabase(db, verifyHealth);
|
|
3719
3734
|
} finally {
|
|
3720
3735
|
db.close();
|
|
3721
3736
|
}
|
|
3722
3737
|
}
|
|
3723
|
-
function assertSupportedDatabase(db) {
|
|
3738
|
+
function assertSupportedDatabase(db, verifyHealth = true) {
|
|
3724
3739
|
const existingVersion = getSchemaVersion(db);
|
|
3725
3740
|
if (existingVersion && existingVersion.version > SCHEMA_VERSION) {
|
|
3726
3741
|
throw new Error(`database schema v${existingVersion.version} is newer than supported v${SCHEMA_VERSION}`);
|
|
@@ -3731,7 +3746,7 @@ function assertSupportedDatabase(db) {
|
|
|
3731
3746
|
if (!existingVersion) {
|
|
3732
3747
|
if (!hasLegacyV2(db))
|
|
3733
3748
|
throw new Error("unrecognized_database");
|
|
3734
|
-
assertLegacySchemaIdentity(db, 2);
|
|
3749
|
+
assertLegacySchemaIdentity(db, 2, { verifyHealth });
|
|
3735
3750
|
return;
|
|
3736
3751
|
}
|
|
3737
3752
|
if (existingVersion.version === SCHEMA_VERSION || hasV11Marker) {
|
|
@@ -3741,7 +3756,7 @@ function assertSupportedDatabase(db) {
|
|
|
3741
3756
|
if (existingVersion.version < 2 || existingVersion.version > 10) {
|
|
3742
3757
|
throw new Error("unrecognized_database");
|
|
3743
3758
|
}
|
|
3744
|
-
assertLegacySchemaIdentity(db, existingVersion.version);
|
|
3759
|
+
assertLegacySchemaIdentity(db, existingVersion.version, { verifyHealth });
|
|
3745
3760
|
}
|
|
3746
3761
|
function assertDatabasePath(path) {
|
|
3747
3762
|
try {
|
|
@@ -4054,7 +4069,7 @@ import { McpServer } from "@modelcontextprotocol/server";
|
|
|
4054
4069
|
|
|
4055
4070
|
// src/context.ts
|
|
4056
4071
|
var MEMORY_GUIDANCE = `Use project-scoped memory for durable facts across sessions.
|
|
4057
|
-
- Start with project_list. Reuse a project only when it intentionally represents the same durable workspace or product;
|
|
4072
|
+
- Start with project_list. Reuse a project only when it intentionally represents the same durable workspace or product; Git linked worktrees are separate checkouts of that same workspace, not new memory projects. Create one only when no matching project exists. If the listed projects are ambiguous, ask rather than guessing from a directory, worktree, branch, or session name.
|
|
4058
4073
|
- Prefer the immutable projectID for stable references. projectName is a convenient unique lookup, but names can change.
|
|
4059
4074
|
- Every memory_recall, memory_read, memory_update, memory_link, and memory_pin call must select exactly one project by projectID or projectName.
|
|
4060
4075
|
- The MCP server does not inject notes automatically. Recall relevant history before relying on prior decisions, and use memory_read for full indexed content and graph neighbors.
|
|
@@ -4065,42 +4080,233 @@ var MEMORY_GUIDANCE = `Use project-scoped memory for durable facts across sessio
|
|
|
4065
4080
|
- memory_update with delete:true permanently deletes one note. project_delete permanently deletes a project and all owned memory. Verify current IDs first and use destructive operations only when explicitly intended.`;
|
|
4066
4081
|
|
|
4067
4082
|
// src/tools.ts
|
|
4083
|
+
import * as z3 from "zod/v4";
|
|
4084
|
+
|
|
4085
|
+
// src/contracts/limits.ts
|
|
4068
4086
|
import * as z2 from "zod/v4";
|
|
4069
|
-
var
|
|
4087
|
+
var LIMITS = {
|
|
4088
|
+
title: 240,
|
|
4089
|
+
summary: 4096,
|
|
4090
|
+
content: 65536,
|
|
4091
|
+
query: 4096,
|
|
4092
|
+
noteID: 256,
|
|
4093
|
+
batch: 10,
|
|
4094
|
+
requestBytes: 1048576,
|
|
4095
|
+
responseBytes: 1048576,
|
|
4096
|
+
pageSize: 100
|
|
4097
|
+
};
|
|
4098
|
+
function utf8Bytes(value) {
|
|
4099
|
+
return Buffer.byteLength(value, "utf8");
|
|
4100
|
+
}
|
|
4101
|
+
function assertTextLimit(field, value) {
|
|
4102
|
+
const maximum = LIMITS[field];
|
|
4103
|
+
if (utf8Bytes(value) > maximum)
|
|
4104
|
+
throw new RangeError(`${field} exceeds ${maximum} UTF-8 bytes`);
|
|
4105
|
+
}
|
|
4106
|
+
function boundedText(field, description) {
|
|
4107
|
+
return z2.string().superRefine((value, context) => {
|
|
4108
|
+
if (utf8Bytes(value) > LIMITS[field]) {
|
|
4109
|
+
context.addIssue({ code: "custom", message: `${field} exceeds ${LIMITS[field]} UTF-8 bytes` });
|
|
4110
|
+
}
|
|
4111
|
+
}).describe(description);
|
|
4112
|
+
}
|
|
4113
|
+
function assertRequestLimit(value) {
|
|
4114
|
+
if (utf8Bytes(JSON.stringify(value)) > LIMITS.requestBytes) {
|
|
4115
|
+
throw new RangeError(`request exceeds ${LIMITS.requestBytes} UTF-8 bytes`);
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
// src/contracts/error.ts
|
|
4120
|
+
class MemoryBusinessError extends Error {
|
|
4121
|
+
code;
|
|
4122
|
+
correlationID;
|
|
4123
|
+
cause;
|
|
4124
|
+
constructor(code, message, correlationID = newCorrelationID(), cause) {
|
|
4125
|
+
super(message);
|
|
4126
|
+
this.code = code;
|
|
4127
|
+
this.correlationID = correlationID;
|
|
4128
|
+
this.cause = cause;
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
function correlationID() {
|
|
4132
|
+
return newCorrelationID();
|
|
4133
|
+
}
|
|
4134
|
+
function newCorrelationID() {
|
|
4135
|
+
return crypto.randomUUID();
|
|
4136
|
+
}
|
|
4137
|
+
function businessError(code, message, id = correlationID(), cause) {
|
|
4138
|
+
return new MemoryBusinessError(code, message, id, cause);
|
|
4139
|
+
}
|
|
4140
|
+
function toPublicError(error) {
|
|
4141
|
+
return { code: error.code, correlationID: error.correlationID, retryable: error.code === "internal_error", message: error.message };
|
|
4142
|
+
}
|
|
4143
|
+
function asBusinessError(error, id = correlationID()) {
|
|
4144
|
+
if (error instanceof MemoryBusinessError)
|
|
4145
|
+
return error;
|
|
4146
|
+
if (error instanceof RangeError)
|
|
4147
|
+
return businessError("limit_exceeded", error.message, id, error);
|
|
4148
|
+
if (error instanceof TypeError) {
|
|
4149
|
+
if (error.message === "invalid_cursor")
|
|
4150
|
+
return businessError("invalid_cursor", "cursor is invalid", id, error);
|
|
4151
|
+
if (error.message === "cursor_scope_mismatch")
|
|
4152
|
+
return businessError("cursor_scope_mismatch", "cursor does not match this request", id, error);
|
|
4153
|
+
if (error.message === "stale_snapshot")
|
|
4154
|
+
return businessError("stale_cursor", "cursor snapshot is stale", id, error);
|
|
4155
|
+
return businessError("invalid_request", "request is invalid", id, error);
|
|
4156
|
+
}
|
|
4157
|
+
return businessError("internal_error", "memory operation failed", id, error);
|
|
4158
|
+
}
|
|
4159
|
+
|
|
4160
|
+
// src/contracts/mutation.ts
|
|
4161
|
+
function normalizeLegacyMutation(input) {
|
|
4162
|
+
const { id, delete: deleteFlag, confirmation, kind, title, summary, content } = input;
|
|
4163
|
+
const hasEdits = kind !== undefined || title !== undefined || summary !== undefined || content !== undefined;
|
|
4164
|
+
if (deleteFlag) {
|
|
4165
|
+
if (!id)
|
|
4166
|
+
throw new TypeError("id is required for delete");
|
|
4167
|
+
if (hasEdits)
|
|
4168
|
+
throw new TypeError("cannot combine delete with edits");
|
|
4169
|
+
return { operation: "delete", id, confirmation };
|
|
4170
|
+
}
|
|
4171
|
+
if (id) {
|
|
4172
|
+
if (!hasEdits)
|
|
4173
|
+
throw new TypeError("patch requires changes");
|
|
4174
|
+
return { operation: "patch", id, changes: { kind, title, summary, content } };
|
|
4175
|
+
}
|
|
4176
|
+
if (!kind || title === undefined || summary === undefined) {
|
|
4177
|
+
throw new TypeError("create requires kind, title, and summary");
|
|
4178
|
+
}
|
|
4179
|
+
return { operation: "create", kind, title, summary, content };
|
|
4180
|
+
}
|
|
4181
|
+
function assertStrictMutationOperation(value) {
|
|
4182
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4183
|
+
throw new TypeError("invalid mutation");
|
|
4184
|
+
const input = value;
|
|
4185
|
+
if (input.operation !== "create" && input.operation !== "patch" && input.operation !== "delete") {
|
|
4186
|
+
throw new TypeError("invalid mutation operation");
|
|
4187
|
+
}
|
|
4188
|
+
const operation = input.operation;
|
|
4189
|
+
const allowed = {
|
|
4190
|
+
create: ["operation", "kind", "title", "summary", "content"],
|
|
4191
|
+
patch: ["operation", "id", "changes"],
|
|
4192
|
+
delete: ["operation", "id", "confirmation"]
|
|
4193
|
+
};
|
|
4194
|
+
const invalid = Object.keys(input).some((key) => !allowed[operation].includes(key));
|
|
4195
|
+
if (invalid)
|
|
4196
|
+
throw new TypeError("invalid mutation keys");
|
|
4197
|
+
if (input.operation === "create") {
|
|
4198
|
+
if (!KINDS.includes(input.kind) || typeof input.title !== "string" || typeof input.summary !== "string") {
|
|
4199
|
+
throw new TypeError("invalid create mutation");
|
|
4200
|
+
}
|
|
4201
|
+
if (input.content !== undefined && typeof input.content !== "string")
|
|
4202
|
+
throw new TypeError("invalid create mutation");
|
|
4203
|
+
}
|
|
4204
|
+
if (input.operation === "patch") {
|
|
4205
|
+
if (typeof input.id !== "string" || !input.id || !input.changes || typeof input.changes !== "object" || Array.isArray(input.changes)) {
|
|
4206
|
+
throw new TypeError("patch requires changes");
|
|
4207
|
+
}
|
|
4208
|
+
const changes = input.changes;
|
|
4209
|
+
if (Object.values(changes).every((value2) => value2 === undefined))
|
|
4210
|
+
throw new TypeError("patch requires changes");
|
|
4211
|
+
if (Object.keys(changes).some((key) => !["kind", "title", "summary", "content"].includes(key)))
|
|
4212
|
+
throw new TypeError("invalid patch changes");
|
|
4213
|
+
if (changes.kind !== undefined && !KINDS.includes(changes.kind))
|
|
4214
|
+
throw new TypeError("invalid patch changes");
|
|
4215
|
+
for (const key of ["title", "summary", "content"]) {
|
|
4216
|
+
if (changes[key] !== undefined && typeof changes[key] !== "string")
|
|
4217
|
+
throw new TypeError("invalid patch changes");
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
if (input.operation === "delete") {
|
|
4221
|
+
if (typeof input.id !== "string" || !input.id)
|
|
4222
|
+
throw new TypeError("id is required for delete");
|
|
4223
|
+
if (input.confirmation !== undefined && typeof input.confirmation !== "string")
|
|
4224
|
+
throw new TypeError("invalid delete confirmation");
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
// src/tools.ts
|
|
4229
|
+
var MAX_BATCH = LIMITS.batch;
|
|
4230
|
+
var pageLimit = z3.number().int().min(1).max(LIMITS.pageSize).optional();
|
|
4231
|
+
var cursor = z3.string().max(2048).optional();
|
|
4232
|
+
var snapshot = z3.string().max(1024).optional();
|
|
4070
4233
|
var CLOSED_WORLD = { openWorldHint: false };
|
|
4071
|
-
var projectID =
|
|
4072
|
-
var projectNameValue =
|
|
4234
|
+
var projectID = z3.uuid().describe("The immutable project UUID returned by project_create or project_list.");
|
|
4235
|
+
var projectNameValue = z3.string().min(1).max(MAX_PROJECT_NAME_LENGTH);
|
|
4073
4236
|
var projectName = projectNameValue.describe("The project's unique current name used to select it. Prefer projectID for long-lived references.");
|
|
4074
4237
|
var newProjectName = projectNameValue.describe("Unique name for the new durable workspace or product memory project.");
|
|
4075
4238
|
var replacementProjectName = projectNameValue.describe("New unique name for the selected project. The projectID remains unchanged.");
|
|
4076
|
-
var noteID =
|
|
4077
|
-
var kind =
|
|
4078
|
-
var title =
|
|
4079
|
-
var summary =
|
|
4080
|
-
var content =
|
|
4081
|
-
var query =
|
|
4082
|
-
var createUpdateSchema =
|
|
4239
|
+
var noteID = boundedText("noteID", "A note ID returned by memory_update, memory_recall, or memory_read.");
|
|
4240
|
+
var kind = z3.enum(KINDS).describe("The durable information category for this note.");
|
|
4241
|
+
var title = boundedText("title", "Short, specific title for identifying the durable record.");
|
|
4242
|
+
var summary = boundedText("summary", "Concise retrieval summary of the durable record.");
|
|
4243
|
+
var content = boundedText("content", "Full durable content. For a new note, the summary is used when content is omitted.");
|
|
4244
|
+
var query = boundedText("query", "Search terms for durable records in the selected project.");
|
|
4245
|
+
var createUpdateSchema = z3.object({
|
|
4083
4246
|
kind,
|
|
4084
4247
|
title,
|
|
4085
4248
|
summary,
|
|
4086
4249
|
content: content.optional()
|
|
4087
4250
|
}).strict();
|
|
4088
|
-
var patchUpdateSchema =
|
|
4251
|
+
var patchUpdateSchema = z3.object({
|
|
4089
4252
|
id: noteID,
|
|
4090
4253
|
kind: kind.optional(),
|
|
4091
4254
|
title: title.optional(),
|
|
4092
4255
|
summary: summary.optional(),
|
|
4093
4256
|
content: content.optional(),
|
|
4094
|
-
delete:
|
|
4095
|
-
}).strict()
|
|
4096
|
-
|
|
4097
|
-
|
|
4257
|
+
delete: z3.boolean().describe("Set true to permanently delete this note; false or omitted performs a patch.").optional()
|
|
4258
|
+
}).strict().superRefine((value, context) => {
|
|
4259
|
+
const hasEdits = value.kind !== undefined || value.title !== undefined || value.summary !== undefined || value.content !== undefined;
|
|
4260
|
+
if (value.delete && hasEdits)
|
|
4261
|
+
context.addIssue({ code: "custom", message: "cannot combine delete with edits" });
|
|
4262
|
+
if (!value.delete && !hasEdits)
|
|
4263
|
+
context.addIssue({ code: "custom", message: "patch requires changes" });
|
|
4264
|
+
});
|
|
4265
|
+
var updateSchema = z3.union([createUpdateSchema, patchUpdateSchema]);
|
|
4266
|
+
var linkSchema = z3.object({
|
|
4098
4267
|
sourceID: noteID.describe("Subject/source note of the directed relationship."),
|
|
4099
4268
|
targetID: noteID.describe("Object/target note of the directed relationship."),
|
|
4100
|
-
predicate:
|
|
4269
|
+
predicate: z3.enum(PREDICATES).describe("Read as sourceID PREDICATE targetID: A SUPPORTS B; A DERIVED_FROM B; A PART_OF B; A ABOUT B; A PRECEDES B; A SUPERSEDES B.")
|
|
4101
4270
|
}).strict();
|
|
4102
4271
|
function textResult(value) {
|
|
4103
|
-
|
|
4272
|
+
const text = JSON.stringify(value, null, 2);
|
|
4273
|
+
if (Buffer.byteLength(text, "utf8") > LIMITS.responseBytes)
|
|
4274
|
+
return errorResult(businessError("limit_exceeded", "response exceeds configured limit"));
|
|
4275
|
+
return { content: [{ type: "text", text }] };
|
|
4276
|
+
}
|
|
4277
|
+
function errorResult(error, operation = "tool") {
|
|
4278
|
+
const publicError = toPublicError(asBusinessError(error));
|
|
4279
|
+
logPublicError(operation, publicError);
|
|
4280
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: publicError }, null, 2) }], isError: true };
|
|
4281
|
+
}
|
|
4282
|
+
function logPublicError(operation, publicError) {
|
|
4283
|
+
console.error(JSON.stringify({ component: "mcp", operation, outcome: "error", error_code: publicError.code, correlation_id: publicError.correlationID }));
|
|
4284
|
+
}
|
|
4285
|
+
function batchFailure(error, operation) {
|
|
4286
|
+
const publicError = toPublicError(asBusinessError(error));
|
|
4287
|
+
logPublicError(operation, publicError);
|
|
4288
|
+
return { ok: false, error: publicError };
|
|
4289
|
+
}
|
|
4290
|
+
async function guardTool(operation, work) {
|
|
4291
|
+
try {
|
|
4292
|
+
return await work();
|
|
4293
|
+
} catch (error) {
|
|
4294
|
+
return errorResult(error, operation);
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
function updateFailure(result) {
|
|
4298
|
+
const reason = result.reason ?? "memory update failed";
|
|
4299
|
+
if (reason.includes("not found"))
|
|
4300
|
+
return businessError("not_found", "memory record not found");
|
|
4301
|
+
if (reason.includes("conflict") || reason.includes("already exists"))
|
|
4302
|
+
return businessError("conflict", "memory operation conflicted");
|
|
4303
|
+
return businessError("invalid_request", "memory operation was rejected");
|
|
4304
|
+
}
|
|
4305
|
+
function singleResult(result, operation) {
|
|
4306
|
+
if (result.ok !== false)
|
|
4307
|
+
return textResult({ results: [result] });
|
|
4308
|
+
const error = updateFailure(result);
|
|
4309
|
+
return errorResult(error, operation);
|
|
4104
4310
|
}
|
|
4105
4311
|
function resolveProject(store, selector) {
|
|
4106
4312
|
const resolved = store.resolveProject(selector);
|
|
@@ -4110,168 +4316,207 @@ function registerTools(server, store) {
|
|
|
4110
4316
|
server.registerTool("project_list", {
|
|
4111
4317
|
title: "List memory projects",
|
|
4112
4318
|
description: "List all memory projects with their immutable IDs, current names, note counts, and pinned-note counts. Use this before selecting or creating a project; reuse one only when it represents the same durable workspace.",
|
|
4113
|
-
inputSchema:
|
|
4319
|
+
inputSchema: z3.object({ limit: pageLimit, cursor, snapshot }).strict(),
|
|
4114
4320
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD }
|
|
4115
|
-
}, async () =>
|
|
4321
|
+
}, async (raw) => guardTool("project_list", async () => {
|
|
4322
|
+
const page = store.listProjectsPage(raw.limit ?? LIMITS.pageSize, raw.cursor, raw.snapshot);
|
|
4323
|
+
return textResult({ projects: page.items, snapshot: page.snapshot, etag: page.etag, nextCursor: page.nextCursor });
|
|
4324
|
+
}));
|
|
4116
4325
|
server.registerTool("project_create", {
|
|
4117
4326
|
title: "Create a memory project",
|
|
4118
|
-
description: "Create an empty memory project only after project_list confirms that no existing project represents the same durable workspace. The returned projectID is immutable; the unique project name may be changed later.",
|
|
4119
|
-
inputSchema:
|
|
4327
|
+
description: "Create an empty memory project only after project_list confirms that no existing project represents the same durable workspace. Git linked worktrees share the existing workspace memory project. The returned projectID is immutable; the unique project name may be changed later.",
|
|
4328
|
+
inputSchema: z3.object({ projectName: newProjectName }).strict(),
|
|
4120
4329
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD }
|
|
4121
|
-
}, async ({ projectName: projectName2 }) =>
|
|
4330
|
+
}, async ({ projectName: projectName2 }) => guardTool("project_create", () => singleResult(store.createProject(projectName2), "project_create")));
|
|
4122
4331
|
server.registerTool("project_update", {
|
|
4123
4332
|
title: "Rename a memory project",
|
|
4124
4333
|
description: "Rename one project by its immutable projectID. Renaming does not change the ID or detach any notes.",
|
|
4125
|
-
inputSchema:
|
|
4334
|
+
inputSchema: z3.object({ projectID, projectName: replacementProjectName }).strict(),
|
|
4126
4335
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, ...CLOSED_WORLD }
|
|
4127
|
-
}, async ({ projectID: projectID2, projectName: projectName2 }) =>
|
|
4336
|
+
}, async ({ projectID: projectID2, projectName: projectName2 }) => guardTool("project_update", () => singleResult(store.updateProject(projectID2, projectName2), "project_update")));
|
|
4128
4337
|
server.registerTool("project_delete", {
|
|
4129
4338
|
title: "Permanently delete a memory project",
|
|
4130
4339
|
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.",
|
|
4131
|
-
inputSchema:
|
|
4340
|
+
inputSchema: z3.object({
|
|
4132
4341
|
projectID,
|
|
4133
4342
|
confirmProjectName: projectNameValue.describe("Must exactly match the project's current case-sensitive name. This prevents deletion after an unnoticed rename or wrong-ID selection."),
|
|
4134
|
-
confirmation:
|
|
4343
|
+
confirmation: z3.literal("DELETE_PROJECT_AND_ALL_MEMORY").describe("Required destructive-action confirmation phrase.")
|
|
4135
4344
|
}).strict(),
|
|
4136
4345
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, ...CLOSED_WORLD }
|
|
4137
|
-
}, async ({ projectID: projectID2, confirmProjectName }) =>
|
|
4346
|
+
}, async ({ projectID: projectID2, confirmProjectName }) => guardTool("project_delete", () => singleResult(store.deleteProject(projectID2, confirmProjectName), "project_delete")));
|
|
4138
4347
|
server.registerTool("memory_recall", {
|
|
4139
4348
|
title: "Search project memory",
|
|
4140
4349
|
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.",
|
|
4141
|
-
inputSchema:
|
|
4142
|
-
|
|
4143
|
-
|
|
4350
|
+
inputSchema: z3.union([
|
|
4351
|
+
z3.object({ projectID, query, limit: pageLimit, cursor, snapshot }).strict(),
|
|
4352
|
+
z3.object({
|
|
4144
4353
|
projectID,
|
|
4145
|
-
queries:
|
|
4354
|
+
queries: z3.array(query).min(1).max(MAX_BATCH).describe("One to 10 independent searches.")
|
|
4146
4355
|
}).strict(),
|
|
4147
|
-
|
|
4148
|
-
|
|
4356
|
+
z3.object({ projectName, query, limit: pageLimit, cursor, snapshot }).strict(),
|
|
4357
|
+
z3.object({
|
|
4149
4358
|
projectName,
|
|
4150
|
-
queries:
|
|
4359
|
+
queries: z3.array(query).min(1).max(MAX_BATCH).describe("One to 10 independent searches.")
|
|
4151
4360
|
}).strict()
|
|
4152
4361
|
]),
|
|
4153
4362
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD }
|
|
4154
|
-
}, async (raw) => {
|
|
4363
|
+
}, async (raw) => guardTool("memory_recall", async () => {
|
|
4364
|
+
assertRequestLimit(raw);
|
|
4155
4365
|
const resolved = resolveProject(store, raw);
|
|
4156
4366
|
if ("error" in resolved)
|
|
4157
|
-
return
|
|
4367
|
+
return errorResult(businessError("not_found", "project not found"), "memory_recall");
|
|
4158
4368
|
const queries = "query" in raw ? [raw.query] : raw.queries;
|
|
4159
4369
|
return textResult({
|
|
4160
4370
|
project: resolved.project,
|
|
4161
4371
|
results: queries.map((query2) => ({
|
|
4162
4372
|
query: query2,
|
|
4163
|
-
|
|
4373
|
+
...(() => {
|
|
4374
|
+
return store.recallPage(resolved.project.projectID, query2, raw.limit ?? LIMITS.pageSize, raw.cursor, raw.snapshot);
|
|
4375
|
+
})()
|
|
4164
4376
|
}))
|
|
4165
4377
|
});
|
|
4166
|
-
});
|
|
4378
|
+
}));
|
|
4167
4379
|
server.registerTool("memory_update", {
|
|
4168
4380
|
title: "Create, patch, or delete project memory",
|
|
4169
4381
|
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.",
|
|
4170
|
-
inputSchema:
|
|
4382
|
+
inputSchema: z3.union([
|
|
4171
4383
|
createUpdateSchema.extend({ projectID }),
|
|
4172
4384
|
patchUpdateSchema.extend({ projectID }),
|
|
4173
|
-
|
|
4385
|
+
z3.object({
|
|
4174
4386
|
projectID,
|
|
4175
|
-
updates:
|
|
4387
|
+
updates: z3.array(updateSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic create, patch, or delete operations.")
|
|
4176
4388
|
}).strict(),
|
|
4177
4389
|
createUpdateSchema.extend({ projectName }),
|
|
4178
4390
|
patchUpdateSchema.extend({ projectName }),
|
|
4179
|
-
|
|
4391
|
+
z3.object({
|
|
4180
4392
|
projectName,
|
|
4181
|
-
updates:
|
|
4393
|
+
updates: z3.array(updateSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic create, patch, or delete operations.")
|
|
4182
4394
|
}).strict()
|
|
4183
4395
|
]),
|
|
4184
4396
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, ...CLOSED_WORLD }
|
|
4185
|
-
}, async (raw) => {
|
|
4397
|
+
}, async (raw) => guardTool("memory_update", async () => {
|
|
4398
|
+
assertRequestLimit(raw);
|
|
4186
4399
|
const resolved = resolveProject(store, raw);
|
|
4187
4400
|
if ("error" in resolved)
|
|
4188
|
-
return
|
|
4401
|
+
return errorResult(businessError("not_found", "project not found"), "memory_update");
|
|
4189
4402
|
const updates = "updates" in raw ? raw.updates : [raw];
|
|
4403
|
+
if (updates.length === 1 && !("updates" in raw)) {
|
|
4404
|
+
try {
|
|
4405
|
+
const result = store.update(resolved.project.projectID, normalizeLegacyMutation(updates[0]));
|
|
4406
|
+
return result.ok ? textResult({ project: resolved.project, results: [result] }) : errorResult(updateFailure(result), "memory_update");
|
|
4407
|
+
} catch (error) {
|
|
4408
|
+
return errorResult(error, "memory_update");
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
const results = updates.map((update) => {
|
|
4412
|
+
try {
|
|
4413
|
+
const result = store.update(resolved.project.projectID, normalizeLegacyMutation(update));
|
|
4414
|
+
return result.ok ? result : batchFailure(updateFailure(result), "memory_update");
|
|
4415
|
+
} catch (error) {
|
|
4416
|
+
return batchFailure(error, "memory_update");
|
|
4417
|
+
}
|
|
4418
|
+
});
|
|
4190
4419
|
return textResult({
|
|
4191
4420
|
project: resolved.project,
|
|
4192
|
-
results
|
|
4421
|
+
...results.some((result) => !result.ok) ? { status: "partial_failure" } : {},
|
|
4422
|
+
results
|
|
4193
4423
|
});
|
|
4194
|
-
});
|
|
4424
|
+
}));
|
|
4195
4425
|
server.registerTool("memory_pin", {
|
|
4196
4426
|
title: "Pin or unpin project memory",
|
|
4197
4427
|
description: "Set the pinned state of one active note inside one selected project. pinned:true prioritizes matching recall results; pinned:false removes that priority. This tool never deletes note content.",
|
|
4198
|
-
inputSchema:
|
|
4199
|
-
|
|
4428
|
+
inputSchema: z3.union([
|
|
4429
|
+
z3.object({
|
|
4200
4430
|
projectID,
|
|
4201
4431
|
id: noteID,
|
|
4202
|
-
pinned:
|
|
4432
|
+
pinned: z3.boolean().describe("True to pin the note; false to unpin it.")
|
|
4203
4433
|
}).strict(),
|
|
4204
|
-
|
|
4434
|
+
z3.object({
|
|
4205
4435
|
projectName,
|
|
4206
4436
|
id: noteID,
|
|
4207
|
-
pinned:
|
|
4437
|
+
pinned: z3.boolean().describe("True to pin the note; false to unpin it.")
|
|
4208
4438
|
}).strict()
|
|
4209
4439
|
]),
|
|
4210
4440
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, ...CLOSED_WORLD }
|
|
4211
|
-
}, async (raw) => {
|
|
4441
|
+
}, async (raw) => guardTool("memory_pin", async () => {
|
|
4212
4442
|
const resolved = resolveProject(store, raw);
|
|
4213
4443
|
if ("error" in resolved)
|
|
4214
|
-
return
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
});
|
|
4219
|
-
});
|
|
4444
|
+
return errorResult(businessError("not_found", "project not found"), "memory_pin");
|
|
4445
|
+
const result = store.pin(resolved.project.projectID, raw.id, raw.pinned);
|
|
4446
|
+
return result.ok === false ? errorResult(updateFailure(result), "memory_pin") : textResult({ project: resolved.project, results: [result] });
|
|
4447
|
+
}));
|
|
4220
4448
|
server.registerTool("memory_link", {
|
|
4221
4449
|
title: "Link project memories",
|
|
4222
4450
|
description: `Create one directed graph edge or up to 10 ordered, non-atomic edge operations between active notes in the same selected project; inspect every result because earlier links remain applied if a later item fails. Read each edge as sourceID PREDICATE targetID. Cross-project links are rejected. Predicates: ${PREDICATES.join(", ")}.`,
|
|
4223
|
-
inputSchema:
|
|
4451
|
+
inputSchema: z3.union([
|
|
4224
4452
|
linkSchema.extend({ projectID }),
|
|
4225
|
-
|
|
4453
|
+
z3.object({
|
|
4226
4454
|
projectID,
|
|
4227
|
-
links:
|
|
4455
|
+
links: z3.array(linkSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic directed edges.")
|
|
4228
4456
|
}).strict(),
|
|
4229
4457
|
linkSchema.extend({ projectName }),
|
|
4230
|
-
|
|
4458
|
+
z3.object({
|
|
4231
4459
|
projectName,
|
|
4232
|
-
links:
|
|
4460
|
+
links: z3.array(linkSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic directed edges.")
|
|
4233
4461
|
}).strict()
|
|
4234
4462
|
]),
|
|
4235
4463
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD }
|
|
4236
|
-
}, async (raw) => {
|
|
4464
|
+
}, async (raw) => guardTool("memory_link", async () => {
|
|
4237
4465
|
const resolved = resolveProject(store, raw);
|
|
4238
4466
|
if ("error" in resolved)
|
|
4239
|
-
return
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4467
|
+
return errorResult(businessError("not_found", "project not found"), "memory_link");
|
|
4468
|
+
if (!("links" in raw)) {
|
|
4469
|
+
try {
|
|
4470
|
+
const result = store.link(resolved.project.projectID, raw.sourceID, raw.targetID, raw.predicate);
|
|
4471
|
+
return result.ok ? textResult({ project: resolved.project, results: [result] }) : errorResult(updateFailure(result), "memory_link");
|
|
4472
|
+
} catch (error) {
|
|
4473
|
+
return errorResult(error, "memory_link");
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4476
|
+
const results = raw.links.map((link) => {
|
|
4477
|
+
try {
|
|
4478
|
+
const result = store.link(resolved.project.projectID, link.sourceID, link.targetID, link.predicate);
|
|
4479
|
+
return result.ok ? result : batchFailure(updateFailure(result), "memory_link");
|
|
4480
|
+
} catch (error) {
|
|
4481
|
+
return batchFailure(error, "memory_link");
|
|
4482
|
+
}
|
|
4244
4483
|
});
|
|
4245
|
-
|
|
4484
|
+
return textResult({ project: resolved.project, ...results.some((result) => !result.ok) ? { status: "partial_failure" } : {}, results });
|
|
4485
|
+
}));
|
|
4246
4486
|
server.registerTool("memory_read", {
|
|
4247
4487
|
title: "Read project memory",
|
|
4248
4488
|
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.",
|
|
4249
|
-
inputSchema:
|
|
4250
|
-
|
|
4251
|
-
|
|
4489
|
+
inputSchema: z3.union([
|
|
4490
|
+
z3.object({ projectID, id: noteID, limit: pageLimit, cursor, snapshot }).strict(),
|
|
4491
|
+
z3.object({
|
|
4252
4492
|
projectID,
|
|
4253
|
-
ids:
|
|
4493
|
+
ids: z3.array(noteID).min(1).max(MAX_BATCH).describe("One to 10 note IDs to read.")
|
|
4254
4494
|
}).strict(),
|
|
4255
|
-
|
|
4256
|
-
|
|
4495
|
+
z3.object({ projectName, id: noteID, limit: pageLimit, cursor, snapshot }).strict(),
|
|
4496
|
+
z3.object({
|
|
4257
4497
|
projectName,
|
|
4258
|
-
ids:
|
|
4498
|
+
ids: z3.array(noteID).min(1).max(MAX_BATCH).describe("One to 10 note IDs to read.")
|
|
4259
4499
|
}).strict()
|
|
4260
4500
|
]),
|
|
4261
4501
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD }
|
|
4262
|
-
}, async (raw) => {
|
|
4502
|
+
}, async (raw) => guardTool("memory_read", async () => {
|
|
4263
4503
|
const resolved = resolveProject(store, raw);
|
|
4264
4504
|
if ("error" in resolved)
|
|
4265
|
-
return
|
|
4505
|
+
return errorResult(businessError("not_found", "project not found"), "memory_read");
|
|
4266
4506
|
const ids = "id" in raw ? [raw.id] : raw.ids;
|
|
4507
|
+
const results = ids.map((id) => ({
|
|
4508
|
+
id,
|
|
4509
|
+
result: "id" in raw && (raw.limit !== undefined || raw.cursor !== undefined || raw.snapshot !== undefined) ? store.readPage(resolved.project.projectID, id, raw.limit ?? LIMITS.pageSize, raw.cursor, raw.snapshot) : store.read(resolved.project.projectID, id)
|
|
4510
|
+
}));
|
|
4511
|
+
const firstResult = results[0]?.result;
|
|
4512
|
+
if (!("ids" in raw) && (!firstResult || !("note" in firstResult) || !firstResult.note)) {
|
|
4513
|
+
return errorResult(businessError("not_found", "memory record not found"), "memory_read");
|
|
4514
|
+
}
|
|
4267
4515
|
return textResult({
|
|
4268
4516
|
project: resolved.project,
|
|
4269
|
-
results
|
|
4270
|
-
id,
|
|
4271
|
-
result: store.read(resolved.project.projectID, id)
|
|
4272
|
-
}))
|
|
4517
|
+
results
|
|
4273
4518
|
});
|
|
4274
|
-
});
|
|
4519
|
+
}));
|
|
4275
4520
|
}
|
|
4276
4521
|
|
|
4277
4522
|
// src/server.ts
|
|
@@ -4285,6 +4530,62 @@ function createMemoryServer(store) {
|
|
|
4285
4530
|
|
|
4286
4531
|
// src/store.ts
|
|
4287
4532
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
4533
|
+
|
|
4534
|
+
// src/contracts/pagination.ts
|
|
4535
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
4536
|
+
var CURSOR_VERSION = 1;
|
|
4537
|
+
var CURSOR_KEY = randomBytes(32);
|
|
4538
|
+
var MAX_CURSOR_BYTES = 2048;
|
|
4539
|
+
function encodeCursor(payload) {
|
|
4540
|
+
const body = Buffer.from(JSON.stringify({ v: CURSOR_VERSION, ...payload }));
|
|
4541
|
+
const signature = createHmac("sha256", CURSOR_KEY).update(body).digest();
|
|
4542
|
+
return Buffer.concat([body, signature]).toString("base64url");
|
|
4543
|
+
}
|
|
4544
|
+
function decodeCursor(cursor2, scope) {
|
|
4545
|
+
let payload;
|
|
4546
|
+
try {
|
|
4547
|
+
if (Buffer.byteLength(cursor2, "utf8") > MAX_CURSOR_BYTES)
|
|
4548
|
+
throw new Error;
|
|
4549
|
+
const encoded = Buffer.from(cursor2, "base64url");
|
|
4550
|
+
if (encoded.length <= 32)
|
|
4551
|
+
throw new Error;
|
|
4552
|
+
const body = encoded.subarray(0, -32);
|
|
4553
|
+
const signature = encoded.subarray(-32);
|
|
4554
|
+
const expected = createHmac("sha256", CURSOR_KEY).update(body).digest();
|
|
4555
|
+
if (!timingSafeEqual(signature, expected))
|
|
4556
|
+
throw new Error;
|
|
4557
|
+
payload = JSON.parse(body.toString("utf8"));
|
|
4558
|
+
} catch {
|
|
4559
|
+
throw new TypeError("invalid_cursor");
|
|
4560
|
+
}
|
|
4561
|
+
if (payload.v !== CURSOR_VERSION || !Number.isSafeInteger(payload.offset) || payload.offset < 0)
|
|
4562
|
+
throw new TypeError("invalid_cursor");
|
|
4563
|
+
if (payload.projectID !== scope.projectID || payload.query !== scope.query) {
|
|
4564
|
+
throw new TypeError("cursor_scope_mismatch");
|
|
4565
|
+
}
|
|
4566
|
+
if (payload.snapshot !== scope.snapshot)
|
|
4567
|
+
throw new TypeError("stale_snapshot");
|
|
4568
|
+
return { offset: payload.offset };
|
|
4569
|
+
}
|
|
4570
|
+
function paginate(items, options) {
|
|
4571
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1)
|
|
4572
|
+
throw new RangeError("page limit must be positive");
|
|
4573
|
+
if (options.requestedSnapshot !== undefined && options.requestedSnapshot !== options.snapshot) {
|
|
4574
|
+
throw new TypeError("stale_snapshot");
|
|
4575
|
+
}
|
|
4576
|
+
const offset = options.cursor !== undefined ? decodeCursor(options.cursor, options).offset : 0;
|
|
4577
|
+
const limit = Math.min(options.limit, LIMITS.pageSize);
|
|
4578
|
+
const page = items.slice(offset, offset + limit);
|
|
4579
|
+
const nextOffset = offset + page.length;
|
|
4580
|
+
return {
|
|
4581
|
+
items: page,
|
|
4582
|
+
snapshot: options.snapshot,
|
|
4583
|
+
etag: options.snapshot,
|
|
4584
|
+
...nextOffset < items.length ? { nextCursor: encodeCursor({ projectID: options.projectID, query: options.query, snapshot: options.snapshot, offset: nextOffset }) } : {}
|
|
4585
|
+
};
|
|
4586
|
+
}
|
|
4587
|
+
|
|
4588
|
+
// src/store.ts
|
|
4288
4589
|
class MemoryStore {
|
|
4289
4590
|
db;
|
|
4290
4591
|
indexBackends;
|
|
@@ -4314,6 +4615,18 @@ class MemoryStore {
|
|
|
4314
4615
|
pinnedCount: row.pinned_count
|
|
4315
4616
|
}));
|
|
4316
4617
|
}
|
|
4618
|
+
listProjectsPage(limit, cursor2, snapshot2) {
|
|
4619
|
+
const projects = this.listProjects();
|
|
4620
|
+
const current = hashTuple("project-list-snapshot", 1, projects.flatMap((project) => [
|
|
4621
|
+
project.projectID,
|
|
4622
|
+
project.projectName,
|
|
4623
|
+
project.createdAt,
|
|
4624
|
+
project.updatedAt,
|
|
4625
|
+
project.noteCount,
|
|
4626
|
+
project.pinnedCount
|
|
4627
|
+
]));
|
|
4628
|
+
return paginate(projects, { projectID: "__projects__", query: "", limit, cursor: cursor2, snapshot: current, requestedSnapshot: snapshot2 });
|
|
4629
|
+
}
|
|
4317
4630
|
createProject(nameValue) {
|
|
4318
4631
|
const reason = validateProjectName(nameValue);
|
|
4319
4632
|
if (reason)
|
|
@@ -4358,7 +4671,12 @@ class MemoryStore {
|
|
|
4358
4671
|
}
|
|
4359
4672
|
const now = Date.now();
|
|
4360
4673
|
try {
|
|
4361
|
-
const updated = this.immediateTransaction(() => this.db.query(
|
|
4674
|
+
const updated = this.immediateTransaction(() => this.db.query(`UPDATE projects
|
|
4675
|
+
SET name = ?,
|
|
4676
|
+
normalized_name = ?,
|
|
4677
|
+
updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
4678
|
+
WHERE id = ?
|
|
4679
|
+
RETURNING *`).get(name, normalizedName, now, now, projectID2));
|
|
4362
4680
|
if (!updated)
|
|
4363
4681
|
return { ok: false, reason: `project ${projectID2} not found` };
|
|
4364
4682
|
return { ok: true, project: rowToProject(updated) };
|
|
@@ -4416,14 +4734,14 @@ class MemoryStore {
|
|
|
4416
4734
|
};
|
|
4417
4735
|
});
|
|
4418
4736
|
}
|
|
4419
|
-
update(projectID2,
|
|
4737
|
+
update(projectID2, operation) {
|
|
4420
4738
|
const project = this.getProjectRow(projectID2);
|
|
4421
4739
|
if (!project)
|
|
4422
4740
|
return { ok: false, reason: `project ${projectID2} not found` };
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4741
|
+
assertStrictMutationOperation(operation);
|
|
4742
|
+
if (operation.operation === "delete") {
|
|
4743
|
+
const id2 = operation.id;
|
|
4744
|
+
assertTextLimit("noteID", id2);
|
|
4427
4745
|
const existing2 = this.getNoteRow(projectID2, id2);
|
|
4428
4746
|
if (!existing2)
|
|
4429
4747
|
return {
|
|
@@ -4440,6 +4758,7 @@ class MemoryStore {
|
|
|
4440
4758
|
};
|
|
4441
4759
|
}
|
|
4442
4760
|
const now2 = Date.now();
|
|
4761
|
+
this.bumpProjectVersion(projectID2, now2);
|
|
4443
4762
|
for (const backend of this.indexBackends) {
|
|
4444
4763
|
this.enqueueOutbox(backend, "delete-note", deleted.project_id, deleted.id, deleted.current_revision, null, now2);
|
|
4445
4764
|
}
|
|
@@ -4452,31 +4771,35 @@ class MemoryStore {
|
|
|
4452
4771
|
};
|
|
4453
4772
|
});
|
|
4454
4773
|
}
|
|
4455
|
-
|
|
4456
|
-
|
|
4774
|
+
if (operation.operation === "patch")
|
|
4775
|
+
assertTextLimit("noteID", operation.id);
|
|
4776
|
+
const existing = operation.operation === "patch" ? this.getNoteRow(projectID2, operation.id) : undefined;
|
|
4777
|
+
if (operation.operation === "patch" && !existing) {
|
|
4457
4778
|
return {
|
|
4458
4779
|
ok: false,
|
|
4459
|
-
reason: `note ${
|
|
4780
|
+
reason: `note ${operation.id} not found in project ${project.name}`
|
|
4460
4781
|
};
|
|
4461
4782
|
}
|
|
4462
4783
|
if (existing && existing.status !== "active") {
|
|
4463
4784
|
return { ok: false, reason: `note is ${existing.status}` };
|
|
4464
4785
|
}
|
|
4465
|
-
const
|
|
4786
|
+
const changes = operation.operation === "patch" ? operation.changes : undefined;
|
|
4787
|
+
const kindValue = operation.operation === "create" ? operation.kind : changes?.kind ?? existing?.kind;
|
|
4466
4788
|
const kind2 = KINDS.includes(kindValue ?? "") ? kindValue : null;
|
|
4467
4789
|
if (!kind2)
|
|
4468
4790
|
return { ok: false, reason: `kind must be one of: ${KINDS.join(", ")}` };
|
|
4469
|
-
const title2 =
|
|
4470
|
-
const summary2 =
|
|
4471
|
-
const content2 =
|
|
4791
|
+
const title2 = operation.operation === "create" ? operation.title.trim() : changes?.title === undefined ? existing?.title ?? "" : changes.title.trim();
|
|
4792
|
+
const summary2 = operation.operation === "create" ? operation.summary.trim() : changes?.summary === undefined ? existing?.summary ?? "" : changes.summary.trim();
|
|
4793
|
+
const content2 = operation.operation === "create" ? (operation.content ?? summary2).trim() : changes?.content === undefined ? existing?.content ?? summary2 : changes.content.trim();
|
|
4472
4794
|
if (!title2)
|
|
4473
4795
|
return { ok: false, reason: "title is required" };
|
|
4474
|
-
if (title2.length > 240)
|
|
4475
|
-
return { ok: false, reason: "title exceeds 240 characters" };
|
|
4476
4796
|
if (!summary2)
|
|
4477
4797
|
return { ok: false, reason: "summary is required" };
|
|
4478
4798
|
if (!content2)
|
|
4479
4799
|
return { ok: false, reason: "content is empty" };
|
|
4800
|
+
assertTextLimit("title", title2);
|
|
4801
|
+
assertTextLimit("summary", summary2);
|
|
4802
|
+
assertTextLimit("content", content2);
|
|
4480
4803
|
const now = Date.now();
|
|
4481
4804
|
const sizeClass = content2.length <= INLINE_LIMIT ? "inline" : "indexed";
|
|
4482
4805
|
const contentHash = noteContentHash(kind2, title2, summary2, content2);
|
|
@@ -4511,6 +4834,7 @@ class MemoryStore {
|
|
|
4511
4834
|
RETURNING *`).get(kind2, title2, summary2, content2, sizeClass, subjectKey, contentHash, now, projectID2, existing.id, existing.current_revision);
|
|
4512
4835
|
if (!updated)
|
|
4513
4836
|
return;
|
|
4837
|
+
this.bumpProjectVersion(projectID2, now);
|
|
4514
4838
|
this.recordCurrentRevision(updated, "mcp-manual", now);
|
|
4515
4839
|
const derived = deriveDocument({
|
|
4516
4840
|
projectID: updated.project_id,
|
|
@@ -4556,6 +4880,7 @@ class MemoryStore {
|
|
|
4556
4880
|
};
|
|
4557
4881
|
}
|
|
4558
4882
|
pin(projectID2, id, pinned) {
|
|
4883
|
+
assertTextLimit("noteID", id);
|
|
4559
4884
|
const project = this.getProjectRow(projectID2);
|
|
4560
4885
|
if (!project)
|
|
4561
4886
|
return { ok: false, reason: `project ${projectID2} not found` };
|
|
@@ -4590,6 +4915,7 @@ class MemoryStore {
|
|
|
4590
4915
|
RETURNING *`).get(pinned ? 1 : 0, now, projectID2, id, note.current_revision);
|
|
4591
4916
|
if (!updated)
|
|
4592
4917
|
return;
|
|
4918
|
+
this.bumpProjectVersion(projectID2, now);
|
|
4593
4919
|
this.recordCurrentRevision(updated, "mcp-manual", now);
|
|
4594
4920
|
const derived = deriveDocument({
|
|
4595
4921
|
projectID: updated.project_id,
|
|
@@ -4634,6 +4960,7 @@ class MemoryStore {
|
|
|
4634
4960
|
RETURNING *`).get(id, projectID2, kind2, title2, summary2, content2, sizeClass, supersedesID, subjectKey, contentHash, now, now);
|
|
4635
4961
|
if (!note)
|
|
4636
4962
|
throw new Error(`note ${id} insert returned no row`);
|
|
4963
|
+
this.bumpProjectVersion(projectID2, now);
|
|
4637
4964
|
this.recordCurrentRevision(note, "mcp-manual", now);
|
|
4638
4965
|
const derived = deriveDocument({
|
|
4639
4966
|
projectID: note.project_id,
|
|
@@ -4653,19 +4980,43 @@ class MemoryStore {
|
|
|
4653
4980
|
});
|
|
4654
4981
|
}
|
|
4655
4982
|
read(projectID2, id) {
|
|
4983
|
+
const page = this.readPage(projectID2, id, LIMITS.pageSize);
|
|
4984
|
+
if (!("note" in page))
|
|
4985
|
+
return page;
|
|
4986
|
+
return {
|
|
4987
|
+
note: page.note,
|
|
4988
|
+
edges: page.items,
|
|
4989
|
+
snapshot: page.snapshot,
|
|
4990
|
+
etag: page.etag,
|
|
4991
|
+
nextCursor: page.nextCursor
|
|
4992
|
+
};
|
|
4993
|
+
}
|
|
4994
|
+
readPage(projectID2, id, limit, cursor2, snapshot2) {
|
|
4995
|
+
assertTextLimit("noteID", id);
|
|
4656
4996
|
const row = this.getNoteRow(projectID2, id);
|
|
4657
4997
|
if (!row)
|
|
4658
4998
|
return { reason: `note ${id} not found in project ${projectID2}` };
|
|
4659
4999
|
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
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
5000
|
+
FROM note_edges e
|
|
5001
|
+
JOIN projects p ON p.id = e.project_id
|
|
5002
|
+
WHERE e.project_id = ? AND (e.source_id = ? OR e.target_id = ?)
|
|
5003
|
+
ORDER BY e.created_at, e.id`).all(projectID2, id, id);
|
|
5004
|
+
const resultEdges = edges.map(rowToEdge);
|
|
5005
|
+
const current = hashTuple("note-edge-snapshot", 1, [id, row.updated_at, ...resultEdges.flatMap((edge) => [edge.id, edge.projectID, edge.projectName, edge.sourceID, edge.targetID, edge.predicate, edge.createdAt])]);
|
|
4663
5006
|
return {
|
|
4664
5007
|
note: rowToNote(row),
|
|
4665
|
-
edges:
|
|
5008
|
+
...paginate(resultEdges, { projectID: projectID2, query: `edges:${id}`, limit, cursor: cursor2, snapshot: current, requestedSnapshot: snapshot2 })
|
|
4666
5009
|
};
|
|
4667
5010
|
}
|
|
5011
|
+
listRevisionsPage(projectID2, noteID2, limit, cursor2, snapshot2) {
|
|
5012
|
+
assertTextLimit("noteID", noteID2);
|
|
5013
|
+
const rows = this.db.query("SELECT revision, created_at FROM note_revisions WHERE project_id = ? AND note_id = ? ORDER BY revision").all(projectID2, noteID2);
|
|
5014
|
+
const current = hashTuple("note-revision-snapshot", 1, [noteID2, ...rows.flatMap((row) => [row.revision, row.created_at])]);
|
|
5015
|
+
return paginate(rows, { projectID: projectID2, query: `revisions:${noteID2}`, limit, cursor: cursor2, snapshot: current, requestedSnapshot: snapshot2 });
|
|
5016
|
+
}
|
|
4668
5017
|
link(projectID2, sourceID, targetID, predicate) {
|
|
5018
|
+
assertTextLimit("noteID", sourceID);
|
|
5019
|
+
assertTextLimit("noteID", targetID);
|
|
4669
5020
|
const project = this.getProjectRow(projectID2);
|
|
4670
5021
|
if (!project)
|
|
4671
5022
|
return { ok: false, reason: `project ${projectID2} not found` };
|
|
@@ -4687,24 +5038,64 @@ class MemoryStore {
|
|
|
4687
5038
|
if (row.status !== "active")
|
|
4688
5039
|
return { ok: false, reason: `note ${id} is ${row.status}` };
|
|
4689
5040
|
}
|
|
4690
|
-
this.
|
|
5041
|
+
this.immediateTransaction(() => {
|
|
5042
|
+
const now = Date.now();
|
|
5043
|
+
const inserted = this.db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(randomUUID7(), projectID2, sourceID, targetID, predicate, now);
|
|
5044
|
+
if (inserted.changes > 0)
|
|
5045
|
+
this.bumpProjectVersion(projectID2, now);
|
|
5046
|
+
});
|
|
4691
5047
|
return { ok: true, projectID: projectID2, projectName: project.name };
|
|
4692
5048
|
}
|
|
4693
5049
|
recall(projectID2, query2, limit = 10) {
|
|
5050
|
+
return this.recallPage(projectID2, query2, limit).cards;
|
|
5051
|
+
}
|
|
5052
|
+
recallPage(projectID2, query2, limit = LIMITS.pageSize, cursor2, snapshot2) {
|
|
5053
|
+
assertTextLimit("query", query2);
|
|
5054
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
5055
|
+
return { cards: [], snapshot: hashTuple("recall-snapshot", 1, [projectID2, query2]), etag: hashTuple("recall-snapshot", 1, [projectID2, query2]) };
|
|
5056
|
+
}
|
|
5057
|
+
const pageLimit2 = Math.min(limit, LIMITS.pageSize);
|
|
4694
5058
|
const tokens = query2.split(/\s+/).map((token) => token.trim()).filter(Boolean).slice(0, 12).map((token) => `"${token.replace(/"/g, '""')}"`);
|
|
4695
|
-
if (tokens.length === 0)
|
|
4696
|
-
|
|
4697
|
-
|
|
5059
|
+
if (tokens.length === 0) {
|
|
5060
|
+
const current2 = hashTuple("recall-snapshot", 1, [projectID2, query2]);
|
|
5061
|
+
return { cards: [], snapshot: current2, etag: current2 };
|
|
5062
|
+
}
|
|
5063
|
+
const project = this.getProjectRow(projectID2);
|
|
5064
|
+
const current = hashTuple("recall-snapshot", 2, [
|
|
5065
|
+
projectID2,
|
|
5066
|
+
query2,
|
|
5067
|
+
project?.name ?? "",
|
|
5068
|
+
project?.updated_at ?? 0
|
|
5069
|
+
]);
|
|
5070
|
+
if (snapshot2 !== undefined && snapshot2 !== current)
|
|
5071
|
+
throw new TypeError("stale_snapshot");
|
|
5072
|
+
const offset = cursor2 !== undefined ? decodeCursor(cursor2, { projectID: projectID2, query: query2, snapshot: current }).offset : 0;
|
|
5073
|
+
const directCount = this.db.query(`SELECT COUNT(*) AS count
|
|
5074
|
+
FROM notes_fts
|
|
5075
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
5076
|
+
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'`).get(tokens.join(" OR "), projectID2).count;
|
|
5077
|
+
const matches = offset < directCount ? this.db.query(`SELECT n.*, p.name AS project_name, bm25(notes_fts) AS rank
|
|
4698
5078
|
FROM notes_fts
|
|
4699
5079
|
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
4700
5080
|
JOIN projects p ON p.id = n.project_id
|
|
4701
5081
|
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'
|
|
4702
|
-
|
|
4703
|
-
|
|
5082
|
+
ORDER BY n.pinned DESC, rank, n.id
|
|
5083
|
+
LIMIT ? OFFSET ?`).all(tokens.join(" OR "), projectID2, pageLimit2, offset) : [];
|
|
4704
5084
|
const cards = matches.map((row) => toCard(rowToNote(row), "match"));
|
|
4705
|
-
const
|
|
4706
|
-
|
|
4707
|
-
|
|
5085
|
+
const directRemaining = Math.max(0, directCount - offset);
|
|
5086
|
+
const neighborOffset = Math.max(0, offset - directCount);
|
|
5087
|
+
if (cards.length < pageLimit2 && directRemaining <= cards.length) {
|
|
5088
|
+
const firstMatches = this.db.query(`SELECT n.*, p.name AS project_name, bm25(notes_fts) AS rank
|
|
5089
|
+
FROM notes_fts
|
|
5090
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
5091
|
+
JOIN projects p ON p.id = n.project_id
|
|
5092
|
+
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'
|
|
5093
|
+
ORDER BY n.pinned DESC, rank, n.id
|
|
5094
|
+
LIMIT 5`).all(tokens.join(" OR "), projectID2);
|
|
5095
|
+
const neighborsToAppend = [];
|
|
5096
|
+
const seen = new Set;
|
|
5097
|
+
for (const match of firstMatches) {
|
|
5098
|
+
const neighbors = this.db.query(`SELECT e.predicate, n.*, p.name AS project_name
|
|
4708
5099
|
FROM note_edges e
|
|
4709
5100
|
JOIN notes n ON n.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END
|
|
4710
5101
|
JOIN projects p ON p.id = n.project_id
|
|
@@ -4712,22 +5103,49 @@ class MemoryStore {
|
|
|
4712
5103
|
AND (e.source_id = ? OR e.target_id = ?)
|
|
4713
5104
|
AND n.project_id = ?
|
|
4714
5105
|
AND n.status = 'active'
|
|
4715
|
-
|
|
5106
|
+
ORDER BY n.pinned DESC, n.updated_at DESC, n.id
|
|
4716
5107
|
LIMIT 6`).all(match.id, projectID2, match.id, match.id, projectID2);
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
5108
|
+
for (const neighbor of neighbors) {
|
|
5109
|
+
if (seen.has(neighbor.id))
|
|
5110
|
+
continue;
|
|
5111
|
+
const isDirectMatch = this.db.query(`SELECT 1
|
|
5112
|
+
FROM notes_fts
|
|
5113
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
5114
|
+
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.id = ? AND n.status = 'active'`).get(tokens.join(" OR "), projectID2, neighbor.id);
|
|
5115
|
+
if (isDirectMatch)
|
|
5116
|
+
continue;
|
|
5117
|
+
seen.add(neighbor.id);
|
|
5118
|
+
const card = toCard(rowToNote(neighbor), "neighbor");
|
|
5119
|
+
card.predicates = [neighbor.predicate];
|
|
5120
|
+
neighborsToAppend.push(card);
|
|
5121
|
+
}
|
|
4724
5122
|
}
|
|
5123
|
+
cards.push(...neighborsToAppend.slice(neighborOffset, neighborOffset + (pageLimit2 - cards.length)));
|
|
5124
|
+
const consumedNeighbors = neighborOffset + Math.max(0, cards.length - matches.length);
|
|
5125
|
+
const hasMore2 = offset + matches.length < directCount || consumedNeighbors < neighborsToAppend.length;
|
|
5126
|
+
return {
|
|
5127
|
+
cards,
|
|
5128
|
+
snapshot: current,
|
|
5129
|
+
etag: current,
|
|
5130
|
+
...hasMore2 ? { nextCursor: encodeCursor({ projectID: projectID2, query: query2, snapshot: current, offset: offset + cards.length }) } : {}
|
|
5131
|
+
};
|
|
4725
5132
|
}
|
|
4726
|
-
|
|
5133
|
+
const hasMore = offset + cards.length < directCount;
|
|
5134
|
+
return {
|
|
5135
|
+
cards,
|
|
5136
|
+
snapshot: current,
|
|
5137
|
+
etag: current,
|
|
5138
|
+
...hasMore ? { nextCursor: encodeCursor({ projectID: projectID2, query: query2, snapshot: current, offset: offset + cards.length }) } : {}
|
|
5139
|
+
};
|
|
4727
5140
|
}
|
|
4728
5141
|
getProjectRow(id) {
|
|
4729
5142
|
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id);
|
|
4730
5143
|
}
|
|
5144
|
+
bumpProjectVersion(projectID2, now) {
|
|
5145
|
+
this.db.query(`UPDATE projects
|
|
5146
|
+
SET updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
5147
|
+
WHERE id = ?`).run(now, now, projectID2);
|
|
5148
|
+
}
|
|
4731
5149
|
getProjectByNormalizedName(normalizedName) {
|
|
4732
5150
|
return this.db.query("SELECT * FROM projects WHERE normalized_name = ?").get(normalizedName);
|
|
4733
5151
|
}
|
|
@@ -4872,13 +5290,332 @@ function takeHead(value, maxCharacters) {
|
|
|
4872
5290
|
return value.slice(0, offset);
|
|
4873
5291
|
}
|
|
4874
5292
|
|
|
5293
|
+
// src/security/quarantine-key.ts
|
|
5294
|
+
import { createHmac as createHmac2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
5295
|
+
import {
|
|
5296
|
+
closeSync as closeSync3,
|
|
5297
|
+
constants as constants3,
|
|
5298
|
+
existsSync as existsSync5,
|
|
5299
|
+
fchmodSync,
|
|
5300
|
+
fstatSync as fstatSync2,
|
|
5301
|
+
fsyncSync as fsyncSync3,
|
|
5302
|
+
lstatSync as lstatSync4,
|
|
5303
|
+
mkdirSync as mkdirSync4,
|
|
5304
|
+
openSync as openSync3,
|
|
5305
|
+
readFileSync as readFileSync4,
|
|
5306
|
+
renameSync as renameSync4,
|
|
5307
|
+
rmSync as rmSync3,
|
|
5308
|
+
writeSync as writeSync2
|
|
5309
|
+
} from "fs";
|
|
5310
|
+
import { dirname as dirname3 } from "path";
|
|
5311
|
+
var KEYRING_FORMAT = "agz-memory.quarantine-keyring/1";
|
|
5312
|
+
var KEY_BYTES = 32;
|
|
5313
|
+
var KEY_ID_BYTES = 12;
|
|
5314
|
+
var MAX_KEYRING_BYTES = 64 * 1024;
|
|
5315
|
+
var KEY_MODE = 384;
|
|
5316
|
+
var LOCK_TIMEOUT_MS = 1000;
|
|
5317
|
+
var RETRIES = 20;
|
|
5318
|
+
|
|
5319
|
+
class QuarantineKeyring {
|
|
5320
|
+
path;
|
|
5321
|
+
constructor(path) {
|
|
5322
|
+
this.path = path;
|
|
5323
|
+
}
|
|
5324
|
+
readActiveKey() {
|
|
5325
|
+
return this.keyReference(this.readDocument());
|
|
5326
|
+
}
|
|
5327
|
+
ensureActiveKey() {
|
|
5328
|
+
let lastError;
|
|
5329
|
+
for (let attempt = 0;attempt < RETRIES; attempt++) {
|
|
5330
|
+
try {
|
|
5331
|
+
return this.readActiveKey();
|
|
5332
|
+
} catch (error) {
|
|
5333
|
+
lastError = error;
|
|
5334
|
+
if (!isKeyringError(error, "quarantine_keyring_missing")) {
|
|
5335
|
+
if (attempt + 1 < RETRIES) {
|
|
5336
|
+
pause();
|
|
5337
|
+
continue;
|
|
5338
|
+
}
|
|
5339
|
+
throw error;
|
|
5340
|
+
}
|
|
5341
|
+
}
|
|
5342
|
+
try {
|
|
5343
|
+
this.createInitialKeyring();
|
|
5344
|
+
} catch (error) {
|
|
5345
|
+
lastError = error;
|
|
5346
|
+
if (!isKeyringError(error, "quarantine_keyring_exists"))
|
|
5347
|
+
throw error;
|
|
5348
|
+
}
|
|
5349
|
+
pause();
|
|
5350
|
+
}
|
|
5351
|
+
throw lastError instanceof Error ? lastError : new Error("quarantine_keyring_unavailable");
|
|
5352
|
+
}
|
|
5353
|
+
rotate() {
|
|
5354
|
+
this.assertSupportedPlatform();
|
|
5355
|
+
const lockPath = `${this.path}.lock`;
|
|
5356
|
+
const lock = this.acquireLock(lockPath);
|
|
5357
|
+
try {
|
|
5358
|
+
const document = this.readDocument();
|
|
5359
|
+
const keyID = randomBytes2(KEY_ID_BYTES).toString("hex");
|
|
5360
|
+
document.keys[keyID] = randomBytes2(KEY_BYTES).toString("base64");
|
|
5361
|
+
document.activeKeyID = keyID;
|
|
5362
|
+
this.writeAtomically(document);
|
|
5363
|
+
return { keyID };
|
|
5364
|
+
} finally {
|
|
5365
|
+
closeSync3(lock);
|
|
5366
|
+
rmSync3(lockPath, { force: true });
|
|
5367
|
+
}
|
|
5368
|
+
}
|
|
5369
|
+
digestSource(source, payloadFingerprint) {
|
|
5370
|
+
const active = this.activeKey(true);
|
|
5371
|
+
return digestSourceWithKey(source, payloadFingerprint, active);
|
|
5372
|
+
}
|
|
5373
|
+
digestExistingSource(source, payloadFingerprint) {
|
|
5374
|
+
const active = this.activeKey(false);
|
|
5375
|
+
return digestSourceWithKey(source, payloadFingerprint, active);
|
|
5376
|
+
}
|
|
5377
|
+
verifySourceDigest(source, payloadFingerprint, keyID, digest) {
|
|
5378
|
+
if (!isPayloadFingerprint(payloadFingerprint) || !/^[0-9a-f]{24}$/.test(keyID) || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
5379
|
+
return false;
|
|
5380
|
+
}
|
|
5381
|
+
const document = this.readDocument();
|
|
5382
|
+
const encoded = document.keys[keyID];
|
|
5383
|
+
if (!encoded)
|
|
5384
|
+
return false;
|
|
5385
|
+
const expected = createHmac2("sha256", Buffer.from(encoded, "base64")).update(sourceDigestBytes(source, payloadFingerprint)).digest();
|
|
5386
|
+
return timingSafeEqual2(expected, Buffer.from(digest, "hex"));
|
|
5387
|
+
}
|
|
5388
|
+
activeKey(createIfMissing) {
|
|
5389
|
+
if (createIfMissing)
|
|
5390
|
+
this.ensureActiveKey();
|
|
5391
|
+
const document = this.readDocument();
|
|
5392
|
+
const keyID = document.activeKeyID;
|
|
5393
|
+
const encoded = document.keys[keyID];
|
|
5394
|
+
if (!encoded)
|
|
5395
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5396
|
+
return { keyID, key: Buffer.from(encoded, "base64") };
|
|
5397
|
+
}
|
|
5398
|
+
keyReference(document) {
|
|
5399
|
+
if (!document.keys[document.activeKeyID])
|
|
5400
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5401
|
+
return { keyID: document.activeKeyID };
|
|
5402
|
+
}
|
|
5403
|
+
createInitialKeyring() {
|
|
5404
|
+
this.assertSupportedPlatform();
|
|
5405
|
+
this.assertSafeParent();
|
|
5406
|
+
const keyID = randomBytes2(KEY_ID_BYTES).toString("hex");
|
|
5407
|
+
const document = {
|
|
5408
|
+
format: KEYRING_FORMAT,
|
|
5409
|
+
activeKeyID: keyID,
|
|
5410
|
+
keys: { [keyID]: randomBytes2(KEY_BYTES).toString("base64") }
|
|
5411
|
+
};
|
|
5412
|
+
let fd;
|
|
5413
|
+
try {
|
|
5414
|
+
fd = openSync3(this.path, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, KEY_MODE);
|
|
5415
|
+
fchmodSync(fd, KEY_MODE);
|
|
5416
|
+
writeSync2(fd, JSON.stringify(document));
|
|
5417
|
+
fsyncSync3(fd);
|
|
5418
|
+
} catch (error) {
|
|
5419
|
+
if (error.code === "EEXIST") {
|
|
5420
|
+
throw new Error("quarantine_keyring_exists");
|
|
5421
|
+
}
|
|
5422
|
+
throw error;
|
|
5423
|
+
} finally {
|
|
5424
|
+
if (fd !== undefined)
|
|
5425
|
+
closeSync3(fd);
|
|
5426
|
+
}
|
|
5427
|
+
}
|
|
5428
|
+
readDocument() {
|
|
5429
|
+
this.assertSupportedPlatform();
|
|
5430
|
+
let before;
|
|
5431
|
+
try {
|
|
5432
|
+
before = lstatSync4(this.path);
|
|
5433
|
+
} catch (error) {
|
|
5434
|
+
if (error.code === "ENOENT") {
|
|
5435
|
+
throw new Error("quarantine_keyring_missing");
|
|
5436
|
+
}
|
|
5437
|
+
throw new Error("quarantine_keyring_unavailable");
|
|
5438
|
+
}
|
|
5439
|
+
if (before.isSymbolicLink())
|
|
5440
|
+
throw new Error("quarantine_keyring_symlink");
|
|
5441
|
+
if (!before.isFile())
|
|
5442
|
+
throw new Error("quarantine_keyring_not_regular");
|
|
5443
|
+
this.assertPermissions(before.mode);
|
|
5444
|
+
if (before.size <= 0 || before.size > MAX_KEYRING_BYTES) {
|
|
5445
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5446
|
+
}
|
|
5447
|
+
let fd;
|
|
5448
|
+
try {
|
|
5449
|
+
fd = openSync3(this.path, constants3.O_RDONLY | constants3.O_NOFOLLOW);
|
|
5450
|
+
const opened = fstatSync2(fd);
|
|
5451
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size <= 0 || opened.size > MAX_KEYRING_BYTES) {
|
|
5452
|
+
throw new Error("quarantine_keyring_toctou");
|
|
5453
|
+
}
|
|
5454
|
+
this.assertPermissions(opened.mode);
|
|
5455
|
+
const bytes = readFileSync4(fd);
|
|
5456
|
+
const after = fstatSync2(fd);
|
|
5457
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
|
|
5458
|
+
throw new Error("quarantine_keyring_toctou");
|
|
5459
|
+
}
|
|
5460
|
+
return parseKeyring(bytes);
|
|
5461
|
+
} catch (error) {
|
|
5462
|
+
if (error.code === "ELOOP") {
|
|
5463
|
+
throw new Error("quarantine_keyring_symlink");
|
|
5464
|
+
}
|
|
5465
|
+
throw error;
|
|
5466
|
+
} finally {
|
|
5467
|
+
if (fd !== undefined)
|
|
5468
|
+
closeSync3(fd);
|
|
5469
|
+
}
|
|
5470
|
+
}
|
|
5471
|
+
writeAtomically(document) {
|
|
5472
|
+
this.assertSafeParent();
|
|
5473
|
+
const temporary = `${this.path}.${process.pid}.${randomBytes2(8).toString("hex")}.tmp`;
|
|
5474
|
+
let fd;
|
|
5475
|
+
try {
|
|
5476
|
+
fd = openSync3(temporary, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, KEY_MODE);
|
|
5477
|
+
fchmodSync(fd, KEY_MODE);
|
|
5478
|
+
writeSync2(fd, JSON.stringify(document));
|
|
5479
|
+
fsyncSync3(fd);
|
|
5480
|
+
closeSync3(fd);
|
|
5481
|
+
fd = undefined;
|
|
5482
|
+
renameSync4(temporary, this.path);
|
|
5483
|
+
this.syncParent();
|
|
5484
|
+
} finally {
|
|
5485
|
+
if (fd !== undefined)
|
|
5486
|
+
closeSync3(fd);
|
|
5487
|
+
rmSync3(temporary, { force: true });
|
|
5488
|
+
}
|
|
5489
|
+
}
|
|
5490
|
+
acquireLock(lockPath) {
|
|
5491
|
+
this.assertSafeParent();
|
|
5492
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
5493
|
+
while (Date.now() < deadline) {
|
|
5494
|
+
try {
|
|
5495
|
+
const fd = openSync3(lockPath, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, KEY_MODE);
|
|
5496
|
+
fchmodSync(fd, KEY_MODE);
|
|
5497
|
+
return fd;
|
|
5498
|
+
} catch (error) {
|
|
5499
|
+
if (error.code !== "EEXIST")
|
|
5500
|
+
throw error;
|
|
5501
|
+
pause();
|
|
5502
|
+
}
|
|
5503
|
+
}
|
|
5504
|
+
throw new Error("quarantine_keyring_busy");
|
|
5505
|
+
}
|
|
5506
|
+
assertSafeParent() {
|
|
5507
|
+
const parent = dirname3(this.path);
|
|
5508
|
+
if (!existsSync5(parent))
|
|
5509
|
+
mkdirSync4(parent, { recursive: true, mode: 448 });
|
|
5510
|
+
const stat = lstatSync4(parent);
|
|
5511
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
5512
|
+
throw new Error("quarantine_keyring_parent_unsafe");
|
|
5513
|
+
}
|
|
5514
|
+
}
|
|
5515
|
+
assertSupportedPlatform() {
|
|
5516
|
+
if (process.platform === "win32") {
|
|
5517
|
+
throw new Error("quarantine_keyring_platform_unsupported");
|
|
5518
|
+
}
|
|
5519
|
+
}
|
|
5520
|
+
assertPermissions(mode) {
|
|
5521
|
+
if ((mode & 511) !== KEY_MODE)
|
|
5522
|
+
throw new Error("quarantine_keyring_permissions");
|
|
5523
|
+
}
|
|
5524
|
+
syncParent() {
|
|
5525
|
+
let fd;
|
|
5526
|
+
try {
|
|
5527
|
+
fd = openSync3(dirname3(this.path), constants3.O_RDONLY);
|
|
5528
|
+
fsyncSync3(fd);
|
|
5529
|
+
} catch {} finally {
|
|
5530
|
+
if (fd !== undefined)
|
|
5531
|
+
closeSync3(fd);
|
|
5532
|
+
}
|
|
5533
|
+
}
|
|
5534
|
+
}
|
|
5535
|
+
function digestSourceWithKey(source, payloadFingerprint, active) {
|
|
5536
|
+
if (!isPayloadFingerprint(payloadFingerprint)) {
|
|
5537
|
+
throw new Error("quarantine_payload_fingerprint_invalid");
|
|
5538
|
+
}
|
|
5539
|
+
return {
|
|
5540
|
+
keyID: active.keyID,
|
|
5541
|
+
digest: createHmac2("sha256", active.key).update(sourceDigestBytes(source, payloadFingerprint)).digest("hex")
|
|
5542
|
+
};
|
|
5543
|
+
}
|
|
5544
|
+
function parseKeyring(bytes) {
|
|
5545
|
+
let value;
|
|
5546
|
+
try {
|
|
5547
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
5548
|
+
} catch {
|
|
5549
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5550
|
+
}
|
|
5551
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5552
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5553
|
+
}
|
|
5554
|
+
const record = value;
|
|
5555
|
+
if (record.format !== KEYRING_FORMAT || typeof record.activeKeyID !== "string" || !record.keys || typeof record.keys !== "object" || Array.isArray(record.keys) || Object.keys(record).length !== 3) {
|
|
5556
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5557
|
+
}
|
|
5558
|
+
const keys = record.keys;
|
|
5559
|
+
if (!/^[0-9a-f]{24}$/.test(record.activeKeyID)) {
|
|
5560
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5561
|
+
}
|
|
5562
|
+
for (const [keyID, encoded] of Object.entries(keys)) {
|
|
5563
|
+
if (!/^[0-9a-f]{24}$/.test(keyID) || typeof encoded !== "string" || !isCanonicalKey(encoded)) {
|
|
5564
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
if (typeof keys[record.activeKeyID] !== "string") {
|
|
5568
|
+
throw new Error("quarantine_keyring_invalid");
|
|
5569
|
+
}
|
|
5570
|
+
return {
|
|
5571
|
+
format: KEYRING_FORMAT,
|
|
5572
|
+
activeKeyID: record.activeKeyID,
|
|
5573
|
+
keys
|
|
5574
|
+
};
|
|
5575
|
+
}
|
|
5576
|
+
function isCanonicalKey(value) {
|
|
5577
|
+
const key = Buffer.from(value, "base64");
|
|
5578
|
+
return key.length === KEY_BYTES && key.toString("base64") === value;
|
|
5579
|
+
}
|
|
5580
|
+
function sourceDigestBytes(source, payloadFingerprint) {
|
|
5581
|
+
return Buffer.from(JSON.stringify([
|
|
5582
|
+
"quarantine-source-payload/2",
|
|
5583
|
+
source.schema,
|
|
5584
|
+
source.projectID,
|
|
5585
|
+
source.bindingKey,
|
|
5586
|
+
source.kind,
|
|
5587
|
+
source.source.system,
|
|
5588
|
+
source.source.opencodeVersion,
|
|
5589
|
+
source.source.pluginVersion,
|
|
5590
|
+
source.source.sessionID,
|
|
5591
|
+
source.source.messageID ?? null,
|
|
5592
|
+
source.source.ordinal ?? null,
|
|
5593
|
+
source.source.toolCallID ?? null,
|
|
5594
|
+
payloadFingerprint
|
|
5595
|
+
]), "utf8");
|
|
5596
|
+
}
|
|
5597
|
+
function isPayloadFingerprint(value) {
|
|
5598
|
+
return /^[0-9a-f]{64}$/.test(value);
|
|
5599
|
+
}
|
|
5600
|
+
function isKeyringError(error, code) {
|
|
5601
|
+
return error instanceof Error && error.message === code;
|
|
5602
|
+
}
|
|
5603
|
+
function pause() {
|
|
5604
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
5605
|
+
}
|
|
5606
|
+
|
|
4875
5607
|
// src/store/capture.ts
|
|
4876
5608
|
class CaptureStore {
|
|
4877
5609
|
db;
|
|
4878
5610
|
indexBackends;
|
|
4879
|
-
|
|
5611
|
+
quarantineKeyring;
|
|
5612
|
+
constructor(db, indexBackends = [], options = {}) {
|
|
4880
5613
|
this.db = db;
|
|
4881
5614
|
this.indexBackends = indexBackends;
|
|
5615
|
+
this.quarantineKeyring = options.quarantineKeyring ?? new QuarantineKeyring(resolveConfig().quarantineKeyringPath);
|
|
5616
|
+
try {
|
|
5617
|
+
this.quarantineKeyring.ensureActiveKey();
|
|
5618
|
+
} catch {}
|
|
4882
5619
|
}
|
|
4883
5620
|
bindProject(input) {
|
|
4884
5621
|
const workspaceID = input.workspaceID ?? "";
|
|
@@ -5059,7 +5796,7 @@ class CaptureStore {
|
|
|
5059
5796
|
}
|
|
5060
5797
|
if (!this.binding(parsed.bindingKey, parsed.projectID))
|
|
5061
5798
|
throw new Error("binding_conflict");
|
|
5062
|
-
const prepared = prepareForPersistence(parsed, options.denylist);
|
|
5799
|
+
const prepared = prepareForPersistence(parsed, options.denylist, this.quarantineKeyring);
|
|
5063
5800
|
const now = Date.now();
|
|
5064
5801
|
let result = {
|
|
5065
5802
|
outcome: prepared.quarantined ? "quarantined" : "shadowed",
|
|
@@ -5074,13 +5811,13 @@ class CaptureStore {
|
|
|
5074
5811
|
generation, created_at, updated_at, processed_at)
|
|
5075
5812
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
5076
5813
|
ON CONFLICT DO NOTHING
|
|
5077
|
-
`).run(prepared.event.idempotencyKey, prepared.event.schema, prepared.event.projectID, prepared.event.bindingKey, prepared.event.kind, prepared.event.source.sessionID, prepared.event.source.messageID ?? null, prepared.event.source.ordinal ?? null, prepared.event.source.toolCallID ?? null, prepared.payload, prepared.payloadHash,
|
|
5814
|
+
`).run(prepared.event.idempotencyKey, prepared.event.schema, prepared.event.projectID, prepared.event.bindingKey, prepared.event.kind, prepared.event.source.sessionID, prepared.event.source.messageID ?? null, prepared.event.source.ordinal ?? null, prepared.event.source.toolCallID ?? null, prepared.payload, prepared.payloadHash, prepared.redactionVersion, prepared.quarantined ? "quarantined" : "pending", now, now, prepared.quarantined ? now : null);
|
|
5078
5815
|
if (inserted.changes === 0) {
|
|
5079
5816
|
const existing = this.db.query(`SELECT contract, project_id, binding_key, event_kind, source_session_id,
|
|
5080
5817
|
source_message_id, source_ordinal, source_tool_call_id,
|
|
5081
5818
|
payload_json, payload_hash, redaction_version, note_id
|
|
5082
5819
|
FROM capture_events WHERE idempotency_key = ?`).get(parsed.idempotencyKey);
|
|
5083
|
-
if (!existing || !samePersistedCapture(existing, prepared.event, prepared.payload, prepared.payloadHash)) {
|
|
5820
|
+
if (!existing || !samePersistedCapture(existing, prepared.event, prepared.payload, prepared.payloadHash, prepared.redactionVersion, prepared.payloadFingerprint, this.quarantineKeyring)) {
|
|
5084
5821
|
throw new Error("idempotency_conflict");
|
|
5085
5822
|
}
|
|
5086
5823
|
result = {
|
|
@@ -5245,6 +5982,9 @@ class CaptureStore {
|
|
|
5245
5982
|
supersedes_id, current_revision, subject_key, content_hash, created_at, updated_at)
|
|
5246
5983
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, 1, ?, ?, ?, ?)
|
|
5247
5984
|
`).run(id, event.projectID, candidate.kind, candidate.title, candidate.summary, candidate.content, sizeClass, supersedesID, subjectKey, contentHash, now, now);
|
|
5985
|
+
this.db.query(`UPDATE projects
|
|
5986
|
+
SET updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
5987
|
+
WHERE id = ?`).run(now, now, event.projectID);
|
|
5248
5988
|
this.recordRevision(event, id, now);
|
|
5249
5989
|
return id;
|
|
5250
5990
|
}
|
|
@@ -5321,7 +6061,7 @@ class CaptureStore {
|
|
|
5321
6061
|
return rows[0];
|
|
5322
6062
|
}
|
|
5323
6063
|
}
|
|
5324
|
-
function prepareForPersistence(event, denylist) {
|
|
6064
|
+
function prepareForPersistence(event, denylist, quarantineKeyring) {
|
|
5325
6065
|
const copy = structuredClone(event);
|
|
5326
6066
|
let replacements = 0;
|
|
5327
6067
|
let truncated = copy.redaction.truncated;
|
|
@@ -5362,11 +6102,28 @@ function prepareForPersistence(event, denylist) {
|
|
|
5362
6102
|
truncated
|
|
5363
6103
|
};
|
|
5364
6104
|
const validated = parseCaptureEvent(copy);
|
|
6105
|
+
const payloadFingerprint = capturePayloadHash2(validated);
|
|
6106
|
+
let payloadHash = payloadFingerprint;
|
|
6107
|
+
let redactionVersion = REDACTION_POLICY_VERSION;
|
|
6108
|
+
if (quarantined) {
|
|
6109
|
+
let keyID = "unavailable";
|
|
6110
|
+
payloadHash = null;
|
|
6111
|
+
try {
|
|
6112
|
+
const keyed = quarantineKeyring?.digestExistingSource(validated, payloadFingerprint);
|
|
6113
|
+
if (keyed) {
|
|
6114
|
+
keyID = keyed.keyID;
|
|
6115
|
+
payloadHash = keyed.digest;
|
|
6116
|
+
}
|
|
6117
|
+
} catch {}
|
|
6118
|
+
redactionVersion = quarantineRedactionVersion(keyID);
|
|
6119
|
+
}
|
|
5365
6120
|
const payload = quarantined ? null : JSON.stringify(validated);
|
|
5366
6121
|
return {
|
|
5367
6122
|
event: validated,
|
|
5368
6123
|
payload,
|
|
5369
|
-
payloadHash
|
|
6124
|
+
payloadHash,
|
|
6125
|
+
redactionVersion,
|
|
6126
|
+
payloadFingerprint,
|
|
5370
6127
|
quarantined,
|
|
5371
6128
|
additionalReplacements: replacements
|
|
5372
6129
|
};
|
|
@@ -5440,9 +6197,34 @@ function captureKeyForEvent(event) {
|
|
|
5440
6197
|
terminalStatus: event.signal.status
|
|
5441
6198
|
});
|
|
5442
6199
|
}
|
|
5443
|
-
function samePersistedCapture(existing, event, payload, payloadHash) {
|
|
5444
|
-
const retainedLegacyPayload = existing.payload_json === null && existing.payload_hash === null;
|
|
5445
|
-
return existing.contract === event.schema && existing.project_id === event.projectID && existing.binding_key === event.bindingKey && existing.event_kind === event.kind && existing.source_session_id === event.source.sessionID && existing.source_message_id === (event.source.messageID ?? null) && existing.source_ordinal === (event.source.ordinal ?? null) && existing.source_tool_call_id === (event.source.toolCallID ?? null) && (retainedLegacyPayload || sameCapturePayload(existing.payload_json, existing.payload_hash, payload, payloadHash) && existing.redaction_version ===
|
|
6200
|
+
function samePersistedCapture(existing, event, payload, payloadHash, redactionVersion, payloadFingerprint, quarantineKeyring) {
|
|
6201
|
+
const retainedLegacyPayload = existing.payload_json === null && existing.payload_hash === null && !isQuarantineRedactionVersion(existing.redaction_version);
|
|
6202
|
+
return existing.contract === event.schema && existing.project_id === event.projectID && existing.binding_key === event.bindingKey && existing.event_kind === event.kind && existing.source_session_id === event.source.sessionID && existing.source_message_id === (event.source.messageID ?? null) && existing.source_ordinal === (event.source.ordinal ?? null) && existing.source_tool_call_id === (event.source.toolCallID ?? null) && (retainedLegacyPayload || sameCapturePayload(existing.payload_json, existing.payload_hash, payload, payloadHash) && existing.redaction_version === redactionVersion || sameKeyedQuarantinedCapture(existing, event, payload, payloadHash, redactionVersion, payloadFingerprint, quarantineKeyring));
|
|
6203
|
+
}
|
|
6204
|
+
function sameKeyedQuarantinedCapture(existing, event, payload, payloadHash, redactionVersion, payloadFingerprint, quarantineKeyring) {
|
|
6205
|
+
if (payload !== null || existing.payload_json !== null || payloadHash === null || existing.payload_hash === null || !isCurrentKeyedQuarantineVersion(existing.redaction_version) || !isCurrentKeyedQuarantineVersion(redactionVersion)) {
|
|
6206
|
+
return false;
|
|
6207
|
+
}
|
|
6208
|
+
const keyID = quarantineKeyID(existing.redaction_version);
|
|
6209
|
+
if (!keyID)
|
|
6210
|
+
return false;
|
|
6211
|
+
try {
|
|
6212
|
+
return quarantineKeyring.verifySourceDigest(event, payloadFingerprint, keyID, existing.payload_hash);
|
|
6213
|
+
} catch {
|
|
6214
|
+
return false;
|
|
6215
|
+
}
|
|
6216
|
+
}
|
|
6217
|
+
function quarantineRedactionVersion(keyID) {
|
|
6218
|
+
return `${REDACTION_POLICY_VERSION};quarantine-key=${keyID};quarantine-digest=2`;
|
|
6219
|
+
}
|
|
6220
|
+
function isQuarantineRedactionVersion(value) {
|
|
6221
|
+
return /^redaction\/1;quarantine-key=(?:[0-9a-f]{24}|unavailable)(?:;quarantine-digest=2)?$/.test(value);
|
|
6222
|
+
}
|
|
6223
|
+
function isCurrentKeyedQuarantineVersion(value) {
|
|
6224
|
+
return /^redaction\/1;quarantine-key=[0-9a-f]{24};quarantine-digest=2$/.test(value);
|
|
6225
|
+
}
|
|
6226
|
+
function quarantineKeyID(value) {
|
|
6227
|
+
return /^redaction\/1;quarantine-key=([0-9a-f]{24});quarantine-digest=2$/.exec(value)?.[1];
|
|
5446
6228
|
}
|
|
5447
6229
|
function sameCapturePayload(existingPayload, existingHash, payload, payloadHash) {
|
|
5448
6230
|
if (existingHash !== null && existingHash === payloadHash)
|
|
@@ -5481,9 +6263,9 @@ function derivedHash(note) {
|
|
|
5481
6263
|
// src/index.ts
|
|
5482
6264
|
function main() {
|
|
5483
6265
|
const { databasePath } = resolveConfig();
|
|
5484
|
-
const directory =
|
|
5485
|
-
if (!
|
|
5486
|
-
|
|
6266
|
+
const directory = dirname4(databasePath);
|
|
6267
|
+
if (!existsSync6(directory))
|
|
6268
|
+
mkdirSync5(directory, { recursive: true });
|
|
5487
6269
|
const opened = openMemoryDatabase(databasePath);
|
|
5488
6270
|
const store = new MemoryStore(opened.db);
|
|
5489
6271
|
const capture = new CaptureStore(opened.db);
|