@butlerbot/sdk 0.0.20 → 0.0.22

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>, subscriptionIds?: string[]): 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,41 @@ 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[];
102
+ /**
103
+ * Reports an event to subscriptions you picked yourself.
104
+ *
105
+ * The escape hatch from the prefilter, for conditions that are not field equality: "mentions my
106
+ * user", "within 50 metres", "the third time today". Read `subscriptions`, decide with real code —
107
+ * `identities` is there for the "is this about my user" half — and pass the ids you chose.
108
+ *
109
+ * The prefilter is deliberately not applied to these: you already decided. What is checked is that
110
+ * each id is one this link currently holds for this hook, so a stale id is dropped rather than sent
111
+ * and rejected. Unknown ids are dropped quietly, because a subscription disappearing between your
112
+ * decision and this call is a race, not a mistake.
113
+ */
114
+ reportTo(subscriptionIds: string[], event: string, payload?: Record<string, unknown>): Promise<string[]>;
70
115
  }
package/dist/link/hook.js CHANGED
@@ -56,5 +56,65 @@ 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
+ }
95
+ /**
96
+ * Reports an event to subscriptions you picked yourself.
97
+ *
98
+ * The escape hatch from the prefilter, for conditions that are not field equality: "mentions my
99
+ * user", "within 50 metres", "the third time today". Read `subscriptions`, decide with real code —
100
+ * `identities` is there for the "is this about my user" half — and pass the ids you chose.
101
+ *
102
+ * The prefilter is deliberately not applied to these: you already decided. What is checked is that
103
+ * each id is one this link currently holds for this hook, so a stale id is dropped rather than sent
104
+ * and rejected. Unknown ids are dropped quietly, because a subscription disappearing between your
105
+ * decision and this call is a race, not a mistake.
106
+ */
107
+ async reportTo(subscriptionIds, event, payload) {
108
+ if (!this.link) {
109
+ throw new Error(`Hook "${this.id}" is not on a link yet — call link.addHook(hook) first.`);
110
+ }
111
+ if (!this.config.events.some(declared => declared.name === event)) {
112
+ const declared = this.config.events.map(entry => entry.name).join(", ") || "none";
113
+ throw new Error(`Hook "${this.id}" does not declare an event named "${event}". Declared: ${declared}.`);
114
+ }
115
+ if (subscriptionIds.length === 0)
116
+ return [];
117
+ return this.link.reportHookEvent(this.id, event, payload, subscriptionIds);
118
+ }
59
119
  }
60
120
  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>, chosenIds?: string[]): 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,48 @@ 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, chosenIds) {
272
+ await this.ready();
273
+ const sourceId = this.hooks.get(hookId)?.sourceId ?? hookId;
274
+ // Explicit ids are still checked against what this link actually holds. Not out of distrust of
275
+ // the caller — the server checks again anyway — but because an id it was never given can only
276
+ // be a bug or a race with a removal, and both are better as a dropped report than as a frame
277
+ // the server rejects. Dropped rather than thrown: a subscription vanishing mid-event is normal.
278
+ const subscriptionIds = chosenIds
279
+ ? chosenIds.filter(id => this.subscriptionStore.get(id)?.sourceId === sourceId)
280
+ : this.subscriptionStore.match({ sourceId, event, payload });
281
+ if (chosenIds && subscriptionIds.length !== chosenIds.length) {
282
+ this.debug(`dropped ${chosenIds.length - subscriptionIds.length} unknown subscription id(s) on ${event}`);
283
+ }
284
+ if (subscriptionIds.length === 0)
285
+ return [];
286
+ this.send("hook.event", {
287
+ sourceId,
288
+ subscriptionIds,
289
+ event,
290
+ ...(payload ? { payload } : {}),
291
+ epoch: this.subscriptionStore.epoch,
292
+ });
293
+ return subscriptionIds;
294
+ }
295
+ /** Called by `Hook.subscriptions`. */
296
+ hookSubscriptions(hookId) {
297
+ const sourceId = this.hooks.get(hookId)?.sourceId;
298
+ return sourceId ? this.subscriptionStore.forSource(sourceId) : [];
299
+ }
300
+ /** Everything this link has been asked to watch, across all of its hooks. */
301
+ get subscriptions() {
302
+ return this.subscriptionStore.all();
303
+ }
243
304
  /** Called by `Hook.emit`. */
244
305
  async emitHook(hookId, event, payload, ownerId) {
245
306
  await this.ready();
@@ -349,6 +410,12 @@ class Link {
349
410
  this.debug(`call ${callId} cancelled: ${reason}`);
350
411
  return;
351
412
  }
413
+ case "hook.subscriptions":
414
+ this.subscriptionStore.applySnapshot(frame.payload);
415
+ return;
416
+ case "hook.subscriptions.delta":
417
+ this.subscriptionStore.applyDelta(frame.payload);
418
+ return;
352
419
  case "log":
353
420
  this.emitter.emit("log", frame.payload.log);
354
421
  return;
@@ -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;
@@ -132,6 +152,7 @@ export type LinkServerPayloads = {
132
152
  userId: string;
133
153
  chatId?: string;
134
154
  runId: string;
155
+ identities?: Record<string, string>;
135
156
  };
136
157
  timeoutMs: number;
137
158
  };
@@ -162,6 +183,25 @@ export type LinkServerPayloads = {
162
183
  reason: string;
163
184
  reconnectAfterMs: number;
164
185
  };
186
+ /**
187
+ * The full set this connection should watch, for the sources it has registered.
188
+ *
189
+ * `chunk` / `of` are 1-based and present only when the set was split; every chunk of one snapshot
190
+ * carries the same `epoch`.
191
+ */
192
+ "hook.subscriptions": {
193
+ epoch: number;
194
+ chunk?: number;
195
+ of?: number;
196
+ subscriptions: LinkSubscription[];
197
+ };
198
+ /** An incremental change. An epoch more than one ahead means a delta was missed. */
199
+ "hook.subscriptions.delta": {
200
+ epoch: number;
201
+ added?: LinkSubscription[];
202
+ removed?: string[];
203
+ updated?: LinkSubscription[];
204
+ };
165
205
  };
166
206
  export type LinkServerFrameType = keyof LinkServerPayloads;
167
207
  /** 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;
@@ -24,6 +24,17 @@ export type ToolCallMeta = {
24
24
  chatId?: string;
25
25
  /** Unique per call, useful for logs. */
26
26
  runId: string;
27
+ /**
28
+ * The user's linked accounts, in namespaces you understand: `{ discord: "1897..." }`.
29
+ *
30
+ * Present only for global-scope links — a platform client acting *as* the user, which is the case
31
+ * that needs it: to kick somebody as them you need their Discord id, and resolving that from an
32
+ * Alfred user id is the server's job. A user-scoped link is already the user's own process and is
33
+ * told nothing extra.
34
+ *
35
+ * Absent when the user has linked nothing. Refuse the call rather than guessing.
36
+ */
37
+ identities?: Record<string, string>;
27
38
  };
28
39
  export type ToolRunContext<S extends ToolSchema | undefined> = {
29
40
  /** Typed from `schema` when one was given. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -98,6 +98,64 @@ over and the older one's registrations are released. That is deliberate, so a ha
98
98
  socket cannot lock out a fresh one during a deploy, but it does mean two genuinely
99
99
  different clients must not share an id.
100
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. Ignoring prefilters entirely is still *correct*, just louder — the server evaluates
134
+ them again before spending anything.
135
+
136
+ For a condition that is not field equality — "mentions my user", "within 50 metres", "the third time
137
+ today" — decide with real code and use `reportTo`:
138
+
139
+ ```ts
140
+ const mine = doorbell.subscriptions.filter(
141
+ (s) => s.identities?.discord && message.mentions.users.has(s.identities.discord),
142
+ );
143
+
144
+ await doorbell.reportTo(mine.map((s) => s.subscriptionId), "rang", payload);
145
+ ```
146
+
147
+ `reportTo` does not apply the prefilter — you already decided. It does drop any id this link isn't
148
+ currently holding, so a subscription that disappeared between your decision and the call is a
149
+ dropped report rather than a rejected frame.
150
+
151
+ `subscription.identities` is how you answer "is this event about *my* user": a plain string map in
152
+ namespaces you understand, e.g. `{ discord: "1897..." }`, present only for owners who have linked
153
+ that account. Nothing else about the user is exposed.
154
+
155
+ Subscriptions are never persisted by the SDK. They arrive on connect, follow deltas while
156
+ connected, and are dropped on disconnect — so there is nothing to reconcile, and a restart is
157
+ correct by construction.
158
+
101
159
  ### Tools belong to the user, not to a conversation
102
160
 
103
161
  Once a tool is registered, Alfred can call it anywhere that user talks to it — the web