moflo 4.10.7 → 4.10.8
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 +1 -1
- package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -1
- package/.claude/guidance/shipped/moflo-yaml-reference.md +4 -4
- package/.claude/skills/memory-optimization/SKILL.md +1 -1
- package/.claude/skills/memory-patterns/SKILL.md +3 -3
- package/.claude/skills/vector-search/SKILL.md +2 -2
- package/README.md +5 -5
- package/bin/lib/daemon-port.mjs +66 -0
- package/dist/src/cli/commands/daemon.js +31 -10
- package/dist/src/cli/commands/doctor-checks-config.js +139 -1
- package/dist/src/cli/commands/doctor-fixes.js +75 -2
- package/dist/src/cli/commands/doctor-registry.js +10 -1
- package/dist/src/cli/commands/memory.js +8 -8
- package/dist/src/cli/commands/neural.js +8 -6
- package/dist/src/cli/config/moflo-config.js +68 -3
- package/dist/src/cli/index.js +18 -19
- package/dist/src/cli/init/moflo-yaml-template.js +1 -1
- package/dist/src/cli/mcp-server.js +59 -10
- package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
- package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
- package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
- package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
- package/dist/src/cli/memory/daemon-write-client.js +178 -49
- package/dist/src/cli/memory/database-provider.js +58 -3
- package/dist/src/cli/memory/intelligence.js +54 -26
- package/dist/src/cli/memory/memory-initializer.js +21 -11
- package/dist/src/cli/services/daemon-dashboard.js +94 -25
- package/dist/src/cli/services/daemon-lock.js +390 -3
- package/dist/src/cli/services/daemon-port.js +217 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/dist/src/cli/config-adapter.js +0 -182
|
@@ -37,11 +37,18 @@
|
|
|
37
37
|
* @module cli/memory/daemon-write-client
|
|
38
38
|
*/
|
|
39
39
|
import * as http from 'node:http';
|
|
40
|
+
import { findProjectRoot } from '../services/project-root.js';
|
|
41
|
+
import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealth as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
|
|
40
42
|
// ============================================================================
|
|
41
43
|
// Constants
|
|
42
44
|
// ============================================================================
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Read-only legacy default exported for tests; the actual port comes from
|
|
47
|
+
* `getDaemonPort()` which delegates to `resolveClientPort(findProjectRoot())`.
|
|
48
|
+
* Routes through `LEGACY_DEFAULT_PORT` so no literal port number lives in
|
|
49
|
+
* this file — see `daemon-port.ts` and the no-fixed-port regression guard.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_DAEMON_PORT = LEGACY_DEFAULT_PORT;
|
|
45
52
|
/** HTTP timeout for ALL daemon requests (probe + write). Bounds the worst-case CLI hang. */
|
|
46
53
|
const DAEMON_HTTP_TIMEOUT_MS = 100;
|
|
47
54
|
/** Health-probe cache TTL. Probe at most once per 5s in either direction. */
|
|
@@ -55,18 +62,35 @@ let configCache = null;
|
|
|
55
62
|
export function _resetForTest() {
|
|
56
63
|
healthCache = null;
|
|
57
64
|
configCache = null;
|
|
65
|
+
identityCache = null;
|
|
66
|
+
_portCache = null;
|
|
67
|
+
_identityWarnedFor.clear();
|
|
58
68
|
}
|
|
59
69
|
// ============================================================================
|
|
60
70
|
// Resolve daemon port (env override → moflo.yaml unused for v1 → default)
|
|
61
71
|
// ============================================================================
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the daemon HTTP port for this project.
|
|
74
|
+
*
|
|
75
|
+
* Delegates to `resolveClientPort(findProjectRoot())`:
|
|
76
|
+
* 1. `MOFLO_DAEMON_PORT` env override (consumer pin)
|
|
77
|
+
* 2. `port` field in `<projectRoot>/.moflo/daemon.lock` (server records
|
|
78
|
+
* the actual bound port after startup — #1145)
|
|
79
|
+
* 3. Deterministic per-project port `33000 + sha256(path)%1000`
|
|
80
|
+
*
|
|
81
|
+
* Cached per-process — the lock-file path doesn't change once a process is
|
|
82
|
+
* up. On a routed-failure the health cache is invalidated (which triggers
|
|
83
|
+
* the next port resolve), keeping the client honest about daemon location
|
|
84
|
+
* after a recycle.
|
|
85
|
+
*/
|
|
86
|
+
let _portCache = null;
|
|
62
87
|
function getDaemonPort() {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return DEFAULT_DAEMON_PORT;
|
|
88
|
+
const projectRoot = findProjectRoot();
|
|
89
|
+
if (_portCache && _portCache.projectRoot === projectRoot)
|
|
90
|
+
return _portCache.port;
|
|
91
|
+
const port = resolveClientPort(projectRoot);
|
|
92
|
+
_portCache = { port, projectRoot };
|
|
93
|
+
return port;
|
|
70
94
|
}
|
|
71
95
|
// ============================================================================
|
|
72
96
|
// Daemon-disabled check (cached) — reads `daemon.auto_start` from moflo.yaml
|
|
@@ -90,12 +114,18 @@ async function isDaemonEnabledInConfig() {
|
|
|
90
114
|
configCache = { daemonEnabled: enabled, checkedAt: now };
|
|
91
115
|
return enabled;
|
|
92
116
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
let identityCache = null;
|
|
118
|
+
/**
|
|
119
|
+
* Ports we've already warned about during this process — bounded by the
|
|
120
|
+
* number of distinct daemon ports a single client process can see in its
|
|
121
|
+
* lifetime (usually 1). Keeps the stderr noise to a single line per
|
|
122
|
+
* mismatched daemon per process.
|
|
123
|
+
*/
|
|
124
|
+
const _identityWarnedFor = new Set();
|
|
96
125
|
/**
|
|
97
126
|
* Cached daemon health probe. Returns true iff the daemon's HTTP server
|
|
98
|
-
* is reachable on `127.0.0.1:<port>` within {@link DAEMON_HTTP_TIMEOUT_MS}
|
|
127
|
+
* is reachable on `127.0.0.1:<port>` within {@link DAEMON_HTTP_TIMEOUT_MS}
|
|
128
|
+
* AND its `/api/health` reports a `projectRoot` matching ours (#1145).
|
|
99
129
|
*
|
|
100
130
|
* Cache survives 5s in either direction — so a daemon that just came up
|
|
101
131
|
* is missed for ≤5s, and a daemon that just died is incorrectly assumed
|
|
@@ -113,9 +143,74 @@ export async function isDaemonAvailable() {
|
|
|
113
143
|
if (healthCache && (now - healthCache.checkedAt) < HEALTH_CACHE_TTL_MS) {
|
|
114
144
|
return healthCache.available;
|
|
115
145
|
}
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
146
|
+
const port = getDaemonPort();
|
|
147
|
+
const reachable = await probeDaemonHealth(port);
|
|
148
|
+
if (!reachable) {
|
|
149
|
+
healthCache = { available: false, checkedAt: now };
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
// 4) Identity check — daemon reachable but is it OUR daemon?
|
|
153
|
+
const identityOk = await isDaemonIdentityMatch(port);
|
|
154
|
+
healthCache = { available: identityOk, checkedAt: now };
|
|
155
|
+
return identityOk;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Probe `/api/health` and confirm the daemon's reported `projectRoot`
|
|
159
|
+
* matches ours. Caches the result for {@link HEALTH_CACHE_TTL_MS}.
|
|
160
|
+
*
|
|
161
|
+
* Mismatch consequence: this function returns `false`, the caller falls
|
|
162
|
+
* through to the direct-SQL path (the path that is provably correct, see
|
|
163
|
+
* the `MOFLO_DISABLE_DAEMON_ROUTING=1` reproducer in
|
|
164
|
+
* `docs/internal/1145-daemon-port-collision-analysis.md`), and we emit
|
|
165
|
+
* ONE stderr line per port per process so the user can see the wrong-
|
|
166
|
+
* project daemon is the problem.
|
|
167
|
+
*
|
|
168
|
+
* Tolerant of legacy daemons that don't expose `/api/health`: a 404 means
|
|
169
|
+
* the daemon predates #1145, so we trust the legacy port resolution (the
|
|
170
|
+
* client is presumably hitting the same project's daemon anyway) and
|
|
171
|
+
* return `true`. The lock-file port-discovery path is the primary
|
|
172
|
+
* collision defence; identity check is the safety net.
|
|
173
|
+
*/
|
|
174
|
+
async function isDaemonIdentityMatch(port) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const ourProjectRoot = findProjectRoot();
|
|
177
|
+
if (identityCache &&
|
|
178
|
+
identityCache.ourProjectRoot === ourProjectRoot &&
|
|
179
|
+
(now - identityCache.checkedAt) < HEALTH_CACHE_TTL_MS) {
|
|
180
|
+
return identityCache.matches;
|
|
181
|
+
}
|
|
182
|
+
const probe = await probeDaemonHealthIdentity(port, DAEMON_HTTP_TIMEOUT_MS);
|
|
183
|
+
if (probe.kind === 'legacy' || probe.kind === 'unreachable') {
|
|
184
|
+
// No identity to compare — daemon either predates #1145 or the probe
|
|
185
|
+
// itself failed transport-side. Fall open: rely on port-discovery
|
|
186
|
+
// (lock file + deterministic hash) as the primary defence. Only a
|
|
187
|
+
// CONFIRMED mismatch blocks routing — that's the conservative safety
|
|
188
|
+
// net that doesn't break upgraded-client-against-legacy-daemon.
|
|
189
|
+
//
|
|
190
|
+
// Asymmetry with doctor's `checkDaemonIdentity`: the healer probes
|
|
191
|
+
// LEGACY_DEFAULT_PORT explicitly and flags a foreign legacy daemon
|
|
192
|
+
// as `fail`, while this hot path lets it through. That's intentional
|
|
193
|
+
// — the doctor runs on-demand for diagnostics, and live writes must
|
|
194
|
+
// not block when the cluster is mid-upgrade. The CHANGELOG migration
|
|
195
|
+
// window is the agreed remediation surface.
|
|
196
|
+
identityCache = { matches: true, checkedAt: now, ourProjectRoot };
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const matches = normalizeProjectRoot(probe.projectRoot) === normalizeProjectRoot(ourProjectRoot);
|
|
200
|
+
identityCache = {
|
|
201
|
+
matches,
|
|
202
|
+
checkedAt: now,
|
|
203
|
+
ourProjectRoot,
|
|
204
|
+
daemonProjectRoot: probe.projectRoot,
|
|
205
|
+
};
|
|
206
|
+
if (!matches && !_identityWarnedFor.has(port)) {
|
|
207
|
+
_identityWarnedFor.add(port);
|
|
208
|
+
// One stderr line per mismatched daemon, ever. Quiet enough that scripts
|
|
209
|
+
// don't drown but loud enough that healer-class diagnostics surface it.
|
|
210
|
+
process.stderr.write(`[moflo] daemon at 127.0.0.1:${port} claims project '${probe.projectRoot}' but cwd is '${ourProjectRoot}' — ` +
|
|
211
|
+
`using direct DB. Run flo healer --fix to repair daemon binding (#1145).\n`);
|
|
212
|
+
}
|
|
213
|
+
return matches;
|
|
119
214
|
}
|
|
120
215
|
function probeDaemonHealth(port) {
|
|
121
216
|
return new Promise((resolve) => {
|
|
@@ -212,6 +307,31 @@ export async function tryDaemonList(opts) {
|
|
|
212
307
|
total: typeof data?.total === 'number' ? data.total : 0,
|
|
213
308
|
}));
|
|
214
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Route a memory-stats query through the daemon (#1149). Single GROUP BY
|
|
312
|
+
* query server-side — replaces the list-and-iterate path in the MCP
|
|
313
|
+
* `memory_stats` handler that fetched up to 100 000 rows just to count
|
|
314
|
+
* them and tripped the daemon's `limit ≤ 10 000` cap.
|
|
315
|
+
*
|
|
316
|
+
* Returns `{ routed: false }` when the daemon is unavailable or any 5xx /
|
|
317
|
+
* transport fault fires — caller falls back to a direct
|
|
318
|
+
* `getNamespaceCounts()` so users never see a fake zero.
|
|
319
|
+
*/
|
|
320
|
+
export async function tryDaemonStats() {
|
|
321
|
+
if (!(await isDaemonAvailable()))
|
|
322
|
+
return { routed: false };
|
|
323
|
+
return requestReadJson('GET', '/api/memory/stats', undefined, (data) => {
|
|
324
|
+
if (typeof data?.totalEntries !== 'number')
|
|
325
|
+
return null;
|
|
326
|
+
return {
|
|
327
|
+
namespaces: (data?.namespaces && typeof data.namespaces === 'object')
|
|
328
|
+
? data.namespaces
|
|
329
|
+
: {},
|
|
330
|
+
totalEntries: data.totalEntries,
|
|
331
|
+
withEmbeddings: typeof data?.withEmbeddings === 'number' ? data.withEmbeddings : 0,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
}
|
|
215
335
|
// ============================================================================
|
|
216
336
|
// Internal HTTP poster — never throws, bounded timeout
|
|
217
337
|
// ============================================================================
|
|
@@ -261,8 +381,14 @@ function postJson(path, body) {
|
|
|
261
381
|
// On routed-failure, invalidate the health cache so the next call
|
|
262
382
|
// re-probes and trips back to direct-write quickly when the daemon
|
|
263
383
|
// is dying.
|
|
264
|
-
if (result.routed === false)
|
|
384
|
+
if (result.routed === false) {
|
|
385
|
+
// Daemon recycled to a different port (post #1145 server restart)
|
|
386
|
+
// → invalidate the port cache too so the next call re-reads
|
|
387
|
+
// .moflo/daemon.lock. Otherwise we'd keep hammering a stale port.
|
|
265
388
|
healthCache = null;
|
|
389
|
+
identityCache = null;
|
|
390
|
+
_portCache = null;
|
|
391
|
+
}
|
|
266
392
|
resolve(result);
|
|
267
393
|
};
|
|
268
394
|
const payload = JSON.stringify(body);
|
|
@@ -322,45 +448,49 @@ function postJson(path, body) {
|
|
|
322
448
|
});
|
|
323
449
|
}
|
|
324
450
|
/**
|
|
325
|
-
* Generic JSON
|
|
326
|
-
*
|
|
327
|
-
*
|
|
451
|
+
* Generic JSON read-request that returns a daemon-read envelope. Never
|
|
452
|
+
* throws, bounded by `DAEMON_HTTP_TIMEOUT_MS`, invalidates the health +
|
|
453
|
+
* identity + port caches on any routed-failure (daemon-recycle covers).
|
|
328
454
|
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
455
|
+
* Failure-shape contract (#1101):
|
|
456
|
+
* 2xx → routed:true with shape(data)
|
|
457
|
+
* 4xx → routed:true with error (caller propagates)
|
|
458
|
+
* 5xx / transport / parse-fail → routed:false (caller falls back)
|
|
459
|
+
*
|
|
460
|
+
* `body === undefined` ⇒ GET (no Content-* headers); otherwise POST with
|
|
461
|
+
* JSON body. The `shape` callback maps parsed JSON to the typed payload;
|
|
462
|
+
* returning `null` downgrades the response to routed:false so the caller
|
|
463
|
+
* falls back to bridge-direct.
|
|
332
464
|
*/
|
|
333
|
-
function
|
|
465
|
+
function requestReadJson(method, path, body,
|
|
466
|
+
// `data` is a JSON.parse boundary — typed `any` here mirrors JSON.parse's
|
|
467
|
+
// own return type so callers can do safe optional-chaining narrowing.
|
|
468
|
+
shape) {
|
|
334
469
|
return new Promise((resolve) => {
|
|
335
470
|
let done = false;
|
|
336
471
|
const finish = (result) => {
|
|
337
472
|
if (done)
|
|
338
473
|
return;
|
|
339
474
|
done = true;
|
|
340
|
-
if (result.routed === false)
|
|
475
|
+
if (result.routed === false) {
|
|
476
|
+
// Daemon may have recycled to a new port (post-#1145 restart) →
|
|
477
|
+
// invalidate the lock-file-port cache and the identity probe so
|
|
478
|
+
// the next call re-discovers reality.
|
|
341
479
|
healthCache = null;
|
|
480
|
+
identityCache = null;
|
|
481
|
+
_portCache = null;
|
|
482
|
+
}
|
|
342
483
|
resolve(result);
|
|
343
484
|
};
|
|
344
|
-
const payload = JSON.stringify(body);
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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) => {
|
|
485
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
486
|
+
const headers = payload === undefined
|
|
487
|
+
? undefined
|
|
488
|
+
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) };
|
|
489
|
+
const req = http.request({ host: '127.0.0.1', port: getDaemonPort(), path, method, timeout: DAEMON_HTTP_TIMEOUT_MS, headers }, (res) => {
|
|
356
490
|
let buf = '';
|
|
357
491
|
res.setEncoding('utf8');
|
|
358
492
|
res.on('data', (chunk) => { buf += chunk; });
|
|
359
493
|
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
494
|
const status = res.statusCode ?? 0;
|
|
365
495
|
if (status >= 500 || status < 200) {
|
|
366
496
|
finish({ routed: false });
|
|
@@ -371,13 +501,8 @@ function postReadJson(path, body, shape) {
|
|
|
371
501
|
return;
|
|
372
502
|
}
|
|
373
503
|
try {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
if (shaped === null) {
|
|
377
|
-
finish({ routed: false });
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
finish({ routed: true, data: shaped });
|
|
504
|
+
const shaped = shape(JSON.parse(buf));
|
|
505
|
+
finish(shaped === null ? { routed: false } : { routed: true, data: shaped });
|
|
381
506
|
}
|
|
382
507
|
catch {
|
|
383
508
|
finish({ routed: false });
|
|
@@ -387,8 +512,12 @@ function postReadJson(path, body, shape) {
|
|
|
387
512
|
});
|
|
388
513
|
req.on('error', () => finish({ routed: false }));
|
|
389
514
|
req.on('timeout', () => { req.destroy(); finish({ routed: false }); });
|
|
390
|
-
|
|
515
|
+
if (payload !== undefined)
|
|
516
|
+
req.write(payload);
|
|
391
517
|
req.end();
|
|
392
518
|
});
|
|
393
519
|
}
|
|
520
|
+
function postReadJson(path, body, shape) {
|
|
521
|
+
return requestReadJson('POST', path, body, shape);
|
|
522
|
+
}
|
|
394
523
|
//# sourceMappingURL=daemon-write-client.js.map
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
import { platform } from 'node:os';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { SqliteBackend } from './sqlite-backend.js';
|
|
14
|
+
/**
|
|
15
|
+
* Canonical label returned in MCP `backend` fields and other consumer-visible
|
|
16
|
+
* surfaces. Single source of truth so a future engine swap is a one-line edit
|
|
17
|
+
* instead of an 8-site grep. Phase 5 (#1084) finalized node:sqlite as the
|
|
18
|
+
* only SQLite backend; the HNSW vector index sits on top.
|
|
19
|
+
*/
|
|
20
|
+
export const BACKEND_LABEL = 'node:sqlite + HNSW';
|
|
14
21
|
/**
|
|
15
22
|
* Detect platform and recommend provider
|
|
16
23
|
*/
|
|
@@ -118,9 +125,15 @@ async function selectProvider(preferred, verbose = false) {
|
|
|
118
125
|
* ```
|
|
119
126
|
*/
|
|
120
127
|
export async function createDatabase(path, options = {}) {
|
|
121
|
-
const { provider
|
|
122
|
-
//
|
|
123
|
-
|
|
128
|
+
const { provider, verbose = false, walMode: _walMode = true, optimize = true, defaultNamespace = 'default', maxEntries = 1000000, autoPersistInterval = 5000, wasmPath: _wasmPath, } = options;
|
|
129
|
+
// When no explicit provider is given, consult moflo.yaml's
|
|
130
|
+
// `memory.backend` knob (#1144). This is what makes the YAML value
|
|
131
|
+
// truthful instead of cosmetic — the runtime now actually honours
|
|
132
|
+
// whatever the consumer put in their config. Falls back to `'auto'` if
|
|
133
|
+
// the config can't be loaded (e.g. running from a directory with no
|
|
134
|
+
// `moflo.yaml`), preserving the previous behaviour for raw callers.
|
|
135
|
+
const effectiveProvider = provider ?? (await preferredProviderFromConfig(verbose)) ?? 'auto';
|
|
136
|
+
const selectedProvider = await selectProvider(effectiveProvider, verbose);
|
|
124
137
|
if (verbose) {
|
|
125
138
|
console.log(`[DatabaseProvider] Creating database with provider: ${selectedProvider}`);
|
|
126
139
|
console.log(`[DatabaseProvider] Database path: ${path}`);
|
|
@@ -178,6 +191,48 @@ export async function createDatabase(path, options = {}) {
|
|
|
178
191
|
export function getPlatformInfo() {
|
|
179
192
|
return detectPlatform();
|
|
180
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Read `memory.backend` from the project's `moflo.yaml`, resolve any
|
|
196
|
+
* deprecated aliases (sql.js → node-sqlite), and return a value
|
|
197
|
+
* `selectProvider()` understands. Returns `null` on any failure so
|
|
198
|
+
* `createDatabase()` cleanly falls back to platform auto-detection
|
|
199
|
+
* — config loading must never break the runtime.
|
|
200
|
+
*
|
|
201
|
+
* Wrapped in a dynamic import so the memory subtree doesn't pull
|
|
202
|
+
* `js-yaml` / `fs` into hot paths (e.g. the in-memory test backend).
|
|
203
|
+
*
|
|
204
|
+
* Memoised per (cwd, process) — a test suite or daemon that opens many
|
|
205
|
+
* DBs in sequence parses moflo.yaml once. Keyed on cwd so a test that
|
|
206
|
+
* `chdir`s into a temp dir gets a fresh resolution.
|
|
207
|
+
*/
|
|
208
|
+
const _resolvedProviderCache = new Map();
|
|
209
|
+
async function preferredProviderFromConfig(verbose) {
|
|
210
|
+
const key = process.cwd();
|
|
211
|
+
if (_resolvedProviderCache.has(key)) {
|
|
212
|
+
return _resolvedProviderCache.get(key) ?? null;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const { loadMofloConfig, resolveDatabaseProvider } = await import('../config/moflo-config.js');
|
|
216
|
+
const cfg = loadMofloConfig();
|
|
217
|
+
const resolved = resolveDatabaseProvider(cfg.memory.backend);
|
|
218
|
+
if (verbose) {
|
|
219
|
+
console.log(`[DatabaseProvider] moflo.yaml memory.backend="${cfg.memory.backend}" → ${resolved}`);
|
|
220
|
+
}
|
|
221
|
+
_resolvedProviderCache.set(key, resolved);
|
|
222
|
+
return resolved;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (verbose) {
|
|
226
|
+
console.warn(`[DatabaseProvider] Could not load moflo.yaml backend preference (${err.message}) — falling back to auto-detection`);
|
|
227
|
+
}
|
|
228
|
+
_resolvedProviderCache.set(key, null);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** @internal — test hook only; resets the per-cwd cache between cases. */
|
|
233
|
+
export function _resetPreferredProviderCache() {
|
|
234
|
+
_resolvedProviderCache.clear();
|
|
235
|
+
}
|
|
181
236
|
/**
|
|
182
237
|
* Check which providers are available.
|
|
183
238
|
*
|
|
@@ -10,31 +10,30 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @module v3/cli/intelligence
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
14
|
import { join } from 'node:path';
|
|
16
15
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
16
|
+
import { findProjectRoot } from '../services/project-root.js';
|
|
17
|
+
import { MOFLO_DIR, mofloHomeDir } from '../services/moflo-paths.js';
|
|
17
18
|
// ============================================================================
|
|
18
19
|
// Persistence Configuration
|
|
19
20
|
// ============================================================================
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const NEURAL_SUBDIR = 'neural';
|
|
22
|
+
const PATTERNS_FILE = 'patterns.json';
|
|
23
|
+
const STATS_FILE = 'stats.json';
|
|
24
|
+
// #1152: pre-fix builds wrote neural patterns to `~/.moflo/neural/` whenever
|
|
25
|
+
// cwd lacked `.moflo`. The bleed was silent — every moflo-using project on
|
|
26
|
+
// the machine shared one ReasoningBank. Resolver now anchors on
|
|
27
|
+
// findProjectRoot() like neural-tools.ts (#829); legacy home-dir files are
|
|
28
|
+
// copied (not moved) into the active project on first load so users do not
|
|
29
|
+
// lose history when older co-installed projects still point at the home-dir
|
|
30
|
+
// copy.
|
|
25
31
|
function getDataDir() {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (existsSync(join(cwd, '.moflo'))) {
|
|
31
|
-
return localDir;
|
|
32
|
-
}
|
|
33
|
-
return homeDir;
|
|
32
|
+
return join(findProjectRoot(), MOFLO_DIR, NEURAL_SUBDIR);
|
|
33
|
+
}
|
|
34
|
+
function getLegacyDataDir() {
|
|
35
|
+
return join(mofloHomeDir(), NEURAL_SUBDIR);
|
|
34
36
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Ensure the data directory exists
|
|
37
|
-
*/
|
|
38
37
|
function ensureDataDir() {
|
|
39
38
|
const dir = getDataDir();
|
|
40
39
|
if (!existsSync(dir)) {
|
|
@@ -42,17 +41,36 @@ function ensureDataDir() {
|
|
|
42
41
|
}
|
|
43
42
|
return dir;
|
|
44
43
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Get the patterns file path
|
|
47
|
-
*/
|
|
48
44
|
function getPatternsPath() {
|
|
49
|
-
return join(getDataDir(),
|
|
45
|
+
return join(getDataDir(), PATTERNS_FILE);
|
|
50
46
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Get the stats file path
|
|
53
|
-
*/
|
|
54
47
|
function getStatsPath() {
|
|
55
|
-
return join(getDataDir(),
|
|
48
|
+
return join(getDataDir(), STATS_FILE);
|
|
49
|
+
}
|
|
50
|
+
// Latch is intentionally not async-safe — all I/O in this module is
|
|
51
|
+
// synchronous so re-entry only happens via clearIntelligence() in tests.
|
|
52
|
+
let legacyMigrationAttempted = false;
|
|
53
|
+
function migrateLegacyIfNeeded() {
|
|
54
|
+
if (legacyMigrationAttempted)
|
|
55
|
+
return;
|
|
56
|
+
legacyMigrationAttempted = true;
|
|
57
|
+
const legacyDir = getLegacyDataDir();
|
|
58
|
+
const localDir = getDataDir();
|
|
59
|
+
if (legacyDir === localDir)
|
|
60
|
+
return;
|
|
61
|
+
for (const file of [PATTERNS_FILE, STATS_FILE]) {
|
|
62
|
+
const legacy = join(legacyDir, file);
|
|
63
|
+
const local = join(localDir, file);
|
|
64
|
+
if (existsSync(legacy) && !existsSync(local)) {
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(localDir, { recursive: true });
|
|
67
|
+
copyFileSync(legacy, local);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Best-effort migration; failures fall through to a fresh local store.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
56
74
|
}
|
|
57
75
|
// ============================================================================
|
|
58
76
|
// Default Configuration
|
|
@@ -173,6 +191,7 @@ class LocalReasoningBank {
|
|
|
173
191
|
*/
|
|
174
192
|
loadFromDisk() {
|
|
175
193
|
try {
|
|
194
|
+
migrateLegacyIfNeeded();
|
|
176
195
|
const path = getPatternsPath();
|
|
177
196
|
if (existsSync(path)) {
|
|
178
197
|
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
@@ -207,6 +226,13 @@ class LocalReasoningBank {
|
|
|
207
226
|
* Immediately flush patterns to disk
|
|
208
227
|
*/
|
|
209
228
|
flushToDisk() {
|
|
229
|
+
// Cancel any pending debounced save — we are writing right now and the
|
|
230
|
+
// deferred handler would otherwise fire post-teardown on short-lived
|
|
231
|
+
// processes (test runners hit this as a Windows EPERM during cleanup).
|
|
232
|
+
if (this.saveTimeout) {
|
|
233
|
+
clearTimeout(this.saveTimeout);
|
|
234
|
+
this.saveTimeout = null;
|
|
235
|
+
}
|
|
210
236
|
if (!this.persistenceEnabled || !this.dirty)
|
|
211
237
|
return;
|
|
212
238
|
try {
|
|
@@ -368,6 +394,7 @@ let globalStats = {
|
|
|
368
394
|
*/
|
|
369
395
|
function loadPersistedStats() {
|
|
370
396
|
try {
|
|
397
|
+
migrateLegacyIfNeeded();
|
|
371
398
|
const path = getStatsPath();
|
|
372
399
|
if (existsSync(path)) {
|
|
373
400
|
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
@@ -605,6 +632,7 @@ export function clearIntelligence() {
|
|
|
605
632
|
sonaCoordinator = null;
|
|
606
633
|
reasoningBank = null;
|
|
607
634
|
intelligenceInitialized = false;
|
|
635
|
+
legacyMigrationAttempted = false;
|
|
608
636
|
globalStats = {
|
|
609
637
|
trajectoriesRecorded: 0,
|
|
610
638
|
lastAdaptation: null
|
|
@@ -2220,32 +2220,42 @@ export async function deleteEntry(options) {
|
|
|
2220
2220
|
}
|
|
2221
2221
|
}
|
|
2222
2222
|
/**
|
|
2223
|
-
* Get
|
|
2224
|
-
*
|
|
2223
|
+
* Get memory stats via a single GROUP BY query — namespace counts plus the
|
|
2224
|
+
* number of rows that carry a non-null embedding. One trip to disk; the
|
|
2225
|
+
* server-side aggregation replaces a pre-#1149 client iteration that
|
|
2226
|
+
* fetched 100 000 rows just to count them.
|
|
2227
|
+
*
|
|
2228
|
+
* Throws on DB read errors. Returns a zero shape ONLY when the DB file
|
|
2229
|
+
* doesn't exist yet (the real "empty project" signal) — never swallows a
|
|
2230
|
+
* locked/corrupt-DB error into a fake zero, since that's the exact silent
|
|
2231
|
+
* wrong-answer this fix is for.
|
|
2225
2232
|
*/
|
|
2226
2233
|
export async function getNamespaceCounts(dbPath) {
|
|
2227
2234
|
const resolvedPath = dbPath || memoryDbPath(process.cwd());
|
|
2235
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
2236
|
+
return { namespaces: {}, total: 0, withEmbeddings: 0 };
|
|
2237
|
+
}
|
|
2238
|
+
const db = openDaemonDatabase(resolvedPath);
|
|
2228
2239
|
try {
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
}
|
|
2232
|
-
const db = openDaemonDatabase(resolvedPath);
|
|
2233
|
-
const result = db.exec("SELECT namespace, COUNT(*) as cnt FROM memory_entries WHERE status = 'active' GROUP BY namespace ORDER BY cnt DESC");
|
|
2234
|
-
db.close();
|
|
2240
|
+
const result = db.exec("SELECT namespace, COUNT(*) AS cnt, SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) AS emb_cnt " +
|
|
2241
|
+
"FROM memory_entries WHERE status = 'active' GROUP BY namespace ORDER BY cnt DESC");
|
|
2235
2242
|
const namespaces = {};
|
|
2236
2243
|
let total = 0;
|
|
2244
|
+
let withEmbeddings = 0;
|
|
2237
2245
|
if (result[0]?.values) {
|
|
2238
2246
|
for (const row of result[0].values) {
|
|
2239
2247
|
const ns = String(row[0]);
|
|
2240
2248
|
const count = Number(row[1]);
|
|
2249
|
+
const embCount = Number(row[2] ?? 0);
|
|
2241
2250
|
namespaces[ns] = count;
|
|
2242
2251
|
total += count;
|
|
2252
|
+
withEmbeddings += embCount;
|
|
2243
2253
|
}
|
|
2244
2254
|
}
|
|
2245
|
-
return { namespaces, total };
|
|
2255
|
+
return { namespaces, total, withEmbeddings };
|
|
2246
2256
|
}
|
|
2247
|
-
|
|
2248
|
-
|
|
2257
|
+
finally {
|
|
2258
|
+
db.close();
|
|
2249
2259
|
}
|
|
2250
2260
|
}
|
|
2251
2261
|
export default {
|