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.
- package/.claude/guidance/shipped/moflo-memory-protocol.md +5 -1
- package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
- package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
- package/.claude/helpers/statusline.cjs +69 -33
- package/.claude/helpers/subagent-bootstrap.json +1 -1
- package/.claude/helpers/subagent-start.cjs +1 -1
- package/bin/build-embeddings.mjs +6 -20
- package/bin/cli.js +5 -0
- 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 +36 -85
- 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 +7 -8
- package/bin/migrations/strip-context-preambles.mjs +4 -6
- package/bin/run-migrations.mjs +1 -10
- package/bin/semantic-search.mjs +7 -18
- package/bin/session-start-launcher.mjs +102 -102
- 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 +146 -86
- 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 +13 -23
- package/dist/src/cli/embeddings/persistent-cache.js +44 -83
- package/dist/src/cli/init/moflo-init.js +40 -0
- package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
- package/dist/src/cli/memory/bridge-core.js +256 -30
- package/dist/src/cli/memory/bridge-entries.js +70 -6
- 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 +269 -209
- 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
|
@@ -45,34 +45,55 @@ const SECURITY_PATHS = [
|
|
|
45
45
|
/(?:^|[\\\/])\.claude[\\\/]helpers[\\\/]gate/i,
|
|
46
46
|
];
|
|
47
47
|
|
|
48
|
-
function safeExec(cmd) {
|
|
49
|
-
try {
|
|
50
|
-
|
|
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
|
-
|
|
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/'))
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
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
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|