switchroom 0.21.9 → 0.21.11

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 (46) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -2
  4. package/telegram-plugin/dist/gateway/gateway.js +1152 -296
  5. package/telegram-plugin/format.ts +74 -1
  6. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  7. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  8. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  9. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  10. package/telegram-plugin/gateway/gateway.ts +61 -68
  11. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  12. package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
  13. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  14. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  15. package/telegram-plugin/gateway/speech-capture.ts +158 -0
  16. package/telegram-plugin/gateway/stream-render.ts +1 -1
  17. package/telegram-plugin/history.ts +21 -0
  18. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  19. package/telegram-plugin/render/html-fold.ts +372 -0
  20. package/telegram-plugin/render/parse.ts +578 -29
  21. package/telegram-plugin/render/render.ts +14 -13
  22. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  23. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  24. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  25. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  26. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  27. package/telegram-plugin/tests/history.test.ts +78 -0
  28. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  29. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  30. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
  31. package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
  32. package/telegram-plugin/tests/render/parse.test.ts +9 -5
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
  34. package/telegram-plugin/tests/speech-capture.test.ts +296 -0
  35. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  36. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  37. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  38. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  39. package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
  40. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  41. package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
  42. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  43. package/telegram-plugin/tts-normalize.ts +47 -9
  44. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  45. package/telegram-plugin/voice-normalize-text.ts +48 -9
  46. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Raw pre-normalisation speech-text capture (TTS normalisation redesign,
3
+ * PR-0 — `/tmp/claude-0/tts/out/fable-plan-v2.md` §9).
4
+ *
5
+ * Flag-gated on `SWITCHROOM_SPEECH_CAPTURE=1` (or `=true`), OFF by default:
6
+ * unset (or any other value) is a true no-op — `captureSpeechText` returns
7
+ * immediately without touching the filesystem, so the send path pays zero
8
+ * cost. When enabled, appends exactly one JSON line `{ts, text}` — where
9
+ * `text` is the EXACT string about to be passed to `resolveVoiceOutPlan`,
10
+ * byte-for-byte, no re-encoding — to `$TELEGRAM_STATE_DIR/speech-capture.jsonl`
11
+ * before that call (fable-plan-v2.md §0 C-4: that string IS Stage A's future
12
+ * input). NOT the same string the visible rich render displays — the render
13
+ * path additionally runs `computeEffectiveText`/`addParagraphSpacers` (U+00A0
14
+ * paragraph spacers) downstream of this capture point; this captures the
15
+ * plain pre-spacer prose Stage A will actually consume.
16
+ *
17
+ * Why this exists: `corpus.json` (the existing replay corpus) was captured
18
+ * downstream of the legacy pass-1 normaliser, so it carries zero tables,
19
+ * fences, pipes, or emoji (fable-plan-v2.md §0 C-3) — useless for validating
20
+ * the new Stage-A renderer beyond synthetic fixtures. `telegram/history.db`
21
+ * is also unusable: it stores text as Telegram echoed it back (rendered),
22
+ * not the pre-render markdown. Capture must happen in-process, here.
23
+ *
24
+ * Privacy: the capture file holds the full plaintext body of every outbound
25
+ * reply while the flag is on, so it is created `0o600` (owner-only) — NOT
26
+ * the default-umask `0o644` `appendFileSync` would otherwise produce, which
27
+ * would leave it world-readable in a directory where `access.json` (the chat
28
+ * allowlist) is deliberately `0o600`. Mode only applies at file CREATION
29
+ * (Node ignores `mode` on an append to an existing file), matching the
30
+ * established pattern in this codebase (`shown-ledger.ts`, `outbox.ts`).
31
+ *
32
+ * Write failures are swallowed — capture must never break a send — but never
33
+ * silently: the first write failure (and, rate-limited, subsequent ones)
34
+ * logs one stderr line, because a foreign-uid EACCES on this file (the #4371
35
+ * failure class: some other process touches it, it becomes root-owned, the
36
+ * agent uid EACCESes on every append thereafter) must be observable across a
37
+ * 7-day unattended capture window, not discovered after the fact from an
38
+ * empty corpus.
39
+ *
40
+ * Deliberately does NOT bound or rotate the capture file: the spec (§9 PR-0)
41
+ * describes a plain unbounded append for a time-boxed 7-day fleet-wide
42
+ * capture window (measured fleet-wide: ~2.9 MB over 7 days), after which the
43
+ * file is reviewed, secrets-scrubbed, and checked into the repo as a static
44
+ * fixture — it is not a long-lived production log.
45
+ *
46
+ * The enabled check reads `process.env` on every call (deliberately NOT
47
+ * cached at module scope, unlike `current-turn-map.ts`'s
48
+ * `EMISSION_AUTHORITY_ENABLED` kill-switch convention): the read is a single
49
+ * property lookup, not the per-turn state-store cost that convention exists
50
+ * to avoid, and caching it would make the flag un-togglable within a single
51
+ * test process — which the load-bearing "a capture write failure never
52
+ * breaks the reply" guarantee needs to exercise against the REAL `sendReply`
53
+ * wiring (see `send-reply-golden.test.ts`), not a synthetic call.
54
+ */
55
+
56
+ import { appendFileSync } from 'node:fs'
57
+ import { join } from 'node:path'
58
+
59
+ export const SPEECH_CAPTURE_FILE_NAME = 'speech-capture.jsonl'
60
+
61
+ /** File created owner-read-write only (see the Privacy note above). */
62
+ const SPEECH_CAPTURE_FILE_MODE = 0o600
63
+
64
+ export interface CaptureSpeechTextOptions {
65
+ /** Override the enabled flag (tests only); default reads
66
+ * `SWITCHROOM_SPEECH_CAPTURE` from the environment. */
67
+ enabled?: boolean
68
+ /** Override the destination directory (tests only); default
69
+ * `process.env.TELEGRAM_STATE_DIR`. */
70
+ stateDir?: string
71
+ /** Override the clock (tests only). */
72
+ now?: () => number
73
+ }
74
+
75
+ function envFlagEnabled(): boolean {
76
+ const v = process.env.SWITCHROOM_SPEECH_CAPTURE
77
+ return v === '1' || v === 'true'
78
+ }
79
+
80
+ function isCaptureEnabled(override: boolean | undefined): boolean {
81
+ if (override !== undefined) return override
82
+ return envFlagEnabled()
83
+ }
84
+
85
+ // One-shot "capture is live" log line, latched the first time capture
86
+ // actually fires (not at module import — importing this module must stay a
87
+ // true no-op regardless of whether the flag ends up used) — and a
88
+ // rate-limited write-error log so a foreign-uid EACCES (#4371 class) is
89
+ // observable within the window, not just discoverable from an empty corpus
90
+ // after the fact.
91
+ let loggedEnabledOnce = false
92
+ // `null`, not `0`: a real failure at `Date.now() === 0` (or in a test driven
93
+ // by `vi.setSystemTime(0)`) must still log — `0` is a valid past timestamp,
94
+ // not "never logged", so it cannot double as the sentinel.
95
+ let lastWriteErrorLoggedAt: number | null = null
96
+ const WRITE_ERROR_LOG_INTERVAL_MS = 5 * 60_000
97
+
98
+ function logEnabledOnce(): void {
99
+ if (loggedEnabledOnce) return
100
+ loggedEnabledOnce = true
101
+ try {
102
+ process.stderr.write(
103
+ `telegram gateway: speech-capture: enabled — writing $TELEGRAM_STATE_DIR/${SPEECH_CAPTURE_FILE_NAME}\n`,
104
+ )
105
+ } catch {
106
+ // Logging must never break the send path.
107
+ }
108
+ }
109
+
110
+ function logWriteErrorRateLimited(err: unknown, nowMs: number): void {
111
+ if (lastWriteErrorLoggedAt !== null && nowMs - lastWriteErrorLoggedAt < WRITE_ERROR_LOG_INTERVAL_MS) return
112
+ lastWriteErrorLoggedAt = nowMs
113
+ try {
114
+ const msg = err instanceof Error ? err.message : String(err)
115
+ process.stderr.write(
116
+ `telegram gateway: speech-capture: write failed (further failures rate-limited ` +
117
+ `${Math.round(WRITE_ERROR_LOG_INTERVAL_MS / 60_000)}m) err=${msg}\n`,
118
+ )
119
+ } catch {
120
+ // Logging must never break the send path.
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Append one `{ts, text}` JSON line to the raw-corpus capture file when
126
+ * capture is enabled. `text` must be the EXACT string about to be passed to
127
+ * `resolveVoiceOutPlan` — no trimming, no re-encoding, byte-for-byte
128
+ * preservation of markdown (tables, fences, pipes, emoji, backticks, ...).
129
+ * Off by default; a true no-op when the flag is unset. Never throws.
130
+ */
131
+ export function captureSpeechText(text: string, opts: CaptureSpeechTextOptions = {}): void {
132
+ if (!isCaptureEnabled(opts.enabled)) return
133
+ logEnabledOnce()
134
+ try {
135
+ const stateDir = opts.stateDir ?? process.env.TELEGRAM_STATE_DIR
136
+ if (stateDir == null || stateDir.length === 0) return
137
+ const now = opts.now ?? Date.now
138
+ const line = `${JSON.stringify({ ts: now(), text })}\n`
139
+ appendFileSync(join(stateDir, SPEECH_CAPTURE_FILE_NAME), line, {
140
+ encoding: 'utf8',
141
+ mode: SPEECH_CAPTURE_FILE_MODE,
142
+ })
143
+ } catch (err) {
144
+ // Capture must never break the send path — but never silently either
145
+ // (M2/#4371 class).
146
+ logWriteErrorRateLimited(err, Date.now())
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Test-only: reset the one-shot enable log and the write-error rate limiter,
152
+ * so a test asserting stderr output is independent of import/call order
153
+ * versus every other test sharing this module instance.
154
+ */
155
+ export function __resetSpeechCaptureLogStateForTests(): void {
156
+ loggedEnabledOnce = false
157
+ lastWriteErrorLoggedAt = null
158
+ }
@@ -546,7 +546,7 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
546
546
  // the narrative gate but NEVER touched the activity card — so the card
547
547
  // froze on its last landed edit, kept the `fg:<statusKey>` status pin
548
548
  // forever, and left NO `turn-lifecycle clear` line to explain it (carrie
549
- // 2026-07-28, turn `-1004223464247:_#1078`). `clearActivitySummary`
549
+ // 2026-07-28, turn `-1004444444444:_#1078`). `clearActivitySummary`
550
550
  // finalizes/deletes the card AND releases the pin; it is idempotent and
551
551
  // no-ops when the turn never opened a card.
552
552
  // `endedAt == null` narrows this to a turn that never ended: a turn that
@@ -1263,6 +1263,20 @@ export function getRecentOutboundCount(
1263
1263
  * - explicit number → only that thread (precise for supergroups with topics)
1264
1264
  * - explicit null → only chat-root (non-thread) messages
1265
1265
  *
1266
+ * `untilMs` semantics — the optional UPPER bound (#4681):
1267
+ * - omitted → open-ended forward, the original behaviour. Correct for a caller
1268
+ * whose SCOPE is already tied to the obligation (its own topic): anything
1269
+ * substantive delivered there since it opened is plausibly its answer.
1270
+ * - a number → only rows at or before it. Required by any caller whose scope is
1271
+ * licensed by a SEPARATE, time-bounded piece of evidence — the obligation
1272
+ * escalate branch's reroute fallback, where an `EXPLICIT_OVERRIDDEN` record
1273
+ * licenses a look in ANOTHER topic. There an open-ended forward query lets
1274
+ * unrelated traffic in that topic, arriving arbitrarily later, be mistaken
1275
+ * for the answer that routing produced. See `escalation-staleness.ts`.
1276
+ * Applied at SECOND granularity like the lower bound, rounded UP (ceil) so
1277
+ * rounding can only ever ADMIT a borderline row, never drop a legitimate one —
1278
+ * the false-negative-safe direction for this predicate.
1279
+ *
1266
1280
  * Falls back to false (safe: never suppresses escalation) if history is not yet
1267
1281
  * initialised or the query fails.
1268
1282
  */
@@ -1271,6 +1285,7 @@ export function hasOutboundDeliveredSince(
1271
1285
  sinceMs: number,
1272
1286
  threadId?: number | null,
1273
1287
  minChars = 200,
1288
+ untilMs?: number,
1274
1289
  ): boolean {
1275
1290
  try {
1276
1291
  const cutoffSec = Math.floor(sinceMs / 1000)
@@ -1287,6 +1302,12 @@ export function hasOutboundDeliveredSince(
1287
1302
  // edits / typing indicators are structurally excluded.
1288
1303
  let sql =
1289
1304
  "SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= ?"
1305
+ if (untilMs != null && Number.isFinite(untilMs)) {
1306
+ // Ceil: `ts` is whole seconds, so a bound of e.g. 18_500 ms past the
1307
+ // second boundary must still admit the row written in that second.
1308
+ sql += ' AND ts <= ?'
1309
+ params.push(Math.ceil(untilMs / 1000))
1310
+ }
1290
1311
  if (threadId !== undefined) {
1291
1312
  if (threadId === null) {
1292
1313
  sql += ' AND thread_id IS NULL'
@@ -431,9 +431,9 @@ function seedTurn(turnKey: string, chatId = '12345', threadId: string | null = n
431
431
  describe('Bug 5 — parent_turn_key stamped from the turn-active marker', () => {
432
432
  it('stamps parent_turn_key = marker.turnKey when a turn is active', () => {
433
433
  // Supergroup forum-topic turn_key (chat:thread:startedAt).
434
- const turnKey = '-1003831053471:4:1780370238492'
435
- seedTurn(turnKey, '-1003831053471', '4')
436
- writeTurnActiveMarker(turnKey, '-1003831053471', '4')
434
+ const turnKey = '-1001234567890:4:1780370238492'
435
+ seedTurn(turnKey, '-1001234567890', '4')
436
+ writeTurnActiveMarker(turnKey, '-1001234567890', '4')
437
437
 
438
438
  const event = {
439
439
  session_id: 'sess-turnkey',
@@ -0,0 +1,372 @@
1
+ // Raw-HTML dialect handling for the Bot API 10.1 rich-markdown render path.
2
+ //
3
+ // ── Why this module exists ────────────────────────────────────────────────
4
+ // Agents habitually emit raw HTML tags in prose (`<b>`, `<i>`, `<a href>`).
5
+ // Before this module those tags reached the wire byte-verbatim, which is two
6
+ // distinct hazards at once:
7
+ //
8
+ // 1. Nothing establishes that Telegram's rich markdown parser accepts them.
9
+ // The wire-verified allowlist (raw `sendRichMessage` probes, 2026-08-13)
10
+ // covers exactly `<u>`, `<sub>`, `<sup>`, `<details>`/`<summary>` and
11
+ // `<aside>`/`<cite>`. `isParseEntitiesError` (`rich-send.ts`) matches
12
+ // `unsupported start tag` / `unclosed start tag`, so the wire CAN 400 on
13
+ // an unknown tag — and that fallback resends the body as PLAIN TEXT,
14
+ // where the reader then sees literal `<b>` markup.
15
+ // 2. mdast `html` nodes degrade to `plain` in `parse.ts`, and `plain` is
16
+ // `escapeMarkdown`'d on render. `escapeMarkdown` escapes `=`, so
17
+ // `<a href="https://example.com">` shipped as `<a href\="…">` — an
18
+ // attribute no parser can read.
19
+ //
20
+ // ── The policy (three buckets, degrade by TYPE, never silently) ───────────
21
+ // • FOLD — a tag with an exact native markdown equivalent is folded
22
+ // into the IR node for that construct, so it renders as the
23
+ // markdown the wire actually understands:
24
+ // <b>/<strong> -> bold **…**
25
+ // <i>/<em> -> italic *…*
26
+ // <s>/<del>/<strike> -> strike ~~…~~
27
+ // <code> -> code span `…`
28
+ // <a href="URL"> -> link [label](URL)
29
+ // <br> -> a line break
30
+ // <pre> -> fenced code block ```…```
31
+ // • PASSTHROUGH — the wire-verified allowlist above is emitted RAW and
32
+ // UNESCAPED (an IR `raw` inline), which is also what stops
33
+ // `escapeMarkdown` from mangling their attributes. Requires a
34
+ // MATCHED close: an unbalanced `<u>` emitted raw is exactly
35
+ // the `unclosed start tag` 400 this module exists to prevent.
36
+ // • DEGRADE — everything else, split by whether the token delimits
37
+ // content. The discriminator is MATCHING, not the tag name:
38
+ // – A MATCHED pair (`<marquee>…</marquee>`, `<div>…</div>`)
39
+ // is markup wrapped around content. The markup is dropped
40
+ // and the content kept; a block-level name additionally
41
+ // leaves a hard line break, so `<li>one</li><li>two</li>`
42
+ // cannot glue into `onetwo`.
43
+ // – A comment, or a void / self-closing tag (`<hr/>`,
44
+ // `<img …/>`), delimits nothing. Markup dropped; a
45
+ // block-level one leaves the same separator.
46
+ // – An UNMATCHED open/close marker is not markup at all — it
47
+ // is PROSE. `<service>/<key>`, `<agent>`, "the `<b>` tag",
48
+ // `run switchroom vault get <key>` are pervasive in this
49
+ // project's own agent output, and DELETING them silently
50
+ // mangles the agent's own reply. Such a token is kept as
51
+ // LITERAL TEXT with `&`/`<`/`>` HTML-entity-escaped (see
52
+ // `escapeHtmlLiteral`). Bot API rich markdown "can contain
53
+ // arbitrary HTML … parsed as described in Rich HTML
54
+ // style", and Rich HTML documents `&lt;`, `&gt;` and
55
+ // `&amp;` among its supported named entities — so the
56
+ // entity form is the DOCUMENTED way to ship a literal
57
+ // angle bracket without tripping `unsupported start tag`.
58
+ //
59
+ // ── The invariant ─────────────────────────────────────────────────────────
60
+ // Only MARKUP is dropped. A token that is not demonstrably markup — an
61
+ // UNMATCHED open/close marker, which is overwhelmingly the shape prose
62
+ // actually takes — always survives to the wire as escaped literal text. That
63
+ // case is protected absolutely. The three shapes classified as markup, and so
64
+ // dropped, are a matched pair, a comment, and a void tag.
65
+ //
66
+ // The invariant is NOT "content is never lost", and must not be read that
67
+ // way. MATCHING is a syntactic discriminator, not a semantic one, so a
68
+ // BALANCED pair of angle brackets in prose is indistinguishable from real
69
+ // markup and is dropped with it:
70
+ // "Use <key> then later </key> done." -> "Use then later done."
71
+ // "Wrap it in <b> and close with </b>." -> "Wrap it in ** and close
72
+ // with **."
73
+ // — author-visible prose is deleted in both, and the second additionally
74
+ // re-wraps the surviving text in the construct the pair named. This is the
75
+ // accepted, deliberate price of the matching discriminator, NOT a defect to
76
+ // fix by weakening it: the alternative (classifying by tag NAME) is what
77
+ // silently deleted every `<service>/<key>` and `<agent>` in this project's own
78
+ // agent output — far more common, and far more damaging, than a balanced pair
79
+ // of brackets in prose.
80
+ //
81
+ // (Second known residual, NOT introduced by this module and not fixed here: a
82
+ // `<` in prose that mdast hands over as a TEXT node rather than an `html` node
83
+ // — the decoded `&lt;c&gt;`, or `response in <1s` — never reaches this module
84
+ // and still ships unescaped. That is a `plain`-node / `escapeMarkdown`
85
+ // concern.)
86
+ //
87
+ // This is a deterministic code-level guarantee, deliberately not a prompt
88
+ // instruction to the agents that emit the tags.
89
+
90
+ /** How a raw HTML token reads. `other` covers anything the tokenizer matched
91
+ * but could not classify (it degrades like an unknown tag). */
92
+ export type HtmlTagKind = "open" | "close" | "selfclose" | "comment" | "other";
93
+
94
+ export interface HtmlTagInfo {
95
+ kind: HtmlTagKind;
96
+ /** Lowercased tag name (`""` for a comment). */
97
+ name: string;
98
+ /** The raw source bytes of the token, verbatim. */
99
+ raw: string;
100
+ /** Raw attribute text between the tag name and the closing `>`. */
101
+ attrs: string;
102
+ }
103
+
104
+ /** The IR construct a foldable tag maps onto. `pre` is BLOCK-level (a fenced
105
+ * code block); every other target is inline. */
106
+ export type HtmlFoldTarget =
107
+ | "bold"
108
+ | "italic"
109
+ | "strike"
110
+ | "code"
111
+ | "link"
112
+ | "break"
113
+ | "pre";
114
+
115
+ /** Tags with an exact native markdown equivalent on the Bot API 10.1 rich
116
+ * path. Folding them means the wire sees markdown it definitely parses
117
+ * instead of HTML it may reject. */
118
+ export const HTML_FOLD_TAGS: Readonly<Record<string, HtmlFoldTarget>> = {
119
+ b: "bold",
120
+ strong: "bold",
121
+ i: "italic",
122
+ em: "italic",
123
+ s: "strike",
124
+ del: "strike",
125
+ strike: "strike",
126
+ code: "code",
127
+ a: "link",
128
+ br: "break",
129
+ // `<pre>` is the one BLOCK-level fold. Telegram's own Rich HTML reference
130
+ // pairs `<pre><code class="language-…">` with the ```` ```lang ```` fence,
131
+ // so the fenced block is the exact native equivalent. Without it `<pre>`
132
+ // dropped its markup and the inner `<code>` folded to an INLINE span
133
+ // wrapping a newline — a construct Telegram will not parse, leaving the
134
+ // reader literal backticks around broken text.
135
+ pre: "pre",
136
+ };
137
+
138
+ /** HTML elements that carry no content of their own (void elements) plus the
139
+ * XHTML self-closing spelling. Dropping such a token's markup cannot lose
140
+ * text, so it degrades silently rather than surviving as literal prose.
141
+ * `br` is excluded — it FOLDS to a line break. */
142
+ export const HTML_VOID_TAGS: ReadonlySet<string> = new Set([
143
+ "area",
144
+ "base",
145
+ "col",
146
+ "embed",
147
+ "hr",
148
+ "img",
149
+ "input",
150
+ "link",
151
+ "meta",
152
+ "param",
153
+ "source",
154
+ "track",
155
+ "wbr",
156
+ ]);
157
+
158
+ /** Structural / block-level element names. A degrade of one of these leaves a
159
+ * hard line break behind so its neighbours do not glue together
160
+ * (`<ul><li>one</li><li>two</li></ul>` -> `onetwo` was the defect). The
161
+ * passthrough allowlist (`details`, `summary`, `aside`, `cite`) is
162
+ * deliberately absent — those never degrade. */
163
+ export const HTML_BLOCK_LEVEL_TAGS: ReadonlySet<string> = new Set([
164
+ "address",
165
+ "article",
166
+ "blockquote",
167
+ "center",
168
+ "dd",
169
+ "div",
170
+ "dl",
171
+ "dt",
172
+ "fieldset",
173
+ "figcaption",
174
+ "figure",
175
+ "footer",
176
+ "form",
177
+ "h1",
178
+ "h2",
179
+ "h3",
180
+ "h4",
181
+ "h5",
182
+ "h6",
183
+ "header",
184
+ "hr",
185
+ "li",
186
+ "main",
187
+ "nav",
188
+ "ol",
189
+ "p",
190
+ "section",
191
+ "table",
192
+ "tbody",
193
+ "td",
194
+ "tfoot",
195
+ "th",
196
+ "thead",
197
+ "tr",
198
+ "ul",
199
+ ]);
200
+
201
+ /** True when a degraded token should leave a line break behind. */
202
+ export function isBlockLevelTag(name: string): boolean {
203
+ return HTML_BLOCK_LEVEL_TAGS.has(name);
204
+ }
205
+
206
+ /** True for a tag that delimits no content of its own. */
207
+ export function isVoidTag(name: string): boolean {
208
+ return HTML_VOID_TAGS.has(name);
209
+ }
210
+
211
+ /**
212
+ * HTML-entity-escape a run of literal text so its angle brackets reach the
213
+ * reader instead of being parsed as a tag (or 400ing as an unsupported one).
214
+ *
215
+ * Bot API rich markdown "can contain arbitrary HTML … parsed as described in
216
+ * Rich HTML style", and Rich HTML's supported named entities are documented as
217
+ * exactly `&lt; &gt; &amp; &quot; &apos; &nbsp; &hellip; &mdash; &ndash;
218
+ * &lsquo; &rsquo; &ldquo; &rdquo;` (https://core.telegram.org/bots/api). Only
219
+ * the first three are needed here. `&` is escaped FIRST so an already-entity
220
+ * -looking run cannot be double-decoded.
221
+ */
222
+ export function escapeHtmlLiteral(text: string): string {
223
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
224
+ }
225
+
226
+ /** The `class="language-xxx"` hint on a `<pre><code …>` block, or null. */
227
+ const LANGUAGE_CLASS_RE = /(?:^|\s)class\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
228
+
229
+ /** Pull the fenced-block language out of a `<code class="language-python">`
230
+ * tag's attributes, or null. Telegram's reference spells the language hint
231
+ * exactly this way; anything else yields a plain fence. */
232
+ export function languageOf(tag: HtmlTagInfo): string | null {
233
+ const m = LANGUAGE_CLASS_RE.exec(tag.attrs);
234
+ if (m == null) return null;
235
+ const cls = m[1] ?? m[2] ?? m[3] ?? "";
236
+ const lang = cls
237
+ .split(/\s+/)
238
+ .map((c) => /^language-(.+)$/.exec(c)?.[1])
239
+ .find((c): c is string => c != null && c.length > 0);
240
+ // A fence info string cannot contain a backtick (it would close the fence).
241
+ return lang != null && !lang.includes("`") ? lang : null;
242
+ }
243
+
244
+ /** Tags PROVEN on the wire by raw `sendRichMessage` probes (2026-08-13) to
245
+ * render as native typed nodes. These pass through raw and unescaped.
246
+ * Deliberately an allowlist: an unprobed tag is not known-good syntax. */
247
+ export const HTML_PASSTHROUGH_TAGS: ReadonlySet<string> = new Set([
248
+ "u",
249
+ "sub",
250
+ "sup",
251
+ "details",
252
+ "summary",
253
+ "aside",
254
+ "cite",
255
+ ]);
256
+
257
+ /** Matches one HTML comment or one start/end tag. Attribute values containing
258
+ * a raw `>` are not supported (they are invalid unquoted HTML and vanishingly
259
+ * rare in agent prose); such a token simply degrades. */
260
+ export const HTML_TOKEN_RE =
261
+ /<!--[\s\S]*?-->|<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*?)?\/?>/g;
262
+
263
+ const TAG_RE = /^<(\/?)([A-Za-z][A-Za-z0-9-]*)((?:\s[^<>]*?)?)(\/?)>$/;
264
+ const HREF_RE = /(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
265
+
266
+ /** Classify a single raw HTML token. */
267
+ export function classifyHtmlTag(raw: string): HtmlTagInfo {
268
+ if (raw.startsWith("<!--")) return { kind: "comment", name: "", raw, attrs: "" };
269
+ const m = TAG_RE.exec(raw);
270
+ if (m == null) return { kind: "other", name: "", raw, attrs: "" };
271
+ const [, slash, name, attrs, selfClose] = m;
272
+ const kind: HtmlTagKind =
273
+ slash === "/" ? "close" : selfClose === "/" ? "selfclose" : "open";
274
+ return { kind, name: name.toLowerCase(), raw, attrs: attrs ?? "" };
275
+ }
276
+
277
+ /** Pull the `href` value out of a tag's attribute text, or null when absent
278
+ * or empty. The ORIGINAL bytes are returned — never rewritten. */
279
+ export function hrefOf(tag: HtmlTagInfo): string | null {
280
+ const m = HREF_RE.exec(tag.attrs);
281
+ if (m == null) return null;
282
+ const value = m[1] ?? m[2] ?? m[3] ?? "";
283
+ return value.length > 0 ? value : null;
284
+ }
285
+
286
+ /** True when this tag is emitted raw and unescaped (wire-verified allowlist). */
287
+ export function isPassthroughTag(tag: HtmlTagInfo): boolean {
288
+ return tag.name.length > 0 && HTML_PASSTHROUGH_TAGS.has(tag.name);
289
+ }
290
+
291
+ /**
292
+ * Decide which HTML tokens in a DOCUMENT are balanced markup and which are
293
+ * bare prose, given every token in document order.
294
+ *
295
+ * Matching cannot be decided inside a single mdast `html` node: micromark
296
+ * splits `<details open>…\n\nbody\n\n</details>` into THREE nodes, so the open
297
+ * and close markers arrive in different streams. A document-level pass is the
298
+ * only place the question is answerable. Feeding it mdast `html` nodes (rather
299
+ * than the raw source) also means tags inside a code fence or a code span are
300
+ * never counted — they are not markup and must not balance anything.
301
+ *
302
+ * Standard HTML-ish matching: opens are pushed on a stack and a close pops to
303
+ * the nearest same-named open, leaving anything above it unmatched. Void and
304
+ * self-closing tags are balanced by definition and are not tracked.
305
+ *
306
+ * Returns the SOURCE OFFSETS of every token that has a partner; a token whose
307
+ * offset is absent is unmatched — prose, per the module policy above.
308
+ */
309
+ export function matchedTagOffsets(
310
+ tokens: ReadonlyArray<{ tag: HtmlTagInfo; start: number }>,
311
+ ): Set<number> {
312
+ const matched = new Set<number>();
313
+ const stack: { name: string; start: number }[] = [];
314
+ for (const { tag, start } of tokens) {
315
+ if (tag.kind === "open" && !isVoidTag(tag.name)) {
316
+ stack.push({ name: tag.name, start });
317
+ continue;
318
+ }
319
+ if (tag.kind !== "close") continue;
320
+ for (let i = stack.length - 1; i >= 0; i--) {
321
+ if (stack[i].name !== tag.name) continue;
322
+ matched.add(stack[i].start);
323
+ matched.add(start);
324
+ stack.length = i;
325
+ break;
326
+ }
327
+ }
328
+ return matched;
329
+ }
330
+
331
+ /** A piece of a raw-HTML string: either an HTML token or a run of text. */
332
+ export type HtmlPiece =
333
+ | { kind: "tag"; tag: HtmlTagInfo; start: number; end: number }
334
+ | { kind: "text"; text: string; start: number; end: number };
335
+
336
+ /**
337
+ * Split a raw string into HTML tokens and the text runs between them.
338
+ * `base` is the absolute source offset the string starts at, so every piece
339
+ * carries usable UTF-16 offsets into the original markdown.
340
+ */
341
+ export function tokenizeHtml(raw: string, base: number): HtmlPiece[] {
342
+ const pieces: HtmlPiece[] = [];
343
+ const re = new RegExp(HTML_TOKEN_RE.source, "g");
344
+ let last = 0;
345
+ let m: RegExpExecArray | null;
346
+ while ((m = re.exec(raw)) != null) {
347
+ if (m.index > last) {
348
+ pieces.push({
349
+ kind: "text",
350
+ text: raw.slice(last, m.index),
351
+ start: base + last,
352
+ end: base + m.index,
353
+ });
354
+ }
355
+ pieces.push({
356
+ kind: "tag",
357
+ tag: classifyHtmlTag(m[0]),
358
+ start: base + m.index,
359
+ end: base + m.index + m[0].length,
360
+ });
361
+ last = m.index + m[0].length;
362
+ }
363
+ if (last < raw.length) {
364
+ pieces.push({
365
+ kind: "text",
366
+ text: raw.slice(last),
367
+ start: base + last,
368
+ end: base + raw.length,
369
+ });
370
+ }
371
+ return pieces;
372
+ }