switchroom 0.19.48 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- package/vendor/hindsight-memory/scripts/recall.py +7 -2
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Impure orchestration for the gateway boot briefing
|
|
3
|
+
* (`session_continuity.briefing: gateway`). The pure logic — flag
|
|
4
|
+
* decision, surface collection, budget-bounded rendering, resume dedup —
|
|
5
|
+
* lives in `boot-briefing-builder.ts`; this module reads the real env /
|
|
6
|
+
* fs / history handle and hands the finished inbound to the caller's
|
|
7
|
+
* `put` (spool or in-memory buffer).
|
|
8
|
+
*
|
|
9
|
+
* Contract with gateway.ts: a single call at boot, AFTER `initHistory`
|
|
10
|
+
* and AFTER the boot-resume inbound is built (its interrupted-turn window
|
|
11
|
+
* feeds the dedup), and BEFORE the resume inbound is spooled when the
|
|
12
|
+
* caller wants briefing-before-resume delivery order. NEVER throws and
|
|
13
|
+
* never blocks: every failure path degrades to "no briefing".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { GATEWAY_BOOT_BRIEFING_CAPABILITY } from './boot-briefing-capability.js'
|
|
19
|
+
import { getHistoryDbForBriefing } from '../history.js'
|
|
20
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
21
|
+
import {
|
|
22
|
+
buildBootBriefingInbound,
|
|
23
|
+
collectBriefingSurfaces,
|
|
24
|
+
decideBootBriefing,
|
|
25
|
+
excludeWindowFromResumeInbound,
|
|
26
|
+
readRestartBreadcrumb,
|
|
27
|
+
renderBootBriefing,
|
|
28
|
+
type BriefingDailyMemory,
|
|
29
|
+
type HindsightRecallResult,
|
|
30
|
+
} from './boot-briefing-builder.js'
|
|
31
|
+
|
|
32
|
+
/** Recall query the legacy handoff-briefing.sh sends verbatim. */
|
|
33
|
+
const HINDSIGHT_RECALL_QUERY = 'what was happening recently in our conversation?'
|
|
34
|
+
/** `max_tokens` the shell script sends (jq `--argjson m 800`). */
|
|
35
|
+
const HINDSIGHT_RECALL_MAX_TOKENS = 800
|
|
36
|
+
/** Recall HTTP budget — the shell's `curl -m 4`. */
|
|
37
|
+
const HINDSIGHT_TIMEOUT_MS = 4000
|
|
38
|
+
|
|
39
|
+
export interface MaybeQueueBootBriefingOptions {
|
|
40
|
+
env: Record<string, string | undefined>
|
|
41
|
+
/** Gateway STATE_DIR (`<agentDir>/telegram` in production). */
|
|
42
|
+
stateDir: string
|
|
43
|
+
/** The already-built boot resume/report inbound (or null) — its
|
|
44
|
+
* interrupted-turn window is elided from the briefing so the two boot
|
|
45
|
+
* synthetics never double-inject the same messages. */
|
|
46
|
+
resumeMsg: InboundMessage | null
|
|
47
|
+
/** Durable enqueue — `inboundSpool.put` (or the in-memory buffer's push
|
|
48
|
+
* in STATIC mode). */
|
|
49
|
+
put: (agent: string, msg: InboundMessage) => unknown
|
|
50
|
+
log?: (line: string) => void
|
|
51
|
+
nowMs?: number
|
|
52
|
+
/** Test seam: injected `fetch` for the Hindsight recall. Defaults to the
|
|
53
|
+
* runtime global `fetch`. Never used in production wiring. */
|
|
54
|
+
fetchImpl?: typeof fetch
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fetch the Hindsight recall slice (source 2 of the legacy handoff
|
|
59
|
+
* contract). Mirrors `bin/handoff-briefing.sh`'s request shape:
|
|
60
|
+
* `POST ${HINDSIGHT_API_URL}/v1/default/banks/${HINDSIGHT_BANK_ID}/memories/recall`
|
|
61
|
+
* with body `{query, max_tokens: 800}`. This gateway path uses a 4s abort
|
|
62
|
+
* timeout; the shell script caps its curl at 3s (it runs under start.sh's
|
|
63
|
+
* outer `timeout`, which the async gateway daemon is not subject to).
|
|
64
|
+
*
|
|
65
|
+
* Graceful-skip on ANY failure — missing env, timeout, non-200, malformed
|
|
66
|
+
* JSON — returns `[]` so the briefing degrades to its other sources rather
|
|
67
|
+
* than crashing or blocking boot. Never throws.
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchHindsightRecall(
|
|
70
|
+
env: Record<string, string | undefined>,
|
|
71
|
+
opts: { fetchImpl?: typeof fetch; timeoutMs?: number; log?: (line: string) => void } = {},
|
|
72
|
+
): Promise<HindsightRecallResult[]> {
|
|
73
|
+
const base = (env.HINDSIGHT_API_URL ?? '').replace(/\/+$/, '')
|
|
74
|
+
const bank = env.HINDSIGHT_BANK_ID ?? ''
|
|
75
|
+
if (!base || !bank) return []
|
|
76
|
+
const doFetch = opts.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined)
|
|
77
|
+
if (typeof doFetch !== 'function') return []
|
|
78
|
+
const log = opts.log
|
|
79
|
+
const controller = new AbortController()
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? HINDSIGHT_TIMEOUT_MS)
|
|
81
|
+
try {
|
|
82
|
+
const url = `${base}/v1/default/banks/${encodeURIComponent(bank)}/memories/recall`
|
|
83
|
+
const resp = await doFetch(url, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
query: HINDSIGHT_RECALL_QUERY,
|
|
88
|
+
max_tokens: HINDSIGHT_RECALL_MAX_TOKENS,
|
|
89
|
+
}),
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
})
|
|
92
|
+
if (!resp.ok) {
|
|
93
|
+
// Never log the URL/host — keep the deny reason generic (defence in
|
|
94
|
+
// depth even though the recall URL carries no token).
|
|
95
|
+
log?.(`telegram gateway: boot-briefing hindsight recall non-200 (${resp.status}) — skipping section\n`)
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
const body = (await resp.json()) as { results?: Array<{ text?: unknown; timestamp?: unknown }> }
|
|
99
|
+
const results = Array.isArray(body?.results) ? body.results : []
|
|
100
|
+
return results.map((r) => ({
|
|
101
|
+
text: typeof r?.text === 'string' ? r.text : '',
|
|
102
|
+
timestamp: typeof r?.timestamp === 'string' ? r.timestamp : null,
|
|
103
|
+
}))
|
|
104
|
+
} catch {
|
|
105
|
+
// Timeout (abort), DNS/connection failure, malformed JSON — all graceful.
|
|
106
|
+
log?.('telegram gateway: boot-briefing hindsight recall unavailable — skipping section\n')
|
|
107
|
+
return []
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read today's daily-memory file (source 3 of the legacy handoff contract):
|
|
115
|
+
* `<workspaceDir>/memory/<YYYY-MM-DD>.md`, with the date derived in the
|
|
116
|
+
* agent's LOCAL timezone (SWITCHROOM_TIMEZONE → TZ → system-local), exactly
|
|
117
|
+
* as `bin/workspace-dynamic-hook.sh` reads it.
|
|
118
|
+
*
|
|
119
|
+
* NOTE — deliberate divergence from bin/handoff-briefing.sh: that script
|
|
120
|
+
* reads `${WORKSPACE_DIR:-$AGENT_DIR}/memory/...`, but WORKSPACE_DIR is never
|
|
121
|
+
* set in the agent env, so it resolves to `<agentDir>/memory/...` — the WRONG
|
|
122
|
+
* path. Daily notes actually live at `<agentDir>/workspace/memory/...` (see
|
|
123
|
+
* `resolveAgentWorkspaceDir` and `bin/workspace-dynamic-hook.sh`, the
|
|
124
|
+
* authoritative reader). We mirror the correct path here (honouring an
|
|
125
|
+
* explicit WORKSPACE_DIR override if one is ever set), not the shell's bug.
|
|
126
|
+
* Returns null on a missing/empty file or any read error. Never throws.
|
|
127
|
+
*/
|
|
128
|
+
export function readDailyMemory(
|
|
129
|
+
agentDir: string,
|
|
130
|
+
env: Record<string, string | undefined>,
|
|
131
|
+
nowMs: number,
|
|
132
|
+
readFile: (path: string) => string = (p) => readFileSync(p, 'utf8'),
|
|
133
|
+
): BriefingDailyMemory | null {
|
|
134
|
+
const date = agentLocalDate(nowMs, env.SWITCHROOM_TIMEZONE || env.TZ || undefined)
|
|
135
|
+
if (!date) return null
|
|
136
|
+
const workspaceDir = env.WORKSPACE_DIR && env.WORKSPACE_DIR.trim()
|
|
137
|
+
? env.WORKSPACE_DIR
|
|
138
|
+
: join(agentDir, 'workspace')
|
|
139
|
+
const file = join(workspaceDir, 'memory', `${date}.md`)
|
|
140
|
+
try {
|
|
141
|
+
const content = readFile(file)
|
|
142
|
+
if (!content || !content.trim()) return null
|
|
143
|
+
return { date, content }
|
|
144
|
+
} catch {
|
|
145
|
+
return null // ENOENT / unreadable — no section, no crash.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Format `nowMs` as `YYYY-MM-DD` in `tz` (SWITCHROOM_TIMEZONE → TZ →
|
|
150
|
+
* system-local). Uses `en-CA` which renders ISO `YYYY-MM-DD`. Returns ''
|
|
151
|
+
* if the timezone is invalid (Intl throws) so the caller skips the
|
|
152
|
+
* section rather than looking up the wrong day. */
|
|
153
|
+
function agentLocalDate(nowMs: number, tz: string | undefined): string {
|
|
154
|
+
try {
|
|
155
|
+
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
156
|
+
timeZone: tz,
|
|
157
|
+
year: 'numeric',
|
|
158
|
+
month: '2-digit',
|
|
159
|
+
day: '2-digit',
|
|
160
|
+
})
|
|
161
|
+
// en-CA yields YYYY-MM-DD; guard against locale/impl drift anyway.
|
|
162
|
+
const s = fmt.format(new Date(nowMs))
|
|
163
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : ''
|
|
164
|
+
} catch {
|
|
165
|
+
return ''
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build + enqueue the boot briefing when the feature flag and suppression
|
|
171
|
+
* rules allow. Returns the queued inbound (for observability/tests) or
|
|
172
|
+
* null when nothing was queued.
|
|
173
|
+
*
|
|
174
|
+
* ASYNC because source 2 (Hindsight recall) is an HTTP fetch. The fetch is
|
|
175
|
+
* AWAITED to completion-or-timeout BEFORE `put` runs, so the briefing is
|
|
176
|
+
* only ever enqueued with its Hindsight section already assembled — it can
|
|
177
|
+
* never be delivered with the section racing in late. The 4s ceiling bounds
|
|
178
|
+
* the added boot latency, and (like every other path here) any failure
|
|
179
|
+
* degrades to "no Hindsight section", never a blocked or crashed boot. The
|
|
180
|
+
* caller must AWAIT this before the resume inbound is spooled / the
|
|
181
|
+
* boot-replay loop pulls live entries, to preserve briefing-before-resume
|
|
182
|
+
* delivery order.
|
|
183
|
+
*/
|
|
184
|
+
export async function maybeQueueBootBriefing(
|
|
185
|
+
opts: MaybeQueueBootBriefingOptions,
|
|
186
|
+
): Promise<InboundMessage | null> {
|
|
187
|
+
const log = opts.log ?? ((l: string) => process.stderr.write(l))
|
|
188
|
+
try {
|
|
189
|
+
const agentDir = opts.stateDir.endsWith('/telegram')
|
|
190
|
+
? opts.stateDir.slice(0, -'/telegram'.length)
|
|
191
|
+
: opts.stateDir
|
|
192
|
+
// Session-generation guard (#4242). This module re-evaluates on EVERY
|
|
193
|
+
// gateway process start, including a supervisor respawn after the
|
|
194
|
+
// gateway crashes — but a respawn does NOT restart the inner Claude
|
|
195
|
+
// session, so re-queuing a boot briefing would inject a "you just
|
|
196
|
+
// rebooted" reorientation into a live, mid-conversation session. The
|
|
197
|
+
// spool's dedup can't catch it: by respawn time boot-1's briefing has
|
|
198
|
+
// been delivered AND acked, so its spool entry is already gone.
|
|
199
|
+
//
|
|
200
|
+
// start.sh's OUTER pass stamps SWITCHROOM_GATEWAY_BOOT_ID once per REAL
|
|
201
|
+
// boot, before forking the gateway; `_switchroom_supervise` respawns
|
|
202
|
+
// `bun` in a loop within that same shell, so every respawn inherits the
|
|
203
|
+
// identical id, while the next real boot re-derives a fresh one. We
|
|
204
|
+
// persist the id the first time a generation actually queues (or
|
|
205
|
+
// determines it has nothing to queue) and skip when the persisted id
|
|
206
|
+
// matches — that is a respawn. Absent env (non-docker / pre-upgrade
|
|
207
|
+
// start.sh) leaves the guard inert: legacy best-effort behaviour.
|
|
208
|
+
const bootId = opts.env.SWITCHROOM_GATEWAY_BOOT_ID
|
|
209
|
+
const genMarkerPath = join(agentDir, '.boot-briefing-generation')
|
|
210
|
+
if (bootId) {
|
|
211
|
+
let prevGen: string | null = null
|
|
212
|
+
try {
|
|
213
|
+
prevGen = readFileSync(genMarkerPath, 'utf8').trim()
|
|
214
|
+
} catch {
|
|
215
|
+
prevGen = null
|
|
216
|
+
}
|
|
217
|
+
if (prevGen === bootId) {
|
|
218
|
+
log(
|
|
219
|
+
'telegram gateway: boot-briefing suppressed (supervisor respawn — this boot generation already briefed)\n',
|
|
220
|
+
)
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const markGeneration = (): void => {
|
|
225
|
+
if (!bootId) return
|
|
226
|
+
try {
|
|
227
|
+
writeFileSync(genMarkerPath, `${bootId}\n`)
|
|
228
|
+
} catch {
|
|
229
|
+
// Best-effort: a failed persist only risks one redundant re-queue on
|
|
230
|
+
// respawn, which the spool still dedups; never block boot.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Force-fresh suppression is keyed on env, NOT on existsSync at this
|
|
234
|
+
// module-eval time. start.sh's OUTER pass snapshots the
|
|
235
|
+
// `.force-fresh-session` marker into SWITCHROOM_FORCE_FRESH *before*
|
|
236
|
+
// forking this gateway, so the value is fixed at fork time and immune to
|
|
237
|
+
// the inner tmux pass's later `rm` of the marker (the two race with no
|
|
238
|
+
// ordering — the old existsSync could lose that race and resurrect the
|
|
239
|
+
// briefing on a /reset boot). The existsSync is retained only as a
|
|
240
|
+
// fallback for runtimes where start.sh doesn't hoist the env (non-docker),
|
|
241
|
+
// where there is no such fork race.
|
|
242
|
+
const forceFresh =
|
|
243
|
+
opts.env.SWITCHROOM_FORCE_FRESH === '1' ||
|
|
244
|
+
existsSync(join(agentDir, '.force-fresh-session'))
|
|
245
|
+
const decision = decideBootBriefing({
|
|
246
|
+
briefingMode: opts.env.SWITCHROOM_SESSION_BRIEFING,
|
|
247
|
+
resumeMode: opts.env.SWITCHROOM_RESUME_MODE,
|
|
248
|
+
forceFreshMarker: forceFresh,
|
|
249
|
+
})
|
|
250
|
+
if (!decision.build) {
|
|
251
|
+
if (decision.reason !== 'flag-legacy') {
|
|
252
|
+
log(`telegram gateway: boot-briefing suppressed (${decision.reason})\n`)
|
|
253
|
+
}
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
const selfAgent = opts.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
257
|
+
if (!selfAgent) return null
|
|
258
|
+
const db = getHistoryDbForBriefing()
|
|
259
|
+
if (db == null) {
|
|
260
|
+
log('telegram gateway: boot-briefing skipped — history DB unavailable\n')
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
const nowMs = opts.nowMs ?? Date.now()
|
|
264
|
+
const surfaces = collectBriefingSurfaces(db, {
|
|
265
|
+
nowMs,
|
|
266
|
+
exclude: excludeWindowFromResumeInbound(opts.resumeMsg),
|
|
267
|
+
})
|
|
268
|
+
// No active Telegram surface = no delivery target for the synthetic
|
|
269
|
+
// briefing inbound (it routes to the primary surface's chat). Short-circuit
|
|
270
|
+
// BEFORE the Hindsight fetch so a zero-history boot never pays the 4s
|
|
271
|
+
// recall timeout. (Deliberate narrowing vs the file-writing legacy path,
|
|
272
|
+
// which has no routing target and can emit a Hindsight/daily-only
|
|
273
|
+
// briefing — documented in the PR.)
|
|
274
|
+
if (surfaces.length === 0) {
|
|
275
|
+
// Consume the generation even when empty (parity with the !text path
|
|
276
|
+
// below, and with main's pre-short-circuit behaviour where zero
|
|
277
|
+
// surfaces rendered to '' and hit markGeneration()). Had there been
|
|
278
|
+
// nothing to brief at boot, a later respawn on the same generation must
|
|
279
|
+
// not suddenly brief mid-session just because fresh messages arrived
|
|
280
|
+
// after the session came up.
|
|
281
|
+
markGeneration()
|
|
282
|
+
log('telegram gateway: boot-briefing empty (no recent surfaces) — nothing queued\n')
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
const restartReason = readRestartBreadcrumb({
|
|
286
|
+
restartReasonPath: join(agentDir, '.restart-reason'),
|
|
287
|
+
env: opts.env,
|
|
288
|
+
readFile: (p) => readFileSync(p, 'utf8'),
|
|
289
|
+
})
|
|
290
|
+
// Sources 2 + 3 of the legacy handoff contract. The Hindsight fetch is
|
|
291
|
+
// AWAITED here (4s ceiling) so the section is present before `put` — the
|
|
292
|
+
// briefing is never enqueued mid-fetch.
|
|
293
|
+
const hindsight = await fetchHindsightRecall(opts.env, {
|
|
294
|
+
fetchImpl: opts.fetchImpl,
|
|
295
|
+
log,
|
|
296
|
+
})
|
|
297
|
+
const dailyMemory = readDailyMemory(agentDir, opts.env, nowMs)
|
|
298
|
+
const text = renderBootBriefing(surfaces, { nowMs, restartReason, hindsight, dailyMemory })
|
|
299
|
+
if (!text) {
|
|
300
|
+
// Consume the generation even when empty: had there been nothing to
|
|
301
|
+
// brief at boot, a later respawn must not suddenly brief mid-session
|
|
302
|
+
// just because fresh messages arrived after the session came up.
|
|
303
|
+
markGeneration()
|
|
304
|
+
log('telegram gateway: boot-briefing empty (no recent surfaces) — nothing queued\n')
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
const primary = surfaces[0]!
|
|
308
|
+
const msg = buildBootBriefingInbound({
|
|
309
|
+
chatId: primary.chatId,
|
|
310
|
+
threadId: primary.threadId,
|
|
311
|
+
text,
|
|
312
|
+
nowMs,
|
|
313
|
+
})
|
|
314
|
+
opts.put(selfAgent, msg)
|
|
315
|
+
markGeneration()
|
|
316
|
+
log(
|
|
317
|
+
`telegram gateway: boot-briefing queued chat=${primary.chatId}` +
|
|
318
|
+
`${primary.threadId != null ? ` thread=${primary.threadId}` : ''} ` +
|
|
319
|
+
`surfaces=${surfaces.length} hindsight=${hindsight.length} ` +
|
|
320
|
+
`daily=${dailyMemory != null ? 'yes' : 'no'} chars=${text.length} ` +
|
|
321
|
+
`cap=${GATEWAY_BOOT_BRIEFING_CAPABILITY}\n`,
|
|
322
|
+
)
|
|
323
|
+
return msg
|
|
324
|
+
} catch (err) {
|
|
325
|
+
// The briefing is best-effort context — a failure here must never
|
|
326
|
+
// block or crash gateway boot.
|
|
327
|
+
log(
|
|
328
|
+
`telegram gateway: boot-briefing failed (${(err as Error).message}) — continuing without briefing\n`,
|
|
329
|
+
)
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable msg→Buzz correlation store for the hub-side mirror (#4222).
|
|
3
|
+
*
|
|
4
|
+
* `BuzzMirror` records `${chatId}:${messageId}` → the published Buzz event it
|
|
5
|
+
* mirrored to, so a later `edit_message` on that Telegram message can publish a
|
|
6
|
+
* superseding `correction`. Before this module that map lived IN MEMORY ONLY
|
|
7
|
+
* (a bounded FIFO). After a gateway restart the map was empty, so a correction
|
|
8
|
+
* to an answer mirrored before the restart was SILENTLY skipped — the Buzz copy
|
|
9
|
+
* went stale with no signal.
|
|
10
|
+
*
|
|
11
|
+
* This store closes that gap the same way the sidecar's inbound dedup does
|
|
12
|
+
* (`src/buzz-gateway/dedup.ts`): an in-memory insertion-ordered map backed by an
|
|
13
|
+
* append-only JSONL journal, fsync'd after each record so a gateway restart
|
|
14
|
+
* reloads the correlation and corrections survive. It lives at a DIFFERENT path
|
|
15
|
+
* from the sidecar's `journal.jsonl` (`mirror-correlation.jsonl`) — the two share
|
|
16
|
+
* `$TELEGRAM_STATE_DIR/buzz/` and a filename collision would corrupt both.
|
|
17
|
+
*
|
|
18
|
+
* Bounding: on construction the journal is compacted to the last `capacity`
|
|
19
|
+
* unique keys (matching the in-memory FIFO bound); during a session the journal
|
|
20
|
+
* is re-compacted in place once it grows past `capacity * COMPACTION_FACTOR`
|
|
21
|
+
* appends, so it never grows unbounded even in a long-lived gateway.
|
|
22
|
+
*
|
|
23
|
+
* The filesystem is injected so the pure map/journal logic is unit-testable with
|
|
24
|
+
* an in-memory fake (see `buzz-mirror-correlation-store.test.ts`). When no
|
|
25
|
+
* journal path is configured (dev/one-shot contexts, or `TELEGRAM_STATE_DIR`
|
|
26
|
+
* unset) the store degrades to in-memory only — identical bound, no durability.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
closeSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
fsyncSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
openSync,
|
|
35
|
+
readFileSync,
|
|
36
|
+
renameSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
writeSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import { dirname } from "node:path";
|
|
41
|
+
|
|
42
|
+
/** The value a Telegram message key maps to: the Buzz event that mirrored it. */
|
|
43
|
+
export interface CorrelationValue {
|
|
44
|
+
eventId: string;
|
|
45
|
+
channelId: string;
|
|
46
|
+
/**
|
|
47
|
+
* The NIP-10 thread ROOT of `eventId` (#4280 follow-up — outbound thread
|
|
48
|
+
* continuity). For a top-level mirror this equals `eventId` itself; for a
|
|
49
|
+
* mirror that threaded under a parent it is that parent's thread root. Lets a
|
|
50
|
+
* LATER outbound reply whose Telegram antecedent is THIS message emit a correct
|
|
51
|
+
* NIP-10 `root` marker (thread root) alongside the `reply` marker (this
|
|
52
|
+
* `eventId`, the immediate parent), instead of collapsing a deep thread to a
|
|
53
|
+
* single mislabelled root. Optional for backward compatibility: a journal
|
|
54
|
+
* record written before this field existed replays with `threadRoot`
|
|
55
|
+
* undefined, degrading to a `reply`-only tag (still valid NIP-10).
|
|
56
|
+
*/
|
|
57
|
+
threadRoot?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CorrelationFsLike {
|
|
61
|
+
existsSync(path: string): boolean;
|
|
62
|
+
mkdirSync(path: string, opts: { recursive: true }): void;
|
|
63
|
+
readFileSync(path: string, enc: "utf8"): string;
|
|
64
|
+
writeFileSync(path: string, data: string): void;
|
|
65
|
+
renameSync(from: string, to: string): void;
|
|
66
|
+
openSync(path: string, flags: "a"): number;
|
|
67
|
+
writeSync(fd: number, data: string): void;
|
|
68
|
+
fsyncSync(fd: number): void;
|
|
69
|
+
closeSync(fd: number): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const NODE_FS: CorrelationFsLike = {
|
|
73
|
+
existsSync,
|
|
74
|
+
mkdirSync: (p, opts) => {
|
|
75
|
+
mkdirSync(p, opts);
|
|
76
|
+
},
|
|
77
|
+
readFileSync: (p, enc) => readFileSync(p, enc),
|
|
78
|
+
writeFileSync: (p, data) => writeFileSync(p, data),
|
|
79
|
+
renameSync: (from, to) => renameSync(from, to),
|
|
80
|
+
openSync: (p, flags) => openSync(p, flags),
|
|
81
|
+
writeSync: (fd, data) => {
|
|
82
|
+
writeSync(fd, data);
|
|
83
|
+
},
|
|
84
|
+
fsyncSync: (fd) => fsyncSync(fd),
|
|
85
|
+
closeSync: (fd) => closeSync(fd),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface CorrelationStore {
|
|
89
|
+
/** The Buzz event `key` was mirrored to, or undefined if not tracked. */
|
|
90
|
+
get(key: string): CorrelationValue | undefined;
|
|
91
|
+
/** Record `key` → `value`: update memory (FIFO) AND append+fsync the journal. */
|
|
92
|
+
set(key: string, value: CorrelationValue): void;
|
|
93
|
+
/** Number of keys currently tracked in memory. */
|
|
94
|
+
size(): number;
|
|
95
|
+
/** Release the append fd. */
|
|
96
|
+
close(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CorrelationStoreOptions {
|
|
100
|
+
/** Journal path. Omit for in-memory-only (no durability, same bound). */
|
|
101
|
+
journalPath?: string;
|
|
102
|
+
/** Max keys retained in memory / after compaction. Default 4096 (MAX_TRACKED). */
|
|
103
|
+
capacity?: number;
|
|
104
|
+
fs?: CorrelationFsLike;
|
|
105
|
+
log?: (msg: string) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Re-compact the on-disk journal once appends exceed capacity * this factor. */
|
|
109
|
+
const COMPACTION_FACTOR = 4;
|
|
110
|
+
|
|
111
|
+
interface JournalRecord {
|
|
112
|
+
key?: unknown;
|
|
113
|
+
eventId?: unknown;
|
|
114
|
+
channelId?: unknown;
|
|
115
|
+
threadRoot?: unknown;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Open (and boot-compact) the correlation store. On construction it replays any
|
|
120
|
+
* existing journal into the in-memory map (last-write-wins per key, oldest-first
|
|
121
|
+
* insertion order preserved), keeps the last `capacity` unique keys, rewrites the
|
|
122
|
+
* journal compacted, then opens a persistent append fd.
|
|
123
|
+
*/
|
|
124
|
+
export function createCorrelationStore(
|
|
125
|
+
opts: CorrelationStoreOptions = {},
|
|
126
|
+
): CorrelationStore {
|
|
127
|
+
const capacity = opts.capacity ?? 4096;
|
|
128
|
+
const fs = opts.fs ?? NODE_FS;
|
|
129
|
+
const log = opts.log ?? (() => {});
|
|
130
|
+
const journalPath = opts.journalPath;
|
|
131
|
+
|
|
132
|
+
// Insertion-ordered map → FIFO. Re-setting a key moves it to newest.
|
|
133
|
+
const map = new Map<string, CorrelationValue>();
|
|
134
|
+
|
|
135
|
+
function put(key: string, value: CorrelationValue): void {
|
|
136
|
+
if (map.has(key)) map.delete(key); // move to newest on update
|
|
137
|
+
map.set(key, value);
|
|
138
|
+
while (map.size > capacity) {
|
|
139
|
+
const oldest = map.keys().next().value as string | undefined;
|
|
140
|
+
if (oldest === undefined) break;
|
|
141
|
+
map.delete(oldest);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function encodeRecord(key: string, v: CorrelationValue): string {
|
|
146
|
+
// Omit threadRoot when absent so pre-existing (pre-threadRoot) journals round-
|
|
147
|
+
// trip byte-identically and a value that never carried a root stays compact.
|
|
148
|
+
const record: { key: string; eventId: string; channelId: string; threadRoot?: string } = {
|
|
149
|
+
key,
|
|
150
|
+
eventId: v.eventId,
|
|
151
|
+
channelId: v.channelId,
|
|
152
|
+
};
|
|
153
|
+
if (v.threadRoot) record.threadRoot = v.threadRoot;
|
|
154
|
+
return JSON.stringify(record);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function serialize(): string {
|
|
158
|
+
let out = "";
|
|
159
|
+
for (const [key, v] of map) {
|
|
160
|
+
out += encodeRecord(key, v) + "\n";
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Number of physical lines in the journal since the last compaction (seeded to
|
|
166
|
+
// the compacted size below). Bounds on-disk growth in a long-lived session.
|
|
167
|
+
let journalLines = 0;
|
|
168
|
+
|
|
169
|
+
// --- Boot compaction (only when a journal path is configured) ---
|
|
170
|
+
if (journalPath) {
|
|
171
|
+
try {
|
|
172
|
+
const dir = dirname(journalPath);
|
|
173
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
174
|
+
if (fs.existsSync(journalPath)) {
|
|
175
|
+
const raw = fs.readFileSync(journalPath, "utf8");
|
|
176
|
+
for (const line of raw.split("\n")) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed) continue;
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(trimmed) as JournalRecord;
|
|
181
|
+
if (
|
|
182
|
+
typeof parsed.key === "string" &&
|
|
183
|
+
parsed.key &&
|
|
184
|
+
typeof parsed.eventId === "string" &&
|
|
185
|
+
parsed.eventId &&
|
|
186
|
+
typeof parsed.channelId === "string" &&
|
|
187
|
+
parsed.channelId
|
|
188
|
+
) {
|
|
189
|
+
// Replay in order — put() enforces last-write-wins + FIFO bound.
|
|
190
|
+
// threadRoot is optional (added post-#4280): accept a non-empty
|
|
191
|
+
// string, otherwise leave undefined so an older record degrades to
|
|
192
|
+
// a reply-only tag rather than corrupting the map.
|
|
193
|
+
const threadRoot =
|
|
194
|
+
typeof parsed.threadRoot === "string" && parsed.threadRoot
|
|
195
|
+
? parsed.threadRoot
|
|
196
|
+
: undefined;
|
|
197
|
+
put(parsed.key, { eventId: parsed.eventId, channelId: parsed.channelId, threadRoot });
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Tolerate a torn final line (crash mid-write) — skip it.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Rewrite compacted, atomically (tmp + rename).
|
|
204
|
+
const tmp = `${journalPath}.tmp`;
|
|
205
|
+
fs.writeFileSync(tmp, serialize());
|
|
206
|
+
fs.renameSync(tmp, journalPath);
|
|
207
|
+
journalLines = map.size;
|
|
208
|
+
log(`buzz-mirror correlation: compacted journal, ${map.size} keys retained`);
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// A journal we cannot read must not disturb the gateway — degrade to an
|
|
212
|
+
// empty in-memory map. The cross-restart guarantee is degraded until the
|
|
213
|
+
// journal is writable again, but the Telegram copy is unaffected.
|
|
214
|
+
log(
|
|
215
|
+
`buzz-mirror correlation: journal load failed, starting empty: ${(err as Error).message}`,
|
|
216
|
+
);
|
|
217
|
+
map.clear();
|
|
218
|
+
journalLines = 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let fd: number | null = null;
|
|
223
|
+
if (journalPath) {
|
|
224
|
+
try {
|
|
225
|
+
fd = fs.openSync(journalPath, "a");
|
|
226
|
+
} catch (err) {
|
|
227
|
+
log(`buzz-mirror correlation: could not open append fd: ${(err as Error).message}`);
|
|
228
|
+
fd = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function compactInPlace(): void {
|
|
233
|
+
if (!journalPath) return;
|
|
234
|
+
try {
|
|
235
|
+
// Close the append fd, rewrite from memory, reopen.
|
|
236
|
+
if (fd !== null) {
|
|
237
|
+
try {
|
|
238
|
+
fs.closeSync(fd);
|
|
239
|
+
} catch {
|
|
240
|
+
/* nothing to do */
|
|
241
|
+
}
|
|
242
|
+
fd = null;
|
|
243
|
+
}
|
|
244
|
+
const tmp = `${journalPath}.tmp`;
|
|
245
|
+
fs.writeFileSync(tmp, serialize());
|
|
246
|
+
fs.renameSync(tmp, journalPath);
|
|
247
|
+
journalLines = map.size;
|
|
248
|
+
fd = fs.openSync(journalPath, "a");
|
|
249
|
+
} catch (err) {
|
|
250
|
+
log(`buzz-mirror correlation: in-session compaction failed: ${(err as Error).message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
get(key: string): CorrelationValue | undefined {
|
|
256
|
+
return map.get(key);
|
|
257
|
+
},
|
|
258
|
+
set(key: string, value: CorrelationValue): void {
|
|
259
|
+
put(key, value);
|
|
260
|
+
if (fd !== null && journalPath) {
|
|
261
|
+
try {
|
|
262
|
+
fs.writeSync(fd, encodeRecord(key, value) + "\n");
|
|
263
|
+
fs.fsyncSync(fd);
|
|
264
|
+
journalLines++;
|
|
265
|
+
if (journalLines > capacity * COMPACTION_FACTOR) compactInPlace();
|
|
266
|
+
} catch (err) {
|
|
267
|
+
log(`buzz-mirror correlation: journal append failed: ${(err as Error).message}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
size(): number {
|
|
272
|
+
return map.size;
|
|
273
|
+
},
|
|
274
|
+
close(): void {
|
|
275
|
+
if (fd !== null) {
|
|
276
|
+
try {
|
|
277
|
+
fs.closeSync(fd);
|
|
278
|
+
} catch {
|
|
279
|
+
/* nothing to do */
|
|
280
|
+
}
|
|
281
|
+
fd = null;
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|