switchroom 0.19.26 → 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,319 @@
1
+ /**
2
+ * The fuse's meter must count what Telegram counts (#3855).
3
+ *
4
+ * ── The bug these tests guard ────────────────────────────────────────────
5
+ * `apply` opened with an ALLOWLIST:
6
+ *
7
+ * const isEdit = EDIT_METHODS.has(method)
8
+ * const isSend = SEND_METHODS.has(method)
9
+ * if (!isEdit && !isSend) return runObserved(next) // ← everything
10
+ * // else was FREE
11
+ *
12
+ * So `perChatTotalMaxPerWindow` — documented as counting "every admitted call"
13
+ * and "matching Telegram's own per-chat metering" — counted neither. Measured
14
+ * on overlord's gateway log (46791 `tg-post` lines), the unmetered share was
15
+ * 29% overall and 42% in the 30 minutes before the 15908s ban:
16
+ *
17
+ * sendChatAction 10457 (up to 18/min on ONE DM — the whole documented
18
+ * per-chat/minute allowance, counted as zero)
19
+ * setMessageReaction 2098
20
+ * deleteMessage 370
21
+ * pinChatMessage 366
22
+ * unpinChatMessage 365
23
+ * answerCallbackQuery 31 (no chat_id — unkeyable per-chat)
24
+ *
25
+ * Plus: the ban is scoped to the BOT TOKEN, and the fuse had no token-scoped
26
+ * window at all, so nothing bounded the aggregate rate across chats.
27
+ *
28
+ * Every test below asserts an OUTCOME — how many calls reached `next` inside a
29
+ * window — not that a branch executed.
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest'
33
+ import {
34
+ createEditFloodFuse,
35
+ deriveBotScopeKey,
36
+ EDIT_FLOOD_FUSE_DEFAULTS,
37
+ } from '../edit-flood-fuse.js'
38
+ import type { Clock } from '../send-gate.js'
39
+
40
+ const CHAT = '1001'
41
+
42
+ class FakeClock implements Clock {
43
+ private cur = 0
44
+ private seq = 0
45
+ private timers: { at: number; id: number; resolve: () => void }[] = []
46
+ now(): number { return this.cur }
47
+ sleep(ms: number): Promise<void> {
48
+ return new Promise<void>((resolve) => {
49
+ this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
50
+ })
51
+ }
52
+ async advance(ms: number): Promise<void> {
53
+ const target = this.cur + ms
54
+ for (;;) {
55
+ await flush()
56
+ const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
57
+ if (due.length === 0) break
58
+ const t = due[0]!
59
+ this.timers = this.timers.filter((x) => x !== t)
60
+ this.cur = t.at
61
+ t.resolve()
62
+ await flush()
63
+ }
64
+ this.cur = target
65
+ await flush()
66
+ }
67
+ }
68
+ const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
69
+
70
+ describe('default-deny metering — the 29% the allowlist could not see', () => {
71
+ /**
72
+ * The headline outcome. `sendChatAction` was the single largest unmetered
73
+ * method in the incident. Before the fix this loop put 60 calls on the wire
74
+ * inside one 60s window while the fuse reported a 20/60s ceiling as intact.
75
+ */
76
+ it('meters sendChatAction — 60 offered, at most the ceiling reaches the wire', async () => {
77
+ const clock = new FakeClock()
78
+ let landed = 0
79
+ const fuse = createEditFloodFuse({
80
+ clock, perChatTotalMaxPerWindow: 20, perChatReplyReserve: 8, perChatWindowMs: 60_000,
81
+ })
82
+
83
+ const calls = Array.from({ length: 60 }, () =>
84
+ fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => {
85
+ landed++
86
+ return true
87
+ }))
88
+ await clock.advance(59_000)
89
+ await Promise.all(calls)
90
+
91
+ // 20 total minus the 8-slot reply reserve = 12 available to cosmetic
92
+ // traffic, which a typing indicator is.
93
+ expect(landed).toBeLessThanOrEqual(12)
94
+ expect(landed).toBeGreaterThan(0)
95
+ // And the meter SAW them — this counter is zero on the pre-fix code.
96
+ expect(fuse.stats().meteredByDefault).toBe(60)
97
+ })
98
+
99
+ it.each([
100
+ ['setMessageReaction', { chat_id: CHAT, message_id: 5, reaction: [] }],
101
+ ['deleteMessage', { chat_id: CHAT, message_id: 5 }],
102
+ ['pinChatMessage', { chat_id: CHAT, message_id: 5 }],
103
+ ['unpinChatMessage', { chat_id: CHAT, message_id: 5 }],
104
+ // Not in EDIT_METHODS or SEND_METHODS and never was — the exact class of
105
+ // method default-deny exists to catch. It carries chat_id and creates a
106
+ // message (gateway.ts `rawSendChecklist`), so it consumes chat budget.
107
+ ['sendChecklist', { chat_id: CHAT, title: 't', tasks: [] }],
108
+ // A Bot API method this file has never heard of.
109
+ ['sendSomethingInventedNextYear', { chat_id: CHAT }],
110
+ ])('meters %s against the shared per-chat window', async (method, payload) => {
111
+ const clock = new FakeClock()
112
+ let landed = 0
113
+ const fuse = createEditFloodFuse({
114
+ clock, perChatTotalMaxPerWindow: 5, perChatReplyReserve: 0, perChatWindowMs: 60_000,
115
+ maxDeferMs: 1_000,
116
+ })
117
+ const calls = Array.from({ length: 30 }, () =>
118
+ fuse.apply(method, payload, async () => { landed++; return true }))
119
+ await clock.advance(2_000) // past the 1s defer deadline
120
+ await Promise.all(calls)
121
+
122
+ // These have durable, user-visible effects, so they are PACED not dropped —
123
+ // over-budget calls release at the defer deadline. The outcome that matters
124
+ // is that they took slots at all: the shared window is now full.
125
+ expect(fuse.stats().meteredByDefault).toBe(30)
126
+ expect(landed).toBe(30)
127
+ expect(fuse.stats().dropped).toBe(0)
128
+
129
+ // The proof the slots were really charged: a reply offered now finds the
130
+ // window saturated and is deferred rather than sailing straight through.
131
+ // On the pre-fix code none of the 30 took a slot, so this never deferred.
132
+ const before = fuse.stats().deferred
133
+ const reply = fuse.apply('sendMessage', { chat_id: CHAT, text: 'hi' }, async () => true)
134
+ await flush()
135
+ expect(fuse.stats().deferred).toBeGreaterThan(before)
136
+ await clock.advance(120_000)
137
+ await reply
138
+ })
139
+
140
+ it('never DROPS a durable-effect ancillary call — a deletion is paced, not lost', async () => {
141
+ const clock = new FakeClock()
142
+ let landed = 0
143
+ const fuse = createEditFloodFuse({
144
+ clock, perChatTotalMaxPerWindow: 2, perChatReplyReserve: 0, perChatWindowMs: 60_000,
145
+ maxDeferMs: 5_000,
146
+ })
147
+ const calls = Array.from({ length: 10 }, (_, i) =>
148
+ fuse.apply('deleteMessage', { chat_id: CHAT, message_id: i }, async () => {
149
+ landed++
150
+ return true
151
+ }))
152
+ await clock.advance(120_000)
153
+ await Promise.all(calls)
154
+ expect(landed).toBe(10)
155
+ expect(fuse.stats().dropped).toBe(0)
156
+ })
157
+
158
+ it('DOES drop a typing indicator rather than deliver it stale', async () => {
159
+ const clock = new FakeClock()
160
+ let landed = 0
161
+ const fuse = createEditFloodFuse({
162
+ clock, perChatTotalMaxPerWindow: 2, perChatReplyReserve: 0, perChatWindowMs: 60_000,
163
+ maxDeferMs: 30_000, chatActionMaxDeferMs: 3_000,
164
+ })
165
+ const calls = Array.from({ length: 10 }, () =>
166
+ fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => {
167
+ landed++
168
+ return true
169
+ }))
170
+ await clock.advance(30_000)
171
+ await Promise.all(calls)
172
+ expect(landed).toBe(2)
173
+ expect(fuse.stats().dropped).toBe(8)
174
+ })
175
+
176
+ it('the reply reserve survives a saturating ancillary stream', async () => {
177
+ // The starvation guarantee has to hold against the traffic that was
178
+ // previously invisible, or it is not a guarantee.
179
+ const clock = new FakeClock()
180
+ let repliesLanded = 0
181
+ const fuse = createEditFloodFuse({
182
+ clock, perChatTotalMaxPerWindow: 20, perChatReplyReserve: 8, perChatWindowMs: 60_000,
183
+ maxDeferMs: 30_000,
184
+ })
185
+ const noise = Array.from({ length: 200 }, () =>
186
+ fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => true))
187
+ await flush()
188
+ const replies = Array.from({ length: 8 }, (_, i) =>
189
+ fuse.apply('sendMessage', { chat_id: CHAT, text: `answer ${i}` }, async () => {
190
+ repliesLanded++
191
+ return true
192
+ }))
193
+ await clock.advance(120_000)
194
+ await Promise.all([...noise, ...replies])
195
+ expect(repliesLanded).toBe(8)
196
+ })
197
+ })
198
+
199
+ describe('the per-BOT-TOKEN window — the scope the ban actually uses', () => {
200
+ it('bounds the aggregate rate across MANY chats, which per-chat windows cannot', async () => {
201
+ // 40 distinct chats, one call each, all in the same instant. Every per-chat
202
+ // window is comfortably in budget; only a token-scoped window sees the sum.
203
+ const clock = new FakeClock()
204
+ let landed = 0
205
+ const fuse = createEditFloodFuse({
206
+ clock, botScopeKey: '123456789',
207
+ perTokenMaxPerWindow: 25, perTokenWindowMs: 1_000, maxDeferMs: 30_000,
208
+ })
209
+ const calls = Array.from({ length: 40 }, (_, i) =>
210
+ fuse.apply('sendMessage', { chat_id: `chat-${i}`, text: 'x' }, async () => {
211
+ landed++
212
+ return true
213
+ }))
214
+ await flush()
215
+ expect(landed).toBe(25) // the token ceiling, not 40
216
+
217
+ await clock.advance(2_000)
218
+ await Promise.all(calls)
219
+ // Nothing is LOST — the remaining 15 land once the window slides.
220
+ expect(landed).toBe(40)
221
+ })
222
+
223
+ it('charges a CHAT-LESS call (answerCallbackQuery) to the token window', async () => {
224
+ // answerCallbackQuery carries `callback_query_id`, never `chat_id`, so the
225
+ // per-chat tier structurally cannot key it. Before #3855 that meant it was
226
+ // free everywhere. It is now charged where it genuinely counts.
227
+ const clock = new FakeClock()
228
+ let landed = 0
229
+ const fuse = createEditFloodFuse({
230
+ clock, perTokenMaxPerWindow: 3, perTokenWindowMs: 1_000, maxDeferMs: 30_000,
231
+ })
232
+ const calls = Array.from({ length: 10 }, (_, i) =>
233
+ fuse.apply('answerCallbackQuery', { callback_query_id: `q${i}` }, async () => {
234
+ landed++
235
+ return true
236
+ }))
237
+ await flush()
238
+ expect(landed).toBe(3)
239
+ expect(fuse.stats().chatless).toBe(10)
240
+
241
+ await clock.advance(5_000)
242
+ await Promise.all(calls)
243
+ expect(landed).toBe(10) // paced, never dropped
244
+ })
245
+
246
+ it('BOTH windows must pass — a single hot chat is still bounded per-chat', async () => {
247
+ const clock = new FakeClock()
248
+ let landed = 0
249
+ const fuse = createEditFloodFuse({
250
+ clock,
251
+ perChatTotalMaxPerWindow: 4, perChatReplyReserve: 0, perChatWindowMs: 60_000,
252
+ // Token window wide open, so only the per-chat tier can bind.
253
+ perTokenMaxPerWindow: 1_000, perTokenWindowMs: 1_000,
254
+ maxDeferMs: 500,
255
+ })
256
+ const calls = Array.from({ length: 20 }, () =>
257
+ fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => {
258
+ landed++
259
+ return true
260
+ }))
261
+ await clock.advance(2_000) // past the 500ms defer deadline
262
+ await Promise.all(calls)
263
+ expect(landed).toBe(4)
264
+ })
265
+
266
+ it('separate bot ids get separate windows; the same id shares one', async () => {
267
+ const clock = new FakeClock()
268
+ const mk = (botScopeKey: string) =>
269
+ createEditFloodFuse({
270
+ clock, botScopeKey, perTokenMaxPerWindow: 2, perTokenWindowMs: 60_000,
271
+ perChatTotalMaxPerWindow: 1_000, maxDeferMs: 100,
272
+ })
273
+ // Two fuses with the SAME id are still two processes' worth of state — the
274
+ // documented limitation. What this asserts is the KEY derivation, which is
275
+ // what a future shared store would key on.
276
+ expect(deriveBotScopeKey('123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw')).toBe('123456789')
277
+ expect(mk('123456789')).toBeDefined()
278
+ })
279
+ })
280
+
281
+ describe('deriveBotScopeKey — the token itself must never become a key', () => {
282
+ const REAL_SHAPE = '8123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw'
283
+
284
+ it('returns ONLY the public bot id, never any part of the secret', () => {
285
+ const key = deriveBotScopeKey(REAL_SHAPE)
286
+ expect(key).toBe('8123456789')
287
+ const secret = REAL_SHAPE.slice(REAL_SHAPE.indexOf(':') + 1)
288
+ expect(key).not.toContain(secret)
289
+ // Not even a prefix of the secret leaks — check every prefix ≥ 4 chars.
290
+ for (let n = 4; n <= secret.length; n++) {
291
+ expect(key).not.toContain(secret.slice(0, n))
292
+ }
293
+ })
294
+
295
+ it('falls back to an irreversible hash for a token of unexpected shape', () => {
296
+ const odd = 'not-a-normal-token'
297
+ const key = deriveBotScopeKey(odd)
298
+ expect(key).toMatch(/^h[0-9a-f]{16}$/)
299
+ expect(key).not.toContain(odd)
300
+ // Stable — the same token must key the same window across constructions.
301
+ expect(deriveBotScopeKey(odd)).toBe(key)
302
+ })
303
+
304
+ it('handles an absent token without throwing', () => {
305
+ expect(deriveBotScopeKey(undefined)).toBe('unknown')
306
+ expect(deriveBotScopeKey('')).toBe('unknown')
307
+ })
308
+ })
309
+
310
+ describe('the defaults still describe the real budget', () => {
311
+ it('the token ceiling sits below Telegram\'s ~30/s token-level limit', () => {
312
+ expect(EDIT_FLOOD_FUSE_DEFAULTS.perTokenMaxPerWindow).toBeLessThan(30)
313
+ expect(EDIT_FLOOD_FUSE_DEFAULTS.perTokenWindowMs).toBe(1_000)
314
+ })
315
+
316
+ it('a chat action may not be held past its own ~5s lifetime', () => {
317
+ expect(EDIT_FLOOD_FUSE_DEFAULTS.chatActionMaxDeferMs).toBeLessThan(5_000)
318
+ })
319
+ })
@@ -297,9 +297,18 @@ describe('edit-flood fuse — structural: installed where nothing can bypass it'
297
297
  expect(fuse.stats().dropped + fuse.stats().superseded).toBeGreaterThan(0)
298
298
  })
299
299
 
300
- it('passes non-message methods straight through (getUpdates must never be paced)', async () => {
300
+ // #3855: this test used to be titled "passes non-message methods straight
301
+ // through" and stood for the ALLOWLIST — everything outside EDIT/SEND was
302
+ // free, which left 29% of real traffic unmetered. The exemption is now a
303
+ // one-entry list and this test guards exactly that entry, not a category.
304
+ it('getUpdates — the long-poll RECEIVE loop — is the one unmetered method', async () => {
301
305
  const clock = new FakeClock()
302
- const fuse = createEditFloodFuse({ clock, perChatSendMaxPerWindow: 1, perChatWindowMs: 600_000 })
306
+ const fuse = createEditFloodFuse({
307
+ clock, perChatSendMaxPerWindow: 1, perChatWindowMs: 600_000,
308
+ // Deliberately brutal: if getUpdates were metered at all, the 2nd call
309
+ // would block forever on this clock.
310
+ perTokenMaxPerWindow: 1, perTokenWindowMs: 600_000,
311
+ })
303
312
  let calls = 0
304
313
  for (let i = 0; i < 100; i++) {
305
314
  await fuse.apply('getUpdates', {}, async () => { calls++; return [] })