diff --git a/agent/extensions/dankvoice.ts b/agent/extensions/dankvoice.ts index b66bf94..3cdfd65 100644 --- a/agent/extensions/dankvoice.ts +++ b/agent/extensions/dankvoice.ts @@ -30,6 +30,11 @@ * DANKVOICE_VOICE voice name ("" = server default) * DANKVOICE_MAX spoken-character budget per answer (default 700) * DANKVOICE_AUTO=1 start with auto-speak armed + * DANKVOICE_REWRITE_URL @tiny chat base URL (…/v1) for spoken-prose rewrite + * DANKVOICE_REWRITE_MODEL chat model id (default: the @tiny role's model) + * DANKVOICE_REWRITE=0 disable the rewrite step (speak mechanical text) + * DANKVOICE_THINKING=0|1 narration of thinking blocks (default: on) + * DANKVOICE_OMPCONFIG omp config.yml to resolve the @tiny role from * * Long answers are cut into speakable segments by a state machine ported * from omp's built-in vocalizer: strong boundaries on [.!?…] runs followed @@ -493,6 +498,122 @@ export function budgetSegments(segments: string[], maxChars: number): string[] { return chosen; } +// ------------------------------------------------------------ humanization +// +// Port of the built-in vocalizer's speech enhancer (the `Qyr` class and its +// system prompt `T6o` in dist/cli.js): rewrite each unit into natural spoken +// prose with the @tiny model before synthesis. Same prompt, same budgets +// (1536 max tokens, 6 s, 4000-char input window), and the same contract: +// null means the enhancer failed — fall back to the mechanical text — while +// an empty string means the enhancer found nothing worth saying. + +const REWRITE_MAX_TOKENS = 1536; +const REWRITE_TIMEOUT_MS = 6_000; +const REWRITE_MAX_INPUT = 4_000; + +const REWRITE_SYSTEM_PROMPT = `Rewrite coding-assistant replies as words a person would say aloud. Audience: developer listening while working; written reply remains onscreen. Narrate; NEVER dictate syntax. + +Output ONLY spoken words—no Markdown, quotes, preamble, or stage directions. + +- Natural colleague-over-the-shoulder summary. Preserve original meaning, order, tone; add no opinions, greetings, or content absent from text. +- NEVER read URLs, Markdown/table syntax, or separators. Link: label or site name ("the Bun issue on GitHub"). File path: file name only (say "vocalizer.ts," not "vocalizer dot t s"). +- Code blocks: NEVER read; replace each with one short clause describing it or its function ("a small helper that retries the request"). Skip it if surrounding prose already explains it. +- Short inline identifiers, flags, commands: speak as-is ("run bun check"); paraphrase if awkward. +- Speak numbers, versions, symbols naturally: "v1.2" → "version one point two"; "→" → "to"; "&" → "and"; "~5s" → "about five seconds". +- Lists: flowing sentences ("first, then, and finally"); NEVER recite bullets or numbers. +- Concise: same length or shorter; compress boilerplate, NEVER pad. +- Partial mid-thought fragments: render only what exists; NEVER invent an ending. +- Pure code, tables, or markup: empty reply. +`; + +export interface RewriteTarget { + /** OpenAI-compatible chat completions route of the @tiny model. */ + chatUrl: string; + /** Served model id to name in the request body. */ + model: string; +} + +/** + * Resolve the @tiny role the way the harness does: `modelRoles.tiny` in + * config.yml reads `provider (host)/model:effort`; the provider block in + * models.yml supplies the base URL. Unknown role or provider → undefined. + */ +export function resolveTinyRewriter(modelsYml: string, configYml: string): RewriteTarget | undefined { + const role = configYml.match(/^[ \t]*tiny:[ \t]*(.+?)[ \t]*$/m)?.[1]; + if (!role) return undefined; + const ref = role.includes(":") ? role.slice(0, role.lastIndexOf(":")) : role; + const slash = ref.lastIndexOf("/"); + if (slash <= 0) return undefined; + const wantProvider = ref.slice(0, slash).trim(); + const wantModel = ref.slice(slash + 1).trim(); + if (!wantProvider || !wantModel) return undefined; + let inProvider = false; + let base: string | undefined; + for (const line of modelsYml.split(/\r?\n/)) { + const key = line.match(/^ {2}"?([^"\s][^":]*?)"?[ \t]*:[ \t]*$/); + if (key) { + inProvider = key[1].trim() === wantProvider; + base = undefined; + continue; + } + if (!inProvider) continue; + const url = line.trim().match(/^baseUrl:\s*(\S+)/); + if (url) { + base = url[1].replace(/\/+$/, ""); + continue; + } + if (line.trim().match(/^-\s*id:\s*(\S+)/)?.[1] === wantModel && base) { + return { chatUrl: `${base}/chat/completions`, model: wantModel }; + } + } + return undefined; +} + +/** Env override first, then the @tiny role; both miss → no humanization. */ +export function resolveRewriter(env: NodeJS.ProcessEnv, modelsYml: string, configYml: string): RewriteTarget | undefined { + const override = env.DANKVOICE_REWRITE_URL?.trim().replace(/\/+$/, ""); + if (override) return { chatUrl: `${override}/chat/completions`, model: env.DANKVOICE_REWRITE_MODEL?.trim() || "tiny" }; + return resolveTinyRewriter(modelsYml, configYml); +} + +/** The enhancer's rewrite step; never throws — null is its "couldn't". */ +export async function rewriteForSpeech(target: RewriteTarget, text: string): Promise { + try { + const res = await fetch(target.chatUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: target.model, + messages: [ + { role: "system", content: REWRITE_SYSTEM_PROMPT }, + { role: "user", content: text.slice(0, REWRITE_MAX_INPUT) }, + ], + max_tokens: REWRITE_MAX_TOKENS, + }), + signal: AbortSignal.timeout(REWRITE_TIMEOUT_MS), + }); + if (!res.ok) return null; + const payload: unknown = await res.json(); + if (!payload || typeof payload !== "object" || !("choices" in payload) || !Array.isArray(payload.choices) || payload.choices.length === 0) return null; + const first: unknown = payload.choices[0]; + if (!first || typeof first !== "object" || !("message" in first)) return null; + const message: unknown = first.message; + if (!message || typeof message !== "object" || !("content" in message)) return null; + const content: unknown = message.content; + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return null; + 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(); + } catch { + return null; + } +} + /** The text the assistant last actually said; tool-only messages do not count. */ export function lastAssistantText(messages: readonly unknown[]): string { for (let i = messages.length - 1; i >= 0; i--) { @@ -504,8 +625,12 @@ export function lastAssistantText(messages: readonly unknown[]): string { if (!Array.isArray(content)) continue; 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") { + if (!part || typeof part !== "object" || !("type" in part)) continue; + if (part.type === "text" && "text" in part && typeof part.text === "string") { parts.push(part.text); + } else if (part.type === "thinking" && "narration" in part && part.narration === true && "thinking" in part && typeof part.thinking === "string") { + // provider-returned thinking addressed to the user: it is answer text + parts.push(part.thinking); } } const joined = parts.join("\n").trim(); @@ -514,6 +639,23 @@ export function lastAssistantText(messages: readonly unknown[]): string { return ""; } +/** The last assistant message's true thinking blocks; narration-flagged ones are answer text elsewhere. */ +export function lastAssistantThinking(messages: readonly unknown[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") continue; + if (!("content" in message) || !Array.isArray(message.content)) return ""; + const parts: string[] = []; + for (const part of message.content) { + if (part && typeof part === "object" && "type" in part && part.type === "thinking" && !(("narration" in part) && part.narration === true) && "thinking" in part && typeof part.thinking === "string") { + parts.push(part.thinking); + } + } + return parts.join("\n").trim(); + } + return ""; +} + /** Menu choices, persisted between sessions; every field optional. */ export interface Settings { voice?: string; @@ -521,6 +663,7 @@ export interface Settings { sink?: string; maxChars?: number; auto?: boolean; + thinking?: boolean; } /** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */ @@ -539,6 +682,7 @@ export function readSettings(path: string): Settings { if (typeof raw.sink === "string") settings.sink = raw.sink; if (typeof raw.maxChars === "number" && Number.isFinite(raw.maxChars) && raw.maxChars > 0) settings.maxChars = Math.floor(raw.maxChars); if (typeof raw.auto === "boolean") settings.auto = raw.auto; + if (typeof raw.thinking === "boolean") settings.thinking = raw.thinking; return settings; } catch { return {}; @@ -568,11 +712,13 @@ export function parseSinkList(output: string): SinkRow[] { interface Config { target?: SpeechTarget; + rewriter?: RewriteTarget; player?: Player; sink: string; maxChars: number; auto: boolean; voice: string; + thinking: boolean; } function lookupOnPath(command: string): string | null { @@ -589,12 +735,22 @@ function readModelsYml(): string { } } +function readOmpConfigYml(): string { + const path = process.env.DANKVOICE_OMPCONFIG ?? join(process.env.HOME ?? "", ".omp", "agent", "config.yml"); + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + function loadConfig(): Config { const env = process.env; const settings = readSettings(settingsPath(env)); + const modelsYml = readModelsYml(); const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, ""); const modelOverride = env.DANKVOICE_MODEL?.trim(); - const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml()); + const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(modelsYml); const target: SpeechTarget | undefined = overrideUrl ? { speechUrl: `${overrideUrl}/audio/speech`, @@ -608,13 +764,17 @@ function loadConfig(): Config { const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0]; const sink = settings.sink ?? ""; const maxChars = Number(env.DANKVOICE_MAX); + const rewriter = env.DANKVOICE_REWRITE === "0" ? undefined : resolveRewriter(env, modelsYml, readOmpConfigYml()); + const thinking = env.DANKVOICE_THINKING === "1" ? true : env.DANKVOICE_THINKING === "0" ? false : (settings.thinking ?? true); return { target, + rewriter, player: playerCommand ? playerFor(playerCommand, sink) : undefined, sink, maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : (settings.maxChars ?? DEFAULT_MAX_CHARS), auto: env.DANKVOICE_AUTO === "1" || (settings.auto ?? false), voice: env.DANKVOICE_VOICE?.trim() ?? settings.voice ?? "", + thinking, }; } @@ -718,6 +878,7 @@ export default function dankvoice(pi: ExtensionAPI) { sink: cfg.sink, maxChars: cfg.maxChars, auto: state.auto, + thinking: cfg.thinking, }; try { writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`); @@ -747,9 +908,12 @@ export default function dankvoice(pi: ExtensionAPI) { /** * Speak one answer as strictly sequential requests: synthesize a segment, * play it to the end, only then ask for the next — audio never stacks. - * Superseded chains (new utterance, /say stop, disarm) exit at the gates. + * Like the built-in enhancer path, each unit (thinking first, then the + * answer) is rewritten into spoken prose by the @tiny model before + * segmentation; a failed rewrite falls back to the mechanical text and an + * empty rewrite skips its unit. Superseded chains exit at the gates. */ - async function speak(ctx: ExtensionContext, text: string) { + async function speak(ctx: ExtensionContext, text: string, thinking = "") { if (!cfg.target) { ctx.ui.notify(missingTarget(), "error"); return; @@ -760,7 +924,18 @@ export default function dankvoice(pi: ExtensionAPI) { } const target = cfg.target; const player = cfg.player; - const segments = budgetSegments(segmentsForSpeech(text), cfg.maxChars); + const units: string[] = []; + if (cfg.thinking && thinking.trim()) units.push(thinking.trim()); + if (text.trim()) units.push(text.trim()); + const segments: string[] = []; + let used = 0; + for (const unit of units) { + const rewritten = cfg.rewriter ? await rewriteForSpeech(cfg.rewriter, unit) : null; + if (rewritten === "") continue; + const chosen = budgetSegments(segmentsForSpeech(rewritten ?? unit), Math.max(0, cfg.maxChars - used)); + used += chosen.reduce((chars, segment) => chars + segment.length, 0); + segments.push(...chosen); + } if (segments.length === 0) return; stop(); const run = state.run; @@ -798,6 +973,8 @@ export default function dankvoice(pi: ExtensionAPI) { `Output device: ${cfg.sink || "system default"} (change)`, `Player: ${cfg.player?.command ?? "none"} (change)`, `Spoken cap: ${cfg.maxChars} chars (change)`, + `Thinking narration: ${cfg.thinking ? "on" : "off"} (toggle)`, + `Humanize answers: ${cfg.rewriter ? `on (${cfg.rewriter.model})` : "off"} (toggle)`, "Speak a test phrase", ]; const choice = await ctx.ui.select("dankvoice — options", options); @@ -854,6 +1031,18 @@ export default function dankvoice(pi: ExtensionAPI) { persistSettings(ctx); return; } + if (choice === options[5]) { + cfg.thinking = !cfg.thinking; + ctx.ui.notify(`dankvoice: thinking narration ${cfg.thinking ? "on" : "off"}`, "info"); + persistSettings(ctx); + return; + } + if (choice === options[6]) { + cfg.rewriter = cfg.rewriter ? undefined : resolveRewriter(process.env, readModelsYml(), readOmpConfigYml()); + ctx.ui.notify(cfg.rewriter ? `dankvoice: humanizing with ${cfg.rewriter.model}` : "dankvoice: humanization off", "info"); + persistSettings(ctx); + return; + } await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`); } @@ -907,7 +1096,7 @@ export default function dankvoice(pi: ExtensionAPI) { } case "status": ctx.ui.notify( - `dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`, + `dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars · thinking ${cfg.thinking ? "on" : "off"} · humanize ${cfg.rewriter ? cfg.rewriter.model : "off"}`, "info", ); return; @@ -929,7 +1118,7 @@ export default function dankvoice(pi: ExtensionAPI) { // Fire-and-forget: a multi-segment utterance legitimately outlives the // harness's handler timeout, and blocking here would only produce a // spurious "handler timed out" toast while playback continues anyway. - void speak(ctx, lastAssistantText(event.messages)).catch((error) => + void speak(ctx, lastAssistantText(event.messages), lastAssistantThinking(event.messages)).catch((error) => ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"), ); }); diff --git a/omp-docs/core/dankvoice.md b/omp-docs/core/dankvoice.md index 47530f4..0d6d568 100644 --- a/omp-docs/core/dankvoice.md +++ b/omp-docs/core/dankvoice.md @@ -17,9 +17,11 @@ timestamp: 2026-10-19 `agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive harness: `/say` opens the options menu, `/say ` speaks through the -speakers and `/say on` narrates the assistant's final answer after every -turn, all served by **frank-tts** (Dankclaw :7772, the `speech` model role -in [agent/models.yml](/core/index.md)). +speakers and `/say on` narrates the assistant's thinking and final answer +after every turn. The words are first rewritten into natural spoken prose by +the **@tiny** role model (exactly what the built-in vocalizer's +`speech.enhanced` does), and everything is served by **frank-tts** +(Dankclaw :7772, the `speech` model role in [agent/models.yml](/core/index.md)). ## Why it exists @@ -39,13 +41,13 @@ local speech and the cloned voices carry the real narration. | Command | Effect | |---|---| -| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, test phrase | +| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, thinking-narration toggle, humanize toggle, test phrase | | `/say ` | synthesize once and play; interrupts whatever is playing | -| `/say on` \| `/say off` | arm/disarm auto-speak of the assistant's final text at `agent_end`; the footer shows `🔊 ` while armed | +| `/say on` \| `/say off` | arm/disarm auto-speak of the thinking blocks and final text at `agent_end`; the footer shows `🔊 ` while armed | | `/say voice ` | 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 | +| `/say status` \| `/say help` | endpoint, voice, player, device, cap, thinking, humanize | Menu choices persist to `~/.omp/cache/dankvoice.json` (runtime state, never tracked); the environment overrides the file, the file overrides defaults. @@ -54,7 +56,11 @@ 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 -silent. A long answer is never one monolithic synthesis request; see +silent. Each spoken unit — thinking first when narrated, then the answer — +is first rewritten into spoken prose by the @tiny model; when the enhancer +(below) declines, the text goes to segmentation as-is, where fenced code and +tables vanish, links collapse to their label and bullets and table pipes +disappear. A long answer is never one monolithic synthesis request; see [Segmentation](#segmentation) for how utterances are cut and played. ## Segmentation @@ -85,7 +91,8 @@ the harness's 30 s handler timeout — the old awaiting design produced a `/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. +segments that fit are spoken, the rest are dropped — the thinking unit +draws on it before the answer does. ## Endpoint resolution @@ -102,9 +109,14 @@ resolves, so no host address and no credential appears in this file. From | `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) | +| `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 @@ -115,7 +127,29 @@ if wireplumber is unreachable the menu offers only "system default". Each utterance is written to `$TMPDIR/dankvoice-*/say-.wav` and the file plus its directory are removed when the player process exits. -## Verified facts (2026-10-18, 2026-10-19) +## 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 @@ -152,7 +186,16 @@ its directory are removed when the player process exits. 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. `bats tests/dankvoice.bats` 17/17. + 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 @@ -160,8 +203,13 @@ its directory are removed when the player process exits. a compile-time enum in the harness bundle, and no setting routes it to frank-tts. Cloned voices live only on this file-producing/speaking path and on the `tts` tool. See [Handy](/tools/handy.md) for the STT half. -* `agent_end` narrates the final message text only — mid-turn tool chatter is - never spoken. +* `agent_end` narrates thinking blocks and the final message only — mid-turn + 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 @@ -176,14 +224,14 @@ its directory are removed when the player process exits. * `omp --help` (extension discovery flags, `say`, `plugin`) and `@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts` (`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`). -* The stock speak-aloud implementation — vocalizer pipeline `I6o` and - utterance segmenter `fHe` — in `@oh-my-pi/pi-coding-agent`'s - `dist/cli.js`, read off the minified bundle 2026-10-19; the enhanced - rewrite ("with the tiny/smol model before synthesis") is that bundle's - own `speech.enhanced` description. dankvoice ports the mechanical half - only: no LLM rewrite happens before frank-tts. +* 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 diff --git a/omp-docs/log.md b/omp-docs/log.md index 2f40ec1..cf850f6 100644 --- a/omp-docs/log.md +++ b/omp-docs/log.md @@ -12,6 +12,22 @@ timestamp: 2026-08-28 # Bundle Change Log +## 2026-10-19 — dankvoice: humanized narration and thinking aloud (Refs #27) + +dankvoice answered in verbatim markdown-cleaned text while the built-in +vocalizer speaks humanized prose — the bundle's `Qyr` enhancer (`speech.enhanced`, +system prompt `T6o`) now lives in the extension: every spoken unit is rewritten +by the @tiny role (resolved from `modelRoles.tiny` in config.yml + the provider +baseUrl in models.yml → frank-zealot :7771) under the same budgets (1536 tokens, +6 s, 4000-char window), with mechanical cleanup as the fallback and an empty +rewrite skipping the unit. Auto-speak also narrates the last assistant message's +thinking blocks before the answer (`narration: true` blocks count as answer +text); both are menu-toggleable and persisted (`thinking` in the settings file), +env levers `DANKVOICE_REWRITE_URL/MODEL/REWRITE=0/THINKING/OMPCONFIG`. The +fixture grows a chat route with echo/prose modes; 7 new scenarios, 22 green. +Live smoke: a markdown answer became 3 sequential prose segments through real +zealot + frank-tts, thinking played before its answer. + ## 2026-10-19 — dankvoice: segmented sequential speech, copied from the bundle's vocalizer Silent-answer incident follow-up: `agent_end` awaiting one whole-answer diff --git a/tests/dankvoice.bats b/tests/dankvoice.bats index 8025bb9..96de17e 100755 --- a/tests/dankvoice.bats +++ b/tests/dankvoice.bats @@ -68,6 +68,13 @@ providers: 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. @@ -95,6 +102,14 @@ const server = createServer((req, res) => { 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("{}"); }); @@ -107,6 +122,8 @@ if (scenario === "endpoint") { } 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; @@ -195,6 +212,17 @@ if (!say) { 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; @@ -213,7 +241,7 @@ for (const call of received) { }; console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}`); } -console.log(`CALLS ${received.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() : ""; console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`); if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`); @@ -281,6 +309,7 @@ say_run() { @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"* ]] } @@ -316,6 +345,42 @@ say_run() { [[ "$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"* ]] @@ -331,7 +396,7 @@ say_run() { @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)|Speak a test phrase"* ]] + [[ "$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"* ]]