@butlerbot/sdk 0.0.18-alpha.3 → 0.0.19

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,427 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Link = void 0;
4
+ const config_1 = require("../config");
5
+ const emitter_1 = require("../util/emitter");
6
+ const protocol_1 = require("./protocol");
7
+ const socket_1 = require("./socket");
8
+ const DEFAULTS = {
9
+ minReconnectDelayMs: 500,
10
+ maxReconnectDelayMs: 30000,
11
+ heartbeatMs: 30000,
12
+ requestTimeoutMs: 30000,
13
+ };
14
+ /**
15
+ * A live connection to Alfred that carries tools, hooks and conversations.
16
+ *
17
+ * The server keeps no record of a link between connections: everything is
18
+ * re-declared on connect, and ids are derived from your `linkId`, so a reconnect
19
+ * anywhere lands on the same saved settings and subscriptions.
20
+ */
21
+ class Link {
22
+ constructor(options) {
23
+ this.emitter = new emitter_1.Emitter();
24
+ this.tools = new Map();
25
+ this.hooks = new Map();
26
+ this.pending = new Map();
27
+ this.calls = new Map();
28
+ this.socket = null;
29
+ this.frameCounter = 0;
30
+ this.currentState = "idle";
31
+ this.reconnectAttempt = 0;
32
+ this.reconnectAfterMs = 0;
33
+ this.closedByUs = false;
34
+ this.options = {
35
+ ...DEFAULTS,
36
+ reconnect: true,
37
+ debug: false,
38
+ client: "@butlerbot/sdk",
39
+ serverUrl: config_1.CONFIG.server,
40
+ socketFactory: socket_1.defaultSocketFactory,
41
+ ...stripUndefined(options),
42
+ };
43
+ }
44
+ // =============================================
45
+ // WHAT THE LINK HOLDS
46
+ // =============================================
47
+ /** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
48
+ addTool(tool) {
49
+ this.tools.set(tool.id, tool);
50
+ if (this.currentState === "open")
51
+ void this.registerTools([tool]);
52
+ return this;
53
+ }
54
+ /** Adds a hook that can wake the user's background agents. */
55
+ addHook(hook) {
56
+ this.hooks.set(hook.id, hook);
57
+ hook.attach(this);
58
+ if (this.currentState === "open")
59
+ void this.registerHook(hook);
60
+ return this;
61
+ }
62
+ getTool(id) {
63
+ return this.tools.get(id);
64
+ }
65
+ getHook(id) {
66
+ return this.hooks.get(id);
67
+ }
68
+ // =============================================
69
+ // STATE
70
+ // =============================================
71
+ get state() {
72
+ return this.currentState;
73
+ }
74
+ get linkId() {
75
+ return this.options.linkId;
76
+ }
77
+ /** The ephemeral id of this connection. Changes on every reconnect. */
78
+ get connectionId() {
79
+ return this.identity?.connectionId;
80
+ }
81
+ /** Whether this link speaks for one user or for the whole service. */
82
+ get scope() {
83
+ return this.identity?.scope;
84
+ }
85
+ on(event, listener) {
86
+ return this.emitter.on(event, listener);
87
+ }
88
+ off(event, id) {
89
+ this.emitter.off(event, id);
90
+ }
91
+ // =============================================
92
+ // LIFECYCLE
93
+ // =============================================
94
+ /** Connects, resolving once every tool and hook has been registered. */
95
+ connect() {
96
+ if (this.currentState === "open")
97
+ return Promise.resolve(this);
98
+ if (this.connecting)
99
+ return this.connecting;
100
+ this.closedByUs = false;
101
+ this.connecting = this.openSocket().then(() => this, error => {
102
+ this.connecting = undefined;
103
+ throw error;
104
+ });
105
+ return this.connecting;
106
+ }
107
+ /** Resolves when the link is usable, connecting first if it has not been asked to yet. */
108
+ async ready() {
109
+ if (this.currentState === "open")
110
+ return;
111
+ if (this.currentState === "closed")
112
+ throw new Error("This link has been closed.");
113
+ await this.connect();
114
+ }
115
+ /** Closes for good. Registrations are released server-side as the socket drops. */
116
+ close(reason = "client closed") {
117
+ this.closedByUs = true;
118
+ this.currentState = "closed";
119
+ this.stopHeartbeat();
120
+ this.failPending(new protocol_1.LinkError("closed", "The link was closed."));
121
+ this.socket?.close(1000, reason);
122
+ this.socket = null;
123
+ this.connecting = undefined;
124
+ this.readySignal = undefined;
125
+ }
126
+ openSocket() {
127
+ this.currentState = "connecting";
128
+ const signal = deferred();
129
+ this.readySignal = signal;
130
+ const { url, protocols } = (0, socket_1.buildHandshake)(this.options.serverUrl, "link", this.options.apiKey);
131
+ this.debug(`connecting to ${url.replace(/api_key=[^&]+/, "api_key=***")}`);
132
+ try {
133
+ this.socket = this.options.socketFactory(url, protocols, {
134
+ onOpen: () => this.onOpen(),
135
+ onMessage: (data) => this.onMessage(data),
136
+ onClose: (code, reason) => this.onClose(code, reason),
137
+ onError: (error) => this.emitter.emit("error", asError(error)),
138
+ });
139
+ }
140
+ catch (error) {
141
+ signal.reject(asError(error));
142
+ }
143
+ return signal.promise;
144
+ }
145
+ onOpen() {
146
+ // The handshake declares who we are; nothing else may be sent before it.
147
+ void this.exchange("hello", {
148
+ linkId: this.options.linkId,
149
+ client: this.options.client,
150
+ protocolVersion: protocol_1.LINK_PROTOCOL_VERSION,
151
+ }, {
152
+ awaitReady: false,
153
+ isDone: (frame) => frame.type === "welcome",
154
+ }).then(async (frame) => {
155
+ const welcome = frame.payload;
156
+ this.identity = { connectionId: welcome.connectionId, scope: welcome.scope };
157
+ await this.registerAll();
158
+ this.currentState = "open";
159
+ this.reconnectAttempt = 0;
160
+ this.reconnectAfterMs = 0;
161
+ this.startHeartbeat();
162
+ this.readySignal?.resolve();
163
+ this.emitter.emit("connect", this.identity);
164
+ }).catch((error) => {
165
+ this.emitter.emit("error", error);
166
+ // A rejected claim or an unsupported protocol will not fix itself by
167
+ // trying again, so this stops rather than looping.
168
+ this.closedByUs = true;
169
+ this.readySignal?.reject(error);
170
+ this.socket?.close(1000, "handshake failed");
171
+ });
172
+ }
173
+ onClose(code, reason) {
174
+ const willReconnect = this.options.reconnect && !this.closedByUs;
175
+ this.socket = null;
176
+ this.identity = undefined;
177
+ this.connecting = undefined;
178
+ this.currentState = willReconnect ? "connecting" : "closed";
179
+ this.stopHeartbeat();
180
+ this.failPending(new protocol_1.LinkError("disconnected", `The link disconnected (${code}${reason ? `: ${reason}` : ""}).`));
181
+ this.emitter.emit("disconnect", { code, reason, willReconnect });
182
+ this.readySignal?.reject(new protocol_1.LinkError("disconnected", `The link disconnected (${code}).`));
183
+ this.readySignal = undefined;
184
+ if (willReconnect)
185
+ this.scheduleReconnect();
186
+ }
187
+ /**
188
+ * Reconnects with full jitter on top of any delay the server asked for.
189
+ *
190
+ * A fleet told to reconnect must not come back in unison, which is exactly what
191
+ * a fixed delay produces.
192
+ */
193
+ scheduleReconnect() {
194
+ const ceiling = Math.min(this.options.maxReconnectDelayMs, this.options.minReconnectDelayMs * 2 ** this.reconnectAttempt);
195
+ const delay = this.reconnectAfterMs + Math.random() * ceiling;
196
+ this.reconnectAttempt += 1;
197
+ this.debug(`reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempt})`);
198
+ const timer = setTimeout(() => {
199
+ if (this.closedByUs)
200
+ return;
201
+ this.openSocket().catch(error => this.emitter.emit("error", asError(error)));
202
+ }, delay);
203
+ unref(timer);
204
+ }
205
+ startHeartbeat() {
206
+ if (!this.options.heartbeatMs)
207
+ return;
208
+ this.heartbeat = setInterval(() => {
209
+ // The reply is consumed by the exchange, so it never reaches log listeners.
210
+ this.exchange("ping", {}, { isDone: (frame) => frame.type === "log" })
211
+ .catch(() => undefined);
212
+ }, this.options.heartbeatMs);
213
+ unref(this.heartbeat);
214
+ }
215
+ stopHeartbeat() {
216
+ if (this.heartbeat)
217
+ clearInterval(this.heartbeat);
218
+ this.heartbeat = undefined;
219
+ }
220
+ // =============================================
221
+ // REGISTRATION
222
+ // =============================================
223
+ async registerAll() {
224
+ await this.registerTools(Array.from(this.tools.values()));
225
+ for (const hook of this.hooks.values())
226
+ await this.registerHook(hook);
227
+ }
228
+ async registerTools(tools) {
229
+ if (!tools.length)
230
+ return;
231
+ const frame = await this.exchange("tool.register", { tools: tools.map(tool => tool.descriptor()) }, { awaitReady: false });
232
+ const ids = frame.payload.ids ?? [];
233
+ // Ids come back in declaration order; taking them from the server rather than
234
+ // rebuilding them here keeps the id format in one place.
235
+ tools.forEach((tool, index) => { tool.linkedId = ids[index]; });
236
+ this.debug(`registered ${tools.length} tool(s)`);
237
+ }
238
+ async registerHook(hook) {
239
+ const frame = await this.exchange("hook.register", hook.declaration(), { awaitReady: false });
240
+ hook.sourceId = frame.payload.ids?.[0];
241
+ this.debug(`registered hook ${hook.id}`);
242
+ }
243
+ /** Called by `Hook.emit`. */
244
+ async emitHook(hookId, event, payload, ownerId) {
245
+ await this.ready();
246
+ // Resolved after waiting, not before: an emit issued during startup would
247
+ // otherwise carry the local id, which the server does not know.
248
+ const sourceId = this.hooks.get(hookId)?.sourceId ?? hookId;
249
+ this.send("hook.emit", {
250
+ sourceId,
251
+ event,
252
+ ...(payload ? { payload } : {}),
253
+ ...(ownerId ? { ownerId } : {}),
254
+ });
255
+ }
256
+ // =============================================
257
+ // FRAMES
258
+ // =============================================
259
+ /** Sends a frame without waiting for anything. Returns its id. */
260
+ send(type, payload, replyTo) {
261
+ const id = `c${++this.frameCounter}`;
262
+ const frame = { v: protocol_1.LINK_PROTOCOL_VERSION, id, type, payload, ...(replyTo ? { replyTo } : {}) };
263
+ if (!this.socket)
264
+ throw new protocol_1.LinkError("disconnected", "The link is not connected.");
265
+ this.socket.send(JSON.stringify(frame));
266
+ return id;
267
+ }
268
+ /**
269
+ * Sends a frame and waits for the reply that ends it.
270
+ *
271
+ * Intermediate replies (a turn's events, a status update) go to `onFrame`, and an
272
+ * `error` frame rejects — so a caller handles one outcome, not a stream of maybes.
273
+ */
274
+ async exchange(type, payload, options = {}) {
275
+ if (options.awaitReady !== false)
276
+ await this.ready();
277
+ const isDone = options.isDone ?? ((frame) => frame.type === "ack");
278
+ const timeoutMs = options.timeoutMs ?? this.options.requestTimeoutMs;
279
+ return new Promise((resolve, reject) => {
280
+ let id;
281
+ try {
282
+ id = this.send(type, payload);
283
+ }
284
+ catch (error) {
285
+ reject(asError(error));
286
+ return;
287
+ }
288
+ const entry = {
289
+ isDone,
290
+ onFrame: options.onFrame,
291
+ resolve: (frame) => { this.settle(id); resolve(frame); },
292
+ reject: (error) => { this.settle(id); reject(error); },
293
+ };
294
+ if (timeoutMs > 0) {
295
+ entry.timer = setTimeout(() => {
296
+ entry.reject(new protocol_1.LinkError("timeout", `No reply to "${type}" within ${timeoutMs}ms.`));
297
+ }, timeoutMs);
298
+ unref(entry.timer);
299
+ }
300
+ this.pending.set(id, entry);
301
+ });
302
+ }
303
+ settle(id) {
304
+ const entry = this.pending.get(id);
305
+ if (entry?.timer)
306
+ clearTimeout(entry.timer);
307
+ this.pending.delete(id);
308
+ }
309
+ failPending(error) {
310
+ for (const entry of Array.from(this.pending.values()))
311
+ entry.reject(error);
312
+ this.pending.clear();
313
+ for (const controller of Array.from(this.calls.values()))
314
+ controller.abort();
315
+ this.calls.clear();
316
+ }
317
+ onMessage(raw) {
318
+ let frame;
319
+ try {
320
+ frame = JSON.parse(raw);
321
+ }
322
+ catch {
323
+ this.emitter.emit("error", new protocol_1.LinkError("bad_frame", `Unparseable frame from the server: ${raw.slice(0, 200)}`));
324
+ return;
325
+ }
326
+ // A reply belongs to whoever is waiting on it, and never reaches the general
327
+ // handlers below.
328
+ const waiting = frame.replyTo ? this.pending.get(frame.replyTo) : undefined;
329
+ if (waiting) {
330
+ if (frame.type === "error") {
331
+ const { code, error, fatal } = frame.payload;
332
+ waiting.reject(new protocol_1.LinkError(code, error, fatal));
333
+ return;
334
+ }
335
+ if (waiting.isDone(frame))
336
+ waiting.resolve(frame);
337
+ else
338
+ waiting.onFrame?.(frame);
339
+ return;
340
+ }
341
+ switch (frame.type) {
342
+ case "tool.call":
343
+ this.handleToolCall(frame);
344
+ return;
345
+ case "tool.cancel": {
346
+ const { callId, reason } = frame.payload;
347
+ this.calls.get(callId)?.abort();
348
+ this.calls.delete(callId);
349
+ this.debug(`call ${callId} cancelled: ${reason}`);
350
+ return;
351
+ }
352
+ case "log":
353
+ this.emitter.emit("log", frame.payload.log);
354
+ return;
355
+ case "goodbye": {
356
+ const payload = frame.payload;
357
+ this.reconnectAfterMs = payload.reconnectAfterMs;
358
+ this.emitter.emit("goodbye", payload);
359
+ return;
360
+ }
361
+ case "error": {
362
+ const payload = frame.payload;
363
+ const error = new protocol_1.LinkError(payload.code, payload.error, payload.fatal);
364
+ if (payload.fatal)
365
+ this.closedByUs = true;
366
+ this.emitter.emit("error", error);
367
+ return;
368
+ }
369
+ default:
370
+ this.debug(`unhandled frame "${frame.type}"`);
371
+ }
372
+ }
373
+ handleToolCall(frame) {
374
+ const { callId, localId, args, meta } = frame.payload;
375
+ const tool = this.tools.get(localId);
376
+ if (!tool) {
377
+ this.send("tool.result", { ok: false, error: `This link has no tool "${localId}".` }, frame.id);
378
+ return;
379
+ }
380
+ const controller = new AbortController();
381
+ this.calls.set(callId, controller);
382
+ const status = {
383
+ update: (label) => this.send("tool.status", { label, state: "running" }, frame.id),
384
+ fail: (label) => this.send("tool.status", { label, state: "failed" }, frame.id),
385
+ };
386
+ void tool.invoke(args, meta, status, controller.signal).then(result => {
387
+ this.calls.delete(callId);
388
+ // The socket may have gone while the tool ran; the server has already
389
+ // given up on the call, so there is nothing to report to.
390
+ if (!this.socket)
391
+ return;
392
+ this.send("tool.result", result.ok
393
+ ? { ok: true, output: result.output }
394
+ : { ok: false, error: result.error }, frame.id);
395
+ });
396
+ }
397
+ debug(message) {
398
+ if (this.options.debug)
399
+ console.log(`[link:${this.options.linkId}] ${message}`);
400
+ }
401
+ }
402
+ exports.Link = Link;
403
+ // =============================================
404
+ // HELPERS
405
+ // =============================================
406
+ function deferred() {
407
+ let resolve;
408
+ let reject;
409
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
410
+ // Rejections are surfaced through connect() and the error event; an unobserved
411
+ // one here must not take the process down.
412
+ promise.catch(() => undefined);
413
+ return { promise, resolve, reject };
414
+ }
415
+ function asError(value) {
416
+ if (value instanceof Error)
417
+ return value;
418
+ const message = value?.message;
419
+ return new Error(message ?? "Unknown link error");
420
+ }
421
+ function stripUndefined(value) {
422
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
423
+ }
424
+ /** Keeps timers from holding a Node process open. No-op in browsers. */
425
+ function unref(timer) {
426
+ timer?.unref?.();
427
+ }
@@ -0,0 +1,186 @@
1
+ import { ConversationEvent } from "../types/response/v5";
2
+ /**
3
+ * LINK WIRE PROTOCOL (client side)
4
+ * ===============================
5
+ *
6
+ * The mirror of the server's protocol definition. Every frame is an envelope:
7
+ * `{ v, id, type, replyTo?, payload }`, where `replyTo` carries the id of the
8
+ * frame being answered — that is what makes request/response work over a stream.
9
+ *
10
+ * Keep this in step with the server. Fields are only ever added, never
11
+ * repurposed, so an older SDK stays correct against a newer server.
12
+ */
13
+ export declare const LINK_PROTOCOL_VERSION = 1;
14
+ export type LinkToolDescriptor = {
15
+ localId: string;
16
+ description: string;
17
+ inputSchema: Record<string, unknown>;
18
+ display?: {
19
+ name: string;
20
+ shortDescription: string;
21
+ longDescription: string;
22
+ };
23
+ defaultEnabled?: boolean;
24
+ timeoutMs?: number;
25
+ };
26
+ export type LinkHookEventDeclaration = {
27
+ name: string;
28
+ description?: string;
29
+ /** Field name to human description, shown to whoever configures the hook. */
30
+ payloadShape?: Record<string, string>;
31
+ };
32
+ export type LinkHookDeclaration = {
33
+ localId: string;
34
+ name: string;
35
+ description: string;
36
+ argsSchema: Record<string, unknown>;
37
+ events: LinkHookEventDeclaration[];
38
+ };
39
+ export type LinkClientPayloads = {
40
+ "hello": {
41
+ linkId: string;
42
+ client?: string;
43
+ protocolVersion?: number;
44
+ };
45
+ "ping": {
46
+ message?: string;
47
+ };
48
+ "tool.register": {
49
+ tools: LinkToolDescriptor[];
50
+ };
51
+ "tool.result": {
52
+ ok: true;
53
+ output: unknown;
54
+ cost?: number;
55
+ } | {
56
+ ok: false;
57
+ error: string;
58
+ };
59
+ "tool.status": {
60
+ label: string;
61
+ state?: "running" | "completed" | "failed";
62
+ };
63
+ "hook.register": LinkHookDeclaration;
64
+ "hook.emit": {
65
+ sourceId: string;
66
+ event: string;
67
+ payload?: Record<string, unknown>;
68
+ ownerId?: string;
69
+ };
70
+ "conversation.start": {
71
+ chatId?: string;
72
+ model?: string;
73
+ personality?: string;
74
+ instructions?: string;
75
+ platform?: string;
76
+ };
77
+ "conversation.chat": {
78
+ sessionId: string;
79
+ message: string;
80
+ model?: string;
81
+ instructions?: string;
82
+ personality?: string;
83
+ };
84
+ "conversation.end": {
85
+ sessionId: string;
86
+ };
87
+ };
88
+ export type LinkClientFrameType = keyof LinkClientPayloads;
89
+ /**
90
+ * A union of one member per frame type, rather than one type whose `type` and
91
+ * `payload` are both unions — otherwise checking `frame.type` narrows nothing and
92
+ * `payload` stays a union of every payload.
93
+ */
94
+ export type LinkClientFrame = {
95
+ [T in LinkClientFrameType]: {
96
+ v: number;
97
+ id: string;
98
+ type: T;
99
+ replyTo?: string;
100
+ payload: LinkClientPayloads[T];
101
+ };
102
+ }[LinkClientFrameType];
103
+ /** One specific client frame, e.g. `LinkClientFrameOf<"tool.result">`. */
104
+ export type LinkClientFrameOf<T extends LinkClientFrameType> = Extract<LinkClientFrame, {
105
+ type: T;
106
+ }>;
107
+ export type LinkScopeKind = "user" | "global";
108
+ export type LinkServerPayloads = {
109
+ "welcome": {
110
+ connectionId: string;
111
+ linkId: string;
112
+ scope: LinkScopeKind;
113
+ protocolVersion: number;
114
+ };
115
+ "ack": {
116
+ ids?: string[];
117
+ message?: string;
118
+ };
119
+ "error": {
120
+ code: string;
121
+ error: string;
122
+ fatal?: boolean;
123
+ };
124
+ "log": {
125
+ log: string;
126
+ };
127
+ "tool.call": {
128
+ callId: string;
129
+ localId: string;
130
+ args: unknown;
131
+ meta: {
132
+ userId: string;
133
+ chatId?: string;
134
+ runId: string;
135
+ };
136
+ timeoutMs: number;
137
+ };
138
+ "tool.cancel": {
139
+ callId: string;
140
+ reason: string;
141
+ };
142
+ "conversation.open": {
143
+ sessionId: string;
144
+ chatId?: string;
145
+ };
146
+ "conversation.event": {
147
+ chatId?: string;
148
+ event: ConversationEvent;
149
+ };
150
+ "conversation.notice": {
151
+ chatId?: string;
152
+ message: string;
153
+ };
154
+ "conversation.done": {
155
+ chatId?: string;
156
+ ok: boolean;
157
+ code?: string;
158
+ error?: string;
159
+ message?: string;
160
+ };
161
+ "goodbye": {
162
+ reason: string;
163
+ reconnectAfterMs: number;
164
+ };
165
+ };
166
+ export type LinkServerFrameType = keyof LinkServerPayloads;
167
+ /** A union of one member per frame type, so `frame.type` narrows `frame.payload`. */
168
+ export type LinkServerFrame = {
169
+ [T in LinkServerFrameType]: {
170
+ v?: number;
171
+ id: string;
172
+ type: T;
173
+ replyTo?: string;
174
+ payload: LinkServerPayloads[T];
175
+ };
176
+ }[LinkServerFrameType];
177
+ /** One specific server frame, e.g. `LinkServerFrameOf<"conversation.done">`. */
178
+ export type LinkServerFrameOf<T extends LinkServerFrameType> = Extract<LinkServerFrame, {
179
+ type: T;
180
+ }>;
181
+ /** An error reported by the server, carrying the code it used. */
182
+ export declare class LinkError extends Error {
183
+ readonly code: string;
184
+ readonly fatal: boolean;
185
+ constructor(code: string, message: string, fatal?: boolean);
186
+ }
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LinkError = exports.LINK_PROTOCOL_VERSION = void 0;
4
+ /**
5
+ * LINK WIRE PROTOCOL (client side)
6
+ * ===============================
7
+ *
8
+ * The mirror of the server's protocol definition. Every frame is an envelope:
9
+ * `{ v, id, type, replyTo?, payload }`, where `replyTo` carries the id of the
10
+ * frame being answered — that is what makes request/response work over a stream.
11
+ *
12
+ * Keep this in step with the server. Fields are only ever added, never
13
+ * repurposed, so an older SDK stays correct against a newer server.
14
+ */
15
+ exports.LINK_PROTOCOL_VERSION = 1;
16
+ /** An error reported by the server, carrying the code it used. */
17
+ class LinkError extends Error {
18
+ constructor(code, message, fatal = false) {
19
+ super(message);
20
+ this.code = code;
21
+ this.fatal = fatal;
22
+ this.name = "LinkError";
23
+ }
24
+ }
25
+ exports.LinkError = LinkError;