switchroom 0.19.48 → 0.20.0

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 (50) hide show
  1. package/dist/agent-scheduler/index.js +18 -1
  2. package/dist/auth-broker/index.js +19 -2
  3. package/dist/buzz-gateway/index.js +9207 -0
  4. package/dist/cli/notion-write-pretool.mjs +18 -1
  5. package/dist/cli/switchroom.js +63 -4
  6. package/dist/host-control/main.js +20 -3
  7. package/dist/vault/approvals/kernel-server.js +19 -2
  8. package/dist/vault/broker/server.js +19 -2
  9. package/package.json +4 -3
  10. package/profiles/_base/start.sh.hbs +78 -1
  11. package/profiles/default/CLAUDE.md.hbs +1 -1
  12. package/skills/dev-protocol/SKILL.md +30 -1
  13. package/skills/switchroom-architecture/SKILL.md +5 -0
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  16. package/telegram-plugin/dist/gateway/gateway.js +1149 -247
  17. package/telegram-plugin/dist/server.js +7 -4
  18. package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
  19. package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
  20. package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
  21. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  22. package/telegram-plugin/gateway/channel-route.ts +272 -0
  23. package/telegram-plugin/gateway/gateway.ts +73 -81
  24. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  25. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  27. package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
  28. package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
  29. package/telegram-plugin/gateway/stream-render.ts +21 -0
  30. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  31. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  32. package/telegram-plugin/history.ts +15 -0
  33. package/telegram-plugin/llm-error-present.ts +9 -4
  34. package/telegram-plugin/model-unavailable.ts +4 -0
  35. package/telegram-plugin/operator-events.fixtures.json +12 -12
  36. package/telegram-plugin/operator-events.ts +81 -9
  37. package/telegram-plugin/session-tail.ts +7 -1
  38. package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
  39. package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
  40. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  41. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  42. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  43. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  44. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  45. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  46. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  47. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  48. package/telegram-plugin/voice-normalize-text.ts +5 -0
  49. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  50. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Buzz co-channel — Phase 2b hub-side mirror module.
3
+ *
4
+ * This is the gateway-side half of "the part that actually sends". It sits
5
+ * strictly DOWNSTREAM of a successful Telegram delivery: `sendReply` calls
6
+ * `mirrorReplyDelivered(...)` only AFTER the Telegram copy has landed, and
7
+ * `executeEditMessage` calls `mirrorCorrection(...)` after a Telegram edit
8
+ * lands. Every Buzz publish is fire-and-forget — a failure or absence of the
9
+ * Buzz peer NEVER fails, delays, or retries the Telegram answer (the core
10
+ * invariant). There is no agent-facing Buzz-only send path: the ONLY way a
11
+ * Buzz event is ever emitted is as a mirror of an already-delivered Telegram
12
+ * message.
13
+ *
14
+ * Content signing lives in the sidecar (`src/buzz-gateway/publisher.ts`, the
15
+ * sole content-signer, S3). The hub only decides ROUTE + OWNER SAFETY and hands
16
+ * already-Telegram-scrubbed text (layer-1 redaction, via `normalizeOutboundBody`
17
+ * upstream) to the peer as an `outbound_to_buzz` request; the sidecar re-scrubs
18
+ * through `detectSecrets` (layer-2) before it ever reaches `finalizeEvent`.
19
+ *
20
+ * Dark by default: `getBuzzMirror()` returns null until `initBuzzMirror(...)`
21
+ * is called, which the gateway does ONLY when `channels.buzz.enabled === true`.
22
+ * With Buzz disabled the hook sites are `getBuzzMirror()?.…` — a byte-identical
23
+ * no-op on the hot path.
24
+ */
25
+
26
+ import { randomUUID } from "crypto";
27
+ import type { OutboundToBuzzMessage } from "./ipc-protocol.js";
28
+ import {
29
+ resolveRoute,
30
+ isBuzzThreadedPublishSafe,
31
+ parseConfiguredMirrorMode,
32
+ type BuzzCoords,
33
+ type Channel,
34
+ } from "./channel-route.js";
35
+
36
+ export type BuzzPeerSender = (msg: OutboundToBuzzMessage) => boolean;
37
+
38
+ export interface BuzzMirrorConfig {
39
+ /** Configured mode, ALREADY narrowed to both|off (S2) by the caller. */
40
+ mode: "both" | "off";
41
+ /** This gateway's agent name — stamped on every outbound_to_buzz. */
42
+ agentName: string;
43
+ /**
44
+ * The relay-minted group UUID a TELEGRAM-origin answer is mirrored to as a
45
+ * fresh top-level post (`channels.buzz.default_channel_id`). Empty string ⇒
46
+ * telegram-origin mirroring is disabled (no channel to post into); buzz-origin
47
+ * threaded replies still work (they carry their own channelId).
48
+ */
49
+ defaultChannelId: string;
50
+ /** Optional log sink (defaults to a no-op). */
51
+ log?: (msg: string) => void;
52
+ }
53
+
54
+ export interface MirrorReplyInput {
55
+ /** The answer text AFTER layer-1 Telegram scrub (normalizeOutboundBody). */
56
+ scrubbedText: string;
57
+ /** Resolved reply-owner turn's origin channel. */
58
+ ownerOriginChannel: Channel;
59
+ /** Owner turn's Buzz coordinates, when it originated on Buzz. */
60
+ ownerBuzzCoords?: BuzzCoords;
61
+ /** True IFF the reply positively echoed the owner turn's id (S1). */
62
+ ownerEchoed: boolean;
63
+ /** True IFF a live/recent turn of a DIFFERENT origin exists (S1). */
64
+ hasRecentDifferentOriginTurn: boolean;
65
+ /**
66
+ * `${chatId}:${messageId}` keys of the Telegram messages this answer was
67
+ * delivered as. Recorded so a later `edit_message` on any of them can find
68
+ * the published Buzz event to correct.
69
+ */
70
+ telegramMessageKeys: string[];
71
+ }
72
+
73
+ export interface MirrorCorrectionInput {
74
+ /** `${chatId}:${messageId}` of the edited Telegram message. */
75
+ telegramMessageKey: string;
76
+ /** The edit text AFTER layer-1 Telegram scrub. */
77
+ scrubbedText: string;
78
+ }
79
+
80
+ /** F6 — coalesce a burst of edits into a single correction event. */
81
+ export const CORRECTION_DEBOUNCE_MS = 30_000;
82
+
83
+ /** Bound the in-memory correlation / message maps (FIFO eviction). */
84
+ const MAX_TRACKED = 4096;
85
+
86
+ interface PendingPublish {
87
+ channelId: string;
88
+ telegramMessageKeys: string[];
89
+ }
90
+
91
+ class BuzzMirror {
92
+ private readonly cfg: BuzzMirrorConfig;
93
+ private readonly log: (msg: string) => void;
94
+ private sender: BuzzPeerSender | null = null;
95
+
96
+ /** correlationId → in-flight publish awaiting its buzz_publish_result. */
97
+ private readonly pending = new Map<string, PendingPublish>();
98
+ private readonly pendingOrder: string[] = [];
99
+
100
+ /** `${chatId}:${messageId}` → the published Buzz event it maps to. */
101
+ private readonly msgToBuzz = new Map<string, { eventId: string; channelId: string }>();
102
+ private readonly msgOrder: string[] = [];
103
+
104
+ /** `${chatId}:${messageId}` → live correction debounce timer. */
105
+ private readonly correctionTimers = new Map<string, ReturnType<typeof setTimeout>>();
106
+
107
+ constructor(cfg: BuzzMirrorConfig) {
108
+ this.cfg = cfg;
109
+ this.log = cfg.log ?? (() => {});
110
+ }
111
+
112
+ /** Register the transport to the duplex Buzz peer (ipcServer.sendToBuzzPeer). */
113
+ attachSender(sender: BuzzPeerSender): void {
114
+ this.sender = sender;
115
+ }
116
+
117
+ private evict<T>(map: Map<string, T>, order: string[]): void {
118
+ while (order.length > MAX_TRACKED) {
119
+ const k = order.shift();
120
+ if (k !== undefined) map.delete(k);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Mirror a just-delivered Telegram answer to Buzz, if the route calls for it
126
+ * and the owner binding is safe (S1). No-op when Buzz is not in the route.
127
+ * Never throws — a mirror failure must never disturb the Telegram answer.
128
+ */
129
+ mirrorReplyDelivered(input: MirrorReplyInput): void {
130
+ try {
131
+ // buzzEnabled is implied — this instance only exists when enabled.
132
+ const route = resolveRoute(input.ownerOriginChannel, this.cfg.mode, true);
133
+ const buzzInRoute =
134
+ route.primary === "buzz" || route.mirrors.includes("buzz");
135
+ if (!buzzInRoute) return;
136
+
137
+ let channelId: string;
138
+ let replyToEventId: string | undefined;
139
+ let threadRootId: string | undefined;
140
+
141
+ if (input.ownerOriginChannel === "buzz" && input.ownerBuzzCoords) {
142
+ // THREADED reply into an existing Buzz conversation — the ONLY path the
143
+ // S1 owner guard gates. Fail safe to Telegram-only on an ambiguous bind.
144
+ if (
145
+ !isBuzzThreadedPublishSafe({
146
+ ownerEchoed: input.ownerEchoed,
147
+ hasRecentDifferentOriginTurn: input.hasRecentDifferentOriginTurn,
148
+ })
149
+ ) {
150
+ this.log(
151
+ "buzz-mirror: S1 guard blocked a threaded publish on an ambiguous " +
152
+ "owner binding (un-echoed reply + a recent different-origin turn) " +
153
+ "— delivered Telegram-only",
154
+ );
155
+ return;
156
+ }
157
+ channelId = input.ownerBuzzCoords.channelId;
158
+ replyToEventId = input.ownerBuzzCoords.eventId;
159
+ threadRootId = input.ownerBuzzCoords.threadRoot;
160
+ } else {
161
+ // TELEGRAM-origin → fresh top-level post to the configured channel. Not
162
+ // an owner-bound thread, so the S1 guard does not apply (design §3.3).
163
+ if (!this.cfg.defaultChannelId) return; // no channel to post into
164
+ channelId = this.cfg.defaultChannelId;
165
+ }
166
+
167
+ this.publish(
168
+ {
169
+ channelId,
170
+ replyToEventId,
171
+ threadRootId,
172
+ payload: { kind: "message", text: input.scrubbedText },
173
+ },
174
+ input.telegramMessageKeys,
175
+ );
176
+ } catch (err) {
177
+ this.log(`buzz-mirror: mirrorReplyDelivered threw (ignored): ${String(err)}`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Debounced correction: an `edit_message` on a Telegram message that was
183
+ * mirrored to Buzz publishes a superseding `correction` event 30s after the
184
+ * last edit (F6 CORRECTION_DEBOUNCE_MS). No-op when the edited message was
185
+ * never mirrored (no Buzz event to correct). Never throws.
186
+ */
187
+ mirrorCorrection(input: MirrorCorrectionInput): void {
188
+ try {
189
+ const target = this.msgToBuzz.get(input.telegramMessageKey);
190
+ if (!target) return; // this Telegram message was never mirrored to Buzz
191
+
192
+ const existing = this.correctionTimers.get(input.telegramMessageKey);
193
+ if (existing) clearTimeout(existing);
194
+
195
+ const timer = setTimeout(() => {
196
+ this.correctionTimers.delete(input.telegramMessageKey);
197
+ // Re-read: the mapping may have advanced (a later publish), but the
198
+ // targetEventId to supersede is the one current at fire time.
199
+ const t = this.msgToBuzz.get(input.telegramMessageKey);
200
+ if (!t) return;
201
+ this.publish(
202
+ {
203
+ channelId: t.channelId,
204
+ replyToEventId: t.eventId,
205
+ threadRootId: t.eventId,
206
+ payload: {
207
+ kind: "correction",
208
+ text: input.scrubbedText,
209
+ targetEventId: t.eventId,
210
+ },
211
+ },
212
+ [], // a correction is not itself re-correctable via a Telegram edit
213
+ );
214
+ }, CORRECTION_DEBOUNCE_MS);
215
+ if (typeof (timer as { unref?: () => void }).unref === "function") {
216
+ (timer as { unref: () => void }).unref();
217
+ }
218
+ this.correctionTimers.set(input.telegramMessageKey, timer);
219
+ } catch (err) {
220
+ this.log(`buzz-mirror: mirrorCorrection threw (ignored): ${String(err)}`);
221
+ }
222
+ }
223
+
224
+ /** Handle the sidecar's advisory publish outcome (buzz_publish_result). */
225
+ onPublishResult(msg: {
226
+ correlationId: string;
227
+ ok: boolean;
228
+ eventId?: string;
229
+ error?: string;
230
+ }): void {
231
+ const p = this.pending.get(msg.correlationId);
232
+ this.pending.delete(msg.correlationId);
233
+ if (!p) return;
234
+ if (!msg.ok || !msg.eventId) {
235
+ this.log(
236
+ `buzz-mirror: publish failed (correlationId=${msg.correlationId.slice(0, 8)}` +
237
+ `${msg.error ? ` error=${msg.error}` : ""}) — Telegram copy already delivered`,
238
+ );
239
+ return;
240
+ }
241
+ // Record the published event against each Telegram message it mirrored, so
242
+ // a later edit_message on any of them can target it for a correction.
243
+ for (const key of p.telegramMessageKeys) {
244
+ this.msgToBuzz.set(key, { eventId: msg.eventId, channelId: p.channelId });
245
+ this.msgOrder.push(key);
246
+ }
247
+ this.evict(this.msgToBuzz, this.msgOrder);
248
+ }
249
+
250
+ private publish(
251
+ fields: Omit<OutboundToBuzzMessage, "type" | "correlationId" | "agentName">,
252
+ telegramMessageKeys: string[],
253
+ ): void {
254
+ if (!this.sender) {
255
+ this.log("buzz-mirror: no Buzz peer connected — mirror dropped (Telegram copy delivered)");
256
+ return;
257
+ }
258
+ const correlationId = randomUUID();
259
+ const msg: OutboundToBuzzMessage = {
260
+ type: "outbound_to_buzz",
261
+ correlationId,
262
+ agentName: this.cfg.agentName,
263
+ ...fields,
264
+ };
265
+ const sent = this.sender(msg);
266
+ if (!sent) {
267
+ this.log("buzz-mirror: Buzz peer send returned false — mirror dropped (Telegram copy delivered)");
268
+ return;
269
+ }
270
+ this.pending.set(correlationId, {
271
+ channelId: fields.channelId,
272
+ telegramMessageKeys,
273
+ });
274
+ this.pendingOrder.push(correlationId);
275
+ this.evict(this.pending, this.pendingOrder);
276
+ }
277
+ }
278
+
279
+ let singleton: BuzzMirror | null = null;
280
+
281
+ /**
282
+ * Initialize the hub mirror. Called by the gateway ONLY when
283
+ * `channels.buzz.enabled === true`. Idempotent-ish: a second call replaces the
284
+ * instance (used by tests). When never called, `getBuzzMirror()` stays null and
285
+ * every hook site is a no-op.
286
+ */
287
+ export function initBuzzMirror(cfg: BuzzMirrorConfig): BuzzMirror {
288
+ singleton = new BuzzMirror(cfg);
289
+ return singleton;
290
+ }
291
+
292
+ export function getBuzzMirror(): BuzzMirror | null {
293
+ return singleton;
294
+ }
295
+
296
+ /**
297
+ * Boot the hub mirror from env at gateway startup — the single wiring seam the
298
+ * gateway calls. DARK BY DEFAULT and by construction: returns null (leaving
299
+ * `getBuzzMirror()` null, every hook site a no-op) unless BOTH hold —
300
+ * (1) `BUZZ_ENABLED` is truthy, AND
301
+ * (2) the S2-narrowed mode (`parseConfiguredMirrorMode`) is `both`;
302
+ * a configured `origin`/`off` degrades to dark, never a half-live mirror.
303
+ * The Buzz env vars are UNSET everywhere in this branch (projection deferred),
304
+ * so in practice this is inert. `sender` is the transport to the duplex peer
305
+ * (`ipcServer.sendToBuzzPeer`). Returns the booted instance for tests.
306
+ */
307
+ export function maybeBootBuzzMirror(
308
+ sender: BuzzPeerSender,
309
+ env: Record<string, string | undefined> = process.env,
310
+ ): BuzzMirror | null {
311
+ if (env.BUZZ_ENABLED !== "1" && env.BUZZ_ENABLED !== "true") return null;
312
+ const mode = parseConfiguredMirrorMode(env.BUZZ_MIRROR);
313
+ if (mode !== "both") return null;
314
+ const bm = initBuzzMirror({
315
+ mode,
316
+ agentName: env.SWITCHROOM_AGENT_NAME?.trim() ?? "",
317
+ defaultChannelId: env.BUZZ_CHANNEL_IDS?.trim() ?? "",
318
+ log: (m) => process.stderr.write(`telegram gateway: buzz-mirror — ${m}\n`),
319
+ });
320
+ bm.attachSender(sender);
321
+ return bm;
322
+ }
323
+
324
+ /** Test-only: tear down the singleton so cases don't leak state into each other. */
325
+ export function __resetBuzzMirrorForTests(): void {
326
+ singleton = null;
327
+ }
328
+
329
+ export type { BuzzMirror };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Buzz co-channel — compile-time type-identity guards (2a MINOR-3).
3
+ *
4
+ * `gateway.ts` INLINES the `CurrentTurn.buzzCoords` shape
5
+ * (`{ channelId; eventId; threadRoot }`) rather than importing the canonical
6
+ * `BuzzCoords` from `channel-route.ts`, purely to hold gateway.ts at its
7
+ * zero-headroom line-ratchet (switchroom#2996). That inlining is a drift hazard:
8
+ * nothing structurally ties the two shapes together, so an edit to one could
9
+ * silently diverge from the other.
10
+ *
11
+ * This file closes that hazard with a strict, invariant type-equality assertion
12
+ * that makes `tsc --noEmit` (the lint gate) FAIL the moment the shapes differ.
13
+ * Both imports are `import type` — fully erased at runtime, so this introduces
14
+ * NO runtime dependency and NO module-load side effect (in particular it does
15
+ * NOT load gateway.ts, which binds a UDS listener at import under prod). The
16
+ * file is imported by nothing; it exists only to be type-checked.
17
+ */
18
+
19
+ import type { CurrentTurn } from "./gateway.js";
20
+ import type { BuzzCoords } from "./channel-route.js";
21
+
22
+ /**
23
+ * Invariant type equality: `true` IFF `A` and `B` are mutually assignable with
24
+ * identical `readonly`/optional modifiers (the `(<T>() => …)` wrapper defeats
25
+ * the bivariant/structural leniency a plain `extends` pair would allow).
26
+ */
27
+ type TypeEq<A, B> =
28
+ (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
29
+
30
+ // `buzzCoords` is optional on CurrentTurn; compare its PRESENT shape to the
31
+ // canonical BuzzCoords. If these ever drift, `TypeEq<…>` becomes `false` and the
32
+ // `= true` initializer is a hard `tsc` error — the intended build break.
33
+ const _buzzCoordsIdentity: TypeEq<NonNullable<CurrentTurn["buzzCoords"]>, BuzzCoords> = true;
34
+ void _buzzCoordsIdentity;
@@ -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
+ }