@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/dist/server.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // @bun
3
3
 
4
4
  // src/index.ts
5
- import { existsSync as existsSync5, mkdirSync as mkdirSync4 } from "fs";
6
- import { dirname as dirname3 } from "path";
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
- return { databasePath };
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
- assertHealthyDatabase(db);
2196
+ if (options.verifyHealth)
2197
+ assertHealthyDatabase(db);
2196
2198
  return;
2197
2199
  }
2198
2200
  if (version < 8) {
2199
2201
  assertPreV8Identity(db, version);
2200
- assertHealthyDatabase(db);
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
- assertHealthyDatabase(db);
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.0";
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 openMemoryDatabase(path) {
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
- assertHealthyDatabase(db);
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; create one only when no matching project exists. If the listed projects are ambiguous, ask rather than guessing from a directory or session name.
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 MAX_BATCH = 10;
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 = z2.uuid().describe("The immutable project UUID returned by project_create or project_list.");
4072
- var projectNameValue = z2.string().min(1).max(MAX_PROJECT_NAME_LENGTH);
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 = z2.string().describe("A note ID returned by memory_update, memory_recall, or memory_read.");
4077
- var kind = z2.enum(KINDS).describe("The durable information category for this note.");
4078
- var title = z2.string().describe("Short, specific title for identifying the durable record.");
4079
- var summary = z2.string().describe("Concise retrieval summary of the durable record.");
4080
- var content = z2.string().describe("Full durable content. For a new note, the summary is used when content is omitted.");
4081
- var query = z2.string().describe("Search terms for durable records in the selected project.");
4082
- var createUpdateSchema = z2.object({
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 = z2.object({
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: z2.boolean().describe("Set true to permanently delete this note; false or omitted performs a patch.").optional()
4095
- }).strict();
4096
- var updateSchema = z2.union([createUpdateSchema, patchUpdateSchema]);
4097
- var linkSchema = z2.object({
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: z2.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.")
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
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
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: z2.object({}).strict(),
4319
+ inputSchema: z3.object({ limit: pageLimit, cursor, snapshot }).strict(),
4114
4320
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD }
4115
- }, async () => textResult({ projects: store.listProjects() }));
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: z2.object({ projectName: newProjectName }).strict(),
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 }) => textResult({ results: [store.createProject(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: z2.object({ projectID, projectName: replacementProjectName }).strict(),
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 }) => textResult({ results: [store.updateProject(projectID2, 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: z2.object({
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: z2.literal("DELETE_PROJECT_AND_ALL_MEMORY").describe("Required destructive-action confirmation phrase.")
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 }) => textResult({ results: [store.deleteProject(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: z2.union([
4142
- z2.object({ projectID, query }).strict(),
4143
- z2.object({
4350
+ inputSchema: z3.union([
4351
+ z3.object({ projectID, query, limit: pageLimit, cursor, snapshot }).strict(),
4352
+ z3.object({
4144
4353
  projectID,
4145
- queries: z2.array(query).min(1).max(MAX_BATCH).describe("One to 10 independent searches.")
4354
+ queries: z3.array(query).min(1).max(MAX_BATCH).describe("One to 10 independent searches.")
4146
4355
  }).strict(),
4147
- z2.object({ projectName, query }).strict(),
4148
- z2.object({
4356
+ z3.object({ projectName, query, limit: pageLimit, cursor, snapshot }).strict(),
4357
+ z3.object({
4149
4358
  projectName,
4150
- queries: z2.array(query).min(1).max(MAX_BATCH).describe("One to 10 independent searches.")
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 textResult({ results: [resolved.error] });
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
- cards: store.recall(resolved.project.projectID, query2)
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: z2.union([
4382
+ inputSchema: z3.union([
4171
4383
  createUpdateSchema.extend({ projectID }),
4172
4384
  patchUpdateSchema.extend({ projectID }),
4173
- z2.object({
4385
+ z3.object({
4174
4386
  projectID,
4175
- updates: z2.array(updateSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic create, patch, or delete operations.")
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
- z2.object({
4391
+ z3.object({
4180
4392
  projectName,
4181
- updates: z2.array(updateSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic create, patch, or delete operations.")
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 textResult({ results: [resolved.error] });
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: updates.map((update) => store.update(resolved.project.projectID, update))
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: z2.union([
4199
- z2.object({
4428
+ inputSchema: z3.union([
4429
+ z3.object({
4200
4430
  projectID,
4201
4431
  id: noteID,
4202
- pinned: z2.boolean().describe("True to pin the note; false to unpin it.")
4432
+ pinned: z3.boolean().describe("True to pin the note; false to unpin it.")
4203
4433
  }).strict(),
4204
- z2.object({
4434
+ z3.object({
4205
4435
  projectName,
4206
4436
  id: noteID,
4207
- pinned: z2.boolean().describe("True to pin the note; false to unpin it.")
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 textResult({ results: [resolved.error] });
4215
- return textResult({
4216
- project: resolved.project,
4217
- results: [store.pin(resolved.project.projectID, raw.id, raw.pinned)]
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: z2.union([
4451
+ inputSchema: z3.union([
4224
4452
  linkSchema.extend({ projectID }),
4225
- z2.object({
4453
+ z3.object({
4226
4454
  projectID,
4227
- links: z2.array(linkSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic directed edges.")
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
- z2.object({
4458
+ z3.object({
4231
4459
  projectName,
4232
- links: z2.array(linkSchema).min(1).max(MAX_BATCH).describe("One to 10 ordered, non-atomic directed edges.")
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 textResult({ results: [resolved.error] });
4240
- const links = "links" in raw ? raw.links : [raw];
4241
- return textResult({
4242
- project: resolved.project,
4243
- results: links.map((link) => store.link(resolved.project.projectID, link.sourceID, link.targetID, link.predicate))
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: z2.union([
4250
- z2.object({ projectID, id: noteID }).strict(),
4251
- z2.object({
4489
+ inputSchema: z3.union([
4490
+ z3.object({ projectID, id: noteID, limit: pageLimit, cursor, snapshot }).strict(),
4491
+ z3.object({
4252
4492
  projectID,
4253
- ids: z2.array(noteID).min(1).max(MAX_BATCH).describe("One to 10 note IDs to read.")
4493
+ ids: z3.array(noteID).min(1).max(MAX_BATCH).describe("One to 10 note IDs to read.")
4254
4494
  }).strict(),
4255
- z2.object({ projectName, id: noteID }).strict(),
4256
- z2.object({
4495
+ z3.object({ projectName, id: noteID, limit: pageLimit, cursor, snapshot }).strict(),
4496
+ z3.object({
4257
4497
  projectName,
4258
- ids: z2.array(noteID).min(1).max(MAX_BATCH).describe("One to 10 note IDs to read.")
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 textResult({ results: [resolved.error] });
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: ids.map((id) => ({
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("UPDATE projects SET name = ?, normalized_name = ?, updated_at = ? WHERE id = ? RETURNING *").get(name, normalizedName, now, projectID2));
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, input) {
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
- if (input.delete) {
4424
- if (!input.id)
4425
- return { ok: false, reason: "id is required for delete" };
4426
- const id2 = input.id;
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
- const existing = input.id ? this.getNoteRow(projectID2, input.id) : undefined;
4456
- if (input.id && !existing) {
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 ${input.id} not found in project ${project.name}`
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 kindValue = input.kind ?? existing?.kind;
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 = input.title === undefined ? existing?.title ?? "" : input.title.trim();
4470
- const summary2 = input.summary === undefined ? existing?.summary ?? "" : input.summary.trim();
4471
- const content2 = input.content === undefined ? existing?.content ?? summary2 : input.content.trim();
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
- FROM note_edges e
4661
- JOIN projects p ON p.id = e.project_id
4662
- WHERE e.project_id = ? AND (e.source_id = ? OR e.target_id = ?)`).all(projectID2, id, id);
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: edges.map(rowToEdge)
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.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, Date.now());
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
- return [];
4697
- const matches = this.db.query(`SELECT n.*, p.name AS project_name, bm25(notes_fts) AS rank
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
- ORDER BY n.pinned DESC, rank
4703
- LIMIT ?`).all(tokens.join(" OR "), projectID2, limit);
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 seen = new Set(cards.map((card) => card.id));
4706
- for (const match of matches.slice(0, 5)) {
4707
- const neighbors = this.db.query(`SELECT e.predicate, n.*, p.name AS project_name
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
- ORDER BY n.pinned DESC
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
- for (const neighbor of neighbors) {
4718
- if (seen.has(neighbor.id))
4719
- continue;
4720
- seen.add(neighbor.id);
4721
- const card = toCard(rowToNote(neighbor), "neighbor");
4722
- card.predicates = [neighbor.predicate];
4723
- cards.push(card);
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
- return cards.slice(0, limit + 5);
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
- constructor(db, indexBackends = []) {
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, REDACTION_POLICY_VERSION, prepared.quarantined ? "quarantined" : "pending", now, now, prepared.quarantined ? now : null);
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: capturePayloadHash2(validated),
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 === REDACTION_POLICY_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 = dirname3(databasePath);
5485
- if (!existsSync5(directory))
5486
- mkdirSync4(directory, { recursive: true });
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);