@butlerbot/sdk 0.0.18-alpha.3 → 0.0.19

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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * SCHEMAS
3
+ * =======
4
+ *
5
+ * A tool needs two things from its schema: JSON Schema to describe itself to the
6
+ * model, and a way to check the arguments that come back. Both are obtained
7
+ * without depending on any validation library — a Standard Schema (zod 3.24+,
8
+ * valibot, arktype, ...) provides validation through `~standard`, and a plain
9
+ * JSON Schema object is accepted as-is.
10
+ */
11
+ export type JSONSchema = Record<string, unknown>;
12
+ /**
13
+ * The Standard Schema v1 interface, vendored so this SDK needs no dependency.
14
+ * @see https://standardschema.dev
15
+ */
16
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
17
+ readonly "~standard": {
18
+ readonly version: 1;
19
+ readonly vendor: string;
20
+ readonly validate: (value: unknown) => {
21
+ value: Output;
22
+ issues?: undefined;
23
+ } | {
24
+ issues: ReadonlyArray<{
25
+ message: string;
26
+ path?: ReadonlyArray<PropertyKey | {
27
+ key: PropertyKey;
28
+ }>;
29
+ }>;
30
+ } | Promise<{
31
+ value: Output;
32
+ issues?: undefined;
33
+ } | {
34
+ issues: ReadonlyArray<{
35
+ message: string;
36
+ path?: ReadonlyArray<PropertyKey | {
37
+ key: PropertyKey;
38
+ }>;
39
+ }>;
40
+ }>;
41
+ readonly types?: {
42
+ readonly input: Input;
43
+ readonly output: Output;
44
+ };
45
+ };
46
+ }
47
+ /** Anything accepted as a tool or hook schema. */
48
+ export type ToolSchema = StandardSchemaV1 | {
49
+ toJSONSchema(): JSONSchema;
50
+ } | JSONSchema;
51
+ /**
52
+ * The argument type a schema produces, so `run({ args })` is typed from the
53
+ * schema rather than left as a bag of unknowns. Falls back to a loose record for
54
+ * a raw JSON Schema, which carries no type information.
55
+ */
56
+ export type InferSchemaOutput<S> = S extends StandardSchemaV1<unknown, infer Output> ? Output : S extends {
57
+ _output: infer Output;
58
+ } ? Output : Record<string, unknown>;
59
+ /**
60
+ * Derives JSON Schema from whatever the caller supplied.
61
+ *
62
+ * Order matters: an explicit converter or a raw JSON Schema is used verbatim, and
63
+ * zod is only reached for when nothing else can answer.
64
+ */
65
+ export declare function toJSONSchema(schema: ToolSchema, label: string): JSONSchema;
66
+ export type ValidationResult<T> = {
67
+ ok: true;
68
+ value: T;
69
+ } | {
70
+ ok: false;
71
+ error: string;
72
+ };
73
+ /**
74
+ * Validates arguments if the schema can validate at all.
75
+ *
76
+ * The server deliberately does not check a client's tool arguments — the client
77
+ * authored the schema, so it owns the check. This is the only place it can happen,
78
+ * which is why a Standard Schema is worth passing.
79
+ */
80
+ export declare function validateArgs<T>(schema: ToolSchema | undefined, args: unknown): Promise<ValidationResult<T>>;
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ /**
3
+ * SCHEMAS
4
+ * =======
5
+ *
6
+ * A tool needs two things from its schema: JSON Schema to describe itself to the
7
+ * model, and a way to check the arguments that come back. Both are obtained
8
+ * without depending on any validation library — a Standard Schema (zod 3.24+,
9
+ * valibot, arktype, ...) provides validation through `~standard`, and a plain
10
+ * JSON Schema object is accepted as-is.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.toJSONSchema = toJSONSchema;
14
+ exports.validateArgs = validateArgs;
15
+ function isStandardSchema(schema) {
16
+ return typeof schema === "object" && schema !== null && "~standard" in schema;
17
+ }
18
+ function hasOwnConverter(schema) {
19
+ return typeof schema?.toJSONSchema === "function";
20
+ }
21
+ function looksLikeJSONSchema(schema) {
22
+ if (typeof schema !== "object" || schema === null)
23
+ return false;
24
+ const keys = ["type", "properties", "$schema", "anyOf", "oneOf", "allOf", "enum", "const", "$ref"];
25
+ return keys.some(key => key in schema);
26
+ }
27
+ /** Loads zod only if the caller already has it, without upsetting bundlers. */
28
+ function loadZod() {
29
+ try {
30
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
31
+ const load = eval("typeof require === 'function' ? require : undefined");
32
+ return load?.("zod");
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /**
39
+ * Derives JSON Schema from whatever the caller supplied.
40
+ *
41
+ * Order matters: an explicit converter or a raw JSON Schema is used verbatim, and
42
+ * zod is only reached for when nothing else can answer.
43
+ */
44
+ function toJSONSchema(schema, label) {
45
+ if (hasOwnConverter(schema))
46
+ return schema.toJSONSchema();
47
+ if (isStandardSchema(schema)) {
48
+ const zod = loadZod();
49
+ if (typeof zod?.toJSONSchema === "function") {
50
+ try {
51
+ return zod.toJSONSchema(schema, { io: "input" });
52
+ }
53
+ catch (error) {
54
+ throw new Error(`Could not convert the schema for "${label}" to JSON Schema: ${error.message}. `
55
+ + `Pass \`jsonSchema\` explicitly instead.`);
56
+ }
57
+ }
58
+ throw new Error(`The schema for "${label}" needs zod 4+ to be converted to JSON Schema (zod 3 cannot). `
59
+ + `Either upgrade zod or pass \`jsonSchema\` alongside \`schema\`.`);
60
+ }
61
+ if (looksLikeJSONSchema(schema))
62
+ return schema;
63
+ throw new Error(`The schema for "${label}" is neither a JSON Schema nor a Standard Schema. `
64
+ + `Pass a zod 4 schema, any Standard Schema, or a plain JSON Schema object.`);
65
+ }
66
+ /**
67
+ * Validates arguments if the schema can validate at all.
68
+ *
69
+ * The server deliberately does not check a client's tool arguments — the client
70
+ * authored the schema, so it owns the check. This is the only place it can happen,
71
+ * which is why a Standard Schema is worth passing.
72
+ */
73
+ async function validateArgs(schema, args) {
74
+ if (!schema || !isStandardSchema(schema))
75
+ return { ok: true, value: args };
76
+ const result = await schema["~standard"].validate(args);
77
+ if (!result.issues)
78
+ return { ok: true, value: result.value };
79
+ const described = result.issues
80
+ .map(issue => {
81
+ const path = (issue.path ?? [])
82
+ .map(segment => (typeof segment === "object" && segment !== null ? String(segment.key) : String(segment)))
83
+ .join(".");
84
+ return path ? `${path}: ${issue.message}` : issue.message;
85
+ })
86
+ .join("; ");
87
+ return { ok: false, error: `Invalid arguments — ${described}` };
88
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * SOCKETS
3
+ * =======
4
+ *
5
+ * A websocket handshake cannot carry headers in a browser, so the service and the
6
+ * credential travel as subprotocols, with the credential falling back to the query
7
+ * string when it contains characters a subprotocol token cannot hold.
8
+ *
9
+ * The global `WebSocket` is used where there is one (browsers, Node 22+). Node
10
+ * before that gets the `ws` package if it is installed.
11
+ */
12
+ export type SocketHandlers = {
13
+ onOpen(): void;
14
+ onMessage(data: string): void;
15
+ onClose(code: number, reason: string): void;
16
+ onError(error: unknown): void;
17
+ };
18
+ export type SocketConnection = {
19
+ send(data: string): void;
20
+ close(code?: number, reason?: string): void;
21
+ };
22
+ export type SocketFactory = (url: string, protocols: string[], handlers: SocketHandlers) => SocketConnection;
23
+ export type HandshakeTarget = {
24
+ url: string;
25
+ protocols: string[];
26
+ };
27
+ /**
28
+ * Builds the websocket URL and subprotocols for a service.
29
+ *
30
+ * The credential prefers the subprotocol because a URL ends up in access logs.
31
+ */
32
+ export declare function buildHandshake(serverUrl: string, service: string, apiKey: string): HandshakeTarget;
33
+ export declare const defaultSocketFactory: SocketFactory;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ /**
3
+ * SOCKETS
4
+ * =======
5
+ *
6
+ * A websocket handshake cannot carry headers in a browser, so the service and the
7
+ * credential travel as subprotocols, with the credential falling back to the query
8
+ * string when it contains characters a subprotocol token cannot hold.
9
+ *
10
+ * The global `WebSocket` is used where there is one (browsers, Node 22+). Node
11
+ * before that gets the `ws` package if it is installed.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.defaultSocketFactory = void 0;
15
+ exports.buildHandshake = buildHandshake;
16
+ const SERVICE_PROTOCOL_PREFIX = "butler.service.";
17
+ const CREDENTIAL_PROTOCOL_PREFIX = "butler.key.";
18
+ /** HTTP token characters, which is all a subprotocol may contain. */
19
+ const TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
20
+ /**
21
+ * Builds the websocket URL and subprotocols for a service.
22
+ *
23
+ * The credential prefers the subprotocol because a URL ends up in access logs.
24
+ */
25
+ function buildHandshake(serverUrl, service, apiKey) {
26
+ const url = new URL(serverUrl);
27
+ url.protocol = url.protocol === "http:" ? "ws:" : url.protocol === "https:" ? "wss:" : url.protocol;
28
+ url.searchParams.set("service", service);
29
+ const protocols = [`${SERVICE_PROTOCOL_PREFIX}${service}`];
30
+ if (TOKEN_PATTERN.test(apiKey))
31
+ protocols.push(`${CREDENTIAL_PROTOCOL_PREFIX}${apiKey}`);
32
+ else
33
+ url.searchParams.set("api_key", apiKey);
34
+ return { url: url.toString(), protocols };
35
+ }
36
+ function resolveWebSocket() {
37
+ const globalWebSocket = globalThis.WebSocket;
38
+ if (globalWebSocket)
39
+ return globalWebSocket;
40
+ try {
41
+ // Only reached on Node versions without a global WebSocket.
42
+ const load = eval("typeof require === 'function' ? require : undefined");
43
+ const ws = load?.("ws");
44
+ const constructor = ws?.WebSocket ?? ws;
45
+ if (constructor)
46
+ return constructor;
47
+ }
48
+ catch {
49
+ // Falls through to the error below.
50
+ }
51
+ throw new Error("No WebSocket implementation found. Use Node 22+, or install `ws`, "
52
+ + "or pass your own `socketFactory` to the Link.");
53
+ }
54
+ const defaultSocketFactory = (url, protocols, handlers) => {
55
+ const WebSocketImpl = resolveWebSocket();
56
+ const socket = new WebSocketImpl(url, protocols);
57
+ socket.addEventListener("open", (() => handlers.onOpen()));
58
+ socket.addEventListener("message", ((event) => {
59
+ handlers.onMessage(typeof event.data === "string" ? event.data : String(event.data));
60
+ }));
61
+ socket.addEventListener("close", ((event) => {
62
+ handlers.onClose(event.code ?? 1006, event.reason ?? "");
63
+ }));
64
+ socket.addEventListener("error", ((event) => handlers.onError(event)));
65
+ return {
66
+ send: (data) => socket.send(data),
67
+ close: (code, reason) => socket.close(code, reason),
68
+ };
69
+ };
70
+ exports.defaultSocketFactory = defaultSocketFactory;
@@ -0,0 +1,95 @@
1
+ import { LinkToolDescriptor } from "./protocol";
2
+ import { InferSchemaOutput, JSONSchema, ToolSchema } from "./schema";
3
+ /** Reports progress while a tool runs. Shown live in Alfred's tool status feed. */
4
+ export type ToolStatusReporter = {
5
+ /** Replaces the current status label. */
6
+ update(label: string): void;
7
+ /** Marks the tool as failed in the UI. The thrown error still decides the result. */
8
+ fail(label: string): void;
9
+ };
10
+ export type ToolCallMeta = {
11
+ /** The user Alfred is acting for. */
12
+ userId: string;
13
+ /** The conversation the call came from, when it came from one. */
14
+ chatId?: string;
15
+ /** Unique per call, useful for logs. */
16
+ runId: string;
17
+ };
18
+ export type ToolRunContext<S extends ToolSchema | undefined> = {
19
+ /** Typed from `schema` when one was given. */
20
+ args: S extends ToolSchema ? InferSchemaOutput<S> : Record<string, unknown>;
21
+ meta: ToolCallMeta;
22
+ status: ToolStatusReporter;
23
+ /** Aborted when Alfred cancels the call or it times out server-side. */
24
+ signal: AbortSignal;
25
+ };
26
+ export type ToolConfig<S extends ToolSchema | undefined> = {
27
+ /**
28
+ * This tool's id within the link. The public id becomes `link:<linkId>/<id>`,
29
+ * which is what the user's saved settings refer to — so treat it as permanent.
30
+ */
31
+ id: string;
32
+ /** What the tool does. This is what the model reads to decide whether to call it. */
33
+ description: string;
34
+ /** A zod 4 schema, any Standard Schema, or a plain JSON Schema object. */
35
+ schema?: S;
36
+ /** JSON Schema to send instead of deriving it from `schema`. */
37
+ jsonSchema?: JSONSchema;
38
+ /** Shown in Alfred's settings UI. Without it the tool is hidden there. */
39
+ display?: {
40
+ name: string;
41
+ shortDescription: string;
42
+ longDescription: string;
43
+ };
44
+ /** Whether the tool is on before the user has touched it. */
45
+ defaultEnabled?: boolean;
46
+ /** How long the server waits for a result before giving up. */
47
+ timeoutMs?: number;
48
+ /** Runs the tool. Return anything JSON-serialisable, or throw to fail the call. */
49
+ run: (context: ToolRunContext<S>) => unknown | Promise<unknown>;
50
+ };
51
+ /**
52
+ * A tool of any schema, which is what a link holds.
53
+ *
54
+ * `Tool<S>` mentions `S` in its config, so a collection of tools with different
55
+ * schemas has no common `Tool<...>` type. This is the part a link actually uses,
56
+ * and every `Tool` satisfies it whatever its schema.
57
+ */
58
+ export interface AnyTool {
59
+ readonly id: string;
60
+ linkedId?: string;
61
+ descriptor(): LinkToolDescriptor;
62
+ invoke(args: unknown, meta: ToolCallMeta, status: ToolStatusReporter, signal: AbortSignal): Promise<ToolInvocation>;
63
+ }
64
+ /** The outcome of one call, as reported back to the server. */
65
+ export type ToolInvocation = {
66
+ ok: true;
67
+ output: unknown;
68
+ } | {
69
+ ok: false;
70
+ error: string;
71
+ };
72
+ /**
73
+ * A tool that runs on your machine and that Alfred can call.
74
+ *
75
+ * Registered tools belong to the *user*, not to a conversation: once a link is
76
+ * connected Alfred can call them anywhere that user talks to it, including the web
77
+ * app and Discord.
78
+ */
79
+ export declare class Tool<S extends ToolSchema | undefined = undefined> {
80
+ private readonly config;
81
+ readonly id: string;
82
+ /** The public id (`link:<linkId>/<id>`), known once the link has registered it. */
83
+ linkedId?: string;
84
+ constructor(config: ToolConfig<S>);
85
+ get description(): string;
86
+ /** The declaration sent to the server. */
87
+ descriptor(): LinkToolDescriptor;
88
+ /**
89
+ * Validates the arguments, runs the tool, and turns a throw into a failure.
90
+ *
91
+ * Never rejects: a call the server is waiting on must always get an answer, and
92
+ * a thrown error is a result like any other.
93
+ */
94
+ invoke(args: unknown, meta: ToolCallMeta, status: ToolStatusReporter, signal: AbortSignal): Promise<ToolInvocation>;
95
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Tool = void 0;
4
+ const schema_1 = require("./schema");
5
+ /**
6
+ * A tool that runs on your machine and that Alfred can call.
7
+ *
8
+ * Registered tools belong to the *user*, not to a conversation: once a link is
9
+ * connected Alfred can call them anywhere that user talks to it, including the web
10
+ * app and Discord.
11
+ */
12
+ class Tool {
13
+ constructor(config) {
14
+ this.config = config;
15
+ this.id = config.id;
16
+ }
17
+ get description() {
18
+ return this.config.description;
19
+ }
20
+ /** The declaration sent to the server. */
21
+ descriptor() {
22
+ const inputSchema = this.config.jsonSchema
23
+ ?? (this.config.schema ? (0, schema_1.toJSONSchema)(this.config.schema, this.id) : { type: "object", properties: {} });
24
+ return {
25
+ localId: this.id,
26
+ description: this.config.description,
27
+ inputSchema,
28
+ ...(this.config.display ? { display: this.config.display } : {}),
29
+ ...(this.config.defaultEnabled !== undefined ? { defaultEnabled: this.config.defaultEnabled } : {}),
30
+ ...(this.config.timeoutMs !== undefined ? { timeoutMs: this.config.timeoutMs } : {}),
31
+ };
32
+ }
33
+ /**
34
+ * Validates the arguments, runs the tool, and turns a throw into a failure.
35
+ *
36
+ * Never rejects: a call the server is waiting on must always get an answer, and
37
+ * a thrown error is a result like any other.
38
+ */
39
+ async invoke(args, meta, status, signal) {
40
+ const validated = await (0, schema_1.validateArgs)(this.config.schema, args);
41
+ if (!validated.ok)
42
+ return { ok: false, error: validated.error };
43
+ try {
44
+ const output = await this.config.run({ args: validated.value, meta, status, signal });
45
+ return { ok: true, output };
46
+ }
47
+ catch (error) {
48
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
49
+ }
50
+ }
51
+ }
52
+ exports.Tool = Tool;
@@ -1,5 +1,7 @@
1
1
  import { EventSource } from "eventsource";
2
2
  import { APIPath } from "../config";
3
+ import type { Link } from "../link/link";
4
+ import { ConversationStream } from "./transport";
3
5
  import { RequestResponseV3, RequestResponseV4 } from "../types/type_registry";
4
6
  import { RequestResponseV5 } from "../types/response/v5/dialogue_response_v5";
5
7
  import { ConversationStateResponse } from "../types/state/convo_state_response";
@@ -54,6 +56,14 @@ export type ConversationOptions<V extends APIPath = "v4"> = {
54
56
  debug?: boolean;
55
57
  /** The API version to use */
56
58
  chatApiV?: V;
59
+ /**
60
+ * How turns are carried.
61
+ *
62
+ * `"sse"` (the default) opens an HTTP stream per turn. Passing a connected `Link`
63
+ * instead carries turns over that websocket, reusing a connection you already
64
+ * have — everything else, including the payloads you receive, is identical.
65
+ */
66
+ transport?: "sse" | Link;
57
67
  };
58
68
  export declare class Conversation<V extends APIPath = "v4"> {
59
69
  convoId?: string;
@@ -62,6 +72,9 @@ export declare class Conversation<V extends APIPath = "v4"> {
62
72
  private chatApiV;
63
73
  private options?;
64
74
  private events;
75
+ private transport;
76
+ /** The link carrying this conversation, when it is not on SSE. */
77
+ readonly link?: Link;
65
78
  private endpoints;
66
79
  constructor(config: ConversationOptions<V>);
67
80
  /**
@@ -110,12 +123,6 @@ export declare class Conversation<V extends APIPath = "v4"> {
110
123
  getPlatform(): string | undefined;
111
124
  /** Gets the current personality configuration */
112
125
  getPersonality(): string | undefined;
113
- private hasEmitter;
114
- private addEmitter;
115
- private removeEmitter;
116
- private addListener;
117
- private removeListener;
118
- private emit;
119
126
  /**
120
127
  * Fires when the conversation ID is set
121
128
  * if convoId is already set when this is called, fires immediately
@@ -127,8 +134,7 @@ export declare class Conversation<V extends APIPath = "v4"> {
127
134
  * Fires once when the conversation ID is set, then removes the listener.
128
135
  * If convoId is already set, fires immediately
129
136
  */
130
- onceConvoId(cb: (convoId: string) => any): `${string}-${string}-${string}-${string}-${string}` | undefined;
131
- private handleSSE;
137
+ onceConvoId(cb: (convoId: string) => any): string | undefined;
132
138
  /** Fetches the conversation state from the server, including message history and metadata */
133
139
  fetchState(): Promise<ConversationStateResponse>;
134
140
  /** Fetches the conversation progress stream from the server */
@@ -142,6 +148,19 @@ export declare class Conversation<V extends APIPath = "v4"> {
142
148
  includeCompleted?: boolean;
143
149
  }): Promise<TurnProgressEntryForVersion<V>[] | undefined>;
144
150
  /** Sends a message into the conversation */
145
- send(message: string, cb: (chunk: RequestResponseByVersion[V]) => any, options?: DialogueRequestOptions): EventSource;
151
+ send(message: string, cb: (chunk: RequestResponseByVersion[V]) => any, options?: DialogueRequestOptions): ConversationStream;
152
+ /**
153
+ * Sends a message and resolves with the finished reply.
154
+ *
155
+ * For when you want the answer rather than the stream. Every event still arrives
156
+ * through `onEvent` if you pass one.
157
+ */
158
+ ask(message: string, options?: DialogueRequestOptions & {
159
+ onEvent?: (chunk: RequestResponseByVersion[V]) => any;
160
+ }): Promise<{
161
+ text: string;
162
+ convoId?: string;
163
+ events: RequestResponseByVersion[V][];
164
+ }>;
146
165
  }
147
166
  export {};