switchroom 0.19.14 → 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 (49) 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/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +31 -2
  6. package/telegram-plugin/dist/gateway/gateway.js +1690 -932
  7. package/telegram-plugin/dist/server.js +31 -2
  8. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  9. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  10. package/telegram-plugin/gateway/gateway.ts +10 -57
  11. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  13. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  14. package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
  15. package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
  16. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  17. package/telegram-plugin/gateway/stream-render.ts +107 -15
  18. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  19. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  20. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  21. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  22. package/telegram-plugin/narrative-flush.ts +35 -0
  23. package/telegram-plugin/outbox.ts +73 -3
  24. package/telegram-plugin/session-tail.ts +88 -1
  25. package/telegram-plugin/shown-ledger.ts +145 -0
  26. package/telegram-plugin/silence-poke.ts +118 -1
  27. package/telegram-plugin/silent-end.ts +42 -0
  28. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  29. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  30. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  31. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  32. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  34. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  35. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  36. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  37. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  38. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  40. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  41. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  42. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  43. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  44. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  45. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  46. package/telegram-plugin/tts-normalize.ts +12 -0
  47. package/telegram-plugin/turn-flush-safety.ts +66 -53
  48. package/telegram-plugin/voice-normalize-text.ts +100 -0
  49. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Rich-message inbound handler (forwarded-body drop fix).
3
+ *
4
+ * THE BUG THIS CLOSES (evidence: carrie history.db row message_id=944,
5
+ * 2026-07-24 06:03; gateway-supervisor.log update_id=417526125
6
+ * `content_keys=[forward_from,forward_date,rich_message]`): a message whose
7
+ * content is a Bot API 10.1 `rich_message` — typically a FORWARDED bot
8
+ * message, since the gateway itself sends everything via `sendRichMessage` —
9
+ * carries NO top-level `text` or `caption`. It therefore matched none of the
10
+ * registered `message:text` / `:photo` / `:caption` handlers, fell through to
11
+ * the terminal catch-all (`unhandled-message.ts`), and the agent received the
12
+ * placeholder `(unhandled message content: forward_from)` instead of the real
13
+ * body. The forwarded content was silently dropped.
14
+ *
15
+ * grammy ^1.44 DOES ship a `message:rich_message` filter (see
16
+ * `@grammyjs/types` `RichMessage` / `RichBlock` / `RichText`) — the gateway
17
+ * simply never registered it. This module renders the inbound block tree to
18
+ * plain markdown-ish text deterministically and routes it through the SAME
19
+ * inbound pipeline as `message:text` (access gating + forward-origin parsing
20
+ * happen downstream, unchanged — origin metadata stays on the trusted
21
+ * `<channel>`-attr lane per #3162; nothing here injects provenance into the
22
+ * body).
23
+ *
24
+ * Registration stays in gateway.ts (order pinned by
25
+ * gateway-handler-registration-wiring.test.ts): `message:rich_message` is
26
+ * registered BEFORE the terminal catch-all, so the catch-all no longer sees
27
+ * these messages at all. `planUnhandledMessage` also imports
28
+ * `extractRichMessageText` as belt-and-braces for any future shape that
29
+ * carries a `rich_message` alongside unknown content.
30
+ */
31
+
32
+ import type { Context, Filter } from 'grammy'
33
+ import type { MediaEnvelopeDeps } from './media-message-handlers.js'
34
+
35
+ /**
36
+ * Delivered body when a rich message renders to nothing extractable (e.g. a
37
+ * pure media/divider composition). Distinct from the catch-all's
38
+ * `(unhandled message content: …)` placeholder — this one names the actual
39
+ * situation and still yields a turn (fail-toward-delivery).
40
+ */
41
+ export const RICH_MESSAGE_EMPTY_TEXT = '(rich message with no extractable text)'
42
+
43
+ /** Recursion guard: the Bot API caps rich messages at 16 nesting levels; a
44
+ * hostile/malformed payload deeper than this is truncated, not stack-overflowed. */
45
+ const MAX_DEPTH = 32
46
+
47
+ interface RichTextNode {
48
+ type?: string
49
+ text?: unknown
50
+ expression?: string
51
+ alternative_text?: string
52
+ name?: string
53
+ }
54
+
55
+ /**
56
+ * Flatten a `RichText` union (string | RichText[] | typed node) to plain
57
+ * text. Inline formatting (bold/italic/…) is dropped — the agent needs the
58
+ * CONTENT; re-marking inline spans risks re-introducing accidental-formatting
59
+ * classes the render guards exist to kill. Leaf specials keep their readable
60
+ * payload: custom emoji → alternative text, math → LaTeX source, anchors →
61
+ * nothing.
62
+ */
63
+ export function renderRichText(rt: unknown, depth = 0): string {
64
+ if (depth > MAX_DEPTH || rt == null) return ''
65
+ if (typeof rt === 'string') return rt
66
+ if (Array.isArray(rt)) return rt.map(t => renderRichText(t, depth + 1)).join('')
67
+ if (typeof rt !== 'object') return ''
68
+ const node = rt as RichTextNode
69
+ if (node.type === 'custom_emoji') return node.alternative_text ?? ''
70
+ if (node.type === 'mathematical_expression') {
71
+ return typeof node.expression === 'string' ? node.expression : ''
72
+ }
73
+ if (node.type === 'anchor') return ''
74
+ // Every other RichText node (bold, italic, url, mention, code, …) wraps a
75
+ // `text: RichText` payload — recurse into it.
76
+ if ('text' in node) return renderRichText(node.text, depth + 1)
77
+ return ''
78
+ }
79
+
80
+ interface RichBlockNode {
81
+ type?: string
82
+ text?: unknown
83
+ credit?: unknown
84
+ language?: string
85
+ expression?: string
86
+ summary?: unknown
87
+ blocks?: unknown
88
+ items?: Array<{ label?: string; blocks?: unknown; has_checkbox?: boolean; is_checked?: boolean }>
89
+ cells?: Array<Array<{ text?: unknown }>>
90
+ caption?: { text?: unknown; credit?: unknown } | unknown
91
+ }
92
+
93
+ function renderCaption(caption: unknown, depth: number): string {
94
+ if (caption == null || typeof caption !== 'object') return renderRichText(caption, depth)
95
+ const c = caption as { text?: unknown; credit?: unknown }
96
+ const text = renderRichText(c.text, depth)
97
+ const credit = renderRichText(c.credit, depth)
98
+ return [text, credit ? `— ${credit}` : ''].filter(Boolean).join(' ')
99
+ }
100
+
101
+ function renderBlocks(blocks: unknown, depth: number): string {
102
+ if (depth > MAX_DEPTH || !Array.isArray(blocks)) return ''
103
+ return blocks
104
+ .map(b => renderRichBlock(b, depth + 1))
105
+ .filter(s => s.length > 0)
106
+ .join('\n')
107
+ }
108
+
109
+ /** Render one `RichBlock` to a plain-text line/paragraph. Unknown block types
110
+ * degrade to their `text` / nested `blocks` when present (fail-toward-content:
111
+ * a future block type should surface its words, not vanish). */
112
+ export function renderRichBlock(block: unknown, depth = 0): string {
113
+ if (depth > MAX_DEPTH || block == null || typeof block !== 'object') return ''
114
+ const b = block as RichBlockNode
115
+ switch (b.type) {
116
+ case 'paragraph':
117
+ case 'heading':
118
+ case 'footer':
119
+ return renderRichText(b.text, depth)
120
+ case 'pre': {
121
+ const body = renderRichText(b.text, depth)
122
+ return body.length > 0 ? `\`\`\`${b.language ?? ''}\n${body}\n\`\`\`` : ''
123
+ }
124
+ case 'divider':
125
+ return '---'
126
+ case 'mathematical_expression':
127
+ return typeof b.expression === 'string' ? b.expression : ''
128
+ case 'anchor':
129
+ return ''
130
+ case 'list': {
131
+ if (!Array.isArray(b.items)) return ''
132
+ return b.items
133
+ .map(item => {
134
+ const body = renderBlocks(item?.blocks, depth)
135
+ const check = item?.has_checkbox ? (item.is_checked ? '[x] ' : '[ ] ') : ''
136
+ const label = typeof item?.label === 'string' && item.label.length > 0 ? item.label : '-'
137
+ return body.length > 0 ? `${label} ${check}${body}` : ''
138
+ })
139
+ .filter(s => s.length > 0)
140
+ .join('\n')
141
+ }
142
+ case 'blockquote': {
143
+ const body = renderBlocks(b.blocks, depth)
144
+ const credit = renderRichText(b.credit, depth)
145
+ const quoted = body
146
+ .split('\n')
147
+ .map(line => `> ${line}`)
148
+ .join('\n')
149
+ return [quoted, credit ? `> — ${credit}` : ''].filter(s => s.length > 0).join('\n')
150
+ }
151
+ case 'pullquote': {
152
+ const body = renderRichText(b.text, depth)
153
+ const credit = renderRichText(b.credit, depth)
154
+ return [body, credit ? `— ${credit}` : ''].filter(Boolean).join(' ')
155
+ }
156
+ case 'details': {
157
+ const summary = renderRichText(b.summary, depth)
158
+ const body = renderBlocks(b.blocks, depth)
159
+ return [summary, body].filter(s => s.length > 0).join('\n')
160
+ }
161
+ case 'collage':
162
+ case 'slideshow': {
163
+ const body = renderBlocks(b.blocks, depth)
164
+ const caption = renderCaption(b.caption, depth)
165
+ return [body, caption].filter(s => s.length > 0).join('\n')
166
+ }
167
+ case 'table': {
168
+ if (!Array.isArray(b.cells)) return ''
169
+ const rows = b.cells
170
+ .map(row =>
171
+ Array.isArray(row)
172
+ ? row.map(cell => renderRichText(cell?.text, depth)).join(' | ')
173
+ : '',
174
+ )
175
+ .filter(s => s.length > 0)
176
+ .join('\n')
177
+ const caption = renderRichText((b as { caption?: unknown }).caption, depth)
178
+ return [caption, rows].filter(s => s.length > 0).join('\n')
179
+ }
180
+ case 'map':
181
+ case 'animation':
182
+ case 'audio':
183
+ case 'photo':
184
+ case 'video':
185
+ case 'voice_note': {
186
+ // Media blocks: the binary itself is not downloaded here (a forwarded
187
+ // rich message's media has no coalescing/attachment plumbing yet — see
188
+ // PR description follow-up); the caption text IS the readable content.
189
+ const caption = renderCaption(b.caption, depth)
190
+ const tag = `[${b.type}]`
191
+ return caption.length > 0 ? `${tag} ${caption}` : tag
192
+ }
193
+ case 'thinking':
194
+ return ''
195
+ default: {
196
+ // Unknown/future block type — surface any text-ish payload it carries.
197
+ const text = renderRichText(b.text, depth)
198
+ if (text.length > 0) return text
199
+ return renderBlocks(b.blocks, depth)
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Extract the readable text of an inbound `message.rich_message`, or
206
+ * `undefined` when nothing extractable exists. Accepts `unknown` — the value
207
+ * arrives from the Telegram wire and must never throw the handler.
208
+ */
209
+ export function extractRichMessageText(rich: unknown): string | undefined {
210
+ if (rich == null || typeof rich !== 'object') return undefined
211
+ const blocks = (rich as { blocks?: unknown }).blocks
212
+ const rendered = renderBlocks(blocks, 0)
213
+ .replace(/\n{3,}/g, '\n\n')
214
+ .trim()
215
+ return rendered.length > 0 ? rendered : undefined
216
+ }
217
+
218
+ /**
219
+ * `message:rich_message` handler — renders the body and hands it to the
220
+ * normal COALESCING inbound pipeline, identical to `message:text`. It shares
221
+ * the cluster-A `MediaEnvelopeDeps` SHAPE, but gateway.ts binds this handler's
222
+ * `deps.handleInbound` to `handleInboundCoalesced` via an inline spread at the
223
+ * registration site — `{ ...mediaEnvelopeDeps, handleInbound: handleInboundCoalesced }`
224
+ * — NOT the bare `handleInbound` the media-envelope handlers use: a rich
225
+ * message is pure forwarded body text with no attachment, so a forwarded bot
226
+ * message arriving in the same sliding window as another inbound folds into one
227
+ * turn (same coalescing contract as `message:text`). Access gating +
228
+ * forward-origin parsing happen downstream, unchanged.
229
+ */
230
+ export async function handleRichMessageMessage(
231
+ ctx: Filter<Context, 'message:rich_message'>,
232
+ deps: MediaEnvelopeDeps,
233
+ ): Promise<void> {
234
+ try {
235
+ const text = extractRichMessageText(ctx.message.rich_message) ?? RICH_MESSAGE_EMPTY_TEXT
236
+ deps.log(`telegram gateway: inbound rich_message from chat=${ctx.chat?.id ?? '?'} chars=${text.length}\n`)
237
+ await deps.handleInbound(ctx, text, undefined)
238
+ } catch (err) {
239
+ deps.log(`telegram gateway: rich_message handler error: ${(err as Error).message}\n`)
240
+ }
241
+ }
@@ -0,0 +1,89 @@
1
+ // #1122 silence-poke session-event wiring.
2
+ //
3
+ // Extracted from gateway.ts (the file is under a hard line ratchet — see
4
+ // switchroom#2996). Maps a parsed session event onto the silence-poke activity
5
+ // registry so the 300s framework-fallback message wording is honest (thinking
6
+ // vs working, plus the longest-running in-flight tool), threads the #1445
7
+ // cross-turn pending-async ambient, and feeds the #3519 background-shell
8
+ // liveness signal. Called once per session event from the gateway's event loop
9
+ // while a turn is live (the caller owns the `currentTurn != null` guard and the
10
+ // `key` derivation).
11
+ import type { SessionEvent } from '../session-tail.js'
12
+ import { isTelegramSurfaceTool } from '../tool-names.js'
13
+ import { toolLabel } from '../tool-labels.js'
14
+ import { applyBackgroundShellLiveness } from './background-shell-liveness.js'
15
+
16
+ /** The silence-poke module namespace (kept as `typeof` so no surface drift). */
17
+ type SilencePoke = typeof import('../silence-poke.js')
18
+ /** The pending-work-progress module namespace. */
19
+ type PendingProgress = typeof import('../pending-work-progress.js')
20
+
21
+ /**
22
+ * Apply the activity signals carried by `ev` to silence-poke / pending-progress
23
+ * for the live turn identified by `key`. Behaviour is identical to the inline
24
+ * block this replaced — a pure extract/move.
25
+ */
26
+ export function applySilencePokeSessionEvent(
27
+ silencePoke: SilencePoke,
28
+ pendingProgress: PendingProgress,
29
+ key: string,
30
+ ev: SessionEvent,
31
+ ): void {
32
+ if (ev.kind === 'thinking') {
33
+ silencePoke.noteThinking(key, Date.now())
34
+ } else if (ev.kind === 'tool_use') {
35
+ // #1292: track in-flight tool calls so the 300s framework
36
+ // fallback message can name the actual observable (e.g.
37
+ // "running Grep \"foo\" for 4m") instead of the dishonest
38
+ // generic "still working… no update in 5 min" when the agent
39
+ // is clearly busy on tool calls. Telegram-surface tools are
40
+ // excluded — their job IS the outbound message, the silence
41
+ // clock resets via noteOutbound when they fire. Sub-agent
42
+ // tool_use events (kind='sub_agent_tool_use') intentionally
43
+ // NOT tracked: the parent's Task tool_use is already on the
44
+ // map and represents the user-observable wait.
45
+ if (
46
+ ev.toolUseId != null
47
+ && ev.toolUseId.length > 0
48
+ && !isTelegramSurfaceTool(ev.toolName)
49
+ ) {
50
+ const label = toolLabel(
51
+ ev.toolName,
52
+ ev.input,
53
+ /*preamble*/ undefined,
54
+ ev.precomputedLabel,
55
+ )
56
+ silencePoke.noteToolStart(
57
+ key,
58
+ ev.toolUseId,
59
+ ev.toolName,
60
+ label.length > 0 ? label : null,
61
+ Date.now(),
62
+ )
63
+ // #1445 cross-turn pending-async ambient. Mark the chat as
64
+ // having dispatched background work this turn so a turn_end
65
+ // that follows activates the edit-in-place ambient line.
66
+ // Covers `Agent` / `Task` (the harness-managed async path
67
+ // — handback channel turn clears it) and `Bash` with
68
+ // run_in_background:true (model is expected to poll
69
+ // BashOutput; the ambient ticks until next inbound or the
70
+ // 30-min budget cap).
71
+ const evInput = ev.input as { run_in_background?: boolean } | undefined
72
+ if (
73
+ ev.toolName === 'Agent'
74
+ || ev.toolName === 'Task'
75
+ || (ev.toolName === 'Bash' && evInput?.run_in_background === true)
76
+ ) {
77
+ pendingProgress.noteAsyncDispatch(key)
78
+ }
79
+ }
80
+ } else if (ev.kind === 'tool_result') {
81
+ // #1292: drain the in-flight entry. Idempotent on unknown ids
82
+ // (covers Telegram-surface tools we skipped at start time).
83
+ if (ev.toolUseId != null && ev.toolUseId.length > 0) {
84
+ silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
85
+ }
86
+ }
87
+ // #3519 sharpen: feed background-shell liveness (ALIVE/DEAD) to silence-poke — see background-shell-liveness.ts.
88
+ applyBackgroundShellLiveness(silencePoke, key, ev)
89
+ }
@@ -59,11 +59,15 @@ import { normalizeOutboundBody } from './outbound-send-path.js'
59
59
  import { resolveEnvTimezone } from '../shared/local-time.js'
60
60
  import { hasOutboundDeliveredSince, recordOutbound } from '../history.js'
61
61
  import { isReplyTool } from '../narrative-dedup.js'
62
+ import { isEphemeralTool } from '../hooks/narration-classify.mjs'
63
+ import { backstopAlreadyDelivered } from '../outbox.js'
64
+ import { journalExternalDelivery } from './outbox-sweep.js'
62
65
  import { NarrativeFlushController } from '../narrative-flush.js'
63
66
  import { recordTurnEnd, recordTurnStart } from '../registry/turns-schema.js'
64
67
  import { retryWithThreadFallback } from '../retry-api-call.js'
65
68
  import { richMessage } from '../rich-send.js'
66
69
  import { emitRuntimeMetric } from '../runtime-metrics.js'
70
+ import { isShownBlock } from '../shown-ledger.js'
67
71
  import { CAPTURED_PROSE_MIN_CHARS, clearSilentEndState, decideCapturedProseDelivery, recordUndeliveredTurnEnd, silentEndFallbackText, writeSilentEndState } from '../silent-end.js'
68
72
  import { logStreamingEvent } from '../streaming-metrics.js'
69
73
  import { appendActivityLabel } from '../tool-activity-summary.js'
@@ -583,11 +587,21 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
583
587
  case 'tool_use': {
584
588
  const turn = getCurrentTurn()
585
589
  if (turn == null) return
586
- // PR A — the model resumed work (surface or otherwise). Cancel any pending
587
- // answer-ready quiescence flush: the turn is no longer quiescent. (Fire-time
588
- // re-verification would also catch this, but disarming here avoids a wasted
589
- // wakeup and matches the design's disarm-on-tool requirement.)
590
- clearAnswerReadyFlushTimeout(turn)
590
+ // #3513 follow-up (MF1 + MF4b) — a TURN-CONTINUING tool_use (any tool NOT
591
+ // in the ephemeral surface set) is the deterministic signal that every text
592
+ // block captured so far in this turn was intra-turn narration, not the
593
+ // terminal answer. Retro-mark ALL existing captured blocks
594
+ // (`capturedBlockMeta.fill(true)`) so the cross-message shape
595
+ // ([text-only message] → [tool_use in the NEXT message]) is actually seen
596
+ // by `selectBackstopDelivery` — the per-message `!ev.lastInMessage` push at
597
+ // the `text` case under-detects it. The SAME ephemeral gate protects the
598
+ // E2 answer-ready fast path: a trailing ephemeral tool (answer, then
599
+ // react / pin / typing / edit / delete) must neither mark the answer
600
+ // interim NOR disarm the quiescence flush.
601
+ if (!isEphemeralTool(ev.toolName)) {
602
+ turn.capturedBlockMeta.fill(true)
603
+ clearAnswerReadyFlushTimeout(turn)
604
+ }
591
605
  // Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
592
606
  // narrative block was pending; this tool_use is the lookahead event
593
607
  // that decides it. reply/stream_reply with near-identical text ⇒
@@ -678,8 +692,13 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
678
692
  // PR A — a tool_label (real-time, ~250 ms) means the model is producing
679
693
  // work right now: cancel any pending answer-ready quiescence flush (the
680
694
  // turn is not quiescent). Fires ahead of the JSONL tool_use, so it disarms
681
- // the timer at the earliest deterministic point.
682
- clearAnswerReadyFlushTimeout(turn)
695
+ // the timer at the earliest deterministic point. MF4b: an EPHEMERAL surface
696
+ // tool (react / pin / typing / edit / delete fired after a terminal answer)
697
+ // must NOT disarm the quiescence flush — gate on the same ephemeral check as
698
+ // the tool_use reducer so answer-then-react keeps the E2 fast path.
699
+ if (!isEphemeralTool(ev.toolName)) {
700
+ clearAnswerReadyFlushTimeout(turn)
701
+ }
683
702
  // SECONDARY FIX: an active tool_label means the model is producing work
684
703
  // right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
685
704
  // (write → compile → test → fix) that regularly emits labels doesn't let
@@ -826,6 +845,20 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
826
845
  // negation is the draft-then-send narration signal the turn-flush strip
827
846
  // uses (`selectFlushDeliveryText`) to keep a real answer intact instead
828
847
  // of truncating a paragraph that merely opens with "Let me explain…".
848
+ //
849
+ // NOTE (naming/approximation, #3515 review nit): downstream this value is
850
+ // consumed as `followedByToolUse`, but `!ev.lastInMessage` is a CONSERVATIVE
851
+ // APPROXIMATION of that predicate, not an exact match. It is true when the
852
+ // block is not the last block in its assistant message OR when a tool_use
853
+ // follows it in the same turn — i.e. it can over-flag: a block that is
854
+ // genuinely last-in-message may still be marked true. Over-approximation is
855
+ // SAFE here by construction: a `true` flag only ever makes a block a
856
+ // candidate for structural-narration suppression (selectFlushDeliveryText /
857
+ // isStructuralNarration), so the worst case is suppressing MORE narration —
858
+ // it can never promote an answer into the drop path. A real terminal answer
859
+ // is protected independently (it is never followed by a tool and survives
860
+ // the strip). Do not "tighten" this to the exact predicate expecting a
861
+ // behavioural change: the runtime value is deliberately conservative.
829
862
  turn.capturedBlockMeta.push(!ev.lastInMessage)
830
863
  // Narrative-dedup gate step 1 (JSONL-text-narrative primitive):
831
864
  // stage this text block for one lookahead step. If a previous block
@@ -1739,6 +1772,24 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1739
1772
  } catch {}
1740
1773
  }
1741
1774
 
1775
+ // #3513 follow-up (MF2) — DURABLE exactly-once-among-backstops read.
1776
+ // The in-memory `backstopDeliveryLedger` (guard 5, below) only sees
1777
+ // fires within THIS process; it cannot see a prior backstop delivery
1778
+ // that landed in an earlier process (e.g. the Stop-hook captured-prose
1779
+ // bridge E3, or the outbox sweep E4, delivered this turn's answer, then
1780
+ // the gateway restarted and re-ran turn-flush). `backstopAlreadyDelivered`
1781
+ // scans the durable delivered-keys journal counting ONLY prior BACKSTOP
1782
+ // deliveries (sweep / flush / non-E0 reply-tool) for this nonce — it does
1783
+ // NOT count an explicit E0 reply (#3510 recap), so a legitimate later
1784
+ // explicit reply is never blocked by this guard. If a backstop already
1785
+ // delivered, this fire is a durable no-op.
1786
+ if (backstopAlreadyDelivered(turn.turnId, STATE_DIR)) {
1787
+ process.stderr.write(
1788
+ `telegram gateway: turn-flush skipped — turn ${turn.turnId} already delivered by a prior backstop (durable journal)\n`,
1789
+ )
1790
+ return
1791
+ }
1792
+
1742
1793
  // #3276 guard 5 — double-fire guard. If this turn already claimed the
1743
1794
  // delivery latch (a prior backstop fire — e.g. answer-ready quiescence
1744
1795
  // followed by the turn-end backstop for the same turn), do NOT deliver
@@ -1869,6 +1920,33 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1869
1920
  chunkCount,
1870
1921
  cardMessageId: backstopCardMessageId,
1871
1922
  })
1923
+ // #3513 follow-up (MF2) — DURABLE exactly-once-among-backstops
1924
+ // WRITE. Journal this backstop delivery to the delivered-keys
1925
+ // journal with `deliverySource:'flush'` so a later backstop in a
1926
+ // DIFFERENT process (the Stop-hook bridge E3 or the outbox sweep E4,
1927
+ // which read `backstopAlreadyDelivered`) recognises this turn's
1928
+ // answer as already delivered and skips a duplicate durably — not
1929
+ // only via the in-memory ledger, which does not survive a crash
1930
+ // between this send and the next process's backstop. Journal ONLY on
1931
+ // a receipt-gated `delivered` success. Best-effort: a journal-write
1932
+ // failure must never demote the successful delivery.
1933
+ if (delivered) {
1934
+ try {
1935
+ journalExternalDelivery(
1936
+ {
1937
+ turnNonce: turn.turnId,
1938
+ text: capturedText,
1939
+ tgMessageId: sentIds.length > 0 ? sentIds[0] : undefined,
1940
+ deliverySource: 'flush',
1941
+ },
1942
+ STATE_DIR,
1943
+ )
1944
+ } catch (err) {
1945
+ process.stderr.write(
1946
+ `telegram gateway: turn-flush delivered but journal write failed (non-fatal): ${(err as Error).message}\n`,
1947
+ )
1948
+ }
1949
+ }
1872
1950
  if (OBLIGATION_LEDGER_ENABLED) {
1873
1951
  if (delivered) {
1874
1952
  obligationLedger.close(turn.turnId)
@@ -2012,14 +2090,28 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2012
2090
  const proseMinChars =
2013
2091
  !turn.replyCalled && gatewayCapturedEmpty ? 1 : CAPTURED_PROSE_MIN_CHARS
2014
2092
  const proseDecision = CAPTURED_PROSE_DELIVERY_ENABLED
2015
- ? decideCapturedProseDelivery({
2016
- turnKey: tKey,
2017
- // Per-turn nonce (#3228 Finding 3) — the persisted record must
2018
- // belong to THIS turn, not a stale carryover from a prior turn
2019
- // on the same chat/thread (tKey is not per-turn unique).
2020
- turnId: turn.turnId,
2021
- minChars: proseMinChars,
2022
- })
2093
+ ? decideCapturedProseDelivery(
2094
+ {
2095
+ turnKey: tKey,
2096
+ // Per-turn nonce (#3228 Finding 3) — the persisted record must
2097
+ // belong to THIS turn, not a stale carryover from a prior turn
2098
+ // on the same chat/thread (tKey is not per-turn unique).
2099
+ turnId: turn.turnId,
2100
+ minChars: proseMinChars,
2101
+ },
2102
+ {
2103
+ // #3513 (correction 1): refuse to bridge a block already
2104
+ // surfaced on the ephemeral card for this turn (shown-ledger).
2105
+ isBlockShown: (nonce, text) => isShownBlock(nonce ?? null, text),
2106
+ // #3513 follow-up (MF2): refuse to bridge a turn a prior
2107
+ // backstop (turn-flush E1/E2 flush, or the outbox sweep E4)
2108
+ // already delivered — durable exactly-once-among-backstops,
2109
+ // scoped to backstop deliveries only (never an explicit E0
2110
+ // reply, so a genuine later reply is unaffected).
2111
+ backstopDeliveredNonceHit: (nonce) =>
2112
+ backstopAlreadyDelivered(nonce ?? '', STATE_DIR),
2113
+ },
2114
+ )
2023
2115
  : { deliver: false as const, reason: 'no-state' as const }
2024
2116
  if (proseDecision.deliver && proseDecision.text != null) {
2025
2117
  // Deliver the recovered answer directly. This runs async and owns
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { Bot, Context } from 'grammy'
25
+ import { extractRichMessageText } from './rich-message-handler.js'
25
26
 
26
27
  /**
27
28
  * Top-level `message` envelope fields — identity/routing metadata, excluded
@@ -36,6 +37,14 @@ export const MESSAGE_ENVELOPE_KEYS: ReadonlySet<string> = new Set<string>([
36
37
  'business_connection_id', 'effect_id', 'has_protected_content',
37
38
  'is_from_offline', 'link_preview_options', 'show_caption_above_media',
38
39
  'entities', 'caption_entities', 'paid_star_count',
40
+ // Legacy (pre-Bot-API-7.0 spelling) forward metadata. Some wire payloads
41
+ // carry these ALONGSIDE `forward_origin` (observed live: carrie
42
+ // update_id=417526125, content_keys=[forward_from,forward_date,rich_message]).
43
+ // They are provenance metadata, not content — without this exclusion a
44
+ // forwarded unhandled message gets mislabeled `(unhandled message content:
45
+ // forward_from)` instead of naming its actual content type.
46
+ 'forward_from', 'forward_from_chat', 'forward_from_message_id',
47
+ 'forward_signature', 'forward_sender_name', 'forward_date',
39
48
  ])
40
49
 
41
50
  /**
@@ -88,6 +97,11 @@ export function planUnhandledMessage(msg: Record<string, unknown>): UnhandledMes
88
97
  const text =
89
98
  (typeof msg.text === 'string' ? msg.text : undefined) ??
90
99
  (typeof msg.caption === 'string' ? msg.caption : undefined) ??
100
+ // Belt-and-braces: `message:rich_message` has its own registered handler
101
+ // (rich-message-handler.ts), so a rich message normally never reaches the
102
+ // catch-all — but if one arrives ALONGSIDE unknown future content, its
103
+ // real body still beats a placeholder.
104
+ extractRichMessageText(msg.rich_message) ??
91
105
  `(unhandled message content: ${contentType})`
92
106
  return { action: 'turn', text, contentKeys }
93
107
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Type declarations for the shared trailing-text narration classifier
3
+ * (switchroom#3513) so the bundled TypeScript surfaces — `turn-flush-safety.ts`,
4
+ * `shown-ledger.ts`, `narrative-flush.ts` — can import the ONE classifier from
5
+ * `narration-classify.mjs` without tsc resolution errors. The runtime module is
6
+ * plain ESM (it is also imported by the unbundled Stop-hook `.mjs`, which can
7
+ * only import sibling `.mjs` + node builtins), so it cannot be authored in TS.
8
+ */
9
+ export const SUBSTANTIVE_MIN_CHARS: number
10
+ export const NARRATION_OPENER: RegExp
11
+ export const NARRATION_TRAILER: RegExp
12
+ export function isTrailingNarrationLine(block: string): boolean
13
+ export function isNarrationBlock(block: string): boolean
14
+ export function isStructuralNarration(
15
+ text: string,
16
+ followedByToolUse: boolean | undefined,
17
+ ): boolean
18
+ export const EPHEMERAL_TOOLS: Set<string>
19
+ export function isEphemeralTool(name: string | null | undefined): boolean
20
+ export function selectBackstopDelivery(
21
+ blocks: ReadonlyArray<{ text: string; followedByToolUse?: boolean }>,
22
+ ): { text: string } | null
23
+ export function ledgerHashHex(text: string): string