switchroom 0.19.42 → 0.19.44

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 (75) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +284 -124
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +4094 -2711
  5. package/dist/host-control/main.js +472 -155
  6. package/dist/vault/approvals/kernel-server.js +231 -71
  7. package/dist/vault/broker/server.js +293 -133
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +93 -8
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1682 -936
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  73. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  75. package/vendor/hindsight-memory/settings.json +1 -1
@@ -29,6 +29,7 @@ import {
29
29
  stripExcessBold,
30
30
  addParagraphSpacers,
31
31
  splitMarkdownChunks,
32
+ splitPlainTextToCap,
32
33
  hardSliceToCap,
33
34
  RICH_MESSAGE_MAX_CHARS,
34
35
  } from '../format.js'
@@ -36,6 +37,12 @@ import { scrubVoice } from '../text-voice-scrub.js'
36
37
  import { normalizeTemporal } from '../temporal-normalize.js'
37
38
  import { resolveEnvTimezone } from '../shared/local-time.js'
38
39
  import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
40
+ import {
41
+ buildReplyParameters,
42
+ dropQuoteFromSendOpts,
43
+ isQuoteRejectionError,
44
+ sendOptsHaveQuote,
45
+ } from '../reply-quote.js'
39
46
 
40
47
  // ── send-orchestration façade imports (#2996 P2) ──
41
48
  // Pure/deterministic helpers are imported; stateful or side-effecting gateway
@@ -52,6 +59,14 @@ import {
52
59
  } from '../format.js'
53
60
  import { richMessage } from '../rich-send.js'
54
61
  import { journalExternalDelivery } from './outbox-sweep.js'
62
+ import { sha256Hex } from '../outbox.js'
63
+ // #4141: the ONE shared framing implementation, also used by the outbox sweep
64
+ // and (for the classifier half) the unbundled Stop hooks.
65
+ import {
66
+ applyReplyThrowFraming,
67
+ formatReplyThrowFraming,
68
+ shouldFrameReplyThrow,
69
+ } from '../hooks/audience-classify.mjs'
55
70
  import { queueFloodBlockedReply } from './flood-reply-queue.js'
56
71
  import { resolveChatIdFallback } from './chat-id-fallback.js'
57
72
  import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
@@ -416,16 +431,58 @@ export async function sendReplyChunks(
416
431
  // rejects our markdown — better an unformatted answer than a
417
432
  // vanished one. The raw markdown source is itself readable prose, so
418
433
  // we send it verbatim rather than strip anything.
434
+ //
435
+ // #4043: chunks were split for the RICH cap (up to RICH_MESSAGE_MAX_CHARS =
436
+ // 32768), but this fallback ships them through the PLAIN `sendMessage`
437
+ // endpoint, which caps at 4096. Without a re-cap, the rescue send itself
438
+ // 400s with `message is too long` and the answer vanishes — the exact
439
+ // failure the fallback exists to prevent. Re-cap at safe boundaries first.
440
+ //
441
+ // Per-piece trailers: `opts` was built for ONE message, so shipping it
442
+ // verbatim to every piece duplicates the MESSAGE-scoped params.
443
+ // - `reply_markup` (an inline keyboard) would render a LIVE button on
444
+ // every piece. `single_use` only strips the keyboard from the message
445
+ // that was tapped, so the user can fire the same action N times. It
446
+ // rides the FINAL piece only — the same treatment the sweep's own
447
+ // fallback applies (`outbox-sweep.ts` `sendChunkRich`/`withoutMarkup`),
448
+ // and the same message the caller attached it to (the last chunk).
449
+ // - `reply_parameters` would quote-reply from every piece. Only the
450
+ // FIRST piece quotes, matching the caller's `replyMode !== 'all'`
451
+ // rule that quotes chunk 0 only.
452
+ // Thread / link-preview / protect_content / disable_notification are
453
+ // per-message policy and correctly ride every piece, unchanged.
419
454
  const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
420
455
  const plain =
421
456
  chunks[i].length > 0
422
457
  ? chunks[i]
423
458
  : '⚠️ (a fragment could not be rendered for Telegram)'
424
- const sent = await deps.sendLiteralRaw(opts, plain)
425
- sentIds.push(sent.message_id)
426
- deps.logOutbound('reply', chatId, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
459
+ const pieces = splitPlainTextToCap(plain)
460
+ const pieceOpts = (p: number): Record<string, unknown> => {
461
+ // Single piece it is both first and final; pass `opts` through
462
+ // byte-identically so the non-split path stays exactly as it was.
463
+ if (pieces.length === 1) return opts
464
+ const o = { ...opts }
465
+ if (p !== pieces.length - 1) delete o.reply_markup
466
+ if (p !== 0) delete o.reply_parameters
467
+ return o
468
+ }
469
+ for (let p = 0; p < pieces.length; p++) {
470
+ const sent = await deps.sendLiteralRaw(pieceOpts(p), pieces[p])
471
+ sentIds.push(sent.message_id)
472
+ deps.logOutbound(
473
+ 'reply',
474
+ chatId,
475
+ sent.message_id,
476
+ pieces[p].length,
477
+ pieces.length > 1
478
+ ? `chunk=${i + 1}/${chunks.length} plaintext-fallback piece=${p + 1}/${pieces.length}`
479
+ : `chunk=${i + 1}/${chunks.length} plaintext-fallback`,
480
+ )
481
+ }
427
482
  deps.stderr(
428
- `telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
483
+ `telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text` +
484
+ (pieces.length > 1 ? ` in ${pieces.length} piece(s) (4096-char plain cap)` : '') +
485
+ `\n`,
429
486
  )
430
487
  }
431
488
 
@@ -499,6 +556,20 @@ export async function sendReplyChunks(
499
556
  else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
500
557
  else throw retryErr
501
558
  }
559
+ } else if (isQuoteRejectionError(err) && sendOptsHaveQuote(sendOpts)) {
560
+ // Surgical-quote rejection: the quote must be an EXACT substring of the
561
+ // message being replied to, and Telegram 400s when it isn't found (a
562
+ // model paraphrase, a re-rendered fragment, an emoji/entity mismatch).
563
+ // That is a failure of the HIGHLIGHT, not of the answer — so drop the
564
+ // quote and land the reply as an ordinary reply to the same message,
565
+ // rather than throwing the composed answer away.
566
+ const retryOpts = dropQuoteFromSendOpts(sendOpts)
567
+ const sent = await sendChunk(retryOpts, false)
568
+ sentIds.push(sent.message_id)
569
+ deps.logOutbound('reply', chatId, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length} quote-dropped`)
570
+ deps.stderr(
571
+ `telegram gateway: quote not found in the replied-to message — resent chunk ${i + 1}/${chunks.length} without the quote\n`,
572
+ )
502
573
  } else if (isMessageTooLongError(err)) {
503
574
  await sendChunkResplit(sendOpts)
504
575
  } else if (isHtmlParseRejectError(err)) {
@@ -1949,10 +2020,10 @@ export async function sendReply(
1949
2020
  return {
1950
2021
  ...(shouldReplyTo
1951
2022
  ? {
1952
- reply_parameters: {
1953
- message_id: reply_to,
1954
- ...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
1955
- },
2023
+ // `quote` is a Bot API String and `quote_position` a separate
2024
+ // sibling Integer — see reply-quote.ts. Emitting an object here
2025
+ // 400'd every quoted reply the fleet ever sent.
2026
+ reply_parameters: buildReplyParameters(reply_to!, quoteText),
1956
2027
  }
1957
2028
  : {}),
1958
2029
  ...(tid != null ? { message_thread_id: tid } : {}),
@@ -2514,6 +2585,15 @@ export async function deliverCapturedProse(
2514
2585
  text: string
2515
2586
  /** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
2516
2587
  turnDurationMs?: number
2588
+ /**
2589
+ * #4141 — this turn's reply tool threw, so `text` is prose the model wrote
2590
+ * AFTER its delivery attempt failed, not text it chose to send as the answer.
2591
+ * When `true` the send is prefixed with a one-line provenance banner. This is
2592
+ * the ELECTED-path counterpart of the outbox sweep's framing: on this path
2593
+ * the Stop hook defers to the single-writer election and writes NO outbox
2594
+ * record, so the sweep's framing can never reach the message.
2595
+ */
2596
+ replyToolThrewThisTurn?: boolean
2517
2597
  },
2518
2598
  ): Promise<void> {
2519
2599
  const {
@@ -2523,6 +2603,18 @@ export async function deliverCapturedProse(
2523
2603
  } = deps
2524
2604
  const { chatId, threadId, statusKeyStr, registryKey, originTurnId, text, turnDurationMs } = args
2525
2605
  const now = Date.now()
2606
+ // #4141 — framing is a rendering concern, decided ONCE here and applied only
2607
+ // to the bytes handed to Telegram. Everything keyed on identity (the dedup
2608
+ // check + record below, and the journal's `textSha256`) deliberately keeps
2609
+ // using the RAW `text`, so the label cannot change what counts as "the same
2610
+ // answer" — a later reply-tool retry of the same prose still dedups, and the
2611
+ // exactly-once-among-backstops journal key is unchanged. Same
2612
+ // additive-by-construction property the sweep path has, re-established here
2613
+ // because this path has its own send ladder and its own dedup.
2614
+ const framed = shouldFrameReplyThrow(
2615
+ { replyToolThrewThisTurn: args.replyToolThrewThisTurn },
2616
+ { frameEnabled: process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING !== '0' },
2617
+ )
2526
2618
  // #3228 Finding 1 — the three settlement points (sent / skipped-dedup /
2527
2619
  // failed) all funnel through the pure `settleCapturedProseDelivery` core so
2528
2620
  // the failure posture is deterministic and unit-tested. `outcome` is set on
@@ -2530,7 +2622,7 @@ export async function deliverCapturedProse(
2530
2622
  let outcome: CapturedProseSendOutcome
2531
2623
  const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
2532
2624
  if (already == null) {
2533
- let out = normalizeParagraphBreaks(repairEscapedWhitespace(text))
2625
+ let out = normalizeParagraphBreaks(repairEscapedWhitespace(framed ? applyReplyThrowFraming(text) : text))
2534
2626
  out = redactOutboundText(out, 'captured_prose')
2535
2627
  const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
2536
2628
  const sentIds: number[] = []
@@ -2577,11 +2669,31 @@ export async function deliverCapturedProse(
2577
2669
  // journal line from this site following a reply-tool line under the same
2578
2670
  // nonce (replyAlreadyDeliveredThisTurn:true) is direct, journal-only
2579
2671
  // proof of a bridge double-send.
2580
- journalExternalDelivery({ turnNonce: originTurnId, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: false })
2672
+ journalExternalDelivery({
2673
+ turnNonce: originTurnId,
2674
+ text,
2675
+ tgMessageId: sentIds[sentIds.length - 1],
2676
+ replyAlreadyDeliveredThisTurn: false,
2677
+ // #4141 durable terminal stamp — parity with the sweep's
2678
+ // `DeliveredEntry.framedProvenance`, so "was this labelled?" is
2679
+ // answerable from the journal alone, hours later.
2680
+ ...(framed ? { framedProvenance: 'reply-throw' as const } : {}),
2681
+ })
2581
2682
  process.stderr.write(
2582
2683
  `telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
2583
2684
  `transcript scan (chat=${chatId} origin=${originTurnId})\n`,
2584
2685
  )
2686
+ if (framed) {
2687
+ process.stderr.write(
2688
+ formatReplyThrowFraming({
2689
+ turnNonce: originTurnId,
2690
+ turnId: originTurnId,
2691
+ chatId,
2692
+ textSha256: sha256Hex(text),
2693
+ source: 'captured-prose-bridge',
2694
+ }),
2695
+ )
2696
+ }
2585
2697
  outcome = 'sent'
2586
2698
  } catch (err) {
2587
2699
  // #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
@@ -2660,7 +2772,11 @@ export async function deliverCapturedProse(
2660
2772
  `(chat=${chatId} origin=${originTurnId})\n`,
2661
2773
  )
2662
2774
  const plain = redactOutboundText(text, 'captured_prose')
2663
- const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
2775
+ // #4043: these pieces go through the PLAIN `sendMessage` endpoint (4096
2776
+ // cap), not the rich one — splitting at RICH_MESSAGE_MAX_CHARS produced
2777
+ // pieces Telegram rejects with `message is too long`, so a long recovered
2778
+ // answer fell through to the generic apology and was lost.
2779
+ const plainChunks = splitPlainTextToCap(plain)
2664
2780
  try {
2665
2781
  let liveThreadId: number | undefined = threadId
2666
2782
  const plainSentIds: number[] = []