@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/ARCHITECTURE.md +154 -156
- package/CHANGELOG.md +77 -16
- package/README.md +157 -141
- package/README.tr.md +158 -144
- package/dist/admin.js +221 -53
- package/dist/core.js +233 -122
- package/dist/server.js +626 -94
- package/dist/types/capture/contract.d.ts +2 -2
- package/dist/types/core.d.ts +1 -0
- package/dist/types/db/backup.d.ts +1 -1
- package/dist/types/db/migrations/v010.d.ts +2 -0
- package/dist/types/db/schema.d.ts +3 -2
- package/dist/types/server.d.ts +1 -1
- package/dist/types/store/capture.d.ts +5 -0
- package/dist/types/types.d.ts +1 -1
- package/dist/types/version.d.ts +1 -0
- package/docs/backup-restore-runbook.md +109 -33
- package/docs/backup-restore-runbook.tr.md +148 -0
- package/package.json +4 -2
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 =
|
|
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 = "
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
|
621
|
-
db.exec(
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
763
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1263
|
+
import * as z2 from "zod/v4";
|
|
1097
1264
|
var MAX_BATCH = 10;
|
|
1098
|
-
var projectID =
|
|
1099
|
-
var projectName =
|
|
1100
|
-
var createUpdateSchema =
|
|
1101
|
-
kind:
|
|
1102
|
-
title:
|
|
1103
|
-
summary:
|
|
1104
|
-
content:
|
|
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 =
|
|
1107
|
-
id:
|
|
1108
|
-
kind:
|
|
1109
|
-
title:
|
|
1110
|
-
summary:
|
|
1111
|
-
content:
|
|
1112
|
-
delete:
|
|
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 =
|
|
1115
|
-
var linkSchema =
|
|
1116
|
-
sourceID:
|
|
1117
|
-
targetID:
|
|
1118
|
-
predicate:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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:
|
|
1349
|
+
inputSchema: z2.union([
|
|
1183
1350
|
createUpdateSchema.extend({ projectID }),
|
|
1184
1351
|
patchUpdateSchema.extend({ projectID }),
|
|
1185
|
-
|
|
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
|
-
|
|
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:
|
|
1205
|
-
|
|
1206
|
-
|
|
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:
|
|
1388
|
+
inputSchema: z2.union([
|
|
1222
1389
|
linkSchema.extend({ projectID }),
|
|
1223
|
-
|
|
1390
|
+
z2.object({ projectID, links: z2.array(linkSchema).min(1).max(MAX_BATCH) }).strict(),
|
|
1224
1391
|
linkSchema.extend({ projectName }),
|
|
1225
|
-
|
|
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:
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1308
|
-
if (!
|
|
1475
|
+
for (const literal3 of options.denylist ?? []) {
|
|
1476
|
+
if (!literal3)
|
|
1309
1477
|
continue;
|
|
1310
|
-
const count = text.split(
|
|
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(
|
|
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 =
|
|
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 {
|