diff --git a/specs/DESIGN.md b/specs/DESIGN.md index 1de02b7..7ee88cb 100644 --- a/specs/DESIGN.md +++ b/specs/DESIGN.md @@ -343,6 +343,7 @@ v1 ships as **a sequence of sub-specs**, ordered so that **every step runs and a | **~~09~~** | ~~`claude-code-ingestion`~~ — **retired 2026-07-29 as a standalone spec** (row kept: it records why, so it is not re-litigated) | CC → **profile** bootstrap moved into **spec 06 slice B**. CC → **persona** inference **cut** (the persona is user-seeded and stable, §2.3 amended). CC → **activity signals** for 07 **cut**: local keyboard-idle time is cheaper and more accurate than mining CC logs. | — | — | | **10** | `tui` | Front-end refinement: replace the CLI Host's plain print/stdin with a real **TUI** (live now-playing/status region + scrolling program log + a stable input line). The **single richer front-end murmur ever gets** — there is no GUI/menu-bar/web. | front-end polish (off the L0→L1 critical path) | 01 | | **11** | `agentic-steer` ([`specs/spec11/11-agentic-steer.md`](spec11/11-agentic-steer.md)) | The reply turn becomes an agent: the steer/talkback call runs the 03-01 harness with `switch_music` / `end_broadcast` / `submit_reply`, so the listener's words can act on the program (skip lands via handover-on-resolve; shutdown is confirm-first). Boundary automation stays local policy (§7 pillar 1 unchanged — this arms the one call the listener already pays for). | companion feel | 01,03-01,03-02,04 | +| **12** | `settings` ([`specs/spec12/12-settings.md`](spec12/12-settings.md)) | The listener's knobs, persisted: `$MURMUR_HOME/settings.json` merged under env/flags per knob, an engine-owned single-writer store with hot application, two additive wire messages, and the TUI `/settings` pane — exactly seven writable intents (anchors, music, mix gear, gap, mute, pet, memory span) plus read-only home/endpoint status. | companion polish | 01,05,10 | ### 10.1 Decomposition principles - **Interface-first (AI-friendly key)**: spec `01` declares the **VoiceProvider / MusicProvider / Memory contract seams** explicitly; their implementations land in 02 / 03-01 / 05 respectively. Parts stay decoupled and buildable in order, and a coding agent never has to guess an interface. diff --git a/specs/STATUS.md b/specs/STATUS.md index 625c597..ae6a11a 100644 --- a/specs/STATUS.md +++ b/specs/STATUS.md @@ -7,9 +7,9 @@ _This file is a **card, not a ledger**: an entry that is done and no longer guides the work gets **deleted**, not archived. History lives in git and PR bodies; measured facts live in the spec they verify._ -_Last updated: 2026-08-07 (spec 07 invites retired: murmur is a radio and does -not solicit interaction — the turn-to-you/slide-back chain is removed; typed -talkback, anchors, gating, and the activity sensor stay)_ +_Last updated: 2026-08-07 (spec 12 settings layer built: settings.json + the +engine-owned store + two additive wire messages + the TUI `/settings` pane; +seven writable intents, everything hot)_ ## Where we are @@ -43,6 +43,10 @@ how it got there: - spec 11 agentic-steer — the reply turn runs the harness with `switch_music` (handover-on-resolve) / `end_broadcast` (confirm-first) / `submit_reply`; boundary automation stays local policy. +- spec 12 settings — `$MURMUR_HOME/settings.json` merged under env/flags per + knob, the engine-owned single-writer `SettingsStore` with hot application, + `settings`/`settingsSet` on the wire, and the TUI `/settings` pane (seven + intents + read-only home/endpoint facts). ## Open @@ -59,6 +63,7 @@ hand: CI fails if this section points at an issue that is already closed. - **#83** (watch) Enter during an uncommitted IME composition may submit the line. - **#98** (eng) Steer tool-choice eval (Ollama) owed — the smoke is on-demand only; spec 11 §5. - **#99** (by-ear) Spec 11 acceptance pass — handover feel, slow-pick cover, two-phase off. +- **#111** (by-ear) Settings pane acceptance pass — focus handoff, hot knobs by ear; spec 12 §5.7. ## Pinned — do not relitigate diff --git a/specs/spec10/10-tui.md b/specs/spec10/10-tui.md index e805679..f304287 100644 --- a/specs/spec10/10-tui.md +++ b/specs/spec10/10-tui.md @@ -338,7 +338,11 @@ where it lands): Exact composition, palette, and the pet's look belong to the §6.1 creative session; this spec fixes only the four functional regions and that the input -line owns focus permanently. +line owns focus permanently. **One sanctioned exception (2026-08-07, spec 12 +§3.6)**: the listener-invoked `/settings` pane routes keys to itself until +Esc. The permanent-focus rule defends against *radio output* stealing focus +mid-keystroke; a mode the listener opened is the listener spending their own +focus, and the broadcast never pauses under it. **As built (2026-08-06, issue #95): the pet is optional.** `MURMUR_TUI_PET=0` (also `off` / `false` / `no`) drops the creature from the alive band, and the @@ -346,10 +350,13 @@ gutter that separated it from the bars goes with it — the spectrum spans the band, and the band keeps the pet's height, so nothing above or below moves and no dead hole is left where it sat. **Default is ON**: the §5.11 listening pass judged the pet's current form, not its existence, and §6.1 still owns its -identity. The knob is client-side env, like `MURMUR_TUI_KITTY_KEYBOARD` (§5.1) -— not a `Config` field, because the engine has no business in what the band -contains, and a front-end knob that traveled through the engine would be the -first plank of the theming engine §1 rules out. +identity. ~~The knob is client-side env, like `MURMUR_TUI_KITTY_KEYBOARD` +(§5.1) — not a `Config` field, because the engine has no business in what the +band contains.~~ **Superseded (2026-08-07, spec 12 §3.7)**: the knob is +`tuiPet` in the settings layer — the engine is the single configuration +holder, and one boolean riding the existing `settings` snapshot is not the +theming engine this section feared. `MURMUR_TUI_PET` survives as the +client-local escape hatch and final override (env beats file, per knob). ### 3.4 Input & interruption diff --git a/specs/spec12/12-settings.md b/specs/spec12/12-settings.md new file mode 100644 index 0000000..a0db4c9 --- /dev/null +++ b/specs/spec12/12-settings.md @@ -0,0 +1,372 @@ +# spec/12 · settings — the listener's knobs, persisted + +> **Status**: Frozen 2026-08-07 (designed in-session with the user; self-grilled +> per repo convention). Building. +> **Part**: A persistent settings layer (`~/.murmur/settings.json`) owned by the +> engine, plus the TUI settings pane (`/settings`). The layer is engine-wide — +> the plain host consumes the same file; the pane is merely its TUI face. +> **Master**: [`../DESIGN.md`](../DESIGN.md) §10 row 12. +> **Milestone**: companion polish — off every critical path. +> **Conventions**: English; written for a coding agent. Design-level — +> mechanism and contracts, not final code. + +--- + +## 1. Goal & scope + +### Delivers + +1. **`$MURMUR_HOME/settings.json`** — a hand-editable, panel-written, + agent-writable persistence layer for the knobs a *listener* (not a + developer) adjusts, merged under env and flags at boot. +2. **The engine as the sole settings authority**: one live store, one writer, + change events; every consumer (TUI pane today, a brain-harness tool later) + goes through the same engine-side setter. +3. **Two additive wire messages** (`settings` / `settingsSet`) so the TUI + reads and writes settings without touching disk or importing anything + beyond `src/ipc.ts`. +4. **A `/settings` pane in the TUI** — exactly **seven writable items** plus + two read-only lines. Seven is the ceiling, not a first tranche (spec 10 §1 + rules out the sixteen-knob theming engine). +5. **Hot application**: every item targets take-effect-now; anything that + falls to the recorded fallback is labeled honestly in the pane. + +### The seven writable items (locked 2026-08-07 — do not reopen) + +| # | Pane intent (user-facing) | Underlying knob(s) | +|---|---|---| +| 1 | Morning/evening anchors on/off | `anchorsEnabled` | +| 2 | Music on/off (off = pure talk radio) | `musicEnabled` | +| 3 | Mix gear: more-music ↔ more-talk | `cadenceMode` + `musicEveryN` (§3.5) | +| 4 | Breathing room between segments | `gapSeconds` | +| 5 | Sound on/off (mute keeps broadcasting) | `muted` (§3.4 — the master output gain) | +| 6 | Pixel pet on/off | `tuiPet` (§3.7) | +| 7 | Memory span (advanced group) | `recentWindow` | + +Read-only display: the resolved storage home (`config.home`) and the voice +endpoint status (configured / not configured — **never** the key or URL +contents beyond what `hello` already shows). + +The pane exposes **intent, never field names**: `every_n` / `random` / `brain` +and the raw knob identifiers appear nowhere in the UI. + +### Out of scope (explicit non-goals) + +- **No pane entry and no settings-file key** for: `gatingEnabled`, + `bedEnabled`, `ffmpegCmd` / `ytdlpCmd` / `bunCmd`, `MURMUR_HOME`, the + `MURMUR_TTS_*` six (owned by the setup conversation + `voice.json`, spec + 03-03 §7.2), `MURMUR_TUI_KITTY_KEYBOARD`, `brain`, `frontEnd`, + `maxSegments`, the model tier knobs (`model` / `musicModel` / + `compactModel`), `personaPath`, `MURMUR_SCENE` / `MURMUR_ACTIVITY`, + `MURMUR_DEV_LOG`. `memoryDir` / `tuiSocket` are pure derivations with no + handle — they stay read-only facts of the home. +- **No theming, no layout knobs, no mouse** (spec 10 §1). +- **No brain-harness settings tool in this spec.** The engine-side setter is + built so that tool is a later thin wrapper; the tool itself (spec 11's + posture) is not part of this delivery. +- **No schema extension points.** The file schema is exactly the eight keys + of §3.1; unknown keys are dropped on read. + +--- + +## 2. Contract / seams + +### 2.1 The file + +- **Path**: `$MURMUR_HOME/settings.json`, resolved by `paths.ts` + (`settingsPath()`), sitting at the home root beside `voice.json` — it is + re-obtainable configuration, not irreplaceable state (master §6.1). +- **Schema** (zod, `src/settings.ts`): all keys optional; absence = "the user + never touched this knob", which falls through to the layer below. + + ```ts + { + anchorsEnabled?: boolean + musicEnabled?: boolean + cadenceMode?: 'every_n' | 'random' | 'brain' + musicEveryN?: number // int, positive + gapSeconds?: number // >= 0 + recentWindow?: number // int, positive + muted?: boolean // the listener's output mute (§3.4) + tuiPet?: boolean + } + ``` + +- **Per-key salvage on read** (differs from the `voice.json` whole-file rule, + deliberately): the file is hand-editable *and* panel-written, so one broken + key must not silently discard the rest — a lost mute state is a real harm. + Read = parse JSON (unparseable file → no settings, dev-log one line), then + validate **key by key**; a bad key is dropped with a dev-log line, good + keys survive. Unknown keys are dropped. +- **Atomic write**: temp file + rename in the same directory (the + `voice-config.ts` template). No secret ever lives here, so no `0600` + ceremony. The engine writes the full current set of user-touched keys; it + never rewrites keys the user has not touched (absence is meaningful). + +### 2.2 Boot-time precedence + +Per knob, lowest first: **`settings.json` < env < CLI flags** — the +`voice.json` precedent (spec 03-03 §7.2), applied uniformly. Concretely, the +settings layer is spread into `parseCli`'s merge below `ttsFromEnv` and the +flag spreads. + +The settings layer never touches the `voice` provider knob: `muted` is the +output gain (§3.4), while which provider synthesizes (`stub`/`hosted`, +`voiceExplicit`) stays the env/flag/endpoint-derived dev surface it always +was — a muted run keeps its warm hosted voice. + +### 2.3 Runtime rule — the latest user intent wins + +Layering is a **boot-time** rule only. A `settingsSet` arriving at runtime is +the newest statement of user intent: it takes effect immediately **and** +persists to the file, even if this run was launched with a flag that overrode +that knob at boot. (Otherwise the pane is a dead UI: the user flips a switch +and nothing happens.) At the next boot the layers apply again — a re-passed +flag wins again, which is what flags mean. + +### 2.4 The engine-side store (the single authority) + +```ts +// src/settings.ts +export type Settings = { /* the 8 keys of §3.1, all required, resolved */ } + +export interface SettingsStore { + current(): Settings // resolved, live values + set(patch: Partial): void // validate → apply → persist → notify + onChange(fn: (next: Settings) => void): void +} +``` + +- Initialized from the merged boot `Config` (so flags/env are respected as + the starting state), **not** from the file alone. +- `set()` is the **only** mutation path — the pane and any future agent tool + both land here. It zod-validates the patch (reject = drop, no partial + apply), applies to the live values, writes the file atomically, then + notifies. +- What `set()` persists: the knob values being set, merged over the file's + existing user-touched keys. + +### 2.5 The wire (additive; `PROTOCOL` stays 2) + +Engine → TUI — one new message: + +| type | payload | when | +|---|---|---| +| `settings` | `{ values: Settings, home: string, voiceConfigured: boolean }` | after `hello` on every attach, and after **every** `settingsSet` (accepted or rejected — the snapshot always shows truth) | + +TUI → Engine — one new message: + +| type | payload | semantics | +|---|---|---| +| `settingsSet` | `{ patch: Partial }` | engine validates via `SettingsStore.set`; a store-rejected patch (e.g. empty) is answered with a fresh unchanged snapshot — no error channel in v1. A patch that fails the wire schema itself (an illegal value) is a **malformed message** and gets the standard §2.3 treatment: dropped with a dev-log line, no reply — the pane offers only legal values, so only a foreign client can produce one | + +- `settings` snapshots are **excluded from the replay backlog** (like `viz`): + every attach gets a fresh snapshot anyway, and a replayed stale one could + arrive after it. +- Older peers ignore both types (the §2.3 forward-compatibility rule); no + protocol bump. + +--- + +## 3. Design + +### 3.1 File keys mirror `Config` field names + +The file stores **raw fields, not intent gears**. The pane owns the +intent-to-field translation (§3.5); the file layer stays a trivial +`Partial` spread, the future agent tool can set precise values, and +no second vocabulary exists. `tuiPet` is the one new `Config` field +(boolean, default `true`). + +### 3.2 Hot-application matrix + +Every knob targets hot. Mechanism per knob: + +| Knob | Mechanism | Effect boundary | +|---|---|---| +| `gapSeconds`, `recentWindow` | Director deps read the live store instead of captured scalars (`director.ts` already reads per loop) | next gap / next brain call | +| `muted` | one gain move on the engine's master bus (§3.4); nothing else in the program notices | **instant, mid-word** — it is the listener's volume knob, and unmute resumes the sentence in flight | +| `anchorsEnabled` | the scheduler is **always constructed**; the fire site checks the live flag | next anchor check | +| `cadenceMode` + `musicEveryN` | the cadence decision point reads the live store (rebuild-per-boundary or getter — implementation's pick) | next segment boundary | +| `musicEnabled` | the music pipeline is **built whenever its dependencies exist** regardless of the flag; the Director's schedule-next-music site checks the live flag. **Off gates the spend, not just the airtime** (peer-review find): the prefetch paths consult the same flag, so a disabled session pays zero discovery calls. Toggle-on re-enters through the existing boundary cold path (the away-stream precedent) | next segment boundary | +| `tuiPet` | client-side render toggle driven by the `settings` snapshot | immediate | + +**Fallback clause (recorded, with teeth)**: if implementation finds the +music pipeline's build-time coupling (yt-dlp preflight, guide trigger) too +entangled to build-when-disabled, `musicEnabled` falls back to +**restart-required in both directions** — never the asymmetric +"off is hot, on needs restart". The pane must then label the item +"takes effect next start". No other knob has a sanctioned fallback. + +### 3.3 What hot application must NOT do + +- Never interrupt the *program* on air (cadence changes land at the next + boundary; a live segment finishes its script). Muting is not an + interruption: the segment keeps rolling, only its sound is gated (§3.4). +- Never tear down / rebuild providers mid-run (the store flips flags; + construction stays a boot concern). +- Never write the file except through `SettingsStore.set`. + +### 3.4 Mute semantics — the listener's output gain + +> **Re-decided 2026-08-07 (user re-frame, supersedes the original +> voice-provider mechanism).** The first build implemented mute as "route +> synthesis to the silent stub" (`voice: 'stub'` + a clearing `null`). That +> conflated two layers and produced an indefensible lag: the on-air clip and +> the look-ahead's pre-synthesized beats kept speaking after the toggle — a +> mute button that does not mute. The mechanism is retired; this section is +> the contract. + +- **`muted` is the engine's master-bus gain** (`AudioEngine.setMuted`): one + ramp to 0/1, ~80ms, bus-wide — voice, music, and bed together. It is the + radio's volume knob, owned by the listener. +- **The program never notices.** Synthesis, discovery, scheduling, pacing, + and the clips themselves all continue; text keeps landing in the log. + Unmute picks the sound back up mid-sentence, exactly like a real radio. + (The spend continues while muted — mute is a listening state, not a + budget lever; `--no-music` / `--voice stub` remain the spend knobs.) +- **Instant both ways, mid-word.** No buffer to drain, no next-utterance + boundary. +- The `voice` provider knob (`stub`/`hosted`, `voiceExplicit`) is untouched + by the settings layer — it stays the dev/env surface for whether murmur + synthesizes at all. +- The visualizer taps the bus, so muted bars go honestly flat. +- The pane's sound toggle never greys: mute works on any run with a speaker. + The read-only endpoint line still explains "sound on but nobody speaks" + (endpoint not configured — the setup conversation's job). + +### 3.5 The mix gear (intent → fields) + +Three gears, mapped engine-values at write time: + +| Gear (UI intent) | Written values | +|---|---| +| more music | `{ cadenceMode: 'every_n', musicEveryN: 1 }` | +| balanced *(default)* | `{ cadenceMode: 'every_n', musicEveryN: 2 }` | +| more talk | `{ cadenceMode: 'every_n', musicEveryN: 4 }` | + +- Current values that match no gear (hand-set `random` / `brain`, or another + N via file/env/flags) display as a fourth read-only position, **custom**; + selecting any gear overwrites it. `random` / `brain` stay reachable by + hand-edit / flags — the pane never names them. +- `musicEnabled: false` greys the gear out (still visible, not adjustable — + it is meaningless without music). + +### 3.6 The pane (TUI settings mode) + +- **Entry**: the listener types `/settings`. The engine owns command parsing + (spec 10 §3.2-C) — it recognizes the command and replies with a fresh + `settings` snapshot; the TUI opens the pane on that snapshot. An unknown + front-end (plain host) treats `/settings` as it treats any command it has + no rendering for: a one-line `info` pointing at `settings.json`. +- **Keys while open**: ↑/↓ move between items; Space/Enter toggles booleans + and applies a gear; ←/→ step numeric items; Esc closes. Every change sends + `settingsSet` immediately — there is no staged "apply" step; the radio is + live and so are its knobs. +- **Numeric ranges** (pane-enforced; the schema itself stays permissive for + hand edits): `gapSeconds` 0–10 step 0.5; `recentWindow` 4–48 step 2. +- **Groups**: six primary items; `recentWindow` sits under an **advanced** + divider with the two read-only lines. +- **Focus — a sanctioned exception to spec 10's permanent-focus rule**: + while the pane is open, keystrokes route to the pane and the input line + shows a placeholder ("settings open — Esc to return"). The spec-10 red + line defends against *radio output* stealing focus mid-keystroke; a + listener-invoked mode is the listener spending their own focus. Esc is + instant; the broadcast never pauses; the log keeps scrolling behind the + pane. No IME interaction exists inside the pane (toggles and steppers + only), so the CJK gate is untouched. +- **Honesty labels**: any knob operating under the §3.2 fallback clause shows + "takes effect next start" inline. Nothing else carries a label — hot is + the default and needs no caption. + +### 3.7 The pet knob's new home + +`tuiPet` moves from client-only env into the settings layer (this amends +spec 10 §3.3): file `tuiPet` < client env `MURMUR_TUI_PET` — the env stays +as a client-local escape hatch and final override, consistent with the +per-knob env-beats-file rule everywhere else. The value travels in the +`settings` snapshot; the client applies its own env override on top at +render time. The spec-10 argument ("the engine has no business in what the +band contains") loses to a stronger one: the settings layer exists precisely +so the engine is the *single configuration holder*, and one boolean riding +an existing snapshot is not a theming engine. + +### 3.8 Testing posture + +Per master §11: all deterministic → unit tests on fakes, test-first. + +- File layer: per-key salvage (bad key dropped, good keys survive, + unparseable file → empty), atomic write, unknown-key drop. +- Store: set→apply→persist→notify order; invalid patch = no-op; + initialization from merged Config (flag respected at boot, overridden by a + later `set`). +- Config merge: settings < env < flags per knob; `muted` rides in without + touching the `voice` provider derivation. +- Wire: round-trip zod on both new types; snapshot-after-set including the + rejected-patch case; backlog exclusion. +- Engine: `setMuted` silences the whole bus (voice + music) in an offline + render and restores it — gain choreography asserted on samples. +- Hot paths: director picks up a changed `gapSeconds`/`recentWindow` without + reconstruction; anchors/cadence/music flags consulted at their decision + sites (fakes, no audio). +- Pane rendering is not frame-asserted (spec 10 §3.9); it gets the bounded + client smoke plus the human pass (§5). + +--- + +## 4. Dependencies + +- **spec 01 / `config.ts`**: the merge chain. +- **spec 05 / `paths.ts`**: the single path authority gains `settingsPath()`. +- **spec 10**: the wire (`ipc.ts`), the TUI client, the replay backlog rule; + amended at §3.3 (pet) and §3.3-adjacent (focus exception noted from this + spec). +- **spec 03-02**: cadence + music scheduling decision sites; the engine's + master bus carries the mute (§3.4). +- **spec 07**: the anchor fire site. + +--- + +## 5. Acceptance criteria + +1. **File & merge**: a hand-written `settings.json` changes the running + defaults; a broken key is dropped alone (dev-log line) while its siblings + apply; env and flags still beat the file per knob; a file-set `muted: + true` boots the engine silent without touching the voice provider. +2. **Single writer**: every mutation path lands in `SettingsStore.set`; the + file on disk is byte-stable except through it; writes are atomic. +3. **Wire**: attaching yields a `settings` snapshot after `hello`; every + well-formed `settingsSet` (applied or store-rejected) yields a fresh + snapshot, and a schema-invalid one is dropped as malformed (§2.5); old + clients are unaffected; no protocol bump; snapshots never appear in the + replay backlog. +4. **Pane**: `/settings` opens the pane; exactly seven writable items + two + read-only lines; intent labels only (no `every_n`/`random`/`brain`, no + field names); custom gear position renders when values match no preset; + gear greys when music is off; Esc returns focus with the broadcast + uninterrupted; ranges enforced as §3.6. +5. **Hot effect, per knob**: gap and memory-span changes land without + reconstruction (fakes); mute silences the whole output instantly and + unmute restores it mid-sentence (offline render, §3.8); + anchors/cadence/music honor the live flag at their next decision point — + or, under the recorded fallback only, `musicEnabled` is labeled + restart-required in both directions. +6. **Isolation intact**: `test/front-end-isolation.test.ts` stays green — + the client's only `src/` import remains `ipc.ts`; the plain path + (`frontEnd: 'plain'`) gains no new cost beyond one file read at boot. +7. **Human pass (sensory, user-run)**: the pane feels like part of the + radio — open, flip music off, hear pure talk continue; mute and watch the + program roll on silently; Esc back mid-song without a hiccup. + +--- + +## 6. Open questions + +- **Cadence hot mechanism** (rebuild-per-boundary vs live getter) — an + implementation pick inside §3.2's contract, not a design fork. +- **The agent settings tool** (spec 11 posture: "say it and the radio + adjusts itself") — the store is its seam; scheduling that tool is a later + decision. +- **A `settings` CLI subcommand for plain-host users** (`murmur settings + gap 3`)? Deferred — hand-editing the file is the plain path today; open it + only if real friction shows. diff --git a/src/app.ts b/src/app.ts index d0d9770..d16d430 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,7 +10,7 @@ import { AudioContext } from 'node-web-audio-api' import { IdleSensor, osIdleProbe } from './activity.ts' import { CachedBedSource, DEFAULT_MANIFEST, defaultBedCacheDir, pullBed, ytdlpDownload } from './bed.ts' import { ClaudeBrain, StubBrain } from './brain.ts' -import { buildCadence, PacingCadence } from './cadence.ts' +import { LiveCadence, PacingCadence } from './cadence.ts' import { Compactor } from './compaction.ts' import type { Config } from './config.ts' import type { Harness, MemoryStore, VoiceProvider } from './contracts.ts' @@ -28,6 +28,7 @@ import { YtDlpMusicProvider } from './music.ts' import { loadPersona, personaLine } from './persona.ts' import { runSetup, type SetupTargets } from './guide.ts' import { LedgerScheduler } from './scheduler.ts' +import { readSettingsFile, SETTINGS_FILE, SettingsStore } from './settings.ts' import { preflightBun } from './startup.ts' import { VizFeed } from './viz.ts' import { readVoiceConfig, type VoiceConfig, VOICE_CONFIG_FILE } from './voice-config.ts' @@ -128,10 +129,52 @@ export function buildVoice(config: Config, notify: (message: string) => void = ( } } +// The one run-wide settings authority (spec 12 §2.4): seeded from the merged +// config so flags and env are respected at boot, persisting around whatever +// keys the user's own file already holds. +export function buildSettingsStore( + resolved: Config, + log: (message: string) => void = () => {}, +): SettingsStore { + const path = join(resolved.home, SETTINGS_FILE) + return new SettingsStore({ + path, + initial: { + anchorsEnabled: resolved.anchorsEnabled, + musicEnabled: resolved.musicEnabled, + cadenceMode: resolved.cadenceMode, + musicEveryN: resolved.musicEveryN, + gapSeconds: resolved.gapSeconds, + recentWindow: resolved.recentWindow, + muted: resolved.muted, + tuiPet: resolved.tuiPet, + }, + touched: readSettingsFile(path, log), + log, + }) +} + +// Whether the music pipeline is constructed (spec 12 §3.2): whenever its +// dependencies exist, so the live toggle has something to enable. Boot-enabled +// music trusts the preflight (broken binaries = no pipeline, and the pane greys +// the toggle); boot-disabled music was never probed — the probe is a network +// search --no-music deliberately skips — so it builds optimistically and a +// later toggle-on degrades honestly at use. +export function musicWiringWanted(config: Config, hasBrain: boolean, setupMusicOk: boolean): boolean { + return hasBrain && (config.musicEnabled ? setupMusicOk : true) +} + // Music wiring (find+pull -> cadence -> engine), or undefined when the session -// is talk-only: --no-music, a failed preflight, or the stub brain (the harness -// behind the pick task is the real SDK). -function buildMusic(config: Config, harness: Harness, engine: AudioEngine, host: Host): MusicWiring { +// can never play music: a failed preflight or the stub brain (the harness +// behind the pick task is the real SDK). The cadence reads the live settings, +// so the pane's mix gear lands at the next boundary. +function buildMusic( + config: Config, + settings: SettingsStore, + harness: Harness, + engine: AudioEngine, + host: Host, +): MusicWiring { const provider = new YtDlpMusicProvider({ binary: config.ytdlpCmd }) const source = new MusicProgrammer({ brain: harness, @@ -141,8 +184,8 @@ function buildMusic(config: Config, harness: Harness, engine: AudioEngine, host: // Discovery stage timings land in the dev log (issue #76). ...(host.debug !== undefined && { debug: host.debug.bind(host) }), }) - const configured = buildCadence(config.cadenceMode, { - everyN: config.musicEveryN, + const configured = new LiveCadence({ + settings: () => settings.current(), brain: harness, model: config.musicModel, }) @@ -162,7 +205,11 @@ export function buildPacing(config: Config, memory: MemoryStore): PacingWiring | const probe = osIdleProbe() return { sensor: new IdleSensor({ ...(probe !== undefined && { probe }) }), - ...(config.anchorsEnabled && { scheduler: new LedgerScheduler(memory) }), + // Always constructed (spec 12 §3.2): the Director's fire site consults the + // live anchorsEnabled, so the pane's toggle works without a restart. The + // both-flags-off boot above stays the pre-spec-07 escape hatch — a state + // the pane itself cannot reach (gating is not a pane knob). + scheduler: new LedgerScheduler(memory), gating: config.gatingEnabled, } } @@ -329,7 +376,7 @@ export async function runApp(config: Config, maxSegments?: number): Promise host.info(m)) const voice = buildVoice(resolved, (m) => host.info(m)) + // The listener's mute is the engine's master gain (spec 12 §3.4): applied + // from the persisted state now and on every change — the program never + // notices, only the speakers do. + if (settings.current().muted) engine.setMuted(true) + settings.onChange((next) => engine.setMuted(next.muted)) // The bed (spec 03-04): first-run pull at loading time, then local-only. Any // failure degrades to no bed; the radio still starts. Independent of the @@ -359,7 +414,10 @@ export async function runApp(config: Config, maxSegments?: number): Promise settings.current(), ...(pacing !== undefined && { pacing }), ...(music !== undefined && { music }), ...(steer !== undefined && { steer }), ...(compactor !== undefined && { compactor }), }) + // The settings bridge (spec 12 §2.5): the pane reads and writes through the + // engine, and every accepted change is broadcast by the store's own event. + if (host instanceof IpcHost) { + host.setSettings({ + snapshot: () => ({ + values: settings.current(), + home: resolved.home, + voiceConfigured: resolved.ttsUrl !== '', + musicAvailable: music !== undefined, + }), + apply: (patch) => settings.set(patch), + }) + settings.onChange(() => host.sendSettings()) + // The client usually attached before the bridge existed: push it the state. + host.sendSettings() + } + // Orderly shutdown on Ctrl-C (spec 01 §3.6): first signal asks the loop to // stop (it cuts playback on the way out); a second forces exit. let interrupted = false diff --git a/src/cadence.ts b/src/cadence.ts index 02a2684..e6bbb29 100644 --- a/src/cadence.ts +++ b/src/cadence.ts @@ -146,6 +146,47 @@ function chooseSegmentTool(finish: (kind: SegmentKind) => void) { ) } +export type LiveCadenceDeps = { + // The live mix knobs (spec 12 §3.2): read at every decision point, so the + // pane's gear lands at the next boundary with no rebuild. + settings: () => { cadenceMode: 'every_n' | 'random' | 'brain'; musicEveryN: number } + brain?: Harness + model?: string +} + +// The settings-aware policy: stateless modes are constructed per decision +// (EveryNCadence is two fields), the brain policy — which holds a harness — +// is built once on first use and reused. Without a harness, brain mode falls +// back to every_n at the live depth rather than failing the boundary. +export class LiveCadence implements CadencePolicy { + private deps: LiveCadenceDeps + private random = new RandomCadence() + private brainPolicy: BrainCadence | null = null + + constructor(deps: LiveCadenceDeps) { + this.deps = deps + } + + async nextKind(state: CadenceState): Promise { + const { cadenceMode, musicEveryN } = this.deps.settings() + switch (cadenceMode) { + case 'every_n': + return new EveryNCadence(musicEveryN).nextKind(state) + case 'random': + return this.random.nextKind(state) + case 'brain': { + if (this.deps.brain === undefined) return new EveryNCadence(musicEveryN).nextKind(state) + this.brainPolicy ??= new BrainCadence({ + brain: this.deps.brain, + model: this.deps.model ?? '', + fallback: new EveryNCadence(musicEveryN), + }) + return this.brainPolicy.nextKind(state) + } + } + } +} + export function buildCadence( mode: 'every_n' | 'random' | 'brain', { everyN, brain, model = '' }: { everyN: number; brain?: Harness; model?: string }, diff --git a/src/config.ts b/src/config.ts index 69962d6..5be5efe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,8 +12,9 @@ import { parseArgs } from 'node:util' import { z } from 'zod' -import { dataRoot, homeRoot, tuiSocketPath, voiceConfigPath } from './paths.ts' +import { dataRoot, homeRoot, settingsPath, tuiSocketPath, voiceConfigPath } from './paths.ts' import { DEFAULT_PERSONA_PATH } from './prompts.ts' +import { readSettingsFile } from './settings.ts' import { readVoiceConfig, type VoiceConfig } from './voice-config.ts' // The inter-sentence silence pad the hosted voice splices in (spec 02 §3.6). A @@ -76,6 +77,15 @@ export const ConfigSchema = z.object({ anchorsEnabled: z.boolean().default(true), gatingEnabled: z.boolean().default(true), + // Whether the TUI shows the pixel pet (spec 12 §3.7). An engine field only so + // the settings layer can persist and serve it; the client env MURMUR_TUI_PET + // stays the local final override. + tuiPet: z.boolean().default(true), + // The listener's mute (spec 12 §3.4): the engine's master output gain, a + // settings-layer knob. Distinct from `voice` above — muted keeps synthesizing + // and playing, just silently; `--voice stub` does not synthesize at all. + muted: z.boolean().default(false), + // --- front-end (spec 10 §2.2/§3.5) -------------------------------------- // // The TUI is the face murmur shows by default (spec 10 §6): it spawns the // OpenTUI client and speaks over the socket. A machine without bun falls back @@ -206,8 +216,11 @@ export function parseCli(argv: string[], env: NodeJS.ProcessEnv = process.env): const fromEnv = ttsFromEnv(env) const endpoint = (values['tts-url'] ?? fromEnv.ttsUrl ?? saved?.ttsUrl ?? '').trim() const tts = { ...ttsFromFile(saved, endpoint), ...fromEnv } + // The listener's persisted knobs (spec 12 §2.2): the lowest layer, per knob. + const fromSettings = readSettingsFile(settingsPath(env), (m) => console.warn(`warning: ${m}`)) const config = ConfigSchema.parse({ + ...fromSettings, ...tts, home: homeRoot(env), memoryDir: join(dataRoot(env), 'memory'), diff --git a/src/director.ts b/src/director.ts index 87b9814..a1c8eb5 100644 --- a/src/director.ts +++ b/src/director.ts @@ -41,6 +41,7 @@ import { currentScene } from './scene.ts' import type { AnchorId, Scheduler } from './scheduler.ts' const QUIT_COMMAND = '/quit' +const SETTINGS_COMMAND = '/settings' // Bounded attempts for a Brain/synth call before it degrades (lose the beat, // never the radio). @@ -70,10 +71,16 @@ const TALK_LOOKAHEAD = 2 // place. How much longer the gap runs in an empty room. const AWAY_GAP_FACTOR = 3 -export type Steer = { intent: 'quit' } | { intent: 'talkback'; text: string } +export type Steer = + | { intent: 'quit' } + | { intent: 'settings' } + | { intent: 'talkback'; text: string } export function steerFromLine(line: string): Steer { - return line.trim() === QUIT_COMMAND ? { intent: 'quit' } : { intent: 'talkback', text: line } + const trimmed = line.trim() + if (trimmed === QUIT_COMMAND) return { intent: 'quit' } + if (trimmed === SETTINGS_COMMAND) return { intent: 'settings' } + return { intent: 'talkback', text: line } } // A promise with a synchronously readable settled flag: barge-in tells "still @@ -107,6 +114,16 @@ export type PacingWiring = { gating?: boolean // default true; false = --no-gating } +// The live knobs the loop consults per decision (spec 12 §3.2): a thunk rather +// than captured scalars, so a settings change lands at the next boundary with +// no reconstruction. The SettingsStore's current() satisfies it directly. +export type DirectorSettings = { + gapSeconds: number + recentWindow: number + anchorsEnabled: boolean + musicEnabled: boolean +} + export type DirectorDeps = { persona: string brain: Brain @@ -114,8 +131,7 @@ export type DirectorDeps = { player: Player memory: MemoryStore host: Host - gapSeconds: number - recentWindow: number + settings: () => DirectorSettings music?: MusicWiring pacing?: PacingWiring // The agentic reply turn (spec 11): preferred over brain.respond when @@ -204,7 +220,11 @@ export class Director { let produced = 0 while (!this.quit && (maxSegments === undefined || produced < maxSegments)) { this.beginBoundary() - const anchor = this.deps.pacing?.scheduler?.due(this.now) ?? null + // The scheduler stays constructed; the live flag decides at the fire + // site (spec 12 §3.2), so an anchors toggle needs no restart. + const anchor = this.deps.settings().anchorsEnabled + ? (this.deps.pacing?.scheduler?.due(this.now) ?? null) + : null // An anchor is checked BEFORE cadence, so it always wins the boundary it // is due at (spec 07 §2.3). if (anchor !== null) { @@ -318,14 +338,15 @@ export class Director { // in the Director, so the stateless Brain is told what is already scheduled, // not only what has aired and been recorded. private context(queued: readonly string[] = [], cue?: string): ContextPack { - const recent = this.deps.memory.recent(this.deps.recentWindow) + const window = this.deps.settings().recentWindow + const recent = this.deps.memory.recent(window) const turns: Turn[] = queued.map((text) => ({ role: 'radio', text })) return { persona: this.deps.persona, recent: queued.length === 0 ? recent : [...recent, ...turns], scene: currentScene(new Date()), profile: this.deps.memory.profile(), - coveredTopics: this.deps.memory.recentTopics(this.deps.recentWindow), + coveredTopics: this.deps.memory.recentTopics(window), ...(this.activity !== undefined && { activity: this.activity }), ...(cue !== undefined && { cue }), } @@ -427,10 +448,14 @@ export class Director { private async wantsMusic(): Promise { const music = this.deps.music if (music === undefined) return false + // The live off switch (spec 12 §3.2): pure talk radio from the very next + // boundary — checked ahead of a due switch, because a listener who turned + // music off outranks their own earlier request for a different song. + if (!this.deps.settings().musicEnabled) return false // A due switch owns the boundary (spec 11 §2.3): the listener asked, so the // cadence policy is bypassed until a track airs or the pick comes back empty. if (this.switchDue) return true - const recent = this.deps.memory.recent(this.deps.recentWindow) + const recent = this.deps.memory.recent(this.deps.settings().recentWindow) const situation = recent.map((t) => `- ${t.role}: ${t.text}`).join('\n') const kind = await music.cadence.nextKind({ talksSinceMusic: this.talksSinceMusic, @@ -444,7 +469,7 @@ export class Director { return { persona: this.deps.persona, situation: buildMusicSituation( - this.deps.memory.recent(Math.min(MUSIC_RECENT_TURNS, this.deps.recentWindow)), + this.deps.memory.recent(Math.min(MUSIC_RECENT_TURNS, this.deps.settings().recentWindow)), this.deps.memory.recentSongs(AVOID_DEPTH), ), } @@ -455,6 +480,9 @@ export class Director { private prefetchMusic(extraLine?: string): void { const music = this.deps.music if (music === undefined || this.pendingPick !== null) return + // The live off switch gates the SPEND, not just the airtime (spec 12 §3.2): + // a disabled session must not pay discovery calls it will never play. + if (!this.deps.settings().musicEnabled) return const base = this.musicContext() const ctx = extraLine === undefined ? base : { ...base, situation: `${base.situation}\n${extraLine}` } @@ -618,7 +646,9 @@ export class Director { // Longer gaps in an empty room (spec 07 §3.2) — the stream keeps playing, // it just stops crowding a room nobody is in. const factor = this.activity === 'away' ? AWAY_GAP_FACTOR : 1 - const slept = sleep(this.deps.gapSeconds * factor * 1000, undefined, { signal: ac.signal }).then( + const slept = sleep(this.deps.settings().gapSeconds * factor * 1000, undefined, { + signal: ac.signal, + }).then( () => true, () => false, ) @@ -690,6 +720,15 @@ export class Director { this.quit = true return } + if (steer.intent === 'settings') { + // A command, not a turn (spec 12 §3.6): nothing on air is touched and + // no reply is composed. A pane-capable host shows the pane; the plain + // host gets the one pointer it can act on. + if (this.deps.host.showSettings !== undefined) this.deps.host.showSettings() + else this.deps.host.info('settings live in settings.json under the murmur home.') + steer = null + continue + } this.discardTalkAhead() // buffered look-ahead predates this user turn -> stale const composed = await this.compose(steer.text) steer = null @@ -753,6 +792,13 @@ export class Director { this.quit = true return null } + if (merged.intent === 'settings') { + // A /settings typed mid-compose is still just a command: show the pane + // (or the pointer) and keep composing the reply already in flight. + if (this.deps.host.showSettings !== undefined) this.deps.host.showSettings() + else this.deps.host.info('settings live in settings.json under the murmur home.') + continue + } texts.push(merged.text) this.deps.host.onUserLine(merged.text) this.deps.memory.record({ role: 'user', text: merged.text }) diff --git a/src/engine.ts b/src/engine.ts index 693d605..43a07c6 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -35,6 +35,10 @@ const START_SAFETY_S = 0.05 // Dead-sink margin: play() must never hang forever if the output stops pulling. const VOICE_TIMEOUT_MARGIN_S = 5 +// The listener's mute (spec 12 §3.4): fast enough to feel instant, long enough +// not to click. +const MUTE_RAMP_S = 0.08 + // The visualizer tap (spec 10 §3.6). 1024 samples buys ~47Hz resolution at the // mix rate, which the log-spaced bands need at the bass end to look like music // rather than one fat bar. Modest analyser smoothing takes the jitter off a @@ -246,7 +250,8 @@ export class AudioEngine implements MixingPlayer { // Everything the engine plays lands on this one node instead of straight on // the destination, so the visualizer has a single master bus to tap (spec 10 - // §3.6). Unity gain, never automated: it changes nothing about the mix. + // §3.6) — and so the listener's mute is one gain move (spec 12 §3.4). The + // only automation it ever carries is that mute; the mix itself never rides it. private bus: GainNode private analyser: AnalyserNode | null = null @@ -300,6 +305,16 @@ export class AudioEngine implements MixingPlayer { return node } + // -- the listener's mute (spec 12 §3.4) ------------------------------------ // + + // Output gain only: the program (synthesis, discovery, scheduling, the clips + // themselves) never notices. Instant both ways, mid-word — unmute resumes the + // sentence in flight, exactly like a radio's volume knob. The visualizer taps + // the bus, so muted bars honestly go flat. + setMuted(muted: boolean): void { + ramp(this.bus.gain, muted ? 0 : FULL_GAIN, this.ctx.currentTime, MUTE_RAMP_S) + } + // -- Player seam (spec 01): the voice channel ----------------------------- // async play(clip: AudioClip): Promise { diff --git a/src/host.ts b/src/host.ts index 4dc057c..5c6f672 100644 --- a/src/host.ts +++ b/src/host.ts @@ -32,6 +32,9 @@ export interface Host { // boundaries and presence refreshes, never polled. Optional, like debug — a // host with no status region has nothing to do with it. onState?(state: ProgramState): void + // A typed /settings wants the pane (spec 12 §3.6). Optional: a host without + // one leaves it undefined and the Director points at the file instead. + showSettings?(): void // `away` is seconds since murmur last heard anything (spec 10 §3.7.3), for a // front-end that greets the absence. Absent = no history to go on. banner(personaFirstLine: string, opts: { brain: string; voice: string; away?: number }): void diff --git a/src/ipc-host.ts b/src/ipc-host.ts index e249d6c..e2214c7 100644 --- a/src/ipc-host.ts +++ b/src/ipc-host.ts @@ -25,6 +25,8 @@ import { PROTOCOL, type EngineMessage, type ProgramState, + type SettingsPatch, + type SettingsSnapshot, } from './ipc.ts' import { statusMicrocopy } from './prompts.ts' @@ -44,6 +46,14 @@ export type IpcHostOptions = { devLog?: string | undefined } +// How the host reaches the settings authority (spec 12 §2.5): a snapshot thunk +// for the wire, and the store's set() for inbound patches. Wired by the app +// after the store exists — like the viz subscriber, the client can attach first. +export type SettingsBridge = { + snapshot: () => SettingsSnapshot + apply: (patch: SettingsPatch) => boolean +} + export class IpcHost implements Host { private queue = new LineQueue() private server: Server | null = null @@ -55,6 +65,7 @@ export class IpcHost implements Host { private opts: IpcHostOptions private vizSubscriber: ((on: boolean, fps: number | undefined) => void) | null = null private vizWanted: { on: boolean; fps: number | undefined } | null = null + private settingsBridge: SettingsBridge | null = null private mirror: (name: string, message: string) => void private markEof!: () => void private eofSeen: Promise @@ -112,6 +123,13 @@ export class IpcHost implements Host { if (!attached) return if (message.type === 'line') this.queue.push(message.text) if (message.type === 'vizSub') this.wantViz(message.on, message.fps) + if (message.type === 'settingsSet') { + // A successful set is broadcast by the store's own change event (the + // app wires onChange -> sendSettings); the host answers only what + // would otherwise go silent, so the pane always converges on truth. + const applied = this.settingsBridge?.apply(message.patch) ?? false + if (!applied) this.sendSettings() + } }) socket.on('data', (chunk: string) => feed(chunk)) // A front-end that vanished mid-write is not an engine problem. @@ -136,6 +154,9 @@ export class IpcHost implements Host { this.client = socket this.write(socket, { v: 1, type: 'hello', ...this.greeting() }) for (const message of this.replay) this.write(socket, message) + // Every attach gets a fresh snapshot (spec 12 §2.5) — which is exactly why + // snapshots stay out of the replay backlog above. + this.sendSettings() } // The handshake payload, built in one place so a client that attaches late @@ -185,6 +206,28 @@ export class IpcHost implements Host { if (this.client !== null) this.write(this.client, { v: 1, type: 'viz', bins }) } + setSettings(bridge: SettingsBridge): void { + this.settingsBridge = bridge + } + + // The current snapshot, straight to the attached client — also NOT through + // send(): an attach is answered with a fresh one, so a replayed stale copy + // could only ever arrive after (and contradict) it. + sendSettings(opts: { open?: boolean } = {}): void { + if (this.settingsBridge === null || this.client === null) return + this.write(this.client, { + v: 1, + type: 'settings', + ...this.settingsBridge.snapshot(), + ...(opts.open === true && { open: true as const }), + }) + } + + // A typed /settings (spec 12 §3.6): the Director asks, the pane opens. + showSettings(): void { + this.sendSettings({ open: true }) + } + // --- Host ---------------------------------------------------------------- // start(): void {} diff --git a/src/ipc.ts b/src/ipc.ts index 0ebc7ce..b2a03fd 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -28,6 +28,50 @@ export const ProgramStateSchema = z.object({ export type ProgramState = z.infer +// The listener's knobs (spec 12 §1): exactly these eight, resolved — the live +// values the engine's SettingsStore holds. The schema doubles as the per-key +// validator for the settings FILE (spec 12 §2.1), so the file and the wire can +// never disagree on what a legal value is. +export const SettingsValuesSchema = z.object({ + anchorsEnabled: z.boolean(), + musicEnabled: z.boolean(), + cadenceMode: z.enum(['every_n', 'random', 'brain']), + musicEveryN: z.number().int().positive(), + gapSeconds: z.number().min(0), + recentWindow: z.number().int().positive(), + // The listener's mute (spec 12 §3.4): the engine's master output gain. The + // program never notices — this is the radio's volume knob, not a provider + // swap (`--voice stub` remains the dev-surface knob for not synthesizing). + muted: z.boolean(), + tuiPet: z.boolean(), +}) + +export type Settings = z.infer + +// A mutation (spec 12 §2.4): a partial over the same eight knobs. +export const SettingsPatchSchema = z.object({ + anchorsEnabled: z.boolean().optional(), + musicEnabled: z.boolean().optional(), + cadenceMode: z.enum(['every_n', 'random', 'brain']).optional(), + musicEveryN: z.number().int().positive().optional(), + gapSeconds: z.number().min(0).optional(), + recentWindow: z.number().int().positive().optional(), + muted: z.boolean().optional(), + tuiPet: z.boolean().optional(), +}) + +export type SettingsPatch = z.infer + +// The read-only facts that ride the settings snapshot (spec 12 §2.5): where +// the home resolved, and whether the voice endpoint / music pipeline exist — +// never the key, never the URL. +export type SettingsSnapshot = { + values: Settings + home: string + voiceConfigured: boolean + musicAvailable: boolean +} + const v = z.literal(ENVELOPE) // --- engine -> tui --------------------------------------------------------- // @@ -57,6 +101,20 @@ export const EngineMessageSchema = z.discriminatedUnion('type', [ }), z.object({ v, type: z.literal('info'), text: z.string() }), z.object({ v, type: z.literal('viz'), bins: z.array(z.number()) }), + // The settings snapshot (spec 12 §2.5): sent after `hello` on attach and + // after every settingsSet — the pane always renders truth, never local + // optimism. The read-only facts ride along; no key, no URL. `open` marks the + // one snapshot that answers a typed `/settings`, telling the client to show + // the pane rather than just refresh it. + z.object({ + v, + type: z.literal('settings'), + values: SettingsValuesSchema, + home: z.string(), + voiceConfigured: z.boolean(), + musicAvailable: z.boolean(), + open: z.literal(true).optional(), + }), z.object({ v, type: z.literal('bye') }), ]) @@ -68,6 +126,7 @@ export const TuiMessageSchema = z.discriminatedUnion('type', [ z.object({ v, type: z.literal('attach'), protocol: z.number().int() }), z.object({ v, type: z.literal('line'), text: z.string() }), z.object({ v, type: z.literal('vizSub'), on: z.boolean(), fps: z.number().positive().optional() }), + z.object({ v, type: z.literal('settingsSet'), patch: SettingsPatchSchema }), ]) export type TuiMessage = z.infer diff --git a/src/paths.ts b/src/paths.ts index 21a60d1..cced6d4 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -54,3 +54,9 @@ export function tuiSocketPath(env: NodeJS.ProcessEnv = process.env): string { export function voiceConfigPath(env: NodeJS.ProcessEnv = process.env): string { return join(homeRoot(env), 'voice.json') } + +// The listener's knobs (spec 12 §2.1): beside voice.json for the same reason — +// re-obtainable configuration, not irreplaceable state. +export function settingsPath(env: NodeJS.ProcessEnv = process.env): string { + return join(homeRoot(env), 'settings.json') +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..c7f4645 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,115 @@ +// The settings layer (spec 12): the listener's knobs, persisted at +// $MURMUR_HOME/settings.json and held live by the engine-owned SettingsStore — +// the single mutation path for the TUI pane today and any agent tool later. +// +// The file is hand-editable AND panel-written, so reads salvage per key: one +// broken value is dropped alone (a lost mute state is a real harm), good keys +// survive, unknown keys are ignored. Writes are temp-file + rename (the +// voice-config discipline) so a reader never sees a torn file; no secret ever +// lives here, so no permission ceremony. + +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +import { SettingsPatchSchema, SettingsValuesSchema, type Settings, type SettingsPatch } from './ipc.ts' + +export const SETTINGS_FILE = 'settings.json' + +type Log = (message: string) => void + +// Everything on disk is Partial: absence means "the user never touched this +// knob", which falls through to the layer below at boot (spec 12 §2.2). +export function readSettingsFile(path: string, log: Log = () => {}): Partial { + let raw: string + try { + raw = readFileSync(path, 'utf-8') + } catch { + return {} + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + log(`settings: ${path} is not JSON; ignoring it`) + return {} + } + if (typeof parsed !== 'object' || parsed === null) { + log(`settings: ${path} is not an object; ignoring it`) + return {} + } + const values: Partial = {} + for (const key of Object.keys(SettingsValuesSchema.shape) as (keyof Settings)[]) { + if (!(key in parsed)) continue + const checked = SettingsValuesSchema.shape[key].safeParse( + (parsed as Record)[key], + ) + if (checked.success) Object.assign(values, { [key]: checked.data }) + else log(`settings: ignoring unusable ${key} in ${path}`) + } + return values +} + +export function writeSettingsFile(path: string, values: Partial): void { + mkdirSync(dirname(path), { recursive: true }) + const tmp = `${path}.tmp` + writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, 'utf-8') + renameSync(tmp, path) +} + +export type SettingsStoreDeps = { + path: string + // The merged boot Config's values — flags and env respected as the start. + initial: Settings + // The file's keys at boot: what the user has explicitly set, and therefore + // what set() persists around. Never grows a key the user did not touch. + touched: Partial + log?: Log +} + +// The single settings authority (spec 12 §2.4). Layering is boot-time only: +// a set() here is the newest user intent and wins over whatever flags said at +// launch (§2.3). +export class SettingsStore { + private live: Settings + private touched: Partial + private deps: SettingsStoreDeps + private listeners: ((next: Settings) => void)[] = [] + + constructor(deps: SettingsStoreDeps) { + this.deps = deps + this.live = { ...deps.initial } + this.touched = { ...deps.touched } + } + + current(): Settings { + return { ...this.live } + } + + onChange(listener: (next: Settings) => void): void { + this.listeners.push(listener) + } + + // Validate -> apply -> persist -> notify. False = nothing applied (an empty + // or invalid patch); the caller answers with an unchanged snapshot so the + // pane always shows truth. + set(patch: SettingsPatch): boolean { + const checked = SettingsPatchSchema.safeParse(patch) + if (!checked.success) return false + const entries = Object.entries(checked.data).filter(([, value]) => value !== undefined) + if (entries.length === 0) return false + for (const [key, value] of entries) { + Object.assign(this.live, { [key]: value }) + Object.assign(this.touched, { [key]: value }) + } + try { + writeSettingsFile(this.deps.path, this.touched) + } catch (err) { + // The live change stands even if the disk does not: the radio obeys the + // listener now, and the loss is only next boot's default. + this.deps.log?.(`settings: could not persist (${String(err)})`) + } + const next = this.current() + for (const listener of this.listeners) listener(next) + return true + } +} diff --git a/src/voice.ts b/src/voice.ts index e57db8e..ea5fb7d 100644 --- a/src/voice.ts +++ b/src/voice.ts @@ -34,3 +34,4 @@ export class StubVoice implements VoiceProvider { this.dir = null } } + diff --git a/test/app.test.ts b/test/app.test.ts index 324c127..40c7bae 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -9,7 +9,9 @@ import { buildHost, buildMemory, buildPacing, + buildSettingsStore, buildVoice, + musicWiringWanted, resolvePersonaPath, runBootstrapProfileCli, runSetupCli, @@ -23,6 +25,7 @@ import { HostedVoice } from '../src/hosted-voice.ts' import { IpcHost } from '../src/ipc-host.ts' import { InProcessMemoryStore, PersistentMemoryStore } from '../src/memory.ts' import { LedgerScheduler } from '../src/scheduler.ts' +import { readSettingsFile } from '../src/settings.ts' import { StubVoice } from '../src/voice.ts' const config = (argv: string[], env: NodeJS.ProcessEnv = {}) => parseCli(argv, env).config @@ -214,8 +217,10 @@ describe('pacing wiring', () => { expect(pacing).toMatchObject({ gating: true }) }) - it('drops each feature on its flag while keeping the sensor', () => { - expect(buildPacing(config(['--no-anchors']), memory)!.scheduler).toBeUndefined() + it('keeps the scheduler constructed under --no-anchors (the live flag gates firing)', () => { + // spec 12 §3.2: anchors toggle hot, so the scheduler exists whenever the + // pacing block does — the Director's fire site consults the live setting. + expect(buildPacing(config(['--no-anchors']), memory)!.scheduler).toBeInstanceOf(LedgerScheduler) expect(buildPacing(config(['--no-gating']), memory)!.gating).toBe(false) }) @@ -276,6 +281,47 @@ describe('memory wiring', () => { }) }) +// spec 12 §2.4: one store per run, seeded from the merged config (flags/env +// respected), persisting around the file's user-touched keys. +describe('settings store wiring (spec 12)', () => { + it('starts from the merged config and persists around the touched keys', () => { + const home = emptyHome() + writeFileSync(join(home, 'settings.json'), JSON.stringify({ gapSeconds: 5 })) + const store = buildSettingsStore(config(['--gap', '1'], { MURMUR_HOME: home })) + expect(store.current().gapSeconds).toBe(1) // the flag won at boot + store.set({ tuiPet: false }) + // ...but the file remembers the user's own 5 for the next flag-less boot. + expect(readSettingsFile(join(home, 'settings.json'))).toEqual({ gapSeconds: 5, tuiPet: false }) + }) + + it('a persisted mute seeds the store without touching the voice provider', () => { + const home = emptyHome() + writeFileSync(join(home, 'settings.json'), JSON.stringify({ muted: true })) + const c = config([], { MURMUR_HOME: home, MURMUR_TTS_URL: 'https://x' }) + expect(c.voice).toBe('hosted') // the provider still derives from the endpoint + const store = buildSettingsStore(c) + expect(store.current().muted).toBe(true) + store.set({ muted: false }) + expect(store.current().muted).toBe(false) + }) +}) + +// spec 12 §3.2: the music pipeline is built whenever its dependencies exist so +// the live toggle has something to enable — with the one preflight exception. +describe('music wiring decision (spec 12)', () => { + it('follows the preflight when boot-enabled, builds optimistically when boot-disabled', () => { + expect(musicWiringWanted(config([]), true, true)).toBe(true) + // Boot-enabled but broken binaries: no pipeline — the pane greys the toggle. + expect(musicWiringWanted(config([]), true, false)).toBe(false) + // Boot-disabled: the binaries were never probed (the probe is a network + // search); build, and let a later toggle-on degrade honestly at use. + expect(musicWiringWanted(config(['--no-music']), true, false)).toBe(true) + // No harness (stub brain): never. + expect(musicWiringWanted(config(['--no-music']), false, false)).toBe(false) + expect(musicWiringWanted(config([]), false, true)).toBe(false) + }) +}) + describe('--bootstrap-profile (spec 06 §3.4 re-entry)', () => { it('needs the real brain: a stub run refuses instead of pretending', async () => { expect(await runBootstrapProfileCli(config(['--brain', 'stub']))).toBe(false) diff --git a/test/cadence.test.ts b/test/cadence.test.ts index fb8670a..ef5b3e8 100644 --- a/test/cadence.test.ts +++ b/test/cadence.test.ts @@ -4,6 +4,7 @@ import { BrainCadence, buildCadence, EveryNCadence, + LiveCadence, PacingCadence, RandomCadence, type CadencePolicy, @@ -137,3 +138,47 @@ describe('PacingCadence — activity gating (spec 07 §2.5, acceptance 7)', () = expect(harness.calls).toBe(0) }) }) + +// spec 12 §3.2: the mix gear is live — the decision point reads the current +// mode and depth, so a settings change lands at the next boundary with no +// rebuild. The stateless policies are constructed per call; the brain policy +// (which holds a harness) is built once and reused. +describe('LiveCadence (spec 12)', () => { + it('follows the live mode and depth at each decision point', async () => { + let mode: 'every_n' | 'random' | 'brain' = 'every_n' + let everyN = 2 + const cadence = new LiveCadence({ + settings: () => ({ cadenceMode: mode, musicEveryN: everyN }), + }) + expect(await cadence.nextKind({ talksSinceMusic: 1 })).toBe('talk') + everyN = 1 + expect(await cadence.nextKind({ talksSinceMusic: 1 })).toBe('music') + mode = 'every_n' + everyN = 9 + expect(await cadence.nextKind({ talksSinceMusic: 3 })).toBe('talk') + }) + + it('brain mode consults the harnessed policy, and leaving it stops the calls', async () => { + let mode: 'every_n' | 'random' | 'brain' = 'brain' + const harness = new FakeHarness(async (tools) => { + await callTool(tools, 'choose_segment', { kind: 'music' }) + }) + const cadence = new LiveCadence({ + settings: () => ({ cadenceMode: mode, musicEveryN: 2 }), + brain: harness, + model: 'm', + }) + expect(await cadence.nextKind({ talksSinceMusic: 0 })).toBe('music') + expect(harness.calls).toBe(1) + mode = 'every_n' + expect(await cadence.nextKind({ talksSinceMusic: 0 })).toBe('talk') + expect(harness.calls).toBe(1) // no further brain spend + }) + + it('brain mode without a harness falls back to every_n at the live depth', async () => { + const cadence = new LiveCadence({ + settings: () => ({ cadenceMode: 'brain', musicEveryN: 1 }), + }) + expect(await cadence.nextKind({ talksSinceMusic: 1 })).toBe('music') + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts index 25ceeaa..32137c9 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -312,6 +312,67 @@ describe('voice config file precedence', () => { }) }) +// spec 12 §2.2: settings.json merges under env and flags, per knob; a voice key +// present in the file is an explicit choice (the pane's mute), so it carries +// the same provenance a typed --voice does. +describe('settings file layer (spec 12)', () => { + const home = (settings: object | string | null): NodeJS.ProcessEnv => { + const dir = mkdtempSync(join(tmpdir(), 'murmur-cfg-')) + if (settings !== null) { + const body = typeof settings === 'string' ? settings : JSON.stringify(settings) + writeFileSync(join(dir, 'settings.json'), body) + } + return { MURMUR_HOME: dir } + } + + it('a hand-written settings.json changes the running defaults', () => { + const { config } = parseCli([], home({ gapSeconds: 5, musicEnabled: false, tuiPet: false })) + expect(config.gapSeconds).toBe(5) + expect(config.musicEnabled).toBe(false) + expect(config.tuiPet).toBe(false) + expect(config.recentWindow).toBe(12) // untouched knobs keep their defaults + }) + + it('flags beat the file per knob', () => { + const { config } = parseCli(['--gap', '1'], home({ gapSeconds: 5, musicEveryN: 4 })) + expect(config.gapSeconds).toBe(1) + expect(config.musicEveryN).toBe(4) // the un-flagged sibling still applies + }) + + it('a file-set mute rides into the config without touching the voice knob', () => { + // spec 12 §3.4: muted is the output gain; the voice PROVIDER still derives + // from the endpoint exactly as before — a muted run keeps its warm voice. + const env = home({ muted: true }) + env.MURMUR_TTS_URL = 'https://env.example' + const { config } = parseCli([], env) + expect(config.muted).toBe(true) + expect(config.voice).toBe('hosted') + }) + + it('defaults to unmuted', () => { + expect(parseCli([], NO_ENV).config.muted).toBe(false) + }) + + it('a broken key is dropped alone while its siblings apply', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { config } = parseCli([], home({ gapSeconds: -5, musicEveryN: 3 })) + expect(config.gapSeconds).toBe(2) // the broken key falls back to default + expect(config.musicEveryN).toBe(3) + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('an unparseable file degrades to defaults rather than aborting startup', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(parseCli([], home('{not json')).config.gapSeconds).toBe(2) + warn.mockRestore() + }) + + it('defaults the pet on', () => { + expect(parseCli([], NO_ENV).config.tuiPet).toBe(true) + }) +}) + // spec 10 §6 (decided 2026-07-31): the default front-end is 'tui'; a bun-less // machine falls back to plain at the app level, and --plain / TUI=0 are the // explicit escape. diff --git a/test/director-music.test.ts b/test/director-music.test.ts index d8deaf4..8510b79 100644 --- a/test/director-music.test.ts +++ b/test/director-music.test.ts @@ -7,6 +7,7 @@ import { EveryNCadence } from '../src/cadence.ts' import { Director, type DirectorDeps } from '../src/director.ts' import { InProcessMemoryStore } from '../src/memory.ts' import { + directorSettings, FakeBrain, FakeHost, FakeMixingPlayer, @@ -17,7 +18,10 @@ import { until, } from './fakes.ts' -function build(overrides: Partial = {}) { +function build( + overrides: Partial & { gapSeconds?: number; recentWindow?: number } = {}, +) { + const { gapSeconds = 0, recentWindow = 6, ...rest } = overrides const brain = new FakeBrain() brain.batches = [ ['talk one', 'talk two'], @@ -29,6 +33,7 @@ function build(overrides: Partial = {}) { const host = new FakeHost() const source = new FakeTrackSource() const memory = new InProcessMemoryStore() + const knobs = directorSettings({ gapSeconds, recentWindow }) const deps: DirectorDeps = { persona: 'persona', brain, @@ -36,12 +41,11 @@ function build(overrides: Partial = {}) { player, memory, host, - gapSeconds: 0, - recentWindow: 6, + settings: () => knobs, music: { source, cadence: new EveryNCadence(1), engine: player }, - ...overrides, + ...rest, } - return { deps, brain, voice, player, host, source, memory, director: new Director(deps) } + return { deps, knobs, brain, voice, player, host, source, memory, director: new Director(deps) } } describe('music scheduling (cadence at the boundary)', () => { @@ -118,7 +122,8 @@ describe('prefetch (spec 04 slice: never block the air)', () => { it('a pick still resolving yields a talk segment instead of dead air', async () => { const { deps, host, source, player } = build() - const director = new Director({ ...deps, gapSeconds: 0.1 }) + const slow = directorSettings({ gapSeconds: 0.1, recentWindow: 6 }) + const director = new Director({ ...deps, settings: () => slow }) let resolvePick!: (p: ReturnType | null) => void const gated = new Promise | null>((r) => (resolvePick = r)) source.nextTrack = async (ctx) => { @@ -338,3 +343,47 @@ describe('program state during music (spec 10)', () => { await run }) }) + +// spec 12 §3.2: the music switch is live at the scheduling site — flipping it +// off makes the very next boundary talk (pure talk radio), flipping it back on +// lets the cadence schedule again. The pipeline is never torn down. +describe('live musicEnabled (spec 12)', () => { + it('off yields talk at the next boundary; on resumes scheduling', async () => { + const { knobs, player, host, source, director } = build() + source.picks = [pickOf('https://stream/s1'), pickOf('https://stream/s2')] + knobs.musicEnabled = false + await director.run(3) // cadence would say music at every other boundary + expect(player.music.length).toBe(0) + expect(host.radio.length).toBe(3) // all talk + const on = build() + on.source.picks = [pickOf('https://stream/s3')] + const run = on.director.run(2) + await until(() => on.player.handles.length === 1, 'music back on air') + on.player.handles[0]!.end() + await run + }) + + // Peer review (codex): with the pipeline always constructed, the prefetch + // paths (run start, per beat, per reply) still spent real discovery calls on + // a session whose live flag said no music. Off = zero spend, not just zero + // airtime. + it('spends nothing on discovery while music is off', async () => { + const { knobs, source, director } = build() + knobs.musicEnabled = false + await director.run(3) + expect(source.calls).toBe(0) + }) + + it('toggling on schedules music again at the next boundary (the away-stream cold path)', async () => { + const { knobs, player, source, director } = build({ gapSeconds: 0 }) + knobs.musicEnabled = false + source.picks = [pickOf('https://stream/later')] + await director.run(1) // one talk segment, no priming while off + expect(source.calls).toBe(0) + knobs.musicEnabled = true + const run = director.run(2) + await until(() => player.handles.length === 1, 'music on air after the toggle') + player.handles[0]!.end() + await run + }) +}) diff --git a/test/director-pacing.test.ts b/test/director-pacing.test.ts index 7dfb959..73c2a75 100644 --- a/test/director-pacing.test.ts +++ b/test/director-pacing.test.ts @@ -9,6 +9,7 @@ import { Director, type DirectorDeps, type PacingWiring } from '../src/director. import { InProcessMemoryStore } from '../src/memory.ts' import type { AnchorId, Scheduler } from '../src/scheduler.ts' import { + directorSettings, FakeBrain, FakeHost, FakeMixingPlayer, @@ -60,13 +61,18 @@ class FakeScheduler implements Scheduler { } // `pacing: null` builds a pre-spec-07 Director (no pacing block at all). -function build(over: Partial = {}, pacing: Partial | null = {}) { +function build( + over: Partial & { gapSeconds?: number; anchorsEnabled?: boolean } = {}, + pacing: Partial | null = {}, +) { + const { gapSeconds = 0, anchorsEnabled = true, ...rest } = over const brain = new FakeBrain() const voice = new FakeVoice() const player = new FakeMixingPlayer() const host = new FakeHost() const memory = new InProcessMemoryStore() const sensor = new FakeSensor() + const knobs = directorSettings({ gapSeconds, anchorsEnabled }) const deps: DirectorDeps = { persona: 'p', brain, @@ -74,12 +80,11 @@ function build(over: Partial = {}, pacing: Partial | player, memory, host, - gapSeconds: 0, - recentWindow: 12, + settings: () => knobs, ...(pacing !== null && { pacing: { sensor, ...pacing } }), - ...over, + ...rest, } - return { brain, voice, player, host, memory, sensor, deps, director: new Director(deps) } + return { brain, voice, player, host, memory, sensor, knobs, deps, director: new Director(deps) } } // Music wiring whose cadence always says music — the away room's stream. @@ -279,3 +284,24 @@ describe('ordinary talk carries no cue', () => { expect(host.radio).toHaveLength(6) }) }) + +// spec 12 §3.2: anchors are gated live at the fire site — the scheduler stays +// constructed, the flag decides at each boundary. +describe('live anchorsEnabled (spec 12)', () => { + it('a due anchor is skipped while off and fires once back on', async () => { + const scheduler = new FakeScheduler('morning') + const { brain, host, knobs, director } = build({}, { scheduler }) + knobs.anchorsEnabled = false + brain.batches = [['plain talk', 'more talk'], ['later']] + const off = director.run(1) + await off + expect(scheduler.fired).toEqual([]) // due, but the live flag said no + expect(host.radio).toEqual(['plain talk']) + + const again = new FakeScheduler('morning') + const on = build({}, { scheduler: again }) + on.brain.batches = [['anchor beat', 'x'], ['y']] + await on.director.run(1) + expect(again.fired).toEqual(['morning']) + }) +}) diff --git a/test/director-steer.test.ts b/test/director-steer.test.ts index d5d5502..c80f220 100644 --- a/test/director-steer.test.ts +++ b/test/director-steer.test.ts @@ -9,6 +9,7 @@ import type { ContextPack, SteerActions, SteerBrain } from '../src/contracts.ts' import { Director, type DirectorDeps } from '../src/director.ts' import { InProcessMemoryStore } from '../src/memory.ts' import { + directorSettings, FakeBrain, FakeHost, FakeMixingPlayer, @@ -50,8 +51,7 @@ function build(steer: SteerBrain, opts: { music?: boolean } = {}) { player, memory, host, - gapSeconds: 0, - recentWindow: 6, + settings: () => directorSettings({ recentWindow: 6 }), ...(opts.music !== false && { music: { source, cadence: new EveryNCadence(1), engine: player }, }), diff --git a/test/director.test.ts b/test/director.test.ts index 6fa2681..e66c195 100644 --- a/test/director.test.ts +++ b/test/director.test.ts @@ -7,14 +7,16 @@ import { describe, expect, it } from 'vitest' import { InProcessMemoryStore, PersistentMemoryStore } from '../src/memory.ts' import { Director, steerFromLine, type DirectorDeps } from '../src/director.ts' import { SCENES } from '../src/scene.ts' -import { FakeBrain, FakeHost, FakePlayer, FakeVoice, until } from './fakes.ts' +import { directorSettings, FakeBrain, FakeHost, FakePlayer, FakeVoice, until } from './fakes.ts' -function setup(over: Partial = {}) { +function setup(over: Partial & { gapSeconds?: number } = {}) { + const { gapSeconds = 0, ...rest } = over const brain = new FakeBrain() const voice = new FakeVoice() const player = new FakePlayer() const host = new FakeHost() const memory = new InProcessMemoryStore() + const knobs = directorSettings({ gapSeconds }) const director = new Director({ persona: 'p', brain, @@ -22,20 +24,79 @@ function setup(over: Partial = {}) { player, memory, host, - gapSeconds: 0, - recentWindow: 12, - ...over, + settings: () => knobs, + ...rest, }) - return { brain, voice, player, host, memory, director } + return { brain, voice, player, host, memory, knobs, director } } describe('steerFromLine', () => { - it('classifies /quit and talkback', () => { + it('classifies /quit, /settings and talkback', () => { expect(steerFromLine(' /quit ')).toEqual({ intent: 'quit' }) + expect(steerFromLine('/settings')).toEqual({ intent: 'settings' }) expect(steerFromLine('hello')).toEqual({ intent: 'talkback', text: 'hello' }) }) }) +// spec 12 §3.6: /settings is a command like /quit — the engine owns the parse. +// A front-end with a pane is told to show it; the plain host gets one pointer +// line. Neither burns a reply turn nor interrupts what is on air. +describe('Director — /settings command', () => { + it('tells a pane-capable host to show the pane, without composing a reply', async () => { + const { brain, host, director } = setup({ gapSeconds: 3 }) + let shown = 0 + host.showSettings = () => void shown++ + brain.batches = [['a'], ['b']] + const run = director.run(2) + await until(() => host.radio.length === 1, 'first segment') + host.type('/settings') + await until(() => shown === 1, 'pane shown') + host.type('/quit') + await run + expect(brain.respondCalls).toEqual([]) // never treated as talkback + }) + + it('points a plain host at the file instead', async () => { + const { brain, host, director } = setup({ gapSeconds: 3 }) + brain.batches = [['a'], ['b']] + const run = director.run(2) + await until(() => host.radio.length === 1, 'first segment') + host.type('/settings') + await until(() => host.infos.some((m) => m.includes('settings.json')), 'pointer line') + host.type('/quit') + await run + expect(brain.respondCalls).toEqual([]) + }) +}) + +// spec 12 §3.2: gap and memory span read the live thunk — a change lands at the +// next boundary with no reconstruction. +describe('Director — live settings', () => { + it('picks up a changed recentWindow at the next brain call', async () => { + const { brain, memory, knobs, director } = setup() + for (let i = 1; i <= 6; i++) memory.record({ role: 'radio', text: `old ${i}` }) + knobs.recentWindow = 3 + brain.batches = [['a', 'b']] + await director.run(1) + expect(brain.talkContexts[0]!.recent.length).toBe(3) + }) + + it('picks up a changed gapSeconds at the next gap', async () => { + const { brain, player, host, knobs, director } = setup({ gapSeconds: 60 }) + player.auto = false + brain.batches = [['a'], ['b']] + const run = director.run(2) + await until(() => player.played.length === 1, 'clip on air') + knobs.gapSeconds = 0 // lands while the clip still plays -> the gap reads it + player.finish() + // A captured 60s gap would park the loop here; the live read airs b now. + await until(() => player.played.length === 2, 'second segment aired') + player.finish() + await run + expect(host.radio).toEqual(['a', 'b']) + }) +}) + describe('Director — autonomous talk loop', () => { it('airs beats from batched calls and records them', async () => { const { brain, player, host, memory, director } = setup() diff --git a/test/engine.test.ts b/test/engine.test.ts index b3c2216..50fbb16 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -396,3 +396,30 @@ describe('visualizer tap (spec 10 §3.6)', () => { await Promise.all([quiet.engine.aclose(), watched.engine.aclose()]) }) }) + +// spec 12 §3.4: the listener's mute is the master bus gain — instant, mid-word, +// bus-wide (voice + music + bed). The program never notices: clips keep +// rolling, only the output is silent, and unmute picks up mid-sentence. +describe('master mute (spec 12)', () => { + it('setMuted(true) silences voice and music together', async () => { + const { context, engine } = build(2, dcChunks(0.5, 2)) + const handle = await engine.playMusic(MUSIC) + await handle.waitStarted(1) + const played = engine.play(voiceClip) + await settle() + engine.setMuted(true) + const rendered = await context.startRendering() + await played + expect(level(rendered, 0.3, 1.9)).toBeLessThan(0.01) // everything silent post-ramp + }) + + it('setMuted(false) restores the full mix', async () => { + const { context, engine } = build(2, dcChunks(0.5, 2)) + const handle = await engine.playMusic(MUSIC) + await handle.waitStarted(1) + engine.setMuted(true) + engine.setMuted(false) + const rendered = await context.startRendering() + expect(level(rendered, 1.0, 1.9)).toBeCloseTo(0.5, 1) // music back at full + }) +}) diff --git a/test/fakes.ts b/test/fakes.ts index d781899..6a9c9b0 100644 --- a/test/fakes.ts +++ b/test/fakes.ts @@ -22,10 +22,17 @@ import type { TrackSource, Turn, } from '../src/contracts.ts' +import type { DirectorSettings } from '../src/director.ts' import type { Host } from '../src/host.ts' import type { ProgramState } from '../src/ipc.ts' import { LineQueue } from '../src/host.ts' +// The Director's live-settings thunk (spec 12 §3.2), test defaults. Mutate the +// returned object to exercise hot application. +export function directorSettings(over: Partial = {}): DirectorSettings { + return { gapSeconds: 0, recentWindow: 12, anchorsEnabled: true, musicEnabled: true, ...over } +} + // Stands in for the model driving an agentic task: `play` is handed the task's // tools and calls them the way the model would. Whatever a tool passes to // `finish` is what runTask returns — so the termination rule is exercised for @@ -265,6 +272,9 @@ export class FakeHost implements Host { debugs: string[] = [] states: ProgramState[] = [] banners: { personaFirstLine: string; brain: string; voice: string }[] = [] + // Assign in a test to model a front-end with a settings pane (spec 12 §3.6); + // left undefined, the host is the plain one and the Director degrades to info. + showSettings?: () => void start(): void {} diff --git a/test/ipc-host.test.ts b/test/ipc-host.test.ts index 144e762..1bb941b 100644 --- a/test/ipc-host.test.ts +++ b/test/ipc-host.test.ts @@ -7,7 +7,15 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { IpcHost } from '../src/ipc-host.ts' -import { PROTOCOL, decodeEngineMessage, encode, ndjson, type EngineMessage } from '../src/ipc.ts' +import { + PROTOCOL, + decodeEngineMessage, + encode, + ndjson, + type EngineMessage, + type Settings, + type SettingsPatch, +} from '../src/ipc.ts' import { STATUS_MICROCOPY } from '../src/prompts.ts' // A stand-in TUI: the fast layer proves the bridge, never a rendered frame. @@ -51,6 +59,10 @@ class FakeClient { this.send(encode({ v: 1, type: 'vizSub', on, ...(fps !== undefined && { fps }) })) } + set(patch: SettingsPatch): void { + this.send(encode({ v: 1, type: 'settingsSet', patch })) + } + close(): void { this.socket.destroy() } @@ -310,6 +322,108 @@ describe('IpcHost (spec 10 §2.1/§2.3)', () => { }) }) + // spec 12 §2.5: the settings bridge. The host routes; the store (wired by the + // app) is the single authority — a successful set is broadcast by the store's + // own change event, so the host answers only what would otherwise go silent. + describe('the settings bridge (spec 12)', () => { + const VALUES: Settings = { + anchorsEnabled: true, + musicEnabled: true, + cadenceMode: 'every_n', + musicEveryN: 2, + gapSeconds: 2, + recentWindow: 12, + muted: false, + tuiPet: true, + } + + function wire(applyOk = true): SettingsPatch[] { + const applied: SettingsPatch[] = [] + host.setSettings({ + snapshot: () => ({ values: VALUES, home: '/tmp/m', voiceConfigured: true, musicAvailable: true }), + apply: (patch) => { + applied.push(patch) + return applyOk + }, + }) + return applied + } + + it('sends a snapshot after hello on every attach', async () => { + wire() + const c = await client() + c.attach() + await c.settle() + expect(c.types()).toEqual(['hello', 'settings']) + expect(c.received[1]).toMatchObject({ type: 'settings', home: '/tmp/m', values: VALUES }) + }) + + it('routes a set to the store; a rejected patch is answered with truth', async () => { + const applied = wire(false) + const c = await client() + c.attach() + await c.settle() + c.set({ musicEnabled: false }) + await c.settle() + expect(applied).toEqual([{ musicEnabled: false }]) + // The rejection's only reply is a fresh (unchanged) snapshot. + expect(c.types().filter((t) => t === 'settings')).toHaveLength(2) + }) + + it('leaves the broadcast of a successful set to the store change event', async () => { + const applied = wire(true) + const c = await client() + c.attach() + await c.settle() + c.set({ gapSeconds: 4 }) + await c.settle() + expect(applied).toEqual([{ gapSeconds: 4 }]) + expect(c.types().filter((t) => t === 'settings')).toHaveLength(1) // the attach one + host.sendSettings() // what the app's onChange wiring performs + await c.settle() + expect(c.types().filter((t) => t === 'settings')).toHaveLength(2) + }) + + it('showSettings answers /settings with an open-flagged snapshot', async () => { + wire() + const c = await client() + c.attach() + await c.settle() + host.showSettings() + await c.settle() + expect(c.received.at(-1)).toMatchObject({ type: 'settings', open: true }) + }) + + it('snapshots never enter the replay backlog', async () => { + wire() + host.info('a notice') + host.sendSettings() + host.sendSettings() + const c = await client() + c.attach() + await c.settle() + // One snapshot — the attach one — not the two stale broadcasts. + expect(c.types()).toEqual(['hello', 'info', 'settings']) + }) + + it('a set before the bridge is wired is dropped without a crash', async () => { + const c = await client() + c.attach() + await c.settle() + c.set({ tuiPet: false }) + await c.settle() + expect(c.types()).toEqual(['hello']) + }) + + it('ignores a set from a client that never attached', async () => { + const applied = wire() + const c = await client() + c.set({ musicEnabled: false }) + await c.settle() + expect(applied).toEqual([]) + }) + }) + it('says bye and leaves no socket file behind on close', async () => { const c = await client() c.attach() diff --git a/test/ipc.test.ts b/test/ipc.test.ts index 8d92f18..4cdcc6a 100644 --- a/test/ipc.test.ts +++ b/test/ipc.test.ts @@ -22,6 +22,41 @@ const ENGINE_MESSAGES: EngineMessage[] = [ { v: 1, type: 'state', state: { kind: 'talk' }, microcopy: 'on the air' }, { v: 1, type: 'info', text: 'now playing: a song' }, { v: 1, type: 'viz', bins: [0, 0.5, 1] }, + { + v: 1, + type: 'settings', + values: { + anchorsEnabled: true, + musicEnabled: false, + cadenceMode: 'every_n', + musicEveryN: 2, + gapSeconds: 2, + recentWindow: 12, + muted: true, + tuiPet: true, + }, + home: '/home/someone/.murmur', + voiceConfigured: true, + musicAvailable: true, + }, + { + v: 1, + type: 'settings', + values: { + anchorsEnabled: true, + musicEnabled: true, + cadenceMode: 'random', + musicEveryN: 4, + gapSeconds: 0, + recentWindow: 4, + muted: false, + tuiPet: false, + }, + home: '/tmp/m', + voiceConfigured: false, + musicAvailable: false, + open: true, + }, { v: 1, type: 'bye' }, ] @@ -30,6 +65,9 @@ const TUI_MESSAGES: TuiMessage[] = [ { v: 1, type: 'line', text: '/quit' }, { v: 1, type: 'vizSub', on: true, fps: 24 }, { v: 1, type: 'vizSub', on: false }, + { v: 1, type: 'settingsSet', patch: { musicEnabled: false, gapSeconds: 3.5 } }, + { v: 1, type: 'settingsSet', patch: { muted: true } }, + { v: 1, type: 'settingsSet', patch: { muted: false } }, ] describe('the wire protocol (spec 10 §2.3)', () => { @@ -66,6 +104,15 @@ describe('the wire protocol (spec 10 §2.3)', () => { expect(decodeEngineMessage(JSON.stringify({ v: 2, type: 'bye' }))).toBeNull() }) + it('a settings patch with an illegal value is a malformed message (spec 12 §2.5)', () => { + expect( + decodeTuiMessage(JSON.stringify({ v: 1, type: 'settingsSet', patch: { gapSeconds: -1 } })), + ).toBeNull() + expect( + decodeTuiMessage(JSON.stringify({ v: 1, type: 'settingsSet', patch: { muted: 'yes' } })), + ).toBeNull() + }) + it('does not confuse the two directions', () => { expect(decodeEngineMessage(encode({ v: 1, type: 'line', text: 'hi' }).trimEnd())).toBeNull() expect(decodeTuiMessage(encode({ v: 1, type: 'bye' }).trimEnd())).toBeNull() diff --git a/test/paths.test.ts b/test/paths.test.ts index 3e35680..3616dbf 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { cacheRoot, claudeCodeRoot, dataRoot, homeRoot, tuiSocketPath } from '../src/paths.ts' +import { cacheRoot, claudeCodeRoot, dataRoot, homeRoot, settingsPath, tuiSocketPath } from '../src/paths.ts' describe('paths', () => { it('defaults to ~/.murmur with data/ and cache/ beneath', () => { @@ -54,3 +54,11 @@ describe('the TUI socket', () => { expect(tuiSocketPath({}).length).toBeLessThan(100) }) }) + +// spec 12 §2.1: the listener's knobs sit beside voice.json at the home root. +describe('the settings file', () => { + it('lives at the home root and moves with MURMUR_HOME', () => { + expect(settingsPath({ MURMUR_HOME: '/tmp/mh' })).toBe('/tmp/mh/settings.json') + expect(settingsPath({}).endsWith('/.murmur/settings.json')).toBe(true) + }) +}) diff --git a/test/settings.test.ts b/test/settings.test.ts new file mode 100644 index 0000000..f301462 --- /dev/null +++ b/test/settings.test.ts @@ -0,0 +1,134 @@ +// The settings layer (spec 12 §2.1/§2.4): per-key salvage on read, atomic +// write, and the engine-owned store that is the single mutation path. + +import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import type { Settings } from '../src/ipc.ts' +import { readSettingsFile, SettingsStore, writeSettingsFile } from '../src/settings.ts' + +const BASE: Settings = { + anchorsEnabled: true, + musicEnabled: true, + cadenceMode: 'every_n', + musicEveryN: 2, + gapSeconds: 2, + recentWindow: 12, + muted: false, + tuiPet: true, +} + +const home = () => mkdtempSync(join(tmpdir(), 'murmur-settings-')) +const fileIn = (dir: string) => join(dir, 'settings.json') + +function store(over: { + path?: string + initial?: Partial + touched?: Partial + log?: (message: string) => void +}) { + return new SettingsStore({ + path: over.path ?? fileIn(home()), + initial: { ...BASE, ...over.initial }, + touched: over.touched ?? {}, + ...(over.log !== undefined && { log: over.log }), + }) +} + +describe('the settings file (spec 12 §2.1)', () => { + it('reads back what it wrote, atomically (no temp file left)', () => { + const path = fileIn(home()) + writeSettingsFile(path, { musicEnabled: false, gapSeconds: 3.5 }) + expect(readSettingsFile(path)).toEqual({ musicEnabled: false, gapSeconds: 3.5 }) + const names = readdirSync(join(path, '..')) + expect(names).toEqual(['settings.json']) + }) + + it('a missing file reads as empty', () => { + expect(readSettingsFile(fileIn(home()))).toEqual({}) + }) + + it('an unparseable file reads as empty, with one log line', () => { + const path = fileIn(home()) + writeFileSync(path, '{not json') + const logged: string[] = [] + expect(readSettingsFile(path, (m) => logged.push(m))).toEqual({}) + expect(logged.length).toBe(1) + }) + + it('salvages per key: a broken key is dropped alone, siblings survive', () => { + const path = fileIn(home()) + writeFileSync( + path, + JSON.stringify({ + gapSeconds: -5, // broken (negative) + recentWindow: 'many', // broken (not a number) + muted: true, // fine + musicEveryN: 3, // fine + somethingElse: true, // unknown -> dropped silently + }), + ) + const logged: string[] = [] + expect(readSettingsFile(path, (m) => logged.push(m))).toEqual({ + muted: true, + musicEveryN: 3, + }) + expect(logged.length).toBe(2) // one line per broken key + }) + + it('creates the parent directory at the write site', () => { + const path = join(home(), 'deeper', 'settings.json') + writeSettingsFile(path, { tuiPet: false }) + expect(readSettingsFile(path)).toEqual({ tuiPet: false }) + }) +}) + +describe('SettingsStore (spec 12 §2.4)', () => { + it('starts from the merged config, not the file', () => { + const s = store({ initial: { gapSeconds: 7 }, touched: { gapSeconds: 3 } }) + expect(s.current().gapSeconds).toBe(7) // a flag beat the file at boot + }) + + it('set applies live, persists, and notifies — in that order', () => { + const path = fileIn(home()) + const seen: Settings[] = [] + const s = store({ path }) + s.onChange((next) => seen.push(next)) + expect(s.set({ musicEnabled: false })).toBe(true) + expect(s.current().musicEnabled).toBe(false) + expect(readSettingsFile(path)).toEqual({ musicEnabled: false }) + expect(seen.length).toBe(1) + expect(seen[0]!.musicEnabled).toBe(false) + }) + + it('an unrelated set never rewrites keys the user has not touched', () => { + const path = fileIn(home()) + const s = store({ path, touched: { gapSeconds: 3 } }) + s.set({ tuiPet: false }) + expect(readSettingsFile(path)).toEqual({ gapSeconds: 3, tuiPet: false }) + }) + + it('mute is a plain persisted boolean, both ways', () => { + const path = fileIn(home()) + const s = store({ path }) + s.set({ muted: true }) + expect(s.current().muted).toBe(true) + expect(readSettingsFile(path)).toEqual({ muted: true }) + s.set({ muted: false }) + expect(s.current().muted).toBe(false) + expect(readSettingsFile(path)).toEqual({ muted: false }) + }) + + it('an invalid patch is a no-op reported false', () => { + const path = fileIn(home()) + const s = store({ path }) + const before = s.current() + expect(s.set({ gapSeconds: -1 })).toBe(false) + expect(s.set({})).toBe(false) + expect(s.current()).toEqual(before) + expect(existsSync(path)).toBe(false) // nothing was ever written + }) +}) diff --git a/test/tui-pet.test.ts b/test/tui-pet.test.ts index b9a55aa..303b3af 100644 --- a/test/tui-pet.test.ts +++ b/test/tui-pet.test.ts @@ -151,6 +151,20 @@ describe('bandLayout (spec 10 §3.3: the alive band, pet optional)', () => { expect(bandLayout({ MURMUR_TUI_PET: 'maybe' }).pet).toBe(true) expect(bandLayout({ MURMUR_TUI_PET: '' }).pet).toBe(true) }) + + // spec 12 §3.7: the knob lives in the settings layer now; the env survives as + // the client-local final override — set env beats setting, unset env defers. + it('defers to the live tuiPet setting when the env says nothing', () => { + expect(bandLayout({}, false).pet).toBe(false) + expect(bandLayout({}, false).vizPadLeft).toBe(0) + expect(bandLayout({}, true).pet).toBe(true) + expect(bandLayout({ MURMUR_TUI_PET: '' }, false).pet).toBe(false) + }) + + it('an explicitly set env still overrides the setting, both ways', () => { + expect(bandLayout({ MURMUR_TUI_PET: '0' }, true).pet).toBe(false) + expect(bandLayout({ MURMUR_TUI_PET: '1' }, false).pet).toBe(true) + }) }) describe('awayGreeting (spec 10 §3.7.3: alive across absence)', () => { diff --git a/test/tui-settings.test.ts b/test/tui-settings.test.ts new file mode 100644 index 0000000..3e2d0b5 --- /dev/null +++ b/test/tui-settings.test.ts @@ -0,0 +1,135 @@ +// The settings pane's pure logic (spec 12 §3.5/§3.6): exactly seven items, +// intent labels only, gear presets with an honest custom position, steppers +// with pane-enforced ranges. Rendering stays untested (spec 10 §3.9); this is +// the state machine the client renders. + +import { describe, expect, it } from 'vitest' + +import type { Settings, SettingsSnapshot } from '../src/ipc.ts' +import { adjust, gearOf, paneFacts, paneItems } from '../tui/src/settings-pane.ts' + +const VALUES: Settings = { + anchorsEnabled: true, + musicEnabled: true, + cadenceMode: 'every_n', + musicEveryN: 2, + gapSeconds: 2, + recentWindow: 12, + muted: false, + tuiPet: true, +} + +const snap = ( + values: Partial = {}, + over: Partial> = {}, +): SettingsSnapshot => ({ + values: { ...VALUES, ...values }, + home: '/home/me/.murmur', + voiceConfigured: true, + musicAvailable: true, + ...over, +}) + +describe('paneItems', () => { + it('lists exactly the seven writable intents, in a fixed order', () => { + const keys = paneItems(snap()).map((item) => item.key) + expect(keys).toEqual(['anchors', 'music', 'gear', 'gap', 'voice', 'pet', 'window']) + }) + + it('never leaks a field name or mode name into a label or value', () => { + const rendered = paneItems(snap({ cadenceMode: 'random' })) + .flatMap((item) => [item.label, item.value]) + .join(' ') + for (const leak of ['every_n', 'random', 'brain', 'cadenceMode', 'musicEveryN', 'recentWindow', 'gapSeconds', 'anchorsEnabled', 'tuiPet']) { + expect(rendered).not.toContain(leak) + } + }) + + it('the sound toggle never greys — the mute is the output gain, not an endpoint feature', () => { + const sound = (s: SettingsSnapshot) => paneItems(s).find((i) => i.key === 'voice')! + expect(sound(snap()).enabled).toBe(true) + expect(sound(snap({}, { voiceConfigured: false })).enabled).toBe(true) + expect(sound(snap({ muted: true })).value).toBe('muted') + }) + + it('greys music when unavailable, and the gear whenever music is off or unavailable', () => { + const of = (s: SettingsSnapshot, key: string) => paneItems(s).find((i) => i.key === key)! + expect(of(snap({}, { musicAvailable: false }), 'music').enabled).toBe(false) + expect(of(snap({}, { musicAvailable: false }), 'gear').enabled).toBe(false) + expect(of(snap({ musicEnabled: false }), 'gear').enabled).toBe(false) + expect(of(snap(), 'gear').enabled).toBe(true) + }) + + it('puts only the memory span in the advanced group', () => { + const advanced = paneItems(snap()).filter((i) => i.advanced) + expect(advanced.map((i) => i.key)).toEqual(['window']) + }) +}) + +describe('gearOf', () => { + it('names the three presets and everything else custom', () => { + expect(gearOf({ ...VALUES, musicEveryN: 1 })).toBe('more music') + expect(gearOf({ ...VALUES, musicEveryN: 2 })).toBe('balanced') + expect(gearOf({ ...VALUES, musicEveryN: 4 })).toBe('more talk') + expect(gearOf({ ...VALUES, musicEveryN: 7 })).toBe('custom') + expect(gearOf({ ...VALUES, cadenceMode: 'random' })).toBe('custom') + expect(gearOf({ ...VALUES, cadenceMode: 'brain' })).toBe('custom') + }) +}) + +describe('adjust', () => { + it('flips the toggles', () => { + expect(adjust(snap(), 'anchors', 1)).toEqual({ anchorsEnabled: false }) + expect(adjust(snap({ anchorsEnabled: false }), 'anchors', 1)).toEqual({ anchorsEnabled: true }) + expect(adjust(snap(), 'music', 1)).toEqual({ musicEnabled: false }) + expect(adjust(snap(), 'pet', 1)).toEqual({ tuiPet: false }) + }) + + it('the sound toggle flips the mute boolean', () => { + expect(adjust(snap(), 'voice', 1)).toEqual({ muted: true }) + expect(adjust(snap({ muted: true }), 'voice', 1)).toEqual({ muted: false }) + }) + + it('walks the gear presets without wrapping, writing both underlying knobs', () => { + expect(adjust(snap(), 'gear', -1)).toEqual({ cadenceMode: 'every_n', musicEveryN: 1 }) + expect(adjust(snap(), 'gear', 1)).toEqual({ cadenceMode: 'every_n', musicEveryN: 4 }) + expect(adjust(snap({ musicEveryN: 1 }), 'gear', -1)).toBeNull() // already at the end + expect(adjust(snap({ musicEveryN: 4 }), 'gear', 1)).toBeNull() + }) + + it('any gear press from custom lands on balanced (selecting overwrites)', () => { + expect(adjust(snap({ cadenceMode: 'random' }), 'gear', 1)).toEqual({ + cadenceMode: 'every_n', + musicEveryN: 2, + }) + }) + + it('steps the numbers within the pane ranges and stops at the bounds', () => { + expect(adjust(snap(), 'gap', 1)).toEqual({ gapSeconds: 2.5 }) + expect(adjust(snap({ gapSeconds: 0 }), 'gap', -1)).toBeNull() + expect(adjust(snap({ gapSeconds: 10 }), 'gap', 1)).toBeNull() + expect(adjust(snap(), 'window', 1)).toEqual({ recentWindow: 14 }) + expect(adjust(snap({ recentWindow: 4 }), 'window', -1)).toBeNull() + expect(adjust(snap({ recentWindow: 48 }), 'window', 1)).toBeNull() + }) + + it('clamps an off-grid hand-edited value back onto the range', () => { + expect(adjust(snap({ gapSeconds: 9.8 }), 'gap', 1)).toEqual({ gapSeconds: 10 }) + expect(adjust(snap({ recentWindow: 47 }), 'window', 1)).toEqual({ recentWindow: 48 }) + }) + + it('a greyed item adjusts to nothing', () => { + expect(adjust(snap({ musicEnabled: false }), 'gear', 1)).toBeNull() + expect(adjust(snap({}, { musicAvailable: false }), 'music', 1)).toBeNull() + }) +}) + +describe('paneFacts', () => { + it('shows the home and the endpoint status — a fact, never the endpoint', () => { + const facts = paneFacts(snap()).map((f) => `${f.label}: ${f.value}`) + expect(facts.some((f) => f.includes('/home/me/.murmur'))).toBe(true) + expect(facts.join(' ')).toContain('configured') + const bare = paneFacts(snap({}, { voiceConfigured: false })) + expect(bare.map((f) => f.value).join(' ')).toContain('not configured') + }) +}) diff --git a/tui/src/app.tsx b/tui/src/app.tsx index dc8e57f..62da83a 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -11,9 +11,10 @@ import { useEffect, useRef, useState } from 'react' import { useKeyboard, type InputProps } from '@opentui/react' import type { InputRenderable } from '@opentui/core' -import type { EngineMessage, ProgramState } from '../../src/ipc.ts' +import type { EngineMessage, ProgramState, SettingsSnapshot } from '../../src/ipc.ts' import { Bars, render } from './bars.ts' import { accentFor, INK, mix, type Accent } from './palette.ts' +import { adjust, paneFacts, paneItems } from './settings-pane.ts' import { awayGreeting, bandLayout, @@ -39,9 +40,9 @@ const POSES = loadPoses() // shown: the band is the bars' room, and it must not resize under a knob. const BAND_ROWS = POSES.idle[0]!.length / 2 -// Whether the creature is part of that band at all, and what the bars get when -// it is not (§3.3, issue #95). Read once: it is an env knob, not a live setting. -const BAND = bandLayout() +// Whether the creature is part of that band is a live setting now (spec 12 +// §3.7), so its layout is computed per render inside App — the env override +// stays, resolved in bandLayout itself. // Half-block: the upper pixel is the ink, the lower is the ground behind it. const HALF = '▀' @@ -135,6 +136,14 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): const [state, setState] = useState(null) // The DJ's line for the strip, authored engine-side (§3.7.4). const [microcopy, setMicrocopy] = useState(null) + // The settings pane (spec 12 §3.6). The snapshot is engine truth; the pane + // renders it and sends patches, never local optimism. Mirrored into a ref so + // the keyboard handler always sees the current pane, not a stale closure. + const [settings, setSettings] = useState(null) + const [paneOpen, setPaneOpen] = useState(false) + const [paneAt, setPaneAt] = useState(0) + const pane = useRef({ open: false, at: 0, snap: null as SettingsSnapshot | null }) + pane.current = { open: paneOpen, at: paneAt, snap: settings } // The absence the pet greets (§3.7.3). It stands until the program itself has // something to say, so the welcome is never cut short by a timer. const [greeting, setGreeting] = useState(null) @@ -165,6 +174,17 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): setMicrocopy(message.microcopy ?? null) setGreeting(null) break + case 'settings': + setSettings({ + values: message.values, + home: message.home, + voiceConfigured: message.voiceConfigured, + musicAvailable: message.musicAvailable, + }) + // Only the snapshot answering a typed /settings opens the pane; a + // broadcast refresh just keeps an open one true. + if (message.open === true) setPaneOpen(true) + break case 'viz': vizSink.current?.(message.bins) break @@ -185,9 +205,22 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): }, [wire]) // One shutdown path (§3.4): Ctrl-C is a /quit typed for you, so the engine - // and the voice go down in order instead of the face dying alone. + // and the voice go down in order instead of the face dying alone. While the + // settings pane is open, keys route to it (spec 12 §3.6 — the sanctioned + // exception to the input line's permanent focus): Esc returns, arrows move, + // space/enter/arrows adjust; every change goes over the wire immediately. useKeyboard((key) => { - if (key.ctrl && key.name === 'c') wire.line('/quit') + if (key.ctrl && key.name === 'c') return wire.line('/quit') + const { open, at, snap } = pane.current + if (!open || snap === null) return + if (key.name === 'escape') return setPaneOpen(false) + const items = paneItems(snap) + if (key.name === 'up') return setPaneAt(Math.max(0, at - 1)) + if (key.name === 'down') return setPaneAt(Math.min(items.length - 1, at + 1)) + if (['left', 'right', 'space', 'return'].includes(key.name)) { + const patch = adjust(snap, items[at]!.key, key.name === 'left' ? -1 : 1) + if (patch !== null) wire.send({ v: 1, type: 'settingsSet', patch }) + } }) const submit = (text: string): void => { @@ -201,6 +234,10 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): const strip = [greeting ?? microcopy ?? 'warming up...', state?.nowPlaying] .filter((part) => part !== undefined && part !== '') .join(' ♪ ') + // The alive band's composition follows the live pet setting (spec 12 §3.7), + // with the env override resolved inside bandLayout. + const band = bandLayout(process.env, settings?.values.tuiPet) + const items = paneOpen && settings !== null ? paneItems(settings) : null return ( @@ -219,32 +256,61 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): - - {entries.map((entry) => ( - - {MARKER[entry.kind]} - {entry.text} - - ))} - + {items !== null && settings !== null ? ( + + settings + + {items.map((item, index) => ( + + {item.advanced && items.findIndex((i) => i.advanced) === index && ( + {' ── advanced ──'} + )} + + {`${index === paneAt ? '▸ ' : ' '}${item.label.padEnd(24)}${item.value}`} + + + ))} + + {paneFacts(settings).map((fact) => ( + + {` ${fact.label.padEnd(24)}${fact.value}`} + + ))} + + {' ↑↓ move · ←→/space adjust · esc back'} + + ) : ( + + {entries.map((entry) => ( + + {MARKER[entry.kind]} + {entry.text} + + ))} + + )} - {BAND.pet && } - + {band.pet && } + @@ -265,8 +331,8 @@ export function App({ subscribe, wire }: { subscribe: Subscribe; wire: Wire }): {'> '} gear.musicEveryN === values.musicEveryN)?.name ?? 'custom' +} + +const GAP = { min: 0, max: 10, step: 0.5 } +const WINDOW = { min: 4, max: 48, step: 2 } + +export type PaneItemKey = 'anchors' | 'music' | 'gear' | 'gap' | 'voice' | 'pet' | 'window' + +export type PaneItem = { + key: PaneItemKey + label: string + value: string + enabled: boolean + advanced: boolean +} + +const onOff = (on: boolean): string => (on ? 'on' : 'off') + +// Exactly seven, in a fixed order (spec 12 §1 — the ceiling, not a tranche). +export function paneItems(snap: SettingsSnapshot): PaneItem[] { + const v = snap.values + const musicOn = snap.musicAvailable && v.musicEnabled + return [ + { key: 'anchors', label: 'morning & night moments', value: onOff(v.anchorsEnabled), enabled: true, advanced: false }, + { key: 'music', label: 'music', value: onOff(v.musicEnabled), enabled: snap.musicAvailable, advanced: false }, + { key: 'gear', label: 'the mix', value: gearOf(v), enabled: musicOn, advanced: false }, + { key: 'gap', label: 'breathing room', value: `${v.gapSeconds.toFixed(1)}s`, enabled: true, advanced: false }, + // The mute works on any run with a speaker — it is the output gain, not a + // voice-endpoint feature, so it never greys. The endpoint fact below still + // explains "sound on but nobody speaks". + { key: 'voice', label: 'sound', value: v.muted ? 'muted' : 'on', enabled: true, advanced: false }, + { key: 'pet', label: 'pixel pet', value: onOff(v.tuiPet), enabled: true, advanced: false }, + { key: 'window', label: 'memory span', value: String(v.recentWindow), enabled: true, advanced: true }, + ] +} + +// The read-only lines under the advanced divider (spec 12 §1): facts, never +// secrets — the endpoint's existence, not the endpoint. +export function paneFacts(snap: SettingsSnapshot): { label: string; value: string }[] { + return [ + { label: 'stored at', value: snap.home }, + { label: 'voice endpoint', value: snap.voiceConfigured ? 'configured' : 'not configured' }, + ] +} + +function stepped( + current: number, + dir: -1 | 1, + range: { min: number; max: number; step: number }, +): number | null { + const next = Math.min(range.max, Math.max(range.min, current + dir * range.step)) + return next === current ? null : next +} + +// One keypress on one item -> the patch to send, or null when there is nothing +// to do (a greyed item, a stepper at its bound). Toggles flip on any direction; +// space/enter callers pass dir 1. +export function adjust(snap: SettingsSnapshot, key: PaneItemKey, dir: -1 | 1): SettingsPatch | null { + const item = paneItems(snap).find((entry) => entry.key === key) + if (item === undefined || !item.enabled) return null + const v = snap.values + switch (key) { + case 'anchors': + return { anchorsEnabled: !v.anchorsEnabled } + case 'music': + return { musicEnabled: !v.musicEnabled } + case 'pet': + return { tuiPet: !v.tuiPet } + case 'voice': + return { muted: !v.muted } + case 'gear': { + const current = GEARS.findIndex((gear) => gear.name === gearOf(v)) + // From custom, any press lands on balanced: selecting a gear overwrites. + const next = current === -1 ? 1 : current + dir + const gear = GEARS[next] + if (gear === undefined || next === current) return null + return { cadenceMode: 'every_n', musicEveryN: gear.musicEveryN } + } + case 'gap': { + const next = stepped(v.gapSeconds, dir, GAP) + return next === null ? null : { gapSeconds: next } + } + case 'window': { + const next = stepped(v.recentWindow, dir, WINDOW) + return next === null ? null : { recentWindow: next } + } + } +}