@sigrun/ext-gateway 0.0.0-stage → 0.1.1

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 +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -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 +111 -0
  38. package/dist/interactions/chat.d.ts +20 -0
  39. package/dist/interactions/chat.js +155 -0
  40. package/dist/interactions/prompts.d.ts +42 -0
  41. package/dist/interactions/prompts.js +96 -0
  42. package/dist/interactions/receipts.d.ts +6 -0
  43. package/dist/interactions/receipts.js +36 -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 +57 -0
  52. package/dist/outbound/delivery.d.ts +124 -0
  53. package/dist/outbound/delivery.js +387 -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 +80 -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 +8 -0
  65. package/dist/session-meta.js +21 -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 +61 -0
  69. package/dist/state/identities.js +131 -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 +152 -0
  73. package/dist/state/ledger.js +207 -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,124 @@
1
+ /**
2
+ * The per-channel delivery worker (spec §8.6): ONE in-flight send per
3
+ * channel, entries pulled from the ledger in enqueue order.
4
+ *
5
+ * Retry ladder (pinned): a `transient` failure retries at 1000, 5000
6
+ * and 30000 ms, then the entry is `failed`. A `permanent` failure
7
+ * fails at once. A `chat_gone` failure marks the binding dead
8
+ * (future sends to that chat skip; the chat's next inbound message
9
+ * clears it) and drops the entry. A send that exceeds the 60 s bound
10
+ * (spec's Review Focus 2 — an adapter call that never resolves)
11
+ * classifies TRANSIENT: the worker abandons the call, retries, and
12
+ * never wedges the channel's queue.
13
+ *
14
+ * Recovered entries (restart recovery flagged them) are resent with the
15
+ * leading "(resent after restart)" line — the platform may already
16
+ * hold the message, and the marker tells the human what happened.
17
+ *
18
+ * `drain(deadlineMs)` is the shutdown path: send until the ledger is
19
+ * empty or the deadline passes; retries are NOT waited out (a retry
20
+ * timer armed by drain settles after stop() — the entry stays pending
21
+ * and the next boot's recovery resends it).
22
+ */
23
+ import type { ChannelContribution, OutboundMessage } from "@sigrun/sigrun/extension";
24
+ import type { Ledger, LedgerEntry } from "../state/ledger.js";
25
+ import type { BindingStore } from "../state/bindings.js";
26
+ import { type EngineLog } from "../log.js";
27
+ /** The worker's injectable dependencies. */
28
+ export interface DeliveryDeps {
29
+ readonly bindings: BindingStore;
30
+ /** The send bound (a hung adapter call never wedges the queue). */
31
+ readonly timeoutMs: number;
32
+ /** The pinned retry ladder: delays[attempt] before retry N. */
33
+ readonly delays: readonly [number, number, number];
34
+ /** Schedule `fn` at `ms`; the return value cancels it. */
35
+ readonly setTimer: (fn: () => void, ms: number) => () => void;
36
+ readonly log: EngineLog;
37
+ /** Clock (the tests' virtual clock). Default: real. */
38
+ readonly now?: () => number;
39
+ /** Re-read file bytes at send time; ledger entries persist paths only. */
40
+ readonly resolveFiles?: (entry: LedgerEntry) => Promise<OutboundMessage["files"]>;
41
+ /** Terminal delivery outcomes for the channel's status counters. */
42
+ readonly counts?: {
43
+ delivered(): void;
44
+ failed(): void;
45
+ };
46
+ /** Persist a delivered interaction prompt's platform refs for edits/replies. */
47
+ readonly onInteractionReceipt?: (entry: LedgerEntry, receipt: {
48
+ readonly messageIds: readonly string[];
49
+ }) => void | Promise<void>;
50
+ }
51
+ /** The pinned resend marker for recovered entries. */
52
+ export declare const RESENT_MARKER = "(resent after restart)";
53
+ /**
54
+ * One channel's delivery worker. `kick()` starts the loop if idle (an
55
+ * enqueue calls it); `drain(deadlineMs)` runs sends until empty or the
56
+ * deadline; `stop()` cancels timers and joins the in-flight send. Not
57
+ * restartable after `stop()` (the engine stops once per boot).
58
+ */
59
+ export declare class DeliveryWorker {
60
+ private readonly channel;
61
+ private readonly ledger;
62
+ private readonly deps;
63
+ private readonly now;
64
+ private busy;
65
+ private stopped;
66
+ /** The in-flight send's settle handle (stop joins it). */
67
+ private inFlight;
68
+ /** The cancel for the armed retry timer. */
69
+ private cancelRetry;
70
+ constructor(channel: ChannelContribution, ledger: Ledger, deps: DeliveryDeps);
71
+ /** Start the loop if idle (the enqueue-side poke). A no-op while a
72
+ * drain owns the queue (the drain IS the loop until it resolves). */
73
+ kick(): void;
74
+ /**
75
+ * Send until the ledger's channel queue is empty or `deadlineMs`
76
+ * (absolute clock ms) passes. The drain TAKES OWNERSHIP of the
77
+ * queue: `busy` is set for its duration (a kick during a drain is a
78
+ * no-op), the armed retry timer is CANCELLED (the drain pulls the
79
+ * re-pended entry directly — no timer-driven second loop races
80
+ * it), and any in-flight loop is joined first. Retries are NOT
81
+ * waited out: a retry-armed entry parks the drain (the entry stays
82
+ * pending; the next boot's recovery resends it).
83
+ */
84
+ drain(deadlineMs: number): Promise<void>;
85
+ /** Cancel the retry timer and join the in-flight send. Idempotent. */
86
+ stop(): Promise<void>;
87
+ /**
88
+ * The steady-state loop: pull, send, repeat; a retry-armed state
89
+ * parks until the timer fires (kick re-arms after each timer hop).
90
+ */
91
+ private runLoop;
92
+ /**
93
+ * Send one entry: attempting → send → delivered (receipt) / the
94
+ * error-classified failure path. Returns false when the entry needs
95
+ * a retry wait (the loop parks until the retry timer re-kicks) —
96
+ * enqueue ORDER holds: no later entry overtakes a retrying one.
97
+ * Never throws.
98
+ */
99
+ private sendOne;
100
+ /**
101
+ * The platform call, abandoned at `timeoutMs`: a send that exceeds
102
+ * the bound classifies TRANSIENT (the worker retries, never wedges).
103
+ */
104
+ private boundedSend;
105
+ /** Apply an edit entry; its ledger row carries the target MessageRef. */
106
+ private boundedEdit;
107
+ /**
108
+ * The classified failure path: transient retries (the ladder),
109
+ * permanent fails at once, chat_gone marks the binding dead.
110
+ * Returns false when a retry wait was armed (the caller parks).
111
+ */
112
+ private onSendError;
113
+ /** A ledger mark with its own error isolation (never throws). */
114
+ private markLedger;
115
+ /**
116
+ * The classification read (adapter's `classifyError` when present,
117
+ * transient otherwise) with the timeout error forced transient.
118
+ */
119
+ private classify;
120
+ /** The send-shaped message for one entry (the recovered marker rule). */
121
+ private buildMessage;
122
+ }
123
+ /** The timeout error's name (matched by `classify`, never instanceof). */
124
+ export declare const SEND_TIMEOUT_NAME = "GatewaySendTimeout";
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The per-channel delivery worker (spec §8.6): ONE in-flight send per
3
+ * channel, entries pulled from the ledger in enqueue order.
4
+ *
5
+ * Retry ladder (pinned): a `transient` failure retries at 1000, 5000
6
+ * and 30000 ms, then the entry is `failed`. A `permanent` failure
7
+ * fails at once. A `chat_gone` failure marks the binding dead
8
+ * (future sends to that chat skip; the chat's next inbound message
9
+ * clears it) and drops the entry. A send that exceeds the 60 s bound
10
+ * (spec's Review Focus 2 — an adapter call that never resolves)
11
+ * classifies TRANSIENT: the worker abandons the call, retries, and
12
+ * never wedges the channel's queue.
13
+ *
14
+ * Recovered entries (restart recovery flagged them) are resent with the
15
+ * leading "(resent after restart)" line — the platform may already
16
+ * hold the message, and the marker tells the human what happened.
17
+ *
18
+ * `drain(deadlineMs)` is the shutdown path: send until the ledger is
19
+ * empty or the deadline passes; retries are NOT waited out (a retry
20
+ * timer armed by drain settles after stop() — the entry stays pending
21
+ * and the next boot's recovery resends it).
22
+ */
23
+ import { hashId } from "../log.js";
24
+ /** The pinned resend marker for recovered entries. */
25
+ export const RESENT_MARKER = "(resent after restart)";
26
+ /**
27
+ * One channel's delivery worker. `kick()` starts the loop if idle (an
28
+ * enqueue calls it); `drain(deadlineMs)` runs sends until empty or the
29
+ * deadline; `stop()` cancels timers and joins the in-flight send. Not
30
+ * restartable after `stop()` (the engine stops once per boot).
31
+ */
32
+ export class DeliveryWorker {
33
+ channel;
34
+ ledger;
35
+ deps;
36
+ now;
37
+ busy = false;
38
+ stopped = false;
39
+ /** The in-flight send's settle handle (stop joins it). */
40
+ inFlight;
41
+ /** The cancel for the armed retry timer. */
42
+ cancelRetry;
43
+ constructor(channel, ledger, deps) {
44
+ this.channel = channel;
45
+ this.ledger = ledger;
46
+ this.deps = deps;
47
+ this.now = deps.now ?? Date.now;
48
+ }
49
+ /** Start the loop if idle (the enqueue-side poke). A no-op while a
50
+ * drain owns the queue (the drain IS the loop until it resolves). */
51
+ kick() {
52
+ if (this.stopped || this.busy)
53
+ return;
54
+ const loop = this.runLoop();
55
+ this.inFlight = loop.catch((error) => {
56
+ // A loop failure is engine-side (the ledger or the adapter
57
+ // wrapper): logged loud, never silent — the channel's queue
58
+ // stays honest (the next kick retries).
59
+ this.deps.log.error(`delivery: loop failed (channel "${this.channel.id}")`, error);
60
+ });
61
+ void loop;
62
+ }
63
+ /**
64
+ * Send until the ledger's channel queue is empty or `deadlineMs`
65
+ * (absolute clock ms) passes. The drain TAKES OWNERSHIP of the
66
+ * queue: `busy` is set for its duration (a kick during a drain is a
67
+ * no-op), the armed retry timer is CANCELLED (the drain pulls the
68
+ * re-pended entry directly — no timer-driven second loop races
69
+ * it), and any in-flight loop is joined first. Retries are NOT
70
+ * waited out: a retry-armed entry parks the drain (the entry stays
71
+ * pending; the next boot's recovery resends it).
72
+ */
73
+ async drain(deadlineMs) {
74
+ if (this.stopped)
75
+ return;
76
+ // Cancel the pending retry: the drain owns the queue now.
77
+ this.cancelRetry?.();
78
+ this.cancelRetry = undefined;
79
+ // Join any in-flight loop before taking the busy flag.
80
+ await this.inFlight;
81
+ if (this.stopped)
82
+ return;
83
+ if (this.busy) {
84
+ // A concurrent drain (the engine's stop is idempotent, but a
85
+ // caller may race): join it rather than run a second loop.
86
+ await this.inFlight;
87
+ return;
88
+ }
89
+ this.busy = true;
90
+ try {
91
+ for (;;) {
92
+ if (this.stopped)
93
+ return;
94
+ if (this.now() >= deadlineMs)
95
+ return;
96
+ const entry = this.ledger.nextPending(this.channel.id);
97
+ if (entry === undefined)
98
+ return;
99
+ const progressed = await this.sendOne(entry);
100
+ if (!progressed)
101
+ return;
102
+ }
103
+ }
104
+ finally {
105
+ this.busy = false;
106
+ }
107
+ }
108
+ /** Cancel the retry timer and join the in-flight send. Idempotent. */
109
+ async stop() {
110
+ if (this.stopped)
111
+ return;
112
+ this.stopped = true;
113
+ this.cancelRetry?.();
114
+ this.cancelRetry = undefined;
115
+ const flight = this.inFlight;
116
+ if (flight !== undefined)
117
+ await flight.catch(() => { });
118
+ }
119
+ /**
120
+ * The steady-state loop: pull, send, repeat; a retry-armed state
121
+ * parks until the timer fires (kick re-arms after each timer hop).
122
+ */
123
+ async runLoop() {
124
+ if (this.busy)
125
+ return;
126
+ this.busy = true;
127
+ try {
128
+ for (;;) {
129
+ if (this.stopped)
130
+ return;
131
+ const entry = this.ledger.nextPending(this.channel.id);
132
+ if (entry === undefined)
133
+ return;
134
+ const progressed = await this.sendOne(entry);
135
+ if (!progressed) {
136
+ // A retry is armed (or the entry is terminal-failed): the loop
137
+ // parks — the retry timer's kick() restarts it. Enqueue order
138
+ // holds (no later entry overtakes a retrying one).
139
+ return;
140
+ }
141
+ }
142
+ }
143
+ finally {
144
+ this.busy = false;
145
+ }
146
+ }
147
+ /**
148
+ * Send one entry: attempting → send → delivered (receipt) / the
149
+ * error-classified failure path. Returns false when the entry needs
150
+ * a retry wait (the loop parks until the retry timer re-kicks) —
151
+ * enqueue ORDER holds: no later entry overtakes a retrying one.
152
+ * Never throws.
153
+ */
154
+ async sendOne(entry) {
155
+ try {
156
+ // Resolution belongs inside the classified boundary: a deleted
157
+ // or unreadable file must fail this entry, never wedge the queue.
158
+ const message = await this.buildMessage(entry);
159
+ // The attempting mark rides the same isolation as the rest: a
160
+ // failing ledger write is LOGGED and the send does not run (an
161
+ // unmarked entry must not be sent — the crash window would
162
+ // duplicate it on recovery).
163
+ await this.ledger.mark(entry.id, "attempting");
164
+ const receipt = entry.kind === "edit"
165
+ ? await this.boundedEdit(entry, message)
166
+ : await this.boundedSend(message);
167
+ await this.ledger.mark(entry.id, "delivered", receipt.messageIds);
168
+ if (entry.kind === "interaction") {
169
+ await this.deps.onInteractionReceipt?.(entry, receipt);
170
+ }
171
+ this.deps.counts?.delivered();
172
+ return true;
173
+ }
174
+ catch (error) {
175
+ return await this.onSendError(entry, error);
176
+ }
177
+ }
178
+ /**
179
+ * The platform call, abandoned at `timeoutMs`: a send that exceeds
180
+ * the bound classifies TRANSIENT (the worker retries, never wedges).
181
+ */
182
+ async boundedSend(message) {
183
+ return await new Promise((resolve, reject) => {
184
+ let settled = false;
185
+ const timer = this.deps.setTimer(() => {
186
+ if (settled)
187
+ return;
188
+ settled = true;
189
+ reject(timeoutError(this.deps.timeoutMs));
190
+ }, this.deps.timeoutMs);
191
+ this.channel
192
+ .send(message)
193
+ .then((receipt) => {
194
+ if (settled)
195
+ return;
196
+ settled = true;
197
+ timer();
198
+ resolve(receipt);
199
+ }, (error) => {
200
+ if (settled)
201
+ return;
202
+ settled = true;
203
+ timer();
204
+ reject(error);
205
+ })
206
+ .catch((error) => {
207
+ // The .then handlers never throw; the catch is the
208
+ // belt-and-braces shape for a misbehaving adapter.
209
+ if (settled)
210
+ return;
211
+ settled = true;
212
+ timer();
213
+ reject(error);
214
+ });
215
+ });
216
+ }
217
+ /** Apply an edit entry; its ledger row carries the target MessageRef. */
218
+ async boundedEdit(entry, message) {
219
+ if (entry.ref === undefined || this.channel.edit === undefined) {
220
+ const error = new Error("gateway: edit delivery requires a ref and adapter edit()");
221
+ error.name = EDIT_PRECONDITION_ERROR;
222
+ throw error;
223
+ }
224
+ await new Promise((resolve, reject) => {
225
+ let settled = false;
226
+ const timer = this.deps.setTimer(() => {
227
+ if (settled)
228
+ return;
229
+ settled = true;
230
+ reject(timeoutError(this.deps.timeoutMs));
231
+ }, this.deps.timeoutMs);
232
+ this.channel.edit(entry.ref, message).then(() => {
233
+ if (settled)
234
+ return;
235
+ settled = true;
236
+ timer();
237
+ resolve();
238
+ }, (error) => {
239
+ if (settled)
240
+ return;
241
+ settled = true;
242
+ timer();
243
+ reject(error);
244
+ });
245
+ });
246
+ return { messageIds: [entry.ref.messageId] };
247
+ }
248
+ /**
249
+ * The classified failure path: transient retries (the ladder),
250
+ * permanent fails at once, chat_gone marks the binding dead.
251
+ * Returns false when a retry wait was armed (the caller parks).
252
+ */
253
+ async onSendError(entry, error) {
254
+ const kind = this.classify(error);
255
+ const detail = error instanceof Error ? error.message : String(error);
256
+ // A ledger-mark failure arrives here too (sendOne's try wraps the
257
+ // attempting mark): it is NOT a send failure — a transient retry of
258
+ // an unmarked entry would double-send after recovery. Mark errors
259
+ // log loud and park the entry as-is (the next boot's recovery owns
260
+ // the resend).
261
+ if (kind === "chat_gone") {
262
+ if (entry.bindingKey !== undefined) {
263
+ try {
264
+ await this.deps.bindings.markDead(entry.bindingKey);
265
+ }
266
+ catch (markError) {
267
+ this.deps.log.error(`delivery: markDead failed for binding "${hashId(entry.bindingKey)}"`, markError);
268
+ this.deps.log.debug(`delivery: markDead failed for binding "${entry.bindingKey}"`);
269
+ }
270
+ }
271
+ this.deps.log.warn(`delivery: chat gone for "${entry.channel}:${hashId(entry.chat.chatId)}" — binding marked dead`);
272
+ this.deps.log.debug(`delivery: chat gone for "${entry.channel}:${entry.chat.chatId}" — binding marked dead`);
273
+ await this.markLedger(entry.id, "failed");
274
+ this.deps.counts?.failed();
275
+ return true;
276
+ }
277
+ if (kind === "permanent") {
278
+ const fileDetail = error instanceof Error && error.name === FILE_RESOLUTION_ERROR
279
+ ? ` (${error.message})`
280
+ : "";
281
+ this.deps.log.error(`delivery: permanent failure ("${entry.kind}" to "${hashId(entry.chat.chatId)}")${fileDetail}`, error);
282
+ this.deps.log.debug(`delivery: permanent failure ("${entry.kind}" to "${entry.chat.chatId}")${fileDetail}`);
283
+ await this.markLedger(entry.id, "failed");
284
+ this.deps.counts?.failed();
285
+ return true;
286
+ }
287
+ // Transient: the retry ladder. attempts was already incremented by
288
+ // the attempting mark; a send is allowed delays.length + 1 tries
289
+ // total (the initial attempt plus one per delay).
290
+ const retriesUsed = entry.attempts;
291
+ if (retriesUsed >= this.deps.delays.length) {
292
+ this.deps.log.error(`delivery: retries exhausted ("${entry.kind}" to "${hashId(entry.chat.chatId)}"): ${detail}`);
293
+ this.deps.log.debug(`delivery: retries exhausted ("${entry.kind}" to "${entry.chat.chatId}"): ${detail}`);
294
+ await this.markLedger(entry.id, "failed");
295
+ this.deps.counts?.failed();
296
+ return true;
297
+ }
298
+ const delay = this.deps.delays[retriesUsed] ?? 0;
299
+ this.deps.log.warn(`delivery: transient failure (attempt ${retriesUsed + 1}, retry in ${delay} ms): ${detail}`);
300
+ // The entry returns to pending (the retry re-pulls it); the timer
301
+ // re-kicks the loop. A stop cancels the timer — the entry stays
302
+ // pending for the next boot's recovery.
303
+ await this.markLedger(entry.id, "pending");
304
+ if (this.stopped)
305
+ return false;
306
+ this.cancelRetry?.();
307
+ this.cancelRetry = this.deps.setTimer(() => {
308
+ this.cancelRetry = undefined;
309
+ this.kick();
310
+ }, delay);
311
+ return false;
312
+ }
313
+ /** A ledger mark with its own error isolation (never throws). */
314
+ async markLedger(id, state) {
315
+ try {
316
+ await this.ledger.mark(id, state);
317
+ }
318
+ catch (markError) {
319
+ this.deps.log.error("delivery: ledger mark failed", markError);
320
+ }
321
+ }
322
+ /**
323
+ * The classification read (adapter's `classifyError` when present,
324
+ * transient otherwise) with the timeout error forced transient.
325
+ */
326
+ classify(error) {
327
+ if (error instanceof Error && (error.name === FILE_RESOLUTION_ERROR || error.name === EDIT_PRECONDITION_ERROR)) {
328
+ return "permanent";
329
+ }
330
+ if (error instanceof Error &&
331
+ error.name === "GatewaySendTimeout") {
332
+ // Review Focus 2: a send over the bound is TRANSIENT — never
333
+ // permanent, never chat_gone.
334
+ return "transient";
335
+ }
336
+ if (this.channel.classifyError !== undefined) {
337
+ return this.channel.classifyError(error);
338
+ }
339
+ return "transient";
340
+ }
341
+ /** The send-shaped message for one entry (the recovered marker rule). */
342
+ async buildMessage(entry) {
343
+ // An edit replaces its target; adding a resend prefix would corrupt
344
+ // the already-final outcome text (the marker belongs on fresh sends).
345
+ const text = entry.recovered === true && entry.kind !== "edit"
346
+ ? `${RESENT_MARKER}\n${entry.message.text}`
347
+ : entry.message.text;
348
+ let files;
349
+ if (entry.message.files !== undefined && this.deps.resolveFiles !== undefined) {
350
+ try {
351
+ files = await this.deps.resolveFiles(entry);
352
+ }
353
+ catch (error) {
354
+ const paths = entry.message.files.map((file) => file.path).join(", ");
355
+ this.deps.log.warn(`delivery: file resolution failed for ${paths}`);
356
+ const failure = new Error(`file resolution failed for ${paths}`, {
357
+ cause: error,
358
+ });
359
+ failure.name = FILE_RESOLUTION_ERROR;
360
+ throw failure;
361
+ }
362
+ }
363
+ return {
364
+ chat: entry.message.chat,
365
+ text,
366
+ ...(entry.message.replyTo === undefined
367
+ ? {}
368
+ : { replyTo: entry.message.replyTo }),
369
+ ...(entry.message.buttons === undefined
370
+ ? {}
371
+ : { buttons: entry.message.buttons }),
372
+ ...(files === undefined ? {} : { files }),
373
+ };
374
+ }
375
+ }
376
+ /** A workspace file no longer exists/readable; retrying cannot repair it. */
377
+ const FILE_RESOLUTION_ERROR = "GatewayFileResolutionError";
378
+ /** A structurally undeliverable edit can never succeed on retry. */
379
+ const EDIT_PRECONDITION_ERROR = "GatewayEditPreconditionError";
380
+ /** The timeout error's name (matched by `classify`, never instanceof). */
381
+ export const SEND_TIMEOUT_NAME = "GatewaySendTimeout";
382
+ /** Build the timeout error. */
383
+ function timeoutError(ms) {
384
+ const error = new Error(`gateway: send exceeded ${ms} ms`);
385
+ error.name = SEND_TIMEOUT_NAME;
386
+ return error;
387
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Markdown splitting and stripping (spec §8.6): the outbound chunker's
3
+ * step 1 (split at paragraph, then line, then hard boundaries, with
4
+ * fence close-and-reopen) and the plain-format renderer's stripper
5
+ * (headings, emphasis, links → `text (url)`, fences → plain blocks).
6
+ *
7
+ * Pure functions over strings — no imports, no clock, no I/O.
8
+ */
9
+ /**
10
+ * Split `text` into chunks of at most `limit` characters, preferring
11
+ * paragraph boundaries, then line boundaries, then hard cuts.
12
+ *
13
+ * Fence rule: a code fence cut by a split is CLOSED in the earlier
14
+ * chunk and REOPENED with its original info string at the head of the
15
+ * next. An unterminated fence in the input is treated as running to
16
+ * the end (the last chunk's close terminates it — every emitted chunk
17
+ * is well-formed on its own).
18
+ *
19
+ * Every EMITTED chunk respects the limit: the close suffix a
20
+ * fence-interior chunk carries ("\n```") is reserved in each fit
21
+ * check, and hard-split pieces shrink by the ACTUAL reopen width
22
+ * (info string included — "typescript" reopens 13 chars wide, so a
23
+ * fixed reserve under-sizes pieces).
24
+ *
25
+ * The join of the chunks is a lossless representation of the input
26
+ * apart from the inserted fence markers and the whitespace the
27
+ * paragraph/line splits consumed.
28
+ */
29
+ export declare function splitMarkdown(text: string, limit: number): string[];
30
+ /**
31
+ * Strip markdown to plain text (the `plain` capability's formatter):
32
+ * headings lose their `#` markers, emphasis markers are removed, links
33
+ * render as `text (url)`, and fenced code blocks become their inner
34
+ * lines. Structure (paragraphs, line breaks) is preserved.
35
+ */
36
+ export declare function stripMarkdown(text: string): string;