moflo 4.13.2 → 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/bin/build-embeddings.mjs +6 -6
- package/bin/lib/embedding-backlog.mjs +55 -6
- package/bin/session-start-launcher.mjs +6 -0
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +5 -17
- package/dist/src/cli/commands/memory.js +7 -3
- 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/version.js +1 -1
- package/package.json +2 -2
package/bin/build-embeddings.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
20
20
|
import { mofloInternalURL } from './lib/moflo-resolve.mjs';
|
|
21
21
|
import { memoryDbPath, hnswIndexPath, findProjectRoot } from './lib/moflo-paths.mjs';
|
|
22
22
|
import { openBackend } from './lib/get-backend.mjs';
|
|
23
|
-
import { PENDING_EMBEDDING_WHERE } from './lib/embedding-backlog.mjs';
|
|
23
|
+
import { DELIBERATELY_UNEMBEDDED_WHERE, EMBEDDABLE_WHERE, PENDING_EMBEDDING_WHERE } from './lib/embedding-backlog.mjs';
|
|
24
24
|
const FASTEMBED_INLINE = 'dist/src/cli/embeddings/fastembed-inline/index.js';
|
|
25
25
|
const BRIDGE_CORE = 'dist/src/cli/memory/bridge-core.js';
|
|
26
26
|
const HNSW_PERSISTENCE = 'dist/src/cli/memory/hnsw-persistence.js';
|
|
@@ -100,12 +100,12 @@ function saveDb(db) {
|
|
|
100
100
|
// The backlog clause is imported, not restated (#1383): the indexer gate asks
|
|
101
101
|
// this exact question to decide whether to run this script at all, and a gate
|
|
102
102
|
// that approximates its step's own query is how new chunks got left unembedded
|
|
103
|
-
// in the first place.
|
|
104
|
-
//
|
|
105
|
-
//
|
|
103
|
+
// in the first place. Both clauses carry `status = 'active'` and exclude rows
|
|
104
|
+
// deliberately left unembedded (ephemeral namespaces, opt-outs — #1492), so
|
|
105
|
+
// `--force` re-embeds everything embeddable without resurrecting those.
|
|
106
106
|
function getEntriesNeedingEmbeddings(db, namespace, forceAll) {
|
|
107
107
|
let sql = `SELECT id, key, namespace, content FROM memory_entries WHERE `
|
|
108
|
-
+ (forceAll ?
|
|
108
|
+
+ (forceAll ? EMBEDDABLE_WHERE : PENDING_EMBEDDING_WHERE);
|
|
109
109
|
const params = [];
|
|
110
110
|
|
|
111
111
|
if (namespace) {
|
|
@@ -136,7 +136,7 @@ function getNamespaceStats(db) {
|
|
|
136
136
|
namespace,
|
|
137
137
|
COUNT(*) as total,
|
|
138
138
|
SUM(CASE WHEN embedding IS NOT NULL AND embedding != '' THEN 1 ELSE 0 END) as vectorized,
|
|
139
|
-
SUM(CASE WHEN embedding IS NULL OR embedding = '' THEN 1 ELSE 0 END) as missing
|
|
139
|
+
SUM(CASE WHEN (embedding IS NULL OR embedding = '') AND NOT ${DELIBERATELY_UNEMBEDDED_WHERE} THEN 1 ELSE 0 END) as missing
|
|
140
140
|
FROM memory_entries
|
|
141
141
|
WHERE status = 'active'
|
|
142
142
|
GROUP BY namespace
|
|
@@ -26,16 +26,65 @@ import { existsSync } from 'node:fs';
|
|
|
26
26
|
import { openBackendSync } from './get-backend.mjs';
|
|
27
27
|
import { memoryDbPath } from './moflo-paths.mjs';
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* Rows `build-embeddings` will pick up on its next run. Active rows whose
|
|
31
|
-
* embedding is absent — either NULL (never written) or empty string (a
|
|
32
|
-
* producer that failed and wrote a placeholder).
|
|
33
|
-
*/
|
|
34
29
|
/** Retry budget for the probe's read-only open. See {@link hasPendingEmbeddings}. */
|
|
35
30
|
const PROBE_BUSY_TIMEOUT_MS = 2000;
|
|
36
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Mirrors of `EPHEMERAL_NAMESPACES` / `EPHEMERAL_NAMESPACE_PREFIXES` /
|
|
34
|
+
* `EMBEDDING_MODEL_OPT_OUT` in `src/cli/memory/bridge-embedder.ts`. `bin/`
|
|
35
|
+
* cannot import TypeScript source, so the values are restated here and
|
|
36
|
+
* `tests/bin/embedding-backfill-ephemeral-1492.test.ts` fails if the two
|
|
37
|
+
* copies drift.
|
|
38
|
+
*/
|
|
39
|
+
export const EPHEMERAL_NAMESPACES = Object.freeze([
|
|
40
|
+
'hive-mind',
|
|
41
|
+
'tasklist',
|
|
42
|
+
'epic-state',
|
|
43
|
+
'test-bridge-fix',
|
|
44
|
+
'swarm-agents',
|
|
45
|
+
'swarm-topology',
|
|
46
|
+
'swarm-tasks',
|
|
47
|
+
'swarm-consensus',
|
|
48
|
+
]);
|
|
49
|
+
export const EPHEMERAL_NAMESPACE_PREFIXES = Object.freeze([]);
|
|
50
|
+
export const EMBEDDING_MODEL_OPT_OUT = 'none';
|
|
51
|
+
|
|
52
|
+
const sqlString = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rows written deliberately without an embedding — ephemeral run state and
|
|
56
|
+
* explicit opt-outs. The writers leave `embedding` NULL for these, which is
|
|
57
|
+
* indistinguishable from "not embedded yet" unless every backfill excludes
|
|
58
|
+
* them (#1492). Inlined literals rather than bindings so the clause drops into
|
|
59
|
+
* any statement unchanged; every value is a compile-time constant above.
|
|
60
|
+
*/
|
|
61
|
+
export const DELIBERATELY_UNEMBEDDED_WHERE = (() => {
|
|
62
|
+
const clauses = [];
|
|
63
|
+
if (EPHEMERAL_NAMESPACES.length > 0) {
|
|
64
|
+
clauses.push(`namespace IN (${EPHEMERAL_NAMESPACES.map(sqlString).join(', ')})`);
|
|
65
|
+
}
|
|
66
|
+
for (const prefix of EPHEMERAL_NAMESPACE_PREFIXES) clauses.push(`namespace LIKE ${sqlString(`${prefix}%`)}`);
|
|
67
|
+
clauses.push(`COALESCE(embedding_model, '') = ${sqlString(EMBEDDING_MODEL_OPT_OUT)}`);
|
|
68
|
+
// COALESCE to a strict 0/1: `namespace` is nullable and `NULL IN (...)` is
|
|
69
|
+
// NULL, so every negated use below would silently drop a NULL-namespace row.
|
|
70
|
+
return `COALESCE((${clauses.join(' OR ')}), 0)`;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every row a backfill may embed: active, and not deliberately unembedded.
|
|
75
|
+
* `build-embeddings --force` selects on this alone.
|
|
76
|
+
*/
|
|
77
|
+
export const EMBEDDABLE_WHERE = `status = 'active' AND NOT ${DELIBERATELY_UNEMBEDDED_WHERE}`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rows `build-embeddings` will pick up on its next run. Embeddable rows whose
|
|
81
|
+
* embedding is absent — either NULL (never written) or empty string (a
|
|
82
|
+
* producer that failed and wrote a placeholder). The embedding test leads so
|
|
83
|
+
* the steady-state probe — nearly every row embedded — rejects each row before
|
|
84
|
+
* evaluating the exclusion.
|
|
85
|
+
*/
|
|
37
86
|
export const PENDING_EMBEDDING_WHERE =
|
|
38
|
-
`
|
|
87
|
+
`(embedding IS NULL OR embedding = '') AND ${EMBEDDABLE_WHERE}`;
|
|
39
88
|
|
|
40
89
|
/**
|
|
41
90
|
* Does the memory DB hold rows that still need embedding?
|
|
@@ -2438,6 +2438,12 @@ try {
|
|
|
2438
2438
|
`${plural(result.superseded, 'stale learnings row')} whose verdict already exists in the verify namespace`,
|
|
2439
2439
|
);
|
|
2440
2440
|
}
|
|
2441
|
+
if (result?.stripped > 0) {
|
|
2442
|
+
emitMutation(
|
|
2443
|
+
'unindexed run records',
|
|
2444
|
+
`${plural(result.stripped, 'row')} of run state removed from semantic search`,
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2441
2447
|
}
|
|
2442
2448
|
} catch (err) {
|
|
2443
2449
|
// Non-fatal — leftover rows just sit until the next session retries.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* row on the canonical label; this check verifies it actually did.
|
|
18
18
|
*
|
|
19
19
|
* Story #729 carve-out: ephemeral-namespace rows (tasklist, hive-mind,
|
|
20
|
-
* epic-state,
|
|
20
|
+
* epic-state, swarm-*, …, plus EPHEMERAL_NAMESPACE_PREFIXES) are
|
|
21
21
|
* intentionally written with `embedding IS NULL AND embedding_model IS
|
|
22
22
|
* NULL`. They are excluded from the count so they don't trip branch (4)
|
|
23
23
|
* "unrecognised embedding_model" on every publish — see bridge-embedder.ts
|
|
@@ -33,7 +33,7 @@ import { existsSync } from 'fs';
|
|
|
33
33
|
import { CANONICAL_EMBEDDING_MODEL } from '../embeddings/migration/types.js';
|
|
34
34
|
import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
|
|
35
35
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
36
|
-
import {
|
|
36
|
+
import { ephemeralNamespaceSql } from '../memory/bridge-embedder.js';
|
|
37
37
|
import { resolveStateRoot } from '../services/project-root.js';
|
|
38
38
|
/**
|
|
39
39
|
* Known neural-model labels that all share the all-MiniLM-L6-v2 384-dim
|
|
@@ -183,21 +183,9 @@ async function loadModelGroups(dbPath) {
|
|
|
183
183
|
// embedding_model IS NULL`. Without this exclusion every spell run that
|
|
184
184
|
// logs to `tasklist` re-trips branch (4) "unrecognised embedding_model"
|
|
185
185
|
// on the next publish, even though the writer is doing the right thing.
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const params = [];
|
|
190
|
-
if (ephemeralNames.length > 0) {
|
|
191
|
-
matchClauses.push(`namespace IN (${ephemeralNames.map(() => '?').join(', ')})`);
|
|
192
|
-
params.push(...ephemeralNames);
|
|
193
|
-
}
|
|
194
|
-
for (const prefix of ephemeralPrefixes) {
|
|
195
|
-
matchClauses.push(`namespace LIKE ?`);
|
|
196
|
-
params.push(`${prefix}%`);
|
|
197
|
-
}
|
|
198
|
-
const ephemeralExclusion = matchClauses.length > 0
|
|
199
|
-
? `AND NOT (embedding IS NULL AND embedding_model IS NULL AND (${matchClauses.join(' OR ')}))`
|
|
200
|
-
: '';
|
|
186
|
+
const ephemeral = ephemeralNamespaceSql();
|
|
187
|
+
const params = ephemeral.params;
|
|
188
|
+
const ephemeralExclusion = `AND NOT (embedding IS NULL AND embedding_model IS NULL AND ${ephemeral.sql})`;
|
|
201
189
|
const sql = `SELECT
|
|
202
190
|
COALESCE(embedding_model, 'NULL') AS model,
|
|
203
191
|
COUNT(*) AS n,
|
|
@@ -1888,9 +1888,13 @@ const rebuildIndexCommand = {
|
|
|
1888
1888
|
output.writeln(output.bold('Rebuilding Embedding Index'));
|
|
1889
1889
|
output.writeln(output.dim('─'.repeat(50)));
|
|
1890
1890
|
const { db, dbPath } = await openDb(cwd);
|
|
1891
|
-
// Build query
|
|
1892
|
-
|
|
1893
|
-
|
|
1891
|
+
// Build query. Rows deliberately left unembedded (ephemeral namespaces,
|
|
1892
|
+
// explicit opt-outs) are excluded even under --force: NULL there means
|
|
1893
|
+
// "never embed", not "not embedded yet" (#1492).
|
|
1894
|
+
const { backfillExclusionSql } = await import('../memory/bridge-embedder.js');
|
|
1895
|
+
const exclusion = backfillExclusionSql();
|
|
1896
|
+
let sql = `SELECT id, key, namespace, content FROM memory_entries WHERE status = 'active' ${exclusion.sql}`;
|
|
1897
|
+
const params = [...exclusion.params];
|
|
1894
1898
|
if (!forceAll) {
|
|
1895
1899
|
sql += ` AND (embedding IS NULL OR embedding = '')`;
|
|
1896
1900
|
}
|
|
@@ -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();
|
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"
|