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.
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Reactive companion: event mapping, narrator, state engine, animation loader.
3
+ */
4
+
5
+ /** Animations that play once then return to idle */
6
+ export const ONE_SHOT_ANIMS = new Set([
7
+ 'waving hello',
8
+ ]);
9
+
10
+ /** Default idle animation name */
11
+ export const IDLE_ANIM = 'idle breathing';
12
+
13
+ /** Sleep animation name */
14
+ export const SLEEP_ANIM = 'falling asleep';
15
+
16
+ /** Seconds of inactivity before sleep */
17
+ export const SLEEP_TIMEOUT_S = 120;
18
+
19
+ /**
20
+ * Static map: hook event → animation name + type.
21
+ * Key format matches STORYMODE_HOOK_EVENT env var values.
22
+ */
23
+ export const STATIC_MAP = {
24
+ SessionStart: { anim: 'waving hello', type: 'one-shot' },
25
+ UserPromptSubmit: { anim: 'scratching head while thinking', type: 'loop' },
26
+ PostToolUse_action: { anim: 'casting lightning spell', type: 'loop' },
27
+ PostToolUse_research: { anim: 'scratching head while thinking', type: 'loop' },
28
+ PostToolUseFailure: { anim: 'charging up magical energy', type: 'loop' },
29
+ Stop: { anim: 'idle breathing', type: 'loop' },
30
+ SessionEnd: { anim: 'waving hello', type: 'one-shot' },
31
+ SubagentStart: { anim: 'charging up magical energy', type: 'one-shot' },
32
+ SubagentStop: { anim: 'idle breathing', type: 'loop' },
33
+ };
34
+
35
+ /**
36
+ * Speech lines per event type. A random one is picked each time.
37
+ * null = no speech (stay quiet).
38
+ */
39
+ export const SPEECH_MAP = {
40
+ SessionStart: ['Hey there!', 'Ready to code!', "Let's go~", 'Hi hi!', 'Oh, hello!'],
41
+ UserPromptSubmit: ['Hmm...', 'Interesting...', 'Ooh, a challenge!', 'Let me think...', null, null],
42
+ PostToolUse_action: ['Nice!', 'Zap!', 'Code go brrr~', null, null, null],
43
+ PostToolUse_research: ['Looking...', 'Where is it...', null, null, null, null],
44
+ PostToolUseFailure: ['Oof!', 'That stings...', "We'll fix it!", 'Ouch!', 'Ow ow ow'],
45
+ Stop: ['Done!', 'All yours~', null, null],
46
+ SessionEnd: ['Bye bye!', 'See ya~', 'Good session!'],
47
+ SubagentStart: ['Reinforcements!', 'A friend!', 'Backup arriving~'],
48
+ SubagentStop: ['And they vanish...', 'Back to us~', null],
49
+ };
50
+
51
+ /** Pick a random speech line for an event, avoiding the last spoken line */
52
+ export function pickSpeech(eventType, lastSpeech) {
53
+ const lines = SPEECH_MAP[eventType];
54
+ if (!lines || lines.length === 0) return null;
55
+ // Try up to 3 times to avoid repeating
56
+ for (let i = 0; i < 3; i++) {
57
+ const pick = lines[Math.floor(Math.random() * lines.length)];
58
+ if (pick !== lastSpeech) return pick;
59
+ }
60
+ return lines[Math.floor(Math.random() * lines.length)];
61
+ }
62
+
63
+ /**
64
+ * Instant-layer lookup: event string → { anim, type } or null.
65
+ * Falls back gracefully if the animation doesn't exist in the loaded set.
66
+ */
67
+ export function mapEventToAnimation(eventType, availableAnims) {
68
+ const mapping = STATIC_MAP[eventType];
69
+ if (!mapping) return null;
70
+ // If the target animation isn't available, fall back to idle
71
+ if (availableAnims && !availableAnims.has(mapping.anim)) {
72
+ if (mapping.anim === IDLE_ANIM) return null; // already idle
73
+ return { anim: IDLE_ANIM, type: 'loop' };
74
+ }
75
+ return mapping;
76
+ }
77
+
78
+ /**
79
+ * Narrate a raw hook event into a 1-line human-readable summary.
80
+ * Used for future LLM context (Phase 2).
81
+ */
82
+ export function narrateEvent(msg) {
83
+ const { event } = msg;
84
+
85
+ if (event === 'SessionStart') return 'Session started';
86
+ if (event === 'SessionEnd') return 'Session ended';
87
+ if (event === 'SubagentStart') return 'Sub-agent spawned';
88
+ if (event === 'SubagentStop') return 'Sub-agent finished';
89
+ if (event === 'Stop') return 'Claude stopped generating';
90
+
91
+ if (event === 'UserPromptSubmit') {
92
+ const prompt = msg.prompt || msg.tool_input?.prompt;
93
+ if (prompt) {
94
+ const short = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt;
95
+ return `User asked: '${short}'`;
96
+ }
97
+ return 'User submitted a prompt';
98
+ }
99
+
100
+ if (event === 'PostToolUseFailure') {
101
+ const tool = msg.tool_name || msg.tool || 'unknown tool';
102
+ return `${tool} failed`;
103
+ }
104
+
105
+ // PostToolUse_action or PostToolUse_research
106
+ if (event?.startsWith('PostToolUse')) {
107
+ const tool = msg.tool_name || msg.tool || '';
108
+ const input = msg.tool_input || {};
109
+
110
+ if (tool === 'Bash') {
111
+ const cmd = input.command || '';
112
+ const short = cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
113
+ return `Ran \`${short}\``;
114
+ }
115
+ if (tool === 'Edit' || tool === 'Write') {
116
+ const file = (input.file_path || '').split('/').pop() || 'a file';
117
+ return `Edited ${file}`;
118
+ }
119
+ if (tool === 'Read') {
120
+ const file = (input.file_path || '').split('/').pop() || 'a file';
121
+ return `Read ${file}`;
122
+ }
123
+ if (tool === 'Grep') {
124
+ const pattern = input.pattern || '';
125
+ return `Searched for '${pattern}'`;
126
+ }
127
+ if (tool === 'Glob') {
128
+ const pattern = input.pattern || '';
129
+ return `Searched files: ${pattern}`;
130
+ }
131
+ return `Used ${tool}`;
132
+ }
133
+
134
+ return `Event: ${event || 'unknown'}`;
135
+ }
136
+
137
+ /**
138
+ * Session context — tracks accumulated stats for LLM context (Phase 2).
139
+ */
140
+ export class SessionContext {
141
+ constructor() {
142
+ this.startedAt = Date.now();
143
+ this.toolCount = 0;
144
+ this.errorCount = 0;
145
+ this.filesTouched = new Set();
146
+ this.currentActivity = 'idle';
147
+ }
148
+
149
+ update(msg) {
150
+ const { event } = msg;
151
+ if (event?.startsWith('PostToolUse')) {
152
+ this.toolCount++;
153
+ const file = msg.tool_input?.file_path;
154
+ if (file) this.filesTouched.add(file.split('/').pop());
155
+ this.currentActivity = event.includes('research') ? 'researching' : 'coding';
156
+ }
157
+ if (event === 'PostToolUseFailure') {
158
+ this.errorCount++;
159
+ this.currentActivity = 'debugging';
160
+ }
161
+ if (event === 'UserPromptSubmit') {
162
+ this.currentActivity = 'thinking';
163
+ }
164
+ }
165
+
166
+ toJSON() {
167
+ return {
168
+ duration_s: Math.round((Date.now() - this.startedAt) / 1000),
169
+ tool_count: this.toolCount,
170
+ error_count: this.errorCount,
171
+ files_touched: [...this.filesTouched].slice(-10),
172
+ current_activity: this.currentActivity,
173
+ };
174
+ }
175
+ }
176
+
177
+ // --- State Engine ---
178
+
179
+ /**
180
+ * Parse prose descriptions for rate keywords.
181
+ * Returns a magnitude (0-1 scale per event/tick).
182
+ */
183
+ function parseRate(prose) {
184
+ if (!prose) return 0.06;
185
+ const lower = prose.toLowerCase();
186
+ if (/spike|hard|enormous|catastrophic|dramatic/.test(lower)) return 0.18;
187
+ if (/fast|quick|rapid|immediate/.test(lower)) return 0.12;
188
+ if (/slow|glacial|barely|steady|small/.test(lower)) return 0.03;
189
+ if (/moderate|gradual/.test(lower)) return 0.06;
190
+ return 0.06;
191
+ }
192
+
193
+ /**
194
+ * Keyword → category mapping for trigger extraction.
195
+ */
196
+ const TRIGGER_KEYWORDS = [
197
+ [/\b(?:error|fail|wrong|broke|stings)\b/, 'error'],
198
+ [/\b(?:success|complete|done|finish|overcome|solved)\b/, 'success'],
199
+ [/\b(?:idle|silence|bored|quiet|nothing|waiting|still)\b/, 'idle'],
200
+ [/\b(?:over time|constant|always|even when|exhaust|deplete)\b/, 'time'],
201
+ [/\b(?:any event|anything|everything|any activity|always happen|wake)\b/, 'any'],
202
+ [/\b(?:novel|interesting|curious)\b/, 'novel'],
203
+ [/\b(?:sloppy|messy|hasty|rush)\b/, 'sloppy'],
204
+ [/\b(?:craft|clean|thought|careful|proper|organiz)/, 'craft'],
205
+ [/\b(?:praise|liked|respect)\b/, 'praise'],
206
+ [/\b(?:repeat|again|compound|stack|pile|sustain|streak)\b/, 'repeat'],
207
+ ];
208
+
209
+ /**
210
+ * Parse prose descriptions for event-type triggers.
211
+ *
212
+ * @param {string} prose
213
+ * @param {'drain'|'restore'} fieldType - Which field this prose comes from
214
+ * @returns {{ positive: string[], negative: string[] }}
215
+ * positive: triggers that act in the field's expected direction
216
+ * negative: triggers in the OPPOSITE direction (e.g. "clean refactors lower it" in a restore field)
217
+ */
218
+ function parseTriggers(prose, fieldType) {
219
+ if (!prose) return { positive: [], negative: [] };
220
+
221
+ // Split into clauses on sentence boundaries
222
+ const clauses = prose.toLowerCase().split(/[.;!]\s*|\s+but\s+/);
223
+ const positive = new Set();
224
+ const negative = new Set();
225
+
226
+ for (const clause of clauses) {
227
+ if (!clause.trim()) continue;
228
+
229
+ // Only detect contradictions in restore fields:
230
+ // "clean refactors lower it" in a restore field → clean is actually a drain trigger
231
+ // In drain fields, decrease-words just confirm the field direction, not negate it
232
+ const isContradiction = fieldType === 'restore'
233
+ && /\blower|reduce|drop|decrease|diminish\b/.test(clause);
234
+
235
+ for (const [pattern, category] of TRIGGER_KEYWORDS) {
236
+ if (pattern.test(clause)) {
237
+ if (isContradiction) {
238
+ negative.add(category);
239
+ } else {
240
+ positive.add(category);
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ return { positive: [...positive], negative: [...negative] };
247
+ }
248
+
249
+ /**
250
+ * Parse a "when" condition like "< 0.15" or "> 0.8" into a test function.
251
+ */
252
+ function parseCondition(condStr) {
253
+ const match = condStr.match(/^\s*(>=?|<=?)\s*([\d.]+)\s*$/);
254
+ if (!match) return null;
255
+ const op = match[1];
256
+ const val = parseFloat(match[2]);
257
+ if (op === '<') return (v) => v < val;
258
+ if (op === '<=') return (v) => v <= val;
259
+ if (op === '>') return (v) => v > val;
260
+ if (op === '>=') return (v) => v >= val;
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * Categorize a raw hook event into abstract categories for state matching.
266
+ * Categories match the keywords extracted by parseTriggers().
267
+ */
268
+ function categorizeEvent(eventType) {
269
+ const cats = new Set();
270
+ cats.add('any');
271
+ if (eventType === 'PostToolUseFailure') {
272
+ cats.add('error');
273
+ cats.add('sloppy'); // errors can be seen as sloppy work
274
+ } else if (eventType === 'Stop') {
275
+ cats.add('success');
276
+ cats.add('craft');
277
+ } else if (eventType?.startsWith('PostToolUse')) {
278
+ cats.add('success');
279
+ cats.add('activity');
280
+ cats.add('craft');
281
+ } else if (eventType === 'UserPromptSubmit') {
282
+ cats.add('novel');
283
+ cats.add('activity');
284
+ } else if (eventType === 'SessionStart') {
285
+ cats.add('success');
286
+ }
287
+ return cats;
288
+ }
289
+
290
+ /**
291
+ * State engine — tracks internal dimensions over time based on disposition.
292
+ *
293
+ * @param {object} stateConfig - disposition.state from personality.json
294
+ * e.g. { energy: { initial: 0.8, drain: "...", restore: "..." }, ... }
295
+ */
296
+ export class StateEngine {
297
+ constructor(stateConfig) {
298
+ this.values = {};
299
+ this.config = {};
300
+ this.lastTickTime = Date.now();
301
+ this.consecutiveErrors = 0;
302
+ this.consecutiveSuccesses = 0;
303
+
304
+ if (!stateConfig) return;
305
+
306
+ for (const [name, cfg] of Object.entries(stateConfig)) {
307
+ this.values[name] = cfg.initial ?? 0.5;
308
+
309
+ const drainParsed = parseTriggers(cfg.drain, 'drain');
310
+ const restoreParsed = parseTriggers(cfg.restore, 'restore');
311
+
312
+ this.config[name] = {
313
+ drainRate: parseRate(cfg.drain),
314
+ restoreRate: parseRate(cfg.restore),
315
+ // Drain triggers: positive from drain field + negated from restore field
316
+ drainTriggers: [...drainParsed.positive, ...restoreParsed.negative],
317
+ // Restore triggers: positive from restore field + negated from drain field
318
+ restoreTriggers: [...restoreParsed.positive, ...drainParsed.negative],
319
+ // Time-based drain flag
320
+ hasTimeDrain: drainParsed.positive.includes('time') || drainParsed.positive.includes('idle'),
321
+ };
322
+ }
323
+ }
324
+
325
+ /** Returns true if this engine has any dimensions to track. */
326
+ get active() {
327
+ return Object.keys(this.config).length > 0;
328
+ }
329
+
330
+ /**
331
+ * Update state based on an event.
332
+ * @param {string} eventType - Raw hook event type (e.g. 'PostToolUseFailure')
333
+ */
334
+ update(eventType) {
335
+ if (!this.active) return;
336
+
337
+ // Track streaks
338
+ if (eventType === 'PostToolUseFailure') {
339
+ this.consecutiveErrors++;
340
+ this.consecutiveSuccesses = 0;
341
+ } else if (eventType?.startsWith('PostToolUse') || eventType === 'Stop') {
342
+ this.consecutiveSuccesses++;
343
+ this.consecutiveErrors = 0;
344
+ }
345
+
346
+ const cats = categorizeEvent(eventType);
347
+ const repeatMultiplier = cats.has('error')
348
+ ? Math.min(1 + this.consecutiveErrors * 0.3, 2.5)
349
+ : cats.has('success')
350
+ ? Math.min(1 + this.consecutiveSuccesses * 0.2, 2.0)
351
+ : 1;
352
+
353
+ for (const [name, cfg] of Object.entries(this.config)) {
354
+ let delta = 0;
355
+
356
+ // Check restore triggers (things that INCREASE this dimension)
357
+ const restoreMatch = cfg.restoreTriggers.some(t => cats.has(t));
358
+ if (restoreMatch) {
359
+ delta += cfg.restoreRate * repeatMultiplier;
360
+ }
361
+
362
+ // Check drain triggers (things that DECREASE this dimension), excluding time-based
363
+ const drainMatch = cfg.drainTriggers.some(t => cats.has(t) && t !== 'time' && t !== 'idle');
364
+ if (drainMatch) {
365
+ delta -= cfg.drainRate * repeatMultiplier;
366
+ }
367
+
368
+ this.values[name] = Math.max(0, Math.min(1, this.values[name] + delta));
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Time-based tick — applies passive drain/restore.
374
+ * Call this periodically (e.g. every 5 seconds).
375
+ */
376
+ tick() {
377
+ if (!this.active) return;
378
+
379
+ const now = Date.now();
380
+ const elapsed = (now - this.lastTickTime) / 1000;
381
+ this.lastTickTime = now;
382
+
383
+ // Scale changes by time elapsed (normalized to 10s intervals)
384
+ const scale = elapsed / 10;
385
+
386
+ for (const [name, cfg] of Object.entries(this.config)) {
387
+ let delta = 0;
388
+
389
+ // Time-based drain
390
+ if (cfg.hasTimeDrain) {
391
+ delta -= cfg.drainRate * scale;
392
+ }
393
+
394
+ // Passive decay toward 0 for dimensions without event-based drain triggers.
395
+ // Uses the parsed drain rate so "evaporates quickly" decays faster than "slow decay."
396
+ if (!cfg.hasTimeDrain && cfg.drainTriggers.length === 0 && this.values[name] > 0) {
397
+ delta -= cfg.drainRate * scale;
398
+ }
399
+
400
+ if (delta !== 0) {
401
+ this.values[name] = Math.max(0, Math.min(1, this.values[name] + delta));
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Check animation `when` conditions against current state.
408
+ * Returns the first matching animation role, or null.
409
+ *
410
+ * @param {object} animations - animations section from personality.json
411
+ * Structure: { set: { role: { name, type, when? } } }
412
+ * @returns {{ animName: string, type: string } | null}
413
+ */
414
+ checkThresholds(animations) {
415
+ if (!this.active || !animations) return null;
416
+
417
+ for (const set of Object.values(animations)) {
418
+ for (const [, entry] of Object.entries(set)) {
419
+ if (!entry.when) continue;
420
+ let allMatch = true;
421
+ for (const [dim, condStr] of Object.entries(entry.when)) {
422
+ if (!(dim in this.values)) { allMatch = false; break; }
423
+ const test = parseCondition(condStr);
424
+ if (!test || !test(this.values[dim])) { allMatch = false; break; }
425
+ }
426
+ if (allMatch) {
427
+ return { animName: entry.name, type: entry.type || 'loop' };
428
+ }
429
+ }
430
+ }
431
+ return null;
432
+ }
433
+
434
+ /** Current state as a plain object. */
435
+ toJSON() {
436
+ const out = {};
437
+ for (const [k, v] of Object.entries(this.values)) {
438
+ out[k] = Math.round(v * 100) / 100;
439
+ }
440
+ return out;
441
+ }
442
+ }