live: heartbeat sync 2026-10-19T17:29:41-07:00 (Refs #11)

This commit is contained in:
Frank
2026-10-19 17:29:41 -07:00
parent e22acf4913
commit 802014fea9
6 changed files with 123 additions and 52 deletions
@@ -1,56 +1,58 @@
---
name: dankvoice-bundle-swap-red-proof
description: "Prove a dankvoice behavior test is red-before/green-after by pointing DV_EXT at the previously committed dist bundle, with a pinned scratch fixture dir."
description: "Prove a dankvoice behavior test is red against the pre-change bundle by pointing DV_EXT at git show HEAD:dist/index.js, with the driver extracted from the bats heredoc into a pinned scratch directory. Use before claiming a new bats assertion is red-capable."
---
Use when a new black-box `tests/behavior.bats` assertion in dankvoice must be shown to be red-capable (fails on the pre-change code) without `git stash`/worktree juggling, or when the old bundle must be compared to the new one.
Prove a new `tests/behavior.bats` assertion is **red-capable** — that it fails against the *previous* build, not just passes against the new one — without stashing, branching, or editing `src/`.
## Why
The bats suite drives `dist/index.js` only (nothing imports `src/`), and the bundle is self-contained. So the *previous commit's* behavior is one `git show` away: write it to a file, point `DV_EXT` at it, run the same driver, and read the same `PLAYS n` / `CALL` / `NOTIFY` lines the test asserts.
## Procedure
1. Build the new bundle and export the old one:
```bash
cd ~/Projects/<dankvoice-checkout>
bun run build >/dev/null 2>&1
git show HEAD:dist/index.js > /tmp/old-dist.js
```
2. Stand up the fixtures in a **pinned** scratch dir — never `$(ls -dt /tmp/tmp.* | head -1)`: other live sessions on the box create `tmp.*` dirs continuously, and a glob picks *theirs*, which is how a red-proof silently becomes "Module not found" and then a false `PLAYS 0`.
```bash
W=/tmp/dv-redproof; rm -rf "$W"; mkdir -p "$W/bin"
```
3. Copy the **real** player stub, not a simplification. The driver counts plays from the stub log; a stub that only `sleep`s records nothing and reports `PLAYS 0` for every build, red and green alike. Mirror `setup()` in `tests/behavior.bats`:
```bash
cat > "$W/player.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$DV_STUB_LOG"
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
sleep "${DV_HOLD:-0.1}"
SH
chmod +x "$W/player.sh"
ln -s "$W/player.sh" "$W/bin/pw-play" # PATH fallback player
printf '#!/usr/bin/env bash\nprintf "116\\talsa_output.usb-KT.0\\taudio/sink\\t*\\n"\n' > "$W/bin/wpctl"
chmod +x "$W/bin/wpctl"
sed -n '86,408p' tests/behavior.bats > "$W/driver.ts" # the inline heredoc driver
```
(Re-check those line bounds against the current file; the driver lives inside `setup()`.)
4. Export the env the driver expects and run old vs new against the SAME scenario:
```bash
export DV_STUB="$W/player.sh" DV_STUB_LOG="$W/player.log" DV_COPY="$W/played.wav" \
DV_OMPCONFIG=/dev/null DANKVOICE_CONFIG="$W/settings.json" DV_MODELS="$W/models.yml" DV_WPCTL=x
for ext in /tmp/old-dist.js "$PWD/dist/index.js"; do
rm -f "$W/player.log"
DV_EXT="$ext" DV_PLAYER_BIN="$W/no-such-player" bun "$W/driver.ts" speak | grep -E '^PLAYS|^NOTIFY'
done
```
The driver sets its own fixture server URL and prepends `dirname "$DV_STUB")/bin` to `PATH` (see its `DANKVOICE_*` block), so the discovered-player fallback has somewhere real to land.
5. Add a **control**: same scenario with a valid player path. Old-vs-new differences that the control also shows are harness bugs, not behavior changes.
`tests/behavior.bats` is a black-box suite: the plugin bundle is loaded through `DV_EXT`, so a *different bundle* is the red side. Stashing the fix would break the `dist` byte-identity pin test and the shared worktree.
## Working recipe
```bash
cd ~/Projects/dankvoice # or the feature worktree: ~/Projects/wt-dv-*
bun run build # current src = the green side
git show HEAD:dist/index.js > /tmp/old-dist.js # the red side (pre-change bundle)
W=/tmp/dv-redproof # PINNED name; see traps below
rm -rf "$W" && mkdir -p "$W/bin"
cat > "$W/player.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$DV_STUB_LOG"
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
sleep "${DV_HOLD:-0.1}"
SH
chmod +x "$W/player.sh"
ln -s "$W/player.sh" "$W/bin/pw-play"
printf '#!/usr/bin/env bash\nprintf "116\\talsa_output.usb-KT.0\\taudio/sink\\t*\\n"\n' > "$W/bin/wpctl"
chmod +x "$W/bin/wpctl"
# The driver lives inside the bats file as a heredoc: extract it verbatim.
end=$(grep -n '^TS$' tests/behavior.bats | cut -d: -f1)
sed -n "86,$((end - 1))p" tests/behavior.bats > "$W/driver.ts"
export DV_EXT="$PWD/dist/index.js" DV_STUB="$W/player.sh" DV_STUB_LOG="$W/player.log" \
DV_COPY="$W/played.wav" DV_OMPCONFIG=/dev/null DANKVOICE_CONFIG="$W/settings.json" \
DV_MODELS="$W/models.yml" DV_WPCTL=x DV_SELECT=""
for ext in /tmp/old-dist.js "$PWD/dist/index.js"; do
rm -f "$W/player.log" "$DANKVOICE_CONFIG"
DV_EXT="$ext" bun "$W/driver.ts" <scenario> 2>&1 | grep -E '^PLAYS|^CALLS|^NOTIFY|^SETTINGS'
done
```
The scenario name is the driver's third argv (`speak`, `auto`, `repeat`, `humanizecycle`, `badurl`, …); the bats `-f` test body's env (`DV_PLAYER_BIN`, `DV_SETTINGS_JSON`, `DANKVOICE_VERBOSITY`, …) is passed the same way. `DV_SELECT` holds the menu answers, `|`-separated, consumed in order.
## Traps, each one cost a turn
- **The scratch directory must be pinned by name.** `ls -dt /tmp/tmp.*` picks up *other sessions'* temp dirs, and then `player.sh` and `driver.ts` live in different directories — the run silently does the wrong thing.
- **The stub must append to `$DV_STUB_LOG`.** The driver counts player invocations from that file; a stub that only `sleep`s reports `PLAYS 0` for *both* bundles and the whole proof reads as "fix does nothing".
- **`DV_EXT` is required.** Missing it yields `Cannot find package 'undefined'` from the driver, which looks like a broken driver, not a missing env var.
- **`DV_SETTINGS_JSON` supports `__PORT__`** (the driver substitutes the fixture port), so a preseed can name a prefixed route — needed whenever the test is about *which* endpoint got resolved.
- **Never run this while a `bats` run is in flight** in the same repo: `setup()` runs `bun run build`, so two runs clobber `dist/` and the pin test.
## Reading the result
- old `PLAYS 0` + new `PLAYS 1` + control `PLAYS 1` → the assertion is red-capable and the fix is real.
- identical results old vs new → the harness is lying (stub too thin, wrong scratch dir, or the scenario never reaches the changed path). Fix the harness before believing any green.
- File-mode assertions: pre-seed the file with the *old* mode (`printf … > file; chmod 644 file`) — a fresh write with `mode:` in `writeFileSync` only applies at creation, so a fresh file can look green while the old code is equally green.
## Gotchas
- Never poll the bats suite while doing this; `setup()` runs `bun run build`, so a concurrent `src/` edit gets compiled into someone else's run (see `dankvoice-bats-serialization`).
- `bun run build` does not typecheck; a stale identifier reference survives bundling and only fails at runtime — the suite, not the build, is the gate.
Red side must show the *defect* (`PLAYS 0`, `644`, a chat `CALL` with the wrong prefix, `Invalid URL`), green side the fix — assert on printed markers only (`PLAYS`, `CALLS`, `CALL <url>`, `NOTIFY`, `SETTINGS`). `CALLS` counts `/audio/speech` calls only; the rewrite call appears as its own `CALL /…/chat/completions … sys=<full system prompt>` line, which is how prompt-level knobs (verbosity, heckle) are observed.
Prefer this over a synthetic leak: if the check already caught a real leftover/failure once, that event *is* the sensitivity proof — say so in the commit message instead of faking one.
@@ -0,0 +1,38 @@
---
name: dankvoice-menu-knob-workflow
description: "Add a persisted /say knob to dankvoice and test its menu row through the bats driver (DV_SELECT picks, INPUT stub, dist-pin rebuild gate)."
---
Adding a setting to `dankvoice` (`~/Projects/dankvoice`, omp speech extension) means five touchpoints plus one behavioral test. Skip one and TypeScript or a silent drop catches it; skip the test and the row ships dead.
## The five touchpoints (order matters)
1. `src/settings.ts` — field on `Settings` (optional) **and** a type-guarded line in `readSettings`. A field without a guard line is written to disk and never read back; tsc stays clean, so nothing else catches it.
2. `src/config.ts` — `Config` field (required, defaulted), resolution line, entry in the returned object, and the knob documented in the file header comment (that header is the README's pointer for "full knob list").
- Convention: explicit env wins over settings (`env.X === "1" ? true : env.X === "0" ? false : (settings.x ?? false)`); booleans use `1`/`0`, enumerations use the literal value.
3. `src/index.ts` — `persistSettings()` key list (a knob missing there never survives the session) **and** the getter in the `new Speaker({...})` deps object.
4. `src/utterance.ts` — getter on `SpeakerSettings` if the Speaker reads it at speak time.
5. `src/menu.ts` — the row. Rows carry their own `run`; never dispatch on `choice === options[N]`.
Gate at the call site, not the prompt, when a mode must not apply to operator-typed text: `Speaker.speak(ctx, text, thinking, auto)` — `auto=true` only from `message_end`/`agent_end`; `/say <text>` passes nothing. Prompt-only gating leaks.
## Test the row behaviorally, never as a config assertion
`tests/behavior.bats` drives the built `dist/index.js` against a mock harness. Selects and typed text both come from one pipe-separated queue: `DV_SELECT="row label|sub choice|typed text"`, consumed in call order; a consumed-exhausted `select` returns `options[0]`, so always name the row by its **rendered label** (`"Verbosity: normal (change)"`, `"Intro: none (change)"`).
- `ui.input(title, placeholder)` in the driver mock logs `INPUT <title> <placeholder>` and takes the next queue entry. If the mock lacks `input`, no typed value was ever testable — add the stub before writing the test.
- Scenario pattern that proves a pick works (`case "knobs"`): `say.handler("menu")` → `say.handler("on")` → two `agentEnd(answer(...))` with `settle()` between. Then assert on observable output, not on the settings file:
- prompt-shaping knob: `[[ "$output" == *"ONE spoken sentence"* ]]` (directive reached the rewrite request)
- framing: `[[ "$output" == *"input=Update, chief!"* ]]`
- throttle/framing counting: `[[ "$output" == *"CALLS 1"* ]]`
- A guard test whose negative case would pass trivially must also assert its premise, e.g. truncation: assert `CALLS 1` so "no outro" cannot be a green caused by nothing ever being cut.
Red first, in a batch: write all the new `@test` blocks, run `bats tests/behavior.bats -f "<pattern>"`, confirm the new ones fail, then implement.
## Gates and gotchas
- **Two `bats` runs at once corrupt each other**: `setup()` runs `bun run build`, so a concurrent run rebuilds `dist/` mid-suite and the byte-identical pin test fails spuriously. Serialize.
- **Edit `src/` → rebuild before commit**: the pin test compares committed `dist/` to a fresh build. Sequence is `bun run build && bunx tsc --noEmit && bats -f <pattern>`, then `git add -A` (dist included). A failure there is normally a stale bundle, not a bad assertion.
- `VAR=value some_function args` in bats does reach the child process, so knob tests need no `export`.
- `edit` tool: inserting after a line whose content you know but that the current tag never displayed gets refused — the refusal echoes the real line; if it matches intent, re-issue the identical edit instead of re-reading.
- Ship: branch `feat/<issue#>-<slug>` off fresh `origin/main`, one commit per knob, PR targets `main`, merge only on Frank's explicit OK (`do=merge`, `merge_style=no-ff`), then `omp plugin upgrade dankvoice` and `cmp` installed `dist/index.js` against the merged one.
@@ -0,0 +1,31 @@
---
name: tts-endpoint-parameter-capability-audit
description: "Determine which request parameters a fleet TTS endpoint actually honors by probing façade and engine separately, with a nondeterminism control, instead of trusting docs or byte diffs."
---
Use when asked whether a TTS (or other OpenAI-compatible shim) endpoint supports a parameter — mood, tone, instructions, speed, seed — or when a request "seems to" change the audio. Docs and single-request comparisons both lie; audit the layers.
## Why the naive checks fail
- **Byte-size diffs are meaningless.** Synthesis is nondeterministic: three byte-identical requests to frank-tts came back 107564 / 134444 / 111404 bytes (~±15 % length). "The mood file differs from the baseline" proves nothing.
- **HTTP 200 proves nothing.** A shim that rebuilds its own payload answers 200 to `speed:"banana"` because it never looks at the field.
- **A façade can drop fields the engine supports**, so testing only the published port under-reports capability.
## Procedure
1. **Read the shim's parse block** (which request keys it reads) — not its docstring:
`ssh -o BatchMode=yes dankclaw 'podman exec frank-tts grep -nE "body\.get|payload = |json\.loads" /app/app.py'`
Whatever keys appear there (plus what it forwards in its own `payload = {...}` dict) is the real published contract.
2. **Enumerate the engine's key table from the binary** (works with no `strings` in the image):
`podman exec <engine> sh -c 'grep -aoE ".{60}instructions.{60}" /app/tts-server | head -2'`
Adjacent tokens come out as a run — that run IS the accepted-key list (`...instructions response_formatspeedseedmax_new_tokensrepetition_penalty subtalker_top_k subtalker_temperature ...`). Also `podman exec <engine> /app/tts-server --help`, and `podman inspect <c> --format "{{json .Config.Entrypoint}}"` to find the binary.
3. **Reach the engine directly** when its port is unpublished: `TTS_UPSTREAM` in `podman inspect <shim>` gives the real in-network address (`http://frank-tts-engine:8880`, not `host.containers.internal`). No curl/python on the host? Pipe a script into the container that has one: `podman exec -i frank-tts python3 - <<'PY' ... PY`.
4. **Control for nondeterminism before claiming an effect**: repeat the identical request twice as the control; only compare conditions separated by far more than the jitter, and prefer an error response (a 400 naming the unsupported flag) as the decisive evidence.
## Findings for the dank fleet (verify before trusting — they move)
- Published façade `:7772` reads only `model`, `input`, `voice`/`voice_id`, `response_format`; it rebuilds the payload, so `speed` and every unknown field are dropped before the engine.
- Engine (`frank-tts-engine`, qwentts.cpp, qwen-talker-1.7b-**base**, Vulkan) accepts `instructions`, `speed`, `seed`, `max_new_tokens`, `repetition_penalty`, `subtalker_top_k/_temperature/_top_p`, but answers `400 "--instruct is not supported for base models"` for `instructions` on the base checkpoint. `seed` does not make output reproducible.
- Therefore tone/mood must be produced in the **text layer** (prompt directives), not the TTS request; and `speed` needs a two-line façade fix if tempo control is ever wanted.
## Verification habits
- POST with a wrong *type* (`speed:"banana"`) to learn whether anything validates, and with a bogus `voice` to see the error shape (`available_voices` came from the engine, not the shim).
- Plain GET of `/v1/audio/voices` for the roster.
- Read-only probes of the fleet are always allowed; synthesis requests are transient GPU work, keep them to a handful.
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792452174894,
"lastConsolidatedAt": 1792456074901,
"lastResult": {
"at": 1792433353188,
"sessions": 1,
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792452243238,
"lastConsolidatedAt": 1792455940767,
"lastResult": {
"at": 1792452243238,
"sessions": 1,
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200
[wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_y8ewzk.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_l38ljr.jpg"
[wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_y8ewzk.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_l38ljr.jpg"
[wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_y8ewzk.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_l38ljr.jpg"
[wallpaper.monitors.DP-2]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
@@ -290,7 +290,7 @@ transition = [ "fade" ]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
[wallpaper.monitors.eDP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_y8ewzk.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_l38ljr.jpg"
[weather]
unit = "imperial"