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
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
88
88
|
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
89
89
|
import { resolveReplyOwnerTurnWith, SUPERSEDE_OPEN_CAP_MS, SUPERSEDE_GRACE_MS } from './reply-owner-wiring.js'
|
|
90
|
+
import { getBuzzMirror, maybeBootBuzzMirror } from './buzz-mirror.js'
|
|
90
91
|
import { subagentReplyAuthority } from './subagent-reply-authority.js'
|
|
91
92
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
92
93
|
import {
|
|
@@ -410,6 +411,7 @@ import {
|
|
|
410
411
|
import { createMcpFailureHook } from './mcp-failure-hook.js'
|
|
411
412
|
import { pendingUserNoticeGate } from '../pending-user-notice.js'
|
|
412
413
|
import { recordOperatorEvent } from '../operator-events-history.js'
|
|
414
|
+
import { emitTransportTransientEvent, flushDeferredUserNotices, type UserFailureNoticeDeps } from './user-failure-notices.js'
|
|
413
415
|
import {
|
|
414
416
|
parseLlmError,
|
|
415
417
|
renderLlmErrorSafe,
|
|
@@ -960,6 +962,8 @@ import {
|
|
|
960
962
|
buildResumeDeferredReportInbound,
|
|
961
963
|
decideBootResumeKind,
|
|
962
964
|
} from './resume-inbound-builder.js'
|
|
965
|
+
import { maybeQueueBootBriefing } from './boot-briefing-wiring.js'
|
|
966
|
+
import { writePendingTurnEnv } from './pending-turn-env.js'
|
|
963
967
|
import {
|
|
964
968
|
createBridgeDeadWatchdog,
|
|
965
969
|
consumeBridgeDeadEscalationMarker,
|
|
@@ -2034,35 +2038,9 @@ if (isGatewayMain) try { // #2996 P0c: gated — opens bun:sqlite + writes .pen
|
|
|
2034
2038
|
|
|
2035
2039
|
// Diagnostic env file (one-shot, sourced by start.sh) — kept for the
|
|
2036
2040
|
// wake-audit context. The injected inbound above is the real wake signal;
|
|
2037
|
-
// these vars are passive context only.
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
if (pending != null) {
|
|
2041
|
-
const lines = [
|
|
2042
|
-
`SWITCHROOM_PENDING_TURN=true`,
|
|
2043
|
-
`SWITCHROOM_PENDING_TURN_KEY=${pending.turn_key}`,
|
|
2044
|
-
`SWITCHROOM_PENDING_CHAT_ID=${pending.chat_id}`,
|
|
2045
|
-
pending.thread_id != null ? `SWITCHROOM_PENDING_THREAD_ID=${pending.thread_id}` : `SWITCHROOM_PENDING_THREAD_ID=`,
|
|
2046
|
-
pending.last_user_msg_id != null ? `SWITCHROOM_PENDING_USER_MSG_ID=${pending.last_user_msg_id}` : `SWITCHROOM_PENDING_USER_MSG_ID=`,
|
|
2047
|
-
`SWITCHROOM_PENDING_ENDED_VIA=${pending.ended_via ?? 'unknown'}`,
|
|
2048
|
-
`SWITCHROOM_PENDING_STARTED_AT=${pending.started_at}`,
|
|
2049
|
-
pending.interrupt_reason != null ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending.interrupt_reason}` : `SWITCHROOM_PENDING_INTERRUPT_REASON=`,
|
|
2050
|
-
]
|
|
2051
|
-
// Atomic write: tmp + rename. Without this, a crash mid-write
|
|
2052
|
-
// (power loss, OOM, panic) leaves a truncated `.pending-turn.env`
|
|
2053
|
-
// that start.sh `source`s — partial SWITCHROOM_PENDING_* vars
|
|
2054
|
-
// or a malformed line break shell parsing inside the source.
|
|
2055
|
-
const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`
|
|
2056
|
-
writeFileSync(pendingEnvTmp, lines.join('\n') + '\n', { mode: 0o600 })
|
|
2057
|
-
renameSync(pendingEnvTmp, pendingEnvPath)
|
|
2058
|
-
process.stderr.write(`telegram gateway: pending-turn env written to ${pendingEnvPath} turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'}\n`)
|
|
2059
|
-
} else if (existsSync(pendingEnvPath)) {
|
|
2060
|
-
rmSync(pendingEnvPath, { force: true })
|
|
2061
|
-
process.stderr.write(`telegram gateway: pending-turn env cleared (clean previous shutdown)\n`)
|
|
2062
|
-
}
|
|
2063
|
-
} catch (err) {
|
|
2064
|
-
process.stderr.write(`telegram gateway: pending-turn env write failed (${(err as Error).message})\n`)
|
|
2065
|
-
}
|
|
2041
|
+
// these vars are passive context only. Extracted to pending-turn-env.ts
|
|
2042
|
+
// (atomic tmp+rename writer; never throws).
|
|
2043
|
+
writePendingTurnEnv(agentDir, pending)
|
|
2066
2044
|
} catch (err) {
|
|
2067
2045
|
process.stderr.write(`telegram gateway: turn-registry init failed (${(err as Error).message}) — turn tracking disabled\n`)
|
|
2068
2046
|
turnsDb = null
|
|
@@ -3690,7 +3668,7 @@ export type CurrentTurn = {
|
|
|
3690
3668
|
// registry's older entries (and any hand-built test turn) tolerate its
|
|
3691
3669
|
// absence; `emissionAuthorityFor` lazily backfills one when missing.
|
|
3692
3670
|
emissionAuthority?: EmissionAuthority
|
|
3693
|
-
}
|
|
3671
|
+
readonly originChannel: 'telegram' | 'buzz'; readonly buzzCoords?: { channelId: string; eventId: string; threadRoot: string } } // Buzz Phase 2a/2b: origin provenance stamped ONCE at the turn ctor via parseChannelOrigin(ev.rawContent) (channel-route.ts); buzzCoords present IFF originChannel==='buzz'. `readonly` enforces single-writer immutability (MINOR-2) — no `.originChannel=`/`.buzzCoords=` reassignment compiles. Types inlined + brace merged to hold gateway.ts at its zero-headroom ratchet (switchroom#2996); structurally identical to channel-route.ts Channel/BuzzCoords (type-identity asserted in channel-route.ts, MINOR-3).
|
|
3694
3672
|
|
|
3695
3673
|
// PR-4e — the singleton `currentTurn` is RETAINED as (a) the flag-OFF store and
|
|
3696
3674
|
// (b) the flag-ON "most-recent-set" MIRROR. Every GLOBAL-liveness read in this
|
|
@@ -7886,6 +7864,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7886
7864
|
// 429 metrics — defense in depth, no secret survives in ANY downstream sink.
|
|
7887
7865
|
event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }
|
|
7888
7866
|
|
|
7867
|
+
// transport-transient (mid-response stream abort — `server_error`/`api_error`
|
|
7868
|
+
// with no HTTP status): no broadcast card, no Reauth; record + deferred user
|
|
7869
|
+
// notice, burst escalates. Orchestration in user-failure-notices.ts.
|
|
7870
|
+
if (kind === 'transport-transient') {
|
|
7871
|
+
emitTransportTransientEvent(event, userFailureNoticeDeps())
|
|
7872
|
+
return
|
|
7873
|
+
}
|
|
7874
|
+
|
|
7889
7875
|
// ── 429 throttle tier (operator spec: "retry in place under 5 min, else
|
|
7890
7876
|
// mark + failover, honest reset messaging") ────────────────────────────
|
|
7891
7877
|
// A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
|
|
@@ -8269,56 +8255,47 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
8269
8255
|
// liveness only THAT topic's turn end resolves it (full rationale on
|
|
8270
8256
|
// `PendingUserNotice.key`). `undefined` (no live turn — the event is
|
|
8271
8257
|
// agent-level, empty wire chatId) keeps the legacy agent-wide resolution.
|
|
8272
|
-
const
|
|
8273
|
-
const noticeKey =
|
|
8274
|
-
|
|
8275
|
-
pendingUserNoticeGate.schedule({
|
|
8276
|
-
chatIds: userNoticeChats,
|
|
8277
|
-
text: renderUserFacingFailureNotice(),
|
|
8278
|
-
agent,
|
|
8279
|
-
kind,
|
|
8280
|
-
atMs: Date.now(),
|
|
8281
|
-
key: noticeKey,
|
|
8282
|
-
})
|
|
8258
|
+
const noticeDeps = userFailureNoticeDeps()
|
|
8259
|
+
const noticeKey = noticeDeps.liveTurnKey()
|
|
8260
|
+
noticeDeps.scheduleUserNotice({ chatIds: userNoticeChats, agent, kind, key: noticeKey, atMs: Date.now() })
|
|
8283
8261
|
process.stderr.write(
|
|
8284
8262
|
`telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length} topic=${noticeKey ?? '-'}\n`,
|
|
8285
8263
|
)
|
|
8286
8264
|
}
|
|
8287
8265
|
}
|
|
8288
8266
|
|
|
8267
|
+
/**
|
|
8268
|
+
* Live gateway deps for the plain user-failure-notice subsystem
|
|
8269
|
+
* (`user-failure-notices.ts`): transport-transient handling AND the turn-end
|
|
8270
|
+
* flush share this one wiring. Every side effect is a closure over live state.
|
|
8271
|
+
*/
|
|
8272
|
+
function userFailureNoticeDeps(): UserFailureNoticeDeps {
|
|
8273
|
+
return {
|
|
8274
|
+
now: () => Date.now(),
|
|
8275
|
+
allowFrom: () => loadAccess().allowFrom,
|
|
8276
|
+
liveTurnKey: () => currentTurn != null ? statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId) : undefined,
|
|
8277
|
+
record: (e) => { try { recordOperatorEvent(e) } catch { /* history best-effort */ } },
|
|
8278
|
+
scheduleUserNotice: (i) => pendingUserNoticeGate.schedule({ ...i, text: renderUserFacingFailureNotice() }),
|
|
8279
|
+
resolveNotices: (delivered, key) => pendingUserNoticeGate.resolveTurnEnd(key, delivered),
|
|
8280
|
+
send: (chat_id, text, keyboard) => {
|
|
8281
|
+
const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: resolveAgentOutboundTopic({ kind: 'compact-watchdog' }), supergroupChatId: resolveAgentSupergroupChatId() })
|
|
8282
|
+
const opts = { ...(keyboard ? { reply_markup: keyboard } : {}), ...(thread != null ? { message_thread_id: thread } : {}) }
|
|
8283
|
+
// allow-raw-bot-api: user-failure-notice / transport escalation send; topic-aware opts
|
|
8284
|
+
void bot.api.sendRichMessage(chat_id, richMessage(text), opts as never).catch((e) => process.stderr.write(`telegram gateway: user-failure-notice send to ${chat_id} failed: ${e}\n`))
|
|
8285
|
+
},
|
|
8286
|
+
log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
8287
|
+
}
|
|
8288
|
+
}
|
|
8289
|
+
|
|
8289
8290
|
/**
|
|
8290
8291
|
* Turn-end resolution of deferred user failure notices (#3293 finding 1).
|
|
8291
8292
|
* Called from `endCurrentTurnAtomic` — the ONE funnel every turn-end path
|
|
8292
8293
|
* passes through. `turnDeliveredReply` is `finalAnswerDelivered || replyCalled`
|
|
8293
8294
|
* (the model explicitly replied → the turn recovered → notices are dropped by
|
|
8294
|
-
* the gate).
|
|
8295
|
-
* non-operator chats, so the user notice fires IFF the turn genuinely died.
|
|
8296
|
-
* `turnKey` (#3294) scopes resolution to the ending turn's topic — a concurrent
|
|
8297
|
-
* topic's pending notice is left for its own turn end under keyed liveness.
|
|
8295
|
+
* the gate). The send loop lives in `user-failure-notices.ts`.
|
|
8298
8296
|
*/
|
|
8299
8297
|
function flushPendingUserFailureNotices(turnDeliveredReply: boolean, turnKey: string): void {
|
|
8300
|
-
|
|
8301
|
-
if (notices.length === 0) return
|
|
8302
|
-
const noticeTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
|
|
8303
|
-
const noticeSupergroup = resolveAgentSupergroupChatId()
|
|
8304
|
-
for (const notice of notices) {
|
|
8305
|
-
process.stderr.write(
|
|
8306
|
-
`telegram gateway: user-notice flush (turn died reply-less) agent=${notice.agent} kind=${notice.kind} chats=${notice.chatIds.length}\n`,
|
|
8307
|
-
)
|
|
8308
|
-
for (const chat_id of notice.chatIds) {
|
|
8309
|
-
const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: noticeTopic, supergroupChatId: noticeSupergroup })
|
|
8310
|
-
const opts = {
|
|
8311
|
-
...(thread != null ? { message_thread_id: thread } : {}),
|
|
8312
|
-
}
|
|
8313
|
-
// allow-raw-bot-api: deferred user-notice flush loop; topic-aware opts
|
|
8314
|
-
void bot.api.sendRichMessage(chat_id, richMessage(notice.text), opts as never)
|
|
8315
|
-
.catch(e => {
|
|
8316
|
-
process.stderr.write(
|
|
8317
|
-
`telegram gateway: user-notice send to ${chat_id} failed agent=${notice.agent} kind=${notice.kind}: ${e}\n`,
|
|
8318
|
-
)
|
|
8319
|
-
})
|
|
8320
|
-
}
|
|
8321
|
-
}
|
|
8298
|
+
flushDeferredUserNotices(turnDeliveredReply, turnKey, userFailureNoticeDeps())
|
|
8322
8299
|
}
|
|
8323
8300
|
|
|
8324
8301
|
/**
|
|
@@ -10196,6 +10173,22 @@ if (isGatewayMain && !STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
|
10196
10173
|
setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref()
|
|
10197
10174
|
}
|
|
10198
10175
|
|
|
10176
|
+
// Gateway boot briefing (session_continuity.briefing: gateway — default
|
|
10177
|
+
// legacy/off): a surface-scoped reorientation turn assembled from the durable
|
|
10178
|
+
// history DB and delivered as <channel source="boot_briefing"> over the spool
|
|
10179
|
+
// transport. Queued BEFORE the resume inbound below so a session that has
|
|
10180
|
+
// both reorients first, then resumes. All decision/build logic (flag,
|
|
10181
|
+
// --continue suppression, resume-window dedup, budget) lives in
|
|
10182
|
+
// boot-briefing-wiring.ts / boot-briefing-builder.ts; never throws.
|
|
10183
|
+
if (isGatewayMain && HISTORY_ENABLED) {
|
|
10184
|
+
maybeQueueBootBriefing({
|
|
10185
|
+
env: process.env,
|
|
10186
|
+
stateDir: STATE_DIR,
|
|
10187
|
+
resumeMsg: bootResumeInbound?.msg ?? null,
|
|
10188
|
+
put: (agent, msg) =>
|
|
10189
|
+
inboundSpool != null ? inboundSpool.put(agent, msg) : pendingInboundBuffer.push(agent, msg),
|
|
10190
|
+
})
|
|
10191
|
+
}
|
|
10199
10192
|
// Honest-restart-resume: inject the boot resume/report inbound built by the
|
|
10200
10193
|
// registry classifier above. When the spool exists we only PUT it (the
|
|
10201
10194
|
// boot-replay loop below pulls it into the in-memory buffer exactly once via
|
|
@@ -11760,8 +11753,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
11760
11753
|
)
|
|
11761
11754
|
},
|
|
11762
11755
|
|
|
11756
|
+
// Buzz Phase 2b: the duplex peer's advisory publish outcome — no-op unless the hub mirror booted.
|
|
11757
|
+
onBuzzPublishResult: (_c, m) => getBuzzMirror()?.onPublishResult(m),
|
|
11763
11758
|
log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
|
|
11764
11759
|
})
|
|
11760
|
+
// Buzz Phase 2b: boot the hub mirror (dark unless channels.buzz.enabled + mode both); wires the peer transport, else a no-op.
|
|
11761
|
+
if (isGatewayMain) maybeBootBuzzMirror((msg) => ipcServer.sendToBuzzPeer(msg))
|
|
11765
11762
|
|
|
11766
11763
|
// ─── Webhook ingest server (RFC webhook-via-gateway-socket) ───────────────
|
|
11767
11764
|
// Under the Docker runtime the host-side web receiver runs as the operator
|
|
@@ -12504,21 +12501,15 @@ async function executeReply(
|
|
|
12504
12501
|
function gatewaySendReplyDeps(): SendReplyGatewayDeps {
|
|
12505
12502
|
return {
|
|
12506
12503
|
// the ONE live instances (Amendment 1/9 — never re-new in a module)
|
|
12507
|
-
outboundDedup,
|
|
12508
|
-
|
|
12509
|
-
|
|
12510
|
-
|
|
12511
|
-
|
|
12512
|
-
lastPtyPreviewByChat,
|
|
12513
|
-
voiceOnDemandCache,
|
|
12514
|
-
voicePreSynthQueue,
|
|
12515
|
-
pendingProgress,
|
|
12516
|
-
signalTracker,
|
|
12504
|
+
outboundDedup, flushedTurnSupersede,
|
|
12505
|
+
firstTextReplyLogged, suppressPtyPreview,
|
|
12506
|
+
activeDraftStreams, lastPtyPreviewByChat,
|
|
12507
|
+
voiceOnDemandCache, voicePreSynthQueue,
|
|
12508
|
+
pendingProgress, signalTracker,
|
|
12517
12509
|
silencePoke,
|
|
12518
12510
|
getCurrentTurn: () => currentTurn,
|
|
12519
12511
|
getLastActiveTurnChatId: () => lastActiveTurnChatId,
|
|
12520
|
-
HISTORY_ENABLED,
|
|
12521
|
-
TURN_ORIGIN_ROUTING_ENABLED,
|
|
12512
|
+
HISTORY_ENABLED, TURN_ORIGIN_ROUTING_ENABLED,
|
|
12522
12513
|
AUTOCLASSIFY_MIDTURN_SHADOW,
|
|
12523
12514
|
MAX_ATTACHMENT_BYTES,
|
|
12524
12515
|
MAX_CHUNK_LIMIT,
|
|
@@ -12533,8 +12524,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
|
|
|
12533
12524
|
statusKey,
|
|
12534
12525
|
streamKey,
|
|
12535
12526
|
resolveReplyOwnerTurn,
|
|
12536
|
-
findTurnByOriginId,
|
|
12537
|
-
findTurnByQuotedMessageId,
|
|
12527
|
+
findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat,
|
|
12538
12528
|
resolveAnswerThreadWithLog,
|
|
12539
12529
|
resolveThreadId,
|
|
12540
12530
|
getLatestInboundMessageId,
|
|
@@ -13282,6 +13272,8 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
13282
13272
|
process.stderr.write(`telegram gateway: history recordEdit failed: ${err}\n`)
|
|
13283
13273
|
}
|
|
13284
13274
|
}
|
|
13275
|
+
// Buzz Phase 2b: mirror the edit as a debounced Buzz correction keyed on the edited id — no-op when Buzz is dark or the message was never published; post-delivery, never throws.
|
|
13276
|
+
getBuzzMirror()?.mirrorCorrection({ telegramMessageKey: `${String(args.chat_id ?? '')}:${Number(args.message_id)}`, scrubbedText: editRawText })
|
|
13285
13277
|
return { content: [{ type: 'text', text: `edited (id: ${id})` }] }
|
|
13286
13278
|
}
|
|
13287
13279
|
|
|
@@ -96,6 +96,15 @@ export function spoolId(msg: InboundMessage): string {
|
|
|
96
96
|
) {
|
|
97
97
|
return `s:resume:${msg.meta.resume_turn_key}`
|
|
98
98
|
}
|
|
99
|
+
// Gateway boot briefing (session_continuity.briefing: gateway): keyed
|
|
100
|
+
// per chat, NOT per boot — the synthetic messageId is the boot's ts, so
|
|
101
|
+
// without this a multi-restart sequence would stack one briefing per
|
|
102
|
+
// boot. One live briefing per chat at a time; once delivered (acked) a
|
|
103
|
+
// later boot can mint a fresh one. Staleness is separately bounded by
|
|
104
|
+
// the entry's meta.expiresAt TTL (see liveEntries).
|
|
105
|
+
if (msg.meta?.source === 'boot_briefing') {
|
|
106
|
+
return `s:boot-briefing:${msg.chatId}`
|
|
107
|
+
}
|
|
99
108
|
// Cron BOOT-REPLAY (#2793 part B): a scheduled fire that the boot
|
|
100
109
|
// replay re-injects because it was missed across a restart. Keyed on
|
|
101
110
|
// the minute-aligned fire it is replaying (`replay_fire_ms`) plus the
|
|
@@ -488,7 +497,30 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
488
497
|
return {
|
|
489
498
|
put(agent, msg) {
|
|
490
499
|
const id = spoolId(msg)
|
|
491
|
-
|
|
500
|
+
const existing = live.get(id)
|
|
501
|
+
if (existing != null) {
|
|
502
|
+
// Dedup: the same logical event is already spooled and un-acked, so
|
|
503
|
+
// by default drop the duplicate (retried synthetics of one event).
|
|
504
|
+
//
|
|
505
|
+
// Exception (#4246): a boot_briefing is keyed per chat, NOT per boot
|
|
506
|
+
// (spoolId `s:boot-briefing:<chatId>`), so a LATER boot re-puts a
|
|
507
|
+
// FRESHER briefing under the same id. The strict-dedup path kept
|
|
508
|
+
// boot-1's now-stale text, so a boot-2 delivery carried boot-1's
|
|
509
|
+
// briefing (only self-corrected by the 60-min TTL). Refresh the
|
|
510
|
+
// entry's payload in place so the newest briefing wins, keeping the
|
|
511
|
+
// original firstAt so escalation timing isn't reset by repeated
|
|
512
|
+
// restarts. The refreshed put is re-appended durably (hydrate's
|
|
513
|
+
// "last put for an id wins" restores it across a crash). No new live
|
|
514
|
+
// entry is created, so this can never double-deliver.
|
|
515
|
+
if (msg.meta?.source === 'boot_briefing') {
|
|
516
|
+
existing.agent = agent
|
|
517
|
+
existing.msg = msg
|
|
518
|
+
appendRecord({ t: 'put', id, agent, msg, firstAt: existing.firstAt })
|
|
519
|
+
maybeCompact()
|
|
520
|
+
return true
|
|
521
|
+
}
|
|
522
|
+
return false
|
|
523
|
+
}
|
|
492
524
|
const firstAt = now()
|
|
493
525
|
live.set(id, { agent, msg, firstAt })
|
|
494
526
|
appendRecord({ t: 'put', id, agent, msg, firstAt })
|
|
@@ -169,6 +169,43 @@ export interface PreApprovedResultEvent {
|
|
|
169
169
|
preApproved: boolean;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Buzz co-channel — Phase 2b. Gateway → Buzz-sidecar peer: a request to
|
|
174
|
+
* publish (or correct) a Nostr channel message. Sent ONLY to the single
|
|
175
|
+
* duplex peer client that announced itself via `hello_buzz_peer`, never to a
|
|
176
|
+
* registered agent bridge. The sidecar's `publisher.ts` is the sole content-
|
|
177
|
+
* signer: it re-scrubs `payload.text` through `detectSecrets` before signing
|
|
178
|
+
* and answers with a `buzz_publish_result` carrying the same `correlationId`.
|
|
179
|
+
*
|
|
180
|
+
* `payload.kind` is restricted to `message` | `correction` in Phase 2b
|
|
181
|
+
* (reaction / approval / patch are deferred per design §3.1 / F4 — the Buzz
|
|
182
|
+
* desktop renders only a fixed content-kind allowlist). A `correction`
|
|
183
|
+
* carries the `targetEventId` of the already-published event it supersedes.
|
|
184
|
+
*/
|
|
185
|
+
export interface OutboundToBuzzMessage {
|
|
186
|
+
type: "outbound_to_buzz";
|
|
187
|
+
/** Caller-generated id, echoed back in `buzz_publish_result`. */
|
|
188
|
+
correlationId: string;
|
|
189
|
+
/**
|
|
190
|
+
* The publishing agent. Validated for wire SHAPE only (AGENT_NAME_RE in
|
|
191
|
+
* `isValidClientToGateway`) and stamped by the hub itself (buzz-mirror sets it
|
|
192
|
+
* from its own `agentName`), so it is diagnostic here — the gateway does NOT
|
|
193
|
+
* cross-check it against a configured own-name (createIpcServer holds no such
|
|
194
|
+
* name). Impersonation is prevented structurally instead: only the registered
|
|
195
|
+
* duplex peer connection receives `outbound_to_buzz` and may answer it.
|
|
196
|
+
*/
|
|
197
|
+
agentName: string;
|
|
198
|
+
/** Target NIP-29 channel (group) id, `["h", …]`. */
|
|
199
|
+
channelId: string;
|
|
200
|
+
/** NIP-10 reply target (the Buzz event being answered), when threading. */
|
|
201
|
+
replyToEventId?: string;
|
|
202
|
+
/** NIP-10 thread root, when threading into an existing conversation. */
|
|
203
|
+
threadRootId?: string;
|
|
204
|
+
payload:
|
|
205
|
+
| { kind: "message"; text: string }
|
|
206
|
+
| { kind: "correction"; text: string; targetEventId: string };
|
|
207
|
+
}
|
|
208
|
+
|
|
172
209
|
export type GatewayToClient =
|
|
173
210
|
| InboundMessage
|
|
174
211
|
| PermissionEvent
|
|
@@ -181,7 +218,8 @@ export type GatewayToClient =
|
|
|
181
218
|
| RolloutStatusPostedEvent
|
|
182
219
|
| RolloutStatusEditedEvent
|
|
183
220
|
| PendingPermissionStatusEvent
|
|
184
|
-
| PreApprovedResultEvent
|
|
221
|
+
| PreApprovedResultEvent
|
|
222
|
+
| OutboundToBuzzMessage;
|
|
185
223
|
|
|
186
224
|
// === Bridge (Client) -> Gateway messages ===
|
|
187
225
|
|
|
@@ -671,6 +709,45 @@ export interface CheckPreApprovedMessage {
|
|
|
671
709
|
unifiedDiff: string;
|
|
672
710
|
}
|
|
673
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Buzz co-channel — Phase 2b. The Buzz sidecar's one-time announcement that
|
|
714
|
+
* this connection is the DUPLEX publish peer, not an agent bridge. It carries
|
|
715
|
+
* NO `agentIndex` claim and NEVER registers a topic: the gateway parks it in a
|
|
716
|
+
* dedicated `buzzPeerClient` slot, marks it watchdog-exempt (it has no live
|
|
717
|
+
* `agentName`), and refuses a subsequent `register` on the same connection
|
|
718
|
+
* (and, conversely, refuses `hello_buzz_peer` on a client that already
|
|
719
|
+
* `register`ed) — the peer role and the agent-bridge role are mutually
|
|
720
|
+
* exclusive per design §3.2 / S7. `agentName` here is the fleet agent whose
|
|
721
|
+
* outbound this peer publishes; it is validated for wire SHAPE only
|
|
722
|
+
* (AGENT_NAME_RE) and used for logging — the gateway does NOT cross-check it
|
|
723
|
+
* against a configured own-name (it holds none). The peer role is secured
|
|
724
|
+
* structurally: a live peer cannot be displaced by a fresh hello, and only the
|
|
725
|
+
* peer connection may send `buzz_publish_result`.
|
|
726
|
+
*/
|
|
727
|
+
export interface HelloBuzzPeerMessage {
|
|
728
|
+
type: "hello_buzz_peer";
|
|
729
|
+
agentName: string;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Buzz co-channel — Phase 2b. The sidecar's reply to an `outbound_to_buzz`:
|
|
734
|
+
* the outcome of the publish attempt. Advisory ONLY under `both` mode — the
|
|
735
|
+
* Telegram copy is the guaranteed delivery, so a `buzz_publish_result` with
|
|
736
|
+
* `ok: false` never fails or retries the answer, it only feeds the hub's
|
|
737
|
+
* correlation map (freeing the pending slot, logging, and — on success —
|
|
738
|
+
* recording the published `eventId` so a later `correction` can target it).
|
|
739
|
+
*/
|
|
740
|
+
export interface BuzzPublishResultMessage {
|
|
741
|
+
type: "buzz_publish_result";
|
|
742
|
+
/** Echoes the `correlationId` from the originating `outbound_to_buzz`. */
|
|
743
|
+
correlationId: string;
|
|
744
|
+
ok: boolean;
|
|
745
|
+
/** The locally-computed (`getEventHash`) id of the signed event, on success. */
|
|
746
|
+
eventId?: string;
|
|
747
|
+
/** Diagnostic detail on failure (never carries scrubbed content). */
|
|
748
|
+
error?: string;
|
|
749
|
+
}
|
|
750
|
+
|
|
674
751
|
export type ClientToGateway =
|
|
675
752
|
| RegisterMessage
|
|
676
753
|
| ToolCallMessage
|
|
@@ -692,4 +769,6 @@ export type ClientToGateway =
|
|
|
692
769
|
| RolloutStatusPostMessage
|
|
693
770
|
| RolloutStatusEditMessage
|
|
694
771
|
| QueryPendingPermissionMessage
|
|
695
|
-
| CheckPreApprovedMessage
|
|
772
|
+
| CheckPreApprovedMessage
|
|
773
|
+
| HelloBuzzPeerMessage
|
|
774
|
+
| BuzzPublishResultMessage;
|