@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.
@@ -56,7 +56,11 @@ export type LinkOptions = {
56
56
  reconnect?: boolean;
57
57
  minReconnectDelayMs?: number;
58
58
  maxReconnectDelayMs?: number;
59
- /** Keeps idle connections alive through proxies. 0 disables. Default 30s. */
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 on an interval and, the important half, notices when a ping goes unanswered.
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
- * Floored so that a very short interval cannot declare a merely busy connection dead.
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 on an interval and, the important half, notices when a ping goes unanswered.
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.heartbeat = setInterval(() => {
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
- // The reply is consumed by the exchange, so it never reaches log listeners.
387
- this.exchange("ping", {}, {
388
- awaitReady: false,
389
- isDone: (frame) => frame.type === "log",
390
- timeoutMs: this.heartbeatTimeoutMs(),
391
- }).catch(() => {
392
- if (generation !== this.generation)
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
- * Never longer than the interval itself: a second ping in flight tells us nothing new.
402
- *
403
- * Floored so that a very short interval cannot declare a merely busy connection dead.
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
- clearInterval(this.heartbeat);
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);
@@ -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
- /** Fetches the conversation progress stream from the server */
141
- fetchProgressStream(cb: (chunk: RequestResponseByVersion[V]) => any): EventSource;
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
- /** Fetches the conversation progress stream from the server */
180
- fetchProgressStream(cb) {
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: (payload) => cb(payload) });
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: (payload) => cb(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
- // Message chunks arrive cumulative, and Alfred's own notices are not part
247
- // of the reply, so they are collected but not concatenated into it.
248
- if (response.type === "message" && metadata?.participantId !== "system" && response.payload?.message) {
249
- text = response.payload.message;
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
- /** The message content */
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
- /** The message content */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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 [ButlerBot](https://butler.now/) API.
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 [ButlerBot](https://butler.now/) and install the package:
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
- - ButlerBot API key
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
- Two differences to know about:
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
- - Sessions are ephemeral. If the connection drops mid-turn the SDK reopens the session
218
- and resends transparently; the conversation itself is persisted server-side, so
219
- nothing is lost.
220
- - The HTTP transport replays your own message back to you (it exists so a browser
221
- reconnecting mid-turn sees it). A Link does not, since it has nothing to replay.
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.