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/README.md
CHANGED
|
@@ -1,64 +1,62 @@
|
|
|
1
1
|
# storymode-cli
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
6
|
+
npx storymode-cli play
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Quick Start
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
npx storymode-cli play 1
|
|
11
|
+
```bash
|
|
12
|
+
npx storymode-cli play
|
|
16
13
|
```
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
- `space` — pause / resume
|
|
20
|
-
- `←` `→` — step frames (when paused)
|
|
21
|
-
- `+` `-` — adjust speed
|
|
22
|
-
- `q` — quit
|
|
15
|
+
That's it. A 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.
|
|
23
16
|
|
|
24
|
-
|
|
25
|
-
Print the first frame as a static portrait.
|
|
17
|
+
Use `browse` to switch characters or visit [storymode.fixmy.codes](https://storymode.fixmy.codes) to see the full gallery.
|
|
26
18
|
|
|
27
|
-
```
|
|
28
|
-
npx storymode-cli
|
|
19
|
+
```bash
|
|
20
|
+
npx storymode-cli browse
|
|
29
21
|
```
|
|
30
22
|
|
|
31
|
-
|
|
32
|
-
Browse the gallery and pick an animation to play.
|
|
23
|
+
## Options
|
|
33
24
|
|
|
34
|
-
```
|
|
35
|
-
npx storymode-cli
|
|
25
|
+
```bash
|
|
26
|
+
npx storymode-cli play # full size (default)
|
|
27
|
+
npx storymode-cli play --compact # smaller pane
|
|
28
|
+
npx storymode-cli play --tiny # tiny pane
|
|
29
|
+
npx storymode-cli play --no-ai # disable AI personality
|
|
30
|
+
npx storymode-cli play --classic # full-screen animation viewer
|
|
36
31
|
```
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
Start a Model Context Protocol server for Claude Code integration.
|
|
33
|
+
**Keyboard:** `a` add API key (enables AI personality), `q` quit.
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
## AI Personality
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
37
|
+
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`.
|
|
38
|
+
|
|
39
|
+
Without it, you still get reactive animations and preset speech lines.
|
|
40
|
+
|
|
41
|
+
## How It Works
|
|
42
|
+
|
|
43
|
+
Three layers, each faster than the last:
|
|
44
|
+
|
|
45
|
+
1. **Instant** (0ms) — maps Claude Code events to animations + random speech lines
|
|
46
|
+
2. **State engine** (0ms) — tracks character dimensions (energy, frustration, etc.) over time. Threshold crossings change animations (e.g. falls asleep when energy is low)
|
|
47
|
+
3. **LLM personality** (~3s) — batches recent events and asks Haiku for an in-character reaction
|
|
48
|
+
|
|
49
|
+
## Character Personalities
|
|
50
|
+
|
|
51
|
+
Characters can have personality files that control their behavior. See [docs/personality.md](docs/personality.md) for the full guide.
|
|
53
52
|
|
|
54
53
|
## Requirements
|
|
55
54
|
|
|
56
55
|
- Node.js 18+
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
## Zero Dependencies
|
|
56
|
+
- Truecolor terminal (iTerm2, Terminal.app, Windows Terminal, Claude Code)
|
|
57
|
+
- tmux (auto-installed if missing)
|
|
60
58
|
|
|
61
|
-
|
|
59
|
+
Zero runtime dependencies — built entirely on Node.js built-ins.
|
|
62
60
|
|
|
63
61
|
## Gallery
|
|
64
62
|
|
package/package.json
CHANGED
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
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
CHANGED
|
@@ -57,6 +57,38 @@ export function readCachedCharacter(galleryId, size) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
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
|
+
|
|
60
92
|
/** Read local animation overrides. Returns Map<name, {fps, frames}> */
|
|
61
93
|
export function readLocalOverrides(galleryId) {
|
|
62
94
|
const dir = getLocalDir(galleryId);
|
|
@@ -77,7 +109,12 @@ export function readLocalOverrides(galleryId) {
|
|
|
77
109
|
|
|
78
110
|
/**
|
|
79
111
|
* Load all animations for a gallery: local overrides > cache > server fetch.
|
|
80
|
-
* Returns { animMap: Map<name, {fps, frames}>, character }
|
|
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)
|
|
81
118
|
*/
|
|
82
119
|
export async function loadAnimations(galleryId, { size = 'compact', mode } = {}) {
|
|
83
120
|
const localOverrides = readLocalOverrides(galleryId);
|
|
@@ -124,7 +161,12 @@ export async function loadAnimations(galleryId, { size = 'compact', mode } = {})
|
|
|
124
161
|
animMap.set(name, data);
|
|
125
162
|
}
|
|
126
163
|
|
|
127
|
-
|
|
164
|
+
// Load personality: local file > cached > null
|
|
165
|
+
const personality = readLocalPersonality(galleryId)
|
|
166
|
+
|| readCachedPersonality(galleryId, size)
|
|
167
|
+
|| null;
|
|
168
|
+
|
|
169
|
+
return { animMap, character, personality };
|
|
128
170
|
}
|
|
129
171
|
|
|
130
172
|
/** Delete cache for one or all characters */
|