moflo 4.10.0 → 4.10.2
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/skills/healer/SKILL.md +3 -1
- package/bin/lib/db-repair.mjs +358 -41
- package/bin/session-start-launcher.mjs +42 -6
- package/dist/src/cli/commands/doctor-checks-config.js +60 -0
- package/dist/src/cli/commands/doctor-checks-memory-access.js +27 -1
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +48 -12
- package/dist/src/cli/commands/doctor-fixes.js +57 -0
- package/dist/src/cli/commands/doctor-registry.js +10 -1
- package/dist/src/cli/commands/doctor-render.js +118 -74
- package/dist/src/cli/commands/doctor.js +70 -25
- package/dist/src/cli/memory/bridge-core.js +36 -0
- package/dist/src/cli/memory/bridge-embedder.js +84 -3
- package/dist/src/cli/memory/memory-initializer.js +2 -2
- package/dist/src/cli/services/ephemeral-namespace-purge.js +15 -5
- package/dist/src/cli/services/memory-db-integrity-repair.js +119 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -54,9 +54,14 @@ export const EMBEDDING_MODEL_LEGACY_DEFAULT = 'local';
|
|
|
54
54
|
* - `epic-state` — Epic progress (epic-N, story-M) written by commands/epic.ts
|
|
55
55
|
* - `test-bridge-fix` — Single 2026-04-23 row left over from a one-off test
|
|
56
56
|
*
|
|
57
|
+
* Membership is also extended by {@link EPHEMERAL_NAMESPACE_PREFIXES} for
|
|
58
|
+
* dynamic-name namespaces (e.g. `doctor-memprobe-<persona>`). Most callers
|
|
59
|
+
* should use {@link isEphemeralNamespace} which checks both sets.
|
|
60
|
+
*
|
|
57
61
|
* See story #729 for the source-trace and rationale. The session-start
|
|
58
|
-
* launcher only purges {@link PURGE_ON_SESSION_START_NAMESPACES}
|
|
59
|
-
* subset that *excludes*
|
|
62
|
+
* launcher only purges {@link PURGE_ON_SESSION_START_NAMESPACES} +
|
|
63
|
+
* {@link PURGE_ON_SESSION_START_PREFIXES} — a strict subset that *excludes*
|
|
64
|
+
* `tasklist`, because the dashboard's Flo Runs tab
|
|
60
65
|
* (`daemon-dashboard.ts handleSpells`) reads tasklist; purging it on every
|
|
61
66
|
* session would empty the tab between sessions (#968).
|
|
62
67
|
*/
|
|
@@ -66,6 +71,26 @@ export const EPHEMERAL_NAMESPACES = new Set([
|
|
|
66
71
|
'epic-state',
|
|
67
72
|
'test-bridge-fix',
|
|
68
73
|
]);
|
|
74
|
+
/**
|
|
75
|
+
* Prefix patterns that extend {@link EPHEMERAL_NAMESPACES} for namespaces
|
|
76
|
+
* whose suffix is generated at runtime. Any namespace beginning with one of
|
|
77
|
+
* these prefixes is treated as ephemeral (skips embedding).
|
|
78
|
+
*
|
|
79
|
+
* NOTE — design distinction from {@link PURGE_ON_SESSION_START_PREFIXES}:
|
|
80
|
+
* a namespace can be auto-purgeable WITHOUT being skip-embed. For example,
|
|
81
|
+
* `doctor-memprobe-<persona>` rows are intentionally purged on every
|
|
82
|
+
* session start (the cleanup is best-effort and accumulates across
|
|
83
|
+
* sessions) but MUST still get embeddings — the probe's whole purpose is
|
|
84
|
+
* to validate the embedder is wired (`Memory Access Functional` check
|
|
85
|
+
* asserts `hasEmbedding=true`). Skipping embedding for those rows breaks
|
|
86
|
+
* the doctor check. Put a prefix here only when both properties apply.
|
|
87
|
+
*
|
|
88
|
+
* Currently empty — there's no namespace today that needs both skip-embed
|
|
89
|
+
* AND prefix-match. Kept as an explicit export so the bridge embedder's
|
|
90
|
+
* call site is uniform and future skip-embed prefixes have an obvious
|
|
91
|
+
* home.
|
|
92
|
+
*/
|
|
93
|
+
export const EPHEMERAL_NAMESPACE_PREFIXES = new Set([]);
|
|
69
94
|
/**
|
|
70
95
|
* Subset of {@link EPHEMERAL_NAMESPACES} that the session-start launcher
|
|
71
96
|
* hard-purges via `services/ephemeral-namespace-purge.ts`. Excludes
|
|
@@ -77,6 +102,62 @@ export const PURGE_ON_SESSION_START_NAMESPACES = new Set([
|
|
|
77
102
|
'epic-state',
|
|
78
103
|
'test-bridge-fix',
|
|
79
104
|
]);
|
|
105
|
+
/**
|
|
106
|
+
* Prefix patterns purged alongside {@link PURGE_ON_SESSION_START_NAMESPACES}
|
|
107
|
+
* by the session-start launcher.
|
|
108
|
+
*
|
|
109
|
+
* Members:
|
|
110
|
+
* - `doctor-memprobe-` — `flo healer`'s `Memory Access` round-trip probe
|
|
111
|
+
* writes a sentinel into `doctor-memprobe-<persona>` (persona is one of
|
|
112
|
+
* `subagent`, `swarm-agent`, `hive-mind-worker`, plus test variants).
|
|
113
|
+
* - `doctor-neighbors-` — `flo healer`'s neighbor-traversal probe creates a
|
|
114
|
+
* fresh `doctor-neighbors-<timestamp>` namespace for each run and seeds
|
|
115
|
+
* three chunk rows. Unlike memprobe (fixed personas), every healer run
|
|
116
|
+
* spawns a NEW namespace, so namespace pollution grows linearly with
|
|
117
|
+
* healer-run count if cleanup races fail.
|
|
118
|
+
*
|
|
119
|
+
* Both probes register an explicit cleanup via `safeDelete`, but the
|
|
120
|
+
* cleanup is best-effort and silently swallows failures (e.g. daemon
|
|
121
|
+
* races, MCP transport errors) — so rows accumulate across consumer
|
|
122
|
+
* sessions. Auto-purging matches the pattern for
|
|
123
|
+
* `hive-mind`/`epic-state`/`test-bridge-fix`. These rows MUST still get
|
|
124
|
+
* embeddings (see {@link EPHEMERAL_NAMESPACE_PREFIXES} for why) — only
|
|
125
|
+
* their persistence across sessions is curtailed.
|
|
126
|
+
*/
|
|
127
|
+
export const PURGE_ON_SESSION_START_PREFIXES = new Set([
|
|
128
|
+
'doctor-memprobe-',
|
|
129
|
+
'doctor-neighbors-',
|
|
130
|
+
]);
|
|
131
|
+
/**
|
|
132
|
+
* Return `true` if a namespace is ephemeral — either an exact member of
|
|
133
|
+
* {@link EPHEMERAL_NAMESPACES} or one whose name begins with a prefix in
|
|
134
|
+
* {@link EPHEMERAL_NAMESPACE_PREFIXES}. Callers checking embedding-skip
|
|
135
|
+
* behavior should use this helper rather than `.has()` on the Set directly.
|
|
136
|
+
*/
|
|
137
|
+
export function isEphemeralNamespace(namespace) {
|
|
138
|
+
if (EPHEMERAL_NAMESPACES.has(namespace))
|
|
139
|
+
return true;
|
|
140
|
+
for (const prefix of EPHEMERAL_NAMESPACE_PREFIXES) {
|
|
141
|
+
if (namespace.startsWith(prefix))
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Return `true` if a namespace should be hard-purged on session start —
|
|
148
|
+
* either an exact member of {@link PURGE_ON_SESSION_START_NAMESPACES} or one
|
|
149
|
+
* whose name begins with a prefix in
|
|
150
|
+
* {@link PURGE_ON_SESSION_START_PREFIXES}.
|
|
151
|
+
*/
|
|
152
|
+
export function shouldPurgeOnSessionStart(namespace) {
|
|
153
|
+
if (PURGE_ON_SESSION_START_NAMESPACES.has(namespace))
|
|
154
|
+
return true;
|
|
155
|
+
for (const prefix of PURGE_ON_SESSION_START_PREFIXES) {
|
|
156
|
+
if (namespace.startsWith(prefix))
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
80
161
|
/**
|
|
81
162
|
* Maximum number of `tasklist` rows kept across session restarts. The
|
|
82
163
|
* session-start retention pass deletes oldest rows beyond this cap, so the
|
|
@@ -140,7 +221,7 @@ export async function resolveBridgeEmbedding(value, precomputed, generateEmbeddi
|
|
|
140
221
|
// Ephemeral namespaces (run-tracking, never user knowledge) skip embeddings
|
|
141
222
|
// unconditionally — even precomputed vectors are dropped. Result row has
|
|
142
223
|
// `embedding IS NULL` and `embedding_model IS NULL`. See #729.
|
|
143
|
-
if (namespace &&
|
|
224
|
+
if (namespace && isEphemeralNamespace(namespace)) {
|
|
144
225
|
return { ok: true, json: null, dimensions: 0, model: null };
|
|
145
226
|
}
|
|
146
227
|
const wantsEmbedding = generateEmbeddingFlag !== false && value.length > 0;
|
|
@@ -20,7 +20,7 @@ import * as path from 'path';
|
|
|
20
20
|
import { formatEmbeddingError } from './embedding-errors.js';
|
|
21
21
|
import { HnswLite } from './hnsw-lite.js';
|
|
22
22
|
import { tryLoadHnswSidecar } from './hnsw-persistence.js';
|
|
23
|
-
import { EMBEDDING_MODEL_OPT_OUT,
|
|
23
|
+
import { EMBEDDING_MODEL_OPT_OUT, getBridgeEmbedder, isEphemeralNamespace } from './bridge-embedder.js';
|
|
24
24
|
import { parseEmbeddingJson, toFloat32 } from './controllers/_shared.js';
|
|
25
25
|
import { writeVectorStatsJson } from './bridge-core.js';
|
|
26
26
|
import { serialiseMetadata } from './bridge-entries.js';
|
|
@@ -1619,7 +1619,7 @@ export async function storeEntry(options) {
|
|
|
1619
1619
|
let embeddingJson = null;
|
|
1620
1620
|
let embeddingDimensions = null;
|
|
1621
1621
|
let embeddingModel = EMBEDDING_MODEL_OPT_OUT;
|
|
1622
|
-
const isEphemeralNs =
|
|
1622
|
+
const isEphemeralNs = isEphemeralNamespace(namespace);
|
|
1623
1623
|
if (isEphemeralNs) {
|
|
1624
1624
|
embeddingModel = null;
|
|
1625
1625
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* @module cli/services/ephemeral-namespace-purge
|
|
28
28
|
*/
|
|
29
29
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
30
|
-
import { PURGE_ON_SESSION_START_NAMESPACES, TASKLIST_RETENTION_CAP, } from '../memory/bridge-embedder.js';
|
|
30
|
+
import { PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES, TASKLIST_RETENTION_CAP, } from '../memory/bridge-embedder.js';
|
|
31
31
|
import { memoryDbPath } from './moflo-paths.js';
|
|
32
32
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
33
33
|
/**
|
|
@@ -56,18 +56,28 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
56
56
|
// Single COUNT pass to gate both DELETEs — a clean DB is the steady
|
|
57
57
|
// state and we don't want two no-op DELETEs (with their query-planner
|
|
58
58
|
// overhead) on every session start.
|
|
59
|
+
//
|
|
60
|
+
// Match shape: exact namespace IN (...) OR namespace LIKE 'prefix-%'.
|
|
61
|
+
// The prefix clause covers runtime-suffixed namespaces like
|
|
62
|
+
// `doctor-memprobe-<persona>` whose set of suffixes isn't known upfront.
|
|
59
63
|
const namespaces = Array.from(PURGE_ON_SESSION_START_NAMESPACES);
|
|
64
|
+
const prefixes = Array.from(PURGE_ON_SESSION_START_PREFIXES);
|
|
60
65
|
const cap = options.tasklistRetentionCap ?? TASKLIST_RETENTION_CAP;
|
|
61
|
-
const
|
|
66
|
+
const exactClause = namespaces.length
|
|
67
|
+
? `namespace IN (${namespaces.map(() => '?').join(', ')})`
|
|
68
|
+
: '0';
|
|
69
|
+
const prefixClause = prefixes.map(() => 'namespace LIKE ?').join(' OR ');
|
|
70
|
+
const purgeWhere = prefixClause ? `(${exactClause} OR ${prefixClause})` : exactClause;
|
|
71
|
+
const purgeBindings = [...namespaces, ...prefixes.map((p) => `${p}%`)];
|
|
62
72
|
const countRows = db.exec(`SELECT
|
|
63
|
-
(SELECT COUNT(*) FROM memory_entries WHERE
|
|
64
|
-
(SELECT COUNT(*) FROM memory_entries WHERE namespace = 'tasklist') AS tasklistTotal`,
|
|
73
|
+
(SELECT COUNT(*) FROM memory_entries WHERE ${purgeWhere}) AS purgeable,
|
|
74
|
+
(SELECT COUNT(*) FROM memory_entries WHERE namespace = 'tasklist') AS tasklistTotal`, purgeBindings);
|
|
65
75
|
const counts = countRows[0]?.values?.[0] ?? [0, 0];
|
|
66
76
|
const purgeable = Number(counts[0] ?? 0);
|
|
67
77
|
const tasklistTotal = Number(counts[1] ?? 0);
|
|
68
78
|
let purged = 0;
|
|
69
79
|
if (purgeable > 0) {
|
|
70
|
-
db.run(`DELETE FROM memory_entries WHERE
|
|
80
|
+
db.run(`DELETE FROM memory_entries WHERE ${purgeWhere}`, purgeBindings);
|
|
71
81
|
purged = db.getRowsModified?.() ?? 0;
|
|
72
82
|
}
|
|
73
83
|
let trimmed = 0;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TS bridge for `flo healer --fix -c memory-db-integrity` and any other
|
|
3
|
+
* caller that wants the tiered repair (REINDEX → VACUUM INTO → row-level
|
|
4
|
+
* salvage) implemented in {@link
|
|
5
|
+
* "../../../bin/lib/db-repair.mjs".repairMemoryDbIfCorrupt} but with the
|
|
6
|
+
* caller-side daemon coordination that the launcher path gets for free.
|
|
7
|
+
*
|
|
8
|
+
* The launcher (bin/session-start-launcher.mjs § 0c) runs the same repair
|
|
9
|
+
* at session start after the daemon is already stopped. A mid-session
|
|
10
|
+
* healer call needs to stop the daemon itself first — a live writer would
|
|
11
|
+
* race the atomic swap on Windows (EBUSY on `renameSync`) and leak
|
|
12
|
+
* corruption back through stale POSIX inodes elsewhere.
|
|
13
|
+
*
|
|
14
|
+
* Cross-platform notes:
|
|
15
|
+
* - `process.kill(pid, 'SIGTERM')` maps to `TerminateProcess` on Windows
|
|
16
|
+
* (Node maps every signal name to immediate termination on win32);
|
|
17
|
+
* behaves like POSIX SIGTERM on Linux/macOS. Either way the daemon
|
|
18
|
+
* exits before we touch the DB file.
|
|
19
|
+
* - Path resolution uses `import.meta.url` so dist/ and bin/ stay
|
|
20
|
+
* siblings whether moflo is running from a dogfood checkout or from a
|
|
21
|
+
* consumer's `node_modules/moflo/` install.
|
|
22
|
+
* - The MCP server (spawned by Claude Code per `.mcp.json`, not by moflo)
|
|
23
|
+
* is out of our process tree and cannot be stopped here. We surface
|
|
24
|
+
* explicit guidance to restart Claude Code in the caller's UX.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
27
|
+
import { join, resolve } from 'node:path';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
29
|
+
import { getDaemonLockHolder, getDaemonLockPayload } from './daemon-lock.js';
|
|
30
|
+
import { findMofloPackageRoot } from './moflo-require.js';
|
|
31
|
+
async function loadJsDbRepairModule() {
|
|
32
|
+
// Resolve the JS module via the moflo package root walk so the path
|
|
33
|
+
// works identically in three contexts:
|
|
34
|
+
// - Dogfood TS source (vitest): walks up from the .ts location to the
|
|
35
|
+
// repo's package.json → joins `bin/lib/db-repair.mjs`
|
|
36
|
+
// - Compiled dist (CLI runtime): walks up from dist/src/cli/services/
|
|
37
|
+
// to package root → joins `bin/lib/db-repair.mjs`
|
|
38
|
+
// - Consumer install: walks up from
|
|
39
|
+
// node_modules/moflo/dist/src/cli/services/ to
|
|
40
|
+
// node_modules/moflo/ → joins `bin/lib/db-repair.mjs`
|
|
41
|
+
// The previous `new URL('../../../../bin/lib/...', import.meta.url)` only
|
|
42
|
+
// worked in the dist context — source-tree depth is one level shallower
|
|
43
|
+
// so vitest hit "Cannot find module" on the wrong path.
|
|
44
|
+
const root = findMofloPackageRoot();
|
|
45
|
+
if (!root) {
|
|
46
|
+
throw new Error('moflo package root not found — cannot locate bin/lib/db-repair.mjs');
|
|
47
|
+
}
|
|
48
|
+
const repairPath = join(root, 'bin', 'lib', 'db-repair.mjs');
|
|
49
|
+
const repairUrl = pathToFileURL(repairPath).href;
|
|
50
|
+
return (await import(repairUrl));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Probe `.moflo/moflo.db` for corruption without WAL pragmas — the readonly
|
|
54
|
+
* raw-DatabaseSync open that bypasses the openBackend code path which itself
|
|
55
|
+
* throws on corrupt files (pre-#1090's silent-"healthy"-reporting bug).
|
|
56
|
+
*
|
|
57
|
+
* Single source of truth: delegates to {@link
|
|
58
|
+
* "../../../bin/lib/db-repair.mjs".probeIntegrityRaw}. Callers in the TS tree
|
|
59
|
+
* (currently `checkMemoryDbIntegrity` doctor check) should use this rather
|
|
60
|
+
* than re-deriving the readonly+no-PRAGMAs probe so the implementation
|
|
61
|
+
* stays in one place.
|
|
62
|
+
*/
|
|
63
|
+
export async function probeDbIntegrity(dbPath) {
|
|
64
|
+
const mod = await loadJsDbRepairModule();
|
|
65
|
+
return mod.probeIntegrityRaw(dbPath);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Send a SIGTERM-equivalent to the daemon PID and clear the lockfile.
|
|
69
|
+
* Returns true if a live daemon was actually stopped. Cross-platform:
|
|
70
|
+
* `process.kill` accepts the signal name on all platforms; Node treats it
|
|
71
|
+
* as an immediate terminate on Windows.
|
|
72
|
+
*/
|
|
73
|
+
function stopDaemon(projectRoot) {
|
|
74
|
+
const payload = getDaemonLockPayload(projectRoot);
|
|
75
|
+
if (!payload?.pid || payload.pid <= 0)
|
|
76
|
+
return false;
|
|
77
|
+
try {
|
|
78
|
+
process.kill(payload.pid, 'SIGTERM');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// ESRCH (already dead) or EPERM — treat both as "nothing to stop".
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const lockFile = join(projectRoot, '.moflo', 'daemon.lock');
|
|
85
|
+
try {
|
|
86
|
+
if (existsSync(lockFile))
|
|
87
|
+
unlinkSync(lockFile);
|
|
88
|
+
}
|
|
89
|
+
catch { /* */ }
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Run the tiered repair against the project's `.moflo/moflo.db`.
|
|
94
|
+
*
|
|
95
|
+
* Default behavior is to stop the daemon if alive (cross-platform via
|
|
96
|
+
* `process.kill('SIGTERM')`) so the atomic swap doesn't race a live writer.
|
|
97
|
+
* Pass `stopDaemonFirst: false` to suppress that — the launcher path uses
|
|
98
|
+
* this because its own daemon-stop already ran before § 0c.
|
|
99
|
+
*
|
|
100
|
+
* Never throws; any internal error surfaces as
|
|
101
|
+
* `{ repaired: false, errors: 0, persistent: true }`.
|
|
102
|
+
*/
|
|
103
|
+
export async function repairMemoryDbIntegrity(projectRoot = process.cwd(), options = {}) {
|
|
104
|
+
const root = resolve(projectRoot);
|
|
105
|
+
const stopFirst = options.stopDaemonFirst !== false;
|
|
106
|
+
let daemonStopped = false;
|
|
107
|
+
if (stopFirst && getDaemonLockHolder(root) !== null) {
|
|
108
|
+
daemonStopped = stopDaemon(root);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const mod = await loadJsDbRepairModule();
|
|
112
|
+
const result = await mod.repairMemoryDbIfCorrupt(root);
|
|
113
|
+
return { ...result, daemonStopped };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { repaired: false, errors: 0, persistent: true, daemonStopped };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=memory-db-integrity-repair.js.map
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.10.
|
|
3
|
+
"version": "4.10.2",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
96
96
|
"@typescript-eslint/parser": "^7.18.0",
|
|
97
97
|
"eslint": "^8.0.0",
|
|
98
|
-
"moflo": "^4.
|
|
98
|
+
"moflo": "^4.10.1",
|
|
99
99
|
"tsx": "^4.21.0",
|
|
100
100
|
"typescript": "^5.9.3",
|
|
101
101
|
"vitest": "^4.0.0"
|