switchroom 0.19.17 → 0.19.19

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 (91) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +13 -4
  4. package/dist/auth-broker/index.js +32 -5
  5. package/dist/cli/drive-write-pretool.mjs +48 -5
  6. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  7. package/dist/cli/notion-write-pretool.mjs +13 -4
  8. package/dist/cli/switchroom.js +10614 -8104
  9. package/dist/host-control/main.js +12849 -11446
  10. package/dist/vault/approvals/kernel-server.js +90 -12
  11. package/dist/vault/broker/server.js +277 -94
  12. package/package.json +5 -3
  13. package/profiles/_base/start.sh.hbs +69 -5
  14. package/profiles/coding/CLAUDE.md.hbs +1 -1
  15. package/profiles/default/CLAUDE.md.hbs +3 -3
  16. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  17. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  18. package/skills/mental-model-curator/SKILL.md +8 -6
  19. package/telegram-plugin/bridge/bridge.ts +25 -19
  20. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +28 -20
  22. package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
  23. package/telegram-plugin/dist/server.js +32 -20
  24. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  25. package/telegram-plugin/gateway/boot-card.ts +5 -1
  26. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  27. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  28. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  30. package/telegram-plugin/gateway/gateway.ts +43 -1
  31. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  32. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  33. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  34. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  35. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  36. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  37. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  38. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  39. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  40. package/telegram-plugin/gateway/store-file.ts +244 -0
  41. package/telegram-plugin/gateway/stream-render.ts +24 -5
  42. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  43. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  44. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  45. package/telegram-plugin/registry/turns-schema.ts +40 -12
  46. package/telegram-plugin/runtime-metrics.ts +14 -0
  47. package/telegram-plugin/silence-poke.ts +138 -0
  48. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  49. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  51. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  52. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  53. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  54. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  56. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  57. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  58. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  59. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  60. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  61. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  62. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  63. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  64. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  66. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  67. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  68. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  69. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  70. package/telegram-plugin/tts-normalize.ts +6 -4
  71. package/telegram-plugin/voice-normalize-text.ts +168 -11
  72. package/telegram-plugin/worker-activity-feed.ts +51 -1
  73. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  74. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  75. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  76. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  77. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  78. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  79. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  81. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  82. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  84. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  85. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  87. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  89. package/vendor/hindsight-memory/settings.json +1 -1
  90. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  91. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,301 @@
1
+ /**
2
+ * switchroom#3551 — the 5-minute silence poke killed turns silently.
3
+ *
4
+ * `formatFrameworkFallbackText` returns a string on exactly one branch
5
+ * (`blockedOnApproval`); the stall-notice path was retired and every other
6
+ * branch returns null. So on a normal fire the gateway delivered NO text while
7
+ * still tearing the turn down and redelivering buffered inbound — from the
8
+ * user's seat, five minutes of nothing followed by the agent apparently
9
+ * restarting the same request. 110 fires / 14 days, p50 silence 302s.
10
+ *
11
+ * `decideFallbackTeardownNotice` makes that kill observable without
12
+ * re-introducing the banned cadence ping. These tests assert the decision
13
+ * OUTCOMES — which real situations speak and which stay quiet — plus the
14
+ * structural guarantee that the wiring actually sends on a speaking decision.
15
+ */
16
+ import { describe, it, expect, afterEach } from 'vitest'
17
+ import { readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import {
20
+ decideFallbackTeardownNotice,
21
+ formatFrameworkFallbackText,
22
+ startTurn,
23
+ __setDepsForTests,
24
+ __tickForTests,
25
+ __resetAllForTests,
26
+ type FallbackTeardownNoticeInput,
27
+ } from '../silence-poke.js'
28
+ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
29
+ import { makeLivenessFixture, makeTurn } from './helpers/liveness-wiring-fixture.js'
30
+
31
+ /** The situation the issue is about: a human asked, got nothing, turn killed. */
32
+ function killedUserTurn(over: Partial<FallbackTeardownNoticeInput> = {}): FallbackTeardownNoticeInput {
33
+ return {
34
+ tearsDownLiveTurn: true,
35
+ role: 'user',
36
+ finalAnswerDelivered: false,
37
+ userAddressedTextDelivered: false,
38
+ representWillFollow: true,
39
+ silenceMs: 302_000,
40
+ ...over,
41
+ }
42
+ }
43
+
44
+ describe('#3551 — a fire that ends a user turn is never silent', () => {
45
+ it('speaks when the framework kills an undelivered user turn', () => {
46
+ const d = decideFallbackTeardownNotice(killedUserTurn())
47
+ expect(d.send).toBe(true)
48
+ if (!d.send) throw new Error('unreachable')
49
+ // Honest about WHAT happened (the turn was ended) and WHO did it (the
50
+ // framework, not the agent) — the two facts the user had no way to know.
51
+ expect(d.text).toContain('the framework ended that stalled turn')
52
+ expect(d.text).toContain('5 min')
53
+ })
54
+
55
+ it('closes the exact gap: the branch where formatFrameworkFallbackText is null still produces a message', () => {
56
+ // This is the bug, stated as a test. On the non-approval branch the
57
+ // formatter yields nothing; before the fix, that meant the whole fire was
58
+ // user-invisible.
59
+ expect(formatFrameworkFallbackText('working', 302_000, [], false)).toBeNull()
60
+ expect(decideFallbackTeardownNotice(killedUserTurn({ userAddressedTextDelivered: false })).send).toBe(true)
61
+ })
62
+
63
+ it('derives the minute count from the real silence, not the nominal threshold', () => {
64
+ const d = decideFallbackTeardownNotice(killedUserTurn({ silenceMs: 904_000 }))
65
+ if (!d.send) throw new Error('expected a notice')
66
+ expect(d.text).toContain('for 15 min')
67
+ expect(d.text).not.toContain('for 5 min')
68
+ })
69
+
70
+ it('never promises a re-ask the obligation ledger cannot deliver', () => {
71
+ const on = decideFallbackTeardownNotice(killedUserTurn({ representWillFollow: true }))
72
+ const off = decideFallbackTeardownNotice(killedUserTurn({ representWillFollow: false }))
73
+ if (!on.send || !off.send) throw new Error('expected notices')
74
+ expect(on.text).toContain('being re-asked now')
75
+ expect(off.text).toContain('please re-send it')
76
+ expect(off.text).not.toContain('re-asked')
77
+ })
78
+ })
79
+
80
+ describe('#3551 — the gates that keep this from becoming the retired stall ping', () => {
81
+ it('stays quiet when a delivered approval re-ping already addressed the user', () => {
82
+ const d = decideFallbackTeardownNotice(killedUserTurn({ userAddressedTextDelivered: true }))
83
+ expect(d).toEqual({ send: false, reason: 'user-addressed-text-delivered' })
84
+ })
85
+
86
+ it('stays quiet on a late fire that tore down nothing', () => {
87
+ const d = decideFallbackTeardownNotice(killedUserTurn({ tearsDownLiveTurn: false }))
88
+ expect(d).toEqual({ send: false, reason: 'no-live-turn-torn-down' })
89
+ })
90
+
91
+ it('stays quiet on cron / system / sub-agent turns — nobody is waiting', () => {
92
+ expect(decideFallbackTeardownNotice(killedUserTurn({ role: 'system' })))
93
+ .toEqual({ send: false, reason: 'non-user-turn' })
94
+ expect(decideFallbackTeardownNotice(killedUserTurn({ role: 'sub-agent' })))
95
+ .toEqual({ send: false, reason: 'non-user-turn' })
96
+ expect(decideFallbackTeardownNotice(killedUserTurn({ role: null })))
97
+ .toEqual({ send: false, reason: 'non-user-turn' })
98
+ })
99
+
100
+ it('stays quiet when the user already has their answer', () => {
101
+ const d = decideFallbackTeardownNotice(killedUserTurn({ finalAnswerDelivered: true }))
102
+ expect(d).toEqual({ send: false, reason: 'answer-already-delivered' })
103
+ })
104
+ })
105
+
106
+ /**
107
+ * #3575 review — BEHAVIOURAL wiring coverage.
108
+ *
109
+ * The three describe blocks below assert regexes against `liveness-wiring.ts`'s
110
+ * own SOURCE TEXT. They pass if `onFrameworkFallback` is never invoked, if the
111
+ * notice goes to the wrong chat, or if `sendSilenceText` throws. This block
112
+ * drives the REAL wiring through silence-poke's REAL tick: arm a turn, step the
113
+ * fake clock past the 300 s threshold, and assert on the send that happens.
114
+ */
115
+ describe('#3551 — the teardown notice actually reaches the user (integration)', () => {
116
+ const CHAT = '-100999'
117
+ const THREAD = 11
118
+ const KEY = `${CHAT}:${THREAD}`
119
+ const TURN_ID = '-100999:11#42'
120
+
121
+ afterEach(() => {
122
+ __resetAllForTests()
123
+ __setDepsForTests(null)
124
+ })
125
+
126
+ async function fireFallback(opts: { obligationOpen: boolean }) {
127
+ const fx = makeLivenessFixture()
128
+ const turn = makeTurn({ sessionChatId: CHAT, sessionThreadId: THREAD, turnId: TURN_ID })
129
+ fx.activeTurnStartedAt.set(KEY, 0)
130
+ fx.setCurrentTurn(turn)
131
+ if (opts.obligationOpen) fx.openObligations.add(TURN_ID)
132
+ __setDepsForTests(buildSilencePokeOptions(fx.deps))
133
+ startTurn(KEY, 0)
134
+ __tickForTests(301_000)
135
+ // onFrameworkFallback is async; let its awaits settle.
136
+ await new Promise((r) => setTimeout(r, 0))
137
+ return fx
138
+ }
139
+
140
+ it('sends the notice — right chat, right thread, LOUD — after 300s of silence', async () => {
141
+ const fx = await fireFallback({ obligationOpen: true })
142
+ const notice = fx.sent.find((s) => s.text.includes('the framework ended that stalled turn'))
143
+ expect(notice, 'the teardown notice must actually be sent').toBeDefined()
144
+ expect(notice!.chatId).toBe(CHAT)
145
+ expect(notice!.threadId).toBe(THREAD)
146
+ // LOUD: a killed question is not a silent status surface.
147
+ expect(notice!.silent).toBe(false)
148
+ // ...and the load-bearing unwedge still ran.
149
+ expect(fx.endedKeys).toContain(KEY)
150
+ })
151
+
152
+ it('promises the re-ask only when THIS turn has an open obligation', async () => {
153
+ // #3575 review B2. `representWillFollow` used to be the STATIC
154
+ // `OBLIGATION_LEDGER_ENABLED` env boolean, so the notice promised "being
155
+ // re-asked now" even when no re-ask could ever happen — the obligation
156
+ // already hit OBLIGATION_REPRESENT_MAX and escalated+closed, or was closed
157
+ // by an outbound-since-open, or was never opened. The user was told to wait,
158
+ // and waited forever.
159
+ //
160
+ // These two runs differ ONLY in whether this turn's obligation is open. Any
161
+ // process-wide constant fails one of them, so restoring the static flag
162
+ // cannot pass.
163
+ const open = await fireFallback({ obligationOpen: true })
164
+ const closed = await fireFallback({ obligationOpen: false })
165
+ const openText = open.sent.find((s) => s.text.includes('framework ended'))!.text
166
+ const closedText = closed.sent.find((s) => s.text.includes('framework ended'))!.text
167
+ expect(openText).toContain('being re-asked now')
168
+ expect(closedText).toContain('please re-send it')
169
+ expect(closedText).not.toContain('re-asked')
170
+ })
171
+ })
172
+
173
+ /**
174
+ * #3580 L3 — `tearsDownLiveTurn` must be the SAME predicate as the teardown
175
+ * gate, not a looser restatement of it.
176
+ *
177
+ * The old wiring passed the bare `turnMatchesFallback` (chat + thread equality)
178
+ * while the teardown itself additionally required `turnLiveForItsTopic`. When
179
+ * those disagree the decider is told a live turn was torn down when it was not,
180
+ * and the user gets a terminal "the framework ended that stalled turn" notice
181
+ * about a turn that never ended.
182
+ */
183
+ describe('#3580 L3 — the notice speaks for exactly the teardown that ran', () => {
184
+ const CHAT = '-100999'
185
+ const THREAD = 11
186
+ const KEY = `${CHAT}:${THREAD}`
187
+
188
+ afterEach(() => {
189
+ __resetAllForTests()
190
+ __setDepsForTests(null)
191
+ })
192
+
193
+ async function fire(turnLiveForItsTopic: () => boolean) {
194
+ const fx = makeLivenessFixture({ turnLiveForItsTopic })
195
+ const turn = makeTurn({ sessionChatId: CHAT, sessionThreadId: THREAD, turnId: `${KEY}#42` })
196
+ fx.activeTurnStartedAt.set(KEY, 0)
197
+ fx.setCurrentTurn(turn)
198
+ fx.openObligations.add(`${KEY}#42`)
199
+ __setDepsForTests(buildSilencePokeOptions(fx.deps))
200
+ startTurn(KEY, 0)
201
+ __tickForTests(301_000)
202
+ await new Promise((r) => setTimeout(r, 0))
203
+ return fx
204
+ }
205
+
206
+ it('stays quiet when the keyed liveness check SKIPPED the teardown', async () => {
207
+ // chat + thread match (so the loose `turnMatchesFallback` is true), but the
208
+ // keyed read says this turn is no longer live for its own topic — so no
209
+ // teardown happens. The user must not be told their turn was ended.
210
+ const fx = await fire(() => false)
211
+ expect(fx.endedKeys, 'no teardown must have run').toEqual([])
212
+ expect(fx.sent.filter((s) => s.text.includes('framework ended')), 'no teardown notice').toEqual([])
213
+ })
214
+
215
+ it('still speaks when the teardown DID run (the guard did not mute the honest case)', async () => {
216
+ const fx = await fire(() => true)
217
+ expect(fx.endedKeys).toContain(KEY)
218
+ expect(fx.sent.some((s) => s.text.includes('the framework ended that stalled turn'))).toBe(true)
219
+ })
220
+
221
+ it('the decider is fed the gate ITSELF — exactly one evaluation, no restatement', () => {
222
+ const code = readFileSync(join(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf8')
223
+ .split('\n')
224
+ .filter((l) => {
225
+ const t = l.trim()
226
+ return !(t.startsWith('//') || t.startsWith('*') || t.startsWith('/*'))
227
+ })
228
+ // Declared once...
229
+ expect(code.filter((l) => l.trim().startsWith('const tearsDownLiveTurn')).length).toBe(1)
230
+ // ...passed by shorthand (a `tearsDownLiveTurn: <anything>` restatement is
231
+ // exactly the L3 divergence, and would re-admit `: turnMatchesFallback`)...
232
+ expect(code.map((l) => l.trim())).toContain('tearsDownLiveTurn,')
233
+ expect(code.join('\n')).not.toMatch(/tearsDownLiveTurn:/)
234
+ // ...and it is the gate on the teardown block, not a parallel condition.
235
+ expect(code.map((l) => l.trim())).toContain('if (tearsDownLiveTurn && wedgedTurn != null) {')
236
+ })
237
+ })
238
+
239
+ describe('#3551 — wiring: the teardown path actually sends the notice', () => {
240
+ // buildSilencePokeOptions needs the whole gateway module's dep object, which
241
+ // cannot be instantiated in a unit test (module-load side effects). Pin the
242
+ // wiring structurally instead — the same technique the repo uses for other
243
+ // gateway-resident callbacks.
244
+ const src = readFileSync(join(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf8')
245
+ const fallbackBody = src.slice(src.indexOf('onFrameworkFallback:'))
246
+
247
+ it('calls the decider inside onFrameworkFallback and sends on a speaking decision', () => {
248
+ expect(fallbackBody).toMatch(/decideFallbackTeardownNotice\(/)
249
+ expect(fallbackBody).toMatch(/if \(teardownNotice\.send\)[\s\S]{0,400}?sendSilenceText\(/)
250
+ })
251
+
252
+ it('keeps the load-bearing unwedge — the notice does not replace the teardown', () => {
253
+ // The whole point of the 300s fallback is #1122: end the wedged turn so
254
+ // later inbound is not queued behind a dead turn forever. A "fix" that
255
+ // stopped killing the turn would trade dead air for a wedged conversation.
256
+ expect(fallbackBody).toMatch(/silencePoke\.endTurn\(fbKey\)/)
257
+ expect(fallbackBody).toMatch(/endCurrentTurnForKey\(wedgedTurn, fbKey\)/)
258
+ expect(fallbackBody).toMatch(/redeliverBufferedInbound\(/)
259
+ })
260
+
261
+ it('sends the notice LOUD — a killed question is not a silent status surface', () => {
262
+ expect(fallbackBody).toMatch(/sendSilenceText\(fbChatId, ctx\.threadId \?\? null, teardownNotice\.text, false\)/)
263
+ })
264
+ })
265
+
266
+ describe('#3551 L1/L2 — the suppression signal is narrow, and means DELIVERED', () => {
267
+ // L1. `text` in onFrameworkFallback has TWO sources. Treating them alike
268
+ // reproduced the very bug #3551 closes: user asks something during an
269
+ // `update_apply`, gets no answer, the fallback emits a SILENT status line
270
+ // about the update, the turn is torn down — and the notice was suppressed as
271
+ // "other text sent". Silent kill, again.
272
+ it('a silent, unrelated update-status line does NOT buy silence — that IS the #3551 case', () => {
273
+ const d = decideFallbackTeardownNotice(killedUserTurn({ userAddressedTextDelivered: false }))
274
+ expect(d.send).toBe(true)
275
+ })
276
+
277
+ const src = readFileSync(join(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf8')
278
+ const fallbackBody = src.slice(src.indexOf('onFrameworkFallback:'))
279
+
280
+ it('the wiring feeds the decider the DELIVERY flag, never `text != null`', () => {
281
+ // The regression this pins: reverting to `otherTextSent: text != null`
282
+ // silently re-couples the silent status line (L1) and the failed send (L2).
283
+ expect(fallbackBody).toMatch(/userAddressedTextDelivered,/)
284
+ expect(fallbackBody).not.toMatch(/otherTextSent:\s*text != null/)
285
+ })
286
+
287
+ it('L2 — the flag is set only AFTER a successful send, inside the try', () => {
288
+ // A throwing approval re-ping must not buy silence: the catch only logs, so
289
+ // assigning before/outside the await would leave the user with nothing at
290
+ // all while the turn was torn down.
291
+ expect(fallbackBody).toMatch(
292
+ /await sendSilenceText\(ctx\.chatId[^\n]*\n\s*userAddressedTextDelivered = blockedOnApproval/,
293
+ )
294
+ // and it is initialised false, so the catch path leaves it false
295
+ expect(fallbackBody).toMatch(/let userAddressedTextDelivered = false/)
296
+ })
297
+
298
+ it('L1 — the flag is gated on blockedOnApproval, which is what makes it user-addressed', () => {
299
+ expect(fallbackBody).toMatch(/userAddressedTextDelivered = blockedOnApproval/)
300
+ })
301
+ })