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.
- package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
- package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
- package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
- package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
- package/.claude/guidance/shipped/moflo-subagents.md +4 -0
- package/.claude/helpers/gate.cjs +3 -3
- package/.claude/helpers/statusline.cjs +69 -33
- package/.claude/helpers/subagent-bootstrap.json +1 -1
- package/.claude/helpers/subagent-start.cjs +1 -1
- package/.claude/skills/eldar/SKILL.md +8 -0
- package/bin/build-embeddings.mjs +6 -20
- package/bin/cli.js +5 -0
- package/bin/gate.cjs +3 -3
- package/bin/generate-code-map.mjs +4 -24
- package/bin/hooks.mjs +3 -12
- package/bin/index-all.mjs +3 -13
- package/bin/index-guidance.mjs +59 -119
- package/bin/index-patterns.mjs +6 -24
- package/bin/index-tests.mjs +4 -23
- package/bin/lib/db-repair.mjs +4 -25
- package/bin/lib/get-backend.mjs +306 -0
- package/bin/lib/incremental-write.mjs +27 -7
- package/bin/lib/moflo-paths.mjs +64 -4
- package/bin/lib/suppress-sqlite-warning.mjs +57 -0
- package/bin/migrations/knowledge-purge.mjs +7 -8
- package/bin/migrations/knowledge-to-learnings.mjs +7 -9
- package/bin/migrations/purge-doc-entries.mjs +52 -0
- package/bin/migrations/strip-context-preambles.mjs +95 -0
- package/bin/run-migrations.mjs +1 -10
- package/bin/semantic-search.mjs +11 -19
- package/bin/session-start-launcher.mjs +102 -100
- package/bin/simplify-classify.cjs +38 -17
- package/dist/src/cli/commands/daemon.js +38 -11
- package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
- package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
- package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
- package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
- package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
- package/dist/src/cli/commands/doctor-fixes.js +30 -0
- package/dist/src/cli/commands/doctor-registry.js +14 -0
- package/dist/src/cli/commands/doctor.js +1 -1
- package/dist/src/cli/commands/embeddings.js +17 -22
- package/dist/src/cli/commands/memory.js +54 -75
- package/dist/src/cli/embeddings/persistent-cache.js +44 -83
- package/dist/src/cli/init/claudemd-generator.js +4 -0
- package/dist/src/cli/init/moflo-init.js +40 -0
- package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
- package/dist/src/cli/memory/bridge-core.js +256 -30
- package/dist/src/cli/memory/bridge-entries.js +76 -8
- package/dist/src/cli/memory/controller-registry.js +7 -2
- package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
- package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
- package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
- package/dist/src/cli/memory/daemon-backend.js +400 -0
- package/dist/src/cli/memory/daemon-write-client.js +192 -15
- package/dist/src/cli/memory/database-provider.js +57 -40
- package/dist/src/cli/memory/hnsw-persistence.js +6 -8
- package/dist/src/cli/memory/index.js +0 -1
- package/dist/src/cli/memory/memory-bridge.js +40 -8
- package/dist/src/cli/memory/memory-initializer.js +286 -220
- package/dist/src/cli/memory/rvf-migration.js +25 -11
- package/dist/src/cli/memory/sqlite-backend.js +573 -0
- package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
- package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
- package/dist/src/cli/services/daemon-dashboard.js +13 -1
- package/dist/src/cli/services/daemon-lock.js +58 -1
- package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
- package/dist/src/cli/services/embeddings-migration.js +9 -12
- package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
- package/dist/src/cli/services/learning-service.js +12 -20
- package/dist/src/cli/services/project-root.js +69 -9
- package/dist/src/cli/services/soft-delete-purge.js +6 -11
- package/dist/src/cli/services/sqljs-migration-store.js +4 -1
- package/dist/src/cli/services/subagent-bootstrap.js +1 -1
- package/dist/src/cli/shared/events/event-store.js +26 -55
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -4
- 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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
15
|
-
* rows that have an embedding. Returns null
|
|
16
|
-
*
|
|
17
|
-
* truth", letting the caller fall back to the cached stats rather
|
|
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
|
|
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 (
|
|
141
|
-
//
|
|
142
|
-
const sqlDetail = errorDetail(
|
|
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 (
|
|
150
|
-
fix: '
|
|
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:
|
|
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
|