@butlerbot/sdk 0.0.25 → 0.0.27
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 +28 -7
- package/dist/link/link.js +64 -20
- package/dist/link/protocol.d.ts +16 -0
- package/dist/modules/conversation.d.ts +34 -2
- package/dist/modules/conversation.js +49 -8
- package/dist/modules/stream_accumulator.d.ts +29 -0
- package/dist/modules/stream_accumulator.js +79 -0
- 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/dist/types/conversation/v3/conversation_v3.d.ts +18 -2
- package/dist/types/response/v5/ai_response_v5.d.ts +18 -2
- package/package.json +1 -1
- package/readme.md +56 -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;
|
|
@@ -112,6 +116,13 @@ export declare class Link {
|
|
|
112
116
|
private reconnectAttempt;
|
|
113
117
|
private reconnectAfterMs;
|
|
114
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;
|
|
115
126
|
private closedByUs;
|
|
116
127
|
constructor(options: LinkOptions);
|
|
117
128
|
/** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
|
|
@@ -185,21 +196,31 @@ export declare class Link {
|
|
|
185
196
|
private scheduleReconnect;
|
|
186
197
|
private clearReconnect;
|
|
187
198
|
/**
|
|
188
|
-
* Pings
|
|
199
|
+
* Pings a SILENT connection and, the important half, notices when a ping goes unanswered.
|
|
189
200
|
*
|
|
190
201
|
* A websocket can die without a close frame — a dropped route, a proxy that forgets
|
|
191
202
|
* the connection, a suspended machine — leaving both ends convinced they are
|
|
192
203
|
* connected while every frame sent into it vanishes. An unanswered ping is the only
|
|
193
204
|
* evidence this end will ever get, so it is treated as a dead connection and
|
|
194
205
|
* reconnected rather than swallowed.
|
|
195
|
-
*/
|
|
196
|
-
private startHeartbeat;
|
|
197
|
-
/**
|
|
198
|
-
* Never longer than the interval itself: a second ping in flight tells us nothing new.
|
|
199
206
|
*
|
|
200
|
-
*
|
|
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.
|
|
201
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. */
|
|
202
221
|
private heartbeatTimeoutMs;
|
|
222
|
+
/** Any frame from the server, of any kind, is proof the connection still works. */
|
|
223
|
+
private markInbound;
|
|
203
224
|
private stopHeartbeat;
|
|
204
225
|
/** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
|
|
205
226
|
private wait;
|
package/dist/link/link.js
CHANGED
|
@@ -49,6 +49,13 @@ class Link {
|
|
|
49
49
|
this.openWaiters = [];
|
|
50
50
|
this.reconnectAttempt = 0;
|
|
51
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;
|
|
52
59
|
this.closedByUs = false;
|
|
53
60
|
this.options = {
|
|
54
61
|
...DEFAULTS,
|
|
@@ -368,46 +375,82 @@ class Link {
|
|
|
368
375
|
this.reconnectTimer = undefined;
|
|
369
376
|
}
|
|
370
377
|
/**
|
|
371
|
-
* Pings
|
|
378
|
+
* Pings a SILENT connection and, the important half, notices when a ping goes unanswered.
|
|
372
379
|
*
|
|
373
380
|
* A websocket can die without a close frame — a dropped route, a proxy that forgets
|
|
374
381
|
* the connection, a suspended machine — leaving both ends convinced they are
|
|
375
382
|
* connected while every frame sent into it vanishes. An unanswered ping is the only
|
|
376
383
|
* evidence this end will ever get, so it is treated as a dead connection and
|
|
377
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.
|
|
378
393
|
*/
|
|
379
394
|
startHeartbeat(generation) {
|
|
380
395
|
if (!this.options.heartbeatMs)
|
|
381
396
|
return;
|
|
382
397
|
this.stopHeartbeat();
|
|
383
|
-
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(() => {
|
|
384
408
|
if (generation !== this.generation)
|
|
385
409
|
return;
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return;
|
|
394
|
-
this.debug("heartbeat went unanswered, treating the connection as dead");
|
|
395
|
-
this.dropSocket(generation, 4000, "heartbeat timeout");
|
|
396
|
-
});
|
|
397
|
-
}, 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);
|
|
398
417
|
unref(this.heartbeat);
|
|
399
418
|
}
|
|
400
|
-
/**
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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. */
|
|
405
444
|
heartbeatTimeoutMs() {
|
|
406
445
|
return Math.max(250, Math.min(this.options.requestTimeoutMs, this.options.heartbeatMs));
|
|
407
446
|
}
|
|
447
|
+
/** Any frame from the server, of any kind, is proof the connection still works. */
|
|
448
|
+
markInbound() {
|
|
449
|
+
this.lastInboundAt = Date.now();
|
|
450
|
+
}
|
|
408
451
|
stopHeartbeat() {
|
|
409
452
|
if (this.heartbeat)
|
|
410
|
-
|
|
453
|
+
clearTimeout(this.heartbeat);
|
|
411
454
|
this.heartbeat = undefined;
|
|
412
455
|
}
|
|
413
456
|
// =============================================
|
|
@@ -585,6 +628,7 @@ class Link {
|
|
|
585
628
|
this.calls.clear();
|
|
586
629
|
}
|
|
587
630
|
onMessage(raw) {
|
|
631
|
+
this.markInbound();
|
|
588
632
|
let frame;
|
|
589
633
|
try {
|
|
590
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": {
|
|
@@ -64,6 +64,18 @@ export type ConversationOptions<V extends APIPath = "v4"> = {
|
|
|
64
64
|
* have — everything else, including the payloads you receive, is identical.
|
|
65
65
|
*/
|
|
66
66
|
transport?: "sse" | Link;
|
|
67
|
+
/**
|
|
68
|
+
* Whether streamed text is put back together for you. On by default.
|
|
69
|
+
*
|
|
70
|
+
* The server streams each message a piece at a time. With this on, `payload.message`
|
|
71
|
+
* is the whole message so far — what it has always been — and `payload.delta` is what
|
|
72
|
+
* the event added, for anyone who would rather append than re-render.
|
|
73
|
+
*
|
|
74
|
+
* Turn it off to be handed the wire payloads untouched, where a piece arrives as
|
|
75
|
+
* `delta` with no `message` beside it. Worth it only if you are appending anyway and
|
|
76
|
+
* want nothing between you and the socket.
|
|
77
|
+
*/
|
|
78
|
+
accumulateStream?: boolean;
|
|
67
79
|
};
|
|
68
80
|
export declare class Conversation<V extends APIPath = "v4"> {
|
|
69
81
|
convoId?: string;
|
|
@@ -73,6 +85,7 @@ export declare class Conversation<V extends APIPath = "v4"> {
|
|
|
73
85
|
private options?;
|
|
74
86
|
private events;
|
|
75
87
|
private transport;
|
|
88
|
+
private accumulateStream;
|
|
76
89
|
/** The link carrying this conversation, when it is not on SSE. */
|
|
77
90
|
readonly link?: Link;
|
|
78
91
|
private endpoints;
|
|
@@ -137,8 +150,20 @@ export declare class Conversation<V extends APIPath = "v4"> {
|
|
|
137
150
|
onceConvoId(cb: (convoId: string) => any): string | undefined;
|
|
138
151
|
/** Fetches the conversation state from the server, including message history and metadata */
|
|
139
152
|
fetchState(): Promise<ConversationStateResponse>;
|
|
140
|
-
/**
|
|
141
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Follows the turn currently running in this conversation.
|
|
155
|
+
*
|
|
156
|
+
* Reopening a conversation mid-answer is watching a turn, not starting one, and a
|
|
157
|
+
* turn belongs to the conversation rather than to whoever started it. When the
|
|
158
|
+
* conversation is carried over a websocket Link this rides that same connection;
|
|
159
|
+
* otherwise it opens the HTTP progress stream. Either way the payloads are the same.
|
|
160
|
+
*
|
|
161
|
+
* `afterEventId` resumes from what the caller already has, so a client that reloads
|
|
162
|
+
* is sent what it missed rather than the turn from the beginning.
|
|
163
|
+
*/
|
|
164
|
+
fetchProgressStream(cb: (chunk: RequestResponseByVersion[V]) => any, options?: {
|
|
165
|
+
afterEventId?: string;
|
|
166
|
+
}): ConversationStream | EventSource;
|
|
142
167
|
/**
|
|
143
168
|
* Fetches the conversation progress from the server
|
|
144
169
|
* Returns undefined if no active turn progress
|
|
@@ -147,6 +172,13 @@ export declare class Conversation<V extends APIPath = "v4"> {
|
|
|
147
172
|
lastEventId?: string;
|
|
148
173
|
includeCompleted?: boolean;
|
|
149
174
|
}): Promise<TurnProgressEntryForVersion<V>[] | undefined>;
|
|
175
|
+
/**
|
|
176
|
+
* One stream's worth of delivery: rebuilds whole values, then hands them to the caller.
|
|
177
|
+
*
|
|
178
|
+
* The accumulator is made per stream rather than per conversation because it holds the
|
|
179
|
+
* text of whatever is still being written, and two streams are two different answers.
|
|
180
|
+
*/
|
|
181
|
+
private receiver;
|
|
150
182
|
/** Sends a message into the conversation */
|
|
151
183
|
send(message: string, cb: (chunk: RequestResponseByVersion[V]) => any, options?: DialogueRequestOptions): ConversationStream;
|
|
152
184
|
/**
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Conversation = void 0;
|
|
4
4
|
const config_1 = require("../config");
|
|
5
5
|
const emitter_1 = require("../util/emitter");
|
|
6
|
+
const stream_accumulator_1 = require("./stream_accumulator");
|
|
6
7
|
const transport_link_1 = require("./transport_link");
|
|
7
8
|
const transport_sse_1 = require("./transport_sse");
|
|
8
9
|
const url_formatter_1 = require("../util/url_formatter");
|
|
@@ -22,6 +23,7 @@ class Conversation {
|
|
|
22
23
|
};
|
|
23
24
|
this.apiKey = config.apiKey;
|
|
24
25
|
this.debug = config.debug || false;
|
|
26
|
+
this.accumulateStream = config.accumulateStream ?? true;
|
|
25
27
|
if (config.transport && config.transport !== "sse") {
|
|
26
28
|
this.link = config.transport;
|
|
27
29
|
this.transport = new transport_link_1.LinkConversationTransport(config.transport, () => ({
|
|
@@ -176,12 +178,34 @@ class Conversation {
|
|
|
176
178
|
const data = await response.json();
|
|
177
179
|
return data;
|
|
178
180
|
}
|
|
179
|
-
/**
|
|
180
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Follows the turn currently running in this conversation.
|
|
183
|
+
*
|
|
184
|
+
* Reopening a conversation mid-answer is watching a turn, not starting one, and a
|
|
185
|
+
* turn belongs to the conversation rather than to whoever started it. When the
|
|
186
|
+
* conversation is carried over a websocket Link this rides that same connection;
|
|
187
|
+
* otherwise it opens the HTTP progress stream. Either way the payloads are the same.
|
|
188
|
+
*
|
|
189
|
+
* `afterEventId` resumes from what the caller already has, so a client that reloads
|
|
190
|
+
* is sent what it missed rather than the turn from the beginning.
|
|
191
|
+
*/
|
|
192
|
+
fetchProgressStream(cb, options) {
|
|
181
193
|
if (!this.convoId)
|
|
182
194
|
throw new Error("Conversation ID is not set");
|
|
195
|
+
// A watcher joins mid-answer: it is caught up with whole values and then follows the
|
|
196
|
+
// rest a piece at a time, which the accumulator handles either way.
|
|
197
|
+
const receive = this.receiver(cb);
|
|
198
|
+
if (this.transport.attach) {
|
|
199
|
+
return this.transport.attach({
|
|
200
|
+
chatId: this.convoId,
|
|
201
|
+
...(options?.afterEventId ? { afterEventId: options.afterEventId } : {}),
|
|
202
|
+
}, {
|
|
203
|
+
payload: receive,
|
|
204
|
+
convoId: () => { },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
183
207
|
const url = (0, url_formatter_1.formatURL)(this.endpoints.progressStream, { chatId: this.convoId }, { apiKey: this.apiKey, debug: this.debug });
|
|
184
|
-
return (0, transport_sse_1.streamSSE)(url, { debug: this.debug, onPayload:
|
|
208
|
+
return (0, transport_sse_1.streamSSE)(url, { debug: this.debug, onPayload: receive });
|
|
185
209
|
}
|
|
186
210
|
/**
|
|
187
211
|
* Fetches the conversation progress from the server
|
|
@@ -207,15 +231,28 @@ class Conversation {
|
|
|
207
231
|
return data.events;
|
|
208
232
|
}
|
|
209
233
|
// LIFE CYCLE
|
|
234
|
+
/**
|
|
235
|
+
* One stream's worth of delivery: rebuilds whole values, then hands them to the caller.
|
|
236
|
+
*
|
|
237
|
+
* The accumulator is made per stream rather than per conversation because it holds the
|
|
238
|
+
* text of whatever is still being written, and two streams are two different answers.
|
|
239
|
+
*/
|
|
240
|
+
receiver(cb) {
|
|
241
|
+
if (!this.accumulateStream)
|
|
242
|
+
return (payload) => cb(payload);
|
|
243
|
+
const accumulate = (0, stream_accumulator_1.createStreamAccumulator)();
|
|
244
|
+
return (payload) => cb(accumulate(payload));
|
|
245
|
+
}
|
|
210
246
|
/** Sends a message into the conversation */
|
|
211
247
|
send(message, cb, options) {
|
|
248
|
+
const receive = this.receiver(cb);
|
|
212
249
|
return this.transport.send({
|
|
213
250
|
message,
|
|
214
251
|
...this.options, // options set for convo
|
|
215
252
|
...options, // overwrite convo's for this call
|
|
216
253
|
...(this.convoId ? { chatId: this.convoId } : {}),
|
|
217
254
|
}, {
|
|
218
|
-
payload:
|
|
255
|
+
payload: receive,
|
|
219
256
|
convoId: (convoId) => {
|
|
220
257
|
if (this.convoId === convoId)
|
|
221
258
|
return;
|
|
@@ -243,10 +280,14 @@ class Conversation {
|
|
|
243
280
|
}
|
|
244
281
|
const response = chunk.data.response;
|
|
245
282
|
const metadata = chunk.data.response.metadata;
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
283
|
+
// Alfred's own notices are not part of the reply, so they are collected but
|
|
284
|
+
// not concatenated into it. Appending the piece and replacing on a whole
|
|
285
|
+
// value is right whether or not the caller left accumulation on.
|
|
286
|
+
if (response.type === "message" && metadata?.participantId !== "system") {
|
|
287
|
+
if (typeof response.payload?.delta === "string")
|
|
288
|
+
text += response.payload.delta;
|
|
289
|
+
else if (response.payload?.message)
|
|
290
|
+
text = response.payload.message;
|
|
250
291
|
}
|
|
251
292
|
if (chunk.data.quitStream)
|
|
252
293
|
resolve({ text, convoId: this.convoId, events });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STREAM ACCUMULATION
|
|
3
|
+
* ===================
|
|
4
|
+
*
|
|
5
|
+
* The server streams text a piece at a time.
|
|
6
|
+
*
|
|
7
|
+
* It used to send the whole message again on every token, which is O(N²) bytes for an
|
|
8
|
+
* N-token reply — slow everywhere, and fatal on a Link websocket, where the answer queues
|
|
9
|
+
* ahead of the connection's own heartbeat until this SDK closes it mid-sentence. A
|
|
10
|
+
* streamed value now arrives as one of two shapes, and never both:
|
|
11
|
+
*
|
|
12
|
+
* { messageId, message: "Good day to you", completed } // the whole value: replace
|
|
13
|
+
* { messageId, delta: " to you", completed: false } // what was added: append
|
|
14
|
+
*
|
|
15
|
+
* Callers should not have to care. This puts the message back together, so
|
|
16
|
+
* `payload.message` is the whole message so far exactly as it always was, and keeps
|
|
17
|
+
* `payload.delta` for anyone who would rather append than re-render.
|
|
18
|
+
*
|
|
19
|
+
* Whole values arrive for the last event of a message and for anything replaying after a
|
|
20
|
+
* reconnect, and they replace rather than extend. That is what makes a reconnect cheap and
|
|
21
|
+
* a dropped delta harmless, and it is handled here so no caller has to know about it.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Rebuilds whole values from a stream of pieces.
|
|
25
|
+
*
|
|
26
|
+
* Stateful, and one per stream: it holds the text of every message the stream is still
|
|
27
|
+
* writing, so a turn and a progress stream never see each other's.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createStreamAccumulator(): (payload: unknown) => unknown;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* STREAM ACCUMULATION
|
|
4
|
+
* ===================
|
|
5
|
+
*
|
|
6
|
+
* The server streams text a piece at a time.
|
|
7
|
+
*
|
|
8
|
+
* It used to send the whole message again on every token, which is O(N²) bytes for an
|
|
9
|
+
* N-token reply — slow everywhere, and fatal on a Link websocket, where the answer queues
|
|
10
|
+
* ahead of the connection's own heartbeat until this SDK closes it mid-sentence. A
|
|
11
|
+
* streamed value now arrives as one of two shapes, and never both:
|
|
12
|
+
*
|
|
13
|
+
* { messageId, message: "Good day to you", completed } // the whole value: replace
|
|
14
|
+
* { messageId, delta: " to you", completed: false } // what was added: append
|
|
15
|
+
*
|
|
16
|
+
* Callers should not have to care. This puts the message back together, so
|
|
17
|
+
* `payload.message` is the whole message so far exactly as it always was, and keeps
|
|
18
|
+
* `payload.delta` for anyone who would rather append than re-render.
|
|
19
|
+
*
|
|
20
|
+
* Whole values arrive for the last event of a message and for anything replaying after a
|
|
21
|
+
* reconnect, and they replace rather than extend. That is what makes a reconnect cheap and
|
|
22
|
+
* a dropped delta harmless, and it is handled here so no caller has to know about it.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.createStreamAccumulator = createStreamAccumulator;
|
|
26
|
+
/** Which field of a payload holds the streamed text, and what identifies it. */
|
|
27
|
+
const STREAMED_FIELDS = {
|
|
28
|
+
message: { id: "messageId", text: "message" },
|
|
29
|
+
reasoning: { id: "reasoningId", text: "reasoning" },
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Rebuilds whole values from a stream of pieces.
|
|
33
|
+
*
|
|
34
|
+
* Stateful, and one per stream: it holds the text of every message the stream is still
|
|
35
|
+
* writing, so a turn and a progress stream never see each other's.
|
|
36
|
+
*/
|
|
37
|
+
function createStreamAccumulator() {
|
|
38
|
+
const values = new Map();
|
|
39
|
+
return (payload) => {
|
|
40
|
+
const response = payload;
|
|
41
|
+
const event = response?.data?.response;
|
|
42
|
+
const fields = event?.type ? STREAMED_FIELDS[event.type] : undefined;
|
|
43
|
+
if (!event?.payload || !fields)
|
|
44
|
+
return payload;
|
|
45
|
+
const id = event.payload[fields.id];
|
|
46
|
+
if (typeof id !== "string")
|
|
47
|
+
return payload;
|
|
48
|
+
// Which field is there is the whole discriminant — never `completed`, which says
|
|
49
|
+
// nothing about the shape: a whole value arrives incomplete whenever this client is
|
|
50
|
+
// being caught up in the middle of a message.
|
|
51
|
+
const { delta } = event.payload;
|
|
52
|
+
const whole = event.payload[fields.text];
|
|
53
|
+
if (typeof delta !== "string" && typeof whole !== "string")
|
|
54
|
+
return payload;
|
|
55
|
+
const text = typeof delta === "string" ? (values.get(id) ?? "") + delta : whole;
|
|
56
|
+
// A finished value is the last anyone will hear of that id. Holding it would only
|
|
57
|
+
// leak, and ids are reused across the steps of a turn.
|
|
58
|
+
if (event.payload.completed)
|
|
59
|
+
values.delete(id);
|
|
60
|
+
else
|
|
61
|
+
values.set(id, text);
|
|
62
|
+
return {
|
|
63
|
+
...response,
|
|
64
|
+
data: {
|
|
65
|
+
...response.data,
|
|
66
|
+
response: {
|
|
67
|
+
...event,
|
|
68
|
+
payload: {
|
|
69
|
+
...event.payload,
|
|
70
|
+
[fields.text]: text,
|
|
71
|
+
// Kept as it came: present means this event appended, absent means it
|
|
72
|
+
// replaced. Callers rendering incrementally read exactly this.
|
|
73
|
+
...(typeof delta === "string" ? { delta } : {}),
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -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;
|
|
@@ -144,20 +144,36 @@ export type ResponseMetadata = {
|
|
|
144
144
|
};
|
|
145
145
|
};
|
|
146
146
|
export type MessagePayload = {
|
|
147
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* The message content, whole: everything written so far.
|
|
149
|
+
*
|
|
150
|
+
* The server streams a message a piece at a time; this is the SDK putting it back
|
|
151
|
+
* together, so it reads the same as it always has. With `accumulateStream: false` you
|
|
152
|
+
* get the wire payloads instead, where a piece arrives as `delta` and this is absent.
|
|
153
|
+
*/
|
|
148
154
|
message: string;
|
|
149
155
|
/** The UUID of this message */
|
|
150
156
|
messageId: string;
|
|
151
157
|
/** Whether this message chunk is the final one */
|
|
152
158
|
completed: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* What this event added to the message, when it added anything.
|
|
161
|
+
*
|
|
162
|
+
* Append this instead of re-rendering `message` and you never redraw text you already
|
|
163
|
+
* have. Absent when the whole value arrived at once — the last event of a message, and
|
|
164
|
+
* whatever catches you up after a reconnect — which replaces rather than extends.
|
|
165
|
+
*/
|
|
166
|
+
delta?: string;
|
|
153
167
|
};
|
|
154
168
|
export type ReasoningPayload = {
|
|
155
|
-
/** The reasoning content */
|
|
169
|
+
/** The reasoning content, whole. See {@link MessagePayload.message}. */
|
|
156
170
|
reasoning: string;
|
|
157
171
|
/** The UUID of this reasoning */
|
|
158
172
|
reasoningId: string;
|
|
159
173
|
/** Whether this reasoning chunk is the final one */
|
|
160
174
|
completed: boolean;
|
|
175
|
+
/** What this event added. See {@link MessagePayload.delta}. */
|
|
176
|
+
delta?: string;
|
|
161
177
|
};
|
|
162
178
|
export type FilePayload = {
|
|
163
179
|
/** The file URL */
|
|
@@ -55,20 +55,36 @@ export type BaseResponseMetadata = {
|
|
|
55
55
|
participantId?: string;
|
|
56
56
|
};
|
|
57
57
|
export type MessagePayload = {
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* The message content, whole: everything written so far.
|
|
60
|
+
*
|
|
61
|
+
* The server streams a message a piece at a time; this is the SDK putting it back
|
|
62
|
+
* together, so it reads the same as it always has. With `accumulateStream: false` you
|
|
63
|
+
* get the wire payloads instead, where a piece arrives as `delta` and this is absent.
|
|
64
|
+
*/
|
|
59
65
|
message: string;
|
|
60
66
|
/** The UUID of this message */
|
|
61
67
|
messageId: string;
|
|
62
68
|
/** Whether this message chunk is the final one */
|
|
63
69
|
completed: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* What this event added to the message, when it added anything.
|
|
72
|
+
*
|
|
73
|
+
* Append this instead of re-rendering `message` and you never redraw text you already
|
|
74
|
+
* have. Absent when the whole value arrived at once — the last event of a message, and
|
|
75
|
+
* whatever catches you up after a reconnect — which replaces rather than extends.
|
|
76
|
+
*/
|
|
77
|
+
delta?: string;
|
|
64
78
|
};
|
|
65
79
|
export type ReasoningPayload = {
|
|
66
|
-
/** The reasoning content */
|
|
80
|
+
/** The reasoning content, whole. See {@link MessagePayload.message}. */
|
|
67
81
|
reasoning: string;
|
|
68
82
|
/** The UUID of this reasoning */
|
|
69
83
|
reasoningId: string;
|
|
70
84
|
/** Whether this reasoning chunk is the final one */
|
|
71
85
|
completed: boolean;
|
|
86
|
+
/** What this event added. See {@link MessagePayload.delta}. */
|
|
87
|
+
delta?: string;
|
|
72
88
|
};
|
|
73
89
|
export type FilePayload = {
|
|
74
90
|
/** The file URL */
|
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
|
|
|
@@ -41,6 +39,39 @@ Or, when you only want the answer:
|
|
|
41
39
|
const { text } = await convo.ask("Hey there Alfred!");
|
|
42
40
|
```
|
|
43
41
|
|
|
42
|
+
### Streaming
|
|
43
|
+
|
|
44
|
+
Alfred streams a reply as it writes it. Each event carries either the whole message so far
|
|
45
|
+
or just the piece it added, never both:
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
{ "messageId": "m1", "message": "Good day to you", "completed": false } // whole: replace
|
|
49
|
+
{ "messageId": "m1", "delta": " to you", "completed": false } // added: append
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The SDK puts them back together, so `payload.message` is always the whole message:
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
convo.send("Tell me a story", (res) => {
|
|
56
|
+
if (!res.success || res.data.response.type !== "message") return;
|
|
57
|
+
|
|
58
|
+
const { message, delta } = res.data.response.payload;
|
|
59
|
+
// message — everything written so far
|
|
60
|
+
// delta — just what this event added, when it added anything
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Render `message` and you need do nothing else. Render `delta` and you never re-draw text
|
|
65
|
+
you already have, which for a long reply is the difference between a smooth stream and a
|
|
66
|
+
stuttering one — append it when it is there, and replace with `message` when it is not.
|
|
67
|
+
`delta` is absent in two cases: the last event of a message, and whatever catches you up
|
|
68
|
+
after a reconnect. Do not read `completed` to tell the two apart — a whole message arrives
|
|
69
|
+
with `completed: false` whenever you are being caught up mid-answer.
|
|
70
|
+
|
|
71
|
+
`accumulateStream: false` hands you the wire payloads untouched, where a piece arrives as
|
|
72
|
+
`delta` with no `message` beside it. Only worth it if you are appending anyway and want
|
|
73
|
+
nothing between you and the socket.
|
|
74
|
+
|
|
44
75
|
## Link
|
|
45
76
|
|
|
46
77
|
A Link is a live connection to Alfred. It does three things:
|
|
@@ -212,13 +243,27 @@ Which to use:
|
|
|
212
243
|
Best when you are already running a link for tools or hooks, or holding many
|
|
213
244
|
conversations at once — one socket carries them all.
|
|
214
245
|
|
|
215
|
-
|
|
246
|
+
One difference to know about: sessions are ephemeral. If the connection drops mid-turn
|
|
247
|
+
the SDK reopens the session and resends transparently; the conversation itself is
|
|
248
|
+
persisted server-side, so nothing is lost.
|
|
249
|
+
|
|
250
|
+
### Rejoining a turn already in progress
|
|
251
|
+
|
|
252
|
+
A turn belongs to the conversation, not to whoever started it, so reopening a
|
|
253
|
+
conversation mid-answer picks the reply back up as it is written:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
const convo = client.createConversation({ convoId, transport: link });
|
|
257
|
+
const watching = convo.fetchProgressStream(chunk => render(chunk));
|
|
258
|
+
// ...later
|
|
259
|
+
watching.close(); // stop watching; the turn keeps running
|
|
260
|
+
```
|
|
216
261
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
262
|
+
Over a Link this rides the connection you already hold; over SSE it opens the HTTP
|
|
263
|
+
progress stream. The payloads are identical either way, including your own message and
|
|
264
|
+
the conversation's start — everything a client that arrived late needs to draw the turn
|
|
265
|
+
from the beginning. Pass `{ afterEventId }` to be sent only what you have not already
|
|
266
|
+
seen. A conversation with nothing running simply ends the stream.
|
|
222
267
|
|
|
223
268
|
Neither transport can cancel a turn: `close()` stops delivery locally, and the reply is
|
|
224
269
|
still generated and stored.
|