Compare commits

..
Author SHA1 Message Date
Frank bcd52bb0f4 dankvoice: speak long answers as sequential sentence-sized requests
agent_end awaited one whole-answer synthesis; long answers hit the
harness 30s extension-handler timeout (handler timed out after 30000ms)
and the single frank-tts request was the stall. Port the built-in
speak-aloud vocalizer's utterance segmenter (fHe in dist/cli.js) so
segmentation matches the known-working stock path: [.!?…]-run
boundaries, abbreviation/decimal/inline-code guards, fenced code and
tables swallowed, markup cleaned. Play segment N to player exit before
synthesizing N+1 (audio never stacks); fire-and-forget the chain so
utterances outlive the handler timeout; a run token retires superseded
chains. DANKVOICE_MAX becomes a per-answer budget of whole segments;
stripForSpeech is gone. Refs #23 follow-up.
2026-10-19 01:21:34 -07:00
Frank edd7e0cf68 live: heartbeat sync 2026-10-19T01:00:41-07:00 (Refs #11) 2026-10-19 01:00:41 -07:00
Frank f033639b54 chore(agent): disable built-in speak-aloud — dankvoice cloned voices are the TUI voice (Refs #23) 2026-10-19 00:49:14 -07:00
dank 6fffd83957 Merge pull request 'feat(dankvoice): /say options menu with output-device routing' (#25) from feat/23-dankvoice-menu into main 2026-10-19 00:40:33 -07:00
dank acf67182e9 Merge pull request 'docs: record omp subagent fan-out caps and per-endpoint in-flight ceilings' (#24) from docs/subagent-limits into main 2026-10-19 00:23:29 -07:00
Frank 38bf9c4897 docs: record omp subagent fan-out caps and per-endpoint in-flight ceilings
Codifies the limits set in agent/config.yml 2026-10-19: task concurrency 2,
no nested subagent spawns (maxRecursionDepth 1 - and why 0 must never be
set), 30-min runtime / 120-request budgets, isolation opt-in enabled,
completion probes off, 600s tool timeout ceiling, and per-provider
in-flight caps (archon 3, dragoon 2, zealot 3) keyed by models.yml provider
ids with their per-machine scope caveat. Small docs-only change; no issue
opened per trunk-based small-fix exemption.
2026-10-19 00:22:17 -07:00
22 changed files with 650 additions and 153 deletions
+15 -3
View File
@@ -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,7 +214,7 @@ vault:
tts:
localVoice: bm_george
speech:
enabled: true
enabled: false
mode: all
enhanced: true
voice: am_fenrir
@@ -232,6 +243,7 @@ followUpMode: one-at-a-time
tools:
approvalMode: yolo
format: auto
maxTimeout: 600
marketplace:
autoUpdate: notify
bash:
+355 -41
View File
@@ -28,14 +28,21 @@
* DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json)
* DANKVOICE_PLAYER player binary, skipping detection
* DANKVOICE_VOICE voice name ("" = server default)
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
* 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. 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.
* 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";
@@ -185,25 +192,305 @@ export function playerFor(command: string, sink: string): Player {
return { command, args: ["--target", sink] };
}
/** 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()} …`;
// ------------------------------------------------------------ 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. */
@@ -408,14 +695,16 @@ 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;
};
@@ -455,9 +744,12 @@ export default function dankvoice(pi: ExtensionAPI) {
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;
@@ -466,19 +758,36 @@ 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");
}
}
@@ -603,13 +912,13 @@ export default function dankvoice(pi: ExtensionAPI) {
);
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
@@ -617,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.
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792381479415,
"lastConsolidatedAt": 1792395788647,
"lastResult": {
"at": 1791599865775,
"sessions": 1,
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792392829703
"lastConsolidatedAt": 1792396789735
}
@@ -1,3 +1,20 @@
## Subagent routing (harness law, 2026-10-18)
**No subagent class may route to `frank-archon`** — the operator session lives there and
the endpoint runs 3 sequences (`--max-num-seqs 3`), so `task.maxConcurrency + 1` children
eat the parent's own KV slots. Violation cost: three doc children + parent = 4 sequences
on 3 slots, children ran 2 h 27 m / 2 h 45 m, the interactive session starved.
Routing and bounds live in `~/.omp/agent/config.yml` (`task:` block — the owner; verified
with `read cfg://task/agentModelOverrides`): heavy writers (`task`, `scout`) → frank-dragoon
(2 slots, idle, slow card ⇒ slice work smaller), bursty readers (`reviewer`, `sonic`,
`designer`, `librarian`) → frank-zealot (2 slots, also the compaction hub and every
tiny/smol/plan/commit/memory role). Caps: `maxConcurrency: 2`, `maxRuntimeMs: 1800000`,
`softRequestBudget: 120`, `maxRecursionDepth: 1` (children never spawn children).
`cfg://.../save` approvals auto-deny after a hardcoded 10 s dialog — after Frank has
approved a change, edit `config.yml` directly and validate the YAML; a running session
picks global-config edits up at once. Full procedure: managed skill
`bounding-subagent-runtime`.
## Documentation law (2026-10-18)
**One fact, one owner.** The `serverAppConfigs` repo owns every fleet fact;
@@ -18,8 +35,8 @@ Swap the model, don't instrument the swap: edit the trio
probe → done. **No benchmarks, A/Bs, ladders, or acceptance tables unless
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*.
scratch ports, never `systemctl disable`) is `AGENTS.md` → *Model swaps*
and *Deployment completeness*.
## Where facts live
@@ -3,25 +3,22 @@
- Custom voice source material must exclude the first 10 seconds of any audio sample file used to generate voices; this is a project-wide invariant across all source files, not a per-file exception.
- Voice selector valid options: af_heart, af_alloy, af_aoede, af_bella, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_sky, am_adam, am_echo, am_eric, am_fenrir, am_liam, am_michael, am_onyx, am_puck, am_santa, bf_emma, bf_isabella, bm_george, bm_lewis, bf_alice, bf_lily, bm_daniel, bm_fable.
## Podcast
- Podcast functionality is sourced from open-notebook; this is a project decision established before this document was created.
## Audio Input Policy
- There is no audio input anywhere in the fleet: that capability left with the FLM NPU endpoint on 2026-09-11, returned 2026-09-17 with the Gemma4 projector's audio encoder, and left again with the Gemma4 retirement on 2026-10-01.
- This policy mirrors the architecture-level decision documented in `architecture.md` regarding the fleet's input capabilities.
## Input Capabilities
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.
This rule exists because the Gemma4 projector was retired in 2026-10-01, removing the audio input channel from the fleet. The 2026-09-11 to 2026-10-01 period contained the Gemma4 projector which enabled audio, but that endpoint is now retired.
## Voice Usage Notes
- Voice 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.`,
- Voice selection is client-side only; the server does not generate or return audio data.
## Load Management
- Subagent settings must prevent frank-archon from being overwhelmed; concurrent request handling prioritizes endpoint stability over throughput.
## Podcast
- Podcast functionality is sourced from open-notebook; this is a project decision established before this document was created.
@@ -1,8 +1,8 @@
{
"v": 1,
"lastConsolidatedAt": 1792392891753,
"lastConsolidatedAt": 1792396748497,
"lastResult": {
"at": 1792379504653,
"at": 1792393886960,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
@@ -0,0 +1,3 @@
# Output behavior
Output must not be shaken off during presentation. Output remains anchored until explicitly confirmed.
@@ -1,4 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792392882348
"lastConsolidatedAt": 1792396722373,
"lastResult": {
"at": 1792396071933,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
}
}
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792392961865,
"lastConsolidatedAt": 1792393561866,
"lastResult": {
"at": 1792381870670,
"sessions": 1,
@@ -0,0 +1 @@
{"v":1,"kind":"product_decision","statement":"Limit on zealot set to 3, dragoon set to 2, archon set to 3","source":"explicit_user","evidence":"make the limit on zealot 3, dragoon 2, archon 3","friction":{"corrective":false,"regression":false,"subtle":false},"sessionId":"01a15302-f12d-77d8-9a33-be31a1a05528","ts":1792394470419}
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792394427081
}
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792396749853
}
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200
[wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
[wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
[wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.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_4x17w3.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
[weather]
unit = "imperial"
+48 -7
View File
@@ -9,8 +9,8 @@ tags:
- voice
- frank-tts
- dankclaw
verified: 2026-10-18
timestamp: 2026-10-18
verified: 2026-10-19
timestamp: 2026-10-19
---
# dankvoice
@@ -54,9 +54,38 @@ 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
@@ -73,7 +102,7 @@ resolves, so no host address and no credential appears in this file. From
| `DANKVOICE_CONFIG` | `~/.omp/cache/dankvoice.json` | persisted menu choices (voice, player, sink, cap, auto) |
| `DANKVOICE_VOICE` | empty (server default) | default voice name |
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
| `DANKVOICE_MAX` | `700` | spoken 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 |
@@ -86,7 +115,7 @@ 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
@@ -118,6 +147,12 @@ its directory are removed when the player process exits.
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
@@ -141,6 +176,12 @@ its directory are removed when the player process exits.
* `omp --help` (extension discovery flags, `say`, `plugin`) and
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts`
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
* The stock speak-aloud implementation — vocalizer pipeline `I6o` and
utterance segmenter `fHe` — in `@oh-my-pi/pi-coding-agent`'s
`dist/cli.js`, read off the minified bundle 2026-10-19; the enhanced
rewrite ("with the tiny/smol model before synthesis") is that bundle's
own `speech.enhanced` description. dankvoice ports the mechanical half
only: no LLM rewrite happens before frank-tts.
* 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).
+23
View File
@@ -12,6 +12,29 @@ 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
+2 -1
View File
@@ -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
+97
View File
@@ -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
+31 -4
View File
@@ -189,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;
@@ -280,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" {