storymode-cli 1.1.2 → 1.2.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/package.json +1 -1
- package/src/cache.mjs +147 -0
- package/src/cli.mjs +66 -12
- package/src/hook.mjs +70 -0
- package/src/hooks.mjs +150 -0
- package/src/player.mjs +312 -0
- package/src/reactive.mjs +176 -0
package/package.json
CHANGED
package/src/cache.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fetchFrames, fetchCharacter } from './api.mjs';
|
|
5
|
+
|
|
6
|
+
const STORYMODE_DIR = join(homedir(), '.storymode');
|
|
7
|
+
|
|
8
|
+
export function getCacheDir(galleryId, size = 'compact') {
|
|
9
|
+
return join(STORYMODE_DIR, 'cache', String(galleryId), size);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getLocalDir(galleryId) {
|
|
13
|
+
return join(STORYMODE_DIR, 'local', String(galleryId));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Read all cached animation JSONs from disk. Returns Map<name, {fps, frames}> */
|
|
17
|
+
export function readCached(galleryId, size) {
|
|
18
|
+
const dir = getCacheDir(galleryId, size);
|
|
19
|
+
const map = new Map();
|
|
20
|
+
if (!existsSync(dir)) return map;
|
|
21
|
+
for (const file of readdirSync(dir)) {
|
|
22
|
+
if (!file.endsWith('.json') || file === 'character.json') continue;
|
|
23
|
+
try {
|
|
24
|
+
const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
|
|
25
|
+
const name = file.replace(/\.json$/, '').replace(/-/g, ' ');
|
|
26
|
+
map.set(name, data);
|
|
27
|
+
} catch {
|
|
28
|
+
// Corrupted cache file — skip it
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Write a single animation JSON to cache */
|
|
35
|
+
export function writeCache(galleryId, name, data, size) {
|
|
36
|
+
const dir = getCacheDir(galleryId, size);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
const filename = name.replace(/ /g, '-') + '.json';
|
|
39
|
+
writeFileSync(join(dir, filename), JSON.stringify(data));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Write character.json to cache */
|
|
43
|
+
export function writeCacheCharacter(galleryId, character, size) {
|
|
44
|
+
const dir = getCacheDir(galleryId, size);
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
writeFileSync(join(dir, 'character.json'), JSON.stringify(character));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read cached character.json */
|
|
50
|
+
export function readCachedCharacter(galleryId, size) {
|
|
51
|
+
const file = join(getCacheDir(galleryId, size), 'character.json');
|
|
52
|
+
if (!existsSync(file)) return null;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read local animation overrides. Returns Map<name, {fps, frames}> */
|
|
61
|
+
export function readLocalOverrides(galleryId) {
|
|
62
|
+
const dir = getLocalDir(galleryId);
|
|
63
|
+
const map = new Map();
|
|
64
|
+
if (!existsSync(dir)) return map;
|
|
65
|
+
for (const file of readdirSync(dir)) {
|
|
66
|
+
if (!file.endsWith('.json')) continue;
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
|
|
69
|
+
const name = file.replace(/\.json$/, '').replace(/-/g, ' ');
|
|
70
|
+
map.set(name, data);
|
|
71
|
+
} catch {
|
|
72
|
+
// Skip corrupted files
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load all animations for a gallery: local overrides > cache > server fetch.
|
|
80
|
+
* Returns { animMap: Map<name, {fps, frames}>, character }
|
|
81
|
+
*/
|
|
82
|
+
export async function loadAnimations(galleryId, { size = 'compact', mode } = {}) {
|
|
83
|
+
const localOverrides = readLocalOverrides(galleryId);
|
|
84
|
+
const cached = readCached(galleryId, size);
|
|
85
|
+
let character = readCachedCharacter(galleryId, size);
|
|
86
|
+
|
|
87
|
+
// If we have cached data and character, use it (fetch updates in background)
|
|
88
|
+
const hasCached = cached.size > 0 && character;
|
|
89
|
+
|
|
90
|
+
if (!hasCached) {
|
|
91
|
+
// Must fetch from server
|
|
92
|
+
character = await fetchCharacter(galleryId);
|
|
93
|
+
writeCacheCharacter(galleryId, character, size);
|
|
94
|
+
|
|
95
|
+
const anims = character.animations || [];
|
|
96
|
+
const total = anims.length;
|
|
97
|
+
let loaded = 0;
|
|
98
|
+
|
|
99
|
+
process.stderr.write(` Loading animations... 0/${total}\r`);
|
|
100
|
+
|
|
101
|
+
// Fetch all animations in parallel
|
|
102
|
+
const results = await Promise.all(
|
|
103
|
+
anims.map(async (anim) => {
|
|
104
|
+
const fetchOpts = { size, animId: anim.id };
|
|
105
|
+
if (mode) fetchOpts.mode = mode;
|
|
106
|
+
const data = await fetchFrames(galleryId, fetchOpts);
|
|
107
|
+
loaded++;
|
|
108
|
+
process.stderr.write(` Loading animations... ${loaded}/${total}\r`);
|
|
109
|
+
return { name: anim.name, data };
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
process.stderr.write(` Loading animations... ${total}/${total} done.\n`);
|
|
114
|
+
|
|
115
|
+
for (const { name, data } of results) {
|
|
116
|
+
cached.set(name, data);
|
|
117
|
+
writeCache(galleryId, name, data, size);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Merge: local overrides win over cache
|
|
122
|
+
const animMap = new Map(cached);
|
|
123
|
+
for (const [name, data] of localOverrides) {
|
|
124
|
+
animMap.set(name, data);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { animMap, character };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Delete cache for one or all characters */
|
|
131
|
+
export function clearCache(galleryId) {
|
|
132
|
+
if (galleryId) {
|
|
133
|
+
const dir = getCacheDir(galleryId);
|
|
134
|
+
if (existsSync(dir)) {
|
|
135
|
+
rmSync(dir, { recursive: true });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
// Clear all
|
|
141
|
+
const cacheRoot = join(STORYMODE_DIR, 'cache');
|
|
142
|
+
if (existsSync(cacheRoot)) {
|
|
143
|
+
rmSync(cacheRoot, { recursive: true });
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import { fetchFrames, fetchCharacter } from './api.mjs';
|
|
3
|
-
import { playAnimation, playCompanion, showFrame } from './player.mjs';
|
|
3
|
+
import { playAnimation, playCompanion, playReactiveCompanion, showFrame } from './player.mjs';
|
|
4
4
|
import { browse } from './browse.mjs';
|
|
5
5
|
import { startMcpServer } from './mcp.mjs';
|
|
6
|
+
import { loadAnimations, clearCache } from './cache.mjs';
|
|
7
|
+
import { installHooks, uninstallHooks } from './hooks.mjs';
|
|
6
8
|
|
|
7
9
|
const HELP = `
|
|
8
10
|
storymode-cli — play AI-animated pixel art in your terminal
|
|
@@ -13,12 +15,15 @@ const HELP = `
|
|
|
13
15
|
storymode show <gallery_id> Show first frame as static portrait
|
|
14
16
|
storymode browse Browse the gallery interactively
|
|
15
17
|
storymode mcp Start MCP server (for Claude Code)
|
|
18
|
+
storymode hooks install [--global] Install Claude Code hooks
|
|
19
|
+
storymode hooks uninstall [--global] Remove Claude Code hooks
|
|
20
|
+
storymode cache clear [gallery_id] Clear animation cache
|
|
16
21
|
|
|
17
22
|
Options:
|
|
18
|
-
--sextant
|
|
19
|
-
a modern terminal). Works with play, companion, show.
|
|
23
|
+
--sextant Use sextant rendering (sharper, needs modern terminal).
|
|
20
24
|
--size=compact|tiny Size for companion mode (default: compact).
|
|
21
|
-
--
|
|
25
|
+
--reactive Enable reactive mode (responds to Claude Code events).
|
|
26
|
+
--detach Return control immediately (companion mode).
|
|
22
27
|
|
|
23
28
|
Controls (during playback):
|
|
24
29
|
space pause / resume
|
|
@@ -69,20 +74,32 @@ export async function run(args) {
|
|
|
69
74
|
|
|
70
75
|
case 'companion': {
|
|
71
76
|
if (!id) {
|
|
72
|
-
console.error('Usage: storymode companion <gallery_id> [--detach] [--size=compact|tiny]');
|
|
77
|
+
console.error('Usage: storymode companion <gallery_id> [--reactive] [--detach] [--size=full|compact|tiny]');
|
|
73
78
|
process.exit(1);
|
|
74
79
|
}
|
|
75
80
|
const size = flags.size || 'compact';
|
|
76
81
|
const detach = !!flags.detach;
|
|
77
|
-
const
|
|
82
|
+
const reactive = !!flags.reactive;
|
|
83
|
+
const paneWidth = size === 'full' ? 62 : size === 'tiny' ? 14 : 22;
|
|
78
84
|
|
|
79
85
|
// If we're already the companion pane (spawned by tmux split), play inline
|
|
80
86
|
if (process.env.STORYMODE_COMPANION === '1') {
|
|
81
87
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
if (reactive) {
|
|
89
|
+
// Reactive mode: load all animations, start socket server
|
|
90
|
+
const fetchOpts = { size };
|
|
91
|
+
if (flags.sextant) fetchOpts.mode = 'sextant';
|
|
92
|
+
const { animMap, character } = await loadAnimations(id, fetchOpts);
|
|
93
|
+
await playReactiveCompanion(animMap, {
|
|
94
|
+
characterName: character?.name || `#${id}`,
|
|
95
|
+
fullscreen: size === 'full',
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
const fetchOpts = { size };
|
|
99
|
+
if (flags.sextant) fetchOpts.mode = 'sextant';
|
|
100
|
+
const framesData = await fetchFrames(id, fetchOpts);
|
|
101
|
+
await playCompanion(framesData);
|
|
102
|
+
}
|
|
86
103
|
} catch (err) {
|
|
87
104
|
console.error(`Error: ${err.message}`);
|
|
88
105
|
process.exit(1);
|
|
@@ -90,10 +107,16 @@ export async function run(args) {
|
|
|
90
107
|
break;
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
// Auto-install hooks when reactive mode is used
|
|
111
|
+
if (reactive) {
|
|
112
|
+
installHooks({ global: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
// Otherwise, open a tmux side pane
|
|
94
116
|
const inTmux = !!process.env.TMUX;
|
|
95
117
|
const sextantFlag = flags.sextant ? ' --sextant' : '';
|
|
96
|
-
const
|
|
118
|
+
const reactiveFlag = reactive ? ' --reactive' : '';
|
|
119
|
+
const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli companion ${id} --size=${size}${sextantFlag}${reactiveFlag}`;
|
|
97
120
|
|
|
98
121
|
try {
|
|
99
122
|
if (inTmux) {
|
|
@@ -103,7 +126,7 @@ export async function run(args) {
|
|
|
103
126
|
{ stdio: 'ignore' }
|
|
104
127
|
);
|
|
105
128
|
if (!detach) {
|
|
106
|
-
process.stderr.write(` Companion sprite opened in side pane (${size}).\n`);
|
|
129
|
+
process.stderr.write(` Companion sprite opened in side pane (${size}${reactive ? ', reactive' : ''}).\n`);
|
|
107
130
|
process.stderr.write(' Close the pane or press q in it to stop.\n');
|
|
108
131
|
}
|
|
109
132
|
} else {
|
|
@@ -168,6 +191,37 @@ export async function run(args) {
|
|
|
168
191
|
startMcpServer();
|
|
169
192
|
break;
|
|
170
193
|
|
|
194
|
+
case 'hooks': {
|
|
195
|
+
const subcmd = positional[0];
|
|
196
|
+
const global = !!flags.global;
|
|
197
|
+
if (subcmd === 'install') {
|
|
198
|
+
installHooks({ global });
|
|
199
|
+
} else if (subcmd === 'uninstall') {
|
|
200
|
+
uninstallHooks({ global });
|
|
201
|
+
} else {
|
|
202
|
+
console.error('Usage: storymode hooks install|uninstall [--global]');
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'cache': {
|
|
209
|
+
const subcmd = positional[0];
|
|
210
|
+
if (subcmd === 'clear') {
|
|
211
|
+
const galleryId = positional[1];
|
|
212
|
+
const cleared = clearCache(galleryId);
|
|
213
|
+
if (cleared) {
|
|
214
|
+
console.log(` Cache cleared${galleryId ? ` for gallery ${galleryId}` : ' (all)'}.\n`);
|
|
215
|
+
} else {
|
|
216
|
+
console.log(' No cache found.\n');
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
console.error('Usage: storymode cache clear [gallery_id]');
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
171
225
|
case '--help':
|
|
172
226
|
case '-h':
|
|
173
227
|
case 'help':
|
package/src/hook.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight hook forwarder for Claude Code hooks.
|
|
4
|
+
* Reads STORYMODE_HOOK_EVENT env var + stdin JSON, sends to companion socket.
|
|
5
|
+
* Always exits 0 — must never block Claude Code.
|
|
6
|
+
*/
|
|
7
|
+
import { connect } from 'node:net';
|
|
8
|
+
import { readdirSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
const event = process.env.STORYMODE_HOOK_EVENT;
|
|
11
|
+
if (!event) process.exit(0);
|
|
12
|
+
|
|
13
|
+
// Find the companion socket in /tmp
|
|
14
|
+
function findSocket() {
|
|
15
|
+
try {
|
|
16
|
+
const files = readdirSync('/tmp');
|
|
17
|
+
const sock = files.find(f => f.startsWith('storymode-companion-') && f.endsWith('.sock'));
|
|
18
|
+
return sock ? `/tmp/${sock}` : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Read stdin (Claude Code passes hook context as JSON on stdin)
|
|
25
|
+
let stdinData = '';
|
|
26
|
+
process.stdin.setEncoding('utf-8');
|
|
27
|
+
process.stdin.on('data', (chunk) => { stdinData += chunk; });
|
|
28
|
+
|
|
29
|
+
// Use a 1s safety timeout — must not hang
|
|
30
|
+
const timeout = setTimeout(() => process.exit(0), 1000);
|
|
31
|
+
|
|
32
|
+
process.stdin.on('end', () => {
|
|
33
|
+
const sockPath = findSocket();
|
|
34
|
+
if (!sockPath) {
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let parsed = {};
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(stdinData);
|
|
42
|
+
} catch {
|
|
43
|
+
// No valid JSON on stdin — that's fine for some events
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const payload = JSON.stringify({ event, ...parsed }) + '\n';
|
|
47
|
+
|
|
48
|
+
const client = connect(sockPath, () => {
|
|
49
|
+
client.write(payload);
|
|
50
|
+
client.end();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
client.on('close', () => {
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
client.on('error', () => {
|
|
59
|
+
// Companion not running — that's fine
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// If stdin is already closed (no piped data), trigger 'end' manually
|
|
66
|
+
if (process.stdin.readableEnded) {
|
|
67
|
+
process.stdin.emit('end');
|
|
68
|
+
} else {
|
|
69
|
+
process.stdin.resume();
|
|
70
|
+
}
|
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook installer/uninstaller for Claude Code.
|
|
3
|
+
* Merges storymode hook entries into Claude's settings.json.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
// Resolve absolute path to hook.mjs
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const HOOK_SCRIPT = join(dirname(__filename), 'hook.mjs');
|
|
13
|
+
|
|
14
|
+
function getSettingsPath(global) {
|
|
15
|
+
if (global) {
|
|
16
|
+
return join(homedir(), '.claude', 'settings.json');
|
|
17
|
+
}
|
|
18
|
+
return join(process.cwd(), '.claude', 'settings.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeHookEntry(eventName) {
|
|
22
|
+
return {
|
|
23
|
+
type: 'command',
|
|
24
|
+
command: `STORYMODE_HOOK_EVENT=${eventName} node "${HOOK_SCRIPT}"`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The hook configuration we want to merge into settings.
|
|
30
|
+
* Uses PostToolUse matchers for action vs research distinction.
|
|
31
|
+
*/
|
|
32
|
+
function getStorymodeHooks() {
|
|
33
|
+
return {
|
|
34
|
+
SessionStart: [
|
|
35
|
+
{ hooks: [makeHookEntry('SessionStart')] },
|
|
36
|
+
],
|
|
37
|
+
UserPromptSubmit: [
|
|
38
|
+
{ hooks: [makeHookEntry('UserPromptSubmit')] },
|
|
39
|
+
],
|
|
40
|
+
PostToolUse: [
|
|
41
|
+
{ matcher: 'Bash|Edit|Write', hooks: [makeHookEntry('PostToolUse_action')] },
|
|
42
|
+
{ matcher: 'Read|Grep|Glob', hooks: [makeHookEntry('PostToolUse_research')] },
|
|
43
|
+
],
|
|
44
|
+
PostToolUseFailure: [
|
|
45
|
+
{ hooks: [makeHookEntry('PostToolUseFailure')] },
|
|
46
|
+
],
|
|
47
|
+
Stop: [
|
|
48
|
+
{ hooks: [makeHookEntry('Stop')] },
|
|
49
|
+
],
|
|
50
|
+
SessionEnd: [
|
|
51
|
+
{ hooks: [makeHookEntry('SessionEnd')] },
|
|
52
|
+
],
|
|
53
|
+
SubagentStart: [
|
|
54
|
+
{ hooks: [makeHookEntry('SubagentStart')] },
|
|
55
|
+
],
|
|
56
|
+
SubagentStop: [
|
|
57
|
+
{ hooks: [makeHookEntry('SubagentStop')] },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Check if a hook entry is one of ours (by command prefix) */
|
|
63
|
+
function isStorymodeHook(entry) {
|
|
64
|
+
if (entry.type !== 'command') return false;
|
|
65
|
+
return entry.command.includes('STORYMODE_HOOK_EVENT=');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check if a hook group is one of ours */
|
|
69
|
+
function isStorymodeGroup(group) {
|
|
70
|
+
return group.hooks?.some(isStorymodeHook);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function installHooks({ global = false } = {}) {
|
|
74
|
+
const settingsPath = getSettingsPath(global);
|
|
75
|
+
let settings = {};
|
|
76
|
+
|
|
77
|
+
if (existsSync(settingsPath)) {
|
|
78
|
+
try {
|
|
79
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
80
|
+
} catch {
|
|
81
|
+
console.error(` Error reading ${settingsPath}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!settings.hooks) settings.hooks = {};
|
|
87
|
+
|
|
88
|
+
const newHooks = getStorymodeHooks();
|
|
89
|
+
|
|
90
|
+
for (const [eventName, groups] of Object.entries(newHooks)) {
|
|
91
|
+
if (!settings.hooks[eventName]) {
|
|
92
|
+
settings.hooks[eventName] = [];
|
|
93
|
+
}
|
|
94
|
+
// Remove any existing storymode entries
|
|
95
|
+
settings.hooks[eventName] = settings.hooks[eventName].filter(g => !isStorymodeGroup(g));
|
|
96
|
+
// Add our entries
|
|
97
|
+
settings.hooks[eventName].push(...groups);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
101
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
102
|
+
|
|
103
|
+
const scope = global ? 'global' : 'project';
|
|
104
|
+
console.log(` Storymode hooks installed (${scope}): ${settingsPath}`);
|
|
105
|
+
console.log(` Hook script: ${HOOK_SCRIPT}`);
|
|
106
|
+
console.log(` Events: ${Object.keys(newHooks).join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function uninstallHooks({ global = false } = {}) {
|
|
110
|
+
const settingsPath = getSettingsPath(global);
|
|
111
|
+
|
|
112
|
+
if (!existsSync(settingsPath)) {
|
|
113
|
+
console.log(' No settings file found — nothing to uninstall.');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let settings;
|
|
118
|
+
try {
|
|
119
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
120
|
+
} catch {
|
|
121
|
+
console.error(` Error reading ${settingsPath}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!settings.hooks) {
|
|
126
|
+
console.log(' No hooks configured — nothing to uninstall.');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let removed = 0;
|
|
131
|
+
for (const eventName of Object.keys(settings.hooks)) {
|
|
132
|
+
const before = settings.hooks[eventName].length;
|
|
133
|
+
settings.hooks[eventName] = settings.hooks[eventName].filter(g => !isStorymodeGroup(g));
|
|
134
|
+
removed += before - settings.hooks[eventName].length;
|
|
135
|
+
// Clean up empty arrays
|
|
136
|
+
if (settings.hooks[eventName].length === 0) {
|
|
137
|
+
delete settings.hooks[eventName];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Clean up empty hooks object
|
|
142
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
143
|
+
delete settings.hooks;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
147
|
+
|
|
148
|
+
const scope = global ? 'global' : 'project';
|
|
149
|
+
console.log(` Removed ${removed} storymode hook entries (${scope}): ${settingsPath}`);
|
|
150
|
+
}
|
package/src/player.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { unlinkSync, existsSync } from 'node:fs';
|
|
3
|
+
import { mapEventToAnimation, pickSpeech, ONE_SHOT_ANIMS, IDLE_ANIM, SLEEP_ANIM, SLEEP_TIMEOUT_S, narrateEvent, SessionContext } from './reactive.mjs';
|
|
4
|
+
|
|
1
5
|
const { stdout, stdin } = process;
|
|
2
6
|
|
|
3
7
|
export function playAnimation(framesData) {
|
|
@@ -201,6 +205,314 @@ export function playCompanion(framesData) {
|
|
|
201
205
|
});
|
|
202
206
|
}
|
|
203
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Reactive companion player — responds to Claude Code events via Unix socket.
|
|
210
|
+
*
|
|
211
|
+
* @param {Map<string, {fps: number, frames: string[][]}>} animMap - All animations keyed by name
|
|
212
|
+
* @param {object} [opts]
|
|
213
|
+
* @param {string} [opts.characterName] - Character name for status display
|
|
214
|
+
* @param {boolean} [opts.fullscreen] - Use alt screen (full-screen mode)
|
|
215
|
+
*/
|
|
216
|
+
export function playReactiveCompanion(animMap, opts = {}) {
|
|
217
|
+
const characterName = opts.characterName || 'companion';
|
|
218
|
+
const fullscreen = !!opts.fullscreen;
|
|
219
|
+
|
|
220
|
+
// Find idle animation (required)
|
|
221
|
+
const idleData = animMap.get(IDLE_ANIM);
|
|
222
|
+
if (!idleData || idleData.frames.length === 0) {
|
|
223
|
+
console.log('No idle animation found. Need "idle breathing" animation.');
|
|
224
|
+
return Promise.resolve();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Calculate maxLines across ALL animations for stable layout
|
|
228
|
+
let maxLines = 0;
|
|
229
|
+
for (const [, data] of animMap) {
|
|
230
|
+
for (const frame of data.frames) {
|
|
231
|
+
if (frame.length > maxLines) maxLines = frame.length;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Speech bubble: max 3 lines + border (top + bottom) + blank line = 6 extra lines
|
|
236
|
+
const SPEECH_CLEAR_MS = 6000;
|
|
237
|
+
const BUBBLE_MAX_LINES = 3;
|
|
238
|
+
const bubbleLines = BUBBLE_MAX_LINES + 3; // top border + text lines + bottom border
|
|
239
|
+
|
|
240
|
+
// Status line + quit hint + speech bubble area
|
|
241
|
+
const totalLines = maxLines + 1 + bubbleLines;
|
|
242
|
+
|
|
243
|
+
// Animation state
|
|
244
|
+
let currentAnimName = IDLE_ANIM;
|
|
245
|
+
let currentFrames = idleData.frames;
|
|
246
|
+
let currentFps = idleData.fps || 16;
|
|
247
|
+
let frameIdx = 0;
|
|
248
|
+
let loopCount = 0;
|
|
249
|
+
let oneShotLoopsRemaining = 0; // >0 means playing a one-shot
|
|
250
|
+
let quit = false;
|
|
251
|
+
|
|
252
|
+
// Sleep state
|
|
253
|
+
let lastEventTime = Date.now();
|
|
254
|
+
let sleeping = false;
|
|
255
|
+
|
|
256
|
+
// Speech bubble state
|
|
257
|
+
let speechText = null;
|
|
258
|
+
let speechSetAt = 0;
|
|
259
|
+
let lastSpeech = null;
|
|
260
|
+
|
|
261
|
+
// Recent animation tracking (avoid repeats)
|
|
262
|
+
const recentAnims = [];
|
|
263
|
+
|
|
264
|
+
// Session context (for Phase 2 narrator)
|
|
265
|
+
const sessionCtx = new SessionContext();
|
|
266
|
+
|
|
267
|
+
// Socket path
|
|
268
|
+
const sockPath = `/tmp/storymode-companion-${process.pid}.sock`;
|
|
269
|
+
|
|
270
|
+
// Clean up stale socket
|
|
271
|
+
if (existsSync(sockPath)) {
|
|
272
|
+
try { unlinkSync(sockPath); } catch { /* ignore */ }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// --- Terminal setup ---
|
|
276
|
+
if (fullscreen) {
|
|
277
|
+
// Alt screen, hide cursor, clear
|
|
278
|
+
stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H');
|
|
279
|
+
} else {
|
|
280
|
+
stdout.write('\x1b[?25l'); // hide cursor
|
|
281
|
+
// Reserve space
|
|
282
|
+
for (let i = 0; i < totalLines; i++) {
|
|
283
|
+
stdout.write('\n');
|
|
284
|
+
}
|
|
285
|
+
stdout.write(`\x1b[${totalLines}A`);
|
|
286
|
+
stdout.write('\x1b7'); // save cursor position
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function switchAnimation(animName, type) {
|
|
290
|
+
if (!animMap.has(animName)) {
|
|
291
|
+
// Fallback to idle if animation not found
|
|
292
|
+
if (animName === IDLE_ANIM) return;
|
|
293
|
+
animName = IDLE_ANIM;
|
|
294
|
+
type = 'loop';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const data = animMap.get(animName);
|
|
298
|
+
currentAnimName = animName;
|
|
299
|
+
currentFrames = data.frames;
|
|
300
|
+
currentFps = data.fps || 16;
|
|
301
|
+
frameIdx = 0;
|
|
302
|
+
loopCount = 0;
|
|
303
|
+
|
|
304
|
+
if (type === 'one-shot' || ONE_SHOT_ANIMS.has(animName)) {
|
|
305
|
+
oneShotLoopsRemaining = 2; // play 2 loops for one-shot
|
|
306
|
+
} else {
|
|
307
|
+
oneShotLoopsRemaining = 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Track recent
|
|
311
|
+
recentAnims.push(animName);
|
|
312
|
+
if (recentAnims.length > 3) recentAnims.shift();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Word-wrap text to fit inside a bubble of given inner width */
|
|
316
|
+
function wrapText(text, width) {
|
|
317
|
+
const words = text.split(' ');
|
|
318
|
+
const lines = [];
|
|
319
|
+
let line = '';
|
|
320
|
+
for (const word of words) {
|
|
321
|
+
if (line.length + word.length + (line ? 1 : 0) > width) {
|
|
322
|
+
if (line) lines.push(line);
|
|
323
|
+
line = word.slice(0, width); // truncate long words
|
|
324
|
+
} else {
|
|
325
|
+
line = line ? line + ' ' + word : word;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (line) lines.push(line);
|
|
329
|
+
return lines.slice(0, BUBBLE_MAX_LINES);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Render a speech bubble as an array of strings */
|
|
333
|
+
function renderBubble(text, width) {
|
|
334
|
+
const innerW = width - 4; // │ + space + text + space + │
|
|
335
|
+
const wrapped = wrapText(text, innerW);
|
|
336
|
+
const lines = [];
|
|
337
|
+
lines.push(' \x1b[0m╭' + '─'.repeat(innerW + 2) + '╮');
|
|
338
|
+
for (const wl of wrapped) {
|
|
339
|
+
lines.push(' \x1b[0m│ ' + wl + ' '.repeat(innerW - wl.length) + ' │');
|
|
340
|
+
}
|
|
341
|
+
// Pad remaining bubble lines if fewer than max
|
|
342
|
+
for (let i = wrapped.length; i < BUBBLE_MAX_LINES; i++) {
|
|
343
|
+
lines.push(' \x1b[0m│' + ' '.repeat(innerW + 2) + '│');
|
|
344
|
+
}
|
|
345
|
+
lines.push(' \x1b[0m╰' + '─'.repeat(innerW + 2) + '╯');
|
|
346
|
+
return lines;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Estimate sprite width from first frame of idle animation
|
|
350
|
+
const spriteWidth = (() => {
|
|
351
|
+
const firstLine = idleData.frames[0]?.[0] || '';
|
|
352
|
+
// Strip ANSI escape sequences to get visible character count
|
|
353
|
+
return firstLine.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
354
|
+
})();
|
|
355
|
+
const bubbleWidth = Math.max(20, Math.min(spriteWidth, 60));
|
|
356
|
+
|
|
357
|
+
function drawFrame(idx) {
|
|
358
|
+
let out = fullscreen ? '\x1b[H' : '\x1b8'; // cursor home vs restore
|
|
359
|
+
const frameLines = currentFrames[idx] || [];
|
|
360
|
+
for (let i = 0; i < maxLines; i++) {
|
|
361
|
+
if (i < frameLines.length) {
|
|
362
|
+
out += frameLines[i];
|
|
363
|
+
}
|
|
364
|
+
out += '\x1b[K\n';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Auto-clear speech after timeout
|
|
368
|
+
if (speechText && (Date.now() - speechSetAt > SPEECH_CLEAR_MS)) {
|
|
369
|
+
speechText = null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Speech bubble or empty space
|
|
373
|
+
if (speechText) {
|
|
374
|
+
const bubble = renderBubble(speechText, bubbleWidth);
|
|
375
|
+
for (const bl of bubble) {
|
|
376
|
+
out += bl + '\x1b[K\n';
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
// Empty lines to keep layout stable
|
|
380
|
+
for (let i = 0; i < bubbleLines - 1; i++) {
|
|
381
|
+
out += '\x1b[K\n';
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Status line
|
|
386
|
+
out += `\x1b[0m\x1b[K ${characterName} [${currentAnimName}] [q]=quit\n`;
|
|
387
|
+
stdout.write(out);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// --- Socket server ---
|
|
391
|
+
const server = createServer((conn) => {
|
|
392
|
+
let buf = '';
|
|
393
|
+
conn.on('data', (chunk) => { buf += chunk.toString(); });
|
|
394
|
+
conn.on('end', () => {
|
|
395
|
+
// Process each line (usually just one)
|
|
396
|
+
for (const line of buf.split('\n').filter(Boolean)) {
|
|
397
|
+
try {
|
|
398
|
+
const msg = JSON.parse(line);
|
|
399
|
+
handleEvent(msg);
|
|
400
|
+
} catch {
|
|
401
|
+
// Ignore malformed messages
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
server.on('error', (err) => {
|
|
408
|
+
// If socket already in use, try to recover
|
|
409
|
+
if (err.code === 'EADDRINUSE') {
|
|
410
|
+
try { unlinkSync(sockPath); } catch { /* ignore */ }
|
|
411
|
+
server.listen(sockPath);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
server.listen(sockPath);
|
|
416
|
+
|
|
417
|
+
function handleEvent(msg) {
|
|
418
|
+
lastEventTime = Date.now();
|
|
419
|
+
sleeping = false;
|
|
420
|
+
|
|
421
|
+
// Update session context
|
|
422
|
+
sessionCtx.update(msg);
|
|
423
|
+
|
|
424
|
+
// Instant layer: static map
|
|
425
|
+
const mapping = mapEventToAnimation(msg.event, animMap);
|
|
426
|
+
if (mapping) {
|
|
427
|
+
switchAnimation(mapping.anim, mapping.type);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Speech bubble
|
|
431
|
+
const speech = pickSpeech(msg.event, lastSpeech);
|
|
432
|
+
if (speech) {
|
|
433
|
+
speechText = speech;
|
|
434
|
+
speechSetAt = Date.now();
|
|
435
|
+
lastSpeech = speech;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function cleanup() {
|
|
440
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
441
|
+
stdin.removeAllListeners('data');
|
|
442
|
+
stdin.pause();
|
|
443
|
+
if (fullscreen) {
|
|
444
|
+
stdout.write('\x1b[?1049l\x1b[?25h'); // exit alt screen, show cursor
|
|
445
|
+
} else {
|
|
446
|
+
stdout.write('\x1b[?25h'); // show cursor
|
|
447
|
+
}
|
|
448
|
+
server.close();
|
|
449
|
+
try { unlinkSync(sockPath); } catch { /* ignore */ }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function handleKey(data) {
|
|
453
|
+
const key = data.toString();
|
|
454
|
+
if (key === 'q' || key === '\x03') {
|
|
455
|
+
quit = true;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (stdin.isTTY) {
|
|
460
|
+
stdin.setRawMode(true);
|
|
461
|
+
stdin.resume();
|
|
462
|
+
stdin.on('data', handleKey);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return new Promise((resolve) => {
|
|
466
|
+
const interval = () => 1000 / currentFps;
|
|
467
|
+
|
|
468
|
+
function tick() {
|
|
469
|
+
if (quit) {
|
|
470
|
+
cleanup();
|
|
471
|
+
resolve();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Check for sleep
|
|
476
|
+
const idleTime = (Date.now() - lastEventTime) / 1000;
|
|
477
|
+
if (idleTime >= SLEEP_TIMEOUT_S && !sleeping) {
|
|
478
|
+
sleeping = true;
|
|
479
|
+
if (animMap.has(SLEEP_ANIM)) {
|
|
480
|
+
switchAnimation(SLEEP_ANIM, 'loop');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
drawFrame(frameIdx);
|
|
485
|
+
frameIdx++;
|
|
486
|
+
|
|
487
|
+
if (frameIdx >= currentFrames.length) {
|
|
488
|
+
loopCount++;
|
|
489
|
+
frameIdx = 0;
|
|
490
|
+
|
|
491
|
+
// One-shot: count down loops, return to idle
|
|
492
|
+
if (oneShotLoopsRemaining > 0) {
|
|
493
|
+
oneShotLoopsRemaining--;
|
|
494
|
+
if (oneShotLoopsRemaining <= 0) {
|
|
495
|
+
switchAnimation(IDLE_ANIM, 'loop');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
setTimeout(tick, interval());
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
tick();
|
|
504
|
+
|
|
505
|
+
// Print socket path so hooks can find it
|
|
506
|
+
process.stderr.write(` Socket: ${sockPath}\n`);
|
|
507
|
+
process.stderr.write(` Listening for Claude Code events...\n`);
|
|
508
|
+
|
|
509
|
+
const sigHandler = () => { quit = true; };
|
|
510
|
+
process.on('SIGINT', sigHandler);
|
|
511
|
+
process.on('SIGTERM', sigHandler);
|
|
512
|
+
process.on('SIGHUP', sigHandler);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
204
516
|
export function showFrame(framesData, info) {
|
|
205
517
|
const frames = framesData.frames;
|
|
206
518
|
if (!frames || frames.length === 0) {
|
package/src/reactive.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive companion: event mapping, narrator, animation loader.
|
|
3
|
+
* Phase 1 — static map only (no LLM).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Animations that play once then return to idle */
|
|
7
|
+
export const ONE_SHOT_ANIMS = new Set([
|
|
8
|
+
'waving hello',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
/** Default idle animation name */
|
|
12
|
+
export const IDLE_ANIM = 'idle breathing';
|
|
13
|
+
|
|
14
|
+
/** Sleep animation name */
|
|
15
|
+
export const SLEEP_ANIM = 'falling asleep';
|
|
16
|
+
|
|
17
|
+
/** Seconds of inactivity before sleep */
|
|
18
|
+
export const SLEEP_TIMEOUT_S = 120;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Static map: hook event → animation name + type.
|
|
22
|
+
* Key format matches STORYMODE_HOOK_EVENT env var values.
|
|
23
|
+
*/
|
|
24
|
+
export const STATIC_MAP = {
|
|
25
|
+
SessionStart: { anim: 'waving hello', type: 'one-shot' },
|
|
26
|
+
UserPromptSubmit: { anim: 'scratching head while thinking', type: 'loop' },
|
|
27
|
+
PostToolUse_action: { anim: 'casting lightning spell', type: 'loop' },
|
|
28
|
+
PostToolUse_research: { anim: 'scratching head while thinking', type: 'loop' },
|
|
29
|
+
PostToolUseFailure: { anim: 'charging up magical energy', type: 'loop' },
|
|
30
|
+
Stop: { anim: 'idle breathing', type: 'loop' },
|
|
31
|
+
SessionEnd: { anim: 'waving hello', type: 'one-shot' },
|
|
32
|
+
SubagentStart: { anim: 'charging up magical energy', type: 'one-shot' },
|
|
33
|
+
SubagentStop: { anim: 'idle breathing', type: 'loop' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Speech lines per event type. A random one is picked each time.
|
|
38
|
+
* null = no speech (stay quiet).
|
|
39
|
+
*/
|
|
40
|
+
export const SPEECH_MAP = {
|
|
41
|
+
SessionStart: ['Hey there!', 'Ready to code!', "Let's go~", 'Hi hi!', 'Oh, hello!'],
|
|
42
|
+
UserPromptSubmit: ['Hmm...', 'Interesting...', 'Ooh, a challenge!', 'Let me think...', null, null],
|
|
43
|
+
PostToolUse_action: ['Nice!', 'Zap!', 'Code go brrr~', null, null, null],
|
|
44
|
+
PostToolUse_research: ['Looking...', 'Where is it...', null, null, null, null],
|
|
45
|
+
PostToolUseFailure: ['Oof!', 'That stings...', "We'll fix it!", 'Ouch!', 'Ow ow ow'],
|
|
46
|
+
Stop: ['Done!', 'All yours~', null, null],
|
|
47
|
+
SessionEnd: ['Bye bye!', 'See ya~', 'Good session!'],
|
|
48
|
+
SubagentStart: ['Reinforcements!', 'A friend!', 'Backup arriving~'],
|
|
49
|
+
SubagentStop: ['And they vanish...', 'Back to us~', null],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Pick a random speech line for an event, avoiding the last spoken line */
|
|
53
|
+
export function pickSpeech(eventType, lastSpeech) {
|
|
54
|
+
const lines = SPEECH_MAP[eventType];
|
|
55
|
+
if (!lines || lines.length === 0) return null;
|
|
56
|
+
// Try up to 3 times to avoid repeating
|
|
57
|
+
for (let i = 0; i < 3; i++) {
|
|
58
|
+
const pick = lines[Math.floor(Math.random() * lines.length)];
|
|
59
|
+
if (pick !== lastSpeech) return pick;
|
|
60
|
+
}
|
|
61
|
+
return lines[Math.floor(Math.random() * lines.length)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Instant-layer lookup: event string → { anim, type } or null.
|
|
66
|
+
* Falls back gracefully if the animation doesn't exist in the loaded set.
|
|
67
|
+
*/
|
|
68
|
+
export function mapEventToAnimation(eventType, availableAnims) {
|
|
69
|
+
const mapping = STATIC_MAP[eventType];
|
|
70
|
+
if (!mapping) return null;
|
|
71
|
+
// If the target animation isn't available, fall back to idle
|
|
72
|
+
if (availableAnims && !availableAnims.has(mapping.anim)) {
|
|
73
|
+
if (mapping.anim === IDLE_ANIM) return null; // already idle
|
|
74
|
+
return { anim: IDLE_ANIM, type: 'loop' };
|
|
75
|
+
}
|
|
76
|
+
return mapping;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Narrate a raw hook event into a 1-line human-readable summary.
|
|
81
|
+
* Used for future LLM context (Phase 2).
|
|
82
|
+
*/
|
|
83
|
+
export function narrateEvent(msg) {
|
|
84
|
+
const { event } = msg;
|
|
85
|
+
|
|
86
|
+
if (event === 'SessionStart') return 'Session started';
|
|
87
|
+
if (event === 'SessionEnd') return 'Session ended';
|
|
88
|
+
if (event === 'SubagentStart') return 'Sub-agent spawned';
|
|
89
|
+
if (event === 'SubagentStop') return 'Sub-agent finished';
|
|
90
|
+
if (event === 'Stop') return 'Claude stopped generating';
|
|
91
|
+
|
|
92
|
+
if (event === 'UserPromptSubmit') {
|
|
93
|
+
const prompt = msg.prompt || msg.tool_input?.prompt;
|
|
94
|
+
if (prompt) {
|
|
95
|
+
const short = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt;
|
|
96
|
+
return `User asked: '${short}'`;
|
|
97
|
+
}
|
|
98
|
+
return 'User submitted a prompt';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (event === 'PostToolUseFailure') {
|
|
102
|
+
const tool = msg.tool_name || msg.tool || 'unknown tool';
|
|
103
|
+
return `${tool} failed`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// PostToolUse_action or PostToolUse_research
|
|
107
|
+
if (event?.startsWith('PostToolUse')) {
|
|
108
|
+
const tool = msg.tool_name || msg.tool || '';
|
|
109
|
+
const input = msg.tool_input || {};
|
|
110
|
+
|
|
111
|
+
if (tool === 'Bash') {
|
|
112
|
+
const cmd = input.command || '';
|
|
113
|
+
const short = cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
|
|
114
|
+
return `Ran \`${short}\``;
|
|
115
|
+
}
|
|
116
|
+
if (tool === 'Edit' || tool === 'Write') {
|
|
117
|
+
const file = (input.file_path || '').split('/').pop() || 'a file';
|
|
118
|
+
return `Edited ${file}`;
|
|
119
|
+
}
|
|
120
|
+
if (tool === 'Read') {
|
|
121
|
+
const file = (input.file_path || '').split('/').pop() || 'a file';
|
|
122
|
+
return `Read ${file}`;
|
|
123
|
+
}
|
|
124
|
+
if (tool === 'Grep') {
|
|
125
|
+
const pattern = input.pattern || '';
|
|
126
|
+
return `Searched for '${pattern}'`;
|
|
127
|
+
}
|
|
128
|
+
if (tool === 'Glob') {
|
|
129
|
+
const pattern = input.pattern || '';
|
|
130
|
+
return `Searched files: ${pattern}`;
|
|
131
|
+
}
|
|
132
|
+
return `Used ${tool}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return `Event: ${event || 'unknown'}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Session context — tracks accumulated stats for LLM context (Phase 2).
|
|
140
|
+
*/
|
|
141
|
+
export class SessionContext {
|
|
142
|
+
constructor() {
|
|
143
|
+
this.startedAt = Date.now();
|
|
144
|
+
this.toolCount = 0;
|
|
145
|
+
this.errorCount = 0;
|
|
146
|
+
this.filesTouched = new Set();
|
|
147
|
+
this.currentActivity = 'idle';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
update(msg) {
|
|
151
|
+
const { event } = msg;
|
|
152
|
+
if (event?.startsWith('PostToolUse')) {
|
|
153
|
+
this.toolCount++;
|
|
154
|
+
const file = msg.tool_input?.file_path;
|
|
155
|
+
if (file) this.filesTouched.add(file.split('/').pop());
|
|
156
|
+
this.currentActivity = event.includes('research') ? 'researching' : 'coding';
|
|
157
|
+
}
|
|
158
|
+
if (event === 'PostToolUseFailure') {
|
|
159
|
+
this.errorCount++;
|
|
160
|
+
this.currentActivity = 'debugging';
|
|
161
|
+
}
|
|
162
|
+
if (event === 'UserPromptSubmit') {
|
|
163
|
+
this.currentActivity = 'thinking';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
toJSON() {
|
|
168
|
+
return {
|
|
169
|
+
duration_s: Math.round((Date.now() - this.startedAt) / 1000),
|
|
170
|
+
tool_count: this.toolCount,
|
|
171
|
+
error_count: this.errorCount,
|
|
172
|
+
files_touched: [...this.filesTouched].slice(-10),
|
|
173
|
+
current_activity: this.currentActivity,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|