switchroom 0.16.38 โ†’ 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 (61) hide show
  1. package/dist/agent-scheduler/index.js +88 -82
  2. package/dist/auth-broker/index.js +87 -81
  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 +89 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1621 -737
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +271 -239
  10. package/dist/vault/approvals/kernel-server.js +90 -84
  11. package/dist/vault/broker/server.js +91 -85
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +2 -2
  15. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -1
  18. package/telegram-plugin/card-format.ts +7 -1
  19. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  20. package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
  21. package/telegram-plugin/dist/server.js +180 -162
  22. package/telegram-plugin/format.ts +305 -31
  23. package/telegram-plugin/gateway/gateway.ts +262 -63
  24. package/telegram-plugin/gateway/model-command.ts +173 -19
  25. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  26. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  27. package/telegram-plugin/package.json +1 -1
  28. package/telegram-plugin/session-tail.ts +47 -1
  29. package/telegram-plugin/stream-reply-handler.ts +19 -1
  30. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  31. package/telegram-plugin/tests/card-format.test.ts +28 -0
  32. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  33. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  34. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  35. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  36. package/telegram-plugin/tests/model-command.test.ts +213 -47
  37. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  38. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  40. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  41. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  42. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  43. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  44. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  45. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  46. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  47. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  48. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  49. package/telegram-plugin/text-voice-scrub.ts +68 -18
  50. package/telegram-plugin/tool-activity-summary.ts +20 -108
  51. package/telegram-plugin/tts-normalize.ts +377 -0
  52. package/telegram-plugin/uat/driver.ts +472 -22
  53. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  54. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  56. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  57. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  58. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  59. package/telegram-plugin/voice-ondemand.ts +161 -10
  60. package/telegram-plugin/voice-presynth.ts +242 -0
  61. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -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
+ }
@@ -429,7 +429,15 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
429
429
  const liveElapsed = h.dispatchAtMs != null ? now - h.dispatchAtMs : h.lastView.elapsedMs
430
430
  const liveSuffix = ' ยท ' + formatFeedElapsed(liveElapsed)
431
431
  // Re-render THROUGH the chain + doUpdate path โ€” never editMessageText directly.
432
- const view = h.lastView
432
+ //
433
+ // CLOCK-ANCHOR PARITY: refresh the view's elapsedMs to the SAME
434
+ // `liveElapsed` the step suffix shows. The header renders
435
+ // `view.elapsedMs`; passing the stale lastView froze the header at the
436
+ // last watcher event while the `ยท Ns` suffix kept ticking, so the
437
+ // current step's timer could read MORE than the card's master elapsed
438
+ // (Ken-observed defect). Both numbers now derive from one anchor
439
+ // (dispatchAtMs) at one `now`, so header elapsed >= step suffix always.
440
+ const view = { ...h.lastView, elapsedMs: Math.max(h.lastView.elapsedMs, liveElapsed) }
433
441
  h.chain = h.chain
434
442
  .then(() => doUpdate(h, view, liveSuffix))
435
443
  .catch((err) => {