ultimate-pi 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -1
- package/.pi/extensions/soundboard.ts +533 -0
- package/.pi/mcp.json +7 -0
- package/.pi/pi-vcc-config.json +4 -0
- package/.pi/prompts/harness-setup.md +164 -15
- package/.pi/settings.json +2 -2
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +1 -1
- package/firecrawl/.env.template +49 -45
- package/package.json +9 -7
- package/vault/wiki/concepts/vcc-conversation-compaction-for-pi.md +3 -1
- package/vault/wiki/decisions/2026-05-07-replace-lean-ctx-with-context-mode.md +59 -0
- package/vault/wiki/decisions/adr-027.md +94 -0
- package/vault/wiki/log.md +5 -1
- package/.pi/extensions/ck-enforce.ts +0 -216
package/.env.example
CHANGED
|
@@ -6,4 +6,6 @@ FIRECRAWL_API_KEY="fc_your_firecrawl_api_key"
|
|
|
6
6
|
FIRECRAWL_API_URL="http://localhost:3002"
|
|
7
7
|
# FIRECRAWL_NO_TELEMETRY=0
|
|
8
8
|
VAULT_WIKI_PATH="vault/wiki"
|
|
9
|
-
CURSOR_API_KEY=""
|
|
9
|
+
CURSOR_API_KEY=""
|
|
10
|
+
PI_VCC_CONFIG_PATH=".pi/pi-vcc-config.json"
|
|
11
|
+
# VCC config: points to project-level pi-vcc config (overrideDefaultCompaction, debug)
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-sounds — per-project sound notifications.
|
|
3
|
+
*
|
|
4
|
+
* Reads `.pi/sounds/project-sounds.json` from project root.
|
|
5
|
+
* Plays category sounds on agent events (success, error, alert).
|
|
6
|
+
* No global fallback — silent when no project config found.
|
|
7
|
+
*
|
|
8
|
+
* Manifest format (`.pi/sounds/project-sounds.json`):
|
|
9
|
+
* {
|
|
10
|
+
* "randomizeSounds": true,
|
|
11
|
+
* "sounds": {
|
|
12
|
+
* "success": ["success/tada.mp3", "success/boom.mp3"],
|
|
13
|
+
* "error": ["error/buzzer.mp3"],
|
|
14
|
+
* "alert": ["alert/kaching.mp3"],
|
|
15
|
+
* "notification": ["notification/soft.mp3"],
|
|
16
|
+
* "reminder": ["reminder/soft.mp3"]
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
23
|
+
import {
|
|
24
|
+
basename,
|
|
25
|
+
dirname,
|
|
26
|
+
extname,
|
|
27
|
+
isAbsolute,
|
|
28
|
+
join,
|
|
29
|
+
resolve,
|
|
30
|
+
} from "node:path";
|
|
31
|
+
import type {
|
|
32
|
+
ExtensionAPI,
|
|
33
|
+
ExtensionCommandContext,
|
|
34
|
+
} from "@mariozechner/pi-coding-agent";
|
|
35
|
+
|
|
36
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const SOUND_CATEGORIES = [
|
|
39
|
+
"success",
|
|
40
|
+
"error",
|
|
41
|
+
"alert",
|
|
42
|
+
"notification",
|
|
43
|
+
"reminder",
|
|
44
|
+
] as const;
|
|
45
|
+
type SoundCategory = (typeof SOUND_CATEGORIES)[number];
|
|
46
|
+
|
|
47
|
+
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac"]);
|
|
48
|
+
|
|
49
|
+
const PROJECT_MARKERS = [
|
|
50
|
+
".git",
|
|
51
|
+
"package.json",
|
|
52
|
+
"pyproject.toml",
|
|
53
|
+
"go.mod",
|
|
54
|
+
"Cargo.toml",
|
|
55
|
+
"composer.json",
|
|
56
|
+
"pom.xml",
|
|
57
|
+
"build.gradle",
|
|
58
|
+
".pi",
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
const QUESTION_HINTS = [
|
|
62
|
+
"question",
|
|
63
|
+
"need your input",
|
|
64
|
+
"please answer",
|
|
65
|
+
"requires your input",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const MIN_INTERVAL_MS = 1500;
|
|
69
|
+
|
|
70
|
+
interface SoundManifest {
|
|
71
|
+
themeName?: string;
|
|
72
|
+
randomizeSounds?: boolean;
|
|
73
|
+
sounds?: Partial<Record<SoundCategory, string | string[]>>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ProjectSoundContext {
|
|
77
|
+
projectRoot: string;
|
|
78
|
+
soundsDirectory: string;
|
|
79
|
+
soundsByCategory: Record<SoundCategory, string[]>;
|
|
80
|
+
randomizeSounds: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface AudioPlayer {
|
|
84
|
+
cmd: string;
|
|
85
|
+
args: (path: string) => string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Module-level cache ────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
let cachedContext: ProjectSoundContext | null = null;
|
|
91
|
+
let cachedProjectRoot: string | null = null;
|
|
92
|
+
let playerPromise: Promise<AudioPlayer | null> | null = null;
|
|
93
|
+
|
|
94
|
+
async function getPlayer(): Promise<AudioPlayer | null> {
|
|
95
|
+
if (!playerPromise) playerPromise = findPlayer();
|
|
96
|
+
return playerPromise;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Utility ───────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function pickRandom<T>(items: T[]): T | null {
|
|
102
|
+
if (items.length === 0) return null;
|
|
103
|
+
return items[Math.floor(Math.random() * items.length)] ?? items[0];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
107
|
+
try {
|
|
108
|
+
await access(path);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
116
|
+
try {
|
|
117
|
+
return (await stat(path)).isDirectory();
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function isReadableAudioFile(path: string): Promise<boolean> {
|
|
124
|
+
if (!AUDIO_EXTENSIONS.has(extname(path).toLowerCase())) return false;
|
|
125
|
+
try {
|
|
126
|
+
const s = await stat(path);
|
|
127
|
+
if (!s.isFile()) return false;
|
|
128
|
+
await access(path);
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function listAudioFiles(directory: string): Promise<string[]> {
|
|
136
|
+
if (!(await isDirectory(directory))) return [];
|
|
137
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
138
|
+
const files: string[] = [];
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
if (!e.isFile()) continue;
|
|
141
|
+
const f = join(directory, e.name);
|
|
142
|
+
if (
|
|
143
|
+
AUDIO_EXTENSIONS.has(extname(f).toLowerCase()) &&
|
|
144
|
+
(await isReadableAudioFile(f))
|
|
145
|
+
) {
|
|
146
|
+
files.push(resolve(f));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function resolveReference(
|
|
153
|
+
ref: string,
|
|
154
|
+
soundsDir: string,
|
|
155
|
+
): Promise<string | null> {
|
|
156
|
+
const trimmed = ref.trim();
|
|
157
|
+
if (!trimmed) return null;
|
|
158
|
+
const abs = isAbsolute(trimmed) ? trimmed : join(soundsDir, trimmed);
|
|
159
|
+
return (await isReadableAudioFile(abs)) ? resolve(abs) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function resolveCategoryFiles(
|
|
163
|
+
manifest: SoundManifest | null,
|
|
164
|
+
soundsDir: string,
|
|
165
|
+
category: SoundCategory,
|
|
166
|
+
): Promise<string[]> {
|
|
167
|
+
const resolved: string[] = [];
|
|
168
|
+
const entry = manifest?.sounds?.[category];
|
|
169
|
+
|
|
170
|
+
if (typeof entry === "string") {
|
|
171
|
+
const r = await resolveReference(entry, soundsDir);
|
|
172
|
+
if (r) resolved.push(r);
|
|
173
|
+
} else if (Array.isArray(entry)) {
|
|
174
|
+
for (const e of entry) {
|
|
175
|
+
if (typeof e === "string") {
|
|
176
|
+
const r = await resolveReference(e, soundsDir);
|
|
177
|
+
if (r) resolved.push(r);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
resolved.push(...(await listAudioFiles(join(soundsDir, category))));
|
|
183
|
+
return [...new Set(resolved)];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function detectProjectRoot(cwd = process.cwd()): Promise<string | null> {
|
|
187
|
+
let dir = resolve(cwd);
|
|
188
|
+
while (true) {
|
|
189
|
+
for (const marker of PROJECT_MARKERS) {
|
|
190
|
+
if (await pathExists(join(dir, marker))) return dir;
|
|
191
|
+
}
|
|
192
|
+
const parent = dirname(dir);
|
|
193
|
+
if (parent === dir) break;
|
|
194
|
+
dir = parent;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function buildProjectSoundContext(
|
|
200
|
+
projectRoot: string,
|
|
201
|
+
): Promise<ProjectSoundContext | null> {
|
|
202
|
+
const soundsDir = join(projectRoot, ".pi", "sounds");
|
|
203
|
+
if (!(await isDirectory(soundsDir))) return null;
|
|
204
|
+
|
|
205
|
+
let manifest: SoundManifest | null = null;
|
|
206
|
+
for (const name of [
|
|
207
|
+
"project-sounds.json",
|
|
208
|
+
"sound-theme.json",
|
|
209
|
+
"theme.json",
|
|
210
|
+
]) {
|
|
211
|
+
const p = join(soundsDir, name);
|
|
212
|
+
if (await pathExists(p)) {
|
|
213
|
+
try {
|
|
214
|
+
manifest = JSON.parse(await readFile(p, "utf-8")) as SoundManifest;
|
|
215
|
+
break;
|
|
216
|
+
} catch {
|
|
217
|
+
/* skip */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const soundsByCategory: Record<SoundCategory, string[]> = {
|
|
223
|
+
success: [],
|
|
224
|
+
error: [],
|
|
225
|
+
alert: [],
|
|
226
|
+
notification: [],
|
|
227
|
+
reminder: [],
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (const cat of SOUND_CATEGORIES) {
|
|
231
|
+
soundsByCategory[cat] = await resolveCategoryFiles(
|
|
232
|
+
manifest,
|
|
233
|
+
soundsDir,
|
|
234
|
+
cat,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const hasAny = SOUND_CATEGORIES.some((c) => soundsByCategory[c].length > 0);
|
|
239
|
+
if (!hasAny) return null;
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
projectRoot,
|
|
243
|
+
soundsDirectory: soundsDir,
|
|
244
|
+
soundsByCategory,
|
|
245
|
+
randomizeSounds: manifest?.randomizeSounds ?? true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function getSoundContext(
|
|
250
|
+
cwd = process.cwd(),
|
|
251
|
+
): Promise<ProjectSoundContext | null> {
|
|
252
|
+
const projectRoot = await detectProjectRoot(cwd);
|
|
253
|
+
if (projectRoot === cachedProjectRoot) return cachedContext;
|
|
254
|
+
|
|
255
|
+
cachedProjectRoot = projectRoot;
|
|
256
|
+
if (!projectRoot) {
|
|
257
|
+
cachedContext = null;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
cachedContext = await buildProjectSoundContext(projectRoot);
|
|
262
|
+
return cachedContext;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Audio Playback (spawn via child_process) ──────────────────────
|
|
266
|
+
|
|
267
|
+
function runCommand(
|
|
268
|
+
cmd: string,
|
|
269
|
+
args: string[],
|
|
270
|
+
timeoutMs = 15000,
|
|
271
|
+
): Promise<{ code: number }> {
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
const child = spawn(cmd, args, { timeout: timeoutMs, stdio: "ignore" });
|
|
274
|
+
child.on("close", (code) => resolve({ code: code ?? 1 }));
|
|
275
|
+
child.on("error", () => resolve({ code: 1 }));
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function hasCommand(cmd: string): Promise<boolean> {
|
|
280
|
+
try {
|
|
281
|
+
const { code } = await runCommand("which", [cmd], 3000);
|
|
282
|
+
return code === 0;
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function findPlayer(): Promise<AudioPlayer | null> {
|
|
289
|
+
if (process.platform === "darwin") {
|
|
290
|
+
if (await hasCommand("afplay")) return { cmd: "afplay", args: (p) => [p] };
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
if (process.platform === "win32") {
|
|
294
|
+
return {
|
|
295
|
+
cmd: "powershell.exe",
|
|
296
|
+
args: (p) => [
|
|
297
|
+
"-NoProfile",
|
|
298
|
+
"-NonInteractive",
|
|
299
|
+
"-Command",
|
|
300
|
+
`(New-Object Media.SoundPlayer '${p}').PlaySync()`,
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// Linux
|
|
305
|
+
if (await hasCommand("paplay")) return { cmd: "paplay", args: (p) => [p] };
|
|
306
|
+
if (await hasCommand("ffplay"))
|
|
307
|
+
return {
|
|
308
|
+
cmd: "ffplay",
|
|
309
|
+
args: (p) => ["-nodisp", "-autoexit", "-loglevel", "quiet", p],
|
|
310
|
+
};
|
|
311
|
+
if (await hasCommand("mpv"))
|
|
312
|
+
return { cmd: "mpv", args: (p) => ["--no-video", "--really-quiet", p] };
|
|
313
|
+
if (await hasCommand("aplay"))
|
|
314
|
+
return { cmd: "aplay", args: (p) => ["-q", p] };
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function playSound(filePath: string): Promise<boolean> {
|
|
319
|
+
const player = await getPlayer();
|
|
320
|
+
if (!player) return false;
|
|
321
|
+
const { code } = await runCommand(player.cmd, player.args(filePath), 15000);
|
|
322
|
+
return code === 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function clearCache(): void {
|
|
326
|
+
cachedContext = null;
|
|
327
|
+
cachedProjectRoot = null;
|
|
328
|
+
playerPromise = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Event Classification ──────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function extractTextContent(content: unknown): string {
|
|
334
|
+
if (!Array.isArray(content)) return "";
|
|
335
|
+
const parts: string[] = [];
|
|
336
|
+
for (const item of content) {
|
|
337
|
+
if (
|
|
338
|
+
item &&
|
|
339
|
+
typeof item === "object" &&
|
|
340
|
+
(item as Record<string, unknown>).type === "text" &&
|
|
341
|
+
typeof (item as Record<string, unknown>).text === "string"
|
|
342
|
+
) {
|
|
343
|
+
parts.push((item as Record<string, unknown>).text as string);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return parts.join("\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function classifyToolResult(
|
|
350
|
+
toolName: string,
|
|
351
|
+
isError: boolean,
|
|
352
|
+
textContent: string,
|
|
353
|
+
): SoundCategory | null {
|
|
354
|
+
const tool = toolName.toLowerCase();
|
|
355
|
+
const text = textContent.toLowerCase().slice(0, 800);
|
|
356
|
+
if (tool.includes("question")) return "alert";
|
|
357
|
+
if (QUESTION_HINTS.some((h) => text.includes(h))) return "alert";
|
|
358
|
+
if (isError) return "error";
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function readAgentEndOutcome(event: unknown): {
|
|
363
|
+
status: "completed" | "error" | "aborted";
|
|
364
|
+
reason?: string;
|
|
365
|
+
} {
|
|
366
|
+
const msgs = (event as Record<string, unknown>)?.messages;
|
|
367
|
+
if (!Array.isArray(msgs)) return { status: "completed" };
|
|
368
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
369
|
+
const msg = msgs[i] as Record<string, unknown>;
|
|
370
|
+
if (msg.role !== "assistant") continue;
|
|
371
|
+
const stopReason =
|
|
372
|
+
typeof msg.stopReason === "string" ? msg.stopReason.trim() : undefined;
|
|
373
|
+
const errorMsg =
|
|
374
|
+
typeof msg.errorMessage === "string"
|
|
375
|
+
? msg.errorMessage.trim()
|
|
376
|
+
: undefined;
|
|
377
|
+
if (stopReason === "error")
|
|
378
|
+
return {
|
|
379
|
+
status: "error",
|
|
380
|
+
reason: errorMsg || extractTextContent(msg.content),
|
|
381
|
+
};
|
|
382
|
+
if (stopReason === "aborted")
|
|
383
|
+
return { status: "aborted", reason: errorMsg };
|
|
384
|
+
if (errorMsg) return { status: "error", reason: errorMsg };
|
|
385
|
+
return { status: "completed" };
|
|
386
|
+
}
|
|
387
|
+
return { status: "completed" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Extension ─────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
export default function piSoundsExtension(pi: ExtensionAPI): void {
|
|
393
|
+
let hadErrorInTurn = false;
|
|
394
|
+
const suppressIdleAfterError = true;
|
|
395
|
+
const notifiedToolCalls = new Set<string>();
|
|
396
|
+
const lastPlayedAt = new Map<SoundCategory, number>();
|
|
397
|
+
|
|
398
|
+
function shouldThrottle(category: SoundCategory): boolean {
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
const last = lastPlayedAt.get(category) ?? 0;
|
|
401
|
+
if (now - last < MIN_INTERVAL_MS) return true;
|
|
402
|
+
lastPlayedAt.set(category, now);
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function queue(fn: () => Promise<void>): void {
|
|
407
|
+
void fn().catch(() => {});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
pi.on("session_start", async () => {
|
|
411
|
+
hadErrorInTurn = false;
|
|
412
|
+
notifiedToolCalls.clear();
|
|
413
|
+
clearCache();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
pi.on("session_shutdown", async () => {
|
|
417
|
+
clearCache();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
pi.on("resources_discover", (event) => {
|
|
421
|
+
if (event.reason === "reload") clearCache();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
pi.on("agent_start", async () => {
|
|
425
|
+
hadErrorInTurn = false;
|
|
426
|
+
notifiedToolCalls.clear();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
pi.on("tool_execution_start", async (event) => {
|
|
430
|
+
notifiedToolCalls.delete(event.toolCallId);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
pi.on("tool_result", async (event) => {
|
|
434
|
+
if (notifiedToolCalls.has(event.toolCallId)) return;
|
|
435
|
+
notifiedToolCalls.add(event.toolCallId);
|
|
436
|
+
|
|
437
|
+
const toolName = typeof event.toolName === "string" ? event.toolName : "";
|
|
438
|
+
const text = extractTextContent(event.content);
|
|
439
|
+
const category = classifyToolResult(toolName, event.isError, text);
|
|
440
|
+
if (!category) return;
|
|
441
|
+
if (event.isError) hadErrorInTurn = true;
|
|
442
|
+
|
|
443
|
+
queue(async () => {
|
|
444
|
+
const ctx = await getSoundContext();
|
|
445
|
+
if (!ctx) return;
|
|
446
|
+
const files = ctx.soundsByCategory[category];
|
|
447
|
+
if (files.length === 0) return;
|
|
448
|
+
const picked = ctx.randomizeSounds ? pickRandom(files) : files[0];
|
|
449
|
+
if (!picked) return;
|
|
450
|
+
if (!shouldThrottle(category)) await playSound(picked);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
pi.on("agent_end", async (event) => {
|
|
455
|
+
const outcome = readAgentEndOutcome(event);
|
|
456
|
+
if (outcome.status === "error" || outcome.status === "aborted") {
|
|
457
|
+
hadErrorInTurn = true;
|
|
458
|
+
queue(async () => {
|
|
459
|
+
const ctx = await getSoundContext();
|
|
460
|
+
if (!ctx) return;
|
|
461
|
+
const files = ctx.soundsByCategory.error;
|
|
462
|
+
if (files.length === 0) return;
|
|
463
|
+
const picked = ctx.randomizeSounds ? pickRandom(files) : files[0];
|
|
464
|
+
if (!picked) return;
|
|
465
|
+
if (!shouldThrottle("error")) await playSound(picked);
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (suppressIdleAfterError && hadErrorInTurn) {
|
|
470
|
+
hadErrorInTurn = false;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
queue(async () => {
|
|
474
|
+
const ctx = await getSoundContext();
|
|
475
|
+
if (!ctx) return;
|
|
476
|
+
const files = ctx.soundsByCategory.success;
|
|
477
|
+
if (files.length === 0) return;
|
|
478
|
+
const picked = ctx.randomizeSounds ? pickRandom(files) : files[0];
|
|
479
|
+
if (!picked) return;
|
|
480
|
+
if (!shouldThrottle("success")) await playSound(picked);
|
|
481
|
+
});
|
|
482
|
+
hadErrorInTurn = false;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ── Command ────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
pi.registerCommand("sounds", {
|
|
488
|
+
description: "Show pi-sounds status: loaded sounds, player",
|
|
489
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
490
|
+
const soundCtx = await getSoundContext();
|
|
491
|
+
if (!soundCtx) {
|
|
492
|
+
const msg = "pi-sounds: no .pi/sounds/project-sounds.json found";
|
|
493
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "warning");
|
|
494
|
+
else
|
|
495
|
+
pi.sendMessage({
|
|
496
|
+
customType: "pi-sounds",
|
|
497
|
+
content: msg,
|
|
498
|
+
display: true,
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const player = await getPlayer();
|
|
504
|
+
const playerName = player ? player.cmd : "none";
|
|
505
|
+
const total = SOUND_CATEGORIES.reduce(
|
|
506
|
+
(sum, c) => sum + soundCtx.soundsByCategory[c].length,
|
|
507
|
+
0,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const lines = [
|
|
511
|
+
`pi-sounds status:`,
|
|
512
|
+
` player: ${playerName}`,
|
|
513
|
+
` sounds: ${total} files in ${soundCtx.soundsDirectory}`,
|
|
514
|
+
` randomize: ${soundCtx.randomizeSounds ? "yes" : "no"}`,
|
|
515
|
+
` categories:`,
|
|
516
|
+
];
|
|
517
|
+
for (const cat of SOUND_CATEGORIES) {
|
|
518
|
+
const files = soundCtx.soundsByCategory[cat];
|
|
519
|
+
lines.push(
|
|
520
|
+
` ${cat}: ${files.length > 0 ? files.map((f) => basename(f)).join(", ") : "(none)"}`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
|
|
525
|
+
else
|
|
526
|
+
pi.sendMessage({
|
|
527
|
+
customType: "pi-sounds",
|
|
528
|
+
content: lines.join("\n"),
|
|
529
|
+
display: true,
|
|
530
|
+
});
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
package/.pi/mcp.json
ADDED