switchroom 0.16.29 → 0.16.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -0,0 +1,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
+ }