switchroom 0.18.25 → 0.18.26

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.
@@ -0,0 +1,126 @@
1
+ // Outbound guard against accidental TeX/inline-math typesetting of currency
2
+ // amounts (issue #3252).
3
+ //
4
+ // ── Root cause ───────────────────────────────────────────────────────────
5
+ // Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
6
+ // Telegram as RAW GFM markdown via `sendRichMessage({ markdown })` — Telegram
7
+ // parses the markdown server-side. Telegram's rich GFM parser honours the GFM
8
+ // inline-math extension: a `$...$` PAIR typesets the text between the two
9
+ // dollar signs as math (math-italic, U+1D400-range glyphs on the reader's
10
+ // screen). switchroom's renderer (`render.ts` → `escapeMarkdown`) escapes the
11
+ // other inline-formatting triggers (`` ` `` `*` `_` `~` `=` `[` `]` `|`) but
12
+ // NOT `$`. So a perfectly ordinary reply with TWO dollar amounts —
13
+ // "…ceiling is ~$0.5-0.9M but nearly all in small dealers … is ~$150-450k"
14
+ // — accidentally forms a math span across everything between the two `$`, and
15
+ // the reader sees the middle typeset as math-italic. history.db stores the
16
+ // clean ASCII; switchroom emits clean ASCII; the math is produced downstream
17
+ // by Telegram's rich-markdown parser from the unescaped `$…$` pair.
18
+ //
19
+ // ── Fix ──────────────────────────────────────────────────────────────────
20
+ // Deterministically break the `$…$` pairing on the wire by backslash-escaping
21
+ // EVERY prose `$` (`$` → `\$`) once the message looks like it carries currency
22
+ // — i.e. the prose (non-code) content holds 2+ total `$` AND at least one of
23
+ // them sits next to a digit (`$50`, `$.50`, or a trailing `50$`). A lone `$`
24
+ // (single total, or no digit-adjacent `$` anywhere) can never form a currency
25
+ // math span the way #3252 describes, so it is left byte-for-byte untouched.
26
+ // Code spans / fenced code blocks are NEVER touched (verbatim). Escaping every
27
+ // prose `$` (not just the `$digit` ones) is what closes the F3 false-negatives:
28
+ // `"$50 or 50$"`, `"$10 … from $ yesterday"`, and `"$.50 and $5"` all still form
29
+ // a `$…$` pair if only the leading-`$digit` token is escaped — so we escape the
30
+ // lot once the currency signal + 2-dollar threshold are met.
31
+ //
32
+ // Idempotent (F5): the escape uses a negative-lookbehind (`(?<!\\)\$`) so an
33
+ // already-escaped `\$` is never doubled to `\\$`. Running the guard twice (e.g.
34
+ // the streaming path renders then this wrapper re-wraps) is a strict no-op the
35
+ // second time.
36
+ //
37
+ // ── Where this runs (F1) ───────────────────────────────────────────────────
38
+ // This guard is invoked from `richMessage()` (rich-send.ts) — the ONE adapter
39
+ // every `{ markdown }` wire send funnels through (the `reply`-tool final answer
40
+ // via computeReplyChunks/sendReplyChunks, the draft-stream previews, cards,
41
+ // approvals, banners). That is the single deterministic seam that covers every
42
+ // markdown-parsed outbound exactly once. `plain`-mode degradations bypass
43
+ // `richMessage` (they go straight to `sendMessage`, no markdown parsing → no
44
+ // math), so they are correctly left untouched.
45
+ //
46
+ // Why `\$` and not a zero-width joiner (U+2060):
47
+ // - It reuses the EXACT mechanism switchroom already relies on for every
48
+ // other formatting char (`escapeMarkdown` backslash-escapes `~ = | * _`
49
+ // etc.), which Telegram's rich parser demonstrably strips and renders
50
+ // literally — the whole card system depends on it. `$` is ASCII
51
+ // punctuation, so CommonMark's "any ASCII punctuation may be
52
+ // backslash-escaped" rule applies identically.
53
+ // - The reader sees a literal `$`; copy-paste yields exactly `$0.5-0.9M`
54
+ // (the backslash is consumed by the parser, never in the rendered text or
55
+ // the clipboard). No zero-width characters that could pollute search /
56
+ // copy / re-tokenisation.
57
+ // - Fully deterministic (pure string transform), and a strict no-op for any
58
+ // message with fewer than two `$digit` tokens.
59
+ //
60
+ // Tradeoff (UNVERIFIED — see F4): this relies on Telegram's server-side rich
61
+ // GFM parser (a) recognising `$` as an escapable ASCII punctuation char and
62
+ // (b) CONSUMING the backslash so the reader sees a literal `$` and copy-paste
63
+ // yields `$0.5-0.9M` (no stray `\`). This is asserted by analogy to the
64
+ // `` ~ = | * _ `` chars that `escapeMarkdown` (format.ts) already backslash-
65
+ // escapes and that Telegram demonstrably strips — BUT note `escapeMarkdown`
66
+ // pointedly does NOT include `$` in its escape set, so we have no in-repo
67
+ // evidence that `\$` specifically round-trips. Vitest CANNOT cover this (it is
68
+ // server-side Telegram behaviour). If Telegram does NOT consume the backslash,
69
+ // guarded replies would show a visible `\$0.5-0.9M` — arguably worse than the
70
+ // math bug. **This is the one residual risk requiring human UAT before merge:**
71
+ // send a real two-dollar-amount message through a live agent and confirm it
72
+ // renders as a literal `$` (not `\$`, not math-italic). See the PR's UAT note.
73
+
74
+ // The code-span/fence splitter now lives in ONE shared module so every #3252
75
+ // guard reuses the same CommonMark-correct implementation. Re-exported here for
76
+ // backwards compatibility with any importer that reached for it via this file.
77
+ import { splitCodeSegments, splitProtectedSegments, type Segment } from "./code-segments.js";
78
+ export { splitCodeSegments, splitProtectedSegments, type Segment };
79
+
80
+ /** Counts every `$` in a segment (the total-dollar threshold input). */
81
+ const ANY_DOLLAR = /\$/g;
82
+
83
+ /** The "this is currency, not a stray symbol" signal: a `$` sitting next to a
84
+ * digit on either side — `$50` / `$.50` (leading) or `50$` (trailing), the
85
+ * latter covering European / trailing-symbol conventions. One such token in
86
+ * the prose is enough to arm the guard (combined with the 2+ total threshold).
87
+ * Non-global: used only as a boolean `.test`. */
88
+ const CURRENCY_SIGNAL = /\$\.?\d|\d\s?\$/;
89
+
90
+ /** Every prose `$` that is NOT already backslash-escaped. The negative
91
+ * lookbehind makes the escape idempotent (F5): a pre-existing `\$` is left
92
+ * alone, so running the guard twice never produces `\\$`. Global for replace. */
93
+ const UNESCAPED_DOLLAR = /(?<!\\)\$/g;
94
+
95
+ /**
96
+ * Neutralise accidental `$…$` inline-math typesetting of currency amounts.
97
+ *
98
+ * Operates on the FINAL rendered rich-markdown string (post-`render`). A no-op
99
+ * unless the prose (non-code) content holds 2+ total `$` AND at least one of
100
+ * them is digit-adjacent (a currency signal). When armed, EVERY unescaped prose
101
+ * `$` is backslash-escaped so no two `$` can pair into a math span — this is
102
+ * what closes the F3 trailing-`$` / `$.50` false-negatives. Code spans / fenced
103
+ * blocks are never touched. Idempotent (F5) and deterministic.
104
+ */
105
+ export function guardDollarMath(text: string): string {
106
+ if (!text.includes("$")) return text;
107
+ // Link-aware: a `$` inside a URL/table (rare but possible) is structural and
108
+ // must not be escaped. splitProtectedSegments skips code/links/autolinks/tables.
109
+ const segments = splitProtectedSegments(text);
110
+
111
+ let total = 0;
112
+ let hasCurrencySignal = false;
113
+ for (const seg of segments) {
114
+ if (seg.code) continue;
115
+ total += seg.text.match(ANY_DOLLAR)?.length ?? 0;
116
+ if (!hasCurrencySignal && CURRENCY_SIGNAL.test(seg.text)) hasCurrencySignal = true;
117
+ }
118
+ // Fewer than two dollars can never form a pair; without a digit-adjacent `$`
119
+ // the two dollars are (e.g.) `$foo`/`$bar` shell-var prose, which we leave
120
+ // alone to avoid escaping legitimate lone symbols (F3: no false positives).
121
+ if (total < 2 || !hasCurrencySignal) return text;
122
+
123
+ return segments
124
+ .map((seg) => (seg.code ? seg.text : seg.text.replace(UNESCAPED_DOLLAR, () => "\\$")))
125
+ .join("");
126
+ }
@@ -0,0 +1,158 @@
1
+ // Outbound guard against accidental inline-emphasis typesetting from `_`/`*`
2
+ // that the model never meant as formatting (issue #3252, sibling of the
3
+ // `$…$` currency-math guard in dollar-math-guard.ts).
4
+ //
5
+ // ── Root cause ───────────────────────────────────────────────────────────
6
+ // Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
7
+ // Telegram as RAW GFM markdown via `sendRichMessage({ markdown })` — Telegram
8
+ // parses the markdown server-side. Telegram's rich GFM parser pairs `_`/`*`
9
+ // delimiters into emphasis spans. The agent DELIBERATELY uses `**bold**`,
10
+ // `*italic*` and `_italic_` (Ken's rich-formatting directive) — that is a
11
+ // wanted feature and MUST survive untouched. But the same characters also
12
+ // occur in prose the model never meant to format:
13
+ // - snake_case identifiers — `file_name_here` italicises "name" once two
14
+ // intra-word `_` pair up (and Telegram's `_` pairing is more liberal than
15
+ // strict CommonMark, which does not emphasise intra-word underscores);
16
+ // - inline multiplication — `a*b*c`, `2*3` — where two intra-word `*` pair
17
+ // and italicise the middle factor;
18
+ // - mid-word `_`/`*` in paths / variable names.
19
+ // These are the direct sibling of the dollar bug: a delimiter that pairs up
20
+ // across prose and typesets a span nobody meant.
21
+ //
22
+ // ── Fix ──────────────────────────────────────────────────────────────────
23
+ // Deterministically break the pairing by backslash-escaping ONLY the clearly-
24
+ // accidental delimiters, identified by a single unambiguous signal:
25
+ //
26
+ // an INTRA-WORD delimiter — a single `_` or `*` with an ASCII alphanumeric
27
+ // character IMMEDIATELY on BOTH sides (`e_n`, `a*b`, `2_f`, `2*3`).
28
+ //
29
+ // This signal is chosen because intended emphasis can NEVER match it: an
30
+ // intended opener (`*italic*`, `_italic_`) is flanked on its OUTER side by a
31
+ // word boundary — whitespace, start-of-string, or punctuation — never by an
32
+ // alphanumeric. So an alphanumeric-on-both-sides delimiter is, by
33
+ // construction, not the opener or closer of an intended span. `**bold**` /
34
+ // `__x__` double runs are excluded for free: the inner neighbour of each `*`
35
+ // in `a**b` is another `*` (not alphanumeric), so neither half matches.
36
+ //
37
+ // What we DELIBERATELY LEAVE ALONE (conservative false-negatives, per the
38
+ // "when in doubt, leave it" doctrine inherited from the dollar guard):
39
+ // - Space-flanked operators (`3 * 4`): a whitespace-flanked `*`/`_` is
40
+ // neither left- nor right-flanking under GFM, so it can never open or
41
+ // close emphasis — there is no bug to fix, and escaping it would be pure
42
+ // churn.
43
+ // - Boundary-flanked delimiters (`rm *`, `*.ts`, leading-`_` `_private`):
44
+ // these are INDISTINGUISHABLE from an intended `*glob*` / `_italic_`
45
+ // opener (`*.ts is a glob*` is a legitimate italic). Escaping them would
46
+ // risk breaking intended emphasis — the one thing this guard must never
47
+ // do — so a glob/leading-underscore that pairs into an accidental span is
48
+ // accepted as a rare false-negative rather than risked as a false-positive.
49
+ // (A future arm could target these behind live Telegram UAT; see below.)
50
+ //
51
+ // Idempotent: the escape is expressed as an intra-word match, so an
52
+ // already-escaped `\_`/`\*` has a backslash (not an alphanumeric) immediately
53
+ // before the delimiter and therefore never re-matches — running the guard
54
+ // twice is a strict no-op. Code spans / fenced code blocks are NEVER touched
55
+ // (verbatim), via the same splitCodeSegments logic the dollar guard uses.
56
+ //
57
+ // ── Where this runs ────────────────────────────────────────────────────────
58
+ // Like the dollar guard, this is intended to be invoked from `richMessage()`
59
+ // (rich-send.ts) — the single seam every `{ markdown }` wire send funnels
60
+ // through. THIS commit only adds the pure function + its test; the seam
61
+ // wiring is integrated separately (all guards in one pass) to avoid conflicts.
62
+ //
63
+ // ── UNVERIFIED (needs live Telegram UAT) ───────────────────────────────────
64
+ // Same residual risk as the dollar guard: this relies on Telegram's server-
65
+ // side rich GFM parser (a) recognising `\_` / `\*` as escapable ASCII
66
+ // punctuation and (b) CONSUMING the backslash so the reader sees a literal
67
+ // `_` / `*` and copy-paste yields `file_name` / `a*b` (no stray `\`). This is
68
+ // asserted by analogy to the `` ~ = | * _ `` chars `escapeMarkdown`
69
+ // (format.ts) already backslash-escapes and that Telegram demonstrably strips.
70
+ // Vitest CANNOT cover server-side rendering; a real intra-word `_`/`*` message
71
+ // through a live agent must confirm it renders literal (not `\_`, not italic).
72
+
73
+ // Segment splitting (code spans / fences AND markdown link destinations /
74
+ // autolinks / table rows) is shared across all #3252 guards — one source of
75
+ // truth in render/code-segments.ts. Routing through `splitProtectedSegments`
76
+ // (not the code-only `splitCodeSegments`) is what makes this guard link-aware:
77
+ // an intra-word `_`/`*` inside a URL path (`.../a_b_c`) is STRUCTURAL, not
78
+ // prose, and must never be escaped (the link would break). See code-segments.ts.
79
+ import { splitProtectedSegments } from "./code-segments.js";
80
+
81
+ /** An intra-word underscore: a single `_` with an ASCII alphanumeric on BOTH
82
+ * immediate sides (`file_name`, `v2_final`). Intended `_italic_` never
83
+ * matches — its opener is boundary-flanked on the outer side. The
84
+ * alphanumeric lookbehind also makes the escape idempotent: an already-
85
+ * escaped `\_` has `\` (not alnum) before the `_`, so it never re-matches. */
86
+ const INTRA_WORD_UNDERSCORE = /(?<=[A-Za-z0-9])_(?=[A-Za-z0-9])/g;
87
+
88
+ /** An intra-word asterisk: a single `*` with an ASCII alphanumeric on BOTH
89
+ * immediate sides (`a*b`, `2*3`). Intended `*italic*` / `**bold**` never
90
+ * match — italic openers are boundary-flanked, and in a `**` run the inner
91
+ * neighbour is `*` (not alnum). Idempotent for the same reason as above. */
92
+ const INTRA_WORD_ASTERISK = /(?<=[A-Za-z0-9])\*(?=[A-Za-z0-9])/g;
93
+
94
+ /** Every unescaped `_` / `*` in prose (the pair-threshold input). An emphasis
95
+ * span needs a MATCHING pair of the same delimiter, so a delimiter can only
96
+ * mis-render when 2+ of it exist. The `(?<!\\)` keeps the count idempotent. */
97
+ const ANY_UNDERSCORE = /(?<!\\)_/g;
98
+ const ANY_ASTERISK = /(?<!\\)\*/g;
99
+
100
+ /**
101
+ * Neutralise accidental inline emphasis produced by intra-word `_` / `*`.
102
+ *
103
+ * Operates on the FINAL rendered rich-markdown string (post-`render`).
104
+ *
105
+ * ── Threshold: 2+ of the delimiter (finding 4) ────────────────────────────
106
+ * A `_` (or `*`) escape only fires when the prose holds an intra-word
107
+ * occurrence of that delimiter AND 2+ TOTAL of it. Emphasis is a PAIRED
108
+ * construct — a single `_` (or `*`) in the whole message can never form a span,
109
+ * so escaping a lone intra-word delimiter is pure churn that only widens the
110
+ * false-positive surface (a stray `\` if Telegram ever failed to consume it),
111
+ * with zero correctness gain. Requiring 2+ matches the dollar / inline-pairs
112
+ * "a lone delimiter can't pair" doctrine. This is strictly SAFE: any real
113
+ * mis-render needs 2+ delimiters, and when 2+ exist (e.g. `file_name_here`, or
114
+ * an intra-word `file_name` sitting alongside an intended `_italic_` elsewhere —
115
+ * three underscores that Telegram could mis-pair) the intra-word delimiter IS
116
+ * escaped, so no accidental span survives. Only the provably-inert lone-`_`
117
+ * (`file_name` as the ONLY underscore in the message) is now left byte-identical.
118
+ *
119
+ * Intended `**bold**`, `*italic*` and `_italic_` are LEFT UNTOUCHED by
120
+ * construction (their delimiters are boundary-flanked, never intra-word).
121
+ * Code spans / fenced blocks, link destinations and autolinks are never touched
122
+ * (via `splitProtectedSegments`). Idempotent and deterministic.
123
+ */
124
+ export function guardAccidentalEmphasis(text: string): string {
125
+ if (!text.includes("_") && !text.includes("*")) return text;
126
+ const segments = splitProtectedSegments(text);
127
+
128
+ // NO-OP guard: only rewrite when a real intra-word signal exists in prose AND
129
+ // 2+ of that delimiter exist (so a pair — hence a mis-render — is possible).
130
+ let hasIntraUnderscore = false;
131
+ let hasIntraAsterisk = false;
132
+ let underscoreCount = 0;
133
+ let asteriskCount = 0;
134
+ for (const seg of segments) {
135
+ if (seg.code) continue;
136
+ if (INTRA_WORD_UNDERSCORE.test(seg.text)) hasIntraUnderscore = true;
137
+ if (INTRA_WORD_ASTERISK.test(seg.text)) hasIntraAsterisk = true;
138
+ underscoreCount += seg.text.match(ANY_UNDERSCORE)?.length ?? 0;
139
+ asteriskCount += seg.text.match(ANY_ASTERISK)?.length ?? 0;
140
+ }
141
+ // Reset lastIndex — the /g regexes above are stateful across .test() calls.
142
+ INTRA_WORD_UNDERSCORE.lastIndex = 0;
143
+ INTRA_WORD_ASTERISK.lastIndex = 0;
144
+
145
+ const armUnderscore = hasIntraUnderscore && underscoreCount >= 2;
146
+ const armAsterisk = hasIntraAsterisk && asteriskCount >= 2;
147
+ if (!armUnderscore && !armAsterisk) return text;
148
+
149
+ return segments
150
+ .map((seg) => {
151
+ if (seg.code) return seg.text;
152
+ let out = seg.text;
153
+ if (armUnderscore) out = out.replace(INTRA_WORD_UNDERSCORE, "\\_");
154
+ if (armAsterisk) out = out.replace(INTRA_WORD_ASTERISK, "\\*");
155
+ return out;
156
+ })
157
+ .join("");
158
+ }
@@ -0,0 +1,171 @@
1
+ // Outbound guard against ACCIDENTAL inline-pair typesetting — the sibling of the
2
+ // dollar-math guard (#3252 follow-up). Mirrors that guard's doctrine exactly.
3
+ //
4
+ // ── Root cause (same seam, same class of bug) ─────────────────────────────
5
+ // Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
6
+ // Telegram as RAW GFM markdown via `sendRichMessage({ markdown })`; Telegram
7
+ // parses it server-side. Telegram's rich GFM parser honours several PAIRED
8
+ // inline-formatting delimiters that ordinary prose can form by accident:
9
+ //
10
+ // • `~text~` / `~~text~~` → strikethrough
11
+ // • `==text==` → highlight / "marked" (Telegram 10.1 supports it)
12
+ // • `||text||` → spoiler
13
+ // • `` `text` `` → code span
14
+ //
15
+ // switchroom's `escapeMarkdown` (format.ts) backslash-escapes exactly these
16
+ // trigger chars (`` ` `` `*` `_` `~` `=` `|` `[` `]`) for DYNAMIC card content —
17
+ // which is our IN-REPO EVIDENCE that Telegram's rich parser genuinely INTERPRETS
18
+ // `~ = |` and `` ` `` as formatting (otherwise escaping them would be pointless).
19
+ // But the assistant's free-form reply body is NOT run through escapeMarkdown
20
+ // (that would nuke the agent's DELIBERATE `~~strike~~` / `||spoiler||` /
21
+ // `` `code` `` formatting — Ken's rich-formatting directive). So an ordinary
22
+ // reply like "trims it to ~10 units, down from ~20" can accidentally form a
23
+ // `~…~` strikethrough span across "10 units, down from ".
24
+ //
25
+ // ── Which of the four we actually guard (and why) ─────────────────────────
26
+ // Ranked by real-world likelihood in normal agent prose:
27
+ //
28
+ // 1. TILDE `~` — HIGH. Approximation tildes on numbers ("~10", "~$5M",
29
+ // "~.5s") are extremely common in agent prose and two of them form a
30
+ // strikethrough pair. GUARDED.
31
+ // 2. MARK `==` — MEDIUM. Equality/comparison operators in prose ("x==y",
32
+ // "if a==b and c==d") sit word-flanked and two of them form a highlight
33
+ // span. GUARDED.
34
+ // 3. SPOILER `||` — LOW. Logical-OR in prose ("a||b || c||d"). Rare but the
35
+ // same word-flanked shape, same fix. GUARDED (cheap, precise).
36
+ // 4. BACKTICK `` ` `` — SKIPPED as effectively-literal. A TRULY unpaired
37
+ // backtick renders LITERALLY per CommonMark (a backtick run with no
38
+ // equal-length closer is left as text — this is exactly what
39
+ // `findClosingBackticks` returns -1 for, so `splitCodeSegments` already
40
+ // leaves it in a PROSE segment untouched). The only case a stray backtick
41
+ // "mis-renders" is an ODD count where an earlier pair swallows prose into a
42
+ // code span — but that is INDISTINGUISHABLE from an intended `` `code` ``
43
+ // span plus a separate literal backtick, and the agent uses `` `code` ``
44
+ // deliberately. Per the false-negative-beats-false-positive rule we leave
45
+ // it. (Neutralising it would risk mangling real code spans, which the task
46
+ // forbids.) See the design note for the full verification.
47
+ //
48
+ // ── The heuristic — precision over recall, never touch intended formatting ──
49
+ // The hard constraint: the agent DELIBERATELY emits `~~strike~~`, `||spoiler||`
50
+ // and `` `code` `` as wanted formatting. We must neutralise ONLY the
51
+ // unmistakably-accidental shape and leave every intended span byte-identical.
52
+ //
53
+ // • TILDE: a `~` is accidental only when it is DIGIT-ADJACENT — immediately
54
+ // followed by an optional `$`/`.` and a digit (`~10`, `~$5`, `~.5`). Intended
55
+ // strikethrough wraps WORDS (`~~deprecated~~`, `~struck~`) → its tildes are
56
+ // letter-adjacent, never matched. We arm only when 2+ such digit-adjacent
57
+ // tildes exist in prose (one alone can never form a pair), then escape them.
58
+ // This also catches the inner tilde of a `~~10` double-open.
59
+ //
60
+ // • MARK / SPOILER: the delimiter is accidental only when it is a binary
61
+ // OPERATOR — i.e. flanked by a word char on BOTH sides (`x==y`, `a||b`).
62
+ // An INTENDED `==mark==` / `||spoiler||` has its opening delimiter preceded
63
+ // by whitespace/line-start and its closing delimiter followed by
64
+ // whitespace/line-end, so it is NEVER word-flanked on both sides. We arm only
65
+ // when 2+ such both-word-flanked operators exist (a lone one cannot form a
66
+ // span, and a space-flanked `a == b` cannot open a span under CommonMark
67
+ // left/right-flanking rules, so both are correctly left alone), then escape.
68
+ //
69
+ // When in doubt we LEAVE IT: a missed neutralisation (reader sees an accidental
70
+ // strike) is strictly better than shredding the agent's intended formatting.
71
+ //
72
+ // ── Mechanics (identical to the dollar guard) ─────────────────────────────
73
+ // • Code-span/fence aware: reuses `splitCodeSegments` from dollar-math-guard so
74
+ // NOTHING inside a `` `code` `` span or fenced block is ever touched.
75
+ // • Deterministic pure string transform; strict NO-OP unless a real signal is
76
+ // present (2+ of a construct in prose).
77
+ // • Idempotent: escaped output (`\~`, `\=\=`, `\|\|`) can never re-match the
78
+ // triggers (the `~` case uses a `(?<!\\)` negative-lookbehind; the `==`/`||`
79
+ // cases can't recur because the escaped form no longer contains two
80
+ // consecutive `=`/`|`), so running the guard twice is byte-for-byte stable.
81
+ //
82
+ // ── Telegram assumptions requiring live UAT (Vitest CANNOT cover these) ────
83
+ // (a) That Telegram's rich parser CONSUMES the backslash so the reader sees a
84
+ // literal `~` / `=` / `|` (not a visible `\`). Asserted by analogy to
85
+ // `escapeMarkdown`, which backslash-escapes these very chars and which the
86
+ // whole card system depends on Telegram stripping.
87
+ // (b) Whether a SINGLE `~` renders strikethrough on the rich path (GitHub GFM
88
+ // needs `~~`; Telegram MarkdownV2 uses single `~`). Our digit-adjacent
89
+ // heuristic is correct under BOTH readings, but the *likelihood* ranking of
90
+ // the tilde case depends on it.
91
+ // (c) That `==` highlight and `||` spoiler render for the word-flanked operator
92
+ // shape (left/right-flanking behaviour).
93
+ // Send real prose through a live agent and confirm literal glyphs before merge.
94
+
95
+ // `splitProtectedSegments` skips code spans/fences AND markdown link
96
+ // destinations / autolinks / GFM table rows verbatim — so a `~`/`==`/`||` that
97
+ // is STRUCTURAL (in a URL query like `?a==1`, or a table's empty cell `|a||b|`)
98
+ // is never escaped. See code-segments.ts.
99
+ import { splitProtectedSegments } from './code-segments.js'
100
+
101
+ /** Digit-adjacent tilde: `~` directly before an optional `$`/`.` and a digit
102
+ * (`~10`, `~$5`, `~.5`). The `(?<!\\)` keeps it idempotent — an already-escaped
103
+ * `\~` is never re-escaped. Global: used for both counting and replacement. */
104
+ const TILDE_APPROX = /(?<!\\)~(?=\$?\.?\d)/g
105
+
106
+ /** A `==` acting as a binary operator: word char on BOTH sides (`x==y`). Never
107
+ * matches an intended `==mark==` (whose delimiters are whitespace-flanked on
108
+ * the outside). Idempotency is structural — the escaped `\=\=` holds no two
109
+ * consecutive `=`, so it can never re-match. Global. */
110
+ const MARK_OP = /(?<=\w)==(?=\w)/g
111
+
112
+ /** A `||` acting as a binary operator: word char on BOTH sides (`a||b`). Never
113
+ * matches an intended `||spoiler||`. Structurally idempotent like `MARK_OP`.
114
+ * Global. */
115
+ const SPOILER_OP = /(?<=\w)\|\|(?=\w)/g
116
+
117
+ /** Count matches of a global regex in a string (throwaway, resets lastIndex via
118
+ * match). */
119
+ function countMatches(text: string, re: RegExp): number {
120
+ return text.match(re)?.length ?? 0
121
+ }
122
+
123
+ /**
124
+ * Neutralise ACCIDENTAL inline-pair formatting (`~…~` strikethrough,
125
+ * `==…==` highlight, `||…||` spoiler) that Telegram's rich GFM parser would
126
+ * typeset from ordinary prose.
127
+ *
128
+ * Operates on the FINAL rendered rich-markdown string (post-`render`), at the
129
+ * same `richMessage()` seam as `guardDollarMath`. A strict NO-OP unless the
130
+ * prose (non-code) content holds 2+ of a construct in its clearly-accidental
131
+ * shape (digit-adjacent tildes; word-flanked `==`/`||` operators). Code spans /
132
+ * fenced blocks are NEVER touched, and the agent's DELIBERATE `~~strike~~` /
133
+ * `||spoiler||` / `==mark==` / `` `code` `` formatting passes through untouched.
134
+ * Deterministic and idempotent.
135
+ */
136
+ export function guardAccidentalInlinePairs(text: string): string {
137
+ // Fast bail: none of the guarded trigger chars are present.
138
+ if (!/[~=|]/.test(text)) return text
139
+
140
+ const segments = splitProtectedSegments(text)
141
+
142
+ // Arm each construct independently, counting only PROSE (never code). A single
143
+ // occurrence can never form a pair, so the threshold is 2.
144
+ let tildes = 0
145
+ let marks = 0
146
+ let spoilers = 0
147
+ for (const seg of segments) {
148
+ if (seg.code) continue
149
+ tildes += countMatches(seg.text, TILDE_APPROX)
150
+ marks += countMatches(seg.text, MARK_OP)
151
+ spoilers += countMatches(seg.text, SPOILER_OP)
152
+ }
153
+
154
+ const armTilde = tildes >= 2
155
+ const armMark = marks >= 2
156
+ const armSpoiler = spoilers >= 2
157
+ if (!armTilde && !armMark && !armSpoiler) return text
158
+
159
+ return segments
160
+ .map((seg) => {
161
+ if (seg.code) return seg.text
162
+ let out = seg.text
163
+ // `\~` — first tilde of an accidental pair can no longer pair.
164
+ if (armTilde) out = out.replace(TILDE_APPROX, () => '\\~')
165
+ // `\=\=` / `\|\|` — the operator can no longer open/close a span.
166
+ if (armMark) out = out.replace(MARK_OP, () => '\\=\\=')
167
+ if (armSpoiler) out = out.replace(SPOILER_OP, () => '\\|\\|')
168
+ return out
169
+ })
170
+ .join('')
171
+ }
@@ -0,0 +1,167 @@
1
+ // Outbound guard against accidental LINE-START BLOCK CONSTRUCTS (issue #3252,
2
+ // sibling of dollar-math-guard.ts).
3
+ //
4
+ // ── Root cause ───────────────────────────────────────────────────────────
5
+ // Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
6
+ // Telegram as RAW GFM markdown via `sendRichMessage({ markdown })`. Telegram
7
+ // parses that markdown server-side with a CommonMark/GFM-family parser. That
8
+ // means a line of ordinary PROSE that merely happens to START with a markdown
9
+ // block-construct trigger (`#`, `>`, `-`/`+`/`*`, or `N.`) can be silently
10
+ // promoted to a heading / blockquote / bullet / ordered-list item the model
11
+ // never intended:
12
+ // "> 50% of users" → blockquote ">2x faster" → blockquote
13
+ // "# of items" → heading "#1 priority" → (see below)
14
+ // "- 5 degrees" → bullet "2026. was a great year" → ol item
15
+ //
16
+ // ── Why this is the HARD, AMBIGUOUS guard family ─────────────────────────
17
+ // Unlike the `$…$` currency case (where math typesetting of a two-dollar span
18
+ // is NEVER wanted), the agent DELIBERATELY emits headings, bullets, ordered
19
+ // lists and blockquotes — Ken's rich-formatting directive treats them as a
20
+ // wanted feature. The SAME leading character means "format this" (intended)
21
+ // or "literal prose" (accidental) depending purely on authorial intent, which
22
+ // is not recoverable from the bytes. A blanket line-start escape would DESTROY
23
+ // intended formatting and is unacceptable. So this guard is deliberately
24
+ // CONSERVATIVE: it escapes ONLY the narrow sub-patterns that are unmistakably
25
+ // accidental and can be disambiguated DETERMINISTICALLY from the byte stream,
26
+ // and it leaves every plausibly-intended construct byte-for-byte untouched.
27
+ // Everything that cannot be made safe is DEFERRED (see the block comment on
28
+ // `escapeAccidentalLineStart` and guard-linestart-note.md), NOT guessed at.
29
+ //
30
+ // ── What this guard ACTUALLY escapes (the safe subset) ───────────────────
31
+ // 1. Blockquote comparison-operator: a line-start `>` glued DIRECTLY to a
32
+ // digit or `=` (`>2x`, `>50%`, `>=3`). An INTENDED blockquote is always
33
+ // written `> ` WITH a space; `>` glued to a digit/`=` is a "greater than"
34
+ // comparison in prose, never a blockquote. Escaping only the no-space,
35
+ // digit/`=`-adjacent form leaves every real `> quoted line` untouched.
36
+ // 2. Ordered-list with a 4+ digit "number": a line starting `2026. ` /
37
+ // `1999) ` — i.e. a YEAR or other 4+ digit integer followed by `.`/`)`
38
+ // and a space. No real numbered list is authored starting at item 2026;
39
+ // 4+ digit leading integers are effectively always accidental years/
40
+ // quantities. Real lists (`1.`–`999.`) are LEFT ALONE.
41
+ // Both are backslash-escaped (`\>`, `2026\.`) exactly like the dollar guard —
42
+ // `>`, `.`, `)` are ASCII punctuation, escapable per CommonMark's "any ASCII
43
+ // punctuation may be backslash-escaped" rule; Telegram's rich parser strips
44
+ // the backslash the same way `escapeMarkdown` relies on for `~ = | * _`.
45
+ //
46
+ // ── What is DEFERRED (left to the rich-formatting workstream) ────────────
47
+ // • Heading `# ` (with the required space): indistinguishable from an
48
+ // INTENDED heading. `#1` / `#foo` (NO space) is not a GFM heading at all
49
+ // (ATX headings require `#`+space) → Telegram renders it literally → no
50
+ // guard needed. So there is no safely-guardable heading sub-case.
51
+ // • Bullet lists `-`/`+`/`*` + space (`- 5 degrees`): genuinely ambiguous
52
+ // with the heavily-used bullet construct; the glued form `-5` (no space)
53
+ // is not a list item → already literal → no guard needed. Escaping the
54
+ // spaced form would eat intended bullets, so the whole family is deferred.
55
+ // • Ordered-list with 1–3 digit numbers (`1. `, `42. `): ambiguous with a
56
+ // real numbered list. Decimals (`3.14`) have no space after the dot → not
57
+ // a list item → already literal.
58
+ //
59
+ // ── Where this runs ──────────────────────────────────────────────────────
60
+ // Same seam as the dollar guard: `richMessage()` (rich-send.ts) — the ONE
61
+ // adapter every `{ markdown }` wire send funnels through. Integration of this
62
+ // function into that seam is done SEPARATELY by the integrator; this file only
63
+ // exports the pure transform + its helpers. `plain`-mode degradations bypass
64
+ // `richMessage` (no markdown parsing) and are correctly untouched.
65
+ //
66
+ // Idempotent: an already-escaped `\>` / `2026\.` no longer matches the
67
+ // accidental patterns (the leading char is now `\`), so re-running is a strict
68
+ // no-op. Code spans / fenced code blocks and 4-space indented code lines are
69
+ // NEVER touched.
70
+ //
71
+ // ── Telegram-parser assumptions requiring live UAT (see note) ────────────
72
+ // Vitest cannot cover server-side Telegram rendering. The two claims this
73
+ // guard rests on — (a) Telegram promotes `>2x` (no space) and `2026. ` to
74
+ // blockquote/ordered-list, and (b) it CONSUMES the escaping backslash so the
75
+ // reader sees a literal `>` / `.` (not `\>` / `\.`) — are asserted by analogy
76
+ // to CommonMark + the `escapeMarkdown` chars Telegram demonstrably strips.
77
+ // Both need a live round-trip before merge. See guard-linestart-note.md.
78
+
79
+ // Segment splitting is shared across all #3252 guards — one source of truth in
80
+ // render/code-segments.ts. `splitProtectedSegments` skips code spans/fences AND
81
+ // link destinations / autolinks / GFM table rows verbatim.
82
+ import { splitProtectedSegments } from "./code-segments.js";
83
+
84
+ /** Line-start `>` glued directly to a digit or `=` — the "greater than"
85
+ * comparison-operator prose that Telegram wrongly quotes. NOT matched when a
86
+ * space follows the `>` (that is an intended blockquote). */
87
+ const ACCIDENTAL_BLOCKQUOTE = /^>[0-9=]/;
88
+
89
+ /** Line-start 4+ digit integer followed by `.`/`)` then a space or end-of-line
90
+ * — a year/quantity Telegram wrongly promotes to an ordered-list item. Real
91
+ * lists (1–3 digit markers) are excluded by the `{4,}` bound. */
92
+ const ACCIDENTAL_ORDERED_LIST = /^(\d{4,})([.)])(\s|$)/;
93
+
94
+ /**
95
+ * Escape the accidental block-construct trigger at the start of ONE line
96
+ * (the string must NOT contain a newline). Applies only when the line is a
97
+ * true line start (the caller guarantees this). A no-op unless the line begins
98
+ * with one of the narrow, deterministically-accidental patterns above.
99
+ *
100
+ * Lines indented 4+ spaces are an indented-code context in CommonMark and are
101
+ * left verbatim.
102
+ */
103
+ function escapeAccidentalLineStart(line: string): string {
104
+ const indent = /^ */.exec(line)![0];
105
+ // 4+ leading spaces => indented code block; never a block construct here.
106
+ if (indent.length >= 4) return line;
107
+ const rest = line.slice(indent.length);
108
+
109
+ // 1. Accidental blockquote (`>2x`, `>50%`, `>=3`). Idempotent: an already
110
+ // escaped `\>` starts with `\`, so `rest` no longer starts with `>`.
111
+ if (ACCIDENTAL_BLOCKQUOTE.test(rest)) {
112
+ return indent + "\\" + rest;
113
+ }
114
+
115
+ // 2. Accidental ordered-list from a 4+ digit number (`2026. was`).
116
+ // Idempotent: `2026\.` has a `\` where the delimiter was, so the
117
+ // `(\d{4,})([.)])` shape no longer matches.
118
+ const ol = ACCIDENTAL_ORDERED_LIST.exec(rest);
119
+ if (ol) {
120
+ const digits = ol[1];
121
+ const delim = ol[2];
122
+ return indent + digits + "\\" + delim + rest.slice(digits.length + 1);
123
+ }
124
+
125
+ return line;
126
+ }
127
+
128
+ /**
129
+ * Neutralise accidental line-start block constructs (heading / blockquote /
130
+ * bullet / ordered-list promotion of prose) on the FINAL rendered rich-markdown
131
+ * string (post-`render`). CONSERVATIVE and deterministic: escapes ONLY the two
132
+ * unmistakably-accidental patterns documented above and leaves all plausibly
133
+ * intended formatting untouched. Code spans / fenced blocks / 4-space indented
134
+ * code are never touched. Idempotent and a strict no-op absent a real signal.
135
+ */
136
+ export function guardAccidentalBlockConstructs(text: string): string {
137
+ // Cheap short-circuit: no `>` and no plausible 4+ digit list marker => no-op.
138
+ if (!text.includes(">") && !/\d{4,}[.)]/.test(text)) return text;
139
+
140
+ const segments = splitProtectedSegments(text);
141
+ let out = "";
142
+ // True at text start and immediately after any emitted `\n`.
143
+ let atLineStart = true;
144
+
145
+ for (const seg of segments) {
146
+ if (seg.code) {
147
+ out += seg.text;
148
+ // Code spans never contain a newline; fenced blocks end on backticks, not
149
+ // a newline. Either way the next char is on the same line unless the code
150
+ // text itself ends with a newline.
151
+ atLineStart = seg.text.endsWith("\n");
152
+ continue;
153
+ }
154
+ const lines = seg.text.split("\n");
155
+ for (let k = 0; k < lines.length; k++) {
156
+ // A prose segment can begin MID-LINE (right after an inline code span),
157
+ // so its first line is a real line start only if the running flag says so.
158
+ const lineIsAtStart = k === 0 ? atLineStart : true;
159
+ const processed = lineIsAtStart ? escapeAccidentalLineStart(lines[k]) : lines[k];
160
+ out += processed;
161
+ if (k < lines.length - 1) out += "\n";
162
+ }
163
+ atLineStart = seg.text.endsWith("\n");
164
+ }
165
+
166
+ return out;
167
+ }
@@ -67,6 +67,13 @@ export function renderOutbound(
67
67
  maxLen: number = RICH_MESSAGE_MAX_CHARS,
68
68
  ): RenderResult {
69
69
  return renderSafe(parse(text), text, maxLen);
70
+ // #3252 note: the `$…$` currency-math neutraliser (`guardDollarMath`) is NOT
71
+ // applied here. It lives at the single wire seam — `richMessage()` in
72
+ // rich-send.ts — through which EVERY `{ markdown }` send funnels (the
73
+ // reply-tool final answer, the draft-stream previews this renderer feeds,
74
+ // cards, approvals). Applying it there guards every markdown-parsed outbound
75
+ // exactly once (F1), and `plain`-mode degradations correctly bypass it. See
76
+ // dollar-math-guard.ts for the rationale.
70
77
  }
71
78
 
72
79
  /**