diff --git a/README.md b/README.md index 6d82371..655e031 100644 --- a/README.md +++ b/README.md @@ -66,17 +66,13 @@ Then load it from local `node_modules` or publish it to your npm registry later. ## Sound alerts -The extension generates three small WAV files programmatically on first use: +The package ships with three pre-generated WAV files and the extension only plays them at runtime: - `confirm.wav` — soft ascending alert for human confirmation - `block.wav` — lower, harsher alert for denied actions - `refine.wav` — distinct mid-tone alert for “blocked, but reformulate safely” cases -By default it writes them under: - -```text -~/.pi/agent/policy-gate-sounds/ -``` +By default it plays the bundled files from the package `assets/` directory, so it does **not** write audio files into your agent home at startup. It automatically uses the first available local player among: @@ -147,7 +143,7 @@ Example: "soundBlockEnabled": true, "soundRefineEnabled": true, "soundPlayer": "auto", - "soundDirectory": "~/.pi/agent/policy-gate-sounds", + "soundDirectory": "/absolute/path/to/custom/policy-gate-sounds", "sensitivePathGlobs": ["~/.ssh/**", "**/.env"], "overrides": [ { @@ -168,7 +164,7 @@ Example: - `soundPlayer: "auto"` picks the first available local player. - Set `soundEnabled: false` to disable all alert sounds. - `soundConfirmEnabled`, `soundBlockEnabled`, and `soundRefineEnabled` let you mute just one class of event. -- `soundDirectory` can be absolute, `~`-based, or relative to the project cwd. +- `soundDirectory` is optional: by default the extension uses the package's bundled `assets/` directory. If you set it, it should point to an existing directory containing `confirm.wav`, `block.wav`, and `refine.wav`. - `workspaceRoots` can be absolute paths, `~` paths, or paths relative to the current project cwd. @@ -212,10 +208,11 @@ Inside pi: ## Development -Install deps and verify the package tarball: +Install deps, regenerate the packaged WAV assets if needed, and verify the tarball: ```bash npm install +npm run generate:sounds npm run pack:check ``` diff --git a/assets/block.wav b/assets/block.wav new file mode 100644 index 0000000..c97c002 Binary files /dev/null and b/assets/block.wav differ diff --git a/assets/confirm.wav b/assets/confirm.wav new file mode 100644 index 0000000..e6e2f00 Binary files /dev/null and b/assets/confirm.wav differ diff --git a/assets/refine.wav b/assets/refine.wav new file mode 100644 index 0000000..34f8429 Binary files /dev/null and b/assets/refine.wav differ diff --git a/index.ts b/index.ts index 2ca7e1f..f6222ad 100644 --- a/index.ts +++ b/index.ts @@ -1,9 +1,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { getAgentDir, isToolCallEventType } from "@earendil-works/pi-coding-agent"; import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import os from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { minimatch } from "minimatch"; type PolicyAction = "allow" | "confirm" | "deny" | "refine"; @@ -67,9 +68,10 @@ interface Decision { const HOME = os.homedir(); const AGENT_DIR = getAgentDir(); +const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url)); const GLOBAL_CONFIG = resolve(AGENT_DIR, "policy-gate.json"); const PROJECT_CONFIG_NAME = ".pi/policy-gate.json"; -const DEFAULT_SOUND_DIR = resolve(AGENT_DIR, "policy-gate-sounds"); +const DEFAULT_SOUND_DIR = resolve(PACKAGE_DIR, "assets"); const AUDIO_PLAYER_CANDIDATES = ["paplay", "pw-play", "aplay", "ffplay", "play"]; const DEFAULT_SENSITIVE_PATH_GLOBS = [ @@ -412,84 +414,6 @@ function soundPath(kind: AlertSoundKind, config: ResolvedConfig): string { return join(config.soundDirectory, `${kind}.wav`); } -function createAlertWav(kind: AlertSoundKind): Buffer { - const sampleRate = 44100; - const amplitude = kind === "block" ? 0.42 : kind === "refine" ? 0.3 : 0.28; - const segments = - kind === "confirm" - ? [ - { durationMs: 80, freqs: [660, 990] }, - { durationMs: 35, freqs: [] }, - { durationMs: 95, freqs: [880, 1320] }, - { durationMs: 35, freqs: [] }, - { durationMs: 130, freqs: [1174, 1568] }, - ] - : kind === "refine" - ? [ - { durationMs: 95, freqs: [587, 784] }, - { durationMs: 32, freqs: [] }, - { durationMs: 95, freqs: [659, 880] }, - { durationMs: 32, freqs: [] }, - { durationMs: 95, freqs: [587, 784] }, - ] - : [ - { durationMs: 120, freqs: [392, 587] }, - { durationMs: 36, freqs: [] }, - { durationMs: 130, freqs: [294, 440] }, - { durationMs: 36, freqs: [] }, - { durationMs: 190, freqs: [196, 294] }, - ]; - - const sampleCount = segments.reduce((sum, segment) => sum + Math.round((segment.durationMs / 1000) * sampleRate), 0); - const pcm = Buffer.alloc(sampleCount * 2); - let sampleOffset = 0; - - for (const segment of segments) { - const segmentSamples = Math.round((segment.durationMs / 1000) * sampleRate); - for (let i = 0; i < segmentSamples; i += 1) { - const globalIndex = sampleOffset + i; - let sample = 0; - if (segment.freqs.length > 0) { - const t = i / sampleRate; - const attack = Math.min(1, i / Math.max(1, segmentSamples * 0.1)); - const release = Math.min(1, (segmentSamples - i) / Math.max(1, segmentSamples * 0.12)); - const envelope = Math.min(attack, release); - for (const freq of segment.freqs) { - sample += Math.sin(2 * Math.PI * freq * t); - } - sample = (sample / segment.freqs.length) * amplitude * envelope; - } - pcm.writeInt16LE(Math.max(-1, Math.min(1, sample)) * 32767, globalIndex * 2); - } - sampleOffset += segmentSamples; - } - - const header = Buffer.alloc(44); - header.write("RIFF", 0); - header.writeUInt32LE(36 + pcm.length, 4); - header.write("WAVE", 8); - header.write("fmt ", 12); - header.writeUInt32LE(16, 16); - header.writeUInt16LE(1, 20); - header.writeUInt16LE(1, 22); - header.writeUInt32LE(sampleRate, 24); - header.writeUInt32LE(sampleRate * 2, 28); - header.writeUInt16LE(2, 32); - header.writeUInt16LE(16, 34); - header.write("data", 36); - header.writeUInt32LE(pcm.length, 40); - return Buffer.concat([header, pcm]); -} - -function ensureSoundAssets(config: ResolvedConfig) { - if (!config.soundEnabled) return; - mkdirSync(config.soundDirectory, { recursive: true }); - for (const kind of ["confirm", "block", "refine"] as const) { - const target = soundPath(kind, config); - writeFileSync(target, createAlertWav(kind)); - } -} - function playAlert(kind: AlertSoundKind, config: ResolvedConfig) { if (!config.soundEnabled) return; if (kind === "confirm" && !config.soundConfirmEnabled) return; @@ -498,8 +422,8 @@ function playAlert(kind: AlertSoundKind, config: ResolvedConfig) { const player = resolveAudioPlayer(config.soundPlayer); if (!player) return; try { - ensureSoundAssets(config); const file = soundPath(kind, config); + if (!existsSync(file)) return; const args = player === "ffplay" ? ["-nodisp", "-autoexit", "-loglevel", "quiet", file] @@ -944,7 +868,6 @@ export default function policyGate(pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { const extraConfig = pi.getFlag("policy-gate-config") as string | undefined; activeConfig = loadConfig(ctx.cwd, extraConfig ? resolveConfigPath(extraConfig, ctx.cwd) : undefined); - ensureSoundAssets(activeConfig); if (ctx.hasUI) { ctx.ui.setStatus("policy-gate", ctx.ui.theme.fg("accent", "policy-gate active")); } diff --git a/package-lock.json b/package-lock.json index 4e1c2e5..b9233ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@enne2/pi-policy-gate", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@enne2/pi-policy-gate", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "minimatch": "^10.1.1" diff --git a/package.json b/package.json index 56461bb..e9da0ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@enne2/pi-policy-gate", - "version": "0.4.0", + "version": "0.5.0", "description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.", "type": "module", "keywords": [ @@ -13,7 +13,8 @@ "files": [ "index.ts", "README.md", - "policy-gate.example.json" + "policy-gate.example.json", + "assets/*.wav" ], "pi": { "extensions": [ @@ -22,7 +23,8 @@ }, "scripts": { "check": "npm pack --dry-run >/dev/null", - "pack:check": "npm pack --dry-run" + "pack:check": "npm pack --dry-run", + "generate:sounds": "python3 scripts/generate_sounds.py" }, "dependencies": { "minimatch": "^10.1.1" diff --git a/scripts/generate_sounds.py b/scripts/generate_sounds.py new file mode 100644 index 0000000..463ced7 --- /dev/null +++ b/scripts/generate_sounds.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import math +import wave +from pathlib import Path + +SAMPLE_RATE = 44100 + +PROFILES = { + "confirm": { + "amplitude": 0.28, + "segments": [ + (80, [660, 990]), + (35, []), + (95, [880, 1320]), + (35, []), + (130, [1174, 1568]), + ], + }, + "block": { + "amplitude": 0.42, + "segments": [ + (120, [392, 587]), + (36, []), + (130, [294, 440]), + (36, []), + (190, [196, 294]), + ], + }, + "refine": { + "amplitude": 0.30, + "segments": [ + (95, [587, 784]), + (32, []), + (95, [659, 880]), + (32, []), + (95, [587, 784]), + ], + }, +} + + +def synth_segment(duration_ms: int, freqs: list[int], amplitude: float) -> list[int]: + samples = round((duration_ms / 1000) * SAMPLE_RATE) + data: list[int] = [] + for i in range(samples): + sample = 0.0 + if freqs: + t = i / SAMPLE_RATE + attack = min(1.0, i / max(1, int(samples * 0.1))) + release = min(1.0, (samples - i) / max(1, int(samples * 0.12))) + envelope = min(attack, release) + sample = sum(math.sin(2 * math.pi * freq * t) for freq in freqs) / len(freqs) + sample *= amplitude * envelope + data.append(int(max(-1.0, min(1.0, sample)) * 32767)) + return data + + +def write_wave(path: Path, profile: dict[str, object]) -> None: + amplitude = float(profile["amplitude"]) + segments = profile["segments"] + pcm: list[int] = [] + for duration_ms, freqs in segments: # type: ignore[misc] + pcm.extend(synth_segment(duration_ms, list(freqs), amplitude)) + + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(SAMPLE_RATE) + wav.writeframes(b"".join(sample.to_bytes(2, "little", signed=True) for sample in pcm)) + + +if __name__ == "__main__": + root = Path(__file__).resolve().parent.parent + assets = root / "assets" + for name, profile in PROFILES.items(): + write_wave(assets / f"{name}.wav", profile) + print(f"wrote {assets / f'{name}.wav'}")