switchroom 0.19.41 → 0.19.43

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 (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  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 +1318 -693
  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/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -152,12 +152,14 @@ export interface IpcServerOptions {
152
152
  ) => Promise<void>;
153
153
  /**
154
154
  * #2726 Part 2 — hostd asks the gateway to EDIT a previously-posted rollout
155
- * status message as later phases arrive. Best-effort, no reply. Optional.
155
+ * status message as later phases arrive. Optional. #4065: the handler replies
156
+ * with a `rollout_status_edited` outcome so hostd can tell an applied edit
157
+ * from an edit into a deleted card; the roll never blocks on that reply.
156
158
  */
157
159
  onRolloutStatusEdit?: (
158
160
  client: IpcClient,
159
161
  msg: RolloutStatusEditMessage,
160
- ) => void;
162
+ ) => void | Promise<void>;
161
163
  log?: (msg: string) => void;
162
164
  /**
163
165
  * How long (in ms) to wait without a heartbeat before force-closing the
@@ -813,14 +815,21 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
813
815
  case "rollout_status_edit":
814
816
  if (onRolloutStatusEdit) {
815
817
  try {
816
- onRolloutStatusEdit(client, msg as RolloutStatusEditMessage);
818
+ void Promise.resolve(
819
+ onRolloutStatusEdit(client, msg as RolloutStatusEditMessage),
820
+ ).catch((err) => {
821
+ log(
822
+ `rollout_status_edit handler rejected (client=${client.id}): ${(err as Error).message}`,
823
+ );
824
+ });
817
825
  } catch (err) {
818
826
  log(
819
827
  `rollout_status_edit handler threw (client=${client.id}): ${(err as Error).message}`,
820
828
  );
821
829
  }
822
830
  }
823
- // Fire-and-forget; no reply.
831
+ // The handler replies `rollout_status_edited` (#4065) when it can; an
832
+ // unwired gateway sends nothing and hostd's bounded wait expires.
824
833
  break;
825
834
  case "update_placeholder":
826
835
  // Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
@@ -62,6 +62,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
62
62
  endCurrentTurnForKey,
63
63
  getPendingInboundBuffer,
64
64
  trackRedeliveredInbound,
65
+ drainParkedTurnStarts,
65
66
  closeActivityLane,
66
67
  closeProgressLane,
67
68
  hangRestart,
@@ -593,6 +594,16 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
593
594
  getInboundSpool(),
594
595
  trackRedeliveredInbound,
595
596
  )
597
+ // Two-state desync unwedge. `pendingInboundBuffer` (above) is NOT where a
598
+ // wedge-behind-a-dead-lock message sits: an already-`enqueue`d message lives
599
+ // in stream-render's `parkedTurnStarts`, and the busy park gate stays latched
600
+ // while that store is non-empty. A hung REPL never emits the `dequeue`/
601
+ // `remove` that drain it, so drain it HERE, scoped to the wedged chat/thread,
602
+ // routing each envelope back through the real `dequeue` turn-start path. The
603
+ // count joins the `drained_buffered` field below so the log stops reading a
604
+ // misleading `0/0` while real user messages were stranded. Guarded getter:
605
+ // absent (older deps) → 0, so the fallback degrades to its prior behaviour.
606
+ const fbDrainedParked = drainParkedTurnStarts?.(fbChatId, fbThreadId ?? null) ?? 0
596
607
  process.stderr.write(
597
608
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
598
609
  `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
@@ -601,6 +612,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
601
612
  // keyed liveness check skipped the teardown, so the field lied.
602
613
  `currentTurn_nulled=${tearsDownLiveTurn} ` +
603
614
  `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
615
+ `${fbDrainedParked > 0 ? ` drained_parked=${fbDrainedParked}` : ''}` +
604
616
  `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
605
617
  `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
606
618
  )
@@ -34,6 +34,27 @@ import {
34
34
  type ModelPickerOption,
35
35
  } from '../../src/agents/model-picker.js'
36
36
  import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.js'
37
+ // The model-alias tables + expansion helpers live in the shared
38
+ // `src/agents/model-aliases.ts` module so the gateway `/model` path and
39
+ // scaffold-time config-default resolution expand from ONE table (#3998).
40
+ // Imported for this module's internal use and re-exported below to preserve the
41
+ // existing `model-command.js` public API (every importer keeps its entrypoint).
42
+ import {
43
+ CLAUDE_MODEL_ALIASES,
44
+ SR_MODEL_ALIASES,
45
+ expandClaudeAlias,
46
+ expandSrAlias,
47
+ canonicalModelToken,
48
+ expandModelAlias,
49
+ } from '../../src/agents/model-aliases.js'
50
+ export {
51
+ CLAUDE_MODEL_ALIASES,
52
+ SR_MODEL_ALIASES,
53
+ expandClaudeAlias,
54
+ expandSrAlias,
55
+ canonicalModelToken,
56
+ expandModelAlias,
57
+ }
37
58
 
38
59
  /**
39
60
  * Aliases the claude CLI resolves natively (`claude --help`: "an alias for
@@ -51,34 +72,6 @@ import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.
51
72
  */
52
73
  export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
53
74
 
54
- /**
55
- * Short SWITCHROOM-side spellings for pinned **Anthropic** model ids. These are
56
- * expanded to the full `claude-*` id on the `/model` path BEFORE any
57
- * Claude-vs-external classification runs, so what reaches the `.session-model`
58
- * carrier (and therefore `claude --model`) is always the canonical id — the CLI
59
- * never sees the short spelling.
60
- *
61
- * Deliberately NOT `SR_MODEL_ALIASES`: that map means "external / OpenRouter /
62
- * the Anthropic OAuth header is NOT forwarded". These targets are Anthropic
63
- * OAuth-passthrough models, so putting them there would flip the
64
- * header-forwarding + external-billing classification and surface them under the
65
- * "🌐 External models" keyboard page with an `srFriendlyLabel`.
66
- *
67
- * Also NOT `MODEL_ALIASES`: that list is the aliases the claude CLI resolves
68
- * *itself*, and its members double as FAMILY tokens in `modelFamilyToken` /
69
- * `canonicalClaudeToken` / `servedModelMatchesRequested`. A pinned-id shortcut
70
- * is neither.
71
- *
72
- * NB the repo prefers family aliases over pinned ids (an alias tracks the
73
- * current flagship, a pinned id goes stale) — these exist because an operator
74
- * sometimes wants a specific pinned Opus, and typing `claude-opus-4-8` on a
75
- * phone is hostile. Keys are matched case-insensitively.
76
- */
77
- export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
78
- opus48: 'claude-opus-4-8',
79
- 'opus-4-8': 'claude-opus-4-8',
80
- }
81
-
82
75
  /**
83
76
  * Shape gate for the model argument. This string is typed literally
84
77
  * into the agent's tmux pane, so the gate is strict by construction:
@@ -1166,35 +1159,6 @@ export const SR_MODEL_LABELS: Record<string, string> = {
1166
1159
  'sr-gpt-5.6-luna': 'GPT-5.6 Luna',
1167
1160
  }
1168
1161
 
1169
- /**
1170
- * Short text-command aliases for sr-* models. These let the operator type
1171
- * `/model flash`, `/model codex`, etc. instead of the full `sr-*` id.
1172
- * Expanded in handleModelCommand before injection; the full sr-* id is what
1173
- * reaches the agent session and LiteLLM.
1174
- */
1175
- export const SR_MODEL_ALIASES: Record<string, string> = {
1176
- flash: 'sr-gemini-2.5-flash',
1177
- gemini: 'sr-gemini-2.5-pro',
1178
- deepseek: 'sr-deepseek-v3',
1179
- r1: 'sr-deepseek-r1',
1180
- glm: 'sr-glm-5',
1181
- codex: 'sr-codex-5.5',
1182
- // Flagship keyboard buttons for the three providers added 2026-07-19. Each
1183
- // points at that provider's top tier; the cheaper tiers (grok-4.3,
1184
- // kimi-k2.7-code, gpt-5.6-terra/luna) stay typeable via `/model sr-<id>` and
1185
- // keep a friendly label above, matching the "labels are a superset of buttons"
1186
- // design (SR_MODEL_LABELS doc comment).
1187
- grok: 'sr-grok-4.5',
1188
- kimi: 'sr-kimi-k3',
1189
- gpt: 'sr-gpt-5.6-sol',
1190
- // Per-tier buttons for the GPT-5.6 line so each is a one-word shortcut
1191
- // (`gpt` stays the flagship-Sol default). Added 2026-07-19.
1192
- sol: 'sr-gpt-5.6-sol',
1193
- terra: 'sr-gpt-5.6-terra',
1194
- luna: 'sr-gpt-5.6-luna',
1195
- }
1196
-
1197
- /** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
1198
1162
  /**
1199
1163
  * #3042 review blocker 2a: can `token` be trusted for a boot-applied
1200
1164
  * `.session-model` carrier persist WITHOUT a live confirmation from claude?
@@ -1230,58 +1194,6 @@ export function isOfflineTrustedModelToken(token: string): boolean {
1230
1194
  return Object.values(CLAUDE_MODEL_ALIASES).includes(lower)
1231
1195
  }
1232
1196
 
1233
- export function expandSrAlias(arg: string): string {
1234
- return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
1235
- }
1236
-
1237
- /**
1238
- * The ONE canonical form of a model token — what `claude --model` is handed and
1239
- * what every downstream gate compares against.
1240
- *
1241
- * Two normalizations, both narrow:
1242
- * - **trim** — unconditional. `unvalidatedIdCaveat` already trimmed while
1243
- * expansion did not, so the two disagreed on a padded token.
1244
- * - **lowercase, `claude-*` ONLY.** Every real Anthropic model id is lowercase
1245
- * and a phone autocapitalizes, so `/model Claude-Opus-4-8` used to be launched
1246
- * VERBATIM as an unvalidated id while the caveat exemption (which lowercases)
1247
- * suppressed the warning — a clean green ack for a token the caveat was
1248
- * written for. Non-`claude-` tokens are left alone: `sr-*` ids are matched
1249
- * case-insensitively by their own maps, and rewriting an arbitrary external
1250
- * id's case is not ours to do.
1251
- *
1252
- * Applied by `expandModelAlias`, so canonicalization happens on every `/model`
1253
- * path (typed apply, mid-turn queue, menu tap, queued-across-restart persist)
1254
- * whether or not the token hit a shortcut map — one canonical form flows to
1255
- * `claude --model`, and `unvalidatedIdCaveat` sees exactly that form.
1256
- */
1257
- export function canonicalModelToken(arg: string): string {
1258
- const trimmed = arg.trim()
1259
- const lower = trimmed.toLowerCase()
1260
- return lower.startsWith('claude-') ? lower : trimmed
1261
- }
1262
-
1263
- /** Expand a short pinned-Claude spelling (case-insensitive) to its full `claude-*` id. */
1264
- export function expandClaudeAlias(arg: string): string {
1265
- return CLAUDE_MODEL_ALIASES[arg.trim().toLowerCase()] ?? arg
1266
- }
1267
-
1268
- /**
1269
- * The ONE expansion every `/model` argument goes through, on every path that
1270
- * consumes a user-supplied token (typed apply, mid-turn queue, menu tap,
1271
- * queued-across-restart persist). Claude shortcuts resolve first, then sr-*
1272
- * shortcuts; the two key sets are disjoint by construction (a Claude shortcut
1273
- * never expands to an `sr-*` id and vice versa), so order only documents intent.
1274
- * The result is `canonicalModelToken`-normalized, so a miss can no longer emit a
1275
- * verbatim mixed-case `claude-*` id that the caveat exemption then mis-matches.
1276
- *
1277
- * Expanding HERE — before `isClaudeModel` / `isSrModel` / `externalModelNames`
1278
- * ever see the token — is what keeps a pinned-Claude shortcut on the Anthropic
1279
- * OAuth passthrough instead of being misread as an external route.
1280
- */
1281
- export function expandModelAlias(arg: string): string {
1282
- return canonicalModelToken(expandSrAlias(expandClaudeAlias(arg)))
1283
- }
1284
-
1285
1197
  export function srFriendlyLabel(srName: string): string {
1286
1198
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
1287
1199
  }
@@ -43,6 +43,11 @@ export interface ObligationStoreFsSeam {
43
43
  * entry lives in the parent's own cached metadata and is otherwise lost
44
44
  * in a power cut. */
45
45
  fsyncDirSync: (path: string) => void
46
+ /** #4146: remove a snapshot this process will not maintain. Optional so any
47
+ * existing seam (tests, other callers) keeps compiling and keeps working —
48
+ * an absent `unlinkSync` degrades to "no cleanup", which is what those
49
+ * callers do today. */
50
+ unlinkSync?: (path: string) => void
46
51
  }
47
52
 
48
53
  interface SnapshotEnvelope {
@@ -126,6 +131,56 @@ export function loadObligations(path: string, fs: ObligationStoreFsSeam): Obliga
126
131
  return env.obligations.filter(isObligationRow).map(sanitizeCapturedDelivery)
127
132
  }
128
133
 
134
+ /**
135
+ * #4146 — drop a snapshot this process is NOT going to maintain.
136
+ *
137
+ * When the gateway is STATIC or `SWITCHROOM_OBLIGATION_LEDGER=0`, the ledger's
138
+ * `onChange` persister is never wired, so obligations open and close purely in
139
+ * memory while any previously-written `obligations.json` sits on disk
140
+ * unchanged. That leftover is not harmless: the W1-d audience classifier
141
+ * (`hooks/audience-classify.mjs`) reads this exact file to decide whether
142
+ * anyone is waiting on a chat, and an empty-but-stale open set reads there as
143
+ * POSITIVE evidence that nobody is — which is the one input that can make the
144
+ * classifier suppress a message owed to a human.
145
+ *
146
+ * Removing the file restores that classifier's stated invariant ("absent ⇒
147
+ * unknown ⇒ deliver") deterministically, with no freshness heuristic and no
148
+ * clock. Note this is safe precisely because the snapshot has exactly one
149
+ * writer: with the ledger ON, a merely-stopped gateway leaves a snapshot that
150
+ * is accurate rather than stale, so nothing is lost by only cleaning up in the
151
+ * modes where persistence is disabled.
152
+ *
153
+ * Best-effort and never throws — a failure degrades to the reader-side
154
+ * ledger-off guard in the hook.
155
+ *
156
+ * @returns `true` iff a file was actually removed.
157
+ */
158
+ export function removeUnmaintainedSnapshot(
159
+ path: string,
160
+ fs: ObligationStoreFsSeam,
161
+ reason = 'persistence disabled',
162
+ log: (line: string) => void = (l) => process.stderr.write(l),
163
+ ): boolean {
164
+ const unlink = fs.unlinkSync
165
+ if (unlink == null) return false
166
+ try {
167
+ if (!fs.existsSync(path)) return false
168
+ unlink(path)
169
+ log(
170
+ `obligation-store: persistence is off (${reason}) — removed unmaintained ` +
171
+ `snapshot path=${path} so it cannot be read as "nobody is waiting" (#4146)\n`,
172
+ )
173
+ return true
174
+ } catch (err) {
175
+ log(
176
+ `obligation-store: could not remove unmaintained snapshot path=${path}: ` +
177
+ `${(err as Error).message} — audience classification falls back to the ` +
178
+ `reader-side ledger-off guard (#4146)\n`,
179
+ )
180
+ return false
181
+ }
182
+ }
183
+
129
184
  /**
130
185
  * Persist the open set atomically (write sibling tmp → rename over the real
131
186
  * path). Best-effort relative to fs availability: a write failure is logged but
@@ -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[] = []