@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 +19 -0
- package/dist/config.js +24 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +19 -3
- package/dist/link/link.d.ts +1 -0
- package/dist/link/link.js +1 -1
- package/dist/link/protocol.d.ts +7 -0
- package/dist/modules/transport_link.d.ts +29 -0
- package/dist/modules/transport_link.js +230 -74
- package/package.json +1 -1
- package/readme.md +15 -4
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
|
|
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
|
|
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
|
|
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.
|
|
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) {
|
package/dist/link/link.d.ts
CHANGED
|
@@ -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
package/dist/link/protocol.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
+
progress.sawCompletion = true;
|
|
284
|
+
deliver({
|
|
199
285
|
success: true,
|
|
200
286
|
data: {
|
|
201
287
|
response: event,
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|