switchroom 0.19.15 → 0.19.16

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 (27) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +30 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +693 -433
  6. package/telegram-plugin/dist/server.js +30 -1
  7. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  8. package/telegram-plugin/gateway/gateway.ts +7 -58
  9. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  10. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  11. package/telegram-plugin/gateway/outbox-sweep.ts +92 -18
  12. package/telegram-plugin/gateway/rich-message-handler.ts +10 -4
  13. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  14. package/telegram-plugin/session-tail.ts +88 -1
  15. package/telegram-plugin/silence-poke.ts +118 -1
  16. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  17. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  18. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  19. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  20. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  21. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  22. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  23. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  24. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  25. package/telegram-plugin/tts-normalize.ts +12 -0
  26. package/telegram-plugin/voice-normalize-text.ts +100 -0
  27. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -311,3 +311,74 @@ export function mayInjectListenButton(
311
311
  if (rawKeyboard == null) return true
312
312
  return !rawKeyboard.some((row) => Array.isArray(row) && row.length > 0)
313
313
  }
314
+
315
+ /** The voice-out plan fields the Listen-button decision depends on. A subset of
316
+ * the gateway's full `VoiceOutPlan` so this pure helper carries no gateway
317
+ * import. */
318
+ export interface ListenButtonVoiceOutPlan {
319
+ engine: 'kokoro' | 'openai'
320
+ voice?: string
321
+ speed: number
322
+ replyMode: 'voice+text' | 'voice-only' | 'on-demand'
323
+ ttsChunks: string[]
324
+ }
325
+
326
+ /** The decision to inject a 🔊 Listen button on a message, plus the cache
327
+ * payload the caller must persist so a tap can resynthesize. */
328
+ export interface ListenButtonPlan {
329
+ /** The single-row Listen keyboard to attach as `reply_markup`. */
330
+ replyMarkup: {
331
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
332
+ }
333
+ /** The freshly minted token keying the keyboard callback + cache entry. */
334
+ token: string
335
+ /** The payload the caller stores under `token` in the VoiceOnDemandCache
336
+ * (and enqueues for eager pre-synth). */
337
+ payload: VoiceOnDemandPayload
338
+ }
339
+
340
+ /**
341
+ * The SINGLE source of truth for "should this delivered message carry a 🔊
342
+ * Listen button, and if so which token/keyboard/cache payload?" — shared by
343
+ * BOTH the normal reply path (`sendReply` in outbound-send-path.ts) and the
344
+ * durable-outbox safety-net delivery (`outbox-sweep.ts` wiring), so a
345
+ * net-delivered final answer is indistinguishable from a normally-delivered
346
+ * one (switchroom #3502 regression: the sweep dropped the button).
347
+ *
348
+ * Returns null — no button — when any gate fails:
349
+ * - no voice-out plan, or the plan is not kokoro on-demand (openai on-demand
350
+ * synthesizes immediately and its taps would dead-end on the local sidecar);
351
+ * - the TTS text is empty (nothing to speak — the empty-TTS guard);
352
+ * - the agent supplied its own inline keyboard (single_use collision gate —
353
+ * see mayInjectListenButton).
354
+ *
355
+ * Pure: it mints a token and builds the keyboard/payload but performs NO side
356
+ * effects (no cache write, no eager enqueue). The caller owns those so the
357
+ * token is only persisted when it decides to actually attach the button.
358
+ */
359
+ export function planListenButton(params: {
360
+ voiceOutPlan: ListenButtonVoiceOutPlan | null
361
+ rawKeyboard: unknown[][] | undefined | null
362
+ }): ListenButtonPlan | null {
363
+ const plan = params.voiceOutPlan
364
+ // on-demand Listen button is a LOCAL-engine (kokoro) feature only.
365
+ const useOnDemandButton =
366
+ plan != null && plan.replyMode === 'on-demand' && plan.engine === 'kokoro'
367
+ if (!useOnDemandButton) return null
368
+ // Empty-TTS guard: nothing to speak → no button.
369
+ if (!(plan.ttsChunks.length > 0 && plan.ttsChunks[0]!.length > 0)) return null
370
+ // Collision gate: agent supplied its own keyboard → skip.
371
+ if (!mayInjectListenButton(params.rawKeyboard)) return null
372
+
373
+ const token = mintVoiceOnDemandToken()
374
+ return {
375
+ replyMarkup: buildListenKeyboard(token),
376
+ token,
377
+ payload: {
378
+ // ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
379
+ text: plan.ttsChunks[0]!,
380
+ ...(plan.voice != null ? { voice: plan.voice } : {}),
381
+ speed: plan.speed,
382
+ },
383
+ }
384
+ }