@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.
- package/dist/link/hook.d.ts +14 -1
- package/dist/link/hook.js +24 -0
- package/dist/link/link.d.ts +1 -1
- package/dist/link/link.js +11 -2
- package/dist/link/protocol.d.ts +2 -0
- package/dist/link/tool.d.ts +24 -0
- package/dist/link/tool.js +1 -0
- package/package.json +1 -1
- package/readme.md +37 -2
package/dist/link/hook.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/link/link.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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", {
|
package/dist/link/protocol.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/link/tool.d.ts
CHANGED
|
@@ -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
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
|
|
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
|