switchroom 0.20.0 → 0.20.2

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 (29) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +4 -3
  4. package/dist/buzz-gateway/index.js +166 -6
  5. package/dist/cli/notion-write-pretool.mjs +2 -2
  6. package/dist/cli/switchroom.js +24704 -16399
  7. package/dist/host-control/main.js +44 -10
  8. package/dist/vault/approvals/kernel-server.js +4 -3
  9. package/dist/vault/broker/server.js +4 -3
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +79 -10
  12. package/telegram-plugin/dist/gateway/gateway.js +1400 -964
  13. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  14. package/telegram-plugin/gateway/access-store.ts +194 -0
  15. package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
  16. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  17. package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
  18. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  19. package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
  20. package/telegram-plugin/gateway/gateway.ts +43 -123
  21. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  22. package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
  23. package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
  24. package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
  25. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  26. package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
  27. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  28. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  29. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Unit tests for access-store.ts — the access/allowlist file layer extracted
3
+ * from gateway.ts (switchroom#4248).
4
+ *
5
+ * These lock in the behavior that used to be inline in gateway.ts, in
6
+ * particular the init-time-only static-mode snapshot (BOOT_ACCESS): in static
7
+ * mode the allowlist is read ONCE when the store is built and frozen for the
8
+ * life of the store, so a later edit to access.json on disk is NOT observed.
9
+ * That timing is the whole reason the store is a factory rather than a set of
10
+ * lazy free functions — this suite is the outcome guard for it.
11
+ *
12
+ * Run with: npx vitest run telegram-plugin/gateway/access-store.test.ts
13
+ */
14
+
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
16
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { createAccessStore, type AccessStoreDeps } from './access-store.js'
20
+ import type { Access } from './gateway.js'
21
+
22
+ function makeDeps(dir: string, isStatic: boolean): AccessStoreDeps {
23
+ return {
24
+ accessFile: join(dir, 'access.json'),
25
+ peopleFile: join(dir, 'people.json'),
26
+ stateDir: dir,
27
+ isStatic,
28
+ }
29
+ }
30
+
31
+ describe('access-store', () => {
32
+ let dir: string
33
+ let stderrSpy: ReturnType<typeof vi.spyOn>
34
+
35
+ beforeEach(() => {
36
+ dir = mkdtempSync(join(tmpdir(), 'access-store-'))
37
+ stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
38
+ })
39
+
40
+ afterEach(() => {
41
+ stderrSpy.mockRestore()
42
+ rmSync(dir, { recursive: true, force: true })
43
+ })
44
+
45
+ // ─── defaultAccess ─────────────────────────────────────────────────────────
46
+
47
+ it('defaultAccess returns a pairing-mode empty allowlist', () => {
48
+ const store = createAccessStore(makeDeps(dir, false))
49
+ expect(store.defaultAccess()).toEqual({
50
+ dmPolicy: 'pairing',
51
+ allowFrom: [],
52
+ groups: {},
53
+ pending: {},
54
+ })
55
+ })
56
+
57
+ // ─── readAccessFile ────────────────────────────────────────────────────────
58
+
59
+ it('readAccessFile returns defaultAccess when the file is missing (ENOENT)', () => {
60
+ const store = createAccessStore(makeDeps(dir, false))
61
+ expect(store.readAccessFile()).toEqual(store.defaultAccess())
62
+ })
63
+
64
+ it('readAccessFile parses a valid access.json and projects known fields', () => {
65
+ writeFileSync(
66
+ join(dir, 'access.json'),
67
+ JSON.stringify({
68
+ dmPolicy: 'allowlist',
69
+ allowFrom: ['111'],
70
+ groups: { '-100': { requireMention: true, allowFrom: ['222'] } },
71
+ pending: {},
72
+ parseMode: 'text',
73
+ historyEnabled: false,
74
+ }),
75
+ )
76
+ const store = createAccessStore(makeDeps(dir, false))
77
+ const a = store.readAccessFile()
78
+ expect(a.dmPolicy).toBe('allowlist')
79
+ expect(a.allowFrom).toEqual(['111'])
80
+ expect(a.groups['-100']).toEqual({ requireMention: true, allowFrom: ['222'] })
81
+ expect(a.parseMode).toBe('text')
82
+ expect(a.historyEnabled).toBe(false)
83
+ })
84
+
85
+ it('readAccessFile fails closed on number-array fields (the hand-edit bug)', () => {
86
+ // Unquoted IDs parse as numbers; validateStringArray must drop them.
87
+ writeFileSync(
88
+ join(dir, 'access.json'),
89
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [12345], groups: {}, pending: {} }),
90
+ )
91
+ const store = createAccessStore(makeDeps(dir, false))
92
+ expect(store.readAccessFile().allowFrom).toEqual([])
93
+ expect(stderrSpy).toHaveBeenCalled()
94
+ })
95
+
96
+ it('readAccessFile moves a corrupt file aside and returns defaultAccess', () => {
97
+ writeFileSync(join(dir, 'access.json'), '{ not valid json')
98
+ const store = createAccessStore(makeDeps(dir, false))
99
+ expect(store.readAccessFile()).toEqual(store.defaultAccess())
100
+ // Original replaced by a .corrupt-* sibling.
101
+ expect(existsSync(join(dir, 'access.json'))).toBe(false)
102
+ const corrupt = readdirSync(dir).filter((f) => f.startsWith('access.json.corrupt-'))
103
+ expect(corrupt.length).toBe(1)
104
+ })
105
+
106
+ // ─── loadAccess — non-static re-reads live ─────────────────────────────────
107
+
108
+ it('loadAccess re-reads the file on every call in non-static mode', () => {
109
+ writeFileSync(
110
+ join(dir, 'access.json'),
111
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
112
+ )
113
+ const store = createAccessStore(makeDeps(dir, false))
114
+ expect(store.loadAccess().allowFrom).toEqual(['111'])
115
+ // Edit on disk is observed immediately (no snapshot in non-static mode).
116
+ writeFileSync(
117
+ join(dir, 'access.json'),
118
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['222'], groups: {}, pending: {} }),
119
+ )
120
+ expect(store.loadAccess().allowFrom).toEqual(['222'])
121
+ })
122
+
123
+ // ─── Static-mode BOOT_ACCESS snapshot (init-time semantics) ────────────────
124
+
125
+ it('static mode snapshots at build time: downgrades pairing→allowlist and clears pending', () => {
126
+ writeFileSync(
127
+ join(dir, 'access.json'),
128
+ JSON.stringify({
129
+ dmPolicy: 'pairing',
130
+ allowFrom: ['111'],
131
+ groups: {},
132
+ pending: { code123: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: 2, replies: 0 } },
133
+ }),
134
+ )
135
+ const store = createAccessStore(makeDeps(dir, true))
136
+ const a = store.loadAccess()
137
+ expect(a.dmPolicy).toBe('allowlist')
138
+ expect(a.pending).toEqual({})
139
+ expect(a.allowFrom).toEqual(['111'])
140
+ })
141
+
142
+ it('static mode freezes the allowlist: an on-disk edit after build is NOT observed', () => {
143
+ writeFileSync(
144
+ join(dir, 'access.json'),
145
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
146
+ )
147
+ const store = createAccessStore(makeDeps(dir, true))
148
+ expect(store.loadAccess().allowFrom).toEqual(['111'])
149
+ // Change the file after the store was built.
150
+ writeFileSync(
151
+ join(dir, 'access.json'),
152
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['999'], groups: {}, pending: {} }),
153
+ )
154
+ // Still the boot snapshot — this is the init-time-only guarantee.
155
+ expect(store.loadAccess().allowFrom).toEqual(['111'])
156
+ })
157
+
158
+ // ─── saveAccess ────────────────────────────────────────────────────────────
159
+
160
+ it('saveAccess writes access.json atomically in non-static mode', () => {
161
+ const store = createAccessStore(makeDeps(dir, false))
162
+ const a: Access = { dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} }
163
+ store.saveAccess(a)
164
+ const written = JSON.parse(readFileSync(join(dir, 'access.json'), 'utf8'))
165
+ expect(written.allowFrom).toEqual(['abc'])
166
+ // No leftover temp file.
167
+ expect(existsSync(join(dir, 'access.json.tmp'))).toBe(false)
168
+ })
169
+
170
+ it('saveAccess is a no-op in static mode', () => {
171
+ const store = createAccessStore(makeDeps(dir, true))
172
+ store.saveAccess({ dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} })
173
+ expect(existsSync(join(dir, 'access.json'))).toBe(false)
174
+ })
175
+
176
+ // ─── pruneExpired ──────────────────────────────────────────────────────────
177
+
178
+ it('pruneExpired removes expired pending entries and reports whether it changed', () => {
179
+ const now = Date.now()
180
+ const store = createAccessStore(makeDeps(dir, false))
181
+ const a: Access = {
182
+ dmPolicy: 'pairing',
183
+ allowFrom: [],
184
+ groups: {},
185
+ pending: {
186
+ old: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now - 1000, replies: 0 },
187
+ fresh: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now + 100000, replies: 0 },
188
+ },
189
+ }
190
+ expect(store.pruneExpired(a)).toBe(true)
191
+ expect(Object.keys(a.pending)).toEqual(['fresh'])
192
+ // Second pass: nothing expired now → no change.
193
+ expect(store.pruneExpired(a)).toBe(false)
194
+ })
195
+
196
+ // ─── assertAllowedChat ─────────────────────────────────────────────────────
197
+
198
+ it('assertAllowedChat permits an allowFrom chat, a group chat, and rejects others', () => {
199
+ writeFileSync(
200
+ join(dir, 'access.json'),
201
+ JSON.stringify({
202
+ dmPolicy: 'allowlist',
203
+ allowFrom: ['111'],
204
+ groups: { '-100': { requireMention: false, allowFrom: [] } },
205
+ pending: {},
206
+ }),
207
+ )
208
+ const store = createAccessStore(makeDeps(dir, false))
209
+ expect(() => store.assertAllowedChat('111')).not.toThrow()
210
+ expect(() => store.assertAllowedChat(111)).not.toThrow() // number coerced
211
+ expect(() => store.assertAllowedChat('-100')).not.toThrow()
212
+ expect(() => store.assertAllowedChat('404')).toThrow(/not allowlisted/)
213
+ })
214
+
215
+ // ─── readPeopleFile ────────────────────────────────────────────────────────
216
+
217
+ it('readPeopleFile returns entries for a valid people.json', () => {
218
+ writeFileSync(
219
+ join(dir, 'people.json'),
220
+ JSON.stringify({ entries: [{ telegramUserId: '5', personId: 'p1' }] }),
221
+ )
222
+ const store = createAccessStore(makeDeps(dir, false))
223
+ expect(store.readPeopleFile()).toEqual([{ telegramUserId: '5', personId: 'p1' }])
224
+ })
225
+
226
+ it('readPeopleFile fails open to [] on missing or corrupt file', () => {
227
+ const store = createAccessStore(makeDeps(dir, false))
228
+ expect(store.readPeopleFile()).toEqual([]) // ENOENT
229
+ writeFileSync(join(dir, 'people.json'), 'not json')
230
+ expect(store.readPeopleFile()).toEqual([]) // corrupt
231
+ writeFileSync(join(dir, 'people.json'), JSON.stringify({ entries: 'nope' }))
232
+ expect(store.readPeopleFile()).toEqual([]) // entries not an array
233
+ })
234
+ })
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Access / allowlist file layer for the Telegram gateway.
3
+ *
4
+ * This module owns the read/write side of `access.json` (the per-agent DM
5
+ * allowlist + group policy + pairing state) and the read-only `people.json`
6
+ * projection. It was extracted verbatim out of `gateway.ts` (switchroom#4248)
7
+ * to relieve the gateway line-ratchet; the behavior is byte-identical to the
8
+ * inline version.
9
+ *
10
+ * Init-time semantics preserved: in static-access mode the gateway snapshots
11
+ * `access.json` ONCE at module-init time (the `BOOT_ACCESS` constant below),
12
+ * downgrades a `pairing` dmPolicy to `allowlist`, and clears pending pairings —
13
+ * so a static agent's allowlist is frozen for the life of the process. That
14
+ * snapshot must happen at the same point in startup as before, so the store is
15
+ * built by a factory (`createAccessStore`) that the gateway calls at the
16
+ * original `BOOT_ACCESS` site; the snapshot runs eagerly inside the factory,
17
+ * NOT lazily on first `loadAccess()`.
18
+ *
19
+ * The gateway-internal dependencies (`ACCESS_FILE`, `PEOPLE_FILE`, `STATE_DIR`
20
+ * path constants and the `STATIC` mode flag) are injected as explicit params so
21
+ * this module holds no runtime import of gateway.ts. The only back-reference is
22
+ * the `Access` / `GroupPolicy` types, imported type-only (erased under
23
+ * `isolatedModules`, so no runtime cycle) — the same seam
24
+ * `turn-start-surfaces.ts` already uses.
25
+ */
26
+
27
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
28
+ import { validateStringArray } from './access-validator.js'
29
+ import type { RawPersonEntry } from './resolve-person.js'
30
+ import type { Access } from './gateway.js'
31
+
32
+ /** The per-group policy shape, derived from the exported `Access` type so the
33
+ * store needs no extra symbol export from gateway.ts. */
34
+ type GroupPolicy = Access['groups'][string]
35
+
36
+ /** Gateway-internal deps the store needs, injected at build time. */
37
+ export interface AccessStoreDeps {
38
+ /** Absolute path to `access.json` (gateway's `ACCESS_FILE`). */
39
+ accessFile: string
40
+ /** Absolute path to `people.json` (gateway's `PEOPLE_FILE`). */
41
+ peopleFile: string
42
+ /** State dir created (mode 0o700) before an atomic access.json write. */
43
+ stateDir: string
44
+ /** Static-access mode (`TELEGRAM_ACCESS_MODE === 'static'`). Freezes the
45
+ * allowlist at init and turns `saveAccess` into a no-op. */
46
+ isStatic: boolean
47
+ }
48
+
49
+ /** The access/allowlist file layer, bound to one set of paths + mode. */
50
+ export interface AccessStore {
51
+ defaultAccess(): Access
52
+ readAccessFile(): Access
53
+ loadAccess(): Access
54
+ readPeopleFile(): RawPersonEntry[]
55
+ assertAllowedChat(chat_id: string | number): void
56
+ saveAccess(a: Access): void
57
+ pruneExpired(a: Access): boolean
58
+ }
59
+
60
+ /**
61
+ * Build the access store. The `BOOT_ACCESS` snapshot happens eagerly here, so
62
+ * call this at the same point in gateway startup where `BOOT_ACCESS` used to be
63
+ * initialized — that keeps the static-mode freeze timing identical.
64
+ */
65
+ export function createAccessStore(deps: AccessStoreDeps): AccessStore {
66
+ const { accessFile, peopleFile, stateDir, isStatic } = deps
67
+
68
+ function defaultAccess(): Access {
69
+ return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
70
+ }
71
+
72
+ function readAccessFile(): Access {
73
+ try {
74
+ const raw = readFileSync(accessFile, 'utf8')
75
+ const parsed = JSON.parse(raw) as Partial<Access>
76
+ const allowFrom = validateStringArray('allowFrom', parsed.allowFrom ?? [])
77
+ const groups: Record<string, GroupPolicy> = {}
78
+ for (const [chatId, policy] of Object.entries(parsed.groups ?? {})) {
79
+ groups[chatId] = {
80
+ ...policy,
81
+ allowFrom: validateStringArray(`groups.${chatId}.allowFrom`, policy.allowFrom ?? []),
82
+ }
83
+ }
84
+ return {
85
+ dmPolicy: parsed.dmPolicy ?? 'pairing',
86
+ allowFrom,
87
+ groups,
88
+ pending: parsed.pending ?? {},
89
+ mentionPatterns: parsed.mentionPatterns,
90
+ ackReaction: parsed.ackReaction,
91
+ replyToMode: parsed.replyToMode,
92
+ textChunkLimit: parsed.textChunkLimit,
93
+ chunkMode: parsed.chunkMode,
94
+ parseMode: parsed.parseMode,
95
+ disableLinkPreview: parsed.disableLinkPreview,
96
+ coalescingGapMs: parsed.coalescingGapMs,
97
+ litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
98
+ coalesceMaxAttachments: parsed.coalesceMaxAttachments,
99
+ interruptSafeBoundary: parsed.interruptSafeBoundary,
100
+ interruptMaxWaitMs: parsed.interruptMaxWaitMs,
101
+ statusReactions: parsed.statusReactions,
102
+ historyEnabled: parsed.historyEnabled,
103
+ historyRetentionDays: parsed.historyRetentionDays,
104
+ // #596: telegram features projected into access.json by scaffold.
105
+ // Without these passthroughs, gateway readers (`access.voice_in`,
106
+ // `access.telegraph`, `access.stickers`) silently see undefined.
107
+ stickers: parsed.stickers,
108
+ voice_in: parsed.voice_in,
109
+ voice_out: parsed.voice_out,
110
+ telegraph: parsed.telegraph,
111
+ // #789: button-choice-confirmation config projected by scaffold.
112
+ button_choice_confirmation: parsed.button_choice_confirmation,
113
+ }
114
+ } catch (err) {
115
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
116
+ try { renameSync(accessFile, `${accessFile}.corrupt-${Date.now()}`) } catch {}
117
+ process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.\n`)
118
+ return defaultAccess()
119
+ }
120
+ }
121
+
122
+ const BOOT_ACCESS: Access | null = isStatic
123
+ ? (() => {
124
+ const a = readAccessFile()
125
+ if (a.dmPolicy === 'pairing') {
126
+ process.stderr.write('telegram gateway: static mode — dmPolicy "pairing" downgraded to "allowlist"\n')
127
+ a.dmPolicy = 'allowlist'
128
+ }
129
+ a.pending = {}
130
+ return a
131
+ })()
132
+ : null
133
+
134
+ function loadAccess(): Access {
135
+ return BOOT_ACCESS ?? readAccessFile()
136
+ }
137
+
138
+ /**
139
+ * Read `people.json` (the scaffold's plain projection of `users:` entries
140
+ * that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
141
+ * returns an empty array rather than throwing — this feature must never
142
+ * block startup. Unlike `access.json` this file is never gateway-mutated,
143
+ * so there's no "move corrupt file aside" concern; the scaffold owns and
144
+ * regenerates it on every reconcile.
145
+ */
146
+ function readPeopleFile(): RawPersonEntry[] {
147
+ try {
148
+ const raw = readFileSync(peopleFile, 'utf8')
149
+ const parsed = JSON.parse(raw) as { entries?: unknown }
150
+ if (!Array.isArray(parsed.entries)) return []
151
+ return parsed.entries as RawPersonEntry[]
152
+ } catch {
153
+ return []
154
+ }
155
+ }
156
+
157
+ function assertAllowedChat(chat_id: string | number): void {
158
+ const id = String(chat_id)
159
+ const access = loadAccess()
160
+ if (access.allowFrom.includes(id)) return
161
+ if (id in access.groups) return
162
+ throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
163
+ }
164
+
165
+ function saveAccess(a: Access): void {
166
+ if (isStatic) return
167
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 })
168
+ const tmp = accessFile + '.tmp'
169
+ writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
170
+ renameSync(tmp, accessFile)
171
+ }
172
+
173
+ function pruneExpired(a: Access): boolean {
174
+ const now = Date.now()
175
+ let changed = false
176
+ for (const [code, p] of Object.entries(a.pending)) {
177
+ if (p.expiresAt < now) {
178
+ delete a.pending[code]
179
+ changed = true
180
+ }
181
+ }
182
+ return changed
183
+ }
184
+
185
+ return {
186
+ defaultAccess,
187
+ readAccessFile,
188
+ loadAccess,
189
+ readPeopleFile,
190
+ assertAllowedChat,
191
+ saveAccess,
192
+ pruneExpired,
193
+ }
194
+ }
@@ -244,15 +244,37 @@ export function collectBriefingSurfaces(
244
244
  }
245
245
  }
246
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. */
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`. */
250
272
  function truncateOneLine(s: string, max: number): string {
251
273
  const t = s.replace(/\s+/g, ' ').trim()
252
274
  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() + '…'
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() + '…'
256
278
  }
257
279
 
258
280
  function surfaceLabel(s: { chatId: string; threadId: number | null }): string {
@@ -269,6 +291,35 @@ function renderMessageLine(
269
291
  return `- [${age} ago] ${label}: ${truncateOneLine(m.text, perMessageMax)}`
270
292
  }
271
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
+
272
323
  export interface RenderBriefingOptions {
273
324
  nowMs: number
274
325
  /** Restart-reason breadcrumb (`.restart-reason` / SWITCHROOM_PENDING_*),
@@ -276,6 +327,64 @@ export interface RenderBriefingOptions {
276
327
  restartReason?: string | null
277
328
  charBudget?: number
278
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() + '…'
279
388
  }
280
389
 
281
390
  /**
@@ -351,7 +460,26 @@ export function renderBootBriefing(
351
460
  if (assemble(kept, [...secondaries, block]).length > charBudget) break
352
461
  secondaries.push(block)
353
462
  }
354
- return assemble(kept, secondaries)
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
355
483
  }
356
484
 
357
485
  /**
@@ -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'