switchroom 0.20.12 → 0.20.14

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.
@@ -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,
@@ -9919,6 +9919,7 @@ async function deliverCapturedProse(args: {
9919
9919
  text: string
9920
9920
  /** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
9921
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)
9922
9923
  }): Promise<void> {
9923
9924
  // #2996 P2: body moved VERBATIM to outbound-send-path.ts (single tested
9924
9925
  // send module). Injects the ONE `outboundDedup` instance (Amendment 1/9).
@@ -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
  )
@@ -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,
@@ -3,9 +3,16 @@ export type Audience = 'user' | 'internal'
3
3
  export const AUDIENCE_USER: 'user'
4
4
  export const AUDIENCE_INTERNAL: 'internal'
5
5
 
6
+ /** Mirror of `REVIEW_SOURCE` in `src/self-improve/review-prompt.ts`. */
7
+ export const REVIEW_SOURCE: 'self_improve_review'
8
+
9
+ export function isReviewOriginatedSource(source: string | null | undefined): boolean
10
+
6
11
  export function decideCaptureAudience(signals: {
7
12
  replyToolThrewThisTurn?: boolean
8
13
  openInboundObligation?: true | false | 'unknown'
14
+ reviewOriginated?: boolean
15
+ reviewTextIsCard?: boolean
9
16
  }): Audience
10
17
 
11
18
  export function resolveOpenObligation(args: {
@@ -39,6 +46,25 @@ export function formatReplyThrowFraming(s: {
39
46
  source?: string
40
47
  }): string
41
48
 
49
+ export const SELF_IMPROVEMENT_TITLE: string
50
+
51
+ export function isSelfImprovementCard(text: string | null | undefined): boolean
52
+
53
+ export function shouldFrameSelfImprovement(
54
+ record: { reviewOriginated?: unknown },
55
+ opts?: { frameEnabled?: boolean },
56
+ ): boolean
57
+
58
+ export function applySelfImprovementFraming(text: string): string
59
+
60
+ export function formatSelfImprovementFraming(s: {
61
+ turnNonce: string
62
+ turnId?: string | null
63
+ chatId?: string | null
64
+ textSha256?: string
65
+ source?: string
66
+ }): string
67
+
42
68
  export function formatInternalSuppression(s: {
43
69
  turnNonce: string
44
70
  turnId?: string | null
@@ -35,6 +35,39 @@ export const AUDIENCE_USER = 'user'
35
35
  */
36
36
  export const AUDIENCE_INTERNAL = 'internal'
37
37
 
38
+ /**
39
+ * The `meta.source` a self-improvement review turn's synthesized inbound
40
+ * carries. MUST stay byte-identical to `REVIEW_SOURCE` in
41
+ * `src/self-improve/review-prompt.ts` — this unbundled `.mjs` cannot import the
42
+ * TS module, so the constant is mirrored here (same discipline as `MAX_RETRIES`
43
+ * / `isTurnFlushSafetyEnabledEnv`). `tests/self-improve-review-audience.test.ts`
44
+ * pins the equality so a rename on either side reds CI.
45
+ *
46
+ * WHY IT LIVES IN THE AUDIENCE MODULE. A self-improvement review turn is
47
+ * injected with an explicit contract (`buildReviewPrompt`): "act SILENTLY, do
48
+ * NOT reply to the operator, end the turn when done." Its trailing transcript
49
+ * prose is therefore the agent reasoning to ITSELF — an `internal` audience by
50
+ * construction, exactly the vocabulary this module owns. Ken hit the leak this
51
+ * closes: a silent review turn's mid-turn reasoning ("I own personal-garmin,
52
+ * but the script I hand-rolled…") was captured by the outbox backstop and
53
+ * delivered into his DM as a raw, unlabelled message.
54
+ */
55
+ export const REVIEW_SOURCE = 'self_improve_review'
56
+
57
+ /**
58
+ * Did the turn that produced this capture originate from a self-improvement
59
+ * review inbound? Deterministic: it keys ONLY on the enqueue envelope's
60
+ * `source="…"` attribute (parsed upstream by `parseChannelEnvelope`), never on
61
+ * prose shape or a wording heuristic — the same source tag the rest of the
62
+ * self-improve machinery already routes on.
63
+ *
64
+ * @param {string | null | undefined} source The capture/record `source` field.
65
+ * @returns {boolean}
66
+ */
67
+ export function isReviewOriginatedSource(source) {
68
+ return source === REVIEW_SOURCE
69
+ }
70
+
38
71
  /**
39
72
  * Obligation state for the resolved chat, as seen at capture time.
40
73
  *
@@ -72,13 +105,46 @@ export const AUDIENCE_INTERNAL = 'internal'
72
105
  * would let a heuristic prose-shape match silently swallow a real answer —
73
106
  * exactly the severity-3 error this function is built to avoid.
74
107
  *
108
+ * `reviewOriginated` is the ONE other positive signal, and it is deterministic
109
+ * rather than heuristic. A self-improvement review turn is injected with a
110
+ * SYNTHESIZED inbound (`source="self_improve_review"`); no operator is waiting on
111
+ * an answer to it. Its contract (`buildReviewPrompt`) gives it exactly ONE
112
+ * operator-facing output: a well-formed self-improvement CARD (leading
113
+ * `SELF_IMPROVEMENT_TITLE` line) when — and only when — it surfaces a real
114
+ * outcome; otherwise it stays silent. So the review branch is checked FIRST and
115
+ * routes on card-shape:
116
+ *
117
+ * - review + text IS a card (`reviewTextIsCard === true`) ⇒ `user`. The card
118
+ * is the sanctioned surfacing message; deliver it. It is self-labelled by
119
+ * construction (it opens with the title), so it can never appear as raw,
120
+ * unlabelled reasoning.
121
+ * - review + text is NOT a card ⇒ `internal`. This is the leak Ken hit — a
122
+ * review turn's mid-turn reasoning captured by the backstop — and it is
123
+ * SUPPRESSED. Deterministic: the gate is an EXACT title-line prefix, not a
124
+ * fuzzy prose-shape guess, and the failure direction is SAFE (a mis-typed
125
+ * card is withheld, never a real answer swallowed — a review inbound has no
126
+ * waiting question to swallow, so this never manufactures the severity-3
127
+ * silent no-op the asymmetry above guards against).
128
+ *
129
+ * `reviewTextIsCard` is scoped to review turns ONLY; it can never affect a
130
+ * normal user turn's classification, so the "no prose-shape heuristic on user
131
+ * text" rule above is intact.
132
+ *
75
133
  * @param {{
76
134
  * replyToolThrewThisTurn?: boolean,
77
135
  * openInboundObligation?: true | false | 'unknown',
136
+ * reviewOriginated?: boolean,
137
+ * reviewTextIsCard?: boolean,
78
138
  * }} signals
79
139
  * @returns {'user' | 'internal'}
80
140
  */
81
141
  export function decideCaptureAudience(signals) {
142
+ // Self-improvement review turns (see the header note): the sanctioned card
143
+ // delivers (`user`); any other trailing prose is the leak and is suppressed
144
+ // (`internal`). Deterministic, independent of the reply-throw path below.
145
+ if (signals?.reviewOriginated === true) {
146
+ return signals?.reviewTextIsCard === true ? AUDIENCE_USER : AUDIENCE_INTERNAL
147
+ }
82
148
  const threw = signals?.replyToolThrewThisTurn === true
83
149
  if (!threw) return AUDIENCE_USER
84
150
  // Only a POSITIVE, known-empty obligation state clears the second gate.
@@ -330,6 +396,133 @@ export function formatReplyThrowFraming(s) {
330
396
  )
331
397
  }
332
398
 
399
+ /**
400
+ * ── Self-improvement review labelling (Ken, 2026-08-07) ──────────────────────
401
+ *
402
+ * TWO layers, both in this one shared module so the hook (which classifies +
403
+ * stamps) and the sweep (which delivers) run identical rules:
404
+ *
405
+ * 1. CARD GATE (primary, `decideCaptureAudience` above). A review turn's
406
+ * trailing backstop text is delivered to the operator ONLY IF it is a
407
+ * well-formed self-improvement card — `isSelfImprovementCard`, an EXACT
408
+ * leading-`SELF_IMPROVEMENT_TITLE` prefix. Non-card review prose (the raw
409
+ * reasoning Ken saw leak) classifies `internal` and is suppressed. This is
410
+ * the deterministic "never leak raw reasoning; the card is the only
411
+ * operator-facing output" guarantee.
412
+ *
413
+ * 2. TITLE FRAMING (residual). The card gate delivers only text that is
414
+ * ALREADY self-titled, so in the default config the delivered body needs no
415
+ * relabelling. This block is the belt-and-braces for the degraded config:
416
+ * if the audience gate is turned OFF (`SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE=0`)
417
+ * so a NON-card review record reaches delivery, the title is prepended so it
418
+ * can still never appear as raw, unlabelled reasoning. Idempotent: text that
419
+ * already opens with the title is left untouched (no double title on a real
420
+ * card). Mirrors the reply-throw framing above — pure predicate + pure body
421
+ * transform + telemetry — and is additive only, so it can never manufacture
422
+ * silence.
423
+ */
424
+
425
+ /**
426
+ * The title line every self-improvement card opens with, and the label the
427
+ * residual framing prepends. `🔧 **Self-improvement**` — a leading glyph + bold
428
+ * so the operator sees at a glance this is a review note, not a normal reply.
429
+ * The card contract (`buildReviewPrompt`) continues the same line with
430
+ * ` — <one-line outcome>`, so this is a PREFIX of a real card, which is exactly
431
+ * what `isSelfImprovementCard` keys on.
432
+ */
433
+ export const SELF_IMPROVEMENT_TITLE = '🔧 **Self-improvement**'
434
+
435
+ /**
436
+ * Is `text` a well-formed self-improvement card — i.e. does it open with the
437
+ * `SELF_IMPROVEMENT_TITLE` line? This is the deterministic gate that separates
438
+ * the sanctioned surfacing card (deliver) from raw review reasoning (suppress).
439
+ *
440
+ * EXACT structural prefix, not a fuzzy prose-shape match: the model is
441
+ * instructed to emit the title verbatim as the first line of its one surfacing
442
+ * message, and the failure direction is SAFE — a mis-formatted card is withheld
443
+ * (silence), never a real answer delivered. Leading whitespace is tolerated so a
444
+ * stray newline before the title does not defeat the gate.
445
+ *
446
+ * @param {string | null | undefined} text
447
+ * @returns {boolean}
448
+ */
449
+ export function isSelfImprovementCard(text) {
450
+ if (typeof text !== 'string') return false
451
+ return text.trimStart().startsWith(SELF_IMPROVEMENT_TITLE)
452
+ }
453
+
454
+ /**
455
+ * Should this record's delivered text carry the residual title header?
456
+ *
457
+ * POSITIVE evidence only: an exact `true` on the record's persisted
458
+ * `reviewOriginated`. Missing / `undefined` / `'true'` / `1` — anything that is
459
+ * not the boolean — changes nothing, so a non-review record (every normal turn)
460
+ * delivers byte-for-byte as it does today. Text that is already a card is a
461
+ * no-op at `applySelfImprovementFraming` (idempotent), so this predicate stays
462
+ * simple: "is this a review record".
463
+ *
464
+ * `frameEnabled === false` is the kill switch, and it is the seam a revert-check
465
+ * flips.
466
+ *
467
+ * @param {{ reviewOriginated?: unknown }} record
468
+ * @param {{ frameEnabled?: boolean }} [opts]
469
+ * @returns {boolean}
470
+ */
471
+ export function shouldFrameSelfImprovement(record, opts) {
472
+ if (opts?.frameEnabled === false) return false
473
+ return record?.reviewOriginated === true
474
+ }
475
+
476
+ /**
477
+ * Compose the title header onto the delivered body. Pure; the caller owns the
478
+ * `(delayed) ` / `(from background task) ` delivery prefixes, which stay OUTSIDE
479
+ * (they describe the delivery, this describes the text). The title is the
480
+ * OUTERMOST content line so it always reads first, even when the reply-throw
481
+ * banner is also present.
482
+ *
483
+ * IDEMPOTENT: text that already opens with the title (a real card) is returned
484
+ * unchanged, so a delivered card never carries a duplicated title.
485
+ *
486
+ * @param {string} text
487
+ * @returns {string}
488
+ */
489
+ export function applySelfImprovementFraming(text) {
490
+ const body = typeof text === 'string' ? text : ''
491
+ // Empty body: the sweep's send adapter early-returns on empty text, and a
492
+ // title over nothing would be a message about nothing. Leave it.
493
+ if (body.trim().length === 0) return body
494
+ // Already a card — do not prepend a second title.
495
+ if (isSelfImprovementCard(body)) return body
496
+ return `${SELF_IMPROVEMENT_TITLE}\n\n${body}`
497
+ }
498
+
499
+ /**
500
+ * Structured telemetry for a self-improvement-framed delivery — the
501
+ * observability half of the rule, mirroring {@link formatReplyThrowFraming}.
502
+ * Framing changes what a human sees, so it must never be inferable only from the
503
+ * absence of a log line. Worded to match NONE of `GATEWAY_SIGNATURES` in
504
+ * `src/fleet-health/detect.ts`: a framed delivery IS a delivery, and must not
505
+ * page anyone.
506
+ *
507
+ * @param {{
508
+ * turnNonce: string,
509
+ * turnId?: string | null,
510
+ * chatId?: string | null,
511
+ * textSha256?: string,
512
+ * source?: string,
513
+ * }} s
514
+ * @returns {string}
515
+ */
516
+ export function formatSelfImprovementFraming(s) {
517
+ return (
518
+ `telegram gateway: outbox self-improvement framing nonce=${s.turnNonce} ` +
519
+ `turnId=${s.turnId ?? 'unknown'} chatId=${s.chatId ?? 'unresolved'} ` +
520
+ `sha=${(s.textSha256 ?? '').slice(0, 12)} source=${s.source ?? 'unknown'} ` +
521
+ `reviewOriginated=true — review-turn prose delivered with its self-improvement ` +
522
+ `title, never as raw unlabelled reasoning\n`
523
+ )
524
+ }
525
+
333
526
  /**
334
527
  * The structured telemetry line emitted when the sweep suppresses an
335
528
  * `internal` record. Mirrors `formatOrphanEscalation` (#4104): an exported pure