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 +42 -4
- package/bin/lib/index-fingerprint.mjs +162 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
+
}
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
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.
|
|
84
|
+
"moflo": "^4.9.6",
|
|
85
85
|
"tsx": "^4.21.0",
|
|
86
86
|
"typescript": "^5.9.3",
|
|
87
87
|
"vitest": "^4.0.0"
|