switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,298 @@
1
+ /**
2
+ * turn-start-surfaces.ts — the turn-START surfaces block (#2996 P8 PR-D).
3
+ *
4
+ * Extracted VERBATIM from gateway.ts `handleInbound` (the status-reaction
5
+ * controller install + fresh-turn init + progress-card `startTurn`): the
6
+ * steer/queue ack reactions, the fresh-turn StatusReactionController install
7
+ * (incl. the #472-finding-17 prior-draft finalize await — load-bearing, kept
8
+ * at the same await point), the silence-poke / signal-tracker / typing-loop
9
+ * arming, and the progress-card `startTurn`.
10
+ *
11
+ * Why a single extracted surface, called ONCE on the shared tail: P7's inbound
12
+ * router (`SWITCHROOM_INBOUND_ROUTER_V2`) branches only at the intercept
13
+ * gauntlet (the v2 chain in inbound-router.ts vs the retained legacy inline
14
+ * arm) and both arms re-converge BEFORE this block. Extracting it and calling
15
+ * it from that shared convergence point means the v2 and legacy routing modes
16
+ * run byte-identical turn-start surfaces — the design's "called from both
17
+ * arms" acceptance criterion, satisfied structurally (a flip of the router
18
+ * flag cannot fork turn-start behaviour because there is one implementation).
19
+ *
20
+ * DI contract (the turn-end.ts / stream-render precedent): STATE stays in
21
+ * gateway.ts; this module owns only the logic. Mutable stores, live singletons
22
+ * and gateway-bound helpers arrive through `TurnStartSurfacesDeps`
23
+ * (`gatewayTurnStartSurfacesDeps()` in gateway.ts — exact-by-construction via
24
+ * `ReturnType`). The volatile `currentTurn` read (the mid-turn auto-classify
25
+ * shadow) crosses as `getCurrentTurn()` so it sees the live value (the P9
26
+ * getter funnel, cluster for turn-start reads). Pure imports
27
+ * (`StatusReactionController`, `autoClassifyMidTurnInbound`) come in directly.
28
+ */
29
+ import type { ReactionTypeEmoji } from 'grammy/types'
30
+ import type { Access, TurnStartSurfacesDeps } from './gateway.js'
31
+ import { StatusReactionController } from '../status-reactions.js'
32
+ import { autoClassifyMidTurnInbound } from './auto-classify-mid-turn.js'
33
+
34
+ /** At-call captured facts the turn-start surfaces need. Every field is a
35
+ * value snapshotted at the handler's convergence point (never a live getter);
36
+ * the one live read (`currentTurn`) is funnelled through the deps getter. */
37
+ export interface TurnStartSurfacesParams {
38
+ chat_id: string
39
+ msgId: number | undefined
40
+ messageThreadId: number | undefined
41
+ text: string
42
+ effectiveText: string
43
+ access: Access
44
+ inboundReceivedAt: number
45
+ isSteerPrefix: boolean
46
+ isQueuedPrefix: boolean
47
+ }
48
+
49
+ /** What the block computes for the downstream envelope/delivery path:
50
+ * `isSteering` (explicit /steer or /s) and `priorTurnStartedAt` (the prior
51
+ * in-flight turn's start, undefined on a fresh turn). */
52
+ export interface TurnStartSurfacesResult {
53
+ isSteering: boolean
54
+ priorTurnStartedAt: number | undefined
55
+ }
56
+
57
+ export function createTurnStartSurfaces(deps: TurnStartSurfacesDeps) {
58
+ const {
59
+ AUTOCLASSIFY_MIDTURN_SHADOW,
60
+ getCurrentTurn,
61
+ statusKey,
62
+ streamKey,
63
+ isDmChatId,
64
+ sendReaction,
65
+ logStreamingEvent,
66
+ probeAvailableReactions,
67
+ startTurnTypingLoop,
68
+ emitRuntimeMetric,
69
+ classifyInbound,
70
+ resolveAgentDirFromEnv,
71
+ addActiveReaction,
72
+ signalTracker,
73
+ silencePoke,
74
+ pendingProgress,
75
+ progressDriver,
76
+ activeStatusReactions,
77
+ activeTurnStartedAt,
78
+ lastAgentOutputAt,
79
+ activeDraftStreams,
80
+ suppressPtyPreview,
81
+ chatAvailableReactions,
82
+ reactionTransitionCounts,
83
+ activeReactionMsgIds,
84
+ progressUpdateTurnCount,
85
+ } = deps
86
+
87
+ async function armTurnStartSurfaces(
88
+ p: TurnStartSurfacesParams,
89
+ ): Promise<TurnStartSurfacesResult> {
90
+ const { chat_id, msgId, messageThreadId, text, effectiveText, access, inboundReceivedAt, isSteerPrefix, isQueuedPrefix } = p
91
+ // Status reaction controller
92
+ let isSteering = false
93
+ let priorTurnStartedAt: number | undefined
94
+ if (msgId != null) {
95
+ const key = statusKey(chat_id, messageThreadId)
96
+ const priorActive = activeStatusReactions.get(key)
97
+ const priorTurnInFlight = priorActive != null
98
+ // New default: mid-turn messages are queued unless the user explicitly
99
+ // steers. isSteering is true only when the steer prefix is present.
100
+ // (Legacy: without any prefix the old behavior was isSteering=true; now
101
+ // it's false so the message goes through as queued="true".)
102
+ isSteering = priorTurnInFlight && isSteerPrefix
103
+ if (priorTurnInFlight) priorTurnStartedAt = activeTurnStartedAt.get(key)
104
+
105
+ // Mid-turn auto-classify SHADOW: compute what a topic+recency classifier
106
+ // WOULD decide and log it — behaviour is UNCHANGED (isSteering above is
107
+ // untouched). Gathers the real-world distribution (same-topic continuation
108
+ // vs cross-topic, recency spread) to tune auto-steer before it ever acts.
109
+ // No-op unless the shadow flag is on AND a turn is in flight (the only case
110
+ // a steer-vs-queue decision is meaningful).
111
+ if (AUTOCLASSIFY_MIDTURN_SHADOW && priorTurnInFlight) {
112
+ const lastOut = lastAgentOutputAt.get(key)
113
+ const msSinceOut = lastOut != null ? Date.now() - lastOut : null
114
+ const shadow = autoClassifyMidTurnInbound({
115
+ isSteerPrefix,
116
+ isQueuePrefix: isQueuedPrefix,
117
+ priorTurnInFlight,
118
+ isDm: isDmChatId(chat_id),
119
+ incomingThreadId: messageThreadId ?? null,
120
+ activeTurnThreadId: getCurrentTurn()?.sessionThreadId ?? null,
121
+ msSinceLastAgentOutput: msSinceOut,
122
+ dmSteerWindowMs: 0, // DM auto-steer stays off (the April regime)
123
+ topicSteerWindowMs: 8_000, // candidate window — what we're tuning
124
+ })
125
+ process.stderr.write(
126
+ `telegram gateway: autoclassify-shadow chat_id=${chat_id} ` +
127
+ `would=${shadow.decision} reason=${shadow.reason} same_topic=${shadow.sameTopic ?? '-'} ` +
128
+ `ms_since_out=${msSinceOut ?? '-'} actual=${isSteering ? 'steer' : 'queue'}\n`,
129
+ )
130
+ }
131
+
132
+ if (access.statusReactions !== false) {
133
+ if (isSteering) {
134
+ // Explicit steer: mark with 🤝 on the inbound message; leave the
135
+ // existing StatusReactionController running for the in-flight turn.
136
+ void sendReaction(chat_id, msgId, '🤝').catch(() => {})
137
+ } else if (priorTurnInFlight) {
138
+ // Queued mid-turn message (new default): don't touch the existing
139
+ // controller; just ack the inbound message with 👀 so the user
140
+ // knows we received it, without disrupting the in-flight reaction.
141
+ void sendReaction(chat_id, msgId, '👀').catch(() => {})
142
+ // #203: time-to-ack metric — measure gateway-receive → ack-post delta.
143
+ logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
144
+ } else {
145
+ // Fresh turn — priorTurnInFlight is false, so priorActive is
146
+ // provably undefined. Earlier `if (priorActive)` block was dead
147
+ // code, removed in the same first-paint cleanup pass.
148
+ const sKey = streamKey(chat_id, messageThreadId)
149
+ const priorStream = activeDraftStreams.get(sKey)
150
+ if (priorStream && !priorStream.isFinal()) {
151
+ // Closes #472 finding #17 — pre-fix this finalize was
152
+ // fire-and-forget. The new turn's reply tool would then create
153
+ // a fresh stream and send its first chunk while the prior
154
+ // stream's terminal sendMessage was still in flight. The
155
+ // late-materialise landed AFTER the new turn's content,
156
+ // visible to the user as a stale "Done" message followed by
157
+ // the new reply (or worse — duplicate content).
158
+ //
159
+ // Awaiting here costs the few hundred ms the final API call
160
+ // takes, but only on rapid follow-ups where the prior turn
161
+ // hadn't yet flushed. The latency hit beats the duplicate-
162
+ // content bug. Delete from the map FIRST so any concurrent
163
+ // reads can't see the stale stream while we await.
164
+ activeDraftStreams.delete(sKey)
165
+ await priorStream.finalize().catch(() => {})
166
+ }
167
+ suppressPtyPreview.delete(sKey)
168
+
169
+ // #542 fix: pass the cached chat-level allowed-reactions filter
170
+ // so the controller's resolveEmoji can fall through to a permitted
171
+ // variant instead of attempting an emoji Telegram will reject.
172
+ // First message in a chat sees `null` (cache miss) — kicks off
173
+ // the probe for next time.
174
+ const allowedReactions = chatAvailableReactions.get(chat_id) ?? null
175
+ if (!chatAvailableReactions.has(chat_id)) {
176
+ probeAvailableReactions(chat_id)
177
+ }
178
+ // #2527: use inbound msgId as a stable per-turn reaction identifier.
179
+ // The controller is created before currentTurn.turnId is assigned
180
+ // (that happens in handleSessionEvent's enqueue branch), so we capture
181
+ // msgId here and use it as the reaction-session token in log events.
182
+ const ctrlTurnToken = `${chat_id}:${msgId}`
183
+ const ctrl = new StatusReactionController(async (emoji) => {
184
+ await sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji'])
185
+ // #203: every status-reaction transition is a user-visible signal.
186
+ signalTracker.noteSignal(key, Date.now())
187
+ }, allowedReactions, {
188
+ // #2527: emit a structured transition event on each emoji change so
189
+ // the reaction lifecycle is visible in streaming-metrics logs. Also
190
+ // increment the per-key counter for the turn_no_reply_warn metric.
191
+ onTransition: (emoji) => {
192
+ reactionTransitionCounts.set(key, (reactionTransitionCounts.get(key) ?? 0) + 1)
193
+ logStreamingEvent({
194
+ kind: 'status_reaction_transition',
195
+ chatId: chat_id,
196
+ turnId: ctrlTurnToken,
197
+ emoji,
198
+ })
199
+ },
200
+ })
201
+ activeStatusReactions.set(key, ctrl)
202
+ activeReactionMsgIds.set(key, { chatId: chat_id, messageId: msgId })
203
+ activeTurnStartedAt.set(key, Date.now())
204
+ progressUpdateTurnCount.set(key, 0) // Reset turn counter
205
+ // #2527: log controller install so the lifecycle start is observable.
206
+ logStreamingEvent({
207
+ kind: 'status_reaction_install',
208
+ chatId: chat_id,
209
+ turnId: ctrlTurnToken,
210
+ messageId: msgId,
211
+ })
212
+ ctrl.setQueued()
213
+ // #203: time-to-ack metric — setQueued() triggers the initial 👀 reaction
214
+ // asynchronously through the controller chain.
215
+ logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
216
+ // #203: signal tracker — start tracking silent gaps for this fresh turn.
217
+ signalTracker.reset(statusKey(chat_id, messageThreadId), Date.now())
218
+ // #1122 silence-poke: start the silence clock for this turn so
219
+ // the framework can nudge the model if it goes quiet past the
220
+ // soft / firm thresholds.
221
+ silencePoke.startTurn(statusKey(chat_id, messageThreadId), Date.now())
222
+ // Ack-first gate clear is centralised in handleSessionEvent's
223
+ // `enqueue` branch — that fires for EVERY fresh turn atom
224
+ // (real inbound, cron, subagent-handback, vault-grant wake,
225
+ // restart marker) so cron/handback turns also re-arm the gate.
226
+ // See the call site under `case 'enqueue'` (~line 6794).
227
+ // #1445 cross-turn pending-async ambient. A new turn starting
228
+ // (user inbound, synthesised wake, or handback channel) is the
229
+ // signal that the model is about to re-engage — clear any
230
+ // pending-progress edits anchored to the *prior* turn's
231
+ // outbound so the framework stops talking over the new turn.
232
+ // clearPending drops the per-key state outright, so the new
233
+ // turn's `tool_use(Agent|Task|Bash bg)` + outbound capture
234
+ // afresh via `noteAsyncDispatch` / `noteOutbound`.
235
+ pendingProgress.clearPending(
236
+ statusKey(chat_id, messageThreadId),
237
+ 'inbound',
238
+ )
239
+ // Human-feel UX: hold a continuous `typing…` indicator for the
240
+ // WHOLE turn, not just the split-second a reply is transmitted.
241
+ // A person you message shows as typing the entire time they
242
+ // compose; switchroom used to fire only one-shot ~5s pings, so
243
+ // any turn that read a file or thought for a moment went dark
244
+ // after 5s. Self-renews every 4s; stopped at the canonical
245
+ // turn-end (`purgeReactionTracking → stopTurnTypingLoop`).
246
+ // Deterministic, framework-owned, no prose — the mechanical
247
+ // ambient layer of the pacing contract.
248
+ // PR3 supergroup-mode: pass thread so the indicator lands in
249
+ // this turn's topic (otherwise topic A's turn-end would kill
250
+ // topic B's typing indicator on shared chat_id keying).
251
+ startTurnTypingLoop(chat_id, messageThreadId ?? null)
252
+ // #1122 KPI: emit turn_started so dashboards can compute funnel
253
+ // start counts + correlate to turn_ended for duration / TTFO.
254
+ emitRuntimeMetric({
255
+ kind: 'turn_started',
256
+ chat_id,
257
+ message_id: msgId,
258
+ thread_id: messageThreadId ?? null,
259
+ inbound_classified_as_status_query: classifyInbound(text).isStatusQuery,
260
+ })
261
+ const agentDir = resolveAgentDirFromEnv()
262
+ if (agentDir != null) {
263
+ addActiveReaction(agentDir, { chatId: chat_id, messageId: msgId, threadId: messageThreadId ?? null, reactedAt: Date.now() })
264
+ }
265
+ }
266
+ } else if (access.ackReaction) {
267
+ void sendReaction(chat_id, msgId, access.ackReaction as ReactionTypeEmoji['emoji']).catch(() => {})
268
+ // #203: time-to-ack metric for the custom-ack-reaction path.
269
+ logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
270
+ }
271
+ }
272
+
273
+ // Start a new progress card only for fresh turns (no prior turn in flight).
274
+ // Queued mid-turn messages piggyback on the existing card; steer messages
275
+ // also don't start a new card (the in-flight turn owns it).
276
+ if (!isSteering && priorTurnStartedAt == null) {
277
+ try {
278
+ progressDriver?.startTurn({
279
+ chatId: chat_id,
280
+ threadId: messageThreadId != null ? String(messageThreadId) : undefined,
281
+ userText: effectiveText,
282
+ replyToMessageId: msgId != null ? msgId : undefined,
283
+ })
284
+ } catch (err) {
285
+ process.stderr.write(`telegram gateway: progress-card startTurn failed: ${(err as Error).message}\n`)
286
+ }
287
+
288
+ // Pre-allocated draft + forum-topic placeholder send removed in
289
+ // #553 PR 5. The 👀 status reaction (#568) and
290
+ // sendChatAction('typing') indicator (#585) now bridge the
291
+ // ~1s gap between inbound and the agent's first real text.
292
+ }
293
+
294
+ return { isSteering, priorTurnStartedAt }
295
+ }
296
+
297
+ return { armTurnStartSurfaces }
298
+ }
@@ -63,3 +63,19 @@ export function renderVaultRequestAccessCard(
63
63
  // direct via richMessage, bypassing the switchroomReply chokepoint.
64
64
  return hardenCardBreaks(lines.join('\n'))
65
65
  }
66
+
67
+ /**
68
+ * Inline keyboard for the vault-access request card (#1012). Callback prefix
69
+ * `vra:`, handled by handleVaultRequestAccessCallback in
70
+ * callback-query-handlers.ts. Extracted verbatim from gateway.ts (#2996 P5).
71
+ */
72
+ export function buildVaultRequestAccessKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
73
+ return {
74
+ inline_keyboard: [
75
+ [
76
+ { text: '✅ Approve', callback_data: `vra:approve:${stageId}` },
77
+ { text: '🚫 Deny', callback_data: `vra:deny:${stageId}` },
78
+ ],
79
+ ],
80
+ }
81
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ renderVaultRequestSaveCard,
4
+ buildVaultRequestSaveKeyboard,
5
+ } from './vault-request-save-card.js'
6
+
7
+ describe('vault-request-save-card (#2996 P5 extraction — verbatim from gateway.ts)', () => {
8
+ describe('renderVaultRequestSaveCard', () => {
9
+ it('renders the title, key field, why line, and footer', () => {
10
+ const out = renderVaultRequestSaveCard(
11
+ { key: 'openai/api_key', why: 'to call the API' },
12
+ 'worker',
13
+ )
14
+ expect(out).toContain('🔐 **worker** wants to save a secret')
15
+ expect(out).toContain('key: `openai/api_key`')
16
+ expect(out).toContain('why: _to call the API_')
17
+ expect(out).toContain('Tap Save to write to the host vault')
18
+ })
19
+
20
+ it('omits the why line when no rationale is given', () => {
21
+ const out = renderVaultRequestSaveCard({ key: 'openai/api_key' }, 'worker')
22
+ expect(out).not.toContain('why:')
23
+ })
24
+
25
+ it('markdown-escapes the why text (not the code-span key)', () => {
26
+ const out = renderVaultRequestSaveCard(
27
+ { key: 'a/b_c', why: 'has_underscore' },
28
+ 'worker',
29
+ )
30
+ // escapeHtmlForTg backslash-escapes emphasis chars in the why line…
31
+ expect(out).toContain('why: _has\\_underscore_')
32
+ // …but the key rides in a code span verbatim (literal underscore kept).
33
+ expect(out).toContain('key: `a/b_c`')
34
+ })
35
+ })
36
+
37
+ describe('buildVaultRequestSaveKeyboard', () => {
38
+ it('carries the vrs:* callback_data contract', () => {
39
+ const kb = buildVaultRequestSaveKeyboard('abcd1234')
40
+ const flat = kb.inline_keyboard.flat()
41
+ expect(flat.map((b) => b.callback_data)).toEqual([
42
+ 'vrs:save:abcd1234',
43
+ 'vrs:discard:abcd1234',
44
+ 'vrs:rename:abcd1234',
45
+ ])
46
+ expect(flat.map((b) => b.text)).toEqual(['✅ Save once', '🚫 Discard', '✏️ Rename'])
47
+ })
48
+ })
49
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pure renderer + inline-keyboard builder for the agent-initiated vault-SAVE
3
+ * approval card (#969 P1a — the agent HAS a value and asks the operator to
4
+ * confirm writing it to the host vault). Callback prefix `vrs:`
5
+ * (vault-request-save), handled by handleVaultRequestSaveCallback in
6
+ * callback-query-handlers.ts.
7
+ *
8
+ * Extracted verbatim from gateway.ts (#2996 P5); the render body is unchanged.
9
+ * The card is sent via the rich-message (GFM markdown) path, so field lines are
10
+ * hardened against soft-collapse via hardenCardBreaks (see renderer footer).
11
+ *
12
+ * Buttons must fit Telegram's 64-byte callback_data limit. Stage IDs are 8 hex
13
+ * chars (32 bits), so each callback comfortably fits.
14
+ */
15
+
16
+ import { escapeHtmlForTg } from '../shared/bot-runtime.js'
17
+ import { hardenCardBreaks } from '../format.js'
18
+
19
+ /** Minimal shape the card needs — a subset of PendingVaultRequestSave. */
20
+ export interface VaultRequestSaveCardInput {
21
+ key: string
22
+ why?: string
23
+ }
24
+
25
+ export function renderVaultRequestSaveCard(req: VaultRequestSaveCardInput, agentSlug: string): string {
26
+ const lines: string[] = []
27
+ lines.push(`🔐 **${escapeHtmlForTg(agentSlug)}** wants to save a secret`)
28
+ lines.push(`key: \`${req.key}\``)
29
+ if (req.why && req.why.length > 0) {
30
+ lines.push(`why: _${escapeHtmlForTg(req.why)}_`)
31
+ }
32
+ lines.push('')
33
+ lines.push(`_Tap Save to write to the host vault, Rename to change the key name, or Discard to drop it. The value is held in this chat's gateway memory until you decide._`)
34
+ // hardenCardBreaks: labelled field lines (key: / why:) would soft-collapse
35
+ // into one blob under the GFM rich renderer; this card is sent direct via
36
+ // richMessage, bypassing the switchroomReply chokepoint.
37
+ return hardenCardBreaks(lines.join('\n'))
38
+ }
39
+
40
+ export function buildVaultRequestSaveKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
41
+ return {
42
+ inline_keyboard: [
43
+ [
44
+ { text: '✅ Save once', callback_data: `vrs:save:${stageId}` },
45
+ { text: '🚫 Discard', callback_data: `vrs:discard:${stageId}` },
46
+ ],
47
+ [
48
+ { text: '✏️ Rename', callback_data: `vrs:rename:${stageId}` },
49
+ ],
50
+ ],
51
+ }
52
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Voice inbound handler (switchroom#2996 P6 cluster E).
3
+ *
4
+ * `message:voice` is the attachment type with the richest branch: when
5
+ * `voice_in` is enabled in access.json it downloads the audio and transcribes
6
+ * it (local GPU sidecar or OpenAI cloud, per the host voice-engine verdict)
7
+ * before surfacing the transcript to the agent as inbound text. Every failure
8
+ * path falls back to the legacy `(voice message)` envelope so the agent still
9
+ * gets something — better than a silent drop. Split out of gateway.ts so the
10
+ * engine-selection + fallback logic is unit-testable against injected
11
+ * transcribe/access/capability surfaces.
12
+ *
13
+ * The registration line stays in gateway.ts (order is a load-bearing invariant
14
+ * pinned by gateway-handler-registration-wiring.test.ts) and delegates here.
15
+ * The engine env (`SWITCHROOM_VOICE_ENGINE`) is read directly — it is process
16
+ * ambient, not a gateway module global. All gateway surfaces (access load,
17
+ * host-capabilities load, both transcribe entry points, the coalescing
18
+ * dispatch) are injected (switchroom#2996 Amendment 9).
19
+ */
20
+
21
+ import type { Context, Filter } from 'grammy'
22
+ import type { VoiceEngine } from '../../src/setup/gpu-detect.js'
23
+ import type { AttachmentMeta } from './attachment-message-handlers.js'
24
+
25
+ interface VoiceInConfig {
26
+ enabled?: boolean
27
+ provider?: 'openai'
28
+ language?: string
29
+ api_key?: string
30
+ }
31
+
32
+ export interface VoiceHandlerDeps {
33
+ /** Load the agent's access config (for the `voice_in` block). */
34
+ loadAccess: () => { voice_in?: VoiceInConfig }
35
+ /** Load the persisted host voice-engine verdict (null when absent). */
36
+ loadHostCapabilities: () => { voice: { engine: VoiceEngine } } | null
37
+ /** Transcribe via the in-fleet local GPU sidecar (no third-party key). */
38
+ maybeTranscribeVoiceLocal: (
39
+ fileId: string,
40
+ mimeType: string | undefined,
41
+ language: string | undefined,
42
+ ) => Promise<string | null>
43
+ /** Transcribe via the OpenAI cloud provider. */
44
+ maybeTranscribeVoice: (
45
+ fileId: string,
46
+ mimeType: string | undefined,
47
+ language: string | undefined,
48
+ apiKeyRef: string | undefined,
49
+ ) => Promise<string | null>
50
+ /** Normal coalescing inbound pipeline. */
51
+ handleInboundCoalesced: (
52
+ ctx: Context,
53
+ text: string,
54
+ downloadImage: undefined,
55
+ attachment?: AttachmentMeta,
56
+ ) => Promise<void>
57
+ }
58
+
59
+ export async function handleVoiceMessage(
60
+ ctx: Filter<Context, 'message:voice'>,
61
+ deps: VoiceHandlerDeps,
62
+ ): Promise<void> {
63
+ const voice = ctx.message.voice
64
+ // #578 spike: when voice_in is enabled in access.json, download
65
+ // the audio and transcribe via Whisper before surfacing to the
66
+ // agent. The transcript becomes the inbound text; the agent reads
67
+ // it like a typed message. Failure paths gracefully fall back to
68
+ // the legacy "(voice message)" envelope so the agent still gets
69
+ // SOMETHING — better than silent drops.
70
+ const access = deps.loadAccess()
71
+ const voiceIn = access.voice_in
72
+ // Engine selection (PR-B2): the persisted host verdict decides HOW we
73
+ // transcribe. `local` → the in-fleet GPU sidecar (no third-party key,
74
+ // vision #3 + #4); anything else → the OpenAI cloud provider. The local
75
+ // path needs no `provider === 'openai'` gate — it has no API key — so we
76
+ // route to the sidecar whenever voice_in is enabled AND the host verdict
77
+ // is `local`. The cloud path keeps its existing openai gate.
78
+ // Source precedence: the compose-injected SWITCHROOM_VOICE_ENGINE env
79
+ // (set per-agent by compose-gen from the host verdict — PR-B3) wins,
80
+ // then the persisted host-capabilities file, then a fail-safe `cloud`.
81
+ // The env is load-bearing in-fleet: the in-container `~/.switchroom`
82
+ // is a read-only constructed view that does NOT carry the host's
83
+ // host-capabilities.json, so without the env the file lookup always
84
+ // misses and every agent silently falls back to `cloud`. Narrow the
85
+ // env value to the VoiceEngine union so a bogus value can't leak
86
+ // through — anything but 'local'/'cloud' is ignored and we fall back.
87
+ const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
88
+ const voiceEngine: VoiceEngine =
89
+ envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
90
+ ? envVoiceEngine
91
+ : deps.loadHostCapabilities()?.voice.engine ?? 'cloud'
92
+ const localEnabled = voiceIn?.enabled === true && voiceEngine === 'local'
93
+ const cloudEnabled =
94
+ voiceIn?.enabled === true && voiceEngine !== 'local' && voiceIn?.provider === 'openai'
95
+ if (localEnabled || cloudEnabled) {
96
+ const transcript = localEnabled
97
+ ? await deps.maybeTranscribeVoiceLocal(
98
+ voice.file_id,
99
+ voice.mime_type,
100
+ voiceIn?.language,
101
+ )
102
+ : await deps.maybeTranscribeVoice(
103
+ voice.file_id,
104
+ voice.mime_type,
105
+ voiceIn?.language,
106
+ voiceIn?.api_key,
107
+ )
108
+ if (transcript != null) {
109
+ const text = ctx.message.caption
110
+ ? `${ctx.message.caption}\n\n[voice transcript] ${transcript}`
111
+ : `[voice transcript] ${transcript}`
112
+ await deps.handleInboundCoalesced(ctx, text, undefined, {
113
+ kind: 'voice',
114
+ file_id: voice.file_id,
115
+ size: voice.file_size,
116
+ mime: voice.mime_type,
117
+ })
118
+ return
119
+ }
120
+ // Fall through to the legacy path on transcription failure.
121
+ }
122
+ await deps.handleInboundCoalesced(ctx, ctx.message.caption ?? '(voice message)', undefined, { kind: 'voice', file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type })
123
+ }