switchroom 0.19.14 → 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 (31) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1002 -504
  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/outbox-sweep.ts +32 -2
  12. package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
  13. package/telegram-plugin/gateway/stream-render.ts +107 -15
  14. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  15. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  16. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  17. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  18. package/telegram-plugin/narrative-flush.ts +35 -0
  19. package/telegram-plugin/outbox.ts +73 -3
  20. package/telegram-plugin/shown-ledger.ts +145 -0
  21. package/telegram-plugin/silent-end.ts +42 -0
  22. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  23. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  24. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  25. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  26. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  27. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  29. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  30. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  31. package/telegram-plugin/turn-flush-safety.ts +66 -53
@@ -19,6 +19,13 @@
19
19
  * and can be set to `0` / `false` / `off` to disable without a rebuild.
20
20
  */
21
21
 
22
+ import {
23
+ isNarrationBlock,
24
+ isStructuralNarration,
25
+ selectBackstopDelivery,
26
+ SUBSTANTIVE_MIN_CHARS,
27
+ } from './hooks/narration-classify.mjs'
28
+
22
29
  const SILENT_MARKERS = new Set(['NO_REPLY', 'HEARTBEAT_OK'])
23
30
  // Small buffer so `NO_REPLY.` with a stray period still counts as silent.
24
31
  const SILENT_MARKER_MAX_LEN = Math.max(
@@ -134,7 +141,7 @@ export function endsWithSilentMarker(text: string | undefined): boolean {
134
141
  * `hooks/silent-end-scan.mjs` — the same bar the codebase uses everywhere to
135
142
  * recognise "this text block is a real answer, not a short narration/closer".
136
143
  */
137
- export const FLUSH_SUBSTANTIVE_MIN_CHARS = 200
144
+ export const FLUSH_SUBSTANTIVE_MIN_CHARS = SUBSTANTIVE_MIN_CHARS
138
145
 
139
146
  /**
140
147
  * Pick the text a turn-flush should actually DELIVER from the captured
@@ -203,9 +210,33 @@ export function selectFlushDeliveryText(
203
210
  .map((b, i) => ({ text: b.trim(), followedByToolUse: followedByToolUse?.[i] }))
204
211
  .filter(c => c.text.length > 0)
205
212
  if (candidates.length === 0) return ''
206
- if (candidates.length === 1) return candidates[0].text
207
- const answer = candidates[candidates.length - 1].text
208
- const preceding = candidates.slice(0, -1)
213
+
214
+ // #3513 — the STRUCTURAL rule now applies to the TERMINAL block too, not only
215
+ // preceding blocks. A lone trailing block that a `tool_use` FOLLOWED (the
216
+ // model kept acting after writing it) is intra-turn narration by construction,
217
+ // never the terminal answer — so it must not be delivered as one. This is the
218
+ // primary fix for the observed leak ("Now checking the gateway logs…"),
219
+ // wording-independent where the opener regex fails. It stays substance-gated
220
+ // (correction 3): only a SHORT or narration-shaped block that a tool followed
221
+ // is suppressed — a SUBSTANTIVE, non-narration block followed by a
222
+ // non-delivering tool (react / pin / typing / edit) is NOT structural
223
+ // narration and still delivers.
224
+ //
225
+ // Only the STRUCTURAL signal (`followedByToolUse === true`) may strip the
226
+ // terminal block; the opener/trailer heuristic remains a residual tie-breaker
227
+ // for PRECEDING blocks only, so a provenance-less single block still delivers
228
+ // verbatim (#3276 finding 7 — a colon-terminated line that IS the whole answer
229
+ // is never dropped).
230
+ let cs = candidates
231
+ while (cs.length > 1 && isStructuralNarration(cs[cs.length - 1].text, cs[cs.length - 1].followedByToolUse)) {
232
+ cs = cs.slice(0, -1)
233
+ }
234
+ if (cs.length === 1) {
235
+ return isStructuralNarration(cs[0].text, cs[0].followedByToolUse) ? '' : cs[0].text
236
+ }
237
+
238
+ const answer = cs[cs.length - 1].text
239
+ const preceding = cs.slice(0, -1)
209
240
  // Deliver only the terminal answer when every earlier block is
210
241
  // intent-narration. Per block:
211
242
  // - structural flag TRUE ⇒ a tool_use followed this block, but that alone
@@ -228,50 +259,15 @@ export function selectFlushDeliveryText(
228
259
  ? false
229
260
  : isNarrationBlock(c.text),
230
261
  )
231
- return allNarration ? answer : candidates.map(c => c.text).join('\n\n')
262
+ return allNarration ? answer : cs.map(c => c.text).join('\n\n')
232
263
  }
233
264
 
234
- /**
235
- * Narration heuristic: a block that opens with a first-person "about to do X"
236
- * phrase the model emits BEFORE composing its real answer ("Let me check…",
237
- * "I'll look it up…", "Now let me…"). Deliberately NOT length-based — the
238
- * narration that shadowed the real answer in the observed bug was a LONG
239
- * (≥200-char) "Let me pull the numbers…" block, and short blocks are frequently
240
- * legitimate multi-paragraph answer content — so gating on length either drops a
241
- * short real answer or keeps a long narration. When earlier blocks don't match
242
- * this opener we keep the full joined text (never drop content we can't
243
- * confidently attribute to narration).
244
- */
245
- const NARRATION_OPENER =
246
- /^(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
247
-
248
- /**
249
- * #3276 guard 8 — the `NARRATION_OPENER` regex alone misses common progress
250
- * narration that opens with a gerund/present-continuous verb and trails off
251
- * into an ellipsis or colon: "Checking now…", "Pulling the numbers:",
252
- * "Looking into that…". Relying on the opener regex leaked those blocks into
253
- * the delivered answer. This recognises a NON-terminal narration line
254
- * deterministically, WITHOUT re-gating on a min-char floor (a short real
255
- * answer like "Yes, done." must still deliver): a single short line that ends
256
- * with an ellipsis or a colon is progress narration, not the terminal answer.
257
- *
258
- * Kept conservative on purpose — only a SINGLE-line block (no internal
259
- * paragraph) under the substantive floor, ending in `…` / `...` / `:`, so a
260
- * genuine multi-paragraph answer that happens to end a paragraph with a colon
261
- * is never mistaken for narration.
262
- */
263
- const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
264
-
265
- function isTrailingNarrationLine(block: string): boolean {
266
- const t = block.trim()
267
- if (t.length === 0 || t.length >= FLUSH_SUBSTANTIVE_MIN_CHARS) return false
268
- if (t.includes('\n')) return false
269
- return NARRATION_TRAILER.test(t)
270
- }
271
-
272
- function isNarrationBlock(block: string): boolean {
273
- return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
274
- }
265
+ // The narration heuristic (`isNarrationBlock` / openers / trailer) and the
266
+ // structural rule (`isStructuralNarration`) now live in the ONE shared
267
+ // classifier `hooks/narration-classify.mjs`, imported at the top of this file
268
+ // and by the unbundled Stop hook (`hooks/silent-end-scan.mjs`) alike — so the
269
+ // TS gateway side and the `.mjs` side can never drift (switchroom#3513
270
+ // correction 2; retires the old "MUST stay in sync" hand-synced copies).
275
271
 
276
272
  export type FlushDecision =
277
273
  | { kind: 'flush'; text: string }
@@ -283,6 +279,11 @@ export type FlushSkipReason =
283
279
  | 'no-inbound-chat'
284
280
  | 'empty-text'
285
281
  | 'silent-marker'
282
+ // A prior BACKSTOP (E2 answer-ready flush, or an out-of-process E3/E4 machine
283
+ // across a crash/restart) already delivered this turn's answer — the durable
284
+ // exactly-once-among-backstops guard (#3513 follow-up, MF2). Set by the
285
+ // caller, not `decideTurnFlush`.
286
+ | 'already-delivered'
286
287
 
287
288
  export interface FlushDecisionInput {
288
289
  /** Inbound chat the turn was servicing. `null` means system-initiated /
@@ -375,14 +376,26 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
375
376
  // sentinel — treat the whole turn as intentionally silent rather than
376
377
  // flush the prose with the sentinel glued on.
377
378
  if (endsWithSilentMarker(joined)) return { kind: 'skip', reason: 'silent-marker' }
378
- // Deliver only the substantive answer block, never the whole narration+answer
379
- // blob (see `selectFlushDeliveryText`). The silent-marker / empty guards above
380
- // still run on the full `joined` string so a partly-silent turn is classified
381
- // correctly; only the DELIVERED text is narrowed to the answer.
382
- return {
383
- kind: 'flush',
384
- text: selectFlushDeliveryText(input.capturedText, input.capturedBlockMeta),
379
+ // #3513 follow-up — deterministic, model-discipline-free coalescing. The
380
+ // DELIVERED text is the TERMINAL RUN (the suffix of blocks NOT followed by a
381
+ // turn-continuing tool_use); every tool-followed block is suppressed
382
+ // UNCONDITIONALLY (no length/wording gate), replacing #3515's substance-gated
383
+ // heuristic on this backstop path. `selectBackstopDelivery` returns null when
384
+ // nothing terminal survives (a short tool-followed fragment) — treat that as
385
+ // 'empty-text' so the turn routes to the Stop-hook re-prompt ladder rather
386
+ // than delivering an interim narration line. The silent-marker / empty guards
387
+ // above still run on the full `joined` string so a partly-silent turn is
388
+ // classified correctly.
389
+ const selected = selectBackstopDelivery(
390
+ input.capturedText.map((text, i) => ({
391
+ text,
392
+ followedByToolUse: input.capturedBlockMeta?.[i],
393
+ })),
394
+ )
395
+ if (selected == null || selected.text.trim().length === 0) {
396
+ return { kind: 'skip', reason: 'empty-text' }
385
397
  }
398
+ return { kind: 'flush', text: selected.text }
386
399
  }
387
400
 
388
401
  /**