Bundle pre-generated alert sounds
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"));
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
+5
-3
@@ -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"
|
||||
|
||||
@@ -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'}")
|
||||
Reference in New Issue
Block a user