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.
@@ -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} — a strict
59
- * subset that *excludes* `tasklist`, because the dashboard's Flo Runs tab
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 && EPHEMERAL_NAMESPACES.has(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, EPHEMERAL_NAMESPACES, getBridgeEmbedder } from './bridge-embedder.js';
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 = EPHEMERAL_NAMESPACES.has(namespace);
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 placeholders = namespaces.map(() => '?').join(', ');
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 namespace IN (${placeholders})) AS purgeable,
64
- (SELECT COUNT(*) FROM memory_entries WHERE namespace = 'tasklist') AS tasklistTotal`, namespaces);
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 namespace IN (${placeholders})`, namespaces);
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
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.10.0';
5
+ export const VERSION = '4.10.2';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.0",
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.9.37",
98
+ "moflo": "^4.10.1",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"