moflo 4.10.7 → 4.10.9

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 (38) 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/hooks.mjs +3 -2
  9. package/bin/index-all.mjs +3 -2
  10. package/bin/index-guidance.mjs +4 -4
  11. package/bin/lib/daemon-port.mjs +66 -0
  12. package/bin/lib/process-manager.mjs +3 -3
  13. package/dist/src/cli/commands/daemon.js +31 -10
  14. package/dist/src/cli/commands/doctor-checks-config.js +182 -10
  15. package/dist/src/cli/commands/doctor-fixes.js +208 -3
  16. package/dist/src/cli/commands/doctor-registry.js +16 -1
  17. package/dist/src/cli/commands/memory.js +8 -8
  18. package/dist/src/cli/commands/neural.js +8 -6
  19. package/dist/src/cli/config/moflo-config.js +68 -3
  20. package/dist/src/cli/index.js +18 -19
  21. package/dist/src/cli/init/moflo-yaml-template.js +1 -1
  22. package/dist/src/cli/mcp-server.js +59 -10
  23. package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
  24. package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
  25. package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
  26. package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
  27. package/dist/src/cli/memory/daemon-write-client.js +178 -49
  28. package/dist/src/cli/memory/database-provider.js +58 -3
  29. package/dist/src/cli/memory/intelligence.js +54 -26
  30. package/dist/src/cli/memory/memory-initializer.js +21 -11
  31. package/dist/src/cli/movector/model-router.js +1 -1
  32. package/dist/src/cli/movector/q-learning-router.js +2 -2
  33. package/dist/src/cli/services/daemon-dashboard.js +94 -25
  34. package/dist/src/cli/services/daemon-lock.js +390 -3
  35. package/dist/src/cli/services/daemon-port.js +252 -0
  36. package/dist/src/cli/version.js +1 -1
  37. package/package.json +2 -2
  38. package/dist/src/cli/config-adapter.js +0 -182
@@ -1,17 +1,16 @@
1
1
  /**
2
- * Memory MCP Tools for CLI - V3 with sql.js/HNSW Backend
2
+ * Memory MCP Tools for CLI node:sqlite + HNSW backend
3
3
  *
4
- * UPGRADED: Now uses the advanced sql.js + HNSW backend for:
5
- * - 150x-12,500x faster semantic search
6
- * - Vector embeddings with cosine similarity
7
- * - Persistent SQLite storage (WASM)
8
- * - Backward compatible with legacy JSON storage (auto-migrates)
4
+ * Backed by Node's built-in `node:sqlite` engine (Phase 4 #1083 flipped the
5
+ * default; Phase 5 #1084 deleted the prior sql.js path) plus an HNSW vector
6
+ * index for semantic search. Auto-migrates legacy JSON stores on first use.
9
7
  *
10
8
  * @module v3/cli/mcp-tools/memory-tools
11
9
  */
12
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
13
11
  import { join, resolve } from 'path';
14
12
  import { GateService } from '../services/spell-gate.js';
13
+ import { BACKEND_LABEL } from '../memory/database-provider.js';
15
14
  // Paths
16
15
  const MEMORY_DIR = '.moflo/memory';
17
16
  const LEGACY_MEMORY_FILE = 'store.json';
@@ -108,7 +107,7 @@ function shapeRetrievedEntry(entry) {
108
107
  hasEmbedding: entry.hasEmbedding,
109
108
  navigation: parseNavigation(entry.metadata, 'full'),
110
109
  found: true,
111
- backend: 'sql.js + HNSW',
110
+ backend: BACKEND_LABEL,
112
111
  };
113
112
  }
114
113
  function validateMemoryInput(key, value, query) {
@@ -185,7 +184,7 @@ async function ensureInitialized() {
185
184
  if (hasLegacyStore()) {
186
185
  const legacyStore = loadLegacyStore();
187
186
  if (legacyStore && Object.keys(legacyStore.entries).length > 0) {
188
- console.error('[MCP Memory] Migrating legacy JSON store to sql.js...');
187
+ console.error('[MCP Memory] Migrating legacy JSON store to node:sqlite...');
189
188
  let migrated = 0;
190
189
  for (const [key, entry] of Object.entries(legacyStore.entries)) {
191
190
  try {
@@ -211,7 +210,7 @@ async function ensureInitialized() {
211
210
  export const memoryTools = [
212
211
  {
213
212
  name: 'memory_store',
214
- description: 'Store a value in memory with vector embedding for semantic search (sql.js + HNSW backend). Upserts by default — pass upsert:false to fail on duplicate keys. Optional `metadata` lets chunk-row producers set the navigation fields (parentDoc, prevChunk, nextChunk, siblings, …) that `memory_get_neighbors` reads.',
213
+ description: 'Store a value in memory with vector embedding for semantic search (node:sqlite + HNSW backend). Upserts by default — pass upsert:false to fail on duplicate keys. Optional `metadata` lets chunk-row producers set the navigation fields (parentDoc, prevChunk, nextChunk, siblings, …) that `memory_get_neighbors` reads.',
215
214
  category: 'memory',
216
215
  inputSchema: {
217
216
  type: 'object',
@@ -268,7 +267,7 @@ export const memoryTools = [
268
267
  storedAt: new Date().toISOString(),
269
268
  hasEmbedding: !!result.embedding,
270
269
  embeddingDimensions: result.embedding?.dimensions || null,
271
- backend: 'sql.js + HNSW',
270
+ backend: BACKEND_LABEL,
272
271
  storeTime: `${duration.toFixed(2)}ms`,
273
272
  error: result.error,
274
273
  };
@@ -387,7 +386,7 @@ export const memoryTools = [
387
386
  query,
388
387
  results,
389
388
  total: results.length,
390
- backend: 'HNSW + sql.js',
389
+ backend: BACKEND_LABEL,
391
390
  };
392
391
  }
393
392
  catch (error) {
@@ -479,7 +478,7 @@ export const memoryTools = [
479
478
  include,
480
479
  neighbors,
481
480
  total: neighbors.length,
482
- backend: 'sql.js + HNSW',
481
+ backend: BACKEND_LABEL,
483
482
  };
484
483
  }
485
484
  catch (error) {
@@ -524,7 +523,7 @@ export const memoryTools = [
524
523
  key,
525
524
  namespace,
526
525
  deleted,
527
- backend: 'sql.js + HNSW',
526
+ backend: BACKEND_LABEL,
528
527
  ...(errorReason ? { error: errorReason } : {}),
529
528
  };
530
529
  }
@@ -577,7 +576,7 @@ export const memoryTools = [
577
576
  total: result.total,
578
577
  limit,
579
578
  offset,
580
- backend: 'sql.js + HNSW',
579
+ backend: BACKEND_LABEL,
581
580
  };
582
581
  }
583
582
  catch (error) {
@@ -601,27 +600,47 @@ export const memoryTools = [
601
600
  },
602
601
  handler: async () => {
603
602
  await ensureInitialized();
604
- const { checkMemoryInitialization, listEntries } = await getMemoryFunctions();
603
+ const { checkMemoryInitialization } = await getMemoryFunctions();
605
604
  try {
606
605
  const status = await checkMemoryInitialization();
607
- const allEntries = await listEntries({ limit: 100000 });
608
- // Count by namespace
609
- const namespaces = {};
610
- let withEmbeddings = 0;
611
- for (const entry of allEntries.entries) {
612
- namespaces[entry.namespace] = (namespaces[entry.namespace] || 0) + 1;
613
- if (entry.hasEmbedding)
614
- withEmbeddings++;
606
+ // #1149 server-side aggregation. The pre-fix path iterated every
607
+ // namespace via listEntries({limit:100000}) and tripped the daemon's
608
+ // `limit 10 000` cap → 400 → tryDaemonList defaulted total to 0 →
609
+ // the MCP tool silently reported zero entries on populated DBs.
610
+ // Route through the dedicated stats endpoint; on routed:false, run
611
+ // the same GROUP BY in-process so users always see real counts; on
612
+ // a daemon error, surface it rather than masking it as zero.
613
+ const { tryDaemonStats } = await import('../memory/daemon-write-client.js');
614
+ const routed = await tryDaemonStats();
615
+ let namespaces;
616
+ let totalEntries;
617
+ let withEmbeddings;
618
+ if (routed.routed && routed.data) {
619
+ ({ namespaces, totalEntries, withEmbeddings } = routed.data);
620
+ }
621
+ else if (routed.routed && routed.error) {
622
+ return {
623
+ initialized: status.initialized,
624
+ error: `daemon memory_stats failed: ${routed.error}`,
625
+ backend: BACKEND_LABEL,
626
+ };
627
+ }
628
+ else {
629
+ const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
630
+ const direct = await getNamespaceCounts();
631
+ namespaces = direct.namespaces;
632
+ totalEntries = direct.total;
633
+ withEmbeddings = direct.withEmbeddings;
615
634
  }
616
635
  return {
617
636
  initialized: status.initialized,
618
- totalEntries: allEntries.total,
637
+ totalEntries,
619
638
  entriesWithEmbeddings: withEmbeddings,
620
- embeddingCoverage: allEntries.total > 0
621
- ? `${((withEmbeddings / allEntries.total) * 100).toFixed(1)}%`
639
+ embeddingCoverage: totalEntries > 0
640
+ ? `${((withEmbeddings / totalEntries) * 100).toFixed(1)}%`
622
641
  : '0%',
623
642
  namespaces,
624
- backend: 'sql.js + HNSW',
643
+ backend: BACKEND_LABEL,
625
644
  version: status.version || '3.0.0',
626
645
  features: status.features || {
627
646
  vectorEmbeddings: true,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Per ADR-048: Bridges Claude Code's auto memory (markdown files at
5
5
  * ~/.claude/projects/<project>/memory/) with claude-flow's unified memory
6
- * system (MofloDb: sql.js + HNSW).
6
+ * system (MofloDb: node:sqlite + HNSW).
7
7
  *
8
8
  * Auto memory files are human-readable markdown that Claude loads into its
9
9
  * system prompt. MEMORY.md (first 200 lines) is the entrypoint; topic files
@@ -2,7 +2,7 @@
2
2
  * AttestationLog — moflo-owned append-only audit log (epic #464 Phase C2).
3
3
  *
4
4
  * Replaces `agentdb.AttestationLog`. Writes observability records into a
5
- * sql.js-backed table with a hash chain so tampering can be detected.
5
+ * SQLite table with a hash chain so tampering can be detected.
6
6
  *
7
7
  * Consumer surface (from src/cli/memory/memory-bridge.ts):
8
8
  * - record({ operation, entryId, timestamp?, ...metadata })
@@ -2,7 +2,7 @@
2
2
  * CausalGraph — moflo-owned causal edge store.
3
3
  *
4
4
  * Replaces `agentdb.CausalGraph.addEdge`. Stores typed edges between
5
- * memory-entry IDs in a sql.js-backed table with composite indexes so
5
+ * memory-entry IDs in a SQLite table with composite indexes so
6
6
  * CausalRecall's BFS walks don't hit a full scan on the relation filter.
7
7
  */
8
8
  import { clamp01, clampInt, parseJsonSafe } from './_shared.js';
@@ -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
  *