@butlerbot/sdk 0.0.20 → 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.
- package/dist/link/hook.d.ts +32 -0
- package/dist/link/hook.js +36 -0
- package/dist/link/index.d.ts +4 -0
- package/dist/link/index.js +6 -1
- package/dist/link/link.d.ts +24 -0
- package/dist/link/link.js +58 -0
- package/dist/link/prefilter.d.ts +25 -0
- package/dist/link/prefilter.js +50 -0
- package/dist/link/protocol.d.ts +39 -0
- package/dist/link/subscriptions.d.ts +95 -0
- package/dist/link/subscriptions.js +112 -0
- package/package.json +1 -1
- package/readme.md +44 -0
package/dist/link/hook.d.ts
CHANGED
|
@@ -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;
|
package/dist/link/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/link/index.js
CHANGED
|
@@ -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; } });
|
package/dist/link/link.d.ts
CHANGED
|
@@ -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;
|
|
@@ -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
|
+
}
|
package/dist/link/protocol.d.ts
CHANGED
|
@@ -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;
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -98,6 +98,50 @@ 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: 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
|
+
|
|
101
145
|
### Tools belong to the user, not to a conversation
|
|
102
146
|
|
|
103
147
|
Once a tool is registered, Alfred can call it anywhere that user talks to it — the web
|