feat: add /speak command for vocalizing conclusions
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Context Warning Extension
|
* Context Warn + Voice Extension
|
||||||
*
|
*
|
||||||
* Emits a generated sound alert when the context window reaches or exceeds
|
* 1. Emits a sound alert when the context window reaches 100k tokens.
|
||||||
* 100 000 tokens. Also shows a UI notification.
|
* 2. Registers a /speak command so the agent can vocalize conclusions.
|
||||||
*
|
*
|
||||||
* Usage:
|
* 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 <text> — Speak the text aloud (for the agent to use)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
@@ -20,24 +23,22 @@ import { fileURLToPath } from "node:url";
|
|||||||
// Configuration
|
// Configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const WARNING_TOKEN_THRESHOLD = 100_000; // alert at 100k tokens
|
const WARNING_TOKEN_THRESHOLD = 100_000;
|
||||||
const CHECK_INTERVAL_MS = 15_000; // re-check every 15 s
|
const CHECK_INTERVAL_MS = 15_000;
|
||||||
const WARNING_COOLDOWN_MS = 60_000; // min seconds between alerts
|
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 EXT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
const ALERT_WAV = join(EXT_DIR, "alert.wav");
|
const ALERT_WAV = join(EXT_DIR, "alert.wav");
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Play the bundled alert.wav sound via aplay.
|
// Sound: play bundled alert.wav
|
||||||
* No runtime generation needed — the WAV is committed alongside this file.
|
// ---------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
function playAlert(): void {
|
function playAlert(): void {
|
||||||
if (!existsSync(ALERT_WAV)) {
|
if (!existsSync(ALERT_WAV)) {
|
||||||
console.error(`[context-warn] alert.wav not found at ${ALERT_WAV}`);
|
console.error(`[context-warn] alert.wav not found at ${ALERT_WAV}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync("aplay", ["-q", ALERT_WAV], { stdio: "ignore" });
|
execFileSync("aplay", ["-q", ALERT_WAV], { stdio: "ignore" });
|
||||||
} catch (err) {
|
} 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
|
// Extension
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -53,28 +77,19 @@ export default function (pi: ExtensionAPI) {
|
|||||||
let lastAlertTimestamp: number | null = null;
|
let lastAlertTimestamp: number | null = null;
|
||||||
let alertedAtThreshold: boolean = false;
|
let alertedAtThreshold: boolean = false;
|
||||||
|
|
||||||
/** Check context usage and alert if threshold reached. */
|
const checkContext = (_event: unknown, ctx: any) => {
|
||||||
const checkContext = (_event: unknown, ctx: ReturnType<ExtensionAPI["on"]> extends (ev: string, h: (e: any, c: infer C) => any) ? C : never) => {
|
|
||||||
const usage = ctx.getContextUsage();
|
const usage = ctx.getContextUsage();
|
||||||
if (!usage || usage.tokens === null || usage.tokens === undefined) {
|
if (!usage || usage.tokens === null || usage.tokens === undefined) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTokens = usage.tokens;
|
const currentTokens = usage.tokens;
|
||||||
|
|
||||||
// Check if we crossed the threshold (went from below to >= threshold)
|
|
||||||
const crossedThreshold = !alertedAtThreshold && currentTokens >= WARNING_TOKEN_THRESHOLD;
|
const crossedThreshold = !alertedAtThreshold && currentTokens >= WARNING_TOKEN_THRESHOLD;
|
||||||
alertedAtThreshold = alertedAtThreshold || crossedThreshold;
|
alertedAtThreshold = alertedAtThreshold || crossedThreshold;
|
||||||
|
|
||||||
if (crossedThreshold) {
|
if (crossedThreshold) {
|
||||||
// Check cooldown
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (lastAlertTimestamp && (now - lastAlertTimestamp) < WARNING_COOLDOWN_MS) {
|
if (lastAlertTimestamp && (now - lastAlertTimestamp) < WARNING_COOLDOWN_MS) return;
|
||||||
return; // still in cooldown
|
|
||||||
}
|
|
||||||
lastAlertTimestamp = now;
|
lastAlertTimestamp = now;
|
||||||
|
|
||||||
// Show UI notification
|
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"⚠️ Context Warning",
|
"⚠️ 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` : "?"}`);
|
ctx.ui.setStatus("context-warn", `${(currentTokens / 1000).toFixed(0)}k / ${usage.contextWindow ? `${usage.contextWindow / 1000}k` : "?"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play alert sound
|
|
||||||
playAlert();
|
playAlert();
|
||||||
|
|
||||||
console.log(`[context-warn] Context: ${currentTokens.toLocaleString()} tokens (threshold: ${WARNING_TOKEN_THRESHOLD.toLocaleString()})`);
|
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
|
pi.on("turn_end", (_event, ctx) => checkContext(_event, ctx));
|
||||||
// we know the new context size.
|
pi.on("agent_end", (_event, ctx) => checkContext(_event, ctx));
|
||||||
pi.on("turn_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) => {
|
pi.on("session_start", (_event, ctx) => {
|
||||||
alertedAtThreshold = false;
|
alertedAtThreshold = false;
|
||||||
lastAlertTimestamp = null;
|
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) => {
|
pi.on("session_compact", (_event, ctx) => {
|
||||||
alertedAtThreshold = false;
|
alertedAtThreshold = false;
|
||||||
lastAlertTimestamp = Date.now();
|
lastAlertTimestamp = Date.now();
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) ctx.ui.setStatus("context-warn", "compact ✓");
|
||||||
ctx.ui.setStatus("context-warn", "compact ✓");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register /context-warn-status command
|
// /context-warn-status
|
||||||
pi.registerCommand("context-warn-status", {
|
pi.registerCommand("context-warn-status", {
|
||||||
description: "Show current context token usage and warning status",
|
description: "Show current context token usage and warning status",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
@@ -130,9 +131,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
ctx.ui.notify("Context", "No usage data available yet.", "info");
|
ctx.ui.notify("Context", "No usage data available yet.", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pct = usage.percent !== null && usage.percent !== undefined
|
const pct = usage.percent !== null && usage.percent !== undefined ? `${usage.percent.toFixed(1)}%` : "?";
|
||||||
? `${usage.percent.toFixed(1)}%`
|
|
||||||
: "?";
|
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"Context Status",
|
"Context Status",
|
||||||
`${usage.tokens.toLocaleString()} tokens / ${usage.contextWindow?.toLocaleString()} (${pct})\nThreshold: ${WARNING_TOKEN_THRESHOLD.toLocaleString()}\nAlerted: ${alertedAtThreshold ? "yes" : "no"}`,
|
`${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", {
|
pi.registerCommand("context-warn-alert", {
|
||||||
description: "Play the alert sound immediately (test)",
|
description: "Play the alert sound immediately (test)",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
@@ -149,4 +148,17 @@ export default function (pi: ExtensionAPI) {
|
|||||||
ctx.ui.notify("🔔 Alert", "Sound played.", "info");
|
ctx.ui.notify("🔔 Alert", "Sound played.", "info");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// /speak <text> — 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 <text>", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
speak(text.trim());
|
||||||
|
ctx.ui.notify("🔊 Speaking", `"${text.trim().substring(0, 60)}${text.trim().length > 60 ? "..." : ""}"`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user