Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38bf9c4897 | ||
|
|
54f15e2a58 | ||
|
|
1e7e212e5c | ||
|
|
475211d2e7 | ||
|
|
0258ef70f8 | ||
|
|
0c7fe8b4bf | ||
|
|
765b686859 | ||
|
|
332067a516 | ||
|
|
66e3f220e7 | ||
|
|
6134e1168e | ||
|
|
c379d16858 | ||
|
|
ca8b46241e | ||
|
|
3fa0242f56 | ||
|
|
fe039a0b73 | ||
|
|
d4092f2f65 | ||
|
|
cedef97c99 | ||
|
|
0ee08bc368 |
+2
-2
@@ -13,7 +13,7 @@ modelRoles:
|
|||||||
default: llamacpp (dankserver)/frank-archon:medium
|
default: llamacpp (dankserver)/frank-archon:medium
|
||||||
smol: llamacpp (dankclaw-3090)/frank-zealot:medium
|
smol: llamacpp (dankclaw-3090)/frank-zealot:medium
|
||||||
web: web/searxng
|
web: web/searxng
|
||||||
speech: local/kokoro
|
speech: frank-tts (dankclaw)/frank-tts
|
||||||
judge: openrouter/~typesafe/jev-latest:auto
|
judge: openrouter/~typesafe/jev-latest:auto
|
||||||
dictation: local/parakeet-tdt-0.6b-v3
|
dictation: local/parakeet-tdt-0.6b-v3
|
||||||
memory: llamacpp (dankclaw-3090)/frank-zealot:medium
|
memory: llamacpp (dankclaw-3090)/frank-zealot:medium
|
||||||
@@ -204,7 +204,7 @@ tts:
|
|||||||
localVoice: bm_george
|
localVoice: bm_george
|
||||||
speech:
|
speech:
|
||||||
enabled: true
|
enabled: true
|
||||||
mode: yield
|
mode: all
|
||||||
enhanced: true
|
enhanced: true
|
||||||
voice: am_fenrir
|
voice: am_fenrir
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
/**
|
||||||
|
* dankvoice — cloned-voice vocalization for omp, served by frank-tts.
|
||||||
|
*
|
||||||
|
* The built-in speak-aloud feature (`speech.enabled` / `speech.voice`) and the
|
||||||
|
* `omp say` CLI are gated to the bundled Kokoro voice enum, so the cloned
|
||||||
|
* voices on frank-tts (Dankclaw :7772, the `speech` model role in
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* 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:
|
||||||
|
*
|
||||||
|
* 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_PLAYER player binary, skipping detection
|
||||||
|
* DANKVOICE_MODELS path to models.yml (default ~/.omp/agent/models.yml)
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_CHARS = 700;
|
||||||
|
const SYNTH_TIMEOUT_MS = 60_000;
|
||||||
|
const VOICES_TIMEOUT_MS = 15_000;
|
||||||
|
/** A voice name the shim must reject; its 400 body carries the real list. */
|
||||||
|
const VOICE_PROBE = "_dankvoice_probe_";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- pure logic
|
||||||
|
|
||||||
|
export interface SpeechTarget {
|
||||||
|
/** POST target for OpenAI speech synthesis. */
|
||||||
|
speechUrl: string;
|
||||||
|
/** GET target for the voice roster (frank-tts answers a bare JSON array). */
|
||||||
|
voicesUrl: string;
|
||||||
|
/** GET target for the liveness route of the same host:port. */
|
||||||
|
healthUrl: string;
|
||||||
|
/** Served model id to name in the request body. */
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SayAction =
|
||||||
|
| { kind: "help" }
|
||||||
|
| { kind: "speak"; text: string }
|
||||||
|
| { kind: "on" }
|
||||||
|
| { kind: "off" }
|
||||||
|
| { kind: "stop" }
|
||||||
|
| { kind: "voices" }
|
||||||
|
| { kind: "status" }
|
||||||
|
| { kind: "voice"; name: string };
|
||||||
|
|
||||||
|
/** `/say <args>` grammar: subcommands win, anything else is spoken text. */
|
||||||
|
export function parseSayArgs(raw: string): SayAction {
|
||||||
|
const args = raw.trim();
|
||||||
|
const lower = args.toLowerCase();
|
||||||
|
if (lower === "" || lower === "help") return { kind: "help" };
|
||||||
|
if (lower === "on") return { kind: "on" };
|
||||||
|
if (lower === "off") return { kind: "off" };
|
||||||
|
if (lower === "stop") return { kind: "stop" };
|
||||||
|
if (lower === "voices") return { kind: "voices" };
|
||||||
|
if (lower === "status") return { kind: "status" };
|
||||||
|
if (lower === "voice" || lower.startsWith("voice ")) return { kind: "voice", name: args.slice(5).trim() };
|
||||||
|
return { kind: "speak", text: args };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the speech endpoint out of agent/models.yml. The runner API lives on
|
||||||
|
* the MODEL row (`api: openai-speech`), the base URL on its provider, so this
|
||||||
|
* scan remembers the last provider base URL and model id seen and returns when
|
||||||
|
* a model row claims the speech runner API. A new provider key (indent two)
|
||||||
|
* resets both.
|
||||||
|
*/
|
||||||
|
export function resolveSpeechEndpoint(modelsYml: string): SpeechTarget | undefined {
|
||||||
|
let base: string | undefined;
|
||||||
|
let id: string | undefined;
|
||||||
|
for (const line of modelsYml.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
if (/^ {2}\S.*:\s*$/.test(line)) {
|
||||||
|
base = undefined;
|
||||||
|
id = undefined;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const providerUrl = trimmed.match(/^baseUrl:\s*(\S+)/);
|
||||||
|
if (providerUrl) {
|
||||||
|
base = providerUrl[1].replace(/\/+$/, "");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const modelId = trimmed.match(/^-\s*id:\s*(\S+)/);
|
||||||
|
if (modelId) {
|
||||||
|
id = modelId[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^api:\s*openai-speech\s*$/.test(trimmed) && base) {
|
||||||
|
return {
|
||||||
|
speechUrl: `${base}/audio/speech`,
|
||||||
|
voicesUrl: `${base}/audio/voices`,
|
||||||
|
healthUrl: `${base.replace(/\/v1$/, "")}/health`,
|
||||||
|
model: id ?? "frank-tts",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OpenAI speech body; `voice` is sent only when named — the shim carries its own default. */
|
||||||
|
export function speechBody(model: string, text: string, voice: string): Record<string, string> {
|
||||||
|
const body: Record<string, string> = { model, input: text, response_format: "wav" };
|
||||||
|
if (voice) body.voice = voice;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Voice names as frank-tts reports them: the invalid-voice 400 body carries `available_voices`. */
|
||||||
|
export function parseVoices(payload: unknown): string[] {
|
||||||
|
// frank-tts answers GET /v1/audio/voices with a bare array and an unknown voice with
|
||||||
|
// {error:{available_voices:[…]}}; a flat body is accepted too.
|
||||||
|
if (typeof payload !== "object" || payload === null) return [];
|
||||||
|
let list: unknown;
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
list = payload;
|
||||||
|
} else if ("error" in payload && typeof payload.error === "object" && payload.error !== null && "available_voices" in payload.error) {
|
||||||
|
list = payload.error.available_voices;
|
||||||
|
} else if ("available_voices" in payload) {
|
||||||
|
list = payload.available_voices;
|
||||||
|
} else if ("voices" in payload) {
|
||||||
|
list = payload.voices;
|
||||||
|
} else if ("data" in payload) {
|
||||||
|
list = payload.data;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const entry of list) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
names.push(entry);
|
||||||
|
} else if (typeof entry === "object" && entry !== null) {
|
||||||
|
const name = "id" in entry ? entry.id : "name" in entry ? entry.name : undefined;
|
||||||
|
if (typeof name === "string") names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()} …`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text the assistant last actually said; tool-only messages do not count. */
|
||||||
|
export function lastAssistantText(messages: readonly unknown[]): string {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const message = messages[i];
|
||||||
|
if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") continue;
|
||||||
|
if (!("content" in message)) continue;
|
||||||
|
const content = message.content;
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
|
||||||
|
parts.push(part.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const joined = parts.join("\n").trim();
|
||||||
|
if (joined) return joined;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- live wiring
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
target?: SpeechTarget;
|
||||||
|
player?: Player;
|
||||||
|
maxChars: number;
|
||||||
|
auto: boolean;
|
||||||
|
voice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupOnPath(command: string): string | null {
|
||||||
|
const found = spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" });
|
||||||
|
return found.status === 0 && found.stdout.trim() ? found.stdout.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readModelsYml(): string {
|
||||||
|
const path = process.env.DANKVOICE_MODELS ?? join(process.env.HOME ?? "", ".omp", "agent", "models.yml");
|
||||||
|
try {
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig(): Config {
|
||||||
|
const overrideUrl = process.env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
||||||
|
const modelOverride = process.env.DANKVOICE_MODEL?.trim();
|
||||||
|
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
|
||||||
|
const target: SpeechTarget | undefined = overrideUrl
|
||||||
|
? {
|
||||||
|
speechUrl: `${overrideUrl}/audio/speech`,
|
||||||
|
voicesUrl: `${overrideUrl}/audio/voices`,
|
||||||
|
healthUrl: `${overrideUrl.replace(/\/v1$/, "")}/health`,
|
||||||
|
model: modelOverride || "frank-tts",
|
||||||
|
}
|
||||||
|
: fromYml
|
||||||
|
? { ...fromYml, model: modelOverride || fromYml.model }
|
||||||
|
: undefined;
|
||||||
|
const playerOverride = process.env.DANKVOICE_PLAYER?.trim();
|
||||||
|
const maxChars = Number(process.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() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
if (!res.ok || contentType.includes("json")) {
|
||||||
|
let message = `frank-tts HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const payload: unknown = await res.json();
|
||||||
|
if (
|
||||||
|
typeof payload === "object" &&
|
||||||
|
payload !== null &&
|
||||||
|
"error" in payload &&
|
||||||
|
typeof payload.error === "object" &&
|
||||||
|
payload.error !== null &&
|
||||||
|
"message" in payload.error &&
|
||||||
|
typeof payload.error.message === "string"
|
||||||
|
) {
|
||||||
|
message = payload.error.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep the status-line message
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||||
|
if (bytes.byteLength === 0) throw new Error("frank-tts returned no audio");
|
||||||
|
const file = join(dir, `say-${Date.now()}.wav`);
|
||||||
|
writeFileSync(file, bytes);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** frank-tts's roster route first; if it ever answers nothing, ask with a voice it must reject. */
|
||||||
|
async function fetchVoices(target: SpeechTarget): Promise<string[]> {
|
||||||
|
const roster = await fetch(target.voicesUrl, { signal: AbortSignal.timeout(VOICES_TIMEOUT_MS) })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch(() => undefined);
|
||||||
|
const listed = parseVoices(roster);
|
||||||
|
if (listed.length > 0) return listed;
|
||||||
|
const probe = await fetch(target.speechUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(speechBody(target.model, "voice list probe", VOICE_PROBE)),
|
||||||
|
signal: AbortSignal.timeout(VOICES_TIMEOUT_MS),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
if (!probe) return [];
|
||||||
|
return parseVoices(await probe.json().catch(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play a WAV and clean its temp directory up when the player exits. */
|
||||||
|
function startPlayback(player: Player, file: string, dir: string): ChildProcess {
|
||||||
|
const child = spawn(player.command, [...player.args, file], { stdio: "ignore" });
|
||||||
|
const clean = () => {
|
||||||
|
try {
|
||||||
|
rmSync(file, { force: true });
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// temp files are not worth a toast
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.once("exit", clean);
|
||||||
|
child.once("error", clean);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function dankvoice(pi: ExtensionAPI) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const state: { auto: boolean; voice: string; child?: ChildProcess; lastCtx?: ExtensionContext } = {
|
||||||
|
auto: cfg.auto,
|
||||||
|
voice: cfg.voice,
|
||||||
|
};
|
||||||
|
const voiceLabel = () => state.voice || "server default";
|
||||||
|
const reflectStatus = () => state.lastCtx?.ui.setStatus("dankvoice", state.auto ? `🔊 ${voiceLabel()}` : undefined);
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
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)";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (!cfg.player) {
|
||||||
|
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
|
||||||
|
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();
|
||||||
|
try {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerCommand("say", {
|
||||||
|
description:
|
||||||
|
"Speak with the fleet's cloned voices (frank-tts): /say <text> | on | off | voice <name> | voices | stop | status",
|
||||||
|
handler: async (args: string, ctx) => {
|
||||||
|
state.lastCtx = ctx;
|
||||||
|
const action = parseSayArgs(args);
|
||||||
|
switch (action.kind) {
|
||||||
|
case "help":
|
||||||
|
ctx.ui.notify(
|
||||||
|
"dankvoice: /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");
|
||||||
|
return;
|
||||||
|
case "off":
|
||||||
|
state.auto = false;
|
||||||
|
stop();
|
||||||
|
reflectStatus();
|
||||||
|
ctx.ui.notify("dankvoice: auto-speak off", "info");
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "voices": {
|
||||||
|
if (!cfg.target) {
|
||||||
|
ctx.ui.notify(missingTarget(), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const voices: string[] = [];
|
||||||
|
try {
|
||||||
|
voices.push(...(await fetchVoices(cfg.target)));
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.ui.notify(
|
||||||
|
voices.length
|
||||||
|
? `dankvoice voices: ${voices.join(", ")} — using ${voiceLabel()}`
|
||||||
|
: "dankvoice: frank-tts reported no voice list",
|
||||||
|
voices.length ? "info" : "warning",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "speak":
|
||||||
|
await speak(ctx, action.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", async (event, ctx) => {
|
||||||
|
state.lastCtx = ctx;
|
||||||
|
if (!state.auto) return;
|
||||||
|
// `willContinue` means the harness already scheduled a continuation
|
||||||
|
// (auto-retry, empty-stop retry): this is not the user-visible terminal
|
||||||
|
// 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));
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", () => {
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
name: audit-never-run-container-deploy
|
||||||
|
description: "Audit a locally built podman/quadlet deploy path that has never actually run: prove base-image assumptions, env-file reader divergence, and image-hash coverage before the first deploy."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Audit a never-run container deploy path
|
||||||
|
|
||||||
|
A deployable that has never been built or started has never been *executed*: every
|
||||||
|
fault in it is invisible to tests that stub the runtime. Audit it as static code
|
||||||
|
against the tools it calls, then prove each suspicion cheaply against the real
|
||||||
|
tool. The faults worth hunting are the ones that **report success while doing
|
||||||
|
nothing** — those are the ones that survive a green `/health` and a green e2e run.
|
||||||
|
|
||||||
|
## The three fault classes (all measured 2026-10-18, frank-tts-engine)
|
||||||
|
|
||||||
|
1. **A command the base image does not ship.** e.g. `adduser` in a `RUN` line
|
||||||
|
against a trimmed `ubuntu:24.04` — absent, and not pulled in by
|
||||||
|
`ca-certificates curl libgomp1`. Dies at build time (loud), but only the first
|
||||||
|
time anyone builds it.
|
||||||
|
Prove it in the base, never from memory:
|
||||||
|
`podman run --rm <base> sh -c "command -v adduser || echo absent; useradd --system --uid 10000 --home-dir /app --shell /usr/sbin/nologin tts && id tts"`
|
||||||
|
Note the accepted cosmetic warning (`uid 10000 is greater than SYS_UID_MAX 999`)
|
||||||
|
in a comment, or a future agent "fixes" it by dropping `--system` and the uid
|
||||||
|
stops matching the sibling container.
|
||||||
|
2. **Two readers of one env file disagree.** systemd units that pass
|
||||||
|
`EnvironmentFile=` hand it to podman `--env-file`, which does **no shell
|
||||||
|
parsing** — it takes the literal remainder of the line — while bash `source`
|
||||||
|
strips one quoting layer. So `WARM_TEXT="Warm up, one two three."` reaches the
|
||||||
|
container *with its quotes*, and a script that interpolates it into a JSON body
|
||||||
|
produces invalid JSON: the container boots, `active`, every voice registered,
|
||||||
|
every voice cold. Fix in the consumer: strip exactly one matched pair **only**
|
||||||
|
when both ends are the same quote AND the inner content carries another quote or
|
||||||
|
backslash (never mangle a value that legitimately contains quotes), then escape.
|
||||||
|
3. **A build-skip hash that under-covers the image.** `build_compose_image`-style
|
||||||
|
helpers skip `podman build` when a label hash matches; hashing the `Containerfile`
|
||||||
|
alone means editing a `COPY`'d app/script rebuilds **nothing** — deploy reports
|
||||||
|
success, `/health` answers, container serves old code. Each case arm must name
|
||||||
|
the paths its `Containerfile` copies (`extra=(…)`) and hash Containerfile+extras.
|
||||||
|
Arms that have no extras keep their old hash, so they do not needlessly rebuild.
|
||||||
|
|
||||||
|
## Test it as behaviour, never as a grep of the handler
|
||||||
|
|
||||||
|
- Drive the **real** function: `awk '/^build_compose_image\(\) \{/,/^\}/' deploy-remote.sh > bci.sh`,
|
||||||
|
`source` it, put a stub `podman` first on `PATH`, assert the observable question —
|
||||||
|
*did the deploy ask podman to build, or did it skip?*
|
||||||
|
- Stub traps that cost real time: podman is invoked `podman image inspect <image> --format …`
|
||||||
|
so the tag is **`$3`**, not `$2`; tags contain `/` so a state file named after one
|
||||||
|
needs `${tag//\//_}` or the write silently lands in a nonexistent directory and
|
||||||
|
every image looks permanently unbuilt; `--label cfhash=…` arrives **before** `-t <tag>`,
|
||||||
|
so collect the label and write it after the arg loop.
|
||||||
|
- For an env-file bug, write one test that reads the file **both** ways — `source`
|
||||||
|
and the `--env-file` way (parse `KEY=literal remainder`) — and assert the program
|
||||||
|
receives the same value in both shapes, including a value that carries its own
|
||||||
|
quotes and a backslash. A suite that only sources the file is how the bug shipped.
|
||||||
|
- Prove red: `git show HEAD:scripts/deploy-remote.sh > scripts/deploy-remote.sh`,
|
||||||
|
run the new test (must fail at the exact intended step), restore. Keep each fix in
|
||||||
|
its own commit and run the seam at each one so the branch bisects.
|
||||||
|
|
||||||
|
## Base-image and host facts to check while auditing
|
||||||
|
|
||||||
|
- `useradd` lives in `login(1)` (base carries it); `adduser` does not.
|
||||||
|
- Rootless podman **cannot raise rlimits** — `--ulimit nofile=…` above the host hard
|
||||||
|
limit dies at container create, not at runtime.
|
||||||
|
- A referenced `Network=` `.network` file the generator cannot find means the
|
||||||
|
container unit is **not produced at all**.
|
||||||
|
- Two containers talk by DNS name only if they attach to a **common** network;
|
||||||
|
check both unit files rather than trusting a comment that says they share one.
|
||||||
|
- Container DNS name + the network that owns it, never `host.containers.internal`
|
||||||
|
or a published loopback port, for service-to-service traffic.
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
- Do not deploy the audited thing because the audit is finished: first build of a
|
||||||
|
heavy image (recursive source clone ~10 min) plus a new unit on a host serving
|
||||||
|
people is the operator's call.
|
||||||
|
- Do not add a Prometheus target for a container with no `/metrics` — say the probe
|
||||||
|
is a decision (blackbox HTTP), do not fake a scrape.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: corpus-voice-skill-rewrite
|
||||||
|
description: "Rewrite a skill to emulate an author corpus's voice: full read, style-DNA distillation from writing-for-agents levers, positive-phrasing pass, sharpened completion criteria"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Corpus-voice skill rewrite
|
||||||
|
|
||||||
|
Rewrite a skill so it reads as if a chosen author wrote it. Done for `grilling` against the `mattpocock/skills` corpus (2026-10-18, command-center PR #20); behaviour preserved, voice swapped.
|
||||||
|
|
||||||
|
## Where the corpus lives
|
||||||
|
|
||||||
|
Installed third-party skills: `~/.agents/skills/<name>/SKILL.md` (the skills-CLI global store; `~/.agents/.skill-lock.json` names sources). Whole mattpocock corpus ≈ 2,800 lines — small enough to read inline in ~4 `cat` batches via bash; do NOT delegate the reading, style lives in the exact prose.
|
||||||
|
|
||||||
|
Read the author's own meta-skill FIRST if one exists (`writing-for-agents` for mattpocock — it states his levers explicitly), then the corpus practicing them.
|
||||||
|
|
||||||
|
## Style DNA checklist (mattpocock; generalise per author)
|
||||||
|
|
||||||
|
- **Leading words**: compact pretrained concepts repeated as tokens, never sentences (_red_, _seam_, _fog of war_, _brief_, _frontier_). Define each once, reuse everywhere. Prefer words already in the author's other skills so the corpus shares one language.
|
||||||
|
- **Vocabulary-first**: bolded glossary terms, then used exactly; explicit "_Avoid_: component, service".
|
||||||
|
- **Positive phrasing**: prohibitions rewritten as target behaviours; keep a prohibition only when no positive form exists (hard guardrails).
|
||||||
|
- **Sharpened completion criteria**: fuzzy bounds ("understanding reached") → checkable + exhaustive ("frontier empty and nothing sits blocked", "one artefact per question, counted before sending").
|
||||||
|
- **Zero history**: no provenance, attribution, or absorbed-from lineage in the body.
|
||||||
|
- **No-op hunting**: delete harness defaults and anything the environment already confesses (formats, rendering, caps). Gotchas the environment CANNOT confess (exclusive dialogs, Esc-abort semantics) stay inline — they're the cache worth keeping.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Read entire corpus; tabulate levers with per-skill evidence before writing.
|
||||||
|
2. Rewrite preserving settled behaviour 1:1 — voice only; diff the two versions for semantic drift.
|
||||||
|
3. Audit pass: grep the new text for history words (extracted/absorbed/from/upstream), machine-specific constants, repo-specific paths.
|
||||||
|
4. Run repo checks, commit as `style(<scope>)`, note the lever→instance mapping in the PR comment so the reviewer can grade the emulation.
|
||||||
@@ -1,125 +1,81 @@
|
|||||||
---
|
---
|
||||||
name: frank-tts-add-cloned-voice
|
name: frank-tts-add-cloned-voice
|
||||||
description: "Add or repair a cloned voice on the dank fleet: /home/dank/voices/<name> pack, name rules, boot-warm registration, and the under-a-second proof"
|
description: "Add or repair a cloned voice on the dank fleet: pack builder, the reference.wav contract, the boot warm hook (argv-cap and env-quote laws), and how to prove a voice really answers."
|
||||||
---
|
---
|
||||||
|
|
||||||
Use for "give frank-tts a new voice", "voice X is missing/unknown", or "the first
|
# Adding (or repairing) a cloned voice on the dank fleet
|
||||||
request of the day takes 5 s". Runbook of record: vault pages [[Frank TTS]] (voice
|
|
||||||
packs, naming) and [[Frank TTS Engine]] (the warm registry). Everything below runs
|
|
||||||
as the deploy user on **Dankclaw** (`ssh -o BatchMode=yes dankclaw '…'`); the
|
|
||||||
containers are **rootless** (`systemctl --user …`, `podman …` without sudo).
|
|
||||||
|
|
||||||
## Naming (the question that gets asked first)
|
Repo law lives in `serverAppConfigs-docs/Dankclaw/Voice Packs.md` and
|
||||||
|
`Frank TTS Engine.md` — read those for the pack format. This skill is the
|
||||||
|
**operating sequence plus the traps that cost real time on 2026-10-18**.
|
||||||
|
|
||||||
A voice is addressed **by its directory name** — `voice=narrator1` on
|
## Stack, fast
|
||||||
`POST /v1/audio/speech` (omp's TTS device may send `voice_id`, same value). Use:
|
|
||||||
|
|
||||||
- `^[a-z0-9][a-z0-9._-]*$`, **lowercase**, ≤ 64 chars. No slash, no `..`, no
|
- `frank-tts-engine` — arm A `qwentts.cpp tts-server`, container-internal `:8880`,
|
||||||
space, no uppercase; must not start with `.`/`_`/`-`.
|
publishes **no port**. Reach it with `podman exec frank-tts-engine curl …`.
|
||||||
- Why lowercase rather than just "alphanumeric": the shim accepts
|
- `frank-tts` — OpenAI façade on host `:7772`; resolves voice **by name**.
|
||||||
`[A-Za-z0-9][A-Za-z0-9._-]{0,63}` (`app.py`, `VOICE_NAME_RE`) but the **engine
|
- Packs: `/home/dank/voices/<name>/` with `reference.wav` (+ manifest, `.spk`,
|
||||||
lowercases names**, so `Narrator1` resolves on one side and not the other. The
|
`.rvq`). Builder: `scripts/build-voice-pack.sh` (crop 19.84–25 s, first 10 s of
|
||||||
boot warmer refuses a mixed-case pack loudly instead of half-working.
|
any source is forbidden).
|
||||||
- Names are user-visible: a 400 carries them in `available_voices`. Fleet
|
- Weights: `/home/dank/models/Qwen3-TTS-GGUF/` —
|
||||||
convention is `narrator1`, `narrator2`, … for the open-notebook podcast
|
`scripts/download-frank-tts-engine-qwentts.sh [--verify-only]` (1.4 GiB, ~18 s
|
||||||
speakers — open-notebook is configured with exactly `narrator1/2/3`, so renaming
|
on the LAN; verifies byte count + Hub LFS sha256).
|
||||||
those breaks podcast rendering, not just TTS — and a lowercase personal name for
|
|
||||||
a person's own voice.
|
|
||||||
|
|
||||||
## Pack layout (host state — never commit it)
|
## Sequence
|
||||||
|
|
||||||
`/home/dank/voices/<name>/` is a voice **iff** it holds `reference.wav`:
|
1. Build the pack (dry-run first — sizes are arithmetic and asserted).
|
||||||
|
2. Deploy the engine only if its files changed:
|
||||||
|
`./scripts/deploy.sh dankclaw --service frank-tts-engine`
|
||||||
|
(first run builds the image: ~10 min recursive source clone).
|
||||||
|
3. Register/warm: `podman exec frank-tts-engine /app/warm-voices.sh`
|
||||||
|
— registers **every** pack each run (the engine replaces a name it already
|
||||||
|
holds; `tools/tts-server.cpp:148`), then synthesizes twice per voice.
|
||||||
|
4. Prove from the consumer side, not the config:
|
||||||
|
`curl -s localhost:7772/v1/audio/voices` then a real
|
||||||
|
`POST /v1/audio/speech` and listen to the bytes.
|
||||||
|
|
||||||
```
|
## Trap 1 — the kernel's argument cap (this broke the first deploy)
|
||||||
reference.wav 24 kHz mono pcm_s16le, 18-25 s of clean single-speaker audio
|
|
||||||
reference.spk engine x-vector: enc_dim 2048 f32 = 8192 B
|
|
||||||
reference.rvq RVQ codes: 22 B per 12.5 Hz frame (5456-6864 B)
|
|
||||||
manifest.json source path + sha256, crop start/end, engine commit, name
|
|
||||||
```
|
|
||||||
|
|
||||||
Build it; do not hand-assemble and never fabricate `.spk`/`.rvq` from a WAV:
|
`/usr/bin/curl: Argument list too long` in the warm log ⇒ a request body was
|
||||||
|
passed as **one argv string**. Linux caps a single argument at
|
||||||
|
`MAX_ARG_STRLEN = 131 072 bytes`; a ~1 MB `reference.wav` base64's to ~1.27 MB.
|
||||||
|
curl never runs, and hooks report it as `HTTP no response` while `/health` is
|
||||||
|
green. Fix = write the body to a file and use `--data-binary @file`; feed the
|
||||||
|
payload through a redirection, not `$(base64 …)`.
|
||||||
|
Regression-test shape: **real** curl + real listener + a fixture >128 KB, so the
|
||||||
|
kernel (not a stub) produces the failure. Any pack over ~96 KB trips it.
|
||||||
|
|
||||||
```bash
|
## Trap 2 — two readers of the env file disagree about quotes
|
||||||
scripts/build-voice-pack.sh --all /home/dank/voices # everything in VOICE_PACKS
|
|
||||||
scripts/build-voice-pack.sh --source SRC --offset SEC --duration SEC --sha256 HEX NAME /home/dank/voices
|
|
||||||
scripts/build-voice-pack.sh --dry-run ... # validate + crop, no extractor
|
|
||||||
```
|
|
||||||
|
|
||||||
Knobs live in `scripts/voice-packs.env` (one `_SOURCE`/`_OFFSET`/`_DURATION`/
|
`bash set -a; source` strips `"…"`. podman's `--env-file` (how the unit really
|
||||||
`_SHA256` set per voice). Three things it enforces that an operator gets wrong:
|
reads it) does **not**. A value interpolated into a JSON body therefore arrives
|
||||||
|
quoted and invalidates it. Test both readers, not just `source`.
|
||||||
|
|
||||||
1. **The 10-second gate.** A crop starting inside the first 10 s of the source is
|
## Trap 3 — locally built images skip rebuilds on a stale hash
|
||||||
refused before anything is written (project source-material law), and the
|
|
||||||
manifest is re-checked against the source's sha256 afterwards.
|
|
||||||
2. **The effective crop window is 19.84-25 s, not 18-25 s.** An 18.0 s crop is a
|
|
||||||
legal `reference.wav` but yields only 22 × 225 = 4 950 B of `.rvq`, under the
|
|
||||||
5 456 B floor the codec config implies — so the builder says so up front.
|
|
||||||
3. **It never deletes.** An existing pack is refused unless `--force`, and
|
|
||||||
`--force` moves the old directory to `$PACKS_DIR/.AGENTTRASH/<voice>-<UTC>/`.
|
|
||||||
|
|
||||||
Files must be world-readable (the container runs as uid 10000): `644` files,
|
`build_compose_image` compares a `cfhash` label; it must cover every file the
|
||||||
`755` dirs. `mkdir -p /home/dank/voices` as the deploy user if it is absent —
|
`Containerfile` `COPY`s, or an edited `app.py`/hook deploys "successfully" while
|
||||||
rootless podman auto-creates a missing bind source **root-owned**, and the
|
the container runs old code.
|
||||||
endpoint then sees no voices at all.
|
|
||||||
|
|
||||||
## Register and warm (the part people forget)
|
## Measured numbers (2026-10-18, deployed build — retire the trial's ladder)
|
||||||
|
|
||||||
The engine keeps its voice registry **in memory**, and the **first synthesis of a
|
| thing | value |
|
||||||
registered voice decodes the whole reference clip** to snapshot codec state:
|
|---|---|
|
||||||
6.2-8.0 s, first audio byte at ~5 530 ms. Later requests restore in 0.5 ms.
|
| first synthesis of a freshly registered voice | 1 883 ms |
|
||||||
Re-registering does **not** warm — only synthesizing does. So:
|
| later short requests | 1 226–1 283 ms |
|
||||||
|
| ~110-word request (repeat) | 9.4 s / 9.8 s — tracks generated audio, not state |
|
||||||
|
| whole boot warm, 3 packs | 15.2 s |
|
||||||
|
| engine resident | ~4.0 GiB |
|
||||||
|
|
||||||
```bash
|
The trial's `6.2–8.0 s cold → 0.5 ms warm` claim does **not** reproduce; warming
|
||||||
# registers every pack that has a reference.wav, then speaks the warm sentence once
|
is for registration (registry is memory-only, re-register ≠ warm) plus ~0.6 s.
|
||||||
podman exec frank-tts-engine /app/warm-voices.sh
|
|
||||||
systemctl --user --no-pager --full status frank-tts-engine.service | tail -20
|
|
||||||
```
|
|
||||||
|
|
||||||
The unit's
|
## Ops notes
|
||||||
`ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` does
|
|
||||||
the same at every boot, so a fresh boot is already warm; the explicit call above
|
|
||||||
is for **adding one voice to a running engine** and needs no restart — a restart
|
|
||||||
drops every registration and re-pays the cold cost for the whole library. The
|
|
||||||
`podman exec` form is not cosmetic: the engine **publishes no port**, so there is
|
|
||||||
no host address to curl.
|
|
||||||
|
|
||||||
## Proof (do not report "added" without it)
|
- `systemctl --user stop` **removes** the quadlet container — `podman start` then
|
||||||
|
finds nothing. To run the engine without its `ExecStartPost` hook, use a
|
||||||
```bash
|
separate container, not stop+start.
|
||||||
curl -s localhost:7772/v1/audio/voices # name is listed
|
- `podman cp` of a fixed script into a running container is a **trial only**: the
|
||||||
time curl -s -o /tmp/a.wav localhost:7772/v1/audio/speech \
|
layer dies with the container. Merge + redeploy to make it durable.
|
||||||
-H 'content-type: application/json' \
|
- Nothing on the voice pair exposes `/metrics`; Prometheus can't scrape it (a
|
||||||
-d '{"model":"tts-1","voice":"NAME","input":"Checking the cloned voice.","response_format":"wav"}'
|
blackbox HTTP probe on 7772 is the only honest option).
|
||||||
time curl -s -o /tmp/b.wav localhost:7772/v1/audio/speech \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-d '{"model":"tts-1","voice":"NAME","input":"And the second request.","response_format":"wav"}'
|
|
||||||
file /tmp/a.wav /tmp/b.wav # really audio
|
|
||||||
```
|
|
||||||
|
|
||||||
The warmer's journal lines carry `voice NAME cold=…ms warm=…ms` — quote that pair.
|
|
||||||
Note that streaming and buffered output differ at the same seed (92.2 % of samples,
|
|
||||||
-41.6 dB RMS): never compare two paths or two "identical" renders, only that each
|
|
||||||
is audio and that the second request is fast.
|
|
||||||
|
|
||||||
## Failure modes worth naming
|
|
||||||
|
|
||||||
- `400 unknown voice …; available voices: …` → the name is not a pack: wrong
|
|
||||||
spelling/case, missing `reference.wav`, or `/home/dank/voices` root-owned/empty.
|
|
||||||
Trust the 400's list over any manifest.
|
|
||||||
- 400 with `(none: no voice pack with a reference.wav in /voices)` → mount problem,
|
|
||||||
not a voice problem.
|
|
||||||
- First request slow **after** a boot → the warm pass failed; read the unit's
|
|
||||||
journal (the warmer exits 0 and logs when there is no voice directory, so
|
|
||||||
silence there is not success either).
|
|
||||||
- Endpoint answers but synthesis fails 502 → the façade must dial
|
|
||||||
`http://frank-tts-engine:8880` over the shared bridge
|
|
||||||
(`frank-tts-engine.network`); a `host.containers.internal` / `127.0.0.1:8880`
|
|
||||||
upstream can never work between containers, because netavark DNATs published
|
|
||||||
ports only.
|
|
||||||
|
|
||||||
## Never
|
|
||||||
|
|
||||||
Never write under `/voices` from the endpoint (mounted `:ro` by design), never
|
|
||||||
`git add` a voice pack, never `rm` a pack (repo policy: move to `.AGENTTRASH/`),
|
|
||||||
and never benchmark against this endpoint while it serves — stop and mask the
|
|
||||||
units first.
|
|
||||||
|
|||||||
@@ -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,105 +1,64 @@
|
|||||||
---
|
---
|
||||||
name: gitea-api-pr-operations
|
name: gitea-api-pr-operations
|
||||||
description: "Open, list, merge, and verify pull requests (and diagnose issue-API scope failures) on the Dankclaw Gitea via its REST API from a workstation, keeping the token out of argv (curl -K config file, mode 600) — includes the credential-store extraction one-liner, the merge endpoint's Do-field law and Content-Type-header trap, the suggested-URL-is-not-a-PR gotcha, and the insufficient-token-scope trap."
|
description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea API (192.168.0.143:3000, repo dank/command-center) with the token stored in ~/.config/secrets/gitea — incl. the required merge payload and where review comments actually live on 1.27.3."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Gitea API PR operations (Dankclaw)
|
# Gitea API PR operations (Dankclaw `dank/command-center`)
|
||||||
|
|
||||||
`git push` on this fleet prints `Create a new pull request for ...` — that is a
|
## Where
|
||||||
**suggestion, not a PR**. Verify (`GET /pulls?state=open` → expect `[]`) before
|
- Token: `~/.config/secrets/gitea` (`http://user:TOKEN@192.168.0.143` single line).
|
||||||
claiming a PR "should exist"; create it via the API when asked.
|
Extract WITHOUT echoing:
|
||||||
|
`tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)`
|
||||||
|
- Build a 600-perm temp curl config and use `curl -K`:
|
||||||
|
`cfg=$(mktemp) && chmod 600 "$cfg" && printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"`
|
||||||
|
Delete `$cfg` after use. NEVER pass `-H "Authorization: token $tok"` (the literal
|
||||||
|
header lands in transcript + logs). NEVER echo the token.
|
||||||
|
- Base URL: `http://192.168.0.143:3000/api/v1/repos/dank/command-center`.
|
||||||
|
|
||||||
## Endpoint + auth
|
## Recipes (all verified live 2026-10-18)
|
||||||
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/<repo>`
|
- **Open PR** (token only needed for private repos; this one reads publicly but
|
||||||
- Token lives in the git credential store configured by
|
POST needs auth): `POST /pulls` JSON `{title, head, base:"main", body}`.
|
||||||
`git config --get credential.helper` → `store --file ~/.config/secrets/gitea`,
|
- **PATCH PR title/body**: `PATCH /pulls/<n>` with JSON body `{title}` and/or
|
||||||
line format `http://user:TOKEN@192.168.0.143:3000`.
|
`{body}`. Returns 201 with the full PR. Use `jq -n --arg` to build JSON; for
|
||||||
- The repo-local `.env` `GITEA_TOKEN` (main checkout, gitignored) carries
|
multi-line bodies write the text to a temp file and `--arg b "$(cat b.txt)"`.
|
||||||
**`read:issue` too** (measured 2026-10-18): filing issues needs THAT token,
|
(jq CLI: a bare `---` argument after the program is parsed as a flag → error;
|
||||||
the credential-store one 403s on issues.
|
fold footers into the --arg string instead.)
|
||||||
|
- **List open PRs**: `GET /pulls?state=open` → `jq -r '.[] | "#\(.number) \(.head.ref) \(.title)"'`.
|
||||||
|
- **Read PR**: `GET /pulls/<n>` (no token; public repo) → `jq -c '{state,merged,mergeable,head:.head.ref}'`.
|
||||||
|
- **MERGE**: `POST /pulls/<n>/merge` with `-H 'Content-Type: application/json'`
|
||||||
|
and body **`{"Do":"merge"}`** — this build REQUIRES the `Do` field:
|
||||||
|
`{}` or `{"DoNotMerge":false,...}` → 422; `{"Do":"merge"}` → 200 +
|
||||||
|
`{"state":"closed","merged":true,"merge_commit_sha":"..."}`. Verify with a
|
||||||
|
plain GET /pulls/<n> afterwards (merge endpoint's own stdout may not parse).
|
||||||
|
Merge is the `--no-ff` merge commit on main; then `git -C ~/.omp fetch && git
|
||||||
|
merge --ff-only origin/main` propagates the live deployment.
|
||||||
|
- **Issues**: same shape at `/issues` (`POST {title, body}`), `/issues/<n>/comments`
|
||||||
|
(`POST {body}`, `PATCH .../comments/<id>`) — both work with the temp-cfg auth.
|
||||||
|
|
||||||
Extract the token (the `http://` scheme contains a colon — scheme-stripping
|
## Review comments: the API lies on 1.27.3 (learned 2026-10-18)
|
||||||
awk regexes silently grab the port instead; use this anchored sed):
|
- `GET /pulls/<n>/comments` (inline review comments) → **404 `not found` on every
|
||||||
|
route** (token or not, `?pull_request_id=` or not) even when a review with
|
||||||
|
comments exists.
|
||||||
|
- `GET /pulls/<n>/reviews` works (shows `state`, `comments_count`) but bodies are empty.
|
||||||
|
- **The comments are readable from the PR HTML**:
|
||||||
|
`curl -sS http://192.168.0.143:3000/dank/command-center/pulls/<n> -o /tmp/pr.html`
|
||||||
|
then extract `<div class="content comment-content">` blocks
|
||||||
|
(python: `re.finditer(r'comment-content', src)` → widen ±800 chars, strip tags).
|
||||||
|
Duplicates appear (suggested-change UI shows text twice); dedupe mentally.
|
||||||
|
- Per-conversation resolve API also 404s — flag resolutions in an issue comment instead.
|
||||||
|
|
||||||
```bash
|
## Body-footer law (AGENTS.md)
|
||||||
tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
|
Every issue/PR body ends with `---` line then `cwd: <wd> · session: <id>`. Keep
|
||||||
[ -n "$tok" ] && echo "token-extracted(len=${#tok})" # length check, never echo the value
|
it inside the --arg string (not a `---` CLI arg).
|
||||||
```
|
|
||||||
|
|
||||||
## Keep the token out of argv (repo secret law)
|
## Gotchas
|
||||||
`curl -H "Authorization: token $tok"` is world-readable in `/proc/<pid>/cmdline`.
|
- Content-Type: POSTs with `-d @file.json` MUST carry
|
||||||
Use a mode-600 config file:
|
`-H 'Content-Type: application/json'` on the command line. `-H` entries put in
|
||||||
|
the curl config file are ignored for this (header ordering quirk).
|
||||||
```bash
|
- `xdg-open <pr-url>` lands the PR in Frank's Firefox the moment it is opened —
|
||||||
cfg=$(mktemp); chmod 600 "$cfg"
|
law, not courtesy.
|
||||||
printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
|
- `jq: cannot index "not found" with "user"` = endpoint 404'd into an error
|
||||||
```
|
object; check `-w '%{http_code}'` before blaming jq.
|
||||||
|
- The token in `.git/config`'s remote URL is the same secret; the credential
|
||||||
- POST JSON needs a second header line: `header = "Content-Type: application/json"`.
|
file (`~/.config/secrets/gitea`) is the one git actually consults (global
|
||||||
There IS NO `content-type` config option — curl rejects the file.
|
credential.helper) and the one to reuse for API calls.
|
||||||
- `rm -f "$cfg"` when done (a mode-600 token file left in /tmp is its own leak).
|
|
||||||
|
|
||||||
## Operations
|
|
||||||
```bash
|
|
||||||
# list open PRs
|
|
||||||
curl -sS -K "$cfg" ".../pulls?state=open" | jq -r '.[] | [.number,.head.name,.base.name,.title] | @tsv'
|
|
||||||
# create (build the payload as a FILE, see below)
|
|
||||||
curl -sS -K "$cfg" -o /tmp/pr.json -w '%{http_code}\n' -X POST -d @/tmp/pr-payload.json ".../pulls"
|
|
||||||
# verify: 201 is not enough — check mergeable
|
|
||||||
curl -sS -K "$cfg" ".../pulls/1" | jq '{number,state,merged,mergeable,base:.base.ref,head:.head.ref,url:.html_url}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Merging: the field is `Do`, and it is a STRING naming the strategy
|
|
||||||
|
|
||||||
Measured 2026-10-18 on Gitea at 192.168.0.143:3000 (`POST .../pulls/<n>/merge`).
|
|
||||||
`MergePullRequestOption` is `{From, To, When, Do}` — **there is no `DoMerge` and
|
|
||||||
no `merge_method`**. Every wrong shape is a 422 with a distinct message, so read it:
|
|
||||||
|
|
||||||
|body sent|http|message|
|
|
||||||
|---|---|---|
|
|
||||||
|`{"DoMerge":true,"merge_method":"merge"}`|422|`[Do]: Required`|
|
|
||||||
|`{"Do":true,"merge_method":"merge"}`|422|`[]: json: cannot unmarshal JSON boolean into Go string within "/Do"`|
|
|
||||||
|`{"Do":"true"}`|422|`[Do]: In…` (validation: not a strategy name)|
|
|
||||||
|form body `Do=true&merge_method=merge`|422|`[Do]: Required` (the value must be a strategy name)|
|
|
||||||
|`{"Do":"merge"}`|**200**|merged|
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -K "$cfg" -H 'Content-Type: application/json' -X POST -d '{"Do":"merge"}' ".../pulls/4/merge"
|
|
||||||
# then prove it: .merged true, .state closed, .merge_commit_sha set, and origin/main moved
|
|
||||||
curl -sS ".../pulls/4" | jq -c '{number,state,merged,merge_commit_sha}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Strategy names: `merge`, `rebase`, `rebase-merge`, `squash`, `manually` — a
|
|
||||||
strategy the repo does not allow is rejected too.
|
|
||||||
|
|
||||||
**Merge trap (measured 2026-10-18):** on `POST /merge` the `Content-Type` header
|
|
||||||
must go on the **command line** (`-H 'Content-Type: application/json'`). Same
|
|
||||||
curl that succeeds on `POST /pulls` with Content-Type supplied only via the `-K`
|
|
||||||
config file returns **405 with an EMPTY body** on `/merge`. A 405 there is this
|
|
||||||
trap (or a base-moved mergeability recheck — GET the PR, see `mergeable:true`,
|
|
||||||
then retry with `-H`), NOT a permission problem.
|
|
||||||
|
|
||||||
## Building issue/PR bodies without quote carnage
|
|
||||||
Hand-written `jq -n --arg t "..." '{title:$t, body:"..."}'` one-liners with long
|
|
||||||
markdown bodies WILL break on a stray quote (and the broken quoting can swallow
|
|
||||||
the following commands). Build bodies as files (heredoc or write tool), then:
|
|
||||||
```bash
|
|
||||||
b=$(jq -Rs . body.md)
|
|
||||||
jq -n --arg t "$title" --argjson b "$b" '{title:$t, body:$b, head:"<branch>", base:"main"}' > payload.json
|
|
||||||
curl ... -d @payload.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gotchas measured 2026-10-14 / 2026-10-17 / 2026-10-18
|
|
||||||
- Unauthenticated/bad token → HTTP 401 body `{"message":"not found"}`; `jq '.[]|[.number,..]'` then fails with *cannot index "not found"* — that is an auth failure, not a missing repo.
|
|
||||||
- **Insufficient token scope ≠ missing data.** The credential-store token
|
|
||||||
(measured 2026-10-17) carries only `write:repository,write:user`; the issues
|
|
||||||
API answers HTTP 403 `{"message":"token does not have at least one of required
|
|
||||||
scope(s), required=[read:issue], token scope=write:repository,write:user"}`.
|
|
||||||
jq surfaces that JSON as the *cannot index* error — do NOT conclude the issue
|
|
||||||
list is empty, and note POSTs under this token create NOTHING (verify, don't
|
|
||||||
assume retry safety). Filing issues needs a fresh token minted in the Gitea
|
|
||||||
web UI with `read:issue`+`write:issue` (Frank-class credential action; ask) —
|
|
||||||
OR use the main-checkout `.env` `GITEA_TOKEN`, which already has issue scope.
|
|
||||||
PR endpoints work fine with either token (`pulls` POST/GET/merge all 200/201).
|
|
||||||
- On the created-PR response `.head.name` can be null; verify with the `GET /pulls/<n>` and use `.head.ref`.
|
|
||||||
- **Swagger JSON is disabled on this instance** — `/api/swagger.v1.json`, `/api/swagger.json` and `/api/v1/swagger.json` all 404. Do not go looking for the schema there; the 422 messages name the field and its Go type, which is the faster instrument.
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: gitea-pr-api-cycle
|
||||||
|
description: "Open/update/merge PRs and issues on the Dankclaw Gitea API (192.168.0.143:3000, dank/command-center) with the token-safe curl+jq sequence, including the {\"Do\":\"merge\"} merge payload and post-merge live-checkout fast-forward."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gitea PR/issue cycle — dank/command-center
|
||||||
|
|
||||||
|
Friction-earned 2026-10-18 across PRs #15–#20 and issue #18: three separate fumbles (jq trailer flag, 422 merge payload, silent -o/-w swallowing) codified once.
|
||||||
|
|
||||||
|
## Where
|
||||||
|
- API base: `http://192.168.0.143:3000/api/v1/repos/dank/command-center/{pulls,issues}`
|
||||||
|
- Token: never inline, never echoed. Extract from the git credential file into a 600-perm temp curl config:
|
||||||
|
`tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)`
|
||||||
|
`cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"` → `curl -K "$cfg"`; `rm -f "$cfg"` when done.
|
||||||
|
- Unauthenticated GET works for state reads (`GET /pulls/N` → `jq -c '{state,merged,merge_commit_sha}'`).
|
||||||
|
|
||||||
|
## Body construction (the two traps)
|
||||||
|
- Build JSON with `jq -n --arg t <title> --arg b "$(cat b.txt)" '{title:$t, head:<branch>, base:"main", body:$b}'`; write the body to a temp file first (`printf '%s' … > b.txt`). NEVER pass `---` as a bare jq argument — jq parses it as a flag (`unknown flag: ---`). The AGENTS.md attribution trailer (`---` + `cwd: … · session: …`) goes INSIDE the body string.
|
||||||
|
- Always capture status explicitly: `-o r.json -w '%{http_code}'` and print it; a bare `-o` hides failures (a 422 looked like success-with-no-output once).
|
||||||
|
|
||||||
|
## Merge (the 422 trap)
|
||||||
|
`POST /pulls/N/merge` requires body `{"Do":"merge"}` — `Do` is the Required field on this Gitea. `{}`, `{"DoNotMerge":false}`, `{"merge_style":…}` all return 422 (`"[Do]: Required"`). Success may return an EMPTY body — verify by GET afterwards (`state=closed, merged=true`), not by the POST response.
|
||||||
|
- Merge only with Frank's explicit per-PR OK (repo law).
|
||||||
|
- Post-merge the live deployment must follow: `git -C ~/.omp fetch -q origin && git -C ~/.omp merge --ff-only origin/main` (the checkout IS the deployment; branch is `main`).
|
||||||
|
|
||||||
|
## PR etiquette per AGENTS.md
|
||||||
|
- Immediately after opening: `xdg-open <PR-URL>` lands it in Frank's Firefox.
|
||||||
|
- Redesign mid-review: amend + `git push -f` the branch, then PATCH the PR body (`/pulls/N`, `{"body":…}`) so title/body match the net diff — verify `{number,state,mergeable}` in the response.
|
||||||
|
- Issue creation identical shape: `POST /issues` with `{title, body}`, expect 201; PATCH same endpoint `/issues/N` to fix bodies retroactively, then GET-readback verify.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: gitea-review-comment-html-scrape
|
||||||
|
description: "Read inline review comments on the Dankclaw Gitea when pulls/N/comments 404s (Gitea 1.27.3): scrape comment-content blocks and diff anchors from the PR HTML page"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gitea review-comment extraction when the API 404s
|
||||||
|
|
||||||
|
On the Dankclaw Gitea (192.168.0.143:3000, Gitea 1.27.3) the review-comment API is dead for inline comments:
|
||||||
|
`/api/v1/repos/{owner}/{repo}/pulls/{N}/comments` returns 404 `not found` **even with a valid token**, and `pulls/comments?pull_id=` / `?pull_request_id=` variants 404 too. `/pulls/{N}/reviews` works but its bodies are empty for inline reviews (read `comments_count` for how many exist); `/issues/{N}/comments` shows only conversation comments, never inline ones.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Confirm the review exists and how many inline comments it holds:
|
||||||
|
`curl -sS http://192.168.0.143:3000/api/v1/repos/dank/command-center/pulls/{N}/reviews | jq -c '.[] | {id, state, comments_count}'`
|
||||||
|
2. Scrape the PR page instead (no token needed for a public repo):
|
||||||
|
`curl -sS http://192.168.0.143:3000/dank/command-center/pulls/{N} -o /tmp/pr.html`
|
||||||
|
3. Extract each comment's text — every inline comment renders a `comment-content` block; dedupe (each comment appears twice in the HTML):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re, html
|
||||||
|
src = open('/tmp/pr.html').read()
|
||||||
|
seen = set()
|
||||||
|
for m in re.finditer(r'comment-content', src):
|
||||||
|
t = html.unescape(re.sub(r'<[^>]+>', ' ', src[m.start()-100:m.start()+800]))
|
||||||
|
t = re.sub(r'\s+', ' ', t).strip()[:400]
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t); print('>>', t)
|
||||||
|
```
|
||||||
|
|
||||||
|
The comment text appears twice back-to-back inside each block ("text text") — read it once. Blocks start after `Copy Link|Copy Source`; ignore those chrome words.
|
||||||
|
|
||||||
|
4. **Locate the file/line anchor** (comment-content blocks carry no `data-path`/`data-line` attrs on this build): search the raw HTML for the comment string, walk *backward* ~4–7 KB, strip tags; the nearby `agent/...` path, `@@ -a,b +c,d @@` hunk header, and surrounding diff context lines identify the anchored file and the quoted source line.
|
||||||
|
|
||||||
|
## Replying / housekeeping
|
||||||
|
|
||||||
|
- Per-conversation resolve API does not exist on this build (404s) — reply to the round as a normal issue comment (`POST /repos/{o}/{r}/issues/{N}/comments`, token via `curl -K` temp cfg, `header = "Authorization: token …"`, 600 perms, `jq -n --arg b "$(cat b.txt)" '{body:$b}'`) listing which note got which fix commit.
|
||||||
|
- Edit a posted comment: `PATCH /repos/{o}/{r}/issues/{N}/comments/{id}` with `{"body": ...}` — use to fix your own typos rather than double-posting.
|
||||||
|
- Token source on this box: `~/.config/secrets/gitea` (grep the remote-URL pattern); never echo it.
|
||||||
|
|
||||||
|
## Why this works
|
||||||
|
|
||||||
|
The PR page is server-rendered with full inline comment bodies; Gitea's "Copy Source" UI element guarantees the raw text is present pre-markdown-render.
|
||||||
@@ -22,7 +22,7 @@ description: "Locate, edit, validate, and commit niri compositor config on Frank
|
|||||||
- Preset list order = cycle order for `Mod+R` (`switch-preset-column-width`); `Mod+Shift+R` cycles backwards. WITH custom preset lists the cycle contains ONLY the configured presets — no implicit full/maximize tail (verified: [0.25, 0.5] cycles period 2). Maximize = separate `Mod+F` `maximize-column`. Built-in defaults without the node: 1/3, 1/2, 2/3.
|
- Preset list order = cycle order for `Mod+R` (`switch-preset-column-width`); `Mod+Shift+R` cycles backwards. WITH custom preset lists the cycle contains ONLY the configured presets — no implicit full/maximize tail (verified: [0.25, 0.5] cycles period 2). Maximize = separate `Mod+F` `maximize-column`. Built-in defaults without the node: 1/3, 1/2, 2/3.
|
||||||
- WITHOUT `preset-window-heights`, `switch-preset-window-height` FALLS BACK to the width presets — user sees width-fractions as heights; define the node explicitly to stop it.
|
- WITHOUT `preset-window-heights`, `switch-preset-window-height` FALLS BACK to the width presets — user sees width-fractions as heights; define the node explicitly to stop it.
|
||||||
- Ground truth for node placement: the binary's embedded template — `grep -a -B4 -A8 "preset-column-widths {" $(which niri)`.
|
- Ground truth for node placement: the binary's embedded template — `grep -a -B4 -A8 "preset-column-widths {" $(which niri)`.
|
||||||
- Window outlines: base geometry (`width`, `off`) lives in `cfg/layout.kdl` `focus-ring`/`border`; COLORS effectively live in `noctalia.kdl` (see include-order law). `border { off }` + focus-ring active = the visible outline.
|
- Window outlines: base geometry (`width`, `off`) lives in `cfg/layout.kdl` `focus-ring`/`border`; COLORS effectively live in `noctalia.kdl` (see include-order law). `border { off }` + focus-ring active = the visible outline. Frank's look since 2026-10-18: amber-orange `#ff8c00`, width 4 (`active`+`urgent` in noctalia.kdl; tab-indicator/insert-hint/recent-windows stay red `#f43841` by choice).
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
1. Edit the live file under `~/.config/niri/cfg/` (symlinked = repo file; except `noctalia.kdl`, edit live then `cp` to mirror).
|
1. Edit the live file under `~/.config/niri/cfg/` (symlinked = repo file; except `noctalia.kdl`, edit live then `cp` to mirror).
|
||||||
|
|||||||
@@ -1,50 +1,52 @@
|
|||||||
---
|
---
|
||||||
name: noctalia-bar-layout-edit
|
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.
|
Bar widget layout on Frank's noctalia v5 workstations is GUI-override state, not config.toml.
|
||||||
|
|
||||||
## Where
|
## 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).
|
- 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).
|
- 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.
|
- 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"]`.
|
- Defaults when a key is absent: `start = ["launcher","wallpaper","workspaces"]`, `center = ["clock"]`, `end = ["notifications","clipboard","network","bluetooth","tray","battery","session","control-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.
|
|
||||||
|
|
||||||
## GUI→repo propagation (checkout-is-deployment)
|
## Clobber window and recovery (2026-10-18 incident law)
|
||||||
- `~/.config/noctalia/config.toml` is symlinked into the repo → GUI settings writes land in the repo working tree automatically.
|
- 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.
|
||||||
- `~/.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`.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- After recovery, commit the file immediately so the window closes.
|
||||||
|
|
||||||
## If the bar got wiped (recovery)
|
## Widgets (v5 census, verified 2026-10-17)
|
||||||
- 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`.
|
- 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.
|
||||||
- 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.
|
- `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.
|
||||||
- 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.
|
- `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)
|
## Bar styles (GUI-visible, settings.toml keys)
|
||||||
1. Edit the repo `settings.toml`.
|
- `[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`).
|
||||||
2. `noctalia config validate` (checks merged layers; also `noctalia config export [full] | yq -p toml` to inspect effective values).
|
- 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).
|
||||||
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.
|
- The GUI "Bar" page is the reference list; anything absent from settings.toml is a built-in default.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Clock widgets
|
## Slowouts (window-height-aware bars/panels)
|
||||||
- `[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.
|
- `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`.
|
||||||
- **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.
|
- `position = "top"` + `exclusive = false` + slowouts on the monitor -> panel hides when a window touches its edge, re-reveals otherwise. `slowouts-visible` = toggle capsule.
|
||||||
- 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).
|
- **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.
|
||||||
- Local date + local 12h fit in ONE widget (same tz); the UTC 24h clock must be a second widget.
|
- 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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Taskbar recipes
|
## Layout modes and window title decorations
|
||||||
- Move window indicator beside workspaces: put `"taskbar"` in `start` after `"workspaces"`.
|
- `[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`.
|
||||||
- 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).
|
- `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.
|
- The `workspaces` widget itself has no window display; styles are regular/minimal/focus_hint only.
|
||||||
|
|
||||||
## Law
|
## 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).
|
- 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.)
|
- **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.
|
- 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.
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: omp-models-yml-runner-api-placement
|
name: omp-models-yml-runner-api-placement
|
||||||
description: Add or edit a non-chat (tts/image/embedding) model row in ~/.omp/agent/models.yml without disabling every custom provider
|
description: "Add or edit a non-chat (tts/image/embedding) model row in ~/.omp/agent/models.yml, wire the speech role, and diagnose a failing speech device without blaming a healthy endpoint"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Scope
|
# Scope
|
||||||
|
|
||||||
`~/.omp/agent/models.yml` (or `models.yaml`) on a workstation: adding a **runner**
|
`~/.omp/agent/models.yml` (or `models.yaml`) on a workstation: adding a **runner**
|
||||||
model row (TTS / image / embedding / rerank / video) alongside chat providers.
|
model row (TTS / image / embedding / rerank / video) alongside chat providers,
|
||||||
|
flipping `config.yml` `modelRoles` at it, and diagnosing a speech device that
|
||||||
|
refuses to speak.
|
||||||
|
|
||||||
# The landmine
|
# The landmine
|
||||||
|
|
||||||
@@ -63,9 +65,52 @@ yielding.
|
|||||||
# Speech transport contract (`openai-speech`)
|
# Speech transport contract (`openai-speech`)
|
||||||
|
|
||||||
Body is `{model, input, response_format, voice?, speed?}`; `voice` is forwarded
|
Body is `{model, input, response_format, voice?, speed?}`; `voice` is forwarded
|
||||||
only when explicitly set; `sample_rate`/`bit_rate` **throw**
|
**only** when the caller passed `voice_id`; `sample_rate`/`bit_rate` **throw**
|
||||||
(`openai-speech does not support sampleRate`). Codec comes from the
|
(`openai-speech does not support sampleRate`). Codec comes from the
|
||||||
`output_path` suffix; 15 000-character cap. An unknown `voice` name is a server
|
`output_path` suffix; 15 000-character cap. An unknown `voice` name is a server
|
||||||
error, not a fallback — and `auth: none` makes a dead endpoint read as
|
error, not a fallback — and `auth: none` makes a dead endpoint read as
|
||||||
*available*, so point `modelRoles.speech` at such a provider only after it has
|
*available*, so point `modelRoles.speech` at such a provider only after it has
|
||||||
answered a real synthesis.
|
answered a real synthesis.
|
||||||
|
|
||||||
|
# Three speech-role laws (bundle-read + measured 2026-10-18)
|
||||||
|
|
||||||
|
1. **No setting carries a voice for `openai-speech`.** The transport builds the
|
||||||
|
body with `voice` from the call's `voice_id` and nothing else.
|
||||||
|
`tts.localVoice` is the *local Kokoro backend's* key; `speech.voice` is the
|
||||||
|
TUI's **speak-aloud** voice, an enum of Kokoro names that never reaches an
|
||||||
|
HTTP request (setting it to a pack name breaks the vocalizer and does nothing
|
||||||
|
for synthesis — read `cfgSpeechVoice` in `dist/cli.js` before believing
|
||||||
|
otherwise). So a voice-less call is the harness's **normal** shape: a
|
||||||
|
voice-required endpoint needs a server-side default (fleet answer:
|
||||||
|
`DEFAULT_VOICE` in `scripts/frank-tts.env`, journal marks the line
|
||||||
|
`(default)`, unknown names still 400 with the list).
|
||||||
|
2. **An explicit role has no fallback candidate.** `modelRoles.speech` set to one
|
||||||
|
model yields exactly one candidate, so a dead endpoint or an unresolvable
|
||||||
|
selector fails with *"No available speech model matches the speech role"*
|
||||||
|
instead of degrading to Kokoro. Loud-by-design; do not "fix" it by adding a
|
||||||
|
fallback in the client.
|
||||||
|
3. **`models.yml` loads at process start; `config.yml` is read live.** A session
|
||||||
|
older than the new row fails exactly like a dead endpoint while a fresh
|
||||||
|
process succeeds in the same minute.
|
||||||
|
|
||||||
|
# Diagnosing "speech device won't speak" — in this order
|
||||||
|
|
||||||
|
1. `curl` the endpoint directly with a real voice name. 200 + audio ⇒ the service
|
||||||
|
is fine and the fault is harness-side; stop touching the server.
|
||||||
|
2. Control test in the *same* session: point `modelRoles.speech` at
|
||||||
|
`local/kokoro` and call the device. It works ⇒ `config.yml` is live-read and
|
||||||
|
the runner selector or its voice argument is the problem, not the endpoint.
|
||||||
|
Revert the role immediately after.
|
||||||
|
3. Distinguish stale registry from dead endpoint with a fresh process:
|
||||||
|
`omp -p --no-session --no-extensions --no-skills --no-rules --tools tts --cwd /tmp
|
||||||
|
'Call the tts tool exactly once with text="…" output_path=/tmp/x.wav
|
||||||
|
voice_id=<name> — then reply with the tool result line.'`
|
||||||
|
Succeeds ⇒ the long-lived session predates the `models.yml` row; restart it.
|
||||||
|
4. Cross-check server-side, never trust the client's echo: the façade/access log
|
||||||
|
line and the engine's own ledger must carry the same byte count the tool
|
||||||
|
printed. (`model=…` in a tool result is what the client *requested*; a server
|
||||||
|
line is the fact.)
|
||||||
|
5. For harness-behaviour claims, read the bundle rather than reasoning from the
|
||||||
|
tool description: `/home/blank/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js`,
|
||||||
|
e.g. `grep -o -E '.{300}response_format:[a-zA-Z$]+.{250}'` for the speech body
|
||||||
|
builder, `grep -o -E 'cfgSpeechVoice:.{0,400}'` for what a settings key feeds.
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
name: one-decision-per-ask-question
|
|
||||||
description: "Use when putting decisions to Frank via the ask tool or a grilling round — exactly one work item per question, rounds may group many single-item questions."
|
|
||||||
---
|
|
||||||
|
|
||||||
# One Decision, One Work Item, One Question
|
|
||||||
|
|
||||||
Friction-earned rule (corrected twice on 2026-10-18, during the voice/TTS planning).
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Each question addresses **exactly one work item**. A round may contain many questions covering different work items; a question may not.
|
|
||||||
- Never merge two artefacts into one option because they share an implementation dependency. Sharing an endpoint, a service, or a build step is NOT grounds for one question. (`frank-tts` serving both a podcast and an audiobook generator produced exactly this bug.)
|
|
||||||
- Never offer something Frank did not ask for inside another question's option text. If an idea is mine, it gets its own question or stays out of the plan; it never rides along as "…and also X" in an option label or preview.
|
|
||||||
- Do not derive option wording from my own scaffolding (todo text, my table row labels, my runbook headings). Derive it from his stated goal.
|
|
||||||
- Mark the recommended option; a bare "yes" must accept it.
|
|
||||||
- Facts are mine to fetch (subagent/scout), never a question for him. Only decisions go to him.
|
|
||||||
- A question whose answer depends on an unsettled fact or another open question belongs to a **later round**, not this one.
|
|
||||||
|
|
||||||
## Anti-patterns that triggered this
|
|
||||||
|
|
||||||
- One option: "Audiobook/podcast factory — open-notebook + EPUB→narration builder." Two products, one shared dependency, zero requests for the second one. He answered "I never asked for audiobook generation."
|
|
||||||
- One question: "Is the deliverable the podcast, or the platform (harness voice + OpenWebUI + alerts)?" Four work items in one question. Rejected: "You're lumping multiple asks into one question. Ask me per item."
|
|
||||||
|
|
||||||
## Check before sending
|
|
||||||
|
|
||||||
For each question: can I name the single artefact it decides? If two names come to mind, split it.
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: skills-cli-retroactive-store-wiring
|
||||||
|
description: "Retroactively register hand-copied skills with the vercel-labs skills CLI (global store + lockfile), wire omp via the agents provider (no repo symlinks), and extract authored overrides without twins"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skills CLI retroactive store wiring (omp tiers)
|
||||||
|
|
||||||
|
Retrofit provenance for hand-copied skills and wire them for omp on Dank workstations. Executed 2026-10-18 on blankpad; doctrine doc: `omp-docs/tools/vercel-labs-skills/skill-store-wiring.md` (repo).
|
||||||
|
|
||||||
|
## Tier law (final design — do NOT reintroduce per-skill symlinks in the repo)
|
||||||
|
| Tier | Path | Owner |
|
||||||
|
|---|---|---|
|
||||||
|
| Store | `~/.agents/skills/` + lockfile `~/.agents/.skill-lock.json` (v3) | `skills` CLI |
|
||||||
|
| Authored | `<repo>/agent/skills/` real dirs | Frank, branch → PR |
|
||||||
|
| Managed | `~/.omp/agent/managed-skills/` | `manage_skill` only |
|
||||||
|
|
||||||
|
omp has NO agent id in the skills CLI (79-agent registry in `dist/cli.mjs`; `-a pi` writes `~/.agents/skills` too — pi detector keys on `~/.pi/agent`). omp reads the store DIRECTLY via its `agents` provider (`skills.enableAgentsUser`, default true; check with `read cfg://skills`). Precedence: authored (native, 100) > skillshare > ... > agents provider > managed (5). Identical copies collapse by realpath; differing same-named copies survive namespaced `agents/<name>` with a startup warning — so when authoring an override, REMOVE the store twin (`npx -y skills remove <name> -g`; prunes lockfile too) or `skills update -g` resurrects a twin.
|
||||||
|
|
||||||
|
## Procedure (retroactive registration)
|
||||||
|
1. List the repo/live skill dirs to register; identify true sources (per-skill — e.g. `find-skills` is `vercel-labs/skills`, not `mattpocock/skills`).
|
||||||
|
2. Drift check FIRST, per skill, before anything moves: `diff -rq <live>/<name> ~/.agents/skills/<name>` after canonical install; abort-and-report on drift (byte-identical = clean cutover).
|
||||||
|
3. Canonical install (scratch dir with a package.json; global scope): `npx -y skills add <owner>/<repo> --all -g` (+ per extra source `npx -y skills add vercel-labs/skills --skill find-skills --agent universal -g`). Artifacts: store dirs + `~/.agents/.skill-lock.json`; verify `jq '.skills|length'`.
|
||||||
|
4. Authored extraction (only for skills the user will customize): copy dir into repo `agent/skills/<name>/`, add an "Authored override" note after frontmatter, then `npx -y skills remove <name> -g` and confirm `jq '.skills|has("<name>")' == false`.
|
||||||
|
5. Clear repo copies of store-managed skills (repo records authored overrides ONLY). Live cutover after merge: move old dirs to /tmp snapshot, `git merge --ff-only origin/main` in the live checkout.
|
||||||
|
6. `skills ls` from a repo containing `agent/skills` mislabels skills as Eve project skills (path collision) — attribution noise, ignore.
|
||||||
|
7. Fresh-box recipe (three commands, no wiring): add `--all -g`, add find-skills, `remove grilling -g`.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- Eve/Codex `agents/openai.yaml` files ride along from upstream; omp ignores them — prune from authored dirs (they get swept off disk elsewhere, leaving phantom ` D` in git status).
|
||||||
|
- `skills remove` prunes only global scope if `-g` given; check lockfile after.
|
||||||
|
- Mid-run doc readers (subagents) may see pre-cutover trees — send them a correction message, don't let stale advice ship in issue bodies.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792368375152,
|
"lastConsolidatedAt": 1792381479415,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1791599865775,
|
"at": 1791599865775,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792359704968
|
"lastConsolidatedAt": 1792386375195
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792359739617
|
"lastConsolidatedAt": 1792392829703
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-109
@@ -1,118 +1,53 @@
|
|||||||
## Runtime Stack
|
## Documentation 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.
|
**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.
|
||||||
|
|
||||||
## Model Inventory
|
## Model swaps (2026-10-18)
|
||||||
|
|
||||||
### frank-zealot (Dankclaw: 192.168.0.143:7771)
|
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*.
|
||||||
|
|
||||||
**Ternary-Bonsai-2-27B-PQ2_0.gguf** (prism-ml/Ternary-Bonsai-2-27B-gguf)
|
## Where facts live
|
||||||
|
|
||||||
- Runtime: llama.cpp
|
|Need|Owner|
|
||||||
- Format: PQ2_0 quantized GGUF
|
|---|---|
|
||||||
- 27B parameter class
|
|Topology, ports, payload, shape|repo `AGENTS.md` → Fleet topology; `scripts/<svc>.env` is the authority|
|
||||||
- Vision: native BF16 mmproj projector for image input
|
|Endpoint lore, eras, laws, rollback|vault `serverAppConfigs-docs/LLM/<endpoint>.md`|
|
||||||
- MTP: OFF (no `nextn` tensors in checkpoint)
|
|Harness model entries (`contextWindow`, `maxTokens`, `reasoningContentField`, `modelRoles`)|`~/.omp/agent/models.yml`|
|
||||||
- Context: 256k per-slot window (`--ctx-size 401408 --parallel 2` = 200,704 tokens effective)
|
|Incidents and operational laws|vault `serverAppConfigs-docs/log.md`|
|
||||||
- Batch: 4096 / ubatch 2048 (CUDA optimum)
|
|Service/infra pages|vault `Dankclaw/`, `Dankserver/`, `Infrastructure/`, `Architecture/`|
|
||||||
- Log verbosity: 4
|
|
||||||
- Ports: 7771 on Dankclaw
|
|
||||||
|
|
||||||
### frank-dragoon (Dankclaw: 192.168.0.143:7770)
|
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".
|
||||||
|
|
||||||
**Quat3rnion/halogen-swift1.5-qwen3.8-flash-next-v2-abliterated** (`.hgn` format)
|
There is **no audio input anywhere in the fleet** (gone with the Gemma4
|
||||||
|
retirement, 2026-10-01). All three chat endpoints take text + images.
|
||||||
|
|
||||||
- Runtime: halogen-flash-server 0.16.2 (digest-pinned `ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84…`)
|
## Retired laws
|
||||||
- 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
|
|
||||||
|
|
||||||
### frank-archon (Dankserver: 192.168.0.235:7770)
|
- The old "Benchmarking Policy (MANDATORY)" wording demanded a bench harness per
|
||||||
|
payload; superseded 2026-10-18 by the swap law above.
|
||||||
**StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe** (`.rad` format)
|
- A "Subagent Lifecycle (MANDATORY)" rule (heartbeat ≤5 s, checkpoints, hard
|
||||||
|
timeout) was recorded here 2026-09 and **never implemented anywhere**; it
|
||||||
- Runtime: radiance 1.0.13 (digest-pinned `stilldeadcode/radiance@sha256:4c3f484564…`)
|
described nothing that exists and is deleted 2026-10-18. Do not re-add it
|
||||||
- Format: fp8-KV `.rad` (121,969,901,568 B = 116,322 MiB)
|
without a real implementation.
|
||||||
- MTP head: MTP depth 2, MNBT 2048, Froggeric v22.5 template
|
- The fleet model inventory that used to live in this memory file drifted out of
|
||||||
- Vision: 27-layer Qwen-VL tower native
|
date within hours (it still claimed Bonsai-2-**27B** at `--ctx-size 401408`
|
||||||
- KV: 3 × 200,000 fp8-KV (`--max-num-seqs 3`)
|
and a dragoon 524,288-position pool). Memory files do not get deploys — the
|
||||||
- 30,720 MiB of 30,969 MiB claimable
|
repo does. That is why it is pointers now.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
## Voice Generation
|
## 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.
|
- 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
|
||||||
|
|
||||||
- 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.
|
||||||
|
|
||||||
|
## 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.`,
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792370846394,
|
"lastConsolidatedAt": 1792392891753,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792370515419,
|
"at": 1792379504653,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
"deltas": 1,
|
"deltas": 1,
|
||||||
"model": "frank-zealot"
|
"model": "frank-zealot"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
## top bar
|
## top bar
|
||||||
- Preserve top bar customizations; customizations lost or obliterated on subsequent runs
|
- 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
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"lastConsolidatedAt": 1792370776395,
|
"lastConsolidatedAt": 1792381281460,
|
||||||
"lastResult": {
|
"lastResult": {
|
||||||
"at": 1792370420509,
|
"at": 1792381281460,
|
||||||
"sessions": 1,
|
"sessions": 1,
|
||||||
"deltas": 1,
|
"deltas": 1,
|
||||||
"model": "frank-zealot"
|
"model": "frank-zealot"
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
## window outlines
|
## 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,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792392882348
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792381487662
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792392961865,
|
||||||
|
"lastResult": {
|
||||||
|
"at": 1792381870670,
|
||||||
|
"sessions": 1,
|
||||||
|
"deltas": 1,
|
||||||
|
"model": "frank-zealot"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"lastConsolidatedAt": 1792388836682
|
||||||
|
}
|
||||||
+39
-85
@@ -24,27 +24,13 @@ providers:
|
|||||||
# compactionModel candidate keeps the wire model id valid there.
|
# compactionModel candidate keeps the wire model id valid there.
|
||||||
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
||||||
name: "frank-archon"
|
name: "frank-archon"
|
||||||
# Dankserver: BOTH Radeon AI PRO R9700s (gfx1201, TP=2 over the
|
# Dankserver: BOTH Radeon AI PRO R9700s (gfx1201, TP=2, wht6 wire) on
|
||||||
# 2026-09-20 board swap's root ports, wht6 wire). Engine:
|
# the digest-pinned radiance image, plain `podman run` under a root
|
||||||
# radiance (StillDeadcode, Apache-2.0) from the digest-pinned
|
# unit. Payload, shape, eras, laws, rollback:
|
||||||
# stilldeadcode/radiance image (index digest sha256:4c3f4845..., 287
|
# serverAppConfigs-docs/LLM/frank-archon.md — and
|
||||||
# MiB, kernels prebuilt for gfx1201), plain `podman run` through
|
# scripts/frank-archon.env is the authority for every number there.
|
||||||
# scripts/serve-frank-archon.sh, root unit (User=dank, dank's
|
# contextWindow MUST quote the engine's --max-model-len: radiance
|
||||||
# rootless podman storage). Serving the OFFICIAL
|
# refuses a longer prompt and never truncates.
|
||||||
# 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).
|
|
||||||
contextWindow: 200000
|
contextWindow: 200000
|
||||||
# maxTokens must survive the harness compaction gap AND the chars/4
|
# maxTokens must survive the harness compaction gap AND the chars/4
|
||||||
# token-estimate under-run on code-heavy prompts. The clamp is
|
# 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
|
# compaction model candidate so the wire model id stays valid
|
||||||
# there even from a frank-dragoon session.
|
# there even from a frank-dragoon session.
|
||||||
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
||||||
# halogen-flash-server 0.16.2 (ghcr.io/peonist-ai/
|
# halogen flash-server on the Strix Halo iGPU. Payload, shape, eras,
|
||||||
# halogen-flash-server@sha256:0c61bf84, digest-pinned) serving the
|
# laws, rollback: serverAppConfigs-docs/LLM/frank-dragoon.md — and
|
||||||
# Quat3rnion NATIVE v2 ABLITERATED conversion of orcarouter's pinned
|
# scripts/frank-dragoon.env is the authority for every number there.
|
||||||
# BF16 Qwen3.8-Flash-Next (qwen38-flash-next-v2-abliterated.hgn,
|
# contextWindow sits deliberately BELOW the engine ceiling
|
||||||
# ~62 GiB resident, + REQUIRED n-gram table + vision tower, both
|
# (HALOGEN_CTX): the clamp below leaves the gap that absorbs the
|
||||||
# hardlinked from the swift15 dir) on the Strix Halo iGPU: TWO slots
|
# harness's chars/4 under-count, so a window equal to the ceiling
|
||||||
# over ONE 401,408-position KV pool (= 2 x ctx, raised 2026-10-18) at
|
# would make every long request a guaranteed 400.
|
||||||
# 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.
|
|
||||||
contextWindow: 155648
|
contextWindow: 155648
|
||||||
# The clamp is max_tokens = min(maxTokens, contextWindow − est −
|
# The clamp is max_tokens = min(maxTokens, contextWindow − est −
|
||||||
# 4096); the chars/4 est UNDER-counts the rendered prompt by ~3 %
|
# 4096); the chars/4 est UNDER-counts the rendered prompt by ~3 %
|
||||||
# on code/agent traffic (measured 2026-10-07: est 152,938 ->
|
# on code/agent traffic (measured 2026-10-07: est 152,938 ->
|
||||||
# server 157,388). Worst-case request = window − 4096 + 3 % est
|
# server 157,388). Worst-case request = window − 4096 + 3 % est
|
||||||
# error ≈ 156.2k, inside the 200,704 engine ceiling with ~44k
|
# error ≈ 156.2k, inside the engine ceiling with ~44k of margin
|
||||||
# margin (robust to a ~25 % under-count). Compaction fires at 85 %
|
# (robust to a ~25 % under-count). Compaction fires at 85 % of the
|
||||||
# of the window = 132,300 est ≈ 136k measured — the 180k target of
|
# window and rides on frank-zealot: input ≈ threshold −
|
||||||
# the 262,144 era is unreachable now (the window itself is 155,648)
|
# keepRecent(20k), comfortably inside its per-slot window.
|
||||||
# — and rides on frank-zealot: input ≈ threshold − keepRecent(20k)
|
|
||||||
# ≈ 112k, inside its 200,704 slot.
|
|
||||||
# HALOGEN_MAX_TOKENS_CAP in frank-dragoon.env must stay >= this.
|
# HALOGEN_MAX_TOKENS_CAP in frank-dragoon.env must stay >= this.
|
||||||
maxTokens: 45056
|
maxTokens: 45056
|
||||||
reasoning: true
|
reasoning: true
|
||||||
@@ -183,33 +151,14 @@ providers:
|
|||||||
# config.yml (192.168.0.143:7771 — this endpoint); the
|
# config.yml (192.168.0.143:7771 — this endpoint); the
|
||||||
# compactionModel candidate keeps the wire model id valid there.
|
# compactionModel candidate keeps the wire model id valid there.
|
||||||
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
compactionModel: llamacpp (dankclaw-3090)/frank-zealot
|
||||||
# Dankclaw's RTX 3090: llama.cpp PrismML fork build (vendor 10777,
|
# Dankclaw's RTX 3090 llama.cpp endpoint (PrismML fork build — the only
|
||||||
# commit e3de71522 — the ONLY loader for this payload's custom tensor
|
# loader for this payload's custom tensor type). Shape, payload, eras,
|
||||||
# type 142 + prism.hadamard.* metadata, and the minimum build that
|
# laws, rollback: serverAppConfigs-docs/LLM/frank-zealot.md, and
|
||||||
# pairs MTP with a quantized V cache) serving
|
# scripts/frank-zealot.env is the authority for every number there.
|
||||||
# prism-ml/Ternary-Bonsai-2-9B-gguf at PQ2_0 (2,675,410,720 B) WITH the
|
# contextWindow is the PER-SLOT window: llama.cpp divides --ctx-size
|
||||||
# MTP head in the file — qwen35 hybrid, 32 layers (8 full-attention +
|
# across slots and answers a longer prompt with a 400, never a
|
||||||
# 24 Gated DeltaNet), 4 KV heads x 256 dim, native 262,144 — in THREE
|
# truncation, so this value must follow every reslice (live now:
|
||||||
# conversations (--ctx-size 786432 / --parallel 3), each slot at the
|
# 2 x 262,144 over exact f16 KV, 68afc6d).
|
||||||
# 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.
|
|
||||||
contextWindow: 262144
|
contextWindow: 262144
|
||||||
# Prompt + budget must fit inside the slot window; thinking counts
|
# Prompt + budget must fit inside the slot window; thinking counts
|
||||||
# against the budget. Compaction fires at 85 % of the window =
|
# against the budget. Compaction fires at 85 % of the window =
|
||||||
@@ -269,14 +218,19 @@ providers:
|
|||||||
# when explicitly set, and sampleRate/bitRate are REJECTED outright (the
|
# when explicitly set, and sampleRate/bitRate are REJECTED outright (the
|
||||||
# transport throws "openai-speech does not support sampleRate"), so the
|
# transport throws "openai-speech does not support sampleRate"), so the
|
||||||
# codec comes from the output_path suffix and the only tunable that matters
|
# codec comes from the output_path suffix and the only tunable that matters
|
||||||
# is the voice name — which must be a real pack name (narrator1/2/3); an
|
# is the voice name — and it comes from ONE place: the tool's `voice_id`
|
||||||
# unknown name is an engine error, not a fallback.
|
# argument. No setting feeds it (`speech.voice` is the speak-aloud Kokoro enum,
|
||||||
# DELIBERATELY NOT WIRED YET: config.yml `modelRoles.speech` still points at
|
# `tts.localVoice` is the local backend's), so a call that omits voice_id arrives
|
||||||
# local/kokoro. Flip it to "frank-tts (dankclaw)/frank-tts" only after the
|
# at 7772 with no `voice` key and the endpoint answers 400 "unknown voice None;
|
||||||
# service is deployed and has answered a real synthesis, because `auth: none`
|
# available voices: narrator1, narrator2, narrator3". Say the voice in the call.
|
||||||
# makes the provider read as AVAILABLE even while the container is down — the
|
# WIRED 2026-10-18: `config.yml` `modelRoles.speech` is this row, flipped only
|
||||||
# role would then fail instead of falling back to Kokoro. The same flip must
|
# after the service was deployed and answered real synthesis (two packs,
|
||||||
# re-key `speech.voice` (now am_fenrir, a Kokoro voice) to a pack name.
|
# different bytes, 24 kHz PCM16, RTF 0.57). Why the gate existed: `auth: none`
|
||||||
|
# makes this provider read as AVAILABLE even while the container is down, and an
|
||||||
|
# explicit speech role has NO fallback candidate, so a dead 7772 fails the device
|
||||||
|
# loudly instead of quietly narrating in Kokoro. Reverting is ONE key:
|
||||||
|
# `modelRoles.speech: local/kokoro`. `speech.voice` stays a Kokoro name either
|
||||||
|
# way — it was never this endpoint's voice.
|
||||||
# SCHEMA LAW, learned the hard way (2026-10-18): provider-level `api` must be
|
# SCHEMA LAW, learned the hard way (2026-10-18): provider-level `api` must be
|
||||||
# a CHAT transport — the models.yml schema accepts only the chat/judge APIs
|
# a CHAT transport — the models.yml schema accepts only the chat/judge APIs
|
||||||
# there, and naming a runner API (`openai-speech`) at provider level fails the
|
# there, and naming a runner API (`openai-speech`) at provider level fails the
|
||||||
|
|||||||
@@ -3,30 +3,44 @@ name: grilling
|
|||||||
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
|
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Authored override.** Extracted 2026-10-18 from `mattpocock/skills` (identical to upstream at extraction) into the command-center repo so Frank can customize it. Removed from the `skills` CLI global store (`~/.agents/.skill-lock.json` pruned) so `skills update -g` never resurrects a duplicate. Edits to this file are repo prose: branch → PR → Frank OK.
|
# Grilling
|
||||||
|
|
||||||
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
|
Interview the user relentlessly until nothing is silently assumed and you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
|
||||||
|
|
||||||
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled: the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
|
A decision is **frontier** when it can be asked now, without an answer you haven't heard yet. The interview is done when the frontier is empty: every branch of the tree visited.
|
||||||
|
|
||||||
Format a round like so:
|
## Rounds
|
||||||
|
|
||||||
```
|
Work the tree in **rounds**: ask the whole frontier at once, then wait. A decision whose answer hangs on an open question or an unfetched fact joins a later round.
|
||||||
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
|
|
||||||
|
|
||||||
➡️ <your recommended answer>
|
Ask each closed decision - anything expressible as 2–5 options - through the `ask` tool:
|
||||||
|
|
||||||
---
|
- **One artefact per question**, counted before sending. Name the artefact each question decides; two names means two questions. Shared plumbing (an endpoint, a service, a build step) still leaves two artefacts.
|
||||||
|
- Recommended answer first, marked `recommended: 0`, which labels it "(Recommended)" for one-glance acceptance. Every reply names what was picked, so the record is unambiguous.
|
||||||
|
- Options quote the user's goal. Their words, their stated goal, ground the wording; your todo text and table rows stay out of it.
|
||||||
|
- Your own idea is welcome: it gets its own recommended question. Attach it to its own question, so it rides there on its merits.
|
||||||
|
- `multi: true` for pick-many, `header` for the branch chip, `preview` when the decision needs code or config in front of the user.
|
||||||
|
|
||||||
❓ **Q2** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
|
Open narrative - "walk me through the deploy flow" - has no options to offer; ask it as prose in chat. Without an `ask` tool the whole round degrades to text: `❓ **Q1 — title**: body`, then `➡️ recommendation`, phrased so one bare "yes" accepts every recommendation.
|
||||||
|
|
||||||
➡️ <your recommended answer>
|
The dialog is exclusive: dispatch scouts before the `ask` call in the same message, or they wait behind it; their results land between rounds. Esc ends the run outright, with no turn to wrap up, so the ledger is what survives it.
|
||||||
```
|
|
||||||
|
|
||||||
Word each question so "yes" accepts your recommended answer.
|
## The ledger
|
||||||
|
|
||||||
Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
|
The tree lives in the `todo` list while the interview runs, so the user watches it fall:
|
||||||
|
|
||||||
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait.
|
- One task per decision, filed under the phase for its branch; `via: <parent decision>` in the task text carries the edge.
|
||||||
|
- `pending` is the frontier; `block` with a one-line reason waits on a fact or an open decision; `done` is settled. Unblock the moment the dependency settles.
|
||||||
|
|
||||||
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
|
Write `local://grilling-state.md` at the end of every round: the tree, each decision's verdict and one-line rationale. It is the durable copy of the interview; keep it current so an Esc costs one round.
|
||||||
|
|
||||||
|
## Facts are yours, decisions are theirs
|
||||||
|
|
||||||
|
- Environment facts (filesystem, repo, live config): dispatch a **scout** per fact-cluster, batched into one `task` call, before the round's `ask`. Cluster tightly; doing so keeps each scout more aware of possible adjacencies.
|
||||||
|
- Outside-world facts: call `web_search` directly, do not delegate to a subagent.
|
||||||
|
- Cheap lookups: `read`/`grep` inline.
|
||||||
|
- The main session is where questions happen: every spawn carries the exact facts to return, and comes back into the next round.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
The frontier is empty and nothing sits blocked. Distil the ledger into a **brief**: one line per decision, verdict plus rationale, grouped by branch. Put it in chat, then one closing `ask`: "Proceed on this brief?" Nothing executes before that yes. After it, fold the brief into the project's knowledge base, or a log markdown file, wherever the project keeps its decisions.
|
||||||
|
|||||||
@@ -275,13 +275,13 @@ transition = [ "fade" ]
|
|||||||
interval_seconds = 1200
|
interval_seconds = 1200
|
||||||
|
|
||||||
[wallpaper.default]
|
[wallpaper.default]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
|
||||||
|
|
||||||
[wallpaper.last]
|
[wallpaper.last]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
|
||||||
|
|
||||||
[wallpaper.monitors.DP-1]
|
[wallpaper.monitors.DP-1]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
|
||||||
|
|
||||||
[wallpaper.monitors.DP-2]
|
[wallpaper.monitors.DP-2]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
|
||||||
@@ -290,7 +290,7 @@ transition = [ "fade" ]
|
|||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
|
||||||
|
|
||||||
[wallpaper.monitors.eDP-1]
|
[wallpaper.monitors.eDP-1]
|
||||||
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_73qev3.jpg"
|
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_4x17w3.jpg"
|
||||||
|
|
||||||
[weather]
|
[weather]
|
||||||
unit = "imperial"
|
unit = "imperial"
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
title: dankvoice
|
||||||
|
description: omp extension that speaks assistant output with the fleet's cloned voices through frank-tts.
|
||||||
|
tags:
|
||||||
|
- omp
|
||||||
|
- extension
|
||||||
|
- tts
|
||||||
|
- voice
|
||||||
|
- frank-tts
|
||||||
|
- dankclaw
|
||||||
|
verified: 2026-10-18
|
||||||
|
timestamp: 2026-10-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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)).
|
||||||
|
|
||||||
|
## Why it exists
|
||||||
|
|
||||||
|
Nothing built into the harness can pronounce Frank's cloned voices.
|
||||||
|
|
||||||
|
| Surface | Engine | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| TUI speak-aloud (`speech.enabled`, `speech.voice`) | bundled Kokoro ONNX worker, `synthesizeStream(voice, speed, signal)` | Kokoro voice enum only; never consults `models.yml` or the speech role |
|
||||||
|
| `omp say [TEXT]` | same local backend (`--model` = *local* model key) | `omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, af_bella, af_nicole, af_kore, af_sarah, am_michael, am_fenrir, am_puck, bf_emma, bm_george, bm_fable` (verified 2026-10-18) |
|
||||||
|
| `tts` tool / `xd://tts` device (`modelRoles.speech`) | frank-tts over OpenAI `/v1/audio/speech` | cloned voices work, but only as a **file** — nothing plays them |
|
||||||
|
|
||||||
|
`dankvoice` is not a patch to that vocalizer (harness code, not ours); it is a
|
||||||
|
second transport that happens to speak, so Kokoro stays available for quick
|
||||||
|
local speech and the cloned voices carry the real narration.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `/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 |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Endpoint resolution
|
||||||
|
|
||||||
|
The base URL is **read from `agent/models.yml`**, from the provider block whose
|
||||||
|
model row carries `api: openai-speech` — the same row the `speech` role
|
||||||
|
resolves, so no host address and no credential appears in this file. From
|
||||||
|
`baseUrl: http://192.168.0.143:7772/v1` it derives
|
||||||
|
`POST …/v1/audio/speech`, `GET …/v1/audio/voices` and `GET …/health`.
|
||||||
|
|
||||||
|
| Environment | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `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_VOICE` | empty (server default) | default voice name |
|
||||||
|
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
|
||||||
|
| `DANKVOICE_MAX` | `700` | spoken characters per utterance |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
## Verified facts (2026-10-18)
|
||||||
|
|
||||||
|
* **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
|
||||||
|
fresh `omp -p` run. `omp --help` documents `--no-extensions` (discovery off,
|
||||||
|
explicit `-e/--extension` and `--hook` still load) and `plugin install|link`.
|
||||||
|
* **Endpoint** — resolving the live `agent/models.yml` yields
|
||||||
|
`speechUrl http://192.168.0.143:7772/v1/audio/speech`,
|
||||||
|
`voicesUrl …/v1/audio/voices`, `healthUrl …/health`, `model frank-tts`.
|
||||||
|
* **Roster** — `GET /v1/audio/voices` answers the bare JSON array
|
||||||
|
`["narrator1","narrator2","narrator3"]`; `/health` answers
|
||||||
|
`{"status":"ok","backend":"omni-http","voices":[…]}`. `GET /v1/voices` and
|
||||||
|
`GET /v1/models` are **404** on this shim, and an unknown voice answers
|
||||||
|
`400 unknown voice 'zzz'; available voices: …` with `available_voices` in the
|
||||||
|
body — which is the fallback the extension uses if the roster route ever
|
||||||
|
disappears.
|
||||||
|
* **Omitting `voice` is legal** — frank-tts fills its `DEFAULT_VOICE` since the
|
||||||
|
2026-10-18 voice-pack work, so an unset voice synthesizes instead of failing.
|
||||||
|
* **Speech** — `/say "dankvoice live smoke test"` produced a 99,884 B / 2.08 s
|
||||||
|
WAV and `/say voice narrator2` + `/say "cloned voice check"` a 69,164 B /
|
||||||
|
1.44 s WAV (durations from `ffprobe`), each handed to the player; temp
|
||||||
|
directories were gone afterwards.
|
||||||
|
* **Auto-speak** — verified in a real TUI session: `/say on` armed the footer
|
||||||
|
(`🔊 narrator1`), the next finished turn synthesized and played the answer
|
||||||
|
(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.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
* The TUI's own speak-aloud feature still narrates in Kokoro: its voice list is
|
||||||
|
a compile-time enum in the harness bundle, and no setting routes it to
|
||||||
|
frank-tts. Cloned voices live only on this file-producing/speaking path and
|
||||||
|
on the `tts` tool. See [Handy](/tools/handy.md) for the STT half.
|
||||||
|
* `agent_end` narrates the final message text only — mid-turn tool chatter is
|
||||||
|
never spoken.
|
||||||
|
* frank-tts is LAN-only: no Caddy vhost fronts :7772, so this extension is
|
||||||
|
usable on the home network.
|
||||||
|
* Deployment, packs and the voice registry are owned by the
|
||||||
|
`serverAppConfigs` repo (`serverAppConfigs-docs/Dankclaw/Frank TTS.md`), not
|
||||||
|
by this one.
|
||||||
|
|
||||||
|
## Citations
|
||||||
|
|
||||||
|
* Live probes on the workstation, 2026-10-18: `omp say --voice narrator1 …`,
|
||||||
|
`curl http://192.168.0.143:7772/health`, `/v1/audio/voices`,
|
||||||
|
`/v1/audio/…/speech` (invalid voice), `ffprobe` on the produced WAVs.
|
||||||
|
* `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"`).
|
||||||
|
* Gitea issue #21 on `dank/command-center` (the work item this page ships with).
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
* [Core Documentation](/core/index.md) — the live harness configuration.
|
||||||
|
* [Handy](/tools/handy.md) — offline speech-to-text, the input half of voice.
|
||||||
|
* `serverAppConfigs-docs/Dankclaw/Frank TTS.md` — the frank-tts service runbook.
|
||||||
@@ -13,6 +13,7 @@ These files define the structural conventions that govern the agent system itsel
|
|||||||
* [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior.
|
* [AGENTS.md Format Specification](agents-md-format.md) — Canonical structure and conventions for the system-level instruction file that governs agent behavior.
|
||||||
* [AGENTS.md Index](index-doc.md) — Overview of the cached tool documentation system and how to add new entries.
|
* [AGENTS.md Index](index-doc.md) — Overview of the cached tool documentation system and how to add new entries.
|
||||||
* [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides.
|
* [Pi Settings System](settings.md) — pi-era `settings.json` record (archive): providers, models, packages, subagent overrides.
|
||||||
|
* [dankvoice](dankvoice.md) — Cloned-voice vocalization extension: `/say` and auto-speak over frank-tts, endpoint read from `agent/models.yml`.
|
||||||
|
|
||||||
## Change Tracking
|
## Change Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ timestamp: 2026-08-28
|
|||||||
|
|
||||||
# Bundle Change Log
|
# Bundle Change Log
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
## 2026-10-18 — Noctalia bar layout clobbered by trunk-sync stash, recovered from dangling objects
|
## 2026-10-18 — Noctalia bar layout clobbered by trunk-sync stash, recovered from dangling objects
|
||||||
|
|
||||||
Frank's months of GUI-built bar customizations (cat/clock/clock-utc/weather start, taskbar center, capsule groups g1–g4, control-cheat-sheet, thickness/font-scale/opacity styling) vanished from the live bar at 16:46. Root cause: the layout existed **only in the working tree** — the repo copy of `settings.toml` carried a stale plain layout because GUI edits hadn't been committed inside the hourly heartbeat window — and a trunk-sync session clearing the dirty tree for `origin/main` stashed it; the stash entry was subsequently destroyed (three `WIP on main` stashes within 60 s, all later dropped). Recovery: `git fsck --lost-found` surfaced the destroyed stash as dangling commit `7fe3ed8` (16:46:09); candidate validated in a sandboxed `XDG_*_HOME` before restore, clobbered file preserved to `.AGENTTRASH/`, layout restored through the symlink target and proven by screenshot, committed as `c87b378` — the durable copy the layout never had. Lessons codified into managed skills: trunk-sync must preservation-**commit** every dirty deployment path (never stash, never `stash clear`), and GUI layout edits get committed the same session they're made.
|
Frank's months of GUI-built bar customizations (cat/clock/clock-utc/weather start, taskbar center, capsule groups g1–g4, control-cheat-sheet, thickness/font-scale/opacity styling) vanished from the live bar at 16:46. Root cause: the layout existed **only in the working tree** — the repo copy of `settings.toml` carried a stale plain layout because GUI edits hadn't been committed inside the hourly heartbeat window — and a trunk-sync session clearing the dirty tree for `origin/main` stashed it; the stash entry was subsequently destroyed (three `WIP on main` stashes within 60 s, all later dropped). Recovery: `git fsck --lost-found` surfaced the destroyed stash as dangling commit `7fe3ed8` (16:46:09); candidate validated in a sandboxed `XDG_*_HOME` before restore, clobbered file preserved to `.AGENTTRASH/`, layout restored through the symlink target and proven by screenshot, committed as `c87b378` — the durable copy the layout never had. Lessons codified into managed skills: trunk-sync must preservation-**commit** every dirty deployment path (never stash, never `stash clear`), and GUI layout edits get committed the same session they're made.
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
|
|
||||||
## Subagent Architecture
|
## 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
|
## 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
|
||||||
Executable
+263
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# tests/dankvoice.bats — black-box tests for agent/extensions/dankvoice.ts
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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).
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
|
||||||
|
export DV_EXT="$REPO/agent/extensions/dankvoice.ts"
|
||||||
|
export DV_STUB="$WORK/player.sh"
|
||||||
|
export DV_STUB_LOG="$WORK/player.log"
|
||||||
|
export DV_COPY="$WORK/played.wav"
|
||||||
|
export DV_EMPTY_BIN="$WORK/emptybin"
|
||||||
|
mkdir -p "$DV_EMPTY_BIN"
|
||||||
|
|
||||||
|
cat > "$DV_STUB" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$1" >> "$DV_STUB_LOG"
|
||||||
|
cp "$1" "$DV_COPY"
|
||||||
|
sleep 0.1
|
||||||
|
SH
|
||||||
|
chmod +x "$DV_STUB"
|
||||||
|
|
||||||
|
# models.yml fixture: a chat provider first, the speech provider second
|
||||||
|
export DV_MODELS="$WORK/models.yml"
|
||||||
|
cat > "$DV_MODELS" <<'YML'
|
||||||
|
providers:
|
||||||
|
"chat (box)":
|
||||||
|
baseUrl: http://10.0.0.1:9999/v1
|
||||||
|
models:
|
||||||
|
- id: chatmodel
|
||||||
|
api: openai-completions
|
||||||
|
"speech (box)":
|
||||||
|
baseUrl: http://127.0.0.1:1/v1
|
||||||
|
auth: none
|
||||||
|
models:
|
||||||
|
- id: speechmodel
|
||||||
|
api: openai-speech
|
||||||
|
YML
|
||||||
|
|
||||||
|
cat > "$WORK/driver.ts" <<'TS'
|
||||||
|
// Black-box driver: stand up the frank-tts fixture, load the extension against
|
||||||
|
// it, invoke one scenario, print what the endpoint received and what got played.
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
|
||||||
|
const scenario = process.argv[2] ?? "speak";
|
||||||
|
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||||
|
const received: Array<{ url: string | undefined; body: string }> = [];
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
const body = Buffer.concat(chunks).toString("utf8");
|
||||||
|
received.push({ url: req.url, body });
|
||||||
|
if (req.url?.endsWith("/audio/voices")) {
|
||||||
|
res.setHeader("content-type", "application/json");
|
||||||
|
res.end(JSON.stringify(["narrator1", "narrator2"]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url?.endsWith("/audio/speech")) {
|
||||||
|
res.setHeader("content-type", "audio/wav");
|
||||||
|
res.end(AUDIO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("{}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = (server.address() as { port: number }).port;
|
||||||
|
|
||||||
|
if (scenario === "endpoint") {
|
||||||
|
process.env.DANKVOICE_MODELS = process.env.DV_MODELS;
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
|
||||||
|
const extension = await import(process.env.DV_EXT as string);
|
||||||
|
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
|
||||||
|
const events = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
|
||||||
|
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 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 ?? ""}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const say = commands.get("say");
|
||||||
|
const agentEnd = events.get("agent_end");
|
||||||
|
const answer = (text: string) => ({ messages: [{ role: "assistant", content: [{ type: "text", text }] }] });
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
if (!say) {
|
||||||
|
console.log("COMMANDS none");
|
||||||
|
} else {
|
||||||
|
console.log(`COMMANDS ${[...commands.keys()].join(",")} EVENTS ${[...events.keys()].join(",")}`);
|
||||||
|
switch (scenario) {
|
||||||
|
case "speak":
|
||||||
|
await say.handler("hello world", ctx);
|
||||||
|
break;
|
||||||
|
case "voice":
|
||||||
|
await say.handler("voice narrator2", ctx);
|
||||||
|
await say.handler("good morning", ctx);
|
||||||
|
break;
|
||||||
|
case "auto":
|
||||||
|
await say.handler("on", ctx);
|
||||||
|
await agentEnd?.(answer("the answer"), ctx);
|
||||||
|
await settle();
|
||||||
|
await say.handler("off", ctx);
|
||||||
|
await agentEnd?.(answer("the second answer"), ctx);
|
||||||
|
break;
|
||||||
|
case "continuation":
|
||||||
|
await say.handler("on", ctx);
|
||||||
|
await agentEnd?.({ ...answer("not yet"), willContinue: true }, ctx);
|
||||||
|
await settle();
|
||||||
|
await agentEnd?.(answer("the settled answer"), ctx);
|
||||||
|
break;
|
||||||
|
case "printmode":
|
||||||
|
await say.handler("on", ctx);
|
||||||
|
await agentEnd?.(answer("quiet"), ctx);
|
||||||
|
break;
|
||||||
|
case "voices":
|
||||||
|
await say.handler("voices", ctx);
|
||||||
|
break;
|
||||||
|
case "endpoint":
|
||||||
|
await say.handler("status", ctx);
|
||||||
|
break;
|
||||||
|
case "markdown":
|
||||||
|
await say.handler("## Title\n\nUse `pw-play` here.\n\n```bash\nnpm i x\n```\n\nsee [docs](http://example.test) and **bold**\n- a\n- b", ctx);
|
||||||
|
break;
|
||||||
|
case "truncate":
|
||||||
|
await say.handler("Sentence one is here. ".repeat(40), ctx);
|
||||||
|
break;
|
||||||
|
case "noplayer":
|
||||||
|
await say.handler("speak anyway", ctx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const call of received) {
|
||||||
|
const parsed: unknown = JSON.parse(call.body === "" ? "{}" : call.body);
|
||||||
|
const field = (key: string): string => {
|
||||||
|
if (parsed && typeof parsed === "object" && key in parsed) {
|
||||||
|
const value = parsed[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
};
|
||||||
|
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}`);
|
||||||
|
}
|
||||||
|
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 (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
|
||||||
|
server.close();
|
||||||
|
TS
|
||||||
|
|
||||||
|
export DV_DRIVER="$WORK/driver.ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
|
||||||
|
say_run() {
|
||||||
|
run bun "$DV_DRIVER" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the extension registers the say command and an agent_end listener" {
|
||||||
|
say_run speak
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"COMMANDS say EVENTS agent_end"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "speaking posts the text to the speech route" {
|
||||||
|
say_run speak
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"CALL /v1/audio/speech model=frank-tts format=wav voice=- input=hello world"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "one utterance is played once, as exactly the bytes the endpoint sent" {
|
||||||
|
say_run speak
|
||||||
|
[[ "$output" == *"PLAYS 1"* ]]
|
||||||
|
[[ "$output" == *"COPIED 24"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the voice is named in the request only when the user chose one" {
|
||||||
|
say_run speak
|
||||||
|
[[ "$output" == *"voice=- input=hello world"* ]]
|
||||||
|
say_run voice
|
||||||
|
[[ "$output" == *"voice=narrator2 input=good morning"* ]]
|
||||||
|
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "auto-speak narrates the final answer once and stays silent after it is disarmed" {
|
||||||
|
say_run auto
|
||||||
|
[[ "$output" == *"PLAYS 1"* ]]
|
||||||
|
[[ "$output" == *"input=the answer"* ]]
|
||||||
|
[[ "$output" != *"the second answer"* ]]
|
||||||
|
[[ "$output" == *"STATUS dankvoice 🔊"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "auto-speak stays silent outside the interactive modes" {
|
||||||
|
DV_MODE=print say_run printmode
|
||||||
|
[[ "$output" == *"PLAYS 0"* ]]
|
||||||
|
[[ "$output" != *"input=quiet"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the voice list comes from the endpoint roster route" {
|
||||||
|
say_run voices
|
||||||
|
[[ "$output" == *"CALL /v1/audio/voices"* ]]
|
||||||
|
[[ "$output" == *"dankvoice voices: narrator1, narrator2"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the endpoint is taken from the speech row of the models file, never the chat provider" {
|
||||||
|
say_run endpoint
|
||||||
|
[[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]]
|
||||||
|
[[ "$output" != *"10.0.0.1"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "markdown never reaches the speech engine" {
|
||||||
|
say_run markdown
|
||||||
|
[[ "$output" == *"input=Title Use pw-play here. see docs and bold a b"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an over-long utterance is bounded and cut at a sentence boundary" {
|
||||||
|
DANKVOICE_MAX=60 say_run truncate
|
||||||
|
[[ "$output" == *"here. …"* ]]
|
||||||
|
[ "$(grep -o 'Sentence one is here\.' <<<"$output" | wc -l)" -le 4 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a missing player is reported, not swallowed" {
|
||||||
|
say_run noplayer
|
||||||
|
[[ "$output" == *"NOTIFY error dankvoice: no audio player found"* ]]
|
||||||
|
[[ "$output" == *"PLAYS 0"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a scheduled continuation is never narrated as the final answer" {
|
||||||
|
say_run continuation
|
||||||
|
[[ "$output" == *"PLAYS 1"* ]]
|
||||||
|
[[ "$output" == *"input=the settled answer"* ]]
|
||||||
|
[[ "$output" != *"input=not yet"* ]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user