switchroom 0.19.48 → 0.20.1

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 (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,586 @@
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
+ /**
248
+ * Slice `s` to AT MOST `maxUnits` UTF-16 code units without ever bisecting a
249
+ * surrogate pair. `max` in this module is a UTF-16 `.length` budget (that is
250
+ * what Telegram / the char-budget invariant measure), so truncation MUST be
251
+ * expressed in UTF-16 units — measuring codepoints against a UTF-16 budget
252
+ * lets astral-plane input (each 😀 is 1 codepoint but 2 UTF-16 units) blow the
253
+ * bound. If the cut would land between a high and low surrogate we step back
254
+ * one unit, dropping the lone high surrogate rather than emitting a broken
255
+ * pair. Guarantees `result.length <= maxUnits`.
256
+ */
257
+ function sliceUtf16Safe(s: string, maxUnits: number): string {
258
+ if (maxUnits <= 0) return ''
259
+ if (s.length <= maxUnits) return s
260
+ let end = maxUnits
261
+ // A high surrogate (0xD800–0xDBFF) at the last kept position means its low
262
+ // half sits at index `end` and would be severed — drop the high surrogate.
263
+ const code = s.charCodeAt(end - 1)
264
+ if (code >= 0xd800 && code <= 0xdbff) end -= 1
265
+ return s.slice(0, end)
266
+ }
267
+
268
+ /** UTF-16-safe truncation (a naive .slice can split a surrogate pair, and
269
+ * measuring codepoints against a UTF-16 `max` can overflow it for astral
270
+ * input). Also collapses whitespace runs so each message renders as one
271
+ * line. Guarantees `result.length <= max`. */
272
+ function truncateOneLine(s: string, max: number): string {
273
+ const t = s.replace(/\s+/g, ' ').trim()
274
+ if (t.length <= max) return t
275
+ if (max <= 0) return ''
276
+ // Reserve one UTF-16 unit for the ellipsis ('…' is a single BMP unit).
277
+ return sliceUtf16Safe(t, max - 1).trimEnd() + '…'
278
+ }
279
+
280
+ function surfaceLabel(s: { chatId: string; threadId: number | null }): string {
281
+ return s.threadId != null ? `chat ${s.chatId}, topic ${s.threadId}` : `chat ${s.chatId}`
282
+ }
283
+
284
+ function renderMessageLine(
285
+ m: BriefingMessageRow,
286
+ nowMs: number,
287
+ perMessageMax: number,
288
+ ): string {
289
+ const label = m.role === 'user' ? (m.user && m.user.trim() ? m.user.trim() : 'user') : 'you'
290
+ const age = humanizeElapsed(Math.max(0, nowMs - m.ts * 1000))
291
+ return `- [${age} ago] ${label}: ${truncateOneLine(m.text, perMessageMax)}`
292
+ }
293
+
294
+ /**
295
+ * One Hindsight recall result, as folded into the briefing's Hindsight
296
+ * section. Mirrors the `.results[]` shape `bin/handoff-briefing.sh` reads:
297
+ * a `text` body and an optional `timestamp` string. `text` may be empty —
298
+ * rendered as `(no text)` to match the shell's `.text // "(no text)"`.
299
+ */
300
+ export interface HindsightRecallResult {
301
+ text: string
302
+ timestamp?: string | null
303
+ }
304
+
305
+ /**
306
+ * Today's daily-memory input for the briefing — the `date` (agent-local
307
+ * `YYYY-MM-DD`, used only in the section header) and the file `content`.
308
+ * Absent/empty content renders no section (no empty header).
309
+ */
310
+ export interface BriefingDailyMemory {
311
+ date: string
312
+ content: string
313
+ }
314
+
315
+ /** Section title for the Hindsight-recall block (mirrors the shell). */
316
+ const HINDSIGHT_SECTION_TITLE = '## Hindsight recall (recent context)'
317
+
318
+ /** Minimum chars of room a trailing (Hindsight / daily-memory) section
319
+ * needs before it is worth appending at all — below this, a truncated
320
+ * section would be just a header + a sliver, so skip it whole. */
321
+ const TRAILING_SECTION_MIN_ROOM = 64
322
+
323
+ export interface RenderBriefingOptions {
324
+ nowMs: number
325
+ /** Restart-reason breadcrumb (`.restart-reason` / SWITCHROOM_PENDING_*),
326
+ * folded into the header when present. */
327
+ restartReason?: string | null
328
+ charBudget?: number
329
+ perMessageMax?: number
330
+ /** Hindsight recall results (source 2 of the legacy handoff contract).
331
+ * Empty / absent → no Hindsight section. */
332
+ hindsight?: HindsightRecallResult[] | null
333
+ /** Today's daily memory (source 3). Absent / empty content → no section. */
334
+ dailyMemory?: BriefingDailyMemory | null
335
+ }
336
+
337
+ /** Render the Hindsight recall body (the lines under the section header).
338
+ * Mirrors the shell's jq: `- <text> (<timestamp>)`, `(no text)` when the
339
+ * text is blank, and no trailing ` (…)` when there is no timestamp.
340
+ * Returns '' when there are no results (caller then emits no header). */
341
+ function renderHindsightBody(results: HindsightRecallResult[]): string {
342
+ const lines: string[] = []
343
+ for (const r of results) {
344
+ const text = (r.text ?? '').replace(/\s+/g, ' ').trim() || '(no text)'
345
+ const ts = r.timestamp && String(r.timestamp).trim() ? ` (${String(r.timestamp).trim()})` : ''
346
+ lines.push(`- ${text}${ts}`)
347
+ }
348
+ return lines.join('\n')
349
+ }
350
+
351
+ /**
352
+ * Append a `## title` + body section to `base`, but only within the char
353
+ * budget. If the whole section fits, append it. If not, append the header
354
+ * plus as much body as fits (so an over-long daily memory truncates rather
355
+ * than vanishing) — unless there isn't even `TRAILING_SECTION_MIN_ROOM`
356
+ * left, in which case the section is skipped whole. Guarantees the result
357
+ * is always <= budget. A blank body is a no-op (no empty header).
358
+ */
359
+ function appendSectionWithinBudget(
360
+ base: string,
361
+ title: string,
362
+ body: string,
363
+ budget: number,
364
+ ): string {
365
+ const trimmedBody = body.trimEnd()
366
+ if (!trimmedBody) return base
367
+ const sep = '\n\n'
368
+ const full = `${base}${sep}${title}${sep}${trimmedBody}`
369
+ if (full.length <= budget) return full
370
+ // Room left for the body after the base + separators + title.
371
+ const room = budget - base.length - sep.length - title.length - sep.length
372
+ if (room < TRAILING_SECTION_MIN_ROOM) return base
373
+ const truncated = truncateOneLineOrBlock(trimmedBody, room)
374
+ return `${base}${sep}${title}${sep}${truncated}`
375
+ }
376
+
377
+ /** UTF-16-safe hard truncation preserving newlines (unlike `truncateOneLine`,
378
+ * which collapses whitespace to one line). Used for block sections (daily
379
+ * memory / Hindsight) where line structure matters. Bounds the result in
380
+ * UTF-16 units — measuring codepoints against a UTF-16 `max` overflows the
381
+ * budget for astral input (each 😀 is 2 UTF-16 units). Guarantees
382
+ * `result.length <= max`. */
383
+ function truncateOneLineOrBlock(s: string, max: number): string {
384
+ if (s.length <= max) return s
385
+ if (max <= 0) return ''
386
+ // Reserve one UTF-16 unit for the ellipsis ('…' is a single BMP unit).
387
+ return sliceUtf16Safe(s, max - 1).trimEnd() + '…'
388
+ }
389
+
390
+ /**
391
+ * Render the briefing text. Empty string when there is nothing to brief
392
+ * (no active surfaces) — the caller must then inject NOTHING.
393
+ *
394
+ * Deliberately starts with `RESUME_SYNTHETIC_PROMPT_PREFIX` ("You just
395
+ * restarted.") — the same machine-stable token every synthetic boot
396
+ * inbound leads with — so if the briefing turn is itself interrupted, the
397
+ * boot-resume loop-guard (`isResumeSyntheticTurn`) classifies it as a
398
+ * synthetic turn and never auto-resumes it into a restart→briefing chain.
399
+ *
400
+ * Budgeting: the primary section is trimmed OLDEST-first (newest messages
401
+ * are the ones worth keeping) until the total fits `charBudget`; secondary
402
+ * surface blocks are then appended most-recent-first only while they fit.
403
+ * The returned string is always <= charBudget.
404
+ */
405
+ export function renderBootBriefing(
406
+ surfaces: BriefingSurface[],
407
+ opts: RenderBriefingOptions,
408
+ ): string {
409
+ if (surfaces.length === 0) return ''
410
+ const charBudget = opts.charBudget ?? BRIEFING_CHAR_BUDGET
411
+ const perMessageMax = opts.perMessageMax ?? BRIEFING_PER_MESSAGE_MAX_CHARS
412
+ const reasonClause =
413
+ opts.restartReason && opts.restartReason.trim()
414
+ ? ` The previous session ended via: ${truncateOneLine(opts.restartReason, 120)}.`
415
+ : ''
416
+ const header =
417
+ `${RESUME_SYNTHETIC_PROMPT_PREFIX} This is an automatic boot briefing assembled ` +
418
+ `from your durable message history — context to reorient you, NOT a new user ` +
419
+ `request.${reasonClause} Read it, then: if nothing in it is unfinished or owed, ` +
420
+ `do NOT message the user (end the turn with NO_REPLY); if something was clearly ` +
421
+ `left unfinished or owed, briefly pick it up. The full history is available via ` +
422
+ `get_recent_messages.`
423
+
424
+ const primary = surfaces[0]!
425
+ const primaryTitle =
426
+ `## Active conversation — ${surfaceLabel(primary)} ` +
427
+ `(last active ${humanizeElapsed(Math.max(0, opts.nowMs - primary.lastTs * 1000))} ago)`
428
+ const primaryLines = primary.messages.map((m) =>
429
+ renderMessageLine(m, opts.nowMs, perMessageMax),
430
+ )
431
+
432
+ const assemble = (lines: string[], secondaries: string[]): string => {
433
+ const parts = [header, '', primaryTitle, ...lines]
434
+ if (secondaries.length > 0) {
435
+ parts.push('', '## Other recent surfaces (active in the last 48h)', ...secondaries)
436
+ }
437
+ return parts.join('\n')
438
+ }
439
+
440
+ // Trim the primary section oldest-first until the briefing (without any
441
+ // secondaries yet) fits the budget. Always keep at least the newest line.
442
+ const kept = [...primaryLines]
443
+ while (kept.length > 1 && assemble(kept, []).length > charBudget) {
444
+ kept.shift()
445
+ }
446
+ if (assemble(kept, []).length > charBudget) {
447
+ // Degenerate (budget smaller than header + one line): hard-truncate.
448
+ return assemble(kept, []).slice(0, charBudget)
449
+ }
450
+
451
+ // Append secondary surfaces (header + last-message preview — the
452
+ // "2-line header + last message" shape) while they fit.
453
+ const secondaries: string[] = []
454
+ for (const s of surfaces.slice(1)) {
455
+ const last = s.messages[s.messages.length - 1]!
456
+ const block =
457
+ `- ${surfaceLabel(s)} — last active ` +
458
+ `${humanizeElapsed(Math.max(0, opts.nowMs - s.lastTs * 1000))} ago:\n` +
459
+ ` ${renderMessageLine(last, opts.nowMs, perMessageMax).slice(2)}`
460
+ if (assemble(kept, [...secondaries, block]).length > charBudget) break
461
+ secondaries.push(block)
462
+ }
463
+
464
+ // Sources 2 + 3 of the legacy handoff contract, appended after the durable
465
+ // Telegram slice and within the SAME char budget (Telegram history is the
466
+ // freshest context, so it keeps priority; Hindsight then daily memory fill
467
+ // the remaining room, truncating rather than blowing the budget). Order
468
+ // mirrors bin/handoff-briefing.sh: telegram → hindsight → daily memory.
469
+ let out = assemble(kept, secondaries)
470
+ const hindsightBody = opts.hindsight && opts.hindsight.length > 0
471
+ ? renderHindsightBody(opts.hindsight)
472
+ : ''
473
+ out = appendSectionWithinBudget(out, HINDSIGHT_SECTION_TITLE, hindsightBody, charBudget)
474
+ if (opts.dailyMemory && opts.dailyMemory.content.trim()) {
475
+ out = appendSectionWithinBudget(
476
+ out,
477
+ `## Today's memory (${opts.dailyMemory.date})`,
478
+ opts.dailyMemory.content,
479
+ charBudget,
480
+ )
481
+ }
482
+ return out
483
+ }
484
+
485
+ /**
486
+ * Read the restart-reason breadcrumb the legacy handoff-briefing.sh folds
487
+ * in: `<agentDir>/.restart-reason` (first line), overridden by
488
+ * `SWITCHROOM_PENDING_ENDED_VIA` when set (same precedence as the shell
489
+ * script). Pure via the injected `readFile`; never throws.
490
+ */
491
+ export function readRestartBreadcrumb(opts: {
492
+ restartReasonPath: string | null
493
+ env: Record<string, string | undefined>
494
+ readFile: (path: string) => string
495
+ }): string | null {
496
+ let reason: string | null = null
497
+ if (opts.restartReasonPath) {
498
+ try {
499
+ const raw = opts.readFile(opts.restartReasonPath)
500
+ const first = raw.split('\n')[0]?.replace(/\r/g, '').trim()
501
+ if (first) reason = first
502
+ } catch {
503
+ /* missing / unreadable breadcrumb — fine */
504
+ }
505
+ }
506
+ const envVia = opts.env.SWITCHROOM_PENDING_ENDED_VIA
507
+ if (typeof envVia === 'string' && envVia.trim().length > 0) reason = envVia.trim()
508
+ return reason
509
+ }
510
+
511
+ /** Feature-flag / suppression decision for the gateway briefing. Pure. */
512
+ export interface BootBriefingDecision {
513
+ build: boolean
514
+ reason:
515
+ | 'ok'
516
+ | 'flag-legacy'
517
+ | 'force-fresh'
518
+ | 'transcript-replay-possible'
519
+ }
520
+
521
+ /**
522
+ * Decide whether this boot should build a gateway briefing at all.
523
+ *
524
+ * - `briefingMode !== 'gateway'` → legacy path owns continuity; build
525
+ * nothing (the default until the gateway path has soaked).
526
+ * - `.force-fresh-session` marker present (a /reset · /new restart) →
527
+ * the user explicitly asked for a clean slate; re-injecting recent
528
+ * context would defeat the reset.
529
+ * - `resumeMode` 'continue' or 'auto' → the inner claude launch may
530
+ * replay the full transcript via `--continue`; a briefing on top would
531
+ * duplicate it. ('auto' can still fall back to a fresh session for an
532
+ * oversized/stale transcript — the gateway forks before start.sh's
533
+ * inner pass computes CONTINUE_FLAG, so we suppress conservatively;
534
+ * documented follow-up.)
535
+ */
536
+ export function decideBootBriefing(opts: {
537
+ briefingMode: string | undefined
538
+ resumeMode: string | undefined
539
+ forceFreshMarker: boolean
540
+ }): BootBriefingDecision {
541
+ if (opts.briefingMode !== 'gateway') return { build: false, reason: 'flag-legacy' }
542
+ if (opts.forceFreshMarker) return { build: false, reason: 'force-fresh' }
543
+ if (opts.resumeMode === 'continue' || opts.resumeMode === 'auto') {
544
+ return { build: false, reason: 'transcript-replay-possible' }
545
+ }
546
+ return { build: true, reason: 'ok' }
547
+ }
548
+
549
+ /**
550
+ * Build the synthetic briefing inbound. Routed to the primary (most
551
+ * recently active) surface so the turn gets a currentTurn / progress card
552
+ * in the conversation the context belongs to — same rationale as the
553
+ * resume builders' `meta.chat_id`. Carries `meta.expiresAt` so the spool's
554
+ * TTL filter drops a briefing that went stale before delivery.
555
+ */
556
+ export function buildBootBriefingInbound(args: {
557
+ chatId: string
558
+ threadId: number | null
559
+ text: string
560
+ nowMs?: number
561
+ ttlMs?: number
562
+ }): InboundMessage {
563
+ const ts = args.nowMs ?? Date.now()
564
+ const ttlMs = args.ttlMs ?? BRIEFING_TTL_MS
565
+ const meta: Record<string, string> = {
566
+ source: BOOT_BRIEFING_SOURCE,
567
+ chat_id: args.chatId,
568
+ ...(args.threadId != null ? { message_thread_id: String(args.threadId) } : {}),
569
+ // message_id mirrors the resume builders: rides the enqueue's channel
570
+ // XML so the deliver-until-acked queue can ack THIS synthetic. Never
571
+ // used as a Telegram reply_to.
572
+ message_id: String(ts),
573
+ expiresAt: String(ts + ttlMs),
574
+ }
575
+ return {
576
+ type: 'inbound',
577
+ chatId: args.chatId,
578
+ ...(args.threadId != null ? { threadId: args.threadId } : {}),
579
+ messageId: ts,
580
+ user: 'switchroom',
581
+ userId: 0,
582
+ ts,
583
+ text: args.text,
584
+ meta,
585
+ }
586
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Capability sentinel for the start.sh ↔ gateway-bundle version handshake
3
+ * (#4245). This is a DEPENDENCY-FREE leaf module on purpose: it is imported
4
+ * both by the gateway (`boot-briefing-wiring.ts`, which references it so the
5
+ * un-minified bundle carries the literal) AND by `src/agents/scaffold.ts`
6
+ * (which templates the value into the generated start.sh). Keeping it free of
7
+ * `bun:sqlite` / history imports lets the node-side scaffold import it without
8
+ * pulling in bun-only runtime deps.
9
+ *
10
+ * ## The skew it closes
11
+ *
12
+ * A freshly-scaffolded start.sh sets `SWITCHROOM_SESSION_BRIEFING=gateway` AND
13
+ * skips the legacy shell handoff-briefing assembler for that mode. If the
14
+ * deployed `/opt/switchroom/telegram-plugin/dist/gateway/gateway.js` bundle
15
+ * PREDATES the boot-briefing builder, the gateway path is dead too — the agent
16
+ * gets a SILENT no-briefing until the image is updated. The stale bundle cannot
17
+ * self-report a feature it doesn't contain, so the FRESH component (start.sh)
18
+ * performs the handshake: it greps the deployed bundle for this literal before
19
+ * trusting the flag. On a miss it warns loudly and drops a marker so the inner
20
+ * pass runs the legacy handoff assembler as a fallback (see
21
+ * `profiles/_base/start.sh.hbs`), rather than leaving the boot with nothing.
22
+ *
23
+ * The gateway bundle is built UN-minified (`telegram-plugin/scripts/build.mjs`:
24
+ * `bun build --target node`, no `--minify`), so this string literal survives
25
+ * verbatim into `gateway.js`.
26
+ *
27
+ * Bump the version suffix ONLY on a breaking change to the boot-briefing
28
+ * capability contract that an older start.sh must not treat as present. Both
29
+ * sides of the handshake read this single constant, so a bump stays in sync.
30
+ */
31
+ export const GATEWAY_BOOT_BRIEFING_CAPABILITY = 'switchroom-cap:boot-briefing:v1'