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.
Files changed (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. 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 {
@@ -126,6 +127,7 @@ import {
126
127
  routeInbound,
127
128
  admitInbound,
128
129
  buildReplyForwardContext,
130
+ resolveReplyToFromBuffer,
129
131
  buildInboundEnvelope,
130
132
  INBOUND_ROUTER_V2,
131
133
  runPreTurnIntercepts,
@@ -410,6 +412,7 @@ import {
410
412
  import { createMcpFailureHook } from './mcp-failure-hook.js'
411
413
  import { pendingUserNoticeGate } from '../pending-user-notice.js'
412
414
  import { recordOperatorEvent } from '../operator-events-history.js'
415
+ import { emitTransportTransientEvent, flushDeferredUserNotices, type UserFailureNoticeDeps } from './user-failure-notices.js'
413
416
  import {
414
417
  parseLlmError,
415
418
  renderLlmErrorSafe,
@@ -431,7 +434,7 @@ import { parseLitellmNoticeWindowMs } from '../litellm-local-notice.js'
431
434
  import { createLitellmLocalNoticeRunner, decideRateLimitedSurface } from './litellm-local-notice-wiring.js'
432
435
  import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
433
436
  import { startRestartWatchdog } from './restart-watchdog.js'
434
- import { validateStringArray } from './access-validator.js'
437
+ import { createAccessStore } from './access-store.js'
435
438
 
436
439
  /**
437
440
  * Truncation cap for the `reply_to_text` channel-meta attribute (issue #119).
@@ -960,6 +963,8 @@ import {
960
963
  buildResumeDeferredReportInbound,
961
964
  decideBootResumeKind,
962
965
  } from './resume-inbound-builder.js'
966
+ import { maybeQueueBootBriefing } from './boot-briefing-wiring.js'
967
+ import { writePendingTurnEnv } from './pending-turn-env.js'
963
968
  import {
964
969
  createBridgeDeadWatchdog,
965
970
  consumeBridgeDeadEscalationMarker,
@@ -1532,10 +1537,6 @@ export type Access = {
1532
1537
  }
1533
1538
  }
1534
1539
 
1535
- function defaultAccess(): Access {
1536
- return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
1537
- }
1538
-
1539
1540
  // Rich-message wire cap (#2669): the rich path allows 32768 UTF-8 chars.
1540
1541
  const MAX_CHUNK_LIMIT = RICH_MESSAGE_MAX_CHARS
1541
1542
  const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
@@ -1576,90 +1577,18 @@ function assertSendable(f: string): void {
1576
1577
  }
1577
1578
  }
1578
1579
 
1579
- function readAccessFile(): Access {
1580
- try {
1581
- const raw = readFileSync(ACCESS_FILE, 'utf8')
1582
- const parsed = JSON.parse(raw) as Partial<Access>
1583
- const allowFrom = validateStringArray('allowFrom', parsed.allowFrom ?? [])
1584
- const groups: Record<string, GroupPolicy> = {}
1585
- for (const [chatId, policy] of Object.entries(parsed.groups ?? {})) {
1586
- groups[chatId] = {
1587
- ...policy,
1588
- allowFrom: validateStringArray(`groups.${chatId}.allowFrom`, policy.allowFrom ?? []),
1589
- }
1590
- }
1591
- return {
1592
- dmPolicy: parsed.dmPolicy ?? 'pairing',
1593
- allowFrom,
1594
- groups,
1595
- pending: parsed.pending ?? {},
1596
- mentionPatterns: parsed.mentionPatterns,
1597
- ackReaction: parsed.ackReaction,
1598
- replyToMode: parsed.replyToMode,
1599
- textChunkLimit: parsed.textChunkLimit,
1600
- chunkMode: parsed.chunkMode,
1601
- parseMode: parsed.parseMode,
1602
- disableLinkPreview: parsed.disableLinkPreview,
1603
- coalescingGapMs: parsed.coalescingGapMs,
1604
- litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
1605
- coalesceMaxAttachments: parsed.coalesceMaxAttachments,
1606
- interruptSafeBoundary: parsed.interruptSafeBoundary,
1607
- interruptMaxWaitMs: parsed.interruptMaxWaitMs,
1608
- statusReactions: parsed.statusReactions,
1609
- historyEnabled: parsed.historyEnabled,
1610
- historyRetentionDays: parsed.historyRetentionDays,
1611
- // #596: telegram features projected into access.json by scaffold.
1612
- // Without these passthroughs, gateway readers (`access.voice_in`,
1613
- // `access.telegraph`, `access.stickers`) silently see undefined.
1614
- stickers: parsed.stickers,
1615
- voice_in: parsed.voice_in,
1616
- voice_out: parsed.voice_out,
1617
- telegraph: parsed.telegraph,
1618
- // #789: button-choice-confirmation config projected by scaffold.
1619
- button_choice_confirmation: parsed.button_choice_confirmation,
1620
- }
1621
- } catch (err) {
1622
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
1623
- try { renameSync(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`) } catch {}
1624
- process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.\n`)
1625
- return defaultAccess()
1626
- }
1627
- }
1628
-
1629
- const BOOT_ACCESS: Access | null = STATIC
1630
- ? (() => {
1631
- const a = readAccessFile()
1632
- if (a.dmPolicy === 'pairing') {
1633
- process.stderr.write('telegram gateway: static mode — dmPolicy "pairing" downgraded to "allowlist"\n')
1634
- a.dmPolicy = 'allowlist'
1635
- }
1636
- a.pending = {}
1637
- return a
1638
- })()
1639
- : null
1640
-
1641
- function loadAccess(): Access {
1642
- return BOOT_ACCESS ?? readAccessFile()
1643
- }
1644
-
1645
- /**
1646
- * Read `people.json` (the scaffold's plain projection of `users:` entries
1647
- * that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
1648
- * returns an empty array rather than throwing — this feature must never
1649
- * block startup. Unlike `access.json` this file is never gateway-mutated,
1650
- * so there's no "move corrupt file aside" concern; the scaffold owns and
1651
- * regenerates it on every reconcile.
1652
- */
1653
- function readPeopleFile(): RawPersonEntry[] {
1654
- try {
1655
- const raw = readFileSync(PEOPLE_FILE, 'utf8')
1656
- const parsed = JSON.parse(raw) as { entries?: unknown }
1657
- if (!Array.isArray(parsed.entries)) return []
1658
- return parsed.entries as RawPersonEntry[]
1659
- } catch {
1660
- return []
1661
- }
1662
- }
1580
+ // Access/allowlist file layer (access.json + people.json). Extracted to
1581
+ // ./access-store.ts (switchroom#4248) to relieve the gateway line-ratchet;
1582
+ // behavior is byte-identical. The factory runs the static-mode BOOT_ACCESS
1583
+ // snapshot eagerly at THIS point in startup — same timing as the inline
1584
+ // version so the frozen-allowlist semantics are unchanged.
1585
+ const { loadAccess, readPeopleFile, assertAllowedChat, saveAccess, pruneExpired } =
1586
+ createAccessStore({
1587
+ accessFile: ACCESS_FILE,
1588
+ peopleFile: PEOPLE_FILE,
1589
+ stateDir: STATE_DIR,
1590
+ isStatic: STATIC,
1591
+ })
1663
1592
 
1664
1593
  /**
1665
1594
  * Boot-time-only person-name directory (see resolve-person.ts module doc).
@@ -1669,34 +1598,6 @@ function readPeopleFile(): RawPersonEntry[] {
1669
1598
  */
1670
1599
  let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
1671
1600
 
1672
- function assertAllowedChat(chat_id: string | number): void {
1673
- const id = String(chat_id)
1674
- const access = loadAccess()
1675
- if (access.allowFrom.includes(id)) return
1676
- if (id in access.groups) return
1677
- throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
1678
- }
1679
-
1680
- function saveAccess(a: Access): void {
1681
- if (STATIC) return
1682
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
1683
- const tmp = ACCESS_FILE + '.tmp'
1684
- writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
1685
- renameSync(tmp, ACCESS_FILE)
1686
- }
1687
-
1688
- function pruneExpired(a: Access): boolean {
1689
- const now = Date.now()
1690
- let changed = false
1691
- for (const [code, p] of Object.entries(a.pending)) {
1692
- if (p.expiresAt < now) {
1693
- delete a.pending[code]
1694
- changed = true
1695
- }
1696
- }
1697
- return changed
1698
- }
1699
-
1700
1601
  // ─── History ──────────────────────────────────────────────────────────────
1701
1602
  const HISTORY_ACCESS = loadAccess()
1702
1603
  const HISTORY_ENABLED = HISTORY_ACCESS.historyEnabled !== false
@@ -2034,35 +1935,9 @@ if (isGatewayMain) try { // #2996 P0c: gated — opens bun:sqlite + writes .pen
2034
1935
 
2035
1936
  // Diagnostic env file (one-shot, sourced by start.sh) — kept for the
2036
1937
  // wake-audit context. The injected inbound above is the real wake signal;
2037
- // these vars are passive context only.
2038
- const pendingEnvPath = join(agentDir, '.pending-turn.env')
2039
- try {
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
- }
1938
+ // these vars are passive context only. Extracted to pending-turn-env.ts
1939
+ // (atomic tmp+rename writer; never throws).
1940
+ writePendingTurnEnv(agentDir, pending)
2066
1941
  } catch (err) {
2067
1942
  process.stderr.write(`telegram gateway: turn-registry init failed (${(err as Error).message}) — turn tracking disabled\n`)
2068
1943
  turnsDb = null
@@ -3690,7 +3565,7 @@ export type CurrentTurn = {
3690
3565
  // registry's older entries (and any hand-built test turn) tolerate its
3691
3566
  // absence; `emissionAuthorityFor` lazily backfills one when missing.
3692
3567
  emissionAuthority?: EmissionAuthority
3693
- }
3568
+ 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
3569
 
3695
3570
  // PR-4e — the singleton `currentTurn` is RETAINED as (a) the flag-OFF store and
3696
3571
  // (b) the flag-ON "most-recent-set" MIRROR. Every GLOBAL-liveness read in this
@@ -7886,6 +7761,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7886
7761
  // 429 metrics — defense in depth, no secret survives in ANY downstream sink.
7887
7762
  event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }
7888
7763
 
7764
+ // transport-transient (mid-response stream abort — `server_error`/`api_error`
7765
+ // with no HTTP status): no broadcast card, no Reauth; record + deferred user
7766
+ // notice, burst escalates. Orchestration in user-failure-notices.ts.
7767
+ if (kind === 'transport-transient') {
7768
+ emitTransportTransientEvent(event, userFailureNoticeDeps())
7769
+ return
7770
+ }
7771
+
7889
7772
  // ── 429 throttle tier (operator spec: "retry in place under 5 min, else
7890
7773
  // mark + failover, honest reset messaging") ────────────────────────────
7891
7774
  // A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
@@ -8269,56 +8152,47 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8269
8152
  // liveness only THAT topic's turn end resolves it (full rationale on
8270
8153
  // `PendingUserNotice.key`). `undefined` (no live turn — the event is
8271
8154
  // agent-level, empty wire chatId) keeps the legacy agent-wide resolution.
8272
- const liveTurn = currentTurn
8273
- const noticeKey =
8274
- liveTurn != null ? statusKey(liveTurn.sessionChatId, liveTurn.sessionThreadId) : undefined
8275
- pendingUserNoticeGate.schedule({
8276
- chatIds: userNoticeChats,
8277
- text: renderUserFacingFailureNotice(),
8278
- agent,
8279
- kind,
8280
- atMs: Date.now(),
8281
- key: noticeKey,
8282
- })
8155
+ const noticeDeps = userFailureNoticeDeps()
8156
+ const noticeKey = noticeDeps.liveTurnKey()
8157
+ noticeDeps.scheduleUserNotice({ chatIds: userNoticeChats, agent, kind, key: noticeKey, atMs: Date.now() })
8283
8158
  process.stderr.write(
8284
8159
  `telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length} topic=${noticeKey ?? '-'}\n`,
8285
8160
  )
8286
8161
  }
8287
8162
  }
8288
8163
 
8164
+ /**
8165
+ * Live gateway deps for the plain user-failure-notice subsystem
8166
+ * (`user-failure-notices.ts`): transport-transient handling AND the turn-end
8167
+ * flush share this one wiring. Every side effect is a closure over live state.
8168
+ */
8169
+ function userFailureNoticeDeps(): UserFailureNoticeDeps {
8170
+ return {
8171
+ now: () => Date.now(),
8172
+ allowFrom: () => loadAccess().allowFrom,
8173
+ liveTurnKey: () => currentTurn != null ? statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId) : undefined,
8174
+ record: (e) => { try { recordOperatorEvent(e) } catch { /* history best-effort */ } },
8175
+ scheduleUserNotice: (i) => pendingUserNoticeGate.schedule({ ...i, text: renderUserFacingFailureNotice() }),
8176
+ resolveNotices: (delivered, key) => pendingUserNoticeGate.resolveTurnEnd(key, delivered),
8177
+ send: (chat_id, text, keyboard) => {
8178
+ const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: resolveAgentOutboundTopic({ kind: 'compact-watchdog' }), supergroupChatId: resolveAgentSupergroupChatId() })
8179
+ const opts = { ...(keyboard ? { reply_markup: keyboard } : {}), ...(thread != null ? { message_thread_id: thread } : {}) }
8180
+ // allow-raw-bot-api: user-failure-notice / transport escalation send; topic-aware opts
8181
+ 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`))
8182
+ },
8183
+ log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
8184
+ }
8185
+ }
8186
+
8289
8187
  /**
8290
8188
  * Turn-end resolution of deferred user failure notices (#3293 finding 1).
8291
8189
  * Called from `endCurrentTurnAtomic` — the ONE funnel every turn-end path
8292
8190
  * passes through. `turnDeliveredReply` is `finalAnswerDelivered || replyCalled`
8293
8191
  * (the model explicitly replied → the turn recovered → notices are dropped by
8294
- * the gate). Only a reply-less turn end flushes the pending notices to the
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.
8192
+ * the gate). The send loop lives in `user-failure-notices.ts`.
8298
8193
  */
8299
8194
  function flushPendingUserFailureNotices(turnDeliveredReply: boolean, turnKey: string): void {
8300
- const notices = pendingUserNoticeGate.resolveTurnEnd(turnKey, turnDeliveredReply)
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
- }
8195
+ flushDeferredUserNotices(turnDeliveredReply, turnKey, userFailureNoticeDeps())
8322
8196
  }
8323
8197
 
8324
8198
  /**
@@ -10196,6 +10070,22 @@ if (isGatewayMain && !STATIC && OBLIGATION_LEDGER_ENABLED) {
10196
10070
  setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref()
10197
10071
  }
10198
10072
 
10073
+ // Gateway boot briefing (session_continuity.briefing: gateway — default
10074
+ // legacy/off): a surface-scoped reorientation turn assembled from the durable
10075
+ // history DB and delivered as <channel source="boot_briefing"> over the spool
10076
+ // transport. Queued BEFORE the resume inbound below so a session that has
10077
+ // both reorients first, then resumes. All decision/build logic (flag,
10078
+ // --continue suppression, resume-window dedup, budget) lives in
10079
+ // boot-briefing-wiring.ts / boot-briefing-builder.ts; never throws.
10080
+ if (isGatewayMain && HISTORY_ENABLED) {
10081
+ await maybeQueueBootBriefing({
10082
+ env: process.env,
10083
+ stateDir: STATE_DIR,
10084
+ resumeMsg: bootResumeInbound?.msg ?? null,
10085
+ put: (agent, msg) =>
10086
+ inboundSpool != null ? inboundSpool.put(agent, msg) : pendingInboundBuffer.push(agent, msg),
10087
+ })
10088
+ }
10199
10089
  // Honest-restart-resume: inject the boot resume/report inbound built by the
10200
10090
  // registry classifier above. When the spool exists we only PUT it (the
10201
10091
  // boot-replay loop below pulls it into the in-memory buffer exactly once via
@@ -11760,8 +11650,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
11760
11650
  )
11761
11651
  },
11762
11652
 
11653
+ // Buzz Phase 2b: the duplex peer's advisory publish outcome — no-op unless the hub mirror booted.
11654
+ onBuzzPublishResult: (_c, m) => getBuzzMirror()?.onPublishResult(m),
11763
11655
  log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
11764
11656
  })
11657
+ // Buzz Phase 2b: boot the hub mirror (dark unless channels.buzz.enabled + mode both); wires the peer transport, else a no-op.
11658
+ if (isGatewayMain) maybeBootBuzzMirror((msg) => ipcServer.sendToBuzzPeer(msg))
11765
11659
 
11766
11660
  // ─── Webhook ingest server (RFC webhook-via-gateway-socket) ───────────────
11767
11661
  // Under the Docker runtime the host-side web receiver runs as the operator
@@ -12504,21 +12398,15 @@ async function executeReply(
12504
12398
  function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12505
12399
  return {
12506
12400
  // the ONE live instances (Amendment 1/9 — never re-new in a module)
12507
- outboundDedup,
12508
- flushedTurnSupersede,
12509
- firstTextReplyLogged,
12510
- suppressPtyPreview,
12511
- activeDraftStreams,
12512
- lastPtyPreviewByChat,
12513
- voiceOnDemandCache,
12514
- voicePreSynthQueue,
12515
- pendingProgress,
12516
- signalTracker,
12401
+ outboundDedup, flushedTurnSupersede,
12402
+ firstTextReplyLogged, suppressPtyPreview,
12403
+ activeDraftStreams, lastPtyPreviewByChat,
12404
+ voiceOnDemandCache, voicePreSynthQueue,
12405
+ pendingProgress, signalTracker,
12517
12406
  silencePoke,
12518
12407
  getCurrentTurn: () => currentTurn,
12519
12408
  getLastActiveTurnChatId: () => lastActiveTurnChatId,
12520
- HISTORY_ENABLED,
12521
- TURN_ORIGIN_ROUTING_ENABLED,
12409
+ HISTORY_ENABLED, TURN_ORIGIN_ROUTING_ENABLED,
12522
12410
  AUTOCLASSIFY_MIDTURN_SHADOW,
12523
12411
  MAX_ATTACHMENT_BYTES,
12524
12412
  MAX_CHUNK_LIMIT,
@@ -12533,8 +12421,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12533
12421
  statusKey,
12534
12422
  streamKey,
12535
12423
  resolveReplyOwnerTurn,
12536
- findTurnByOriginId,
12537
- findTurnByQuotedMessageId,
12424
+ findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat,
12538
12425
  resolveAnswerThreadWithLog,
12539
12426
  resolveThreadId,
12540
12427
  getLatestInboundMessageId,
@@ -13282,6 +13169,8 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
13282
13169
  process.stderr.write(`telegram gateway: history recordEdit failed: ${err}\n`)
13283
13170
  }
13284
13171
  }
13172
+ // 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.
13173
+ getBuzzMirror()?.mirrorCorrection({ telegramMessageKey: `${String(args.chat_id ?? '')}:${Number(args.message_id)}`, scrubbedText: editRawText })
13285
13174
  return { content: [{ type: 'text', text: `edited (id: ${id})` }] }
13286
13175
  }
13287
13176
 
@@ -15190,17 +15079,39 @@ export async function handleInbound(
15190
15079
 
15191
15080
  // Reply-to + forward-origin context — moved to buildReplyForwardContext
15192
15081
  // (#2996 P7 PR-10, pure builder).
15082
+ const replyForwardCtx = buildReplyForwardContext({
15083
+ ctx,
15084
+ coalescedForwardOrigins,
15085
+ replyToTextMax: REPLY_TO_TEXT_MAX,
15086
+ })
15193
15087
  const {
15194
15088
  replyToMessageId,
15195
- replyToText,
15196
- replyToTextEscaped,
15197
15089
  forwardOrigins,
15198
15090
  forwardOriginMeta,
15199
15091
  primaryForwardOrigin,
15200
- } = buildReplyForwardContext({
15201
- ctx,
15202
- coalescedForwardOrigins,
15092
+ } = replyForwardCtx
15093
+
15094
+ // Reply-to buffer fallback (post-reset continuity, resolveReplyToFromBuffer).
15095
+ // On a native reply to the BOT's OWN message, Telegram delivers
15096
+ // reply_to_message.message_id but NOT its .text — so the live reply text is
15097
+ // empty even though we authored (and, via recordOutbound, persisted) that
15098
+ // message to history.db (role='assistant', 30-day retention). Recover it so
15099
+ // the antecedent survives a session reset. Fills replyToText (raw, so the
15100
+ // recordInbound write below persists it — envelope-only would leave the row
15101
+ // NULL and starve future briefings), replyToTextEscaped (channel meta), and
15102
+ // replyToRole (the reply_to_role attribute). Only when the live text is
15103
+ // empty; degrades silently when history is off / the row is missing.
15104
+ const {
15105
+ replyToText,
15106
+ replyToTextEscaped,
15107
+ replyToRole,
15108
+ } = resolveReplyToFromBuffer({
15109
+ replyToMessageId,
15110
+ replyToText: replyForwardCtx.replyToText,
15111
+ replyToTextEscaped: replyForwardCtx.replyToTextEscaped,
15112
+ historyEnabled: HISTORY_ENABLED,
15203
15113
  replyToTextMax: REPLY_TO_TEXT_MAX,
15114
+ lookup: (messageId) => lookupMessageRoleAndText(chat_id, messageId),
15204
15115
  })
15205
15116
 
15206
15117
  if (HISTORY_ENABLED) {
@@ -15277,6 +15188,7 @@ export async function handleInbound(
15277
15188
  priorAssistantPreview,
15278
15189
  replyToMessageId,
15279
15190
  replyToTextEscaped,
15191
+ replyToRole,
15280
15192
  forwardOriginMeta,
15281
15193
  topicFramingEnabled: TOPIC_FRAMING_ENABLED,
15282
15194
  personDirectory: PERSON_DIRECTORY,
@@ -151,9 +151,20 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
151
151
  // (for channel meta).
152
152
  const replyToMsg = p.ctx.message?.reply_to_message
153
153
  const replyToMessageId = replyToMsg?.message_id
154
- const replyToTextRaw = replyToMsg
155
- ? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
156
- : undefined
154
+ // Native partial-quote preference (Bot API 7.0+ `message.quote`, issue #119
155
+ // follow-up). When the user long-presses a message and drag-selects a
156
+ // substring before choosing Reply, Telegram delivers only that quoted span
157
+ // on `message.quote.text` — the user is pointing at that exact slice, so it
158
+ // is a stronger antecedent than the full parent message. Prefer it over the
159
+ // parent `.text`/`.caption`, and over the history-buffer fallback the
160
+ // gateway runs when neither is present. Accessed defensively in case the
161
+ // installed grammy/@grammyjs/types predate `TextQuote`.
162
+ const quoteText = p.ctx.message?.quote?.text
163
+ const replyToTextRaw = (quoteText != null && quoteText.length > 0)
164
+ ? quoteText
165
+ : replyToMsg
166
+ ? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
167
+ : undefined
157
168
  const replyToText = replyToTextRaw != null
158
169
  ? (replyToTextRaw.length > p.replyToTextMax
159
170
  ? replyToTextRaw.slice(0, p.replyToTextMax - 1) + '…'
@@ -183,6 +194,73 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
183
194
  }
184
195
  }
185
196
 
197
+ /** Inputs for {@link resolveReplyToFromBuffer}. */
198
+ export interface ReplyToBufferFallbackParams {
199
+ /** From {@link buildReplyForwardContext}. */
200
+ replyToMessageId: number | undefined
201
+ /** Raw reply text off the live update (for the SQLite write); empty when the
202
+ * reply target is the bot's own message (Telegram omits its text). */
203
+ replyToText: string | undefined
204
+ /** XML-escaped reply text for the channel meta; empty in the same case. */
205
+ replyToTextEscaped: string | undefined
206
+ /** HISTORY_ENABLED — the DB is only present when history is on. */
207
+ historyEnabled: boolean
208
+ /** REPLY_TO_TEXT_MAX. */
209
+ replyToTextMax: number
210
+ /** `lookupMessageRoleAndText` bound to the chat, or any equivalent. May
211
+ * throw (e.g. requireDb when history disabled mid-run) — this is caught. */
212
+ lookup: (messageId: number) => { role: 'user' | 'assistant'; text: string } | null
213
+ }
214
+
215
+ /**
216
+ * Reply-to buffer fallback (post-reset continuity). On a native reply to the
217
+ * BOT's OWN message, Telegram delivers `reply_to_message.message_id` but NOT
218
+ * its `.text` — so the live reply text is empty even though the gateway
219
+ * authored (and, via `recordOutbound`, persisted) that message. This recovers
220
+ * the antecedent from the local history buffer so it survives a session reset
221
+ * (`resume_mode: handoff`), where the transcript is gone.
222
+ *
223
+ * Returns updated `replyToText` (raw, for the SQLite `recordInbound` write —
224
+ * envelope-only would leave the row NULL and starve future handoff briefings),
225
+ * `replyToTextEscaped` (for the channel-meta `reply_to_text`), and the
226
+ * recovered `replyToRole` ('assistant' = the bot's own message, disambiguating
227
+ * the "you're replying to yourself" case). Pure except for the injected
228
+ * `lookup`. Only fills in when the live reply text is empty — never overwrites
229
+ * a non-empty live value (a partial-quote or a reply to a person's message).
230
+ * Degrades silently to the id-only inputs on any lookup failure.
231
+ */
232
+ export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
233
+ replyToText: string | undefined
234
+ replyToTextEscaped: string | undefined
235
+ replyToRole: 'user' | 'assistant' | undefined
236
+ } {
237
+ let replyToText = p.replyToText
238
+ let replyToTextEscaped = p.replyToTextEscaped
239
+ let replyToRole: 'user' | 'assistant' | undefined
240
+ const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
241
+ if (p.historyEnabled && p.replyToMessageId != null && liveTextEmpty) {
242
+ try {
243
+ const recovered = p.lookup(p.replyToMessageId)
244
+ if (recovered && recovered.text.length > 0) {
245
+ replyToText =
246
+ recovered.text.length > p.replyToTextMax
247
+ ? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
248
+ : recovered.text
249
+ replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
250
+ replyToRole = recovered.role
251
+ } else if (recovered) {
252
+ // Row exists (authorship known) but text is empty/redacted — still
253
+ // surface the role so the model knows whose message it is replying to.
254
+ replyToRole = recovered.role
255
+ }
256
+ } catch {
257
+ // History disabled mid-run / requireDb throws / row missing — degrade
258
+ // silently to the id-only inputs (current behavior).
259
+ }
260
+ }
261
+ return { replyToText, replyToTextEscaped, replyToRole }
262
+ }
263
+
186
264
  /** Inputs for {@link buildInboundEnvelope} — every field is an at-call
187
265
  * captured value (steering meta, at-receipt snapshots, resolved attachments),
188
266
  * never a live getter. */
@@ -206,6 +284,11 @@ export interface EnvelopeBuildParams {
206
284
  priorAssistantPreview: string | undefined
207
285
  replyToMessageId: number | undefined
208
286
  replyToTextEscaped: string | undefined
287
+ /** Authorship of the replied-to message ('assistant' = the bot's own
288
+ * message, 'user' = a person's), when known — recovered from the history
289
+ * buffer by handleInbound's reply-to fallback. Undefined when the role
290
+ * can't be determined (e.g. text came live off the update, not the DB). */
291
+ replyToRole: 'user' | 'assistant' | undefined
209
292
  forwardOriginMeta: Record<string, string>
210
293
  /** TOPIC_FRAMING_ENABLED — fixed constant. */
211
294
  topicFramingEnabled: boolean
@@ -300,6 +383,13 @@ export function buildInboundEnvelope(p: EnvelopeBuildParams): InboundMessage {
300
383
  // Use the XML-escaped form for the meta — the raw form is in the
301
384
  // SQLite buffer for verbatim retrieval via get_recent_messages.
302
385
  ...(p.replyToTextEscaped != null && p.replyToTextEscaped.length > 0 ? { reply_to_text: p.replyToTextEscaped } : {}),
386
+ // Authorship of the replied-to message. Disambiguates "you are replying
387
+ // to the BOT's own message" (assistant) from "…to a person's message"
388
+ // (user) — the incident where a native reply to one of the bot's own
389
+ // messages ("is this added as a calendar invite yet?") left the agent
390
+ // guessing the antecedent. Free: the history-buffer lookup that recovers
391
+ // reply_to_text already returns the role. Emitted only when known.
392
+ ...(p.replyToRole != null ? { reply_to_role: p.replyToRole } : {}),
303
393
  // Forwarded-message origin (server-stamped, attrs-only — see above).
304
394
  // forwarded_from / forwarded_from_type / forwarded_from_id /
305
395
  // forwarded_date, plus numbered _2.. siblings for a multi-origin
@@ -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
- if (live.has(id)) return false // dedup: already spooled & un-acked
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 })