@vaur94/agz-memory 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +7 -4
- package/CHANGELOG.md +32 -0
- package/README.md +24 -16
- package/README.tr.md +24 -16
- package/dist/admin.js +687 -124
- package/dist/core.js +852 -91
- package/dist/server.js +947 -165
- package/dist/types/admin/quarantine.d.ts +15 -0
- package/dist/types/admin/reindex.d.ts +41 -0
- package/dist/types/config.d.ts +1 -0
- package/dist/types/context.d.ts +1 -1
- package/dist/types/contracts/error.d.ts +16 -0
- package/dist/types/contracts/limits.d.ts +18 -0
- package/dist/types/contracts/mutation.d.ts +31 -0
- package/dist/types/contracts/pagination.d.ts +27 -0
- package/dist/types/core.d.ts +4 -0
- package/dist/types/db/health.d.ts +3 -1
- package/dist/types/db/legacy-health.d.ts +3 -1
- package/dist/types/db.d.ts +11 -1
- package/dist/types/security/quarantine-key.d.ts +47 -0
- package/dist/types/server.d.ts +1 -1
- package/dist/types/store/capture.d.ts +5 -1
- package/dist/types/store/outbox.d.ts +10 -1
- package/dist/types/store.d.ts +27 -9
- package/dist/types/version.d.ts +1 -1
- package/docs/backup-restore-runbook.md +16 -16
- package/docs/backup-restore-runbook.tr.md +16 -16
- package/docs/schema-v11.md +1 -1
- package/package.json +3 -3
- package/skills/agz-memory/agz-memory.md +3 -1
- package/skills/index.json +1 -1
package/dist/core.js
CHANGED
|
@@ -734,7 +734,7 @@ function inspectDatabase(db) {
|
|
|
734
734
|
}
|
|
735
735
|
return { integrity, foreignKeyViolations, schemaVersion, counts };
|
|
736
736
|
}
|
|
737
|
-
function assertHealthyDatabase(db) {
|
|
737
|
+
function assertHealthyDatabase(db, options = {}) {
|
|
738
738
|
const health = inspectDatabase(db);
|
|
739
739
|
if (health.integrity !== "ok") {
|
|
740
740
|
throw new Error(`database integrity check failed: ${health.integrity}`);
|
|
@@ -742,7 +742,7 @@ function assertHealthyDatabase(db) {
|
|
|
742
742
|
if (health.foreignKeyViolations.length > 0) {
|
|
743
743
|
throw new Error(`database foreign key check failed: ${health.foreignKeyViolations.length} violation(s)`);
|
|
744
744
|
}
|
|
745
|
-
if (health.schemaVersion === 11)
|
|
745
|
+
if (options.verifySchema !== false && health.schemaVersion === 11)
|
|
746
746
|
assertSchemaV11(db);
|
|
747
747
|
return health;
|
|
748
748
|
}
|
|
@@ -2169,7 +2169,7 @@ var V9_V10_COLUMNS = {
|
|
|
2169
2169
|
"completed_at"
|
|
2170
2170
|
]
|
|
2171
2171
|
};
|
|
2172
|
-
function assertLegacySchemaIdentity(db, version) {
|
|
2172
|
+
function assertLegacySchemaIdentity(db, version, options = {}) {
|
|
2173
2173
|
if (version < 2 || version > 10)
|
|
2174
2174
|
throw new Error("unrecognized_database");
|
|
2175
2175
|
const applicationID = db.query("PRAGMA application_id").get().application_id;
|
|
@@ -2177,12 +2177,14 @@ function assertLegacySchemaIdentity(db, version) {
|
|
|
2177
2177
|
throw new Error("unrecognized_database");
|
|
2178
2178
|
if (version === 2 && tableExists(db, "memory_items")) {
|
|
2179
2179
|
assertV2Identity(db);
|
|
2180
|
-
|
|
2180
|
+
if (options.verifyHealth)
|
|
2181
|
+
assertHealthyDatabase(db);
|
|
2181
2182
|
return;
|
|
2182
2183
|
}
|
|
2183
2184
|
if (version < 8) {
|
|
2184
2185
|
assertPreV8Identity(db, version);
|
|
2185
|
-
|
|
2186
|
+
if (options.verifyHealth)
|
|
2187
|
+
assertHealthyDatabase(db);
|
|
2186
2188
|
return;
|
|
2187
2189
|
}
|
|
2188
2190
|
const states = db.query("SELECT version FROM schema_state").all();
|
|
@@ -2207,7 +2209,8 @@ function assertLegacySchemaIdentity(db, version) {
|
|
|
2207
2209
|
throw new Error("unrecognized_database");
|
|
2208
2210
|
}
|
|
2209
2211
|
}
|
|
2210
|
-
|
|
2212
|
+
if (options.verifyHealth)
|
|
2213
|
+
assertHealthyDatabase(db);
|
|
2211
2214
|
if (version === 10)
|
|
2212
2215
|
assertV10SourceDatabase(db);
|
|
2213
2216
|
}
|
|
@@ -3375,7 +3378,7 @@ function migrateV9ToV10(db) {
|
|
|
3375
3378
|
}
|
|
3376
3379
|
|
|
3377
3380
|
// src/version.ts
|
|
3378
|
-
var PRODUCT_VERSION = "0.5.
|
|
3381
|
+
var PRODUCT_VERSION = "0.5.2";
|
|
3379
3382
|
|
|
3380
3383
|
// src/db.ts
|
|
3381
3384
|
var DDL = `
|
|
@@ -3418,15 +3421,25 @@ CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, targe
|
|
|
3418
3421
|
CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
|
|
3419
3422
|
`;
|
|
3420
3423
|
var PRE_OPEN_PROBE_TIMEOUT_MS = 5000;
|
|
3421
|
-
function
|
|
3424
|
+
function timeMigrationStage(timing, stage, work) {
|
|
3425
|
+
if (!timing)
|
|
3426
|
+
return work();
|
|
3427
|
+
const started = performance.now();
|
|
3428
|
+
try {
|
|
3429
|
+
return work();
|
|
3430
|
+
} finally {
|
|
3431
|
+
timing.phases.push({ stage, elapsedMs: Math.round((performance.now() - started) * 1000) / 1000 });
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
function openMemoryDatabase(path, options = {}) {
|
|
3422
3435
|
ensureDatabaseParent(path);
|
|
3423
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3436
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3424
3437
|
recoverStaleMaintenanceGate(path, () => assertSupportedDatabaseBeforeOpen(path));
|
|
3425
3438
|
let lock = acquireMigrationLock(path, SCHEMA_VERSION);
|
|
3426
3439
|
let lease = acquireDatabaseLease(path);
|
|
3427
3440
|
let db;
|
|
3428
3441
|
try {
|
|
3429
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3442
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3430
3443
|
db = openDatabase(path);
|
|
3431
3444
|
} catch (error) {
|
|
3432
3445
|
lease.release();
|
|
@@ -3448,7 +3461,7 @@ function openMemoryDatabase(path) {
|
|
|
3448
3461
|
lease.release();
|
|
3449
3462
|
lease = undefined;
|
|
3450
3463
|
lease = acquireDatabaseLease(path);
|
|
3451
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3464
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3452
3465
|
db = openDatabase(path);
|
|
3453
3466
|
dbOpen = true;
|
|
3454
3467
|
if (hasApplicationObjects(db)) {
|
|
@@ -3498,7 +3511,7 @@ function openMemoryDatabase(path) {
|
|
|
3498
3511
|
lease.release();
|
|
3499
3512
|
lease = undefined;
|
|
3500
3513
|
lease = acquireDatabaseLease(path);
|
|
3501
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3514
|
+
assertSupportedDatabaseBeforeOpen(path, false);
|
|
3502
3515
|
db = openDatabase(path);
|
|
3503
3516
|
dbOpen = true;
|
|
3504
3517
|
let migrationVersion = getSchemaVersion(db);
|
|
@@ -3526,7 +3539,7 @@ function openMemoryDatabase(path) {
|
|
|
3526
3539
|
lease.release();
|
|
3527
3540
|
lease = undefined;
|
|
3528
3541
|
maintenance = acquireMaintenanceGate(path);
|
|
3529
|
-
assertSupportedDatabaseBeforeOpen(path);
|
|
3542
|
+
timeMigrationStage(options.timing, "source-validation", () => assertSupportedDatabaseBeforeOpen(path, false));
|
|
3530
3543
|
db = openDatabase(path);
|
|
3531
3544
|
dbOpen = true;
|
|
3532
3545
|
migrationVersion = getSchemaVersion(db);
|
|
@@ -3556,12 +3569,12 @@ function openMemoryDatabase(path) {
|
|
|
3556
3569
|
lease = undefined;
|
|
3557
3570
|
return opened3;
|
|
3558
3571
|
}
|
|
3559
|
-
backup = createVerifiedBackup(db, path, migrationVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION);
|
|
3572
|
+
backup = timeMigrationStage(options.timing, "backup-checkpoint", () => createVerifiedBackup(db, path, migrationVersion?.version ?? 2, SCHEMA_VERSION, PRODUCT_VERSION));
|
|
3560
3573
|
db.exec("PRAGMA foreign_keys=OFF");
|
|
3561
3574
|
if (!migrationVersion && hasLegacyV2(db)) {
|
|
3562
3575
|
db.exec(DDL);
|
|
3563
3576
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
3564
|
-
migrateFromV2(db, path);
|
|
3577
|
+
timeMigrationStage(options.timing, "v2-import", () => migrateFromV2(db, path));
|
|
3565
3578
|
} else if (!migrationVersion) {
|
|
3566
3579
|
db.exec(DDL);
|
|
3567
3580
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
@@ -3577,24 +3590,25 @@ function openMemoryDatabase(path) {
|
|
|
3577
3590
|
}
|
|
3578
3591
|
let version = getSchemaVersion(db)?.version ?? 8;
|
|
3579
3592
|
if (version < 9) {
|
|
3580
|
-
db.transaction(() => migrateV8ToV9(db))();
|
|
3593
|
+
timeMigrationStage(options.timing, "v8-to-v9", () => db.transaction(() => migrateV8ToV9(db))());
|
|
3581
3594
|
version = 9;
|
|
3582
3595
|
}
|
|
3583
3596
|
if (version < 10) {
|
|
3584
|
-
db.transaction(() => migrateV9ToV10(db))();
|
|
3597
|
+
timeMigrationStage(options.timing, "v9-to-v10", () => db.transaction(() => migrateV9ToV10(db))());
|
|
3585
3598
|
version = 10;
|
|
3586
3599
|
}
|
|
3587
3600
|
if (version < SCHEMA_VERSION) {
|
|
3588
|
-
db.transaction(() => {
|
|
3601
|
+
timeMigrationStage(options.timing, "v10-to-v11", () => db.transaction(() => {
|
|
3589
3602
|
db.exec(`PRAGMA application_id = ${APPLICATION_ID}`);
|
|
3590
3603
|
migrateV10ToV11(db);
|
|
3591
|
-
})();
|
|
3604
|
+
})());
|
|
3592
3605
|
}
|
|
3593
3606
|
db.exec("PRAGMA foreign_keys=ON");
|
|
3594
3607
|
if (db.query("PRAGMA foreign_keys").get().foreign_keys !== 1) {
|
|
3595
3608
|
throw new Error("failed to enable database foreign keys");
|
|
3596
3609
|
}
|
|
3597
|
-
|
|
3610
|
+
timeMigrationStage(options.timing, "fingerprint", () => assertSchemaV11(db));
|
|
3611
|
+
timeMigrationStage(options.timing, "deep-health", () => assertHealthyDatabase(db, { verifySchema: false }));
|
|
3598
3612
|
console.warn(`[agz-memory] migrated to v${SCHEMA_VERSION} (backup: ${backup.manifestPath})`);
|
|
3599
3613
|
backup = undefined;
|
|
3600
3614
|
db.close();
|
|
@@ -3680,11 +3694,11 @@ function openDatabase(path) {
|
|
|
3680
3694
|
throw error;
|
|
3681
3695
|
}
|
|
3682
3696
|
}
|
|
3683
|
-
function assertSupportedDatabaseBeforeOpen(path) {
|
|
3697
|
+
function assertSupportedDatabaseBeforeOpen(path, verifyHealth = true) {
|
|
3684
3698
|
const deadline = Date.now() + PRE_OPEN_PROBE_TIMEOUT_MS;
|
|
3685
3699
|
while (true) {
|
|
3686
3700
|
try {
|
|
3687
|
-
assertSupportedDatabaseBeforeOpenOnce(path);
|
|
3701
|
+
assertSupportedDatabaseBeforeOpenOnce(path, verifyHealth);
|
|
3688
3702
|
return;
|
|
3689
3703
|
} catch (error) {
|
|
3690
3704
|
if (!isSQLiteBusyError(error) || Date.now() >= deadline)
|
|
@@ -3693,19 +3707,19 @@ function assertSupportedDatabaseBeforeOpen(path) {
|
|
|
3693
3707
|
}
|
|
3694
3708
|
}
|
|
3695
3709
|
}
|
|
3696
|
-
function assertSupportedDatabaseBeforeOpenOnce(path) {
|
|
3710
|
+
function assertSupportedDatabaseBeforeOpenOnce(path, verifyHealth) {
|
|
3697
3711
|
assertDatabasePath(path);
|
|
3698
3712
|
if (!existsSync4(path))
|
|
3699
3713
|
return;
|
|
3700
3714
|
const db = new Database3(path, { readonly: true });
|
|
3701
3715
|
try {
|
|
3702
3716
|
assertDatabasePath(path);
|
|
3703
|
-
assertSupportedDatabase(db);
|
|
3717
|
+
assertSupportedDatabase(db, verifyHealth);
|
|
3704
3718
|
} finally {
|
|
3705
3719
|
db.close();
|
|
3706
3720
|
}
|
|
3707
3721
|
}
|
|
3708
|
-
function assertSupportedDatabase(db) {
|
|
3722
|
+
function assertSupportedDatabase(db, verifyHealth = true) {
|
|
3709
3723
|
const existingVersion = getSchemaVersion(db);
|
|
3710
3724
|
if (existingVersion && existingVersion.version > SCHEMA_VERSION) {
|
|
3711
3725
|
throw new Error(`database schema v${existingVersion.version} is newer than supported v${SCHEMA_VERSION}`);
|
|
@@ -3716,7 +3730,7 @@ function assertSupportedDatabase(db) {
|
|
|
3716
3730
|
if (!existingVersion) {
|
|
3717
3731
|
if (!hasLegacyV2(db))
|
|
3718
3732
|
throw new Error("unrecognized_database");
|
|
3719
|
-
assertLegacySchemaIdentity(db, 2);
|
|
3733
|
+
assertLegacySchemaIdentity(db, 2, { verifyHealth });
|
|
3720
3734
|
return;
|
|
3721
3735
|
}
|
|
3722
3736
|
if (existingVersion.version === SCHEMA_VERSION || hasV11Marker) {
|
|
@@ -3726,7 +3740,7 @@ function assertSupportedDatabase(db) {
|
|
|
3726
3740
|
if (existingVersion.version < 2 || existingVersion.version > 10) {
|
|
3727
3741
|
throw new Error("unrecognized_database");
|
|
3728
3742
|
}
|
|
3729
|
-
assertLegacySchemaIdentity(db, existingVersion.version);
|
|
3743
|
+
assertLegacySchemaIdentity(db, existingVersion.version, { verifyHealth });
|
|
3730
3744
|
}
|
|
3731
3745
|
function assertDatabasePath(path) {
|
|
3732
3746
|
try {
|
|
@@ -4086,13 +4100,341 @@ function takeHead(value, maxCharacters) {
|
|
|
4086
4100
|
return value.slice(0, offset);
|
|
4087
4101
|
}
|
|
4088
4102
|
|
|
4103
|
+
// src/config.ts
|
|
4104
|
+
import { homedir } from "os";
|
|
4105
|
+
import { join as join3 } from "path";
|
|
4106
|
+
function resolveConfig(environment = process.env) {
|
|
4107
|
+
const databasePath = environment.OPENCODE_MEMORY_DATABASE_PATH?.trim() || join3(environment.HOME ?? homedir(), ".local", "share", "opencode-memory", "memory.sqlite");
|
|
4108
|
+
const quarantineKeyringPath = environment.OPENCODE_MEMORY_QUARANTINE_KEYRING_PATH?.trim() || `${databasePath}.quarantine-keys`;
|
|
4109
|
+
return { databasePath, quarantineKeyringPath };
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
// src/security/quarantine-key.ts
|
|
4113
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
4114
|
+
import {
|
|
4115
|
+
closeSync as closeSync3,
|
|
4116
|
+
constants as constants3,
|
|
4117
|
+
existsSync as existsSync5,
|
|
4118
|
+
fchmodSync,
|
|
4119
|
+
fstatSync as fstatSync2,
|
|
4120
|
+
fsyncSync as fsyncSync3,
|
|
4121
|
+
lstatSync as lstatSync4,
|
|
4122
|
+
mkdirSync as mkdirSync4,
|
|
4123
|
+
openSync as openSync3,
|
|
4124
|
+
readFileSync as readFileSync4,
|
|
4125
|
+
renameSync as renameSync4,
|
|
4126
|
+
rmSync as rmSync3,
|
|
4127
|
+
writeSync as writeSync2
|
|
4128
|
+
} from "fs";
|
|
4129
|
+
import { dirname as dirname3 } from "path";
|
|
4130
|
+
var KEYRING_FORMAT = "agz-memory.quarantine-keyring/1";
|
|
4131
|
+
var KEY_BYTES = 32;
|
|
4132
|
+
var KEY_ID_BYTES = 12;
|
|
4133
|
+
var MAX_KEYRING_BYTES = 64 * 1024;
|
|
4134
|
+
var KEY_MODE = 384;
|
|
4135
|
+
var LOCK_TIMEOUT_MS = 1000;
|
|
4136
|
+
var RETRIES = 20;
|
|
4137
|
+
|
|
4138
|
+
class QuarantineKeyring {
|
|
4139
|
+
path;
|
|
4140
|
+
constructor(path) {
|
|
4141
|
+
this.path = path;
|
|
4142
|
+
}
|
|
4143
|
+
readActiveKey() {
|
|
4144
|
+
return this.keyReference(this.readDocument());
|
|
4145
|
+
}
|
|
4146
|
+
ensureActiveKey() {
|
|
4147
|
+
let lastError;
|
|
4148
|
+
for (let attempt = 0;attempt < RETRIES; attempt++) {
|
|
4149
|
+
try {
|
|
4150
|
+
return this.readActiveKey();
|
|
4151
|
+
} catch (error) {
|
|
4152
|
+
lastError = error;
|
|
4153
|
+
if (!isKeyringError(error, "quarantine_keyring_missing")) {
|
|
4154
|
+
if (attempt + 1 < RETRIES) {
|
|
4155
|
+
pause();
|
|
4156
|
+
continue;
|
|
4157
|
+
}
|
|
4158
|
+
throw error;
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
try {
|
|
4162
|
+
this.createInitialKeyring();
|
|
4163
|
+
} catch (error) {
|
|
4164
|
+
lastError = error;
|
|
4165
|
+
if (!isKeyringError(error, "quarantine_keyring_exists"))
|
|
4166
|
+
throw error;
|
|
4167
|
+
}
|
|
4168
|
+
pause();
|
|
4169
|
+
}
|
|
4170
|
+
throw lastError instanceof Error ? lastError : new Error("quarantine_keyring_unavailable");
|
|
4171
|
+
}
|
|
4172
|
+
rotate() {
|
|
4173
|
+
this.assertSupportedPlatform();
|
|
4174
|
+
const lockPath = `${this.path}.lock`;
|
|
4175
|
+
const lock = this.acquireLock(lockPath);
|
|
4176
|
+
try {
|
|
4177
|
+
const document = this.readDocument();
|
|
4178
|
+
const keyID = randomBytes(KEY_ID_BYTES).toString("hex");
|
|
4179
|
+
document.keys[keyID] = randomBytes(KEY_BYTES).toString("base64");
|
|
4180
|
+
document.activeKeyID = keyID;
|
|
4181
|
+
this.writeAtomically(document);
|
|
4182
|
+
return { keyID };
|
|
4183
|
+
} finally {
|
|
4184
|
+
closeSync3(lock);
|
|
4185
|
+
rmSync3(lockPath, { force: true });
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
digestSource(source, payloadFingerprint) {
|
|
4189
|
+
const active = this.activeKey(true);
|
|
4190
|
+
return digestSourceWithKey(source, payloadFingerprint, active);
|
|
4191
|
+
}
|
|
4192
|
+
digestExistingSource(source, payloadFingerprint) {
|
|
4193
|
+
const active = this.activeKey(false);
|
|
4194
|
+
return digestSourceWithKey(source, payloadFingerprint, active);
|
|
4195
|
+
}
|
|
4196
|
+
verifySourceDigest(source, payloadFingerprint, keyID, digest) {
|
|
4197
|
+
if (!isPayloadFingerprint(payloadFingerprint) || !/^[0-9a-f]{24}$/.test(keyID) || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
4198
|
+
return false;
|
|
4199
|
+
}
|
|
4200
|
+
const document = this.readDocument();
|
|
4201
|
+
const encoded = document.keys[keyID];
|
|
4202
|
+
if (!encoded)
|
|
4203
|
+
return false;
|
|
4204
|
+
const expected = createHmac("sha256", Buffer.from(encoded, "base64")).update(sourceDigestBytes(source, payloadFingerprint)).digest();
|
|
4205
|
+
return timingSafeEqual(expected, Buffer.from(digest, "hex"));
|
|
4206
|
+
}
|
|
4207
|
+
activeKey(createIfMissing) {
|
|
4208
|
+
if (createIfMissing)
|
|
4209
|
+
this.ensureActiveKey();
|
|
4210
|
+
const document = this.readDocument();
|
|
4211
|
+
const keyID = document.activeKeyID;
|
|
4212
|
+
const encoded = document.keys[keyID];
|
|
4213
|
+
if (!encoded)
|
|
4214
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4215
|
+
return { keyID, key: Buffer.from(encoded, "base64") };
|
|
4216
|
+
}
|
|
4217
|
+
keyReference(document) {
|
|
4218
|
+
if (!document.keys[document.activeKeyID])
|
|
4219
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4220
|
+
return { keyID: document.activeKeyID };
|
|
4221
|
+
}
|
|
4222
|
+
createInitialKeyring() {
|
|
4223
|
+
this.assertSupportedPlatform();
|
|
4224
|
+
this.assertSafeParent();
|
|
4225
|
+
const keyID = randomBytes(KEY_ID_BYTES).toString("hex");
|
|
4226
|
+
const document = {
|
|
4227
|
+
format: KEYRING_FORMAT,
|
|
4228
|
+
activeKeyID: keyID,
|
|
4229
|
+
keys: { [keyID]: randomBytes(KEY_BYTES).toString("base64") }
|
|
4230
|
+
};
|
|
4231
|
+
let fd;
|
|
4232
|
+
try {
|
|
4233
|
+
fd = openSync3(this.path, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, KEY_MODE);
|
|
4234
|
+
fchmodSync(fd, KEY_MODE);
|
|
4235
|
+
writeSync2(fd, JSON.stringify(document));
|
|
4236
|
+
fsyncSync3(fd);
|
|
4237
|
+
} catch (error) {
|
|
4238
|
+
if (error.code === "EEXIST") {
|
|
4239
|
+
throw new Error("quarantine_keyring_exists");
|
|
4240
|
+
}
|
|
4241
|
+
throw error;
|
|
4242
|
+
} finally {
|
|
4243
|
+
if (fd !== undefined)
|
|
4244
|
+
closeSync3(fd);
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
readDocument() {
|
|
4248
|
+
this.assertSupportedPlatform();
|
|
4249
|
+
let before;
|
|
4250
|
+
try {
|
|
4251
|
+
before = lstatSync4(this.path);
|
|
4252
|
+
} catch (error) {
|
|
4253
|
+
if (error.code === "ENOENT") {
|
|
4254
|
+
throw new Error("quarantine_keyring_missing");
|
|
4255
|
+
}
|
|
4256
|
+
throw new Error("quarantine_keyring_unavailable");
|
|
4257
|
+
}
|
|
4258
|
+
if (before.isSymbolicLink())
|
|
4259
|
+
throw new Error("quarantine_keyring_symlink");
|
|
4260
|
+
if (!before.isFile())
|
|
4261
|
+
throw new Error("quarantine_keyring_not_regular");
|
|
4262
|
+
this.assertPermissions(before.mode);
|
|
4263
|
+
if (before.size <= 0 || before.size > MAX_KEYRING_BYTES) {
|
|
4264
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4265
|
+
}
|
|
4266
|
+
let fd;
|
|
4267
|
+
try {
|
|
4268
|
+
fd = openSync3(this.path, constants3.O_RDONLY | constants3.O_NOFOLLOW);
|
|
4269
|
+
const opened = fstatSync2(fd);
|
|
4270
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size <= 0 || opened.size > MAX_KEYRING_BYTES) {
|
|
4271
|
+
throw new Error("quarantine_keyring_toctou");
|
|
4272
|
+
}
|
|
4273
|
+
this.assertPermissions(opened.mode);
|
|
4274
|
+
const bytes = readFileSync4(fd);
|
|
4275
|
+
const after = fstatSync2(fd);
|
|
4276
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
|
|
4277
|
+
throw new Error("quarantine_keyring_toctou");
|
|
4278
|
+
}
|
|
4279
|
+
return parseKeyring(bytes);
|
|
4280
|
+
} catch (error) {
|
|
4281
|
+
if (error.code === "ELOOP") {
|
|
4282
|
+
throw new Error("quarantine_keyring_symlink");
|
|
4283
|
+
}
|
|
4284
|
+
throw error;
|
|
4285
|
+
} finally {
|
|
4286
|
+
if (fd !== undefined)
|
|
4287
|
+
closeSync3(fd);
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
writeAtomically(document) {
|
|
4291
|
+
this.assertSafeParent();
|
|
4292
|
+
const temporary = `${this.path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
4293
|
+
let fd;
|
|
4294
|
+
try {
|
|
4295
|
+
fd = openSync3(temporary, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, KEY_MODE);
|
|
4296
|
+
fchmodSync(fd, KEY_MODE);
|
|
4297
|
+
writeSync2(fd, JSON.stringify(document));
|
|
4298
|
+
fsyncSync3(fd);
|
|
4299
|
+
closeSync3(fd);
|
|
4300
|
+
fd = undefined;
|
|
4301
|
+
renameSync4(temporary, this.path);
|
|
4302
|
+
this.syncParent();
|
|
4303
|
+
} finally {
|
|
4304
|
+
if (fd !== undefined)
|
|
4305
|
+
closeSync3(fd);
|
|
4306
|
+
rmSync3(temporary, { force: true });
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
acquireLock(lockPath) {
|
|
4310
|
+
this.assertSafeParent();
|
|
4311
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
4312
|
+
while (Date.now() < deadline) {
|
|
4313
|
+
try {
|
|
4314
|
+
const fd = openSync3(lockPath, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL | constants3.O_NOFOLLOW, KEY_MODE);
|
|
4315
|
+
fchmodSync(fd, KEY_MODE);
|
|
4316
|
+
return fd;
|
|
4317
|
+
} catch (error) {
|
|
4318
|
+
if (error.code !== "EEXIST")
|
|
4319
|
+
throw error;
|
|
4320
|
+
pause();
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
throw new Error("quarantine_keyring_busy");
|
|
4324
|
+
}
|
|
4325
|
+
assertSafeParent() {
|
|
4326
|
+
const parent = dirname3(this.path);
|
|
4327
|
+
if (!existsSync5(parent))
|
|
4328
|
+
mkdirSync4(parent, { recursive: true, mode: 448 });
|
|
4329
|
+
const stat = lstatSync4(parent);
|
|
4330
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
4331
|
+
throw new Error("quarantine_keyring_parent_unsafe");
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
assertSupportedPlatform() {
|
|
4335
|
+
if (process.platform === "win32") {
|
|
4336
|
+
throw new Error("quarantine_keyring_platform_unsupported");
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
assertPermissions(mode) {
|
|
4340
|
+
if ((mode & 511) !== KEY_MODE)
|
|
4341
|
+
throw new Error("quarantine_keyring_permissions");
|
|
4342
|
+
}
|
|
4343
|
+
syncParent() {
|
|
4344
|
+
let fd;
|
|
4345
|
+
try {
|
|
4346
|
+
fd = openSync3(dirname3(this.path), constants3.O_RDONLY);
|
|
4347
|
+
fsyncSync3(fd);
|
|
4348
|
+
} catch {} finally {
|
|
4349
|
+
if (fd !== undefined)
|
|
4350
|
+
closeSync3(fd);
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4354
|
+
function digestSourceWithKey(source, payloadFingerprint, active) {
|
|
4355
|
+
if (!isPayloadFingerprint(payloadFingerprint)) {
|
|
4356
|
+
throw new Error("quarantine_payload_fingerprint_invalid");
|
|
4357
|
+
}
|
|
4358
|
+
return {
|
|
4359
|
+
keyID: active.keyID,
|
|
4360
|
+
digest: createHmac("sha256", active.key).update(sourceDigestBytes(source, payloadFingerprint)).digest("hex")
|
|
4361
|
+
};
|
|
4362
|
+
}
|
|
4363
|
+
function parseKeyring(bytes) {
|
|
4364
|
+
let value;
|
|
4365
|
+
try {
|
|
4366
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
4367
|
+
} catch {
|
|
4368
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4369
|
+
}
|
|
4370
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4371
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4372
|
+
}
|
|
4373
|
+
const record = value;
|
|
4374
|
+
if (record.format !== KEYRING_FORMAT || typeof record.activeKeyID !== "string" || !record.keys || typeof record.keys !== "object" || Array.isArray(record.keys) || Object.keys(record).length !== 3) {
|
|
4375
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4376
|
+
}
|
|
4377
|
+
const keys = record.keys;
|
|
4378
|
+
if (!/^[0-9a-f]{24}$/.test(record.activeKeyID)) {
|
|
4379
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4380
|
+
}
|
|
4381
|
+
for (const [keyID, encoded] of Object.entries(keys)) {
|
|
4382
|
+
if (!/^[0-9a-f]{24}$/.test(keyID) || typeof encoded !== "string" || !isCanonicalKey(encoded)) {
|
|
4383
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
if (typeof keys[record.activeKeyID] !== "string") {
|
|
4387
|
+
throw new Error("quarantine_keyring_invalid");
|
|
4388
|
+
}
|
|
4389
|
+
return {
|
|
4390
|
+
format: KEYRING_FORMAT,
|
|
4391
|
+
activeKeyID: record.activeKeyID,
|
|
4392
|
+
keys
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
function isCanonicalKey(value) {
|
|
4396
|
+
const key = Buffer.from(value, "base64");
|
|
4397
|
+
return key.length === KEY_BYTES && key.toString("base64") === value;
|
|
4398
|
+
}
|
|
4399
|
+
function sourceDigestBytes(source, payloadFingerprint) {
|
|
4400
|
+
return Buffer.from(JSON.stringify([
|
|
4401
|
+
"quarantine-source-payload/2",
|
|
4402
|
+
source.schema,
|
|
4403
|
+
source.projectID,
|
|
4404
|
+
source.bindingKey,
|
|
4405
|
+
source.kind,
|
|
4406
|
+
source.source.system,
|
|
4407
|
+
source.source.opencodeVersion,
|
|
4408
|
+
source.source.pluginVersion,
|
|
4409
|
+
source.source.sessionID,
|
|
4410
|
+
source.source.messageID ?? null,
|
|
4411
|
+
source.source.ordinal ?? null,
|
|
4412
|
+
source.source.toolCallID ?? null,
|
|
4413
|
+
payloadFingerprint
|
|
4414
|
+
]), "utf8");
|
|
4415
|
+
}
|
|
4416
|
+
function isPayloadFingerprint(value) {
|
|
4417
|
+
return /^[0-9a-f]{64}$/.test(value);
|
|
4418
|
+
}
|
|
4419
|
+
function isKeyringError(error, code) {
|
|
4420
|
+
return error instanceof Error && error.message === code;
|
|
4421
|
+
}
|
|
4422
|
+
function pause() {
|
|
4423
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4089
4426
|
// src/store/capture.ts
|
|
4090
4427
|
class CaptureStore {
|
|
4091
4428
|
db;
|
|
4092
4429
|
indexBackends;
|
|
4093
|
-
|
|
4430
|
+
quarantineKeyring;
|
|
4431
|
+
constructor(db, indexBackends = [], options = {}) {
|
|
4094
4432
|
this.db = db;
|
|
4095
4433
|
this.indexBackends = indexBackends;
|
|
4434
|
+
this.quarantineKeyring = options.quarantineKeyring ?? new QuarantineKeyring(resolveConfig().quarantineKeyringPath);
|
|
4435
|
+
try {
|
|
4436
|
+
this.quarantineKeyring.ensureActiveKey();
|
|
4437
|
+
} catch {}
|
|
4096
4438
|
}
|
|
4097
4439
|
bindProject(input) {
|
|
4098
4440
|
const workspaceID = input.workspaceID ?? "";
|
|
@@ -4273,7 +4615,7 @@ class CaptureStore {
|
|
|
4273
4615
|
}
|
|
4274
4616
|
if (!this.binding(parsed.bindingKey, parsed.projectID))
|
|
4275
4617
|
throw new Error("binding_conflict");
|
|
4276
|
-
const prepared = prepareForPersistence(parsed, options.denylist);
|
|
4618
|
+
const prepared = prepareForPersistence(parsed, options.denylist, this.quarantineKeyring);
|
|
4277
4619
|
const now = Date.now();
|
|
4278
4620
|
let result = {
|
|
4279
4621
|
outcome: prepared.quarantined ? "quarantined" : "shadowed",
|
|
@@ -4288,13 +4630,13 @@ class CaptureStore {
|
|
|
4288
4630
|
generation, created_at, updated_at, processed_at)
|
|
4289
4631
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
4290
4632
|
ON CONFLICT DO NOTHING
|
|
4291
|
-
`).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,
|
|
4633
|
+
`).run(prepared.event.idempotencyKey, prepared.event.schema, prepared.event.projectID, prepared.event.bindingKey, prepared.event.kind, prepared.event.source.sessionID, prepared.event.source.messageID ?? null, prepared.event.source.ordinal ?? null, prepared.event.source.toolCallID ?? null, prepared.payload, prepared.payloadHash, prepared.redactionVersion, prepared.quarantined ? "quarantined" : "pending", now, now, prepared.quarantined ? now : null);
|
|
4292
4634
|
if (inserted.changes === 0) {
|
|
4293
4635
|
const existing = this.db.query(`SELECT contract, project_id, binding_key, event_kind, source_session_id,
|
|
4294
4636
|
source_message_id, source_ordinal, source_tool_call_id,
|
|
4295
4637
|
payload_json, payload_hash, redaction_version, note_id
|
|
4296
4638
|
FROM capture_events WHERE idempotency_key = ?`).get(parsed.idempotencyKey);
|
|
4297
|
-
if (!existing || !samePersistedCapture(existing, prepared.event, prepared.payload, prepared.payloadHash)) {
|
|
4639
|
+
if (!existing || !samePersistedCapture(existing, prepared.event, prepared.payload, prepared.payloadHash, prepared.redactionVersion, prepared.payloadFingerprint, this.quarantineKeyring)) {
|
|
4298
4640
|
throw new Error("idempotency_conflict");
|
|
4299
4641
|
}
|
|
4300
4642
|
result = {
|
|
@@ -4459,6 +4801,9 @@ class CaptureStore {
|
|
|
4459
4801
|
supersedes_id, current_revision, subject_key, content_hash, created_at, updated_at)
|
|
4460
4802
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', ?, 1, ?, ?, ?, ?)
|
|
4461
4803
|
`).run(id, event.projectID, candidate.kind, candidate.title, candidate.summary, candidate.content, sizeClass, supersedesID, subjectKey, contentHash, now, now);
|
|
4804
|
+
this.db.query(`UPDATE projects
|
|
4805
|
+
SET updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
4806
|
+
WHERE id = ?`).run(now, now, event.projectID);
|
|
4462
4807
|
this.recordRevision(event, id, now);
|
|
4463
4808
|
return id;
|
|
4464
4809
|
}
|
|
@@ -4535,7 +4880,7 @@ class CaptureStore {
|
|
|
4535
4880
|
return rows[0];
|
|
4536
4881
|
}
|
|
4537
4882
|
}
|
|
4538
|
-
function prepareForPersistence(event, denylist) {
|
|
4883
|
+
function prepareForPersistence(event, denylist, quarantineKeyring) {
|
|
4539
4884
|
const copy = structuredClone(event);
|
|
4540
4885
|
let replacements = 0;
|
|
4541
4886
|
let truncated = copy.redaction.truncated;
|
|
@@ -4576,11 +4921,28 @@ function prepareForPersistence(event, denylist) {
|
|
|
4576
4921
|
truncated
|
|
4577
4922
|
};
|
|
4578
4923
|
const validated = parseCaptureEvent(copy);
|
|
4924
|
+
const payloadFingerprint = capturePayloadHash2(validated);
|
|
4925
|
+
let payloadHash = payloadFingerprint;
|
|
4926
|
+
let redactionVersion = REDACTION_POLICY_VERSION;
|
|
4927
|
+
if (quarantined) {
|
|
4928
|
+
let keyID = "unavailable";
|
|
4929
|
+
payloadHash = null;
|
|
4930
|
+
try {
|
|
4931
|
+
const keyed = quarantineKeyring?.digestExistingSource(validated, payloadFingerprint);
|
|
4932
|
+
if (keyed) {
|
|
4933
|
+
keyID = keyed.keyID;
|
|
4934
|
+
payloadHash = keyed.digest;
|
|
4935
|
+
}
|
|
4936
|
+
} catch {}
|
|
4937
|
+
redactionVersion = quarantineRedactionVersion(keyID);
|
|
4938
|
+
}
|
|
4579
4939
|
const payload = quarantined ? null : JSON.stringify(validated);
|
|
4580
4940
|
return {
|
|
4581
4941
|
event: validated,
|
|
4582
4942
|
payload,
|
|
4583
|
-
payloadHash
|
|
4943
|
+
payloadHash,
|
|
4944
|
+
redactionVersion,
|
|
4945
|
+
payloadFingerprint,
|
|
4584
4946
|
quarantined,
|
|
4585
4947
|
additionalReplacements: replacements
|
|
4586
4948
|
};
|
|
@@ -4654,9 +5016,34 @@ function captureKeyForEvent(event) {
|
|
|
4654
5016
|
terminalStatus: event.signal.status
|
|
4655
5017
|
});
|
|
4656
5018
|
}
|
|
4657
|
-
function samePersistedCapture(existing, event, payload, payloadHash) {
|
|
4658
|
-
const retainedLegacyPayload = existing.payload_json === null && existing.payload_hash === null;
|
|
4659
|
-
return existing.contract === event.schema && existing.project_id === event.projectID && existing.binding_key === event.bindingKey && existing.event_kind === event.kind && existing.source_session_id === event.source.sessionID && existing.source_message_id === (event.source.messageID ?? null) && existing.source_ordinal === (event.source.ordinal ?? null) && existing.source_tool_call_id === (event.source.toolCallID ?? null) && (retainedLegacyPayload || sameCapturePayload(existing.payload_json, existing.payload_hash, payload, payloadHash) && existing.redaction_version ===
|
|
5019
|
+
function samePersistedCapture(existing, event, payload, payloadHash, redactionVersion, payloadFingerprint, quarantineKeyring) {
|
|
5020
|
+
const retainedLegacyPayload = existing.payload_json === null && existing.payload_hash === null && !isQuarantineRedactionVersion(existing.redaction_version);
|
|
5021
|
+
return existing.contract === event.schema && existing.project_id === event.projectID && existing.binding_key === event.bindingKey && existing.event_kind === event.kind && existing.source_session_id === event.source.sessionID && existing.source_message_id === (event.source.messageID ?? null) && existing.source_ordinal === (event.source.ordinal ?? null) && existing.source_tool_call_id === (event.source.toolCallID ?? null) && (retainedLegacyPayload || sameCapturePayload(existing.payload_json, existing.payload_hash, payload, payloadHash) && existing.redaction_version === redactionVersion || sameKeyedQuarantinedCapture(existing, event, payload, payloadHash, redactionVersion, payloadFingerprint, quarantineKeyring));
|
|
5022
|
+
}
|
|
5023
|
+
function sameKeyedQuarantinedCapture(existing, event, payload, payloadHash, redactionVersion, payloadFingerprint, quarantineKeyring) {
|
|
5024
|
+
if (payload !== null || existing.payload_json !== null || payloadHash === null || existing.payload_hash === null || !isCurrentKeyedQuarantineVersion(existing.redaction_version) || !isCurrentKeyedQuarantineVersion(redactionVersion)) {
|
|
5025
|
+
return false;
|
|
5026
|
+
}
|
|
5027
|
+
const keyID = quarantineKeyID(existing.redaction_version);
|
|
5028
|
+
if (!keyID)
|
|
5029
|
+
return false;
|
|
5030
|
+
try {
|
|
5031
|
+
return quarantineKeyring.verifySourceDigest(event, payloadFingerprint, keyID, existing.payload_hash);
|
|
5032
|
+
} catch {
|
|
5033
|
+
return false;
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
function quarantineRedactionVersion(keyID) {
|
|
5037
|
+
return `${REDACTION_POLICY_VERSION};quarantine-key=${keyID};quarantine-digest=2`;
|
|
5038
|
+
}
|
|
5039
|
+
function isQuarantineRedactionVersion(value) {
|
|
5040
|
+
return /^redaction\/1;quarantine-key=(?:[0-9a-f]{24}|unavailable)(?:;quarantine-digest=2)?$/.test(value);
|
|
5041
|
+
}
|
|
5042
|
+
function isCurrentKeyedQuarantineVersion(value) {
|
|
5043
|
+
return /^redaction\/1;quarantine-key=[0-9a-f]{24};quarantine-digest=2$/.test(value);
|
|
5044
|
+
}
|
|
5045
|
+
function quarantineKeyID(value) {
|
|
5046
|
+
return /^redaction\/1;quarantine-key=([0-9a-f]{24});quarantine-digest=2$/.exec(value)?.[1];
|
|
4660
5047
|
}
|
|
4661
5048
|
function sameCapturePayload(existingPayload, existingHash, payload, payloadHash) {
|
|
4662
5049
|
if (existingHash !== null && existingHash === payloadHash)
|
|
@@ -4694,6 +5081,172 @@ function derivedHash(note) {
|
|
|
4694
5081
|
|
|
4695
5082
|
// src/store.ts
|
|
4696
5083
|
import { randomUUID as randomUUID8 } from "crypto";
|
|
5084
|
+
|
|
5085
|
+
// src/contracts/limits.ts
|
|
5086
|
+
import * as z2 from "zod/v4";
|
|
5087
|
+
var LIMITS = {
|
|
5088
|
+
title: 240,
|
|
5089
|
+
summary: 4096,
|
|
5090
|
+
content: 65536,
|
|
5091
|
+
query: 4096,
|
|
5092
|
+
noteID: 256,
|
|
5093
|
+
batch: 10,
|
|
5094
|
+
requestBytes: 1048576,
|
|
5095
|
+
responseBytes: 1048576,
|
|
5096
|
+
pageSize: 100
|
|
5097
|
+
};
|
|
5098
|
+
function utf8Bytes(value) {
|
|
5099
|
+
return Buffer.byteLength(value, "utf8");
|
|
5100
|
+
}
|
|
5101
|
+
function assertTextLimit(field, value) {
|
|
5102
|
+
const maximum = LIMITS[field];
|
|
5103
|
+
if (utf8Bytes(value) > maximum)
|
|
5104
|
+
throw new RangeError(`${field} exceeds ${maximum} UTF-8 bytes`);
|
|
5105
|
+
}
|
|
5106
|
+
function boundedText(field, description) {
|
|
5107
|
+
return z2.string().superRefine((value, context) => {
|
|
5108
|
+
if (utf8Bytes(value) > LIMITS[field]) {
|
|
5109
|
+
context.addIssue({ code: "custom", message: `${field} exceeds ${LIMITS[field]} UTF-8 bytes` });
|
|
5110
|
+
}
|
|
5111
|
+
}).describe(description);
|
|
5112
|
+
}
|
|
5113
|
+
function assertRequestLimit(value) {
|
|
5114
|
+
if (utf8Bytes(JSON.stringify(value)) > LIMITS.requestBytes) {
|
|
5115
|
+
throw new RangeError(`request exceeds ${LIMITS.requestBytes} UTF-8 bytes`);
|
|
5116
|
+
}
|
|
5117
|
+
}
|
|
5118
|
+
|
|
5119
|
+
// src/contracts/pagination.ts
|
|
5120
|
+
import { createHmac as createHmac2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
5121
|
+
var CURSOR_VERSION = 1;
|
|
5122
|
+
var CURSOR_KEY = randomBytes2(32);
|
|
5123
|
+
var MAX_CURSOR_BYTES = 2048;
|
|
5124
|
+
function encodeCursor(payload) {
|
|
5125
|
+
const body = Buffer.from(JSON.stringify({ v: CURSOR_VERSION, ...payload }));
|
|
5126
|
+
const signature = createHmac2("sha256", CURSOR_KEY).update(body).digest();
|
|
5127
|
+
return Buffer.concat([body, signature]).toString("base64url");
|
|
5128
|
+
}
|
|
5129
|
+
function decodeCursor(cursor, scope) {
|
|
5130
|
+
let payload;
|
|
5131
|
+
try {
|
|
5132
|
+
if (Buffer.byteLength(cursor, "utf8") > MAX_CURSOR_BYTES)
|
|
5133
|
+
throw new Error;
|
|
5134
|
+
const encoded = Buffer.from(cursor, "base64url");
|
|
5135
|
+
if (encoded.length <= 32)
|
|
5136
|
+
throw new Error;
|
|
5137
|
+
const body = encoded.subarray(0, -32);
|
|
5138
|
+
const signature = encoded.subarray(-32);
|
|
5139
|
+
const expected = createHmac2("sha256", CURSOR_KEY).update(body).digest();
|
|
5140
|
+
if (!timingSafeEqual2(signature, expected))
|
|
5141
|
+
throw new Error;
|
|
5142
|
+
payload = JSON.parse(body.toString("utf8"));
|
|
5143
|
+
} catch {
|
|
5144
|
+
throw new TypeError("invalid_cursor");
|
|
5145
|
+
}
|
|
5146
|
+
if (payload.v !== CURSOR_VERSION || !Number.isSafeInteger(payload.offset) || payload.offset < 0)
|
|
5147
|
+
throw new TypeError("invalid_cursor");
|
|
5148
|
+
if (payload.projectID !== scope.projectID || payload.query !== scope.query) {
|
|
5149
|
+
throw new TypeError("cursor_scope_mismatch");
|
|
5150
|
+
}
|
|
5151
|
+
if (payload.snapshot !== scope.snapshot)
|
|
5152
|
+
throw new TypeError("stale_snapshot");
|
|
5153
|
+
return { offset: payload.offset };
|
|
5154
|
+
}
|
|
5155
|
+
function paginate(items, options) {
|
|
5156
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1)
|
|
5157
|
+
throw new RangeError("page limit must be positive");
|
|
5158
|
+
if (options.requestedSnapshot !== undefined && options.requestedSnapshot !== options.snapshot) {
|
|
5159
|
+
throw new TypeError("stale_snapshot");
|
|
5160
|
+
}
|
|
5161
|
+
const offset = options.cursor !== undefined ? decodeCursor(options.cursor, options).offset : 0;
|
|
5162
|
+
const limit = Math.min(options.limit, LIMITS.pageSize);
|
|
5163
|
+
const page = items.slice(offset, offset + limit);
|
|
5164
|
+
const nextOffset = offset + page.length;
|
|
5165
|
+
return {
|
|
5166
|
+
items: page,
|
|
5167
|
+
snapshot: options.snapshot,
|
|
5168
|
+
etag: options.snapshot,
|
|
5169
|
+
...nextOffset < items.length ? { nextCursor: encodeCursor({ projectID: options.projectID, query: options.query, snapshot: options.snapshot, offset: nextOffset }) } : {}
|
|
5170
|
+
};
|
|
5171
|
+
}
|
|
5172
|
+
|
|
5173
|
+
// src/contracts/mutation.ts
|
|
5174
|
+
function normalizeLegacyMutation(input) {
|
|
5175
|
+
const { id, delete: deleteFlag, confirmation, kind, title, summary, content } = input;
|
|
5176
|
+
const hasEdits = kind !== undefined || title !== undefined || summary !== undefined || content !== undefined;
|
|
5177
|
+
if (deleteFlag) {
|
|
5178
|
+
if (!id)
|
|
5179
|
+
throw new TypeError("id is required for delete");
|
|
5180
|
+
if (hasEdits)
|
|
5181
|
+
throw new TypeError("cannot combine delete with edits");
|
|
5182
|
+
return { operation: "delete", id, confirmation };
|
|
5183
|
+
}
|
|
5184
|
+
if (id) {
|
|
5185
|
+
if (!hasEdits)
|
|
5186
|
+
throw new TypeError("patch requires changes");
|
|
5187
|
+
return { operation: "patch", id, changes: { kind, title, summary, content } };
|
|
5188
|
+
}
|
|
5189
|
+
if (!kind || title === undefined || summary === undefined) {
|
|
5190
|
+
throw new TypeError("create requires kind, title, and summary");
|
|
5191
|
+
}
|
|
5192
|
+
return { operation: "create", kind, title, summary, content };
|
|
5193
|
+
}
|
|
5194
|
+
function isMutationOperation(value) {
|
|
5195
|
+
try {
|
|
5196
|
+
assertStrictMutationOperation(value);
|
|
5197
|
+
return true;
|
|
5198
|
+
} catch {
|
|
5199
|
+
return false;
|
|
5200
|
+
}
|
|
5201
|
+
}
|
|
5202
|
+
function assertStrictMutationOperation(value) {
|
|
5203
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5204
|
+
throw new TypeError("invalid mutation");
|
|
5205
|
+
const input = value;
|
|
5206
|
+
if (input.operation !== "create" && input.operation !== "patch" && input.operation !== "delete") {
|
|
5207
|
+
throw new TypeError("invalid mutation operation");
|
|
5208
|
+
}
|
|
5209
|
+
const operation = input.operation;
|
|
5210
|
+
const allowed = {
|
|
5211
|
+
create: ["operation", "kind", "title", "summary", "content"],
|
|
5212
|
+
patch: ["operation", "id", "changes"],
|
|
5213
|
+
delete: ["operation", "id", "confirmation"]
|
|
5214
|
+
};
|
|
5215
|
+
const invalid = Object.keys(input).some((key) => !allowed[operation].includes(key));
|
|
5216
|
+
if (invalid)
|
|
5217
|
+
throw new TypeError("invalid mutation keys");
|
|
5218
|
+
if (input.operation === "create") {
|
|
5219
|
+
if (!KINDS.includes(input.kind) || typeof input.title !== "string" || typeof input.summary !== "string") {
|
|
5220
|
+
throw new TypeError("invalid create mutation");
|
|
5221
|
+
}
|
|
5222
|
+
if (input.content !== undefined && typeof input.content !== "string")
|
|
5223
|
+
throw new TypeError("invalid create mutation");
|
|
5224
|
+
}
|
|
5225
|
+
if (input.operation === "patch") {
|
|
5226
|
+
if (typeof input.id !== "string" || !input.id || !input.changes || typeof input.changes !== "object" || Array.isArray(input.changes)) {
|
|
5227
|
+
throw new TypeError("patch requires changes");
|
|
5228
|
+
}
|
|
5229
|
+
const changes = input.changes;
|
|
5230
|
+
if (Object.values(changes).every((value2) => value2 === undefined))
|
|
5231
|
+
throw new TypeError("patch requires changes");
|
|
5232
|
+
if (Object.keys(changes).some((key) => !["kind", "title", "summary", "content"].includes(key)))
|
|
5233
|
+
throw new TypeError("invalid patch changes");
|
|
5234
|
+
if (changes.kind !== undefined && !KINDS.includes(changes.kind))
|
|
5235
|
+
throw new TypeError("invalid patch changes");
|
|
5236
|
+
for (const key of ["title", "summary", "content"]) {
|
|
5237
|
+
if (changes[key] !== undefined && typeof changes[key] !== "string")
|
|
5238
|
+
throw new TypeError("invalid patch changes");
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
if (input.operation === "delete") {
|
|
5242
|
+
if (typeof input.id !== "string" || !input.id)
|
|
5243
|
+
throw new TypeError("id is required for delete");
|
|
5244
|
+
if (input.confirmation !== undefined && typeof input.confirmation !== "string")
|
|
5245
|
+
throw new TypeError("invalid delete confirmation");
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
|
|
5249
|
+
// src/store.ts
|
|
4697
5250
|
class MemoryStore {
|
|
4698
5251
|
db;
|
|
4699
5252
|
indexBackends;
|
|
@@ -4723,6 +5276,18 @@ class MemoryStore {
|
|
|
4723
5276
|
pinnedCount: row.pinned_count
|
|
4724
5277
|
}));
|
|
4725
5278
|
}
|
|
5279
|
+
listProjectsPage(limit, cursor, snapshot) {
|
|
5280
|
+
const projects = this.listProjects();
|
|
5281
|
+
const current = hashTuple("project-list-snapshot", 1, projects.flatMap((project) => [
|
|
5282
|
+
project.projectID,
|
|
5283
|
+
project.projectName,
|
|
5284
|
+
project.createdAt,
|
|
5285
|
+
project.updatedAt,
|
|
5286
|
+
project.noteCount,
|
|
5287
|
+
project.pinnedCount
|
|
5288
|
+
]));
|
|
5289
|
+
return paginate(projects, { projectID: "__projects__", query: "", limit, cursor, snapshot: current, requestedSnapshot: snapshot });
|
|
5290
|
+
}
|
|
4726
5291
|
createProject(nameValue) {
|
|
4727
5292
|
const reason = validateProjectName(nameValue);
|
|
4728
5293
|
if (reason)
|
|
@@ -4767,7 +5332,12 @@ class MemoryStore {
|
|
|
4767
5332
|
}
|
|
4768
5333
|
const now = Date.now();
|
|
4769
5334
|
try {
|
|
4770
|
-
const updated = this.immediateTransaction(() => this.db.query(
|
|
5335
|
+
const updated = this.immediateTransaction(() => this.db.query(`UPDATE projects
|
|
5336
|
+
SET name = ?,
|
|
5337
|
+
normalized_name = ?,
|
|
5338
|
+
updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
5339
|
+
WHERE id = ?
|
|
5340
|
+
RETURNING *`).get(name, normalizedName, now, now, projectID));
|
|
4771
5341
|
if (!updated)
|
|
4772
5342
|
return { ok: false, reason: `project ${projectID} not found` };
|
|
4773
5343
|
return { ok: true, project: rowToProject(updated) };
|
|
@@ -4825,14 +5395,14 @@ class MemoryStore {
|
|
|
4825
5395
|
};
|
|
4826
5396
|
});
|
|
4827
5397
|
}
|
|
4828
|
-
update(projectID,
|
|
5398
|
+
update(projectID, operation) {
|
|
4829
5399
|
const project = this.getProjectRow(projectID);
|
|
4830
5400
|
if (!project)
|
|
4831
5401
|
return { ok: false, reason: `project ${projectID} not found` };
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
5402
|
+
assertStrictMutationOperation(operation);
|
|
5403
|
+
if (operation.operation === "delete") {
|
|
5404
|
+
const id2 = operation.id;
|
|
5405
|
+
assertTextLimit("noteID", id2);
|
|
4836
5406
|
const existing2 = this.getNoteRow(projectID, id2);
|
|
4837
5407
|
if (!existing2)
|
|
4838
5408
|
return {
|
|
@@ -4849,6 +5419,7 @@ class MemoryStore {
|
|
|
4849
5419
|
};
|
|
4850
5420
|
}
|
|
4851
5421
|
const now2 = Date.now();
|
|
5422
|
+
this.bumpProjectVersion(projectID, now2);
|
|
4852
5423
|
for (const backend of this.indexBackends) {
|
|
4853
5424
|
this.enqueueOutbox(backend, "delete-note", deleted.project_id, deleted.id, deleted.current_revision, null, now2);
|
|
4854
5425
|
}
|
|
@@ -4861,31 +5432,35 @@ class MemoryStore {
|
|
|
4861
5432
|
};
|
|
4862
5433
|
});
|
|
4863
5434
|
}
|
|
4864
|
-
|
|
4865
|
-
|
|
5435
|
+
if (operation.operation === "patch")
|
|
5436
|
+
assertTextLimit("noteID", operation.id);
|
|
5437
|
+
const existing = operation.operation === "patch" ? this.getNoteRow(projectID, operation.id) : undefined;
|
|
5438
|
+
if (operation.operation === "patch" && !existing) {
|
|
4866
5439
|
return {
|
|
4867
5440
|
ok: false,
|
|
4868
|
-
reason: `note ${
|
|
5441
|
+
reason: `note ${operation.id} not found in project ${project.name}`
|
|
4869
5442
|
};
|
|
4870
5443
|
}
|
|
4871
5444
|
if (existing && existing.status !== "active") {
|
|
4872
5445
|
return { ok: false, reason: `note is ${existing.status}` };
|
|
4873
5446
|
}
|
|
4874
|
-
const
|
|
5447
|
+
const changes = operation.operation === "patch" ? operation.changes : undefined;
|
|
5448
|
+
const kindValue = operation.operation === "create" ? operation.kind : changes?.kind ?? existing?.kind;
|
|
4875
5449
|
const kind = KINDS.includes(kindValue ?? "") ? kindValue : null;
|
|
4876
5450
|
if (!kind)
|
|
4877
5451
|
return { ok: false, reason: `kind must be one of: ${KINDS.join(", ")}` };
|
|
4878
|
-
const title =
|
|
4879
|
-
const summary =
|
|
4880
|
-
const content =
|
|
5452
|
+
const title = operation.operation === "create" ? operation.title.trim() : changes?.title === undefined ? existing?.title ?? "" : changes.title.trim();
|
|
5453
|
+
const summary = operation.operation === "create" ? operation.summary.trim() : changes?.summary === undefined ? existing?.summary ?? "" : changes.summary.trim();
|
|
5454
|
+
const content = operation.operation === "create" ? (operation.content ?? summary).trim() : changes?.content === undefined ? existing?.content ?? summary : changes.content.trim();
|
|
4881
5455
|
if (!title)
|
|
4882
5456
|
return { ok: false, reason: "title is required" };
|
|
4883
|
-
if (title.length > 240)
|
|
4884
|
-
return { ok: false, reason: "title exceeds 240 characters" };
|
|
4885
5457
|
if (!summary)
|
|
4886
5458
|
return { ok: false, reason: "summary is required" };
|
|
4887
5459
|
if (!content)
|
|
4888
5460
|
return { ok: false, reason: "content is empty" };
|
|
5461
|
+
assertTextLimit("title", title);
|
|
5462
|
+
assertTextLimit("summary", summary);
|
|
5463
|
+
assertTextLimit("content", content);
|
|
4889
5464
|
const now = Date.now();
|
|
4890
5465
|
const sizeClass = content.length <= INLINE_LIMIT ? "inline" : "indexed";
|
|
4891
5466
|
const contentHash = noteContentHash(kind, title, summary, content);
|
|
@@ -4920,6 +5495,7 @@ class MemoryStore {
|
|
|
4920
5495
|
RETURNING *`).get(kind, title, summary, content, sizeClass, subjectKey, contentHash, now, projectID, existing.id, existing.current_revision);
|
|
4921
5496
|
if (!updated)
|
|
4922
5497
|
return;
|
|
5498
|
+
this.bumpProjectVersion(projectID, now);
|
|
4923
5499
|
this.recordCurrentRevision(updated, "mcp-manual", now);
|
|
4924
5500
|
const derived = deriveDocument({
|
|
4925
5501
|
projectID: updated.project_id,
|
|
@@ -4965,6 +5541,7 @@ class MemoryStore {
|
|
|
4965
5541
|
};
|
|
4966
5542
|
}
|
|
4967
5543
|
pin(projectID, id, pinned) {
|
|
5544
|
+
assertTextLimit("noteID", id);
|
|
4968
5545
|
const project = this.getProjectRow(projectID);
|
|
4969
5546
|
if (!project)
|
|
4970
5547
|
return { ok: false, reason: `project ${projectID} not found` };
|
|
@@ -4999,6 +5576,7 @@ class MemoryStore {
|
|
|
4999
5576
|
RETURNING *`).get(pinned ? 1 : 0, now, projectID, id, note.current_revision);
|
|
5000
5577
|
if (!updated)
|
|
5001
5578
|
return;
|
|
5579
|
+
this.bumpProjectVersion(projectID, now);
|
|
5002
5580
|
this.recordCurrentRevision(updated, "mcp-manual", now);
|
|
5003
5581
|
const derived = deriveDocument({
|
|
5004
5582
|
projectID: updated.project_id,
|
|
@@ -5043,6 +5621,7 @@ class MemoryStore {
|
|
|
5043
5621
|
RETURNING *`).get(id, projectID, kind, title, summary, content, sizeClass, supersedesID, subjectKey, contentHash, now, now);
|
|
5044
5622
|
if (!note)
|
|
5045
5623
|
throw new Error(`note ${id} insert returned no row`);
|
|
5624
|
+
this.bumpProjectVersion(projectID, now);
|
|
5046
5625
|
this.recordCurrentRevision(note, "mcp-manual", now);
|
|
5047
5626
|
const derived = deriveDocument({
|
|
5048
5627
|
projectID: note.project_id,
|
|
@@ -5062,19 +5641,43 @@ class MemoryStore {
|
|
|
5062
5641
|
});
|
|
5063
5642
|
}
|
|
5064
5643
|
read(projectID, id) {
|
|
5644
|
+
const page = this.readPage(projectID, id, LIMITS.pageSize);
|
|
5645
|
+
if (!("note" in page))
|
|
5646
|
+
return page;
|
|
5647
|
+
return {
|
|
5648
|
+
note: page.note,
|
|
5649
|
+
edges: page.items,
|
|
5650
|
+
snapshot: page.snapshot,
|
|
5651
|
+
etag: page.etag,
|
|
5652
|
+
nextCursor: page.nextCursor
|
|
5653
|
+
};
|
|
5654
|
+
}
|
|
5655
|
+
readPage(projectID, id, limit, cursor, snapshot) {
|
|
5656
|
+
assertTextLimit("noteID", id);
|
|
5065
5657
|
const row = this.getNoteRow(projectID, id);
|
|
5066
5658
|
if (!row)
|
|
5067
5659
|
return { reason: `note ${id} not found in project ${projectID}` };
|
|
5068
5660
|
const edges = this.db.query(`SELECT e.id, e.project_id, p.name AS project_name, e.source_id, e.target_id, e.predicate, e.created_at
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5661
|
+
FROM note_edges e
|
|
5662
|
+
JOIN projects p ON p.id = e.project_id
|
|
5663
|
+
WHERE e.project_id = ? AND (e.source_id = ? OR e.target_id = ?)
|
|
5664
|
+
ORDER BY e.created_at, e.id`).all(projectID, id, id);
|
|
5665
|
+
const resultEdges = edges.map(rowToEdge);
|
|
5666
|
+
const current = hashTuple("note-edge-snapshot", 1, [id, row.updated_at, ...resultEdges.flatMap((edge) => [edge.id, edge.projectID, edge.projectName, edge.sourceID, edge.targetID, edge.predicate, edge.createdAt])]);
|
|
5072
5667
|
return {
|
|
5073
5668
|
note: rowToNote(row),
|
|
5074
|
-
edges:
|
|
5669
|
+
...paginate(resultEdges, { projectID, query: `edges:${id}`, limit, cursor, snapshot: current, requestedSnapshot: snapshot })
|
|
5075
5670
|
};
|
|
5076
5671
|
}
|
|
5672
|
+
listRevisionsPage(projectID, noteID, limit, cursor, snapshot) {
|
|
5673
|
+
assertTextLimit("noteID", noteID);
|
|
5674
|
+
const rows = this.db.query("SELECT revision, created_at FROM note_revisions WHERE project_id = ? AND note_id = ? ORDER BY revision").all(projectID, noteID);
|
|
5675
|
+
const current = hashTuple("note-revision-snapshot", 1, [noteID, ...rows.flatMap((row) => [row.revision, row.created_at])]);
|
|
5676
|
+
return paginate(rows, { projectID, query: `revisions:${noteID}`, limit, cursor, snapshot: current, requestedSnapshot: snapshot });
|
|
5677
|
+
}
|
|
5077
5678
|
link(projectID, sourceID, targetID, predicate) {
|
|
5679
|
+
assertTextLimit("noteID", sourceID);
|
|
5680
|
+
assertTextLimit("noteID", targetID);
|
|
5078
5681
|
const project = this.getProjectRow(projectID);
|
|
5079
5682
|
if (!project)
|
|
5080
5683
|
return { ok: false, reason: `project ${projectID} not found` };
|
|
@@ -5096,24 +5699,64 @@ class MemoryStore {
|
|
|
5096
5699
|
if (row.status !== "active")
|
|
5097
5700
|
return { ok: false, reason: `note ${id} is ${row.status}` };
|
|
5098
5701
|
}
|
|
5099
|
-
this.
|
|
5702
|
+
this.immediateTransaction(() => {
|
|
5703
|
+
const now = Date.now();
|
|
5704
|
+
const inserted = this.db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(randomUUID8(), projectID, sourceID, targetID, predicate, now);
|
|
5705
|
+
if (inserted.changes > 0)
|
|
5706
|
+
this.bumpProjectVersion(projectID, now);
|
|
5707
|
+
});
|
|
5100
5708
|
return { ok: true, projectID, projectName: project.name };
|
|
5101
5709
|
}
|
|
5102
5710
|
recall(projectID, query, limit = 10) {
|
|
5711
|
+
return this.recallPage(projectID, query, limit).cards;
|
|
5712
|
+
}
|
|
5713
|
+
recallPage(projectID, query, limit = LIMITS.pageSize, cursor, snapshot) {
|
|
5714
|
+
assertTextLimit("query", query);
|
|
5715
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
5716
|
+
return { cards: [], snapshot: hashTuple("recall-snapshot", 1, [projectID, query]), etag: hashTuple("recall-snapshot", 1, [projectID, query]) };
|
|
5717
|
+
}
|
|
5718
|
+
const pageLimit = Math.min(limit, LIMITS.pageSize);
|
|
5103
5719
|
const tokens = query.split(/\s+/).map((token) => token.trim()).filter(Boolean).slice(0, 12).map((token) => `"${token.replace(/"/g, '""')}"`);
|
|
5104
|
-
if (tokens.length === 0)
|
|
5105
|
-
|
|
5106
|
-
|
|
5720
|
+
if (tokens.length === 0) {
|
|
5721
|
+
const current2 = hashTuple("recall-snapshot", 1, [projectID, query]);
|
|
5722
|
+
return { cards: [], snapshot: current2, etag: current2 };
|
|
5723
|
+
}
|
|
5724
|
+
const project = this.getProjectRow(projectID);
|
|
5725
|
+
const current = hashTuple("recall-snapshot", 2, [
|
|
5726
|
+
projectID,
|
|
5727
|
+
query,
|
|
5728
|
+
project?.name ?? "",
|
|
5729
|
+
project?.updated_at ?? 0
|
|
5730
|
+
]);
|
|
5731
|
+
if (snapshot !== undefined && snapshot !== current)
|
|
5732
|
+
throw new TypeError("stale_snapshot");
|
|
5733
|
+
const offset = cursor !== undefined ? decodeCursor(cursor, { projectID, query, snapshot: current }).offset : 0;
|
|
5734
|
+
const directCount = this.db.query(`SELECT COUNT(*) AS count
|
|
5735
|
+
FROM notes_fts
|
|
5736
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
5737
|
+
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'`).get(tokens.join(" OR "), projectID).count;
|
|
5738
|
+
const matches = offset < directCount ? this.db.query(`SELECT n.*, p.name AS project_name, bm25(notes_fts) AS rank
|
|
5107
5739
|
FROM notes_fts
|
|
5108
5740
|
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
5109
5741
|
JOIN projects p ON p.id = n.project_id
|
|
5110
5742
|
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'
|
|
5111
|
-
|
|
5112
|
-
|
|
5743
|
+
ORDER BY n.pinned DESC, rank, n.id
|
|
5744
|
+
LIMIT ? OFFSET ?`).all(tokens.join(" OR "), projectID, pageLimit, offset) : [];
|
|
5113
5745
|
const cards = matches.map((row) => toCard(rowToNote(row), "match"));
|
|
5114
|
-
const
|
|
5115
|
-
|
|
5116
|
-
|
|
5746
|
+
const directRemaining = Math.max(0, directCount - offset);
|
|
5747
|
+
const neighborOffset = Math.max(0, offset - directCount);
|
|
5748
|
+
if (cards.length < pageLimit && directRemaining <= cards.length) {
|
|
5749
|
+
const firstMatches = this.db.query(`SELECT n.*, p.name AS project_name, bm25(notes_fts) AS rank
|
|
5750
|
+
FROM notes_fts
|
|
5751
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
5752
|
+
JOIN projects p ON p.id = n.project_id
|
|
5753
|
+
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'
|
|
5754
|
+
ORDER BY n.pinned DESC, rank, n.id
|
|
5755
|
+
LIMIT 5`).all(tokens.join(" OR "), projectID);
|
|
5756
|
+
const neighborsToAppend = [];
|
|
5757
|
+
const seen = new Set;
|
|
5758
|
+
for (const match of firstMatches) {
|
|
5759
|
+
const neighbors = this.db.query(`SELECT e.predicate, n.*, p.name AS project_name
|
|
5117
5760
|
FROM note_edges e
|
|
5118
5761
|
JOIN notes n ON n.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END
|
|
5119
5762
|
JOIN projects p ON p.id = n.project_id
|
|
@@ -5121,22 +5764,49 @@ class MemoryStore {
|
|
|
5121
5764
|
AND (e.source_id = ? OR e.target_id = ?)
|
|
5122
5765
|
AND n.project_id = ?
|
|
5123
5766
|
AND n.status = 'active'
|
|
5124
|
-
|
|
5767
|
+
ORDER BY n.pinned DESC, n.updated_at DESC, n.id
|
|
5125
5768
|
LIMIT 6`).all(match.id, projectID, match.id, match.id, projectID);
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5769
|
+
for (const neighbor of neighbors) {
|
|
5770
|
+
if (seen.has(neighbor.id))
|
|
5771
|
+
continue;
|
|
5772
|
+
const isDirectMatch = this.db.query(`SELECT 1
|
|
5773
|
+
FROM notes_fts
|
|
5774
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
5775
|
+
WHERE notes_fts MATCH ? AND n.project_id = ? AND n.id = ? AND n.status = 'active'`).get(tokens.join(" OR "), projectID, neighbor.id);
|
|
5776
|
+
if (isDirectMatch)
|
|
5777
|
+
continue;
|
|
5778
|
+
seen.add(neighbor.id);
|
|
5779
|
+
const card = toCard(rowToNote(neighbor), "neighbor");
|
|
5780
|
+
card.predicates = [neighbor.predicate];
|
|
5781
|
+
neighborsToAppend.push(card);
|
|
5782
|
+
}
|
|
5133
5783
|
}
|
|
5784
|
+
cards.push(...neighborsToAppend.slice(neighborOffset, neighborOffset + (pageLimit - cards.length)));
|
|
5785
|
+
const consumedNeighbors = neighborOffset + Math.max(0, cards.length - matches.length);
|
|
5786
|
+
const hasMore2 = offset + matches.length < directCount || consumedNeighbors < neighborsToAppend.length;
|
|
5787
|
+
return {
|
|
5788
|
+
cards,
|
|
5789
|
+
snapshot: current,
|
|
5790
|
+
etag: current,
|
|
5791
|
+
...hasMore2 ? { nextCursor: encodeCursor({ projectID, query, snapshot: current, offset: offset + cards.length }) } : {}
|
|
5792
|
+
};
|
|
5134
5793
|
}
|
|
5135
|
-
|
|
5794
|
+
const hasMore = offset + cards.length < directCount;
|
|
5795
|
+
return {
|
|
5796
|
+
cards,
|
|
5797
|
+
snapshot: current,
|
|
5798
|
+
etag: current,
|
|
5799
|
+
...hasMore ? { nextCursor: encodeCursor({ projectID, query, snapshot: current, offset: offset + cards.length }) } : {}
|
|
5800
|
+
};
|
|
5136
5801
|
}
|
|
5137
5802
|
getProjectRow(id) {
|
|
5138
5803
|
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id);
|
|
5139
5804
|
}
|
|
5805
|
+
bumpProjectVersion(projectID, now) {
|
|
5806
|
+
this.db.query(`UPDATE projects
|
|
5807
|
+
SET updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
5808
|
+
WHERE id = ?`).run(now, now, projectID);
|
|
5809
|
+
}
|
|
5140
5810
|
getProjectByNormalizedName(normalizedName) {
|
|
5141
5811
|
return this.db.query("SELECT * FROM projects WHERE normalized_name = ?").get(normalizedName);
|
|
5142
5812
|
}
|
|
@@ -5261,6 +5931,8 @@ import { randomUUID as randomUUID9 } from "crypto";
|
|
|
5261
5931
|
var LEASE_DURATION_MS = 30000;
|
|
5262
5932
|
var BACKEND_TIMEOUT_MS = 5000;
|
|
5263
5933
|
var HEARTBEAT_INTERVAL_MS = 1e4;
|
|
5934
|
+
var TERMINAL_OUTBOX_RETENTION = 1e4;
|
|
5935
|
+
var TERMINAL_OUTBOX_PRUNE_INTERVAL = 100;
|
|
5264
5936
|
|
|
5265
5937
|
class OutboxWorker {
|
|
5266
5938
|
db;
|
|
@@ -5268,11 +5940,21 @@ class OutboxWorker {
|
|
|
5268
5940
|
now;
|
|
5269
5941
|
random;
|
|
5270
5942
|
workerID = randomUUID9();
|
|
5271
|
-
|
|
5943
|
+
terminalRetention;
|
|
5944
|
+
terminalPruneInterval;
|
|
5945
|
+
terminalTransitions;
|
|
5946
|
+
constructor(db, backends, now = Date.now, random = Math.random, retention = {}) {
|
|
5272
5947
|
this.db = db;
|
|
5273
5948
|
this.backends = backends;
|
|
5274
5949
|
this.now = now;
|
|
5275
5950
|
this.random = random;
|
|
5951
|
+
this.terminalRetention = retention.terminalRetention ?? TERMINAL_OUTBOX_RETENTION;
|
|
5952
|
+
this.terminalPruneInterval = retention.terminalPruneInterval ?? TERMINAL_OUTBOX_PRUNE_INTERVAL;
|
|
5953
|
+
if (!Number.isSafeInteger(this.terminalRetention) || this.terminalRetention < 1)
|
|
5954
|
+
throw new Error("outbox_terminal_retention_invalid");
|
|
5955
|
+
if (!Number.isSafeInteger(this.terminalPruneInterval) || this.terminalPruneInterval < 1)
|
|
5956
|
+
throw new Error("outbox_terminal_prune_interval_invalid");
|
|
5957
|
+
this.terminalTransitions = this.terminalPruneInterval - 1;
|
|
5276
5958
|
for (const backend of backends.values()) {
|
|
5277
5959
|
if (backend.outboxProtocol !== "agz-memory-outbox/1") {
|
|
5278
5960
|
throw new Error("outbox_backend_fencing_required");
|
|
@@ -5377,30 +6059,52 @@ class OutboxWorker {
|
|
|
5377
6059
|
return transition === "succeeded" ? outcome : transition;
|
|
5378
6060
|
}
|
|
5379
6061
|
succeed(row) {
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
6062
|
+
let changed = false;
|
|
6063
|
+
this.db.transaction(() => {
|
|
6064
|
+
const result = this.db.query(`
|
|
6065
|
+
UPDATE index_outbox
|
|
6066
|
+
SET state = 'succeeded', completed_at = ?, lease_owner = NULL,
|
|
6067
|
+
lease_expires_at = NULL, heartbeat_at = NULL, last_error_code = NULL
|
|
6068
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
6069
|
+
AND lease_generation = ? AND fence = ?
|
|
6070
|
+
`).run(this.now(), row.id, this.workerID, row.lease_generation, row.fence);
|
|
6071
|
+
changed = result.changes === 1;
|
|
6072
|
+
if (changed)
|
|
6073
|
+
this.pruneTerminalOutbox();
|
|
6074
|
+
})();
|
|
6075
|
+
return changed ? "succeeded" : "lost_lease";
|
|
5388
6076
|
}
|
|
5389
6077
|
fail(row, errorCode) {
|
|
5390
6078
|
const dead = row.attempt_count >= 10;
|
|
5391
6079
|
const availableAt = this.now() + retryDelay(row.attempt_count, this.random());
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
6080
|
+
let changed = false;
|
|
6081
|
+
this.db.transaction(() => {
|
|
6082
|
+
const result = this.db.query(`
|
|
6083
|
+
UPDATE index_outbox
|
|
6084
|
+
SET state = ?, available_at = ?, lease_owner = NULL,
|
|
6085
|
+
lease_expires_at = NULL, heartbeat_at = NULL,
|
|
6086
|
+
last_error_code = ?, completed_at = ?
|
|
6087
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
6088
|
+
AND lease_generation = ? AND fence = ?
|
|
6089
|
+
`).run(dead ? "dead" : "pending", availableAt, errorCode, dead ? this.now() : null, row.id, this.workerID, row.lease_generation, row.fence);
|
|
6090
|
+
changed = result.changes === 1;
|
|
6091
|
+
if (changed && dead)
|
|
6092
|
+
this.pruneTerminalOutbox();
|
|
6093
|
+
})();
|
|
6094
|
+
if (!changed)
|
|
5401
6095
|
return "lost_lease";
|
|
5402
6096
|
return dead ? "dead" : "retry";
|
|
5403
6097
|
}
|
|
6098
|
+
pruneTerminalOutbox() {
|
|
6099
|
+
this.terminalTransitions++;
|
|
6100
|
+
if (this.terminalTransitions < this.terminalPruneInterval)
|
|
6101
|
+
return;
|
|
6102
|
+
this.terminalTransitions = 0;
|
|
6103
|
+
this.db.query(`DELETE FROM index_outbox WHERE id IN (
|
|
6104
|
+
SELECT id FROM index_outbox WHERE state IN ('succeeded', 'dead')
|
|
6105
|
+
ORDER BY completed_at DESC, id DESC LIMIT -1 OFFSET ?
|
|
6106
|
+
)`).run(this.terminalRetention);
|
|
6107
|
+
}
|
|
5404
6108
|
}
|
|
5405
6109
|
function exportDocument(note) {
|
|
5406
6110
|
return deriveDocument({
|
|
@@ -5544,6 +6248,7 @@ class RetrievalStore {
|
|
|
5544
6248
|
this.backend = backend;
|
|
5545
6249
|
}
|
|
5546
6250
|
async retrieve(request) {
|
|
6251
|
+
assertTextLimit("query", request.query);
|
|
5547
6252
|
const query = request.query.trim();
|
|
5548
6253
|
const limit = boundedCardLimit(request.limit);
|
|
5549
6254
|
if (!query || limit === 0 || Date.now() >= request.deadlineAt) {
|
|
@@ -5912,6 +6617,46 @@ function escapeText(value) {
|
|
|
5912
6617
|
function escapeAttribute(value) {
|
|
5913
6618
|
return escapeText(value).replaceAll('"', """).replaceAll("'", "'");
|
|
5914
6619
|
}
|
|
6620
|
+
// src/contracts/error.ts
|
|
6621
|
+
class MemoryBusinessError extends Error {
|
|
6622
|
+
code;
|
|
6623
|
+
correlationID;
|
|
6624
|
+
cause;
|
|
6625
|
+
constructor(code, message, correlationID = newCorrelationID(), cause) {
|
|
6626
|
+
super(message);
|
|
6627
|
+
this.code = code;
|
|
6628
|
+
this.correlationID = correlationID;
|
|
6629
|
+
this.cause = cause;
|
|
6630
|
+
}
|
|
6631
|
+
}
|
|
6632
|
+
function correlationID() {
|
|
6633
|
+
return newCorrelationID();
|
|
6634
|
+
}
|
|
6635
|
+
function newCorrelationID() {
|
|
6636
|
+
return crypto.randomUUID();
|
|
6637
|
+
}
|
|
6638
|
+
function businessError(code, message, id = correlationID(), cause) {
|
|
6639
|
+
return new MemoryBusinessError(code, message, id, cause);
|
|
6640
|
+
}
|
|
6641
|
+
function toPublicError(error) {
|
|
6642
|
+
return { code: error.code, correlationID: error.correlationID, retryable: error.code === "internal_error", message: error.message };
|
|
6643
|
+
}
|
|
6644
|
+
function asBusinessError(error, id = correlationID()) {
|
|
6645
|
+
if (error instanceof MemoryBusinessError)
|
|
6646
|
+
return error;
|
|
6647
|
+
if (error instanceof RangeError)
|
|
6648
|
+
return businessError("limit_exceeded", error.message, id, error);
|
|
6649
|
+
if (error instanceof TypeError) {
|
|
6650
|
+
if (error.message === "invalid_cursor")
|
|
6651
|
+
return businessError("invalid_cursor", "cursor is invalid", id, error);
|
|
6652
|
+
if (error.message === "cursor_scope_mismatch")
|
|
6653
|
+
return businessError("cursor_scope_mismatch", "cursor does not match this request", id, error);
|
|
6654
|
+
if (error.message === "stale_snapshot")
|
|
6655
|
+
return businessError("stale_cursor", "cursor snapshot is stale", id, error);
|
|
6656
|
+
return businessError("invalid_request", "request is invalid", id, error);
|
|
6657
|
+
}
|
|
6658
|
+
return businessError("internal_error", "memory operation failed", id, error);
|
|
6659
|
+
}
|
|
5915
6660
|
|
|
5916
6661
|
// src/core.ts
|
|
5917
6662
|
class MemoryCore {
|
|
@@ -5937,25 +6682,41 @@ function openMemoryCore(databasePath, options = {}) {
|
|
|
5937
6682
|
}
|
|
5938
6683
|
export {
|
|
5939
6684
|
validateBackendHits,
|
|
6685
|
+
utf8Bytes,
|
|
6686
|
+
toPublicError,
|
|
5940
6687
|
redactText,
|
|
5941
6688
|
projectUserPrompt,
|
|
5942
6689
|
projectToolSignal,
|
|
5943
6690
|
projectSessionSummary,
|
|
5944
6691
|
projectAssistantParts,
|
|
5945
6692
|
parseCaptureEvent,
|
|
6693
|
+
paginate,
|
|
5946
6694
|
openMemoryCore,
|
|
5947
6695
|
normalizeSubjectKey,
|
|
6696
|
+
normalizeLegacyMutation,
|
|
6697
|
+
isMutationOperation,
|
|
5948
6698
|
formatUntrustedContext,
|
|
5949
6699
|
extractExplicitUserCandidate,
|
|
6700
|
+
encodeCursor,
|
|
6701
|
+
decodeCursor,
|
|
6702
|
+
correlationID,
|
|
5950
6703
|
captureIdempotencyKey,
|
|
5951
6704
|
captureEventSchema,
|
|
5952
6705
|
canAutoWrite,
|
|
6706
|
+
businessError,
|
|
6707
|
+
boundedText,
|
|
6708
|
+
assertTextLimit,
|
|
6709
|
+
assertStrictMutationOperation,
|
|
6710
|
+
assertRequestLimit,
|
|
6711
|
+
asBusinessError,
|
|
5953
6712
|
SUPPORTED_OPENCODE_VERSION,
|
|
5954
6713
|
SCHEMA_VERSION,
|
|
5955
6714
|
REDACTION_POLICY_VERSION,
|
|
5956
6715
|
PRODUCT_VERSION,
|
|
5957
6716
|
PREDICATES,
|
|
5958
6717
|
MemoryCore,
|
|
6718
|
+
MemoryBusinessError,
|
|
6719
|
+
LIMITS,
|
|
5959
6720
|
KINDS,
|
|
5960
6721
|
INLINE_LIMIT,
|
|
5961
6722
|
EXTRACTOR_VERSION,
|