@butlerbot/sdk 0.0.26 → 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.
@@ -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;
@@ -159,6 +172,13 @@ export declare class Conversation<V extends APIPath = "v4"> {
159
172
  lastEventId?: string;
160
173
  includeCompleted?: boolean;
161
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;
162
182
  /** Sends a message into the conversation */
163
183
  send(message: string, cb: (chunk: RequestResponseByVersion[V]) => any, options?: DialogueRequestOptions): ConversationStream;
164
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, () => ({
@@ -190,17 +192,20 @@ class Conversation {
190
192
  fetchProgressStream(cb, options) {
191
193
  if (!this.convoId)
192
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);
193
198
  if (this.transport.attach) {
194
199
  return this.transport.attach({
195
200
  chatId: this.convoId,
196
201
  ...(options?.afterEventId ? { afterEventId: options.afterEventId } : {}),
197
202
  }, {
198
- payload: (payload) => cb(payload),
203
+ payload: receive,
199
204
  convoId: () => { },
200
205
  });
201
206
  }
202
207
  const url = (0, url_formatter_1.formatURL)(this.endpoints.progressStream, { chatId: this.convoId }, { apiKey: this.apiKey, debug: this.debug });
203
- 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 });
204
209
  }
205
210
  /**
206
211
  * Fetches the conversation progress from the server
@@ -226,15 +231,28 @@ class Conversation {
226
231
  return data.events;
227
232
  }
228
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
+ }
229
246
  /** Sends a message into the conversation */
230
247
  send(message, cb, options) {
248
+ const receive = this.receiver(cb);
231
249
  return this.transport.send({
232
250
  message,
233
251
  ...this.options, // options set for convo
234
252
  ...options, // overwrite convo's for this call
235
253
  ...(this.convoId ? { chatId: this.convoId } : {}),
236
254
  }, {
237
- payload: (payload) => cb(payload),
255
+ payload: receive,
238
256
  convoId: (convoId) => {
239
257
  if (this.convoId === convoId)
240
258
  return;
@@ -262,10 +280,14 @@ class Conversation {
262
280
  }
263
281
  const response = chunk.data.response;
264
282
  const metadata = chunk.data.response.metadata;
265
- // Message chunks arrive cumulative, and Alfred's own notices are not part
266
- // of the reply, so they are collected but not concatenated into it.
267
- if (response.type === "message" && metadata?.participantId !== "system" && response.payload?.message) {
268
- 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;
269
291
  }
270
292
  if (chunk.data.quitStream)
271
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
+ }
@@ -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.26",
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
@@ -39,6 +39,39 @@ Or, when you only want the answer:
39
39
  const { text } = await convo.ask("Hey there Alfred!");
40
40
  ```
41
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
+
42
75
  ## Link
43
76
 
44
77
  A Link is a live connection to Alfred. It does three things: