switchroom 0.19.13 → 0.19.15
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/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +4 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +1027 -509
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +4 -0
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
- package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
- package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
- package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +87 -0
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/turn-flush-safety.ts +66 -53
|
@@ -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 =
|
|
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
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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 (
|
|
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))
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
507
|
-
?
|
|
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
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
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.
|
|
@@ -894,7 +917,7 @@ function resolveSessionOriginChat(lines) {
|
|
|
894
917
|
*
|
|
895
918
|
* @param {string} jsonl
|
|
896
919
|
* @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 }}
|
|
920
|
+
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null }}
|
|
898
921
|
*/
|
|
899
922
|
export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
900
923
|
const lines = jsonl.split('\n')
|
|
@@ -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 (
|
|
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))
|
|
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)) {
|
|
@@ -948,24 +982,74 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
948
982
|
disableNotification: input.disable_notification === true,
|
|
949
983
|
done: input.done === true,
|
|
950
984
|
})) {
|
|
951
|
-
|
|
985
|
+
// #3510: keep the delivered reply's text so the caller can log its
|
|
986
|
+
// sha256 next to the captured trailing prose's sha256 — a double-send
|
|
987
|
+
// becomes provable from logs alone, without transcript reconstruction.
|
|
988
|
+
blocks.push({ kind: 'deliver', reason: 'final-reply', text })
|
|
952
989
|
}
|
|
953
990
|
// interim ack — neither delivery nor undelivered prose.
|
|
954
991
|
}
|
|
955
992
|
}
|
|
956
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
|
+
|
|
957
1009
|
let lastDeliverIdx = -1
|
|
1010
|
+
let lastFinalReplyBlock = null
|
|
958
1011
|
for (let i = 0; i < blocks.length; i++) {
|
|
959
|
-
if (blocks[i].kind
|
|
1012
|
+
if (blocks[i].kind !== 'deliver') continue
|
|
1013
|
+
lastDeliverIdx = i
|
|
1014
|
+
if (blocks[i].reason === 'final-reply') lastFinalReplyBlock = blocks[i]
|
|
960
1015
|
}
|
|
961
|
-
|
|
1016
|
+
// #3510: single source of truth for BOTH the fix's branching and the
|
|
1017
|
+
// instrumentation. When true, a genuine FINAL-ANSWER reply/stream_reply
|
|
1018
|
+
// already went through the gateway THIS turn — so a gateway anchor provably
|
|
1019
|
+
// exists (`turn.finalAnswerDelivered` is set by the SAME `isFinalAnswerReply`
|
|
1020
|
+
// classifier at outbound-send-path.ts ~2293) and the single-writer election
|
|
1021
|
+
// in the Stop hook is reachable and safe. The caller
|
|
1022
|
+
// (silent-end-interrupt-stop.mjs) MUST NOT self-exit a capture in that case;
|
|
1023
|
+
// doing so creates a third, uncoordinated delivery path that re-sends a
|
|
1024
|
+
// trailing recap of the reply as a second message (the #3510 double-send).
|
|
1025
|
+
//
|
|
1026
|
+
// #3511 review finding 1: ONLY a `'final-reply'` deliver block counts. A
|
|
1027
|
+
// `'silent-marker'` block (reply-tool NO_REPLY / HEARTBEAT_OK) delivered
|
|
1028
|
+
// NOTHING to the user, and the gateway routes such turns to sentinel
|
|
1029
|
+
// suppression (`decideTurnEndGate` → 'silent_end') BEFORE the captured-prose
|
|
1030
|
+
// bridge ever runs — deferring to the election there would orphan the
|
|
1031
|
+
// trailing answer (a drop). A silent marker still advances `lastDeliverIdx`
|
|
1032
|
+
// (the trailing-prose CURSOR — H6 semantics: NO_REPLY intentionally silences
|
|
1033
|
+
// what came BEFORE it), but it must keep the shape on the durable
|
|
1034
|
+
// outbox/sweep path, never route it to the election.
|
|
1035
|
+
const replyAlreadyDeliveredThisTurn = lastFinalReplyBlock != null
|
|
1036
|
+
const deliveredReplySha256 =
|
|
1037
|
+
lastFinalReplyBlock != null && typeof lastFinalReplyBlock.text === 'string'
|
|
1038
|
+
? createHash('sha256').update(lastFinalReplyBlock.text, 'utf8').digest('hex')
|
|
1039
|
+
: null
|
|
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
|
|
962
1045
|
.slice(lastDeliverIdx + 1)
|
|
963
1046
|
.filter((b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0)
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1047
|
+
const backstopSelected = selectBackstopDelivery(
|
|
1048
|
+
trailingTextBlocks.map((b) => ({ text: b.text, followedByToolUse: b.followedByToolUse })),
|
|
1049
|
+
)
|
|
1050
|
+
const text = backstopSelected != null ? backstopSelected.text : null
|
|
967
1051
|
if (text == null || text.trim().length < FINAL_ANSWER_MIN_CHARS) {
|
|
968
|
-
return { capture: false, reason:
|
|
1052
|
+
return { capture: false, reason: trailingTextBlocks.length === 0 ? 'no-trailing-prose' : 'below-floor' }
|
|
969
1053
|
}
|
|
970
1054
|
|
|
971
1055
|
const turnNonce = deriveTurnNonce({
|
|
@@ -995,5 +1079,7 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
995
1079
|
anchorContent: anchor.anchorContent,
|
|
996
1080
|
originChatId: origin.chatId,
|
|
997
1081
|
originThreadId: origin.threadId,
|
|
1082
|
+
replyAlreadyDeliveredThisTurn,
|
|
1083
|
+
deliveredReplySha256,
|
|
998
1084
|
}
|
|
999
1085
|
}
|
|
@@ -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
|
|
@@ -104,6 +104,15 @@ export interface OutboxRecord {
|
|
|
104
104
|
originChatId?: string | null
|
|
105
105
|
/** Forum thread of the per-session origin chat (see `originChatId`). */
|
|
106
106
|
originThreadId?: number | null
|
|
107
|
+
/**
|
|
108
|
+
* #3510 instrumentation: was a qualifying reply already delivered through
|
|
109
|
+
* the gateway in the turn that produced this record? Stamped by the Stop
|
|
110
|
+
* hook from the SAME boolean that gates its capture-vs-election branch.
|
|
111
|
+
* After #3510 this is always `false` for a written record (a `true` routes
|
|
112
|
+
* to the single-writer election instead of the outbox), so a `true` here —
|
|
113
|
+
* or in a sweep journal entry — is direct evidence of a regression.
|
|
114
|
+
*/
|
|
115
|
+
replyAlreadyDeliveredThisTurn?: boolean
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
/** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
|
|
@@ -112,6 +121,16 @@ export interface DeliveredEntry {
|
|
|
112
121
|
textSha256: string
|
|
113
122
|
tgMessageId?: number
|
|
114
123
|
ts: number
|
|
124
|
+
/**
|
|
125
|
+
* #3510 instrumentation: which machine delivered — the outbox sweep, the
|
|
126
|
+
* gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
|
|
127
|
+
* captured-prose bridge), or the turn-end / answer-ready quiescence flush
|
|
128
|
+
* (`'flush'`, added in the #3513 exactly-once-among-backstops follow-up).
|
|
129
|
+
* Absent on pre-#3510 journal lines.
|
|
130
|
+
*/
|
|
131
|
+
deliverySource?: 'sweep' | 'reply-tool' | 'flush'
|
|
132
|
+
/** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
|
|
133
|
+
replyAlreadyDeliveredThisTurn?: boolean
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
export function sha256Hex(s: string): string {
|
|
@@ -290,6 +309,59 @@ export function outboxAlreadyDelivered(nonce: string, stateDir?: string): boolea
|
|
|
290
309
|
return readDeliveredNonces(stateDir).has(nonce)
|
|
291
310
|
}
|
|
292
311
|
|
|
312
|
+
/**
|
|
313
|
+
* True iff a journal entry is a prior BACKSTOP delivery (turn-flush E1/E2,
|
|
314
|
+
* captured-prose bridge E3, outbox sweep E4) — NOT an explicit E0 `reply` /
|
|
315
|
+
* `stream_reply` send (#3513 follow-up, MF2).
|
|
316
|
+
*
|
|
317
|
+
* - `deliverySource === 'sweep'` → E4 backstop.
|
|
318
|
+
* - `deliverySource === 'flush'` → E1/E2 backstop.
|
|
319
|
+
* - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
|
|
320
|
+
* false` → E3 captured-prose bridge (a backstop; the bridge only fires when
|
|
321
|
+
* NO genuine final answer was delivered this turn).
|
|
322
|
+
* - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
|
|
323
|
+
* true` → an explicit E0 reply send — NOT a backstop. E0 replies are
|
|
324
|
+
* ungated by design (a turn may send N of them), so they must NOT satisfy a
|
|
325
|
+
* backstop's exactly-once guard (else the guard would eat a legitimate
|
|
326
|
+
* #3510 trailing recap or a multi-reply turn's later bridge).
|
|
327
|
+
*
|
|
328
|
+
* A pre-#3510 line with no `deliverySource` is treated conservatively as NOT a
|
|
329
|
+
* backstop (fail open — never suppress a backstop on ambiguous provenance).
|
|
330
|
+
*/
|
|
331
|
+
export function isBackstopDeliveredEntry(e: DeliveredEntry): boolean {
|
|
332
|
+
if (e.deliverySource === 'sweep' || e.deliverySource === 'flush') return true
|
|
333
|
+
if (e.deliverySource === 'reply-tool' && e.replyAlreadyDeliveredThisTurn === false) return true
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* True iff `nonce` already has a prior BACKSTOP delivery journaled (see
|
|
339
|
+
* `isBackstopDeliveredEntry`). This is the BACKSTOP-SCOPED exactly-once read the
|
|
340
|
+
* turn-flush (E1/E2) and captured-prose bridge (E3) consult before delivering —
|
|
341
|
+
* unlike `outboxAlreadyDelivered` (any journal line) it does NOT count an
|
|
342
|
+
* explicit E0 reply, so it can never suppress a legitimate second explicit
|
|
343
|
+
* message (#3513 follow-up, MF2).
|
|
344
|
+
*/
|
|
345
|
+
export function backstopAlreadyDelivered(nonce: string, stateDir?: string): boolean {
|
|
346
|
+
if (nonce == null || nonce === '') return false
|
|
347
|
+
const path = join(resolveOutboxDir(stateDir), JOURNAL_FILE)
|
|
348
|
+
if (!existsSync(path)) return false
|
|
349
|
+
try {
|
|
350
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
351
|
+
if (!line) continue
|
|
352
|
+
try {
|
|
353
|
+
const e = JSON.parse(line) as DeliveredEntry
|
|
354
|
+
if (e.turnNonce === nonce && isBackstopDeliveredEntry(e)) return true
|
|
355
|
+
} catch {
|
|
356
|
+
/* skip corrupt line */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
/* best-effort */
|
|
361
|
+
}
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
|
|
293
365
|
/**
|
|
294
366
|
* Max delivered-keys kept in the journal. On exceeding `JOURNAL_ROTATE_AT` lines
|
|
295
367
|
* the journal is compacted down to the newest `JOURNAL_KEEP` entries — bounding
|
|
@@ -361,6 +433,7 @@ export type OutboxSweepAction =
|
|
|
361
433
|
| 'skip-quiet'
|
|
362
434
|
| 'skip-dedup'
|
|
363
435
|
| 'skip-unroutable'
|
|
436
|
+
| 'skip-ephemeral-shown'
|
|
364
437
|
|
|
365
438
|
export interface OutboxSweepDecision {
|
|
366
439
|
action: OutboxSweepAction
|
|
@@ -391,6 +464,18 @@ export function decideOutboxSweep(input: {
|
|
|
391
464
|
routePrefix?: string
|
|
392
465
|
quietMs?: number
|
|
393
466
|
maxAgeMs?: number
|
|
467
|
+
/**
|
|
468
|
+
* #3513 (correction 1): was this record's text marked ephemeral-shown on the
|
|
469
|
+
* progress card for its turnNonce (the durable shown-ledger)? A hit means the
|
|
470
|
+
* block was already assigned to the ephemeral surface — the single-surface
|
|
471
|
+
* invariant forbids ANY delivery machine, including this out-of-process late
|
|
472
|
+
* sweep, from ALSO delivering it to chat. The ledger only ever contains
|
|
473
|
+
* STRUCTURAL narration (correction 4: never a possibly-terminal answer), so
|
|
474
|
+
* this can never suppress a genuine answer. Checked here (in the backstop
|
|
475
|
+
* decision itself) rather than only at a send seam, because the sweep bypasses
|
|
476
|
+
* `normalizeOutboundBody` (it sends via `bot.api.sendMessage` directly).
|
|
477
|
+
*/
|
|
478
|
+
shownLedgerHit?: boolean
|
|
394
479
|
}): OutboxSweepDecision {
|
|
395
480
|
const {
|
|
396
481
|
record,
|
|
@@ -401,8 +486,10 @@ export function decideOutboxSweep(input: {
|
|
|
401
486
|
routePrefix = '',
|
|
402
487
|
quietMs = OUTBOX_QUIET_MS,
|
|
403
488
|
maxAgeMs = OUTBOX_MAX_AGE_MS,
|
|
489
|
+
shownLedgerHit = false,
|
|
404
490
|
} = input
|
|
405
491
|
if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
|
|
492
|
+
if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
|
|
406
493
|
const age = now - record.createdAt
|
|
407
494
|
if (age < quietMs) return { action: 'skip-quiet' }
|
|
408
495
|
if (textAlreadyDelivered) return { action: 'skip-dedup' }
|