@vaur94/agz-memory 0.4.0-beta.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -43,7 +43,7 @@ function validateProjectName(value) {
43
43
  }
44
44
 
45
45
  // src/types.ts
46
- var SCHEMA_VERSION = 9;
46
+ var SCHEMA_VERSION = 10;
47
47
  var INLINE_LIMIT = 1200;
48
48
  var KINDS = ["decision", "fact", "procedure", "context", "research", "preference", "task"];
49
49
  var PREDICATES = ["SUPPORTS", "DERIVED_FROM", "PART_OF", "ABOUT", "PRECEDES", "SUPERSEDES"];
@@ -107,9 +107,12 @@ function hasTable(db, table) {
107
107
  }
108
108
 
109
109
  // src/db/backup.ts
110
- var BACKUP_FORMAT = "opencode2-memory-backup/1";
110
+ var BACKUP_FORMAT = "agz-memory-backup/1";
111
111
  function createVerifiedBackup(db, databasePath, sourceSchema, targetSchema, productVersion) {
112
112
  const sourceHealth = assertHealthyDatabase(db);
113
+ if (sourceHealth.schemaVersion !== undefined && sourceHealth.schemaVersion !== sourceSchema) {
114
+ throw new Error(`backup source schema v${sourceSchema} does not match database schema v${sourceHealth.schemaVersion}`);
115
+ }
113
116
  const checkpoint = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
114
117
  if (checkpoint.busy !== 0)
115
118
  throw new Error("database WAL checkpoint is busy");
@@ -137,6 +140,9 @@ function createVerifiedBackup(db, databasePath, sourceSchema, targetSchema, prod
137
140
  if (JSON.stringify(backupHealth.counts) !== JSON.stringify(sourceHealth.counts)) {
138
141
  throw new Error("backup row counts differ from source database");
139
142
  }
143
+ if (backupHealth.schemaVersion !== undefined && backupHealth.schemaVersion !== sourceSchema) {
144
+ throw new Error("backup schema does not match source schema");
145
+ }
140
146
  const bytes = readFileSync(temporaryDatabasePath);
141
147
  const manifest = {
142
148
  format: BACKUP_FORMAT,
@@ -209,6 +215,9 @@ function verifyBackupManifest(manifestPath) {
209
215
  if (JSON.stringify(health.counts) !== JSON.stringify(manifest.counts)) {
210
216
  throw new Error("backup manifest row counts do not match");
211
217
  }
218
+ if (health.schemaVersion !== undefined && health.schemaVersion !== manifest.sourceSchema) {
219
+ throw new Error("backup manifest source schema does not match database");
220
+ }
212
221
  } finally {
213
222
  db.close();
214
223
  }
@@ -394,7 +403,7 @@ function acquireMigrationLock(databasePath, targetSchema, timeoutMs = 30000) {
394
403
  rmSync2(path, { recursive: true, force: true });
395
404
  throw error;
396
405
  }
397
- if (!existsSync2(path))
406
+ if (!isAlreadyExistsError(error))
398
407
  throw error;
399
408
  if (Date.now() >= deadline) {
400
409
  const current = readMigrationLockOwner(path);
@@ -437,12 +446,84 @@ function processStartMarker(pid) {
437
446
  return;
438
447
  }
439
448
  }
449
+ function isAlreadyExistsError(error) {
450
+ return Boolean(error && typeof error === "object" && "code" in error && String(error.code) === "EEXIST");
451
+ }
440
452
 
441
453
  // src/db/migrations/v009.ts
442
454
  import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
443
455
 
456
+ // src/capture/contract.ts
457
+ import * as z from "zod/v4";
458
+ var CAPTURE_SCHEMA = "agz-memory.capture/1";
459
+ var SUPPORTED_OPENCODE_VERSION = "0.0.0-beta-18743";
460
+ var CAPTURE_EVENT_MAX_BYTES = 16 * 1024;
461
+ var CAPTURE_CONTENT_MAX_CHARACTERS = 4800;
462
+ var CAPTURE_SUMMARY_MAX_CHARACTERS = 1200;
463
+ var CAPTURE_SUBJECT_MAX_CHARACTERS = 240;
464
+ var candidateSchema = z.object({
465
+ kind: z.enum(KINDS),
466
+ title: z.string().min(1).max(240),
467
+ summary: z.string().min(1).max(CAPTURE_SUMMARY_MAX_CHARACTERS),
468
+ content: z.string().min(1).max(CAPTURE_CONTENT_MAX_CHARACTERS),
469
+ subjectKey: z.string().min(1).max(CAPTURE_SUBJECT_MAX_CHARACTERS).optional(),
470
+ intent: z.enum(["create", "supersede", "ignore", "review"]),
471
+ targetNoteID: z.string().min(1).max(240).optional(),
472
+ confidence: z.number().finite().min(0).max(1),
473
+ evidence: z.enum(["explicit-user", "verified-outcome", "session-summary"])
474
+ }).strict();
475
+ var signalSchema = z.object({
476
+ tool: z.string().min(1).max(160),
477
+ status: z.enum(["completed", "error"]),
478
+ errorType: z.string().min(1).max(160).optional()
479
+ }).strict();
480
+ var captureEventSchema = z.object({
481
+ schema: z.literal(CAPTURE_SCHEMA),
482
+ idempotencyKey: z.string().regex(/^[0-9a-f]{64}$/),
483
+ projectID: z.uuid(),
484
+ bindingKey: z.string().regex(/^[0-9a-f]{64}$/),
485
+ kind: z.enum(["user-candidate", "assistant-candidate", "session-summary", "tool-signal"]),
486
+ source: z.object({
487
+ system: z.literal("opencode-v2"),
488
+ opencodeVersion: z.literal(SUPPORTED_OPENCODE_VERSION),
489
+ pluginVersion: z.string().min(1).max(80),
490
+ sessionID: z.string().min(1).max(240),
491
+ messageID: z.string().min(1).max(240).optional(),
492
+ ordinal: z.number().int().nonnegative().optional(),
493
+ toolCallID: z.string().min(1).max(240).optional(),
494
+ observedAt: z.number().int().nonnegative()
495
+ }).strict(),
496
+ candidate: candidateSchema.optional(),
497
+ signal: signalSchema.optional(),
498
+ redaction: z.object({
499
+ policyVersion: z.string().min(1).max(80),
500
+ replacements: z.number().int().nonnegative(),
501
+ truncated: z.boolean()
502
+ }).strict()
503
+ }).strict().superRefine((event, context) => {
504
+ if (event.kind === "tool-signal" && !event.signal) {
505
+ context.addIssue({ code: "custom", message: "tool-signal requires signal" });
506
+ }
507
+ if (event.kind !== "tool-signal" && !event.candidate) {
508
+ context.addIssue({ code: "custom", message: `${event.kind} requires candidate` });
509
+ }
510
+ if (event.kind === "tool-signal" && event.candidate) {
511
+ context.addIssue({ code: "custom", message: "tool-signal cannot carry candidate" });
512
+ }
513
+ if (event.kind !== "tool-signal" && event.signal) {
514
+ context.addIssue({ code: "custom", message: `${event.kind} cannot carry signal` });
515
+ }
516
+ });
517
+ function parseCaptureEvent(value) {
518
+ const event = captureEventSchema.parse(value);
519
+ if (Buffer.byteLength(JSON.stringify(event), "utf8") > CAPTURE_EVENT_MAX_BYTES) {
520
+ throw new Error(`capture event exceeds ${CAPTURE_EVENT_MAX_BYTES} UTF-8 bytes`);
521
+ }
522
+ return event;
523
+ }
524
+
444
525
  // src/db/schema.ts
445
- var SCHEMA_V9_TABLES = `
526
+ var SCHEMA_TABLES = `
446
527
  CREATE TABLE IF NOT EXISTS projects (
447
528
  id TEXT PRIMARY KEY,
448
529
  name TEXT NOT NULL,
@@ -512,28 +593,7 @@ CREATE TABLE IF NOT EXISTS capture_checkpoints (
512
593
  );
513
594
  CREATE INDEX IF NOT EXISTS capture_checkpoints_due_idx
514
595
  ON capture_checkpoints(state, next_reconcile_at);
515
- CREATE TABLE IF NOT EXISTS capture_events (
516
- idempotency_key TEXT PRIMARY KEY,
517
- contract TEXT NOT NULL CHECK (contract = 'opencode2-memory.capture/1'),
518
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
519
- binding_key TEXT NOT NULL REFERENCES project_bindings(binding_key) ON DELETE CASCADE,
520
- event_kind TEXT NOT NULL CHECK (event_kind IN ('user-candidate','assistant-candidate','session-summary','tool-signal')),
521
- source_session_id TEXT NOT NULL,
522
- source_message_id TEXT,
523
- source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
524
- source_tool_call_id TEXT,
525
- payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
526
- payload_hash TEXT,
527
- redaction_version TEXT NOT NULL,
528
- state TEXT NOT NULL CHECK (state IN ('pending','shadowed','review','materialized','duplicate','ignored','rejected','quarantined','failed','dead')),
529
- attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
530
- note_id TEXT,
531
- last_error_code TEXT,
532
- generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0),
533
- created_at INTEGER NOT NULL,
534
- updated_at INTEGER NOT NULL,
535
- processed_at INTEGER
536
- );
596
+ ${captureEventsTable()}
537
597
  CREATE INDEX IF NOT EXISTS capture_events_state_idx ON capture_events(state, updated_at);
538
598
  CREATE INDEX IF NOT EXISTS capture_events_session_idx ON capture_events(project_id, source_session_id);
539
599
  CREATE INDEX IF NOT EXISTS capture_events_note_idx ON capture_events(project_id, note_id);
@@ -617,15 +677,39 @@ CREATE TRIGGER IF NOT EXISTS notes_fts_au AFTER UPDATE OF title, summary, conten
617
677
  VALUES (new.rowid, new.title, new.summary, new.content);
618
678
  END;
619
679
  `;
620
- function createSchemaV9(db) {
621
- db.exec(SCHEMA_V9_TABLES);
680
+ function createSchema(db) {
681
+ db.exec(SCHEMA_TABLES);
622
682
  db.exec(FTS_V9);
623
683
  db.query("DELETE FROM schema_state").run();
624
- db.query("INSERT INTO schema_state(version) VALUES (9)").run();
684
+ db.query("INSERT INTO schema_state(version) VALUES (10)").run();
625
685
  }
626
686
  function rebuildFts(db) {
627
687
  db.query("INSERT INTO notes_fts(notes_fts) VALUES ('rebuild')").run();
628
688
  }
689
+ function captureEventsTable(table = "capture_events") {
690
+ return `CREATE TABLE IF NOT EXISTS ${table} (
691
+ idempotency_key TEXT PRIMARY KEY,
692
+ contract TEXT NOT NULL CHECK (contract = '${CAPTURE_SCHEMA}'),
693
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
694
+ binding_key TEXT NOT NULL REFERENCES project_bindings(binding_key) ON DELETE CASCADE,
695
+ event_kind TEXT NOT NULL CHECK (event_kind IN ('user-candidate','assistant-candidate','session-summary','tool-signal')),
696
+ source_session_id TEXT NOT NULL,
697
+ source_message_id TEXT,
698
+ source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
699
+ source_tool_call_id TEXT,
700
+ payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
701
+ payload_hash TEXT,
702
+ redaction_version TEXT NOT NULL,
703
+ state TEXT NOT NULL CHECK (state IN ('pending','shadowed','review','materialized','duplicate','ignored','rejected','quarantined','failed','dead')),
704
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
705
+ note_id TEXT,
706
+ last_error_code TEXT,
707
+ generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0),
708
+ created_at INTEGER NOT NULL,
709
+ updated_at INTEGER NOT NULL,
710
+ processed_at INTEGER
711
+ );`;
712
+ }
629
713
 
630
714
  // src/db/migrations/v009.ts
631
715
  function migrateV8ToV9(db) {
@@ -694,7 +778,7 @@ function migrateV8ToV9(db) {
694
778
  ALTER TABLE notes_v9 RENAME TO notes;
695
779
  ALTER TABLE note_edges_v9 RENAME TO note_edges;
696
780
  `);
697
- db.exec(SCHEMA_V9_TABLES);
781
+ db.exec(SCHEMA_TABLES);
698
782
  for (const note of notes) {
699
783
  const provenanceID = randomUUID3();
700
784
  db.query(`
@@ -718,6 +802,57 @@ function noteContentHash(kind, title, summary, content) {
718
802
  return createHash3("sha256").update(`${kind}\x00${title}\x00${summary}\x00${content}`, "utf8").digest("hex");
719
803
  }
720
804
 
805
+ // src/db/migrations/v010.ts
806
+ import { createHash as createHash4 } from "crypto";
807
+ function migrateV9ToV10(db) {
808
+ const payloads = db.query("SELECT idempotency_key, payload_json FROM capture_events WHERE payload_json IS NOT NULL").all();
809
+ const migratedPayloads = payloads.map((row) => {
810
+ const event = JSON.parse(row.payload_json);
811
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
812
+ throw new Error(`capture event ${row.idempotency_key} payload is not an object`);
813
+ }
814
+ event.schema = CAPTURE_SCHEMA;
815
+ const payload = JSON.stringify(parseCaptureEvent(event));
816
+ return {
817
+ idempotencyKey: row.idempotency_key,
818
+ payload,
819
+ payloadHash: createHash4("sha256").update(payload, "utf8").digest("hex")
820
+ };
821
+ });
822
+ db.exec("DROP TABLE IF EXISTS capture_events_v10");
823
+ db.exec(captureEventsTable("capture_events_v10"));
824
+ db.query(`
825
+ INSERT INTO capture_events_v10
826
+ (idempotency_key, contract, project_id, binding_key, event_kind,
827
+ source_session_id, source_message_id, source_ordinal, source_tool_call_id,
828
+ payload_json, payload_hash, redaction_version, state, attempt_count,
829
+ note_id, last_error_code, generation, created_at, updated_at, processed_at)
830
+ SELECT idempotency_key, ?, project_id, binding_key, event_kind,
831
+ source_session_id, source_message_id, source_ordinal, source_tool_call_id,
832
+ payload_json, payload_hash, redaction_version, state, attempt_count,
833
+ note_id, last_error_code, generation, created_at, updated_at, processed_at
834
+ FROM capture_events
835
+ `).run(CAPTURE_SCHEMA);
836
+ const updatePayload = db.query(`
837
+ UPDATE capture_events_v10
838
+ SET payload_json = ?, payload_hash = ?
839
+ WHERE idempotency_key = ?
840
+ `);
841
+ for (const row of migratedPayloads) {
842
+ updatePayload.run(row.payload, row.payloadHash, row.idempotencyKey);
843
+ }
844
+ db.exec(`
845
+ DROP TABLE capture_events;
846
+ ALTER TABLE capture_events_v10 RENAME TO capture_events;
847
+ `);
848
+ db.exec(SCHEMA_TABLES);
849
+ db.query("DELETE FROM schema_state").run();
850
+ db.query("INSERT INTO schema_state(version) VALUES (10)").run();
851
+ }
852
+
853
+ // src/version.ts
854
+ var PRODUCT_VERSION = "0.4.1";
855
+
721
856
  // src/db.ts
722
857
  var DDL = `
723
858
  CREATE TABLE IF NOT EXISTS projects (
@@ -759,13 +894,11 @@ CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, targe
759
894
  CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
760
895
  `;
761
896
  function openMemoryDatabase(path) {
762
- const db = new Database2(path, { create: true });
763
- chmodSync2(path, 384);
897
+ let db = openDatabase(path);
898
+ let dbOpen = true;
764
899
  let lock;
765
900
  let backup;
766
901
  try {
767
- db.exec("PRAGMA busy_timeout=5000");
768
- db.exec("PRAGMA journal_mode=WAL");
769
902
  const existingVersion = getSchemaVersion(db);
770
903
  if (existingVersion && existingVersion.version > SCHEMA_VERSION) {
771
904
  throw new Error(`database schema v${existingVersion.version} is newer than supported v${SCHEMA_VERSION}`);
@@ -773,19 +906,36 @@ function openMemoryDatabase(path) {
773
906
  const hasExistingData = hasLegacyV2(db) || hasTable2(db, "notes") || Boolean(existingVersion);
774
907
  if (!hasExistingData) {
775
908
  db.exec("PRAGMA foreign_keys=ON");
776
- db.transaction(() => createSchemaV9(db))();
909
+ db.transaction(() => createSchema(db))();
777
910
  assertHealthyDatabase(db);
778
911
  return { db, close: () => db.close() };
779
912
  }
780
913
  if ((existingVersion?.version ?? 0) < SCHEMA_VERSION) {
914
+ db.close();
915
+ dbOpen = false;
781
916
  lock = acquireMigrationLock(path, SCHEMA_VERSION);
782
- backup = createVerifiedBackup(db, path, existingVersion?.version ?? 2, SCHEMA_VERSION, "0.4.0-beta.1");
917
+ db = openDatabase(path);
918
+ dbOpen = true;
919
+ const migrationVersion = getSchemaVersion(db);
920
+ if (migrationVersion && migrationVersion.version > SCHEMA_VERSION) {
921
+ throw new Error(`database schema v${migrationVersion.version} is newer than supported v${SCHEMA_VERSION}`);
922
+ }
923
+ if ((migrationVersion?.version ?? 0) === SCHEMA_VERSION) {
924
+ lock.release();
925
+ lock = undefined;
926
+ db.exec("PRAGMA foreign_keys=ON");
927
+ db.exec(SCHEMA_TABLES);
928
+ db.exec(FTS_V9);
929
+ assertHealthyDatabase(db);
930
+ return { db, close: () => db.close() };
931
+ }
932
+ backup = createVerifiedBackup(db, path, migrationVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION);
783
933
  db.exec("PRAGMA foreign_keys=OFF");
784
- if (!existingVersion && hasLegacyV2(db)) {
934
+ if (!migrationVersion && hasLegacyV2(db)) {
785
935
  db.exec(DDL);
786
936
  db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
787
937
  migrateFromV2(db, path);
788
- } else if (!existingVersion) {
938
+ } else if (!migrationVersion) {
789
939
  db.exec(DDL);
790
940
  db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
791
941
  db.transaction(() => {
@@ -793,14 +943,18 @@ function openMemoryDatabase(path) {
793
943
  db.query("DELETE FROM schema_state").run();
794
944
  db.query("INSERT INTO schema_state (version) VALUES (8)").run();
795
945
  })();
796
- } else if (existingVersion.version < 8) {
946
+ } else if (migrationVersion.version < 8) {
797
947
  db.exec(DDL);
798
948
  db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
799
949
  migrateToV8(db);
800
950
  }
801
- const version = getSchemaVersion(db)?.version ?? 8;
802
- if (version < 9)
951
+ let version = getSchemaVersion(db)?.version ?? 8;
952
+ if (version < 9) {
803
953
  db.transaction(() => migrateV8ToV9(db))();
954
+ version = 9;
955
+ }
956
+ if (version < 10)
957
+ db.transaction(() => migrateV9ToV10(db))();
804
958
  db.exec("PRAGMA foreign_keys=ON");
805
959
  if (db.query("PRAGMA foreign_keys").get().foreign_keys !== 1) {
806
960
  throw new Error("failed to enable database foreign keys");
@@ -812,13 +966,14 @@ function openMemoryDatabase(path) {
812
966
  return { db, close: () => db.close() };
813
967
  }
814
968
  db.exec("PRAGMA foreign_keys=ON");
815
- db.exec(SCHEMA_V9_TABLES);
969
+ db.exec(SCHEMA_TABLES);
816
970
  db.exec(FTS_V9);
817
971
  assertHealthyDatabase(db);
818
972
  db.exec("PRAGMA foreign_keys=ON");
819
973
  return { db, close: () => db.close() };
820
974
  } catch (error) {
821
- db.close();
975
+ if (dbOpen)
976
+ db.close();
822
977
  if (backup) {
823
978
  try {
824
979
  restoreVerifiedBackup(backup.manifestPath, path, "RESTORE_DATABASE_FROM_VERIFIED_BACKUP");
@@ -831,6 +986,18 @@ function openMemoryDatabase(path) {
831
986
  lock?.release();
832
987
  }
833
988
  }
989
+ function openDatabase(path) {
990
+ const db = new Database2(path, { create: true });
991
+ try {
992
+ chmodSync2(path, 384);
993
+ db.exec("PRAGMA busy_timeout=5000");
994
+ db.exec("PRAGMA journal_mode=WAL");
995
+ return db;
996
+ } catch (error) {
997
+ db.close();
998
+ throw error;
999
+ }
1000
+ }
834
1001
  function getSchemaVersion(db) {
835
1002
  if (!hasTable2(db, "schema_state"))
836
1003
  return;
@@ -1093,29 +1260,29 @@ var MEMORY_GUIDANCE = `Use project-scoped memory for durable facts across sessio
1093
1260
  - project_delete permanently destroys the project and all of its memory. Call project_list first and provide the immutable ID, exact current name, and required confirmation phrase only when deletion is explicitly intended.`;
1094
1261
 
1095
1262
  // src/tools.ts
1096
- import * as z from "zod/v4";
1263
+ import * as z2 from "zod/v4";
1097
1264
  var MAX_BATCH = 10;
1098
- var projectID = z.uuid().describe("The immutable project UUID returned by project_create or project_list.");
1099
- var projectName = z.string().min(1).max(MAX_PROJECT_NAME_LENGTH).describe("The project's unique current name. Prefer projectID when retaining a long-lived reference.");
1100
- var createUpdateSchema = z.object({
1101
- kind: z.enum(KINDS),
1102
- title: z.string(),
1103
- summary: z.string(),
1104
- content: z.string().optional()
1265
+ var projectID = z2.uuid().describe("The immutable project UUID returned by project_create or project_list.");
1266
+ var projectName = z2.string().min(1).max(MAX_PROJECT_NAME_LENGTH).describe("The project's unique current name. Prefer projectID when retaining a long-lived reference.");
1267
+ var createUpdateSchema = z2.object({
1268
+ kind: z2.enum(KINDS),
1269
+ title: z2.string(),
1270
+ summary: z2.string(),
1271
+ content: z2.string().optional()
1105
1272
  }).strict();
1106
- var patchUpdateSchema = z.object({
1107
- id: z.string(),
1108
- kind: z.enum(KINDS).optional(),
1109
- title: z.string().optional(),
1110
- summary: z.string().optional(),
1111
- content: z.string().optional(),
1112
- delete: z.boolean().optional()
1273
+ var patchUpdateSchema = z2.object({
1274
+ id: z2.string(),
1275
+ kind: z2.enum(KINDS).optional(),
1276
+ title: z2.string().optional(),
1277
+ summary: z2.string().optional(),
1278
+ content: z2.string().optional(),
1279
+ delete: z2.boolean().optional()
1113
1280
  }).strict();
1114
- var updateSchema = z.union([createUpdateSchema, patchUpdateSchema]);
1115
- var linkSchema = z.object({
1116
- sourceID: z.string(),
1117
- targetID: z.string(),
1118
- predicate: z.enum(PREDICATES)
1281
+ var updateSchema = z2.union([createUpdateSchema, patchUpdateSchema]);
1282
+ var linkSchema = z2.object({
1283
+ sourceID: z2.string(),
1284
+ targetID: z2.string(),
1285
+ predicate: z2.enum(PREDICATES)
1119
1286
  }).strict();
1120
1287
  function textResult(value) {
1121
1288
  return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
@@ -1128,39 +1295,39 @@ function registerTools(server, store) {
1128
1295
  server.registerTool("project_list", {
1129
1296
  title: "List memory projects",
1130
1297
  description: "List all memory projects with their immutable IDs, current names, note counts, and pinned-note counts. Use this before selecting a project by ID.",
1131
- inputSchema: z.object({}).strict(),
1298
+ inputSchema: z2.object({}).strict(),
1132
1299
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
1133
1300
  }, async () => textResult({ projects: store.listProjects() }));
1134
1301
  server.registerTool("project_create", {
1135
1302
  title: "Create a memory project",
1136
1303
  description: "Create an empty memory project. The returned projectID is immutable; the unique project name may be changed later.",
1137
- inputSchema: z.object({ projectName }).strict(),
1304
+ inputSchema: z2.object({ projectName }).strict(),
1138
1305
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
1139
1306
  }, async ({ projectName: projectName2 }) => textResult({ results: [store.createProject(projectName2)] }));
1140
1307
  server.registerTool("project_update", {
1141
1308
  title: "Rename a memory project",
1142
1309
  description: "Rename one project by its immutable projectID. Renaming does not change the ID or detach any notes.",
1143
- inputSchema: z.object({ projectID, projectName }).strict(),
1310
+ inputSchema: z2.object({ projectID, projectName }).strict(),
1144
1311
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1145
1312
  }, async ({ projectID: projectID2, projectName: projectName2 }) => textResult({ results: [store.updateProject(projectID2, projectName2)] }));
1146
1313
  server.registerTool("project_delete", {
1147
1314
  title: "Permanently delete a memory project",
1148
1315
  description: "DANGER: Permanently deletes the selected project and every note, pinned note, graph edge, and search record owned by it. This cannot be undone. First call project_list, verify the immutable projectID and current name, then provide both confirmation fields exactly.",
1149
- inputSchema: z.object({
1316
+ inputSchema: z2.object({
1150
1317
  projectID,
1151
1318
  confirmProjectName: projectName.describe("Must exactly match the project's current case-sensitive name. This prevents deletion after an unnoticed rename or wrong-ID selection."),
1152
- confirmation: z.literal("DELETE_PROJECT_AND_ALL_MEMORY").describe("Required destructive-action confirmation phrase.")
1319
+ confirmation: z2.literal("DELETE_PROJECT_AND_ALL_MEMORY").describe("Required destructive-action confirmation phrase.")
1153
1320
  }).strict(),
1154
1321
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
1155
1322
  }, async ({ projectID: projectID2, confirmProjectName }) => textResult({ results: [store.deleteProject(projectID2, confirmProjectName)] }));
1156
1323
  server.registerTool("memory_recall", {
1157
1324
  title: "Search project memory",
1158
1325
  description: "Search memory only inside one project selected by immutable projectID or unique projectName. Pass one query or up to 10 queries. Indexed cards require memory_read for full content.",
1159
- inputSchema: z.union([
1160
- z.object({ projectID, query: z.string() }).strict(),
1161
- z.object({ projectID, queries: z.array(z.string()).min(1).max(MAX_BATCH) }).strict(),
1162
- z.object({ projectName, query: z.string() }).strict(),
1163
- z.object({ projectName, queries: z.array(z.string()).min(1).max(MAX_BATCH) }).strict()
1326
+ inputSchema: z2.union([
1327
+ z2.object({ projectID, query: z2.string() }).strict(),
1328
+ z2.object({ projectID, queries: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict(),
1329
+ z2.object({ projectName, query: z2.string() }).strict(),
1330
+ z2.object({ projectName, queries: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict()
1164
1331
  ]),
1165
1332
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
1166
1333
  }, async (raw) => {
@@ -1179,13 +1346,13 @@ function registerTools(server, store) {
1179
1346
  server.registerTool("memory_update", {
1180
1347
  title: "Create, patch, or delete project memory",
1181
1348
  description: "Create or patch notes only inside one selected project. Setting delete:true permanently deletes only the specified note from that project; verify the note ID before using delete. A batch contains up to 10 ordered, non-atomic updates: inspect every result because earlier items remain applied if a later item fails. Do not batch destructive deletes unless partial completion is acceptable. Pin state is changed only through memory_pin.",
1182
- inputSchema: z.union([
1349
+ inputSchema: z2.union([
1183
1350
  createUpdateSchema.extend({ projectID }),
1184
1351
  patchUpdateSchema.extend({ projectID }),
1185
- z.object({ projectID, updates: z.array(updateSchema).min(1).max(MAX_BATCH) }).strict(),
1352
+ z2.object({ projectID, updates: z2.array(updateSchema).min(1).max(MAX_BATCH) }).strict(),
1186
1353
  createUpdateSchema.extend({ projectName }),
1187
1354
  patchUpdateSchema.extend({ projectName }),
1188
- z.object({ projectName, updates: z.array(updateSchema).min(1).max(MAX_BATCH) }).strict()
1355
+ z2.object({ projectName, updates: z2.array(updateSchema).min(1).max(MAX_BATCH) }).strict()
1189
1356
  ]),
1190
1357
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
1191
1358
  }, async (raw) => {
@@ -1201,9 +1368,9 @@ function registerTools(server, store) {
1201
1368
  server.registerTool("memory_pin", {
1202
1369
  title: "Pin or unpin project memory",
1203
1370
  description: "Set the pinned state of one active note inside one selected project. Pinned matching notes are prioritized in recall results. This tool never deletes content.",
1204
- inputSchema: z.union([
1205
- z.object({ projectID, id: z.string(), pinned: z.boolean() }).strict(),
1206
- z.object({ projectName, id: z.string(), pinned: z.boolean() }).strict()
1371
+ inputSchema: z2.union([
1372
+ z2.object({ projectID, id: z2.string(), pinned: z2.boolean() }).strict(),
1373
+ z2.object({ projectName, id: z2.string(), pinned: z2.boolean() }).strict()
1207
1374
  ]),
1208
1375
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1209
1376
  }, async (raw) => {
@@ -1218,11 +1385,11 @@ function registerTools(server, store) {
1218
1385
  server.registerTool("memory_link", {
1219
1386
  title: "Link project memories",
1220
1387
  description: `Create one graph edge or up to 10 ordered, non-atomic edge operations between notes in the same selected project; inspect every result because earlier links remain applied if a later item fails. Cross-project links are rejected. Predicates: ${PREDICATES.join(", ")}.`,
1221
- inputSchema: z.union([
1388
+ inputSchema: z2.union([
1222
1389
  linkSchema.extend({ projectID }),
1223
- z.object({ projectID, links: z.array(linkSchema).min(1).max(MAX_BATCH) }).strict(),
1390
+ z2.object({ projectID, links: z2.array(linkSchema).min(1).max(MAX_BATCH) }).strict(),
1224
1391
  linkSchema.extend({ projectName }),
1225
- z.object({ projectName, links: z.array(linkSchema).min(1).max(MAX_BATCH) }).strict()
1392
+ z2.object({ projectName, links: z2.array(linkSchema).min(1).max(MAX_BATCH) }).strict()
1226
1393
  ]),
1227
1394
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1228
1395
  }, async (raw) => {
@@ -1238,11 +1405,11 @@ function registerTools(server, store) {
1238
1405
  server.registerTool("memory_read", {
1239
1406
  title: "Read project memory",
1240
1407
  description: "Read one note or up to 10 notes from one selected project, including full content, pin state, project identity, and same-project graph edges.",
1241
- inputSchema: z.union([
1242
- z.object({ projectID, id: z.string() }).strict(),
1243
- z.object({ projectID, ids: z.array(z.string()).min(1).max(MAX_BATCH) }).strict(),
1244
- z.object({ projectName, id: z.string() }).strict(),
1245
- z.object({ projectName, ids: z.array(z.string()).min(1).max(MAX_BATCH) }).strict()
1408
+ inputSchema: z2.union([
1409
+ z2.object({ projectID, id: z2.string() }).strict(),
1410
+ z2.object({ projectID, ids: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict(),
1411
+ z2.object({ projectName, id: z2.string() }).strict(),
1412
+ z2.object({ projectName, ids: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict()
1246
1413
  ]),
1247
1414
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
1248
1415
  }, async (raw) => {
@@ -1262,7 +1429,7 @@ function registerTools(server, store) {
1262
1429
 
1263
1430
  // src/server.ts
1264
1431
  var SERVER_NAME = "agz-memory";
1265
- var SERVER_VERSION = "0.4.0-beta.1";
1432
+ var SERVER_VERSION = PRODUCT_VERSION;
1266
1433
  function createMemoryServer(store) {
1267
1434
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: MEMORY_GUIDANCE });
1268
1435
  registerTools(server, store);
@@ -1273,9 +1440,10 @@ function createMemoryServer(store) {
1273
1440
  import { randomUUID as randomUUID5 } from "crypto";
1274
1441
 
1275
1442
  // src/retrieval/derived.ts
1276
- import { createHash as createHash4 } from "crypto";
1443
+ import { createHash as createHash5 } from "crypto";
1277
1444
 
1278
1445
  // src/capture/redact.ts
1446
+ var REDACTION_POLICY_VERSION = "redaction/1";
1279
1447
  var RULES = [
1280
1448
  {
1281
1449
  name: "private-key",
@@ -1304,15 +1472,15 @@ function redactText(value, options = {}) {
1304
1472
  let replacements = 0;
1305
1473
  let highRisk = 0;
1306
1474
  const classes = {};
1307
- for (const literal2 of options.denylist ?? []) {
1308
- if (!literal2)
1475
+ for (const literal3 of options.denylist ?? []) {
1476
+ if (!literal3)
1309
1477
  continue;
1310
- const count = text.split(literal2).length - 1;
1478
+ const count = text.split(literal3).length - 1;
1311
1479
  if (count === 0)
1312
1480
  continue;
1313
1481
  replacements += count;
1314
1482
  classes.denylist = (classes.denylist ?? 0) + count;
1315
- text = text.replaceAll(literal2, "[REDACTED:denylist]");
1483
+ text = text.replaceAll(literal3, "[REDACTED:denylist]");
1316
1484
  }
1317
1485
  for (const rule of RULES) {
1318
1486
  text = text.replace(rule.pattern, () => {
@@ -1362,7 +1530,7 @@ function deriveDocument(source) {
1362
1530
  const content = redactText(source.content);
1363
1531
  if (title.quarantined || summary.quarantined || content.quarantined)
1364
1532
  return;
1365
- const contentHash = createHash4("sha256").update(`${source.kind}\x00${title.text}\x00${summary.text}\x00${content.text}`, "utf8").digest("hex");
1533
+ const contentHash = createHash5("sha256").update(`${source.kind}\x00${title.text}\x00${summary.text}\x00${content.text}`, "utf8").digest("hex");
1366
1534
  return {
1367
1535
  projectID: source.projectID,
1368
1536
  noteID: source.noteID,
@@ -1756,6 +1924,359 @@ function toCard(note, via) {
1756
1924
  };
1757
1925
  }
1758
1926
 
1927
+ // src/store/capture.ts
1928
+ import { createHash as createHash6, randomUUID as randomUUID6 } from "crypto";
1929
+
1930
+ // src/capture/policy.ts
1931
+ var EXTRACTOR_VERSION = "deterministic-extractor/1";
1932
+ function canAutoWrite(candidate, redaction, allowedKinds = ["preference", "decision"], minConfidence = 0.95) {
1933
+ return candidate.evidence === "explicit-user" && candidate.confidence >= minConfidence && allowedKinds.includes(candidate.kind) && !redaction.truncated && !redaction.quarantined && (candidate.intent === "create" || candidate.intent === "supersede");
1934
+ }
1935
+ function normalizeSubjectKey(value) {
1936
+ return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLocaleLowerCase("en-US").slice(0, 240);
1937
+ }
1938
+
1939
+ // src/store/capture.ts
1940
+ class CaptureStore {
1941
+ db;
1942
+ indexBackends;
1943
+ constructor(db, indexBackends = []) {
1944
+ this.db = db;
1945
+ this.indexBackends = indexBackends;
1946
+ }
1947
+ bindProject(input) {
1948
+ const workspaceID = input.workspaceID ?? "";
1949
+ const canonicalPathHash = sha256(input.canonicalDirectory);
1950
+ const bindingKey = sha256(["opencode-v2", input.opencodeProjectID, workspaceID, canonicalPathHash].join("\x00"));
1951
+ const project = this.db.query("SELECT id FROM projects WHERE id = ?").get(input.memoryProjectID);
1952
+ if (!project)
1953
+ throw new Error(`memory project ${input.memoryProjectID} not found`);
1954
+ const existing = this.db.query(`SELECT * FROM project_bindings
1955
+ WHERE source = 'opencode-v2' AND source_project_id = ? AND workspace_id = ?`).get(input.opencodeProjectID, workspaceID);
1956
+ if (existing) {
1957
+ if (existing.binding_key !== bindingKey || existing.project_id !== input.memoryProjectID || existing.canonical_path_hash !== canonicalPathHash) {
1958
+ throw new Error("binding_conflict");
1959
+ }
1960
+ return { bindingKey, projectID: existing.project_id };
1961
+ }
1962
+ const now = Date.now();
1963
+ this.db.query(`
1964
+ INSERT INTO project_bindings
1965
+ (binding_key, project_id, source, source_project_id, workspace_id,
1966
+ canonical_path_hash, created_at, updated_at)
1967
+ VALUES (?, ?, 'opencode-v2', ?, ?, ?, ?, ?)
1968
+ `).run(bindingKey, input.memoryProjectID, input.opencodeProjectID, workspaceID, canonicalPathHash, now, now);
1969
+ return { bindingKey, projectID: input.memoryProjectID };
1970
+ }
1971
+ checkpoint(sessionID, bindingKey, projectID2, messageID, state = "active") {
1972
+ const binding = this.binding(bindingKey, projectID2);
1973
+ if (!binding)
1974
+ throw new Error("binding_conflict");
1975
+ const now = Date.now();
1976
+ this.db.query(`
1977
+ INSERT INTO capture_checkpoints
1978
+ (session_id, binding_key, project_id, state, last_message_id,
1979
+ next_reconcile_at, failure_count, created_at, updated_at)
1980
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
1981
+ ON CONFLICT(session_id) DO UPDATE SET
1982
+ binding_key = excluded.binding_key,
1983
+ project_id = excluded.project_id,
1984
+ state = excluded.state,
1985
+ last_message_id = COALESCE(excluded.last_message_id, capture_checkpoints.last_message_id),
1986
+ next_reconcile_at = excluded.next_reconcile_at,
1987
+ updated_at = excluded.updated_at
1988
+ WHERE capture_checkpoints.binding_key = excluded.binding_key
1989
+ AND capture_checkpoints.project_id = excluded.project_id
1990
+ `).run(sessionID, bindingKey, projectID2, state, messageID ?? null, now, now, now);
1991
+ const row = this.db.query("SELECT binding_key, project_id FROM capture_checkpoints WHERE session_id = ?").get(sessionID);
1992
+ if (!row || row.binding_key !== bindingKey || row.project_id !== projectID2) {
1993
+ throw new Error("checkpoint_binding_conflict");
1994
+ }
1995
+ }
1996
+ markReconciled(sessionID, state, lastMessageID, failed = false) {
1997
+ const now = Date.now();
1998
+ const result = this.db.query(`
1999
+ UPDATE capture_checkpoints
2000
+ SET state = ?,
2001
+ last_message_id = COALESCE(?, last_message_id),
2002
+ last_reconciled_at = ?,
2003
+ next_reconcile_at = ?,
2004
+ failure_count = CASE WHEN ? THEN failure_count + 1 ELSE 0 END,
2005
+ updated_at = ?
2006
+ WHERE session_id = ?
2007
+ `).run(state, lastMessageID ?? null, now, now + (failed ? 5000 : 30000), failed, now, sessionID);
2008
+ if (result.changes === 0)
2009
+ throw new Error(`checkpoint ${sessionID} not found`);
2010
+ }
2011
+ getCheckpoint(sessionID) {
2012
+ const row = this.db.query("SELECT session_id, last_message_id, state FROM capture_checkpoints WHERE session_id = ?").get(sessionID);
2013
+ return row ? {
2014
+ sessionID: row.session_id,
2015
+ ...row.last_message_id ? { lastMessageID: row.last_message_id } : {},
2016
+ state: row.state
2017
+ } : undefined;
2018
+ }
2019
+ ingest(input, mode, options = {}) {
2020
+ const parsed = parseCaptureEvent(input);
2021
+ if (!this.binding(parsed.bindingKey, parsed.projectID))
2022
+ throw new Error("binding_conflict");
2023
+ const prepared = prepareForPersistence(parsed, options.denylist);
2024
+ const now = Date.now();
2025
+ let result = {
2026
+ outcome: prepared.quarantined ? "quarantined" : "shadowed",
2027
+ idempotencyKey: parsed.idempotencyKey
2028
+ };
2029
+ this.db.transaction(() => {
2030
+ const inserted = this.db.query(`
2031
+ INSERT OR IGNORE INTO capture_events
2032
+ (idempotency_key, contract, project_id, binding_key, event_kind,
2033
+ source_session_id, source_message_id, source_ordinal, source_tool_call_id,
2034
+ payload_json, payload_hash, redaction_version, state, attempt_count,
2035
+ generation, created_at, updated_at, processed_at)
2036
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
2037
+ `).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);
2038
+ if (inserted.changes === 0) {
2039
+ const existing = this.db.query("SELECT state, note_id FROM capture_events WHERE idempotency_key = ?").get(parsed.idempotencyKey);
2040
+ result = {
2041
+ outcome: "duplicate",
2042
+ idempotencyKey: parsed.idempotencyKey,
2043
+ ...existing.note_id ? { noteID: existing.note_id } : {},
2044
+ existing: true
2045
+ };
2046
+ return;
2047
+ }
2048
+ if (prepared.quarantined)
2049
+ return;
2050
+ if (mode === "shadow") {
2051
+ this.finishEvent(parsed.idempotencyKey, "shadowed", null, now);
2052
+ result.outcome = "shadowed";
2053
+ return;
2054
+ }
2055
+ const candidate = prepared.event.candidate;
2056
+ if (!candidate) {
2057
+ this.finishEvent(parsed.idempotencyKey, "shadowed", null, now);
2058
+ result.outcome = "shadowed";
2059
+ return;
2060
+ }
2061
+ if (candidate.intent === "ignore") {
2062
+ this.finishEvent(parsed.idempotencyKey, "ignored", null, now);
2063
+ result.outcome = "ignored";
2064
+ return;
2065
+ }
2066
+ if (candidate.intent === "review" || !canAutoWrite(candidate, {
2067
+ truncated: prepared.event.redaction.truncated,
2068
+ quarantined: prepared.event.redaction.replacements > 0
2069
+ }, options.allowedKinds, options.minConfidence)) {
2070
+ this.finishEvent(parsed.idempotencyKey, "review", null, now);
2071
+ result.outcome = "review";
2072
+ return;
2073
+ }
2074
+ result = this.materialize(prepared.event, candidate, now);
2075
+ })();
2076
+ return result;
2077
+ }
2078
+ runRetention(now = Date.now(), batchSize = 100) {
2079
+ const terminalCutoff = now - 30 * 24 * 60 * 60 * 1000;
2080
+ const quarantineCutoff = now - 7 * 24 * 60 * 60 * 1000;
2081
+ const summarized = this.db.query(`
2082
+ UPDATE capture_events
2083
+ SET payload_json = NULL, payload_hash = NULL, updated_at = ?
2084
+ WHERE idempotency_key IN (
2085
+ SELECT idempotency_key FROM capture_events
2086
+ WHERE state IN ('materialized','duplicate','ignored','rejected','shadowed','review','failed','dead')
2087
+ AND processed_at < ? AND payload_json IS NOT NULL
2088
+ ORDER BY processed_at LIMIT ?
2089
+ )
2090
+ `).run(now, terminalCutoff, batchSize).changes;
2091
+ const deleted = this.db.query(`
2092
+ DELETE FROM capture_events
2093
+ WHERE idempotency_key IN (
2094
+ SELECT idempotency_key FROM capture_events
2095
+ WHERE state = 'quarantined' AND processed_at < ?
2096
+ ORDER BY processed_at LIMIT ?
2097
+ )
2098
+ `).run(quarantineCutoff, batchSize).changes;
2099
+ const checkpoints = this.db.query(`
2100
+ DELETE FROM capture_checkpoints
2101
+ WHERE session_id IN (
2102
+ SELECT session_id FROM capture_checkpoints
2103
+ WHERE (state IN ('idle','closed') AND updated_at < ?)
2104
+ OR (state = 'unavailable' AND updated_at < ?)
2105
+ ORDER BY updated_at LIMIT ?
2106
+ )
2107
+ `).run(terminalCutoff, quarantineCutoff, batchSize).changes;
2108
+ return { summarized, deleted, checkpoints };
2109
+ }
2110
+ runRetentionBacklog(now = Date.now(), batchSize = 100, maxBatches = 10) {
2111
+ const total = { summarized: 0, deleted: 0, checkpoints: 0 };
2112
+ for (let batch = 0;batch < maxBatches; batch++) {
2113
+ const result = this.runRetention(now, batchSize);
2114
+ total.summarized += result.summarized;
2115
+ total.deleted += result.deleted;
2116
+ total.checkpoints += result.checkpoints;
2117
+ if (result.summarized < batchSize && result.deleted < batchSize && result.checkpoints < batchSize)
2118
+ break;
2119
+ }
2120
+ return total;
2121
+ }
2122
+ materialize(event, candidate, now) {
2123
+ const subjectKey = candidate.subjectKey ? normalizeSubjectKey(candidate.subjectKey) : null;
2124
+ const hash = noteContentHash(candidate.kind, candidate.title, candidate.summary, candidate.content);
2125
+ const existing = subjectKey ? this.db.query(`SELECT * FROM notes
2126
+ WHERE project_id = ? AND kind = ? AND subject_key = ? AND status = 'active'`).get(event.projectID, candidate.kind, subjectKey) : undefined;
2127
+ if (existing?.content_hash === hash) {
2128
+ this.finishEvent(event.idempotencyKey, "duplicate", existing.id, now);
2129
+ return { outcome: "duplicate", idempotencyKey: event.idempotencyKey, noteID: existing.id };
2130
+ }
2131
+ if (existing) {
2132
+ if (candidate.intent !== "supersede" || candidate.targetNoteID !== existing.id || candidate.confidence < 0.95) {
2133
+ this.finishEvent(event.idempotencyKey, "review", existing.id, now);
2134
+ return { outcome: "review", idempotencyKey: event.idempotencyKey, noteID: existing.id };
2135
+ }
2136
+ this.db.query(`
2137
+ UPDATE notes
2138
+ SET status = 'superseded', current_revision = current_revision + 1, updated_at = ?
2139
+ WHERE project_id = ? AND id = ? AND status = 'active'
2140
+ `).run(now, event.projectID, existing.id);
2141
+ this.recordRevision(event, existing.id, now);
2142
+ const id2 = this.insertCapturedNote(event, candidate, subjectKey, existing.id, hash, now);
2143
+ this.db.query(`
2144
+ INSERT INTO note_edges
2145
+ (id, project_id, source_id, target_id, predicate, created_at)
2146
+ VALUES (?, ?, ?, ?, 'SUPERSEDES', ?)
2147
+ `).run(randomUUID6(), event.projectID, id2, existing.id, now);
2148
+ for (const backend of this.indexBackends) {
2149
+ this.enqueueOutbox(backend, "delete-note", event.projectID, existing.id, existing.current_revision + 1, existing.content_hash, now);
2150
+ const note2 = this.db.query("SELECT * FROM notes WHERE id = ?").get(id2);
2151
+ this.enqueueOutbox(backend, "upsert-note", event.projectID, id2, 1, derivedHash(note2), now);
2152
+ }
2153
+ this.finishEvent(event.idempotencyKey, "materialized", id2, now);
2154
+ return { outcome: "materialized", idempotencyKey: event.idempotencyKey, noteID: id2 };
2155
+ }
2156
+ if (candidate.intent === "supersede") {
2157
+ this.finishEvent(event.idempotencyKey, "review", candidate.targetNoteID ?? null, now);
2158
+ return {
2159
+ outcome: "review",
2160
+ idempotencyKey: event.idempotencyKey,
2161
+ ...candidate.targetNoteID ? { noteID: candidate.targetNoteID } : {}
2162
+ };
2163
+ }
2164
+ const id = this.insertCapturedNote(event, candidate, subjectKey, null, hash, now);
2165
+ const note = this.db.query("SELECT * FROM notes WHERE id = ?").get(id);
2166
+ for (const backend of this.indexBackends) {
2167
+ this.enqueueOutbox(backend, "upsert-note", event.projectID, id, 1, derivedHash(note), now);
2168
+ }
2169
+ this.finishEvent(event.idempotencyKey, "materialized", id, now);
2170
+ return { outcome: "materialized", idempotencyKey: event.idempotencyKey, noteID: id };
2171
+ }
2172
+ insertCapturedNote(event, candidate, subjectKey, supersedesID, contentHash, now) {
2173
+ const id = randomUUID6();
2174
+ const sizeClass = candidate.content.length <= 1200 ? "inline" : "indexed";
2175
+ this.db.query(`
2176
+ INSERT INTO notes
2177
+ (id, project_id, kind, title, summary, content, size_class, pinned, status,
2178
+ supersedes_id, current_revision, subject_key, content_hash, created_at, updated_at)
2179
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, 1, ?, ?, ?, ?)
2180
+ `).run(id, event.projectID, candidate.kind, candidate.title, candidate.summary, candidate.content, sizeClass, supersedesID, subjectKey, contentHash, now, now);
2181
+ this.recordRevision(event, id, now);
2182
+ return id;
2183
+ }
2184
+ recordRevision(event, noteID, now) {
2185
+ const note = this.db.query("SELECT * FROM notes WHERE project_id = ? AND id = ?").get(event.projectID, noteID);
2186
+ const provenanceID = randomUUID6();
2187
+ this.db.query(`
2188
+ INSERT INTO note_provenance
2189
+ (id, project_id, note_id, source_type, capture_event_id, source_session_id,
2190
+ source_message_id, source_ordinal, source_tool_call_id, redaction_version,
2191
+ extractor_version, confidence, created_at)
2192
+ VALUES (?, ?, ?, 'opencode-capture', ?, ?, ?, ?, ?, ?, ?, ?, ?)
2193
+ `).run(provenanceID, event.projectID, noteID, event.idempotencyKey, event.source.sessionID, event.source.messageID ?? null, event.source.ordinal ?? null, event.source.toolCallID ?? null, REDACTION_POLICY_VERSION, EXTRACTOR_VERSION, event.candidate?.confidence ?? null, now);
2194
+ this.db.query(`
2195
+ INSERT INTO note_revisions
2196
+ (project_id, note_id, revision, kind, title, summary, content, size_class,
2197
+ pinned, status, supersedes_id, subject_key, content_hash, provenance_id, created_at)
2198
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2199
+ `).run(note.project_id, note.id, note.current_revision, note.kind, note.title, note.summary, note.content, note.size_class, note.pinned, note.status, note.supersedes_id, note.subject_key, note.content_hash, provenanceID, now);
2200
+ }
2201
+ enqueueOutbox(backend, operation, projectID2, noteID, revision, contentHash, now) {
2202
+ this.db.query(`
2203
+ INSERT OR IGNORE INTO index_outbox
2204
+ (backend, operation, project_id, note_id, revision, content_hash,
2205
+ state, attempt_count, available_at, created_at)
2206
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
2207
+ `).run(backend, operation, projectID2, noteID, revision, contentHash, now, now);
2208
+ }
2209
+ finishEvent(idempotencyKey, state, noteID, now) {
2210
+ this.db.query(`
2211
+ UPDATE capture_events
2212
+ SET state = ?, note_id = ?, updated_at = ?, processed_at = ?
2213
+ WHERE idempotency_key = ?
2214
+ `).run(state, noteID, now, now, idempotencyKey);
2215
+ }
2216
+ binding(bindingKey, projectID2) {
2217
+ return this.db.query("SELECT * FROM project_bindings WHERE binding_key = ? AND project_id = ?").get(bindingKey, projectID2);
2218
+ }
2219
+ }
2220
+ function prepareForPersistence(event, denylist) {
2221
+ const copy = structuredClone(event);
2222
+ let replacements = 0;
2223
+ let truncated = copy.redaction.truncated;
2224
+ let quarantined = event.redaction.policyVersion.endsWith("/quarantined");
2225
+ if (copy.candidate) {
2226
+ for (const field of ["title", "summary", "content", "subjectKey"]) {
2227
+ const value = copy.candidate[field];
2228
+ if (typeof value !== "string")
2229
+ continue;
2230
+ const maximum = field === "title" || field === "subjectKey" ? 240 : field === "summary" ? 1200 : 4800;
2231
+ const result = redactText(value, { maxCharacters: maximum, denylist });
2232
+ copy.candidate[field] = result.text;
2233
+ replacements += result.replacements;
2234
+ truncated ||= result.truncated;
2235
+ quarantined ||= result.quarantined;
2236
+ }
2237
+ }
2238
+ if (copy.signal) {
2239
+ for (const field of ["tool", "errorType"]) {
2240
+ const value = copy.signal[field];
2241
+ if (typeof value !== "string")
2242
+ continue;
2243
+ const result = redactText(value, { maxCharacters: 160, denylist });
2244
+ copy.signal[field] = result.text;
2245
+ replacements += result.replacements;
2246
+ truncated ||= result.truncated;
2247
+ quarantined ||= result.quarantined;
2248
+ }
2249
+ }
2250
+ copy.redaction = {
2251
+ policyVersion: REDACTION_POLICY_VERSION,
2252
+ replacements: copy.redaction.replacements + replacements,
2253
+ truncated
2254
+ };
2255
+ const validated = parseCaptureEvent(copy);
2256
+ const payload = quarantined ? null : JSON.stringify(validated);
2257
+ return {
2258
+ event: validated,
2259
+ payload,
2260
+ payloadHash: payload ? sha256(payload) : null,
2261
+ quarantined,
2262
+ additionalReplacements: replacements
2263
+ };
2264
+ }
2265
+ function sha256(value) {
2266
+ return createHash6("sha256").update(value, "utf8").digest("hex");
2267
+ }
2268
+ function derivedHash(note) {
2269
+ return deriveDocument({
2270
+ projectID: note.project_id,
2271
+ noteID: note.id,
2272
+ revision: note.current_revision,
2273
+ kind: note.kind,
2274
+ title: note.title,
2275
+ summary: note.summary,
2276
+ content: note.content
2277
+ })?.contentHash ?? null;
2278
+ }
2279
+
1759
2280
  // src/index.ts
1760
2281
  function main() {
1761
2282
  const { databasePath } = resolveConfig();
@@ -1764,6 +2285,16 @@ function main() {
1764
2285
  mkdirSync3(directory, { recursive: true });
1765
2286
  const opened = openMemoryDatabase(databasePath);
1766
2287
  const store = new MemoryStore(opened.db);
2288
+ const capture = new CaptureStore(opened.db);
2289
+ capture.runRetentionBacklog();
2290
+ const retentionTimer = setInterval(() => {
2291
+ try {
2292
+ capture.runRetentionBacklog();
2293
+ } catch (error) {
2294
+ console.error(`[agz-memory] retention failed: ${error instanceof Error ? error.message : String(error)}`);
2295
+ }
2296
+ }, 60 * 60000);
2297
+ retentionTimer.unref();
1767
2298
  const handle = serveStdio(() => createMemoryServer(store), {
1768
2299
  onerror: (error) => console.error(`[agz-memory] ${error.message}`)
1769
2300
  });
@@ -1772,6 +2303,7 @@ function main() {
1772
2303
  if (closing)
1773
2304
  return;
1774
2305
  closing = true;
2306
+ clearInterval(retentionTimer);
1775
2307
  try {
1776
2308
  await handle.close();
1777
2309
  } finally {