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,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
|
+
}
|