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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +2 -2
- package/dist/auth-broker/index.js +4 -3
- package/dist/buzz-gateway/index.js +166 -6
- package/dist/cli/notion-write-pretool.mjs +2 -2
- package/dist/cli/switchroom.js +24704 -16399
- package/dist/host-control/main.js +44 -10
- package/dist/vault/approvals/kernel-server.js +4 -3
- package/dist/vault/broker/server.js +4 -3
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -10
- package/telegram-plugin/dist/gateway/gateway.js +1400 -964
- 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 +135 -7
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
- package/telegram-plugin/gateway/gateway.ts +43 -123
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
- 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
|
@@ -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
|
-
|
|
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}
|
|
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
|
+
}
|