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
|
@@ -62,11 +62,19 @@ import {
|
|
|
62
62
|
buildListenKeyboard,
|
|
63
63
|
mayInjectListenButton,
|
|
64
64
|
} from '../voice-ondemand.js'
|
|
65
|
+
import {
|
|
66
|
+
PreSynthQueue,
|
|
67
|
+
sweepVoiceCacheDir,
|
|
68
|
+
writeVoiceCacheFile,
|
|
69
|
+
eagerVoiceEnabled,
|
|
70
|
+
VOICE_SWEEP_INTERVAL_MS,
|
|
71
|
+
} from '../voice-presynth.js'
|
|
65
72
|
|
|
66
73
|
/** Fleet default TTS speed when voice_out is enabled but speed is unset —
|
|
67
74
|
* slightly brisker than neutral (Ken's chosen default). */
|
|
68
75
|
const VOICE_OUT_DEFAULT_SPEED = 1.1
|
|
69
76
|
import { normalizeForSpeech } from '../voice-normalize-text.js'
|
|
77
|
+
import { normalizeForTts } from '../tts-normalize.js'
|
|
70
78
|
import { synthesizeViaOpenAi } from '../voice-synthesize.js'
|
|
71
79
|
import {
|
|
72
80
|
createTelegraphAccount,
|
|
@@ -225,7 +233,7 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
225
233
|
const SILENT_END_FALLBACK_TEXT =
|
|
226
234
|
'⚠️ The agent finished working but didn’t send a reply — your last ' +
|
|
227
235
|
'message may not have been answered. Please try asking again.'
|
|
228
|
-
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
236
|
+
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
229
237
|
import { richMessage } from '../rich-send.js'
|
|
230
238
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
231
239
|
import {
|
|
@@ -317,6 +325,8 @@ import {
|
|
|
317
325
|
MODEL_CALLBACK_PREFIX,
|
|
318
326
|
MODEL_CALLBACK_HEADER,
|
|
319
327
|
MODEL_CALLBACK_SR,
|
|
328
|
+
MODEL_CALLBACK_PAGE_EXTERNAL,
|
|
329
|
+
MODEL_CALLBACK_PAGE_MAIN,
|
|
320
330
|
srFriendlyLabel,
|
|
321
331
|
type ModelMenuDeps,
|
|
322
332
|
type ModelCommandDeps,
|
|
@@ -8623,7 +8633,79 @@ const VOICE_OUT_HARD_CHUNK_CAP = 4096
|
|
|
8623
8633
|
* Bounded LRU with a TTL so a stale button (tapped an hour later) degrades to
|
|
8624
8634
|
* a graceful "expired" toast rather than pinning reply text in memory forever.
|
|
8625
8635
|
*/
|
|
8626
|
-
|
|
8636
|
+
// Persist the Listen-token cache under STATE_DIR (= /state/agent/telegram in
|
|
8637
|
+
// prod, a volume that survives container recreation). Without this the Map was
|
|
8638
|
+
// wiped on every gateway restart, so a 🔊 Listen button tapped on a message
|
|
8639
|
+
// sent before the restart degraded to "Voice expired". The same TTL + LRU cap
|
|
8640
|
+
// still applies; expired entries are dropped on load.
|
|
8641
|
+
const voiceOnDemandCache = new VoiceOnDemandCache({
|
|
8642
|
+
persistPath: join(STATE_DIR, 'voice-ondemand.json'),
|
|
8643
|
+
})
|
|
8644
|
+
|
|
8645
|
+
// ─── Eager voice pre-synthesis (#2763) ─────────────────────────────────────
|
|
8646
|
+
// Whenever a reply becomes Listen-eligible (a voiceOnDemandCache entry is
|
|
8647
|
+
// persisted alongside the Listen button), ALSO synthesize the audio in the
|
|
8648
|
+
// background via the LOCAL sidecar and park it on disk, so the 🔊 Listen tap
|
|
8649
|
+
// attaches the pre-made file instantly instead of waiting on the GPU. Local
|
|
8650
|
+
// engine only (kokoro — zero token/dollar cost); the openai cloud engine
|
|
8651
|
+
// never pre-synthesizes. Strictly off the reply critical path: jobs drain
|
|
8652
|
+
// through a bounded FIFO (concurrency 1, drop-oldest past the cap) and every
|
|
8653
|
+
// failure is swallowed — the tap just falls back to the lazy synth path.
|
|
8654
|
+
// Kill switch: SWITCHROOM_DISABLE_EAGER_VOICE=1 (the sweep still runs so old
|
|
8655
|
+
// files age out).
|
|
8656
|
+
const VOICE_CACHE_DIR = join(STATE_DIR, 'voice-cache')
|
|
8657
|
+
|
|
8658
|
+
const voicePreSynthQueue = new PreSynthQueue({
|
|
8659
|
+
runJob: async (job) => {
|
|
8660
|
+
const sidecarToken = await materializeSidecarToken()
|
|
8661
|
+
if (!sidecarToken) return // sidecar unavailable — lazy path will retry on tap
|
|
8662
|
+
const result = await synthesizeViaSidecar({
|
|
8663
|
+
token: sidecarToken,
|
|
8664
|
+
// Same deterministic normalization the lazy Listen path applies —
|
|
8665
|
+
// job.text is normalizeForSpeech(reply); normalizeForTts is the #2760
|
|
8666
|
+
// L1 pass on top.
|
|
8667
|
+
text: normalizeForTts(job.text),
|
|
8668
|
+
voice: job.voice,
|
|
8669
|
+
speed: job.speed,
|
|
8670
|
+
})
|
|
8671
|
+
if (!result.ok) {
|
|
8672
|
+
process.stderr.write(
|
|
8673
|
+
`telegram gateway: voice-presynth: sidecar synthesis failed token=${job.token} reason=${result.reason}\n`,
|
|
8674
|
+
)
|
|
8675
|
+
return
|
|
8676
|
+
}
|
|
8677
|
+
const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
|
|
8678
|
+
voiceOnDemandCache.setFilePath(job.token, filePath)
|
|
8679
|
+
process.stderr.write(
|
|
8680
|
+
`telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
|
|
8681
|
+
`(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
|
|
8682
|
+
)
|
|
8683
|
+
},
|
|
8684
|
+
})
|
|
8685
|
+
|
|
8686
|
+
/** Rolling 7-day TTL + 500MB size-budget sweep over VOICE_CACHE_DIR. Runs at
|
|
8687
|
+
* boot and hourly; ALWAYS runs (even under the eager-voice kill switch) so
|
|
8688
|
+
* previously written files age out. Crash-safe: missing dir/files tolerated. */
|
|
8689
|
+
function sweepVoiceCache(): void {
|
|
8690
|
+
try {
|
|
8691
|
+
const result = sweepVoiceCacheDir({ dir: VOICE_CACHE_DIR })
|
|
8692
|
+
if (result.deletedTokens.length > 0) {
|
|
8693
|
+
voiceOnDemandCache.prune(result.deletedTokens)
|
|
8694
|
+
process.stderr.write(
|
|
8695
|
+
`telegram gateway: voice-presynth: sweep removed ${result.deletedTokens.length} file(s), ` +
|
|
8696
|
+
`${result.remainingBytes} bytes remain\n`,
|
|
8697
|
+
)
|
|
8698
|
+
}
|
|
8699
|
+
} catch (err) {
|
|
8700
|
+
// Defence in depth — sweepVoiceCacheDir is already per-file crash-safe.
|
|
8701
|
+
process.stderr.write(`telegram gateway: voice-presynth: sweep failed: ${String(err)}\n`)
|
|
8702
|
+
}
|
|
8703
|
+
}
|
|
8704
|
+
|
|
8705
|
+
if (!STATIC) {
|
|
8706
|
+
sweepVoiceCache() // boot sweep — covers a died timer / long downtime
|
|
8707
|
+
setInterval(sweepVoiceCache, VOICE_SWEEP_INTERVAL_MS).unref()
|
|
8708
|
+
}
|
|
8627
8709
|
|
|
8628
8710
|
type VoiceOutAccess = NonNullable<Access['voice_out']>
|
|
8629
8711
|
|
|
@@ -8735,12 +8817,16 @@ async function synthesizeVoiceOut(plan: {
|
|
|
8735
8817
|
ttsText: string
|
|
8736
8818
|
}): Promise<Uint8Array | null> {
|
|
8737
8819
|
try {
|
|
8820
|
+
// #2760 Phase 1: deterministic L1 TTS normalization, applied at the
|
|
8821
|
+
// single choke point where every engine's request body is built.
|
|
8822
|
+
// On by default; kill switch: SWITCHROOM_DISABLE_TTS_NORMALIZE=1.
|
|
8823
|
+
const ttsText = normalizeForTts(plan.ttsText)
|
|
8738
8824
|
if (plan.engine === 'kokoro') {
|
|
8739
8825
|
const token = await materializeSidecarToken()
|
|
8740
8826
|
if (!token) return null // materializeSidecarToken already logged why.
|
|
8741
8827
|
const result = await synthesizeViaSidecar({
|
|
8742
8828
|
token,
|
|
8743
|
-
text:
|
|
8829
|
+
text: ttsText,
|
|
8744
8830
|
voice: plan.voice,
|
|
8745
8831
|
speed: plan.speed,
|
|
8746
8832
|
})
|
|
@@ -8764,7 +8850,7 @@ async function synthesizeVoiceOut(plan: {
|
|
|
8764
8850
|
if (!apiKey) return null // materializeVoiceKey already logged why.
|
|
8765
8851
|
const result = await synthesizeViaOpenAi({
|
|
8766
8852
|
apiKey,
|
|
8767
|
-
text:
|
|
8853
|
+
text: ttsText,
|
|
8768
8854
|
voice: plan.voice,
|
|
8769
8855
|
})
|
|
8770
8856
|
if (!result.ok) {
|
|
@@ -8834,8 +8920,16 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
8834
8920
|
// Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
|
|
8835
8921
|
// the stderr preview below, the dedup key, the send, and the history
|
|
8836
8922
|
// record. Mutates `text` so every downstream consumer sees the masked
|
|
8837
|
-
// value, exactly like the voice scrub that follows.
|
|
8923
|
+
// value, exactly like the voice scrub that follows. Runs BEFORE the
|
|
8924
|
+
// punctuation/bold normalizers so a secret containing an em-dash or `**`
|
|
8925
|
+
// is matched literally by the redactor before any mutation.
|
|
8838
8926
|
text = redactOutboundText(text, 'reply')
|
|
8927
|
+
// Fleet-wide consistent formatting: normalize dashes/bullets and trip the
|
|
8928
|
+
// over-bold guard deterministically, on code-masked text (same contract on
|
|
8929
|
+
// reply / edit / stream paths). Runs before scrubVoice below so dashes get
|
|
8930
|
+
// the comma treatment on every path; scrubVoice's dash telemetry
|
|
8931
|
+
// (voice_scrub_applied) drops to ~zero here as a result — deliberate.
|
|
8932
|
+
text = stripExcessBold(normalizePunctuation(text))
|
|
8839
8933
|
// Voice scrub (#1683): replace em / en dashes with commas / periods.
|
|
8840
8934
|
// Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
|
|
8841
8935
|
// raw markdown text (the scrubber does its own code-region parking so
|
|
@@ -9258,6 +9352,22 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
9258
9352
|
// replayed. The gate above guarantees this is the ONLY button on the
|
|
9259
9353
|
// message, so keeping it is safe (no agent buttons to protect).
|
|
9260
9354
|
replyMarkup = buildListenKeyboard(token)
|
|
9355
|
+
// #2763 eager pre-synthesis: kick a background synth of the same
|
|
9356
|
+
// payload so the Listen tap attaches the pre-made file instantly.
|
|
9357
|
+
// Local-engine only (useOnDemandButton already gates engine==='kokoro'
|
|
9358
|
+
// — the cloud engine never eager-synthesizes). enqueue() is a
|
|
9359
|
+
// synchronous array push; the drain is deferred + async and never
|
|
9360
|
+
// awaited here, so the text sends below are never delayed and a synth
|
|
9361
|
+
// failure can never affect message delivery (the tap just falls back
|
|
9362
|
+
// to the lazy path).
|
|
9363
|
+
if (eagerVoiceEnabled()) {
|
|
9364
|
+
voicePreSynthQueue.enqueue({
|
|
9365
|
+
token,
|
|
9366
|
+
text: voiceOutPlan.ttsChunks[0]!,
|
|
9367
|
+
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
9368
|
+
speed: voiceOutPlan.speed,
|
|
9369
|
+
})
|
|
9370
|
+
}
|
|
9261
9371
|
}
|
|
9262
9372
|
}
|
|
9263
9373
|
|
|
@@ -10094,6 +10204,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
10094
10204
|
// controller, dedup record, history record) see the scrubbed
|
|
10095
10205
|
// version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
10096
10206
|
{
|
|
10207
|
+
// Cross-path consistency (#2755): normalizePunctuation runs BEFORE
|
|
10208
|
+
// scrubVoice here, matching the reply/edit paths, so a spaced em-dash
|
|
10209
|
+
// gets the same comma treatment on every outbound path (scrubVoice
|
|
10210
|
+
// would otherwise see the raw dash first on this path only and apply
|
|
10211
|
+
// its period substitution). handleStreamReply's own deps run
|
|
10212
|
+
// normalizePunctuation again downstream — it is idempotent, so the
|
|
10213
|
+
// second pass is a no-op. Side effect: scrubVoice's dash telemetry
|
|
10214
|
+
// (voice_scrub_applied) drops to ~zero on this path since the dashes
|
|
10215
|
+
// are consumed here first — deliberate.
|
|
10216
|
+
args.text = normalizePunctuation(args.text as string)
|
|
10097
10217
|
const scrub = scrubVoice(args.text as string)
|
|
10098
10218
|
if (scrub.replaced > 0) {
|
|
10099
10219
|
args.text = scrub.scrubbed
|
|
@@ -10251,6 +10371,8 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
10251
10371
|
retry: robustApiCall,
|
|
10252
10372
|
repairEscapedWhitespace,
|
|
10253
10373
|
normalizeParagraphBreaks,
|
|
10374
|
+
normalizePunctuation,
|
|
10375
|
+
stripExcessBold,
|
|
10254
10376
|
addParagraphSpacers,
|
|
10255
10377
|
assertAllowedChat,
|
|
10256
10378
|
resolveThreadId,
|
|
@@ -11319,7 +11441,12 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
11319
11441
|
if (scopeRaw !== 'read' && scopeRaw !== 'write') {
|
|
11320
11442
|
throw new Error('vault_request_access: scope must be "read" or "write"')
|
|
11321
11443
|
}
|
|
11322
|
-
|
|
11444
|
+
// Accept `why` as an alias for `reason`: the sibling tool
|
|
11445
|
+
// vault_request_save uses `why`, and agents cross-contaminate the two
|
|
11446
|
+
// schemas — without the alias the rationale silently drops off the
|
|
11447
|
+
// approval card and the operator sees "why: not provided".
|
|
11448
|
+
const reason =
|
|
11449
|
+
typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
|
|
11323
11450
|
// Duration: accept a "30d" / "12h" string from the agent OR default
|
|
11324
11451
|
// to 30 days. Cap at 90 days — beyond that the operator should use
|
|
11325
11452
|
// the host CLI and pick the lifetime explicitly. Refuse "never"
|
|
@@ -11480,10 +11607,14 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
11480
11607
|
// paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
|
|
11481
11608
|
// skips paragraph normalization — it must edit byte-for-byte as given.
|
|
11482
11609
|
let editRawText = repairEscapedWhitespace(args.text as string)
|
|
11483
|
-
if (!editLiteralText) editRawText =
|
|
11610
|
+
if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
|
|
11484
11611
|
// Outbound secret scrub (#2044): an edit must not re-introduce a raw
|
|
11485
11612
|
// secret into a live bubble or the history row. Mask before scrub/send.
|
|
11486
11613
|
editRawText = redactOutboundText(editRawText, 'edit_message')
|
|
11614
|
+
// Fleet-wide consistent formatting (same order as the reply path: redact
|
|
11615
|
+
// first so secrets are matched literally, then normalize, then spacers on
|
|
11616
|
+
// the rich path only).
|
|
11617
|
+
if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
|
|
11487
11618
|
// Voice scrub (#1683): same em-dash scrub as the reply path. Edits
|
|
11488
11619
|
// are how silent-anchor and progress-update mutate already-sent
|
|
11489
11620
|
// bubbles, so without this an edit can re-introduce dashes the
|
|
@@ -22076,7 +22207,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22076
22207
|
await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
|
|
22077
22208
|
return
|
|
22078
22209
|
}
|
|
22079
|
-
|
|
22210
|
+
// Page navigation (External ▸ / ◂ Back) just re-renders the menu with the
|
|
22211
|
+
// other keyboard page — it never drives the picker, so ack "Loading…"
|
|
22212
|
+
// rather than the switch-oriented "Switching…".
|
|
22213
|
+
const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
|
|
22214
|
+
await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
|
|
22080
22215
|
// sr-* inject waits for claude to respond (can take 10-30s). Edit the
|
|
22081
22216
|
// menu immediately to show a "working on it" state so the operator isn't
|
|
22082
22217
|
// left looking at a stale menu with no feedback. The final edit (✅/❌)
|
|
@@ -22453,7 +22588,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22453
22588
|
.catch(() => {})
|
|
22454
22589
|
return
|
|
22455
22590
|
}
|
|
22456
|
-
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
22457
22591
|
const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
|
|
22458
22592
|
const cbMessageId = ctx.callbackQuery?.message?.message_id
|
|
22459
22593
|
const cbThreadId = (() => {
|
|
@@ -22464,31 +22598,57 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22464
22598
|
}
|
|
22465
22599
|
return undefined
|
|
22466
22600
|
})()
|
|
22467
|
-
//
|
|
22468
|
-
//
|
|
22469
|
-
//
|
|
22470
|
-
|
|
22471
|
-
|
|
22472
|
-
|
|
22473
|
-
|
|
22474
|
-
|
|
22475
|
-
|
|
22601
|
+
// #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
|
|
22602
|
+
// by the pre-synth queue at reply time). If it's on disk, attach it
|
|
22603
|
+
// immediately — no GPU wait. Missing/unreadable file (expired + swept,
|
|
22604
|
+
// crash, pre-feature entry, kill-switched gateway) falls back
|
|
22605
|
+
// transparently to the lazy synth path below.
|
|
22606
|
+
let audio: Uint8Array | null = null
|
|
22607
|
+
if (entry.filePath != null) {
|
|
22608
|
+
try {
|
|
22609
|
+
audio = readFileSync(entry.filePath)
|
|
22610
|
+
} catch {
|
|
22611
|
+
audio = null // swept/missing — lazy fallback
|
|
22612
|
+
}
|
|
22476
22613
|
}
|
|
22477
|
-
|
|
22478
|
-
|
|
22479
|
-
|
|
22480
|
-
|
|
22481
|
-
|
|
22482
|
-
|
|
22483
|
-
|
|
22484
|
-
|
|
22485
|
-
|
|
22486
|
-
|
|
22487
|
-
|
|
22488
|
-
|
|
22489
|
-
|
|
22490
|
-
|
|
22614
|
+
if (audio != null) {
|
|
22615
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
22616
|
+
} else {
|
|
22617
|
+
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
22618
|
+
// Local sidecar (kokoro) synthesis — same helper the immediate voice-out
|
|
22619
|
+
// path uses. On-demand is a local-engine feature; the cache is only
|
|
22620
|
+
// populated when resolveVoiceOutPlan gated the local verdict.
|
|
22621
|
+
const sidecarToken = await materializeSidecarToken()
|
|
22622
|
+
if (!sidecarToken) {
|
|
22623
|
+
await ctx
|
|
22624
|
+
.answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
|
|
22625
|
+
.catch(() => {})
|
|
22626
|
+
return
|
|
22627
|
+
}
|
|
22628
|
+
const result = await synthesizeViaSidecar({
|
|
22629
|
+
token: sidecarToken,
|
|
22630
|
+
// #2760 Phase 1: same deterministic normalization as the immediate
|
|
22631
|
+
// voice-out path — the Listen lazy path builds its own /tts body from
|
|
22632
|
+
// the persisted cache, so it must normalize independently (cache
|
|
22633
|
+
// entries may predate the flag flip).
|
|
22634
|
+
text: normalizeForTts(entry.text),
|
|
22635
|
+
voice: entry.voice,
|
|
22636
|
+
speed: entry.speed,
|
|
22637
|
+
})
|
|
22638
|
+
if (!result.ok) {
|
|
22639
|
+
process.stderr.write(
|
|
22640
|
+
`telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
|
|
22641
|
+
)
|
|
22642
|
+
await ctx
|
|
22643
|
+
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
22644
|
+
.catch(() => {})
|
|
22645
|
+
return
|
|
22646
|
+
}
|
|
22647
|
+
audio = result.audio
|
|
22491
22648
|
}
|
|
22649
|
+
// Rebind as const so the closure below narrows to non-null (TS doesn't
|
|
22650
|
+
// narrow a captured `let` inside an arrow function).
|
|
22651
|
+
const audioOut: Uint8Array = audio
|
|
22492
22652
|
try {
|
|
22493
22653
|
// Native voice note (NOT a document), quote-replying the button's
|
|
22494
22654
|
// message.
|
|
@@ -22497,7 +22657,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22497
22657
|
bot.api.sendVoice(
|
|
22498
22658
|
cbChatId,
|
|
22499
22659
|
// allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
|
|
22500
|
-
new InputFile(Buffer.from(
|
|
22660
|
+
new InputFile(Buffer.from(audioOut)),
|
|
22501
22661
|
{
|
|
22502
22662
|
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
22503
22663
|
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
@@ -22759,9 +22919,48 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22759
22919
|
action: naturalAction(details.tool_name, details.input_preview),
|
|
22760
22920
|
})
|
|
22761
22921
|
|
|
22762
|
-
// (3)
|
|
22763
|
-
//
|
|
22764
|
-
//
|
|
22922
|
+
// (3) Ack the tap IMMEDIATELY with an honest interim status. The
|
|
22923
|
+
// durable persist below goes through hostd `config_propose_edit`
|
|
22924
|
+
// (validate→approve→apply→reconcile), which can take 5-10 minutes on
|
|
22925
|
+
// a busy host — the old code awaited only 60s and then reported
|
|
22926
|
+
// "did NOT save" even when the edit later landed. Instead: ack now,
|
|
22927
|
+
// run the persistence in a background continuation with a 12-min
|
|
22928
|
+
// dispatch window, then edit the card with the REAL outcome.
|
|
22929
|
+
// HTML-escape baseText — `ctx.callbackQuery.message.text` returns
|
|
22930
|
+
// entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
|
|
22931
|
+
// expanded permission card's `description` or `input_preview`
|
|
22932
|
+
// (claude-generated tool descriptions routinely carry these)
|
|
22933
|
+
// would break the HTML re-parse. PR #1158 review caught the same
|
|
22934
|
+
// issue on the operator-event card.
|
|
22935
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
22936
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
22937
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
22938
|
+
: ''
|
|
22939
|
+
const interimLabel =
|
|
22940
|
+
`⏳ **Rule applied for this session** — ${escapeHtmlForTg(agentName)} can ${escapeHtmlForTg(grantPhrase)} ` +
|
|
22941
|
+
`without asking for now; saving durably in background…`
|
|
22942
|
+
// #1150 audit: route through finalizeCallback so the keyboard
|
|
22943
|
+
// strips alongside the status-line edit. The in-flight verdict was
|
|
22944
|
+
// ALREADY dispatched above (independently of this host round-trip)
|
|
22945
|
+
// so the turn never blocked — finalizeCallback here only edits the
|
|
22946
|
+
// card; no synthInbound (would double-fire the verdict).
|
|
22947
|
+
await finalizeCallback(ctx, {
|
|
22948
|
+
ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
|
|
22949
|
+
newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
|
|
22950
|
+
})
|
|
22951
|
+
|
|
22952
|
+
// Background continuation: decide the persistence path.
|
|
22953
|
+
// tryHostdDispatch returns "not-configured" when host_control is
|
|
22954
|
+
// disabled or the per-agent socket is absent → legacy fallback.
|
|
22955
|
+
// The whole body is wrapped in try/catch: a throw inside a void
|
|
22956
|
+
// IIFE (scheduleGrantRestart's sync fs writes, richMessage, etc.)
|
|
22957
|
+
// would surface as an unhandledRejection — which this gateway
|
|
22958
|
+
// routes through shutdown(). Pre-background, those throws were
|
|
22959
|
+
// contained by grammY's handler wrapper; keep that containment.
|
|
22960
|
+
// Known accepted gap: if the gateway restarts mid-persist, the
|
|
22961
|
+
// interim "saving durably in background…" card stays stale.
|
|
22962
|
+
void (async () => {
|
|
22963
|
+
try {
|
|
22765
22964
|
let durable = false
|
|
22766
22965
|
let legacy = false
|
|
22767
22966
|
let failReason = ''
|
|
@@ -22803,8 +23002,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22803
23002
|
},
|
|
22804
23003
|
}
|
|
22805
23004
|
// config_propose_edit blocks on validate→approve→apply→reconcile,
|
|
22806
|
-
//
|
|
22807
|
-
|
|
23005
|
+
// which can legitimately take 5-10 minutes — allow 12 min. Safe
|
|
23006
|
+
// to wait this long because we're in a background continuation:
|
|
23007
|
+
// the tap was already acked and the verdict already dispatched.
|
|
23008
|
+
const resp = await tryHostdDispatch(agentName, req, 720_000)
|
|
22808
23009
|
if (resp === 'not-configured') {
|
|
22809
23010
|
warnLegacySpawnIfHostdDisabled('always-allow')
|
|
22810
23011
|
legacy = true
|
|
@@ -22888,23 +23089,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22888
23089
|
`always-allow: ${grantPhrase}`,
|
|
22889
23090
|
) !== "disabled"
|
|
22890
23091
|
const liveSuffix = restartScheduled ? " — applying now (restarting to take effect)" : ""
|
|
22891
|
-
const ackText = ok
|
|
22892
|
-
? (legacyNote
|
|
22893
|
-
? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
|
|
22894
|
-
: `✅ Saved. ${agentName} can now ${grantPhrase} without asking.${liveSuffix}`)
|
|
22895
|
-
: (editLockHint
|
|
22896
|
-
? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
|
|
22897
|
-
: `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
|
|
22898
|
-
// HTML-escape baseText — `ctx.callbackQuery.message.text` returns
|
|
22899
|
-
// entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
|
|
22900
|
-
// expanded permission card's `description` or `input_preview`
|
|
22901
|
-
// (claude-generated tool descriptions routinely carry these)
|
|
22902
|
-
// would break the HTML re-parse. PR #1158 review caught the same
|
|
22903
|
-
// issue on the operator-event card.
|
|
22904
|
-
const sourceMsg = ctx.callbackQuery?.message
|
|
22905
|
-
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
22906
|
-
? escapeHtmlForTg(sourceMsg.text)
|
|
22907
|
-
: ''
|
|
22908
23092
|
const editLabel = ok
|
|
22909
23093
|
? (legacyNote
|
|
22910
23094
|
? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
|
|
@@ -22914,15 +23098,30 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22914
23098
|
: (editLockHint
|
|
22915
23099
|
? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
|
|
22916
23100
|
: `⚠️ **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
|
|
22917
|
-
//
|
|
22918
|
-
//
|
|
22919
|
-
//
|
|
22920
|
-
//
|
|
22921
|
-
|
|
22922
|
-
|
|
22923
|
-
|
|
22924
|
-
|
|
23101
|
+
// Edit the card with the REAL outcome. The keyboard was already
|
|
23102
|
+
// stripped by the interim finalizeCallback ack above, and the
|
|
23103
|
+
// callback query was already answered — this is a plain edit
|
|
23104
|
+
// replacing the "saving durably in background…" interim line.
|
|
23105
|
+
await ctx.editMessageText(
|
|
23106
|
+
richMessage(baseText ? `${baseText}\n\n${editLabel}` : editLabel),
|
|
23107
|
+
{
|
|
23108
|
+
reply_markup: { inline_keyboard: [] },
|
|
23109
|
+
link_preview_options: { is_disabled: true },
|
|
23110
|
+
},
|
|
23111
|
+
).catch((err: unknown) => {
|
|
23112
|
+
process.stderr.write(
|
|
23113
|
+
`telegram gateway: always-allow outcome card edit failed: ${(err as Error).message} (request_id=${request_id})\n`,
|
|
23114
|
+
)
|
|
22925
23115
|
})
|
|
23116
|
+
} catch (err) {
|
|
23117
|
+
// Never let the background persist take the gateway down — an
|
|
23118
|
+
// unhandledRejection here becomes shutdown(). Log and move on;
|
|
23119
|
+
// the in-session rule was already applied at tap time.
|
|
23120
|
+
process.stderr.write(
|
|
23121
|
+
`telegram gateway: always-allow background persist threw: ${(err as Error).message} (request_id=${request_id})\n`,
|
|
23122
|
+
)
|
|
23123
|
+
}
|
|
23124
|
+
})()
|
|
22926
23125
|
return
|
|
22927
23126
|
}
|
|
22928
23127
|
|