moflo 4.9.36 → 4.10.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.
Files changed (79) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
  2. package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
  3. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  4. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  5. package/.claude/guidance/shipped/moflo-subagents.md +4 -0
  6. package/.claude/helpers/gate.cjs +3 -3
  7. package/.claude/helpers/statusline.cjs +69 -33
  8. package/.claude/helpers/subagent-bootstrap.json +1 -1
  9. package/.claude/helpers/subagent-start.cjs +1 -1
  10. package/.claude/skills/eldar/SKILL.md +8 -0
  11. package/bin/build-embeddings.mjs +6 -20
  12. package/bin/cli.js +5 -0
  13. package/bin/gate.cjs +3 -3
  14. package/bin/generate-code-map.mjs +4 -24
  15. package/bin/hooks.mjs +3 -12
  16. package/bin/index-all.mjs +3 -13
  17. package/bin/index-guidance.mjs +59 -119
  18. package/bin/index-patterns.mjs +6 -24
  19. package/bin/index-tests.mjs +4 -23
  20. package/bin/lib/db-repair.mjs +4 -25
  21. package/bin/lib/get-backend.mjs +306 -0
  22. package/bin/lib/incremental-write.mjs +27 -7
  23. package/bin/lib/moflo-paths.mjs +64 -4
  24. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  25. package/bin/migrations/knowledge-purge.mjs +7 -8
  26. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  27. package/bin/migrations/purge-doc-entries.mjs +52 -0
  28. package/bin/migrations/strip-context-preambles.mjs +95 -0
  29. package/bin/run-migrations.mjs +1 -10
  30. package/bin/semantic-search.mjs +11 -19
  31. package/bin/session-start-launcher.mjs +102 -100
  32. package/bin/simplify-classify.cjs +38 -17
  33. package/dist/src/cli/commands/daemon.js +38 -11
  34. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  35. package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
  36. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  37. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  38. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  39. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  40. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  41. package/dist/src/cli/commands/doctor-registry.js +14 -0
  42. package/dist/src/cli/commands/doctor.js +1 -1
  43. package/dist/src/cli/commands/embeddings.js +17 -22
  44. package/dist/src/cli/commands/memory.js +54 -75
  45. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  46. package/dist/src/cli/init/claudemd-generator.js +4 -0
  47. package/dist/src/cli/init/moflo-init.js +40 -0
  48. package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
  49. package/dist/src/cli/memory/bridge-core.js +256 -30
  50. package/dist/src/cli/memory/bridge-entries.js +76 -8
  51. package/dist/src/cli/memory/controller-registry.js +7 -2
  52. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  53. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  54. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  55. package/dist/src/cli/memory/daemon-backend.js +400 -0
  56. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  57. package/dist/src/cli/memory/database-provider.js +57 -40
  58. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  59. package/dist/src/cli/memory/index.js +0 -1
  60. package/dist/src/cli/memory/memory-bridge.js +40 -8
  61. package/dist/src/cli/memory/memory-initializer.js +286 -220
  62. package/dist/src/cli/memory/rvf-migration.js +25 -11
  63. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  64. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  65. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  66. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  67. package/dist/src/cli/services/daemon-lock.js +58 -1
  68. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  69. package/dist/src/cli/services/embeddings-migration.js +9 -12
  70. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  71. package/dist/src/cli/services/learning-service.js +12 -20
  72. package/dist/src/cli/services/project-root.js +69 -9
  73. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  74. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  75. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  76. package/dist/src/cli/shared/events/event-store.js +26 -55
  77. package/dist/src/cli/version.js +1 -1
  78. package/package.json +2 -4
  79. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -1,26 +1,33 @@
1
1
  /**
2
2
  * V3 Memory Initializer
3
- * Properly initializes the memory database with sql.js (WASM SQLite)
4
- * Includes pattern tables, vector embeddings, migration state tracking
3
+ * Properly initializes the memory database with node:sqlite via the
4
+ * unified `openDaemonDatabase` factory.
5
+ * Includes pattern tables, vector embeddings, migration state tracking.
5
6
  *
6
7
  * ADR-053: Routes through ControllerRegistry → AgentDB v3 when available,
7
- * falls back to raw sql.js for backwards compatibility.
8
+ * falls back to a direct node:sqlite write for backwards compatibility.
9
+ *
10
+ * Phase 5 (#1084): every `mofloImport('sql.js') + new SQL.Database(buffer)`
11
+ * triplet (and the matching `atomicWriteFileSync(path, db.export())`) was
12
+ * replaced with `openDaemonDatabase(path)`. node:sqlite + WAL persists each
13
+ * mutation incrementally; the explicit whole-file-dump that sql.js needed
14
+ * was the multi-writer clobber vector epic #1078 killed structurally.
8
15
  *
9
16
  * @module v3/cli/memory-initializer
10
17
  */
11
18
  import * as fs from 'fs';
12
19
  import * as path from 'path';
13
- import { mofloImport } from '../services/moflo-require.js';
14
- import { atomicWriteFileSync } from '../services/atomic-file-write.js';
15
20
  import { formatEmbeddingError } from './embedding-errors.js';
16
21
  import { HnswLite } from './hnsw-lite.js';
17
22
  import { tryLoadHnswSidecar } from './hnsw-persistence.js';
18
23
  import { EMBEDDING_MODEL_OPT_OUT, EPHEMERAL_NAMESPACES, getBridgeEmbedder } from './bridge-embedder.js';
19
24
  import { parseEmbeddingJson, toFloat32 } from './controllers/_shared.js';
20
25
  import { writeVectorStatsJson } from './bridge-core.js';
26
+ import { serialiseMetadata } from './bridge-entries.js';
21
27
  import { errorDetail } from '../shared/utils/error-detail.js';
22
28
  import { MOFLO_DIR, hnswIndexPath, legacyMemoryDbPath, memoryDbPath, } from '../services/moflo-paths.js';
23
- import { tryDaemonStore, tryDaemonDelete } from './daemon-write-client.js';
29
+ import { tryDaemonStore, tryDaemonDelete, tryDaemonGet, tryDaemonSearch, tryDaemonList } from './daemon-write-client.js';
30
+ import { openDaemonDatabase } from './daemon-backend.js';
24
31
  // #981 — daemon-write-client throws are a contract violation (it's documented
25
32
  // as never-throw). When a throw escapes anyway, log to stderr ONCE per process
26
33
  // and fall through to the direct-write path. Silent swallow would hide bugs;
@@ -34,8 +41,8 @@ function logRoutingFault(err) {
34
41
  }
35
42
  /**
36
43
  * Write vector-stats.json cache for the statusline (no subprocess needed).
37
- * Called after memory store in the raw-sql.js fallback path. The bridge path
38
- * goes through refreshVectorStatsCache() in bridge-core.ts instead.
44
+ * Called after memory store in the direct-write fallback path. The bridge
45
+ * path goes through refreshVectorStatsCache() in bridge-core.ts instead.
39
46
  * @param dbPath - path to the SQLite database file
40
47
  * @param stats - exact counts from a db query already in progress (required —
41
48
  * making this optional caused issue #639 by silently writing 0)
@@ -424,14 +431,11 @@ export async function getHNSWIndex(options) {
424
431
  // adjacency. When the sidecar IS loaded we skip the per-row JSON.parse
425
432
  // of the embedding column, which is the expensive part on a populated
426
433
  // consumer DB.
427
- const SELECT_WITH_EMBEDDING = `id, key, namespace, content, embedding`;
428
- const SELECT_METADATA_ONLY = `id, key, namespace, content`;
434
+ const SELECT_WITH_EMBEDDING = `id, key, namespace, content, metadata, embedding`;
435
+ const SELECT_METADATA_ONLY = `id, key, namespace, content, metadata`;
429
436
  if (fs.existsSync(dbPath)) {
430
437
  try {
431
- const initSqlJs = (await mofloImport('sql.js')).default;
432
- const SQL = await initSqlJs();
433
- const fileBuffer = fs.readFileSync(dbPath);
434
- const sqlDb = new SQL.Database(fileBuffer);
438
+ const sqlDb = openDaemonDatabase(dbPath);
435
439
  const cols = sidecarLoaded ? SELECT_METADATA_ONLY : SELECT_WITH_EMBEDDING;
436
440
  const result = sqlDb.exec(`
437
441
  SELECT ${cols}
@@ -442,7 +446,9 @@ export async function getHNSWIndex(options) {
442
446
  let parseSkipped = 0;
443
447
  if (result[0]?.values) {
444
448
  for (const row of result[0].values) {
445
- const [id, key, ns, content, embeddingJson] = row;
449
+ // Column order matches SELECT_WITH_EMBEDDING / SELECT_METADATA_ONLY.
450
+ // When sidecar is loaded, embeddingJson is undefined (column absent).
451
+ const [id, key, ns, content, metadataJson, embeddingJson] = row;
446
452
  if (!sidecarLoaded) {
447
453
  const vec = parseEmbeddingJson(embeddingJson);
448
454
  if (!vec) {
@@ -455,7 +461,8 @@ export async function getHNSWIndex(options) {
455
461
  id: String(id),
456
462
  key: key || String(id),
457
463
  namespace: ns || 'default',
458
- content: content || ''
464
+ content: content || '',
465
+ metadata: metadataJson || undefined
459
466
  });
460
467
  }
461
468
  }
@@ -545,7 +552,8 @@ export async function searchHNSWIndex(queryEmbedding, options) {
545
552
  key: entry.key || entry.id.substring(0, 15),
546
553
  content: entry.content.substring(0, 60) + (entry.content.length > 60 ? '...' : ''),
547
554
  score,
548
- namespace: entry.namespace
555
+ namespace: entry.namespace,
556
+ metadata: entry.metadata
549
557
  });
550
558
  if (filtered.length >= k)
551
559
  break;
@@ -830,10 +838,7 @@ export async function ensureSchemaColumns(dbPath) {
830
838
  if (!fs.existsSync(dbPath)) {
831
839
  return { success: true, columnsAdded: [] };
832
840
  }
833
- const initSqlJs = (await mofloImport('sql.js')).default;
834
- const SQL = await initSqlJs();
835
- const fileBuffer = fs.readFileSync(dbPath);
836
- const db = new SQL.Database(fileBuffer);
841
+ const db = openDaemonDatabase(dbPath);
837
842
  // Get current columns in memory_entries
838
843
  const tableInfo = db.exec("PRAGMA table_info(memory_entries)");
839
844
  const existingColumns = new Set(tableInfo[0]?.values?.map(row => row[1]) || []);
@@ -853,22 +858,17 @@ export async function ensureSchemaColumns(dbPath) {
853
858
  { name: 'access_count', definition: 'access_count INTEGER DEFAULT 0' },
854
859
  { name: 'status', definition: "status TEXT DEFAULT 'active'" }
855
860
  ];
856
- let modified = false;
857
861
  for (const col of requiredColumns) {
858
862
  if (!existingColumns.has(col.name)) {
859
863
  try {
860
864
  db.run(`ALTER TABLE memory_entries ADD COLUMN ${col.definition}`);
861
865
  columnsAdded.push(col.name);
862
- modified = true;
863
866
  }
864
867
  catch (e) {
865
868
  // Column might already exist or other error - continue
866
869
  }
867
870
  }
868
871
  }
869
- if (modified) {
870
- atomicWriteFileSync(dbPath, db.export());
871
- }
872
872
  db.close();
873
873
  return { success: true, columnsAdded };
874
874
  }
@@ -900,10 +900,7 @@ export async function checkAndMigrateLegacy(options) {
900
900
  for (const legacyPath of legacyPaths) {
901
901
  if (fs.existsSync(legacyPath) && legacyPath !== dbPath) {
902
902
  try {
903
- const initSqlJs = (await mofloImport('sql.js')).default;
904
- const SQL = await initSqlJs();
905
- const legacyBuffer = fs.readFileSync(legacyPath);
906
- const legacyDb = new SQL.Database(legacyBuffer);
903
+ const legacyDb = openDaemonDatabase(legacyPath);
907
904
  // Check if it has data
908
905
  const countResult = legacyDb.exec('SELECT COUNT(*) FROM memory_entries');
909
906
  const count = countResult[0]?.values[0]?.[0] || 0;
@@ -976,7 +973,43 @@ async function activateControllerRegistry(dbPath, verbose) {
976
973
  return { activated, failed, initTimeMs: performance.now() - startTime };
977
974
  }
978
975
  /**
979
- * Initialize the memory database properly using sql.js
976
+ * Cross-platform safe unlink with a short EBUSY/EPERM retry loop. Windows
977
+ * holds file handles briefly after process exit (and the consumer's
978
+ * background daemon may still own the moflo.db handle when `memory init
979
+ * --force` runs); on POSIX the first attempt always succeeds. Total wait
980
+ * is bounded at ~750ms so we never paper over a real long-held handle.
981
+ *
982
+ * Used by `initializeMemoryDatabase` to ride out the daemon's brief handle
983
+ * release race after `flo healer --kill-zombies`. See #1098 for the smoke
984
+ * harness incident that drove this — the daemon's WAL+SHM file handles
985
+ * weren't released by Windows for ~200ms after SIGKILL.
986
+ */
987
+ function unlinkWithRetry(filePath) {
988
+ const delays = [0, 50, 100, 100, 100, 100, 100, 100, 50, 50]; // ~750ms total
989
+ let lastErr = null;
990
+ for (let attempt = 0; attempt < delays.length; attempt++) {
991
+ if (delays[attempt] > 0)
992
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delays[attempt]);
993
+ try {
994
+ fs.unlinkSync(filePath);
995
+ return;
996
+ }
997
+ catch (err) {
998
+ lastErr = err;
999
+ const code = err?.code;
1000
+ // Only retry on the handle-release race codes. ENOENT means we've
1001
+ // already won (or the file was never there); EACCES could be a real
1002
+ // permission issue worth surfacing immediately.
1003
+ if (code === 'ENOENT')
1004
+ return;
1005
+ if (code !== 'EBUSY' && code !== 'EPERM')
1006
+ throw err;
1007
+ }
1008
+ }
1009
+ throw lastErr;
1010
+ }
1011
+ /**
1012
+ * Initialize the memory database via the unified node:sqlite factory.
980
1013
  */
981
1014
  export async function initializeMemoryDatabase(options) {
982
1015
  const { backend = 'hybrid', dbPath: customPath, force = false, verbose = false, migrate = true } = options;
@@ -1013,130 +1046,91 @@ export async function initializeMemoryDatabase(options) {
1013
1046
  error: 'Database already exists. Use --force to reinitialize.'
1014
1047
  };
1015
1048
  }
1016
- // Try to use sql.js (WASM SQLite)
1017
- let db;
1018
- let usedSqlJs = false;
1019
- try {
1020
- // Dynamic import of sql.js
1021
- const initSqlJs = (await mofloImport('sql.js')).default;
1022
- const SQL = await initSqlJs();
1023
- // Load existing database or create new
1024
- if (fs.existsSync(dbPath) && force) {
1025
- fs.unlinkSync(dbPath);
1049
+ // Force a clean slate so the new file gets fresh WAL state too.
1050
+ if (fs.existsSync(dbPath) && force) {
1051
+ // Windows EBUSY guard (#1098): the consumer's background daemon
1052
+ // (spawned by session-start during `npm install`) holds an open
1053
+ // file handle on moflo.db. `unlinkSync` would otherwise throw EBUSY
1054
+ // immediately and the OS-level handle release race only resolves
1055
+ // after the daemon process actually exits, so a tight retry loop
1056
+ // can't outwait it. Stop the daemon first (graceful SIGTERM +
1057
+ // 1s grace + SIGKILL via `killBackgroundDaemon`), THEN retry the
1058
+ // unlink to ride out any residual handle-release lag.
1059
+ const projectRoot = path.dirname(path.dirname(dbPath));
1060
+ const { killBackgroundDaemon } = await import('../commands/daemon.js');
1061
+ try {
1062
+ await killBackgroundDaemon(projectRoot);
1026
1063
  }
1027
- db = new SQL.Database();
1028
- usedSqlJs = true;
1029
- }
1030
- catch (e) {
1031
- // sql.js not available, fall back to writing schema file
1032
- if (verbose) {
1033
- console.log('sql.js not available, writing schema file for later initialization');
1064
+ catch { /* best-effort; the retry below still gives us a budget */ }
1065
+ unlinkWithRetry(dbPath);
1066
+ // Also drop any sidecar WAL files so the next open doesn't replay
1067
+ // stale uncommitted transactions from the previous DB.
1068
+ for (const suffix of ['-wal', '-shm']) {
1069
+ const sidecar = dbPath + suffix;
1070
+ if (fs.existsSync(sidecar)) {
1071
+ try {
1072
+ unlinkWithRetry(sidecar);
1073
+ }
1074
+ catch { /* best-effort */ }
1075
+ }
1034
1076
  }
1035
1077
  }
1036
- if (usedSqlJs && db) {
1078
+ const db = openDaemonDatabase(dbPath);
1079
+ try {
1037
1080
  // Execute schema
1038
1081
  db.run(MEMORY_SCHEMA_V3);
1039
1082
  // Insert initial metadata
1040
1083
  db.run(getInitialMetadata(backend));
1041
- // Save to file
1042
- const data = db.export();
1043
- const buffer = Buffer.from(data);
1044
- fs.writeFileSync(dbPath, buffer);
1045
- // Close database
1046
- db.close();
1047
- // Also create schema file for reference
1048
- const schemaPath = path.join(dbDir, 'schema.sql');
1049
- fs.writeFileSync(schemaPath, MEMORY_SCHEMA_V3 + '\n' + getInitialMetadata(backend));
1050
- // ADR-053: Activate ControllerRegistry so controllers (ReasoningBank,
1051
- // SkillLibrary, ExplainableRecall, etc.) are instantiated during init
1052
- const controllerResult = await activateControllerRegistry(dbPath, verbose);
1053
- return {
1054
- success: true,
1055
- backend,
1056
- dbPath,
1057
- schemaVersion: '3.0.0',
1058
- tablesCreated: [
1059
- 'memory_entries',
1060
- 'patterns',
1061
- 'pattern_history',
1062
- 'trajectories',
1063
- 'trajectory_steps',
1064
- 'migration_state',
1065
- 'sessions',
1066
- 'vector_indexes',
1067
- 'metadata'
1068
- ],
1069
- indexesCreated: [
1070
- 'idx_memory_namespace',
1071
- 'idx_memory_key',
1072
- 'idx_memory_type',
1073
- 'idx_memory_status',
1074
- 'idx_memory_created',
1075
- 'idx_memory_accessed',
1076
- 'idx_memory_owner',
1077
- 'idx_patterns_type',
1078
- 'idx_patterns_confidence',
1079
- 'idx_patterns_status',
1080
- 'idx_patterns_last_matched',
1081
- 'idx_pattern_history_pattern',
1082
- 'idx_steps_trajectory'
1083
- ],
1084
- features: {
1085
- vectorEmbeddings: true,
1086
- patternLearning: true,
1087
- temporalDecay: true,
1088
- hnswIndexing: true,
1089
- migrationTracking: true
1090
- },
1091
- controllers: controllerResult,
1092
- };
1093
1084
  }
1094
- else {
1095
- // Fall back to schema file approach
1096
- const schemaPath = path.join(dbDir, 'schema.sql');
1097
- fs.writeFileSync(schemaPath, MEMORY_SCHEMA_V3 + '\n' + getInitialMetadata(backend));
1098
- // Create minimal valid SQLite file
1099
- const sqliteHeader = Buffer.alloc(4096, 0);
1100
- // SQLite format 3 header
1101
- Buffer.from('SQLite format 3\0').copy(sqliteHeader, 0);
1102
- sqliteHeader[16] = 0x10; // page size high byte (4096)
1103
- sqliteHeader[17] = 0x00; // page size low byte
1104
- sqliteHeader[18] = 0x01; // file format write version
1105
- sqliteHeader[19] = 0x01; // file format read version
1106
- sqliteHeader[24] = 0x00; // max embedded payload
1107
- sqliteHeader[25] = 0x40;
1108
- sqliteHeader[26] = 0x20; // min embedded payload
1109
- sqliteHeader[27] = 0x20; // leaf payload
1110
- fs.writeFileSync(dbPath, sqliteHeader);
1111
- // ADR-053: Activate ControllerRegistry even on fallback path
1112
- const controllerResult = await activateControllerRegistry(dbPath, verbose);
1113
- return {
1114
- success: true,
1115
- backend,
1116
- dbPath,
1117
- schemaVersion: '3.0.0',
1118
- tablesCreated: [
1119
- 'memory_entries (pending)',
1120
- 'patterns (pending)',
1121
- 'pattern_history (pending)',
1122
- 'trajectories (pending)',
1123
- 'trajectory_steps (pending)',
1124
- 'migration_state (pending)',
1125
- 'sessions (pending)',
1126
- 'vector_indexes (pending)',
1127
- 'metadata (pending)'
1128
- ],
1129
- indexesCreated: [],
1130
- features: {
1131
- vectorEmbeddings: true,
1132
- patternLearning: true,
1133
- temporalDecay: true,
1134
- hnswIndexing: true,
1135
- migrationTracking: true
1136
- },
1137
- controllers: controllerResult,
1138
- };
1085
+ finally {
1086
+ db.close();
1139
1087
  }
1088
+ // Also create schema file for reference
1089
+ const schemaPath = path.join(dbDir, 'schema.sql');
1090
+ fs.writeFileSync(schemaPath, MEMORY_SCHEMA_V3 + '\n' + getInitialMetadata(backend));
1091
+ // ADR-053: Activate ControllerRegistry so controllers (ReasoningBank,
1092
+ // SkillLibrary, ExplainableRecall, etc.) are instantiated during init
1093
+ const controllerResult = await activateControllerRegistry(dbPath, verbose);
1094
+ return {
1095
+ success: true,
1096
+ backend,
1097
+ dbPath,
1098
+ schemaVersion: '3.0.0',
1099
+ tablesCreated: [
1100
+ 'memory_entries',
1101
+ 'patterns',
1102
+ 'pattern_history',
1103
+ 'trajectories',
1104
+ 'trajectory_steps',
1105
+ 'migration_state',
1106
+ 'sessions',
1107
+ 'vector_indexes',
1108
+ 'metadata'
1109
+ ],
1110
+ indexesCreated: [
1111
+ 'idx_memory_namespace',
1112
+ 'idx_memory_key',
1113
+ 'idx_memory_type',
1114
+ 'idx_memory_status',
1115
+ 'idx_memory_created',
1116
+ 'idx_memory_accessed',
1117
+ 'idx_memory_owner',
1118
+ 'idx_patterns_type',
1119
+ 'idx_patterns_confidence',
1120
+ 'idx_patterns_status',
1121
+ 'idx_patterns_last_matched',
1122
+ 'idx_pattern_history_pattern',
1123
+ 'idx_steps_trajectory'
1124
+ ],
1125
+ features: {
1126
+ vectorEmbeddings: true,
1127
+ patternLearning: true,
1128
+ temporalDecay: true,
1129
+ hnswIndexing: true,
1130
+ migrationTracking: true
1131
+ },
1132
+ controllers: controllerResult,
1133
+ };
1140
1134
  }
1141
1135
  catch (error) {
1142
1136
  return {
@@ -1166,11 +1160,7 @@ export async function checkMemoryInitialization(dbPath) {
1166
1160
  return { initialized: false };
1167
1161
  }
1168
1162
  try {
1169
- // Try to load with sql.js
1170
- const initSqlJs = (await mofloImport('sql.js')).default;
1171
- const SQL = await initSqlJs();
1172
- const fileBuffer = fs.readFileSync(path_);
1173
- const db = new SQL.Database(fileBuffer);
1163
+ const db = openDaemonDatabase(path_);
1174
1164
  // Check for metadata table
1175
1165
  const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table'");
1176
1166
  const tableNames = tables[0]?.values?.map(v => v[0]) || [];
@@ -1211,10 +1201,7 @@ export async function checkMemoryInitialization(dbPath) {
1211
1201
  export async function applyTemporalDecay(dbPath) {
1212
1202
  const path_ = dbPath || memoryDbPath(process.cwd());
1213
1203
  try {
1214
- const initSqlJs = (await mofloImport('sql.js')).default;
1215
- const SQL = await initSqlJs();
1216
- const fileBuffer = fs.readFileSync(path_);
1217
- const db = new SQL.Database(fileBuffer);
1204
+ const db = openDaemonDatabase(path_);
1218
1205
  // Apply decay: confidence *= exp(-decay_rate * days_since_last_use)
1219
1206
  const now = Date.now();
1220
1207
  const decayQuery = `
@@ -1228,7 +1215,6 @@ export async function applyTemporalDecay(dbPath) {
1228
1215
  `;
1229
1216
  db.run(decayQuery, [now, now, now]);
1230
1217
  const changes = db.getRowsModified();
1231
- atomicWriteFileSync(path_, db.export());
1232
1218
  db.close();
1233
1219
  return {
1234
1220
  success: true,
@@ -1395,12 +1381,7 @@ export async function verifyMemoryInit(dbPath, options) {
1395
1381
  const { verbose = false } = options || {};
1396
1382
  const tests = [];
1397
1383
  try {
1398
- const initSqlJs = (await mofloImport('sql.js')).default;
1399
- const SQL = await initSqlJs();
1400
- const fs = await import('fs');
1401
- // Load database
1402
- const fileBuffer = fs.readFileSync(dbPath);
1403
- const db = new SQL.Database(fileBuffer);
1384
+ const db = openDaemonDatabase(dbPath);
1404
1385
  // Test 1: Schema verification
1405
1386
  const schemaStart = Date.now();
1406
1387
  const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table'");
@@ -1528,9 +1509,10 @@ export async function verifyMemoryInit(dbPath, options) {
1528
1509
  duration: Date.now() - indexStart
1529
1510
  });
1530
1511
  }
1531
- // Cleanup test entry
1512
+ // Cleanup test entry — WAL persists this DELETE incrementally; no
1513
+ // export+rewrite needed (the sql.js whole-file dump that lived here
1514
+ // was the multi-writer clobber vector epic #1078 killed).
1532
1515
  db.run(`DELETE FROM memory_entries WHERE id = ?`, [testId]);
1533
- atomicWriteFileSync(dbPath, db.export());
1534
1516
  db.close();
1535
1517
  const passed = tests.filter(t => t.passed).length;
1536
1518
  const failed = tests.filter(t => !t.passed).length;
@@ -1557,8 +1539,8 @@ export async function verifyMemoryInit(dbPath, options) {
1557
1539
  }
1558
1540
  }
1559
1541
  /**
1560
- * Store an entry directly using sql.js
1561
- * This bypasses MCP and writes directly to the database
1542
+ * Store an entry directly via node:sqlite.
1543
+ * This bypasses MCP and writes directly to the database.
1562
1544
  */
1563
1545
  export async function storeEntry(options) {
1564
1546
  // Soft-redirect: `knowledge` is a deprecated alias for `learnings`. Writes
@@ -1575,9 +1557,9 @@ export async function storeEntry(options) {
1575
1557
  }
1576
1558
  // #981 — single-writer routing. When an external daemon is reachable AND
1577
1559
  // we're not the daemon ourselves AND no custom dbPath was supplied, route
1578
- // the write through the daemon's HTTP RPC so its in-memory sql.js handle
1579
- // stays authoritative. Any failure path falls through to the existing
1580
- // bridge / raw-sql.js logic below — byte-identical behaviour to today.
1560
+ // the write through the daemon's HTTP RPC so its in-memory handle stays
1561
+ // authoritative. Any failure path falls through to the existing bridge /
1562
+ // direct-write logic below — byte-identical behaviour to today.
1581
1563
  if (!options.dbPath
1582
1564
  && process.env.MOFLO_IS_DAEMON !== '1'
1583
1565
  && process.env.MOFLO_DISABLE_DAEMON_ROUTING !== '1') {
@@ -1588,9 +1570,19 @@ export async function storeEntry(options) {
1588
1570
  value: options.value,
1589
1571
  tags: options.tags,
1590
1572
  ttl: options.ttl,
1573
+ metadata: options.metadata,
1591
1574
  });
1592
1575
  if (routed.routed && routed.ok) {
1593
- return { success: true, id: routed.id ?? '' };
1576
+ // #1065 surface the daemon's embedding metadata so the MCP
1577
+ // memory_store handler reports `hasEmbedding: true` on
1578
+ // daemon-routed writes (matching the bridge-direct shape).
1579
+ return { success: true, id: routed.id ?? '', embedding: routed.embedding };
1580
+ }
1581
+ // #1101 — daemon validated and rejected (4xx). Bridge-direct would
1582
+ // fail the same way; surface the daemon's error instead of silently
1583
+ // falling back.
1584
+ if (routed.routed && routed.ok === false) {
1585
+ return { success: false, id: '', error: routed.error ?? 'Daemon rejected store request' };
1594
1586
  }
1595
1587
  }
1596
1588
  catch (err) {
@@ -1607,7 +1599,7 @@ export async function storeEntry(options) {
1607
1599
  if (bridgeResult)
1608
1600
  return bridgeResult;
1609
1601
  }
1610
- // Fallback: raw sql.js
1602
+ // Fallback: direct node:sqlite write via the unified factory.
1611
1603
  const { key, value, namespace = 'default', generateEmbeddingFlag = true, tags = [], ttl, dbPath: customPath, upsert = false } = options;
1612
1604
  const dbPath = customPath || memoryDbPath(process.cwd());
1613
1605
  try {
@@ -1616,10 +1608,7 @@ export async function storeEntry(options) {
1616
1608
  }
1617
1609
  // Ensure schema has all required columns (migration for older DBs)
1618
1610
  await ensureSchemaColumns(dbPath);
1619
- const initSqlJs = (await mofloImport('sql.js')).default;
1620
- const SQL = await initSqlJs();
1621
- const fileBuffer = fs.readFileSync(dbPath);
1622
- const db = new SQL.Database(fileBuffer);
1611
+ const db = openDaemonDatabase(dbPath);
1623
1612
  const id = `entry_${Date.now()}_${Math.random().toString(36).substring(7)}`;
1624
1613
  const now = Date.now();
1625
1614
  // generateEmbedding() throws on embed failure; the outer try/catch returns
@@ -1650,7 +1639,7 @@ export async function storeEntry(options) {
1650
1639
  embeddingModel = embResult.model;
1651
1640
  }
1652
1641
  }
1653
- // Idempotency guard. By the time we reach the raw-sql.js fallback, an
1642
+ // Idempotency guard. By the time we reach the direct-write fallback, an
1654
1643
  // earlier write attempt — daemon route via `tryDaemonStore`, or bridge
1655
1644
  // via `bridgeStoreEntry` — may have already persisted this exact row to
1656
1645
  // disk. If a post-persist throw escaped the bridge's inner guards (#994,
@@ -1707,12 +1696,14 @@ export async function storeEntry(options) {
1707
1696
  embeddingDimensions,
1708
1697
  embeddingModel,
1709
1698
  tags.length > 0 ? JSON.stringify(tags) : null,
1710
- '{}',
1699
+ serialiseMetadata(options.metadata),
1711
1700
  now,
1712
1701
  now,
1713
1702
  ttl ? now + (ttl * 1000) : null
1714
1703
  ]);
1715
- atomicWriteFileSync(dbPath, db.export());
1704
+ // node:sqlite + WAL persisted that INSERT on commit — the sql.js
1705
+ // whole-file `db.export()` + atomicWriteFileSync that lived here was
1706
+ // the multi-writer clobber vector epic #1078 killed structurally.
1716
1707
  // Query exact stats while DB is still open. `missing` is the active-rows-
1717
1708
  // with-NULL-embedding count, surfaced via vector-stats.json so the
1718
1709
  // statusline can warn on coverage holes (#648 / #649).
@@ -1775,10 +1766,42 @@ export async function storeEntries(items, dbPath) {
1775
1766
  return out;
1776
1767
  }
1777
1768
  /**
1778
- * Search entries using sql.js with vector similarity
1779
- * Uses HNSW index for 150x faster search when available
1769
+ * Search entries via node:sqlite with vector similarity.
1770
+ * Uses HNSW index for 150x faster search when available.
1780
1771
  */
1781
1772
  export async function searchEntries(options) {
1773
+ // #1058 — read-side routing preamble. When a daemon is reachable AND we're
1774
+ // not the daemon ourselves AND no custom dbPath was supplied, route the
1775
+ // search through the daemon's HTTP RPC so callers see its authoritative,
1776
+ // up-to-the-write state. Without this, a non-daemon process queries its
1777
+ // own bridge's sql.js snapshot loaded at process-start and never sees
1778
+ // anything the daemon has written since (epic #1054 silent-drop).
1779
+ if (!options.dbPath
1780
+ && process.env.MOFLO_IS_DAEMON !== '1'
1781
+ && process.env.MOFLO_DISABLE_DAEMON_ROUTING !== '1') {
1782
+ try {
1783
+ const routed = await tryDaemonSearch({
1784
+ query: options.query,
1785
+ namespace: options.namespace,
1786
+ limit: options.limit,
1787
+ threshold: options.threshold,
1788
+ });
1789
+ if (routed.routed && routed.data) {
1790
+ return {
1791
+ success: true,
1792
+ results: routed.data.results,
1793
+ searchTime: routed.data.searchTime ?? 0,
1794
+ };
1795
+ }
1796
+ // #1101 — daemon rejected query (4xx); propagate instead of falling back.
1797
+ if (routed.routed && routed.error) {
1798
+ return { success: false, results: [], searchTime: 0, error: routed.error };
1799
+ }
1800
+ }
1801
+ catch (err) {
1802
+ logRoutingFault(err);
1803
+ }
1804
+ }
1782
1805
  // ADR-053: Try AgentDB v3 bridge first
1783
1806
  const bridge = await getBridge();
1784
1807
  if (bridge) {
@@ -1786,7 +1809,7 @@ export async function searchEntries(options) {
1786
1809
  if (bridgeResult)
1787
1810
  return bridgeResult;
1788
1811
  }
1789
- // Fallback: raw sql.js
1812
+ // Fallback: direct node:sqlite write via the unified factory.
1790
1813
  const { query, namespace = 'default', limit = 10, threshold = 0.3, dbPath: customPath } = options;
1791
1814
  const dbPath = customPath || memoryDbPath(process.cwd());
1792
1815
  const startTime = Date.now();
@@ -1810,14 +1833,11 @@ export async function searchEntries(options) {
1810
1833
  searchTime: Date.now() - startTime
1811
1834
  };
1812
1835
  }
1813
- // Fall back to brute-force SQLite search
1814
- const initSqlJs = (await mofloImport('sql.js')).default;
1815
- const SQL = await initSqlJs();
1816
- const fileBuffer = fs.readFileSync(dbPath);
1817
- const db = new SQL.Database(fileBuffer);
1836
+ // Fall back to brute-force SQLite search via the unified factory.
1837
+ const db = openDaemonDatabase(dbPath);
1818
1838
  // Get entries with embeddings
1819
1839
  const entries = db.exec(`
1820
- SELECT id, key, namespace, content, embedding
1840
+ SELECT id, key, namespace, content, metadata, embedding
1821
1841
  FROM memory_entries
1822
1842
  WHERE status = 'active'
1823
1843
  ${namespace !== 'all' ? `AND namespace = '${namespace.replace(/'/g, "''")}'` : ''}
@@ -1826,7 +1846,7 @@ export async function searchEntries(options) {
1826
1846
  const results = [];
1827
1847
  if (entries[0]?.values) {
1828
1848
  for (const row of entries[0].values) {
1829
- const [id, key, ns, content, embeddingJson] = row;
1849
+ const [id, key, ns, content, metadataJson, embeddingJson] = row;
1830
1850
  let score = 0;
1831
1851
  if (embeddingJson) {
1832
1852
  try {
@@ -1849,7 +1869,8 @@ export async function searchEntries(options) {
1849
1869
  key: key || id.substring(0, 15),
1850
1870
  content: (content || '').substring(0, 60) + ((content || '').length > 60 ? '...' : ''),
1851
1871
  score,
1852
- namespace: ns || 'default'
1872
+ namespace: ns || 'default',
1873
+ metadata: metadataJson || undefined
1853
1874
  });
1854
1875
  }
1855
1876
  }
@@ -1897,6 +1918,28 @@ function cosineSim(a, b) {
1897
1918
  * List all entries from the memory database
1898
1919
  */
1899
1920
  export async function listEntries(options) {
1921
+ // #1058 — read-side routing preamble (mirrors searchEntries/getEntry).
1922
+ if (!options.dbPath
1923
+ && process.env.MOFLO_IS_DAEMON !== '1'
1924
+ && process.env.MOFLO_DISABLE_DAEMON_ROUTING !== '1') {
1925
+ try {
1926
+ const routed = await tryDaemonList({
1927
+ namespace: options.namespace,
1928
+ limit: options.limit,
1929
+ offset: options.offset,
1930
+ });
1931
+ if (routed.routed && routed.data) {
1932
+ return { success: true, entries: routed.data.entries, total: routed.data.total };
1933
+ }
1934
+ // #1101 — daemon rejected list args (4xx); propagate.
1935
+ if (routed.routed && routed.error) {
1936
+ return { success: false, entries: [], total: 0, error: routed.error };
1937
+ }
1938
+ }
1939
+ catch (err) {
1940
+ logRoutingFault(err);
1941
+ }
1942
+ }
1900
1943
  // ADR-053: Try AgentDB v3 bridge first
1901
1944
  const bridge = await getBridge();
1902
1945
  if (bridge) {
@@ -1904,7 +1947,7 @@ export async function listEntries(options) {
1904
1947
  if (bridgeResult)
1905
1948
  return bridgeResult;
1906
1949
  }
1907
- // Fallback: raw sql.js
1950
+ // Fallback: direct node:sqlite write via the unified factory.
1908
1951
  const { namespace, limit = 20, offset = 0, dbPath: customPath } = options;
1909
1952
  const dbPath = customPath || memoryDbPath(process.cwd());
1910
1953
  try {
@@ -1913,10 +1956,7 @@ export async function listEntries(options) {
1913
1956
  }
1914
1957
  // Ensure schema has all required columns (migration for older DBs)
1915
1958
  await ensureSchemaColumns(dbPath);
1916
- const initSqlJs = (await mofloImport('sql.js')).default;
1917
- const SQL = await initSqlJs();
1918
- const fileBuffer = fs.readFileSync(dbPath);
1919
- const db = new SQL.Database(fileBuffer);
1959
+ const db = openDaemonDatabase(dbPath);
1920
1960
  // Get total count
1921
1961
  const countQuery = namespace
1922
1962
  ? `SELECT COUNT(*) as cnt FROM memory_entries WHERE status = 'active' AND namespace = '${namespace.replace(/'/g, "''")}'`
@@ -1965,6 +2005,27 @@ export async function listEntries(options) {
1965
2005
  * Get a specific entry from the memory database
1966
2006
  */
1967
2007
  export async function getEntry(options) {
2008
+ // #1058 — read-side routing preamble (mirrors searchEntries/listEntries).
2009
+ if (!options.dbPath
2010
+ && process.env.MOFLO_IS_DAEMON !== '1'
2011
+ && process.env.MOFLO_DISABLE_DAEMON_ROUTING !== '1') {
2012
+ try {
2013
+ const routed = await tryDaemonGet({
2014
+ namespace: options.namespace ?? 'default',
2015
+ key: options.key,
2016
+ });
2017
+ if (routed.routed && routed.data) {
2018
+ return { success: true, found: routed.data.found, entry: routed.data.entry };
2019
+ }
2020
+ // #1101 — daemon rejected get args (4xx); propagate.
2021
+ if (routed.routed && routed.error) {
2022
+ return { success: false, found: false, error: routed.error };
2023
+ }
2024
+ }
2025
+ catch (err) {
2026
+ logRoutingFault(err);
2027
+ }
2028
+ }
1968
2029
  // ADR-053: Try AgentDB v3 bridge first
1969
2030
  const bridge = await getBridge();
1970
2031
  if (bridge) {
@@ -1972,7 +2033,7 @@ export async function getEntry(options) {
1972
2033
  if (bridgeResult)
1973
2034
  return bridgeResult;
1974
2035
  }
1975
- // Fallback: raw sql.js
2036
+ // Fallback: direct node:sqlite write via the unified factory.
1976
2037
  const { key, namespace = 'default', dbPath: customPath } = options;
1977
2038
  const dbPath = customPath || memoryDbPath(process.cwd());
1978
2039
  try {
@@ -1981,13 +2042,10 @@ export async function getEntry(options) {
1981
2042
  }
1982
2043
  // Ensure schema has all required columns (migration for older DBs)
1983
2044
  await ensureSchemaColumns(dbPath);
1984
- const initSqlJs = (await mofloImport('sql.js')).default;
1985
- const SQL = await initSqlJs();
1986
- const fileBuffer = fs.readFileSync(dbPath);
1987
- const db = new SQL.Database(fileBuffer);
2045
+ const db = openDaemonDatabase(dbPath);
1988
2046
  // Find entry by key
1989
2047
  const result = db.exec(`
1990
- SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, tags
2048
+ SELECT id, key, namespace, content, embedding, access_count, created_at, updated_at, tags, metadata
1991
2049
  FROM memory_entries
1992
2050
  WHERE status = 'active'
1993
2051
  AND key = '${key.replace(/'/g, "''")}'
@@ -1998,14 +2056,16 @@ export async function getEntry(options) {
1998
2056
  db.close();
1999
2057
  return { success: true, found: false };
2000
2058
  }
2001
- const [id, entryKey, ns, content, embedding, accessCount, createdAt, updatedAt, tagsJson] = result[0].values[0];
2002
- // Update access count
2003
- db.run(`
2004
- UPDATE memory_entries
2005
- SET access_count = access_count + 1, last_accessed_at = strftime('%s', 'now') * 1000
2006
- WHERE id = '${String(id).replace(/'/g, "''")}'
2007
- `);
2008
- atomicWriteFileSync(dbPath, db.export());
2059
+ const [id, entryKey, ns, content, embedding, accessCount, createdAt, updatedAt, tagsJson, metadataJson] = result[0].values[0];
2060
+ // #1058: previously this path issued `UPDATE memory_entries SET access_count = ...`
2061
+ // followed by `atomicWriteFileSync(dbPath, db.export())` — a read that
2062
+ // dumped the entire DB snapshot back to disk just to bump access_count.
2063
+ // Any write by another process between this function's readFileSync and
2064
+ // the writeback was clobbered (read-side writeback-clobber). Access_count
2065
+ // is observability, not correctness — drop the writeback. The caller's
2066
+ // return value reports the in-memory incremented count so existing
2067
+ // surfaces aren't disturbed; persistence of the counter is deferred to a
2068
+ // future controller-table refactor (out of scope).
2009
2069
  db.close();
2010
2070
  let tags = [];
2011
2071
  if (tagsJson) {
@@ -2028,7 +2088,8 @@ export async function getEntry(options) {
2028
2088
  createdAt: createdAt || new Date().toISOString(),
2029
2089
  updatedAt: updatedAt || new Date().toISOString(),
2030
2090
  hasEmbedding: !!embedding && embedding.length > 10,
2031
- tags
2091
+ tags,
2092
+ metadata: metadataJson || undefined
2032
2093
  }
2033
2094
  };
2034
2095
  }
@@ -2047,7 +2108,7 @@ export async function getEntry(options) {
2047
2108
  export async function deleteEntry(options) {
2048
2109
  // #981 — single-writer routing for deletes. Same gates as storeEntry:
2049
2110
  // not the daemon, no custom dbPath, routing not opted out. Failure paths
2050
- // fall through to the existing bridge / raw-sql.js logic below.
2111
+ // fall through to the existing bridge / direct-write logic below.
2051
2112
  if (!options.dbPath
2052
2113
  && process.env.MOFLO_IS_DAEMON !== '1'
2053
2114
  && process.env.MOFLO_DISABLE_DAEMON_ROUTING !== '1') {
@@ -2068,6 +2129,17 @@ export async function deleteEntry(options) {
2068
2129
  remainingEntries: 0,
2069
2130
  };
2070
2131
  }
2132
+ // #1101 — daemon rejected delete args (4xx); propagate.
2133
+ if (routed.routed && routed.ok === false) {
2134
+ return {
2135
+ success: false,
2136
+ deleted: false,
2137
+ key: options.key,
2138
+ namespace: options.namespace ?? 'default',
2139
+ remainingEntries: 0,
2140
+ error: routed.error ?? 'Daemon rejected delete request',
2141
+ };
2142
+ }
2071
2143
  }
2072
2144
  catch (err) {
2073
2145
  logRoutingFault(err);
@@ -2080,7 +2152,7 @@ export async function deleteEntry(options) {
2080
2152
  if (bridgeResult)
2081
2153
  return bridgeResult;
2082
2154
  }
2083
- // Fallback: raw sql.js
2155
+ // Fallback: direct node:sqlite write via the unified factory.
2084
2156
  const { key, namespace = 'default', dbPath: customPath } = options;
2085
2157
  const dbPath = customPath || memoryDbPath(process.cwd());
2086
2158
  try {
@@ -2096,10 +2168,7 @@ export async function deleteEntry(options) {
2096
2168
  }
2097
2169
  // Ensure schema has all required columns (migration for older DBs)
2098
2170
  await ensureSchemaColumns(dbPath);
2099
- const initSqlJs = (await mofloImport('sql.js')).default;
2100
- const SQL = await initSqlJs();
2101
- const fileBuffer = fs.readFileSync(dbPath);
2102
- const db = new SQL.Database(fileBuffer);
2171
+ const db = openDaemonDatabase(dbPath);
2103
2172
  // Check if entry exists first
2104
2173
  const checkResult = db.exec(`
2105
2174
  SELECT id FROM memory_entries
@@ -2129,7 +2198,7 @@ export async function deleteEntry(options) {
2129
2198
  // Get remaining count
2130
2199
  const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE status = 'active'`);
2131
2200
  const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
2132
- atomicWriteFileSync(dbPath, db.export());
2201
+ // WAL persisted the DELETE incrementally — no whole-file dump needed.
2133
2202
  db.close();
2134
2203
  return {
2135
2204
  success: true,
@@ -2160,10 +2229,7 @@ export async function getNamespaceCounts(dbPath) {
2160
2229
  if (!fs.existsSync(resolvedPath)) {
2161
2230
  return { namespaces: {}, total: 0 };
2162
2231
  }
2163
- const initSqlJs = (await mofloImport('sql.js')).default;
2164
- const SQL = await initSqlJs();
2165
- const fileBuffer = fs.readFileSync(resolvedPath);
2166
- const db = new SQL.Database(fileBuffer);
2232
+ const db = openDaemonDatabase(resolvedPath);
2167
2233
  const result = db.exec("SELECT namespace, COUNT(*) as cnt FROM memory_entries WHERE status = 'active' GROUP BY namespace ORDER BY cnt DESC");
2168
2234
  db.close();
2169
2235
  const namespaces = {};