Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd52bb0f4 | ||
|
|
edd7e0cf68 | ||
|
|
f033639b54 | ||
|
|
6fffd83957 | ||
|
|
dd8f3974c1 | ||
|
|
acf67182e9 | ||
|
|
38bf9c4897 | ||
|
|
54f15e2a58 | ||
|
|
1e7e212e5c | ||
|
|
475211d2e7 | ||
|
|
0258ef70f8 | ||
|
|
0c7fe8b4bf |
+16
-8
@@ -1,6 +1,10 @@
|
||||
providers:
|
||||
openrouterVariant: online
|
||||
fetch: auto
|
||||
maxInFlightRequests:
|
||||
llamacpp (dankserver): 3
|
||||
llamacpp (dankclaw): 2
|
||||
llamacpp (dankclaw-3090): 3
|
||||
modelRoles:
|
||||
slow: llamacpp (dankclaw)/frank-dragoon:medium
|
||||
vision: llamacpp (dankclaw-3090)/frank-zealot:xhigh
|
||||
@@ -145,17 +149,24 @@ display:
|
||||
subagentLivePreview: true
|
||||
task:
|
||||
showResolvedModelBadge: true
|
||||
maxConcurrency: 3
|
||||
maxConcurrency: 2
|
||||
agentModelOverrides:
|
||||
designer: llamacpp (dankclaw-3090)/frank-zealot
|
||||
librarian: llamacpp (dankclaw-3090)/frank-zealot
|
||||
reviewer: llamacpp (dankserver)/frank-archon
|
||||
scout: llamacpp (dankserver)/frank-archon
|
||||
scout: llamacpp (dankclaw-3090)/frank-zealot
|
||||
security-reviewer: llamacpp (dankclaw)/frank-dragoon
|
||||
sonic: llamacpp (dankclaw-3090)/frank-zealot
|
||||
task: llamacpp (dankserver)/frank-archon
|
||||
eager: default
|
||||
enableLsp: true
|
||||
maxRuntimeMs: 1800000
|
||||
softRequestBudget: 120
|
||||
maxRecursionDepth: 1
|
||||
disabledAgents: []
|
||||
isolation:
|
||||
enabled: true
|
||||
completionProbe: false
|
||||
snapcompact:
|
||||
systemPrompt: none
|
||||
toolResults: false
|
||||
@@ -203,13 +214,9 @@ vault:
|
||||
tts:
|
||||
localVoice: bm_george
|
||||
speech:
|
||||
enabled: true
|
||||
mode: yield
|
||||
enabled: false
|
||||
mode: all
|
||||
enhanced: true
|
||||
# Kokoro voice for SPEAK-ALOUD only — the enum is Kokoro's, and this block never
|
||||
# reaches an HTTP request. The tts tool forwards `voice_id` and nothing else, so
|
||||
# a frank-tts pack name here would break the vocalizer and still leave a
|
||||
# synthesis call voice-less. Say `voice_id: "narrator1"` in the call.
|
||||
voice: am_fenrir
|
||||
secrets:
|
||||
enabled: true
|
||||
@@ -236,6 +243,7 @@ followUpMode: one-at-a-time
|
||||
tools:
|
||||
approvalMode: yolo
|
||||
format: auto
|
||||
maxTimeout: 600
|
||||
marketplace:
|
||||
autoUpdate: notify
|
||||
bash:
|
||||
|
||||
+562
-85
@@ -7,28 +7,42 @@
|
||||
* agent/models.yml) could only ever produce files. This extension adds the
|
||||
* missing path — the TUI speaks with the cloned voices:
|
||||
*
|
||||
* /say <text> speak once through the speakers
|
||||
* /say on | off auto-speak the assistant's final answer each turn
|
||||
* /say voice <name> choose a voice (empty = whatever the server defaults to)
|
||||
* /say voices list the voices frank-tts reports
|
||||
* /say stop cut off the current utterance
|
||||
* /say status voice, endpoint and player in use
|
||||
* /say open the options menu
|
||||
* /say <text> speak once through the speakers
|
||||
* /say on | off arm/disarm auto-speak of the assistant's final answer
|
||||
* /say menu the options menu (voice, output device, player, cap)
|
||||
* /say voice <name> choose a voice (empty = whatever the server defaults to)
|
||||
* /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
|
||||
* `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_MODEL served model id (default: the id in that block)
|
||||
* DANKVOICE_VOICE default voice name
|
||||
* DANKVOICE_AUTO=1 start with auto-speak armed
|
||||
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
|
||||
* DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json)
|
||||
* 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
|
||||
*
|
||||
* Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed
|
||||
* when its player process exits. A new utterance interrupts the previous one
|
||||
* instead of queueing behind it.
|
||||
* Long answers are cut into speakable segments by a state machine ported
|
||||
* from omp's built-in vocalizer: strong boundaries on [.!?…] runs followed
|
||||
* 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";
|
||||
@@ -62,6 +76,7 @@ export interface Player {
|
||||
}
|
||||
|
||||
export type SayAction =
|
||||
| { kind: "menu" }
|
||||
| { kind: "help" }
|
||||
| { kind: "speak"; text: string }
|
||||
| { kind: "on" }
|
||||
@@ -75,7 +90,8 @@ export type SayAction =
|
||||
export function parseSayArgs(raw: string): SayAction {
|
||||
const args = raw.trim();
|
||||
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 === "off") return { kind: "off" };
|
||||
if (lower === "stop") return { kind: "stop" };
|
||||
@@ -162,38 +178,319 @@ export function parseVoices(payload: unknown): string[] {
|
||||
return names.filter((name) => name.length > 0);
|
||||
}
|
||||
|
||||
/** Player preference: PipeWire first (this fleet runs WirePlumber), then Pulse, then ffmpeg. */
|
||||
export function detectPlayer(lookup: (command: string) => string | null): Player | null {
|
||||
const candidates: Player[] = [
|
||||
{ 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;
|
||||
/** Known players, PipeWire first (the workstations run WirePlumber); first existing wins. */
|
||||
export function availablePlayers(lookup: (command: string) => string | null): string[] {
|
||||
return ["pw-play", "paplay", "ffplay"].filter((command) => lookup(command) !== null);
|
||||
}
|
||||
|
||||
/** What a speech engine should say: prose only, no fences, no markup, bounded. */
|
||||
export function stripForSpeech(text: string, maxChars: number = DEFAULT_MAX_CHARS): string {
|
||||
let out = text;
|
||||
out = out.replace(/```[\s\S]*?```/g, " ");
|
||||
out = out.replace(/~~~[\s\S]*?~~~/g, " ");
|
||||
out = out.replace(/`([^`]*)`/g, "$1");
|
||||
out = out.replace(/!\[[^\]]*\]\([^)]*\)/g, " ");
|
||||
out = out.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
out = out.replace(/^\s{0,3}#{1,6}\s+/gm, "");
|
||||
out = out.replace(/^\s*[-*+]\s+/gm, "");
|
||||
out = out.replace(/^\s*\|[-: |]+\|\s*$/gm, " ");
|
||||
out = out.replace(/\|/g, " ");
|
||||
out = out.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, "$1");
|
||||
out = out.replace(/\s+/g, " ").trim();
|
||||
if (out.length <= maxChars) return out;
|
||||
const window = out.slice(0, maxChars);
|
||||
const cut = Math.max(window.lastIndexOf(". "), window.lastIndexOf("! "), window.lastIndexOf("? "));
|
||||
const clipped = cut > maxChars / 2 ? window.slice(0, cut + 1) : window.replace(/[,;:]\s*\S*$/, "");
|
||||
return `${clipped.trim()} …`;
|
||||
/** One player invocation: the saved sink becomes each player's routing dialect. */
|
||||
export function playerFor(command: string, sink: string): Player {
|
||||
const base = command.split("/").pop() ?? command;
|
||||
if (base === "ffplay") return { command, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] };
|
||||
if (!sink) return { command, args: [] };
|
||||
if (base === "paplay") return { command, args: [`--device=${sink}`] };
|
||||
return { command, args: ["--target", sink] };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ segmenting
|
||||
//
|
||||
// Ported from omp's built-in speak-aloud vocalizer (the `fHe` segmenter and
|
||||
// its helpers in dist/cli.js). The same boundary rules as the stock path,
|
||||
// kept deliberately: push() takes text and returns the segments that are
|
||||
// complete, flush() ends the message.
|
||||
|
||||
const SENTENCE_END = /[.!?\u2026]+[)\]"'\u00BB\u201D\u2019]*\s/g;
|
||||
const WEAK_BREAK = /[,;:\u2014\u2013]\s/g;
|
||||
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;
|
||||
}
|
||||
|
||||
/** The text the assistant last actually said; tool-only messages do not count. */
|
||||
@@ -217,11 +514,62 @@ export function lastAssistantText(messages: readonly unknown[]): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Menu choices, persisted between sessions; every field optional. */
|
||||
export interface Settings {
|
||||
voice?: string;
|
||||
player?: string;
|
||||
sink?: string;
|
||||
maxChars?: number;
|
||||
auto?: 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;
|
||||
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
|
||||
|
||||
interface Config {
|
||||
target?: SpeechTarget;
|
||||
player?: Player;
|
||||
sink: string;
|
||||
maxChars: number;
|
||||
auto: boolean;
|
||||
voice: string;
|
||||
@@ -242,8 +590,10 @@ function readModelsYml(): string {
|
||||
}
|
||||
|
||||
function loadConfig(): Config {
|
||||
const overrideUrl = process.env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
||||
const modelOverride = process.env.DANKVOICE_MODEL?.trim();
|
||||
const env = process.env;
|
||||
const settings = readSettings(settingsPath(env));
|
||||
const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
||||
const modelOverride = env.DANKVOICE_MODEL?.trim();
|
||||
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
|
||||
const target: SpeechTarget | undefined = overrideUrl
|
||||
? {
|
||||
@@ -255,17 +605,25 @@ function loadConfig(): Config {
|
||||
: fromYml
|
||||
? { ...fromYml, model: modelOverride || fromYml.model }
|
||||
: undefined;
|
||||
const playerOverride = process.env.DANKVOICE_PLAYER?.trim();
|
||||
const maxChars = Number(process.env.DANKVOICE_MAX);
|
||||
const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0];
|
||||
const sink = settings.sink ?? "";
|
||||
const maxChars = Number(env.DANKVOICE_MAX);
|
||||
return {
|
||||
target,
|
||||
player: playerOverride ? { command: playerOverride, args: [] } : (detectPlayer(lookupOnPath) ?? undefined),
|
||||
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_MAX_CHARS,
|
||||
auto: process.env.DANKVOICE_AUTO === "1",
|
||||
voice: process.env.DANKVOICE_VOICE?.trim() ?? "",
|
||||
player: playerCommand ? playerFor(playerCommand, sink) : undefined,
|
||||
sink,
|
||||
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : (settings.maxChars ?? DEFAULT_MAX_CHARS),
|
||||
auto: env.DANKVOICE_AUTO === "1" || (settings.auto ?? false),
|
||||
voice: env.DANKVOICE_VOICE?.trim() ?? settings.voice ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> {
|
||||
const res = await fetch(target.speechUrl, {
|
||||
@@ -337,23 +695,61 @@ function startPlayback(player: Player, file: string, dir: string): ChildProcess
|
||||
|
||||
export default function dankvoice(pi: ExtensionAPI) {
|
||||
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,
|
||||
voice: cfg.voice,
|
||||
run: 0,
|
||||
};
|
||||
const voiceLabel = () => state.voice || "server default";
|
||||
const reflectStatus = () => state.lastCtx?.ui.setStatus("dankvoice", state.auto ? `🔊 ${voiceLabel()}` : undefined);
|
||||
|
||||
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");
|
||||
state.child = undefined;
|
||||
};
|
||||
|
||||
const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)";
|
||||
|
||||
const persistSettings = (ctx: ExtensionContext) => {
|
||||
const settings: Settings = {
|
||||
voice: state.voice,
|
||||
player: cfg.player?.command ?? "",
|
||||
sink: cfg.sink,
|
||||
maxChars: cfg.maxChars,
|
||||
auto: state.auto,
|
||||
};
|
||||
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.
|
||||
* Superseded chains (new utterance, /say stop, disarm) exit at the gates.
|
||||
*/
|
||||
async function speak(ctx: ExtensionContext, text: string) {
|
||||
const spoken = stripForSpeech(text, cfg.maxChars);
|
||||
if (!spoken) return;
|
||||
if (!cfg.target) {
|
||||
ctx.ui.notify(missingTarget(), "error");
|
||||
return;
|
||||
@@ -362,56 +758,132 @@ export default function dankvoice(pi: ExtensionAPI) {
|
||||
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
|
||||
return;
|
||||
}
|
||||
const target = cfg.target;
|
||||
const player = cfg.player;
|
||||
const segments = budgetSegments(segmentsForSpeech(text), cfg.maxChars);
|
||||
if (segments.length === 0) return;
|
||||
stop();
|
||||
const dir = mkdtempSync(join(tmpdir(), "dankvoice-"));
|
||||
try {
|
||||
const file = await synthesize(cfg.target, speechBody(cfg.target.model, spoken, state.voice), dir, SYNTH_TIMEOUT_MS);
|
||||
state.child = startPlayback(cfg.player, file, dir);
|
||||
} catch (error) {
|
||||
stop();
|
||||
const run = state.run;
|
||||
for (const segment of segments) {
|
||||
if (state.run !== run) return;
|
||||
const dir = mkdtempSync(join(tmpdir(), "dankvoice-"));
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
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;
|
||||
}
|
||||
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
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)`,
|
||||
"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;
|
||||
}
|
||||
await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`);
|
||||
}
|
||||
|
||||
pi.registerCommand("say", {
|
||||
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) => {
|
||||
state.lastCtx = ctx;
|
||||
const action = parseSayArgs(args);
|
||||
switch (action.kind) {
|
||||
case "menu":
|
||||
await openMenu(ctx);
|
||||
return;
|
||||
case "help":
|
||||
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",
|
||||
);
|
||||
return;
|
||||
case "on":
|
||||
state.auto = true;
|
||||
reflectStatus();
|
||||
ctx.ui.notify(`dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})`, "info");
|
||||
setAuto(ctx, true);
|
||||
return;
|
||||
case "off":
|
||||
state.auto = false;
|
||||
stop();
|
||||
reflectStatus();
|
||||
ctx.ui.notify("dankvoice: auto-speak off", "info");
|
||||
setAuto(ctx, false);
|
||||
return;
|
||||
case "stop":
|
||||
stop();
|
||||
return;
|
||||
case "voice":
|
||||
state.voice = action.name;
|
||||
reflectStatus();
|
||||
ctx.ui.notify(
|
||||
action.name ? `dankvoice: voice = ${action.name}` : "dankvoice: voice = whatever frank-tts defaults to",
|
||||
"info",
|
||||
);
|
||||
setVoice(ctx, action.name);
|
||||
return;
|
||||
case "voices": {
|
||||
if (!cfg.target) {
|
||||
@@ -435,18 +907,18 @@ export default function dankvoice(pi: ExtensionAPI) {
|
||||
}
|
||||
case "status":
|
||||
ctx.ui.notify(
|
||||
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · 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`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
state.lastCtx = ctx;
|
||||
if (!state.auto) return;
|
||||
// `willContinue` means the harness already scheduled a continuation
|
||||
@@ -454,7 +926,12 @@ export default function dankvoice(pi: ExtensionAPI) {
|
||||
// settle, so narrating it would speak an answer the model is about to redo.
|
||||
if (event.willContinue) 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)).catch((error) =>
|
||||
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"),
|
||||
);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", () => {
|
||||
|
||||
@@ -1,82 +1,32 @@
|
||||
---
|
||||
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),
|
||||
when it **finishes its deliverable and still never exits**, when you want a hard cap
|
||||
before spawning, or when you must tell a busy agent to stop.
|
||||
## Read first
|
||||
- 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.
|
||||
- `cfg://task` and `cfg://providers/maxInFlightRequests` — effective values, not the stock defaults.
|
||||
|
||||
## The knobs (`cfg://task/...`)
|
||||
|
||||
| Key | Default | Meaning |
|
||||
## The knobs (cfg:// writes; `/save` suffix persists)
|
||||
| Knob | Effect | Gotcha |
|
||||
|---|---|---|
|
||||
| `maxRuntimeMs` | `0` (unlimited) | **Hard wall-clock limit per subagent (ms).** The only true time cap. |
|
||||
| `softRequestBudget` | `200` | Soft cap on assistant **requests** per run; force-stop at **1.5×**. |
|
||||
| `softRequestBudgetNotice` | `true` | Injects one "wrap up" steering message at the soft mark. |
|
||||
| `agentIdleTtlMs` | `420000` | Parks an **idle** agent after 7 min. Useless against a busy-but-slow agent. |
|
||||
| `completionProbe` | `true` | Asks a working subagent to self-report completeness at 2/5/10 requests. |
|
||||
| `maxConcurrency` | `32` (often lowered) | Concurrent subagents; sizes GPU/endpoint load. |
|
||||
| `agentModelOverrides` | `{}` | Per-agent model routing, e.g. `{"scout":"provider/model-id"}`. |
|
||||
| `tools.maxTimeout` | `0` | Caps the timeout the agent may set on a **tool**, not on the agent. |
|
||||
| `task.maxConcurrency` | session semaphore; excess queues | stock default 32 — "2" is not a default |
|
||||
| `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 |
|
||||
| `task.maxRuntimeMs` | hard wall clock, overrun = `aborted` | default 0 = off |
|
||||
| `task.softRequestBudget` | wrap-up notice at N, force-yield at 1.5N | scout/sonic may impose lower built-ins |
|
||||
| `task.agentIdleTtlMs` | park idle finished agents | `<= 0` disables parking |
|
||||
| `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 |
|
||||
| `tools.maxTimeout` | ceiling on any agent-requested tool timeout | kills `timeout: 0` runaways |
|
||||
| `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
|
||||
alone unless the failure mode is a looping search agent (then lower it — request count,
|
||||
not the clock, is what bounds a 200-call research loop).
|
||||
## Verify semantics from the bundle, not the label
|
||||
Setting descriptions lie by omission. `~/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js`:
|
||||
- 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
|
||||
any session that plans to rely on it. A session-only write is gone next session, and an
|
||||
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.
|
||||
## Landing a policy change
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: frank-tts-consumer-voice-triage
|
||||
description: "Triage a consumer (OpenWebUI, omp, open-notebook) rejecting or ignoring a frank-tts voice name: prove whether the server or the browser-side Kokoro.js engine produced the error."
|
||||
---
|
||||
|
||||
# frank-tts consumer voice triage
|
||||
|
||||
A consumer says a voice name is invalid, speaks in the wrong voice, or is
|
||||
silent. `frank-tts` (`192.168.0.143:7772`, shim
|
||||
`dankclaw/voiceservice/app.py`) resolves `voice=<name>` against
|
||||
`/home/dank/voices/<name>/reference.wav` and answers **400 + the list of
|
||||
voices that exist** for a name it cannot resolve — never a silent fallback.
|
||||
Any error naming a *different* voice list did not come from it.
|
||||
|
||||
**Signature that decides the case immediately:** an enum of ~29 names
|
||||
`af_heart, af_alloy, … am_*, bf_*, bm_* … bm_fable` is **Kokoro's fixed pack**,
|
||||
the voice list of **Kokoro.js running in the browser** (OpenWebUI ships it
|
||||
in its own front end as TTS engine value `browser-kokoro`, with a personal
|
||||
"Kokoro.js dtype" setting; model `onnx-community/Kokoro-82M-v1.0-ONNX`).
|
||||
`narrator1/2/3` are frank-tts cloned voices. Kokoro cannot clone.
|
||||
|
||||
## Instrument ladder (stop at the first that answers)
|
||||
|
||||
1. **What the endpoint actually accepts** (never infer from a consumer's text):
|
||||
`curl -s http://127.0.0.1:7772/v1/audio/voices` → JSON array of pack names.
|
||||
2. **Speak with the consumer's exact body, from inside the consumer's
|
||||
container** — same network, same shape. Write the JSON to a file and
|
||||
`--data-binary @file` (nested `sh -c` quoting silently sends an empty
|
||||
body and yields a bogus `400 malformed request body`):
|
||||
```
|
||||
printf '%s' '{"model":"frank-tts","input":"probe","voice":"narrator1","response_format":"mp3","speed":1.0}' > /tmp/p.json
|
||||
podman cp /tmp/p.json <consumer>:/tmp/p.json
|
||||
podman exec <consumer> curl -sS -o /tmp/o.bin -w 'http=%{http_code} type=%{content_type} bytes=%{size_download}\n' \
|
||||
-X POST http://host.containers.internal:7772/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' --data-binary @/tmp/p.json
|
||||
```
|
||||
`200 audio/mpeg|audio/wav` ⇒ the server path is healthy; the failure is elsewhere.
|
||||
3. **Did the request arrive at all?** `podman logs --tail 400 <consumer> | grep -c audio/speech`.
|
||||
A server-path failure leaves a log line **and** a frank-tts error body; a
|
||||
client-side engine leaves **neither**. This contrast is the discriminator.
|
||||
4. **Where the rejection string lives** (proves the emitter without guessing):
|
||||
```
|
||||
podman exec <consumer> sh -c 'grep -ho ".\{200\}Should be one of.\{80\}" /app/build/_app/immutable/chunks/*.js | head'
|
||||
podman exec <consumer> sh -c 'grep -ho ".\{60\}af_heart.\{200\}" /app/build/_app/immutable/chunks/*.js | head -2'
|
||||
```
|
||||
Matched inside the front-end bundle ⇒ browser-side validator, not the API.
|
||||
5. **Config authority sweep** (OpenWebUI): admin config is the `config`
|
||||
table (one key per row, JSON `value`; expected
|
||||
`audio.tts.engine=openai`, `audio.tts.model`, `audio.tts.voice`,
|
||||
`audio.tts.openai.api_base_url`); per-user overrides are the `settings`
|
||||
JSON column of table `user` (singular, no `is_active`; some rows hold the
|
||||
string `null` → guard it). `audio.stt.*` and `audio.tts.*` are separate.
|
||||
No `sqlite3` CLI in the image or on the host: `podman exec -i <c> python -`
|
||||
with the stdlib module. **`podman exec` needs `-i` to accept a heredoc
|
||||
script** — without it the script is silently dropped and you get no output.
|
||||
|
||||
## Known traps
|
||||
|
||||
- OpenWebUI is a **rootless** quadlet: `sudo podman …` reports *no such
|
||||
container*. Read it rootless.
|
||||
- `podman ps` in this podman build has no `.ImageName` field — use `{{.Image}}`.
|
||||
- The in-browser engine choice is **per-browser storage**: empty
|
||||
`user.settings` + correct admin config + zero backend hits is the normal
|
||||
shape of this bug. Fix is UI: Settings → Audio → TTS Engine → **Default**.
|
||||
- `GET /api/config` publishes no `audio` block, and nonexistent `/api/...`
|
||||
paths return the SPA HTML with `200` — never conclude "config missing" from
|
||||
an HTML body.
|
||||
- TTS is memory-registry sensitive: the engine's voice registry is
|
||||
memory-only, so "every name returns 400" after a restart points at the warm
|
||||
pass, not at the caller (see [[frank-tts-add-cloned-voice]]).
|
||||
|
||||
## Local playback side (omp `/say`, manual probes)
|
||||
|
||||
If synthesis succeeds (`200`, sane WAV bytes) but nothing is audible, the
|
||||
fault is the PipeWire graph, not the endpoint:
|
||||
`wpctl status` (default sink marked `*`, and *Default Configured Devices* —
|
||||
a remembered-but-offline `bluez_output.*` there is the classic silent
|
||||
route), `wpctl get-volume @DEFAULT_AUDIO_SINK@`, `pw-play -v <file>` (expect
|
||||
`stream drained` + exit 0), and target a device explicitly with
|
||||
`pw-play --target <node-id>` to identify which hardware is live. A one-word
|
||||
clip (~0.7 s) is easy to miss — probe with a multi-second sentence.
|
||||
@@ -1,50 +1,52 @@
|
||||
---
|
||||
name: noctalia-bar-layout-edit
|
||||
description: "Edit/verify Noctalia v5 bar layout and clock widgets (multi-timezone instances, capsule groups); GUI→repo propagation chain, settings.toml symlink audit, and clobber-recovery via dangling stashes on Frank's dotfiles workstations"
|
||||
description: "Edit/verify Noctalia v5 bar layout and clock widgets (multi-timezone instances, capsule groups); GUI-repo propagation chain, settings.toml symlink audit, and stash-clobber recovery on Frank's dotfiles workstations"
|
||||
---
|
||||
|
||||
# Noctalia v5 bar layout edit
|
||||
|
||||
Bar widget layout on Frank's noctalia v5 workstations is GUI-override state, not config.toml.
|
||||
|
||||
## Where
|
||||
- Layout file: `~/.local/state/noctalia/settings.toml` → `[bar.<name>]` keys `start` / `center` / `end` (arrays of widget ids; `[widget.<id>]` subtables hold per-widget settings).
|
||||
- On Frank's boxes this file MUST be a **symlink into the command-center repo** (e.g. `~/.omp/dotfiles/noctalia/.local/state/noctalia/settings.toml`) — edit the repo file, not the link path (atomic-rename writes through the link path would replace the symlink).
|
||||
- Layout file: `~/.local/state/noctalia/settings.toml` -> `[bar.<name>]` keys `start` / `center` / `end` (arrays of widget ids; `[widget.<id>]` subtables hold per-widget settings).
|
||||
- On Frank's boxes this file MUST be a **symlink into the command-center repo** (e.g. `~/.omp/dotfiles/noctalia/.local/state/noctalia/settings.toml`) - edit the repo file, not the link path (atomic-rename writes through the link path would replace the symlink).
|
||||
- Layer order: built-in defaults < `~/.config/noctalia/config.toml` (+`[include]`) < `settings.toml` (wins). Noctalia writes through symlinks and keeps them.
|
||||
- Defaults when a key is absent: `start = ["launcher","wallpaper","workspaces"]`, `center = ["clock"]`.
|
||||
- **Noctalia rewrites this file itself** (GUI actions + normalizes keys/format, e.g. strips `{:%...}` braces, reorders widget lists). Always re-read the exact lines immediately before editing; stale line anchors WILL drift between reads.
|
||||
- Defaults when a key is absent: `start = ["launcher","wallpaper","workspaces"]`, `center = ["clock"]`, `end = ["notifications","clipboard","network","bluetooth","tray","battery","session","control-center","clock"]`.
|
||||
|
||||
## GUI→repo propagation (checkout-is-deployment)
|
||||
- `~/.config/noctalia/config.toml` is symlinked into the repo → GUI settings writes land in the repo working tree automatically.
|
||||
- `~/.local/state/noctalia/settings.toml` can SILENTLY become a plain file (observed 2026-10-17; hardlink/symlink wiring decays) → GUI bar-layout changes then diverge from the repo with no signal. AUDIT: `readlink ~/.local/state/noctalia/settings.toml` must print the repo path; if it's a plain file and repo copy is content-identical, re-arm: `ln -sfn <repo-path> <live-path>` + `noctalia config validate`.
|
||||
- Landing in the working tree ≠ propagated: other boxes only see it after commit + push to Gitea main. `live-sync.path` (PR #15; DirectoryNotEmpty on `dotfiles/`) commits dotfiles-tier writes within ~2 s once deployed — until it is active on a box, the hourly heartbeat leaves up to 59 min of exposure, and on 2026-10-18 exactly that window let a trunk-sync stash destroy months of GUI layout work. Until verified active (`systemctl --user status live-sync.path`), commit GUI layout edits the same session yourself. Noctalia's runtime churn (wallpaper path, nightlight, panel positions) rides along; normal.
|
||||
- niri has NO settings GUI; niri "GUI changes" don't exist, and `niri msg output ...` runtime overrides are not persisted. niri cfg mirror↔live links also decay (all inodes decoupled once) → audit by CONTENT: `diff` every `~/.config/niri/cfg/*.kdl` against its `dotfiles/zz-blankpad/niri/...` mirror, never trust links.
|
||||
## Clobber window and recovery (2026-10-18 incident law)
|
||||
- GUI-built layout is **working-tree-only until the hourly live-sync heartbeat commits it**. A merge/sync session that "clears the dirty tree" can `git stash` those edits, and later stash operations can destroy the entries - customizations then vanish from the live bar with the repo copy stale.
|
||||
- Never blame the GUI or noctalia first: diff live file vs `HEAD`, check heartbeat commits (`git log -- dotfiles/noctalia/.local/state/noctalia/settings.toml`), then the stash chain.
|
||||
- Destroyed stashes are recoverable: `git fsck --lost-found` lists dangling commits; `git show <dangling>:dotfiles/noctalia/.local/state/noctalia/settings.toml` dumps each candidate; pick by `[bar.default]` richness (capsule groups, extra clock instances). Validate a candidate in a sandboxed `XDG_*_HOME` before touching the live file.
|
||||
- After recovery, commit the file immediately so the window closes.
|
||||
|
||||
## If the bar got wiped (recovery)
|
||||
- The working-tree file is often the ONLY copy of GUI work; a `git checkout`/stash/merge clobber is survivable because git objects outlive stash entries: `git fsck --lost-found`, inspect `git log -1 --format='%ci %s' <dangling>` (newest `WIP on` stash commits hold the last good settings.toml), dump with `git show <c>:dotfiles/noctalia/.local/state/noctalia/settings.toml`.
|
||||
- Before restoring: back up the clobbered file to `.AGENTTRASH/`; validate the candidate in a sandbox (`XDG_CONFIG_HOME=… XDG_STATE_HOME=… noctalia config validate` — plugin-widget "unrecognized type" warnings there are offline-CLI blindness, not blockers); restore via `cp` onto the repo path (cp writes through the symlink; `mv` would replace it); prove visually with `noctalia msg screenshot-fullscreen`; commit immediately.
|
||||
- Cross-check the recovered layout against Frank's old screenshots in `~/Pictures/screenshot_*.png` (crop top ~7% with magick) — they are ground truth for what the bar should look like.
|
||||
## Widgets (v5 census, verified 2026-10-17)
|
||||
- Full roster in `settings.toml` widget_order plus docs: activity, backlinks, battery, bluetooth, brief, bright, cat, cheatsheet, chart, clipby, clipboard, control-center, cpu, controls, cava (now playing), disk, gpu, help, keyboard, language, launcher, mediaplayer, memory, mic, network, night-light, noctalia-palette, notification-history, notifications, niri-colors, power-section, power-profile, screenrecord, scratchpad, screenshot, session, shell (custom shell command), slowouts, slowouts-visible (per-output bar assignment), speedtest, swayosd-*, taskbar, tray, voiceline, volume, wallpaper, weather, workspaces.
|
||||
- `clock` widget: `format` (chrono strftime), `schema` (12/24), `multi_line`, `show_seconds`, `popup`, and **timezone** = IANA name. Multiple clock widget instances are supported: ids like `clock`, `clock-utc`, `clock-sea` each get a `[widget.<id>]` subtable -> one capsule per instance. This is how multi-timezone (Seattle work + UTC dev) lands: add `clock` + `clock-utc` to `end` (or `center`), set `timezone = "Etc/UTC"` on the utc one.
|
||||
- `cat` widget: shows random ASCII art, `rows` setting (4-10).
|
||||
- `shell` widget: `command`, `run interval` (seconds), `icon`, `terminal`; output lands in its capsule popup - can wrap any CLI (hyprstatus-style).
|
||||
- `taskbar` widget: shows window buttons **only on outputs where `niri-bridged` is enabled** (noctalia-launcher enables it). Buttons render **icon-only** (app icon from icon theme; fallback = first letter of title); the window title string is NOT shown in the bar. Hover tooltip carries title + app_name. Per-widget settings: `show_icons`, `show_actions`, `disable_middle_click`, `preferred_app_names` (map app_name -> icon for missing icons).
|
||||
- Widget ids usable in `[bar.*].start|center|end` are restricted to "bar widgets" (`is_bar_widget` allowlist in `bar.rs`); non-bar widget ids are silently dropped at load. `group:<id>` entries render capsule groups.
|
||||
- Capsule groups: `[bar.<name>.capsule_groups]` id = `["widget",...]`.
|
||||
- Niri layout law for bars: window width must stay **odd** (strut = (W-1)/2); bars reserve via layer-shell, niri's reserved area = layer surface size, so bar height changes need no niri edit.
|
||||
|
||||
## Apply/verify loop (no restart, no reload IPC)
|
||||
1. Edit the repo `settings.toml`.
|
||||
2. `noctalia config validate` (checks merged layers; also `noctalia config export [full] | yq -p toml` to inspect effective values).
|
||||
3. Both layers are file-watched → hot-reload. Prove visually: `noctalia msg screenshot-fullscreen` saves a PNG to `~/Pictures`; read the image and inspect the bar.
|
||||
4. **Screenshot race:** the `ok` return precedes the PNG write. Never chain `noctalia msg screenshot-fullscreen && magick "$(ls -t ~/Pictures/screenshot_*.png | head -1)" ...` in one command — you crop the PREVIOUS capture. List `ls -t` in a separate later call and crop the named file.
|
||||
5. Crop for reading: `magick <png> -crop 100%x8%+0+0 +repage /tmp/strip.png`, then read the strip; cross-check clocks against `date -u` / `date`.
|
||||
## Bar styles (GUI-visible, settings.toml keys)
|
||||
- `[bar.*]`: `thickness`, `font_family`, `font_weight`, `font_size`, `opacity`, `border_width`, `border_offset`, `rounded`, `paddings`, `icon_ratio`, `follow_layout`, `output`, `exclusive`, `layer` (`bottom` = below window layer; `overlay` = above windows), `position` (`top`/`bottom`).
|
||||
- Per-monitor bars: add a `[bar.<name>]` with `output = "<monitor>"` and `follow_layout = true` (niri) -> per-workspace strut follows tall windows on that monitor. This is the mechanism for "bar shrinks when a window is tall" (the bar is a layer-shell strut, NOT a niri layer rule).
|
||||
- The GUI "Bar" page is the reference list; anything absent from settings.toml is a built-in default.
|
||||
|
||||
## Clock widgets
|
||||
- `[widget.clock]` settings: `format`, `vertical_format`, `tooltip_format`, `timezone` (IANA id, e.g. `"UTC"`, `""` = system local). Tokens are strftime-compatible (`%H` 24h, `%I:%M %p` 12h, `%F` ISO date); `{:%...}` brace fields optional; `\n` = second line. Docs: docs.noctalia.dev → bar/widgets/clock + configuration/date-format-tokens.
|
||||
- **One timezone per widget; multiple instances allowed:** any `[widget.<id>]` needs `type = "clock"` for custom ids (bare `clock` id infers it). Each instance converts independently — e.g. local 12h + `timezone = "UTC"` 24h side by side.
|
||||
- Placement: add ids to `[bar.<name>]` start/center/end, or to a capsule group: `[[bar.default.capsule_group]]` with `id = "g1"` + `members = [...]`, referenced from `start` as `"group:g1"` (member order = left-to-right inside the capsule).
|
||||
- Local date + local 12h fit in ONE widget (same tz); the UTC 24h clock must be a second widget.
|
||||
- Expect the date widget (local) and UTC clock to disagree on the day after ~5 PM Seattle — that's correct UTC, not a bug; offer `%F` inside the UTC widget if it irks.
|
||||
## Slowouts (window-height-aware bars/panels)
|
||||
- `slowouts` = hidden engine (not a bar capsule): `[slowouts]` keys `enabled`, `duration`, `easing`, `trigger_offset`; per-monitor `[slowouts.<monitor-name>]` overrides (e.g. `DP-1`) with the same keys plus `edge`.
|
||||
- `position = "top"` + `exclusive = false` + slowouts on the monitor -> panel hides when a window touches its edge, re-reveals otherwise. `slowouts-visible` = toggle capsule.
|
||||
- **Slowouts needs niri workspace-autoresize ON** (niri `workspace-autoresize` = "per-workspace" in cfg/layout.kdl `output ... { workspace-autoresize per-workspace }`): noctalia computes hide from workspace height < panel bottom edge; uniform-strut layouts never trigger it.
|
||||
- Per-output bars: slowouts config keys are monitor names, so one `[bar.<x>]` with `output` + `exclusive=false` + `layer=overlay` per slowout monitor is the supported shape.
|
||||
|
||||
## Taskbar recipes
|
||||
- Move window indicator beside workspaces: put `"taskbar"` in `start` after `"workspaces"`.
|
||||
- Combine workspaces+windows into ONE widget: `[widget.taskbar] group_by_workspace = true` — per-workspace capsules with disc labels (focused/occupied/empty/urgent colors); `workspace_group_content = "icons"|"count"|"dots"` (count/dots: left-click activates the workspace); `hide_empty_workspaces`, `show_workspace_label`, `minimal`, `workspace_label_placement = corner|centered|inside`. Requires compositor per-window workspace assignment (niri: yes; LabWC: no → stays flat).
|
||||
## Layout modes and window title decorations
|
||||
- `[layout]`: `mode` (`tiling`|`floating`|`monocle`), `pseudotiled`, `focus_bar` (window-title bar at top, center, layer-shell; style via `[ui.controls...]`/bar-ish settings), `window_follows_cursor`, `gaps`, `border_width`, `rounded`, `blur`, `opacity`, `borderless_csd`, `show_native_decoration`.
|
||||
- `focus_bar` is the noctalia analog of niri's drawn window title, but ONE global bar (no per-window arrows). True per-window title tags = niri `draw-with-border` on window rules; noctalia adds no per-window decoration (it's a bar app + portal, composites nothing).
|
||||
- Floating mode: right-click desktop / `SUPER+Shift+F` / toggle widget; widgets `floating-directories`, `floating-files`, `pins` (sticky notes), `canvas`, `sketchpad`, `post-its`. Float windows snap to screen quarters (`SUPER+Ctrl+Alt+Arrows`) and tile into current layout (`SUPER+Shift+T`). `[layout.floating.files]` = icon/view/sort/preview size.
|
||||
- `[bar.*].window_display` = `disabled`|`icons`|`text`|`combined` - per-window chips in the bar strip (niri only; this is what niri-taskbar could not do). Values: `disabled`|`icons`|`centered`|`inside`. Requires compositor per-window workspace assignment (niri: yes; LabWC: no -> stays flat).
|
||||
- The `workspaces` widget itself has no window display; styles are regular/minimal/focus_hint only.
|
||||
|
||||
## Law
|
||||
- Full widget/setting tables: https://docs.noctalia.dev/noctalia/bar/ and .../bar/widgets/<name>/ . Commit bar changes atomically to the command-center repo (checkout is the deployment).
|
||||
- **Ask before applying.** Frank asks "how do I X" questions about the live bar; answer with the exact snippet + file, apply ONLY on explicit go. A settings.toml edit is a live-deployment change, not a demo. (Exception: restoring Frank's own layout after data loss, on his ask.)
|
||||
- Git `M` on settings.toml usually carries noctalia's own runtime churn (wallpaper path, nightlight, panel positions) alongside your edit — mention it when committing.
|
||||
- **Ask before applying.** Frank asks "how do I X" questions about the live bar; answer with the exact snippet + file, apply ONLY on explicit go. A settings.toml edit is a live-deployment change, not a demo.
|
||||
- Git `M` on settings.toml usually carries noctalia's own runtime churn (wallpaper path, nightlight, panel positions) alongside your edit - mention it when committing.
|
||||
- Right after building layout in the GUI, commit settings.toml (or trigger `scripts/live-sync.sh`) before any branch/merge work: the uncommitted-GUI-edit window is exactly where the 2026-10-18 clobber happened.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: omp-extension-live-tui-verify
|
||||
description: "Verify an omp extension's slash commands and event hooks against a real TUI session: pty submit semantics, observable side-effect instrument, transient-toast trap, agent_end willContinue law, PipeWire output triage."
|
||||
---
|
||||
|
||||
Verify that a `.ts` file in `agent/extensions/` actually works inside the interactive TUI — the black-box bats seam cannot see harness-side wiring (event names, arg passing, mode gating), and it will pass beside a dead hook.
|
||||
|
||||
Distinct from `omp-tui-live-visual-verify` (config/theme rendering proven by screenshot): here the instrument is **observable side effects**, not pixels, because extension notifications are transient toasts that vanish before the pty buffer is read.
|
||||
|
||||
## 1. Launch the exact build under test
|
||||
|
||||
```bash
|
||||
omp --no-extensions --no-session -e /abs/path/agent/extensions/<name>.ts
|
||||
```
|
||||
as a **pty service** (`pty: true`, `ready: {"log": "╭|❯|Tip:|─", "timeout": 60}`).
|
||||
`--no-extensions` + explicit `-e` proves the file loads by path and nothing else interferes. Neutral cwd (`/tmp`) so repo state cannot color the run.
|
||||
|
||||
## 2. Submit input correctly (the costly gotcha)
|
||||
|
||||
`write proc://<id>` with a trailing `\n` does **not** submit — the text just sits in the composer.
|
||||
|
||||
- **Works:** two writes — first `"/cmd args"` (no terminator), then a **separate** write whose whole content is `\r`.
|
||||
- **Broken:** `"/cmd args\r"` in one write. It still submits, but the trailing `\r` arrives inside the command args. Symptom: `unknown voice 'narrator1\r'`, an unexpected extra utterance, or a chat message whose transcript line ends in a literal `\r`. That is a harness artifact — never "fix" it by stripping control characters in the extension (`.trim()` already covers what a keyboard can produce).
|
||||
|
||||
## 3. Instrument with a stub, never with ears or eyes
|
||||
|
||||
Point the extension's player/handler at a script that appends evidence:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
echo "$1" >> /tmp/stub.log; cp "$1" "/tmp/out-$$.wav"; exit 0
|
||||
```
|
||||
Run the TUI with that stub in the env the extension reads (`DANKVOICE_PLAYER=…` style). Then every assertion is a file: `wc -l` of the log (how many times it fired), `ffprobe -show_entries format=duration` (what it produced), `ls -d <tmp-glob>` (that cleanup ran).
|
||||
|
||||
Sequence per behavior: command → CR → short `sleep` → count. **Count before and after** each command; the delta is the only honest proof a subcommand stayed silent.
|
||||
|
||||
## 4. Discriminate "fired twice" from "fired once per turn"
|
||||
|
||||
Stray CR writes can create extra empty turns, each with its own `agent_end` — two utterances for one intended prompt is usually **two turns**, not a duplicate speak. Confirm by counting assistant turns in the transcript (`read proc://<id>`), and by matching each clip's duration to its own answer's length.
|
||||
|
||||
## 5. Event-hook contract traps
|
||||
|
||||
- `agent_end` payload (`dist/types/extensibility/shared-events.d.ts`): `{ type, messages: AgentMessage[], willContinue? }`. **`willContinue: true` means the harness already scheduled a continuation** (auto-retry / empty-stop retry) — a subscriber that narrates or notifies there speaks an answer the model is about to redo. Gate on it.
|
||||
- Read the real event/payload shape from `~/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/types/…`; grep `dist/cli.js` for the literal event names to confirm they exist.
|
||||
- A driver test that *calls* the handler by hand proves nothing about emission — that is exactly the gap this live check closes.
|
||||
|
||||
## 6. Audio output goes silent while every command reports success
|
||||
|
||||
`pw-play` exits 0 and prints `stream drained` even when nobody hears anything. Triage order:
|
||||
|
||||
```bash
|
||||
wpctl status # Sinks, the `*` default, and live Streams
|
||||
wpctl get-volume @DEFAULT_AUDIO_SINK@
|
||||
```
|
||||
- A **`Default Configured Devices` entry naming a `bluez_output.*` node that is not in the Sinks list** = remembered-but-offline Bluetooth: the classic silent-route cause.
|
||||
- Confirm the stream really links: a short clip can vanish before inspection, so use a multi-second clip and read `wpctl status` mid-play, or target explicitly: `pw-play --target <node-id> <file>` (node ids from `wpctl status`; `wpctl inspect <id>` gives `node.name`).
|
||||
- Prove the synth path independently of the player: `curl -o x.wav …` then `file x.wav` (expect `RIFF … WAVE … PCM`).
|
||||
|
||||
## 7. Land it
|
||||
|
||||
Live result belongs in the commit message, the reference page, and the work log — and the guard the live check uncovered gets its own behavior test (assert observable output only: fired count, payload content, silence).
|
||||
|
||||
## Cleanup
|
||||
|
||||
`/exit` or `proc://<id>/kill` the service; move stub scripts, logs, and clips to `~/.AGENTTRASH/<date>-<topic>/` — never `rm`. Kill background jobs that keep playing to the operator's speakers.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: openwebui-tts-chain-audit
|
||||
description: Audit/repair OpenWebUI TTS on Dankclaw when cloned voices get rejected by a 29-name Kokoro enum or synthesis is silent; rootless DB + endpoint probes
|
||||
---
|
||||
|
||||
Audit OpenWebUI's TTS/STT wiring on Dankclaw when a voice is rejected, synthesis is silent, or someone asks "is the cloned voice wired correctly". Not for model-picker/ACL issues (openwebui-picker-acl-repair owns those).
|
||||
|
||||
## Shape of the stack
|
||||
- Container `open-webui` runs **rootless as `dank`** on Dankclaw: `ssh dankclaw 'podman exec open-webui ...'` (NO sudo: `sudo podman` sees an empty world). Host port 3005.
|
||||
- Server-side config = `webui.db` `config` table (admin), per-user overrides = `user.settings` JSON column. Settings changed via UI persist per-user; admin page persists to `config`.
|
||||
- Cloned voices live behind the frank-tts façade `:7772` (`/v1/audio/speech`, `/v1/audio/voices`); voices resolve to `/home/dank/voices/<name>/reference.wav`.
|
||||
|
||||
## The trap (2026-10-18 law)
|
||||
`Error: Voice "narrator1" not found. Should be one of: af_heart, ...` (29 names) is **OpenWebUI's bundled in-browser Kokoro** (`browser-kokoro`) rejecting client-side — frank-tts never sees the request. Signature: container logs show zero `audio/speech` hits. Fix = the personal dropdown Settings -> Audio -> TTS Engine -> Default (empty string), not any deploy.
|
||||
|
||||
## Probe sequence (read-only, safe)
|
||||
1. Admin truth:
|
||||
`podman exec open-webui python -c "import sqlite3; c=sqlite3.connect('/app/backend/data/webui.db'); [print(k,'=',v) for k,v in sorted(dict(c.execute('SELECT key,value FROM config')).items()) if 'audio' in k or 'tts' in k.lower()]"`
|
||||
Expect: `audio.tts.engine=openai`, `audio.tts.openai.api_base_url=http://host.containers.internal:7772/v1`, `audio.tts.model=frank-tts`, `audio.tts.voice=<cloned name>`.
|
||||
2. User overrides — a user pinned to `browser-kokoro` beats admin config. CAUTION: `user.settings` can be SQL NULL or the JSON string `"null"`:
|
||||
`... for uid,s in c.execute('SELECT id,settings FROM user'): d=json.loads(s) if s else None; d=d if isinstance(d,dict) else {}; hits={k:v for k,v in d.items() if 'audio' in k or 'tts' in k.lower()} ...`
|
||||
3. Endpoint truth: `curl -s http://127.0.0.1:7772/v1/audio/voices` (roster), then POST the exact OpenWebUI body `{"model":"frank-tts","input":"probe","voice":"narrator1","response_format":"mp3","speed":1.0}` to `/v1/audio/speech` — expect `200 audio/mpeg`.
|
||||
4. Contrast logic: server-path failure leaves a container log line AND a façade error; browser-kokoro failure leaves neither.
|
||||
|
||||
## Law
|
||||
- Full contract + diagnosis live in serverAppConfigs docs (`Dankclaw/OpenWebUI.md` § Text-to-speech, `Dankclaw/Frank TTS.md` troubleshooting). Docs are the source; this skill is just the probe route.
|
||||
- Rootless gotcha generalizes: any Dankclaw service probe through podman goes WITHOUT sudo unless the unit is a system quadlet.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792368375152,
|
||||
"lastConsolidatedAt": 1792395788647,
|
||||
"lastResult": {
|
||||
"at": 1791599865775,
|
||||
"sessions": 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792359704968
|
||||
"lastConsolidatedAt": 1792386375195
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792378334862
|
||||
"lastConsolidatedAt": 1792396789735
|
||||
}
|
||||
|
||||
+59
-107
@@ -1,118 +1,70 @@
|
||||
## Runtime Stack
|
||||
## Subagent routing (harness law, 2026-10-18)
|
||||
|
||||
**llama.cpp** is the inference runtime for all `frank-zealot`-class endpoints. The `frank-zealot` service runs llama.cpp master (0.5.0-dev, commit `5fc4f3c`), CUDA sm_86 build, under a root systemd unit.
|
||||
**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`.
|
||||
|
||||
## Model Inventory
|
||||
## Documentation law (2026-10-18)
|
||||
|
||||
### frank-zealot (Dankclaw: 192.168.0.143:7771)
|
||||
**One fact, one owner.** The `serverAppConfigs` repo owns every fleet fact;
|
||||
`AGENTS.md` restates only what a deploy gate or the harness needs and otherwise
|
||||
links. `serverAppConfigs-docs/` (Obsidian vault) carries the lore: one page per
|
||||
endpoint (`[[frank-archon]]`, `[[frank-dragoon]]`, `[[frank-zealot]]`) holding
|
||||
Current shape, **Eras** (one row per payload swap: dates, payload, runtime,
|
||||
shape, why replaced, rollback), Laws, wire contract, files. `log.md` records
|
||||
incidents and laws only — no per-swap ledgers, no bench tables. A retired
|
||||
payload is an Eras row, never a page of its own. `scripts/<service>.env` beats
|
||||
any page: where they disagree, the page is the bug.
|
||||
|
||||
**Ternary-Bonsai-2-27B-PQ2_0.gguf** (prism-ml/Ternary-Bonsai-2-27B-gguf)
|
||||
## Model swaps (2026-10-18)
|
||||
|
||||
- Runtime: llama.cpp
|
||||
- Format: PQ2_0 quantized GGUF
|
||||
- 27B parameter class
|
||||
- Vision: native BF16 mmproj projector for image input
|
||||
- MTP: OFF (no `nextn` tensors in checkpoint)
|
||||
- Context: 256k per-slot window (`--ctx-size 401408 --parallel 2` = 200,704 tokens effective)
|
||||
- Batch: 4096 / ubatch 2048 (CUDA optimum)
|
||||
- Log verbosity: 4
|
||||
- Ports: 7771 on Dankclaw
|
||||
Swap the model, don't instrument the swap: edit the trio
|
||||
(`scripts/<svc>.env` + serve script + unit) → `./tests/run.sh` → PR →
|
||||
`deploy.sh <host> --service <svc>` → `/health` + one chat / tool-call / vision
|
||||
probe → done. **No benchmarks, A/Bs, ladders, or acceptance tables unless
|
||||
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
|
||||
scratch ports, never `systemctl disable`) is `AGENTS.md` → *Model swaps*
|
||||
and *Deployment completeness*.
|
||||
|
||||
### frank-dragoon (Dankclaw: 192.168.0.143:7770)
|
||||
## Where facts live
|
||||
|
||||
**Quat3rnion/halogen-swift1.5-qwen3.8-flash-next-v2-abliterated** (`.hgn` format)
|
||||
|Need|Owner|
|
||||
|---|---|
|
||||
|Topology, ports, payload, shape|repo `AGENTS.md` → Fleet topology; `scripts/<svc>.env` is the authority|
|
||||
|Endpoint lore, eras, laws, rollback|vault `serverAppConfigs-docs/LLM/<endpoint>.md`|
|
||||
|Harness model entries (`contextWindow`, `maxTokens`, `reasoningContentField`, `modelRoles`)|`~/.omp/agent/models.yml`|
|
||||
|Incidents and operational laws|vault `serverAppConfigs-docs/log.md`|
|
||||
|Service/infra pages|vault `Dankclaw/`, `Dankserver/`, `Infrastructure/`, `Architecture/`|
|
||||
|
||||
- Runtime: halogen-flash-server 0.16.2 (digest-pinned `ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`)
|
||||
- Format: v2 `.hgn` (flash-server exclusive)
|
||||
- MTP head: INSIDE checkpoint (`HALOGEN_MTP_HEAD` unset)
|
||||
- Vision: ON (v2 tower loads beside checkpoint)
|
||||
- Base: abliterated (27B-era was censored finetune)
|
||||
- Slots: 2 × 262,144 ctx (KV pool 524,288 positions)
|
||||
- Server sampler: 1.0/0.95/20
|
||||
- Prompt cache: ON by default
|
||||
Models.yml resolution rules that are harness-local: a default model resolves as
|
||||
a `defaultProvider` + `defaultModel` pair; a subagent override is a bare model
|
||||
`id` and resolves only when unique across providers; two ports on one host need
|
||||
two provider blocks — the file cannot express "same host, different port".
|
||||
|
||||
### frank-archon (Dankserver: 192.168.0.235:7770)
|
||||
There is **no audio input anywhere in the fleet** (gone with the Gemma4
|
||||
retirement, 2026-10-01). All three chat endpoints take text + images.
|
||||
|
||||
**StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe** (`.rad` format)
|
||||
## Retired laws
|
||||
|
||||
- Runtime: radiance 1.0.13 (digest-pinned `stilldeadcode/radiance@sha256:4c3f484564…`)
|
||||
- Format: fp8-KV `.rad` (121,969,901,568 B = 116,322 MiB)
|
||||
- MTP head: MTP depth 2, MNBT 2048, Froggeric v22.5 template
|
||||
- Vision: 27-layer Qwen-VL tower native
|
||||
- KV: 3 × 200,000 fp8-KV (`--max-num-seqs 3`)
|
||||
- 30,720 MiB of 30,969 MiB claimable
|
||||
- 44 GiB/rank pinned host pool, PLE pinned to RAM
|
||||
- Base-alignment: censored (abliterated family lives on frank-dragoon)
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
Official model ids are StarCraft unit names, on the wire since 2026-10-09, and since 2026-10-10 they are also the SERVICE names: `frank-archon` → `192.168.0.235:7770`, `frank-dragoon` → `192.168.0.143:7770`, `frank-zealot` → `192.168.0.143:7771`.
|
||||
|
||||
The default model resolves as a `defaultProvider` + `defaultModel` pair (provider must serve the model); a subagent override uses a bare model `id`, which resolves only when unique across providers. Two ports on one host need two providers — the file cannot express "same host, different port".
|
||||
|
||||
When renaming a service, retiring an endpoint, or moving a model: update the provider block, model `id`, `contextWindow`, `maxTokens`, `input` in `models.yml`, then fix every `modelRoles` entry that named the old endpoint — a role pointing at a provider the file does not define fails at call time. As of 2026-10-06 **all three endpoints accept images** (`input: [text, image]`) — `frank-dragoon` returned to vision with the flash-server v2 swap, while `frank-archon` takes them natively and `frank-zealot` through its BF16 mmproj.
|
||||
|
||||
Reasoning channel differs by runtime: llama.cpp AND SGLang (qwen3 parser, live-verified) emit `reasoning_content`, vLLM emits `reasoning` — `models.yml` sets `reasoningContentField` per endpoint to match.
|
||||
|
||||
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.
|
||||
|
||||
## Boot Persistence & Self-Healing (MANDATORY)
|
||||
|
||||
Every deployed, working Podman container MUST have a self-healing, boot-persistent Quadlet unit. A container that runs only from `compose.yaml` or a manual `podman run` dies silently at reboot — rootless Podman does not auto-start bare containers, and `restart: unless-stopped` does not survive the explicit stop that shutdown performs. Never report such a service as "up".
|
||||
|
||||
Unit requirements:
|
||||
|
||||
- **Boot persistence** — `[Install] WantedBy=default.target`, installed to `~/.config/containers/systemd/<name>.container`, enabled in the user manager, with linger on for the deploy user (`loginctl show-user dank -p Linger` → `Linger=yes`).
|
||||
- **Self-healing** — `Restart=always` (or `on-failure`) with a short `RestartSec`; quadlet health check where the service has one.
|
||||
- **Repo provenance** — the `.container` file lives in this repo (`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by the quadlet test seam. The host-side unit is generated state; the repo file is the source of truth.
|
||||
|
||||
Before declaring a service deployed: `systemctl --user is-enabled <name>` → `enabled`, `is-active` → `active`, and the container answers its health check.
|
||||
|
||||
A running container with no unit is a gap: write the unit, test it, deploy it.
|
||||
|
||||
This rule exists because reboots found gaps twice (2026-08-26: Caddy as a bare compose container never returned; 2026-09-11: five observability services had no units). The observability services now have units under `dankclaw/observability/quadlet/` and their compose definitions were removed — one owner per container name. The stack is **configured and delivering as of 2026-09-17**: a Grafana datasource and the `llm-fleet` dashboard are provisioned from `shared/grafana/provisioning/`, `shared/prometheus/rules/llm-fleet.yml` loads 5 alert rules + 1 recording rule, and firing rules are delivered: Prometheus → Alertmanager (`dankclaw/observability/quadlet/alertmanager.container`, loopback :9093) → host-side Signal relay (`scripts/signal-relay.py`, user unit, 192.168.0.143:18093 — the host LAN IP, the one bind a container can reach as `host.containers.internal`; iptables admits loopback, the host and the podman bridge subnet only) → signal-cli account 318311.
|
||||
|
||||
Prometheus targets read down for structural reasons (HTML-scraped "probes", Gitea metrics off); the remedy is a blackbox exporter. See `serverAppConfigs-docs/Infrastructure/Observability.md`.
|
||||
|
||||
## Benchmarking Policy (MANDATORY)
|
||||
|
||||
**STOP the production service BEFORE benchmarking** — these servers serve real users, and a bench beside a live service starves the GPU and corrupts results. Use the runtime's own harness: `llama-bench` for the llama.cpp endpoints; a live llama.cpp server also reports honest per-request numbers in the `/completion` `timings` object (prefill/decode rates, `draft_n`, `draft_n_accepted`), which is how frank-dragoon and frank-zealot were measured. Rolling your own bench requires asking first.
|
||||
|
||||
Scratch ports are not used on LLM hosts — a test process cannot share the single GPU anyway; validate against the service itself.
|
||||
|
||||
A gufo-era law: **`gufo bench` cannot rank speculative-decode settings, and will invert the ranking.**
|
||||
|
||||
Measured 2026-09-28 on frank-dragoon at depth 32768: its corpus reports `acceptance=0.933 ± 0.000` with an **identical trace sha256** across draft widths, i.e. the same highly-predictable text every run, and 93 % of every proposal accepted. It reported 29–37 t/s decode where real traffic gives ~15 t/s, and it made `--draft-tokens 16` look 6 % faster than 7 when real text made it 14 % *slower*. Use it for shape, not for A/B. For an A/B, replay **real** text through the live server and read the `Server-Timing` header — but note the unit rides in the **name** half (`ttft;dur=402.485`), so splitting on `=` alone keys the dict `ttft;dur` and every lookup silently returns nothing. Generate **at least 128 tokens** for the measurement: at 8 tokens, `inter_token` is first-token noise and the run-to-run spread swamps every real difference.
|
||||
|
||||
This covers **any GPU experiment, not just benchmarks** — including a bare 2-rank RCCL/`torch.distributed` probe. On 2026-09-16 one such probe was run against the live service "because it only needs 200 MB of VRAM"; the host went hard down ~20 minutes later (no ARP reply, 100% ICMP loss from two subnets, no recovery — a power cycle was required). A hung AQL dispatch wedging the GPU driver is the plausible mechanism, and this host has precedent for going unreachable until a power cycle. If an experiment needs GPUs, stop and mask first, or use a host that serves nobody. Telling an off machine from a wedged one: `ip neigh show <ip>` from a peer — FAILED means off/NIC dead, while a merely wedged kernel usually still answers ARP.
|
||||
|
||||
1. Stop and mask (self-healing would restart it):
|
||||
- Dankclaw `frank-dragoon` (root unit, halogen podman on the iGPU):
|
||||
`sudo systemctl stop frank-dragoon && sudo systemctl mask frank-dragoon`
|
||||
- Dankclaw `frank-zealot` (root unit, llama.cpp on the 3090):
|
||||
`sudo systemctl stop frank-zealot && sudo systemctl mask frank-zealot`
|
||||
- Dankserver `frank-archon` (root unit, radiance on both R9700s, TP=2):
|
||||
`sudo systemctl stop frank-archon && sudo systemctl mask frank-archon`
|
||||
2. Bench.
|
||||
NOTE: `systemctl mask` FAILS on a relinked unit (the unit file in `/etc/systemd/system/` is itself a symlink into the checkout — mask refuses to replace it). Stop is enough for the bench window; the deploy restores the unit, so re-deploy after benching instead of unmasking.
|
||||
3. Unmask and start; verify `curl http://192.168.0.<host>:7770/health`.
|
||||
|
||||
**CRITICAL:** never `systemctl disable` a system unit on these hosts — it deletes the unit file from `/etc/systemd/system/`, not just the symlink. `mask` symlinks to `/dev/null` and is safe/reversible.
|
||||
|
||||
## Subagent Lifecycle (MANDATORY)
|
||||
|
||||
A subagent that appears stuck must have an explicit recovery path defined before the subagent is considered deployed. "Stuck" includes: silent completion halt, repeated identical responses, or extended latency without token emission.
|
||||
|
||||
Recovery requirements:
|
||||
|
||||
- **Explicit timeout** — a hard timeout (≤30s) on the subagent's response channel, with a defined action (retry, fallback, or escalate).
|
||||
- **State checkpoint** — the subagent maintains checkpoints at decision boundaries, allowing resumption from a known good state.
|
||||
- **Health signal** — the subagent publishes a heartbeat (≤5s interval) that triggers the recovery path when missed.
|
||||
|
||||
A subagent without these three properties is a gap: write the timeout, add the checkpoint, wire the heartbeat, then deploy.
|
||||
|
||||
This rule exists because subagent hangs were observed in early deployments (2026-09-xx: silent completion halt; 2026-09-xx: repeated identical responses). The deployment script must verify the heartbeat channel is live before reporting "deployed".
|
||||
|
||||
Before declaring a subagent deployed: verify the heartbeat endpoint returns within the timeout window, and confirm the recovery path is wired (retry/fallback/escalation logic exists in the codepath).
|
||||
|
||||
A subagent without a heartbeat or recovery path is a gap: wire the heartbeat, add the recovery path, then deploy.
|
||||
- The old "Benchmarking Policy (MANDATORY)" wording demanded a bench harness per
|
||||
payload; superseded 2026-10-18 by the swap law above.
|
||||
- A "Subagent Lifecycle (MANDATORY)" rule (heartbeat ≤5 s, checkpoints, hard
|
||||
timeout) was recorded here 2026-09 and **never implemented anywhere**; it
|
||||
described nothing that exists and is deleted 2026-10-18. Do not re-add it
|
||||
without a real implementation.
|
||||
- The fleet model inventory that used to live in this memory file drifted out of
|
||||
date within hours (it still claimed Bonsai-2-**27B** at `--ctx-size 401408`
|
||||
and a dragoon 524,288-position pool). Memory files do not get deploys — the
|
||||
repo does. That is why it is pointers now.
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
## Voice Generation
|
||||
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Voice Usage Notes
|
||||
|
||||
- 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 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.
|
||||
- Podcast functionality is sourced from open-notebook; this is a project decision established before this document was created.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792378149820,
|
||||
"lastConsolidatedAt": 1792396748497,
|
||||
"lastResult": {
|
||||
"at": 1792374778678,
|
||||
"at": 1792393886960,
|
||||
"sessions": 1,
|
||||
"deltas": 1,
|
||||
"model": "frank-zealot"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
## top bar
|
||||
- Preserve top bar customizations; customizations lost or obliterated on subsequent runs
|
||||
- Todo list updates as tasks change, not waiting for entire bill of tasks to finish
|
||||
- Todo list updates as tasks change, not waiting for entire bill of tasks to finish
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792378276529,
|
||||
"lastConsolidatedAt": 1792381281460,
|
||||
"lastResult": {
|
||||
"at": 1792374324899,
|
||||
"at": 1792381281460,
|
||||
"sessions": 1,
|
||||
"deltas": 1,
|
||||
"model": "frank-zealot"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
## window outlines
|
||||
- Window outline color is amber, and outline thickness is thickened
|
||||
- Window outline color is amber, and outline thickness is thickened
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792381246498
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Output behavior
|
||||
|
||||
Output must not be shaken off during presentation. Output remains anchored until explicitly confirmed.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792396722373,
|
||||
"lastResult": {
|
||||
"at": 1792396071933,
|
||||
"sessions": 1,
|
||||
"deltas": 1,
|
||||
"model": "frank-zealot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792381487662
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792393561866,
|
||||
"lastResult": {
|
||||
"at": 1792381870670,
|
||||
"sessions": 1,
|
||||
"deltas": 1,
|
||||
"model": "frank-zealot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"v": 1,
|
||||
"lastConsolidatedAt": 1792388836682
|
||||
}
|
||||
+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
|
||||
}
|
||||
+26
-77
@@ -24,27 +24,13 @@ providers:
|
||||
# compactionModel candidate keeps the wire model id valid there.
|
||||
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
||||
name: "frank-archon"
|
||||
# Dankserver: BOTH Radeon AI PRO R9700s (gfx1201, TP=2 over the
|
||||
# 2026-09-20 board swap's root ports, wht6 wire). Engine:
|
||||
# radiance (StillDeadcode, Apache-2.0) from the digest-pinned
|
||||
# stilldeadcode/radiance image (index digest sha256:4c3f4845..., 287
|
||||
# MiB, kernels prebuilt for gfx1201), plain `podman run` through
|
||||
# scripts/serve-frank-archon.sh, root unit (User=dank, dank's
|
||||
# rootless podman storage). Serving the OFFICIAL
|
||||
# Qwen/Qwen3.8-Flash-Next-FP8 at NATIVE modelopt block-FP8
|
||||
# precision — a rad-convert no-recipe pure copy into
|
||||
# qwen4exp-fp8-native.rad (172.76 GiB measured by plan-only), built by
|
||||
# scripts/build-frank-archon-rad.sh against a staging dir whose
|
||||
# config.json drops quantization_config so the shipped qwen4exp
|
||||
# plugin's (qwen4exp, "") selection key matches; per-weight
|
||||
# encodings are untouched. Experts stream from a 40 GiB/rank
|
||||
# pinned host pool; the 47.7 GiB PLE ngram table is pinned in RAM
|
||||
# (Frank's call); KV fp8; MTP depth 3 (the checkpoint's own mtp.*
|
||||
# head); prefix cache 4 GiB host + 64 GiB disk. Chat template:
|
||||
# froggeric's fixed qwen3.8 v22.5
|
||||
# (scripts/qwen38-froggeric-v22.5.jinja) via
|
||||
# --override-chat-template. Rollback: git tag
|
||||
# frank-archon-freetoken-pr587-20261005 (FreeToken, one card).
|
||||
# Dankserver: BOTH Radeon AI PRO R9700s (gfx1201, TP=2, wht6 wire) on
|
||||
# the digest-pinned radiance image, plain `podman run` under a root
|
||||
# unit. Payload, shape, eras, laws, rollback:
|
||||
# serverAppConfigs-docs/LLM/frank-archon.md — and
|
||||
# scripts/frank-archon.env is the authority for every number there.
|
||||
# contextWindow MUST quote the engine's --max-model-len: radiance
|
||||
# refuses a longer prompt and never truncates.
|
||||
contextWindow: 200000
|
||||
# maxTokens must survive the harness compaction gap AND the chars/4
|
||||
# token-estimate under-run on code-heavy prompts. The clamp is
|
||||
@@ -105,40 +91,22 @@ providers:
|
||||
# compaction model candidate so the wire model id stays valid
|
||||
# there even from a frank-dragoon session.
|
||||
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
||||
# halogen-flash-server 0.16.2 (ghcr.io/peonist-ai/
|
||||
# halogen-flash-server@sha256:0c61bf84, digest-pinned) serving the
|
||||
# Quat3rnion NATIVE v2 ABLITERATED conversion of orcarouter's pinned
|
||||
# BF16 Qwen3.8-Flash-Next (qwen38-flash-next-v2-abliterated.hgn,
|
||||
# ~62 GiB resident, + REQUIRED n-gram table + vision tower, both
|
||||
# hardlinked from the swift15 dir) on the Strix Halo iGPU: TWO slots
|
||||
# over ONE 401,408-position KV pool (= 2 x ctx, raised 2026-10-18) at
|
||||
# HALOGEN_CTX=200704 (the ceiling lowered from the card's 262,144 on
|
||||
# 2026-10-17; ~10.8 GiB reserved where 200,704 cost 5.5, 262,144 cost
|
||||
# 7.2 and 524,288 measured 14.6), with the MTP head ALWAYS ON
|
||||
# (HALOGEN_SPEC_ADAPT=0 — the default adaptive policy halves
|
||||
# decode). Measured 2026-10-17: 50 t/s greedy decode, 1,604 t/s
|
||||
# cold prefill at 59k, NIAH 18/18. ABLITERATED base (no refusals:
|
||||
# 0/5 on the hostile census) and VISION ON (OpenAI image_url
|
||||
# parts; >=256 px images or it answers "video"). Runbook:
|
||||
# serverAppConfigs-docs/LLM/frank-dragoon-flash-server-v2-native.md.
|
||||
# Window the harness may fill — deliberately BELOW the engine
|
||||
# ceiling (HALOGEN_CTX=200704, lowered from 262,144 on 2026-10-17):
|
||||
# the clamp below leaves the 45k gap that absorbs the estimate error.
|
||||
# Both slots SHARE one 401,408-position KV pool — exactly two full
|
||||
# windows — so TWO maximum-length conversations can be resident at the
|
||||
# same time (at the old pool == ctx 200,704 only two of <=100k could).
|
||||
# The per-request ceiling is unchanged, so this window is unchanged.
|
||||
# halogen flash-server on the Strix Halo iGPU. Payload, shape, eras,
|
||||
# laws, rollback: serverAppConfigs-docs/LLM/frank-dragoon.md — and
|
||||
# scripts/frank-dragoon.env is the authority for every number there.
|
||||
# contextWindow sits deliberately BELOW the engine ceiling
|
||||
# (HALOGEN_CTX): the clamp below leaves the gap that absorbs the
|
||||
# harness's chars/4 under-count, so a window equal to the ceiling
|
||||
# would make every long request a guaranteed 400.
|
||||
contextWindow: 155648
|
||||
# The clamp is max_tokens = min(maxTokens, contextWindow − est −
|
||||
# 4096); the chars/4 est UNDER-counts the rendered prompt by ~3 %
|
||||
# on code/agent traffic (measured 2026-10-07: est 152,938 ->
|
||||
# server 157,388). Worst-case request = window − 4096 + 3 % est
|
||||
# error ≈ 156.2k, inside the 200,704 engine ceiling with ~44k
|
||||
# margin (robust to a ~25 % under-count). Compaction fires at 85 %
|
||||
# of the window = 132,300 est ≈ 136k measured — the 180k target of
|
||||
# the 262,144 era is unreachable now (the window itself is 155,648)
|
||||
# — and rides on frank-zealot: input ≈ threshold − keepRecent(20k)
|
||||
# ≈ 112k, inside its 200,704 slot.
|
||||
# error ≈ 156.2k, inside the engine ceiling with ~44k of margin
|
||||
# (robust to a ~25 % under-count). Compaction fires at 85 % of the
|
||||
# window and rides on frank-zealot: input ≈ threshold −
|
||||
# keepRecent(20k), comfortably inside its per-slot window.
|
||||
# HALOGEN_MAX_TOKENS_CAP in frank-dragoon.env must stay >= this.
|
||||
maxTokens: 45056
|
||||
reasoning: true
|
||||
@@ -183,33 +151,14 @@ providers:
|
||||
# config.yml (192.168.0.143:7771 — this endpoint); the
|
||||
# compactionModel candidate keeps the wire model id valid there.
|
||||
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
||||
# Dankclaw's RTX 3090: llama.cpp PrismML fork build (vendor 10777,
|
||||
# commit e3de71522 — the ONLY loader for this payload's custom tensor
|
||||
# type 142 + prism.hadamard.* metadata, and the minimum build that
|
||||
# pairs MTP with a quantized V cache) serving
|
||||
# prism-ml/Ternary-Bonsai-2-9B-gguf at PQ2_0 (2,675,410,720 B) WITH the
|
||||
# MTP head in the file — qwen35 hybrid, 32 layers (8 full-attention +
|
||||
# 24 Gated DeltaNet), 4 KV heads x 256 dim, native 262,144 — in THREE
|
||||
# conversations (--ctx-size 786432 / --parallel 3), each slot at the
|
||||
# model's TRAINED window.
|
||||
# KV is q8_0 K + q8_0 V (17 KiB/token over the 8 full layers =
|
||||
# 13,056 MiB pool, ledger-exact); f16 KV would bill 26,112 MiB and
|
||||
# cannot boot this card. Boot ledger 17,876 used / 6,249 free. Vision
|
||||
# through the publisher's Q8_0 mmproj (624,229,664 B — Q8_0 is the
|
||||
# publisher's requirement for concurrent image requests), whose
|
||||
# worst-case lands outside the engine's fit projection. **MTP OFF —
|
||||
# measured, not assumed**: the draft context bills its own f16 KV over
|
||||
# the WHOLE pool (3,072 MiB) and at ubatch 2048 that squeeze halves
|
||||
# decode (167.2 → 109.2 t/s); SPEC_TYPE=draft-mtp + UBATCH_SIZE=1024 is
|
||||
# the 213.5 t/s arm if the card ever has room. 2026-10-18 swap from
|
||||
# Ternary-Bonsai-2-27B PQ2_0 at 2 × 200,704. Measured: 167.2 t/s
|
||||
# single-stream greedy decode, 280.2 t/s across three concurrent
|
||||
# streams, 2,877 t/s prefill at 106,418. Base-alignment: censored base
|
||||
# lineage; unlike the 27B there is NO CRACK re-export matching this
|
||||
# file. Runbook:
|
||||
# serverAppConfigs-docs/LLM/frank-zealot-llama-prism-bonsai9-mtp.md.
|
||||
# contextWindow is the PER-SLOT window (= the native 262,144): a longer
|
||||
# prompt needs compaction — llama.cpp answers a 400, never truncates.
|
||||
# Dankclaw's RTX 3090 llama.cpp endpoint (PrismML fork build — the only
|
||||
# loader for this payload's custom tensor type). Shape, payload, eras,
|
||||
# laws, rollback: serverAppConfigs-docs/LLM/frank-zealot.md, and
|
||||
# scripts/frank-zealot.env is the authority for every number there.
|
||||
# contextWindow is the PER-SLOT window: llama.cpp divides --ctx-size
|
||||
# across slots and answers a longer prompt with a 400, never a
|
||||
# truncation, so this value must follow every reslice (live now:
|
||||
# 2 x 262,144 over exact f16 KV, 68afc6d).
|
||||
contextWindow: 262144
|
||||
# Prompt + budget must fit inside the slot window; thinking counts
|
||||
# against the budget. Compaction fires at 85 % of the window =
|
||||
|
||||
@@ -275,13 +275,13 @@ transition = [ "fade" ]
|
||||
interval_seconds = 1200
|
||||
|
||||
[wallpaper.default]
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_odl3r9.jpg"
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
|
||||
|
||||
[wallpaper.last]
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_odl3r9.jpg"
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
|
||||
|
||||
[wallpaper.monitors.DP-1]
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_odl3r9.jpg"
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
|
||||
|
||||
[wallpaper.monitors.DP-2]
|
||||
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"
|
||||
|
||||
[wallpaper.monitors.eDP-1]
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_odl3r9.jpg"
|
||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
|
||||
|
||||
[weather]
|
||||
unit = "imperial"
|
||||
|
||||
+75
-16
@@ -9,17 +9,17 @@ tags:
|
||||
- voice
|
||||
- frank-tts
|
||||
- dankclaw
|
||||
verified: 2026-10-18
|
||||
timestamp: 2026-10-18
|
||||
verified: 2026-10-19
|
||||
timestamp: 2026-10-19
|
||||
---
|
||||
|
||||
# dankvoice
|
||||
|
||||
`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive
|
||||
harness: `/say <text>` speaks through the speakers and `/say on` narrates the
|
||||
assistant's final answer after every turn, both served by
|
||||
**frank-tts** (Dankclaw :7772, the `speech` model role in
|
||||
[agent/models.yml](/core/index.md)).
|
||||
harness: `/say` opens the options menu, `/say <text>` speaks through the
|
||||
speakers and `/say on` narrates the assistant's final answer after every
|
||||
turn, all served by **frank-tts** (Dankclaw :7772, the `speech` model role
|
||||
in [agent/models.yml](/core/index.md)).
|
||||
|
||||
## Why it exists
|
||||
|
||||
@@ -39,17 +39,53 @@ local speech and the cloned voices carry the real narration.
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, test phrase |
|
||||
| `/say <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 voice <name>` | pick a voice; an empty name defers to the server's own default |
|
||||
| `/say voices` | list what frank-tts reports |
|
||||
| `/say stop` | cut the current utterance |
|
||||
| `/say status` \| `/say help` | endpoint, voice, player, cap |
|
||||
| `/say status` \| `/say help` | endpoint, voice, player, device, cap |
|
||||
|
||||
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
|
||||
silent. Markdown is stripped before synthesis — fenced code is dropped, links
|
||||
collapse to their label, bullets and table pipes disappear — and the utterance
|
||||
is truncated at a sentence boundary inside the cap.
|
||||
silent. 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.
|
||||
|
||||
## Endpoint resolution
|
||||
|
||||
@@ -63,18 +99,23 @@ 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_MODEL` | the id in that block (`frank-tts`) | model named in the request body |
|
||||
| `DANKVOICE_CONFIG` | `~/.omp/cache/dankvoice.json` | persisted menu choices (voice, player, sink, cap, auto) |
|
||||
| `DANKVOICE_VOICE` | empty (server default) | default voice name |
|
||||
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
|
||||
| `DANKVOICE_MAX` | `700` | spoken characters per utterance |
|
||||
| `DANKVOICE_MAX` | `700` | spoken character budget per answer (whole leading segments) |
|
||||
| `DANKVOICE_PLAYER` | detect | player binary, skipping detection |
|
||||
| `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from |
|
||||
|
||||
Player detection order is `pw-play` → `paplay` → `ffplay -nodisp -autoexit`
|
||||
(PipeWire first: the workstations run WirePlumber). Each utterance is written
|
||||
to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus its directory are
|
||||
removed when the player process exits.
|
||||
Player detection order is `pw-play` → `paplay` → `ffplay` (PipeWire first:
|
||||
the workstations run WirePlumber). A saved output device routes the player
|
||||
explicitly — `pw-play --target <sink>` or `paplay --device=<sink>`; ffplay
|
||||
has no device concept and ignores it — while an unset device leaves the
|
||||
player's own default. Sinks are enumerated with `wpctl list audio sinks`;
|
||||
if wireplumber is unreachable the menu offers only "system default". Each
|
||||
utterance is written to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus
|
||||
its directory are removed when the player process exits.
|
||||
|
||||
## Verified facts (2026-10-18)
|
||||
## Verified facts (2026-10-18, 2026-10-19)
|
||||
|
||||
* **Extension discovery** — any `.ts` file in `~/.omp/agent/extensions/` loads
|
||||
at startup: a probe file there printed `loaded 2026-10-19T02:36:55Z` from a
|
||||
@@ -101,6 +142,17 @@ removed when the player process exits.
|
||||
(1.20 s WAV), and an unknown voice name answers with the roster instead of
|
||||
speaking. An `agent_end` carrying `willContinue` (the harness scheduled an
|
||||
auto-retry) is never narrated — only the terminal settle speaks.
|
||||
* **Menu** — verified via driver scenarios 2026-10-18 (Refs #23): the menu
|
||||
lists auto-speak/voice/device/player/cap/test-phrase; a voice chosen in the
|
||||
menu is used by later utterances and survives a fresh load via the settings
|
||||
file; a device chosen in the menu appears in the player invocation as
|
||||
`--target <sink>`.
|
||||
* **Segmented playback** — driver scenarios 2026-10-19: "First stop here.
|
||||
Second one goes!!! Really? Third ... done" produced exactly 3 sequential
|
||||
requests and 3 plays; "Model 3.5 ships today. Version 1,5 too." produced
|
||||
exactly 2 (decimals never split); the markdown fixture's fenced code and
|
||||
URL never reached the fixture endpoint; with `DANKVOICE_MAX=60` only the
|
||||
leading segment was spoken. `bats tests/dankvoice.bats` 17/17.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -124,7 +176,14 @@ removed when the player process exits.
|
||||
* `omp --help` (extension discovery flags, `say`, `plugin`) and
|
||||
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts`
|
||||
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
|
||||
* The stock speak-aloud implementation — vocalizer pipeline `I6o` and
|
||||
utterance segmenter `fHe` — in `@oh-my-pi/pi-coding-agent`'s
|
||||
`dist/cli.js`, read off the minified bundle 2026-10-19; the enhanced
|
||||
rewrite ("with the tiny/smol model before synthesis") is that bundle's
|
||||
own `speech.enhanced` description. dankvoice ports the mechanical half
|
||||
only: no LLM rewrite happens before frank-tts.
|
||||
* 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).
|
||||
|
||||
## See Also
|
||||
|
||||
|
||||
@@ -11,6 +11,44 @@ timestamp: 2026-08-28
|
||||
|
||||
# Bundle Change Log
|
||||
|
||||
|
||||
## 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)
|
||||
|
||||
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).
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
|
||||
## Subagent Architecture
|
||||
|
||||
- [Pi Subagents](/pi/packages/pi-subagents.md) — Core subagent delegation system. Chains, parallel execution, TUI clarification.
|
||||
- [OMP Subagent Limits & Fan-out Policy](/pi/subagent-limits.md) — Live caps: concurrency 2, no nested spawns (and the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy.
|
||||
- [Pi Subagents](/pi/packages/pi-subagents.md) — (historical) pi-era delegation system. Chains, parallel execution, TUI clarification.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
type: reference
|
||||
title: OMP Subagent Limits & Fan-out Policy
|
||||
description: The live caps on omp subagents — concurrency 2, no nested spawns, per-endpoint in-flight ceilings, 30-min runtime, soft request budget, and the isolated-workspace policy; includes the maxRecursionDepth=0 trap.
|
||||
tags:
|
||||
- omp
|
||||
- subagents
|
||||
- config
|
||||
- fleet
|
||||
timestamp: 2026-10-19
|
||||
---
|
||||
|
||||
# OMP Subagent Limits & Fan-out Policy
|
||||
|
||||
Enforced caps on subagents spawned through the `task` tool / eval `agent()`+`workpool()`
|
||||
from this workstation (config root `agent/config.yml`, machine-live tier — the live-sync
|
||||
heartbeat commits it). Set 2026-10-19; values verified against the live config and the
|
||||
enforcement code in the installed bundle (`@oh-my-pi/pi-coding-agent/dist/cli.js`).
|
||||
|
||||
## The caps in force
|
||||
|
||||
| Key | Value | Default | Effect |
|
||||
|---|---|---|---|
|
||||
| `task.maxConcurrency` | 2 | 32 | Session-scoped semaphore; excess spawns queue, never drop. |
|
||||
| `task.maxRecursionDepth` | 1 | 2 | Subagents cannot spawn their own subagents. See depth semantics. |
|
||||
| `task.maxRuntimeMs` | 1800000 | 0 (off) | Hard 30-min wall clock per subagent; overrun = `aborted`. |
|
||||
| `task.softRequestBudget` | 120 | 200 | Wrap-up notice at 120 assistant requests; force-stopped to yield at 180 (1.5×). |
|
||||
| `task.agentIdleTtlMs` | 420000 | same | Finished subagents parked to disk after 7 min idle (revivable via `agent://`). |
|
||||
| `task.enableEffort` | false | false | Per-spawn `effort` hint hidden from the schema. |
|
||||
| `task.isolation.enabled` | true | false | `isolated: true` spawns run in a copy of the checkout; only successful patches land (merge=patch, apply). Opt-in per spawn. |
|
||||
| `task.completionProbe` | false | true | No periodic completeness side-requests into running subagents. |
|
||||
| `tools.maxTimeout` | 600 | 0 (off) | No agent may request a tool timeout above 10 min; kills `timeout: 0` runaways. |
|
||||
| `providers.maxInFlightRequests` | see below | {} (unlimited) | Per-provider ceiling on concurrent LLM requests, shared across local omp processes. |
|
||||
|
||||
## Depth semantics — never set `maxRecursionDepth: 0`
|
||||
|
||||
Bundle predicate (`U_`): a session at `taskDepth` *s* keeps the `task` tool iff
|
||||
`n < 0 || s < n` where `n = maxRecursionDepth`. The preflight repeats the check
|
||||
(`Cannot spawn another agent at task depth s; maximum depth is n`).
|
||||
|
||||
- `n = 1` → main (depth 0) spawns; children (depth 1) have `task` stripped. **This is
|
||||
"no nested subagents".**
|
||||
- `n = 0` → `0 < 0` fails → **not even the main session can spawn anything.** Don't.
|
||||
- `n < 0` → cap disabled.
|
||||
|
||||
## Per-endpoint in-flight ceilings
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
maxInFlightRequests:
|
||||
"llamacpp (dankserver)": 3 # main session + up to 2 task/reviewer subagents
|
||||
"llamacpp (dankclaw)": 2 # frank-dragoon: security-reviewer + fallbacks
|
||||
"llamacpp (dankclaw-3090)": 3 # frank-zealot: scout/sonic/librarian/designer + smol/tiny/plan/commit/vision roles
|
||||
```
|
||||
|
||||
- Keys are **provider ids** = the quoted block labels in `agent/models.yml`
|
||||
(one provider per host:port). The consumer does a flat map lookup by provider id;
|
||||
absent or ≤0 = unlimited, otherwise clamped to ≥1.
|
||||
- Scope is **per machine, per config root**: it bounds what *this workstation's* omp
|
||||
processes put on each endpoint. Other workstations, OpenWebUI, and any non-omp
|
||||
client are not counted. Requests past the ceiling queue, never fail.
|
||||
- `web/searxng` intentionally uncapped.
|
||||
|
||||
## Isolation note
|
||||
|
||||
`task.isolation.enabled: true` makes the per-spawn `isolated` flag available — it is
|
||||
**opt-in per spawn**, not a blanket sandbox. Requires a git repo; unavailable in plan
|
||||
mode; baseline snapshot capped at 1 GiB; backends resolve via `isolation.backend: auto`
|
||||
(overlayfs/reflink/rcopy chain on this box). Given *the checkout is the deployment*,
|
||||
prefer `isolated: true` for any subagent that edits tracked files.
|
||||
|
||||
## Deliberately not set
|
||||
|
||||
- `bash.gitGuard` — offered 2026-10-19, declined; destructive-git protection stays opt-in.
|
||||
- `task.disabledAgents` — no bundled agent type warrants a ban.
|
||||
- `task.agentAccountPools`, `tier.subagent` — no-op against keyless local endpoints.
|
||||
- `PI_TASK_MAX_OUTPUT_BYTES` / `PI_TASK_MAX_OUTPUT_LINES` — 500 KB / 5000 line defaults are sane.
|
||||
- `before_subagent_spawn` hook — programmable spawn policy; revisit if these caps prove insufficient.
|
||||
|
||||
## Verification
|
||||
|
||||
`cfg://task`, `cfg://providers/maxInFlightRequests` reflect all rows above; persisted
|
||||
copies in `agent/config.yml`. Depth semantics confirmed against the shipped bundle, not
|
||||
just the setting description.
|
||||
|
||||
# Citations
|
||||
|
||||
- `agent/config.yml` (live config, this repo)
|
||||
- `agent/models.yml` — provider id block labels
|
||||
- `@oh-my-pi/pi-coding-agent/dist/cli.js` — `U_` depth predicate, in-flight map lookup
|
||||
- omp docs: `omp://tools/task.md` § Limits & Caps
|
||||
|
||||
# See Also
|
||||
|
||||
- [/pi/statusline.md](/pi/statusline.md) — the omp-era bundle-audit page pattern this page follows
|
||||
- [/pi/packages/pi-subagents.md](/pi/packages/pi-subagents.md) — retired pi-era subagent system (historical)
|
||||
- [/system/index.md](/system/index.md) — fleet endpoints these ceilings target
|
||||
+106
-15
@@ -3,15 +3,19 @@
|
||||
#
|
||||
# The extension's contract, from outside: it registers a `say` command and an
|
||||
# `agent_end` listener; what is observable is (a) the JSON it POSTs to the
|
||||
# speech route, (b) the file it hands to the player, and (c) the notification
|
||||
# text it prints. A fixture HTTP server stands in for frank-tts (it answers the
|
||||
# speech route with a fixed WAV, the roster route with a JSON array, and
|
||||
# everything else with 404) and a stub player stands in for pw-play: it records
|
||||
# the path it was given and copies the bytes it was told to play.
|
||||
# speech route, (b) the file it hands to the player, and (c) the notifications
|
||||
# and select-lists it renders. A fixture HTTP server stands in for frank-tts
|
||||
# (it answers the speech route with a fixed WAV, the roster route with a JSON
|
||||
# array, and everything else with 404), a stub player stands in for pw-play
|
||||
# (it records its full argv and copies the .wav it was given), and a fixture
|
||||
# `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration.
|
||||
#
|
||||
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
|
||||
# (player fixture), DV_MODELS (models.yml fixture), DV_EMPTY_BIN (a PATH with no
|
||||
# players), DV_MODE (the harness mode reported to the extension).
|
||||
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
|
||||
# name the fixture wpctl reports), DV_MODELS (models.yml fixture),
|
||||
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
|
||||
# DV_SELECT (pipe-separated answers fed to ctx.ui.select in order), DV_MODE
|
||||
# (the harness mode reported to the extension).
|
||||
|
||||
setup() {
|
||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
@@ -26,12 +30,27 @@ setup() {
|
||||
|
||||
cat > "$DV_STUB" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$1" >> "$DV_STUB_LOG"
|
||||
cp "$1" "$DV_COPY"
|
||||
printf '%s\n' "$*" >> "$DV_STUB_LOG"
|
||||
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
|
||||
sleep 0.1
|
||||
SH
|
||||
chmod +x "$DV_STUB"
|
||||
|
||||
# the same stub reachable under the canonical player names, plus a fixture
|
||||
# wpctl so output-device enumeration is deterministic on any machine
|
||||
mkdir -p "$WORK/bin"
|
||||
ln -s "$DV_STUB" "$WORK/bin/pw-play"
|
||||
export DV_PWPLAY="$WORK/bin/pw-play"
|
||||
export DV_WPCTL="alsa_output.usb-KT.0"
|
||||
cat > "$WORK/bin/wpctl" <<'WPC'
|
||||
#!/usr/bin/env bash
|
||||
printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL"
|
||||
WPC
|
||||
chmod +x "$WORK/bin/wpctl"
|
||||
|
||||
# settings live in an isolated file so menu choices never leak between tests
|
||||
export DANKVOICE_CONFIG="$WORK/settings.json"
|
||||
|
||||
# models.yml fixture: a chat provider first, the speech provider second
|
||||
export DV_MODELS="$WORK/models.yml"
|
||||
cat > "$DV_MODELS" <<'YML'
|
||||
@@ -54,6 +73,7 @@ YML
|
||||
// it, invoke one scenario, print what the endpoint received and what got played.
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const scenario = process.argv[2] ?? "speak";
|
||||
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
@@ -88,7 +108,8 @@ if (scenario === "endpoint") {
|
||||
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
|
||||
}
|
||||
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 commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
|
||||
@@ -97,11 +118,19 @@ extension.default({
|
||||
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
|
||||
on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => events.set(event, handler),
|
||||
});
|
||||
const answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== "");
|
||||
let answerIndex = 0;
|
||||
const ctx = {
|
||||
mode: process.env.DV_MODE ?? "tui",
|
||||
ui: {
|
||||
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
|
||||
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
|
||||
select: (title: string, options: string[]) => {
|
||||
console.log(`SELECT ${title} ${options.join("|")}`);
|
||||
const answer = answerIndex < answers.length ? answers[answerIndex] : undefined;
|
||||
answerIndex += 1;
|
||||
return Promise.resolve(answer ?? options[0]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const say = commands.get("say");
|
||||
@@ -121,6 +150,16 @@ if (!say) {
|
||||
await say.handler("voice narrator2", ctx);
|
||||
await say.handler("good morning", ctx);
|
||||
break;
|
||||
case "menu":
|
||||
await say.handler("menu", ctx);
|
||||
break;
|
||||
case "empty":
|
||||
await say.handler("", ctx);
|
||||
break;
|
||||
case "devicemenu":
|
||||
await say.handler("menu", ctx);
|
||||
await say.handler("hello world", ctx);
|
||||
break;
|
||||
case "auto":
|
||||
await say.handler("on", ctx);
|
||||
await agentEnd?.(answer("the answer"), ctx);
|
||||
@@ -150,6 +189,12 @@ if (!say) {
|
||||
case "truncate":
|
||||
await say.handler("Sentence one is here. ".repeat(40), ctx);
|
||||
break;
|
||||
case "sentences":
|
||||
await say.handler("First stop here. Second one goes!!! Really? Third ... done", ctx);
|
||||
break;
|
||||
case "decimals":
|
||||
await say.handler("Model 3.5 ships today. Version 1,5 too.", ctx);
|
||||
break;
|
||||
case "noplayer":
|
||||
await say.handler("speak anyway", ctx);
|
||||
break;
|
||||
@@ -171,6 +216,7 @@ for (const call of received) {
|
||||
console.log(`CALLS ${received.length}`);
|
||||
const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
|
||||
console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`);
|
||||
if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
|
||||
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
|
||||
server.close();
|
||||
TS
|
||||
@@ -183,7 +229,7 @@ teardown() {
|
||||
}
|
||||
|
||||
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" {
|
||||
@@ -240,13 +286,34 @@ say_run() {
|
||||
|
||||
@test "markdown never reaches the speech engine" {
|
||||
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
|
||||
[[ "$output" == *"here. …"* ]]
|
||||
[ "$(grep -o 'Sentence one is here\.' <<<"$output" | wc -l)" -le 4 ]
|
||||
[[ "$output" == *"CALLS 1"* ]]
|
||||
[[ "$output" == *"PLAYS 1"* ]]
|
||||
[[ "$output" == *"input=Sentence one is here."* ]]
|
||||
}
|
||||
|
||||
@test "a long answer is spoken as sequential sentence-sized requests" {
|
||||
say_run sentences
|
||||
[[ "$output" == *"CALLS 3"* ]]
|
||||
[[ "$output" == *"PLAYS 3"* ]]
|
||||
[[ "$output" == *"input=First stop here."* ]]
|
||||
[[ "$output" == *"input=Second one goes!!! Really?"* ]]
|
||||
[[ "$output" == *"input=Third ... done"* ]]
|
||||
}
|
||||
|
||||
@test "decimals and comma-decimals never split a sentence" {
|
||||
say_run decimals
|
||||
[[ "$output" == *"CALLS 2"* ]]
|
||||
[[ "$output" == *"input=Model 3.5 ships today."* ]]
|
||||
[[ "$output" == *"input=Version 1,5 too."* ]]
|
||||
}
|
||||
|
||||
@test "a missing player is reported, not swallowed" {
|
||||
@@ -261,3 +328,27 @@ say_run() {
|
||||
[[ "$output" == *"input=the settled answer"* ]]
|
||||
[[ "$output" != *"input=not yet"* ]]
|
||||
}
|
||||
|
||||
@test "the menu lists every knob and a plain /say opens it" {
|
||||
say_run menu
|
||||
[[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|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