moflo 4.9.37 → 4.10.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 (76) hide show
  1. package/.claude/guidance/shipped/moflo-memory-protocol.md +5 -1
  2. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  3. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  4. package/.claude/helpers/statusline.cjs +69 -33
  5. package/.claude/helpers/subagent-bootstrap.json +1 -1
  6. package/.claude/helpers/subagent-start.cjs +1 -1
  7. package/bin/build-embeddings.mjs +6 -20
  8. package/bin/cli.js +5 -0
  9. package/bin/generate-code-map.mjs +4 -24
  10. package/bin/hooks.mjs +3 -12
  11. package/bin/index-all.mjs +3 -13
  12. package/bin/index-guidance.mjs +36 -85
  13. package/bin/index-patterns.mjs +6 -24
  14. package/bin/index-tests.mjs +4 -23
  15. package/bin/lib/db-repair.mjs +358 -62
  16. package/bin/lib/get-backend.mjs +306 -0
  17. package/bin/lib/incremental-write.mjs +27 -7
  18. package/bin/lib/moflo-paths.mjs +64 -4
  19. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  20. package/bin/migrations/knowledge-purge.mjs +7 -8
  21. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  22. package/bin/migrations/purge-doc-entries.mjs +7 -8
  23. package/bin/migrations/strip-context-preambles.mjs +4 -6
  24. package/bin/run-migrations.mjs +1 -10
  25. package/bin/semantic-search.mjs +7 -18
  26. package/bin/session-start-launcher.mjs +144 -108
  27. package/bin/simplify-classify.cjs +38 -17
  28. package/dist/src/cli/commands/daemon.js +38 -11
  29. package/dist/src/cli/commands/doctor-checks-config.js +60 -0
  30. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  31. package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
  32. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  33. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  34. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  35. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  36. package/dist/src/cli/commands/doctor-fixes.js +87 -0
  37. package/dist/src/cli/commands/doctor-registry.js +24 -1
  38. package/dist/src/cli/commands/doctor.js +1 -1
  39. package/dist/src/cli/commands/embeddings.js +17 -22
  40. package/dist/src/cli/commands/memory.js +13 -23
  41. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  42. package/dist/src/cli/init/moflo-init.js +40 -0
  43. package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
  44. package/dist/src/cli/memory/bridge-core.js +256 -30
  45. package/dist/src/cli/memory/bridge-embedder.js +84 -3
  46. package/dist/src/cli/memory/bridge-entries.js +70 -6
  47. package/dist/src/cli/memory/controller-registry.js +7 -2
  48. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  49. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  50. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  51. package/dist/src/cli/memory/daemon-backend.js +400 -0
  52. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  53. package/dist/src/cli/memory/database-provider.js +57 -40
  54. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  55. package/dist/src/cli/memory/index.js +0 -1
  56. package/dist/src/cli/memory/memory-bridge.js +40 -8
  57. package/dist/src/cli/memory/memory-initializer.js +271 -211
  58. package/dist/src/cli/memory/rvf-migration.js +25 -11
  59. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  60. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  61. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  62. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  63. package/dist/src/cli/services/daemon-lock.js +58 -1
  64. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  65. package/dist/src/cli/services/embeddings-migration.js +9 -12
  66. package/dist/src/cli/services/ephemeral-namespace-purge.js +21 -16
  67. package/dist/src/cli/services/learning-service.js +12 -20
  68. package/dist/src/cli/services/memory-db-integrity-repair.js +119 -0
  69. package/dist/src/cli/services/project-root.js +69 -9
  70. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  71. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  72. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  73. package/dist/src/cli/shared/events/event-store.js +26 -55
  74. package/dist/src/cli/version.js +1 -1
  75. package/package.json +2 -4
  76. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -1,22 +1,38 @@
1
1
  /**
2
- * Daemon write client (#981 / #984 — single-writer architecture).
2
+ * Daemon RPC client (#981 / #984 / #1058 — single-writer architecture and
3
+ * its read-side symmetry).
3
4
  *
4
- * HTTP client for the `POST /api/memory/{store,delete,batch}` RPC added by
5
- * Story #983. Lets short-lived CLI processes and the long-lived MCP server
6
- * route their `.moflo/moflo.db` writes through the daemon, which owns the
7
- * authoritative sql.js handle. Avoids the multi-process clobber from #981.
5
+ * HTTP client for the `POST /api/memory/{store,delete,batch,get,search,list}`
6
+ * RPC added by Stories #983 and #1058. Lets short-lived CLI processes and
7
+ * the long-lived MCP server route their `.moflo/moflo.db` operations through
8
+ * the daemon, which owns the authoritative sql.js handle. Avoids the
9
+ * multi-process write clobber from #981 AND the stale read snapshot from
10
+ * #1058 (sql.js never re-reads disk after init).
8
11
  *
9
12
  * Contract — every function in this module:
10
- * - Never throws. Any error path returns `{ routed: false }`.
13
+ * - Never throws. Outcomes are reported via the result envelope.
11
14
  * - Returns within ≤100ms even if the daemon is dead/slow (HTTP timeout).
12
- * - Caches daemon health for 5s to keep the hot write path cheap.
15
+ * - Caches daemon health for 5s to keep the hot path cheap.
13
16
  * - Short-circuits without HTTP when:
14
17
  * (a) `process.env.MOFLO_IS_DAEMON === '1'` (daemon's own process)
15
18
  * (b) `moflo.yaml` has `daemon.auto_start: false`
16
19
  *
17
- * Story #984 ships the client without any consumer wiring — Story #985 / #986
18
- * add the routing preamble inside `storeEntry` / `deleteEntry` (see
19
- * `docs/internal/981-writer-audit.md`).
20
+ * Failure-shape contract (#1101 surface 4xx as a real error):
21
+ * - 4xx response → `routed: true, ok: false, error: <msg>` (writes)
22
+ * `routed: true, error: <msg>` (reads)
23
+ * → caller PROPAGATES the error; does NOT fall back.
24
+ * - 5xx, 503, timeout, → `routed: false`
25
+ * ECONNREFUSED, malformed JSON, → caller falls back to bridge-direct.
26
+ * socket destroyed mid-stream
27
+ *
28
+ * The 4xx codes only fire on daemon-side payload validation (see
29
+ * daemon-memory-rpc.ts). Bridge-direct has the same validation and would
30
+ * fail the same way — falling back silently loses the daemon's actionable
31
+ * error message. 5xx and transport faults are transient/daemon-side bugs;
32
+ * bridge-direct is the right next step.
33
+ *
34
+ * Naming note: the module is named `daemon-write-client` for compat with
35
+ * existing importers, but as of #1058 it also covers reads.
20
36
  *
21
37
  * @module cli/memory/daemon-write-client
22
38
  */
@@ -136,6 +152,7 @@ export async function tryDaemonStore(opts) {
136
152
  value: opts.value,
137
153
  tags: opts.tags,
138
154
  ttl: opts.ttl,
155
+ metadata: opts.metadata,
139
156
  });
140
157
  }
141
158
  /**
@@ -150,9 +167,90 @@ export async function tryDaemonDelete(opts) {
150
167
  key: opts.key,
151
168
  });
152
169
  }
170
+ /**
171
+ * Route a single-entry retrieve through the daemon (#1058). Returns
172
+ * `{ routed: false }` if the daemon is unavailable; otherwise
173
+ * `{ routed: true, data: { found, entry? } }`. The `entry` field is the
174
+ * same shape as `getEntry`'s in-process return.
175
+ */
176
+ export async function tryDaemonGet(opts) {
177
+ if (!(await isDaemonAvailable()))
178
+ return { routed: false };
179
+ return postReadJson('/api/memory/get', { namespace: opts.namespace, key: opts.key }, (data) => ({
180
+ found: !!data?.found,
181
+ entry: data?.entry,
182
+ }));
183
+ }
184
+ /**
185
+ * Route a semantic search through the daemon (#1058).
186
+ */
187
+ export async function tryDaemonSearch(opts) {
188
+ if (!(await isDaemonAvailable()))
189
+ return { routed: false };
190
+ return postReadJson('/api/memory/search', {
191
+ query: opts.query,
192
+ namespace: opts.namespace,
193
+ limit: opts.limit,
194
+ threshold: opts.threshold,
195
+ }, (data) => ({
196
+ results: Array.isArray(data?.results) ? data.results : [],
197
+ searchTime: typeof data?.searchTime === 'number' ? data.searchTime : undefined,
198
+ }));
199
+ }
200
+ /**
201
+ * Route a paginated list through the daemon (#1058).
202
+ */
203
+ export async function tryDaemonList(opts) {
204
+ if (!(await isDaemonAvailable()))
205
+ return { routed: false };
206
+ return postReadJson('/api/memory/list', {
207
+ namespace: opts.namespace,
208
+ limit: opts.limit,
209
+ offset: opts.offset,
210
+ }, (data) => ({
211
+ entries: Array.isArray(data?.entries) ? data.entries : [],
212
+ total: typeof data?.total === 'number' ? data.total : 0,
213
+ }));
214
+ }
153
215
  // ============================================================================
154
216
  // Internal HTTP poster — never throws, bounded timeout
155
217
  // ============================================================================
218
+ /**
219
+ * Extract a human-readable error message from a daemon 4xx response body.
220
+ * Prefers `message` (the daemon's specific reason — e.g. "invalid namespace"),
221
+ * falls back to `error` (the daemon's error category), then to a generic
222
+ * status-code string when the body is non-JSON.
223
+ */
224
+ function parse4xxError(buf, status) {
225
+ try {
226
+ const data = JSON.parse(buf);
227
+ const detail = typeof data?.message === 'string' ? data.message
228
+ : typeof data?.error === 'string' ? data.error
229
+ : undefined;
230
+ if (detail)
231
+ return detail;
232
+ }
233
+ catch {
234
+ // Non-JSON 4xx body — fall through to the generic message.
235
+ }
236
+ return `daemon returned ${status}`;
237
+ }
238
+ /**
239
+ * Narrow a parsed JSON value to the `{ dimensions, model }` embedding-response
240
+ * shape (#1065). Returns `undefined` when the field is missing or malformed —
241
+ * a malformed field is treated as "no embedding info" rather than failing the
242
+ * whole response, so an older daemon that hasn't been updated still works.
243
+ */
244
+ function parseEmbeddingField(value) {
245
+ if (!value || typeof value !== 'object')
246
+ return undefined;
247
+ const v = value;
248
+ if (typeof v.dimensions !== 'number' || !Number.isFinite(v.dimensions))
249
+ return undefined;
250
+ if (typeof v.model !== 'string' || v.model.length === 0)
251
+ return undefined;
252
+ return { dimensions: v.dimensions, model: v.model };
253
+ }
156
254
  function postJson(path, body) {
157
255
  return new Promise((resolve) => {
158
256
  let done = false;
@@ -183,15 +281,23 @@ function postJson(path, body) {
183
281
  res.setEncoding('utf8');
184
282
  res.on('data', (chunk) => { buf += chunk; });
185
283
  res.on('end', () => {
186
- // Status >=500 is a daemon-side fault; treat as unrouted so the
187
- // caller falls back. Status 4xx (validation) is ALSO unrouted —
188
- // we don't want a malformed payload silently lost just because
189
- // the HTTP delivery succeeded.
284
+ // #1101 per-shape failure contract:
285
+ // 2xx → routed:true, ok:true (caller uses data)
286
+ // 4xx → routed:true, ok:false (caller propagates daemon error)
287
+ // 5xx → routed:false (caller falls back to bridge)
288
+ // parse fail → routed:false (fall back)
190
289
  const status = res.statusCode ?? 0;
191
- if (status < 200 || status >= 300) {
290
+ if (status >= 500 || status < 200) {
192
291
  finish({ routed: false });
193
292
  return;
194
293
  }
294
+ if (status >= 400) {
295
+ // Daemon validated the payload and rejected it. Bridge-direct
296
+ // has the same validation; falling back loses the actionable
297
+ // error. Surface it to the caller instead.
298
+ finish({ routed: true, ok: false, error: parse4xxError(buf, status) });
299
+ return;
300
+ }
195
301
  try {
196
302
  const data = JSON.parse(buf);
197
303
  finish({
@@ -199,6 +305,7 @@ function postJson(path, body) {
199
305
  ok: !!data?.ok,
200
306
  id: typeof data?.id === 'string' ? data.id : undefined,
201
307
  deleted: typeof data?.deleted === 'boolean' ? data.deleted : undefined,
308
+ embedding: parseEmbeddingField(data?.embedding),
202
309
  error: typeof data?.error === 'string' ? data.error : undefined,
203
310
  });
204
311
  }
@@ -214,4 +321,74 @@ function postJson(path, body) {
214
321
  req.end();
215
322
  });
216
323
  }
324
+ /**
325
+ * Generic JSON POST that returns a daemon-read envelope. Same transport
326
+ * guarantees as `postJson`: never throws, bounded timeout, invalidates health
327
+ * cache on routed-failure.
328
+ *
329
+ * The `shape` callback maps the daemon's parsed JSON payload to the typed
330
+ * data shape the caller expects. Returning `null` from `shape` (or a parse
331
+ * failure) downgrades to `{ routed: false }` so the caller falls back.
332
+ */
333
+ function postReadJson(path, body, shape) {
334
+ return new Promise((resolve) => {
335
+ let done = false;
336
+ const finish = (result) => {
337
+ if (done)
338
+ return;
339
+ done = true;
340
+ if (result.routed === false)
341
+ healthCache = null;
342
+ resolve(result);
343
+ };
344
+ const payload = JSON.stringify(body);
345
+ const req = http.request({
346
+ host: '127.0.0.1',
347
+ port: getDaemonPort(),
348
+ path,
349
+ method: 'POST',
350
+ timeout: DAEMON_HTTP_TIMEOUT_MS,
351
+ headers: {
352
+ 'Content-Type': 'application/json',
353
+ 'Content-Length': Buffer.byteLength(payload),
354
+ },
355
+ }, (res) => {
356
+ let buf = '';
357
+ res.setEncoding('utf8');
358
+ res.on('data', (chunk) => { buf += chunk; });
359
+ res.on('end', () => {
360
+ // #1101 — mirror postJson contract for reads:
361
+ // 2xx → routed:true with shaped data
362
+ // 4xx → routed:true with error (no data) — caller propagates
363
+ // 5xx → routed:false (caller falls back)
364
+ const status = res.statusCode ?? 0;
365
+ if (status >= 500 || status < 200) {
366
+ finish({ routed: false });
367
+ return;
368
+ }
369
+ if (status >= 400) {
370
+ finish({ routed: true, error: parse4xxError(buf, status) });
371
+ return;
372
+ }
373
+ try {
374
+ const parsed = JSON.parse(buf);
375
+ const shaped = shape(parsed);
376
+ if (shaped === null) {
377
+ finish({ routed: false });
378
+ return;
379
+ }
380
+ finish({ routed: true, data: shaped });
381
+ }
382
+ catch {
383
+ finish({ routed: false });
384
+ }
385
+ });
386
+ res.on('error', () => finish({ routed: false }));
387
+ });
388
+ req.on('error', () => finish({ routed: false }));
389
+ req.on('timeout', () => { req.destroy(); finish({ routed: false }); });
390
+ req.write(payload);
391
+ req.end();
392
+ });
393
+ }
217
394
  //# sourceMappingURL=daemon-write-client.js.map
@@ -1,16 +1,16 @@
1
1
  /**
2
- * DatabaseProvider - Platform-aware database selection
2
+ * DatabaseProvider moflo's IMemoryBackend factory.
3
3
  *
4
- * Automatically selects best backend:
5
- * - All platforms: sql.js (WASM, no native deps)
6
- * - Windows: sql.js (WASM, universal) when native fails
7
- * - Fallback: JSON file storage
4
+ * Phase 5 (#1084) removed the sql.js backend. Selection now collapses to:
5
+ * - `node-sqlite` (Node 22+ built-in, the only SQLite backend)
6
+ * - `rvf` (pure-TS fallback when node:sqlite is somehow unavailable)
7
+ * - `json` (last-resort file storage when nothing else works)
8
8
  *
9
9
  * @module v3/memory/database-provider
10
10
  */
11
11
  import { platform } from 'node:os';
12
12
  import { existsSync } from 'node:fs';
13
- import { SqlJsBackend } from './sqljs-backend.js';
13
+ import { SqliteBackend } from './sqlite-backend.js';
14
14
  /**
15
15
  * Detect platform and recommend provider
16
16
  */
@@ -19,8 +19,9 @@ function detectPlatform() {
19
19
  const isWindows = os === 'win32';
20
20
  const isMacOS = os === 'darwin';
21
21
  const isLinux = os === 'linux';
22
- // Recommend better-sqlite3 for Unix-like systems, sql.js for Windows
23
- const recommendedProvider = 'sql.js';
22
+ // Phase 4 (#1083) flipped the default to node:sqlite (built into Node 22+,
23
+ // moflo's minimum). Phase 5 (#1084) deletes the sql.js backend + dep.
24
+ const recommendedProvider = 'node-sqlite';
24
25
  return {
25
26
  os,
26
27
  isWindows,
@@ -35,55 +36,60 @@ function detectPlatform() {
35
36
  async function testRvf() {
36
37
  return true;
37
38
  }
38
- /** better-sqlite3 removed — sql.js is the only SQLite backend */
39
39
  /**
40
- * Test if sql.js is available and working
40
+ * Test if the built-in node:sqlite engine is available (Node 22+).
41
+ *
42
+ * Loads the suppress-sqlite-warning side-effect BEFORE the probe import so
43
+ * the once-per-process ExperimentalWarning never fires (#1098). A probe
44
+ * that prints the warning to stderr defeats every consumer's "clean
45
+ * startup" expectation even when the rest of the run is healthy.
41
46
  */
42
- async function testSqlJs() {
47
+ async function testNodeSqlite() {
43
48
  try {
44
- const { initSqlJsForNode } = await import('./sqljs-backend.js');
45
- const SQL = await initSqlJsForNode();
46
- const testDb = new SQL.Database();
47
- testDb.close();
49
+ await import('./suppress-sqlite-warning.js');
50
+ await import('node:sqlite');
48
51
  return true;
49
52
  }
50
- catch (error) {
53
+ catch {
51
54
  return false;
52
55
  }
53
56
  }
54
57
  /**
55
- * Select best available provider
58
+ * Select best available provider.
59
+ *
60
+ * Phase 5 (#1084) collapsed the chain — sql.js is gone, so the order is
61
+ * just: explicit override → node:sqlite → RVF → JSON. Passing `'sql.js'`
62
+ * explicitly is a hard error.
56
63
  */
57
64
  async function selectProvider(preferred, verbose = false) {
65
+ if (preferred === 'sql.js') {
66
+ throw new Error(`DatabaseProvider: sql.js was removed in Phase 5 (#1084). ` +
67
+ `Use 'node-sqlite' (the new default) or omit the provider entirely.`);
68
+ }
58
69
  if (preferred && preferred !== 'auto') {
59
70
  if (verbose) {
60
71
  console.log(`[DatabaseProvider] Using explicitly specified provider: ${preferred}`);
61
72
  }
62
73
  return preferred;
63
74
  }
64
- const platformInfo = detectPlatform();
75
+ if (await testNodeSqlite()) {
76
+ if (verbose) {
77
+ console.log('[DatabaseProvider] node:sqlite available — using new default');
78
+ }
79
+ return 'node-sqlite';
80
+ }
81
+ // node:sqlite missing is the "broken install" signal — surface it whenever
82
+ // verbose is on so the fallback chain doesn't silently regress consumers
83
+ // to a slower backend without anyone noticing.
65
84
  if (verbose) {
66
- console.log(`[DatabaseProvider] Platform detected: ${platformInfo.os}`);
67
- console.log(`[DatabaseProvider] Recommended provider: ${platformInfo.recommendedProvider}`);
85
+ console.warn('[DatabaseProvider] node:sqlite unavailable — check Node version (22+ required); falling back to RVF');
68
86
  }
69
- // Try RVF first (always available via pure-TS fallback)
70
87
  if (await testRvf()) {
71
- if (verbose) {
72
- console.log('[DatabaseProvider] RVF backend available');
73
- }
74
88
  return 'rvf';
75
89
  }
76
- // Try sql.js (moflo: sql.js is the only SQLite backend)
77
- if (await testSqlJs()) {
78
- if (verbose) {
79
- console.log('[DatabaseProvider] sql.js available and working');
80
- }
81
- return 'sql.js';
82
- }
83
- else if (verbose) {
84
- console.log('[DatabaseProvider] sql.js not available, using JSON fallback');
90
+ if (verbose) {
91
+ console.warn('[DatabaseProvider] node:sqlite + RVF unavailable — falling back to JSON');
85
92
  }
86
- // Final fallback to JSON
87
93
  return 'json';
88
94
  }
89
95
  /**
@@ -112,7 +118,7 @@ async function selectProvider(preferred, verbose = false) {
112
118
  * ```
113
119
  */
114
120
  export async function createDatabase(path, options = {}) {
115
- const { provider = 'auto', verbose = false, walMode = true, optimize = true, defaultNamespace = 'default', maxEntries = 1000000, autoPersistInterval = 5000, wasmPath, } = options;
121
+ const { provider = 'auto', verbose = false, walMode: _walMode = true, optimize = true, defaultNamespace = 'default', maxEntries = 1000000, autoPersistInterval = 5000, wasmPath: _wasmPath, } = options;
116
122
  // Select provider
117
123
  const selectedProvider = await selectProvider(provider, verbose);
118
124
  if (verbose) {
@@ -122,6 +128,13 @@ export async function createDatabase(path, options = {}) {
122
128
  let backend;
123
129
  switch (selectedProvider) {
124
130
  case 'sql.js': {
131
+ // selectProvider() guards against 'sql.js' as an explicit preference,
132
+ // but a stale caller could still land here if `auto` resolution drifted
133
+ // (it can't — left only for exhaustive-check safety).
134
+ throw new Error(`DatabaseProvider: sql.js was removed in Phase 5 (#1084). ` +
135
+ `This case is unreachable; if you see this error, file a bug.`);
136
+ }
137
+ case 'node-sqlite': {
125
138
  const config = {
126
139
  databasePath: path,
127
140
  optimize,
@@ -129,9 +142,8 @@ export async function createDatabase(path, options = {}) {
129
142
  maxEntries,
130
143
  verbose,
131
144
  autoPersistInterval,
132
- wasmPath,
133
145
  };
134
- backend = new SqlJsBackend(config);
146
+ backend = new SqliteBackend(config);
135
147
  break;
136
148
  }
137
149
  case 'rvf': {
@@ -167,13 +179,18 @@ export function getPlatformInfo() {
167
179
  return detectPlatform();
168
180
  }
169
181
  /**
170
- * Check which providers are available
182
+ * Check which providers are available.
183
+ *
184
+ * `sqlJs` / `betterSqlite3` are retained for API stability but always
185
+ * report `false` — Phase 5 (#1084) deleted the sql.js backend and
186
+ * better-sqlite3 was never wired.
171
187
  */
172
188
  export async function getAvailableProviders() {
173
189
  return {
174
190
  rvf: true,
175
- betterSqlite3: false, // Removed — sql.js is the only SQLite backend
176
- sqlJs: await testSqlJs(),
191
+ betterSqlite3: false,
192
+ sqlJs: false,
193
+ nodeSqlite: await testNodeSqlite(),
177
194
  json: true,
178
195
  };
179
196
  }
@@ -16,11 +16,11 @@
16
16
  */
17
17
  import * as fs from 'node:fs';
18
18
  import * as path from 'node:path';
19
- import { mofloImport } from '../services/moflo-require.js';
20
19
  import { atomicWriteFileSync } from '../services/atomic-file-write.js';
21
20
  import { HnswLite } from './hnsw-lite.js';
22
21
  import { parseEmbeddingJson } from './controllers/_shared.js';
23
22
  import { hnswIndexPath } from '../services/moflo-paths.js';
23
+ import { openDaemonDatabase } from './daemon-backend.js';
24
24
  /**
25
25
  * Build an HnswLite from every active row in `dbPath` that has an embedding,
26
26
  * then atomically write the sidecar to `<projectRoot>/.moflo/hnsw.index`.
@@ -37,13 +37,11 @@ export async function buildAndWriteHnswSidecar(dbPath, projectRoot, options = {}
37
37
  if (!fs.existsSync(dbPath)) {
38
38
  throw new Error(`buildAndWriteHnswSidecar: db not found at ${dbPath}`);
39
39
  }
40
- const sqlJsModule = await mofloImport('sql.js');
41
- if (!sqlJsModule) {
42
- throw new Error(`buildAndWriteHnswSidecar: sql.js not available`);
43
- }
44
- const SQL = await sqlJsModule.default();
45
- const buf = fs.readFileSync(dbPath);
46
- const db = new SQL.Database(buf);
40
+ // node:sqlite via the unified factory — Phase 5 (#1084) replaced the
41
+ // sql.js readFileSync + new SQL.Database round-trip with a direct open
42
+ // through openDaemonDatabase. WAL writes incrementally so there's nothing
43
+ // to flush back here; the sidecar persistence below is unaffected.
44
+ const db = openDaemonDatabase(dbPath);
47
45
  const hnsw = new HnswLite(dimensions, m, efConstruction, metric);
48
46
  let skipped = 0;
49
47
  try {
@@ -61,7 +61,6 @@ export { ControllerRegistry, INIT_LEVELS } from './controller-registry.js';
61
61
  export { CONTROLLER_SPECS } from './controller-specs.js';
62
62
  // ===== Core Components =====
63
63
  export { MofloDbAdapter } from './moflo-db-adapter.js';
64
- export { SqlJsBackend } from './sqljs-backend.js';
65
64
  export { RvfBackend } from './rvf-backend.js';
66
65
  export { HnswLite, cosineSimilarity } from './hnsw-lite.js';
67
66
  export { buildAndWriteHnswSidecar, tryLoadHnswSidecar } from './hnsw-persistence.js';
@@ -119,17 +119,49 @@ export async function bridgeAddToHNSW(id, embedding, entry, dbPath) {
119
119
  // Bridge-produced vectors come from getBridgeEmbedder() (fastembed, 384-dim).
120
120
  // Pre-#648 this column was hardcoded to 'Xenova/all-MiniLM-L6-v2' which
121
121
  // misrepresented the producing model — fixed to the canonical bridge label.
122
- ctx.db.prepare(`
123
- INSERT OR REPLACE INTO memory_entries (
124
- id, key, namespace, content, type,
125
- embedding, embedding_dimensions, embedding_model,
126
- created_at, updated_at, status
127
- ) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, 'active')
122
+ //
123
+ // Pre-#1067: this issued `INSERT OR REPLACE INTO memory_entries (...)` with
124
+ // only the embedding-related columns. When `storeEntry`'s direct-write
125
+ // fallback first inserted a row carrying `metadata`/`tags`/`expires_at`
126
+ // and then called `addToHNSWIndex(...)` for the same id, REPLACE clobbered
127
+ // the row wiping the metadata column to NULL and re-introducing the
128
+ // "first chunk has no navigation" smoke failure even though serialiseMetadata
129
+ // produced the right JSON one statement earlier. UPDATE-by-id preserves the
130
+ // pre-existing row's metadata/tags/expires_at/access_count while still
131
+ // back-filling embedding columns for callers that supplied them.
132
+ const updated = ctx.db.prepare(`
133
+ UPDATE memory_entries
134
+ SET embedding = ?,
135
+ embedding_dimensions = ?,
136
+ embedding_model = ?,
137
+ updated_at = ?
138
+ WHERE id = ?
128
139
  `).run([
129
- id, entry.key, entry.namespace, entry.content,
130
140
  embeddingJson, embedding.length, BRIDGE_EMBEDDING_MODEL,
131
- now, now,
141
+ now, id,
132
142
  ]);
143
+ // If no row was matched by id (rare: a rebuild path passing an embedding
144
+ // for a key that never went through storeEntry first) fall back to an
145
+ // INSERT carrying every column the bridge can populate, so the row still
146
+ // becomes searchable. Metadata/tags/expires_at stay NULL because the
147
+ // caller didn't supply them — same shape `bridgeStoreEntry` would write
148
+ // for a no-metadata caller.
149
+ const rowCount = typeof updated.changes === 'number'
150
+ ? updated.changes
151
+ : (ctx.db.getRowsModified?.() ?? 0);
152
+ if (rowCount === 0) {
153
+ ctx.db.prepare(`
154
+ INSERT INTO memory_entries (
155
+ id, key, namespace, content, type,
156
+ embedding, embedding_dimensions, embedding_model,
157
+ created_at, updated_at, status
158
+ ) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, 'active')
159
+ `).run([
160
+ id, entry.key, entry.namespace, entry.content,
161
+ embeddingJson, embedding.length, BRIDGE_EMBEDDING_MODEL,
162
+ now, now,
163
+ ]);
164
+ }
133
165
  persistBridgeDb(ctx.db, dbPath);
134
166
  return true;
135
167
  });