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.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- 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
|
+
}
|