switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -0,0 +1,297 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { decodeRichMessage } from "./driver.js";
3
+
4
+ /**
5
+ * Direct unit tests for `decodeRichMessage` — the Bot API 10.1 rich-message
6
+ * decoder in the MTProto UAT driver. These are HOSTED (no live Telegram, no
7
+ * driver session): they feed hand-built Instant-View page trees whose shapes
8
+ * are grounded 1:1 against `@mtcute/tl@223.0.0` (the TL layer mtcute 0.30
9
+ * ships) and assert the flat `{ text, entities }` the decoder produces.
10
+ *
11
+ * They are the CI-verifiable floor under the live render scenario
12
+ * (`scenarios/jtbd-rich-formatting-render-dm.test.ts`), which can only run on
13
+ * the gated uat-host. If Telegram's real GFM parser ever emits a shape these
14
+ * fixtures don't model, the live scenario catches it; these tests pin the
15
+ * decoder's behavior for the shapes we DO model so a decoder regression reds
16
+ * in ordinary CI without needing the runner.
17
+ *
18
+ * TL shape sources (all from `@mtcute/tl@223.0.0` index.d.ts):
19
+ * RichText: textPlain{text:string} · textConcat{texts:RichText[]} ·
20
+ * textBold/Italic/Underline/Strike/Fixed{text:RichText} ·
21
+ * textUrl{text:RichText,url:string} · textEmail{text,email} ·
22
+ * textMarked{text:RichText} (no textSpoiler exists → spoiler)
23
+ * PageBlock: pageBlockParagraph{text} · pageBlockPreformatted{text,language}
24
+ * · pageBlockHeader/Subheader{text} · pageBlockBlockquote/
25
+ * Pullquote{text,caption} · pageBlockList{items:PageListItem[]} ·
26
+ * pageBlockOrderedList{items:PageListOrderedItem[]} ·
27
+ * pageBlockTable{title,rows:PageTableRow[]} · pageBlockDivider
28
+ * Items: pageListItemText{text} · pageListItemBlocks{blocks} ·
29
+ * pageListOrderedItemText{num,text} ·
30
+ * pageListOrderedItemBlocks{num,blocks}
31
+ * Table: pageTableRow{cells:PageTableCell[]} ·
32
+ * pageTableCell{header?,text?:RichText}
33
+ */
34
+
35
+ // ── RichText builders (grounded shapes) ─────────────────────────────────────
36
+ const plain = (text: string) => ({ _: "textPlain", text });
37
+ const concat = (...texts: unknown[]) => ({ _: "textConcat", texts });
38
+ const bold = (text: unknown) => ({ _: "textBold", text });
39
+ const italic = (text: unknown) => ({ _: "textItalic", text });
40
+ const underline = (text: unknown) => ({ _: "textUnderline", text });
41
+ const strike = (text: unknown) => ({ _: "textStrike", text });
42
+ const fixed = (text: unknown) => ({ _: "textFixed", text });
43
+ const marked = (text: unknown) => ({ _: "textMarked", text }); // → spoiler
44
+ const url = (text: unknown, u: string) => ({ _: "textUrl", text, url: u });
45
+ const email = (text: unknown, e: string) => ({ _: "textEmail", text, email: e });
46
+
47
+ // ── PageBlock builders ──────────────────────────────────────────────────────
48
+ const para = (text: unknown) => ({ _: "pageBlockParagraph", text });
49
+ const pre = (text: unknown, language = "") => ({
50
+ _: "pageBlockPreformatted",
51
+ text,
52
+ language,
53
+ });
54
+ const header = (text: unknown) => ({ _: "pageBlockHeader", text });
55
+ const subheader = (text: unknown) => ({ _: "pageBlockSubheader", text });
56
+ const blockquote = (text: unknown, caption: unknown = { _: "textEmpty" }) => ({
57
+ _: "pageBlockBlockquote",
58
+ text,
59
+ caption,
60
+ });
61
+ const list = (...items: unknown[]) => ({ _: "pageBlockList", items });
62
+ const orderedList = (...items: unknown[]) => ({
63
+ _: "pageBlockOrderedList",
64
+ items,
65
+ });
66
+ const table = (title: unknown, rows: unknown[]) => ({
67
+ _: "pageBlockTable",
68
+ title,
69
+ rows,
70
+ });
71
+ const divider = () => ({ _: "pageBlockDivider" });
72
+
73
+ const itemText = (text: unknown) => ({ _: "pageListItemText", text });
74
+ const ordItemText = (num: string, text: unknown) => ({
75
+ _: "pageListOrderedItemText",
76
+ num,
77
+ text,
78
+ });
79
+ const row = (...cells: unknown[]) => ({ _: "pageTableRow", cells });
80
+ const cell = (text: unknown, header = false) => ({
81
+ _: "pageTableCell",
82
+ ...(header ? { header: true } : {}),
83
+ text,
84
+ });
85
+
86
+ const rich = (...blocks: unknown[]) => ({ _: "richMessage", blocks });
87
+
88
+ // A convenience matcher — find the single entity of a kind.
89
+ const entOf = (
90
+ r: { entities: Array<{ kind: string }> },
91
+ kind: string,
92
+ ) => r.entities.filter((e) => e.kind === kind);
93
+
94
+ describe("decodeRichMessage — non-rich guards", () => {
95
+ it("returns null for a non-richMessage value", () => {
96
+ expect(decodeRichMessage(undefined)).toBeNull();
97
+ expect(decodeRichMessage(null)).toBeNull();
98
+ expect(decodeRichMessage({ _: "message", blocks: [] })).toBeNull();
99
+ expect(decodeRichMessage({ _: "richMessage" })).toBeNull(); // no blocks[]
100
+ });
101
+
102
+ it("decodes an empty block list to empty output", () => {
103
+ expect(decodeRichMessage(rich())).toEqual({ text: "", entities: [] });
104
+ });
105
+ });
106
+
107
+ describe("decodeRichMessage — inline RichText marks (existing coverage)", () => {
108
+ it("bold / italic / code with exact spans", () => {
109
+ const r = decodeRichMessage(
110
+ rich(
111
+ para(
112
+ concat(
113
+ plain("a "),
114
+ bold(plain("bold")),
115
+ plain(" "),
116
+ italic(plain("it")),
117
+ plain(" "),
118
+ fixed(plain("code")),
119
+ ),
120
+ ),
121
+ ),
122
+ )!;
123
+ expect(r.text).toBe("a bold it code");
124
+ expect(entOf(r, "bold")[0]).toMatchObject({ offset: 2, length: 4, text: "bold" });
125
+ expect(entOf(r, "italic")[0]).toMatchObject({ text: "it" });
126
+ expect(entOf(r, "code")[0]).toMatchObject({ text: "code" });
127
+ });
128
+
129
+ it("text_link carries the destination url; email classified", () => {
130
+ const r = decodeRichMessage(
131
+ rich(
132
+ para(
133
+ concat(
134
+ url(plain("repo"), "https://example.com/x"),
135
+ plain(" "),
136
+ email(plain("a@b.com"), "a@b.com"),
137
+ ),
138
+ ),
139
+ ),
140
+ )!;
141
+ expect(entOf(r, "text_link")[0]).toMatchObject({
142
+ text: "repo",
143
+ url: "https://example.com/x",
144
+ });
145
+ expect(entOf(r, "email")[0]).toMatchObject({ text: "a@b.com" });
146
+ });
147
+
148
+ it("pageBlockPreformatted → a single pre entity with language", () => {
149
+ const r = decodeRichMessage(rich(pre(plain('echo "hi"'), "bash")))!;
150
+ expect(r.text).toBe('echo "hi"');
151
+ expect(entOf(r, "pre")[0]).toMatchObject({
152
+ offset: 0,
153
+ length: 9,
154
+ language: "bash",
155
+ });
156
+ });
157
+ });
158
+
159
+ describe("decodeRichMessage — strikethrough / underline / spoiler marks", () => {
160
+ it("textStrike → strikethrough, textUnderline → underline", () => {
161
+ const r = decodeRichMessage(
162
+ rich(para(concat(strike(plain("gone")), plain(" "), underline(plain("keep"))))),
163
+ )!;
164
+ expect(r.text).toBe("gone keep");
165
+ expect(entOf(r, "strikethrough")[0]).toMatchObject({ text: "gone", offset: 0 });
166
+ expect(entOf(r, "underline")[0]).toMatchObject({ text: "keep", offset: 5 });
167
+ });
168
+
169
+ it("textMarked → spoiler (TL 223 has no textSpoiler)", () => {
170
+ const r = decodeRichMessage(rich(para(concat(plain("psst "), marked(plain("secret"))))))!;
171
+ expect(r.text).toBe("psst secret");
172
+ expect(entOf(r, "spoiler")[0]).toMatchObject({ text: "secret", offset: 5 });
173
+ });
174
+ });
175
+
176
+ describe("decodeRichMessage — headings", () => {
177
+ it("pageBlockHeader / Subheader render as bold over the line", () => {
178
+ const r = decodeRichMessage(
179
+ rich(header(plain("Title")), subheader(plain("Sub")), para(plain("body"))),
180
+ )!;
181
+ expect(r.text).toBe("Title\nSub\nbody");
182
+ const bolds = entOf(r, "bold");
183
+ expect(bolds.map((e) => e.text).sort()).toEqual(["Sub", "Title"]);
184
+ expect(bolds.find((e) => e.text === "Title")).toMatchObject({ offset: 0, length: 5 });
185
+ });
186
+ });
187
+
188
+ describe("decodeRichMessage — blockquotes", () => {
189
+ it("pageBlockBlockquote → blockquote entity over the body", () => {
190
+ const r = decodeRichMessage(rich(blockquote(plain("quoted line"))))!;
191
+ expect(r.text).toBe("quoted line");
192
+ expect(entOf(r, "blockquote")[0]).toMatchObject({
193
+ offset: 0,
194
+ length: 11,
195
+ text: "quoted line",
196
+ });
197
+ });
198
+
199
+ it("blockquote caption follows on its own line, unstyled", () => {
200
+ const r = decodeRichMessage(rich(blockquote(plain("body"), plain("— src"))))!;
201
+ expect(r.text).toBe("body\n— src");
202
+ expect(entOf(r, "blockquote")[0]).toMatchObject({ text: "body" });
203
+ // caption is not part of the blockquote span
204
+ expect(entOf(r, "blockquote")[0].length).toBe(4);
205
+ });
206
+ });
207
+
208
+ describe("decodeRichMessage — lists", () => {
209
+ it("unordered list → bulleted lines, inline marks preserved", () => {
210
+ const r = decodeRichMessage(
211
+ rich(list(itemText(plain("first")), itemText(bold(plain("second"))))),
212
+ )!;
213
+ expect(r.text).toBe("• first\n• second");
214
+ expect(entOf(r, "bold")[0]).toMatchObject({ text: "second" });
215
+ });
216
+
217
+ it("ordered list → numbered lines using the wire .num label", () => {
218
+ const r = decodeRichMessage(
219
+ rich(
220
+ orderedList(
221
+ ordItemText("1", plain("alpha")),
222
+ ordItemText("2", plain("beta")),
223
+ ),
224
+ ),
225
+ )!;
226
+ expect(r.text).toBe("1. alpha\n2. beta");
227
+ });
228
+ });
229
+
230
+ describe("decodeRichMessage — tables", () => {
231
+ it("table rows join with ' | ', header cells emit bold", () => {
232
+ const r = decodeRichMessage(
233
+ rich(
234
+ table({ _: "textEmpty" }, [
235
+ row(cell(plain("H1"), true), cell(plain("H2"), true)),
236
+ row(cell(plain("a")), cell(plain("b"))),
237
+ ]),
238
+ ),
239
+ )!;
240
+ expect(r.text).toBe("H1 | H2\na | b");
241
+ const bolds = entOf(r, "bold").map((e) => e.text).sort();
242
+ expect(bolds).toEqual(["H1", "H2"]);
243
+ // body cells are not bold
244
+ expect(entOf(r, "bold").some((e) => e.text === "a")).toBe(false);
245
+ });
246
+
247
+ it("table title renders on its own line above the rows", () => {
248
+ const r = decodeRichMessage(
249
+ rich(table(plain("Scores"), [row(cell(plain("x")), cell(plain("y")))])),
250
+ )!;
251
+ expect(r.text).toBe("Scores\nx | y");
252
+ });
253
+ });
254
+
255
+ describe("decodeRichMessage — divider", () => {
256
+ it("pageBlockDivider → a literal rule line, no entity", () => {
257
+ const r = decodeRichMessage(rich(para(plain("above")), divider(), para(plain("below"))))!;
258
+ expect(r.text).toBe("above\n———\nbelow");
259
+ expect(r.entities).toEqual([]);
260
+ });
261
+ });
262
+
263
+ describe("decodeRichMessage — full torture tree (offsets stay UTF-16 correct)", () => {
264
+ it("mixed blocks decode with coherent offsets across the whole body", () => {
265
+ const r = decodeRichMessage(
266
+ rich(
267
+ header(plain("Report")),
268
+ para(
269
+ concat(
270
+ bold(plain("bold")),
271
+ plain(" "),
272
+ italic(plain("it")),
273
+ plain(" "),
274
+ strike(plain("old")),
275
+ plain(" "),
276
+ marked(plain("spoil")),
277
+ ),
278
+ ),
279
+ blockquote(plain("a wise quote")),
280
+ list(itemText(plain("one")), itemText(plain("two"))),
281
+ orderedList(ordItemText("1", plain("step"))),
282
+ divider(),
283
+ pre(plain("code()"), "js"),
284
+ ),
285
+ )!;
286
+ // Every entity's recorded text must equal the slice at its offset —
287
+ // the invariant that proves offset bookkeeping is coherent.
288
+ for (const e of r.entities) {
289
+ expect(r.text.slice(e.offset, e.offset + e.length)).toBe(e.text);
290
+ }
291
+ // Sanity on the kinds present.
292
+ const kinds = new Set(r.entities.map((e) => e.kind));
293
+ for (const k of ["bold", "italic", "strikethrough", "spoiler", "blockquote", "pre"]) {
294
+ expect(kinds.has(k)).toBe(true);
295
+ }
296
+ });
297
+ });
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Speech normalization for OUTBOUND voice replies (voice-out).
3
+ *
4
+ * The problem: the agent's reply is Markdown-flavoured text. A TTS engine
5
+ * (Kokoro sidecar or OpenAI) reads it LITERALLY — so `~about 3`, `**bold**`,
6
+ * `` `code` ``, `# Heading`, `[label](https://…)`, and code fences all get
7
+ * spoken as "tilde", "asterisk asterisk", "backtick", "hash", or the raw URL
8
+ * read character-by-character. That is exactly the operator feedback: the
9
+ * voice is good but it pronounces the markup.
10
+ *
11
+ * `normalizeForSpeech` is a PURE string→string pass applied to the reply text
12
+ * BEFORE it is handed to any TTS engine. It replaces the older, partial
13
+ * `stripMarkdown` pass on the voice-out path (that one left `~`, code fences,
14
+ * tables, and arrows leaking through). It is deliberately conservative: the
15
+ * goal is natural prose, not aggressive rewriting — when in doubt it leaves
16
+ * real words alone.
17
+ *
18
+ * Documented behavioural choices (the "sensible defaults" the task allows):
19
+ * - Inline code (`` `x` ``): the backticks are dropped, the CONTENT is
20
+ * kept and spoken. Short inline code is usually a word/identifier the
21
+ * listener wants to hear.
22
+ * - Fenced code blocks (``` … ```): DROPPED entirely and replaced with a
23
+ * short spoken placeholder ("(code block omitted)"). Reading a block of
24
+ * code aloud is noise; a listener on a bike can't act on it anyway.
25
+ * - Links `[text](url)`: spoken as just `text`; the URL is dropped. A bare
26
+ * autolink `<https://…>` or a raw URL is replaced with "a link" so the
27
+ * engine never spells out a URL character-by-character.
28
+ * - `~` is DROPPED (not read as "tilde", not expanded to "about") — it is
29
+ * ambiguous (strikethrough marker vs. approx) and dropping is the safest
30
+ * choice that never mangles a real word.
31
+ * - `->` / `=>` / `→` become the spoken word "to".
32
+ * - A tiny, well-tested set of trivially-safe abbreviations is expanded
33
+ * ("e.g." → "for example", "i.e." → "that is", "etc." → "and so on",
34
+ * "vs" → "versus", "approx" → "approximately", "w/" → "with").
35
+ * Anything ambiguous is left alone.
36
+ *
37
+ * Phase 2 pre-TTS naturalization (all conservative, number/token-guarded):
38
+ * - Emoji & pictographs are dropped entirely (TTS would read their long
39
+ * CLDR names); `:shortcode:` forms are dropped too. Whitespace collapses.
40
+ * - Numbers, units & symbols are spoken: `%` → "percent", `$5.50` → "five
41
+ * dollars fifty", `12x`/`12×` → "twelve times", `°C` → "degrees",
42
+ * unit suffixes (`500ms`, `2h`, `10KB`, `100k`) expand to words, and the
43
+ * `& + =` glue symbols become "and / plus / equals".
44
+ * - A curated acronym set (CI, PR, API, URL, GPU, CPU, TTS, STT, HTTP,
45
+ * JSON, SQL, UI) is spelled letter-by-letter; word-style acronyms
46
+ * (NASA) are left alone.
47
+ * - Clear time / date patterns: `12:45` → "twelve forty-five", ISO
48
+ * `2026-07-01` → "July first two thousand twenty-six".
49
+ * Every phase-2 pass is guarded to fire only on a clear number+token so real
50
+ * identifiers (`my_var`, `class5`, `my5thing`) pass through untouched.
51
+ */
52
+
53
+ /** Replace a fenced code block with a spoken placeholder. */
54
+ const CODE_BLOCK_PLACEHOLDER = 'code block omitted'
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Number → words helpers (small, deterministic, English cardinal only).
58
+ // Used by the numbers/units pass. Supports 0..999_999_999 which is far more
59
+ // than any realistic spoken quantity; larger inputs are left as digits so we
60
+ // never emit a wrong or truncated reading.
61
+ // ---------------------------------------------------------------------------
62
+ const ONES = [
63
+ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight',
64
+ 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
65
+ 'sixteen', 'seventeen', 'eighteen', 'nineteen',
66
+ ]
67
+ const TENS = [
68
+ '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty',
69
+ 'ninety',
70
+ ]
71
+
72
+ /** Cardinal words for 0..999. */
73
+ function belowThousand(n: number): string {
74
+ if (n < 20) return ONES[n]
75
+ if (n < 100) {
76
+ const t = TENS[Math.floor(n / 10)]
77
+ const o = n % 10
78
+ return o ? `${t}-${ONES[o]}` : t
79
+ }
80
+ const h = `${ONES[Math.floor(n / 100)]} hundred`
81
+ const rest = n % 100
82
+ return rest ? `${h} ${belowThousand(rest)}` : h
83
+ }
84
+
85
+ /** Cardinal words for a non-negative integer, or null if out of range. */
86
+ function numberToWords(n: number): string | null {
87
+ if (!Number.isInteger(n) || n < 0 || n > 999_999_999) return null
88
+ if (n === 0) return 'zero'
89
+ const parts: string[] = []
90
+ const millions = Math.floor(n / 1_000_000)
91
+ const thousands = Math.floor((n % 1_000_000) / 1000)
92
+ const rest = n % 1000
93
+ if (millions) parts.push(`${belowThousand(millions)} million`)
94
+ if (thousands) parts.push(`${belowThousand(thousands)} thousand`)
95
+ if (rest) parts.push(belowThousand(rest))
96
+ return parts.join(' ')
97
+ }
98
+
99
+ /** Ordinal words for 1..31 (used for spoken dates). */
100
+ const ORDINALS: Record<number, string> = {
101
+ 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', 6: 'sixth',
102
+ 7: 'seventh', 8: 'eighth', 9: 'ninth', 10: 'tenth', 11: 'eleventh',
103
+ 12: 'twelfth', 13: 'thirteenth', 14: 'fourteenth', 15: 'fifteenth',
104
+ 16: 'sixteenth', 17: 'seventeenth', 18: 'eighteenth', 19: 'nineteenth',
105
+ 20: 'twentieth', 21: 'twenty-first', 22: 'twenty-second',
106
+ 23: 'twenty-third', 24: 'twenty-fourth', 25: 'twenty-fifth',
107
+ 26: 'twenty-sixth', 27: 'twenty-seventh', 28: 'twenty-eighth',
108
+ 29: 'twenty-ninth', 30: 'thirtieth', 31: 'thirty-first',
109
+ }
110
+
111
+ const MONTHS = [
112
+ '', 'January', 'February', 'March', 'April', 'May', 'June', 'July',
113
+ 'August', 'September', 'October', 'November', 'December',
114
+ ]
115
+
116
+ /** Spoken form for a 4-digit year (e.g. 2026 → "two thousand twenty six"). */
117
+ function yearToWords(y: number): string | null {
118
+ if (y < 1000 || y > 9999) return null
119
+ // 2000..2099 read as "two thousand …" which is the realistic range for
120
+ // these timestamps and reads naturally for TTS.
121
+ if (y >= 2000 && y < 2100) {
122
+ const lo = y % 100
123
+ const base = 'two thousand'
124
+ return lo ? `${base} ${belowThousand(lo)}` : base
125
+ }
126
+ // Generic "nineteen eighty-four" style for other centuries.
127
+ const hi = Math.floor(y / 100)
128
+ const lo = y % 100
129
+ const hiW = belowThousand(hi)
130
+ if (lo === 0) return `${hiW} hundred`
131
+ return `${hiW} ${belowThousand(lo)}`
132
+ }
133
+
134
+ /** Spoken minutes for a time-of-day (e.g. 45 → "forty-five", 5 → "oh five"). */
135
+ function minutesToWords(mm: number): string {
136
+ if (mm === 0) return "o'clock"
137
+ if (mm < 10) return `oh ${ONES[mm]}`
138
+ return belowThousand(mm)
139
+ }
140
+
141
+ /** Number-unit suffixes: token suffix → { singular, plural } spoken unit. */
142
+ const UNIT_MAP: Record<string, { s: string; p: string }> = {
143
+ ms: { s: 'millisecond', p: 'milliseconds' },
144
+ s: { s: 'second', p: 'seconds' },
145
+ sec: { s: 'second', p: 'seconds' },
146
+ min: { s: 'minute', p: 'minutes' },
147
+ m: { s: 'minute', p: 'minutes' },
148
+ h: { s: 'hour', p: 'hours' },
149
+ hr: { s: 'hour', p: 'hours' },
150
+ d: { s: 'day', p: 'days' },
151
+ kb: { s: 'kilobyte', p: 'kilobytes' },
152
+ mb: { s: 'megabyte', p: 'megabytes' },
153
+ gb: { s: 'gigabyte', p: 'gigabytes' },
154
+ tb: { s: 'terabyte', p: 'terabytes' },
155
+ }
156
+
157
+ /** Curated initialisms spoken letter-by-letter. Uppercase keys only. */
158
+ const ACRONYMS = new Set([
159
+ 'CI', 'PR', 'API', 'URL', 'GPU', 'CPU', 'TTS', 'STT', 'HTTP', 'JSON',
160
+ 'SQL', 'UI',
161
+ ])
162
+
163
+ /**
164
+ * Convert a Markdown/plain reply into clean text for a TTS engine.
165
+ * Pure and deterministic — same input always yields the same output.
166
+ */
167
+ export function normalizeForSpeech(input: string): string {
168
+ if (!input) return ''
169
+ let s = input.replace(/\r\n?/g, '\n')
170
+
171
+ // 0. Emoji & pictographs → dropped entirely, then whitespace collapsed.
172
+ // TTS reads an emoji as its long CLDR name ("grinning face"), which is
173
+ // noise. We also drop `:shortcode:` forms so nothing is read as
174
+ // "colon rocket colon". The shortcode form is matched narrowly
175
+ // (:word: with letters/digits/_/- ) so real colon usage survives.
176
+ s = s.replace(
177
+ /[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{200D}\u{2B50}\u{3030}\u{303D}\u{3297}\u{3299}\u{24C2}]/gu,
178
+ '',
179
+ )
180
+ s = s.replace(/:([a-z0-9][a-z0-9_+-]*):/gi, ' ')
181
+
182
+ // 1. Fenced code blocks first (```lang … ``` or ~~~ … ~~~) — drop the
183
+ // whole block before any inline processing can see its contents.
184
+ s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, `$1${CODE_BLOCK_PLACEHOLDER}.`)
185
+ // An unterminated fence (opening ``` with no close) — drop to end.
186
+ s = s.replace(/(^|\n)[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*$/g, `$1${CODE_BLOCK_PLACEHOLDER}.`)
187
+
188
+ // 2. Images ![alt](url) → alt text (or drop when alt is empty).
189
+ s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
190
+
191
+ // 3. Links [text](url) → text ; drop the URL entirely.
192
+ s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
193
+
194
+ // 4. Autolinks <https://…> and bare URLs → "a link" (never spell a URL).
195
+ s = s.replace(/<https?:\/\/[^>\s]+>/gi, 'a link')
196
+ s = s.replace(/\bhttps?:\/\/[^\s)]+/gi, 'a link')
197
+
198
+ // 5. Inline code `x` → x (keep content, drop backticks). Run before the
199
+ // generic backtick sweep so paired spans are handled cleanly.
200
+ s = s.replace(/`([^`\n]+)`/g, '$1')
201
+ // Any residual backticks → drop.
202
+ s = s.replace(/`/g, '')
203
+
204
+ // 6. Emphasis markers. Paired forms first (longest marker first), then
205
+ // strip residual markup-by-construction doubles. A LONE `*` or `_` in
206
+ // the middle of maths/words is left alone (see step 11).
207
+ s = s.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
208
+ s = s.replace(/___(.+?)___/g, '$1')
209
+ s = s.replace(/\*\*(.+?)\*\*/g, '$1')
210
+ s = s.replace(/__(.+?)__/g, '$1')
211
+ s = s.replace(/\*(.+?)\*/g, '$1')
212
+ s = s.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, '$1')
213
+ // Strikethrough ~~text~~ → text.
214
+ s = s.replace(/~~(.+?)~~/g, '$1')
215
+
216
+ // 7. Leading block markup, per line: headings, blockquotes, list markers.
217
+ // List bullets/numbers become a natural sentence pause rather than a
218
+ // spoken "dash" / "1 dot".
219
+ s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
220
+ s = s.replace(/^[ \t]{0,3}>[ \t]?/gm, '')
221
+ s = s.replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '')
222
+ s = s.replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, '')
223
+
224
+ // 8. Horizontal rules (---, ___, ***) on their own line → drop.
225
+ s = s.replace(/^[ \t]{0,3}([-_*])\1{2,}[ \t]*$/gm, '')
226
+
227
+ // 9. Table syntax: drop pipes and separator rows so tables read as prose.
228
+ s = s.replace(/^[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*$/gm, '')
229
+ s = s.replace(/\|/g, ' ')
230
+
231
+ // 10. Arrows → the spoken word "to".
232
+ s = s.replace(/[=-]>/g, ' to ')
233
+ s = s.replace(/[→⇒]/g, ' to ')
234
+
235
+ // 11. Stray tildes (approx / leftover markers) → drop. Ambiguous; dropping
236
+ // is the safe choice that never mangles a real word.
237
+ s = s.replace(/~/g, '')
238
+
239
+ // 12. Trivially-safe abbreviation expansions (case-insensitive, only at a
240
+ // word boundary followed by space/comma). Kept minimal on purpose.
241
+ s = s.replace(/\be\.g\.,?/gi, 'for example,')
242
+ s = s.replace(/\bi\.e\.,?/gi, 'that is,')
243
+ s = s.replace(/\betc\./gi, 'and so on')
244
+ s = s.replace(/\bapprox\.?(?=\s|$)/gi, 'approximately')
245
+ s = s.replace(/\bvs\.?(?=\s|$)/gi, 'versus')
246
+ s = s.replace(/\bw\/(?=\s)/gi, 'with ')
247
+
248
+ // 13. Dates & times (clear patterns only, run before the numbers pass so
249
+ // the colon in HH:MM and the hyphens in ISO dates are consumed here).
250
+ // ISO date YYYY-MM-DD → "Month Dayth Year".
251
+ s = s.replace(/\b(\d{4})-(\d{2})-(\d{2})\b/g, (m, y, mo, da) => {
252
+ const year = Number(y)
253
+ const month = Number(mo)
254
+ const day = Number(da)
255
+ if (month < 1 || month > 12 || day < 1 || day > 31) return m
256
+ const yw = yearToWords(year)
257
+ const ord = ORDINALS[day]
258
+ if (!yw || !ord) return m
259
+ return `${MONTHS[month]} ${ord} ${yw}`
260
+ })
261
+ // Clock time HH:MM (24h ok) → spoken. Guarded by word boundaries so a
262
+ // ratio like "3:2" or a bare number isn't caught (needs 2-digit MM).
263
+ s = s.replace(/\b([01]?\d|2[0-3]):([0-5]\d)\b/g, (m, hh, mm) => {
264
+ const h = Number(hh)
265
+ const min = Number(mm)
266
+ const hw = belowThousand(h)
267
+ if (min === 0) return `${hw} o'clock`
268
+ return `${hw} ${minutesToWords(min)}`
269
+ })
270
+
271
+ // 14. Numbers, units & symbols → spoken words. Each sub-pass is guarded so
272
+ // it only fires on a clear number+token, never mid-word.
273
+ // Currency: $5 / $5.50 → "five dollars" / "five dollars fifty".
274
+ s = s.replace(/\$(\d{1,9})(?:\.(\d{2}))?\b/g, (m, dollars, cents) => {
275
+ const dw = numberToWords(Number(dollars))
276
+ if (!dw) return m
277
+ const noun = Number(dollars) === 1 && !cents ? 'dollar' : 'dollars'
278
+ if (cents && cents !== '00') {
279
+ const cw = numberToWords(Number(cents))
280
+ return `${dw} ${noun} ${cw}`
281
+ }
282
+ return `${dw} ${noun}`
283
+ })
284
+ // Percent sign → " percent".
285
+ s = s.replace(/(\d)\s*%/g, '$1 percent')
286
+ // Temperature °C / °F → "degrees".
287
+ s = s.replace(/°\s*[CF]\b/g, ' degrees')
288
+ s = s.replace(/°/g, ' degrees')
289
+ // Multiplier NN× / NNx → "NN times" (x only when glued to a number).
290
+ s = s.replace(/\b(\d{1,9})\s*[×x](?![a-z0-9])/gi, (m, n) => {
291
+ const w = numberToWords(Number(n))
292
+ return w ? `${w} times` : m
293
+ })
294
+ // "100k" shorthand → "one hundred thousand" (k as thousands multiplier).
295
+ s = s.replace(/\b(\d{1,6})k\b/gi, (m, n) => {
296
+ const w = numberToWords(Number(n) * 1000)
297
+ return w ? w : m
298
+ })
299
+ // Number + unit suffix → "<number> <unit>" (e.g. 500ms, 2h, 10KB).
300
+ // Only when the suffix is a known unit glued directly to the number and
301
+ // bounded by a non-letter (so "my5thing" / "class5" are never touched).
302
+ s = s.replace(
303
+ /\b(\d{1,9})(ms|sec|min|kb|mb|gb|tb|hr|s|m|h|d)(?![a-z])/gi,
304
+ (m, num, unitRaw) => {
305
+ const unit = UNIT_MAP[unitRaw.toLowerCase()]
306
+ if (!unit) return m
307
+ const n = Number(num)
308
+ const w = numberToWords(n)
309
+ if (!w) return m
310
+ return `${w} ${n === 1 ? unit.s : unit.p}`
311
+ },
312
+ )
313
+ // Symbols between tokens: standalone & → and, + → plus, = → equals.
314
+ s = s.replace(/(\S)\s*\+\s*(\S)/g, '$1 plus $2')
315
+ s = s.replace(/\s=\s/g, ' equals ')
316
+ s = s.replace(/(\s)&(\s)/g, '$1and$2')
317
+ s = s.replace(/(\w)&(\w)/g, '$1 and $2')
318
+
319
+ // 15. Acronyms → letter-by-letter for a curated set of initialisms. Only a
320
+ // standalone all-caps token that exactly matches the map is expanded;
321
+ // word-style acronyms (NASA) and sub-tokens of larger words are left.
322
+ s = s.replace(/\b[A-Z]{2,5}\b/g, (tok) =>
323
+ ACRONYMS.has(tok) ? tok.split('').join(' ') : tok,
324
+ )
325
+
326
+ // 16. Collapse excessive punctuation the engine would over-emphasise.
327
+ s = s.replace(/([!?.]){2,}/g, '$1')
328
+
329
+ // 17. Whitespace → sentence flow. Blank lines become a sentence break so
330
+ // paragraphs don't run together; everything else collapses to a single
331
+ // space.
332
+ s = s.replace(/[ \t]*\n[ \t]*\n[ \t]*/g, '. ')
333
+ s = s.replace(/\s*\n\s*/g, ' ')
334
+ s = s.replace(/[ \t]{2,}/g, ' ')
335
+ // Tidy artifacts from the paragraph→". " substitution (". ." → ". ").
336
+ s = s.replace(/\.\s*\.(\s|$)/g, '.$1')
337
+ s = s.replace(/\s+([,.!?;:])/g, '$1')
338
+
339
+ return s.trim()
340
+ }