Compare commits

..
Author SHA1 Message Date
Frank a8727ddf0b chore: retire dankvoice drop-in for the dank/dankvoice package
The extension's full behaviour now lives in dank/dankvoice (41/41
black-box suite against the built artifact; this branch's 26 drop-in
tests are a strict subset, names ported 1:1). Delete the drop-in and
its bats suite; repoint omp-docs/core/dankvoice.md at the package,
keeping only fleet wiring (frank-tts + @tiny resolution, the
enable_thinking hybrid law, voice/roster contract) and the cutover
procedure. kb-lint 0 violations; bats tests/ 45/45.

Refs #34
2026-10-19 12:38:28 -07:00
Frank 2faeab665a live: heartbeat sync 2026-10-19T12:19:36-07:00 (Refs #11) 2026-10-19 12:19:36 -07:00
Frank 62b3b92e69 live: heartbeat sync 2026-10-19T11:18:41-07:00 (Refs #11) 2026-10-19 11:18:41 -07:00
dank ec6a707fe4 Merge pull request 'fix(dankvoice): pin thinking off in rewrite, fix silent and timeout modes' (#33) from fix/32-dankvoice-latency into main 2026-10-19 11:12:50 -07:00
Frank 96553979fb fix(dankvoice): pin thinking off in rewrite, fix silent and timeout modes
Refs #32

The humanizer's raw fetch to frank-zealot omitted
chat_template_kwargs.enable_thinking: false, so the Qwen35 hybrid thought
instead of answering (6.9 s reasoning_content, empty content - timing out
the 6 s abort; or answering "" and muting the unit). Pin the kwarg, treat
empty-content-with-reasoning as failure over silence, and stop the menu's
test phrase from blocking its handler. Three red-captured regressions;
26/26 green, verified red against HEAD.
2026-10-19 10:46:06 -07:00
Frank ac5b377750 live: heartbeat sync 2026-10-19T10:17:52-07:00 (Refs #11) 2026-10-19 10:17:52 -07:00
dank 8f5a229cc6 Merge pull request 'chore(scripts): promote index-coverage checker; document bundle gates' (#31) from chore/29-kb-coverage-tooling into main 2026-10-19 10:01:30 -07:00
Frank ea514c8fd2 chore(scripts): promote index-coverage checker from campaign scaffolding
PR #30 merged the throwaway checker under its .tmp- name. Promoted to
scripts/kb-coverage.py and documented on okf-maintenance.md § Automated
Gates alongside kb-lint (the bundle previously documented neither gate).
Self-tested: kb-coverage.py reports 0 issues on the current bundle.

Refs #29
2026-10-19 09:59:50 -07:00
dank bc60fa33be Merge pull request 'docs: exhaustive dedupe, compaction, OKF conformance, fact verification' (#30) from docs/29-dedupe-compact into main 2026-10-19 09:56:23 -07:00
Frank e4ecc93f29 live: heartbeat sync 2026-10-19T09:17:41-07:00 (Refs #11) 2026-10-19 09:17:41 -07:00
Frank d055eb1e7f live: heartbeat sync 2026-10-19T08:15:41-07:00 (Refs #11) 2026-10-19 08:15:41 -07:00
Frank 70346f3d57 live: heartbeat sync 2026-10-19T07:12:41-07:00 (Refs #11) 2026-10-19 07:12:41 -07:00
Frank 1251cfbd40 live: heartbeat sync 2026-10-19T06:09:41-07:00 (Refs #11) 2026-10-19 06:09:41 -07:00
Frank e67bbd0de0 live: heartbeat sync 2026-10-19T05:07:41-07:00 (Refs #11) 2026-10-19 05:07:41 -07:00
Frank cd20d6ddfd live: heartbeat sync 2026-10-19T04:06:41-07:00 (Refs #11) 2026-10-19 04:06:41 -07:00
Frank d99c1c3c4f live: heartbeat sync 2026-10-19T03:04:41-07:00 (Refs #11) 2026-10-19 03:04:41 -07:00
35 changed files with 659 additions and 1825 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,35 @@
---
name: docs-dedupe-verify-campaign
description: "Run an exhaustive docs dedupe + fact-verification campaign in dank/command-center: scout cluster ledgers, override scouts with evidence, gate every commit, and sweep scaffolding before PR."
---
# Exhaustive docs dedupe + verification campaign (dank/command-center)
## Frame
- Read `omp-docs/references/okf-spec.md` + root `omp-docs/index.md` first; inventory with `find omp-docs -name '*.md' | wc -l`.
- Run `scripts/kb-lint.sh omp-docs` baseline; index-coverage gate is `scripts/kb-coverage.py omp-docs` (each dir's `index.md` must link every sibling file/subdir).
- Branch off fresh `origin/main` in a NEW worktree (`git -C ~/.omp worktree add ~/.omp/wt/<name> -b docs/<issue>-slug origin/main`) — workspace policy blocks `git checkout -b` in session worktrees.
- Open the Gitea issue first (multi-moving-parts law); branch name `docs/<issue#>-<slug>`.
## Scout fleet (overlap detection)
- One `task` call, `tasks[]` batch, agent `scout`, read-only; one cluster per task (skills trees, llama trees, pi vs packages, niri, sleep/power, core, gitea, gitea+cross-cutting sweep). Cap 2 concurrent; queue rest.
- Ledger format per cluster → `local://dedupe/<cluster>.md`: inventory table (file | lines | verdict KEEP|MERGE|DELETE|VENDOR|UPDATE), overlaps, facts-to-keep, inbound links, staleness.
- **Scouts lie.** Verified misses in the 2026-10-19 campaign: fabricated ledger entries ("peer-reviewed BrowseComp" claim that exists in no file), a DELETE verdict for a generated artifact that is checked in by design AND under test (`tools/niri/keybindings.md`, see `scripts/gen-keybinds.sh` + `tests/gen-keybinds.bats`), and a KEEP for a leftover the scout misread its own changelog about. NEVER apply a scout verdict without re-checking the file, the tests, and the changelog yourself.
## Fact verification
- Fleet facts: read-only probes over `ssh -o BatchMode=yes dankclaw|dankserver` (podman ps/inspect/top, stat, /etc/os-release, curl localhost APIs, `podman exec <c> <tool> --version`). Live state beats docs even when docs claim recent verification — the 2026-10-18 "audit" still drifted (zealot 27B→9B, build 10754→10777, container census 14→32).
- External claims: web_search/web read yourself (no subagents per Frank's rule). Kill fabrications outright (invented distro `FreeToken`, nonexistent `ornith-llama-cpp-setup` tree, unattributable "44.5% peer-reviewed CUA") — delete/drop with an explicit note, never keep "plausible" numbers.
- Policy drift (live ≠ tuned docs): flag in the doc + PR body as an operator decision; NEVER silently re-apply policy.
## Log compaction (log.md)
- Entry law: dated entry = what changed + durable lesson + concept-page pointer; narratives recoverable via `git log --follow`. Record the law at the top of the log. Tag historical-era entries.
## Gates + landing
- Gate every commit: `bash scripts/kb-lint.sh omp-docs && python3 scripts/kb-coverage.py omp-docs && bats tests/`. Pre-commit hook prints `error: command not found: caddy` on Caddyfile-touching commits — warning only, commit still lands.
- Before opening the PR: `git diff --stat origin/main..HEAD | grep -i 'tmp\|scratch\|\.bak'` — scaffolding merged under a `.tmp-` name in the 2026-10-19 campaign and needed a follow-up PR to promote.
- PR via API (skill `gitea-api-pr-operations` for token-extraction + merge payload); `xdg-open` the PR the moment it opens; comment the campaign ledger on the issue.
- After Frank's merge OK: propagate deployment `git -C ~/.omp fetch && git merge --ff-only origin/main`; reap campaign worktree (`git worktree remove --force`) + delete branch local/remote; close issue with landing comment.
## Line-edit op syntax trap (bit three times)
- Valid ops: `PUT N.=M:` (body `+text` lines), `CUT N.=M` / `CUT N.=M @reg`, `PUT <N @reg`, `PUT >N:` / `PUT <N:`. There is NO `PUT N+=:` form — it lands in the file as a literal garbage line and shifts the insert to the wrong position. For gap-insert use a lone `PUT >N:` or `PUT <N:` per call; pre-compute line numbers against the file map (ops reference original lines within one call, but a failed op poisons positions).
- Recovery recipe when a literal op line lands: read exact bounds, `CUT N.=N` (junk), `CUT A.=B @block` (misplaced section), then `PUT <M @block` at the true anchor, re-read tail, add spacing blank if a `---` now abuts a list line.
@@ -0,0 +1,70 @@
---
name: docs-point-of-use-destruction
description: "Documentation exists for point of use, not archival. Use before any dedupe/compact/verify-docs job, and whenever a census or \"only copy\" argument is being used to KEEP documentation: apply the purpose test and destroy obsolete measurements. Supersedes the preservation conclusions of vault-one-fact-one-owner-dedupe §3/§12."
---
# Documentation exists for point of use
**Supersedes** `vault-one-fact-one-owner-dedupe` §3 ("port before cut") and §12
("expect docs flat") wherever those conclude a page must be *kept*. Sole custody of a
useless fact is still useless.
## The test, before you write and again before you keep
- Would the reader, doing a real task tomorrow, **act differently** because of this
line? No → delete it.
- Is it a measured rate (t/s, tok/s, ms, MiB-of-pool, acceptance %, a bench ladder) for
a model / runtime / layout that is **not running now and not being compared now**?
Destroy it. Not "file it in an Eras row", not "preserve it for history". AI moves
faster than the vault: the number is wrong before anyone reads it, and a wrong number
in a runbook gets believed.
- Keep the **law** the measurement taught; drop the measurement. Qualitative causal
stories survive; numbers do not.
- What survives: live config and its exact numbers, procedures, failure signatures,
security posture, interface contracts, rollback pointers.
- **Losing information is an acceptable, usually correct outcome.** Frank said so
explicitly on 2026-10-19 after rejecting a PR that had preserved 53 ledgers.
## Traps that lead agents to preserve junk
- **A census cannot decide what to keep.** `port-census.py`-style tools answer "where
does this fact live", never "does anyone need it". "14–63 sole-record lines per page"
was read as *irreplaceable* when the fact was *obsolete*. Ask usefulness, not custody.
- **A census that includes the file under test proves the page is held by itself.**
Exclude it, print the corpus size, assert absence; treat `0 unheld` on a long page as
a tool bug until proven otherwise.
- **"One fact, one owner" is a placement rule, not a preservation rule.** It says where
a surviving fact lives; it never says the fact must survive.
- **A change diary (`log.md`) is the largest archive in most vaults** (this one: 2,380
lines, ~8 % of the vault). Git holds the chronology; a causal law belongs on the page
that owns the thing it governs. Retire the diary.
## Execution that worked (2026-10-19, −13.7k lines)
1. Write the kill list as a reviewed artifact, one path per line, grouped by *why it is
dead* — that list becomes the PR's argument and the trash manifest.
2. Destroy whole pages first (`scripts/destroy-vault.py <list>`): move to
`.AGENTTRASH/<date>-destruction/`, then delete every reference from survivors, whole
line included when the dead link was its only content. Do not compare link targets
with `pathlib.Path.stem` — it strips real suffixes (`sd.cpp`).
3. Then strip numbers from what remains (`scripts/axe-measurements.py`): delete ledger
sections (noun-LEDS the heading, nothing instructional after it), blank any table cell
reporting a rate, fold Eras older than the current one into a single row pointing at
git. Never touch a shape number or a Laws/Wire/Rollback section.
4. Sweep indices (generator must exclude the trash dir, drop rows whose page cell became
empty), re-lint, re-check links.
5. Rewrite the governing policy in the same PR, or the next agent re-grows the vault:
project `AGENTS.md` *Documentation Policy* + the global agent settings.
## Report honestly, per area
docs / scripts / operator-doc separately, measured against the merge-base. The tools are
a cost that must be named, not hidden inside a headline "net" number — that is what got
the previous pass rejected.
## Verify the repair tools themselves
One repair pass matched "line ends with a comma" and deleted the trailing comma from 660
wrapped prose lines. Restore by matching lines that are byte-identical to `HEAD` except
for trailing punctuation (`restore-punctuation.py`), keep the destructive rule out of the
tool, and record the incident in its docstring.
@@ -0,0 +1,62 @@
---
name: fleet-service-runtime-census
description: "Answer \"what is service running right now?\" for a dank-fleet service: live endpoint probe plus host units, image ids, live-vs-repo env, and drop-ins."
---
# Fleet service runtime census
Answer the question "what is `<service>` running currently?" with evidence, not from docs. Docs say what *should* run; the host says what *does*. Run all five probes, then report one table.
## 1. Probe the published port (works from any LAN box, no ssh)
```bash
curl -s -m 10 http://<host-ip>:<port>/health; echo
curl -s -m 10 http://<host-ip>:<port>/v1/audio/voices # per-endpoint extras: /v1/models, /metrics
```
The extra route matters more than `/health` for identity claims:
- llama.cpp and radiance **echo the requested model name** — a 200 proves nothing; read `/v1/models`.
- halogen 404s `/completion`, `/ping`, `/props`, `/tokenize`.
- The frank-tts façade answers `/health` **without dialing the engine**, so "façade healthy, engine down" is a real state; the voice list in that same response is the honest census.
## 2. Units + containers on the host (ssh as the deploy user, no sudo)
```bash
ssh <host> 'systemctl --user status <svc>.service <engine>.service --no-pager \
| grep -E "^(●| Active| Loaded)"; \
podman images --format "{{.Repository}}:{{.Tag}} {{.ID}} {{.Created}}"; \
podman ps -a --format "{{.Names}}\t{{.Status}}\t{{.Image}}"'
```
- `active (running) since …` + `Up N hours (healthy)` is the answer; `activating (auto-restart)` with a ~50 ms exit is a **preflight refusal**, not an engine fault.
- **NEVER prefix these podman calls with `sudo`.** Containers live in the deploy user's rootless storage; `sudo podman inspect frank-tts-engine` returns `no such object` while the plain form succeeds. Treating that as "container missing" is a false finding.
## 3. Drop-ins — the override that no health gate sees
```bash
ssh <host> 'systemctl --user show <svc>.service -p DropInPaths; \
systemctl --user show <engine>.service -p DropInPaths'
```
`DropInPaths=` must be empty. A leftover bench drop-in (`Environment=FRANK_<SVC>_ENV_FILE=/tmp/<arm>.env`) makes the next deploy boot a different model at a different shape while `/health` still answers 200 and the e2e gate passes.
## 4. Live env vs repo env — the only truth test
```bash
# repo side (local checkout)
grep -vE '^\s*#|^\s*$' scripts/<svc>.env
# host side
ssh <host> 'sudo cat /home/dank/serverAppConfigs/scripts/<svc>.env' | grep -vE '^\s*#|^\s*$'
```
Diff them. Per repo law the env file beside the serve script **is** the authority; a doc page that disagrees with it is the bug, and host-side drift means an uncommitted edit. Also check `git log --oneline -3 -- scripts/<svc>.env` to confirm the host isn't ahead of git.
## 5. Report
Table of: unit state + since, image repo:tag@short-ID + age, backend/alias actually served (from step 1, not from the name), drop-ins, and whether live env == repo env. State explicitly when a fact came from docs rather than from a probe.
## Traps that produce false findings
- Digest-pinned images: the local image ID's *creation date* answers "is this stale" better than the tag; `latest` + `Pull=never` is a locally built image, not a registry pull.
- `ssh` user context changes what podman sees (root vs rootless) — see step 2.
- Two ports on one host are two providers in `models.yml`; a port mismatch is a rename or drift, not a config typo.
@@ -1,6 +1,6 @@
--- ---
name: gitea-api-pr-operations name: gitea-api-pr-operations
description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea API (192.168.0.143:3000, repo dank/command-center) with the token stored in ~/.config/secrets/gitea — incl. the required merge payload and where review comments actually live on 1.27.3." description: "\"Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea API (192.168.0.143:3000, repo dank/command-center) with the token stored in ~/.config/secrets/gitea — incl. the required merge payload, where review comments actually live on 1.27.3, and how to recover the session id for the mandatory body footer.\""
--- ---
# Gitea API PR operations (Dankclaw `dank/command-center`) # Gitea API PR operations (Dankclaw `dank/command-center`)
@@ -25,11 +25,13 @@ description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea A
fold footers into the --arg string instead.) fold footers into the --arg string instead.)
- **List open PRs**: `GET /pulls?state=open` → `jq -r '.[] | "#\(.number) \(.head.ref) \(.title)"'`. - **List open PRs**: `GET /pulls?state=open` → `jq -r '.[] | "#\(.number) \(.head.ref) \(.title)"'`.
- **Read PR**: `GET /pulls/<n>` (no token; public repo) → `jq -c '{state,merged,mergeable,head:.head.ref}'`. - **Read PR**: `GET /pulls/<n>` (no token; public repo) → `jq -c '{state,merged,mergeable,head:.head.ref}'`.
`mergeable:false` is often Gitea's *not-yet-checked* state, not a conflict — prove a real
conflict locally with `git merge-tree --write-tree HEAD origin/main` before believing it.
- **MERGE**: `POST /pulls/<n>/merge` with `-H 'Content-Type: application/json'` - **MERGE**: `POST /pulls/<n>/merge` with `-H 'Content-Type: application/json'`
and body **`{"Do":"merge"}`** — this build REQUIRES the `Do` field: and body **`{"Do":"merge"}`** — this build REQUIRES the `Do` field:
`{}` or `{"DoNotMerge":false,...}` → 422; `{"Do":"merge"}` → 200 + `{}` or `{"DoNotMerge":false,...}` → 422; `{"Do":"merge"}` → 200 +
`{"state":"closed","merged":true,"merge_commit_sha":"..."}`. Verify with a `{"state":"closed","merged":true,"merge_commit_sha":"..."}`. Verify with a plain
plain GET /pulls/<n> afterwards (merge endpoint's own stdout may not parse). GET /pulls/<n> afterwards (merge endpoint's own stdout may not parse).
Merge is the `--no-ff` merge commit on main; then `git -C ~/.omp fetch && git Merge is the `--no-ff` merge commit on main; then `git -C ~/.omp fetch && git
merge --ff-only origin/main` propagates the live deployment. merge --ff-only origin/main` propagates the live deployment.
- **Issues**: same shape at `/issues` (`POST {title, body}`), `/issues/<n>/comments` - **Issues**: same shape at `/issues` (`POST {title, body}`), `/issues/<n>/comments`
@@ -51,6 +53,13 @@ description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea A
Every issue/PR body ends with `---` line then `cwd: <wd> · session: <id>`. Keep Every issue/PR body ends with `---` line then `cwd: <wd> · session: <id>`. Keep
it inside the --arg string (not a `---` CLI arg). it inside the --arg string (not a `---` CLI arg).
`cwd` is THIS session's own working directory (note a build worktree separately if they
differ). When `/status` is unreachable (headless/subagent context), recover the session id
from the session store instead of inventing one:
`ls -t ~/.omp/agent/sessions/<cwd-slug>/ | head -2` → `<timestamp>_<uuid>` (the same uuid as
`<uuid>.jsonl`); the slug is the cwd with `/` replaced by `-`. Subagent files live under the
**parent** session's directory, so the newest entry there is the parent id.
## Gotchas ## Gotchas
- Content-Type: POSTs with `-d @file.json` MUST carry - Content-Type: POSTs with `-d @file.json` MUST carry
`-H 'Content-Type: application/json'` on the command line. `-H` entries put in `-H 'Content-Type: application/json'` on the command line. `-H` entries put in
@@ -0,0 +1,27 @@
---
name: gitea-merge-form-encoded
description: "Use when a Gitea API merge on Dankclaw (192.168.0.143:3000) returns HTTP 422 \"[Do]: Required\" — the merge endpoint needs form-encoded fields, not the JSON body that PR creation accepts."
---
Gitea on Dankclaw (192.168.0.143:3000, `dank/serverAppConfigs`) — merge quirk, measured 2026-10-19.
Creating a PR accepts a JSON body. **Merging does not**: `POST /api/v1/repos/{owner}/{repo}/pulls/{n}/merge` with a JSON body (`{"merge_style":"no-ff", "Do-Not-Reply-From":…, "Reply-To":…}`) or with those values as request headers returns `422 {"message":"[Do]: Required"}` — four variants of that all fail identically, so do not "fix" it by changing the style name or moving the headers around.
Working call (form-encoded):
```bash
tok=$(sed -n 's|http://[^:]*:\([^@]*\)@.*|\1|p' ~/.config/secrets/gitea | head -1) # git credential.helper store file
curl -sS -X POST -K <(printf 'header = "Authorization: token %s"\n' "$tok") \
--data-urlencode 'do=merge' \
--data-urlencode 'merge_style=no-ff' \
--data-urlencode 'Do-Not-Reply-From=chief@franksdank.cloud' \
--data-urlencode 'Reply-To=chief@franksdank.cloud' \
http://192.168.0.143:3000/api/v1/repos/dank/serverAppConfigs/pulls/<N>/merge
# 200 with an empty body == merged
```
- `do=merge` is the field the 422 was demanding (the web-UI form field name, not an API doc field).
- `merge_style` values are the dashed names: `no-ff`, `merge`, `rebase`, `squash`.
- Keep the token out of argv: `-K <(printf …)` feeds the `Authorization` header through stdin (a `-H "Authorization: token …"` lands in `/proc/<pid>/cmdline`, world-readable — the 2026-09-16 HF-token leak class).
- Verify by fast-forwarding a checkout (`git fetch && git merge --ff-only origin/main`) rather than re-reading the PR.
- Fleet law still applies: never merge without an explicit current OK from Frank, and open the PR URL in his browser (`xdg-open`) the moment it exists.
@@ -0,0 +1,29 @@
---
name: omp-extension-stale-module-audit
description: "Diagnose recurring omp extension handler timeouts/silence when the on-disk fix is already deployed: harness has no extension hot-reload, so running sessions freeze the module at session start (zombie modules)."
---
Use when a fix for an omp extension is merged/deployed yet the SAME errors persist ("handler timed out after 30000ms", silence, wrong behavior) in running sessions.
**Core fact:** the harness loads extension `.ts` modules at session start and never reloads them (no `/reload` for extensions; bundle `reload` hits are LSP/MCP/browser only). A session older than the fix keeps executing stale code regardless of the file on disk.
**Procedure:**
1. Census the errors from logs, not guesses — timeout lines carry the culprit pids:
`grep -h "handler timed out" ~/.omp/logs/omp.<today>*.log | python3 -c 'import sys,json; rows=[json.loads(l) for l in sys.stdin]; rows.sort(key=lambda r:r["timestamp"]); [print(r["timestamp"], r["event"], r["pid"]) for r in rows]'`
(`~/.omp/logs/omp.<date>.<pid>.log` — the pid in the filename is the session.)
2. Session start times vs deployed file versions:
`ps -eo pid,lstart,args | grep '[o]mp'`
`git log --format='%h %ad %s' --date=format:'%m-%d %H:%M' -- <extension path>`
`git reflog --format='%gd %ad %gs' --date=format:'%m-%d %H:%M' | grep merge` (live-sync pull times = when the file content changed).
Any session whose start time precedes the fix's arrival has the stale module.
3. Confirm the stale revision actually contains the bad pattern — read what that session loaded, not HEAD:
`git show <old-rev>:<path> | grep -n 'await speak\|async (event'`
(e.g. an `async agent_end` awaiting a >30 s chain is exactly what the harness's per-handler `Kzc` 30 s race logs; fire-and-forget `void` is fine — detached promises are NOT tracked by the dispatch window.)
4. Optional empirical proof when static analysis says "impossible": probe extensions through the real harness:
`omp -p --no-extensions -e /tmp/probe.ts "Reply OK"` with a probe that logs handler start/return and `void`s a 40 s detached promise + spawns a long child. Fast exit + no timeout log ⇒ detachment is untracked ⇒ blame stale modules, not the harness.
5. Remediation: restart the affected sessions (kill the listed pids or their terminals — the operator session itself may be one of the zombies; say so explicitly). There is no other fix.
**Gotchas:**
- Per-handler attribution in the log is accurate per dispatch; don't blame other extensions before checking the pid's start time.
- Endpoints healthy + silence persisting usually means TWO stacked bugs (e.g. stale awaiting module in old sessions AND a mute bug in current code) — diagnose each pid cohort separately.
- Extension chain code must use self-owned `AbortSignal.timeout(...)`, never a ctx-scoped signal: harness dispatch signals die when the dispatch window closes.
@@ -0,0 +1,29 @@
---
name: red-verify-without-stash
description: "Prove a regression test is red-capable against pre-fix code in a shared worktree where git stash/checkout are policy-blocked, via git-show blob swap with md5-verified restore."
---
# Red-verifying tests without `git stash`
## When
You fixed a file and added tests that should have failed before the fix, but workspace policy blocks `git stash` (and `git checkout -- file`) in shared worktrees (e.g. `~/.omp` shared-workspace policy). Needed to prove red-capture instead of asserting it.
## Recipe
One `bash` call — swap the fixed file for the HEAD (pre-fix) blob, run only the fix-relevant tests, restore, verify byte-identity:
```bash
cd "$WT"
md5sum path/to/fixed.file > /tmp/file-hash
cp path/to/fixed.file /tmp/fixed.file.bak
git show HEAD:path/to/fixed.file > path/to/fixed.file # pre-fix content; new tests now exercise old code
bats -f "test name A|test name B" tests/suite.bats # expect: not ok for every fix-relevant test
cp /tmp/fixed.file.bak path/to/fixed.file
md5sum -c /tmp/file-hash && echo RESTORED
```
## Notes
- `git show HEAD:path > path` truncates-and-refills the file in place; safe because you hold the md5 and a backup copy, and the restore check proves the fixed bytes are back.
- Run only the fix-relevant tests via the runner's name filter (`bats -f` regex); a full suite wastes time and old tests pass on both versions.
- Expect exactly the tests that pin the fix's behavior to fail pre-fix; tests unrelated to the change (pure coverage of old behavior) pass on both versions — don't count them as red evidence.
- `bats` pipelines mask exit codes without `pipefail`; judge by the `ok`/`not ok` lines, not the shell status.
- For non-test proof of endpoint behavior (live A/B of an HTTP request body), replay the exact body with `curl` against the live endpoint instead of speculating; keep both timings.
@@ -0,0 +1,43 @@
---
name: ryzenai-npu-oga-linux-spike
description: "Run a vendor-compiled AMD Ryzen AI (XDNA2) ONNX-GenAI model on a Linux Strix Halo host from public artifacts: staging, rootless NPU device-access wall, container recipe, and the NuTTS-air speech-token contract."
---
Run an AMD-precompiled XDNA2 model (OGA / onnxruntime-genai) on a Linux Strix Halo host (Dankclaw-class) without installing the vendor stack on the host.
## Where the bits actually live (both public, no AMD account)
- Runtime tarball: `https://download.amd.com/opendownload/RyzenAI/1.8.0/ryzen_ai-1.8.0.tgz` (~7.28 GiB). Verify with a ranged GET — first bytes `1f 8b 08`.
- NPU driver/XRT zip: `https://download.amd.com/opendownload/RyzenAI/Driver/RAI_1.8_Linux_NPU_XRT.zip` (90 MB).
- Dead ends (404, do not re-probe): `download.amd.com/driver/linux/RAI_1.8_Linux_NPU_XRT.zip`, `.../opendownload/RyzenAI/ryzen_ai-1.8.0.tgz`, `www.xilinx.com/bin/public/openDownload?filename=...` (301 → 404). The login-walled EULA *form* is not the gate; the `opendownload` tree is the real file host.
Tarball has 30 top-level entries: cp312/linux_x86_64 wheels (`onnxruntime_genai_ryzenai-0.14.0`, `onnxruntime_vitisai-1.27.0`, `vitis_mllib`, `flexml*`, `device_essentials_strx`, `model_generate`, `voe-1.8.0`), plus `deployment.tar.gz` (contains `lib/libonnxruntime-genai.so`, `lib/libonnxruntime_providers_ryzenai.so`, AIE compiler libs), `onnxruntime.tar.gz`, `quicktest.tar.gz` (VitisAI-EP install validator), `LLM.tar.gz` (text-only OGA examples: `run_model.py`, `tokenizer_factory.py`), `install_ryzen_ai.sh`. `voe-4.0-linux_x86_64.tar.gz` is only `vaip_config.json` — no TTS xclbins. **AMD ships no TTS runner and no TTS example.**
## Host rules
- **Never `dpkg -i` the vendor XRT.** `dpkg-deb -x <deb> <dir>` and bind-mount `<dir>/opt/xilinx/xrt` at the same path in the container. The plugin deb would install a DKMS `amdxdna` that replaces the distro in-tree driver; the in-tree driver on Ubuntu 26.04 is `amdxdna 0.7.0` and already loads `amdnpu/17f0_11/npu_7.sbin` at boot (`dmesg | grep amdxdna`).
- **Rootless containers cannot read `/dev/accel/accel0`** (`root:render 0660`): the host gid never enters the userns gid map, so `--device` yields `nobody:nogroup`. Verified failing 2026-10-19: `--group-add keep-groups`, `--userns=keep-id`, `--userns=keep-id:gid=990` (process gets gid 990, node still unreadable), `--group-add render`, `--group-add gid:990` (both error). Options: run rootful (`sudo podman`, separate storage → rebuild the image rootful, ~1 min), or add a world-writable udev rule for `accel0` (the fleet already does exactly this for `kfd`/`renderD*` in `99-comfyui-gpu-access.rules`, whose header explains why: systemd user-unit spawns carry no supplementary groups).
- **Rootless image build trap:** the vendor installer's `tar -xf` preserves recorded uids (95777 etc.) and aborts under `set -e` with `Cannot change ownership ... Invalid argument`, silently skipping the `deployment`/`onnxruntime` extraction. Patch it in the Containerfile: `sed -i 's/tar -xf/tar --no-same-owner -xf/g' install_ryzen_ai.sh`.
- Installer usage: `./install_ryzen_ai.sh -a yes -p /opt/rai -c /opt/rai` (python **3.12** required; base `python:3.12-slim`; needs `linux-libc-dev zip pciutils patch libgomp1`). It builds a venv from the local wheels, reinstalls `onnxruntime-vitisai` then `--no-deps onnxruntime-genai-ryzenai`, installs `requirements_extras.txt` (transformers 4.57.6), patches `bin/activate` (sets `RYZEN_AI_INSTALLATION_PATH=$VIRTUAL_ENV` + `LD_LIBRARY_PATH` into site-packages), and untars `LLM/deployment/onnxruntime/voe/quicktest` into the cpp path.
## HF artifact staging
- Gated (401 on API, 403 on git) for a plain account: `neuphonic/neucodec` (the **only** NeuCodec encoder), `neuphonic/neucodec-onnx-decoder`, `neuphonic/neucodec-onnx-decoder-int8`, `neuphonic/neutts-air`. `amd/neutts-air_rai_1.8.0_npu_4K` is public. Public mirrors of the codec exist (`eugenehp/neucodec` has the full 1.16 GB `pytorch_model.bin`) — treat a mirror of a gated release as a licensing decision for the operator, not a workaround to take silently.
- `github.com/neuphonic/neutts` is public and ships `samples/{dave,emily,greta,jo}.{pt,txt,wav}` = **pre-encoded reference codes + transcripts** → a spike needs zero gated bytes.
- Auth without leaking the token into `ps`: build a 600-perm credential file with a shell builtin and point git at it —
`umask 077; printf 'https://oauth2:%s@huggingface.co\n' "$(cat ~/.cache/huggingface/token)" > ~/.git-credentials-hf`
then `git -c credential.helper="store --file ~/.git-credentials-hf" clone ...`. Never `git clone https://oauth2:$TOKEN@...`.
## NuTTS-air on XDNA2 (the model-side contract)
- The package is an OGA model dir: `genai_config.json` (`type: qwen2`, 24 layers, hidden 896, 14 heads / 2 KV, ctx 32768, `provider_options: [{"RyzenAI": {"hybrid_opt_token_backend": "npu"}}]`), plus `dd_metastate_*` AIE artifacts and `model.onnx` + `model.onnx.data`.
- **Speech tokens live in the text vocab**: `<|speech_0|>` = id 151671 (10 001 codes), with `<|TEXT_PROMPT_START|>` 151666, `<|TEXT_PROMPT_END|>` 151667, `<|TEXT_REPLACE|>` 151665, `<|SPEECH_REPLACE|>` 151668, `<|SPEECH_GENERATION_START|>` 151669, `<|SPEECH_GENERATION_END|>` 151670. Generation is a plain `og.Generator` loop; the graph's own `eos_token_id` is `[151645, 151643]`, so pass 151670 as an extra eos or stop on it manually.
- **Real-time line = 50 speech tokens/s** (`sample_rate=24000`, `hop_length=480`). Judge any NPU/iGPU TTS number against 50.
- Build the prompt by *importing* the vendor function, never by copying the template:
`sys.path.insert(0, <neutts repo>); from neutts.neutts import NeuTTS`, then
`NeuTTS._apply_chat_template(SimpleNamespace(tokenizer=hf_tok, input_format="text"), ref_codes, ref_text, text, None)` — works on a duck-typed `self`, so no backbone/codec load. Template: `user\n<|TEXT_REPLACE|>\nassistant\n<|SPEECH_REPLACE|>`; splice `TEXT_PROMPT_START + encode(f"{ref_text} {text}") + TEXT_PROMPT_END` at TEXT_REPLACE, then truncate at SPEECH_REPLACE and append `[SPEECH_GENERATION_START] + encode("".join(f"<|speech_{i}|>"))`.
- Voice identity = reference codes + transcript (request-time conditioning). No trained artifact exists to preserve; packs holding only `reference.wav` require the gated encoder once, offline.
## Status caveat
Measured on 2026-10-19 up to and including: staged artifacts, image build, device-access matrix, prompt contract. The token-rate measurement (does the vendor userspace drive the in-tree 0.7.0 driver, and does it clear 50 tok/s) is **not** captured here — re-measure, never trust a remembered rate.
@@ -0,0 +1,44 @@
---
name: sigterm-kill-test-stub
description: "Pin \"code kills its child process\" behavior with a bash stub player: make the stub SIGTERM-reactive via sleep&wait, log KILLED via trap; diagnose deferred-trap failures."
---
Use when a test must prove production code sends SIGTERM to a spawned child (player, worker, server stub) and the test asserts the kill happened — or when such a test mysteriously fails with `KILLED` missing and wall time equal to the stub's sleep duration.
## The trap-during-sleep gotcha
A bash stub that sleeps in the **foreground** does NOT run its TERM trap when signalled: bash defers traps until the current foreground command completes. The stub dies (or logs) only after the sleep finishes — long after the test read the evidence file.
Symptoms:
- Assertion on a `KILLED` marker line fails although production code demonstrably calls `child.kill("SIGTERM")`.
- Test wall time equals the stub's full sleep (the kill "had no effect").
## Fix: reactive stub skeleton
```bash
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$STUB_LOG" # call record
trap 'printf "KILLED\n" >> "$STUB_LOG"; exit 143' TERM # BEFORE the sleep
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$COPY" ;; esac; done
sleep "${STUB_HOLD:-0.1}" & # background sleep…
wait $! # …wait is interruptible
printf 'DONE\n' >> "$STUB_LOG"
```
- `sleep N & wait $!` is the idiom: a signal makes builtin `wait` return immediately, the trap runs at once, `exit` inside the trap prevents fallthrough to `DONE`.
- Tune duration per test via the env knob (`STUB_HOLD=5`) so the child is provably still alive when the kill is triggered.
- Put the trap BEFORE any long command; a trap registered after the sleep never guards it.
## Harness wiring (black-box driver pattern)
- Scenario: start the long work → settle (>spawn latency, <hold) → trigger the killer (shutdown event, second invocation, `/stop`) → settle → read log.
- Expose the stub log lines on the driver's stdout (`LOG <line>` per line) so tests grep output, not files: black-box seam stays output-only.
- Assert `KILLED` present; for interrupt-semantics also count (`kills -eq 1`) and total call lines (`PLAYS 3` = call, KILLED, call).
## Orphan hygiene
Second child still sleeping when the driver exits: spawn with `stdio: "ignore"` so the orphan holds no capture pipe — it SIGPIPEs/quiets at teardown; do not lengthen tests waiting for it.
## Verify before trusting
Run the pair separately: kill-tests green while a byte-equality test (`cp` of the payload before sleep) stays green — the reactive rewrite must not drop the copy step (it sits between trap and sleep).
@@ -0,0 +1,44 @@
---
name: strix-halo-npu-readiness-census
description: "Engine-agnostic census of whether an AMD Strix Halo / Ryzen AI host can run anything on its XDNA2 NPU: driver bind, firmware, device perms, IOMMU, distro XRT, missing AIE compiler, fabric clock, RAM headroom."
---
Use when asked "can we run <engine/model> on the Strix Halo / Ryzen AI NPU?" for a runtime that is **not** halogen (halogen has its own gate + vendor catalog: use `halogen-npu-feasibility-census` for that). This answers only: is the *host* able, and what is missing.
## Why it is usually the wrong door first
XDNA2 executes **precompiled AIE partition programs** loaded through XRT → `amdxdna`. There is no generic tensor dispatch: an engine built on ggml/llama.cpp (or any GGUF loader) cannot "enable" the NPU — the port must be a re-export + vendor compile, not a flag. So pair this census with the accelerator the same engine *can* select by build flag (Vulkan/HIP on the gfx1151 iGPU) before proposing NPU work.
## Census (all read-only, over ssh from the operator box)
```bash
ssh <host> '
cat /proc/cmdline; ls /sys/kernel/iommu_groups | wc -l # iommu=pt required; groups==0 => IOMMU off => NPU dead
lsmod | grep -i amdxdna # in-tree driver
sudo dmesg | grep -iE "xdna|npu" | tail # "Initialized amdxdna_accel_driver <ver> for 0000:c6:00.1"
lspci -nn | grep -iE "npu|processing" # Strix Halo = 1022:17f0
ls /sys/bus/pci/drivers/amdxdna # must list the BDF
ls -l /dev/accel # accel0, root:render 660
id -nG <deploy-user> | tr " " "\n" | grep -E "render|video" # rootless podman needs render, else --device fails at create
find /usr/lib/firmware -iname "*npu*" | head # firmware is /usr/lib/firmware/amdnpu/<devid>_<rev>/npu.sbin*.zst — match the PCI rev
dpkg -l | grep -iE "xrt|xdna"; ls /opt/xilinx/xrt/lib 2>&1 # distro libxrt-npu2 = XDNA2 runtime; vendor /opt/xilinx/xrt usually absent
apt-cache policy libxrt2 libxrt-npu2 | grep Candidate
apt-cache search onnxruntime | head # distro ONNX RT exists; the Ryzen AI EP does not
python3 -c "import onnxruntime" 2>&1 | tail -1 # absence of the EP + of ryzenai-compile is the real blocker
for c in /sys/class/drm/card*/device; do echo "-- $c"; cat $c/power_dpm_force_performance_level 2>&1; cat $c/pp_dpm_fclk 2>&1 | tr "\n" " "; echo; done
nproc; free -g | head -2 # RAM headroom + thread count the engine will pick
'
```
## Reading it
- Driver bound + `accel0` + firmware dir + `iommu=pt` ⇒ **kernel side is ready**; do not re-litigate "is the NPU disabled". A missing device node is *not* the modern failure mode.
- `auto` on `pp_dpm_fclk` / `power_dpm_force_performance_level` ⇒ fabric clock **unheld**. GPU+NPU concurrently with a moving fclk hangs these hosts and corrupts NPU results; any NPU plan must pin the top fclk step (or run in a container holding it) first.
- No `ryzenai-compile`/ONNX Ryzen AI EP ⇒ nothing on the host can produce a partition program. That is the enablement delta, and it is a toolchain install + a model-specific export/quantize/compile project, not a config change.
- `free` headroom matters: NPU partitions want a few GB of host RAM each and can log benign `Failed to alloc dev bo memory, ret -28` under pressure.
## Laws / traps
- Read state only. Never probe or load the NPU beside a live inference service — stop + mask the service first (fleet benching policy); this host class has a precedent of a concurrent GPU/NPU experiment needing a power cycle.
- `sudo podman ...` finds nothing (per-user rootless storage); run podman as the deploy user.
- `/dev/accel/accel0` existing does not mean a context/PASID can be created; with IOMMU off the failure is at context creation, not at the node.
@@ -1,6 +1,6 @@
--- ---
name: vault-one-fact-one-owner-dedupe name: vault-one-fact-one-owner-dedupe
description: "Collapse per-payload/per-attempt vault pages into one owner page per endpoint without losing a fact: port-before-cut orphan census, index regeneration, the one-page seam, and the merge-resolution order." description: "\"Collapse per-payload/per-attempt vault pages into one owner page per endpoint without losing a fact: port-before-cut orphan census (corpus must exclude the file under test), doubled-page repair, trash-aware tools, delegated-write verification, honest per-area line accounting, index regeneration, and the merge-resolution order.\""
--- ---
# One fact, one owner: dedupe the vault without losing a fact # One fact, one owner: dedupe the vault without losing a fact
@@ -18,6 +18,9 @@ mechanics are general.
table or boot ledger**. table or boot ledger**.
- `log.md` owns incidents and laws only. A swap is an Eras row, never a log entry. - `log.md` owns incidents and laws only. A swap is an Eras row, never a log entry.
- `AGENTS.md` carries operator-critical numbers + cross-border laws; everything else links. - `AGENTS.md` carries operator-critical numbers + cross-border laws; everything else links.
- A **retired** endpoint still gets one locator page: era windows quoted verbatim from each
era page's own banner, no numbers restated, and any date conflict recorded as an open
question instead of silently resolved. Never pick a winner between two primary sources.
## 2. Single-writer rule (the mistake that costs the most) ## 2. Single-writer rule (the mistake that costs the most)
@@ -29,56 +32,85 @@ to be reconciled by hand.
## 3. Port-before-cut census (proves nothing was lost) ## 3. Port-before-cut census (proves nothing was lost)
For every deleted line, extract distinctive keys (integers ≥ 4 digits with commas stripped, For every deleted line, extract distinctive keys and check them against the vault minus the
decimals with ≥ 2 fraction digits) and check them against the whole vault minus the file file being rewritten. Any line with **no** surviving key is an orphan → port, or accept
being rewritten. Any line with **no** surviving key is an orphan → port, or accept deliberately and say so. Use `scripts/port-census.py <new-dir> <git-HEAD:path|file>` (numbers
deliberately and say so. with ≥ 4 int digits, decimals, flags, paths, backticked spans, `--`, ALL-CAPS, or ≥ 2 hyphens).
```python Three calibration traps:
num = re.compile(r'\d[\d,]*(?:\.\d+)?') - **Prose words are not facts.** A hyphen or trailing period inside a word (`long-standing`,
def keys(line): `deployed:`) was counted as a tracked token while real identifiers hid behind it; that noise
out = set() buried 300 real lines under 979 fake ones. Strip punctuation, require fact shape.
for n in num.findall(line): - **The corpus decides the question.** Corpus = the new file only → "what did this rewrite
i = n.split('.')[0].replace(',', '') drop?" Corpus = whole vault → "is the dropped fact orphaned repo-wide?" Ask one at a time.
if len(i) >= 4: out.add(i) - **The corpus MUST exclude the file under test, and you must assert it.** A census pointed at
if '.' in n and len(n.split('.')[1]) >= 2: out.add(n.replace(',', '')[:6]) the directory that *contains* the page proves the page is held by itself: ten era pages
return out reported `0 sole-record lines`, which reads as "pure duplication, trash it". With the file
# orphan = deleted line whose keys are absent from corpus(vault minus target file, minus .AGENTTRASH) excluded, the same pages held **14–63 sole-record lines each** — campaign ledgers that exist
``` nowhere else. A tool's own docstring promise ("excluding it") is not a check: print the
corpus size, assert the target is absent, and treat `0 unheld` on a long page as a tool bug
until proven otherwise. A mechanical census is a scout: its verdict is a lead, not a finding.
Expect ~30 orphans per 1,000 deleted lines, of which ~40 % are process trivia recoverable Also grep `AGENTS.md` separately — a fact it carries is not an orphan.
from git (PR/commit hashes, per-boot log lines). Port the measurement/law ones to the owner
page's Laws/Eras, and list the accepted losses in the commit message and PR body. Also grep
`AGENTS.md` separately — a fact it carries is not an orphan.
## 4. Deletion mechanics ## 4. When a page is the same page twice
- Trash is **gitignored** (`.gitignore` has `.AGENTTRASH/`). Move with plain `mv` into the Signature: a page whose `wc -l` is ~2× its `HEAD` size, with a **second frontmatter block in
repo-root dated dir, e.g. `.AGENTTRASH/<YYYYMMDD>-topic/LLM/`. `git mv` a tracked page into the body** whose closing `---` is glued onto the end of a body/timestamp line. Cause chain: a
it stages a rename *into an ignored path* (index entry the next agent cannot see); undo with frontmatter rewrite that re-reads the file it just wrote (so its own output becomes the body),
`git restore --staged <src> <dst>` and let the deletion be the change. then a fence-repair pass keyed on `line == "---"` that cannot see a glued fence and inserts a
- Never `rm`, never `git clean -f`. fence mid-page, so the next page-tail becomes a new document.
## 5. Rewire + indexes mechanically Un-double rule that worked: find the first frontmatter's close (accepting a glued fence), then
the first `type:` line after it that has more keys (`title|description|tags|timestamp`) behind
it — that is the second copy, and a stray `type:` in a code block has no key run. Keep the copy
whose body is a **superset** (compare after stripping the dropped copy's own frontmatter),
un-glue the fence, and prove `0` census losses before writing. Refuse to guess: if the halves
differ materially, stop.
## 5. `.AGENTTRASH` is not vault content
Every tool that walks the vault must exclude it — **both the scan set and the resolution
index**. The link checker resolving links *into* the trash turns "live page points at a
deleted page" into a false PASS, which is worse than a false alarm. The `docs_completeness`
seam's `find` must carry `-not -path '*/.AGENTTRASH/*'` in both walks, or a trashed page
demands an index row and a trashed page can satisfy the docs requirement for a live service.
Trash is gitignored; move with plain `mv` into the dated dir. `git mv` into an ignored path
stages a rename the next agent cannot see (`git restore --staged <src> <dst>`). Never `rm`,
never `git clean -f`.
## 6. Rewire + regenerate indexes mechanically
```bash ```bash
grep -rnoE "\[\[(page-a|page-b|...)\]\]" --include="*.md" serverAppConfigs-docs | grep -v AGENTTRASH grep -rnoE "\[\[(page-a|page-b|...)\]\]" --include="*.md" serverAppConfigs-docs | grep -v AGENTTRASH
grep -rnoE "\((page-a|page-b|...)\.md\)" --include="*.md" serverAppConfigs-docs | grep -v AGENTTRASH grep -rnoE "\((page-a|page-b|...)\.md\)" --include="*.md" serverAppConfigs-docs | grep -v AGENTTRASH
``` ```
Both wikilinks and markdown path refs must be repointed (Research `See Also` lists and
`ubatch`-style bench pages are where path refs hide). Then **regenerate** index pages with a
script that walks the vault (`os.walk`, skip `.obsidian`/`.AGENTTRASH`, one row = `[[basename]]`
+ first-sentence description stripped of markdown links) rather than hand-editing rows — the
`docs index links every docs page` seam fails on any row left behind.
## 6. Enforce the law with a seam Both wikilinks and markdown path refs must be repointed (Research `See Also` lists and bench
pages are where path refs hide). Then **regenerate** index rows with a script, never by hand.
Generator bugs to check for: presence tested as a **bare stem substring** — a page whose name
prefixes another's (`frank-medium` inside `frank-medium-rocmfpx`) then silently never gets a
row (test the wikilink form `[[<stem>]]`); and **no dead-row sweep**, so deleting a page leaves
an index row that fails the link check — the generator must drop rows for absent pages too.
## 7. Enforce the law with a seam
`tests/docs_completeness.bats`: derive the live set instead of hardcoding it — an endpoint is `tests/docs_completeness.bats`: derive the live set instead of hardcoding it — an endpoint is
live when `scripts/` holds **both** `<name>.env` and `<name>.service`; fail if any live when `scripts/` holds **both** `<name>.env` and `<name>.service`; fail if any
`<name>-*.md` page exists in the vault, and guard against a vacuous derivation `<name>-*.md` page exists, and guard against a vacuous derivation (`derived -ge 3`). A
(`derived -ge 3`). A re-created era page then cannot pass tests. re-created era page then cannot pass tests. For the length hint, prefer **derived** exemptions
(live endpoint, or a `retired-endpoint` tag: a campaign ledger that exists nowhere else is
owned by the page that measured it) plus one documented per-page opt-out; never let a size
ceiling force a ledger to be split across two owners.
## 7. Edit-tool hazard (costs the most time) Splitting an over-long page to satisfy a ceiling is usually line-NEGATIVE: one measured split
turned a 700-line page into 488 + 306 across five files (+23 for the folder net of stub
deletions), because frontmatter blocks, pointer sections and connective prose are charged per
file while the facts move for free. Roll the split back, carry `okf-exempt: length` with the
reason, and keep the stub pages deleted.
## 8. Edit-tool hazard (costs the most time)
Body rows beginning with `-` or `+` are op markers. The documented `+-` escape **writes a Body rows beginning with `-` or `+` are op markers. The documented `+-` escape **writes a
literal `+`** — do not use it. Safe rewrites inside an existing bullet: keep the `- ` opener literal `+`** — do not use it. Safe rewrites inside an existing bullet: keep the `- ` opener
@@ -86,30 +118,70 @@ line untouched and PUT only the indented continuation lines, or convert the note
bullet whose body lines start with two/four spaces. If a wrapped `[[Two Word Page]]` shows up bullet whose body lines start with two/four spaces. If a wrapped `[[Two Word Page]]` shows up
as an unresolved link, the target was hard-wrapped across lines — unwrap it. as an unresolved link, the target was hard-wrapped across lines — unwrap it.
## 8. Merge order (a long-lived branch under a moving main) A `PUT` that spans a multi-line statement can silently drop neighbouring assignments
(auto-repaired boundary echo); after editing a constants block, `grep -n "^[A-Z_]* = "` and
run a syntax probe before executing.
## 9. Delegated writes: verify bytes, never narrative
Measured in one campaign, all four failure modes live:
- A subagent killed by the runtime limit yielded a 495-line owner page, a verified era table
and a byte census — **the file did not exist** and its table was self-referential.
- Another wrote its output to disk and died without reporting, leaving a rewritten `log.md`;
the census showed it had dropped laws held nowhere else.
- Verdict tables claimed "0 sole records / no contradictions" while missing the contradiction
sitting in their own evidence row, and invented bullet IDs — one renamed a bullet to the
**opposite** claim.
- A scout called accurate docs "WRONG" because the docs said `wip: "down"`.
Rules: after ANY delegated write run `wc`/`ls` + the orphan census yourself; re-grep every
claimed holder before cutting a line; treat `cancelled`/timed-out yields as unverified no
matter how concrete they read; and re-read the source bullet before trusting a table's ID.
## 10. Merge order (a long-lived branch under a moving main)
1. Commit your work in atomic groups **before** merging (a dirty tree cannot merge cleanly). 1. Commit your work in atomic groups **before** merging (a dirty tree cannot merge cleanly).
2. Inspect the incoming commits first: `git log --oneline --stat HEAD..origin/main`. If they 2. Inspect the incoming commits: `git log --oneline --stat HEAD..origin/main`. If they modify
modify a page you deleted, **port their additions into the owner page**, then merge and a page you deleted, **port their additions into the owner page**, then merge and resolve
resolve the modify/delete as `git rm`. the modify/delete as `git rm`.
3. Resolve a content block with `write conflict://<id>` (`@ours` / `@theirs` / explicit text), 3. Resolve a content block with `write conflict://<id>` (`@ours` / `@theirs` / explicit text),
then **`git add` the file** — resolving content does not stage it, and `git commit` refuses then **`git add` the file** — resolving content does not stage it.
with "you have unmerged files". 4. **Re-probe live state after the merge, before you write any number.** A campaign that
4. `git * --no-verify` is forbidden here; commit hooks are fine. reported fleet shapes at the start was made stale by a mid-campaign reslice on main: probe
5. Sweep for dead paths left in `AGENTS.md`: `grep -oE "[A-Za-z0-9._-]+\.md" AGENTS.md | sort -u` `/slots` and `tr '\0' '\n' < /proc/$(pidof <engine>)/cmdline` again at the end, and let the
then `find` each. Expect false positives from names hard-wrapped across two lines — confirm live answer beat both your earlier probe and the docs.
each with `ls` before "fixing". 5. `git * --no-verify` is forbidden here. Sweep dead paths in `AGENTS.md`
(`grep -oE "[A-Za-z0-9._-]+\.md" AGENTS.md | sort -u`, then `find` each; hard-wrapped names
are false positives — confirm with `ls`).
## 9. Verify, open, land ## 11. Verify, open, land
- `./tests/run.sh` (full suite) + `bats tests/docs_completeness.bats` + - `./tests/run.sh` (full suite) + `python3 scripts/check-vault-links.py serverAppConfigs-docs`
`python3 scripts/check-vault-links.py serverAppConfigs-docs` → must print `0 unresolved links`. → must print `0 unresolved links`, and the OKF linter `0 violations`.
- Env trims: prove value-neutrality mechanically — `git show HEAD:scripts/<svc>.env | grep -E - Env trims: prove value-neutrality mechanically (`git show HEAD:scripts/<svc>.env | grep -E
'^[A-Z_]+=' | sort` vs the working copy, diff must be empty, then run that service's seam. '^[A-Z_]+=' | sort` vs working copy, diff empty). **`scripts/<svc>.env` is a deploy-planner
**Warn that `scripts/<svc>.env` is a deploy-planner path**: a comment-only trim makes the path**: a comment-only trim makes the next service-scoped deploy restart a healthy endpoint.
next service-scoped deploy restart a healthy endpoint for identical values.
- Open the PR through the API with a 600-perm curl config (managed skill - Open the PR through the API with a 600-perm curl config (managed skill
`gitea-api-pr-operations`), end the body with the `cwd:` / `session:` footer, then `gitea-api-pr-operations`), end the body with the `cwd:` / `session:` footer, then
`xdg-open <PR-URL>` the instant it exists. `xdg-open <PR-URL>` the instant it exists. Never merge without Frank's current explicit OK.
Push with an explicit refspec (`HEAD:refs/heads/<pr-branch>`) — a worktree's own branch name
is not the PR branch, and `git push origin HEAD` opens a stray branch.
- Docs-only change ⇒ **no deploy**. Fast-forward the deploy checkout only - Docs-only change ⇒ **no deploy**. Fast-forward the deploy checkout only
(`git -C ~/Projects/serverAppConfig merge --ff-only origin/main`). (`git -C ~/Projects/serverAppConfig merge --ff-only origin/main`).
- Say what was NOT done, in the PR body: soft lint tallies left (missing `## Citations`),
conflicts left standing, repo config that was never installed on a host.
## 12. Report line counts per area, never one net
"Compaction" measured against the merge-base **per area**: docs / `scripts/` / `AGENTS.md`.
A single headline number hides that tooling and prose move in opposite directions — one
campaign reported "−1,849 vault lines" while the vault was flat and the growth was the two
enforcement scripts that made the law mechanical. Expect a mature one-owner vault to land
**docs flat**: the reductions available are deletions of *restatement* (duplicated pages,
transient deploy chatter in `log.md`, `AGENTS.md` bullets whose numbers an endpoint page owns)
and they are roughly cancelled by the cost of the citations the verification pass adds. When
asked to shrink further, show the menu — drop the tools, drop the citations, or accept content
loss — instead of inventing cuts. `log.md` compaction rule that survived census: drop a line
only when it matches a transient shape (`/health`, `deployed`, `restart`, `systemctl start`)
**and** every fact token in it is held by an owner page; a token-held line can still be the
only record of *when* an incident happened.
@@ -1,4 +1,4 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792386375195 "lastConsolidatedAt": 1792401025778
} }
@@ -1,4 +1,4 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792398289746 "lastConsolidatedAt": 1792432172049
} }
@@ -1,6 +1,6 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792400553633, "lastConsolidatedAt": 1792400853634,
"lastResult": { "lastResult": {
"at": 1792393886960, "at": 1792393886960,
"sessions": 1, "sessions": 1,
@@ -1,4 +1,10 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792400552453 "lastConsolidatedAt": 1792437458615,
"lastResult": {
"at": 1792433135862,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
}
} }
@@ -0,0 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792437542473,
"lastResult": {
"at": 1792433353188,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
}
}
@@ -0,0 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792434572397,
"lastResult": {
"at": 1792432178813,
"sessions": 1,
"deltas": 2,
"model": "frank-zealot"
}
}
+5 -5
View File
@@ -158,13 +158,13 @@ providers:
# contextWindow is the PER-SLOT window: llama.cpp divides --ctx-size # contextWindow is the PER-SLOT window: llama.cpp divides --ctx-size
# across slots and answers a longer prompt with a 400, never a # across slots and answers a longer prompt with a 400, never a
# truncation, so this value must follow every reslice (live now: # truncation, so this value must follow every reslice (live now:
# 2 x 262,144 over exact f16 KV, 68afc6d). # 3 x 200,704 over f16 K + q8_0 V with the MTP drafter armed, ba69ea5).
contextWindow: 262144 contextWindow: 200704
# Prompt + budget must fit inside the slot window; thinking counts # Prompt + budget must fit inside the slot window; thinking counts
# against the budget. Compaction fires at 85 % of the window = # against the budget. Compaction fires at 85 % of the window =
# 222,822 est; worst-case prompt + budget ≈ 222,822 × 1.03 + # 170,598 est; worst-case prompt + budget ≈ 170,598 × 1.03 +
# 16,384 ≈ 245,891 ≤ 262,144. This endpoint is the fleet's # 16,384 ≈ 192,100 ≤ 200,704. This endpoint stays the fleet's
# compaction hub: the widest window in the fleet. # compaction hub: still the widest window in this file.
maxTokens: 16384 maxTokens: 16384
reasoning: true reasoning: true
input: [text, image] input: [text, image]
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200 interval_seconds = 1200
[wallpaper.default] [wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
[wallpaper.last] [wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
[wallpaper.monitors.DP-1] [wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
[wallpaper.monitors.DP-2] [wallpaper.monitors.DP-2]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg" 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" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
[wallpaper.monitors.eDP-1] [wallpaper.monitors.eDP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
[weather] [weather]
unit = "imperial" unit = "imperial"
+55 -205
View File
@@ -1,7 +1,7 @@
--- ---
type: reference type: reference
title: dankvoice title: dankvoice
description: omp extension that speaks assistant output with the fleet's cloned voices through frank-tts. description: Voice output ships as the dank/dankvoice package; this page keeps only the fleet integration facts and the cutover procedure.
tags: tags:
- omp - omp
- extension - extension
@@ -15,226 +15,76 @@ timestamp: 2026-10-19
# dankvoice # dankvoice
`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive Voice output lives in the **`dank/dankvoice` package**:
harness: `/say` opens the options menu, `/say <text>` speaks through the `pi install git:http://192.168.0.143:3000/dank/dankvoice.git`. This page owns
speakers and `/say on` narrates the assistant's thinking and final answer only fleet integration and the cutover. The package README
after every turn. The words are first rewritten into natural spoken prose by (<http://192.168.0.143:3000/dank/dankvoice>) owns the `/say` command
the **@tiny** role model (exactly what the built-in vocalizer's reference, the settings/env knob list, the segmentation state machine, the
`speech.enhanced` does), and everything is served by **frank-tts** enhancer port, and mood control — never duplicate those facts here.
(Dankclaw :7772, the `speech` model role in [agent/models.yml](/core/index.md)).
## Why it exists ## Fleet wiring
Nothing built into the harness can pronounce Frank's cloned voices. * **TTS endpoint** — resolved from the provider block whose model row carries
`api: openai-speech` in [agent/models.yml](/core/index.md):
**frank-tts** on Dankclaw `:7772`. LAN-only — no Caddy vhost fronts it.
* **Narration humanizer** — the harness `@tiny` role
(`modelRoles.tiny` in `agent/config.yml`), on this fleet **frank-zealot**
(Dankclaw `:7771`).
* **Hybrid-endpoint trap (load-bearing)** — a raw `fetch` to a
thinking-by-default model spends the token budget on `reasoning_content`
and answers `content: ""`. The package pins
`chat_template_kwargs: { enable_thinking: false }` on the rewrite call; do
not remove it. On a hybrid endpoint, empty content beside non-empty
`reasoning_content` means the flag was ignored — that is a failed rewrite
(fall back to the mechanical text), never the legitimate "nothing to say".
* **Voice contract** — omitting `voice` is legal: frank-tts fills its
`DEFAULT_VOICE` (2026-10-18 voice-pack work). `GET {base}/v1/audio/voices`
answers a bare JSON array; an unknown voice answers 400 with
`available_voices`, which the package uses as roster fallback. On this shim
`GET /v1/voices` and `GET /v1/models` are 404.
| Surface | Engine | Verdict | ## Cutover (Refs #34)
|---|---|---|
| TUI speak-aloud (`speech.enabled`, `speech.voice`) | bundled Kokoro ONNX worker, `synthesizeStream(voice, speed, signal)` | Kokoro voice enum only; never consults `models.yml` or the speech role |
| `omp say [TEXT]` | same local backend (`--model` = *local* model key) | `omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, af_bella, af_nicole, af_kore, af_sarah, am_michael, am_fenrir, am_puck, bf_emma, bm_george, bm_fable` (verified 2026-10-18) |
| `tts` tool / `xd://tts` device (`modelRoles.speech`) | frank-tts over OpenAI `/v1/audio/speech` | cloned voices work, but only as a **file** — nothing plays them |
`dankvoice` is not a patch to that vocalizer (harness code, not ours); it is a The `agent/extensions/dankvoice.ts` drop-in and its 26-test bats suite are
second transport that happens to speak, so Kokoro stays available for quick retired here (the package suite is a strict superset; test names ported 1:1).
local speech and the cloned voices carry the real narration. Per box:
## Commands 1. `pi install git:http://192.168.0.143:3000/dank/dankvoice.git`
2. `/say status`, then "Speak a test phrase" from the `/say` menu — the
audible proof a silent session needs.
3. Pull `main` (the drop-in disappears with it) and restart the session.
| Command | Effect | Menu choices survive the cutover: both transports persist to the same
|---|---| gitignored settings file, `~/.omp/cache/dankvoice.json`
| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, thinking-narration toggle, humanize toggle, test phrase | (`DANKVOICE_CONFIG` relocates it).
| `/say <text>` | synthesize once and play; interrupts whatever is playing |
| `/say on` \| `/say off` | arm/disarm auto-speak of the thinking blocks and final text at `agent_end`; the footer shows `🔊 <voice>` while armed |
| `/say voice <name>` | pick a voice; an empty name defers to the server's own default |
| `/say voices` | list what frank-tts reports |
| `/say stop` | cut the current utterance |
| `/say status` \| `/say help` | endpoint, voice, player, device, cap, thinking, humanize |
Menu choices persist to `~/.omp/cache/dankvoice.json` (runtime state, never ## Why not the built-in vocalizer
tracked); the environment overrides the file, the file overrides defaults.
"Speak a test phrase" synthesizes a short line through the current voice,
player and device — the audible proof whenever a session "has say on" but
stays silent.
Auto-speak is skipped in `print`/`json` mode so benches and pipelines stay The harness speak-aloud path (`speech.enabled` / `speech.voice`, `omp say`)
silent. Each spoken unit — thinking first when narrated, then the answer — synthesizes with bundled Kokoro ONNX against a **compile-time voice enum** —
is first rewritten into spoken prose by the @tiny model; when the enhancer cloned voices can never reach it, and no setting routes it to frank-tts.
(below) declines, the text goes to segmentation as-is, where fenced code and `dankvoice` is a second transport beside it, not a patch: Kokoro stays for
tables vanish, links collapse to their label and bullets and table pipes quick local speech, the package carries cloned-voice narration. The `tts`
disappear. A long answer is never one monolithic synthesis request; see tool / `xd://tts` device reach frank-tts too, but produce files, not audio
[Segmentation](#segmentation) for how utterances are cut and played. playback.
## Segmentation
Utterances are cut by a state machine ported from the harness's own
speak-aloud vocalizer (the minified `fHe` class in the agent bundle's
`dist/cli.js`), kept deliberately in lockstep with the stock path:
* A strong boundary is a run of `[.!?…]` followed by whitespace — so
`!!!!!` and `...` are one boundary, never a pile of cuts.
* Boundaries after `e.g.`, `etc.`, `Mr.` and friends, or inside inline
code, don't count; decimals (`3.5`, `1,5`) survive because a boundary
requires trailing whitespace.
* Line starts are classified: table rows and fenced code are swallowed
whole (their content is never read aloud), heading and bullet markers
vanish, ordered-list markers speak their number ("1, …"), quote markers
strip.
* Per segment, markdown is cleaned: links collapse to their label, URLs
to their host, deep paths to their last segment; emphasis markup dies.
* Segments aim for one sentence; run-ons break at commas or word wraps
before 280 characters.
Playback is strictly sequential: segment N+1 is synthesized only after
segment N's player process exited, so audio never stacks. The chain runs
detached from `agent_end` (fire-and-forget) and may legitimately outlive
the harness's 30 s handler timeout — the old awaiting design produced a
"handler timed out after 30000ms" toast on long answers. A new utterance,
`/say stop`, or disarming auto-speak retires the live chain through a run
token: the current player is killed and pending segments are skipped.
`DANKVOICE_MAX` is a per-answer character budget: the leading whole
segments that fit are spoken, the rest are dropped — the thinking unit
draws on it before the answer does.
## Endpoint resolution
The base URL is **read from `agent/models.yml`**, from the provider block whose
model row carries `api: openai-speech` — the same row the `speech` role
resolves, so no host address and no credential appears in this file. From
`baseUrl: http://192.168.0.143:7772/v1` it derives
`POST …/v1/audio/speech`, `GET …/v1/audio/voices` and `GET …/health`.
| Environment | Default | Meaning |
|---|---|---|
| `DANKVOICE_URL` | models.yml | speech base URL (`…/v1`), bypasses the YAML read |
| `DANKVOICE_MODEL` | the id in that block (`frank-tts`) | model named in the request body |
| `DANKVOICE_CONFIG` | `~/.omp/cache/dankvoice.json` | persisted menu choices (voice, player, sink, cap, auto) |
| `DANKVOICE_VOICE` | empty (server default) | default voice name |
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
| `DANKVOICE_MAX` | `700` | spoken-character budget per answer (whole leading segments, shared by the thinking and answer units) |
| `DANKVOICE_PLAYER` | detect | player binary, skipping detection |
| `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from |
| `DANKVOICE_REWRITE_URL` | the @tiny role | chat base URL (`…/v1`) for the enhancer, bypassing role resolution |
| `DANKVOICE_REWRITE_MODEL` | the @tiny role's model | chat model named in the rewrite body |
| `DANKVOICE_REWRITE=0` | enhancer on when a route resolves | speak mechanical text |
| `DANKVOICE_THINKING` | settings file, default on | `0`/`1` force thinking narration off/on |
| `DANKVOICE_OMPCONFIG` | `~/.omp/agent/config.yml` | YAML to read the `modelRoles.tiny` role from |
Player detection order is `pw-play` → `paplay` → `ffplay` (PipeWire first:
the workstations run WirePlumber). A saved output device routes the player
explicitly — `pw-play --target <sink>` or `paplay --device=<sink>`; ffplay
has no device concept and ignores it — while an unset device leaves the
player's own default. Sinks are enumerated with `wpctl list audio sinks`;
if wireplumber is unreachable the menu offers only "system default". Each
utterance is written to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus
its directory are removed when the player process exits.
## Humanized narration (the @tiny role)
The built-in vocalizer never reads answers aloud verbatim when
`speech.enhanced` is on: a small model rewrites each utterance into words a
person would say. `dankvoice` ports that enhancer — same system prompt
(verbatim from the bundle), same budgets (1536 max tokens, 6 s timeout,
4000-char input window), same contract (a failed rewrite falls back to the
mechanically cleaned text; an empty rewrite skips the unit as "nothing to
say"). The target model is the harness's `@tiny` role, resolved the way the
harness resolves it: `modelRoles.tiny` in `agent/config.yml` reads
`provider (host)/model:effort`; the provider block in `agent/models.yml`
supplies the `baseUrl`; the request is `POST {base}/chat/completions`. On
this fleet that is **frank-zealot** (`llamacpp (dankclaw-3090)`,
`:7771`) — the 6 s abort keeps a slow tiny model off the critical path.
Thinking blocks ride the same path: at `agent_end` the last assistant
message's true `thinking` parts are narrated (rewritten first, as their own
unit, before the answer), while blocks flagged `narration: true` — which
providers use to address the user through the thinking channel — count as
answer text. The menu, `/say status`, and `DANKVOICE_THINKING` toggle it;
the choice persists in the settings file.
## Verified facts (2026-10-18, extended 2026-10-19)
* **Extension discovery** — any `.ts` file in `~/.omp/agent/extensions/` loads
at startup: a probe file there printed `loaded 2026-10-19T02:36:55Z` from a
fresh `omp -p` run. `omp --help` documents `--no-extensions` (discovery off,
explicit `-e/--extension` and `--hook` still load) and `plugin install|link`.
* **Endpoint** — resolving the live `agent/models.yml` yields
`speechUrl http://192.168.0.143:7772/v1/audio/speech`,
`voicesUrl …/v1/audio/voices`, `healthUrl …/health`, `model frank-tts`.
* **Roster** — `GET /v1/audio/voices` answers the bare JSON array
`["narrator1","narrator2","narrator3"]`; `/health` answers
`{"status":"ok","backend":"omni-http","voices":[…]}`. `GET /v1/voices` and
`GET /v1/models` are **404** on this shim, and an unknown voice answers
`400 unknown voice 'zzz'; available voices: …` with `available_voices` in the
body — which is the fallback the extension uses if the roster route ever
disappears.
* **Omitting `voice` is legal** — frank-tts fills its `DEFAULT_VOICE` since the
2026-10-18 voice-pack work, so an unset voice synthesizes instead of failing.
* **Speech** — `/say "dankvoice live smoke test"` produced a 99,884 B / 2.08 s
WAV and `/say voice narrator2` + `/say "cloned voice check"` a 69,164 B /
1.44 s WAV (durations from `ffprobe`), each handed to the player; temp
directories were gone afterwards.
* **Auto-speak** — verified in a real TUI session: `/say on` armed the footer
(`🔊 narrator1`), the next finished turn synthesized and played the answer
(1.20 s WAV), and an unknown voice name answers with the roster instead of
speaking. An `agent_end` carrying `willContinue` (the harness scheduled an
auto-retry) is never narrated — only the terminal settle speaks.
* **Menu** — verified via driver scenarios 2026-10-18 (Refs #23): the menu
lists auto-speak/voice/device/player/cap/test-phrase; a voice chosen in the
menu is used by later utterances and survives a fresh load via the settings
file; a device chosen in the menu appears in the player invocation as
`--target <sink>`.
* **Segmented playback** — driver scenarios 2026-10-19: "First stop here.
Second one goes!!! Really? Third ... done" produced exactly 3 sequential
requests and 3 plays; "Model 3.5 ships today. Version 1,5 too." produced
exactly 2 (decimals never split); the markdown fixture's fenced code and
URL never reached the fixture endpoint; with `DANKVOICE_MAX=60` only the
leading segment was spoken.
* **Humanize + thinking (2026-10-19, Refs #27)** — driver scenarios cover the
rewrite round-trip (fixture chat route), the 404 → mechanical fallback,
thinking narrated as its own unit before the answer, `DANKVOICE_THINKING=0`
silencing it, and `narration: true` spoken once as the answer; the endpoint
status line shows the resolved humanize model from the @tiny role. Live
smoke against the real services: a markdown `/say` became 3 sequential
prose segments (mechanical cleanup of that text yields at most 2), and a
thinking+answer `agent_end` played thinking first. 22 green in
`tests/dankvoice.bats`.
## Limits ## Limits
* The TUI's own speak-aloud feature still narrates in Kokoro: its voice list is * Auto-speak narrates thinking and the final message only — mid-turn tool
a compile-time enum in the harness bundle, and no setting routes it to chatter is never spoken; skipped in `print`/`json` modes so benches stay
frank-tts. Cloned voices live only on this file-producing/speaking path and silent.
on the `tts` tool. See [Handy](/tools/handy.md) for the STT half. * frank-tts deployment, voice packs and the registry are owned by the
* `agent_end` narrates thinking blocks and the final message only — mid-turn `serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`).
tool chatter is never spoken. Thinking covers the last assistant message;
tool-loop gaps in earlier messages are skipped by design.
* Humanized narration is serial: audio starts only after the @tiny rewrite
returns (≤ 6 s per unit, then frank-tts synthesis). Say `/say off`-grade
latency is the price of prose; `DANKVOICE_REWRITE=0` trades it back for
instant mechanical speech.
* frank-tts is LAN-only: no Caddy vhost fronts :7772, so this extension is
usable on the home network.
* Deployment, packs and the voice registry are owned by the
`serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`), not
by this one.
## Citations ## Citations
* Live probes on the workstation, 2026-10-18: `omp say --voice narrator1 …`, * Package repo `dank/dankvoice` (README, `docs/decisions/`) — behaviour and
`curl http://192.168.0.143:7772/health`, `/v1/audio/voices`, knob truth.
`/v1/audio/…/speech` (invalid voice), `ffprobe` on the produced WAVs. * Gitea issues #21, #23, #27, #32 (drop-in history) and #34 (this cutover)
* `omp --help` (extension discovery flags, `say`, `plugin`) and on `dank/command-center`.
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts` * Live endpoint probes 2026-10-18/19 documented in those issues.
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
* The stock speak-aloud implementation — vocalizer pipeline `I6o`, utterance
segmenter `fHe` and speech enhancer `Qyr` with its prompt `T6o` — in
`@oh-my-pi/pi-coding-agent`'s `dist/cli.js`, read off the minified bundle
2026-10-19: dankvoice ports the mechanical and the enhanced halves, so an
LLM rewrite does happen before frank-tts.
* Gitea issue #21 on `dank/command-center` (the work item this page ships with).
* Gitea issue #23 on `dank/command-center` (the options-menu work item).
* Gitea issue #27 on `dank/command-center` (the enhanced-narration work item).
## See Also ## See Also
* [Core Documentation](/core/index.md) — the live harness configuration. * [Core Documentation](/core/index.md) — the live harness configuration.
* [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice. * [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice.
* `serverAppConfigs-docs/Dankclaw/Frank TTS.md` — the frank-tts service runbook.
+1 -1
View File
@@ -12,7 +12,7 @@ These files define the structural conventions that govern the agent system itsel
* [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior. * [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior.
* [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides. * [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides.
* [dankvoice](dankvoice.md) — Cloned-voice vocalization extension: `/say` and auto-speak over frank-tts, endpoint read from `agent/models.yml`. * [dankvoice](dankvoice.md) — Voice output is the `dank/dankvoice` package (`pi install git:`); this page keeps fleet wiring and the cutover procedure.
* [OMP Status Line Customization](statusline.md) — How presets, composer shapes, and segment arrays actually interact; the fleet's tuned bar. (moved from /pi/, 2026-10-19) * [OMP Status Line Customization](statusline.md) — How presets, composer shapes, and segment arrays actually interact; the fleet's tuned bar. (moved from /pi/, 2026-10-19)
* [OMP Subagent Limits & Fan-out Policy](subagent-limits.md) — Live caps: concurrency 2, no nested spawns (the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy. (moved from /pi/, 2026-10-19) * [OMP Subagent Limits & Fan-out Policy](subagent-limits.md) — Live caps: concurrency 2, no nested spawns (the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy. (moved from /pi/, 2026-10-19)
+21
View File
@@ -16,6 +16,27 @@ lesson, a few lines. Full incident narratives are recoverable from git
(`git log --follow omp-docs/log.md`); knowledge that outlives an entry lives (`git log --follow omp-docs/log.md`); knowledge that outlives an entry lives
on its concept page, linked here. Never re-expand an entry in place. on its concept page, linked here. Never re-expand an entry in place.
## 2026-10-19 — dankvoice cut over to the `dank/dankvoice` package (Refs #34)
Drop-in `agent/extensions/dankvoice.ts` and its 26-test suite retired; the
package carries the behaviour (41/41 black-box suite) and is installed with
`pi install git:`. Law: install and prove `/say status` speaks on a box
*before* pulling the cutover commit — the shared settings file keeps menu
state across both transports, so the swap is stateless once installed.
## 2026-10-19 — dankvoice: silence and timeouts, root-caused (Refs #32)
The humanizer's raw `fetch` to frank-zealot omitted `chat_template_kwargs.
enable_thinking: false`, so the Qwen35 hybrid *thought* instead of answering:
6.9 s of `reasoning_content`, `content: ""` — timing out the rewrite twice
(12 s dead air) and, when it did answer inside the window, speaking silence.
Root-caused by replaying the exact wire body against the live endpoint.
Fix: pin the kwarg in the request body; treat empty-content-with-reasoning as
failure (mechanical voice), not "nothing worth saying"; the menu's test phrase
fires without blocking its handler. Lesson: the harness injects thinking-off
for managed roles, but raw HTTP callers must set it themselves — 26/26 green
with three red-captured regressions.
## 2026-10-19 — dankvoice: humanized narration and thinking aloud (Refs #27) ## 2026-10-19 — dankvoice: humanized narration and thinking aloud (Refs #27)
Spoken units are rewritten by the `@tiny` role (frank-zealot) under the Spoken units are rewritten by the `@tiny` role (frank-zealot) under the
+22 -1
View File
@@ -8,7 +8,7 @@ tags:
- documentation - documentation
- best-practices - best-practices
- unverified - unverified
timestamp: 2026-01-15 timestamp: 2026-10-19
--- ---
# OKF Bundle Maintenance # OKF Bundle Maintenance
@@ -139,6 +139,27 @@ git status --short # What is uncommitted
| `index.md` count differs from file count | Always | Fix immediately | | `index.md` count differs from file count | Always | Fix immediately |
| `log.md` has no entries for 30+ days | Context-dependent | Normal if no changes made | | `log.md` has no entries for 30+ days | Context-dependent | Normal if no changes made |
---
## Automated Gates (this bundle)
Two mechanical checkers live in `scripts/`; both exit non-zero on violations.
Run both before committing any `omp-docs/` change; `tests/kb-lint.bats` pins
their behavior.
```bash
bash scripts/kb-lint.sh omp-docs # OKF conformance
python3 scripts/kb-coverage.py omp-docs # index coverage doctrine
```
- **kb-lint** — OKF v0.1 rules: non-empty `type` frontmatter on every
non-index file, no frontmatter on `index.md`, resolvable wikilinks and
relative links, reserved names (`index.md`/`log.md`), `YYYY-MM-DD` log
headings.
- **kb-coverage** — the AGENTS.md anti-rot mechanism: every directory has an
`index.md`, and that index links every sibling file and subdirectory.
--- ---
## See Also ## See Also
File renamed without changes.
-419
View File
@@ -1,419 +0,0 @@
#!/usr/bin/env bats
# tests/dankvoice.bats — black-box tests for agent/extensions/dankvoice.ts
#
# The extension's contract, from outside: it registers a `say` command and an
# `agent_end` listener; what is observable is (a) the JSON it POSTs to the
# speech route, (b) the file it hands to the player, and (c) the notifications
# and select-lists it renders. A fixture HTTP server stands in for frank-tts
# (it answers the speech route with a fixed WAV, the roster route with a JSON
# array, and everything else with 404), a stub player stands in for pw-play
# (it records its full argv and copies the .wav it was given), and a fixture
# `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration.
#
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
# name the fixture wpctl reports), DV_MODELS (models.yml fixture),
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
# DV_SELECT (pipe-separated answers fed to ctx.ui.select in order), DV_MODE
# (the harness mode reported to the extension).
setup() {
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
WORK="$(mktemp -d)"
export DV_EXT="$REPO/agent/extensions/dankvoice.ts"
export DV_STUB="$WORK/player.sh"
export DV_STUB_LOG="$WORK/player.log"
export DV_COPY="$WORK/played.wav"
export DV_EMPTY_BIN="$WORK/emptybin"
mkdir -p "$DV_EMPTY_BIN"
cat > "$DV_STUB" <<'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 0.1
SH
chmod +x "$DV_STUB"
# the same stub reachable under the canonical player names, plus a fixture
# wpctl so output-device enumeration is deterministic on any machine
mkdir -p "$WORK/bin"
ln -s "$DV_STUB" "$WORK/bin/pw-play"
export DV_PWPLAY="$WORK/bin/pw-play"
export DV_WPCTL="alsa_output.usb-KT.0"
cat > "$WORK/bin/wpctl" <<'WPC'
#!/usr/bin/env bash
printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL"
WPC
chmod +x "$WORK/bin/wpctl"
# settings live in an isolated file so menu choices never leak between tests
export DANKVOICE_CONFIG="$WORK/settings.json"
# models.yml fixture: a chat provider first, the speech provider second
export DV_MODELS="$WORK/models.yml"
cat > "$DV_MODELS" <<'YML'
providers:
"chat (box)":
baseUrl: http://10.0.0.1:9999/v1
models:
- id: chatmodel
api: openai-completions
"speech (box)":
baseUrl: http://127.0.0.1:1/v1
auth: none
models:
- id: speechmodel
api: openai-speech
YML
# omp config.yml fixture: the @tiny role points at the chat provider above
export DV_OMPCONFIG="$WORK/ompconfig.yml"
cat > "$DV_OMPCONFIG" <<'CFY'
modelRoles:
tiny: chat (box)/chatmodel:off
CFY
cat > "$WORK/driver.ts" <<'TS'
// Black-box driver: stand up the frank-tts fixture, load the extension against
// it, invoke one scenario, print what the endpoint received and what got played.
import { createServer } from "node:http";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname } from "node:path";
const scenario = process.argv[2] ?? "speak";
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
const received: Array<{ url: string | undefined; body: string }> = [];
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
received.push({ url: req.url, body });
if (req.url?.endsWith("/audio/voices")) {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(["narrator1", "narrator2"]));
return;
}
if (req.url?.endsWith("/audio/speech")) {
res.setHeader("content-type", "audio/wav");
res.end(AUDIO);
return;
}
if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) {
res.setHeader("content-type", "application/json");
const spoken = process.env.DV_REWRITE === "echo"
? String(JSON.parse(body).messages?.[1]?.content ?? "")
: "Spoken, humanized words.";
res.end(JSON.stringify({ choices: [{ message: { content: spoken } }] }));
return;
}
res.statusCode = 404;
res.end("{}");
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
if (scenario === "endpoint") {
process.env.DANKVOICE_MODELS = process.env.DV_MODELS;
} else {
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
}
process.env.DANKVOICE_OMPCONFIG = process.env.DV_OMPCONFIG;
if (scenario !== "endpoint") process.env.DANKVOICE_REWRITE_URL = `http://127.0.0.1:${port}/v1`;
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
else process.env.PATH = `${dirname(process.env.DV_STUB ?? ".")}/bin:${process.env.PATH ?? ""}`;
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_PLAYER_BIN ?? process.env.DV_STUB;
const extension = await import(process.env.DV_EXT as string);
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
const events = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
extension.default({
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => events.set(event, handler),
});
const answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== "");
let answerIndex = 0;
const ctx = {
mode: process.env.DV_MODE ?? "tui",
ui: {
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
select: (title: string, options: string[]) => {
console.log(`SELECT ${title} ${options.join("|")}`);
const answer = answerIndex < answers.length ? answers[answerIndex] : undefined;
answerIndex += 1;
return Promise.resolve(answer ?? options[0]);
},
},
};
const say = commands.get("say");
const agentEnd = events.get("agent_end");
const answer = (text: string) => ({ messages: [{ role: "assistant", content: [{ type: "text", text }] }] });
const settle = () => new Promise((resolve) => setTimeout(resolve, 800));
if (!say) {
console.log("COMMANDS none");
} else {
console.log(`COMMANDS ${[...commands.keys()].join(",")} EVENTS ${[...events.keys()].join(",")}`);
switch (scenario) {
case "speak":
await say.handler("hello world", ctx);
break;
case "voice":
await say.handler("voice narrator2", ctx);
await say.handler("good morning", ctx);
break;
case "menu":
await say.handler("menu", ctx);
break;
case "empty":
await say.handler("", ctx);
break;
case "devicemenu":
await say.handler("menu", ctx);
await say.handler("hello world", ctx);
break;
case "auto":
await say.handler("on", ctx);
await agentEnd?.(answer("the answer"), ctx);
await settle();
await say.handler("off", ctx);
await agentEnd?.(answer("the second answer"), ctx);
break;
case "continuation":
await say.handler("on", ctx);
await agentEnd?.({ ...answer("not yet"), willContinue: true }, ctx);
await settle();
await agentEnd?.(answer("the settled answer"), ctx);
break;
case "printmode":
await say.handler("on", ctx);
await agentEnd?.(answer("quiet"), ctx);
break;
case "voices":
await say.handler("voices", ctx);
break;
case "endpoint":
await say.handler("status", ctx);
break;
case "markdown":
await say.handler("## Title\n\nUse `pw-play` here.\n\n```bash\nnpm i x\n```\n\nsee [docs](http://example.test) and **bold**\n- a\n- b", ctx);
break;
case "truncate":
await say.handler("Sentence one is here. ".repeat(40), ctx);
break;
case "sentences":
await say.handler("First stop here. Second one goes!!! Really? Third ... done", ctx);
break;
case "decimals":
await say.handler("Model 3.5 ships today. Version 1,5 too.", ctx);
break;
case "rewrite":
await say.handler("The deploy failed because a secret was missing.", ctx);
break;
case "thinking":
await say.handler("on", ctx);
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Considering the wiring." }, { type: "text", text: "The answer is nine." }] }] }, ctx);
break;
case "narration":
await say.handler("on", ctx);
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx);
break;
case "noplayer":
await say.handler("speak anyway", ctx);
break;
}
await settle();
}
for (const call of received) {
const parsed: unknown = JSON.parse(call.body === "" ? "{}" : call.body);
const field = (key: string): string => {
if (parsed && typeof parsed === "object" && key in parsed) {
const value = parsed[key];
if (typeof value === "string") return value;
}
return "-";
};
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}`);
}
console.log(`CALLS ${received.filter((call) => call.url?.endsWith("/audio/speech")).length}`);
const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`);
if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
server.close();
TS
export DV_DRIVER="$WORK/driver.ts"
}
teardown() {
rm -rf "$WORK"
}
say_run() {
run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1"
}
@test "the extension registers the say command and an agent_end listener" {
say_run speak
[ "$status" -eq 0 ]
[[ "$output" == *"COMMANDS say EVENTS agent_end"* ]]
}
@test "speaking posts the text to the speech route" {
say_run speak
[ "$status" -eq 0 ]
[[ "$output" == *"CALL /v1/audio/speech model=frank-tts format=wav voice=- input=hello world"* ]]
}
@test "one utterance is played once, as exactly the bytes the endpoint sent" {
say_run speak
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"COPIED 24"* ]]
}
@test "the voice is named in the request only when the user chose one" {
say_run speak
[[ "$output" == *"voice=- input=hello world"* ]]
say_run voice
[[ "$output" == *"voice=narrator2 input=good morning"* ]]
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
}
@test "auto-speak narrates the final answer once and stays silent after it is disarmed" {
say_run auto
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"input=the answer"* ]]
[[ "$output" != *"the second answer"* ]]
[[ "$output" == *"STATUS dankvoice 🔊"* ]]
}
@test "auto-speak stays silent outside the interactive modes" {
DV_MODE=print say_run printmode
[[ "$output" == *"PLAYS 0"* ]]
[[ "$output" != *"input=quiet"* ]]
}
@test "the voice list comes from the endpoint roster route" {
say_run voices
[[ "$output" == *"CALL /v1/audio/voices"* ]]
[[ "$output" == *"dankvoice voices: narrator1, narrator2"* ]]
}
@test "the endpoint is taken from the speech row of the models file, never the chat provider" {
say_run endpoint
[[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]]
[[ "$output" == *"humanize chatmodel"* ]]
[[ "$output" != *"10.0.0.1"* ]]
}
@test "markdown never reaches the speech engine" {
say_run markdown
[[ "$output" == *"input=Title"* ]]
[[ "$output" == *"input=Use pw-play here."* ]]
[[ "$output" == *"input=see docs and bold"* ]]
[[ "$output" != *"npm i x"* ]]
[[ "$output" != *"example.test"* ]]
}
@test "the spoken budget keeps only the leading whole segments" {
DANKVOICE_MAX=60 say_run truncate
[[ "$output" == *"CALLS 1"* ]]
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"input=Sentence one is here."* ]]
}
@test "a long answer is spoken as sequential sentence-sized requests" {
say_run sentences
[[ "$output" == *"CALLS 3"* ]]
[[ "$output" == *"PLAYS 3"* ]]
[[ "$output" == *"input=First stop here."* ]]
[[ "$output" == *"input=Second one goes!!! Really?"* ]]
[[ "$output" == *"input=Third ... done"* ]]
}
@test "decimals and comma-decimals never split a sentence" {
say_run decimals
[[ "$output" == *"CALLS 2"* ]]
[[ "$output" == *"input=Model 3.5 ships today."* ]]
[[ "$output" == *"input=Version 1,5 too."* ]]
}
@test "an answer is rewritten into spoken prose before synthesis" {
DV_REWRITE=prose say_run rewrite
[[ "$output" == *"CALL /v1/chat/completions"* ]]
[[ "$output" == *"input=Spoken, humanized words."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "a failed rewrite speaks the mechanical text unchanged" {
say_run speak
[[ "$output" == *"input=hello world"* ]]
}
@test "thinking is narrated before the answer as its own segment" {
DV_REWRITE=echo say_run thinking
[[ "$output" == *"input=Considering the wiring."* ]]
[[ "$output" == *"input=The answer is nine."* ]]
[[ "$output" == *"CALLS 2"* ]]
local first second
first=$(grep -n "input=Considering the wiring\." <<<"$output" | head -1 | cut -d: -f1)
second=$(grep -n "input=The answer is nine\." <<<"$output" | head -1 | cut -d: -f1)
[ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ]
}
@test "thinking narration can be switched off" {
DANKVOICE_THINKING=0 DV_REWRITE=echo say_run thinking
[[ "$output" == *"input=The answer is nine."* ]]
[[ "$output" != *"input=Considering the wiring."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "narration-flagged thinking is spoken once as the answer" {
DV_REWRITE=echo say_run narration
[[ "$output" == *"input=Here is the result."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "a missing player is reported, not swallowed" {
say_run noplayer
[[ "$output" == *"NOTIFY error dankvoice: no audio player found"* ]]
[[ "$output" == *"PLAYS 0"* ]]
}
@test "a scheduled continuation is never narrated as the final answer" {
say_run continuation
[[ "$output" == *"PLAYS 1"* ]]
[[ "$output" == *"input=the settled answer"* ]]
[[ "$output" != *"input=not yet"* ]]
}
@test "the menu lists every knob and a plain /say opens it" {
say_run menu
[[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Thinking narration: on (toggle)|Humanize answers: on (tiny) (toggle)|Speak a test phrase"* ]]
[[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]]
say_run empty
[[ "$output" == *"SELECT dankvoice — options"* ]]
}
@test "a voice picked from the menu is spoken by every later request" {
say_run menu "Voice: server default (change)|narrator2"
[[ "$output" == *"SELECT dankvoice — voice server default|narrator1|narrator2"* ]]
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
say_run speak
[[ "$output" == *"voice=narrator2 input=hello world"* ]]
}
@test "a device chosen from the menu becomes the player's output target" {
DV_PLAYER_BIN="$DV_PWPLAY" say_run menu "Output device: system default (change)|$DV_WPCTL"
[[ "$output" == *"SELECT dankvoice — output device system default|alsa_output.pci-0000_07_00.6.HiFi__Speaker__sink|$DV_WPCTL"* ]]
[[ "$output" == *"NOTIFY info dankvoice: output device = $DV_WPCTL"* ]]
DV_PLAYER_BIN="$DV_PWPLAY" say_run speak
[[ "$output" == *"PLAYER --target $DV_WPCTL"* ]]
}