@open-everywhere/provider-discord 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 (166) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/discord-grant-click.ts +237 -0
  5. package/agent/approvals/discord-admin.ts +227 -0
  6. package/agent/approvals/discord-card.ts +248 -0
  7. package/agent/approvals/discord-flow.ts +469 -0
  8. package/agent/approvals/discord-scope-label.ts +27 -0
  9. package/agent/channels/discord-approval-card.ts +373 -0
  10. package/agent/channels/discord-backscroll.ts +593 -0
  11. package/agent/channels/discord-bot-name.ts +113 -0
  12. package/agent/channels/discord-channel-info.ts +83 -0
  13. package/agent/channels/discord-directory.ts +179 -0
  14. package/agent/channels/discord-dispatch-target.ts +103 -0
  15. package/agent/channels/discord-events.ts +175 -0
  16. package/agent/channels/discord-front.ts +355 -0
  17. package/agent/channels/discord-gateway.ts +3256 -0
  18. package/agent/channels/discord-identity.ts +269 -0
  19. package/agent/channels/discord-interaction-defer.ts +123 -0
  20. package/agent/channels/discord-message-ops.ts +127 -0
  21. package/agent/channels/discord-origin-codec.ts +105 -0
  22. package/agent/channels/discord-post-arm.ts +230 -0
  23. package/agent/channels/discord-post.ts +463 -0
  24. package/agent/channels/discord-read.ts +140 -0
  25. package/agent/channels/discord-rest.ts +85 -0
  26. package/agent/channels/discord.ts +244 -0
  27. package/agent/commands/discord-command-sync.ts +285 -0
  28. package/agent/commands/discord-commands.ts +291 -0
  29. package/agent/contributions.ts +31 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/identity/discord-link-consent.ts +124 -0
  32. package/agent/ops.ts +14 -0
  33. package/agent/provider-events.ts +86 -0
  34. package/agent/register.ts +147 -0
  35. package/agent/surface-parity.ts +95 -0
  36. package/dist/agent/access/discord-grant-click.d.ts +25 -0
  37. package/dist/agent/access/discord-grant-click.d.ts.map +1 -0
  38. package/dist/agent/access/discord-grant-click.js +210 -0
  39. package/dist/agent/access/discord-grant-click.js.map +1 -0
  40. package/dist/agent/approvals/discord-admin.d.ts +63 -0
  41. package/dist/agent/approvals/discord-admin.d.ts.map +1 -0
  42. package/dist/agent/approvals/discord-admin.js +219 -0
  43. package/dist/agent/approvals/discord-admin.js.map +1 -0
  44. package/dist/agent/approvals/discord-card.d.ts +102 -0
  45. package/dist/agent/approvals/discord-card.d.ts.map +1 -0
  46. package/dist/agent/approvals/discord-card.js +217 -0
  47. package/dist/agent/approvals/discord-card.js.map +1 -0
  48. package/dist/agent/approvals/discord-flow.d.ts +109 -0
  49. package/dist/agent/approvals/discord-flow.d.ts.map +1 -0
  50. package/dist/agent/approvals/discord-flow.js +375 -0
  51. package/dist/agent/approvals/discord-flow.js.map +1 -0
  52. package/dist/agent/approvals/discord-scope-label.d.ts +3 -0
  53. package/dist/agent/approvals/discord-scope-label.d.ts.map +1 -0
  54. package/dist/agent/approvals/discord-scope-label.js +27 -0
  55. package/dist/agent/approvals/discord-scope-label.js.map +1 -0
  56. package/dist/agent/channels/discord-approval-card.d.ts +81 -0
  57. package/dist/agent/channels/discord-approval-card.d.ts.map +1 -0
  58. package/dist/agent/channels/discord-approval-card.js +303 -0
  59. package/dist/agent/channels/discord-approval-card.js.map +1 -0
  60. package/dist/agent/channels/discord-backscroll.d.ts +209 -0
  61. package/dist/agent/channels/discord-backscroll.d.ts.map +1 -0
  62. package/dist/agent/channels/discord-backscroll.js +505 -0
  63. package/dist/agent/channels/discord-backscroll.js.map +1 -0
  64. package/dist/agent/channels/discord-bot-name.d.ts +19 -0
  65. package/dist/agent/channels/discord-bot-name.d.ts.map +1 -0
  66. package/dist/agent/channels/discord-bot-name.js +98 -0
  67. package/dist/agent/channels/discord-bot-name.js.map +1 -0
  68. package/dist/agent/channels/discord-channel-info.d.ts +22 -0
  69. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -0
  70. package/dist/agent/channels/discord-channel-info.js +65 -0
  71. package/dist/agent/channels/discord-channel-info.js.map +1 -0
  72. package/dist/agent/channels/discord-directory.d.ts +11 -0
  73. package/dist/agent/channels/discord-directory.d.ts.map +1 -0
  74. package/dist/agent/channels/discord-directory.js +170 -0
  75. package/dist/agent/channels/discord-directory.js.map +1 -0
  76. package/dist/agent/channels/discord-dispatch-target.d.ts +15 -0
  77. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -0
  78. package/dist/agent/channels/discord-dispatch-target.js +99 -0
  79. package/dist/agent/channels/discord-dispatch-target.js.map +1 -0
  80. package/dist/agent/channels/discord-events.d.ts +58 -0
  81. package/dist/agent/channels/discord-events.d.ts.map +1 -0
  82. package/dist/agent/channels/discord-events.js +150 -0
  83. package/dist/agent/channels/discord-events.js.map +1 -0
  84. package/dist/agent/channels/discord-front.d.ts +115 -0
  85. package/dist/agent/channels/discord-front.d.ts.map +1 -0
  86. package/dist/agent/channels/discord-front.js +279 -0
  87. package/dist/agent/channels/discord-front.js.map +1 -0
  88. package/dist/agent/channels/discord-gateway.d.ts +783 -0
  89. package/dist/agent/channels/discord-gateway.d.ts.map +1 -0
  90. package/dist/agent/channels/discord-gateway.js +2591 -0
  91. package/dist/agent/channels/discord-gateway.js.map +1 -0
  92. package/dist/agent/channels/discord-identity.d.ts +110 -0
  93. package/dist/agent/channels/discord-identity.d.ts.map +1 -0
  94. package/dist/agent/channels/discord-identity.js +201 -0
  95. package/dist/agent/channels/discord-identity.js.map +1 -0
  96. package/dist/agent/channels/discord-interaction-defer.d.ts +33 -0
  97. package/dist/agent/channels/discord-interaction-defer.d.ts.map +1 -0
  98. package/dist/agent/channels/discord-interaction-defer.js +102 -0
  99. package/dist/agent/channels/discord-interaction-defer.js.map +1 -0
  100. package/dist/agent/channels/discord-message-ops.d.ts +3 -0
  101. package/dist/agent/channels/discord-message-ops.d.ts.map +1 -0
  102. package/dist/agent/channels/discord-message-ops.js +123 -0
  103. package/dist/agent/channels/discord-message-ops.js.map +1 -0
  104. package/dist/agent/channels/discord-origin-codec.d.ts +7 -0
  105. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -0
  106. package/dist/agent/channels/discord-origin-codec.js +98 -0
  107. package/dist/agent/channels/discord-origin-codec.js.map +1 -0
  108. package/dist/agent/channels/discord-post-arm.d.ts +3 -0
  109. package/dist/agent/channels/discord-post-arm.d.ts.map +1 -0
  110. package/dist/agent/channels/discord-post-arm.js +195 -0
  111. package/dist/agent/channels/discord-post-arm.js.map +1 -0
  112. package/dist/agent/channels/discord-post.d.ts +120 -0
  113. package/dist/agent/channels/discord-post.d.ts.map +1 -0
  114. package/dist/agent/channels/discord-post.js +426 -0
  115. package/dist/agent/channels/discord-post.js.map +1 -0
  116. package/dist/agent/channels/discord-read.d.ts +40 -0
  117. package/dist/agent/channels/discord-read.d.ts.map +1 -0
  118. package/dist/agent/channels/discord-read.js +128 -0
  119. package/dist/agent/channels/discord-read.js.map +1 -0
  120. package/dist/agent/channels/discord-rest.d.ts +33 -0
  121. package/dist/agent/channels/discord-rest.d.ts.map +1 -0
  122. package/dist/agent/channels/discord-rest.js +75 -0
  123. package/dist/agent/channels/discord-rest.js.map +1 -0
  124. package/dist/agent/channels/discord.d.ts +3 -0
  125. package/dist/agent/channels/discord.d.ts.map +1 -0
  126. package/dist/agent/channels/discord.js +230 -0
  127. package/dist/agent/channels/discord.js.map +1 -0
  128. package/dist/agent/commands/discord-command-sync.d.ts +66 -0
  129. package/dist/agent/commands/discord-command-sync.d.ts.map +1 -0
  130. package/dist/agent/commands/discord-command-sync.js +224 -0
  131. package/dist/agent/commands/discord-command-sync.js.map +1 -0
  132. package/dist/agent/commands/discord-commands.d.ts +45 -0
  133. package/dist/agent/commands/discord-commands.d.ts.map +1 -0
  134. package/dist/agent/commands/discord-commands.js +266 -0
  135. package/dist/agent/commands/discord-commands.js.map +1 -0
  136. package/dist/agent/contributions.d.ts +17 -0
  137. package/dist/agent/contributions.d.ts.map +1 -0
  138. package/dist/agent/contributions.js +31 -0
  139. package/dist/agent/contributions.js.map +1 -0
  140. package/dist/agent/docs-generated.d.ts +3 -0
  141. package/dist/agent/docs-generated.d.ts.map +1 -0
  142. package/dist/agent/docs-generated.js +13 -0
  143. package/dist/agent/docs-generated.js.map +1 -0
  144. package/dist/agent/identity/discord-link-consent.d.ts +13 -0
  145. package/dist/agent/identity/discord-link-consent.d.ts.map +1 -0
  146. package/dist/agent/identity/discord-link-consent.js +111 -0
  147. package/dist/agent/identity/discord-link-consent.js.map +1 -0
  148. package/dist/agent/ops.d.ts +2 -0
  149. package/dist/agent/ops.d.ts.map +1 -0
  150. package/dist/agent/ops.js +13 -0
  151. package/dist/agent/ops.js.map +1 -0
  152. package/dist/agent/provider-events.d.ts +3 -0
  153. package/dist/agent/provider-events.d.ts.map +1 -0
  154. package/dist/agent/provider-events.js +78 -0
  155. package/dist/agent/provider-events.js.map +1 -0
  156. package/dist/agent/register.d.ts +5 -0
  157. package/dist/agent/register.d.ts.map +1 -0
  158. package/dist/agent/register.js +125 -0
  159. package/dist/agent/register.js.map +1 -0
  160. package/dist/agent/surface-parity.d.ts +3 -0
  161. package/dist/agent/surface-parity.d.ts.map +1 -0
  162. package/dist/agent/surface-parity.js +90 -0
  163. package/dist/agent/surface-parity.js.map +1 -0
  164. package/package.json +185 -0
  165. package/tsconfig.build.json +22 -0
  166. package/tsconfig.json +15 -0
@@ -0,0 +1,593 @@
1
+ // Wake-time conversation backscroll for Discord — the parity twin of slack-wake-backscroll.ts.
2
+ //
3
+ // Discord's name-drop WAKE and @mention paths (discord-gateway.ts) used to hand the model ONLY
4
+ // the triggering message: a cold name-drop mid-thread arrived with no idea what's being
5
+ // discussed, and a re-wake after auto-disengage resumed the same session with a GAP for
6
+ // everything said while the bot was away. This module closes both by fetching recent history at
7
+ // wake time and attaching it as the SAME untrusted-wrapped `context` strings the Slack paths use
8
+ // (eve appends each as a user message before the delivery turn — SendPayload.context).
9
+ //
10
+ // SIMPLER THAN SLACK: Discord threads are their OWN channel ids, so ONE endpoint —
11
+ // `GET /channels/{channelId}/messages` — covers both a base channel and a thread (no
12
+ // history/replies split). We always read strictly OLDER than the trigger via `?before=<id>`
13
+ // (Discord returns newest-first; we reverse to oldest-first). For an in-THREAD wake we then slice
14
+ // to "since the agent last spoke" (shared sinceLastAgentReply) — a resumed thread session already
15
+ // holds everything up to its last reply; for a BASE channel we keep the recent tail (last N).
16
+ //
17
+ // AUTH: reuses the Discord bot token exactly as discord-gateway.ts's REST calls do (`Bot <token>`
18
+ // header) — no new auth path. The bot's OWN author id (msg.botId, forwarded by the relay) both
19
+ // labels the agent's prior posts as `sender_type=agent` AND anchors the since-last-agent slice;
20
+ // absent (older relay) it simply degrades (own posts read as a bot, no slice boundary) — never a
21
+ // crash. The requester is injected so the fetcher unit-tests offline with NO network.
22
+ //
23
+ // RENDER REUSE: the ContextLine shape, the render + UNTRUSTED wrap, the caps, and the
24
+ // since-last-agent slice all come from the shared context-render.ts — the SAME renderer Slack
25
+ // uses — so Discord's attribution + untrusted framing are byte-identical to Slack's without
26
+ // cross-importing a slack-named file. The untrusted wrap is a SECURITY invariant (channel content
27
+ // is DATA, never instructions), so it must never diverge between surfaces. NO read-more hint:
28
+ // Discord exposes no read_discord_* tools today, so naming a "fetch more" tool would be a lie —
29
+ // the bounded backscroll (the actual win) is attached regardless.
30
+ //
31
+ // BOOTSTRAP BACKSCROLL is fail-soft: any error yields no context and never blocks the first wake.
32
+ // SEAT CATCH-UP propagates the same read failure to core's shared fail-soft boundary, so the wake
33
+ // still continues but its `seat.catch_up` row says `failed` rather than `empty` (OPE-1842).
34
+ //
35
+ // TWO MECHANISMS LIVE IN THIS FILE (both wake-safe, both Discord-specific fetch adapters):
36
+ // 1. fetchDiscordBackscrollLines — the FLAT name-drop/@mention enrichment above: raw history →
37
+ // context-render.ts ContextLine. BOTH Discord wake dispatch paths call it directly and grade
38
+ // the RAW lines with gradeDiscordContextLines (OPE-475) before rendering: the gateway's NATIVE
39
+ // dispatch hands the graded lines to the shared renderWake as the NormalizedWake's `contextLines`
40
+ // (W3 — renderWake does the untrusted-wrap); the SINGLE-WINDOW divert path renders them itself
41
+ // via the shared renderContext (discord-gateway.ts `discordSeatContextForWindow`) — a
42
+ // pre-rendered `context` string, but sourced from the SAME graded lines the join reads via
43
+ // contextLineIngestion, so the join can never carry a line the render's own source set did
44
+ // not. 🔴 THAT IS NOT AN EQUALITY OF COUNTS: renderContext applies its own MAX_CHARS bound and
45
+ // skips a line whose text trims empty, so the render can keep FEWER lines than the join marks.
46
+ // The direction over-taints, which is the safe one.
47
+ // 🔴 BOTH PATHS TAKE THE DOMAIN'S ADMISSION POLICY, and the SEAT decides, never the road
48
+ // (OPE-1800, discord-gateway.ts `admitDiscordSeatLines`). The policy runs wherever
49
+ // `grainUsesUnifiedWindow` holds for the room — which is the predicate Slack resolves `seated`
50
+ // from — so a wake that falls through to the native arm because its seat STOOD DOWN (the guild
51
+ // vouch absent) is policed too. A room with no seat at all keeps every graded line.
52
+ // 2. fetchDiscordBackscrollWindow / fetchDiscordWakeEnvelopeBackscroll (added below) — the DROP-IN
53
+ // fetch adapter behind the platform-NEUTRAL gap-clustering/bounds core (backscroll-window.ts),
54
+ // the exact Slack shape (slack-wake-backscroll.ts fetchSlackBackscrollWindow /
55
+ // fetchWakeEnvelopeBackscroll): raw history → visibility-gated NormalizedMessage[] →
56
+ // fetchedBackscroll → session-tagged WakeMessageInput[]. This fulfils the neutral core's
57
+ // "Discord drop-in" seam, and it is LIVE (OPE-1726): `discordSeatCatchUpFetch` below registers it
58
+ // as this surface's SEAT CATCH-UP fetch, so every wake on an ongoing generation reads the channel
59
+ // since the seat's own last wake and renders the result as the wake envelope's `<session>`
60
+ // siblings (discord-gateway.ts `divertDiscordWakeToUnifiedWindow`). The BOOTSTRAP half — a fresh
61
+ // generation's first wake — still carries the recorded tail rather than a platform fetch.
62
+ // The flat path (1) continues to serve the mention/name-drop hydration on every Discord wake.
63
+
64
+ import {
65
+ sinceLastAgentReply,
66
+ WAKE_BACKSCROLL_MAX,
67
+ fetchedBackscroll,
68
+ resolveWakeTrustFromContext,
69
+ armedAuthorTrustTenants,
70
+ catchUpSender,
71
+ type ContextLine,
72
+ type NormalizedMessage,
73
+ type BackscrollTrustFor,
74
+ type SeatCatchUpFetch,
75
+ type SeatCatchUpRequest,
76
+ type WakeMessageInput,
77
+ type BindableContext,
78
+ type AuthorTrustQuery,
79
+ } from "@open-everywhere/core/agent/adapter-api.js";
80
+ import { discordAuthorRoles } from "../approvals/discord-admin.js";
81
+
82
+ /** The triggering wake, narrowed to what the backscroll needs. */
83
+ export interface DiscordBackscrollInput {
84
+ /** The surface to READ — the channel/thread the human's message is IN (msg.channelId), NOT the
85
+ * re-keyed reply thread. A Discord thread is its own channel id, so this is one endpoint. */
86
+ readonly channelId: string;
87
+ /** The triggering message's id — we read strictly OLDER than this (`?before=`). */
88
+ readonly triggerMessageId: string;
89
+ /** True when `channelId` is a thread (types 10/11/12): apply the since-last-agent slice. A base
90
+ * channel keeps the recent tail (last N) instead — mirrors Slack's replies/history split. */
91
+ readonly isThread: boolean;
92
+ /** The bot's OWN Discord author id (relay `botId`) — labels agent lines + anchors the slice.
93
+ * Absent (older relay) → own posts read as a bot and there's no slice boundary (fail-soft). */
94
+ readonly botId?: string;
95
+ }
96
+
97
+ /** A Discord REST caller: GETs `/channels/{channelId}/messages?<query>`, returns parsed JSON
98
+ * (an array of message objects) or undefined on ANY failure (fail-soft). Injected so the fetcher
99
+ * runs without a network in tests but defaults to the live bot-token call. */
100
+ export type DiscordRequester = (channelId: string, query: Record<string, string>) => Promise<unknown>;
101
+
102
+ export interface DiscordBackscrollDeps {
103
+ readonly request: DiscordRequester;
104
+ }
105
+
106
+ // Direct bot-token GET to the Discord REST API. Mirrors discord-gateway.ts's own fetch calls
107
+ // (`Bot <token>` header; the token is a SECRET — never logged). Fail-soft: no token / non-ok /
108
+ // any throw → undefined. Kept local rather than imported from discord-gateway.ts so the module
109
+ // graph stays a DAG (discord-gateway → here; importing back up would cycle).
110
+ const liveRequest: DiscordRequester = async (channelId, query) => {
111
+ const token = process.env.DISCORD_BOT_TOKEN;
112
+ if (!token || !channelId) return undefined;
113
+ try {
114
+ const qs = new URLSearchParams(query).toString();
115
+ const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?${qs}`, {
116
+ headers: { authorization: `Bot ${token}` },
117
+ });
118
+ if (!res.ok) return undefined;
119
+ return await res.json();
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ };
124
+
125
+ const liveDeps: DiscordBackscrollDeps = { request: liveRequest };
126
+
127
+ // Discord message `type`s we keep as ordinary conversation: 0 = DEFAULT, 19 = REPLY. Everything
128
+ // else (7 member-join, 6 pin, channel-name-change, thread-created, …) is system noise, dropped
129
+ // like Slack's `subtype` filter. A message with no/other type is treated conservatively as noise.
130
+ function isConversationType(type: unknown): boolean {
131
+ return type === 0 || type === 19;
132
+ }
133
+
134
+ /**
135
+ * Map raw Discord message objects (`GET /channels/{id}/messages` — raw REST JSON, probe
136
+ * defensively) into {@link ContextLine}s, dropping system-type + empty-content messages. `botId`
137
+ * is the agent's own author id: a message by it is labelled `agent` (Discord uses one `author.id`
138
+ * for everyone — bots included — so we compare ids, same idea as Slack comparing bot_id/user_id).
139
+ * Order is PRESERVED — the caller reverses to oldest-first before rendering. Pure. Exported for
140
+ * the test oracle. Kept HERE (not shared): Discord's message JSON is shaped differently from
141
+ * Slack's, so the mapping is genuinely platform-specific; only the ContextLine target + renderer
142
+ * are shared (context-render.ts).
143
+ */
144
+ export function linesFromDiscordMessages(raw: ReadonlyArray<Record<string, unknown>>, botId?: string): ContextLine[] {
145
+ const lines: ContextLine[] = [];
146
+ for (const m of raw) {
147
+ if (!isConversationType(m.type)) continue; // system noise (joins, pins, thread-created, …)
148
+ const text = typeof m.content === "string" ? m.content : "";
149
+ if (!text) continue; // empty content (Message Content Intent off, or an embed-only post)
150
+ const author = typeof m.author === "object" && m.author !== null ? (m.author as Record<string, unknown>) : undefined;
151
+ const authorId = author && typeof author.id === "string" ? author.id : undefined;
152
+ const isBot = author?.bot === true;
153
+ const id = typeof m.id === "string" ? m.id : "";
154
+ if (botId !== undefined && authorId === botId) lines.push({ senderType: "agent", ts: id, text });
155
+ else if (isBot && authorId) lines.push({ senderType: "bot", senderId: authorId, ts: id, text });
156
+ else if (authorId) lines.push({ senderType: "user", senderId: authorId, ts: id, text });
157
+ else lines.push({ senderType: "unknown", ts: id, text });
158
+ }
159
+ return lines;
160
+ }
161
+
162
+ /** Resolves a sibling author's real guild roles for the register query below — injected so the
163
+ * grading step is unit-testable offline. Live wiring: {@link discordAuthorRoles}. `undefined` =
164
+ * cannot check (never a guess); a real (possibly empty) array = verified role evidence. */
165
+ export type DiscordRoleProbe = (guildId: string, userId: string) => Promise<readonly string[] | undefined>;
166
+
167
+ /**
168
+ * PER-LINE trust for a mention/name-drop backscroll's {@link ContextLine}s (OPE-475) — the Discord
169
+ * analogue of Slack's regrade step, converging both backscroll shapes on ONE trust story rather than
170
+ * a second mechanism. Two additive facts, mirroring exactly what the TRIGGER already carries
171
+ * (discord-gateway.ts `resolveWakeTrustFromContext` + `authorFacts`), resolved per SIBLING:
172
+ *
173
+ * `trust` — {@link resolveWakeTrustFromContext} off the SAME binding the trigger reads, with
174
+ * `principalFallback` OFF (a sibling has its own author — see that function's own
175
+ * per-message-resolution note). Read by the RENDER. Resolved for every `user` line,
176
+ * unconditionally — no I/O, no gate.
177
+ * `authorTrust` — the OPERATOR's author-trust register query (identity/author-trust.ts, OPE-516):
178
+ * Discord's ONLY per-author refutation reachable here. `member.pending` (the
179
+ * trigger's own signal) is NOT available: it rides the gateway relay's MESSAGE_CREATE
180
+ * member object, and Discord's documented Message object (the shape
181
+ * `GET /channels/{id}/messages` — what this backscroll fetch reads — returns) has
182
+ * no `member` field at all. Read ONLY by the taint join.
183
+ *
184
+ * 🔴 THE REGISTER QUERY IS BUILT FROM VERIFIED ROLE EVIDENCE, NEVER FROM AN UNCHECKED GUESS (fixed
185
+ * after a caught bug: an earlier revision hardcoded `memberships: []` on EVERY sibling — on a guild
186
+ * armed the documented-usual way, `{ membership: "discord/<g>/role:*" }`, that made EVERY sibling
187
+ * look role-less and therefore `external`, so a mention wake that hydrated ordinary colleagues'
188
+ * messages marked the taint bit on a role-armed guild — the always-on-bit failure this whole plane
189
+ * exists to avoid). So the role fetch is COST-GATED and FAIL-OPEN, in that order:
190
+ *
191
+ * 1. UNARMED tenant (the common case — `armedAuthorTrustTenants()` doesn't list `discord/<guild>`):
192
+ * no register query is even worth building, so NO role fetch runs at all. Zero new I/O on the
193
+ * default posture.
194
+ * 2. ARMED tenant: each DISTINCT candidate author (a `user` line with a `senderId`) gets ONE
195
+ * {@link DiscordRoleProbe} call (TTL-cached — {@link discordAuthorRoles}), mirroring exactly how
196
+ * {@link regradeSlackContextLineTrust}'s probe is one call per distinct author, not per message.
197
+ * 3. The probe's THREE outcomes are kept apart, never collapsed to a boolean: a real (possibly
198
+ * empty, on a verified 404) roles array builds the SAME group-rung `{tenantKey}/role:<id>` keys
199
+ * the trigger's own `authorFacts` builds from `msg.memberRoles`; `undefined` (no token, a
200
+ * network error, an unreadable body) means "cannot check", and that line gets NO `authorTrust`
201
+ * at all — SKIPPED, never an unverified `[]`. "Cannot check" is not "checked and empty".
202
+ *
203
+ * 🔴 THE REGISTER LEG NEVER DEMOTES `trust` — it matches the trigger, and access/taint-signal.ts's own
204
+ * design: the register is a JOIN-ONLY refutation, decoupled from the render (see `nonMemberChatTrust`).
205
+ * So a register-refuted Discord backscroll sibling marks the turn but does not render the reference-only
206
+ * note — an honest limit of what this endpoint can tell the model, not a shortfall against Slack's
207
+ * parity (Slack's refutation is a SURFACE fact and demotes the render; Discord has none here).
208
+ *
209
+ * `agent`/`bot`/`unknown` lines are left untouched — machine or unattributable authors are not this
210
+ * leg's question.
211
+ */
212
+ export async function gradeDiscordContextLines(
213
+ lines: readonly ContextLine[],
214
+ ctx: BindableContext,
215
+ guildId: string | undefined,
216
+ roleProbe: DiscordRoleProbe = discordAuthorRoles,
217
+ ): Promise<ContextLine[]> {
218
+ const withTrust = lines.map((l): ContextLine =>
219
+ l.senderType !== "user" ? l : { ...l, trust: resolveWakeTrustFromContext(ctx, { id: l.senderId, isBot: false }) },
220
+ );
221
+ const tenantKey = guildId !== undefined ? `discord/${guildId}` : undefined;
222
+ // COST GATE: an unarmed tenant's register leg contributes nothing (identity/author-trust.ts —
223
+ // silence is not a declaration), so there is nothing worth a role fetch for. Zero new I/O here is
224
+ // the default posture, not an edge case.
225
+ if (tenantKey === undefined || !armedAuthorTrustTenants().includes(tenantKey)) return withTrust;
226
+
227
+ const candidates = new Set<string>();
228
+ for (const l of withTrust) {
229
+ if (l.senderType === "user" && l.senderId !== undefined) candidates.add(l.senderId);
230
+ }
231
+ if (candidates.size === 0) return withTrust;
232
+ const ids = [...candidates];
233
+ const roleResults = await Promise.all(ids.map((id) => roleProbe(guildId!, id)));
234
+ const rolesById = new Map(ids.map((id, i) => [id, roleResults[i]] as const));
235
+
236
+ return withTrust.map((l) => {
237
+ if (l.senderType !== "user" || l.senderId === undefined) return l;
238
+ const roles = rolesById.get(l.senderId);
239
+ if (roles === undefined) return l; // cannot check — SKIP the register query for this line entirely
240
+ const authorTrust: AuthorTrustQuery = {
241
+ tenantKey,
242
+ authorId: l.senderId,
243
+ memberships: roles.map((r) => `${tenantKey}/role:${r}`),
244
+ };
245
+ return { ...l, authorTrust };
246
+ });
247
+ }
248
+
249
+ // Pull the message array out of a REST response, or undefined (fail-soft on junk / non-array).
250
+ function extractDiscordMessages(res: unknown): Array<Record<string, unknown>> | undefined {
251
+ if (!Array.isArray(res)) return undefined;
252
+ return res as Array<Record<string, unknown>>;
253
+ }
254
+
255
+ /**
256
+ * Fetch recent conversation history for a Discord wake/mention as RAW, oldest-first
257
+ * {@link ContextLine}s, capped at {@link WAKE_BACKSCROLL_MAX}. `undefined` = nothing to add OR any
258
+ * failure — always non-fatal. Both Discord wake dispatch paths (discord-gateway.ts, native and
259
+ * single-window divert) call this directly, grade the lines with gradeDiscordContextLines
260
+ * (OPE-475), and render them via the shared renderContext THEMSELVES — no pre-rendered string is
261
+ * built inside this module.
262
+ *
263
+ * One endpoint (`GET /channels/{channelId}/messages?limit&before`) for both cases:
264
+ * - IN-THREAD (`isThread`): strictly-older messages, then sliced to "since the agent's last
265
+ * reply" (a resumed thread session already holds everything up to its last reply).
266
+ * - BASE CHANNEL: strictly-older messages, most recent N.
267
+ */
268
+ export async function fetchDiscordBackscrollLines(
269
+ input: DiscordBackscrollInput,
270
+ deps: DiscordBackscrollDeps = liveDeps,
271
+ ): Promise<{ readonly lines: ContextLine[]; readonly source: string } | undefined> {
272
+ try {
273
+ const res = await deps.request(input.channelId, {
274
+ limit: String(WAKE_BACKSCROLL_MAX),
275
+ // Strictly OLDER than the trigger — the trigger's text already rides on the wake message.
276
+ before: input.triggerMessageId,
277
+ });
278
+ const raw = extractDiscordMessages(res);
279
+ if (!raw) return undefined;
280
+ // Discord returns newest-first → reverse to oldest-first for the slice + render.
281
+ const ordered = [...raw].reverse();
282
+ let lines = linesFromDiscordMessages(ordered, input.botId);
283
+ // In a thread, keep only what followed the agent's last reply (the disengage/re-wake gap, or
284
+ // "someone answered while I was away"); a base channel keeps the recent tail via the N-cap.
285
+ if (input.isThread) lines = sinceLastAgentReply(lines);
286
+ const capped = lines.slice(-WAKE_BACKSCROLL_MAX);
287
+ if (capped.length === 0) return undefined;
288
+ return { lines: capped, source: `discord ${input.isThread ? "thread" : "channel"} ${input.channelId}` };
289
+ } catch {
290
+ return undefined; // fail-soft: a wake never depends on its backscroll
291
+ }
292
+ }
293
+
294
+
295
+ // --- neutral-core first-wake backscroll (supervisor context hydration) — DISCORD ADAPTER -----
296
+ //
297
+ // THIS IS THE DISCORD FETCH ADAPTER behind the platform-NEUTRAL clustering/bounds core
298
+ // (backscroll-window.ts), the mirror of slack-wake-backscroll.ts's fetchSlackBackscrollWindow.
299
+ // Its job: turn `GET /channels/{id}/messages` into a visibility-GATED {@link NormalizedMessage}[]
300
+ // window, then hand it to {@link fetchedBackscroll}, which caps it most-recent-first and
301
+ // spine/bolt + count/time/token bounds and emits session-tagged {@link WakeMessageInput}[] the wake
302
+ // envelope renders as `<session>` groups.
303
+ //
304
+ // SIMPLER THAN SLACK (again): a Discord thread is its own channel id, so the SAME single endpoint
305
+ // covers a base channel AND a thread — there is no conversations.history/replies split and thus no
306
+ // bounded thread-reply fan-out to mirror (a base-channel window here does NOT reach into the
307
+ // replies of threads hanging off it; that's the deliberate one-endpoint scope, matching the flat
308
+ // path). FAIL-SOFT ABSOLUTELY throughout, exactly like {@link fetchDiscordBackscrollLines}.
309
+
310
+ /** Reachback (hours) for the raw Discord window: how far back from the trigger we paginate. Shares
311
+ * the SAME env + 7d default as the Slack adapter (slack-wake-backscroll.ts RAW_FETCH_REACHBACK_HOURS)
312
+ * — it is one supervisor-backscroll concept, kept in sync by reading the same knob. Generous enough
313
+ * that the spine (the most-recent cluster, exempt from the 36h enrichment cap) still spans a
314
+ * weekend/overnight gap, yet a hard floor bounding the fetch. */
315
+ export const DISCORD_RAW_FETCH_REACHBACK_HOURS = Number(process.env.OE_SUPERVISOR_BACKSCROLL_REACHBACK_HOURS) || 168;
316
+
317
+ /** `GET /channels/{id}/messages` page size (Discord's max is 100). */
318
+ const DISCORD_RAW_FETCH_PAGE_LIMIT = 100;
319
+
320
+ /** Max history pages to paginate — bounds a busy channel's fetch at ≤300 messages so a burst can't
321
+ * fan out into an unbounded loop. Combined with the reachback floor (whichever binds first). */
322
+ const DISCORD_RAW_FETCH_MAX_PAGES = 3;
323
+
324
+ /** Discord's epoch (2015-01-01T00:00:00Z, ms) — the offset baked into every snowflake id. */
325
+ const DISCORD_EPOCH_MS = 1_420_070_400_000;
326
+
327
+ /**
328
+ * The creation time (epoch ms) encoded in a Discord snowflake id: the top 42 bits are
329
+ * `(unix_ms - DISCORD_EPOCH_MS)`. Robust — every message id carries it, so we can derive a time even
330
+ * when the raw `timestamp` field is absent/garbled. Returns undefined for a non-numeric / non-positive
331
+ * id (fail-soft; the caller drops that message from the window rather than guessing a time).
332
+ */
333
+ export function snowflakeToMs(id: string): number | undefined {
334
+ try {
335
+ const n = BigInt(id);
336
+ if (n <= 0n) return undefined;
337
+ return Number(n >> 22n) + DISCORD_EPOCH_MS;
338
+ } catch {
339
+ return undefined; // not an integer id → no derivable time
340
+ }
341
+ }
342
+
343
+ /**
344
+ * The INVERSE of {@link snowflakeToMs}: the smallest snowflake whose encoded time is `ms`, for a
345
+ * `before=` anchor when the caller states a TIME and holds no message id (the catch-up fetch with no
346
+ * trigger). The low 22 bits (worker, process, sequence) are zero, which is exactly what "the
347
+ * beginning of this millisecond" means to Discord's own ordering. A time at or before Discord's epoch
348
+ * clamps to `1` — the smallest usable anchor — rather than producing a negative id.
349
+ */
350
+ function msToSnowflake(ms: number): string {
351
+ const delta = Math.max(0, Math.floor(ms) - DISCORD_EPOCH_MS);
352
+ const id = BigInt(delta) << 22n;
353
+ return (id > 0n ? id : 1n).toString();
354
+ }
355
+
356
+ /** The message's epoch ms: prefer the canonical ISO `timestamp` field, else decode the snowflake
357
+ * `id` (both present on a REST message object). undefined ⇒ neither yielded a finite time. */
358
+ function messageMs(m: Record<string, unknown>, id: string): number | undefined {
359
+ const iso = typeof m.timestamp === "string" ? Date.parse(m.timestamp) : Number.NaN;
360
+ if (Number.isFinite(iso)) return iso;
361
+ return snowflakeToMs(id);
362
+ }
363
+
364
+ /**
365
+ * The VISIBILITY / TRUST GATE (the load-bearing SECURITY filter) — the Discord parity of
366
+ * slack-wake-backscroll.ts isBackscrollVisible, kept LOCAL (not imported from a slack-named file, the
367
+ * same DAG discipline as linesFromDiscordMessages). Backscroll must never surface a message it can't
368
+ * ATTRIBUTE. KEEPS: the bot's OWN prior posts (`agent` → `self`); identifiable humans (`user` →
369
+ * `human`); ATTRIBUTABLE foreign bots (`bot` WITH a bot id → `bot`) as MARKED AWARENESS (clearly
370
+ * bot-flagged, CONTEXT-ONLY, never a wake trigger — dropping them would break conversational
371
+ * coherence when a human replies to a bot post). DROPS ONLY unattributable senders (`unknown`, or a
372
+ * `bot` with no id) — no auth'd author ⇒ fail closed. (System-subtype / empty-content noise is already
373
+ * dropped upstream by linesFromDiscordMessages.) The neutral core never sets `trigger` on backscroll,
374
+ * and the gateway's live wake gate still drops bot authors as triggers, so a bot post here is
375
+ * awareness, never a response obligation. */
376
+ export function isDiscordBackscrollVisible(line: ContextLine): boolean {
377
+ return (
378
+ line.senderType === "agent" ||
379
+ line.senderType === "user" ||
380
+ (line.senderType === "bot" && line.senderId !== undefined) // attributable foreign bot: marked awareness
381
+ );
382
+ }
383
+
384
+ /**
385
+ * Normalize ONE raw Discord message → {@link NormalizedMessage}, reusing {@link linesFromDiscordMessages}
386
+ * for the exact sender classification + system/empty drop, then applying the VISIBILITY GATE and
387
+ * resolving the epoch ms. Returns undefined for a dropped (invisible / system / empty / timeless)
388
+ * message. `type`/`threadId` describe the surface the window was read from (a Discord thread is its
389
+ * own channel id, so a thread window tags every message `thread` with the thread's id as the root).
390
+ */
391
+ function normalizeDiscordMessage(
392
+ m: Record<string, unknown>,
393
+ botId: string | undefined,
394
+ type: "channel" | "thread",
395
+ threadId: string | undefined,
396
+ ): NormalizedMessage | undefined {
397
+ const [line] = linesFromDiscordMessages([m], botId);
398
+ if (!line) return undefined; // system type / empty content (dropped upstream)
399
+ if (!isDiscordBackscrollVisible(line)) return undefined; // VISIBILITY GATE (unattributable sender only)
400
+ const ms = messageMs(m, line.ts);
401
+ if (ms === undefined) return undefined; // no derivable time → can't cluster it, drop
402
+ return {
403
+ id: line.ts,
404
+ ms,
405
+ // undefined for the bot's OWN posts (agent); the foreign bot's id for a `bot`; the user id for a
406
+ // human — kept raw for @-tagging / attribution.
407
+ authorId: line.senderId,
408
+ // THE SHARED SENDER RULE (OPE-1857): agent → self (our own post); bot → bot (attributable
409
+ // foreign bot, marked awareness); every other visible line is a `user` → human. `unknown` was
410
+ // already dropped by the gate above.
411
+ sender: catchUpSender({ self: line.senderType === "agent", bot: line.senderType === "bot" }),
412
+ type,
413
+ threadId,
414
+ text: line.text,
415
+ };
416
+ }
417
+
418
+ /** Paginate `GET /channels/{id}/messages` backward from `before` (exclusive), bounded by
419
+ * {@link DISCORD_RAW_FETCH_MAX_PAGES} AND the reachback floor (`oldestMs`, whichever binds first).
420
+ * Discord returns each page newest-first; the next page anchors on the page's OLDEST id. Returns the
421
+ * raw messages (newest-first across pages — the neutral core sorts by ms anyway). Fail-soft: a
422
+ * junk/empty page ends pagination with whatever's in hand. */
423
+ async function fetchDiscordWindow(
424
+ request: DiscordRequester,
425
+ channelId: string,
426
+ triggerMessageId: string,
427
+ oldestMs: number,
428
+ ): Promise<Array<Record<string, unknown>>> {
429
+ const all: Array<Record<string, unknown>> = [];
430
+ let before = triggerMessageId;
431
+ for (let page = 0; page < DISCORD_RAW_FETCH_MAX_PAGES; page++) {
432
+ const res = await request(channelId, { limit: String(DISCORD_RAW_FETCH_PAGE_LIMIT), before });
433
+ const msgs = extractDiscordMessages(res);
434
+ if (!msgs || msgs.length === 0) break;
435
+ all.push(...msgs);
436
+ const oldest = msgs[msgs.length - 1]; // Discord newest-first → last is the oldest in this page
437
+ const oldestId = typeof oldest.id === "string" ? oldest.id : undefined;
438
+ if (!oldestId) break;
439
+ const oldestMsThisPage = messageMs(oldest, oldestId);
440
+ if (oldestMsThisPage !== undefined && oldestMsThisPage < oldestMs) break; // paged past the floor
441
+ if (msgs.length < DISCORD_RAW_FETCH_PAGE_LIMIT) break; // short page ⇒ no more history
442
+ before = oldestId; // next page: strictly older than this page's oldest
443
+ }
444
+ return all;
445
+ }
446
+
447
+ /** The triggering first wake, narrowed to what the neutral-core window needs (mirrors Slack's
448
+ * {@link import("./slack-wake-backscroll.js").WakeEnvelopeBackscrollInput}). */
449
+ export interface DiscordBackscrollWindowInput {
450
+ /** The surface to READ — the channel/thread the trigger is IN (a Discord thread is its own id). */
451
+ readonly channelId: string;
452
+ /** The triggering message's id — the window's upper (exclusive) bound (`before=`) and, decoded to
453
+ * ms, the reachback origin the core clusters against. */
454
+ readonly triggerMessageId: string;
455
+ /** True when `channelId` is a thread (types 10/11/12) — every message is then tagged `thread`. */
456
+ readonly isThread: boolean;
457
+ /** The bot's OWN Discord author id (relay `botId`) — labels its own posts `self`. Absent (older
458
+ * relay) → own posts read as an attributable bot (still visible), never a crash. */
459
+ readonly botId?: string;
460
+ /** Hours to look back from the trigger for the raw fetch. Defaults to
461
+ * {@link DISCORD_RAW_FETCH_REACHBACK_HOURS}. */
462
+ readonly reachbackHours?: number;
463
+ /**
464
+ * THE CATCH-UP BOUND (OPE-1726, the Slack seam's exact field): epoch milliseconds of the seat's own
465
+ * LAST WAKE, so this read returns what the channel said since the seat last looked. It only ever
466
+ * NARROWS the window — the reachback floor above still applies — so a seat quiet for a month reads
467
+ * the recent tail, and a seat with no recorded wake reads the bootstrap window unchanged.
468
+ */
469
+ readonly sinceMs?: number;
470
+ }
471
+
472
+ /**
473
+ * DISCORD FETCH SEAM: fetch a bounded, visibility-GATED, NORMALIZED backscroll window for a first
474
+ * wake. This is the platform boundary — the neutral core ({@link fetchedBackscroll}) consumes its
475
+ * output. FAIL-SOFT: any error/failure yields `[]`. Deduped by message id.
476
+ */
477
+ export async function fetchDiscordBackscrollWindow(
478
+ input: DiscordBackscrollWindowInput,
479
+ deps: DiscordBackscrollDeps = liveDeps,
480
+ failureMode: "soft" | "throw" = "soft",
481
+ ): Promise<NormalizedMessage[]> {
482
+ try {
483
+ const triggerMs = snowflakeToMs(input.triggerMessageId);
484
+ const reachbackHours = input.reachbackHours ?? DISCORD_RAW_FETCH_REACHBACK_HOURS;
485
+ // Reachback origin = the trigger's own time when derivable, else now (fail-soft — a bad trigger
486
+ // id still yields a sane recent window rather than nothing).
487
+ const originMs = triggerMs ?? Date.now();
488
+ // The reachback floor, then the seat's own last wake where it is LATER — never earlier, so the
489
+ // catch-up can only narrow the fetch (see {@link DiscordBackscrollWindowInput.sinceMs}).
490
+ const floorMs = originMs - reachbackHours * 3_600_000;
491
+ const oldestMs = typeof input.sinceMs === "number" && Number.isFinite(input.sinceMs) ? Math.max(floorMs, input.sinceMs) : floorMs;
492
+
493
+ const raw = await fetchDiscordWindow(deps.request, input.channelId, input.triggerMessageId, oldestMs);
494
+ if (raw.length === 0) return [];
495
+
496
+ const type: "channel" | "thread" = input.isThread ? "thread" : "channel";
497
+ const threadId = input.isThread ? input.channelId : undefined;
498
+ const seen = new Set<string>();
499
+ const out: NormalizedMessage[] = [];
500
+ for (const m of raw) {
501
+ const n = normalizeDiscordMessage(m, input.botId, type, threadId);
502
+ if (!n) continue;
503
+ if (n.ms < oldestMs) continue; // outside the reachback window
504
+ if (seen.has(n.id)) continue; // dedupe (paging overlap / repeats)
505
+ seen.add(n.id);
506
+ out.push(n);
507
+ }
508
+ return out;
509
+ } catch (err) {
510
+ if (failureMode === "throw") throw err;
511
+ return []; // fail-soft: a first wake never depends on its backscroll
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Fetch + cluster a Discord first-wake backscroll into session-tagged {@link WakeMessageInput}
517
+ * entries (oldest-first, no `trigger`). Thin orchestration over the Discord fetch seam
518
+ * ({@link fetchDiscordBackscrollWindow}) + the neutral clustering/bounds core
519
+ * ({@link fetchedBackscroll}) — the exact parity of slack-wake-backscroll.ts
520
+ * fetchWakeEnvelopeBackscroll. FAIL-SOFT ABSOLUTELY.
521
+ *
522
+ * Per-message trust mirrors the Slack seam: pass a `trustFor` resolver (build it with
523
+ * identity/wake-trust.ts `backscrollTrustResolver`, off the confirmInternal verdict + domain-grain
524
+ * channelKey) so each backscroll author gets its real roster/gate-derived tier (F3). When omitted,
525
+ * fetchedBackscroll's fail-closed per-sender default applies (a human → `unauth`, self/bot →
526
+ * `relay`), never a blanket `peer`.
527
+ *
528
+ * NOTE: this is a proven DROP-IN with no live caller yet — Discord has no wake-envelope render
529
+ * surface to render the `<session>` output on a live wake (see the file header + the
530
+ * TODO(discord-supervisor-backscroll) at discord-gateway.ts's dispatch seam). The `trustFor` seam
531
+ * is wired here ahead of that surface so, when the Discord supervisor lands, it passes the resolver
532
+ * exactly as slack-events.ts's live supervisor deps do for fetchWakeEnvelopeBackscroll.
533
+ */
534
+ export async function fetchDiscordWakeEnvelopeBackscroll(
535
+ input: DiscordBackscrollWindowInput,
536
+ deps: DiscordBackscrollDeps = liveDeps,
537
+ trustFor?: BackscrollTrustFor,
538
+ ): Promise<WakeMessageInput[]> {
539
+ try {
540
+ const window = await fetchDiscordBackscrollWindow(input, deps);
541
+ if (window.length === 0) return [];
542
+ return fetchedBackscroll(window, undefined, trustFor);
543
+ } catch {
544
+ return []; // fail-soft: a first wake never depends on its backscroll
545
+ }
546
+ }
547
+
548
+ /**
549
+ * DISCORD'S REGISTERED CATCH-UP FETCH (OPE-1726) — the one thing this surface contributes to the
550
+ * shared since-last-wake path (core channels/seat-catch-up.ts). It reuses the window fetch above,
551
+ * addressed by the neutral request shape: the seat's container, bounded below by its last wake.
552
+ * 🔴 THE UPPER BOUND IS A MESSAGE ID, not a time, because `GET /channels/:id/messages` pages with
553
+ * `before=<snowflake>` and takes no timestamp. `excludeId` IS that snowflake (the trigger's own id),
554
+ * and with none the read anchors on a snowflake synthesised from `untilMs` — Discord ids embed their
555
+ * own millisecond, so the synthesised anchor orders correctly even though it names no real message.
556
+ * The shared path re-applies both bounds on its own side, so a page that overshoots is trimmed there.
557
+ * `threadRoot` present ⇒ the seat is bound to a thread, and a Discord thread IS its own channel id,
558
+ * so the read addresses that id and tags every message `thread`.
559
+ * A transport/read failure propagates to core's shared boundary, which keeps the wake alive and
560
+ * records `failed` instead of reporting a quiet channel (OPE-1842). Bootstrap callers keep the
561
+ * window fetch's default fail-soft mode.
562
+ */
563
+ export async function fetchDiscordSeatCatchUp(req: SeatCatchUpRequest, deps: DiscordBackscrollDeps = liveDeps): Promise<readonly NormalizedMessage[]> {
564
+ const isThread = req.threadRoot !== undefined;
565
+ const anchor = req.threadRoot ?? req.container;
566
+ const triggerMessageId = req.excludeId ?? msToSnowflake(req.untilMs ?? Date.now());
567
+ const strictDeps: DiscordBackscrollDeps = {
568
+ request: async (channelId, query) => {
569
+ const res = await deps.request(channelId, query);
570
+ if (!Array.isArray(res)) throw new Error("Discord catch-up request failed");
571
+ return res;
572
+ },
573
+ };
574
+ return fetchDiscordBackscrollWindow({ channelId: anchor, triggerMessageId, isThread, sinceMs: req.sinceMs }, strictDeps, "throw");
575
+ }
576
+
577
+ /**
578
+ * A Discord snowflake encodes MILLISECONDS and the ISO `timestamp` field carries them too (OPE-1847,
579
+ * `core/agent/channels/seat-catch-up.ts` {@link SeatCatchUpDeclaration.timestampPrecisionMs}).
580
+ * 🔴 ONE MILLISECOND IS NOT ZERO WIDENING. The road widens the LOWER bound by nothing here and the
581
+ * UPPER bound by exactly one millisecond, so a message stamped at the trigger's own instant is
582
+ * INSIDE the window and `excludeId` is the only thing that removes the trigger. The paging call
583
+ * already anchors on `before=<the trigger snowflake>`, which is exclusive, and a message that did
584
+ * arrive would carry that snowflake as its id.
585
+ * 🔴 IT IS STILL DECLARED, and the parity test requires it. The declaration is what makes "this
586
+ * surface needs the finest unit" a CHECKED claim rather than an omission, and an omission is
587
+ * indistinguishable from the GitHub and Notion defect this whole mechanism exists to close.
588
+ */
589
+ export const DISCORD_TIMESTAMP_PRECISION_MS = 1;
590
+
591
+ /** The registered form — the transport seam is fixed to the live one. Tests drive
592
+ * {@link fetchDiscordSeatCatchUp} with a canned requester instead. */
593
+ export const discordSeatCatchUpFetch: SeatCatchUpFetch = (req) => fetchDiscordSeatCatchUp(req);