switchroom 0.19.11 → 0.19.13

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.
@@ -55,7 +55,8 @@
55
55
 
56
56
  import { createAnswerStream } from '../answer-stream.js'
57
57
  import { LivenessTracker, isContextExhaustionText } from '../context-exhaustion.js'
58
- import { normalizeParagraphBreaks, normalizePunctuation, repairEscapedWhitespace, stripExcessBold } from '../format.js'
58
+ import { normalizeOutboundBody } from './outbound-send-path.js'
59
+ import { resolveEnvTimezone } from '../shared/local-time.js'
59
60
  import { hasOutboundDeliveredSince, recordOutbound } from '../history.js'
60
61
  import { isReplyTool } from '../narrative-dedup.js'
61
62
  import { NarrativeFlushController } from '../narrative-flush.js'
@@ -65,7 +66,6 @@ import { richMessage } from '../rich-send.js'
65
66
  import { emitRuntimeMetric } from '../runtime-metrics.js'
66
67
  import { CAPTURED_PROSE_MIN_CHARS, clearSilentEndState, decideCapturedProseDelivery, recordUndeliveredTurnEnd, silentEndFallbackText, writeSilentEndState } from '../silent-end.js'
67
68
  import { logStreamingEvent } from '../streaming-metrics.js'
68
- import { scrubVoice } from '../text-voice-scrub.js'
69
69
  import { appendActivityLabel } from '../tool-activity-summary.js'
70
70
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
71
71
  import { decideTurnFlush } from '../turn-flush-safety.js'
@@ -1523,19 +1523,6 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1523
1523
  }
1524
1524
 
1525
1525
  if (turnEndDecision === 'flush' && flushDecision.kind === 'flush') {
1526
- let capturedText = flushDecision.text
1527
- // #2798 — turn-flush delivers the model's terminal prose when it
1528
- // skipped reply/stream_reply, but historically bypassed the reply
1529
- // path's markdown normalization entirely. Mirror executeReply's front
1530
- // of pipeline here so the backstop renders identically: repair LLM
1531
- // JSON-escape bungles (literal `\n`), then promote lone prose paragraph
1532
- // breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
1533
- // collapse them (lists/tables/code left untouched). Runs BEFORE the
1534
- // redact/scrub below, exactly as reply orders it (repair → normalize →
1535
- // redact → scrub), so masking sees the repaired text. Paragraph gaps
1536
- // are the plain `\n\n` normalizeParagraphBreaks guarantees — no spacer
1537
- // pass runs on the send side any more (removed in the #2669 follow-up).
1538
- capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
1539
1526
  // Component 3 — origin-thread backstop. `chatId`/`threadId` are
1540
1527
  // captured from the turn atom (turn.sessionChatId/sessionThreadId)
1541
1528
  // at the top of this turn_end handler, NOT from the live
@@ -1547,41 +1534,30 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1547
1534
  const backstopThreadId = threadId
1548
1535
  const backstopCtrl = ctrl
1549
1536
 
1550
- // Outbound secret scrub (#2044). Turn-flush delivers the model's
1551
- // terminal answer prose when it skipped reply/stream_reply — that
1552
- // is arbitrary agent free-text, sent via sendMessage/editMessageText
1553
- // and previewed to stderr below, so it needs the same mask as the
1554
- // three reply tools. Mirror the voice scrub: mask before the send,
1555
- // the preview, and recordOutbound.
1556
- capturedText = redactOutboundText(capturedText, 'turn_flush')
1557
-
1558
- // #2798 reply-parity — normalize dashes/bullets and trip the over-bold
1559
- // guard deterministically, on code-masked text. This is the SAME chain
1560
- // the reply path applies (`stripExcessBold(normalizePunctuation(text))`)
1561
- // in the SAME order: after redact, before scrubVoice. Without it the
1562
- // turn-flush backstop rendered punctuation/bold differently from an
1563
- // identical reply. Kept inline to mirror reply exactly — a shared helper
1564
- // is a deliberate future refactor, not this change.
1565
- capturedText = stripExcessBold(normalizePunctuation(capturedText))
1566
-
1567
- // Voice scrub (PR #1683 follow-up). Turn-flush is the path
1568
- // that fires when the model emits raw transcript text WITHOUT
1569
- // calling reply / stream_reply. That captured text bypasses
1570
- // PR #1683's executeReply scrub site entirely and is delivered
1571
- // via the rich-message path directly. Scrub the capturedText on the
1572
- // raw markdown so em-dashes never reach the wire. Kill switch:
1573
- // SWITCHROOM_DISABLE_VOICE_SCRUB.
1574
- {
1575
- const scrub = scrubVoice(capturedText)
1576
- if (scrub.replaced > 0) {
1577
- capturedText = scrub.scrubbed
1578
- emitRuntimeMetric({
1579
- kind: 'voice_scrub_applied',
1580
- chatKey: statusKey(backstopChatId, backstopThreadId),
1581
- replaced: scrub.replaced,
1582
- site: 'turn_flush',
1583
- })
1584
- }
1537
+ // #3501: route through the single shared outbound seam. Turn-flush
1538
+ // delivers the model's terminal prose when it skipped reply/stream_reply
1539
+ // and used to hand-mirror the reply pipeline inline (repair → paragraph-
1540
+ // break redact punctuation/bold voice scrub). `normalizeOutboundBody`
1541
+ // IS that pipeline with the same load-bearing order the metric side
1542
+ // effect (voice_scrub_applied on a non-zero replacement) is emitted here
1543
+ // by the caller, exactly as the reply/edit sites do.
1544
+ const _flushNorm = normalizeOutboundBody(
1545
+ flushDecision.text,
1546
+ 'turn_flush',
1547
+ redactOutboundText,
1548
+ // #3501 temporal pass a cron/mail-watcher notification that skipped
1549
+ // reply is delivered here, so this is where "closed tomorrow (Thu 23
1550
+ // Jul)" gets corrected against the agent's local date.
1551
+ { tz: resolveEnvTimezone(), nowMs: Date.now() },
1552
+ )
1553
+ let capturedText = _flushNorm.text
1554
+ if (_flushNorm.voiceReplaced > 0) {
1555
+ emitRuntimeMetric({
1556
+ kind: 'voice_scrub_applied',
1557
+ chatKey: statusKey(backstopChatId, backstopThreadId),
1558
+ replaced: _flushNorm.voiceReplaced,
1559
+ site: 'turn_flush',
1560
+ })
1585
1561
  }
1586
1562
 
1587
1563
  // #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
@@ -54,12 +54,16 @@
54
54
  * every session close.
55
55
  */
56
56
 
57
- import { readFileSync, writeFileSync, existsSync } from 'node:fs'
57
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
58
58
  import { join } from 'node:path'
59
59
  import { homedir } from 'node:os'
60
60
 
61
+ import { createHash } from 'node:crypto'
62
+ import { renameSync } from 'node:fs'
63
+
61
64
  import {
62
65
  scanTurnForFinalReply,
66
+ scanForOutboxCapture,
63
67
  decideStopHookDisposition,
64
68
  isTurnFlushSafetyEnabledEnv,
65
69
  isCapturedProseDeliveryEnabledEnv,
@@ -131,6 +135,64 @@ function writeElectedState(statePath, base, decision) {
131
135
  }
132
136
  }
133
137
 
138
+ const JOURNAL_FILE = 'delivered.jsonl'
139
+
140
+ /** Is `nonce` already in the outbox delivered-keys journal? (best-effort) */
141
+ function outboxAlreadyDelivered(outboxDir, nonce) {
142
+ const path = join(outboxDir, JOURNAL_FILE)
143
+ if (!existsSync(path)) return false
144
+ try {
145
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
146
+ if (!line) continue
147
+ try {
148
+ if (JSON.parse(line)?.turnNonce === nonce) return true
149
+ } catch {
150
+ /* skip corrupt */
151
+ }
152
+ }
153
+ } catch {
154
+ /* best-effort */
155
+ }
156
+ return false
157
+ }
158
+
159
+ /**
160
+ * Write the durable outbox record for a captured undelivered final answer
161
+ * (atomic tmp+rename), mirroring `writeOutboxRecordAtomic` in `../outbox.ts`.
162
+ * The gateway heartbeat sweep is the single deliverer. Best-effort; never
163
+ * throws. Returns true when a record exists on disk for the nonce afterward.
164
+ */
165
+ function writeOutboxRecord(stateDir, capture) {
166
+ const outboxDir = join(stateDir, 'outbox')
167
+ try {
168
+ mkdirSync(outboxDir, { recursive: true })
169
+ const finalPath = join(outboxDir, `${capture.turnNonce}.json`)
170
+ if (existsSync(finalPath)) return true
171
+ // Already delivered by another machine (reply/flush) under this nonce.
172
+ if (outboxAlreadyDelivered(outboxDir, capture.turnNonce)) return true
173
+ const record = {
174
+ turnNonce: capture.turnNonce,
175
+ chatId: capture.chatId,
176
+ threadId: capture.threadId,
177
+ text: capture.text,
178
+ textSha256: createHash('sha256').update(capture.text, 'utf8').digest('hex'),
179
+ createdAt: Date.now(),
180
+ source: capture.source,
181
+ anchorContent: capture.chatId == null ? capture.anchorContent : undefined,
182
+ // F2: per-session origin chat for envelope-less routing (fail-closed).
183
+ originChatId: capture.chatId == null ? (capture.originChatId ?? null) : undefined,
184
+ originThreadId: capture.chatId == null ? (capture.originThreadId ?? null) : undefined,
185
+ }
186
+ const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
187
+ writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
188
+ renameSync(tmpPath, finalPath)
189
+ return true
190
+ } catch (err) {
191
+ process.stderr.write(`[silent-end-interrupt] failed to write outbox record: ${err.message}\n`)
192
+ return false
193
+ }
194
+ }
195
+
134
196
  function main() {
135
197
  const raw = readStdin().trim()
136
198
  if (!raw) process.exit(0)
@@ -161,6 +223,37 @@ function main() {
161
223
  process.exit(0)
162
224
  }
163
225
 
226
+ const stateDir = getStateDir()
227
+
228
+ // ── Outbox capture (guaranteed final-message delivery) ────────────────
229
+ // Class-agnostic: fires on EVERY main-session turn end. When the turn ended
230
+ // with substantive undelivered trailing prose (Telegram inbound,
231
+ // task-notification handback, cron, or a future/unknown wake shape), write a
232
+ // durable outbox record. The gateway heartbeat sweep is the single deliverer
233
+ // — no CurrentTurn, no election, no re-prompt needed. This is the deterministic
234
+ // guarantee: the model calling `reply` is no longer required. Fail-open on any
235
+ // error (never loop the session).
236
+ //
237
+ // Kill switch symmetry: `SWITCHROOM_TG_OUTBOX_DELIVERY=0` disables the gateway
238
+ // sweep (the deliverer). Capture MUST honour the SAME gate — otherwise, with
239
+ // the flag off, capture would write records that nothing delivers AND
240
+ // short-circuit the legacy re-prompt path below = silent loss. Flag off ⇒ skip
241
+ // capture entirely and fall through to the legacy block/re-prompt behaviour.
242
+ if (process.env.SWITCHROOM_TG_OUTBOX_DELIVERY !== '0') try {
243
+ const capture = scanForOutboxCapture(jsonl)
244
+ if (capture.capture === true) {
245
+ writeOutboxRecord(stateDir, capture)
246
+ process.stderr.write(
247
+ `[silent-end-interrupt] captured undelivered final answer to outbox ` +
248
+ `(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length}) — ` +
249
+ `sweep will deliver; allowing stop\n`,
250
+ )
251
+ process.exit(0)
252
+ }
253
+ } catch (err) {
254
+ process.stderr.write(`[silent-end-interrupt] outbox capture error (fail-open): ${err.message}\n`)
255
+ }
256
+
164
257
  const decision = scanTurnForFinalReply(jsonl)
165
258
 
166
259
  // 'allow' (qualifying reply or silent marker) and 'unknown' (no
@@ -175,7 +268,6 @@ function main() {
175
268
  // transcript above. If a state file exists from a prior turn that
176
269
  // never got cleared (clean shutdown not perfect), this read still
177
270
  // works; if absent, retryCount defaults to 0.
178
- const stateDir = getStateDir()
179
271
  const statePath = join(stateDir, 'silent-end-pending.json')
180
272
 
181
273
  let state = {}
@@ -57,6 +57,7 @@
57
57
  // re-verify only if a new outbound-delivery tool is added to bridge.ts.
58
58
  import { statSync } from 'node:fs'
59
59
  import { join } from 'node:path'
60
+ import { createHash } from 'node:crypto'
60
61
 
61
62
  const REPLY_TOOLS = new Set([
62
63
  'mcp__switchroom-telegram__reply',
@@ -718,3 +719,281 @@ export function isGatewayHeartbeatFresh(stateDir, now = Date.now()) {
718
719
  return false
719
720
  }
720
721
  }
722
+
723
+ // ── Outbox capture (guaranteed final-message delivery) ───────────────────────
724
+ //
725
+ // The Stop hook fires on EVERY main-session turn end, regardless of what woke
726
+ // the turn (Telegram inbound, `<task-notification>` handback, cron, or a future
727
+ // harness wake type). `scanForOutboxCapture` decides — class-agnostically —
728
+ // whether this turn ended with substantive undelivered trailing prose that must
729
+ // be captured into the durable outbox for the gateway sweep to deliver.
730
+ //
731
+ // Fail CLOSED (H4): capture keys on "a turn ended with unsent trailing prose",
732
+ // never on recognising the wake shape. An anchor-less transcript tail still
733
+ // captures (source='unknown') rather than silently allowing a lost answer.
734
+ //
735
+ // Cron (H5): NOT exempted — a cron turn that ends with prose is captured and
736
+ // guaranteed-delivered, same as any other class. Only NO_REPLY stays silent.
737
+ //
738
+ // H6: a turn ending with real prose followed by a stray bare `NO_REPLY` still
739
+ // captures the prose (the marker doesn't suppress a genuine answer), while a
740
+ // legitimately-silent turn (pure NO_REPLY / narration-only) writes no record.
741
+
742
+ /** Mirror of `deriveTurnNonce` in `../outbox.ts` — MUST stay in sync (a .mjs
743
+ * can't import the .ts). Test `outbox-nonce-parity` pins the equality. */
744
+ export function deriveTurnNonce({ chatId, threadId, messageId, anchorTimestampMs, anchorContent }) {
745
+ if (chatId != null && messageId != null && messageId !== '' && String(messageId) !== '0') {
746
+ const key = `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
747
+ return `${key}#${messageId}`
748
+ }
749
+ return createHash('sha256').update(`${anchorTimestampMs}\n${anchorContent}`, 'utf8').digest('hex')
750
+ }
751
+
752
+ /**
753
+ * Strip a trailing bare silent-marker line (NO_REPLY / HEARTBEAT_OK) from a text
754
+ * block, returning the prose that precedes it (H6). If the whole block is the
755
+ * marker, prose is ''. If there is no trailing marker, `hadMarker` is false and
756
+ * prose is the block unchanged.
757
+ *
758
+ * @param {string} text
759
+ * @returns {{ prose: string, hadMarker: boolean }}
760
+ */
761
+ export function stripTrailingSilentMarker(text) {
762
+ if (typeof text !== 'string') return { prose: '', hadMarker: false }
763
+ const rawLines = text.split('\n')
764
+ // Find the last non-empty line.
765
+ let lastIdx = -1
766
+ for (let i = rawLines.length - 1; i >= 0; i--) {
767
+ if (rawLines[i].trim().length > 0) {
768
+ lastIdx = i
769
+ break
770
+ }
771
+ }
772
+ if (lastIdx === -1) return { prose: '', hadMarker: false }
773
+ if (!SILENT_MARKER_RE.test(rawLines[lastIdx].trim())) {
774
+ return { prose: text.trim(), hadMarker: false }
775
+ }
776
+ const prose = rawLines.slice(0, lastIdx).join('\n').trim()
777
+ return { prose, hadMarker: true }
778
+ }
779
+
780
+ /**
781
+ * Resolve the turn-start anchor for capture. Primary: the most-recent
782
+ * `queue-operation`/`enqueue` line. H4 fallbacks (fail closed) when none is
783
+ * found: the last non-sidechain `user`-type line, else the last
784
+ * `queue-operation` of any operation, else the whole scanned range (startIdx=-1
785
+ * → scan from the top). `degraded` marks a non-primary anchor (source unknown).
786
+ *
787
+ * @param {string[]} lines
788
+ * @returns {{ startIdx: number, envelope: ReturnType<typeof parseChannelEnvelope>, anchorTimestampMs: number, anchorContent: string, degraded: boolean }}
789
+ */
790
+ function resolveCaptureAnchor(lines) {
791
+ let enqueueIdx = -1
792
+ let anyQueueIdx = -1
793
+ let userIdx = -1
794
+ const parsed = new Array(lines.length)
795
+ for (let i = lines.length - 1; i >= 0; i--) {
796
+ const line = lines[i]
797
+ if (!line || line[0] !== '{') continue
798
+ let obj
799
+ try { obj = JSON.parse(line) } catch { continue }
800
+ parsed[i] = obj
801
+ if (obj?.type === 'queue-operation') {
802
+ if (obj.operation === 'enqueue' && enqueueIdx === -1) enqueueIdx = i
803
+ if (anyQueueIdx === -1) anyQueueIdx = i
804
+ }
805
+ if (obj?.type === 'user' && obj?.isSidechain !== true && userIdx === -1) userIdx = i
806
+ }
807
+ const anchorTs = (obj) => {
808
+ const t = obj?.timestamp
809
+ if (typeof t === 'number' && Number.isFinite(t)) return t
810
+ if (typeof t === 'string') {
811
+ const ms = Date.parse(t)
812
+ if (Number.isFinite(ms)) return ms
813
+ }
814
+ return Date.now()
815
+ }
816
+ if (enqueueIdx !== -1) {
817
+ const obj = parsed[enqueueIdx]
818
+ const content = typeof obj.content === 'string' ? obj.content : ''
819
+ return {
820
+ startIdx: enqueueIdx,
821
+ envelope: parseChannelEnvelope(content),
822
+ anchorTimestampMs: anchorTs(obj),
823
+ anchorContent: content,
824
+ degraded: false,
825
+ }
826
+ }
827
+ if (userIdx !== -1) {
828
+ const obj = parsed[userIdx]
829
+ const content = typeof obj?.message?.content === 'string' ? obj.message.content : JSON.stringify(obj?.message?.content ?? '')
830
+ return {
831
+ startIdx: userIdx,
832
+ envelope: { chatId: null, threadId: null, messageId: null, source: 'unknown' },
833
+ anchorTimestampMs: anchorTs(obj),
834
+ anchorContent: content,
835
+ degraded: true,
836
+ }
837
+ }
838
+ if (anyQueueIdx !== -1) {
839
+ const obj = parsed[anyQueueIdx]
840
+ const content = typeof obj.content === 'string' ? obj.content : ''
841
+ return {
842
+ startIdx: anyQueueIdx,
843
+ envelope: parseChannelEnvelope(content),
844
+ anchorTimestampMs: anchorTs(obj),
845
+ anchorContent: content,
846
+ degraded: true,
847
+ }
848
+ }
849
+ // No anchor at all — scan the whole range (fail closed on unknown shape).
850
+ return {
851
+ startIdx: -1,
852
+ envelope: { chatId: null, threadId: null, messageId: null, source: 'unknown' },
853
+ anchorTimestampMs: Date.now(),
854
+ anchorContent: '',
855
+ degraded: true,
856
+ }
857
+ }
858
+
859
+ /**
860
+ * Resolve THIS session's origin chat — the most-recent non-sidechain enqueue
861
+ * line whose content carries a real Telegram `<channel>` envelope with a
862
+ * chatId. Scoped to the session's OWN transcript, this is the conversation of
863
+ * record for an envelope-less handback turn (F2): the sweep routes an
864
+ * unresolved-registry record here instead of a gateway-global "last chat anyone
865
+ * messaged" fallback, so a DM-origin handback can never leak into an unrelated
866
+ * chat. Null when the session has no prior channel inbound → the record fails
867
+ * CLOSED (held, never delivered to an arbitrary chat).
868
+ *
869
+ * @param {string[]} lines
870
+ * @returns {{ chatId: string | null, threadId: number | null }}
871
+ */
872
+ function resolveSessionOriginChat(lines) {
873
+ for (let i = lines.length - 1; i >= 0; i--) {
874
+ const line = lines[i]
875
+ if (!line || line[0] !== '{') continue
876
+ let obj
877
+ try { obj = JSON.parse(line) } catch { continue }
878
+ if (obj?.isSidechain === true) continue
879
+ if (obj?.type !== 'queue-operation') continue
880
+ const content = typeof obj.content === 'string' ? obj.content : ''
881
+ const env = parseChannelEnvelope(content)
882
+ if (env.chatId != null && env.chatId !== '') {
883
+ return { chatId: env.chatId, threadId: env.threadId ?? null }
884
+ }
885
+ }
886
+ return { chatId: null, threadId: null }
887
+ }
888
+
889
+ /**
890
+ * Class-agnostic capture scan. Walks the turn from its anchor, tracking the last
891
+ * delivery event (qualifying reply / silent marker) and the substantive prose
892
+ * blocks that trail it. Returns a capture descriptor when the turn ended with
893
+ * undelivered final-answer prose, else `{ capture: false }`.
894
+ *
895
+ * @param {string} jsonl
896
+ * @param {number} [now]
897
+ * @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string }}
898
+ */
899
+ export function scanForOutboxCapture(jsonl, now = Date.now()) {
900
+ const lines = jsonl.split('\n')
901
+ const anchor = resolveCaptureAnchor(lines)
902
+ const { envelope } = anchor
903
+
904
+ const blocks = []
905
+ for (let i = anchor.startIdx + 1; i < lines.length; i++) {
906
+ const line = lines[i]
907
+ if (!line || line[0] !== '{') continue
908
+ let obj
909
+ try { obj = JSON.parse(line) } catch { continue }
910
+ if (obj?.isSidechain === true) continue
911
+ if (obj?.type !== 'assistant') continue
912
+ const content = obj?.message?.content
913
+ if (!Array.isArray(content)) continue
914
+ for (const c of content) {
915
+ if (c?.type === 'text') {
916
+ const raw = String(c.text ?? '')
917
+ // H6: strip a trailing bare marker; if substantive non-narration prose
918
+ // remains, it is a genuine (undelivered) answer — capture it. If nothing
919
+ // but the marker (or narration) remains, the block is a silence event.
920
+ const { prose, hadMarker } = stripTrailingSilentMarker(raw)
921
+ if (hadMarker) {
922
+ // H6: a block ending "…real answer…\nNO_REPLY" carries a genuine
923
+ // answer — capture the prose and do NOT treat the block as a silence
924
+ // event (which would mask the answer). When the prose is empty or pure
925
+ // narration the marker is an intentional silence, but it must NOT emit
926
+ // a 'deliver' event: a *separate* bare-`NO_REPLY` block arriving AFTER
927
+ // a real (undelivered) prose block would otherwise move the
928
+ // last-delivery cursor past that prose and mask it (multi-block H6).
929
+ // A bare/narration marker is simply not a delivery — push nothing.
930
+ if (prose.length > 0 && !isNarrationBlock(prose)) {
931
+ blocks.push({ kind: 'text', chars: prose.length, text: prose })
932
+ }
933
+ } else if (prose.length > 0) {
934
+ blocks.push({ kind: 'text', chars: prose.length, text: prose })
935
+ }
936
+ continue
937
+ }
938
+ if (c?.type !== 'tool_use') continue
939
+ if (!REPLY_TOOLS.has(c.name)) continue
940
+ const input = c.input ?? {}
941
+ const text = String(input.text ?? '')
942
+ if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
943
+ blocks.push({ kind: 'deliver', reason: 'silent-marker' })
944
+ continue
945
+ }
946
+ if (isFinalAnswerReply({
947
+ text,
948
+ disableNotification: input.disable_notification === true,
949
+ done: input.done === true,
950
+ })) {
951
+ blocks.push({ kind: 'deliver', reason: 'final-reply' })
952
+ }
953
+ // interim ack — neither delivery nor undelivered prose.
954
+ }
955
+ }
956
+
957
+ let lastDeliverIdx = -1
958
+ for (let i = 0; i < blocks.length; i++) {
959
+ if (blocks[i].kind === 'deliver') lastDeliverIdx = i
960
+ }
961
+ const trailing = blocks
962
+ .slice(lastDeliverIdx + 1)
963
+ .filter((b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0)
964
+ .map((b) => b.text)
965
+
966
+ const text = selectBridgePendingText(trailing)
967
+ if (text == null || text.trim().length < FINAL_ANSWER_MIN_CHARS) {
968
+ return { capture: false, reason: trailing.length === 0 ? 'no-trailing-prose' : 'below-floor' }
969
+ }
970
+
971
+ const turnNonce = deriveTurnNonce({
972
+ chatId: envelope.chatId,
973
+ threadId: envelope.threadId,
974
+ messageId: envelope.messageId,
975
+ anchorTimestampMs: anchor.anchorTimestampMs,
976
+ anchorContent: anchor.anchorContent,
977
+ })
978
+ let source = envelope.source
979
+ if (source == null) {
980
+ if (anchor.degraded) source = 'unknown'
981
+ else if (/task-notification|task-id/.test(anchor.anchorContent)) source = 'task-notification'
982
+ else source = 'channel'
983
+ }
984
+ // F2: for an envelope-less record, stamp THIS session's own origin chat so the
985
+ // sweep can route it without a gateway-global fallback. For an envelope-
986
+ // bearing record the anchor chatId already routes it (origin is redundant).
987
+ const origin = envelope.chatId != null ? { chatId: null, threadId: null } : resolveSessionOriginChat(lines)
988
+ return {
989
+ capture: true,
990
+ text: text.trim(),
991
+ turnNonce,
992
+ chatId: envelope.chatId,
993
+ threadId: envelope.threadId ?? null,
994
+ source,
995
+ anchorContent: anchor.anchorContent,
996
+ originChatId: origin.chatId,
997
+ originThreadId: origin.threadId,
998
+ }
999
+ }