moflo 4.10.10 → 4.10.12

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/README.md CHANGED
@@ -419,7 +419,7 @@ flo daemon status # shows whether the service is registered AND running
419
419
 
420
420
  `flo spell schedule create` warns when the daemon isn't installed so you don't quietly miss runs.
421
421
 
422
- **Monitoring.** **The Luminarium** (the moflo daemon's localhost UI) surfaces live schedules, recent executions, and per-schedule controls (disable / re-enable / run now). It starts alongside the daemon at `http://localhost:3117` (override with `--dashboard-port` or disable with `--no-dashboard`).
422
+ **Monitoring.** **[The Luminarium](#the-luminarium)** moflo's localhost daemon dashboard — surfaces live schedules, recent executions, and per-schedule controls (disable / re-enable / run now), alongside worker health, memory stats, and Claude Code session stats. Each project gets its own deterministic port (33000–33999) recorded in `.moflo/daemon.lock`; ask `/luminarium` in your Claude session and it'll print the link.
423
423
 
424
424
  For full configuration (`scheduler:` block in `moflo.yaml`), event types, and the catch-up window after restarts, see [docs/SPELLS.md#scheduling](docs/SPELLS.md#scheduling).
425
425
 
@@ -459,6 +459,35 @@ flo epic reset 42 # Reset state for re-run
459
459
 
460
460
  See the [Epic handling](#epic-handling) section above for detection criteria and the comparison between `/flo <epic>` and `flo epic run`.
461
461
 
462
+ ## The Luminarium
463
+
464
+ The Luminarium is moflo's localhost daemon dashboard. It boots automatically with the background daemon (no extra service to install) and stays running as long as the daemon is up.
465
+
466
+ ### Finding the URL
467
+
468
+ Each project gets a deterministic port in the range 33000–33999, derived from a hash of the project root so two projects never collide on the same machine. The actual bound port is written to `.moflo/daemon.lock` when the daemon starts — if the deterministic port is already taken the daemon scans forward, so the lock file is the source of truth, not the hash.
469
+
470
+ Three ways to get the URL:
471
+
472
+ - **`/luminarium`** — inside a Claude Code session in a moflo project, this skill reads `.moflo/daemon.lock` and prints `http://localhost:<port>`. Fastest path.
473
+ - **`flo daemon status`** — prints the URL alongside the health summary.
474
+ - **`cat .moflo/daemon.lock`** — read the JSON directly: `{ "pid": ..., "port": 33421, ... }`.
475
+
476
+ ### What it shows
477
+
478
+ | Tab | What you see |
479
+ |-----|--------------|
480
+ | **Workers** | Live agent processes the daemon is running (statusline updater, indexer, embedder, etc.) |
481
+ | **Schedules** | All registered spell schedules (cron / interval / one-time), with run-now and disable controls |
482
+ | **Executions** | Recent spell runs — duration, exit code, step-by-step output |
483
+ | **Memory** | Memory namespace breakdown, vector count, embedder backend, HNSW index health |
484
+ | **Claude Stats** | Per-session Claude Code transcript stats — tokens, tools called, files touched (local primary sessions only) |
485
+
486
+ ### Flags
487
+
488
+ - `flo daemon start --no-dashboard` — disable the HTTP server entirely (the daemon itself still runs)
489
+ - `flo daemon start --dashboard-port <N>` — pin to a specific port, overriding the deterministic resolver. Also accepts the `MOFLO_DAEMON_PORT` env var, which the rest of moflo respects when talking to the daemon
490
+
462
491
  ## Commands
463
492
 
464
493
  You don't need to run these for normal use — `flo init` sets everything up, and the hooks handle memory, routing, and learning automatically. These commands are here for manual setup, debugging, and tweaking.
@@ -272,6 +272,12 @@ export async function checkMemoryDatabase() {
272
272
  * - `memory.db` / `memory.db.bak` — stale once `.moflo/moflo.db` exists.
273
273
  * - `q-learning-model.json` / `model-router-state.json` — live router state
274
274
  * that pre-dates the `.moflo/movector/` defaults; migrate, don't delete.
275
+ * - `lora-weights.json` / `moe-weights.json` — LoRA + MoE weights (#1168
276
+ * moved the writers to `.moflo/movector/`).
277
+ * - `ewc-fisher.json` / `sona-patterns.json` — neural runtime state (#1168
278
+ * moved the writers to `.moflo/neural/`).
279
+ * - `state.json` — `flo swarm init` snapshot (#1168 → `.moflo/swarm/`).
280
+ * - `code-map-hash.txt` — `flo memory code-map` cache (#1168 → `.moflo/memory/`).
275
281
  * - `hooks.log` / `background.log` — diagnostic logs the launcher used to
276
282
  * route to `.swarm/`; relocate to `.moflo/logs/`.
277
283
  *
@@ -291,6 +297,12 @@ export async function checkSwarmResidue() {
291
297
  'memory.db.bak',
292
298
  'q-learning-model.json',
293
299
  'model-router-state.json',
300
+ 'lora-weights.json',
301
+ 'moe-weights.json',
302
+ 'ewc-fisher.json',
303
+ 'sona-patterns.json',
304
+ 'state.json',
305
+ 'code-map-hash.txt',
294
306
  'hooks.log',
295
307
  'background.log',
296
308
  ];
@@ -26,6 +26,7 @@ import { existsSync } from 'fs';
26
26
  import { errorDetail } from '../shared/utils/error-detail.js';
27
27
  import { memoryDbPath } from '../services/moflo-paths.js';
28
28
  import { findProjectRoot } from '../services/project-root.js';
29
+ import { purgeMemoryProbeNamespaces } from '../services/ephemeral-namespace-purge.js';
29
30
  import { loadToolArrays, getTool, pushDetail, summarizeFunctional, } from './doctor-checks-functional-shared.js';
30
31
  const MEMORY_ACCESS_CHECK = 'Memory Access Functional';
31
32
  const MEMORY_ACCESS_FAIL_FIX = 'Run `flo doctor --json` for per-subcheck details. Common fixes: ensure fastembed installed (memory_store.hasEmbedding=false), explicit threshold:0 honored (#837), or rebuild HNSW index (`flo memory rebuild-index`)';
@@ -563,6 +564,12 @@ export async function checkMemoryAccessFunctional() {
563
564
  }
564
565
  catch { /* ignore */ }
565
566
  }
567
+ // #1166 — namespace-level sweep backstop for the per-key safeDelete
568
+ // loop above (see purgeMemoryProbeNamespaces docstring for why).
569
+ try {
570
+ await purgeMemoryProbeNamespaces({ dbPath: memoryDbPath(findProjectRoot()) });
571
+ }
572
+ catch { /* best-effort */ }
566
573
  }
567
574
  return summarizeFunctional(MEMORY_ACCESS_CHECK, details, {
568
575
  passSuffix: '(memory_store + memory_search round-trip verified across subagent, swarm-agent, and hive-mind contexts)',
@@ -152,10 +152,27 @@ async function fixSwarmLegacyResidue() {
152
152
  allMigrated = false;
153
153
  }
154
154
  }
155
- // (2) router state JSONs — rename into .moflo/movector/.
155
+ // (2) router state + neural state JSONs — rename into .moflo/{movector,neural,swarm,memory}/.
156
+ //
157
+ // q-learning-model.json + model-router-state.json: shipped at #727.
158
+ // lora-weights.json + moe-weights.json: writer relocation in #1168
159
+ // (lora-adapter.ts, moe-router.ts).
160
+ // ewc-fisher.json + sona-patterns.json: writer relocation in #1168
161
+ // (ewc-consolidation.ts, sona-optimizer.ts).
162
+ // state.json + code-map-hash.txt: writer relocation in #1168
163
+ // (commands/swarm.ts, commands/memory.ts).
164
+ const neuralDir = join(moflo, 'neural');
165
+ const swarmStateDir = join(moflo, 'swarm');
166
+ const memoryStateDir = join(moflo, 'memory');
156
167
  const stateFiles = [
157
168
  { name: 'q-learning-model.json', dest: movectorDir },
158
169
  { name: 'model-router-state.json', dest: movectorDir },
170
+ { name: 'lora-weights.json', dest: movectorDir },
171
+ { name: 'moe-weights.json', dest: movectorDir },
172
+ { name: 'ewc-fisher.json', dest: neuralDir },
173
+ { name: 'sona-patterns.json', dest: neuralDir },
174
+ { name: 'state.json', dest: swarmStateDir },
175
+ { name: 'code-map-hash.txt', dest: memoryStateDir },
159
176
  ];
160
177
  for (const { name, dest } of stateFiles) {
161
178
  const src = join(swarmDir, name);
@@ -230,10 +247,11 @@ export async function autoFixCheck(check) {
230
247
  // Map checks to programmatic fixes (not just shell commands)
231
248
  const fixActions = {
232
249
  'Memory Database': async () => {
250
+ // Canonical DB lives at `.moflo/moflo.db`; `initializeMemoryDatabase`
251
+ // creates the parent dir itself. The pre-#1168 fix also `mkdirSync`'d
252
+ // `.swarm/` — vestigial residue that fought the 'Swarm Residue' fix in
253
+ // the same healer pass. Removed.
233
254
  try {
234
- const swarmDir = join(process.cwd(), '.swarm');
235
- if (!existsSync(swarmDir))
236
- mkdirSync(swarmDir, { recursive: true });
237
255
  const { initializeMemoryDatabase } = await import('../memory/memory-initializer.js');
238
256
  const result = await initializeMemoryDatabase({ force: true, verbose: false });
239
257
  return result.success;
@@ -243,12 +261,11 @@ export async function autoFixCheck(check) {
243
261
  }
244
262
  },
245
263
  'Embeddings': async () => {
264
+ // Same fix as Memory Database — ensure the canonical DB exists, then
265
+ // populate embeddings. Pre-#1168 wrote to `.swarm/memory.db` directly,
266
+ // contradicting the post-#727 layout; that branch is removed.
246
267
  try {
247
- const swarmDir = join(process.cwd(), '.swarm');
248
- if (!existsSync(swarmDir))
249
- mkdirSync(swarmDir, { recursive: true });
250
- const dbPath = join(swarmDir, 'memory.db');
251
- if (!existsSync(dbPath)) {
268
+ if (!existsSync(memoryDbPath(findProjectRoot()))) {
252
269
  const { initializeMemoryDatabase } = await import('../memory/memory-initializer.js');
253
270
  await initializeMemoryDatabase({ force: true, verbose: false });
254
271
  }
@@ -9,6 +9,7 @@ import { select, confirm, input } from '../prompt.js';
9
9
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
10
10
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
11
11
  import { errorDetail } from '../shared/utils/error-detail.js';
12
+ import { legacySwarmPath, runtimePath } from '../services/moflo-paths.js';
12
13
  // Memory backends
13
14
  const BACKENDS = [
14
15
  { value: 'agentdb', label: 'AgentDB', hint: 'Vector database with HNSW indexing (150x-12,500x faster)' },
@@ -2129,7 +2130,10 @@ const codeMapCommand = {
2129
2130
  const { execSync } = await import('child_process');
2130
2131
  const { createHash } = await import('crypto');
2131
2132
  const cwd = ctx.cwd || process.cwd();
2132
- const hashCachePath = pathModule.join(cwd, '.swarm', 'code-map-hash.txt');
2133
+ // Post-#1168: canonical at `.moflo/memory/code-map-hash.txt`. Legacy
2134
+ // `.swarm/code-map-hash.txt` is read-only fallback for upgrade scenarios.
2135
+ const hashCachePath = runtimePath('memory', 'code-map-hash.txt');
2136
+ const legacyHashCachePath = legacySwarmPath('code-map-hash.txt');
2133
2137
  output.writeln();
2134
2138
  output.writeln(output.bold('Generating Code Map'));
2135
2139
  output.writeln(output.dim('─'.repeat(50)));
@@ -2168,9 +2172,12 @@ const codeMapCommand = {
2168
2172
  output.writeln(`File list hash: ${currentHash.slice(0, 12)}...`);
2169
2173
  return { success: true };
2170
2174
  }
2171
- // Check if unchanged
2172
- if (!forceRegen && fs.existsSync(hashCachePath)) {
2173
- const cached = fs.readFileSync(hashCachePath, 'utf-8').trim();
2175
+ // Check if unchanged — canonical first, then legacy `.swarm/` fallback.
2176
+ const cachedReadPath = fs.existsSync(hashCachePath)
2177
+ ? hashCachePath
2178
+ : (fs.existsSync(legacyHashCachePath) ? legacyHashCachePath : null);
2179
+ if (!forceRegen && cachedReadPath) {
2180
+ const cached = fs.readFileSync(cachedReadPath, 'utf-8').trim();
2174
2181
  if (cached === currentHash) {
2175
2182
  const { db } = await openDb(cwd);
2176
2183
  const stmt = db.prepare(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = ?`);
@@ -7,14 +7,26 @@ import { select, confirm } from '../prompt.js';
7
7
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
- import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
10
+ import { LEGACY_SWARM_DIR, memoryDbCandidatePaths, mofloDir } from '../services/moflo-paths.js';
11
+ import { findProjectRoot } from '../services/project-root.js';
11
12
  // Get dynamic swarm status from memory/session files
12
13
  function getSwarmStatus(swarmId) {
13
- const swarmDir = path.join(process.cwd(), '.swarm');
14
+ const projectRoot = findProjectRoot();
15
+ // `.moflo/swarm/state.json` is canonical post-#1168; `.swarm/state.json`
16
+ // is a read-only fallback so a consumer who initialised on an older moflo
17
+ // still sees their swarm. The pre-#1168 agents/tasks JSON probe blocks
18
+ // were removed — no current writer creates those directories, so they
19
+ // always produced 0 counts. The coordinator-backed MCP tools
20
+ // (agent_list / task_list) are the live source of truth.
21
+ const canonicalSwarmDir = path.join(mofloDir(projectRoot), 'swarm');
22
+ const legacySwarmDir = path.join(projectRoot, LEGACY_SWARM_DIR);
14
23
  const sessionDir = path.join(process.cwd(), '.claude', 'sessions');
15
24
  const memoryPaths = memoryDbCandidatePaths(process.cwd());
16
- // Check for active swarm state file
17
- const swarmStateFile = path.join(swarmDir, 'state.json');
25
+ // Check for active swarm state file — canonical first, then legacy.
26
+ let swarmStateFile = path.join(canonicalSwarmDir, 'state.json');
27
+ if (!fs.existsSync(swarmStateFile)) {
28
+ swarmStateFile = path.join(legacySwarmDir, 'state.json');
29
+ }
18
30
  let swarmState = null;
19
31
  if (fs.existsSync(swarmStateFile)) {
20
32
  try {
@@ -24,30 +36,14 @@ function getSwarmStatus(swarmId) {
24
36
  // Ignore parse errors
25
37
  }
26
38
  }
27
- // Count active agents from process files
28
- let activeAgents = 0;
29
- let totalAgents = 0;
30
- const agentsDir = path.join(swarmDir, 'agents');
31
- if (fs.existsSync(agentsDir)) {
32
- try {
33
- const agentFiles = fs.readdirSync(agentsDir).filter(f => f.endsWith('.json'));
34
- totalAgents = agentFiles.length;
35
- for (const file of agentFiles) {
36
- try {
37
- const agent = JSON.parse(fs.readFileSync(path.join(agentsDir, file), 'utf-8'));
38
- if (agent.status === 'active' || agent.status === 'running') {
39
- activeAgents++;
40
- }
41
- }
42
- catch {
43
- // Ignore
44
- }
45
- }
46
- }
47
- catch {
48
- // Ignore
49
- }
50
- }
39
+ // agents/tasks counters: no file-store readers post-#1168. Coordinator
40
+ // MCP tools own the live counts; getSwarmStatus surfaces a static summary
41
+ // of the persisted state file plus session/memory rough indicators.
42
+ const activeAgents = 0;
43
+ const totalAgents = 0;
44
+ const completedTasks = 0;
45
+ const inProgressTasks = 0;
46
+ const pendingTasks = 0;
51
47
  // Get session count
52
48
  let sessionCount = 0;
53
49
  if (fs.existsSync(sessionDir)) {
@@ -71,36 +67,6 @@ function getSwarmStatus(swarmId) {
71
67
  }
72
68
  }
73
69
  }
74
- // Count task files if they exist
75
- let completedTasks = 0;
76
- let inProgressTasks = 0;
77
- let pendingTasks = 0;
78
- const tasksDir = path.join(swarmDir, 'tasks');
79
- if (fs.existsSync(tasksDir)) {
80
- try {
81
- const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
82
- for (const file of taskFiles) {
83
- try {
84
- const task = JSON.parse(fs.readFileSync(path.join(tasksDir, file), 'utf-8'));
85
- if (task.status === 'completed' || task.status === 'done') {
86
- completedTasks++;
87
- }
88
- else if (task.status === 'in_progress' || task.status === 'running') {
89
- inProgressTasks++;
90
- }
91
- else {
92
- pendingTasks++;
93
- }
94
- }
95
- catch {
96
- // Ignore
97
- }
98
- }
99
- }
100
- catch {
101
- // Ignore
102
- }
103
- }
104
70
  // Calculate dynamic progress based on actual state
105
71
  // If no swarm state, show 0%. Otherwise calculate from completed tasks
106
72
  const totalTasks = completedTasks + inProgressTasks + pendingTasks;
@@ -279,8 +245,11 @@ const initCommand = {
279
245
  });
280
246
  output.writeln();
281
247
  output.printSuccess('Swarm initialized successfully');
282
- // Save swarm state locally for status command to read
283
- const swarmDir = path.join(process.cwd(), '.swarm');
248
+ // Save swarm state locally for status command to read. Post-#1168 the
249
+ // canonical home is `<root>/.moflo/swarm/state.json`; the legacy
250
+ // `.swarm/state.json` path is preserved as a read-only fallback in
251
+ // `getSwarmStatus`.
252
+ const swarmDir = path.join(mofloDir(findProjectRoot()), 'swarm');
284
253
  try {
285
254
  if (!fs.existsSync(swarmDir)) {
286
255
  fs.mkdirSync(swarmDir, { recursive: true });
@@ -116,13 +116,16 @@ export const PURGE_ON_SESSION_START_NAMESPACES = new Set([
116
116
  * spawns a NEW namespace, so namespace pollution grows linearly with
117
117
  * healer-run count if cleanup races fail.
118
118
  *
119
- * Both probes register an explicit cleanup via `safeDelete`, but the
120
- * cleanup is best-effort and silently swallows failures (e.g. daemon
121
- * races, MCP transport errors) so rows accumulate across consumer
122
- * sessions. Auto-purging matches the pattern for
123
- * `hive-mind`/`epic-state`/`test-bridge-fix`. These rows MUST still get
124
- * embeddings (see {@link EPHEMERAL_NAMESPACE_PREFIXES} for why) only
125
- * their persistence across sessions is curtailed.
119
+ * Both probes register an explicit cleanup via `safeDelete`. Post-#1166
120
+ * the doctor also runs an in-process namespace sweep over these prefixes
121
+ * inside `checkMemoryAccessFunctional`'s finally block, so a healthy
122
+ * doctor run leaves zero rows behind. The session-start launcher's
123
+ * prefix-purge is now strictly a safety net for crashed-process residue
124
+ * (the doctor never reached its finally) or pre-#1166 consumer DBs that
125
+ * still carry accumulated probe rows. Auto-purging matches the pattern
126
+ * for `hive-mind`/`epic-state`/`test-bridge-fix`. These rows MUST still
127
+ * get embeddings (see {@link EPHEMERAL_NAMESPACE_PREFIXES} for why) —
128
+ * only their persistence across sessions is curtailed.
126
129
  */
127
130
  export const PURGE_ON_SESSION_START_PREFIXES = new Set([
128
131
  'doctor-memprobe-',
@@ -16,13 +16,15 @@
16
16
  * - Fisher Information Matrix computation from gradient history
17
17
  * - Online EWC updates for streaming patterns
18
18
  * - Selective consolidation based on pattern importance
19
- * - Persistent storage in .swarm/ewc-fisher.json
19
+ * - Persistent storage in .moflo/neural/ewc-fisher.json
20
+ * (legacy fallback read: .swarm/ewc-fisher.json)
20
21
  *
21
22
  * @module v3/cli/memory/ewc-consolidation
22
23
  */
23
24
  import * as fs from 'fs';
24
25
  import * as path from 'path';
25
26
  import { errorDetail } from '../shared/utils/error-detail.js';
27
+ import { legacySwarmPath, runtimePath } from '../services/moflo-paths.js';
26
28
  // ============================================================================
27
29
  // Default Configuration
28
30
  // ============================================================================
@@ -31,7 +33,6 @@ const DEFAULT_EWC_CONFIG = {
31
33
  maxPatterns: 1000,
32
34
  fisherDecayRate: 0.01,
33
35
  importanceThreshold: 0.3,
34
- storagePath: path.join(process.cwd(), '.swarm', 'ewc-fisher.json'),
35
36
  onlineMode: true,
36
37
  dimensions: 384
37
38
  };
@@ -51,7 +52,15 @@ export class EWCConsolidator {
51
52
  consolidationHistory = [];
52
53
  initialized = false;
53
54
  constructor(config) {
54
- this.config = { ...DEFAULT_EWC_CONFIG, ...config };
55
+ // Resolve storagePath lazily here (#1168) — the default routes through
56
+ // findProjectRoot at construct-time, not module-load time. Default-rescue
57
+ // runs *last* so an explicit `storagePath: undefined` falls back to the
58
+ // canonical path instead of leaving the field undefined.
59
+ this.config = {
60
+ ...DEFAULT_EWC_CONFIG,
61
+ ...config,
62
+ storagePath: config?.storagePath ?? runtimePath('neural', 'ewc-fisher.json'),
63
+ };
55
64
  this.globalFisher = new Array(this.config.dimensions).fill(0);
56
65
  }
57
66
  /**
@@ -447,10 +456,17 @@ export class EWCConsolidator {
447
456
  * Load state from disk
448
457
  */
449
458
  async loadFromDisk() {
450
- if (!fs.existsSync(this.config.storagePath)) {
451
- throw new Error('No persisted state found');
459
+ // Canonical path first, then legacy `.swarm/ewc-fisher.json` as a
460
+ // read-only fallback for consumers who upgraded mid-training (#1168).
461
+ let sourcePath = this.config.storagePath;
462
+ if (!fs.existsSync(sourcePath)) {
463
+ const legacy = legacySwarmPath('ewc-fisher.json');
464
+ if (!fs.existsSync(legacy)) {
465
+ throw new Error('No persisted state found');
466
+ }
467
+ sourcePath = legacy;
452
468
  }
453
- const content = fs.readFileSync(this.config.storagePath, 'utf-8');
469
+ const content = fs.readFileSync(sourcePath, 'utf-8');
454
470
  const state = JSON.parse(content);
455
471
  // Validate version
456
472
  if (state.version !== '1.0.0') {
@@ -8,17 +8,18 @@
8
8
  * - Processes trajectory outcomes from the spell-engine trajectory pipeline
9
9
  * - Extracts keywords from tasks for pattern matching
10
10
  * - Maintains learned routing patterns with confidence scoring
11
- * - Persists patterns to .swarm/sona-patterns.json
11
+ * - Persists patterns to .moflo/neural/sona-patterns.json
12
+ * (legacy fallback read: .swarm/sona-patterns.json)
12
13
  * - Integrates with Q-learning router for combined routing
13
14
  *
14
15
  * @module v3/cli/memory/sona-optimizer
15
16
  */
16
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
17
- import { dirname, join } from 'path';
18
+ import { dirname, isAbsolute, resolve } from 'path';
19
+ import { legacySwarmPath, runtimePath } from '../services/moflo-paths.js';
18
20
  // ============================================================================
19
21
  // Constants
20
22
  // ============================================================================
21
- const DEFAULT_PERSISTENCE_PATH = '.swarm/sona-patterns.json';
22
23
  const PATTERN_VERSION = '1.0.0';
23
24
  const MIN_CONFIDENCE = 0.1;
24
25
  const MAX_CONFIDENCE = 0.99;
@@ -105,7 +106,12 @@ export class SONAOptimizer {
105
106
  qLearningRouter = null;
106
107
  qLearningEnabled = false;
107
108
  constructor(options) {
108
- this.persistencePath = options?.persistencePath || DEFAULT_PERSISTENCE_PATH;
109
+ // Resolve persistencePath lazily here (#1168). When the caller supplies
110
+ // one we honor it verbatim (may be relative — preserved for existing
111
+ // tests/callers that join against their own cwd). When unset, we route
112
+ // through runtimePath so writes land under `.moflo/neural/` regardless
113
+ // of subprocess cwd.
114
+ this.persistencePath = options?.persistencePath || runtimePath('neural', 'sona-patterns.json');
109
115
  }
110
116
  /**
111
117
  * Initialize the optimizer and load persisted state
@@ -499,9 +505,17 @@ export class SONAOptimizer {
499
505
  */
500
506
  loadFromDisk() {
501
507
  try {
502
- const fullPath = join(process.cwd(), this.persistencePath);
508
+ // Treat absolute persistencePath verbatim (new #1168 default routes
509
+ // through runtimePath → absolute `.moflo/neural/...`); relative paths
510
+ // preserve the pre-#1168 behaviour of resolving against cwd.
511
+ let fullPath = isAbsolute(this.persistencePath)
512
+ ? this.persistencePath
513
+ : resolve(process.cwd(), this.persistencePath);
503
514
  if (!existsSync(fullPath)) {
504
- return false;
515
+ const legacy = legacySwarmPath('sona-patterns.json');
516
+ if (!existsSync(legacy))
517
+ return false;
518
+ fullPath = legacy;
505
519
  }
506
520
  const data = readFileSync(fullPath, 'utf-8');
507
521
  const state = JSON.parse(data);
@@ -536,7 +550,11 @@ export class SONAOptimizer {
536
550
  */
537
551
  saveToDisk() {
538
552
  try {
539
- const fullPath = join(process.cwd(), this.persistencePath);
553
+ // See loadFromDisk: absolute persistencePath is taken verbatim, relative
554
+ // paths resolve against cwd. New #1168 default writes to `.moflo/neural/`.
555
+ const fullPath = isAbsolute(this.persistencePath)
556
+ ? this.persistencePath
557
+ : resolve(process.cwd(), this.persistencePath);
540
558
  const dir = dirname(fullPath);
541
559
  // Ensure directory exists
542
560
  if (!existsSync(dir)) {
@@ -8,7 +8,7 @@
8
8
  * - Rank decomposition (r << d) for memory efficiency
9
9
  * - Additive weight updates: W' = W + BA (where B ∈ R^{d×r}, A ∈ R^{r×k})
10
10
  * - Support for multiple adaptation heads
11
- * - Persistence to .swarm/lora-weights.json
11
+ * - Persistence to .moflo/movector/lora-weights.json (legacy fallback: .swarm/lora-weights.json)
12
12
  *
13
13
  * Memory savings:
14
14
  * - Original: d × k parameters
@@ -18,7 +18,8 @@
18
18
  * @module lora-adapter
19
19
  */
20
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
21
- import { dirname, join } from 'path';
21
+ import { dirname } from 'path';
22
+ import { legacySwarmPath, runtimePath } from '../services/moflo-paths.js';
22
23
  // ============================================================================
23
24
  // Types & Constants
24
25
  // ============================================================================
@@ -47,7 +48,6 @@ const DEFAULT_CONFIG = {
47
48
  inputDim: INPUT_DIM,
48
49
  outputDim: OUTPUT_DIM,
49
50
  learningRate: 0.001,
50
- weightsPath: join(process.cwd(), '.swarm', 'lora-weights.json'),
51
51
  enableDropout: true,
52
52
  dropoutProb: 0.1,
53
53
  autoSaveInterval: 50,
@@ -67,7 +67,16 @@ export class LoRAAdapter {
67
67
  lastUpdate = null;
68
68
  updatesSinceLastSave = 0;
69
69
  constructor(config) {
70
- this.config = { ...DEFAULT_CONFIG, ...config };
70
+ // Resolve weightsPath lazily here, not at module load — captures the
71
+ // *current* consumer project root, not the cwd at first import (#1168).
72
+ // Default-rescue runs *last* so an explicit `weightsPath: undefined` from
73
+ // the caller still falls back to the canonical path instead of crashing
74
+ // save/load on an undefined string.
75
+ this.config = {
76
+ ...DEFAULT_CONFIG,
77
+ ...config,
78
+ weightsPath: config?.weightsPath ?? runtimePath('movector', 'lora-weights.json'),
79
+ };
71
80
  this.weights = this.initializeWeights();
72
81
  }
73
82
  /**
@@ -309,10 +318,16 @@ export class LoRAAdapter {
309
318
  */
310
319
  loadWeights() {
311
320
  try {
312
- if (!existsSync(this.config.weightsPath)) {
313
- return false;
321
+ // Canonical path first, then legacy `.swarm/lora-weights.json` as a
322
+ // read-only fallback for consumers who upgraded mid-training (#1168).
323
+ let sourcePath = this.config.weightsPath;
324
+ if (!existsSync(sourcePath)) {
325
+ const legacy = legacySwarmPath('lora-weights.json');
326
+ if (!existsSync(legacy))
327
+ return false;
328
+ sourcePath = legacy;
314
329
  }
315
- const content = readFileSync(this.config.weightsPath, 'utf-8');
330
+ const content = readFileSync(sourcePath, 'utf-8');
316
331
  const data = JSON.parse(content);
317
332
  if (data.version !== 1) {
318
333
  return false;
@@ -6,7 +6,8 @@
6
6
  * - Gating network for soft expert selection (top-k)
7
7
  * - Online weight updates via reward signals
8
8
  * - Load balancing with auxiliary loss
9
- * - Weight persistence to .swarm/moe-weights.json
9
+ * - Weight persistence to .moflo/movector/moe-weights.json
10
+ * (legacy fallback read: .swarm/moe-weights.json)
10
11
  *
11
12
  * Architecture:
12
13
  * - Input: 384-dim task embedding (from ONNX)
@@ -17,6 +18,7 @@
17
18
  */
18
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
19
20
  import { dirname } from 'path';
21
+ import { legacySwarmPath, runtimePath } from '../services/moflo-paths.js';
20
22
  /**
21
23
  * Expert names in order (index corresponds to expert slot)
22
24
  */
@@ -43,14 +45,15 @@ export const INPUT_DIM = 384;
43
45
  */
44
46
  export const HIDDEN_DIM = 128;
45
47
  /**
46
- * Default configuration
48
+ * Default configuration. `weightsPath` is overridden lazily in the
49
+ * MoERouter constructor (see #1168) so the path resolves against the
50
+ * consumer's project root, not the module-load cwd.
47
51
  */
48
52
  const DEFAULT_CONFIG = {
49
53
  topK: 2,
50
54
  learningRate: 0.01,
51
55
  temperature: 1.0,
52
56
  loadBalanceCoef: 0.01,
53
- weightsPath: '.swarm/moe-weights.json',
54
57
  autoSaveInterval: 50,
55
58
  enableNoise: true,
56
59
  noiseStd: 0.1,
@@ -202,7 +205,15 @@ export class MoERouter {
202
205
  lastProbs = null;
203
206
  lastSelectedExperts = [];
204
207
  constructor(config = {}) {
205
- this.config = { ...DEFAULT_CONFIG, ...config };
208
+ // Resolve weightsPath lazily here (#1168) — the default routes through
209
+ // findProjectRoot at construct-time, not module-load time. Default-rescue
210
+ // runs *last* so an explicit `weightsPath: undefined` falls back to the
211
+ // canonical path instead of leaving the field undefined.
212
+ this.config = {
213
+ ...DEFAULT_CONFIG,
214
+ ...config,
215
+ weightsPath: config.weightsPath ?? runtimePath('movector', 'moe-weights.json'),
216
+ };
206
217
  // Initialize weights
207
218
  this.W1 = xavierInit(INPUT_DIM, HIDDEN_DIM);
208
219
  this.b1 = new Float32Array(HIDDEN_DIM);
@@ -445,10 +456,15 @@ export class MoERouter {
445
456
  * Load weights from persistence file
446
457
  */
447
458
  async loadWeights(path) {
448
- const weightsPath = path || this.config.weightsPath;
459
+ // Canonical path first, then legacy `.swarm/moe-weights.json` as a
460
+ // read-only fallback for consumers who upgraded mid-training (#1168).
461
+ let weightsPath = path || this.config.weightsPath;
449
462
  try {
450
463
  if (!existsSync(weightsPath)) {
451
- return false;
464
+ const legacy = legacySwarmPath('moe-weights.json');
465
+ if (!existsSync(legacy))
466
+ return false;
467
+ weightsPath = legacy;
452
468
  }
453
469
  const data = readFileSync(weightsPath, 'utf-8');
454
470
  const model = JSON.parse(data);
@@ -105,4 +105,51 @@ export async function purgeEphemeralNamespaces(options = {}) {
105
105
  db.close();
106
106
  }
107
107
  }
108
+ /**
109
+ * Hard-delete rows whose namespace matches one of
110
+ * {@link PURGE_ON_SESSION_START_PREFIXES} — currently `doctor-memprobe-*`
111
+ * and `doctor-neighbors-*`. Scoped down from {@link purgeEphemeralNamespaces}:
112
+ * no exact-namespace pass, no tasklist trim, no VACUUM. Returns
113
+ * `{ purged: 0 }` on a missing DB / missing `memory_entries` / clean state.
114
+ *
115
+ * Intended for the doctor's Memory Access functional check finally block
116
+ * (#1166). Only the doctor writes to these namespaces in production, so
117
+ * sweeping by prefix at the end of every healer run kills the
118
+ * `populated:ephemeral-purged` flake class — a per-key `safeDelete` that
119
+ * silently no-ops (row not visible at delete time, MCP transport error,
120
+ * `memory_delete` returning `success: true, deleted: false`) no longer
121
+ * leaks a row into the next assertion. The launcher's session-start
122
+ * purge stays in place as a defence-in-depth safety net for residue from
123
+ * crashed-process scenarios where the doctor never reached its finally.
124
+ *
125
+ * Errors propagate to the caller (the doctor absorbs them so a failed
126
+ * sweep never poisons the check return value).
127
+ */
128
+ export async function purgeMemoryProbeNamespaces(options = {}) {
129
+ const fs = await import('fs');
130
+ const path = await import('path');
131
+ const dbPath = path.resolve(options.dbPath ?? memoryDbPath(process.cwd()));
132
+ if (!fs.existsSync(dbPath))
133
+ return { purged: 0 };
134
+ const prefixes = Array.from(PURGE_ON_SESSION_START_PREFIXES);
135
+ if (prefixes.length === 0)
136
+ return { purged: 0 };
137
+ const db = openDaemonDatabase(dbPath);
138
+ try {
139
+ const probe = db.exec(`SELECT name FROM sqlite_master WHERE type='table' AND name='memory_entries' LIMIT 1`);
140
+ if (!probe[0]?.values?.[0])
141
+ return { purged: 0 };
142
+ const whereClause = prefixes.map(() => 'namespace LIKE ?').join(' OR ');
143
+ const bindings = prefixes.map((p) => `${p}%`);
144
+ const countRows = db.exec(`SELECT COUNT(*) FROM memory_entries WHERE ${whereClause}`, bindings);
145
+ const purgeable = Number(countRows[0]?.values?.[0]?.[0] ?? 0);
146
+ if (purgeable === 0)
147
+ return { purged: 0 };
148
+ db.run(`DELETE FROM memory_entries WHERE ${whereClause}`, bindings);
149
+ return { purged: db.getRowsModified?.() ?? 0 };
150
+ }
151
+ finally {
152
+ db.close();
153
+ }
154
+ }
108
155
  //# sourceMappingURL=ephemeral-namespace-purge.js.map
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { homedir } from 'node:os';
23
23
  import { join } from 'node:path';
24
+ import { findProjectRoot } from './project-root.js';
24
25
  export const MOFLO_DIR = '.moflo';
25
26
  /** Canonical memory DB filename (post-#727). Lives at `<root>/.moflo/moflo.db`. */
26
27
  export const MEMORY_DB_FILE = 'moflo.db';
@@ -68,6 +69,25 @@ export function legacyHnswIndexPath(projectRoot) {
68
69
  export function legacyMemoryDbBakPath(projectRoot) {
69
70
  return join(projectRoot, LEGACY_SWARM_DIR, `${LEGACY_MEMORY_DB_FILE}${LEGACY_MEMORY_DB_BAK_SUFFIX}`);
70
71
  }
72
+ /**
73
+ * Resolve a runtime-state path under `.moflo/<subdir>/<filename>` at the
74
+ * project root. Lazy by design — every call routes through `findProjectRoot()`
75
+ * so the path never bakes in `process.cwd()` at module-load time (#1168 bug
76
+ * class). Use this for any persisted runtime state the daemon, MCP server,
77
+ * or neural runtime writes (weights, fisher matrix, routing patterns, etc.).
78
+ */
79
+ export function runtimePath(subdir, filename) {
80
+ return join(mofloDir(findProjectRoot()), subdir, filename);
81
+ }
82
+ /**
83
+ * Legacy `.swarm/<filename>` path at the project root. Read-only fallback for
84
+ * consumers who saved state under the pre-#1168 location; never written by
85
+ * production code (enforced by the drift-guard in
86
+ * `published-package-drift-guard.test.ts`).
87
+ */
88
+ export function legacySwarmPath(filename) {
89
+ return join(findProjectRoot(), LEGACY_SWARM_DIR, filename);
90
+ }
71
91
  /**
72
92
  * Memory-DB probe order used by every reader that does best-effort detection
73
93
  * (statusline, doctor, swarm status, hooks aggregator). Canonical first so
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.10.10';
5
+ export const VERSION = '4.10.12';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.10",
3
+ "version": "4.10.12",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "@typescript-eslint/eslint-plugin": "^7.18.0",
96
96
  "@typescript-eslint/parser": "^7.18.0",
97
97
  "eslint": "^8.0.0",
98
- "moflo": "^4.10.9",
98
+ "moflo": "^4.10.11",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"