switchroom 0.20.12 → 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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +5 -1
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/self-improve-stop.mjs +13 -1
- package/dist/cli/switchroom.js +1472 -89
- package/dist/host-control/main.js +6 -2
- package/dist/vault/approvals/kernel-server.js +5 -1
- package/dist/vault/broker/server.js +5 -1
- package/package.json +7 -4
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/bunfig.toml +9 -5
- package/telegram-plugin/dist/gateway/gateway.js +169 -68
- package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
- package/telegram-plugin/gateway/gateway.ts +1 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
- package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
- package/telegram-plugin/gateway/stream-render.ts +4 -0
- package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
- package/telegram-plugin/hooks/audience-classify.mjs +193 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +8 -1
- package/telegram-plugin/outbox.ts +69 -3
- package/telegram-plugin/silent-end.ts +48 -5
- package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
- package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
- package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
- package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
|
@@ -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
|
-
// #
|
|
2762
|
-
//
|
|
2763
|
-
//
|
|
2764
|
-
//
|
|
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
|
|
2866
|
-
//
|
|
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
|