Compare commits

...
Author SHA1 Message Date
Frank e4ecc93f29 live: heartbeat sync 2026-10-19T09:17:41-07:00 (Refs #11) 2026-10-19 09:17:41 -07:00
Frank d055eb1e7f live: heartbeat sync 2026-10-19T08:15:41-07:00 (Refs #11) 2026-10-19 08:15:41 -07:00
Frank 70346f3d57 live: heartbeat sync 2026-10-19T07:12:41-07:00 (Refs #11) 2026-10-19 07:12:41 -07:00
Frank 1251cfbd40 live: heartbeat sync 2026-10-19T06:09:41-07:00 (Refs #11) 2026-10-19 06:09:41 -07:00
Frank e67bbd0de0 live: heartbeat sync 2026-10-19T05:07:41-07:00 (Refs #11) 2026-10-19 05:07:41 -07:00
Frank cd20d6ddfd live: heartbeat sync 2026-10-19T04:06:41-07:00 (Refs #11) 2026-10-19 04:06:41 -07:00
Frank d99c1c3c4f live: heartbeat sync 2026-10-19T03:04:41-07:00 (Refs #11) 2026-10-19 03:04:41 -07:00
Frank 01d9d3fbd5 live: heartbeat sync 2026-10-19T02:02:41-07:00 (Refs #11) 2026-10-19 02:02:41 -07:00
dank 570b5509d0 Merge pull request 'dankvoice: humanized narration via @tiny and thinking aloud' (#28) from feat/dankvoice-enhanced-narration into main 2026-10-19 01:57:51 -07:00
Frank 3579c7ebc5 dankvoice: humanize answers via the @tiny role and narrate thinking
Answers spoken with /say were verbatim markdown-cleaned text while the
built-in vocalizer speaks prose rewritten by a small model. Port that
enhancer (bundle Qyr/T6o: same prompt, 1536 tokens, 6 s, 4000-char
window, null-fallback/empty-skip contract) and resolve its model from
modelRoles.tiny exactly as the harness does, so auto-speak sounds like
the enhanced TUI voice instead of a pager. agent_end now also speaks the
last assistant message's thinking blocks as their own unit before the
answer; narration-flagged thinking counts as answer text. Both knobs are
menu-toggleable, persisted, and env-overridable; the fixture gains a
chat route (echo/prose) and seven scenarios cover the round-trip, the
404 fallback, ordering, the kill switches and role resolution (22 green,
58 suite-wide; live smoke through real frank-zealot + frank-tts).

Refs #27
2026-10-19 01:55:57 -07:00
dank 0075ca3bbf Merge pull request 'dankvoice: segmented sequential speech (ported bundle vocalizer)' (#26) from fix/dankvoice-segmented-speech into main 2026-10-19 01:25:03 -07:00
34 changed files with 827 additions and 47 deletions

No files matched your search

+1
View File
@@ -158,6 +158,7 @@ task:
security-reviewer: llamacpp (dankclaw)/frank-dragoon
sonic: llamacpp (dankclaw-3090)/frank-zealot
task: llamacpp (dankserver)/frank-archon
seance: llamacpp (dankclaw-3090)/frank-zealot
eager: default
enableLsp: true
maxRuntimeMs: 1800000
+196 -7
View File
@@ -30,6 +30,11 @@
* DANKVOICE_VOICE voice name ("" = server default)
* DANKVOICE_MAX spoken-character budget per answer (default 700)
* DANKVOICE_AUTO=1 start with auto-speak armed
* DANKVOICE_REWRITE_URL @tiny chat base URL (…/v1) for spoken-prose rewrite
* DANKVOICE_REWRITE_MODEL chat model id (default: the @tiny role's model)
* DANKVOICE_REWRITE=0 disable the rewrite step (speak mechanical text)
* DANKVOICE_THINKING=0|1 narration of thinking blocks (default: on)
* DANKVOICE_OMPCONFIG omp config.yml to resolve the @tiny role from
*
* Long answers are cut into speakable segments by a state machine ported
* from omp's built-in vocalizer: strong boundaries on [.!?…] runs followed
@@ -493,6 +498,122 @@ export function budgetSegments(segments: string[], maxChars: number): string[] {
return chosen;
}
// ------------------------------------------------------------ humanization
//
// Port of the built-in vocalizer's speech enhancer (the `Qyr` class and its
// system prompt `T6o` in dist/cli.js): rewrite each unit into natural spoken
// prose with the @tiny model before synthesis. Same prompt, same budgets
// (1536 max tokens, 6 s, 4000-char input window), and the same contract:
// null means the enhancer failed — fall back to the mechanical text — while
// an empty string means the enhancer found nothing worth saying.
const REWRITE_MAX_TOKENS = 1536;
const REWRITE_TIMEOUT_MS = 6_000;
const REWRITE_MAX_INPUT = 4_000;
const REWRITE_SYSTEM_PROMPT = `Rewrite coding-assistant replies as words a person would say aloud. Audience: developer listening while working; written reply remains onscreen. Narrate; NEVER dictate syntax.
Output ONLY spoken words—no Markdown, quotes, preamble, or stage directions.
- Natural colleague-over-the-shoulder summary. Preserve original meaning, order, tone; add no opinions, greetings, or content absent from text.
- NEVER read URLs, Markdown/table syntax, or separators. Link: label or site name ("the Bun issue on GitHub"). File path: file name only (say "vocalizer.ts," not "vocalizer dot t s").
- Code blocks: NEVER read; replace each with one short clause describing it or its function ("a small helper that retries the request"). Skip it if surrounding prose already explains it.
- Short inline identifiers, flags, commands: speak as-is ("run bun check"); paraphrase if awkward.
- Speak numbers, versions, symbols naturally: "v1.2" → "version one point two"; "→" → "to"; "&" → "and"; "~5s" → "about five seconds".
- Lists: flowing sentences ("first, then, and finally"); NEVER recite bullets or numbers.
- Concise: same length or shorter; compress boilerplate, NEVER pad.
- Partial mid-thought fragments: render only what exists; NEVER invent an ending.
- Pure code, tables, or markup: empty reply.
`;
export interface RewriteTarget {
/** OpenAI-compatible chat completions route of the @tiny model. */
chatUrl: string;
/** Served model id to name in the request body. */
model: string;
}
/**
* Resolve the @tiny role the way the harness does: `modelRoles.tiny` in
* config.yml reads `provider (host)/model:effort`; the provider block in
* models.yml supplies the base URL. Unknown role or provider → undefined.
*/
export function resolveTinyRewriter(modelsYml: string, configYml: string): RewriteTarget | undefined {
const role = configYml.match(/^[ \t]*tiny:[ \t]*(.+?)[ \t]*$/m)?.[1];
if (!role) return undefined;
const ref = role.includes(":") ? role.slice(0, role.lastIndexOf(":")) : role;
const slash = ref.lastIndexOf("/");
if (slash <= 0) return undefined;
const wantProvider = ref.slice(0, slash).trim();
const wantModel = ref.slice(slash + 1).trim();
if (!wantProvider || !wantModel) return undefined;
let inProvider = false;
let base: string | undefined;
for (const line of modelsYml.split(/\r?\n/)) {
const key = line.match(/^ {2}"?([^"\s][^":]*?)"?[ \t]*:[ \t]*$/);
if (key) {
inProvider = key[1].trim() === wantProvider;
base = undefined;
continue;
}
if (!inProvider) continue;
const url = line.trim().match(/^baseUrl:\s*(\S+)/);
if (url) {
base = url[1].replace(/\/+$/, "");
continue;
}
if (line.trim().match(/^-\s*id:\s*(\S+)/)?.[1] === wantModel && base) {
return { chatUrl: `${base}/chat/completions`, model: wantModel };
}
}
return undefined;
}
/** Env override first, then the @tiny role; both miss → no humanization. */
export function resolveRewriter(env: NodeJS.ProcessEnv, modelsYml: string, configYml: string): RewriteTarget | undefined {
const override = env.DANKVOICE_REWRITE_URL?.trim().replace(/\/+$/, "");
if (override) return { chatUrl: `${override}/chat/completions`, model: env.DANKVOICE_REWRITE_MODEL?.trim() || "tiny" };
return resolveTinyRewriter(modelsYml, configYml);
}
/** The enhancer's rewrite step; never throws — null is its "couldn't". */
export async function rewriteForSpeech(target: RewriteTarget, text: string): Promise<string | null> {
try {
const res = await fetch(target.chatUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: target.model,
messages: [
{ role: "system", content: REWRITE_SYSTEM_PROMPT },
{ role: "user", content: text.slice(0, REWRITE_MAX_INPUT) },
],
max_tokens: REWRITE_MAX_TOKENS,
}),
signal: AbortSignal.timeout(REWRITE_TIMEOUT_MS),
});
if (!res.ok) return null;
const payload: unknown = await res.json();
if (!payload || typeof payload !== "object" || !("choices" in payload) || !Array.isArray(payload.choices) || payload.choices.length === 0) return null;
const first: unknown = payload.choices[0];
if (!first || typeof first !== "object" || !("message" in first)) return null;
const message: unknown = first.message;
if (!message || typeof message !== "object" || !("content" in message)) return null;
const content: unknown = message.content;
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const part of content) {
if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
parts.push(part.text);
}
}
return parts.join(" ").trim();
} catch {
return null;
}
}
/** The text the assistant last actually said; tool-only messages do not count. */
export function lastAssistantText(messages: readonly unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
@@ -504,8 +625,12 @@ export function lastAssistantText(messages: readonly unknown[]): string {
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") {
if (!part || typeof part !== "object" || !("type" in part)) continue;
if (part.type === "text" && "text" in part && typeof part.text === "string") {
parts.push(part.text);
} else if (part.type === "thinking" && "narration" in part && part.narration === true && "thinking" in part && typeof part.thinking === "string") {
// provider-returned thinking addressed to the user: it is answer text
parts.push(part.thinking);
}
}
const joined = parts.join("\n").trim();
@@ -514,6 +639,23 @@ export function lastAssistantText(messages: readonly unknown[]): string {
return "";
}
/** The last assistant message's true thinking blocks; narration-flagged ones are answer text elsewhere. */
export function lastAssistantThinking(messages: readonly unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") continue;
if (!("content" in message) || !Array.isArray(message.content)) return "";
const parts: string[] = [];
for (const part of message.content) {
if (part && typeof part === "object" && "type" in part && part.type === "thinking" && !(("narration" in part) && part.narration === true) && "thinking" in part && typeof part.thinking === "string") {
parts.push(part.thinking);
}
}
return parts.join("\n").trim();
}
return "";
}
/** Menu choices, persisted between sessions; every field optional. */
export interface Settings {
voice?: string;
@@ -521,6 +663,7 @@ export interface Settings {
sink?: string;
maxChars?: number;
auto?: boolean;
thinking?: boolean;
}
/** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */
@@ -539,6 +682,7 @@ export function readSettings(path: string): Settings {
if (typeof raw.sink === "string") settings.sink = raw.sink;
if (typeof raw.maxChars === "number" && Number.isFinite(raw.maxChars) && raw.maxChars > 0) settings.maxChars = Math.floor(raw.maxChars);
if (typeof raw.auto === "boolean") settings.auto = raw.auto;
if (typeof raw.thinking === "boolean") settings.thinking = raw.thinking;
return settings;
} catch {
return {};
@@ -568,11 +712,13 @@ export function parseSinkList(output: string): SinkRow[] {
interface Config {
target?: SpeechTarget;
rewriter?: RewriteTarget;
player?: Player;
sink: string;
maxChars: number;
auto: boolean;
voice: string;
thinking: boolean;
}
function lookupOnPath(command: string): string | null {
@@ -589,12 +735,22 @@ function readModelsYml(): string {
}
}
function readOmpConfigYml(): string {
const path = process.env.DANKVOICE_OMPCONFIG ?? join(process.env.HOME ?? "", ".omp", "agent", "config.yml");
try {
return readFileSync(path, "utf8");
} catch {
return "";
}
}
function loadConfig(): Config {
const env = process.env;
const settings = readSettings(settingsPath(env));
const modelsYml = readModelsYml();
const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
const modelOverride = env.DANKVOICE_MODEL?.trim();
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(modelsYml);
const target: SpeechTarget | undefined = overrideUrl
? {
speechUrl: `${overrideUrl}/audio/speech`,
@@ -608,13 +764,17 @@ function loadConfig(): Config {
const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0];
const sink = settings.sink ?? "";
const maxChars = Number(env.DANKVOICE_MAX);
const rewriter = env.DANKVOICE_REWRITE === "0" ? undefined : resolveRewriter(env, modelsYml, readOmpConfigYml());
const thinking = env.DANKVOICE_THINKING === "1" ? true : env.DANKVOICE_THINKING === "0" ? false : (settings.thinking ?? true);
return {
target,
rewriter,
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 ?? "",
thinking,
};
}
@@ -718,6 +878,7 @@ export default function dankvoice(pi: ExtensionAPI) {
sink: cfg.sink,
maxChars: cfg.maxChars,
auto: state.auto,
thinking: cfg.thinking,
};
try {
writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`);
@@ -747,9 +908,12 @@ export default function dankvoice(pi: ExtensionAPI) {
/**
* Speak one answer as strictly sequential requests: synthesize a segment,
* play it to the end, only then ask for the next — audio never stacks.
* Superseded chains (new utterance, /say stop, disarm) exit at the gates.
* Like the built-in enhancer path, each unit (thinking first, then the
* answer) is rewritten into spoken prose by the @tiny model before
* segmentation; a failed rewrite falls back to the mechanical text and an
* empty rewrite skips its unit. Superseded chains exit at the gates.
*/
async function speak(ctx: ExtensionContext, text: string) {
async function speak(ctx: ExtensionContext, text: string, thinking = "") {
if (!cfg.target) {
ctx.ui.notify(missingTarget(), "error");
return;
@@ -760,7 +924,18 @@ export default function dankvoice(pi: ExtensionAPI) {
}
const target = cfg.target;
const player = cfg.player;
const segments = budgetSegments(segmentsForSpeech(text), cfg.maxChars);
const units: string[] = [];
if (cfg.thinking && thinking.trim()) units.push(thinking.trim());
if (text.trim()) units.push(text.trim());
const segments: string[] = [];
let used = 0;
for (const unit of units) {
const rewritten = cfg.rewriter ? await rewriteForSpeech(cfg.rewriter, unit) : null;
if (rewritten === "") continue;
const chosen = budgetSegments(segmentsForSpeech(rewritten ?? unit), Math.max(0, cfg.maxChars - used));
used += chosen.reduce((chars, segment) => chars + segment.length, 0);
segments.push(...chosen);
}
if (segments.length === 0) return;
stop();
const run = state.run;
@@ -798,6 +973,8 @@ export default function dankvoice(pi: ExtensionAPI) {
`Output device: ${cfg.sink || "system default"} (change)`,
`Player: ${cfg.player?.command ?? "none"} (change)`,
`Spoken cap: ${cfg.maxChars} chars (change)`,
`Thinking narration: ${cfg.thinking ? "on" : "off"} (toggle)`,
`Humanize answers: ${cfg.rewriter ? `on (${cfg.rewriter.model})` : "off"} (toggle)`,
"Speak a test phrase",
];
const choice = await ctx.ui.select("dankvoice — options", options);
@@ -854,6 +1031,18 @@ export default function dankvoice(pi: ExtensionAPI) {
persistSettings(ctx);
return;
}
if (choice === options[5]) {
cfg.thinking = !cfg.thinking;
ctx.ui.notify(`dankvoice: thinking narration ${cfg.thinking ? "on" : "off"}`, "info");
persistSettings(ctx);
return;
}
if (choice === options[6]) {
cfg.rewriter = cfg.rewriter ? undefined : resolveRewriter(process.env, readModelsYml(), readOmpConfigYml());
ctx.ui.notify(cfg.rewriter ? `dankvoice: humanizing with ${cfg.rewriter.model}` : "dankvoice: humanization off", "info");
persistSettings(ctx);
return;
}
await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`);
}
@@ -907,7 +1096,7 @@ export default function dankvoice(pi: ExtensionAPI) {
}
case "status":
ctx.ui.notify(
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`,
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars · thinking ${cfg.thinking ? "on" : "off"} · humanize ${cfg.rewriter ? cfg.rewriter.model : "off"}`,
"info",
);
return;
@@ -929,7 +1118,7 @@ export default function dankvoice(pi: ExtensionAPI) {
// Fire-and-forget: a multi-segment utterance legitimately outlives the
// harness's handler timeout, and blocking here would only produce a
// spurious "handler timed out" toast while playback continues anyway.
void speak(ctx, lastAssistantText(event.messages)).catch((error) =>
void speak(ctx, lastAssistantText(event.messages), lastAssistantThinking(event.messages)).catch((error) =>
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error"),
);
});
@@ -0,0 +1,36 @@
---
name: docs-dedupe-verify-campaign
description: "Run an exhaustive docs dedupe + fact-verification campaign in dank/command-center: scout cluster ledgers, evidence-overridden merges, live fleet probes, log compaction law, and landing via Gitea PR."
---
Campaign procedure for deduping + fact-checking `omp-docs/` in dank/command-center (proven on Refs #29, 2026-10-19: 8 commits, -2,229 lines, 0 lint/coverage/bats failures).
## 1. Recon
- Fresh branch off `origin/main` in a new worktree (`git worktree add ~/.omp/wt/<name> -b docs/<issue>-<slug> origin/main`); the session worktree's `git checkout -b` is policy-blocked.
- Run `scripts/kb-lint.sh omp-docs` baseline; write a throwaway index-coverage checker (each dir's `index.md` must link every subdir/file) — mechanical, no scout needed.
- Open the Gitea issue first (multi-moving-parts law).
## 2. Scout fan-out (overlap detection)
- One `task` batch, `agent: scout`, one task per doc cluster + one cross-cutting sweep (cap 2 concurrent; rest queue).
- Each scout writes a ledger to `local://dedupe/<cluster>.md`: inventory table (file, lines, purpose, verdict KEEP/MERGE/DELETE/UPDATE), overlaps, facts-to-keep ledger, inbound links, staleness, claims-needing-verification.
- Scouts are LEADS, not truth. Observed failures: fabricated ledger entries (claims about text that does not exist in any file), a DELETE verdict on a generated artifact that is under test (`tools/niri/keybindings.md` is generated by `scripts/gen-keybinds.sh` and tested by `tests/gen-keybinds.bats` — keep), and a KEEP based on misreading a changelog leftover. Re-derive every consequential verdict from the files/repo yourself.
## 3. Verify fleet facts live (never trust doc dates)
- Read-only BatchMode probes: `ssh -o BatchMode=yes dankclaw '...'` (podman ps/inspect, systemctl status, curl localhost, os-release, cpuinfo). `sudo -n` only has systemctl verbs — use `podman exec <ctr> grep/cat` for in-container files.
- Update docs with dated drift banners ("probed 2026-10-19") for policy divergence; NEVER silently re-apply a policy — flag as operator decision (e.g. SearXNG engine policy).
## 4. Verify external claims via direct web research (no subagents)
- Version/protocol/benchmark claims: primary source only (upstream README, raw.githubusercontent, /api/v1/version, arxiv). If a number cannot be re-attributed to a published benchmark, drop it with an explicit note in the doc.
- Fabrication hunt: invented distros/projects/model names (found: "FreeToken" distro, ornith-llama-cpp-setup tree, Intel CPU on an AMD box) — delete the artifact, not just the line.
## 5. Log compaction law
- Changelog entry = date + what changed + durable lesson + concept-page pointer, few lines. Full narratives live in git (`git log --follow`). Record the law at the top of `log.md`. Never re-expand an entry in place. Keep pi-era entries tagged "(pi era)".
## 6. Gates + landing
- Gate every commit: `bash scripts/kb-lint.sh omp-docs` + index-coverage + `bats tests/` (67 tests). Pre-commit hook prints `error: command not found: caddy` on Caddyfile-touching commits — warning only, commit still lands.
- Gitea API auth: `~/.config/secrets/gitea` is **URL-form** (`http://dank:<token>@host`), not a raw token. Extract: `T=$(sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p' ~/.config/secrets/gitea | head -1)`; raw `cat` as bearer token → 401.
- PR body ends with `---` + `cwd: <wd> · session: <id>` footer; then `xdg-open <PR-URL>` immediately; comment the campaign ledger summary on the issue. NEVER merge without Frank's explicit OK.
## Edit-tool field notes (recurring traps)
- `PUT N+=:` is invalid syntax — it inserts the literal text. One insert-op per edit call; recompute line numbers per call.
- File hash tag changes after every edit — re-`read` before each subsequent edit call to the same file; reusing a stale tag is rejected.
@@ -0,0 +1,85 @@
---
name: docs-refresh-scout-summaries
description: "Refresh a stale documentation cluster without delegating the writing: scout subagents produce per-line verdict tables (holder verified by comma-insensitive grep), the parent makes every edit, and a port-before-cut census proves nothing was lost. Use for dedupe-our-docs, fix-the-stale-runbooks, pointers-instead-of-copied-numbers jobs."
---
# Refresh stale docs with scout summaries, parent-side writes
Use when a cluster of documentation pages is stale and/or restates facts another page owns, and
the fix touches many files. **Scouts read and classify; the parent writes.** Sibling skill
`vault-one-fact-one-owner-dedupe` covers collapsing era pages into owner pages - its section 3
(orphan census), section 7 (edit-tool hazard) and section 9 (verify/PR) apply unchanged.
## Why not writer subagents (measured cost)
Writer agents produce plausible output and cannot be trusted on the one claim that matters - the
owner page already holds this fact. In one run they asserted every cut was grep-confirmed while
FOUR cuts left pointers to pages that did not contain the fact (118->238 GiB, 16,095 tokens,
0.739/0.554/0.427/0.340, PR #23897). Each became a broken promise the next reader trusts.
Classification is the only part that parallelizes; writing is where taste (ownership, era
naming, what counts as a law) lives. So fan out read-only scouts and keep the pen.
If writer agents were already spawned and are no longer wanted: `write agent://all` to stop
them, then `git status` the target tree - `proc://` does not list agent jobs, and a finished
worker may already have edited files.
## Pipeline
1. **Cluster** targets into groups of 3-4 pages sharing subject matter (one cluster per scout).
Know the owner map first (endpoint/service/host pages, plus the repo documentation law).
2. **Wave of 2 scouts** (harness cap is `task.maxConcurrency`, usually 2). Scout contract:
> READ-ONLY, no writes of any kind. Per file, one table row per candidate fact:
> `| lines | verbatim figure + 6 words | holder elsewhere (file:line) or none | VERDICT |`
> Verdicts: `CUT -> [[Owner]]` (holder verified), `KEEP(owned here)` (no holder, this page is
> the record), `PORT(parent)` (rightful owner lacks it - name page and section), `STALE`
> (contradicted by current state; quote the contradicting line), `INDEX` (locator page).
> Verify every holder claim with a COMMA-INSENSITIVE grep: a holder writing 1,350,182
> satisfies a page writing 1350182 and vice versa; 16,095 does NOT satisfy a check for 16095
> unless commas are stripped. Never name a holder line you have not read.
> End with a per-file tally: kept / cut / port / stale.
Put the law, the owner map and the verification rule in the batch-level `context`, never per
task.
3. **Write while the next wave runs**: edit cluster N from wave N, launch wave N+1 in the same
turn. Never launch a wave you have nothing to write.
4. **Verify each CUT yourself** before cutting - read the holder line. A scout row is a
hypothesis, not evidence.
5. **PORT items are your edits**: add the fact to the owner page section (Eras row / Laws / spec
table), then cut it from the technique page in the same commit.
6. **STALE items: check live state**, not another document - `ssh <host> systemctl is-active
<svc>`, the endpoint `/health`, the env file. Then rewrite with a date, or drop transient
live state an owner page already records.
## The ownership test that stops wasted ports
Two pages carrying one figure is the defect - a figure with exactly one holder is fine wherever
it lives. A technique page that is the sole record of a campaign owns that campaign's arm
tables; do not port such ledgers into an endpoint Eras table. Write the clause into the repo
documentation law so the next pass stops porting single-owner facts. Corollary: a worked example
may repeat a live number if it declares itself dated and names the owner (a snapshot, not fleet
state - recompute from the live files).
## Census after each cluster (catches your own slips too)
Run the sibling skill's section 3 census over `git diff -U0` for the cluster (deleted lines x
distinctive numeric keys vs the whole vault **plus AGENTS.md**), comma-insensitively; drive the
count to zero or list each accepted loss in the commit message.
```python
flat = corpus.replace(',', '')
pres = lambda n: n in corpus or n.replace(',', '') in flat
toks = lambda l: [n for n in re.findall(r'\d[\d,]*(?:\.\d+)?', l)
if len(n.replace(',', '').split('.')[0]) >= 4
or (len(n.split('.')[-1]) >= 2 and len(n.replace(',', '')) >= 5)]
orphan = [l for l in deleted if toks(l) and not any(pres(n) for n in toks(l))]
```
## Verify and land
- Vault link seam must print `0 unresolved links`; hard-wrapped `[[Two Word Page]]` links split
across lines are the usual cause - unwrap them.
- Full suite once at the end, not per cluster; a docs seam running while scouts are live may
read half-written files, so re-run after the last edit.
- Commit per cluster (docs-only means **no deploy**); push, open the PR through the API with a
600-perm curl config, `xdg-open` the PR URL immediately.
@@ -0,0 +1,62 @@
---
name: fleet-service-runtime-census
description: "Answer \"what is service running right now?\" for a dank-fleet service: live endpoint probe plus host units, image ids, live-vs-repo env, and drop-ins."
---
# Fleet service runtime census
Answer the question "what is `<service>` running currently?" with evidence, not from docs. Docs say what *should* run; the host says what *does*. Run all five probes, then report one table.
## 1. Probe the published port (works from any LAN box, no ssh)
```bash
curl -s -m 10 http://<host-ip>:<port>/health; echo
curl -s -m 10 http://<host-ip>:<port>/v1/audio/voices # per-endpoint extras: /v1/models, /metrics
```
The extra route matters more than `/health` for identity claims:
- llama.cpp and radiance **echo the requested model name** — a 200 proves nothing; read `/v1/models`.
- halogen 404s `/completion`, `/ping`, `/props`, `/tokenize`.
- The frank-tts façade answers `/health` **without dialing the engine**, so "façade healthy, engine down" is a real state; the voice list in that same response is the honest census.
## 2. Units + containers on the host (ssh as the deploy user, no sudo)
```bash
ssh <host> 'systemctl --user status <svc>.service <engine>.service --no-pager \
| grep -E "^(●| Active| Loaded)"; \
podman images --format "{{.Repository}}:{{.Tag}} {{.ID}} {{.Created}}"; \
podman ps -a --format "{{.Names}}\t{{.Status}}\t{{.Image}}"'
```
- `active (running) since …` + `Up N hours (healthy)` is the answer; `activating (auto-restart)` with a ~50 ms exit is a **preflight refusal**, not an engine fault.
- **NEVER prefix these podman calls with `sudo`.** Containers live in the deploy user's rootless storage; `sudo podman inspect frank-tts-engine` returns `no such object` while the plain form succeeds. Treating that as "container missing" is a false finding.
## 3. Drop-ins — the override that no health gate sees
```bash
ssh <host> 'systemctl --user show <svc>.service -p DropInPaths; \
systemctl --user show <engine>.service -p DropInPaths'
```
`DropInPaths=` must be empty. A leftover bench drop-in (`Environment=FRANK_<SVC>_ENV_FILE=/tmp/<arm>.env`) makes the next deploy boot a different model at a different shape while `/health` still answers 200 and the e2e gate passes.
## 4. Live env vs repo env — the only truth test
```bash
# repo side (local checkout)
grep -vE '^\s*#|^\s*$' scripts/<svc>.env
# host side
ssh <host> 'sudo cat /home/dank/serverAppConfigs/scripts/<svc>.env' | grep -vE '^\s*#|^\s*$'
```
Diff them. Per repo law the env file beside the serve script **is** the authority; a doc page that disagrees with it is the bug, and host-side drift means an uncommitted edit. Also check `git log --oneline -3 -- scripts/<svc>.env` to confirm the host isn't ahead of git.
## 5. Report
Table of: unit state + since, image repo:tag@short-ID + age, backend/alias actually served (from step 1, not from the name), drop-ins, and whether live env == repo env. State explicitly when a fact came from docs rather than from a probe.
## Traps that produce false findings
- Digest-pinned images: the local image ID's *creation date* answers "is this stale" better than the tag; `latest` + `Pull=never` is a locally built image, not a registry pull.
- `ssh` user context changes what podman sees (root vs rootless) — see step 2.
- Two ports on one host are two providers in `models.yml`; a port mismatch is a rename or drift, not a config typo.
@@ -1,6 +1,6 @@
---
name: gitea-api-pr-operations
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."
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, where review comments actually live on 1.27.3, and how to recover the session id for the mandatory body footer.\""
---
# Gitea API PR operations (Dankclaw `dank/command-center`)
@@ -25,11 +25,13 @@ description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea A
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}'`.
`mergeable:false` is often Gitea's *not-yet-checked* state, not a conflict — prove a real
conflict locally with `git merge-tree --write-tree HEAD origin/main` before believing it.
- **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).
`{"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`
@@ -51,6 +53,13 @@ description: "Open, list, merge, and diagnose issues/PRs on the Dankclaw Gitea A
Every issue/PR body ends with `---` line then `cwd: <wd> · session: <id>`. Keep
it inside the --arg string (not a `---` CLI arg).
`cwd` is THIS session's own working directory (note a build worktree separately if they
differ). When `/status` is unreachable (headless/subagent context), recover the session id
from the session store instead of inventing one:
`ls -t ~/.omp/agent/sessions/<cwd-slug>/ | head -2` → `<timestamp>_<uuid>` (the same uuid as
`<uuid>.jsonl`); the slug is the cwd with `/` replaced by `-`. Subagent files live under the
**parent** session's directory, so the newest entry there is the parent id.
## Gotchas
- Content-Type: POSTs with `-d @file.json` MUST carry
`-H 'Content-Type: application/json'` on the command line. `-H` entries put in
@@ -0,0 +1,38 @@
---
name: omp-bundle-implementation-port
description: "Extract and port a working implementation from omp's minified dist/cli.js bundle (copy-not-invent requests); anchors, python slicing, regex-constant sweep, trace-driven test prediction."
---
# Porting built-in omp behavior out of the minified bundle
Use when Frank says "copy the omp implementation / work off known-working code" — i.e. port a built-in feature (vocalizer segmentation, retry logic, formatting rules) into our own file instead of hand-rolling a heuristic. For merely *auditing behavior claims*, use `omp-bundle-claim-audit`; this skill is extraction-for-porting.
Bundle: `~/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js` (minified; single-letter names, private `#x` fields survive).
## Extraction recipe
1. **Find the setting/entry strings first.** `grep -o 'ce({id:"<area>[^"]*"[^}]\{0,200\}' cli.js` — setting descriptions state semantics verbatim (e.g. `speech.enhanced` = "Rewrite assistant output into natural spoken prose with the tiny/smol model"). Descriptions are quotable ground truth.
2. **Anchor to code with a distinctive token**, not guesses: a method name (`pushDelta`), a state init, or a string. Then slice generously with python (bash heredoc), NOT grep -o which truncates:
```python
src = open('cli.js').read()
i = src.index('pushDelta'); print(src[i-1500:i+3000])
```
3. **Follow the constructor**: pipeline classes instantiate collaborators there (`this.#t ??= new fHe` → `fHe` is the real worker; `#g()` returned `{speakable:new fHe, pending:[]…}`). Dump `class fHe{` +4000 chars.
4. **Regex/helper sweep**: minified code declares shared constants in a trailing `var a,b,c,…` list and initializes them elsewhere (`Xyr=`, `Pwc=function`). Loop the names with `re.search(rf'{name}=', src)` and print ~400 chars each — the actual boundary/cleanup rules live in these one-liners.
5. Write slices to `/tmp/*.txt` and `read` them; don't eyeball megabyte greps.
## Port rules
- Rename fields/methods to readable names but keep logic **1:1** — the value is behavioral fidelity (Frank's "known-working set of code").
- Private `#x` fields port cleanly: TS classes in Bun extensions support `#`.
- Global-regex `.lastIndex=0` before each scan loop — minified code relies on it; keep it.
## Verification shortcut
Before running tests, **hand-trace** each test input through the ported rules (boundary indices, state transitions) and predict exact segment counts/strings; then run — predicted-vs-actual mismatch localizes port errors faster than test output alone. Worked example (2026-10-19, dankvoice PR #26): traced `"First stop here. Second one goes!!! Really? Third ... done"` → predicted 3 segments incl. `"Third ... done"` merged via flush (10-char boundary < 24-char started-min) → bats green first run.
## Known anchors (2026-10-19 bundle)
- Speak-aloud vocalizer pipeline: class `I6o` (gating on `speech.enabled`, suspend counter, enhancer, `#g()` per-message state).
- Utterance segmenter: class `fHe` + helpers `Pwc` (line-head classify), `Iwc`/`_wc`/`b6o`/`w6o` (boundary search), `Cwc`+`y6o` (cleanup: links→label, URL→host, path→basename). Regex consts: `Xyr` sentence-end runs, `pHe` weak breaks, `mwc` abbreviations, `hwc` undecided line-heads, `g6o` horizontal rules.
- Port already living in `agent/extensions/dankvoice.ts` (`SpeechSegmenter`) — when the bundle updates, re-extract `fHe` and diff against it before assuming our copy still matches.
@@ -0,0 +1,44 @@
---
name: strix-halo-npu-readiness-census
description: "Engine-agnostic census of whether an AMD Strix Halo / Ryzen AI host can run anything on its XDNA2 NPU: driver bind, firmware, device perms, IOMMU, distro XRT, missing AIE compiler, fabric clock, RAM headroom."
---
Use when asked "can we run <engine/model> on the Strix Halo / Ryzen AI NPU?" for a runtime that is **not** halogen (halogen has its own gate + vendor catalog: use `halogen-npu-feasibility-census` for that). This answers only: is the *host* able, and what is missing.
## Why it is usually the wrong door first
XDNA2 executes **precompiled AIE partition programs** loaded through XRT → `amdxdna`. There is no generic tensor dispatch: an engine built on ggml/llama.cpp (or any GGUF loader) cannot "enable" the NPU — the port must be a re-export + vendor compile, not a flag. So pair this census with the accelerator the same engine *can* select by build flag (Vulkan/HIP on the gfx1151 iGPU) before proposing NPU work.
## Census (all read-only, over ssh from the operator box)
```bash
ssh <host> '
cat /proc/cmdline; ls /sys/kernel/iommu_groups | wc -l # iommu=pt required; groups==0 => IOMMU off => NPU dead
lsmod | grep -i amdxdna # in-tree driver
sudo dmesg | grep -iE "xdna|npu" | tail # "Initialized amdxdna_accel_driver <ver> for 0000:c6:00.1"
lspci -nn | grep -iE "npu|processing" # Strix Halo = 1022:17f0
ls /sys/bus/pci/drivers/amdxdna # must list the BDF
ls -l /dev/accel # accel0, root:render 660
id -nG <deploy-user> | tr " " "\n" | grep -E "render|video" # rootless podman needs render, else --device fails at create
find /usr/lib/firmware -iname "*npu*" | head # firmware is /usr/lib/firmware/amdnpu/<devid>_<rev>/npu.sbin*.zst — match the PCI rev
dpkg -l | grep -iE "xrt|xdna"; ls /opt/xilinx/xrt/lib 2>&1 # distro libxrt-npu2 = XDNA2 runtime; vendor /opt/xilinx/xrt usually absent
apt-cache policy libxrt2 libxrt-npu2 | grep Candidate
apt-cache search onnxruntime | head # distro ONNX RT exists; the Ryzen AI EP does not
python3 -c "import onnxruntime" 2>&1 | tail -1 # absence of the EP + of ryzenai-compile is the real blocker
for c in /sys/class/drm/card*/device; do echo "-- $c"; cat $c/power_dpm_force_performance_level 2>&1; cat $c/pp_dpm_fclk 2>&1 | tr "\n" " "; echo; done
nproc; free -g | head -2 # RAM headroom + thread count the engine will pick
'
```
## Reading it
- Driver bound + `accel0` + firmware dir + `iommu=pt` ⇒ **kernel side is ready**; do not re-litigate "is the NPU disabled". A missing device node is *not* the modern failure mode.
- `auto` on `pp_dpm_fclk` / `power_dpm_force_performance_level` ⇒ fabric clock **unheld**. GPU+NPU concurrently with a moving fclk hangs these hosts and corrupts NPU results; any NPU plan must pin the top fclk step (or run in a container holding it) first.
- No `ryzenai-compile`/ONNX Ryzen AI EP ⇒ nothing on the host can produce a partition program. That is the enablement delta, and it is a toolchain install + a model-specific export/quantize/compile project, not a config change.
- `free` headroom matters: NPU partitions want a few GB of host RAM each and can log benign `Failed to alloc dev bo memory, ret -28` under pressure.
## Laws / traps
- Read state only. Never probe or load the NPU beside a live inference service — stop + mask the service first (fleet benching policy); this host class has a precedent of a concurrent GPU/NPU experiment needing a power cycle.
- `sudo podman ...` finds nothing (per-user rootless storage); run podman as the deploy user.
- `/dev/accel/accel0` existing does not mean a context/PASID can be created; with IOMMU off the failure is at context creation, not at the node.
@@ -0,0 +1,152 @@
---
name: vault-one-fact-one-owner-dedupe
description: "\"Collapse per-payload/per-attempt vault pages into one owner page per endpoint without losing a fact: port-before-cut orphan census, doubled-page repair, trash-aware tools, delegated-write verification, index regeneration, and the merge-resolution order.\""
---
# One fact, one owner: dedupe the vault without losing a fact
Use when a repo has accumulated one page per attempt/era (runbooks, bench logs, swap
narratives) and the law is **one page owns one thing**. Applies to
`serverAppConfigs-docs/` (Obsidian vault, `AGENTS.md` → *Documentation Policy*), but the
mechanics are general.
## 1. Decide ownership before touching files
- Endpoint/service page owns: current shape, **Eras** table (window | payload | runtime |
shape | replaced because | rollback), **Laws**, wire contract, files.
- Technique page owns the mechanism and the arithmetic — **no per-endpoint throughput
table or boot ledger**.
- `log.md` owns incidents and laws only. A swap is an Eras row, never a log entry.
- `AGENTS.md` carries operator-critical numbers + cross-border laws; everything else links.
- A **retired** endpoint still gets one locator page: era windows quoted verbatim from each
era page's own banner, no numbers restated, and any date conflict recorded as an open
question instead of silently resolved. Never pick a winner between two primary sources.
## 2. Single-writer rule (the mistake that costs the most)
Facts get **ported into the owner page before they are cut** anywhere else. So while any
subagent may port facts, only ONE writer may touch the owner pages. Spawn children with an
explicit `DO NOT EDIT <owner pages>` and require a `needsPort` list in their report instead
of letting them append. Send the narrowing message early — a child already editing them has
to be reconciled by hand.
## 3. Port-before-cut census (proves nothing was lost)
For every deleted line, extract distinctive keys and check them against the vault minus the
file being rewritten. Any line with **no** surviving key is an orphan → port, or accept
deliberately and say so. Use `scripts/port-census.py <new-dir> <git-HEAD:path|file>` (numbers
with ≥ 4 int digits, decimals, flags, paths, backticked spans, `--`, ALL-CAPS, or ≥ 2 hyphens).
Two calibration traps:
- **Prose words are not facts.** A hyphen or trailing period inside a word (`long-standing`,
`deployed:`) was counted as a tracked token while real identifiers hid behind it; that noise
buried 300 real lines under 979 fake ones. Strip punctuation, require fact shape.
- **The corpus decides the question.** Corpus = the new file only → "what did this rewrite
drop?" Corpus = whole vault → "is the dropped fact orphaned repo-wide?" Ask one at a time.
Also grep `AGENTS.md` separately — a fact it carries is not an orphan.
## 4. When a page is the same page twice
Signature: a page whose `wc -l` is ~2× its `HEAD` size, with a **second frontmatter block in
the body** whose closing `---` is glued onto the end of a body/timestamp line. Cause chain: a
frontmatter rewrite that re-reads the file it just wrote (so its own output becomes the body),
then a fence-repair pass keyed on `line == "---"` that cannot see a glued fence and inserts a
fence mid-page, so the next pass treats the tail as a new document.
Un-double rule that worked: find the first frontmatter's close (accepting a glued fence), then
the first `type:` line after it that has more keys (`title|description|tags|timestamp`) behind
it — that is the second copy, and a stray `type:` in a code block has no key run. Keep the copy
whose body is a **superset** (compare after stripping the dropped copy's own frontmatter),
un-glue the fence, and prove `0` census losses before writing. Refuse to guess: if the halves
differ materially, stop.
## 5. `.AGENTTRASH` is not vault content
Every tool that walks the vault must exclude it — **both the scan set and the resolution
index**. The link checker resolving links *into* the trash turns "live page points at a
deleted page" into a false PASS, which is worse than a false alarm. The `docs_completeness`
seam's `find` must carry `-not -path '*/.AGENTTRASH/*'` in both walks, or a trashed page
demands an index row and a trashed page can satisfy the docs requirement for a live service.
Trash is gitignored; move with plain `mv` into the dated dir. `git mv` into an ignored path
stages a rename the next agent cannot see (`git restore --staged <src> <dst>`). Never `rm`,
never `git clean -f`.
## 6. Rewire + regenerate indexes mechanically
```bash
grep -rnoE "\[\[(page-a|page-b|...)\]\]" --include="*.md" serverAppConfigs-docs | grep -v AGENTTRASH
grep -rnoE "\((page-a|page-b|...)\.md\)" --include="*.md" serverAppConfigs-docs | grep -v AGENTTRASH
```
Both wikilinks and markdown path refs must be repointed (Research `See Also` lists and bench
pages are where path refs hide). Then **regenerate** index rows with a script, never by hand.
The generator bug to check for: presence tested as a **bare stem substring** — a page whose
name prefixes another's (`frank-medium` inside `frank-medium-rocmfpx`) then silently never
gets a row. Test the wikilink form `[[<stem>]]`.
## 7. Enforce the law with a seam
`tests/docs_completeness.bats`: derive the live set instead of hardcoding it — an endpoint is
live when `scripts/` holds **both** `<name>.env` and `<name>.service`; fail if any
`<name>-*.md` page exists, and guard against a vacuous derivation (`derived -ge 3`). A
re-created era page then cannot pass tests. For the length hint, prefer **derived** exemptions
(live endpoint, or a `retired-endpoint` tag: a campaign ledger that exists nowhere else is
owned by the page that measured it) plus one documented per-page opt-out; never let a size
ceiling force a ledger to be split across two owners.
## 8. Edit-tool hazard (costs the most time)
Body rows beginning with `-` or `+` are op markers. The documented `+-` escape **writes a
literal `+`** — do not use it. Safe rewrites inside an existing bullet: keep the `- ` opener
line untouched and PUT only the indented continuation lines, or convert the note to a nested
bullet whose body lines start with two/four spaces. If a wrapped `[[Two Word Page]]` shows up
as an unresolved link, the target was hard-wrapped across lines — unwrap it.
## 9. Delegated writes: verify bytes, never narrative
Measured in one campaign, all four failure modes live:
- A subagent killed by the runtime limit yielded a 495-line owner page, a verified era table
and a byte census — **the file did not exist** and its table was self-referential.
- Another wrote its output to disk and died without reporting, leaving a rewritten `log.md`;
the census showed it had dropped laws held nowhere else.
- Verdict tables claimed "0 sole records / no contradictions" while missing the contradiction
sitting in their own evidence row, and invented bullet IDs — one renamed a bullet to the
**opposite** claim.
- A scout called accurate docs "WRONG" because the docs said `wip: "down"`.
Rules: after ANY delegated write run `wc`/`ls` + the orphan census yourself; re-grep every
claimed holder before cutting a line; treat `cancelled`/timed-out yields as unverified no
matter how concrete they read; and re-read the source bullet before trusting a table's ID.
## 10. Merge order (a long-lived branch under a moving main)
1. Commit your work in atomic groups **before** merging (a dirty tree cannot merge cleanly).
2. Inspect the incoming commits: `git log --oneline --stat HEAD..origin/main`. If they modify
a page you deleted, **port their additions into the owner page**, then merge and resolve
the modify/delete as `git rm`.
3. Resolve a content block with `write conflict://<id>` (`@ours` / `@theirs` / explicit text),
then **`git add` the file** — resolving content does not stage it.
4. **Re-probe live state after the merge, before you write any number.** A campaign that
reported fleet shapes at the start was made stale by a mid-campaign reslice on main: probe
`/slots` and `tr '\0' '\n' < /proc/$(pidof <engine>)/cmdline` again at the end, and let the
live answer beat both your earlier probe and the docs.
5. `git * --no-verify` is forbidden here. Sweep dead paths in `AGENTS.md`
(`grep -oE "[A-Za-z0-9._-]+\.md" AGENTS.md | sort -u`, then `find` each; hard-wrapped names
are false positives — confirm with `ls`).
## 11. Verify, open, land
- `./tests/run.sh` (full suite) + `python3 scripts/check-vault-links.py serverAppConfigs-docs`
→ must print `0 unresolved links`, and the OKF linter `0 violations`.
- Env trims: prove value-neutrality mechanically (`git show HEAD:scripts/<svc>.env | grep -E
'^[A-Z_]+=' | sort` vs working copy, diff empty). **`scripts/<svc>.env` is a deploy-planner
path**: a comment-only trim makes the next service-scoped deploy restart a healthy endpoint.
- Open the PR through the API with a 600-perm curl config (managed skill
`gitea-api-pr-operations`), end the body with the `cwd:` / `session:` footer, then
`xdg-open <PR-URL>` the instant it exists. Never merge without Frank's current explicit OK.
- Docs-only change ⇒ **no deploy**. Fast-forward the deploy checkout only
(`git -C ~/Projects/serverAppConfig merge --ff-only origin/main`).
- Say what was NOT done, in the PR body: soft lint tallies left (missing `## Citations`),
conflicts left standing, repo config that was never installed on a host.
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792395788647,
"lastConsolidatedAt": 1792398332410,
"lastResult": {
"at": 1791599865775,
"sessions": 1,
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792386375195
"lastConsolidatedAt": 1792401025778
}
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792396789735
"lastConsolidatedAt": 1792398289746
}
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1792396748497,
"lastConsolidatedAt": 1792400853634,
"lastResult": {
"at": 1792393886960,
"sessions": 1,
@@ -1,8 +1,8 @@
{
"v": 1,
"lastConsolidatedAt": 1792396722373,
"lastConsolidatedAt": 1792400453482,
"lastResult": {
"at": 1792396071933,
"at": 1792398531282,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
@@ -0,0 +1 @@
{"v":1,"kind":"product_decision","statement":"Documentation links to @~/Projects/serverAppConfig/serverAppConfigs-docs/","source":"explicit_user","evidence":"correct our documentation to just link to @~/Projects/serverAppConfig/serverAppConfigs-docs/","friction":{"corrective":false,"regression":false,"subtle":false},"sessionId":"01a1531d-3e48-70b0-8ee0-6d48a197cae3","ts":1792397374946}
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792396749853
"lastConsolidatedAt": 1792397349857
}
@@ -0,0 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792426412619,
"lastResult": {
"at": 1792401456699,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
}
}
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1792398333838
}
@@ -0,0 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792426569902,
"lastResult": {
"at": 1792402864677,
"sessions": 1,
"deltas": 8,
"model": "frank-zealot"
}
}
@@ -0,0 +1,10 @@
{
"v": 1,
"lastConsolidatedAt": 1792426408958,
"lastResult": {
"at": 1792401394555,
"sessions": 1,
"deltas": 1,
"model": "frank-zealot"
}
}
+5 -5
View File
@@ -158,13 +158,13 @@ providers:
# contextWindow is the PER-SLOT window: llama.cpp divides --ctx-size
# across slots and answers a longer prompt with a 400, never a
# truncation, so this value must follow every reslice (live now:
# 2 x 262,144 over exact f16 KV, 68afc6d).
contextWindow: 262144
# 3 x 200,704 over f16 K + q8_0 V with the MTP drafter armed, ba69ea5).
contextWindow: 200704
# Prompt + budget must fit inside the slot window; thinking counts
# against the budget. Compaction fires at 85 % of the window =
# 222,822 est; worst-case prompt + budget ≈ 222,822 × 1.03 +
# 16,384 ≈ 245,891 ≤ 262,144. This endpoint is the fleet's
# compaction hub: the widest window in the fleet.
# 170,598 est; worst-case prompt + budget ≈ 170,598 × 1.03 +
# 16,384 ≈ 192,100 ≤ 200,704. This endpoint stays the fleet's
# compaction hub: still the widest window in this file.
maxTokens: 16384
reasoning: true
input: [text, image]
@@ -275,13 +275,13 @@ transition = [ "fade" ]
interval_seconds = 1200
[wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
[wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
[wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_y8ewzk.jpg"
[wallpaper.monitors.DP-2]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_oxex69.jpg"
@@ -290,7 +290,7 @@ transition = [ "fade" ]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_v9e2w3.jpg"
[wallpaper.monitors.eDP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_nzz3gy.jpg"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven_ymp267.jpg"
[weather]
unit = "imperial"
+67 -19
View File
@@ -17,9 +17,11 @@ timestamp: 2026-10-19
`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)).
speakers and `/say on` narrates the assistant's thinking and final answer
after every turn. The words are first rewritten into natural spoken prose by
the **@tiny** role model (exactly what the built-in vocalizer's
`speech.enhanced` does), and everything is served by **frank-tts**
(Dankclaw :7772, the `speech` model role in [agent/models.yml](/core/index.md)).
## Why it exists
@@ -39,13 +41,13 @@ local speech and the cloned voices carry the real narration.
| Command | Effect |
|---|---|
| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, test phrase |
| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, thinking-narration toggle, humanize toggle, test phrase |
| `/say <text>` | synthesize once and play; interrupts whatever is playing |
| `/say on` \| `/say off` | arm/disarm auto-speak of the assistant's final text at `agent_end`; the footer shows `🔊 <voice>` while armed |
| `/say on` \| `/say off` | arm/disarm auto-speak of the thinking blocks and final text at `agent_end`; the footer shows `🔊 <voice>` while armed |
| `/say voice <name>` | pick a voice; an empty name defers to the server's own default |
| `/say voices` | list what frank-tts reports |
| `/say stop` | cut the current utterance |
| `/say status` \| `/say help` | endpoint, voice, player, device, cap |
| `/say status` \| `/say help` | endpoint, voice, player, device, cap, thinking, humanize |
Menu choices persist to `~/.omp/cache/dankvoice.json` (runtime state, never
tracked); the environment overrides the file, the file overrides defaults.
@@ -54,7 +56,11 @@ 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. A long answer is never one monolithic synthesis request; see
silent. Each spoken unit — thinking first when narrated, then the answer —
is first rewritten into spoken prose by the @tiny model; when the enhancer
(below) declines, the text goes to segmentation as-is, where fenced code and
tables vanish, links collapse to their label and bullets and table pipes
disappear. A long answer is never one monolithic synthesis request; see
[Segmentation](#segmentation) for how utterances are cut and played.
## Segmentation
@@ -85,7 +91,8 @@ the harness's 30 s handler timeout — the old awaiting design produced a
`/say stop`, or disarming auto-speak retires the live chain through a run
token: the current player is killed and pending segments are skipped.
`DANKVOICE_MAX` is a per-answer character budget: the leading whole
segments that fit are spoken, the rest are dropped.
segments that fit are spoken, the rest are dropped — the thinking unit
draws on it before the answer does.
## Endpoint resolution
@@ -102,9 +109,14 @@ resolves, so no host address and no credential appears in this file. From
| `DANKVOICE_CONFIG` | `~/.omp/cache/dankvoice.json` | persisted menu choices (voice, player, sink, cap, auto) |
| `DANKVOICE_VOICE` | empty (server default) | default voice name |
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
| `DANKVOICE_MAX` | `700` | spoken character budget per answer (whole leading segments) |
| `DANKVOICE_MAX` | `700` | spoken-character budget per answer (whole leading segments, shared by the thinking and answer units) |
| `DANKVOICE_PLAYER` | detect | player binary, skipping detection |
| `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from |
| `DANKVOICE_REWRITE_URL` | the @tiny role | chat base URL (`…/v1`) for the enhancer, bypassing role resolution |
| `DANKVOICE_REWRITE_MODEL` | the @tiny role's model | chat model named in the rewrite body |
| `DANKVOICE_REWRITE=0` | enhancer on when a route resolves | speak mechanical text |
| `DANKVOICE_THINKING` | settings file, default on | `0`/`1` force thinking narration off/on |
| `DANKVOICE_OMPCONFIG` | `~/.omp/agent/config.yml` | YAML to read the `modelRoles.tiny` role from |
Player detection order is `pw-play` → `paplay` → `ffplay` (PipeWire first:
the workstations run WirePlumber). A saved output device routes the player
@@ -115,7 +127,29 @@ 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, 2026-10-19)
## Humanized narration (the @tiny role)
The built-in vocalizer never reads answers aloud verbatim when
`speech.enhanced` is on: a small model rewrites each utterance into words a
person would say. `dankvoice` ports that enhancer — same system prompt
(verbatim from the bundle), same budgets (1536 max tokens, 6 s timeout,
4000-char input window), same contract (a failed rewrite falls back to the
mechanically cleaned text; an empty rewrite skips the unit as "nothing to
say"). The target model is the harness's `@tiny` role, resolved the way the
harness resolves it: `modelRoles.tiny` in `agent/config.yml` reads
`provider (host)/model:effort`; the provider block in `agent/models.yml`
supplies the `baseUrl`; the request is `POST {base}/chat/completions`. On
this fleet that is **frank-zealot** (`llamacpp (dankclaw-3090)`,
`:7771`) — the 6 s abort keeps a slow tiny model off the critical path.
Thinking blocks ride the same path: at `agent_end` the last assistant
message's true `thinking` parts are narrated (rewritten first, as their own
unit, before the answer), while blocks flagged `narration: true` — which
providers use to address the user through the thinking channel — count as
answer text. The menu, `/say status`, and `DANKVOICE_THINKING` toggle it;
the choice persists in the settings file.
## Verified facts (2026-10-18, extended 2026-10-19)
* **Extension discovery** — any `.ts` file in `~/.omp/agent/extensions/` loads
at startup: a probe file there printed `loaded 2026-10-19T02:36:55Z` from a
@@ -152,7 +186,16 @@ its directory are removed when the player process exits.
requests and 3 plays; "Model 3.5 ships today. Version 1,5 too." produced
exactly 2 (decimals never split); the markdown fixture's fenced code and
URL never reached the fixture endpoint; with `DANKVOICE_MAX=60` only the
leading segment was spoken. `bats tests/dankvoice.bats` 17/17.
leading segment was spoken.
* **Humanize + thinking (2026-10-19, Refs #27)** — driver scenarios cover the
rewrite round-trip (fixture chat route), the 404 → mechanical fallback,
thinking narrated as its own unit before the answer, `DANKVOICE_THINKING=0`
silencing it, and `narration: true` spoken once as the answer; the endpoint
status line shows the resolved humanize model from the @tiny role. Live
smoke against the real services: a markdown `/say` became 3 sequential
prose segments (mechanical cleanup of that text yields at most 2), and a
thinking+answer `agent_end` played thinking first. 22 green in
`tests/dankvoice.bats`.
## Limits
@@ -160,8 +203,13 @@ its directory are removed when the player process exits.
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.
* `agent_end` narrates thinking blocks and the final message only — mid-turn
tool chatter is never spoken. Thinking covers the last assistant message;
tool-loop gaps in earlier messages are skipped by design.
* Humanized narration is serial: audio starts only after the @tiny rewrite
returns (≤ 6 s per unit, then frank-tts synthesis). Say `/say off`-grade
latency is the price of prose; `DANKVOICE_REWRITE=0` trades it back for
instant mechanical speech.
* frank-tts is LAN-only: no Caddy vhost fronts :7772, so this extension is
usable on the home network.
* Deployment, packs and the voice registry are owned by the
@@ -176,14 +224,14 @@ its directory are removed when the player process exits.
* `omp --help` (extension discovery flags, `say`, `plugin`) and
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts`
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
* The stock speak-aloud implementation — vocalizer pipeline `I6o` and
utterance segmenter `fHe` — in `@oh-my-pi/pi-coding-agent`'s
`dist/cli.js`, read off the minified bundle 2026-10-19; the enhanced
rewrite ("with the tiny/smol model before synthesis") is that bundle's
own `speech.enhanced` description. dankvoice ports the mechanical half
only: no LLM rewrite happens before frank-tts.
* The stock speak-aloud implementation — vocalizer pipeline `I6o`, utterance
segmenter `fHe` and speech enhancer `Qyr` with its prompt `T6o` — in
`@oh-my-pi/pi-coding-agent`'s `dist/cli.js`, read off the minified bundle
2026-10-19: dankvoice ports the mechanical and the enhanced halves, so an
LLM rewrite does happen before frank-tts.
* Gitea issue #21 on `dank/command-center` (the work item this page ships with).
* Gitea issue #23 on `dank/command-center` (the options-menu work item).
* Gitea issue #27 on `dank/command-center` (the enhanced-narration work item).
## See Also
+16
View File
@@ -12,6 +12,22 @@ timestamp: 2026-08-28
# Bundle Change Log
## 2026-10-19 — dankvoice: humanized narration and thinking aloud (Refs #27)
dankvoice answered in verbatim markdown-cleaned text while the built-in
vocalizer speaks humanized prose — the bundle's `Qyr` enhancer (`speech.enhanced`,
system prompt `T6o`) now lives in the extension: every spoken unit is rewritten
by the @tiny role (resolved from `modelRoles.tiny` in config.yml + the provider
baseUrl in models.yml → frank-zealot :7771) under the same budgets (1536 tokens,
6 s, 4000-char window), with mechanical cleanup as the fallback and an empty
rewrite skipping the unit. Auto-speak also narrates the last assistant message's
thinking blocks before the answer (`narration: true` blocks count as answer
text); both are menu-toggleable and persisted (`thinking` in the settings file),
env levers `DANKVOICE_REWRITE_URL/MODEL/REWRITE=0/THINKING/OMPCONFIG`. The
fixture grows a chat route with echo/prose modes; 7 new scenarios, 22 green.
Live smoke: a markdown answer became 3 sequential prose segments through real
zealot + frank-tts, thinking played before its answer.
## 2026-10-19 — dankvoice: segmented sequential speech, copied from the bundle's vocalizer
Silent-answer incident follow-up: `agent_end` awaiting one whole-answer
+67 -2
View File
@@ -68,6 +68,13 @@ providers:
api: openai-speech
YML
# omp config.yml fixture: the @tiny role points at the chat provider above
export DV_OMPCONFIG="$WORK/ompconfig.yml"
cat > "$DV_OMPCONFIG" <<'CFY'
modelRoles:
tiny: chat (box)/chatmodel:off
CFY
cat > "$WORK/driver.ts" <<'TS'
// 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.
@@ -95,6 +102,14 @@ const server = createServer((req, res) => {
res.end(AUDIO);
return;
}
if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) {
res.setHeader("content-type", "application/json");
const spoken = process.env.DV_REWRITE === "echo"
? String(JSON.parse(body).messages?.[1]?.content ?? "")
: "Spoken, humanized words.";
res.end(JSON.stringify({ choices: [{ message: { content: spoken } }] }));
return;
}
res.statusCode = 404;
res.end("{}");
});
@@ -107,6 +122,8 @@ if (scenario === "endpoint") {
} else {
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
}
process.env.DANKVOICE_OMPCONFIG = process.env.DV_OMPCONFIG;
if (scenario !== "endpoint") process.env.DANKVOICE_REWRITE_URL = `http://127.0.0.1:${port}/v1`;
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
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;
@@ -195,6 +212,17 @@ if (!say) {
case "decimals":
await say.handler("Model 3.5 ships today. Version 1,5 too.", ctx);
break;
case "rewrite":
await say.handler("The deploy failed because a secret was missing.", ctx);
break;
case "thinking":
await say.handler("on", ctx);
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Considering the wiring." }, { type: "text", text: "The answer is nine." }] }] }, ctx);
break;
case "narration":
await say.handler("on", ctx);
await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx);
break;
case "noplayer":
await say.handler("speak anyway", ctx);
break;
@@ -213,7 +241,7 @@ for (const call of received) {
};
console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}`);
}
console.log(`CALLS ${received.length}`);
console.log(`CALLS ${received.filter((call) => call.url?.endsWith("/audio/speech")).length}`);
const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`);
if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
@@ -281,6 +309,7 @@ say_run() {
@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" == *"humanize chatmodel"* ]]
[[ "$output" != *"10.0.0.1"* ]]
}
@@ -316,6 +345,42 @@ say_run() {
[[ "$output" == *"input=Version 1,5 too."* ]]
}
@test "an answer is rewritten into spoken prose before synthesis" {
DV_REWRITE=prose say_run rewrite
[[ "$output" == *"CALL /v1/chat/completions"* ]]
[[ "$output" == *"input=Spoken, humanized words."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "a failed rewrite speaks the mechanical text unchanged" {
say_run speak
[[ "$output" == *"input=hello world"* ]]
}
@test "thinking is narrated before the answer as its own segment" {
DV_REWRITE=echo say_run thinking
[[ "$output" == *"input=Considering the wiring."* ]]
[[ "$output" == *"input=The answer is nine."* ]]
[[ "$output" == *"CALLS 2"* ]]
local first second
first=$(grep -n "input=Considering the wiring\." <<<"$output" | head -1 | cut -d: -f1)
second=$(grep -n "input=The answer is nine\." <<<"$output" | head -1 | cut -d: -f1)
[ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ]
}
@test "thinking narration can be switched off" {
DANKVOICE_THINKING=0 DV_REWRITE=echo say_run thinking
[[ "$output" == *"input=The answer is nine."* ]]
[[ "$output" != *"input=Considering the wiring."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "narration-flagged thinking is spoken once as the answer" {
DV_REWRITE=echo say_run narration
[[ "$output" == *"input=Here is the result."* ]]
[[ "$output" == *"CALLS 1"* ]]
}
@test "a missing player is reported, not swallowed" {
say_run noplayer
[[ "$output" == *"NOTIFY error dankvoice: no audio player found"* ]]
@@ -331,7 +396,7 @@ say_run() {
@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" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Thinking narration: on (toggle)|Humanize answers: on (tiny) (toggle)|Speak a test phrase"* ]]
[[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]]
say_run empty
[[ "$output" == *"SELECT dankvoice — options"* ]]