switchroom 0.18.18 → 0.18.19

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.
@@ -111,6 +111,16 @@ export function escapeLinkHref(href: string): string {
111
111
  * the old whole-message heuristic ("bail if any real newline exists") was
112
112
  * too broad and prevented repair of mixed messages that had both real
113
113
  * newlines and stray literal `\n` escape sequences outside code spans.
114
+ *
115
+ * KNOWN ISSUE (accepted tradeoff): a literal backslash sequence `\n`/`\r`/`\t`
116
+ * that a user genuinely meant as text OUTSIDE a code span is unescaped into a
117
+ * real newline/tab — e.g. a bare Windows path `C:\new\table` becomes
118
+ * `C:<newline>ew<tab>able`. This is deliberately not guarded: the fleet is
119
+ * Linux-only, such paths in prose (rather than inside a `code span`, which is
120
+ * masked and safe) are vanishingly rare, and the far commoner failure this
121
+ * repairs is an LLM emitting JSON-escaped `\n\n` as literal text. If a
122
+ * Windows-path use case ever matters, restrict the unescape to sequences not
123
+ * flanked by path-like characters rather than removing it.
114
124
  */
115
125
  export function repairEscapedWhitespace(text: string): string {
116
126
  if (!/\\[nrt"\\]/.test(text)) return text
@@ -315,13 +325,14 @@ export function normalizeParagraphBreaks(text: string): string {
315
325
  // as an oversized / ragged gap in the raw text and in some clients, and
316
326
  // it was the "stray blank line" seen in real replies. Collapse it.
317
327
  //
318
- // Deliberately ASCII-only: a line whose only content is U+00A0 is the
319
- // INTENTIONAL, non-collapsible paragraph spacer added later by
320
- // addParagraphSpacers (#2692) to force a visible gap on the rich-message
321
- // path. This step runs BEFORE that spacer pass and must never eat a U+00A0
322
- // line, so the `[ \t\r]` character class here excludes U+00A0 by
323
- // construction. Runs on code-masked text, so a blank-ish line inside a
324
- // fenced block is parked and never touched.
328
+ // Deliberately ASCII-only: the `[ \t\r]` character class excludes U+00A0 by
329
+ // construction, so a line whose only content is a non-breaking space a user
330
+ // legitimately typed is left intact rather than silently collapsed. (This
331
+ // used to also protect the NBSP paragraph spacer that addParagraphSpacers
332
+ // injected downstream; that spacer pass was removed in the #2669 follow-up —
333
+ // paragraph gaps now rely on plain `\n\n` but keeping this ASCII-only is
334
+ // still the conservative choice.) Runs on code-masked text, so a blank-ish
335
+ // line inside a fenced block is parked and never touched.
325
336
  let out = masked
326
337
  // Collapse any run of newlines interleaved with ASCII whitespace-only
327
338
  // interior lines down to a single clean `\n\n`. Requires at least one
@@ -469,184 +480,14 @@ export function hardenCardBreaks(text: string): string {
469
480
  }
470
481
 
471
482
  // ---------------------------------------------------------------------------
472
- // Paragraph spacers restore a VISIBLE blank line between prose paragraphs
473
- // ---------------------------------------------------------------------------
474
-
475
- /**
476
- * The non-collapsible spacer paragraph injected between two prose paragraphs.
477
- *
478
- * Telegram's Bot API 10.1 rich-message renderer (the GFM/CommonMark engine
479
- * behind `sendRichMessage` / `editMessageText({ markdown })`) renders a `\n\n`
480
- * paragraph break TIGHT — the two paragraphs sit on adjacent lines with no
481
- * visible empty line between them. The legacy markdown→HTML path (removed in
482
- * #2669) sent `\n\n` literally with `parse_mode:"HTML"`, where two newlines
483
- * render as a real blank line. That regression is the operator-confirmed
484
- * "paragraphs jammed together" symptom.
485
- *
486
- * CommonMark discards blank lines made of ASCII whitespace, but a line whose
487
- * only content is a NON-breaking space (U+00A0) is a genuine, non-empty
488
- * paragraph — it renders as a visible empty line. So `A\n\n \n\nB`
489
- * renders as three paragraphs: A, a blank-looking line, then B — the visible
490
- * gap the HTML path used to produce.
491
- */
492
- export const PARAGRAPH_SPACER = ' '
493
-
494
- /**
495
- * Insert a visible blank-line spacer into each genuine `\n\n` paragraph gap so
496
- * the rich GFM renderer shows a real empty line between paragraphs (matching
497
- * the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
498
- * the reliable trick.
499
- *
500
- * Uniform-block-spacing contract: a spacer is inserted into EVERY `\n\n` gap
501
- * that separates two DISTINCT blocks — prose→prose, paragraph→list,
502
- * list→paragraph, heading→anything, blockquote/table/fence boundaries — so a
503
- * mixed message renders with one identical visible blank line between blocks.
504
- * The one exception is a gap INSIDE a block of the same structural kind (two
505
- * items of a loose list, consecutive table rows/quotes/fences): those stay
506
- * tight so the block's contiguity survives. Interiors joined by a single `\n`
507
- * are never gaps at all and are untouched by construction.
508
- *
509
- * Runs on code-masked text (so a blank line inside a fenced block is never
510
- * touched) and is idempotent — a gap that already contains a U+00A0 spacer
511
- * paragraph is recognised and never doubled.
512
- *
513
- * Intended to run in the outbound send path AFTER normalizeParagraphBreaks,
514
- * which has already collapsed 3+ newline runs to `\n\n`, promoted lone prose
515
- * breaks, and guaranteed block-boundary blank lines. normalizeParagraphBreaks
516
- * itself deliberately does NOT do this so its (well-tested) `\n\n`-preserving
517
- * contract is unchanged.
518
- */
519
- export function addParagraphSpacers(text: string): string {
520
- if (!text.includes('\n\n')) return text
521
-
522
- const nonce = Math.random().toString(36).slice(2)
523
- const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
524
-
525
- if (!masked.includes('\n\n')) return restore(masked)
526
-
527
- // The line we inject for a spacer paragraph (its only content is U+00A0).
528
- const spacerLine = PARAGRAPH_SPACER
529
-
530
- // CRITICAL: `String.prototype.trim()` strips U+00A0, so a spacer line would
531
- // read as "blank" and the pass would lose idempotency (re-spacing an
532
- // already-spaced gap). Detect blank-ness with an ASCII-whitespace-only test
533
- // so the U+00A0 spacer line is correctly seen as NON-blank.
534
- const isBlankLine = (line: string): boolean => /^[ \t\r\f\v]*$/.test(line)
535
- // Trim ASCII-only (preserve U+00A0) so the spacer line is recognisable.
536
- const asciiTrim = (line: string): string => line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
537
-
538
- // Classify the block kind of a facing line so the spacer decision can be
539
- // made per BLOCK TRANSITION (#uniform-block-spacing). A spacer is inserted
540
- // at every `\n\n` gap between two DIFFERENT block kinds (paragraph→list,
541
- // list→paragraph, heading→anything, blockquote/table boundaries) and
542
- // between two prose paragraphs — but NEVER inside a single block's interior
543
- // (between two items of the same loose list, two rows of a table, two
544
- // quote lines, two fenced blocks). One visible blank line between distinct
545
- // blocks, identical everywhere; list/table interiors stay tight.
546
- type BlockKind = 'spacer' | 'list' | 'table' | 'quote' | 'heading' | 'fence' | 'divider' | 'prose'
547
- const blockKind = (line: string): BlockKind => {
548
- if (asciiTrim(line) === spacerLine) return 'spacer'
549
- if (isFenceOpenLine(line, placeholder)) return 'fence'
550
- if (isListItemLine(line)) return 'list'
551
- if (isTableRowLine(line) || isTableDelimiterLine(line)) return 'table'
552
- if (isBlockquoteLine(line)) return 'quote'
553
- if (isHeadingLine(line)) return 'heading'
554
- if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trimStart())) return 'divider'
555
- return 'prose'
556
- }
557
-
558
- // Same-kind structural pairs whose `\n\n` gap is a block INTERIOR (a loose
559
- // list's item gap, consecutive tables/quotes/fences) — no spacer there.
560
- const SAME_KIND_TIGHT: ReadonlySet<BlockKind> = new Set([
561
- 'list',
562
- 'table',
563
- 'quote',
564
- 'fence',
565
- 'divider',
566
- ])
567
-
568
- const shouldSpaceGap = (above: string, below: string): boolean => {
569
- const a = blockKind(above)
570
- const b = blockKind(below)
571
- // A facing spacer line means the gap is already spaced (idempotency —
572
- // also guarded by alreadySpaced at the call site).
573
- if (a === 'spacer' || b === 'spacer') return false
574
- if (a === b && SAME_KIND_TIGHT.has(a)) return false
575
- // Everything else is a genuine block transition (incl. prose→prose,
576
- // heading→anything, list↔paragraph, table/quote boundaries) — space it.
577
- return true
578
- }
579
-
580
- // Split into blank-line-delimited segments, then re-join inserting a spacer
581
- // paragraph between two adjacent NON-blank segments whose facing lines are
582
- // both prose and which are not already separated by a spacer.
583
- // A `\n\n` paragraph gap is a SINGLE blank entry between two content lines
584
- // (`"A\n\nB".split('\n')` → `["A", "", "B"]`). normalizeParagraphBreaks has
585
- // already collapsed 3+ newline runs to exactly `\n\n`, so we only ever see a
586
- // one-blank gap here; a multi-blank run is handled defensively the same way
587
- // (the FIRST blank of the run carries the spacer decision).
588
- const lines = masked.split('\n')
589
- const out: string[] = []
590
- for (let i = 0; i < lines.length; i++) {
591
- const line = lines[i]
592
- const isBlank = isBlankLine(line)
593
- // The spacer decision is made at the FIRST blank of a gap, i.e. when the
594
- // previously emitted line is non-blank prose. Inject the spacer BEFORE the
595
- // blank so the result is `above \n\n   \n\n below`.
596
- if (isBlank) {
597
- const prevEmitted = out.length > 0 ? out[out.length - 1] : null
598
- const prevIsBlank = prevEmitted != null && isBlankLine(prevEmitted)
599
- if (!prevIsBlank) {
600
- const above = lastNonBlank(out, isBlankLine)
601
- const below = nextNonBlank(lines, i + 1, isBlankLine)
602
- const alreadySpaced =
603
- (above != null && asciiTrim(above) === spacerLine) ||
604
- (below != null && asciiTrim(below) === spacerLine)
605
- if (
606
- !alreadySpaced &&
607
- above != null &&
608
- below != null &&
609
- shouldSpaceGap(above, below)
610
- ) {
611
- // Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
612
- // between two real blank lines so CommonMark renders it as a visible
613
- // empty line between the two prose paragraphs.
614
- out.push('')
615
- out.push(spacerLine)
616
- out.push('')
617
- continue
618
- }
619
- }
620
- }
621
- out.push(line)
622
- }
623
-
624
- return restore(out.join('\n'))
625
- }
626
-
627
- /**
628
- * Last non-blank entry already emitted into `arr`, or null. `isBlank` is the
629
- * caller's blank test (ASCII-only, so a U+00A0 spacer line counts as
630
- * non-blank — `String.trim()` would wrongly strip it).
631
- */
632
- function lastNonBlank(arr: string[], isBlank: (s: string) => boolean): string | null {
633
- for (let i = arr.length - 1; i >= 0; i--) {
634
- if (!isBlank(arr[i])) return arr[i]
635
- }
636
- return null
637
- }
638
-
639
- /** First non-blank line at or after index `from` in `lines`, or null. */
640
- function nextNonBlank(
641
- lines: string[],
642
- from: number,
643
- isBlank: (s: string) => boolean,
644
- ): string | null {
645
- for (let i = from; i < lines.length; i++) {
646
- if (!isBlank(lines[i])) return lines[i]
647
- }
648
- return null
649
- }
483
+ // Paragraph spacing note (#2669 follow-up): the NBSP paragraph-spacer
484
+ // (PARAGRAPH_SPACER / addParagraphSpacers) was REMOVED. Its premise — that the
485
+ // Bot API 10.1 rich (GFM) renderer collapses a `\n\n` gap TIGHT — is false for
486
+ // the live renderer, which shows a `\n\n` gap as a normal single blank line.
487
+ // The old U+00A0 spacer therefore injected a spurious SECOND blank line
488
+ // (`\n\n \n\n`) between every paragraph fleet-wide. Paragraph spacing now
489
+ // relies on the plain `\n\n` that normalizeParagraphBreaks already guarantees
490
+ // at block boundaries one correct single blank line, no spacer pass.
650
491
 
651
492
  // ---------------------------------------------------------------------------
652
493
  // Punctuation / bullet normalization — fleet-wide consistent typography
@@ -678,7 +519,41 @@ export function normalizePunctuation(text: string): string {
678
519
  const nonce = Math.random().toString(36).slice(2)
679
520
  const { masked, restore } = maskCodeRegions(text, nonce)
680
521
 
681
- let out = masked
522
+ // Mask inline-link DESTINATIONS `](href)` before the dash passes so a dash
523
+ // inside a URL is never rewritten. maskCodeRegions only masks code spans/
524
+ // fences, not link hrefs, so without this an en-dash in a path becomes a
525
+ // silently-wrong URL, and an em-dash becomes `, ` — the injected space
526
+ // TERMINATES the markdown link and leaks the trailing text as prose
527
+ // (`[a](https://x/foo—bar)` → `[a](https://x/foo, bar)`). Only the href
528
+ // inside the parens is masked; the visible link LABEL still normalizes like
529
+ // ordinary prose (dashes in label text are intended, per existing behaviour).
530
+ const linkMasks: string[] = []
531
+ const LINK_MASK_PH = `\x00RML${nonce}_`
532
+ const maskedLinks = masked.replace(/(\]\()([^)\n]*)(\))/g, (_m, open: string, href: string, close: string) => {
533
+ const idx = linkMasks.length
534
+ linkMasks.push(href)
535
+ return `${open}${LINK_MASK_PH}${idx}\x00${close}`
536
+ })
537
+ // Also protect GFM ANGLE-BRACKET AUTOLINK destinations `<scheme:…>` for the
538
+ // same reason (`<https://x/foo–bar>` → the en-dash would be rewritten to `-`,
539
+ // corrupting the URL). Only the URI inside the brackets is masked; the `<`/`>`
540
+ // are untouched. Conservative — requires a scheme-like `xxx:` prefix and no
541
+ // whitespace/`>` in the body (loosely mirrors the GFM absolute-URI autolink
542
+ // rule), so arbitrary `<…>` prose is never masked.
543
+ const maskedAutolinks = maskedLinks.replace(
544
+ /(<)([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]*)(>)/g,
545
+ (_m, lt: string, uri: string, gt: string) => {
546
+ const idx = linkMasks.length
547
+ linkMasks.push(uri)
548
+ return `${lt}${LINK_MASK_PH}${idx}\x00${gt}`
549
+ },
550
+ )
551
+ const escNonce = nonce.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
552
+ const linkRestoreRe = new RegExp(`\x00RML${escNonce}_(\\d+)\x00`, 'g')
553
+ const restoreLinks = (s: string): string =>
554
+ s.replace(linkRestoreRe, (_m, idx: string) => linkMasks[Number(idx)] ?? _m)
555
+
556
+ let out = maskedAutolinks
682
557
  // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
683
558
  // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
684
559
  // dashes ("a — b — c") all normalize in one pass — a consumed \S would
@@ -694,6 +569,10 @@ export function normalizePunctuation(text: string): string {
694
569
  // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
695
570
  .replace(/(\w)–(?=\w)/g, '$1-')
696
571
 
572
+ // Restore link hrefs now that the dash passes are done — before the bullet
573
+ // pass and the code restore.
574
+ out = restoreLinks(out)
575
+
697
576
  // 4. Leading unicode bullet markers → GFM `- ` (per line, indent kept).
698
577
  out = out
699
578
  .split('\n')
@@ -884,9 +763,10 @@ function ensureBlockBoundaries(text: string, placeholder?: string): string {
884
763
  const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
885
764
  // List start glued to prose above by a single `\n` (uniform-block-
886
765
  // spacing): a `- ` line already interrupts a paragraph in CommonMark,
887
- // so the blank line is render-safe — it only lets the spacer pass see
888
- // the transition. Never fires between two list items (prev is a list
889
- // item) so list interiors stay tight.
766
+ // so the blank line is render-safe — it makes the prose→list transition
767
+ // a real `\n\n` block boundary the GFM renderer honours. Never fires
768
+ // between two list items (prev is a list item) so list interiors stay
769
+ // tight.
890
770
  const startsList = isListItemLine(line) && !isListItemLine(prev)
891
771
 
892
772
  if (startsTableHere || startsFence || startsQuote || startsHeading || startsList) {
@@ -1096,6 +976,12 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
1096
976
  cut = backOffOpenFence(rest, cut)
1097
977
  // Back off so the cut doesn't bisect a table row (a line with `|`).
1098
978
  cut = backOffTableRow(rest, cut)
979
+ // Back off so the cut doesn't bisect an inline entity (`**bold**`,
980
+ // `` `code` ``, `_italic_`, `[label](href)`), which would leave an unclosed
981
+ // delimiter in the emitted chunk (Telegram then parse-rejects it to
982
+ // plaintext, or mis-renders the continuation). Runs LAST so it also cleans
983
+ // up a boundary the fence/table back-offs landed on.
984
+ cut = backOffOpenInline(rest, cut)
1099
985
 
1100
986
  if (cut <= 0) {
1101
987
  // Could not find a safe boundary below maxLen — the region is one
@@ -1120,38 +1006,26 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
1120
1006
  }
1121
1007
 
1122
1008
  /**
1123
- * Strip stray paragraph-spacer / blank lines off a chunk boundary so a cut that
1124
- * lands inside an injected spacer gap (`\n\n${PARAGRAPH_SPACER}\n\n`, see
1125
- * addParagraphSpacers) never leaves a continuation chunk that OPENS with a bare
1126
- * U+00A0 spacer line, nor a prior chunk that ENDS with one.
1009
+ * Strip stray blank lines off a chunk boundary so a cut that lands in a `\n\n`
1010
+ * paragraph gap never leaves a continuation chunk that OPENS with a bare blank
1011
+ * line, nor a prior chunk that ENDS with one.
1127
1012
  *
1128
- * A "boundary blank run" is any sequence of newlines and spacer-only lines (a
1129
- * line whose only content is the U+00A0 spacer, optionally surrounded by ASCII
1130
- * spaces/tabs) in ANY interleaving `\n \n`, ` \n\n`, `\n\n \n`, etc. The
1131
- * legacy behaviour (strip leading ASCII `\n+` only) is a strict subset, so a
1132
- * boundary with NO spacer is unaffected. Idempotent: a chunk already trimmed
1133
- * has nothing left to strip.
1013
+ * (Pre-#2669-follow-up this also peeled the NBSP paragraph spacer that
1014
+ * addParagraphSpacers injected into every gap. That spacer pass was removed
1015
+ * gaps are now plain `\n\n` so this reduces to the original behaviour: strip
1016
+ * a run of ASCII-whitespace-only blank lines off the boundary.) Idempotent: a
1017
+ * chunk already trimmed has nothing left to strip.
1134
1018
  *
1135
1019
  * - `'leading'` → strip the run from the START (the continuation chunk).
1136
1020
  * - `'trailing'` → strip the run from the END (the just-emitted prior chunk).
1137
1021
  */
1138
1022
  function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): string {
1139
- // One blank-or-spacer line: optional ASCII ws, optional one U+00A0, optional
1140
- // ASCII ws i.e. a line that renders empty. A run of these (joined by \n,
1141
- // with leading/trailing \n) is what we peel off the boundary.
1142
- const sp = PARAGRAPH_SPACER
1023
+ // A boundary blank run is one-or-more lines that render empty (ASCII
1024
+ // whitespace only). Peel it off the requested side.
1143
1025
  if (side === 'leading') {
1144
- // Leading: one-or-more newlines, optionally with spacer-only lines mixed in.
1145
- return chunk.replace(
1146
- new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`),
1147
- '',
1148
- )
1026
+ return chunk.replace(/^(?:[ \t]*\n)+/, '')
1149
1027
  }
1150
- // Trailing: a newline run, optionally with spacer-only lines, at the very end.
1151
- return chunk.replace(
1152
- new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`),
1153
- '',
1154
- )
1028
+ return chunk.replace(/(?:\n[ \t]*)+$/, '')
1155
1029
  }
1156
1030
 
1157
1031
  /**
@@ -1190,3 +1064,53 @@ function backOffTableRow(text: string, cut: number): number {
1190
1064
  }
1191
1065
  return cut
1192
1066
  }
1067
+
1068
+ /**
1069
+ * Inline entities that must not be bisected by a chunk cut. Each pattern is
1070
+ * matched over the full `text`; if the chosen `cut` lands STRICTLY inside a
1071
+ * matched span, retreat to that span's start so the whole span moves to the
1072
+ * next chunk (mirrors backOffOpenFence / backOffTableRow). Cutting inside a
1073
+ * span would strand an unclosed `***`/`**`/`*` / `` ` `` / `___`/`__`/`_` /
1074
+ * `](` delimiter, which Telegram parse-rejects to plaintext or mis-renders
1075
+ * across the boundary.
1076
+ *
1077
+ * The TRIPLE-marker patterns (`***bold-italic***` / `___…___`) come FIRST so
1078
+ * their whole span wins the earliest-start back-off in backOffOpenInline: the
1079
+ * double-marker pattern would otherwise match the inner `**…**` of a `***…***`
1080
+ * span and retreat only past that, stranding the lone outer `*` (odd asterisk
1081
+ * count → the italic is lost).
1082
+ *
1083
+ * The `_italic_` pattern is boundary-guarded so snake_case identifiers
1084
+ * (`foo_bar_baz`) don't read as emphasis; a stray match there is harmless
1085
+ * anyway (it only shifts the cut to a `_` character, still a clean boundary).
1086
+ */
1087
+ const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
1088
+ /`[^`\n]+`/g, // inline code
1089
+ /\*\*\*[^*\n]+\*\*\*/g, // bold-italic (triple) — before the bold pattern
1090
+ /___[^_\n]+___/g, // bold-italic underscore (triple)
1091
+ /\*\*[^*\n]+\*\*/g, // bold
1092
+ /__[^_\n]+__/g, // underline
1093
+ /(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
1094
+ /\[[^\]\n]*\]\([^)\n]*\)/g, // link [label](href)
1095
+ ]
1096
+
1097
+ function backOffOpenInline(text: string, cut: number): number {
1098
+ if (cut <= 0 || cut >= text.length) return cut
1099
+ let earliest = cut
1100
+ for (const re of INLINE_SPAN_PATTERNS) {
1101
+ re.lastIndex = 0
1102
+ let m: RegExpExecArray | null
1103
+ while ((m = re.exec(text)) !== null) {
1104
+ const start = m.index
1105
+ const end = start + m[0].length
1106
+ // Cut strictly inside this span → the span straddles the boundary.
1107
+ if (start < cut && cut < end && start < earliest) earliest = start
1108
+ // Matches arrive in order; once a span starts at/after the cut, no later
1109
+ // span can contain it.
1110
+ if (start >= cut) break
1111
+ // Guard against a zero-width match wedging the loop.
1112
+ if (re.lastIndex === start) re.lastIndex = start + 1
1113
+ }
1114
+ }
1115
+ return earliest
1116
+ }
@@ -317,7 +317,7 @@ import {
317
317
  import { recordOperatorEvent } from '../operator-events-history.js'
318
318
  import {
319
319
  parseLlmError,
320
- renderLlmError,
320
+ renderLlmErrorSafe,
321
321
  decideErrorSurface,
322
322
  } from '../llm-error-present.js'
323
323
  import {
@@ -348,7 +348,7 @@ const REPLY_TO_TEXT_MAX = 200
348
348
  // #1161 silent-end fallback text now lives in ../silent-end.ts
349
349
  // (`silentEndFallbackText`, imported above) so the transport-boundary
350
350
  // tests exercise the real string — see PR #2892.
351
- import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
351
+ import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
352
352
  import { richMessage } from '../rich-send.js'
353
353
  import { scrubVoice } from '../text-voice-scrub.js'
354
354
  import {
@@ -7690,6 +7690,21 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7690
7690
  function emitGatewayOperatorEvent(event: OperatorEvent): void {
7691
7691
  const { agent, kind } = event
7692
7692
 
7693
+ // #llm-error-surfacing FIX 2 (secret leak): the operator-event cards are sent
7694
+ // via a raw bot.api.sendRichMessage that BYPASSES the normal outbound redact
7695
+ // chokepoint (normalizeOutboundBody → redact, outbound-send-path.ts). The only
7696
+ // scrub the renderers apply is stripRawErrorBytes — a JSON-SHAPE scrub, NOT a
7697
+ // secret scrubber — so a bearer token / `sk-…` key / url-embedded credential
7698
+ // smuggled in an error `detail` would reach the operator card verbatim on the
7699
+ // credentials-expired / credit-exhausted / unknown-4xx paths. Redact the detail
7700
+ // ONCE here, up front, through the SAME redact() the reply path uses — and
7701
+ // crucially BEFORE renderOperatorEvent runs escapeMarkdown on it (redacting the
7702
+ // already-escaped text would let url-query-param secrets slip past url-redact,
7703
+ // exactly the order the outbound pipeline documents: redact before markdown).
7704
+ // Doing it at the top also scrubs the recorded operator-event history and the
7705
+ // 429 metrics — defense in depth, no secret survives in ANY downstream sink.
7706
+ event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }
7707
+
7693
7708
  // ── 429 throttle tier (operator spec: "retry in place under 5 min, else
7694
7709
  // mark + failover, honest reset messaging") ────────────────────────────
7695
7710
  // A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
@@ -7957,9 +7972,16 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7957
7972
  return
7958
7973
  }
7959
7974
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
7960
- const r = renderLlmError(parsed, agent, tz, new Date(now))
7975
+ // #llm-error-surfacing FIX 3 (crash guard): renderLlmErrorSafe wraps the
7976
+ // tz-formatting render — an invalid IANA `SWITCHROOM_TIMEZONE`/`TZ` throws a
7977
+ // RangeError out of Intl.DateTimeFormat (local-time.ts's "never throws" claim
7978
+ // does NOT hold for construction-time zone validation). Pre-fix this branch
7979
+ // had no guard, so a bad tz crashed the whole operator-event turn; now it
7980
+ // degrades to a minimal tz-free line. There are no action buttons on this
7981
+ // card (FIX 1) — the humanized text carries any recommendation inline.
7982
+ const r = renderLlmErrorSafe(parsed, agent, tz, new Date(now))
7961
7983
  renderedText = r.text
7962
- renderedKeyboard = r.keyboard
7984
+ renderedKeyboard = undefined
7963
7985
  } else {
7964
7986
  try {
7965
7987
  const r = renderOperatorEvent(event)
@@ -14999,9 +15021,11 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
14999
15021
  // secret into a live bubble or the history row. Mask before scrub/send.
15000
15022
  editRawText = redactOutboundText(editRawText, 'edit_message')
15001
15023
  // Fleet-wide consistent formatting (same order as the reply path: redact
15002
- // first so secrets are matched literally, then normalize, then spacers on
15003
- // the rich path only).
15004
- if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
15024
+ // first so secrets are matched literally, then normalize). No paragraph
15025
+ // spacer pass — the NBSP spacer was removed in the #2669 follow-up because it
15026
+ // double-gapped every paragraph; the rich renderer already shows `\n\n` as
15027
+ // one blank line.
15028
+ if (!editLiteralText) editRawText = stripExcessBold(normalizePunctuation(editRawText))
15005
15029
  // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
15006
15030
  // are how silent-anchor and progress-update mutate already-sent
15007
15031
  // bubbles, so without this an edit can re-introduce dashes the
@@ -17437,9 +17461,9 @@ function handleSessionEvent(ev: SessionEvent): void {
17437
17461
  // breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
17438
17462
  // collapse them (lists/tables/code left untouched). Runs BEFORE the
17439
17463
  // redact/scrub below, exactly as reply orders it (repair → normalize →
17440
- // redact → scrub), so masking sees the repaired text. The matching
17441
- // addParagraphSpacers pass runs on the send side just before
17442
- // splitMarkdownChunks (see below).
17464
+ // redact → scrub), so masking sees the repaired text. Paragraph gaps
17465
+ // are the plain `\n\n` normalizeParagraphBreaks guarantees no spacer
17466
+ // pass runs on the send side any more (removed in the #2669 follow-up).
17443
17467
  capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
17444
17468
  // Component 3 — origin-thread backstop. `chatId`/`threadId` are
17445
17469
  // captured from the turn atom (turn.sessionChatId/sessionThreadId)
@@ -17583,13 +17607,12 @@ function handleSessionEvent(ev: SessionEvent): void {
17583
17607
  link_preview_options: { is_disabled: true },
17584
17608
  }
17585
17609
  const limit = RICH_MESSAGE_MAX_CHARS
17586
- // #2798 / #2692 inject visible blank-line spacers into prose `\n\n`
17587
- // gaps before splitting, exactly as executeReply does. The rich GFM
17588
- // renderer collapses a bare `\n\n` gap TIGHT, so without this the
17589
- // paragraph boundaries from the '\n\n' block join (turn-flush-safety
17590
- // .ts) would still render jammed together. Mirrors reply's
17591
- // `addParagraphSpacers(text)` on the non-literal path.
17592
- const renderedText = addParagraphSpacers(capturedText)
17610
+ // The `\n\n` block joins from turn-flush-safety.ts render as normal
17611
+ // single blank lines under the Bot API 10.1 rich GFM path, so no
17612
+ // spacer pass runs before splitting (the NBSP spacer was removed in
17613
+ // the #2669 follow-up it double-gapped every paragraph). Mirrors
17614
+ // executeReply, which now also sends the normalized text as-is.
17615
+ const renderedText = capturedText
17593
17616
  const htmlChunks = splitMarkdownChunks(renderedText, limit)
17594
17617
  const sentIds: number[] = []
17595
17618
  try {
@@ -27,7 +27,6 @@ import {
27
27
  normalizeParagraphBreaks,
28
28
  normalizePunctuation,
29
29
  stripExcessBold,
30
- addParagraphSpacers,
31
30
  splitMarkdownChunks,
32
31
  hardSliceToCap,
33
32
  RICH_MESSAGE_MAX_CHARS,
@@ -88,13 +87,16 @@ export function normalizeOutboundBody(
88
87
  }
89
88
 
90
89
  /**
91
- * Effective-text spacing (#2669 rich-message regression fix). The rich GFM
92
- * renderer collapses `\n\n` gaps tight, so prose paragraphs render jammed.
93
- * Inject a visible blank-line spacer on the rich path only; the literal
94
- * (`format:'text'`) path stays byte-exact. Pure.
90
+ * Effective-text stage. Historically this injected an NBSP paragraph spacer on
91
+ * the rich path (#2669) to force a visible gap; that pass was removed in the
92
+ * #2669 follow-up because the live Bot API 10.1 GFM renderer already renders a
93
+ * `\n\n` gap as one normal blank line — the spacer was double-gapping every
94
+ * paragraph. Both paths now pass the text through byte-identically; the stage
95
+ * is retained as the named pipeline seam (and the literal/rich distinction is
96
+ * kept for callers) so a future rich-only transform has a home. Pure.
95
97
  */
96
- export function computeEffectiveText(text: string, literalText: boolean): string {
97
- return literalText ? text : addParagraphSpacers(text)
98
+ export function computeEffectiveText(text: string, _literalText: boolean): string {
99
+ return text
98
100
  }
99
101
 
100
102
  /**