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.
- package/dist/agent-scheduler/index.js +5 -2
- package/dist/auth-broker/index.js +32 -25
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/self-improve-stop.mjs +13 -1
- package/dist/cli/switchroom.js +3069 -1146
- package/dist/host-control/main.js +33 -26
- package/dist/vault/approvals/kernel-server.js +32 -25
- package/dist/vault/broker/server.js +32 -25
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +7 -4
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/bunfig.toml +9 -5
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +219 -127
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
- package/telegram-plugin/gateway/gateway.ts +25 -59
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
- package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +8 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
- package/telegram-plugin/hooks/audience-classify.mjs +193 -0
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
- package/telegram-plugin/outbox.ts +69 -3
- package/telegram-plugin/silent-end.ts +48 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- 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
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
|
@@ -24,6 +24,37 @@
|
|
|
24
24
|
*/
|
|
25
25
|
export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The ONE way a turn's `duration_ms` is computed, shared by every emitter
|
|
29
|
+
* (`buildTurnRecord` → turns.jsonl, and all three `turn_ended` runtime-metric
|
|
30
|
+
* sites: the two stream-render turn-end paths + the liveness framework_fallback).
|
|
31
|
+
*
|
|
32
|
+
* Why a single helper: the three `turn_ended` emitters each hand-rolled the
|
|
33
|
+
* subtraction, and one drifted. `stream-render.ts` guarded with
|
|
34
|
+
* `startedAt > 0 ? now - startedAt : 0`; the framework_fallback path in
|
|
35
|
+
* `liveness-wiring.ts` did a bare `Date.now() - turnStartedAt` with only a
|
|
36
|
+
* `!= null` presence check. When `activeTurnStartedAt` held `0` (or any
|
|
37
|
+
* non-positive / non-finite value), that path emitted `duration_ms = now - 0`,
|
|
38
|
+
* i.e. the absolute Unix-epoch-ms — a ~56,000-year "duration". This poisoned the
|
|
39
|
+
* analysed turn_ended dataset (observed: 110 rows where `duration_ms === ts`),
|
|
40
|
+
* making every latency aggregate unusable.
|
|
41
|
+
*
|
|
42
|
+
* The invariant this helper enforces: a `duration_ms` is ALWAYS a non-negative
|
|
43
|
+
* elapsed-milliseconds value derived from a positive, finite start stamp — never
|
|
44
|
+
* an absolute epoch value, never negative (clock step back), never NaN. A start
|
|
45
|
+
* that is missing / zero / bogus yields `0` (a bounded, filterable sentinel)
|
|
46
|
+
* instead of a value that destroys aggregates.
|
|
47
|
+
*/
|
|
48
|
+
export function computeTurnDurationMs(
|
|
49
|
+
startedAt: number | null | undefined,
|
|
50
|
+
now: number,
|
|
51
|
+
): number {
|
|
52
|
+
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt) || startedAt <= 0) {
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
return Math.max(0, now - startedAt)
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
/** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
|
|
28
59
|
export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
|
|
29
60
|
|
|
@@ -261,7 +292,7 @@ export function buildTurnRecord(
|
|
|
261
292
|
return {
|
|
262
293
|
ts: Math.floor(endedAt / 1000),
|
|
263
294
|
agent: turn.agent,
|
|
264
|
-
duration_ms: turn.startedAt
|
|
295
|
+
duration_ms: computeTurnDurationMs(turn.startedAt, endedAt),
|
|
265
296
|
tools: turn.toolCallCount ?? 0,
|
|
266
297
|
status: computeTurnStatus(turn),
|
|
267
298
|
turn_id: turn.turnId,
|
|
@@ -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
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
8
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
|
|
9
9
|
"timeout": 10
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
+
"matcher": "^mcp__switchroom-telegram__(reply|stream_reply)$",
|
|
14
15
|
"hooks": [
|
|
15
16
|
{
|
|
16
17
|
"type": "command",
|
|
17
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
18
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
|
|
18
19
|
"timeout": 5
|
|
19
20
|
}
|
|
20
21
|
]
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"hooks": [
|
|
25
26
|
{
|
|
26
27
|
"type": "command",
|
|
27
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
28
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
|
|
28
29
|
"timeout": 10
|
|
29
30
|
}
|
|
30
31
|
]
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"hooks": [
|
|
34
35
|
{
|
|
35
36
|
"type": "command",
|
|
36
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
37
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
|
|
37
38
|
"timeout": 5
|
|
38
39
|
}
|
|
39
40
|
]
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"hooks": [
|
|
44
45
|
{
|
|
45
46
|
"type": "command",
|
|
46
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
47
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
|
|
47
48
|
"timeout": 5
|
|
48
49
|
}
|
|
49
50
|
]
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
"hooks": [
|
|
56
57
|
{
|
|
57
58
|
"type": "command",
|
|
58
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
59
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
|
|
59
60
|
"timeout": 10
|
|
60
61
|
}
|
|
61
62
|
]
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
"hooks": [
|
|
66
67
|
{
|
|
67
68
|
"type": "command",
|
|
68
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
69
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
|
|
69
70
|
"timeout": 3
|
|
70
71
|
}
|
|
71
72
|
]
|
|
@@ -76,7 +77,7 @@
|
|
|
76
77
|
"hooks": [
|
|
77
78
|
{
|
|
78
79
|
"type": "command",
|
|
79
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
80
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
|
|
80
81
|
"timeout": 5
|
|
81
82
|
}
|
|
82
83
|
]
|
|
@@ -87,7 +88,7 @@
|
|
|
87
88
|
"hooks": [
|
|
88
89
|
{
|
|
89
90
|
"type": "command",
|
|
90
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
91
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
|
|
91
92
|
"timeout": 15,
|
|
92
93
|
"async": true
|
|
93
94
|
}
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
"hooks": [
|
|
98
99
|
{
|
|
99
100
|
"type": "command",
|
|
100
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
101
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
|
|
101
102
|
"timeout": 5
|
|
102
103
|
}
|
|
103
104
|
]
|
|
@@ -106,7 +107,7 @@
|
|
|
106
107
|
"hooks": [
|
|
107
108
|
{
|
|
108
109
|
"type": "command",
|
|
109
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
110
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
|
|
110
111
|
"timeout": 5
|
|
111
112
|
}
|
|
112
113
|
]
|
|
@@ -115,7 +116,7 @@
|
|
|
115
116
|
"hooks": [
|
|
116
117
|
{
|
|
117
118
|
"type": "command",
|
|
118
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
119
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
|
|
119
120
|
"timeout": 5,
|
|
120
121
|
"async": true
|
|
121
122
|
}
|
|
@@ -108,7 +108,7 @@ export function isStructuralNarration(text, followedByToolUse) {
|
|
|
108
108
|
* follow-up, MF4). A text block followed ONLY by tools in this set is still the
|
|
109
109
|
* TERMINAL answer for backstop-coalescing purposes — these tools carry no
|
|
110
110
|
* model-authored answer text and are the only ones plausibly fired AFTER a
|
|
111
|
-
* terminal answer (answer, then react /
|
|
111
|
+
* terminal answer (answer, then react / typing / edit). Everything NOT in
|
|
112
112
|
* this set — work / deliverable tools (download_attachment, get_recent_messages,
|
|
113
113
|
* send_checklist, ask_user, …) and every non-telegram tool (retain / read /
|
|
114
114
|
* bash / web / …) — is turn-CONTINUING: a text block a real tool followed is
|
|
@@ -123,7 +123,6 @@ export function isStructuralNarration(text, followedByToolUse) {
|
|
|
123
123
|
export const EPHEMERAL_TOOLS = new Set([
|
|
124
124
|
'react',
|
|
125
125
|
'send_typing',
|
|
126
|
-
'pin_message',
|
|
127
126
|
'delete_message',
|
|
128
127
|
'edit_message',
|
|
129
128
|
])
|
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
import {
|
|
73
73
|
decideCaptureAudience,
|
|
74
74
|
resolveOpenObligation,
|
|
75
|
+
isReviewOriginatedSource,
|
|
76
|
+
isSelfImprovementCard,
|
|
75
77
|
AUDIENCE_INTERNAL,
|
|
76
78
|
} from './audience-classify.mjs'
|
|
77
79
|
|
|
@@ -129,6 +131,19 @@ function buildNextState(base, decision, retryCount) {
|
|
|
129
131
|
} else {
|
|
130
132
|
delete next.replyToolThrewThisTurn
|
|
131
133
|
}
|
|
134
|
+
// #4490: same carry-through for review-turn provenance. The outbox capture
|
|
135
|
+
// path (`writeOutboxRecord` above) stamps `reviewOriginated` from the SAME
|
|
136
|
+
// envelope `source` tag; the ELECTED path ('trailing-text-after-reply') never
|
|
137
|
+
// writes an outbox record, so the captured-prose bridge (the delivering
|
|
138
|
+
// machine on this path) has no other way to learn it. Explicitly deleted
|
|
139
|
+
// when this turn's scan saw no review-source envelope, so a spread of a
|
|
140
|
+
// prior turn's file can never mislabel a normal turn's prose as a review
|
|
141
|
+
// turn's (or vice versa).
|
|
142
|
+
if (isReviewOriginatedSource(decision.source)) {
|
|
143
|
+
next.reviewOriginated = true
|
|
144
|
+
} else {
|
|
145
|
+
delete next.reviewOriginated
|
|
146
|
+
}
|
|
132
147
|
return next
|
|
133
148
|
}
|
|
134
149
|
|
|
@@ -181,7 +196,7 @@ function outboxAlreadyDelivered(outboxDir, nonce) {
|
|
|
181
196
|
* `'unknown'` → the record delivers exactly as it does today.
|
|
182
197
|
*
|
|
183
198
|
* @param {string} stateDir
|
|
184
|
-
* @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean }} capture
|
|
199
|
+
* @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean, source?: string|null }} capture
|
|
185
200
|
* @returns {'user' | 'internal'}
|
|
186
201
|
*/
|
|
187
202
|
function classifyCaptureAudience(stateDir, capture) {
|
|
@@ -212,6 +227,14 @@ function classifyCaptureAudience(stateDir, capture) {
|
|
|
212
227
|
process.env.TELEGRAM_ACCESS_MODE !== 'static'
|
|
213
228
|
return decideCaptureAudience({
|
|
214
229
|
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
230
|
+
// A self-improvement review turn has exactly ONE sanctioned operator-facing
|
|
231
|
+
// output: a well-formed self-improvement CARD. Deterministic on the enqueue
|
|
232
|
+
// envelope's `source` tag (the same signal the rest of the self-improve
|
|
233
|
+
// machinery routes on) plus the EXACT card-shape of the captured text. A
|
|
234
|
+
// card delivers; any other trailing prose (the raw-reasoning leak) is
|
|
235
|
+
// suppressed. Independent of the reply-throw / obligation state below.
|
|
236
|
+
reviewOriginated: isReviewOriginatedSource(capture.source),
|
|
237
|
+
reviewTextIsCard: isSelfImprovementCard(capture.text),
|
|
215
238
|
openInboundObligation: resolveOpenObligation({
|
|
216
239
|
snapshotRaw,
|
|
217
240
|
snapshotTrusted,
|
|
@@ -265,6 +288,13 @@ function writeOutboxRecord(stateDir, capture, audience) {
|
|
|
265
288
|
// not catch (the foreground case). Stamped here because this is the last
|
|
266
289
|
// point in the pipeline that can still see the transcript.
|
|
267
290
|
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
291
|
+
// Ken 2026-08-07: did this turn originate from a self-improvement review
|
|
292
|
+
// inbound? `audience` already consumed it (with the card-shape signal) to
|
|
293
|
+
// deliver a well-formed card and suppress raw reasoning by default; the
|
|
294
|
+
// sweep consumes this raw flag again, on its own, to prepend the
|
|
295
|
+
// self-improvement TITLE to any review record delivered with the audience
|
|
296
|
+
// gate OFF, so review reasoning can never appear as raw, unlabelled prose.
|
|
297
|
+
reviewOriginated: isReviewOriginatedSource(capture.source),
|
|
268
298
|
}
|
|
269
299
|
const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
|
|
270
300
|
writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// Cross-checked the full tool surface in `telegram-plugin/bridge/bridge.ts`
|
|
42
42
|
// (`TOOL_SCHEMAS`, kept in sync with `gateway/gateway.ts`): `edit_message`
|
|
43
43
|
// explicitly does NOT ping/deliver a fresh answer (its own description says
|
|
44
|
-
// "send a new reply when a long task completes"); `react`,
|
|
44
|
+
// "send a new reply when a long task completes"); `react`,
|
|
45
45
|
// `delete_message`, `forward_message`, `send_typing`, `download_attachment`,
|
|
46
46
|
// `get_recent_messages` carry no model-authored answer text at all;
|
|
47
47
|
// `send_checklist` / `send_sticker` / `send_gif` / `ask_user` /
|
|
@@ -259,6 +259,13 @@ function buildTurnId(chatId, threadId, messageId) {
|
|
|
259
259
|
*/
|
|
260
260
|
function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, replyToolThrewThisTurn) {
|
|
261
261
|
const block = { decided: 'block', reason }
|
|
262
|
+
// #4490: the enqueue envelope's RAW `source` tag, carried through exactly
|
|
263
|
+
// like `replyToolThrewThisTurn` above — the single-writer election needs it
|
|
264
|
+
// to derive `reviewOriginated` for the captured-prose bridge (the ELECTED
|
|
265
|
+
// path's deliverer, which never writes an outbox record and so has no other
|
|
266
|
+
// way to learn a review turn's provenance). Omitted when the envelope carried
|
|
267
|
+
// none, so a spread of a prior state file is never read as positive evidence.
|
|
268
|
+
if (envelope.source != null) block.source = envelope.source
|
|
262
269
|
if (replyToolThrewThisTurn === true) block.replyToolThrewThisTurn = true
|
|
263
270
|
// Single-writer election input (#duplicate-message fix): does ANY
|
|
264
271
|
// non-empty, non-silent trailing text block exist after the last
|
|
@@ -360,7 +367,7 @@ function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, reply
|
|
|
360
367
|
* false-positive that burned retry budget on healthy turns.
|
|
361
368
|
*
|
|
362
369
|
* @param {string} jsonl
|
|
363
|
-
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string }}
|
|
370
|
+
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string, source?: string, replyToolThrewThisTurn?: boolean }}
|
|
364
371
|
*/
|
|
365
372
|
export function scanTurnForFinalReply(jsonl) {
|
|
366
373
|
const lines = jsonl.split('\n')
|
|
@@ -75,6 +75,9 @@ import type { Audience } from './hooks/audience-classify.mjs'
|
|
|
75
75
|
import {
|
|
76
76
|
applyReplyThrowFraming,
|
|
77
77
|
shouldFrameReplyThrow,
|
|
78
|
+
applySelfImprovementFraming,
|
|
79
|
+
shouldFrameSelfImprovement,
|
|
80
|
+
isSelfImprovementCard,
|
|
78
81
|
} from './hooks/audience-classify.mjs'
|
|
79
82
|
|
|
80
83
|
export type { Audience }
|
|
@@ -162,6 +165,25 @@ export interface OutboxRecord {
|
|
|
162
165
|
* exact boolean `true` changes anything — see `shouldFrameReplyThrow`.
|
|
163
166
|
*/
|
|
164
167
|
replyToolThrewThisTurn?: boolean
|
|
168
|
+
/**
|
|
169
|
+
* Ken 2026-08-07: did the turn that produced this record originate from a
|
|
170
|
+
* self-improvement review inbound (`source="self_improve_review"`)? Stamped at
|
|
171
|
+
* capture by `hooks/silent-end-interrupt-stop.mjs` from the enqueue envelope's
|
|
172
|
+
* source tag.
|
|
173
|
+
*
|
|
174
|
+
* `audience` already consumed it once — a review turn classifies `'internal'`
|
|
175
|
+
* (`decideCaptureAudience`), so by default it is SUPPRESSED at the sweep's
|
|
176
|
+
* entry gate and never delivered. The sweep consumes this raw flag again, on
|
|
177
|
+
* its own, only for the residual: a review record that IS delivered (the
|
|
178
|
+
* audience gate turned off, or a legacy/`user` route) must carry the
|
|
179
|
+
* self-improvement TITLE so it can never appear as raw, unlabelled agent
|
|
180
|
+
* reasoning — the leak this closes.
|
|
181
|
+
*
|
|
182
|
+
* OPTIONAL: absent on every pre-change record and on non-review turns, which
|
|
183
|
+
* deliver unframed. Only an exact boolean `true` changes anything — see
|
|
184
|
+
* `shouldFrameSelfImprovement`.
|
|
185
|
+
*/
|
|
186
|
+
reviewOriginated?: boolean
|
|
165
187
|
}
|
|
166
188
|
|
|
167
189
|
/** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
|
|
@@ -204,6 +226,16 @@ export interface DeliveredEntry {
|
|
|
204
226
|
* framed delivery is a delivery.
|
|
205
227
|
*/
|
|
206
228
|
framedProvenance?: 'reply-throw'
|
|
229
|
+
/**
|
|
230
|
+
* Ken 2026-08-07: this delivery carried the self-improvement TITLE header
|
|
231
|
+
* (`SELF_IMPROVEMENT_TITLE`) in front of a review-originated record's prose.
|
|
232
|
+
* Durable and terminal, for the same reason `framedProvenance` is: framing
|
|
233
|
+
* changes what a human sees, so the outcome is recorded as an explicit named
|
|
234
|
+
* state on the journal line rather than being inferable only from a log line
|
|
235
|
+
* that may have rotated away. `tgMessageId` IS present on these lines — a
|
|
236
|
+
* framed delivery is a delivery.
|
|
237
|
+
*/
|
|
238
|
+
framedSelfImprovement?: 'self-improve'
|
|
207
239
|
}
|
|
208
240
|
|
|
209
241
|
export function sha256Hex(s: string): string {
|
|
@@ -519,6 +551,13 @@ export interface OutboxSweepDecision {
|
|
|
519
551
|
* the rule.
|
|
520
552
|
*/
|
|
521
553
|
framedProvenance?: 'reply-throw'
|
|
554
|
+
/**
|
|
555
|
+
* Ken 2026-08-07: `text` carries the self-improvement title header. Surfaced
|
|
556
|
+
* on the decision (rather than left implicit in a string compare) so the
|
|
557
|
+
* caller can journal the outcome and emit telemetry without re-deriving the
|
|
558
|
+
* rule — same shape as `framedProvenance`.
|
|
559
|
+
*/
|
|
560
|
+
framedSelfImprovement?: 'self-improve'
|
|
522
561
|
}
|
|
523
562
|
|
|
524
563
|
/**
|
|
@@ -538,7 +577,7 @@ export interface OutboxSweepDecision {
|
|
|
538
577
|
export function decideOutboxSweep(input: {
|
|
539
578
|
record: Pick<
|
|
540
579
|
OutboxRecord,
|
|
541
|
-
'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn'
|
|
580
|
+
'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn' | 'reviewOriginated'
|
|
542
581
|
>
|
|
543
582
|
now: number
|
|
544
583
|
deliveredNonces: Set<string>
|
|
@@ -566,6 +605,13 @@ export function decideOutboxSweep(input: {
|
|
|
566
605
|
* revert-check can flip it in-process.
|
|
567
606
|
*/
|
|
568
607
|
provenanceFraming?: boolean
|
|
608
|
+
/**
|
|
609
|
+
* Ken 2026-08-07 kill switch for the self-improvement TITLE header. `false`
|
|
610
|
+
* restores the pre-change body byte-for-byte; the default is ON. Passed in
|
|
611
|
+
* rather than read from env here so this core stays pure and a revert-check
|
|
612
|
+
* can flip it in-process.
|
|
613
|
+
*/
|
|
614
|
+
selfImprovementFraming?: boolean
|
|
569
615
|
}): OutboxSweepDecision {
|
|
570
616
|
const {
|
|
571
617
|
record,
|
|
@@ -578,6 +624,7 @@ export function decideOutboxSweep(input: {
|
|
|
578
624
|
maxAgeMs = OUTBOX_MAX_AGE_MS,
|
|
579
625
|
shownLedgerHit = false,
|
|
580
626
|
provenanceFraming = true,
|
|
627
|
+
selfImprovementFraming = true,
|
|
581
628
|
} = input
|
|
582
629
|
if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
|
|
583
630
|
if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
|
|
@@ -592,11 +639,30 @@ export function decideOutboxSweep(input: {
|
|
|
592
639
|
// the banner describes the TEXT. It is additive only: it can never turn a
|
|
593
640
|
// send into a skip, so this branch cannot manufacture silence.
|
|
594
641
|
const framed = shouldFrameReplyThrow(record, { frameEnabled: provenanceFraming })
|
|
595
|
-
const
|
|
642
|
+
const provenanceBody = framed ? applyReplyThrowFraming(record.text) : record.text
|
|
643
|
+
// Ken 2026-08-07: the self-improvement title is the OUTERMOST content line, so
|
|
644
|
+
// it is applied AFTER the reply-throw banner — a review record that somehow
|
|
645
|
+
// also threw reads "🔧 Self-improvement" first, then the provenance note, then
|
|
646
|
+
// the prose. Additive only: it can never turn a send into a skip.
|
|
647
|
+
//
|
|
648
|
+
// #4489: idempotency is gated on the RAW `record.text`, NOT on `provenanceBody`.
|
|
649
|
+
// A real card's `record.text` opens with the title; the reply-throw banner
|
|
650
|
+
// above is PREPENDED in front of it, so `provenanceBody` no longer opens with
|
|
651
|
+
// the title even though the underlying card does. `applySelfImprovementFraming`
|
|
652
|
+
// alone can't see that — its idempotency check only looks at what it was
|
|
653
|
+
// handed — so gating the whole decision on the raw text is what keeps a real
|
|
654
|
+
// card from acquiring a second, duplicated title when both framings compose.
|
|
655
|
+
const selfImproveFramed =
|
|
656
|
+
shouldFrameSelfImprovement(record, { frameEnabled: selfImprovementFraming }) &&
|
|
657
|
+
!isSelfImprovementCard(record.text)
|
|
658
|
+
const body = selfImproveFramed ? applySelfImprovementFraming(provenanceBody) : provenanceBody
|
|
596
659
|
return {
|
|
597
660
|
action: delayed ? 'send-delayed' : 'send',
|
|
598
661
|
text: prefix + body,
|
|
599
|
-
...(framed &&
|
|
662
|
+
...(framed && provenanceBody !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
|
|
663
|
+
...(selfImproveFramed && body !== provenanceBody
|
|
664
|
+
? { framedSelfImprovement: 'self-improve' as const }
|
|
665
|
+
: {}),
|
|
600
666
|
}
|
|
601
667
|
}
|
|
602
668
|
|