switchroom 0.20.11 → 0.20.13

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 (50) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +32 -25
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/self-improve-stop.mjs +13 -1
  5. package/dist/cli/switchroom.js +3069 -1146
  6. package/dist/host-control/main.js +33 -26
  7. package/dist/vault/approvals/kernel-server.js +32 -25
  8. package/dist/vault/broker/server.js +32 -25
  9. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  10. package/package.json +7 -4
  11. package/skills/switchroom-architecture/telegram.md +0 -1
  12. package/skills/switchroom-cli/SKILL.md +0 -1
  13. package/skills/switchroom-release/SKILL.md +3 -2
  14. package/telegram-plugin/README.md +2 -11
  15. package/telegram-plugin/bridge/bridge.ts +0 -12
  16. package/telegram-plugin/bunfig.toml +9 -5
  17. package/telegram-plugin/chat-lock.ts +1 -1
  18. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  19. package/telegram-plugin/dist/gateway/gateway.js +219 -127
  20. package/telegram-plugin/dist/server.js +0 -12
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
  22. package/telegram-plugin/gateway/gateway.ts +25 -59
  23. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  24. package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
  25. package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
  26. package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
  27. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  28. package/telegram-plugin/gateway/stream-render.ts +8 -4
  29. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  30. package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
  31. package/telegram-plugin/hooks/audience-classify.mjs +193 -0
  32. package/telegram-plugin/hooks/hooks.json +13 -12
  33. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  34. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
  35. package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
  36. package/telegram-plugin/outbox.ts +69 -3
  37. package/telegram-plugin/silent-end.ts +48 -5
  38. package/telegram-plugin/status-pin.ts +2 -5
  39. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  40. package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
  41. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  42. package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
  43. package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
  44. package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
  45. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  46. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  48. package/telegram-plugin/worker-activity-feed.ts +1 -1
  49. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
@@ -24962,18 +24962,6 @@ var init_bridge = __esm(async () => {
24962
24962
  required: ["chat_id"]
24963
24963
  }
24964
24964
  },
24965
- {
24966
- name: "pin_message",
24967
- description: "Pin a message in a Telegram chat. Useful for important outputs the user wants to find later. Requires admin rights in groups.",
24968
- inputSchema: {
24969
- type: "object",
24970
- properties: {
24971
- chat_id: { type: "string" },
24972
- message_id: { type: "string" }
24973
- },
24974
- required: ["chat_id", "message_id"]
24975
- }
24976
- },
24977
24965
  {
24978
24966
  name: "delete_message",
24979
24967
  description: "Delete a message the bot previously sent. Prefer edit_message if you just want to update text \u2014 delete_message is for true removal.",
@@ -68,7 +68,7 @@
68
68
  * `deliver` (via `deliverAnswer`), the durable oracle, and the obligation I/O.
69
69
  */
70
70
 
71
- import { hasOutboundWithText } from '../history.js'
71
+ import { hasOutboundWithText as realHasOutboundWithText } from '../history.js'
72
72
  import type { BackstopDeliveryLedger } from './backstop-delivery.js'
73
73
  import type { CapturedDeliverySnapshot, Obligation } from './obligation-ledger.js'
74
74
 
@@ -168,6 +168,27 @@ export interface CapturedResumePorts {
168
168
  /** True when history is queryable (else the durable-text oracle reconcile that
169
169
  * gives crash-idempotency — §2.3/A5 — is skipped). */
170
170
  historyEnabled: boolean
171
+ /**
172
+ * The durable outbound-text oracle. Defaults to the real `history.js`
173
+ * implementation; tests inject a fake here instead of module-mocking
174
+ * `../history.js`. A `vi.mock('../history.js', ...)` looks file-scoped
175
+ * under vitest but is NOT under bun's vitest-compat layer (`bun test`
176
+ * maps `vi.mock` onto the PROCESS-GLOBAL `mock.module`, which retroactively
177
+ * rebinds every other file's import of the same specifier in the same
178
+ * `bun-test-run` sweep — see check-bun-module-mock-scope.mjs). That leak
179
+ * was the actual root cause of #4488/#4491: a mocked `hasOutboundWithText`
180
+ * from this module's own test file silently answered
181
+ * `telegram-plugin/tests/history.test.ts`'s calls to the REAL function
182
+ * elsewhere in the same 651-file sweep, so a row `history.test.ts` had
183
+ * genuinely just written was reported as not found. Injecting via this
184
+ * port keeps the fake scoped to the dispatcher instance that receives it.
185
+ */
186
+ hasOutboundWithText?: (
187
+ chatId: string,
188
+ text: string,
189
+ threadId: number | null,
190
+ sinceMs?: number,
191
+ ) => boolean
171
192
  stderr?: (s: string) => void
172
193
  }
173
194
 
@@ -198,6 +219,7 @@ export interface CapturedResumeDispatcher {
198
219
  */
199
220
  export function createCapturedResumeDispatcher(ports: CapturedResumePorts): CapturedResumeDispatcher {
200
221
  const stderr = ports.stderr ?? (() => {})
222
+ const hasOutboundWithText = ports.hasOutboundWithText ?? realHasOutboundWithText
201
223
  const inFlight = new Set<string>()
202
224
 
203
225
  /** Re-deliver the UNSENT tail of `o`'s captured answer, byte-identical,
@@ -8370,8 +8370,7 @@ const statusPinClaims = new Map<string, StatusPinClaim>()
8370
8370
  // Rights-aware negative cache (#3024): chats where an auto status-pin attempt
8371
8371
  // failed with the permanent "not enough rights to manage pinned messages" 400.
8372
8372
  // Per-process only — a restart clears it so a later-granted pin right re-enables
8373
- // auto-pin. The explicit `pin_message` MCP tool deliberately does NOT consult
8374
- // this cache (it always attempts and surfaces the error to the agent).
8373
+ // auto-pin.
8375
8374
  const statusPinRightsCache = new PinRightsCache()
8376
8375
 
8377
8376
  // Durable snapshot of the pin claim set on the persistent per-agent volume
@@ -8466,21 +8465,16 @@ const BANNER_PIN_KEY = 'banner:owner'
8466
8465
  // run → no-op). The boot-cleanup gate below is widened to cover this.
8467
8466
  const bannerPinPersistEnabled = !STATIC
8468
8467
 
8469
- // `pin_message` MCP-tool pin registration (#3001). Tool pins ride the same
8470
- // shared status-pins.json store under `tool:<chatId>:<messageId>` keys, but
8471
- // with a TTL row (`expiresAt`): a tool pin is a deliberate agent action with
8472
- // no "work finished" event, so a restart does NOT reset it boot cleanup
8473
- // keeps unexpired tool rows and only unpins them once the TTL lapses (the
8474
- // backstop against agent-pinned messages accumulating forever). Independent
8475
- // of PIN_STATUS_WHILE_WORKING (it gates the auto status pin, not the tool).
8468
+ // LEGACY `tool:` pin drain (#3001; the `pin_message` MCP tool was retired in
8469
+ // #4452). No new `tool:<chatId>:<messageId>` rows are ever written now that the
8470
+ // tool is gone, but rows an earlier build persisted may still sit in the shared
8471
+ // status-pins.json with a TTL (`expiresAt`). This flag keeps the boot cleanup /
8472
+ // stale-pin sweep aware of those legacy rows so they are still expired and
8473
+ // drained correctly on upgrade an unexpired legacy row is preserved across
8474
+ // boots (never reaped as if it were a work-scoped card), then unpinned once its
8475
+ // TTL lapses, exactly as before. Independent of PIN_STATUS_WHILE_WORKING.
8476
+ // Self-clears within one TTL window since nothing writes new tool rows.
8476
8477
  const toolPinPersistEnabled = !STATIC
8477
- // 7 days: generous — an agent-pinned message the operator still cares about
8478
- // after a week has usually been re-pinned or acted on; anything older is the
8479
- // stale-pin long tail this issue exists to clear. Override for tuning.
8480
- const TOOL_PIN_TTL_MS = (() => {
8481
- const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
8482
- return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
8483
- })()
8484
8478
 
8485
8479
  // Persist (or drop) the slot-banner's pin row into the shared store. Routes
8486
8480
  // through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
@@ -8950,8 +8944,7 @@ async function reconcileStatusPin(
8950
8944
  // "not enough rights to manage pinned messages" 400 in a supergroup took the
8951
8945
  // whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
8952
8946
  // must NEVER be able to crash the gateway. Any throw here is logged and
8953
- // absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
8954
- // as a normal tool-error — that path is `executePinMessage`, not this one.)
8947
+ // absorbed.
8955
8948
  try {
8956
8949
  // Serialize per pinKey (F2): reconcileStatusPinInner reads `prev` from the
8957
8950
  // in-memory claim map at its top, so overlapping same-key reconciles must
@@ -9926,6 +9919,7 @@ async function deliverCapturedProse(args: {
9926
9919
  text: string
9927
9920
  /** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
9928
9921
  turnDurationMs?: number; replyToolThrewThisTurn?: boolean // #4141: label prose recovered from a turn whose reply tool threw (see outbound-send-path.ts)
9922
+ reviewOriginated?: boolean // #4490: review-turn provenance, threaded through to the SAME card-gate / title-framing rules (see outbound-send-path.ts)
9929
9923
  }): Promise<void> {
9930
9924
  // #2996 P2: body moved VERBATIM to outbound-send-path.ts (single tested
9931
9925
  // send module). Injects the ONE `outboundDedup` instance (Amendment 1/9).
@@ -11808,7 +11802,7 @@ if (isGatewayMain && !STATIC) {
11808
11802
  * bridge from calling arbitrary functions by name. */
11809
11803
  const ALLOWED_TOOLS = new Set([
11810
11804
  'reply', 'progress_update', 'react', 'download_attachment',
11811
- 'edit_message', 'send_typing', 'pin_message', 'delete_message',
11805
+ 'edit_message', 'send_typing', 'delete_message',
11812
11806
  'forward_message', 'get_recent_messages',
11813
11807
  'send_checklist', 'update_checklist',
11814
11808
  'ask_user',
@@ -11844,8 +11838,6 @@ async function executeToolCall(
11844
11838
  return executeEditMessage(args)
11845
11839
  case 'send_typing':
11846
11840
  return executeSendTyping(args)
11847
- case 'pin_message':
11848
- return executePinMessage(args)
11849
11841
  case 'delete_message':
11850
11842
  return executeDeleteMessage(args)
11851
11843
  case 'forward_message':
@@ -13195,44 +13187,6 @@ async function executeSendTyping(args: Record<string, unknown>): Promise<unknown
13195
13187
  return { content: [{ type: 'text', text: `${action} indicator sent (auto-refreshes every 4s, stops after 30s or next reply)` }] }
13196
13188
  }
13197
13189
 
13198
- async function executePinMessage(args: Record<string, unknown>): Promise<unknown> {
13199
- if (!args.chat_id) throw new Error('pin_message: chat_id is required')
13200
- if (!args.message_id) throw new Error('pin_message: message_id is required')
13201
- const pinChatId = String(args.chat_id ?? '')
13202
- assertAllowedChat(pinChatId)
13203
- // #1075: wrap through robustApiCall so flood-wait / transient network
13204
- // errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
13205
- // agent as a tool-error — pinning a vanished message is genuinely a
13206
- // failure the agent should see.
13207
- const pinMsgId = Number(args.message_id)
13208
- await robustApiCall(
13209
- () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
13210
- { chat_id: pinChatId, verb: 'pin_message' },
13211
- )
13212
- // An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
13213
- // this chat now — clear any auto-pin negative-cache entry (#3024) so the auto
13214
- // status-pin path resumes immediately rather than waiting for a restart. The
13215
- // explicit tool itself never consults the cache; a failure above still
13216
- // surfaces to the agent as a normal tool error (robustApiCall rethrows).
13217
- statusPinRightsCache.clear(pinChatId)
13218
- // #3001: register the tool pin in the shared status-pin store under a
13219
- // `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
13220
- // fg:/wk: rows a tool pin has no "work finished" event, so a restart does
13221
- // NOT reset it — boot cleanup keeps the row until its TTL, then unpins the
13222
- // (likely long-forgotten) message. Best-effort fire-and-forget: a store
13223
- // failure must never fail the tool call the pin already landed for.
13224
- if (toolPinPersistEnabled) {
13225
- const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
13226
- void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
13227
- pinKey: toolPinKey,
13228
- chatId: pinChatId,
13229
- messageId: pinMsgId,
13230
- expiresAt: Date.now() + TOOL_PIN_TTL_MS,
13231
- })
13232
- }
13233
- return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
13234
- }
13235
-
13236
13190
  async function executeDeleteMessage(args: Record<string, unknown>): Promise<unknown> {
13237
13191
  if (!args.chat_id) throw new Error('delete_message: chat_id is required')
13238
13192
  if (!args.message_id) throw new Error('delete_message: message_id is required')
@@ -23874,6 +23828,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23874
23828
  const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_MAX_ROWS)
23875
23829
  return Number.isInteger(raw) && raw > 0 ? raw : undefined
23876
23830
  })()
23831
+ // First-paint hold for a prose-silent sub-agent card. Unset /
23832
+ // non-positive → the feed's built-in default (4000ms). Operator
23833
+ // override (SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS) lets first
23834
+ // paint be retuned/reverted (e.g. back to 8000) without a redeploy.
23835
+ const workerFeedFirstPaintMs = (() => {
23836
+ const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS)
23837
+ return Number.isInteger(raw) && raw > 0 ? raw : undefined
23838
+ })()
23877
23839
  // Model A — foreground sub-agent nesting in the parent's live
23878
23840
  // activity draft. ON by default; this edits the SAME activity-
23879
23841
  // summary message the tool_label feed already owns (not the
@@ -23947,6 +23909,10 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23947
23909
  // channels.telegram.worker_feed.max_rows via the config cascade
23948
23910
  // (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
23949
23911
  maxRows: workerFeedMaxRows,
23912
+ // First-paint hold for a prose-silent worker's initial card.
23913
+ // Unset → the feed's built-in default (4000ms); the operator
23914
+ // override SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS retunes it.
23915
+ firstPaintMinMs: workerFeedFirstPaintMs,
23950
23916
  // Backstop TTL for the feed's stale-row reaper, DERIVED in code
23951
23917
  // from the watcher's effective in-flight terminal cap (same env /
23952
23918
  // default the watcher itself resolves) plus a margin — NOT a
@@ -21,6 +21,7 @@ import * as silencePoke from '../silence-poke.js'
21
21
  import * as signalTracker from '../turn-signal-tracker.js'
22
22
  import * as pendingProgress from '../pending-work-progress.js'
23
23
  import { emitRuntimeMetric } from '../runtime-metrics.js'
24
+ import { computeTurnDurationMs } from './turn-record-status.js'
24
25
  import { logStreamingEvent } from '../streaming-metrics.js'
25
26
  import { clearSilentEndState } from '../silent-end.js'
26
27
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
@@ -377,7 +378,11 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
377
378
  turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
378
379
  const turnStartedAt = activeTurnStartedAt.get(fbKey)
379
380
  if (turnStartedAt != null) {
380
- const turnDurationMs = Date.now() - turnStartedAt
381
+ // Guard shared with buildTurnRecord + the stream-render turn_ended paths:
382
+ // a 0 / bogus start must never emit `Date.now() - 0` (an absolute epoch
383
+ // value) as a duration. This path previously did a bare subtraction and
384
+ // poisoned the turn_ended dataset when `activeTurnStartedAt` held 0.
385
+ const turnDurationMs = computeTurnDurationMs(turnStartedAt, Date.now())
381
386
  const outboundMetrics = signalTracker.getOutboundMetrics(fbKey)
382
387
  emitRuntimeMetric({
383
388
  kind: 'turn_ended',
@@ -62,10 +62,27 @@ import { journalExternalDelivery } from './outbox-sweep.js'
62
62
  import { sha256Hex } from '../outbox.js'
63
63
  // #4141: the ONE shared framing implementation, also used by the outbox sweep
64
64
  // and (for the classifier half) the unbundled Stop hooks.
65
+ //
66
+ // #4490: previously this import only pulled the reply-throw half. The
67
+ // captured-prose bridge below is the ELECTED path's deliverer — it never
68
+ // writes an outbox record, so without the audience gate / self-improvement
69
+ // framing wired in here too, a review-turn's raw, unlabelled reasoning could
70
+ // reach the operator whenever the single-writer election (in
71
+ // `silent-end-interrupt-stop.mjs`) routes a 'trailing-text-after-reply' case
72
+ // here instead of to the outbox sweep. Wiring the SAME shared predicates in
73
+ // both places (rather than re-deriving a third, parallel implementation)
74
+ // restores the #4141-style symmetry #4485 left one-sided.
65
75
  import {
66
76
  applyReplyThrowFraming,
67
77
  formatReplyThrowFraming,
68
78
  shouldFrameReplyThrow,
79
+ decideCaptureAudience,
80
+ isSelfImprovementCard,
81
+ shouldFrameSelfImprovement,
82
+ applySelfImprovementFraming,
83
+ formatSelfImprovementFraming,
84
+ formatInternalSuppression,
85
+ AUDIENCE_INTERNAL,
69
86
  } from '../hooks/audience-classify.mjs'
70
87
  import { queueFloodBlockedReply } from './flood-reply-queue.js'
71
88
  import { resolveChatIdFallback } from './chat-id-fallback.js'
@@ -2737,6 +2754,17 @@ export async function deliverCapturedProse(
2737
2754
  * record, so the sweep's framing can never reach the message.
2738
2755
  */
2739
2756
  replyToolThrewThisTurn?: boolean
2757
+ /**
2758
+ * #4490 — this turn originated from a self-improvement review inbound
2759
+ * (`source="self_improve_review"`). Stamped by the Stop hook's
2760
+ * single-writer election onto the `SilentEndState` file (the ONLY way this
2761
+ * path — which never writes an outbox record — can learn a review turn's
2762
+ * provenance) and threaded through `decideCapturedProseDelivery`. Gates the
2763
+ * SAME audience-suppression / title-framing rules the outbox sweep applies
2764
+ * to a review record, restoring the #4141-style symmetry #4485 left
2765
+ * one-sided (card gate + title framing applied at the sweep only).
2766
+ */
2767
+ reviewOriginated?: boolean
2740
2768
  },
2741
2769
  ): Promise<void> {
2742
2770
  const {
@@ -2758,14 +2786,69 @@ export async function deliverCapturedProse(
2758
2786
  { replyToolThrewThisTurn: args.replyToolThrewThisTurn },
2759
2787
  { frameEnabled: process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING !== '0' },
2760
2788
  )
2761
- // #3228 Finding 1 the three settlement points (sent / skipped-dedup /
2762
- // failed) all funnel through the pure `settleCapturedProseDelivery` core so
2763
- // the failure posture is deterministic and unit-tested. `outcome` is set on
2764
- // each branch and applied ONCE at the bottom.
2789
+ // #4490 CARD GATE, primary: same two-layer design the outbox sweep uses
2790
+ // (`audience-classify.mjs`'s module doc). `decideCaptureAudience` checks
2791
+ // `reviewOriginated` independent of the reply-throw path above: a review
2792
+ // turn's prose classifies `internal` UNLESS the text itself is the one
2793
+ // sanctioned card (`isSelfImprovementCard`), in which case it's `user` and
2794
+ // delivered normally. A non-review turn's `reviewOriginated` is `false`/
2795
+ // `undefined`, which never enters this branch — so this can only ever
2796
+ // ADD suppression to a review turn, never touch a normal answer.
2797
+ const audienceGateEnabled = process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE !== '0'
2798
+ const audience = decideCaptureAudience({
2799
+ reviewOriginated: args.reviewOriginated === true,
2800
+ reviewTextIsCard: isSelfImprovementCard(text),
2801
+ })
2802
+ const suppressed = audienceGateEnabled && audience === AUDIENCE_INTERNAL
2803
+ // #4490 — TITLE FRAMING, residual belt-and-braces: applied OUTERMOST, after
2804
+ // the reply-throw banner, exactly mirroring `decideOutboxSweep`'s ordering
2805
+ // (`outbox.ts`). Only reachable when the card gate is disabled (or a
2806
+ // legacy/`user`-audience route) — a review record that IS delivered here
2807
+ // must still carry the title so it can never appear as raw, unlabelled
2808
+ // agent reasoning even in a degraded config. `!isSelfImprovementCard(text)`
2809
+ // keeps this idempotent against a text that already opens with the title
2810
+ // (#4489's fix, same discipline).
2811
+ const selfImproveFramed =
2812
+ !suppressed &&
2813
+ shouldFrameSelfImprovement(
2814
+ { reviewOriginated: args.reviewOriginated },
2815
+ { frameEnabled: process.env.SWITCHROOM_TG_OUTBOX_SELF_IMPROVE_FRAMING !== '0' },
2816
+ ) &&
2817
+ !isSelfImprovementCard(text)
2818
+ // #3228 Finding 1 — the settlement points (sent / skipped-dedup / failed /
2819
+ // suppressed-internal) all funnel through the pure
2820
+ // `settleCapturedProseDelivery` core so the failure posture is
2821
+ // deterministic and unit-tested. `outcome` is set on each branch and
2822
+ // applied ONCE at the bottom.
2765
2823
  let outcome: CapturedProseSendOutcome
2824
+ if (suppressed) {
2825
+ // #4490 — the gap this closes: previously this path had NO audience gate
2826
+ // at all, so a non-card review turn's raw reasoning would be sent below
2827
+ // exactly like any other captured prose. Suppress before the dedup check
2828
+ // and before any send attempt — nothing is delivered, so there is nothing
2829
+ // to dedup against.
2830
+ journalExternalDelivery({
2831
+ turnNonce: originTurnId,
2832
+ text,
2833
+ replyAlreadyDeliveredThisTurn: false,
2834
+ audience,
2835
+ suppressedAudience: AUDIENCE_INTERNAL,
2836
+ })
2837
+ process.stderr.write(
2838
+ formatInternalSuppression({
2839
+ turnNonce: originTurnId,
2840
+ turnId: originTurnId,
2841
+ chatId,
2842
+ textSha256: sha256Hex(text),
2843
+ source: 'captured-prose-bridge',
2844
+ }),
2845
+ )
2846
+ outcome = 'suppressed-internal'
2847
+ } else {
2766
2848
  const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
2767
2849
  if (already == null) {
2768
2850
  let out = normalizeParagraphBreaks(repairEscapedWhitespace(framed ? applyReplyThrowFraming(text) : text))
2851
+ out = selfImproveFramed ? applySelfImprovementFraming(out) : out
2769
2852
  out = redactOutboundText(out, 'captured_prose')
2770
2853
  const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
2771
2854
  const sentIds: number[] = []
@@ -2821,6 +2904,14 @@ export async function deliverCapturedProse(
2821
2904
  // `DeliveredEntry.framedProvenance`, so "was this labelled?" is
2822
2905
  // answerable from the journal alone, hours later.
2823
2906
  ...(framed ? { framedProvenance: 'reply-throw' as const } : {}),
2907
+ // #4490 durable terminal stamp — parity with the sweep's
2908
+ // `DeliveredEntry.framedSelfImprovement`.
2909
+ ...(selfImproveFramed ? { framedSelfImprovement: 'self-improve' as const } : {}),
2910
+ // #4490 — stamp the audience decision on every journal line from this
2911
+ // site, not only the suppressed one, so "what audience did this
2912
+ // classify as" is answerable from the journal alone even for a
2913
+ // successful send (parity with the outbox sweep's journal rows).
2914
+ audience,
2824
2915
  })
2825
2916
  process.stderr.write(
2826
2917
  `telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
@@ -2837,6 +2928,17 @@ export async function deliverCapturedProse(
2837
2928
  }),
2838
2929
  )
2839
2930
  }
2931
+ if (selfImproveFramed) {
2932
+ process.stderr.write(
2933
+ formatSelfImprovementFraming({
2934
+ turnNonce: originTurnId,
2935
+ turnId: originTurnId,
2936
+ chatId,
2937
+ textSha256: sha256Hex(text),
2938
+ source: 'captured-prose-bridge',
2939
+ }),
2940
+ )
2941
+ }
2840
2942
  outcome = 'sent'
2841
2943
  } catch (err) {
2842
2944
  // #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
@@ -2861,9 +2963,12 @@ export async function deliverCapturedProse(
2861
2963
  )
2862
2964
  outcome = 'skipped-dedup'
2863
2965
  }
2966
+ }
2864
2967
  // Apply the settlement bookkeeping through the pure core (#3228 Finding 1):
2865
- // sent / skipped-dedup → close obligation + clear state (answer is with the
2866
- // user, so represent + exhausted fallback must not fire)
2968
+ // sent / skipped-dedup / suppressed-internal → close obligation + clear
2969
+ // state (either the answer is with the user, or it
2970
+ // was never meant to reach them — either way the
2971
+ // represent + exhausted fallback must not fire).
2867
2972
  // failed → arm the Stop-hook re-prompt net (recordUndelivered),
2868
2973
  // do NOT close/clear.
2869
2974
  const settlement = settleCapturedProseDelivery(outcome, {
@@ -47,8 +47,10 @@ import {
47
47
  AUDIENCE_INTERNAL,
48
48
  formatInternalSuppression,
49
49
  formatReplyThrowFraming,
50
+ formatSelfImprovementFraming,
50
51
  resolveRecordAudience,
51
52
  shouldSuppressForAudience,
53
+ type Audience,
52
54
  } from '../hooks/audience-classify.mjs'
53
55
  import { isShownBlock } from '../shown-ledger.js'
54
56
  import { richMessage, isParseEntitiesError } from '../rich-send.js'
@@ -162,6 +164,19 @@ export interface OutboxSweepDeps {
162
164
  * Defaults to `log`.
163
165
  */
164
166
  logProvenanceFraming?: (line: string) => void
167
+ /**
168
+ * Ken 2026-08-07 kill switch for the self-improvement title header. Defaults
169
+ * to `SWITCHROOM_TG_OUTBOX_SELF_IMPROVE_FRAMING !== '0'` (ON). Same
170
+ * injected-seam reasoning as {@link provenanceFramingEnabled}: a module-scope
171
+ * env read would be captured at import and a revert-check could not run.
172
+ */
173
+ selfImprovementFramingEnabled?: () => boolean
174
+ /**
175
+ * Ken 2026-08-07 telemetry sink for a self-improvement-framed delivery.
176
+ * Separate from {@link logProvenanceFraming} so the two framings are greppable
177
+ * apart. Defaults to `log`.
178
+ */
179
+ logSelfImprovementFraming?: (line: string) => void
165
180
  }
166
181
 
167
182
  export interface OutboxSweepSummary {
@@ -190,6 +205,12 @@ export interface OutboxSweepSummary {
190
205
  * delivery, not a skip.
191
206
  */
192
207
  provenanceFramed?: number
208
+ /**
209
+ * Ken 2026-08-07: deliveries this sweep that carried the self-improvement
210
+ * title header. These ARE counted in `delivered` — framing is a delivery, not
211
+ * a skip.
212
+ */
213
+ selfImprovementFramed?: number
193
214
  }
194
215
 
195
216
  /**
@@ -246,6 +267,9 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
246
267
  const provenanceFramingEnabled = deps.provenanceFramingEnabled?.() ??
247
268
  process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING !== '0'
248
269
  const logProvenanceFraming = deps.logProvenanceFraming ?? log
270
+ const selfImprovementFramingEnabled = deps.selfImprovementFramingEnabled?.() ??
271
+ process.env.SWITCHROOM_TG_OUTBOX_SELF_IMPROVE_FRAMING !== '0'
272
+ const logSelfImprovementFraming = deps.logSelfImprovementFraming ?? log
249
273
 
250
274
  for (const record of records) {
251
275
  summary.scanned++
@@ -341,6 +365,11 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
341
365
  // exists to prevent. So we state the provenance instead, and the human
342
366
  // (who can tell the difference, unlike any classifier here) decides.
343
367
  provenanceFraming: provenanceFramingEnabled,
368
+ // Ken 2026-08-07: label any review-originated record that reaches delivery
369
+ // (the audience gate's default suppression already withheld it above; this
370
+ // covers the gate-off / `user`-route residual) with the self-improvement
371
+ // title, so review reasoning can never appear as raw, unlabelled prose.
372
+ selfImprovementFraming: selfImprovementFramingEnabled,
344
373
  })
345
374
 
346
375
  if (decision.action !== 'send' && decision.action !== 'send-delayed') {
@@ -423,6 +452,11 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
423
452
  ...(decision.framedProvenance != null
424
453
  ? { framedProvenance: decision.framedProvenance }
425
454
  : {}),
455
+ // Ken 2026-08-07: durable terminal stamp for a self-improvement-framed
456
+ // delivery — same reasoning as `framedProvenance`.
457
+ ...(decision.framedSelfImprovement != null
458
+ ? { framedSelfImprovement: decision.framedSelfImprovement }
459
+ : {}),
426
460
  },
427
461
  deps.stateDir,
428
462
  )
@@ -438,6 +472,18 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
438
472
  }),
439
473
  )
440
474
  }
475
+ if (decision.framedSelfImprovement != null) {
476
+ summary.selfImprovementFramed = (summary.selfImprovementFramed ?? 0) + 1
477
+ logSelfImprovementFraming(
478
+ formatSelfImprovementFraming({
479
+ turnNonce: record.turnNonce,
480
+ turnId: turnIdFromNonce(record.turnNonce),
481
+ chatId: resolvedChat.chatId,
482
+ textSha256: record.textSha256,
483
+ source: record.source,
484
+ }),
485
+ )
486
+ }
441
487
  // Persist parity (mirrors outbound-send-path.ts + backstop-delivery.ts):
442
488
  // record the delivered chunk ids + texts to history so a net-delivered
443
489
  // answer is not silently absent from history.db. Best-effort — a missing
@@ -980,6 +1026,26 @@ export function journalExternalDelivery(
980
1026
  * bridge), hours later.
981
1027
  */
982
1028
  framedProvenance?: 'reply-throw'
1029
+ /**
1030
+ * #4490: the record's audience, carried onto the journal line — parity
1031
+ * with the sweep's own `DeliveredEntry.audience`. Lets the captured-prose
1032
+ * bridge (`outbound-send-path.ts`), the OTHER delivery machine, journal
1033
+ * through this ONE shared function instead of a second, hand-rolled
1034
+ * `appendDelivered` call.
1035
+ */
1036
+ audience?: Audience
1037
+ /**
1038
+ * #4490: this journal line is a TERMINAL SUPPRESSION (the audience gate
1039
+ * withheld an `'internal'` record), not a delivery — mirrors
1040
+ * `DeliveredEntry.suppressedAudience`.
1041
+ */
1042
+ suppressedAudience?: Audience
1043
+ /**
1044
+ * #4490: this delivery carried the self-improvement title framing —
1045
+ * mirrors `DeliveredEntry.framedSelfImprovement`, applied on either
1046
+ * delivery path.
1047
+ */
1048
+ framedSelfImprovement?: 'self-improve'
983
1049
  },
984
1050
  stateDir?: string,
985
1051
  now: number = Date.now(),
@@ -1000,6 +1066,9 @@ export function journalExternalDelivery(
1000
1066
  ? {}
1001
1067
  : { replyAlreadyDeliveredThisTurn: args.replyAlreadyDeliveredThisTurn }),
1002
1068
  ...(args.framedProvenance == null ? {} : { framedProvenance: args.framedProvenance }),
1069
+ ...(args.audience == null ? {} : { audience: args.audience }),
1070
+ ...(args.suppressedAudience == null ? {} : { suppressedAudience: args.suppressedAudience }),
1071
+ ...(args.framedSelfImprovement == null ? {} : { framedSelfImprovement: args.framedSelfImprovement }),
1003
1072
  },
1004
1073
  stateDir,
1005
1074
  )
@@ -352,9 +352,10 @@ export function collectSweepTargets(input: {
352
352
 
353
353
  /**
354
354
  * The messageIds in `chatId` that must SURVIVE a drain because they belong to
355
- * deliberately-retained store rows: unexpired time-scoped `tool:` pins (the
356
- * `pin_message` MCP tool, #3001), which `runStatusPinBootCleanup` intentionally
357
- * KEEPS across restarts. Work-scoped rows (no `expiresAt`) are stale by
355
+ * deliberately-retained store rows: unexpired time-scoped `tool:` pins (legacy
356
+ * rows from the retired `pin_message` MCP tool, #3001 / #4452 — no new ones are
357
+ * written), which `runStatusPinBootCleanup` intentionally KEEPS across restarts
358
+ * until they expire. Work-scoped rows (no `expiresAt`) are stale by
358
359
  * definition after a restart and are NOT re-pin candidates; expired
359
360
  * time-scoped rows are due for sweeping.
360
361
  */
@@ -17,7 +17,8 @@
17
17
  * — the turn it represented is over or crashed), dropping rows only after a
18
18
  * successful unpin (failed ones are retained with an attempt counter for a
19
19
  * next-boot retry — see runStatusPinBootCleanup). Time-scoped `tool:` rows
20
- * (the `pin_message` MCP tool, #3001) survive boots until their `expiresAt`.
20
+ * (legacy pins from the retired `pin_message` MCP tool, #3001 / #4452 no new
21
+ * ones are written) survive boots until their `expiresAt`.
21
22
  * It does NOT re-adopt or re-pin — it only cleans up.
22
23
  *
23
24
  * Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
@@ -78,10 +79,10 @@ export interface PersistedStatusPin {
78
79
  /** Wall-clock ms after which this pin is stale and boot cleanup unpins it.
79
80
  * Rows WITHOUT this field are work-scoped (fg:/wk:/banner:) — stale the
80
81
  * moment their owning session dies, so boot cleanup unpins them
81
- * unconditionally. Rows WITH it (the `tool:` pins written by the
82
- * `pin_message` MCP tool, #3001) represent deliberate agent pins that have
83
- * no "work finished" event: they SURVIVE restarts and are only swept once
84
- * expired. */
82
+ * unconditionally. Rows WITH it (legacy `tool:` pins from the retired
83
+ * `pin_message` MCP tool, #3001 / #4452 no new ones are written)
84
+ * represented deliberate agent pins with no "work finished" event: they
85
+ * SURVIVE restarts and are only swept once expired. */
85
86
  expiresAt?: number
86
87
  /** Boot-cleanup unpin retry counter (#3001). Incremented each boot the
87
88
  * unpin fails (flood-wait exhausted / transient 5xx); the row is retained
@@ -282,10 +283,10 @@ export function pinnedMessageIsOurs(
282
283
  * record whose pin MAY have landed in Telegram, so we must treat it exactly
283
284
  * like a confirmed one and unpin it.
284
285
  *
285
- * TIME-SCOPED rows (`tool:` pins from the `pin_message` MCP tool, carrying
286
- * `expiresAt`) have no "work finished" event, so a restart does NOT reset
287
- * them: an unexpired row is RETAINED untouched across boots and only unpinned
288
- * once `now >= expiresAt`.
286
+ * TIME-SCOPED rows (legacy `tool:` pins from the retired `pin_message` MCP
287
+ * tool, #4452, carrying `expiresAt`) have no "work finished" event, so a
288
+ * restart does NOT reset them: an unexpired row is RETAINED untouched across
289
+ * boots and only unpinned once `now >= expiresAt`.
289
290
  *
290
291
  * RETRY-SAFETY (#3001): a row is dropped only AFTER its unpin resolves. A
291
292
  * failing unpin (flood-wait exhausted / transient 5xx) retains the row with an
@@ -90,7 +90,7 @@ import { formatTurnLifecycle } from './status-surface-log.js'
90
90
  import { removeTurnActiveMarker, touchTurnActiveMarker, writeTurnActiveMarker } from './turn-active-marker.js'
91
91
  import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
92
92
  import { decideTurnEndGate } from './turn-end-gate.js'
93
- import { finalizeBackstopSendGated } from './turn-record-status.js'
93
+ import { finalizeBackstopSendGated, computeTurnDurationMs } from './turn-record-status.js'
94
94
  import type { SilentEndDeps } from '../silent-end.js'
95
95
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
96
96
  import type { SessionEvent } from '../session-tail.js'
@@ -2174,7 +2174,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2174
2174
  chatId,
2175
2175
  threadId,
2176
2176
  turnId: turn.turnId,
2177
- turnDurationMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
2177
+ turnDurationMs: computeTurnDurationMs(turn.startedAt, Date.now()),
2178
2178
  reactionCount: reactionTransitionCounts.get(tKey) ?? 0,
2179
2179
  })
2180
2180
  }
@@ -2214,7 +2214,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2214
2214
  // still appear in turn-duration graphs.
2215
2215
  {
2216
2216
  const sKey = streamKey(chatId, threadId)
2217
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
2217
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
2218
2218
  logStreamingEvent({
2219
2219
  kind: 'turn_end',
2220
2220
  chatId,
@@ -2799,7 +2799,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2799
2799
  finalizeStatusReaction(chatId, threadId, terminalReason)
2800
2800
  {
2801
2801
  const sKey = streamKey(chatId, threadId)
2802
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
2802
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
2803
2803
  logStreamingEvent({
2804
2804
  kind: 'turn_end',
2805
2805
  chatId,
@@ -2941,6 +2941,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2941
2941
  // not what it chose to send. Pass the raw signal through so the
2942
2942
  // send states that provenance. It never gates delivery.
2943
2943
  replyToolThrewThisTurn: proseDecision.replyToolThrewThisTurn === true,
2944
+ // #4490 — same pass-through for the review-origin signal, so the
2945
+ // send path can apply the SAME card-gate / title-framing rules the
2946
+ // outbox sweep applies to a review turn's prose.
2947
+ reviewOriginated: proseDecision.reviewOriginated === true,
2944
2948
  // For the honest "(waited Ns)" clause if the exhaustion-boundary
2945
2949
  // apology fallback fires (#3228).
2946
2950
  turnDurationMs,