@butlerbot/sdk 0.0.24 → 0.0.26
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/link/link.d.ts +39 -7
- package/dist/link/link.js +106 -30
- package/dist/link/protocol.d.ts +16 -0
- package/dist/modules/conversation.d.ts +14 -2
- package/dist/modules/conversation.js +21 -2
- package/dist/modules/transport.d.ts +13 -0
- package/dist/modules/transport_link.d.ts +13 -1
- package/dist/modules/transport_link.js +73 -0
- package/package.json +1 -1
- package/readme.md +23 -11
package/dist/link/link.d.ts
CHANGED
|
@@ -56,7 +56,11 @@ export type LinkOptions = {
|
|
|
56
56
|
reconnect?: boolean;
|
|
57
57
|
minReconnectDelayMs?: number;
|
|
58
58
|
maxReconnectDelayMs?: number;
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* How long the connection may be silent before it is pinged, which both proves it is
|
|
61
|
+
* alive and keeps proxies from dropping it. A connection that is receiving frames is
|
|
62
|
+
* never pinged. 0 disables. Default 30s.
|
|
63
|
+
*/
|
|
60
64
|
heartbeatMs?: number;
|
|
61
65
|
/** How long to wait for an acknowledgement. Turns are never timed out here. */
|
|
62
66
|
requestTimeoutMs?: number;
|
|
@@ -88,6 +92,8 @@ export declare class Link {
|
|
|
88
92
|
private readonly pending;
|
|
89
93
|
private readonly calls;
|
|
90
94
|
private socket;
|
|
95
|
+
/** Every socket this link has opened and not yet closed, by generation. */
|
|
96
|
+
private readonly sockets;
|
|
91
97
|
private frameCounter;
|
|
92
98
|
private currentState;
|
|
93
99
|
private identity?;
|
|
@@ -110,6 +116,13 @@ export declare class Link {
|
|
|
110
116
|
private reconnectAttempt;
|
|
111
117
|
private reconnectAfterMs;
|
|
112
118
|
private heartbeat?;
|
|
119
|
+
/**
|
|
120
|
+
* When the server last said anything at all.
|
|
121
|
+
*
|
|
122
|
+
* Any frame is proof the connection is alive, so a link that is busy carrying a turn
|
|
123
|
+
* never needs to ask.
|
|
124
|
+
*/
|
|
125
|
+
private lastInboundAt;
|
|
113
126
|
private closedByUs;
|
|
114
127
|
constructor(options: LinkOptions);
|
|
115
128
|
/** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
|
|
@@ -162,6 +175,15 @@ export declare class Link {
|
|
|
162
175
|
* means the real event, whenever it turns up, is ignored.
|
|
163
176
|
*/
|
|
164
177
|
private dropSocket;
|
|
178
|
+
/**
|
|
179
|
+
* Closes a socket this end has stopped using, whatever generation it belongs to.
|
|
180
|
+
*
|
|
181
|
+
* Every path that walks away from a socket goes through here. Forgetting one is not harmless:
|
|
182
|
+
* the server has no way to tell an abandoned connection from a live one — it has said hello,
|
|
183
|
+
* registered its tools and claimed its link id — so it keeps it, keeps serving from it, and
|
|
184
|
+
* hands the link id back and forth between it and its replacements.
|
|
185
|
+
*/
|
|
186
|
+
private abandon;
|
|
165
187
|
private onClose;
|
|
166
188
|
/** Retries when it is allowed to, and tells everyone waiting when it is not. */
|
|
167
189
|
private retryOrGiveUp;
|
|
@@ -174,21 +196,31 @@ export declare class Link {
|
|
|
174
196
|
private scheduleReconnect;
|
|
175
197
|
private clearReconnect;
|
|
176
198
|
/**
|
|
177
|
-
* Pings
|
|
199
|
+
* Pings a SILENT connection and, the important half, notices when a ping goes unanswered.
|
|
178
200
|
*
|
|
179
201
|
* A websocket can die without a close frame — a dropped route, a proxy that forgets
|
|
180
202
|
* the connection, a suspended machine — leaving both ends convinced they are
|
|
181
203
|
* connected while every frame sent into it vanishes. An unanswered ping is the only
|
|
182
204
|
* evidence this end will ever get, so it is treated as a dead connection and
|
|
183
205
|
* reconnected rather than swallowed.
|
|
184
|
-
*/
|
|
185
|
-
private startHeartbeat;
|
|
186
|
-
/**
|
|
187
|
-
* Never longer than the interval itself: a second ping in flight tells us nothing new.
|
|
188
206
|
*
|
|
189
|
-
*
|
|
207
|
+
* It only asks when nothing has arrived for a whole interval, because a connection
|
|
208
|
+
* that is delivering frames has already answered the question. Pinging regardless
|
|
209
|
+
* meant a busy link had to complete a round trip while the socket was carrying a
|
|
210
|
+
* streaming turn: the reply queues behind everything already in flight, and a turn
|
|
211
|
+
* big enough to take longer than the timeout to drain got its own connection torn
|
|
212
|
+
* down with `4000 heartbeat timeout` — always mid-response, always on the longest
|
|
213
|
+
* answers, which are the ones a user least wants to lose.
|
|
190
214
|
*/
|
|
215
|
+
private startHeartbeat;
|
|
216
|
+
/** Wakes when the connection will have been silent for a full interval, not before. */
|
|
217
|
+
private scheduleHeartbeat;
|
|
218
|
+
/** One liveness round trip. Only ever sent to a connection that has gone quiet. */
|
|
219
|
+
private ping;
|
|
220
|
+
/** Never longer than the interval itself: a second ping in flight tells us nothing new. */
|
|
191
221
|
private heartbeatTimeoutMs;
|
|
222
|
+
/** Any frame from the server, of any kind, is proof the connection still works. */
|
|
223
|
+
private markInbound;
|
|
192
224
|
private stopHeartbeat;
|
|
193
225
|
/** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
|
|
194
226
|
private wait;
|
package/dist/link/link.js
CHANGED
|
@@ -28,6 +28,8 @@ class Link {
|
|
|
28
28
|
this.pending = new Map();
|
|
29
29
|
this.calls = new Map();
|
|
30
30
|
this.socket = null;
|
|
31
|
+
/** Every socket this link has opened and not yet closed, by generation. */
|
|
32
|
+
this.sockets = new Map();
|
|
31
33
|
this.frameCounter = 0;
|
|
32
34
|
this.currentState = "idle";
|
|
33
35
|
/**
|
|
@@ -47,6 +49,13 @@ class Link {
|
|
|
47
49
|
this.openWaiters = [];
|
|
48
50
|
this.reconnectAttempt = 0;
|
|
49
51
|
this.reconnectAfterMs = 0;
|
|
52
|
+
/**
|
|
53
|
+
* When the server last said anything at all.
|
|
54
|
+
*
|
|
55
|
+
* Any frame is proof the connection is alive, so a link that is busy carrying a turn
|
|
56
|
+
* never needs to ask.
|
|
57
|
+
*/
|
|
58
|
+
this.lastInboundAt = 0;
|
|
50
59
|
this.closedByUs = false;
|
|
51
60
|
this.options = {
|
|
52
61
|
...DEFAULTS,
|
|
@@ -171,7 +180,10 @@ class Link {
|
|
|
171
180
|
this.failPending(closed);
|
|
172
181
|
this.settleWaiters(this.attemptWaiters, closed);
|
|
173
182
|
this.settleWaiters(this.openWaiters, closed);
|
|
174
|
-
|
|
183
|
+
// Every socket, not just the current one: a link that is being closed for good must not
|
|
184
|
+
// leave anything of itself attached to the server.
|
|
185
|
+
for (const generation of [...this.sockets.keys()])
|
|
186
|
+
this.abandon(generation, reason);
|
|
175
187
|
this.socket = null;
|
|
176
188
|
}
|
|
177
189
|
/**
|
|
@@ -194,14 +206,23 @@ class Link {
|
|
|
194
206
|
const { url, protocols } = (0, socket_1.buildHandshake)(this.options.serverUrl, "link", this.options.apiKey);
|
|
195
207
|
this.debug(`connecting to ${url.replace(/api_key=[^&]+/, "api_key=***")}`);
|
|
196
208
|
try {
|
|
197
|
-
|
|
209
|
+
const socket = this.options.socketFactory(url, protocols, {
|
|
198
210
|
onOpen: () => this.onOpen(generation),
|
|
199
211
|
onMessage: (data) => { if (generation === this.generation)
|
|
200
212
|
this.onMessage(data); },
|
|
201
|
-
onClose: (code, reason) =>
|
|
213
|
+
onClose: (code, reason) => {
|
|
214
|
+
this.sockets.delete(generation);
|
|
215
|
+
this.onClose(generation, code, reason);
|
|
216
|
+
},
|
|
202
217
|
onError: (error) => { if (generation === this.generation)
|
|
203
218
|
this.emitter.emit("error", asError(error)); },
|
|
204
219
|
});
|
|
220
|
+
// Held by generation, not only as `this.socket`, so a socket that stops being the
|
|
221
|
+
// current one can still be closed. One that is merely forgotten stays open at the far
|
|
222
|
+
// end: it has already said hello and registered its tools, so the server goes on serving
|
|
223
|
+
// it, and it answers websocket pings forever because the network stack does that for it.
|
|
224
|
+
this.sockets.set(generation, socket);
|
|
225
|
+
this.socket = socket;
|
|
205
226
|
}
|
|
206
227
|
catch (error) {
|
|
207
228
|
// A factory that throws never produces a close event, so this failure is
|
|
@@ -230,9 +251,11 @@ class Link {
|
|
|
230
251
|
const identity = { connectionId: welcome.connectionId, scope: welcome.scope };
|
|
231
252
|
this.identity = identity;
|
|
232
253
|
await this.registerAll();
|
|
233
|
-
// Registration is several round trips; the socket may have gone during them.
|
|
254
|
+
// Registration is several round trips; the socket may have gone during them. It is fully
|
|
255
|
+
// registered at the server by now, so abandoning it quietly would leave it serving tools
|
|
256
|
+
// nothing here will ever answer.
|
|
234
257
|
if (generation !== this.generation)
|
|
235
|
-
return;
|
|
258
|
+
return this.abandon(generation, "superseded during handshake");
|
|
236
259
|
this.attempting = false;
|
|
237
260
|
this.currentState = "open";
|
|
238
261
|
this.reconnectAttempt = 0;
|
|
@@ -243,7 +266,7 @@ class Link {
|
|
|
243
266
|
this.emitter.emit("connect", identity);
|
|
244
267
|
}).catch((error) => {
|
|
245
268
|
if (generation !== this.generation)
|
|
246
|
-
return;
|
|
269
|
+
return this.abandon(generation, "superseded during handshake");
|
|
247
270
|
this.emitter.emit("error", error);
|
|
248
271
|
this.settleWaiters(this.attemptWaiters, error);
|
|
249
272
|
// Only the server saying "do not come back" stops us: a rejected claim or an
|
|
@@ -266,12 +289,28 @@ class Link {
|
|
|
266
289
|
dropSocket(generation, code, reason) {
|
|
267
290
|
if (generation !== this.generation)
|
|
268
291
|
return;
|
|
269
|
-
const socket = this.socket;
|
|
270
292
|
this.onClose(generation, code, reason);
|
|
293
|
+
// 1006 is reserved and rejected by browsers; anything else we raise is a valid
|
|
294
|
+
// application code.
|
|
295
|
+
this.abandon(generation, reason, code === 1006 ? 1000 : code);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Closes a socket this end has stopped using, whatever generation it belongs to.
|
|
299
|
+
*
|
|
300
|
+
* Every path that walks away from a socket goes through here. Forgetting one is not harmless:
|
|
301
|
+
* the server has no way to tell an abandoned connection from a live one — it has said hello,
|
|
302
|
+
* registered its tools and claimed its link id — so it keeps it, keeps serving from it, and
|
|
303
|
+
* hands the link id back and forth between it and its replacements.
|
|
304
|
+
*/
|
|
305
|
+
abandon(generation, reason, code = 1000) {
|
|
306
|
+
const socket = this.sockets.get(generation);
|
|
307
|
+
if (!socket)
|
|
308
|
+
return;
|
|
309
|
+
this.sockets.delete(generation);
|
|
310
|
+
if (this.socket === socket)
|
|
311
|
+
this.socket = null;
|
|
271
312
|
try {
|
|
272
|
-
|
|
273
|
-
// application code.
|
|
274
|
-
socket?.close(code === 1006 ? 1000 : code, reason);
|
|
313
|
+
socket.close(code, reason);
|
|
275
314
|
}
|
|
276
315
|
catch {
|
|
277
316
|
// Already gone, which is the outcome we wanted anyway.
|
|
@@ -336,46 +375,82 @@ class Link {
|
|
|
336
375
|
this.reconnectTimer = undefined;
|
|
337
376
|
}
|
|
338
377
|
/**
|
|
339
|
-
* Pings
|
|
378
|
+
* Pings a SILENT connection and, the important half, notices when a ping goes unanswered.
|
|
340
379
|
*
|
|
341
380
|
* A websocket can die without a close frame — a dropped route, a proxy that forgets
|
|
342
381
|
* the connection, a suspended machine — leaving both ends convinced they are
|
|
343
382
|
* connected while every frame sent into it vanishes. An unanswered ping is the only
|
|
344
383
|
* evidence this end will ever get, so it is treated as a dead connection and
|
|
345
384
|
* reconnected rather than swallowed.
|
|
385
|
+
*
|
|
386
|
+
* It only asks when nothing has arrived for a whole interval, because a connection
|
|
387
|
+
* that is delivering frames has already answered the question. Pinging regardless
|
|
388
|
+
* meant a busy link had to complete a round trip while the socket was carrying a
|
|
389
|
+
* streaming turn: the reply queues behind everything already in flight, and a turn
|
|
390
|
+
* big enough to take longer than the timeout to drain got its own connection torn
|
|
391
|
+
* down with `4000 heartbeat timeout` — always mid-response, always on the longest
|
|
392
|
+
* answers, which are the ones a user least wants to lose.
|
|
346
393
|
*/
|
|
347
394
|
startHeartbeat(generation) {
|
|
348
395
|
if (!this.options.heartbeatMs)
|
|
349
396
|
return;
|
|
350
397
|
this.stopHeartbeat();
|
|
351
|
-
this.
|
|
398
|
+
this.markInbound();
|
|
399
|
+
this.scheduleHeartbeat(generation);
|
|
400
|
+
}
|
|
401
|
+
/** Wakes when the connection will have been silent for a full interval, not before. */
|
|
402
|
+
scheduleHeartbeat(generation) {
|
|
403
|
+
if (generation !== this.generation || !this.options.heartbeatMs)
|
|
404
|
+
return;
|
|
405
|
+
const silentFor = Date.now() - this.lastInboundAt;
|
|
406
|
+
const due = Math.max(this.options.heartbeatMs - silentFor, 0);
|
|
407
|
+
this.heartbeat = setTimeout(() => {
|
|
352
408
|
if (generation !== this.generation)
|
|
353
409
|
return;
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return;
|
|
362
|
-
this.debug("heartbeat went unanswered, treating the connection as dead");
|
|
363
|
-
this.dropSocket(generation, 4000, "heartbeat timeout");
|
|
364
|
-
});
|
|
365
|
-
}, this.options.heartbeatMs);
|
|
410
|
+
// Something arrived while this was pending: the connection is demonstrably
|
|
411
|
+
// alive and there is nothing to ask. Wait out the rest of its silence instead.
|
|
412
|
+
if (Date.now() - this.lastInboundAt < this.options.heartbeatMs) {
|
|
413
|
+
return this.scheduleHeartbeat(generation);
|
|
414
|
+
}
|
|
415
|
+
this.ping(generation);
|
|
416
|
+
}, due);
|
|
366
417
|
unref(this.heartbeat);
|
|
367
418
|
}
|
|
368
|
-
/**
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
419
|
+
/** One liveness round trip. Only ever sent to a connection that has gone quiet. */
|
|
420
|
+
ping(generation) {
|
|
421
|
+
const sentAt = Date.now();
|
|
422
|
+
// The reply is consumed by the exchange, so it never reaches log listeners.
|
|
423
|
+
this.exchange("ping", {}, {
|
|
424
|
+
awaitReady: false,
|
|
425
|
+
isDone: (frame) => frame.type === "log",
|
|
426
|
+
timeoutMs: this.heartbeatTimeoutMs(),
|
|
427
|
+
}).then(() => {
|
|
428
|
+
this.scheduleHeartbeat(generation);
|
|
429
|
+
}, () => {
|
|
430
|
+
if (generation !== this.generation)
|
|
431
|
+
return;
|
|
432
|
+
// The pong is late but other frames are flowing, so the socket is fine and only
|
|
433
|
+
// this reply is stuck behind them. Killing it here would throw away a working
|
|
434
|
+
// connection — and whatever it was busy delivering.
|
|
435
|
+
if (this.lastInboundAt > sentAt) {
|
|
436
|
+
this.debug("heartbeat was slow but the connection is delivering frames, keeping it");
|
|
437
|
+
return this.scheduleHeartbeat(generation);
|
|
438
|
+
}
|
|
439
|
+
this.debug("heartbeat went unanswered, treating the connection as dead");
|
|
440
|
+
this.dropSocket(generation, 4000, "heartbeat timeout");
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/** Never longer than the interval itself: a second ping in flight tells us nothing new. */
|
|
373
444
|
heartbeatTimeoutMs() {
|
|
374
445
|
return Math.max(250, Math.min(this.options.requestTimeoutMs, this.options.heartbeatMs));
|
|
375
446
|
}
|
|
447
|
+
/** Any frame from the server, of any kind, is proof the connection still works. */
|
|
448
|
+
markInbound() {
|
|
449
|
+
this.lastInboundAt = Date.now();
|
|
450
|
+
}
|
|
376
451
|
stopHeartbeat() {
|
|
377
452
|
if (this.heartbeat)
|
|
378
|
-
|
|
453
|
+
clearTimeout(this.heartbeat);
|
|
379
454
|
this.heartbeat = undefined;
|
|
380
455
|
}
|
|
381
456
|
// =============================================
|
|
@@ -553,6 +628,7 @@ class Link {
|
|
|
553
628
|
this.calls.clear();
|
|
554
629
|
}
|
|
555
630
|
onMessage(raw) {
|
|
631
|
+
this.markInbound();
|
|
556
632
|
let frame;
|
|
557
633
|
try {
|
|
558
634
|
frame = JSON.parse(raw);
|
package/dist/link/protocol.d.ts
CHANGED
|
@@ -105,6 +105,21 @@ export type LinkClientPayloads = {
|
|
|
105
105
|
"conversation.end": {
|
|
106
106
|
sessionId: string;
|
|
107
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* Watches a turn that is already running, without starting one.
|
|
110
|
+
*
|
|
111
|
+
* A turn belongs to the conversation, not to the socket that asked for it, so a
|
|
112
|
+
* client that reloads or opens the conversation elsewhere can pick the answer back
|
|
113
|
+
* up as it is written. `afterEventId` resumes from what the client already has.
|
|
114
|
+
*/
|
|
115
|
+
"conversation.attach": {
|
|
116
|
+
chatId: string;
|
|
117
|
+
afterEventId?: string;
|
|
118
|
+
};
|
|
119
|
+
/** Stops watching. The turn itself keeps running. */
|
|
120
|
+
"conversation.detach": {
|
|
121
|
+
chatId: string;
|
|
122
|
+
};
|
|
108
123
|
};
|
|
109
124
|
export type LinkClientFrameType = keyof LinkClientPayloads;
|
|
110
125
|
/**
|
|
@@ -167,6 +182,7 @@ export type LinkServerPayloads = {
|
|
|
167
182
|
};
|
|
168
183
|
"conversation.event": {
|
|
169
184
|
chatId?: string;
|
|
185
|
+
eventId?: string;
|
|
170
186
|
event: ConversationEvent;
|
|
171
187
|
};
|
|
172
188
|
"conversation.notice": {
|
|
@@ -137,8 +137,20 @@ export declare class Conversation<V extends APIPath = "v4"> {
|
|
|
137
137
|
onceConvoId(cb: (convoId: string) => any): string | undefined;
|
|
138
138
|
/** Fetches the conversation state from the server, including message history and metadata */
|
|
139
139
|
fetchState(): Promise<ConversationStateResponse>;
|
|
140
|
-
/**
|
|
141
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Follows the turn currently running in this conversation.
|
|
142
|
+
*
|
|
143
|
+
* Reopening a conversation mid-answer is watching a turn, not starting one, and a
|
|
144
|
+
* turn belongs to the conversation rather than to whoever started it. When the
|
|
145
|
+
* conversation is carried over a websocket Link this rides that same connection;
|
|
146
|
+
* otherwise it opens the HTTP progress stream. Either way the payloads are the same.
|
|
147
|
+
*
|
|
148
|
+
* `afterEventId` resumes from what the caller already has, so a client that reloads
|
|
149
|
+
* is sent what it missed rather than the turn from the beginning.
|
|
150
|
+
*/
|
|
151
|
+
fetchProgressStream(cb: (chunk: RequestResponseByVersion[V]) => any, options?: {
|
|
152
|
+
afterEventId?: string;
|
|
153
|
+
}): ConversationStream | EventSource;
|
|
142
154
|
/**
|
|
143
155
|
* Fetches the conversation progress from the server
|
|
144
156
|
* Returns undefined if no active turn progress
|
|
@@ -176,10 +176,29 @@ class Conversation {
|
|
|
176
176
|
const data = await response.json();
|
|
177
177
|
return data;
|
|
178
178
|
}
|
|
179
|
-
/**
|
|
180
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Follows the turn currently running in this conversation.
|
|
181
|
+
*
|
|
182
|
+
* Reopening a conversation mid-answer is watching a turn, not starting one, and a
|
|
183
|
+
* turn belongs to the conversation rather than to whoever started it. When the
|
|
184
|
+
* conversation is carried over a websocket Link this rides that same connection;
|
|
185
|
+
* otherwise it opens the HTTP progress stream. Either way the payloads are the same.
|
|
186
|
+
*
|
|
187
|
+
* `afterEventId` resumes from what the caller already has, so a client that reloads
|
|
188
|
+
* is sent what it missed rather than the turn from the beginning.
|
|
189
|
+
*/
|
|
190
|
+
fetchProgressStream(cb, options) {
|
|
181
191
|
if (!this.convoId)
|
|
182
192
|
throw new Error("Conversation ID is not set");
|
|
193
|
+
if (this.transport.attach) {
|
|
194
|
+
return this.transport.attach({
|
|
195
|
+
chatId: this.convoId,
|
|
196
|
+
...(options?.afterEventId ? { afterEventId: options.afterEventId } : {}),
|
|
197
|
+
}, {
|
|
198
|
+
payload: (payload) => cb(payload),
|
|
199
|
+
convoId: () => { },
|
|
200
|
+
});
|
|
201
|
+
}
|
|
183
202
|
const url = (0, url_formatter_1.formatURL)(this.endpoints.progressStream, { chatId: this.convoId }, { apiKey: this.apiKey, debug: this.debug });
|
|
184
203
|
return (0, transport_sse_1.streamSSE)(url, { debug: this.debug, onPayload: (payload) => cb(payload) });
|
|
185
204
|
}
|
|
@@ -30,8 +30,21 @@ export type TransportHandlers = {
|
|
|
30
30
|
/** The conversation this turn belongs to, as soon as it is known. */
|
|
31
31
|
convoId(convoId: string): void;
|
|
32
32
|
};
|
|
33
|
+
/** A request to watch a turn that is already running in a conversation. */
|
|
34
|
+
export type TransportAttachRequest = {
|
|
35
|
+
chatId: string;
|
|
36
|
+
/** Resume point: only what came after this event is replayed. */
|
|
37
|
+
afterEventId?: string;
|
|
38
|
+
};
|
|
33
39
|
export interface ConversationTransport {
|
|
34
40
|
send(request: TransportTurnRequest, handlers: TransportHandlers): ConversationStream;
|
|
41
|
+
/**
|
|
42
|
+
* Watches a turn that is already running, without starting one.
|
|
43
|
+
*
|
|
44
|
+
* Optional: a transport that cannot follow someone else's turn simply does not
|
|
45
|
+
* implement it, and the conversation falls back to the HTTP progress stream.
|
|
46
|
+
*/
|
|
47
|
+
attach?(request: TransportAttachRequest, handlers: TransportHandlers): ConversationStream;
|
|
35
48
|
}
|
|
36
49
|
/**
|
|
37
50
|
* The system message form the HTTP transport uses for a notice.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Link } from "../link/link";
|
|
2
|
-
import { ConversationStream, ConversationTransport, TransportHandlers, TransportTurnRequest } from "./transport";
|
|
2
|
+
import { ConversationStream, ConversationTransport, TransportAttachRequest, TransportHandlers, TransportTurnRequest } from "./transport";
|
|
3
3
|
export type LinkSessionConfig = {
|
|
4
4
|
model?: string;
|
|
5
5
|
personality?: string;
|
|
@@ -22,6 +22,18 @@ export declare class LinkConversationTransport implements ConversationTransport
|
|
|
22
22
|
send(request: TransportTurnRequest, handlers: TransportHandlers): ConversationStream;
|
|
23
23
|
/** Ends the session, if one is open. The conversation can still be resumed later. */
|
|
24
24
|
end(): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Follows a turn that is already running, over the link this conversation already holds.
|
|
27
|
+
*
|
|
28
|
+
* A turn belongs to the conversation rather than to the socket that started it, so
|
|
29
|
+
* reopening a conversation mid-answer — a reload, a second tab, a turn started from
|
|
30
|
+
* another device or over HTTP — streams here instead of dropping to an SSE connection
|
|
31
|
+
* just to watch. Needs no session: watching is not speaking.
|
|
32
|
+
*
|
|
33
|
+
* A conversation with nothing running ends the stream immediately, which is the
|
|
34
|
+
* ordinary answer for one that is simply idle.
|
|
35
|
+
*/
|
|
36
|
+
attach(request: TransportAttachRequest, handlers: TransportHandlers): ConversationStream;
|
|
25
37
|
private runTurn;
|
|
26
38
|
/** Opens a session, or reuses the open one when it is for the same conversation. */
|
|
27
39
|
private session;
|
|
@@ -41,6 +41,79 @@ class LinkConversationTransport {
|
|
|
41
41
|
this.sessionId = undefined;
|
|
42
42
|
await this.link.exchange("conversation.end", { sessionId });
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Follows a turn that is already running, over the link this conversation already holds.
|
|
46
|
+
*
|
|
47
|
+
* A turn belongs to the conversation rather than to the socket that started it, so
|
|
48
|
+
* reopening a conversation mid-answer — a reload, a second tab, a turn started from
|
|
49
|
+
* another device or over HTTP — streams here instead of dropping to an SSE connection
|
|
50
|
+
* just to watch. Needs no session: watching is not speaking.
|
|
51
|
+
*
|
|
52
|
+
* A conversation with nothing running ends the stream immediately, which is the
|
|
53
|
+
* ordinary answer for one that is simply idle.
|
|
54
|
+
*/
|
|
55
|
+
attach(request, handlers) {
|
|
56
|
+
let closed = false;
|
|
57
|
+
const deliver = (payload) => { if (!closed)
|
|
58
|
+
handlers.payload(payload); };
|
|
59
|
+
const watching = this.link.exchange("conversation.attach", {
|
|
60
|
+
chatId: request.chatId,
|
|
61
|
+
...(request.afterEventId ? { afterEventId: request.afterEventId } : {}),
|
|
62
|
+
}, {
|
|
63
|
+
// A turn takes as long as it takes; only the transport dying ends it early.
|
|
64
|
+
timeoutMs: 0,
|
|
65
|
+
isDone: (frame) => frame.type === "conversation.done",
|
|
66
|
+
onFrame: (frame) => {
|
|
67
|
+
if (frame.type === "conversation.event") {
|
|
68
|
+
const payload = frame.payload;
|
|
69
|
+
const event = payload.event;
|
|
70
|
+
const final = event.type === "response_status" && Boolean(event.payload?.completed);
|
|
71
|
+
deliver({
|
|
72
|
+
success: true,
|
|
73
|
+
data: {
|
|
74
|
+
response: event,
|
|
75
|
+
convoId: payload.chatId ?? request.chatId,
|
|
76
|
+
...(final ? { quitStream: true } : {}),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (frame.type === "conversation.notice") {
|
|
82
|
+
deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? request.chatId));
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
void watching.then((done) => {
|
|
87
|
+
const payload = done.payload;
|
|
88
|
+
if (payload.ok)
|
|
89
|
+
return;
|
|
90
|
+
// Nothing running is not a failure: the caller asked to watch a conversation
|
|
91
|
+
// that has nothing to watch, and the stream simply ends.
|
|
92
|
+
if (payload.code === "no_active_turn")
|
|
93
|
+
return;
|
|
94
|
+
deliver((0, transport_1.failurePayload)(payload.code ?? "link_error", payload.error ?? "The turn could not be watched.", payload.message ?? payload.error ?? "I'm afraid I couldn't follow that response.", payload.chatId ?? request.chatId));
|
|
95
|
+
}, (error) => {
|
|
96
|
+
if (error instanceof protocol_1.LinkError && error.code === "no_active_turn")
|
|
97
|
+
return;
|
|
98
|
+
deliver(error instanceof protocol_1.LinkError
|
|
99
|
+
? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
|
|
100
|
+
: (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId));
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
close: () => {
|
|
104
|
+
if (closed)
|
|
105
|
+
return;
|
|
106
|
+
closed = true;
|
|
107
|
+
// Best-effort: a socket that has gone has already ended the watch for us.
|
|
108
|
+
try {
|
|
109
|
+
this.link.send("conversation.detach", { chatId: request.chatId });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Disconnected. The server drops the attachment with the connection.
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
44
117
|
async runTurn(request, handlers, mayRetry) {
|
|
45
118
|
const sessionId = await this.session(request);
|
|
46
119
|
let chatId = request.chatId ?? this.sessionChatId;
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
# ButlerBot SDK
|
|
2
2
|
|
|
3
|
-
ButlerBot SDK is a JavaScript library that provides a simple way to interact with the [
|
|
3
|
+
ButlerBot SDK is a JavaScript library that provides a simple way to interact with the [Butler](https://butler.now/) API.
|
|
4
4
|
|
|
5
5
|
## Quickstart
|
|
6
6
|
|
|
7
|
-
Grab an API key at [
|
|
7
|
+
Grab an API key at [Butler](https://butler.now/) (Dashboard -> Account Dropdown -> API Keys) and install the package:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npm i @butlerbot/sdk
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
> NOTE: API key is currently not available on the ButlerBot UI
|
|
14
|
-
|
|
15
13
|
## Prerequisites
|
|
16
14
|
|
|
17
|
-
-
|
|
15
|
+
- Butler API key
|
|
18
16
|
|
|
19
17
|
## Talking to Alfred
|
|
20
18
|
|
|
@@ -212,13 +210,27 @@ Which to use:
|
|
|
212
210
|
Best when you are already running a link for tools or hooks, or holding many
|
|
213
211
|
conversations at once — one socket carries them all.
|
|
214
212
|
|
|
215
|
-
|
|
213
|
+
One difference to know about: sessions are ephemeral. If the connection drops mid-turn
|
|
214
|
+
the SDK reopens the session and resends transparently; the conversation itself is
|
|
215
|
+
persisted server-side, so nothing is lost.
|
|
216
|
+
|
|
217
|
+
### Rejoining a turn already in progress
|
|
218
|
+
|
|
219
|
+
A turn belongs to the conversation, not to whoever started it, so reopening a
|
|
220
|
+
conversation mid-answer picks the reply back up as it is written:
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
const convo = client.createConversation({ convoId, transport: link });
|
|
224
|
+
const watching = convo.fetchProgressStream(chunk => render(chunk));
|
|
225
|
+
// ...later
|
|
226
|
+
watching.close(); // stop watching; the turn keeps running
|
|
227
|
+
```
|
|
216
228
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
229
|
+
Over a Link this rides the connection you already hold; over SSE it opens the HTTP
|
|
230
|
+
progress stream. The payloads are identical either way, including your own message and
|
|
231
|
+
the conversation's start — everything a client that arrived late needs to draw the turn
|
|
232
|
+
from the beginning. Pass `{ afterEventId }` to be sent only what you have not already
|
|
233
|
+
seen. A conversation with nothing running simply ends the stream.
|
|
222
234
|
|
|
223
235
|
Neither transport can cancel a turn: `close()` stops delivery locally, and the reply is
|
|
224
236
|
still generated and stored.
|