Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
501
extensions/sounds.ts
Normal file
501
extensions/sounds.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
// ABOUTME: Soundcn Extension — Browser-based sound viewer with Pi lifecycle hook notifications.
|
||||
// ABOUTME: /sounds command opens browser UI to browse, preview, and assign sounds from soundcn.xyz to Pi events.
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { outputLine } from "./lib/output-box.ts";
|
||||
import { applyExtensionDefaults } from "./lib/themeMap.ts";
|
||||
import { generateSoundsViewerHTML, type CatalogItem } from "./lib/sounds-viewer-html.ts";
|
||||
import {
|
||||
loadConfig, saveConfig, getActiveAssignmentCount, getAssignedSoundNames,
|
||||
type SoundsConfig, type HookName, ALL_HOOKS, HOOK_DISPLAY_NAMES,
|
||||
} from "./lib/sounds-config.ts";
|
||||
import {
|
||||
playInstalledSound, installSound, uninstallSound, isSoundInstalled,
|
||||
cleanupAllPlayback,
|
||||
} from "./lib/sounds-player.ts";
|
||||
import { registerActiveViewer, clearActiveViewer, notifyViewerOpen } from "./lib/viewer-session.ts";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SoundsViewerResult {
|
||||
action: "applied" | "cancelled";
|
||||
assignments?: Record<string, string>;
|
||||
volume?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// ── Catalog Fetching ─────────────────────────────────────────────────
|
||||
|
||||
let cachedCatalog: CatalogItem[] | null = null;
|
||||
|
||||
async function fetchCatalog(): Promise<CatalogItem[]> {
|
||||
if (cachedCatalog) return cachedCatalog;
|
||||
|
||||
const resp = await fetch(
|
||||
"https://raw.githubusercontent.com/ruizrica/soundcn/main/registry.json",
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Failed to fetch catalog: ${resp.status}`);
|
||||
|
||||
const data = await resp.json();
|
||||
const items: CatalogItem[] = (data.items || [])
|
||||
.filter((item: any) => item.type === "registry:block")
|
||||
.map((item: any) => ({
|
||||
name: item.name,
|
||||
title: item.title || item.name,
|
||||
description: item.description || "",
|
||||
categories: item.categories || [],
|
||||
author: item.author,
|
||||
meta: item.meta,
|
||||
}));
|
||||
|
||||
cachedCatalog = items;
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── HTTP Server ──────────────────────────────────────────────────────
|
||||
|
||||
function startSoundsServer(
|
||||
catalog: CatalogItem[],
|
||||
config: SoundsConfig,
|
||||
): Promise<{ port: number; server: Server; waitForResult: () => Promise<SoundsViewerResult> }> {
|
||||
return new Promise((resolveSetup) => {
|
||||
let resolveResult: (result: SoundsViewerResult) => void;
|
||||
const resultPromise = new Promise<SoundsViewerResult>((res) => {
|
||||
resolveResult = res;
|
||||
});
|
||||
let lastHeartbeat = Date.now();
|
||||
const heartbeatCheck = setInterval(() => {
|
||||
if (Date.now() - lastHeartbeat > 15_000) {
|
||||
clearInterval(heartbeatCheck);
|
||||
resolveResult!({ action: "cancelled" });
|
||||
}
|
||||
}, 5_000);
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
|
||||
// Serve main HTML page
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
const port = (server.address() as any)?.port || 0;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const html = generateSoundsViewerHTML({ catalog, config, port });
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve logo
|
||||
if (req.method === "GET" && url.pathname === "/logo.png") {
|
||||
try {
|
||||
const logoPath = join(dirname(fileURLToPath(import.meta.url)), "assets", "agent-logo.png");
|
||||
const logoData = readFileSync(logoPath);
|
||||
res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" });
|
||||
res.end(logoData);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Heartbeat keep-alive
|
||||
if (req.method === "POST" && url.pathname === "/heartbeat") {
|
||||
lastHeartbeat = Date.now();
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// CORS proxy: fetch sound data from soundcn.xyz server-side
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/sound/")) {
|
||||
const name = decodeURIComponent(url.pathname.slice("/api/sound/".length));
|
||||
if (!name || name.includes("/") || name.includes("..")) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid sound name" }));
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const upstream = await fetch(`https://soundcn.xyz/r/${encodeURIComponent(name)}.json`);
|
||||
if (!upstream.ok) {
|
||||
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: `Upstream returned ${upstream.status}` }));
|
||||
return;
|
||||
}
|
||||
const body = await upstream.text();
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
});
|
||||
res.end(body);
|
||||
} catch (err: any) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err?.message || "Proxy fetch failed" }));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle result submission (apply/cancel)
|
||||
if (req.method === "POST" && url.pathname === "/result") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
resolveResult!({
|
||||
action: data.action || "cancelled",
|
||||
assignments: data.assignments,
|
||||
volume: data.volume,
|
||||
enabled: data.enabled,
|
||||
});
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Install sound (save base64 data to cache)
|
||||
if (req.method === "POST" && url.pathname === "/install") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data.name && data.dataUri) {
|
||||
installSound(data.name, data.dataUri);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Missing name or dataUri" }));
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err?.message || "Install failed" }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Uninstall sound (remove from cache)
|
||||
if (req.method === "POST" && url.pathname === "/uninstall") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data.name) {
|
||||
uninstallSound(data.name);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Missing name" }));
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err?.message || "Uninstall failed" }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as any;
|
||||
resolveSetup({
|
||||
port: addr.port,
|
||||
server,
|
||||
waitForResult: () => resultPromise.finally(() => clearInterval(heartbeatCheck)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
try {
|
||||
execSync(`open "${url}"`, { stdio: "ignore" });
|
||||
} catch {
|
||||
try {
|
||||
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
||||
} catch {
|
||||
try {
|
||||
execSync(`start "${url}"`, { stdio: "ignore" });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extension ────────────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let activeServer: Server | null = null;
|
||||
let activeSession: any | null = null;
|
||||
let currentConfig: SoundsConfig = loadConfig();
|
||||
|
||||
function cleanupServer() {
|
||||
const server = activeServer;
|
||||
activeServer = null;
|
||||
if (server) {
|
||||
try { server.close(); } catch {}
|
||||
}
|
||||
if (activeSession) {
|
||||
clearActiveViewer(activeSession);
|
||||
activeSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(ctx: ExtensionContext) {
|
||||
if (!ctx.hasUI) return;
|
||||
const count = getActiveAssignmentCount(currentConfig);
|
||||
if (!currentConfig.enabled) {
|
||||
ctx.ui.setStatus("sounds", "🔇 Sounds OFF");
|
||||
} else if (count > 0) {
|
||||
ctx.ui.setStatus("sounds", `🔊 ${count} hook${count !== 1 ? "s" : ""}`);
|
||||
} else {
|
||||
ctx.ui.setStatus("sounds", "🔊 Sounds");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core viewer logic ────────────────────────────────────────────
|
||||
|
||||
async function runSoundsViewer(ctx: ExtensionContext): Promise<SoundsViewerResult> {
|
||||
cleanupServer();
|
||||
|
||||
// Fetch catalog
|
||||
ctx.ui.notify("Loading sound catalog from soundcn.xyz...", "info");
|
||||
let catalog: CatalogItem[];
|
||||
try {
|
||||
catalog = await fetchCatalog();
|
||||
} catch (err: any) {
|
||||
ctx.ui.notify(`Failed to fetch catalog: ${err.message}`, "error");
|
||||
return { action: "cancelled" };
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Loaded ${catalog.length} sounds. Opening browser...`, "info");
|
||||
|
||||
// Start server
|
||||
const { port, server, waitForResult } = await startSoundsServer(catalog, currentConfig);
|
||||
activeServer = server;
|
||||
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
activeSession = {
|
||||
kind: "sounds" as const,
|
||||
title: "Sound Browser",
|
||||
url,
|
||||
server,
|
||||
onClose: () => { activeServer = null; activeSession = null; },
|
||||
};
|
||||
registerActiveViewer(activeSession);
|
||||
|
||||
openBrowser(url);
|
||||
notifyViewerOpen(ctx, activeSession);
|
||||
|
||||
try {
|
||||
const result = await waitForResult();
|
||||
|
||||
// Apply config if user clicked "Apply"
|
||||
if (result.action === "applied" && result.assignments) {
|
||||
currentConfig = {
|
||||
assignments: result.assignments as Partial<Record<HookName, string>>,
|
||||
volume: typeof result.volume === "number" ? result.volume : currentConfig.volume,
|
||||
enabled: typeof result.enabled === "boolean" ? result.enabled : currentConfig.enabled,
|
||||
};
|
||||
saveConfig(currentConfig);
|
||||
updateStatus(ctx);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
cleanupServer();
|
||||
}
|
||||
}
|
||||
|
||||
// ── /sounds command ──────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("sounds", {
|
||||
description: "Open the sound browser, or use: /sounds toggle | /sounds status",
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/sounds requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const arg = args.trim().toLowerCase();
|
||||
|
||||
// /sounds toggle
|
||||
if (arg === "toggle") {
|
||||
currentConfig = { ...currentConfig, enabled: !currentConfig.enabled };
|
||||
saveConfig(currentConfig);
|
||||
updateStatus(ctx);
|
||||
ctx.ui.notify(
|
||||
currentConfig.enabled ? "🔊 Sounds enabled" : "🔇 Sounds disabled",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// /sounds status
|
||||
if (arg === "status") {
|
||||
const count = getActiveAssignmentCount(currentConfig);
|
||||
const lines: string[] = [
|
||||
`Sounds: ${currentConfig.enabled ? "Enabled" : "Disabled"}`,
|
||||
`Volume: ${Math.round(currentConfig.volume * 100)}%`,
|
||||
`Hooks: ${count}/${ALL_HOOKS.length} assigned`,
|
||||
];
|
||||
for (const hook of ALL_HOOKS) {
|
||||
const sound = currentConfig.assignments[hook];
|
||||
const label = HOOK_DISPLAY_NAMES[hook];
|
||||
lines.push(` ${label}: ${sound || "(none)"}`);
|
||||
}
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// /sounds — open browser
|
||||
const result = await runSoundsViewer(ctx);
|
||||
if (result.action === "applied") {
|
||||
const count = getActiveAssignmentCount(currentConfig);
|
||||
ctx.ui.notify(`✓ Sound config applied — ${count} hook${count !== 1 ? "s" : ""} assigned`, "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── show_sounds tool ─────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "show_sounds",
|
||||
label: "Show Sounds",
|
||||
description:
|
||||
"Open the sound browser to let the user browse, preview, and assign sounds from soundcn.xyz to Pi lifecycle hooks like task completion, agent start, tool calls, etc.",
|
||||
parameters: Type.Object({}),
|
||||
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Sound browser requires interactive mode." }],
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runSoundsViewer(ctx);
|
||||
|
||||
if (result.action === "applied") {
|
||||
const count = getActiveAssignmentCount(currentConfig);
|
||||
const assigned = Object.entries(currentConfig.assignments)
|
||||
.map(([hook, sound]) => ` ${HOOK_DISPLAY_NAMES[hook as HookName]}: ${sound}`)
|
||||
.join("\n");
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Sound config applied. ${count} hook${count !== 1 ? "s" : ""} assigned:\n${assigned || " (none)"}`,
|
||||
}],
|
||||
details: { action: "applied", config: currentConfig },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Sound browser closed without applying changes." }],
|
||||
details: { action: "cancelled" },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(_args, theme) {
|
||||
const text = theme.fg("toolTitle", theme.bold("show_sounds ")) +
|
||||
theme.fg("dim", "Opening sound browser...");
|
||||
return new Text(outputLine(theme, "accent", text), 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as any;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
if (details.action === "applied") {
|
||||
const count = getActiveAssignmentCount(details.config || {});
|
||||
return new Text(outputLine(theme, "success", `Sound config applied — ${count} hooks`), 0, 0);
|
||||
}
|
||||
return new Text(outputLine(theme, "warning", "Sound browser closed"), 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Lifecycle Hook Sound Playback ────────────────────────────────
|
||||
|
||||
function playHookSound(hookName: HookName): void {
|
||||
if (!currentConfig.enabled) return;
|
||||
const soundName = currentConfig.assignments[hookName];
|
||||
if (!soundName) return;
|
||||
if (!isSoundInstalled(soundName)) return;
|
||||
// Fire and forget — don't block the hook
|
||||
playInstalledSound(soundName, currentConfig.volume).catch(() => {});
|
||||
}
|
||||
|
||||
pi.on("agent_end", async () => {
|
||||
playHookSound("agent_end");
|
||||
});
|
||||
|
||||
pi.on("agent_start", async () => {
|
||||
playHookSound("agent_start");
|
||||
});
|
||||
|
||||
pi.on("tool_execution_start", async () => {
|
||||
playHookSound("tool_execution_start");
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", async () => {
|
||||
playHookSound("tool_execution_end");
|
||||
});
|
||||
|
||||
pi.on("turn_start", async () => {
|
||||
playHookSound("turn_start");
|
||||
});
|
||||
|
||||
pi.on("turn_end", async () => {
|
||||
playHookSound("turn_end");
|
||||
});
|
||||
|
||||
pi.on("session_compact", async () => {
|
||||
playHookSound("session_compact");
|
||||
});
|
||||
|
||||
// ── Session Lifecycle ────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
currentConfig = loadConfig();
|
||||
updateStatus(ctx);
|
||||
|
||||
// Play session start sound if assigned
|
||||
playHookSound("session_start");
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
cleanupServer();
|
||||
cleanupAllPlayback();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user