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,210 @@
1
+ /**
2
+ * narration-classify.mjs — the ONE shared trailing-text narration classifier
3
+ * (switchroom#3513).
4
+ *
5
+ * ## Why this file exists (correction 2)
6
+ * Before #3513 the narration heuristic existed as TWO hand-synced copies — the
7
+ * TS gateway side (`turn-flush-safety.ts` `NARRATION_OPENER` / `isNarrationBlock`
8
+ * / `selectFlushDeliveryText`) and the unbundled Stop-hook `.mjs` side
9
+ * (`silent-end-scan.mjs`, with a "MUST stay in sync" comment). Two independent
10
+ * classifiers judging the SAME trailing assistant text is exactly the drift
11
+ * hazard that let intent-narration ("Now checking the gateway logs…") leak into
12
+ * a delivered Telegram message. This module is the single source of truth,
13
+ * authored as a raw `.mjs` (JS + JSDoc types) so it can be imported by BOTH:
14
+ * - the unbundled Stop-hook `.mjs` (which can only import sibling `.mjs` +
15
+ * node builtins — see `silent-end-scan.mjs`), and
16
+ * - the bundled gateway TS (`turn-flush-safety.ts`) via `.mjs` interop (the
17
+ * same pattern `tool-activity-summary.ts` already uses to import
18
+ * `hooks/tool-label-pretool.mjs`).
19
+ *
20
+ * ## The primary rule — STRUCTURAL, substance-gated (corrections 3 + 4)
21
+ * The wording heuristic (`isNarrationBlock`) is provably incomplete (this bug is
22
+ * its counterexample). The deterministic, wording-independent discriminator is
23
+ * STRUCTURAL: did a `tool_use` follow this text block in the model's message?
24
+ * A narration preamble is drafted, then the model ACTS (a tool follows it); a
25
+ * terminal answer is not followed by a tool. `followedByToolUse === true` is a
26
+ * DEMOTION signal, NOT an unconditional narration verdict — it is gated by the
27
+ * substantive floor / opener heuristic so a SUBSTANTIVE real answer that merely
28
+ * precedes a tool call (the model answers, then reacts / pins / edits) still
29
+ * delivers. Only a short OR narration-shaped block that a tool followed is
30
+ * treated as non-deliverable. `followedByToolUse !== true` (false or absent) is
31
+ * never structural narration here — the caller applies the opener heuristic as a
32
+ * residual tie-breaker where it has no provenance.
33
+ */
34
+
35
+ import { createHash } from 'node:crypto'
36
+
37
+ /**
38
+ * Substantive-answer floor (chars, trimmed). The same bar the codebase uses
39
+ * everywhere (`final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS`,
40
+ * `turn-flush-safety.ts` `FLUSH_SUBSTANTIVE_MIN_CHARS`) to recognise "this block
41
+ * is a real answer, not a short narration/closer".
42
+ */
43
+ export const SUBSTANTIVE_MIN_CHARS = 200
44
+
45
+ /**
46
+ * Narration heuristic: a block that OPENS with a first-person "about to do X"
47
+ * phrase the model emits BEFORE composing its real answer ("Let me check…",
48
+ * "I'll look it up…", "Now let me…"). Deliberately NOT length-based.
49
+ */
50
+ export const NARRATION_OPENER =
51
+ /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
52
+
53
+ /**
54
+ * A NON-terminal progress line that opens with a gerund and trails into an
55
+ * ellipsis/colon: "Checking now…", "Pulling the numbers:". A single short line
56
+ * (under the substantive floor, no internal paragraph) ending in `…`/`...`/`:`.
57
+ */
58
+ export const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
59
+
60
+ /**
61
+ * @param {string} block
62
+ * @returns {boolean}
63
+ */
64
+ export function isTrailingNarrationLine(block) {
65
+ const t = typeof block === 'string' ? block.trim() : ''
66
+ if (t.length === 0 || t.length >= SUBSTANTIVE_MIN_CHARS) return false
67
+ if (t.includes('\n')) return false
68
+ return NARRATION_TRAILER.test(t)
69
+ }
70
+
71
+ /**
72
+ * The wording heuristic (residual tie-breaker for provenance-less callers).
73
+ * @param {string} block
74
+ * @returns {boolean}
75
+ */
76
+ export function isNarrationBlock(block) {
77
+ const s = typeof block === 'string' ? block : ''
78
+ return NARRATION_OPENER.test(s.trimStart()) || isTrailingNarrationLine(s)
79
+ }
80
+
81
+ /**
82
+ * The #3513 PRIMARY rule: is this block structural intra-turn narration?
83
+ *
84
+ * TRUE only when a `tool_use` followed the block in the model's message
85
+ * (`followedByToolUse === true`) AND the block also reads as narration OR falls
86
+ * below the substantive floor. This preserves the #3237 asymmetry:
87
+ * - `followedByToolUse === true` → demotion, GATED by substance/heuristic
88
+ * (a substantive non-narration block followed by react/pin/typing/edit is
89
+ * NOT structural narration → still delivers).
90
+ * - `followedByToolUse === false` → never structural narration (a terminal
91
+ * block; nothing followed it).
92
+ * - `followedByToolUse` absent → never structural narration here; the
93
+ * caller applies `isNarrationBlock` as a residual tie-breaker.
94
+ *
95
+ * @param {string} text
96
+ * @param {boolean | undefined} followedByToolUse
97
+ * @returns {boolean}
98
+ */
99
+ export function isStructuralNarration(text, followedByToolUse) {
100
+ if (followedByToolUse !== true) return false
101
+ const t = typeof text === 'string' ? text.trim() : ''
102
+ if (t.length === 0) return true
103
+ return isNarrationBlock(t) || t.length < SUBSTANTIVE_MIN_CHARS
104
+ }
105
+
106
+ /**
107
+ * Ephemeral (non-turn-continuing) Telegram surface tools (switchroom#3513
108
+ * follow-up, MF4). A text block followed ONLY by tools in this set is still the
109
+ * TERMINAL answer for backstop-coalescing purposes — these tools carry no
110
+ * model-authored answer text and are the only ones plausibly fired AFTER a
111
+ * terminal answer (answer, then react / pin / typing / edit). Everything NOT in
112
+ * this set — work / deliverable tools (download_attachment, get_recent_messages,
113
+ * send_checklist, ask_user, …) and every non-telegram tool (retain / read /
114
+ * bash / web / …) — is turn-CONTINUING: a text block a real tool followed is
115
+ * intra-turn narration, never the terminal answer.
116
+ *
117
+ * Authoritative source: `bridge/bridge.ts` TOOL_SCHEMAS. `reply` / `stream_reply`
118
+ * are deliberately absent — they are handled by the `replyCalled` gate, not this
119
+ * list. `progress_update` is NOT a bridge tool (it is a runtime-metrics /
120
+ * sub-agent parent-card send site) and never appears as a turn `tool_use`, so it
121
+ * is intentionally excluded.
122
+ */
123
+ export const EPHEMERAL_TOOLS = new Set([
124
+ 'react',
125
+ 'send_typing',
126
+ 'pin_message',
127
+ 'delete_message',
128
+ 'edit_message',
129
+ ])
130
+
131
+ /**
132
+ * True iff `name` is an ephemeral Telegram surface tool (see EPHEMERAL_TOOLS).
133
+ * Strips the host-chosen `mcp__<key>__` MCP prefix (matching `tool-names.ts`'s
134
+ * `stripPrefix`) so it works regardless of the registration key
135
+ * (`switchroom-telegram`, `clerk-telegram`, a fork's custom key).
136
+ *
137
+ * @param {string | null | undefined} name
138
+ * @returns {boolean}
139
+ */
140
+ export function isEphemeralTool(name) {
141
+ if (typeof name !== 'string') return false
142
+ const suffix = name.replace(/^mcp__[^_].*?telegram__/, '')
143
+ return EPHEMERAL_TOOLS.has(suffix)
144
+ }
145
+
146
+ /**
147
+ * The deterministic, model-discipline-free backstop coalescer (switchroom#3513
148
+ * follow-up). Selects the ONE text a per-turn backstop (turn-flush E1/E2,
149
+ * captured-prose bridge E3, outbox sweep E4) should deliver from all captured /
150
+ * scanned assistant text blocks of a turn.
151
+ *
152
+ * `blocks` is in source order; each `{ text, followedByToolUse }` carries the
153
+ * per-TURN structural provenance (`followedByToolUse === true` ⇔ a
154
+ * turn-CONTINUING tool_use — a non-ephemeral, non-reply tool — arrived after
155
+ * this block, ANYWHERE later in the turn, not only later in its own message).
156
+ * `undefined` = provenance unknown → treated as NOT followed (fail OPEN: deliver,
157
+ * never drop).
158
+ *
159
+ * Rules:
160
+ * 1. TERMINAL RUN — the maximal SUFFIX of non-empty blocks with
161
+ * `followedByToolUse !== true`. Non-empty → return its `\n\n` join. A
162
+ * legitimate multi-paragraph answer written as several consecutive terminal
163
+ * blocks stays whole (no truncation; #3237/#2798 parity).
164
+ * 2. UNCONDITIONAL structural suppression — every block with
165
+ * `followedByToolUse === true` is excluded from the terminal run, with NO
166
+ * length gate and NO opener/wording heuristic. "A real tool followed" is a
167
+ * hard structural fact the turn continued past this block.
168
+ * 3. EMPTY-TERMINAL corner — if the terminal run is empty (the LAST non-empty
169
+ * block was itself tool-followed), return that last block IFF its trimmed
170
+ * length ≥ SUBSTANTIVE_MIN_CHARS, else null. A short tool-followed fragment
171
+ * is narration/a closer → null routes to the re-prompt ladder, never silence.
172
+ *
173
+ * @param {ReadonlyArray<{ text: string, followedByToolUse?: boolean }>} blocks
174
+ * @returns {{ text: string } | null} null = nothing to deliver.
175
+ */
176
+ export function selectBackstopDelivery(blocks) {
177
+ const nonEmpty = (Array.isArray(blocks) ? blocks : [])
178
+ .map((b) => ({
179
+ text: typeof b?.text === 'string' ? b.text.trim() : '',
180
+ followedByToolUse: b?.followedByToolUse,
181
+ }))
182
+ .filter((b) => b.text.length > 0)
183
+ if (nonEmpty.length === 0) return null
184
+
185
+ // Rule 1 + 2: the terminal SUFFIX run of non-tool-followed blocks.
186
+ const run = []
187
+ for (let i = nonEmpty.length - 1; i >= 0; i--) {
188
+ if (nonEmpty[i].followedByToolUse === true) break
189
+ run.unshift(nonEmpty[i].text)
190
+ }
191
+ if (run.length > 0) return { text: run.join('\n\n') }
192
+
193
+ // Rule 3: empty terminal — the last block was tool-followed. Deliver it only
194
+ // if it clears the substantive floor; otherwise it is a narration fragment.
195
+ const last = nonEmpty[nonEmpty.length - 1]
196
+ if (last.text.length >= SUBSTANTIVE_MIN_CHARS) return { text: last.text }
197
+ return null
198
+ }
199
+
200
+ /**
201
+ * The durable shown-ledger key for a block: sha256 of its trimmed text. Matches
202
+ * the hash both the ephemeral-paint writer and the backstop-delivery readers
203
+ * compute, so a marked block is recognised across processes (#3513 §4).
204
+ *
205
+ * @param {string} text
206
+ * @returns {string}
207
+ */
208
+ export function ledgerHashHex(text) {
209
+ return createHash('sha256').update(String(text ?? '').trim(), 'utf8').digest('hex')
210
+ }
@@ -58,12 +58,18 @@
58
58
  import { statSync } from 'node:fs'
59
59
  import { join } from 'node:path'
60
60
  import { createHash } from 'node:crypto'
61
+ import {
62
+ isNarrationBlock,
63
+ isEphemeralTool,
64
+ selectBackstopDelivery,
65
+ SUBSTANTIVE_MIN_CHARS,
66
+ } from './narration-classify.mjs'
61
67
 
62
68
  const REPLY_TOOLS = new Set([
63
69
  'mcp__switchroom-telegram__reply',
64
70
  'mcp__switchroom-telegram__stream_reply',
65
71
  ])
66
- const FINAL_ANSWER_MIN_CHARS = 200
72
+ const FINAL_ANSWER_MIN_CHARS = SUBSTANTIVE_MIN_CHARS
67
73
  // Match the gateway's silent-marker classifier (gateway.ts:6692 — the
68
74
  // `isSilentFlushMarker` helper accepts trailing punctuation + case
69
75
  // variants like "NO_REPLY." / "no_reply").
@@ -101,29 +107,13 @@ export function endsWithSilentMarker(text) {
101
107
  return SILENT_MARKER_RE.test(lines[lines.length - 1])
102
108
  }
103
109
 
104
- // ── Narration heuristics — ported from `turn-flush-safety.ts:198-274` ──
105
- //
106
- // Kept byte-parallel with `selectFlushDeliveryText` / `isNarrationBlock` so
107
- // the JOINED multi-block prose the scan persists as `pendingText` (for the
108
- // capture-divergence corner) matches what the gateway flush would itself have
109
- // delivered. The scan has no structural `followedByToolUse` provenance, so it
110
- // uses the opener/trailer heuristic fallback (the same branch the TS side
111
- // takes when the flag is absent). MUST stay in sync with the TS source; a
112
- // drift only affects the rare capture-empty corner, never the primary flush.
113
- const NARRATION_OPENER =
114
- /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
115
- const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
116
-
117
- function isTrailingNarrationLine(block) {
118
- const t = block.trim()
119
- if (t.length === 0 || t.length >= FINAL_ANSWER_MIN_CHARS) return false
120
- if (t.includes('\n')) return false
121
- return NARRATION_TRAILER.test(t)
122
- }
123
-
124
- function isNarrationBlock(block) {
125
- return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
126
- }
110
+ // The narration heuristic (`isNarrationBlock`) and the STRUCTURAL rule
111
+ // (`isStructuralNarration`) live in the ONE shared classifier
112
+ // `./narration-classify.mjs`, imported at the top. Before switchroom#3513 this
113
+ // `.mjs` carried a byte-parallel COPY of the TS heuristic with a "MUST stay in
114
+ // sync" comment — the exact hand-synced drift that let intent-narration leak.
115
+ // Both the bundled gateway TS (`turn-flush-safety.ts`) and this unbundled hook
116
+ // now import the same module, so a single edit governs every classifier.
127
117
 
128
118
  /**
129
119
  * Choose the prose the captured-prose bridge should deliver from the trailing
@@ -403,7 +393,8 @@ export function scanTurnForFinalReply(jsonl) {
403
393
  if (obj?.type !== 'assistant') continue
404
394
  const content = obj?.message?.content
405
395
  if (!Array.isArray(content)) continue
406
- for (const c of content) {
396
+ for (let ci = 0; ci < content.length; ci++) {
397
+ const c = content[ci]
407
398
  if (c?.type === 'text') {
408
399
  // Plain assistant text carve-out (#2053): a turn that ends with
409
400
  // a trailing bare NO_REPLY / HEARTBEAT_OK line — emitted as
@@ -428,16 +419,36 @@ export function scanTurnForFinalReply(jsonl) {
428
419
  // bridge): when this turn ends up blocked, the joined undelivered
429
420
  // text becomes `pendingText` so the gateway can deliver the model's
430
421
  // real answer directly. Trimmed per-block; joined below.
422
+ //
423
+ // #3513 follow-up (MF1): carry the block's text/length; the STRUCTURAL
424
+ // provenance `followedByToolUse` is NOT computed per-message here (the
425
+ // old `content.slice(ci+1)` only saw a tool later in this SAME message,
426
+ // missing the cross-message shape [text-only message] → [tool_use in the
427
+ // NEXT message]). It is filled by the PER-TURN two-pass below, which
428
+ // sees a turn-continuing tool_use ANYWHERE later in the turn.
431
429
  blocks.push({
432
430
  kind: 'text',
433
431
  chars: String(c.text ?? '').trim().length,
434
432
  text: String(c.text ?? '').trim(),
433
+ followedByToolUse: false,
435
434
  })
436
435
  }
437
436
  continue
438
437
  }
439
438
  if (c?.type !== 'tool_use') continue
440
- if (!REPLY_TOOLS.has(c.name)) continue
439
+ if (!REPLY_TOOLS.has(c.name)) {
440
+ // #3513 follow-up (MF1): a turn-CONTINUING tool_use (any tool NOT in the
441
+ // ephemeral surface set and NOT a reply tool) is the deterministic signal
442
+ // that every PRIOR text block — including ones in earlier messages — was
443
+ // intra-turn narration. Record it as an ordered provenance marker so the
444
+ // two-pass below can retro-mark those blocks. Ephemeral surface tools
445
+ // (react / pin / typing / edit / delete) are NOT turn-continuing: a text
446
+ // block followed only by them is still the terminal answer.
447
+ if (!isEphemeralTool(c.name)) {
448
+ blocks.push({ kind: 'work-tool' })
449
+ }
450
+ continue
451
+ }
441
452
  const input = c.input ?? {}
442
453
  const text = String(input.text ?? '')
443
454
  // Silent-marker carve-out: the operator explicitly signaled
@@ -464,6 +475,28 @@ export function scanTurnForFinalReply(jsonl) {
464
475
  }
465
476
  }
466
477
 
478
+ // 2b. #3513 follow-up (MF1) — PER-TURN two-pass structural provenance. Walk
479
+ // the flattened blocks in REVERSE and mark each text block
480
+ // `followedByToolUse` iff a turn-continuing tool_use (`kind:'work-tool'`,
481
+ // recorded above for any non-ephemeral, non-reply tool) appears LATER
482
+ // anywhere in the turn — not just later in its own message. This is the
483
+ // root-cause fix: the per-message computation missed the cross-message
484
+ // shape ([text-only message] → [tool_use in the NEXT message]), leaking
485
+ // intra-turn narration to the bridge/sweep. A `deliver` (reply-tool) marker
486
+ // does NOT reset the flag: reply provenance is handled by the deliver
487
+ // cursor (lastAllowBlockIdx), not the structural mark.
488
+ {
489
+ let sawWorkToolLater = false
490
+ for (let i = blocks.length - 1; i >= 0; i--) {
491
+ const b = blocks[i]
492
+ if (b.kind === 'work-tool') {
493
+ sawWorkToolLater = true
494
+ continue
495
+ }
496
+ if (b.kind === 'text') b.followedByToolUse = sawWorkToolLater
497
+ }
498
+ }
499
+
467
500
  // 3. Find the LAST delivery event's position, then check whether any
468
501
  // plain-text block appears strictly after it. This is the fix for
469
502
  // the "at least once" bug: a naive scan that stops at the FIRST
@@ -478,60 +511,50 @@ export function scanTurnForFinalReply(jsonl) {
478
511
  }
479
512
  }
480
513
  const undeliveredSlice = blocks.slice(lastAllowBlockIdx + 1)
481
- const sawUndeliveredTextAfterAllow = undeliveredSlice
482
- .some((b) => b.kind === 'text' && (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS)
514
+ // #3513 follow-up — the ONE shared backstop coalescer selects the terminal
515
+ // delivery from the trailing text blocks (in source order, carrying the
516
+ // per-TURN structural provenance filled by the two-pass above).
517
+ // `selectBackstopDelivery` UNCONDITIONALLY excludes any block a
518
+ // turn-continuing tool followed (no length/wording gate — the #3515 substance
519
+ // heuristic is gone on the backstop path) and joins the maximal terminal
520
+ // suffix run of non-tool-followed blocks. `null` ⇒ nothing deliverable
521
+ // (pure narration run, or an empty-terminal fragment below the floor).
522
+ const backstopBlocks = undeliveredSlice
523
+ .filter((b) => b.kind === 'text' && typeof b.text === 'string')
524
+ .map((b) => ({ text: b.text, followedByToolUse: b.followedByToolUse }))
525
+ const backstopSelected = selectBackstopDelivery(backstopBlocks)
526
+ const backstopText =
527
+ backstopSelected != null && typeof backstopSelected.text === 'string'
528
+ ? backstopSelected.text
529
+ : undefined
483
530
 
484
- // Option A transcript-prose bridge: isolate the undelivered final-answer
485
- // prose so the gateway can deliver it directly.
486
- //
487
- // Finding 2 (#3228): a real dropped answer is a SINGLE substantive block —
488
- // NOT concatenated inter-tool narration ("Let me check…", "Still querying…")
489
- // that only crosses the floor once joined. The old code joined ALL post-
490
- // delivery text blocks and surfaced the join whenever the COMBINED length
491
- // hit the floor, so a run of short narration masqueraded as a final answer
492
- // (and in the zero-delivery case that join was every text block in the
493
- // turn). Instead, deliver only the LAST block that CLEARS the substance
494
- // floor ON ITS OWN. This mirrors the block decision itself
495
- // (`sawUndeliveredTextAfterAllow`, which requires a single ≥floor block) and
496
- // handles the "big answer then short closer" shape by delivering the answer,
497
- // not the closer. When no single block clears the floor, `pendingText` stays
498
- // undefined and the gateway falls through to the re-prompt / represent nets.
499
- const substantiveBlocks = undeliveredSlice.filter(
500
- (b) =>
501
- b.kind === 'text' &&
502
- typeof b.text === 'string' &&
503
- (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS,
504
- )
531
+ // The interim-ack path (`trailing-text-after-reply`) keeps the 200-char
532
+ // substance floor: a short trailing closer after a real reply is not a
533
+ // dropped answer. A qualifying delivery already happened, so blocking here
534
+ // only re-nags for a genuine ≥floor dropped answer.
535
+ const sawUndeliveredTextAfterAllow =
536
+ backstopText != null && backstopText.trim().length >= FINAL_ANSWER_MIN_CHARS
505
537
  const pendingText =
506
- substantiveBlocks.length > 0
507
- ? substantiveBlocks[substantiveBlocks.length - 1].text
538
+ backstopText != null && backstopText.trim().length >= FINAL_ANSWER_MIN_CHARS
539
+ ? backstopText
508
540
  : undefined
541
+ // `hasTrailingProse` INTENTIONALLY still counts ANY non-empty trailing prose
542
+ // (narration included): in the zero-reply case it gates the single-writer
543
+ // election to ALLOW the stop (`flush-will-deliver`) rather than BLOCK+nag —
544
+ // the gateway flush then suppresses the narration structurally (its own
545
+ // `selectBackstopDelivery` returns null), so the turn ends silently (no leak,
546
+ // no nag loop). Only the DELIVERED text is narrowed by the coalescer.
509
547
  const trailingTextBlocks = undeliveredSlice.filter(
510
548
  (b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0,
511
549
  )
512
550
  const hasTrailingProse = trailingTextBlocks.length > 0
513
- // Capture-divergence bridge (#duplicate-message fix): in the ZERO-reply
514
- // case, persist the last trailing block even when no single block clears
515
- // the 200-char substance floor. The gateway's flush normally delivers any
516
- // non-empty captured text, but when the gateway's own capture diverged
517
- // (captured empty) the captured-prose bridge is the only delivery machine
518
- // left, and it reads `pendingText` — the gateway lowers `minChars` for
519
- // exactly this corner (`capturedProseMinCharsFor`, silent-end.ts). The
520
- // interim-ack case (`trailing-text-after-reply`) keeps the substantive
521
- // floor unchanged — a short closer after a real reply is not a dropped
522
- // answer.
523
- //
524
- // Multi-block corner (review item 3): a real answer split across ≥2
525
- // individually-sub-200 blocks (e.g. two ~150-char paragraphs) would
526
- // otherwise yield NO pendingText — and in the capture-divergence-empty
527
- // corner (gateway `capturedText` empty → flush skips 'empty-text') the
528
- // bridge would then have nothing to deliver and the hook already allowed the
529
- // stop: a DROPPED ANSWER. `selectBridgePendingText` mirrors the flush's own
530
- // `selectFlushDeliveryText` narration-strip/join, so the bridge delivers the
531
- // joined prose (with the lowered `minChars`) instead of dropping. The #3228
532
- // Finding 2 guard is preserved: a pure narration run still yields undefined.
533
- const zeroReplyPendingText =
534
- pendingText ?? selectBridgePendingText(trailingTextBlocks.map((b) => b.text))
551
+ // Zero-reply / capture-divergence corner: the coalesced terminal run is the
552
+ // deliverable prose regardless of the 200-char interim-ack floor — a real
553
+ // answer split across ≥2 individually-sub-200 terminal blocks is joined by
554
+ // `selectBackstopDelivery` (its terminal-run join has no floor), so the bridge
555
+ // delivers the joined prose instead of dropping. A pure narration run still
556
+ // yields `undefined` (the #3228 Finding 2 guard, now structural).
557
+ const zeroReplyPendingText = backstopText
535
558
 
536
559
  if (lastAllowBlockIdx === -1) {
537
560
  // No qualifying delivery/silence event anywhere in the turn.
@@ -911,13 +934,17 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
911
934
  if (obj?.type !== 'assistant') continue
912
935
  const content = obj?.message?.content
913
936
  if (!Array.isArray(content)) continue
914
- for (const c of content) {
937
+ for (let ci = 0; ci < content.length; ci++) {
938
+ const c = content[ci]
915
939
  if (c?.type === 'text') {
916
940
  const raw = String(c.text ?? '')
917
941
  // H6: strip a trailing bare marker; if substantive non-narration prose
918
942
  // remains, it is a genuine (undelivered) answer — capture it. If nothing
919
943
  // but the marker (or narration) remains, the block is a silence event.
920
944
  const { prose, hadMarker } = stripTrailingSilentMarker(raw)
945
+ // #3513 follow-up (MF1): `followedByToolUse` is filled by the PER-TURN
946
+ // two-pass below (a turn-continuing tool_use later ANYWHERE in the turn),
947
+ // not per-message — the cross-message shape needs the whole-turn view.
921
948
  if (hadMarker) {
922
949
  // H6: a block ending "…real answer…\nNO_REPLY" carries a genuine
923
950
  // answer — capture the prose and do NOT treat the block as a silence
@@ -928,15 +955,22 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
928
955
  // last-delivery cursor past that prose and mask it (multi-block H6).
929
956
  // A bare/narration marker is simply not a delivery — push nothing.
930
957
  if (prose.length > 0 && !isNarrationBlock(prose)) {
931
- blocks.push({ kind: 'text', chars: prose.length, text: prose })
958
+ blocks.push({ kind: 'text', chars: prose.length, text: prose, followedByToolUse: false })
932
959
  }
933
960
  } else if (prose.length > 0) {
934
- blocks.push({ kind: 'text', chars: prose.length, text: prose })
961
+ blocks.push({ kind: 'text', chars: prose.length, text: prose, followedByToolUse: false })
935
962
  }
936
963
  continue
937
964
  }
938
965
  if (c?.type !== 'tool_use') continue
939
- if (!REPLY_TOOLS.has(c.name)) continue
966
+ if (!REPLY_TOOLS.has(c.name)) {
967
+ // #3513 follow-up (MF1): record a turn-continuing (non-ephemeral,
968
+ // non-reply) tool_use as an ordered provenance marker for the two-pass.
969
+ if (!isEphemeralTool(c.name)) {
970
+ blocks.push({ kind: 'work-tool' })
971
+ }
972
+ continue
973
+ }
940
974
  const input = c.input ?? {}
941
975
  const text = String(input.text ?? '')
942
976
  if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
@@ -957,6 +991,21 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
957
991
  }
958
992
  }
959
993
 
994
+ // #3513 follow-up (MF1) — PER-TURN two-pass structural provenance (same as
995
+ // scanTurnForFinalReply): mark each text block `followedByToolUse` iff a
996
+ // turn-continuing tool_use appears LATER anywhere in the turn.
997
+ {
998
+ let sawWorkToolLater = false
999
+ for (let i = blocks.length - 1; i >= 0; i--) {
1000
+ const b = blocks[i]
1001
+ if (b.kind === 'work-tool') {
1002
+ sawWorkToolLater = true
1003
+ continue
1004
+ }
1005
+ if (b.kind === 'text') b.followedByToolUse = sawWorkToolLater
1006
+ }
1007
+ }
1008
+
960
1009
  let lastDeliverIdx = -1
961
1010
  let lastFinalReplyBlock = null
962
1011
  for (let i = 0; i < blocks.length; i++) {
@@ -988,14 +1037,19 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
988
1037
  lastFinalReplyBlock != null && typeof lastFinalReplyBlock.text === 'string'
989
1038
  ? createHash('sha256').update(lastFinalReplyBlock.text, 'utf8').digest('hex')
990
1039
  : null
991
- const trailing = blocks
1040
+ // #3513 follow-up — the ONE shared coalescer selects the terminal delivery,
1041
+ // UNCONDITIONALLY excluding any block a turn-continuing tool followed (no
1042
+ // length/wording gate) and joining the terminal suffix run. The durable outbox
1043
+ // never captures intra-turn narration for the sweep to deliver.
1044
+ const trailingTextBlocks = blocks
992
1045
  .slice(lastDeliverIdx + 1)
993
1046
  .filter((b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0)
994
- .map((b) => b.text)
995
-
996
- const text = selectBridgePendingText(trailing)
1047
+ const backstopSelected = selectBackstopDelivery(
1048
+ trailingTextBlocks.map((b) => ({ text: b.text, followedByToolUse: b.followedByToolUse })),
1049
+ )
1050
+ const text = backstopSelected != null ? backstopSelected.text : null
997
1051
  if (text == null || text.trim().length < FINAL_ANSWER_MIN_CHARS) {
998
- return { capture: false, reason: trailing.length === 0 ? 'no-trailing-prose' : 'below-floor' }
1052
+ return { capture: false, reason: trailingTextBlocks.length === 0 ? 'no-trailing-prose' : 'below-floor' }
999
1053
  }
1000
1054
 
1001
1055
  const turnNonce = deriveTurnNonce({
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { isReplyTool, isDraftOfReply } from './narrative-dedup.js'
43
+ import { isStructuralNarration } from './hooks/narration-classify.mjs'
43
44
 
44
45
  /**
45
46
  * Time-box for the parked-narrative early-paint (the kernel's home for the
@@ -70,6 +71,18 @@ export interface NarrativeFlushEffects {
70
71
  * Caller removes it from the feed. No-op-safe if already gone.
71
72
  */
72
73
  retractShown(text: string): void
74
+ /**
75
+ * #3513 (correction 4): durably mark a block that has surfaced ONLY on the
76
+ * ephemeral card as structural narration, so the out-of-process turn-end
77
+ * backstops (E3 captured-prose bridge, E4 outbox sweep) refuse to re-emit it
78
+ * as a real chat message. Called ONLY from the mid-turn SHOW paths (`stage`,
79
+ * `resolveOnTool`), where the block is provably followed by more content
80
+ * (another text block / a tool_use) and is therefore structural narration —
81
+ * NEVER from `onTimerFire` (the timer paints the LAST/terminal block, which
82
+ * could still be the genuine unsent answer) nor from `flushAtTurnEnd`. The
83
+ * effect is optional; callers without a durable ledger omit it.
84
+ */
85
+ markDurableNarration?(text: string): void
73
86
  }
74
87
 
75
88
  /** Arms / disarms the real early-paint timer. Injected so tests can fake it. */
@@ -113,6 +126,10 @@ export class NarrativeFlushController {
113
126
  this.scheduler.disarm()
114
127
  if (this.pending != null) {
115
128
  this.effects.show(this.pending)
129
+ // The just-shown block is provably followed by MORE narration text (this
130
+ // new block is its lookahead) → structural narration. Mark it durably so
131
+ // the turn-end backstops never re-deliver it (#3513, correction 4).
132
+ this.markShown(this.pending)
116
133
  }
117
134
  this.pending = text
118
135
  this.scheduler.arm(() => this.onTimerFire(), this.flushMs)
@@ -146,6 +163,10 @@ export class NarrativeFlushController {
146
163
  this.pending = null
147
164
  if (replyText != null && isDraftOfReply(pending, replyText)) return // draft → SUPPRESS
148
165
  this.effects.show(pending)
166
+ // The just-shown block is provably followed by a tool_use (this lookahead) →
167
+ // structural narration. Mark it durably so the turn-end backstops never
168
+ // re-deliver it as a real chat message (#3513, correction 4).
169
+ this.markShown(pending)
149
170
  }
150
171
 
151
172
  /**
@@ -170,6 +191,20 @@ export class NarrativeFlushController {
170
191
  this.timerShown = null
171
192
  }
172
193
 
194
+ /**
195
+ * Durably mark a card-only block as narration — but ONLY when it is
196
+ * structurally narration under the shared classifier (substance-gated:
197
+ * `isNarrationBlock || < SUBSTANTIVE_MIN_CHARS`). This is the belt-and-braces
198
+ * guard: a substantive real message that happens to precede a non-delivering
199
+ * tool (react / pin / typing) is NOT marked, so it can still be delivered by a
200
+ * backstop if it was the genuine unsent answer (#3513, corrections 3 & 4).
201
+ */
202
+ private markShown(text: string): void {
203
+ if (this.effects.markDurableNarration == null) return
204
+ if (!isStructuralNarration(text, true)) return
205
+ this.effects.markDurableNarration(text)
206
+ }
207
+
173
208
  /** Retract the timer-painted block iff this reply is its draft-then-send. */
174
209
  private maybeRetract(replyText: string): void {
175
210
  const shown = this.timerShown