moflo 4.13.1 → 4.13.3
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/guidance/shipped/moflo-yaml-reference.md +4 -2
- package/.claude/helpers/gate.cjs +252 -8
- package/.claude/helpers/prompt-hook.mjs +10 -0
- package/README.md +5 -2
- package/bin/build-embeddings.mjs +6 -6
- package/bin/gate.cjs +252 -8
- package/bin/lib/embedding-backlog.mjs +55 -6
- package/bin/prompt-hook.mjs +10 -0
- package/bin/session-start-launcher.mjs +37 -8
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +5 -17
- package/dist/src/cli/commands/memory.js +7 -3
- package/dist/src/cli/config/moflo-config.js +2 -1
- package/dist/src/cli/init/embedded-helpers.js +2 -2
- package/dist/src/cli/memory/bridge-embedder.js +57 -0
- package/dist/src/cli/services/ephemeral-namespace-purge.js +35 -17
- package/dist/src/cli/services/spell-gate.js +24 -4
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -53,6 +53,18 @@ export const EMBEDDING_MODEL_LEGACY_DEFAULT = 'local';
|
|
|
53
53
|
* - `tasklist` — Spell run records (sp-*) written by spells/core/runner.ts + daemon-dashboard.ts
|
|
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
|
+
* - `swarm-agents`, `swarm-topology`, `swarm-tasks`, `swarm-consensus` —
|
|
57
|
+
* coordinator state written through by `swarm/swarm-persistence.ts` and
|
|
58
|
+
* read back by key on hydrate, never by similarity (#1492). Embed-skip
|
|
59
|
+
* ONLY: they are deliberately absent from the purge sets below, because the
|
|
60
|
+
* swarm must survive an MCP-server restart (Story #806).
|
|
61
|
+
*
|
|
62
|
+
* The rule is enforced at write time AND at backfill time. The writers leave
|
|
63
|
+
* `embedding` NULL; every backfill selector (`bin/build-embeddings.mjs`, the
|
|
64
|
+
* backlog probe in `bin/lib/embedding-backlog.mjs`, `flo memory rebuild-index`)
|
|
65
|
+
* excludes these rows via {@link backfillExclusionSql} or its `bin/` mirror —
|
|
66
|
+
* otherwise NULL reads as "not embedded yet" and the backfill embeds them
|
|
67
|
+
* anyway (#1492). The `bin/` copy is parity-guarded against this set.
|
|
56
68
|
*
|
|
57
69
|
* Membership is also extended by {@link EPHEMERAL_NAMESPACE_PREFIXES} for
|
|
58
70
|
* dynamic-name namespaces (e.g. `doctor-memprobe-<persona>`). Most callers
|
|
@@ -70,6 +82,10 @@ export const EPHEMERAL_NAMESPACES = new Set([
|
|
|
70
82
|
'tasklist',
|
|
71
83
|
'epic-state',
|
|
72
84
|
'test-bridge-fix',
|
|
85
|
+
'swarm-agents',
|
|
86
|
+
'swarm-topology',
|
|
87
|
+
'swarm-tasks',
|
|
88
|
+
'swarm-consensus',
|
|
73
89
|
]);
|
|
74
90
|
/**
|
|
75
91
|
* Prefix patterns that extend {@link EPHEMERAL_NAMESPACES} for namespaces
|
|
@@ -146,6 +162,47 @@ export function isEphemeralNamespace(namespace) {
|
|
|
146
162
|
}
|
|
147
163
|
return false;
|
|
148
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* SQL match for a namespace set: exact `names` or any `prefixes` (as
|
|
167
|
+
* `LIKE 'p%'`). Returned parenthesised with positional bindings so callers can
|
|
168
|
+
* wrap it in `NOT (...)` or use it as a positive filter; an empty set matches
|
|
169
|
+
* nothing.
|
|
170
|
+
*/
|
|
171
|
+
export function namespaceMatchSql(names, prefixes) {
|
|
172
|
+
const exact = [...names];
|
|
173
|
+
const likes = [...prefixes].map((p) => `${p}%`);
|
|
174
|
+
const clauses = [];
|
|
175
|
+
// Bare column, not COALESCE'd, so positive filters (the session-start purge)
|
|
176
|
+
// keep using the namespace index. Negate via `NOT COALESCE(match, 0)`.
|
|
177
|
+
if (exact.length > 0)
|
|
178
|
+
clauses.push(`namespace IN (${exact.map(() => '?').join(', ')})`);
|
|
179
|
+
for (let i = 0; i < likes.length; i++)
|
|
180
|
+
clauses.push('namespace LIKE ?');
|
|
181
|
+
return {
|
|
182
|
+
sql: clauses.length > 0 ? `(${clauses.join(' OR ')})` : '(0)',
|
|
183
|
+
params: [...exact, ...likes],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** {@link namespaceMatchSql} over the ephemeral (embed-skip) namespaces. */
|
|
187
|
+
export function ephemeralNamespaceSql() {
|
|
188
|
+
return namespaceMatchSql(EPHEMERAL_NAMESPACES, EPHEMERAL_NAMESPACE_PREFIXES);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* `AND ...` fragment every embedding backfill appends to its selector (#1492):
|
|
192
|
+
* skip ephemeral namespaces and rows a writer explicitly opted out of
|
|
193
|
+
* (`embedding_model = 'none'`). Both write a NULL `embedding` that means
|
|
194
|
+
* "deliberately unembedded", which the backfill must not read as "pending".
|
|
195
|
+
* Mirrored in `bin/lib/embedding-backlog.mjs` (parity-guarded).
|
|
196
|
+
*/
|
|
197
|
+
export function backfillExclusionSql() {
|
|
198
|
+
const ephemeral = ephemeralNamespaceSql();
|
|
199
|
+
return {
|
|
200
|
+
// COALESCE: `namespace` is nullable and `NOT (NULL IN (...))` is NULL,
|
|
201
|
+
// which would silently drop a NULL-namespace row from the backfill.
|
|
202
|
+
sql: `AND NOT COALESCE(${ephemeral.sql}, 0) AND COALESCE(embedding_model, '') <> ?`,
|
|
203
|
+
params: [...ephemeral.params, EMBEDDING_MODEL_OPT_OUT],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
149
206
|
/**
|
|
150
207
|
* Return `true` if a namespace should be hard-purged on session start —
|
|
151
208
|
* either an exact member of {@link PURGE_ON_SESSION_START_NAMESPACES} or one
|
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
* it on every session start, leaving the tab permanently empty. Trim
|
|
27
27
|
* instead so users see recent history without unbounded growth.
|
|
28
28
|
*
|
|
29
|
+
* 4. **Strip vectors** from surviving rows in any ephemeral namespace
|
|
30
|
+
* ({@link ephemeralNamespaceSql} — `tasklist`, `swarm-*`, …). Their writers
|
|
31
|
+
* leave `embedding` NULL, but until #1492 the background backfill read that
|
|
32
|
+
* NULL as "pending" and embedded them, so run records ranked in ordinary
|
|
33
|
+
* `memory_search` results. The backfill no longer does; this heals the rows
|
|
34
|
+
* every existing install already carries, and re-heals if anything embeds
|
|
35
|
+
* them again. Rows are kept — only their vector is cleared, back to the
|
|
36
|
+
* shape the writer produced. The next index chain drops them from the HNSW
|
|
37
|
+
* sidecar (the DB write invalidates the `hnsw-rebuild` fingerprint).
|
|
38
|
+
*
|
|
29
39
|
* All passes share the file open + final VACUUM + atomic write, so disk I/O
|
|
30
40
|
* is the same as before. Writes back to disk only when something changed.
|
|
31
41
|
*
|
|
@@ -38,7 +48,7 @@
|
|
|
38
48
|
* @module cli/services/ephemeral-namespace-purge
|
|
39
49
|
*/
|
|
40
50
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
41
|
-
import { PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES, TASKLIST_RETENTION_CAP, VERIFY_RECORD_NAMESPACE, VERIFY_RETENTION_CAP, } from '../memory/bridge-embedder.js';
|
|
51
|
+
import { ephemeralNamespaceSql, namespaceMatchSql, PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES, TASKLIST_RETENTION_CAP, VERIFY_RECORD_NAMESPACE, VERIFY_RETENTION_CAP, } from '../memory/bridge-embedder.js';
|
|
42
52
|
import { memoryDbPath } from './moflo-paths.js';
|
|
43
53
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
44
54
|
import { resolveStateRoot } from './project-root.js';
|
|
@@ -56,7 +66,7 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
56
66
|
const fs = await import('fs');
|
|
57
67
|
const path = await import('path');
|
|
58
68
|
const nothingToDo = {
|
|
59
|
-
purged: 0, trimmed: 0, relocated: 0, superseded: 0,
|
|
69
|
+
purged: 0, trimmed: 0, relocated: 0, superseded: 0, stripped: 0,
|
|
60
70
|
};
|
|
61
71
|
const dbPath = path.resolve(options.dbPath ?? memoryDbPath(resolveStateRoot()));
|
|
62
72
|
if (!fs.existsSync(dbPath))
|
|
@@ -77,18 +87,11 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
77
87
|
// Purge match shape: exact namespace IN (...) OR namespace LIKE 'prefix-%'.
|
|
78
88
|
// The prefix clause covers runtime-suffixed namespaces like
|
|
79
89
|
// `doctor-memprobe-<persona>` whose set of suffixes isn't known upfront.
|
|
80
|
-
const namespaces = Array.from(PURGE_ON_SESSION_START_NAMESPACES);
|
|
81
|
-
const prefixes = Array.from(PURGE_ON_SESSION_START_PREFIXES);
|
|
82
90
|
const caps = [
|
|
83
91
|
['tasklist', options.tasklistRetentionCap ?? TASKLIST_RETENTION_CAP],
|
|
84
92
|
[VERIFY_RECORD_NAMESPACE, options.verifyRetentionCap ?? VERIFY_RETENTION_CAP],
|
|
85
93
|
];
|
|
86
|
-
const
|
|
87
|
-
? `namespace IN (${namespaces.map(() => '?').join(', ')})`
|
|
88
|
-
: '0';
|
|
89
|
-
const prefixClause = prefixes.map(() => 'namespace LIKE ?').join(' OR ');
|
|
90
|
-
const purgeWhere = prefixClause ? `(${exactClause} OR ${prefixClause})` : exactClause;
|
|
91
|
-
const purgeBindings = [...namespaces, ...prefixes.map((p) => `${p}%`)];
|
|
94
|
+
const { sql: purgeWhere, params: purgeBindings } = namespaceMatchSql(PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES);
|
|
92
95
|
// GLOB, not LIKE: SQLite's LIKE is case-INSENSITIVE for ASCII, so
|
|
93
96
|
// `key LIKE 'verify:%'` would also sweep a `Verify:...` row that
|
|
94
97
|
// `gate.cjs`'s `record-verify-outcome` — which tests
|
|
@@ -98,6 +101,8 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
98
101
|
// so the only wildcard in the pattern is the trailing `*`.
|
|
99
102
|
const strayVerifyWhere = 'namespace = ? AND key GLOB ?';
|
|
100
103
|
const strayVerifyBindings = [LEARNINGS_NAMESPACE, `${VERIFY_RECORD_NAMESPACE}:*`];
|
|
104
|
+
const ephemeral = ephemeralNamespaceSql();
|
|
105
|
+
const embeddedEphemeralWhere = `${ephemeral.sql} AND embedding IS NOT NULL`;
|
|
101
106
|
// EVERY column needs a distinct alias. `exec` maps each row to an object
|
|
102
107
|
// keyed by column name, so two columns that SQLite names identically
|
|
103
108
|
// collapse into one and silently shift every later index. Unaliased
|
|
@@ -107,11 +112,13 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
107
112
|
const countRows = db.exec(`SELECT
|
|
108
113
|
(SELECT COUNT(*) FROM memory_entries WHERE ${purgeWhere}) AS purgeable,
|
|
109
114
|
(SELECT COUNT(*) FROM memory_entries WHERE ${strayVerifyWhere}) AS relocatable,
|
|
110
|
-
|
|
115
|
+
(SELECT COUNT(*) FROM memory_entries WHERE ${embeddedEphemeralWhere}) AS strippable,
|
|
116
|
+
${caps.map((_, i) => `(SELECT COUNT(*) FROM memory_entries WHERE namespace = ?) AS capTotal${i}`).join(',\n ')}`, [...purgeBindings, ...strayVerifyBindings, ...ephemeral.params, ...caps.map(([ns]) => ns)]);
|
|
111
117
|
const counts = countRows[0]?.values?.[0] ?? [];
|
|
112
118
|
const purgeable = Number(counts[0] ?? 0);
|
|
113
119
|
const relocatable = Number(counts[1] ?? 0);
|
|
114
|
-
const
|
|
120
|
+
const strippable = Number(counts[2] ?? 0);
|
|
121
|
+
const capTotals = caps.map((_, i) => Number(counts[i + 3] ?? 0));
|
|
115
122
|
let purged = 0;
|
|
116
123
|
if (purgeable > 0) {
|
|
117
124
|
db.run(`DELETE FROM memory_entries WHERE ${purgeWhere}`, purgeBindings);
|
|
@@ -136,6 +143,15 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
136
143
|
db.run(`UPDATE memory_entries SET namespace = ? WHERE ${strayVerifyWhere}`, [VERIFY_RECORD_NAMESPACE, ...strayVerifyBindings]);
|
|
137
144
|
relocated = db.getRowsModified?.() ?? 0;
|
|
138
145
|
}
|
|
146
|
+
// After the purge, so rows it already deleted are not counted twice. Model
|
|
147
|
+
// goes back to NULL — the exact shape the ephemeral writers produce.
|
|
148
|
+
let stripped = 0;
|
|
149
|
+
if (strippable > 0) {
|
|
150
|
+
db.run(`UPDATE memory_entries
|
|
151
|
+
SET embedding = NULL, embedding_model = NULL, embedding_dimensions = NULL
|
|
152
|
+
WHERE ${embeddedEphemeralWhere}`, ephemeral.params);
|
|
153
|
+
stripped = db.getRowsModified?.() ?? 0;
|
|
154
|
+
}
|
|
139
155
|
let trimmed = 0;
|
|
140
156
|
for (const [i, [ns, cap]] of caps.entries()) {
|
|
141
157
|
// Rows just relocated into `verify` count toward its cap in this same run.
|
|
@@ -154,16 +170,18 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
154
170
|
)`, [ns, ns, cap]);
|
|
155
171
|
trimmed += db.getRowsModified?.() ?? 0;
|
|
156
172
|
}
|
|
157
|
-
if (purged === 0 && trimmed === 0 && relocated === 0 && superseded === 0)
|
|
173
|
+
if (purged === 0 && trimmed === 0 && relocated === 0 && superseded === 0 && stripped === 0) {
|
|
158
174
|
return nothingToDo;
|
|
175
|
+
}
|
|
159
176
|
// VACUUM only after a DELETE actually freed pages. A relocation is an
|
|
160
|
-
// UPDATE
|
|
161
|
-
//
|
|
162
|
-
//
|
|
177
|
+
// UPDATE that reclaims nothing; a vector strip frees some pages, but later
|
|
178
|
+
// writes reuse them. VACUUMing for either would rewrite the whole file
|
|
179
|
+
// (60+ MB on a populated store) in the foreground of session start for no
|
|
180
|
+
// real benefit. Has to run outside any open transaction; node:sqlite/sql.js
|
|
163
181
|
// both auto-commit each `db.run`, so this is safe to chain.
|
|
164
182
|
if (purged > 0 || trimmed > 0 || superseded > 0)
|
|
165
183
|
db.run('VACUUM');
|
|
166
|
-
return { purged, trimmed, relocated, superseded };
|
|
184
|
+
return { purged, trimmed, relocated, superseded, stripped };
|
|
167
185
|
}
|
|
168
186
|
finally {
|
|
169
187
|
db.close();
|
|
@@ -70,10 +70,18 @@ const DIRECTIVE_PATTERNS = [
|
|
|
70
70
|
/^(also|too|and also)\s+(for|with|on)\b/i,
|
|
71
71
|
/^(what about|how about)\s+(the\s+)?(other|rest|same)\b/i,
|
|
72
72
|
];
|
|
73
|
+
/**
|
|
74
|
+
* Phrased as observations, and each says out loud that the bracket comes from a
|
|
75
|
+
* turn count rather than measured tokens (#1487). This code path has no
|
|
76
|
+
* transcript to read — it is the legacy `npx flo gate prompt-reminder` CLI, not
|
|
77
|
+
* the hook — so the honest thing it can do is not sound like a measurement.
|
|
78
|
+
* Instruction-shaped text the model cannot verify gets obeyed: the old CRITICAL
|
|
79
|
+
* line had agents abandoning work over a nearly empty window.
|
|
80
|
+
*/
|
|
73
81
|
const BRACKET_MESSAGES = {
|
|
74
|
-
MODERATE: 'Context:
|
|
75
|
-
DEPLETED: 'Context:
|
|
76
|
-
CRITICAL: 'Context:
|
|
82
|
+
MODERATE: 'Context: 11-20 turns since session start (turn count, not measured token usage).',
|
|
83
|
+
DEPLETED: 'Context: 21-30 turns since session start (turn count, not measured token usage). Checkpointing progress is worth considering.',
|
|
84
|
+
CRITICAL: 'Context: 30+ turns since session start (turn count, not measured token usage). /compact or a fresh session is worth considering.',
|
|
77
85
|
};
|
|
78
86
|
/** Paths exempt from memory-first gate (they ARE the memory system) */
|
|
79
87
|
const EXEMPT_PATTERNS = [
|
|
@@ -329,8 +337,20 @@ export class GateService {
|
|
|
329
337
|
result.reminder = 'REMINDER: Use TaskCreate before spawning agents.';
|
|
330
338
|
}
|
|
331
339
|
if (this.config.context_tracking) {
|
|
340
|
+
// Edge-triggered (#1487): the old level-triggered form re-emitted the same
|
|
341
|
+
// line every turn once past the threshold, with no reset anywhere, so it
|
|
342
|
+
// read as a standing instruction long after it stopped being true.
|
|
343
|
+
//
|
|
344
|
+
// Derived from the counter rather than remembered in a field, deliberately.
|
|
345
|
+
// This file and bin/gate.cjs write the SAME .claude/workflow-state.json,
|
|
346
|
+
// and gate.cjs owns `contextBand` with a richer vocabulary (`tokens:400000`
|
|
347
|
+
// as well as the brackets). A second writer with an incompatible vocabulary
|
|
348
|
+
// would silently invalidate gate.cjs's edge-trigger on any project wired to
|
|
349
|
+
// both. Comparing this turn's bracket with the previous turn's is exactly
|
|
350
|
+
// the same edge, needs no state, and cannot collide.
|
|
332
351
|
const bracket = this.getContextBracket(state.interactionCount);
|
|
333
|
-
|
|
352
|
+
const previous = this.getContextBracket(state.interactionCount - 1);
|
|
353
|
+
if (bracket !== 'FRESH' && bracket !== previous) {
|
|
334
354
|
result.bracket = BRACKET_MESSAGES[bracket];
|
|
335
355
|
}
|
|
336
356
|
}
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.13.
|
|
3
|
+
"version": "4.13.3",
|
|
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",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@typescript-eslint/parser": "^8.65.0",
|
|
100
100
|
"eslint": "^10.8.0",
|
|
101
101
|
"glob": "^11.1.0",
|
|
102
|
-
"moflo": "^4.13.
|
|
102
|
+
"moflo": "^4.13.2",
|
|
103
103
|
"tsx": "^4.21.0",
|
|
104
104
|
"typescript": "^5.9.3",
|
|
105
105
|
"vitest": "^4.0.0"
|