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/README.md +36 -38
- package/package.json +1 -1
- package/src/agent.mjs +282 -0
- package/src/browse.mjs +91 -7
- package/src/cache.mjs +44 -2
- package/src/cli.mjs +130 -61
- package/src/config.mjs +104 -0
- package/src/mcp.mjs +10 -7
- package/src/player.mjs +136 -12
- package/src/reactive.mjs +268 -2
package/src/reactive.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reactive companion: event mapping, narrator, animation loader.
|
|
3
|
-
* Phase 1 — static map only (no LLM).
|
|
2
|
+
* Reactive companion: event mapping, narrator, state engine, animation loader.
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
5
|
/** Animations that play once then return to idle */
|
|
@@ -174,3 +173,270 @@ export class SessionContext {
|
|
|
174
173
|
};
|
|
175
174
|
}
|
|
176
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
|
+
}
|