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.
- package/dist/agent-scheduler/index.js +88 -82
- package/dist/auth-broker/index.js +87 -81
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +89 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1621 -737
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +271 -239
- package/dist/vault/approvals/kernel-server.js +90 -84
- package/dist/vault/broker/server.js +91 -85
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
- package/telegram-plugin/dist/server.js +180 -162
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +262 -63
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -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/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- 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-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- 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
|
-
|
|
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) => {
|