switchroom 0.20.0 → 0.20.2

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 (29) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +4 -3
  4. package/dist/buzz-gateway/index.js +166 -6
  5. package/dist/cli/notion-write-pretool.mjs +2 -2
  6. package/dist/cli/switchroom.js +24704 -16399
  7. package/dist/host-control/main.js +44 -10
  8. package/dist/vault/approvals/kernel-server.js +4 -3
  9. package/dist/vault/broker/server.js +4 -3
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +79 -10
  12. package/telegram-plugin/dist/gateway/gateway.js +1400 -964
  13. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  14. package/telegram-plugin/gateway/access-store.ts +194 -0
  15. package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
  16. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  17. package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
  18. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  19. package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
  20. package/telegram-plugin/gateway/gateway.ts +43 -123
  21. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  22. package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
  23. package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
  24. package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
  25. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  26. package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
  27. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  28. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  29. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
+ import { GATEWAY_BOOT_BRIEFING_CAPABILITY } from './boot-briefing-capability.js'
18
19
  import { getHistoryDbForBriefing } from '../history.js'
19
20
  import type { InboundMessage } from './ipc-protocol.js'
20
21
  import {
@@ -24,8 +25,17 @@ import {
24
25
  excludeWindowFromResumeInbound,
25
26
  readRestartBreadcrumb,
26
27
  renderBootBriefing,
28
+ type BriefingDailyMemory,
29
+ type HindsightRecallResult,
27
30
  } from './boot-briefing-builder.js'
28
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
+
29
39
  export interface MaybeQueueBootBriefingOptions {
30
40
  env: Record<string, string | undefined>
31
41
  /** Gateway STATE_DIR (`<agentDir>/telegram` in production). */
@@ -39,16 +49,141 @@ export interface MaybeQueueBootBriefingOptions {
39
49
  put: (agent: string, msg: InboundMessage) => unknown
40
50
  log?: (line: string) => void
41
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
+ }
42
167
  }
43
168
 
44
169
  /**
45
170
  * Build + enqueue the boot briefing when the feature flag and suppression
46
171
  * rules allow. Returns the queued inbound (for observability/tests) or
47
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.
48
183
  */
49
- export function maybeQueueBootBriefing(
184
+ export async function maybeQueueBootBriefing(
50
185
  opts: MaybeQueueBootBriefingOptions,
51
- ): InboundMessage | null {
186
+ ): Promise<InboundMessage | null> {
52
187
  const log = opts.log ?? ((l: string) => process.stderr.write(l))
53
188
  try {
54
189
  const agentDir = opts.stateDir.endsWith('/telegram')
@@ -130,12 +265,37 @@ export function maybeQueueBootBriefing(
130
265
  nowMs,
131
266
  exclude: excludeWindowFromResumeInbound(opts.resumeMsg),
132
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
+ }
133
285
  const restartReason = readRestartBreadcrumb({
134
286
  restartReasonPath: join(agentDir, '.restart-reason'),
135
287
  env: opts.env,
136
288
  readFile: (p) => readFileSync(p, 'utf8'),
137
289
  })
138
- const text = renderBootBriefing(surfaces, { nowMs, restartReason })
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 })
139
299
  if (!text) {
140
300
  // Consume the generation even when empty: had there been nothing to
141
301
  // brief at boot, a later respawn must not suddenly brief mid-session
@@ -156,7 +316,9 @@ export function maybeQueueBootBriefing(
156
316
  log(
157
317
  `telegram gateway: boot-briefing queued chat=${primary.chatId}` +
158
318
  `${primary.threadId != null ? ` thread=${primary.threadId}` : ''} ` +
159
- `surfaces=${surfaces.length} chars=${text.length}\n`,
319
+ `surfaces=${surfaces.length} hindsight=${hindsight.length} ` +
320
+ `daily=${dailyMemory != null ? 'yes' : 'no'} chars=${text.length} ` +
321
+ `cap=${GATEWAY_BOOT_BRIEFING_CAPABILITY}\n`,
160
322
  )
161
323
  return msg
162
324
  } catch (err) {
@@ -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
+ }