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
|
@@ -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 {
|
|
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
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
})
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
12509
|
-
|
|
12510
|
-
|
|
12511
|
-
|
|
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
|
-
} =
|
|
15201
|
-
|
|
15202
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 })
|