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
@@ -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
  /**
@@ -18,15 +18,61 @@
18
18
  */
19
19
 
20
20
  import { GrammyError } from 'grammy'
21
+ import { guardDollarMath } from './render/dollar-math-guard.js'
22
+ import { guardAccidentalEmphasis } from './render/emphasis-guard.js'
23
+ import { guardAccidentalBlockConstructs } from './render/line-start-guard.js'
24
+ import { guardAccidentalInlinePairs } from './render/inline-pairs-guard.js'
21
25
 
22
26
  /** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
23
27
  export interface InputRichMessageMarkdown {
24
28
  markdown: string
25
29
  }
26
30
 
27
- /** Wrap raw GFM markdown into the rich-message input object. */
31
+ /**
32
+ * Neutralise ALL accidental Telegram markdown typesetting in one composed pass
33
+ * (#3252). Each sub-guard targets a DISJOINT set of trigger characters, skips
34
+ * code spans / fenced blocks verbatim (shared `splitCodeSegments`), is a strict
35
+ * no-op absent a real signal, and is idempotent — so the composition is itself
36
+ * idempotent and safe to apply once per send.
37
+ *
38
+ * ── Ordering (deliberate, not arbitrary) ──────────────────────────────────
39
+ * `guardDollarMath` runs LAST. It backslash-escapes `$` → `\$`, and the
40
+ * inline-pairs guard's approximation-tilde signal is `~(?=\$?\.?\d)` — a `~`
41
+ * glued to a `$digit`. If the dollar guard ran first, a body like
42
+ * `~$5M and ~$10M` would become `~\$5M …`, and the interposed `\` would hide
43
+ * the digit-adjacent tildes from `guardAccidentalInlinePairs`, leaving the
44
+ * accidental `~…~` strikethrough pair un-neutralised (a false negative /
45
+ * residual bug). Running inline-pairs FIRST escapes the tildes (`\~$5M`), then
46
+ * the dollar guard escapes the `$` — both spans are killed. Every other pair of
47
+ * guards is disjoint in the characters it inspects AND the characters it
48
+ * inserts (`\_ \* \> \. \~ \=\= \|\|` vs `\$`), so no other insertion can
49
+ * create or destroy a signal for a sibling. Verified by composition tests.
50
+ */
51
+ export function guardAccidentalFormatting(markdown: string): string {
52
+ let out = markdown
53
+ out = guardAccidentalEmphasis(out)
54
+ out = guardAccidentalBlockConstructs(out)
55
+ out = guardAccidentalInlinePairs(out)
56
+ out = guardDollarMath(out)
57
+ return out
58
+ }
59
+
60
+ /**
61
+ * Wrap raw GFM markdown into the rich-message input object.
62
+ *
63
+ * This is the ONE adapter every `{ markdown }` wire send funnels through
64
+ * (`sendRichMessage` / `editMessageText({ markdown })`) — the reply-tool final
65
+ * answer, draft-stream previews, cards, approvals, banners. It is therefore the
66
+ * single deterministic seam for the #3252 accidental-formatting guards (F1):
67
+ * applying `guardAccidentalFormatting` here guards EVERY markdown-parsed
68
+ * outbound exactly once, without touching `plain`-mode degradations (which
69
+ * bypass this wrapper and go straight to `sendMessage`, where no markdown
70
+ * parsing happens). The composed guard is a strict no-op for any body without
71
+ * an accidental-formatting signal and is idempotent, so callers that already
72
+ * ran it (or the streaming path that renders then re-wraps) stay byte-identical.
73
+ */
28
74
  export function richMessage(markdown: string): InputRichMessageMarkdown {
29
- return { markdown }
75
+ return { markdown: guardAccidentalFormatting(markdown) }
30
76
  }
31
77
 
32
78
  /**
@@ -118,6 +118,17 @@ export type SessionEvent =
118
118
  // and reserved for a future staging-skip optimization; do not assume the
119
119
  // gate keys on it.
120
120
  | { kind: 'text'; text: string; blockIndex: number; lastInMessage: boolean }
121
+ // Per-assistant-message token usage for the MAIN agent, extracted from
122
+ // `message.usage` on each `type:"assistant"` transcript line. `totalTokens`
123
+ // is the NEW-work delta for THIS message (input + output + cache_creation,
124
+ // via sumUsageTokens; cache_read is deliberately excluded — replayed cached
125
+ // context, not new work). `messageId` is `message.id` —
126
+ // REQUIRED for dedup: Claude Code persists one logical assistant message as
127
+ // MULTIPLE JSONL lines sharing one `message.id`, each stamped with the SAME
128
+ // `usage` block, so the accumulator must count a given `messageId` only once
129
+ // (naive summing across lines over-counts). Null messageId → un-dedupable,
130
+ // counted as-is. Mirrors `sub_agent_usage` but for the parent's OWN tokens.
131
+ | { kind: 'usage'; messageId: string | null; totalTokens: number }
121
132
  | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
122
133
  // `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
123
134
  // the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
@@ -134,6 +145,17 @@ export type SessionEvent =
134
145
  // `model` kind (sentinel-filtered, emitted first) but agent-scoped so the
135
146
  // watcher can track it per WorkerEntry and thread it onto the worker card.
136
147
  | { kind: 'sub_agent_model'; agentId: string; model: string }
148
+ // Per-assistant-message token usage for a SUB-AGENT, extracted from
149
+ // `message.usage` on each `type:"assistant"` transcript line. `totalTokens`
150
+ // is the NEW-work delta for THIS message (input + output + cache_creation;
151
+ // cache_read is deliberately excluded — replayed cached context, not new
152
+ // work). `messageId` is `message.id` — REQUIRED for dedup: Claude
153
+ // Code ≥2.1.x persists one logical assistant message as MULTIPLE JSONL lines
154
+ // sharing one `message.id`, each stamped with the SAME `usage` block, so the
155
+ // watcher must count a given `messageId` only once (naive summing across
156
+ // lines 2-3x over-counts; verified against live worker jsonl — 131 usage
157
+ // lines / 59 unique ids). Null messageId → un-dedupable, counted as-is.
158
+ | { kind: 'sub_agent_usage'; agentId: string; messageId: string | null; totalTokens: number }
137
159
  | { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown>; precomputedLabel?: string }
138
160
  // Same shared contract as the main-agent `text` kind — see its doc above
139
161
  // (including the `lastInMessage` projection-artifact note). The wire-kind
@@ -283,6 +305,34 @@ export function projectAssistantTextBlocks(
283
305
  * projectSubagentLine). A thinking-only or empty line returns false; the real
284
306
  * terminal rides the following content line, which also carries `end_turn`.
285
307
  */
308
+ /**
309
+ * Sum the NEW token work carried by a single assistant message's `usage`
310
+ * object: `input_tokens + output_tokens + cache_creation_input_tokens`. Every
311
+ * field is guarded with `?? 0` — Claude Code omits fields that are zero/absent
312
+ * on some messages. A non-object (or missing) usage returns 0 so the caller
313
+ * can skip a no-usage line.
314
+ *
315
+ * `cache_read_input_tokens` is DELIBERATELY EXCLUDED. On a prompt-cached turn
316
+ * it is replayed context (billed at ~10% and doing no new work), and it
317
+ * dominates the raw total — including it made the displayed number 2-5x bigger
318
+ * than the actual work done this turn and misread as a cost/effort figure. The
319
+ * three fields kept here represent new tokens processed this turn: fresh input,
320
+ * generated output, and newly-written cache. The nested `iterations` /
321
+ * `cache_creation` breakdowns are subsets already reflected in the top-level
322
+ * fields — never add them, that double-counts. Verified against live worker
323
+ * jsonl.
324
+ */
325
+ export function sumUsageTokens(usage: unknown): number {
326
+ if (usage == null || typeof usage !== 'object') return 0
327
+ const u = usage as Record<string, unknown>
328
+ const n = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
329
+ return (
330
+ n(u.input_tokens) +
331
+ n(u.output_tokens) +
332
+ n(u.cache_creation_input_tokens)
333
+ )
334
+ }
335
+
286
336
  export function assistantLineCarriesAnswerSurface(
287
337
  content: Array<Record<string, unknown>> | undefined,
288
338
  ): boolean {
@@ -303,6 +353,108 @@ export function assistantLineCarriesAnswerSurface(
303
353
  return false
304
354
  }
305
355
 
356
+ export interface TrailingAnswer {
357
+ /** Concatenated trailing assistant text of the last turn (after the last
358
+ * tool_use / turn boundary). Empty when there is none to redeliver. */
359
+ text: string
360
+ /** True iff the last content-bearing event of the transcript was text — i.e.
361
+ * the turn ended on an answer, not a dangling tool_use (mid-stream). Bounds
362
+ * the preamble-vs-final ambiguity for crash-survival redelivery. */
363
+ trailingIsText: boolean
364
+ }
365
+
366
+ /**
367
+ * Re-project the TRAILING assistant answer of the last turn from a claude
368
+ * session transcript's full text. Pure — reuses the same `projectTranscriptLine`
369
+ * kernel the live tail uses, so it inherits the `isApiErrorMessage` suppression
370
+ * (a usage-limit error line is NEVER resurfaced as an answer) and the empty-block
371
+ * drop. Used by crash-survival redelivery to recover the finished-but-never-sent
372
+ * answer from disk after a pre-flush crash.
373
+ *
374
+ * Semantics: walk the event stream in order; the "answer buffer" accumulates
375
+ * `text` events and is RESET by any `tool_use` (the answer-so-far was a preamble
376
+ * to a tool call) or by a turn boundary. What remains at end-of-file is the
377
+ * trailing answer of the last turn. `trailingIsText` is true only when the final
378
+ * content-bearing event was that text (not a tool_use), so a turn killed mid-tool
379
+ * never redelivers a stale preamble as an answer.
380
+ *
381
+ * TURN BOUNDARY (diff-review defect #2). A boundary is BOTH an `enqueue`
382
+ * queue-operation AND a real `type:"user"` message line. The kernel
383
+ * (`projectTranscriptLine`) emits nothing for a plain user text line — it only
384
+ * projects `tool_result` blocks out of `type:"user"` — so relying on `enqueue`
385
+ * alone would let two turns separated by a plain user line (no intervening
386
+ * tool_use) CONCATENATE. `isRealUserTurnBoundary` detects that separator
387
+ * directly so only the LAST turn's trailing text is projected.
388
+ */
389
+ export function projectTrailingAnswerFromTranscript(transcriptText: string): TrailingAnswer {
390
+ const buf: string[] = []
391
+ let lastMeaningful: 'text' | 'tool_use' | null = null
392
+ for (const rawLine of transcriptText.split('\n')) {
393
+ const line = rawLine.trim()
394
+ if (!line) continue
395
+ if (isRealUserTurnBoundary(line)) {
396
+ // A real user message opens a new turn — discard any prior turn's tail.
397
+ buf.length = 0
398
+ lastMeaningful = null
399
+ continue
400
+ }
401
+ for (const ev of projectTranscriptLine(line)) {
402
+ if (ev.kind === 'enqueue') {
403
+ // New inbound turn — any prior turn's trailing text is not this turn's.
404
+ buf.length = 0
405
+ lastMeaningful = null
406
+ } else if (ev.kind === 'tool_use') {
407
+ buf.length = 0
408
+ lastMeaningful = 'tool_use'
409
+ } else if (ev.kind === 'text') {
410
+ const t = (ev as { text?: string }).text ?? ''
411
+ if (t.trim().length > 0) {
412
+ buf.push(t)
413
+ lastMeaningful = 'text'
414
+ }
415
+ }
416
+ // thinking / model / dequeue / tool_result etc. do not affect the answer.
417
+ }
418
+ }
419
+ const text = buf.join('').trim()
420
+ return { text, trailingIsText: lastMeaningful === 'text' && text.length > 0 }
421
+ }
422
+
423
+ /**
424
+ * Detect a real inbound-user turn separator in a claude session JSONL.
425
+ *
426
+ * A `type:"user"` line is EITHER a genuine user message (its `message.content`
427
+ * is a string, or an array carrying a `{type:"text"}` block) OR a tool_result
428
+ * carrier (`message.content` is an array of `{type:"tool_result"}` blocks only).
429
+ * Only the former opens a new turn. The main projection kernel emits NO event
430
+ * for a genuine user text line (it projects tool_result blocks only), so the
431
+ * trailing-answer projector needs this to break turns that are separated by a
432
+ * plain user line rather than an interleaved `enqueue` queue-operation. Pure.
433
+ */
434
+ export function isRealUserTurnBoundary(line: string): boolean {
435
+ let obj: Record<string, unknown>
436
+ try {
437
+ obj = JSON.parse(line)
438
+ } catch {
439
+ return false
440
+ }
441
+ if (obj.type !== 'user') return false
442
+ const message = obj.message as Record<string, unknown> | undefined
443
+ const content = message?.content
444
+ if (typeof content === 'string') return content.trim().length > 0
445
+ if (Array.isArray(content)) {
446
+ // A genuine user message carries a text block; a tool_result carrier does
447
+ // not. Presence of any text block ⇒ real user turn.
448
+ for (const c of content) {
449
+ if (typeof c === 'object' && c != null && (c as Record<string, unknown>).type === 'text') {
450
+ const t = String((c as Record<string, unknown>).text ?? '')
451
+ if (t.trim().length > 0) return true
452
+ }
453
+ }
454
+ }
455
+ return false
456
+ }
457
+
306
458
  /**
307
459
  * Project a single transcript line into a SessionEvent (or null if it's
308
460
  * uninteresting noise). Caller is responsible for the JSON parse — if a
@@ -364,6 +516,23 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
364
516
  if (typeof mainModel === 'string' && !isModelSentinel(mainModel)) {
365
517
  events.push({ kind: 'model', model: mainModel })
366
518
  }
519
+ // Per-message token usage (MAIN tier): surface the summed delta so the
520
+ // gateway can accumulate the parent's OWN running total for the turn card's
521
+ // metrics line. `messageId` (message.id) rides along so the accumulator
522
+ // dedups the multi-line split-message shape (one logical message → many
523
+ // JSONL lines, one shared `usage`). Emitted only when a usage object with a
524
+ // non-zero total exists — a no-usage line contributes nothing and is
525
+ // skipped. Mirrors the sub-agent emit below; this counts the parent alone
526
+ // (sub-agents report their own tokens on their worker-feed rows).
527
+ const mainUsageTotal = sumUsageTokens(message?.usage)
528
+ if (mainUsageTotal > 0) {
529
+ const mainMsgId = message?.id
530
+ events.push({
531
+ kind: 'usage',
532
+ messageId: typeof mainMsgId === 'string' ? mainMsgId : null,
533
+ totalTokens: mainUsageTotal,
534
+ })
535
+ }
367
536
  // Text→narrative projection comes from the ONE shared kernel
368
537
  // (projectAssistantTextBlocks): it owns the empty-drop + blockIndex +
369
538
  // lastInMessage contract. We emit its events at their source positions
@@ -518,6 +687,22 @@ export function projectSubagentLine(
518
687
  if (typeof subModel === 'string' && !isModelSentinel(subModel)) {
519
688
  events.push({ kind: 'sub_agent_model', agentId, model: subModel })
520
689
  }
690
+ // Per-message token usage: surface the summed delta so the watcher can
691
+ // accumulate a running total for the worker-activity card's metrics line.
692
+ // `messageId` (message.id) rides along so the watcher dedups the multi-line
693
+ // split-message shape (one logical message → many JSONL lines, one shared
694
+ // `usage`). Emitted only when a usage object with a non-zero total exists —
695
+ // a no-usage line contributes nothing and is skipped here.
696
+ const subUsageTotal = sumUsageTokens(message?.usage)
697
+ if (subUsageTotal > 0) {
698
+ const subMsgId = message?.id
699
+ events.push({
700
+ kind: 'sub_agent_usage',
701
+ agentId,
702
+ messageId: typeof subMsgId === 'string' ? subMsgId : null,
703
+ totalTokens: subUsageTotal,
704
+ })
705
+ }
521
706
  // Text→narrative projection comes from the SAME shared kernel as the
522
707
  // main agent (projectAssistantTextBlocks): one source for the empty-drop
523
708
  // + blockIndex + lastInMessage contract. The `make` adapter only changes