dankvoice: humanize answers via the @tiny role and narrate thinking

Answers spoken with /say were verbatim markdown-cleaned text while the
built-in vocalizer speaks prose rewritten by a small model. Port that
enhancer (bundle Qyr/T6o: same prompt, 1536 tokens, 6 s, 4000-char
window, null-fallback/empty-skip contract) and resolve its model from
modelRoles.tiny exactly as the harness does, so auto-speak sounds like
the enhanced TUI voice instead of a pager. agent_end now also speaks the
last assistant message's thinking blocks as their own unit before the
answer; narration-flagged thinking counts as answer text. Both knobs are
menu-toggleable, persisted, and env-overridable; the fixture gains a
chat route (echo/prose) and seven scenarios cover the round-trip, the
404 fallback, ordering, the kill switches and role resolution (22 green,
58 suite-wide; live smoke through real frank-zealot + frank-tts).

Refs #27
This commit is contained in:
Frank committed 2026-10-19 01:55:57 -07:00
1 parent 0075ca3bbf
commit 3579c7ebc5
4 files changed
+346 -28

No files matched your search

+196 -7
View File
@@ -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<string | null> {
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"),
);
});