@misaka-net/misakanet-setup 0.4.1 → 0.5.0

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @misaka-net/misakanet-setup
2
2
 
3
- One command to teach your **Claude Code**, **Codex**, **Hermes** or **OpenClaw** to check
4
- MisakaNet's failure lessons before repeating a mistake — and to distil the session's reusable
3
+ One command to teach your **Claude Code**, **Codex**, **Hermes**, **OpenClaw** or **codewhale** to
4
+ check MisakaNet's failure lessons before repeating a mistake — and to distil the session's reusable
5
5
  lessons at a checkpoint.
6
6
 
7
7
  ```bash
@@ -18,6 +18,7 @@ knowledge base on its own instead of guessing.
18
18
  |---|---|---|
19
19
  | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token so reads are not metered by the anonymous 5/day/IP limit. Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`. All four are config files written file-to-file — no subprocess is spawned, so **no token ever appears in a command line** |
20
20
  | 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md` (issue, retry, risky-operation triggers; desensitisation rules) |
21
+ | 4 | *(opt-in)* you **hear** it | `--voice` adds a `PostToolUse` hook that plays the cue the server asked for (`lesson-found` / `failure-warning` / `connect-success` / `pair-success`). Off by default; mute with `MISAKANET_VOICE=0`; see `docs/integrations/mcp-voice-hooks.md` |
21
22
  | 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "distil and submit" reminder; a failed tool call injects a "search before you retry" reminder built from the error text. Claude Code only today — Codex's user-level hook shape is unconfirmed and OpenClaw's events are unverified, so those two work from the rules block, and `--verify` says so per target instead of implying otherwise |
22
23
 
23
24
  Without (3) a rule saying "summarise every 20 turns" never fires — agents do not keep
@@ -33,6 +34,8 @@ npx @misaka-net/misakanet-setup --no-register # read-only, no anonymous token
33
34
  npx @misaka-net/misakanet-setup --upgrade # same as installing the latest (the command is idempotent)
34
35
  # re-running also *refreshes* an older hook (the previous copy
35
36
  # is kept as ~/.misakanet-agent/hook.mjs.misakanet.bak)
37
+ npx @misaka-net/misakanet-setup --voice # opt-in: a cue when a search hits, another when it misses
38
+ # (Claude Code: a PostToolUse hook; mute later with MISAKANET_VOICE=0)
36
39
  npx @misaka-net/misakanet-setup --uninstall # remove exactly what it added
37
40
  ```
38
41
 
@@ -23,13 +23,13 @@
23
23
  * Everything is idempotent, every rewritten file is backed up to *.misakanet.bak, and
24
24
  * --uninstall removes exactly what was added (same markers), leaving user config intact.
25
25
  */
26
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, chmodSync, statSync } from 'node:fs';
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync, chmodSync, statSync } from 'node:fs';
27
27
  // No child_process import on purpose: this installer must never hand a file-derived value to
28
28
  // another program (the plugin scanner's SHELL_INJECTION_PATTERN, alert #269, and argv secrets
29
29
  // are visible to every process on the box). Every target is configured by writing its own
30
30
  // config file; commands aimed at the user are printed, never executed.
31
31
  import { homedir } from 'node:os';
32
- import { join, dirname, resolve } from 'node:path';
32
+ import { delimiter, join, dirname, resolve } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
34
 
35
35
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -69,7 +69,7 @@ const valueOf = (flag, dflt) => {
69
69
  const only = valueOf('--only', '').split(',').map((s) => s.trim()).filter(Boolean);
70
70
  const DRY = has('--dry-run');
71
71
  const HOME = resolve(valueOf('--home', homedir()));
72
- const AGENTS = ['claude', 'codex', 'hermes', 'openclaw'];
72
+ const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale'];
73
73
 
74
74
  const done = [];
75
75
  const manual = [];
@@ -103,7 +103,7 @@ function readText(path) {
103
103
  * meant to prevent is now caught in CI instead, by a test that binds this literal to the
104
104
  * manifest — a failing test is a better place for that than a request header.
105
105
  */
106
- const VERSION = '0.4.1';
106
+ const VERSION = '0.5.0';
107
107
 
108
108
  function backup(path) {
109
109
  if (DRY || !readText(path)) return;
@@ -236,6 +236,51 @@ const PROMPT_BLOCK = `遇到报错、要重试一个刚失败的修法、或要
236
236
  * on every prompt" is a supply-chain hole, and it buys nothing - `prepack` already puts the
237
237
  * canonical hook inside the package. If it is somehow missing, say so instead of fetching.
238
238
  */
239
+ /** Is `cmd` on PATH? Presence, not a spawn: this installer deliberately has no
240
+ * child_process import (a value derived from a file must never reach a shell — that was the
241
+ * shape behind the plugin scanner's shell-injection report). */
242
+ function onPath(cmd) {
243
+ for (const dir of String(process.env.PATH || '').split(delimiter)) {
244
+ if (!dir) continue;
245
+ try {
246
+ if (statSync(join(dir, cmd)).isFile()) return join(dir, cmd);
247
+ } catch { /* not here */ }
248
+ }
249
+ return '';
250
+ }
251
+
252
+ /** The first player the voice hook will actually use on this machine. */
253
+ function detectPlayer() {
254
+ for (const cmd of ['afplay', 'paplay', 'ffplay', 'mpv', 'mpg123', 'cvlc', 'powershell.exe']) {
255
+ if (onPath(cmd)) return cmd;
256
+ }
257
+ return '';
258
+ }
259
+
260
+ /**
261
+ * Where the player and the cues come from. Two layouts, and they do **not** share a name:
262
+ * the shipped copy is `voice/voice-hook.mjs` with the MP3s beside it, while the canonical file
263
+ * in a repo checkout is `integrations/agent-autostart/voice_hook.mjs` (underscore, like
264
+ * `checkpoint_reminder.mjs`) with the cues under `docs/assets/voice/`.
265
+ *
266
+ * Getting this wrong is invisible locally — a pack step leaves the shipped copy behind — and
267
+ * only CI caught it: the repo layout was rejected, so `--voice` silently added nothing.
268
+ */
269
+ function locateVoice() {
270
+ const shipped = join(PKG_ROOT, 'voice');
271
+ if (existsSync(join(shipped, 'voice-hook.mjs'))) {
272
+ return { player: join(shipped, 'voice-hook.mjs'), cues: shipped };
273
+ }
274
+ const repo = join(PKG_ROOT, '..', '..', 'integrations', 'agent-autostart');
275
+ if (existsSync(join(repo, 'voice_hook.mjs'))) {
276
+ return {
277
+ player: join(repo, 'voice_hook.mjs'),
278
+ cues: join(PKG_ROOT, '..', '..', 'docs', 'assets', 'voice'),
279
+ };
280
+ }
281
+ return null;
282
+ }
283
+
239
284
  function locateHook() {
240
285
  const candidates = [
241
286
  join(PKG_ROOT, 'hook', 'checkpoint_reminder.mjs'), // shipped in the tarball
@@ -352,6 +397,7 @@ function detect(agent) {
352
397
  codex: ['.codex'],
353
398
  hermes: ['.hermes'],
354
399
  openclaw: ['.openclaw'],
400
+ codewhale: ['.codewhale'],
355
401
  }[agent] || [];
356
402
  return paths.some((p) => existsSync(join(HOME, p)));
357
403
  }
@@ -399,6 +445,52 @@ async function installClaude(hookPath, bearer) {
399
445
  settings.hooks[event] = bucket;
400
446
  changed = true;
401
447
  }
448
+ // Voice cues are opt-in (`--voice`): the server answers with a `voice` field, and this
449
+ // turns it into a sound through a PostToolUse hook. Default off on purpose — an assistant
450
+ // that starts talking on its own is exactly the kind of surprise this installer exists to
451
+ // avoid. `MISAKANET_VOICE=0` mutes it later without uninstalling anything.
452
+ if (has('--voice')) {
453
+ const voice = locateVoice();
454
+ if (!voice) {
455
+ need('语音钩子:这个 npm 包里没有带播放器(安装不完整)→ 重新执行 npx 安装即可;其它功能不受影响');
456
+ } else {
457
+ const destDir = join(stateDir(), 'voice');
458
+ if (!DRY) {
459
+ mkdirSync(destDir, { recursive: true });
460
+ copyFileSync(voice.player, join(destDir, 'voice-hook.mjs'));
461
+ const cues = existsSync(voice.cues) ? readdirSync(voice.cues).filter((f) => f.endsWith('.mp3')) : [];
462
+ for (const cue of cues) {
463
+ copyFileSync(join(voice.cues, cue), join(destDir, cue));
464
+ }
465
+ if (!cues.length) {
466
+ need('语音钩子:找到了播放器但没有找到音频文件 → 重装一次 npx 包即可(其它功能不受影响)');
467
+ }
468
+ }
469
+ const post = settings.hooks.PostToolUse || [];
470
+ if (!JSON.stringify(post).includes('voice-hook')) {
471
+ // `matcher: '*'` is load-bearing, not cosmetic: measured on 2026-09-16, a PostToolUse
472
+ // entry **without** a matcher never fired in this host, while the same command with
473
+ // `matcher: '*'` fired on every tool call. The hook itself filters by the `voice`
474
+ // field, so matching everything costs nothing.
475
+ post.push({
476
+ matcher: '*',
477
+ hooks: [{ type: 'command', command: `"${node}" "${join(destDir, 'voice-hook.mjs')}"` }],
478
+ });
479
+ settings.hooks.PostToolUse = post;
480
+ changed = true;
481
+ }
482
+ const player = detectPlayer();
483
+ if (player) {
484
+ ok(`语音钩子:已开启(PostToolUse → ${player});想静音设 MISAKANET_VOICE=0`);
485
+ } else {
486
+ need('语音钩子:装上了,但这台机器上没有找到播放器 → 不会有声音'
487
+ + '(macOS 自带 afplay;Linux 装 paplay 或 ffplay;WSL 会走 Windows 的 PowerShell)');
488
+ }
489
+ }
490
+ } else {
491
+ ok('语音钩子:未开启(想让命中/未命中时出声:重跑安装器并加 --voice)');
492
+ }
493
+
402
494
  if (changed) {
403
495
  backup(settingsPath);
404
496
  writeText(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
@@ -673,6 +765,75 @@ async function installHermes(hookPath, bearer) {
673
765
  * The manual `openclaw mcp add …` line is still printed for the user to run themselves — as
674
766
  * text, never executed.
675
767
  */
768
+ /**
769
+ * codewhale keeps MCP servers in its own JSON file, and reads workspace rules from a
770
+ * plain `AGENTS.md` — but only in a *trusted* project (`config.toml`'s
771
+ * `[projects."<dir>"] trust_level = "trusted"`). So the block goes into the trusted
772
+ * project directories, which are also where the user actually works, rather than into a
773
+ * guessed user-level path (checked on 0.9.7: no user-level AGENTS.md is read).
774
+ *
775
+ * The token is the one thing codewhale will not take inline: `bearer_token_env_var` names
776
+ * an environment variable, so "install once" for this agent ends with the user exporting
777
+ * `MISAKANET_TOKEN` once. That is stated rather than silently half-installed.
778
+ */
779
+ function codewhaleProjects() {
780
+ const text = readText(join(HOME, '.codewhale', 'config.toml'));
781
+ if (!text) return [];
782
+ const out = [];
783
+ let current = null;
784
+ for (const line of text.split('\n')) {
785
+ const header = line.match(/^\s*\[projects\.(.+?)\]\s*$/);
786
+ if (header) { current = header[1].trim().replace(/^"|"$/g, ''); continue; }
787
+ if (/^\s*\[/.test(line)) { current = null; continue; }
788
+ if (current && /^\s*trust_level\s*=\s*"trusted"/.test(line)) out.push(current);
789
+ }
790
+ return out;
791
+ }
792
+
793
+ async function installCodewhale(bearer) {
794
+ const cfg = join(HOME, '.codewhale', 'mcp.json');
795
+ const data = readJson(cfg, null) || {
796
+ timeouts: { connect_timeout: 10, execute_timeout: 60, read_timeout: 120 },
797
+ servers: {},
798
+ };
799
+ const servers = { ...(data.servers || {}) };
800
+ const entry = {
801
+ command: null,
802
+ args: [],
803
+ env: {},
804
+ url: ENDPOINT,
805
+ connect_timeout: null,
806
+ execute_timeout: null,
807
+ read_timeout: null,
808
+ disabled: false,
809
+ enabled: true,
810
+ required: false,
811
+ enabled_tools: [],
812
+ disabled_tools: [],
813
+ bearer_token_env_var: 'MISAKANET_TOKEN',
814
+ };
815
+ if (JSON.stringify(servers.misakanet || null) !== JSON.stringify(entry)) {
816
+ servers.misakanet = entry;
817
+ backup(cfg);
818
+ writeText(cfg, `${JSON.stringify({ ...data, servers }, null, 2)}\n`);
819
+ ok(`codewhale:注册 MCP(streamable-http)→ ${cfg}`);
820
+ } else {
821
+ ok('codewhale:MCP 已注册(无改动)');
822
+ }
823
+
824
+ const projects = codewhaleProjects();
825
+ for (const project of projects) {
826
+ if (!existsSync(project)) continue;
827
+ ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
828
+ }
829
+ if (!projects.length) {
830
+ need('codewhale:没有受信任的项目目录(config.toml 里没有 trust_level = "trusted")→ '
831
+ + '先在 codewhale 里打开一次你的项目并信任它,再运行本命令,规则块才会写进去');
832
+ }
833
+ need('codewhale:token 只能通过环境变量给 → 在你的 shell 配置里加 `export MISAKANET_TOKEN=<你的 token>`'
834
+ + '(本机状态:npx @misaka-net/misakanet-setup --verify 会告诉你 token 在哪)');
835
+ }
836
+
676
837
  async function installOpenclaw(bearer) {
677
838
  const workspace = openclawWorkspaces()[0];
678
839
  const rules = join(workspace, 'AGENTS.md');
@@ -850,6 +1011,20 @@ function uninstall() {
850
1011
  const rules = join(workspace, 'AGENTS.md');
851
1012
  if (stripBlock(rules)) ok(`移除规则块 → ${rules}`);
852
1013
  }
1014
+
1015
+ // codewhale: same two surfaces as the install (its own mcp.json + trusted projects).
1016
+ for (const project of codewhaleProjects()) {
1017
+ const rules = join(project, 'AGENTS.md');
1018
+ if (stripBlock(rules)) ok(`移除规则块 → ${rules}`);
1019
+ }
1020
+ const whaleCfg = join(HOME, '.codewhale', 'mcp.json');
1021
+ const whale = readJson(whaleCfg, null);
1022
+ if (whale?.servers?.misakanet) {
1023
+ delete whale.servers.misakanet;
1024
+ backup(whaleCfg);
1025
+ writeText(whaleCfg, `${JSON.stringify(whale, null, 2)}\n`);
1026
+ ok(`移除 MCP 注册 → ${whaleCfg}`);
1027
+ }
853
1028
  const cfg = join(HOME, '.claude.json');
854
1029
  const data = readJson(cfg, null);
855
1030
  if (data && data.mcpServers?.misakanet) {
@@ -858,12 +1033,20 @@ function uninstall() {
858
1033
  writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
859
1034
  ok(`移除 MCP 注册 → ${cfg}`);
860
1035
  }
1036
+ const voiceDir = join(stateDir(), 'voice');
1037
+ if (existsSync(voiceDir)) {
1038
+ try {
1039
+ rmSync(voiceDir, { recursive: true, force: true });
1040
+ ok(`移除语音钩子文件 → ${voiceDir}`);
1041
+ } catch { /* best effort */ }
1042
+ }
861
1043
  const settingsPath = join(HOME, '.claude', 'settings.json');
862
1044
  const settings = readJson(settingsPath, null);
863
1045
  if (settings?.hooks) {
864
1046
  let changed = false;
865
1047
  for (const event of Object.keys(settings.hooks)) {
866
- const kept = (settings.hooks[event] || []).filter((entry) => !JSON.stringify(entry).includes('hook.mjs'));
1048
+ const kept = (settings.hooks[event] || []).filter((entry) =>
1049
+ !JSON.stringify(entry).includes('hook.mjs') && !JSON.stringify(entry).includes('voice-hook'));
867
1050
  if (kept.length !== settings.hooks[event].length) {
868
1051
  changed = true;
869
1052
  if (kept.length) settings.hooks[event] = kept; else delete settings.hooks[event];
@@ -974,6 +1157,7 @@ if (!targets.length) {
974
1157
  else if (agent === 'codex') await installCodex(hookPath, bearer);
975
1158
  else if (agent === 'hermes') await installHermes(hookPath, bearer);
976
1159
  else if (agent === 'openclaw') await installOpenclaw(bearer);
1160
+ else if (agent === 'codewhale') await installCodewhale(bearer);
977
1161
  }
978
1162
  }
979
1163
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@misaka-net/misakanet-setup",
3
- "version": "0.4.1",
4
- "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
3
+ "version": "0.5.0",
4
+ "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw / codewhale to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
5
5
  "bin": {
6
6
  "misakanet-setup": "bin/misakanet-setup.mjs"
7
7
  },
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin/misakanet-setup.mjs",
11
11
  "hook/checkpoint_reminder.mjs",
12
+ "voice/",
12
13
  "README.md"
13
14
  ],
14
15
  "scripts": {
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MisakaNet voice hook — play the cue a tool result asked for.
4
+ *
5
+ * The MCP server already answers with a `voice` field (`lesson-found` when a search hit,
6
+ * `failure-warning` when it did not, `connect-success` for a lesson, `pair-success` for a
7
+ * usage receipt). This hook turns that field into a sound. It is registered as a Claude Code
8
+ * `PostToolUse` hook by `npx @misaka-net/misakanet-setup --voice`, and it is *opt-in*: nobody
9
+ * wants an assistant that starts talking on its own.
10
+ *
11
+ * Why Node and not the older shell script: that one parses stdin with `python`, and `python`
12
+ * is not on PATH on many Linux/WSL boxes (`python3` is) — this repo has that exact bug on
13
+ * record. Node is guaranteed here: the installer itself runs on it.
14
+ *
15
+ * Rules it must never break:
16
+ * - **never block the agent**: the player is spawned detached and the process exits
17
+ * immediately, with a hard timeout as a backstop;
18
+ * - **never fail loudly**: a missing player, a missing MP3 or unparsable stdin all end in
19
+ * exit 0 with no output, because a hook that prints to stderr can show up as a tool error;
20
+ * - **be silent when asked**: `MISAKANET_VOICE=0` mutes it even after it is installed.
21
+ *
22
+ * Test/debug affordances:
23
+ * - `MISAKANET_VOICE_DRY_RUN=1` prints the cue name instead of playing it (that is what the
24
+ * test suite uses, so CI never needs an audio device);
25
+ * - `MISAKANET_VOICE_DIR` overrides where the MP3s live.
26
+ */
27
+ import { spawn } from 'node:child_process';
28
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
29
+ import { dirname, join } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url));
33
+ const CUES = ['connect-success', 'pair-success', 'lesson-found', 'failure-warning'];
34
+
35
+ /**
36
+ * Find the first cue name anywhere in the payload.
37
+ *
38
+ * Two nests are real, not hypothetical (both observed on 2026-09-16 with Claude Code):
39
+ * * `tool_response` is the MCP result, so the cue is one level down;
40
+ * * for an MCP tool that value is a **JSON string**, not an object — the whole response is
41
+ * escaped inside it. Descending only into objects therefore found nothing even when the
42
+ * server had sent the cue.
43
+ */
44
+ function findCue(value, depth = 0) {
45
+ if (depth > 8) return '';
46
+ if (typeof value === 'string') {
47
+ const trimmed = value.trim();
48
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return '';
49
+ try {
50
+ return findCue(JSON.parse(trimmed), depth + 1);
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+ if (value === null || typeof value !== 'object') return '';
56
+ if (typeof value.voice === 'string' && CUES.includes(value.voice)) return value.voice;
57
+ for (const child of Array.isArray(value) ? value : Object.values(value)) {
58
+ const found = findCue(child, depth + 1);
59
+ if (found) return found;
60
+ }
61
+ return '';
62
+ }
63
+
64
+ function readStdin() {
65
+ try {
66
+ return readFileSync(0, 'utf8');
67
+ } catch {
68
+ return '';
69
+ }
70
+ }
71
+
72
+ function parseCue(raw) {
73
+ if (!raw) return '';
74
+ const trimmed = raw.trim();
75
+ // Claude Code writes one JSON object per line; the CLI sends the whole document.
76
+ for (const candidate of [trimmed, ...trimmed.split('\n')]) {
77
+ try {
78
+ const cue = findCue(JSON.parse(candidate));
79
+ if (cue) return cue;
80
+ } catch {
81
+ /* try the next shape */
82
+ }
83
+ }
84
+ // Last resort: a bare cue name on the line, so a hand-rolled hook still works.
85
+ const bare = trimmed.match(new RegExp(`\\b(${CUES.join('|')})\\b`));
86
+ return bare ? bare[1] : '';
87
+ }
88
+
89
+ /** The path Windows needs, for the WSL case. */
90
+ function windowsPathFor(file) {
91
+ const drive = file.match(/^\/mnt\/([a-z])\/(.*)$/);
92
+ if (drive) return `${drive[1].toUpperCase()}:\\${drive[2].replace(/\//g, '\\')}`;
93
+ const distro = process.env.WSL_DISTRO_NAME;
94
+ if (distro && file.startsWith('/')) {
95
+ return `\\\\wsl.localhost\\${distro}${file.replace(/\//g, '\\')}`;
96
+ }
97
+ return '';
98
+ }
99
+
100
+ function isWsl() {
101
+ return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
102
+ }
103
+
104
+ /**
105
+ * Players in preference order, with the reason each one is where it is.
106
+ *
107
+ * `aplay` is deliberately absent: the assets are MP3 and aplay only plays raw/WAV, so the
108
+ * older shell script's ALSA branch was silent on exactly the machines that reached it.
109
+ *
110
+ * On WSL the Windows side comes **first**, and that is not a detail: measured on this
111
+ * machine, `ffplay` cannot open an ALSA card at all in WSL ("cannot find card '0'"), so the
112
+ * local-player branch installs a hook that never makes a sound. PowerShell's
113
+ * presentationCore plays the same file by URI through Windows audio — verified by asking it
114
+ * for the duration it had loaded (4.2s for `lesson-found`).
115
+ */
116
+ function players(file) {
117
+ const winPath = windowsPathFor(file);
118
+ const powershell = winPath
119
+ ? [
120
+ 'powershell.exe',
121
+ [
122
+ '-NoProfile',
123
+ '-Command',
124
+ 'Add-Type -AssemblyName presentationCore;'
125
+ + '$p = New-Object System.Windows.Media.MediaPlayer;'
126
+ + `$p.Open([uri]'${winPath}'); $p.Play(); Start-Sleep -Seconds 6`,
127
+ ],
128
+ ]
129
+ : null;
130
+ const local = [
131
+ ['afplay', [file]], // macOS
132
+ ['paplay', [file]], // Linux (PulseAudio)
133
+ ['ffplay', ['-nodisp', '-autoexit', '-loglevel', 'quiet', file]],
134
+ ['mpv', ['--no-video', '--really-quiet', file]],
135
+ ['mpg123', ['-q', file]],
136
+ ['cvlc', ['--play-and-exit', '--intf', 'dummy', file]],
137
+ ];
138
+ return powershell && isWsl() ? [powershell, ...local] : [...local, ...(powershell ? [powershell] : [])];
139
+ }
140
+
141
+ function play(file) {
142
+ for (const [cmd, args] of players(file)) {
143
+ try {
144
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
145
+ child.on('error', () => {});
146
+ child.unref();
147
+ return cmd;
148
+ } catch {
149
+ /* try the next player */
150
+ }
151
+ }
152
+ return '';
153
+ }
154
+
155
+ function main() {
156
+ if (process.env.MISAKANET_VOICE === '0') return;
157
+ const cue = parseCue(readStdin());
158
+ if (!cue) return;
159
+
160
+ if (process.env.MISAKANET_VOICE_DRY_RUN === '1') {
161
+ process.stdout.write(`${cue}\n`);
162
+ return;
163
+ }
164
+
165
+ // The cues sit next to the player when it is installed (…/voice/), next to the canonical
166
+ // copy in a repo checkout, or under docs/assets/voice/ there. Getting this wrong is silent
167
+ // by design — which is exactly how the first version of this file shipped mute.
168
+ const dirs = [
169
+ process.env.MISAKANET_VOICE_DIR,
170
+ HERE,
171
+ join(HERE, 'voice'),
172
+ join(HERE, '..', '..', 'docs', 'assets', 'voice'),
173
+ ].filter(Boolean);
174
+ const file = dirs.map((dir) => join(dir, `${cue}.mp3`)).find((candidate) => existsSync(candidate));
175
+ if (!file) return;
176
+ const used = play(file);
177
+ if (process.env.MISAKANET_VOICE_DEBUG === '1') {
178
+ // Synchronous write: process.exit(0) below would truncate an async stdout write, and a
179
+ // hook that prints on the normal path can look like a tool failure.
180
+ writeFileSync(1, `cue=${cue} player=${used || 'none'} file=${file}\n`);
181
+ }
182
+ }
183
+
184
+ try {
185
+ main();
186
+ } catch {
187
+ /* a hook must never surface as a tool failure */
188
+ } finally {
189
+ process.exit(0);
190
+ }
191
+
192
+ // Backstop: if a detached player leaves this process alive (some players keep a handle),
193
+ // leave anyway. The sound is best-effort by design.
194
+ setTimeout(() => process.exit(0), 3000).unref();