@kernel.chat/kbot 3.70.0 → 3.71.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,158 @@
1
+ /**
2
+ * ableton-bridge.ts — kbot ↔ AbletonBridge TCP Client
3
+ *
4
+ * Connects to AbletonBridge (https://github.com/hidingwill/AbletonBridge),
5
+ * a 353-tool Remote Script that exposes Ableton's full Browser API
6
+ * via a TCP server on localhost:9001.
7
+ *
8
+ * Protocol:
9
+ * Send: {"id": 1, "method": "search_browser", "params": {...}}\n
10
+ * Receive: {"id": 1, "result": {...}}\n
11
+ *
12
+ * Fallback chain (used by tools):
13
+ * 1. AbletonBridge (port 9001) — full browser API
14
+ * 2. KBotBridge (port 9998) — kbot's own Remote Script
15
+ * 3. Error with install instructions
16
+ *
17
+ * Follows the same singleton + newline-delimited JSON pattern as AbletonM4L.
18
+ */
19
+ export interface BrowserItem {
20
+ name: string;
21
+ uri: string;
22
+ isLoadable: boolean;
23
+ isDevice: boolean;
24
+ isFolder: boolean;
25
+ }
26
+ export interface Preset {
27
+ name: string;
28
+ uri: string;
29
+ }
30
+ export interface Device {
31
+ name: string;
32
+ className: string;
33
+ index: number;
34
+ }
35
+ export interface BridgeCommand {
36
+ id: number;
37
+ method: string;
38
+ params?: Record<string, unknown>;
39
+ }
40
+ export interface BridgeResponse {
41
+ id: number;
42
+ result?: unknown;
43
+ error?: string;
44
+ }
45
+ export declare class AbletonBridgeClient {
46
+ private static instance;
47
+ private socket;
48
+ private connected;
49
+ private pending;
50
+ private nextId;
51
+ private buffer;
52
+ static PORT: number;
53
+ static HOST: string;
54
+ static TIMEOUT: number;
55
+ static CONNECT_TIMEOUT: number;
56
+ private constructor();
57
+ /**
58
+ * Get the singleton instance.
59
+ */
60
+ static getInstance(): AbletonBridgeClient;
61
+ /**
62
+ * Connect to AbletonBridge TCP server.
63
+ * Returns true if connected and responds to a ping/handshake.
64
+ */
65
+ connect(): Promise<boolean>;
66
+ /**
67
+ * Check if connected.
68
+ */
69
+ isConnected(): boolean;
70
+ /**
71
+ * Disconnect from the bridge.
72
+ */
73
+ disconnect(): void;
74
+ /**
75
+ * Send a method call and wait for a response.
76
+ */
77
+ send(method: string, params?: Record<string, unknown>): Promise<BridgeResponse>;
78
+ private handleResponse;
79
+ private handleDisconnect;
80
+ /**
81
+ * Search Ableton's browser for items matching a query.
82
+ * Optionally filter by category: "instruments", "audio_effects", "midi_effects",
83
+ * "drums", "sounds", "packs", "plugins", "samples", "presets".
84
+ */
85
+ searchBrowser(query: string, category?: string): Promise<BrowserItem[]>;
86
+ /**
87
+ * Load a device onto a track by its browser URI.
88
+ */
89
+ loadDevice(trackIndex: number, uri: string): Promise<boolean>;
90
+ /**
91
+ * Search for a device by name and load the first loadable match onto a track.
92
+ * Optionally filter by category to narrow results.
93
+ */
94
+ loadDeviceByName(trackIndex: number, name: string, category?: string): Promise<boolean>;
95
+ /**
96
+ * List presets available for a device by its URI.
97
+ */
98
+ listPresets(deviceUri: string): Promise<Preset[]>;
99
+ /**
100
+ * Load a preset onto a device on a specific track.
101
+ */
102
+ loadPreset(trackIndex: number, deviceIndex: number, presetUri: string): Promise<boolean>;
103
+ /**
104
+ * Get the effect/device chain on a track.
105
+ */
106
+ getEffectChain(trackIndex: number): Promise<Device[]>;
107
+ }
108
+ /**
109
+ * Lightweight TCP probe for the kbot Remote Script on port 9998.
110
+ * Uses the same newline-delimited JSON protocol as AbletonM4L.
111
+ */
112
+ export declare class KBotRemoteClient {
113
+ private static instance;
114
+ private socket;
115
+ private connected;
116
+ private pending;
117
+ private nextId;
118
+ private buffer;
119
+ static PORT: number;
120
+ static HOST: string;
121
+ static TIMEOUT: number;
122
+ static CONNECT_TIMEOUT: number;
123
+ private constructor();
124
+ static getInstance(): KBotRemoteClient;
125
+ connect(): Promise<boolean>;
126
+ isConnected(): boolean;
127
+ disconnect(): void;
128
+ send(cmd: Record<string, unknown>): Promise<Record<string, unknown>>;
129
+ /** Load a device by name via the kbot Remote Script's search. */
130
+ loadDevice(trackIndex: number, name: string): Promise<boolean>;
131
+ /** Search the browser via the kbot Remote Script. */
132
+ searchBrowser(query: string): Promise<BrowserItem[]>;
133
+ private handleResponse;
134
+ private handleDisconnect;
135
+ }
136
+ /**
137
+ * Try to connect to AbletonBridge (port 9001).
138
+ * Returns the connected client or null if unavailable.
139
+ */
140
+ export declare function tryAbletonBridge(): Promise<AbletonBridgeClient | null>;
141
+ /**
142
+ * Try to connect to KBotBridge Remote Script (port 9998).
143
+ * Returns the connected client or null if unavailable.
144
+ */
145
+ export declare function tryKBotRemote(): Promise<KBotRemoteClient | null>;
146
+ /**
147
+ * Get any available bridge, trying AbletonBridge first, then KBotBridge.
148
+ * Returns { bridge, type } or null if neither is available.
149
+ */
150
+ export declare function getAvailableBridge(): Promise<{
151
+ bridge: AbletonBridgeClient | KBotRemoteClient;
152
+ type: 'ableton-bridge' | 'kbot-remote';
153
+ } | null>;
154
+ /**
155
+ * Format a helpful error message when no bridge is available.
156
+ */
157
+ export declare function formatBridgeError(): string;
158
+ //# sourceMappingURL=ableton-bridge.d.ts.map
@@ -0,0 +1,486 @@
1
+ /**
2
+ * ableton-bridge.ts — kbot ↔ AbletonBridge TCP Client
3
+ *
4
+ * Connects to AbletonBridge (https://github.com/hidingwill/AbletonBridge),
5
+ * a 353-tool Remote Script that exposes Ableton's full Browser API
6
+ * via a TCP server on localhost:9001.
7
+ *
8
+ * Protocol:
9
+ * Send: {"id": 1, "method": "search_browser", "params": {...}}\n
10
+ * Receive: {"id": 1, "result": {...}}\n
11
+ *
12
+ * Fallback chain (used by tools):
13
+ * 1. AbletonBridge (port 9001) — full browser API
14
+ * 2. KBotBridge (port 9998) — kbot's own Remote Script
15
+ * 3. Error with install instructions
16
+ *
17
+ * Follows the same singleton + newline-delimited JSON pattern as AbletonM4L.
18
+ */
19
+ import * as net from 'node:net';
20
+ // ── Client ────────────────────────────────────────────────────────────
21
+ export class AbletonBridgeClient {
22
+ static instance = null;
23
+ socket = null;
24
+ connected = false;
25
+ pending = new Map();
26
+ nextId = 1;
27
+ buffer = '';
28
+ static PORT = 9001;
29
+ static HOST = '127.0.0.1';
30
+ static TIMEOUT = 15_000;
31
+ static CONNECT_TIMEOUT = 5_000;
32
+ constructor() { }
33
+ /**
34
+ * Get the singleton instance.
35
+ */
36
+ static getInstance() {
37
+ if (!AbletonBridgeClient.instance) {
38
+ AbletonBridgeClient.instance = new AbletonBridgeClient();
39
+ }
40
+ return AbletonBridgeClient.instance;
41
+ }
42
+ /**
43
+ * Connect to AbletonBridge TCP server.
44
+ * Returns true if connected and responds to a ping/handshake.
45
+ */
46
+ async connect() {
47
+ if (this.connected && this.socket) {
48
+ // Already connected — verify with a lightweight call
49
+ try {
50
+ await this.send('ping');
51
+ return true;
52
+ }
53
+ catch {
54
+ // Connection stale, reconnect
55
+ this.disconnect();
56
+ }
57
+ }
58
+ return new Promise((resolve) => {
59
+ this.socket = new net.Socket();
60
+ this.buffer = '';
61
+ this.socket.on('data', (data) => {
62
+ this.buffer += data.toString();
63
+ const lines = this.buffer.split('\n');
64
+ this.buffer = lines.pop() || '';
65
+ for (const line of lines) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed)
68
+ continue;
69
+ try {
70
+ const response = JSON.parse(trimmed);
71
+ this.handleResponse(response);
72
+ }
73
+ catch {
74
+ // Malformed JSON — skip
75
+ }
76
+ }
77
+ });
78
+ this.socket.on('error', () => {
79
+ if (!this.connected) {
80
+ resolve(false);
81
+ }
82
+ this.handleDisconnect();
83
+ });
84
+ this.socket.on('close', () => {
85
+ this.handleDisconnect();
86
+ });
87
+ this.socket.connect(AbletonBridgeClient.PORT, AbletonBridgeClient.HOST, async () => {
88
+ this.connected = true;
89
+ // Verify connectivity
90
+ try {
91
+ const pong = await this.send('ping');
92
+ resolve(!pong.error);
93
+ }
94
+ catch {
95
+ // Even if ping fails, we may still be connected to a bridge
96
+ // that doesn't support ping — consider it connected
97
+ resolve(true);
98
+ }
99
+ });
100
+ // Connection timeout
101
+ setTimeout(() => {
102
+ if (!this.connected) {
103
+ this.socket?.destroy();
104
+ resolve(false);
105
+ }
106
+ }, AbletonBridgeClient.CONNECT_TIMEOUT);
107
+ });
108
+ }
109
+ /**
110
+ * Check if connected.
111
+ */
112
+ isConnected() {
113
+ return this.connected;
114
+ }
115
+ /**
116
+ * Disconnect from the bridge.
117
+ */
118
+ disconnect() {
119
+ this.connected = false;
120
+ if (this.socket) {
121
+ this.socket.destroy();
122
+ this.socket = null;
123
+ }
124
+ // Reject all pending requests
125
+ for (const [, req] of this.pending) {
126
+ clearTimeout(req.timer);
127
+ req.reject(new Error('Disconnected'));
128
+ }
129
+ this.pending.clear();
130
+ this.buffer = '';
131
+ }
132
+ /**
133
+ * Send a method call and wait for a response.
134
+ */
135
+ async send(method, params) {
136
+ if (!this.connected || !this.socket) {
137
+ throw new Error('Not connected to AbletonBridge. Is Ableton running with the AbletonBridge Remote Script?');
138
+ }
139
+ const id = this.nextId++;
140
+ const cmd = { id, method };
141
+ if (params)
142
+ cmd.params = params;
143
+ return new Promise((resolve, reject) => {
144
+ const timer = setTimeout(() => {
145
+ this.pending.delete(id);
146
+ reject(new Error(`Timeout: ${method}`));
147
+ }, AbletonBridgeClient.TIMEOUT);
148
+ this.pending.set(id, { resolve, reject, timer });
149
+ const json = JSON.stringify(cmd) + '\n';
150
+ this.socket.write(json);
151
+ });
152
+ }
153
+ // ── Response handling ─────────────────────────────────────────────
154
+ handleResponse(response) {
155
+ if (response.id && this.pending.has(response.id)) {
156
+ const req = this.pending.get(response.id);
157
+ this.pending.delete(response.id);
158
+ clearTimeout(req.timer);
159
+ req.resolve(response);
160
+ }
161
+ // No event/push support for AbletonBridge — all request/response
162
+ }
163
+ handleDisconnect() {
164
+ if (!this.connected)
165
+ return;
166
+ this.connected = false;
167
+ this.socket = null;
168
+ // Reject pending
169
+ for (const [, req] of this.pending) {
170
+ clearTimeout(req.timer);
171
+ req.reject(new Error('Connection lost'));
172
+ }
173
+ this.pending.clear();
174
+ }
175
+ // ── Browser API ───────────────────────────────────────────────────
176
+ /**
177
+ * Search Ableton's browser for items matching a query.
178
+ * Optionally filter by category: "instruments", "audio_effects", "midi_effects",
179
+ * "drums", "sounds", "packs", "plugins", "samples", "presets".
180
+ */
181
+ async searchBrowser(query, category) {
182
+ const params = { query };
183
+ if (category)
184
+ params.category = category;
185
+ const resp = await this.send('search_browser', params);
186
+ if (resp.error)
187
+ throw new Error(resp.error);
188
+ const items = resp.result;
189
+ if (!Array.isArray(items))
190
+ return [];
191
+ return items.map((item) => ({
192
+ name: String(item.name ?? ''),
193
+ uri: String(item.uri ?? ''),
194
+ isLoadable: Boolean(item.is_loadable ?? item.isLoadable ?? false),
195
+ isDevice: Boolean(item.is_device ?? item.isDevice ?? false),
196
+ isFolder: Boolean(item.is_folder ?? item.isFolder ?? false),
197
+ }));
198
+ }
199
+ /**
200
+ * Load a device onto a track by its browser URI.
201
+ */
202
+ async loadDevice(trackIndex, uri) {
203
+ const resp = await this.send('load_device', { track: trackIndex, uri });
204
+ if (resp.error)
205
+ throw new Error(resp.error);
206
+ return Boolean(resp.result);
207
+ }
208
+ /**
209
+ * Search for a device by name and load the first loadable match onto a track.
210
+ * Optionally filter by category to narrow results.
211
+ */
212
+ async loadDeviceByName(trackIndex, name, category) {
213
+ const items = await this.searchBrowser(name, category);
214
+ // Find the first loadable device
215
+ const device = items.find((item) => item.isLoadable && item.isDevice);
216
+ if (!device) {
217
+ // Fallback: try any loadable item
218
+ const loadable = items.find((item) => item.isLoadable);
219
+ if (!loadable) {
220
+ throw new Error(`No loadable device found for "${name}"${category ? ` in category "${category}"` : ''}`);
221
+ }
222
+ return this.loadDevice(trackIndex, loadable.uri);
223
+ }
224
+ return this.loadDevice(trackIndex, device.uri);
225
+ }
226
+ /**
227
+ * List presets available for a device by its URI.
228
+ */
229
+ async listPresets(deviceUri) {
230
+ const resp = await this.send('list_presets', { uri: deviceUri });
231
+ if (resp.error)
232
+ throw new Error(resp.error);
233
+ const presets = resp.result;
234
+ if (!Array.isArray(presets))
235
+ return [];
236
+ return presets.map((p) => ({
237
+ name: String(p.name ?? ''),
238
+ uri: String(p.uri ?? ''),
239
+ }));
240
+ }
241
+ /**
242
+ * Load a preset onto a device on a specific track.
243
+ */
244
+ async loadPreset(trackIndex, deviceIndex, presetUri) {
245
+ const resp = await this.send('load_preset', {
246
+ track: trackIndex,
247
+ device: deviceIndex,
248
+ uri: presetUri,
249
+ });
250
+ if (resp.error)
251
+ throw new Error(resp.error);
252
+ return Boolean(resp.result);
253
+ }
254
+ /**
255
+ * Get the effect/device chain on a track.
256
+ */
257
+ async getEffectChain(trackIndex) {
258
+ const resp = await this.send('get_device_chain', { track: trackIndex });
259
+ if (resp.error)
260
+ throw new Error(resp.error);
261
+ const devices = resp.result;
262
+ if (!Array.isArray(devices))
263
+ return [];
264
+ return devices.map((d, i) => ({
265
+ name: String(d.name ?? ''),
266
+ className: String(d.class_name ?? d.className ?? ''),
267
+ index: typeof d.index === 'number' ? d.index : i,
268
+ }));
269
+ }
270
+ }
271
+ // ── KBotBridge fallback (port 9998) ────────────────────────────────────
272
+ /**
273
+ * Lightweight TCP probe for the kbot Remote Script on port 9998.
274
+ * Uses the same newline-delimited JSON protocol as AbletonM4L.
275
+ */
276
+ export class KBotRemoteClient {
277
+ static instance = null;
278
+ socket = null;
279
+ connected = false;
280
+ pending = new Map();
281
+ nextId = 1;
282
+ buffer = '';
283
+ static PORT = 9998;
284
+ static HOST = '127.0.0.1';
285
+ static TIMEOUT = 10_000;
286
+ static CONNECT_TIMEOUT = 3_000;
287
+ constructor() { }
288
+ static getInstance() {
289
+ if (!KBotRemoteClient.instance) {
290
+ KBotRemoteClient.instance = new KBotRemoteClient();
291
+ }
292
+ return KBotRemoteClient.instance;
293
+ }
294
+ async connect() {
295
+ if (this.connected && this.socket) {
296
+ try {
297
+ await this.send({ action: 'ping' });
298
+ return true;
299
+ }
300
+ catch {
301
+ this.disconnect();
302
+ }
303
+ }
304
+ return new Promise((resolve) => {
305
+ this.socket = new net.Socket();
306
+ this.buffer = '';
307
+ this.socket.on('data', (data) => {
308
+ this.buffer += data.toString();
309
+ const lines = this.buffer.split('\n');
310
+ this.buffer = lines.pop() || '';
311
+ for (const line of lines) {
312
+ const trimmed = line.trim();
313
+ if (!trimmed)
314
+ continue;
315
+ try {
316
+ const response = JSON.parse(trimmed);
317
+ this.handleResponse(response);
318
+ }
319
+ catch {
320
+ // skip
321
+ }
322
+ }
323
+ });
324
+ this.socket.on('error', () => {
325
+ if (!this.connected)
326
+ resolve(false);
327
+ this.handleDisconnect();
328
+ });
329
+ this.socket.on('close', () => {
330
+ this.handleDisconnect();
331
+ });
332
+ this.socket.connect(KBotRemoteClient.PORT, KBotRemoteClient.HOST, async () => {
333
+ this.connected = true;
334
+ try {
335
+ const pong = await this.send({ action: 'ping' });
336
+ resolve(Boolean(pong.ok));
337
+ }
338
+ catch {
339
+ resolve(true); // Connected but no ping support — still usable
340
+ }
341
+ });
342
+ setTimeout(() => {
343
+ if (!this.connected) {
344
+ this.socket?.destroy();
345
+ resolve(false);
346
+ }
347
+ }, KBotRemoteClient.CONNECT_TIMEOUT);
348
+ });
349
+ }
350
+ isConnected() {
351
+ return this.connected;
352
+ }
353
+ disconnect() {
354
+ this.connected = false;
355
+ if (this.socket) {
356
+ this.socket.destroy();
357
+ this.socket = null;
358
+ }
359
+ for (const [, req] of this.pending) {
360
+ clearTimeout(req.timer);
361
+ req.reject(new Error('Disconnected'));
362
+ }
363
+ this.pending.clear();
364
+ this.buffer = '';
365
+ }
366
+ async send(cmd) {
367
+ if (!this.connected || !this.socket) {
368
+ throw new Error('Not connected to KBotBridge Remote Script');
369
+ }
370
+ const id = this.nextId++;
371
+ const fullCmd = { id, ...cmd };
372
+ return new Promise((resolve, reject) => {
373
+ const timer = setTimeout(() => {
374
+ this.pending.delete(id);
375
+ reject(new Error(`Timeout: ${cmd.action ?? 'unknown'}`));
376
+ }, KBotRemoteClient.TIMEOUT);
377
+ this.pending.set(id, { resolve, reject, timer });
378
+ this.socket.write(JSON.stringify(fullCmd) + '\n');
379
+ });
380
+ }
381
+ /** Load a device by name via the kbot Remote Script's search. */
382
+ async loadDevice(trackIndex, name) {
383
+ const resp = await this.send({ action: 'load_device', track: trackIndex, name });
384
+ return Boolean(resp.ok);
385
+ }
386
+ /** Search the browser via the kbot Remote Script. */
387
+ async searchBrowser(query) {
388
+ const resp = await this.send({ action: 'search_browser', query });
389
+ const items = resp.results;
390
+ if (!Array.isArray(items))
391
+ return [];
392
+ return items.map((item) => ({
393
+ name: String(item.name ?? ''),
394
+ uri: String(item.uri ?? ''),
395
+ isLoadable: Boolean(item.is_loadable ?? false),
396
+ isDevice: Boolean(item.is_device ?? false),
397
+ isFolder: Boolean(item.is_folder ?? false),
398
+ }));
399
+ }
400
+ handleResponse(response) {
401
+ const id = response.id;
402
+ if (id && this.pending.has(id)) {
403
+ const req = this.pending.get(id);
404
+ this.pending.delete(id);
405
+ clearTimeout(req.timer);
406
+ req.resolve(response);
407
+ }
408
+ }
409
+ handleDisconnect() {
410
+ if (!this.connected)
411
+ return;
412
+ this.connected = false;
413
+ this.socket = null;
414
+ for (const [, req] of this.pending) {
415
+ clearTimeout(req.timer);
416
+ req.reject(new Error('Connection lost'));
417
+ }
418
+ this.pending.clear();
419
+ }
420
+ }
421
+ // ── Convenience exports ────────────────────────────────────────────────
422
+ /**
423
+ * Try to connect to AbletonBridge (port 9001).
424
+ * Returns the connected client or null if unavailable.
425
+ */
426
+ export async function tryAbletonBridge() {
427
+ const client = AbletonBridgeClient.getInstance();
428
+ if (client.isConnected())
429
+ return client;
430
+ const ok = await client.connect();
431
+ return ok ? client : null;
432
+ }
433
+ /**
434
+ * Try to connect to KBotBridge Remote Script (port 9998).
435
+ * Returns the connected client or null if unavailable.
436
+ */
437
+ export async function tryKBotRemote() {
438
+ const client = KBotRemoteClient.getInstance();
439
+ if (client.isConnected())
440
+ return client;
441
+ const ok = await client.connect();
442
+ return ok ? client : null;
443
+ }
444
+ /**
445
+ * Get any available bridge, trying AbletonBridge first, then KBotBridge.
446
+ * Returns { bridge, type } or null if neither is available.
447
+ */
448
+ export async function getAvailableBridge() {
449
+ // Try AbletonBridge first (full browser API)
450
+ const ab = await tryAbletonBridge();
451
+ if (ab)
452
+ return { bridge: ab, type: 'ableton-bridge' };
453
+ // Fallback to KBotBridge Remote Script
454
+ const kb = await tryKBotRemote();
455
+ if (kb)
456
+ return { bridge: kb, type: 'kbot-remote' };
457
+ return null;
458
+ }
459
+ /**
460
+ * Format a helpful error message when no bridge is available.
461
+ */
462
+ export function formatBridgeError() {
463
+ return [
464
+ '**No Ableton bridge connected**',
465
+ '',
466
+ 'kbot tried two connection methods and neither is available:',
467
+ '',
468
+ '**Option 1 — AbletonBridge (recommended)**',
469
+ ' Full browser API with 353 tools. Install:',
470
+ ' 1. Download from https://github.com/hidingwill/AbletonBridge',
471
+ ' 2. Copy the `AbletonBridge` folder to your Remote Scripts:',
472
+ ' macOS: ~/Music/Ableton/User Library/Remote Scripts/',
473
+ ' Win: ~\\Documents\\Ableton\\User Library\\Remote Scripts\\',
474
+ ' 3. In Ableton: Preferences → Link/Tempo/MIDI → Control Surface → AbletonBridge',
475
+ ' 4. Verify: TCP server starts on localhost:9001',
476
+ '',
477
+ '**Option 2 — KBotBridge**',
478
+ ' kbot\'s own Remote Script. Install:',
479
+ ' 1. Run `kbot ableton install` or copy KBotBridge to Remote Scripts',
480
+ ' 2. Enable in Ableton: Preferences → Link/Tempo/MIDI → Control Surface → KBotBridge',
481
+ ' 3. Verify: TCP server starts on localhost:9998',
482
+ '',
483
+ 'Both require Ableton Live to be running.',
484
+ ].join('\n');
485
+ }
486
+ //# sourceMappingURL=ableton-bridge.js.map