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.
@@ -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 exactClause = namespaces.length
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
- ${caps.map((_, i) => `(SELECT COUNT(*) FROM memory_entries WHERE namespace = ?) AS capTotal${i}`).join(',\n ')}`, [...purgeBindings, ...strayVerifyBindings, ...caps.map(([ns]) => ns)]);
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 capTotals = caps.map((_, i) => Number(counts[i + 2] ?? 0));
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 — it reclaims nothing, so VACUUMing for it would rewrite the whole
161
- // file (60+ MB on a populated store) in the foreground of session start for
162
- // no benefit. Has to run outside any open transaction; node:sqlite/sql.js
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: MODERATE. Re-state goal before architectural decisions. Use agents for >300 LOC.',
75
- DEPLETED: 'Context: DEPLETED. Checkpoint progress. Recommend /compact or fresh session.',
76
- CRITICAL: 'Context: CRITICAL. Stop accepting complex tasks. Commit, store learnings, suggest new session.',
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
- if (bracket !== 'FRESH') {
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
  }
@@ -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.13.1';
5
+ export const VERSION = '4.13.3';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.13.1",
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.0",
102
+ "moflo": "^4.13.2",
103
103
  "tsx": "^4.21.0",
104
104
  "typescript": "^5.9.3",
105
105
  "vitest": "^4.0.0"