@butlerbot/sdk 0.0.33 → 0.0.34

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/config.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  export declare const CONFIG: {
2
2
  server: string;
3
+ /**
4
+ * Where links connect.
5
+ *
6
+ * Not the core server: links are carried by their own service, so the SDK holds two
7
+ * addresses rather than one.
8
+ */
9
+ link: string;
3
10
  healthcheckPath: string;
4
11
  paths: {
5
12
  conversation: {
@@ -55,3 +62,15 @@ export declare const CONFIG: {
55
62
  };
56
63
  };
57
64
  export type APIPath = keyof typeof CONFIG.paths.conversation;
65
+ /**
66
+ * Which server a link should connect to.
67
+ *
68
+ * `linkUrl` names it outright and always wins. Failing that, a `serverUrl` pointing anywhere
69
+ * other than the hosted core is taken at its word: a self-hosted stack is usually one address,
70
+ * and sending an API key to a host nobody named would be a worse surprise than a wrong path.
71
+ * Otherwise it is the hosted link service, which is what the core server used to be and is not.
72
+ */
73
+ export declare function resolveLinkUrl(config: {
74
+ linkUrl?: string;
75
+ serverUrl?: string;
76
+ }): string;
package/dist/config.js CHANGED
@@ -1,8 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CONFIG = void 0;
4
+ exports.resolveLinkUrl = resolveLinkUrl;
4
5
  exports.CONFIG = {
5
6
  server: "https://core.butler.now",
7
+ /**
8
+ * Where links connect.
9
+ *
10
+ * Not the core server: links are carried by their own service, so the SDK holds two
11
+ * addresses rather than one.
12
+ */
13
+ link: "https://link.butler.now",
6
14
  healthcheckPath: "/api/healthcheck",
7
15
  paths: {
8
16
  conversation: {
@@ -45,3 +53,19 @@ exports.CONFIG = {
45
53
  }
46
54
  }
47
55
  };
56
+ const withoutTrailingSlash = (url) => url.replace(/\/+$/, "");
57
+ /**
58
+ * Which server a link should connect to.
59
+ *
60
+ * `linkUrl` names it outright and always wins. Failing that, a `serverUrl` pointing anywhere
61
+ * other than the hosted core is taken at its word: a self-hosted stack is usually one address,
62
+ * and sending an API key to a host nobody named would be a worse surprise than a wrong path.
63
+ * Otherwise it is the hosted link service, which is what the core server used to be and is not.
64
+ */
65
+ function resolveLinkUrl(config) {
66
+ if (config.linkUrl)
67
+ return config.linkUrl;
68
+ if (config.serverUrl && withoutTrailingSlash(config.serverUrl) !== withoutTrailingSlash(exports.CONFIG.server))
69
+ return config.serverUrl;
70
+ return exports.CONFIG.link;
71
+ }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,14 @@ type OptionalApiKey<T> = Omit<T, "apiKey"> & {
9
9
  export type ButlerBotClientOptions = {
10
10
  /** The server endpoint, API calls are sent here */
11
11
  serverUrl?: string;
12
+ /**
13
+ * Where links connect. Defaults to the hosted link service.
14
+ *
15
+ * Links are no longer carried by the core server, so this is a second address rather than
16
+ * a path on the first. A `serverUrl` pointing at your own stack is used for links too,
17
+ * unless this names somewhere else.
18
+ */
19
+ linkUrl?: string;
12
20
  /** The API key to use with ButlerBot */
13
21
  apiKey: string;
14
22
  /** Whether to enable debug logs */
@@ -17,6 +25,7 @@ export type ButlerBotClientOptions = {
17
25
  export declare class ButlerBotClient {
18
26
  private apiKey;
19
27
  private serverUrl;
28
+ private linkUrl;
20
29
  private debug;
21
30
  constructor(config: ButlerBotClientOptions);
22
31
  /** Checks the health of the server returning true if server is alive */
@@ -25,7 +34,10 @@ export declare class ButlerBotClient {
25
34
  createConversation<V extends APIPath = "v4">(config?: OptionalApiKey<ConversationOptions<V>>): Conversation<V>;
26
35
  /**
27
36
  * Creates a Link: a live connection that can register tools and hooks, and carry
28
- * conversations. Inherits the client's API key and server URL.
37
+ * conversations. Inherits the client's API key and link URL.
38
+ *
39
+ * The link URL, not the server URL: links are served by their own service. Pass
40
+ * `serverUrl` here, or `linkUrl` to the client, to point somewhere else.
29
41
  */
30
42
  createLink(config: OptionalApiKey<LinkOptions>): Link;
31
43
  /** Get current usage policy data */
@@ -33,7 +45,8 @@ export declare class ButlerBotClient {
33
45
  }
34
46
  export * from "./types/type_registry";
35
47
  export * from "./link";
36
- export { Conversation, ConversationOptions };
48
+ export { Conversation };
49
+ export type { ConversationOptions };
37
50
  export type { APIPath };
38
51
  export type { ConversationStream, ConversationTransport, TransportTurnRequest, TransportHandlers, } from "./modules/transport";
39
52
  export { LinkConversationTransport } from "./modules/transport_link";
package/dist/index.js CHANGED
@@ -20,10 +20,23 @@ const link_1 = require("./link");
20
20
  const conversation_1 = require("./modules/conversation");
21
21
  Object.defineProperty(exports, "Conversation", { enumerable: true, get: function () { return conversation_1.Conversation; } });
22
22
  const usage_1 = require("./modules/usage");
23
+ /**
24
+ * The options a caller actually gave, with the keys they left out removed.
25
+ *
26
+ * A client's own API key and URLs are the fallback for whatever a factory call omits, and the
27
+ * usual way to omit something is to forward an optional setting that happens to be unset —
28
+ * `createLink({ serverUrl: process.env.LINK_URL })`. Spread as it stands, that `undefined`
29
+ * lands on top of the client's resolved value and erases it, so the link ends up at the hosted
30
+ * service rather than the self-hosted stack the client was pointed at. Absent means "not given".
31
+ */
32
+ function given(config) {
33
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
34
+ }
23
35
  class ButlerBotClient {
24
36
  constructor(config) {
25
37
  this.apiKey = config.apiKey;
26
38
  this.serverUrl = config.serverUrl || config_1.CONFIG.server;
39
+ this.linkUrl = (0, config_1.resolveLinkUrl)(config);
27
40
  this.debug = config.debug || false;
28
41
  }
29
42
  /** Checks the health of the server returning true if server is alive */
@@ -41,14 +54,17 @@ class ButlerBotClient {
41
54
  }
42
55
  /** Spawns a new Conversation, inherits api key and server URL */
43
56
  createConversation(config = {}) {
44
- return new conversation_1.Conversation({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.serverUrl, ...config });
57
+ return new conversation_1.Conversation({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.serverUrl, ...given(config) });
45
58
  }
46
59
  /**
47
60
  * Creates a Link: a live connection that can register tools and hooks, and carry
48
- * conversations. Inherits the client's API key and server URL.
61
+ * conversations. Inherits the client's API key and link URL.
62
+ *
63
+ * The link URL, not the server URL: links are served by their own service. Pass
64
+ * `serverUrl` here, or `linkUrl` to the client, to point somewhere else.
49
65
  */
50
66
  createLink(config) {
51
- return new link_1.Link({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.serverUrl, ...config });
67
+ return new link_1.Link({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.linkUrl, ...given(config) });
52
68
  }
53
69
  /** Get current usage policy data */
54
70
  getUsagePolicyData(config) {
@@ -49,6 +49,7 @@ export type LinkOptions = {
49
49
  * one takes over and the older one's registrations are released.
50
50
  */
51
51
  linkId: string;
52
+ /** The link service. Defaults to the hosted one, which is not the core server. */
52
53
  serverUrl?: string;
53
54
  /** Informational, shown in server logs. Defaults to the SDK name. */
54
55
  client?: string;
package/dist/link/link.js CHANGED
@@ -66,7 +66,7 @@ class Link {
66
66
  reconnect: true,
67
67
  debug: false,
68
68
  client: "@butlerbot/sdk",
69
- serverUrl: config_1.CONFIG.server,
69
+ serverUrl: config_1.CONFIG.link,
70
70
  socketFactory: socket_1.defaultSocketFactory,
71
71
  ...stripUndefined(options),
72
72
  };
@@ -236,12 +236,19 @@ export type LinkServerPayloads = {
236
236
  chatId?: string;
237
237
  message: string;
238
238
  };
239
+ /**
240
+ * A turn ended, one way or another.
241
+ *
242
+ * `lastEventId` comes with `turn_suspended`: the turn is still being answered somewhere
243
+ * and this is where a `conversation.attach` should pick it back up.
244
+ */
239
245
  "conversation.done": {
240
246
  chatId?: string;
241
247
  ok: boolean;
242
248
  code?: string;
243
249
  error?: string;
244
250
  message?: string;
251
+ lastEventId?: string;
245
252
  };
246
253
  /** A turn was stopped. `mode` is what was applied, `requestedMode` what was asked for. */
247
254
  "conversation.stopped": {
@@ -50,6 +50,35 @@ export declare class LinkConversationTransport implements ConversationTransport
50
50
  */
51
51
  attach(request: TransportAttachRequest, handlers: TransportHandlers): ConversationStream;
52
52
  private runTurn;
53
+ /**
54
+ * One `conversation.attach`: streams a turn's events to the caller and resolves with how
55
+ * that watch ended.
56
+ *
57
+ * Shared by watching somebody else's turn and by rejoining one of our own, because from
58
+ * here the two are the same act. `progress` is carried rather than returned: a watch that
59
+ * dies halfway still has to leave behind where it got to, or a resume would replay from
60
+ * the beginning and the caller would read the answer twice.
61
+ */
62
+ private watch;
63
+ /**
64
+ * Picks a turn back up after losing sight of it.
65
+ *
66
+ * Two ways to lose one, one way to get it back. The socket can go — a deploy of the link
67
+ * service, a proxy timing out — which rejects the exchange carrying the turn. Or core can
68
+ * suspend the turn at its own deploy and hand it to another instance, which the link
69
+ * service follows for two minutes before giving up and saying `turn_suspended`. Either way
70
+ * the turn is still being answered and the conversation still holds it, so this re-attaches
71
+ * from the last event the caller was actually given and the stream reads as one answer.
72
+ *
73
+ * `no_active_turn` is the ambiguous reply and it is deliberately not treated as an ending:
74
+ * during a handover it means "not picked up yet" far more often than it means "gone", and
75
+ * the window is what decides between them.
76
+ *
77
+ * Nothing here throws. It runs behind a stream the caller already holds, so the outcomes
78
+ * that matter are the ones delivered into it: a completion, or a failure that says plainly
79
+ * that the answer was lost track of rather than that it failed.
80
+ */
81
+ private resume;
53
82
  /** Opens a session, or reuses the open one when it is for the same conversation. */
54
83
  private session;
55
84
  }
@@ -3,6 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LinkConversationTransport = void 0;
4
4
  const protocol_1 = require("../link/protocol");
5
5
  const transport_1 = require("./transport");
6
+ /**
7
+ * How long to keep trying to pick a lost turn back up before giving up on it.
8
+ *
9
+ * A turn belongs to its conversation, not to the socket that asked for one, so neither losing
10
+ * the connection nor losing the instance answering it — which is what a deploy does — ends it.
11
+ * Long enough to outlast a deploy of either service, short enough that a caller awaiting a
12
+ * reply is not left there forever when the turn really is gone.
13
+ */
14
+ const RESUME_WINDOW_MS = 60000;
15
+ /** Waits between attempts to pick a turn back up. The last value repeats. */
16
+ const RESUME_BACKOFF_MS = [250, 500, 1000, 2000, 4000, 5000];
6
17
  /**
7
18
  * Carries a turn over an existing Link connection.
8
19
  *
@@ -18,20 +29,20 @@ class LinkConversationTransport {
18
29
  link.on("disconnect", () => { this.sessionId = undefined; });
19
30
  }
20
31
  send(request, handlers) {
21
- let closed = false;
32
+ const listening = { closed: false };
22
33
  const deliver = {
23
- payload: (payload) => { if (!closed)
34
+ payload: (payload) => { if (!listening.closed)
24
35
  handlers.payload(payload); },
25
- convoId: (convoId) => { if (!closed)
36
+ convoId: (convoId) => { if (!listening.closed)
26
37
  handlers.convoId(convoId); },
27
38
  };
28
- void this.runTurn(request, deliver, true).catch((error) => {
39
+ void this.runTurn(request, deliver, true, listening).catch((error) => {
29
40
  const failure = error instanceof protocol_1.LinkError
30
41
  ? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
31
42
  : (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId);
32
43
  deliver.payload(failure);
33
44
  });
34
- return { close: () => { closed = true; } };
45
+ return { close: () => { listening.closed = true; } };
35
46
  }
36
47
  /**
37
48
  * Stops a running turn.
@@ -96,57 +107,46 @@ class LinkConversationTransport {
96
107
  * ordinary answer for one that is simply idle.
97
108
  */
98
109
  attach(request, handlers) {
99
- let closed = false;
100
- const deliver = (payload) => { if (!closed)
110
+ const listening = { closed: false };
111
+ const deliver = (payload) => { if (!listening.closed)
101
112
  handlers.payload(payload); };
102
- const watching = this.link.exchange("conversation.attach", {
103
- chatId: request.chatId,
104
- ...(request.afterEventId ? { afterEventId: request.afterEventId } : {}),
105
- }, {
106
- // A turn takes as long as it takes; only the transport dying ends it early.
107
- timeoutMs: 0,
108
- isDone: (frame) => frame.type === "conversation.done",
109
- onFrame: (frame) => {
110
- if (frame.type === "conversation.event") {
111
- const payload = frame.payload;
112
- const event = payload.event;
113
- const final = event.type === "response_status" && Boolean(event.payload?.completed);
114
- deliver({
115
- success: true,
116
- data: {
117
- response: event,
118
- convoId: payload.chatId ?? request.chatId,
119
- ...(final ? { quitStream: true } : {}),
120
- },
121
- });
122
- return;
123
- }
124
- if (frame.type === "conversation.notice") {
125
- deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? request.chatId));
126
- }
127
- },
128
- });
129
- void watching.then((done) => {
130
- const payload = done.payload;
113
+ const progress = { chatId: request.chatId, lastEventId: request.afterEventId, sawCompletion: false };
114
+ // Watching is allowed to end in silence, which is why `quietWhenGone` is true here and
115
+ // false for a turn of our own: nobody is waiting on an answer to a question they asked.
116
+ const resume = () => this.resume(request.chatId, progress, deliver, listening, { quietWhenGone: true });
117
+ void this.watch(request.chatId, progress, deliver).then(async (payload) => {
131
118
  if (payload.ok)
132
119
  return;
133
120
  // Nothing running is not a failure: the caller asked to watch a conversation
134
121
  // that has nothing to watch, and the stream simply ends.
135
122
  if (payload.code === "no_active_turn")
136
123
  return;
124
+ // The turn is alive, somewhere this connection can no longer see. Following it is
125
+ // the entire point of being here.
126
+ if (payload.code === "turn_suspended") {
127
+ if (payload.lastEventId)
128
+ progress.lastEventId = payload.lastEventId;
129
+ await resume();
130
+ return;
131
+ }
137
132
  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));
138
- }, (error) => {
133
+ }, async (error) => {
139
134
  if (error instanceof protocol_1.LinkError && error.code === "no_active_turn")
140
135
  return;
136
+ // The socket went while we were watching. The turn did not go with it.
137
+ if (error instanceof protocol_1.LinkError && error.code === "disconnected") {
138
+ await resume();
139
+ return;
140
+ }
141
141
  deliver(error instanceof protocol_1.LinkError
142
142
  ? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
143
143
  : (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId));
144
144
  });
145
145
  return {
146
146
  close: () => {
147
- if (closed)
147
+ if (listening.closed)
148
148
  return;
149
- closed = true;
149
+ listening.closed = true;
150
150
  // Best-effort: a socket that has gone has already ended the watch for us.
151
151
  try {
152
152
  this.link.send("conversation.detach", { chatId: request.chatId });
@@ -157,15 +157,14 @@ class LinkConversationTransport {
157
157
  },
158
158
  };
159
159
  }
160
- async runTurn(request, handlers, mayRetry) {
160
+ async runTurn(request, handlers, mayRetry, listening) {
161
161
  const sessionId = await this.session(request);
162
- let chatId = request.chatId ?? this.sessionChatId;
162
+ const progress = { chatId: request.chatId ?? this.sessionChatId, sawCompletion: false };
163
163
  let announcedChatId = false;
164
- let sawCompletion = false;
165
164
  const learnChatId = (candidate) => {
166
165
  if (!candidate)
167
166
  return;
168
- chatId = candidate;
167
+ progress.chatId = candidate;
169
168
  this.sessionChatId = candidate;
170
169
  handlers.convoId(candidate);
171
170
  // Mirrors the HTTP transport's first byte, which tells a client which
@@ -176,13 +175,99 @@ class LinkConversationTransport {
176
175
  handlers.payload((0, transport_1.convoStartedPayload)(candidate));
177
176
  }
178
177
  };
179
- learnChatId(chatId);
180
- const done = await this.link.exchange("conversation.chat", {
181
- sessionId,
182
- message: request.message,
183
- ...(request.model ? { model: request.model } : {}),
184
- ...(request.instructions ? { instructions: request.instructions } : {}),
185
- ...(request.personality ? { personality: request.personality } : {}),
178
+ learnChatId(progress.chatId);
179
+ let done;
180
+ try {
181
+ done = await this.link.exchange("conversation.chat", {
182
+ sessionId,
183
+ message: request.message,
184
+ ...(request.model ? { model: request.model } : {}),
185
+ ...(request.instructions ? { instructions: request.instructions } : {}),
186
+ ...(request.personality ? { personality: request.personality } : {}),
187
+ }, {
188
+ // A turn takes as long as it takes; only the transport dying ends it early.
189
+ timeoutMs: 0,
190
+ isDone: (frame) => frame.type === "conversation.done",
191
+ onFrame: (frame) => {
192
+ if (frame.type === "conversation.event") {
193
+ const payload = frame.payload;
194
+ learnChatId(payload.chatId);
195
+ if (payload.eventId)
196
+ progress.lastEventId = payload.eventId;
197
+ const event = payload.event;
198
+ const final = event.type === "response_status" && Boolean(event.payload?.completed);
199
+ if (final)
200
+ progress.sawCompletion = true;
201
+ handlers.payload({
202
+ success: true,
203
+ data: {
204
+ response: event,
205
+ ...(payload.chatId ?? progress.chatId ? { convoId: payload.chatId ?? progress.chatId } : {}),
206
+ ...(final ? { quitStream: true } : {}),
207
+ },
208
+ });
209
+ return;
210
+ }
211
+ if (frame.type === "conversation.notice") {
212
+ const payload = frame.payload;
213
+ learnChatId(payload.chatId);
214
+ handlers.payload((0, transport_1.noticePayload)(payload.message, payload.chatId ?? progress.chatId));
215
+ }
216
+ },
217
+ });
218
+ }
219
+ catch (error) {
220
+ // The socket went while the turn was running. The turn did not go with it: it
221
+ // belongs to the conversation, and the conversation outlives this connection.
222
+ if (progress.chatId && error instanceof protocol_1.LinkError && error.code === "disconnected") {
223
+ await this.resume(progress.chatId, progress, handlers.payload, listening, { quietWhenGone: false });
224
+ return;
225
+ }
226
+ throw error;
227
+ }
228
+ const payload = done.payload;
229
+ learnChatId(payload.chatId);
230
+ if (payload.ok) {
231
+ // Nearly always the pipeline's own completion event has already closed the
232
+ // stream; this is for the turn that ended without one.
233
+ if (!progress.sawCompletion)
234
+ handlers.payload((0, transport_1.completedPayload)(payload.chatId ?? progress.chatId));
235
+ return;
236
+ }
237
+ // The session died with a connection we have since replaced. Reopening it is
238
+ // invisible to the caller, and the message has not been delivered yet.
239
+ if (payload.code === "unknown_session" && mayRetry) {
240
+ this.sessionId = undefined;
241
+ await this.runTurn(request, handlers, false, listening);
242
+ return;
243
+ }
244
+ // Core suspended the turn at a deploy and handed it to another instance, and the link
245
+ // service followed it as far as it could. The answer is still being written; rejoining
246
+ // it is the difference between a deploy costing a reply and costing nothing.
247
+ if (payload.code === "turn_suspended" && progress.chatId) {
248
+ if (payload.lastEventId)
249
+ progress.lastEventId = payload.lastEventId;
250
+ await this.resume(progress.chatId, progress, handlers.payload, listening, { quietWhenGone: false });
251
+ return;
252
+ }
253
+ handlers.payload((0, transport_1.failurePayload)(
254
+ // `turn_failed` means the dialogue itself failed, and then `error` holds the
255
+ // code the HTTP transport would have reported.
256
+ payload.code === "turn_failed" ? payload.error ?? payload.code : payload.code ?? "link_error", payload.error ?? "The turn failed.", payload.message ?? payload.error ?? "I'm afraid that turn could not be completed.", payload.chatId ?? progress.chatId));
257
+ }
258
+ /**
259
+ * One `conversation.attach`: streams a turn's events to the caller and resolves with how
260
+ * that watch ended.
261
+ *
262
+ * Shared by watching somebody else's turn and by rejoining one of our own, because from
263
+ * here the two are the same act. `progress` is carried rather than returned: a watch that
264
+ * dies halfway still has to leave behind where it got to, or a resume would replay from
265
+ * the beginning and the caller would read the answer twice.
266
+ */
267
+ async watch(chatId, progress, deliver) {
268
+ const done = await this.link.exchange("conversation.attach", {
269
+ chatId,
270
+ ...(progress.lastEventId ? { afterEventId: progress.lastEventId } : {}),
186
271
  }, {
187
272
  // A turn takes as long as it takes; only the transport dying ends it early.
188
273
  timeoutMs: 0,
@@ -190,48 +275,112 @@ class LinkConversationTransport {
190
275
  onFrame: (frame) => {
191
276
  if (frame.type === "conversation.event") {
192
277
  const payload = frame.payload;
193
- learnChatId(payload.chatId);
278
+ if (payload.eventId)
279
+ progress.lastEventId = payload.eventId;
194
280
  const event = payload.event;
195
281
  const final = event.type === "response_status" && Boolean(event.payload?.completed);
196
282
  if (final)
197
- sawCompletion = true;
198
- handlers.payload({
283
+ progress.sawCompletion = true;
284
+ deliver({
199
285
  success: true,
200
286
  data: {
201
287
  response: event,
202
- ...(payload.chatId ?? chatId ? { convoId: payload.chatId ?? chatId } : {}),
288
+ convoId: payload.chatId ?? chatId,
203
289
  ...(final ? { quitStream: true } : {}),
204
290
  },
205
291
  });
206
292
  return;
207
293
  }
208
294
  if (frame.type === "conversation.notice") {
209
- const payload = frame.payload;
210
- learnChatId(payload.chatId);
211
- handlers.payload((0, transport_1.noticePayload)(payload.message, payload.chatId ?? chatId));
295
+ deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? chatId));
212
296
  }
213
297
  },
214
298
  });
215
- const payload = done.payload;
216
- learnChatId(payload.chatId);
217
- if (payload.ok) {
218
- // Nearly always the pipeline's own completion event has already closed the
219
- // stream; this is for the turn that ended without one.
220
- if (!sawCompletion)
221
- handlers.payload((0, transport_1.completedPayload)(payload.chatId ?? chatId));
299
+ return done.payload;
300
+ }
301
+ /**
302
+ * Picks a turn back up after losing sight of it.
303
+ *
304
+ * Two ways to lose one, one way to get it back. The socket can go — a deploy of the link
305
+ * service, a proxy timing out — which rejects the exchange carrying the turn. Or core can
306
+ * suspend the turn at its own deploy and hand it to another instance, which the link
307
+ * service follows for two minutes before giving up and saying `turn_suspended`. Either way
308
+ * the turn is still being answered and the conversation still holds it, so this re-attaches
309
+ * from the last event the caller was actually given and the stream reads as one answer.
310
+ *
311
+ * `no_active_turn` is the ambiguous reply and it is deliberately not treated as an ending:
312
+ * during a handover it means "not picked up yet" far more often than it means "gone", and
313
+ * the window is what decides between them.
314
+ *
315
+ * Nothing here throws. It runs behind a stream the caller already holds, so the outcomes
316
+ * that matter are the ones delivered into it: a completion, or a failure that says plainly
317
+ * that the answer was lost track of rather than that it failed.
318
+ */
319
+ async resume(chatId, progress, deliver, listening, options) {
320
+ const deadline = Date.now() + RESUME_WINDOW_MS;
321
+ for (let attempt = 0; !listening.closed && Date.now() < deadline; attempt++) {
322
+ if (attempt > 0)
323
+ await pause(RESUME_BACKOFF_MS[Math.min(attempt - 1, RESUME_BACKOFF_MS.length - 1)]);
324
+ if (listening.closed)
325
+ return;
326
+ try {
327
+ // The link reconnects on its own; this waits for it rather than racing it.
328
+ await this.link.ready();
329
+ }
330
+ catch (error) {
331
+ // Closed for good means nobody is coming back. Anything else is the link still
332
+ // being down, which is exactly what the window is for.
333
+ if (error instanceof protocol_1.LinkError && error.code === "closed")
334
+ break;
335
+ continue;
336
+ }
337
+ // An attachment from before may still be registered against a connection that is
338
+ // itself still alive, and the server allows only one per conversation. Dropping it
339
+ // first costs nothing when there is none to drop.
340
+ try {
341
+ this.link.send("conversation.detach", { chatId });
342
+ }
343
+ catch {
344
+ // Not connected. The server dropped the attachment with the socket.
345
+ }
346
+ let payload;
347
+ try {
348
+ payload = await this.watch(chatId, progress, deliver);
349
+ }
350
+ catch (error) {
351
+ if (error instanceof protocol_1.LinkError && error.code === "closed")
352
+ break;
353
+ if (error instanceof protocol_1.LinkError && error.code === "no_active_turn" && options.quietWhenGone)
354
+ return;
355
+ continue;
356
+ }
357
+ if (payload.ok) {
358
+ if (!progress.sawCompletion)
359
+ deliver((0, transport_1.completedPayload)(payload.chatId ?? chatId));
360
+ return;
361
+ }
362
+ // Not picked up yet, or suspended again mid-hop. Both mean it is still moving.
363
+ if (payload.code === "no_active_turn") {
364
+ if (options.quietWhenGone)
365
+ return;
366
+ continue;
367
+ }
368
+ if (payload.code === "turn_suspended") {
369
+ if (payload.lastEventId)
370
+ progress.lastEventId = payload.lastEventId;
371
+ continue;
372
+ }
373
+ deliver((0, transport_1.failurePayload)(payload.code ?? "link_error", payload.error ?? "The turn could not be picked back up.", payload.message ?? payload.error ?? "I'm afraid I couldn't follow that response.", payload.chatId ?? chatId));
222
374
  return;
223
375
  }
224
- // The session died with a connection we have since replaced. Reopening it is
225
- // invisible to the caller, and the message has not been delivered yet.
226
- if (payload.code === "unknown_session" && mayRetry) {
227
- this.sessionId = undefined;
228
- await this.runTurn(request, handlers, false);
376
+ if (listening.closed)
229
377
  return;
230
- }
231
- handlers.payload((0, transport_1.failurePayload)(
232
- // `turn_failed` means the dialogue itself failed, and then `error` holds the
233
- // code the HTTP transport would have reported.
234
- payload.code === "turn_failed" ? payload.error ?? payload.code : payload.code ?? "link_error", payload.error ?? "The turn failed.", payload.message ?? payload.error ?? "I'm afraid that turn could not be completed.", payload.chatId ?? chatId));
378
+ // The completion already reached the caller; there is nothing left to say.
379
+ if (progress.sawCompletion)
380
+ return;
381
+ if (options.quietWhenGone)
382
+ return;
383
+ deliver((0, transport_1.failurePayload)("turn_suspended", "The turn could not be picked back up.", "I'm afraid I lost track of that answer. It may well have finished — reopen the conversation to see where it got to.", chatId));
235
384
  }
236
385
  /** Opens a session, or reuses the open one when it is for the same conversation. */
237
386
  async session(request) {
@@ -257,3 +406,10 @@ class LinkConversationTransport {
257
406
  }
258
407
  }
259
408
  exports.LinkConversationTransport = LinkConversationTransport;
409
+ /** Waits, without keeping a Node process alive on its own. */
410
+ function pause(ms) {
411
+ return new Promise(resolve => {
412
+ const timer = setTimeout(resolve, ms);
413
+ timer.unref?.();
414
+ });
415
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -78,7 +78,12 @@ between you and the socket.
78
78
 
79
79
  ## Link
80
80
 
81
- A Link is a live connection to Alfred. It does three things:
81
+ A Link is a live connection to Alfred, served by its own endpoint — `link.butler.now`,
82
+ not the core API server. `createLink` goes there by default; pass `linkUrl` to the client
83
+ (or `serverUrl` to `createLink`) to point somewhere else. A client given a `serverUrl` of
84
+ its own — a self-hosted stack — uses that for links too.
85
+
86
+ It does three things:
82
87
 
83
88
  - **Tools** — Alfred calls code that runs on your machine
84
89
  - **Hooks** — your code wakes the user's background agents when something happens
@@ -286,9 +291,15 @@ Which to use:
286
291
  Best when you are already running a link for tools or hooks, or holding many
287
292
  conversations at once — one socket carries them all.
288
293
 
289
- One difference to know about: sessions are ephemeral. If the connection drops mid-turn
290
- the SDK reopens the session and resends transparently; the conversation itself is
291
- persisted server-side, so nothing is lost.
294
+ A turn survives losing the connection it was asked for on. Sessions are ephemeral the
295
+ server drops one with its socket but the turn belongs to the conversation, so when the
296
+ socket goes mid-answer the SDK reconnects, rejoins the turn from the last event it gave
297
+ you, and carries on into the same stream. The same holds when the platform deploys
298
+ mid-turn and the answer is handed to another instance. A message that had not been
299
+ delivered yet is simply sent again on a fresh session; one that had is never sent twice.
300
+
301
+ If the turn cannot be picked back up within a minute, the stream ends with a failure that
302
+ says so — the reply may still have finished, and reopening the conversation will show it.
292
303
 
293
304
  ### Rejoining a turn already in progress
294
305