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.
Files changed (32) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +1 -1
  2. package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -1
  3. package/.claude/guidance/shipped/moflo-yaml-reference.md +4 -4
  4. package/.claude/skills/memory-optimization/SKILL.md +1 -1
  5. package/.claude/skills/memory-patterns/SKILL.md +3 -3
  6. package/.claude/skills/vector-search/SKILL.md +2 -2
  7. package/README.md +5 -5
  8. package/bin/lib/daemon-port.mjs +66 -0
  9. package/dist/src/cli/commands/daemon.js +31 -10
  10. package/dist/src/cli/commands/doctor-checks-config.js +139 -1
  11. package/dist/src/cli/commands/doctor-fixes.js +75 -2
  12. package/dist/src/cli/commands/doctor-registry.js +10 -1
  13. package/dist/src/cli/commands/memory.js +8 -8
  14. package/dist/src/cli/commands/neural.js +8 -6
  15. package/dist/src/cli/config/moflo-config.js +68 -3
  16. package/dist/src/cli/index.js +18 -19
  17. package/dist/src/cli/init/moflo-yaml-template.js +1 -1
  18. package/dist/src/cli/mcp-server.js +59 -10
  19. package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
  20. package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
  21. package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
  22. package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
  23. package/dist/src/cli/memory/daemon-write-client.js +178 -49
  24. package/dist/src/cli/memory/database-provider.js +58 -3
  25. package/dist/src/cli/memory/intelligence.js +54 -26
  26. package/dist/src/cli/memory/memory-initializer.js +21 -11
  27. package/dist/src/cli/services/daemon-dashboard.js +94 -25
  28. package/dist/src/cli/services/daemon-lock.js +390 -3
  29. package/dist/src/cli/services/daemon-port.js +217 -0
  30. package/dist/src/cli/version.js +1 -1
  31. package/package.json +2 -2
  32. 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
- /** Default daemon HTTP port. Mirrors `DEFAULT_DASHBOARD_PORT` in daemon-dashboard.ts. */
44
- const DEFAULT_DAEMON_PORT = 3117;
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 fromEnv = process.env.MOFLO_DAEMON_PORT;
64
- if (fromEnv) {
65
- const n = parseInt(fromEnv, 10);
66
- if (Number.isFinite(n) && n > 0 && n < 65536)
67
- return n;
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
- // Health probe (cached) — GET /api/status
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 available = await probeDaemonHealth(getDaemonPort());
117
- healthCache = { available, checkedAt: now };
118
- return available;
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 POST that returns a daemon-read envelope. Same transport
326
- * guarantees as `postJson`: never throws, bounded timeout, invalidates health
327
- * cache on routed-failure.
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
- * 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.
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 postReadJson(path, body, shape) {
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 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) => {
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 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 });
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
- req.write(payload);
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 = 'auto', verbose = false, walMode: _walMode = true, optimize = true, defaultNamespace = 'default', maxEntries = 1000000, autoPersistInterval = 5000, wasmPath: _wasmPath, } = options;
122
- // Select provider
123
- const selectedProvider = await selectProvider(provider, verbose);
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
- * Get the data directory for neural pattern persistence
22
- * Uses .moflo/neural in the current working directory,
23
- * falling back to home directory
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
- const cwd = process.cwd();
27
- const localDir = join(cwd, '.moflo', 'neural');
28
- const homeDir = join(homedir(), '.moflo', 'neural');
29
- // Prefer local directory if .moflo exists
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(), 'patterns.json');
45
+ return join(getDataDir(), PATTERNS_FILE);
50
46
  }
51
- /**
52
- * Get the stats file path
53
- */
54
47
  function getStatsPath() {
55
- return join(getDataDir(), 'stats.json');
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 per-namespace entry counts via a single GROUP BY query.
2224
- * Returns { namespaces: Record<string, number>, total: number }.
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
- if (!fs.existsSync(resolvedPath)) {
2230
- return { namespaces: {}, total: 0 };
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
- catch {
2248
- return { namespaces: {}, total: 0 };
2257
+ finally {
2258
+ db.close();
2249
2259
  }
2250
2260
  }
2251
2261
  export default {