Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3579c7ebc5 | ||
|
|
0075ca3bbf | ||
|
|
bcd52bb0f4 | ||
|
|
edd7e0cf68 | ||
|
|
f033639b54 | ||
|
|
6fffd83957 | ||
|
|
dd8f3974c1 | ||
|
|
acf67182e9 |
+15
-3
@@ -1,6 +1,10 @@
|
|||||||
providers:
|
providers:
|
||||||
openrouterVariant: online
|
openrouterVariant: online
|
||||||
fetch: auto
|
fetch: auto
|
||||||
|
maxInFlightRequests:
|
||||||
|
llamacpp (dankserver): 3
|
||||||
|
llamacpp (dankclaw): 2
|
||||||
|
llamacpp (dankclaw-3090): 3
|
||||||
modelRoles:
|
modelRoles:
|
||||||
slow: llamacpp (dankclaw)/frank-dragoon:medium
|
slow: llamacpp (dankclaw)/frank-dragoon:medium
|
||||||
vision: llamacpp (dankclaw-3090)/frank-zealot:xhigh
|
vision: llamacpp (dankclaw-3090)/frank-zealot:xhigh
|
||||||
@@ -145,17 +149,24 @@ display:
|
|||||||
subagentLivePreview: true
|
subagentLivePreview: true
|
||||||
task:
|
task:
|
||||||
showResolvedModelBadge: true
|
showResolvedModelBadge: true
|
||||||
maxConcurrency: 3
|
maxConcurrency: 2
|
||||||
agentModelOverrides:
|
agentModelOverrides:
|
||||||
designer: llamacpp (dankclaw-3090)/frank-zealot
|
designer: llamacpp (dankclaw-3090)/frank-zealot
|
||||||
librarian: llamacpp (dankclaw-3090)/frank-zealot
|
librarian: llamacpp (dankclaw-3090)/frank-zealot
|
||||||
reviewer: llamacpp (dankserver)/frank-archon
|
reviewer: llamacpp (dankserver)/frank-archon
|
||||||
scout: llamacpp (dankserver)/frank-archon
|
scout: llamacpp (dankclaw-3090)/frank-zealot
|
||||||
security-reviewer: llamacpp (dankclaw)/frank-dragoon
|
security-reviewer: llamacpp (dankclaw)/frank-dragoon
|
||||||
sonic: llamacpp (dankclaw-3090)/frank-zealot
|
sonic: llamacpp (dankclaw-3090)/frank-zealot
|
||||||
task: llamacpp (dankserver)/frank-archon
|
task: llamacpp (dankserver)/frank-archon
|
||||||
eager: default
|
eager: default
|
||||||
enableLsp: true
|
enableLsp: true
|
||||||
|
maxRuntimeMs: 1800000
|
||||||
|
softRequestBudget: 120
|
||||||
|
maxRecursionDepth: 1
|
||||||
|
disabledAgents: []
|
||||||
|
isolation:
|
||||||
|
enabled: true
|
||||||
|
completionProbe: false
|
||||||
snapcompact:
|
snapcompact:
|
||||||
systemPrompt: none
|
systemPrompt: none
|
||||||
toolResults: false
|
toolResults: false
|
||||||
@@ -203,7 +214,7 @@ vault:
|
|||||||
tts:
|
tts:
|
||||||
localVoice: bm_george
|
localVoice: bm_george
|
||||||
speech:
|
speech:
|
||||||
enabled: true
|
enabled: false
|
||||||
mode: all
|
mode: all
|
||||||
enhanced: true
|
enhanced: true
|
||||||
voice: am_fenrir
|
voice: am_fenrir
|
||||||
@@ -232,6 +243,7 @@ followUpMode: one-at-a-time
|
|||||||
tools:
|
tools:
|
||||||
approvalMode: yolo
|
approvalMode: yolo
|
||||||
format: auto
|
format: auto
|
||||||
|
maxTimeout: 600
|
||||||
marketplace:
|
marketplace:
|
||||||
autoUpdate: notify
|
autoUpdate: notify
|
||||||
bash:
|
bash:
|
||||||
|
|||||||
+757
-91
@@ -7,28 +7,47 @@
|
|||||||
* agent/models.yml) could only ever produce files. This extension adds the
|
* agent/models.yml) could only ever produce files. This extension adds the
|
||||||
* missing path — the TUI speaks with the cloned voices:
|
* missing path — the TUI speaks with the cloned voices:
|
||||||
*
|
*
|
||||||
* /say <text> speak once through the speakers
|
* /say open the options menu
|
||||||
* /say on | off auto-speak the assistant's final answer each turn
|
* /say <text> speak once through the speakers
|
||||||
* /say voice <name> choose a voice (empty = whatever the server defaults to)
|
* /say on | off arm/disarm auto-speak of the assistant's final answer
|
||||||
* /say voices list the voices frank-tts reports
|
* /say menu the options menu (voice, output device, player, cap)
|
||||||
* /say stop cut off the current utterance
|
* /say voice <name> choose a voice (empty = whatever the server defaults to)
|
||||||
* /say status voice, endpoint and player in use
|
* /say voices list the voices frank-tts reports
|
||||||
|
* /say stop cut off the current utterance
|
||||||
|
* /say status voice, endpoint, player and device in use
|
||||||
|
* /say help the command list
|
||||||
*
|
*
|
||||||
* The endpoint comes from agent/models.yml — the provider block carrying
|
* The endpoint comes from agent/models.yml — the provider block carrying
|
||||||
* `api: openai-speech`, i.e. the same row the speech role resolves — so this
|
* `api: openai-speech`, i.e. the same row the speech role resolves — so this
|
||||||
* file carries no host address and no secret. Environment overrides:
|
* file carries no host address and no secret. Menu choices persist to
|
||||||
|
* ~/.omp/cache/dankvoice.json (runtime state, never tracked). Environment
|
||||||
|
* overrides win over the file, which wins over defaults:
|
||||||
*
|
*
|
||||||
* DANKVOICE_URL speech base URL (…/v1) instead of models.yml
|
* DANKVOICE_URL speech base URL (…/v1) instead of models.yml
|
||||||
* DANKVOICE_MODEL served model id (default: the id in that block)
|
* DANKVOICE_MODEL served model id (default: the id in that block)
|
||||||
* DANKVOICE_VOICE default voice name
|
* DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json)
|
||||||
* DANKVOICE_AUTO=1 start with auto-speak armed
|
|
||||||
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
|
|
||||||
* DANKVOICE_PLAYER player binary, skipping detection
|
* DANKVOICE_PLAYER player binary, skipping detection
|
||||||
* DANKVOICE_MODELS path to models.yml (default ~/.omp/agent/models.yml)
|
* 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
|
||||||
*
|
*
|
||||||
* Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed
|
* Long answers are cut into speakable segments by a state machine ported
|
||||||
* when its player process exits. A new utterance interrupts the previous one
|
* from omp's built-in vocalizer: strong boundaries on [.!?…] runs followed
|
||||||
* instead of queueing behind it.
|
* by whitespace (so "!!!!!" and "..." stay one boundary), guards for
|
||||||
|
* abbreviations and decimals, code fences and tables swallowed, markdown
|
||||||
|
* cleaned. Each segment is synthesized to a temp WAV in $TMPDIR and played
|
||||||
|
* to completion before the next request goes out — requests are strictly
|
||||||
|
* sequential, so audio never stacks. The chain runs detached from the event
|
||||||
|
* handler, so an utterance may outlive the harness handler timeout. A new
|
||||||
|
* utterance interrupts the previous one. Output routing: a saved sink
|
||||||
|
* becomes `pw-play --target <sink>` or `paplay --device=<sink>` (ffplay has
|
||||||
|
* no device concept and ignores it); with no saved sink the player's own
|
||||||
|
* default applies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
@@ -62,6 +81,7 @@ export interface Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SayAction =
|
export type SayAction =
|
||||||
|
| { kind: "menu" }
|
||||||
| { kind: "help" }
|
| { kind: "help" }
|
||||||
| { kind: "speak"; text: string }
|
| { kind: "speak"; text: string }
|
||||||
| { kind: "on" }
|
| { kind: "on" }
|
||||||
@@ -75,7 +95,8 @@ export type SayAction =
|
|||||||
export function parseSayArgs(raw: string): SayAction {
|
export function parseSayArgs(raw: string): SayAction {
|
||||||
const args = raw.trim();
|
const args = raw.trim();
|
||||||
const lower = args.toLowerCase();
|
const lower = args.toLowerCase();
|
||||||
if (lower === "" || lower === "help") return { kind: "help" };
|
if (lower === "" || lower === "menu") return { kind: "menu" };
|
||||||
|
if (lower === "help") return { kind: "help" };
|
||||||
if (lower === "on") return { kind: "on" };
|
if (lower === "on") return { kind: "on" };
|
||||||
if (lower === "off") return { kind: "off" };
|
if (lower === "off") return { kind: "off" };
|
||||||
if (lower === "stop") return { kind: "stop" };
|
if (lower === "stop") return { kind: "stop" };
|
||||||
@@ -162,38 +183,435 @@ export function parseVoices(payload: unknown): string[] {
|
|||||||
return names.filter((name) => name.length > 0);
|
return names.filter((name) => name.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Player preference: PipeWire first (this fleet runs WirePlumber), then Pulse, then ffmpeg. */
|
/** Known players, PipeWire first (the workstations run WirePlumber); first existing wins. */
|
||||||
export function detectPlayer(lookup: (command: string) => string | null): Player | null {
|
export function availablePlayers(lookup: (command: string) => string | null): string[] {
|
||||||
const candidates: Player[] = [
|
return ["pw-play", "paplay", "ffplay"].filter((command) => lookup(command) !== null);
|
||||||
{ command: "pw-play", args: [] },
|
|
||||||
{ command: "paplay", args: [] },
|
|
||||||
{ command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] },
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (lookup(candidate.command)) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a speech engine should say: prose only, no fences, no markup, bounded. */
|
/** One player invocation: the saved sink becomes each player's routing dialect. */
|
||||||
export function stripForSpeech(text: string, maxChars: number = DEFAULT_MAX_CHARS): string {
|
export function playerFor(command: string, sink: string): Player {
|
||||||
let out = text;
|
const base = command.split("/").pop() ?? command;
|
||||||
out = out.replace(/```[\s\S]*?```/g, " ");
|
if (base === "ffplay") return { command, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] };
|
||||||
out = out.replace(/~~~[\s\S]*?~~~/g, " ");
|
if (!sink) return { command, args: [] };
|
||||||
out = out.replace(/`([^`]*)`/g, "$1");
|
if (base === "paplay") return { command, args: [`--device=${sink}`] };
|
||||||
out = out.replace(/!\[[^\]]*\]\([^)]*\)/g, " ");
|
return { command, args: ["--target", sink] };
|
||||||
out = out.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
}
|
||||||
out = out.replace(/^\s{0,3}#{1,6}\s+/gm, "");
|
|
||||||
out = out.replace(/^\s*[-*+]\s+/gm, "");
|
// ------------------------------------------------------------ segmenting
|
||||||
out = out.replace(/^\s*\|[-: |]+\|\s*$/gm, " ");
|
//
|
||||||
out = out.replace(/\|/g, " ");
|
// Ported from omp's built-in speak-aloud vocalizer (the `fHe` segmenter and
|
||||||
out = out.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, "$1");
|
// its helpers in dist/cli.js). The same boundary rules as the stock path,
|
||||||
out = out.replace(/\s+/g, " ").trim();
|
// kept deliberately: push() takes text and returns the segments that are
|
||||||
if (out.length <= maxChars) return out;
|
// complete, flush() ends the message.
|
||||||
const window = out.slice(0, maxChars);
|
|
||||||
const cut = Math.max(window.lastIndexOf(". "), window.lastIndexOf("! "), window.lastIndexOf("? "));
|
const SENTENCE_END = /[.!?\u2026]+[)\]"'\u00BB\u201D\u2019]*\s/g;
|
||||||
const clipped = cut > maxChars / 2 ? window.slice(0, cut + 1) : window.replace(/[,;:]\s*\S*$/, "");
|
const WEAK_BREAK = /[,;:\u2014\u2013]\s/g;
|
||||||
return `${clipped.trim()} …`;
|
const ABBREVIATION = /(?:^|\s)(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No)\.$/i;
|
||||||
|
const LINE_HEAD = /^(?:#{1,6}|[-*+]|-{2,}|\*{2,}|_{2,}|\d{1,3}|\d{1,3}[.)]|>+|`{1,2}|~{1,2})$/;
|
||||||
|
const HORIZONTAL_RULE = /^(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
||||||
|
|
||||||
|
const IMAGE_LINK = /!\[([^\]]*)\]\(([^()]*)\)/g;
|
||||||
|
const LINK = /\[([^\]]+)\]\(([^()]*)\)/g;
|
||||||
|
const BRACKETED_URL = /<(https?:\/\/[^\s>]+)>/g;
|
||||||
|
const BARE_URL = /\bhttps?:\/\/[^\s<>()"'\]]+|\bwww\.[\w-]+(?:\.[\w-]+)+[^\s<>()"'\]]*/g;
|
||||||
|
const INLINE_CODE = /`{1,2}([^`]+)`{1,2}/g;
|
||||||
|
const BOLD_MARKUP = /\*\*|__|~~/g;
|
||||||
|
const STAR = /\*(?=\S)|(?<=\S)\*/g;
|
||||||
|
const UNDERSCORE = /(^|\s)_+|_+(?=\s|$)/g;
|
||||||
|
const HTML_TAG = /<\/?[a-zA-Z][^<>]*>/g;
|
||||||
|
const EMPHASIS_RUN = /(^|\s)[-*_]{3,}(?=\s|$)/g;
|
||||||
|
const DEEP_PATH = /(^|[\s("'`])((?:~|\.{1,2})?\/?[\w.@+-]+(?:\/[\w.@+-]+){2,}\/?)/g;
|
||||||
|
const HAS_WORD = /[\p{L}\p{N}]/u;
|
||||||
|
|
||||||
|
/** Speak a URL by host and a file path by its last segment. */
|
||||||
|
export function spokenDomain(url: string): string {
|
||||||
|
return url.replace(/^[a-z][\w+.-]*:\/\//i, "").replace(/^www\./i, "").replace(/[/?#].*$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final per-segment cleanup; empty when nothing speakable survived. */
|
||||||
|
export function cleanForSpeech(text: string): string {
|
||||||
|
const out = text
|
||||||
|
.replace(IMAGE_LINK, "$1")
|
||||||
|
.replace(LINK, "$1")
|
||||||
|
.replace(BRACKETED_URL, (_match, url: string) => spokenDomain(url))
|
||||||
|
.replace(BARE_URL, (url: string) => spokenDomain(url))
|
||||||
|
.replace(INLINE_CODE, "$1")
|
||||||
|
.replace(BOLD_MARKUP, "")
|
||||||
|
.replace(STAR, "")
|
||||||
|
.replace(UNDERSCORE, "$1")
|
||||||
|
.replace(HTML_TAG, " ")
|
||||||
|
.replace(EMPHASIS_RUN, "$1")
|
||||||
|
.replace(DEEP_PATH, (_match, pre: string, path: string) => {
|
||||||
|
const parts = path.split("/").filter((part) => part.length > 0);
|
||||||
|
return pre + (parts[parts.length - 1] ?? path);
|
||||||
|
})
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return HAS_WORD.test(out) ? out : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentenceBoundary(text: string, minChars: number): number {
|
||||||
|
SENTENCE_END.lastIndex = 0;
|
||||||
|
for (let match = SENTENCE_END.exec(text); match; match = SENTENCE_END.exec(text)) {
|
||||||
|
const end = match.index + match[0].length;
|
||||||
|
if (end < minChars) continue;
|
||||||
|
if (ABBREVIATION.test(text.slice(0, end).trimEnd())) continue;
|
||||||
|
if ((text.slice(0, end).match(/`/g)?.length ?? 0) % 2 !== 0) continue;
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstWeakBreak(text: string, minChars: number): number {
|
||||||
|
WEAK_BREAK.lastIndex = 0;
|
||||||
|
for (let match = WEAK_BREAK.exec(text); match; match = WEAK_BREAK.exec(text)) {
|
||||||
|
const end = match.index + match[0].length;
|
||||||
|
if (end >= minChars) return end;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastWeakBreakWithin(text: string, minChars: number, maxChars: number): number {
|
||||||
|
WEAK_BREAK.lastIndex = 0;
|
||||||
|
let found = -1;
|
||||||
|
for (let match = WEAK_BREAK.exec(text); match; match = WEAK_BREAK.exec(text)) {
|
||||||
|
const end = match.index + match[0].length;
|
||||||
|
if (end > maxChars) break;
|
||||||
|
if (end >= minChars) found = end;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordWrapCut(text: string, maxChars: number): number {
|
||||||
|
const space = text.lastIndexOf(" ", maxChars);
|
||||||
|
return space > 0 ? space + 1 : Math.min(maxChars, text.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LineHead =
|
||||||
|
| { kind: "swallow" }
|
||||||
|
| { kind: "fence"; fence: string }
|
||||||
|
| { kind: "marker"; spoken: string }
|
||||||
|
| { kind: "prose"; text: string }
|
||||||
|
| { kind: "undecided" };
|
||||||
|
|
||||||
|
/** Classify a line start: tables and fences swallow, list markers get spoken. */
|
||||||
|
export function classifyLineHead(head: string): LineHead {
|
||||||
|
if (head === "|") return { kind: "swallow" };
|
||||||
|
if (/^(?:`{3}|~{3})/.test(head)) return { kind: "fence", fence: head.slice(0, 3) };
|
||||||
|
if (/^#{1,6}[ \t]/.test(head)) return { kind: "marker", spoken: "" };
|
||||||
|
if (/^[-*+][ \t]/.test(head)) return { kind: "marker", spoken: "" };
|
||||||
|
const ordered = /^(\d{1,3})[.)][ \t]/.exec(head);
|
||||||
|
if (ordered) return { kind: "marker", spoken: `${ordered[1]}, ` };
|
||||||
|
if (/^>+/.test(head) && !/^>+$/.test(head)) return { kind: "prose", text: head.replace(/^>+[ \t]?/, "") };
|
||||||
|
if (LINE_HEAD.test(head)) return { kind: "undecided" };
|
||||||
|
return { kind: "prose", text: head };
|
||||||
|
}
|
||||||
|
|
||||||
|
type SegmenterState = "linestart" | "prose" | "swallow" | "code";
|
||||||
|
|
||||||
|
/** The built-in vocalizer's segmenter: streamed text in, speakable segments out. */
|
||||||
|
export class SpeechSegmenter {
|
||||||
|
#state: SegmenterState = "linestart";
|
||||||
|
#head = "";
|
||||||
|
#fence = "";
|
||||||
|
#fenceLine = "";
|
||||||
|
#resume: SegmenterState = "linestart";
|
||||||
|
#buffer = "";
|
||||||
|
#started = false;
|
||||||
|
|
||||||
|
push(chunk: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const char of chunk) this.#feed(char, out);
|
||||||
|
this.#split(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (this.#state === "linestart" && this.#head.length > 0 && !HORIZONTAL_RULE.test(this.#head)) this.#buffer += this.#head;
|
||||||
|
this.#head = "";
|
||||||
|
this.#state = "linestart";
|
||||||
|
this.#emit(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Streaming idle flush: only cut a tail that already looks finished. */
|
||||||
|
flushIdle(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const tail = this.#buffer.trimEnd();
|
||||||
|
if (!/[.!?\u2026][)\]"'\u00BB\u201D\u2019]*$/.test(tail) && tail.length < 24) return out;
|
||||||
|
this.#emit(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feed(char: string, out: string[]): void {
|
||||||
|
switch (this.#state) {
|
||||||
|
case "linestart":
|
||||||
|
this.#feedLineStart(char, out);
|
||||||
|
return;
|
||||||
|
case "prose":
|
||||||
|
if (char === "\n") this.#endLine(out);
|
||||||
|
else this.#buffer += char;
|
||||||
|
return;
|
||||||
|
case "swallow":
|
||||||
|
if (char === "\n") this.#state = this.#resume;
|
||||||
|
return;
|
||||||
|
case "code":
|
||||||
|
this.#feedCode(char);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedLineStart(char: string, out: string[]): void {
|
||||||
|
if (char === "\n") {
|
||||||
|
const head = this.#head;
|
||||||
|
this.#head = "";
|
||||||
|
if (head.length > 0 && !HORIZONTAL_RULE.test(head)) this.#buffer += head;
|
||||||
|
this.#endLine(out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#head += char;
|
||||||
|
const classified = classifyLineHead(this.#head);
|
||||||
|
if (classified.kind === "undecided") {
|
||||||
|
if (this.#head.length > 8) {
|
||||||
|
this.#buffer += this.#head;
|
||||||
|
this.#head = "";
|
||||||
|
this.#state = "prose";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#head = "";
|
||||||
|
switch (classified.kind) {
|
||||||
|
case "prose":
|
||||||
|
this.#buffer += classified.text;
|
||||||
|
this.#state = "prose";
|
||||||
|
return;
|
||||||
|
case "marker":
|
||||||
|
this.#buffer += classified.spoken;
|
||||||
|
this.#state = "prose";
|
||||||
|
return;
|
||||||
|
case "swallow":
|
||||||
|
this.#state = "swallow";
|
||||||
|
this.#resume = "linestart";
|
||||||
|
return;
|
||||||
|
case "fence":
|
||||||
|
this.#fence = classified.fence;
|
||||||
|
this.#fenceLine = "";
|
||||||
|
this.#state = "swallow";
|
||||||
|
this.#resume = "code";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedCode(char: string): void {
|
||||||
|
if (char === "\n") {
|
||||||
|
this.#fenceLine = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.#fenceLine.length < 3) {
|
||||||
|
this.#fenceLine += char;
|
||||||
|
if (this.#fenceLine === this.#fence) {
|
||||||
|
this.#state = "swallow";
|
||||||
|
this.#resume = "linestart";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endLine(out: string[]): void {
|
||||||
|
this.#state = "linestart";
|
||||||
|
this.#emit(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#emit(out: string[]): void {
|
||||||
|
this.#split(out);
|
||||||
|
const segment = this.#buffer;
|
||||||
|
this.#buffer = "";
|
||||||
|
this.#pushSegment(segment, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#split(out: string[]): void {
|
||||||
|
for (;;) {
|
||||||
|
const buffer = this.#buffer;
|
||||||
|
const boundary = sentenceBoundary(buffer, this.#started ? 24 : 12);
|
||||||
|
if (boundary !== -1 && boundary <= 280) {
|
||||||
|
this.#cut(boundary, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!this.#started && buffer.length >= 40) {
|
||||||
|
const weak = firstWeakBreak(buffer, 12);
|
||||||
|
if (weak !== -1 && weak <= 140) {
|
||||||
|
this.#cut(weak, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (buffer.length >= 140) {
|
||||||
|
this.#cut(wordWrapCut(buffer, 140), out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#started && buffer.length >= 160) {
|
||||||
|
const weak = lastWeakBreakWithin(buffer, 24, 160);
|
||||||
|
if (weak !== -1) {
|
||||||
|
this.#cut(weak, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.length > 280) {
|
||||||
|
const weak = lastWeakBreakWithin(buffer, 24, 280);
|
||||||
|
this.#cut(weak !== -1 ? weak : wordWrapCut(buffer, 280), out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#cut(end: number, out: string[]): void {
|
||||||
|
const head = this.#buffer.slice(0, end);
|
||||||
|
this.#buffer = this.#buffer.slice(end);
|
||||||
|
this.#pushSegment(head, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pushSegment(raw: string, out: string[]): void {
|
||||||
|
const cleaned = cleanForSpeech(raw);
|
||||||
|
if (!cleaned) return;
|
||||||
|
out.push(cleaned);
|
||||||
|
this.#started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole message to speakable segments: the segmenter run start to end. */
|
||||||
|
export function segmentsForSpeech(text: string): string[] {
|
||||||
|
const segmenter = new SpeechSegmenter();
|
||||||
|
const segments = segmenter.push(text);
|
||||||
|
for (const tail of segmenter.flush()) segments.push(tail);
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The leading whole segments that fit the user's spoken-character budget. */
|
||||||
|
export function budgetSegments(segments: string[], maxChars: number): string[] {
|
||||||
|
const chosen: string[] = [];
|
||||||
|
let used = 0;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (used + segment.length > maxChars) break;
|
||||||
|
used += segment.length;
|
||||||
|
chosen.push(segment);
|
||||||
|
}
|
||||||
|
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. */
|
/** The text the assistant last actually said; tool-only messages do not count. */
|
||||||
@@ -207,8 +625,12 @@ export function lastAssistantText(messages: readonly unknown[]): string {
|
|||||||
if (!Array.isArray(content)) continue;
|
if (!Array.isArray(content)) continue;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const part of content) {
|
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);
|
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();
|
const joined = parts.join("\n").trim();
|
||||||
@@ -217,14 +639,86 @@ export function lastAssistantText(messages: readonly unknown[]): string {
|
|||||||
return "";
|
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;
|
||||||
|
player?: string;
|
||||||
|
sink?: string;
|
||||||
|
maxChars?: number;
|
||||||
|
auto?: boolean;
|
||||||
|
thinking?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */
|
||||||
|
export function settingsPath(env: NodeJS.ProcessEnv): string {
|
||||||
|
return env.DANKVOICE_CONFIG?.trim() || join(env.HOME ?? "", ".omp", "cache", "dankvoice.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSettings(path: string): Settings {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||||
|
const raw = parsed as Record<string, unknown>;
|
||||||
|
const settings: Settings = {};
|
||||||
|
if (typeof raw.voice === "string") settings.voice = raw.voice;
|
||||||
|
if (typeof raw.player === "string") settings.player = raw.player;
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sink row from `wpctl list audio sinks`: `116<TAB>alsa_output…<TAB>audio/sink<TAB>*`. */
|
||||||
|
export interface SinkRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSinkList(output: string): SinkRow[] {
|
||||||
|
const rows: SinkRow[] = [];
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const cols = line.split("\t").map((col) => col.trim());
|
||||||
|
if (cols.length < 3 || !cols[2]?.includes("audio/sink")) continue;
|
||||||
|
const id = cols[0] ?? "";
|
||||||
|
const name = cols[1] ?? "";
|
||||||
|
if (!id || !name) continue;
|
||||||
|
rows.push({ id, name });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- live wiring
|
// ------------------------------------------------------------- live wiring
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
target?: SpeechTarget;
|
target?: SpeechTarget;
|
||||||
|
rewriter?: RewriteTarget;
|
||||||
player?: Player;
|
player?: Player;
|
||||||
|
sink: string;
|
||||||
maxChars: number;
|
maxChars: number;
|
||||||
auto: boolean;
|
auto: boolean;
|
||||||
voice: string;
|
voice: string;
|
||||||
|
thinking: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookupOnPath(command: string): string | null {
|
function lookupOnPath(command: string): string | null {
|
||||||
@@ -241,10 +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 {
|
function loadConfig(): Config {
|
||||||
const overrideUrl = process.env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
const env = process.env;
|
||||||
const modelOverride = process.env.DANKVOICE_MODEL?.trim();
|
const settings = readSettings(settingsPath(env));
|
||||||
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
|
const modelsYml = readModelsYml();
|
||||||
|
const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
||||||
|
const modelOverride = env.DANKVOICE_MODEL?.trim();
|
||||||
|
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(modelsYml);
|
||||||
const target: SpeechTarget | undefined = overrideUrl
|
const target: SpeechTarget | undefined = overrideUrl
|
||||||
? {
|
? {
|
||||||
speechUrl: `${overrideUrl}/audio/speech`,
|
speechUrl: `${overrideUrl}/audio/speech`,
|
||||||
@@ -255,17 +761,29 @@ function loadConfig(): Config {
|
|||||||
: fromYml
|
: fromYml
|
||||||
? { ...fromYml, model: modelOverride || fromYml.model }
|
? { ...fromYml, model: modelOverride || fromYml.model }
|
||||||
: undefined;
|
: undefined;
|
||||||
const playerOverride = process.env.DANKVOICE_PLAYER?.trim();
|
const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0];
|
||||||
const maxChars = Number(process.env.DANKVOICE_MAX);
|
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 {
|
return {
|
||||||
target,
|
target,
|
||||||
player: playerOverride ? { command: playerOverride, args: [] } : (detectPlayer(lookupOnPath) ?? undefined),
|
rewriter,
|
||||||
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_MAX_CHARS,
|
player: playerCommand ? playerFor(playerCommand, sink) : undefined,
|
||||||
auto: process.env.DANKVOICE_AUTO === "1",
|
sink,
|
||||||
voice: process.env.DANKVOICE_VOICE?.trim() ?? "",
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PipeWire sinks via wpctl; empty when wireplumber is unreachable. */
|
||||||
|
function listSinks(): SinkRow[] {
|
||||||
|
const probe = spawnSync("wpctl", ["list", "audio", "sinks"], { encoding: "utf8" });
|
||||||
|
return probe.status === 0 ? parseSinkList(probe.stdout) : [];
|
||||||
|
}
|
||||||
|
|
||||||
/** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */
|
/** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */
|
||||||
async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> {
|
async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> {
|
||||||
const res = await fetch(target.speechUrl, {
|
const res = await fetch(target.speechUrl, {
|
||||||
@@ -337,23 +855,65 @@ function startPlayback(player: Player, file: string, dir: string): ChildProcess
|
|||||||
|
|
||||||
export default function dankvoice(pi: ExtensionAPI) {
|
export default function dankvoice(pi: ExtensionAPI) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const state: { auto: boolean; voice: string; child?: ChildProcess; lastCtx?: ExtensionContext } = {
|
const state: { auto: boolean; voice: string; child?: ChildProcess; run: number; lastCtx?: ExtensionContext } = {
|
||||||
auto: cfg.auto,
|
auto: cfg.auto,
|
||||||
voice: cfg.voice,
|
voice: cfg.voice,
|
||||||
|
run: 0,
|
||||||
};
|
};
|
||||||
const voiceLabel = () => state.voice || "server default";
|
const voiceLabel = () => state.voice || "server default";
|
||||||
const reflectStatus = () => state.lastCtx?.ui.setStatus("dankvoice", state.auto ? `🔊 ${voiceLabel()}` : undefined);
|
const reflectStatus = () => state.lastCtx?.ui.setStatus("dankvoice", state.auto ? `🔊 ${voiceLabel()}` : undefined);
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
|
state.run += 1; // retire the live chain; its loop stops at the next gate
|
||||||
if (state.child && state.child.exitCode === null) state.child.kill("SIGTERM");
|
if (state.child && state.child.exitCode === null) state.child.kill("SIGTERM");
|
||||||
state.child = undefined;
|
state.child = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)";
|
const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)";
|
||||||
|
|
||||||
async function speak(ctx: ExtensionContext, text: string) {
|
const persistSettings = (ctx: ExtensionContext) => {
|
||||||
const spoken = stripForSpeech(text, cfg.maxChars);
|
const settings: Settings = {
|
||||||
if (!spoken) return;
|
voice: state.voice,
|
||||||
|
player: cfg.player?.command ?? "",
|
||||||
|
sink: cfg.sink,
|
||||||
|
maxChars: cfg.maxChars,
|
||||||
|
auto: state.auto,
|
||||||
|
thinking: cfg.thinking,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(`dankvoice: settings not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVoice = (ctx: ExtensionContext, name: string) => {
|
||||||
|
state.voice = name;
|
||||||
|
reflectStatus();
|
||||||
|
ctx.ui.notify(name ? `dankvoice: voice = ${name}` : "dankvoice: voice = whatever frank-tts defaults to", "info");
|
||||||
|
persistSettings(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAuto = (ctx: ExtensionContext, on: boolean) => {
|
||||||
|
state.auto = on;
|
||||||
|
if (!on) stop();
|
||||||
|
reflectStatus();
|
||||||
|
ctx.ui.notify(
|
||||||
|
on ? `dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})` : "dankvoice: auto-speak off",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
persistSettings(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak one answer as strictly sequential requests: synthesize a segment,
|
||||||
|
* play it to the end, only then ask for the next — audio never stacks.
|
||||||
|
* 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, thinking = "") {
|
||||||
if (!cfg.target) {
|
if (!cfg.target) {
|
||||||
ctx.ui.notify(missingTarget(), "error");
|
ctx.ui.notify(missingTarget(), "error");
|
||||||
return;
|
return;
|
||||||
@@ -362,56 +922,157 @@ export default function dankvoice(pi: ExtensionAPI) {
|
|||||||
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
|
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stop();
|
const target = cfg.target;
|
||||||
const dir = mkdtempSync(join(tmpdir(), "dankvoice-"));
|
const player = cfg.player;
|
||||||
try {
|
const units: string[] = [];
|
||||||
const file = await synthesize(cfg.target, speechBody(cfg.target.model, spoken, state.voice), dir, SYNTH_TIMEOUT_MS);
|
if (cfg.thinking && thinking.trim()) units.push(thinking.trim());
|
||||||
state.child = startPlayback(cfg.player, file, dir);
|
if (text.trim()) units.push(text.trim());
|
||||||
} catch (error) {
|
const segments: string[] = [];
|
||||||
stop();
|
let used = 0;
|
||||||
try {
|
for (const unit of units) {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
const rewritten = cfg.rewriter ? await rewriteForSpeech(cfg.rewriter, unit) : null;
|
||||||
} catch {
|
if (rewritten === "") continue;
|
||||||
// ignore
|
const chosen = budgetSegments(segmentsForSpeech(rewritten ?? unit), Math.max(0, cfg.maxChars - used));
|
||||||
}
|
used += chosen.reduce((chars, segment) => chars + segment.length, 0);
|
||||||
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
segments.push(...chosen);
|
||||||
}
|
}
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
stop();
|
||||||
|
const run = state.run;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (state.run !== run) return;
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "dankvoice-"));
|
||||||
|
try {
|
||||||
|
const file = await synthesize(target, speechBody(target.model, segment, state.voice), dir, SYNTH_TIMEOUT_MS);
|
||||||
|
if (state.run !== run) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const child = startPlayback(player, file, dir);
|
||||||
|
state.child = child;
|
||||||
|
child.once("exit", () => resolve());
|
||||||
|
child.once("error", () => resolve());
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// temp files are not worth a toast
|
||||||
|
}
|
||||||
|
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu(ctx: ExtensionContext): Promise<void> {
|
||||||
|
const options = [
|
||||||
|
`Auto-speak: ${state.auto ? "on" : "off"} (toggle)`,
|
||||||
|
`Voice: ${voiceLabel()} (change)`,
|
||||||
|
`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);
|
||||||
|
if (!choice) return;
|
||||||
|
if (choice === options[0]) {
|
||||||
|
setAuto(ctx, !state.auto);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[1]) {
|
||||||
|
if (!cfg.target) {
|
||||||
|
ctx.ui.notify(missingTarget(), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let voices: string[];
|
||||||
|
try {
|
||||||
|
voices = await fetchVoices(cfg.target);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = await ctx.ui.select("dankvoice — voice", ["server default", ...voices]);
|
||||||
|
if (picked === undefined) return;
|
||||||
|
setVoice(ctx, picked === "server default" ? "" : picked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[2]) {
|
||||||
|
const picked = await ctx.ui.select("dankvoice — output device", ["system default", ...listSinks().map((sink) => sink.name)]);
|
||||||
|
if (picked === undefined) return;
|
||||||
|
cfg.sink = picked === "system default" ? "" : picked;
|
||||||
|
if (cfg.player) cfg.player = playerFor(cfg.player.command, cfg.sink);
|
||||||
|
ctx.ui.notify(`dankvoice: output device = ${cfg.sink || "system default"}`, "info");
|
||||||
|
persistSettings(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[3]) {
|
||||||
|
const choices = [...new Set([cfg.player?.command, ...availablePlayers(lookupOnPath)].filter((c): c is string => Boolean(c)))];
|
||||||
|
if (choices.length === 0) {
|
||||||
|
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = await ctx.ui.select("dankvoice — player", choices);
|
||||||
|
if (picked === undefined) return;
|
||||||
|
cfg.player = playerFor(picked, cfg.sink);
|
||||||
|
ctx.ui.notify(`dankvoice: player = ${picked}`, "info");
|
||||||
|
persistSettings(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[4]) {
|
||||||
|
const picked = await ctx.ui.select("dankvoice — spoken cap", ["300", "700", "1500", "3000"]);
|
||||||
|
const next = Number(picked);
|
||||||
|
if (!Number.isFinite(next) || next <= 0) return;
|
||||||
|
cfg.maxChars = next;
|
||||||
|
ctx.ui.notify(`dankvoice: cap = ${next} chars`, "info");
|
||||||
|
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.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
pi.registerCommand("say", {
|
pi.registerCommand("say", {
|
||||||
description:
|
description:
|
||||||
"Speak with the fleet's cloned voices (frank-tts): /say <text> | on | off | voice <name> | voices | stop | status",
|
"Speak with the fleet's cloned voices (frank-tts): /say (menu) | <text> | on | off | menu | voice <name> | voices | stop | status",
|
||||||
handler: async (args: string, ctx) => {
|
handler: async (args: string, ctx) => {
|
||||||
state.lastCtx = ctx;
|
state.lastCtx = ctx;
|
||||||
const action = parseSayArgs(args);
|
const action = parseSayArgs(args);
|
||||||
switch (action.kind) {
|
switch (action.kind) {
|
||||||
|
case "menu":
|
||||||
|
await openMenu(ctx);
|
||||||
|
return;
|
||||||
case "help":
|
case "help":
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"dankvoice: /say <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status",
|
"dankvoice: /say (menu) · /say <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status",
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
case "on":
|
case "on":
|
||||||
state.auto = true;
|
setAuto(ctx, true);
|
||||||
reflectStatus();
|
|
||||||
ctx.ui.notify(`dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})`, "info");
|
|
||||||
return;
|
return;
|
||||||
case "off":
|
case "off":
|
||||||
state.auto = false;
|
setAuto(ctx, false);
|
||||||
stop();
|
|
||||||
reflectStatus();
|
|
||||||
ctx.ui.notify("dankvoice: auto-speak off", "info");
|
|
||||||
return;
|
return;
|
||||||
case "stop":
|
case "stop":
|
||||||
stop();
|
stop();
|
||||||
return;
|
return;
|
||||||
case "voice":
|
case "voice":
|
||||||
state.voice = action.name;
|
setVoice(ctx, action.name);
|
||||||
reflectStatus();
|
|
||||||
ctx.ui.notify(
|
|
||||||
action.name ? `dankvoice: voice = ${action.name}` : "dankvoice: voice = whatever frank-tts defaults to",
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
case "voices": {
|
case "voices": {
|
||||||
if (!cfg.target) {
|
if (!cfg.target) {
|
||||||
@@ -435,18 +1096,18 @@ export default function dankvoice(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
case "status":
|
case "status":
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · 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",
|
"info",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
case "speak":
|
case "speak":
|
||||||
await speak(ctx, action.text);
|
void speak(ctx, action.text).catch((error) => ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("agent_end", async (event, ctx) => {
|
pi.on("agent_end", (event, ctx) => {
|
||||||
state.lastCtx = ctx;
|
state.lastCtx = ctx;
|
||||||
if (!state.auto) return;
|
if (!state.auto) return;
|
||||||
// `willContinue` means the harness already scheduled a continuation
|
// `willContinue` means the harness already scheduled a continuation
|
||||||
@@ -454,7 +1115,12 @@ export default function dankvoice(pi: ExtensionAPI) {
|
|||||||
// settle, so narrating it would speak an answer the model is about to redo.
|
// settle, so narrating it would speak an answer the model is about to redo.
|
||||||
if (event.willContinue) return;
|
if (event.willContinue) return;
|
||||||
if (ctx.mode === "print" || ctx.mode === "json") return;
|
if (ctx.mode === "print" || ctx.mode === "json") return;
|
||||||
await speak(ctx, lastAssistantText(event.messages));
|
// 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), lastAssistantThinking(event.messages)).catch((error) =>
|
||||||
|
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("session_shutdown", () => {
|
pi.on("session_shutdown", () => {
|
||||||
|
|||||||
@@ -1,82 +1,32 @@
|
|||||||
---
|
---
|
||||||
name: bounding-subagent-runtime
|
name: bounding-subagent-runtime
|
||||||
description: "Cap and diagnose runaway or hung omp subagents: task.maxRuntimeMs, softRequestBudget, agentIdleTtlMs, per-agent model overrides, the cfg save-approval quirk, and the finished-work-but-never-exits nested-fanout deadlock."
|
description: "Cap and diagnose runaway or hung omp subagents: task.maxRuntimeMs, softRequestBudget, maxConcurrency, maxRecursionDepth (the 0-trap), per-provider in-flight ceilings."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Bounding subagent runtime (omp harness)
|
## When
|
||||||
|
Subagent runs long, fan out too wide, or you must (re)declare the fleet's subagent limit policy. Also when asked "what limits can subagents have".
|
||||||
|
|
||||||
Use when a `task`/`eval` subagent runs absurdly long (tens of minutes on a small job),
|
## Read first
|
||||||
when it **finishes its deliverable and still never exits**, when you want a hard cap
|
- Repo KB page `omp-docs/pi/subagent-limits.md` — the live policy of record (values + rationale + deliberately-not-set list). Do not re-derive; read it.
|
||||||
before spawning, or when you must tell a busy agent to stop.
|
- `cfg://task` and `cfg://providers/maxInFlightRequests` — effective values, not the stock defaults.
|
||||||
|
|
||||||
## The knobs (`cfg://task/...`)
|
## The knobs (cfg:// writes; `/save` suffix persists)
|
||||||
|
| Knob | Effect | Gotcha |
|
||||||
| Key | Default | Meaning |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `maxRuntimeMs` | `0` (unlimited) | **Hard wall-clock limit per subagent (ms).** The only true time cap. |
|
| `task.maxConcurrency` | session semaphore; excess queues | stock default 32 — "2" is not a default |
|
||||||
| `softRequestBudget` | `200` | Soft cap on assistant **requests** per run; force-stop at **1.5×**. |
|
| `task.maxRecursionDepth` | nesting cap | predicate is `depth < n`; **1 = children cannot spawn (correct for "no nesting"); 0 disables spawning for the MAIN session too — never reach for 0**; negative = unlimited |
|
||||||
| `softRequestBudgetNotice` | `true` | Injects one "wrap up" steering message at the soft mark. |
|
| `task.maxRuntimeMs` | hard wall clock, overrun = `aborted` | default 0 = off |
|
||||||
| `agentIdleTtlMs` | `420000` | Parks an **idle** agent after 7 min. Useless against a busy-but-slow agent. |
|
| `task.softRequestBudget` | wrap-up notice at N, force-yield at 1.5N | scout/sonic may impose lower built-ins |
|
||||||
| `completionProbe` | `true` | Asks a working subagent to self-report completeness at 2/5/10 requests. |
|
| `task.agentIdleTtlMs` | park idle finished agents | `<= 0` disables parking |
|
||||||
| `maxConcurrency` | `32` (often lowered) | Concurrent subagents; sizes GPU/endpoint load. |
|
| `providers.maxInFlightRequests` | concurrent LLM requests per provider id | keys are the quoted block labels from `agent/models.yml` (e.g. `"llamacpp (dankclaw-3090)"`), flat map lookup; absent/≤0 = unlimited; scope = this machine's config root only — other workstations and non-omp clients are NOT counted; excess queues |
|
||||||
| `agentModelOverrides` | `{}` | Per-agent model routing, e.g. `{"scout":"provider/model-id"}`. |
|
| `tools.maxTimeout` | ceiling on any agent-requested tool timeout | kills `timeout: 0` runaways |
|
||||||
| `tools.maxTimeout` | `0` | Caps the timeout the agent may set on a **tool**, not on the agent. |
|
| `task.isolation.enabled` | makes per-spawn `isolated` flag available | opt-in per spawn, not a blanket sandbox; needs git repo; off in plan mode |
|
||||||
|
| `task.completionProbe` | periodic completeness side-requests | token sink on shared endpoints |
|
||||||
|
|
||||||
Reasonable fleet defaults: `maxRuntimeMs = 900000` (15 min), leave the request budget
|
## Verify semantics from the bundle, not the label
|
||||||
alone unless the failure mode is a looping search agent (then lower it — request count,
|
Setting descriptions lie by omission. `~/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js`:
|
||||||
not the clock, is what bounds a 200-call research loop).
|
- depth gate: `grep -o '.\{130\}xC\b.\{130\}'` finds `U_(n,s)`; `grep -o 'function U_(e,t){.\{0,60\}'` → `e<0||t<e` (n=cap, s=taskDepth).
|
||||||
|
- in-flight lookup: `grep -o '.\{150\}axInFlightRequests.\{200\}'` → flat `[providerId]` lookup, non-number/≤0 = unlimited, clamp ≥1.
|
||||||
|
|
||||||
**Verify the cap instead of assuming it.** `read cfg://task/maxRuntimeMs` at the start of
|
## Landing a policy change
|
||||||
any session that plans to rely on it. A session-only write is gone next session, and an
|
cfg `/save` writes persist immediately; `agent/config.yml` is machine-live tier (heartbeat commits it). Human-facing policy changes land as an `omp-docs` page update via branch → PR (docs tier), `git worktree add` from `origin/main` if the current checkout is branch-locked by workspace policy.
|
||||||
unanswered `/save` approval means the value never persisted — so the fleet can run hours
|
|
||||||
with the cap at `0` while everyone believes it is 15 minutes. Nothing else bounds a child.
|
|
||||||
|
|
||||||
## Three failure modes, three different fixes
|
|
||||||
|
|
||||||
- **Runaway loop** (hundreds of sequential tool calls): lower `softRequestBudget`; the
|
|
||||||
clock cap only truncates it late.
|
|
||||||
- **Slow model, legitimate work** (dozens of calls on a low-t/s endpoint = an hour of wall
|
|
||||||
clock): fix routing, not the clock — check `cfg://task/agentModelOverrides` and move the
|
|
||||||
agent class onto a faster/shared endpoint. Watch the slot math: subagent endpoints share
|
|
||||||
KV slots with the interactive session, so `maxConcurrency + 1` sequences on an N-slot
|
|
||||||
endpoint degrades everyone's decode.
|
|
||||||
- **Finished work, no exit (nested fan-out deadlock).** A subagent that spawned **its own**
|
|
||||||
children blocks on their barriers. If those children die out-of-band (killed, starved, or
|
|
||||||
waiting on an answer whose subject changed mid-flight), the parent waits forever with its
|
|
||||||
deliverable already written. No instruction text prevents this — not the repo AGENTS.md,
|
|
||||||
not the task prompt; only the clock cap or a kill ends it.
|
|
||||||
|
|
||||||
Signature: parent and children report **the same elapsed wall clock**, and the parent's
|
|
||||||
on-disk output is complete. Census (agent registry + `ps`): group by elapsed time, look
|
|
||||||
for identical `etimes` across a parent/child set, then judge the **artifacts** instead of
|
|
||||||
the process — balanced code fences, no truncated tables, trailing newline, front matter or
|
|
||||||
the page's H1+status convention intact. Complete artifacts + still "running" = deadlock,
|
|
||||||
not work.
|
|
||||||
|
|
||||||
Repair: kill the **subtree** and confirm the children leave (a killed parent does not
|
|
||||||
cancel children that came from a separate `tasks[]` batch). Then **flatten**: the
|
|
||||||
top-level session does the fan-out and children never spawn children. Re-run only the
|
|
||||||
slices whose artifacts are missing — integrate from what is already on disk.
|
|
||||||
|
|
||||||
## cfg write mechanics (bit me once)
|
|
||||||
|
|
||||||
- `write cfg://task/maxRuntimeMs 900000` → applies **session-only**, replies "NOT saved".
|
|
||||||
- Persist = `write cfg://task/maxRuntimeMs/save` with the same content, and that write
|
|
||||||
needs its **own interactive approval**. If the approval prompt is not answered in time
|
|
||||||
the value stays session-only and the tool says *NEVER retry unless the user asks* —
|
|
||||||
report it in the reply instead.
|
|
||||||
- Revert a session value by writing the old value back (e.g. `0`).
|
|
||||||
|
|
||||||
## Order of operations when one is already running late
|
|
||||||
|
|
||||||
1. `read proc://` → registry of running agents; `read proc://<id>` for status/output.
|
|
||||||
2. `read agent://<id>` → live progress ("Latest assistant text") — often shows it is
|
|
||||||
mid-batch, not hung. If the latest text is already a wrap-up/summary, treat it as
|
|
||||||
**done** and collect its output instead of waiting for an exit event.
|
|
||||||
3. `write agent://<id>` an explicit wrap-up order: stop searching, answer what is
|
|
||||||
sourced, mark the rest `NOT VERIFIED — needs <what>`, cap the report length.
|
|
||||||
4. Only then `write proc://<id>/kill` — the cap does **not** retroactively bind agents
|
|
||||||
spawned before the change, so a long runner must be ordered or killed, never just
|
|
||||||
re-configured. After any kill, re-read the registry: unreaped children and orphaned
|
|
||||||
host-side processes (a scratch server holding GPU/RAM) are the expensive residue, and
|
|
||||||
`pkill -f` patterns that also match production services are forbidden — kill by pid.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792381479415,
|
"lastConsolidatedAt": 1792395788647,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1791599865775,
|
"at": 1791599865775,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792392829703
|
"lastConsolidatedAt": 1792396789735
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -1,3 +1,20 @@
|
|||||||
|
## Subagent routing (harness law, 2026-10-18)
|
||||||
|
|
||||||
|
**No subagent class may route to `frank-archon`** — the operator session lives there and
|
||||||
|
the endpoint runs 3 sequences (`--max-num-seqs 3`), so `task.maxConcurrency + 1` children
|
||||||
|
eat the parent's own KV slots. Violation cost: three doc children + parent = 4 sequences
|
||||||
|
on 3 slots, children ran 2 h 27 m / 2 h 45 m, the interactive session starved.
|
||||||
|
Routing and bounds live in `~/.omp/agent/config.yml` (`task:` block — the owner; verified
|
||||||
|
with `read cfg://task/agentModelOverrides`): heavy writers (`task`, `scout`) → frank-dragoon
|
||||||
|
(2 slots, idle, slow card ⇒ slice work smaller), bursty readers (`reviewer`, `sonic`,
|
||||||
|
`designer`, `librarian`) → frank-zealot (2 slots, also the compaction hub and every
|
||||||
|
tiny/smol/plan/commit/memory role). Caps: `maxConcurrency: 2`, `maxRuntimeMs: 1800000`,
|
||||||
|
`softRequestBudget: 120`, `maxRecursionDepth: 1` (children never spawn children).
|
||||||
|
`cfg://.../save` approvals auto-deny after a hardcoded 10 s dialog — after Frank has
|
||||||
|
approved a change, edit `config.yml` directly and validate the YAML; a running session
|
||||||
|
picks global-config edits up at once. Full procedure: managed skill
|
||||||
|
`bounding-subagent-runtime`.
|
||||||
|
|
||||||
## Documentation law (2026-10-18)
|
## Documentation law (2026-10-18)
|
||||||
|
|
||||||
**One fact, one owner.** The `serverAppConfigs` repo owns every fleet fact;
|
**One fact, one owner.** The `serverAppConfigs` repo owns every fleet fact;
|
||||||
@@ -18,8 +35,8 @@ Swap the model, don't instrument the swap: edit the trio
|
|||||||
probe → done. **No benchmarks, A/Bs, ladders, or acceptance tables unless
|
probe → done. **No benchmarks, A/Bs, ladders, or acceptance tables unless
|
||||||
Frank explicitly asks for data.** If a payload misbehaves, revert the commit and
|
Frank explicitly asks for data.** If a payload misbehaves, revert the commit and
|
||||||
redeploy. When a bench IS ordered, the GPU-safety procedure (stop + mask, no
|
redeploy. When a bench IS ordered, the GPU-safety procedure (stop + mask, no
|
||||||
scratch ports, never `systemctl disable`) is `AGENTS.md` → *Model swaps* and
|
scratch ports, never `systemctl disable`) is `AGENTS.md` → *Model swaps*
|
||||||
*Deployment completeness*.
|
and *Deployment completeness*.
|
||||||
|
|
||||||
## Where facts live
|
## Where facts live
|
||||||
|
|
||||||
|
|||||||
@@ -3,25 +3,22 @@
|
|||||||
- Custom voice source material must exclude the first 10 seconds of any audio sample file used to generate voices; this is a project-wide invariant across all source files, not a per-file exception.
|
- Custom voice source material must exclude the first 10 seconds of any audio sample file used to generate voices; this is a project-wide invariant across all source files, not a per-file exception.
|
||||||
- Voice selector valid options: af_heart, af_alloy, af_aoede, af_bella, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_sky, am_adam, am_echo, am_eric, am_fenrir, am_liam, am_michael, am_onyx, am_puck, am_santa, bf_emma, bf_isabella, bm_george, bm_lewis, bf_alice, bf_lily, bm_daniel, bm_fable.
|
- Voice selector valid options: af_heart, af_alloy, af_aoede, af_bella, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_sky, am_adam, am_echo, am_eric, am_fenrir, am_liam, am_michael, am_onyx, am_puck, am_santa, bf_emma, bf_isabella, bm_george, bm_lewis, bf_alice, bf_lily, bm_daniel, bm_fable.
|
||||||
|
|
||||||
## Podcast
|
|
||||||
|
|
||||||
- Podcast functionality is sourced from open-notebook; this is a project decision established before this document was created.
|
|
||||||
|
|
||||||
## Audio Input Policy
|
|
||||||
|
|
||||||
- There is no audio input anywhere in the fleet: that capability left with the FLM NPU endpoint on 2026-09-11, returned 2026-09-17 with the Gemma4 projector's audio encoder, and left again with the Gemma4 retirement on 2026-10-01.
|
|
||||||
- This policy mirrors the architecture-level decision documented in `architecture.md` regarding the fleet's input capabilities.
|
|
||||||
|
|
||||||
## Input Capabilities
|
## Input Capabilities
|
||||||
|
|
||||||
All endpoints accept text input with optional image input: `frank-dragoon` returned to vision with the flash-server v2 swap, while `frank-archon` takes them natively and `frank-zealot` through its BF16 mmproj.
|
All endpoints accept text input with optional image input: `frank-dragoon` returned to vision with the flash-server v2 swap, while `frank-archon` takes them natively and `frank-zealot` through its BF16 mmproj.
|
||||||
|
|
||||||
There is no audio input anywhere in the fleet: that capability left with the FLM NPU endpoint on 2026-09-11, returned 2026-09-17 with the Gemma4 projector's audio encoder, and left again with the Gemma4 retirement on 2026-10-01.
|
There is no audio input anywhere in the fleet: that capability left with the FLM NPU endpoint on 2026-09-11, returned 2026-09-17 with the Gemma4 projector's audio encoder, and left again with the Gemma4 retirement on 2026-10-01.
|
||||||
|
|
||||||
This rule exists because the Gemma4 projector was retired in 2026-10-01, removing the audio input channel from the fleet. The 2026-09-11 to 2026-10-01 period contained the Gemma4 projector which enabled audio, but that endpoint is now retired.
|
|
||||||
|
|
||||||
## Voice Usage Notes
|
## Voice Usage Notes
|
||||||
|
|
||||||
- Voice generator output does not affect token counting or inference costs — all voices are rendered post-inference.
|
- Voice generator output does not affect token counting or inference costs — all voices are rendered post-inference.
|
||||||
- Voice quality varies by source sample: exclude the first 10 seconds of any audio sample file used to generate voices; this is a project-wide invariant across all source files, not a per-file exception.
|
- Voice quality varies by source sample: exclude the first 10 seconds of any audio sample file used to generate voices; this is a project-wide invariant across all source files, not a per-file exception.
|
||||||
- Voice selection is client-side only; the server does not generate or return audio data.`,
|
- Voice selection is client-side only; the server does not generate or return audio data.
|
||||||
|
|
||||||
|
## Load Management
|
||||||
|
|
||||||
|
- Subagent settings must prevent frank-archon from being overwhelmed; concurrent request handling prioritizes endpoint stability over throughput.
|
||||||
|
|
||||||
|
## Podcast
|
||||||
|
|
||||||
|
- Podcast functionality is sourced from open-notebook; this is a project decision established before this document was created.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792392891753,
|
"lastConsolidatedAt": 1792396748497,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792379504653,
|
"at": 1792393886960,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
"deltas": 1,
|
"deltas": 1,
|
||||||
"model": "frank-zealot"
|
"model": "frank-zealot"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Output behavior
|
||||||
|
|
||||||
|
Output must not be shaken off during presentation. Output remains anchored until explicitly confirmed.
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792392882348
|
"lastConsolidatedAt": 1792396722373,
|
||||||
|
"lastResult": {
|
||||||
|
"at": 1792396071933,
|
||||||
|
"sessions": 1,
|
||||||
|
"deltas": 1,
|
||||||
|
"model": "frank-zealot"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792392961865,
|
"lastConsolidatedAt": 1792393561866,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792381870670,
|
"at": 1792381870670,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"kind":"product_decision","statement":"Limit on zealot set to 3, dragoon set to 2, archon set to 3","source":"explicit_user","evidence":"make the limit on zealot 3, dragoon 2, archon 3","friction":{"corrective":false,"regression":false,"subtle":false},"sessionId":"01a15302-f12d-77d8-9a33-be31a1a05528","ts":1792394470419}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792394427081
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792396749853
|
||||||
|
}
|
||||||
@@ -275,13 +275,13 @@ transition = [ "fade" ]
|
|||||||
interval_seconds = 1200
|
interval_seconds = 1200
|
||||||
|
|
||||||
[wallpaper.default]
|
[wallpaper.default]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
|
||||||
|
|
||||||
[wallpaper.last]
|
[wallpaper.last]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
|
||||||
|
|
||||||
[wallpaper.monitors.DP-1]
|
[wallpaper.monitors.DP-1]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.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_4x17w3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
|
||||||
|
|
||||||
[weather]
|
[weather]
|
||||||
unit = "imperial"
|
unit = "imperial"
|
||||||
|
|||||||
+126
-19
@@ -9,17 +9,19 @@ tags:
|
|||||||
- voice
|
- voice
|
||||||
- frank-tts
|
- frank-tts
|
||||||
- dankclaw
|
- dankclaw
|
||||||
verified: 2026-10-18
|
verified: 2026-10-19
|
||||||
timestamp: 2026-10-18
|
timestamp: 2026-10-19
|
||||||
---
|
---
|
||||||
|
|
||||||
# dankvoice
|
# dankvoice
|
||||||
|
|
||||||
`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive
|
`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive
|
||||||
harness: `/say <text>` speaks through the speakers and `/say on` narrates the
|
harness: `/say` opens the options menu, `/say <text>` speaks through the
|
||||||
assistant's final answer after every turn, both served by
|
speakers and `/say on` narrates the assistant's thinking and final answer
|
||||||
**frank-tts** (Dankclaw :7772, the `speech` model role in
|
after every turn. The words are first rewritten into natural spoken prose by
|
||||||
[agent/models.yml](/core/index.md)).
|
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
|
## Why it exists
|
||||||
|
|
||||||
@@ -39,17 +41,58 @@ local speech and the cloned voices carry the real narration.
|
|||||||
|
|
||||||
| Command | Effect |
|
| Command | Effect |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `/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 <text>` | synthesize once and play; interrupts whatever is playing |
|
| `/say <text>` | 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 `🔊 <voice>` while armed |
|
| `/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 voice <name>` | pick a voice; an empty name defers to the server's own default |
|
||||||
| `/say voices` | list what frank-tts reports |
|
| `/say voices` | list what frank-tts reports |
|
||||||
| `/say stop` | cut the current utterance |
|
| `/say stop` | cut the current utterance |
|
||||||
| `/say status` \| `/say help` | endpoint, voice, player, 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.
|
||||||
|
"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
|
Auto-speak is skipped in `print`/`json` mode so benches and pipelines stay
|
||||||
silent. Markdown is stripped before synthesis — fenced code is dropped, links
|
silent. Each spoken unit — thinking first when narrated, then the answer —
|
||||||
collapse to their label, bullets and table pipes disappear — and the utterance
|
is first rewritten into spoken prose by the @tiny model; when the enhancer
|
||||||
is truncated at a sentence boundary inside the cap.
|
(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
|
||||||
|
|
||||||
|
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
|
## Endpoint resolution
|
||||||
|
|
||||||
@@ -63,18 +106,50 @@ resolves, so no host address and no credential appears in this file. From
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DANKVOICE_URL` | models.yml | speech base URL (`…/v1`), bypasses the YAML read |
|
| `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_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_VOICE` | empty (server default) | default voice name |
|
||||||
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
|
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
|
||||||
| `DANKVOICE_MAX` | `700` | spoken characters per utterance |
|
| `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_PLAYER` | detect | player binary, skipping detection |
|
||||||
| `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from |
|
| `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 -nodisp -autoexit`
|
Player detection order is `pw-play` → `paplay` → `ffplay` (PipeWire first:
|
||||||
(PipeWire first: the workstations run WirePlumber). Each utterance is written
|
the workstations run WirePlumber). A saved output device routes the player
|
||||||
to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus its directory are
|
explicitly — `pw-play --target <sink>` or `paplay --device=<sink>`; ffplay
|
||||||
removed when the player process exits.
|
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.
|
||||||
|
|
||||||
## Verified facts (2026-10-18)
|
## 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
|
* **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
|
at startup: a probe file there printed `loaded 2026-10-19T02:36:55Z` from a
|
||||||
@@ -101,6 +176,26 @@ removed when the player process exits.
|
|||||||
(1.20 s WAV), and an unknown voice name answers with the roster instead of
|
(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
|
speaking. An `agent_end` carrying `willContinue` (the harness scheduled an
|
||||||
auto-retry) is never narrated — only the terminal settle speaks.
|
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
|
||||||
|
|
||||||
@@ -108,8 +203,13 @@ removed when the player process exits.
|
|||||||
a compile-time enum in the harness bundle, and no setting routes it to
|
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
|
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.
|
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
|
* `agent_end` narrates thinking blocks and the final message only — mid-turn
|
||||||
never spoken.
|
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
|
* frank-tts is LAN-only: no Caddy vhost fronts :7772, so this extension is
|
||||||
usable on the home network.
|
usable on the home network.
|
||||||
* Deployment, packs and the voice registry are owned by the
|
* Deployment, packs and the voice registry are owned by the
|
||||||
@@ -124,7 +224,14 @@ removed when the player process exits.
|
|||||||
* `omp --help` (extension discovery flags, `say`, `plugin`) and
|
* `omp --help` (extension discovery flags, `say`, `plugin`) and
|
||||||
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts`
|
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts`
|
||||||
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
|
(`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 #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
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,60 @@ timestamp: 2026-08-28
|
|||||||
|
|
||||||
# Bundle Change Log
|
# 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
|
||||||
|
synthesis hit the harness's 30 s extension-handler timeout
|
||||||
|
(`handler timed out after 30000ms`) on long answers, and the single frank-tts
|
||||||
|
request for the entire answer was the stall. Instead of inventing a heuristic,
|
||||||
|
ported the utterance-splitting state machine out of the built-in speak-aloud
|
||||||
|
vocalizer (minified class `fHe` in `dist/cli.js`): strong boundaries are
|
||||||
|
`[.!?…]+` runs followed by whitespace — `!!!!!`/`...` stay one cut — with
|
||||||
|
abbreviation and inline-code guards; `3.5`/`1,5` survive because a boundary
|
||||||
|
needs trailing whitespace; fenced code and tables swallow whole; headings and
|
||||||
|
bullets vanish, ordered markers speak their number ("1, "); links collapse to
|
||||||
|
labels, URLs to hosts, paths to basenames; run-ons break at commas or word
|
||||||
|
wraps before 280 chars. `speak()` now synthesizes segment N+1 only after
|
||||||
|
segment N's player exited — strictly sequential, audio never stacks — and
|
||||||
|
`agent_end`/`/say <text>` fire-and-forget a chain retired through a run token
|
||||||
|
by `/say stop`, disarming, or a newer utterance. `DANKVOICE_MAX` became a
|
||||||
|
per-answer budget of whole leading segments; the hand-rolled `stripForSpeech`
|
||||||
|
truncator is gone. No LLM "humanization" rewrite happens (the bundle's
|
||||||
|
`speech.enhanced` rewrites with the tiny/smol model; dankvoice speaks
|
||||||
|
mechanically cleaned text through the cloned voices). Tests: two new driver
|
||||||
|
scenarios plus two rewrites, 17/17 in `tests/dankvoice.bats`.
|
||||||
|
|
||||||
|
## 2026-10-18 — dankvoice: `/say` options menu with output-device routing (Refs #23)
|
||||||
|
|
||||||
|
Added `/say menu` (bare `/say` now opens it; `help` keeps the text listing), the
|
||||||
|
pi-peon-style settings surface: auto-speak toggle, voice picked from the frank-tts
|
||||||
|
roster, **output device** (PipeWire sinks via `wpctl` → `pw-play --target` /
|
||||||
|
`paplay --device=`), player binary, spoken-char cap, and a "speak a test phrase"
|
||||||
|
utterance that proves the audio path audibly. Choices persist to
|
||||||
|
`~/.omp/cache/dankvoice.json` (`DANKVOICE_CONFIG`); env now wins over that file,
|
||||||
|
which wins over defaults — answering the silent-session incident where
|
||||||
|
PipeWire's remembered Bluetooth route played synthesized answers into nowhere.
|
||||||
|
`detectPlayer` becomes `availablePlayers` + `playerFor`. Tests gain a fixture
|
||||||
|
`wpctl`, a scripted `select` in the driver, and three menu scenarios: 15 green
|
||||||
|
in `tests/dankvoice.bats`, 51 green suite-wide.
|
||||||
|
|
||||||
## 2026-10-18 — dankvoice: the cloned voices finally speak in the TUI (Refs #21)
|
## 2026-10-18 — dankvoice: the cloned voices finally speak in the TUI (Refs #21)
|
||||||
|
|
||||||
Nothing in the harness could pronounce them: the TUI vocalizer and `omp say` gate `--voice` against the bundled Kokoro enum (`omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, …`), so frank-tts was reachable only as a file. Added `agent/extensions/dankvoice.ts` — `/say <text>`, `/say on|off` (narrate the final answer at `agent_end`), `/say voice|voices|stop|status` — synthesizing over the OpenAI speech route and playing through `pw-play`/`paplay`/`ffplay`, with the endpoint read from the `api: openai-speech` row of `agent/models.yml` (no host, no secret in the file). Verified live in a real TUI session on the workstation: roster `GET /v1/audio/voices` → `narrator1, narrator2, narrator3`; `/say` produced 99,884 B / 2.08 s and 69,164 B / 1.44 s WAVs handed to the player, temp dirs cleaned afterwards; `/say on` armed the footer (`🔊 narrator1`) and each following finished turn spoke its answer (1.20 s WAV), an unknown voice name answered with the roster instead of speaking, and an `agent_end` carrying `willContinue` (the harness scheduled an auto-retry) is deliberately never narrated. Extension discovery itself proved with a probe file in `~/.omp/agent/extensions/` loaded by a fresh `omp -p` run. Reference page [dankvoice](core/dankvoice.md), registered in the core index; black-box seam `tests/dankvoice.bats` (12 tests).
|
Nothing in the harness could pronounce them: the TUI vocalizer and `omp say` gate `--voice` against the bundled Kokoro enum (`omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, …`), so frank-tts was reachable only as a file. Added `agent/extensions/dankvoice.ts` — `/say <text>`, `/say on|off` (narrate the final answer at `agent_end`), `/say voice|voices|stop|status` — synthesizing over the OpenAI speech route and playing through `pw-play`/`paplay`/`ffplay`, with the endpoint read from the `api: openai-speech` row of `agent/models.yml` (no host, no secret in the file). Verified live in a real TUI session on the workstation: roster `GET /v1/audio/voices` → `narrator1, narrator2, narrator3`; `/say` produced 99,884 B / 2.08 s and 69,164 B / 1.44 s WAVs handed to the player, temp dirs cleaned afterwards; `/say on` armed the footer (`🔊 narrator1`) and each following finished turn spoke its answer (1.20 s WAV), an unknown voice name answered with the roster instead of speaking, and an `agent_end` carrying `willContinue` (the harness scheduled an auto-retry) is deliberately never narrated. Extension discovery itself proved with a probe file in `~/.omp/agent/extensions/` loaded by a fresh `omp -p` run. Reference page [dankvoice](core/dankvoice.md), registered in the core index; black-box seam `tests/dankvoice.bats` (12 tests).
|
||||||
|
|||||||
+172
-16
@@ -3,15 +3,19 @@
|
|||||||
#
|
#
|
||||||
# The extension's contract, from outside: it registers a `say` command and an
|
# 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
|
# `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 notification
|
# speech route, (b) the file it hands to the player, and (c) the notifications
|
||||||
# text it prints. A fixture HTTP server stands in for frank-tts (it answers the
|
# and select-lists it renders. A fixture HTTP server stands in for frank-tts
|
||||||
# speech route with a fixed WAV, the roster route with a JSON array, and
|
# (it answers the speech route with a fixed WAV, the roster route with a JSON
|
||||||
# everything else with 404) and a stub player stands in for pw-play: it records
|
# array, and everything else with 404), a stub player stands in for pw-play
|
||||||
# the path it was given and copies the bytes it was told to 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
|
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
|
||||||
# (player fixture), DV_MODELS (models.yml fixture), DV_EMPTY_BIN (a PATH with no
|
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
|
||||||
# players), DV_MODE (the harness mode reported to the extension).
|
# 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() {
|
setup() {
|
||||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||||
@@ -26,12 +30,27 @@ setup() {
|
|||||||
|
|
||||||
cat > "$DV_STUB" <<'SH'
|
cat > "$DV_STUB" <<'SH'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
printf '%s\n' "$1" >> "$DV_STUB_LOG"
|
printf '%s\n' "$*" >> "$DV_STUB_LOG"
|
||||||
cp "$1" "$DV_COPY"
|
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
SH
|
SH
|
||||||
chmod +x "$DV_STUB"
|
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
|
# models.yml fixture: a chat provider first, the speech provider second
|
||||||
export DV_MODELS="$WORK/models.yml"
|
export DV_MODELS="$WORK/models.yml"
|
||||||
cat > "$DV_MODELS" <<'YML'
|
cat > "$DV_MODELS" <<'YML'
|
||||||
@@ -49,11 +68,19 @@ providers:
|
|||||||
api: openai-speech
|
api: openai-speech
|
||||||
YML
|
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'
|
cat > "$WORK/driver.ts" <<'TS'
|
||||||
// Black-box driver: stand up the frank-tts fixture, load the extension against
|
// 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.
|
// it, invoke one scenario, print what the endpoint received and what got played.
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
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]);
|
||||||
@@ -75,6 +102,14 @@ const server = createServer((req, res) => {
|
|||||||
res.end(AUDIO);
|
res.end(AUDIO);
|
||||||
return;
|
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.statusCode = 404;
|
||||||
res.end("{}");
|
res.end("{}");
|
||||||
});
|
});
|
||||||
@@ -87,8 +122,11 @@ if (scenario === "endpoint") {
|
|||||||
} else {
|
} else {
|
||||||
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
|
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";
|
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
|
||||||
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_STUB;
|
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 extension = await import(process.env.DV_EXT as string);
|
||||||
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
|
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
|
||||||
@@ -97,11 +135,19 @@ extension.default({
|
|||||||
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
|
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),
|
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 = {
|
const ctx = {
|
||||||
mode: process.env.DV_MODE ?? "tui",
|
mode: process.env.DV_MODE ?? "tui",
|
||||||
ui: {
|
ui: {
|
||||||
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
|
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
|
||||||
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
|
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 say = commands.get("say");
|
||||||
@@ -121,6 +167,16 @@ if (!say) {
|
|||||||
await say.handler("voice narrator2", ctx);
|
await say.handler("voice narrator2", ctx);
|
||||||
await say.handler("good morning", ctx);
|
await say.handler("good morning", ctx);
|
||||||
break;
|
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":
|
case "auto":
|
||||||
await say.handler("on", ctx);
|
await say.handler("on", ctx);
|
||||||
await agentEnd?.(answer("the answer"), ctx);
|
await agentEnd?.(answer("the answer"), ctx);
|
||||||
@@ -150,6 +206,23 @@ if (!say) {
|
|||||||
case "truncate":
|
case "truncate":
|
||||||
await say.handler("Sentence one is here. ".repeat(40), ctx);
|
await say.handler("Sentence one is here. ".repeat(40), ctx);
|
||||||
break;
|
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":
|
case "noplayer":
|
||||||
await say.handler("speak anyway", ctx);
|
await say.handler("speak anyway", ctx);
|
||||||
break;
|
break;
|
||||||
@@ -168,9 +241,10 @@ 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(`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() : "";
|
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}`);
|
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}`);
|
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
|
||||||
server.close();
|
server.close();
|
||||||
TS
|
TS
|
||||||
@@ -183,7 +257,7 @@ teardown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
say_run() {
|
say_run() {
|
||||||
run bun "$DV_DRIVER" "$1"
|
run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "the extension registers the say command and an agent_end listener" {
|
@test "the extension registers the say command and an agent_end listener" {
|
||||||
@@ -235,18 +309,76 @@ say_run() {
|
|||||||
@test "the endpoint is taken from the speech row of the models file, never the chat provider" {
|
@test "the endpoint is taken from the speech row of the models file, never the chat provider" {
|
||||||
say_run endpoint
|
say_run endpoint
|
||||||
[[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]]
|
[[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]]
|
||||||
|
[[ "$output" == *"humanize chatmodel"* ]]
|
||||||
[[ "$output" != *"10.0.0.1"* ]]
|
[[ "$output" != *"10.0.0.1"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "markdown never reaches the speech engine" {
|
@test "markdown never reaches the speech engine" {
|
||||||
say_run markdown
|
say_run markdown
|
||||||
[[ "$output" == *"input=Title Use pw-play here. see docs and bold a b"* ]]
|
[[ "$output" == *"input=Title"* ]]
|
||||||
|
[[ "$output" == *"input=Use pw-play here."* ]]
|
||||||
|
[[ "$output" == *"input=see docs and bold"* ]]
|
||||||
|
[[ "$output" != *"npm i x"* ]]
|
||||||
|
[[ "$output" != *"example.test"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "an over-long utterance is bounded and cut at a sentence boundary" {
|
@test "the spoken budget keeps only the leading whole segments" {
|
||||||
DANKVOICE_MAX=60 say_run truncate
|
DANKVOICE_MAX=60 say_run truncate
|
||||||
[[ "$output" == *"here. …"* ]]
|
[[ "$output" == *"CALLS 1"* ]]
|
||||||
[ "$(grep -o 'Sentence one is here\.' <<<"$output" | wc -l)" -le 4 ]
|
[[ "$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" {
|
@test "a missing player is reported, not swallowed" {
|
||||||
@@ -261,3 +393,27 @@ say_run() {
|
|||||||
[[ "$output" == *"input=the settled answer"* ]]
|
[[ "$output" == *"input=the settled answer"* ]]
|
||||||
[[ "$output" != *"input=not yet"* ]]
|
[[ "$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"* ]]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user