@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 registerStreamingTools(): void;
2
+ //# sourceMappingURL=streaming.d.ts.map
@@ -0,0 +1,491 @@
1
+ // kbot Streaming Tools — Multi-platform livestreaming to Twitch, Rumble, Kick
2
+ //
3
+ // Tools: stream_start, stream_stop, stream_status, stream_setup
4
+ //
5
+ // kbot captures screen/window/webcam via ffmpeg and simultaneously
6
+ // streams to multiple RTMP endpoints. Supports Twitch, Rumble, and Kick.
7
+ //
8
+ // Env: TWITCH_STREAM_KEY, RUMBLE_STREAM_KEY, KICK_STREAM_KEY
9
+ // Or configure via: kbot stream_setup
10
+ import { registerTool } from './index.js';
11
+ import { execFile, spawn } from 'node:child_process';
12
+ import { homedir, platform } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
15
+ const KBOT_DIR = join(homedir(), '.kbot');
16
+ const STREAM_STATE = join(KBOT_DIR, 'stream-state.json');
17
+ const STREAM_PID = join(KBOT_DIR, 'stream.pid');
18
+ // ─── RTMP Ingest Endpoints ────────────────────────────────────
19
+ const RTMP_ENDPOINTS = {
20
+ twitch: 'rtmp://live.twitch.tv/app',
21
+ rumble: 'rtmp://rtmp.rumble.com/live',
22
+ kick: 'rtmps://fa723fc1b171.global-contribute.live-video.net/app',
23
+ };
24
+ function loadState() {
25
+ try {
26
+ if (existsSync(STREAM_STATE))
27
+ return JSON.parse(readFileSync(STREAM_STATE, 'utf-8'));
28
+ }
29
+ catch { /* fresh state */ }
30
+ return {
31
+ active: false, platforms: [], startedAt: null,
32
+ source: 'screen', resolution: '1920x1080', bitrate: 4500,
33
+ pid: null, history: [],
34
+ };
35
+ }
36
+ function saveState(state) {
37
+ if (!existsSync(KBOT_DIR))
38
+ mkdirSync(KBOT_DIR, { recursive: true });
39
+ if (state.history.length > 100)
40
+ state.history = state.history.slice(-100);
41
+ writeFileSync(STREAM_STATE, JSON.stringify(state, null, 2));
42
+ }
43
+ // ─── Stream Key Resolution ─────────────────────────────────────
44
+ function getStreamKey(platform) {
45
+ const envMap = {
46
+ twitch: 'TWITCH_STREAM_KEY',
47
+ rumble: 'RUMBLE_STREAM_KEY',
48
+ kick: 'KICK_STREAM_KEY',
49
+ };
50
+ const envVar = envMap[platform];
51
+ if (!envVar)
52
+ return null;
53
+ return process.env[envVar] || null;
54
+ }
55
+ function getConfiguredPlatforms() {
56
+ return Object.keys(RTMP_ENDPOINTS).filter(p => getStreamKey(p));
57
+ }
58
+ // ─── FFmpeg Check ──────────────────────────────────────────────
59
+ function checkFfmpeg() {
60
+ return new Promise(resolve => {
61
+ execFile('ffmpeg', ['-version'], { timeout: 5000 }, (err) => {
62
+ resolve(!err);
63
+ });
64
+ });
65
+ }
66
+ // ─── Screen Capture Input (platform-specific) ──────────────────
67
+ function getInputArgs(source) {
68
+ const os = platform();
69
+ if (source === 'screen') {
70
+ if (os === 'darwin') {
71
+ // macOS: AVFoundation screen capture (device 1 = screen, 0 = webcam typically)
72
+ return ['-f', 'avfoundation', '-framerate', '30', '-i', '1:0'];
73
+ }
74
+ else if (os === 'linux') {
75
+ return ['-f', 'x11grab', '-framerate', '30', '-video_size', '1920x1080', '-i', ':0.0'];
76
+ }
77
+ else {
78
+ // Windows
79
+ return ['-f', 'gdigrab', '-framerate', '30', '-i', 'desktop'];
80
+ }
81
+ }
82
+ if (source === 'webcam') {
83
+ if (os === 'darwin') {
84
+ return ['-f', 'avfoundation', '-framerate', '30', '-i', '0:0'];
85
+ }
86
+ else if (os === 'linux') {
87
+ return ['-f', 'v4l2', '-framerate', '30', '-i', '/dev/video0'];
88
+ }
89
+ else {
90
+ return ['-f', 'dshow', '-framerate', '30', '-i', 'video=Integrated Camera'];
91
+ }
92
+ }
93
+ // File input (e.g., a video file or test pattern)
94
+ if (source === 'test') {
95
+ return [
96
+ '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30',
97
+ '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=44100',
98
+ ];
99
+ }
100
+ // Assume it's a file path
101
+ return ['-re', '-i', source];
102
+ }
103
+ // ─── Build FFmpeg Command ──────────────────────────────────────
104
+ function buildFfmpegArgs(opts) {
105
+ const { platforms, source, resolution, bitrate } = opts;
106
+ const inputArgs = getInputArgs(source);
107
+ // Video encoding settings
108
+ const videoArgs = [
109
+ '-c:v', 'libx264',
110
+ '-preset', 'veryfast',
111
+ '-b:v', `${bitrate}k`,
112
+ '-maxrate', `${bitrate}k`,
113
+ '-bufsize', `${bitrate * 2}k`,
114
+ '-s', resolution,
115
+ '-g', '60', // keyframe every 2s at 30fps
116
+ '-keyint_min', '60',
117
+ '-pix_fmt', 'yuv420p',
118
+ ];
119
+ // Audio encoding
120
+ const audioArgs = [
121
+ '-c:a', 'aac',
122
+ '-b:a', '128k',
123
+ '-ar', '44100',
124
+ ];
125
+ // For multi-platform: use tee muxer to send to all RTMP endpoints
126
+ if (platforms.length === 1) {
127
+ const key = getStreamKey(platforms[0]);
128
+ const endpoint = RTMP_ENDPOINTS[platforms[0]];
129
+ return [
130
+ ...inputArgs,
131
+ ...videoArgs,
132
+ ...audioArgs,
133
+ '-f', 'flv',
134
+ `${endpoint}/${key}`,
135
+ ];
136
+ }
137
+ // Multiple platforms: use tee muxer
138
+ const teeTargets = platforms.map(p => {
139
+ const key = getStreamKey(p);
140
+ const endpoint = RTMP_ENDPOINTS[p];
141
+ return `[f=flv]${endpoint}/${key}`;
142
+ }).join('|');
143
+ return [
144
+ ...inputArgs,
145
+ ...videoArgs,
146
+ ...audioArgs,
147
+ '-f', 'tee',
148
+ '-map', '0:v',
149
+ '-map', source === 'test' ? '1:a' : '0:a',
150
+ teeTargets,
151
+ ];
152
+ }
153
+ // ─── Active Process Tracking ───────────────────────────────────
154
+ let activeProcess = null;
155
+ function isStreamRunning() {
156
+ if (activeProcess && !activeProcess.killed)
157
+ return true;
158
+ // Check PID file for streams started in a different session
159
+ try {
160
+ if (existsSync(STREAM_PID)) {
161
+ const pid = parseInt(readFileSync(STREAM_PID, 'utf-8').trim());
162
+ process.kill(pid, 0); // signal 0 = check if process exists
163
+ return true;
164
+ }
165
+ }
166
+ catch {
167
+ // Process doesn't exist, clean up stale PID
168
+ try {
169
+ writeFileSync(STREAM_PID, '');
170
+ }
171
+ catch { /* ignore */ }
172
+ }
173
+ return false;
174
+ }
175
+ // ─── Register Tools ────────────────────────────────────────────
176
+ export function registerStreamingTools() {
177
+ registerTool({
178
+ name: 'stream_start',
179
+ description: 'Start livestreaming to one or more platforms simultaneously. Supports Twitch, Rumble, and Kick. Uses ffmpeg to capture screen/webcam and stream via RTMP.',
180
+ parameters: {
181
+ platforms: { type: 'string', description: 'Comma-separated platforms: "twitch,rumble,kick" or "all". Default: all configured', required: false },
182
+ source: { type: 'string', description: 'Video source: "screen" (default), "webcam", "test" (test pattern), or a file path' },
183
+ resolution: { type: 'string', description: 'Output resolution: "1920x1080" (default), "1280x720", "2560x1440"' },
184
+ bitrate: { type: 'string', description: 'Video bitrate in kbps. Default: 4500. Twitch max: 6000' },
185
+ title: { type: 'string', description: 'Stream title (for logging/state tracking)' },
186
+ },
187
+ tier: 'free',
188
+ timeout: 600_000, // 10 min timeout for the start command itself
189
+ execute: async (args) => {
190
+ // Pre-flight checks
191
+ const hasFfmpeg = await checkFfmpeg();
192
+ if (!hasFfmpeg) {
193
+ return 'Error: ffmpeg not found. Install it:\n macOS: brew install ffmpeg\n Linux: sudo apt install ffmpeg\n Windows: choco install ffmpeg';
194
+ }
195
+ if (isStreamRunning()) {
196
+ return 'Error: A stream is already active. Run stream_stop first.';
197
+ }
198
+ // Resolve platforms
199
+ const configured = getConfiguredPlatforms();
200
+ if (configured.length === 0) {
201
+ return 'Error: No stream keys configured. Set environment variables:\n TWITCH_STREAM_KEY=your_key\n RUMBLE_STREAM_KEY=your_key\n KICK_STREAM_KEY=your_key\n\nOr run stream_setup to configure interactively.';
202
+ }
203
+ let platforms;
204
+ if (args.platforms) {
205
+ const requested = String(args.platforms).toLowerCase();
206
+ if (requested === 'all') {
207
+ platforms = configured;
208
+ }
209
+ else {
210
+ platforms = requested.split(',').map(p => p.trim());
211
+ // Validate
212
+ const missing = platforms.filter(p => !getStreamKey(p));
213
+ if (missing.length > 0) {
214
+ return `Error: Missing stream keys for: ${missing.join(', ')}\nConfigured platforms: ${configured.join(', ')}`;
215
+ }
216
+ const unknown = platforms.filter(p => !RTMP_ENDPOINTS[p]);
217
+ if (unknown.length > 0) {
218
+ return `Error: Unknown platforms: ${unknown.join(', ')}\nSupported: twitch, rumble, kick`;
219
+ }
220
+ }
221
+ }
222
+ else {
223
+ platforms = configured;
224
+ }
225
+ const source = String(args.source || 'screen');
226
+ const resolution = String(args.resolution || '1920x1080');
227
+ const bitrate = parseInt(String(args.bitrate || '4500'));
228
+ // Build ffmpeg command
229
+ const ffmpegArgs = buildFfmpegArgs({ platforms, source, resolution, bitrate });
230
+ // Spawn ffmpeg in background
231
+ const proc = spawn('ffmpeg', ffmpegArgs, {
232
+ stdio: ['ignore', 'pipe', 'pipe'],
233
+ detached: true,
234
+ });
235
+ activeProcess = proc;
236
+ const pid = proc.pid;
237
+ // Save PID for cross-session tracking
238
+ writeFileSync(STREAM_PID, String(pid));
239
+ // Collect stderr for initial connection status
240
+ let stderrBuffer = '';
241
+ proc.stderr?.on('data', (chunk) => {
242
+ stderrBuffer += chunk.toString();
243
+ });
244
+ // Wait a moment to check if ffmpeg started successfully
245
+ await new Promise(resolve => setTimeout(resolve, 3000));
246
+ if (proc.exitCode !== null) {
247
+ // Process already exited — something went wrong
248
+ const error = stderrBuffer.slice(-500);
249
+ return `Error: ffmpeg exited immediately.\n\n${error}\n\nCheck your stream keys and source device.`;
250
+ }
251
+ // Detach so kbot can continue
252
+ proc.unref();
253
+ // Update state
254
+ const state = loadState();
255
+ state.active = true;
256
+ state.platforms = platforms;
257
+ state.startedAt = new Date().toISOString();
258
+ state.source = source;
259
+ state.resolution = resolution;
260
+ state.bitrate = bitrate;
261
+ state.pid = pid;
262
+ saveState(state);
263
+ const platformList = platforms.map(p => {
264
+ const urls = {
265
+ twitch: 'https://twitch.tv/your-channel',
266
+ rumble: 'https://rumble.com/your-channel',
267
+ kick: 'https://kick.com/your-channel',
268
+ };
269
+ return ` - ${p.charAt(0).toUpperCase() + p.slice(1)}: ${urls[p] || p}`;
270
+ }).join('\n');
271
+ return `Stream started!\n\nPlatforms:\n${platformList}\n\nSettings:\n Source: ${source}\n Resolution: ${resolution}\n Bitrate: ${bitrate}kbps\n PID: ${pid}\n\nUse stream_status to check health, stream_stop to end.`;
272
+ },
273
+ });
274
+ registerTool({
275
+ name: 'stream_stop',
276
+ description: 'Stop the active livestream. Gracefully terminates ffmpeg and records the session.',
277
+ parameters: {},
278
+ tier: 'free',
279
+ execute: async () => {
280
+ const state = loadState();
281
+ if (!state.active && !isStreamRunning()) {
282
+ return 'No active stream to stop.';
283
+ }
284
+ let stopped = false;
285
+ // Try in-process reference first
286
+ if (activeProcess && !activeProcess.killed) {
287
+ activeProcess.kill('SIGINT'); // graceful shutdown
288
+ activeProcess = null;
289
+ stopped = true;
290
+ }
291
+ // Try PID file
292
+ if (!stopped && state.pid) {
293
+ try {
294
+ process.kill(state.pid, 'SIGINT');
295
+ stopped = true;
296
+ }
297
+ catch {
298
+ // Process already dead
299
+ }
300
+ }
301
+ // Calculate duration
302
+ let durationMinutes = 0;
303
+ if (state.startedAt) {
304
+ durationMinutes = Math.round((Date.now() - new Date(state.startedAt).getTime()) / 60_000);
305
+ }
306
+ // Record in history
307
+ state.history.push({
308
+ date: state.startedAt || new Date().toISOString(),
309
+ platforms: state.platforms,
310
+ duration_minutes: durationMinutes,
311
+ source: state.source,
312
+ });
313
+ // Reset active state
314
+ state.active = false;
315
+ state.pid = null;
316
+ state.startedAt = null;
317
+ state.platforms = [];
318
+ saveState(state);
319
+ // Clean PID file
320
+ try {
321
+ writeFileSync(STREAM_PID, '');
322
+ }
323
+ catch { /* ignore */ }
324
+ return `Stream stopped after ${durationMinutes} minutes.\n\nSession recorded in history. Run stream_status to see past streams.`;
325
+ },
326
+ });
327
+ registerTool({
328
+ name: 'stream_status',
329
+ description: 'Check livestream status — active stream info, configured platforms, and stream history.',
330
+ parameters: {},
331
+ tier: 'free',
332
+ execute: async () => {
333
+ const state = loadState();
334
+ const configured = getConfiguredPlatforms();
335
+ const running = isStreamRunning();
336
+ const lines = [];
337
+ // Active stream
338
+ if (running && state.active) {
339
+ const elapsed = state.startedAt
340
+ ? Math.round((Date.now() - new Date(state.startedAt).getTime()) / 60_000)
341
+ : 0;
342
+ lines.push('🔴 LIVE');
343
+ lines.push(` Platforms: ${state.platforms.join(', ')}`);
344
+ lines.push(` Duration: ${elapsed} minutes`);
345
+ lines.push(` Source: ${state.source}`);
346
+ lines.push(` Resolution: ${state.resolution}`);
347
+ lines.push(` Bitrate: ${state.bitrate}kbps`);
348
+ lines.push(` PID: ${state.pid}`);
349
+ }
350
+ else {
351
+ lines.push('⚫ Offline');
352
+ // Clean up stale state
353
+ if (state.active) {
354
+ state.active = false;
355
+ state.pid = null;
356
+ saveState(state);
357
+ }
358
+ }
359
+ lines.push('');
360
+ lines.push('Configured platforms:');
361
+ for (const p of Object.keys(RTMP_ENDPOINTS)) {
362
+ const has = configured.includes(p);
363
+ lines.push(` ${has ? '✓' : '✗'} ${p.charAt(0).toUpperCase() + p.slice(1)}`);
364
+ }
365
+ // History
366
+ if (state.history.length > 0) {
367
+ lines.push('');
368
+ lines.push(`Stream history (${state.history.length} sessions):`);
369
+ for (const h of state.history.slice(-5).reverse()) {
370
+ lines.push(` ${h.date.split('T')[0]} — ${h.platforms.join(', ')} — ${h.duration_minutes}m — ${h.source}`);
371
+ }
372
+ }
373
+ return lines.join('\n');
374
+ },
375
+ });
376
+ registerTool({
377
+ name: 'stream_setup',
378
+ description: 'Show setup instructions for configuring stream keys for Twitch, Rumble, and Kick.',
379
+ parameters: {
380
+ platform: { type: 'string', description: 'Platform to show setup for: "twitch", "rumble", "kick", or "all" (default)' },
381
+ },
382
+ tier: 'free',
383
+ execute: async (args) => {
384
+ const p = String(args.platform || 'all').toLowerCase();
385
+ const lines = ['Stream Setup — Multi-Platform Livestreaming', ''];
386
+ const instructions = {
387
+ twitch: `Twitch:
388
+ 1. Go to https://dashboard.twitch.tv/settings → Stream
389
+ 2. Copy your Primary Stream Key
390
+ 3. Set: export TWITCH_STREAM_KEY="your_key"
391
+ 4. Or add to ~/.kbot/.env: TWITCH_STREAM_KEY=your_key
392
+
393
+ RTMP endpoint: ${RTMP_ENDPOINTS.twitch}
394
+ Max bitrate: 6000kbps
395
+ Recommended: 1080p @ 4500kbps or 720p @ 2500kbps`,
396
+ rumble: `Rumble:
397
+ 1. Go to https://rumble.com/account/live-stream
398
+ 2. Create a stream → copy the Stream Key
399
+ 3. Set: export RUMBLE_STREAM_KEY="your_key"
400
+ 4. Or add to ~/.kbot/.env: RUMBLE_STREAM_KEY=your_key
401
+
402
+ RTMP endpoint: ${RTMP_ENDPOINTS.rumble}
403
+ Recommended: 1080p @ 4500kbps`,
404
+ kick: `Kick:
405
+ 1. Go to https://kick.com/dashboard/settings/stream
406
+ 2. Copy your Stream Key
407
+ 3. Set: export KICK_STREAM_KEY="your_key"
408
+ 4. Or add to ~/.kbot/.env: KICK_STREAM_KEY=your_key
409
+
410
+ RTMP endpoint: ${RTMP_ENDPOINTS.kick}
411
+ Recommended: 1080p @ 4500kbps`,
412
+ };
413
+ if (p === 'all') {
414
+ for (const [name, inst] of Object.entries(instructions)) {
415
+ lines.push(inst);
416
+ lines.push('');
417
+ }
418
+ }
419
+ else if (instructions[p]) {
420
+ lines.push(instructions[p]);
421
+ }
422
+ else {
423
+ return `Unknown platform: ${p}. Supported: twitch, rumble, kick`;
424
+ }
425
+ lines.push('Prerequisites:');
426
+ lines.push(' - ffmpeg installed (brew install ffmpeg / apt install ffmpeg)');
427
+ lines.push(' - Screen Recording permission granted (macOS)');
428
+ lines.push('');
429
+ lines.push('Quick test:');
430
+ lines.push(' kbot stream_start --source test --platforms all');
431
+ lines.push(' (sends test pattern to verify keys work)');
432
+ const configured = getConfiguredPlatforms();
433
+ if (configured.length > 0) {
434
+ lines.push('');
435
+ lines.push(`Currently configured: ${configured.join(', ')}`);
436
+ }
437
+ return lines.join('\n');
438
+ },
439
+ });
440
+ registerTool({
441
+ name: 'stream_scene',
442
+ description: 'Switch stream scene/source without stopping. Change between screen, webcam, file, or test pattern.',
443
+ parameters: {
444
+ source: { type: 'string', description: 'New source: "screen", "webcam", "test", or file path', required: true },
445
+ },
446
+ tier: 'free',
447
+ execute: async (args) => {
448
+ const state = loadState();
449
+ if (!state.active || !isStreamRunning()) {
450
+ return 'No active stream. Start one with stream_start first.';
451
+ }
452
+ // To switch source, we need to restart ffmpeg with new input
453
+ // Store current settings, stop, restart with new source
454
+ const platforms = state.platforms;
455
+ const resolution = state.resolution;
456
+ const bitrate = state.bitrate;
457
+ const newSource = String(args.source);
458
+ // Stop current
459
+ if (activeProcess && !activeProcess.killed) {
460
+ activeProcess.kill('SIGINT');
461
+ activeProcess = null;
462
+ }
463
+ else if (state.pid) {
464
+ try {
465
+ process.kill(state.pid, 'SIGINT');
466
+ }
467
+ catch { /* */ }
468
+ }
469
+ await new Promise(r => setTimeout(r, 2000));
470
+ // Restart with new source
471
+ const ffmpegArgs = buildFfmpegArgs({ platforms, source: newSource, resolution, bitrate });
472
+ const proc = spawn('ffmpeg', ffmpegArgs, {
473
+ stdio: ['ignore', 'pipe', 'pipe'],
474
+ detached: true,
475
+ });
476
+ activeProcess = proc;
477
+ const pid = proc.pid;
478
+ writeFileSync(STREAM_PID, String(pid));
479
+ await new Promise(r => setTimeout(r, 3000));
480
+ if (proc.exitCode !== null) {
481
+ return `Error switching source to "${newSource}". ffmpeg exited.`;
482
+ }
483
+ proc.unref();
484
+ state.source = newSource;
485
+ state.pid = pid;
486
+ saveState(state);
487
+ return `Scene switched to: ${newSource}\nStream continues on: ${platforms.join(', ')}`;
488
+ },
489
+ });
490
+ }
491
+ //# sourceMappingURL=streaming.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernel.chat/kbot",
3
- "version": "3.73.3",
3
+ "version": "3.82.0",
4
4
  "description": "Open-source terminal AI agent. 764+ tools, 35 agents, 20 providers. Dreams, learns, watches your system. Controls your phone. Fully local, fully sovereign. MIT.",
5
5
  "type": "module",
6
6
  "repository": {