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.
- package/dist/agent-scheduler/index.js +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- 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}**` :
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 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
|
+
}
|