switchroom 0.16.38 → 0.16.47
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 +8 -2
- package/dist/auth-broker/index.js +7 -1
- package/dist/cli/notion-write-pretool.mjs +7 -1
- package/dist/cli/switchroom.js +1259 -375
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +116 -84
- package/dist/vault/approvals/kernel-server.js +8 -2
- package/dist/vault/broker/server.js +8 -2
- 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 +20 -2
- package/telegram-plugin/dist/gateway/gateway.js +2197 -964
- package/telegram-plugin/dist/server.js +20 -2
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +310 -70
- 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/vault-request-access-unlock-resume.test.ts +46 -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
|
|
@@ -20029,20 +20160,61 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
20029
20160
|
// cards open at once.
|
|
20030
20161
|
const joiningBatch = items.length > 1
|
|
20031
20162
|
await ctx.answerCallbackQuery({ text: joiningBatch ? `🔐 Queued — one passphrase covers ${items.length} cards` : '🔐 Send your passphrase…' }).catch(() => {})
|
|
20163
|
+
|
|
20164
|
+
// Strip the buttons on the ORIGINAL card and mark it "waiting" so it
|
|
20165
|
+
// can't be re-tapped, but do NOT overload it as the passphrase prompt.
|
|
20166
|
+
// An in-place edit fires no notification and stays pinned to the card's
|
|
20167
|
+
// old position in the chat, so a busy topic buries it and the operator
|
|
20168
|
+
// never sees the passphrase ask — the exact admin-key miss this fixes
|
|
20169
|
+
// (v0.16.45: the prompt scrolled off, the passphrase never arrived, the
|
|
20170
|
+
// grant was never minted). The prompt goes out as a fresh message below.
|
|
20032
20171
|
await ctx.api
|
|
20033
20172
|
.editMessageText(
|
|
20034
20173
|
pending.chat_id,
|
|
20035
20174
|
pending.card_message_id,
|
|
20036
|
-
|
|
20037
|
-
? `🔐 **Queued behind an earlier card.** Type your passphrase as your next message — it covers **${items.length}** pending approvals in this chat (one entry mints all grants, no re-type per card).`
|
|
20038
|
-
: isAdminOnly
|
|
20039
|
-
? `🔒 **Admin-only credential.** \`${pending.key}\` requires your vault passphrase to grant — reply with it as your next message and we'll mint the grant for **${escapeHtmlForTg(pending.agent)}**, then delete the passphrase message.\n\n` +
|
|
20040
|
-
`_The passphrase is what proves it's you: an agent can never mint this key on its own._`
|
|
20041
|
-
: `🔐 **Vault is locked.** Reply with your passphrase as your next message — we'll unlock, mint the grant for **${escapeHtmlForTg(pending.agent)}**, and delete the passphrase message in one step.\n\n` +
|
|
20042
|
-
richMessage(`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches._`),
|
|
20175
|
+
richMessage(`🔐 _Approved — waiting for your vault passphrase. See the prompt below._`),
|
|
20043
20176
|
{ reply_markup: { inline_keyboard: [] } },
|
|
20044
20177
|
)
|
|
20045
20178
|
.catch(() => {})
|
|
20179
|
+
|
|
20180
|
+
// The passphrase prompt as a NEW rich message. Three fixes vs. the old
|
|
20181
|
+
// in-place edit, all of which the reported bug needed:
|
|
20182
|
+
// 1. Real bold/italic — rendered through the sanctioned `richMessage`
|
|
20183
|
+
// GFM path (`sendRichMessage`), never a raw string. The old admin
|
|
20184
|
+
// and joining-batch branches passed raw markdown to editMessageText
|
|
20185
|
+
// (parse_mode=none), so `**`/`_` rendered as literal characters;
|
|
20186
|
+
// the "locked" branch even concatenated a string with a
|
|
20187
|
+
// `richMessage()` object (→ `[object Object]`). All three are gone.
|
|
20188
|
+
// 2. It lands at the BOTTOM of the chat, not stapled to an old card
|
|
20189
|
+
// that later messages bury.
|
|
20190
|
+
// 3. It fires a notification — `disable_notification` is deliberately
|
|
20191
|
+
// NOT set — so the operator is actually pinged to act.
|
|
20192
|
+
// Attention-grabbing header, short lines, key in code formatting.
|
|
20193
|
+
const promptText = joiningBatch
|
|
20194
|
+
? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
20195
|
+
`Type your vault passphrase as your **next message**.\n` +
|
|
20196
|
+
`One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
|
|
20197
|
+
`_We delete the passphrase message the moment we read it._`
|
|
20198
|
+
: isAdminOnly
|
|
20199
|
+
? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
20200
|
+
`\`${pending.key}\` is an **admin-only credential**.\n` +
|
|
20201
|
+
`Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
|
|
20202
|
+
`_The passphrase is what proves it's you. An agent can never mint this key on its own. We delete the passphrase message the moment we read it._`
|
|
20203
|
+
: `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
20204
|
+
`Your vault is locked.\n` +
|
|
20205
|
+
`Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
|
|
20206
|
+
`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches. We delete the passphrase message the moment we read it._`
|
|
20207
|
+
|
|
20208
|
+
// #1075: deleted-topic safe — fall back to the main chat. Wrapped
|
|
20209
|
+
// through robustApiCall for flood-wait retries, mirroring the card send.
|
|
20210
|
+
await retryWithThreadFallback<{ message_id: number }>(
|
|
20211
|
+
robustApiCall,
|
|
20212
|
+
(tid) =>
|
|
20213
|
+
lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
|
|
20214
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
20215
|
+
}),
|
|
20216
|
+
{ threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
|
|
20217
|
+
).catch(() => {})
|
|
20046
20218
|
return
|
|
20047
20219
|
}
|
|
20048
20220
|
|
|
@@ -22076,7 +22248,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22076
22248
|
await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
|
|
22077
22249
|
return
|
|
22078
22250
|
}
|
|
22079
|
-
|
|
22251
|
+
// Page navigation (External ▸ / ◂ Back) just re-renders the menu with the
|
|
22252
|
+
// other keyboard page — it never drives the picker, so ack "Loading…"
|
|
22253
|
+
// rather than the switch-oriented "Switching…".
|
|
22254
|
+
const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
|
|
22255
|
+
await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
|
|
22080
22256
|
// sr-* inject waits for claude to respond (can take 10-30s). Edit the
|
|
22081
22257
|
// menu immediately to show a "working on it" state so the operator isn't
|
|
22082
22258
|
// left looking at a stale menu with no feedback. The final edit (✅/❌)
|
|
@@ -22453,7 +22629,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22453
22629
|
.catch(() => {})
|
|
22454
22630
|
return
|
|
22455
22631
|
}
|
|
22456
|
-
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
22457
22632
|
const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
|
|
22458
22633
|
const cbMessageId = ctx.callbackQuery?.message?.message_id
|
|
22459
22634
|
const cbThreadId = (() => {
|
|
@@ -22464,31 +22639,57 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22464
22639
|
}
|
|
22465
22640
|
return undefined
|
|
22466
22641
|
})()
|
|
22467
|
-
//
|
|
22468
|
-
//
|
|
22469
|
-
//
|
|
22470
|
-
|
|
22471
|
-
|
|
22472
|
-
|
|
22473
|
-
|
|
22474
|
-
|
|
22475
|
-
|
|
22642
|
+
// #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
|
|
22643
|
+
// by the pre-synth queue at reply time). If it's on disk, attach it
|
|
22644
|
+
// immediately — no GPU wait. Missing/unreadable file (expired + swept,
|
|
22645
|
+
// crash, pre-feature entry, kill-switched gateway) falls back
|
|
22646
|
+
// transparently to the lazy synth path below.
|
|
22647
|
+
let audio: Uint8Array | null = null
|
|
22648
|
+
if (entry.filePath != null) {
|
|
22649
|
+
try {
|
|
22650
|
+
audio = readFileSync(entry.filePath)
|
|
22651
|
+
} catch {
|
|
22652
|
+
audio = null // swept/missing — lazy fallback
|
|
22653
|
+
}
|
|
22476
22654
|
}
|
|
22477
|
-
|
|
22478
|
-
|
|
22479
|
-
|
|
22480
|
-
|
|
22481
|
-
|
|
22482
|
-
|
|
22483
|
-
|
|
22484
|
-
|
|
22485
|
-
|
|
22486
|
-
|
|
22487
|
-
|
|
22488
|
-
|
|
22489
|
-
|
|
22490
|
-
|
|
22655
|
+
if (audio != null) {
|
|
22656
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
22657
|
+
} else {
|
|
22658
|
+
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
22659
|
+
// Local sidecar (kokoro) synthesis — same helper the immediate voice-out
|
|
22660
|
+
// path uses. On-demand is a local-engine feature; the cache is only
|
|
22661
|
+
// populated when resolveVoiceOutPlan gated the local verdict.
|
|
22662
|
+
const sidecarToken = await materializeSidecarToken()
|
|
22663
|
+
if (!sidecarToken) {
|
|
22664
|
+
await ctx
|
|
22665
|
+
.answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
|
|
22666
|
+
.catch(() => {})
|
|
22667
|
+
return
|
|
22668
|
+
}
|
|
22669
|
+
const result = await synthesizeViaSidecar({
|
|
22670
|
+
token: sidecarToken,
|
|
22671
|
+
// #2760 Phase 1: same deterministic normalization as the immediate
|
|
22672
|
+
// voice-out path — the Listen lazy path builds its own /tts body from
|
|
22673
|
+
// the persisted cache, so it must normalize independently (cache
|
|
22674
|
+
// entries may predate the flag flip).
|
|
22675
|
+
text: normalizeForTts(entry.text),
|
|
22676
|
+
voice: entry.voice,
|
|
22677
|
+
speed: entry.speed,
|
|
22678
|
+
})
|
|
22679
|
+
if (!result.ok) {
|
|
22680
|
+
process.stderr.write(
|
|
22681
|
+
`telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
|
|
22682
|
+
)
|
|
22683
|
+
await ctx
|
|
22684
|
+
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
22685
|
+
.catch(() => {})
|
|
22686
|
+
return
|
|
22687
|
+
}
|
|
22688
|
+
audio = result.audio
|
|
22491
22689
|
}
|
|
22690
|
+
// Rebind as const so the closure below narrows to non-null (TS doesn't
|
|
22691
|
+
// narrow a captured `let` inside an arrow function).
|
|
22692
|
+
const audioOut: Uint8Array = audio
|
|
22492
22693
|
try {
|
|
22493
22694
|
// Native voice note (NOT a document), quote-replying the button's
|
|
22494
22695
|
// message.
|
|
@@ -22497,7 +22698,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22497
22698
|
bot.api.sendVoice(
|
|
22498
22699
|
cbChatId,
|
|
22499
22700
|
// allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
|
|
22500
|
-
new InputFile(Buffer.from(
|
|
22701
|
+
new InputFile(Buffer.from(audioOut)),
|
|
22501
22702
|
{
|
|
22502
22703
|
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
22503
22704
|
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
@@ -22759,9 +22960,48 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22759
22960
|
action: naturalAction(details.tool_name, details.input_preview),
|
|
22760
22961
|
})
|
|
22761
22962
|
|
|
22762
|
-
// (3)
|
|
22763
|
-
//
|
|
22764
|
-
//
|
|
22963
|
+
// (3) Ack the tap IMMEDIATELY with an honest interim status. The
|
|
22964
|
+
// durable persist below goes through hostd `config_propose_edit`
|
|
22965
|
+
// (validate→approve→apply→reconcile), which can take 5-10 minutes on
|
|
22966
|
+
// a busy host — the old code awaited only 60s and then reported
|
|
22967
|
+
// "did NOT save" even when the edit later landed. Instead: ack now,
|
|
22968
|
+
// run the persistence in a background continuation with a 12-min
|
|
22969
|
+
// dispatch window, then edit the card with the REAL outcome.
|
|
22970
|
+
// HTML-escape baseText — `ctx.callbackQuery.message.text` returns
|
|
22971
|
+
// entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
|
|
22972
|
+
// expanded permission card's `description` or `input_preview`
|
|
22973
|
+
// (claude-generated tool descriptions routinely carry these)
|
|
22974
|
+
// would break the HTML re-parse. PR #1158 review caught the same
|
|
22975
|
+
// issue on the operator-event card.
|
|
22976
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
22977
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
22978
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
22979
|
+
: ''
|
|
22980
|
+
const interimLabel =
|
|
22981
|
+
`⏳ **Rule applied for this session** — ${escapeHtmlForTg(agentName)} can ${escapeHtmlForTg(grantPhrase)} ` +
|
|
22982
|
+
`without asking for now; saving durably in background…`
|
|
22983
|
+
// #1150 audit: route through finalizeCallback so the keyboard
|
|
22984
|
+
// strips alongside the status-line edit. The in-flight verdict was
|
|
22985
|
+
// ALREADY dispatched above (independently of this host round-trip)
|
|
22986
|
+
// so the turn never blocked — finalizeCallback here only edits the
|
|
22987
|
+
// card; no synthInbound (would double-fire the verdict).
|
|
22988
|
+
await finalizeCallback(ctx, {
|
|
22989
|
+
ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
|
|
22990
|
+
newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
|
|
22991
|
+
})
|
|
22992
|
+
|
|
22993
|
+
// Background continuation: decide the persistence path.
|
|
22994
|
+
// tryHostdDispatch returns "not-configured" when host_control is
|
|
22995
|
+
// disabled or the per-agent socket is absent → legacy fallback.
|
|
22996
|
+
// The whole body is wrapped in try/catch: a throw inside a void
|
|
22997
|
+
// IIFE (scheduleGrantRestart's sync fs writes, richMessage, etc.)
|
|
22998
|
+
// would surface as an unhandledRejection — which this gateway
|
|
22999
|
+
// routes through shutdown(). Pre-background, those throws were
|
|
23000
|
+
// contained by grammY's handler wrapper; keep that containment.
|
|
23001
|
+
// Known accepted gap: if the gateway restarts mid-persist, the
|
|
23002
|
+
// interim "saving durably in background…" card stays stale.
|
|
23003
|
+
void (async () => {
|
|
23004
|
+
try {
|
|
22765
23005
|
let durable = false
|
|
22766
23006
|
let legacy = false
|
|
22767
23007
|
let failReason = ''
|
|
@@ -22803,8 +23043,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22803
23043
|
},
|
|
22804
23044
|
}
|
|
22805
23045
|
// config_propose_edit blocks on validate→approve→apply→reconcile,
|
|
22806
|
-
//
|
|
22807
|
-
|
|
23046
|
+
// which can legitimately take 5-10 minutes — allow 12 min. Safe
|
|
23047
|
+
// to wait this long because we're in a background continuation:
|
|
23048
|
+
// the tap was already acked and the verdict already dispatched.
|
|
23049
|
+
const resp = await tryHostdDispatch(agentName, req, 720_000)
|
|
22808
23050
|
if (resp === 'not-configured') {
|
|
22809
23051
|
warnLegacySpawnIfHostdDisabled('always-allow')
|
|
22810
23052
|
legacy = true
|
|
@@ -22888,23 +23130,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22888
23130
|
`always-allow: ${grantPhrase}`,
|
|
22889
23131
|
) !== "disabled"
|
|
22890
23132
|
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
23133
|
const editLabel = ok
|
|
22909
23134
|
? (legacyNote
|
|
22910
23135
|
? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
|
|
@@ -22914,15 +23139,30 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22914
23139
|
: (editLockHint
|
|
22915
23140
|
? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
|
|
22916
23141
|
: `⚠️ **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
|
|
22917
|
-
//
|
|
22918
|
-
//
|
|
22919
|
-
//
|
|
22920
|
-
//
|
|
22921
|
-
|
|
22922
|
-
|
|
22923
|
-
|
|
22924
|
-
|
|
23142
|
+
// Edit the card with the REAL outcome. The keyboard was already
|
|
23143
|
+
// stripped by the interim finalizeCallback ack above, and the
|
|
23144
|
+
// callback query was already answered — this is a plain edit
|
|
23145
|
+
// replacing the "saving durably in background…" interim line.
|
|
23146
|
+
await ctx.editMessageText(
|
|
23147
|
+
richMessage(baseText ? `${baseText}\n\n${editLabel}` : editLabel),
|
|
23148
|
+
{
|
|
23149
|
+
reply_markup: { inline_keyboard: [] },
|
|
23150
|
+
link_preview_options: { is_disabled: true },
|
|
23151
|
+
},
|
|
23152
|
+
).catch((err: unknown) => {
|
|
23153
|
+
process.stderr.write(
|
|
23154
|
+
`telegram gateway: always-allow outcome card edit failed: ${(err as Error).message} (request_id=${request_id})\n`,
|
|
23155
|
+
)
|
|
22925
23156
|
})
|
|
23157
|
+
} catch (err) {
|
|
23158
|
+
// Never let the background persist take the gateway down — an
|
|
23159
|
+
// unhandledRejection here becomes shutdown(). Log and move on;
|
|
23160
|
+
// the in-session rule was already applied at tap time.
|
|
23161
|
+
process.stderr.write(
|
|
23162
|
+
`telegram gateway: always-allow background persist threw: ${(err as Error).message} (request_id=${request_id})\n`,
|
|
23163
|
+
)
|
|
23164
|
+
}
|
|
23165
|
+
})()
|
|
22926
23166
|
return
|
|
22927
23167
|
}
|
|
22928
23168
|
|