switchroom 0.19.41 → 0.19.43
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 +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4065 — the rollout narration card's EDIT relay, with a truthful reply.
|
|
3
|
+
*
|
|
4
|
+
* hostd owns the rollout card's lifecycle (post once, then edit in place
|
|
5
|
+
* through the phases). Before #4065 this handler was pure fire-and-forget:
|
|
6
|
+
* every edit failure was swallowed here and hostd never learned about it. That
|
|
7
|
+
* is invisible in the steady state (hostd posted the card seconds earlier, so
|
|
8
|
+
* it is virtually always editable) but NOT after a hostd self-bump: the
|
|
9
|
+
* resumed narrator is SEEDED with the carried `narration_message_id` and takes
|
|
10
|
+
* the edit branch forever. If that carried id is gone — operator deleted the
|
|
11
|
+
* card, or the message is no longer editable — the roll edits into the void
|
|
12
|
+
* and the operator is left staring at a stale card that never reaches a
|
|
13
|
+
* terminal ✅/❌.
|
|
14
|
+
*
|
|
15
|
+
* So the handler now REPLIES with a `rollout_status_edited` outcome and, when
|
|
16
|
+
* the failure means the target message no longer exists, says so (`gone`).
|
|
17
|
+
* hostd's narrator turns exactly one `gone` into one fresh post and escalates
|
|
18
|
+
* anything past that to telemetry — never a chat card asking the operator to
|
|
19
|
+
* do something (the PR #4104 rule).
|
|
20
|
+
*
|
|
21
|
+
* Still fire-and-forget toward the ROLL: the reply is advisory, hostd never
|
|
22
|
+
* blocks on it, and a gateway that predates this reply simply times out
|
|
23
|
+
* hostd-side and behaves exactly as before.
|
|
24
|
+
*
|
|
25
|
+
* Why this call opts out of TWO swallows
|
|
26
|
+
* --------------------------------------
|
|
27
|
+
* This handler infers edit success from the ABSENCE of a throw. That inference
|
|
28
|
+
* is only sound if every "the edit did not land" path actually throws — and
|
|
29
|
+
* the production stack has two layers that RESOLVE instead. Both are opted out
|
|
30
|
+
* of explicitly at the `robustApiCall` call site in gateway.ts, because either
|
|
31
|
+
* one alone is enough to make `gone` — the entire reason this module exists —
|
|
32
|
+
* unreachable in production.
|
|
33
|
+
*
|
|
34
|
+
* 1. `priorityClass: 'critical'` — never inherit the untagged default
|
|
35
|
+
* (`UNTAGGED_SEND_CLASS`, today `'critical'`). A SHED send does not throw:
|
|
36
|
+
* the send gate returns `SEND_GATE_SHED` without ever calling Telegram. If
|
|
37
|
+
* a future change to that default made this call sheddable, the handler
|
|
38
|
+
* would reply `ok:true` for an edit that never happened.
|
|
39
|
+
*
|
|
40
|
+
* 2. `rethrowBenign400: true` — the retry policy's benign-400 swallow
|
|
41
|
+
* (`classifyBenignTelegram400`, retry-api-call.ts) treats
|
|
42
|
+
* `400 "message to edit not found"` as a non-event and resolves
|
|
43
|
+
* `undefined`. That description is EXACTLY the deleted-card case, i.e. the
|
|
44
|
+
* primary trigger for `gone`. Without the opt-out the seeded-resume
|
|
45
|
+
* scenario this module was written for — carried `narration_message_id`,
|
|
46
|
+
* operator deleted the card — threw nothing at all, so the handler replied
|
|
47
|
+
* `ok:true` and hostd recorded a frozen card as live. That is WORSE than
|
|
48
|
+
* pre-fix: hostd positively believes the card is fine.
|
|
49
|
+
*
|
|
50
|
+
* Both failures are silent by construction — green tests over an inert
|
|
51
|
+
* feature — so neither is pinned by inspection alone.
|
|
52
|
+
* `tests/rollout-status-edit-retry-policy.test.ts` drives the REAL retry
|
|
53
|
+
* policy + send gate with a REAL `GrammyError` and asserts a deleted card
|
|
54
|
+
* produces a re-post; `tests/rollout-narration-edit-socket.test.ts` pins the
|
|
55
|
+
* transport that carries the reply back to hostd.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import type {
|
|
59
|
+
RolloutStatusEditMessage,
|
|
60
|
+
RolloutStatusEditedEvent,
|
|
61
|
+
} from './ipc-protocol.js'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The reply channel — structurally satisfied by `IpcClient` (whose `send`
|
|
65
|
+
* accepts the wider `GatewayToClient` union). Kept narrow here so this module
|
|
66
|
+
* doesn't depend on the IPC server.
|
|
67
|
+
*/
|
|
68
|
+
export interface RolloutStatusEditReplySink {
|
|
69
|
+
send(msg: RolloutStatusEditedEvent): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classification of an edit failure, from the perspective of "is the card the
|
|
74
|
+
* operator can see still there?".
|
|
75
|
+
*
|
|
76
|
+
* - `not-modified` — Telegram rejected the edit because the body is already
|
|
77
|
+
* byte-identical. The card IS live and correct; this is a success.
|
|
78
|
+
* - `gone` — the target message no longer exists / can no longer be
|
|
79
|
+
* edited. Only this class may trigger a re-post.
|
|
80
|
+
* - `transient` — anything else (429 past retries, network, chat errors).
|
|
81
|
+
* Re-posting would duplicate a card that is probably still fine.
|
|
82
|
+
*/
|
|
83
|
+
export type RolloutEditFailureClass = 'not-modified' | 'gone' | 'transient'
|
|
84
|
+
|
|
85
|
+
/** Telegram descriptions that mean "the message you asked me to edit is gone". */
|
|
86
|
+
const GONE_PATTERNS = [
|
|
87
|
+
/message to edit not found/i,
|
|
88
|
+
/message can't be edited/i,
|
|
89
|
+
/message_id_invalid/i,
|
|
90
|
+
/message to be edited not found/i,
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
/** True IFF the error means the edit body already equals the live message. */
|
|
94
|
+
const NOT_MODIFIED = /message is not modified/i
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Classify a thrown Telegram edit error. Reads `description` (grammy's
|
|
98
|
+
* `GrammyError`) and falls back to the message string, so a plain `Error`
|
|
99
|
+
* carrying the same text classifies identically.
|
|
100
|
+
*/
|
|
101
|
+
export function classifyRolloutEditError(err: unknown): RolloutEditFailureClass {
|
|
102
|
+
const e = err as { description?: unknown; message?: unknown } | null
|
|
103
|
+
const text = [
|
|
104
|
+
typeof e?.description === 'string' ? e.description : '',
|
|
105
|
+
typeof e?.message === 'string' ? e.message : '',
|
|
106
|
+
].join(' ')
|
|
107
|
+
if (NOT_MODIFIED.test(text)) return 'not-modified'
|
|
108
|
+
if (GONE_PATTERNS.some((re) => re.test(text))) return 'gone'
|
|
109
|
+
return 'transient'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RolloutStatusEditDeps {
|
|
113
|
+
/** The gateway's own agent name (`SWITCHROOM_AGENT_NAME`), or undefined. */
|
|
114
|
+
selfAgentName: string | undefined
|
|
115
|
+
/** Operator chat (`allowFrom[0]`), or undefined when access is unconfigured. */
|
|
116
|
+
operatorChatId: () => string | number | undefined
|
|
117
|
+
/**
|
|
118
|
+
* Perform the edit.
|
|
119
|
+
*
|
|
120
|
+
* CONTRACT — resolve IFF the edit landed, reject otherwise. This handler has
|
|
121
|
+
* no other channel: it reads success from the absence of a throw. So the
|
|
122
|
+
* implementation must be wrapped in the retry policy AND must opt out of
|
|
123
|
+
* every layer that turns a failed edit into a resolved promise —
|
|
124
|
+
* `priorityClass: 'critical'` (no shed) and `rethrowBenign400: true` (no
|
|
125
|
+
* benign-400 swallow). See the "TWO swallows" note at the top of this file.
|
|
126
|
+
*/
|
|
127
|
+
editMessage: (chatId: string | number, messageId: number, text: string) => Promise<unknown>
|
|
128
|
+
log: (line: string) => void
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Handle one `rollout_status_edit` and reply with its outcome. Never throws:
|
|
133
|
+
* every failure path resolves after sending (best-effort) a
|
|
134
|
+
* `rollout_status_edited` event.
|
|
135
|
+
*/
|
|
136
|
+
export async function handleRolloutStatusEdit(
|
|
137
|
+
deps: RolloutStatusEditDeps,
|
|
138
|
+
client: RolloutStatusEditReplySink,
|
|
139
|
+
msg: RolloutStatusEditMessage,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const reply = (ok: boolean, extra: { gone?: boolean; reason?: string } = {}): void => {
|
|
142
|
+
try {
|
|
143
|
+
client.send({ type: 'rollout_status_edited', requestId: msg.requestId, ok, ...extra })
|
|
144
|
+
} catch {
|
|
145
|
+
/* best effort — hostd falls back to its bounded reply timeout */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const self = deps.selfAgentName
|
|
149
|
+
if (self && msg.agentName !== self) {
|
|
150
|
+
deps.log(`rollout_status_edit rejected — agent mismatch (${msg.agentName} != ${self})`)
|
|
151
|
+
reply(false, { gone: false, reason: 'agent mismatch' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const operator = deps.operatorChatId()
|
|
155
|
+
if (operator === undefined) {
|
|
156
|
+
deps.log('rollout_status_edit — no operator chat (allowFrom empty)')
|
|
157
|
+
reply(false, { gone: false, reason: 'no operator chat' })
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await deps.editMessage(operator, msg.messageId, msg.text)
|
|
162
|
+
reply(true)
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const cls = classifyRolloutEditError(err)
|
|
165
|
+
if (cls === 'not-modified') {
|
|
166
|
+
// The live card already carries this exact body — a success, not a loss.
|
|
167
|
+
reply(true)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
deps.log(
|
|
171
|
+
`rollout_status_edit failed (${cls}) request=${msg.requestId} message_id=${msg.messageId}: ${(err as Error).message}`,
|
|
172
|
+
)
|
|
173
|
+
reply(false, { gone: cls === 'gone', reason: (err as Error).message })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -232,6 +232,63 @@ export function __parkedTurnStartCountForTest(): number {
|
|
|
232
232
|
return parkedTurnStarts.length
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Silence-fallback unwedge for the parked store (the two-state desync fix).
|
|
237
|
+
*
|
|
238
|
+
* The park gate in `case 'enqueue'` treats the session as busy while EITHER a
|
|
239
|
+
* live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
|
|
240
|
+
* framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
|
|
241
|
+
* hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
|
|
242
|
+
* before this it NEVER touched `parkedTurnStarts`. When the claude REPL hangs
|
|
243
|
+
* mid-turn the CLI's own `dequeue` / `remove` events (the only real drains of
|
|
244
|
+
* this store) never arrive, so a leftover parked envelope latches the busy gate
|
|
245
|
+
* permanently and every later inbound parks unseen until a manual container
|
|
246
|
+
* restart (gymbro parked msgs 4502→4504 behind a dead lock until SIGTERM). The
|
|
247
|
+
* `drained_buffered=0/0` fallback log was honest-but-misleading: it counted the
|
|
248
|
+
* empty `pendingInboundBuffer`, not this store, where the real messages sat.
|
|
249
|
+
*
|
|
250
|
+
* This drains the parked envelopes for ONE chat/thread — scoped, never global,
|
|
251
|
+
* so a fallback fired for chat A cannot release chat B's queued messages — and
|
|
252
|
+
* hands each back into turn processing through the EXACT `beginTurn` path a real
|
|
253
|
+
* `dequeue` uses. Envelopes are re-begun in arrival order (oldest first) so the
|
|
254
|
+
* NEWEST, the message the user is actually waiting on, ends up the live turn and
|
|
255
|
+
* the older ones are cleanly superseded (never silently dropped). The store is
|
|
256
|
+
* kept module-encapsulated: callers get a function, not the array. Returns the
|
|
257
|
+
* drained envelopes so the caller can log an honest count. Idempotent w.r.t. a
|
|
258
|
+
* late CLI event: once drained, a subsequent `dequeue` finds an empty store and
|
|
259
|
+
* `takeParkedTurnStart` returns null, so a message can never be double-started.
|
|
260
|
+
*/
|
|
261
|
+
export function drainParkedTurnStartsForChat(
|
|
262
|
+
deps: StreamRenderDeps,
|
|
263
|
+
chatId: string | null,
|
|
264
|
+
threadId: string | null,
|
|
265
|
+
): TurnStartEnvelope[] {
|
|
266
|
+
const wantThread = envThreadIdNum(threadId)
|
|
267
|
+
const drained: ParkedTurnStart[] = []
|
|
268
|
+
// Splice out the matching entries, preserving arrival order (oldest first).
|
|
269
|
+
for (let i = 0; i < parkedTurnStarts.length; ) {
|
|
270
|
+
const entry = parkedTurnStarts[i]!
|
|
271
|
+
if (entry.chatId === chatId && envThreadIdNum(entry.threadId) === wantThread) {
|
|
272
|
+
drained.push(entry)
|
|
273
|
+
parkedTurnStarts.splice(i, 1)
|
|
274
|
+
} else {
|
|
275
|
+
i++
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const env of drained) {
|
|
279
|
+
process.stderr.write(
|
|
280
|
+
`telegram gateway: parked-turn-start drained on silence-fallback ` +
|
|
281
|
+
`chat=${env.chatId ?? '-'} thread=${envThreadIdNum(env.threadId) ?? '-'} ` +
|
|
282
|
+
`msg=${env.messageId ?? '-'}\n`,
|
|
283
|
+
)
|
|
284
|
+
// Reuse the dequeue turn-start path verbatim — beginTurn adopts the parked
|
|
285
|
+
// envelope's queued card (Part B) so the frozen "⏳ Queued" surface becomes
|
|
286
|
+
// the live progress card rather than lingering.
|
|
287
|
+
beginTurn(deps, env)
|
|
288
|
+
}
|
|
289
|
+
return drained
|
|
290
|
+
}
|
|
291
|
+
|
|
235
292
|
// ─── Queued-turn card send / finalize (Part B) ───────────────────────────────
|
|
236
293
|
// These use the ALREADY-injected `bot` + `robustApiCall` deps, so the whole
|
|
237
294
|
// feature lives in stream-render.ts with zero new wiring in gateway.ts. No
|
|
@@ -2686,6 +2743,11 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2686
2743
|
registryKey: turn.registryKey ?? null,
|
|
2687
2744
|
originTurnId: turn.turnId,
|
|
2688
2745
|
text: proseDecision.text,
|
|
2746
|
+
// #4141 — the Stop hook saw this turn's reply tool throw, so the
|
|
2747
|
+
// prose is what the model wrote AFTER its delivery attempt failed,
|
|
2748
|
+
// not what it chose to send. Pass the raw signal through so the
|
|
2749
|
+
// send states that provenance. It never gates delivery.
|
|
2750
|
+
replyToolThrewThisTurn: proseDecision.replyToolThrewThisTurn === true,
|
|
2689
2751
|
// For the honest "(waited Ns)" clause if the exhaustion-boundary
|
|
2690
2752
|
// apology fallback fires (#3228).
|
|
2691
2753
|
turnDurationMs,
|
|
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync, openSync, closeSync, readFileSync } from 'node:f
|
|
|
23
23
|
import { join } from 'node:path'
|
|
24
24
|
import { homedir } from 'node:os'
|
|
25
25
|
import { readAndFilter, defaultAuditLogPath, type AuditEntry } from '../../src/host-control/audit-reader.js'
|
|
26
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
26
27
|
|
|
27
28
|
/** Default lookback window: 10 minutes is enough to catch the boot that
|
|
28
29
|
* follows a normal update_apply but small enough that an audit row from
|
|
@@ -98,9 +99,9 @@ function shortSha(s: string): string {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
|
-
* Pure renderer. Returns the
|
|
102
|
-
*
|
|
103
|
-
* (entry too stale, schema invalid, etc.).
|
|
102
|
+
* Pure renderer. Returns the line(s) to append to the boot card body —
|
|
103
|
+
* markdown-safe (no unescaped entity markers of its own). `null` means
|
|
104
|
+
* nothing to surface (entry too stale, schema invalid, etc.).
|
|
104
105
|
*/
|
|
105
106
|
export function renderUpdateOutcomeLine(entry: AuditEntry): string {
|
|
106
107
|
const success = entry.exit_code === 0 && entry.result !== 'error' && entry.result !== 'denied'
|
|
@@ -117,7 +118,13 @@ export function renderUpdateOutcomeLine(entry: AuditEntry): string {
|
|
|
117
118
|
const opStep = entry.op
|
|
118
119
|
const hint = recoveryHint(entry.install_context?.install_type)
|
|
119
120
|
// Single line is reader-friendly when short; multi-line when stderr is present.
|
|
120
|
-
|
|
121
|
+
// The recovery line is NESTED under the failure line with the repo's one
|
|
122
|
+
// indent idiom (#4115): a literal ASCII-space indent is left-trimmed off a
|
|
123
|
+
// content line by Telegram's parser and renders FLAT on a phone (#3668).
|
|
124
|
+
const lines = [
|
|
125
|
+
`❌ update failed at ${opStep}: ${stderrTail || '(no stderr captured)'}`,
|
|
126
|
+
`${NESTED_PREFIX}Recovery: ${hint}`,
|
|
127
|
+
]
|
|
121
128
|
return lines.join('\n')
|
|
122
129
|
}
|
|
123
130
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type Audience = 'user' | 'internal'
|
|
2
|
+
|
|
3
|
+
export const AUDIENCE_USER: 'user'
|
|
4
|
+
export const AUDIENCE_INTERNAL: 'internal'
|
|
5
|
+
|
|
6
|
+
export function decideCaptureAudience(signals: {
|
|
7
|
+
replyToolThrewThisTurn?: boolean
|
|
8
|
+
openInboundObligation?: true | false | 'unknown'
|
|
9
|
+
}): Audience
|
|
10
|
+
|
|
11
|
+
export function resolveOpenObligation(args: {
|
|
12
|
+
snapshotRaw?: string | null
|
|
13
|
+
chatId?: string | null
|
|
14
|
+
/** #4146: `false` ⇒ persistence is off, so the file proves nothing. */
|
|
15
|
+
snapshotTrusted?: boolean
|
|
16
|
+
}): true | false | 'unknown'
|
|
17
|
+
|
|
18
|
+
export function resolveRecordAudience(value: unknown): Audience
|
|
19
|
+
|
|
20
|
+
export function shouldSuppressForAudience(
|
|
21
|
+
record: { audience?: unknown },
|
|
22
|
+
opts?: { gateEnabled?: boolean },
|
|
23
|
+
): boolean
|
|
24
|
+
|
|
25
|
+
export const REPLY_THROW_PROVENANCE_NOTICE: string
|
|
26
|
+
|
|
27
|
+
export function shouldFrameReplyThrow(
|
|
28
|
+
record: { replyToolThrewThisTurn?: unknown },
|
|
29
|
+
opts?: { frameEnabled?: boolean },
|
|
30
|
+
): boolean
|
|
31
|
+
|
|
32
|
+
export function applyReplyThrowFraming(text: string): string
|
|
33
|
+
|
|
34
|
+
export function formatReplyThrowFraming(s: {
|
|
35
|
+
turnNonce: string
|
|
36
|
+
turnId?: string | null
|
|
37
|
+
chatId?: string | null
|
|
38
|
+
textSha256?: string
|
|
39
|
+
source?: string
|
|
40
|
+
}): string
|
|
41
|
+
|
|
42
|
+
export function formatInternalSuppression(s: {
|
|
43
|
+
turnNonce: string
|
|
44
|
+
turnId?: string | null
|
|
45
|
+
chatId?: string | null
|
|
46
|
+
textSha256?: string
|
|
47
|
+
ageMs?: number
|
|
48
|
+
source?: string
|
|
49
|
+
pastWindow?: boolean
|
|
50
|
+
}): string
|