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.
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9207 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +63 -4
- package/dist/host-control/main.js +20 -3
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +78 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +1149 -247
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +73 -81
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- 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 (`& " <
|
|
104
|
+
* >`). 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. `&` is applied LAST so a literal `&lt;` in the source
|
|
108
|
+
* decodes to `<`, not `<`.
|
|
109
|
+
*/
|
|
110
|
+
function unescapeXmlAttr(s: string): string {
|
|
111
|
+
return s
|
|
112
|
+
.replace(/"/g, '"')
|
|
113
|
+
.replace(/</g, '<')
|
|
114
|
+
.replace(/>/g, '>')
|
|
115
|
+
.replace(/&/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
|
+
}
|