@open-everywhere/provider-slack 0.1.0

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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/slack-attachment-fetch.ts +168 -0
  5. package/agent/app-manifest.ts +149 -0
  6. package/agent/channels/slack-approval-card.ts +275 -0
  7. package/agent/channels/slack-attachments.ts +195 -0
  8. package/agent/channels/slack-commands.ts +227 -0
  9. package/agent/channels/slack-context.ts +185 -0
  10. package/agent/channels/slack-events.ts +5406 -0
  11. package/agent/channels/slack-front.ts +2349 -0
  12. package/agent/channels/slack-identity.ts +520 -0
  13. package/agent/channels/slack-input-response.ts +162 -0
  14. package/agent/channels/slack-mrkdwn.ts +61 -0
  15. package/agent/channels/slack-origin-codec.ts +62 -0
  16. package/agent/channels/slack-post-arm.ts +314 -0
  17. package/agent/channels/slack-read.ts +248 -0
  18. package/agent/channels/slack-route-hold.ts +107 -0
  19. package/agent/channels/slack-shortcut.ts +336 -0
  20. package/agent/channels/slack-signature.ts +53 -0
  21. package/agent/channels/slack-spend-gate.ts +62 -0
  22. package/agent/channels/slack-tenant-fence.ts +99 -0
  23. package/agent/channels/slack-wake-backscroll.ts +604 -0
  24. package/agent/channels/slack-web-api.ts +178 -0
  25. package/agent/channels/slack-webhook-verifier.ts +116 -0
  26. package/agent/channels/slack.ts +1266 -0
  27. package/agent/contributions.ts +32 -0
  28. package/agent/docs-generated.ts +15 -0
  29. package/agent/ops.ts +50 -0
  30. package/agent/provider-events.ts +91 -0
  31. package/agent/register.ts +144 -0
  32. package/agent/surface-parity.ts +99 -0
  33. package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
  34. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
  35. package/dist/agent/access/slack-attachment-fetch.js +151 -0
  36. package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
  37. package/dist/agent/app-manifest.d.ts +15 -0
  38. package/dist/agent/app-manifest.d.ts.map +1 -0
  39. package/dist/agent/app-manifest.js +141 -0
  40. package/dist/agent/app-manifest.js.map +1 -0
  41. package/dist/agent/channels/slack-approval-card.d.ts +71 -0
  42. package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
  43. package/dist/agent/channels/slack-approval-card.js +232 -0
  44. package/dist/agent/channels/slack-approval-card.js.map +1 -0
  45. package/dist/agent/channels/slack-attachments.d.ts +66 -0
  46. package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-attachments.js +174 -0
  48. package/dist/agent/channels/slack-attachments.js.map +1 -0
  49. package/dist/agent/channels/slack-commands.d.ts +27 -0
  50. package/dist/agent/channels/slack-commands.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-commands.js +208 -0
  52. package/dist/agent/channels/slack-commands.js.map +1 -0
  53. package/dist/agent/channels/slack-context.d.ts +26 -0
  54. package/dist/agent/channels/slack-context.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-context.js +178 -0
  56. package/dist/agent/channels/slack-context.js.map +1 -0
  57. package/dist/agent/channels/slack-events.d.ts +1654 -0
  58. package/dist/agent/channels/slack-events.d.ts.map +1 -0
  59. package/dist/agent/channels/slack-events.js +4240 -0
  60. package/dist/agent/channels/slack-events.js.map +1 -0
  61. package/dist/agent/channels/slack-front.d.ts +387 -0
  62. package/dist/agent/channels/slack-front.d.ts.map +1 -0
  63. package/dist/agent/channels/slack-front.js +1958 -0
  64. package/dist/agent/channels/slack-front.js.map +1 -0
  65. package/dist/agent/channels/slack-identity.d.ts +245 -0
  66. package/dist/agent/channels/slack-identity.d.ts.map +1 -0
  67. package/dist/agent/channels/slack-identity.js +405 -0
  68. package/dist/agent/channels/slack-identity.js.map +1 -0
  69. package/dist/agent/channels/slack-input-response.d.ts +26 -0
  70. package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
  71. package/dist/agent/channels/slack-input-response.js +127 -0
  72. package/dist/agent/channels/slack-input-response.js.map +1 -0
  73. package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
  74. package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
  75. package/dist/agent/channels/slack-mrkdwn.js +53 -0
  76. package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
  77. package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
  78. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
  79. package/dist/agent/channels/slack-origin-codec.js +55 -0
  80. package/dist/agent/channels/slack-origin-codec.js.map +1 -0
  81. package/dist/agent/channels/slack-post-arm.d.ts +54 -0
  82. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
  83. package/dist/agent/channels/slack-post-arm.js +268 -0
  84. package/dist/agent/channels/slack-post-arm.js.map +1 -0
  85. package/dist/agent/channels/slack-read.d.ts +84 -0
  86. package/dist/agent/channels/slack-read.d.ts.map +1 -0
  87. package/dist/agent/channels/slack-read.js +205 -0
  88. package/dist/agent/channels/slack-read.js.map +1 -0
  89. package/dist/agent/channels/slack-route-hold.d.ts +34 -0
  90. package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
  91. package/dist/agent/channels/slack-route-hold.js +97 -0
  92. package/dist/agent/channels/slack-route-hold.js.map +1 -0
  93. package/dist/agent/channels/slack-shortcut.d.ts +162 -0
  94. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
  95. package/dist/agent/channels/slack-shortcut.js +262 -0
  96. package/dist/agent/channels/slack-shortcut.js.map +1 -0
  97. package/dist/agent/channels/slack-signature.d.ts +19 -0
  98. package/dist/agent/channels/slack-signature.d.ts.map +1 -0
  99. package/dist/agent/channels/slack-signature.js +50 -0
  100. package/dist/agent/channels/slack-signature.js.map +1 -0
  101. package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
  102. package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
  103. package/dist/agent/channels/slack-spend-gate.js +48 -0
  104. package/dist/agent/channels/slack-spend-gate.js.map +1 -0
  105. package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
  106. package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
  107. package/dist/agent/channels/slack-tenant-fence.js +88 -0
  108. package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
  109. package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
  111. package/dist/agent/channels/slack-wake-backscroll.js +473 -0
  112. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
  113. package/dist/agent/channels/slack-web-api.d.ts +46 -0
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
  115. package/dist/agent/channels/slack-web-api.js +147 -0
  116. package/dist/agent/channels/slack-web-api.js.map +1 -0
  117. package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
  118. package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
  119. package/dist/agent/channels/slack-webhook-verifier.js +112 -0
  120. package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
  121. package/dist/agent/channels/slack.d.ts +37 -0
  122. package/dist/agent/channels/slack.d.ts.map +1 -0
  123. package/dist/agent/channels/slack.js +1169 -0
  124. package/dist/agent/channels/slack.js.map +1 -0
  125. package/dist/agent/contributions.d.ts +17 -0
  126. package/dist/agent/contributions.d.ts.map +1 -0
  127. package/dist/agent/contributions.js +32 -0
  128. package/dist/agent/contributions.js.map +1 -0
  129. package/dist/agent/docs-generated.d.ts +3 -0
  130. package/dist/agent/docs-generated.d.ts.map +1 -0
  131. package/dist/agent/docs-generated.js +13 -0
  132. package/dist/agent/docs-generated.js.map +1 -0
  133. package/dist/agent/ops.d.ts +3 -0
  134. package/dist/agent/ops.d.ts.map +1 -0
  135. package/dist/agent/ops.js +38 -0
  136. package/dist/agent/ops.js.map +1 -0
  137. package/dist/agent/provider-events.d.ts +3 -0
  138. package/dist/agent/provider-events.d.ts.map +1 -0
  139. package/dist/agent/provider-events.js +84 -0
  140. package/dist/agent/provider-events.js.map +1 -0
  141. package/dist/agent/register.d.ts +5 -0
  142. package/dist/agent/register.d.ts.map +1 -0
  143. package/dist/agent/register.js +118 -0
  144. package/dist/agent/register.js.map +1 -0
  145. package/dist/agent/surface-parity.d.ts +3 -0
  146. package/dist/agent/surface-parity.d.ts.map +1 -0
  147. package/dist/agent/surface-parity.js +95 -0
  148. package/dist/agent/surface-parity.js.map +1 -0
  149. package/package.json +209 -0
  150. package/tsconfig.build.json +22 -0
  151. package/tsconfig.json +15 -0
@@ -0,0 +1,604 @@
1
+ // Wake-time conversation backscroll.
2
+ //
3
+ // The MENTION path enriches a turn with surrounding conversation (slack-context.ts, via Eve's
4
+ // `SlackContext` handle). The WAKE path — a name-drop that pulls the bot into a conversation it
5
+ // was never mentioned in (slack-events.ts routeNameDropEvent) — used to hand the model ONLY the
6
+ // triggering message, so:
7
+ // (a) a cold name-drop mid-thread arrived with no idea what's being discussed, and
8
+ // (b) a re-wake after auto-disengage resumed the SAME thread session, which has a GAP for
9
+ // everything said while the bot was disengaged.
10
+ // This module closes both by fetching recent history at wake time and attaching it as the same
11
+ // `context` strings the mention path uses.
12
+ //
13
+ // WHY A STANDALONE CALLER (not Eve's `ctx`): a wake fires from a raw HTTP route, not a live
14
+ // session, so there's no `SlackContext`/`ctx.slack` to borrow. We call the Web API through OE's
15
+ // `callSlackWebApi` seam (channels/slack-web-api.ts) with the bot token — the same seam every other
16
+ // Slack Web API call uses, so this path inherits the 429 backoff. The message→line mapping,
17
+ // the render, and the UNTRUSTED wrapping are REUSED from slack-context.ts (linesFromMessages /
18
+ // renderContext / ContextLine), so wake context is attributed + fenced byte-identically to the
19
+ // mention path — the untrusted framing is a security invariant (channel content is DATA, never
20
+ // instructions), not decoration, so it must never diverge between the two paths.
21
+ //
22
+ // BOOTSTRAP BACKSCROLL is fail-soft: any error yields no context and never blocks the first wake.
23
+ // SEAT CATCH-UP propagates the same read failure to core's shared fail-soft boundary, so the wake
24
+ // still continues but its `seat.catch_up` row says `failed` rather than `empty` (OPE-1842).
25
+
26
+ import { callSlackWebApi } from "./slack-web-api.js";
27
+ import { RateLimitExhaustedError } from "@open-everywhere/core/agent/adapter-api.js";
28
+ import { linesFromMessages, readMoreHint } from "./slack-context.js";
29
+ import {
30
+ renderContext,
31
+ sinceLastAgentReply,
32
+ WAKE_BACKSCROLL_MAX,
33
+ fetchedBackscroll,
34
+ catchUpSender,
35
+ type ContextLine,
36
+ type NormalizedMessage,
37
+ type BackscrollTrustFor,
38
+ type SeatAdmissionFn,
39
+ type SeatCatchUpFetch,
40
+ type SeatCatchUpRequest,
41
+ type WakeMessageInput,
42
+ } from "@open-everywhere/core/agent/adapter-api.js";
43
+
44
+ // Re-export the shared cap so this module's public surface (and its test, which imports it from
45
+ // here) is unchanged after the extraction into context-render.ts (now reached via the barrel).
46
+ export { WAKE_BACKSCROLL_MAX };
47
+
48
+ /**
49
+ * The widest THREAD slice this module hands a caller that ADMITS BEFORE IT CAPS (OPE-1744,
50
+ * {@link WakeBackscrollInput.threadAdmissionSlice}). Five times {@link WAKE_BACKSCROLL_MAX}, so the
51
+ * policy can look past a long run of refused replies and still fill the ten-line slot; half the page
52
+ * the thread fetch buys, so the slice stays bounded.
53
+ * 🔴 IT IS A COST BOUND, AND THE COST IS THE CALLER'S PER-AUTHOR PROBE. Slack's hydration regrades
54
+ * every returned line before it admits, and that regrade runs one TTL-cached `users.info` per
55
+ * DISTINCT vouchable author. Handing back the whole 100-reply page would put that fan-out on a busy
56
+ * thread; the probe fails OPEN, so an exhausted burst demotes nobody and a guest keeps the tier a
57
+ * confirmed-internal channel gives. The slice is bounded here to keep that burst small.
58
+ */
59
+ export const THREAD_ADMISSION_MAX = WAKE_BACKSCROLL_MAX * 5;
60
+
61
+ /** The Slack bot's resolved identity — used to label the agent's own prior posts as `agent`. */
62
+ export interface WakeBotIdentity {
63
+ readonly userId?: string;
64
+ readonly botId?: string;
65
+ }
66
+
67
+ /** A Slack Web-API caller: POSTs `method` with url-encoded `params`, returns parsed JSON or
68
+ * undefined on any failure (fail-soft). Injected so the fetcher is unit-testable offline. */
69
+ export type SlackRequester = (method: string, params: Record<string, string>) => Promise<Record<string, unknown> | undefined>;
70
+
71
+ /** The triggering event, narrowed to what the backscroll needs. */
72
+ export interface WakeBackscrollInput {
73
+ readonly channelId: string;
74
+ /** The triggering message's ts. */
75
+ readonly ts: string;
76
+ /** Thread root ts — present ⇒ in-thread wake (conversations.replies); absent ⇒ top-level. */
77
+ readonly threadTs?: string;
78
+ /**
79
+ * THREAD LEG ONLY (OPE-1744): hand back the ADMISSION slice — up to {@link THREAD_ADMISSION_MAX}
80
+ * replies rather than the render cap's {@link WAKE_BACKSCROLL_MAX} — so the caller filters first
81
+ * and caps after. That is the order channels/seat-admission.ts {@link admitToSeat} states ("filter,
82
+ * then let the caller cap — never the reverse, or a refused bot's bytes would eat a person's
83
+ * message out of the cap"). A caller that applies an admission policy asks for it and caps with
84
+ * `capLines` once the policy has run; every other caller leaves it absent and gets the render-cap
85
+ * slice it always got.
86
+ * 🔴 IT MOVES THE CAP, NEVER THE POLICY. The admission stays ABOVE this seam, in the caller, so a
87
+ * wiring that supplies its own `backscroll` still cannot bypass it — a cap is a budget, not a
88
+ * security control, which is why this is the half that may cross.
89
+ * The CHANNEL leg ignores it: its raw fetch asks Slack for {@link WAKE_BACKSCROLL_MAX} messages, so
90
+ * there is no wider slice to hand back and backfilling would mean buying a bigger page.
91
+ */
92
+ readonly threadAdmissionSlice?: boolean;
93
+ }
94
+
95
+ /** Injected seams so the fetcher runs without a network (tests) but defaults to live Slack. */
96
+ export interface WakeBackscrollDeps {
97
+ readonly request: SlackRequester;
98
+ readonly identity: () => Promise<WakeBotIdentity>;
99
+ }
100
+
101
+ // --- live (bot-token) implementations ---------------------------------------
102
+
103
+ // Bot-token POST to the Slack Web API via OE's `callSlackWebApi` (channels/slack-web-api.ts), which
104
+ // a wake path can't reach through `ctx` because it fires from a raw HTTP route with no live session.
105
+ // Form-encodes `params` and resolves the token from `SLACK_BOT_TOKEN`. Fail-soft: a missing token
106
+ // makes the seam throw, and any throw → undefined (backscroll never blocks a wake).
107
+ const liveRequest: SlackRequester = async (method, params) => {
108
+ try {
109
+ return (await callSlackWebApi({ botToken: undefined, operation: method, body: params })) as Record<string, unknown>;
110
+ } catch (err) {
111
+ // 🔴 LOG A RATE-LIMIT EXHAUSTION BEFORE SWALLOWING IT (OPE-1211). Fail-soft is right here — a
112
+ // backscroll must never block a wake — but an exhausted 429 retry with no trace is the silent
113
+ // drop the ticket exists to prevent. Every field is token-free.
114
+ if (err instanceof RateLimitExhaustedError) {
115
+ console.warn(`[open-everywhere:slack] rate limited: backscroll ${method} gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
116
+ }
117
+ return undefined;
118
+ }
119
+ };
120
+
121
+ // The bot's (user_id, bot_id) via auth.test, resolved once (cached). Same shape as
122
+ // slack-front.ts's resolveBotIdentity; a local copy avoids the import cycle noted above. Used
123
+ // only to attribute the agent's own posts in the backscroll — best-effort, never a hard gate.
124
+ let wakeBotIdentityPromise: Promise<WakeBotIdentity> | undefined;
125
+ function liveWakeBotIdentity(): Promise<WakeBotIdentity> {
126
+ if (!wakeBotIdentityPromise) {
127
+ wakeBotIdentityPromise = (async () => {
128
+ const res = await liveRequest("auth.test", {});
129
+ if (res?.ok === true) {
130
+ return {
131
+ userId: typeof res.user_id === "string" ? res.user_id : undefined,
132
+ botId: typeof res.bot_id === "string" ? res.bot_id : undefined,
133
+ };
134
+ }
135
+ return {};
136
+ })();
137
+ }
138
+ return wakeBotIdentityPromise;
139
+ }
140
+
141
+ /** Reset the cached wake bot identity (tests only). */
142
+ export function _resetWakeBotIdentity(): void {
143
+ wakeBotIdentityPromise = undefined;
144
+ }
145
+
146
+ const liveDeps: WakeBackscrollDeps = { request: liveRequest, identity: liveWakeBotIdentity };
147
+
148
+ // --- the fetcher ------------------------------------------------------------
149
+
150
+ /** The RAW-lines result of a name-drop backscroll fetch (W3): the oldest-first
151
+ * {@link ContextLine}s — capped at {@link WAKE_BACKSCROLL_MAX}, or at the wider
152
+ * {@link THREAD_ADMISSION_MAX} where the caller asked for the thread leg's admission slice
153
+ * ({@link WakeBackscrollInput.threadAdmissionSlice}) and caps itself — plus the renderContext
154
+ * `source` label and the Slack read-more hint. The renderer
155
+ * ({@link renderContext}, called inside renderWake) turns these into the untrusted-wrapped context
156
+ * block — so no pre-rendered string ever flows through the wake plumbing. `null` = nothing to add
157
+ * OR any failure (always non-fatal). */
158
+ export interface WakeBackscrollLines {
159
+ readonly lines: readonly ContextLine[];
160
+ readonly source: string;
161
+ /** Slack HAS read tools, so the hint names them; renderContext appends it OUTSIDE the untrusted
162
+ * block only when the slice actually hit the cap. */
163
+ readonly readMoreHint: string;
164
+ }
165
+
166
+ /**
167
+ * Fetch recent conversation history for a name-drop wake as RAW ContextLines + source + hint (W3) —
168
+ * the renderer wraps them (renderContext, inside renderWake). `null` = nothing to add OR any
169
+ * failure (always non-fatal).
170
+ *
171
+ * - IN-THREAD wake (`threadTs`): `conversations.replies`, sliced to "since the agent's last reply"
172
+ * (see {@link sinceLastAgentReply}); the triggering message itself is excluded (the wake message
173
+ * already carries its text). Capped at {@link WAKE_BACKSCROLL_MAX}, or at the wider
174
+ * {@link THREAD_ADMISSION_MAX} when the caller asks for the admission slice
175
+ * ({@link WakeBackscrollInput.threadAdmissionSlice}).
176
+ * - TOP-LEVEL wake (no `threadTs`): `conversations.history` with `latest = ts`, `inclusive: false`
177
+ * → strictly-older messages only, most recent N.
178
+ */
179
+ export async function fetchWakeBackscrollLines(input: WakeBackscrollInput, deps: WakeBackscrollDeps = liveDeps): Promise<WakeBackscrollLines | null> {
180
+ try {
181
+ const ids = await deps.identity().catch(() => ({} as WakeBotIdentity));
182
+ if (input.threadTs) {
183
+ const res = await deps.request("conversations.replies", {
184
+ channel: input.channelId,
185
+ ts: input.threadTs,
186
+ // A single (oldest-anchored) page — no cursor paging. Comfortably covers any realistic
187
+ // thread; a pathologically long one degrades to "older context only", still fail-soft.
188
+ limit: "100",
189
+ });
190
+ const raw = extractMessages(res);
191
+ if (!raw) return null;
192
+ // Drop the trigger itself (its text rides on the wake message, not the backscroll).
193
+ const priorRaw = raw.filter((m) => (typeof m.ts === "string" ? m.ts : "") !== input.ts);
194
+ // conversations.replies is oldest-first already — no reverse.
195
+ const lines = sinceLastAgentReply(linesFromMessages(priorRaw, ids));
196
+ const source = `slack thread ${input.channelId}/${input.threadTs}`;
197
+ // The wider ADMISSION slice when the caller admits before it caps (OPE-1744), the render-cap
198
+ // slice otherwise — see {@link WakeBackscrollInput.threadAdmissionSlice}.
199
+ return input.threadAdmissionSlice ? backscrollEntry(lines.slice(-THREAD_ADMISSION_MAX), source) : cappedEntry(lines, source);
200
+ }
201
+ // Top-level: strictly-older backscroll of the channel (mirrors slack-context's channel path).
202
+ const res = await deps.request("conversations.history", {
203
+ channel: input.channelId,
204
+ latest: input.ts,
205
+ inclusive: "false",
206
+ limit: String(WAKE_BACKSCROLL_MAX),
207
+ });
208
+ const raw = extractMessages(res);
209
+ if (!raw) return null;
210
+ const lines = linesFromMessages(raw, ids);
211
+ lines.reverse(); // conversations.history is newest-first → oldest-first for rendering
212
+ return cappedEntry(lines, `slack channel ${input.channelId}`);
213
+ } catch {
214
+ return null; // fail-soft: a wake never depends on its backscroll
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Fetch recent conversation history for a name-drop wake, rendered as `context` strings
220
+ * (untrusted-wrapped, oldest-first, capped at {@link WAKE_BACKSCROLL_MAX}). Empty array = nothing to
221
+ * add OR any failure. Thin wrapper over {@link fetchWakeBackscrollLines} + the shared renderContext,
222
+ * so its output stays byte-identical to before the W3 refactor (retained for its existing callers
223
+ * and byte-identity tests; the live name-drop path now uses the lines variant directly).
224
+ */
225
+ export async function fetchWakeBackscroll(input: WakeBackscrollInput, deps: WakeBackscrollDeps = liveDeps): Promise<string[]> {
226
+ const res = await fetchWakeBackscrollLines(input, deps);
227
+ if (!res) return [];
228
+ const entry = renderContext(res.lines, res.source, { readMoreHint: res.readMoreHint });
229
+ return entry ? [entry] : [];
230
+ }
231
+
232
+ // Pull the `messages` array out of an ok response, or undefined (fail-soft on junk / not-ok).
233
+ function extractMessages(res: Record<string, unknown> | undefined): Array<Record<string, unknown>> | undefined {
234
+ if (!res || res.ok !== true || !Array.isArray(res.messages)) return undefined;
235
+ return res.messages as Array<Record<string, unknown>>;
236
+ }
237
+
238
+ // Return the RAW lines + source + hint — the renderer (renderContext, inside renderWake) does the
239
+ // untrusted-wrap, so wrapping happens in exactly ONE place (W3). `lines` must be oldest-first.
240
+ // Returns null when there's nothing to keep, so the caller renders no context block.
241
+ // The hint is handed over unconditionally: `renderContext` appends it only when the RENDERED slice
242
+ // fills MAX_MESSAGES, so it always describes what the model was shown, never what was fetched.
243
+ function backscrollEntry(lines: readonly ContextLine[], source: string): WakeBackscrollLines | null {
244
+ if (lines.length === 0) return null;
245
+ return { lines, source, readMoreHint: readMoreHint() };
246
+ }
247
+
248
+ // {@link backscrollEntry} with the WAKE_BACKSCROLL_MAX cap applied first (keep the most recent).
249
+ function cappedEntry(lines: readonly ContextLine[], source: string): WakeBackscrollLines | null {
250
+ return backscrollEntry(lines.slice(-WAKE_BACKSCROLL_MAX), source);
251
+ }
252
+
253
+ // --- envelope-shaped first-wake backscroll (supervisor context hydration) — SLACK ADAPTER ----
254
+ //
255
+ // The channel-supervisor's FIRST wake on a generation (a brand-new session OR the fresh generation
256
+ // minted by a rotation, slack-events.ts shouldRotate/routeSupervisorEvent) carries channel
257
+ // backscroll rendered as the wake ENVELOPE's own non-trigger `<message>` siblings, grouped into
258
+ // `<session>` blocks (wake-envelope.ts renderWakeEnvelope) — Claude Tag's observed first-wake shape
259
+ // is individual attributed messages, oldest-first, ending at the trigger (claude-tag-behaviour.md
260
+ // §4.1), and OE's is richer: idle-gap-clustered pseudo-sessions with count/time/token bounds
261
+ //.
262
+ //
263
+ // THIS FILE IS THE SLACK FETCH ADAPTER behind the platform-NEUTRAL clustering/bounds core
264
+ // (backscroll-window.ts). Its job: turn conversations.history (+ conversations.replies for thread
265
+ // enrichment) into a visibility-GATED {@link NormalizedMessage}[] window, then hand it to
266
+ // {@link fetchedBackscroll}. Discord/Teams later add their OWN fetch adapter with zero change to the
267
+ // core. FAIL-SOFT ABSOLUTELY throughout, exactly like {@link fetchWakeBackscroll}.
268
+
269
+ // --- fetch bounds (Slack-specific; the clustering/token bounds live in backscroll-window.ts) ---
270
+
271
+ /** Reachback (hours) for the raw Slack window: how far back from the trigger conversations.history
272
+ * is paginated. Generous enough that the SPINE (the most-recent cluster, exempt from the 36h
273
+ * enrichment cap) can still reach across a weekend/overnight gap (~66h Fri-4pm→Mon-10am), yet a
274
+ * hard floor that bounds the fetch. 7d default; overridable for ops/testing. */
275
+ export const RAW_FETCH_REACHBACK_HOURS = Number(process.env.OE_SUPERVISOR_BACKSCROLL_REACHBACK_HOURS) || 168;
276
+
277
+ /** conversations.history page size (Slack max is 1000; 200 keeps each page cheap). */
278
+ const RAW_FETCH_PAGE_LIMIT = 200;
279
+
280
+ /** Max history pages to paginate — bounds a busy channel's fetch at ≤600 messages so a burst can't
281
+ * fan out into an unbounded loop. Combined with the reachback floor (whichever binds first). */
282
+ const RAW_FETCH_MAX_PAGES = 3;
283
+
284
+ /** Max in-window thread ROOTS to enrich via conversations.replies — bounds the reply fan-out so a
285
+ * channel full of threads can't explode into unbounded API calls. */
286
+ const MAX_THREADS_ENRICHED = 8;
287
+
288
+ /** Single-page reply fetch per thread (mirrors the name-drop thread leg's own 100). */
289
+ const THREAD_REPLIES_LIMIT = 100;
290
+
291
+ /**
292
+ * The VISIBILITY / TRUST GATE (spec §Bounds — the load-bearing SECURITY filter): backscroll must
293
+ * never surface a message it can't ATTRIBUTE. The channel-internal confirmation
294
+ * (confirmChannelInternalWithMeta) fail-closes the restricted-area / at-rest-isolation dimension
295
+ * BEFORE this fetcher is ever called. So the per-message gate here KEEPS:
296
+ * - the bot's OWN prior posts (`agent` → `self`);
297
+ * - identifiable humans (`user` → `human`);
298
+ * - ATTRIBUTABLE foreign bots (`bot` WITH a bot id → `bot`) — INCLUDED as MARKED AWARENESS
299
+ * (clearly bot-flagged, CONTEXT-ONLY, never a wake trigger). Design intent: the supervisor
300
+ * SHOULD see foreign-bot posts, and dropping them breaks CONVERSATIONAL COHERENCE — a human
301
+ * replying to a bot's message would otherwise arrive with the bot message missing, so the agent
302
+ * couldn't tell what the human is responding to.
303
+ * and DROPS ONLY:
304
+ * - unattributable senders (`unknown`, or a `bot` with no id) — no auth'd author ⇒ fail closed.
305
+ * (System-subtype noise is already dropped upstream by linesFromMessages.) Foreign-bot messages are
306
+ * CONTEXT-ONLY on this path: the neutral core never sets `trigger` on backscroll, and the LIVE
307
+ * trigger gate (slack-events.ts shouldSupervisorWake) still drops bot-authored messages as triggers,
308
+ * so a bot post is awareness, never a response obligation. Wired explicitly + unit-tested
309
+ * (slack-wake-backscroll-test.ts) so it can't be forgotten.
310
+ */
311
+ export function isBackscrollVisible(line: ContextLine): boolean {
312
+ return (
313
+ line.senderType === "agent" ||
314
+ line.senderType === "user" ||
315
+ (line.senderType === "bot" && line.senderId !== undefined) // attributable foreign bot: marked awareness
316
+ );
317
+ }
318
+
319
+ export interface WakeEnvelopeBackscrollInput {
320
+ readonly channelId: string;
321
+ /** The triggering message's ts — the window's upper (exclusive) bound and the reachback origin. */
322
+ readonly ts: string;
323
+ /** Known thread root for a thread-bound catch-up. Present ⇒ read that thread directly; a root
324
+ * older than `sinceMs` is absent from `conversations.history` but its newer replies are not. */
325
+ readonly threadRoot?: string;
326
+ /** Hours to look back from `ts` for the raw fetch. Defaults to {@link RAW_FETCH_REACHBACK_HOURS}. */
327
+ readonly reachbackHours?: number;
328
+ /**
329
+ * THE CATCH-UP BOUND (OPE-1695 (b)): epoch milliseconds of the seat's own LAST WAKE, so this read
330
+ * returns what the room said since the seat last looked. It only ever NARROWS the window — the
331
+ * reachback floor above still applies — so a seat quiet for a month reads the recent tail, and a
332
+ * seat with no recorded wake reads the bootstrap window exactly as a first wake always did.
333
+ */
334
+ readonly sinceMs?: number;
335
+ }
336
+
337
+ // Pull the pagination cursor from a conversations.history response, or undefined when there's no
338
+ // next page (fail-soft on junk).
339
+ function extractCursor(res: Record<string, unknown> | undefined): string | undefined {
340
+ const meta = res?.response_metadata as { next_cursor?: unknown } | undefined;
341
+ const cursor = meta?.next_cursor;
342
+ return typeof cursor === "string" && cursor.length > 0 ? cursor : undefined;
343
+ }
344
+
345
+ // Paginate conversations.history backward from `latest` (exclusive) down to `oldest`, bounded by
346
+ // RAW_FETCH_MAX_PAGES. Returns the raw messages (newest-first across pages — the neutral core sorts
347
+ // by ts anyway). Fail-soft: a not-ok/absent page ends pagination with whatever's in hand.
348
+ async function fetchChannelWindow(
349
+ request: SlackRequester,
350
+ channelId: string,
351
+ latest: string,
352
+ oldest: string,
353
+ ): Promise<Array<Record<string, unknown>>> {
354
+ const all: Array<Record<string, unknown>> = [];
355
+ let cursor: string | undefined;
356
+ for (let page = 0; page < RAW_FETCH_MAX_PAGES; page++) {
357
+ const res = await request("conversations.history", {
358
+ channel: channelId,
359
+ latest,
360
+ inclusive: "false",
361
+ oldest,
362
+ limit: String(RAW_FETCH_PAGE_LIMIT),
363
+ ...(cursor ? { cursor } : {}),
364
+ });
365
+ const msgs = extractMessages(res);
366
+ if (!msgs) break;
367
+ all.push(...msgs);
368
+ cursor = extractCursor(res);
369
+ if (!cursor) break; // no more pages
370
+ }
371
+ return all;
372
+ }
373
+
374
+ // Enrich with thread replies for in-window thread ROOTS (conversations.history returns roots +
375
+ // top-level messages, not the replies inside a thread). BOUNDED: only roots that actually have
376
+ // replies, capped at MAX_THREADS_ENRICHED, one page each. Returns [{ root, msgs }]; fail-soft per
377
+ // thread (a failed reply fetch just omits that thread's replies).
378
+ async function fetchThreadReplies(
379
+ request: SlackRequester,
380
+ channelId: string,
381
+ channelMsgs: ReadonlyArray<Record<string, unknown>>,
382
+ ): Promise<Array<{ root: string; msgs: Array<Record<string, unknown>> }>> {
383
+ const roots: string[] = [];
384
+ for (const m of channelMsgs) {
385
+ if (roots.length >= MAX_THREADS_ENRICHED) break;
386
+ const ts = typeof m.ts === "string" ? m.ts : undefined;
387
+ const threadTs = typeof m.thread_ts === "string" ? m.thread_ts : undefined;
388
+ const replyCount = typeof m.reply_count === "number" ? m.reply_count : 0;
389
+ // A thread root: it IS its own thread anchor (no thread_ts, or thread_ts === ts) and has replies.
390
+ if (ts && replyCount > 0 && (!threadTs || threadTs === ts)) roots.push(ts);
391
+ }
392
+ const out: Array<{ root: string; msgs: Array<Record<string, unknown>> }> = [];
393
+ for (const root of roots) {
394
+ const res = await request("conversations.replies", { channel: channelId, ts: root, limit: String(THREAD_REPLIES_LIMIT) });
395
+ const msgs = extractMessages(res);
396
+ if (msgs) out.push({ root, msgs });
397
+ }
398
+ return out;
399
+ }
400
+
401
+ // Read one KNOWN thread without first discovering its root through channel history. A standing
402
+ // seat's catch-up window commonly starts after the thread root, so root discovery would answer an
403
+ // empty channel page even when the thread has new replies inside the window (OPE-1920).
404
+ async function fetchKnownThreadReplies(
405
+ request: SlackRequester,
406
+ channelId: string,
407
+ threadRoot: string,
408
+ oldest: string,
409
+ latest: string,
410
+ ): Promise<Array<{ root: string; msgs: Array<Record<string, unknown>> }>> {
411
+ const res = await request("conversations.replies", {
412
+ channel: channelId,
413
+ ts: threadRoot,
414
+ oldest,
415
+ latest,
416
+ // Keep both endpoints at the transport seam. Core removes the trigger by exact id and enforces
417
+ // the same bounds again after normalization.
418
+ inclusive: "true",
419
+ limit: String(THREAD_REPLIES_LIMIT),
420
+ });
421
+ const msgs = extractMessages(res);
422
+ return msgs ? [{ root: threadRoot, msgs }] : [];
423
+ }
424
+
425
+ // Normalize ONE raw Slack message → {@link NormalizedMessage}, reusing linesFromMessages for the
426
+ // exact sender classification + system/empty drop, then applying the VISIBILITY GATE. Returns
427
+ // undefined for a dropped (invisible / subtype / empty / unparseable-ts) message.
428
+ function normalizeOne(
429
+ m: Record<string, unknown>,
430
+ ids: WakeBotIdentity,
431
+ type: "channel" | "thread",
432
+ threadId: string | undefined,
433
+ ): NormalizedMessage | undefined {
434
+ const [line] = linesFromMessages([m], ids);
435
+ if (!line) return undefined; // system subtype / empty text (dropped upstream)
436
+ if (!isBackscrollVisible(line)) return undefined; // VISIBILITY GATE (unattributable sender only)
437
+ const seconds = Number.parseFloat(line.ts);
438
+ if (!Number.isFinite(seconds)) return undefined;
439
+ return {
440
+ id: line.ts,
441
+ ms: seconds * 1000,
442
+ // undefined for the bot's OWN posts (agent); the foreign bot's id for a `bot`; the user id for
443
+ // a human — kept raw for @-tagging / attribution.
444
+ authorId: line.senderId,
445
+ // THE SHARED SENDER RULE (OPE-1857): agent → self (our own post); bot → bot (attributable
446
+ // foreign bot, marked awareness); every other visible line is a `user` → human. `unknown` was
447
+ // already dropped by the gate above.
448
+ sender: catchUpSender({ self: line.senderType === "agent", bot: line.senderType === "bot" }),
449
+ type,
450
+ threadId,
451
+ text: line.text,
452
+ };
453
+ }
454
+
455
+ /**
456
+ * SLACK FETCH SEAM: fetch a bounded, visibility-GATED, NORMALIZED backscroll window (channel history
457
+ * + in-window thread replies) for a supervisor first wake. This is the platform boundary — the
458
+ * neutral core ({@link fetchedBackscroll}) consumes its output. FAIL-SOFT: any error/failure yields
459
+ * `[]`. Deduped by message id (a thread root appears in both history and its replies[0]).
460
+ */
461
+ export async function fetchSlackBackscrollWindow(
462
+ input: WakeEnvelopeBackscrollInput,
463
+ deps: WakeBackscrollDeps = liveDeps,
464
+ failureMode: "soft" | "throw" = "soft",
465
+ ): Promise<NormalizedMessage[]> {
466
+ try {
467
+ const ids = await deps.identity().catch(() => ({} as WakeBotIdentity));
468
+ const latestSeconds = Number.parseFloat(input.ts);
469
+ if (!Number.isFinite(latestSeconds)) return [];
470
+ const reachbackHours = input.reachbackHours ?? RAW_FETCH_REACHBACK_HOURS;
471
+ // The reachback floor, then the seat's own last wake where it is LATER — never earlier, so the
472
+ // catch-up can only narrow the fetch (see {@link WakeEnvelopeBackscrollInput.sinceMs}).
473
+ const floorSeconds = latestSeconds - reachbackHours * 3600;
474
+ const sinceSeconds = typeof input.sinceMs === "number" && Number.isFinite(input.sinceMs) ? input.sinceMs / 1000 : undefined;
475
+ const oldestSeconds = sinceSeconds !== undefined ? Math.max(floorSeconds, sinceSeconds) : floorSeconds;
476
+ const oldest = String(oldestSeconds);
477
+
478
+ // A thread-bound catch-up already knows its root. Read it directly: the root can predate the
479
+ // lower bound, which makes it undiscoverable through the bounded channel-history page even
480
+ // though newer replies belong in this wake. Bootstrap/container reads still take the original
481
+ // channel-history + bounded enrichment path.
482
+ const channelMsgs = input.threadRoot === undefined ? await fetchChannelWindow(deps.request, input.channelId, input.ts, oldest) : [];
483
+ const threadGroups =
484
+ input.threadRoot === undefined
485
+ ? await fetchThreadReplies(deps.request, input.channelId, channelMsgs)
486
+ : await fetchKnownThreadReplies(deps.request, input.channelId, input.threadRoot, oldest, input.ts);
487
+ if (channelMsgs.length === 0 && threadGroups.length === 0) return [];
488
+
489
+ const seen = new Set<string>();
490
+ const out: NormalizedMessage[] = [];
491
+ const add = (n: NormalizedMessage | undefined) => {
492
+ if (n && !seen.has(n.id)) {
493
+ seen.add(n.id);
494
+ out.push(n);
495
+ }
496
+ };
497
+ for (const m of channelMsgs) add(normalizeOne(m, ids, "channel", undefined));
498
+ for (const g of threadGroups) {
499
+ for (const m of g.msgs) {
500
+ const ts = typeof m.ts === "string" ? m.ts : "";
501
+ // The root is already a channel message — and so is the TRIGGER when it is a reply inside a
502
+ // thread whose root is in the window: `conversations.replies` returns it, and the wake
503
+ // renderer would then show the seat the message that woke it twice, once as context and once
504
+ // as the live trigger. The channel fetch excludes the trigger with `inclusive: "false"`; this
505
+ // is the same exclusion on the enrichment leg (the precedent is `linesFromMessages`' own
506
+ // "drop the trigger itself").
507
+ // A discovered root is already present in `channelMsgs`; a directly-read root is not, so
508
+ // let the ordinary lower/upper bounds decide whether it belongs. The trigger always leaves
509
+ // exactly by id.
510
+ if ((input.threadRoot === undefined && ts === g.root) || ts === input.ts) continue;
511
+ const secs = Number.parseFloat(ts);
512
+ if (Number.isFinite(secs) && secs < oldestSeconds) continue; // reply older than the window
513
+ if (input.threadRoot !== undefined && Number.isFinite(secs) && secs > latestSeconds) continue; // reply after the trigger
514
+ add(normalizeOne(m, ids, "thread", g.root));
515
+ }
516
+ }
517
+ return out;
518
+ } catch (err) {
519
+ if (failureMode === "throw") throw err;
520
+ return []; // fail-soft: a first wake never depends on its backscroll
521
+ }
522
+ }
523
+
524
+ /**
525
+ * SLACK'S REGISTERED CATCH-UP FETCH (OPE-1726) — the one thing this surface contributes to the shared
526
+ * since-last-wake path (core channels/seat-catch-up.ts). It is the SAME window fetch the bootstrap
527
+ * arm takes ({@link fetchSlackBackscrollWindow}), addressed by the neutral request shape: the seat's
528
+ * container, bounded below by its last wake and above by the trigger.
529
+ * 🔴 THE UPPER BOUND IS RE-EXPRESSED AS A SLACK `ts`, because `conversations.history` takes `latest`
530
+ * in Slack's own seconds-with-fraction form and not epoch ms. `excludeId` IS that ts on this surface
531
+ * (a Slack ts is both a time and an id), so it is preferred over the derived string — a derived one
532
+ * can lose the trailing digits Slack's ordering depends on. With neither, the read falls back to
533
+ * `now`, which is the honest upper bound for a catch-up with no stated trigger.
534
+ * A transport/read failure propagates to core's shared boundary, which keeps the wake alive and
535
+ * records `failed` instead of reporting a quiet channel (OPE-1842). Bootstrap callers keep the
536
+ * window fetch's default fail-soft mode.
537
+ */
538
+ export async function fetchSlackSeatCatchUp(req: SeatCatchUpRequest, deps: WakeBackscrollDeps = liveDeps): Promise<readonly NormalizedMessage[]> {
539
+ const latestTs = req.excludeId ?? (req.untilMs !== undefined ? String(req.untilMs / 1000) : String(Date.now() / 1000));
540
+ const strictDeps: WakeBackscrollDeps = {
541
+ ...deps,
542
+ request: async (method, params) => {
543
+ const res = await deps.request(method, params);
544
+ if (!res || res.ok !== true || !Array.isArray(res.messages)) {
545
+ throw new Error(`Slack catch-up ${method} failed`);
546
+ }
547
+ return res;
548
+ },
549
+ };
550
+ return fetchSlackBackscrollWindow(
551
+ { channelId: req.container, ts: latestTs, sinceMs: req.sinceMs, ...(req.threadRoot ? { threadRoot: req.threadRoot } : {}) },
552
+ strictDeps,
553
+ "throw",
554
+ );
555
+ }
556
+
557
+ /**
558
+ * A Slack `ts` is a second with a six-digit fraction, so it carries MILLISECOND grain (OPE-1847,
559
+ * `core/agent/channels/seat-catch-up.ts` {@link SeatCatchUpDeclaration.timestampPrecisionMs}).
560
+ * 🔴 ONE MILLISECOND IS NOT ZERO WIDENING. The road widens the LOWER bound by nothing here and the
561
+ * UPPER bound by exactly one millisecond, so a message stamped at the trigger's own instant is
562
+ * INSIDE the window and `excludeId` is the only thing that removes the trigger. That holds on this
563
+ * surface twice over: `conversations.history` is called with `inclusive: "false"`, so the trigger
564
+ * never reaches the fetch, and a message that did would carry its `ts` as its id — the same value
565
+ * the wake path passes as `excludeId`.
566
+ * 🔴 THE DECLARATION CANNOT EXPRESS THIS SURFACE'S SUB-MILLISECOND `ts`. The unit is a whole number
567
+ * of milliseconds, so the road's floor rounds Slack's fraction away. It costs nothing today: the
568
+ * bound is a bound, not an ordering, and the fetch orders by `ts` itself.
569
+ * 🔴 IT IS STILL DECLARED, and the parity test requires it. The declaration is what makes "this
570
+ * surface needs the finest unit" a CHECKED claim rather than an omission, and an omission is
571
+ * indistinguishable from the GitHub and Notion defect this whole mechanism exists to close.
572
+ */
573
+ export const SLACK_TIMESTAMP_PRECISION_MS = 1;
574
+
575
+ /** The registered form — the transport seam is fixed to the live one, so a caller cannot inject past
576
+ * the bot token. Tests drive {@link fetchSlackSeatCatchUp} with a canned requester instead. */
577
+ export const slackSeatCatchUpFetch: SeatCatchUpFetch = (req) => fetchSlackSeatCatchUp(req);
578
+
579
+ /**
580
+ * Fetch + cluster a standing seat's channel backscroll into session-tagged
581
+ * {@link WakeMessageInput} entries (oldest-first, no `trigger`; per-message trust via
582
+ * `trustFor` when the caller supplies the resolver, the fail-closed per-sender default otherwise).
583
+ * Thin orchestration over the Slack fetch seam ({@link fetchSlackBackscrollWindow}) + the neutral
584
+ * capped core ({@link fetchedBackscroll}), which applies the domain's `admit` policy. Serves the
585
+ * BOOTSTRAP window ONLY — a fresh generation's first wake, the one read with no cross-surface twin.
586
+ * Since OPE-1726 the since-last-wake CATCH-UP is not this function's job: it runs through
587
+ * {@link fetchSlackSeatCatchUp}, registered into the shared core path (core channels/seat-catch-up.ts
588
+ * `seatCatchUp`), which owns the bounds, the admission policy and the cap for every surface. This
589
+ * function still ACCEPTS `input.sinceMs`, but no caller sets it. FAIL-SOFT ABSOLUTELY.
590
+ */
591
+ export async function fetchWakeEnvelopeBackscroll(
592
+ input: WakeEnvelopeBackscrollInput,
593
+ deps: WakeBackscrollDeps = liveDeps,
594
+ trustFor?: BackscrollTrustFor,
595
+ admit?: SeatAdmissionFn,
596
+ ): Promise<WakeMessageInput[]> {
597
+ try {
598
+ const window = await fetchSlackBackscrollWindow(input, deps);
599
+ if (window.length === 0) return [];
600
+ return fetchedBackscroll(window, undefined, trustFor, admit);
601
+ } catch {
602
+ return []; // fail-soft: a wake never depends on its backscroll
603
+ }
604
+ }