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