moflo 4.13.2 → 4.13.4
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 +27 -0
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +5 -17
- package/dist/src/cli/commands/memory-audit-learnings.js +5 -39
- package/dist/src/cli/commands/memory.js +12 -3
- package/dist/src/cli/memory/bridge-embedder.js +57 -0
- package/dist/src/cli/memory/learnings-audit-state.js +53 -0
- package/dist/src/cli/memory/learnings-audit.js +16 -1
- package/dist/src/cli/services/durable-key-rules.js +70 -0
- package/dist/src/cli/services/durable-store-io.js +32 -2
- package/dist/src/cli/services/durable-sync.js +21 -6
- package/dist/src/cli/services/ephemeral-namespace-purge.js +93 -38
- package/dist/src/cli/services/snapshot-restore.js +1 -1
- package/dist/src/cli/services/team-artifact-sync.js +26 -7
- 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?
|
|
@@ -144,6 +144,13 @@ function emitWarning(message) {
|
|
|
144
144
|
process.stderr.write(`moflo: ${message}\n`);
|
|
145
145
|
} catch { /* stderr write must not throw */ }
|
|
146
146
|
}
|
|
147
|
+
// Advisory stdout line for a condition the user can act on but the launcher
|
|
148
|
+
// deliberately did not change. Not counted in `mutationCount` — nothing mutated.
|
|
149
|
+
function emitNotice(message) {
|
|
150
|
+
try {
|
|
151
|
+
process.stdout.write(`moflo: ${message}\n`);
|
|
152
|
+
} catch { /* writing must never throw */ }
|
|
153
|
+
}
|
|
147
154
|
function errMessage(err) {
|
|
148
155
|
return err && err.message ? err.message : String(err);
|
|
149
156
|
}
|
|
@@ -2438,6 +2445,20 @@ try {
|
|
|
2438
2445
|
`${plural(result.superseded, 'stale learnings row')} whose verdict already exists in the verify namespace`,
|
|
2439
2446
|
);
|
|
2440
2447
|
}
|
|
2448
|
+
if (result?.stripped > 0) {
|
|
2449
|
+
emitMutation(
|
|
2450
|
+
'unindexed run records',
|
|
2451
|
+
`${plural(result.stripped, 'row')} of run state removed from semantic search`,
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
// Report-only (#1495): key shape is a heuristic, so these are never moved
|
|
2455
|
+
// automatically — the audit nominates them for a model verdict.
|
|
2456
|
+
if (result?.runSummaries > 0) {
|
|
2457
|
+
emitNotice(
|
|
2458
|
+
`${result.runSummaries} learning${result.runSummaries === 1 ? ' looks' : 's look'} like per-ticket run summaries, not lessons ` +
|
|
2459
|
+
'— review with `flo memory audit-learnings`',
|
|
2460
|
+
);
|
|
2461
|
+
}
|
|
2441
2462
|
}
|
|
2442
2463
|
} catch (err) {
|
|
2443
2464
|
// Non-fatal — leftover rows just sit until the next session retries.
|
|
@@ -2521,6 +2542,12 @@ try {
|
|
|
2521
2542
|
`${plural(result.flushedToShared, 'durable entry')} pushed to the shared store`,
|
|
2522
2543
|
);
|
|
2523
2544
|
}
|
|
2545
|
+
if (result?.healedShared > 0) {
|
|
2546
|
+
emitMutation(
|
|
2547
|
+
'removed verify records from the shared store',
|
|
2548
|
+
`${plural(result.healedShared, 'row')} that are not learnings`,
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2524
2551
|
}
|
|
2525
2552
|
} catch (err) {
|
|
2526
2553
|
// Non-fatal — durable sync reconciles on the next session start.
|
|
@@ -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,
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
* @module commands/memory-audit-learnings
|
|
24
24
|
*/
|
|
25
25
|
import * as fs from 'fs';
|
|
26
|
-
import * as pathModule from 'path';
|
|
27
26
|
import { spawn } from 'child_process';
|
|
28
27
|
import { output } from '../output.js';
|
|
29
28
|
import { confirm } from '../prompt.js';
|
|
@@ -33,14 +32,13 @@ import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
|
33
32
|
import { resolveBridgeDbPath } from '../memory/bridge-core.js';
|
|
34
33
|
import { findProjectRoot } from '../services/project-root.js';
|
|
35
34
|
import { hasMemoryEntriesTable } from '../services/cherry-pick-learnings.js';
|
|
36
|
-
import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
|
|
37
35
|
import { hashContent } from '../memory/auto-memory-bridge.js';
|
|
38
36
|
import { listWorkspacePrefixes, makeTreeResolver } from '../memory/learnings-tree.js';
|
|
39
37
|
import { LEARNINGS_NAMESPACE, buildAuditPlan, buildJudgePrompt, parseVerdicts, selectArchivable, selectManualActions, DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_JUDGE_LIMIT, DEFAULT_UNUSED_LIMIT, DEFAULT_UNUSED_MIN_AGE_MS, } from '../memory/learnings-audit.js';
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
// The verdict record moved to `memory/learnings-audit-state.ts` so the
|
|
39
|
+
// session-start purge can read it (#1495); re-exported for existing callers.
|
|
40
|
+
export { AUDIT_STATE_FILE, readAuditState, writeAuditState } from '../memory/learnings-audit-state.js';
|
|
41
|
+
import { readAuditState, writeAuditState } from '../memory/learnings-audit-state.js';
|
|
44
42
|
/** Cheap formatter/judge model — same tier the auto-meditate distill runs on. */
|
|
45
43
|
const JUDGE_MODEL_ID = 'claude-haiku-4-5-20251001';
|
|
46
44
|
/** Hard ceiling on the headless judge; killed past this. */
|
|
@@ -54,39 +52,6 @@ const JUDGE_ALLOWED_TOOLS = 'Read';
|
|
|
54
52
|
* Claude CLI on PATH.
|
|
55
53
|
*/
|
|
56
54
|
export const JUDGE_STUB_ENV = 'MOFLO_AUDIT_LEARNINGS_NODE_STUB';
|
|
57
|
-
function stateFilePath(projectRoot) {
|
|
58
|
-
return pathModule.join(projectRoot, '.moflo', AUDIT_STATE_FILE);
|
|
59
|
-
}
|
|
60
|
-
/** Read recorded verdicts. Any unreadable or stale-version file reads as empty. */
|
|
61
|
-
export function readAuditState(projectRoot) {
|
|
62
|
-
try {
|
|
63
|
-
const raw = fs.readFileSync(stateFilePath(projectRoot), 'utf-8');
|
|
64
|
-
const parsed = JSON.parse(raw);
|
|
65
|
-
if (parsed?.version !== AUDIT_STATE_VERSION || !parsed.decided)
|
|
66
|
-
return new Map();
|
|
67
|
-
return new Map(Object.entries(parsed.decided));
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
// Absent, truncated, or hand-edited into invalid JSON. Losing the record
|
|
71
|
-
// costs one re-judgement; refusing to run over it costs the command.
|
|
72
|
-
return new Map();
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Write the verdict record back.
|
|
77
|
-
*
|
|
78
|
-
* Read-modify-write with no lock: two `--apply` runs racing on the same project
|
|
79
|
-
* would lose one run's verdicts. Not worth a lock — this is a hand-invoked
|
|
80
|
-
* curation command, the loss costs one re-judgement, and `atomicWriteFileSync`
|
|
81
|
-
* already rules out a torn file (its temp name is pid- and random-suffixed, so
|
|
82
|
-
* concurrent writers cannot clobber each other's staging file either).
|
|
83
|
-
*/
|
|
84
|
-
export function writeAuditState(projectRoot, decided) {
|
|
85
|
-
const file = stateFilePath(projectRoot);
|
|
86
|
-
fs.mkdirSync(pathModule.dirname(file), { recursive: true });
|
|
87
|
-
const payload = { version: AUDIT_STATE_VERSION, decided: Object.fromEntries(decided) };
|
|
88
|
-
atomicWriteFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
89
|
-
}
|
|
90
55
|
/**
|
|
91
56
|
* Parse a stored embedding. A malformed vector reads as absent rather than
|
|
92
57
|
* throwing — one bad row must not take the whole audit down.
|
|
@@ -222,6 +187,7 @@ function printPlan(plan, deadPathsScanned) {
|
|
|
222
187
|
{ bucket: 'Unused and old', count: plan.counts.unused },
|
|
223
188
|
{ bucket: 'Superseded vocabulary', count: plan.counts.superseded },
|
|
224
189
|
{ bucket: 'Dead path reference', count: plan.counts.deadPath },
|
|
190
|
+
{ bucket: 'Run summary (ticket-shaped key)', count: plan.counts.runSummary },
|
|
225
191
|
{ bucket: output.bold('To judge'), count: output.bold(String(plan.candidates.length)) },
|
|
226
192
|
],
|
|
227
193
|
});
|
|
@@ -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
|
}
|
|
@@ -2537,6 +2541,8 @@ const teamExportCommand = {
|
|
|
2537
2541
|
changes.push(`${report.deleted} retired`);
|
|
2538
2542
|
if (report.resurrected > 0)
|
|
2539
2543
|
changes.push(`${report.resurrected} restored`);
|
|
2544
|
+
if (report.droppedMisfiled > 0)
|
|
2545
|
+
changes.push(`${report.droppedMisfiled} verify record${report.droppedMisfiled === 1 ? '' : 's'} removed (not learnings, #1495)`);
|
|
2540
2546
|
if (changes.length > 0)
|
|
2541
2547
|
output.printInfo(`Also propagated: ${changes.join(', ')}.`);
|
|
2542
2548
|
if (report.keptRemote > 0) {
|
|
@@ -2612,6 +2618,9 @@ const teamImportCommand = {
|
|
|
2612
2618
|
if (report.skippedCorrupt > 0) {
|
|
2613
2619
|
output.printWarning(`${report.skippedCorrupt} artifact line${report.skippedCorrupt === 1 ? '' : 's'} NOT imported — captured tool-call markup in the content (#1467).`);
|
|
2614
2620
|
}
|
|
2621
|
+
if (report.skippedMisfiled > 0) {
|
|
2622
|
+
output.printInfo(`${report.skippedMisfiled} verify record${report.skippedMisfiled === 1 ? '' : 's'} in the artifact ignored (not learnings, #1495) — \`flo memory team-export\` removes them.`);
|
|
2623
|
+
}
|
|
2615
2624
|
if (report.skippedNonDurable > 0) {
|
|
2616
2625
|
output.printWarning(`${report.skippedNonDurable} non-durable entr${report.skippedNonDurable === 1 ? 'y' : 'ies'} skipped (only learnings/knowledge are shared).`);
|
|
2617
2626
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verdict record `flo memory audit-learnings --apply` keeps between runs
|
|
3
|
+
* (#1466), at `.moflo/learnings-audit.json`.
|
|
4
|
+
*
|
|
5
|
+
* Carved out of the command so the session-start purge can read it without
|
|
6
|
+
* importing CLI command machinery: the purge's run-summary count (#1495) skips
|
|
7
|
+
* keys a human already judged, so the notice clears once the store is curated.
|
|
8
|
+
*
|
|
9
|
+
* Local-only — never part of the shared artifact.
|
|
10
|
+
*
|
|
11
|
+
* @module memory/learnings-audit-state
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
|
|
16
|
+
/** Where recorded verdicts live, under `.moflo/`. */
|
|
17
|
+
export const AUDIT_STATE_FILE = 'learnings-audit.json';
|
|
18
|
+
/** Bump when the record shape changes; an older file is discarded, not migrated. */
|
|
19
|
+
const AUDIT_STATE_VERSION = 1;
|
|
20
|
+
function stateFilePath(projectRoot) {
|
|
21
|
+
return path.join(projectRoot, '.moflo', AUDIT_STATE_FILE);
|
|
22
|
+
}
|
|
23
|
+
/** Read recorded verdicts. Any unreadable or stale-version file reads as empty. */
|
|
24
|
+
export function readAuditState(projectRoot) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = fs.readFileSync(stateFilePath(projectRoot), 'utf-8');
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (parsed?.version !== AUDIT_STATE_VERSION || !parsed.decided)
|
|
29
|
+
return new Map();
|
|
30
|
+
return new Map(Object.entries(parsed.decided));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Absent, truncated, or hand-edited into invalid JSON. Losing the record
|
|
34
|
+
// costs one re-judgement; refusing to run over it costs the command.
|
|
35
|
+
return new Map();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Write the verdict record back.
|
|
40
|
+
*
|
|
41
|
+
* Read-modify-write with no lock: two `--apply` runs racing on the same project
|
|
42
|
+
* would lose one run's verdicts. Not worth a lock — this is a hand-invoked
|
|
43
|
+
* curation command, the loss costs one re-judgement, and `atomicWriteFileSync`
|
|
44
|
+
* already rules out a torn file (its temp name is pid- and random-suffixed, so
|
|
45
|
+
* concurrent writers cannot clobber each other's staging file either).
|
|
46
|
+
*/
|
|
47
|
+
export function writeAuditState(projectRoot, decided) {
|
|
48
|
+
const file = stateFilePath(projectRoot);
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
const payload = { version: AUDIT_STATE_VERSION, decided: Object.fromEntries(decided) };
|
|
51
|
+
atomicWriteFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=learnings-audit-state.js.map
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
// audit's public surface — a caller configuring the pass should not have to
|
|
34
34
|
// know which file the detector was carved into.
|
|
35
35
|
import { findDeadPaths } from './learnings-dead-paths.js';
|
|
36
|
+
import { isRunSummaryKey, LEARNINGS_NAMESPACE } from '../services/durable-key-rules.js';
|
|
36
37
|
export { DEFAULT_DEAD_PATHS_PER_ENTRY, extractCandidatePaths, findDeadPaths, resolvesInTree, } from './learnings-dead-paths.js';
|
|
37
38
|
/** The namespace this audit is scoped to. */
|
|
38
|
-
export
|
|
39
|
+
export { LEARNINGS_NAMESPACE };
|
|
39
40
|
export const AUDIT_VERDICTS = ['KEEP', 'RETIRE', 'COMPRESS', 'MERGE'];
|
|
40
41
|
/**
|
|
41
42
|
* Retired vocabulary. **Ships empty, and a guard test keeps it that way.**
|
|
@@ -238,11 +239,18 @@ export function buildAuditPlan(rows, options = {}) {
|
|
|
238
239
|
const candidate = nominate(hit.row, 'dead-path');
|
|
239
240
|
candidate.deadPaths = hit.deadPaths;
|
|
240
241
|
}
|
|
242
|
+
// A run summary is git history stored as a learning (#1495). The key shape
|
|
243
|
+
// only nominates — a lesson can carry its ticket number as provenance, which
|
|
244
|
+
// is the case the model verdict tells apart.
|
|
245
|
+
const runSummaries = pending.filter((row) => isRunSummaryKey(row.key));
|
|
246
|
+
for (const row of runSummaries)
|
|
247
|
+
nominate(row, 'run-summary');
|
|
241
248
|
const counts = {
|
|
242
249
|
duplicate: duplicates.length,
|
|
243
250
|
unused: unused.length,
|
|
244
251
|
superseded: superseded.length,
|
|
245
252
|
deadPath: deadPaths.length,
|
|
253
|
+
runSummary: runSummaries.length,
|
|
246
254
|
};
|
|
247
255
|
// Most-nominated first, then oldest — an entry three passes agree on is the
|
|
248
256
|
// one a bounded prompt should spend its budget on.
|
|
@@ -273,6 +281,9 @@ function describeBuckets(candidate) {
|
|
|
273
281
|
else if (bucket === 'dead-path') {
|
|
274
282
|
parts.push(`cites path(s) that resolve nowhere in the tree: ${(candidate.deadPaths ?? []).join(', ')}`);
|
|
275
283
|
}
|
|
284
|
+
else if (bucket === 'run-summary') {
|
|
285
|
+
parts.push('key is shaped like a per-ticket run summary');
|
|
286
|
+
}
|
|
276
287
|
else {
|
|
277
288
|
const terms = (candidate.supersededTerms ?? [])
|
|
278
289
|
.map((t) => `"${t.from}" → "${t.to}"`)
|
|
@@ -339,6 +350,10 @@ export function buildJudgePrompt(candidates, now = Date.now()) {
|
|
|
339
350
|
'| Deleted, but the lesson generalises past it | COMPRESS — drop the path, keep the rule |',
|
|
340
351
|
'| The entry is a historical record, correct as written | KEEP |',
|
|
341
352
|
'',
|
|
353
|
+
'A run-summary flag means the key looks like a ticket record ("flo-123-...", "...-done").',
|
|
354
|
+
'What one ticket changed is git history, not a lesson: RETIRE it unless the content states a',
|
|
355
|
+
'rule that would help a DIFFERENT task, in which case COMPRESS it down to that rule.',
|
|
356
|
+
'',
|
|
342
357
|
`Answer with exactly ${candidates.length} line(s), nothing else. One line per entry:`,
|
|
343
358
|
'',
|
|
344
359
|
'<key><TAB><VERDICT><TAB><reason in under 15 words>',
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows that must never live in a durable namespace, whichever store they are in
|
|
3
|
+
* (#1495).
|
|
4
|
+
*
|
|
5
|
+
* #1375 moved `verify:*` verdict records out of `learnings`, but only in the
|
|
6
|
+
* local `.moflo/moflo.db`. The same rows still sat in the two durable copies —
|
|
7
|
+
* the worktree shared store (`<git-common-dir>/moflo/durable.db`) and the team
|
|
8
|
+
* JSONL artifact — and the session-start seed and import wrote them straight
|
|
9
|
+
* back. The cleanup ran on every session and never converged, because each
|
|
10
|
+
* store was healed by its own code path and none of the sync paths knew the
|
|
11
|
+
* rule.
|
|
12
|
+
*
|
|
13
|
+
* This module is the ONE statement of the rule. The sync boundary applies it
|
|
14
|
+
* (`readDurableSnapshot` never returns a misfiled row, so no flush, seed,
|
|
15
|
+
* import or export can carry one), the shared store and artifact drop what they
|
|
16
|
+
* already hold, and the local purge relocates or drops the local copy. A new
|
|
17
|
+
* relocation rule is a new entry in {@link MISFILED_DURABLE_RULES} — never a
|
|
18
|
+
* new clause in one store's cleanup, which is the shape that failed.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no fs, no sqlite.
|
|
21
|
+
*
|
|
22
|
+
* @module cli/services/durable-key-rules
|
|
23
|
+
*/
|
|
24
|
+
import { VERIFY_RECORD_NAMESPACE } from '../memory/bridge-embedder.js';
|
|
25
|
+
/** The durable namespace user-authored lessons live in. */
|
|
26
|
+
export const LEARNINGS_NAMESPACE = 'learnings';
|
|
27
|
+
/** Every rule. `/verify` verdicts used to be written to `learnings` (#1375). */
|
|
28
|
+
export const MISFILED_DURABLE_RULES = [
|
|
29
|
+
{ namespace: LEARNINGS_NAMESPACE, keyPrefix: `${VERIFY_RECORD_NAMESPACE}:`, relocateTo: VERIFY_RECORD_NAMESPACE },
|
|
30
|
+
];
|
|
31
|
+
/** True when `(namespace, key)` is a row no durable store should hold. */
|
|
32
|
+
export function isMisfiledDurable(namespace, key) {
|
|
33
|
+
return MISFILED_DURABLE_RULES.some((r) => r.namespace === namespace && key.startsWith(r.keyPrefix));
|
|
34
|
+
}
|
|
35
|
+
/** SQL predicate matching the rows of ONE rule — the only place its shape is spelled. */
|
|
36
|
+
export function misfiledRuleSql(rule) {
|
|
37
|
+
return { sql: '(namespace = ? AND key GLOB ?)', params: [rule.namespace, `${rule.keyPrefix}*`] };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* SQL predicate matching every misfiled row, with its bindings. Callers negate
|
|
41
|
+
* it (`NOT (...)`) to read a clean durable slice, or use it as-is to delete.
|
|
42
|
+
*/
|
|
43
|
+
export function misfiledDurableSql() {
|
|
44
|
+
if (MISFILED_DURABLE_RULES.length === 0)
|
|
45
|
+
return { sql: '0', params: [] };
|
|
46
|
+
const parts = MISFILED_DURABLE_RULES.map(misfiledRuleSql);
|
|
47
|
+
return { sql: `(${parts.map((p) => p.sql).join(' OR ')})`, params: parts.flatMap((p) => p.params) };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Key shapes of a per-ticket run summary stored as a learning: `flo-2595-...`,
|
|
51
|
+
* `2679-...`, or a `-done`/`-complete`/`-shipped`/`-merged` suffix.
|
|
52
|
+
*
|
|
53
|
+
* A heuristic on key shape only, which is why nothing moves on it
|
|
54
|
+
* automatically: the session start COUNTS matches, and
|
|
55
|
+
* `flo memory audit-learnings` nominates them for a verdict. Content is not
|
|
56
|
+
* consulted — a real lesson can open with a ticket number as provenance
|
|
57
|
+
* (`1145-daemon-port-collision-fix`), and that is exactly the case a model
|
|
58
|
+
* verdict exists to tell apart.
|
|
59
|
+
*/
|
|
60
|
+
const TICKET_PREFIX = /^(?:flo-)?\d{3,5}-/;
|
|
61
|
+
const STATUS_SUFFIX = /-(?:done|complete|completed|shipped|merged)$/;
|
|
62
|
+
/** A date-prefixed key (`2026-09-28-...`) is a dated note, not a ticket number. */
|
|
63
|
+
const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}(?:\D|$)/;
|
|
64
|
+
/** True when a `learnings` key looks like a per-ticket run summary. */
|
|
65
|
+
export function isRunSummaryKey(key) {
|
|
66
|
+
if (STATUS_SUFFIX.test(key))
|
|
67
|
+
return true;
|
|
68
|
+
return TICKET_PREFIX.test(key) && !DATE_PREFIX.test(key);
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=durable-key-rules.js.map
|
|
@@ -37,13 +37,20 @@ import { DURABLE_NAMESPACES, DURABLE_INSERT_OR_IGNORE_SQL, DURABLE_ROW_COLUMNS,
|
|
|
37
37
|
// rather than each assembling its own copy of the retire-a-durable-row rule.
|
|
38
38
|
export { isDurableNamespace };
|
|
39
39
|
import { reconcileId, } from './durable-reconcile.js';
|
|
40
|
+
import { misfiledDurableSql } from './durable-key-rules.js';
|
|
40
41
|
/** The `status` value that marks a durable row as deleted-but-propagatable. */
|
|
41
42
|
export const ARCHIVED_STATUS = 'archived';
|
|
42
43
|
// Same column list as the shared INSERT, so a future column can never be added
|
|
43
44
|
// to one half of the round trip only. The status filter is what differs from
|
|
44
45
|
// the legacy cherry-pick read: archived rows are the deletions we must carry.
|
|
46
|
+
//
|
|
47
|
+
// Misfiled rows (#1495) are excluded HERE, at the one read every sync direction
|
|
48
|
+
// shares, so none of them — flush, seed, artifact import, artifact export — can
|
|
49
|
+
// carry a `verify:*` record in either state. Filtering per call site is what let
|
|
50
|
+
// the local cleanup be undone by the next seed.
|
|
45
51
|
const selectDurableSql = (placeholders, columns, byKey) => `SELECT ${columns} FROM memory_entries ` +
|
|
46
|
-
`WHERE namespace IN (${placeholders}) AND status IN ('active', '${ARCHIVED_STATUS}')` +
|
|
52
|
+
`WHERE namespace IN (${placeholders}) AND status IN ('active', '${ARCHIVED_STATUS}') ` +
|
|
53
|
+
`AND NOT ${misfiledDurableSql().sql}` +
|
|
47
54
|
(byKey ? ` AND key = ?` : '');
|
|
48
55
|
/**
|
|
49
56
|
* The columns the merge rule alone needs. A comparison never looks at the
|
|
@@ -69,7 +76,8 @@ export function readDurableSnapshot(db, namespaces = DURABLE_NAMESPACES, opts =
|
|
|
69
76
|
const placeholders = namespaces.map(() => '?').join(',');
|
|
70
77
|
const stmt = db.prepare(selectDurableSql(placeholders, withPayloads ? DURABLE_ROW_COLUMNS : RECORD_ONLY_COLUMNS, opts.key != null));
|
|
71
78
|
try {
|
|
72
|
-
|
|
79
|
+
const misfiled = misfiledDurableSql().params;
|
|
80
|
+
stmt.bind(opts.key != null ? [...namespaces, ...misfiled, opts.key] : [...namespaces, ...misfiled]);
|
|
73
81
|
while (stmt.step()) {
|
|
74
82
|
const row = stmt.getAsObject();
|
|
75
83
|
const namespace = String(row.namespace);
|
|
@@ -288,4 +296,26 @@ export function pruneExpiredArchives(db, now, ttlMs, namespaces = DURABLE_NAMESP
|
|
|
288
296
|
`AND namespace IN (${placeholders}) AND updated_at < ?`, [...namespaces, now - ttlMs]);
|
|
289
297
|
return db.getRowsModified();
|
|
290
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Hard-delete every misfiled row (#1495) from a DURABLE TRANSFER store — the
|
|
301
|
+
* worktree shared store, never a project's own `.moflo/moflo.db` (the local
|
|
302
|
+
* purge relocates those instead, since the local copy is the one worth keeping).
|
|
303
|
+
*
|
|
304
|
+
* A shared store holds only durable namespaces, so a `verify:*` record there has
|
|
305
|
+
* no home to move to, and a tombstone for it would be pointless: the snapshot
|
|
306
|
+
* read already ignores the key in every store. Deleting is what makes the store
|
|
307
|
+
* shrink rather than carry the rows forever. Returns rows removed.
|
|
308
|
+
*/
|
|
309
|
+
export function deleteMisfiledDurableRows(db) {
|
|
310
|
+
if (!hasMemoryEntriesTable(db))
|
|
311
|
+
return 0;
|
|
312
|
+
const { sql, params } = misfiledDurableSql();
|
|
313
|
+
// Probe first: the steady state is a clean store, and a DELETE would open a
|
|
314
|
+
// write transaction on every session start for nothing.
|
|
315
|
+
const probe = db.exec(`SELECT 1 FROM memory_entries WHERE ${sql} LIMIT 1`, params);
|
|
316
|
+
if (!probe[0]?.values?.[0])
|
|
317
|
+
return 0;
|
|
318
|
+
db.run(`DELETE FROM memory_entries WHERE ${sql}`, params);
|
|
319
|
+
return db.getRowsModified();
|
|
320
|
+
}
|
|
291
321
|
//# sourceMappingURL=durable-store-io.js.map
|
|
@@ -53,7 +53,7 @@ import { loadMofloConfig } from '../config/moflo-config.js';
|
|
|
53
53
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
54
54
|
import { CHERRY_PICK_SKIP_REASONS, DURABLE_NAMESPACES, isDurableNamespace, } from './cherry-pick-learnings.js';
|
|
55
55
|
import { planReconcile, TOMBSTONE_TTL_MS } from './durable-reconcile.js';
|
|
56
|
-
import { readDurableSnapshot, applyDurableActions, pruneExpiredArchives } from './durable-store-io.js';
|
|
56
|
+
import { readDurableSnapshot, applyDurableActions, pruneExpiredArchives, deleteMisfiledDurableRows, } from './durable-store-io.js';
|
|
57
57
|
export { isDurableNamespace };
|
|
58
58
|
/**
|
|
59
59
|
* Read `<projectRoot>/.git` ONCE and classify the checkout for worktree sharing,
|
|
@@ -249,10 +249,10 @@ export function reconcileDurableStores(sourcePath, targetPath, opts = {}) {
|
|
|
249
249
|
return result;
|
|
250
250
|
}
|
|
251
251
|
/**
|
|
252
|
-
*
|
|
253
|
-
* unopenable store is "nothing to
|
|
252
|
+
* Run one best-effort maintenance write against a store. A missing or
|
|
253
|
+
* unopenable store is "nothing to do", never an error that fails a sync.
|
|
254
254
|
*/
|
|
255
|
-
function
|
|
255
|
+
function maintainStore(dbPath, op) {
|
|
256
256
|
if (!fs.existsSync(dbPath))
|
|
257
257
|
return 0;
|
|
258
258
|
let db;
|
|
@@ -263,7 +263,7 @@ function pruneStore(dbPath) {
|
|
|
263
263
|
return 0;
|
|
264
264
|
}
|
|
265
265
|
try {
|
|
266
|
-
return
|
|
266
|
+
return op(db);
|
|
267
267
|
}
|
|
268
268
|
catch {
|
|
269
269
|
return 0;
|
|
@@ -272,6 +272,8 @@ function pruneStore(dbPath) {
|
|
|
272
272
|
db.close();
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
|
+
/** Apply the archive retention window to one store. */
|
|
276
|
+
const pruneStore = (dbPath) => maintainStore(dbPath, (db) => pruneExpiredArchives(db, Date.now(), TOMBSTONE_TTL_MS));
|
|
275
277
|
/** Total rows a direction actually changed — what the launcher reports. */
|
|
276
278
|
export function changedRows(result) {
|
|
277
279
|
return result.copied + result.updated + result.archived + result.resurrected;
|
|
@@ -331,6 +333,7 @@ export async function syncDurableAtSessionStart(opts = {}) {
|
|
|
331
333
|
skipped,
|
|
332
334
|
flushedToShared: 0,
|
|
333
335
|
seededToLocal: 0,
|
|
336
|
+
healedShared: 0,
|
|
334
337
|
prunedArchives: pruneStore(memoryDbPath(projectRoot)),
|
|
335
338
|
};
|
|
336
339
|
}
|
|
@@ -345,12 +348,24 @@ export async function syncDurableAtSessionStart(opts = {}) {
|
|
|
345
348
|
// flush re-inserts the purged entry into every workspace. Running the seed
|
|
346
349
|
// first means this store has applied the deletion before anything drops the
|
|
347
350
|
// evidence for it.
|
|
348
|
-
|
|
351
|
+
//
|
|
352
|
+
// The shared store also sheds misfiled rows (#1495) in the same open — it is
|
|
353
|
+
// the store sibling worktrees contend on, so a second open is a second lock
|
|
354
|
+
// wait. Neither direction above can move such a row (the snapshot read
|
|
355
|
+
// excludes them), so this only shrinks the store. The local copy is the
|
|
356
|
+
// purge's job, which relocates rather than deletes.
|
|
357
|
+
let healedShared = 0;
|
|
358
|
+
const pruned = pruneStore(memoryDbPath(projectRoot)) +
|
|
359
|
+
maintainStore(durablePath, (db) => {
|
|
360
|
+
healedShared = deleteMisfiledDurableRows(db);
|
|
361
|
+
return pruneExpiredArchives(db, Date.now(), TOMBSTONE_TTL_MS);
|
|
362
|
+
});
|
|
349
363
|
return {
|
|
350
364
|
durablePath,
|
|
351
365
|
autoWorktree: autoWorktree ?? false,
|
|
352
366
|
flushedToShared: changedRows(flush),
|
|
353
367
|
seededToLocal: changedRows(seed),
|
|
368
|
+
healedShared,
|
|
354
369
|
prunedArchives: pruned,
|
|
355
370
|
};
|
|
356
371
|
}
|
|
@@ -26,6 +26,26 @@
|
|
|
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
|
+
*
|
|
39
|
+
* 5. **Count run summaries** (#1495) — active `learnings` rows whose key is
|
|
40
|
+
* shaped like a per-ticket run summary ({@link isRunSummaryKey}). Report
|
|
41
|
+
* only: key shape is a heuristic, so nothing moves on it here.
|
|
42
|
+
* `flo memory audit-learnings` nominates them for a verdict instead.
|
|
43
|
+
*
|
|
44
|
+
* The relocation in pass 2 is one of three halves of the #1495 heal: the
|
|
45
|
+
* durable sync never reads a misfiled row (so the shared store and the team
|
|
46
|
+
* artifact cannot seed it back), and each of those stores drops its own copy.
|
|
47
|
+
* Healing this DB alone is what #1375 did, and the next seed undid it.
|
|
48
|
+
*
|
|
29
49
|
* All passes share the file open + final VACUUM + atomic write, so disk I/O
|
|
30
50
|
* is the same as before. Writes back to disk only when something changed.
|
|
31
51
|
*
|
|
@@ -38,12 +58,12 @@
|
|
|
38
58
|
* @module cli/services/ephemeral-namespace-purge
|
|
39
59
|
*/
|
|
40
60
|
/* 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';
|
|
61
|
+
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
62
|
import { memoryDbPath } from './moflo-paths.js';
|
|
63
|
+
import { isRunSummaryKey, LEARNINGS_NAMESPACE, MISFILED_DURABLE_RULES, misfiledDurableSql, misfiledRuleSql, } from './durable-key-rules.js';
|
|
64
|
+
import { readAuditState } from '../memory/learnings-audit-state.js';
|
|
43
65
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
44
66
|
import { resolveStateRoot } from './project-root.js';
|
|
45
|
-
/** Namespace stray verdict records are re-filed OUT of (#1375). */
|
|
46
|
-
const LEARNINGS_NAMESPACE = 'learnings';
|
|
47
67
|
/**
|
|
48
68
|
* Hard-delete rows in {@link PURGE_ON_SESSION_START_NAMESPACES}, relocate
|
|
49
69
|
* stray `verify:*` records out of `learnings`, and trim the retention-capped
|
|
@@ -56,9 +76,10 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
56
76
|
const fs = await import('fs');
|
|
57
77
|
const path = await import('path');
|
|
58
78
|
const nothingToDo = {
|
|
59
|
-
purged: 0, trimmed: 0, relocated: 0, superseded: 0,
|
|
79
|
+
purged: 0, trimmed: 0, relocated: 0, superseded: 0, stripped: 0, runSummaries: 0,
|
|
60
80
|
};
|
|
61
|
-
const
|
|
81
|
+
const stateRoot = options.dbPath ? options.projectRoot : (options.projectRoot ?? resolveStateRoot());
|
|
82
|
+
const dbPath = path.resolve(options.dbPath ?? memoryDbPath(options.projectRoot ?? resolveStateRoot()));
|
|
62
83
|
if (!fs.existsSync(dbPath))
|
|
63
84
|
return nothingToDo;
|
|
64
85
|
// node:sqlite via the unified factory (Phase 5 / #1084). WAL persists each
|
|
@@ -77,27 +98,20 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
77
98
|
// Purge match shape: exact namespace IN (...) OR namespace LIKE 'prefix-%'.
|
|
78
99
|
// The prefix clause covers runtime-suffixed namespaces like
|
|
79
100
|
// `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
101
|
const caps = [
|
|
83
102
|
['tasklist', options.tasklistRetentionCap ?? TASKLIST_RETENTION_CAP],
|
|
84
103
|
[VERIFY_RECORD_NAMESPACE, options.verifyRetentionCap ?? VERIFY_RETENTION_CAP],
|
|
85
104
|
];
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
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}%`)];
|
|
105
|
+
const { sql: purgeWhere, params: purgeBindings } = namespaceMatchSql(PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES);
|
|
106
|
+
// The misfiled-row rule is shared with the durable sync (#1495), so this
|
|
107
|
+
// pass and the stores it cannot reach agree on exactly which rows move.
|
|
92
108
|
// GLOB, not LIKE: SQLite's LIKE is case-INSENSITIVE for ASCII, so
|
|
93
109
|
// `key LIKE 'verify:%'` would also sweep a `Verify:...` row that
|
|
94
110
|
// `gate.cjs`'s `record-verify-outcome` — which tests
|
|
95
|
-
// `key.indexOf('verify:') !== 0` — would never have credited.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const strayVerifyWhere = 'namespace = ? AND key GLOB ?';
|
|
100
|
-
const strayVerifyBindings = [LEARNINGS_NAMESPACE, `${VERIFY_RECORD_NAMESPACE}:*`];
|
|
111
|
+
// `key.indexOf('verify:') !== 0` — would never have credited.
|
|
112
|
+
const misfiled = misfiledDurableSql();
|
|
113
|
+
const ephemeral = ephemeralNamespaceSql();
|
|
114
|
+
const embeddedEphemeralWhere = `${ephemeral.sql} AND embedding IS NOT NULL`;
|
|
101
115
|
// EVERY column needs a distinct alias. `exec` maps each row to an object
|
|
102
116
|
// keyed by column name, so two columns that SQLite names identically
|
|
103
117
|
// collapse into one and silently shift every later index. Unaliased
|
|
@@ -106,12 +120,14 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
106
120
|
// trims then read a total that isn't theirs.
|
|
107
121
|
const countRows = db.exec(`SELECT
|
|
108
122
|
(SELECT COUNT(*) FROM memory_entries WHERE ${purgeWhere}) AS purgeable,
|
|
109
|
-
(SELECT COUNT(*) FROM memory_entries WHERE ${
|
|
110
|
-
|
|
123
|
+
(SELECT COUNT(*) FROM memory_entries WHERE ${misfiled.sql}) AS relocatable,
|
|
124
|
+
(SELECT COUNT(*) FROM memory_entries WHERE ${embeddedEphemeralWhere}) AS strippable,
|
|
125
|
+
${caps.map((_, i) => `(SELECT COUNT(*) FROM memory_entries WHERE namespace = ?) AS capTotal${i}`).join(',\n ')}`, [...purgeBindings, ...misfiled.params, ...ephemeral.params, ...caps.map(([ns]) => ns)]);
|
|
111
126
|
const counts = countRows[0]?.values?.[0] ?? [];
|
|
112
127
|
const purgeable = Number(counts[0] ?? 0);
|
|
113
128
|
const relocatable = Number(counts[1] ?? 0);
|
|
114
|
-
const
|
|
129
|
+
const strippable = Number(counts[2] ?? 0);
|
|
130
|
+
const capTotals = caps.map((_, i) => Number(counts[i + 3] ?? 0));
|
|
115
131
|
let purged = 0;
|
|
116
132
|
if (purgeable > 0) {
|
|
117
133
|
db.run(`DELETE FROM memory_entries WHERE ${purgeWhere}`, purgeBindings);
|
|
@@ -119,27 +135,41 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
119
135
|
}
|
|
120
136
|
let relocated = 0;
|
|
121
137
|
let superseded = 0;
|
|
122
|
-
|
|
138
|
+
let relocatedIntoVerify = 0;
|
|
139
|
+
for (const rule of relocatable > 0 ? MISFILED_DURABLE_RULES : []) {
|
|
140
|
+
const { sql: ruleWhere, params: ruleBindings } = misfiledRuleSql(rule);
|
|
123
141
|
// UNIQUE(namespace, key): a stray row whose key already exists in the
|
|
124
142
|
// target cannot simply move — the UPDATE would violate the constraint,
|
|
125
143
|
// and `UPDATE OR REPLACE` would clobber the newer record with the older.
|
|
126
|
-
// Drop the
|
|
127
|
-
//
|
|
128
|
-
//
|
|
144
|
+
// Drop the stray copy instead, and COUNT it: this is the one place the
|
|
145
|
+
// pass destroys a row rather than re-filing it, so it must surface in the
|
|
146
|
+
// result rather than hide inside `relocated`.
|
|
129
147
|
db.run(`DELETE FROM memory_entries
|
|
130
|
-
WHERE ${
|
|
131
|
-
AND key IN (SELECT key FROM memory_entries WHERE namespace = ?)`, [...
|
|
132
|
-
superseded
|
|
148
|
+
WHERE ${ruleWhere}
|
|
149
|
+
AND key IN (SELECT key FROM memory_entries WHERE namespace = ?)`, [...ruleBindings, rule.relocateTo]);
|
|
150
|
+
superseded += db.getRowsModified?.() ?? 0;
|
|
133
151
|
// Content is unchanged, so the row's existing embedding stays valid: the
|
|
134
152
|
// HNSW sidecar is keyed by row id and re-reads `namespace` from SQL on
|
|
135
153
|
// every index build, so this is a re-filing, not a re-index.
|
|
136
|
-
db.run(`UPDATE memory_entries SET namespace = ? WHERE ${
|
|
137
|
-
|
|
154
|
+
db.run(`UPDATE memory_entries SET namespace = ? WHERE ${ruleWhere}`, [rule.relocateTo, ...ruleBindings]);
|
|
155
|
+
const moved = db.getRowsModified?.() ?? 0;
|
|
156
|
+
relocated += moved;
|
|
157
|
+
if (rule.relocateTo === VERIFY_RECORD_NAMESPACE)
|
|
158
|
+
relocatedIntoVerify += moved;
|
|
159
|
+
}
|
|
160
|
+
// After the purge, so rows it already deleted are not counted twice. Model
|
|
161
|
+
// goes back to NULL — the exact shape the ephemeral writers produce.
|
|
162
|
+
let stripped = 0;
|
|
163
|
+
if (strippable > 0) {
|
|
164
|
+
db.run(`UPDATE memory_entries
|
|
165
|
+
SET embedding = NULL, embedding_model = NULL, embedding_dimensions = NULL
|
|
166
|
+
WHERE ${embeddedEphemeralWhere}`, ephemeral.params);
|
|
167
|
+
stripped = db.getRowsModified?.() ?? 0;
|
|
138
168
|
}
|
|
139
169
|
let trimmed = 0;
|
|
140
170
|
for (const [i, [ns, cap]] of caps.entries()) {
|
|
141
171
|
// Rows just relocated into `verify` count toward its cap in this same run.
|
|
142
|
-
const total = capTotals[i] + (ns === VERIFY_RECORD_NAMESPACE ?
|
|
172
|
+
const total = capTotals[i] + (ns === VERIFY_RECORD_NAMESPACE ? relocatedIntoVerify : 0);
|
|
143
173
|
if (total <= cap)
|
|
144
174
|
continue;
|
|
145
175
|
// Keep the newest `cap` rows by created_at, falling back to `id DESC`
|
|
@@ -154,21 +184,46 @@ export async function purgeEphemeralNamespaces(options = {}) {
|
|
|
154
184
|
)`, [ns, ns, cap]);
|
|
155
185
|
trimmed += db.getRowsModified?.() ?? 0;
|
|
156
186
|
}
|
|
157
|
-
|
|
158
|
-
|
|
187
|
+
const runSummaries = countRunSummaries(db, stateRoot);
|
|
188
|
+
if (purged === 0 && trimmed === 0 && relocated === 0 && superseded === 0 && stripped === 0) {
|
|
189
|
+
return { ...nothingToDo, runSummaries };
|
|
190
|
+
}
|
|
159
191
|
// VACUUM only after a DELETE actually freed pages. A relocation is an
|
|
160
|
-
// UPDATE
|
|
161
|
-
//
|
|
162
|
-
//
|
|
192
|
+
// UPDATE that reclaims nothing; a vector strip frees some pages, but later
|
|
193
|
+
// writes reuse them. VACUUMing for either would rewrite the whole file
|
|
194
|
+
// (60+ MB on a populated store) in the foreground of session start for no
|
|
195
|
+
// real benefit. Has to run outside any open transaction; node:sqlite/sql.js
|
|
163
196
|
// both auto-commit each `db.run`, so this is safe to chain.
|
|
164
197
|
if (purged > 0 || trimmed > 0 || superseded > 0)
|
|
165
198
|
db.run('VACUUM');
|
|
166
|
-
return { purged, trimmed, relocated, superseded };
|
|
199
|
+
return { purged, trimmed, relocated, superseded, stripped, runSummaries };
|
|
167
200
|
}
|
|
168
201
|
finally {
|
|
169
202
|
db.close();
|
|
170
203
|
}
|
|
171
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Count active `learnings` rows keyed like a per-ticket run summary. Keys only
|
|
207
|
+
* — a single indexed read of short strings, cheap on a store of thousands.
|
|
208
|
+
*
|
|
209
|
+
* Keys `flo memory audit-learnings` already judged are skipped: a KEEP is a
|
|
210
|
+
* human saying "this one is a lesson", and a notice that ignored it would print
|
|
211
|
+
* on every session forever.
|
|
212
|
+
*/
|
|
213
|
+
function countRunSummaries(db, projectRoot) {
|
|
214
|
+
const rows = db.exec(`SELECT key FROM memory_entries WHERE namespace = ? AND status = 'active'`, [LEARNINGS_NAMESPACE]);
|
|
215
|
+
let decided = null;
|
|
216
|
+
let count = 0;
|
|
217
|
+
for (const [raw] of rows[0]?.values ?? []) {
|
|
218
|
+
const key = String(raw);
|
|
219
|
+
if (!isRunSummaryKey(key))
|
|
220
|
+
continue;
|
|
221
|
+
decided ??= projectRoot ? readAuditState(projectRoot) : new Map();
|
|
222
|
+
if (!decided.has(key))
|
|
223
|
+
count++;
|
|
224
|
+
}
|
|
225
|
+
return count;
|
|
226
|
+
}
|
|
172
227
|
/**
|
|
173
228
|
* Hard-delete rows whose namespace matches one of
|
|
174
229
|
* {@link PURGE_ON_SESSION_START_PREFIXES} — currently `doctor-memprobe-*`
|
|
@@ -269,7 +269,7 @@ export async function restoreSnapshot(options) {
|
|
|
269
269
|
let purged = 0;
|
|
270
270
|
try {
|
|
271
271
|
const { purgeEphemeralNamespaces } = await import('./ephemeral-namespace-purge.js');
|
|
272
|
-
const result = await purgeEphemeralNamespaces({ dbPath: target });
|
|
272
|
+
const result = await purgeEphemeralNamespaces({ dbPath: target, projectRoot });
|
|
273
273
|
purged = (result?.purged ?? 0) + (result?.trimmed ?? 0);
|
|
274
274
|
}
|
|
275
275
|
catch {
|
|
@@ -72,6 +72,7 @@ import { isDurableNamespace } from './cherry-pick-learnings.js';
|
|
|
72
72
|
import { planReconcile, reconcileId, splitReconcileId, isPrunableTombstone, recordStamp, TOMBSTONE_TTL_MS, } from './durable-reconcile.js';
|
|
73
73
|
import { readDurableSnapshot, applyDurableActions, } from './durable-store-io.js';
|
|
74
74
|
import { detectToolCallMarkup } from '../memory/tool-call-markup.js';
|
|
75
|
+
import { isMisfiledDurable } from './durable-key-rules.js';
|
|
75
76
|
/** Allowed `type` values — the schema CHECK set. An out-of-set value would make
|
|
76
77
|
* INSERT OR IGNORE silently drop a hand-edited artifact row, so we coerce. */
|
|
77
78
|
const VALID_TYPES = new Set([
|
|
@@ -208,8 +209,9 @@ function readArtifact(artifactPath) {
|
|
|
208
209
|
const lines = new Map();
|
|
209
210
|
const records = new Map();
|
|
210
211
|
let malformed = 0;
|
|
212
|
+
let misfiled = 0;
|
|
211
213
|
if (!fs.existsSync(artifactPath))
|
|
212
|
-
return { lines, records, malformed, existed: false };
|
|
214
|
+
return { lines, records, malformed, misfiled, existed: false };
|
|
213
215
|
const raw = fs.readFileSync(artifactPath, 'utf-8');
|
|
214
216
|
for (const line of raw.split(/\r?\n/)) {
|
|
215
217
|
const trimmed = line.trim();
|
|
@@ -222,6 +224,13 @@ function readArtifact(artifactPath) {
|
|
|
222
224
|
}
|
|
223
225
|
const id = lineId(parsed);
|
|
224
226
|
const record = lineToRecord(parsed);
|
|
227
|
+
// Dropped at the read both directions share, so import never applies one
|
|
228
|
+
// and export rewrites the file without it — the artifact shrinks instead of
|
|
229
|
+
// re-seeding every teammate's `learnings` on their next session (#1495).
|
|
230
|
+
if (isMisfiledDurable(record.namespace, record.key)) {
|
|
231
|
+
misfiled++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
225
234
|
const existing = records.get(id);
|
|
226
235
|
// Newest wins, ties keep the first. Built on the merge rule's own
|
|
227
236
|
// comparison basis so the two cannot disagree about which of a live line
|
|
@@ -231,7 +240,7 @@ function readArtifact(artifactPath) {
|
|
|
231
240
|
lines.set(id, parsed);
|
|
232
241
|
records.set(id, record);
|
|
233
242
|
}
|
|
234
|
-
return { lines, records, malformed, existed: true };
|
|
243
|
+
return { lines, records, malformed, misfiled, existed: true };
|
|
235
244
|
}
|
|
236
245
|
/**
|
|
237
246
|
* Serialise lines to JSONL, sorted by the (namespace, key) each line is about —
|
|
@@ -296,8 +305,15 @@ function payloadFromLine(id, line) {
|
|
|
296
305
|
tags: line.tags ? JSON.stringify(line.tags) : null,
|
|
297
306
|
metadata: JSON.stringify({ provenance: line.provenance, sharedFrom: 'team-artifact' }),
|
|
298
307
|
ownerId: line.provenance?.author || null,
|
|
299
|
-
// created_at/updated_at are INTEGER NOT NULL — never bind null.
|
|
300
|
-
|
|
308
|
+
// created_at/updated_at are INTEGER NOT NULL — never bind null. A line with
|
|
309
|
+
// no created_at falls back to its edit time, never the import time: stamping
|
|
310
|
+
// now makes a re-imported old entry read as a new learning, which is what
|
|
311
|
+
// inflated growth metrics after every re-seed (#1495).
|
|
312
|
+
createdAt: typeof line.created_at === 'number'
|
|
313
|
+
? line.created_at
|
|
314
|
+
: typeof line.updated_at === 'number'
|
|
315
|
+
? line.updated_at
|
|
316
|
+
: Date.now(),
|
|
301
317
|
// The row gets the best timestamp available even when the RECORD compared
|
|
302
318
|
// as 0 (a pre-#1463 line): 0 governs only who wins the merge, while the row
|
|
303
319
|
// itself should carry the most accurate time we have.
|
|
@@ -326,7 +342,7 @@ export function exportTeamArtifact(opts) {
|
|
|
326
342
|
const parsedSharedAt = Date.parse(opts.sharedAt);
|
|
327
343
|
const now = opts.now ?? (Number.isNaN(parsedSharedAt) ? Date.now() : parsedSharedAt);
|
|
328
344
|
const ttlMs = opts.tombstoneTtlMs ?? TOMBSTONE_TTL_MS;
|
|
329
|
-
const { lines, records: target, malformed, existed } = readArtifact(opts.artifactPath);
|
|
345
|
+
const { lines, records: target, malformed, misfiled, existed } = readArtifact(opts.artifactPath);
|
|
330
346
|
const provenance = {
|
|
331
347
|
author: resolveAuthor(projectRoot),
|
|
332
348
|
source: resolveSource(),
|
|
@@ -418,7 +434,7 @@ export function exportTeamArtifact(opts) {
|
|
|
418
434
|
}
|
|
419
435
|
// Skip the write when the merge changed nothing AND the file already exists:
|
|
420
436
|
// a git-tracked artifact should not show up as modified after a no-op run.
|
|
421
|
-
const changed = actions.length > 0 || prunedTombstones > 0 || backfilled > 0;
|
|
437
|
+
const changed = actions.length > 0 || prunedTombstones > 0 || backfilled > 0 || misfiled > 0;
|
|
422
438
|
const wrote = changed || !existed;
|
|
423
439
|
if (wrote) {
|
|
424
440
|
fs.mkdirSync(path.dirname(opts.artifactPath), { recursive: true });
|
|
@@ -436,6 +452,7 @@ export function exportTeamArtifact(opts) {
|
|
|
436
452
|
backfilled,
|
|
437
453
|
skippedMalformed: malformed,
|
|
438
454
|
skippedCorrupt,
|
|
455
|
+
droppedMisfiled: misfiled,
|
|
439
456
|
total: live,
|
|
440
457
|
tombstones,
|
|
441
458
|
wrote,
|
|
@@ -467,9 +484,11 @@ export function importTeamArtifact(opts) {
|
|
|
467
484
|
skippedMalformed: 0,
|
|
468
485
|
skippedNonDurable: 0,
|
|
469
486
|
skippedCorrupt: 0,
|
|
487
|
+
skippedMisfiled: 0,
|
|
470
488
|
};
|
|
471
|
-
const { lines, records: parsed, malformed } = readArtifact(opts.artifactPath);
|
|
489
|
+
const { lines, records: parsed, malformed, misfiled } = readArtifact(opts.artifactPath);
|
|
472
490
|
report.skippedMalformed = malformed;
|
|
491
|
+
report.skippedMisfiled = misfiled;
|
|
473
492
|
if (lines.size === 0)
|
|
474
493
|
return report;
|
|
475
494
|
const source = new Map();
|
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.4",
|
|
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.3",
|
|
103
103
|
"tsx": "^4.21.0",
|
|
104
104
|
"typescript": "^5.9.3",
|
|
105
105
|
"vitest": "^4.0.0"
|