moflo 4.9.37 → 4.10.1
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-memory-protocol.md +5 -1
- package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
- package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
- package/.claude/helpers/statusline.cjs +69 -33
- package/.claude/helpers/subagent-bootstrap.json +1 -1
- package/.claude/helpers/subagent-start.cjs +1 -1
- package/bin/build-embeddings.mjs +6 -20
- package/bin/cli.js +5 -0
- package/bin/generate-code-map.mjs +4 -24
- package/bin/hooks.mjs +3 -12
- package/bin/index-all.mjs +3 -13
- package/bin/index-guidance.mjs +36 -85
- package/bin/index-patterns.mjs +6 -24
- package/bin/index-tests.mjs +4 -23
- package/bin/lib/db-repair.mjs +358 -62
- package/bin/lib/get-backend.mjs +306 -0
- package/bin/lib/incremental-write.mjs +27 -7
- package/bin/lib/moflo-paths.mjs +64 -4
- package/bin/lib/suppress-sqlite-warning.mjs +57 -0
- package/bin/migrations/knowledge-purge.mjs +7 -8
- package/bin/migrations/knowledge-to-learnings.mjs +7 -9
- package/bin/migrations/purge-doc-entries.mjs +7 -8
- package/bin/migrations/strip-context-preambles.mjs +4 -6
- package/bin/run-migrations.mjs +1 -10
- package/bin/semantic-search.mjs +7 -18
- package/bin/session-start-launcher.mjs +144 -108
- package/bin/simplify-classify.cjs +38 -17
- package/dist/src/cli/commands/daemon.js +38 -11
- package/dist/src/cli/commands/doctor-checks-config.js +60 -0
- package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
- package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
- package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
- package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
- package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
- package/dist/src/cli/commands/doctor-fixes.js +87 -0
- package/dist/src/cli/commands/doctor-registry.js +24 -1
- package/dist/src/cli/commands/doctor.js +1 -1
- package/dist/src/cli/commands/embeddings.js +17 -22
- package/dist/src/cli/commands/memory.js +13 -23
- package/dist/src/cli/embeddings/persistent-cache.js +44 -83
- package/dist/src/cli/init/moflo-init.js +40 -0
- package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
- package/dist/src/cli/memory/bridge-core.js +256 -30
- package/dist/src/cli/memory/bridge-embedder.js +84 -3
- package/dist/src/cli/memory/bridge-entries.js +70 -6
- package/dist/src/cli/memory/controller-registry.js +7 -2
- package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
- package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
- package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
- package/dist/src/cli/memory/daemon-backend.js +400 -0
- package/dist/src/cli/memory/daemon-write-client.js +192 -15
- package/dist/src/cli/memory/database-provider.js +57 -40
- package/dist/src/cli/memory/hnsw-persistence.js +6 -8
- package/dist/src/cli/memory/index.js +0 -1
- package/dist/src/cli/memory/memory-bridge.js +40 -8
- package/dist/src/cli/memory/memory-initializer.js +271 -211
- package/dist/src/cli/memory/rvf-migration.js +25 -11
- package/dist/src/cli/memory/sqlite-backend.js +573 -0
- package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
- package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
- package/dist/src/cli/services/daemon-dashboard.js +13 -1
- package/dist/src/cli/services/daemon-lock.js +58 -1
- package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
- package/dist/src/cli/services/embeddings-migration.js +9 -12
- package/dist/src/cli/services/ephemeral-namespace-purge.js +21 -16
- package/dist/src/cli/services/learning-service.js +12 -20
- package/dist/src/cli/services/memory-db-integrity-repair.js +119 -0
- package/dist/src/cli/services/project-root.js +69 -9
- package/dist/src/cli/services/soft-delete-purge.js +6 -11
- package/dist/src/cli/services/sqljs-migration-store.js +4 -1
- package/dist/src/cli/services/subagent-bootstrap.js +1 -1
- package/dist/src/cli/shared/events/event-store.js +26 -55
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -4
- package/dist/src/cli/memory/sqljs-backend.js +0 -643
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon
|
|
2
|
+
* Daemon RPC client (#981 / #984 / #1058 — single-writer architecture and
|
|
3
|
+
* its read-side symmetry).
|
|
3
4
|
*
|
|
4
|
-
* HTTP client for the `POST /api/memory/{store,delete,batch}`
|
|
5
|
-
*
|
|
6
|
-
* route their `.moflo/moflo.db`
|
|
7
|
-
* authoritative sql.js handle. Avoids the
|
|
5
|
+
* HTTP client for the `POST /api/memory/{store,delete,batch,get,search,list}`
|
|
6
|
+
* RPC added by Stories #983 and #1058. Lets short-lived CLI processes and
|
|
7
|
+
* the long-lived MCP server route their `.moflo/moflo.db` operations through
|
|
8
|
+
* the daemon, which owns the authoritative sql.js handle. Avoids the
|
|
9
|
+
* multi-process write clobber from #981 AND the stale read snapshot from
|
|
10
|
+
* #1058 (sql.js never re-reads disk after init).
|
|
8
11
|
*
|
|
9
12
|
* Contract — every function in this module:
|
|
10
|
-
* - Never throws.
|
|
13
|
+
* - Never throws. Outcomes are reported via the result envelope.
|
|
11
14
|
* - Returns within ≤100ms even if the daemon is dead/slow (HTTP timeout).
|
|
12
|
-
* - Caches daemon health for 5s to keep the hot
|
|
15
|
+
* - Caches daemon health for 5s to keep the hot path cheap.
|
|
13
16
|
* - Short-circuits without HTTP when:
|
|
14
17
|
* (a) `process.env.MOFLO_IS_DAEMON === '1'` (daemon's own process)
|
|
15
18
|
* (b) `moflo.yaml` has `daemon.auto_start: false`
|
|
16
19
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
20
|
+
* Failure-shape contract (#1101 — surface 4xx as a real error):
|
|
21
|
+
* - 4xx response → `routed: true, ok: false, error: <msg>` (writes)
|
|
22
|
+
* → `routed: true, error: <msg>` (reads)
|
|
23
|
+
* → caller PROPAGATES the error; does NOT fall back.
|
|
24
|
+
* - 5xx, 503, timeout, → `routed: false`
|
|
25
|
+
* ECONNREFUSED, malformed JSON, → caller falls back to bridge-direct.
|
|
26
|
+
* socket destroyed mid-stream
|
|
27
|
+
*
|
|
28
|
+
* The 4xx codes only fire on daemon-side payload validation (see
|
|
29
|
+
* daemon-memory-rpc.ts). Bridge-direct has the same validation and would
|
|
30
|
+
* fail the same way — falling back silently loses the daemon's actionable
|
|
31
|
+
* error message. 5xx and transport faults are transient/daemon-side bugs;
|
|
32
|
+
* bridge-direct is the right next step.
|
|
33
|
+
*
|
|
34
|
+
* Naming note: the module is named `daemon-write-client` for compat with
|
|
35
|
+
* existing importers, but as of #1058 it also covers reads.
|
|
20
36
|
*
|
|
21
37
|
* @module cli/memory/daemon-write-client
|
|
22
38
|
*/
|
|
@@ -136,6 +152,7 @@ export async function tryDaemonStore(opts) {
|
|
|
136
152
|
value: opts.value,
|
|
137
153
|
tags: opts.tags,
|
|
138
154
|
ttl: opts.ttl,
|
|
155
|
+
metadata: opts.metadata,
|
|
139
156
|
});
|
|
140
157
|
}
|
|
141
158
|
/**
|
|
@@ -150,9 +167,90 @@ export async function tryDaemonDelete(opts) {
|
|
|
150
167
|
key: opts.key,
|
|
151
168
|
});
|
|
152
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Route a single-entry retrieve through the daemon (#1058). Returns
|
|
172
|
+
* `{ routed: false }` if the daemon is unavailable; otherwise
|
|
173
|
+
* `{ routed: true, data: { found, entry? } }`. The `entry` field is the
|
|
174
|
+
* same shape as `getEntry`'s in-process return.
|
|
175
|
+
*/
|
|
176
|
+
export async function tryDaemonGet(opts) {
|
|
177
|
+
if (!(await isDaemonAvailable()))
|
|
178
|
+
return { routed: false };
|
|
179
|
+
return postReadJson('/api/memory/get', { namespace: opts.namespace, key: opts.key }, (data) => ({
|
|
180
|
+
found: !!data?.found,
|
|
181
|
+
entry: data?.entry,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Route a semantic search through the daemon (#1058).
|
|
186
|
+
*/
|
|
187
|
+
export async function tryDaemonSearch(opts) {
|
|
188
|
+
if (!(await isDaemonAvailable()))
|
|
189
|
+
return { routed: false };
|
|
190
|
+
return postReadJson('/api/memory/search', {
|
|
191
|
+
query: opts.query,
|
|
192
|
+
namespace: opts.namespace,
|
|
193
|
+
limit: opts.limit,
|
|
194
|
+
threshold: opts.threshold,
|
|
195
|
+
}, (data) => ({
|
|
196
|
+
results: Array.isArray(data?.results) ? data.results : [],
|
|
197
|
+
searchTime: typeof data?.searchTime === 'number' ? data.searchTime : undefined,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Route a paginated list through the daemon (#1058).
|
|
202
|
+
*/
|
|
203
|
+
export async function tryDaemonList(opts) {
|
|
204
|
+
if (!(await isDaemonAvailable()))
|
|
205
|
+
return { routed: false };
|
|
206
|
+
return postReadJson('/api/memory/list', {
|
|
207
|
+
namespace: opts.namespace,
|
|
208
|
+
limit: opts.limit,
|
|
209
|
+
offset: opts.offset,
|
|
210
|
+
}, (data) => ({
|
|
211
|
+
entries: Array.isArray(data?.entries) ? data.entries : [],
|
|
212
|
+
total: typeof data?.total === 'number' ? data.total : 0,
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
153
215
|
// ============================================================================
|
|
154
216
|
// Internal HTTP poster — never throws, bounded timeout
|
|
155
217
|
// ============================================================================
|
|
218
|
+
/**
|
|
219
|
+
* Extract a human-readable error message from a daemon 4xx response body.
|
|
220
|
+
* Prefers `message` (the daemon's specific reason — e.g. "invalid namespace"),
|
|
221
|
+
* falls back to `error` (the daemon's error category), then to a generic
|
|
222
|
+
* status-code string when the body is non-JSON.
|
|
223
|
+
*/
|
|
224
|
+
function parse4xxError(buf, status) {
|
|
225
|
+
try {
|
|
226
|
+
const data = JSON.parse(buf);
|
|
227
|
+
const detail = typeof data?.message === 'string' ? data.message
|
|
228
|
+
: typeof data?.error === 'string' ? data.error
|
|
229
|
+
: undefined;
|
|
230
|
+
if (detail)
|
|
231
|
+
return detail;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Non-JSON 4xx body — fall through to the generic message.
|
|
235
|
+
}
|
|
236
|
+
return `daemon returned ${status}`;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Narrow a parsed JSON value to the `{ dimensions, model }` embedding-response
|
|
240
|
+
* shape (#1065). Returns `undefined` when the field is missing or malformed —
|
|
241
|
+
* a malformed field is treated as "no embedding info" rather than failing the
|
|
242
|
+
* whole response, so an older daemon that hasn't been updated still works.
|
|
243
|
+
*/
|
|
244
|
+
function parseEmbeddingField(value) {
|
|
245
|
+
if (!value || typeof value !== 'object')
|
|
246
|
+
return undefined;
|
|
247
|
+
const v = value;
|
|
248
|
+
if (typeof v.dimensions !== 'number' || !Number.isFinite(v.dimensions))
|
|
249
|
+
return undefined;
|
|
250
|
+
if (typeof v.model !== 'string' || v.model.length === 0)
|
|
251
|
+
return undefined;
|
|
252
|
+
return { dimensions: v.dimensions, model: v.model };
|
|
253
|
+
}
|
|
156
254
|
function postJson(path, body) {
|
|
157
255
|
return new Promise((resolve) => {
|
|
158
256
|
let done = false;
|
|
@@ -183,15 +281,23 @@ function postJson(path, body) {
|
|
|
183
281
|
res.setEncoding('utf8');
|
|
184
282
|
res.on('data', (chunk) => { buf += chunk; });
|
|
185
283
|
res.on('end', () => {
|
|
186
|
-
//
|
|
187
|
-
// caller
|
|
188
|
-
//
|
|
189
|
-
//
|
|
284
|
+
// #1101 — per-shape failure contract:
|
|
285
|
+
// 2xx → routed:true, ok:true (caller uses data)
|
|
286
|
+
// 4xx → routed:true, ok:false (caller propagates daemon error)
|
|
287
|
+
// 5xx → routed:false (caller falls back to bridge)
|
|
288
|
+
// parse fail → routed:false (fall back)
|
|
190
289
|
const status = res.statusCode ?? 0;
|
|
191
|
-
if (status
|
|
290
|
+
if (status >= 500 || status < 200) {
|
|
192
291
|
finish({ routed: false });
|
|
193
292
|
return;
|
|
194
293
|
}
|
|
294
|
+
if (status >= 400) {
|
|
295
|
+
// Daemon validated the payload and rejected it. Bridge-direct
|
|
296
|
+
// has the same validation; falling back loses the actionable
|
|
297
|
+
// error. Surface it to the caller instead.
|
|
298
|
+
finish({ routed: true, ok: false, error: parse4xxError(buf, status) });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
195
301
|
try {
|
|
196
302
|
const data = JSON.parse(buf);
|
|
197
303
|
finish({
|
|
@@ -199,6 +305,7 @@ function postJson(path, body) {
|
|
|
199
305
|
ok: !!data?.ok,
|
|
200
306
|
id: typeof data?.id === 'string' ? data.id : undefined,
|
|
201
307
|
deleted: typeof data?.deleted === 'boolean' ? data.deleted : undefined,
|
|
308
|
+
embedding: parseEmbeddingField(data?.embedding),
|
|
202
309
|
error: typeof data?.error === 'string' ? data.error : undefined,
|
|
203
310
|
});
|
|
204
311
|
}
|
|
@@ -214,4 +321,74 @@ function postJson(path, body) {
|
|
|
214
321
|
req.end();
|
|
215
322
|
});
|
|
216
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Generic JSON POST that returns a daemon-read envelope. Same transport
|
|
326
|
+
* guarantees as `postJson`: never throws, bounded timeout, invalidates health
|
|
327
|
+
* cache on routed-failure.
|
|
328
|
+
*
|
|
329
|
+
* The `shape` callback maps the daemon's parsed JSON payload to the typed
|
|
330
|
+
* data shape the caller expects. Returning `null` from `shape` (or a parse
|
|
331
|
+
* failure) downgrades to `{ routed: false }` so the caller falls back.
|
|
332
|
+
*/
|
|
333
|
+
function postReadJson(path, body, shape) {
|
|
334
|
+
return new Promise((resolve) => {
|
|
335
|
+
let done = false;
|
|
336
|
+
const finish = (result) => {
|
|
337
|
+
if (done)
|
|
338
|
+
return;
|
|
339
|
+
done = true;
|
|
340
|
+
if (result.routed === false)
|
|
341
|
+
healthCache = null;
|
|
342
|
+
resolve(result);
|
|
343
|
+
};
|
|
344
|
+
const payload = JSON.stringify(body);
|
|
345
|
+
const req = http.request({
|
|
346
|
+
host: '127.0.0.1',
|
|
347
|
+
port: getDaemonPort(),
|
|
348
|
+
path,
|
|
349
|
+
method: 'POST',
|
|
350
|
+
timeout: DAEMON_HTTP_TIMEOUT_MS,
|
|
351
|
+
headers: {
|
|
352
|
+
'Content-Type': 'application/json',
|
|
353
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
354
|
+
},
|
|
355
|
+
}, (res) => {
|
|
356
|
+
let buf = '';
|
|
357
|
+
res.setEncoding('utf8');
|
|
358
|
+
res.on('data', (chunk) => { buf += chunk; });
|
|
359
|
+
res.on('end', () => {
|
|
360
|
+
// #1101 — mirror postJson contract for reads:
|
|
361
|
+
// 2xx → routed:true with shaped data
|
|
362
|
+
// 4xx → routed:true with error (no data) — caller propagates
|
|
363
|
+
// 5xx → routed:false (caller falls back)
|
|
364
|
+
const status = res.statusCode ?? 0;
|
|
365
|
+
if (status >= 500 || status < 200) {
|
|
366
|
+
finish({ routed: false });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (status >= 400) {
|
|
370
|
+
finish({ routed: true, error: parse4xxError(buf, status) });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(buf);
|
|
375
|
+
const shaped = shape(parsed);
|
|
376
|
+
if (shaped === null) {
|
|
377
|
+
finish({ routed: false });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
finish({ routed: true, data: shaped });
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
finish({ routed: false });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
res.on('error', () => finish({ routed: false }));
|
|
387
|
+
});
|
|
388
|
+
req.on('error', () => finish({ routed: false }));
|
|
389
|
+
req.on('timeout', () => { req.destroy(); finish({ routed: false }); });
|
|
390
|
+
req.write(payload);
|
|
391
|
+
req.end();
|
|
392
|
+
});
|
|
393
|
+
}
|
|
217
394
|
//# sourceMappingURL=daemon-write-client.js.map
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DatabaseProvider
|
|
2
|
+
* DatabaseProvider — moflo's IMemoryBackend factory.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
4
|
+
* Phase 5 (#1084) removed the sql.js backend. Selection now collapses to:
|
|
5
|
+
* - `node-sqlite` (Node 22+ built-in, the only SQLite backend)
|
|
6
|
+
* - `rvf` (pure-TS fallback when node:sqlite is somehow unavailable)
|
|
7
|
+
* - `json` (last-resort file storage when nothing else works)
|
|
8
8
|
*
|
|
9
9
|
* @module v3/memory/database-provider
|
|
10
10
|
*/
|
|
11
11
|
import { platform } from 'node:os';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
|
-
import {
|
|
13
|
+
import { SqliteBackend } from './sqlite-backend.js';
|
|
14
14
|
/**
|
|
15
15
|
* Detect platform and recommend provider
|
|
16
16
|
*/
|
|
@@ -19,8 +19,9 @@ function detectPlatform() {
|
|
|
19
19
|
const isWindows = os === 'win32';
|
|
20
20
|
const isMacOS = os === 'darwin';
|
|
21
21
|
const isLinux = os === 'linux';
|
|
22
|
-
//
|
|
23
|
-
|
|
22
|
+
// Phase 4 (#1083) flipped the default to node:sqlite (built into Node 22+,
|
|
23
|
+
// moflo's minimum). Phase 5 (#1084) deletes the sql.js backend + dep.
|
|
24
|
+
const recommendedProvider = 'node-sqlite';
|
|
24
25
|
return {
|
|
25
26
|
os,
|
|
26
27
|
isWindows,
|
|
@@ -35,55 +36,60 @@ function detectPlatform() {
|
|
|
35
36
|
async function testRvf() {
|
|
36
37
|
return true;
|
|
37
38
|
}
|
|
38
|
-
/** better-sqlite3 removed — sql.js is the only SQLite backend */
|
|
39
39
|
/**
|
|
40
|
-
* Test if
|
|
40
|
+
* Test if the built-in node:sqlite engine is available (Node 22+).
|
|
41
|
+
*
|
|
42
|
+
* Loads the suppress-sqlite-warning side-effect BEFORE the probe import so
|
|
43
|
+
* the once-per-process ExperimentalWarning never fires (#1098). A probe
|
|
44
|
+
* that prints the warning to stderr defeats every consumer's "clean
|
|
45
|
+
* startup" expectation even when the rest of the run is healthy.
|
|
41
46
|
*/
|
|
42
|
-
async function
|
|
47
|
+
async function testNodeSqlite() {
|
|
43
48
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const testDb = new SQL.Database();
|
|
47
|
-
testDb.close();
|
|
49
|
+
await import('./suppress-sqlite-warning.js');
|
|
50
|
+
await import('node:sqlite');
|
|
48
51
|
return true;
|
|
49
52
|
}
|
|
50
|
-
catch
|
|
53
|
+
catch {
|
|
51
54
|
return false;
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
55
|
-
* Select best available provider
|
|
58
|
+
* Select best available provider.
|
|
59
|
+
*
|
|
60
|
+
* Phase 5 (#1084) collapsed the chain — sql.js is gone, so the order is
|
|
61
|
+
* just: explicit override → node:sqlite → RVF → JSON. Passing `'sql.js'`
|
|
62
|
+
* explicitly is a hard error.
|
|
56
63
|
*/
|
|
57
64
|
async function selectProvider(preferred, verbose = false) {
|
|
65
|
+
if (preferred === 'sql.js') {
|
|
66
|
+
throw new Error(`DatabaseProvider: sql.js was removed in Phase 5 (#1084). ` +
|
|
67
|
+
`Use 'node-sqlite' (the new default) or omit the provider entirely.`);
|
|
68
|
+
}
|
|
58
69
|
if (preferred && preferred !== 'auto') {
|
|
59
70
|
if (verbose) {
|
|
60
71
|
console.log(`[DatabaseProvider] Using explicitly specified provider: ${preferred}`);
|
|
61
72
|
}
|
|
62
73
|
return preferred;
|
|
63
74
|
}
|
|
64
|
-
|
|
75
|
+
if (await testNodeSqlite()) {
|
|
76
|
+
if (verbose) {
|
|
77
|
+
console.log('[DatabaseProvider] node:sqlite available — using new default');
|
|
78
|
+
}
|
|
79
|
+
return 'node-sqlite';
|
|
80
|
+
}
|
|
81
|
+
// node:sqlite missing is the "broken install" signal — surface it whenever
|
|
82
|
+
// verbose is on so the fallback chain doesn't silently regress consumers
|
|
83
|
+
// to a slower backend without anyone noticing.
|
|
65
84
|
if (verbose) {
|
|
66
|
-
console.
|
|
67
|
-
console.log(`[DatabaseProvider] Recommended provider: ${platformInfo.recommendedProvider}`);
|
|
85
|
+
console.warn('[DatabaseProvider] node:sqlite unavailable — check Node version (22+ required); falling back to RVF');
|
|
68
86
|
}
|
|
69
|
-
// Try RVF first (always available via pure-TS fallback)
|
|
70
87
|
if (await testRvf()) {
|
|
71
|
-
if (verbose) {
|
|
72
|
-
console.log('[DatabaseProvider] RVF backend available');
|
|
73
|
-
}
|
|
74
88
|
return 'rvf';
|
|
75
89
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (verbose) {
|
|
79
|
-
console.log('[DatabaseProvider] sql.js available and working');
|
|
80
|
-
}
|
|
81
|
-
return 'sql.js';
|
|
82
|
-
}
|
|
83
|
-
else if (verbose) {
|
|
84
|
-
console.log('[DatabaseProvider] sql.js not available, using JSON fallback');
|
|
90
|
+
if (verbose) {
|
|
91
|
+
console.warn('[DatabaseProvider] node:sqlite + RVF unavailable — falling back to JSON');
|
|
85
92
|
}
|
|
86
|
-
// Final fallback to JSON
|
|
87
93
|
return 'json';
|
|
88
94
|
}
|
|
89
95
|
/**
|
|
@@ -112,7 +118,7 @@ async function selectProvider(preferred, verbose = false) {
|
|
|
112
118
|
* ```
|
|
113
119
|
*/
|
|
114
120
|
export async function createDatabase(path, options = {}) {
|
|
115
|
-
const { provider = 'auto', verbose = false, walMode = true, optimize = true, defaultNamespace = 'default', maxEntries = 1000000, autoPersistInterval = 5000, wasmPath, } = options;
|
|
121
|
+
const { provider = 'auto', verbose = false, walMode: _walMode = true, optimize = true, defaultNamespace = 'default', maxEntries = 1000000, autoPersistInterval = 5000, wasmPath: _wasmPath, } = options;
|
|
116
122
|
// Select provider
|
|
117
123
|
const selectedProvider = await selectProvider(provider, verbose);
|
|
118
124
|
if (verbose) {
|
|
@@ -122,6 +128,13 @@ export async function createDatabase(path, options = {}) {
|
|
|
122
128
|
let backend;
|
|
123
129
|
switch (selectedProvider) {
|
|
124
130
|
case 'sql.js': {
|
|
131
|
+
// selectProvider() guards against 'sql.js' as an explicit preference,
|
|
132
|
+
// but a stale caller could still land here if `auto` resolution drifted
|
|
133
|
+
// (it can't — left only for exhaustive-check safety).
|
|
134
|
+
throw new Error(`DatabaseProvider: sql.js was removed in Phase 5 (#1084). ` +
|
|
135
|
+
`This case is unreachable; if you see this error, file a bug.`);
|
|
136
|
+
}
|
|
137
|
+
case 'node-sqlite': {
|
|
125
138
|
const config = {
|
|
126
139
|
databasePath: path,
|
|
127
140
|
optimize,
|
|
@@ -129,9 +142,8 @@ export async function createDatabase(path, options = {}) {
|
|
|
129
142
|
maxEntries,
|
|
130
143
|
verbose,
|
|
131
144
|
autoPersistInterval,
|
|
132
|
-
wasmPath,
|
|
133
145
|
};
|
|
134
|
-
backend = new
|
|
146
|
+
backend = new SqliteBackend(config);
|
|
135
147
|
break;
|
|
136
148
|
}
|
|
137
149
|
case 'rvf': {
|
|
@@ -167,13 +179,18 @@ export function getPlatformInfo() {
|
|
|
167
179
|
return detectPlatform();
|
|
168
180
|
}
|
|
169
181
|
/**
|
|
170
|
-
* Check which providers are available
|
|
182
|
+
* Check which providers are available.
|
|
183
|
+
*
|
|
184
|
+
* `sqlJs` / `betterSqlite3` are retained for API stability but always
|
|
185
|
+
* report `false` — Phase 5 (#1084) deleted the sql.js backend and
|
|
186
|
+
* better-sqlite3 was never wired.
|
|
171
187
|
*/
|
|
172
188
|
export async function getAvailableProviders() {
|
|
173
189
|
return {
|
|
174
190
|
rvf: true,
|
|
175
|
-
betterSqlite3: false,
|
|
176
|
-
sqlJs:
|
|
191
|
+
betterSqlite3: false,
|
|
192
|
+
sqlJs: false,
|
|
193
|
+
nodeSqlite: await testNodeSqlite(),
|
|
177
194
|
json: true,
|
|
178
195
|
};
|
|
179
196
|
}
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as fs from 'node:fs';
|
|
18
18
|
import * as path from 'node:path';
|
|
19
|
-
import { mofloImport } from '../services/moflo-require.js';
|
|
20
19
|
import { atomicWriteFileSync } from '../services/atomic-file-write.js';
|
|
21
20
|
import { HnswLite } from './hnsw-lite.js';
|
|
22
21
|
import { parseEmbeddingJson } from './controllers/_shared.js';
|
|
23
22
|
import { hnswIndexPath } from '../services/moflo-paths.js';
|
|
23
|
+
import { openDaemonDatabase } from './daemon-backend.js';
|
|
24
24
|
/**
|
|
25
25
|
* Build an HnswLite from every active row in `dbPath` that has an embedding,
|
|
26
26
|
* then atomically write the sidecar to `<projectRoot>/.moflo/hnsw.index`.
|
|
@@ -37,13 +37,11 @@ export async function buildAndWriteHnswSidecar(dbPath, projectRoot, options = {}
|
|
|
37
37
|
if (!fs.existsSync(dbPath)) {
|
|
38
38
|
throw new Error(`buildAndWriteHnswSidecar: db not found at ${dbPath}`);
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const buf = fs.readFileSync(dbPath);
|
|
46
|
-
const db = new SQL.Database(buf);
|
|
40
|
+
// node:sqlite via the unified factory — Phase 5 (#1084) replaced the
|
|
41
|
+
// sql.js readFileSync + new SQL.Database round-trip with a direct open
|
|
42
|
+
// through openDaemonDatabase. WAL writes incrementally so there's nothing
|
|
43
|
+
// to flush back here; the sidecar persistence below is unaffected.
|
|
44
|
+
const db = openDaemonDatabase(dbPath);
|
|
47
45
|
const hnsw = new HnswLite(dimensions, m, efConstruction, metric);
|
|
48
46
|
let skipped = 0;
|
|
49
47
|
try {
|
|
@@ -61,7 +61,6 @@ export { ControllerRegistry, INIT_LEVELS } from './controller-registry.js';
|
|
|
61
61
|
export { CONTROLLER_SPECS } from './controller-specs.js';
|
|
62
62
|
// ===== Core Components =====
|
|
63
63
|
export { MofloDbAdapter } from './moflo-db-adapter.js';
|
|
64
|
-
export { SqlJsBackend } from './sqljs-backend.js';
|
|
65
64
|
export { RvfBackend } from './rvf-backend.js';
|
|
66
65
|
export { HnswLite, cosineSimilarity } from './hnsw-lite.js';
|
|
67
66
|
export { buildAndWriteHnswSidecar, tryLoadHnswSidecar } from './hnsw-persistence.js';
|
|
@@ -119,17 +119,49 @@ export async function bridgeAddToHNSW(id, embedding, entry, dbPath) {
|
|
|
119
119
|
// Bridge-produced vectors come from getBridgeEmbedder() (fastembed, 384-dim).
|
|
120
120
|
// Pre-#648 this column was hardcoded to 'Xenova/all-MiniLM-L6-v2' which
|
|
121
121
|
// misrepresented the producing model — fixed to the canonical bridge label.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
//
|
|
123
|
+
// Pre-#1067: this issued `INSERT OR REPLACE INTO memory_entries (...)` with
|
|
124
|
+
// only the embedding-related columns. When `storeEntry`'s direct-write
|
|
125
|
+
// fallback first inserted a row carrying `metadata`/`tags`/`expires_at`
|
|
126
|
+
// and then called `addToHNSWIndex(...)` for the same id, REPLACE clobbered
|
|
127
|
+
// the row — wiping the metadata column to NULL and re-introducing the
|
|
128
|
+
// "first chunk has no navigation" smoke failure even though serialiseMetadata
|
|
129
|
+
// produced the right JSON one statement earlier. UPDATE-by-id preserves the
|
|
130
|
+
// pre-existing row's metadata/tags/expires_at/access_count while still
|
|
131
|
+
// back-filling embedding columns for callers that supplied them.
|
|
132
|
+
const updated = ctx.db.prepare(`
|
|
133
|
+
UPDATE memory_entries
|
|
134
|
+
SET embedding = ?,
|
|
135
|
+
embedding_dimensions = ?,
|
|
136
|
+
embedding_model = ?,
|
|
137
|
+
updated_at = ?
|
|
138
|
+
WHERE id = ?
|
|
128
139
|
`).run([
|
|
129
|
-
id, entry.key, entry.namespace, entry.content,
|
|
130
140
|
embeddingJson, embedding.length, BRIDGE_EMBEDDING_MODEL,
|
|
131
|
-
now,
|
|
141
|
+
now, id,
|
|
132
142
|
]);
|
|
143
|
+
// If no row was matched by id (rare: a rebuild path passing an embedding
|
|
144
|
+
// for a key that never went through storeEntry first) fall back to an
|
|
145
|
+
// INSERT carrying every column the bridge can populate, so the row still
|
|
146
|
+
// becomes searchable. Metadata/tags/expires_at stay NULL because the
|
|
147
|
+
// caller didn't supply them — same shape `bridgeStoreEntry` would write
|
|
148
|
+
// for a no-metadata caller.
|
|
149
|
+
const rowCount = typeof updated.changes === 'number'
|
|
150
|
+
? updated.changes
|
|
151
|
+
: (ctx.db.getRowsModified?.() ?? 0);
|
|
152
|
+
if (rowCount === 0) {
|
|
153
|
+
ctx.db.prepare(`
|
|
154
|
+
INSERT INTO memory_entries (
|
|
155
|
+
id, key, namespace, content, type,
|
|
156
|
+
embedding, embedding_dimensions, embedding_model,
|
|
157
|
+
created_at, updated_at, status
|
|
158
|
+
) VALUES (?, ?, ?, ?, 'semantic', ?, ?, ?, ?, ?, 'active')
|
|
159
|
+
`).run([
|
|
160
|
+
id, entry.key, entry.namespace, entry.content,
|
|
161
|
+
embeddingJson, embedding.length, BRIDGE_EMBEDDING_MODEL,
|
|
162
|
+
now, now,
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
133
165
|
persistBridgeDb(ctx.db, dbPath);
|
|
134
166
|
return true;
|
|
135
167
|
});
|