@sigrun/ext-gateway 0.0.0-stage → 0.1.2

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +112 -0
  38. package/dist/interactions/chat.d.ts +28 -0
  39. package/dist/interactions/chat.js +199 -0
  40. package/dist/interactions/prompts.d.ts +68 -0
  41. package/dist/interactions/prompts.js +246 -0
  42. package/dist/interactions/receipts.d.ts +7 -0
  43. package/dist/interactions/receipts.js +69 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +64 -0
  52. package/dist/outbound/delivery.d.ts +131 -0
  53. package/dist/outbound/delivery.js +404 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +81 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +18 -0
  65. package/dist/session-meta.js +39 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +65 -0
  69. package/dist/state/identities.js +135 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +176 -0
  73. package/dist/state/ledger.js +256 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The per-channel supervisor (spec §8.12): a backoff state machine over
3
+ * ONE channel adapter. `start` resolves once the first attempt settled
4
+ * (running or backoff — a failing adapter never wedges boot); retries
5
+ * ride the injected timer.
6
+ *
7
+ * Pinned values (the plan's Global Constraints): the backoff delay
8
+ * starts at 1000 ms and doubles per consecutive failure, capped at
9
+ * 300000 ms, plus jitter from the injected `random()` — jitter only
10
+ * ever ADDS time (the delay stays within [base, min(2·base, cap)]), so
11
+ * a retry never fires early against the pinned floor. 600000 ms of
12
+ * running RESETS the delay (a healthy stretch earns a fresh 1000 ms).
13
+ *
14
+ * State machine: a `not_configured` adapter is never started (terminal
15
+ * state, the contract's rule). A rejected `start` and a later `ctx.fail`
16
+ * both enter `backoff` and schedule ONE retry — the failure path is
17
+ * event-driven, not a polling loop, so a healthy channel owns zero
18
+ * timers. `stop()` cancels the pending retry, waits for an in-flight
19
+ * attempt to settle, and awaits the adapter's optional `stop`.
20
+ */
21
+ import type { ChannelContext, ChannelContribution } from "@sigrun/sigrun/extension";
22
+ import type { EngineLog } from "./log.js";
23
+ /** The supervision state machine's states (spec §8.12 status values).
24
+ * `failed` is the engine-side CONFIG-ERROR state — a supervisor never
25
+ * enters it (see engine.ts's static rows). */
26
+ export type ChannelState = "stopped" | "not_configured" | "connecting" | "running" | "backoff" | "failed";
27
+ /** The status-report callback a started adapter drives (the contract's
28
+ * `ChannelContext.status` parameter — only the states a LIVE adapter
29
+ * can report; `backoff`/`failed`/`stopped` are supervisor-owned). */
30
+ export type StatusSink = (state: {
31
+ readonly state: "connecting" | "running";
32
+ readonly detail?: string;
33
+ }) => void;
34
+ /** One channel's status row (the admin status route's shape). */
35
+ export interface ChannelStatus {
36
+ readonly state: ChannelState;
37
+ readonly detail?: string;
38
+ /** Clock ms when the row entered its current state. */
39
+ readonly since: number;
40
+ readonly lastError?: string;
41
+ readonly counts: {
42
+ readonly admitted: number;
43
+ readonly dropped: number;
44
+ readonly delivered: number;
45
+ readonly failed: number;
46
+ };
47
+ }
48
+ /** The supervisor's injectable dependencies. */
49
+ export interface SupervisorDeps {
50
+ /**
51
+ * Build the ChannelContext the adapter's `start` receives, wiring the
52
+ * engine-owned members (`receive`/`action`/`log`) and `status` to
53
+ * `report`. The supervisor wraps the result's `fail` (failures route
54
+ * here); every other member passes through verbatim.
55
+ */
56
+ makeContext(signal: AbortSignal, report: StatusSink): ChannelContext;
57
+ /** Clock ms. */
58
+ now(): number;
59
+ /** [0, 1) — the backoff jitter source. */
60
+ random(): number;
61
+ /** Schedule `fn` at `ms`; the return value cancels it. */
62
+ setTimer(fn: () => void, ms: number): () => void;
63
+ readonly log: EngineLog;
64
+ }
65
+ /**
66
+ * One channel adapter's supervisor: constructed per channel at engine
67
+ * `start`, `start()` awaited by the engine, `stop()` awaited at engine
68
+ * stop. Not restartable after `stop()` (the engine stops once per boot).
69
+ */
70
+ export declare class Supervisor {
71
+ private readonly entry;
72
+ private readonly deps;
73
+ private state;
74
+ private stateSince;
75
+ private detail;
76
+ private lastError;
77
+ private readonly counts;
78
+ /** The delay head for the NEXT retry (doubling bookkeeping). */
79
+ private delayMs;
80
+ /** When the channel entered running (0 = not running now). */
81
+ private runningSince;
82
+ /**
83
+ * When the channel became unhealthy (the FIRST failure after a
84
+ * healthy stretch). Persists across retry cycles — a perpetually
85
+ * failing channel's health dwell must not reset per attempt — and
86
+ * clears only when a retry reaches running.
87
+ */
88
+ private unhealthySince;
89
+ private cancelTimer;
90
+ private stopped;
91
+ private signal;
92
+ /** The in-flight start attempt (stop awaits it before settling). */
93
+ private attempt;
94
+ constructor(entry: {
95
+ extension: string;
96
+ channel: ChannelContribution;
97
+ }, deps: SupervisorDeps);
98
+ /** The channel id (the status route's key). */
99
+ get id(): string;
100
+ /** The extension slug that contributed the channel (logging). */
101
+ get extension(): string;
102
+ /** The current status row (a copy — callers never mutate internals). */
103
+ status(): ChannelStatus;
104
+ /**
105
+ * Begin supervision: resolves once the first attempt settled (running
106
+ * or backoff). A `not_configured` adapter resolves immediately and is
107
+ * never started.
108
+ */
109
+ start(signal: AbortSignal): Promise<void>;
110
+ /** Stop supervision: cancel the retry, await the in-flight attempt and
111
+ * the adapter's optional `stop`. Idempotent. */
112
+ stop(): Promise<void>;
113
+ /** Count one admitted outcome (the engine's receive path reports). */
114
+ countAdmitted(): void;
115
+ /** Count one dropped outcome. */
116
+ countDropped(): void;
117
+ /** Count one delivered message (the outbound path, Task 5). */
118
+ countDelivered(): void;
119
+ /** Count one failed ledger entry (Task 5's worker reports). */
120
+ countFailed(): void;
121
+ /**
122
+ * Unhealthy dwell at `now`: time since the channel first became
123
+ * unhealthy WHILE it sits in `backoff` (the engine's §8.12 health
124
+ * input). 0 when healthy — a config-error channel never reaches here
125
+ * (it is an engine-side static row, never a supervisor).
126
+ */
127
+ unhealthyMs(now: number): number;
128
+ /** Mark the channel running (the engine's own transitions, tests). */
129
+ markRunning(detail?: string): void;
130
+ /**
131
+ * One start attempt: connecting → `channel.start(ctx)` → running, or
132
+ * backoff through {@link onFailure}. Never throws.
133
+ */
134
+ private runAttempt;
135
+ /**
136
+ * Build the adapter's context: the engine's `makeContext` result with
137
+ * `fail` intercepted (a post-connect failure routes into supervision,
138
+ * never into the engine's log-only base).
139
+ */
140
+ private buildContext;
141
+ /** The adapter's own connecting/running report. */
142
+ private onReport;
143
+ /**
144
+ * A start rejection or `ctx.fail`: enter backoff and schedule exactly
145
+ * ONE retry. Duplicates (a second `ctx.fail` while a retry is already
146
+ * pending, or an in-flight start's rejection after its `ctx.fail`)
147
+ * are ignored — the pending retry owns the recovery.
148
+ */
149
+ private onFailure;
150
+ /** Schedule the retry at the doubling delay plus jitter. */
151
+ private scheduleRetry;
152
+ private setState;
153
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The per-channel supervisor (spec §8.12): a backoff state machine over
3
+ * ONE channel adapter. `start` resolves once the first attempt settled
4
+ * (running or backoff — a failing adapter never wedges boot); retries
5
+ * ride the injected timer.
6
+ *
7
+ * Pinned values (the plan's Global Constraints): the backoff delay
8
+ * starts at 1000 ms and doubles per consecutive failure, capped at
9
+ * 300000 ms, plus jitter from the injected `random()` — jitter only
10
+ * ever ADDS time (the delay stays within [base, min(2·base, cap)]), so
11
+ * a retry never fires early against the pinned floor. 600000 ms of
12
+ * running RESETS the delay (a healthy stretch earns a fresh 1000 ms).
13
+ *
14
+ * State machine: a `not_configured` adapter is never started (terminal
15
+ * state, the contract's rule). A rejected `start` and a later `ctx.fail`
16
+ * both enter `backoff` and schedule ONE retry — the failure path is
17
+ * event-driven, not a polling loop, so a healthy channel owns zero
18
+ * timers. `stop()` cancels the pending retry, waits for an in-flight
19
+ * attempt to settle, and awaits the adapter's optional `stop`.
20
+ */
21
+ /** Initial backoff delay (ms) — the spec's "1 s doubling". */
22
+ const INITIAL_BACKOFF_MS = 1_000;
23
+ /** Backoff cap (ms) — the spec's "5 min". */
24
+ const MAX_BACKOFF_MS = 300_000;
25
+ /** Running time (ms) after which the backoff delay resets — the spec's
26
+ * "reset after 10 min running". */
27
+ const RUNNING_RESET_MS = 600_000;
28
+ /**
29
+ * One channel adapter's supervisor: constructed per channel at engine
30
+ * `start`, `start()` awaited by the engine, `stop()` awaited at engine
31
+ * stop. Not restartable after `stop()` (the engine stops once per boot).
32
+ */
33
+ export class Supervisor {
34
+ entry;
35
+ deps;
36
+ state = "stopped";
37
+ stateSince;
38
+ detail;
39
+ lastError;
40
+ counts = {
41
+ admitted: 0,
42
+ dropped: 0,
43
+ delivered: 0,
44
+ failed: 0,
45
+ };
46
+ /** The delay head for the NEXT retry (doubling bookkeeping). */
47
+ delayMs = INITIAL_BACKOFF_MS;
48
+ /** When the channel entered running (0 = not running now). */
49
+ runningSince = 0;
50
+ /**
51
+ * When the channel became unhealthy (the FIRST failure after a
52
+ * healthy stretch). Persists across retry cycles — a perpetually
53
+ * failing channel's health dwell must not reset per attempt — and
54
+ * clears only when a retry reaches running.
55
+ */
56
+ unhealthySince;
57
+ cancelTimer;
58
+ stopped = false;
59
+ signal;
60
+ /** The in-flight start attempt (stop awaits it before settling). */
61
+ attempt;
62
+ constructor(entry, deps) {
63
+ this.entry = entry;
64
+ this.deps = deps;
65
+ this.stateSince = deps.now();
66
+ }
67
+ /** The channel id (the status route's key). */
68
+ get id() {
69
+ return this.entry.channel.id;
70
+ }
71
+ /** The extension slug that contributed the channel (logging). */
72
+ get extension() {
73
+ return this.entry.extension;
74
+ }
75
+ /** The current status row (a copy — callers never mutate internals). */
76
+ status() {
77
+ return {
78
+ state: this.state,
79
+ ...(this.detail === undefined ? {} : { detail: this.detail }),
80
+ since: this.stateSince,
81
+ ...(this.lastError === undefined ? {} : { lastError: this.lastError }),
82
+ counts: { ...this.counts },
83
+ };
84
+ }
85
+ /**
86
+ * Begin supervision: resolves once the first attempt settled (running
87
+ * or backoff). A `not_configured` adapter resolves immediately and is
88
+ * never started.
89
+ */
90
+ async start(signal) {
91
+ this.signal = signal;
92
+ const readiness = await this.entry.channel.configured();
93
+ if (readiness.status !== "ready") {
94
+ // not_configured (the readiness union's only other member): the
95
+ // adapter is never started (the contract's rule) and the detail
96
+ // rides the status row.
97
+ this.setState("not_configured", readiness.detail);
98
+ return;
99
+ }
100
+ await this.runAttempt();
101
+ }
102
+ /** Stop supervision: cancel the retry, await the in-flight attempt and
103
+ * the adapter's optional `stop`. Idempotent. */
104
+ async stop() {
105
+ if (this.stopped)
106
+ return;
107
+ this.stopped = true;
108
+ this.cancelTimer?.();
109
+ this.cancelTimer = undefined;
110
+ const attempt = this.attempt;
111
+ if (attempt !== undefined)
112
+ await attempt.catch(() => { });
113
+ this.runningSince = 0;
114
+ if (this.entry.channel.stop !== undefined) {
115
+ try {
116
+ await this.entry.channel.stop();
117
+ }
118
+ catch (error) {
119
+ this.deps.log.error(`channel "${this.id}" stop failed`, error);
120
+ }
121
+ }
122
+ this.setState("stopped");
123
+ }
124
+ /** Count one admitted outcome (the engine's receive path reports). */
125
+ countAdmitted() {
126
+ this.counts.admitted++;
127
+ }
128
+ /** Count one dropped outcome. */
129
+ countDropped() {
130
+ this.counts.dropped++;
131
+ }
132
+ /** Count one delivered message (the outbound path, Task 5). */
133
+ countDelivered() {
134
+ this.counts.delivered++;
135
+ }
136
+ /** Count one failed ledger entry (Task 5's worker reports). */
137
+ countFailed() {
138
+ this.counts.failed++;
139
+ }
140
+ /**
141
+ * Unhealthy dwell at `now`: time since the channel first became
142
+ * unhealthy WHILE it sits in `backoff` (the engine's §8.12 health
143
+ * input). 0 when healthy — a config-error channel never reaches here
144
+ * (it is an engine-side static row, never a supervisor).
145
+ */
146
+ unhealthyMs(now) {
147
+ if (this.state !== "backoff")
148
+ return 0;
149
+ return now - (this.unhealthySince ?? this.stateSince);
150
+ }
151
+ /** Mark the channel running (the engine's own transitions, tests). */
152
+ markRunning(detail) {
153
+ if (this.runningSince === 0)
154
+ this.runningSince = this.deps.now();
155
+ this.unhealthySince = undefined;
156
+ this.setState("running", detail);
157
+ }
158
+ /**
159
+ * One start attempt: connecting → `channel.start(ctx)` → running, or
160
+ * backoff through {@link onFailure}. Never throws.
161
+ */
162
+ async runAttempt() {
163
+ const signal = this.signal;
164
+ if (signal === undefined || this.stopped)
165
+ return;
166
+ this.setState("connecting");
167
+ const ctx = this.buildContext(signal);
168
+ const settled = (async () => {
169
+ try {
170
+ await this.entry.channel.start(ctx);
171
+ }
172
+ catch (error) {
173
+ this.onFailure(error);
174
+ return;
175
+ }
176
+ if (this.stopped)
177
+ return;
178
+ // A `ctx.fail` that fired while a hanging start later resolved:
179
+ // the scheduled retry stands — never overwrite backoff with
180
+ // running out from under it.
181
+ if (this.state === "connecting")
182
+ this.markRunning();
183
+ })();
184
+ this.attempt = settled;
185
+ try {
186
+ await settled;
187
+ }
188
+ catch (error) {
189
+ // The inner function catches adapter failures; a throw past it
190
+ // is a supervision bug — logged, never propagated (the loop
191
+ // must keep serving the other channels).
192
+ this.deps.log.error(`channel "${this.id}" supervision error`, error);
193
+ }
194
+ finally {
195
+ this.attempt = undefined;
196
+ }
197
+ }
198
+ /**
199
+ * Build the adapter's context: the engine's `makeContext` result with
200
+ * `fail` intercepted (a post-connect failure routes into supervision,
201
+ * never into the engine's log-only base).
202
+ */
203
+ buildContext(signal) {
204
+ const report = (state) => this.onReport(state);
205
+ const base = this.deps.makeContext(signal, report);
206
+ return {
207
+ ...base,
208
+ fail: (error) => this.onFailure(error),
209
+ };
210
+ }
211
+ /** The adapter's own connecting/running report. */
212
+ onReport(state) {
213
+ if (this.stopped)
214
+ return;
215
+ if (state.state === "running")
216
+ this.markRunning(state.detail);
217
+ else
218
+ this.setState("connecting", state.detail);
219
+ }
220
+ /**
221
+ * A start rejection or `ctx.fail`: enter backoff and schedule exactly
222
+ * ONE retry. Duplicates (a second `ctx.fail` while a retry is already
223
+ * pending, or an in-flight start's rejection after its `ctx.fail`)
224
+ * are ignored — the pending retry owns the recovery.
225
+ */
226
+ onFailure(error) {
227
+ if (this.stopped)
228
+ return;
229
+ if (this.state === "backoff")
230
+ return;
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ this.lastError = message;
233
+ this.deps.log.error(`channel "${this.id}" failed`, error);
234
+ // The running reset: a channel that held running for 600000 ms
235
+ // earns a fresh 1000 ms; anything shorter keeps the doubled delay.
236
+ if (this.runningSince !== 0) {
237
+ if (this.deps.now() - this.runningSince >= RUNNING_RESET_MS) {
238
+ this.delayMs = INITIAL_BACKOFF_MS;
239
+ }
240
+ this.runningSince = 0;
241
+ }
242
+ if (this.unhealthySince === undefined) {
243
+ this.unhealthySince = this.deps.now();
244
+ }
245
+ this.setState("backoff");
246
+ this.scheduleRetry();
247
+ // The doubling arms the NEXT failure (the retry this failure just
248
+ // scheduled rides the current, undoubled delay).
249
+ this.delayMs = Math.min(this.delayMs * 2, MAX_BACKOFF_MS);
250
+ }
251
+ /** Schedule the retry at the doubling delay plus jitter. */
252
+ scheduleRetry() {
253
+ const signal = this.signal;
254
+ if (signal === undefined)
255
+ return;
256
+ const base = Math.min(this.delayMs, MAX_BACKOFF_MS);
257
+ // Jitter ADDS up to the next doubling step: the delay stays in
258
+ // [base, min(2·base, cap)] — never below the pinned floor, never
259
+ // past the cap.
260
+ const ceiling = Math.min(2 * base, MAX_BACKOFF_MS);
261
+ const delay = base + this.deps.random() * (ceiling - base);
262
+ this.cancelTimer?.();
263
+ this.cancelTimer = this.deps.setTimer(() => {
264
+ this.cancelTimer = undefined;
265
+ void this.runAttempt();
266
+ }, delay);
267
+ }
268
+ setState(state, detail) {
269
+ this.state = state;
270
+ this.stateSince = this.deps.now();
271
+ this.detail = detail;
272
+ }
273
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The chat-facing agent tools (spec §8.9). Policy failures are ordinary
3
+ * error results, never exceptions. Sends persist to the delivery ledger
4
+ * before waiting for a receipt, so a timeout leaves honest queued work.
5
+ */
6
+ import { type ToolRegistration } from "@earendil-works/pi-durable";
7
+ import type { ChannelContribution, SigrunHost } from "@sigrun/sigrun/extension";
8
+ import type { BindingStore } from "./state/bindings.js";
9
+ import type { IdentityStore } from "./state/identities.js";
10
+ import type { Ledger } from "./state/ledger.js";
11
+ import type { GatewayConfig } from "./config.js";
12
+ import type { EngineLog } from "./log.js";
13
+ export declare const RECEIPT_WAIT_MS = 30000;
14
+ export interface ToolDeps {
15
+ readonly host: SigrunHost;
16
+ readonly bindings: BindingStore;
17
+ readonly identities: IdentityStore;
18
+ readonly ledger: Ledger;
19
+ channelOf(channel: string): ChannelContribution | undefined;
20
+ readConfig(): Promise<GatewayConfig>;
21
+ readonly log: EngineLog;
22
+ /** Poke the channel's worker after enqueueing. */
23
+ readonly kick?: () => void;
24
+ readonly receiptWaitMs?: number;
25
+ }
26
+ /** Build both tools over the engine's shared state. */
27
+ export declare function createGatewayTools(deps: ToolDeps): readonly ToolRegistration[];
package/dist/tools.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The chat-facing agent tools (spec §8.9). Policy failures are ordinary
3
+ * error results, never exceptions. Sends persist to the delivery ledger
4
+ * before waiting for a receipt, so a timeout leaves honest queued work.
5
+ */
6
+ import { Type } from "@earendil-works/pi-ai";
7
+ import { defineTool, } from "@earendil-works/pi-durable";
8
+ import { resolveTargets } from "./jobs.js";
9
+ export const RECEIPT_WAIT_MS = 30_000;
10
+ const RECEIPT_POLL_MS = 25;
11
+ /** Build both tools over the engine's shared state. */
12
+ export function createGatewayTools(deps) {
13
+ const sendMessage = defineTool({
14
+ name: "send_message",
15
+ description: "Send a message to one of the acting user's linked chats. `to` is a channel or channel:chatId; files are workspace paths.",
16
+ parameters: Type.Object({
17
+ to: Type.String({ description: "Target channel or channel:chatId" }),
18
+ text: Type.String({ description: "Message text" }),
19
+ files: Type.Optional(Type.Array(Type.String(), {
20
+ description: "Workspace paths of files to attach",
21
+ })),
22
+ }),
23
+ async execute(args, api, context) {
24
+ try {
25
+ const owner = await deps.host.identity.actingUser(api.conversationId);
26
+ if (owner === undefined)
27
+ return toolError("The acting user could not be resolved.");
28
+ if (deps.identities.homesOf(owner).length === 0) {
29
+ return toolError(`The acting user ${owner} has no linked chats.`);
30
+ }
31
+ const targets = await resolveTargets(deps, owner, [args.to]);
32
+ const target = targets.resolved[0];
33
+ if (target === undefined) {
34
+ return toolError(targets.rejected[0]?.reason ?? "The target chat is not available.");
35
+ }
36
+ const channel = deps.channelOf(target.channel);
37
+ if (channel === undefined) {
38
+ return toolError(`Channel "${target.channel}" is not running.`);
39
+ }
40
+ const config = await deps.readConfig();
41
+ const files = [];
42
+ for (const path of args.files ?? []) {
43
+ const read = await deps.host.conversations.readFile(api.conversationId, path);
44
+ if (read.bytes.byteLength > config.media.maxBytes) {
45
+ return toolError(`File "${path}" is too large (limit ${config.media.maxBytes} bytes).`);
46
+ }
47
+ const kind = mediaKind(read.mime, path);
48
+ const supported = channel.capabilities.media ?? [];
49
+ if (!supported.includes(kind) && !supported.includes("document")) {
50
+ return toolError(`Channel "${channel.label}" does not support ${kind} files or document fallback.`);
51
+ }
52
+ files.push({
53
+ conversationId: api.conversationId,
54
+ path,
55
+ name: fileName(path),
56
+ ...(read.mime === undefined ? {} : { mime: read.mime }),
57
+ bytes: read.bytes,
58
+ });
59
+ }
60
+ const entry = await deps.ledger.enqueue({
61
+ channel: target.channel,
62
+ chat: target.chat,
63
+ kind: "notice",
64
+ tag: `tool:${api.callId}`,
65
+ message: {
66
+ chat: target.chat,
67
+ text: args.text,
68
+ ...(files.length === 0
69
+ ? {}
70
+ : {
71
+ files: files.map((file) => ({
72
+ name: file.name,
73
+ ...(file.mime === undefined ? {} : { mime: file.mime }),
74
+ bytes: file.bytes,
75
+ })),
76
+ }),
77
+ },
78
+ ...(files.length === 0 ? {} : { files }),
79
+ });
80
+ if (entry === undefined)
81
+ return toolError("The message could not be queued.");
82
+ deps.kick?.();
83
+ const receipt = await waitForReceipt(deps.ledger, entry, deps.receiptWaitMs ?? RECEIPT_WAIT_MS, context.abortSignal ?? new AbortController().signal);
84
+ if (receipt === "failed") {
85
+ return toolError("Delivery failed; the message was not sent.");
86
+ }
87
+ if (receipt === undefined) {
88
+ return toolSuccess({ delivered: false, queued: true });
89
+ }
90
+ return toolSuccess({ delivered: true, messageIds: receipt });
91
+ }
92
+ catch (error) {
93
+ // Transport and host errors are also model-recoverable tool
94
+ // outcomes; logging retains the diagnostic policy errors lack.
95
+ deps.log.error("send_message failed", error);
96
+ return toolError(error instanceof Error ? error.message : String(error));
97
+ }
98
+ },
99
+ });
100
+ const listChats = defineTool({
101
+ name: "list_chats",
102
+ description: "List chats the acting user may target with send_message.",
103
+ parameters: Type.Object({}),
104
+ async execute(_args, api) {
105
+ try {
106
+ const owner = await deps.host.identity.actingUser(api.conversationId);
107
+ if (owner === undefined)
108
+ return toolError("The acting user could not be resolved.");
109
+ const chats = [];
110
+ const seen = new Set();
111
+ for (const home of deps.identities.homesOf(owner)) {
112
+ const channel = deps.channelOf(home.channel);
113
+ if (channel === undefined)
114
+ continue;
115
+ seen.add(`${home.channel}:${home.chat.chatId}`);
116
+ chats.push({
117
+ to: home.channel,
118
+ label: `${channel.label} direct message ${home.chat.chatId}`,
119
+ });
120
+ }
121
+ for (const [key, binding] of Object.entries(deps.bindings.entries())) {
122
+ if (binding.dead === true)
123
+ continue;
124
+ const channelId = key.split(":", 1)[0];
125
+ if (channelId === undefined)
126
+ continue;
127
+ const channel = deps.channelOf(channelId);
128
+ if (channel === undefined)
129
+ continue;
130
+ const access = await deps.host.access.accessOf({ user: owner }, await deps.host.conversations.meta(binding.conversationId));
131
+ if (access !== "write")
132
+ continue;
133
+ const target = `${channelId}:${binding.chat.chatId}`;
134
+ if (seen.has(target))
135
+ continue;
136
+ seen.add(target);
137
+ chats.push({
138
+ to: target,
139
+ label: `${channel.label} ${binding.chat.chatType === "dm" ? "direct message" : "group"} ${binding.chat.chatId}`,
140
+ });
141
+ }
142
+ if (chats.length === 0) {
143
+ return toolError(`The acting user ${owner} has no linked chats.`);
144
+ }
145
+ return toolSuccess({ chats });
146
+ }
147
+ catch (error) {
148
+ deps.log.error("list_chats failed", error);
149
+ return toolError(error instanceof Error ? error.message : String(error));
150
+ }
151
+ },
152
+ });
153
+ return [sendMessage, listChats];
154
+ }
155
+ function toolError(message) {
156
+ return { output: [{ type: "text", text: message }], isError: true };
157
+ }
158
+ function toolSuccess(value) {
159
+ return { output: [{ type: "text", text: JSON.stringify(value) }] };
160
+ }
161
+ /** Poll the durable row so a same-process worker receipt resolves quickly. */
162
+ async function waitForReceipt(ledger, entry, waitMs, signal) {
163
+ const deadline = Date.now() + waitMs;
164
+ while (!signal.aborted) {
165
+ const current = ledger.list().find((candidate) => candidate.id === entry.id);
166
+ if (current?.state === "delivered")
167
+ return current.receipt ?? [];
168
+ if (current?.state === "failed")
169
+ return "failed";
170
+ if (Date.now() >= deadline)
171
+ return undefined;
172
+ await new Promise((resolve) => {
173
+ const timer = setTimeout(() => {
174
+ signal.removeEventListener("abort", abort);
175
+ resolve();
176
+ }, Math.min(RECEIPT_POLL_MS, Math.max(0, deadline - Date.now())));
177
+ const abort = () => {
178
+ clearTimeout(timer);
179
+ signal.removeEventListener("abort", abort);
180
+ resolve();
181
+ };
182
+ signal.addEventListener("abort", abort, { once: true });
183
+ });
184
+ }
185
+ return undefined;
186
+ }
187
+ function fileName(path) {
188
+ return path.split(/[\\/]/).pop() ?? "attachment";
189
+ }
190
+ function mediaKind(mime, path) {
191
+ if (mime?.startsWith("image/") === true)
192
+ return "image";
193
+ if (mime?.startsWith("audio/") === true)
194
+ return "audio";
195
+ if (mime?.startsWith("video/") === true)
196
+ return "video";
197
+ if (mime !== undefined)
198
+ return "document";
199
+ const extension = path.split(".").pop()?.toLowerCase();
200
+ if (extension !== undefined && ["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(extension))
201
+ return "image";
202
+ if (extension !== undefined && ["ogg", "mp3", "wav", "m4a"].includes(extension))
203
+ return "audio";
204
+ if (extension !== undefined && ["mp4", "webm", "mov"].includes(extension))
205
+ return "video";
206
+ return "document";
207
+ }