storymode-cli 1.1.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymode-cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Play AI-animated pixel art characters in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -28,11 +28,12 @@ export async function fetchGallery() {
28
28
  return JSON.parse(buffer.toString('utf-8'));
29
29
  }
30
30
 
31
- export async function fetchFrames(galleryId, { size, mode } = {}) {
31
+ export async function fetchFrames(galleryId, { size, mode, animId } = {}) {
32
32
  let url = `${BASE}/gallery/${galleryId}/frames`;
33
33
  const params = [];
34
34
  if (size && size !== 'full') params.push(`size=${size}`);
35
35
  if (mode) params.push(`mode=${mode}`);
36
+ if (animId) params.push(`anim_id=${animId}`);
36
37
  if (params.length) url += '?' + params.join('&');
37
38
  const { buffer, contentType } = await request(url);
38
39
  let json;
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 Use sextant rendering (2x3 sub-pixels, sharper but needs
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
- --detach Return control immediately (companion mode).
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 paneWidth = size === 'tiny' ? 14 : 22;
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
- const fetchOpts = { size };
83
- if (flags.sextant) fetchOpts.mode = 'sextant';
84
- const framesData = await fetchFrames(id, fetchOpts);
85
- await playCompanion(framesData);
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 companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli companion ${id} --size=${size}${sextantFlag}`;
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/mcp.mjs CHANGED
@@ -4,72 +4,122 @@ import { fetchGallery, fetchFrames, fetchCharacter } from './api.mjs';
4
4
  const TOOLS = [
5
5
  {
6
6
  name: 'list_characters',
7
- description: 'List all characters in the Storymode gallery',
7
+ description: 'List all characters in the Storymode gallery with their available animations',
8
8
  inputSchema: { type: 'object', properties: {} },
9
9
  },
10
10
  {
11
11
  name: 'show_character',
12
- description: 'Show character info and portrait (first frame as ANSI art)',
12
+ description: 'Show character info, portrait, and list of available animations. Use this to see what a character looks like and what animations they have.',
13
13
  inputSchema: {
14
14
  type: 'object',
15
- properties: { gallery_id: { type: 'string', description: 'Gallery item ID' } },
15
+ properties: {
16
+ gallery_id: { type: 'string', description: 'Gallery item ID' },
17
+ animation: { type: 'string', description: 'Animation name to show a frame from (e.g. "waving hello", "casting lightning spell"). If omitted, shows the default animation.' },
18
+ },
16
19
  required: ['gallery_id'],
17
20
  },
18
21
  },
19
22
  {
20
23
  name: 'play_animation',
21
- description: 'Get animation data for a gallery character (returns first frame as ANSI art + metadata)',
24
+ description: 'Show a frame from a specific animation and provide the CLI command to play it. Choose the animation contextually — "waving hello" for greetings, "thinking" for processing, "casting lightning spell" for impressive actions, "idle breathing" for ambient.',
22
25
  inputSchema: {
23
26
  type: 'object',
24
- properties: { gallery_id: { type: 'string', description: 'Gallery item ID' } },
27
+ properties: {
28
+ gallery_id: { type: 'string', description: 'Gallery item ID' },
29
+ animation: { type: 'string', description: 'Animation name (e.g. "waving hello", "idle breathing"). Use list_characters or show_character to see available animations.' },
30
+ },
25
31
  required: ['gallery_id'],
26
32
  },
27
33
  },
28
34
  ];
29
35
 
36
+ function findAnimation(animations, query) {
37
+ if (!query || !animations?.length) return null;
38
+ const q = query.toLowerCase();
39
+ // Exact match first
40
+ const exact = animations.find(a => (a.name || a.prompt || '').toLowerCase() === q);
41
+ if (exact) return exact;
42
+ // Partial match
43
+ const partial = animations.find(a => (a.name || a.prompt || '').toLowerCase().includes(q));
44
+ return partial || null;
45
+ }
46
+
30
47
  async function handleToolCall(name, args) {
31
48
  switch (name) {
32
49
  case 'list_characters': {
33
50
  const items = await fetchGallery();
34
- const lines = items.map((item, i) =>
35
- `${i + 1}. ${item.name || 'untitled'}${item.prompt ? ' — ' + item.prompt : ''} (id: ${item.id})`
36
- );
37
- return lines.join('\n') || 'Gallery is empty.';
51
+ if (!items.length) return 'Gallery is empty.';
52
+ const parts = [];
53
+ for (const item of items) {
54
+ const char = await fetchCharacter(item.id).catch(() => null);
55
+ let line = `${item.name || 'untitled'} (id: ${item.id})`;
56
+ if (char?.backstory) line += `\n ${char.backstory}`;
57
+ if (char?.animations?.length) {
58
+ const names = char.animations.map(a => a.name || a.prompt).join(', ');
59
+ line += `\n Animations: ${names}`;
60
+ }
61
+ parts.push(line);
62
+ }
63
+ return parts.join('\n\n');
38
64
  }
39
65
  case 'show_character': {
40
66
  const id = args.gallery_id;
41
- const [character, framesData] = await Promise.all([
42
- fetchCharacter(id).catch(() => null),
43
- fetchFrames(id, { size: 'tiny' }),
44
- ]);
67
+ const character = await fetchCharacter(id).catch(() => null);
68
+
69
+ // Determine which animation to show
70
+ const fetchOpts = { size: 'tiny' };
71
+ if (args.animation && character?.animations?.length) {
72
+ const match = findAnimation(character.animations, args.animation);
73
+ if (match) fetchOpts.animId = match.id;
74
+ }
75
+ const framesData = await fetchFrames(id, fetchOpts);
76
+
45
77
  let text = '';
46
78
  if (character) {
47
79
  text += `Name: ${character.name || 'untitled'}\n`;
48
80
  if (character.backstory) text += `Backstory: ${character.backstory}\n`;
49
81
  if (character.animations?.length) {
50
- text += `Animations: ${character.animations.map(a => a.name || a.prompt).join(', ')}\n`;
82
+ text += `\nAnimations (${character.animations.length}):\n`;
83
+ for (const a of character.animations) {
84
+ text += ` • ${a.name || a.prompt}\n`;
85
+ }
51
86
  }
52
87
  text += '\n';
53
88
  }
54
- // First frame as ANSI
55
89
  if (framesData.frames?.length > 0) {
56
- text += 'Portrait (ANSI):\n';
90
+ const label = args.animation || 'portrait';
91
+ text += `${label} (ANSI):\n`;
57
92
  text += framesData.frames[0].join('\n');
58
93
  }
59
94
  return text;
60
95
  }
61
96
  case 'play_animation': {
62
97
  const id = args.gallery_id;
63
- const framesData = await fetchFrames(id, { size: 'tiny' });
64
- let text = '';
65
- if (framesData.frames?.length > 0) {
66
- text += framesData.frames[0].join('\n');
67
- text += `\n\n${framesData.frames.length} frames @ ${framesData.fps || 16} fps`;
68
- text += `\n\nTo see the full animation, run:\n npx storymode-cli play ${id}`;
69
- text += `\n\nTo play as a companion sprite alongside your session:\n npx storymode-cli companion ${id}`;
70
- } else {
71
- text = 'No frames available.';
98
+
99
+ // Look up animation ID by name
100
+ const fetchOpts = { size: 'tiny' };
101
+ let animLabel = 'default';
102
+ if (args.animation) {
103
+ const character = await fetchCharacter(id).catch(() => null);
104
+ if (character?.animations?.length) {
105
+ const match = findAnimation(character.animations, args.animation);
106
+ if (match) {
107
+ fetchOpts.animId = match.id;
108
+ animLabel = match.name || match.prompt;
109
+ } else {
110
+ const names = character.animations.map(a => a.name || a.prompt).join(', ');
111
+ return `Animation "${args.animation}" not found. Available: ${names}`;
112
+ }
113
+ }
72
114
  }
115
+
116
+ const framesData = await fetchFrames(id, fetchOpts);
117
+ if (!framesData.frames?.length) return 'No frames available.';
118
+
119
+ let text = framesData.frames[0].join('\n');
120
+ text += `\n\n"${animLabel}" — ${framesData.frames.length} frames @ ${framesData.fps || 16} fps`;
121
+ text += `\n\nTo see the full animation, run:\n npx storymode-cli play ${id}`;
122
+ text += `\n\nTo play as a companion sprite alongside your session:\n npx storymode-cli companion ${id}`;
73
123
  return text;
74
124
  }
75
125
  default:
@@ -92,7 +142,7 @@ function handleMessage(msg) {
92
142
  result: {
93
143
  protocolVersion: '2024-11-05',
94
144
  capabilities: { tools: {} },
95
- serverInfo: { name: 'storymode', version: '1.0.0' },
145
+ serverInfo: { name: 'storymode', version: '1.1.2' },
96
146
  },
97
147
  });
98
148
  break;
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) {
@@ -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
+ }