switchroom 0.21.8 → 0.21.10

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 (40) hide show
  1. package/dist/cli/switchroom.js +100 -39
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/skills/switchroom-architecture/telegram.md +12 -10
  5. package/skills/switchroom-cli/SKILL.md +1 -1
  6. package/telegram-plugin/README.md +3 -1
  7. package/telegram-plugin/dist/gateway/gateway.js +919 -348
  8. package/telegram-plugin/format.ts +30 -5
  9. package/telegram-plugin/gateway/outbound-send-path.ts +7 -0
  10. package/telegram-plugin/gateway/speech-capture.ts +158 -0
  11. package/telegram-plugin/package.json +1 -1
  12. package/telegram-plugin/render/code-segments.ts +38 -4
  13. package/telegram-plugin/render/dollar-math-guard.ts +16 -1
  14. package/telegram-plugin/render/html-fold.ts +354 -0
  15. package/telegram-plugin/render/ir.ts +53 -3
  16. package/telegram-plugin/render/parse.ts +642 -42
  17. package/telegram-plugin/render/render.ts +53 -15
  18. package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
  19. package/telegram-plugin/rich-send.ts +22 -7
  20. package/telegram-plugin/shared/bot-runtime.ts +3 -2
  21. package/telegram-plugin/telegraph.ts +6 -4
  22. package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
  23. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
  24. package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
  25. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +253 -0
  26. package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
  27. package/telegram-plugin/tests/render/parse.test.ts +39 -10
  28. package/telegram-plugin/tests/render/render.test.ts +9 -4
  29. package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
  30. package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
  31. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
  32. package/telegram-plugin/tests/send-reply-golden.test.ts +99 -1
  33. package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
  34. package/telegram-plugin/tests/speech-capture.test.ts +296 -0
  35. package/telegram-plugin/tests/telegraph.test.ts +1 -1
  36. package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
  37. package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
  38. package/telegram-plugin/tts-normalize.ts +47 -9
  39. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
  40. package/telegram-plugin/voice-normalize-text.ts +48 -9
@@ -99,9 +99,26 @@ export function codeSpanSafe(s: string): string {
99
99
  * `\` first (so we never double-escape a following escape), then BOTH `(` and
100
100
  * `)` — the whole URL is preserved balanced and micromark decodes it back to
101
101
  * the original href on round-trip. Bot API 10.1 lists `(`/`)` as escapable.
102
+ *
103
+ * WHITESPACE and control characters are PERCENT-ENCODED rather than
104
+ * backslash-escaped. A bare link destination ends at the first ASCII
105
+ * whitespace character, so a space or a newline inside the href does not just
106
+ * truncate the URL — the remainder is re-read as a link title, or (for a
107
+ * newline) the inline link is terminated outright, leaving a structurally
108
+ * broken construct with URL fragments visible as prose. Backslash cannot
109
+ * rescue that: whitespace is not escapable in a bare destination. `%20` /
110
+ * `%0A` are the canonical URL encodings, so the href a client resolves is
111
+ * equivalent to the one the author wrote.
102
112
  */
103
113
  export function escapeLinkHref(href: string): string {
104
- return href.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
114
+ return href
115
+ .replace(/\\/g, '\\\\')
116
+ .replace(/\(/g, '\\(')
117
+ .replace(/\)/g, '\\)')
118
+ .replace(
119
+ /[\x00-\x20\x7f]/g,
120
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`,
121
+ )
105
122
  }
106
123
 
107
124
  /**
@@ -735,9 +752,12 @@ export function normalizePunctuation(text: string): string {
735
752
  // The dash rewrite runs per line so blockquote lines can be exempted:
736
753
  // `>` blockquotes are reserved for VERBATIM quoted text, and rewriting an
737
754
  // author's ` — ` to `, ` inside a quotation would corrupt what they quoted.
738
- // The expandable-blockquote opener `**>` is a blockquote line too even though
739
- // isBlockquoteLine (which keys on a leading `>`) doesn't catch the `**`
740
- // prefix, so exempt it explicitly. Code spans and link hrefs stay masked
755
+ // A line opening with the LEGACY `**>` expandable-quote marker is a
756
+ // blockquote line too (the render path no longer emits `**>` — it is
757
+ // MarkdownV2-only syntax, see render.ts renderBlockquote — but legacy agent
758
+ // output still contains it and parse.ts repairs it into a real quote), so
759
+ // exempt it explicitly even though isBlockquoteLine (which keys on a leading
760
+ // `>`) doesn't catch the `**` prefix. Code spans and link hrefs stay masked
741
761
  // throughout, so their dashes are already protected on every line.
742
762
  const rewriteDashes = (line: string): string =>
743
763
  line
@@ -1414,7 +1434,12 @@ const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
1414
1434
  /\*\*[^*\n]+\*\*/g, // bold
1415
1435
  /__[^_\n]+__/g, // underline
1416
1436
  /(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
1417
- /\[[^\]\n]*\]\([^)\n]*\)/g, // link [label](href)
1437
+ // link `[label](href)` — and, via the optional leading `!`, Telegram's
1438
+ // inline-entity form `![22:45 tomorrow](tg://time?unix=…&format=…)` /
1439
+ // `![](tg://emoji?id=…)`. Without the `!?` the protected span would start at
1440
+ // the `[`, so a cut could land in the one-character gap and strand the `!`
1441
+ // on the previous chunk — silently demoting a date_time entity to a link.
1442
+ /!?\[[^\]\n]*\]\([^)\n]*\)/g,
1418
1443
  /~~[^~\n]+~~/g, // strikethrough
1419
1444
  /\|\|[^|\n]+\|\|/g, // spoiler
1420
1445
  ]
@@ -43,6 +43,7 @@ import {
43
43
  isQuoteRejectionError,
44
44
  sendOptsHaveQuote,
45
45
  } from '../reply-quote.js'
46
+ import { captureSpeechText } from './speech-capture.js'
46
47
 
47
48
  // ── send-orchestration façade imports (#2996 P2) ──
48
49
  // Pure/deterministic helpers are imported; stateful or side-effecting gateway
@@ -1376,6 +1377,12 @@ export async function sendReply(
1376
1377
  // plain-text TTS input); synthesis happens just before the send so a
1377
1378
  // voice-only reply can suppress the text chunk loop on success. Voice is
1378
1379
  // fully best-effort — every failure below falls back to the text reply.
1380
+ // Raw-corpus capture (TTS redesign PR-0, flag-gated, off by default): `text`
1381
+ // here IS Stage A's future input — capture it byte-for-byte BEFORE the
1382
+ // resolve call, ahead of any TTS normalisation, so the redesign's property
1383
+ // tests can validate against real markdown instead of synthetic fixtures
1384
+ // only. See telegram-plugin/gateway/speech-capture.ts.
1385
+ captureSpeechText(text)
1379
1386
  const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
1380
1387
  const configParseMode = access.parseMode ?? 'html'
1381
1388
  const format = (args.format as string | undefined) ?? configParseMode
@@ -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
+ }
@@ -31,7 +31,7 @@
31
31
  "@secretlint/core": "^12.2.0",
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
33
  "@xterm/headless": "^6.0.0",
34
- "grammy": "^1.44",
34
+ "grammy": "^1.45",
35
35
  "mdast-util-from-markdown": "^2.0.2",
36
36
  "mdast-util-gfm": "^3.0.0",
37
37
  "micromark-extension-gfm": "^3.0.0",
@@ -73,6 +73,13 @@ export function splitCodeSegments(text: string): Segment[] {
73
73
  // • bare autolinked URLs — `http(s)://…` and `www.…` runs Telegram auto-links.
74
74
  // • GFM table rows — a table's structural pipes / empty cells (`|a||b|`) must
75
75
  // survive; escaping inside a real table row corrupts the table.
76
+ // • inline-math `$…$` spans — Telegram's rich GFM parser typesets a `$…$`
77
+ // pair as a real mathematical_expression node (wire-verified 2026-08-13).
78
+ // A COMPACT span (whitespace-free inner run that is not a bare currency
79
+ // amount, e.g. `$x^2+y^2$`) is intentional math and must reach the wire
80
+ // byte-identical: no guard may escape its `$`, `^`, `_`, `*`, or `+`.
81
+ // Currency-shaped inners (`$5M-$`) are NOT protected, so the dollar-math
82
+ // guard still breaks accidental `$5M … $10M` currency pairs (#3252).
76
83
  // Everything else is prose and stays fully guarded. This is the sibling of the
77
84
  // code-span skip: a PROTECTED segment (`code: true`) is emitted verbatim.
78
85
  //
@@ -93,6 +100,19 @@ function isTableCandidateLine(line: string): boolean {
93
100
  return /^\s*\|/.test(line);
94
101
  }
95
102
 
103
+ /** A compact `$…$` inline-math pair: opening `$`, a whitespace-free inner run
104
+ * with no nested `$`, closing `$`. Anchored — tested at the scanner's current
105
+ * position only. */
106
+ const COMPACT_MATH_PAIR = /^\$([^\s$]+)\$/;
107
+
108
+ /** A currency-shaped inner run: digits plus amount punctuation and magnitude
109
+ * suffix letters only (`5M-`, `0.5`, `10,000`, `5+`). Such a run between two
110
+ * `$` is a pair of ADJACENT currency amounts (`$5M-$10M`), not math — it must
111
+ * stay guardable so the dollar-math guard can break the accidental pair. A
112
+ * run containing any other character (`x^2+y^2`, `\alpha`, `a_b`) is treated
113
+ * as intentional math and protected. */
114
+ const CURRENCY_SHAPED_INNER = /^[0-9.,+\-kKmMbB]+$/;
115
+
96
116
  /** Find the [start, end) char ranges (relative to `text`) of GFM table blocks —
97
117
  * maximal runs of 2+ consecutive `|`-leading lines that contain a delimiter
98
118
  * row. Each returned range spans whole lines INCLUDING their trailing newline,
@@ -185,6 +205,19 @@ function splitProseProtected(text: string): Segment[] {
185
205
  continue;
186
206
  }
187
207
  }
208
+ // 4. Compact inline-math pair `$…$` — a supported Telegram construct
209
+ // (mathematical_expression, wire-verified 2026-08-13) that must reach
210
+ // the wire verbatim. Currency-shaped inners are NOT math (they are two
211
+ // adjacent amounts like `$5M-$10M`) and stay guardable.
212
+ if (ch === "$") {
213
+ const m = COMPACT_MATH_PAIR.exec(text.slice(i));
214
+ if (m && !CURRENCY_SHAPED_INNER.test(m[1])) {
215
+ const end = i + m[0].length;
216
+ pushProtected(i, end);
217
+ i = end;
218
+ continue;
219
+ }
220
+ }
188
221
  i++;
189
222
  }
190
223
  if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
@@ -193,10 +226,11 @@ function splitProseProtected(text: string): Segment[] {
193
226
 
194
227
  /** Split rendered markdown into prose / protected segments where a PROTECTED
195
228
  * (`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. */
229
+ * fenced code blocks, markdown link destinations, bare autolinks, GFM table
230
+ * rows, and compact `$…$` inline-math spans. This is the link/table/math-aware
231
+ * superset of `splitCodeSegments` that all the #3252 guards route through.
232
+ * Prose segments (`code: false`) remain guardable (including a link's
233
+ * `[label]` text). Deterministic, linear-time. */
200
234
  export function splitProtectedSegments(text: string): Segment[] {
201
235
  const out: Segment[] = [];
202
236
  for (const seg of splitCodeSegments(text)) {
@@ -29,6 +29,20 @@
29
29
  // a `$…$` pair if only the leading-`$digit` token is escaped — so we escape the
30
30
  // lot once the currency signal + 2-dollar threshold are met.
31
31
  //
32
+ // ── INTENTIONAL math is exempt (wire-verified 2026-08-13) ─────────────────
33
+ // Telegram's rich path renders a `$…$` pair as a native mathematical_expression
34
+ // node, and intentional math (`$x^2+y^2$`) must reach the wire byte-identical —
35
+ // an escaped `\$x^2+y^2\$` destroys a SUPPORTED construct. The discrimination
36
+ // lives in `splitProtectedSegments` (code-segments.ts): a COMPACT math span
37
+ // (whitespace-free inner, not currency-shaped) is a protected segment, so this
38
+ // guard neither counts its `$`s toward the 2+ threshold nor escapes them.
39
+ // Currency amounts always sit next to whitespace/prose (`$5M and $10M`) or have
40
+ // a digits-and-punctuation-only inner (`$5M-$10M`), so #3252-class accidental
41
+ // pairs remain fully guarded. Known residual: a SPACED math span (`$a + b$`)
42
+ // is indistinguishable from currency prose and is not exempted — it is escaped
43
+ // when the message also carries a currency signal, rendering as literal text
44
+ // (legible, not broken).
45
+ //
32
46
  // Idempotent (F5): the escape uses a negative-lookbehind (`(?<!\\)\$`) so an
33
47
  // already-escaped `\$` is never doubled to `\\$`. Running the guard twice (e.g.
34
48
  // the streaming path renders then this wrapper re-wraps) is a strict no-op the
@@ -100,7 +114,8 @@ const UNESCAPED_DOLLAR = /(?<!\\)\$/g;
100
114
  * them is digit-adjacent (a currency signal). When armed, EVERY unescaped prose
101
115
  * `$` is backslash-escaped so no two `$` can pair into a math span — this is
102
116
  * what closes the F3 trailing-`$` / `$.50` false-negatives. Code spans / fenced
103
- * blocks are never touched. Idempotent (F5) and deterministic.
117
+ * blocks AND compact intentional-math `$…$` spans (protected segments, see
118
+ * code-segments.ts) are never touched. Idempotent (F5) and deterministic.
104
119
  */
105
120
  export function guardDollarMath(text: string): string {
106
121
  if (!text.includes("$")) return text;