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.
Files changed (35) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +4 -2
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1027 -509
  7. package/telegram-plugin/dist/server.js +1 -1
  8. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  9. package/telegram-plugin/gateway/gateway.ts +4 -0
  10. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
  12. package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
  13. package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
  14. package/telegram-plugin/gateway/stream-render.ts +107 -15
  15. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  16. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  17. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  18. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
  19. package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
  20. package/telegram-plugin/narrative-flush.ts +35 -0
  21. package/telegram-plugin/outbox.ts +87 -0
  22. package/telegram-plugin/shown-ledger.ts +145 -0
  23. package/telegram-plugin/silent-end.ts +42 -0
  24. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  25. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  26. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  27. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  28. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  29. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  30. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  33. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  34. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  35. package/telegram-plugin/turn-flush-safety.ts +66 -53
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { Bot, Context } from 'grammy'
25
+ import { extractRichMessageText } from './rich-message-handler.js'
25
26
 
26
27
  /**
27
28
  * Top-level `message` envelope fields — identity/routing metadata, excluded
@@ -36,6 +37,14 @@ export const MESSAGE_ENVELOPE_KEYS: ReadonlySet<string> = new Set<string>([
36
37
  'business_connection_id', 'effect_id', 'has_protected_content',
37
38
  'is_from_offline', 'link_preview_options', 'show_caption_above_media',
38
39
  'entities', 'caption_entities', 'paid_star_count',
40
+ // Legacy (pre-Bot-API-7.0 spelling) forward metadata. Some wire payloads
41
+ // carry these ALONGSIDE `forward_origin` (observed live: carrie
42
+ // update_id=417526125, content_keys=[forward_from,forward_date,rich_message]).
43
+ // They are provenance metadata, not content — without this exclusion a
44
+ // forwarded unhandled message gets mislabeled `(unhandled message content:
45
+ // forward_from)` instead of naming its actual content type.
46
+ 'forward_from', 'forward_from_chat', 'forward_from_message_id',
47
+ 'forward_signature', 'forward_sender_name', 'forward_date',
39
48
  ])
40
49
 
41
50
  /**
@@ -88,6 +97,11 @@ export function planUnhandledMessage(msg: Record<string, unknown>): UnhandledMes
88
97
  const text =
89
98
  (typeof msg.text === 'string' ? msg.text : undefined) ??
90
99
  (typeof msg.caption === 'string' ? msg.caption : undefined) ??
100
+ // Belt-and-braces: `message:rich_message` has its own registered handler
101
+ // (rich-message-handler.ts), so a rich message normally never reaches the
102
+ // catch-all — but if one arrives ALONGSIDE unknown future content, its
103
+ // real body still beats a placeholder.
104
+ extractRichMessageText(msg.rich_message) ??
91
105
  `(unhandled message content: ${contentType})`
92
106
  return { action: 'turn', text, contentKeys }
93
107
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Type declarations for the shared trailing-text narration classifier
3
+ * (switchroom#3513) so the bundled TypeScript surfaces — `turn-flush-safety.ts`,
4
+ * `shown-ledger.ts`, `narrative-flush.ts` — can import the ONE classifier from
5
+ * `narration-classify.mjs` without tsc resolution errors. The runtime module is
6
+ * plain ESM (it is also imported by the unbundled Stop-hook `.mjs`, which can
7
+ * only import sibling `.mjs` + node builtins), so it cannot be authored in TS.
8
+ */
9
+ export const SUBSTANTIVE_MIN_CHARS: number
10
+ export const NARRATION_OPENER: RegExp
11
+ export const NARRATION_TRAILER: RegExp
12
+ export function isTrailingNarrationLine(block: string): boolean
13
+ export function isNarrationBlock(block: string): boolean
14
+ export function isStructuralNarration(
15
+ text: string,
16
+ followedByToolUse: boolean | undefined,
17
+ ): boolean
18
+ export const EPHEMERAL_TOOLS: Set<string>
19
+ export function isEphemeralTool(name: string | null | undefined): boolean
20
+ export function selectBackstopDelivery(
21
+ blocks: ReadonlyArray<{ text: string; followedByToolUse?: boolean }>,
22
+ ): { text: string } | null
23
+ export function ledgerHashHex(text: string): string
@@ -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
+ }
@@ -182,6 +182,11 @@ function writeOutboxRecord(stateDir, capture) {
182
182
  // F2: per-session origin chat for envelope-less routing (fail-closed).
183
183
  originChatId: capture.chatId == null ? (capture.originChatId ?? null) : undefined,
184
184
  originThreadId: capture.chatId == null ? (capture.originThreadId ?? null) : undefined,
185
+ // #3510 instrumentation: carried into every delivered.jsonl entry the
186
+ // sweep writes for this record, so a double-send is provable from the
187
+ // journal alone. Driven by the SAME boolean that gates capture-vs-election
188
+ // in main() — fix and telemetry cannot drift.
189
+ replyAlreadyDeliveredThisTurn: capture.replyAlreadyDeliveredThisTurn === true,
185
190
  }
186
191
  const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
187
192
  writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
@@ -242,13 +247,34 @@ function main() {
242
247
  if (process.env.SWITCHROOM_TG_OUTBOX_DELIVERY !== '0') try {
243
248
  const capture = scanForOutboxCapture(jsonl)
244
249
  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)
250
+ if (capture.replyAlreadyDeliveredThisTurn === true) {
251
+ // #3510: a qualifying reply ALREADY delivered through the gateway this
252
+ // turn, so a gateway anchor provably exists and the single-writer
253
+ // election below ('trailing-text-after-reply') is reachable. Writing an
254
+ // outbox record + self-exiting here would create a third, uncoordinated
255
+ // delivery path that bypasses the #3469 election and re-sends a
256
+ // trailing recap of the reply as a second (unformatted) message. Do NOT
257
+ // capture; fall through so the election is the actual single writer.
258
+ // The gateway-blind backstop (#3502) is untouched: it is the
259
+ // replyAlreadyDeliveredThisTurn === false branch below.
260
+ // Log both hashes so a double-send is provable from logs alone.
261
+ process.stderr.write(
262
+ `[silent-end-interrupt] capture found trailing prose after a delivered reply ` +
263
+ `(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
264
+ `replyAlreadyDeliveredThisTurn=true ` +
265
+ `capturedTextSha256=${createHash('sha256').update(capture.text, 'utf8').digest('hex')} ` +
266
+ `deliveredReplySha256=${capture.deliveredReplySha256 ?? 'unknown'}) — ` +
267
+ `deferring to single-writer election (#3510)\n`,
268
+ )
269
+ } else {
270
+ writeOutboxRecord(stateDir, capture)
271
+ process.stderr.write(
272
+ `[silent-end-interrupt] captured undelivered final answer to outbox ` +
273
+ `(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
274
+ `replyAlreadyDeliveredThisTurn=false) — sweep will deliver; allowing stop\n`,
275
+ )
276
+ process.exit(0)
277
+ }
252
278
  }
253
279
  } catch (err) {
254
280
  process.stderr.write(`[silent-end-interrupt] outbox capture error (fail-open): ${err.message}\n`)