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.
- package/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- 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 +1524 -129
- 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/model-command.ts +173 -19
- 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/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- 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/package.json +1 -1
- 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/session-tail.ts +47 -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 +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- 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 +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -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/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -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/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- 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/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -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/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- 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/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -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 +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -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 +22 -5
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs'
|
|
21
|
+
import { dirname } from 'path'
|
|
22
|
+
|
|
23
|
+
/** Reserved callback_data prefix for on-demand Listen buttons. Handled
|
|
24
|
+
* INTERNALLY by the gateway's callback dispatcher (never routed to the agent
|
|
25
|
+
* as an inbound), so it must NOT overlap the agent: / auth: / op: families. */
|
|
26
|
+
export const VOICE_ONDEMAND_CALLBACK_PREFIX = 'voice:'
|
|
27
|
+
|
|
28
|
+
/** Cache entry TTL. A button tapped after this degrades to an "expired" toast
|
|
29
|
+
* rather than pinning reply text in memory forever.
|
|
30
|
+
*
|
|
31
|
+
* 7 days since #2763 (was 1h): eager pre-synthesis keeps the spoken file on
|
|
32
|
+
* disk for 7 days, and the acceptance bar is "tapping Listen on any message
|
|
33
|
+
* <7 days old plays instantly" — which needs the ENTRY alive that long too
|
|
34
|
+
* (the lazy fallback also reads it). Memory stays bounded by
|
|
35
|
+
* VOICE_ONDEMAND_MAX_ENTRIES; the entry is a few hundred bytes of text. */
|
|
36
|
+
export const VOICE_ONDEMAND_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
37
|
+
|
|
38
|
+
/** Bounded cache size; oldest entries are evicted past this. */
|
|
39
|
+
export const VOICE_ONDEMAND_MAX_ENTRIES = 500
|
|
40
|
+
|
|
41
|
+
export type VoiceOnDemandPayload = {
|
|
42
|
+
/** Speech-normalized text to synthesize (normalizeForSpeech(reply)). */
|
|
43
|
+
text: string
|
|
44
|
+
/** Optional engine-specific voice id. */
|
|
45
|
+
voice?: string
|
|
46
|
+
/** Resolved + clamped playback speed. */
|
|
47
|
+
speed: number
|
|
48
|
+
/** Absolute path of the eagerly pre-synthesized audio file (#2763), set by
|
|
49
|
+
* the pre-synth queue after a successful background synth. Absent on
|
|
50
|
+
* pre-feature entries, on kill-switched gateways, and until the job runs. */
|
|
51
|
+
filePath?: string
|
|
52
|
+
/** Epoch ms the entry was stored (put time) — recorded alongside filePath
|
|
53
|
+
* per #2763 so the sweep/introspection can reason about entry age. */
|
|
54
|
+
createdAt?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type StoredEntry = VoiceOnDemandPayload & { expiresAt: number }
|
|
58
|
+
|
|
59
|
+
/** On-disk shape of the persisted cache: a versioned envelope around the
|
|
60
|
+
* token→entry map. Versioned so a future schema change can be detected and
|
|
61
|
+
* discarded rather than mis-parsed. */
|
|
62
|
+
type PersistedCache = {
|
|
63
|
+
version: 1
|
|
64
|
+
entries: Record<string, StoredEntry>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Options for {@link VoiceOnDemandCache}. `persistPath`, when set, backs the
|
|
69
|
+
* cache with a JSON file so Listen tokens survive a gateway restart — a button
|
|
70
|
+
* tapped after a restart still synthesizes instead of degrading to "Voice
|
|
71
|
+
* expired". Omit it (the default) for a pure in-memory cache.
|
|
72
|
+
*/
|
|
73
|
+
export type VoiceOnDemandCacheOptions = {
|
|
74
|
+
ttlMs?: number
|
|
75
|
+
maxEntries?: number
|
|
76
|
+
now?: () => number
|
|
77
|
+
/** Absolute path to the JSON persistence file (e.g. under TELEGRAM_STATE_DIR,
|
|
78
|
+
* which lives on a volume that survives container recreation). When set, the
|
|
79
|
+
* cache loads existing (non-expired) entries on construction and rewrites the
|
|
80
|
+
* file on every mutation. */
|
|
81
|
+
persistPath?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Bounded, TTL'd LRU cache from Listen token → synthesis payload. Keyed by
|
|
86
|
+
* token (not message_id) because the message_id isn't known when the button
|
|
87
|
+
* is sent. A Map preserves insertion order, so the first key is the oldest.
|
|
88
|
+
*
|
|
89
|
+
* Optionally durable: pass `persistPath` to back the store with a JSON file so
|
|
90
|
+
* tokens survive a gateway restart. Persistence uses a synchronous
|
|
91
|
+
* write-to-temp + atomic rename on each mutation — the store is small (≤
|
|
92
|
+
* maxEntries) and writes are infrequent (one per reply that offers a Listen
|
|
93
|
+
* button), so the cost is negligible and the module stays dependency-free.
|
|
94
|
+
* Any load/write error is swallowed (logged to stderr): a broken persistence
|
|
95
|
+
* file must never take the gateway down — the cache simply degrades to the old
|
|
96
|
+
* in-memory behaviour.
|
|
97
|
+
*/
|
|
98
|
+
export class VoiceOnDemandCache {
|
|
99
|
+
private readonly store = new Map<string, StoredEntry>()
|
|
100
|
+
private readonly ttlMs: number
|
|
101
|
+
private readonly maxEntries: number
|
|
102
|
+
private readonly now: () => number
|
|
103
|
+
private readonly persistPath: string | undefined
|
|
104
|
+
|
|
105
|
+
constructor(options: VoiceOnDemandCacheOptions = {}) {
|
|
106
|
+
this.ttlMs = options.ttlMs ?? VOICE_ONDEMAND_TTL_MS
|
|
107
|
+
this.maxEntries = options.maxEntries ?? VOICE_ONDEMAND_MAX_ENTRIES
|
|
108
|
+
this.now = options.now ?? Date.now
|
|
109
|
+
this.persistPath = options.persistPath
|
|
110
|
+
if (this.persistPath !== undefined) this.load()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Store a payload under `token`, evicting the oldest entries past the cap. */
|
|
114
|
+
put(token: string, payload: VoiceOnDemandPayload): void {
|
|
115
|
+
// Re-insert to move an existing key to the newest (most-recent) position.
|
|
116
|
+
this.store.delete(token)
|
|
117
|
+
this.store.set(token, {
|
|
118
|
+
createdAt: this.now(),
|
|
119
|
+
...payload,
|
|
120
|
+
expiresAt: this.now() + this.ttlMs,
|
|
121
|
+
})
|
|
122
|
+
while (this.store.size > this.maxEntries) {
|
|
123
|
+
const oldest = this.store.keys().next().value
|
|
124
|
+
if (oldest === undefined) break
|
|
125
|
+
this.store.delete(oldest)
|
|
126
|
+
}
|
|
127
|
+
this.flush()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Look up a payload; returns null on miss or expiry (and evicts expired). */
|
|
131
|
+
get(token: string): VoiceOnDemandPayload | null {
|
|
132
|
+
const entry = this.store.get(token)
|
|
133
|
+
if (entry == null) return null
|
|
134
|
+
if (entry.expiresAt <= this.now()) {
|
|
135
|
+
this.store.delete(token)
|
|
136
|
+
this.flush()
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
const { text, voice, speed, filePath } = entry
|
|
140
|
+
// createdAt stays internal (persisted for sweep/introspection) so the
|
|
141
|
+
// returned shape only grows when a pre-synth file actually exists —
|
|
142
|
+
// pre-#2763 callers and tests see the exact old payload.
|
|
143
|
+
return {
|
|
144
|
+
text,
|
|
145
|
+
speed,
|
|
146
|
+
...(voice !== undefined ? { voice } : {}),
|
|
147
|
+
...(filePath !== undefined ? { filePath } : {}),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Record the pre-synthesized audio file path on an existing entry (#2763).
|
|
152
|
+
* No-op if the entry has expired or been evicted meanwhile (the file will
|
|
153
|
+
* simply age out via the sweep). Does NOT bump LRU position or TTL — the
|
|
154
|
+
* entry's lifetime is anchored at put time. */
|
|
155
|
+
setFilePath(token: string, filePath: string): void {
|
|
156
|
+
const entry = this.store.get(token)
|
|
157
|
+
if (entry == null || entry.expiresAt <= this.now()) return
|
|
158
|
+
entry.filePath = filePath
|
|
159
|
+
this.flush()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Drop entries whose pre-synth files the sweep deleted (#2763). Unknown
|
|
163
|
+
* tokens are ignored (file may belong to an already-evicted entry). */
|
|
164
|
+
prune(tokens: Iterable<string>): void {
|
|
165
|
+
let changed = false
|
|
166
|
+
for (const token of tokens) {
|
|
167
|
+
if (this.store.delete(token)) changed = true
|
|
168
|
+
}
|
|
169
|
+
if (changed) this.flush()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Current entry count (test/introspection aid). */
|
|
173
|
+
get size(): number {
|
|
174
|
+
return this.store.size
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Load persisted entries, dropping any already expired. Best-effort: a
|
|
178
|
+
* missing or corrupt file leaves the cache empty (fresh-start). */
|
|
179
|
+
private load(): void {
|
|
180
|
+
if (this.persistPath === undefined) return
|
|
181
|
+
let raw: string
|
|
182
|
+
try {
|
|
183
|
+
raw = readFileSync(this.persistPath, 'utf8')
|
|
184
|
+
} catch {
|
|
185
|
+
// No file yet (first boot) — nothing to load.
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(raw) as PersistedCache
|
|
190
|
+
if (parsed == null || parsed.version !== 1 || typeof parsed.entries !== 'object') return
|
|
191
|
+
const nowMs = this.now()
|
|
192
|
+
// Object.entries preserves insertion order for string keys, which is the
|
|
193
|
+
// insertion order we wrote — so LRU ordering survives the round-trip.
|
|
194
|
+
for (const [token, entry] of Object.entries(parsed.entries)) {
|
|
195
|
+
if (
|
|
196
|
+
entry == null ||
|
|
197
|
+
typeof entry.expiresAt !== 'number' ||
|
|
198
|
+
typeof entry.text !== 'string' ||
|
|
199
|
+
typeof entry.speed !== 'number'
|
|
200
|
+
) {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (entry.expiresAt <= nowMs) continue // already expired — drop
|
|
204
|
+
this.store.set(token, entry)
|
|
205
|
+
}
|
|
206
|
+
// Honour the cap in case the file predates a smaller maxEntries.
|
|
207
|
+
while (this.store.size > this.maxEntries) {
|
|
208
|
+
const oldest = this.store.keys().next().value
|
|
209
|
+
if (oldest === undefined) break
|
|
210
|
+
this.store.delete(oldest)
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
process.stderr.write(
|
|
214
|
+
`voice-ondemand: failed to parse persisted cache ${this.persistPath}: ${String(err)}\n`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Write the current store to disk atomically. No-op when not persisting. */
|
|
220
|
+
private flush(): void {
|
|
221
|
+
if (this.persistPath === undefined) return
|
|
222
|
+
const doc: PersistedCache = {
|
|
223
|
+
version: 1,
|
|
224
|
+
entries: Object.fromEntries(this.store),
|
|
225
|
+
}
|
|
226
|
+
const tmp = `${this.persistPath}.tmp`
|
|
227
|
+
try {
|
|
228
|
+
mkdirSync(dirname(this.persistPath), { recursive: true })
|
|
229
|
+
writeFileSync(tmp, JSON.stringify(doc), 'utf8')
|
|
230
|
+
renameSync(tmp, this.persistPath)
|
|
231
|
+
} catch (err) {
|
|
232
|
+
process.stderr.write(
|
|
233
|
+
`voice-ondemand: failed to persist cache ${this.persistPath}: ${String(err)}\n`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Mint a short, unguessable Listen token (8 hex chars). */
|
|
240
|
+
export function mintVoiceOnDemandToken(): string {
|
|
241
|
+
return randomBytes(4).toString('hex')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** True iff `data` is an on-demand Listen callback (must be routed
|
|
245
|
+
* internally, before agent: routing). */
|
|
246
|
+
export function isVoiceOnDemandCallback(data: string): boolean {
|
|
247
|
+
return data.startsWith(VOICE_ONDEMAND_CALLBACK_PREFIX)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Extract the token from a `voice:<token>` callback_data, or null. */
|
|
251
|
+
export function parseVoiceOnDemandToken(data: string): string | null {
|
|
252
|
+
if (!isVoiceOnDemandCallback(data)) return null
|
|
253
|
+
const token = data.slice(VOICE_ONDEMAND_CALLBACK_PREFIX.length)
|
|
254
|
+
return token.length > 0 ? token : null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Build the single-row Listen keyboard for a token. Effectively single-use:
|
|
258
|
+
* the callback handler strips this keyboard after a SUCCESSFUL synth+send, so
|
|
259
|
+
* a delivered voice note can't be re-tapped. On expiry / synth-failure /
|
|
260
|
+
* sidecar-unavailable the button is left intact so the user can retry. */
|
|
261
|
+
export function buildListenKeyboard(token: string): {
|
|
262
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
263
|
+
} {
|
|
264
|
+
return {
|
|
265
|
+
inline_keyboard: [
|
|
266
|
+
[{ text: '🔊 Listen', callback_data: `${VOICE_ONDEMAND_CALLBACK_PREFIX}${token}` }],
|
|
267
|
+
],
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Collision gate: the Listen button may be injected ONLY when the reply
|
|
273
|
+
* carries no agent-authored buttons.
|
|
274
|
+
*
|
|
275
|
+
* Why: the callback dispatcher's single_use strip (keyboardIsSingleUse)
|
|
276
|
+
* governs the WHOLE message's keyboard, keyed off the agent-button metadata.
|
|
277
|
+
* The Listen button strips its own keyboard independently (on successful
|
|
278
|
+
* send, in the voice-on-demand callback handler) and carries no agent-button
|
|
279
|
+
* meta; mixing it alongside agent buttons would put two independent strip
|
|
280
|
+
* regimes on one message and could defeat the agent's double-fire protection.
|
|
281
|
+
* So if the agent supplied any button, we skip the Listen button for this
|
|
282
|
+
* message.
|
|
283
|
+
*/
|
|
284
|
+
export function mayInjectListenButton(
|
|
285
|
+
rawKeyboard: unknown[][] | undefined | null,
|
|
286
|
+
): boolean {
|
|
287
|
+
if (rawKeyboard == null) return true
|
|
288
|
+
return !rawKeyboard.some((row) => Array.isArray(row) && row.length > 0)
|
|
289
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eager voice pre-synthesis + rolling voice-cache cleanup (issue #2763).
|
|
3
|
+
*
|
|
4
|
+
* When an outbound reply becomes Listen-eligible (a voiceOnDemandCache entry
|
|
5
|
+
* is persisted for its 🔊 Listen button), the gateway ALSO kicks an async
|
|
6
|
+
* background synthesis of the normalized text via the LOCAL sidecar (kokoro
|
|
7
|
+
* only — never the cloud engine, which costs money and stays lazy-only). The
|
|
8
|
+
* resulting OGG lands on disk under TELEGRAM_STATE_DIR/voice-cache/ and its
|
|
9
|
+
* path is recorded on the cache entry, so a Listen tap attaches the pre-made
|
|
10
|
+
* file instantly instead of synthesizing on demand.
|
|
11
|
+
*
|
|
12
|
+
* This module owns the two dependency-free primitives:
|
|
13
|
+
*
|
|
14
|
+
* - {@link PreSynthQueue} — a bounded FIFO (concurrency 1, drop-oldest past
|
|
15
|
+
* the backlog cap) so message bursts can't pile up GPU synth jobs.
|
|
16
|
+
* - {@link sweepVoiceCacheDir} — the 7-day TTL + size-budget sweep that
|
|
17
|
+
* keeps the voice-cache directory from growing unbounded.
|
|
18
|
+
*
|
|
19
|
+
* Kill switch: SWITCHROOM_DISABLE_EAGER_VOICE=1 reverts to lazy-only synth
|
|
20
|
+
* (no pre-synth jobs, no new files). The sweep still runs under the kill
|
|
21
|
+
* switch so previously written files age out.
|
|
22
|
+
*
|
|
23
|
+
* Strictly off the reply critical path: enqueue is a synchronous array push;
|
|
24
|
+
* the drain is async and never awaited by the reply flow; every job failure
|
|
25
|
+
* is swallowed (logged) — a synth failure can never affect message delivery.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readdirSync, statSync, unlinkSync, mkdirSync, writeFileSync, renameSync } from 'fs'
|
|
29
|
+
import { join } from 'path'
|
|
30
|
+
|
|
31
|
+
/** Voice files older than this are deleted by the rolling sweep. */
|
|
32
|
+
export const VOICE_FILE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
33
|
+
|
|
34
|
+
/** Size budget for the voice-cache dir; oldest files are deleted first when
|
|
35
|
+
* the directory exceeds this (defence in depth if the timer dies and the
|
|
36
|
+
* boot sweep is far away). ~500MB per the #2763 design notes. */
|
|
37
|
+
export const VOICE_CACHE_MAX_BYTES = 500 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
/** Backlog cap for the pre-synth FIFO. Past this, the OLDEST pending job is
|
|
40
|
+
* dropped (newer replies are the ones the user is most likely to tap). */
|
|
41
|
+
export const PRESYNTH_MAX_PENDING = 50
|
|
42
|
+
|
|
43
|
+
/** Sweep cadence: hourly, plus one sweep at gateway boot. */
|
|
44
|
+
export const VOICE_SWEEP_INTERVAL_MS = 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
/** True unless the operator flipped the kill switch. Same env shape as
|
|
47
|
+
* SWITCHROOM_DISABLE_TTS_NORMALIZE / SWITCHROOM_DISABLE_VOICE_SCRUB. */
|
|
48
|
+
export function eagerVoiceEnabled(): boolean {
|
|
49
|
+
const kill = process.env.SWITCHROOM_DISABLE_EAGER_VOICE
|
|
50
|
+
return !(kill === '1' || kill === 'true')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type PreSynthJob = {
|
|
54
|
+
/** Listen token — doubles as the on-disk filename stem (`<token>.ogg`). */
|
|
55
|
+
token: string
|
|
56
|
+
/** Speech-normalized text to synthesize (same payload the lazy path uses). */
|
|
57
|
+
text: string
|
|
58
|
+
voice?: string
|
|
59
|
+
speed?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type PreSynthQueueOptions = {
|
|
63
|
+
/** Runs ONE job: synthesize + persist + record. Errors are swallowed. */
|
|
64
|
+
runJob: (job: PreSynthJob) => Promise<void>
|
|
65
|
+
maxPending?: number
|
|
66
|
+
/** Injected scheduler (tests). Defaults to setTimeout(fn, 0). */
|
|
67
|
+
defer?: (fn: () => void) => void
|
|
68
|
+
log?: (line: string) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Bounded FIFO pre-synth queue, concurrency 1. Jobs are drained strictly in
|
|
73
|
+
* order; past `maxPending` queued jobs the oldest pending job is dropped
|
|
74
|
+
* (drop-oldest). Enqueue is synchronous and never throws; the drain kicks off
|
|
75
|
+
* via a deferred callback so the reply flow that enqueued is never blocked.
|
|
76
|
+
*/
|
|
77
|
+
export class PreSynthQueue {
|
|
78
|
+
private readonly pending: PreSynthJob[] = []
|
|
79
|
+
private running = false
|
|
80
|
+
private readonly runJob: (job: PreSynthJob) => Promise<void>
|
|
81
|
+
private readonly maxPending: number
|
|
82
|
+
private readonly defer: (fn: () => void) => void
|
|
83
|
+
private readonly log: (line: string) => void
|
|
84
|
+
|
|
85
|
+
constructor(options: PreSynthQueueOptions) {
|
|
86
|
+
this.runJob = options.runJob
|
|
87
|
+
this.maxPending = options.maxPending ?? PRESYNTH_MAX_PENDING
|
|
88
|
+
this.defer = options.defer ?? ((fn) => setTimeout(fn, 0))
|
|
89
|
+
this.log = options.log ?? ((line) => process.stderr.write(line + '\n'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Number of queued (not yet started) jobs — test/introspection aid. */
|
|
93
|
+
get size(): number {
|
|
94
|
+
return this.pending.length
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True while a job is in flight. */
|
|
98
|
+
get busy(): boolean {
|
|
99
|
+
return this.running
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Enqueue a job. Never throws; never blocks the caller. */
|
|
103
|
+
enqueue(job: PreSynthJob): void {
|
|
104
|
+
this.pending.push(job)
|
|
105
|
+
while (this.pending.length > this.maxPending) {
|
|
106
|
+
const dropped = this.pending.shift()
|
|
107
|
+
if (dropped != null) {
|
|
108
|
+
this.log(
|
|
109
|
+
`voice-presynth: backlog over ${this.maxPending} — dropped oldest job token=${dropped.token}`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!this.running) {
|
|
114
|
+
this.running = true
|
|
115
|
+
this.defer(() => void this.drain())
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async drain(): Promise<void> {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const job = this.pending.shift()
|
|
122
|
+
if (job == null) break
|
|
123
|
+
try {
|
|
124
|
+
await this.runJob(job)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Best-effort by contract: a failed pre-synth only means the Listen
|
|
127
|
+
// tap falls back to the lazy path. Never propagate.
|
|
128
|
+
this.log(`voice-presynth: job failed token=${job.token}: ${String(err)}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
this.running = false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Absolute path of the pre-synthesized file for a Listen token. */
|
|
136
|
+
export function voiceCacheFilePath(dir: string, token: string): string {
|
|
137
|
+
return join(dir, `${token}.ogg`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Atomically persist synthesized audio for `token` under `dir`; returns the
|
|
141
|
+
* final path. Throws on IO failure (caller — the queue runner — swallows). */
|
|
142
|
+
export function writeVoiceCacheFile(dir: string, token: string, audio: Uint8Array): string {
|
|
143
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
144
|
+
const final = voiceCacheFilePath(dir, token)
|
|
145
|
+
const tmp = `${final}.tmp`
|
|
146
|
+
writeFileSync(tmp, audio)
|
|
147
|
+
renameSync(tmp, final)
|
|
148
|
+
return final
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Minimal fs facade so the sweep is testable without a real 500MB dir. */
|
|
152
|
+
export type SweepFs = {
|
|
153
|
+
readdirSync: (dir: string) => string[]
|
|
154
|
+
statSync: (path: string) => { mtimeMs: number; size: number; isFile(): boolean }
|
|
155
|
+
unlinkSync: (path: string) => void
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type SweepOptions = {
|
|
159
|
+
dir: string
|
|
160
|
+
ttlMs?: number
|
|
161
|
+
maxBytes?: number
|
|
162
|
+
now?: () => number
|
|
163
|
+
fs?: SweepFs
|
|
164
|
+
log?: (line: string) => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type SweepResult = {
|
|
168
|
+
/** Listen tokens whose files were deleted (filename stem, `.ogg` stripped) —
|
|
169
|
+
* the caller prunes the matching voiceOnDemandCache entries. */
|
|
170
|
+
deletedTokens: string[]
|
|
171
|
+
/** Total bytes remaining in the dir after the sweep. */
|
|
172
|
+
remainingBytes: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Rolling voice-cache sweep: delete files older than `ttlMs` (7 days), then —
|
|
177
|
+
* if the directory still exceeds `maxBytes` — delete oldest-first down to
|
|
178
|
+
* budget. Crash-safe: a missing dir yields an empty result; a file that
|
|
179
|
+
* vanishes mid-sweep (concurrent delete) is tolerated; per-file errors never
|
|
180
|
+
* abort the sweep.
|
|
181
|
+
*/
|
|
182
|
+
export function sweepVoiceCacheDir(options: SweepOptions): SweepResult {
|
|
183
|
+
const ttlMs = options.ttlMs ?? VOICE_FILE_TTL_MS
|
|
184
|
+
const maxBytes = options.maxBytes ?? VOICE_CACHE_MAX_BYTES
|
|
185
|
+
const now = options.now ?? Date.now
|
|
186
|
+
const fsx: SweepFs = options.fs ?? { readdirSync, statSync, unlinkSync }
|
|
187
|
+
const log = options.log ?? ((line: string) => process.stderr.write(line + '\n'))
|
|
188
|
+
|
|
189
|
+
let names: string[]
|
|
190
|
+
try {
|
|
191
|
+
names = fsx.readdirSync(options.dir)
|
|
192
|
+
} catch {
|
|
193
|
+
return { deletedTokens: [], remainingBytes: 0 } // no dir yet — nothing to sweep
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type Entry = { name: string; path: string; mtimeMs: number; size: number }
|
|
197
|
+
const files: Entry[] = []
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
const path = join(options.dir, name)
|
|
200
|
+
try {
|
|
201
|
+
const st = fsx.statSync(path)
|
|
202
|
+
if (!st.isFile()) continue
|
|
203
|
+
files.push({ name, path, mtimeMs: st.mtimeMs, size: st.size })
|
|
204
|
+
} catch {
|
|
205
|
+
// vanished mid-sweep / unreadable — skip, never abort
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const deletedTokens: string[] = []
|
|
210
|
+
const cutoff = now() - ttlMs
|
|
211
|
+
const remove = (f: Entry, reason: string): boolean => {
|
|
212
|
+
try {
|
|
213
|
+
fsx.unlinkSync(f.path)
|
|
214
|
+
} catch {
|
|
215
|
+
// Already gone (concurrent delete) — treat as removed either way; a
|
|
216
|
+
// truly stuck file just gets retried next sweep.
|
|
217
|
+
}
|
|
218
|
+
deletedTokens.push(f.name.replace(/\.(ogg|ogg\.tmp)$/, ''))
|
|
219
|
+
log(`voice-presynth: sweep removed ${f.name} (${reason})`)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Pass 1 — TTL: anything older than 7 days goes (including orphaned .tmp).
|
|
224
|
+
let kept: Entry[] = []
|
|
225
|
+
for (const f of files) {
|
|
226
|
+
if (f.mtimeMs <= cutoff) remove(f, 'ttl')
|
|
227
|
+
else kept.push(f)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Pass 2 — size budget: oldest-first down to maxBytes.
|
|
231
|
+
let total = kept.reduce((sum, f) => sum + f.size, 0)
|
|
232
|
+
if (total > maxBytes) {
|
|
233
|
+
kept.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
234
|
+
while (total > maxBytes && kept.length > 0) {
|
|
235
|
+
const oldest = kept.shift()!
|
|
236
|
+
remove(oldest, 'size-budget')
|
|
237
|
+
total -= oldest.size
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { deletedTokens, remainingBytes: total }
|
|
242
|
+
}
|