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,273 @@
1
+ /**
2
+ * Reply-to buffer fallback + role tag + native partial-quote preference.
3
+ *
4
+ * WHAT THESE PIN (post-reset conversation continuity)
5
+ * ---------------------------------------------------
6
+ * The real incident: after a session reset (`resume_mode: handoff` → fresh
7
+ * session, transcript gone) a user native-REPLIED to one of the BOT's OWN
8
+ * messages ("is this added as a calendar invite yet?"). Telegram delivers
9
+ * `reply_to_message.message_id` on such a reply but NOT the message's `.text`
10
+ * (it omits the text when the reply target is the bot's own message), so the
11
+ * live update carried no antecedent — and the agent had to guess. The bot had
12
+ * already persisted that outbound to `history.db` via `recordOutbound`
13
+ * (role='assistant'); the fix reads it back.
14
+ *
15
+ * These are OUTCOME tests against the real pure builders, not the code paths:
16
+ * - `resolveReplyToFromBuffer` recovers the text AND the role from a buffer
17
+ * hit, truncates to the cap, and — critically — sets BOTH the raw
18
+ * `replyToText` (which the gateway's recordInbound write persists, so the
19
+ * DB row is non-NULL for future briefings) and the escaped form (for the
20
+ * envelope). A test that only checked the envelope would pass on the
21
+ * rejected envelope-only design and miss the NULL-row regression.
22
+ * - The history-disabled / lookup-throws guard degrades silently (no throw).
23
+ * - A non-empty LIVE reply text is never overwritten.
24
+ * - `buildReplyForwardContext` prefers `message.quote.text` (a native
25
+ * partial quote) over the full parent `.text`.
26
+ * - `buildInboundEnvelope` emits `reply_to_role` when known and `reply_to_text`
27
+ * from the recovered escaped form.
28
+ *
29
+ * The persisted-row-non-NULL half of the 1a contract (which needs a real
30
+ * bun:sqlite history.db) lives in reply-to-buffer-history.test.ts (bun).
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest'
34
+ import type { Context } from 'grammy'
35
+ import {
36
+ buildReplyForwardContext,
37
+ resolveReplyToFromBuffer,
38
+ buildInboundEnvelope,
39
+ type EnvelopeBuildParams,
40
+ } from '../gateway/inbound-router.js'
41
+
42
+ const REPLY_TO_TEXT_MAX = 200
43
+
44
+ /** A minimal grammy Context carrying only the message fields the builders read. */
45
+ function makeCtx(message: Record<string, unknown>): Context {
46
+ return { message: { date: 1_700_000_000, ...message } } as unknown as Context
47
+ }
48
+
49
+ describe('resolveReplyToFromBuffer — reply-to buffer fallback (1a)', () => {
50
+ it('recovers a bot-authored antecedent: sets raw text, escaped text, AND role', () => {
51
+ // Live update: a native reply to the bot's own message → id present, text empty.
52
+ const out = resolveReplyToFromBuffer({
53
+ replyToMessageId: 42,
54
+ replyToText: undefined,
55
+ replyToTextEscaped: undefined,
56
+ historyEnabled: true,
57
+ replyToTextMax: REPLY_TO_TEXT_MAX,
58
+ lookup: (id) =>
59
+ id === 42
60
+ ? { role: 'assistant', text: 'Added the calendar invite for Friday 3pm.' }
61
+ : null,
62
+ })
63
+ // Raw text (for the SQLite recordInbound write — NOT envelope-only).
64
+ expect(out.replyToText).toBe('Added the calendar invite for Friday 3pm.')
65
+ // Escaped form (for the channel-meta reply_to_text).
66
+ expect(out.replyToTextEscaped).toBe('Added the calendar invite for Friday 3pm.')
67
+ // Role disambiguates "you are replying to the bot's own message".
68
+ expect(out.replyToRole).toBe('assistant')
69
+ })
70
+
71
+ it("tags a person's message as role='user'", () => {
72
+ const out = resolveReplyToFromBuffer({
73
+ replyToMessageId: 7,
74
+ replyToText: undefined,
75
+ replyToTextEscaped: undefined,
76
+ historyEnabled: true,
77
+ replyToTextMax: REPLY_TO_TEXT_MAX,
78
+ lookup: () => ({ role: 'user', text: 'the thing I asked earlier' }),
79
+ })
80
+ expect(out.replyToRole).toBe('user')
81
+ expect(out.replyToText).toBe('the thing I asked earlier')
82
+ })
83
+
84
+ it('truncates the recovered text to REPLY_TO_TEXT_MAX (raw and escaped)', () => {
85
+ const long = 'x'.repeat(500)
86
+ const out = resolveReplyToFromBuffer({
87
+ replyToMessageId: 1,
88
+ replyToText: undefined,
89
+ replyToTextEscaped: undefined,
90
+ historyEnabled: true,
91
+ replyToTextMax: REPLY_TO_TEXT_MAX,
92
+ lookup: () => ({ role: 'assistant', text: long }),
93
+ })
94
+ // Raw: sliced to max-1 chars + ellipsis = exactly max glyphs.
95
+ expect([...(out.replyToText ?? '')].length).toBe(REPLY_TO_TEXT_MAX)
96
+ expect(out.replyToText?.endsWith('…')).toBe(true)
97
+ expect([...(out.replyToTextEscaped ?? '')].length).toBe(REPLY_TO_TEXT_MAX)
98
+ })
99
+
100
+ it('does NOT overwrite a non-empty LIVE reply text (reply to a person, or a partial quote)', () => {
101
+ const lookup = () => {
102
+ throw new Error('lookup must not be called when live text is present')
103
+ }
104
+ const out = resolveReplyToFromBuffer({
105
+ replyToMessageId: 9,
106
+ replyToText: 'live raw text',
107
+ replyToTextEscaped: 'live escaped text',
108
+ historyEnabled: true,
109
+ replyToTextMax: REPLY_TO_TEXT_MAX,
110
+ lookup,
111
+ })
112
+ expect(out.replyToText).toBe('live raw text')
113
+ expect(out.replyToTextEscaped).toBe('live escaped text')
114
+ expect(out.replyToRole).toBeUndefined()
115
+ })
116
+
117
+ it('history-disabled guard: never calls lookup, degrades to id-only, does not throw', () => {
118
+ let called = false
119
+ const call = () =>
120
+ resolveReplyToFromBuffer({
121
+ replyToMessageId: 5,
122
+ replyToText: undefined,
123
+ replyToTextEscaped: undefined,
124
+ historyEnabled: false,
125
+ replyToTextMax: REPLY_TO_TEXT_MAX,
126
+ lookup: () => {
127
+ called = true
128
+ throw new Error('requireDb would throw with history disabled')
129
+ },
130
+ })
131
+ expect(call).not.toThrow()
132
+ expect(called).toBe(false)
133
+ expect(call().replyToText).toBeUndefined()
134
+ expect(call().replyToRole).toBeUndefined()
135
+ })
136
+
137
+ it('a lookup that throws (requireDb mid-run) degrades silently', () => {
138
+ let out: ReturnType<typeof resolveReplyToFromBuffer> | undefined
139
+ expect(() => {
140
+ out = resolveReplyToFromBuffer({
141
+ replyToMessageId: 5,
142
+ replyToText: undefined,
143
+ replyToTextEscaped: undefined,
144
+ historyEnabled: true,
145
+ replyToTextMax: REPLY_TO_TEXT_MAX,
146
+ lookup: () => {
147
+ throw new Error('SQLITE_ERROR')
148
+ },
149
+ })
150
+ }).not.toThrow()
151
+ expect(out?.replyToText).toBeUndefined()
152
+ expect(out?.replyToRole).toBeUndefined()
153
+ })
154
+
155
+ it('a missing row (reacted-to message predates retention) yields id-only', () => {
156
+ const out = resolveReplyToFromBuffer({
157
+ replyToMessageId: 999,
158
+ replyToText: undefined,
159
+ replyToTextEscaped: undefined,
160
+ historyEnabled: true,
161
+ replyToTextMax: REPLY_TO_TEXT_MAX,
162
+ lookup: () => null,
163
+ })
164
+ expect(out.replyToText).toBeUndefined()
165
+ expect(out.replyToRole).toBeUndefined()
166
+ })
167
+
168
+ it('a row with empty text still surfaces the role (authorship known, text redacted)', () => {
169
+ const out = resolveReplyToFromBuffer({
170
+ replyToMessageId: 3,
171
+ replyToText: undefined,
172
+ replyToTextEscaped: undefined,
173
+ historyEnabled: true,
174
+ replyToTextMax: REPLY_TO_TEXT_MAX,
175
+ lookup: () => ({ role: 'assistant', text: '' }),
176
+ })
177
+ expect(out.replyToRole).toBe('assistant')
178
+ expect(out.replyToText).toBeUndefined()
179
+ })
180
+ })
181
+
182
+ describe('buildReplyForwardContext — native partial-quote preference (2a)', () => {
183
+ it('prefers message.quote.text over the full parent .text', () => {
184
+ const ctx = makeCtx({
185
+ reply_to_message: { message_id: 100, text: 'the entire long parent message body' },
186
+ quote: { text: 'the exact fragment I selected', position: 5, is_manual: true },
187
+ })
188
+ const out = buildReplyForwardContext({ ctx, coalescedForwardOrigins: undefined, replyToTextMax: REPLY_TO_TEXT_MAX })
189
+ expect(out.replyToMessageId).toBe(100)
190
+ expect(out.replyToText).toBe('the exact fragment I selected')
191
+ expect(out.replyToTextEscaped).toBe('the exact fragment I selected')
192
+ })
193
+
194
+ it('falls back to the parent .text when there is no quote', () => {
195
+ const ctx = makeCtx({
196
+ reply_to_message: { message_id: 101, text: 'parent body only' },
197
+ })
198
+ const out = buildReplyForwardContext({ ctx, coalescedForwardOrigins: undefined, replyToTextMax: REPLY_TO_TEXT_MAX })
199
+ expect(out.replyToText).toBe('parent body only')
200
+ })
201
+
202
+ it('leaves reply text empty when the bot-authored parent carries no text (the 1a trigger)', () => {
203
+ const ctx = makeCtx({ reply_to_message: { message_id: 102 } })
204
+ const out = buildReplyForwardContext({ ctx, coalescedForwardOrigins: undefined, replyToTextMax: REPLY_TO_TEXT_MAX })
205
+ expect(out.replyToMessageId).toBe(102)
206
+ expect(out.replyToText).toBeUndefined()
207
+ expect(out.replyToTextEscaped).toBeUndefined()
208
+ })
209
+ })
210
+
211
+ function makeEnvelopeParams(overrides: Partial<EnvelopeBuildParams>): EnvelopeBuildParams {
212
+ return {
213
+ ctx: makeCtx({}),
214
+ chat_id: '5550001',
215
+ messageThreadId: undefined,
216
+ msgId: 200,
217
+ effectiveText: 'is this added as a calendar invite yet?',
218
+ imagePath: undefined,
219
+ attachment: undefined,
220
+ attachmentCount: 0,
221
+ extraMeta: {},
222
+ from: { id: 111, username: 'alice' },
223
+ access: { groups: {} },
224
+ isSteering: false,
225
+ isQueuedPrefix: false,
226
+ isQueuedMidTurn: false,
227
+ priorTurnInProgress: false,
228
+ secondsSinceTurnStart: undefined,
229
+ priorAssistantPreview: undefined,
230
+ replyToMessageId: 42,
231
+ replyToTextEscaped: undefined,
232
+ replyToRole: undefined,
233
+ forwardOriginMeta: {},
234
+ topicFramingEnabled: false,
235
+ personDirectory: { byTelegramKey: {} },
236
+ isDmChatId: () => true,
237
+ ...overrides,
238
+ }
239
+ }
240
+
241
+ describe('buildInboundEnvelope — reply_to_role + reply_to_text emission', () => {
242
+ it('emits reply_to_role and reply_to_text when the buffer fallback recovered them', () => {
243
+ const msg = buildInboundEnvelope(
244
+ makeEnvelopeParams({
245
+ replyToMessageId: 42,
246
+ replyToTextEscaped: 'Added the calendar invite for Friday 3pm.',
247
+ replyToRole: 'assistant',
248
+ }),
249
+ )
250
+ expect(msg.meta?.reply_to_message_id).toBe('42')
251
+ expect(msg.meta?.reply_to_text).toBe('Added the calendar invite for Friday 3pm.')
252
+ expect(msg.meta?.reply_to_role).toBe('assistant')
253
+ })
254
+
255
+ it('omits reply_to_role when unknown (no buffer hit) but still emits the id', () => {
256
+ const msg = buildInboundEnvelope(
257
+ makeEnvelopeParams({ replyToMessageId: 42, replyToTextEscaped: undefined, replyToRole: undefined }),
258
+ )
259
+ expect(msg.meta?.reply_to_message_id).toBe('42')
260
+ expect('reply_to_role' in (msg.meta ?? {})).toBe(false)
261
+ expect('reply_to_text' in (msg.meta ?? {})).toBe(false)
262
+ })
263
+
264
+ it("emits reply_to_role='user' for a recovered person message", () => {
265
+ const msg = buildInboundEnvelope(
266
+ makeEnvelopeParams({
267
+ replyToTextEscaped: 'the thing I asked earlier',
268
+ replyToRole: 'user',
269
+ }),
270
+ )
271
+ expect(msg.meta?.reply_to_role).toBe('user')
272
+ })
273
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Reply-to buffer fallback — REAL history.db integration (1a persistence half).
3
+ *
4
+ * The pure-builder outcomes live in reply-to-buffer-fallback.test.ts (vitest).
5
+ * This file pins the half that needs a real bun:sqlite `history.db`: that the
6
+ * recovered antecedent is actually PERSISTED to the inbound row (reply_to_text
7
+ * non-NULL), not just emitted on the envelope. Envelope-only was the rejected
8
+ * design — it leaves the DB row NULL and starves the next handoff briefing,
9
+ * which reads reply_to_text back out of this store. So this drives the exact
10
+ * production chain: recordOutbound (the bot's own message) → lookup it back →
11
+ * resolveReplyToFromBuffer → recordInbound(reply_to_text) → read the row.
12
+ *
13
+ * Runs under `bun test` (bun:sqlite is a Bun built-in vitest/Node can't
14
+ * resolve); vitest-excluded in vitest.config.ts, covered by the bun `tests/`
15
+ * target in telegram-plugin/scripts/bun-test-ci.sh.
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
19
+ import { mkdtempSync, rmSync, existsSync } from 'fs'
20
+ import { tmpdir } from 'os'
21
+ import { join } from 'path'
22
+ import {
23
+ initHistory,
24
+ recordInbound,
25
+ recordOutbound,
26
+ lookupMessageRoleAndText,
27
+ query,
28
+ _resetForTests,
29
+ } from '../history.js'
30
+ import { resolveReplyToFromBuffer } from '../gateway/inbound-router.js'
31
+
32
+ const REPLY_TO_TEXT_MAX = 200
33
+ const CHAT = '5550001'
34
+
35
+ let stateDir: string
36
+
37
+ beforeEach(() => {
38
+ stateDir = mkdtempSync(join(tmpdir(), 'reply-to-buffer-'))
39
+ initHistory(stateDir, 30)
40
+ })
41
+
42
+ afterEach(() => {
43
+ _resetForTests()
44
+ if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
45
+ })
46
+
47
+ describe('reply-to buffer fallback persists the recovered antecedent (1a)', () => {
48
+ it('a native reply to the bot own message recovers text+role from history and writes a non-NULL row', () => {
49
+ // 1. The bot sent a message earlier; recordOutbound persisted it (role='assistant').
50
+ const botMsgId = 4242
51
+ recordOutbound({
52
+ chat_id: CHAT,
53
+ thread_id: null,
54
+ message_ids: [botMsgId],
55
+ texts: ['Added the calendar invite for Friday 3pm.'],
56
+ ts: 1000,
57
+ })
58
+
59
+ // 2. Session reset happened (transcript gone). The user native-replies to
60
+ // that bot message. Telegram gives us the id but NOT the text, so the
61
+ // live reply text is empty — exactly the incident condition.
62
+ const recovered = resolveReplyToFromBuffer({
63
+ replyToMessageId: botMsgId,
64
+ replyToText: undefined,
65
+ replyToTextEscaped: undefined,
66
+ historyEnabled: true,
67
+ replyToTextMax: REPLY_TO_TEXT_MAX,
68
+ lookup: (id) => lookupMessageRoleAndText(CHAT, id),
69
+ })
70
+
71
+ // The buffer recovered both the text and the authorship.
72
+ expect(recovered.replyToText).toBe('Added the calendar invite for Friday 3pm.')
73
+ expect(recovered.replyToRole).toBe('assistant')
74
+
75
+ // 3. The gateway records the inbound with the recovered reply_to_text.
76
+ const userMsgId = 4300
77
+ recordInbound({
78
+ chat_id: CHAT,
79
+ thread_id: null,
80
+ message_id: userMsgId,
81
+ user: 'alice',
82
+ user_id: '111',
83
+ ts: 2000,
84
+ text: 'is this added as a calendar invite yet?',
85
+ reply_to_message_id: botMsgId,
86
+ reply_to_text: recovered.replyToText ?? null,
87
+ })
88
+
89
+ // 4. THE outcome that would fail on the old bug and the rejected
90
+ // envelope-only design: the persisted inbound row's reply_to_text is
91
+ // non-NULL and carries the recovered antecedent.
92
+ const rows = query({ chat_id: CHAT, thread_id: null, limit: 10 })
93
+ const inboundRow = rows.find((r) => r.message_id === userMsgId) as
94
+ | { reply_to_text?: string | null; reply_to_message_id?: number | null }
95
+ | undefined
96
+ expect(inboundRow).toBeDefined()
97
+ expect(inboundRow!.reply_to_message_id).toBe(botMsgId)
98
+ expect(inboundRow!.reply_to_text).not.toBeNull()
99
+ expect(inboundRow!.reply_to_text).toBe('Added the calendar invite for Friday 3pm.')
100
+ })
101
+
102
+ it('a reply target absent from history (predates retention) writes a NULL row without throwing — no regression', () => {
103
+ const missingId = 99999
104
+ const recovered = resolveReplyToFromBuffer({
105
+ replyToMessageId: missingId,
106
+ replyToText: undefined,
107
+ replyToTextEscaped: undefined,
108
+ historyEnabled: true,
109
+ replyToTextMax: REPLY_TO_TEXT_MAX,
110
+ lookup: (id) => lookupMessageRoleAndText(CHAT, id),
111
+ })
112
+ expect(recovered.replyToText).toBeUndefined()
113
+ expect(recovered.replyToRole).toBeUndefined()
114
+
115
+ const userMsgId = 4301
116
+ recordInbound({
117
+ chat_id: CHAT,
118
+ thread_id: null,
119
+ message_id: userMsgId,
120
+ user: 'alice',
121
+ user_id: '111',
122
+ ts: 2000,
123
+ text: 'what about this one?',
124
+ reply_to_message_id: missingId,
125
+ reply_to_text: recovered.replyToText ?? null,
126
+ })
127
+ const rows = query({ chat_id: CHAT, thread_id: null, limit: 10 })
128
+ const inboundRow = rows.find((r) => r.message_id === userMsgId) as
129
+ | { reply_to_text?: string | null }
130
+ | undefined
131
+ expect(inboundRow).toBeDefined()
132
+ expect(inboundRow!.reply_to_text ?? null).toBeNull()
133
+ })
134
+ })