switchroom 0.16.38 → 0.16.47

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 (59) hide show
  1. package/dist/agent-scheduler/index.js +8 -2
  2. package/dist/auth-broker/index.js +7 -1
  3. package/dist/cli/notion-write-pretool.mjs +7 -1
  4. package/dist/cli/switchroom.js +1259 -375
  5. package/dist/cli/ui/index.html +877 -214
  6. package/dist/host-control/main.js +116 -84
  7. package/dist/vault/approvals/kernel-server.js +8 -2
  8. package/dist/vault/broker/server.js +8 -2
  9. package/examples/minimal.yaml +1 -1
  10. package/examples/switchroom.yaml +1 -1
  11. package/package.json +2 -2
  12. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  13. package/skills/switchroom-status/SKILL.md +1 -1
  14. package/telegram-plugin/bridge/bridge.ts +2 -1
  15. package/telegram-plugin/card-format.ts +7 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +20 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +2197 -964
  18. package/telegram-plugin/dist/server.js +20 -2
  19. package/telegram-plugin/format.ts +305 -31
  20. package/telegram-plugin/gateway/gateway.ts +310 -70
  21. package/telegram-plugin/gateway/model-command.ts +173 -19
  22. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  23. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  24. package/telegram-plugin/package.json +1 -1
  25. package/telegram-plugin/session-tail.ts +47 -1
  26. package/telegram-plugin/stream-reply-handler.ts +19 -1
  27. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  28. package/telegram-plugin/tests/card-format.test.ts +28 -0
  29. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  30. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  31. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  32. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  33. package/telegram-plugin/tests/model-command.test.ts +213 -47
  34. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  35. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  36. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  37. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  39. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  40. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  41. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  42. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  43. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  44. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  45. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  46. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  47. package/telegram-plugin/text-voice-scrub.ts +68 -18
  48. package/telegram-plugin/tool-activity-summary.ts +20 -108
  49. package/telegram-plugin/tts-normalize.ts +377 -0
  50. package/telegram-plugin/uat/driver.ts +472 -22
  51. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  52. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  53. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  56. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  57. package/telegram-plugin/voice-ondemand.ts +161 -10
  58. package/telegram-plugin/voice-presynth.ts +242 -0
  59. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -17253,6 +17253,23 @@ function projectAssistantTextBlocks(content, make) {
17253
17253
  });
17254
17254
  return out;
17255
17255
  }
17256
+ function assistantLineCarriesAnswerSurface(content) {
17257
+ if (!Array.isArray(content))
17258
+ return false;
17259
+ for (const c of content) {
17260
+ const ct = c?.type ?? "";
17261
+ if (ct === "text") {
17262
+ const t = c.text;
17263
+ if (typeof t === "string" && t.trim().length > 0)
17264
+ return true;
17265
+ } else if (ct === "tool_use") {
17266
+ const name = c.name ?? "";
17267
+ if (name !== "Agent" && name !== "Task")
17268
+ return true;
17269
+ }
17270
+ }
17271
+ return false;
17272
+ }
17256
17273
  function projectTranscriptLine(line) {
17257
17274
  if (line.length > MAX_JSONL_LINE_BYTES)
17258
17275
  return [];
@@ -17414,7 +17431,7 @@ function projectSubagentLine(line, agentId, state) {
17414
17431
  }
17415
17432
  });
17416
17433
  const stopReason = message?.stop_reason;
17417
- if (stopReason === "end_turn") {
17434
+ if (stopReason === "end_turn" && assistantLineCarriesAnswerSurface(content)) {
17418
17435
  events.push({ kind: "sub_agent_turn_end", agentId });
17419
17436
  }
17420
17437
  return events;
@@ -24677,7 +24694,8 @@ var init_bridge = __esm(async () => {
24677
24694
  chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
24678
24695
  key: { type: "string", description: "Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`)." },
24679
24696
  scope: { type: "string", enum: ["read", "write"], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
24680
- reason: { type: "string", description: `REQUIRED in practice \u2014 short human-readable rationale rendered on the card (e.g. "to look up today's food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation \u2014 they will usually Deny. Always supply a one-line rationale.` },
24697
+ reason: { type: "string", description: 'REQUIRED in practice \u2014 short human-readable rationale rendered on the card (e.g. "to look up today\'s food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation \u2014 they will usually Deny. Always supply a one-line rationale. `why` is accepted as an alias (matching `vault_request_save`).' },
24698
+ why: { type: "string", description: "Alias for `reason` (matches the sibling tool `vault_request_save`). If both are supplied, `reason` wins." },
24681
24699
  duration: { type: "string", description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
24682
24700
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
24683
24701
  },
@@ -266,10 +266,35 @@ export function normalizeParagraphBreaks(text: string): string {
266
266
  // never touched. See splitCollapsedInlineBullets for the exact rule.
267
267
  const masked = splitCollapsedInlineBullets(maskedRaw)
268
268
 
269
- // Step 1: collapse 3+ newlines to exactly two. This also normalizes runs that
270
- // contain interleaved spaces only between the newlines is NOT done here —
271
- // we only touch pure newline runs so we never eat meaningful whitespace.
272
- let out = masked.replace(/\n{3,}/g, '\n\n')
269
+ // Step 1: collapse blank-line runs to exactly ONE clean blank line (`\n\n`),
270
+ // never leaving a whitespace-only line between two paragraphs.
271
+ //
272
+ // (a) Pure newline runs of 3+ → `\n\n`.
273
+ // (b) A blank-line run whose interior lines are ASCII-whitespace-only
274
+ // (spaces / tabs / CR) → `\n\n`. A model (or an upstream transform)
275
+ // that authors `A\n\n \n\nB` leaves a lone-space line between the
276
+ // paragraphs; CommonMark discards it (so it buys no gap) but it reads
277
+ // as an oversized / ragged gap in the raw text and in some clients, and
278
+ // it was the "stray blank line" seen in real replies. Collapse it.
279
+ //
280
+ // Deliberately ASCII-only: a line whose only content is U+00A0 is the
281
+ // INTENTIONAL, non-collapsible paragraph spacer added later by
282
+ // addParagraphSpacers (#2692) to force a visible gap on the rich-message
283
+ // path. This step runs BEFORE that spacer pass and must never eat a U+00A0
284
+ // line, so the `[ \t\r]` character class here excludes U+00A0 by
285
+ // construction. Runs on code-masked text, so a blank-ish line inside a
286
+ // fenced block is parked and never touched.
287
+ let out = masked
288
+ // Collapse any run of newlines interleaved with ASCII whitespace-only
289
+ // interior lines down to a single clean `\n\n`. Requires at least one
290
+ // whitespace char between the first two newlines OR 3+ newlines, so it
291
+ // fires on both `A\n \nB` (one space-only blank line) and `A\n\n \n\nB`
292
+ // (a space-only line inside a multi-blank run) but never rewrites a clean
293
+ // `A\n\nB` (no interior whitespace) — that already-correct gap is left to
294
+ // the `\n{3,}` pass below, which collapses any surviving run of 3+
295
+ // newlines (including pure `A\n\n\n\nB`) down to a single `\n\n`.
296
+ .replace(/\n[ \t\r]+\n(?:[ \t\r]*\n)*/g, '\n\n')
297
+ .replace(/\n{3,}/g, '\n\n')
273
298
 
274
299
  // Step 2: walk lines and promote lone prose breaks. We rebuild the string by
275
300
  // joining lines with the right separator. A separator is "hard" (` \n`) only
@@ -348,13 +373,14 @@ export const PARAGRAPH_SPACER = ' '
348
373
  * the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
349
374
  * the reliable trick.
350
375
  *
351
- * Conservative by construction — a spacer is inserted into a `\n\n` gap ONLY
352
- * when BOTH the block ENDING above the gap and the block STARTING below it are
353
- * ordinary prose. A gap adjacent to a structural block (list item, table row,
354
- * fenced code, blockquote, heading, indented code) is left exactly as it was:
355
- * those blocks already carry their own vertical rhythm, and a spacer paragraph
356
- * wedged against a tight list / table would either re-introduce the cramped
357
- * look from the other side or break the block's contiguity.
376
+ * Uniform-block-spacing contract: a spacer is inserted into EVERY `\n\n` gap
377
+ * that separates two DISTINCT blocks prose→prose, paragraph→list,
378
+ * list→paragraph, heading→anything, blockquote/table/fence boundaries so a
379
+ * mixed message renders with one identical visible blank line between blocks.
380
+ * The one exception is a gap INSIDE a block of the same structural kind (two
381
+ * items of a loose list, consecutive table rows/quotes/fences): those stay
382
+ * tight so the block's contiguity survives. Interiors joined by a single `\n`
383
+ * are never gaps at all and are untouched by construction.
358
384
  *
359
385
  * Runs on code-masked text (so a blank line inside a fenced block is never
360
386
  * touched) and is idempotent — a gap that already contains a U+00A0 spacer
@@ -385,15 +411,45 @@ export function addParagraphSpacers(text: string): string {
385
411
  // Trim ASCII-only (preserve U+00A0) so the spacer line is recognisable.
386
412
  const asciiTrim = (line: string): string => line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
387
413
 
388
- // A line is "prose" for spacing purposes when it is non-blank, not a
389
- // structural block marker, not the spacer line itself. Only a gap with prose
390
- // on BOTH sides earns a visible spacer.
391
- const isProseLine = (line: string): boolean => {
392
- if (isBlankLine(line)) return false
393
- if (asciiTrim(line) === spacerLine) return false
394
- if (isMarkerLine(line, placeholder)) return false
395
- // A standalone masked fenced block line is structural.
396
- if (isFenceOpenLine(line, placeholder)) return false
414
+ // Classify the block kind of a facing line so the spacer decision can be
415
+ // made per BLOCK TRANSITION (#uniform-block-spacing). A spacer is inserted
416
+ // at every `\n\n` gap between two DIFFERENT block kinds (paragraph→list,
417
+ // list→paragraph, heading→anything, blockquote/table boundaries) and
418
+ // between two prose paragraphs — but NEVER inside a single block's interior
419
+ // (between two items of the same loose list, two rows of a table, two
420
+ // quote lines, two fenced blocks). One visible blank line between distinct
421
+ // blocks, identical everywhere; list/table interiors stay tight.
422
+ type BlockKind = 'spacer' | 'list' | 'table' | 'quote' | 'heading' | 'fence' | 'divider' | 'prose'
423
+ const blockKind = (line: string): BlockKind => {
424
+ if (asciiTrim(line) === spacerLine) return 'spacer'
425
+ if (isFenceOpenLine(line, placeholder)) return 'fence'
426
+ if (isListItemLine(line)) return 'list'
427
+ if (isTableRowLine(line) || isTableDelimiterLine(line)) return 'table'
428
+ if (isBlockquoteLine(line)) return 'quote'
429
+ if (isHeadingLine(line)) return 'heading'
430
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trimStart())) return 'divider'
431
+ return 'prose'
432
+ }
433
+
434
+ // Same-kind structural pairs whose `\n\n` gap is a block INTERIOR (a loose
435
+ // list's item gap, consecutive tables/quotes/fences) — no spacer there.
436
+ const SAME_KIND_TIGHT: ReadonlySet<BlockKind> = new Set([
437
+ 'list',
438
+ 'table',
439
+ 'quote',
440
+ 'fence',
441
+ 'divider',
442
+ ])
443
+
444
+ const shouldSpaceGap = (above: string, below: string): boolean => {
445
+ const a = blockKind(above)
446
+ const b = blockKind(below)
447
+ // A facing spacer line means the gap is already spaced (idempotency —
448
+ // also guarded by alreadySpaced at the call site).
449
+ if (a === 'spacer' || b === 'spacer') return false
450
+ if (a === b && SAME_KIND_TIGHT.has(a)) return false
451
+ // Everything else is a genuine block transition (incl. prose→prose,
452
+ // heading→anything, list↔paragraph, table/quote boundaries) — space it.
397
453
  return true
398
454
  }
399
455
 
@@ -426,8 +482,7 @@ export function addParagraphSpacers(text: string): string {
426
482
  !alreadySpaced &&
427
483
  above != null &&
428
484
  below != null &&
429
- isProseLine(above) &&
430
- isProseLine(below)
485
+ shouldSpaceGap(above, below)
431
486
  ) {
432
487
  // Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
433
488
  // between two real blank lines so CommonMark renders it as a visible
@@ -469,6 +524,154 @@ function nextNonBlank(
469
524
  return null
470
525
  }
471
526
 
527
+ // ---------------------------------------------------------------------------
528
+ // Punctuation / bullet normalization — fleet-wide consistent typography
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Deterministic punctuation + bullet normalization for outbound messages
533
+ * (fleet-wide consistent Telegram formatting). Runs in the send path AFTER
534
+ * normalizeParagraphBreaks, on code-masked text (reuses maskCodeRegions), so
535
+ * code spans and fenced blocks are never touched.
536
+ *
537
+ * Transforms:
538
+ * 1. Space-flanked em/en dash (` — ` / ` – `) → `, ` — except a
539
+ * digit-flanked spaced dash (a numeric range `3 – 5`), which becomes a
540
+ * plain hyphen range (`3-5`).
541
+ * 2. Bare em-dash between word characters (`word—word`) → `, `; a
542
+ * digit-flanked one (`3—5`) → hyphen.
543
+ * 3. Bare en-dash between word characters → ASCII hyphen (`2019–2024` →
544
+ * `2019-2024`).
545
+ * 4. Leading unicode bullets (`•` / `·`) as list markers → `- ` so every
546
+ * list renders as a real GFM list (indent preserved).
547
+ *
548
+ * Idempotent: the output contains no em/en dashes or leading unicode bullets
549
+ * outside code regions, so a second pass is a no-op.
550
+ */
551
+ export function normalizePunctuation(text: string): string {
552
+ if (!/[—–•·]/.test(text)) return text
553
+
554
+ const nonce = Math.random().toString(36).slice(2)
555
+ const { masked, restore } = maskCodeRegions(text, nonce)
556
+
557
+ let out = masked
558
+ // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
559
+ // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
560
+ // dashes ("a — b — c") all normalize in one pass — a consumed \S would
561
+ // swallow the char that anchors the next match.
562
+ .replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
563
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
564
+ )
565
+ // 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
566
+ // Right flank is a lookahead for the same consecutive-match reason.
567
+ .replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
568
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
569
+ )
570
+ // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
571
+ .replace(/(\w)–(?=\w)/g, '$1-')
572
+
573
+ // 4. Leading unicode bullet markers → GFM `- ` (per line, indent kept).
574
+ out = out
575
+ .split('\n')
576
+ .map((line) => line.replace(/^([ \t]*)[•·][ \t]+/, '$1- '))
577
+ .join('\n')
578
+
579
+ return restore(out)
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // Over-bold tripwire — strip bold when a message is clearly over-bolded
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /** A line's content once a leading list marker (`- ` / `1. `) is removed. */
587
+ function listItemContent(line: string): string {
588
+ return line.trimStart().replace(/^(?:[-*+]|\d+[.)])\s+/, '')
589
+ }
590
+
591
+ /** True when a text fragment is one single fully-bolded span (`**…**`). */
592
+ function isFullyBolded(fragment: string): boolean {
593
+ return /^\*\*[^*]+\*\*[.,:;!?]?$/.test(fragment.trim())
594
+ }
595
+
596
+ /** Strip `**bold**` markers from a fragment, keeping the text. */
597
+ function unbold(fragment: string): string {
598
+ return fragment.replace(/\*\*([^*]+)\*\*/g, '$1')
599
+ }
600
+
601
+ /**
602
+ * Over-bold tripwire (fleet-wide consistent Telegram formatting). If a
603
+ * message is clearly over-bolded, strip the `**` markers and keep the text:
604
+ *
605
+ * - GLOBAL: when >30% of the message's non-code characters sit inside
606
+ * `**bold**` spans, ALL bold markers are stripped.
607
+ * - PER-BLOCK: an entire multi-line paragraph fully bolded, or a list whose
608
+ * EVERY item is fully bolded, has that block's bold stripped.
609
+ *
610
+ * Deliberately conservative:
611
+ * - Messages under 100 non-code characters are exempt (a short reply whose
612
+ * one key fact is bolded is exactly the house style).
613
+ * - A single-line fully-bolded paragraph of ≤48 chars is treated as a
614
+ * pseudo-heading (the "**Section**" label the fleet style encourages) and
615
+ * is NOT stripped by the per-block rule (it still counts toward the
616
+ * global ratio).
617
+ * - A list with any non-fully-bolded item is left alone.
618
+ *
619
+ * Code spans/fences are masked (maskCodeRegions) and never counted or
620
+ * modified. Idempotent: stripped output has no `**` spans left to trip on.
621
+ */
622
+ export function stripExcessBold(text: string): string {
623
+ if (!text.includes('**')) return text
624
+
625
+ const nonce = Math.random().toString(36).slice(2)
626
+ const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
627
+
628
+ // Non-code character budget: masked text with the placeholders removed.
629
+ const placeholderRe = new RegExp(
630
+ `${placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\d+\x00`,
631
+ 'g',
632
+ )
633
+ const visible = masked.replace(placeholderRe, '')
634
+ if (visible.length < 100) return restore(masked)
635
+
636
+ let boldChars = 0
637
+ for (const m of visible.matchAll(/\*\*([^*]+)\*\*/g)) boldChars += m[1].length
638
+
639
+ if (boldChars / visible.length > 0.3) {
640
+ // Clearly over-bolded — strip every bold span, keep the text.
641
+ return restore(unbold(masked))
642
+ }
643
+
644
+ // Per-block check on blank-line-delimited blocks of the masked text.
645
+ const blocks = masked.split(/\n{2,}/)
646
+ const rebuilt = blocks.map((block) => {
647
+ const lines = block.split('\n').filter((l) => l.trim() !== '')
648
+ if (lines.length === 0 || !block.includes('**')) return block
649
+
650
+ const listLines = lines.filter((l) => isListItemLine(l))
651
+ if (listLines.length >= 2 && listLines.length === lines.length) {
652
+ // A list block: strip only when EVERY item is fully bolded.
653
+ if (lines.every((l) => isFullyBolded(listItemContent(l)))) return unbold(block)
654
+ return block
655
+ }
656
+
657
+ // A prose paragraph (no structural marker lines): strip when every line
658
+ // is one fully-bolded span — except the single-short-line pseudo-heading.
659
+ const isProseBlock = lines.every((l) => !isMarkerLine(l, placeholder))
660
+ if (!isProseBlock) return block
661
+ if (!lines.every((l) => isFullyBolded(l))) return block
662
+ if (lines.length === 1 && lines[0].trim().length <= 48) return block
663
+ return unbold(block)
664
+ })
665
+
666
+ // Rejoin with the original gap shapes: split() lost them, so re-split the
667
+ // masked text capturing the separators and interleave.
668
+ const seps = masked.match(/\n{2,}/g) ?? []
669
+ let out = rebuilt[0] ?? ''
670
+ for (let i = 1; i < rebuilt.length; i++) out += (seps[i - 1] ?? '\n\n') + rebuilt[i]
671
+
672
+ return restore(out)
673
+ }
674
+
472
675
  // ---------------------------------------------------------------------------
473
676
  // Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
474
677
  // ---------------------------------------------------------------------------
@@ -558,8 +761,34 @@ function ensureBlockBoundaries(text: string, placeholder?: string): string {
558
761
  const startsFence = isFenceOpenLine(line, placeholder) && !isFenceOpenLine(prev, placeholder)
559
762
  const startsQuote = isBlockquoteLine(line) && !isBlockquoteLine(prev)
560
763
  const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
764
+ // List start glued to prose above by a single `\n` (uniform-block-
765
+ // spacing): a `- ` line already interrupts a paragraph in CommonMark,
766
+ // so the blank line is render-safe — it only lets the spacer pass see
767
+ // the transition. Never fires between two list items (prev is a list
768
+ // item) so list interiors stay tight.
769
+ const startsList = isListItemLine(line) && !isListItemLine(prev)
770
+
771
+ if (startsTableHere || startsFence || startsQuote || startsHeading || startsList) {
772
+ result.push('')
773
+ }
774
+ }
561
775
 
562
- if (startsTableHere || startsFence || startsQuote || startsHeading) {
776
+ // ---- Rule A2: blank line AFTER a closed code fence, before prose ----
777
+ // Prose glued directly onto a fence's close (single `\n`) can be swallowed
778
+ // or mis-parsed; a blank line after a closed fence is always CommonMark-safe.
779
+ // Masked blocks collapse to a single placeholder-leading line, so a fence
780
+ // "close" is a prev line that opens a fence (masked placeholder or literal
781
+ // ``` fence). Only fire when the current line is non-blank prose that is NOT
782
+ // itself a new block start (those are handled by Rule A above).
783
+ if (prevNonBlank && !curBlank && isFenceOpenLine(prev, placeholder)) {
784
+ const alreadySeparated = result.length > 0 && result[result.length - 1].trim() === ''
785
+ const curIsBlockStart =
786
+ isFenceOpenLine(line, placeholder) ||
787
+ isBlockquoteLine(line) ||
788
+ isHeadingLine(line) ||
789
+ isTableRowLine(line) ||
790
+ isTableDelimiterLine(line)
791
+ if (!alreadySeparated && !curIsBlockStart) {
563
792
  result.push('')
564
793
  }
565
794
  }
@@ -604,9 +833,13 @@ function isMarkerLine(line: string, placeholder?: string): boolean {
604
833
  t.startsWith('>') ||
605
834
  // ATX heading.
606
835
  /^#{1,6}\s/.test(t) ||
607
- // Table row (leading pipe) or table-ish line (interior ` | `).
608
- t.startsWith('|') ||
609
- line.includes(' | ') ||
836
+ // Table row (leading pipe) or a table delimiter row. A loose interior
837
+ // ` | ` substring misclassifies prose like `choose A | B` as a table row
838
+ // and suppresses paragraph spacing — require a real GFM table row/delimiter
839
+ // instead. (A header row lacking a leading pipe is still recognised as
840
+ // structural when its delimiter row follows, via ensureBlockBoundaries.)
841
+ isTableRowLine(line) ||
842
+ isTableDelimiterLine(line) ||
610
843
  // Fenced code delimiter (defensive — fences are masked, but a lone/odd
611
844
  // fence line can survive masking).
612
845
  t.startsWith('```') ||
@@ -633,7 +866,41 @@ function shouldPromoteBreak(prev: string, next: string, placeholder?: string): b
633
866
  // terminator (e.g. `He said "go."` or `(done.)`).
634
867
  const unwrapped = prevTrimmed.replace(/[)"'’”\]]+$/, '')
635
868
  const terminator = unwrapped.slice(-1)
636
- return terminator === '.' || terminator === '!' || terminator === '?' || terminator === ':'
869
+ if (terminator === '.' || terminator === '!' || terminator === '?' || terminator === ':') {
870
+ return true
871
+ }
872
+ // Stat-card / key-value promotion (#2750). A vertical card like
873
+ // `Calories: 1800 / Protein: 120g / Carbs: 200g` (or the fleet's HOUSE style
874
+ // `**Calories:** 1800 / **Protein:** 120g`) has lines ending in digits/
875
+ // letters, so the terminator rule above never fires and the whole card
876
+ // collapses into one wall of text. Promote a lone `\n` when the prev line
877
+ // reads as a standalone `label: value` stat line.
878
+ //
879
+ // The discriminator that separates a stat line from a mid-sentence soft wrap
880
+ // that merely contains a colon (`The deal has one catch: the buyer wants it
881
+ // and\nToronto lawyers sign off`) is two-fold:
882
+ // 1. The LABEL (text before the colon) is SHORT — a stat label is a term
883
+ // of a few words, a wrapped prose clause is a long run of words. We cap
884
+ // the label at 3 words / 24 chars.
885
+ // 2. The next line is NOT a lowercase-started mid-sentence continuation.
886
+ // A genuine wrapped sentence continues in lowercase; a stat/label line
887
+ // is followed by another label or capitalised prose.
888
+ // Markdown emphasis (`*` `_` `` ` ``) is stripped first so bold labels like
889
+ // `**Calories:**` are recognised. A prev with no colon never matches, so a
890
+ // plain soft-wrapped sentence is untouched.
891
+ const statStripped = prevTrimmed.replace(/[*_`]/g, '')
892
+ const colonIdx = statStripped.indexOf(':')
893
+ if (colonIdx > 0) {
894
+ const label = statStripped.slice(0, colonIdx).trim()
895
+ const value = statStripped.slice(colonIdx + 1)
896
+ const labelWords = label.length === 0 ? 0 : label.split(/\s+/).length
897
+ const isStatLine =
898
+ /^[ \t]+\S/.test(value) && labelWords >= 1 && labelWords <= 3 && label.length <= 24
899
+ if (isStatLine && !/^[a-z]/.test(nextTrimmed)) {
900
+ return true
901
+ }
902
+ }
903
+ return false
637
904
  }
638
905
 
639
906
  /**
@@ -711,10 +978,17 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
711
978
 
712
979
  if (cut <= 0) {
713
980
  // Could not find a safe boundary below maxLen — the region is one
714
- // indivisible block (e.g. a single huge fenced block). Emit the
715
- // whole remainder rather than loop forever.
716
- chunks.push(rest)
717
- break
981
+ // indivisible block (e.g. a single huge fenced block or an unbreakable
982
+ // token run). Emitting the oversized remainder whole makes Telegram
983
+ // reject it (RICH_MESSAGE_TEXT_TOO_LONG) and drops the whole answer, so
984
+ // fall back to a raw character slice: every piece is guaranteed <= maxLen
985
+ // (a degraded-but-delivered message beats a hard reject). hardSliceToCap
986
+ // returns the head chunk plus the rest; keep looping on the remainder so
987
+ // any trailing splittable region still gets normal boundary treatment.
988
+ const sliced = hardSliceToCap(rest, maxLen)
989
+ chunks.push(stripBoundarySpacers(sliced[0], 'trailing'))
990
+ rest = stripBoundarySpacers(sliced.slice(1).join(''), 'leading')
991
+ continue
718
992
  }
719
993
 
720
994
  chunks.push(stripBoundarySpacers(rest.slice(0, cut), 'trailing'))