switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -177,7 +177,7 @@ import {
177
177
  STATUS_LINE_MAX,
178
178
  NESTED_PREFIX,
179
179
  } from './status-no-truncate.js'
180
- import { escapeMarkdown, stripMarkdown, truncate } from './card-format.js'
180
+ import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
181
181
  import { isTelegramSurfaceTool } from './tool-names.js'
182
182
 
183
183
  /**
@@ -304,7 +304,7 @@ function escapeStepLine(raw: string): string {
304
304
  *
305
305
  * `out` — accumulator mutated in place
306
306
  * `steps` — pre-cleaned + pre-escaped HTML step strings
307
- * `allDone` — when true ALL steps render done (✓ italic); when false the
307
+ * `allDone` — when true ALL steps render done (✓ struck italic); when false the
308
308
  * newest renders in-progress (→ bold)
309
309
  * `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
310
310
  */
@@ -320,7 +320,7 @@ export function renderStepFeed(
320
320
  if (hidden > 0) out.push(`_✓ +${hidden} earlier…_`)
321
321
  const lastIdx = shown.length - 1
322
322
  shown.forEach((s, i) => {
323
- out.push(!allDone && i === lastIdx ? `**→ ${s}${liveSuffix}**` : `_✓ ${s}_`)
323
+ out.push(!allDone && i === lastIdx ? `**→ ${s}${liveSuffix}**` : `~~_✓ ${s}_~~`)
324
324
  })
325
325
  }
326
326
 
@@ -400,7 +400,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
400
400
  const shownParent = steps.slice(-STATUS_ROLLING_LINES)
401
401
  const hiddenParent = steps.length - shownParent.length
402
402
  if (hiddenParent > 0) out.push(`_✓ +${hiddenParent} earlier…_`)
403
- for (const s of shownParent) out.push(`_✓ ${s}_`)
403
+ for (const s of shownParent) out.push(`~~_✓ ${s}_~~`)
404
404
  // Child block.
405
405
  const shownChild = children.slice(-STATUS_ROLLING_LINES)
406
406
  const hiddenChild = children.length - shownChild.length
@@ -410,7 +410,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
410
410
  out.push(
411
411
  i === lastChildIdx && !final
412
412
  ? `${NESTED_PREFIX}**→ ${s}${liveSuffix}**`
413
- : `${NESTED_PREFIX}_${s}_`,
413
+ : `${NESTED_PREFIX}~~_${s}_~~`,
414
414
  )
415
415
  })
416
416
  } else {
@@ -429,7 +429,10 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
429
429
  // out always carries the two header lines, so it is never empty — but guard
430
430
  // against a degenerate header-less future caller.
431
431
  if (out.length === 0) return null
432
- const joined = out.join('\n')
432
+ // Stack lines with GFM hard breaks (` \n`) so the card's styled prose lines
433
+ // don't collapse onto one visual line in the rich-message renderer — see
434
+ // stackCardLines. This is what makes a card render identically to a reply.
435
+ const joined = stackCardLines(out)
433
436
  if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
434
437
  return fitCardToBudget(opts, headerLines)
435
438
  }
@@ -482,7 +485,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
482
485
  const lastIdx = shown.length - 1
483
486
  shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
484
487
  lines.push(...footerLines)
485
- const candidate = lines.join('\n')
488
+ const candidate = stackCardLines(lines)
486
489
  if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
487
490
  }
488
491
 
@@ -508,7 +511,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
508
511
  if (parentMarker != null) lines.push(parentMarker)
509
512
  lines.push(newestLine)
510
513
  lines.push(...footerLines)
511
- return lines.join('\n')
514
+ return stackCardLines(lines)
512
515
  }
513
516
 
514
517
  /**
@@ -838,11 +838,18 @@ export class Driver {
838
838
  }
839
839
 
840
840
  function toObserved(msg: Message, edited: boolean): ObservedMessage {
841
+ // Bot API 10.1 sendRichMessage stores text in a new MTProto media type.
842
+ // mtcute 0.27.9 decodes this as messageMediaUnsupported with empty
843
+ // message.message. Use a sentinel so text-based assertions still fire
844
+ // until mtcute is updated to support the new TL constructor.
845
+ const rawText = msg.text ?? "";
846
+ const isRichMedia = rawText === "" &&
847
+ msg.raw._ === "message" && msg.raw.media?._ === "messageMediaUnsupported";
841
848
  return {
842
849
  chatId: msg.chat.id,
843
850
  messageId: msg.id,
844
851
  threadId: msg.replyToMessage?.threadId ?? undefined,
845
- text: msg.text ?? "",
852
+ text: isRichMedia ? "\x01" : rawText,
846
853
  senderUserId: msg.sender.id,
847
854
  fromBot: msg.sender.type === "user" && msg.sender.isBot === true,
848
855
  date: msg.date,
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * On-demand voice-out (reply_mode='on-demand').
3
+ *
4
+ * Instead of synthesizing at reply time, the gateway appends a single
5
+ * "🔊 Listen" inline button carrying a reserved `voice:<token>` callback_data;
6
+ * the voice note is synthesized + sent only when the user taps it. Zero
7
+ * GPU/sidecar work happens unless requested — which keeps the voice pipeline
8
+ * subscription-honest and visible: nothing is generated behind the user's back
9
+ * (see reference/vision.md outcome 3, jobs/keep-my-subscription-honest.md and
10
+ * the local-sidecar voice path).
11
+ *
12
+ * This module owns the reserved callback prefix, the token mint, the bounded
13
+ * TTL LRU cache (keyed by TOKEN because the message_id is not known at send
14
+ * time), the Listen-button builder, and the collision gate that decides
15
+ * whether the button may be injected. It is deliberately dependency-free so it
16
+ * can be unit-tested without importing the 25k-line gateway module.
17
+ */
18
+
19
+ import { randomBytes } from 'crypto'
20
+
21
+ /** Reserved callback_data prefix for on-demand Listen buttons. Handled
22
+ * INTERNALLY by the gateway's callback dispatcher (never routed to the agent
23
+ * as an inbound), so it must NOT overlap the agent: / auth: / op: families. */
24
+ export const VOICE_ONDEMAND_CALLBACK_PREFIX = 'voice:'
25
+
26
+ /** Cache entry TTL. A button tapped after this degrades to an "expired" toast
27
+ * rather than pinning reply text in memory forever. */
28
+ export const VOICE_ONDEMAND_TTL_MS = 60 * 60 * 1000 // 1h
29
+
30
+ /** Bounded cache size; oldest entries are evicted past this. */
31
+ export const VOICE_ONDEMAND_MAX_ENTRIES = 500
32
+
33
+ export type VoiceOnDemandPayload = {
34
+ /** Speech-normalized text to synthesize (normalizeForSpeech(reply)). */
35
+ text: string
36
+ /** Optional engine-specific voice id. */
37
+ voice?: string
38
+ /** Resolved + clamped playback speed. */
39
+ speed: number
40
+ }
41
+
42
+ type StoredEntry = VoiceOnDemandPayload & { expiresAt: number }
43
+
44
+ /**
45
+ * Bounded, TTL'd LRU cache from Listen token → synthesis payload. Keyed by
46
+ * token (not message_id) because the message_id isn't known when the button
47
+ * is sent. A Map preserves insertion order, so the first key is the oldest.
48
+ */
49
+ export class VoiceOnDemandCache {
50
+ private readonly store = new Map<string, StoredEntry>()
51
+
52
+ constructor(
53
+ private readonly ttlMs: number = VOICE_ONDEMAND_TTL_MS,
54
+ private readonly maxEntries: number = VOICE_ONDEMAND_MAX_ENTRIES,
55
+ private readonly now: () => number = Date.now,
56
+ ) {}
57
+
58
+ /** Store a payload under `token`, evicting the oldest entries past the cap. */
59
+ put(token: string, payload: VoiceOnDemandPayload): void {
60
+ // Re-insert to move an existing key to the newest (most-recent) position.
61
+ this.store.delete(token)
62
+ this.store.set(token, { ...payload, expiresAt: this.now() + this.ttlMs })
63
+ while (this.store.size > this.maxEntries) {
64
+ const oldest = this.store.keys().next().value
65
+ if (oldest === undefined) break
66
+ this.store.delete(oldest)
67
+ }
68
+ }
69
+
70
+ /** Look up a payload; returns null on miss or expiry (and evicts expired). */
71
+ get(token: string): VoiceOnDemandPayload | null {
72
+ const entry = this.store.get(token)
73
+ if (entry == null) return null
74
+ if (entry.expiresAt <= this.now()) {
75
+ this.store.delete(token)
76
+ return null
77
+ }
78
+ const { text, voice, speed } = entry
79
+ return voice === undefined ? { text, speed } : { text, voice, speed }
80
+ }
81
+
82
+ /** Current entry count (test/introspection aid). */
83
+ get size(): number {
84
+ return this.store.size
85
+ }
86
+ }
87
+
88
+ /** Mint a short, unguessable Listen token (8 hex chars). */
89
+ export function mintVoiceOnDemandToken(): string {
90
+ return randomBytes(4).toString('hex')
91
+ }
92
+
93
+ /** True iff `data` is an on-demand Listen callback (must be routed
94
+ * internally, before agent: routing). */
95
+ export function isVoiceOnDemandCallback(data: string): boolean {
96
+ return data.startsWith(VOICE_ONDEMAND_CALLBACK_PREFIX)
97
+ }
98
+
99
+ /** Extract the token from a `voice:<token>` callback_data, or null. */
100
+ export function parseVoiceOnDemandToken(data: string): string | null {
101
+ if (!isVoiceOnDemandCallback(data)) return null
102
+ const token = data.slice(VOICE_ONDEMAND_CALLBACK_PREFIX.length)
103
+ return token.length > 0 ? token : null
104
+ }
105
+
106
+ /** Build the single-row Listen keyboard for a token. Effectively single-use:
107
+ * the callback handler strips this keyboard after a SUCCESSFUL synth+send, so
108
+ * a delivered voice note can't be re-tapped. On expiry / synth-failure /
109
+ * sidecar-unavailable the button is left intact so the user can retry. */
110
+ export function buildListenKeyboard(token: string): {
111
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
112
+ } {
113
+ return {
114
+ inline_keyboard: [
115
+ [{ text: '🔊 Listen', callback_data: `${VOICE_ONDEMAND_CALLBACK_PREFIX}${token}` }],
116
+ ],
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Collision gate: the Listen button may be injected ONLY when the reply
122
+ * carries no agent-authored buttons.
123
+ *
124
+ * Why: the callback dispatcher's single_use strip (keyboardIsSingleUse)
125
+ * governs the WHOLE message's keyboard, keyed off the agent-button metadata.
126
+ * The Listen button strips its own keyboard independently (on successful
127
+ * send, in the voice-on-demand callback handler) and carries no agent-button
128
+ * meta; mixing it alongside agent buttons would put two independent strip
129
+ * regimes on one message and could defeat the agent's double-fire protection.
130
+ * So if the agent supplied any button, we skip the Listen button for this
131
+ * message.
132
+ */
133
+ export function mayInjectListenButton(
134
+ rawKeyboard: unknown[][] | undefined | null,
135
+ ): boolean {
136
+ if (rawKeyboard == null) return true
137
+ return !rawKeyboard.some((row) => Array.isArray(row) && row.length > 0)
138
+ }