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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- 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 +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -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 +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- 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 +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -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 +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -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/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- 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
|
@@ -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'
|