switchroom 0.18.25 → 0.18.27

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 (44) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +2 -2
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +832 -38
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +305 -41
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/render/code-segments.ts +210 -0
  18. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  19. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  20. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  21. package/telegram-plugin/render/line-start-guard.ts +167 -0
  22. package/telegram-plugin/render/rich-render.ts +7 -0
  23. package/telegram-plugin/rich-send.ts +48 -2
  24. package/telegram-plugin/send-gate.test.ts +138 -0
  25. package/telegram-plugin/send-gate.ts +104 -1
  26. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  27. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  28. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  29. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  30. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  31. package/telegram-plugin/tests/model-command.test.ts +112 -0
  32. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  33. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  34. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  35. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  36. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  37. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  38. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  39. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  40. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  41. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  42. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  43. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  44. package/telegram-plugin/worker-activity-feed.ts +91 -1
@@ -57,10 +57,27 @@ export interface Ms365WritePreview {
57
57
  /** Byte delta — present only for OneDrive uploads with known sizes. */
58
58
  sizeBytesBefore?: number;
59
59
  sizeBytesAfter?: number;
60
+ /**
61
+ * "start → end" human string for calendar events, resolved from Graph.
62
+ * Present only when the opaque event id resolved successfully (#3267).
63
+ */
64
+ eventWhen?: string;
65
+ /**
66
+ * Structural before→after diff for the fields the mutation changes
67
+ * (calendar body/location/time). Present only when resolved (#3267).
68
+ */
69
+ changes?: Ms365PreviewChange[];
60
70
  /** 1-line agent rationale — advisory; operator should not over-trust. */
61
71
  agentRationale?: string;
62
72
  }
63
73
 
74
+ /** A single before→after change rendered on the card. */
75
+ export interface Ms365PreviewChange {
76
+ field: string;
77
+ before?: string;
78
+ after?: string;
79
+ }
80
+
64
81
  /**
65
82
  * Validate a wire payload into a typed Ms365WritePreview. Returns null
66
83
  * on malformed input (defense in depth — the hook is trusted but the
@@ -84,10 +101,32 @@ export function validateMs365Preview(input: unknown): Ms365WritePreview | null {
84
101
  if (typeof o.deepLink === "string") out.deepLink = o.deepLink;
85
102
  if (typeof o.sizeBytesBefore === "number") out.sizeBytesBefore = o.sizeBytesBefore;
86
103
  if (typeof o.sizeBytesAfter === "number") out.sizeBytesAfter = o.sizeBytesAfter;
104
+ if (typeof o.eventWhen === "string") out.eventWhen = o.eventWhen;
105
+ const changes = sanitizeChanges(o.changes);
106
+ if (changes) out.changes = changes;
87
107
  if (typeof o.agentRationale === "string") out.agentRationale = o.agentRationale;
88
108
  return out;
89
109
  }
90
110
 
111
+ /**
112
+ * Validate the wire `changes` array into typed before→after entries. Drops
113
+ * malformed entries defensively; returns undefined when nothing usable.
114
+ */
115
+ function sanitizeChanges(input: unknown): Ms365PreviewChange[] | undefined {
116
+ if (!Array.isArray(input)) return undefined;
117
+ const out: Ms365PreviewChange[] = [];
118
+ for (const raw of input) {
119
+ if (!raw || typeof raw !== "object") continue;
120
+ const c = raw as Record<string, unknown>;
121
+ if (typeof c.field !== "string" || c.field.length === 0) continue;
122
+ const entry: Ms365PreviewChange = { field: c.field };
123
+ if (typeof c.before === "string") entry.before = c.before;
124
+ if (typeof c.after === "string") entry.after = c.after;
125
+ out.push(entry);
126
+ }
127
+ return out.length > 0 ? out : undefined;
128
+ }
129
+
91
130
  // ────────────────────────────────────────────────────────────────────────
92
131
  // Handler — DI shape mirrors DriveApprovalHandlerDeps
93
132
  // ────────────────────────────────────────────────────────────────────────
@@ -168,6 +207,9 @@ export function buildMs365CardText(p: Ms365WritePreview): string {
168
207
  lines.push(`ID: ${truncate(p.itemId, 96)}`);
169
208
  }
170
209
  lines.push(`Account: ${truncate(p.accountEmail, 96)}`);
210
+ if (p.eventWhen) {
211
+ lines.push(`When: ${truncate(p.eventWhen, 96)}`);
212
+ }
171
213
  if (
172
214
  typeof p.sizeBytesBefore === "number" ||
173
215
  typeof p.sizeBytesAfter === "number"
@@ -181,13 +223,26 @@ export function buildMs365CardText(p: Ms365WritePreview): string {
181
223
  if (p.deepLink) {
182
224
  lines.push(`Link: ${truncate(p.deepLink, 256)}`);
183
225
  }
226
+ if (p.changes && p.changes.length > 0) {
227
+ lines.push("");
228
+ lines.push("Changes:");
229
+ for (const c of p.changes.slice(0, 8)) {
230
+ const before = c.before !== undefined ? truncate(c.before, 96) : "(none)";
231
+ const after = c.after !== undefined ? truncate(c.after, 96) : "(cleared)";
232
+ lines.push(`• ${c.field}: ${before} → ${after}`);
233
+ }
234
+ }
184
235
  if (p.agentRationale) {
185
236
  lines.push("");
186
237
  lines.push(`💬 ${truncate(p.agentRationale, 512)}`);
187
238
  }
188
239
  lines.push("");
240
+ // With a resolved structural diff present the operator is no longer
241
+ // approving a blind write; soften the attestation warning accordingly.
189
242
  lines.push(
190
- "⚠️ Weak attestation (RFC §8 v1): operator should click through to verify the actual change before approving. Structural diff coming v1.5.",
243
+ p.changes && p.changes.length > 0
244
+ ? "⚠️ Attestation (RFC §8 v1.5): the diff above is derived from live Graph state + the mutation payload. Verify before approving."
245
+ : "⚠️ Weak attestation (RFC §8 v1): operator should click through to verify the actual change before approving. Structural diff coming v1.5.",
191
246
  );
192
247
  // hardenCardBreaks: labelled field lines (Agent:/Tool:/Item:/Account:/Size:…)
193
248
  // would soft-collapse into one blob under the GFM rich renderer; this card is
@@ -196,8 +251,15 @@ export function buildMs365CardText(p: Ms365WritePreview): string {
196
251
  }
197
252
 
198
253
  function truncate(s: string, n: number): string {
199
- if (s.length <= n) return s;
200
- return s.slice(0, n - 1) + "…";
254
+ // Collapse control whitespace (newline / carriage-return / tab) to a single
255
+ // space FIRST — every field on this card is single-line, and this card is a
256
+ // security decision surface. A Graph-sourced value like an event subject of
257
+ // `Team sync\nAccount: attacker@x` would otherwise inject a fake-looking
258
+ // labelled line onto the card (#3267 review Finding 2). Length-truncation
259
+ // alone does not defend against this.
260
+ const oneLine = s.replace(/[\r\n\t]+/g, " ");
261
+ if (oneLine.length <= n) return oneLine;
262
+ return oneLine.slice(0, n - 1) + "…";
201
263
  }
202
264
 
203
265
  function humanBytes(bytes: number): string {
@@ -115,6 +115,18 @@ export function buildSubagentHandbackInbound(opts: {
115
115
  meta: {
116
116
  source: 'subagent_handback',
117
117
  outcome: opts.ctx.outcome,
118
+ // #3268 — round-trip the fabricated `ts` through `meta.message_id` so it
119
+ // survives to enqueue. `ev.messageId` at enqueue is parsed from the
120
+ // channel envelope's `message_id` attribute, which is rendered ONLY from
121
+ // `meta.message_id` — the top-level `messageId` field does NOT survive the
122
+ // bridge. Without this, enqueue's `deriveTurnId` returns null → the
123
+ // dead-air pre-turn card's identity-based adoption never matches (the card
124
+ // is orphaned + a false "handback never started" reap message fires on
125
+ // every SUCCESSFUL handback). Mirrors resume-inbound-builder.ts's
126
+ // `message_id: String(ts)` for the identical enqueue-round-trip reason. It
127
+ // is NEVER used as a Telegram reply anchor: `parseSourceMessageId` gates
128
+ // the 13-digit synthetic ts out of the reply-anchor path at enqueue.
129
+ message_id: String(ts),
118
130
  // meta.message_thread_id is the model-visible channel attribute
119
131
  // (mirrors the real-inbound shape) so the model's reply targets
120
132
  // the dispatching topic. Mirrors gateway.ts:10557.
@@ -38,6 +38,24 @@ import { join } from "node:path";
38
38
 
39
39
  export const TURN_ACTIVE_MARKER_FILE = "turn-active.json";
40
40
 
41
+ /**
42
+ * Absolute ceiling (ms) beyond which a turn-active signal cannot reflect a
43
+ * real in-flight turn. This is the marker sweep's `hardTtlMs` (`gateway.ts`
44
+ * `sweepStaleTurnActiveMarker` callsite). Exported so the `/model` & `/effort`
45
+ * busy-gate cross-checks the in-memory turn atom and the pending-approval hold
46
+ * against the SAME ceiling it sweeps the marker file at (#3262) — instead of
47
+ * the atom leaking past it and reading as a phantom "active turn" on an idle
48
+ * session.
49
+ */
50
+ export const TURN_ACTIVE_HARD_TTL_MS = 10 * 60_000;
51
+
52
+ /**
53
+ * Idle-sweep threshold (ms): the marker is swept this soon when the caller
54
+ * asserts no turn is in flight. Exported alongside the hard TTL so both
55
+ * bounds have a single source of truth.
56
+ */
57
+ export const TURN_ACTIVE_IDLE_SWEEP_MS = 60_000;
58
+
41
59
  export interface TurnActiveMarker {
42
60
  turnKey: string;
43
61
  chatId: string;
@@ -196,3 +214,20 @@ export function readTurnActiveMarkerAgeMs(stateDir: string, now?: number): numbe
196
214
  return null; // ENOENT / unstattable → not working
197
215
  }
198
216
  }
217
+
218
+ /**
219
+ * Effective age (ms) of a live turn for the phantom-turn cross-check (#3262):
220
+ * prefer the turn-active liveness marker's mtime age (touched on every
221
+ * tool_use / sub-agent activity, so a genuinely long turn keeps it small),
222
+ * falling back to `now - turnStartedAt` when the marker is absent (e.g. already
223
+ * swept away). Pure so the fallback branch is unit-testable with an injected
224
+ * clock. `markerAgeMs` is the result of `readTurnActiveMarkerAgeMs` (null when
225
+ * the marker is gone).
226
+ */
227
+ export function effectiveTurnAgeMs(
228
+ markerAgeMs: number | null,
229
+ turnStartedAt: number,
230
+ now: number,
231
+ ): number {
232
+ return markerAgeMs ?? now - turnStartedAt;
233
+ }
@@ -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
+ }