@kernel.chat/kbot 3.86.0 → 3.88.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.
package/dist/tools/index.js
CHANGED
|
@@ -318,6 +318,8 @@ const LAZY_MODULE_IMPORTS = [
|
|
|
318
318
|
{ path: './stream-character.js', registerFn: 'registerStreamCharacterTools' },
|
|
319
319
|
{ path: './stream-renderer.js', registerFn: 'registerStreamRendererTools' },
|
|
320
320
|
{ path: './kbot-browser.js', registerFn: 'registerKBotBrowserTools' },
|
|
321
|
+
{ path: './kbot-terminal.js', registerFn: 'registerKBotTerminalTools' },
|
|
322
|
+
{ path: './stream-control.js', registerFn: 'registerStreamControlTools' },
|
|
321
323
|
];
|
|
322
324
|
/** Track whether lazy tools have been registered */
|
|
323
325
|
let lazyToolsRegistered = false;
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// kbot Persistent Terminal — kbot's own shell environment
|
|
2
|
+
//
|
|
3
|
+
// A persistent terminal that survives session disconnects.
|
|
4
|
+
// Shell state (cwd, env, history) persists to disk.
|
|
5
|
+
// Command queue enables autonomous unattended execution.
|
|
6
|
+
// Multiple named sessions with independent state.
|
|
7
|
+
//
|
|
8
|
+
// State: ~/.kbot/terminal-state.json
|
|
9
|
+
// Queue: ~/.kbot/terminal-queue.json
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { registerTool } from './index.js';
|
|
15
|
+
// ─── State ────────────────────────────────────────────────────────────────
|
|
16
|
+
const KBOT_DIR = join(homedir(), '.kbot');
|
|
17
|
+
const TERMINAL_STATE = join(KBOT_DIR, 'terminal-state.json');
|
|
18
|
+
const COMMAND_QUEUE = join(KBOT_DIR, 'terminal-queue.json');
|
|
19
|
+
const terminal = {
|
|
20
|
+
sessions: new Map(),
|
|
21
|
+
defaultSession: '',
|
|
22
|
+
};
|
|
23
|
+
let queueInterval = null;
|
|
24
|
+
// ─── Session Management ───────────────────────────────────────────────────
|
|
25
|
+
function createShellSession(name) {
|
|
26
|
+
const id = `term_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
27
|
+
const session = {
|
|
28
|
+
id,
|
|
29
|
+
name,
|
|
30
|
+
cwd: homedir(),
|
|
31
|
+
env: { ...process.env },
|
|
32
|
+
history: [],
|
|
33
|
+
running: true,
|
|
34
|
+
pid: null,
|
|
35
|
+
createdAt: Date.now(),
|
|
36
|
+
lastActivity: Date.now(),
|
|
37
|
+
outputBuffer: [],
|
|
38
|
+
pendingCommand: null,
|
|
39
|
+
exitCode: null,
|
|
40
|
+
};
|
|
41
|
+
return session;
|
|
42
|
+
}
|
|
43
|
+
function findSession(nameOrId) {
|
|
44
|
+
if (terminal.sessions.has(nameOrId))
|
|
45
|
+
return nameOrId;
|
|
46
|
+
for (const [id, s] of terminal.sessions) {
|
|
47
|
+
if (s.name === nameOrId)
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
return terminal.defaultSession;
|
|
51
|
+
}
|
|
52
|
+
// ─── Command Execution ────────────────────────────────────────────────────
|
|
53
|
+
async function executeInSession(session, command) {
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
return new Promise((resolve_) => {
|
|
56
|
+
const proc = spawn('bash', ['-c', command], {
|
|
57
|
+
cwd: session.cwd,
|
|
58
|
+
env: session.env,
|
|
59
|
+
timeout: 300_000, // 5 min timeout
|
|
60
|
+
});
|
|
61
|
+
session.pid = proc.pid ?? null;
|
|
62
|
+
session.pendingCommand = command;
|
|
63
|
+
let stdout = '';
|
|
64
|
+
let stderr = '';
|
|
65
|
+
proc.stdout?.on('data', (d) => {
|
|
66
|
+
const text = d.toString();
|
|
67
|
+
stdout += text;
|
|
68
|
+
// Add to rolling buffer
|
|
69
|
+
const lines = text.split('\n');
|
|
70
|
+
session.outputBuffer.push(...lines);
|
|
71
|
+
if (session.outputBuffer.length > 500) {
|
|
72
|
+
session.outputBuffer = session.outputBuffer.slice(-500);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
proc.stderr?.on('data', (d) => {
|
|
76
|
+
const text = d.toString();
|
|
77
|
+
stderr += text;
|
|
78
|
+
// Also add stderr to output buffer for visibility
|
|
79
|
+
const lines = text.split('\n');
|
|
80
|
+
session.outputBuffer.push(...lines);
|
|
81
|
+
if (session.outputBuffer.length > 500) {
|
|
82
|
+
session.outputBuffer = session.outputBuffer.slice(-500);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
proc.on('close', (code) => {
|
|
86
|
+
const entry = {
|
|
87
|
+
command,
|
|
88
|
+
output: (stdout + stderr).slice(0, 50_000), // cap at 50KB
|
|
89
|
+
exitCode: code ?? 1,
|
|
90
|
+
timestamp: start,
|
|
91
|
+
duration: Date.now() - start,
|
|
92
|
+
};
|
|
93
|
+
session.history.push(entry);
|
|
94
|
+
if (session.history.length > 200)
|
|
95
|
+
session.history = session.history.slice(-200);
|
|
96
|
+
session.lastActivity = Date.now();
|
|
97
|
+
session.exitCode = code;
|
|
98
|
+
session.pendingCommand = null;
|
|
99
|
+
session.pid = null;
|
|
100
|
+
// Track cwd changes
|
|
101
|
+
if (/^\s*cd\s/.test(command) && !command.includes('&&') && !command.includes(';')) {
|
|
102
|
+
const dir = command.replace(/^\s*cd\s+/, '').trim().replace(/^['"]|['"]$/g, '');
|
|
103
|
+
const expanded = dir.replace(/^~/, homedir());
|
|
104
|
+
try {
|
|
105
|
+
const resolved = resolve(session.cwd, expanded);
|
|
106
|
+
if (existsSync(resolved))
|
|
107
|
+
session.cwd = resolved;
|
|
108
|
+
}
|
|
109
|
+
catch { /* keep current cwd */ }
|
|
110
|
+
}
|
|
111
|
+
// Save to disk
|
|
112
|
+
saveTerminalState();
|
|
113
|
+
resolve_(entry);
|
|
114
|
+
});
|
|
115
|
+
proc.on('error', (err) => {
|
|
116
|
+
const entry = {
|
|
117
|
+
command,
|
|
118
|
+
output: `Error: ${err.message}`,
|
|
119
|
+
exitCode: 1,
|
|
120
|
+
timestamp: start,
|
|
121
|
+
duration: Date.now() - start,
|
|
122
|
+
};
|
|
123
|
+
session.history.push(entry);
|
|
124
|
+
session.lastActivity = Date.now();
|
|
125
|
+
session.pendingCommand = null;
|
|
126
|
+
session.pid = null;
|
|
127
|
+
saveTerminalState();
|
|
128
|
+
resolve_(entry);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// ─── State Persistence ────────────────────────────────────────────────────
|
|
133
|
+
function saveTerminalState() {
|
|
134
|
+
try {
|
|
135
|
+
if (!existsSync(KBOT_DIR))
|
|
136
|
+
mkdirSync(KBOT_DIR, { recursive: true });
|
|
137
|
+
const state = {
|
|
138
|
+
sessions: Array.from(terminal.sessions.entries()).map(([_id, s]) => ({
|
|
139
|
+
id: s.id,
|
|
140
|
+
name: s.name,
|
|
141
|
+
cwd: s.cwd,
|
|
142
|
+
createdAt: s.createdAt,
|
|
143
|
+
lastActivity: s.lastActivity,
|
|
144
|
+
history: s.history.slice(-50), // save last 50 commands
|
|
145
|
+
outputBuffer: s.outputBuffer.slice(-100), // save last 100 lines
|
|
146
|
+
})),
|
|
147
|
+
defaultSession: terminal.defaultSession,
|
|
148
|
+
savedAt: Date.now(),
|
|
149
|
+
};
|
|
150
|
+
writeFileSync(TERMINAL_STATE, JSON.stringify(state, null, 2));
|
|
151
|
+
}
|
|
152
|
+
catch { /* best-effort persistence */ }
|
|
153
|
+
}
|
|
154
|
+
function loadTerminalState() {
|
|
155
|
+
try {
|
|
156
|
+
if (existsSync(TERMINAL_STATE)) {
|
|
157
|
+
const raw = readFileSync(TERMINAL_STATE, 'utf-8');
|
|
158
|
+
const state = JSON.parse(raw);
|
|
159
|
+
for (const s of state.sessions || []) {
|
|
160
|
+
terminal.sessions.set(s.id, {
|
|
161
|
+
id: s.id,
|
|
162
|
+
name: s.name,
|
|
163
|
+
cwd: s.cwd || homedir(),
|
|
164
|
+
env: { ...process.env },
|
|
165
|
+
history: s.history || [],
|
|
166
|
+
running: true,
|
|
167
|
+
pid: null,
|
|
168
|
+
createdAt: s.createdAt || Date.now(),
|
|
169
|
+
lastActivity: s.lastActivity || Date.now(),
|
|
170
|
+
outputBuffer: s.outputBuffer || [],
|
|
171
|
+
pendingCommand: null,
|
|
172
|
+
exitCode: null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (state.defaultSession && terminal.sessions.has(state.defaultSession)) {
|
|
176
|
+
terminal.defaultSession = state.defaultSession;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch { /* start fresh if state is corrupted */ }
|
|
181
|
+
}
|
|
182
|
+
// ─── Command Queue ────────────────────────────────────────────────────────
|
|
183
|
+
function loadQueue() {
|
|
184
|
+
try {
|
|
185
|
+
if (existsSync(COMMAND_QUEUE)) {
|
|
186
|
+
return JSON.parse(readFileSync(COMMAND_QUEUE, 'utf-8'));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch { /* empty queue on corruption */ }
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
function saveQueue(queue) {
|
|
193
|
+
try {
|
|
194
|
+
if (!existsSync(KBOT_DIR))
|
|
195
|
+
mkdirSync(KBOT_DIR, { recursive: true });
|
|
196
|
+
writeFileSync(COMMAND_QUEUE, JSON.stringify(queue, null, 2));
|
|
197
|
+
}
|
|
198
|
+
catch { /* best-effort */ }
|
|
199
|
+
}
|
|
200
|
+
function queueCommand(command, sessionId, delay) {
|
|
201
|
+
const queue = loadQueue();
|
|
202
|
+
queue.push({
|
|
203
|
+
command,
|
|
204
|
+
session: sessionId,
|
|
205
|
+
scheduledAt: Date.now(),
|
|
206
|
+
runAt: delay ? Date.now() + delay : undefined,
|
|
207
|
+
});
|
|
208
|
+
saveQueue(queue);
|
|
209
|
+
}
|
|
210
|
+
async function processQueue() {
|
|
211
|
+
const queue = loadQueue();
|
|
212
|
+
if (queue.length === 0)
|
|
213
|
+
return;
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const ready = queue.filter(q => !q.runAt || q.runAt <= now);
|
|
216
|
+
const remaining = queue.filter(q => q.runAt != null && q.runAt > now);
|
|
217
|
+
for (const cmd of ready) {
|
|
218
|
+
const session = terminal.sessions.get(cmd.session);
|
|
219
|
+
if (session) {
|
|
220
|
+
await executeInSession(session, cmd.command);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
saveQueue(remaining);
|
|
224
|
+
}
|
|
225
|
+
function startQueueProcessor() {
|
|
226
|
+
if (queueInterval)
|
|
227
|
+
return;
|
|
228
|
+
queueInterval = setInterval(() => {
|
|
229
|
+
processQueue().catch(() => { });
|
|
230
|
+
}, 10_000);
|
|
231
|
+
// Don't keep the process alive just for the queue
|
|
232
|
+
if (queueInterval.unref)
|
|
233
|
+
queueInterval.unref();
|
|
234
|
+
}
|
|
235
|
+
// ─── Tool Registration ────────────────────────────────────────────────────
|
|
236
|
+
export function registerKBotTerminalTools() {
|
|
237
|
+
// Initialize: load persisted state
|
|
238
|
+
loadTerminalState();
|
|
239
|
+
// Ensure at least one session exists
|
|
240
|
+
if (terminal.sessions.size === 0) {
|
|
241
|
+
const defaultSession = createShellSession('main');
|
|
242
|
+
terminal.sessions.set(defaultSession.id, defaultSession);
|
|
243
|
+
terminal.defaultSession = defaultSession.id;
|
|
244
|
+
saveTerminalState();
|
|
245
|
+
}
|
|
246
|
+
else if (!terminal.defaultSession || !terminal.sessions.has(terminal.defaultSession)) {
|
|
247
|
+
terminal.defaultSession = terminal.sessions.keys().next().value;
|
|
248
|
+
}
|
|
249
|
+
// Start background queue processor
|
|
250
|
+
// Queue processor disabled by default — only runs when explicitly commanded
|
|
251
|
+
// startQueueProcessor()
|
|
252
|
+
registerTool({
|
|
253
|
+
name: 'terminal_exec',
|
|
254
|
+
description: 'Execute a command in kbot\'s persistent terminal. Shell state (cwd, history) persists across calls and sessions. Output is stored for later retrieval.',
|
|
255
|
+
parameters: {
|
|
256
|
+
command: { type: 'string', description: 'Shell command to execute', required: true },
|
|
257
|
+
session: { type: 'string', description: 'Session name or ID (default: current default session)' },
|
|
258
|
+
},
|
|
259
|
+
tier: 'free',
|
|
260
|
+
timeout: 300_000,
|
|
261
|
+
async execute(args) {
|
|
262
|
+
const sessionId = findSession(String(args.session || 'main'));
|
|
263
|
+
const session = terminal.sessions.get(sessionId);
|
|
264
|
+
if (!session)
|
|
265
|
+
return 'Session not found. Use terminal_sessions action="list" to see available sessions.';
|
|
266
|
+
const result = await executeInSession(session, String(args.command));
|
|
267
|
+
return `[${session.name}:${session.cwd}] $ ${result.command}\n${result.output}\n[exit: ${result.exitCode}, ${result.duration}ms]`;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
registerTool({
|
|
271
|
+
name: 'terminal_history',
|
|
272
|
+
description: 'View command history from kbot\'s persistent terminal. Shows recent commands and their output. History persists across process restarts.',
|
|
273
|
+
parameters: {
|
|
274
|
+
count: { type: 'string', description: 'Number of recent commands to show (default: 10)' },
|
|
275
|
+
session: { type: 'string', description: 'Session name or ID' },
|
|
276
|
+
},
|
|
277
|
+
tier: 'free',
|
|
278
|
+
async execute(args) {
|
|
279
|
+
const sessionId = findSession(String(args.session || 'main'));
|
|
280
|
+
const session = terminal.sessions.get(sessionId);
|
|
281
|
+
if (!session)
|
|
282
|
+
return 'Session not found';
|
|
283
|
+
const count = parseInt(String(args.count || '10'), 10) || 10;
|
|
284
|
+
const recent = session.history.slice(-count);
|
|
285
|
+
if (recent.length === 0)
|
|
286
|
+
return `[${session.name}] No command history yet.`;
|
|
287
|
+
return recent.map(h => `[${new Date(h.timestamp).toLocaleTimeString()}] $ ${h.command}\n${h.output.slice(0, 200)}${h.output.length > 200 ? '...' : ''}\n[exit: ${h.exitCode}, ${h.duration}ms]`).join('\n\n');
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
registerTool({
|
|
291
|
+
name: 'terminal_output',
|
|
292
|
+
description: 'Get the current output buffer from a terminal session. Shows the last N lines of combined stdout/stderr output across all commands.',
|
|
293
|
+
parameters: {
|
|
294
|
+
lines: { type: 'string', description: 'Number of lines to show (default: 50)' },
|
|
295
|
+
session: { type: 'string', description: 'Session name or ID' },
|
|
296
|
+
},
|
|
297
|
+
tier: 'free',
|
|
298
|
+
async execute(args) {
|
|
299
|
+
const sessionId = findSession(String(args.session || 'main'));
|
|
300
|
+
const session = terminal.sessions.get(sessionId);
|
|
301
|
+
if (!session)
|
|
302
|
+
return 'Session not found';
|
|
303
|
+
const lines = parseInt(String(args.lines || '50'), 10) || 50;
|
|
304
|
+
const buf = session.outputBuffer.slice(-lines);
|
|
305
|
+
if (buf.length === 0)
|
|
306
|
+
return `[${session.name}] Output buffer empty.`;
|
|
307
|
+
return buf.join('\n');
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
registerTool({
|
|
311
|
+
name: 'terminal_sessions',
|
|
312
|
+
description: 'List, create, or close terminal sessions. Each session has its own cwd, history, and output buffer.',
|
|
313
|
+
parameters: {
|
|
314
|
+
action: { type: 'string', description: '"list", "new", or "close"', required: true },
|
|
315
|
+
name: { type: 'string', description: 'Session name (for new/close)' },
|
|
316
|
+
},
|
|
317
|
+
tier: 'free',
|
|
318
|
+
async execute(args) {
|
|
319
|
+
const action = String(args.action);
|
|
320
|
+
if (action === 'list') {
|
|
321
|
+
const sessions = Array.from(terminal.sessions.values());
|
|
322
|
+
if (sessions.length === 0)
|
|
323
|
+
return 'No sessions.';
|
|
324
|
+
return sessions.map(s => {
|
|
325
|
+
const isDefault = s.id === terminal.defaultSession ? ' [default]' : '';
|
|
326
|
+
const pending = s.pendingCommand ? `\n running: ${s.pendingCommand}` : '';
|
|
327
|
+
return `${s.name} (${s.id})${isDefault}\n cwd: ${s.cwd}\n commands: ${s.history.length}\n last: ${new Date(s.lastActivity).toLocaleString()}${pending}`;
|
|
328
|
+
}).join('\n\n');
|
|
329
|
+
}
|
|
330
|
+
if (action === 'new') {
|
|
331
|
+
const name = String(args.name || `session-${terminal.sessions.size + 1}`);
|
|
332
|
+
// Check for duplicate names
|
|
333
|
+
for (const s of terminal.sessions.values()) {
|
|
334
|
+
if (s.name === name)
|
|
335
|
+
return `Session "${name}" already exists. Choose a different name.`;
|
|
336
|
+
}
|
|
337
|
+
const session = createShellSession(name);
|
|
338
|
+
terminal.sessions.set(session.id, session);
|
|
339
|
+
terminal.defaultSession = session.id;
|
|
340
|
+
saveTerminalState();
|
|
341
|
+
return `Created session: ${name} (${session.id}) — now the default session.`;
|
|
342
|
+
}
|
|
343
|
+
if (action === 'close') {
|
|
344
|
+
if (!args.name)
|
|
345
|
+
return 'Specify session name to close.';
|
|
346
|
+
const sessionId = findSession(String(args.name));
|
|
347
|
+
if (!terminal.sessions.has(sessionId))
|
|
348
|
+
return `Session "${args.name}" not found.`;
|
|
349
|
+
if (sessionId === terminal.defaultSession && terminal.sessions.size === 1) {
|
|
350
|
+
return 'Cannot close the last session.';
|
|
351
|
+
}
|
|
352
|
+
const closedName = terminal.sessions.get(sessionId)?.name;
|
|
353
|
+
terminal.sessions.delete(sessionId);
|
|
354
|
+
if (sessionId === terminal.defaultSession) {
|
|
355
|
+
terminal.defaultSession = terminal.sessions.keys().next().value;
|
|
356
|
+
}
|
|
357
|
+
saveTerminalState();
|
|
358
|
+
return `Closed session: ${closedName}`;
|
|
359
|
+
}
|
|
360
|
+
return 'Unknown action. Use: list, new, close';
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
registerTool({
|
|
364
|
+
name: 'terminal_queue',
|
|
365
|
+
description: 'Queue a command for later execution in kbot\'s terminal. Queued commands only run when explicitly triggered with terminal_exec "process_queue". Use for batching commands you want to run together.',
|
|
366
|
+
parameters: {
|
|
367
|
+
command: { type: 'string', description: 'Command to queue (omit to list pending queue)', required: false },
|
|
368
|
+
delay_seconds: { type: 'string', description: 'Delay in seconds before execution (default: 0 = next cycle)' },
|
|
369
|
+
session: { type: 'string', description: 'Session name or ID' },
|
|
370
|
+
action: { type: 'string', description: '"add" (default), "list", or "clear"' },
|
|
371
|
+
},
|
|
372
|
+
tier: 'free',
|
|
373
|
+
async execute(args) {
|
|
374
|
+
const action = String(args.action || 'add');
|
|
375
|
+
if (action === 'list') {
|
|
376
|
+
const queue = loadQueue();
|
|
377
|
+
if (queue.length === 0)
|
|
378
|
+
return 'Queue is empty.';
|
|
379
|
+
return queue.map((q, i) => {
|
|
380
|
+
const delay = q.runAt ? `runs at ${new Date(q.runAt).toLocaleTimeString()}` : 'runs next cycle';
|
|
381
|
+
return `${i + 1}. [${q.session}] ${q.command}\n scheduled: ${new Date(q.scheduledAt).toLocaleTimeString()}, ${delay}`;
|
|
382
|
+
}).join('\n\n');
|
|
383
|
+
}
|
|
384
|
+
if (action === 'clear') {
|
|
385
|
+
saveQueue([]);
|
|
386
|
+
return 'Queue cleared.';
|
|
387
|
+
}
|
|
388
|
+
// action === 'add'
|
|
389
|
+
if (!args.command)
|
|
390
|
+
return 'Specify a command to queue, or use action="list" to view pending commands.';
|
|
391
|
+
const delay = parseInt(String(args.delay_seconds || '0'), 10) * 1000;
|
|
392
|
+
const sessionId = findSession(String(args.session || 'main'));
|
|
393
|
+
queueCommand(String(args.command), sessionId, delay || undefined);
|
|
394
|
+
return `Queued: ${args.command}${delay ? ` (runs in ${args.delay_seconds}s)` : ' (runs next cycle ~10s)'}`;
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
registerTool({
|
|
398
|
+
name: 'terminal_cwd',
|
|
399
|
+
description: 'Get or change the working directory of a terminal session.',
|
|
400
|
+
parameters: {
|
|
401
|
+
path: { type: 'string', description: 'New directory path (omit to show current cwd)' },
|
|
402
|
+
session: { type: 'string', description: 'Session name or ID' },
|
|
403
|
+
},
|
|
404
|
+
tier: 'free',
|
|
405
|
+
async execute(args) {
|
|
406
|
+
const sessionId = findSession(String(args.session || 'main'));
|
|
407
|
+
const session = terminal.sessions.get(sessionId);
|
|
408
|
+
if (!session)
|
|
409
|
+
return 'Session not found';
|
|
410
|
+
if (args.path) {
|
|
411
|
+
const target = String(args.path).replace(/^~/, homedir());
|
|
412
|
+
const resolved = resolve(session.cwd, target);
|
|
413
|
+
if (existsSync(resolved)) {
|
|
414
|
+
session.cwd = resolved;
|
|
415
|
+
saveTerminalState();
|
|
416
|
+
return `[${session.name}] cwd: ${resolved}`;
|
|
417
|
+
}
|
|
418
|
+
return `Directory not found: ${resolved}`;
|
|
419
|
+
}
|
|
420
|
+
return `[${session.name}] cwd: ${session.cwd}`;
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
//# sourceMappingURL=kbot-terminal.js.map
|
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
// kbot Stream Control Tools — Manage Twitch, Kick, and Rumble dashboards
|
|
2
|
+
//
|
|
3
|
+
// Tools: stream_title, stream_category, stream_info, stream_viewers,
|
|
4
|
+
// stream_chat_settings, stream_clip, stream_marker, stream_followers,
|
|
5
|
+
// stream_chat_send, stream_ban, stream_announce, stream_dashboard,
|
|
6
|
+
// stream_setup_oauth
|
|
7
|
+
//
|
|
8
|
+
// These tools manage the streaming platform *dashboards* — titles, categories,
|
|
9
|
+
// chat moderation, clips, markers, followers — not the video feed itself
|
|
10
|
+
// (that's in streaming.ts).
|
|
11
|
+
//
|
|
12
|
+
// Env: TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, TWITCH_BROADCASTER_ID
|
|
13
|
+
// RUMBLE_API_KEY
|
|
14
|
+
// KICK_CHANNEL_SLUG (for browser-based fallback)
|
|
15
|
+
import { registerTool } from './index.js';
|
|
16
|
+
// ─── Constants ─────────────────────────────────────────────────
|
|
17
|
+
const TWITCH_API = 'https://api.twitch.tv/helix';
|
|
18
|
+
const KICK_API = 'https://kick.com/api/v2';
|
|
19
|
+
const RUMBLE_API = 'https://rumble.com/-livestream-api/get-data';
|
|
20
|
+
// ─── Twitch Helpers ────────────────────────────────────────────
|
|
21
|
+
function twitchHeaders() {
|
|
22
|
+
const token = process.env.TWITCH_OAUTH_TOKEN;
|
|
23
|
+
const clientId = process.env.TWITCH_CLIENT_ID;
|
|
24
|
+
if (!token || !clientId) {
|
|
25
|
+
throw new Error('Twitch OAuth not configured. Set TWITCH_OAUTH_TOKEN and TWITCH_CLIENT_ID.\n' +
|
|
26
|
+
'Run stream_setup_oauth for instructions.');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
'Authorization': `Bearer ${token}`,
|
|
30
|
+
'Client-Id': clientId,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function getBroadcasterId() {
|
|
35
|
+
const id = process.env.TWITCH_BROADCASTER_ID;
|
|
36
|
+
if (!id) {
|
|
37
|
+
throw new Error('TWITCH_BROADCASTER_ID not set. Find yours at: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/');
|
|
38
|
+
}
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
async function twitchGet(endpoint) {
|
|
42
|
+
const res = await fetch(`${TWITCH_API}${endpoint}`, { headers: twitchHeaders() });
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const body = await res.text();
|
|
45
|
+
throw new Error(`Twitch API ${res.status}: ${body}`);
|
|
46
|
+
}
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
async function twitchPatch(endpoint, body) {
|
|
50
|
+
const res = await fetch(`${TWITCH_API}${endpoint}`, {
|
|
51
|
+
method: 'PATCH',
|
|
52
|
+
headers: twitchHeaders(),
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
throw new Error(`Twitch API ${res.status}: ${text}`);
|
|
58
|
+
}
|
|
59
|
+
// 204 No Content is a success response for PATCH
|
|
60
|
+
if (res.status === 204)
|
|
61
|
+
return { ok: true };
|
|
62
|
+
const ct = res.headers.get('content-type') || '';
|
|
63
|
+
if (ct.includes('application/json'))
|
|
64
|
+
return res.json();
|
|
65
|
+
return { ok: true };
|
|
66
|
+
}
|
|
67
|
+
async function twitchPost(endpoint, body) {
|
|
68
|
+
const res = await fetch(`${TWITCH_API}${endpoint}`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: twitchHeaders(),
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
throw new Error(`Twitch API ${res.status}: ${text}`);
|
|
76
|
+
}
|
|
77
|
+
if (res.status === 204)
|
|
78
|
+
return { ok: true };
|
|
79
|
+
return res.json();
|
|
80
|
+
}
|
|
81
|
+
// ─── Rumble Helpers ────────────────────────────────────────────
|
|
82
|
+
async function rumbleGetData() {
|
|
83
|
+
const key = process.env.RUMBLE_API_KEY;
|
|
84
|
+
if (!key)
|
|
85
|
+
throw new Error('RUMBLE_API_KEY not set.');
|
|
86
|
+
const res = await fetch(`${RUMBLE_API}?key=${encodeURIComponent(key)}`);
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
throw new Error(`Rumble API ${res.status}: ${await res.text()}`);
|
|
89
|
+
return res.json();
|
|
90
|
+
}
|
|
91
|
+
// ─── Kick Helpers ──────────────────────────────────────────────
|
|
92
|
+
async function kickGetChannel() {
|
|
93
|
+
const slug = process.env.KICK_CHANNEL_SLUG;
|
|
94
|
+
if (!slug)
|
|
95
|
+
throw new Error('KICK_CHANNEL_SLUG not set.');
|
|
96
|
+
const res = await fetch(`${KICK_API}/channels/${encodeURIComponent(slug)}`, {
|
|
97
|
+
headers: { 'Accept': 'application/json' },
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok)
|
|
100
|
+
throw new Error(`Kick API ${res.status}: ${await res.text()}`);
|
|
101
|
+
return res.json();
|
|
102
|
+
}
|
|
103
|
+
// ─── Utility ───────────────────────────────────────────────────
|
|
104
|
+
function hasTwitch() {
|
|
105
|
+
return !!(process.env.TWITCH_OAUTH_TOKEN && process.env.TWITCH_CLIENT_ID);
|
|
106
|
+
}
|
|
107
|
+
function hasRumble() {
|
|
108
|
+
return !!process.env.RUMBLE_API_KEY;
|
|
109
|
+
}
|
|
110
|
+
function hasKick() {
|
|
111
|
+
return !!process.env.KICK_CHANNEL_SLUG;
|
|
112
|
+
}
|
|
113
|
+
function formatUptime(startedAt) {
|
|
114
|
+
const start = new Date(startedAt);
|
|
115
|
+
const now = new Date();
|
|
116
|
+
const diff = now.getTime() - start.getTime();
|
|
117
|
+
const hours = Math.floor(diff / 3_600_000);
|
|
118
|
+
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
|
119
|
+
return `${hours}h ${minutes}m`;
|
|
120
|
+
}
|
|
121
|
+
function noPlatforms() {
|
|
122
|
+
return ('No streaming platforms configured. Set at least one:\n' +
|
|
123
|
+
' TWITCH_CLIENT_ID + TWITCH_OAUTH_TOKEN + TWITCH_BROADCASTER_ID (Twitch)\n' +
|
|
124
|
+
' RUMBLE_API_KEY (Rumble)\n' +
|
|
125
|
+
' KICK_CHANNEL_SLUG (Kick)\n\n' +
|
|
126
|
+
'Run stream_setup_oauth for detailed instructions.');
|
|
127
|
+
}
|
|
128
|
+
// ─── Registration ──────────────────────────────────────────────
|
|
129
|
+
export function registerStreamControlTools() {
|
|
130
|
+
// ── stream_title ──
|
|
131
|
+
registerTool({
|
|
132
|
+
name: 'stream_title',
|
|
133
|
+
description: 'Update the stream title on Twitch. Optionally update the category/game at the same time. Kick dashboard changes require browser (noted in output).',
|
|
134
|
+
parameters: {
|
|
135
|
+
title: { type: 'string', description: 'New stream title (max 140 chars for Twitch)', required: true },
|
|
136
|
+
category: { type: 'string', description: 'Optional: category/game name to set alongside the title' },
|
|
137
|
+
},
|
|
138
|
+
tier: 'free',
|
|
139
|
+
execute: async (args) => {
|
|
140
|
+
const title = String(args.title || '').slice(0, 140);
|
|
141
|
+
if (!title)
|
|
142
|
+
return 'Error: title is required.';
|
|
143
|
+
const results = [];
|
|
144
|
+
// Twitch
|
|
145
|
+
if (hasTwitch()) {
|
|
146
|
+
try {
|
|
147
|
+
const broadcasterId = getBroadcasterId();
|
|
148
|
+
const body = { title };
|
|
149
|
+
// If category provided, search for game_id first
|
|
150
|
+
if (args.category) {
|
|
151
|
+
const search = await twitchGet(`/search/categories?query=${encodeURIComponent(String(args.category))}&first=1`);
|
|
152
|
+
if (search.data?.length > 0) {
|
|
153
|
+
body.game_id = search.data[0].id;
|
|
154
|
+
results.push(`Twitch: title set to "${title}", category set to "${search.data[0].name}" (id: ${search.data[0].id})`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
results.push(`Twitch: title set to "${title}" (category "${args.category}" not found, skipped)`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
results.push(`Twitch: title set to "${title}"`);
|
|
162
|
+
}
|
|
163
|
+
await twitchPatch(`/channels?broadcaster_id=${broadcasterId}`, body);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
results.push(`Twitch: ${e.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Kick — no direct API for title changes
|
|
170
|
+
if (hasKick()) {
|
|
171
|
+
results.push('Kick: title changes require the dashboard (dashboard.kick.com/Stream). Use computer-use tools or kbot_browse to navigate there.');
|
|
172
|
+
}
|
|
173
|
+
// Rumble — read-only API
|
|
174
|
+
if (hasRumble()) {
|
|
175
|
+
results.push('Rumble: title changes not available via API. Use the Rumble Studio dashboard.');
|
|
176
|
+
}
|
|
177
|
+
if (results.length === 0)
|
|
178
|
+
return noPlatforms();
|
|
179
|
+
return results.join('\n');
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
// ── stream_category ──
|
|
183
|
+
registerTool({
|
|
184
|
+
name: 'stream_category',
|
|
185
|
+
description: 'Change the stream category/game on Twitch. Searches Twitch categories by name and applies the best match.',
|
|
186
|
+
parameters: {
|
|
187
|
+
category: { type: 'string', description: 'Category/game name to search for and set', required: true },
|
|
188
|
+
},
|
|
189
|
+
tier: 'free',
|
|
190
|
+
execute: async (args) => {
|
|
191
|
+
const query = String(args.category || '');
|
|
192
|
+
if (!query)
|
|
193
|
+
return 'Error: category is required.';
|
|
194
|
+
if (!hasTwitch())
|
|
195
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
196
|
+
try {
|
|
197
|
+
const broadcasterId = getBroadcasterId();
|
|
198
|
+
const search = await twitchGet(`/search/categories?query=${encodeURIComponent(query)}&first=5`);
|
|
199
|
+
if (!search.data?.length) {
|
|
200
|
+
return `No categories found for "${query}". Try a different search term.`;
|
|
201
|
+
}
|
|
202
|
+
// Use the first match
|
|
203
|
+
const cat = search.data[0];
|
|
204
|
+
await twitchPatch(`/channels?broadcaster_id=${broadcasterId}`, { game_id: cat.id });
|
|
205
|
+
const alternatives = search.data.slice(1, 5).map((c) => ` - ${c.name} (id: ${c.id})`).join('\n');
|
|
206
|
+
let result = `Category set to: ${cat.name} (id: ${cat.id})`;
|
|
207
|
+
if (alternatives)
|
|
208
|
+
result += `\n\nOther matches:\n${alternatives}`;
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
return `Error: ${e.message}`;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
// ── stream_info ──
|
|
217
|
+
registerTool({
|
|
218
|
+
name: 'stream_info',
|
|
219
|
+
description: 'Get current stream info from Twitch (viewers, uptime, title, category) and Rumble (viewers, status). Shows whether you are live on each platform.',
|
|
220
|
+
parameters: {
|
|
221
|
+
platform: { type: 'string', description: 'Specific platform: "twitch", "kick", "rumble". Default: all configured' },
|
|
222
|
+
},
|
|
223
|
+
tier: 'free',
|
|
224
|
+
execute: async (args) => {
|
|
225
|
+
const platform = args.platform ? String(args.platform).toLowerCase() : 'all';
|
|
226
|
+
const results = [];
|
|
227
|
+
// Twitch
|
|
228
|
+
if ((platform === 'all' || platform === 'twitch') && hasTwitch()) {
|
|
229
|
+
try {
|
|
230
|
+
const broadcasterId = getBroadcasterId();
|
|
231
|
+
const streams = await twitchGet(`/streams?user_id=${broadcasterId}`);
|
|
232
|
+
const channelInfo = await twitchGet(`/channels?broadcaster_id=${broadcasterId}`);
|
|
233
|
+
const channel = channelInfo.data?.[0];
|
|
234
|
+
if (streams.data?.length > 0) {
|
|
235
|
+
const s = streams.data[0];
|
|
236
|
+
results.push(`TWITCH [LIVE]\n` +
|
|
237
|
+
` Title: ${s.title}\n` +
|
|
238
|
+
` Category: ${s.game_name}\n` +
|
|
239
|
+
` Viewers: ${s.viewer_count.toLocaleString()}\n` +
|
|
240
|
+
` Uptime: ${formatUptime(s.started_at)}\n` +
|
|
241
|
+
` Language: ${s.language}`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
results.push(`TWITCH [OFFLINE]\n` +
|
|
245
|
+
` Title: ${channel?.title || 'N/A'}\n` +
|
|
246
|
+
` Category: ${channel?.game_name || 'N/A'}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
results.push(`TWITCH: Error — ${e.message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Rumble
|
|
254
|
+
if ((platform === 'all' || platform === 'rumble') && hasRumble()) {
|
|
255
|
+
try {
|
|
256
|
+
const data = await rumbleGetData();
|
|
257
|
+
if (data.is_live) {
|
|
258
|
+
results.push(`RUMBLE [LIVE]\n` +
|
|
259
|
+
` Viewers: ${(data.viewers || 0).toLocaleString()}\n` +
|
|
260
|
+
` Chat count: ${(data.chat_messages || 0).toLocaleString()}`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
results.push('RUMBLE [OFFLINE]');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
results.push(`RUMBLE: Error — ${e.message}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Kick
|
|
271
|
+
if ((platform === 'all' || platform === 'kick') && hasKick()) {
|
|
272
|
+
try {
|
|
273
|
+
const data = await kickGetChannel();
|
|
274
|
+
const livestream = data.livestream;
|
|
275
|
+
if (livestream && livestream.is_live) {
|
|
276
|
+
results.push(`KICK [LIVE]\n` +
|
|
277
|
+
` Title: ${livestream.session_title || 'N/A'}\n` +
|
|
278
|
+
` Category: ${livestream.categories?.[0]?.name || 'N/A'}\n` +
|
|
279
|
+
` Viewers: ${(livestream.viewer_count || 0).toLocaleString()}`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
results.push(`KICK [OFFLINE]\n` +
|
|
283
|
+
` Channel: ${data.slug || process.env.KICK_CHANNEL_SLUG}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
results.push(`KICK: Error — ${e.message}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (results.length === 0)
|
|
291
|
+
return noPlatforms();
|
|
292
|
+
return results.join('\n\n');
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
// ── stream_viewers ──
|
|
296
|
+
registerTool({
|
|
297
|
+
name: 'stream_viewers',
|
|
298
|
+
description: 'Get viewer count across all configured streaming platforms (Twitch, Kick, Rumble). Returns per-platform counts and a combined total.',
|
|
299
|
+
parameters: {},
|
|
300
|
+
tier: 'free',
|
|
301
|
+
execute: async () => {
|
|
302
|
+
const counts = [];
|
|
303
|
+
// Twitch
|
|
304
|
+
if (hasTwitch()) {
|
|
305
|
+
try {
|
|
306
|
+
const broadcasterId = getBroadcasterId();
|
|
307
|
+
const streams = await twitchGet(`/streams?user_id=${broadcasterId}`);
|
|
308
|
+
if (streams.data?.length > 0) {
|
|
309
|
+
counts.push({ platform: 'Twitch', viewers: streams.data[0].viewer_count, live: true });
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
counts.push({ platform: 'Twitch', viewers: 0, live: false });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
counts.push({ platform: 'Twitch', viewers: 0, live: false });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Rumble
|
|
320
|
+
if (hasRumble()) {
|
|
321
|
+
try {
|
|
322
|
+
const data = await rumbleGetData();
|
|
323
|
+
counts.push({ platform: 'Rumble', viewers: data.viewers || 0, live: !!data.is_live });
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
counts.push({ platform: 'Rumble', viewers: 0, live: false });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Kick
|
|
330
|
+
if (hasKick()) {
|
|
331
|
+
try {
|
|
332
|
+
const data = await kickGetChannel();
|
|
333
|
+
const live = data.livestream?.is_live || false;
|
|
334
|
+
counts.push({ platform: 'Kick', viewers: live ? (data.livestream?.viewer_count || 0) : 0, live });
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
counts.push({ platform: 'Kick', viewers: 0, live: false });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (counts.length === 0)
|
|
341
|
+
return noPlatforms();
|
|
342
|
+
const total = counts.reduce((sum, c) => sum + c.viewers, 0);
|
|
343
|
+
const lines = counts.map(c => ` ${c.platform.padEnd(8)} ${c.live ? 'LIVE' : 'OFF '} ${c.viewers.toLocaleString().padStart(8)} viewers`);
|
|
344
|
+
lines.push(` ${'TOTAL'.padEnd(8)} ${total.toLocaleString().padStart(8)} viewers`);
|
|
345
|
+
return `Viewer counts:\n${lines.join('\n')}`;
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
// ── stream_chat_settings ──
|
|
349
|
+
registerTool({
|
|
350
|
+
name: 'stream_chat_settings',
|
|
351
|
+
description: 'Manage Twitch chat settings: slow mode, subscriber-only mode, emote-only mode, follower-only mode. Pass the settings you want to change.',
|
|
352
|
+
parameters: {
|
|
353
|
+
slow_mode: { type: 'string', description: '"on" or "off" — enable/disable slow mode' },
|
|
354
|
+
slow_mode_wait: { type: 'string', description: 'Seconds between messages in slow mode (3-120). Default: 10' },
|
|
355
|
+
sub_only: { type: 'string', description: '"on" or "off" — subscriber-only mode' },
|
|
356
|
+
emote_only: { type: 'string', description: '"on" or "off" — emote-only mode' },
|
|
357
|
+
follower_only: { type: 'string', description: '"on" or "off" — follower-only mode' },
|
|
358
|
+
follower_only_minutes: { type: 'string', description: 'Minutes a user must follow before chatting (0-129600). Default: 10' },
|
|
359
|
+
},
|
|
360
|
+
tier: 'free',
|
|
361
|
+
execute: async (args) => {
|
|
362
|
+
if (!hasTwitch())
|
|
363
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
364
|
+
try {
|
|
365
|
+
const broadcasterId = getBroadcasterId();
|
|
366
|
+
const body = {};
|
|
367
|
+
if (args.slow_mode !== undefined) {
|
|
368
|
+
body.slow_mode = args.slow_mode === 'on';
|
|
369
|
+
if (body.slow_mode && args.slow_mode_wait) {
|
|
370
|
+
body.slow_mode_wait_time = Math.max(3, Math.min(120, parseInt(String(args.slow_mode_wait), 10) || 10));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (args.sub_only !== undefined)
|
|
374
|
+
body.subscriber_mode = args.sub_only === 'on';
|
|
375
|
+
if (args.emote_only !== undefined)
|
|
376
|
+
body.emote_mode = args.emote_only === 'on';
|
|
377
|
+
if (args.follower_only !== undefined) {
|
|
378
|
+
body.follower_mode = args.follower_only === 'on';
|
|
379
|
+
if (body.follower_mode && args.follower_only_minutes) {
|
|
380
|
+
body.follower_mode_duration = Math.max(0, Math.min(129600, parseInt(String(args.follower_only_minutes), 10) || 10));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (Object.keys(body).length === 0) {
|
|
384
|
+
return 'No settings provided. Available: slow_mode, sub_only, emote_only, follower_only';
|
|
385
|
+
}
|
|
386
|
+
// moderator_id = broadcaster_id when the broadcaster is the moderator
|
|
387
|
+
await twitchPatch(`/chat/settings?broadcaster_id=${broadcasterId}&moderator_id=${broadcasterId}`, body);
|
|
388
|
+
const applied = Object.entries(body)
|
|
389
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
390
|
+
.join('\n');
|
|
391
|
+
return `Chat settings updated:\n${applied}`;
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
return `Error: ${e.message}`;
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
// ── stream_clip ──
|
|
399
|
+
registerTool({
|
|
400
|
+
name: 'stream_clip',
|
|
401
|
+
description: 'Create a clip of the current Twitch stream. The stream must be live. Returns the clip edit URL.',
|
|
402
|
+
parameters: {
|
|
403
|
+
has_delay: { type: 'string', description: '"true" if the stream has a delay (captures from the delay buffer). Default: false' },
|
|
404
|
+
},
|
|
405
|
+
tier: 'free',
|
|
406
|
+
execute: async (args) => {
|
|
407
|
+
if (!hasTwitch())
|
|
408
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
409
|
+
try {
|
|
410
|
+
const broadcasterId = getBroadcasterId();
|
|
411
|
+
// Verify stream is live
|
|
412
|
+
const streams = await twitchGet(`/streams?user_id=${broadcasterId}`);
|
|
413
|
+
if (!streams.data?.length) {
|
|
414
|
+
return 'Cannot create clip — stream is not live.';
|
|
415
|
+
}
|
|
416
|
+
const body = { broadcaster_id: broadcasterId };
|
|
417
|
+
if (args.has_delay === 'true')
|
|
418
|
+
body.has_delay = true;
|
|
419
|
+
const result = await twitchPost('/clips', body);
|
|
420
|
+
const clip = result.data?.[0];
|
|
421
|
+
if (clip) {
|
|
422
|
+
return `Clip created!\n Edit URL: ${clip.edit_url}\n ID: ${clip.id}\n\nNote: It takes ~15 seconds for the clip to be processed.`;
|
|
423
|
+
}
|
|
424
|
+
return 'Clip creation returned no data. The stream may not be clippable.';
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
return `Error: ${e.message}`;
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
// ── stream_marker ──
|
|
432
|
+
registerTool({
|
|
433
|
+
name: 'stream_marker',
|
|
434
|
+
description: 'Add a stream marker (bookmark) on the current Twitch stream. Useful for marking interesting moments to highlight later. Stream must be live.',
|
|
435
|
+
parameters: {
|
|
436
|
+
description: { type: 'string', description: 'Description of the marker (max 140 chars). Default: "Marked by kbot"' },
|
|
437
|
+
},
|
|
438
|
+
tier: 'free',
|
|
439
|
+
execute: async (args) => {
|
|
440
|
+
if (!hasTwitch())
|
|
441
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
442
|
+
try {
|
|
443
|
+
const broadcasterId = getBroadcasterId();
|
|
444
|
+
const description = String(args.description || 'Marked by kbot').slice(0, 140);
|
|
445
|
+
const result = await twitchPost('/streams/markers', {
|
|
446
|
+
user_id: broadcasterId,
|
|
447
|
+
description,
|
|
448
|
+
});
|
|
449
|
+
const marker = result.data?.[0];
|
|
450
|
+
if (marker) {
|
|
451
|
+
return `Stream marker added at ${marker.position_seconds}s: "${description}"\n ID: ${marker.id}\n Created: ${marker.created_at}`;
|
|
452
|
+
}
|
|
453
|
+
return 'Marker created (no position data returned). Stream must be live for markers to work.';
|
|
454
|
+
}
|
|
455
|
+
catch (e) {
|
|
456
|
+
if (e.message.includes('404'))
|
|
457
|
+
return 'Cannot add marker — stream is not live.';
|
|
458
|
+
return `Error: ${e.message}`;
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
// ── stream_followers ──
|
|
463
|
+
registerTool({
|
|
464
|
+
name: 'stream_followers',
|
|
465
|
+
description: 'Get follower count and recent followers from Twitch. Shows total count and the latest followers with follow dates.',
|
|
466
|
+
parameters: {
|
|
467
|
+
count: { type: 'string', description: 'Number of recent followers to show (1-100). Default: 10' },
|
|
468
|
+
},
|
|
469
|
+
tier: 'free',
|
|
470
|
+
execute: async (args) => {
|
|
471
|
+
if (!hasTwitch())
|
|
472
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
473
|
+
try {
|
|
474
|
+
const broadcasterId = getBroadcasterId();
|
|
475
|
+
const first = Math.max(1, Math.min(100, parseInt(String(args.count || '10'), 10) || 10));
|
|
476
|
+
const result = await twitchGet(`/channels/followers?broadcaster_id=${broadcasterId}&first=${first}`);
|
|
477
|
+
const total = result.total || 0;
|
|
478
|
+
const followers = (result.data || []).map((f) => {
|
|
479
|
+
const date = new Date(f.followed_at).toLocaleDateString();
|
|
480
|
+
return ` ${f.user_name.padEnd(25)} followed ${date}`;
|
|
481
|
+
});
|
|
482
|
+
let output = `Total followers: ${total.toLocaleString()}\n`;
|
|
483
|
+
if (followers.length > 0) {
|
|
484
|
+
output += `\nRecent followers:\n${followers.join('\n')}`;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
output += '\nNo recent followers found.';
|
|
488
|
+
}
|
|
489
|
+
return output;
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
return `Error: ${e.message}`;
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
// ── stream_chat_send ──
|
|
497
|
+
registerTool({
|
|
498
|
+
name: 'stream_chat_send',
|
|
499
|
+
description: 'Send a message to Twitch chat as the authenticated user/bot. Requires the chat:edit scope on the OAuth token.',
|
|
500
|
+
parameters: {
|
|
501
|
+
message: { type: 'string', description: 'Message to send to chat (max 500 chars)', required: true },
|
|
502
|
+
},
|
|
503
|
+
tier: 'free',
|
|
504
|
+
execute: async (args) => {
|
|
505
|
+
const message = String(args.message || '').slice(0, 500);
|
|
506
|
+
if (!message)
|
|
507
|
+
return 'Error: message is required.';
|
|
508
|
+
if (!hasTwitch())
|
|
509
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
510
|
+
try {
|
|
511
|
+
const broadcasterId = getBroadcasterId();
|
|
512
|
+
await twitchPost('/chat/messages', {
|
|
513
|
+
broadcaster_id: broadcasterId,
|
|
514
|
+
sender_id: broadcasterId,
|
|
515
|
+
message,
|
|
516
|
+
});
|
|
517
|
+
return `Message sent to Twitch chat: "${message}"`;
|
|
518
|
+
}
|
|
519
|
+
catch (e) {
|
|
520
|
+
return `Error: ${e.message}`;
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
// ── stream_ban ──
|
|
525
|
+
registerTool({
|
|
526
|
+
name: 'stream_ban',
|
|
527
|
+
description: 'Ban or timeout a user from Twitch chat. Omit duration for a permanent ban. Set duration in seconds for a timeout.',
|
|
528
|
+
parameters: {
|
|
529
|
+
user_id: { type: 'string', description: 'Twitch user ID to ban. Use stream_followers or Twitch API to look up by username.', required: true },
|
|
530
|
+
duration: { type: 'string', description: 'Timeout duration in seconds (1-1209600). Omit for permanent ban.' },
|
|
531
|
+
reason: { type: 'string', description: 'Reason for the ban/timeout. Default: "Banned by kbot"' },
|
|
532
|
+
},
|
|
533
|
+
tier: 'free',
|
|
534
|
+
execute: async (args) => {
|
|
535
|
+
const userId = String(args.user_id || '');
|
|
536
|
+
if (!userId)
|
|
537
|
+
return 'Error: user_id is required.';
|
|
538
|
+
if (!hasTwitch())
|
|
539
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
540
|
+
try {
|
|
541
|
+
const broadcasterId = getBroadcasterId();
|
|
542
|
+
const banData = {
|
|
543
|
+
user_id: userId,
|
|
544
|
+
reason: String(args.reason || 'Banned by kbot'),
|
|
545
|
+
};
|
|
546
|
+
if (args.duration) {
|
|
547
|
+
const dur = Math.max(1, Math.min(1_209_600, parseInt(String(args.duration), 10) || 600));
|
|
548
|
+
banData.duration = dur;
|
|
549
|
+
}
|
|
550
|
+
await twitchPost(`/moderation/bans?broadcaster_id=${broadcasterId}&moderator_id=${broadcasterId}`, {
|
|
551
|
+
data: banData,
|
|
552
|
+
});
|
|
553
|
+
const action = args.duration ? `timed out for ${args.duration}s` : 'permanently banned';
|
|
554
|
+
return `User ${userId} ${action}. Reason: ${banData.reason}`;
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
return `Error: ${e.message}`;
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
// ── stream_announce ──
|
|
562
|
+
registerTool({
|
|
563
|
+
name: 'stream_announce',
|
|
564
|
+
description: 'Send an announcement to Twitch chat. Announcements appear highlighted in chat. Requires moderator:manage:chat_settings scope.',
|
|
565
|
+
parameters: {
|
|
566
|
+
message: { type: 'string', description: 'Announcement message', required: true },
|
|
567
|
+
color: { type: 'string', description: 'Announcement color: "primary" (default), "blue", "green", "orange", "purple"' },
|
|
568
|
+
},
|
|
569
|
+
tier: 'free',
|
|
570
|
+
execute: async (args) => {
|
|
571
|
+
const message = String(args.message || '');
|
|
572
|
+
if (!message)
|
|
573
|
+
return 'Error: message is required.';
|
|
574
|
+
if (!hasTwitch())
|
|
575
|
+
return 'Twitch not configured. Set TWITCH_CLIENT_ID, TWITCH_OAUTH_TOKEN, and TWITCH_BROADCASTER_ID.';
|
|
576
|
+
try {
|
|
577
|
+
const broadcasterId = getBroadcasterId();
|
|
578
|
+
const validColors = ['primary', 'blue', 'green', 'orange', 'purple'];
|
|
579
|
+
const color = validColors.includes(String(args.color || '')) ? String(args.color) : 'primary';
|
|
580
|
+
await twitchPost(`/chat/announcements?broadcaster_id=${broadcasterId}&moderator_id=${broadcasterId}`, { message, color });
|
|
581
|
+
return `Announcement sent (${color}): "${message}"`;
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
return `Error: ${e.message}`;
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
// ── stream_dashboard ──
|
|
589
|
+
registerTool({
|
|
590
|
+
name: 'stream_dashboard',
|
|
591
|
+
description: 'Get a unified dashboard view across all configured streaming platforms. Shows live status, viewers, title, category, uptime, and follower count from Twitch, Kick, and Rumble.',
|
|
592
|
+
parameters: {},
|
|
593
|
+
tier: 'free',
|
|
594
|
+
execute: async () => {
|
|
595
|
+
const sections = [];
|
|
596
|
+
const configured = [hasTwitch() && 'Twitch', hasRumble() && 'Rumble', hasKick() && 'Kick'].filter(Boolean);
|
|
597
|
+
if (configured.length === 0)
|
|
598
|
+
return noPlatforms();
|
|
599
|
+
sections.push(`STREAM DASHBOARD — ${new Date().toLocaleString()}`);
|
|
600
|
+
sections.push('═'.repeat(50));
|
|
601
|
+
// Twitch
|
|
602
|
+
if (hasTwitch()) {
|
|
603
|
+
try {
|
|
604
|
+
const broadcasterId = getBroadcasterId();
|
|
605
|
+
const [streams, channelInfo, followers] = await Promise.all([
|
|
606
|
+
twitchGet(`/streams?user_id=${broadcasterId}`),
|
|
607
|
+
twitchGet(`/channels?broadcaster_id=${broadcasterId}`),
|
|
608
|
+
twitchGet(`/channels/followers?broadcaster_id=${broadcasterId}&first=1`),
|
|
609
|
+
]);
|
|
610
|
+
const channel = channelInfo.data?.[0];
|
|
611
|
+
const isLive = streams.data?.length > 0;
|
|
612
|
+
const stream = isLive ? streams.data[0] : null;
|
|
613
|
+
sections.push(`\nTWITCH ${isLive ? '🔴 LIVE' : '⚫ OFFLINE'}` +
|
|
614
|
+
`\n Title: ${stream?.title || channel?.title || 'N/A'}` +
|
|
615
|
+
`\n Category: ${stream?.game_name || channel?.game_name || 'N/A'}` +
|
|
616
|
+
(isLive ? `\n Viewers: ${stream.viewer_count.toLocaleString()}` : '') +
|
|
617
|
+
(isLive ? `\n Uptime: ${formatUptime(stream.started_at)}` : '') +
|
|
618
|
+
`\n Followers: ${(followers.total || 0).toLocaleString()}`);
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
sections.push(`\nTWITCH: Error — ${e.message}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// Kick
|
|
625
|
+
if (hasKick()) {
|
|
626
|
+
try {
|
|
627
|
+
const data = await kickGetChannel();
|
|
628
|
+
const livestream = data.livestream;
|
|
629
|
+
const isLive = livestream?.is_live || false;
|
|
630
|
+
sections.push(`\nKICK ${isLive ? '🔴 LIVE' : '⚫ OFFLINE'}` +
|
|
631
|
+
`\n Channel: ${data.slug || process.env.KICK_CHANNEL_SLUG}` +
|
|
632
|
+
(isLive ? `\n Title: ${livestream.session_title || 'N/A'}` : '') +
|
|
633
|
+
(isLive ? `\n Category: ${livestream.categories?.[0]?.name || 'N/A'}` : '') +
|
|
634
|
+
(isLive ? `\n Viewers: ${(livestream.viewer_count || 0).toLocaleString()}` : '') +
|
|
635
|
+
`\n Followers: ${(data.followers_count || 0).toLocaleString()}`);
|
|
636
|
+
}
|
|
637
|
+
catch (e) {
|
|
638
|
+
sections.push(`\nKICK: Error — ${e.message}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Rumble
|
|
642
|
+
if (hasRumble()) {
|
|
643
|
+
try {
|
|
644
|
+
const data = await rumbleGetData();
|
|
645
|
+
const isLive = !!data.is_live;
|
|
646
|
+
sections.push(`\nRUMBLE ${isLive ? '🔴 LIVE' : '⚫ OFFLINE'}` +
|
|
647
|
+
(isLive ? `\n Viewers: ${(data.viewers || 0).toLocaleString()}` : '') +
|
|
648
|
+
(isLive ? `\n Chat msgs: ${(data.chat_messages || 0).toLocaleString()}` : '') +
|
|
649
|
+
(data.followers !== undefined ? `\n Followers: ${(data.followers || 0).toLocaleString()}` : ''));
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
sections.push(`\nRUMBLE: Error — ${e.message}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Combined stats
|
|
656
|
+
let totalViewers = 0;
|
|
657
|
+
if (hasTwitch()) {
|
|
658
|
+
try {
|
|
659
|
+
const broadcasterId = getBroadcasterId();
|
|
660
|
+
const s = await twitchGet(`/streams?user_id=${broadcasterId}`);
|
|
661
|
+
if (s.data?.length)
|
|
662
|
+
totalViewers += s.data[0].viewer_count;
|
|
663
|
+
}
|
|
664
|
+
catch { /* skip */ }
|
|
665
|
+
}
|
|
666
|
+
if (hasRumble()) {
|
|
667
|
+
try {
|
|
668
|
+
const d = await rumbleGetData();
|
|
669
|
+
if (d.is_live)
|
|
670
|
+
totalViewers += d.viewers || 0;
|
|
671
|
+
}
|
|
672
|
+
catch { /* skip */ }
|
|
673
|
+
}
|
|
674
|
+
if (hasKick()) {
|
|
675
|
+
try {
|
|
676
|
+
const d = await kickGetChannel();
|
|
677
|
+
if (d.livestream?.is_live)
|
|
678
|
+
totalViewers += d.livestream.viewer_count || 0;
|
|
679
|
+
}
|
|
680
|
+
catch { /* skip */ }
|
|
681
|
+
}
|
|
682
|
+
sections.push(`\n${'═'.repeat(50)}`);
|
|
683
|
+
sections.push(`Combined viewers: ${totalViewers.toLocaleString()}`);
|
|
684
|
+
sections.push(`Platforms: ${configured.join(', ')}`);
|
|
685
|
+
return sections.join('\n');
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
// ── stream_setup_oauth ──
|
|
689
|
+
registerTool({
|
|
690
|
+
name: 'stream_setup_oauth',
|
|
691
|
+
description: 'Show instructions for setting up OAuth tokens for Twitch, Kick, and Rumble streaming platform control. Does not execute anything — just shows the setup guide.',
|
|
692
|
+
parameters: {
|
|
693
|
+
platform: { type: 'string', description: 'Specific platform to show setup for: "twitch", "kick", "rumble". Default: all' },
|
|
694
|
+
},
|
|
695
|
+
tier: 'free',
|
|
696
|
+
execute: async (args) => {
|
|
697
|
+
const platform = args.platform ? String(args.platform).toLowerCase() : 'all';
|
|
698
|
+
const sections = [];
|
|
699
|
+
sections.push('STREAM CONTROL — OAUTH SETUP GUIDE');
|
|
700
|
+
sections.push('═'.repeat(50));
|
|
701
|
+
if (platform === 'all' || platform === 'twitch') {
|
|
702
|
+
const scopes = [
|
|
703
|
+
'channel:manage:broadcast',
|
|
704
|
+
'chat:edit',
|
|
705
|
+
'chat:read',
|
|
706
|
+
'moderator:manage:chat_settings',
|
|
707
|
+
'clips:edit',
|
|
708
|
+
'channel:read:stream_key',
|
|
709
|
+
'moderator:manage:banned_users',
|
|
710
|
+
'moderator:manage:announcements',
|
|
711
|
+
'channel:read:editors',
|
|
712
|
+
].join('+');
|
|
713
|
+
sections.push(`
|
|
714
|
+
TWITCH (Helix API)
|
|
715
|
+
──────────────────
|
|
716
|
+
1. Go to: https://dev.twitch.tv/console/apps
|
|
717
|
+
2. Click "Register Your Application"
|
|
718
|
+
3. Name: "kbot" (or anything)
|
|
719
|
+
4. OAuth Redirect URL: http://localhost
|
|
720
|
+
5. Category: Chat Bot
|
|
721
|
+
6. Click "Create" — note the Client ID
|
|
722
|
+
|
|
723
|
+
7. Generate an OAuth token:
|
|
724
|
+
Open this URL in your browser (replace YOUR_CLIENT_ID):
|
|
725
|
+
|
|
726
|
+
https://id.twitch.tv/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost&response_type=token&scope=${scopes}
|
|
727
|
+
|
|
728
|
+
8. After authorizing, you'll be redirected to:
|
|
729
|
+
http://localhost/#access_token=YOUR_TOKEN&...
|
|
730
|
+
Copy the access_token value.
|
|
731
|
+
|
|
732
|
+
9. Find your broadcaster ID:
|
|
733
|
+
https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
|
|
734
|
+
|
|
735
|
+
10. Set environment variables:
|
|
736
|
+
export TWITCH_CLIENT_ID="your_client_id"
|
|
737
|
+
export TWITCH_OAUTH_TOKEN="your_access_token"
|
|
738
|
+
export TWITCH_BROADCASTER_ID="your_user_id"
|
|
739
|
+
|
|
740
|
+
Required scopes:
|
|
741
|
+
channel:manage:broadcast — update title/category
|
|
742
|
+
chat:edit — send chat messages
|
|
743
|
+
chat:read — read chat
|
|
744
|
+
moderator:manage:chat_settings — slow mode, sub-only, etc.
|
|
745
|
+
clips:edit — create clips
|
|
746
|
+
moderator:manage:banned_users — ban/timeout users
|
|
747
|
+
moderator:manage:announcements — send announcements`);
|
|
748
|
+
}
|
|
749
|
+
if (platform === 'all' || platform === 'kick') {
|
|
750
|
+
sections.push(`
|
|
751
|
+
KICK
|
|
752
|
+
────
|
|
753
|
+
Kick has a limited public API. For most dashboard actions, kbot uses
|
|
754
|
+
its built-in browser (kbot_browse) or computer-use tools to navigate
|
|
755
|
+
dashboard.kick.com.
|
|
756
|
+
|
|
757
|
+
For basic channel info via API:
|
|
758
|
+
export KICK_CHANNEL_SLUG="your_channel_name"
|
|
759
|
+
|
|
760
|
+
For full dashboard control, use kbot with --computer-use flag:
|
|
761
|
+
kbot --computer-use "update my Kick stream title"`);
|
|
762
|
+
}
|
|
763
|
+
if (platform === 'all' || platform === 'rumble') {
|
|
764
|
+
sections.push(`
|
|
765
|
+
RUMBLE
|
|
766
|
+
──────
|
|
767
|
+
Rumble provides a livestream API key for reading stream data.
|
|
768
|
+
|
|
769
|
+
1. Go to your Rumble account settings
|
|
770
|
+
2. Find the "Livestream API" section
|
|
771
|
+
3. Copy your API key
|
|
772
|
+
|
|
773
|
+
4. Set environment variable:
|
|
774
|
+
export RUMBLE_API_KEY="your_api_key"
|
|
775
|
+
|
|
776
|
+
Note: Rumble's API is mostly read-only (viewer count, chat, status).
|
|
777
|
+
For dashboard changes, use kbot with --computer-use flag or kbot_browse.`);
|
|
778
|
+
}
|
|
779
|
+
// Show current status
|
|
780
|
+
sections.push(`\n${'═'.repeat(50)}`);
|
|
781
|
+
sections.push('CURRENT STATUS:');
|
|
782
|
+
sections.push(` Twitch: ${hasTwitch() ? 'Configured' : 'NOT configured'}${process.env.TWITCH_BROADCASTER_ID ? '' : ' (missing TWITCH_BROADCASTER_ID)'}`);
|
|
783
|
+
sections.push(` Kick: ${hasKick() ? 'Configured' : 'NOT configured'}`);
|
|
784
|
+
sections.push(` Rumble: ${hasRumble() ? 'Configured' : 'NOT configured'}`);
|
|
785
|
+
return sections.join('\n');
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
//# sourceMappingURL=stream-control.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernel.chat/kbot",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.88.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": {
|