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/src/player.mjs CHANGED
@@ -1,3 +1,9 @@
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, StateEngine } from './reactive.mjs';
4
+ import { createAgent } from './agent.mjs';
5
+ import { getApiKey, hasSeenKeyPrompt, promptForApiKey } from './config.mjs';
6
+
1
7
  const { stdout, stdin } = process;
2
8
 
3
9
  export function playAnimation(framesData) {
@@ -201,6 +207,436 @@ export function playCompanion(framesData) {
201
207
  });
202
208
  }
203
209
 
210
+ /**
211
+ * Reactive companion player — responds to Claude Code events via Unix socket.
212
+ *
213
+ * @param {Map<string, {fps: number, frames: string[][]}>} animMap - All animations keyed by name
214
+ * @param {object} [opts]
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 }
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
221
+ */
222
+ export async function playReactiveCompanion(animMap, opts = {}) {
223
+ const characterName = opts.characterName || 'companion';
224
+ const personality = opts.personality || null;
225
+ let character = opts.noAi ? null : (personality || opts.character || null);
226
+ const fullscreen = !!opts.fullscreen;
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
+
239
+ // Find idle animation (required)
240
+ const idleData = animMap.get(IDLE_ANIM);
241
+ if (!idleData || idleData.frames.length === 0) {
242
+ console.log('No idle animation found. Need "idle breathing" animation.');
243
+ return Promise.resolve();
244
+ }
245
+
246
+ // Calculate maxLines across ALL animations for stable layout
247
+ let maxLines = 0;
248
+ for (const [, data] of animMap) {
249
+ for (const frame of data.frames) {
250
+ if (frame.length > maxLines) maxLines = frame.length;
251
+ }
252
+ }
253
+
254
+ // Speech bubble: max 3 lines + border (top + bottom) + blank line = 6 extra lines
255
+ const SPEECH_CLEAR_MS = 6000;
256
+ const BUBBLE_MAX_LINES = 3;
257
+ const bubbleLines = BUBBLE_MAX_LINES + 3; // top border + text lines + bottom border
258
+
259
+ // Status line + quit hint + speech bubble area
260
+ const totalLines = maxLines + 1 + bubbleLines;
261
+
262
+ // Animation state
263
+ let currentAnimName = IDLE_ANIM;
264
+ let currentFrames = idleData.frames;
265
+ let currentFps = idleData.fps || 16;
266
+ let frameIdx = 0;
267
+ let loopCount = 0;
268
+ let oneShotLoopsRemaining = 0; // >0 means playing a one-shot
269
+ let quit = false;
270
+
271
+ // Sleep state
272
+ let lastEventTime = Date.now();
273
+ let sleeping = false;
274
+
275
+ // Speech bubble state
276
+ let speechText = null;
277
+ let speechSetAt = 0;
278
+ let lastSpeech = null;
279
+
280
+ // Recent animation tracking (avoid repeats)
281
+ const recentAnims = [];
282
+
283
+ // Session context
284
+ const sessionCtx = new SessionContext();
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
+
313
+ // Socket path
314
+ const sockPath = `/tmp/storymode-companion-${process.pid}.sock`;
315
+
316
+ // Clean up stale socket
317
+ if (existsSync(sockPath)) {
318
+ try { unlinkSync(sockPath); } catch { /* ignore */ }
319
+ }
320
+
321
+ // --- Terminal setup ---
322
+ if (fullscreen) {
323
+ // Alt screen, hide cursor, clear
324
+ stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H');
325
+ } else {
326
+ stdout.write('\x1b[?25l'); // hide cursor
327
+ // Reserve space
328
+ for (let i = 0; i < totalLines; i++) {
329
+ stdout.write('\n');
330
+ }
331
+ stdout.write(`\x1b[${totalLines}A`);
332
+ stdout.write('\x1b7'); // save cursor position
333
+ }
334
+
335
+ function switchAnimation(animName, type) {
336
+ if (!animMap.has(animName)) {
337
+ // Fallback to idle if animation not found
338
+ if (animName === IDLE_ANIM) return;
339
+ animName = IDLE_ANIM;
340
+ type = 'loop';
341
+ }
342
+
343
+ const data = animMap.get(animName);
344
+ currentAnimName = animName;
345
+ currentFrames = data.frames;
346
+ currentFps = data.fps || 16;
347
+ frameIdx = 0;
348
+ loopCount = 0;
349
+
350
+ if (type === 'one-shot' || ONE_SHOT_ANIMS.has(animName)) {
351
+ oneShotLoopsRemaining = 2; // play 2 loops for one-shot
352
+ } else {
353
+ oneShotLoopsRemaining = 0;
354
+ }
355
+
356
+ // Track recent
357
+ recentAnims.push(animName);
358
+ if (recentAnims.length > 3) recentAnims.shift();
359
+ }
360
+
361
+ /** Word-wrap text to fit inside a bubble of given inner width */
362
+ function wrapText(text, width) {
363
+ const words = text.split(' ');
364
+ const lines = [];
365
+ let line = '';
366
+ for (const word of words) {
367
+ if (line.length + word.length + (line ? 1 : 0) > width) {
368
+ if (line) lines.push(line);
369
+ line = word.slice(0, width); // truncate long words
370
+ } else {
371
+ line = line ? line + ' ' + word : word;
372
+ }
373
+ }
374
+ if (line) lines.push(line);
375
+ return lines.slice(0, BUBBLE_MAX_LINES);
376
+ }
377
+
378
+ /** Render a speech bubble as an array of strings */
379
+ function renderBubble(text, width) {
380
+ const innerW = width - 4; // │ + space + text + space + │
381
+ const wrapped = wrapText(text, innerW);
382
+ const lines = [];
383
+ lines.push(' \x1b[0m╭' + '─'.repeat(innerW + 2) + '╮');
384
+ for (const wl of wrapped) {
385
+ lines.push(' \x1b[0m│ ' + wl + ' '.repeat(innerW - wl.length) + ' │');
386
+ }
387
+ // Pad remaining bubble lines if fewer than max
388
+ for (let i = wrapped.length; i < BUBBLE_MAX_LINES; i++) {
389
+ lines.push(' \x1b[0m│' + ' '.repeat(innerW + 2) + '│');
390
+ }
391
+ lines.push(' \x1b[0m╰' + '─'.repeat(innerW + 2) + '╯');
392
+ return lines;
393
+ }
394
+
395
+ // Estimate sprite width from first frame of idle animation
396
+ const spriteWidth = (() => {
397
+ const firstLine = idleData.frames[0]?.[0] || '';
398
+ // Strip ANSI escape sequences to get visible character count
399
+ return firstLine.replace(/\x1b\[[0-9;]*m/g, '').length;
400
+ })();
401
+ const bubbleWidth = Math.max(20, Math.min(spriteWidth, 60));
402
+
403
+ function drawFrame(idx) {
404
+ let out = fullscreen ? '\x1b[H' : '\x1b8'; // cursor home vs restore
405
+ const frameLines = currentFrames[idx] || [];
406
+ for (let i = 0; i < maxLines; i++) {
407
+ if (i < frameLines.length) {
408
+ out += frameLines[i];
409
+ }
410
+ out += '\x1b[K\n';
411
+ }
412
+
413
+ // Auto-clear speech after timeout
414
+ if (speechText && (Date.now() - speechSetAt > SPEECH_CLEAR_MS)) {
415
+ speechText = null;
416
+ }
417
+
418
+ // Speech bubble or empty space
419
+ if (speechText) {
420
+ const bubble = renderBubble(speechText, bubbleWidth);
421
+ for (const bl of bubble) {
422
+ out += bl + '\x1b[K\n';
423
+ }
424
+ } else {
425
+ // Empty lines to keep layout stable
426
+ for (let i = 0; i < bubbleLines - 1; i++) {
427
+ out += '\x1b[K\n';
428
+ }
429
+ }
430
+
431
+ // Status line
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`;
437
+ stdout.write(out);
438
+ }
439
+
440
+ // --- Socket server ---
441
+ const server = createServer((conn) => {
442
+ let buf = '';
443
+ conn.on('data', (chunk) => { buf += chunk.toString(); });
444
+ conn.on('end', () => {
445
+ // Process each line (usually just one)
446
+ for (const line of buf.split('\n').filter(Boolean)) {
447
+ try {
448
+ const msg = JSON.parse(line);
449
+ handleEvent(msg);
450
+ } catch {
451
+ // Ignore malformed messages
452
+ }
453
+ }
454
+ });
455
+ });
456
+
457
+ server.on('error', (err) => {
458
+ // If socket already in use, try to recover
459
+ if (err.code === 'EADDRINUSE') {
460
+ try { unlinkSync(sockPath); } catch { /* ignore */ }
461
+ server.listen(sockPath);
462
+ }
463
+ });
464
+
465
+ server.listen(sockPath);
466
+
467
+ function handleEvent(msg) {
468
+ lastEventTime = Date.now();
469
+ sleeping = false;
470
+
471
+ // Update session context
472
+ sessionCtx.update(msg);
473
+
474
+ // Update state engine
475
+ stateEngine.update(msg.event);
476
+
477
+ // Instant layer: static map (0ms)
478
+ const mapping = mapEventToAnimation(msg.event, animMap);
479
+ if (mapping) {
480
+ switchAnimation(mapping.anim, mapping.type);
481
+ }
482
+
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)
492
+ const speech = pickSpeech(msg.event, lastSpeech);
493
+ if (speech) {
494
+ speechText = speech;
495
+ speechSetAt = Date.now();
496
+ lastSpeech = speech;
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
+ }
507
+ }
508
+
509
+ function cleanup() {
510
+ if (agent) agent.destroy();
511
+ if (stdin.isTTY) stdin.setRawMode(false);
512
+ stdin.removeAllListeners('data');
513
+ stdin.pause();
514
+ if (fullscreen) {
515
+ stdout.write('\x1b[?1049l\x1b[?25h'); // exit alt screen, show cursor
516
+ } else {
517
+ stdout.write('\x1b[?25h'); // show cursor
518
+ }
519
+ server.close();
520
+ try { unlinkSync(sockPath); } catch { /* ignore */ }
521
+ }
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
+
552
+ function handleKey(data) {
553
+ const key = data.toString();
554
+ if (key === 'q' || key === '\x03') {
555
+ quit = true;
556
+ } else if (key === 'a' && !agent && character && !opts.noAi) {
557
+ handleApiKeyPrompt();
558
+ }
559
+ }
560
+
561
+ if (stdin.isTTY) {
562
+ stdin.setRawMode(true);
563
+ stdin.resume();
564
+ stdin.on('data', handleKey);
565
+ }
566
+
567
+ return new Promise((resolve) => {
568
+ const interval = () => 1000 / currentFps;
569
+
570
+ function tick() {
571
+ if (quit) {
572
+ cleanup();
573
+ resolve();
574
+ return;
575
+ }
576
+
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
+ }
597
+ }
598
+ }
599
+
600
+ drawFrame(frameIdx);
601
+ frameIdx++;
602
+
603
+ if (frameIdx >= currentFrames.length) {
604
+ loopCount++;
605
+ frameIdx = 0;
606
+
607
+ // One-shot: count down loops, return to idle
608
+ if (oneShotLoopsRemaining > 0) {
609
+ oneShotLoopsRemaining--;
610
+ if (oneShotLoopsRemaining <= 0) {
611
+ switchAnimation(IDLE_ANIM, 'loop');
612
+ }
613
+ }
614
+ }
615
+
616
+ setTimeout(tick, interval());
617
+ }
618
+
619
+ tick();
620
+
621
+ // Print socket path so hooks can find it
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
+ }
631
+ process.stderr.write(` Listening for Claude Code events...\n`);
632
+
633
+ const sigHandler = () => { quit = true; };
634
+ process.on('SIGINT', sigHandler);
635
+ process.on('SIGTERM', sigHandler);
636
+ process.on('SIGHUP', sigHandler);
637
+ });
638
+ }
639
+
204
640
  export function showFrame(framesData, info) {
205
641
  const frames = framesData.frames;
206
642
  if (!frames || frames.length === 0) {