@kernel.chat/kbot 3.94.0 → 3.97.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,60 @@
1
+ export interface Highlight {
2
+ timestamp: number;
3
+ absoluteTime: string;
4
+ type: string;
5
+ description: string;
6
+ chatRate: number;
7
+ }
8
+ export interface RecordingInfo {
9
+ recording: boolean;
10
+ filePath: string | null;
11
+ startedAt: string | null;
12
+ durationSec: number;
13
+ fileSizeMB: number;
14
+ resolution: string;
15
+ highlights: number;
16
+ }
17
+ export interface RecordingResult {
18
+ filePath: string;
19
+ durationSec: number;
20
+ fileSizeMB: number;
21
+ highlights: Highlight[];
22
+ }
23
+ export interface TimelineEvent {
24
+ timeSec: number;
25
+ type: 'chat' | 'highlight' | 'viewer_count' | 'marker';
26
+ data: Record<string, unknown>;
27
+ }
28
+ export interface StreamTimeline {
29
+ date: string;
30
+ durationSec: number;
31
+ events: TimelineEvent[];
32
+ highlights: Highlight[];
33
+ peakChatRate: number;
34
+ totalMessages: number;
35
+ }
36
+ export declare class StreamVOD {
37
+ private process;
38
+ private state;
39
+ constructor();
40
+ startRecording(inputPipe?: string): void;
41
+ stopRecording(): RecordingResult;
42
+ isRecording(): boolean;
43
+ getRecordingInfo(): RecordingInfo;
44
+ addHighlight(type: string, description: string): void;
45
+ getHighlights(): Highlight[];
46
+ /** Feed chat messages for spike detection */
47
+ onChatMessage(username?: string): void;
48
+ /** Auto-detect highlights from stream events */
49
+ onStreamEvent(event: string, detail?: string): void;
50
+ clip(startSec: number, endSec: number, name?: string): Promise<string>;
51
+ clipHighlight(index: number): Promise<string>;
52
+ generateTimeline(): StreamTimeline;
53
+ uploadToYouTube(filePath: string, title: string, description: string): Promise<string>;
54
+ saveState(): void;
55
+ loadState(): void;
56
+ private _chatRate;
57
+ private _persistHighlights;
58
+ }
59
+ export declare function registerStreamVODTools(): void;
60
+ //# sourceMappingURL=stream-vod.d.ts.map
@@ -0,0 +1,449 @@
1
+ // kbot Stream VOD — Auto-record, highlight detection, clip system, YouTube upload
2
+ // Tools: vod_start, vod_stop, vod_status, vod_clip, vod_highlights, vod_upload
3
+ // Output: ~/.kbot/vods/ | Auth: ~/.kbot/youtube-auth.json (optional)
4
+ import { registerTool } from './index.js';
5
+ import { spawn, execFile } from 'node:child_process';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
9
+ // ─── Paths ───────────────────────────────────────────────────
10
+ const KBOT_DIR = join(homedir(), '.kbot');
11
+ const VOD_DIR = join(KBOT_DIR, 'vods');
12
+ const CLIPS_DIR = join(VOD_DIR, 'clips');
13
+ const HIGHLIGHTS_FILE = join(VOD_DIR, 'highlights.json');
14
+ const VOD_STATE_FILE = join(VOD_DIR, 'vod-state.json');
15
+ const YOUTUBE_AUTH_FILE = join(KBOT_DIR, 'youtube-auth.json');
16
+ function ensureDirs() {
17
+ for (const dir of [KBOT_DIR, VOD_DIR, CLIPS_DIR])
18
+ if (!existsSync(dir))
19
+ mkdirSync(dir, { recursive: true });
20
+ }
21
+ // ─── Helpers ─────────────────────────────────────────────────
22
+ function loadVODState() {
23
+ try {
24
+ if (existsSync(VOD_STATE_FILE))
25
+ return JSON.parse(readFileSync(VOD_STATE_FILE, 'utf-8'));
26
+ }
27
+ catch { }
28
+ return {
29
+ recording: false, filePath: null, startedAt: null, resolution: '1280x720',
30
+ pid: null, highlights: [], timelineEvents: [], chatTimestamps: [], totalChatMessages: 0,
31
+ };
32
+ }
33
+ function saveVODState(s) { ensureDirs(); writeFileSync(VOD_STATE_FILE, JSON.stringify(s, null, 2)); }
34
+ function checkFfmpeg() { return new Promise(r => { execFile('ffmpeg', ['-version'], { timeout: 5000 }, e => r(!e)); }); }
35
+ function elapsedSec(t) { return Math.round((Date.now() - new Date(t).getTime()) / 1000); }
36
+ function fileSizeMB(p) { try {
37
+ return Math.round(statSync(p).size / 1048576 * 100) / 100;
38
+ }
39
+ catch {
40
+ return 0;
41
+ } }
42
+ function formatTimestamp() {
43
+ const d = new Date(), p = (n) => String(n).padStart(2, '0');
44
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}`;
45
+ }
46
+ function formatSec(sec) {
47
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
48
+ if (h > 0)
49
+ return `${h}h${String(m).padStart(2, '0')}m${String(s).padStart(2, '0')}s`;
50
+ return m > 0 ? `${m}m${String(s).padStart(2, '0')}s` : `${s}s`;
51
+ }
52
+ // ─── StreamVOD Class ─────────────────────────────────────────
53
+ export class StreamVOD {
54
+ process = null;
55
+ state;
56
+ constructor() { ensureDirs(); this.state = loadVODState(); }
57
+ startRecording(inputPipe) {
58
+ if (this.state.recording)
59
+ throw new Error('Already recording. Stop the current recording first.');
60
+ const outputFile = join(VOD_DIR, `${formatTimestamp()}.mp4`);
61
+ const resolution = '1280x720';
62
+ const inputArgs = inputPipe
63
+ ? ['-i', inputPipe]
64
+ : ['-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=44100'];
65
+ const args = [
66
+ ...inputArgs, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
67
+ '-s', resolution, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k',
68
+ '-ar', '44100', '-movflags', '+faststart',
69
+ ...(inputPipe ? [] : ['-map', '0:v', '-map', '1:a']), '-y', outputFile,
70
+ ];
71
+ const proc = spawn('ffmpeg', args, { stdio: ['pipe', 'pipe', 'pipe'], detached: true });
72
+ this.process = proc;
73
+ proc.unref();
74
+ this.state = {
75
+ recording: true, filePath: outputFile, startedAt: new Date().toISOString(),
76
+ resolution, pid: proc.pid ?? null, highlights: [], timelineEvents: [],
77
+ chatTimestamps: [], totalChatMessages: 0,
78
+ };
79
+ saveVODState(this.state);
80
+ }
81
+ stopRecording() {
82
+ if (!this.state.recording || !this.state.filePath)
83
+ throw new Error('Not currently recording.');
84
+ if (this.process && !this.process.killed) {
85
+ try {
86
+ this.process.stdin?.write('q');
87
+ }
88
+ catch { }
89
+ setTimeout(() => { try {
90
+ this.process?.kill('SIGINT');
91
+ }
92
+ catch { } }, 2000);
93
+ }
94
+ else if (this.state.pid) {
95
+ try {
96
+ process.kill(this.state.pid, 'SIGINT');
97
+ }
98
+ catch { }
99
+ }
100
+ this.process = null;
101
+ const filePath = this.state.filePath;
102
+ const durationSec = this.state.startedAt ? elapsedSec(this.state.startedAt) : 0;
103
+ const highlights = [...this.state.highlights];
104
+ this.generateTimeline();
105
+ this._persistHighlights();
106
+ const result = { filePath, durationSec, fileSizeMB: fileSizeMB(filePath), highlights };
107
+ this.state.recording = false;
108
+ this.state.pid = null;
109
+ saveVODState(this.state);
110
+ return result;
111
+ }
112
+ isRecording() {
113
+ if (!this.state.recording)
114
+ return false;
115
+ if (this.state.pid) {
116
+ try {
117
+ process.kill(this.state.pid, 0);
118
+ return true;
119
+ }
120
+ catch { }
121
+ }
122
+ if (this.process && !this.process.killed)
123
+ return true;
124
+ this.state.recording = false;
125
+ saveVODState(this.state);
126
+ return false;
127
+ }
128
+ getRecordingInfo() {
129
+ return {
130
+ recording: this.isRecording(),
131
+ filePath: this.state.filePath,
132
+ startedAt: this.state.startedAt,
133
+ durationSec: this.state.startedAt ? elapsedSec(this.state.startedAt) : 0,
134
+ fileSizeMB: this.state.filePath ? fileSizeMB(this.state.filePath) : 0,
135
+ resolution: this.state.resolution,
136
+ highlights: this.state.highlights.length,
137
+ };
138
+ }
139
+ addHighlight(type, description) {
140
+ const timeSec = this.state.startedAt ? elapsedSec(this.state.startedAt) : 0;
141
+ const highlight = {
142
+ timestamp: timeSec, absoluteTime: new Date().toISOString(),
143
+ type, description, chatRate: this._chatRate(),
144
+ };
145
+ this.state.highlights.push(highlight);
146
+ this.state.timelineEvents.push({ timeSec, type: 'highlight', data: { highlightType: type, description } });
147
+ saveVODState(this.state);
148
+ }
149
+ getHighlights() { return [...this.state.highlights]; }
150
+ /** Feed chat messages for spike detection */
151
+ onChatMessage(username) {
152
+ const now = Date.now();
153
+ this.state.chatTimestamps.push(now);
154
+ this.state.totalChatMessages++;
155
+ this.state.chatTimestamps = this.state.chatTimestamps.filter(t => t >= now - 60_000);
156
+ if (this.state.recording) {
157
+ const timeSec = this.state.startedAt ? elapsedSec(this.state.startedAt) : 0;
158
+ this.state.timelineEvents.push({ timeSec, type: 'chat', data: { username: username || 'unknown' } });
159
+ }
160
+ // Chat spike: >10 messages in 30 seconds, debounce 60s
161
+ const recentCount = this.state.chatTimestamps.filter(t => t >= now - 30_000).length;
162
+ if (recentCount > 10) {
163
+ const lastSpike = this.state.highlights.filter(h => h.type === 'chat_spike').pop();
164
+ const lastTime = lastSpike ? new Date(lastSpike.absoluteTime).getTime() : 0;
165
+ if (now - lastTime > 60_000)
166
+ this.addHighlight('chat_spike', `Chat spike: ${recentCount} messages in 30s`);
167
+ }
168
+ saveVODState(this.state);
169
+ }
170
+ /** Auto-detect highlights from stream events */
171
+ onStreamEvent(event, detail) {
172
+ const auto = {
173
+ achievement: 'Achievement unlocked', boss_fight_start: 'Boss fight started',
174
+ boss_fight_end: 'Boss fight ended', raid: 'Raid received',
175
+ viewer_milestone: 'Viewer milestone reached', ship: 'Proposal shipped (!ship)',
176
+ sub_bomb: 'Sub bomb', hype_train: 'Hype train started',
177
+ };
178
+ const desc = detail || auto[event] || event;
179
+ if (auto[event] || event === 'custom')
180
+ this.addHighlight(event, desc);
181
+ if (this.state.recording) {
182
+ const timeSec = this.state.startedAt ? elapsedSec(this.state.startedAt) : 0;
183
+ this.state.timelineEvents.push({ timeSec, type: 'marker', data: { event, detail: desc } });
184
+ saveVODState(this.state);
185
+ }
186
+ }
187
+ async clip(startSec, endSec, name) {
188
+ if (!this.state.filePath || !existsSync(this.state.filePath))
189
+ throw new Error('No recording file found. Start and stop a recording first.');
190
+ if (!(await checkFfmpeg()))
191
+ throw new Error('ffmpeg not found. Install: brew install ffmpeg');
192
+ const duration = endSec - startSec;
193
+ if (duration <= 0)
194
+ throw new Error('endSec must be greater than startSec');
195
+ if (duration > 600)
196
+ throw new Error('Maximum clip duration is 10 minutes');
197
+ const safeName = (name || `clip_${Math.round(startSec)}-${Math.round(endSec)}`).replace(/[^a-zA-Z0-9_-]/g, '_');
198
+ const outputPath = join(CLIPS_DIR, `${safeName}.mp4`);
199
+ await new Promise((resolve, reject) => {
200
+ const proc = spawn('ffmpeg', [
201
+ '-ss', String(startSec), '-i', this.state.filePath,
202
+ '-t', String(duration), '-c:v', 'libx264', '-preset', 'fast', '-crf', '20',
203
+ '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', '-y', outputPath,
204
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
205
+ let stderr = '';
206
+ proc.stderr?.on('data', (c) => { stderr += c.toString(); });
207
+ proc.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg failed (${code}): ${stderr.slice(-300)}`)));
208
+ proc.on('error', reject);
209
+ });
210
+ // Thumbnail from middle frame (best-effort)
211
+ const thumbPath = join(CLIPS_DIR, `${safeName}_thumb.jpg`);
212
+ await new Promise(r => {
213
+ const p = spawn('ffmpeg', ['-ss', String(startSec + duration / 2), '-i', this.state.filePath, '-frames:v', '1', '-q:v', '3', '-y', thumbPath], { stdio: ['ignore', 'pipe', 'pipe'] });
214
+ p.on('close', () => r());
215
+ p.on('error', () => r());
216
+ });
217
+ return outputPath;
218
+ }
219
+ async clipHighlight(index) {
220
+ if (index < 0 || index >= this.state.highlights.length)
221
+ throw new Error(`Highlight index ${index} out of range (0-${this.state.highlights.length - 1})`);
222
+ const h = this.state.highlights[index];
223
+ return this.clip(Math.max(0, h.timestamp - 10), h.timestamp + 10, `highlight_${index}_${h.type}`);
224
+ }
225
+ generateTimeline() {
226
+ const durationSec = this.state.startedAt ? elapsedSec(this.state.startedAt) : 0;
227
+ let peakChatRate = 0;
228
+ for (const h of this.state.highlights)
229
+ if (h.chatRate > peakChatRate)
230
+ peakChatRate = h.chatRate;
231
+ const timeline = {
232
+ date: this.state.startedAt || new Date().toISOString(), durationSec,
233
+ events: [...this.state.timelineEvents], highlights: [...this.state.highlights],
234
+ peakChatRate, totalMessages: this.state.totalChatMessages,
235
+ };
236
+ const dateStr = (this.state.startedAt ? new Date(this.state.startedAt) : new Date()).toISOString().slice(0, 10);
237
+ writeFileSync(join(VOD_DIR, `${dateStr}_timeline.json`), JSON.stringify(timeline, null, 2));
238
+ return timeline;
239
+ }
240
+ async uploadToYouTube(filePath, title, description) {
241
+ if (!existsSync(filePath))
242
+ throw new Error(`File not found: ${filePath}`);
243
+ if (!existsSync(YOUTUBE_AUTH_FILE))
244
+ throw new Error('YouTube auth not configured. Create ~/.kbot/youtube-auth.json with client_id, client_secret, refresh_token.\nGet credentials at console.cloud.google.com, enable YouTube Data API v3.');
245
+ let auth;
246
+ try {
247
+ auth = JSON.parse(readFileSync(YOUTUBE_AUTH_FILE, 'utf-8'));
248
+ }
249
+ catch {
250
+ throw new Error('Failed to parse ~/.kbot/youtube-auth.json');
251
+ }
252
+ if (!auth.client_id || !auth.client_secret || !auth.refresh_token)
253
+ throw new Error('youtube-auth.json missing required fields');
254
+ // Token exchange
255
+ const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
256
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
257
+ body: new URLSearchParams({ client_id: auth.client_id, client_secret: auth.client_secret, refresh_token: auth.refresh_token, grant_type: 'refresh_token' }),
258
+ });
259
+ if (!tokenRes.ok)
260
+ throw new Error(`YouTube token refresh failed: ${await tokenRes.text()}`);
261
+ const { access_token: accessToken } = await tokenRes.json();
262
+ const { readFile } = await import('node:fs/promises');
263
+ const fileBuffer = await readFile(filePath);
264
+ // Initiate resumable upload
265
+ const initRes = await fetch('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', {
266
+ method: 'POST',
267
+ headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json; charset=UTF-8', 'X-Upload-Content-Length': String(fileBuffer.byteLength), 'X-Upload-Content-Type': 'video/mp4' },
268
+ body: JSON.stringify({ snippet: { title, description, tags: ['kbot', 'livestream', 'kernel.chat', 'AI'], categoryId: '20' }, status: { privacyStatus: 'unlisted', selfDeclaredMadeForKids: false } }),
269
+ });
270
+ if (!initRes.ok)
271
+ throw new Error(`YouTube upload init failed (${initRes.status}): ${await initRes.text()}`);
272
+ const uploadUrl = initRes.headers.get('Location');
273
+ if (!uploadUrl)
274
+ throw new Error('YouTube did not return a resumable upload URL');
275
+ // Upload file
276
+ const uploadRes = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Length': String(fileBuffer.byteLength), 'Content-Type': 'video/mp4' }, body: fileBuffer });
277
+ if (!uploadRes.ok)
278
+ throw new Error(`YouTube upload failed (${uploadRes.status}): ${await uploadRes.text()}`);
279
+ const result = await uploadRes.json();
280
+ return `https://youtu.be/${result.id}`;
281
+ }
282
+ saveState() { saveVODState(this.state); }
283
+ loadState() { this.state = loadVODState(); }
284
+ _chatRate() { return this.state.chatTimestamps.filter(t => t >= Date.now() - 30_000).length; }
285
+ _persistHighlights() {
286
+ let existing = [];
287
+ try {
288
+ if (existsSync(HIGHLIGHTS_FILE))
289
+ existing = JSON.parse(readFileSync(HIGHLIGHTS_FILE, 'utf-8'));
290
+ }
291
+ catch { }
292
+ writeFileSync(HIGHLIGHTS_FILE, JSON.stringify([...existing, ...this.state.highlights].slice(-500), null, 2));
293
+ }
294
+ }
295
+ // ─── Singleton ───────────────────────────────────────────────
296
+ let vodInstance = null;
297
+ function getVOD() { if (!vodInstance)
298
+ vodInstance = new StreamVOD(); return vodInstance; }
299
+ // ─── Tool Registration ───────────────────────────────────────
300
+ export function registerStreamVODTools() {
301
+ registerTool({
302
+ name: 'vod_start',
303
+ description: 'Start recording the livestream locally as h264+aac MP4 to ~/.kbot/vods/. Pass an RTMP URL, file path, or omit for test pattern.',
304
+ parameters: {
305
+ input: { type: 'string', description: 'Input source: RTMP URL, file path, or omit for test pattern.' },
306
+ },
307
+ tier: 'free',
308
+ timeout: 600_000,
309
+ execute: async (args) => {
310
+ if (!(await checkFfmpeg()))
311
+ return 'Error: ffmpeg not found. Install: brew install ffmpeg';
312
+ const vod = getVOD();
313
+ if (vod.isRecording()) {
314
+ const i = vod.getRecordingInfo();
315
+ return `Already recording: ${i.filePath}\nDuration: ${i.durationSec}s | Size: ${i.fileSizeMB}MB\nRun vod_stop first.`;
316
+ }
317
+ try {
318
+ vod.startRecording(args.input ? String(args.input) : undefined);
319
+ const i = vod.getRecordingInfo();
320
+ return `VOD recording started.\n\nFile: ${i.filePath}\nResolution: ${i.resolution}\nFormat: h264+aac MP4\n\nUse vod_stop to end, vod_status to check.`;
321
+ }
322
+ catch (e) {
323
+ return `Error: ${e.message}`;
324
+ }
325
+ },
326
+ });
327
+ registerTool({
328
+ name: 'vod_stop',
329
+ description: 'Stop VOD recording. Saves file, persists highlights, exports timeline.',
330
+ parameters: {},
331
+ tier: 'free',
332
+ execute: async () => {
333
+ const vod = getVOD();
334
+ if (!vod.isRecording())
335
+ return 'No active VOD recording.';
336
+ try {
337
+ const r = vod.stopRecording();
338
+ const hl = r.highlights.length > 0
339
+ ? `\n\nHighlights (${r.highlights.length}):\n` + r.highlights.map((h, i) => ` ${i}. [${formatSec(h.timestamp)}] ${h.type}: ${h.description}`).join('\n')
340
+ : '\n\nNo highlights detected.';
341
+ return `VOD stopped.\n\nFile: ${r.filePath}\nDuration: ${formatSec(r.durationSec)}\nSize: ${r.fileSizeMB} MB${hl}\n\nUse vod_clip to extract clips.`;
342
+ }
343
+ catch (e) {
344
+ return `Error: ${e.message}`;
345
+ }
346
+ },
347
+ });
348
+ registerTool({
349
+ name: 'vod_status',
350
+ description: 'Check VOD recording status, file size, duration, and highlight count.',
351
+ parameters: {},
352
+ tier: 'free',
353
+ execute: async () => {
354
+ const vod = getVOD();
355
+ const i = vod.getRecordingInfo();
356
+ if (!i.recording) {
357
+ if (i.filePath && existsSync(i.filePath))
358
+ return `Not recording.\n\nLast: ${i.filePath}\nSize: ${i.fileSizeMB} MB | Highlights: ${i.highlights}`;
359
+ return 'Not recording. Use vod_start to begin.';
360
+ }
361
+ return `Recording.\n\nFile: ${i.filePath}\nDuration: ${formatSec(i.durationSec)} | Size: ${i.fileSizeMB} MB\nResolution: ${i.resolution} | Highlights: ${i.highlights}`;
362
+ },
363
+ });
364
+ registerTool({
365
+ name: 'vod_clip',
366
+ description: 'Extract a clip from the VOD by start/end seconds or highlight index (10s padding). Generates thumbnail.',
367
+ parameters: {
368
+ start: { type: 'number', description: 'Start seconds. Ignored if highlight_index set.' },
369
+ end: { type: 'number', description: 'End seconds. Ignored if highlight_index set.' },
370
+ name: { type: 'string', description: 'Clip name (no extension). Default: auto.' },
371
+ highlight_index: { type: 'number', description: 'Highlight index to auto-clip around.' },
372
+ },
373
+ tier: 'free',
374
+ timeout: 120_000,
375
+ execute: async (args) => {
376
+ const vod = getVOD();
377
+ try {
378
+ if (args.highlight_index !== undefined && args.highlight_index !== null) {
379
+ const idx = Number(args.highlight_index);
380
+ const path = await vod.clipHighlight(idx);
381
+ const h = vod.getHighlights()[idx];
382
+ return `Clip from highlight #${idx} (${h.type}).\n\nFile: ${path}\nThumb: ${path.replace('.mp4', '_thumb.jpg')}`;
383
+ }
384
+ const start = Number(args.start ?? 0), end = Number(args.end ?? 20);
385
+ const path = await vod.clip(start, end, args.name ? String(args.name) : undefined);
386
+ return `Clip created.\n\nFile: ${path}\nRange: ${formatSec(start)} - ${formatSec(end)}\nThumb: ${path.replace('.mp4', '_thumb.jpg')}`;
387
+ }
388
+ catch (e) {
389
+ return `Error: ${e.message}`;
390
+ }
391
+ },
392
+ });
393
+ registerTool({
394
+ name: 'vod_highlights',
395
+ description: 'List detected highlights with timestamps, types, and chat rates. Set all=true for full history.',
396
+ parameters: {
397
+ all: { type: 'string', description: 'Set "true" to show all persisted highlights across sessions.' },
398
+ },
399
+ tier: 'free',
400
+ execute: async (args) => {
401
+ const vod = getVOD();
402
+ let highlights;
403
+ if (String(args.all) === 'true') {
404
+ try {
405
+ highlights = existsSync(HIGHLIGHTS_FILE) ? JSON.parse(readFileSync(HIGHLIGHTS_FILE, 'utf-8')) : [];
406
+ }
407
+ catch {
408
+ return 'Error reading highlights file.';
409
+ }
410
+ }
411
+ else {
412
+ highlights = vod.getHighlights();
413
+ }
414
+ if (!highlights.length)
415
+ return 'No highlights yet. Auto-detected from: chat spikes (>10/30s), achievements, raids, milestones, !mark, !ship.';
416
+ const lines = highlights.map((h, i) => ` ${i}. [${formatSec(h.timestamp)}] ${h.type} — ${h.description} (${h.chatRate}/30s)`);
417
+ return `Highlights (${highlights.length}):\n${lines.join('\n')}\n\nUse vod_clip with highlight_index to extract.`;
418
+ },
419
+ });
420
+ registerTool({
421
+ name: 'vod_upload',
422
+ description: 'Upload VOD/clip to YouTube (unlisted). Requires OAuth2 in ~/.kbot/youtube-auth.json.',
423
+ parameters: {
424
+ file: { type: 'string', description: 'Video file path. Default: most recent recording.', required: true },
425
+ title: { type: 'string', description: 'Video title. Default: from filename.' },
426
+ description: { type: 'string', description: 'Video description.' },
427
+ },
428
+ tier: 'free',
429
+ timeout: 600_000,
430
+ execute: async (args) => {
431
+ const vod = getVOD();
432
+ const fp = String(args.file || vod.getRecordingInfo().filePath || '');
433
+ if (!fp || !existsSync(fp))
434
+ return `Error: File not found: ${fp || '(none)'}`;
435
+ const title = String(args.title || fp.split('/').pop()?.replace('.mp4', '') || 'kbot stream');
436
+ const desc = String(args.description || 'Recorded with kbot — https://kernel.chat');
437
+ if (!existsSync(YOUTUBE_AUTH_FILE))
438
+ return `YouTube not configured. Save OAuth2 credentials to ~/.kbot/youtube-auth.json.\nFile ready: ${fp} (${fileSizeMB(fp)} MB)`;
439
+ try {
440
+ const url = await vod.uploadToYouTube(fp, title, desc);
441
+ return `Uploaded (unlisted).\n\nURL: ${url}\nTitle: ${title}\nSize: ${fileSizeMB(fp)} MB`;
442
+ }
443
+ catch (e) {
444
+ return `Upload failed: ${e.message}\n\nFile ready: ${fp} (${fileSizeMB(fp)} MB)`;
445
+ }
446
+ },
447
+ });
448
+ }
449
+ //# sourceMappingURL=stream-vod.js.map
@@ -0,0 +1,79 @@
1
+ import type { CanvasRenderingContext2D } from 'canvas';
2
+ export type WeatherType = 'clear' | 'cloudy' | 'overcast' | 'light_rain' | 'heavy_rain' | 'thunderstorm' | 'snow' | 'blizzard' | 'fog' | 'aurora' | 'sandstorm' | 'meteor_shower';
3
+ export type TimeOfDay = 'dawn' | 'morning' | 'noon' | 'afternoon' | 'dusk' | 'evening' | 'night';
4
+ export interface WeatherState {
5
+ type: WeatherType;
6
+ intensity: number;
7
+ particles: WeatherParticle[];
8
+ skyTint: string;
9
+ ambientMod: number;
10
+ soundKey: string;
11
+ lightningTimer: number;
12
+ lightningFlash: number;
13
+ }
14
+ interface WeatherParticle {
15
+ x: number;
16
+ y: number;
17
+ vx: number;
18
+ vy: number;
19
+ size: number;
20
+ life: number;
21
+ maxLife: number;
22
+ alpha: number;
23
+ color: string;
24
+ type: 'drop' | 'flake' | 'fog' | 'bolt' | 'aurora' | 'meteor' | 'sand' | 'splash';
25
+ }
26
+ export declare class WeatherSystem {
27
+ private syncRealTime;
28
+ private cycleStart;
29
+ private streamHour;
30
+ private current;
31
+ private target;
32
+ private transitionProgress;
33
+ private targetState;
34
+ private weatherTimer;
35
+ private weatherHistory;
36
+ private chatActivityWindow;
37
+ private chatDrivenWeather;
38
+ private celestialAngle;
39
+ constructor(syncRealTime?: boolean);
40
+ tick(frame: number, chatActivity: number): void;
41
+ render(ctx: CanvasRenderingContext2D, width: number, height: number): void;
42
+ renderSky(ctx: CanvasRenderingContext2D, width: number, height: number): void;
43
+ setWeather(type: WeatherType, immediate?: boolean): void;
44
+ getTimeOfDay(): TimeOfDay;
45
+ getAmbientLight(): number;
46
+ getSkyColors(): {
47
+ top: string;
48
+ bottom: string;
49
+ };
50
+ getWeather(): WeatherState;
51
+ /** Get character mood suggestion based on weather */
52
+ getMoodSuggestion(): string;
53
+ /** Handle chat commands like !weather rain */
54
+ handleCommand(cmd: string, args: string): string;
55
+ private updateTime;
56
+ private getNextPhase;
57
+ /** How far through the current phase we are (0-1) */
58
+ private getPhaseProgress;
59
+ private getStarVisibility;
60
+ private buildWeatherState;
61
+ private getActiveWeatherDef;
62
+ private pickRandomWeather;
63
+ private tickLightning;
64
+ private spawnParticles;
65
+ private createParticle;
66
+ private tickParticles;
67
+ private updateParticleList;
68
+ private renderParticles;
69
+ private drawParticle;
70
+ private renderStars;
71
+ private renderCelestialBodies;
72
+ private renderAuroraBands;
73
+ private renderClouds;
74
+ private renderFogOverlay;
75
+ }
76
+ export declare function getWeatherSystem(syncRealTime?: boolean): WeatherSystem;
77
+ export declare function registerStreamWeatherTools(): void;
78
+ export {};
79
+ //# sourceMappingURL=stream-weather.d.ts.map