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.
- package/dist/agent-scheduler/index.js +22 -1
- package/dist/auth-broker/index.js +26 -2
- package/dist/buzz-gateway/index.js +9207 -0
- package/dist/cli/notion-write-pretool.mjs +22 -1
- package/dist/cli/switchroom.js +91 -9
- package/dist/host-control/main.js +27 -3
- package/dist/vault/approvals/kernel-server.js +26 -2
- package/dist/vault/broker/server.js +26 -2
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +78 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +1156 -247
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +73 -81
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- 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-
|
|
17191
|
+
return "unknown-5xx";
|
|
17192
17192
|
}
|
|
17193
17193
|
}
|
|
17194
17194
|
function classifyInner(raw) {
|
|
17195
17195
|
if (raw == null)
|
|
17196
|
-
return "unknown-
|
|
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-
|
|
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
|
+
}
|