@butlerbot/sdk 0.0.19 → 0.0.21

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.
@@ -1,4 +1,5 @@
1
1
  import { LinkHookDeclaration, LinkHookEventDeclaration } from "./protocol";
2
+ import type { LinkSubscription } from "./subscriptions";
2
3
  import { JSONSchema, ToolSchema } from "./schema";
3
4
  export type HookConfig<S extends ToolSchema | undefined> = {
4
5
  /**
@@ -37,6 +38,13 @@ export type HookEmitter = {
37
38
  * which only exists once the hook has been registered.
38
39
  */
39
40
  emitHook(hookId: string, event: string, payload?: Record<string, unknown>, ownerId?: string): Promise<void>;
41
+ /**
42
+ * Reports an event against the subscriptions it matched, sending nothing when it
43
+ * matched none. Returns the ids that were reported.
44
+ */
45
+ reportHookEvent(hookId: string, event: string, payload?: Record<string, unknown>): Promise<string[]>;
46
+ /** The subscriptions the server has pushed for this hook's source. */
47
+ hookSubscriptions(hookId: string): LinkSubscription[];
40
48
  };
41
49
  /**
42
50
  * A source of events that can wake the user's background agents.
@@ -67,4 +75,28 @@ export declare class Hook<S extends ToolSchema | undefined = undefined> {
67
75
  * user-scoped link is always stamped with its own owner and may not name another.
68
76
  */
69
77
  emit(event: string, payload?: Record<string, unknown>, ownerId?: string): Promise<void>;
78
+ /**
79
+ * Reports that something happened, to whoever asked to be told.
80
+ *
81
+ * The difference from `emit` is where the filtering lives. `emit` hands the event to the server
82
+ * and lets it decide who cares, which means the server needs to understand your platform's
83
+ * payloads. `report` matches locally against the subscriptions the server pushed down, and sends
84
+ * only the ids that matched — so "messages in #support from non-bots" stays knowledge that lives
85
+ * in your code, and an event nobody subscribed to costs nothing at all.
86
+ *
87
+ * Returns the subscription ids reported, which is `[]` when nothing matched. `[]` is the common
88
+ * case in a busy channel and is not an error.
89
+ *
90
+ * Match it yourself when the condition is more than field equality — `subscriptions` gives you the
91
+ * list, including `identities` for "is this about *my* user" questions — and pass the ids to
92
+ * `reportTo`.
93
+ */
94
+ report(event: string, payload?: Record<string, unknown>): Promise<string[]>;
95
+ /**
96
+ * What the server has asked this hook to watch.
97
+ *
98
+ * Empty until the link is connected and the snapshot has arrived, and empty again after a
99
+ * disconnect — nothing is persisted, because the server re-sends it on every connect.
100
+ */
101
+ get subscriptions(): LinkSubscription[];
70
102
  }
package/dist/link/hook.js CHANGED
@@ -56,5 +56,41 @@ class Hook {
56
56
  // registration has assigned one, and the link fills it in once it knows.
57
57
  await this.link.emitHook(this.id, event, payload, ownerId);
58
58
  }
59
+ /**
60
+ * Reports that something happened, to whoever asked to be told.
61
+ *
62
+ * The difference from `emit` is where the filtering lives. `emit` hands the event to the server
63
+ * and lets it decide who cares, which means the server needs to understand your platform's
64
+ * payloads. `report` matches locally against the subscriptions the server pushed down, and sends
65
+ * only the ids that matched — so "messages in #support from non-bots" stays knowledge that lives
66
+ * in your code, and an event nobody subscribed to costs nothing at all.
67
+ *
68
+ * Returns the subscription ids reported, which is `[]` when nothing matched. `[]` is the common
69
+ * case in a busy channel and is not an error.
70
+ *
71
+ * Match it yourself when the condition is more than field equality — `subscriptions` gives you the
72
+ * list, including `identities` for "is this about *my* user" questions — and pass the ids to
73
+ * `reportTo`.
74
+ */
75
+ async report(event, payload) {
76
+ if (!this.link) {
77
+ throw new Error(`Hook "${this.id}" is not on a link yet — call link.addHook(hook) first.`);
78
+ }
79
+ // Caught here rather than on the wire: a typo should fail where it was made.
80
+ if (!this.config.events.some(declared => declared.name === event)) {
81
+ const declared = this.config.events.map(entry => entry.name).join(", ") || "none";
82
+ throw new Error(`Hook "${this.id}" does not declare an event named "${event}". Declared: ${declared}.`);
83
+ }
84
+ return this.link.reportHookEvent(this.id, event, payload);
85
+ }
86
+ /**
87
+ * What the server has asked this hook to watch.
88
+ *
89
+ * Empty until the link is connected and the snapshot has arrived, and empty again after a
90
+ * disconnect — nothing is persisted, because the server re-sends it on every connect.
91
+ */
92
+ get subscriptions() {
93
+ return this.link?.hookSubscriptions(this.id) ?? [];
94
+ }
59
95
  }
60
96
  exports.Hook = Hook;
@@ -6,6 +6,10 @@ export { Hook } from "./hook";
6
6
  export type { AnyHook, HookConfig, HookEmitter } from "./hook";
7
7
  export { LINK_PROTOCOL_VERSION, LinkError } from "./protocol";
8
8
  export type { LinkClientFrame, LinkClientFrameOf, LinkClientFrameType, LinkServerFrame, LinkServerFrameOf, LinkServerFrameType, LinkScopeKind, LinkToolDescriptor, LinkHookDeclaration, LinkHookEventDeclaration, } from "./protocol";
9
+ export { SubscriptionStore } from "./subscriptions";
10
+ export type { LinkSubscription, SubscriptionSnapshot, SubscriptionDelta } from "./subscriptions";
11
+ export { matchesPrefilter, readPath } from "./prefilter";
12
+ export type { Prefilter, PrefilterCondition, PrefilterScalar } from "./prefilter";
9
13
  export type { JSONSchema, StandardSchemaV1, ToolSchema, InferSchemaOutput } from "./schema";
10
14
  export { buildHandshake, defaultSocketFactory } from "./socket";
11
15
  export type { SocketFactory, SocketConnection, SocketHandlers } from "./socket";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultSocketFactory = exports.buildHandshake = exports.LinkError = exports.LINK_PROTOCOL_VERSION = exports.Hook = exports.Tool = exports.Link = void 0;
3
+ exports.defaultSocketFactory = exports.buildHandshake = exports.readPath = exports.matchesPrefilter = exports.SubscriptionStore = exports.LinkError = exports.LINK_PROTOCOL_VERSION = exports.Hook = exports.Tool = exports.Link = void 0;
4
4
  var link_1 = require("./link");
5
5
  Object.defineProperty(exports, "Link", { enumerable: true, get: function () { return link_1.Link; } });
6
6
  var tool_1 = require("./tool");
@@ -10,6 +10,11 @@ Object.defineProperty(exports, "Hook", { enumerable: true, get: function () { re
10
10
  var protocol_1 = require("./protocol");
11
11
  Object.defineProperty(exports, "LINK_PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_1.LINK_PROTOCOL_VERSION; } });
12
12
  Object.defineProperty(exports, "LinkError", { enumerable: true, get: function () { return protocol_1.LinkError; } });
13
+ var subscriptions_1 = require("./subscriptions");
14
+ Object.defineProperty(exports, "SubscriptionStore", { enumerable: true, get: function () { return subscriptions_1.SubscriptionStore; } });
15
+ var prefilter_1 = require("./prefilter");
16
+ Object.defineProperty(exports, "matchesPrefilter", { enumerable: true, get: function () { return prefilter_1.matchesPrefilter; } });
17
+ Object.defineProperty(exports, "readPath", { enumerable: true, get: function () { return prefilter_1.readPath; } });
13
18
  var socket_1 = require("./socket");
14
19
  Object.defineProperty(exports, "buildHandshake", { enumerable: true, get: function () { return socket_1.buildHandshake; } });
15
20
  Object.defineProperty(exports, "defaultSocketFactory", { enumerable: true, get: function () { return socket_1.defaultSocketFactory; } });
@@ -2,6 +2,7 @@ import { AnyHook } from "./hook";
2
2
  import { LinkClientFrameType, LinkClientPayloads, LinkScopeKind, LinkServerFrame } from "./protocol";
3
3
  import { SocketFactory } from "./socket";
4
4
  import { AnyTool } from "./tool";
5
+ import { type LinkSubscription } from "./subscriptions";
5
6
  export type LinkState = "idle" | "connecting" | "open" | "closed";
6
7
  export type LinkEvents = {
7
8
  /** The link is connected and everything it holds has been registered. */
@@ -23,6 +24,14 @@ export type LinkEvents = {
23
24
  reason: string;
24
25
  reconnectAfterMs: number;
25
26
  }];
27
+ /**
28
+ * The set of things the server wants watched has changed.
29
+ *
30
+ * Fires on every snapshot and every delta, including the first one after connecting. Use it to
31
+ * set up whatever your platform needs in order to watch — a channel listener, a poll — and note
32
+ * that it can fire with an empty list, which means "nothing is subscribed right now".
33
+ */
34
+ subscriptions: [LinkSubscription[]];
26
35
  };
27
36
  export type LinkOptions = {
28
37
  /** A user API key, or the service key for a global link. */
@@ -75,6 +84,7 @@ export declare class Link {
75
84
  private readonly emitter;
76
85
  private readonly tools;
77
86
  private readonly hooks;
87
+ private readonly subscriptionStore;
78
88
  private readonly pending;
79
89
  private readonly calls;
80
90
  private socket;
@@ -123,6 +133,20 @@ export declare class Link {
123
133
  private registerAll;
124
134
  private registerTools;
125
135
  private registerHook;
136
+ /**
137
+ * Called by `Hook.report`.
138
+ *
139
+ * Sends nothing when the event matched nothing, which is the entire volume story: a busy channel
140
+ * produces thousands of events a day that no reflex asked about, and none of them reach the wire.
141
+ *
142
+ * The epoch travels with the frame so the server can tell a stale view from a bad one — an id
143
+ * that was valid a moment ago is a race, not a bug worth complaining about.
144
+ */
145
+ reportHookEvent(hookId: string, event: string, payload?: Record<string, unknown>): Promise<string[]>;
146
+ /** Called by `Hook.subscriptions`. */
147
+ hookSubscriptions(hookId: string): LinkSubscription[];
148
+ /** Everything this link has been asked to watch, across all of its hooks. */
149
+ get subscriptions(): LinkSubscription[];
126
150
  /** Called by `Hook.emit`. */
127
151
  emitHook(hookId: string, event: string, payload?: Record<string, unknown>, ownerId?: string): Promise<void>;
128
152
  /** Sends a frame without waiting for anything. Returns its id. */
package/dist/link/link.js CHANGED
@@ -5,6 +5,7 @@ const config_1 = require("../config");
5
5
  const emitter_1 = require("../util/emitter");
6
6
  const protocol_1 = require("./protocol");
7
7
  const socket_1 = require("./socket");
8
+ const subscriptions_1 = require("./subscriptions");
8
9
  const DEFAULTS = {
9
10
  minReconnectDelayMs: 500,
10
11
  maxReconnectDelayMs: 30000,
@@ -23,6 +24,7 @@ class Link {
23
24
  this.emitter = new emitter_1.Emitter();
24
25
  this.tools = new Map();
25
26
  this.hooks = new Map();
27
+ this.subscriptionStore = new subscriptions_1.SubscriptionStore();
26
28
  this.pending = new Map();
27
29
  this.calls = new Map();
28
30
  this.socket = null;
@@ -40,6 +42,20 @@ class Link {
40
42
  socketFactory: socket_1.defaultSocketFactory,
41
43
  ...stripUndefined(options),
42
44
  };
45
+ // A gap means a delta was missed, and the answer is always the same: ask for the snapshot
46
+ // again. Sent best-effort — if the socket has gone, the reconnect will bring a snapshot anyway.
47
+ this.subscriptionStore.onResyncNeeded = (have) => {
48
+ this.debug(`subscription epoch gap at ${have}, resyncing`);
49
+ try {
50
+ this.send("hook.subscriptions.resync", { have });
51
+ }
52
+ catch {
53
+ // Disconnected mid-gap. The next connect re-declares and is told again.
54
+ }
55
+ };
56
+ this.subscriptionStore.onChange = (subscriptions) => {
57
+ this.emitter.emit("subscriptions", subscriptions);
58
+ };
43
59
  }
44
60
  // =============================================
45
61
  // WHAT THE LINK HOLDS
@@ -176,6 +192,9 @@ class Link {
176
192
  this.identity = undefined;
177
193
  this.connecting = undefined;
178
194
  this.currentState = willReconnect ? "connecting" : "closed";
195
+ // Nothing about subscriptions survives a socket: the server re-sends the set on every
196
+ // connect, so holding the old one would only risk reporting against ids that are gone.
197
+ this.subscriptionStore.reset();
179
198
  this.stopHeartbeat();
180
199
  this.failPending(new protocol_1.LinkError("disconnected", `The link disconnected (${code}${reason ? `: ${reason}` : ""}).`));
181
200
  this.emitter.emit("disconnect", { code, reason, willReconnect });
@@ -240,6 +259,39 @@ class Link {
240
259
  hook.sourceId = frame.payload.ids?.[0];
241
260
  this.debug(`registered hook ${hook.id}`);
242
261
  }
262
+ /**
263
+ * Called by `Hook.report`.
264
+ *
265
+ * Sends nothing when the event matched nothing, which is the entire volume story: a busy channel
266
+ * produces thousands of events a day that no reflex asked about, and none of them reach the wire.
267
+ *
268
+ * The epoch travels with the frame so the server can tell a stale view from a bad one — an id
269
+ * that was valid a moment ago is a race, not a bug worth complaining about.
270
+ */
271
+ async reportHookEvent(hookId, event, payload) {
272
+ await this.ready();
273
+ const sourceId = this.hooks.get(hookId)?.sourceId ?? hookId;
274
+ const subscriptionIds = this.subscriptionStore.match({ sourceId, event, payload });
275
+ if (subscriptionIds.length === 0)
276
+ return [];
277
+ this.send("hook.event", {
278
+ sourceId,
279
+ subscriptionIds,
280
+ event,
281
+ ...(payload ? { payload } : {}),
282
+ epoch: this.subscriptionStore.epoch,
283
+ });
284
+ return subscriptionIds;
285
+ }
286
+ /** Called by `Hook.subscriptions`. */
287
+ hookSubscriptions(hookId) {
288
+ const sourceId = this.hooks.get(hookId)?.sourceId;
289
+ return sourceId ? this.subscriptionStore.forSource(sourceId) : [];
290
+ }
291
+ /** Everything this link has been asked to watch, across all of its hooks. */
292
+ get subscriptions() {
293
+ return this.subscriptionStore.all();
294
+ }
243
295
  /** Called by `Hook.emit`. */
244
296
  async emitHook(hookId, event, payload, ownerId) {
245
297
  await this.ready();
@@ -349,6 +401,12 @@ class Link {
349
401
  this.debug(`call ${callId} cancelled: ${reason}`);
350
402
  return;
351
403
  }
404
+ case "hook.subscriptions":
405
+ this.subscriptionStore.applySnapshot(frame.payload);
406
+ return;
407
+ case "hook.subscriptions.delta":
408
+ this.subscriptionStore.applyDelta(frame.payload);
409
+ return;
352
410
  case "log":
353
411
  this.emitter.emit("log", frame.payload.log);
354
412
  return;
@@ -379,9 +437,20 @@ class Link {
379
437
  }
380
438
  const controller = new AbortController();
381
439
  this.calls.set(callId, controller);
440
+ // Only a final status is kept in the conversation, so every call has to end on
441
+ // one. The tool may send its own, with a better label than we could invent; if
442
+ // it does not, we send one for it below rather than leave the call unfinished.
443
+ let reported = "running";
444
+ const report = (label, state) => {
445
+ if (reported !== "running")
446
+ return;
447
+ reported = state;
448
+ this.send("tool.status", { label, state }, frame.id);
449
+ };
382
450
  const status = {
383
- update: (label) => this.send("tool.status", { label, state: "running" }, frame.id),
384
- fail: (label) => this.send("tool.status", { label, state: "failed" }, frame.id),
451
+ update: (label) => report(label, "running"),
452
+ complete: (label) => report(label, "completed"),
453
+ fail: (label) => report(label, "failed"),
385
454
  };
386
455
  void tool.invoke(args, meta, status, controller.signal).then(result => {
387
456
  this.calls.delete(callId);
@@ -389,6 +458,15 @@ class Link {
389
458
  // given up on the call, so there is nothing to report to.
390
459
  if (!this.socket)
391
460
  return;
461
+ if (!result.ok && reported === "completed") {
462
+ // The tool said it succeeded and then failed anyway. The outcome is the
463
+ // part worth keeping, so let the failure replace what it claimed.
464
+ reported = "running";
465
+ }
466
+ if (result.ok)
467
+ report(`Finished ${tool.id}.`, "completed");
468
+ else
469
+ report(result.error, "failed");
392
470
  this.send("tool.result", result.ok
393
471
  ? { ok: true, output: result.output }
394
472
  : { ok: false, error: result.error }, frame.id);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The prefilter, client side.
3
+ *
4
+ * A deliberate reimplementation rather than a shared package: the server's copy is the reference,
5
+ * and the point of keeping the language this dumb is that any client in any language can rewrite it
6
+ * in twenty lines. A shared dependency would make that claim untestable and would couple every
7
+ * platform's release cycle to the server's.
8
+ *
9
+ * It is a volume gate, not an expressiveness mechanism. **Nothing may depend on it.** A client that
10
+ * ignores prefilters and reports every event is still correct, just louder — the server evaluates
11
+ * the same condition again before spending anything.
12
+ *
13
+ * Every condition is ANDed. No OR, no regex, no arithmetic, no code.
14
+ */
15
+ export type PrefilterScalar = string | number | boolean | null;
16
+ export type PrefilterCondition = PrefilterScalar | PrefilterScalar[] | {
17
+ not: PrefilterScalar | PrefilterScalar[];
18
+ } | {
19
+ exists: boolean;
20
+ };
21
+ /** Dot-path to condition. An empty or absent prefilter matches everything. */
22
+ export type Prefilter = Record<string, PrefilterCondition>;
23
+ export declare function matchesPrefilter(payload: unknown, prefilter?: Prefilter | null): boolean;
24
+ /** Reads `author.bot` out of `{ author: { bot: true } }`. Missing paths read as `undefined`. */
25
+ export declare function readPath(payload: unknown, path: string): unknown;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ /**
3
+ * The prefilter, client side.
4
+ *
5
+ * A deliberate reimplementation rather than a shared package: the server's copy is the reference,
6
+ * and the point of keeping the language this dumb is that any client in any language can rewrite it
7
+ * in twenty lines. A shared dependency would make that claim untestable and would couple every
8
+ * platform's release cycle to the server's.
9
+ *
10
+ * It is a volume gate, not an expressiveness mechanism. **Nothing may depend on it.** A client that
11
+ * ignores prefilters and reports every event is still correct, just louder — the server evaluates
12
+ * the same condition again before spending anything.
13
+ *
14
+ * Every condition is ANDed. No OR, no regex, no arithmetic, no code.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.matchesPrefilter = matchesPrefilter;
18
+ exports.readPath = readPath;
19
+ function matchesPrefilter(payload, prefilter) {
20
+ if (!prefilter)
21
+ return true;
22
+ for (const [path, condition] of Object.entries(prefilter)) {
23
+ if (!matchesCondition(readPath(payload, path), condition))
24
+ return false;
25
+ }
26
+ return true;
27
+ }
28
+ function matchesCondition(value, condition) {
29
+ if (Array.isArray(condition))
30
+ return condition.some(candidate => value === candidate);
31
+ if (condition && typeof condition === "object") {
32
+ if ("exists" in condition)
33
+ return (value !== undefined) === condition.exists;
34
+ if ("not" in condition)
35
+ return !matchesCondition(value, condition.not);
36
+ }
37
+ return value === condition;
38
+ }
39
+ /** Reads `author.bot` out of `{ author: { bot: true } }`. Missing paths read as `undefined`. */
40
+ function readPath(payload, path) {
41
+ let current = payload;
42
+ for (const segment of path.split(".")) {
43
+ if (current === null || current === undefined)
44
+ return undefined;
45
+ if (typeof current !== "object")
46
+ return undefined;
47
+ current = current[segment];
48
+ }
49
+ return current;
50
+ }
@@ -36,6 +36,8 @@ export type LinkHookDeclaration = {
36
36
  argsSchema: Record<string, unknown>;
37
37
  events: LinkHookEventDeclaration[];
38
38
  };
39
+ export type { LinkSubscription } from "./subscriptions";
40
+ import type { LinkSubscription } from "./subscriptions";
39
41
  export type LinkClientPayloads = {
40
42
  "hello": {
41
43
  linkId: string;
@@ -67,6 +69,24 @@ export type LinkClientPayloads = {
67
69
  payload?: Record<string, unknown>;
68
70
  ownerId?: string;
69
71
  };
72
+ /**
73
+ * An event reported against the subscriptions it matched.
74
+ *
75
+ * The inverted path, and the one to prefer: the server pushed a list of things to watch, this
76
+ * client matched, and the ids say which subscriptions matched. Several ids in one frame is
77
+ * fan-out — five reflexes watching one channel is one frame. No user is ever named.
78
+ */
79
+ "hook.event": {
80
+ sourceId: string;
81
+ subscriptionIds: string[];
82
+ event: string;
83
+ payload?: Record<string, unknown>;
84
+ epoch?: number;
85
+ };
86
+ /** Asks for a fresh snapshot after noticing an epoch gap. */
87
+ "hook.subscriptions.resync": {
88
+ have?: number;
89
+ };
70
90
  "conversation.start": {
71
91
  chatId?: string;
72
92
  model?: string;
@@ -162,6 +182,25 @@ export type LinkServerPayloads = {
162
182
  reason: string;
163
183
  reconnectAfterMs: number;
164
184
  };
185
+ /**
186
+ * The full set this connection should watch, for the sources it has registered.
187
+ *
188
+ * `chunk` / `of` are 1-based and present only when the set was split; every chunk of one snapshot
189
+ * carries the same `epoch`.
190
+ */
191
+ "hook.subscriptions": {
192
+ epoch: number;
193
+ chunk?: number;
194
+ of?: number;
195
+ subscriptions: LinkSubscription[];
196
+ };
197
+ /** An incremental change. An epoch more than one ahead means a delta was missed. */
198
+ "hook.subscriptions.delta": {
199
+ epoch: number;
200
+ added?: LinkSubscription[];
201
+ removed?: string[];
202
+ updated?: LinkSubscription[];
203
+ };
165
204
  };
166
205
  export type LinkServerFrameType = keyof LinkServerPayloads;
167
206
  /** A union of one member per frame type, so `frame.type` narrows `frame.payload`. */
@@ -0,0 +1,95 @@
1
+ import { type Prefilter } from "./prefilter";
2
+ /**
3
+ * One thing the server wants watched.
4
+ *
5
+ * `subscriptionId` is a handle the server issued, not a name you can invent: it is how an event is
6
+ * attributed to a reflex and therefore to a user, which is why reporting an event never involves
7
+ * naming a user at all.
8
+ */
9
+ export type LinkSubscription = {
10
+ subscriptionId: string;
11
+ sourceId: string;
12
+ /** Absent means every event from the source. */
13
+ event?: string;
14
+ /** A cheap condition to apply before reporting. Advisory — see `prefilter.ts`. */
15
+ prefilter?: Prefilter;
16
+ /**
17
+ * The owner's accounts on platforms they have linked, keyed by a namespace this client
18
+ * understands: `{ discord: "1897..." }`. Absent when the owner has linked nothing, in which case
19
+ * a subscription that needs an identity to make sense should be treated as un-matchable rather
20
+ * than matched against a guess.
21
+ */
22
+ identities?: Record<string, string>;
23
+ /** For logging and for telling a user what is being watched. */
24
+ name?: string;
25
+ };
26
+ export type SubscriptionSnapshot = {
27
+ epoch: number;
28
+ chunk?: number;
29
+ of?: number;
30
+ subscriptions: LinkSubscription[];
31
+ };
32
+ export type SubscriptionDelta = {
33
+ epoch: number;
34
+ added?: LinkSubscription[];
35
+ removed?: string[];
36
+ updated?: LinkSubscription[];
37
+ };
38
+ /**
39
+ * WHAT THIS LINK HAS BEEN TOLD TO WATCH
40
+ * =====================================
41
+ *
42
+ * Holds the subscription set, keeps it consistent, and answers "which subscriptions does this event
43
+ * match".
44
+ *
45
+ * Nothing here is persisted, on purpose. A client that restarts reconnects, re-declares its sources
46
+ * and is sent a fresh snapshot, so there is no durable state that can drift out of agreement with
47
+ * the server and therefore no reconciliation to implement. Deltas and epochs are only an
48
+ * optimisation on top of that: the epoch lets a client notice it missed a delta, and the answer to
49
+ * missing one is always the same — ask for the snapshot again.
50
+ *
51
+ * Chunked snapshots are buffered until every chunk has arrived, so a half-applied set never briefly
52
+ * looks like a complete one. That matters: applying half a snapshot would silently stop reporting
53
+ * events for real subscriptions.
54
+ */
55
+ export declare class SubscriptionStore {
56
+ private subscriptions;
57
+ private currentEpoch;
58
+ private pending?;
59
+ /** Called when the store notices a gap and needs a fresh snapshot. */
60
+ onResyncNeeded?: (have: number) => void;
61
+ /** Called after the set changes, with the new set. */
62
+ onChange?: (subscriptions: LinkSubscription[]) => void;
63
+ get epoch(): number;
64
+ all(): LinkSubscription[];
65
+ get(subscriptionId: string): LinkSubscription | undefined;
66
+ /** Subscriptions for one source, for a caller that wants to report matches itself. */
67
+ forSource(sourceId: string): LinkSubscription[];
68
+ /** Everything is forgotten on disconnect: the next connection is told again. */
69
+ reset(): void;
70
+ applySnapshot(payload: SubscriptionSnapshot): void;
71
+ /**
72
+ * Applies a delta, or asks for a snapshot if one was missed.
73
+ *
74
+ * The gap check is the entire consistency mechanism, and the two failure directions are not the
75
+ * same. An epoch *ahead* of the next one means a delta went missing, and applying this one anyway
76
+ * would leave a set that is wrong in a way nothing later can detect — so ask for the truth. An
77
+ * epoch at or *behind* the current one is a replay, which is already applied; asking for a
78
+ * snapshot would turn a harmless duplicate into a round trip, and a re-sending server into a
79
+ * loop.
80
+ */
81
+ applyDelta(payload: SubscriptionDelta): void;
82
+ /**
83
+ * The subscriptions an event should be reported against.
84
+ *
85
+ * Order is the pushed order, so a platform that logs the ids gets a stable list. An empty result
86
+ * means the event is nobody's business and should not be sent at all — which is the point: the
87
+ * server never sees the 10,000 messages a day that match nothing.
88
+ */
89
+ match(input: {
90
+ sourceId: string;
91
+ event: string;
92
+ payload?: Record<string, unknown>;
93
+ }): string[];
94
+ private replace;
95
+ }
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SubscriptionStore = void 0;
4
+ const prefilter_1 = require("./prefilter");
5
+ /**
6
+ * WHAT THIS LINK HAS BEEN TOLD TO WATCH
7
+ * =====================================
8
+ *
9
+ * Holds the subscription set, keeps it consistent, and answers "which subscriptions does this event
10
+ * match".
11
+ *
12
+ * Nothing here is persisted, on purpose. A client that restarts reconnects, re-declares its sources
13
+ * and is sent a fresh snapshot, so there is no durable state that can drift out of agreement with
14
+ * the server and therefore no reconciliation to implement. Deltas and epochs are only an
15
+ * optimisation on top of that: the epoch lets a client notice it missed a delta, and the answer to
16
+ * missing one is always the same — ask for the snapshot again.
17
+ *
18
+ * Chunked snapshots are buffered until every chunk has arrived, so a half-applied set never briefly
19
+ * looks like a complete one. That matters: applying half a snapshot would silently stop reporting
20
+ * events for real subscriptions.
21
+ */
22
+ class SubscriptionStore {
23
+ constructor() {
24
+ this.subscriptions = new Map();
25
+ this.currentEpoch = 0;
26
+ }
27
+ get epoch() {
28
+ return this.currentEpoch;
29
+ }
30
+ all() {
31
+ return [...this.subscriptions.values()];
32
+ }
33
+ get(subscriptionId) {
34
+ return this.subscriptions.get(subscriptionId);
35
+ }
36
+ /** Subscriptions for one source, for a caller that wants to report matches itself. */
37
+ forSource(sourceId) {
38
+ return this.all().filter(subscription => subscription.sourceId === sourceId);
39
+ }
40
+ /** Everything is forgotten on disconnect: the next connection is told again. */
41
+ reset() {
42
+ this.subscriptions.clear();
43
+ this.currentEpoch = 0;
44
+ this.pending = undefined;
45
+ }
46
+ applySnapshot(payload) {
47
+ const total = payload.of ?? 1;
48
+ if (total === 1) {
49
+ this.replace(payload.epoch, payload.subscriptions);
50
+ return;
51
+ }
52
+ // A snapshot at a new epoch supersedes one still being assembled, rather than merging with
53
+ // it: the older chunks describe a set that no longer exists.
54
+ if (!this.pending || this.pending.epoch !== payload.epoch) {
55
+ this.pending = { epoch: payload.epoch, chunks: new Map(), of: total };
56
+ }
57
+ this.pending.chunks.set(payload.chunk ?? 1, payload.subscriptions);
58
+ if (this.pending.chunks.size < this.pending.of)
59
+ return;
60
+ const assembled = [...this.pending.chunks.entries()]
61
+ .sort(([a], [b]) => a - b)
62
+ .flatMap(([, chunk]) => chunk);
63
+ this.pending = undefined;
64
+ this.replace(payload.epoch, assembled);
65
+ }
66
+ /**
67
+ * Applies a delta, or asks for a snapshot if one was missed.
68
+ *
69
+ * The gap check is the entire consistency mechanism, and the two failure directions are not the
70
+ * same. An epoch *ahead* of the next one means a delta went missing, and applying this one anyway
71
+ * would leave a set that is wrong in a way nothing later can detect — so ask for the truth. An
72
+ * epoch at or *behind* the current one is a replay, which is already applied; asking for a
73
+ * snapshot would turn a harmless duplicate into a round trip, and a re-sending server into a
74
+ * loop.
75
+ */
76
+ applyDelta(payload) {
77
+ if (payload.epoch <= this.currentEpoch)
78
+ return;
79
+ if (payload.epoch > this.currentEpoch + 1) {
80
+ this.onResyncNeeded?.(this.currentEpoch);
81
+ return;
82
+ }
83
+ for (const subscriptionId of payload.removed ?? [])
84
+ this.subscriptions.delete(subscriptionId);
85
+ for (const subscription of [...(payload.added ?? []), ...(payload.updated ?? [])]) {
86
+ this.subscriptions.set(subscription.subscriptionId, subscription);
87
+ }
88
+ this.currentEpoch = payload.epoch;
89
+ this.onChange?.(this.all());
90
+ }
91
+ /**
92
+ * The subscriptions an event should be reported against.
93
+ *
94
+ * Order is the pushed order, so a platform that logs the ids gets a stable list. An empty result
95
+ * means the event is nobody's business and should not be sent at all — which is the point: the
96
+ * server never sees the 10,000 messages a day that match nothing.
97
+ */
98
+ match(input) {
99
+ const candidate = { ...(input.payload ?? {}), type: input.event };
100
+ return this.all()
101
+ .filter(subscription => subscription.sourceId === input.sourceId)
102
+ .filter(subscription => !subscription.event || subscription.event === input.event)
103
+ .filter(subscription => (0, prefilter_1.matchesPrefilter)(candidate, subscription.prefilter))
104
+ .map(subscription => subscription.subscriptionId);
105
+ }
106
+ replace(epoch, subscriptions) {
107
+ this.subscriptions = new Map(subscriptions.map(subscription => [subscription.subscriptionId, subscription]));
108
+ this.currentEpoch = epoch;
109
+ this.onChange?.(this.all());
110
+ }
111
+ }
112
+ exports.SubscriptionStore = SubscriptionStore;
@@ -1,9 +1,19 @@
1
1
  import { LinkToolDescriptor } from "./protocol";
2
2
  import { InferSchemaOutput, JSONSchema, ToolSchema } from "./schema";
3
- /** Reports progress while a tool runs. Shown live in Alfred's tool status feed. */
3
+ /**
4
+ * Reports progress while a tool runs. Shown live in Alfred's tool status feed.
5
+ *
6
+ * `update` is progress; `complete` and `fail` are final. Only a final status is kept
7
+ * in the conversation once the turn ends — a status left at `update` is shown live
8
+ * and then forgotten — so a tool that wants to leave a trace should finish with
9
+ * `complete`. If it does not, the link sends a final status for it when `run`
10
+ * returns, so the call is recorded either way.
11
+ */
4
12
  export type ToolStatusReporter = {
5
- /** Replaces the current status label. */
13
+ /** Replaces the current status label. The call is still running. */
6
14
  update(label: string): void;
15
+ /** Marks the tool as finished, with the label the conversation keeps. */
16
+ complete(label: string): void;
7
17
  /** Marks the tool as failed in the UI. The thrown error still decides the result. */
8
18
  fail(label: string): void;
9
19
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -61,8 +61,9 @@ link.addTool(new Tool({
61
61
  description: "Brew a coffee for the user",
62
62
  schema: z.object({ cups: z.number().int().min(1).max(4) }),
63
63
  run: async ({ args, status }) => {
64
- status.update("Grinding beans");
65
- return `Brewed ${args.cups} cup(s).`; // args is typed from the schema
64
+ status.update("Grinding beans"); // progress, shown while it runs
65
+ status.complete("Brewed the coffee"); // the label the conversation keeps
66
+ return `Brewed ${args.cups} cup(s).`; // args is typed from the schema
66
67
  },
67
68
  }));
68
69
 
@@ -97,6 +98,50 @@ over and the older one's registrations are released. That is deliberate, so a ha
97
98
  socket cannot lock out a fresh one during a deploy, but it does mean two genuinely
98
99
  different clients must not share an id.
99
100
 
101
+ ### Hooks: emit, or report what matched
102
+
103
+ `emit` hands an event to the server and lets it work out who cares. That is fine for a source
104
+ that fires rarely — a water tank, a build finishing — and it is what already-deployed clients do.
105
+
106
+ For a busy source, prefer `report`. The server pushes down the list of things it wants watched,
107
+ your client matches locally, and only the subscriptions that matched are sent:
108
+
109
+ ```ts
110
+ link.on("subscriptions", (subscriptions) => {
111
+ // Called on connect and whenever the set changes. Set up whatever you need to watch.
112
+ for (const subscription of subscriptions) {
113
+ console.log(subscription.name, subscription.prefilter, subscription.identities);
114
+ }
115
+ });
116
+
117
+ // Report an event. Sends nothing at all if no subscription matched.
118
+ const matched = await doorbell.report("rang", { camera: "front" });
119
+ ```
120
+
121
+ Why this is the better path:
122
+
123
+ - **Nothing irrelevant is sent.** A channel with 10,000 messages a day that nobody subscribed to
124
+ costs one local comparison per message and zero frames.
125
+ - **Your platform's semantics stay in your code.** "Messages in #support from non-bots" is
126
+ knowledge Alfred's server never has to learn.
127
+ - **Fan-out is one frame.** Five people watching one channel is one `hook.event` with five ids.
128
+ - **You never name a user.** A subscription id is a handle the server issued and already bound to
129
+ an owner, so ownership is not something your client can get wrong or forge.
130
+
131
+ `subscription.prefilter` is applied for you by `report` — a dot-path map of conditions, all ANDed,
132
+ scalars or arrays (`{ "author.bot": false, "channel.id": ["1", "2"] }`). It is a volume gate, not a
133
+ query language: anything it cannot express, match yourself with `hook.subscriptions` and pass the
134
+ ids you chose. Ignoring prefilters entirely is still *correct*, just louder — the server evaluates
135
+ them again before spending anything.
136
+
137
+ `subscription.identities` is how you answer "is this event about *my* user": a plain string map in
138
+ namespaces you understand, e.g. `{ discord: "1897..." }`, present only for owners who have linked
139
+ that account. Nothing else about the user is exposed.
140
+
141
+ Subscriptions are never persisted by the SDK. They arrive on connect, follow deltas while
142
+ connected, and are dropped on disconnect — so there is nothing to reconcile, and a restart is
143
+ correct by construction.
144
+
100
145
  ### Tools belong to the user, not to a conversation
101
146
 
102
147
  Once a tool is registered, Alfred can call it anywhere that user talks to it — the web