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
|
@@ -24962,18 +24962,6 @@ var init_bridge = __esm(async () => {
|
|
|
24962
24962
|
required: ["chat_id"]
|
|
24963
24963
|
}
|
|
24964
24964
|
},
|
|
24965
|
-
{
|
|
24966
|
-
name: "pin_message",
|
|
24967
|
-
description: "Pin a message in a Telegram chat. Useful for important outputs the user wants to find later. Requires admin rights in groups.",
|
|
24968
|
-
inputSchema: {
|
|
24969
|
-
type: "object",
|
|
24970
|
-
properties: {
|
|
24971
|
-
chat_id: { type: "string" },
|
|
24972
|
-
message_id: { type: "string" }
|
|
24973
|
-
},
|
|
24974
|
-
required: ["chat_id", "message_id"]
|
|
24975
|
-
}
|
|
24976
|
-
},
|
|
24977
24965
|
{
|
|
24978
24966
|
name: "delete_message",
|
|
24979
24967
|
description: "Delete a message the bot previously sent. Prefer edit_message if you just want to update text \u2014 delete_message is for true removal.",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
* `deliver` (via `deliverAnswer`), the durable oracle, and the obligation I/O.
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
|
-
import { hasOutboundWithText } from '../history.js'
|
|
71
|
+
import { hasOutboundWithText as realHasOutboundWithText } from '../history.js'
|
|
72
72
|
import type { BackstopDeliveryLedger } from './backstop-delivery.js'
|
|
73
73
|
import type { CapturedDeliverySnapshot, Obligation } from './obligation-ledger.js'
|
|
74
74
|
|
|
@@ -168,6 +168,27 @@ export interface CapturedResumePorts {
|
|
|
168
168
|
/** True when history is queryable (else the durable-text oracle reconcile that
|
|
169
169
|
* gives crash-idempotency — §2.3/A5 — is skipped). */
|
|
170
170
|
historyEnabled: boolean
|
|
171
|
+
/**
|
|
172
|
+
* The durable outbound-text oracle. Defaults to the real `history.js`
|
|
173
|
+
* implementation; tests inject a fake here instead of module-mocking
|
|
174
|
+
* `../history.js`. A `vi.mock('../history.js', ...)` looks file-scoped
|
|
175
|
+
* under vitest but is NOT under bun's vitest-compat layer (`bun test`
|
|
176
|
+
* maps `vi.mock` onto the PROCESS-GLOBAL `mock.module`, which retroactively
|
|
177
|
+
* rebinds every other file's import of the same specifier in the same
|
|
178
|
+
* `bun-test-run` sweep — see check-bun-module-mock-scope.mjs). That leak
|
|
179
|
+
* was the actual root cause of #4488/#4491: a mocked `hasOutboundWithText`
|
|
180
|
+
* from this module's own test file silently answered
|
|
181
|
+
* `telegram-plugin/tests/history.test.ts`'s calls to the REAL function
|
|
182
|
+
* elsewhere in the same 651-file sweep, so a row `history.test.ts` had
|
|
183
|
+
* genuinely just written was reported as not found. Injecting via this
|
|
184
|
+
* port keeps the fake scoped to the dispatcher instance that receives it.
|
|
185
|
+
*/
|
|
186
|
+
hasOutboundWithText?: (
|
|
187
|
+
chatId: string,
|
|
188
|
+
text: string,
|
|
189
|
+
threadId: number | null,
|
|
190
|
+
sinceMs?: number,
|
|
191
|
+
) => boolean
|
|
171
192
|
stderr?: (s: string) => void
|
|
172
193
|
}
|
|
173
194
|
|
|
@@ -198,6 +219,7 @@ export interface CapturedResumeDispatcher {
|
|
|
198
219
|
*/
|
|
199
220
|
export function createCapturedResumeDispatcher(ports: CapturedResumePorts): CapturedResumeDispatcher {
|
|
200
221
|
const stderr = ports.stderr ?? (() => {})
|
|
222
|
+
const hasOutboundWithText = ports.hasOutboundWithText ?? realHasOutboundWithText
|
|
201
223
|
const inFlight = new Set<string>()
|
|
202
224
|
|
|
203
225
|
/** Re-deliver the UNSENT tail of `o`'s captured answer, byte-identical,
|
|
@@ -8370,8 +8370,7 @@ const statusPinClaims = new Map<string, StatusPinClaim>()
|
|
|
8370
8370
|
// Rights-aware negative cache (#3024): chats where an auto status-pin attempt
|
|
8371
8371
|
// failed with the permanent "not enough rights to manage pinned messages" 400.
|
|
8372
8372
|
// Per-process only — a restart clears it so a later-granted pin right re-enables
|
|
8373
|
-
// auto-pin.
|
|
8374
|
-
// this cache (it always attempts and surfaces the error to the agent).
|
|
8373
|
+
// auto-pin.
|
|
8375
8374
|
const statusPinRightsCache = new PinRightsCache()
|
|
8376
8375
|
|
|
8377
8376
|
// Durable snapshot of the pin claim set on the persistent per-agent volume
|
|
@@ -8466,21 +8465,16 @@ const BANNER_PIN_KEY = 'banner:owner'
|
|
|
8466
8465
|
// run → no-op). The boot-cleanup gate below is widened to cover this.
|
|
8467
8466
|
const bannerPinPersistEnabled = !STATIC
|
|
8468
8467
|
|
|
8469
|
-
// `
|
|
8470
|
-
//
|
|
8471
|
-
//
|
|
8472
|
-
//
|
|
8473
|
-
//
|
|
8474
|
-
//
|
|
8475
|
-
//
|
|
8468
|
+
// LEGACY `tool:` pin drain (#3001; the `pin_message` MCP tool was retired in
|
|
8469
|
+
// #4452). No new `tool:<chatId>:<messageId>` rows are ever written now that the
|
|
8470
|
+
// tool is gone, but rows an earlier build persisted may still sit in the shared
|
|
8471
|
+
// status-pins.json with a TTL (`expiresAt`). This flag keeps the boot cleanup /
|
|
8472
|
+
// stale-pin sweep aware of those legacy rows so they are still expired and
|
|
8473
|
+
// drained correctly on upgrade — an unexpired legacy row is preserved across
|
|
8474
|
+
// boots (never reaped as if it were a work-scoped card), then unpinned once its
|
|
8475
|
+
// TTL lapses, exactly as before. Independent of PIN_STATUS_WHILE_WORKING.
|
|
8476
|
+
// Self-clears within one TTL window since nothing writes new tool rows.
|
|
8476
8477
|
const toolPinPersistEnabled = !STATIC
|
|
8477
|
-
// 7 days: generous — an agent-pinned message the operator still cares about
|
|
8478
|
-
// after a week has usually been re-pinned or acted on; anything older is the
|
|
8479
|
-
// stale-pin long tail this issue exists to clear. Override for tuning.
|
|
8480
|
-
const TOOL_PIN_TTL_MS = (() => {
|
|
8481
|
-
const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
|
|
8482
|
-
return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
|
|
8483
|
-
})()
|
|
8484
8478
|
|
|
8485
8479
|
// Persist (or drop) the slot-banner's pin row into the shared store. Routes
|
|
8486
8480
|
// through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
|
|
@@ -8950,8 +8944,7 @@ async function reconcileStatusPin(
|
|
|
8950
8944
|
// "not enough rights to manage pinned messages" 400 in a supergroup took the
|
|
8951
8945
|
// whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
|
|
8952
8946
|
// must NEVER be able to crash the gateway. Any throw here is logged and
|
|
8953
|
-
// absorbed.
|
|
8954
|
-
// as a normal tool-error — that path is `executePinMessage`, not this one.)
|
|
8947
|
+
// absorbed.
|
|
8955
8948
|
try {
|
|
8956
8949
|
// Serialize per pinKey (F2): reconcileStatusPinInner reads `prev` from the
|
|
8957
8950
|
// in-memory claim map at its top, so overlapping same-key reconciles must
|
|
@@ -9926,6 +9919,7 @@ async function deliverCapturedProse(args: {
|
|
|
9926
9919
|
text: string
|
|
9927
9920
|
/** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
|
|
9928
9921
|
turnDurationMs?: number; replyToolThrewThisTurn?: boolean // #4141: label prose recovered from a turn whose reply tool threw (see outbound-send-path.ts)
|
|
9922
|
+
reviewOriginated?: boolean // #4490: review-turn provenance, threaded through to the SAME card-gate / title-framing rules (see outbound-send-path.ts)
|
|
9929
9923
|
}): Promise<void> {
|
|
9930
9924
|
// #2996 P2: body moved VERBATIM to outbound-send-path.ts (single tested
|
|
9931
9925
|
// send module). Injects the ONE `outboundDedup` instance (Amendment 1/9).
|
|
@@ -11808,7 +11802,7 @@ if (isGatewayMain && !STATIC) {
|
|
|
11808
11802
|
* bridge from calling arbitrary functions by name. */
|
|
11809
11803
|
const ALLOWED_TOOLS = new Set([
|
|
11810
11804
|
'reply', 'progress_update', 'react', 'download_attachment',
|
|
11811
|
-
'edit_message', 'send_typing', '
|
|
11805
|
+
'edit_message', 'send_typing', 'delete_message',
|
|
11812
11806
|
'forward_message', 'get_recent_messages',
|
|
11813
11807
|
'send_checklist', 'update_checklist',
|
|
11814
11808
|
'ask_user',
|
|
@@ -11844,8 +11838,6 @@ async function executeToolCall(
|
|
|
11844
11838
|
return executeEditMessage(args)
|
|
11845
11839
|
case 'send_typing':
|
|
11846
11840
|
return executeSendTyping(args)
|
|
11847
|
-
case 'pin_message':
|
|
11848
|
-
return executePinMessage(args)
|
|
11849
11841
|
case 'delete_message':
|
|
11850
11842
|
return executeDeleteMessage(args)
|
|
11851
11843
|
case 'forward_message':
|
|
@@ -13195,44 +13187,6 @@ async function executeSendTyping(args: Record<string, unknown>): Promise<unknown
|
|
|
13195
13187
|
return { content: [{ type: 'text', text: `${action} indicator sent (auto-refreshes every 4s, stops after 30s or next reply)` }] }
|
|
13196
13188
|
}
|
|
13197
13189
|
|
|
13198
|
-
async function executePinMessage(args: Record<string, unknown>): Promise<unknown> {
|
|
13199
|
-
if (!args.chat_id) throw new Error('pin_message: chat_id is required')
|
|
13200
|
-
if (!args.message_id) throw new Error('pin_message: message_id is required')
|
|
13201
|
-
const pinChatId = String(args.chat_id ?? '')
|
|
13202
|
-
assertAllowedChat(pinChatId)
|
|
13203
|
-
// #1075: wrap through robustApiCall so flood-wait / transient network
|
|
13204
|
-
// errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
|
|
13205
|
-
// agent as a tool-error — pinning a vanished message is genuinely a
|
|
13206
|
-
// failure the agent should see.
|
|
13207
|
-
const pinMsgId = Number(args.message_id)
|
|
13208
|
-
await robustApiCall(
|
|
13209
|
-
() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
|
|
13210
|
-
{ chat_id: pinChatId, verb: 'pin_message' },
|
|
13211
|
-
)
|
|
13212
|
-
// An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
|
|
13213
|
-
// this chat now — clear any auto-pin negative-cache entry (#3024) so the auto
|
|
13214
|
-
// status-pin path resumes immediately rather than waiting for a restart. The
|
|
13215
|
-
// explicit tool itself never consults the cache; a failure above still
|
|
13216
|
-
// surfaces to the agent as a normal tool error (robustApiCall rethrows).
|
|
13217
|
-
statusPinRightsCache.clear(pinChatId)
|
|
13218
|
-
// #3001: register the tool pin in the shared status-pin store under a
|
|
13219
|
-
// `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
|
|
13220
|
-
// fg:/wk: rows a tool pin has no "work finished" event, so a restart does
|
|
13221
|
-
// NOT reset it — boot cleanup keeps the row until its TTL, then unpins the
|
|
13222
|
-
// (likely long-forgotten) message. Best-effort fire-and-forget: a store
|
|
13223
|
-
// failure must never fail the tool call the pin already landed for.
|
|
13224
|
-
if (toolPinPersistEnabled) {
|
|
13225
|
-
const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
|
|
13226
|
-
void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
|
|
13227
|
-
pinKey: toolPinKey,
|
|
13228
|
-
chatId: pinChatId,
|
|
13229
|
-
messageId: pinMsgId,
|
|
13230
|
-
expiresAt: Date.now() + TOOL_PIN_TTL_MS,
|
|
13231
|
-
})
|
|
13232
|
-
}
|
|
13233
|
-
return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
|
|
13234
|
-
}
|
|
13235
|
-
|
|
13236
13190
|
async function executeDeleteMessage(args: Record<string, unknown>): Promise<unknown> {
|
|
13237
13191
|
if (!args.chat_id) throw new Error('delete_message: chat_id is required')
|
|
13238
13192
|
if (!args.message_id) throw new Error('delete_message: message_id is required')
|
|
@@ -23874,6 +23828,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23874
23828
|
const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_MAX_ROWS)
|
|
23875
23829
|
return Number.isInteger(raw) && raw > 0 ? raw : undefined
|
|
23876
23830
|
})()
|
|
23831
|
+
// First-paint hold for a prose-silent sub-agent card. Unset /
|
|
23832
|
+
// non-positive → the feed's built-in default (4000ms). Operator
|
|
23833
|
+
// override (SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS) lets first
|
|
23834
|
+
// paint be retuned/reverted (e.g. back to 8000) without a redeploy.
|
|
23835
|
+
const workerFeedFirstPaintMs = (() => {
|
|
23836
|
+
const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS)
|
|
23837
|
+
return Number.isInteger(raw) && raw > 0 ? raw : undefined
|
|
23838
|
+
})()
|
|
23877
23839
|
// Model A — foreground sub-agent nesting in the parent's live
|
|
23878
23840
|
// activity draft. ON by default; this edits the SAME activity-
|
|
23879
23841
|
// summary message the tool_label feed already owns (not the
|
|
@@ -23947,6 +23909,10 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23947
23909
|
// channels.telegram.worker_feed.max_rows via the config cascade
|
|
23948
23910
|
// (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
|
|
23949
23911
|
maxRows: workerFeedMaxRows,
|
|
23912
|
+
// First-paint hold for a prose-silent worker's initial card.
|
|
23913
|
+
// Unset → the feed's built-in default (4000ms); the operator
|
|
23914
|
+
// override SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS retunes it.
|
|
23915
|
+
firstPaintMinMs: workerFeedFirstPaintMs,
|
|
23950
23916
|
// Backstop TTL for the feed's stale-row reaper, DERIVED in code
|
|
23951
23917
|
// from the watcher's effective in-flight terminal cap (same env /
|
|
23952
23918
|
// default the watcher itself resolves) plus a margin — NOT a
|
|
@@ -21,6 +21,7 @@ import * as silencePoke from '../silence-poke.js'
|
|
|
21
21
|
import * as signalTracker from '../turn-signal-tracker.js'
|
|
22
22
|
import * as pendingProgress from '../pending-work-progress.js'
|
|
23
23
|
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
24
|
+
import { computeTurnDurationMs } from './turn-record-status.js'
|
|
24
25
|
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
25
26
|
import { clearSilentEndState } from '../silent-end.js'
|
|
26
27
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
@@ -377,7 +378,11 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
377
378
|
turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
|
|
378
379
|
const turnStartedAt = activeTurnStartedAt.get(fbKey)
|
|
379
380
|
if (turnStartedAt != null) {
|
|
380
|
-
|
|
381
|
+
// Guard shared with buildTurnRecord + the stream-render turn_ended paths:
|
|
382
|
+
// a 0 / bogus start must never emit `Date.now() - 0` (an absolute epoch
|
|
383
|
+
// value) as a duration. This path previously did a bare subtraction and
|
|
384
|
+
// poisoned the turn_ended dataset when `activeTurnStartedAt` held 0.
|
|
385
|
+
const turnDurationMs = computeTurnDurationMs(turnStartedAt, Date.now())
|
|
381
386
|
const outboundMetrics = signalTracker.getOutboundMetrics(fbKey)
|
|
382
387
|
emitRuntimeMetric({
|
|
383
388
|
kind: 'turn_ended',
|
|
@@ -62,10 +62,27 @@ import { journalExternalDelivery } from './outbox-sweep.js'
|
|
|
62
62
|
import { sha256Hex } from '../outbox.js'
|
|
63
63
|
// #4141: the ONE shared framing implementation, also used by the outbox sweep
|
|
64
64
|
// and (for the classifier half) the unbundled Stop hooks.
|
|
65
|
+
//
|
|
66
|
+
// #4490: previously this import only pulled the reply-throw half. The
|
|
67
|
+
// captured-prose bridge below is the ELECTED path's deliverer — it never
|
|
68
|
+
// writes an outbox record, so without the audience gate / self-improvement
|
|
69
|
+
// framing wired in here too, a review-turn's raw, unlabelled reasoning could
|
|
70
|
+
// reach the operator whenever the single-writer election (in
|
|
71
|
+
// `silent-end-interrupt-stop.mjs`) routes a 'trailing-text-after-reply' case
|
|
72
|
+
// here instead of to the outbox sweep. Wiring the SAME shared predicates in
|
|
73
|
+
// both places (rather than re-deriving a third, parallel implementation)
|
|
74
|
+
// restores the #4141-style symmetry #4485 left one-sided.
|
|
65
75
|
import {
|
|
66
76
|
applyReplyThrowFraming,
|
|
67
77
|
formatReplyThrowFraming,
|
|
68
78
|
shouldFrameReplyThrow,
|
|
79
|
+
decideCaptureAudience,
|
|
80
|
+
isSelfImprovementCard,
|
|
81
|
+
shouldFrameSelfImprovement,
|
|
82
|
+
applySelfImprovementFraming,
|
|
83
|
+
formatSelfImprovementFraming,
|
|
84
|
+
formatInternalSuppression,
|
|
85
|
+
AUDIENCE_INTERNAL,
|
|
69
86
|
} from '../hooks/audience-classify.mjs'
|
|
70
87
|
import { queueFloodBlockedReply } from './flood-reply-queue.js'
|
|
71
88
|
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
@@ -2737,6 +2754,17 @@ export async function deliverCapturedProse(
|
|
|
2737
2754
|
* record, so the sweep's framing can never reach the message.
|
|
2738
2755
|
*/
|
|
2739
2756
|
replyToolThrewThisTurn?: boolean
|
|
2757
|
+
/**
|
|
2758
|
+
* #4490 — this turn originated from a self-improvement review inbound
|
|
2759
|
+
* (`source="self_improve_review"`). Stamped by the Stop hook's
|
|
2760
|
+
* single-writer election onto the `SilentEndState` file (the ONLY way this
|
|
2761
|
+
* path — which never writes an outbox record — can learn a review turn's
|
|
2762
|
+
* provenance) and threaded through `decideCapturedProseDelivery`. Gates the
|
|
2763
|
+
* SAME audience-suppression / title-framing rules the outbox sweep applies
|
|
2764
|
+
* to a review record, restoring the #4141-style symmetry #4485 left
|
|
2765
|
+
* one-sided (card gate + title framing applied at the sweep only).
|
|
2766
|
+
*/
|
|
2767
|
+
reviewOriginated?: boolean
|
|
2740
2768
|
},
|
|
2741
2769
|
): Promise<void> {
|
|
2742
2770
|
const {
|
|
@@ -2758,14 +2786,69 @@ export async function deliverCapturedProse(
|
|
|
2758
2786
|
{ replyToolThrewThisTurn: args.replyToolThrewThisTurn },
|
|
2759
2787
|
{ frameEnabled: process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING !== '0' },
|
|
2760
2788
|
)
|
|
2761
|
-
// #
|
|
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
|
)
|
|
@@ -352,9 +352,10 @@ export function collectSweepTargets(input: {
|
|
|
352
352
|
|
|
353
353
|
/**
|
|
354
354
|
* The messageIds in `chatId` that must SURVIVE a drain because they belong to
|
|
355
|
-
* deliberately-retained store rows: unexpired time-scoped `tool:` pins (
|
|
356
|
-
* `pin_message` MCP tool, #3001
|
|
357
|
-
*
|
|
355
|
+
* deliberately-retained store rows: unexpired time-scoped `tool:` pins (legacy
|
|
356
|
+
* rows from the retired `pin_message` MCP tool, #3001 / #4452 — no new ones are
|
|
357
|
+
* written), which `runStatusPinBootCleanup` intentionally KEEPS across restarts
|
|
358
|
+
* until they expire. Work-scoped rows (no `expiresAt`) are stale by
|
|
358
359
|
* definition after a restart and are NOT re-pin candidates; expired
|
|
359
360
|
* time-scoped rows are due for sweeping.
|
|
360
361
|
*/
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* — the turn it represented is over or crashed), dropping rows only after a
|
|
18
18
|
* successful unpin (failed ones are retained with an attempt counter for a
|
|
19
19
|
* next-boot retry — see runStatusPinBootCleanup). Time-scoped `tool:` rows
|
|
20
|
-
* (the `pin_message` MCP tool, #3001
|
|
20
|
+
* (legacy pins from the retired `pin_message` MCP tool, #3001 / #4452 — no new
|
|
21
|
+
* ones are written) survive boots until their `expiresAt`.
|
|
21
22
|
* It does NOT re-adopt or re-pin — it only cleans up.
|
|
22
23
|
*
|
|
23
24
|
* Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
|
|
@@ -78,10 +79,10 @@ export interface PersistedStatusPin {
|
|
|
78
79
|
/** Wall-clock ms after which this pin is stale and boot cleanup unpins it.
|
|
79
80
|
* Rows WITHOUT this field are work-scoped (fg:/wk:/banner:) — stale the
|
|
80
81
|
* moment their owning session dies, so boot cleanup unpins them
|
|
81
|
-
* unconditionally. Rows WITH it (
|
|
82
|
-
* `pin_message` MCP tool, #3001
|
|
83
|
-
* no "work finished" event: they
|
|
84
|
-
* expired. */
|
|
82
|
+
* unconditionally. Rows WITH it (legacy `tool:` pins from the retired
|
|
83
|
+
* `pin_message` MCP tool, #3001 / #4452 — no new ones are written)
|
|
84
|
+
* represented deliberate agent pins with no "work finished" event: they
|
|
85
|
+
* SURVIVE restarts and are only swept once expired. */
|
|
85
86
|
expiresAt?: number
|
|
86
87
|
/** Boot-cleanup unpin retry counter (#3001). Incremented each boot the
|
|
87
88
|
* unpin fails (flood-wait exhausted / transient 5xx); the row is retained
|
|
@@ -282,10 +283,10 @@ export function pinnedMessageIsOurs(
|
|
|
282
283
|
* record whose pin MAY have landed in Telegram, so we must treat it exactly
|
|
283
284
|
* like a confirmed one and unpin it.
|
|
284
285
|
*
|
|
285
|
-
* TIME-SCOPED rows (`tool:` pins from the `pin_message` MCP
|
|
286
|
-
* `expiresAt`) have no "work finished" event, so a
|
|
287
|
-
* them: an unexpired row is RETAINED untouched across
|
|
288
|
-
* once `now >= expiresAt`.
|
|
286
|
+
* TIME-SCOPED rows (legacy `tool:` pins from the retired `pin_message` MCP
|
|
287
|
+
* tool, #4452, carrying `expiresAt`) have no "work finished" event, so a
|
|
288
|
+
* restart does NOT reset them: an unexpired row is RETAINED untouched across
|
|
289
|
+
* boots and only unpinned once `now >= expiresAt`.
|
|
289
290
|
*
|
|
290
291
|
* RETRY-SAFETY (#3001): a row is dropped only AFTER its unpin resolves. A
|
|
291
292
|
* failing unpin (flood-wait exhausted / transient 5xx) retains the row with an
|
|
@@ -90,7 +90,7 @@ import { formatTurnLifecycle } from './status-surface-log.js'
|
|
|
90
90
|
import { removeTurnActiveMarker, touchTurnActiveMarker, writeTurnActiveMarker } from './turn-active-marker.js'
|
|
91
91
|
import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
|
|
92
92
|
import { decideTurnEndGate } from './turn-end-gate.js'
|
|
93
|
-
import { finalizeBackstopSendGated } from './turn-record-status.js'
|
|
93
|
+
import { finalizeBackstopSendGated, computeTurnDurationMs } from './turn-record-status.js'
|
|
94
94
|
import type { SilentEndDeps } from '../silent-end.js'
|
|
95
95
|
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
96
96
|
import type { SessionEvent } from '../session-tail.js'
|
|
@@ -2174,7 +2174,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2174
2174
|
chatId,
|
|
2175
2175
|
threadId,
|
|
2176
2176
|
turnId: turn.turnId,
|
|
2177
|
-
turnDurationMs: turn.startedAt
|
|
2177
|
+
turnDurationMs: computeTurnDurationMs(turn.startedAt, Date.now()),
|
|
2178
2178
|
reactionCount: reactionTransitionCounts.get(tKey) ?? 0,
|
|
2179
2179
|
})
|
|
2180
2180
|
}
|
|
@@ -2214,7 +2214,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2214
2214
|
// still appear in turn-duration graphs.
|
|
2215
2215
|
{
|
|
2216
2216
|
const sKey = streamKey(chatId, threadId)
|
|
2217
|
-
const turnDurationMs = turn.startedAt
|
|
2217
|
+
const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
|
|
2218
2218
|
logStreamingEvent({
|
|
2219
2219
|
kind: 'turn_end',
|
|
2220
2220
|
chatId,
|
|
@@ -2799,7 +2799,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2799
2799
|
finalizeStatusReaction(chatId, threadId, terminalReason)
|
|
2800
2800
|
{
|
|
2801
2801
|
const sKey = streamKey(chatId, threadId)
|
|
2802
|
-
const turnDurationMs = turn.startedAt
|
|
2802
|
+
const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
|
|
2803
2803
|
logStreamingEvent({
|
|
2804
2804
|
kind: 'turn_end',
|
|
2805
2805
|
chatId,
|
|
@@ -2941,6 +2941,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2941
2941
|
// not what it chose to send. Pass the raw signal through so the
|
|
2942
2942
|
// send states that provenance. It never gates delivery.
|
|
2943
2943
|
replyToolThrewThisTurn: proseDecision.replyToolThrewThisTurn === true,
|
|
2944
|
+
// #4490 — same pass-through for the review-origin signal, so the
|
|
2945
|
+
// send path can apply the SAME card-gate / title-framing rules the
|
|
2946
|
+
// outbox sweep applies to a review turn's prose.
|
|
2947
|
+
reviewOriginated: proseDecision.reviewOriginated === true,
|
|
2944
2948
|
// For the honest "(waited Ns)" clause if the exhaustion-boundary
|
|
2945
2949
|
// apology fallback fires (#3228).
|
|
2946
2950
|
turnDurationMs,
|