switchroom 0.16.38 → 0.16.47

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 (59) hide show
  1. package/dist/agent-scheduler/index.js +8 -2
  2. package/dist/auth-broker/index.js +7 -1
  3. package/dist/cli/notion-write-pretool.mjs +7 -1
  4. package/dist/cli/switchroom.js +1259 -375
  5. package/dist/cli/ui/index.html +877 -214
  6. package/dist/host-control/main.js +116 -84
  7. package/dist/vault/approvals/kernel-server.js +8 -2
  8. package/dist/vault/broker/server.js +8 -2
  9. package/examples/minimal.yaml +1 -1
  10. package/examples/switchroom.yaml +1 -1
  11. package/package.json +2 -2
  12. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  13. package/skills/switchroom-status/SKILL.md +1 -1
  14. package/telegram-plugin/bridge/bridge.ts +2 -1
  15. package/telegram-plugin/card-format.ts +7 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +20 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +2197 -964
  18. package/telegram-plugin/dist/server.js +20 -2
  19. package/telegram-plugin/format.ts +305 -31
  20. package/telegram-plugin/gateway/gateway.ts +310 -70
  21. package/telegram-plugin/gateway/model-command.ts +173 -19
  22. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  23. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  24. package/telegram-plugin/package.json +1 -1
  25. package/telegram-plugin/session-tail.ts +47 -1
  26. package/telegram-plugin/stream-reply-handler.ts +19 -1
  27. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  28. package/telegram-plugin/tests/card-format.test.ts +28 -0
  29. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  30. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  31. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  32. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  33. package/telegram-plugin/tests/model-command.test.ts +213 -47
  34. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  35. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  36. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  37. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  39. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  40. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  41. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  42. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  43. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  44. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  45. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  46. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  47. package/telegram-plugin/text-voice-scrub.ts +68 -18
  48. package/telegram-plugin/tool-activity-summary.ts +20 -108
  49. package/telegram-plugin/tts-normalize.ts +377 -0
  50. package/telegram-plugin/uat/driver.ts +472 -22
  51. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  52. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  53. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  56. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  57. package/telegram-plugin/voice-ondemand.ts +161 -10
  58. package/telegram-plugin/voice-presynth.ts +242 -0
  59. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -203,15 +203,27 @@ function restore(
203
203
  * Replace em / en dashes with context-appropriate punctuation.
204
204
  *
205
205
  * Rules, applied in order:
206
- * 1. ` — ` / ` – ` (flanked by single space) → `, ` if followed by a
207
- * lowercase or open-paren character; otherwise `. ` if followed by
208
- * an uppercase or end-of-string. Heuristic: lowercase = mid-clause
209
- * continuation (comma reads naturally); uppercase = new sentence
210
- * (period reads naturally).
206
+ * 1. ` — ` / ` – ` (flanked by single space) → `. ` + the following
207
+ * word recapitalized, in the common case. A spaced em-dash in prose
208
+ * almost always joins two INDEPENDENT clauses ("voice came back —
209
+ * three PRs stacked"); degrading that to a comma produces a comma
210
+ * splice ("voice came back, three PRs stacked"), which reads as
211
+ * broken grammar. A period (full stop) is the substitution that
212
+ * never corrupts meaning: two independent clauses become two
213
+ * sentences. The genuine appositive/parenthetical use of a dash
214
+ * ("the lead — a tall man — spoke") is the minority case and still
215
+ * reads acceptably as two short sentences, so we default to the
216
+ * period rather than risk a splice. A dash already followed by a
217
+ * lowercased conjunction/pronoun that only makes sense mid-sentence
218
+ * is the rare exception the heuristic can't recover; a clean period
219
+ * beats a comma splice there too. Original PR #1683 used a comma for
220
+ * lowercase continuations; that was the #2737-era source of splices
221
+ * in real replies (fixed here).
211
222
  * 2. End-of-line dash (` —\n` / ` –\n`) → `.\n` — treat as full stop.
212
223
  * 3. Bare dash with no flanking spaces between word chars
213
- * (e.g. "word—word") → `, ` the missing-space form is rarer but
214
- * semantically the same as #1.
224
+ * (e.g. "word—word") → `. ` + recapitalized following word the
225
+ * missing-space form is rarer but semantically the same as #1, so it
226
+ * gets the same splice-free full-stop treatment.
215
227
  * 4. Surviving dash (uncommon, e.g. at sentence start "— note") → `-`
216
228
  * so the message still renders without the AI tell.
217
229
  */
@@ -219,14 +231,27 @@ function replaceDashes(text: string): { out: string; replaced: number } {
219
231
  let replaced = 0
220
232
  let out = text
221
233
 
222
- // #1: spaced em-dash mid-prose. Decide between ", " and ". " on
223
- // the leading character of the following token.
224
- out = out.replace(/(\S) [—–] (\S)/g, (_m, before: string, after: string) => {
234
+ // Recapitalize the first ASCII-lowercase letter of `after` so the new
235
+ // sentence that a full-stop substitution creates reads correctly. A
236
+ // non-letter start (digit, `(`, quote) is left as-is. A lowercase-initial
237
+ // mixed-case brand token (iOS, eBay, iPhone, macOS — second char is
238
+ // uppercase) is ALSO left as-is: recapitalizing it to "IOS" corrupts the
239
+ // brand, and the token's own capitalization already reads as intentional.
240
+ const capFirst = (after: string): string => {
241
+ if (/^[a-z][A-Z]/.test(after)) return after
242
+ return after.replace(/^([a-z])/, (_m, ch: string) => ch.toUpperCase())
243
+ }
244
+
245
+ // #1: spaced em-dash mid-prose. A spaced dash joins two independent
246
+ // clauses far more often than it sets off an appositive, so degrade it
247
+ // to a full stop (never a comma — that produces a comma splice) and
248
+ // recapitalize the following word into a new sentence. A digit-flanked
249
+ // dash is a numeric range (10–20), not a clause break, so it's excluded
250
+ // here and falls through to rule #4 (→ ASCII hyphen, "10-20").
251
+ out = out.replace(/(\S) [—–] (\S)(\S?)/g, (m, before: string, after: string, peek: string) => {
252
+ if (/\d/.test(before) && /\d/.test(after)) return m
225
253
  replaced++
226
- // If `after` is uppercase ASCII or one of a known sentence-starter
227
- // set, treat as new sentence; otherwise a parenthetical comma.
228
- const sentenceStart = /[A-Z]/.test(after)
229
- return sentenceStart ? `${before}. ${after}` : `${before}, ${after}`
254
+ return `${before}. ${capFirst(after + peek)}`
230
255
  })
231
256
 
232
257
  // #2: dash at end of line. Treat as full stop.
@@ -235,11 +260,14 @@ function replaceDashes(text: string): { out: string; replaced: number } {
235
260
  return `.${ws}`
236
261
  })
237
262
 
238
- // #3: bare dash between word chars (no flanking spaces). Treat as
239
- // missing-space form of #1; comma is the safe fallback.
240
- out = out.replace(/(\w)[—–](\w)/g, (_m, before: string, after: string) => {
263
+ // #3: bare dash between word chars (no flanking spaces). Missing-space
264
+ // form of #1 same full-stop treatment so it can't create a splice. A
265
+ // digit-flanked dash (3–2) is a numeric range, not a clause break, so it's
266
+ // excluded and falls through to rule #4 (→ ASCII hyphen, "3-2").
267
+ out = out.replace(/(\w)[—–](\w)(\w?)/g, (m, before: string, after: string, peek: string) => {
268
+ if (/\d/.test(before) && /\d/.test(after)) return m
241
269
  replaced++
242
- return `${before}, ${after}`
270
+ return `${before}. ${capFirst(after + peek)}`
243
271
  })
244
272
 
245
273
  // #4: anything still standing — convert to ASCII hyphen so no
@@ -266,6 +294,28 @@ function replaceDashes(text: string): { out: string; replaced: number } {
266
294
  * checked per call so an operator can flip it via env var without a
267
295
  * restart of an in-process test.
268
296
  */
297
+ /**
298
+ * Dash-only normalization — the em/en-dash substitution from `scrubVoice`
299
+ * WITHOUT the opener-strip or the metric/result-object side effects.
300
+ *
301
+ * Exists so non-reply surfaces (progress cards, worker narration, PTY
302
+ * previews via card-format.ts) can close the em-dash leak on model-authored
303
+ * prose without pulling in the opener strip (which those paths must not do)
304
+ * and without duplicating the dash regexes. Reuses the exact same
305
+ * `park`/`replaceDashes`/`restore` mechanism as `scrubVoice`, so code spans
306
+ * and fenced blocks are masked identically and dashes inside them survive.
307
+ *
308
+ * Honors the same `SWITCHROOM_DISABLE_VOICE_SCRUB` kill switch. Returns the
309
+ * input unchanged when disabled, empty, or nothing changed.
310
+ */
311
+ export function normalizeDashes(text: string): string {
312
+ if (!enabled() || text.length === 0) return text
313
+ const { parked, parts } = park(text)
314
+ const { out, replaced } = replaceDashes(parked)
315
+ if (replaced === 0) return text
316
+ return restore(out, parts)
317
+ }
318
+
269
319
  export function scrubVoice(text: string): VoiceScrubResult {
270
320
  if (!enabled() || text.length === 0) {
271
321
  return { scrubbed: text, replaced: 0, openersStripped: 0 }
@@ -36,126 +36,38 @@
36
36
  // plain-English description or a domain label. This is the signal the
37
37
  // draft-mirror renders (option A: uniform across code + non-code agents).
38
38
 
39
- /** Strip a path to its basename for display. */
40
- function baseName(p: unknown): string | null {
41
- if (typeof p !== "string" || p.length === 0) return null;
42
- const parts = p.split("/").filter(Boolean);
43
- return parts.length > 0 ? parts[parts.length - 1] : p;
44
- }
45
-
46
- /** Extract a bare hostname from a URL for display (no scheme/path). */
47
- function hostName(u: unknown): string | null {
48
- if (typeof u !== "string" || u.length === 0) return null;
49
- try {
50
- return new URL(u).hostname.replace(/^www\./, "");
51
- } catch {
52
- return u.replace(/^https?:\/\//, "").split("/")[0] || null;
53
- }
54
- }
55
-
56
- function clip(s: unknown, n: number): string | null {
57
- if (typeof s !== "string") return null;
58
- const t = s.trim();
59
- if (t.length === 0) return null;
60
- return t.length > n ? t.slice(0, n - 1) + "…" : t;
61
- }
39
+ // (The per-tool wording helpers basename/hostname/clip live with the one
40
+ // composer in hooks/tool-label-pretool.mjs. See describeToolUse below.)
41
+ import { computeLabel } from './hooks/tool-label-pretool.mjs'
62
42
 
63
43
  /**
64
44
  * Render a single tool_use into a human-friendly, present-tense activity
65
45
  * line for the live draft preview — or null when the tool should NOT be
66
46
  * surfaced (the Telegram-plugin surface tools, which ARE the conversation).
67
47
  *
68
- * Leads with the model-authored descriptive field per the map above; falls
69
- * back to a domain label, then to a humanized tool name. Never emits raw
70
- * shell/query syntax.
48
+ * SINGLE-COMPOSER RULE: the wording itself lives in ONE place
49
+ * `computeLabel` in hooks/tool-label-pretool.mjs, the same function the
50
+ * real-time PreToolUse sidecar runs at tool-call time. This function is a
51
+ * thin delegating wrapper, NOT a second vocabulary: before the delegation
52
+ * the two tables had drifted (Grep "Searching . for X" vs "Searching for
53
+ * X", WebFetch "Fetching host/path" vs "Reading host", retain "Saving
54
+ * memory" vs "Saving to memory"), so the live feed and the flush-time /
55
+ * nested-sub-agent / worker-card lines rendered the SAME action with
56
+ * different copy — the wording drift the know-what-my-agent-is-doing spec
57
+ * bars. `status-vocabulary-unification.test.ts` pins the delegation; do not
58
+ * re-fork the table here.
71
59
  */
72
60
  export function describeToolUse(
73
61
  toolName: string,
74
62
  input: Record<string, unknown> | undefined,
75
63
  ): string | null {
76
64
  if (!toolName) return null;
77
- const inp = input ?? {};
78
-
79
- const mcpMatch = /^mcp__(.+?)__(.+)$/.exec(toolName);
80
- if (mcpMatch) {
81
- const server = mcpMatch[1].toLowerCase();
82
- const tool = mcpMatch[2].toLowerCase();
83
- // Surface tools ARE the conversation — never mirror them.
84
- // Use isTelegramSurfaceTool (regex-based, key-agnostic) so forks/renames work.
85
- if (isTelegramSurfaceTool(toolName)) return null;
86
- if (server === "hindsight") {
87
- if (tool === "recall" || tool === "reflect") return "Searching memory";
88
- if (tool === "retain" || tool === "update_memory" || tool === "sync_retain")
89
- return "Saving to memory";
90
- return "Working with memory";
91
- }
92
- if (
93
- server === "google-workspace" ||
94
- server === "claude_ai_google_calendar"
95
- ) {
96
- return "Checking your calendar";
97
- }
98
- if (server === "claude_ai_gmail") return "Checking your email";
99
- if (server === "claude_ai_google_drive") return "Looking through your files";
100
- if (server === "notion" || server === "claude_ai_notion") {
101
- return "Checking your notes";
102
- }
103
- // Unknown MCP tool: prefer a model-authored field, else a humanized name.
104
- const desc = clip(inp.description, 60) ?? clip(inp.query, 50) ?? clip(inp.title, 50);
105
- if (desc) return desc;
106
- return "Using " + tool.replace(/[-_]+/g, " ");
107
- }
108
-
109
- switch (toolName) {
110
- case "Bash": {
111
- // The model writes a plain-English description for every command.
112
- return clip(inp.description, 70) ?? "Running a command";
113
- }
114
- case "BashOutput":
115
- case "KillShell":
116
- return "Managing a background command";
117
- case "Read": {
118
- const f = baseName(inp.file_path);
119
- return f ? `Reading ${f}` : "Reading a file";
120
- }
121
- case "Edit":
122
- case "MultiEdit":
123
- case "NotebookEdit": {
124
- const f = baseName(inp.file_path) ?? baseName(inp.notebook_path);
125
- return f ? `Editing ${f}` : "Editing a file";
126
- }
127
- case "Write": {
128
- const f = baseName(inp.file_path);
129
- return f ? `Writing ${f}` : "Writing a file";
130
- }
131
- case "Grep":
132
- case "Glob": {
133
- const p = clip(inp.pattern, 40);
134
- return p ? `Searching for ${p}` : "Searching files";
135
- }
136
- case "WebFetch": {
137
- const h = hostName(inp.url);
138
- return h ? `Reading ${h}` : "Reading a web page";
139
- }
140
- case "WebSearch": {
141
- const q = clip(inp.query, 50);
142
- return q ? `Searching the web for ${q}` : "Searching the web";
143
- }
144
- case "Task":
145
- case "Agent": {
146
- const d = clip(inp.description, 60);
147
- return d ? `Delegating: ${d}` : "Delegating to a sub-agent";
148
- }
149
- case "TodoWrite":
150
- case "TaskCreate":
151
- case "TaskUpdate":
152
- case "TaskList":
153
- return "Updating the plan";
154
- case "ToolSearch":
155
- return "Finding the right tool";
156
- default:
157
- return "Working…";
158
- }
65
+ // Surface tools ARE the conversation — never mirror them. computeLabel
66
+ // suppresses these too (its own key-agnostic regex); this guard stays as
67
+ // the TS-side belt so a hook-side refactor can't leak a reply label into
68
+ // the feed.
69
+ if (isTelegramSurfaceTool(toolName)) return null;
70
+ return computeLabel(toolName, input ?? {});
159
71
  }
160
72
 
161
73
  // ─── Accumulating activity feed (draft-mirror Phase 2) ──────────────────────
@@ -0,0 +1,377 @@
1
+ /**
2
+ * tts-normalize.ts — deterministic L1 text-normalization front-end for
3
+ * the voice sidecar (issue #2760, Phase 1).
4
+ *
5
+ * Kokoro ships almost no text normalization, so raw agent text (markdown,
6
+ * symbols, numbers, currency, emoji, URLs, code) gets read literally and
7
+ * sounds unnatural. This module is a PURE string→string pass applied in
8
+ * the gateway voice-out path immediately before the `POST /tts` body is
9
+ * built, mirroring the `text-voice-scrub.ts` precedent: deterministic
10
+ * transform, code-region park/restore, kill-switch env var, no network,
11
+ * no LLM (Phase 2 adds the optional local-LLM layer).
12
+ *
13
+ * It runs AFTER `normalizeForSpeech` (which already strips most markdown
14
+ * on the plan-building path) and is deliberately idempotent/overlapping
15
+ * with it: the Listen lazy path synthesizes from a persisted cache whose
16
+ * entries may predate normalizeForSpeech coverage, and this stage is the
17
+ * last line of defence at the actual /tts body build. Rules where both
18
+ * passes apply are no-ops the second time.
19
+ *
20
+ * Gating: ON BY DEFAULT (operator decision 2026-07-04 — Phase 1 ships
21
+ * default-on). The only gate is the kill switch:
22
+ * `SWITCHROOM_DISABLE_TTS_NORMALIZE=1` returns the input byte-identical —
23
+ * same shape as `SWITCHROOM_DISABLE_VOICE_SCRUB`; rollback is one env var.
24
+ *
25
+ * Rules (en locale, deterministic, conservative — when unsure, pass
26
+ * text through unchanged):
27
+ * - Markdown: bold/italic/strike markers removed; headings → plain;
28
+ * links → link text; bare URLs → spoken domain ("github dot com
29
+ * link"); list markers dropped; tables → "table omitted"; code
30
+ * fences → "code block omitted"; inline code read as-is without
31
+ * backticks (content is parked so number/symbol expansion can never
32
+ * mangle an identifier).
33
+ * - Emoji: stripped (a small map speaks the common ones).
34
+ * - Numbers: currency ($5.20 → "five dollars twenty"), percentages,
35
+ * ordinals (3rd → "third"), times (14:30), ISO dates, phone-like
36
+ * digit runs read digit-by-digit, units (km, GB, ms, …).
37
+ * - Symbols: & → and, @ → at, # → hash, word/word → "word slash word",
38
+ * ~5 → "about 5", > blockquote markers dropped.
39
+ */
40
+
41
+ const NULL = '\x00'
42
+ const INLINE_PH = `${NULL}TN_INLINE`
43
+
44
+ /** On by default; the kill switch is the only gate. */
45
+ export function ttsNormalizeEnabled(): boolean {
46
+ const kill = process.env.SWITCHROOM_DISABLE_TTS_NORMALIZE
47
+ return !(kill === '1' || kill === 'true')
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Number → words (English cardinal, 0..999,999,999; larger stays digits so we
52
+ // never emit a wrong reading).
53
+ // ---------------------------------------------------------------------------
54
+ const ONES = [
55
+ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight',
56
+ 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
57
+ 'sixteen', 'seventeen', 'eighteen', 'nineteen',
58
+ ]
59
+ const TENS = [
60
+ '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy',
61
+ 'eighty', 'ninety',
62
+ ]
63
+
64
+ function belowThousand(n: number): string {
65
+ if (n < 20) return ONES[n]!
66
+ if (n < 100) {
67
+ const t = TENS[Math.floor(n / 10)]!
68
+ const o = n % 10
69
+ return o ? `${t}-${ONES[o]}` : t
70
+ }
71
+ const h = `${ONES[Math.floor(n / 100)]} hundred`
72
+ const rest = n % 100
73
+ return rest ? `${h} ${belowThousand(rest)}` : h
74
+ }
75
+
76
+ function numberToWords(n: number): string | null {
77
+ if (!Number.isInteger(n) || n < 0 || n > 999_999_999) return null
78
+ if (n === 0) return 'zero'
79
+ const parts: string[] = []
80
+ const millions = Math.floor(n / 1_000_000)
81
+ const thousands = Math.floor((n % 1_000_000) / 1000)
82
+ const rest = n % 1000
83
+ if (millions) parts.push(`${belowThousand(millions)} million`)
84
+ if (thousands) parts.push(`${belowThousand(thousands)} thousand`)
85
+ if (rest) parts.push(belowThousand(rest))
86
+ return parts.join(' ')
87
+ }
88
+
89
+ /** Ordinal words for 0..99 ("third", "twenty-first"). null out of range. */
90
+ function ordinalToWords(n: number): string | null {
91
+ if (!Number.isInteger(n) || n < 0 || n > 99) return null
92
+ const IRREGULAR: Record<number, string> = {
93
+ 0: 'zeroth', 1: 'first', 2: 'second', 3: 'third', 5: 'fifth',
94
+ 8: 'eighth', 9: 'ninth', 12: 'twelfth',
95
+ }
96
+ if (IRREGULAR[n]) return IRREGULAR[n]
97
+ if (n < 20) return `${ONES[n]}th`
98
+ const tens = Math.floor(n / 10)
99
+ const ones = n % 10
100
+ if (ones === 0) return `${TENS[tens]!.replace(/y$/, 'ie')}th`
101
+ return `${TENS[tens]}-${ordinalToWords(ones)}`
102
+ }
103
+
104
+ function yearToWords(y: number): string | null {
105
+ if (y < 1000 || y > 9999) return null
106
+ if (y >= 2000 && y < 2100) {
107
+ const lo = y % 100
108
+ return lo ? `two thousand ${belowThousand(lo)}` : 'two thousand'
109
+ }
110
+ const hi = Math.floor(y / 100)
111
+ const lo = y % 100
112
+ return lo === 0 ? `${belowThousand(hi)} hundred` : `${belowThousand(hi)} ${belowThousand(lo)}`
113
+ }
114
+
115
+ const MONTHS = [
116
+ '', 'January', 'February', 'March', 'April', 'May', 'June', 'July',
117
+ 'August', 'September', 'October', 'November', 'December',
118
+ ]
119
+
120
+ /** Unit suffixes → spoken units (singular/plural). Lowercased keys. */
121
+ const UNIT_MAP: Record<string, { s: string; p: string }> = {
122
+ ms: { s: 'millisecond', p: 'milliseconds' },
123
+ s: { s: 'second', p: 'seconds' },
124
+ sec: { s: 'second', p: 'seconds' },
125
+ min: { s: 'minute', p: 'minutes' },
126
+ h: { s: 'hour', p: 'hours' },
127
+ hr: { s: 'hour', p: 'hours' },
128
+ d: { s: 'day', p: 'days' },
129
+ km: { s: 'kilometre', p: 'kilometres' },
130
+ m: { s: 'metre', p: 'metres' },
131
+ cm: { s: 'centimetre', p: 'centimetres' },
132
+ mm: { s: 'millimetre', p: 'millimetres' },
133
+ mi: { s: 'mile', p: 'miles' },
134
+ kg: { s: 'kilogram', p: 'kilograms' },
135
+ g: { s: 'gram', p: 'grams' },
136
+ kb: { s: 'kilobyte', p: 'kilobytes' },
137
+ mb: { s: 'megabyte', p: 'megabytes' },
138
+ gb: { s: 'gigabyte', p: 'gigabytes' },
139
+ tb: { s: 'terabyte', p: 'terabytes' },
140
+ ghz: { s: 'gigahertz', p: 'gigahertz' },
141
+ mhz: { s: 'megahertz', p: 'megahertz' },
142
+ }
143
+
144
+ /** Small spoken map for the most common emoji; everything else is dropped. */
145
+ const EMOJI_SPOKEN: Record<string, string> = {
146
+ '👍': 'thumbs up',
147
+ '👎': 'thumbs down',
148
+ '✅': 'done',
149
+ '❤️': 'love',
150
+ '⚠️': 'warning',
151
+ }
152
+
153
+ /** Speak a URL's domain: "https://github.com/x/y" → "github dot com link". */
154
+ function spokenUrl(url: string): string {
155
+ const m = /^https?:\/\/(?:www\.)?([^/\s:?#]+)/i.exec(url)
156
+ if (!m) return 'a link'
157
+ const host = m[1]!
158
+ // Only verbalize simple dotted hostnames; an IP or userinfo-laden host
159
+ // reads badly — fall back to "a link".
160
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host)) {
161
+ return 'a link'
162
+ }
163
+ return `${host.toLowerCase().split('.').join(' dot ')} link`
164
+ }
165
+
166
+ function parkInline(text: string): {
167
+ parked: string
168
+ parts: Array<{ idx: number; raw: string }>
169
+ } {
170
+ const parts: Array<{ idx: number; raw: string }> = []
171
+ const parked = text.replace(/`([^`\n]+)`/g, (_m, content: string) => {
172
+ const idx = parts.length
173
+ // Park the CONTENT (backticks already dropped) so the spoken output
174
+ // reads the identifier as-is and no later pass can rewrite it.
175
+ parts.push({ idx, raw: content })
176
+ return `${INLINE_PH}${idx}${NULL}`
177
+ })
178
+ return { parked, parts }
179
+ }
180
+
181
+ function restoreInline(text: string, parts: Array<{ idx: number; raw: string }>): string {
182
+ let restored = text
183
+ for (let i = parts.length - 1; i >= 0; i--) {
184
+ const p = parts[i]!
185
+ restored = restored.replace(`${INLINE_PH}${p.idx}${NULL}`, () => p.raw)
186
+ }
187
+ return restored
188
+ }
189
+
190
+ /**
191
+ * Normalize reply text for TTS. Pure + deterministic; returns the input
192
+ * unchanged when the feature flag is off, the kill switch is on, or the
193
+ * input is empty.
194
+ */
195
+ export function normalizeForTts(text: string): string {
196
+ if (!ttsNormalizeEnabled() || text.length === 0) return text
197
+
198
+ let s = text.replace(/\r\n?/g, '\n')
199
+
200
+ // -- Code fences → spoken placeholder (before anything can see contents).
201
+ s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '$1code block omitted.')
202
+ s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*$/g, '$1code block omitted.')
203
+
204
+ // -- Park inline code (content spoken as-is, backticks gone).
205
+ const { parked, parts } = parkInline(s)
206
+ s = parked
207
+ s = s.replace(/`/g, '') // residual unpaired backticks
208
+
209
+ // -- Markdown tables → "table omitted." A table block = consecutive lines
210
+ // with pipes that include a separator row (|---|---|).
211
+ s = s.replace(
212
+ /(?:^|\n)((?:[ \t]*\|[^\n]*\n)?[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(?:\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*(?:\n[ \t]*\|[^\n]*)*)/g,
213
+ '\ntable omitted.',
214
+ )
215
+
216
+ // -- Images / links → text; autolinks + bare URLs → spoken domain.
217
+ s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
218
+ s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
219
+ s = s.replace(/<(https?:\/\/[^>\s]+)>/gi, (_m, url: string) => spokenUrl(url))
220
+ s = s.replace(/\bhttps?:\/\/[^\s)]+/gi, (m) => spokenUrl(m))
221
+
222
+ // -- Emoji: speak the small common map, drop everything else.
223
+ for (const [emoji, spoken] of Object.entries(EMOJI_SPOKEN)) {
224
+ s = s.split(emoji).join(` ${spoken} `)
225
+ }
226
+ s = s.replace(
227
+ /[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{200D}\u{2B50}]/gu,
228
+ '',
229
+ )
230
+
231
+ // -- Emphasis markers (paired forms, longest first), strikethrough.
232
+ s = s.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
233
+ s = s.replace(/___(.+?)___/g, '$1')
234
+ s = s.replace(/\*\*(.+?)\*\*/g, '$1')
235
+ s = s.replace(/__(.+?)__/g, '$1')
236
+ s = s.replace(/\*(.+?)\*/g, '$1')
237
+ s = s.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, '$1')
238
+ s = s.replace(/~~(.+?)~~/g, '$1')
239
+
240
+ // -- Block markup per line: headings, blockquote '>' markers, list
241
+ // markers, horizontal rules.
242
+ s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
243
+ s = s.replace(/^[ \t]{0,3}>[ \t]?/gm, '')
244
+ s = s.replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '')
245
+ s = s.replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, '')
246
+ s = s.replace(/^[ \t]{0,3}([-_*])\1{2,}[ \t]*$/gm, '')
247
+
248
+ // -- ISO dates YYYY-MM-DD → "July fourth two thousand twenty-six".
249
+ // MUST run before the phone-run pass (an ISO date is 8 digits with
250
+ // hyphens and would otherwise be read digit-by-digit).
251
+ s = s.replace(/\b(\d{4})-(\d{2})-(\d{2})\b/g, (m, y: string, mo: string, da: string) => {
252
+ const month = Number(mo)
253
+ const day = Number(da)
254
+ if (month < 1 || month > 12 || day < 1 || day > 31) return m
255
+ const yw = yearToWords(Number(y))
256
+ const dw = ordinalToWords(day)
257
+ if (!yw || !dw) return m
258
+ return `${MONTHS[month]} ${dw} ${yw}`
259
+ })
260
+
261
+ // -- Phone-like digit runs → digit-by-digit. Fires ONLY on clearly
262
+ // phone-shaped tokens: a leading + (international), or a leading 0
263
+ // (national) with separator-delimited 2-4-digit grouping; 7-15
264
+ // digits total. Ordinary space-separated numbers ("1024 2048 4096")
265
+ // and short groupings without a phone prefix ("12 34 56 78") stay
266
+ // untouched.
267
+ s = s.replace(/(?<![\w.])(\+?\d[\d\- ]{6,}\d)(?![\w.])/g, (m: string) => {
268
+ const digits = m.replace(/\D/g, '')
269
+ if (digits.length < 7 || digits.length > 15) return m
270
+ const groups = m.replace(/^\+/, '').split(/[- ]+/)
271
+ const phoneShaped =
272
+ m.startsWith('+') ||
273
+ (m.startsWith('0') &&
274
+ groups.length >= 2 &&
275
+ groups.every((g) => /^\d{2,4}$/.test(g)))
276
+ if (!phoneShaped) return m
277
+ const words: string[] = []
278
+ if (m.startsWith('+')) words.push('plus')
279
+ for (const ch of digits) words.push(ONES[Number(ch)]!)
280
+ return words.join(' ')
281
+ })
282
+
283
+ // -- Times HH:MM (24h) → spoken. The (?!:?\d) guard skips HH:MM:SS
284
+ // entirely — a half-spoken time with a dangling ":46" reads worse
285
+ // than leaving the digits as-is.
286
+ s = s.replace(/\b([01]?\d|2[0-3]):([0-5]\d)(?!:?\d)/g, (_m, hh: string, mm: string) => {
287
+ const h = Number(hh)
288
+ const min = Number(mm)
289
+ const hw = belowThousand(h)
290
+ if (min === 0) return `${hw} o'clock`
291
+ if (min < 10) return `${hw} oh ${ONES[min]}`
292
+ return `${hw} ${belowThousand(min)}`
293
+ })
294
+
295
+ // -- Currency: $5 → "five dollars"; $5.20 → "five dollars twenty";
296
+ // $1,000 → "one thousand dollars" (thousands separators consumed).
297
+ // The trailing lookahead bails on odd-cents ("$5.203") and partial
298
+ // thousands ("$1,00") — the whole token is left unchanged rather
299
+ // than half-read.
300
+ s = s.replace(
301
+ /\$(\d{1,3}(?:,\d{3})+|\d{1,9})(?:\.(\d{2}))?(?![\d,]|\.\d)/g,
302
+ (m, dollarsRaw: string, cents?: string) => {
303
+ const dollars = Number(dollarsRaw.replace(/,/g, ''))
304
+ const dw = numberToWords(dollars)
305
+ if (!dw) return m
306
+ const noun = dollars === 1 && !cents ? 'dollar' : 'dollars'
307
+ if (cents && cents !== '00') {
308
+ const cw = numberToWords(Number(cents))
309
+ if (!cw) return m
310
+ return `${dw} ${noun} ${cw}`
311
+ }
312
+ return `${dw} ${noun}`
313
+ },
314
+ )
315
+
316
+ // -- Percentages: 12% → "twelve percent"; 3.5% keeps digits + "percent".
317
+ s = s.replace(/\b(\d{1,9})%/g, (m, n: string) => {
318
+ const w = numberToWords(Number(n))
319
+ return w ? `${w} percent` : m
320
+ })
321
+ s = s.replace(/(\d)\s*%/g, '$1 percent')
322
+
323
+ // -- Ordinals: 3rd → "third" (guarded to matching suffix only).
324
+ s = s.replace(/\b(\d{1,2})(st|nd|rd|th)\b/gi, (m, n: string, suffix: string) => {
325
+ const num = Number(n)
326
+ const correct =
327
+ (num % 10 === 1 && num % 100 !== 11 && suffix.toLowerCase() === 'st') ||
328
+ (num % 10 === 2 && num % 100 !== 12 && suffix.toLowerCase() === 'nd') ||
329
+ (num % 10 === 3 && num % 100 !== 13 && suffix.toLowerCase() === 'rd') ||
330
+ (suffix.toLowerCase() === 'th' &&
331
+ !((num % 10 === 1 && num % 100 !== 11) || (num % 10 === 2 && num % 100 !== 12) || (num % 10 === 3 && num % 100 !== 13)))
332
+ if (!correct) return m
333
+ const w = ordinalToWords(num)
334
+ return w ?? m
335
+ })
336
+
337
+ // -- Number + unit suffix glued to the number (500ms, 5km, 10GB). Only a
338
+ // known unit bounded by a non-letter, so identifiers never match.
339
+ s = s.replace(
340
+ /\b(\d{1,9})\s?(ms|sec|min|km|cm|mm|kg|kb|mb|gb|tb|ghz|mhz|hr|mi|s|m|h|d|g)(?![a-z])/gi,
341
+ (m, num: string, unitRaw: string) => {
342
+ const unit = UNIT_MAP[unitRaw.toLowerCase()]
343
+ if (!unit) return m
344
+ const n = Number(num)
345
+ const w = numberToWords(n)
346
+ if (!w) return m
347
+ return `${w} ${n === 1 ? unit.s : unit.p}`
348
+ },
349
+ )
350
+
351
+ // -- Symbols in prose.
352
+ s = s.replace(/(\s)&(\s)/g, '$1and$2')
353
+ s = s.replace(/(\w)&(\w)/g, '$1 and $2')
354
+ s = s.replace(/(^|\s)@(\w)/g, '$1at $2')
355
+ s = s.replace(/(^|\s)#(\w)/g, '$1hash $2')
356
+ // word/word in prose → "word slash word" (letters only, so paths/dates
357
+ // with digits are untouched).
358
+ s = s.replace(/\b([a-zA-Z]{2,})\/([a-zA-Z]{2,})\b/g, '$1 slash $2')
359
+ // ~ before a number → "about"; other tildes dropped.
360
+ s = s.replace(/~\s*(\d)/g, 'about $1')
361
+ s = s.replace(/~/g, '')
362
+ // Arrows → "to".
363
+ s = s.replace(/[=-]>/g, ' to ')
364
+ s = s.replace(/[→⇒]/g, ' to ')
365
+
366
+ // -- Restore parked inline-code contents verbatim.
367
+ s = restoreInline(s, parts)
368
+
369
+ // -- Whitespace → sentence flow.
370
+ s = s.replace(/[ \t]*\n[ \t]*\n[ \t]*/g, '. ')
371
+ s = s.replace(/\s*\n\s*/g, ' ')
372
+ s = s.replace(/[ \t]{2,}/g, ' ')
373
+ s = s.replace(/\.\s*\.(\s|$)/g, '.$1')
374
+ s = s.replace(/\s+([,.!?;:])/g, '$1')
375
+
376
+ return s.trim()
377
+ }