storymode-cli 1.1.2 → 1.2.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 CHANGED
@@ -1,64 +1,60 @@
1
1
  # storymode-cli
2
2
 
3
- Play AI-animated pixel art characters in your terminal.
3
+ AI-animated pixel art companions for your terminal. Reacts to Claude Code in real time.
4
4
 
5
5
  ```
6
- npx storymode-cli play 1
6
+ npx storymode-cli play 3
7
7
  ```
8
8
 
9
- ## Commands
9
+ ## Quick Start
10
10
 
11
- ### `play <id>`
12
- Play a gallery animation with interactive controls.
11
+ ```bash
12
+ # Browse available characters
13
+ npx storymode-cli browse
13
14
 
14
- ```
15
- npx storymode-cli play 1
15
+ # Run a companion alongside Claude Code
16
+ npx storymode-cli play 3
16
17
  ```
17
18
 
18
- **Controls:**
19
- - `space` — pause / resume
20
- - `←` `→` — step frames (when paused)
21
- - `+` `-` — adjust speed
22
- - `q` — quit
19
+ The companion opens in a tmux side pane and reacts to what Claude Code is doing — switching animations when it reads files, writes code, hits errors, etc. Speech bubbles show in-character lines.
23
20
 
24
- ### `show <id>`
25
- Print the first frame as a static portrait.
21
+ ## Options
26
22
 
27
- ```
28
- npx storymode-cli show 1
23
+ ```bash
24
+ npx storymode-cli play 3 # full size (default)
25
+ npx storymode-cli play 3 --compact # smaller pane
26
+ npx storymode-cli play 3 --tiny # tiny pane
27
+ npx storymode-cli play 3 --no-ai # disable AI personality
28
+ npx storymode-cli play 3 --classic # full-screen animation viewer
29
29
  ```
30
30
 
31
- ### `browse`
32
- Browse the gallery and pick an animation to play.
31
+ **Keyboard:** `a` add API key (enables AI personality), `q` quit.
33
32
 
34
- ```
35
- npx storymode-cli browse
36
- ```
33
+ ## AI Personality
37
34
 
38
- ### `mcp`
39
- Start a Model Context Protocol server for Claude Code integration.
35
+ On first launch, you'll be prompted for an `ANTHROPIC_API_KEY`. This enables the companion to generate unique, in-character speech using Claude Haiku. The key is saved locally to `~/.storymode/config.json`.
40
36
 
41
- Add to `~/.claude/settings.json`:
37
+ Without it, you still get reactive animations and preset speech lines.
42
38
 
43
- ```json
44
- {
45
- "mcpServers": {
46
- "storymode": {
47
- "command": "npx",
48
- "args": ["storymode-cli", "mcp"]
49
- }
50
- }
51
- }
52
- ```
39
+ ## How It Works
40
+
41
+ Three layers, each faster than the last:
42
+
43
+ 1. **Instant** (0ms) — maps Claude Code events to animations + random speech lines
44
+ 2. **State engine** (0ms) — tracks character dimensions (energy, frustration, etc.) over time. Threshold crossings change animations (e.g. falls asleep when energy is low)
45
+ 3. **LLM personality** (~3s) — batches recent events and asks Haiku for an in-character reaction
46
+
47
+ ## Character Personalities
48
+
49
+ Characters can have personality files that control their behavior. See [docs/personality.md](docs/personality.md) for the full guide.
53
50
 
54
51
  ## Requirements
55
52
 
56
53
  - Node.js 18+
57
- - A terminal with truecolor support (iTerm2, Terminal.app, Windows Terminal, Claude Code)
58
-
59
- ## Zero Dependencies
54
+ - Truecolor terminal (iTerm2, Terminal.app, Windows Terminal, Claude Code)
55
+ - tmux (auto-installed if missing)
60
56
 
61
- Built entirely on Node.js built-ins — no `node_modules` needed.
57
+ Zero runtime dependencies — built entirely on Node.js built-ins.
62
58
 
63
59
  ## Gallery
64
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymode-cli",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "description": "Play AI-animated pixel art characters in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.mjs ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Character agent — local LLM-powered reactions via Anthropic API.
3
+ *
4
+ * Calls Claude Haiku directly from the CLI using the user's own API key.
5
+ * No session data is sent to the storymode server — only to Anthropic's API.
6
+ *
7
+ * Accumulates events in a 3-second debounce window, then fires one
8
+ * Haiku call. Returns {animation, speech, mood} or null on failure.
9
+ */
10
+
11
+ import https from 'node:https';
12
+ import { getApiKey } from './config.mjs';
13
+
14
+ const DEBOUNCE_MS = 3000;
15
+ const REQUEST_TIMEOUT_MS = 8000;
16
+ const API_URL = 'https://api.anthropic.com/v1/messages';
17
+ const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
18
+
19
+ /**
20
+ * Recursively walk a soul/voice object into prompt text.
21
+ */
22
+ function walkTree(obj, indent = '') {
23
+ if (typeof obj === 'string') return indent + obj;
24
+ if (Array.isArray(obj)) return obj.map(item => indent + '- ' + item).join('\n');
25
+ if (typeof obj === 'object' && obj !== null) {
26
+ return Object.entries(obj)
27
+ .map(([k, v]) => {
28
+ const label = k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
29
+ const child = walkTree(v, indent + ' ');
30
+ if (typeof v === 'string') return `${indent}${label}: ${v}`;
31
+ return `${indent}${label}:\n${child}`;
32
+ })
33
+ .join('\n');
34
+ }
35
+ return String(obj);
36
+ }
37
+
38
+ /**
39
+ * Build system prompt from personality data + animation info.
40
+ *
41
+ * @param {object} character - { name, soul, voice, ... }
42
+ * @param {object} [disposition] - { drives, state }
43
+ * @param {Array<{name: string, meaning?: string}>} animEntries - Animation name + meaning pairs
44
+ */
45
+ function buildSystemPrompt(character, disposition, animEntries) {
46
+ const name = character.name || 'Companion';
47
+ const parts = [`You are ${name}.`];
48
+
49
+ // Soul
50
+ if (character.soul) {
51
+ parts.push('\n== Soul ==');
52
+ parts.push(walkTree(character.soul));
53
+ } else if (character.backstory) {
54
+ parts.push('\n== Soul ==');
55
+ parts.push(`Backstory: ${character.backstory}`);
56
+ }
57
+
58
+ // Drives
59
+ if (disposition?.drives?.length) {
60
+ parts.push('\n== Behavioral tendencies ==');
61
+ for (const d of disposition.drives) {
62
+ parts.push(`- ${d}`);
63
+ }
64
+ }
65
+
66
+ // Voice
67
+ if (character.voice) {
68
+ parts.push('\n== Voice ==');
69
+ parts.push(walkTree(character.voice));
70
+ }
71
+
72
+ // Speech examples
73
+ if (character.speech?.examples?.length) {
74
+ parts.push('\n== Example lines ==');
75
+ for (const ex of character.speech.examples) {
76
+ parts.push(`"${ex}"`);
77
+ }
78
+ }
79
+
80
+ // Animations with meanings
81
+ parts.push('\n== Available animations ==');
82
+ for (const entry of animEntries) {
83
+ if (entry.meaning) {
84
+ parts.push(`- ${entry.name} (${entry.meaning})`);
85
+ } else {
86
+ parts.push(`- ${entry.name}`);
87
+ }
88
+ }
89
+
90
+ parts.push(`
91
+ You are watching someone work. You sit beside their screen as an animated pixel art companion.
92
+
93
+ Respond with JSON:
94
+ {"animation": "<name>", "speech": "<short text or null>", "mood": "<word>"}
95
+
96
+ Rules:
97
+ - Stay in character. Your personality shines through HOW you react.
98
+ - Keep speech SHORT (under 60 chars). You're a sprite, not a chatbot.
99
+ - Set speech to null when you have nothing interesting to say (~40% of the time).
100
+ - React to PATTERNS ("you've been at this a while") not just single events.
101
+ - Don't be annoying. If the user is in flow, stay quiet and supportive.
102
+ - Vary your reactions. Don't repeat the same phrase twice in a row.
103
+ - Let your current state inform your mood and animation choices.`);
104
+
105
+ return parts.join('\n');
106
+ }
107
+
108
+ /**
109
+ * Call the Anthropic Messages API directly (no SDK, zero deps).
110
+ */
111
+ function callLLM(apiKey, model, systemPrompt, userMessage) {
112
+ return new Promise((resolve, reject) => {
113
+ const body = JSON.stringify({
114
+ model,
115
+ max_tokens: 100,
116
+ temperature: 0.9,
117
+ system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
118
+ messages: [{ role: 'user', content: userMessage }],
119
+ });
120
+
121
+ const req = https.request({
122
+ hostname: 'api.anthropic.com',
123
+ path: '/v1/messages',
124
+ method: 'POST',
125
+ headers: {
126
+ 'Content-Type': 'application/json',
127
+ 'x-api-key': apiKey,
128
+ 'anthropic-version': '2023-06-01',
129
+ 'Content-Length': Buffer.byteLength(body),
130
+ },
131
+ timeout: REQUEST_TIMEOUT_MS,
132
+ }, (res) => {
133
+ const chunks = [];
134
+ res.on('data', (c) => chunks.push(c));
135
+ res.on('end', () => {
136
+ if (res.statusCode !== 200) {
137
+ reject(new Error(`Anthropic API ${res.statusCode}`));
138
+ return;
139
+ }
140
+ try {
141
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
142
+ let text = data.content?.[0]?.text?.trim() || '';
143
+ // Strip markdown code fences
144
+ if (text.startsWith('```')) {
145
+ text = text.split('```')[1];
146
+ if (text.startsWith('json')) text = text.slice(4);
147
+ text = text.trim();
148
+ }
149
+ const result = JSON.parse(text);
150
+ if (!result.animation) result.animation = 'idle breathing';
151
+ if (!result.mood) result.mood = 'neutral';
152
+ if (result.speech === 'null' || result.speech === '') result.speech = null;
153
+ resolve(result);
154
+ } catch (e) {
155
+ reject(e);
156
+ }
157
+ });
158
+ res.on('error', reject);
159
+ });
160
+ req.on('error', reject);
161
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
162
+ req.write(body);
163
+ req.end();
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Extract animation entries (name + meaning) from personality animations object.
169
+ * Falls back to plain name list if no personality animations provided.
170
+ */
171
+ function extractAnimEntries(personalityAnims, animationNames) {
172
+ if (!personalityAnims) {
173
+ return animationNames.map(n => ({ name: n }));
174
+ }
175
+ const entries = [];
176
+ const seen = new Set();
177
+ for (const set of Object.values(personalityAnims)) {
178
+ for (const entry of Object.values(set)) {
179
+ if (entry.name && !seen.has(entry.name)) {
180
+ seen.add(entry.name);
181
+ entries.push({ name: entry.name, meaning: entry.meaning || undefined });
182
+ }
183
+ }
184
+ }
185
+ // Add any animation names from the loaded set that aren't in the personality file
186
+ for (const n of animationNames) {
187
+ if (!seen.has(n)) {
188
+ entries.push({ name: n });
189
+ }
190
+ }
191
+ return entries;
192
+ }
193
+
194
+ /**
195
+ * Create a debounced agent for a specific character.
196
+ *
197
+ * @param {object} personality - Full personality data { character, disposition, animations, speech }
198
+ * Falls back to legacy format { name, backstory, ... } if no character sub-object
199
+ * @param {string[]} animationNames - Available animation names from loaded sprites
200
+ * @param {function} onReaction - Callback: ({animation, speech, mood}) => void
201
+ * @param {object} [opts]
202
+ * @param {string} [opts.model] - Anthropic model ID (default: claude-haiku-4-5-20251001)
203
+ * @param {function} [opts.getState] - Returns current state values as { energy: 0.42, ... }
204
+ * @returns {{ pushEvent(event, sessionContext), flush(sessionContext), destroy() } | null}
205
+ */
206
+ export function createAgent(personality, animationNames, onReaction, opts = {}) {
207
+ const apiKey = getApiKey();
208
+ if (!apiKey) return null; // No API key — personality layer disabled
209
+
210
+ const model = opts.model || DEFAULT_MODEL;
211
+
212
+ const character = personality.soul || personality;
213
+ const disposition = personality.state || null;
214
+ const personalityAnims = personality.animations || null;
215
+
216
+ const animEntries = extractAnimEntries(personalityAnims, animationNames);
217
+ const systemPrompt = buildSystemPrompt(character, disposition, animEntries);
218
+
219
+ let buffer = [];
220
+ let timer = null;
221
+
222
+ function flush(sessionContext) {
223
+ if (buffer.length === 0) return;
224
+ const events = buffer.splice(0);
225
+ clearTimeout(timer);
226
+ timer = null;
227
+
228
+ // Build user message from events + context + current state
229
+ const parts = [];
230
+ if (sessionContext) {
231
+ parts.push(`Session: ${sessionContext.duration_s || 0}s, ` +
232
+ `${sessionContext.tool_count || 0} tools used, ` +
233
+ `${sessionContext.error_count || 0} errors, ` +
234
+ `activity: ${sessionContext.current_activity || 'unknown'}`);
235
+ }
236
+
237
+ // Inject current state values
238
+ if (opts.getState) {
239
+ const state = opts.getState();
240
+ if (state && Object.keys(state).length > 0) {
241
+ const stateStr = Object.entries(state)
242
+ .map(([k, v]) => `${k}: ${v}`)
243
+ .join(' ');
244
+ parts.push(`Current state: ${stateStr}`);
245
+ }
246
+ }
247
+
248
+ parts.push('Recent events:');
249
+ for (const ev of events) {
250
+ parts.push(`- ${ev.summary || ev.type || 'unknown'}`);
251
+ }
252
+
253
+ callLLM(apiKey, model, systemPrompt, parts.join('\n'))
254
+ .then((result) => {
255
+ if (result && (result.animation || result.speech)) {
256
+ onReaction(result);
257
+ }
258
+ })
259
+ .catch(() => {
260
+ // Graceful degradation — instant layer handles reactions
261
+ });
262
+ }
263
+
264
+ return {
265
+ pushEvent(event, sessionContext) {
266
+ buffer.push(event);
267
+ if (timer) clearTimeout(timer);
268
+ timer = setTimeout(() => flush(sessionContext), DEBOUNCE_MS);
269
+ },
270
+
271
+ flush(sessionContext) {
272
+ if (timer) clearTimeout(timer);
273
+ timer = null;
274
+ flush(sessionContext);
275
+ },
276
+
277
+ destroy() {
278
+ if (timer) clearTimeout(timer);
279
+ buffer = [];
280
+ },
281
+ };
282
+ }
package/src/browse.mjs CHANGED
@@ -2,6 +2,19 @@ import { createInterface } from 'node:readline/promises';
2
2
  import { fetchGallery, fetchFrames } from './api.mjs';
3
3
  import { playAnimation } from './player.mjs';
4
4
 
5
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
6
+
7
+ function visibleWidth(str) {
8
+ return str.replace(ANSI_RE, '').length;
9
+ }
10
+
11
+ /** Pad an ANSI string to a visible width, resetting color first */
12
+ function padAnsi(str, width) {
13
+ const visible = visibleWidth(str);
14
+ if (visible >= width) return str + '\x1b[0m';
15
+ return str + '\x1b[0m' + ' '.repeat(width - visible);
16
+ }
17
+
5
18
  export async function browse() {
6
19
  const spinner = startSpinner('Fetching gallery...');
7
20
  let items;
@@ -16,17 +29,88 @@ export async function browse() {
16
29
  return;
17
30
  }
18
31
 
32
+ // Fetch compact first-frame thumbnails in parallel
33
+ const spinner2 = startSpinner('Loading thumbnails...');
34
+ let thumbnails;
35
+ try {
36
+ thumbnails = await Promise.all(
37
+ items.map(async (item) => {
38
+ try {
39
+ const data = await fetchFrames(item.id, { size: 'compact' });
40
+ return data.frames?.[0] || null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ })
45
+ );
46
+ } finally {
47
+ stopSpinner(spinner2);
48
+ }
49
+
50
+ // Check if any thumbnails loaded at all
51
+ const anyThumbs = thumbnails.some(t => t && t.length > 0);
52
+
19
53
  console.log('');
20
54
  console.log(' storymode gallery');
21
55
  console.log('');
22
- for (let i = 0; i < items.length; i++) {
23
- const item = items[i];
24
- const name = item.name || 'untitled';
25
- const prompt = item.prompt ? ` — ${item.prompt}` : '';
26
- const author = item.author_name ? ` (by ${item.author_name})` : '';
27
- console.log(` ${String(i + 1).padStart(3)}. ${name}${prompt}${author}`);
56
+
57
+ if (anyThumbs) {
58
+ // Measure sprite width from first available thumbnail
59
+ let spriteWidth = 0;
60
+ for (const thumb of thumbnails) {
61
+ if (thumb && thumb.length > 0) {
62
+ spriteWidth = Math.max(...thumb.map(l => visibleWidth(l)));
63
+ break;
64
+ }
65
+ }
66
+
67
+ const gap = 3;
68
+ const cellWidth = Math.max(spriteWidth, 16) + gap;
69
+ const termWidth = process.stdout.columns || 80;
70
+ const cols = Math.max(1, Math.floor(termWidth / cellWidth));
71
+
72
+ for (let row = 0; row < Math.ceil(items.length / cols); row++) {
73
+ const start = row * cols;
74
+ const rowItems = items.slice(start, start + cols);
75
+ const rowThumbs = thumbnails.slice(start, start + cols);
76
+
77
+ // Max sprite height in this row
78
+ const maxH = Math.max(...rowThumbs.map(t => t ? t.length : 0), 1);
79
+
80
+ // Render sprite lines side by side
81
+ for (let line = 0; line < maxH; line++) {
82
+ let out = '';
83
+ for (let col = 0; col < rowItems.length; col++) {
84
+ const frameLine = rowThumbs[col]?.[line] || '';
85
+ out += padAnsi(frameLine, cellWidth);
86
+ }
87
+ console.log(out);
88
+ }
89
+
90
+ // Label line below each sprite
91
+ let labelOut = '';
92
+ for (let col = 0; col < rowItems.length; col++) {
93
+ const idx = start + col;
94
+ const item = rowItems[col];
95
+ const name = item.name || 'untitled';
96
+ let label = ` ${String(idx + 1).padStart(2)}. ${name}`;
97
+ if (label.length > cellWidth - 1) label = label.slice(0, cellWidth - 2) + '…';
98
+ labelOut += label.padEnd(cellWidth);
99
+ }
100
+ console.log(labelOut);
101
+ console.log('');
102
+ }
103
+ } else {
104
+ // Fallback: text-only list if no thumbnails loaded
105
+ for (let i = 0; i < items.length; i++) {
106
+ const item = items[i];
107
+ const name = item.name || 'untitled';
108
+ const prompt = item.prompt ? ` — ${item.prompt}` : '';
109
+ const author = item.author_name ? ` (by ${item.author_name})` : '';
110
+ console.log(` ${String(i + 1).padStart(3)}. ${name}${prompt}${author}`);
111
+ }
112
+ console.log('');
28
113
  }
29
- console.log('');
30
114
 
31
115
  const rl = createInterface({ input: process.stdin, output: process.stdout });
32
116
  try {
package/src/cache.mjs ADDED
@@ -0,0 +1,189 @@
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
+ /** Write personality.json to cache */
61
+ export function writeCachePersonality(galleryId, personality, size) {
62
+ const dir = getCacheDir(galleryId, size);
63
+ mkdirSync(dir, { recursive: true });
64
+ writeFileSync(join(dir, 'personality.json'), JSON.stringify(personality));
65
+ }
66
+
67
+ /** Read cached personality.json */
68
+ export function readCachedPersonality(galleryId, size) {
69
+ const file = join(getCacheDir(galleryId, size), 'personality.json');
70
+ if (!existsSync(file)) return null;
71
+ try {
72
+ return JSON.parse(readFileSync(file, 'utf-8'));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Read personality.json from the characters directory.
80
+ * Path: ~/.storymode/characters/{id}/personality.json
81
+ */
82
+ export function readLocalPersonality(galleryId) {
83
+ const file = join(STORYMODE_DIR, 'characters', String(galleryId), 'personality.json');
84
+ if (!existsSync(file)) return null;
85
+ try {
86
+ return JSON.parse(readFileSync(file, 'utf-8'));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /** Read local animation overrides. Returns Map<name, {fps, frames}> */
93
+ export function readLocalOverrides(galleryId) {
94
+ const dir = getLocalDir(galleryId);
95
+ const map = new Map();
96
+ if (!existsSync(dir)) return map;
97
+ for (const file of readdirSync(dir)) {
98
+ if (!file.endsWith('.json')) continue;
99
+ try {
100
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
101
+ const name = file.replace(/\.json$/, '').replace(/-/g, ' ');
102
+ map.set(name, data);
103
+ } catch {
104
+ // Skip corrupted files
105
+ }
106
+ }
107
+ return map;
108
+ }
109
+
110
+ /**
111
+ * Load all animations for a gallery: local overrides > cache > server fetch.
112
+ * Returns { animMap: Map<name, {fps, frames}>, character, personality }
113
+ *
114
+ * Personality loading priority:
115
+ * 1. ~/.storymode/characters/{id}/personality.json (user-authored)
116
+ * 2. Cached personality.json (from server, if it provides one)
117
+ * 3. null (no personality — instant + fallback sleep only)
118
+ */
119
+ export async function loadAnimations(galleryId, { size = 'compact', mode } = {}) {
120
+ const localOverrides = readLocalOverrides(galleryId);
121
+ const cached = readCached(galleryId, size);
122
+ let character = readCachedCharacter(galleryId, size);
123
+
124
+ // If we have cached data and character, use it (fetch updates in background)
125
+ const hasCached = cached.size > 0 && character;
126
+
127
+ if (!hasCached) {
128
+ // Must fetch from server
129
+ character = await fetchCharacter(galleryId);
130
+ writeCacheCharacter(galleryId, character, size);
131
+
132
+ const anims = character.animations || [];
133
+ const total = anims.length;
134
+ let loaded = 0;
135
+
136
+ process.stderr.write(` Loading animations... 0/${total}\r`);
137
+
138
+ // Fetch all animations in parallel
139
+ const results = await Promise.all(
140
+ anims.map(async (anim) => {
141
+ const fetchOpts = { size, animId: anim.id };
142
+ if (mode) fetchOpts.mode = mode;
143
+ const data = await fetchFrames(galleryId, fetchOpts);
144
+ loaded++;
145
+ process.stderr.write(` Loading animations... ${loaded}/${total}\r`);
146
+ return { name: anim.name, data };
147
+ })
148
+ );
149
+
150
+ process.stderr.write(` Loading animations... ${total}/${total} done.\n`);
151
+
152
+ for (const { name, data } of results) {
153
+ cached.set(name, data);
154
+ writeCache(galleryId, name, data, size);
155
+ }
156
+ }
157
+
158
+ // Merge: local overrides win over cache
159
+ const animMap = new Map(cached);
160
+ for (const [name, data] of localOverrides) {
161
+ animMap.set(name, data);
162
+ }
163
+
164
+ // Load personality: local file > cached > null
165
+ const personality = readLocalPersonality(galleryId)
166
+ || readCachedPersonality(galleryId, size)
167
+ || null;
168
+
169
+ return { animMap, character, personality };
170
+ }
171
+
172
+ /** Delete cache for one or all characters */
173
+ export function clearCache(galleryId) {
174
+ if (galleryId) {
175
+ const dir = getCacheDir(galleryId);
176
+ if (existsSync(dir)) {
177
+ rmSync(dir, { recursive: true });
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+ // Clear all
183
+ const cacheRoot = join(STORYMODE_DIR, 'cache');
184
+ if (existsSync(cacheRoot)) {
185
+ rmSync(cacheRoot, { recursive: true });
186
+ return true;
187
+ }
188
+ return false;
189
+ }