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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +342 -6
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
|
@@ -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
|
/**
|