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.
Files changed (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Buzz co-channel — Phase 2a pure routing core.
3
+ *
4
+ * Two pure, side-effect-free (bar one fail-safe stderr breadcrumb) functions
5
+ * plus a flag reader. NO network sends, NO gateway state: this module is
6
+ * unit-testable without booting the gateway, mirroring the `chat-id-fallback.ts`
7
+ * pure-module precedent.
8
+ *
9
+ * 1. `parseChannelOrigin(rawContent)` — decides whether a turn originated on
10
+ * Telegram or Buzz, purely from the turn constructor's view of the inbound
11
+ * (`ev.rawContent`), and lifts the Buzz coordinates when it did.
12
+ *
13
+ * 2. `resolveRoute(originChannel, mode, buzzEnabled)` — the exhaustive 12-row
14
+ * routing table: given a turn's origin, the configured mirror mode, and
15
+ * whether Buzz is enabled at all, returns the primary channel plus any
16
+ * mirror channels an answer should also be copied to. Pure lookup — Phase
17
+ * 2a wires no senders to it (that is Phase 2b).
18
+ *
19
+ * 3. `isBuzzTurnRoutingEnabled(env)` — the feature flag reader.
20
+ *
21
+ * ── Why `parseChannelOrigin` reads the OUTER opening tag's LAST `source=` ──
22
+ *
23
+ * GATE-0 (see `buzz-phase2-design.md` §2.1) established the live transport
24
+ * shape. The Buzz sidecar's `mapBuzzEvent` (`src/buzz-gateway/inbound-map.ts`)
25
+ * pre-renders a FULL `<channel source="buzz" buzz_channel_id=… buzz_event_id=…
26
+ * buzz_thread_root=… …>body</channel>` envelope into the inbound's `text`, AND
27
+ * mirrors those same fields into the inbound's `meta`. The native Claude Code
28
+ * channel renderer then wraps that inbound again: it emits an OUTER
29
+ * `<channel source="switchroom-telegram" … source="buzz" buzz_channel_id=…
30
+ * buzz_event_id=… buzz_thread_root=… …>` opening tag — hoisting every `meta`
31
+ * key onto the outer tag as an attribute (so `meta.source="buzz"` appears as a
32
+ * SECOND `source=` after the renderer's own `source="switchroom-telegram"`) —
33
+ * and renders the sidecar's pre-rendered envelope VERBATIM in the body (the
34
+ * "double-wrap": a nested `<channel>` inside the body).
35
+ *
36
+ * The authoritative provenance signal is therefore the OUTER opening tag, and
37
+ * within it the LAST `source=` (the meta-hoisted one). This is exactly how
38
+ * `deriveTurnRole` (`telegram-plugin/turn-liveness-floor.ts`) already
39
+ * classifies the loop role in production for cron / synthetic inbounds — it
40
+ * matches the first `<channel …>` opening tag and reads the LAST `source=`
41
+ * within it via the greedy `/<channel[^>]*\bsource="([^"]+)"/`. We deliberately
42
+ * mirror that regex byte-for-byte so Buzz turns classify identically. Reading
43
+ * the FIRST `source=` (the renderer's `switchroom-telegram`) — or reading the
44
+ * inner nested envelope — would silently misclassify every Buzz turn as
45
+ * Telegram. The Buzz coordinates are likewise lifted from that same outer
46
+ * meta-hoisted opening tag, never the inner nested envelope.
47
+ *
48
+ * Every failure path is fail-safe to Telegram: a non-string input, no channel
49
+ * tag, a non-buzz last source, or any missing/empty coordinate all yield
50
+ * `{ originChannel: 'telegram' }`. A Buzz origin is only ever returned with a
51
+ * complete, non-empty coordinate triple.
52
+ */
53
+
54
+ export type Channel = 'telegram' | 'buzz'
55
+
56
+ /**
57
+ * Configured mirror mode for the fleet's Buzz co-channel:
58
+ * - `both` — answer on the origin channel AND mirror a copy to the other.
59
+ * - `origin` — answer only on the channel the turn came in on.
60
+ * - `off` — Buzz routing is dormant; everything resolves to Telegram.
61
+ */
62
+ export type MirrorMode = 'both' | 'origin' | 'off'
63
+
64
+ export interface BuzzCoords {
65
+ channelId: string
66
+ eventId: string
67
+ threadRoot: string
68
+ }
69
+
70
+ export interface ChannelOrigin {
71
+ originChannel: Channel
72
+ buzzCoords?: BuzzCoords
73
+ }
74
+
75
+ export interface Route {
76
+ primary: Channel
77
+ mirrors: Channel[]
78
+ }
79
+
80
+ // Frozen shared fail-safe result. `parseChannelOrigin` never attaches
81
+ // `buzzCoords` to a Telegram origin, so a single immutable instance is safe to
82
+ // return from every fail-safe path.
83
+ const TELEGRAM_ONLY: ChannelOrigin = Object.freeze({ originChannel: 'telegram' })
84
+
85
+ /**
86
+ * Greedy match of the FIRST `<channel …>` opening tag's LAST `source=`.
87
+ *
88
+ * Byte-for-byte identical to `deriveTurnRole`'s regex in
89
+ * `turn-liveness-floor.ts`: `[^>]*` cannot cross the tag's closing `>`, so the
90
+ * match is confined to the first opening tag, and its greediness backtracks to
91
+ * the LAST `source="…"` within it — the meta-hoisted `source="buzz"` on a Buzz
92
+ * turn, or the renderer's own `source="switchroom-telegram"` on a Telegram one.
93
+ */
94
+ const OUTER_LAST_SOURCE = /<channel[^>]*\bsource="([^"]+)"/
95
+
96
+ // Isolates the first opening tag (up to its closing `>`), matching the same
97
+ // `[^>]*` boundary the source regex uses. Coordinates are read from this
98
+ // substring so the inner nested (double-wrapped) envelope can never be mistaken
99
+ // for the outer meta-hoisted tag.
100
+ const OUTER_OPEN_TAG = /<channel[^>]*>/
101
+
102
+ /**
103
+ * Reverse of the sidecar/renderer XML-attribute escaping (`&amp; &quot; &lt;
104
+ * &gt;`). Buzz coordinates are hex/uuid in practice (no special chars, so this
105
+ * is usually a pass-through), but the native renderer's exact escaping of
106
+ * hoisted `meta` values is not contractually guaranteed, so we unescape
107
+ * defensively. `&amp;` is applied LAST so a literal `&amp;lt;` in the source
108
+ * decodes to `&lt;`, not `<`.
109
+ */
110
+ function unescapeXmlAttr(s: string): string {
111
+ return s
112
+ .replace(/&quot;/g, '"')
113
+ .replace(/&lt;/g, '<')
114
+ .replace(/&gt;/g, '>')
115
+ .replace(/&amp;/g, '&')
116
+ }
117
+
118
+ /**
119
+ * Reads a named attribute's value from a single opening-tag substring.
120
+ * Returns the unescaped value, or `null` when the attribute is absent or its
121
+ * value is empty (an empty coordinate is treated as missing → fail-safe).
122
+ */
123
+ function readAttr(openTag: string, name: string): string | null {
124
+ const m = openTag.match(new RegExp(`\\b${name}="([^"]*)"`))
125
+ if (m == null) return null
126
+ const value = unescapeXmlAttr(m[1])
127
+ return value.length > 0 ? value : null
128
+ }
129
+
130
+ /**
131
+ * Classify a turn's origin channel purely from the turn constructor's view of
132
+ * the inbound (`ev.rawContent`). Fail-safe to Telegram on every deviation. See
133
+ * the module header for why this reads the outer opening tag's LAST `source=`.
134
+ */
135
+ export function parseChannelOrigin(rawContent: string | null | undefined): ChannelOrigin {
136
+ if (typeof rawContent !== 'string') return TELEGRAM_ONLY
137
+
138
+ const sourceMatch = rawContent.match(OUTER_LAST_SOURCE)
139
+ if (sourceMatch == null || sourceMatch[1] !== 'buzz') return TELEGRAM_ONLY
140
+
141
+ // Coordinates live on the SAME outer, meta-hoisted opening tag. Isolate it so
142
+ // the inner nested envelope (which carries its own buzz_* attrs) can never be
143
+ // read by mistake.
144
+ const openMatch = rawContent.match(OUTER_OPEN_TAG)
145
+ if (openMatch == null) return TELEGRAM_ONLY
146
+ const openTag = openMatch[0]
147
+
148
+ const channelId = readAttr(openTag, 'buzz_channel_id')
149
+ const eventId = readAttr(openTag, 'buzz_event_id')
150
+ const threadRoot = readAttr(openTag, 'buzz_thread_root')
151
+
152
+ if (channelId == null || eventId == null || threadRoot == null) {
153
+ // A turn whose outer tag says source="buzz" but is missing a coordinate is
154
+ // structurally malformed. Degrade to Telegram rather than emit a Buzz
155
+ // origin we cannot address — a breadcrumb so the gap is diagnosable.
156
+ process.stderr.write(
157
+ 'telegram gateway: buzz-origin turn missing coordinate ' +
158
+ `(channel_id=${channelId != null} event_id=${eventId != null} ` +
159
+ `thread_root=${threadRoot != null}) — routing as telegram\n`,
160
+ )
161
+ return TELEGRAM_ONLY
162
+ }
163
+
164
+ return { originChannel: 'buzz', buzzCoords: { channelId, eventId, threadRoot } }
165
+ }
166
+
167
+ /**
168
+ * The exhaustive 12-row routing table (origin × mode × enabled). Pure lookup.
169
+ *
170
+ * Semantics (Finding 6):
171
+ * - `primary` is Buzz IFF the turn ORIGINATED on Buzz and Buzz is live
172
+ * (enabled AND mode ≠ off); otherwise Telegram.
173
+ * - a MIRROR is emitted only under `mode === 'both'` while Buzz is live:
174
+ * · Telegram-origin → mirror to Buzz (reach Buzz readers with the answer)
175
+ * · Buzz-origin → mirror to Telegram (the guaranteed Telegram copy)
176
+ * - under `origin`, `off`, or Buzz-disabled, there are no mirrors and the
177
+ * primary collapses to Telegram unless the turn genuinely originated on a
178
+ * live Buzz channel.
179
+ *
180
+ * "Buzz is live" = `buzzEnabled && mode !== 'off'`. When Buzz is not live, a
181
+ * Buzz-origin turn still resolves to a Telegram primary (fail-safe: we never
182
+ * route to a channel that is switched off).
183
+ */
184
+ export function resolveRoute(
185
+ originChannel: Channel,
186
+ mode: MirrorMode,
187
+ buzzEnabled: boolean,
188
+ ): Route {
189
+ const buzzLive = buzzEnabled && mode !== 'off'
190
+
191
+ const primary: Channel = originChannel === 'buzz' && buzzLive ? 'buzz' : 'telegram'
192
+
193
+ let mirrors: Channel[] = []
194
+ if (buzzLive && mode === 'both') {
195
+ mirrors = originChannel === 'telegram' ? ['buzz'] : ['telegram']
196
+ }
197
+
198
+ return { primary, mirrors }
199
+ }
200
+
201
+ /**
202
+ * Buzz co-channel — Phase 2b, safety correction S2. The ONLY mirror modes that
203
+ * ship live in 2b are `both` and `off`. `origin` is DEFERRED: the mirror hook
204
+ * lives exclusively in `sendReply`, so the `stream_reply` / turn-flush answer
205
+ * paths bypass it — `origin`'s "answer only on the origin channel" contract
206
+ * cannot be honored soundly (a buzz-origin turn under `origin` would still get
207
+ * a Telegram copy from those bypassing paths, and could NOT get a reliable
208
+ * buzz-only answer). Rather than half-honor it, degrade a configured `origin`
209
+ * to `off` (dark) deterministically here — a code mechanism, not a runtime
210
+ * assumption. Absent/`both` ⇒ `both`; `off`/`origin` ⇒ `off`.
211
+ */
212
+ export function parseConfiguredMirrorMode(raw: string | undefined): 'both' | 'off' {
213
+ // Absent ⇒ the schema default (`both`). The ONLY value that ships live is an
214
+ // explicit `both`; `off`/`origin` (S2 deferred) go dark. LOW-1: ANY other
215
+ // value — a typo like `BUZZ_MIRROR=of` set directly in env, unreachable via
216
+ // the schema enum but possible via raw env — fails DARK rather than silently
217
+ // going live. Only an explicit `both` (or absence) is live.
218
+ if (raw === undefined) return 'both'
219
+ if (raw === 'both') return 'both'
220
+ return 'off'
221
+ }
222
+
223
+ /**
224
+ * Buzz co-channel — Phase 2b, safety correction S1 (the misroute fix).
225
+ *
226
+ * DETERMINISTIC pre-publish owner guard for the ONE dangerous path: a THREADED
227
+ * publish into an existing Buzz conversation (a reply whose resolved owner turn
228
+ * originated on Buzz, so the answer would be signed and posted using that turn's
229
+ * Buzz coordinates). This is a code mechanism, never prompt discipline.
230
+ *
231
+ * The hazard (S1): in a Telegram DM the reply tool omits `origin_turn_id`, so
232
+ * reply-owner resolution falls through to the live-turn tier. Without a guard, a
233
+ * late or extra reply that actually belonged to an EARLIER Telegram DM turn can
234
+ * bind to a concurrently-live Buzz turn and be published as a signed public
235
+ * Nostr event addressed into a stranger's thread — a cross-channel misroute.
236
+ *
237
+ * The guard is consulted ONLY on the buzz-origin threaded-publish decision (the
238
+ * buzz-mirror hub gates its call on `ownerOriginChannel === 'buzz'`). It is NOT
239
+ * applied to the telegram-origin→Buzz top-level mirror under `both` mode: that
240
+ * path binds to no Buzz owner turn and no Buzz coordinates (it is a fresh
241
+ * top-level post the operator consented to by enabling `both`), so there is no
242
+ * owner binding to misresolve. See design §3.3 for why the two paths are
243
+ * separated (and the reconciliation note recorded there against S1's wording).
244
+ *
245
+ * Safe to publish the threaded Buzz reply IFF EITHER:
246
+ * - the reply POSITIVELY echoed the owner turn's id (`origin_turn_id`
247
+ * round-tripped and matched) — an explicit, forge-checked binding; OR
248
+ * - there is NO live/recent turn of a DIFFERENT origin within the supersede
249
+ * window that the reply could otherwise have belonged to — so the live-tier
250
+ * binding to the Buzz turn is unambiguous.
251
+ * Otherwise fail safe: the buzz-mirror hub drops the Buzz publish entirely and
252
+ * the answer is delivered Telegram-only. The guarantee is never to sign+publish
253
+ * a Buzz event on an ambiguous owner binding.
254
+ */
255
+ export function isBuzzThreadedPublishSafe(input: {
256
+ ownerEchoed: boolean
257
+ hasRecentDifferentOriginTurn: boolean
258
+ }): boolean {
259
+ return input.ownerEchoed || !input.hasRecentDifferentOriginTurn
260
+ }
261
+
262
+ /**
263
+ * Phase 2a feature flag. Default ON (escape-hatch convention, mirroring the
264
+ * fleet's other `SWITCHROOM_*` module flags): only an explicit `'0'` disables.
265
+ * Reads from an injected env map so the flag is unit-testable without mutating
266
+ * `process.env`.
267
+ */
268
+ export function isBuzzTurnRoutingEnabled(
269
+ env: Record<string, string | undefined> = process.env,
270
+ ): boolean {
271
+ return env.SWITCHROOM_BUZZ_TURN_ROUTING !== '0'
272
+ }