From 8c24bceff3af1f84a194e10a6dbef1f5e234f8db Mon Sep 17 00:00:00 2001 From: enne2 Date: Sat, 23 May 2026 20:34:09 +0200 Subject: [PATCH] feat: add /speak command for vocalizing conclusions --- index.ts | 108 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/index.ts b/index.ts index a74d09f..ce15c0d 100644 --- a/index.ts +++ b/index.ts @@ -1,13 +1,16 @@ /** - * Context Warning Extension + * Context Warn + Voice Extension * - * Emits a generated sound alert when the context window reaches or exceeds - * 100 000 tokens. Also shows a UI notification. + * 1. Emits a sound alert when the context window reaches 100k tokens. + * 2. Registers a /speak command so the agent can vocalize conclusions. * * Usage: - * pi -e ./context-warn-extension/index.ts + * pi install git:git.enne2.net/enne2/context-warn@main * - * Or place in ~/.pi/agent/extensions/context-warn/ for auto-discovery. + * Commands: + * /context-warn-status — Show current context usage + * /context-warn-alert — Manually test the alert sound + * /speak — Speak the text aloud (for the agent to use) */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -20,24 +23,22 @@ import { fileURLToPath } from "node:url"; // Configuration // --------------------------------------------------------------------------- -const WARNING_TOKEN_THRESHOLD = 100_000; // alert at 100k tokens -const CHECK_INTERVAL_MS = 15_000; // re-check every 15 s -const WARNING_COOLDOWN_MS = 60_000; // min seconds between alerts +const WARNING_TOKEN_THRESHOLD = 100_000; +const CHECK_INTERVAL_MS = 15_000; +const WARNING_COOLDOWN_MS = 60_000; -// Resolve the directory where this extension lives so we can find alert.wav const EXT_DIR = dirname(fileURLToPath(import.meta.url)); const ALERT_WAV = join(EXT_DIR, "alert.wav"); -/** - * Play the bundled alert.wav sound via aplay. - * No runtime generation needed — the WAV is committed alongside this file. - */ +// --------------------------------------------------------------------------- +// Sound: play bundled alert.wav +// --------------------------------------------------------------------------- + function playAlert(): void { if (!existsSync(ALERT_WAV)) { console.error(`[context-warn] alert.wav not found at ${ALERT_WAV}`); return; } - try { execFileSync("aplay", ["-q", ALERT_WAV], { stdio: "ignore" }); } catch (err) { @@ -45,6 +46,29 @@ function playAlert(): void { } } +// --------------------------------------------------------------------------- +// Voice: speak text aloud via test_tts.py (blocking, one at a time) +// --------------------------------------------------------------------------- + +const TTS_SCRIPT = "/home/enne2/.pi/agent/test_tts.py"; + +/** + * Speak text aloud using the local TTS script. + * This is a BLOCKING call — must wait for completion before speaking again. + */ +function speak(text: string): void { + if (!existsSync(TTS_SCRIPT)) { + console.error(`[voice] TTS script not found at ${TTS_SCRIPT}`); + return; + } + try { + // execFileSync is synchronous — waits for completion + execFileSync("python3", [TTS_SCRIPT, text], { stdio: "ignore" }); + } catch (err) { + console.error(`[voice] TTS failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- @@ -53,28 +77,19 @@ export default function (pi: ExtensionAPI) { let lastAlertTimestamp: number | null = null; let alertedAtThreshold: boolean = false; - /** Check context usage and alert if threshold reached. */ - const checkContext = (_event: unknown, ctx: ReturnType extends (ev: string, h: (e: any, c: infer C) => any) ? C : never) => { + const checkContext = (_event: unknown, ctx: any) => { const usage = ctx.getContextUsage(); - if (!usage || usage.tokens === null || usage.tokens === undefined) { - return; - } + if (!usage || usage.tokens === null || usage.tokens === undefined) return; const currentTokens = usage.tokens; - - // Check if we crossed the threshold (went from below to >= threshold) const crossedThreshold = !alertedAtThreshold && currentTokens >= WARNING_TOKEN_THRESHOLD; alertedAtThreshold = alertedAtThreshold || crossedThreshold; if (crossedThreshold) { - // Check cooldown const now = Date.now(); - if (lastAlertTimestamp && (now - lastAlertTimestamp) < WARNING_COOLDOWN_MS) { - return; // still in cooldown - } + if (lastAlertTimestamp && (now - lastAlertTimestamp) < WARNING_COOLDOWN_MS) return; lastAlertTimestamp = now; - // Show UI notification if (ctx.hasUI) { ctx.ui.notify( "⚠️ Context Warning", @@ -84,25 +99,14 @@ export default function (pi: ExtensionAPI) { ctx.ui.setStatus("context-warn", `${(currentTokens / 1000).toFixed(0)}k / ${usage.contextWindow ? `${usage.contextWindow / 1000}k` : "?"}`); } - // Play alert sound playAlert(); - console.log(`[context-warn] Context: ${currentTokens.toLocaleString()} tokens (threshold: ${WARNING_TOKEN_THRESHOLD.toLocaleString()})`); } }; - // Listen to turn_end — fired after every LLM response, which is when - // we know the new context size. - pi.on("turn_end", (_event, ctx) => { - checkContext(_event, ctx); - }); + pi.on("turn_end", (_event, ctx) => checkContext(_event, ctx)); + pi.on("agent_end", (_event, ctx) => checkContext(_event, ctx)); - // Also listen to agent_end in case compaction or other events happen - pi.on("agent_end", (_event, ctx) => { - checkContext(_event, ctx); - }); - - // Listen to session_start to reset state pi.on("session_start", (_event, ctx) => { alertedAtThreshold = false; lastAlertTimestamp = null; @@ -112,16 +116,13 @@ export default function (pi: ExtensionAPI) { } }); - // Listen to session_compact to reset alert state pi.on("session_compact", (_event, ctx) => { alertedAtThreshold = false; lastAlertTimestamp = Date.now(); - if (ctx.hasUI) { - ctx.ui.setStatus("context-warn", "compact ✓"); - } + if (ctx.hasUI) ctx.ui.setStatus("context-warn", "compact ✓"); }); - // Register /context-warn-status command + // /context-warn-status pi.registerCommand("context-warn-status", { description: "Show current context token usage and warning status", handler: async (_args, ctx) => { @@ -130,9 +131,7 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("Context", "No usage data available yet.", "info"); return; } - const pct = usage.percent !== null && usage.percent !== undefined - ? `${usage.percent.toFixed(1)}%` - : "?"; + const pct = usage.percent !== null && usage.percent !== undefined ? `${usage.percent.toFixed(1)}%` : "?"; ctx.ui.notify( "Context Status", `${usage.tokens.toLocaleString()} tokens / ${usage.contextWindow?.toLocaleString()} (${pct})\nThreshold: ${WARNING_TOKEN_THRESHOLD.toLocaleString()}\nAlerted: ${alertedAtThreshold ? "yes" : "no"}`, @@ -141,7 +140,7 @@ export default function (pi: ExtensionAPI) { }, }); - // Register /context-warn-alert command — manual test + // /context-warn-alert pi.registerCommand("context-warn-alert", { description: "Play the alert sound immediately (test)", handler: async (_args, ctx) => { @@ -149,4 +148,17 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("🔔 Alert", "Sound played.", "info"); }, }); + + // /speak — voice the agent's conclusion + pi.registerCommand("speak", { + description: "Speak the provided text aloud via TTS (for vocalizing conclusions)", + handler: async ([text], ctx) => { + if (!text || typeof text !== "string" || text.trim().length === 0) { + ctx.ui.notify("Speak", "Usage: /speak ", "info"); + return; + } + speak(text.trim()); + ctx.ui.notify("🔊 Speaking", `"${text.trim().substring(0, 60)}${text.trim().length > 60 ? "..." : ""}"`, "info"); + }, + }); }