switchroom 0.19.47 → 0.20.0

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.
Files changed (50) hide show
  1. package/dist/agent-scheduler/index.js +22 -1
  2. package/dist/auth-broker/index.js +26 -2
  3. package/dist/buzz-gateway/index.js +9207 -0
  4. package/dist/cli/notion-write-pretool.mjs +22 -1
  5. package/dist/cli/switchroom.js +91 -9
  6. package/dist/host-control/main.js +27 -3
  7. package/dist/vault/approvals/kernel-server.js +26 -2
  8. package/dist/vault/broker/server.js +26 -2
  9. package/package.json +4 -3
  10. package/profiles/_base/start.sh.hbs +78 -1
  11. package/profiles/default/CLAUDE.md.hbs +1 -1
  12. package/skills/dev-protocol/SKILL.md +30 -1
  13. package/skills/switchroom-architecture/SKILL.md +5 -0
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  16. package/telegram-plugin/dist/gateway/gateway.js +1156 -247
  17. package/telegram-plugin/dist/server.js +7 -4
  18. package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
  19. package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
  20. package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
  21. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  22. package/telegram-plugin/gateway/channel-route.ts +272 -0
  23. package/telegram-plugin/gateway/gateway.ts +73 -81
  24. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  25. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  27. package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
  28. package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
  29. package/telegram-plugin/gateway/stream-render.ts +21 -0
  30. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  31. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  32. package/telegram-plugin/history.ts +15 -0
  33. package/telegram-plugin/llm-error-present.ts +9 -4
  34. package/telegram-plugin/model-unavailable.ts +4 -0
  35. package/telegram-plugin/operator-events.fixtures.json +12 -12
  36. package/telegram-plugin/operator-events.ts +81 -9
  37. package/telegram-plugin/session-tail.ts +7 -1
  38. package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
  39. package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
  40. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  41. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  42. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  43. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  44. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  45. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  46. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  47. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  48. package/telegram-plugin/voice-normalize-text.ts +5 -0
  49. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  50. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -17188,12 +17188,12 @@ function classifyClaudeError(raw) {
17188
17188
  try {
17189
17189
  return classifyInner(raw);
17190
17190
  } catch {
17191
- return "unknown-4xx";
17191
+ return "unknown-5xx";
17192
17192
  }
17193
17193
  }
17194
17194
  function classifyInner(raw) {
17195
17195
  if (raw == null)
17196
- return "unknown-4xx";
17196
+ return "unknown-5xx";
17197
17197
  const obj = typeof raw === "object" ? raw : {};
17198
17198
  const errorType = extractString(obj, "error_type") ?? extractString(obj, "type") ?? extractString(getNestedObj(obj, "error"), "type") ?? "";
17199
17199
  const errorCode = extractString(obj, "code") ?? extractString(getNestedObj(obj, "error"), "code") ?? "";
@@ -17238,13 +17238,16 @@ ${message}`;
17238
17238
  if (errorType === "agent-restarted-unexpectedly" || errorCode === "agent-restarted-unexpectedly") {
17239
17239
  return "agent-restarted-unexpectedly";
17240
17240
  }
17241
+ if ((status == null || status >= 500) && (errorType === "server_error" || errorCode === "server_error" || sdkCode === "server_error" || errorType === "api_error" || errorCode === "api_error" || sdkCode === "api_error")) {
17242
+ return "transport-transient";
17243
+ }
17241
17244
  if (status != null) {
17242
17245
  if (status >= 400 && status < 500)
17243
17246
  return "unknown-4xx";
17244
17247
  if (status >= 500 && status < 600)
17245
17248
  return "unknown-5xx";
17246
17249
  }
17247
- return "unknown-4xx";
17250
+ return "unknown-5xx";
17248
17251
  }
17249
17252
  function extractString(obj, key) {
17250
17253
  const v = obj[key];
@@ -17792,7 +17795,7 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
17792
17795
  const raw = embeddedError ?? obj;
17793
17796
  const kind = classifyClaudeError(embeddedError ?? obj);
17794
17797
  const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
17795
- const transient = kind === "rate-limited";
17798
+ const transient = kind === "rate-limited" || kind === "transport-transient";
17796
17799
  const retry = extractRetryState(obj);
17797
17800
  const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
17798
17801
  return { kind, raw, detail, transient, terminal };
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Pure builders for the gateway boot-time conversation briefing
3
+ * (`session_continuity.briefing: gateway`).
4
+ *
5
+ * Why this exists: the legacy handoff path reorients a fresh session from
6
+ * artifacts a *previous* process had to write on the way down (the Stop-hook
7
+ * `.handoff.md`, or `bin/handoff-briefing.sh` run from start.sh). Both are
8
+ * crash-dependent and land in `--append-system-prompt`, where every restart
9
+ * invalidates the system-prompt prefix cache. This module instead assembles
10
+ * the briefing at BOOT, from the durable gateway SQLite history
11
+ * (`telegram-plugin/history.ts` — the same `messages` table behind
12
+ * `get_recent_messages`), and the gateway injects it as a synthetic first
13
+ * user turn (`<channel source="boot_briefing">`) over the spool transport —
14
+ * so the system-prompt prefix stays byte-stable across sessions and the
15
+ * briefing survives any crash shape (the DB is written per-message, not at
16
+ * shutdown).
17
+ *
18
+ * Design contract (mirrors `resume-inbound-builder.ts`):
19
+ * - This module stays PURE — no bun:sqlite import, no fs, no env reads.
20
+ * The DB arrives through the minimal `BriefingDb` seam and file/env
21
+ * access through explicit parameters, so every bound (surface scoping,
22
+ * depth, budget, dedup, error tolerance) is unit-testable without a
23
+ * gateway. The impure orchestration lives in `boot-briefing-wiring.ts`.
24
+ * - SURFACE-SCOPED, never a global tail: messages are grouped per
25
+ * (chat_id, thread_id) surface. A DM agent yields one section
26
+ * (`thread_id IS NULL`); a forum agent renders the most-recently-active
27
+ * surface at full depth and every other surface active in the last 48h
28
+ * as a two-line header + its last message.
29
+ * - Bounded: hard character budget (~1.5–2K tokens at ~4 chars/token),
30
+ * per-message truncation, oldest-first within the primary section.
31
+ * - Crash/contention tolerant: any DB error (SQLITE_BUSY, timeout,
32
+ * corruption) yields an EMPTY briefing — boot is never blocked and
33
+ * never throws through this module.
34
+ * - Resume dedup: messages already covered by a synthetic resume
35
+ * inbound's interrupted-turn window (same surface, ts >= the turn's
36
+ * started_at) are ELIDED so this briefing and
37
+ * `resume-inbound-builder.ts` never double-inject the same exchange.
38
+ */
39
+
40
+ import type { InboundMessage } from './ipc-protocol.js'
41
+ import { humanizeElapsed, RESUME_SYNTHETIC_PROMPT_PREFIX } from './resume-inbound-builder.js'
42
+
43
+ /** `meta.source` of the synthetic briefing inbound. The bridge forwards it
44
+ * verbatim, so the model sees `<channel source="boot_briefing">` and knows
45
+ * this is a reorientation turn, not a human message. */
46
+ export const BOOT_BRIEFING_SOURCE = 'boot_briefing'
47
+
48
+ /** Hard character budget for the rendered briefing. ~7000 chars ≈ 1.75K
49
+ * tokens at the ~4 chars/token heuristic — inside the design's 1.5–2K
50
+ * token budget. (Deliberately a CHAR bound: the builder has no tokenizer,
51
+ * and a conservative chars-per-token divisor keeps the guarantee real.) */
52
+ export const BRIEFING_CHAR_BUDGET = 7000
53
+
54
+ /** Per-message truncation bound (chars), before budget accounting. */
55
+ export const BRIEFING_PER_MESSAGE_MAX_CHARS = 400
56
+
57
+ /** Full-depth message count for the most-recently-active surface. */
58
+ export const BRIEFING_PRIMARY_DEPTH = 15
59
+
60
+ /** Only surfaces active within this window are included at all. */
61
+ export const BRIEFING_ACTIVE_WINDOW_MS = 48 * 60 * 60 * 1000
62
+
63
+ /** Cap on the number of surfaces rendered (primary + secondaries). */
64
+ export const BRIEFING_MAX_SURFACES = 8
65
+
66
+ /** TTL on the spooled briefing inbound: a briefing that could not be
67
+ * delivered within this window is stale context — the spool's
68
+ * `meta.expiresAt` filter drops it instead of delivering old news. */
69
+ export const BRIEFING_TTL_MS = 60 * 60 * 1000
70
+
71
+ /** The history writer's boot self-check sentinel chat (see
72
+ * `verifyHistoryWritable` in history.ts). Never a real surface; excluded
73
+ * defensively even though the self-check deletes its rows. */
74
+ const HISTORY_SELFCHECK_CHAT = '__history_selfcheck__'
75
+
76
+ /**
77
+ * Minimal read seam over the history DB. Matches the `prepare(...).all(...)`
78
+ * subset of `bun:sqlite`'s Database that history.ts already types, so the
79
+ * wiring can hand the live handle straight through while tests inject a
80
+ * fake (including one that throws SQLITE_BUSY).
81
+ */
82
+ export interface BriefingDb {
83
+ prepare(sql: string): { all(...params: unknown[]): unknown[] }
84
+ }
85
+
86
+ /** One recorded message, as rendered into the briefing. `ts` is unix
87
+ * SECONDS (the history schema's unit). */
88
+ export interface BriefingMessageRow {
89
+ role: string
90
+ user: string | null
91
+ ts: number
92
+ text: string
93
+ }
94
+
95
+ /** One (chat, thread) surface with its selected messages, newest surface
96
+ * first in the collector's output. `messages` is oldest-first. */
97
+ export interface BriefingSurface {
98
+ chatId: string
99
+ threadId: number | null
100
+ /** unix seconds of the surface's most recent message. */
101
+ lastTs: number
102
+ messages: BriefingMessageRow[]
103
+ }
104
+
105
+ /**
106
+ * The interrupted-turn window a synthetic resume inbound already covers.
107
+ * Messages on this surface at/after `sinceMs` are elided from the briefing
108
+ * so the two boot synthetics never double-inject the same exchange.
109
+ */
110
+ export interface BriefingExcludeWindow {
111
+ chatId: string
112
+ threadId: number | null
113
+ sinceMs: number
114
+ }
115
+
116
+ export interface CollectBriefingOptions {
117
+ nowMs: number
118
+ activeWindowMs?: number
119
+ primaryDepth?: number
120
+ maxSurfaces?: number
121
+ exclude?: BriefingExcludeWindow | null
122
+ }
123
+
124
+ /**
125
+ * Derive the resume-dedup exclusion window from an already-built boot
126
+ * resume/report inbound. Returns null when there is no resume synthetic
127
+ * (the common clean-boot case) or its meta is missing the anchors.
128
+ */
129
+ export function excludeWindowFromResumeInbound(
130
+ msg: InboundMessage | null | undefined,
131
+ ): BriefingExcludeWindow | null {
132
+ if (msg == null) return null
133
+ const chatId = msg.meta?.chat_id
134
+ const startedAt = Number(msg.meta?.started_at)
135
+ if (typeof chatId !== 'string' || chatId.length === 0) return null
136
+ if (!Number.isFinite(startedAt) || startedAt <= 0) return null
137
+ const threadRaw = msg.meta?.message_thread_id
138
+ const threadNum = threadRaw != null && threadRaw !== '' ? Number(threadRaw) : null
139
+ return {
140
+ chatId,
141
+ threadId: threadNum != null && Number.isFinite(threadNum) ? threadNum : null,
142
+ sinceMs: startedAt,
143
+ }
144
+ }
145
+
146
+ function sameSurface(
147
+ a: { chatId: string; threadId: number | null },
148
+ b: { chatId: string; threadId: number | null },
149
+ ): boolean {
150
+ return a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null)
151
+ }
152
+
153
+ /**
154
+ * Query the history DB for the agent's active surfaces and their messages.
155
+ *
156
+ * Surface-scoped by construction: surfaces are the distinct
157
+ * (chat_id, thread_id) pairs with `role IN ('user','assistant')` activity
158
+ * inside the active window, most-recent first. The first surface gets
159
+ * `primaryDepth` messages; every other surface gets its single last
160
+ * message (rendered as a header + preview).
161
+ *
162
+ * NEVER throws: any DB failure (SQLITE_BUSY under writer contention, a
163
+ * missing table, corruption) returns `[]` so the caller degrades to an
164
+ * empty briefing instead of blocking or crashing boot.
165
+ */
166
+ export function collectBriefingSurfaces(
167
+ db: BriefingDb,
168
+ opts: CollectBriefingOptions,
169
+ ): BriefingSurface[] {
170
+ const activeWindowMs = opts.activeWindowMs ?? BRIEFING_ACTIVE_WINDOW_MS
171
+ const primaryDepth = opts.primaryDepth ?? BRIEFING_PRIMARY_DEPTH
172
+ const maxSurfaces = opts.maxSurfaces ?? BRIEFING_MAX_SURFACES
173
+ const cutoffSec = Math.floor((opts.nowMs - activeWindowMs) / 1000)
174
+ try {
175
+ const surfaceRows = db
176
+ .prepare(
177
+ `SELECT chat_id, thread_id, MAX(ts) AS last_ts
178
+ FROM messages
179
+ WHERE role IN ('user','assistant')
180
+ AND ts >= ?
181
+ AND chat_id <> ?
182
+ GROUP BY chat_id, thread_id
183
+ ORDER BY last_ts DESC
184
+ LIMIT ?`,
185
+ )
186
+ .all(cutoffSec, HISTORY_SELFCHECK_CHAT, maxSurfaces) as Array<{
187
+ chat_id: string
188
+ thread_id: number | null
189
+ last_ts: number
190
+ }>
191
+ const out: BriefingSurface[] = []
192
+ for (let i = 0; i < surfaceRows.length; i++) {
193
+ const s = surfaceRows[i]!
194
+ const depth = i === 0 ? primaryDepth : 1
195
+ const threadClause = s.thread_id == null ? 'thread_id IS NULL' : 'thread_id = ?'
196
+ const params: unknown[] = [s.chat_id]
197
+ if (s.thread_id != null) params.push(s.thread_id)
198
+ params.push(depth)
199
+ const msgRows = db
200
+ .prepare(
201
+ `SELECT role, user, ts, text
202
+ FROM messages
203
+ WHERE chat_id = ? AND ${threadClause}
204
+ AND role IN ('user','assistant')
205
+ ORDER BY ts DESC, message_id DESC
206
+ LIMIT ?`,
207
+ )
208
+ .all(...(params as [unknown, ...unknown[]])) as Array<{
209
+ role: string
210
+ user: string | null
211
+ ts: number
212
+ text: string | null
213
+ }>
214
+ msgRows.reverse() // oldest-first for rendering
215
+ let messages: BriefingMessageRow[] = msgRows.map((r) => ({
216
+ role: r.role,
217
+ user: r.user ?? null,
218
+ ts: r.ts,
219
+ text: r.text ?? '',
220
+ }))
221
+ // Resume dedup: elide messages the resume synthetic's interrupted-turn
222
+ // window already covers (same surface, at/after the turn's started_at).
223
+ const ex = opts.exclude
224
+ if (
225
+ ex != null &&
226
+ sameSurface({ chatId: s.chat_id, threadId: s.thread_id ?? null }, ex)
227
+ ) {
228
+ const sinceSec = Math.floor(ex.sinceMs / 1000)
229
+ messages = messages.filter((m) => m.ts < sinceSec)
230
+ }
231
+ if (messages.length === 0) continue // fully elided / empty — drop surface
232
+ out.push({
233
+ chatId: s.chat_id,
234
+ threadId: s.thread_id ?? null,
235
+ lastTs: s.last_ts,
236
+ messages,
237
+ })
238
+ }
239
+ return out
240
+ } catch {
241
+ // SQLITE_BUSY / timeout / schema drift — an empty briefing, never a
242
+ // blocked or crashed boot.
243
+ return []
244
+ }
245
+ }
246
+
247
+ /** Codepoint-safe truncation (mirrors resume-inbound-builder's
248
+ * `truncatePrompt` — a naive .slice can split a surrogate pair). Also
249
+ * collapses whitespace runs so each message renders as one line. */
250
+ function truncateOneLine(s: string, max: number): string {
251
+ const t = s.replace(/\s+/g, ' ').trim()
252
+ if (t.length <= max) return t
253
+ const points = Array.from(t)
254
+ if (points.length <= max) return t
255
+ return points.slice(0, max - 1).join('').trimEnd() + '…'
256
+ }
257
+
258
+ function surfaceLabel(s: { chatId: string; threadId: number | null }): string {
259
+ return s.threadId != null ? `chat ${s.chatId}, topic ${s.threadId}` : `chat ${s.chatId}`
260
+ }
261
+
262
+ function renderMessageLine(
263
+ m: BriefingMessageRow,
264
+ nowMs: number,
265
+ perMessageMax: number,
266
+ ): string {
267
+ const label = m.role === 'user' ? (m.user && m.user.trim() ? m.user.trim() : 'user') : 'you'
268
+ const age = humanizeElapsed(Math.max(0, nowMs - m.ts * 1000))
269
+ return `- [${age} ago] ${label}: ${truncateOneLine(m.text, perMessageMax)}`
270
+ }
271
+
272
+ export interface RenderBriefingOptions {
273
+ nowMs: number
274
+ /** Restart-reason breadcrumb (`.restart-reason` / SWITCHROOM_PENDING_*),
275
+ * folded into the header when present. */
276
+ restartReason?: string | null
277
+ charBudget?: number
278
+ perMessageMax?: number
279
+ }
280
+
281
+ /**
282
+ * Render the briefing text. Empty string when there is nothing to brief
283
+ * (no active surfaces) — the caller must then inject NOTHING.
284
+ *
285
+ * Deliberately starts with `RESUME_SYNTHETIC_PROMPT_PREFIX` ("You just
286
+ * restarted.") — the same machine-stable token every synthetic boot
287
+ * inbound leads with — so if the briefing turn is itself interrupted, the
288
+ * boot-resume loop-guard (`isResumeSyntheticTurn`) classifies it as a
289
+ * synthetic turn and never auto-resumes it into a restart→briefing chain.
290
+ *
291
+ * Budgeting: the primary section is trimmed OLDEST-first (newest messages
292
+ * are the ones worth keeping) until the total fits `charBudget`; secondary
293
+ * surface blocks are then appended most-recent-first only while they fit.
294
+ * The returned string is always <= charBudget.
295
+ */
296
+ export function renderBootBriefing(
297
+ surfaces: BriefingSurface[],
298
+ opts: RenderBriefingOptions,
299
+ ): string {
300
+ if (surfaces.length === 0) return ''
301
+ const charBudget = opts.charBudget ?? BRIEFING_CHAR_BUDGET
302
+ const perMessageMax = opts.perMessageMax ?? BRIEFING_PER_MESSAGE_MAX_CHARS
303
+ const reasonClause =
304
+ opts.restartReason && opts.restartReason.trim()
305
+ ? ` The previous session ended via: ${truncateOneLine(opts.restartReason, 120)}.`
306
+ : ''
307
+ const header =
308
+ `${RESUME_SYNTHETIC_PROMPT_PREFIX} This is an automatic boot briefing assembled ` +
309
+ `from your durable message history — context to reorient you, NOT a new user ` +
310
+ `request.${reasonClause} Read it, then: if nothing in it is unfinished or owed, ` +
311
+ `do NOT message the user (end the turn with NO_REPLY); if something was clearly ` +
312
+ `left unfinished or owed, briefly pick it up. The full history is available via ` +
313
+ `get_recent_messages.`
314
+
315
+ const primary = surfaces[0]!
316
+ const primaryTitle =
317
+ `## Active conversation — ${surfaceLabel(primary)} ` +
318
+ `(last active ${humanizeElapsed(Math.max(0, opts.nowMs - primary.lastTs * 1000))} ago)`
319
+ const primaryLines = primary.messages.map((m) =>
320
+ renderMessageLine(m, opts.nowMs, perMessageMax),
321
+ )
322
+
323
+ const assemble = (lines: string[], secondaries: string[]): string => {
324
+ const parts = [header, '', primaryTitle, ...lines]
325
+ if (secondaries.length > 0) {
326
+ parts.push('', '## Other recent surfaces (active in the last 48h)', ...secondaries)
327
+ }
328
+ return parts.join('\n')
329
+ }
330
+
331
+ // Trim the primary section oldest-first until the briefing (without any
332
+ // secondaries yet) fits the budget. Always keep at least the newest line.
333
+ const kept = [...primaryLines]
334
+ while (kept.length > 1 && assemble(kept, []).length > charBudget) {
335
+ kept.shift()
336
+ }
337
+ if (assemble(kept, []).length > charBudget) {
338
+ // Degenerate (budget smaller than header + one line): hard-truncate.
339
+ return assemble(kept, []).slice(0, charBudget)
340
+ }
341
+
342
+ // Append secondary surfaces (header + last-message preview — the
343
+ // "2-line header + last message" shape) while they fit.
344
+ const secondaries: string[] = []
345
+ for (const s of surfaces.slice(1)) {
346
+ const last = s.messages[s.messages.length - 1]!
347
+ const block =
348
+ `- ${surfaceLabel(s)} — last active ` +
349
+ `${humanizeElapsed(Math.max(0, opts.nowMs - s.lastTs * 1000))} ago:\n` +
350
+ ` ${renderMessageLine(last, opts.nowMs, perMessageMax).slice(2)}`
351
+ if (assemble(kept, [...secondaries, block]).length > charBudget) break
352
+ secondaries.push(block)
353
+ }
354
+ return assemble(kept, secondaries)
355
+ }
356
+
357
+ /**
358
+ * Read the restart-reason breadcrumb the legacy handoff-briefing.sh folds
359
+ * in: `<agentDir>/.restart-reason` (first line), overridden by
360
+ * `SWITCHROOM_PENDING_ENDED_VIA` when set (same precedence as the shell
361
+ * script). Pure via the injected `readFile`; never throws.
362
+ */
363
+ export function readRestartBreadcrumb(opts: {
364
+ restartReasonPath: string | null
365
+ env: Record<string, string | undefined>
366
+ readFile: (path: string) => string
367
+ }): string | null {
368
+ let reason: string | null = null
369
+ if (opts.restartReasonPath) {
370
+ try {
371
+ const raw = opts.readFile(opts.restartReasonPath)
372
+ const first = raw.split('\n')[0]?.replace(/\r/g, '').trim()
373
+ if (first) reason = first
374
+ } catch {
375
+ /* missing / unreadable breadcrumb — fine */
376
+ }
377
+ }
378
+ const envVia = opts.env.SWITCHROOM_PENDING_ENDED_VIA
379
+ if (typeof envVia === 'string' && envVia.trim().length > 0) reason = envVia.trim()
380
+ return reason
381
+ }
382
+
383
+ /** Feature-flag / suppression decision for the gateway briefing. Pure. */
384
+ export interface BootBriefingDecision {
385
+ build: boolean
386
+ reason:
387
+ | 'ok'
388
+ | 'flag-legacy'
389
+ | 'force-fresh'
390
+ | 'transcript-replay-possible'
391
+ }
392
+
393
+ /**
394
+ * Decide whether this boot should build a gateway briefing at all.
395
+ *
396
+ * - `briefingMode !== 'gateway'` → legacy path owns continuity; build
397
+ * nothing (the default until the gateway path has soaked).
398
+ * - `.force-fresh-session` marker present (a /reset · /new restart) →
399
+ * the user explicitly asked for a clean slate; re-injecting recent
400
+ * context would defeat the reset.
401
+ * - `resumeMode` 'continue' or 'auto' → the inner claude launch may
402
+ * replay the full transcript via `--continue`; a briefing on top would
403
+ * duplicate it. ('auto' can still fall back to a fresh session for an
404
+ * oversized/stale transcript — the gateway forks before start.sh's
405
+ * inner pass computes CONTINUE_FLAG, so we suppress conservatively;
406
+ * documented follow-up.)
407
+ */
408
+ export function decideBootBriefing(opts: {
409
+ briefingMode: string | undefined
410
+ resumeMode: string | undefined
411
+ forceFreshMarker: boolean
412
+ }): BootBriefingDecision {
413
+ if (opts.briefingMode !== 'gateway') return { build: false, reason: 'flag-legacy' }
414
+ if (opts.forceFreshMarker) return { build: false, reason: 'force-fresh' }
415
+ if (opts.resumeMode === 'continue' || opts.resumeMode === 'auto') {
416
+ return { build: false, reason: 'transcript-replay-possible' }
417
+ }
418
+ return { build: true, reason: 'ok' }
419
+ }
420
+
421
+ /**
422
+ * Build the synthetic briefing inbound. Routed to the primary (most
423
+ * recently active) surface so the turn gets a currentTurn / progress card
424
+ * in the conversation the context belongs to — same rationale as the
425
+ * resume builders' `meta.chat_id`. Carries `meta.expiresAt` so the spool's
426
+ * TTL filter drops a briefing that went stale before delivery.
427
+ */
428
+ export function buildBootBriefingInbound(args: {
429
+ chatId: string
430
+ threadId: number | null
431
+ text: string
432
+ nowMs?: number
433
+ ttlMs?: number
434
+ }): InboundMessage {
435
+ const ts = args.nowMs ?? Date.now()
436
+ const ttlMs = args.ttlMs ?? BRIEFING_TTL_MS
437
+ const meta: Record<string, string> = {
438
+ source: BOOT_BRIEFING_SOURCE,
439
+ chat_id: args.chatId,
440
+ ...(args.threadId != null ? { message_thread_id: String(args.threadId) } : {}),
441
+ // message_id mirrors the resume builders: rides the enqueue's channel
442
+ // XML so the deliver-until-acked queue can ack THIS synthetic. Never
443
+ // used as a Telegram reply_to.
444
+ message_id: String(ts),
445
+ expiresAt: String(ts + ttlMs),
446
+ }
447
+ return {
448
+ type: 'inbound',
449
+ chatId: args.chatId,
450
+ ...(args.threadId != null ? { threadId: args.threadId } : {}),
451
+ messageId: ts,
452
+ user: 'switchroom',
453
+ userId: 0,
454
+ ts,
455
+ text: args.text,
456
+ meta,
457
+ }
458
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Impure orchestration for the gateway boot briefing
3
+ * (`session_continuity.briefing: gateway`). The pure logic — flag
4
+ * decision, surface collection, budget-bounded rendering, resume dedup —
5
+ * lives in `boot-briefing-builder.ts`; this module reads the real env /
6
+ * fs / history handle and hands the finished inbound to the caller's
7
+ * `put` (spool or in-memory buffer).
8
+ *
9
+ * Contract with gateway.ts: a single call at boot, AFTER `initHistory`
10
+ * and AFTER the boot-resume inbound is built (its interrupted-turn window
11
+ * feeds the dedup), and BEFORE the resume inbound is spooled when the
12
+ * caller wants briefing-before-resume delivery order. NEVER throws and
13
+ * never blocks: every failure path degrades to "no briefing".
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { getHistoryDbForBriefing } from '../history.js'
19
+ import type { InboundMessage } from './ipc-protocol.js'
20
+ import {
21
+ buildBootBriefingInbound,
22
+ collectBriefingSurfaces,
23
+ decideBootBriefing,
24
+ excludeWindowFromResumeInbound,
25
+ readRestartBreadcrumb,
26
+ renderBootBriefing,
27
+ } from './boot-briefing-builder.js'
28
+
29
+ export interface MaybeQueueBootBriefingOptions {
30
+ env: Record<string, string | undefined>
31
+ /** Gateway STATE_DIR (`<agentDir>/telegram` in production). */
32
+ stateDir: string
33
+ /** The already-built boot resume/report inbound (or null) — its
34
+ * interrupted-turn window is elided from the briefing so the two boot
35
+ * synthetics never double-inject the same messages. */
36
+ resumeMsg: InboundMessage | null
37
+ /** Durable enqueue — `inboundSpool.put` (or the in-memory buffer's push
38
+ * in STATIC mode). */
39
+ put: (agent: string, msg: InboundMessage) => unknown
40
+ log?: (line: string) => void
41
+ nowMs?: number
42
+ }
43
+
44
+ /**
45
+ * Build + enqueue the boot briefing when the feature flag and suppression
46
+ * rules allow. Returns the queued inbound (for observability/tests) or
47
+ * null when nothing was queued.
48
+ */
49
+ export function maybeQueueBootBriefing(
50
+ opts: MaybeQueueBootBriefingOptions,
51
+ ): InboundMessage | null {
52
+ const log = opts.log ?? ((l: string) => process.stderr.write(l))
53
+ try {
54
+ const agentDir = opts.stateDir.endsWith('/telegram')
55
+ ? opts.stateDir.slice(0, -'/telegram'.length)
56
+ : opts.stateDir
57
+ // Session-generation guard (#4242). This module re-evaluates on EVERY
58
+ // gateway process start, including a supervisor respawn after the
59
+ // gateway crashes — but a respawn does NOT restart the inner Claude
60
+ // session, so re-queuing a boot briefing would inject a "you just
61
+ // rebooted" reorientation into a live, mid-conversation session. The
62
+ // spool's dedup can't catch it: by respawn time boot-1's briefing has
63
+ // been delivered AND acked, so its spool entry is already gone.
64
+ //
65
+ // start.sh's OUTER pass stamps SWITCHROOM_GATEWAY_BOOT_ID once per REAL
66
+ // boot, before forking the gateway; `_switchroom_supervise` respawns
67
+ // `bun` in a loop within that same shell, so every respawn inherits the
68
+ // identical id, while the next real boot re-derives a fresh one. We
69
+ // persist the id the first time a generation actually queues (or
70
+ // determines it has nothing to queue) and skip when the persisted id
71
+ // matches — that is a respawn. Absent env (non-docker / pre-upgrade
72
+ // start.sh) leaves the guard inert: legacy best-effort behaviour.
73
+ const bootId = opts.env.SWITCHROOM_GATEWAY_BOOT_ID
74
+ const genMarkerPath = join(agentDir, '.boot-briefing-generation')
75
+ if (bootId) {
76
+ let prevGen: string | null = null
77
+ try {
78
+ prevGen = readFileSync(genMarkerPath, 'utf8').trim()
79
+ } catch {
80
+ prevGen = null
81
+ }
82
+ if (prevGen === bootId) {
83
+ log(
84
+ 'telegram gateway: boot-briefing suppressed (supervisor respawn — this boot generation already briefed)\n',
85
+ )
86
+ return null
87
+ }
88
+ }
89
+ const markGeneration = (): void => {
90
+ if (!bootId) return
91
+ try {
92
+ writeFileSync(genMarkerPath, `${bootId}\n`)
93
+ } catch {
94
+ // Best-effort: a failed persist only risks one redundant re-queue on
95
+ // respawn, which the spool still dedups; never block boot.
96
+ }
97
+ }
98
+ // Force-fresh suppression is keyed on env, NOT on existsSync at this
99
+ // module-eval time. start.sh's OUTER pass snapshots the
100
+ // `.force-fresh-session` marker into SWITCHROOM_FORCE_FRESH *before*
101
+ // forking this gateway, so the value is fixed at fork time and immune to
102
+ // the inner tmux pass's later `rm` of the marker (the two race with no
103
+ // ordering — the old existsSync could lose that race and resurrect the
104
+ // briefing on a /reset boot). The existsSync is retained only as a
105
+ // fallback for runtimes where start.sh doesn't hoist the env (non-docker),
106
+ // where there is no such fork race.
107
+ const forceFresh =
108
+ opts.env.SWITCHROOM_FORCE_FRESH === '1' ||
109
+ existsSync(join(agentDir, '.force-fresh-session'))
110
+ const decision = decideBootBriefing({
111
+ briefingMode: opts.env.SWITCHROOM_SESSION_BRIEFING,
112
+ resumeMode: opts.env.SWITCHROOM_RESUME_MODE,
113
+ forceFreshMarker: forceFresh,
114
+ })
115
+ if (!decision.build) {
116
+ if (decision.reason !== 'flag-legacy') {
117
+ log(`telegram gateway: boot-briefing suppressed (${decision.reason})\n`)
118
+ }
119
+ return null
120
+ }
121
+ const selfAgent = opts.env.SWITCHROOM_AGENT_NAME ?? ''
122
+ if (!selfAgent) return null
123
+ const db = getHistoryDbForBriefing()
124
+ if (db == null) {
125
+ log('telegram gateway: boot-briefing skipped — history DB unavailable\n')
126
+ return null
127
+ }
128
+ const nowMs = opts.nowMs ?? Date.now()
129
+ const surfaces = collectBriefingSurfaces(db, {
130
+ nowMs,
131
+ exclude: excludeWindowFromResumeInbound(opts.resumeMsg),
132
+ })
133
+ const restartReason = readRestartBreadcrumb({
134
+ restartReasonPath: join(agentDir, '.restart-reason'),
135
+ env: opts.env,
136
+ readFile: (p) => readFileSync(p, 'utf8'),
137
+ })
138
+ const text = renderBootBriefing(surfaces, { nowMs, restartReason })
139
+ if (!text) {
140
+ // Consume the generation even when empty: had there been nothing to
141
+ // brief at boot, a later respawn must not suddenly brief mid-session
142
+ // just because fresh messages arrived after the session came up.
143
+ markGeneration()
144
+ log('telegram gateway: boot-briefing empty (no recent surfaces) — nothing queued\n')
145
+ return null
146
+ }
147
+ const primary = surfaces[0]!
148
+ const msg = buildBootBriefingInbound({
149
+ chatId: primary.chatId,
150
+ threadId: primary.threadId,
151
+ text,
152
+ nowMs,
153
+ })
154
+ opts.put(selfAgent, msg)
155
+ markGeneration()
156
+ log(
157
+ `telegram gateway: boot-briefing queued chat=${primary.chatId}` +
158
+ `${primary.threadId != null ? ` thread=${primary.threadId}` : ''} ` +
159
+ `surfaces=${surfaces.length} chars=${text.length}\n`,
160
+ )
161
+ return msg
162
+ } catch (err) {
163
+ // The briefing is best-effort context — a failure here must never
164
+ // block or crash gateway boot.
165
+ log(
166
+ `telegram gateway: boot-briefing failed (${(err as Error).message}) — continuing without briefing\n`,
167
+ )
168
+ return null
169
+ }
170
+ }