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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- 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 +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -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 +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- 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 +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -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 +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -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/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- 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,494 @@
|
|
|
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 { join } from "path";
|
|
28
|
+
import type { OutboundToBuzzMessage } from "./ipc-protocol.js";
|
|
29
|
+
import {
|
|
30
|
+
resolveRoute,
|
|
31
|
+
isBuzzThreadedPublishSafe,
|
|
32
|
+
parseConfiguredMirrorMode,
|
|
33
|
+
type BuzzCoords,
|
|
34
|
+
type Channel,
|
|
35
|
+
} from "./channel-route.js";
|
|
36
|
+
import {
|
|
37
|
+
createCorrelationStore,
|
|
38
|
+
type CorrelationStore,
|
|
39
|
+
} from "./buzz-mirror-correlation-store.js";
|
|
40
|
+
|
|
41
|
+
export type BuzzPeerSender = (msg: OutboundToBuzzMessage) => boolean;
|
|
42
|
+
|
|
43
|
+
export interface BuzzMirrorConfig {
|
|
44
|
+
/** Configured mode, ALREADY narrowed to both|off (S2) by the caller. */
|
|
45
|
+
mode: "both" | "off";
|
|
46
|
+
/** This gateway's agent name — stamped on every outbound_to_buzz. */
|
|
47
|
+
agentName: string;
|
|
48
|
+
/**
|
|
49
|
+
* The relay-minted group UUID a TELEGRAM-origin answer is mirrored to as a
|
|
50
|
+
* fresh top-level post (`channels.buzz.default_channel_id`). Empty string ⇒
|
|
51
|
+
* telegram-origin mirroring is disabled (no channel to post into); buzz-origin
|
|
52
|
+
* threaded replies still work (they carry their own channelId).
|
|
53
|
+
*/
|
|
54
|
+
defaultChannelId: string;
|
|
55
|
+
/**
|
|
56
|
+
* Absolute path to the durable msg→Buzz correlation journal (#4222). When
|
|
57
|
+
* set, the `${chatId}:${messageId}` → published-event map is persisted and
|
|
58
|
+
* reloaded on construction, so an `edit_message` correction survives a gateway
|
|
59
|
+
* restart. Omit (dev/one-shot/tests) to keep the map in-memory only — same
|
|
60
|
+
* bound, no durability. See `buzz-mirror-correlation-store.ts`.
|
|
61
|
+
*/
|
|
62
|
+
correlationJournalPath?: string;
|
|
63
|
+
/** Optional log sink (defaults to a no-op). */
|
|
64
|
+
log?: (msg: string) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MirrorReplyInput {
|
|
68
|
+
/** The answer text AFTER layer-1 Telegram scrub (normalizeOutboundBody). */
|
|
69
|
+
scrubbedText: string;
|
|
70
|
+
/** Resolved reply-owner turn's origin channel. */
|
|
71
|
+
ownerOriginChannel: Channel;
|
|
72
|
+
/** Owner turn's Buzz coordinates, when it originated on Buzz. */
|
|
73
|
+
ownerBuzzCoords?: BuzzCoords;
|
|
74
|
+
/** True IFF the reply positively echoed the owner turn's id (S1). */
|
|
75
|
+
ownerEchoed: boolean;
|
|
76
|
+
/** True IFF a live/recent turn of a DIFFERENT origin exists (S1). */
|
|
77
|
+
hasRecentDifferentOriginTurn: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* `${chatId}:${messageId}` keys of the Telegram messages this answer was
|
|
80
|
+
* delivered as. Recorded so a later `edit_message` on any of them can find
|
|
81
|
+
* the published Buzz event to correct.
|
|
82
|
+
*/
|
|
83
|
+
telegramMessageKeys: string[];
|
|
84
|
+
/**
|
|
85
|
+
* `${chatId}:${messageId}` of the Telegram message this outbound answer is
|
|
86
|
+
* itself a reply to (its Telegram `reply_to` antecedent), when it has one.
|
|
87
|
+
* Used ONLY on the telegram-origin path to thread the mirrored Buzz event
|
|
88
|
+
* under the antecedent's previously-published Buzz event (NIP-10 outbound
|
|
89
|
+
* continuity). When the antecedent was never mirrored (e.g. it is a user's
|
|
90
|
+
* inbound message, or it aged past the correlation bound) the lookup misses
|
|
91
|
+
* and the mirror stays a flat top-level post — no wrong/guessed tag. Absent
|
|
92
|
+
* for a non-reply answer. Ignored on the buzz-origin path (that thread is
|
|
93
|
+
* bound by `ownerBuzzCoords`, not a Telegram antecedent).
|
|
94
|
+
*/
|
|
95
|
+
antecedentTelegramMessageKey?: string;
|
|
96
|
+
/**
|
|
97
|
+
* True IFF `antecedentTelegramMessageKey` is the quote-opt-in DEFAULT — i.e.
|
|
98
|
+
* the caller had no explicit/model-supplied `reply_to` and defaulted it to the
|
|
99
|
+
* latest INBOUND user message (#4301). That message is never in the
|
|
100
|
+
* correlation store, so its lookup ALWAYS misses; distinguishing it lets the
|
|
101
|
+
* mirror log the expected flat fallback quietly with a distinct reason instead
|
|
102
|
+
* of as an "outbound thread MISS", so a genuine eviction miss stays visible.
|
|
103
|
+
*/
|
|
104
|
+
antecedentIsQuoteOptInDefault?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface MirrorCorrectionInput {
|
|
108
|
+
/** `${chatId}:${messageId}` of the edited Telegram message. */
|
|
109
|
+
telegramMessageKey: string;
|
|
110
|
+
/** The edit text AFTER layer-1 Telegram scrub. */
|
|
111
|
+
scrubbedText: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** F6 — coalesce a burst of edits into a single correction event. */
|
|
115
|
+
export const CORRECTION_DEBOUNCE_MS = 30_000;
|
|
116
|
+
|
|
117
|
+
/** Bound the in-memory correlation / message maps (FIFO eviction). */
|
|
118
|
+
const MAX_TRACKED = 4096;
|
|
119
|
+
|
|
120
|
+
interface PendingPublish {
|
|
121
|
+
channelId: string;
|
|
122
|
+
telegramMessageKeys: string[];
|
|
123
|
+
/**
|
|
124
|
+
* The NIP-10 thread root this in-flight event belongs to, when it threaded
|
|
125
|
+
* under a parent. Undefined for a fresh top-level post — in which case the
|
|
126
|
+
* event's OWN id (learned in `onPublishResult`) becomes the thread root. Stored
|
|
127
|
+
* so the correlation record carries the root a LATER reply's `root` marker
|
|
128
|
+
* needs.
|
|
129
|
+
*/
|
|
130
|
+
threadRootId?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class BuzzMirror {
|
|
134
|
+
private readonly cfg: BuzzMirrorConfig;
|
|
135
|
+
private readonly log: (msg: string) => void;
|
|
136
|
+
private sender: BuzzPeerSender | null = null;
|
|
137
|
+
|
|
138
|
+
/** correlationId → in-flight publish awaiting its buzz_publish_result. */
|
|
139
|
+
private readonly pending = new Map<string, PendingPublish>();
|
|
140
|
+
private readonly pendingOrder: string[] = [];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `${chatId}:${messageId}` → the published Buzz event it maps to. Durable
|
|
144
|
+
* (JSONL journal) when `correlationJournalPath` is configured, so a correction
|
|
145
|
+
* survives a gateway restart (#4222); the store enforces the same MAX_TRACKED
|
|
146
|
+
* FIFO bound in memory and on disk.
|
|
147
|
+
*/
|
|
148
|
+
private readonly msgToBuzz: CorrelationStore;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Count of `mirrorCorrection` calls whose key was genuinely absent from the
|
|
152
|
+
* correlation store (never mirrored, or evicted past the bound) — surfaced for
|
|
153
|
+
* the loud-miss regression assertion, not just the log line.
|
|
154
|
+
*/
|
|
155
|
+
private correctionMisses = 0;
|
|
156
|
+
|
|
157
|
+
/** `${chatId}:${messageId}` → live correction debounce timer. */
|
|
158
|
+
private readonly correctionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
159
|
+
|
|
160
|
+
constructor(cfg: BuzzMirrorConfig) {
|
|
161
|
+
this.cfg = cfg;
|
|
162
|
+
this.log = cfg.log ?? (() => {});
|
|
163
|
+
this.msgToBuzz = createCorrelationStore({
|
|
164
|
+
journalPath: cfg.correlationJournalPath,
|
|
165
|
+
capacity: MAX_TRACKED,
|
|
166
|
+
log: this.log,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Test/introspection: number of corrections that missed the correlation store. */
|
|
171
|
+
getCorrectionMisses(): number {
|
|
172
|
+
return this.correctionMisses;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Register the transport to the duplex Buzz peer (ipcServer.sendToBuzzPeer). */
|
|
176
|
+
attachSender(sender: BuzzPeerSender): void {
|
|
177
|
+
this.sender = sender;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Release the correlation journal fd and cancel any pending correction timers. */
|
|
181
|
+
close(): void {
|
|
182
|
+
for (const timer of this.correctionTimers.values()) clearTimeout(timer);
|
|
183
|
+
this.correctionTimers.clear();
|
|
184
|
+
this.msgToBuzz.close();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private evict<T>(map: Map<string, T>, order: string[]): void {
|
|
188
|
+
while (order.length > MAX_TRACKED) {
|
|
189
|
+
const k = order.shift();
|
|
190
|
+
if (k !== undefined) map.delete(k);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Mirror a just-delivered Telegram answer to Buzz, if the route calls for it
|
|
196
|
+
* and the owner binding is safe (S1). No-op when Buzz is not in the route.
|
|
197
|
+
* Never throws — a mirror failure must never disturb the Telegram answer.
|
|
198
|
+
*/
|
|
199
|
+
mirrorReplyDelivered(input: MirrorReplyInput): void {
|
|
200
|
+
try {
|
|
201
|
+
// buzzEnabled is implied — this instance only exists when enabled.
|
|
202
|
+
const route = resolveRoute(input.ownerOriginChannel, this.cfg.mode, true);
|
|
203
|
+
const buzzInRoute =
|
|
204
|
+
route.primary === "buzz" || route.mirrors.includes("buzz");
|
|
205
|
+
if (!buzzInRoute) return;
|
|
206
|
+
|
|
207
|
+
let channelId: string;
|
|
208
|
+
let replyToEventId: string | undefined;
|
|
209
|
+
let threadRootId: string | undefined;
|
|
210
|
+
|
|
211
|
+
if (input.ownerOriginChannel === "buzz" && input.ownerBuzzCoords) {
|
|
212
|
+
// THREADED reply into an existing Buzz conversation — the ONLY path the
|
|
213
|
+
// S1 owner guard gates. Fail safe to Telegram-only on an ambiguous bind.
|
|
214
|
+
if (
|
|
215
|
+
!isBuzzThreadedPublishSafe({
|
|
216
|
+
ownerEchoed: input.ownerEchoed,
|
|
217
|
+
hasRecentDifferentOriginTurn: input.hasRecentDifferentOriginTurn,
|
|
218
|
+
})
|
|
219
|
+
) {
|
|
220
|
+
this.log(
|
|
221
|
+
"buzz-mirror: S1 guard blocked a threaded publish on an ambiguous " +
|
|
222
|
+
"owner binding (un-echoed reply + a recent different-origin turn) " +
|
|
223
|
+
"— delivered Telegram-only",
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
channelId = input.ownerBuzzCoords.channelId;
|
|
228
|
+
replyToEventId = input.ownerBuzzCoords.eventId;
|
|
229
|
+
threadRootId = input.ownerBuzzCoords.threadRoot;
|
|
230
|
+
} else {
|
|
231
|
+
// TELEGRAM-origin → post to the configured channel. Not an owner-bound
|
|
232
|
+
// thread, so the S1 guard does not apply (design §3.3).
|
|
233
|
+
if (!this.cfg.defaultChannelId) return; // no channel to post into
|
|
234
|
+
channelId = this.cfg.defaultChannelId;
|
|
235
|
+
|
|
236
|
+
// NIP-10 OUTBOUND thread continuity: if this answer is itself a reply to
|
|
237
|
+
// a Telegram message that was ALREADY mirrored to Buzz, thread the new
|
|
238
|
+
// event under that antecedent's published event — a `reply` marker for
|
|
239
|
+
// the immediate parent and a `root` marker for the thread root — so a
|
|
240
|
+
// Telegram-origin reply chain renders threaded on the Buzz desktop
|
|
241
|
+
// instead of flat. The antecedent→event resolution reuses the SAME
|
|
242
|
+
// durable msg→event correlation store (#4280) the correction path uses;
|
|
243
|
+
// no new lookup surface. A MISS (antecedent never mirrored — e.g. it is
|
|
244
|
+
// the user's own inbound message — or evicted past MAX_TRACKED) leaves
|
|
245
|
+
// the post flat rather than emitting a wrong/guessed tag.
|
|
246
|
+
if (input.antecedentTelegramMessageKey) {
|
|
247
|
+
const parent = this.msgToBuzz.get(input.antecedentTelegramMessageKey);
|
|
248
|
+
if (parent && parent.channelId === channelId) {
|
|
249
|
+
replyToEventId = parent.eventId; // immediate parent → NIP-10 `reply`
|
|
250
|
+
// Thread root → NIP-10 `root`. Fall back to the parent's own id when
|
|
251
|
+
// the parent has no recorded root (it was itself top-level, or was
|
|
252
|
+
// journaled before threadRoot was tracked): then parent IS the root.
|
|
253
|
+
threadRootId = parent.threadRoot ?? parent.eventId;
|
|
254
|
+
} else if (parent) {
|
|
255
|
+
// #4299 CROSS-CHANNEL GUARD: the antecedent WAS mirrored, but into a
|
|
256
|
+
// DIFFERENT Buzz channel than this event's target (e.g. it was
|
|
257
|
+
// recorded via a buzz-origin threaded reply whose channelId came from
|
|
258
|
+
// the inbound event's own `h`-tag). This event publishes into
|
|
259
|
+
// `defaultChannelId`; threading it under a foreign-channel parent
|
|
260
|
+
// would carry e-tags that point into another group. Mirror FLAT
|
|
261
|
+
// instead — same fallback as a MISS, no cross-group e-tag.
|
|
262
|
+
this.log(
|
|
263
|
+
`buzz-mirror: outbound thread CROSS-CHANNEL — antecedent ` +
|
|
264
|
+
`${input.antecedentTelegramMessageKey} was mirrored into channel ` +
|
|
265
|
+
`${parent.channelId} != target ${channelId}; mirroring flat ` +
|
|
266
|
+
`(no cross-group e-tag)`,
|
|
267
|
+
);
|
|
268
|
+
} else if (input.antecedentIsQuoteOptInDefault) {
|
|
269
|
+
// #4301: quote-opt-in defaulted `reply_to` to the latest INBOUND user
|
|
270
|
+
// message, which is never in the correlation store — so this "miss"
|
|
271
|
+
// is EXPECTED, not an eviction. Log it quietly with a distinct reason
|
|
272
|
+
// (no "MISS") so genuine eviction misses stay visible in the logs.
|
|
273
|
+
this.log(
|
|
274
|
+
`buzz-mirror: outbound thread default-quote — antecedent ` +
|
|
275
|
+
`${input.antecedentTelegramMessageKey} is the latest inbound user ` +
|
|
276
|
+
`message (never mirrored); mirroring flat (expected, not an eviction)`,
|
|
277
|
+
);
|
|
278
|
+
} else {
|
|
279
|
+
this.log(
|
|
280
|
+
`buzz-mirror: outbound thread MISS — no Buzz correlation for ` +
|
|
281
|
+
`Telegram antecedent ${input.antecedentTelegramMessageKey}; ` +
|
|
282
|
+
`mirroring flat (never mirrored, or evicted past the bound)`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.publish(
|
|
289
|
+
{
|
|
290
|
+
channelId,
|
|
291
|
+
replyToEventId,
|
|
292
|
+
threadRootId,
|
|
293
|
+
payload: { kind: "message", text: input.scrubbedText },
|
|
294
|
+
},
|
|
295
|
+
input.telegramMessageKeys,
|
|
296
|
+
);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.log(`buzz-mirror: mirrorReplyDelivered threw (ignored): ${String(err)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Debounced correction: an `edit_message` on a Telegram message that was
|
|
304
|
+
* mirrored to Buzz publishes a superseding `correction` event 30s after the
|
|
305
|
+
* last edit (F6 CORRECTION_DEBOUNCE_MS). No-op when the edited message was
|
|
306
|
+
* never mirrored (no Buzz event to correct). Never throws.
|
|
307
|
+
*/
|
|
308
|
+
mirrorCorrection(input: MirrorCorrectionInput): void {
|
|
309
|
+
try {
|
|
310
|
+
const target = this.msgToBuzz.get(input.telegramMessageKey);
|
|
311
|
+
if (!target) {
|
|
312
|
+
// No mapping for this key — either it was genuinely never mirrored, or
|
|
313
|
+
// it aged out past MAX_TRACKED (memory AND journal). Either way the
|
|
314
|
+
// correction cannot land, so DON'T fail silently: emit a loud log and
|
|
315
|
+
// bump the miss counter so the gap is observable (#4222, audit "at
|
|
316
|
+
// minimum" ask). Pre-#4222 a restart also landed here (empty map) — the
|
|
317
|
+
// durable journal is what keeps that from being the common case.
|
|
318
|
+
this.correctionMisses++;
|
|
319
|
+
this.log(
|
|
320
|
+
`buzz-mirror: CORRECTION MISS — no Buzz correlation for Telegram ` +
|
|
321
|
+
`message ${input.telegramMessageKey}; the edit could NOT be mirrored ` +
|
|
322
|
+
`(never mirrored, or evicted past MAX_TRACKED=${MAX_TRACKED}). ` +
|
|
323
|
+
`Buzz copy may be stale. total_misses=${this.correctionMisses}`,
|
|
324
|
+
);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const existing = this.correctionTimers.get(input.telegramMessageKey);
|
|
329
|
+
if (existing) clearTimeout(existing);
|
|
330
|
+
|
|
331
|
+
const timer = setTimeout(() => {
|
|
332
|
+
this.correctionTimers.delete(input.telegramMessageKey);
|
|
333
|
+
// Re-read: the mapping may have advanced (a later publish), but the
|
|
334
|
+
// targetEventId to supersede is the one current at fire time.
|
|
335
|
+
const t = this.msgToBuzz.get(input.telegramMessageKey);
|
|
336
|
+
if (!t) return;
|
|
337
|
+
this.publish(
|
|
338
|
+
{
|
|
339
|
+
channelId: t.channelId,
|
|
340
|
+
replyToEventId: t.eventId,
|
|
341
|
+
threadRootId: t.eventId,
|
|
342
|
+
payload: {
|
|
343
|
+
kind: "correction",
|
|
344
|
+
text: input.scrubbedText,
|
|
345
|
+
targetEventId: t.eventId,
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
[], // a correction is not itself re-correctable via a Telegram edit
|
|
349
|
+
);
|
|
350
|
+
}, CORRECTION_DEBOUNCE_MS);
|
|
351
|
+
if (typeof (timer as { unref?: () => void }).unref === "function") {
|
|
352
|
+
(timer as { unref: () => void }).unref();
|
|
353
|
+
}
|
|
354
|
+
this.correctionTimers.set(input.telegramMessageKey, timer);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
this.log(`buzz-mirror: mirrorCorrection threw (ignored): ${String(err)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Handle the sidecar's advisory publish outcome (buzz_publish_result). */
|
|
361
|
+
onPublishResult(msg: {
|
|
362
|
+
correlationId: string;
|
|
363
|
+
ok: boolean;
|
|
364
|
+
eventId?: string;
|
|
365
|
+
error?: string;
|
|
366
|
+
}): void {
|
|
367
|
+
const p = this.pending.get(msg.correlationId);
|
|
368
|
+
this.pending.delete(msg.correlationId);
|
|
369
|
+
if (!p) return;
|
|
370
|
+
if (!msg.ok || !msg.eventId) {
|
|
371
|
+
this.log(
|
|
372
|
+
`buzz-mirror: publish failed (correlationId=${msg.correlationId.slice(0, 8)}` +
|
|
373
|
+
`${msg.error ? ` error=${msg.error}` : ""}) — Telegram copy already delivered`,
|
|
374
|
+
);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Record the published event against each Telegram message it mirrored, so
|
|
378
|
+
// a later edit_message on any of them can target it for a correction AND a
|
|
379
|
+
// later reply whose antecedent is one of these messages can thread under it.
|
|
380
|
+
// The store persists (durable journal) + enforces the MAX_TRACKED FIFO bound.
|
|
381
|
+
//
|
|
382
|
+
// threadRoot: the root this event belongs to. If it threaded under a parent
|
|
383
|
+
// (`p.threadRootId` set) that parent's root IS this event's root; otherwise
|
|
384
|
+
// this is a fresh top-level post and its OWN id is the thread root.
|
|
385
|
+
const threadRoot = p.threadRootId ?? msg.eventId;
|
|
386
|
+
for (const key of p.telegramMessageKeys) {
|
|
387
|
+
this.msgToBuzz.set(key, { eventId: msg.eventId, channelId: p.channelId, threadRoot });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private publish(
|
|
392
|
+
fields: Omit<OutboundToBuzzMessage, "type" | "correlationId" | "agentName">,
|
|
393
|
+
telegramMessageKeys: string[],
|
|
394
|
+
): void {
|
|
395
|
+
if (!this.sender) {
|
|
396
|
+
this.log("buzz-mirror: no Buzz peer connected — mirror dropped (Telegram copy delivered)");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const correlationId = randomUUID();
|
|
400
|
+
const msg: OutboundToBuzzMessage = {
|
|
401
|
+
type: "outbound_to_buzz",
|
|
402
|
+
correlationId,
|
|
403
|
+
agentName: this.cfg.agentName,
|
|
404
|
+
...fields,
|
|
405
|
+
};
|
|
406
|
+
const sent = this.sender(msg);
|
|
407
|
+
if (!sent) {
|
|
408
|
+
this.log("buzz-mirror: Buzz peer send returned false — mirror dropped (Telegram copy delivered)");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.pending.set(correlationId, {
|
|
412
|
+
channelId: fields.channelId,
|
|
413
|
+
telegramMessageKeys,
|
|
414
|
+
threadRootId: fields.threadRootId,
|
|
415
|
+
});
|
|
416
|
+
this.pendingOrder.push(correlationId);
|
|
417
|
+
this.evict(this.pending, this.pendingOrder);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let singleton: BuzzMirror | null = null;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Initialize the hub mirror. Called by the gateway ONLY when
|
|
425
|
+
* `channels.buzz.enabled === true`. Idempotent-ish: a second call replaces the
|
|
426
|
+
* instance (used by tests). When never called, `getBuzzMirror()` stays null and
|
|
427
|
+
* every hook site is a no-op.
|
|
428
|
+
*/
|
|
429
|
+
export function initBuzzMirror(cfg: BuzzMirrorConfig): BuzzMirror {
|
|
430
|
+
singleton?.close(); // release any prior instance's journal fd + timers
|
|
431
|
+
singleton = new BuzzMirror(cfg);
|
|
432
|
+
return singleton;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function getBuzzMirror(): BuzzMirror | null {
|
|
436
|
+
return singleton;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Resolve the durable msg→Buzz correlation journal path from env (#4222). Lives
|
|
441
|
+
* under the same `$TELEGRAM_STATE_DIR/buzz/` dir as the sidecar's dedup journal
|
|
442
|
+
* but at a DISTINCT filename — a collision on `journal.jsonl` would corrupt
|
|
443
|
+
* both. `BUZZ_MIRROR_CORRELATION_PATH` overrides. When `TELEGRAM_STATE_DIR` is
|
|
444
|
+
* unset (dev/one-shot), returns undefined so the store degrades to in-memory
|
|
445
|
+
* only rather than scattering a journal under the home dir.
|
|
446
|
+
*/
|
|
447
|
+
export function resolveCorrelationJournalPath(
|
|
448
|
+
env: Record<string, string | undefined> = process.env,
|
|
449
|
+
): string | undefined {
|
|
450
|
+
const override = env.BUZZ_MIRROR_CORRELATION_PATH?.trim();
|
|
451
|
+
if (override) return override;
|
|
452
|
+
const stateDir = env.TELEGRAM_STATE_DIR?.trim();
|
|
453
|
+
if (!stateDir) return undefined;
|
|
454
|
+
return join(stateDir, "buzz", "mirror-correlation.jsonl");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Boot the hub mirror from env at gateway startup — the single wiring seam the
|
|
459
|
+
* gateway calls. DARK BY DEFAULT and by construction: returns null (leaving
|
|
460
|
+
* `getBuzzMirror()` null, every hook site a no-op) unless BOTH hold —
|
|
461
|
+
* (1) `BUZZ_ENABLED` is truthy, AND
|
|
462
|
+
* (2) the S2-narrowed mode (`parseConfiguredMirrorMode`) is `both`;
|
|
463
|
+
* a configured `origin`/`off` degrades to dark, never a half-live mirror.
|
|
464
|
+
* The Buzz env vars are projected at compose time from `channels.buzz`
|
|
465
|
+
* (src/agents/compose.ts), with BUZZ_ENABLED=1 gated on `enabled === true`;
|
|
466
|
+
* an enabled:false/absent block leaves them unset, so for those agents this
|
|
467
|
+
* is inert by construction. `sender` is the transport to the duplex peer
|
|
468
|
+
* (`ipcServer.sendToBuzzPeer`). Returns the booted instance for tests.
|
|
469
|
+
*/
|
|
470
|
+
export function maybeBootBuzzMirror(
|
|
471
|
+
sender: BuzzPeerSender,
|
|
472
|
+
env: Record<string, string | undefined> = process.env,
|
|
473
|
+
): BuzzMirror | null {
|
|
474
|
+
if (env.BUZZ_ENABLED !== "1" && env.BUZZ_ENABLED !== "true") return null;
|
|
475
|
+
const mode = parseConfiguredMirrorMode(env.BUZZ_MIRROR);
|
|
476
|
+
if (mode !== "both") return null;
|
|
477
|
+
const bm = initBuzzMirror({
|
|
478
|
+
mode,
|
|
479
|
+
agentName: env.SWITCHROOM_AGENT_NAME?.trim() ?? "",
|
|
480
|
+
defaultChannelId: env.BUZZ_CHANNEL_IDS?.trim() ?? "",
|
|
481
|
+
correlationJournalPath: resolveCorrelationJournalPath(env),
|
|
482
|
+
log: (m) => process.stderr.write(`telegram gateway: buzz-mirror — ${m}\n`),
|
|
483
|
+
});
|
|
484
|
+
bm.attachSender(sender);
|
|
485
|
+
return bm;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Test-only: tear down the singleton so cases don't leak state into each other. */
|
|
489
|
+
export function __resetBuzzMirrorForTests(): void {
|
|
490
|
+
singleton?.close();
|
|
491
|
+
singleton = null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
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;
|