@butlerbot/sdk 0.0.21 → 0.0.23

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.
@@ -42,7 +42,7 @@ export type HookEmitter = {
42
42
  * Reports an event against the subscriptions it matched, sending nothing when it
43
43
  * matched none. Returns the ids that were reported.
44
44
  */
45
- reportHookEvent(hookId: string, event: string, payload?: Record<string, unknown>): Promise<string[]>;
45
+ reportHookEvent(hookId: string, event: string, payload?: Record<string, unknown>, subscriptionIds?: string[]): Promise<string[]>;
46
46
  /** The subscriptions the server has pushed for this hook's source. */
47
47
  hookSubscriptions(hookId: string): LinkSubscription[];
48
48
  };
@@ -99,4 +99,17 @@ export declare class Hook<S extends ToolSchema | undefined = undefined> {
99
99
  * disconnect — nothing is persisted, because the server re-sends it on every connect.
100
100
  */
101
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[]>;
102
115
  }
package/dist/link/hook.js CHANGED
@@ -92,5 +92,29 @@ class Hook {
92
92
  get subscriptions() {
93
93
  return this.link?.hookSubscriptions(this.id) ?? [];
94
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
+ }
95
119
  }
96
120
  exports.Hook = Hook;
@@ -142,7 +142,7 @@ export declare class Link {
142
142
  * The epoch travels with the frame so the server can tell a stale view from a bad one — an id
143
143
  * that was valid a moment ago is a race, not a bug worth complaining about.
144
144
  */
145
- reportHookEvent(hookId: string, event: string, payload?: Record<string, unknown>): Promise<string[]>;
145
+ reportHookEvent(hookId: string, event: string, payload?: Record<string, unknown>, chosenIds?: string[]): Promise<string[]>;
146
146
  /** Called by `Hook.subscriptions`. */
147
147
  hookSubscriptions(hookId: string): LinkSubscription[];
148
148
  /** Everything this link has been asked to watch, across all of its hooks. */
package/dist/link/link.js CHANGED
@@ -268,10 +268,19 @@ class Link {
268
268
  * The epoch travels with the frame so the server can tell a stale view from a bad one — an id
269
269
  * that was valid a moment ago is a race, not a bug worth complaining about.
270
270
  */
271
- async reportHookEvent(hookId, event, payload) {
271
+ async reportHookEvent(hookId, event, payload, chosenIds) {
272
272
  await this.ready();
273
273
  const sourceId = this.hooks.get(hookId)?.sourceId ?? hookId;
274
- const subscriptionIds = this.subscriptionStore.match({ sourceId, event, payload });
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
+ }
275
284
  if (subscriptionIds.length === 0)
276
285
  return [];
277
286
  this.send("hook.event", {
@@ -21,6 +21,7 @@ export type LinkToolDescriptor = {
21
21
  longDescription: string;
22
22
  };
23
23
  defaultEnabled?: boolean;
24
+ platforms?: string[];
24
25
  timeoutMs?: number;
25
26
  };
26
27
  export type LinkHookEventDeclaration = {
@@ -152,6 +153,7 @@ export type LinkServerPayloads = {
152
153
  userId: string;
153
154
  chatId?: string;
154
155
  runId: string;
156
+ identities?: Record<string, string>;
155
157
  };
156
158
  timeoutMs: number;
157
159
  };
@@ -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. */
@@ -53,6 +64,19 @@ export type ToolConfig<S extends ToolSchema | undefined> = {
53
64
  };
54
65
  /** Whether the tool is on before the user has touched it. */
55
66
  defaultEnabled?: boolean;
67
+ /**
68
+ * Where in Alfred the tool is reachable, as platform ids. Defaults to the user's chat.
69
+ *
70
+ * Say this when your client mirrors a whole platform. A Discord bot offering seventy tools does
71
+ * not want seventy entries in front of somebody having a conversation about their groceries — it
72
+ * wants them behind Alfred's Discord agent, which is one entry and already knows how to decide
73
+ * when Discord is relevant.
74
+ *
75
+ * Known values are Alfred's platform ids, e.g. `"platform.chat.user"` (the default) and
76
+ * `"platform.agent.discord"`. An unknown one is rejected at registration rather than ignored,
77
+ * because a tool reachable from nowhere is indistinguishable from a tool that is broken.
78
+ */
79
+ platforms?: string[];
56
80
  /** How long the server waits for a result before giving up. */
57
81
  timeoutMs?: number;
58
82
  /** Runs the tool. Return anything JSON-serialisable, or throw to fail the call. */
package/dist/link/tool.js CHANGED
@@ -27,6 +27,7 @@ class Tool {
27
27
  inputSchema,
28
28
  ...(this.config.display ? { display: this.config.display } : {}),
29
29
  ...(this.config.defaultEnabled !== undefined ? { defaultEnabled: this.config.defaultEnabled } : {}),
30
+ ...(this.config.platforms ? { platforms: this.config.platforms } : {}),
30
31
  ...(this.config.timeoutMs !== undefined ? { timeoutMs: this.config.timeoutMs } : {}),
31
32
  };
32
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -130,10 +130,24 @@ Why this is the better path:
130
130
 
131
131
  `subscription.prefilter` is applied for you by `report` — a dot-path map of conditions, all ANDed,
132
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
133
+ query language. Ignoring prefilters entirely is still *correct*, just louder the server evaluates
135
134
  them again before spending anything.
136
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
+
137
151
  `subscription.identities` is how you answer "is this event about *my* user": a plain string map in
138
152
  namespaces you understand, e.g. `{ discord: "1897..." }`, present only for owners who have linked
139
153
  that account. Nothing else about the user is exposed.
@@ -142,6 +156,27 @@ Subscriptions are never persisted by the SDK. They arrive on connect, follow del
142
156
  connected, and are dropped on disconnect — so there is nothing to reconcile, and a restart is
143
157
  correct by construction.
144
158
 
159
+ ### Tools can belong to an agent instead of a chat
160
+
161
+ By default a tool shows up where a user is talking to Alfred. If your client mirrors a whole
162
+ platform, say so instead:
163
+
164
+ ```ts
165
+ new Tool({
166
+ id: "discord_member_kick",
167
+ description: "Kick a member from a server.",
168
+ platforms: ["platform.agent.discord"],
169
+ run: async ({ args, meta }) => kick(meta.identities?.discord, args),
170
+ });
171
+ ```
172
+
173
+ Seventy tools in front of somebody asking about their groceries is not a feature. Behind Alfred's
174
+ Discord agent they are one entry that already knows when Discord is relevant — and the agent keeps
175
+ whatever tier and permission gating it carries, which tools bolted onto the chat would quietly skip.
176
+
177
+ An unknown platform is rejected when you register, not ignored: a tool reachable from nowhere looks
178
+ exactly like a tool that is broken.
179
+
145
180
  ### Tools belong to the user, not to a conversation
146
181
 
147
182
  Once a tool is registered, Alfred can call it anywhere that user talks to it — the web