Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8f3974c1 | ||
|
|
54f15e2a58 | ||
|
|
1e7e212e5c | ||
|
|
475211d2e7 | ||
|
|
0258ef70f8 | ||
|
|
0c7fe8b4bf | ||
|
|
765b686859 | ||
|
|
332067a516 | ||
|
|
66e3f220e7 | ||
|
|
6134e1168e | ||
|
|
c379d16858 | ||
|
|
ca8b46241e | ||
|
|
3fa0242f56 | ||
|
|
fe039a0b73 | ||
|
|
d4092f2f65 | ||
|
|
cedef97c99 | ||
|
|
0ee08bc368 | ||
|
|
6664f9090a | ||
|
|
7de07ee35c | ||
|
|
fcc7226c3b | ||
|
|
a8011c3d4d | ||
|
|
08f90c6df3 |
@@ -96,6 +96,17 @@ Never assume the user reports correct information. He is human, and humans make
|
|||||||
- Always practice Test Driven Development practices; do UNIT TESTS exist for the functionality about to be edited? Is there a LINTER? Is there a FORMATTER? Verify that they exist, and that they are configured as a pre-commit hook. If they do not exist, they MUST be written PRIOR to the code being written. Invoke `/skill:tdd` for preferred testing style. If tests do exist, ensure that they still match the planned changes. If you believe a PRE-EXISTING test must be changed prior to commencing work, ASK THE USER ABOUT IT.
|
- Always practice Test Driven Development practices; do UNIT TESTS exist for the functionality about to be edited? Is there a LINTER? Is there a FORMATTER? Verify that they exist, and that they are configured as a pre-commit hook. If they do not exist, they MUST be written PRIOR to the code being written. Invoke `/skill:tdd` for preferred testing style. If tests do exist, ensure that they still match the planned changes. If you believe a PRE-EXISTING test must be changed prior to commencing work, ASK THE USER ABOUT IT.
|
||||||
- Always ensure tests run on code changes after every commit, and all project tests run before every push using git hooks, EXCEPT in the event that the individual test in question takes an excessive amount of time. Always ask the user before disabling an individual due to running time or performance concerns.
|
- Always ensure tests run on code changes after every commit, and all project tests run before every push using git hooks, EXCEPT in the event that the individual test in question takes an excessive amount of time. Always ask the user before disabling an individual due to running time or performance concerns.
|
||||||
|
|
||||||
|
|
||||||
|
### 6. Keep the Todo List LIVE, Not Retrospective
|
||||||
|
|
||||||
|
The todo list is the user's cockpit view of your work. A list that only flips at the end is a history dump, not tracking - the user sees nothing while you work.
|
||||||
|
|
||||||
|
- Multi-step work: `todo init` BEFORE the first substantive step - the list must exist while the work is unseen, not after.
|
||||||
|
- Batch the todo transition with the step it describes: `done` + `start` ride along in the same message as the first tool calls of the next step. NEVER defer flips until the whole bill of tasks is finished.
|
||||||
|
- A task is `done` the moment its work is verified - not when the surrounding phase ends.
|
||||||
|
- Blocked on external input (user answer, subagent, service)? `block` it with a reason so the stall is visible; `unblock` the instant it is actionable.
|
||||||
|
- NEVER make a todo call the only call in a message - that is ceremony, not progress; pair every flip with real work.
|
||||||
|
|
||||||
## FORBIDDEN ACTS AND MISTAKES TO NOT REPEAT (DO NOT DO THESE):
|
## FORBIDDEN ACTS AND MISTAKES TO NOT REPEAT (DO NOT DO THESE):
|
||||||
- Editing node_modules or similar import module directories. This is third party code that will be overwritten next time `npm update` or a similar command is run.
|
- Editing node_modules or similar import module directories. This is third party code that will be overwritten next time `npm update` or a similar command is run.
|
||||||
- `git push` - unless directed, except if it's our local gitea instance on Dankclaw.
|
- `git push` - unless directed, except if it's our local gitea instance on Dankclaw.
|
||||||
|
|||||||
+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,626 @@
|
|||||||
|
/**
|
||||||
|
* 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 open the options menu
|
||||||
|
* /say <text> speak once through the speakers
|
||||||
|
* /say on | off arm/disarm auto-speak of the assistant's final answer
|
||||||
|
* /say menu the options menu (voice, output device, player, cap)
|
||||||
|
* /say voice <name> choose a voice (empty = whatever the server defaults to)
|
||||||
|
* /say voices list the voices frank-tts reports
|
||||||
|
* /say stop cut off the current utterance
|
||||||
|
* /say status voice, endpoint, player and device in use
|
||||||
|
* /say help the command list
|
||||||
|
*
|
||||||
|
* The endpoint comes from agent/models.yml — the provider block carrying
|
||||||
|
* `api: openai-speech`, i.e. the same row the speech role resolves — so this
|
||||||
|
* file carries no host address and no secret. Menu choices persist to
|
||||||
|
* ~/.omp/cache/dankvoice.json (runtime state, never tracked). Environment
|
||||||
|
* overrides win over the file, which wins over defaults:
|
||||||
|
*
|
||||||
|
* DANKVOICE_URL speech base URL (…/v1) instead of models.yml
|
||||||
|
* DANKVOICE_MODEL served model id (default: the id in that block)
|
||||||
|
* DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json)
|
||||||
|
* DANKVOICE_PLAYER player binary, skipping detection
|
||||||
|
* DANKVOICE_VOICE voice name ("" = server default)
|
||||||
|
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
|
||||||
|
* DANKVOICE_AUTO=1 start with auto-speak armed
|
||||||
|
*
|
||||||
|
* Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed
|
||||||
|
* when its player process exits. A new utterance interrupts the previous one
|
||||||
|
* instead of queueing behind it. Output routing: a saved sink becomes
|
||||||
|
* `pw-play --target <sink>` or `paplay --device=<sink>` (ffplay has no device
|
||||||
|
* concept and ignores it); with no saved sink the player's own default applies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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: "menu" }
|
||||||
|
| { 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 === "menu") return { kind: "menu" };
|
||||||
|
if (lower === "help") return { kind: "help" };
|
||||||
|
if (lower === "on") return { kind: "on" };
|
||||||
|
if (lower === "off") return { kind: "off" };
|
||||||
|
if (lower === "stop") return { kind: "stop" };
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Known players, PipeWire first (the workstations run WirePlumber); first existing wins. */
|
||||||
|
export function availablePlayers(lookup: (command: string) => string | null): string[] {
|
||||||
|
return ["pw-play", "paplay", "ffplay"].filter((command) => lookup(command) !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One player invocation: the saved sink becomes each player's routing dialect. */
|
||||||
|
export function playerFor(command: string, sink: string): Player {
|
||||||
|
const base = command.split("/").pop() ?? command;
|
||||||
|
if (base === "ffplay") return { command, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] };
|
||||||
|
if (!sink) return { command, args: [] };
|
||||||
|
if (base === "paplay") return { command, args: [`--device=${sink}`] };
|
||||||
|
return { command, args: ["--target", sink] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu choices, persisted between sessions; every field optional. */
|
||||||
|
export interface Settings {
|
||||||
|
voice?: string;
|
||||||
|
player?: string;
|
||||||
|
sink?: string;
|
||||||
|
maxChars?: number;
|
||||||
|
auto?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */
|
||||||
|
export function settingsPath(env: NodeJS.ProcessEnv): string {
|
||||||
|
return env.DANKVOICE_CONFIG?.trim() || join(env.HOME ?? "", ".omp", "cache", "dankvoice.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSettings(path: string): Settings {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||||
|
const raw = parsed as Record<string, unknown>;
|
||||||
|
const settings: Settings = {};
|
||||||
|
if (typeof raw.voice === "string") settings.voice = raw.voice;
|
||||||
|
if (typeof raw.player === "string") settings.player = raw.player;
|
||||||
|
if (typeof raw.sink === "string") settings.sink = raw.sink;
|
||||||
|
if (typeof raw.maxChars === "number" && Number.isFinite(raw.maxChars) && raw.maxChars > 0) settings.maxChars = Math.floor(raw.maxChars);
|
||||||
|
if (typeof raw.auto === "boolean") settings.auto = raw.auto;
|
||||||
|
return settings;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sink row from `wpctl list audio sinks`: `116<TAB>alsa_output…<TAB>audio/sink<TAB>*`. */
|
||||||
|
export interface SinkRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSinkList(output: string): SinkRow[] {
|
||||||
|
const rows: SinkRow[] = [];
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const cols = line.split("\t").map((col) => col.trim());
|
||||||
|
if (cols.length < 3 || !cols[2]?.includes("audio/sink")) continue;
|
||||||
|
const id = cols[0] ?? "";
|
||||||
|
const name = cols[1] ?? "";
|
||||||
|
if (!id || !name) continue;
|
||||||
|
rows.push({ id, name });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- live wiring
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
target?: SpeechTarget;
|
||||||
|
player?: Player;
|
||||||
|
sink: string;
|
||||||
|
maxChars: number;
|
||||||
|
auto: boolean;
|
||||||
|
voice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 env = process.env;
|
||||||
|
const settings = readSettings(settingsPath(env));
|
||||||
|
const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
|
||||||
|
const modelOverride = env.DANKVOICE_MODEL?.trim();
|
||||||
|
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
|
||||||
|
const target: SpeechTarget | undefined = overrideUrl
|
||||||
|
? {
|
||||||
|
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 playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0];
|
||||||
|
const sink = settings.sink ?? "";
|
||||||
|
const maxChars = Number(env.DANKVOICE_MAX);
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
player: playerCommand ? playerFor(playerCommand, sink) : undefined,
|
||||||
|
sink,
|
||||||
|
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : (settings.maxChars ?? DEFAULT_MAX_CHARS),
|
||||||
|
auto: env.DANKVOICE_AUTO === "1" || (settings.auto ?? false),
|
||||||
|
voice: env.DANKVOICE_VOICE?.trim() ?? settings.voice ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PipeWire sinks via wpctl; empty when wireplumber is unreachable. */
|
||||||
|
function listSinks(): SinkRow[] {
|
||||||
|
const probe = spawnSync("wpctl", ["list", "audio", "sinks"], { encoding: "utf8" });
|
||||||
|
return probe.status === 0 ? parseSinkList(probe.stdout) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */
|
||||||
|
async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> {
|
||||||
|
const res = await fetch(target.speechUrl, {
|
||||||
|
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)";
|
||||||
|
|
||||||
|
const persistSettings = (ctx: ExtensionContext) => {
|
||||||
|
const settings: Settings = {
|
||||||
|
voice: state.voice,
|
||||||
|
player: cfg.player?.command ?? "",
|
||||||
|
sink: cfg.sink,
|
||||||
|
maxChars: cfg.maxChars,
|
||||||
|
auto: state.auto,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(`dankvoice: settings not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVoice = (ctx: ExtensionContext, name: string) => {
|
||||||
|
state.voice = name;
|
||||||
|
reflectStatus();
|
||||||
|
ctx.ui.notify(name ? `dankvoice: voice = ${name}` : "dankvoice: voice = whatever frank-tts defaults to", "info");
|
||||||
|
persistSettings(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAuto = (ctx: ExtensionContext, on: boolean) => {
|
||||||
|
state.auto = on;
|
||||||
|
if (!on) stop();
|
||||||
|
reflectStatus();
|
||||||
|
ctx.ui.notify(
|
||||||
|
on ? `dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})` : "dankvoice: auto-speak off",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
persistSettings(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu(ctx: ExtensionContext): Promise<void> {
|
||||||
|
const options = [
|
||||||
|
`Auto-speak: ${state.auto ? "on" : "off"} (toggle)`,
|
||||||
|
`Voice: ${voiceLabel()} (change)`,
|
||||||
|
`Output device: ${cfg.sink || "system default"} (change)`,
|
||||||
|
`Player: ${cfg.player?.command ?? "none"} (change)`,
|
||||||
|
`Spoken cap: ${cfg.maxChars} chars (change)`,
|
||||||
|
"Speak a test phrase",
|
||||||
|
];
|
||||||
|
const choice = await ctx.ui.select("dankvoice — options", options);
|
||||||
|
if (!choice) return;
|
||||||
|
if (choice === options[0]) {
|
||||||
|
setAuto(ctx, !state.auto);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[1]) {
|
||||||
|
if (!cfg.target) {
|
||||||
|
ctx.ui.notify(missingTarget(), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let voices: string[];
|
||||||
|
try {
|
||||||
|
voices = await fetchVoices(cfg.target);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = await ctx.ui.select("dankvoice — voice", ["server default", ...voices]);
|
||||||
|
if (picked === undefined) return;
|
||||||
|
setVoice(ctx, picked === "server default" ? "" : picked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[2]) {
|
||||||
|
const picked = await ctx.ui.select("dankvoice — output device", ["system default", ...listSinks().map((sink) => sink.name)]);
|
||||||
|
if (picked === undefined) return;
|
||||||
|
cfg.sink = picked === "system default" ? "" : picked;
|
||||||
|
if (cfg.player) cfg.player = playerFor(cfg.player.command, cfg.sink);
|
||||||
|
ctx.ui.notify(`dankvoice: output device = ${cfg.sink || "system default"}`, "info");
|
||||||
|
persistSettings(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[3]) {
|
||||||
|
const choices = [...new Set([cfg.player?.command, ...availablePlayers(lookupOnPath)].filter((c): c is string => Boolean(c)))];
|
||||||
|
if (choices.length === 0) {
|
||||||
|
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = await ctx.ui.select("dankvoice — player", choices);
|
||||||
|
if (picked === undefined) return;
|
||||||
|
cfg.player = playerFor(picked, cfg.sink);
|
||||||
|
ctx.ui.notify(`dankvoice: player = ${picked}`, "info");
|
||||||
|
persistSettings(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice === options[4]) {
|
||||||
|
const picked = await ctx.ui.select("dankvoice — spoken cap", ["300", "700", "1500", "3000"]);
|
||||||
|
const next = Number(picked);
|
||||||
|
if (!Number.isFinite(next) || next <= 0) return;
|
||||||
|
cfg.maxChars = next;
|
||||||
|
ctx.ui.notify(`dankvoice: cap = ${next} chars`, "info");
|
||||||
|
persistSettings(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerCommand("say", {
|
||||||
|
description:
|
||||||
|
"Speak with the fleet's cloned voices (frank-tts): /say (menu) | <text> | on | off | menu | voice <name> | voices | stop | status",
|
||||||
|
handler: async (args: string, ctx) => {
|
||||||
|
state.lastCtx = ctx;
|
||||||
|
const action = parseSayArgs(args);
|
||||||
|
switch (action.kind) {
|
||||||
|
case "menu":
|
||||||
|
await openMenu(ctx);
|
||||||
|
return;
|
||||||
|
case "help":
|
||||||
|
ctx.ui.notify(
|
||||||
|
"dankvoice: /say (menu) · /say <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "on":
|
||||||
|
setAuto(ctx, true);
|
||||||
|
return;
|
||||||
|
case "off":
|
||||||
|
setAuto(ctx, false);
|
||||||
|
return;
|
||||||
|
case "stop":
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
case "voice":
|
||||||
|
setVoice(ctx, action.name);
|
||||||
|
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"} · device ${cfg.sink || "system default"} · 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
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/ask-matt
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/chief-of-staff
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/claude-handoff
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/code-review
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/codebase-design
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/diagnosing-bugs
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/domain-modeling
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/find-skills
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/git-guardrails-claude-code
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/grill-me
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/grill-with-docs
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/grilling
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grilling
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## The ledger
|
||||||
|
|
||||||
|
The tree lives in the `todo` list while the interview runs, so the user watches it fall:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/handoff
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/implement
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/implement-spec
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/improve-codebase-architecture
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/loop-me
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/migrate-to-shoehorn
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/pr
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/prototype
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/research
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/retro
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/scaffold-exercises
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/setup-matt-pocock-skills
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/setup-pre-commit
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/setup-ts-deep-modules
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/tdd
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/teach
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/to-questionnaire
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/to-spec
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/to-tickets
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/triage
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/wait-what
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/wayfinder
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/wizard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/writing-beats
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/writing-for-agents
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/writing-fragments
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/blank/.agents/skills/writing-shape
|
|
||||||
@@ -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,151 @@
|
|||||||
|
---
|
||||||
|
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` opens the options menu, `/say <text>` speaks through the
|
||||||
|
speakers and `/say on` narrates the assistant's final answer after every
|
||||||
|
turn, all served by **frank-tts** (Dankclaw :7772, the `speech` model role
|
||||||
|
in [agent/models.yml](/core/index.md)).
|
||||||
|
|
||||||
|
## Why it exists
|
||||||
|
|
||||||
|
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` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, test phrase |
|
||||||
|
| `/say <text>` | synthesize once and play; interrupts whatever is playing |
|
||||||
|
| `/say on` \| `/say off` | arm/disarm auto-speak of the assistant's final text at `agent_end`; the footer shows `🔊 <voice>` while armed |
|
||||||
|
| `/say voice <name>` | pick a voice; an empty name defers to the server's own default |
|
||||||
|
| `/say voices` | list what frank-tts reports |
|
||||||
|
| `/say stop` | cut the current utterance |
|
||||||
|
| `/say status` \| `/say help` | endpoint, voice, player, device, cap |
|
||||||
|
|
||||||
|
Menu choices persist to `~/.omp/cache/dankvoice.json` (runtime state, never
|
||||||
|
tracked); the environment overrides the file, the file overrides defaults.
|
||||||
|
"Speak a test phrase" synthesizes a short line through the current voice,
|
||||||
|
player and device — the audible proof whenever a session "has say on" but
|
||||||
|
stays silent.
|
||||||
|
|
||||||
|
Auto-speak is skipped in `print`/`json` mode so benches and pipelines stay
|
||||||
|
silent. Markdown is stripped before synthesis — fenced code is dropped, links
|
||||||
|
collapse to their label, bullets and table pipes disappear — and the utterance
|
||||||
|
is truncated at a sentence boundary inside the cap.
|
||||||
|
|
||||||
|
## 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_CONFIG` | `~/.omp/cache/dankvoice.json` | persisted menu choices (voice, player, sink, cap, auto) |
|
||||||
|
| `DANKVOICE_VOICE` | empty (server default) | default voice name |
|
||||||
|
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
|
||||||
|
| `DANKVOICE_MAX` | `700` | spoken characters per utterance |
|
||||||
|
| `DANKVOICE_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` (PipeWire first:
|
||||||
|
the workstations run WirePlumber). A saved output device routes the player
|
||||||
|
explicitly — `pw-play --target <sink>` or `paplay --device=<sink>`; ffplay
|
||||||
|
has no device concept and ignores it — while an unset device leaves the
|
||||||
|
player's own default. Sinks are enumerated with `wpctl list audio sinks`;
|
||||||
|
if wireplumber is unreachable the menu offers only "system default". Each
|
||||||
|
utterance is written to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus
|
||||||
|
its directory are removed when the player process exits.
|
||||||
|
|
||||||
|
## Verified facts (2026-10-18)
|
||||||
|
|
||||||
|
* **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.
|
||||||
|
* **Menu** — verified via driver scenarios 2026-10-18 (Refs #23): the menu
|
||||||
|
lists auto-speak/voice/device/player/cap/test-phrase; a voice chosen in the
|
||||||
|
menu is used by later utterances and survives a fresh load via the settings
|
||||||
|
file; a device chosen in the menu appears in the player invocation as
|
||||||
|
`--target <sink>`.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
* Gitea issue #23 on `dank/command-center` (the options-menu work item).
|
||||||
|
|
||||||
|
## 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,25 @@ timestamp: 2026-08-28
|
|||||||
|
|
||||||
# Bundle Change Log
|
# Bundle Change Log
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-10-18 — dankvoice: `/say` options menu with output-device routing (Refs #23)
|
||||||
|
|
||||||
|
Added `/say menu` (bare `/say` now opens it; `help` keeps the text listing), the
|
||||||
|
pi-peon-style settings surface: auto-speak toggle, voice picked from the frank-tts
|
||||||
|
roster, **output device** (PipeWire sinks via `wpctl` → `pw-play --target` /
|
||||||
|
`paplay --device=`), player binary, spoken-char cap, and a "speak a test phrase"
|
||||||
|
utterance that proves the audio path audibly. Choices persist to
|
||||||
|
`~/.omp/cache/dankvoice.json` (`DANKVOICE_CONFIG`); env now wins over that file,
|
||||||
|
which wins over defaults — answering the silent-session incident where
|
||||||
|
PipeWire's remembered Bluetooth route played synthesized answers into nowhere.
|
||||||
|
`detectPlayer` becomes `availablePlayers` + `playerFor`. Tests gain a fixture
|
||||||
|
`wpctl`, a scripted `select` in the driver, and three menu scenarios: 15 green
|
||||||
|
in `tests/dankvoice.bats`, 51 green suite-wide.
|
||||||
|
|
||||||
|
## 2026-10-18 — dankvoice: the cloned voices finally speak in the TUI (Refs #21)
|
||||||
|
|
||||||
|
Nothing in the harness could pronounce them: the TUI vocalizer and `omp say` gate `--voice` against the bundled Kokoro enum (`omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, …`), so frank-tts was reachable only as a file. Added `agent/extensions/dankvoice.ts` — `/say <text>`, `/say on|off` (narrate the final answer at `agent_end`), `/say voice|voices|stop|status` — synthesizing over the OpenAI speech route and playing through `pw-play`/`paplay`/`ffplay`, with the endpoint read from the `api: openai-speech` row of `agent/models.yml` (no host, no secret in the file). Verified live in a real TUI session on the workstation: roster `GET /v1/audio/voices` → `narrator1, narrator2, narrator3`; `/say` produced 99,884 B / 2.08 s and 69,164 B / 1.44 s WAVs handed to the player, temp dirs cleaned afterwards; `/say on` armed the footer (`🔊 narrator1`) and each following finished turn spoke its answer (1.20 s WAV), an unknown voice name answered with the roster instead of speaking, and an `agent_end` carrying `willContinue` (the harness scheduled an auto-retry) is deliberately never narrated. Extension discovery itself proved with a probe file in `~/.omp/agent/extensions/` loaded by a fresh `omp -p` run. Reference page [dankvoice](core/dankvoice.md), registered in the core index; black-box seam `tests/dankvoice.bats` (12 tests).
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -1,44 +1,63 @@
|
|||||||
---
|
---
|
||||||
type: reference
|
type: reference
|
||||||
title: omp Skill Store Wiring
|
title: omp Skill Store Wiring
|
||||||
description: How omp's agent/skills directory is symlinked into the skills CLI global store, and where lockfile provenance lives.
|
description: Division of skill tiers on Dank workstations — skills CLI global store, omp agents provider, and the authored repo tier.
|
||||||
tags:
|
tags:
|
||||||
- skills
|
- skills
|
||||||
- symlinks
|
|
||||||
- provisioning
|
- provisioning
|
||||||
|
- dotfiles
|
||||||
timestamp: 2026-10-18
|
timestamp: 2026-10-18
|
||||||
verified: 2026-10-18
|
verified: 2026-10-18
|
||||||
---
|
---
|
||||||
|
|
||||||
# omp Skill Store Wiring
|
# omp Skill Store Wiring
|
||||||
|
|
||||||
omp is NOT a registered agent in the `skills` CLI (79-agent registry checked in
|
Three tiers, one owner each:
|
||||||
`dist/cli.mjs` 2026-10-18; see [Agent Support](/tools/vercel-labs-skills/agent-support.md)),
|
|
||||||
so `skills add` never writes to `~/.omp/agent/skills/` on its own. The wiring is
|
|
||||||
manual, retrofitted 2026-10-18:
|
|
||||||
|
|
||||||
- **Canonical store:** `~/.agents/skills/<name>/` — real files, written by
|
| Tier | Path | Owner | Content |
|
||||||
`npx skills add <owner>/<repo> --all -g` (universal agent, global scope).
|
|---|---|---|---|
|
||||||
- **Provenance:** `~/.agents/.skill-lock.json` (store lockfile, version 3): per
|
| Canonical store | `~/.agents/skills/` | `skills` CLI | upstream skills, 38 as of 2026-10-18 (all `mattpocock/skills`) |
|
||||||
skill `source` (e.g. `mattpocock/skills`, `vercel-labs/skills`), `skillPath`,
|
| Provenance | `~/.agents/.skill-lock.json` | `skills` CLI | v3 lockfile: `source`, `skillPath`, `skillFolderHash` per skill |
|
||||||
`skillFolderHash`, timestamps. 39 entries as of 2026-10-18: 38 from
|
| Authored | `agent/skills/` (this repo) | Frank, via PR | hand-customized overrides; today only `grilling` |
|
||||||
`mattpocock/skills`, `find-skills` from `vercel-labs/skills`.
|
|
||||||
- **omp tier:** every entry in `agent/skills/` (this repo) is a **symlink** to
|
|
||||||
`~/.agents/skills/<name>` — 39 links. `manage_skill` writes real dirs into the
|
|
||||||
separate tier `agent/managed-skills/`; that tier is untouched by this scheme.
|
|
||||||
|
|
||||||
## Consequences
|
## How omp loads them
|
||||||
|
|
||||||
- `skills update -g` refreshes every skill for omp through the links; the repo
|
omp is NOT a registered agent in the `skills` CLI (79-agent registry checked
|
||||||
records only the link set (which skills are enabled), never content.
|
2026-10-18; see [Agent Support](/tools/vercel-labs-skills/agent-support.md)) —
|
||||||
- A fresh workstation needs two steps: `npx skills add mattpocock/skills --all -g`
|
`skills add` never writes anywhere under `~/.omp`. Instead omp's own `agents`
|
||||||
(+ `find-skills` from `vercel-labs/skills`), then re-create the symlinks;
|
provider reads the store **directly**: `skills.enableAgentsUser` (default true)
|
||||||
`git pull` alone yields dangling links until the store exists.
|
loads `~/.agents/skills/*/SKILL.md`. No per-skill symlinks in the repo — the
|
||||||
- `skills ls` from a repo whose layout contains `agent/skills` mislabels those
|
repo does not mirror the store; it only holds authored overrides.
|
||||||
links as Eve project skills (path collision with Eve's `agent/skills` dir);
|
|
||||||
harmless attribution noise.
|
Precedence (omp `omp://skills.md`): authored `agent/skills/` (native, priority
|
||||||
|
100) beats the agents provider; identical copies collapse by realpath, differing
|
||||||
|
same-named copies survive namespaced (`agents/<name>`) with a startup warning.
|
||||||
|
Therefore: **when authoring an override, remove the skill from the store**
|
||||||
|
(`npx skills remove <name> -g`) so the lockfile stays truthful and no
|
||||||
|
namespaced twin haunts the listing. `grilling` was extracted this way 2026-10-18.
|
||||||
|
|
||||||
|
## Updating upstream skills
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y skills update -g # refreshes everything the lockfile tracks
|
||||||
|
npx skills check # drift report vs GitHub tree SHAs
|
||||||
|
```
|
||||||
|
|
||||||
|
Fresh workstation (omp side is two commands — store restore + authored tier
|
||||||
|
arrives with the repo clone):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y skills add mattpocock/skills --all -g
|
||||||
|
npx -y skills add vercel-labs/skills --skill find-skills --agent universal -g
|
||||||
|
npx -y skills remove grilling -g # authored copy ships in the repo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Managed tier
|
||||||
|
|
||||||
|
`agent/managed-skills/` (auto-learn, priority 5) is separate and untouched by
|
||||||
|
all of the above; authored skills always win name collisions against it.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
* [Lock File](/tools/vercel-labs-skills/lock-file.md) — lockfile format and update checking
|
* [Lock File](/tools/vercel-labs-skills/lock-file.md) — hash-based update checking
|
||||||
* [Installation Scope](/tools/vercel-labs-skills/installation-scope.md) — symlink vs `--copy` methods
|
* [Agent Support](/tools/vercel-labs-skills/agent-support.md) — full CLI agent registry
|
||||||
|
|||||||
Executable
+327
@@ -0,0 +1,327 @@
|
|||||||
|
#!/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 notifications
|
||||||
|
# and select-lists it renders. A fixture HTTP server stands in for frank-tts
|
||||||
|
# (it answers the speech route with a fixed WAV, the roster route with a JSON
|
||||||
|
# array, and everything else with 404), a stub player stands in for pw-play
|
||||||
|
# (it records its full argv and copies the .wav it was given), and a fixture
|
||||||
|
# `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration.
|
||||||
|
#
|
||||||
|
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
|
||||||
|
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink
|
||||||
|
# name the fixture wpctl reports), DV_MODELS (models.yml fixture),
|
||||||
|
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
|
||||||
|
# DV_SELECT (pipe-separated answers fed to ctx.ui.select in order), DV_MODE
|
||||||
|
# (the harness mode reported to the extension).
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||||
|
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' "$*" >> "$DV_STUB_LOG"
|
||||||
|
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done
|
||||||
|
sleep 0.1
|
||||||
|
SH
|
||||||
|
chmod +x "$DV_STUB"
|
||||||
|
|
||||||
|
# the same stub reachable under the canonical player names, plus a fixture
|
||||||
|
# wpctl so output-device enumeration is deterministic on any machine
|
||||||
|
mkdir -p "$WORK/bin"
|
||||||
|
ln -s "$DV_STUB" "$WORK/bin/pw-play"
|
||||||
|
export DV_PWPLAY="$WORK/bin/pw-play"
|
||||||
|
export DV_WPCTL="alsa_output.usb-KT.0"
|
||||||
|
cat > "$WORK/bin/wpctl" <<'WPC'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL"
|
||||||
|
WPC
|
||||||
|
chmod +x "$WORK/bin/wpctl"
|
||||||
|
|
||||||
|
# settings live in an isolated file so menu choices never leak between tests
|
||||||
|
export DANKVOICE_CONFIG="$WORK/settings.json"
|
||||||
|
|
||||||
|
# models.yml fixture: a chat provider first, the speech provider second
|
||||||
|
export DV_MODELS="$WORK/models.yml"
|
||||||
|
cat > "$DV_MODELS" <<'YML'
|
||||||
|
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";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
const scenario = process.argv[2] ?? "speak";
|
||||||
|
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||||
|
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";
|
||||||
|
else process.env.PATH = `${dirname(process.env.DV_STUB ?? ".")}/bin:${process.env.PATH ?? ""}`;
|
||||||
|
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_PLAYER_BIN ?? process.env.DV_STUB;
|
||||||
|
|
||||||
|
const extension = await import(process.env.DV_EXT as string);
|
||||||
|
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
|
||||||
|
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 answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== "");
|
||||||
|
let answerIndex = 0;
|
||||||
|
const ctx = {
|
||||||
|
mode: process.env.DV_MODE ?? "tui",
|
||||||
|
ui: {
|
||||||
|
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
|
||||||
|
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
|
||||||
|
select: (title: string, options: string[]) => {
|
||||||
|
console.log(`SELECT ${title} ${options.join("|")}`);
|
||||||
|
const answer = answerIndex < answers.length ? answers[answerIndex] : undefined;
|
||||||
|
answerIndex += 1;
|
||||||
|
return Promise.resolve(answer ?? options[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const say = commands.get("say");
|
||||||
|
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 "menu":
|
||||||
|
await say.handler("menu", ctx);
|
||||||
|
break;
|
||||||
|
case "empty":
|
||||||
|
await say.handler("", ctx);
|
||||||
|
break;
|
||||||
|
case "devicemenu":
|
||||||
|
await say.handler("menu", ctx);
|
||||||
|
await say.handler("hello world", ctx);
|
||||||
|
break;
|
||||||
|
case "auto":
|
||||||
|
await say.handler("on", ctx);
|
||||||
|
await agentEnd?.(answer("the answer"), ctx);
|
||||||
|
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 (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
|
||||||
|
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
|
||||||
|
server.close();
|
||||||
|
TS
|
||||||
|
|
||||||
|
export DV_DRIVER="$WORK/driver.ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
|
||||||
|
say_run() {
|
||||||
|
run env DV_SELECT="${2:-}" 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"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the menu lists every knob and a plain /say opens it" {
|
||||||
|
say_run menu
|
||||||
|
[[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Speak a test phrase"* ]]
|
||||||
|
[[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]]
|
||||||
|
say_run empty
|
||||||
|
[[ "$output" == *"SELECT dankvoice — options"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a voice picked from the menu is spoken by every later request" {
|
||||||
|
say_run menu "Voice: server default (change)|narrator2"
|
||||||
|
[[ "$output" == *"SELECT dankvoice — voice server default|narrator1|narrator2"* ]]
|
||||||
|
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
|
||||||
|
say_run speak
|
||||||
|
[[ "$output" == *"voice=narrator2 input=hello world"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a device chosen from the menu becomes the player's output target" {
|
||||||
|
DV_PLAYER_BIN="$DV_PWPLAY" say_run menu "Output device: system default (change)|$DV_WPCTL"
|
||||||
|
[[ "$output" == *"SELECT dankvoice — output device system default|alsa_output.pci-0000_07_00.6.HiFi__Speaker__sink|$DV_WPCTL"* ]]
|
||||||
|
[[ "$output" == *"NOTIFY info dankvoice: output device = $DV_WPCTL"* ]]
|
||||||
|
DV_PLAYER_BIN="$DV_PWPLAY" say_run speak
|
||||||
|
[[ "$output" == *"PLAYER --target $DV_WPCTL"* ]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user