@vaur94/agz-memory 0.4.0-beta.1 → 0.4.0
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 +58 -16
- package/README.md +157 -141
- package/README.tr.md +158 -144
- package/dist/admin.js +172 -44
- package/dist/core.js +184 -113
- package/dist/server.js +577 -85
- 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,7 +107,7 @@ 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
113
|
const checkpoint = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
@@ -441,8 +441,77 @@ function processStartMarker(pid) {
|
|
|
441
441
|
// src/db/migrations/v009.ts
|
|
442
442
|
import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
|
|
443
443
|
|
|
444
|
+
// src/capture/contract.ts
|
|
445
|
+
import * as z from "zod/v4";
|
|
446
|
+
var CAPTURE_SCHEMA = "agz-memory.capture/1";
|
|
447
|
+
var SUPPORTED_OPENCODE_VERSION = "0.0.0-beta-18743";
|
|
448
|
+
var CAPTURE_EVENT_MAX_BYTES = 16 * 1024;
|
|
449
|
+
var CAPTURE_CONTENT_MAX_CHARACTERS = 4800;
|
|
450
|
+
var CAPTURE_SUMMARY_MAX_CHARACTERS = 1200;
|
|
451
|
+
var CAPTURE_SUBJECT_MAX_CHARACTERS = 240;
|
|
452
|
+
var candidateSchema = z.object({
|
|
453
|
+
kind: z.enum(KINDS),
|
|
454
|
+
title: z.string().min(1).max(240),
|
|
455
|
+
summary: z.string().min(1).max(CAPTURE_SUMMARY_MAX_CHARACTERS),
|
|
456
|
+
content: z.string().min(1).max(CAPTURE_CONTENT_MAX_CHARACTERS),
|
|
457
|
+
subjectKey: z.string().min(1).max(CAPTURE_SUBJECT_MAX_CHARACTERS).optional(),
|
|
458
|
+
intent: z.enum(["create", "supersede", "ignore", "review"]),
|
|
459
|
+
targetNoteID: z.string().min(1).max(240).optional(),
|
|
460
|
+
confidence: z.number().finite().min(0).max(1),
|
|
461
|
+
evidence: z.enum(["explicit-user", "verified-outcome", "session-summary"])
|
|
462
|
+
}).strict();
|
|
463
|
+
var signalSchema = z.object({
|
|
464
|
+
tool: z.string().min(1).max(160),
|
|
465
|
+
status: z.enum(["completed", "error"]),
|
|
466
|
+
errorType: z.string().min(1).max(160).optional()
|
|
467
|
+
}).strict();
|
|
468
|
+
var captureEventSchema = z.object({
|
|
469
|
+
schema: z.literal(CAPTURE_SCHEMA),
|
|
470
|
+
idempotencyKey: z.string().regex(/^[0-9a-f]{64}$/),
|
|
471
|
+
projectID: z.uuid(),
|
|
472
|
+
bindingKey: z.string().regex(/^[0-9a-f]{64}$/),
|
|
473
|
+
kind: z.enum(["user-candidate", "assistant-candidate", "session-summary", "tool-signal"]),
|
|
474
|
+
source: z.object({
|
|
475
|
+
system: z.literal("opencode-v2"),
|
|
476
|
+
opencodeVersion: z.literal(SUPPORTED_OPENCODE_VERSION),
|
|
477
|
+
pluginVersion: z.string().min(1).max(80),
|
|
478
|
+
sessionID: z.string().min(1).max(240),
|
|
479
|
+
messageID: z.string().min(1).max(240).optional(),
|
|
480
|
+
ordinal: z.number().int().nonnegative().optional(),
|
|
481
|
+
toolCallID: z.string().min(1).max(240).optional(),
|
|
482
|
+
observedAt: z.number().int().nonnegative()
|
|
483
|
+
}).strict(),
|
|
484
|
+
candidate: candidateSchema.optional(),
|
|
485
|
+
signal: signalSchema.optional(),
|
|
486
|
+
redaction: z.object({
|
|
487
|
+
policyVersion: z.string().min(1).max(80),
|
|
488
|
+
replacements: z.number().int().nonnegative(),
|
|
489
|
+
truncated: z.boolean()
|
|
490
|
+
}).strict()
|
|
491
|
+
}).strict().superRefine((event, context) => {
|
|
492
|
+
if (event.kind === "tool-signal" && !event.signal) {
|
|
493
|
+
context.addIssue({ code: "custom", message: "tool-signal requires signal" });
|
|
494
|
+
}
|
|
495
|
+
if (event.kind !== "tool-signal" && !event.candidate) {
|
|
496
|
+
context.addIssue({ code: "custom", message: `${event.kind} requires candidate` });
|
|
497
|
+
}
|
|
498
|
+
if (event.kind === "tool-signal" && event.candidate) {
|
|
499
|
+
context.addIssue({ code: "custom", message: "tool-signal cannot carry candidate" });
|
|
500
|
+
}
|
|
501
|
+
if (event.kind !== "tool-signal" && event.signal) {
|
|
502
|
+
context.addIssue({ code: "custom", message: `${event.kind} cannot carry signal` });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
function parseCaptureEvent(value) {
|
|
506
|
+
const event = captureEventSchema.parse(value);
|
|
507
|
+
if (Buffer.byteLength(JSON.stringify(event), "utf8") > CAPTURE_EVENT_MAX_BYTES) {
|
|
508
|
+
throw new Error(`capture event exceeds ${CAPTURE_EVENT_MAX_BYTES} UTF-8 bytes`);
|
|
509
|
+
}
|
|
510
|
+
return event;
|
|
511
|
+
}
|
|
512
|
+
|
|
444
513
|
// src/db/schema.ts
|
|
445
|
-
var
|
|
514
|
+
var SCHEMA_TABLES = `
|
|
446
515
|
CREATE TABLE IF NOT EXISTS projects (
|
|
447
516
|
id TEXT PRIMARY KEY,
|
|
448
517
|
name TEXT NOT NULL,
|
|
@@ -512,28 +581,7 @@ CREATE TABLE IF NOT EXISTS capture_checkpoints (
|
|
|
512
581
|
);
|
|
513
582
|
CREATE INDEX IF NOT EXISTS capture_checkpoints_due_idx
|
|
514
583
|
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
|
-
);
|
|
584
|
+
${captureEventsTable()}
|
|
537
585
|
CREATE INDEX IF NOT EXISTS capture_events_state_idx ON capture_events(state, updated_at);
|
|
538
586
|
CREATE INDEX IF NOT EXISTS capture_events_session_idx ON capture_events(project_id, source_session_id);
|
|
539
587
|
CREATE INDEX IF NOT EXISTS capture_events_note_idx ON capture_events(project_id, note_id);
|
|
@@ -617,15 +665,39 @@ CREATE TRIGGER IF NOT EXISTS notes_fts_au AFTER UPDATE OF title, summary, conten
|
|
|
617
665
|
VALUES (new.rowid, new.title, new.summary, new.content);
|
|
618
666
|
END;
|
|
619
667
|
`;
|
|
620
|
-
function
|
|
621
|
-
db.exec(
|
|
668
|
+
function createSchema(db) {
|
|
669
|
+
db.exec(SCHEMA_TABLES);
|
|
622
670
|
db.exec(FTS_V9);
|
|
623
671
|
db.query("DELETE FROM schema_state").run();
|
|
624
|
-
db.query("INSERT INTO schema_state(version) VALUES (
|
|
672
|
+
db.query("INSERT INTO schema_state(version) VALUES (10)").run();
|
|
625
673
|
}
|
|
626
674
|
function rebuildFts(db) {
|
|
627
675
|
db.query("INSERT INTO notes_fts(notes_fts) VALUES ('rebuild')").run();
|
|
628
676
|
}
|
|
677
|
+
function captureEventsTable(table = "capture_events") {
|
|
678
|
+
return `CREATE TABLE IF NOT EXISTS ${table} (
|
|
679
|
+
idempotency_key TEXT PRIMARY KEY,
|
|
680
|
+
contract TEXT NOT NULL CHECK (contract = '${CAPTURE_SCHEMA}'),
|
|
681
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
682
|
+
binding_key TEXT NOT NULL REFERENCES project_bindings(binding_key) ON DELETE CASCADE,
|
|
683
|
+
event_kind TEXT NOT NULL CHECK (event_kind IN ('user-candidate','assistant-candidate','session-summary','tool-signal')),
|
|
684
|
+
source_session_id TEXT NOT NULL,
|
|
685
|
+
source_message_id TEXT,
|
|
686
|
+
source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
|
|
687
|
+
source_tool_call_id TEXT,
|
|
688
|
+
payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
|
|
689
|
+
payload_hash TEXT,
|
|
690
|
+
redaction_version TEXT NOT NULL,
|
|
691
|
+
state TEXT NOT NULL CHECK (state IN ('pending','shadowed','review','materialized','duplicate','ignored','rejected','quarantined','failed','dead')),
|
|
692
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
693
|
+
note_id TEXT,
|
|
694
|
+
last_error_code TEXT,
|
|
695
|
+
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0),
|
|
696
|
+
created_at INTEGER NOT NULL,
|
|
697
|
+
updated_at INTEGER NOT NULL,
|
|
698
|
+
processed_at INTEGER
|
|
699
|
+
);`;
|
|
700
|
+
}
|
|
629
701
|
|
|
630
702
|
// src/db/migrations/v009.ts
|
|
631
703
|
function migrateV8ToV9(db) {
|
|
@@ -694,7 +766,7 @@ function migrateV8ToV9(db) {
|
|
|
694
766
|
ALTER TABLE notes_v9 RENAME TO notes;
|
|
695
767
|
ALTER TABLE note_edges_v9 RENAME TO note_edges;
|
|
696
768
|
`);
|
|
697
|
-
db.exec(
|
|
769
|
+
db.exec(SCHEMA_TABLES);
|
|
698
770
|
for (const note of notes) {
|
|
699
771
|
const provenanceID = randomUUID3();
|
|
700
772
|
db.query(`
|
|
@@ -718,6 +790,57 @@ function noteContentHash(kind, title, summary, content) {
|
|
|
718
790
|
return createHash3("sha256").update(`${kind}\x00${title}\x00${summary}\x00${content}`, "utf8").digest("hex");
|
|
719
791
|
}
|
|
720
792
|
|
|
793
|
+
// src/db/migrations/v010.ts
|
|
794
|
+
import { createHash as createHash4 } from "crypto";
|
|
795
|
+
function migrateV9ToV10(db) {
|
|
796
|
+
const payloads = db.query("SELECT idempotency_key, payload_json FROM capture_events WHERE payload_json IS NOT NULL").all();
|
|
797
|
+
const migratedPayloads = payloads.map((row) => {
|
|
798
|
+
const event = JSON.parse(row.payload_json);
|
|
799
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
800
|
+
throw new Error(`capture event ${row.idempotency_key} payload is not an object`);
|
|
801
|
+
}
|
|
802
|
+
event.schema = CAPTURE_SCHEMA;
|
|
803
|
+
const payload = JSON.stringify(parseCaptureEvent(event));
|
|
804
|
+
return {
|
|
805
|
+
idempotencyKey: row.idempotency_key,
|
|
806
|
+
payload,
|
|
807
|
+
payloadHash: createHash4("sha256").update(payload, "utf8").digest("hex")
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
db.exec("DROP TABLE IF EXISTS capture_events_v10");
|
|
811
|
+
db.exec(captureEventsTable("capture_events_v10"));
|
|
812
|
+
db.query(`
|
|
813
|
+
INSERT INTO capture_events_v10
|
|
814
|
+
(idempotency_key, contract, project_id, binding_key, event_kind,
|
|
815
|
+
source_session_id, source_message_id, source_ordinal, source_tool_call_id,
|
|
816
|
+
payload_json, payload_hash, redaction_version, state, attempt_count,
|
|
817
|
+
note_id, last_error_code, generation, created_at, updated_at, processed_at)
|
|
818
|
+
SELECT idempotency_key, ?, project_id, binding_key, event_kind,
|
|
819
|
+
source_session_id, source_message_id, source_ordinal, source_tool_call_id,
|
|
820
|
+
payload_json, payload_hash, redaction_version, state, attempt_count,
|
|
821
|
+
note_id, last_error_code, generation, created_at, updated_at, processed_at
|
|
822
|
+
FROM capture_events
|
|
823
|
+
`).run(CAPTURE_SCHEMA);
|
|
824
|
+
const updatePayload = db.query(`
|
|
825
|
+
UPDATE capture_events_v10
|
|
826
|
+
SET payload_json = ?, payload_hash = ?
|
|
827
|
+
WHERE idempotency_key = ?
|
|
828
|
+
`);
|
|
829
|
+
for (const row of migratedPayloads) {
|
|
830
|
+
updatePayload.run(row.payload, row.payloadHash, row.idempotencyKey);
|
|
831
|
+
}
|
|
832
|
+
db.exec(`
|
|
833
|
+
DROP TABLE capture_events;
|
|
834
|
+
ALTER TABLE capture_events_v10 RENAME TO capture_events;
|
|
835
|
+
`);
|
|
836
|
+
db.exec(SCHEMA_TABLES);
|
|
837
|
+
db.query("DELETE FROM schema_state").run();
|
|
838
|
+
db.query("INSERT INTO schema_state(version) VALUES (10)").run();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// src/version.ts
|
|
842
|
+
var PRODUCT_VERSION = "0.4.0";
|
|
843
|
+
|
|
721
844
|
// src/db.ts
|
|
722
845
|
var DDL = `
|
|
723
846
|
CREATE TABLE IF NOT EXISTS projects (
|
|
@@ -773,13 +896,13 @@ function openMemoryDatabase(path) {
|
|
|
773
896
|
const hasExistingData = hasLegacyV2(db) || hasTable2(db, "notes") || Boolean(existingVersion);
|
|
774
897
|
if (!hasExistingData) {
|
|
775
898
|
db.exec("PRAGMA foreign_keys=ON");
|
|
776
|
-
db.transaction(() =>
|
|
899
|
+
db.transaction(() => createSchema(db))();
|
|
777
900
|
assertHealthyDatabase(db);
|
|
778
901
|
return { db, close: () => db.close() };
|
|
779
902
|
}
|
|
780
903
|
if ((existingVersion?.version ?? 0) < SCHEMA_VERSION) {
|
|
781
904
|
lock = acquireMigrationLock(path, SCHEMA_VERSION);
|
|
782
|
-
backup = createVerifiedBackup(db, path, existingVersion?.version ?? 2, SCHEMA_VERSION,
|
|
905
|
+
backup = createVerifiedBackup(db, path, existingVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION);
|
|
783
906
|
db.exec("PRAGMA foreign_keys=OFF");
|
|
784
907
|
if (!existingVersion && hasLegacyV2(db)) {
|
|
785
908
|
db.exec(DDL);
|
|
@@ -798,9 +921,13 @@ function openMemoryDatabase(path) {
|
|
|
798
921
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
799
922
|
migrateToV8(db);
|
|
800
923
|
}
|
|
801
|
-
|
|
802
|
-
if (version < 9)
|
|
924
|
+
let version = getSchemaVersion(db)?.version ?? 8;
|
|
925
|
+
if (version < 9) {
|
|
803
926
|
db.transaction(() => migrateV8ToV9(db))();
|
|
927
|
+
version = 9;
|
|
928
|
+
}
|
|
929
|
+
if (version < 10)
|
|
930
|
+
db.transaction(() => migrateV9ToV10(db))();
|
|
804
931
|
db.exec("PRAGMA foreign_keys=ON");
|
|
805
932
|
if (db.query("PRAGMA foreign_keys").get().foreign_keys !== 1) {
|
|
806
933
|
throw new Error("failed to enable database foreign keys");
|
|
@@ -812,7 +939,7 @@ function openMemoryDatabase(path) {
|
|
|
812
939
|
return { db, close: () => db.close() };
|
|
813
940
|
}
|
|
814
941
|
db.exec("PRAGMA foreign_keys=ON");
|
|
815
|
-
db.exec(
|
|
942
|
+
db.exec(SCHEMA_TABLES);
|
|
816
943
|
db.exec(FTS_V9);
|
|
817
944
|
assertHealthyDatabase(db);
|
|
818
945
|
db.exec("PRAGMA foreign_keys=ON");
|
|
@@ -1093,29 +1220,29 @@ var MEMORY_GUIDANCE = `Use project-scoped memory for durable facts across sessio
|
|
|
1093
1220
|
- 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
1221
|
|
|
1095
1222
|
// src/tools.ts
|
|
1096
|
-
import * as
|
|
1223
|
+
import * as z2 from "zod/v4";
|
|
1097
1224
|
var MAX_BATCH = 10;
|
|
1098
|
-
var projectID =
|
|
1099
|
-
var projectName =
|
|
1100
|
-
var createUpdateSchema =
|
|
1101
|
-
kind:
|
|
1102
|
-
title:
|
|
1103
|
-
summary:
|
|
1104
|
-
content:
|
|
1225
|
+
var projectID = z2.uuid().describe("The immutable project UUID returned by project_create or project_list.");
|
|
1226
|
+
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.");
|
|
1227
|
+
var createUpdateSchema = z2.object({
|
|
1228
|
+
kind: z2.enum(KINDS),
|
|
1229
|
+
title: z2.string(),
|
|
1230
|
+
summary: z2.string(),
|
|
1231
|
+
content: z2.string().optional()
|
|
1105
1232
|
}).strict();
|
|
1106
|
-
var patchUpdateSchema =
|
|
1107
|
-
id:
|
|
1108
|
-
kind:
|
|
1109
|
-
title:
|
|
1110
|
-
summary:
|
|
1111
|
-
content:
|
|
1112
|
-
delete:
|
|
1233
|
+
var patchUpdateSchema = z2.object({
|
|
1234
|
+
id: z2.string(),
|
|
1235
|
+
kind: z2.enum(KINDS).optional(),
|
|
1236
|
+
title: z2.string().optional(),
|
|
1237
|
+
summary: z2.string().optional(),
|
|
1238
|
+
content: z2.string().optional(),
|
|
1239
|
+
delete: z2.boolean().optional()
|
|
1113
1240
|
}).strict();
|
|
1114
|
-
var updateSchema =
|
|
1115
|
-
var linkSchema =
|
|
1116
|
-
sourceID:
|
|
1117
|
-
targetID:
|
|
1118
|
-
predicate:
|
|
1241
|
+
var updateSchema = z2.union([createUpdateSchema, patchUpdateSchema]);
|
|
1242
|
+
var linkSchema = z2.object({
|
|
1243
|
+
sourceID: z2.string(),
|
|
1244
|
+
targetID: z2.string(),
|
|
1245
|
+
predicate: z2.enum(PREDICATES)
|
|
1119
1246
|
}).strict();
|
|
1120
1247
|
function textResult(value) {
|
|
1121
1248
|
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
@@ -1128,39 +1255,39 @@ function registerTools(server, store) {
|
|
|
1128
1255
|
server.registerTool("project_list", {
|
|
1129
1256
|
title: "List memory projects",
|
|
1130
1257
|
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:
|
|
1258
|
+
inputSchema: z2.object({}).strict(),
|
|
1132
1259
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
|
|
1133
1260
|
}, async () => textResult({ projects: store.listProjects() }));
|
|
1134
1261
|
server.registerTool("project_create", {
|
|
1135
1262
|
title: "Create a memory project",
|
|
1136
1263
|
description: "Create an empty memory project. The returned projectID is immutable; the unique project name may be changed later.",
|
|
1137
|
-
inputSchema:
|
|
1264
|
+
inputSchema: z2.object({ projectName }).strict(),
|
|
1138
1265
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
|
|
1139
1266
|
}, async ({ projectName: projectName2 }) => textResult({ results: [store.createProject(projectName2)] }));
|
|
1140
1267
|
server.registerTool("project_update", {
|
|
1141
1268
|
title: "Rename a memory project",
|
|
1142
1269
|
description: "Rename one project by its immutable projectID. Renaming does not change the ID or detach any notes.",
|
|
1143
|
-
inputSchema:
|
|
1270
|
+
inputSchema: z2.object({ projectID, projectName }).strict(),
|
|
1144
1271
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
1145
1272
|
}, async ({ projectID: projectID2, projectName: projectName2 }) => textResult({ results: [store.updateProject(projectID2, projectName2)] }));
|
|
1146
1273
|
server.registerTool("project_delete", {
|
|
1147
1274
|
title: "Permanently delete a memory project",
|
|
1148
1275
|
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:
|
|
1276
|
+
inputSchema: z2.object({
|
|
1150
1277
|
projectID,
|
|
1151
1278
|
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:
|
|
1279
|
+
confirmation: z2.literal("DELETE_PROJECT_AND_ALL_MEMORY").describe("Required destructive-action confirmation phrase.")
|
|
1153
1280
|
}).strict(),
|
|
1154
1281
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
|
|
1155
1282
|
}, async ({ projectID: projectID2, confirmProjectName }) => textResult({ results: [store.deleteProject(projectID2, confirmProjectName)] }));
|
|
1156
1283
|
server.registerTool("memory_recall", {
|
|
1157
1284
|
title: "Search project memory",
|
|
1158
1285
|
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
|
-
|
|
1286
|
+
inputSchema: z2.union([
|
|
1287
|
+
z2.object({ projectID, query: z2.string() }).strict(),
|
|
1288
|
+
z2.object({ projectID, queries: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict(),
|
|
1289
|
+
z2.object({ projectName, query: z2.string() }).strict(),
|
|
1290
|
+
z2.object({ projectName, queries: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict()
|
|
1164
1291
|
]),
|
|
1165
1292
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
|
|
1166
1293
|
}, async (raw) => {
|
|
@@ -1179,13 +1306,13 @@ function registerTools(server, store) {
|
|
|
1179
1306
|
server.registerTool("memory_update", {
|
|
1180
1307
|
title: "Create, patch, or delete project memory",
|
|
1181
1308
|
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:
|
|
1309
|
+
inputSchema: z2.union([
|
|
1183
1310
|
createUpdateSchema.extend({ projectID }),
|
|
1184
1311
|
patchUpdateSchema.extend({ projectID }),
|
|
1185
|
-
|
|
1312
|
+
z2.object({ projectID, updates: z2.array(updateSchema).min(1).max(MAX_BATCH) }).strict(),
|
|
1186
1313
|
createUpdateSchema.extend({ projectName }),
|
|
1187
1314
|
patchUpdateSchema.extend({ projectName }),
|
|
1188
|
-
|
|
1315
|
+
z2.object({ projectName, updates: z2.array(updateSchema).min(1).max(MAX_BATCH) }).strict()
|
|
1189
1316
|
]),
|
|
1190
1317
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
|
|
1191
1318
|
}, async (raw) => {
|
|
@@ -1201,9 +1328,9 @@ function registerTools(server, store) {
|
|
|
1201
1328
|
server.registerTool("memory_pin", {
|
|
1202
1329
|
title: "Pin or unpin project memory",
|
|
1203
1330
|
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
|
-
|
|
1331
|
+
inputSchema: z2.union([
|
|
1332
|
+
z2.object({ projectID, id: z2.string(), pinned: z2.boolean() }).strict(),
|
|
1333
|
+
z2.object({ projectName, id: z2.string(), pinned: z2.boolean() }).strict()
|
|
1207
1334
|
]),
|
|
1208
1335
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
1209
1336
|
}, async (raw) => {
|
|
@@ -1218,11 +1345,11 @@ function registerTools(server, store) {
|
|
|
1218
1345
|
server.registerTool("memory_link", {
|
|
1219
1346
|
title: "Link project memories",
|
|
1220
1347
|
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:
|
|
1348
|
+
inputSchema: z2.union([
|
|
1222
1349
|
linkSchema.extend({ projectID }),
|
|
1223
|
-
|
|
1350
|
+
z2.object({ projectID, links: z2.array(linkSchema).min(1).max(MAX_BATCH) }).strict(),
|
|
1224
1351
|
linkSchema.extend({ projectName }),
|
|
1225
|
-
|
|
1352
|
+
z2.object({ projectName, links: z2.array(linkSchema).min(1).max(MAX_BATCH) }).strict()
|
|
1226
1353
|
]),
|
|
1227
1354
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
|
|
1228
1355
|
}, async (raw) => {
|
|
@@ -1238,11 +1365,11 @@ function registerTools(server, store) {
|
|
|
1238
1365
|
server.registerTool("memory_read", {
|
|
1239
1366
|
title: "Read project memory",
|
|
1240
1367
|
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
|
-
|
|
1368
|
+
inputSchema: z2.union([
|
|
1369
|
+
z2.object({ projectID, id: z2.string() }).strict(),
|
|
1370
|
+
z2.object({ projectID, ids: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict(),
|
|
1371
|
+
z2.object({ projectName, id: z2.string() }).strict(),
|
|
1372
|
+
z2.object({ projectName, ids: z2.array(z2.string()).min(1).max(MAX_BATCH) }).strict()
|
|
1246
1373
|
]),
|
|
1247
1374
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
|
|
1248
1375
|
}, async (raw) => {
|
|
@@ -1262,7 +1389,7 @@ function registerTools(server, store) {
|
|
|
1262
1389
|
|
|
1263
1390
|
// src/server.ts
|
|
1264
1391
|
var SERVER_NAME = "agz-memory";
|
|
1265
|
-
var SERVER_VERSION =
|
|
1392
|
+
var SERVER_VERSION = PRODUCT_VERSION;
|
|
1266
1393
|
function createMemoryServer(store) {
|
|
1267
1394
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: MEMORY_GUIDANCE });
|
|
1268
1395
|
registerTools(server, store);
|
|
@@ -1273,9 +1400,10 @@ function createMemoryServer(store) {
|
|
|
1273
1400
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
1274
1401
|
|
|
1275
1402
|
// src/retrieval/derived.ts
|
|
1276
|
-
import { createHash as
|
|
1403
|
+
import { createHash as createHash5 } from "crypto";
|
|
1277
1404
|
|
|
1278
1405
|
// src/capture/redact.ts
|
|
1406
|
+
var REDACTION_POLICY_VERSION = "redaction/1";
|
|
1279
1407
|
var RULES = [
|
|
1280
1408
|
{
|
|
1281
1409
|
name: "private-key",
|
|
@@ -1304,15 +1432,15 @@ function redactText(value, options = {}) {
|
|
|
1304
1432
|
let replacements = 0;
|
|
1305
1433
|
let highRisk = 0;
|
|
1306
1434
|
const classes = {};
|
|
1307
|
-
for (const
|
|
1308
|
-
if (!
|
|
1435
|
+
for (const literal3 of options.denylist ?? []) {
|
|
1436
|
+
if (!literal3)
|
|
1309
1437
|
continue;
|
|
1310
|
-
const count = text.split(
|
|
1438
|
+
const count = text.split(literal3).length - 1;
|
|
1311
1439
|
if (count === 0)
|
|
1312
1440
|
continue;
|
|
1313
1441
|
replacements += count;
|
|
1314
1442
|
classes.denylist = (classes.denylist ?? 0) + count;
|
|
1315
|
-
text = text.replaceAll(
|
|
1443
|
+
text = text.replaceAll(literal3, "[REDACTED:denylist]");
|
|
1316
1444
|
}
|
|
1317
1445
|
for (const rule of RULES) {
|
|
1318
1446
|
text = text.replace(rule.pattern, () => {
|
|
@@ -1362,7 +1490,7 @@ function deriveDocument(source) {
|
|
|
1362
1490
|
const content = redactText(source.content);
|
|
1363
1491
|
if (title.quarantined || summary.quarantined || content.quarantined)
|
|
1364
1492
|
return;
|
|
1365
|
-
const contentHash =
|
|
1493
|
+
const contentHash = createHash5("sha256").update(`${source.kind}\x00${title.text}\x00${summary.text}\x00${content.text}`, "utf8").digest("hex");
|
|
1366
1494
|
return {
|
|
1367
1495
|
projectID: source.projectID,
|
|
1368
1496
|
noteID: source.noteID,
|
|
@@ -1756,6 +1884,359 @@ function toCard(note, via) {
|
|
|
1756
1884
|
};
|
|
1757
1885
|
}
|
|
1758
1886
|
|
|
1887
|
+
// src/store/capture.ts
|
|
1888
|
+
import { createHash as createHash6, randomUUID as randomUUID6 } from "crypto";
|
|
1889
|
+
|
|
1890
|
+
// src/capture/policy.ts
|
|
1891
|
+
var EXTRACTOR_VERSION = "deterministic-extractor/1";
|
|
1892
|
+
function canAutoWrite(candidate, redaction, allowedKinds = ["preference", "decision"], minConfidence = 0.95) {
|
|
1893
|
+
return candidate.evidence === "explicit-user" && candidate.confidence >= minConfidence && allowedKinds.includes(candidate.kind) && !redaction.truncated && !redaction.quarantined && (candidate.intent === "create" || candidate.intent === "supersede");
|
|
1894
|
+
}
|
|
1895
|
+
function normalizeSubjectKey(value) {
|
|
1896
|
+
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLocaleLowerCase("en-US").slice(0, 240);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// src/store/capture.ts
|
|
1900
|
+
class CaptureStore {
|
|
1901
|
+
db;
|
|
1902
|
+
indexBackends;
|
|
1903
|
+
constructor(db, indexBackends = []) {
|
|
1904
|
+
this.db = db;
|
|
1905
|
+
this.indexBackends = indexBackends;
|
|
1906
|
+
}
|
|
1907
|
+
bindProject(input) {
|
|
1908
|
+
const workspaceID = input.workspaceID ?? "";
|
|
1909
|
+
const canonicalPathHash = sha256(input.canonicalDirectory);
|
|
1910
|
+
const bindingKey = sha256(["opencode-v2", input.opencodeProjectID, workspaceID, canonicalPathHash].join("\x00"));
|
|
1911
|
+
const project = this.db.query("SELECT id FROM projects WHERE id = ?").get(input.memoryProjectID);
|
|
1912
|
+
if (!project)
|
|
1913
|
+
throw new Error(`memory project ${input.memoryProjectID} not found`);
|
|
1914
|
+
const existing = this.db.query(`SELECT * FROM project_bindings
|
|
1915
|
+
WHERE source = 'opencode-v2' AND source_project_id = ? AND workspace_id = ?`).get(input.opencodeProjectID, workspaceID);
|
|
1916
|
+
if (existing) {
|
|
1917
|
+
if (existing.binding_key !== bindingKey || existing.project_id !== input.memoryProjectID || existing.canonical_path_hash !== canonicalPathHash) {
|
|
1918
|
+
throw new Error("binding_conflict");
|
|
1919
|
+
}
|
|
1920
|
+
return { bindingKey, projectID: existing.project_id };
|
|
1921
|
+
}
|
|
1922
|
+
const now = Date.now();
|
|
1923
|
+
this.db.query(`
|
|
1924
|
+
INSERT INTO project_bindings
|
|
1925
|
+
(binding_key, project_id, source, source_project_id, workspace_id,
|
|
1926
|
+
canonical_path_hash, created_at, updated_at)
|
|
1927
|
+
VALUES (?, ?, 'opencode-v2', ?, ?, ?, ?, ?)
|
|
1928
|
+
`).run(bindingKey, input.memoryProjectID, input.opencodeProjectID, workspaceID, canonicalPathHash, now, now);
|
|
1929
|
+
return { bindingKey, projectID: input.memoryProjectID };
|
|
1930
|
+
}
|
|
1931
|
+
checkpoint(sessionID, bindingKey, projectID2, messageID, state = "active") {
|
|
1932
|
+
const binding = this.binding(bindingKey, projectID2);
|
|
1933
|
+
if (!binding)
|
|
1934
|
+
throw new Error("binding_conflict");
|
|
1935
|
+
const now = Date.now();
|
|
1936
|
+
this.db.query(`
|
|
1937
|
+
INSERT INTO capture_checkpoints
|
|
1938
|
+
(session_id, binding_key, project_id, state, last_message_id,
|
|
1939
|
+
next_reconcile_at, failure_count, created_at, updated_at)
|
|
1940
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
1941
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
1942
|
+
binding_key = excluded.binding_key,
|
|
1943
|
+
project_id = excluded.project_id,
|
|
1944
|
+
state = excluded.state,
|
|
1945
|
+
last_message_id = COALESCE(excluded.last_message_id, capture_checkpoints.last_message_id),
|
|
1946
|
+
next_reconcile_at = excluded.next_reconcile_at,
|
|
1947
|
+
updated_at = excluded.updated_at
|
|
1948
|
+
WHERE capture_checkpoints.binding_key = excluded.binding_key
|
|
1949
|
+
AND capture_checkpoints.project_id = excluded.project_id
|
|
1950
|
+
`).run(sessionID, bindingKey, projectID2, state, messageID ?? null, now, now, now);
|
|
1951
|
+
const row = this.db.query("SELECT binding_key, project_id FROM capture_checkpoints WHERE session_id = ?").get(sessionID);
|
|
1952
|
+
if (!row || row.binding_key !== bindingKey || row.project_id !== projectID2) {
|
|
1953
|
+
throw new Error("checkpoint_binding_conflict");
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
markReconciled(sessionID, state, lastMessageID, failed = false) {
|
|
1957
|
+
const now = Date.now();
|
|
1958
|
+
const result = this.db.query(`
|
|
1959
|
+
UPDATE capture_checkpoints
|
|
1960
|
+
SET state = ?,
|
|
1961
|
+
last_message_id = COALESCE(?, last_message_id),
|
|
1962
|
+
last_reconciled_at = ?,
|
|
1963
|
+
next_reconcile_at = ?,
|
|
1964
|
+
failure_count = CASE WHEN ? THEN failure_count + 1 ELSE 0 END,
|
|
1965
|
+
updated_at = ?
|
|
1966
|
+
WHERE session_id = ?
|
|
1967
|
+
`).run(state, lastMessageID ?? null, now, now + (failed ? 5000 : 30000), failed, now, sessionID);
|
|
1968
|
+
if (result.changes === 0)
|
|
1969
|
+
throw new Error(`checkpoint ${sessionID} not found`);
|
|
1970
|
+
}
|
|
1971
|
+
getCheckpoint(sessionID) {
|
|
1972
|
+
const row = this.db.query("SELECT session_id, last_message_id, state FROM capture_checkpoints WHERE session_id = ?").get(sessionID);
|
|
1973
|
+
return row ? {
|
|
1974
|
+
sessionID: row.session_id,
|
|
1975
|
+
...row.last_message_id ? { lastMessageID: row.last_message_id } : {},
|
|
1976
|
+
state: row.state
|
|
1977
|
+
} : undefined;
|
|
1978
|
+
}
|
|
1979
|
+
ingest(input, mode, options = {}) {
|
|
1980
|
+
const parsed = parseCaptureEvent(input);
|
|
1981
|
+
if (!this.binding(parsed.bindingKey, parsed.projectID))
|
|
1982
|
+
throw new Error("binding_conflict");
|
|
1983
|
+
const prepared = prepareForPersistence(parsed, options.denylist);
|
|
1984
|
+
const now = Date.now();
|
|
1985
|
+
let result = {
|
|
1986
|
+
outcome: prepared.quarantined ? "quarantined" : "shadowed",
|
|
1987
|
+
idempotencyKey: parsed.idempotencyKey
|
|
1988
|
+
};
|
|
1989
|
+
this.db.transaction(() => {
|
|
1990
|
+
const inserted = this.db.query(`
|
|
1991
|
+
INSERT OR IGNORE INTO capture_events
|
|
1992
|
+
(idempotency_key, contract, project_id, binding_key, event_kind,
|
|
1993
|
+
source_session_id, source_message_id, source_ordinal, source_tool_call_id,
|
|
1994
|
+
payload_json, payload_hash, redaction_version, state, attempt_count,
|
|
1995
|
+
generation, created_at, updated_at, processed_at)
|
|
1996
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
1997
|
+
`).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);
|
|
1998
|
+
if (inserted.changes === 0) {
|
|
1999
|
+
const existing = this.db.query("SELECT state, note_id FROM capture_events WHERE idempotency_key = ?").get(parsed.idempotencyKey);
|
|
2000
|
+
result = {
|
|
2001
|
+
outcome: "duplicate",
|
|
2002
|
+
idempotencyKey: parsed.idempotencyKey,
|
|
2003
|
+
...existing.note_id ? { noteID: existing.note_id } : {},
|
|
2004
|
+
existing: true
|
|
2005
|
+
};
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
if (prepared.quarantined)
|
|
2009
|
+
return;
|
|
2010
|
+
if (mode === "shadow") {
|
|
2011
|
+
this.finishEvent(parsed.idempotencyKey, "shadowed", null, now);
|
|
2012
|
+
result.outcome = "shadowed";
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
const candidate = prepared.event.candidate;
|
|
2016
|
+
if (!candidate) {
|
|
2017
|
+
this.finishEvent(parsed.idempotencyKey, "shadowed", null, now);
|
|
2018
|
+
result.outcome = "shadowed";
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (candidate.intent === "ignore") {
|
|
2022
|
+
this.finishEvent(parsed.idempotencyKey, "ignored", null, now);
|
|
2023
|
+
result.outcome = "ignored";
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (candidate.intent === "review" || !canAutoWrite(candidate, {
|
|
2027
|
+
truncated: prepared.event.redaction.truncated,
|
|
2028
|
+
quarantined: prepared.event.redaction.replacements > 0
|
|
2029
|
+
}, options.allowedKinds, options.minConfidence)) {
|
|
2030
|
+
this.finishEvent(parsed.idempotencyKey, "review", null, now);
|
|
2031
|
+
result.outcome = "review";
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
result = this.materialize(prepared.event, candidate, now);
|
|
2035
|
+
})();
|
|
2036
|
+
return result;
|
|
2037
|
+
}
|
|
2038
|
+
runRetention(now = Date.now(), batchSize = 100) {
|
|
2039
|
+
const terminalCutoff = now - 30 * 24 * 60 * 60 * 1000;
|
|
2040
|
+
const quarantineCutoff = now - 7 * 24 * 60 * 60 * 1000;
|
|
2041
|
+
const summarized = this.db.query(`
|
|
2042
|
+
UPDATE capture_events
|
|
2043
|
+
SET payload_json = NULL, payload_hash = NULL, updated_at = ?
|
|
2044
|
+
WHERE idempotency_key IN (
|
|
2045
|
+
SELECT idempotency_key FROM capture_events
|
|
2046
|
+
WHERE state IN ('materialized','duplicate','ignored','rejected','shadowed','review','failed','dead')
|
|
2047
|
+
AND processed_at < ? AND payload_json IS NOT NULL
|
|
2048
|
+
ORDER BY processed_at LIMIT ?
|
|
2049
|
+
)
|
|
2050
|
+
`).run(now, terminalCutoff, batchSize).changes;
|
|
2051
|
+
const deleted = this.db.query(`
|
|
2052
|
+
DELETE FROM capture_events
|
|
2053
|
+
WHERE idempotency_key IN (
|
|
2054
|
+
SELECT idempotency_key FROM capture_events
|
|
2055
|
+
WHERE state = 'quarantined' AND processed_at < ?
|
|
2056
|
+
ORDER BY processed_at LIMIT ?
|
|
2057
|
+
)
|
|
2058
|
+
`).run(quarantineCutoff, batchSize).changes;
|
|
2059
|
+
const checkpoints = this.db.query(`
|
|
2060
|
+
DELETE FROM capture_checkpoints
|
|
2061
|
+
WHERE session_id IN (
|
|
2062
|
+
SELECT session_id FROM capture_checkpoints
|
|
2063
|
+
WHERE (state IN ('idle','closed') AND updated_at < ?)
|
|
2064
|
+
OR (state = 'unavailable' AND updated_at < ?)
|
|
2065
|
+
ORDER BY updated_at LIMIT ?
|
|
2066
|
+
)
|
|
2067
|
+
`).run(terminalCutoff, quarantineCutoff, batchSize).changes;
|
|
2068
|
+
return { summarized, deleted, checkpoints };
|
|
2069
|
+
}
|
|
2070
|
+
runRetentionBacklog(now = Date.now(), batchSize = 100, maxBatches = 10) {
|
|
2071
|
+
const total = { summarized: 0, deleted: 0, checkpoints: 0 };
|
|
2072
|
+
for (let batch = 0;batch < maxBatches; batch++) {
|
|
2073
|
+
const result = this.runRetention(now, batchSize);
|
|
2074
|
+
total.summarized += result.summarized;
|
|
2075
|
+
total.deleted += result.deleted;
|
|
2076
|
+
total.checkpoints += result.checkpoints;
|
|
2077
|
+
if (result.summarized < batchSize && result.deleted < batchSize && result.checkpoints < batchSize)
|
|
2078
|
+
break;
|
|
2079
|
+
}
|
|
2080
|
+
return total;
|
|
2081
|
+
}
|
|
2082
|
+
materialize(event, candidate, now) {
|
|
2083
|
+
const subjectKey = candidate.subjectKey ? normalizeSubjectKey(candidate.subjectKey) : null;
|
|
2084
|
+
const hash = noteContentHash(candidate.kind, candidate.title, candidate.summary, candidate.content);
|
|
2085
|
+
const existing = subjectKey ? this.db.query(`SELECT * FROM notes
|
|
2086
|
+
WHERE project_id = ? AND kind = ? AND subject_key = ? AND status = 'active'`).get(event.projectID, candidate.kind, subjectKey) : undefined;
|
|
2087
|
+
if (existing?.content_hash === hash) {
|
|
2088
|
+
this.finishEvent(event.idempotencyKey, "duplicate", existing.id, now);
|
|
2089
|
+
return { outcome: "duplicate", idempotencyKey: event.idempotencyKey, noteID: existing.id };
|
|
2090
|
+
}
|
|
2091
|
+
if (existing) {
|
|
2092
|
+
if (candidate.intent !== "supersede" || candidate.targetNoteID !== existing.id || candidate.confidence < 0.95) {
|
|
2093
|
+
this.finishEvent(event.idempotencyKey, "review", existing.id, now);
|
|
2094
|
+
return { outcome: "review", idempotencyKey: event.idempotencyKey, noteID: existing.id };
|
|
2095
|
+
}
|
|
2096
|
+
this.db.query(`
|
|
2097
|
+
UPDATE notes
|
|
2098
|
+
SET status = 'superseded', current_revision = current_revision + 1, updated_at = ?
|
|
2099
|
+
WHERE project_id = ? AND id = ? AND status = 'active'
|
|
2100
|
+
`).run(now, event.projectID, existing.id);
|
|
2101
|
+
this.recordRevision(event, existing.id, now);
|
|
2102
|
+
const id2 = this.insertCapturedNote(event, candidate, subjectKey, existing.id, hash, now);
|
|
2103
|
+
this.db.query(`
|
|
2104
|
+
INSERT INTO note_edges
|
|
2105
|
+
(id, project_id, source_id, target_id, predicate, created_at)
|
|
2106
|
+
VALUES (?, ?, ?, ?, 'SUPERSEDES', ?)
|
|
2107
|
+
`).run(randomUUID6(), event.projectID, id2, existing.id, now);
|
|
2108
|
+
for (const backend of this.indexBackends) {
|
|
2109
|
+
this.enqueueOutbox(backend, "delete-note", event.projectID, existing.id, existing.current_revision + 1, existing.content_hash, now);
|
|
2110
|
+
const note2 = this.db.query("SELECT * FROM notes WHERE id = ?").get(id2);
|
|
2111
|
+
this.enqueueOutbox(backend, "upsert-note", event.projectID, id2, 1, derivedHash(note2), now);
|
|
2112
|
+
}
|
|
2113
|
+
this.finishEvent(event.idempotencyKey, "materialized", id2, now);
|
|
2114
|
+
return { outcome: "materialized", idempotencyKey: event.idempotencyKey, noteID: id2 };
|
|
2115
|
+
}
|
|
2116
|
+
if (candidate.intent === "supersede") {
|
|
2117
|
+
this.finishEvent(event.idempotencyKey, "review", candidate.targetNoteID ?? null, now);
|
|
2118
|
+
return {
|
|
2119
|
+
outcome: "review",
|
|
2120
|
+
idempotencyKey: event.idempotencyKey,
|
|
2121
|
+
...candidate.targetNoteID ? { noteID: candidate.targetNoteID } : {}
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
const id = this.insertCapturedNote(event, candidate, subjectKey, null, hash, now);
|
|
2125
|
+
const note = this.db.query("SELECT * FROM notes WHERE id = ?").get(id);
|
|
2126
|
+
for (const backend of this.indexBackends) {
|
|
2127
|
+
this.enqueueOutbox(backend, "upsert-note", event.projectID, id, 1, derivedHash(note), now);
|
|
2128
|
+
}
|
|
2129
|
+
this.finishEvent(event.idempotencyKey, "materialized", id, now);
|
|
2130
|
+
return { outcome: "materialized", idempotencyKey: event.idempotencyKey, noteID: id };
|
|
2131
|
+
}
|
|
2132
|
+
insertCapturedNote(event, candidate, subjectKey, supersedesID, contentHash, now) {
|
|
2133
|
+
const id = randomUUID6();
|
|
2134
|
+
const sizeClass = candidate.content.length <= 1200 ? "inline" : "indexed";
|
|
2135
|
+
this.db.query(`
|
|
2136
|
+
INSERT INTO notes
|
|
2137
|
+
(id, project_id, kind, title, summary, content, size_class, pinned, status,
|
|
2138
|
+
supersedes_id, current_revision, subject_key, content_hash, created_at, updated_at)
|
|
2139
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, 1, ?, ?, ?, ?)
|
|
2140
|
+
`).run(id, event.projectID, candidate.kind, candidate.title, candidate.summary, candidate.content, sizeClass, supersedesID, subjectKey, contentHash, now, now);
|
|
2141
|
+
this.recordRevision(event, id, now);
|
|
2142
|
+
return id;
|
|
2143
|
+
}
|
|
2144
|
+
recordRevision(event, noteID, now) {
|
|
2145
|
+
const note = this.db.query("SELECT * FROM notes WHERE project_id = ? AND id = ?").get(event.projectID, noteID);
|
|
2146
|
+
const provenanceID = randomUUID6();
|
|
2147
|
+
this.db.query(`
|
|
2148
|
+
INSERT INTO note_provenance
|
|
2149
|
+
(id, project_id, note_id, source_type, capture_event_id, source_session_id,
|
|
2150
|
+
source_message_id, source_ordinal, source_tool_call_id, redaction_version,
|
|
2151
|
+
extractor_version, confidence, created_at)
|
|
2152
|
+
VALUES (?, ?, ?, 'opencode-capture', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2153
|
+
`).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);
|
|
2154
|
+
this.db.query(`
|
|
2155
|
+
INSERT INTO note_revisions
|
|
2156
|
+
(project_id, note_id, revision, kind, title, summary, content, size_class,
|
|
2157
|
+
pinned, status, supersedes_id, subject_key, content_hash, provenance_id, created_at)
|
|
2158
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2159
|
+
`).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);
|
|
2160
|
+
}
|
|
2161
|
+
enqueueOutbox(backend, operation, projectID2, noteID, revision, contentHash, now) {
|
|
2162
|
+
this.db.query(`
|
|
2163
|
+
INSERT OR IGNORE INTO index_outbox
|
|
2164
|
+
(backend, operation, project_id, note_id, revision, content_hash,
|
|
2165
|
+
state, attempt_count, available_at, created_at)
|
|
2166
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
|
|
2167
|
+
`).run(backend, operation, projectID2, noteID, revision, contentHash, now, now);
|
|
2168
|
+
}
|
|
2169
|
+
finishEvent(idempotencyKey, state, noteID, now) {
|
|
2170
|
+
this.db.query(`
|
|
2171
|
+
UPDATE capture_events
|
|
2172
|
+
SET state = ?, note_id = ?, updated_at = ?, processed_at = ?
|
|
2173
|
+
WHERE idempotency_key = ?
|
|
2174
|
+
`).run(state, noteID, now, now, idempotencyKey);
|
|
2175
|
+
}
|
|
2176
|
+
binding(bindingKey, projectID2) {
|
|
2177
|
+
return this.db.query("SELECT * FROM project_bindings WHERE binding_key = ? AND project_id = ?").get(bindingKey, projectID2);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
function prepareForPersistence(event, denylist) {
|
|
2181
|
+
const copy = structuredClone(event);
|
|
2182
|
+
let replacements = 0;
|
|
2183
|
+
let truncated = copy.redaction.truncated;
|
|
2184
|
+
let quarantined = event.redaction.policyVersion.endsWith("/quarantined");
|
|
2185
|
+
if (copy.candidate) {
|
|
2186
|
+
for (const field of ["title", "summary", "content", "subjectKey"]) {
|
|
2187
|
+
const value = copy.candidate[field];
|
|
2188
|
+
if (typeof value !== "string")
|
|
2189
|
+
continue;
|
|
2190
|
+
const maximum = field === "title" || field === "subjectKey" ? 240 : field === "summary" ? 1200 : 4800;
|
|
2191
|
+
const result = redactText(value, { maxCharacters: maximum, denylist });
|
|
2192
|
+
copy.candidate[field] = result.text;
|
|
2193
|
+
replacements += result.replacements;
|
|
2194
|
+
truncated ||= result.truncated;
|
|
2195
|
+
quarantined ||= result.quarantined;
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
if (copy.signal) {
|
|
2199
|
+
for (const field of ["tool", "errorType"]) {
|
|
2200
|
+
const value = copy.signal[field];
|
|
2201
|
+
if (typeof value !== "string")
|
|
2202
|
+
continue;
|
|
2203
|
+
const result = redactText(value, { maxCharacters: 160, denylist });
|
|
2204
|
+
copy.signal[field] = result.text;
|
|
2205
|
+
replacements += result.replacements;
|
|
2206
|
+
truncated ||= result.truncated;
|
|
2207
|
+
quarantined ||= result.quarantined;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
copy.redaction = {
|
|
2211
|
+
policyVersion: REDACTION_POLICY_VERSION,
|
|
2212
|
+
replacements: copy.redaction.replacements + replacements,
|
|
2213
|
+
truncated
|
|
2214
|
+
};
|
|
2215
|
+
const validated = parseCaptureEvent(copy);
|
|
2216
|
+
const payload = quarantined ? null : JSON.stringify(validated);
|
|
2217
|
+
return {
|
|
2218
|
+
event: validated,
|
|
2219
|
+
payload,
|
|
2220
|
+
payloadHash: payload ? sha256(payload) : null,
|
|
2221
|
+
quarantined,
|
|
2222
|
+
additionalReplacements: replacements
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
function sha256(value) {
|
|
2226
|
+
return createHash6("sha256").update(value, "utf8").digest("hex");
|
|
2227
|
+
}
|
|
2228
|
+
function derivedHash(note) {
|
|
2229
|
+
return deriveDocument({
|
|
2230
|
+
projectID: note.project_id,
|
|
2231
|
+
noteID: note.id,
|
|
2232
|
+
revision: note.current_revision,
|
|
2233
|
+
kind: note.kind,
|
|
2234
|
+
title: note.title,
|
|
2235
|
+
summary: note.summary,
|
|
2236
|
+
content: note.content
|
|
2237
|
+
})?.contentHash ?? null;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
1759
2240
|
// src/index.ts
|
|
1760
2241
|
function main() {
|
|
1761
2242
|
const { databasePath } = resolveConfig();
|
|
@@ -1764,6 +2245,16 @@ function main() {
|
|
|
1764
2245
|
mkdirSync3(directory, { recursive: true });
|
|
1765
2246
|
const opened = openMemoryDatabase(databasePath);
|
|
1766
2247
|
const store = new MemoryStore(opened.db);
|
|
2248
|
+
const capture = new CaptureStore(opened.db);
|
|
2249
|
+
capture.runRetentionBacklog();
|
|
2250
|
+
const retentionTimer = setInterval(() => {
|
|
2251
|
+
try {
|
|
2252
|
+
capture.runRetentionBacklog();
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
console.error(`[agz-memory] retention failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2255
|
+
}
|
|
2256
|
+
}, 60 * 60000);
|
|
2257
|
+
retentionTimer.unref();
|
|
1767
2258
|
const handle = serveStdio(() => createMemoryServer(store), {
|
|
1768
2259
|
onerror: (error) => console.error(`[agz-memory] ${error.message}`)
|
|
1769
2260
|
});
|
|
@@ -1772,6 +2263,7 @@ function main() {
|
|
|
1772
2263
|
if (closing)
|
|
1773
2264
|
return;
|
|
1774
2265
|
closing = true;
|
|
2266
|
+
clearInterval(retentionTimer);
|
|
1775
2267
|
try {
|
|
1776
2268
|
await handle.close();
|
|
1777
2269
|
} finally {
|