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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -2
- package/telegram-plugin/dist/gateway/gateway.js +1690 -932
- package/telegram-plugin/dist/server.js +31 -2
- package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +10 -57
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
- package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
- package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
- package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -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-scan.mjs +136 -82
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +73 -3
- package/telegram-plugin/session-tail.ts +88 -1
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silence-poke.ts +118 -1
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -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/feed-survival.test.ts +7 -1
- package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -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-sweep-listen-button.test.ts +253 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -1
- package/telegram-plugin/tests/silence-poke.test.ts +280 -0
- 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/tts-normalize.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
- package/telegram-plugin/tts-normalize.ts +12 -0
- package/telegram-plugin/turn-flush-safety.ts +66 -53
- package/telegram-plugin/voice-normalize-text.ts +100 -0
- 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 =
|
|
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.
|
|
@@ -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)) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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:
|
|
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
|