@claude-flow/cli 3.34.0 → 3.35.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/.claude/.proven-config-version +1 -0
- package/.claude/helpers/.helpers-version +1 -1
- package/.claude/helpers/helpers.manifest.json +2 -2
- package/.claude/helpers/statusline.cjs +0 -0
- package/.claude/proven-config.json +42 -0
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/daemon.js +12 -7
- package/dist/src/commands/doctor.js +72 -1
- package/dist/src/commands/metaharness.js +37 -2
- package/dist/src/log-filters.d.ts +3 -3
- package/dist/src/mcp-tools/metaharness-tools.js +35 -2
- package/dist/src/memory/memory-bridge.d.ts +25 -0
- package/dist/src/memory/memory-bridge.js +61 -8
- package/dist/src/memory/memory-initializer.d.ts +9 -3
- package/dist/src/memory/memory-initializer.js +344 -277
- package/dist/src/services/daemon-autostart.d.ts +31 -3
- package/dist/src/services/daemon-autostart.js +45 -3
- package/dist/src/services/distill-oracle.d.ts +1 -1
- package/dist/src/services/distill-oracle.js +2 -2
- package/dist/src/services/evolve-proof.d.ts +40 -1
- package/dist/src/services/evolve-proof.js +76 -14
- package/dist/src/services/flywheel-receipt.d.ts +15 -0
- package/dist/src/services/flywheel-receipt.js +22 -0
- package/dist/src/services/flywheel-sequential-evidence.d.ts +102 -0
- package/dist/src/services/flywheel-sequential-evidence.js +148 -0
- package/dist/src/services/flywheel-transaction.d.ts +71 -0
- package/dist/src/services/flywheel-transaction.js +121 -0
- package/dist/src/services/harness-flywheel-generations.d.ts +14 -0
- package/dist/src/services/harness-flywheel-generations.js +76 -4
- package/dist/src/services/harness-flywheel.d.ts +13 -0
- package/dist/src/services/harness-flywheel.js +31 -1
- package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/package.json +4 -9
- package/plugins/ruflo-metaharness/scripts/smoke.sh +4 -2
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
import * as path from 'path';
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
14
|
import { createRequire } from 'node:module';
|
|
14
15
|
import { readFileMaybeEncrypted, writeFileAtomic, writeFileRestricted } from '../fs-secure.js';
|
|
15
16
|
import { restoreMemoryDbFromBackup } from '../services/memory-backup.js';
|
|
@@ -1109,72 +1110,77 @@ export async function ensureSchemaColumns(dbPath) {
|
|
|
1109
1110
|
if (!fs.existsSync(dbPath)) {
|
|
1110
1111
|
return { success: true, columnsAdded: [] };
|
|
1111
1112
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1113
|
+
// #2878: the ALTER/backfill below is a whole-image read-modify-write like
|
|
1114
|
+
// every other mutator here. Reentrant, so the callers that run this from
|
|
1115
|
+
// inside their own critical section don't self-deadlock.
|
|
1116
|
+
return await withMemoryDbLock(dbPath, async () => {
|
|
1117
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
1118
|
+
const SQL = await initSqlJs();
|
|
1119
|
+
const fileBuffer = readFileMaybeEncrypted(dbPath, null);
|
|
1120
|
+
const db = new SQL.Database(fileBuffer);
|
|
1121
|
+
// Get current columns in memory_entries
|
|
1122
|
+
const tableInfo = db.exec("PRAGMA table_info(memory_entries)");
|
|
1123
|
+
const existingColumns = new Set(tableInfo[0]?.values?.map(row => row[1]) || []);
|
|
1124
|
+
// Required columns that may be missing in older schemas
|
|
1125
|
+
// Issue #977: 'type' column was missing from this list, causing store failures on older DBs
|
|
1126
|
+
const requiredColumns = [
|
|
1127
|
+
{ name: 'content', definition: "content TEXT DEFAULT ''" },
|
|
1128
|
+
{ name: 'type', definition: "type TEXT DEFAULT 'semantic'" },
|
|
1129
|
+
{ name: 'embedding', definition: 'embedding TEXT' },
|
|
1130
|
+
{ name: 'embedding_model', definition: "embedding_model TEXT DEFAULT 'local'" },
|
|
1131
|
+
{ name: 'embedding_dimensions', definition: 'embedding_dimensions INTEGER' },
|
|
1132
|
+
{ name: 'tags', definition: 'tags TEXT' },
|
|
1133
|
+
{ name: 'metadata', definition: 'metadata TEXT' },
|
|
1134
|
+
{ name: 'owner_id', definition: 'owner_id TEXT' },
|
|
1135
|
+
{ name: 'expires_at', definition: 'expires_at INTEGER' },
|
|
1136
|
+
{ name: 'last_accessed_at', definition: 'last_accessed_at INTEGER' },
|
|
1137
|
+
{ name: 'access_count', definition: 'access_count INTEGER DEFAULT 0' },
|
|
1138
|
+
{ name: 'status', definition: "status TEXT DEFAULT 'active'" },
|
|
1139
|
+
// ADR-323: older DBs predate provenance typing entirely — backfilled
|
|
1140
|
+
// as 'unknown' via the DEFAULT, same convention as 'type'/'status'
|
|
1141
|
+
// above (no CHECK on the ALTER; enforcement happens in storeEntry()/
|
|
1142
|
+
// bridgeStoreEntry() so an invalid value gets a CLI-friendly error
|
|
1143
|
+
// instead of a raw SQLite constraint failure).
|
|
1144
|
+
{ name: 'provenance_type', definition: "provenance_type TEXT DEFAULT 'unknown'" }
|
|
1145
|
+
];
|
|
1146
|
+
let modified = false;
|
|
1147
|
+
for (const col of requiredColumns) {
|
|
1148
|
+
if (!existingColumns.has(col.name)) {
|
|
1149
|
+
try {
|
|
1150
|
+
db.run(`ALTER TABLE memory_entries ADD COLUMN ${col.definition}`);
|
|
1151
|
+
columnsAdded.push(col.name);
|
|
1152
|
+
modified = true;
|
|
1153
|
+
}
|
|
1154
|
+
catch (e) {
|
|
1155
|
+
// Column might already exist or other error - continue
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
// #2120 — Belt-and-suspenders backfill. `ALTER TABLE ADD COLUMN
|
|
1160
|
+
// status TEXT DEFAULT 'active'` should populate existing rows with
|
|
1161
|
+
// 'active' in modern SQLite, but: (a) some auto-memory bridge writes
|
|
1162
|
+
// happen via INSERT paths that pass an explicit NULL, (b) some
|
|
1163
|
+
// historical sql.js builds skipped the DEFAULT backfill, (c)
|
|
1164
|
+
// entries can be migrated in from older snapshots. After ensuring
|
|
1165
|
+
// the column exists, force-backfill any remaining NULL → 'active'.
|
|
1166
|
+
// Safe on already-correct DBs (0 rows updated).
|
|
1167
|
+
if (columnsAdded.includes('status') || existingColumns.has('status')) {
|
|
1144
1168
|
try {
|
|
1145
|
-
db.run(`
|
|
1146
|
-
columnsAdded.push(col.name);
|
|
1169
|
+
db.run(`UPDATE memory_entries SET status = 'active' WHERE status IS NULL`);
|
|
1147
1170
|
modified = true;
|
|
1148
1171
|
}
|
|
1149
|
-
catch
|
|
1150
|
-
|
|
1172
|
+
catch {
|
|
1173
|
+
/* table is read-only or doesn't exist — skip */
|
|
1151
1174
|
}
|
|
1152
1175
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
// happen via INSERT paths that pass an explicit NULL, (b) some
|
|
1158
|
-
// historical sql.js builds skipped the DEFAULT backfill, (c)
|
|
1159
|
-
// entries can be migrated in from older snapshots. After ensuring
|
|
1160
|
-
// the column exists, force-backfill any remaining NULL → 'active'.
|
|
1161
|
-
// Safe on already-correct DBs (0 rows updated).
|
|
1162
|
-
if (columnsAdded.includes('status') || existingColumns.has('status')) {
|
|
1163
|
-
try {
|
|
1164
|
-
db.run(`UPDATE memory_entries SET status = 'active' WHERE status IS NULL`);
|
|
1165
|
-
modified = true;
|
|
1176
|
+
if (modified) {
|
|
1177
|
+
// Save updated database
|
|
1178
|
+
const data = db.export();
|
|
1179
|
+
writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
|
|
1166
1180
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
}
|
|
1171
|
-
if (modified) {
|
|
1172
|
-
// Save updated database
|
|
1173
|
-
const data = db.export();
|
|
1174
|
-
writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
|
|
1175
|
-
}
|
|
1176
|
-
db.close();
|
|
1177
|
-
return { success: true, columnsAdded };
|
|
1181
|
+
db.close();
|
|
1182
|
+
return { success: true, columnsAdded };
|
|
1183
|
+
});
|
|
1178
1184
|
}
|
|
1179
1185
|
catch (error) {
|
|
1180
1186
|
return {
|
|
@@ -1856,31 +1862,35 @@ export async function applyTemporalDecay(dbPath) {
|
|
|
1856
1862
|
const swarmDir = getMemoryRoot();
|
|
1857
1863
|
const path_ = dbPath || path.join(swarmDir, 'memory.db');
|
|
1858
1864
|
try {
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1865
|
+
// #2878: decays `patterns` but rewrites the whole memory.db image, so it
|
|
1866
|
+
// races the memory_entries writers over the same file.
|
|
1867
|
+
return await withMemoryDbLock(path_, async () => {
|
|
1868
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
1869
|
+
const SQL = await initSqlJs();
|
|
1870
|
+
const fileBuffer = fs.readFileSync(path_);
|
|
1871
|
+
const db = new SQL.Database(fileBuffer);
|
|
1872
|
+
// Apply decay: confidence *= exp(-decay_rate * days_since_last_use)
|
|
1873
|
+
const now = Date.now();
|
|
1874
|
+
const decayQuery = `
|
|
1875
|
+
UPDATE patterns
|
|
1876
|
+
SET
|
|
1877
|
+
confidence = confidence * (1.0 - decay_rate * ((? - COALESCE(last_matched_at, created_at)) / 86400000.0)),
|
|
1878
|
+
updated_at = ?
|
|
1879
|
+
WHERE status = 'active'
|
|
1880
|
+
AND confidence > 0.1
|
|
1881
|
+
AND (? - COALESCE(last_matched_at, created_at)) > 86400000
|
|
1882
|
+
`;
|
|
1883
|
+
db.run(decayQuery, [now, now, now]);
|
|
1884
|
+
const changes = db.getRowsModified();
|
|
1885
|
+
// Save (atomic — issue #2584: a torn full-image flush corrupts the store)
|
|
1886
|
+
const data = db.export();
|
|
1887
|
+
writeFileAtomic(path_, Buffer.from(data));
|
|
1888
|
+
db.close();
|
|
1889
|
+
return {
|
|
1890
|
+
success: true,
|
|
1891
|
+
patternsDecayed: changes
|
|
1892
|
+
};
|
|
1893
|
+
});
|
|
1884
1894
|
}
|
|
1885
1895
|
catch (error) {
|
|
1886
1896
|
return {
|
|
@@ -2475,28 +2485,13 @@ export async function storeEntry(options) {
|
|
|
2475
2485
|
error: await walRefusalError('write'),
|
|
2476
2486
|
};
|
|
2477
2487
|
}
|
|
2478
|
-
// Ensure schema has all required columns (migration for older DBs)
|
|
2479
|
-
await ensureSchemaColumns(dbPath);
|
|
2480
|
-
const initSqlJs = (await import('sql.js')).default;
|
|
2481
|
-
const SQL = await initSqlJs();
|
|
2482
|
-
const fileBuffer = readFileMaybeEncrypted(dbPath, null);
|
|
2483
|
-
const db = new SQL.Database(fileBuffer);
|
|
2484
|
-
let persistedProvenance = provenanceType ?? 'unknown';
|
|
2485
|
-
if (upsert && provenanceType === undefined) {
|
|
2486
|
-
try {
|
|
2487
|
-
const stmt = db.prepare('SELECT provenance_type FROM memory_entries WHERE namespace = ? AND key = ? LIMIT 1');
|
|
2488
|
-
stmt.bind([namespace, key]);
|
|
2489
|
-
if (stmt.step()) {
|
|
2490
|
-
const existingType = stmt.get()[0];
|
|
2491
|
-
persistedProvenance = isValidProvenanceType(existingType) ? existingType : 'unknown';
|
|
2492
|
-
}
|
|
2493
|
-
stmt.free();
|
|
2494
|
-
}
|
|
2495
|
-
catch { /* legacy schema or new row — keep unknown */ }
|
|
2496
|
-
}
|
|
2497
2488
|
const id = `entry_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
2498
2489
|
const now = Date.now();
|
|
2499
|
-
// Generate embedding if requested
|
|
2490
|
+
// Generate embedding if requested.
|
|
2491
|
+
// #2878: deliberately BEFORE the lock. Embedding generation can take
|
|
2492
|
+
// hundreds of ms (ONNX), and it needs nothing from the database — running
|
|
2493
|
+
// it inside the critical section would hold every other writer off for
|
|
2494
|
+
// its whole duration and push them toward the acquire timeout.
|
|
2500
2495
|
let embeddingJson = null;
|
|
2501
2496
|
let embeddingDimensions = null;
|
|
2502
2497
|
let embeddingModel = null;
|
|
@@ -2506,46 +2501,79 @@ export async function storeEntry(options) {
|
|
|
2506
2501
|
embeddingDimensions = embResult.dimensions;
|
|
2507
2502
|
embeddingModel = embResult.model;
|
|
2508
2503
|
}
|
|
2509
|
-
// #
|
|
2510
|
-
//
|
|
2511
|
-
//
|
|
2512
|
-
//
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
namespace
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2504
|
+
// #2878: load → mutate → persist must be atomic against other writers.
|
|
2505
|
+
// Without this, two concurrent stores both read the same predecessor
|
|
2506
|
+
// image and the last flush silently drops the other's row while still
|
|
2507
|
+
// reporting success.
|
|
2508
|
+
await withMemoryDbLock(dbPath, async () => {
|
|
2509
|
+
// Ensure schema has all required columns (migration for older DBs)
|
|
2510
|
+
await ensureSchemaColumns(dbPath);
|
|
2511
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
2512
|
+
const SQL = await initSqlJs();
|
|
2513
|
+
const fileBuffer = readFileMaybeEncrypted(dbPath, null);
|
|
2514
|
+
const db = new SQL.Database(fileBuffer);
|
|
2515
|
+
let persistedProvenance = provenanceType ?? 'unknown';
|
|
2516
|
+
if (upsert && provenanceType === undefined) {
|
|
2517
|
+
try {
|
|
2518
|
+
const stmt = db.prepare('SELECT provenance_type FROM memory_entries WHERE namespace = ? AND key = ? LIMIT 1');
|
|
2519
|
+
stmt.bind([namespace, key]);
|
|
2520
|
+
if (stmt.step()) {
|
|
2521
|
+
const existingType = stmt.get()[0];
|
|
2522
|
+
persistedProvenance = isValidProvenanceType(existingType) ? existingType : 'unknown';
|
|
2523
|
+
}
|
|
2524
|
+
stmt.free();
|
|
2525
|
+
}
|
|
2526
|
+
catch { /* legacy schema or new row — keep unknown */ }
|
|
2527
|
+
}
|
|
2528
|
+
// #1941: provision a `vector_indexes` row for this namespace before the
|
|
2529
|
+
// entry insert. The HNSW lookup uses this table to find which namespaces
|
|
2530
|
+
// are indexed — without a row, `memory_search({namespace:"X"})` returns
|
|
2531
|
+
// 0 even when memory_entries holds matching rows. INSERT OR IGNORE
|
|
2532
|
+
// preserves the existing `default` / `patterns` rows.
|
|
2533
|
+
try {
|
|
2534
|
+
db.run(`INSERT OR IGNORE INTO vector_indexes (id, name, dimensions) VALUES (?, ?, ?)`, [namespace, namespace, embeddingDimensions ?? 384]);
|
|
2535
|
+
}
|
|
2536
|
+
catch { /* vector_indexes may not exist on legacy DBs — fall through */ }
|
|
2537
|
+
// Insert or update entry (upsert mode uses REPLACE)
|
|
2538
|
+
const insertSql = upsert
|
|
2539
|
+
? `INSERT OR REPLACE INTO memory_entries (
|
|
2540
|
+
id, key, namespace, content, type,
|
|
2541
|
+
embedding, embedding_dimensions, embedding_model,
|
|
2542
|
+
tags, metadata, provenance_type, created_at, updated_at, expires_at, status
|
|
2543
|
+
) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`
|
|
2544
|
+
: `INSERT INTO memory_entries (
|
|
2545
|
+
id, key, namespace, content, type,
|
|
2546
|
+
embedding, embedding_dimensions, embedding_model,
|
|
2547
|
+
tags, metadata, provenance_type, created_at, updated_at, expires_at, status
|
|
2548
|
+
) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`;
|
|
2549
|
+
try {
|
|
2550
|
+
db.run(insertSql, [
|
|
2551
|
+
id,
|
|
2552
|
+
key,
|
|
2553
|
+
namespace,
|
|
2554
|
+
value,
|
|
2555
|
+
embeddingJson,
|
|
2556
|
+
embeddingDimensions,
|
|
2557
|
+
embeddingModel,
|
|
2558
|
+
tags.length > 0 ? JSON.stringify(tags) : null,
|
|
2559
|
+
'{}',
|
|
2560
|
+
persistedProvenance,
|
|
2561
|
+
now,
|
|
2562
|
+
now,
|
|
2563
|
+
ttl ? now + (ttl * 1000) : null
|
|
2564
|
+
]);
|
|
2565
|
+
}
|
|
2566
|
+
catch (e) {
|
|
2567
|
+
// Don't leave the handle open (and the lock held) on a constraint
|
|
2568
|
+
// failure — the outer catch turns this into the caller's error.
|
|
2569
|
+
db.close();
|
|
2570
|
+
throw e;
|
|
2571
|
+
}
|
|
2572
|
+
// Save
|
|
2573
|
+
const data = db.export();
|
|
2574
|
+
writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
|
|
2575
|
+
db.close();
|
|
2576
|
+
});
|
|
2549
2577
|
// Add to HNSW index for faster future searches
|
|
2550
2578
|
if (embeddingJson) {
|
|
2551
2579
|
const embResult = JSON.parse(embeddingJson);
|
|
@@ -2960,67 +2988,74 @@ export async function getEntry(options) {
|
|
|
2960
2988
|
error: await walRefusalError('read/write'),
|
|
2961
2989
|
};
|
|
2962
2990
|
}
|
|
2963
|
-
//
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
}
|
|
2989
|
-
const [id, entryKey, ns, content, embedding, accessCount, createdAt, updatedAt, tagsJson] = result[0].values[0];
|
|
2990
|
-
// Update access count
|
|
2991
|
-
db.run(`
|
|
2992
|
-
UPDATE memory_entries
|
|
2993
|
-
SET access_count = access_count + 1, last_accessed_at = strftime('%s', 'now') * 1000
|
|
2994
|
-
WHERE id = ?
|
|
2995
|
-
`, [String(id)]);
|
|
2996
|
-
// Save updated database
|
|
2997
|
-
const data = db.export();
|
|
2998
|
-
writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
|
|
2999
|
-
db.close();
|
|
3000
|
-
let tags = [];
|
|
3001
|
-
if (tagsJson) {
|
|
3002
|
-
try {
|
|
3003
|
-
tags = JSON.parse(tagsJson);
|
|
2991
|
+
// #2878: this is a mutator, not a reader — the access_count bump below
|
|
2992
|
+
// rewrites the whole image, so an unlocked "get" concurrent with a store
|
|
2993
|
+
// flushes a predecessor image over it and silently drops the new row.
|
|
2994
|
+
// There is no cheaper granularity available: the read and the bump share
|
|
2995
|
+
// one image, so the lock has to span both.
|
|
2996
|
+
return await withMemoryDbLock(dbPath, async () => {
|
|
2997
|
+
// Ensure schema has all required columns (migration for older DBs)
|
|
2998
|
+
await ensureSchemaColumns(dbPath);
|
|
2999
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
3000
|
+
const SQL = await initSqlJs();
|
|
3001
|
+
const fileBuffer = readFileMaybeEncrypted(dbPath, null);
|
|
3002
|
+
const db = new SQL.Database(fileBuffer);
|
|
3003
|
+
// Find entry by key
|
|
3004
|
+
const getStmt = db.prepare(`
|
|
3005
|
+
SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, tags
|
|
3006
|
+
FROM memory_entries
|
|
3007
|
+
WHERE ${ACTIVE_MEMORY_ROW_SQL}
|
|
3008
|
+
AND key = ?
|
|
3009
|
+
AND namespace = ?
|
|
3010
|
+
LIMIT 1
|
|
3011
|
+
`);
|
|
3012
|
+
getStmt.bind([key, namespace]);
|
|
3013
|
+
const getRows = [];
|
|
3014
|
+
while (getStmt.step()) {
|
|
3015
|
+
getRows.push(getStmt.get());
|
|
3004
3016
|
}
|
|
3005
|
-
|
|
3006
|
-
|
|
3017
|
+
getStmt.free();
|
|
3018
|
+
const result = getRows.length > 0 ? [{ values: getRows }] : [];
|
|
3019
|
+
if (!result[0]?.values?.[0]) {
|
|
3020
|
+
db.close();
|
|
3021
|
+
return { success: true, found: false };
|
|
3007
3022
|
}
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3023
|
+
const [id, entryKey, ns, content, embedding, accessCount, createdAt, updatedAt, tagsJson] = result[0].values[0];
|
|
3024
|
+
// Update access count
|
|
3025
|
+
db.run(`
|
|
3026
|
+
UPDATE memory_entries
|
|
3027
|
+
SET access_count = access_count + 1, last_accessed_at = strftime('%s', 'now') * 1000
|
|
3028
|
+
WHERE id = ?
|
|
3029
|
+
`, [String(id)]);
|
|
3030
|
+
// Save updated database
|
|
3031
|
+
const data = db.export();
|
|
3032
|
+
writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
|
|
3033
|
+
db.close();
|
|
3034
|
+
let tags = [];
|
|
3035
|
+
if (tagsJson) {
|
|
3036
|
+
try {
|
|
3037
|
+
tags = JSON.parse(tagsJson);
|
|
3038
|
+
}
|
|
3039
|
+
catch {
|
|
3040
|
+
// Invalid JSON
|
|
3041
|
+
}
|
|
3022
3042
|
}
|
|
3023
|
-
|
|
3043
|
+
return {
|
|
3044
|
+
success: true,
|
|
3045
|
+
found: true,
|
|
3046
|
+
entry: {
|
|
3047
|
+
id: String(id),
|
|
3048
|
+
key: entryKey || String(id),
|
|
3049
|
+
namespace: ns || 'default',
|
|
3050
|
+
content: content || '',
|
|
3051
|
+
accessCount: (accessCount || 0) + 1,
|
|
3052
|
+
createdAt: createdAt || new Date().toISOString(),
|
|
3053
|
+
updatedAt: updatedAt || new Date().toISOString(),
|
|
3054
|
+
hasEmbedding: !!embedding && embedding.length > 10,
|
|
3055
|
+
tags
|
|
3056
|
+
}
|
|
3057
|
+
};
|
|
3058
|
+
});
|
|
3024
3059
|
}
|
|
3025
3060
|
catch (error) {
|
|
3026
3061
|
return {
|
|
@@ -3075,71 +3110,75 @@ export async function deleteEntry(options) {
|
|
|
3075
3110
|
error: await walRefusalError('write'),
|
|
3076
3111
|
};
|
|
3077
3112
|
}
|
|
3078
|
-
//
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3113
|
+
// #2878: whole-image read-modify-write — without the lock a concurrent
|
|
3114
|
+
// writer's flush resurrects the row this call just tombstoned.
|
|
3115
|
+
return await withMemoryDbLock(dbPath, async () => {
|
|
3116
|
+
// Ensure schema has all required columns (migration for older DBs)
|
|
3117
|
+
await ensureSchemaColumns(dbPath);
|
|
3118
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
3119
|
+
const SQL = await initSqlJs();
|
|
3120
|
+
const fileBuffer = readFileMaybeEncrypted(dbPath, null);
|
|
3121
|
+
const db = new SQL.Database(fileBuffer);
|
|
3122
|
+
// Check if entry exists first
|
|
3123
|
+
const checkStmt = db.prepare(`
|
|
3124
|
+
SELECT id FROM memory_entries
|
|
3125
|
+
WHERE ${ACTIVE_MEMORY_ROW_SQL}
|
|
3126
|
+
AND key = ?
|
|
3127
|
+
AND namespace = ?
|
|
3128
|
+
LIMIT 1
|
|
3129
|
+
`);
|
|
3130
|
+
checkStmt.bind([key, namespace]);
|
|
3131
|
+
const checkRows = [];
|
|
3132
|
+
while (checkStmt.step()) {
|
|
3133
|
+
checkRows.push(checkStmt.get());
|
|
3134
|
+
}
|
|
3135
|
+
checkStmt.free();
|
|
3136
|
+
const checkResult = checkRows.length > 0 ? [{ values: checkRows }] : [];
|
|
3137
|
+
if (!checkResult[0]?.values?.[0]) {
|
|
3138
|
+
// Get remaining count before closing
|
|
3139
|
+
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE ${ACTIVE_MEMORY_ROW_SQL}`);
|
|
3140
|
+
const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
|
|
3141
|
+
db.close();
|
|
3142
|
+
return {
|
|
3143
|
+
success: true,
|
|
3144
|
+
deleted: false,
|
|
3145
|
+
key,
|
|
3146
|
+
namespace,
|
|
3147
|
+
remainingEntries,
|
|
3148
|
+
error: `Key '${key}' not found in namespace '${namespace}'`
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
// Delete the entry (soft delete by setting status to 'deleted')
|
|
3152
|
+
// Also null out the embedding to clean up vector data from SQLite
|
|
3153
|
+
db.run(`
|
|
3154
|
+
UPDATE memory_entries
|
|
3155
|
+
SET status = 'deleted',
|
|
3156
|
+
embedding = NULL,
|
|
3157
|
+
updated_at = strftime('%s', 'now') * 1000
|
|
3158
|
+
WHERE key = ?
|
|
3159
|
+
AND namespace = ?
|
|
3160
|
+
AND ${ACTIVE_MEMORY_ROW_SQL}
|
|
3161
|
+
`, [key, namespace]);
|
|
3162
|
+
// Get remaining count
|
|
3101
3163
|
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE ${ACTIVE_MEMORY_ROW_SQL}`);
|
|
3102
3164
|
const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
|
|
3165
|
+
// Save updated database
|
|
3166
|
+
const data = db.export();
|
|
3167
|
+
writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
|
|
3103
3168
|
db.close();
|
|
3169
|
+
// Clean up in-memory HNSW index so ghost vectors don't appear in searches.
|
|
3170
|
+
// Remove the entry from the HNSW entries map and invalidate the index.
|
|
3171
|
+
// The next search will rebuild the HNSW index from the remaining DB rows.
|
|
3172
|
+
if (hnswIndex?.entries)
|
|
3173
|
+
removeHNSWEntriesByKey(key, namespace);
|
|
3104
3174
|
return {
|
|
3105
3175
|
success: true,
|
|
3106
|
-
deleted:
|
|
3176
|
+
deleted: true,
|
|
3107
3177
|
key,
|
|
3108
3178
|
namespace,
|
|
3109
|
-
remainingEntries
|
|
3110
|
-
error: `Key '${key}' not found in namespace '${namespace}'`
|
|
3179
|
+
remainingEntries
|
|
3111
3180
|
};
|
|
3112
|
-
}
|
|
3113
|
-
// Delete the entry (soft delete by setting status to 'deleted')
|
|
3114
|
-
// Also null out the embedding to clean up vector data from SQLite
|
|
3115
|
-
db.run(`
|
|
3116
|
-
UPDATE memory_entries
|
|
3117
|
-
SET status = 'deleted',
|
|
3118
|
-
embedding = NULL,
|
|
3119
|
-
updated_at = strftime('%s', 'now') * 1000
|
|
3120
|
-
WHERE key = ?
|
|
3121
|
-
AND namespace = ?
|
|
3122
|
-
AND ${ACTIVE_MEMORY_ROW_SQL}
|
|
3123
|
-
`, [key, namespace]);
|
|
3124
|
-
// Get remaining count
|
|
3125
|
-
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE ${ACTIVE_MEMORY_ROW_SQL}`);
|
|
3126
|
-
const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
|
|
3127
|
-
// Save updated database
|
|
3128
|
-
const data = db.export();
|
|
3129
|
-
writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
|
|
3130
|
-
db.close();
|
|
3131
|
-
// Clean up in-memory HNSW index so ghost vectors don't appear in searches.
|
|
3132
|
-
// Remove the entry from the HNSW entries map and invalidate the index.
|
|
3133
|
-
// The next search will rebuild the HNSW index from the remaining DB rows.
|
|
3134
|
-
if (hnswIndex?.entries)
|
|
3135
|
-
removeHNSWEntriesByKey(key, namespace);
|
|
3136
|
-
return {
|
|
3137
|
-
success: true,
|
|
3138
|
-
deleted: true,
|
|
3139
|
-
key,
|
|
3140
|
-
namespace,
|
|
3141
|
-
remainingEntries
|
|
3142
|
-
};
|
|
3181
|
+
});
|
|
3143
3182
|
}
|
|
3144
3183
|
catch (error) {
|
|
3145
3184
|
return {
|
|
@@ -3171,32 +3210,48 @@ export async function deleteEntry(options) {
|
|
|
3171
3210
|
// bounds the race to "another purge/delete running at the same instant",
|
|
3172
3211
|
// which is the concrete case this feature needs to be safe against.
|
|
3173
3212
|
const MEMORY_DB_LOCK_STALE_MS = 10_000;
|
|
3213
|
+
const MEMORY_DB_LOCK_ACQUIRE_TIMEOUT_MS = 15_000;
|
|
3174
3214
|
function delayMs(ms) {
|
|
3175
3215
|
return new Promise((r) => setTimeout(r, ms));
|
|
3176
3216
|
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Locks this call stack already holds, keyed by resolved db path. #2878: the
|
|
3219
|
+
* mutators below call each other (every one runs `ensureSchemaColumns`, which
|
|
3220
|
+
* takes the same lock), and an O_EXCL lock is not reentrant — a nested
|
|
3221
|
+
* acquire would spin against our own lock file until the acquire timeout and
|
|
3222
|
+
* then throw. AsyncLocalStorage lets a nested acquire recognise the lock as
|
|
3223
|
+
* already held and run inline, while genuinely independent callers (separate
|
|
3224
|
+
* `Promise.all` branches, separate processes) still contend normally.
|
|
3225
|
+
*/
|
|
3226
|
+
const heldMemoryDbLocks = new AsyncLocalStorage();
|
|
3177
3227
|
/**
|
|
3178
3228
|
* Advisory O_EXCL lock scoped to a single memory.db file (`<dbPath>.lock`),
|
|
3179
|
-
* same stale-takeover pattern as services/global-ai-budget.ts.
|
|
3180
|
-
*
|
|
3181
|
-
*
|
|
3229
|
+
* same stale-takeover pattern as services/global-ai-budget.ts.
|
|
3230
|
+
*
|
|
3231
|
+
* #2878: sql.js persists by rewriting the whole database image, so every
|
|
3232
|
+
* `load → mutate → export → write` sequence is a read-modify-write that a
|
|
3233
|
+
* concurrent writer can clobber — both callers report success and the loser's
|
|
3234
|
+
* rows vanish, with `PRAGMA integrity_check` still clean. Every such sequence
|
|
3235
|
+
* in this module now runs inside this lock. It is advisory, so it still
|
|
3236
|
+
* cannot coordinate against a writer that bypasses this module entirely
|
|
3237
|
+
* (a native WAL connection — see hasNativeWalSidecars).
|
|
3182
3238
|
*/
|
|
3183
3239
|
export async function withMemoryDbLock(dbPath, fn) {
|
|
3184
|
-
|
|
3185
|
-
|
|
3240
|
+
// Callers spell the same file both ways (`storeEntry` resolves, `getEntry`
|
|
3241
|
+
// does not). Normalise, or two spellings would take two different locks and
|
|
3242
|
+
// serialize against nothing.
|
|
3243
|
+
const resolved = path.resolve(dbPath);
|
|
3244
|
+
const held = heldMemoryDbLocks.getStore();
|
|
3245
|
+
if (held?.has(resolved))
|
|
3246
|
+
return await fn();
|
|
3247
|
+
const nested = new Set(held ?? []);
|
|
3248
|
+
nested.add(resolved);
|
|
3249
|
+
const lockFile = `${resolved}.lock`;
|
|
3250
|
+
const deadline = Date.now() + MEMORY_DB_LOCK_ACQUIRE_TIMEOUT_MS;
|
|
3186
3251
|
for (;;) {
|
|
3252
|
+
let fd;
|
|
3187
3253
|
try {
|
|
3188
|
-
|
|
3189
|
-
fs.writeSync(fd, String(process.pid));
|
|
3190
|
-
fs.closeSync(fd);
|
|
3191
|
-
try {
|
|
3192
|
-
return await fn();
|
|
3193
|
-
}
|
|
3194
|
-
finally {
|
|
3195
|
-
try {
|
|
3196
|
-
fs.unlinkSync(lockFile);
|
|
3197
|
-
}
|
|
3198
|
-
catch { /* already gone */ }
|
|
3199
|
-
}
|
|
3254
|
+
fd = fs.openSync(lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
3200
3255
|
}
|
|
3201
3256
|
catch (e) {
|
|
3202
3257
|
if (e.code !== 'EEXIST')
|
|
@@ -3213,6 +3268,18 @@ export async function withMemoryDbLock(dbPath, fn) {
|
|
|
3213
3268
|
throw new Error(`timed out acquiring memory.db lock: ${lockFile}`);
|
|
3214
3269
|
}
|
|
3215
3270
|
await delayMs(25);
|
|
3271
|
+
continue;
|
|
3272
|
+
}
|
|
3273
|
+
fs.writeSync(fd, String(process.pid));
|
|
3274
|
+
fs.closeSync(fd);
|
|
3275
|
+
try {
|
|
3276
|
+
return await heldMemoryDbLocks.run(nested, fn);
|
|
3277
|
+
}
|
|
3278
|
+
finally {
|
|
3279
|
+
try {
|
|
3280
|
+
fs.unlinkSync(lockFile);
|
|
3281
|
+
}
|
|
3282
|
+
catch { /* already gone */ }
|
|
3216
3283
|
}
|
|
3217
3284
|
}
|
|
3218
3285
|
}
|