moflo 4.12.11 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
- package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
- package/.claude/skills/fl/phases.md +51 -17
- package/.claude/skills/optimize-learnings/SKILL.md +220 -0
- package/README.md +95 -1
- package/bin/lib/get-backend.mjs +150 -12
- package/bin/lib/skill-categories.mjs +1 -0
- package/bin/session-start-launcher.mjs +13 -5
- package/dist/src/cli/commands/daemon.js +5 -2
- package/dist/src/cli/commands/epic.js +5 -1
- package/dist/src/cli/commands/hive-mind.js +6 -4
- package/dist/src/cli/commands/hooks.js +8 -8
- package/dist/src/cli/commands/index.js +5 -0
- package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
- package/dist/src/cli/commands/memory.js +71 -10
- package/dist/src/cli/commands/spell-schedule.js +5 -3
- package/dist/src/cli/commands/worktree.js +408 -0
- package/dist/src/cli/config/moflo-config.js +57 -0
- package/dist/src/cli/index.js +4 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
- package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
- package/dist/src/cli/memory/bridge-entries.js +157 -9
- package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
- package/dist/src/cli/memory/daemon-backend.js +152 -11
- package/dist/src/cli/memory/entries-read.js +47 -2
- package/dist/src/cli/memory/entries-write.js +73 -10
- package/dist/src/cli/memory/hnsw-singleton.js +112 -9
- package/dist/src/cli/memory/learnings-audit.js +420 -0
- package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
- package/dist/src/cli/memory/learnings-tree.js +187 -0
- package/dist/src/cli/memory/memory-bridge.js +37 -27
- package/dist/src/cli/memory/tool-call-markup.js +218 -0
- package/dist/src/cli/parser.js +7 -3
- package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
- package/dist/src/cli/services/durable-reconcile.js +161 -0
- package/dist/src/cli/services/durable-store-io.js +291 -0
- package/dist/src/cli/services/durable-sync.js +159 -24
- package/dist/src/cli/services/team-artifact-sync.js +462 -163
- package/dist/src/cli/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -165,6 +165,150 @@ function wrapStatement(stmt) {
|
|
|
165
165
|
* both the daemon adapter and a bin/ writer on the same path only logs once.
|
|
166
166
|
*/
|
|
167
167
|
const _networkFsWarnedPaths = new Set();
|
|
168
|
+
/**
|
|
169
|
+
* Shared parking buffer for the journal-mode retry sleep. `Atomics.wait` is
|
|
170
|
+
* the only synchronous sleep that works identically on Linux, macOS and
|
|
171
|
+
* Windows without shelling out (Rule #1) — and this open path is synchronous,
|
|
172
|
+
* so there is no `await` to hand the thread back with.
|
|
173
|
+
*/
|
|
174
|
+
const WAL_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
|
|
175
|
+
function sleepMs(ms) {
|
|
176
|
+
Atomics.wait(WAL_SLEEP_BUF, 0, 0, ms);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The open-path `busy_timeout`. Named because two places depend on it being
|
|
180
|
+
* the same number: the pragma below sets it, and `readJournalModeBounded`
|
|
181
|
+
* restores it after narrowing it for a probe.
|
|
182
|
+
*/
|
|
183
|
+
const OPEN_BUSY_TIMEOUT_MS = 15_000;
|
|
184
|
+
/**
|
|
185
|
+
* Budget for the post-exhaustion probe. The query form of `PRAGMA
|
|
186
|
+
* journal_mode` takes a SHARED lock and IS covered by the busy handler, so it
|
|
187
|
+
* would otherwise inherit the full `OPEN_BUSY_TIMEOUT_MS` — doubling the
|
|
188
|
+
* worst case to ~30s before we report anything on the one path where we have
|
|
189
|
+
* already decided to give up. A few short attempts distinguish "genuinely not
|
|
190
|
+
* WAL" from "probe lost one more race" without reopening that window.
|
|
191
|
+
*/
|
|
192
|
+
const WAL_PROBE_BUSY_TIMEOUT_MS = 500;
|
|
193
|
+
const WAL_PROBE_ATTEMPTS = 3;
|
|
194
|
+
/**
|
|
195
|
+
* Total time `setWalWithRetry` will spend losing the conversion race before it
|
|
196
|
+
* gives up. Matched to `busy_timeout` (15000ms) on purpose: the two cover the
|
|
197
|
+
* same worst case — a background indexer holding a write lock through its
|
|
198
|
+
* whole first full-tree pass — and diverging budgets would mean the pragma
|
|
199
|
+
* that needs the wait most gets the shortest one. Being wrong-high costs one
|
|
200
|
+
* slow open in a rare race; being wrong-low kills the process outright.
|
|
201
|
+
*/
|
|
202
|
+
const WAL_RETRY_BUDGET_MS = OPEN_BUSY_TIMEOUT_MS;
|
|
203
|
+
/** Backoff bounds: start tight (most races clear in a few ms), cap so a long
|
|
204
|
+
* hold is still polled often enough to return promptly once it releases. */
|
|
205
|
+
const WAL_RETRY_MIN_DELAY_MS = 5;
|
|
206
|
+
const WAL_RETRY_MAX_DELAY_MS = 250;
|
|
207
|
+
/**
|
|
208
|
+
* SQLITE_BUSY (5) and SQLITE_LOCKED (6) — the two contention codes. The
|
|
209
|
+
* message test is a fallback for wrappers that don't propagate `errcode`.
|
|
210
|
+
*/
|
|
211
|
+
function isBusyError(err) {
|
|
212
|
+
const e = err;
|
|
213
|
+
if (e?.errcode === 5 || e?.errcode === 6)
|
|
214
|
+
return true;
|
|
215
|
+
return /database( table)? is locked/i.test(String(e?.message ?? ''));
|
|
216
|
+
}
|
|
217
|
+
/** Current journal mode, lowercased. `''` when the probe itself fails. */
|
|
218
|
+
function readJournalMode(db) {
|
|
219
|
+
try {
|
|
220
|
+
const row = db.prepare('PRAGMA journal_mode').get();
|
|
221
|
+
return String(row?.journal_mode ?? '').toLowerCase();
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return '';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `readJournalMode` under a deliberately narrow busy budget, restoring the
|
|
229
|
+
* open-path budget afterwards so a caller that survives keeps the connection
|
|
230
|
+
* it asked for. Only ever called once the retry budget is already spent.
|
|
231
|
+
*/
|
|
232
|
+
function readJournalModeBounded(db) {
|
|
233
|
+
try {
|
|
234
|
+
try {
|
|
235
|
+
db.exec(`PRAGMA busy_timeout = ${WAL_PROBE_BUSY_TIMEOUT_MS}`);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Non-fatal: we still probe, just without the narrower budget.
|
|
239
|
+
}
|
|
240
|
+
for (let attempt = 0; attempt < WAL_PROBE_ATTEMPTS; attempt++) {
|
|
241
|
+
const mode = readJournalMode(db);
|
|
242
|
+
if (mode)
|
|
243
|
+
return mode;
|
|
244
|
+
}
|
|
245
|
+
return '';
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
try {
|
|
249
|
+
db.exec(`PRAGMA busy_timeout = ${OPEN_BUSY_TIMEOUT_MS}`);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Non-fatal: the handle is still usable, and every path out of here
|
|
253
|
+
// either throws or hands back a database that is already in WAL.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Run `PRAGMA journal_mode = WAL`, retrying on contention (#1471).
|
|
259
|
+
*
|
|
260
|
+
* `busy_timeout` is set first and covers every other statement, but SQLite
|
|
261
|
+
* does **not** invoke the busy handler for a journal-mode change — so the one
|
|
262
|
+
* pragma the budget was put there for never gets it. Concurrent first-opens
|
|
263
|
+
* of a fresh database therefore threw `SQLITE_BUSY` immediately and killed
|
|
264
|
+
* whichever process lost the race: the daemon, the MCP server and a
|
|
265
|
+
* foreground `flo` command starting together is the ordinary consumer
|
|
266
|
+
* configuration, not a test artifact.
|
|
267
|
+
*
|
|
268
|
+
* Note the common path pays nothing: on a database already in WAL the pragma
|
|
269
|
+
* is a no-op that takes no exclusive lock, so the first attempt succeeds with
|
|
270
|
+
* no sleep and the loop below never runs.
|
|
271
|
+
*
|
|
272
|
+
* Twin: `bin/lib/get-backend.mjs:setWalWithRetry`. Must stay in lockstep until
|
|
273
|
+
* Phase 5 (#1084) extracts a shared module.
|
|
274
|
+
*
|
|
275
|
+
* @internal exported for tests — `budgetMs` lets them exercise exhaustion
|
|
276
|
+
* without spending the real 15s.
|
|
277
|
+
*/
|
|
278
|
+
export function setWalWithRetry(db, dbPath, budgetMs = WAL_RETRY_BUDGET_MS) {
|
|
279
|
+
let lastErr = null;
|
|
280
|
+
let waited = 0;
|
|
281
|
+
let delay = WAL_RETRY_MIN_DELAY_MS;
|
|
282
|
+
for (;;) {
|
|
283
|
+
try {
|
|
284
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
lastErr = err;
|
|
289
|
+
// Anything that isn't contention — a corrupt file, a read-only mount —
|
|
290
|
+
// will not clear by waiting. Surface it now rather than after 15s.
|
|
291
|
+
if (!isBusyError(err))
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
if (waited >= budgetMs)
|
|
295
|
+
break;
|
|
296
|
+
const nap = Math.min(delay, budgetMs - waited);
|
|
297
|
+
sleepMs(nap);
|
|
298
|
+
waited += nap;
|
|
299
|
+
delay = Math.min(delay * 2, WAL_RETRY_MAX_DELAY_MS);
|
|
300
|
+
}
|
|
301
|
+
// Budget spent. Another opener may have completed the conversion while we
|
|
302
|
+
// were losing races — the database being in WAL is the outcome we wanted,
|
|
303
|
+
// whichever process got it there.
|
|
304
|
+
const mode = readJournalModeBounded(db);
|
|
305
|
+
if (mode === 'wal')
|
|
306
|
+
return;
|
|
307
|
+
throw new Error(`[moflo] PRAGMA journal_mode = WAL stayed busy for ${waited}ms on ${dbPath} ` +
|
|
308
|
+
`(journal_mode is still "${mode || 'unreadable'}"). Another process is holding an ` +
|
|
309
|
+
`exclusive lock on the database. Original error: ` +
|
|
310
|
+
`${String(lastErr?.message ?? lastErr)}`, { cause: lastErr });
|
|
311
|
+
}
|
|
168
312
|
/**
|
|
169
313
|
* Read `journal_mode` back after we requested WAL. If the engine returned a
|
|
170
314
|
* different mode (`delete`, `truncate`, `persist`, `memory`, `off`), the
|
|
@@ -179,15 +323,10 @@ const _networkFsWarnedPaths = new Set();
|
|
|
179
323
|
function warnIfNotWal(db, dbPath) {
|
|
180
324
|
if (_networkFsWarnedPaths.has(dbPath))
|
|
181
325
|
return;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
mode = String(row?.journal_mode ?? '').toLowerCase();
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
326
|
+
// A probe that throws yields '' and falls through the guard below without
|
|
327
|
+
// warning — the WAL pragma above either took effect or didn't, and a failed
|
|
328
|
+
// read is not evidence either way.
|
|
329
|
+
const mode = readJournalMode(db);
|
|
191
330
|
if (mode && mode !== 'wal') {
|
|
192
331
|
_networkFsWarnedPaths.add(dbPath);
|
|
193
332
|
process.stderr.write(`[moflo] WARNING: SQLite journal_mode=${mode} on ${dbPath} (WAL not active). ` +
|
|
@@ -290,8 +429,10 @@ export function openDaemonDatabase(dbPath) {
|
|
|
290
429
|
// (#1098); 15000ms gives the indexer's full first-pass time to finish
|
|
291
430
|
// before doctor's probe gives up. The price of being wrong-high here
|
|
292
431
|
// is one slow probe per session, not lost data.
|
|
293
|
-
db.exec(
|
|
294
|
-
db.exec
|
|
432
|
+
db.exec(`PRAGMA busy_timeout = ${OPEN_BUSY_TIMEOUT_MS}`);
|
|
433
|
+
// Not `db.exec` directly: SQLite skips the busy handler for a
|
|
434
|
+
// journal-mode change, so this one pragma needs its own retry (#1471).
|
|
435
|
+
setWalWithRetry(db, dbPath);
|
|
295
436
|
db.exec('PRAGMA synchronous = NORMAL');
|
|
296
437
|
// The daemon is the process most exposed to network-FS edge cases
|
|
297
438
|
// (long-lived MCP server, ~30s of writes per indexer pass). NFS/SMB
|
|
@@ -77,9 +77,32 @@ export async function searchEntries(options) {
|
|
|
77
77
|
// Generate query embedding
|
|
78
78
|
const queryEmb = await generateEmbedding(query);
|
|
79
79
|
const queryEmbedding = queryEmb.embedding;
|
|
80
|
-
// Try HNSW search first (approximate-nearest-neighbor)
|
|
80
|
+
// Try HNSW search first (approximate-nearest-neighbor).
|
|
81
|
+
//
|
|
82
|
+
// #1468 — the short-circuit used to be `length > 0`, which made recall
|
|
83
|
+
// non-deterministic. Whenever the index could serve only part of the store —
|
|
84
|
+
// a truncated metadata map, or an index merely stale between rebuilds — a
|
|
85
|
+
// query that found ONE in-index hit suppressed the complete scan below,
|
|
86
|
+
// while a query that found zero got correct and complete results. Both
|
|
87
|
+
// shapes returned `success: true`, so the caller could not tell them apart.
|
|
88
|
+
//
|
|
89
|
+
// The ANN is now trusted only when it FILLED the request. Short of that we
|
|
90
|
+
// fall through and merge, so a partial fast path can add to the answer but
|
|
91
|
+
// never replace it. The test is on the raw count, before the threshold
|
|
92
|
+
// filter: what matters is whether the index could supply the candidates at
|
|
93
|
+
// all, not how similar they turned out to be.
|
|
94
|
+
//
|
|
95
|
+
// A namespace holding fewer than `limit` entries therefore falls through on
|
|
96
|
+
// every query, permanently. That is intended and its cost is proportionate:
|
|
97
|
+
// the scan below filters by the same namespace, so it parses that
|
|
98
|
+
// namespace's handful of embeddings, not the whole cap. The expensive scan
|
|
99
|
+
// is reserved for `all` and for namespaces large enough to fill the request
|
|
100
|
+
// in the first place — which do not reach it. `searchHNSWIndex` widens its
|
|
101
|
+
// retrieval before answering short (#1468), so a shortfall here means the
|
|
102
|
+
// entries genuinely are not in the index rather than that the namespace
|
|
103
|
+
// filter starved a global top-k.
|
|
81
104
|
const hnswResults = await searchHNSWIndex(queryEmbedding, { k: limit, namespace });
|
|
82
|
-
if (hnswResults && hnswResults.length
|
|
105
|
+
if (hnswResults && hnswResults.length >= limit) {
|
|
83
106
|
// Filter by threshold
|
|
84
107
|
const filtered = hnswResults.filter(r => r.score >= threshold);
|
|
85
108
|
return {
|
|
@@ -135,6 +158,28 @@ export async function searchEntries(options) {
|
|
|
135
158
|
}
|
|
136
159
|
}
|
|
137
160
|
db.close();
|
|
161
|
+
// #1468 — merge the partial ANN hits in rather than discarding them. The
|
|
162
|
+
// scan is capped at `searchCandidateCap()` and the index is not, so each
|
|
163
|
+
// side can hold something the other misses; keeping only one of them would
|
|
164
|
+
// trade the old non-determinism for a different gap. Same id from both keeps
|
|
165
|
+
// the higher score — the scan's is exact, the ANN's approximate, and
|
|
166
|
+
// `Math.max` picks the exact one whenever they disagree in its favour.
|
|
167
|
+
if (hnswResults) {
|
|
168
|
+
const byId = new Map(results.map(r => [r.id, r]));
|
|
169
|
+
for (const hit of hnswResults) {
|
|
170
|
+
if (hit.score < threshold)
|
|
171
|
+
continue;
|
|
172
|
+
const existing = byId.get(hit.id);
|
|
173
|
+
if (!existing) {
|
|
174
|
+
byId.set(hit.id, hit);
|
|
175
|
+
}
|
|
176
|
+
else if (hit.score > existing.score) {
|
|
177
|
+
existing.score = hit.score;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
results.length = 0;
|
|
181
|
+
results.push(...byId.values());
|
|
182
|
+
}
|
|
138
183
|
// Sort by score
|
|
139
184
|
results.sort((a, b) => b.score - a.score);
|
|
140
185
|
return {
|
|
@@ -17,7 +17,7 @@ import { findProjectRoot } from '../services/project-root.js';
|
|
|
17
17
|
import { openDaemonDatabase } from './daemon-backend.js';
|
|
18
18
|
import { ensureSchemaColumns } from './schema.js';
|
|
19
19
|
import { generateEmbedding } from './embedding-model.js';
|
|
20
|
-
import { addToHNSWIndex } from './hnsw-singleton.js';
|
|
20
|
+
import { addToHNSWIndex, removeFromHNSWIndex } from './hnsw-singleton.js';
|
|
21
21
|
import { getBridge } from './bridge-loader.js';
|
|
22
22
|
import { tryDaemonStore, tryDaemonDelete } from './daemon-write-client.js';
|
|
23
23
|
import { EMBEDDING_MODEL_OPT_OUT, getBridgeEmbedder, isEphemeralNamespace } from './bridge-embedder.js';
|
|
@@ -25,8 +25,10 @@ import { toFloat32 } from './controllers/_shared.js';
|
|
|
25
25
|
import { serialiseMetadata } from './bridge-entries.js';
|
|
26
26
|
import { logRoutingFault, writeVectorStatsCache } from './entries-shared.js';
|
|
27
27
|
import { writeThroughDurable } from '../services/durable-sync.js';
|
|
28
|
+
import { archiveDurableRow, isDurableNamespace } from '../services/durable-store-io.js';
|
|
28
29
|
import { resolveStateRoot } from '../services/project-root.js';
|
|
29
30
|
import { generateId } from '../shared/utils/id.js';
|
|
31
|
+
import { detectToolCallMarkup, markupCheckDisabled, toolCallMarkupError } from './tool-call-markup.js';
|
|
30
32
|
/**
|
|
31
33
|
* Propagate a just-persisted durable write to the shared store (#1232). The
|
|
32
34
|
* caller passes the project root the row actually landed under so the flush
|
|
@@ -37,8 +39,8 @@ import { generateId } from '../shared/utils/id.js';
|
|
|
37
39
|
* reaches the shared store. `writeThroughDurable` swallows its own errors and
|
|
38
40
|
* is a no-op unless `memory.durable_path` is set, so this is safe either way.
|
|
39
41
|
*/
|
|
40
|
-
async function propagateDurable(namespace, projectRoot) {
|
|
41
|
-
const flush = writeThroughDurable(namespace, { projectRoot });
|
|
42
|
+
async function propagateDurable(namespace, projectRoot, key) {
|
|
43
|
+
const flush = writeThroughDurable(namespace, { projectRoot, key });
|
|
42
44
|
if (process.env.MOFLO_IS_DAEMON === '1') {
|
|
43
45
|
void flush;
|
|
44
46
|
return;
|
|
@@ -50,6 +52,31 @@ async function propagateDurable(namespace, projectRoot) {
|
|
|
50
52
|
* This bypasses MCP and writes directly to the database.
|
|
51
53
|
*/
|
|
52
54
|
export async function storeEntry(options) {
|
|
55
|
+
// #1467 — reject a value that carries captured tool-call markup. Every
|
|
56
|
+
// model-authored write lands here: the CLI (`flo memory store`), the MCP tool
|
|
57
|
+
// (`memory_store`), and the daemon's own RPC handler. The check runs before
|
|
58
|
+
// the routing preamble below and before any embedding, so a rejected value
|
|
59
|
+
// never crosses the wire, produces a vector, or reaches disk.
|
|
60
|
+
//
|
|
61
|
+
// `storeEntries` (bulk) is deliberately NOT checked on its bridge path. Its
|
|
62
|
+
// one production caller is the pattern pre-trainer, whose content is derived
|
|
63
|
+
// from code rather than written by a model — so it cannot carry this
|
|
64
|
+
// corruption, and it is exactly where a truncated XML snippet ending on
|
|
65
|
+
// `</value>` would legitimately arrive. See the blind-spot note in
|
|
66
|
+
// tool-call-markup.ts.
|
|
67
|
+
if (!markupCheckDisabled()) {
|
|
68
|
+
const hit = detectToolCallMarkup(options.value);
|
|
69
|
+
if (hit) {
|
|
70
|
+
// Returned, not logged: every caller surfaces `error` in its own idiom
|
|
71
|
+
// (the CLI prints it, MCP returns it), and a console.error here would
|
|
72
|
+
// print the same refusal twice on the path users actually see.
|
|
73
|
+
return {
|
|
74
|
+
success: false,
|
|
75
|
+
id: '',
|
|
76
|
+
error: toolCallMarkupError(hit, options.value, options.namespace ?? 'default', options.key),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
53
80
|
// Soft-redirect: `knowledge` is a deprecated alias for `learnings`. Writes
|
|
54
81
|
// are accepted but routed to learnings with provenance tags so future
|
|
55
82
|
// decay/prune treats user-forced entries as locked. Old consumer DBs that
|
|
@@ -135,7 +162,7 @@ export async function storeEntry(options) {
|
|
|
135
162
|
// process actually persisted the row). The bridge resolves its DB via
|
|
136
163
|
// findProjectRoot(), so source the flush from the same root.
|
|
137
164
|
if (bridgeResult.success)
|
|
138
|
-
await propagateDurable(options.namespace ?? 'default', findProjectRoot());
|
|
165
|
+
await propagateDurable(options.namespace ?? 'default', findProjectRoot(), options.key);
|
|
139
166
|
return bridgeResult;
|
|
140
167
|
}
|
|
141
168
|
}
|
|
@@ -275,7 +302,7 @@ export async function storeEntry(options) {
|
|
|
275
302
|
// fallback. Source the flush from the root the row actually landed under
|
|
276
303
|
// (`dbPath` = <root>/.moflo/moflo.db) rather than process.cwd(), which can
|
|
277
304
|
// differ from the project root.
|
|
278
|
-
await propagateDurable(namespace, path.dirname(path.dirname(dbPath)));
|
|
305
|
+
await propagateDurable(namespace, path.dirname(path.dirname(dbPath)), key);
|
|
279
306
|
return {
|
|
280
307
|
success: true,
|
|
281
308
|
id,
|
|
@@ -328,6 +355,15 @@ export async function deleteEntry(options) {
|
|
|
328
355
|
key: options.key,
|
|
329
356
|
});
|
|
330
357
|
if (routed.routed && routed.ok) {
|
|
358
|
+
// #1468 — the row left the daemon's DB; drop it from any index THIS
|
|
359
|
+
// process holds too. Usually nothing (a CLI invocation loads no index
|
|
360
|
+
// before routing), but a long-lived client that routes its writes and
|
|
361
|
+
// still searches locally would otherwise keep serving the deleted entry.
|
|
362
|
+
// Gated on `deleted` for the same reason the bridge branch below is: a
|
|
363
|
+
// daemon can report success for a key that was already gone.
|
|
364
|
+
if (routed.deleted ?? true) {
|
|
365
|
+
removeFromHNSWIndex(options.namespace ?? 'default', options.key);
|
|
366
|
+
}
|
|
331
367
|
return {
|
|
332
368
|
success: true,
|
|
333
369
|
deleted: routed.deleted ?? true,
|
|
@@ -359,8 +395,13 @@ export async function deleteEntry(options) {
|
|
|
359
395
|
const bridge = await getBridge();
|
|
360
396
|
if (bridge) {
|
|
361
397
|
const bridgeResult = await bridge.bridgeDeleteEntry(options);
|
|
362
|
-
if (bridgeResult)
|
|
398
|
+
if (bridgeResult) {
|
|
399
|
+
// #1468 — see the note on the direct path below.
|
|
400
|
+
if (bridgeResult.deleted) {
|
|
401
|
+
removeFromHNSWIndex(options.namespace ?? 'default', options.key);
|
|
402
|
+
}
|
|
363
403
|
return bridgeResult;
|
|
404
|
+
}
|
|
364
405
|
}
|
|
365
406
|
// Fallback: direct node:sqlite write via the unified factory.
|
|
366
407
|
const { key, namespace = 'default', dbPath: customPath } = options;
|
|
@@ -401,15 +442,37 @@ export async function deleteEntry(options) {
|
|
|
401
442
|
error: `Key '${key}' not found in namespace '${namespace}'`
|
|
402
443
|
};
|
|
403
444
|
}
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// the
|
|
407
|
-
|
|
445
|
+
// Durable namespaces ARCHIVE instead of hard-deleting (#1463). A hard
|
|
446
|
+
// delete cannot propagate — it is indistinguishable from a row that never
|
|
447
|
+
// existed — so the entry returned at the next session-start import or
|
|
448
|
+
// worktree seed, and the operator saw a clean local store with no signal
|
|
449
|
+
// that the purge would be undone.
|
|
450
|
+
//
|
|
451
|
+
// This narrows story #728's hard-delete rather than reverting it: #728
|
|
452
|
+
// retired a soft-delete that nothing read and nothing bounded. The archived
|
|
453
|
+
// row is read by every sync direction and by a later re-creation that has
|
|
454
|
+
// to beat its timestamp, and `pruneExpiredArchives` bounds it at the same
|
|
455
|
+
// 90-day window the artifact tombstones use. Non-durable namespaces are
|
|
456
|
+
// unchanged — they are never shared, so a tombstone there would be the
|
|
457
|
+
// write-only kind #728 removed.
|
|
458
|
+
if (isDurableNamespace(namespace)) {
|
|
459
|
+
archiveDurableRow(db, namespace, key, Date.now());
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
db.run(`DELETE FROM memory_entries WHERE key = ? AND namespace = ? AND status = 'active'`, [key, namespace]);
|
|
463
|
+
}
|
|
408
464
|
// Get remaining count
|
|
409
465
|
const countResult = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE status = 'active'`);
|
|
410
466
|
const remainingEntries = countResult[0]?.values?.[0]?.[0] || 0;
|
|
411
467
|
// WAL persisted the DELETE incrementally — no whole-file dump needed.
|
|
412
468
|
db.close();
|
|
469
|
+
// #1468 — keep the in-process index in step with the row. The store path
|
|
470
|
+
// has always called `addToHNSWIndex`; the delete path called nothing, so a
|
|
471
|
+
// deleted entry's metadata outlived it in `hnswIndex.entries` and the local
|
|
472
|
+
// HNSW path kept returning it until the process restarted. Archived rows
|
|
473
|
+
// count: the index loads `status = 'active'`, so an archived entry has to
|
|
474
|
+
// leave it exactly as a hard-deleted one does.
|
|
475
|
+
removeFromHNSWIndex(namespace, key);
|
|
413
476
|
return {
|
|
414
477
|
success: true,
|
|
415
478
|
deleted: true,
|
|
@@ -20,6 +20,7 @@ import { parseEmbeddingJson } from './controllers/_shared.js';
|
|
|
20
20
|
import { memoryDbPath } from '../services/moflo-paths.js';
|
|
21
21
|
import { openDaemonDatabase } from './daemon-backend.js';
|
|
22
22
|
import { getBridge, isBridgeLoaded } from './bridge-loader.js';
|
|
23
|
+
import { searchCandidateCap } from './bridge-core.js';
|
|
23
24
|
import { resolveStateRoot } from '../services/project-root.js';
|
|
24
25
|
let hnswIndex = null;
|
|
25
26
|
let hnswInitializing = false;
|
|
@@ -65,6 +66,7 @@ export async function getHNSWIndex(options) {
|
|
|
65
66
|
return hnsw.search(query.vector, query.k);
|
|
66
67
|
},
|
|
67
68
|
len: async () => hnsw.size,
|
|
69
|
+
remove: (id) => hnsw.remove(id),
|
|
68
70
|
};
|
|
69
71
|
const entries = new Map();
|
|
70
72
|
hnswIndex = {
|
|
@@ -84,11 +86,17 @@ export async function getHNSWIndex(options) {
|
|
|
84
86
|
try {
|
|
85
87
|
const sqlDb = openDaemonDatabase(dbPath);
|
|
86
88
|
const cols = sidecarLoaded ? SELECT_METADATA_ONLY : SELECT_WITH_EMBEDDING;
|
|
89
|
+
// #1468 — recency-ordered candidate cap, the same one `entries-read.ts`
|
|
90
|
+
// and `memory-bridge.ts` already use. This load was a bare `LIMIT 10000`,
|
|
91
|
+
// so which rows survived was b-tree order under `idx_memory_status` —
|
|
92
|
+
// arbitrary with respect to both recency and namespace. That is #1201
|
|
93
|
+
// recurring here; the sibling paths got the fix and this loader did not.
|
|
87
94
|
const result = sqlDb.exec(`
|
|
88
95
|
SELECT ${cols}
|
|
89
96
|
FROM memory_entries
|
|
90
97
|
WHERE status = 'active' AND embedding IS NOT NULL
|
|
91
|
-
|
|
98
|
+
ORDER BY created_at DESC
|
|
99
|
+
LIMIT ${searchCandidateCap()}
|
|
92
100
|
`);
|
|
93
101
|
let parseSkipped = 0;
|
|
94
102
|
if (result[0]?.values) {
|
|
@@ -116,6 +124,36 @@ export async function getHNSWIndex(options) {
|
|
|
116
124
|
if (parseSkipped > 0) {
|
|
117
125
|
console.warn(`[memory-initializer] skipped ${parseSkipped} rows with malformed embeddings`);
|
|
118
126
|
}
|
|
127
|
+
// #1468 — drop graph vectors the metadata load did not cover, so the two
|
|
128
|
+
// structures agree.
|
|
129
|
+
//
|
|
130
|
+
// Only the sidecar path can disagree. When the sidecar is absent the loop
|
|
131
|
+
// above inserts each vector alongside its metadata row, so the cap bounds
|
|
132
|
+
// both together. `hnsw-persistence` builds the sidecar over every embedded
|
|
133
|
+
// row with no LIMIT, so a store past the cap pairs a complete graph with a
|
|
134
|
+
// truncated map — and `searchHNSWIndex` silently skips a hit it cannot
|
|
135
|
+
// resolve (`if (!entry) continue`). Those vectors are unreachable by
|
|
136
|
+
// construction: they consume graph space and ANN slots that would
|
|
137
|
+
// otherwise hold a result the caller can actually receive. Rows past the
|
|
138
|
+
// cap stay reachable through the complete brute-force scan in
|
|
139
|
+
// `entries-read.ts`, which now runs whenever the ANN under-fills.
|
|
140
|
+
//
|
|
141
|
+
// Guarded on a non-empty map, which covers the SELECT that succeeds and
|
|
142
|
+
// returns nothing: far more likely a read that went wrong than a store
|
|
143
|
+
// whose rows all vanished, and emptying the graph on that reading is
|
|
144
|
+
// unrecoverable until the next rebuild. A read that *throws* needs no
|
|
145
|
+
// guard — it exits to the catch below and never reaches here, leaving the
|
|
146
|
+
// graph exactly as the sidecar supplied it. Both ways out leave a stale
|
|
147
|
+
// graph rather than an empty one, which is the cheaper mistake.
|
|
148
|
+
if (sidecarLoaded && hnswIndex.entries.size > 0) {
|
|
149
|
+
const orphaned = [];
|
|
150
|
+
for (const id of hnsw.ids()) {
|
|
151
|
+
if (!hnswIndex.entries.has(id))
|
|
152
|
+
orphaned.push(id);
|
|
153
|
+
}
|
|
154
|
+
for (const id of orphaned)
|
|
155
|
+
hnsw.remove(id);
|
|
156
|
+
}
|
|
119
157
|
sqlDb.close();
|
|
120
158
|
}
|
|
121
159
|
catch (err) {
|
|
@@ -162,6 +200,47 @@ export async function addToHNSWIndex(id, embedding, entry) {
|
|
|
162
200
|
return false;
|
|
163
201
|
}
|
|
164
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Remove an entry from the in-process HNSW index — graph vector and metadata
|
|
205
|
+
* row both (#1468).
|
|
206
|
+
*
|
|
207
|
+
* `deleteEntry` used to delete the row and stop there. The store path maintains
|
|
208
|
+
* the index (`addToHNSWIndex`); the delete path had no counterpart and there was
|
|
209
|
+
* no removal function to call, even though `HnswLite.remove` has always existed.
|
|
210
|
+
* `entries` is an in-process map rebuilt from SQL, so the consequence was not
|
|
211
|
+
* merely an orphaned vector: the deleted entry's metadata outlived the row and
|
|
212
|
+
* the local HNSW path could keep **returning deleted content** until the process
|
|
213
|
+
* restarted. A curation pass had no way to see that.
|
|
214
|
+
*
|
|
215
|
+
* Addressed by (namespace, key) rather than id because that is what every caller
|
|
216
|
+
* of `deleteEntry` has. The scan is linear, which is fine — deletes are rare and
|
|
217
|
+
* searches are not, so the cost belongs here rather than in a second index.
|
|
218
|
+
*
|
|
219
|
+
* Never forces a load. When this process holds no initialized index there is
|
|
220
|
+
* nothing stale to correct, and the next load reads a DB the row is already gone
|
|
221
|
+
* from. Returns the number of entries removed.
|
|
222
|
+
*/
|
|
223
|
+
export function removeFromHNSWIndex(namespace, key) {
|
|
224
|
+
const index = hnswIndex;
|
|
225
|
+
if (!index?.initialized)
|
|
226
|
+
return 0;
|
|
227
|
+
const ids = [];
|
|
228
|
+
for (const [id, entry] of index.entries) {
|
|
229
|
+
if (entry.key === key && entry.namespace === namespace)
|
|
230
|
+
ids.push(id);
|
|
231
|
+
}
|
|
232
|
+
for (const id of ids) {
|
|
233
|
+
index.entries.delete(id);
|
|
234
|
+
try {
|
|
235
|
+
index.db.remove?.(id);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// A graph that refuses the removal leaves an unreachable vector, which is
|
|
239
|
+
// the pre-#1468 state and strictly better than the metadata row surviving.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return ids.length;
|
|
243
|
+
}
|
|
165
244
|
/**
|
|
166
245
|
* Search HNSW index (approximate-nearest-neighbor; scales sub-linearly vs. brute-force)
|
|
167
246
|
* Returns results sorted by similarity (highest first)
|
|
@@ -177,12 +256,10 @@ export async function searchHNSWIndex(queryEmbedding, options) {
|
|
|
177
256
|
const index = await getHNSWIndex({ dimensions: queryEmbedding.length });
|
|
178
257
|
if (!index)
|
|
179
258
|
return null;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const results = await index.db.search({ vector, k: k * 2 }); // Get extra for filtering
|
|
185
|
-
const filtered = [];
|
|
259
|
+
const k = options?.k ?? 10;
|
|
260
|
+
/** Resolve raw graph hits through the metadata map, applying the namespace filter. */
|
|
261
|
+
const collect = (results) => {
|
|
262
|
+
const hits = [];
|
|
186
263
|
for (const result of results) {
|
|
187
264
|
const entry = index.entries.get(result.id);
|
|
188
265
|
if (!entry)
|
|
@@ -194,7 +271,7 @@ export async function searchHNSWIndex(queryEmbedding, options) {
|
|
|
194
271
|
// Convert cosine distance to similarity score (1 - distance)
|
|
195
272
|
// Cosine distance: 0 = identical, 2 = opposite
|
|
196
273
|
const score = 1 - (result.score / 2);
|
|
197
|
-
|
|
274
|
+
hits.push({
|
|
198
275
|
id: entry.id.substring(0, 12),
|
|
199
276
|
key: entry.key || entry.id.substring(0, 15),
|
|
200
277
|
content: entry.content.substring(0, 60) + (entry.content.length > 60 ? '...' : ''),
|
|
@@ -202,9 +279,35 @@ export async function searchHNSWIndex(queryEmbedding, options) {
|
|
|
202
279
|
namespace: entry.namespace,
|
|
203
280
|
metadata: entry.metadata
|
|
204
281
|
});
|
|
205
|
-
if (
|
|
282
|
+
if (hits.length >= k)
|
|
206
283
|
break;
|
|
207
284
|
}
|
|
285
|
+
return hits;
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
const vector = new Float32Array(queryEmbedding);
|
|
289
|
+
// HNSW search returns results with cosine distance (lower = more similar)
|
|
290
|
+
let filtered = collect(await index.db.search({ vector, k: k * 2 })); // Get extra for filtering
|
|
291
|
+
// #1468 — widen once when a namespace filter starved the result.
|
|
292
|
+
//
|
|
293
|
+
// The graph is one shared structure and the namespace filter runs AFTER
|
|
294
|
+
// retrieval, so a namespace holding a small share of the store routinely
|
|
295
|
+
// loses most of its `k * 2` candidates to rows in other namespaces and comes
|
|
296
|
+
// back short. That shortfall is not a signal about the store — those entries
|
|
297
|
+
// exist and a full scan would find them — and `searchEntries` reads a short
|
|
298
|
+
// result as "the index could not serve this", which would send every
|
|
299
|
+
// namespaced query on to a SQL scan that JSON.parses thousands of embeddings.
|
|
300
|
+
//
|
|
301
|
+
// `HnswLite.search` brute-forces whenever `k * 2` reaches the graph size, so
|
|
302
|
+
// passing the size guarantees complete coverage. It is a cosine pass over
|
|
303
|
+
// Float32Arrays already resident in memory — far cheaper than the SQL
|
|
304
|
+
// fallback it replaces. Skipped when the first search already covered the
|
|
305
|
+
// whole graph, since a second identical pass would add nothing.
|
|
306
|
+
const graphSize = await index.db.len();
|
|
307
|
+
const namespaced = Boolean(options?.namespace && options.namespace !== 'all');
|
|
308
|
+
if (namespaced && filtered.length < k && graphSize > k * 2) {
|
|
309
|
+
filtered = collect(await index.db.search({ vector, k: graphSize }));
|
|
310
|
+
}
|
|
208
311
|
// Sort by score descending (highest similarity first)
|
|
209
312
|
filtered.sort((a, b) => b.score - a.score);
|
|
210
313
|
return filtered;
|