Compare commits

..
Author SHA1 Message Date
Frank ac5b377750 live: heartbeat sync 2026-10-19T10:17:52-07:00 (Refs #11) 2026-10-19 10:17:52 -07:00
9 changed files with 55 additions and 172 deletions

No files matched your search

+10 -42
View File
@@ -576,18 +576,7 @@ export function resolveRewriter(env: NodeJS.ProcessEnv, modelsYml: string, confi
return resolveTinyRewriter(modelsYml, configYml); return resolveTinyRewriter(modelsYml, configYml);
} }
/** /** The enhancer's rewrite step; never throws — null is its "couldn't". */
* The enhancer's rewrite step; never throws — null is its "couldn't".
*
* The `chat_template_kwargs.enable_thinking: false` field is load-bearing.
* The @tiny fleet model (frank-zealot, a Qwen35 hybrid) defaults to a
* reasoning pass that fills `reasoning_content` and leaves `content` empty.
* The harness injects this flag for the built-in vocalizer; a raw POST does
* not get it, so without it the rewrite spends its whole 6 s budget thinking
* (blowing the timeout → mechanical fallback) and, when it answers in time,
* returns an empty `content` that would mute the utterance. Measured: 6.9 s /
* empty content without the flag, 0.55 s / clean prose with it.
*/
export async function rewriteForSpeech(target: RewriteTarget, text: string): Promise<string | null> { export async function rewriteForSpeech(target: RewriteTarget, text: string): Promise<string | null> {
try { try {
const res = await fetch(target.chatUrl, { const res = await fetch(target.chatUrl, {
@@ -600,7 +589,6 @@ export async function rewriteForSpeech(target: RewriteTarget, text: string): Pro
{ role: "user", content: text.slice(0, REWRITE_MAX_INPUT) }, { role: "user", content: text.slice(0, REWRITE_MAX_INPUT) },
], ],
max_tokens: REWRITE_MAX_TOKENS, max_tokens: REWRITE_MAX_TOKENS,
chat_template_kwargs: { enable_thinking: false },
}), }),
signal: AbortSignal.timeout(REWRITE_TIMEOUT_MS), signal: AbortSignal.timeout(REWRITE_TIMEOUT_MS),
}); });
@@ -612,33 +600,20 @@ export async function rewriteForSpeech(target: RewriteTarget, text: string): Pro
const message: unknown = first.message; const message: unknown = first.message;
if (!message || typeof message !== "object" || !("content" in message)) return null; if (!message || typeof message !== "object" || !("content" in message)) return null;
const content: unknown = message.content; const content: unknown = message.content;
const spoken = typeof content === "string" ? content.trim() : Array.isArray(content) ? textParts(content) : null; if (typeof content === "string") return content.trim();
if (spoken === null) return null; if (!Array.isArray(content)) return null;
if (spoken === "") { const parts: string[] = [];
// A reasoning-only reply means the endpoint ignored the thinking-off for (const part of content) {
// flag: the prose landed in reasoning_content, not content. That is a if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
// failed rewrite (fall back to mechanical text), NOT the enhancer's parts.push(part.text);
// legitimate "" ("nothing worth saying") — which has no reasoning. }
const reasoning = "reasoning_content" in message ? message.reasoning_content : undefined;
if (typeof reasoning === "string" && reasoning.trim().length > 0) return null;
} }
return spoken; return parts.join(" ").trim();
} catch { } catch {
return null; return null;
} }
} }
/** Concatenated text of a content-part array; undefined shape handled by caller. */
function textParts(content: readonly unknown[]): string {
const parts: string[] = [];
for (const part of content) {
if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
parts.push(part.text);
}
}
return parts.join(" ").trim();
}
/** The text the assistant last actually said; tool-only messages do not count. */ /** The text the assistant last actually said; tool-only messages do not count. */
export function lastAssistantText(messages: readonly unknown[]): string { export function lastAssistantText(messages: readonly unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
@@ -1068,14 +1043,7 @@ export default function dankvoice(pi: ExtensionAPI) {
persistSettings(ctx); persistSettings(ctx);
return; return;
} }
// Fire-and-forget on purpose: awaiting the playback chain here blocks await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`);
// the menu handler for the whole phrase and the harness reports the
// command as timed out. The point of the test is to hear it, not to
// block the menu on it.
ctx.ui.notify("dankvoice: speaking the test phrase", "info");
void speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`).catch((error) =>
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"),
);
} }
pi.registerCommand("say", { pi.registerCommand("say", {
@@ -1,36 +1,35 @@
--- ---
name: docs-dedupe-verify-campaign name: docs-dedupe-verify-campaign
description: "Run an exhaustive docs dedupe + fact-verification campaign in dank/command-center: scout cluster ledgers, evidence-overridden merges, live fleet probes, log compaction law, and landing via Gitea PR." 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."
--- ---
Campaign procedure for deduping + fact-checking `omp-docs/` in dank/command-center (proven on Refs #29, 2026-10-19: 8 commits, -2,229 lines, 0 lint/coverage/bats failures). # Exhaustive docs dedupe + verification campaign (dank/command-center)
## 1. Recon ## Frame
- Fresh branch off `origin/main` in a new worktree (`git worktree add ~/.omp/wt/<name> -b docs/<issue>-<slug> origin/main`); the session worktree's `git checkout -b` is policy-blocked. - 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; write a throwaway index-coverage checker (each dir's `index.md` must link every subdir/file) — mechanical, no scout needed. - 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).
- Open the Gitea issue first (multi-moving-parts law). - 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>`.
## 2. Scout fan-out (overlap detection) ## Scout fleet (overlap detection)
- One `task` batch, `agent: scout`, one task per doc cluster + one cross-cutting sweep (cap 2 concurrent; rest queue). - 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.
- Each scout writes a ledger to `local://dedupe/<cluster>.md`: inventory table (file, lines, purpose, verdict KEEP/MERGE/DELETE/UPDATE), overlaps, facts-to-keep ledger, inbound links, staleness, claims-needing-verification. - 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 are LEADS, not truth. Observed failures: fabricated ledger entries (claims about text that does not exist in any file), a DELETE verdict on a generated artifact that is under test (`tools/niri/keybindings.md` is generated by `scripts/gen-keybinds.sh` and tested by `tests/gen-keybinds.bats` — keep), and a KEEP based on misreading a changelog leftover. Re-derive every consequential verdict from the files/repo yourself. - **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.
## 3. Verify fleet facts live (never trust doc dates) ## Fact verification
- Read-only BatchMode probes: `ssh -o BatchMode=yes dankclaw '...'` (podman ps/inspect, systemctl status, curl localhost, os-release, cpuinfo). `sudo -n` only has systemctl verbs — use `podman exec <ctr> grep/cat` for in-container files. - 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).
- Update docs with dated drift banners ("probed 2026-10-19") for policy divergence; NEVER silently re-apply a policy — flag as operator decision (e.g. SearXNG engine policy). - 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.
## 4. Verify external claims via direct web research (no subagents) ## Log compaction (log.md)
- Version/protocol/benchmark claims: primary source only (upstream README, raw.githubusercontent, /api/v1/version, arxiv). If a number cannot be re-attributed to a published benchmark, drop it with an explicit note in the doc. - 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.
- Fabrication hunt: invented distros/projects/model names (found: "FreeToken" distro, ornith-llama-cpp-setup tree, Intel CPU on an AMD box) — delete the artifact, not just the line.
## 5. Log compaction law ## Gates + landing
- Changelog entry = date + what changed + durable lesson + concept-page pointer, few lines. Full narratives live in git (`git log --follow`). Record the law at the top of `log.md`. Never re-expand an entry in place. Keep pi-era entries tagged "(pi era)". - 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.
## 6. Gates + landing ## Line-edit op syntax trap (bit three times)
- Gate every commit: `bash scripts/kb-lint.sh omp-docs` + index-coverage + `bats tests/` (67 tests). Pre-commit hook prints `error: command not found: caddy` on Caddyfile-touching commits — warning only, commit still lands. - 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).
- Gitea API auth: `~/.config/secrets/gitea` is **URL-form** (`http://dank:<token>@host`), not a raw token. Extract: `T=$(sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p' ~/.config/secrets/gitea | head -1)`; raw `cat` as bearer token → 401. - 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.
- PR body ends with `---` + `cwd: <wd> · session: <id>` footer; then `xdg-open <PR-URL>` immediately; comment the campaign ledger summary on the issue. NEVER merge without Frank's explicit OK.
## Edit-tool field notes (recurring traps)
- `PUT N+=:` is invalid syntax — it inserts the literal text. One insert-op per edit call; recompute line numbers per call.
- File hash tag changes after every edit — re-`read` before each subsequent edit call to the same file; reusing a stale tag is rejected.
@@ -1,8 +1,8 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792426412619, "lastConsolidatedAt": 1792430072641,
"lastResult": { "lastResult": {
"at": 1792401456699, "at": 1792429115686,
"sessions": 1, "sessions": 1,
"deltas": 1, "deltas": 1,
"model": "frank-zealot" "model": "frank-zealot"
@@ -1,6 +1,6 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792426569902, "lastConsolidatedAt": 1792430229908,
"lastResult": { "lastResult": {
"at": 1792402864677, "at": 1792402864677,
"sessions": 1, "sessions": 1,
@@ -1,8 +1,8 @@
{ {
"v": 1, "v": 1,
"lastConsolidatedAt": 1792426408958, "lastConsolidatedAt": 1792430069015,
"lastResult": { "lastResult": {
"at": 1792401394555, "at": 1792428814873,
"sessions": 1, "sessions": 1,
"deltas": 1, "deltas": 1,
"model": "frank-zealot" "model": "frank-zealot"
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200 interval_seconds = 1200
[wallpaper.default] [wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.jpg"
[wallpaper.last] [wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.jpg"
[wallpaper.monitors.DP-1] [wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_y8ewzk.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.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_ymp267.jpg" path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_g7r3vd.jpg"
[weather] [weather]
unit = "imperial" unit = "imperial"
-18
View File
@@ -142,15 +142,6 @@ supplies the `baseUrl`; the request is `POST {base}/chat/completions`. On
this fleet that is **frank-zealot** (`llamacpp (dankclaw-3090)`, this fleet that is **frank-zealot** (`llamacpp (dankclaw-3090)`,
`:7771`) — the 6 s abort keeps a slow tiny model off the critical path. `:7771`) — the 6 s abort keeps a slow tiny model off the critical path.
The request body pins `chat_template_kwargs: { enable_thinking: false }`,
and that field is load-bearing: the harness injects it for managed roles,
but a raw `fetch` gets the model's default — thinking on — so the Qwen35
hybrid spends the budget on `reasoning_content` and answers `content: ""`
(measured: 6.9 s / 6893 reasoning chars / empty content without the flag;
0.55 s / 369 clean chars with it). For a hybrid endpoint, empty content
beside non-empty `reasoning_content` means the flag was ignored — a failed
rewrite (mechanical fallback), never the legitimate "nothing to say".
Thinking blocks ride the same path: at `agent_end` the last assistant Thinking blocks ride the same path: at `agent_end` the last assistant
message's true `thinking` parts are narrated (rewritten first, as their own message's true `thinking` parts are narrated (rewritten first, as their own
unit, before the answer), while blocks flagged `narration: true` — which unit, before the answer), while blocks flagged `narration: true` — which
@@ -205,14 +196,6 @@ the choice persists in the settings file.
prose segments (mechanical cleanup of that text yields at most 2), and a prose segments (mechanical cleanup of that text yields at most 2), and a
thinking+answer `agent_end` played thinking first. 22 green in thinking+answer `agent_end` played thinking first. 22 green in
`tests/dankvoice.bats`. `tests/dankvoice.bats`.
* **Thinking-off fix (2026-10-19, Refs #32)** — replaying the exact rewrite
body against live frank-zealot: without `chat_template_kwargs.
enable_thinking: false` the reply arrives as 6893 `reasoning_content`
characters with `content: ""` in 6.9 s (twice the 6 s abort); with it,
369 clean characters in 0.55 s. Driver fixtures now pin the flag
(`thinkoff=false` on the recorded chat call), turn reasoning-only replies
into mechanical speech instead of silence, and time the menu's test
phrase (~0 ms handler). 26 green in `tests/dankvoice.bats`.
## Limits ## Limits
@@ -249,7 +232,6 @@ the choice persists in the settings file.
* Gitea issue #21 on `dank/command-center` (the work item this page ships with). * 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 #23 on `dank/command-center` (the options-menu work item).
* Gitea issue #27 on `dank/command-center` (the enhanced-narration work item). * Gitea issue #27 on `dank/command-center` (the enhanced-narration work item).
* Gitea issue #32 on `dank/command-center` (the latency/silence fix work item).
## See Also ## See Also
-13
View File
@@ -16,19 +16,6 @@ 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: 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
+11 -64
View File
@@ -14,9 +14,8 @@
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink # (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
# name the fixture wpctl reports), DV_MODELS (models.yml fixture), # name the fixture wpctl reports), DV_MODELS (models.yml fixture),
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override), # DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
# DV_MODE (the harness mode reported to the extension), DV_REWRITE (fixture # DV_SELECT (pipe-separated answers fed to ctx.ui.select in order), DV_MODE
# behavior for the humanizer route: prose | echo | reasoning | stall, with # (the harness mode reported to the extension).
# DV_STALL_MS).
setup() { setup() {
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
@@ -32,10 +31,8 @@ setup() {
cat > "$DV_STUB" <<'SH' cat > "$DV_STUB" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
printf '%s\n' "$*" >> "$DV_STUB_LOG" printf '%s\n' "$*" >> "$DV_STUB_LOG"
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'START %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
sleep "${DV_HOLD:-0.1}" sleep 0.1
if [ -n "${DV_TIMING_LOG:-}" ]; then printf 'END %s\n' "$(date +%s%3N)" >> "$DV_TIMING_LOG"; fi
SH SH
chmod +x "$DV_STUB" chmod +x "$DV_STUB"
@@ -87,21 +84,14 @@ import { dirname } from "node:path";
const scenario = process.argv[2] ?? "speak"; 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 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 started = Date.now(); const received: Array<{ url: string | undefined; body: string }> = [];
interface Call {
url: string | undefined;
body: string;
at: number;
}
const received: Call[] = [];
const server = createServer((req, res) => { const server = createServer((req, res) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => { req.on("end", () => {
const at = Date.now() - started;
const body = Buffer.concat(chunks).toString("utf8"); const body = Buffer.concat(chunks).toString("utf8");
received.push({ url: req.url, body, at }); received.push({ url: req.url, body });
if (req.url?.endsWith("/audio/voices")) { if (req.url?.endsWith("/audio/voices")) {
res.setHeader("content-type", "application/json"); res.setHeader("content-type", "application/json");
res.end(JSON.stringify(["narrator1", "narrator2"])); res.end(JSON.stringify(["narrator1", "narrator2"]));
@@ -113,18 +103,11 @@ const server = createServer((req, res) => {
return; return;
} }
if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) { if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) {
const respond = () => { res.setHeader("content-type", "application/json");
res.setHeader("content-type", "application/json"); const spoken = process.env.DV_REWRITE === "echo"
const spoken = process.env.DV_REWRITE === "echo" ? String(JSON.parse(body).messages?.[1]?.content ?? "")
? String(JSON.parse(body).messages?.[1]?.content ?? "") : "Spoken, humanized words.";
: "Spoken, humanized words."; res.end(JSON.stringify({ choices: [{ message: { content: spoken } }] }));
const verdict = process.env.DV_REWRITE === "reasoning"
? { choices: [{ message: { content: "", reasoning_content: "Long internal deliberation that never reaches the reply field." } }] }
: { choices: [{ message: { content: spoken } }] };
res.end(JSON.stringify(verdict));
};
if (process.env.DV_REWRITE === "stall") setTimeout(respond, Number(process.env.DV_STALL_MS ?? 200));
else respond();
return; return;
} }
res.statusCode = 404; res.statusCode = 404;
@@ -236,12 +219,6 @@ if (!say) {
await say.handler("on", ctx); 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); await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Considering the wiring." }, { type: "text", text: "The answer is nine." }] }] }, ctx);
break; break;
case "testphrase": {
const startedAt = Date.now();
await say.handler("menu", ctx);
console.log(`HANDLER_MS ${Date.now() - startedAt}`);
break;
}
case "narration": case "narration":
await say.handler("on", ctx); await say.handler("on", ctx);
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx); await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx);
@@ -262,11 +239,7 @@ for (const call of received) {
} }
return "-"; return "-";
}; };
const kwargs = parsed && typeof parsed === "object" && "chat_template_kwargs" in parsed console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}`);
? (parsed as { chat_template_kwargs?: { enable_thinking?: unknown } }).chat_template_kwargs
: undefined;
const think = kwargs && typeof kwargs === "object" && "enable_thinking" in kwargs ? String(kwargs.enable_thinking) : "absent";
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}${call.url?.endsWith("/chat/completions") ? ` thinkoff=${think}` : ""}`);
} }
console.log(`CALLS ${received.filter((call) => call.url?.endsWith("/audio/speech")).length}`); 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() : ""; const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
@@ -444,29 +417,3 @@ say_run() {
DV_PLAYER_BIN="$DV_PWPLAY" say_run speak DV_PLAYER_BIN="$DV_PWPLAY" say_run speak
[[ "$output" == *"PLAYER --target $DV_WPCTL"* ]] [[ "$output" == *"PLAYER --target $DV_WPCTL"* ]]
} }
@test "the rewrite asks the humanizer with thinking explicitly off" {
DV_REWRITE=prose say_run rewrite
[[ "$output" == *"input=- thinkoff=false"* ]]
}
@test "an answer hiding in reasoning_content speaks the mechanical text, not silence" {
DV_REWRITE=reasoning say_run rewrite
[[ "$output" == *"input=The deploy failed because a secret was missing."* ]]
[[ "$output" == *"CALLS 1"* ]]
[[ "$output" == *"PLAYS 1"* ]]
}
@test "a rewrite slower than a breath but under the timeout still speaks" {
DV_REWRITE=stall DV_STALL_MS=300 say_run rewrite
[[ "$output" == *"input=Spoken, humanized words."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "the menu test phrase speaks in the background instead of blocking the menu" {
DV_REWRITE=stall DV_STALL_MS=1500 say_run testphrase "Speak a test phrase"
[[ "$output" == *"NOTIFY info dankvoice: speaking the test phrase"* ]]
local ms
ms=$(grep -o 'HANDLER_MS [0-9]*' <<<"$output" | cut -d' ' -f2)
[ -n "$ms" ] && [ "$ms" -lt 1200 ]
}