moflo 4.12.11 → 4.13.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 (43) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
  2. package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
  4. package/.claude/skills/fl/phases.md +51 -17
  5. package/.claude/skills/optimize-learnings/SKILL.md +220 -0
  6. package/README.md +95 -1
  7. package/bin/lib/get-backend.mjs +150 -12
  8. package/bin/lib/skill-categories.mjs +1 -0
  9. package/bin/session-start-launcher.mjs +13 -5
  10. package/dist/src/cli/commands/daemon.js +5 -2
  11. package/dist/src/cli/commands/epic.js +5 -1
  12. package/dist/src/cli/commands/hive-mind.js +6 -4
  13. package/dist/src/cli/commands/hooks.js +8 -8
  14. package/dist/src/cli/commands/index.js +5 -0
  15. package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
  16. package/dist/src/cli/commands/memory.js +71 -10
  17. package/dist/src/cli/commands/spell-schedule.js +5 -3
  18. package/dist/src/cli/commands/worktree.js +408 -0
  19. package/dist/src/cli/config/moflo-config.js +57 -0
  20. package/dist/src/cli/index.js +4 -2
  21. package/dist/src/cli/init/executor.js +1 -0
  22. package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
  23. package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
  24. package/dist/src/cli/memory/bridge-entries.js +157 -9
  25. package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
  26. package/dist/src/cli/memory/daemon-backend.js +152 -11
  27. package/dist/src/cli/memory/entries-read.js +47 -2
  28. package/dist/src/cli/memory/entries-write.js +73 -10
  29. package/dist/src/cli/memory/hnsw-singleton.js +112 -9
  30. package/dist/src/cli/memory/learnings-audit.js +420 -0
  31. package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
  32. package/dist/src/cli/memory/learnings-tree.js +187 -0
  33. package/dist/src/cli/memory/memory-bridge.js +37 -27
  34. package/dist/src/cli/memory/tool-call-markup.js +218 -0
  35. package/dist/src/cli/parser.js +7 -3
  36. package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
  37. package/dist/src/cli/services/durable-reconcile.js +161 -0
  38. package/dist/src/cli/services/durable-store-io.js +291 -0
  39. package/dist/src/cli/services/durable-sync.js +159 -24
  40. package/dist/src/cli/services/team-artifact-sync.js +462 -163
  41. package/dist/src/cli/services/worktree-provision.js +400 -0
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
@@ -165,6 +165,150 @@ function wrapStatement(stmt) {
165
165
  * both the daemon adapter and a bin/ writer on the same path only logs once.
166
166
  */
167
167
  const _networkFsWarnedPaths = new Set();
168
+ /**
169
+ * Shared parking buffer for the journal-mode retry sleep. `Atomics.wait` is
170
+ * the only synchronous sleep that works identically on Linux, macOS and
171
+ * Windows without shelling out (Rule #1) — and this open path is synchronous,
172
+ * so there is no `await` to hand the thread back with.
173
+ */
174
+ const WAL_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
175
+ function sleepMs(ms) {
176
+ Atomics.wait(WAL_SLEEP_BUF, 0, 0, ms);
177
+ }
178
+ /**
179
+ * The open-path `busy_timeout`. Named because two places depend on it being
180
+ * the same number: the pragma below sets it, and `readJournalModeBounded`
181
+ * restores it after narrowing it for a probe.
182
+ */
183
+ const OPEN_BUSY_TIMEOUT_MS = 15_000;
184
+ /**
185
+ * Budget for the post-exhaustion probe. The query form of `PRAGMA
186
+ * journal_mode` takes a SHARED lock and IS covered by the busy handler, so it
187
+ * would otherwise inherit the full `OPEN_BUSY_TIMEOUT_MS` — doubling the
188
+ * worst case to ~30s before we report anything on the one path where we have
189
+ * already decided to give up. A few short attempts distinguish "genuinely not
190
+ * WAL" from "probe lost one more race" without reopening that window.
191
+ */
192
+ const WAL_PROBE_BUSY_TIMEOUT_MS = 500;
193
+ const WAL_PROBE_ATTEMPTS = 3;
194
+ /**
195
+ * Total time `setWalWithRetry` will spend losing the conversion race before it
196
+ * gives up. Matched to `busy_timeout` (15000ms) on purpose: the two cover the
197
+ * same worst case — a background indexer holding a write lock through its
198
+ * whole first full-tree pass — and diverging budgets would mean the pragma
199
+ * that needs the wait most gets the shortest one. Being wrong-high costs one
200
+ * slow open in a rare race; being wrong-low kills the process outright.
201
+ */
202
+ const WAL_RETRY_BUDGET_MS = OPEN_BUSY_TIMEOUT_MS;
203
+ /** Backoff bounds: start tight (most races clear in a few ms), cap so a long
204
+ * hold is still polled often enough to return promptly once it releases. */
205
+ const WAL_RETRY_MIN_DELAY_MS = 5;
206
+ const WAL_RETRY_MAX_DELAY_MS = 250;
207
+ /**
208
+ * SQLITE_BUSY (5) and SQLITE_LOCKED (6) — the two contention codes. The
209
+ * message test is a fallback for wrappers that don't propagate `errcode`.
210
+ */
211
+ function isBusyError(err) {
212
+ const e = err;
213
+ if (e?.errcode === 5 || e?.errcode === 6)
214
+ return true;
215
+ return /database( table)? is locked/i.test(String(e?.message ?? ''));
216
+ }
217
+ /** Current journal mode, lowercased. `''` when the probe itself fails. */
218
+ function readJournalMode(db) {
219
+ try {
220
+ const row = db.prepare('PRAGMA journal_mode').get();
221
+ return String(row?.journal_mode ?? '').toLowerCase();
222
+ }
223
+ catch {
224
+ return '';
225
+ }
226
+ }
227
+ /**
228
+ * `readJournalMode` under a deliberately narrow busy budget, restoring the
229
+ * open-path budget afterwards so a caller that survives keeps the connection
230
+ * it asked for. Only ever called once the retry budget is already spent.
231
+ */
232
+ function readJournalModeBounded(db) {
233
+ try {
234
+ try {
235
+ db.exec(`PRAGMA busy_timeout = ${WAL_PROBE_BUSY_TIMEOUT_MS}`);
236
+ }
237
+ catch {
238
+ // Non-fatal: we still probe, just without the narrower budget.
239
+ }
240
+ for (let attempt = 0; attempt < WAL_PROBE_ATTEMPTS; attempt++) {
241
+ const mode = readJournalMode(db);
242
+ if (mode)
243
+ return mode;
244
+ }
245
+ return '';
246
+ }
247
+ finally {
248
+ try {
249
+ db.exec(`PRAGMA busy_timeout = ${OPEN_BUSY_TIMEOUT_MS}`);
250
+ }
251
+ catch {
252
+ // Non-fatal: the handle is still usable, and every path out of here
253
+ // either throws or hands back a database that is already in WAL.
254
+ }
255
+ }
256
+ }
257
+ /**
258
+ * Run `PRAGMA journal_mode = WAL`, retrying on contention (#1471).
259
+ *
260
+ * `busy_timeout` is set first and covers every other statement, but SQLite
261
+ * does **not** invoke the busy handler for a journal-mode change — so the one
262
+ * pragma the budget was put there for never gets it. Concurrent first-opens
263
+ * of a fresh database therefore threw `SQLITE_BUSY` immediately and killed
264
+ * whichever process lost the race: the daemon, the MCP server and a
265
+ * foreground `flo` command starting together is the ordinary consumer
266
+ * configuration, not a test artifact.
267
+ *
268
+ * Note the common path pays nothing: on a database already in WAL the pragma
269
+ * is a no-op that takes no exclusive lock, so the first attempt succeeds with
270
+ * no sleep and the loop below never runs.
271
+ *
272
+ * Twin: `bin/lib/get-backend.mjs:setWalWithRetry`. Must stay in lockstep until
273
+ * Phase 5 (#1084) extracts a shared module.
274
+ *
275
+ * @internal exported for tests — `budgetMs` lets them exercise exhaustion
276
+ * without spending the real 15s.
277
+ */
278
+ export function setWalWithRetry(db, dbPath, budgetMs = WAL_RETRY_BUDGET_MS) {
279
+ let lastErr = null;
280
+ let waited = 0;
281
+ let delay = WAL_RETRY_MIN_DELAY_MS;
282
+ for (;;) {
283
+ try {
284
+ db.exec('PRAGMA journal_mode = WAL');
285
+ return;
286
+ }
287
+ catch (err) {
288
+ lastErr = err;
289
+ // Anything that isn't contention — a corrupt file, a read-only mount —
290
+ // will not clear by waiting. Surface it now rather than after 15s.
291
+ if (!isBusyError(err))
292
+ throw err;
293
+ }
294
+ if (waited >= budgetMs)
295
+ break;
296
+ const nap = Math.min(delay, budgetMs - waited);
297
+ sleepMs(nap);
298
+ waited += nap;
299
+ delay = Math.min(delay * 2, WAL_RETRY_MAX_DELAY_MS);
300
+ }
301
+ // Budget spent. Another opener may have completed the conversion while we
302
+ // were losing races — the database being in WAL is the outcome we wanted,
303
+ // whichever process got it there.
304
+ const mode = readJournalModeBounded(db);
305
+ if (mode === 'wal')
306
+ return;
307
+ throw new Error(`[moflo] PRAGMA journal_mode = WAL stayed busy for ${waited}ms on ${dbPath} ` +
308
+ `(journal_mode is still "${mode || 'unreadable'}"). Another process is holding an ` +
309
+ `exclusive lock on the database. Original error: ` +
310
+ `${String(lastErr?.message ?? lastErr)}`, { cause: lastErr });
311
+ }
168
312
  /**
169
313
  * Read `journal_mode` back after we requested WAL. If the engine returned a
170
314
  * different mode (`delete`, `truncate`, `persist`, `memory`, `off`), the
@@ -179,15 +323,10 @@ const _networkFsWarnedPaths = new Set();
179
323
  function warnIfNotWal(db, dbPath) {
180
324
  if (_networkFsWarnedPaths.has(dbPath))
181
325
  return;
182
- let mode;
183
- try {
184
- const stmt = db.prepare('PRAGMA journal_mode');
185
- const row = stmt.get();
186
- mode = String(row?.journal_mode ?? '').toLowerCase();
187
- }
188
- catch {
189
- return;
190
- }
326
+ // A probe that throws yields '' and falls through the guard below without
327
+ // warning — the WAL pragma above either took effect or didn't, and a failed
328
+ // read is not evidence either way.
329
+ const mode = readJournalMode(db);
191
330
  if (mode && mode !== 'wal') {
192
331
  _networkFsWarnedPaths.add(dbPath);
193
332
  process.stderr.write(`[moflo] WARNING: SQLite journal_mode=${mode} on ${dbPath} (WAL not active). ` +
@@ -290,8 +429,10 @@ export function openDaemonDatabase(dbPath) {
290
429
  // (#1098); 15000ms gives the indexer's full first-pass time to finish
291
430
  // before doctor's probe gives up. The price of being wrong-high here
292
431
  // is one slow probe per session, not lost data.
293
- db.exec('PRAGMA busy_timeout = 15000');
294
- db.exec('PRAGMA journal_mode = WAL');
432
+ db.exec(`PRAGMA busy_timeout = ${OPEN_BUSY_TIMEOUT_MS}`);
433
+ // Not `db.exec` directly: SQLite skips the busy handler for a
434
+ // journal-mode change, so this one pragma needs its own retry (#1471).
435
+ setWalWithRetry(db, dbPath);
295
436
  db.exec('PRAGMA synchronous = NORMAL');
296
437
  // The daemon is the process most exposed to network-FS edge cases
297
438
  // (long-lived MCP server, ~30s of writes per indexer pass). NFS/SMB
@@ -77,9 +77,32 @@ export async function searchEntries(options) {
77
77
  // Generate query embedding
78
78
  const queryEmb = await generateEmbedding(query);
79
79
  const queryEmbedding = queryEmb.embedding;
80
- // Try HNSW search first (approximate-nearest-neighbor)
80
+ // Try HNSW search first (approximate-nearest-neighbor).
81
+ //
82
+ // #1468 — the short-circuit used to be `length > 0`, which made recall
83
+ // non-deterministic. Whenever the index could serve only part of the store —
84
+ // a truncated metadata map, or an index merely stale between rebuilds — a
85
+ // query that found ONE in-index hit suppressed the complete scan below,
86
+ // while a query that found zero got correct and complete results. Both
87
+ // shapes returned `success: true`, so the caller could not tell them apart.
88
+ //
89
+ // The ANN is now trusted only when it FILLED the request. Short of that we
90
+ // fall through and merge, so a partial fast path can add to the answer but
91
+ // never replace it. The test is on the raw count, before the threshold
92
+ // filter: what matters is whether the index could supply the candidates at
93
+ // all, not how similar they turned out to be.
94
+ //
95
+ // A namespace holding fewer than `limit` entries therefore falls through on
96
+ // every query, permanently. That is intended and its cost is proportionate:
97
+ // the scan below filters by the same namespace, so it parses that
98
+ // namespace's handful of embeddings, not the whole cap. The expensive scan
99
+ // is reserved for `all` and for namespaces large enough to fill the request
100
+ // in the first place — which do not reach it. `searchHNSWIndex` widens its
101
+ // retrieval before answering short (#1468), so a shortfall here means the
102
+ // entries genuinely are not in the index rather than that the namespace
103
+ // filter starved a global top-k.
81
104
  const hnswResults = await searchHNSWIndex(queryEmbedding, { k: limit, namespace });
82
- if (hnswResults && hnswResults.length > 0) {
105
+ if (hnswResults && hnswResults.length >= limit) {
83
106
  // Filter by threshold
84
107
  const filtered = hnswResults.filter(r => r.score >= threshold);
85
108
  return {
@@ -135,6 +158,28 @@ export async function searchEntries(options) {
135
158
  }
136
159
  }
137
160
  db.close();
161
+ // #1468 — merge the partial ANN hits in rather than discarding them. The
162
+ // scan is capped at `searchCandidateCap()` and the index is not, so each
163
+ // side can hold something the other misses; keeping only one of them would
164
+ // trade the old non-determinism for a different gap. Same id from both keeps
165
+ // the higher score — the scan's is exact, the ANN's approximate, and
166
+ // `Math.max` picks the exact one whenever they disagree in its favour.
167
+ if (hnswResults) {
168
+ const byId = new Map(results.map(r => [r.id, r]));
169
+ for (const hit of hnswResults) {
170
+ if (hit.score < threshold)
171
+ continue;
172
+ const existing = byId.get(hit.id);
173
+ if (!existing) {
174
+ byId.set(hit.id, hit);
175
+ }
176
+ else if (hit.score > existing.score) {
177
+ existing.score = hit.score;
178
+ }
179
+ }
180
+ results.length = 0;
181
+ results.push(...byId.values());
182
+ }
138
183
  // Sort by score
139
184
  results.sort((a, b) => b.score - a.score);
140
185
  return {
@@ -17,7 +17,7 @@ import { findProjectRoot } from '../services/project-root.js';
17
17
  import { openDaemonDatabase } from './daemon-backend.js';
18
18
  import { ensureSchemaColumns } from './schema.js';
19
19
  import { generateEmbedding } from './embedding-model.js';
20
- import { addToHNSWIndex } from './hnsw-singleton.js';
20
+ import { addToHNSWIndex, removeFromHNSWIndex } from './hnsw-singleton.js';
21
21
  import { getBridge } from './bridge-loader.js';
22
22
  import { tryDaemonStore, tryDaemonDelete } from './daemon-write-client.js';
23
23
  import { EMBEDDING_MODEL_OPT_OUT, getBridgeEmbedder, isEphemeralNamespace } from './bridge-embedder.js';
@@ -25,8 +25,10 @@ import { toFloat32 } from './controllers/_shared.js';
25
25
  import { serialiseMetadata } from './bridge-entries.js';
26
26
  import { logRoutingFault, writeVectorStatsCache } from './entries-shared.js';
27
27
  import { writeThroughDurable } from '../services/durable-sync.js';
28
+ import { archiveDurableRow, isDurableNamespace } from '../services/durable-store-io.js';
28
29
  import { resolveStateRoot } from '../services/project-root.js';
29
30
  import { generateId } from '../shared/utils/id.js';
31
+ import { detectToolCallMarkup, markupCheckDisabled, toolCallMarkupError } from './tool-call-markup.js';
30
32
  /**
31
33
  * Propagate a just-persisted durable write to the shared store (#1232). The
32
34
  * caller passes the project root the row actually landed under so the flush
@@ -37,8 +39,8 @@ import { generateId } from '../shared/utils/id.js';
37
39
  * reaches the shared store. `writeThroughDurable` swallows its own errors and
38
40
  * is a no-op unless `memory.durable_path` is set, so this is safe either way.
39
41
  */
40
- async function propagateDurable(namespace, projectRoot) {
41
- const flush = writeThroughDurable(namespace, { projectRoot });
42
+ async function propagateDurable(namespace, projectRoot, key) {
43
+ const flush = writeThroughDurable(namespace, { projectRoot, key });
42
44
  if (process.env.MOFLO_IS_DAEMON === '1') {
43
45
  void flush;
44
46
  return;
@@ -50,6 +52,31 @@ async function propagateDurable(namespace, projectRoot) {
50
52
  * This bypasses MCP and writes directly to the database.
51
53
  */
52
54
  export async function storeEntry(options) {
55
+ // #1467 — reject a value that carries captured tool-call markup. Every
56
+ // model-authored write lands here: the CLI (`flo memory store`), the MCP tool
57
+ // (`memory_store`), and the daemon's own RPC handler. The check runs before
58
+ // the routing preamble below and before any embedding, so a rejected value
59
+ // never crosses the wire, produces a vector, or reaches disk.
60
+ //
61
+ // `storeEntries` (bulk) is deliberately NOT checked on its bridge path. Its
62
+ // one production caller is the pattern pre-trainer, whose content is derived
63
+ // from code rather than written by a model — so it cannot carry this
64
+ // corruption, and it is exactly where a truncated XML snippet ending on
65
+ // `</value>` would legitimately arrive. See the blind-spot note in
66
+ // tool-call-markup.ts.
67
+ if (!markupCheckDisabled()) {
68
+ const hit = detectToolCallMarkup(options.value);
69
+ if (hit) {
70
+ // Returned, not logged: every caller surfaces `error` in its own idiom
71
+ // (the CLI prints it, MCP returns it), and a console.error here would
72
+ // print the same refusal twice on the path users actually see.
73
+ return {
74
+ success: false,
75
+ id: '',
76
+ error: toolCallMarkupError(hit, options.value, options.namespace ?? 'default', options.key),
77
+ };
78
+ }
79
+ }
53
80
  // Soft-redirect: `knowledge` is a deprecated alias for `learnings`. Writes
54
81
  // are accepted but routed to learnings with provenance tags so future
55
82
  // decay/prune treats user-forced entries as locked. Old consumer DBs that
@@ -135,7 +162,7 @@ export async function storeEntry(options) {
135
162
  // process actually persisted the row). The bridge resolves its DB via
136
163
  // findProjectRoot(), so source the flush from the same root.
137
164
  if (bridgeResult.success)
138
- await propagateDurable(options.namespace ?? 'default', findProjectRoot());
165
+ await propagateDurable(options.namespace ?? 'default', findProjectRoot(), options.key);
139
166
  return bridgeResult;
140
167
  }
141
168
  }
@@ -275,7 +302,7 @@ export async function storeEntry(options) {
275
302
  // fallback. Source the flush from the root the row actually landed under
276
303
  // (`dbPath` = <root>/.moflo/moflo.db) rather than process.cwd(), which can
277
304
  // differ from the project root.
278
- await propagateDurable(namespace, path.dirname(path.dirname(dbPath)));
305
+ await propagateDurable(namespace, path.dirname(path.dirname(dbPath)), key);
279
306
  return {
280
307
  success: true,
281
308
  id,
@@ -328,6 +355,15 @@ export async function deleteEntry(options) {
328
355
  key: options.key,
329
356
  });
330
357
  if (routed.routed && routed.ok) {
358
+ // #1468 — the row left the daemon's DB; drop it from any index THIS
359
+ // process holds too. Usually nothing (a CLI invocation loads no index
360
+ // before routing), but a long-lived client that routes its writes and
361
+ // still searches locally would otherwise keep serving the deleted entry.
362
+ // Gated on `deleted` for the same reason the bridge branch below is: a
363
+ // daemon can report success for a key that was already gone.
364
+ if (routed.deleted ?? true) {
365
+ removeFromHNSWIndex(options.namespace ?? 'default', options.key);
366
+ }
331
367
  return {
332
368
  success: true,
333
369
  deleted: routed.deleted ?? true,
@@ -359,8 +395,13 @@ export async function deleteEntry(options) {
359
395
  const bridge = await getBridge();
360
396
  if (bridge) {
361
397
  const bridgeResult = await bridge.bridgeDeleteEntry(options);
362
- if (bridgeResult)
398
+ if (bridgeResult) {
399
+ // #1468 — see the note on the direct path below.
400
+ if (bridgeResult.deleted) {
401
+ removeFromHNSWIndex(options.namespace ?? 'default', options.key);
402
+ }
363
403
  return bridgeResult;
404
+ }
364
405
  }
365
406
  // Fallback: direct node:sqlite write via the unified factory.
366
407
  const { key, namespace = 'default', dbPath: customPath } = options;
@@ -401,15 +442,37 @@ export async function deleteEntry(options) {
401
442
  error: `Key '${key}' not found in namespace '${namespace}'`
402
443
  };
403
444
  }
404
- // Hard-delete the entry. Soft-delete was retired in story #728: tombstones
405
- // were write-only (no code ever restored from status='deleted') and bloated
406
- // the DB indefinitely.
407
- db.run(`DELETE FROM memory_entries WHERE key = ? AND namespace = ? AND status = 'active'`, [key, namespace]);
445
+ // Durable namespaces ARCHIVE instead of hard-deleting (#1463). A hard
446
+ // delete cannot propagate — it is indistinguishable from a row that never
447
+ // existed — so the entry returned at the next session-start import or
448
+ // worktree seed, and the operator saw a clean local store with no signal
449
+ // that the purge would be undone.
450
+ //
451
+ // This narrows story #728's hard-delete rather than reverting it: #728
452
+ // retired a soft-delete that nothing read and nothing bounded. The archived
453
+ // row is read by every sync direction and by a later re-creation that has
454
+ // to beat its timestamp, and `pruneExpiredArchives` bounds it at the same
455
+ // 90-day window the artifact tombstones use. Non-durable namespaces are
456
+ // unchanged — they are never shared, so a tombstone there would be the
457
+ // write-only kind #728 removed.
458
+ if (isDurableNamespace(namespace)) {
459
+ archiveDurableRow(db, namespace, key, Date.now());
460
+ }
461
+ else {
462
+ db.run(`DELETE FROM memory_entries WHERE key = ? AND namespace = ? AND status = 'active'`, [key, namespace]);
463
+ }
408
464
  // Get remaining count
409
465
  const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE status = 'active'`);
410
466
  const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
411
467
  // WAL persisted the DELETE incrementally — no whole-file dump needed.
412
468
  db.close();
469
+ // #1468 — keep the in-process index in step with the row. The store path
470
+ // has always called `addToHNSWIndex`; the delete path called nothing, so a
471
+ // deleted entry's metadata outlived it in `hnswIndex.entries` and the local
472
+ // HNSW path kept returning it until the process restarted. Archived rows
473
+ // count: the index loads `status = 'active'`, so an archived entry has to
474
+ // leave it exactly as a hard-deleted one does.
475
+ removeFromHNSWIndex(namespace, key);
413
476
  return {
414
477
  success: true,
415
478
  deleted: true,
@@ -20,6 +20,7 @@ import { parseEmbeddingJson } from './controllers/_shared.js';
20
20
  import { memoryDbPath } from '../services/moflo-paths.js';
21
21
  import { openDaemonDatabase } from './daemon-backend.js';
22
22
  import { getBridge, isBridgeLoaded } from './bridge-loader.js';
23
+ import { searchCandidateCap } from './bridge-core.js';
23
24
  import { resolveStateRoot } from '../services/project-root.js';
24
25
  let hnswIndex = null;
25
26
  let hnswInitializing = false;
@@ -65,6 +66,7 @@ export async function getHNSWIndex(options) {
65
66
  return hnsw.search(query.vector, query.k);
66
67
  },
67
68
  len: async () => hnsw.size,
69
+ remove: (id) => hnsw.remove(id),
68
70
  };
69
71
  const entries = new Map();
70
72
  hnswIndex = {
@@ -84,11 +86,17 @@ export async function getHNSWIndex(options) {
84
86
  try {
85
87
  const sqlDb = openDaemonDatabase(dbPath);
86
88
  const cols = sidecarLoaded ? SELECT_METADATA_ONLY : SELECT_WITH_EMBEDDING;
89
+ // #1468 — recency-ordered candidate cap, the same one `entries-read.ts`
90
+ // and `memory-bridge.ts` already use. This load was a bare `LIMIT 10000`,
91
+ // so which rows survived was b-tree order under `idx_memory_status` —
92
+ // arbitrary with respect to both recency and namespace. That is #1201
93
+ // recurring here; the sibling paths got the fix and this loader did not.
87
94
  const result = sqlDb.exec(`
88
95
  SELECT ${cols}
89
96
  FROM memory_entries
90
97
  WHERE status = 'active' AND embedding IS NOT NULL
91
- LIMIT 10000
98
+ ORDER BY created_at DESC
99
+ LIMIT ${searchCandidateCap()}
92
100
  `);
93
101
  let parseSkipped = 0;
94
102
  if (result[0]?.values) {
@@ -116,6 +124,36 @@ export async function getHNSWIndex(options) {
116
124
  if (parseSkipped > 0) {
117
125
  console.warn(`[memory-initializer] skipped ${parseSkipped} rows with malformed embeddings`);
118
126
  }
127
+ // #1468 — drop graph vectors the metadata load did not cover, so the two
128
+ // structures agree.
129
+ //
130
+ // Only the sidecar path can disagree. When the sidecar is absent the loop
131
+ // above inserts each vector alongside its metadata row, so the cap bounds
132
+ // both together. `hnsw-persistence` builds the sidecar over every embedded
133
+ // row with no LIMIT, so a store past the cap pairs a complete graph with a
134
+ // truncated map — and `searchHNSWIndex` silently skips a hit it cannot
135
+ // resolve (`if (!entry) continue`). Those vectors are unreachable by
136
+ // construction: they consume graph space and ANN slots that would
137
+ // otherwise hold a result the caller can actually receive. Rows past the
138
+ // cap stay reachable through the complete brute-force scan in
139
+ // `entries-read.ts`, which now runs whenever the ANN under-fills.
140
+ //
141
+ // Guarded on a non-empty map, which covers the SELECT that succeeds and
142
+ // returns nothing: far more likely a read that went wrong than a store
143
+ // whose rows all vanished, and emptying the graph on that reading is
144
+ // unrecoverable until the next rebuild. A read that *throws* needs no
145
+ // guard — it exits to the catch below and never reaches here, leaving the
146
+ // graph exactly as the sidecar supplied it. Both ways out leave a stale
147
+ // graph rather than an empty one, which is the cheaper mistake.
148
+ if (sidecarLoaded && hnswIndex.entries.size > 0) {
149
+ const orphaned = [];
150
+ for (const id of hnsw.ids()) {
151
+ if (!hnswIndex.entries.has(id))
152
+ orphaned.push(id);
153
+ }
154
+ for (const id of orphaned)
155
+ hnsw.remove(id);
156
+ }
119
157
  sqlDb.close();
120
158
  }
121
159
  catch (err) {
@@ -162,6 +200,47 @@ export async function addToHNSWIndex(id, embedding, entry) {
162
200
  return false;
163
201
  }
164
202
  }
203
+ /**
204
+ * Remove an entry from the in-process HNSW index — graph vector and metadata
205
+ * row both (#1468).
206
+ *
207
+ * `deleteEntry` used to delete the row and stop there. The store path maintains
208
+ * the index (`addToHNSWIndex`); the delete path had no counterpart and there was
209
+ * no removal function to call, even though `HnswLite.remove` has always existed.
210
+ * `entries` is an in-process map rebuilt from SQL, so the consequence was not
211
+ * merely an orphaned vector: the deleted entry's metadata outlived the row and
212
+ * the local HNSW path could keep **returning deleted content** until the process
213
+ * restarted. A curation pass had no way to see that.
214
+ *
215
+ * Addressed by (namespace, key) rather than id because that is what every caller
216
+ * of `deleteEntry` has. The scan is linear, which is fine — deletes are rare and
217
+ * searches are not, so the cost belongs here rather than in a second index.
218
+ *
219
+ * Never forces a load. When this process holds no initialized index there is
220
+ * nothing stale to correct, and the next load reads a DB the row is already gone
221
+ * from. Returns the number of entries removed.
222
+ */
223
+ export function removeFromHNSWIndex(namespace, key) {
224
+ const index = hnswIndex;
225
+ if (!index?.initialized)
226
+ return 0;
227
+ const ids = [];
228
+ for (const [id, entry] of index.entries) {
229
+ if (entry.key === key && entry.namespace === namespace)
230
+ ids.push(id);
231
+ }
232
+ for (const id of ids) {
233
+ index.entries.delete(id);
234
+ try {
235
+ index.db.remove?.(id);
236
+ }
237
+ catch {
238
+ // A graph that refuses the removal leaves an unreachable vector, which is
239
+ // the pre-#1468 state and strictly better than the metadata row surviving.
240
+ }
241
+ }
242
+ return ids.length;
243
+ }
165
244
  /**
166
245
  * Search HNSW index (approximate-nearest-neighbor; scales sub-linearly vs. brute-force)
167
246
  * Returns results sorted by similarity (highest first)
@@ -177,12 +256,10 @@ export async function searchHNSWIndex(queryEmbedding, options) {
177
256
  const index = await getHNSWIndex({ dimensions: queryEmbedding.length });
178
257
  if (!index)
179
258
  return null;
180
- try {
181
- const vector = new Float32Array(queryEmbedding);
182
- const k = options?.k ?? 10;
183
- // HNSW search returns results with cosine distance (lower = more similar)
184
- const results = await index.db.search({ vector, k: k * 2 }); // Get extra for filtering
185
- const filtered = [];
259
+ const k = options?.k ?? 10;
260
+ /** Resolve raw graph hits through the metadata map, applying the namespace filter. */
261
+ const collect = (results) => {
262
+ const hits = [];
186
263
  for (const result of results) {
187
264
  const entry = index.entries.get(result.id);
188
265
  if (!entry)
@@ -194,7 +271,7 @@ export async function searchHNSWIndex(queryEmbedding, options) {
194
271
  // Convert cosine distance to similarity score (1 - distance)
195
272
  // Cosine distance: 0 = identical, 2 = opposite
196
273
  const score = 1 - (result.score / 2);
197
- filtered.push({
274
+ hits.push({
198
275
  id: entry.id.substring(0, 12),
199
276
  key: entry.key || entry.id.substring(0, 15),
200
277
  content: entry.content.substring(0, 60) + (entry.content.length > 60 ? '...' : ''),
@@ -202,9 +279,35 @@ export async function searchHNSWIndex(queryEmbedding, options) {
202
279
  namespace: entry.namespace,
203
280
  metadata: entry.metadata
204
281
  });
205
- if (filtered.length >= k)
282
+ if (hits.length >= k)
206
283
  break;
207
284
  }
285
+ return hits;
286
+ };
287
+ try {
288
+ const vector = new Float32Array(queryEmbedding);
289
+ // HNSW search returns results with cosine distance (lower = more similar)
290
+ let filtered = collect(await index.db.search({ vector, k: k * 2 })); // Get extra for filtering
291
+ // #1468 — widen once when a namespace filter starved the result.
292
+ //
293
+ // The graph is one shared structure and the namespace filter runs AFTER
294
+ // retrieval, so a namespace holding a small share of the store routinely
295
+ // loses most of its `k * 2` candidates to rows in other namespaces and comes
296
+ // back short. That shortfall is not a signal about the store — those entries
297
+ // exist and a full scan would find them — and `searchEntries` reads a short
298
+ // result as "the index could not serve this", which would send every
299
+ // namespaced query on to a SQL scan that JSON.parses thousands of embeddings.
300
+ //
301
+ // `HnswLite.search` brute-forces whenever `k * 2` reaches the graph size, so
302
+ // passing the size guarantees complete coverage. It is a cosine pass over
303
+ // Float32Arrays already resident in memory — far cheaper than the SQL
304
+ // fallback it replaces. Skipped when the first search already covered the
305
+ // whole graph, since a second identical pass would add nothing.
306
+ const graphSize = await index.db.len();
307
+ const namespaced = Boolean(options?.namespace && options.namespace !== 'all');
308
+ if (namespaced && filtered.length < k && graphSize > k * 2) {
309
+ filtered = collect(await index.db.search({ vector, k: graphSize }));
310
+ }
208
311
  // Sort by score descending (highest similarity first)
209
312
  filtered.sort((a, b) => b.score - a.score);
210
313
  return filtered;