@claude-flow/cli 3.34.0 → 3.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude/.proven-config-version +1 -0
  2. package/.claude/helpers/.helpers-version +1 -1
  3. package/.claude/helpers/helpers.manifest.json +2 -2
  4. package/.claude/helpers/statusline.cjs +0 -0
  5. package/.claude/proven-config.json +42 -0
  6. package/catalog-manifest.json +2 -2
  7. package/dist/src/commands/daemon.js +12 -7
  8. package/dist/src/commands/doctor.js +72 -1
  9. package/dist/src/commands/metaharness.js +37 -2
  10. package/dist/src/commands/neural.js +1 -1
  11. package/dist/src/log-filters.d.ts +3 -3
  12. package/dist/src/mcp-tools/metaharness-tools.js +35 -2
  13. package/dist/src/memory/memory-bridge.d.ts +25 -0
  14. package/dist/src/memory/memory-bridge.js +61 -8
  15. package/dist/src/memory/memory-initializer.d.ts +9 -3
  16. package/dist/src/memory/memory-initializer.js +344 -277
  17. package/dist/src/services/daemon-autostart.d.ts +31 -3
  18. package/dist/src/services/daemon-autostart.js +45 -3
  19. package/dist/src/services/distill-oracle.d.ts +1 -1
  20. package/dist/src/services/distill-oracle.js +2 -2
  21. package/dist/src/services/evolve-proof.d.ts +40 -1
  22. package/dist/src/services/evolve-proof.js +76 -14
  23. package/dist/src/services/flywheel-receipt.d.ts +15 -0
  24. package/dist/src/services/flywheel-receipt.js +22 -0
  25. package/dist/src/services/flywheel-sequential-evidence.d.ts +102 -0
  26. package/dist/src/services/flywheel-sequential-evidence.js +148 -0
  27. package/dist/src/services/flywheel-transaction.d.ts +71 -0
  28. package/dist/src/services/flywheel-transaction.js +121 -0
  29. package/dist/src/services/harness-flywheel-generations.d.ts +14 -0
  30. package/dist/src/services/harness-flywheel-generations.js +76 -4
  31. package/dist/src/services/harness-flywheel.d.ts +13 -0
  32. package/dist/src/services/harness-flywheel.js +31 -1
  33. package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
  34. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  35. package/package.json +6 -10
  36. 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
- const initSqlJs = (await import('sql.js')).default;
1113
- const SQL = await initSqlJs();
1114
- const fileBuffer = readFileMaybeEncrypted(dbPath, null);
1115
- const db = new SQL.Database(fileBuffer);
1116
- // Get current columns in memory_entries
1117
- const tableInfo = db.exec("PRAGMA table_info(memory_entries)");
1118
- const existingColumns = new Set(tableInfo[0]?.values?.map(row => row[1]) || []);
1119
- // Required columns that may be missing in older schemas
1120
- // Issue #977: 'type' column was missing from this list, causing store failures on older DBs
1121
- const requiredColumns = [
1122
- { name: 'content', definition: "content TEXT DEFAULT ''" },
1123
- { name: 'type', definition: "type TEXT DEFAULT 'semantic'" },
1124
- { name: 'embedding', definition: 'embedding TEXT' },
1125
- { name: 'embedding_model', definition: "embedding_model TEXT DEFAULT 'local'" },
1126
- { name: 'embedding_dimensions', definition: 'embedding_dimensions INTEGER' },
1127
- { name: 'tags', definition: 'tags TEXT' },
1128
- { name: 'metadata', definition: 'metadata TEXT' },
1129
- { name: 'owner_id', definition: 'owner_id TEXT' },
1130
- { name: 'expires_at', definition: 'expires_at INTEGER' },
1131
- { name: 'last_accessed_at', definition: 'last_accessed_at INTEGER' },
1132
- { name: 'access_count', definition: 'access_count INTEGER DEFAULT 0' },
1133
- { name: 'status', definition: "status TEXT DEFAULT 'active'" },
1134
- // ADR-323: older DBs predate provenance typing entirely — backfilled
1135
- // as 'unknown' via the DEFAULT, same convention as 'type'/'status'
1136
- // above (no CHECK on the ALTER; enforcement happens in storeEntry()/
1137
- // bridgeStoreEntry() so an invalid value gets a CLI-friendly error
1138
- // instead of a raw SQLite constraint failure).
1139
- { name: 'provenance_type', definition: "provenance_type TEXT DEFAULT 'unknown'" }
1140
- ];
1141
- let modified = false;
1142
- for (const col of requiredColumns) {
1143
- if (!existingColumns.has(col.name)) {
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(`ALTER TABLE memory_entries ADD COLUMN ${col.definition}`);
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 (e) {
1150
- // Column might already exist or other error - continue
1172
+ catch {
1173
+ /* table is read-only or doesn't exist skip */
1151
1174
  }
1152
1175
  }
1153
- }
1154
- // #2120 Belt-and-suspenders backfill. `ALTER TABLE ADD COLUMN
1155
- // status TEXT DEFAULT 'active'` should populate existing rows with
1156
- // 'active' in modern SQLite, but: (a) some auto-memory bridge writes
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
- catch {
1168
- /* table is read-only or doesn't exist — skip */
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
- const initSqlJs = (await import('sql.js')).default;
1860
- const SQL = await initSqlJs();
1861
- const fileBuffer = fs.readFileSync(path_);
1862
- const db = new SQL.Database(fileBuffer);
1863
- // Apply decay: confidence *= exp(-decay_rate * days_since_last_use)
1864
- const now = Date.now();
1865
- const decayQuery = `
1866
- UPDATE patterns
1867
- SET
1868
- confidence = confidence * (1.0 - decay_rate * ((? - COALESCE(last_matched_at, created_at)) / 86400000.0)),
1869
- updated_at = ?
1870
- WHERE status = 'active'
1871
- AND confidence > 0.1
1872
- AND (? - COALESCE(last_matched_at, created_at)) > 86400000
1873
- `;
1874
- db.run(decayQuery, [now, now, now]);
1875
- const changes = db.getRowsModified();
1876
- // Save (atomic — issue #2584: a torn full-image flush corrupts the store)
1877
- const data = db.export();
1878
- writeFileAtomic(path_, Buffer.from(data));
1879
- db.close();
1880
- return {
1881
- success: true,
1882
- patternsDecayed: changes
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
- // #1941: provision a `vector_indexes` row for this namespace before the
2510
- // entry insert. The HNSW lookup uses this table to find which namespaces
2511
- // are indexed without a row, `memory_search({namespace:"X"})` returns
2512
- // 0 even when memory_entries holds matching rows. INSERT OR IGNORE
2513
- // preserves the existing `default` / `patterns` rows.
2514
- try {
2515
- db.run(`INSERT OR IGNORE INTO vector_indexes (id, name, dimensions) VALUES (?, ?, ?)`, [namespace, namespace, embeddingDimensions ?? 384]);
2516
- }
2517
- catch { /* vector_indexes may not exist on legacy DBs — fall through */ }
2518
- // Insert or update entry (upsert mode uses REPLACE)
2519
- const insertSql = upsert
2520
- ? `INSERT OR REPLACE INTO memory_entries (
2521
- id, key, namespace, content, type,
2522
- embedding, embedding_dimensions, embedding_model,
2523
- tags, metadata, provenance_type, created_at, updated_at, expires_at, status
2524
- ) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`
2525
- : `INSERT INTO memory_entries (
2526
- id, key, namespace, content, type,
2527
- embedding, embedding_dimensions, embedding_model,
2528
- tags, metadata, provenance_type, created_at, updated_at, expires_at, status
2529
- ) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`;
2530
- db.run(insertSql, [
2531
- id,
2532
- key,
2533
- namespace,
2534
- value,
2535
- embeddingJson,
2536
- embeddingDimensions,
2537
- embeddingModel,
2538
- tags.length > 0 ? JSON.stringify(tags) : null,
2539
- '{}',
2540
- persistedProvenance,
2541
- now,
2542
- now,
2543
- ttl ? now + (ttl * 1000) : null
2544
- ]);
2545
- // Save
2546
- const data = db.export();
2547
- writeFileRestricted(dbPath, Buffer.from(data), { encrypt: true });
2548
- db.close();
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
- // Ensure schema has all required columns (migration for older DBs)
2964
- await ensureSchemaColumns(dbPath);
2965
- const initSqlJs = (await import('sql.js')).default;
2966
- const SQL = await initSqlJs();
2967
- const fileBuffer = readFileMaybeEncrypted(dbPath, null);
2968
- const db = new SQL.Database(fileBuffer);
2969
- // Find entry by key
2970
- const getStmt = db.prepare(`
2971
- SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, tags
2972
- FROM memory_entries
2973
- WHERE ${ACTIVE_MEMORY_ROW_SQL}
2974
- AND key = ?
2975
- AND namespace = ?
2976
- LIMIT 1
2977
- `);
2978
- getStmt.bind([key, namespace]);
2979
- const getRows = [];
2980
- while (getStmt.step()) {
2981
- getRows.push(getStmt.get());
2982
- }
2983
- getStmt.free();
2984
- const result = getRows.length > 0 ? [{ values: getRows }] : [];
2985
- if (!result[0]?.values?.[0]) {
2986
- db.close();
2987
- return { success: true, found: false };
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
- catch {
3006
- // Invalid JSON
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
- return {
3010
- success: true,
3011
- found: true,
3012
- entry: {
3013
- id: String(id),
3014
- key: entryKey || String(id),
3015
- namespace: ns || 'default',
3016
- content: content || '',
3017
- accessCount: (accessCount || 0) + 1,
3018
- createdAt: createdAt || new Date().toISOString(),
3019
- updatedAt: updatedAt || new Date().toISOString(),
3020
- hasEmbedding: !!embedding && embedding.length > 10,
3021
- tags
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
- // Ensure schema has all required columns (migration for older DBs)
3079
- await ensureSchemaColumns(dbPath);
3080
- const initSqlJs = (await import('sql.js')).default;
3081
- const SQL = await initSqlJs();
3082
- const fileBuffer = readFileMaybeEncrypted(dbPath, null);
3083
- const db = new SQL.Database(fileBuffer);
3084
- // Check if entry exists first
3085
- const checkStmt = db.prepare(`
3086
- SELECT id FROM memory_entries
3087
- WHERE ${ACTIVE_MEMORY_ROW_SQL}
3088
- AND key = ?
3089
- AND namespace = ?
3090
- LIMIT 1
3091
- `);
3092
- checkStmt.bind([key, namespace]);
3093
- const checkRows = [];
3094
- while (checkStmt.step()) {
3095
- checkRows.push(checkStmt.get());
3096
- }
3097
- checkStmt.free();
3098
- const checkResult = checkRows.length > 0 ? [{ values: checkRows }] : [];
3099
- if (!checkResult[0]?.values?.[0]) {
3100
- // Get remaining count before closing
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: false,
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. Only
3180
- * meaningful between callers that opt into it (purgeNamespace does); it
3181
- * cannot coordinate against writers that don't call this helper.
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
- const lockFile = `${dbPath}.lock`;
3185
- const deadline = Date.now() + 5000;
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
- const fd = fs.openSync(lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
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
  }