switchroom 0.16.24 → 0.16.27

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.
@@ -16,6 +16,9 @@
16
16
  * - splitMarkdownChunks: split a long markdown body into <=maxLen chunks
17
17
  * at safe boundaries (never mid code-fence, never mid table row),
18
18
  * defaulting maxLen to the rich-message cap of 32768.
19
+ * - normalizeParagraphBreaks: promote a LONE prose `\n` into a GFM hard
20
+ * break (` \n`) so paragraph separation survives the rich GFM path,
21
+ * while leaving lists / tables / code / `\n\n` untouched.
19
22
  * - RICH_MESSAGE_MAX_CHARS: the rich-text wire cap (32768).
20
23
  */
21
24
 
@@ -80,50 +83,355 @@ export function repairEscapedWhitespace(text: string): string {
80
83
  // index and produce "undefined" in the Telegram output. A nonce that is unique
81
84
  // per invocation makes the sentinel statistically impossible to collide with.
82
85
  const nonce = Math.random().toString(36).slice(2)
83
- const CODE_MASK_PH = `\x00RM${nonce}_`
84
86
  const BACKSLASH_PH = `\x00BK${nonce}_`
85
87
 
86
- // Mask fenced code blocks (``` ... ```) and inline code spans (` ... `)
87
- // so the unescape pass never touches their content.
88
- //
89
- // Fenced blocks are extracted first. Only CLOSED fenced blocks (with a
90
- // matching closing ```) are masked — an unclosed fence is left as-is so the
91
- // inline-code pass below won't misparse the two leading backticks as an empty
92
- // inline span and expose the block's interior.
93
- //
94
- // Inline code uses `[^\`\n]+` (one or more non-backtick, non-newline chars)
95
- // matching the same definition the chunker uses, so the masked regions are
96
- // consistent with what the downstream pipeline treats as code.
88
+ // Mask fenced code blocks and inline code spans so the unescape pass never
89
+ // touches their content (shared masker see maskCodeRegions for the exact
90
+ // closed-fence / inline-span definitions).
91
+ const { masked, restore } = maskCodeRegions(text, nonce)
92
+
93
+ // Order matters: protect existing `\\` first so `\\n` stays as a literal
94
+ // backslash + n and doesn't become a newline.
95
+ const unescaped = masked
96
+ .replace(/\\\\/g, BACKSLASH_PH)
97
+ .replace(/\\n/g, '\n')
98
+ .replace(/\\r/g, '\r')
99
+ .replace(/\\t/g, '\t')
100
+ .replace(/\\"/g, '"')
101
+ .replace(new RegExp(BACKSLASH_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\\')
102
+
103
+ // Restore masked code spans verbatim.
104
+ return restore(unescaped)
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Shared code-region masking (used by repairEscapedWhitespace AND
109
+ // normalizeParagraphBreaks). Closed fenced blocks (``` … ```) and inline
110
+ // code spans (` … `) are replaced with unique placeholders so neither pass
111
+ // ever rewrites their interior; `restore` puts them back verbatim.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ interface MaskedCode {
115
+ masked: string
116
+ restore: (s: string) => string
117
+ /** The placeholder prefix injected for each masked region (fence or span). */
118
+ placeholder: string
119
+ }
120
+
121
+ /**
122
+ * Mask fenced code blocks and inline code spans with collision-resistant
123
+ * placeholders. `nonce` is a per-call random string the caller already holds
124
+ * (so two maskers in one function share one nonce namespace cleanly).
125
+ *
126
+ * Fenced blocks are extracted FIRST and only when CLOSED (matching ```), so an
127
+ * unclosed fence is left intact rather than misparsed by the inline pass. Inline
128
+ * spans use `[^\`\n]+` — the same definition the chunker treats as code.
129
+ */
130
+ function maskCodeRegions(text: string, nonce: string): MaskedCode {
131
+ const CODE_MASK_PH = `\x00RM${nonce}_`
97
132
  const codeMasks: string[] = []
98
133
 
99
134
  const masked = text
100
- // Closed fenced code blocks only (``` ... ``` with a matching closer).
101
135
  .replace(/```[\s\S]*?```/g, (m) => {
102
136
  const idx = codeMasks.length
103
137
  codeMasks.push(m)
104
138
  return `${CODE_MASK_PH}${idx}\x00`
105
139
  })
106
- // Inline code spans: at least one character between backticks, no embedded
107
- // backtick or newline.
108
140
  .replace(/`[^`\n]+`/g, (m) => {
109
141
  const idx = codeMasks.length
110
142
  codeMasks.push(m)
111
143
  return `${CODE_MASK_PH}${idx}\x00`
112
144
  })
113
145
 
114
- // Order matters: protect existing `\\` first so `\\n` stays as a literal
115
- // backslash + n and doesn't become a newline.
116
- const unescaped = masked
117
- .replace(/\\\\/g, BACKSLASH_PH)
118
- .replace(/\\n/g, '\n')
119
- .replace(/\\r/g, '\r')
120
- .replace(/\\t/g, '\t')
121
- .replace(/\\"/g, '"')
122
- .replace(new RegExp(BACKSLASH_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\\')
146
+ const restoreRe = new RegExp(
147
+ `${CODE_MASK_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)\x00`,
148
+ 'g',
149
+ )
150
+ const restore = (s: string): string =>
151
+ s.replace(restoreRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
123
152
 
124
- // Restore masked code spans verbatim.
125
- const restoreRe = new RegExp(`${CODE_MASK_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)\x00`, 'g')
126
- return unescaped.replace(restoreRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
153
+ return { masked, restore, placeholder: CODE_MASK_PH }
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Paragraph-break normalizer — make lone prose newlines survive the GFM path
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * GFM (the rich-message render path) treats a LONE `\n` as a *soft* break: the
162
+ * two lines collapse onto the same visual line, so a model that separates its
163
+ * paragraphs with a single newline produces a cramped wall of text. The old
164
+ * markdown→HTML path rendered every `\n` as a hard break, which masked the
165
+ * habit; the rich path no longer does.
166
+ *
167
+ * This normalizer fixes that DETERMINISTICALLY without breaking GFM block
168
+ * syntax. It does exactly two things, on code-masked text:
169
+ *
170
+ * 1. Collapse runs of 3+ newlines down to exactly `\n\n` (never collapse a
171
+ * genuine `\n\n` paragraph gap).
172
+ * 2. Promote a LONE `\n` (one not adjacent to another `\n`) into a GFM hard
173
+ * break (` \n`, two trailing spaces) — but ONLY when it is a genuine
174
+ * prose paragraph break.
175
+ *
176
+ * The promotion heuristic is deliberately CONSERVATIVE — it prefers a false
177
+ * negative (leaving a break un-promoted, so two prose lines stay cramped) over
178
+ * a false positive (double-spacing a tight list or table). A break is promoted
179
+ * only when ALL of these hold:
180
+ *
181
+ * - The preceding line ends in sentence-terminal punctuation: `.`, `!`, `?`,
182
+ * `:`, or a closing `)` / `"` / `'` / `’` / `”` that itself follows such a
183
+ * terminator (e.g. `...done.")`).
184
+ * - The preceding line is NOT itself a marker line (list / table / quote /
185
+ * heading).
186
+ * - The NEXT line starts with a non-marker character: not a list bullet
187
+ * (`-`/`*`/`+`/`\d+.`/`\d+)`, incl. indented), not a table row (`|` or
188
+ * ` | `), not a blockquote (`>`), not a heading (`#`), not blank.
189
+ *
190
+ * Code fences and inline code are masked out before any of this runs, so their
191
+ * interior `\n`s are never touched.
192
+ */
193
+ export function normalizeParagraphBreaks(text: string): string {
194
+ if (!text.includes('\n')) return text
195
+
196
+ const nonce = Math.random().toString(36).slice(2)
197
+ const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
198
+
199
+ // Step 1: collapse 3+ newlines to exactly two. This also normalizes runs that
200
+ // contain interleaved spaces only between the newlines is NOT done here —
201
+ // we only touch pure newline runs so we never eat meaningful whitespace.
202
+ let out = masked.replace(/\n{3,}/g, '\n\n')
203
+
204
+ // Step 2: walk lines and promote lone prose breaks. We rebuild the string by
205
+ // joining lines with the right separator. A separator is "hard" (` \n`) only
206
+ // when the break between this line and the next is a genuine prose paragraph
207
+ // break per the heuristic; otherwise it stays a plain `\n`. Blank lines (the
208
+ // `\n\n` gaps) are preserved as empty entries in the split, so we never
209
+ // promote a break that is adjacent to a blank line.
210
+ const lines = out.split('\n')
211
+ const pieces: string[] = []
212
+ for (let i = 0; i < lines.length; i++) {
213
+ let line = lines[i]
214
+ const isLast = i === lines.length - 1
215
+ const next = isLast ? '' : lines[i + 1]
216
+ // A blank current or next line means this is part of a `\n\n` gap — leave
217
+ // the separator as a plain newline (the blank entry reconstructs the gap).
218
+ const promote =
219
+ !isLast &&
220
+ line.trim() !== '' &&
221
+ next.trim() !== '' &&
222
+ shouldPromoteBreak(line, next, placeholder)
223
+ if (promote) {
224
+ // Strip any trailing whitespace the line already carried so we emit
225
+ // exactly one ` \n` hard break (never accumulate spaces on a re-run).
226
+ // Include `\r` so a CRLF source ("Alpha.\r\nBravo.") doesn't strand a
227
+ // lone carriage return before the injected ` \n`.
228
+ line = line.replace(/[ \t\r]+$/, '')
229
+ }
230
+ pieces.push(line)
231
+ if (isLast) break
232
+ pieces.push(promote ? ' \n' : '\n')
233
+ }
234
+ out = pieces.join('')
235
+
236
+ // Step 3: guarantee a blank line (`\n\n`) at BLOCK BOUNDARIES. The
237
+ // prose-promotion above keeps lists/tables tight by leaving their single
238
+ // `\n` separators alone — but GFM's rich renderer needs a blank line to
239
+ // START a new block, so a block that is glued to the previous line by a
240
+ // single `\n` fails to render (a table prints as literal pipe text, prose
241
+ // after a list is absorbed as a lazy list continuation). This pass inserts
242
+ // the missing blank line at those transitions only, on the same masked text,
243
+ // never touching code interiors, never collapsing/expanding existing `\n\n`,
244
+ // and never splitting a table's header/delimiter/body rows apart.
245
+ out = ensureBlockBoundaries(out, placeholder)
246
+
247
+ return restore(out)
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /** A line that begins a GFM list item (bullet or ordered), incl. leading indent. */
255
+ function isListItemLine(line: string): boolean {
256
+ const t = line.trimStart()
257
+ return /^[-*+]\s/.test(t) || /^\d+[.)]\s/.test(t)
258
+ }
259
+
260
+ /** A GFM table body/header row: a line whose first non-space char is `|`. */
261
+ function isTableRowLine(line: string): boolean {
262
+ return /^\s*\|/.test(line)
263
+ }
264
+
265
+ /**
266
+ * A GFM table delimiter row: optional leading pipe, then one or more
267
+ * `:?-{1,}:?` cells separated by pipes (e.g. `|---|---|`, `---|:--:`,
268
+ * `| :-- | --: |`). This is what turns the line ABOVE it into a table header.
269
+ */
270
+ function isTableDelimiterLine(line: string): boolean {
271
+ return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line)
272
+ }
273
+
274
+ /** A fenced-code OPEN line — either a literal ``` fence or a masked block. */
275
+ function isFenceOpenLine(line: string, placeholder?: string): boolean {
276
+ const t = line.trimStart()
277
+ if (placeholder != null && placeholder.length > 0 && t.startsWith(placeholder)) return true
278
+ return t.startsWith('```')
279
+ }
280
+
281
+ /** A blockquote line. */
282
+ function isBlockquoteLine(line: string): boolean {
283
+ return line.trimStart().startsWith('>')
284
+ }
285
+
286
+ /** An ATX heading line. */
287
+ function isHeadingLine(line: string): boolean {
288
+ return /^#{1,6}\s/.test(line.trimStart())
289
+ }
290
+
291
+ /**
292
+ * Insert a blank line at block boundaries that are currently separated by
293
+ * exactly one `\n`. Operates line-by-line on already-code-masked text.
294
+ *
295
+ * A blank line is guaranteed:
296
+ * - BEFORE the first row of a GFM table (a `|`-leading line that is itself a
297
+ * delimiter row, OR a `|`-containing header line immediately followed by a
298
+ * delimiter row) when the previous emitted line is non-blank and not part
299
+ * of a table — never between a table's own header/delimiter/body rows.
300
+ * - BEFORE a fenced-code open, a blockquote, or an ATX heading when the
301
+ * previous line is non-blank and of a DIFFERENT block type.
302
+ * - AFTER a list block: when a list-item line is followed by a non-blank
303
+ * line that is NOT itself a list item and NOT an indented continuation of
304
+ * the item (4+ leading spaces / a tab), so the prose breaks out of the list.
305
+ *
306
+ * Conservative: prefers a false negative (leave glued) over corrupting a valid
307
+ * block. Existing blank lines (empty entries from a `\n\n` gap) are preserved
308
+ * and short-circuit every rule — we never double up a gap.
309
+ */
310
+ function ensureBlockBoundaries(text: string, placeholder?: string): string {
311
+ if (!text.includes('\n')) return text
312
+ const lines = text.split('\n')
313
+ const result: string[] = []
314
+
315
+ for (let i = 0; i < lines.length; i++) {
316
+ const line = lines[i]
317
+ const prev = result.length > 0 ? result[result.length - 1] : null
318
+ const prevNonBlank = prev != null && prev.trim() !== ''
319
+ const curBlank = line.trim() === ''
320
+
321
+ // ---- Rule A: blank line BEFORE a block that needs one to start ----
322
+ if (prevNonBlank && !curBlank) {
323
+ const next = i + 1 < lines.length ? lines[i + 1] : ''
324
+
325
+ // Table first row: either THIS line is a delimiter row (header was the
326
+ // prev line — but only treat as a table start when prev itself isn't
327
+ // already a table row), or THIS line is a `|`-bearing header whose NEXT
328
+ // line is a delimiter. We anchor the blank-line insertion on the HEADER
329
+ // line so header+delimiter+body stay contiguous.
330
+ const prevIsTable = isTableRowLine(prev)
331
+ const startsTableHere =
332
+ !prevIsTable &&
333
+ ((line.includes('|') && isTableDelimiterLine(next)) ||
334
+ (isTableRowLine(line) && isTableDelimiterLine(next)))
335
+
336
+ const startsFence = isFenceOpenLine(line, placeholder) && !isFenceOpenLine(prev, placeholder)
337
+ const startsQuote = isBlockquoteLine(line) && !isBlockquoteLine(prev)
338
+ const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
339
+
340
+ if (startsTableHere || startsFence || startsQuote || startsHeading) {
341
+ result.push('')
342
+ }
343
+ }
344
+
345
+ // ---- Rule B: blank line AFTER a list block, before breakout prose ----
346
+ if (prevNonBlank && !curBlank && isListItemLine(prev) && !isListItemLine(line)) {
347
+ // A 4+ space (or tab) indent means `line` is a lazy continuation of the
348
+ // list item's paragraph, NOT breakout prose — leave it glued.
349
+ const isIndentedContinuation = /^(\t| {4,})\S/.test(line)
350
+ // A table/fence/quote/heading start is already handled by Rule A above
351
+ // (its blank line was just inserted); avoid inserting a second one.
352
+ const alreadySeparated = result.length > 0 && result[result.length - 1].trim() === ''
353
+ if (!isIndentedContinuation && !alreadySeparated) {
354
+ result.push('')
355
+ }
356
+ }
357
+
358
+ result.push(line)
359
+ }
360
+
361
+ return result.join('\n')
362
+ }
363
+
364
+ /** Lines that introduce GFM block structure — never reflow around these. */
365
+ function isMarkerLine(line: string, placeholder?: string): boolean {
366
+ // CommonMark indented code block: 4+ leading spaces then a non-space char.
367
+ // Checked on the RAW (pre-trim) line — trimming would erase the very indent
368
+ // that makes it a code block, so we must look before `trimStart()`.
369
+ if (/^ {4,}\S/.test(line)) return true
370
+ const t = line.trimStart()
371
+ // A line that begins with the code-mask placeholder is a standalone masked
372
+ // fenced block — treat it as a block marker so we never inject a hard break
373
+ // immediately before/after a code block. (An INLINE code span sits mid-line,
374
+ // so the line won't START with the placeholder and ordinary prose rules apply.)
375
+ if (placeholder != null && placeholder.length > 0 && t.startsWith(placeholder)) return true
376
+ return (
377
+ // Unordered list bullet: -, *, + followed by a space.
378
+ /^[-*+]\s/.test(t) ||
379
+ // Ordered list: `1.` or `1)` followed by a space.
380
+ /^\d+[.)]\s/.test(t) ||
381
+ // Blockquote / pull-quote.
382
+ t.startsWith('>') ||
383
+ // ATX heading.
384
+ /^#{1,6}\s/.test(t) ||
385
+ // Table row (leading pipe) or table-ish line (interior ` | `).
386
+ t.startsWith('|') ||
387
+ line.includes(' | ') ||
388
+ // Fenced code delimiter (defensive — fences are masked, but a lone/odd
389
+ // fence line can survive masking).
390
+ t.startsWith('```') ||
391
+ // Thematic break / divider.
392
+ /^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)
393
+ )
394
+ }
395
+
396
+ /**
397
+ * Decide whether the lone `\n` between `prev` and `next` is a genuine prose
398
+ * paragraph break worth promoting to a GFM hard break. Conservative by design
399
+ * (see normalizeParagraphBreaks doc) — returns false on any doubt.
400
+ */
401
+ function shouldPromoteBreak(prev: string, next: string, placeholder?: string): boolean {
402
+ if (isMarkerLine(prev, placeholder) || isMarkerLine(next, placeholder)) return false
403
+ // Next line must begin with ordinary prose, not a structural marker char.
404
+ const nextTrimmed = next.trimStart()
405
+ if (nextTrimmed.length === 0) return false
406
+ // The preceding line must read as a finished sentence/clause: it ends in a
407
+ // sentence-terminal punctuation mark, optionally wrapped by a closing quote
408
+ // or paren that itself follows such a terminator.
409
+ const prevTrimmed = prev.trimEnd()
410
+ // Strip up to one trailing closing-bracket/quote run to look at the real
411
+ // terminator (e.g. `He said "go."` or `(done.)`).
412
+ const unwrapped = prevTrimmed.replace(/[)"'’”\]]+$/, '')
413
+ const terminator = unwrapped.slice(-1)
414
+ return terminator === '.' || terminator === '!' || terminator === '?' || terminator === ':'
415
+ }
416
+
417
+ /**
418
+ * Last-resort hard slicer for a body that `splitMarkdownChunks` could not break
419
+ * (a single indivisible region larger than the cap — e.g. a giant fenced block
420
+ * with no interior boundary). Cuts on raw character count so every emitted
421
+ * piece is guaranteed `<= cap`, accepting that a cut MAY land inside a fence
422
+ * (which Telegram renders imperfectly) — a degraded-but-delivered message beats
423
+ * a hard `RICH_MESSAGE_TEXT_TOO_LONG` reject that drops the answer entirely.
424
+ *
425
+ * Returns the input as a single-element array when it already fits.
426
+ */
427
+ export function hardSliceToCap(text: string, cap = RICH_MESSAGE_MAX_CHARS): string[] {
428
+ if (cap <= 0) return [text]
429
+ if (text.length <= cap) return [text]
430
+ const out: string[] = []
431
+ for (let i = 0; i < text.length; i += cap) {
432
+ out.push(text.slice(i, i + cap))
433
+ }
434
+ return out
127
435
  }
128
436
 
129
437
  // ---------------------------------------------------------------------------
@@ -12,6 +12,7 @@ import {
12
12
  type DriveApprovalHandlerDeps,
13
13
  handleRequestDriveApproval,
14
14
  } from "./drive-write-approval.js";
15
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
15
16
 
16
17
  // ────────────────────────────────────────────────────────────────────────
17
18
  // Fixtures
@@ -284,17 +285,16 @@ describe("handleRequestDriveApproval — TTL clamping", () => {
284
285
  // ────────────────────────────────────────────────────────────────────────
285
286
 
286
287
  describe("handleRequestDriveApproval — oversize card body fit (#1767)", () => {
287
- it("truncates the rendered text under 4096 chars before posting", async () => {
288
+ it("truncates the rendered text under the rich-message cap before posting", async () => {
288
289
  const spy = makeSpy();
289
- // buildCard returns a body well past Telegram's 4096-char limit
290
- // (simulates a docTitle / anchor / summary whose HTML-escape
291
- // inflated past the limit). Handler must shrink it before postCard.
290
+ // buildCard returns a body well past the rich-message wire cap
291
+ // (RICH_MESSAGE_MAX_CHARS, 32768 post-#2669 — simulates a docTitle /
292
+ // anchor / summary whose escape inflated past the limit). Handler must
293
+ // shrink it before postCard.
292
294
  const giantText =
293
295
  "<b>Title</b>\n" +
294
- Array.from({ length: 200 }, (_, i) => `line-${i}-${"x".repeat(40)}`).join(
295
- "\n",
296
- );
297
- expect(giantText.length).toBeGreaterThan(4096);
296
+ "x".repeat(RICH_MESSAGE_MAX_CHARS + 4000);
297
+ expect(giantText.length).toBeGreaterThan(RICH_MESSAGE_MAX_CHARS);
298
298
  await handleRequestDriveApproval(
299
299
  clientFor(spy),
300
300
  msgFor(),
@@ -304,7 +304,7 @@ describe("handleRequestDriveApproval — oversize card body fit (#1767)", () =>
304
304
  }),
305
305
  );
306
306
  expect(spy.posted).toHaveLength(1);
307
- expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(4096);
307
+ expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
308
308
  expect(spy.posted[0]!.text).toContain("preview truncated");
309
309
  // Card was successfully posted → ok:true response went back.
310
310
  expect(spy.sent[0]?.ok).toBe(true);
@@ -334,7 +334,7 @@ describe("handleRequestDriveApproval — oversize card body fit (#1767)", () =>
334
334
  it("post failure after truncation surfaces a structured reason", async () => {
335
335
  const spy = makeSpy();
336
336
  const giantText =
337
- "<b>Title</b>\n" + "x".repeat(8000);
337
+ "<b>Title</b>\n" + "x".repeat(RICH_MESSAGE_MAX_CHARS + 4000);
338
338
  await handleRequestDriveApproval(
339
339
  clientFor(spy),
340
340
  msgFor(),
@@ -31,6 +31,7 @@ import type {
31
31
  RequestDriveApprovalMessage,
32
32
  } from "./ipc-protocol.js";
33
33
  import { truncateRawToFit } from "./oversize-card-body.js";
34
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
34
35
 
35
36
  // ────────────────────────────────────────────────────────────────────────
36
37
  // Injected deps — caller (gateway.ts) wires these from the existing
@@ -102,15 +103,20 @@ const MAX_TTL_MS = 30 * 60 * 1000; // 30 minutes
102
103
  const MIN_TTL_MS = 30 * 1000; // 30 seconds
103
104
 
104
105
  /**
105
- * Telegram `sendMessage` hard limit. `buildDiffPreviewCard` HTML-
106
- * escapes the docTitle + every body line with no upstream length
107
- * cap, so an adversarial or just-unusually-long docTitle / anchor
108
- * displayName / agent-supplied summary can render past 4096 once
109
- * `&` / `<` / `>` inflate up to 5x. We rebuild a truncated body
110
- * keyed off the wrapper-attested fields when that happens. (#1767)
106
+ * Telegram rich-message hard limit. `buildDiffPreviewCard` escapes the
107
+ * docTitle + every body line with no upstream length cap, so an adversarial
108
+ * or just-unusually-long docTitle / anchor displayName / agent-supplied
109
+ * summary can render past the wire cap once escaping inflates it. We rebuild a
110
+ * truncated body keyed off the wrapper-attested fields when that happens.
111
+ * (#1767)
112
+ *
113
+ * Post-#2669 every card renders as GFM markdown via `sendRichMessage`, so the
114
+ * hard cap is `RICH_MESSAGE_MAX_CHARS` (32768), not the legacy 4096 plain-text
115
+ * limit. RENDERED_BODY_CAP keeps the proportional ~5% headroom under the hard
116
+ * cap that the original 3900-under-4096 budget carried.
111
117
  */
112
- const TELEGRAM_SENDMESSAGE_LIMIT = 4096;
113
- const RENDERED_BODY_CAP = 3900;
118
+ const TELEGRAM_SENDMESSAGE_LIMIT = RICH_MESSAGE_MAX_CHARS;
119
+ const RENDERED_BODY_CAP = RICH_MESSAGE_MAX_CHARS - 200;
114
120
  const OVERSIZE_SENTINEL = "\n[… preview truncated; open in Drive for full context]";
115
121
 
116
122
  /**