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
@@ -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 {
@@ -80,7 +80,7 @@ const DEFAULT_CONFIG = {
80
80
  maxUncertainty: 0.15,
81
81
  enableCircuitBreaker: true,
82
82
  circuitBreakerThreshold: 5,
83
- statePath: '.swarm/model-router-state.json',
83
+ statePath: '.moflo/movector/model-router-state.json',
84
84
  autoSaveInterval: 1, // Save after every decision for CLI persistence
85
85
  enableCostOptimization: true,
86
86
  preferSpeed: true,
@@ -9,7 +9,7 @@
9
9
  * - Optimized state space with feature hashing
10
10
  * - Epsilon decay with exponential annealing
11
11
  * - Experience replay buffer for stable learning
12
- * - Model persistence to .swarm/q-learning-model.json
12
+ * - Model persistence to .moflo/movector/q-learning-model.json
13
13
  *
14
14
  * @module q-learning-router
15
15
  */
@@ -32,7 +32,7 @@ const DEFAULT_CONFIG = {
32
32
  enableReplay: true,
33
33
  cacheSize: 256,
34
34
  cacheTTL: 300000,
35
- modelPath: '.swarm/q-learning-model.json',
35
+ modelPath: '.moflo/movector/q-learning-model.json',
36
36
  autoSaveInterval: 100,
37
37
  stateSpaceDim: 64,
38
38
  };
@@ -16,7 +16,18 @@ import { createServer } from 'node:http';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
17
  import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, handleMemoryGet, handleMemorySearch, handleMemoryList, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
18
18
  import { aggregateClaudeStats, emptyClaudeStatsShape } from './claude-stats.js';
19
- export const DEFAULT_DASHBOARD_PORT = 3117;
19
+ import { serverPortCandidates, LEGACY_DEFAULT_PORT } from './daemon-port.js';
20
+ import { writeLockPort } from './daemon-lock.js';
21
+ import { findProjectRoot } from './project-root.js';
22
+ import { readOwnMofloVersion } from './daemon-lock.js';
23
+ /**
24
+ * Legacy default port retained as a re-export of {@link LEGACY_DEFAULT_PORT}
25
+ * for backward compat with existing importers (`commands/daemon.ts`,
26
+ * `__tests__/daemon-dashboard.test.ts`). The actual port a daemon binds is
27
+ * now resolved deterministically per project via `serverPortCandidates()` —
28
+ * see `daemon-port.ts` and `docs/internal/1145-daemon-port-collision-analysis.md`.
29
+ */
30
+ export const DEFAULT_DASHBOARD_PORT = LEGACY_DEFAULT_PORT;
20
31
  /**
21
32
  * Process-wide promise for the shared MemoryAccessor. Memoized as a *promise*
22
33
  * (not the resolved value) so concurrent first-callers share a single init
@@ -129,6 +140,27 @@ function tryParseSafe(s) {
129
140
  return s;
130
141
  }
131
142
  }
143
+ /**
144
+ * Build the `/api/health` response (#1145).
145
+ *
146
+ * Identity payload — clients compare `projectRoot` against their own
147
+ * `findProjectRoot()` and refuse to route to this daemon on mismatch.
148
+ * Also surfaces `pid`, `version`, and `uptimeMs` for healer-class
149
+ * diagnostics and orphan-daemon detection.
150
+ *
151
+ * Read-only, no-auth, localhost-only (the dashboard binds 127.0.0.1).
152
+ */
153
+ function handleHealth(daemon, opts) {
154
+ const status = daemon.getStatus();
155
+ const startedAt = status.startedAt instanceof Date ? status.startedAt : null;
156
+ return {
157
+ status: 'ok',
158
+ projectRoot: opts.projectRoot ?? findProjectRoot(),
159
+ pid: status.pid ?? process.pid,
160
+ version: readOwnMofloVersion() ?? null,
161
+ uptimeMs: startedAt ? Date.now() - startedAt.getTime() : 0,
162
+ };
163
+ }
132
164
  function handleStatus(daemon) {
133
165
  const status = daemon.getStatus();
134
166
  // Index config rows by worker type so the row renderer can show a
@@ -244,15 +276,18 @@ function tryParse(s) {
244
276
  }
245
277
  }
246
278
  async function handleMemoryStats() {
247
- // Single GROUP BY query — no hardcoded namespace list, no row fetching
248
- try {
249
- const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
250
- const { namespaces, total } = await getNamespaceCounts();
251
- return { namespaces, totalEntries: total, available: total > 0 || Object.keys(namespaces).length > 0 };
252
- }
253
- catch {
254
- return { namespaces: {}, totalEntries: 0, available: false };
255
- }
279
+ // Single GROUP BY query — no hardcoded namespace list, no row fetching.
280
+ // Errors propagate to the request handler's outer try/catch → 500, so
281
+ // MCP clients see a real failure instead of a silent `totalEntries: 0`.
282
+ const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
283
+ const { namespaces, total, withEmbeddings } = await getNamespaceCounts();
284
+ return {
285
+ ok: true,
286
+ namespaces,
287
+ totalEntries: total,
288
+ withEmbeddings,
289
+ available: total > 0 || Object.keys(namespaces).length > 0,
290
+ };
256
291
  }
257
292
  /**
258
293
  * Build the `/api/claude-stats` response (#1044).
@@ -433,6 +468,11 @@ async function handleRequest(req, res, daemon, opts) {
433
468
  if (url === '/') {
434
469
  sendHtml(res, DASHBOARD_HTML);
435
470
  }
471
+ else if (url === '/api/health') {
472
+ // #1145 — identity probe. Clients use this to confirm they're talking
473
+ // to the daemon for their OWN project before routing memory ops here.
474
+ sendJson(res, 200, handleHealth(daemon, opts));
475
+ }
436
476
  else if (url === '/api/status') {
437
477
  sendJson(res, 200, handleStatus(daemon));
438
478
  }
@@ -588,33 +628,62 @@ const MAX_PORT_ATTEMPTS = 10;
588
628
  /**
589
629
  * Start the dashboard HTTP server.
590
630
  *
591
- * Tries the requested port first, then falls back to port+1, port+2, ...
592
- * up to MAX_PORT_ATTEMPTS to avoid crashing the daemon when another
593
- * project's daemon already holds the default port.
631
+ * Port selection (#1145):
632
+ * 1. `opts.port`, if explicitly set (CLI `--dashboard-port` flag).
633
+ * 2. Otherwise `serverPortCandidates(projectRoot)` deterministic per-
634
+ * project port + collision-fallback range.
635
+ * Both honor `MOFLO_DAEMON_PORT` (collapses the candidate list to one).
636
+ *
637
+ * On successful bind the bound port is stamped into `.moflo/daemon.lock`
638
+ * via `writeLockPort()` so clients can discover it without guessing.
594
639
  *
595
- * @param daemon - WorkerDaemon instance for status data
596
- * @param opts - Dashboard configuration
597
- * @returns A handle to stop the server (port reflects the actual bound port)
640
+ * On bind exhaustion (every candidate in use) the server throws — the
641
+ * caller is expected to surface the failure rather than stay half-alive
642
+ * (the silent-trap pattern that produced #1145).
643
+ *
644
+ * @returns handle whose `.port` field reflects the actually bound port
598
645
  */
599
646
  export async function startDashboard(daemon, opts) {
600
- const basePort = opts.port;
601
- for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
602
- const port = basePort + attempt;
647
+ const projectRoot = opts.projectRoot ?? findProjectRoot();
648
+ const candidates = buildBindCandidates(opts.port, projectRoot, MAX_PORT_ATTEMPTS);
649
+ let lastErr = null;
650
+ for (let i = 0; i < candidates.length; i++) {
651
+ const port = candidates[i];
603
652
  try {
604
- const handle = await tryListenOnPort(daemon, opts, port);
653
+ const handle = await tryListenOnPort(daemon, { ...opts, projectRoot }, port);
654
+ // Stamp the bound port into the lock so clients discover us reliably.
655
+ // Best-effort: a missing/locked-by-another-pid lock means stamping
656
+ // is a no-op — the deterministic fallback still works.
657
+ try {
658
+ writeLockPort(projectRoot, handle.port);
659
+ }
660
+ catch { /* ignore */ }
605
661
  return handle;
606
662
  }
607
663
  catch (err) {
664
+ lastErr = err;
608
665
  const code = err && typeof err === 'object' && 'code' in err ? err.code : '';
609
- if (code === 'EADDRINUSE' && attempt < MAX_PORT_ATTEMPTS - 1) {
610
- // Port taken — try the next one
666
+ if (code === 'EADDRINUSE' && i < candidates.length - 1)
611
667
  continue;
612
- }
613
668
  throw err;
614
669
  }
615
670
  }
616
- // Should be unreachable, but satisfies the type checker
617
- throw new Error(`All dashboard ports ${basePort}–${basePort + MAX_PORT_ATTEMPTS - 1} are in use`);
671
+ // Bind exhaustion surface so the daemon can hard-fail (#1145 §9.4).
672
+ throw lastErr ?? new Error(`All dashboard ports (${candidates[0]}…${candidates[candidates.length - 1]}) are in use`);
673
+ }
674
+ /**
675
+ * Build the ordered list of ports to try.
676
+ *
677
+ * When the caller pinned a port (CLI flag), respect it without any
678
+ * fallback — the consumer pinned it on purpose. When they didn't, use
679
+ * the deterministic per-project candidates so two projects never collide
680
+ * silently on a fixed default.
681
+ */
682
+ function buildBindCandidates(explicitPort, projectRoot, maxAttempts) {
683
+ if (typeof explicitPort === 'number' && explicitPort > 0 && explicitPort < 65536) {
684
+ return [explicitPort];
685
+ }
686
+ return serverPortCandidates(projectRoot, maxAttempts);
618
687
  }
619
688
  /**
620
689
  * Attempt to bind the dashboard server to a specific port.