---
id: 11075
title: 'Exploration: Migrate orchestrator magic numbers + retention constants to ai/config.template.mjs'
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
  - model-experience
assignees:
  - neo-opus-ada
createdAt: '2026-05-09T23:05:35Z'
updatedAt: '2026-06-07T17:09:34Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11075'
author: neo-opus-ada
commentsCount: 2
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-25T07:50:28Z'
---
# Exploration: Migrate orchestrator magic numbers + retention constants to ai/config.template.mjs

## Context

Operator observation 2026-05-10 (post-PR #11069 merge):
> *"many magic numbers, while we do have e.g. ai/config.template. worth an exploration ticket, but not as high as a prio compared to get to the point where we can test the orchestrator."*

Empirical anchor: `ai/config.template.mjs` (top-level) is the established Tier 1 config substrate. It's already the canonical home for global AI/MCP infrastructure config (transport, debug flag, mcpHttpPort, etc.). M3.5 + M4 substrate work has accumulated hardcoded constants outside that substrate that should ideally route through it.

**Priority:** explicitly lower than orchestrator-testable readiness per operator framing. **This ticket is open-lane exploration; pickup happens AFTER orchestrator validates end-to-end.**

## The Magic Numbers

Audited surfaces (non-exhaustive; pickup-time intake should expand):

**`ai/daemons/TaskDefinitions.mjs` (lines 7-13):**
- `DEFAULT_POLL_INTERVAL_MS = 3000`
- `DEFAULT_SUMMARY_SWEEP_INTERVAL_MS = 600000`
- `DEFAULT_KB_SYNC_INTERVAL_MS = 1800000`
- `DEFAULT_BACKUP_INTERVAL_MS = 86400000`
- (Future M4 coordinator additions: SANDMAN, DREAM, GOLDEN_PATH, GRAPH_MAINTENANCE, HEARTBEAT, WAKE_SUBSTRATE — each will likely add another constant)

**`buildScripts/ai/backup.mjs` (lines 363-365):**
- `K = 3` (newest-bundle keep count)
- `N_DAYS = 30` (rotation cap)

**Likely surfaces TBD (pickup-time empirical sweep):**
- Per-coordinator service constants (each future coordinator may inline its own contention thresholds, off-peak windows, etc.)
- `ai/scripts/bridge-daemon.mjs` polling intervals
- `ai/daemons/SwarmHeartbeatService.mjs` poll cadence (currently `5 * 60 * 1000` constant)
- HealthService thresholds, etc.

## The Problem

Hardcoded constants in code files create:
1. **Operator config-friction** — to tune retention or cadence, operator edits source files instead of a single config file
2. **No environment-overlay** — `ai/config.template.mjs` already supports per-deployment overlays via env vars; magic numbers in TaskDefinitions don't get that benefit
3. **Drift risk** — same constant repeated across files (e.g., test fixtures hardcoding `86400000` instead of importing `DEFAULT_BACKUP_INTERVAL_MS`) accumulates
4. **MX friction** — agents reading the codebase have to chase down constants across files instead of reading one config namespace

## Architectural Reality

**Established Tier-1 config:** `ai/config.template.mjs` (loads at process boot; supports env-var override; mature substrate).

**Established Tier-3 config:** `ai/mcp/server/<server>/config.template.mjs` per-server (memory-core, knowledge-base, etc.).

**Orchestrator config home:** unclear today. The cleanest path is likely a Tier-2 config namespace under `ai/config.template.mjs` like:

```js
orchestrator: {
    pollIntervalMs           : 3000,
    summarySweepIntervalMs   : 600000,
    kbSyncIntervalMs         : 1800000,
    backupIntervalMs         : 86400000,
    sandmanIntervalMs        : 86400000,
    // ... per future M4 coordinator
    backupRetention: {
        keepNewestK    : 3,
        rotationDays   : 30
    }
}
```

`TaskDefinitions.mjs` + per-coordinator services + backup.mjs read from `aiConfig.data.orchestrator.*` instead of inlined constants.

## Open Questions for /ticket-intake at Pickup

1. **Naming convention:** `aiConfig.data.orchestrator.*` vs flat `aiConfig.data.orchestratorPollIntervalMs`? The Tier-1 config currently uses flat keys + nested objects mixed; sibling-pattern consultation needed.
2. **Migration unit:** all-at-once (one PR migrates everything) OR per-coordinator-service (each new coordinator routes through config from inception, existing migrate later)? Pickup-time judgment.
3. **Test-fixture impact:** specs hardcode `86400000` etc. for backup-interval default checks; migrating may require spec updates.
4. **Backup-cycle K + N_DAYS:** these are buildScripts-territory (not orchestrator-territory); separate config namespace OR fold under orchestrator.backup?

## Acceptance Criteria

- [ ] AC1 — `/ticket-intake` validates premise post-orchestrator-validation milestone
- [ ] AC2 — `ai/config.template.mjs` adds orchestrator config namespace (shape TBD per Open Question 1)
- [ ] AC3 — `ai/daemons/TaskDefinitions.mjs` reads constants from config instead of inlining
- [ ] AC4 — `buildScripts/ai/backup.mjs` retention K + N_DAYS read from config
- [ ] AC5 — Per-coordinator service files read intervals from config (not inlined `DEFAULT_*` constants)
- [ ] AC6 — Test-fixture audit + alignment (replace hardcoded `86400000` etc. with config references where appropriate)
- [ ] AC7 — Existing tests continue passing
- [ ] AC8 — Cross-family review per `pull-request §6.1`

## Out of Scope

- **Detailed prescription pre-staged here** — would go stale per the empirical pattern (#11062/#11065 lesson). Pickup-time intake validates against post-orchestrator-validation reality.
- **Other substrate magic numbers** outside daemon/orchestrator scope (e.g., MCP server transport defaults, MailboxService TTLs, etc.) — sibling-fileable if surfaced.
- **Operator-facing config docs** — separate documentation pass after the migration lands.

## Avoided Traps

- ❌ **Migrate before orchestrator-validates** — wrong sequencing per operator framing; would conflate magic-number cleanup with orchestrator-correctness validation
- ❌ **Bundle with M4 coordinator extractions** — separate scope; M4 coordinators ship with their own constants, then this ticket consolidates
- ❌ **Pre-stage per-coordinator config keys** — each coordinator file may evolve its own config needs; sweep-after pattern is cleaner

## Provenance

- **Operator observation 2026-05-10:** *"many magic numbers, while we do have e.g. ai/config.template. worth an exploration ticket, but not as high as a prio"* — explicit lower-priority signal vs orchestrator-validation
- **Empirical anchor:** PR #11069 BackupCoordinatorService landing surfaced the constant pattern (DEFAULT_BACKUP_INTERVAL_MS hardcoded; K=3 + N_DAYS=30 retention constants in backup.mjs)
- **Tier-1 config substrate:** `ai/config.template.mjs` (top-level, mature)

## Related

- M3.5 keystone: #11022 (closed) + Sub-1/2/3/4 (#11041/#11044/#11051/#11068)
- M4 landscape (where additional constants will accumulate): #11062 (BackupCoord, MERGED via PR #11069), #11065 (SandmanCoord), #11070-#11074 (Dream/GoldenPath/GraphMaintenance/Heartbeat/WakeSubstrate Coords)
- Tier-3 sibling: `ai/mcp/server/memory-core/config.template.mjs`
- Cleanup-companion (sibling-fileable): #10822 Config substrate cleanup epic

---

**Self-Identification:** @neo-opus-ada (Claude Opus 4.7, Claude Code) — exploration-priority filing per operator directive 2026-05-10. Open lane; pickup deferred until orchestrator-validation milestone clears.

Origin Session ID: c2912891-b459-4a03-b2af-154d5e264df1

## Timeline

- 2026-05-09T23:05:36Z @neo-opus-ada added the `enhancement` label
- 2026-05-09T23:05:36Z @neo-opus-ada added the `ai` label
- 2026-05-09T23:05:36Z @neo-opus-ada added the `architecture` label
- 2026-05-09T23:05:36Z @neo-opus-ada added the `model-experience` label
- 2026-05-10T00:03:06Z @neo-opus-ada cross-referenced by #11080
- 2026-05-10T23:07:04Z @neo-opus-ada cross-referenced by #10960
- 2026-05-10T23:16:25Z @neo-opus-ada cross-referenced by #10961
- 2026-05-16T13:44:05Z @neo-gpt cross-referenced by #11459
- 2026-05-16T15:07:22Z @neo-opus-ada cross-referenced by PR #11460
- 2026-05-18T01:16:42Z @neo-gemini-pro assigned to @neo-gemini-pro
- 2026-05-19T15:58:13Z @tobiu unassigned from @neo-gemini-pro
- 2026-05-21T17:08:15Z @neo-opus-ada cross-referenced by #11720
- 2026-05-21T17:23:23Z @neo-opus-ada cross-referenced by #11722
- 2026-05-22T23:20:22Z @neo-gpt cross-referenced by #11797
- 2026-05-23T08:00:23Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-23T08:24:01Z @neo-opus-ada cross-referenced by PR #11825
- 2026-05-23T08:37:38Z @neo-opus-ada referenced in commit `2aba431` - "fix(agentos): use AiConfig (top-level) not Memory_Config for orchestrator intervals (#11075)

Per @neo-gpt cycle-1 review on PR #11825: my cycle-1 import was the WRONG
config substrate. `Memory_Config` from `ai/services.mjs:61` resolves to
`ai/mcp/server/memory-core/config.mjs` — the memory-core-server config, which
has no `orchestrator` block. GPT's runtime check: `{hasOrchestrator: false,
keys: ['data']}`. My fallback chain was inert — `aiConfig.orchestrator?.X`
returned undefined → fell through to legacy DEFAULT_X.

Correct top-level AI config is at `ai/config.template.mjs`. Same singleton
consumed by:
- `buildScripts/ai/backup.mjs:14` (`import AiConfig from '../../ai/config.template.mjs'`)
- `ai/scripts/orchestrator-daemon.mjs:28` (same)
- `orchestrator-daemon.mjs:278-280` calls `loadLocalAiConfig()` then reads
  `AiConfig.orchestrator || {}` — this is the pattern Orchestrator.mjs must follow

Fixes:
- Import: `Memory_Config as aiConfig` → `AiConfig from '../config.template.mjs'`
- 6 references: `aiConfig.orchestrator?.intervals?.X` → `AiConfig.orchestrator?.intervals?.X`
- Inline comment updated to cite correct singleton + daemon-entry-point pattern
- Regression test added (AC4 from cycle-1 review): sets non-default
  `AiConfig.data.orchestrator.intervals.pollMs = 7777`, deletes env override,
  calls `orchestrator.configure({})`, asserts pollIntervalMs === 7777 (not DEFAULT
  3000). Falsifies the wrong-singleton bug.

Test count: 26 → 27 passed.

The 6 interval consumers now actually reach the AiConfig fallback path as
the cycle-1 PR body advertised. Operator `ai/config.mjs` overrides now work.

Co-Authored-By: Claude Opus 4.7 <neo-opus-4-7@neomjs.com>"
- 2026-05-23T08:54:06Z @neo-opus-ada cross-referenced by #11827
- 2026-05-23T11:20:39Z @neo-opus-ada cross-referenced by #11831
- 2026-05-23T11:21:24Z @neo-opus-ada cross-referenced by #11832
- 2026-05-23T11:26:53Z @neo-opus-ada cross-referenced by PR #11838
- 2026-05-23T12:15:10Z @tobiu referenced in commit `07e4389` - "feat(util): Neo.util.Env substrate primitive for env-var parsing (#11832) (#11838)

* feat(util): add Neo.util.Env substrate primitive for env-var parsing (#11832)

Lifts ai/mcp/server/shared/helpers/EnvConfig.mjs parsers verbatim to
src/util/Env.mjs as Neo.util.Env, mirroring Neo.util.String static-class
precedent. THIN substrate-prerequisite unblocking Sub 1 (#11833) Orchestrator
masterclass-reference refactor — provides Env.parseNumber + Env.parseBool +
other decoders for the 2-value chain pattern (Env.parseNumber(process.env.X)
?? AiConfig.X) without coupling daemon code to a domain-class env parser.

API surface (lifted + Orchestrator-needed):
- parseNumber, parseBool, parsePort, parseUrl, parseString
- applyEnvBindings, setDeep (with prototype-pollution guards)

Boolean compatibility preserves both MCP ('true'/'false') AND daemon
(PrimaryRepoSyncService.parseEnabledFlag '0'/'no'/'off') conventions:
- true:  'true'/'yes'/'on'/'1' (case-insensitive after trim)
- false: 'false'/'no'/'off'/'0'
- other: warn + undefined

parseNumber returns undefined (NOT NaN) for absent input — load-bearing
for the Env.parseNumber(...) ?? AiConfig.X consumer pattern because ??
only fires on nullish, not NaN.

Decoders sit BELOW the policy line — know nothing about AiConfig / defaults
/ aliases / fallback chains / specific domain consumers. Fallback to AiConfig
stays at consumer call-site.

Test surface: test/playwright/unit/util/Env.spec.mjs proves decoder semantics
(23 tests, 611ms): absent/empty/null to undefined no warn; well-formed to
decoded; malformed to warn + undefined; ?? fallback pattern works; boolean
tokens (both MCP + daemon sets, case-insensitive); port out-of-range; URL
normalization; prototype-pollution guards in setDeep; applyEnvBindings
skip-on-absent + skip-on-malformed.

DOES NOT include the full 61-file / 80-env-var consumer migration — that
decomposes into separate Sub 6a/6b/6c follow-up tickets (orchestrator-cluster,
mcp-cluster, daemon-cluster) to avoid recreating the #11075 'start somewhere'
failure. EnvConfig.mjs retention deferred to Sub 6a/6b consumer rewire.

Graduates from Discussion #11828 (Cycle-3.3 GRADUATION_APPROVED at body
anchor 2026-05-23T10:58:31Z by @neo-gpt @ DC_kwDODSospM4BA-Ic).

* refactor(util): delegate Env.setDeep namespace-walk to Neo.ns canonical primitive (#11832)

@tobiu REQUEST CHANGES on PR #11838: setDeep was reinventing namespace-walk
already canonicalized in Neo.ns() at src/Neo.mjs:683.

setDeep now uses Neo.ns(parentKeys, false, obj) for the namespace traversal.
Retains the env-binding-substrate-specific concerns Neo.ns does not provide:

- Prototype-pollution guard for ALL keys (loop validates each key against
  __proto__ / constructor / prototype before delegating to Neo.ns)
- Non-object-intermediate refusal (Neo.ns silently returns undefined on
  non-object intermediates; setDeep wraps that with an explicit warn)

These guards are appropriate because env-binding paths derive from
operator-provided env-binding maps (hostile-input surface), unlike the
trusted class-name input Neo.ns was designed for.

Tests: 23/23 pass (600ms). All previous setDeep behaviors preserved:
- sets value at simple path
- blocks prototype pollution via __proto__
- blocks prototype pollution via constructor
- refuses to traverse non-object intermediate keys

* refactor(util): drop setDeep + prototype-pollution guards as over-engineering (#11832)

@tobiu challenge: env values become typed leaf values, never path keys —
the path is developer-authored at source level in envBindings, so there is
no env-value-derived path injection vector. Prototype-pollution guards were
cargo-cult lifted from EnvConfig.mjs original; ~0.01% threat-model relevance
for env-binding-substrate.

Changes:
- Drop Env.setDeep entirely (zero external consumers per grep ai/ src/)
- Inline minimal namespace-walk in applyEnvBindings using Neo.ns
- Add explicit comment documenting the threat-model decision (so future
  agents don't re-add the guards thinking they were missed)
- Drop 4 setDeep tests (simple path, __proto__, constructor, non-object
  intermediate); keep 1 new applyEnvBindings test exercising the non-object-
  intermediate warn path through the actual public API consumer

Tests: 20/20 pass (602ms). Net: 23 → 20 tests, ~30 lines removed from Env.mjs.

* refactor(util): drop impossible-condition globalThis.Neo guard in applyEnvBindings (#11832)

@tobiu: src/util/Env.mjs IS Neo.util.Env — registered via Neo.setupClass().
By the time applyEnvBindings runs, Neo is guaranteed loaded. The dual-branch
`if (globalThis.Neo && Neo.isEmpty(raw))` / `if (!globalThis.Neo && ...)`
pattern was impossible-condition cargo-cult lifted from EnvConfig.mjs
(which sat in ai/mcp/server/shared/helpers/ with plausibly-pre-Neo
MCP-standalone context — that context no longer applies).

Simplifies to single line: `if (Neo.isEmpty(raw)) continue;`

Tests: 20/20 pass (619ms).

* refactor(util): use Neo.isEmpty + Neo.isObject + drop explicit return undefined (#11832)

3 operator corrections in one pass:

1. `if (rawValue === undefined || rawValue === null || rawValue === '')`
   replaced with `if (Neo.isEmpty(rawValue))` — Neo.isEmpty handles all three
   (null, undefined, '') canonically via existing primitive.

2. `if (parent && typeof parent === 'object')` replaced with
   `if (Neo.isObject(parent))` — uses Neo.isObject canonical check;
   handles null safely via constructor.name optional chaining.

3. Explicit `return undefined;` at end of methods (and in guard branches)
   dropped — bare `return;` is the JS-idiomatic shape; default return value
   is already undefined.

Tests: 20/20 pass (615ms).

Net: ~10 lines removed across parsePort, parseUrl, parseBool, parseNumber,
applyEnvBindings.

* refactor(util): switch Env from setupClass to gatekeep pattern (#11832)

@tobiu: Env is pure stateless value-decoders — no Base inheritance, no
reactive configs, no lifecycle hooks. Lightweight gatekeep pattern is the
right fit, mirroring src/core/IdGenerator.mjs precedent.

Drops:
- import Base from '../core/Base.mjs'
- class Env extends Base
- static config = { className: 'Neo.util.Env' }
- Neo.setupClass(Env) export
- all `static` keywords on methods

Adopts:
- Plain object with methods (alphabetically sorted)
- Neo.gatekeep(Env, 'Neo.util.Env') export

Public API unchanged — `import Env from '<path>/src/util/Env.mjs'` and
`Env.parseNumber()` / etc work identically. Test surface untouched
(20/20 pass, 601ms).

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
### @neo-opus-ada - 2026-05-25T06:43:14Z

## Status update 2026-05-25 (assignee-self)

Walking TaskDefinitions.mjs technical debt with operator triggered this update. Live state of the originally-enumerated surfaces:

**Already migrated** (AC3 + AC4 + AC5 partial):
- `ai/config.template.mjs` line 64-86 now hosts `orchestrator.intervals.*` (pollMs / summarySweepMs / kbSyncMs / backupMs / primaryDevSyncMs / tenantRepoSyncMs / dreamMs / goldenPathMs / swarmHeartbeatMs) — the 4 originally-enumerated `DEFAULT_*_INTERVAL_MS` constants have been retired
- `ai/scripts/maintenance/backup.mjs` line 139 reads retention from `aiConfig.maintenance?.backup?.retention` — `K=3` + `N_DAYS=30` are operator-tunable
- Per-coordinator service files consume via `buildTaskDefinitions({...})` reading intervals from config

**Still inlined in TaskDefinitions.mjs** (the remaining concrete gap):
- `DEFAULT_MLX_MODEL = 'mlx-community/gemma-4-31b-it-bf16'` (line 12)
- `DEFAULT_MLX_PORT = '11435'` (line 13)
- `DEFAULT_MLX_ENABLED = false` (line 14)
- `resolveMlxEnabled()` helper (lines 25-35) which already reads `process.env.NEO_ORCHESTRATOR_MLX_ENABLED`

The env-var fallback shape of these values is the giveaway: they're operator-tunable config substrate (orchestrator-owned mlx_lm.server child-process control), same shape as the migrated intervals.

## Proposed concrete scope for the remaining sub-PR

**Target shape under `ai/config.template.mjs` orchestrator namespace:**

```js
orchestrator: {
    deploymentMode: ...,
    intervals: {...},
    swarmHeartbeat: {...},
    mlx: {
        enabled: process.env.NEO_ORCHESTRATOR_MLX_ENABLED === 'true',
        model  : process.env.NEO_ORCHESTRATOR_MLX_MODEL || 'mlx-community/gemma-4-31b-it-bf16',
        port   : process.env.NEO_ORCHESTRATOR_MLX_PORT  || '11435'
    }
}
```

**TaskDefinitions.mjs changes:**
- Remove `DEFAULT_MLX_MODEL` / `DEFAULT_MLX_PORT` / `DEFAULT_MLX_ENABLED` exports (lines 12-14)
- Remove `resolveMlxEnabled` helper (lines 25-35) — the env-var-coercion concern moves inside the AiConfig accessor
- `buildTaskDefinitions({mlxEnabled, mlxModel, mlxPort})` reads from `aiConfig.data.orchestrator.mlx.*` instead of defaulting to the deleted constants
- Update spec fixtures that import the deleted constants

**Sibling pattern reference:** `orchestrator.swarmHeartbeat` (lines 87-115 of config.template.mjs) already establishes the "nested-object-under-orchestrator" pattern for non-interval orchestrator config. The MLX namespace follows that precedent.

**Out of scope for this sub:**
- AiConfig accessor unification (env-var coercion currently spread across `resolveMcpHttpPort`, `resolveMlxEnabled`, ad-hoc inline checks) — separate refactor if surfaced as friction
- Test-fixture audit for hardcoded MLX values — sweep as part of impl PR

## What's not in this ticket's scope (sibling-fileable)

The task-name string constants in TaskDefinitions.mjs lines 7-11 (`PRIMARY_DEV_SYNC_TASK_NAME` / `TENANT_REPO_SYNC_TASK_NAME` / `DREAM_TASK_NAME` / `GOLDEN_PATH_TASK_NAME` / `SWARM_HEARTBEAT_TASK_NAME`) are a **different concern shape** — over-abstraction, not misplacement. The string values are stable substrate (used as config keys, persistence fields, health-payload fields, log substrings, test fixture keys, doc references) — renaming any one literal would break too many non-import surfaces to be plausible, so the constant insures nothing. Filing a focused sibling ticket for that retirement (linking back here as scope-clarification anchor).

## Status

- Operator-validation milestone explicitly cleared since 2026-05-10 deferral note — orchestrator validation has shipped across #11069 / #11070-#11074 / #11790 / #11791 / #11837 / #11942 substrate
- Self-pickup-eligible whenever the operator gives a go-signal; otherwise still open-lane
- Pickup cost estimate: ~1 PR-sized chunk for the MLX migration; ~30 min for impl + tests

Authored by [Claude Opus 4.7] (Claude Code) — Session continuation from cloud-deployment-trial sprint.

- 2026-05-25T06:44:48Z @neo-opus-ada cross-referenced by #11955
- 2026-05-25T07:12:26Z @neo-opus-ada cross-referenced by PR #11957
- 2026-05-25T07:20:47Z @neo-opus-ada cross-referenced by PR #11959
- 2026-05-25T07:28:27Z @neo-opus-ada referenced in commit `9dccc43` - "fix(orchestrator): use canonical Env.parseBool for NEO_ORCHESTRATOR_MLX_ENABLED parsing (#11075)

Cycle-1 review caught a real semantic gap: resolveMlxConfig used a bespoke
`envEnabled === 'true'` check that only accepted lowercase strict 'true',
rejecting the canonical Neo Env.parseBool token set (TRUE/yes/on/1 +
FALSE/no/off/0). GPT's V-B-A probe returned
{"TRUE":false,"yes":false,"one":false,"lowerTrue":true} - confirming the gap.

Replace with Env.parseBool('NEO_ORCHESTRATOR_MLX_ENABLED', {env}) ?? !!mlx.enabled
matching the canonical consumer pattern documented in Env.parseBool JSDoc.

Add regression test asserting:
- All TRUE_TOKENS (true/TRUE/yes/on/1) → enabled: true
- All FALSE_TOKENS (false/FALSE/no/off/0) → enabled: false
- Whitespace-tolerant per Env.parseBool's trim semantics
- Invalid tokens → fall back to AiConfig default (Env.parseBool warns + returns undefined)

13/13 PASS daemon.spec.mjs post-fix.

Co-Authored-By: Claude Opus 4.7 <neo-opus-4-7@neomjs.com>"
### @neo-opus-ada - 2026-05-25T07:28:55Z

## Contract Ledger (post-merge of PR #11957)

Operator-facing MLX inference-server config substrate shipped via PR #11957.

### Config namespace

| Path | Type | Default | Owner | Override |
|---|---|---|---|---|
| `aiConfig.data.orchestrator.mlx.enabled` | `Boolean` | `false` | `ai/config.template.mjs` | env (see below) |
| `aiConfig.data.orchestrator.mlx.model` | `String` | `'mlx-community/gemma-4-31b-it-bf16'` | `ai/config.template.mjs` | env |
| `aiConfig.data.orchestrator.mlx.port` | `String` | `'11435'` | `ai/config.template.mjs` | env |

### Env-var overrides

| Env-var | Type | Maps to | Parser |
|---|---|---|---|
| `NEO_ORCHESTRATOR_MLX_ENABLED` | Boolean | `mlx.enabled` | `Neo.util.Env.parseBool` |
| `NEO_ORCHESTRATOR_MLX_MODEL` | String | `mlx.model` | direct (no normalization) |
| `NEO_ORCHESTRATOR_MLX_PORT` | String | `mlx.port` | direct (no normalization) |

### Precedence

```
env-var present + parseable  →  env value wins
env-var present + invalid    →  Env.parseBool warns + returns undefined → falls back to AiConfig
env-var absent               →  AiConfig default
```

Resolution happens in `daemon.mjs::resolveMlxConfig({orchestratorConfig, env})`. Caller passes resolved `{enabled, model, port}` to `Orchestrator.start()` → `buildTaskDefinitions()` (pure function — no env-var reads, no embedded defaults).

### Boolean-token semantics (`NEO_ORCHESTRATOR_MLX_ENABLED`)

Per `Neo.util.Env.parseBool` ([src/util/Env.mjs:97](src/util/Env.mjs#L97)) — case-insensitive, trimmed:

| Tokens | Resolves to |
|---|---|
| `true`, `yes`, `on`, `1` (any case, whitespace-trimmed) | `true` |
| `false`, `no`, `off`, `0` (any case, whitespace-trimmed) | `false` |
| Any other non-empty string | warns + `undefined` (falls through to AiConfig default) |
| Absent / empty | `undefined` (falls through to AiConfig default) |

Examples that operate operators commonly expect (all behave identically):
```bash
NEO_ORCHESTRATOR_MLX_ENABLED=true     # → enabled
NEO_ORCHESTRATOR_MLX_ENABLED=TRUE     # → enabled (case-insensitive)
NEO_ORCHESTRATOR_MLX_ENABLED=yes      # → enabled (alias)
NEO_ORCHESTRATOR_MLX_ENABLED=1        # → enabled (numeric alias)
NEO_ORCHESTRATOR_MLX_ENABLED="  on  " # → enabled (whitespace-trimmed)

NEO_ORCHESTRATOR_MLX_ENABLED=false    # → disabled (overrides AiConfig default)
NEO_ORCHESTRATOR_MLX_ENABLED=no       # → disabled (alias)
NEO_ORCHESTRATOR_MLX_ENABLED=0        # → disabled (numeric alias)
```

### Migration boundary

- **Before PR #11957**: `DEFAULT_MLX_MODEL` / `DEFAULT_MLX_PORT` / `DEFAULT_MLX_ENABLED` constants + `resolveMlxEnabled` helper lived in `ai/daemons/orchestrator/TaskDefinitions.mjs` (code substrate). `buildTaskDefinitions` performed env-var lookups internally. `NEO_ORCHESTRATOR_MLX_PORT` did not exist — port was hardcoded.
- **After PR #11957**: All config moved to `ai/config.template.mjs` (config substrate). `daemon.mjs::resolveMlxConfig` owns env-var resolution via `Env.parseBool`. `buildTaskDefinitions` is pure. `NEO_ORCHESTRATOR_MLX_PORT` is now a first-class env-var override.

### Test surfaces

- `test/playwright/unit/ai/daemons/orchestrator/daemon.spec.mjs`:
  - `resolveMlxConfig overlays env-var precedence onto AiConfig.orchestrator.mlx` (5 sub-cases)
  - `resolveMlxConfig accepts canonical boolean tokens via Env.parseBool` (TRUE/yes/on/1 + FALSE/no/off/0 + whitespace + invalid-token fallback)
  - `AiConfig.orchestrator.mlx ships canonical MLX launch defaults` (regression guard against re-embedded TaskDefinitions defaults)
  - `buildTaskDefinitions is pure` (2 sub-tests asserting no env-var leakage)

### Substrate boundaries

- **Operator-facing**: env-vars + `ai/config.mjs` gitignored override (read-write)
- **AiConfig template**: `ai/config.template.mjs` orchestrator.mlx slot (read-only canonical default)
- **Resolution layer**: `daemon.mjs::resolveMlxConfig` (env-overlay onto AiConfig)
- **Consumption layer**: `Orchestrator.start({mlxEnabled, mlxModel, mlxPort})` → `buildTaskDefinitions({mlxEnabled, mlxModel, mlxPort})` (pure receivers)

---

Posted as part of the cycle-2 close-out on PR #11957 per @neo-gpt review request 2026-05-25. Authored by [Claude Opus 4.7] (Claude Code).

- 2026-05-25T07:50:28Z @tobiu referenced in commit `d0d43c7` - "feat(orchestrator): migrate MLX config to AiConfig + resolveMlxConfig helper (#11075) (#11957)

* feat(orchestrator): migrate MLX config to AiConfig + new resolveMlxConfig helper (#11075)

Move DEFAULT_MLX_MODEL / DEFAULT_MLX_PORT / DEFAULT_MLX_ENABLED constants +
resolveMlxEnabled helper from TaskDefinitions.mjs (code substrate) to
ai/config.template.mjs orchestrator.mlx namespace (config substrate).
Add daemon.mjs::resolveMlxConfig helper that overlays env-var precedence
onto AiConfig. buildTaskDefinitions becomes a pure function with no
env-var lookups and no embedded MLX defaults.

Architectural shift:
- AiConfig.orchestrator.mlx is now the single source of truth for MLX
  defaults (enabled: false, model: gemma-4-31b-it-bf16, port: 11435).
- daemon.mjs::resolveMlxConfig overlays NEO_ORCHESTRATOR_MLX_{ENABLED,
  MODEL,PORT} env-vars onto AiConfig at the daemon boundary.
- buildTaskDefinitions is pure: receives concrete enabled/model/port
  from caller; no env reads; no fallback defaults.
- Orchestrator.start now passes mlxPort through to buildTaskDefinitions.

New operator capability: NEO_ORCHESTRATOR_MLX_PORT env-var override
(previously the port was hardcoded with no operator config path).

Tests:
- Deleted Orchestrator.spec "falls back to dedicated mlx default" test
  (deprecated contract; the daemon now owns the fallback via AiConfig).
- Replaced 2 daemon.spec tests covering env-var precedence inside
  buildTaskDefinitions with 4 cleaner replacements: pure-function
  behavior, no env-var leakage, resolveMlxConfig precedence, and
  AiConfig canonical-defaults regression guard.

247/247 PASS across the orchestrator test tree post-migration.

Co-Authored-By: Claude Opus 4.7 <neo-opus-4-7@neomjs.com>

* fix(orchestrator): use canonical Env.parseBool for NEO_ORCHESTRATOR_MLX_ENABLED parsing (#11075)

Cycle-1 review caught a real semantic gap: resolveMlxConfig used a bespoke
`envEnabled === 'true'` check that only accepted lowercase strict 'true',
rejecting the canonical Neo Env.parseBool token set (TRUE/yes/on/1 +
FALSE/no/off/0). GPT's V-B-A probe returned
{"TRUE":false,"yes":false,"one":false,"lowerTrue":true} - confirming the gap.

Replace with Env.parseBool('NEO_ORCHESTRATOR_MLX_ENABLED', {env}) ?? !!mlx.enabled
matching the canonical consumer pattern documented in Env.parseBool JSDoc.

Add regression test asserting:
- All TRUE_TOKENS (true/TRUE/yes/on/1) → enabled: true
- All FALSE_TOKENS (false/FALSE/no/off/0) → enabled: false
- Whitespace-tolerant per Env.parseBool's trim semantics
- Invalid tokens → fall back to AiConfig default (Env.parseBool warns + returns undefined)

13/13 PASS daemon.spec.mjs post-fix.

Co-Authored-By: Claude Opus 4.7 <neo-opus-4-7@neomjs.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-25T07:50:29Z @tobiu closed this issue
- 2026-05-25T18:24:52Z @neo-opus-ada cross-referenced by #11986
- 2026-05-25T18:44:04Z @neo-opus-ada cross-referenced by PR #11987
- 2026-05-25T19:34:54Z @tobiu referenced in commit `e0099ff` - "feat(orchestrator): supervise lms server lifecycle as orchestrator task (#11986) (#11987)

* feat(orchestrator): supervise lms server lifecycle as orchestrator task (#11986)

Restore LM Studio CLI auto-boot as an orchestrator-managed task. Mirrors the
existing `mlx_lm.server` task shape (#11075/#11957) for the parallel
macOS-only local-embedding-server lane.

## Context

Regression from PR #11096 (#11093 "Centralize daemon supervision",
commit 7e596385e) which gutted MC-side LM Studio spawn (originally
shipped via #9833) without landing the orchestrator-side counterpart.
`ai/services/memory-core/lifecycle/InferenceLifecycleService.mjs::startInferenceServer()`
now just warns "AgentOrchestrator should be managing it" and returns
offline, while the orchestrator had no `lms` task — net effect: no-one
restarted lms server when it died.

Empirically anchored: 2026-05-25 today, embedding endpoint at :1234
went silent at 13:00:50Z (per orchestrator.log), 46min after the last
successful kbSync at 12:14:27Z, and stayed dark until operator
manually re-ran `lms server start`.

## Changes

- `ai/config.template.mjs::orchestrator.lms` — new namespace mirroring
  `orchestrator.mlx` shape: `{enabled: false, model: 'qwen3-embedding-8b',
  port: '1234'}`. JSDoc notes parallel-alternative + macOS-only scope.
- `ai/daemons/orchestrator/daemon.mjs::resolveLmsConfig` — exact mirror of
  `resolveMlxConfig`. Reads env-vars `NEO_ORCHESTRATOR_LMS_{ENABLED,MODEL,PORT}`,
  falls back to `AiConfig.orchestrator.lms`. `startOrchestrator()` calls it and
  passes `lmsEnabled/lmsModel/lmsPort` through to `Orchestrator.start()`.
- `ai/daemons/orchestrator/TaskDefinitions.mjs::buildTaskDefinitions` — accepts
  `lmsEnabled / lmsModel / lmsPort` params; conditionally emits `tasks.lms` with
  `command: 'lms'` + `args: ['server', 'start', '--port', String(lmsPort)]`.
  Mirrors `mlx` block placement + structure.
- `ai/daemons/orchestrator/Orchestrator.mjs` — accepts `lmsEnabled / lmsModel /
  lmsPort` in `start()` options; forwards to `buildTaskDefinitions`.
- Test coverage: 4 new daemon.spec.mjs tests mirror the MLX pattern (pure-function,
  no env-var leakage, env-var precedence, AiConfig canonical defaults). 1 new
  Orchestrator.spec.mjs test mirrors the MLX pass-through coverage.

## Test Evidence

`npm run test-unit -- test/playwright/unit/ai/daemons/orchestrator/daemon.spec.mjs
test/playwright/unit/ai/daemons/orchestrator/Orchestrator.spec.mjs
test/playwright/unit/ai/daemons/orchestrator/TaskDefinitions.invariants.spec.mjs`
→ **45/45 PASS (1.1s)**.

Two pre-existing `DreamService.spec.mjs` failures observed on dev — verified
unrelated to this diff via clean-tree run on origin/dev.

## Scope decision

AC5 (model-load probe ensuring the configured embedding model is loaded
post-spawn) is **deferred** to a follow-up — it would require either a new
`postSpawnProbe` hook in `ProcessSupervisor` or a wrapper script chaining
`lms server start && lms load <model>`. Server-lifecycle restoration is the
load-bearing AC; model-load probe is additive substrate.

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>

* fix(orchestrator): clarify lms.model JSDoc — flag AC5 residual (#11986)

GPT cycle-1 review on #11987 flagged rhetorical drift: the JSDoc described
`orchestrator.lms.model` as "model identifier loaded into LM Studio" but
this lane does NOT load the model — it only spawns `lms server start`.

Tighten the prose to match mechanical reality: the lane brings the server
up; ensuring the model is loaded is tracked as #11986 AC5 residual and
NOT performed today. Distinct from the OpenAI-compatible API payload
label preserved.

No behavior change; JSDoc-only edit. Paired with PR-body `Resolves #11986`
→ `Refs #11986` change so the ticket stays open for the AC5 follow-up.

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"

