@misaka-net/misakanet-setup 0.4.2 → 0.5.1
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 +6 -0
- package/bin/misakanet-setup.mjs +216 -7
- package/package.json +2 -1
- package/voice/connect-success.mp3 +0 -0
- package/voice/failure-warning.mp3 +0 -0
- package/voice/lesson-found.mp3 +0 -0
- package/voice/pair-success.mp3 +0 -0
- package/voice/voice-hook.mjs +194 -0
package/README.md
CHANGED
|
@@ -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,11 @@ 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 --report # this machine's state as YAML, safe to paste in public
|
|
38
|
+
# (token value never printed, home paths written as ~;
|
|
39
|
+
# two blank fields are yours to fill — see the bounty)
|
|
40
|
+
npx @misaka-net/misakanet-setup --voice # opt-in: a cue when a search hits, another when it misses
|
|
41
|
+
# (Claude Code: a PostToolUse hook; mute later with MISAKANET_VOICE=0)
|
|
36
42
|
npx @misaka-net/misakanet-setup --uninstall # remove exactly what it added
|
|
37
43
|
```
|
|
38
44
|
|
package/bin/misakanet-setup.mjs
CHANGED
|
@@ -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));
|
|
@@ -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.
|
|
106
|
+
const VERSION = '0.5.1';
|
|
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
|
|
@@ -400,6 +445,62 @@ async function installClaude(hookPath, bearer) {
|
|
|
400
445
|
settings.hooks[event] = bucket;
|
|
401
446
|
changed = true;
|
|
402
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
|
+
let post = settings.hooks.PostToolUse || [];
|
|
470
|
+
// Upgrade path: 0.4.2 shipped this entry *without* a matcher, which never fires. Treat a
|
|
471
|
+
// matcher-less entry of ours as stale rather than "already installed", or re-running the
|
|
472
|
+
// installer would leave a hook that stays silent forever (the exact failure this release
|
|
473
|
+
// is about).
|
|
474
|
+
const stale = post.filter((e) => JSON.stringify(e).includes('voice-hook') && e.matcher !== '*');
|
|
475
|
+
if (stale.length) {
|
|
476
|
+
post = post.filter((e) => !stale.includes(e));
|
|
477
|
+
settings.hooks.PostToolUse = post;
|
|
478
|
+
changed = true;
|
|
479
|
+
}
|
|
480
|
+
if (!JSON.stringify(post).includes('voice-hook')) {
|
|
481
|
+
// `matcher: '*'` is load-bearing, not cosmetic: measured on 2026-09-16, a PostToolUse
|
|
482
|
+
// entry **without** a matcher never fired in this host, while the same command with
|
|
483
|
+
// `matcher: '*'` fired on every tool call. The hook itself filters by the `voice`
|
|
484
|
+
// field, so matching everything costs nothing.
|
|
485
|
+
post.push({
|
|
486
|
+
matcher: '*',
|
|
487
|
+
hooks: [{ type: 'command', command: `"${node}" "${join(destDir, 'voice-hook.mjs')}"` }],
|
|
488
|
+
});
|
|
489
|
+
settings.hooks.PostToolUse = post;
|
|
490
|
+
changed = true;
|
|
491
|
+
}
|
|
492
|
+
const player = detectPlayer();
|
|
493
|
+
if (player) {
|
|
494
|
+
ok(`语音钩子:已开启(PostToolUse → ${player});想静音设 MISAKANET_VOICE=0`);
|
|
495
|
+
} else {
|
|
496
|
+
need('语音钩子:装上了,但这台机器上没有找到播放器 → 不会有声音'
|
|
497
|
+
+ '(macOS 自带 afplay;Linux 装 paplay 或 ffplay;WSL 会走 Windows 的 PowerShell)');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
ok('语音钩子:未开启(想让命中/未命中时出声:重跑安装器并加 --voice)');
|
|
502
|
+
}
|
|
503
|
+
|
|
403
504
|
if (changed) {
|
|
404
505
|
backup(settingsPath);
|
|
405
506
|
writeText(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
@@ -774,6 +875,9 @@ async function installOpenclaw(bearer) {
|
|
|
774
875
|
ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
|
|
775
876
|
}
|
|
776
877
|
|
|
878
|
+
// The last endpoint probe, recorded so `--report` can print it without probing twice.
|
|
879
|
+
let lastProbe = { reachable: false, tools: 0 };
|
|
880
|
+
|
|
777
881
|
async function verify() {
|
|
778
882
|
let allOk = true;
|
|
779
883
|
// Handshake, not a search. `tools/list` proves the endpoint speaks MCP and answers, and it
|
|
@@ -784,6 +888,7 @@ async function verify() {
|
|
|
784
888
|
// network was broken (found 2026-09-15 by running the agent chain test on this machine).
|
|
785
889
|
const probe = await mcpRequest('tools/list', {}, 6000, probeEndpoint());
|
|
786
890
|
const tools = Array.isArray(probe?.tools) ? probe.tools : [];
|
|
891
|
+
lastProbe = { reachable: tools.length > 0, tools: tools.length };
|
|
787
892
|
if (tools.length) {
|
|
788
893
|
ok(`端点可达:${ENDPOINT}(MCP 握手成功,${tools.length} 个工具)`);
|
|
789
894
|
} else if (probe && (probe.error || probe.protocolVersion || probe.serverInfo)) {
|
|
@@ -942,12 +1047,20 @@ function uninstall() {
|
|
|
942
1047
|
writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
|
|
943
1048
|
ok(`移除 MCP 注册 → ${cfg}`);
|
|
944
1049
|
}
|
|
1050
|
+
const voiceDir = join(stateDir(), 'voice');
|
|
1051
|
+
if (existsSync(voiceDir)) {
|
|
1052
|
+
try {
|
|
1053
|
+
rmSync(voiceDir, { recursive: true, force: true });
|
|
1054
|
+
ok(`移除语音钩子文件 → ${voiceDir}`);
|
|
1055
|
+
} catch { /* best effort */ }
|
|
1056
|
+
}
|
|
945
1057
|
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
946
1058
|
const settings = readJson(settingsPath, null);
|
|
947
1059
|
if (settings?.hooks) {
|
|
948
1060
|
let changed = false;
|
|
949
1061
|
for (const event of Object.keys(settings.hooks)) {
|
|
950
|
-
const kept = (settings.hooks[event] || []).filter((entry) =>
|
|
1062
|
+
const kept = (settings.hooks[event] || []).filter((entry) =>
|
|
1063
|
+
!JSON.stringify(entry).includes('hook.mjs') && !JSON.stringify(entry).includes('voice-hook'));
|
|
951
1064
|
if (kept.length !== settings.hooks[event].length) {
|
|
952
1065
|
changed = true;
|
|
953
1066
|
if (kept.length) settings.hooks[event] = kept; else delete settings.hooks[event];
|
|
@@ -1014,6 +1127,88 @@ function uninstall() {
|
|
|
1014
1127
|
}
|
|
1015
1128
|
}
|
|
1016
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* `--report`: this machine's state as YAML, safe to paste into a public issue.
|
|
1132
|
+
*
|
|
1133
|
+
* Why it exists: the bounty that asks strangers' machines to run the installer also asks them to
|
|
1134
|
+
* report back — and a report that a human has to assemble by hand is (a) rarely sent and (b)
|
|
1135
|
+
* usually containing their token. So the tool prints its own evidence, redacted by construction,
|
|
1136
|
+
* and leaves only the two things it cannot know (which tools the *agent* sees, and a line of
|
|
1137
|
+
* live-call evidence) as empty fields for the reporter to fill.
|
|
1138
|
+
*
|
|
1139
|
+
* Invariants: the token value never appears (only present/absent); every home path is written as
|
|
1140
|
+
* `~`; no hostname, no IP, no user name. `redact()` is deliberately blunt and applied to every
|
|
1141
|
+
* string, including the messages assembled by the checks.
|
|
1142
|
+
*/
|
|
1143
|
+
function redact(value) {
|
|
1144
|
+
return String(value)
|
|
1145
|
+
.split(HOME).join('~')
|
|
1146
|
+
.replace(/mcp_[A-Za-z0-9_-]{8,}/g, '<REDACTED>')
|
|
1147
|
+
.replace(/sk-[A-Za-z0-9_-]{8,}/g, '<REDACTED>')
|
|
1148
|
+
.replace(/Bearer [A-Za-z0-9_.-]{8,}/gi, 'Bearer <REDACTED>');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function voiceStatus() {
|
|
1152
|
+
try {
|
|
1153
|
+
const settings = readJson(join(HOME, '.claude', 'settings.json'), null);
|
|
1154
|
+
const buckets = settings?.hooks?.PostToolUse;
|
|
1155
|
+
if (!Array.isArray(buckets)) return 'absent';
|
|
1156
|
+
const ours = buckets.filter((entry) => JSON.stringify(entry).includes('voice-hook'));
|
|
1157
|
+
if (!ours.length) return 'absent';
|
|
1158
|
+
return ours.some((entry) => entry.matcher === '*') ? 'on' : 'stale';
|
|
1159
|
+
} catch {
|
|
1160
|
+
return 'unknown';
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function platformName() {
|
|
1165
|
+
if (process.platform === 'win32') return 'windows';
|
|
1166
|
+
if (process.platform === 'darwin') return 'macos';
|
|
1167
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return 'wsl2';
|
|
1168
|
+
return process.platform; // linux
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function distroName() {
|
|
1172
|
+
try {
|
|
1173
|
+
const release = readFileSync('/etc/os-release', 'utf8');
|
|
1174
|
+
const id = release.match(/^ID="?([a-z0-9._-]+)"?$/m);
|
|
1175
|
+
return id ? id[1] : 'unknown';
|
|
1176
|
+
} catch {
|
|
1177
|
+
return 'n/a';
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async function report() {
|
|
1182
|
+
const allOk = await verify();
|
|
1183
|
+
const agents = AGENTS.filter((agent) => detect(agent));
|
|
1184
|
+
const token = existsSync(join(stateDir(), 'token'));
|
|
1185
|
+
const lines = [
|
|
1186
|
+
'# MisakaNet setup report — safe to paste in public; the two empty fields at the end are',
|
|
1187
|
+
'# for you to fill (they are the only parts this tool cannot know).',
|
|
1188
|
+
'schema: misakanet-setup-report/1',
|
|
1189
|
+
`setup-version: ${VERSION}`,
|
|
1190
|
+
`os: ${platformName()}`,
|
|
1191
|
+
`distro: ${distroName()}`,
|
|
1192
|
+
`arch: ${process.arch}`,
|
|
1193
|
+
`node: ${process.version}`,
|
|
1194
|
+
`detected-agents: [${agents.join(', ')}]`,
|
|
1195
|
+
`verify: ${allOk ? 'READY' : 'NOT READY'}`,
|
|
1196
|
+
`endpoint-reachable: ${lastProbe.reachable}`,
|
|
1197
|
+
`endpoint-tools: ${lastProbe.tools}`,
|
|
1198
|
+
`token: ${token ? 'present' : 'absent'}`,
|
|
1199
|
+
`hook: ${existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'}`,
|
|
1200
|
+
`voice: ${voiceStatus()}`,
|
|
1201
|
+
`open-items: ${manual.length}`,
|
|
1202
|
+
...(manual.length
|
|
1203
|
+
? ['open-items-detail:', ...manual.map((m) => ` - ${redact(m)}`)]
|
|
1204
|
+
: []),
|
|
1205
|
+
'# Fill these two in (see the bounty for how):',
|
|
1206
|
+
'tools-visible: {} # e.g. {codex: 7} — from `codex mcp list` / `codewhale mcp tools`',
|
|
1207
|
+
'live-call-evidence: "" # one line your agent printed when it called misakanet_search',
|
|
1208
|
+
];
|
|
1209
|
+
console.log(lines.join('\n'));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1017
1212
|
// ── main ─────────────────────────────────────────────────────────────
|
|
1018
1213
|
function render() {
|
|
1019
1214
|
const out = [];
|
|
@@ -1023,9 +1218,15 @@ function render() {
|
|
|
1023
1218
|
return out.join('\n');
|
|
1024
1219
|
}
|
|
1025
1220
|
|
|
1026
|
-
const mode = has('--uninstall') ? 'uninstall'
|
|
1027
|
-
|
|
1028
|
-
|
|
1221
|
+
const mode = has('--uninstall') ? 'uninstall'
|
|
1222
|
+
: (has('--report') ? 'report'
|
|
1223
|
+
: (has('--verify') ? 'verify' : 'install'));
|
|
1224
|
+
if (mode !== 'report') {
|
|
1225
|
+
// The banner names the home directory, which is exactly the kind of thing that gets pasted
|
|
1226
|
+
// along with a report — so `--report` prints only the report.
|
|
1227
|
+
console.log(`MisakaNet 安装程序(npx 版)${DRY ? '(--dry-run,不会写任何文件)' : ''}${has('--upgrade') ? '(--upgrade:与安装等价,覆盖安装即升级)' : ''}`);
|
|
1228
|
+
console.log(`家目录:${HOME}\n`);
|
|
1229
|
+
}
|
|
1029
1230
|
|
|
1030
1231
|
if (mode === 'uninstall') {
|
|
1031
1232
|
uninstall();
|
|
@@ -1034,6 +1235,14 @@ if (mode === 'uninstall') {
|
|
|
1034
1235
|
process.exit(0);
|
|
1035
1236
|
}
|
|
1036
1237
|
|
|
1238
|
+
if (mode === 'report') {
|
|
1239
|
+
// Reporting must work before an install too: a bot's first useful data point is often "this
|
|
1240
|
+
// machine has no agent config at all". Exit 0 even when NOT READY — the report is evidence,
|
|
1241
|
+
// not a gate.
|
|
1242
|
+
await report();
|
|
1243
|
+
process.exit(0);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1037
1246
|
if (mode === 'verify') {
|
|
1038
1247
|
const allOk = await verify();
|
|
1039
1248
|
console.log(render());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@misaka-net/misakanet-setup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
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"
|
|
@@ -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();
|