moflo 4.9.36 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +12 -0
  2. package/.claude/guidance/shipped/moflo-memory-protocol.md +34 -0
  3. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  4. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  5. package/.claude/guidance/shipped/moflo-subagents.md +4 -0
  6. package/.claude/helpers/gate.cjs +3 -3
  7. package/.claude/helpers/statusline.cjs +69 -33
  8. package/.claude/helpers/subagent-bootstrap.json +1 -1
  9. package/.claude/helpers/subagent-start.cjs +1 -1
  10. package/.claude/skills/eldar/SKILL.md +8 -0
  11. package/bin/build-embeddings.mjs +6 -20
  12. package/bin/cli.js +5 -0
  13. package/bin/gate.cjs +3 -3
  14. package/bin/generate-code-map.mjs +4 -24
  15. package/bin/hooks.mjs +3 -12
  16. package/bin/index-all.mjs +3 -13
  17. package/bin/index-guidance.mjs +59 -119
  18. package/bin/index-patterns.mjs +6 -24
  19. package/bin/index-tests.mjs +4 -23
  20. package/bin/lib/db-repair.mjs +4 -25
  21. package/bin/lib/get-backend.mjs +306 -0
  22. package/bin/lib/incremental-write.mjs +27 -7
  23. package/bin/lib/moflo-paths.mjs +64 -4
  24. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  25. package/bin/migrations/knowledge-purge.mjs +7 -8
  26. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  27. package/bin/migrations/purge-doc-entries.mjs +52 -0
  28. package/bin/migrations/strip-context-preambles.mjs +95 -0
  29. package/bin/run-migrations.mjs +1 -10
  30. package/bin/semantic-search.mjs +11 -19
  31. package/bin/session-start-launcher.mjs +102 -100
  32. package/bin/simplify-classify.cjs +38 -17
  33. package/dist/src/cli/commands/daemon.js +38 -11
  34. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  35. package/dist/src/cli/commands/doctor-checks-memory-access.js +244 -5
  36. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  37. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  38. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  39. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  40. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  41. package/dist/src/cli/commands/doctor-registry.js +14 -0
  42. package/dist/src/cli/commands/doctor.js +1 -1
  43. package/dist/src/cli/commands/embeddings.js +17 -22
  44. package/dist/src/cli/commands/memory.js +54 -75
  45. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  46. package/dist/src/cli/init/claudemd-generator.js +4 -0
  47. package/dist/src/cli/init/moflo-init.js +40 -0
  48. package/dist/src/cli/mcp-tools/memory-tools.js +177 -32
  49. package/dist/src/cli/memory/bridge-core.js +256 -30
  50. package/dist/src/cli/memory/bridge-entries.js +76 -8
  51. package/dist/src/cli/memory/controller-registry.js +7 -2
  52. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  53. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  54. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  55. package/dist/src/cli/memory/daemon-backend.js +400 -0
  56. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  57. package/dist/src/cli/memory/database-provider.js +57 -40
  58. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  59. package/dist/src/cli/memory/index.js +0 -1
  60. package/dist/src/cli/memory/memory-bridge.js +40 -8
  61. package/dist/src/cli/memory/memory-initializer.js +286 -220
  62. package/dist/src/cli/memory/rvf-migration.js +25 -11
  63. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  64. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  65. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  66. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  67. package/dist/src/cli/services/daemon-lock.js +58 -1
  68. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  69. package/dist/src/cli/services/embeddings-migration.js +9 -12
  70. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  71. package/dist/src/cli/services/learning-service.js +12 -20
  72. package/dist/src/cli/services/project-root.js +69 -9
  73. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  74. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  75. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  76. package/dist/src/cli/shared/events/event-store.js +26 -55
  77. package/dist/src/cli/version.js +1 -1
  78. package/package.json +2 -4
  79. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Pure-JS factory for moflo.db low-level SQL handles — JS twin of the
3
+ * `openDaemonDatabase` factory in `src/cli/memory/daemon-backend.ts`. Every
4
+ * `bin/` script that opens `.moflo/moflo.db` MUST go through {@link openBackend}
5
+ * so the engine choice stays consistent with the rest of the runtime.
6
+ *
7
+ * Backend selection: always `node:sqlite` (Phase 5 / #1084 — sql.js has been
8
+ * deleted from the package). The `resolveBackend()` shim is retained because
9
+ * a handful of tests still pass an explicit `backend` option; it now validates
10
+ * the value but only honours `'node-sqlite'`.
11
+ *
12
+ * Engine surface — the handle exposes the **sql.js low-level Statement API**
13
+ * because every existing bin/ caller was written against it (db.prepare/
14
+ * stmt.bind/step/getAsObject/free/run, db.run/exec, db.export-via-save,
15
+ * db.close). For `node:sqlite`, the adapter emulates `stmt.bind()/step()/
16
+ * getAsObject()` via `StatementSync.iterate()` so callers don't refactor
17
+ * their loops.
18
+ *
19
+ * Persistence semantics:
20
+ * - node:sqlite — writes through the OS file handle under WAL; `save()` is
21
+ * a no-op kept for API parity. WAL pragmas (`journal_mode=WAL`,
22
+ * `synchronous=NORMAL`, `busy_timeout=15000`) are set on first open per
23
+ * Phase 0 spike (#1079) and Phase 1 backend (#1080).
24
+ *
25
+ * @module bin/lib/get-backend
26
+ */
27
+
28
+ // MUST come before any direct/transitive `node:sqlite` import below — the
29
+ // node:sqlite module fires ExperimentalWarning exactly once per process on
30
+ // first load, and once it fires there's no way to scrub it from stderr.
31
+ import './suppress-sqlite-warning.mjs';
32
+
33
+ import { existsSync, mkdirSync } from 'node:fs';
34
+ import { dirname } from 'node:path';
35
+ import { memoryDbPath } from './moflo-paths.mjs';
36
+
37
+ export const BACKEND_NODE_SQLITE = 'node-sqlite';
38
+
39
+ /**
40
+ * Resolve the configured backend. Phase 5 (#1084) deleted the sql.js path,
41
+ * so this always returns `node-sqlite`. The `opts.backend` parameter is kept
42
+ * for API compatibility — anything else throws so a stale caller asking for
43
+ * sql.js surfaces a clear error rather than silently dropping to the wrong
44
+ * engine.
45
+ *
46
+ * @param {{ backend?: string }} [opts]
47
+ * @returns {'node-sqlite'}
48
+ */
49
+ export function resolveBackend(opts = {}) {
50
+ if (opts.backend && opts.backend !== BACKEND_NODE_SQLITE) {
51
+ throw new Error(
52
+ `Unknown backend "${opts.backend}". moflo only supports "node-sqlite"; ` +
53
+ `sql.js was retired in Phase 5 (#1084).`,
54
+ );
55
+ }
56
+ return BACKEND_NODE_SQLITE;
57
+ }
58
+
59
+ function ensureDir(filePath) {
60
+ const dir = dirname(filePath);
61
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
62
+ }
63
+
64
+ /**
65
+ * Open a low-level SQL backend handle. Defaults to `.moflo/moflo.db` under
66
+ * `projectRoot`; pass `opts.dbPath` to point at a different file (used by
67
+ * migrations that touch sibling DBs).
68
+ *
69
+ * @param {string} projectRoot
70
+ * @param {{
71
+ * backend?: 'node-sqlite',
72
+ * create?: boolean,
73
+ * readOnly?: boolean,
74
+ * dbPath?: string,
75
+ * }} [opts]
76
+ * @returns {Promise<object>} backend handle (see module doc)
77
+ */
78
+ export async function openBackend(projectRoot, opts = {}) {
79
+ const dbPath = opts.dbPath || memoryDbPath(projectRoot);
80
+ resolveBackend(opts); // throws on stale sql.js callers
81
+ ensureDir(dbPath);
82
+ return openNodeSqlite(dbPath, opts);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // node:sqlite adapter — the only backend as of Phase 5 (#1084)
87
+ // ---------------------------------------------------------------------------
88
+
89
+ // Module-scope guard so we only fire the network-FS warning once per path
90
+ // per process — the indexer + daemon + bin/ scripts all open the same DB and
91
+ // we don't want N copies of the same message in one session.
92
+ const _networkFsWarnedPaths = new Set();
93
+
94
+ async function openNodeSqlite(dbPath, opts) {
95
+ const { DatabaseSync } = await import('node:sqlite');
96
+ const readOnly = opts.readOnly === true;
97
+ const db = new DatabaseSync(dbPath, { readOnly });
98
+ if (!readOnly) {
99
+ // Close the handle on any PRAGMA failure — node:sqlite opens forgivingly
100
+ // (even non-SQLite files succeed in the constructor) and a PRAGMA that
101
+ // throws later would otherwise leak the file handle across processes
102
+ // (visible on Windows as EPERM on subsequent rmdir of the parent).
103
+ try {
104
+ // WAL trinity validated by Phase 0 spike (#1079) and Phase 1 backend.
105
+ // busy_timeout MUST be set BEFORE journal_mode=WAL — the WAL pragma
106
+ // briefly takes an EXCLUSIVE lock, and concurrent openers (parallel
107
+ // doctor probes, indexer subprocess, daemon bridge init) otherwise hit
108
+ // "database is locked" with no retry budget. See #1097.
109
+ // 15000ms — sized for the consumer-smoke worst case where a
110
+ // background indexer holds a write lock for 5–8s during its first
111
+ // full-tree pass after `npm install`. See daemon-backend.ts twin for
112
+ // the full rationale (#1098).
113
+ db.exec('PRAGMA busy_timeout = 15000');
114
+ db.exec('PRAGMA journal_mode = WAL');
115
+ db.exec('PRAGMA synchronous = NORMAL');
116
+ // Phase 4 / #1083 — network-FS detection. SQLite's POSIX advisory locks
117
+ // and WAL shared-memory both fail silently on NFS/SMB; the engine falls
118
+ // back to a non-WAL journal mode rather than erroring. Read journal_mode
119
+ // back and warn if it isn't `wal`.
120
+ if (dbPath !== ':memory:') warnIfNotWal(db, dbPath);
121
+ } catch (err) {
122
+ try { db.close(); } catch { /* already-dead handle */ }
123
+ throw err;
124
+ }
125
+ }
126
+ return wrapNodeSqlite(db, dbPath);
127
+ }
128
+
129
+ /**
130
+ * Read `journal_mode` back after we requested WAL. If the engine returned a
131
+ * different mode (`delete`, `truncate`, `persist`, `memory`, `off`), the
132
+ * underlying filesystem doesn't support WAL's shared-memory sidecar — a
133
+ * strong signal that POSIX advisory locks are also unreliable. Surface a
134
+ * one-line stderr warning naming the path so the user knows to move the
135
+ * project off the network mount. Deduped per (path, process).
136
+ *
137
+ * Exported so the test in `tests/bin/get-backend.test.ts` can drive a real
138
+ * non-WAL handle through the same probe (a local-disk DB will always come
139
+ * back as WAL, so we can't trigger the warning by simply opening a DB).
140
+ *
141
+ * @param {object} db node:sqlite DatabaseSync handle
142
+ * @param {string} dbPath
143
+ */
144
+ export function warnIfNotWal(db, dbPath) {
145
+ if (_networkFsWarnedPaths.has(dbPath)) return;
146
+ let mode;
147
+ try {
148
+ const stmt = db.prepare('PRAGMA journal_mode');
149
+ const row = stmt.get();
150
+ mode = String(row?.journal_mode ?? '').toLowerCase();
151
+ } catch {
152
+ // Probe must never break the open path — silent failure is acceptable
153
+ // because the WAL pragma above already either took effect or didn't.
154
+ return;
155
+ }
156
+ if (mode && mode !== 'wal') {
157
+ _networkFsWarnedPaths.add(dbPath);
158
+ process.stderr.write(
159
+ `[moflo] WARNING: SQLite journal_mode=${mode} on ${dbPath} (WAL not active). ` +
160
+ `If this directory is on NFS/SMB or another network filesystem, POSIX ` +
161
+ `advisory locks are unreliable and concurrent moflo processes can corrupt ` +
162
+ `the database. Move the project to a local disk to restore multi-process safety.\n`
163
+ );
164
+ }
165
+ }
166
+
167
+ /** @internal — test hook only (resets the dedupe set). */
168
+ export function _resetNetworkFsWarnings() {
169
+ _networkFsWarnedPaths.clear();
170
+ }
171
+
172
+ function wrapNodeSqlite(db, dbPath) {
173
+ // node:sqlite has no `db.changes` field, so the rowsModified probe is a
174
+ // tiny prepared statement reused across calls — preparing on every probe
175
+ // would dominate the indexer's tight write loops.
176
+ let changesStmt = null;
177
+ const getChanges = () => {
178
+ if (!changesStmt) changesStmt = db.prepare('SELECT changes() AS c');
179
+ const row = changesStmt.get();
180
+ return Number(row?.c ?? 0);
181
+ };
182
+
183
+ // Per-connection prepare cache for `db.run(sql, params)` calls — without
184
+ // this the indexer's bulk-DELETE loop (index-guidance:698,699,717) allocates
185
+ // a fresh StatementSync per row, churning the engine's compile cache.
186
+ const runStmtCache = new Map();
187
+ const runWithParams = (sql, params) => {
188
+ let s = runStmtCache.get(sql);
189
+ if (!s) {
190
+ s = db.prepare(sql);
191
+ runStmtCache.set(sql, s);
192
+ }
193
+ s.run(...params);
194
+ };
195
+
196
+ return {
197
+ kind: BACKEND_NODE_SQLITE,
198
+ prepare: (sql) => wrapNodeSqliteStmt(db.prepare(sql)),
199
+ run: (sql, params) => {
200
+ if (params && params.length > 0) runWithParams(sql, params);
201
+ else db.exec(sql);
202
+ },
203
+ exec: (sql) => execAsRowsNodeSqlite(db, sql),
204
+ getRowsModified: getChanges,
205
+ save: () => {
206
+ // node:sqlite persists incrementally via WAL — explicit save is a no-op.
207
+ // Callers can still invoke `save()` unconditionally; the API parity
208
+ // matters more than micro-optimising one call away.
209
+ },
210
+ close: () => {
211
+ changesStmt = null;
212
+ runStmtCache.clear();
213
+ db.close();
214
+ },
215
+ _raw: db,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Heuristic match for multi-statement SQL: `;` followed by anything
221
+ * substantive. node:sqlite's `db.prepare()` does NOT throw on multi-statement
222
+ * input — it silently parses the first statement only and drops the rest,
223
+ * which is the worst possible failure mode for DDL batches like
224
+ * `CREATE TABLE a; CREATE INDEX i; CREATE TABLE b;`. Detect and route
225
+ * multi-stmt SQL to `db.exec()` (which runs every statement).
226
+ */
227
+ function isMultiStatement(sql) {
228
+ const trimmed = sql.trimEnd();
229
+ const semi = trimmed.indexOf(';');
230
+ if (semi === -1) return false;
231
+ return /\S/.test(trimmed.slice(semi + 1));
232
+ }
233
+
234
+ /**
235
+ * Adapt node:sqlite to sql.js's `db.exec(sql)` return shape:
236
+ * `[{ columns: string[], values: any[][] }]`. The bin scripts use this for
237
+ * single-statement queries that return rows (`PRAGMA integrity_check` in
238
+ * `db-repair.mjs`, `SELECT COUNT(*)` in `index-guidance.mjs`) and for
239
+ * multi-statement DDL batches (controller `ensureSchema`).
240
+ */
241
+ function execAsRowsNodeSqlite(db, sql) {
242
+ // Multi-statement DDL: db.exec() runs every statement. Matches sql.js's
243
+ // exec() contract — DDL batches return `[]` (no row results to surface).
244
+ if (isMultiStatement(sql)) {
245
+ db.exec(sql);
246
+ return [];
247
+ }
248
+ let stmt;
249
+ try {
250
+ stmt = db.prepare(sql);
251
+ } catch {
252
+ db.exec(sql);
253
+ return [];
254
+ }
255
+ const rows = stmt.all();
256
+ if (rows.length === 0) return [];
257
+ const columns = Object.keys(rows[0]);
258
+ const values = rows.map((r) => columns.map((c) => r[c]));
259
+ return [{ columns, values }];
260
+ }
261
+
262
+ function wrapNodeSqliteStmt(stmt) {
263
+ // sql.js statements are stateful (bind → step* → free); node:sqlite's
264
+ // StatementSync is stateless (each call takes its own params). The shim
265
+ // captures the pending params and lazily opens an iterator on first
266
+ // `step()`, releasing the iterator on `free()` so the next `bind()`+
267
+ // `step()` cycle starts cleanly.
268
+ let pendingParams = null;
269
+ let iter = null;
270
+ let currentRow = null;
271
+ return {
272
+ bind: (params) => {
273
+ pendingParams = params && params.length > 0 ? params : null;
274
+ iter = null;
275
+ currentRow = null;
276
+ },
277
+ step: () => {
278
+ if (!iter) {
279
+ iter = pendingParams ? stmt.iterate(...pendingParams) : stmt.iterate();
280
+ }
281
+ const next = iter.next();
282
+ if (next.done) {
283
+ currentRow = null;
284
+ return false;
285
+ }
286
+ currentRow = next.value;
287
+ return true;
288
+ },
289
+ getAsObject: () => currentRow || {},
290
+ run: (params) => {
291
+ if (params && params.length > 0) stmt.run(...params);
292
+ else stmt.run();
293
+ },
294
+ free: () => {
295
+ // sql.js's `Statement.free()` finalises the underlying statement;
296
+ // node:sqlite has no per-statement finalize (StatementSync is GC'd
297
+ // when the Database closes). The wrapper's `free()` instead resets
298
+ // the iteration state so the next `bind()`+`step()` cycle starts
299
+ // cleanly. Functional parity with sql.js callers despite the
300
+ // different underlying lifecycle.
301
+ iter = null;
302
+ currentRow = null;
303
+ pendingParams = null;
304
+ },
305
+ };
306
+ }
@@ -66,16 +66,30 @@ export function computeContentListHash(files) {
66
66
  }
67
67
 
68
68
  /**
69
- * Load `key → content` for every active row in the namespace.
69
+ * Load `key → content` for every active row in the namespace, optionally
70
+ * scoped to keys starting with `keyPrefix` (one doc's chunks at a time —
71
+ * lets per-file indexers like `index-guidance.mjs` content-diff without
72
+ * loading every chunk across every file).
73
+ *
70
74
  * @param {object} db - sql.js Database
71
75
  * @param {string} namespace
76
+ * @param {string} [keyPrefix] — when set, restricts the scan to `key LIKE '<prefix>%'`.
77
+ * The same prefix scopes the orphan sweep in {@link applyIncrementalChunks}.
72
78
  * @returns {Map<string,string>}
73
79
  */
74
- export function loadExistingContent(db, namespace) {
75
- const stmt = db.prepare(
76
- `SELECT key, content FROM memory_entries WHERE namespace = ? AND status = 'active'`,
77
- );
78
- stmt.bind([namespace]);
80
+ export function loadExistingContent(db, namespace, keyPrefix) {
81
+ const stmt = keyPrefix
82
+ ? db.prepare(
83
+ `SELECT key, content FROM memory_entries WHERE namespace = ? AND key LIKE ? AND status = 'active'`,
84
+ )
85
+ : db.prepare(
86
+ `SELECT key, content FROM memory_entries WHERE namespace = ? AND status = 'active'`,
87
+ );
88
+ if (keyPrefix) {
89
+ stmt.bind([namespace, `${keyPrefix}%`]);
90
+ } else {
91
+ stmt.bind([namespace]);
92
+ }
79
93
  const map = new Map();
80
94
  while (stmt.step()) {
81
95
  const row = stmt.getAsObject();
@@ -95,11 +109,17 @@ export function loadExistingContent(db, namespace) {
95
109
  * @param {object} [opts]
96
110
  * @param {boolean} [opts.serialize=true] - JSON.stringify metadata/tags before
97
111
  * writing. Set false when callers already pass strings.
112
+ * @param {string} [opts.keyPrefix] — when set, the existing-content load AND
113
+ * the orphan sweep are restricted to keys matching `<prefix>%`. Use this
114
+ * when processing a single file's chunks at a time (e.g. index-guidance.mjs
115
+ * iterates files independently) — without it the sweep would delete every
116
+ * chunk from every OTHER file as an orphan on each call.
98
117
  * @returns {{inserted:number, updated:number, unchanged:number, removed:number}}
99
118
  */
100
119
  export function applyIncrementalChunks(db, namespace, chunks, opts = {}) {
101
120
  const serialize = opts.serialize !== false;
102
- const existing = loadExistingContent(db, namespace);
121
+ const keyPrefix = opts.keyPrefix;
122
+ const existing = loadExistingContent(db, namespace, keyPrefix);
103
123
  const newKeys = new Set();
104
124
  let inserted = 0;
105
125
  let updated = 0;
@@ -1,16 +1,19 @@
1
1
  /**
2
- * Pure-JS counterpart to src/cli/services/moflo-paths.ts.
2
+ * Pure-JS counterpart to src/cli/services/moflo-paths.ts and the
3
+ * findProjectRoot helper in src/cli/services/project-root.ts.
3
4
  *
4
5
  * Lives in bin/lib because session-start-launcher.mjs and other bin/ scripts
5
6
  * run before any TS compilation has happened — they can't import the .ts
6
- * source. The TS version is the canonical programmatic API; this version
7
- * exposes the same path constants + helpers.
7
+ * source. The TS versions are the canonical programmatic API; this file
8
+ * exposes the same path constants + helpers and MUST stay algorithmically
9
+ * identical (see tests/system/project-root-twin.test.ts).
8
10
  *
9
11
  * Per #851, the legacy `.claude-flow/` rename + `.swarm/memory.db` byte-copy
10
12
  * helpers no longer ship: the version-bump-gated cherry-pick lives entirely
11
13
  * in the launcher and the TS service `cli/services/cherry-pick-learnings.ts`.
12
14
  */
13
- import { join } from 'node:path';
15
+ import { existsSync } from 'node:fs';
16
+ import { basename, dirname, join, parse, resolve } from 'node:path';
14
17
 
15
18
  export const MOFLO_DIR = '.moflo';
16
19
  export const MEMORY_DB_FILE = 'moflo.db';
@@ -57,3 +60,60 @@ export function memoryDbCandidatePaths(projectRoot) {
57
60
  join(projectRoot, '.claude', LEGACY_MEMORY_DB_FILE),
58
61
  ];
59
62
  }
63
+
64
+ /**
65
+ * Resolve the project root the same way the TS bridge does. Every bin/
66
+ * script that touches `.moflo/moflo.db` (or any sibling state under
67
+ * `.moflo/`) MUST go through this so its writes land on the SAME file the
68
+ * bridge reads from.
69
+ *
70
+ * Algorithmic twin of `src/cli/services/project-root.ts:findProjectRoot()`
71
+ * and `src/cli/memory/bridge-core.ts:getProjectRoot()`. See those files for
72
+ * the canonical algorithm comment.
73
+ *
74
+ * @param {{ cwd?: string; honorEnv?: boolean }} [opts]
75
+ * @returns {string} absolute project root
76
+ */
77
+ export function findProjectRoot(opts) {
78
+ const honorEnv = opts?.honorEnv !== false;
79
+ if (honorEnv && process.env.CLAUDE_PROJECT_DIR) {
80
+ return process.env.CLAUDE_PROJECT_DIR;
81
+ }
82
+ const startDir = opts?.cwd ?? process.cwd();
83
+ const start = resolve(startDir);
84
+ const fsRoot = parse(start).root;
85
+
86
+ // High-priority pass: memory markers + CLAUDE.md/package.json pair.
87
+ let dir = start;
88
+ while (dir !== fsRoot) {
89
+ if (basename(dir) === 'node_modules') {
90
+ dir = dirname(dir);
91
+ continue;
92
+ }
93
+ if (existsSync(join(dir, '.moflo', 'moflo.db'))) return dir;
94
+ if (existsSync(join(dir, '.swarm', 'memory.db'))) return dir;
95
+ if (existsSync(join(dir, 'CLAUDE.md')) && existsSync(join(dir, 'package.json'))) {
96
+ return dir;
97
+ }
98
+ const parent = dirname(dir);
99
+ if (parent === dir) break;
100
+ dir = parent;
101
+ }
102
+
103
+ // Low-priority pass: bare package.json or .git.
104
+ dir = start;
105
+ while (dir !== fsRoot) {
106
+ if (basename(dir) === 'node_modules') {
107
+ dir = dirname(dir);
108
+ continue;
109
+ }
110
+ if (existsSync(join(dir, 'package.json')) || existsSync(join(dir, '.git'))) {
111
+ return dir;
112
+ }
113
+ const parent = dirname(dir);
114
+ if (parent === dir) break;
115
+ dir = parent;
116
+ }
117
+
118
+ return startDir;
119
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Filter out Node's `(node:NNN) ExperimentalWarning: SQLite is an experimental
3
+ * feature and might change at any time` line that fires once per process the
4
+ * first time `node:sqlite` is loaded.
5
+ *
6
+ * Why we suppress: moflo committed to node:sqlite as the only backend in
7
+ * Phase 5 (#1084). The warning is informational from Node's perspective but
8
+ * actively harmful for us because:
9
+ * 1. It lands on stderr → the consumer-smoke harness's 200-char stderr
10
+ * tails (`recordExit`) get filled with the warning prefix, hiding the
11
+ * real failure message behind it (#1098 — `Memory Access Functional`
12
+ * failures looked like ExperimentalWarning failures in CI logs).
13
+ * 2. It appears in every consumer's `flo doctor` / `flo memory init` /
14
+ * `flo daemon start` invocation, polluting their terminal output for
15
+ * something they can't act on.
16
+ *
17
+ * Implementation: replace `process.emitWarning` with a thin filter ONLY for
18
+ * the SQLite warning. Every other warning passes through unchanged — we
19
+ * specifically don't want to broadly mute `--no-warnings`-style suppression
20
+ * because that would also hide e.g. import-attributes ExperimentalWarning
21
+ * which IS something we'd want to know about.
22
+ *
23
+ * The filter is idempotent: re-importing the module is a no-op. Must run
24
+ * BEFORE the first `import 'node:sqlite'` anywhere in the process tree;
25
+ * called from `bin/cli.js`, `bin/lib/get-backend.mjs`, and the daemon-
26
+ * backend module loader.
27
+ *
28
+ * @module bin/lib/suppress-sqlite-warning
29
+ */
30
+
31
+ const INSTALLED = Symbol.for('moflo.suppressSqliteWarning.installed');
32
+
33
+ function shouldSuppress(message) {
34
+ if (typeof message === 'string') {
35
+ return message.includes('SQLite is an experimental feature');
36
+ }
37
+ if (message && typeof message === 'object') {
38
+ return typeof message.message === 'string' && message.message.includes('SQLite is an experimental feature');
39
+ }
40
+ return false;
41
+ }
42
+
43
+ if (!globalThis[INSTALLED]) {
44
+ globalThis[INSTALLED] = true;
45
+ const originalEmitWarning = process.emitWarning;
46
+ process.emitWarning = function (warning, ...args) {
47
+ // Two emit signatures: (message, type, code, ctor) and (message, options).
48
+ // Inspect both `warning` and `args[0]` (which is `type` in the legacy
49
+ // form or `options.type` in the new form) before deciding.
50
+ if (shouldSuppress(warning)) return;
51
+ const typeArg = typeof args[0] === 'string'
52
+ ? args[0]
53
+ : (args[0] && typeof args[0] === 'object' ? args[0].type : undefined);
54
+ if (typeArg === 'ExperimentalWarning' && typeof warning === 'string' && warning.includes('SQLite')) return;
55
+ return originalEmitWarning.apply(this, [warning, ...args]);
56
+ };
57
+ }
@@ -15,9 +15,9 @@
15
15
  * @module bin/migrations/knowledge-purge
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync } from 'fs';
19
- import { mofloResolveURL } from '../lib/moflo-resolve.mjs';
18
+ import { existsSync } from 'fs';
20
19
  import { memoryDbPath } from '../lib/moflo-paths.mjs';
20
+ import { openBackend } from '../lib/get-backend.mjs';
21
21
  import { hasMigrationRun } from '../lib/migrations.mjs';
22
22
  import { MIGRATED_FROM_KNOWLEDGE } from './lib/markers.mjs';
23
23
 
@@ -44,11 +44,10 @@ export async function run(projectRoot) {
44
44
  const dbPath = memoryDbPath(projectRoot);
45
45
  if (!existsSync(dbPath)) return { purged: 0, skipped: 0 };
46
46
 
47
- // Lazy-load sql.js — keeps the manifest-stamped no-op path off the WASM
48
- // init cost (~30ms cold).
49
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
50
- const SQL = await initSqlJs();
51
- const db = new SQL.Database(readFileSync(dbPath));
47
+ // Lazy-load via the backend factory — keeps the manifest-stamped no-op
48
+ // path off the WASM init cost (~30ms cold). Engine selection lives in
49
+ // openBackend() (default: node:sqlite as of #1083 Phase 4).
50
+ const db = await openBackend(projectRoot, { create: false });
52
51
 
53
52
  const knowledgeStmt = db.prepare(
54
53
  `SELECT id, key, status FROM memory_entries
@@ -101,7 +100,7 @@ export async function run(projectRoot) {
101
100
  deleteStmt.free();
102
101
  }
103
102
 
104
- if (purged > 0) writeFileSync(dbPath, Buffer.from(db.export()));
103
+ if (purged > 0) db.save();
105
104
  db.close();
106
105
  return { purged, skipped };
107
106
  }
@@ -10,11 +10,10 @@
10
10
  * @module bin/migrations/knowledge-to-learnings
11
11
  */
12
12
 
13
- import { existsSync, readFileSync } from 'fs';
14
- import { writeFileSync } from 'fs';
13
+ import { existsSync } from 'fs';
15
14
  import { randomBytes } from 'crypto';
16
- import { mofloResolveURL } from '../lib/moflo-resolve.mjs';
17
15
  import { memoryDbPath } from '../lib/moflo-paths.mjs';
16
+ import { openBackend } from '../lib/get-backend.mjs';
18
17
  import { MIGRATED_FROM_KNOWLEDGE } from './lib/markers.mjs';
19
18
 
20
19
  export const name = 'knowledge-to-learnings';
@@ -45,11 +44,10 @@ export async function run(projectRoot) {
45
44
  const dbPath = memoryDbPath(projectRoot);
46
45
  if (!existsSync(dbPath)) return { rowsMigrated: 0, rowsSkipped: 0 };
47
46
 
48
- // Lazy-load sql.js — top-level await would pay ~30ms WASM init even on the
49
- // no-op fast-path where the manifest already records this migration as done.
50
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
51
- const SQL = await initSqlJs();
52
- const db = new SQL.Database(readFileSync(dbPath));
47
+ // Lazy-load via the backend factory — top-level await would pay the engine
48
+ // init cost even on the no-op fast-path where the manifest already records
49
+ // this migration as done.
50
+ const db = await openBackend(projectRoot, { create: false });
53
51
 
54
52
  const sourceStmt = db.prepare(
55
53
  `SELECT id, key, content, type, metadata, tags, embedding, embedding_dimensions,
@@ -119,7 +117,7 @@ export async function run(projectRoot) {
119
117
  insertStmt.free();
120
118
  }
121
119
 
122
- if (migrated > 0) writeFileSync(dbPath, Buffer.from(db.export()));
120
+ if (migrated > 0) db.save();
123
121
  db.close();
124
122
  return { rowsMigrated: migrated, rowsSkipped: skipped };
125
123
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Migration: hard-delete every legacy `doc-*` whole-document entry from the
3
+ * guidance namespace. The chunker no longer writes these (#1053 S4) — audit
4
+ * found zero production readers, they duplicated chunk semantic territory,
5
+ * and they ate ~13% of search slots on every query without unique signal.
6
+ *
7
+ * Idempotent: re-runs are no-ops because there will be no `doc-*` rows left.
8
+ *
9
+ * @module bin/migrations/purge-doc-entries
10
+ */
11
+
12
+ import { existsSync } from 'fs';
13
+ import { memoryDbPath } from '../lib/moflo-paths.mjs';
14
+ import { openBackend } from '../lib/get-backend.mjs';
15
+
16
+ export const name = 'purge-doc-entries';
17
+
18
+ /**
19
+ * @param {string} projectRoot
20
+ * @returns {Promise<{purged:number}>}
21
+ */
22
+ export async function run(projectRoot) {
23
+ const dbPath = memoryDbPath(projectRoot);
24
+ if (!existsSync(dbPath)) return { purged: 0 };
25
+
26
+ // Lazy-load via the backend factory — keeps the manifest-stamped no-op
27
+ // path off the WASM init cost (~30ms cold). Engine selection lives in
28
+ // openBackend() (default: node:sqlite as of #1083 Phase 4).
29
+ const db = await openBackend(projectRoot, { create: false });
30
+
31
+ // Scope: every namespace, since both `flo memory index-guidance` and
32
+ // `bin/index-guidance.mjs` historically wrote doc-* across whatever
33
+ // namespace the entry was scoped to (default for guidance: `guidance`).
34
+ // Conservative — match the prefix only, never sweep user-stored keys
35
+ // that happen to start with "doc".
36
+ const countStmt = db.prepare(`SELECT COUNT(*) AS cnt FROM memory_entries WHERE key LIKE 'doc-%'`);
37
+ countStmt.step();
38
+ const beforeCount = Number(countStmt.getAsObject().cnt ?? 0);
39
+ countStmt.free();
40
+
41
+ if (beforeCount === 0) {
42
+ db.close();
43
+ return { purged: 0 };
44
+ }
45
+
46
+ db.run(`DELETE FROM memory_entries WHERE key LIKE 'doc-%'`);
47
+ const purged = db.getRowsModified?.() ?? beforeCount;
48
+
49
+ if (purged > 0) db.save();
50
+ db.close();
51
+ return { purged };
52
+ }