storymode-cli 1.2.0 → 1.2.2

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/src/cli.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
2
3
  import { fetchFrames, fetchCharacter } from './api.mjs';
3
4
  import { playAnimation, playCompanion, playReactiveCompanion, showFrame } from './player.mjs';
4
5
  import { browse } from './browse.mjs';
@@ -6,29 +7,90 @@ import { startMcpServer } from './mcp.mjs';
6
7
  import { loadAnimations, clearCache } from './cache.mjs';
7
8
  import { installHooks, uninstallHooks } from './hooks.mjs';
8
9
 
10
+ function hasTmux() {
11
+ try {
12
+ execSync('which tmux', { stdio: 'ignore' });
13
+ return true;
14
+ } catch { return false; }
15
+ }
16
+
17
+ function askYesNo(question) {
18
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
19
+ return new Promise((resolve) => {
20
+ rl.question(question, (answer) => {
21
+ rl.close();
22
+ resolve(/^y(es)?$/i.test(answer.trim()));
23
+ });
24
+ });
25
+ }
26
+
27
+ async function ensureTmux() {
28
+ if (hasTmux()) return true;
29
+
30
+ console.error(' Companion mode requires tmux, which is not installed.');
31
+
32
+ const platform = process.platform;
33
+ let installCmd;
34
+ if (platform === 'darwin') {
35
+ try { execSync('which brew', { stdio: 'ignore' }); installCmd = 'brew install tmux'; }
36
+ catch {
37
+ try { execSync('which port', { stdio: 'ignore' }); installCmd = 'sudo port install tmux'; }
38
+ catch { /* no package manager found */ }
39
+ }
40
+ } else if (platform === 'linux') {
41
+ for (const [bin, cmd] of [
42
+ ['apt-get', 'sudo apt-get install -y tmux'],
43
+ ['dnf', 'sudo dnf install -y tmux'],
44
+ ['yum', 'sudo yum install -y tmux'],
45
+ ['pacman', 'sudo pacman -S --noconfirm tmux'],
46
+ ['apk', 'sudo apk add tmux'],
47
+ ]) {
48
+ try { execSync(`which ${bin}`, { stdio: 'ignore' }); installCmd = cmd; break; }
49
+ catch { /* try next */ }
50
+ }
51
+ }
52
+
53
+ if (!installCmd) {
54
+ console.error(' Could not detect a supported package manager.');
55
+ console.error(' Please install tmux manually and try again.');
56
+ return false;
57
+ }
58
+
59
+ const yes = await askYesNo(` Install tmux with \`${installCmd}\`? [y/N] `);
60
+ if (!yes) {
61
+ console.error(' tmux is required for companion mode. Install it and try again.');
62
+ return false;
63
+ }
64
+
65
+ try {
66
+ console.error(` Running: ${installCmd}`);
67
+ execSync(installCmd, { stdio: 'inherit' });
68
+ if (!hasTmux()) throw new Error('tmux not found after install');
69
+ console.error(' tmux installed successfully!\n');
70
+ return true;
71
+ } catch (err) {
72
+ console.error(` Failed to install tmux: ${err.message}`);
73
+ return false;
74
+ }
75
+ }
76
+
9
77
  const HELP = `
10
- storymode-cli — play AI-animated pixel art in your terminal
78
+ storymode-cli — AI-animated pixel art companions for your terminal
11
79
 
12
80
  Usage:
13
- storymode play <gallery_id> Play an animation (full screen)
14
- storymode companion <gallery_id> Play compact sprite alongside your session
15
- storymode show <gallery_id> Show first frame as static portrait
16
- storymode browse Browse the gallery interactively
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
81
+ storymode play Run a reactive companion alongside Claude Code
82
+ storymode browse Browse the gallery and switch characters
21
83
 
22
84
  Options:
23
- --sextant Use sextant rendering (sharper, needs modern terminal).
24
- --size=compact|tiny Size for companion mode (default: compact).
25
- --reactive Enable reactive mode (responds to Claude Code events).
26
- --detach Return control immediately (companion mode).
27
-
28
- Controls (during playback):
29
- space pause / resume
30
- < / > step frames (when paused)
31
- + / - increase / decrease speed
85
+ --compact Smaller companion pane (default: full)
86
+ --tiny Tiny companion pane
87
+ --no-ai Disable AI personality (preset speech only)
88
+ --model=ID Anthropic model for AI personality
89
+ --classic Full-screen animation viewer (non-reactive)
90
+ --detach Return control immediately
91
+
92
+ Controls:
93
+ a add API key (enables AI personality)
32
94
  q quit
33
95
 
34
96
  https://storymode.fixmy.codes
@@ -48,51 +110,56 @@ function parseFlags(args) {
48
110
  return { flags, positional };
49
111
  }
50
112
 
113
+ const DEFAULT_CHARACTER = 3; // Zephyr
114
+
51
115
  export async function run(args) {
52
116
  const cmd = args[0];
53
117
  const { flags, positional } = parseFlags(args.slice(1));
54
- const id = positional[0];
118
+ const id = positional[0] || (cmd === 'play' || cmd === 'companion' ? DEFAULT_CHARACTER : undefined);
55
119
 
56
120
  switch (cmd) {
57
- case 'play': {
58
- if (!id) {
59
- console.error('Usage: storymode play <gallery_id>');
60
- process.exit(1);
61
- }
62
- try {
63
- process.stderr.write(' Loading animation...\n');
64
- const fetchOpts = {};
65
- if (flags.sextant) { fetchOpts.size = 'compact'; fetchOpts.mode = 'sextant'; }
66
- const framesData = await fetchFrames(id, fetchOpts);
67
- await playAnimation(framesData);
68
- } catch (err) {
69
- console.error(`Error: ${err.message}`);
70
- process.exit(1);
71
- }
72
- break;
73
- }
74
-
121
+ // --- Main command: reactive companion ---
122
+ case 'play':
75
123
  case 'companion': {
76
- if (!id) {
77
- console.error('Usage: storymode companion <gallery_id> [--reactive] [--detach] [--size=full|compact|tiny]');
78
- process.exit(1);
124
+
125
+ // --classic: old full-screen animation viewer
126
+ if (flags.classic) {
127
+ try {
128
+ process.stderr.write(' Loading animation...\n');
129
+ const fetchOpts = {};
130
+ if (flags.sextant) { fetchOpts.size = 'compact'; fetchOpts.mode = 'sextant'; }
131
+ const framesData = await fetchFrames(id, fetchOpts);
132
+ await playAnimation(framesData);
133
+ } catch (err) {
134
+ console.error(`Error: ${err.message}`);
135
+ process.exit(1);
136
+ }
137
+ break;
79
138
  }
80
- const size = flags.size || 'compact';
139
+
140
+ // Determine size: default full, --compact, --tiny, or explicit --size=
141
+ const size = flags.tiny ? 'tiny' : flags.compact ? 'compact' : (flags.size || 'full');
81
142
  const detach = !!flags.detach;
82
- const reactive = !!flags.reactive;
143
+ const noAi = !!flags['no-ai'];
144
+ const model = flags.model || '';
145
+ // Reactive is always on unless --classic
146
+ const reactive = !flags['no-reactive'];
83
147
  const paneWidth = size === 'full' ? 62 : size === 'tiny' ? 14 : 22;
84
148
 
85
149
  // If we're already the companion pane (spawned by tmux split), play inline
86
150
  if (process.env.STORYMODE_COMPANION === '1') {
87
151
  try {
88
152
  if (reactive) {
89
- // Reactive mode: load all animations, start socket server
90
153
  const fetchOpts = { size };
91
154
  if (flags.sextant) fetchOpts.mode = 'sextant';
92
- const { animMap, character } = await loadAnimations(id, fetchOpts);
155
+ const { animMap, character, personality } = await loadAnimations(id, fetchOpts);
93
156
  await playReactiveCompanion(animMap, {
94
157
  characterName: character?.name || `#${id}`,
158
+ character,
159
+ personality,
95
160
  fullscreen: size === 'full',
161
+ noAi,
162
+ model: model || undefined,
96
163
  });
97
164
  } else {
98
165
  const fetchOpts = { size };
@@ -107,30 +174,31 @@ export async function run(args) {
107
174
  break;
108
175
  }
109
176
 
110
- // Auto-install hooks when reactive mode is used
177
+ // Auto-install hooks
111
178
  if (reactive) {
112
179
  installHooks({ global: true });
113
180
  }
114
181
 
115
- // Otherwise, open a tmux side pane
182
+ // Open tmux side pane — install tmux if needed
183
+ if (!await ensureTmux()) process.exit(1);
116
184
  const inTmux = !!process.env.TMUX;
117
185
  const sextantFlag = flags.sextant ? ' --sextant' : '';
118
186
  const reactiveFlag = reactive ? ' --reactive' : '';
119
- const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli companion ${id} --size=${size}${sextantFlag}${reactiveFlag}`;
187
+ const noAiFlag = noAi ? ' --no-ai' : '';
188
+ const modelFlag = model ? ` --model=${model}` : '';
189
+ const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli play ${id} --size=${size}${sextantFlag}${reactiveFlag}${noAiFlag}${modelFlag}`;
120
190
 
121
191
  try {
122
192
  if (inTmux) {
123
- // Split current window with a side pane
124
193
  execSync(
125
194
  `tmux split-window -h -l ${paneWidth} '${companionCmd}'`,
126
195
  { stdio: 'ignore' }
127
196
  );
128
197
  if (!detach) {
129
- process.stderr.write(` Companion sprite opened in side pane (${size}${reactive ? ', reactive' : ''}).\n`);
198
+ process.stderr.write(` Companion opened in side pane (${size}).\n`);
130
199
  process.stderr.write(' Close the pane or press q in it to stop.\n');
131
200
  }
132
201
  } else {
133
- // Not in tmux — start a new tmux session with the split
134
202
  process.stderr.write(' Starting tmux session with companion pane...\n');
135
203
  execSync(
136
204
  `tmux new-session -d -s storymode -x 100 -y 30 \\; ` +
@@ -142,19 +210,29 @@ export async function run(args) {
142
210
  process.stderr.write(' Companion running in tmux session "storymode".\n');
143
211
  process.stderr.write(' Attach with: tmux attach -t storymode\n');
144
212
  } else {
145
- // Attach to the session so the user sees it
146
213
  execSync('tmux attach -t storymode', { stdio: 'inherit' });
147
214
  }
148
215
  }
149
216
  } catch (err) {
150
217
  console.error(`Error: ${err.message}`);
151
- console.error(' Make sure tmux is installed: brew install tmux');
152
218
  process.exit(1);
153
219
  }
154
220
  break;
155
221
  }
156
222
 
223
+ case 'browse':
224
+ try {
225
+ await browse();
226
+ } catch (err) {
227
+ console.error(`Error: ${err.message}`);
228
+ process.exit(1);
229
+ }
230
+ break;
231
+
232
+ // --- Advanced / hidden commands ---
233
+
157
234
  case 'show': {
235
+ // Legacy: print first frame. Kept for backward compat.
158
236
  if (!id) {
159
237
  console.error('Usage: storymode show <gallery_id>');
160
238
  process.exit(1);
@@ -178,15 +256,6 @@ export async function run(args) {
178
256
  break;
179
257
  }
180
258
 
181
- case 'browse':
182
- try {
183
- await browse();
184
- } catch (err) {
185
- console.error(`Error: ${err.message}`);
186
- process.exit(1);
187
- }
188
- break;
189
-
190
259
  case 'mcp':
191
260
  startMcpServer();
192
261
  break;
package/src/config.mjs ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Persistent config at ~/.storymode/config.json
3
+ */
4
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { createInterface } from 'node:readline';
8
+
9
+ const STORYMODE_DIR = join(homedir(), '.storymode');
10
+ const CONFIG_PATH = join(STORYMODE_DIR, 'config.json');
11
+
12
+ function readConfig() {
13
+ if (!existsSync(CONFIG_PATH)) return {};
14
+ try {
15
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ function writeConfig(config) {
22
+ mkdirSync(STORYMODE_DIR, { recursive: true });
23
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
24
+ }
25
+
26
+ /**
27
+ * Get the Anthropic API key from env or config file.
28
+ * Priority: ANTHROPIC_API_KEY env var > config file
29
+ */
30
+ export function getApiKey() {
31
+ return process.env.ANTHROPIC_API_KEY || readConfig().anthropic_api_key || '';
32
+ }
33
+
34
+ /**
35
+ * Save the Anthropic API key to config file.
36
+ */
37
+ export function saveApiKey(key) {
38
+ const config = readConfig();
39
+ config.anthropic_api_key = key;
40
+ writeConfig(config);
41
+ }
42
+
43
+ /**
44
+ * Check if we've already shown the first-run prompt before.
45
+ */
46
+ export function hasSeenKeyPrompt() {
47
+ return !!readConfig().seen_key_prompt;
48
+ }
49
+
50
+ /**
51
+ * Mark that we've shown the first-run prompt.
52
+ */
53
+ export function markKeyPromptSeen() {
54
+ const config = readConfig();
55
+ config.seen_key_prompt = true;
56
+ writeConfig(config);
57
+ }
58
+
59
+ /**
60
+ * Interactive prompt for API key.
61
+ * Returns the key if entered, or '' if skipped.
62
+ *
63
+ * @param {'first-run'|'reminder'|'hotkey'} mode
64
+ */
65
+ export function promptForApiKey(mode = 'first-run') {
66
+ return new Promise((resolve) => {
67
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
68
+
69
+ if (mode === 'first-run') {
70
+ process.stderr.write('\n');
71
+ process.stderr.write(' ┌─────────────────────────────────────────────────┐\n');
72
+ process.stderr.write(' │ AI Personality (optional) │\n');
73
+ process.stderr.write(' │ │\n');
74
+ process.stderr.write(' │ Your companion can react with unique, in- │\n');
75
+ process.stderr.write(' │ character speech using your Anthropic API key. │\n');
76
+ process.stderr.write(' │ │\n');
77
+ process.stderr.write(' │ Without it, you still get animations + │\n');
78
+ process.stderr.write(' │ preset speech lines — just not AI-generated. │\n');
79
+ process.stderr.write(' │ │\n');
80
+ process.stderr.write(' │ Key is saved locally in ~/.storymode/config. │\n');
81
+ process.stderr.write(' └─────────────────────────────────────────────────┘\n');
82
+ process.stderr.write('\n');
83
+ } else if (mode === 'reminder') {
84
+ process.stderr.write('\n');
85
+ process.stderr.write(' AI personality is off. Press [a] during playback to add your API key,\n');
86
+ process.stderr.write(' or enter it now:\n\n');
87
+ } else if (mode === 'hotkey') {
88
+ process.stderr.write('\n Enter your Anthropic API key (or press Enter to cancel):\n\n');
89
+ }
90
+
91
+ rl.question(' ANTHROPIC_API_KEY: ', (answer) => {
92
+ rl.close();
93
+ const key = (answer || '').trim();
94
+ if (key) {
95
+ saveApiKey(key);
96
+ process.stderr.write(' Key saved to ~/.storymode/config.json\n\n');
97
+ } else if (mode === 'first-run') {
98
+ markKeyPromptSeen();
99
+ process.stderr.write(' Skipped. Press [a] anytime in companion to add it later.\n\n');
100
+ }
101
+ resolve(key);
102
+ });
103
+ });
104
+ }
package/src/mcp.mjs CHANGED
@@ -49,17 +49,20 @@ async function handleToolCall(name, args) {
49
49
  case 'list_characters': {
50
50
  const items = await fetchGallery();
51
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);
52
+ const chars = await Promise.all(
53
+ items.map(item => fetchCharacter(item.id).catch(() => null))
54
+ );
55
+ const parts = items.map((item, i) => {
56
+ const char = chars[i];
55
57
  let line = `${item.name || 'untitled'} (id: ${item.id})`;
56
- if (char?.backstory) line += `\n ${char.backstory}`;
58
+ if (char?.prompt) line += `\n Appearance: ${char.prompt}`;
59
+ if (char?.backstory) line += `\n Backstory: ${char.backstory}`;
57
60
  if (char?.animations?.length) {
58
61
  const names = char.animations.map(a => a.name || a.prompt).join(', ');
59
- line += `\n Animations: ${names}`;
62
+ line += `\n Animations (${char.animations.length}): ${names}`;
60
63
  }
61
- parts.push(line);
62
- }
64
+ return line;
65
+ });
63
66
  return parts.join('\n\n');
64
67
  }
65
68
  case 'show_character': {
package/src/player.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createServer } from 'node:net';
2
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';
3
+ import { mapEventToAnimation, pickSpeech, ONE_SHOT_ANIMS, IDLE_ANIM, SLEEP_ANIM, SLEEP_TIMEOUT_S, narrateEvent, SessionContext, StateEngine } from './reactive.mjs';
4
+ import { createAgent } from './agent.mjs';
5
+ import { getApiKey, hasSeenKeyPrompt, promptForApiKey } from './config.mjs';
4
6
 
5
7
  const { stdout, stdin } = process;
6
8
 
@@ -211,12 +213,29 @@ export function playCompanion(framesData) {
211
213
  * @param {Map<string, {fps: number, frames: string[][]}>} animMap - All animations keyed by name
212
214
  * @param {object} [opts]
213
215
  * @param {string} [opts.characterName] - Character name for status display
216
+ * @param {object} [opts.character] - Character data {name, backstory, ...} for LLM personality
217
+ * @param {object} [opts.personality] - Full personality.json data { character, disposition, animations, speech }
214
218
  * @param {boolean} [opts.fullscreen] - Use alt screen (full-screen mode)
219
+ * @param {boolean} [opts.noAi] - Disable LLM personality layer
220
+ * @param {string} [opts.model] - Anthropic model ID for personality layer
215
221
  */
216
- export function playReactiveCompanion(animMap, opts = {}) {
222
+ export async function playReactiveCompanion(animMap, opts = {}) {
217
223
  const characterName = opts.characterName || 'companion';
224
+ const personality = opts.personality || null;
225
+ let character = opts.noAi ? null : (personality || opts.character || null);
218
226
  const fullscreen = !!opts.fullscreen;
219
227
 
228
+ // --- API key prompt (before terminal setup) ---
229
+ if (character && !opts.noAi && !getApiKey()) {
230
+ if (!hasSeenKeyPrompt()) {
231
+ // First time: full interactive prompt
232
+ await promptForApiKey('first-run');
233
+ } else {
234
+ // Subsequent launches: short reminder
235
+ process.stderr.write(' AI personality: OFF — press [a] to add your API key\n');
236
+ }
237
+ }
238
+
220
239
  // Find idle animation (required)
221
240
  const idleData = animMap.get(IDLE_ANIM);
222
241
  if (!idleData || idleData.frames.length === 0) {
@@ -261,9 +280,36 @@ export function playReactiveCompanion(animMap, opts = {}) {
261
280
  // Recent animation tracking (avoid repeats)
262
281
  const recentAnims = [];
263
282
 
264
- // Session context (for Phase 2 narrator)
283
+ // Session context
265
284
  const sessionCtx = new SessionContext();
266
285
 
286
+ // State engine — tracks internal dimensions over time
287
+ // Extract state dimensions (skip 'drives' — it's for the LLM, not the engine)
288
+ const rawState = personality?.state;
289
+ const stateConfig = rawState ? Object.fromEntries(
290
+ Object.entries(rawState).filter(([k]) => k !== 'drives')
291
+ ) : null;
292
+ const stateEngine = new StateEngine(stateConfig);
293
+
294
+ // Personality layer (LLM agent) — requires character data + API key
295
+ const agentOpts = {
296
+ model: opts.model,
297
+ getState: () => stateEngine.active ? stateEngine.toJSON() : null,
298
+ };
299
+ function onAgentReaction(reaction) {
300
+ if (reaction.animation && animMap.has(reaction.animation)) {
301
+ switchAnimation(reaction.animation, ONE_SHOT_ANIMS.has(reaction.animation) ? 'one-shot' : 'loop');
302
+ }
303
+ if (reaction.speech) {
304
+ speechText = reaction.speech;
305
+ speechSetAt = Date.now();
306
+ lastSpeech = reaction.speech;
307
+ }
308
+ }
309
+ let agent = character
310
+ ? createAgent(character, [...animMap.keys()], onAgentReaction, agentOpts)
311
+ : null;
312
+
267
313
  // Socket path
268
314
  const sockPath = `/tmp/storymode-companion-${process.pid}.sock`;
269
315
 
@@ -383,7 +429,11 @@ export function playReactiveCompanion(animMap, opts = {}) {
383
429
  }
384
430
 
385
431
  // Status line
386
- out += `\x1b[0m\x1b[K ${characterName} [${currentAnimName}] [q]=quit\n`;
432
+ const stateStr = stateEngine.active
433
+ ? ' ' + Object.entries(stateEngine.toJSON()).map(([k, v]) => `${k.slice(0,3)}:${v}`).join(' ')
434
+ : '';
435
+ const keyHints = !agent && character && !opts.noAi ? '[a]=AI key [q]=quit' : '[q]=quit';
436
+ out += `\x1b[0m\x1b[K ${characterName} [${currentAnimName}]${stateStr} ${keyHints}\n`;
387
437
  stdout.write(out);
388
438
  }
389
439
 
@@ -421,22 +471,43 @@ export function playReactiveCompanion(animMap, opts = {}) {
421
471
  // Update session context
422
472
  sessionCtx.update(msg);
423
473
 
424
- // Instant layer: static map
474
+ // Update state engine
475
+ stateEngine.update(msg.event);
476
+
477
+ // Instant layer: static map (0ms)
425
478
  const mapping = mapEventToAnimation(msg.event, animMap);
426
479
  if (mapping) {
427
480
  switchAnimation(mapping.anim, mapping.type);
428
481
  }
429
482
 
430
- // Speech bubble
483
+ // State layer: check thresholds (0ms, overrides instant layer)
484
+ if (stateEngine.active && personality?.animations) {
485
+ const triggered = stateEngine.checkThresholds(personality.animations);
486
+ if (triggered && animMap.has(triggered.animName)) {
487
+ switchAnimation(triggered.animName, triggered.type);
488
+ }
489
+ }
490
+
491
+ // Speech bubble (static fallback)
431
492
  const speech = pickSpeech(msg.event, lastSpeech);
432
493
  if (speech) {
433
494
  speechText = speech;
434
495
  speechSetAt = Date.now();
435
496
  lastSpeech = speech;
436
497
  }
498
+
499
+ // Personality layer: push to LLM agent (debounced, ~3s)
500
+ if (agent) {
501
+ const narrated = narrateEvent(msg);
502
+ agent.pushEvent(
503
+ { summary: narrated, type: msg.event, tool: msg.tool_name || msg.tool },
504
+ sessionCtx.toJSON(),
505
+ );
506
+ }
437
507
  }
438
508
 
439
509
  function cleanup() {
510
+ if (agent) agent.destroy();
440
511
  if (stdin.isTTY) stdin.setRawMode(false);
441
512
  stdin.removeAllListeners('data');
442
513
  stdin.pause();
@@ -449,10 +520,41 @@ export function playReactiveCompanion(animMap, opts = {}) {
449
520
  try { unlinkSync(sockPath); } catch { /* ignore */ }
450
521
  }
451
522
 
523
+ let promptingKey = false;
524
+
525
+ async function handleApiKeyPrompt() {
526
+ if (promptingKey || agent) return;
527
+ promptingKey = true;
528
+
529
+ // Temporarily exit raw mode for readline
530
+ if (stdin.isTTY) stdin.setRawMode(false);
531
+ stdin.removeAllListeners('data');
532
+
533
+ const key = await promptForApiKey('hotkey');
534
+
535
+ // Restore raw mode
536
+ if (stdin.isTTY) {
537
+ stdin.setRawMode(true);
538
+ stdin.resume();
539
+ stdin.on('data', handleKey);
540
+ }
541
+ promptingKey = false;
542
+
543
+ if (key && character) {
544
+ agent = createAgent(character, [...animMap.keys()], onAgentReaction, agentOpts);
545
+ if (agent) {
546
+ speechText = 'AI personality activated!';
547
+ speechSetAt = Date.now();
548
+ }
549
+ }
550
+ }
551
+
452
552
  function handleKey(data) {
453
553
  const key = data.toString();
454
554
  if (key === 'q' || key === '\x03') {
455
555
  quit = true;
556
+ } else if (key === 'a' && !agent && character && !opts.noAi) {
557
+ handleApiKeyPrompt();
456
558
  }
457
559
  }
458
560
 
@@ -472,12 +574,26 @@ export function playReactiveCompanion(animMap, opts = {}) {
472
574
  return;
473
575
  }
474
576
 
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');
577
+ // State engine tick (passive drain over time)
578
+ stateEngine.tick();
579
+
580
+ // State-driven animation check (e.g. energy < 0.15 → sleep)
581
+ if (stateEngine.active && personality?.animations) {
582
+ const triggered = stateEngine.checkThresholds(personality.animations);
583
+ if (triggered && animMap.has(triggered.animName) && currentAnimName !== triggered.animName) {
584
+ switchAnimation(triggered.animName, triggered.type);
585
+ sleeping = triggered.animName === SLEEP_ANIM;
586
+ }
587
+ }
588
+
589
+ // Fallback sleep check (when no state engine or no energy dimension)
590
+ if (!sleeping) {
591
+ const idleTime = (Date.now() - lastEventTime) / 1000;
592
+ if (idleTime >= SLEEP_TIMEOUT_S) {
593
+ sleeping = true;
594
+ if (animMap.has(SLEEP_ANIM)) {
595
+ switchAnimation(SLEEP_ANIM, 'loop');
596
+ }
481
597
  }
482
598
  }
483
599
 
@@ -504,6 +620,14 @@ export function playReactiveCompanion(animMap, opts = {}) {
504
620
 
505
621
  // Print socket path so hooks can find it
506
622
  process.stderr.write(` Socket: ${sockPath}\n`);
623
+ if (agent) {
624
+ const modelName = opts.model || 'claude-haiku-4-5';
625
+ process.stderr.write(` AI personality: ON (${modelName})\n`);
626
+ } else if (character && !opts.noAi) {
627
+ process.stderr.write(` AI personality: OFF — press [a] to add API key\n`);
628
+ } else if (opts.noAi) {
629
+ process.stderr.write(` AI personality: OFF (--no-ai)\n`);
630
+ }
507
631
  process.stderr.write(` Listening for Claude Code events...\n`);
508
632
 
509
633
  const sigHandler = () => { quit = true; };