moflo 4.9.37 → 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 (73) 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 +4 -25
  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 +102 -102
  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-coverage-truth.js +136 -0
  30. package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
  31. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  32. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  33. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  34. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  35. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  36. package/dist/src/cli/commands/doctor-registry.js +14 -0
  37. package/dist/src/cli/commands/doctor.js +1 -1
  38. package/dist/src/cli/commands/embeddings.js +17 -22
  39. package/dist/src/cli/commands/memory.js +13 -23
  40. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  41. package/dist/src/cli/init/moflo-init.js +40 -0
  42. package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
  43. package/dist/src/cli/memory/bridge-core.js +256 -30
  44. package/dist/src/cli/memory/bridge-entries.js +70 -6
  45. package/dist/src/cli/memory/controller-registry.js +7 -2
  46. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  47. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  48. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  49. package/dist/src/cli/memory/daemon-backend.js +400 -0
  50. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  51. package/dist/src/cli/memory/database-provider.js +57 -40
  52. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  53. package/dist/src/cli/memory/index.js +0 -1
  54. package/dist/src/cli/memory/memory-bridge.js +40 -8
  55. package/dist/src/cli/memory/memory-initializer.js +269 -209
  56. package/dist/src/cli/memory/rvf-migration.js +25 -11
  57. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  58. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  59. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  60. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  61. package/dist/src/cli/services/daemon-lock.js +58 -1
  62. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  63. package/dist/src/cli/services/embeddings-migration.js +9 -12
  64. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  65. package/dist/src/cli/services/learning-service.js +12 -20
  66. package/dist/src/cli/services/project-root.js +69 -9
  67. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  68. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  69. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  70. package/dist/src/cli/shared/events/event-store.js +26 -55
  71. package/dist/src/cli/version.js +1 -1
  72. package/package.json +2 -4
  73. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -45,34 +45,55 @@ const SECURITY_PATHS = [
45
45
  /(?:^|[\\\/])\.claude[\\\/]helpers[\\\/]gate/i,
46
46
  ];
47
47
 
48
- function safeExec(cmd) {
49
- try { return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); }
50
- catch { return ''; }
48
+ function safeExec(cmd, opts) {
49
+ try {
50
+ return execSync(cmd, {
51
+ encoding: 'utf-8',
52
+ stdio: ['pipe', 'pipe', 'pipe'],
53
+ ...(opts && opts.cwd ? { cwd: opts.cwd } : {}),
54
+ });
55
+ } catch { return ''; }
51
56
  }
52
57
 
53
58
  // Detect the consumer's default branch. Hardcoding 'main' silently miscalibrates
54
59
  // classification on repos that use 'master', 'develop', etc. — empty diff →
55
60
  // TRIVIAL → gate stamps clean without any real review.
56
61
  let _cachedDefaultBranch = null;
57
- function detectDefaultBranch() {
58
- if (_cachedDefaultBranch !== null) return _cachedDefaultBranch;
62
+ function detectDefaultBranch(cwd) {
63
+ // Cache by cwd so tests probing multiple repos in-process don't return a
64
+ // single stale value; CLI use passes no cwd and benefits from the cache.
65
+ if (cwd === undefined && _cachedDefaultBranch !== null) return _cachedDefaultBranch;
66
+ const opts = cwd ? { cwd } : undefined;
59
67
 
60
68
  // Preferred: origin/HEAD points to whatever the remote considers default.
61
- const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD').trim();
62
- if (symbolic.startsWith('origin/')) return (_cachedDefaultBranch = symbolic.slice('origin/'.length));
69
+ const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD', opts).trim();
70
+ if (symbolic.startsWith('origin/')) {
71
+ const v = symbolic.slice('origin/'.length);
72
+ if (cwd === undefined) _cachedDefaultBranch = v;
73
+ return v;
74
+ }
63
75
 
64
76
  // Fallback: local init.defaultBranch (set by `git init -b <name>` or config).
65
- const configured = safeExec('git config --get init.defaultBranch').trim();
66
- if (configured) return (_cachedDefaultBranch = configured);
77
+ const configured = safeExec('git config --get init.defaultBranch', opts).trim();
78
+ if (configured) {
79
+ if (cwd === undefined) _cachedDefaultBranch = configured;
80
+ return configured;
81
+ }
67
82
 
68
83
  // Last resort: 'main' (most common modern default).
69
- return (_cachedDefaultBranch = 'main');
84
+ if (cwd === undefined) _cachedDefaultBranch = 'main';
85
+ return 'main';
86
+ }
87
+
88
+ function _resetCacheForTest() {
89
+ _cachedDefaultBranch = null;
70
90
  }
71
91
 
72
- function readDiffFromGit(base) {
92
+ function readDiffFromGit(base, cwd) {
93
+ const opts = cwd ? { cwd } : undefined;
73
94
  // Combined diff: committed-since-base + working-tree
74
- const committed = safeExec(`git diff ${base}...HEAD`);
75
- const working = safeExec('git diff HEAD');
95
+ const committed = safeExec(`git diff ${base}...HEAD`, opts);
96
+ const working = safeExec('git diff HEAD', opts);
76
97
  return committed + (working ? '\n' + working : '');
77
98
  }
78
99
 
@@ -206,9 +227,9 @@ function classifyDiff(diffText) {
206
227
  return decide(parseDiff(diffText));
207
228
  }
208
229
 
209
- function classifyFromGit(base) {
210
- const resolved = base || detectDefaultBranch();
211
- return classifyDiff(readDiffFromGit(resolved));
230
+ function classifyFromGit(base, cwd) {
231
+ const resolved = base || detectDefaultBranch(cwd);
232
+ return classifyDiff(readDiffFromGit(resolved, cwd));
212
233
  }
213
234
 
214
235
  if (require.main === module) {
@@ -232,4 +253,4 @@ if (require.main === module) {
232
253
  }
233
254
  }
234
255
 
235
- module.exports = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch };
256
+ module.exports = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch, _resetCacheForTest };
@@ -14,6 +14,32 @@ import { spawn, execFileSync } from 'child_process';
14
14
  import { join, resolve } from 'path';
15
15
  import * as fs from 'fs';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
+ /**
18
+ * Resolve the dashboard port from CLI flag and env, in that precedence order.
19
+ *
20
+ * Precedence (highest first):
21
+ * 1. `--dashboard-port` flag (explicit caller intent)
22
+ * 2. `MOFLO_DAEMON_PORT` env (shared contract with `daemon-write-client.ts`)
23
+ * 3. `DEFAULT_DASHBOARD_PORT` (3117)
24
+ *
25
+ * The env fallback (#1067) eliminates the client/server asymmetry: prior to
26
+ * this, the client honored `MOFLO_DAEMON_PORT` but the server only read
27
+ * `--dashboard-port`. A consumer pinning the env (e.g. the smoke harness)
28
+ * would point clients at one port while the server bound the default.
29
+ *
30
+ * Exported for unit testing — the command handler calls this once per start.
31
+ */
32
+ export function resolveDashboardPort(flagValue, envValue) {
33
+ const source = flagValue ?? envValue;
34
+ if (!source)
35
+ return { ok: true, port: DEFAULT_DASHBOARD_PORT };
36
+ const parsed = parseInt(source, 10);
37
+ if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
38
+ const label = flagValue ? 'dashboard port' : 'MOFLO_DAEMON_PORT';
39
+ return { ok: false, error: `Invalid ${label}: ${source} (must be 1-65535)` };
40
+ }
41
+ return { ok: true, port: parsed };
42
+ }
17
43
  // Start daemon subcommand
18
44
  const startCommand = {
19
45
  name: 'start',
@@ -43,16 +69,13 @@ const startCommand = {
43
69
  const rawDashboardPort = ctx.flags.dashboardPort;
44
70
  const projectRoot = process.cwd();
45
71
  const isDaemonProcess = process.env.CLAUDE_FLOW_DAEMON === '1';
46
- // Parse dashboard port
47
- let dashboardPort = DEFAULT_DASHBOARD_PORT;
48
- if (rawDashboardPort) {
49
- const parsed = parseInt(rawDashboardPort, 10);
50
- if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
51
- output.printError(`Invalid dashboard port: ${rawDashboardPort} (must be 1-65535)`);
52
- return { success: false, exitCode: 1 };
53
- }
54
- dashboardPort = parsed;
72
+ // Resolve dashboard port; see `resolveDashboardPort` for precedence.
73
+ const portResult = resolveDashboardPort(rawDashboardPort, process.env.MOFLO_DAEMON_PORT);
74
+ if (!portResult.ok) {
75
+ output.printError(portResult.error);
76
+ return { success: false, exitCode: 1 };
55
77
  }
78
+ const dashboardPort = portResult.port;
56
79
  // Parse resource threshold overrides from CLI flags
57
80
  const config = {};
58
81
  const rawMaxCpu = ctx.flags.maxCpuLoad;
@@ -433,9 +456,13 @@ const stopCommand = {
433
456
  },
434
457
  };
435
458
  /**
436
- * Kill background daemon process using lock file
459
+ * Kill background daemon process using lock file.
460
+ *
461
+ * Exported so `memory init --force` can stop the daemon before unlinking
462
+ * moflo.db — on Windows the daemon's open file handle otherwise blocks
463
+ * unlinkSync with EBUSY (#1098).
437
464
  */
438
- async function killBackgroundDaemon(projectRoot) {
465
+ export async function killBackgroundDaemon(projectRoot) {
439
466
  const holderPid = getDaemonLockHolder(projectRoot);
440
467
  if (!holderPid) {
441
468
  // No live daemon — clean up any stale lock
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Embedding Coverage Truth doctor check (epic #1054.S5 / #1059).
3
+ *
4
+ * Stricter complement to `checkEmbeddings`: any disagreement between
5
+ * `.moflo/vector-stats.json` and the live DB count fails the check and
6
+ * forces doctor to report the LOWER number. The existing check allowed a
7
+ * 20% skew tolerance — that tolerance is what let #1054.repro-1 keep saying
8
+ * 100% even after the daemon-tick clobber drove the live count below the
9
+ * cached count.
10
+ *
11
+ * @module cli/commands/doctor-checks-coverage-truth
12
+ */
13
+ import { existsSync, readFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
16
+ import { errorDetail } from '../shared/utils/error-detail.js';
17
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
18
+ async function liveEmbeddedRowCount(dbPath) {
19
+ // Read-only COUNT(*) — open via the unified factory so the engine choice
20
+ // is consistent with every other writer (Phase 5 / #1084).
21
+ try {
22
+ const db = openDaemonDatabase(dbPath);
23
+ try {
24
+ const res = db.exec("SELECT COUNT(*) FROM memory_entries WHERE status = 'active' AND embedding IS NOT NULL AND embedding != ''");
25
+ const cell = res?.[0]?.values?.[0]?.[0];
26
+ const n = typeof cell === 'number' ? cell : Number(cell ?? 0);
27
+ return Number.isFinite(n) ? n : null;
28
+ }
29
+ finally {
30
+ db.close();
31
+ }
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function readCachedStats(cwd) {
38
+ const p = join(cwd, '.moflo', 'vector-stats.json');
39
+ if (!existsSync(p))
40
+ return null;
41
+ try {
42
+ const stats = JSON.parse(readFileSync(p, 'utf8'));
43
+ return {
44
+ vectorCount: typeof stats.vectorCount === 'number' ? stats.vectorCount : 0,
45
+ missing: typeof stats.missing === 'number' ? stats.missing : 0,
46
+ };
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ export async function readCoverage(cwd = process.cwd()) {
53
+ const cached = readCachedStats(cwd);
54
+ const dbPath = memoryDbCandidatePaths(cwd).find((p) => existsSync(p)) ?? null;
55
+ const live = dbPath ? await liveEmbeddedRowCount(dbPath) : null;
56
+ return {
57
+ cached: cached?.vectorCount ?? null,
58
+ live,
59
+ missing: cached?.missing ?? null,
60
+ };
61
+ }
62
+ /**
63
+ * Refuses to report 100% when the on-disk cache disagrees with the live DB.
64
+ * Always reports the LOWER number with the discrepancy noted.
65
+ *
66
+ * Pass cases:
67
+ * - No cache + no DB: nothing to compare, neutral pass
68
+ * - Cache and live agree exactly
69
+ * Warn cases:
70
+ * - Cache present but DB unreadable (sql.js missing, etc.) — defer to the
71
+ * existing checkEmbeddings instead of double-warning
72
+ * Fail cases:
73
+ * - Cache and live disagree → report the lower value, note discrepancy
74
+ */
75
+ export async function checkEmbeddingCoverageTruth(cwd = process.cwd()) {
76
+ const name = 'Embedding Coverage Truth';
77
+ try {
78
+ const { cached, live, missing } = await readCoverage(cwd);
79
+ if (cached === null && live === null) {
80
+ return { name, status: 'pass', message: 'No memory database or cache yet — nothing to reconcile' };
81
+ }
82
+ if (cached !== null && live === null) {
83
+ // Cache exists but live count unreadable — checkEmbeddings owns the
84
+ // "DB unreadable" diagnosis; this check stays neutral so we don't
85
+ // double-warn.
86
+ return {
87
+ name,
88
+ status: 'pass',
89
+ message: `Cache reports ${cached} vectors (live count unverified — DB unreadable)`,
90
+ };
91
+ }
92
+ if (cached === null && live !== null) {
93
+ if (live === 0) {
94
+ // Cold-boot fresh-install state — no rows to vectorise, no cache to
95
+ // diverge from. The check is about coverage DRIFT; empty isn't drift.
96
+ return { name, status: 'pass', message: 'No embedded rows yet — nothing to reconcile' };
97
+ }
98
+ return {
99
+ name,
100
+ status: 'warn',
101
+ message: `Live DB has ${live} embedded rows but no .moflo/vector-stats.json cache exists`,
102
+ fix: 'node node_modules/moflo/bin/build-embeddings.mjs',
103
+ };
104
+ }
105
+ // Both readings present.
106
+ const cachedN = cached ?? 0;
107
+ const liveN = live ?? 0;
108
+ if (cachedN === liveN) {
109
+ const totalRows = liveN + (missing ?? 0);
110
+ const pct = totalRows > 0 ? Math.round((liveN / totalRows) * 100) : 100;
111
+ return {
112
+ name,
113
+ status: 'pass',
114
+ message: `${liveN} vectors confirmed live ${missing && missing > 0 ? `(${pct}% of ${totalRows}, ${missing} missing)` : '(100%)'}`,
115
+ };
116
+ }
117
+ const lower = Math.min(cachedN, liveN);
118
+ const direction = cachedN > liveN ? 'cache higher than DB' : 'DB higher than cache';
119
+ return {
120
+ name,
121
+ status: 'fail',
122
+ message: `Coverage mismatch: cache=${cachedN}, live=${liveN} (${direction}); ` +
123
+ `reporting the lower value ${lower}. ` +
124
+ `Likely cause: writer clobber or stale cache (#1054 bug class).`,
125
+ fix: 'node node_modules/moflo/bin/build-embeddings.mjs',
126
+ };
127
+ }
128
+ catch (e) {
129
+ return {
130
+ name,
131
+ status: 'warn',
132
+ message: `Unable to verify coverage: ${errorDetail(e, { firstLineOnly: true })}`,
133
+ };
134
+ }
135
+ }
136
+ //# sourceMappingURL=doctor-checks-coverage-truth.js.map
@@ -22,10 +22,10 @@
22
22
  * Cleanup runs even on assertion failure so a fail doesn't leave orphaned
23
23
  * agents/hive workers.
24
24
  */
25
- import { existsSync, readFileSync, writeFileSync } from 'fs';
25
+ import { existsSync } from 'fs';
26
26
  import { errorDetail } from '../shared/utils/error-detail.js';
27
- import { mofloImport } from '../services/moflo-require.js';
28
27
  import { memoryDbPath } from '../services/moflo-paths.js';
28
+ import { findProjectRoot } from '../services/project-root.js';
29
29
  import { loadToolArrays, getTool, pushDetail, summarizeFunctional, } from './doctor-checks-functional-shared.js';
30
30
  const MEMORY_ACCESS_CHECK = 'Memory Access Functional';
31
31
  const MEMORY_ACCESS_FAIL_FIX = 'Run `flo doctor --json` for per-subcheck details. Common fixes: ensure fastembed installed (memory_store.hasEmbedding=false), explicit threshold:0 honored (#837), or rebuild HNSW index (`flo memory rebuild-index`)';
@@ -39,7 +39,14 @@ async function invokeOrThrow(tools, name, input) {
39
39
  * Run a memory_store + memory_search round-trip and append three details
40
40
  * (store, search, value-match). Returns the unique key/namespace used so the
41
41
  * caller can clean up. Never throws — assertion failures land in `details`.
42
+ *
43
+ * Exported (under `_` prefix) for the #1111 regression test that simulates
44
+ * the empty-HNSW race with a synthetic `memoryTools` array. Not part of the
45
+ * public doctor surface; use `checkMemoryAccessFunctional` from real callers.
42
46
  */
47
+ export async function _runMemoryRoundTripForTest(ctx) {
48
+ return runMemoryRoundTrip(ctx);
49
+ }
43
50
  async function runMemoryRoundTrip(ctx) {
44
51
  const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
45
52
  const key = `doctor-memprobe-${ctx.persona}-${stamp}`;
@@ -72,12 +79,21 @@ async function runMemoryRoundTrip(ctx) {
72
79
  // 2. memory_search with threshold=0 — must find the row we just stored.
73
80
  // threshold=0 is the explicit "no threshold" value (#837); regressions
74
81
  // there silently filter out matches even when the row is in the index.
82
+ //
83
+ // #1111: when search returns 0 results we do a literal-key fallback via
84
+ // memory_retrieve. On a fresh consumer install pretrain is still computing
85
+ // embeddings async, so the HNSW index can be empty (`0 vectors indexed`)
86
+ // even when the row IS in the DB. The check tests memory access, not
87
+ // embedding-index readiness — a successful literal retrieve demotes the
88
+ // search fail to a warn so the doctor surfaces the race without misreporting
89
+ // it as broken memory access.
75
90
  const searchMeta = {
76
91
  id: `${ctx.idPrefix}.memory_search`,
77
92
  mcpTool: 'memory_search',
78
- expected: 'total>=1 with backend including HNSW',
93
+ expected: 'total>=1 via semantic search, OR row retrievable by key when HNSW is unpopulated',
79
94
  };
80
95
  let searchOut;
96
+ let hnswIndexEmpty = false;
81
97
  try {
82
98
  searchOut = (await invokeOrThrow(ctx.memoryTools, 'memory_search', {
83
99
  query: sentinel,
@@ -86,7 +102,23 @@ async function runMemoryRoundTrip(ctx) {
86
102
  limit: 5,
87
103
  }));
88
104
  const failReason = assertSearch(searchOut);
89
- pushDetail(ctx.details, searchMeta, failReason ? searchOut : { total: searchOut?.total, backend: searchOut?.backend, topKey: searchOut?.results?.[0]?.key }, failReason);
105
+ if (failReason && searchOut?.total === 0) {
106
+ const retrievable = await literalKeyReachable(ctx.memoryTools, key, namespace);
107
+ if (retrievable) {
108
+ hnswIndexEmpty = true;
109
+ ctx.details.push({
110
+ ...searchMeta, status: 'warn',
111
+ observed: { total: 0, backend: searchOut.backend, literalRetrieve: 'found' },
112
+ message: 'search returned 0 results despite threshold=0, but row IS reachable by key — HNSW index not yet populated (likely pretrain/embeddings race on fresh install; memory access path works, only the vector index is empty)',
113
+ });
114
+ }
115
+ else {
116
+ pushDetail(ctx.details, searchMeta, searchOut, failReason);
117
+ }
118
+ }
119
+ else {
120
+ pushDetail(ctx.details, searchMeta, failReason ? searchOut : { total: searchOut?.total, backend: searchOut?.backend, topKey: searchOut?.results?.[0]?.key }, failReason);
121
+ }
90
122
  }
91
123
  catch (err) {
92
124
  const detail = errorDetail(err, { firstLineOnly: true });
@@ -99,8 +131,21 @@ async function runMemoryRoundTrip(ctx) {
99
131
  // 3. The just-stored row must come back from search so callers can find
100
132
  // what they wrote. memory_search returns a 60-char content snippet, so
101
133
  // full-value verification belongs in the retrieve subcheck below.
102
- const top = searchOut.results?.find(r => r.key === key);
103
- pushDetail(ctx.details, { id: `${ctx.idPrefix}.search-finds-key`, mcpTool: 'memory_search', expected: `result containing key=${key}` }, top ? { topKey: top.key, similarity: top.similarity } : { allKeys: searchOut.results?.map(r => r.key) }, top ? null : `stored key ${key} not in results (got: ${searchOut?.results?.map(r => r.key).join(', ') ?? 'none'})`);
134
+ // When step 2 already observed the HNSW index as empty, the literal retrieve
135
+ // in step 4 covers reachability; mark this subcheck warn instead of double-
136
+ // failing on the same root cause.
137
+ if (hnswIndexEmpty) {
138
+ ctx.details.push({
139
+ id: `${ctx.idPrefix}.search-finds-key`, mcpTool: 'memory_search', status: 'warn',
140
+ observed: { hnswEmpty: true },
141
+ expected: `result containing key=${key} via semantic search`,
142
+ message: 'skipped semantic match — HNSW index empty (see memory_search subcheck); literal-key retrieve below covers reachability',
143
+ });
144
+ }
145
+ else {
146
+ const top = searchOut.results?.find(r => r.key === key);
147
+ pushDetail(ctx.details, { id: `${ctx.idPrefix}.search-finds-key`, mcpTool: 'memory_search', expected: `result containing key=${key}` }, top ? { topKey: top.key, similarity: top.similarity } : { allKeys: searchOut.results?.map(r => r.key) }, top ? null : `stored key ${key} not in results (got: ${searchOut?.results?.map(r => r.key).join(', ') ?? 'none'})`);
148
+ }
104
149
  // 4. memory_retrieve returns the full value (search content is truncated
105
150
  // to a 60-char snippet). Catches write clobber and namespace bleed — we
106
151
  // get back exactly what we wrote, not someone else's row at the same key.
@@ -163,6 +208,23 @@ async function safeDelete(memoryTools, key, namespace) {
163
208
  }
164
209
  catch { /* best-effort */ }
165
210
  }
211
+ /**
212
+ * #1111: Probe whether a row is reachable via literal-key lookup. Used to
213
+ * distinguish a real memory-access failure (row missing) from the HNSW-empty
214
+ * race (row written, but vector index not yet populated by pretrain).
215
+ *
216
+ * Best-effort: a thrown handler or missing tool returns false rather than
217
+ * propagating, so the caller still reports the original search failure.
218
+ */
219
+ async function literalKeyReachable(memoryTools, key, namespace) {
220
+ try {
221
+ const out = (await invokeOrThrow(memoryTools, 'memory_retrieve', { key, namespace }));
222
+ return out?.found === true;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
166
228
  /**
167
229
  * #1053 S2: probe `memory_get_neighbors` round-trip.
168
230
  *
@@ -171,13 +233,11 @@ async function safeDelete(memoryTools, key, namespace) {
171
233
  * the metadata-passthrough plumbing in S1), this probe fails BEFORE the
172
234
  * stub ships to consumers.
173
235
  *
174
- * Three chunks are stored via memory_store, then their `metadata` columns
175
- * are written directly via sql.js to inject chunk-shaped nav fields
176
- * (memory_store always sets metadata to '{}', so the chunker write path is
177
- * the only producer in steady state bypass it here for an in-process
178
- * probe). The middle chunk's neighbors are then fetched and verified to
179
- * carry navigation back, proving S1 + S2 + the metadata column passthrough
180
- * survive end-to-end.
236
+ * Three chunks are stored via `memory_store` with the chunk-shaped metadata
237
+ * passed in-band (#1064 the chokepoint now accepts `metadata` so producers
238
+ * no longer have to open their own DB handle). The middle chunk's neighbors
239
+ * are then fetched and verified to carry navigation back, proving S1 + S2 +
240
+ * the metadata column passthrough survive end-to-end.
181
241
  */
182
242
  async function probeMemoryGetNeighbors(memoryTools, details) {
183
243
  const tool = getTool(memoryTools, 'memory_get_neighbors');
@@ -204,14 +264,11 @@ async function probeMemoryGetNeighbors(memoryTools, details) {
204
264
  const prefix = `chunk-doctor-neighbors-${stamp}`;
205
265
  const chunkKeys = [`${prefix}-0`, `${prefix}-1`, `${prefix}-2`];
206
266
  const middleKey = chunkKeys[1];
207
- // Seed three chunks via DIRECT sql.js write (skipping memory_store).
208
- // Going through memory_store would warm the bridge's TieredCache with
209
- // metadata='{}', and a follow-up direct UPDATE would race the bridge's
210
- // writeback (sql.js dump-on-flush hazard, see
211
- // feedback_sqljs_writeback_clobber.md). Writing straight to disk before
212
- // the bridge ever sees these keys means memory_get_neighbors → getEntry
213
- // hits fresh disk state and our injected metadata is what comes back.
214
- const dbPath = memoryDbPath(process.cwd());
267
+ // Seed three chunks through `memory_store` with metadata in-band (#1064).
268
+ // The chokepoint now accepts `metadata`, so we no longer open our own
269
+ // node:sqlite handle here the bridge's TieredCache stays consistent with
270
+ // disk, and the writer-audit no longer has to whitelist a probe-only bypass.
271
+ const dbPath = memoryDbPath(findProjectRoot());
215
272
  if (!existsSync(dbPath)) {
216
273
  details.push({
217
274
  id: 'neighbors.seed',
@@ -223,54 +280,53 @@ async function probeMemoryGetNeighbors(memoryTools, details) {
223
280
  });
224
281
  return { key: middleKey, namespace, chunkKeys };
225
282
  }
226
- try {
227
- const initSqlJs = (await mofloImport('sql.js')).default;
228
- const SQL = await initSqlJs();
229
- const db = new SQL.Database(readFileSync(dbPath));
230
- const insert = db.prepare(`INSERT OR REPLACE INTO memory_entries
231
- (id, key, namespace, content, type, tags, metadata, created_at, updated_at, status)
232
- VALUES (?, ?, ?, ?, 'semantic', '[]', ?, ?, ?, 'active')`);
233
- const now = Date.now();
234
- for (let i = 0; i < chunkKeys.length; i++) {
235
- const meta = {
236
- type: 'chunk',
237
- parentDoc: 'doc-doctor-neighbors',
238
- parentPath: '/doctor-neighbors.md',
239
- chunkIndex: i,
240
- totalChunks: chunkKeys.length,
241
- prevChunk: i > 0 ? chunkKeys[i - 1] : null,
242
- nextChunk: i < chunkKeys.length - 1 ? chunkKeys[i + 1] : null,
243
- siblings: chunkKeys,
244
- hierarchicalParent: null,
245
- hierarchicalChildren: null,
246
- chunkTitle: `Doctor Probe Chunk ${i}`,
247
- headerLevel: 2,
248
- docContentHash: stamp,
249
- };
250
- insert.run([
251
- `memprobe-neighbors-${stamp}-${i}`,
252
- chunkKeys[i],
283
+ for (let i = 0; i < chunkKeys.length; i++) {
284
+ const meta = {
285
+ type: 'chunk',
286
+ parentDoc: 'doc-doctor-neighbors',
287
+ parentPath: '/doctor-neighbors.md',
288
+ chunkIndex: i,
289
+ totalChunks: chunkKeys.length,
290
+ prevChunk: i > 0 ? chunkKeys[i - 1] : null,
291
+ nextChunk: i < chunkKeys.length - 1 ? chunkKeys[i + 1] : null,
292
+ siblings: chunkKeys,
293
+ hierarchicalParent: null,
294
+ hierarchicalChildren: null,
295
+ chunkTitle: `Doctor Probe Chunk ${i}`,
296
+ headerLevel: 2,
297
+ docContentHash: stamp,
298
+ };
299
+ try {
300
+ const out = (await invokeOrThrow(memoryTools, 'memory_store', {
301
+ key: chunkKeys[i],
302
+ value: `chunk body ${i}`,
253
303
  namespace,
254
- `chunk body ${i}`,
255
- JSON.stringify(meta),
256
- now, now,
257
- ]);
304
+ metadata: meta,
305
+ }));
306
+ if (!out?.success) {
307
+ details.push({
308
+ id: 'neighbors.seed',
309
+ mcpTool: 'memory_get_neighbors',
310
+ status: 'fail',
311
+ observed: { chunk: chunkKeys[i], error: out?.error ?? 'unknown' },
312
+ expected: 'memory_store accepts chunk-shaped metadata in-band',
313
+ message: `seed failed at chunk ${i}: ${out?.error ?? 'unknown'}`,
314
+ });
315
+ return { key: middleKey, namespace, chunkKeys };
316
+ }
317
+ }
318
+ catch (err) {
319
+ const msg = errorDetail(err, { firstLineOnly: true });
320
+ details.push({
321
+ id: 'neighbors.seed',
322
+ mcpTool: 'memory_get_neighbors',
323
+ status: 'fail',
324
+ observed: { error: msg },
325
+ expected: 'three chunk rows seeded via memory_store with chunk-shaped metadata',
326
+ message: `seed failed: ${msg}`,
327
+ });
328
+ return { key: middleKey, namespace, chunkKeys };
258
329
  }
259
- insert.free();
260
- writeFileSync(dbPath, Buffer.from(db.export()));
261
- db.close();
262
- }
263
- catch (err) {
264
- const msg = errorDetail(err, { firstLineOnly: true });
265
- details.push({
266
- id: 'neighbors.seed',
267
- mcpTool: 'memory_get_neighbors',
268
- status: 'fail',
269
- observed: { error: msg },
270
- expected: 'three chunk rows seeded with chunk-shaped metadata',
271
- message: `seed failed: ${msg}`,
272
- });
273
- return { key: middleKey, namespace, chunkKeys };
274
330
  }
275
331
  // 3. The probe itself — fetch prev + next of the middle chunk.
276
332
  let result;
@@ -343,35 +399,39 @@ export async function checkMemoryAccessFunctional() {
343
399
  let spawnedAgentId;
344
400
  let hiveInitialized = false;
345
401
  try {
346
- // ── Probe 0: memory_get_neighbors (#1053 S2) ──────────────────────────
347
- // MUST RUN FIRST — before any memory_store call warms the bridge's
348
- // in-memory sql.js snapshot. The probe injects chunk metadata via a
349
- // direct file write to .moflo/moflo.db; once the bridge has loaded the
350
- // DB into memory and done its first persist, external file writes are
351
- // clobbered (sql.js dump-on-flush hazard, see
352
- // feedback_sqljs_writeback_clobber.md). Running this probe first means
353
- // the bridge instantiates AFTER our seed lands — its initial disk read
354
- // sees our rows.
355
- {
356
- const probeResult = await probeMemoryGetNeighbors(memoryTools, details);
357
- if (probeResult) {
358
- const { namespace, chunkKeys } = probeResult;
359
- for (const key of chunkKeys) {
360
- cleanups.push(() => safeDelete(memoryTools, key, namespace));
361
- }
362
- }
363
- }
364
402
  // ── Probe 1: subagent context ─────────────────────────────────────────
365
403
  // The "subagent" path is what Claude's Task tool ends up calling: direct
366
404
  // MCP tools with no surrounding coordinator state. Failures here indicate
367
405
  // the memory subsystem itself is broken before we even get to coordinator
368
- // interactions.
406
+ // interactions. Runs first so the bridge initializes `.moflo/moflo.db`
407
+ // (schema + first WAL commit) before the neighbors probe seeds chunks.
408
+ //
409
+ // Phase 4 (#1083) flipped the engine to node:sqlite + WAL: every
410
+ // node:sqlite connection on the same DB shares the WAL coherently, so
411
+ // the legacy "neighbors first or sql.js snapshot clobbers it" ordering
412
+ // is obsolete. Subagent first means moflo.db exists by the time the
413
+ // neighbors seed's `existsSync` gate runs (probe degraded to warn in
414
+ // the temp-dir test fixture before this reorder).
369
415
  {
370
416
  const { key, namespace } = await runMemoryRoundTrip({
371
417
  persona: 'subagent', idPrefix: 'subagent', memoryTools, details,
372
418
  });
373
419
  cleanups.push(() => safeDelete(memoryTools, key, namespace));
374
420
  }
421
+ // ── Probe 0: memory_get_neighbors (#1053 S2) ──────────────────────────
422
+ // Seeds three chunk rows directly via the node:sqlite factory and asserts
423
+ // memory_get_neighbors returns the prev+next pair. Cross-engine clobber
424
+ // is no longer a concern under Phase 4 (#1083) — every writer on
425
+ // .moflo/moflo.db goes through node:sqlite + WAL.
426
+ {
427
+ const probeResult = await probeMemoryGetNeighbors(memoryTools, details);
428
+ if (probeResult) {
429
+ const { namespace, chunkKeys } = probeResult;
430
+ for (const key of chunkKeys) {
431
+ cleanups.push(() => safeDelete(memoryTools, key, namespace));
432
+ }
433
+ }
434
+ }
375
435
  // ── Probe 2: swarm-agent context ──────────────────────────────────────
376
436
  // After `swarm_init` + `agent_spawn` the UnifiedSwarmCoordinator holds
377
437
  // live state. A regression that opens long-lived sql.js handles in the