switchroom 0.18.24 → 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.
Files changed (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -120,6 +120,26 @@ export interface Turn {
120
120
  * Null for turns that were never resumed.
121
121
  */
122
122
  resumed_at: number | null
123
+ /**
124
+ * The claude session id (the `<sessionId>.jsonl` transcript stem) that
125
+ * produced this turn's assistant output, stamped DURING the turn as soon as
126
+ * the first session event is observed (see `stampTurnSessionId`). Crash-
127
+ * survival redelivery uses this to resolve the EXACT transcript file for an
128
+ * interrupted turn, instead of `findActiveSessionFile`'s most-recent-mtime
129
+ * heuristic — which can shadow the new boot session's own transcript. Null
130
+ * until the turn produces its first session event (or for pre-migration rows).
131
+ */
132
+ session_id: string | null
133
+ /**
134
+ * Ms epoch at which the interrupted turn's captured-but-undelivered final
135
+ * answer was re-sent to the user at boot (crash-survival redelivery). This is
136
+ * the at-most-once ledger for redelivery — first-write-wins via
137
+ * `WHERE answer_redelivered_at IS NULL` (see `markAnswerRedelivered`). Kept on
138
+ * a SEPARATE marker from `resumed_at` because the two concerns have different
139
+ * correctness contracts (resume = at-most-once side-effect replay; redelivery
140
+ * = at-most-once answer send) and a turn can be both. Null until redelivered.
141
+ */
142
+ answer_redelivered_at: number | null
123
143
  created_at: number
124
144
  updated_at: number
125
145
  }
@@ -190,6 +210,15 @@ const PHASE3_MIGRATIONS = [
190
210
  `ALTER TABLE turns ADD COLUMN resumed_at INTEGER`,
191
211
  ]
192
212
 
213
+ // Columns added for crash-survival redelivery. `session_id` pins the exact
214
+ // transcript file for an interrupted turn (so redelivery never resolves the
215
+ // wrong session via a most-recent-file heuristic); `answer_redelivered_at` is
216
+ // the at-most-once redelivery ledger (stamped synchronously with the re-send).
217
+ const PHASE4_MIGRATIONS = [
218
+ `ALTER TABLE turns ADD COLUMN session_id TEXT`,
219
+ `ALTER TABLE turns ADD COLUMN answer_redelivered_at INTEGER`,
220
+ ]
221
+
193
222
  function applySchema(db: SqliteDatabase): void {
194
223
  db.exec('PRAGMA journal_mode = WAL')
195
224
  db.exec('PRAGMA synchronous = NORMAL')
@@ -206,7 +235,7 @@ function applySchema(db: SqliteDatabase): void {
206
235
  // Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
207
236
  // we swallow the "duplicate column" error to stay idempotent on
208
237
  // pre-existing registry.db files.
209
- for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS, ...PHASE3_MIGRATIONS]) {
238
+ for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS, ...PHASE3_MIGRATIONS, ...PHASE4_MIGRATIONS]) {
210
239
  try {
211
240
  db.exec(sql)
212
241
  } catch (err) {
@@ -283,6 +312,8 @@ interface RawTurnRow {
283
312
  tool_call_count: number | null
284
313
  interrupt_reason: string | null
285
314
  resumed_at: number | null
315
+ session_id: string | null
316
+ answer_redelivered_at: number | null
286
317
  created_at: number
287
318
  updated_at: number
288
319
  }
@@ -304,6 +335,8 @@ function mapRow(row: RawTurnRow): Turn {
304
335
  tool_call_count: row.tool_call_count,
305
336
  interrupt_reason: row.interrupt_reason,
306
337
  resumed_at: row.resumed_at,
338
+ session_id: row.session_id ?? null,
339
+ answer_redelivered_at: row.answer_redelivered_at ?? null,
307
340
  created_at: row.created_at,
308
341
  updated_at: row.updated_at,
309
342
  }
@@ -692,6 +725,61 @@ export function markTurnResumed(
692
725
  `).run(now, now, turnKey)
693
726
  }
694
727
 
728
+ /**
729
+ * Stamp the claude `session_id` (the `<sessionId>.jsonl` transcript stem) on a
730
+ * turn the FIRST time it is observed, DURING the turn. First-write-wins via
731
+ * `WHERE session_id IS NULL` so the hot session-event path can call this on
732
+ * every event cheaply and idempotently. This must run while the turn is live
733
+ * (before any crash) so crash-survival redelivery can resolve the exact
734
+ * transcript file for an interrupted turn — never the most-recent-mtime file,
735
+ * which a fresh boot session would shadow. No-ops if `turnKey` is not found.
736
+ */
737
+ export function stampTurnSessionId(
738
+ db: SqliteDatabase,
739
+ turnKey: string,
740
+ sessionId: string,
741
+ now: number = Date.now(),
742
+ ): void {
743
+ if (!sessionId) return
744
+ db.prepare(`
745
+ UPDATE turns
746
+ SET session_id = ?,
747
+ updated_at = ?
748
+ WHERE turn_key = ? AND session_id IS NULL
749
+ `).run(sessionId, now, turnKey)
750
+ }
751
+
752
+ /**
753
+ * Stamp `answer_redelivered_at` on an interrupted turn at the moment its
754
+ * captured-but-undelivered final answer has been re-sent at boot (crash-
755
+ * survival redelivery). This is the at-most-once ledger for redelivery: once
756
+ * stamped, the redelivery decision skips the turn on any later restart.
757
+ *
758
+ * Ordering: the caller stamps SYNCHRONOUSLY with the send (immediately after
759
+ * the send resolves), the same discipline as `markTurnResumed`. A residual
760
+ * race remains — the window between the Telegram send completing and this row
761
+ * (plus the send's own `role='assistant'` history row) becoming durable. A
762
+ * crash landing in that window could re-send on the next boot; the durable
763
+ * text-identity delivery oracle (matching the projected answer text against
764
+ * delivered `messages` rows) is what CATCHES that duplicate, so redelivery is
765
+ * at-most-once modulo detection, never a silent double-send of a fresh answer.
766
+ *
767
+ * Idempotent and first-write-wins (`WHERE answer_redelivered_at IS NULL`).
768
+ * No-ops if `turnKey` is not found.
769
+ */
770
+ export function markAnswerRedelivered(
771
+ db: SqliteDatabase,
772
+ turnKey: string,
773
+ now: number = Date.now(),
774
+ ): void {
775
+ db.prepare(`
776
+ UPDATE turns
777
+ SET answer_redelivered_at = ?,
778
+ updated_at = ?
779
+ WHERE turn_key = ? AND answer_redelivered_at IS NULL
780
+ `).run(now, now, turnKey)
781
+ }
782
+
695
783
  /**
696
784
  * Return the single most-recently-started turn IFF it was interrupted
697
785
  * (`ended_at IS NULL`, or `ended_via` in {restart, sigterm, timeout,
@@ -0,0 +1,210 @@
1
+ // Shared CommonMark-correct code-span / fenced-block splitter for the outbound
2
+ // formatting guards (#3252). Every guard that neutralises accidental Telegram
3
+ // markdown typesetting (dollar-math, emphasis, line-start block constructs,
4
+ // inline pairs) must skip content inside code spans and fenced code blocks
5
+ // verbatim. Rather than each guard forking its own copy of the splitter (the
6
+ // dollar guard shipped one, the emphasis + line-start guards each duplicated
7
+ // it), this is the ONE source of truth they all import.
8
+
9
+ /** A contiguous slice of rendered markdown, tagged as code (verbatim, never
10
+ * transformed) or prose (eligible for a guard's rewrite). */
11
+ export interface Segment {
12
+ code: boolean;
13
+ text: string;
14
+ }
15
+
16
+ /** From index `from` (just past an opening run of `runLen` backticks), find the
17
+ * index immediately AFTER the matching closing run of exactly `runLen`
18
+ * backticks. Returns -1 when there is no matching close (a stray backtick), in
19
+ * which case the opener is treated as literal prose. Equal-length matching is
20
+ * the CommonMark rule for code spans and matches the balanced fences the
21
+ * renderer emits for code blocks. */
22
+ export function findClosingBackticks(text: string, from: number, runLen: number): number {
23
+ let i = from;
24
+ while (i < text.length) {
25
+ if (text[i] === "`") {
26
+ let j = i;
27
+ while (j < text.length && text[j] === "`") j++;
28
+ if (j - i === runLen) return j;
29
+ i = j;
30
+ } else {
31
+ i++;
32
+ }
33
+ }
34
+ return -1;
35
+ }
36
+
37
+ /** Split rendered markdown into alternating prose / code segments so a guard can
38
+ * skip code spans and fenced code blocks entirely. */
39
+ export function splitCodeSegments(text: string): Segment[] {
40
+ const out: Segment[] = [];
41
+ let i = 0;
42
+ let plainStart = 0;
43
+ while (i < text.length) {
44
+ if (text[i] === "`") {
45
+ let j = i;
46
+ while (j < text.length && text[j] === "`") j++;
47
+ const runLen = j - i;
48
+ const close = findClosingBackticks(text, j, runLen);
49
+ if (close !== -1) {
50
+ if (plainStart < i) out.push({ code: false, text: text.slice(plainStart, i) });
51
+ out.push({ code: true, text: text.slice(i, close) });
52
+ i = close;
53
+ plainStart = close;
54
+ continue;
55
+ }
56
+ }
57
+ i++;
58
+ }
59
+ if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
60
+ return out;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Link / autolink / table protection (#3252 — link-aware guards)
65
+ // ---------------------------------------------------------------------------
66
+ //
67
+ // The four accidental-formatting guards must NOT escape inside content where
68
+ // the trigger chars (`_ * ~ = | $`) are STRUCTURAL rather than prose:
69
+ // • markdown link destinations — the `(url)` (and `(url "title")`) target of
70
+ // a `[label](url)` link. Underscores / tildes / `==` in a URL path or query
71
+ // are part of the address; a backslash there can 404 the link. The link
72
+ // LABEL (`[...]`) is rendered prose and stays guarded.
73
+ // • bare autolinked URLs — `http(s)://…` and `www.…` runs Telegram auto-links.
74
+ // • GFM table rows — a table's structural pipes / empty cells (`|a||b|`) must
75
+ // survive; escaping inside a real table row corrupts the table.
76
+ // Everything else is prose and stays fully guarded. This is the sibling of the
77
+ // code-span skip: a PROTECTED segment (`code: true`) is emitted verbatim.
78
+ //
79
+ // Deterministic and linear-time: one left-to-right char scan per prose chunk
80
+ // plus one line pre-pass for tables; no backtracking regexes.
81
+
82
+ /** A GFM table delimiter row: only pipes / colons / dashes / spaces, with at
83
+ * least one dash AND at least one pipe (a bare `---` is a thematic break, not a
84
+ * table). This is what distinguishes a real table from prose that merely starts
85
+ * with `|`. */
86
+ function isTableDelimiterRow(line: string): boolean {
87
+ const t = line.trim();
88
+ return t.length > 0 && /^[\s|:-]+$/.test(t) && t.includes("-") && t.includes("|");
89
+ }
90
+
91
+ /** A candidate table line: after optional leading spaces it begins with `|`. */
92
+ function isTableCandidateLine(line: string): boolean {
93
+ return /^\s*\|/.test(line);
94
+ }
95
+
96
+ /** Find the [start, end) char ranges (relative to `text`) of GFM table blocks —
97
+ * maximal runs of 2+ consecutive `|`-leading lines that contain a delimiter
98
+ * row. Each returned range spans whole lines INCLUDING their trailing newline,
99
+ * so a table becomes ONE protected segment. A lone `|a||b|` line with no
100
+ * delimiter row is NOT a real table → not protected → still guarded. */
101
+ function findTableRanges(text: string): Array<[number, number]> {
102
+ const ranges: Array<[number, number]> = [];
103
+ const lines = text.split("\n");
104
+ let offset = 0;
105
+ let runStart = -1;
106
+ let runEnd = -1;
107
+ let runHasDelim = false;
108
+ let runLineCount = 0;
109
+ const flush = () => {
110
+ if (runStart !== -1 && runLineCount >= 2 && runHasDelim) {
111
+ ranges.push([runStart, runEnd]);
112
+ }
113
+ runStart = -1;
114
+ runEnd = -1;
115
+ runHasDelim = false;
116
+ runLineCount = 0;
117
+ };
118
+ for (let k = 0; k < lines.length; k++) {
119
+ const line = lines[k];
120
+ // char length of this line including its trailing newline (except the last).
121
+ const lineLen = line.length + (k < lines.length - 1 ? 1 : 0);
122
+ if (isTableCandidateLine(line)) {
123
+ if (runStart === -1) runStart = offset;
124
+ runEnd = offset + lineLen;
125
+ runLineCount += 1;
126
+ if (isTableDelimiterRow(line)) runHasDelim = true;
127
+ } else {
128
+ flush();
129
+ }
130
+ offset += lineLen;
131
+ }
132
+ flush();
133
+ return ranges;
134
+ }
135
+
136
+ /** Protect links / autolinks / table rows within a single PROSE chunk, returning
137
+ * alternating prose / protected segments. */
138
+ function splitProseProtected(text: string): Segment[] {
139
+ const out: Segment[] = [];
140
+ const tables = findTableRanges(text);
141
+ let tIdx = 0;
142
+ let i = 0;
143
+ let plainStart = 0;
144
+ const pushProtected = (from: number, to: number) => {
145
+ if (plainStart < from) out.push({ code: false, text: text.slice(plainStart, from) });
146
+ out.push({ code: true, text: text.slice(from, to) });
147
+ plainStart = to;
148
+ };
149
+ while (i < text.length) {
150
+ // Advance the table pointer past any range we've already scanned past.
151
+ while (tIdx < tables.length && tables[tIdx][1] <= i) tIdx++;
152
+ // 1. Table block — protect whole lines verbatim.
153
+ if (tIdx < tables.length && tables[tIdx][0] === i) {
154
+ const [, end] = tables[tIdx];
155
+ pushProtected(i, end);
156
+ i = end;
157
+ continue;
158
+ }
159
+ const ch = text[i];
160
+ // 2. Markdown link destination: `[label](dest)` — protect only `(dest)`.
161
+ if (ch === "[") {
162
+ const close = text.indexOf("]", i + 1);
163
+ if (close !== -1 && text[close + 1] === "(") {
164
+ const destClose = text.indexOf(")", close + 2);
165
+ if (destClose !== -1) {
166
+ // Protect the `(dest)` span; the `[label]` before it stays prose.
167
+ pushProtected(close + 1, destClose + 1);
168
+ i = destClose + 1;
169
+ continue;
170
+ }
171
+ }
172
+ i++;
173
+ continue;
174
+ }
175
+ // 3. Bare autolink: `http(s)://…` or `www.…`, left-flanked by a boundary.
176
+ if (
177
+ (ch === "h" || ch === "w") &&
178
+ (i === 0 || !/[A-Za-z0-9]/.test(text[i - 1]))
179
+ ) {
180
+ const m = /^(?:https?:\/\/|www\.)[^\s<>()\[\]]+/.exec(text.slice(i));
181
+ if (m) {
182
+ const end = i + m[0].length;
183
+ pushProtected(i, end);
184
+ i = end;
185
+ continue;
186
+ }
187
+ }
188
+ i++;
189
+ }
190
+ if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
191
+ return out;
192
+ }
193
+
194
+ /** Split rendered markdown into prose / protected segments where a PROTECTED
195
+ * (`code: true`) segment is any content a guard must emit verbatim: code spans,
196
+ * fenced code blocks, markdown link destinations, bare autolinks, and GFM
197
+ * table rows. This is the link/table-aware superset of `splitCodeSegments` that
198
+ * all four #3252 guards route through. Prose segments (`code: false`) remain
199
+ * guardable (including a link's `[label]` text). Deterministic, linear-time. */
200
+ export function splitProtectedSegments(text: string): Segment[] {
201
+ const out: Segment[] = [];
202
+ for (const seg of splitCodeSegments(text)) {
203
+ if (seg.code) {
204
+ out.push(seg);
205
+ continue;
206
+ }
207
+ for (const sub of splitProseProtected(seg.text)) out.push(sub);
208
+ }
209
+ return out;
210
+ }
@@ -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
+ }