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/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 —
|
|
78
|
+
storymode-cli — AI-animated pixel art companions for your terminal
|
|
11
79
|
|
|
12
80
|
Usage:
|
|
13
|
-
storymode play
|
|
14
|
-
storymode
|
|
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
|
-
--
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
--
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
177
|
+
// Auto-install hooks
|
|
111
178
|
if (reactive) {
|
|
112
179
|
installHooks({ global: true });
|
|
113
180
|
}
|
|
114
181
|
|
|
115
|
-
//
|
|
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
|
|
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
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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; };
|