moflo 4.9.6 → 4.9.7

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/bin/index-all.mjs CHANGED
@@ -15,6 +15,16 @@ import { fileURLToPath } from 'url';
15
15
  import { spawn, spawnSync } from 'child_process';
16
16
  import { platform } from 'os';
17
17
  import { hnswIndexPath } from './lib/moflo-paths.mjs';
18
+ import { decideIndexGate, saveFingerprint } from './lib/index-fingerprint.mjs';
19
+
20
+ // Cap fastembed/ONNX thread count when spawning the heavy steps. Without
21
+ // this, ONNX defaults to one thread per CPU core (22+ on a modern dev box),
22
+ // pegging the entire machine while the indexer runs. 2 threads keeps
23
+ // re-embedding throughput acceptable while leaving the box usable.
24
+ const ONNX_THREAD_CAP = {
25
+ OMP_NUM_THREADS: '2',
26
+ ONNXRUNTIME_INTRA_OP_NUM_THREADS: '2',
27
+ };
18
28
 
19
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
30
 
@@ -110,7 +120,7 @@ function killProcessTree(child) {
110
120
  }
111
121
  }
112
122
 
113
- function runStep(label, cmd, args, timeoutMs = 120_000) {
123
+ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
114
124
  return new Promise((resolveStep) => {
115
125
  const start = Date.now();
116
126
  log(`START ${label}`);
@@ -119,6 +129,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000) {
119
129
  stdio: 'ignore',
120
130
  windowsHide: true,
121
131
  detached: platform() !== 'win32', // POSIX: own process group for tree-kill
132
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
122
133
  });
123
134
  let timedOut = false;
124
135
  const timer = setTimeout(() => {
@@ -150,7 +161,19 @@ function runStep(label, cmd, args, timeoutMs = 120_000) {
150
161
 
151
162
  async function main() {
152
163
  const startTime = Date.now();
153
- log('Sequential indexing chain started');
164
+
165
+ // ── Fingerprint gate ─────────────────────────────────────────────────────
166
+ // Skip the entire chain when none of {memory.db, moflo pkg, moflo.yaml,
167
+ // .claude/guidance/} have changed since the last successful run. Without
168
+ // this gate the chain re-embeds + rebuilds HNSW on every Claude session-
169
+ // start even when nothing changed — the customer-visible CPU peg this
170
+ // module exists to fix. Override with FLO_FORCE_INDEX=1.
171
+ const gate = decideIndexGate(projectRoot);
172
+ if (gate.skip) {
173
+ log(`SKIP full chain — ${gate.reason} (no inputs changed since last run)`);
174
+ return;
175
+ }
176
+ log(`Sequential indexing chain started (gate: ${gate.reason})`);
154
177
 
155
178
  // 1. Guidance indexer
156
179
  if (isIndexEnabled('guidance')) {
@@ -211,9 +234,10 @@ async function main() {
211
234
  // 6. Build embeddings — single pass for ALL namespaces, after all indexers finish.
212
235
  // Individual indexers are called with --no-embeddings to prevent background
213
236
  // embedding spawns that race with this chain (sql.js last-write-wins).
237
+ // Thread-capped: fastembed/ONNX would otherwise pin every CPU core.
214
238
  const embeddingsScript = resolveBin('flo-embeddings', 'build-embeddings.mjs');
215
239
  if (embeddingsScript) {
216
- await runStep('build-embeddings', 'node', [embeddingsScript], 300_000);
240
+ await runStep('build-embeddings', 'node', [embeddingsScript], 300_000, ONNX_THREAD_CAP);
217
241
  } else {
218
242
  log('SKIP build-embeddings (script not found)');
219
243
  }
@@ -222,9 +246,10 @@ async function main() {
222
246
  // rebuild-index now also writes the binary HNSW sidecar at
223
247
  // .moflo/hnsw.index, which can take longer than the default 120s on a
224
248
  // populated consumer DB — match build-embeddings' 300s budget.
249
+ // Thread-capped: same fastembed CPU-peg risk as build-embeddings.
225
250
  let hnswOk = true;
226
251
  if (localCli) {
227
- const ok = await runStep('hnsw-rebuild', 'node', [localCli, 'memory', 'rebuild-index', '--force'], 300_000);
252
+ const ok = await runStep('hnsw-rebuild', 'node', [localCli, 'memory', 'rebuild-index', '--force'], 300_000, ONNX_THREAD_CAP);
228
253
  if (ok) {
229
254
  const sidecar = hnswIndexPath(projectRoot);
230
255
  if (!existsSync(sidecar)) {
@@ -243,6 +268,19 @@ async function main() {
243
268
 
244
269
  const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
245
270
  log(`Sequential indexing chain complete (${totalElapsed}s)`);
271
+
272
+ // Save the fingerprint AFTER a successful chain run. If hnsw failed, we
273
+ // intentionally don't save — the next session-start will retry. Save
274
+ // failures are non-fatal (next run will recompute and just re-run the
275
+ // chain, no correctness hazard).
276
+ if (hnswOk) {
277
+ if (saveFingerprint(projectRoot, gate.current)) {
278
+ log('Saved index-all fingerprint for next-session gate');
279
+ } else {
280
+ log('WARN fingerprint save failed (next session will re-run chain)');
281
+ }
282
+ }
283
+
246
284
  if (!hnswOk) process.exit(1);
247
285
  }
248
286
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Fingerprint gate for the session-start indexer chain.
3
+ *
4
+ * The chain `index-all.mjs` runs on every Claude Code session-start. Without
5
+ * a gate it does full indexing + embedding + HNSW work even when nothing has
6
+ * meaningfully changed — pegging the box for several minutes per session.
7
+ *
8
+ * This module computes a small "did anything that matters change" fingerprint
9
+ * and persists it after each successful run. The chain skips entirely when
10
+ * the current fingerprint matches the saved one.
11
+ *
12
+ * Inputs we consider "changes that warrant re-indexing":
13
+ * - `.moflo/moflo.db` mtime → memory writes (new entries, deletes)
14
+ * - `node_modules/moflo/package.json` mtime → moflo upgrade
15
+ * - `moflo.yaml` mtime → config change (auto_index toggles)
16
+ * - `.claude/guidance/**` recursive newest-mtime → shipped or local guidance
17
+ * content changed (the launcher rewrites these on upgrade)
18
+ *
19
+ * Inputs we DON'T consider:
20
+ * - Source files (src/, app/, lib/, etc.) — individual indexers already
21
+ * have incremental detection, so re-running the chain on a source edit
22
+ * would be redundant. If a consumer reports stale codemap on edits,
23
+ * extend the fingerprint in a follow-up. Keeping the set small makes
24
+ * the gate cheap (no broad project tree walks at session-start).
25
+ *
26
+ * Override: set `FLO_FORCE_INDEX=1` in the environment to bypass the gate
27
+ * (useful for `flo doctor --fix` or manual rebuilds).
28
+ *
29
+ * Failure posture:
30
+ * - Errors reading the fingerprint or computing inputs → return null,
31
+ * which forces the chain to run (safe fallback).
32
+ * - Errors saving the fingerprint → don't fail the run; the next run will
33
+ * also recompute and run, no correctness hazard.
34
+ */
35
+
36
+ import { existsSync, readFileSync, writeFileSync, statSync, readdirSync } from 'node:fs';
37
+ import { join, resolve } from 'node:path';
38
+
39
+ export const FINGERPRINT_FILE_REL = '.moflo/index-all-fingerprint.json';
40
+ export const FINGERPRINT_VERSION = 1;
41
+ export const FORCE_ENV = 'FLO_FORCE_INDEX';
42
+
43
+ function safeMtime(path) {
44
+ try { return statSync(path).mtimeMs; } catch { return 0; }
45
+ }
46
+
47
+ /**
48
+ * Newest mtime across all files under `dir`, recursive. Skips dot-dirs and
49
+ * `node_modules` to avoid walking irrelevant trees.
50
+ *
51
+ * Capped depth keeps the cost bounded (.claude/guidance/ has ~2 levels in
52
+ * the wild; cap at 6 for safety).
53
+ */
54
+ function newestMtimeRecursive(dir, depth = 6) {
55
+ if (depth <= 0) return 0;
56
+ let max = 0;
57
+ try {
58
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
59
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
60
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
61
+ const full = join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ const m = newestMtimeRecursive(full, depth - 1);
64
+ if (m > max) max = m;
65
+ } else if (entry.isFile()) {
66
+ const m = safeMtime(full);
67
+ if (m > max) max = m;
68
+ }
69
+ }
70
+ } catch {
71
+ // Unreadable dir — return 0; caller will compare equality and fall through.
72
+ }
73
+ return max;
74
+ }
75
+
76
+ /**
77
+ * Compute the current fingerprint for `projectRoot`. Cheap (a handful of
78
+ * stat() calls + one bounded recursive walk over .claude/guidance/).
79
+ *
80
+ * Returns a flat object of { input → mtimeMs }. Missing files contribute 0,
81
+ * which still produces a stable fingerprint as long as their absence is
82
+ * stable.
83
+ */
84
+ export function computeFingerprint(projectRoot) {
85
+ return {
86
+ memoryDb: safeMtime(resolve(projectRoot, '.moflo/moflo.db')),
87
+ mofloPkg: safeMtime(resolve(projectRoot, 'node_modules/moflo/package.json')),
88
+ mofloYaml: safeMtime(resolve(projectRoot, 'moflo.yaml')),
89
+ guidance: newestMtimeRecursive(resolve(projectRoot, '.claude/guidance')),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Compare two fingerprints by exact equality on every recorded key. Either
95
+ * side being null/undefined returns false — forces a run when the saved
96
+ * record is missing or unparseable.
97
+ */
98
+ export function fingerprintsEqual(a, b) {
99
+ if (!a || !b) return false;
100
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
101
+ for (const k of keys) {
102
+ if (a[k] !== b[k]) return false;
103
+ }
104
+ return true;
105
+ }
106
+
107
+ /**
108
+ * Read the saved fingerprint payload from disk. Returns the bare fingerprint
109
+ * object (not the wrapper), or null if missing/corrupt/version-mismatched.
110
+ */
111
+ export function readSavedFingerprint(projectRoot) {
112
+ const path = resolve(projectRoot, FINGERPRINT_FILE_REL);
113
+ if (!existsSync(path)) return null;
114
+ try {
115
+ const data = JSON.parse(readFileSync(path, 'utf8'));
116
+ if (!data || data.version !== FINGERPRINT_VERSION) return null;
117
+ return data.fingerprint || null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Persist the fingerprint after a successful run. Wrapper carries a version
125
+ * and timestamp so a future schema change can invalidate gracefully.
126
+ *
127
+ * Returns true on success; false on write failure (caller should not fail
128
+ * the run on this).
129
+ */
130
+ export function saveFingerprint(projectRoot, fp) {
131
+ const path = resolve(projectRoot, FINGERPRINT_FILE_REL);
132
+ const payload = {
133
+ version: FINGERPRINT_VERSION,
134
+ savedAt: new Date().toISOString(),
135
+ fingerprint: fp,
136
+ };
137
+ try {
138
+ writeFileSync(path, JSON.stringify(payload, null, 2));
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * High-level gate decision. Returns one of:
147
+ * { skip: true, reason: 'unchanged' }
148
+ * { skip: false, reason: 'forced' | 'no-saved-fingerprint' | 'inputs-changed', current }
149
+ *
150
+ * `current` is included on the run path so the caller can save it after a
151
+ * successful run without recomputing.
152
+ */
153
+ export function decideIndexGate(projectRoot, env = process.env) {
154
+ if (env[FORCE_ENV]) {
155
+ return { skip: false, reason: 'forced', current: computeFingerprint(projectRoot) };
156
+ }
157
+ const current = computeFingerprint(projectRoot);
158
+ const saved = readSavedFingerprint(projectRoot);
159
+ if (!saved) return { skip: false, reason: 'no-saved-fingerprint', current };
160
+ if (fingerprintsEqual(current, saved)) return { skip: true, reason: 'unchanged' };
161
+ return { skip: false, reason: 'inputs-changed', current };
162
+ }
@@ -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.9.6';
5
+ export const VERSION = '4.9.7';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.6",
3
+ "version": "4.9.7",
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",
@@ -81,7 +81,7 @@
81
81
  "@typescript-eslint/eslint-plugin": "^7.18.0",
82
82
  "@typescript-eslint/parser": "^7.18.0",
83
83
  "eslint": "^8.0.0",
84
- "moflo": "^4.9.5",
84
+ "moflo": "^4.9.6",
85
85
  "tsx": "^4.21.0",
86
86
  "typescript": "^5.9.3",
87
87
  "vitest": "^4.0.0"