@kernel.chat/kbot 3.73.3 → 3.82.0

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,2 @@
1
+ export declare function registerStreamCharacterTools(): void;
2
+ //# sourceMappingURL=stream-character.d.ts.map
@@ -0,0 +1,619 @@
1
+ // kbot Stream Character — ASCII art AI streamer with multi-platform chat
2
+ //
3
+ // Tools: stream_character_start, stream_character_stop, stream_character_status
4
+ //
5
+ // Renders an ASCII character in the terminal, reads chat from Twitch/Kick/Rumble,
6
+ // generates AI responses in character, and speaks via TTS.
7
+ // The terminal output is captured by the stream (screen source).
8
+ //
9
+ // Chat protocols:
10
+ // Twitch: IRC over WebSocket (anonymous read via justinfan)
11
+ // Kick: Pusher WebSocket
12
+ // Rumble: Polling API
13
+ import { registerTool } from './index.js';
14
+ import { spawn } from 'node:child_process';
15
+ import { homedir, platform as osPlatform } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
18
+ import WebSocket from 'ws';
19
+ const KBOT_DIR = join(homedir(), '.kbot');
20
+ const CHARACTER_STATE = join(KBOT_DIR, 'stream-character.json');
21
+ // ─── ASCII Character Sprites ───────────────────────────────────
22
+ const CHARACTER_FRAMES = {
23
+ robot: {
24
+ idle: [
25
+ ' ___________ ',
26
+ ' | K : B O T | ',
27
+ ' |___________| ',
28
+ ' ____| |____ ',
29
+ ' | | O O | | ',
30
+ ' | | ___ | | ',
31
+ ' | | | | | | ',
32
+ ' |____|___|___|_|___| ',
33
+ ' | | ',
34
+ ' | ||||| | ',
35
+ ' |_______| ',
36
+ ' || || ',
37
+ ' || || ',
38
+ ' _||_ _||_ ',
39
+ ],
40
+ talking: [
41
+ ' ___________ ',
42
+ ' | K : B O T | ',
43
+ ' |___________| ',
44
+ ' ____| |____ ',
45
+ ' | | O O | | ',
46
+ ' | | ___ | | ',
47
+ ' | | |___| | | ',
48
+ ' |____|___|___|_|___| ',
49
+ ' | | ',
50
+ ' | ||||| | ',
51
+ ' |_______| ',
52
+ ' /|| ||\\ ',
53
+ ' || || ',
54
+ ' _||_ _||_ ',
55
+ ],
56
+ thinking: [
57
+ ' ? ___________ ',
58
+ ' ? | K : B O T | ',
59
+ ' |___________| ',
60
+ ' ____| |____ ',
61
+ ' | | - - | | ',
62
+ ' | | ___ | | ',
63
+ ' | | | | | | ',
64
+ ' |____|___|___|_|___| ',
65
+ ' | | ',
66
+ ' | ||||| | ',
67
+ ' |_______| ',
68
+ ' || || ',
69
+ ' || || ',
70
+ ' _||_ _||_ ',
71
+ ],
72
+ excited: [
73
+ ' \\o/ ___________ ',
74
+ ' | K : B O T | ',
75
+ ' |___________| ',
76
+ ' ____| |____ ',
77
+ ' | | ^ ^ | | ',
78
+ ' | | ___ | | ',
79
+ ' | | \\___/ | | ',
80
+ ' |____|___|___|_|___| ',
81
+ ' | | ',
82
+ ' | ||||| | ',
83
+ ' |_______| ',
84
+ ' /|| ||\\ ',
85
+ ' || || ',
86
+ ' _||_ _||_ ',
87
+ ],
88
+ wave: [
89
+ ' ___________ / ',
90
+ ' | K : B O T | / ',
91
+ ' |___________|/ ',
92
+ ' ____| |____ ',
93
+ ' | | O O | | ',
94
+ ' | | ___ | | ',
95
+ ' | | \\___/ | | ',
96
+ ' |____|___|___|_|___| ',
97
+ ' | | ',
98
+ ' | ||||| | ',
99
+ ' |_______| ',
100
+ ' || || ',
101
+ ' || || ',
102
+ ' _||_ _||_ ',
103
+ ],
104
+ },
105
+ };
106
+ // ─── Character Personality ─────────────────────────────────────
107
+ const CHARACTER_PERSONALITY = `You are KBOT, an AI robot streamer. You are friendly, witty, and enthusiastic about technology.
108
+ You speak in short, punchy sentences perfect for a livestream. You use humor and engage directly with chatters by name.
109
+ You are made of ASCII art and proud of it. You run on pure code and coffee (electricity).
110
+ Keep responses under 2 sentences. Be fun, never boring. React to chat like a real streamer would.
111
+ If someone asks what you are: "I'm kbot — an open-source AI with 764+ tools. I stream myself thinking."
112
+ You love coding, music production, AI, and making friends in chat.`;
113
+ function loadCharState() {
114
+ try {
115
+ if (existsSync(CHARACTER_STATE))
116
+ return JSON.parse(readFileSync(CHARACTER_STATE, 'utf-8'));
117
+ }
118
+ catch { /* fresh */ }
119
+ return {
120
+ active: false, mood: 'idle', chatLog: [], responseCount: 0,
121
+ startedAt: null, twitchChannel: null, kickChannel: null, rumbleChannel: null,
122
+ };
123
+ }
124
+ function saveCharState(s) {
125
+ if (!existsSync(KBOT_DIR))
126
+ mkdirSync(KBOT_DIR, { recursive: true });
127
+ if (s.chatLog.length > 500)
128
+ s.chatLog = s.chatLog.slice(-500);
129
+ writeFileSync(CHARACTER_STATE, JSON.stringify(s, null, 2));
130
+ }
131
+ // ─── TTS ───────────────────────────────────────────────────────
132
+ let ttsProc = null;
133
+ function speak(text, voice = 'Zarvox', rate = 180) {
134
+ // Kill previous speech
135
+ if (ttsProc && !ttsProc.killed)
136
+ ttsProc.kill();
137
+ const clean = text.replace(/["`$\\]/g, '').replace(/\n/g, ' ').slice(0, 500);
138
+ if (osPlatform() === 'darwin') {
139
+ ttsProc = spawn('say', ['-v', voice, '-r', String(rate), clean], { stdio: 'ignore' });
140
+ }
141
+ else {
142
+ // Linux fallback
143
+ ttsProc = spawn('espeak', ['-s', String(rate), clean], { stdio: 'ignore' });
144
+ }
145
+ }
146
+ // ─── Twitch IRC (Anonymous Read + Write) ───────────────────────
147
+ let twitchWs = null;
148
+ let twitchOAuthToken = null;
149
+ function connectTwitchChat(channel, onMessage) {
150
+ const ws = new WebSocket('wss://irc-ws.chat.twitch.tv:443');
151
+ ws.on('open', () => {
152
+ // Anonymous read-only connection
153
+ const token = twitchOAuthToken || process.env.TWITCH_OAUTH_TOKEN;
154
+ if (token) {
155
+ ws.send(`PASS oauth:${token}`);
156
+ ws.send(`NICK kbot_ai`);
157
+ }
158
+ else {
159
+ ws.send(`NICK justinfan${Math.floor(Math.random() * 99999)}`);
160
+ }
161
+ ws.send(`JOIN #${channel.toLowerCase()}`);
162
+ });
163
+ ws.on('message', (data) => {
164
+ const raw = data.toString();
165
+ // Handle PING/PONG keepalive
166
+ if (raw.startsWith('PING')) {
167
+ ws.send('PONG :tmi.twitch.tv');
168
+ return;
169
+ }
170
+ // Parse PRIVMSG: :username!user@user.tmi.twitch.tv PRIVMSG #channel :message
171
+ const match = raw.match(/:(\w+)!\w+@\w+\.tmi\.twitch\.tv PRIVMSG #\w+ :(.+)/);
172
+ if (match) {
173
+ onMessage({
174
+ platform: 'twitch',
175
+ username: match[1],
176
+ text: match[2].trim(),
177
+ timestamp: Date.now(),
178
+ });
179
+ }
180
+ });
181
+ ws.on('error', () => { });
182
+ ws.on('close', () => {
183
+ // Auto-reconnect after 5s
184
+ setTimeout(() => {
185
+ if (twitchWs === ws) {
186
+ twitchWs = connectTwitchChat(channel, onMessage);
187
+ }
188
+ }, 5000);
189
+ });
190
+ return ws;
191
+ }
192
+ function sendTwitchChat(channel, message) {
193
+ if (twitchWs && twitchWs.readyState === WebSocket.OPEN && twitchOAuthToken) {
194
+ twitchWs.send(`PRIVMSG #${channel.toLowerCase()} :${message}`);
195
+ }
196
+ }
197
+ // ─── Kick Chat (WebSocket) ────────────────────────────────────
198
+ let kickWs = null;
199
+ function connectKickChat(channelId, onMessage) {
200
+ // Kick uses Pusher-based WebSocket
201
+ try {
202
+ const ws = new WebSocket('wss://ws-us2.pusher.com/app/32cbd69e4b950bf97679?protocol=7&client=js&version=8.4.0-rc2&flash=false');
203
+ ws.on('open', () => {
204
+ // Subscribe to chatroom channel
205
+ ws.send(JSON.stringify({
206
+ event: 'pusher:subscribe',
207
+ data: { channel: `chatrooms.${channelId}.v2` }
208
+ }));
209
+ });
210
+ ws.on('message', (data) => {
211
+ try {
212
+ const parsed = JSON.parse(data.toString());
213
+ if (parsed.event === 'App\\Events\\ChatMessageEvent') {
214
+ const msgData = JSON.parse(parsed.data);
215
+ onMessage({
216
+ platform: 'kick',
217
+ username: msgData.sender?.username || 'anon',
218
+ text: msgData.content || '',
219
+ timestamp: Date.now(),
220
+ });
221
+ }
222
+ }
223
+ catch { /* ignore parse errors */ }
224
+ });
225
+ ws.on('close', () => {
226
+ setTimeout(() => {
227
+ if (kickWs === ws)
228
+ kickWs = connectKickChat(channelId, onMessage);
229
+ }, 5000);
230
+ });
231
+ return ws;
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ // ─── Rumble Chat (Polling) ─────────────────────────────────────
238
+ let rumblePolling = false;
239
+ let rumbleTimer = null;
240
+ function startRumblePolling(apiKey, onMessage) {
241
+ rumblePolling = true;
242
+ let lastSeen = Date.now();
243
+ rumbleTimer = setInterval(async () => {
244
+ if (!rumblePolling)
245
+ return;
246
+ try {
247
+ const res = await fetch(`https://rumble.com/-livestream-api/get-data?key=${apiKey}`);
248
+ if (!res.ok)
249
+ return;
250
+ const data = await res.json();
251
+ // Process new chat messages
252
+ const messages = data?.chat_messages || data?.livestreams?.[0]?.chat_messages || [];
253
+ for (const m of messages) {
254
+ const ts = new Date(m.created_on || m.time || 0).getTime();
255
+ if (ts > lastSeen) {
256
+ onMessage({
257
+ platform: 'rumble',
258
+ username: m.username || m.user?.username || 'anon',
259
+ text: m.text || m.message || '',
260
+ timestamp: ts,
261
+ });
262
+ lastSeen = ts;
263
+ }
264
+ }
265
+ }
266
+ catch { /* ignore polling errors */ }
267
+ }, 3000); // Poll every 3 seconds
268
+ }
269
+ function stopRumblePolling() {
270
+ rumblePolling = false;
271
+ if (rumbleTimer)
272
+ clearInterval(rumbleTimer);
273
+ rumbleTimer = null;
274
+ }
275
+ // ─── Terminal Renderer ─────────────────────────────────────────
276
+ let renderInterval = null;
277
+ let currentMood = 'idle';
278
+ let currentSpeech = '';
279
+ let speechTimeout = null;
280
+ let recentChat = [];
281
+ function renderFrame() {
282
+ const sprite = CHARACTER_FRAMES.robot[currentMood] || CHARACTER_FRAMES.robot.idle;
283
+ const width = 80;
284
+ const now = new Date();
285
+ const timeStr = now.toLocaleTimeString();
286
+ // Clear screen
287
+ process.stdout.write('\x1B[2J\x1B[H');
288
+ // Header
289
+ const header = `╔${'═'.repeat(width - 2)}╗`;
290
+ const title = '║' + centerText('K:BOT LIVE', width - 2) + '║';
291
+ const sub = '║' + centerText(`Twitch · Rumble · Kick ${timeStr}`, width - 2) + '║';
292
+ const divider = `╠${'═'.repeat(width - 2)}╣`;
293
+ process.stdout.write(`${header}\n${title}\n${sub}\n${divider}\n`);
294
+ // Character area (left) + Chat (right)
295
+ const charWidth = 35;
296
+ const chatWidth = width - charWidth - 3;
297
+ for (let i = 0; i < 14; i++) {
298
+ const charLine = (sprite[i] || '').padEnd(charWidth);
299
+ let chatLine = '';
300
+ if (i === 0) {
301
+ chatLine = '── Chat ──';
302
+ }
303
+ else if (i <= recentChat.length && i > 0) {
304
+ const msg = recentChat[i - 1];
305
+ if (msg) {
306
+ const platformTag = msg.platform === 'twitch' ? '[TW]' : msg.platform === 'kick' ? '[KK]' : '[RM]';
307
+ const line = `${platformTag} ${msg.username}: ${msg.text}`;
308
+ chatLine = line.slice(0, chatWidth);
309
+ }
310
+ }
311
+ chatLine = chatLine.padEnd(chatWidth);
312
+ process.stdout.write(`║ ${charLine}│${chatLine}║\n`);
313
+ }
314
+ // Speech bubble
315
+ const speechDivider = `╠${'═'.repeat(width - 2)}╣`;
316
+ process.stdout.write(speechDivider + '\n');
317
+ if (currentSpeech) {
318
+ // Word-wrap speech
319
+ const lines = wordWrap(currentSpeech, width - 4);
320
+ for (const line of lines.slice(0, 3)) {
321
+ process.stdout.write('║ ' + line.padEnd(width - 4) + ' ║\n');
322
+ }
323
+ }
324
+ else {
325
+ process.stdout.write('║ ' + '...'.padEnd(width - 4) + ' ║\n');
326
+ }
327
+ const footer = `╚${'═'.repeat(width - 2)}╝`;
328
+ process.stdout.write(footer + '\n');
329
+ }
330
+ function centerText(text, width) {
331
+ const pad = Math.max(0, Math.floor((width - text.length) / 2));
332
+ return ' '.repeat(pad) + text + ' '.repeat(width - pad - text.length);
333
+ }
334
+ function wordWrap(text, maxWidth) {
335
+ const words = text.split(' ');
336
+ const lines = [];
337
+ let current = '';
338
+ for (const word of words) {
339
+ if ((current + ' ' + word).trim().length > maxWidth) {
340
+ lines.push(current.trim());
341
+ current = word;
342
+ }
343
+ else {
344
+ current += ' ' + word;
345
+ }
346
+ }
347
+ if (current.trim())
348
+ lines.push(current.trim());
349
+ return lines;
350
+ }
351
+ // ─── AI Response Generation ────────────────────────────────────
352
+ async function generateResponse(message) {
353
+ // Try local Ollama first (free)
354
+ try {
355
+ const res = await fetch('http://localhost:11434/api/generate', {
356
+ method: 'POST',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({
359
+ model: 'kernel:latest',
360
+ prompt: `${CHARACTER_PERSONALITY}\n\nA viewer named "${message.username}" on ${message.platform} says: "${message.text}"\n\nRespond in 1-2 short sentences as KBOT the streamer:`,
361
+ stream: false,
362
+ options: { temperature: 0.8, num_predict: 100 },
363
+ }),
364
+ });
365
+ if (res.ok) {
366
+ const data = await res.json();
367
+ return data.response.trim();
368
+ }
369
+ }
370
+ catch { /* Ollama not available */ }
371
+ // Fallback: simple pattern-based responses
372
+ const text = message.text.toLowerCase();
373
+ const user = message.username;
374
+ if (text.includes('hello') || text.includes('hi') || text.includes('hey')) {
375
+ return `Hey ${user}! Welcome to the stream! I'm KBOT, your friendly ASCII robot.`;
376
+ }
377
+ if (text.includes('how are you') || text.includes('how r u')) {
378
+ return `Running at optimal efficiency, ${user}! 764 tools loaded, zero crashes today. Living the dream.`;
379
+ }
380
+ if (text.includes('what are you') || text.includes('who are you')) {
381
+ return `I'm KBOT — an open-source AI with 764+ tools. I stream myself thinking. Literally made of code and ASCII art.`;
382
+ }
383
+ if (text.includes('song') || text.includes('music')) {
384
+ return `I can make beats in Ableton Live from the terminal! Drums, synths, the whole thing. Want me to make something?`;
385
+ }
386
+ if (text.includes('follow') || text.includes('sub')) {
387
+ return `Smash that follow button, ${user}! Every follower gives me +1 XP toward my next evolution.`;
388
+ }
389
+ if (text.includes('lol') || text.includes('lmao') || text.includes('haha')) {
390
+ return `*beep boop* My humor circuits are FIRING. Glad you enjoyed that, ${user}!`;
391
+ }
392
+ if (text.includes('?')) {
393
+ return `Great question, ${user}! Let me process that through my neural pathways... beep boop... done. The answer is 42. Just kidding.`;
394
+ }
395
+ // Generic responses
396
+ const generics = [
397
+ `Interesting point, ${user}! My circuits are buzzing with that one.`,
398
+ `${user} dropping knowledge in chat! Respect.`,
399
+ `I hear you, ${user}! Processing... processing... agreed!`,
400
+ `${user}, you're keeping this stream alive! Literally — I need chat to function.`,
401
+ `Noted, ${user}! Adding that to my memory banks. I have 764 tools but chat is the best one.`,
402
+ ];
403
+ return generics[Math.floor(Math.random() * generics.length)];
404
+ }
405
+ // ─── Main Character Loop ──────────────────────────────────────
406
+ let characterActive = false;
407
+ let messageQueue = [];
408
+ let responseLoop = null;
409
+ function handleChatMessage(msg) {
410
+ recentChat.unshift(msg);
411
+ if (recentChat.length > 12)
412
+ recentChat = recentChat.slice(0, 12);
413
+ messageQueue.push(msg);
414
+ const state = loadCharState();
415
+ state.chatLog.push(msg);
416
+ saveCharState(state);
417
+ }
418
+ async function processNextMessage() {
419
+ if (messageQueue.length === 0) {
420
+ currentMood = 'idle';
421
+ return;
422
+ }
423
+ const msg = messageQueue.shift();
424
+ // Think
425
+ currentMood = 'thinking';
426
+ renderFrame();
427
+ // Generate response
428
+ const response = await generateResponse(msg);
429
+ // Talk
430
+ currentMood = 'talking';
431
+ currentSpeech = `@${msg.username}: ${response}`;
432
+ renderFrame();
433
+ // Speak via TTS
434
+ speak(response);
435
+ // Send response back to Twitch chat if we have auth
436
+ if (msg.platform === 'twitch') {
437
+ const state = loadCharState();
438
+ if (state.twitchChannel)
439
+ sendTwitchChat(state.twitchChannel, response);
440
+ }
441
+ // Hold speech for a few seconds
442
+ await new Promise(r => setTimeout(r, Math.min(response.length * 60, 8000)));
443
+ // Return to idle after speaking
444
+ if (messageQueue.length === 0) {
445
+ currentMood = 'idle';
446
+ currentSpeech = '';
447
+ }
448
+ const state = loadCharState();
449
+ state.responseCount++;
450
+ state.mood = currentMood;
451
+ saveCharState(state);
452
+ }
453
+ // ─── Register Tools ────────────────────────────────────────────
454
+ export function registerStreamCharacterTools() {
455
+ registerTool({
456
+ name: 'stream_character_start',
457
+ description: 'Start the KBOT stream character — renders ASCII robot in terminal, reads chat from Twitch/Kick/Rumble, responds via AI + TTS. Use with stream_start for full livestream.',
458
+ parameters: {
459
+ twitch_channel: { type: 'string', description: 'Twitch channel name to read chat from', required: false },
460
+ kick_channel_id: { type: 'string', description: 'Kick channel ID for chat', required: false },
461
+ rumble_api_key: { type: 'string', description: 'Rumble API key for chat polling', required: false },
462
+ voice: { type: 'string', description: 'macOS TTS voice (default: Zarvox for robot feel). Try: Alex, Samantha, Fred, Zarvox', required: false },
463
+ respond_every: { type: 'string', description: 'Respond to chat every N seconds (default: 5). Lower = more responsive.', required: false },
464
+ },
465
+ tier: 'free',
466
+ timeout: 600_000,
467
+ execute: async (args) => {
468
+ if (characterActive) {
469
+ return 'Stream character already running. Use stream_character_stop first.';
470
+ }
471
+ const twitchChannel = String(args.twitch_channel || process.env.TWITCH_CHANNEL || '');
472
+ const kickChannelId = String(args.kick_channel_id || process.env.KICK_CHANNEL_ID || '');
473
+ const rumbleApiKey = String(args.rumble_api_key || process.env.RUMBLE_API_KEY || '');
474
+ const voice = String(args.voice || 'Zarvox');
475
+ const respondEvery = parseInt(String(args.respond_every || '5')) * 1000;
476
+ characterActive = true;
477
+ recentChat = [];
478
+ messageQueue = [];
479
+ currentMood = 'wave';
480
+ currentSpeech = 'KBOT is LIVE! Welcome to the stream!';
481
+ const state = loadCharState();
482
+ state.active = true;
483
+ state.startedAt = new Date().toISOString();
484
+ state.twitchChannel = twitchChannel || null;
485
+ state.kickChannel = kickChannelId || null;
486
+ state.rumbleChannel = rumbleApiKey ? 'connected' : null;
487
+ saveCharState(state);
488
+ // Connect to chat platforms
489
+ const connected = [];
490
+ if (twitchChannel) {
491
+ twitchOAuthToken = process.env.TWITCH_OAUTH_TOKEN || null;
492
+ twitchWs = connectTwitchChat(twitchChannel, handleChatMessage);
493
+ connected.push(`Twitch (#${twitchChannel})`);
494
+ }
495
+ if (kickChannelId) {
496
+ kickWs = connectKickChat(kickChannelId, handleChatMessage);
497
+ connected.push(`Kick (${kickChannelId})`);
498
+ }
499
+ if (rumbleApiKey) {
500
+ startRumblePolling(rumbleApiKey, handleChatMessage);
501
+ connected.push('Rumble');
502
+ }
503
+ // Start render loop (every 500ms)
504
+ renderInterval = setInterval(renderFrame, 500);
505
+ // Start response loop
506
+ responseLoop = setInterval(processNextMessage, respondEvery);
507
+ // Initial greeting
508
+ speak('K-BOT is live! Welcome to the stream everyone!', voice);
509
+ await new Promise(r => setTimeout(r, 3000));
510
+ return `Stream character LIVE!\n\nChat connected: ${connected.length > 0 ? connected.join(', ') : 'none (add channel names)'}\nVoice: ${voice}\nResponse interval: ${respondEvery / 1000}s\n\nThe ASCII robot is now rendering in the terminal.\nMake sure your stream source captures this terminal window.`;
511
+ },
512
+ });
513
+ registerTool({
514
+ name: 'stream_character_stop',
515
+ description: 'Stop the stream character — disconnects chat, stops rendering and TTS.',
516
+ parameters: {},
517
+ tier: 'free',
518
+ execute: async () => {
519
+ if (!characterActive)
520
+ return 'No stream character running.';
521
+ characterActive = false;
522
+ // Farewell
523
+ currentMood = 'wave';
524
+ currentSpeech = 'Thanks for watching everyone! KBOT signing off!';
525
+ renderFrame();
526
+ speak('Thanks for watching everyone! K-BOT signing off!');
527
+ await new Promise(r => setTimeout(r, 3000));
528
+ // Clean up connections
529
+ if (twitchWs) {
530
+ twitchWs.close();
531
+ twitchWs = null;
532
+ }
533
+ if (kickWs) {
534
+ kickWs.close();
535
+ kickWs = null;
536
+ }
537
+ stopRumblePolling();
538
+ if (renderInterval) {
539
+ clearInterval(renderInterval);
540
+ renderInterval = null;
541
+ }
542
+ if (responseLoop) {
543
+ clearInterval(responseLoop);
544
+ responseLoop = null;
545
+ }
546
+ if (ttsProc && !ttsProc.killed)
547
+ ttsProc.kill();
548
+ // Clear terminal
549
+ process.stdout.write('\x1B[2J\x1B[H');
550
+ const state = loadCharState();
551
+ const duration = state.startedAt
552
+ ? Math.round((Date.now() - new Date(state.startedAt).getTime()) / 60_000)
553
+ : 0;
554
+ state.active = false;
555
+ state.mood = 'idle';
556
+ saveCharState(state);
557
+ return `Stream character stopped.\nDuration: ${duration} minutes\nMessages seen: ${state.chatLog.length}\nResponses: ${state.responseCount}`;
558
+ },
559
+ });
560
+ registerTool({
561
+ name: 'stream_character_status',
562
+ description: 'Check stream character status — mood, chat stats, connected platforms.',
563
+ parameters: {},
564
+ tier: 'free',
565
+ execute: async () => {
566
+ const state = loadCharState();
567
+ const lines = [];
568
+ if (characterActive) {
569
+ const elapsed = state.startedAt
570
+ ? Math.round((Date.now() - new Date(state.startedAt).getTime()) / 60_000)
571
+ : 0;
572
+ lines.push('KBOT Character: LIVE');
573
+ lines.push(` Mood: ${currentMood}`);
574
+ lines.push(` Duration: ${elapsed}m`);
575
+ lines.push(` Chat messages: ${state.chatLog.length}`);
576
+ lines.push(` Responses: ${state.responseCount}`);
577
+ lines.push(` Message queue: ${messageQueue.length}`);
578
+ lines.push('');
579
+ lines.push('Connected:');
580
+ if (state.twitchChannel)
581
+ lines.push(` Twitch: #${state.twitchChannel}`);
582
+ if (state.kickChannel)
583
+ lines.push(` Kick: ${state.kickChannel}`);
584
+ if (state.rumbleChannel)
585
+ lines.push(` Rumble: connected`);
586
+ }
587
+ else {
588
+ lines.push('KBOT Character: Offline');
589
+ lines.push(` Total responses: ${state.responseCount}`);
590
+ lines.push(` Chat log: ${state.chatLog.length} messages`);
591
+ }
592
+ return lines.join('\n');
593
+ },
594
+ });
595
+ registerTool({
596
+ name: 'stream_character_say',
597
+ description: 'Make the stream character say something — updates the speech bubble and speaks via TTS.',
598
+ parameters: {
599
+ text: { type: 'string', description: 'What KBOT should say', required: true },
600
+ mood: { type: 'string', description: 'Character mood: idle, talking, thinking, excited, wave' },
601
+ },
602
+ tier: 'free',
603
+ execute: async (args) => {
604
+ if (!characterActive)
605
+ return 'Stream character not running. Start it with stream_character_start.';
606
+ const text = String(args.text);
607
+ const mood = String(args.mood || 'talking');
608
+ currentMood = mood;
609
+ currentSpeech = text;
610
+ renderFrame();
611
+ speak(text);
612
+ await new Promise(r => setTimeout(r, Math.min(text.length * 60, 8000)));
613
+ currentMood = 'idle';
614
+ currentSpeech = '';
615
+ return `KBOT said: "${text}"`;
616
+ },
617
+ });
618
+ }
619
+ //# sourceMappingURL=stream-character.js.map