moflo 4.9.36 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
  2. package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
  3. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  4. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  5. package/.claude/guidance/shipped/moflo-subagents.md +4 -0
  6. package/.claude/helpers/gate.cjs +3 -3
  7. package/.claude/helpers/statusline.cjs +69 -33
  8. package/.claude/helpers/subagent-bootstrap.json +1 -1
  9. package/.claude/helpers/subagent-start.cjs +1 -1
  10. package/.claude/skills/eldar/SKILL.md +8 -0
  11. package/bin/build-embeddings.mjs +6 -20
  12. package/bin/cli.js +5 -0
  13. package/bin/gate.cjs +3 -3
  14. package/bin/generate-code-map.mjs +4 -24
  15. package/bin/hooks.mjs +3 -12
  16. package/bin/index-all.mjs +3 -13
  17. package/bin/index-guidance.mjs +59 -119
  18. package/bin/index-patterns.mjs +6 -24
  19. package/bin/index-tests.mjs +4 -23
  20. package/bin/lib/db-repair.mjs +4 -25
  21. package/bin/lib/get-backend.mjs +306 -0
  22. package/bin/lib/incremental-write.mjs +27 -7
  23. package/bin/lib/moflo-paths.mjs +64 -4
  24. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  25. package/bin/migrations/knowledge-purge.mjs +7 -8
  26. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  27. package/bin/migrations/purge-doc-entries.mjs +52 -0
  28. package/bin/migrations/strip-context-preambles.mjs +95 -0
  29. package/bin/run-migrations.mjs +1 -10
  30. package/bin/semantic-search.mjs +11 -19
  31. package/bin/session-start-launcher.mjs +102 -100
  32. package/bin/simplify-classify.cjs +38 -17
  33. package/dist/src/cli/commands/daemon.js +38 -11
  34. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  35. package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
  36. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  37. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  38. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  39. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  40. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  41. package/dist/src/cli/commands/doctor-registry.js +14 -0
  42. package/dist/src/cli/commands/doctor.js +1 -1
  43. package/dist/src/cli/commands/embeddings.js +17 -22
  44. package/dist/src/cli/commands/memory.js +54 -75
  45. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  46. package/dist/src/cli/init/claudemd-generator.js +4 -0
  47. package/dist/src/cli/init/moflo-init.js +40 -0
  48. package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
  49. package/dist/src/cli/memory/bridge-core.js +256 -30
  50. package/dist/src/cli/memory/bridge-entries.js +76 -8
  51. package/dist/src/cli/memory/controller-registry.js +7 -2
  52. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  53. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  54. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  55. package/dist/src/cli/memory/daemon-backend.js +400 -0
  56. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  57. package/dist/src/cli/memory/database-provider.js +57 -40
  58. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  59. package/dist/src/cli/memory/index.js +0 -1
  60. package/dist/src/cli/memory/memory-bridge.js +40 -8
  61. package/dist/src/cli/memory/memory-initializer.js +286 -220
  62. package/dist/src/cli/memory/rvf-migration.js +25 -11
  63. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  64. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  65. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  66. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  67. package/dist/src/cli/services/daemon-lock.js +58 -1
  68. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  69. package/dist/src/cli/services/embeddings-migration.js +9 -12
  70. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  71. package/dist/src/cli/services/learning-service.js +12 -20
  72. package/dist/src/cli/services/project-root.js +69 -9
  73. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  74. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  75. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  76. package/dist/src/cli/shared/events/event-store.js +26 -55
  77. package/dist/src/cli/version.js +1 -1
  78. package/package.json +2 -4
  79. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -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,7 +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 } from 'fs';
25
26
  import { errorDetail } from '../shared/utils/error-detail.js';
27
+ import { memoryDbPath } from '../services/moflo-paths.js';
28
+ import { findProjectRoot } from '../services/project-root.js';
26
29
  import { loadToolArrays, getTool, pushDetail, summarizeFunctional, } from './doctor-checks-functional-shared.js';
27
30
  const MEMORY_ACCESS_CHECK = 'Memory Access Functional';
28
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`)';
@@ -36,7 +39,14 @@ async function invokeOrThrow(tools, name, input) {
36
39
  * Run a memory_store + memory_search round-trip and append three details
37
40
  * (store, search, value-match). Returns the unique key/namespace used so the
38
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.
39
46
  */
47
+ export async function _runMemoryRoundTripForTest(ctx) {
48
+ return runMemoryRoundTrip(ctx);
49
+ }
40
50
  async function runMemoryRoundTrip(ctx) {
41
51
  const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
42
52
  const key = `doctor-memprobe-${ctx.persona}-${stamp}`;
@@ -69,12 +79,21 @@ async function runMemoryRoundTrip(ctx) {
69
79
  // 2. memory_search with threshold=0 — must find the row we just stored.
70
80
  // threshold=0 is the explicit "no threshold" value (#837); regressions
71
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.
72
90
  const searchMeta = {
73
91
  id: `${ctx.idPrefix}.memory_search`,
74
92
  mcpTool: 'memory_search',
75
- expected: 'total>=1 with backend including HNSW',
93
+ expected: 'total>=1 via semantic search, OR row retrievable by key when HNSW is unpopulated',
76
94
  };
77
95
  let searchOut;
96
+ let hnswIndexEmpty = false;
78
97
  try {
79
98
  searchOut = (await invokeOrThrow(ctx.memoryTools, 'memory_search', {
80
99
  query: sentinel,
@@ -83,7 +102,23 @@ async function runMemoryRoundTrip(ctx) {
83
102
  limit: 5,
84
103
  }));
85
104
  const failReason = assertSearch(searchOut);
86
- 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
+ }
87
122
  }
88
123
  catch (err) {
89
124
  const detail = errorDetail(err, { firstLineOnly: true });
@@ -96,8 +131,21 @@ async function runMemoryRoundTrip(ctx) {
96
131
  // 3. The just-stored row must come back from search so callers can find
97
132
  // what they wrote. memory_search returns a 60-char content snippet, so
98
133
  // full-value verification belongs in the retrieve subcheck below.
99
- const top = searchOut.results?.find(r => r.key === key);
100
- 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
+ }
101
149
  // 4. memory_retrieve returns the full value (search content is truncated
102
150
  // to a 60-char snippet). Catches write clobber and namespace bleed — we
103
151
  // get back exactly what we wrote, not someone else's row at the same key.
@@ -160,6 +208,175 @@ async function safeDelete(memoryTools, key, namespace) {
160
208
  }
161
209
  catch { /* best-effort */ }
162
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
+ }
228
+ /**
229
+ * #1053 S2: probe `memory_get_neighbors` round-trip.
230
+ *
231
+ * Same #798 protected-functionality posture as the swarm/agent/task probes:
232
+ * if a future refactor stubs the handler to literals (or unwires it from
233
+ * the metadata-passthrough plumbing in S1), this probe fails BEFORE the
234
+ * stub ships to consumers.
235
+ *
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.
241
+ */
242
+ async function probeMemoryGetNeighbors(memoryTools, details) {
243
+ const tool = getTool(memoryTools, 'memory_get_neighbors');
244
+ if (!tool?.handler) {
245
+ details.push({
246
+ id: 'neighbors.registered',
247
+ mcpTool: 'memory_get_neighbors',
248
+ status: 'fail',
249
+ observed: { registered: false },
250
+ expected: 'memory_get_neighbors registered in MCP tool surface (#1053 S2)',
251
+ message: 'memory_get_neighbors is not registered — has the tool been removed or its name changed?',
252
+ });
253
+ return null;
254
+ }
255
+ details.push({
256
+ id: 'neighbors.registered',
257
+ mcpTool: 'memory_get_neighbors',
258
+ status: 'pass',
259
+ observed: { registered: true },
260
+ expected: 'memory_get_neighbors registered',
261
+ });
262
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
263
+ const namespace = `doctor-neighbors-${stamp}`;
264
+ const prefix = `chunk-doctor-neighbors-${stamp}`;
265
+ const chunkKeys = [`${prefix}-0`, `${prefix}-1`, `${prefix}-2`];
266
+ const middleKey = chunkKeys[1];
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());
272
+ if (!existsSync(dbPath)) {
273
+ details.push({
274
+ id: 'neighbors.seed',
275
+ mcpTool: 'memory_get_neighbors',
276
+ status: 'warn',
277
+ observed: { dbPath, exists: false },
278
+ expected: 'memory.db present so the neighbors probe can seed chunks',
279
+ message: 'memory.db missing — skip the neighbors probe (run `flo memory init` first)',
280
+ });
281
+ return { key: middleKey, namespace, chunkKeys };
282
+ }
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}`,
303
+ namespace,
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 };
329
+ }
330
+ }
331
+ // 3. The probe itself — fetch prev + next of the middle chunk.
332
+ let result;
333
+ try {
334
+ result = (await invokeOrThrow(memoryTools, 'memory_get_neighbors', {
335
+ key: middleKey,
336
+ namespace,
337
+ }));
338
+ }
339
+ catch (err) {
340
+ const msg = errorDetail(err, { firstLineOnly: true });
341
+ details.push({
342
+ id: 'neighbors.roundtrip',
343
+ mcpTool: 'memory_get_neighbors',
344
+ status: 'fail',
345
+ observed: { error: msg },
346
+ expected: 'memory_get_neighbors returns success=true with prev + next',
347
+ message: `handler threw: ${msg}`,
348
+ });
349
+ return { key: middleKey, namespace, chunkKeys };
350
+ }
351
+ const failReason = assertNeighbors(result, [chunkKeys[0], chunkKeys[2]]);
352
+ pushDetail(details, {
353
+ id: 'neighbors.roundtrip',
354
+ mcpTool: 'memory_get_neighbors',
355
+ expected: `success=true, total=2, neighbors include ${chunkKeys[0]} + ${chunkKeys[2]} with navigation`,
356
+ }, failReason ? result : { total: result.total, neighborKeys: result.neighbors?.map(n => n.key) }, failReason);
357
+ return { key: middleKey, namespace, chunkKeys };
358
+ }
359
+ function assertNeighbors(result, expectedKeys) {
360
+ if (!result?.success) {
361
+ return `success=${result?.success} (error: ${result?.error ?? 'unknown'}) — handler did not return success`;
362
+ }
363
+ if (result.total !== expectedKeys.length) {
364
+ return `expected total=${expectedKeys.length}, got ${result.total} — neighbors traversal returned wrong count`;
365
+ }
366
+ const got = (result.neighbors ?? []).map(n => n.key).sort();
367
+ const want = [...expectedKeys].sort();
368
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
369
+ return `expected neighbor keys ${JSON.stringify(want)}, got ${JSON.stringify(got)} — wrong neighbors returned`;
370
+ }
371
+ // Every neighbor must carry navigation (S1 metadata passthrough). A stub
372
+ // that returns shaped envelopes but null nav would pass the count check;
373
+ // this catches it.
374
+ const missingNav = (result.neighbors ?? []).filter(n => !n.navigation);
375
+ if (missingNav.length > 0) {
376
+ return `${missingNav.length} neighbor(s) returned with navigation=null — S1 metadata passthrough may be broken`;
377
+ }
378
+ return null;
379
+ }
163
380
  export async function checkMemoryAccessFunctional() {
164
381
  const details = [];
165
382
  const mods = await loadToolArrays({
@@ -186,13 +403,35 @@ export async function checkMemoryAccessFunctional() {
186
403
  // The "subagent" path is what Claude's Task tool ends up calling: direct
187
404
  // MCP tools with no surrounding coordinator state. Failures here indicate
188
405
  // the memory subsystem itself is broken before we even get to coordinator
189
- // 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).
190
415
  {
191
416
  const { key, namespace } = await runMemoryRoundTrip({
192
417
  persona: 'subagent', idPrefix: 'subagent', memoryTools, details,
193
418
  });
194
419
  cleanups.push(() => safeDelete(memoryTools, key, namespace));
195
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
+ }
196
435
  // ── Probe 2: swarm-agent context ──────────────────────────────────────
197
436
  // After `swarm_init` + `agent_spawn` the UnifiedSwarmCoordinator holds
198
437
  // live state. A regression that opens long-lived sql.js handles in the
@@ -8,24 +8,19 @@ import { existsSync, readFileSync, statSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
10
10
  import { errorDetail } from '../shared/utils/error-detail.js';
11
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
11
12
  /** Skew (cached / live count delta) above which the cache is treated as stale. */
12
13
  const VECTOR_STATS_SKEW_WARN_THRESHOLD = 0.2;
13
14
  /**
14
- * Open `dbPath` via moflo's bundled sql.js and return the count of memory_entries
15
- * rows that have an embedding. Returns null if sql.js can't be loaded, the file
16
- * isn't a v3 schema, or the query fails — every error is treated as "unknown
17
- * truth", letting the caller fall back to the cached stats rather than masking
18
- * a healthy DB as broken.
15
+ * Open `dbPath` via the unified node:sqlite factory and return the count of
16
+ * memory_entries rows that have an embedding. Returns null on any error
17
+ * (corrupt DB, missing column, schema mismatch) — every error is treated as
18
+ * "unknown truth", letting the caller fall back to the cached stats rather
19
+ * than masking a healthy DB as broken.
19
20
  */
20
21
  async function countEmbeddedRowsFromDb(dbPath) {
21
22
  try {
22
- const { mofloImport } = await import('../services/moflo-require.js');
23
- const initSqlJs = (await mofloImport('sql.js'))?.default;
24
- if (!initSqlJs)
25
- return null;
26
- const SQL = await initSqlJs();
27
- const buffer = readFileSync(dbPath);
28
- const db = new SQL.Database(buffer);
23
+ const db = openDaemonDatabase(dbPath);
29
24
  try {
30
25
  const res = db.exec("SELECT COUNT(*) FROM memory_entries WHERE embedding IS NOT NULL AND embedding != ''");
31
26
  const cell = res?.[0]?.values?.[0]?.[0];
@@ -137,21 +132,21 @@ export async function checkEmbeddings() {
137
132
  message: `Memory DB initialized (v${info.version}, vectors enabled)`,
138
133
  };
139
134
  }
140
- catch (sqlJsError) {
141
- // sql.js not available — fall back to file-size heuristic
142
- const sqlDetail = errorDetail(sqlJsError);
135
+ catch (sqliteError) {
136
+ // node:sqlite open / introspection failed — fall back to file-size heuristic.
137
+ const sqlDetail = errorDetail(sqliteError);
143
138
  try {
144
139
  const stats = statSync(foundDbPath);
145
140
  const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
146
141
  return {
147
142
  name: 'Embeddings',
148
143
  status: 'warn',
149
- message: `Memory DB exists (${sizeMB} MB) — cannot verify vectors (sql.js not available: ${sqlDetail})`,
150
- fix: 'npm install sql.js && npx moflo embeddings init',
144
+ message: `Memory DB exists (${sizeMB} MB) — cannot verify vectors (DB unreadable: ${sqlDetail})`,
145
+ fix: 'npx moflo embeddings init',
151
146
  };
152
147
  }
153
148
  catch (statError) {
154
- return { name: 'Embeddings', status: 'warn', message: `Unable to check: sql.js failed (${sqlDetail}), stat failed (${errorDetail(statError)})` };
149
+ return { name: 'Embeddings', status: 'warn', message: `Unable to check: DB read failed (${sqlDetail}), stat failed (${errorDetail(statError)})` };
155
150
  }
156
151
  }
157
152
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Daemon Version Skew doctor check (epic #1054.S5 / #1059).
3
+ *
4
+ * Surfaces the failure mode that silently masked the #1054 bug class for two
5
+ * version bumps: a long-lived daemon that survived `npm install moflo@<new>`
6
+ * keeps running pre-upgrade code while the on-disk package.json reads `<new>`.
7
+ *
8
+ * Distinct failure mode — NOT buried in "stale cache". Consumes the same
9
+ * signal the launcher acts on (`bin/session-start-launcher.mjs` section 3a-pre)
10
+ * via `getDaemonLockPayload` so the diagnosis and the auto-recycle path share
11
+ * one source of truth.
12
+ *
13
+ * @module cli/commands/doctor-checks-version-skew
14
+ */
15
+ import { existsSync, readFileSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { getDaemonLockPayload, readOwnMofloVersion } from '../services/daemon-lock.js';
18
+ import { errorDetail } from '../shared/utils/error-detail.js';
19
+ /**
20
+ * Resolve the installed package version from `node_modules/moflo/package.json`.
21
+ * Falls back to the daemon's own version (same package on consumers; same
22
+ * dogfood layout in this repo).
23
+ */
24
+ function readInstalledVersion(cwd) {
25
+ const pkgPath = join(cwd, 'node_modules', 'moflo', 'package.json');
26
+ if (existsSync(pkgPath)) {
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
29
+ if (typeof pkg.version === 'string')
30
+ return pkg.version;
31
+ }
32
+ catch {
33
+ // unreadable / malformed — fall through
34
+ }
35
+ }
36
+ // Dogfood path (this repo): no `node_modules/moflo`; read the root package
37
+ // via the same walker the daemon uses.
38
+ return readOwnMofloVersion() ?? null;
39
+ }
40
+ /**
41
+ * Distinct doctor entry — fails when the running daemon's `version` (recorded
42
+ * in `.moflo/daemon.lock` by S2) does not match the installed package version.
43
+ *
44
+ * Pre-#1054 daemons have no `version` in their lock — treated as a mismatch
45
+ * because by construction they were launched before version publishing
46
+ * existed.
47
+ *
48
+ * No daemon running → pass with a neutral message (the daemon-status check
49
+ * already owns the "not running" diagnosis).
50
+ */
51
+ export async function checkDaemonVersionSkew(cwd = process.cwd()) {
52
+ const name = 'Daemon Version Skew';
53
+ try {
54
+ const installed = readInstalledVersion(cwd);
55
+ if (!installed) {
56
+ return {
57
+ name,
58
+ status: 'warn',
59
+ message: 'Cannot resolve installed moflo version (no node_modules/moflo/package.json)',
60
+ };
61
+ }
62
+ const payload = getDaemonLockPayload(cwd);
63
+ if (!payload) {
64
+ return {
65
+ name,
66
+ status: 'pass',
67
+ message: `No daemon running — installed v${installed}`,
68
+ };
69
+ }
70
+ const observed = payload.version ?? '<pre-1054 / unknown>';
71
+ if (payload.version === installed) {
72
+ return {
73
+ name,
74
+ status: 'pass',
75
+ message: `Daemon v${observed} matches installed v${installed} (PID ${payload.pid})`,
76
+ };
77
+ }
78
+ return {
79
+ name,
80
+ status: 'fail',
81
+ message: `Daemon (PID ${payload.pid}) running v${observed} but installed package is v${installed}. ` +
82
+ `Stale pre-upgrade daemon — every write it makes is against pre-upgrade code paths (#1054 bug class).`,
83
+ fix: 'npx moflo daemon stop && npx moflo daemon start',
84
+ };
85
+ }
86
+ catch (e) {
87
+ return {
88
+ name,
89
+ status: 'warn',
90
+ message: `Unable to check version skew: ${errorDetail(e, { firstLineOnly: true })}`,
91
+ };
92
+ }
93
+ }
94
+ //# sourceMappingURL=doctor-checks-version-skew.js.map