switchroom 0.16.38 → 0.16.47

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 (59) hide show
  1. package/dist/agent-scheduler/index.js +8 -2
  2. package/dist/auth-broker/index.js +7 -1
  3. package/dist/cli/notion-write-pretool.mjs +7 -1
  4. package/dist/cli/switchroom.js +1259 -375
  5. package/dist/cli/ui/index.html +877 -214
  6. package/dist/host-control/main.js +116 -84
  7. package/dist/vault/approvals/kernel-server.js +8 -2
  8. package/dist/vault/broker/server.js +8 -2
  9. package/examples/minimal.yaml +1 -1
  10. package/examples/switchroom.yaml +1 -1
  11. package/package.json +2 -2
  12. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  13. package/skills/switchroom-status/SKILL.md +1 -1
  14. package/telegram-plugin/bridge/bridge.ts +2 -1
  15. package/telegram-plugin/card-format.ts +7 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +20 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +2197 -964
  18. package/telegram-plugin/dist/server.js +20 -2
  19. package/telegram-plugin/format.ts +305 -31
  20. package/telegram-plugin/gateway/gateway.ts +310 -70
  21. package/telegram-plugin/gateway/model-command.ts +173 -19
  22. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  23. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  24. package/telegram-plugin/package.json +1 -1
  25. package/telegram-plugin/session-tail.ts +47 -1
  26. package/telegram-plugin/stream-reply-handler.ts +19 -1
  27. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  28. package/telegram-plugin/tests/card-format.test.ts +28 -0
  29. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  30. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  31. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  32. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  33. package/telegram-plugin/tests/model-command.test.ts +213 -47
  34. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  35. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  36. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  37. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  39. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  40. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  41. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  42. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  43. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  44. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  45. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  46. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  47. package/telegram-plugin/text-voice-scrub.ts +68 -18
  48. package/telegram-plugin/tool-activity-summary.ts +20 -108
  49. package/telegram-plugin/tts-normalize.ts +377 -0
  50. package/telegram-plugin/uat/driver.ts +472 -22
  51. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  52. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  53. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  56. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  57. package/telegram-plugin/voice-ondemand.ts +161 -10
  58. package/telegram-plugin/voice-presynth.ts +242 -0
  59. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -0,0 +1,297 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { decodeRichMessage } from "./driver.js";
3
+
4
+ /**
5
+ * Direct unit tests for `decodeRichMessage` — the Bot API 10.1 rich-message
6
+ * decoder in the MTProto UAT driver. These are HOSTED (no live Telegram, no
7
+ * driver session): they feed hand-built Instant-View page trees whose shapes
8
+ * are grounded 1:1 against `@mtcute/tl@223.0.0` (the TL layer mtcute 0.30
9
+ * ships) and assert the flat `{ text, entities }` the decoder produces.
10
+ *
11
+ * They are the CI-verifiable floor under the live render scenario
12
+ * (`scenarios/jtbd-rich-formatting-render-dm.test.ts`), which can only run on
13
+ * the gated uat-host. If Telegram's real GFM parser ever emits a shape these
14
+ * fixtures don't model, the live scenario catches it; these tests pin the
15
+ * decoder's behavior for the shapes we DO model so a decoder regression reds
16
+ * in ordinary CI without needing the runner.
17
+ *
18
+ * TL shape sources (all from `@mtcute/tl@223.0.0` index.d.ts):
19
+ * RichText: textPlain{text:string} · textConcat{texts:RichText[]} ·
20
+ * textBold/Italic/Underline/Strike/Fixed{text:RichText} ·
21
+ * textUrl{text:RichText,url:string} · textEmail{text,email} ·
22
+ * textMarked{text:RichText} (no textSpoiler exists → spoiler)
23
+ * PageBlock: pageBlockParagraph{text} · pageBlockPreformatted{text,language}
24
+ * · pageBlockHeader/Subheader{text} · pageBlockBlockquote/
25
+ * Pullquote{text,caption} · pageBlockList{items:PageListItem[]} ·
26
+ * pageBlockOrderedList{items:PageListOrderedItem[]} ·
27
+ * pageBlockTable{title,rows:PageTableRow[]} · pageBlockDivider
28
+ * Items: pageListItemText{text} · pageListItemBlocks{blocks} ·
29
+ * pageListOrderedItemText{num,text} ·
30
+ * pageListOrderedItemBlocks{num,blocks}
31
+ * Table: pageTableRow{cells:PageTableCell[]} ·
32
+ * pageTableCell{header?,text?:RichText}
33
+ */
34
+
35
+ // ── RichText builders (grounded shapes) ─────────────────────────────────────
36
+ const plain = (text: string) => ({ _: "textPlain", text });
37
+ const concat = (...texts: unknown[]) => ({ _: "textConcat", texts });
38
+ const bold = (text: unknown) => ({ _: "textBold", text });
39
+ const italic = (text: unknown) => ({ _: "textItalic", text });
40
+ const underline = (text: unknown) => ({ _: "textUnderline", text });
41
+ const strike = (text: unknown) => ({ _: "textStrike", text });
42
+ const fixed = (text: unknown) => ({ _: "textFixed", text });
43
+ const marked = (text: unknown) => ({ _: "textMarked", text }); // → spoiler
44
+ const url = (text: unknown, u: string) => ({ _: "textUrl", text, url: u });
45
+ const email = (text: unknown, e: string) => ({ _: "textEmail", text, email: e });
46
+
47
+ // ── PageBlock builders ──────────────────────────────────────────────────────
48
+ const para = (text: unknown) => ({ _: "pageBlockParagraph", text });
49
+ const pre = (text: unknown, language = "") => ({
50
+ _: "pageBlockPreformatted",
51
+ text,
52
+ language,
53
+ });
54
+ const header = (text: unknown) => ({ _: "pageBlockHeader", text });
55
+ const subheader = (text: unknown) => ({ _: "pageBlockSubheader", text });
56
+ const blockquote = (text: unknown, caption: unknown = { _: "textEmpty" }) => ({
57
+ _: "pageBlockBlockquote",
58
+ text,
59
+ caption,
60
+ });
61
+ const list = (...items: unknown[]) => ({ _: "pageBlockList", items });
62
+ const orderedList = (...items: unknown[]) => ({
63
+ _: "pageBlockOrderedList",
64
+ items,
65
+ });
66
+ const table = (title: unknown, rows: unknown[]) => ({
67
+ _: "pageBlockTable",
68
+ title,
69
+ rows,
70
+ });
71
+ const divider = () => ({ _: "pageBlockDivider" });
72
+
73
+ const itemText = (text: unknown) => ({ _: "pageListItemText", text });
74
+ const ordItemText = (num: string, text: unknown) => ({
75
+ _: "pageListOrderedItemText",
76
+ num,
77
+ text,
78
+ });
79
+ const row = (...cells: unknown[]) => ({ _: "pageTableRow", cells });
80
+ const cell = (text: unknown, header = false) => ({
81
+ _: "pageTableCell",
82
+ ...(header ? { header: true } : {}),
83
+ text,
84
+ });
85
+
86
+ const rich = (...blocks: unknown[]) => ({ _: "richMessage", blocks });
87
+
88
+ // A convenience matcher — find the single entity of a kind.
89
+ const entOf = (
90
+ r: { entities: Array<{ kind: string }> },
91
+ kind: string,
92
+ ) => r.entities.filter((e) => e.kind === kind);
93
+
94
+ describe("decodeRichMessage — non-rich guards", () => {
95
+ it("returns null for a non-richMessage value", () => {
96
+ expect(decodeRichMessage(undefined)).toBeNull();
97
+ expect(decodeRichMessage(null)).toBeNull();
98
+ expect(decodeRichMessage({ _: "message", blocks: [] })).toBeNull();
99
+ expect(decodeRichMessage({ _: "richMessage" })).toBeNull(); // no blocks[]
100
+ });
101
+
102
+ it("decodes an empty block list to empty output", () => {
103
+ expect(decodeRichMessage(rich())).toEqual({ text: "", entities: [] });
104
+ });
105
+ });
106
+
107
+ describe("decodeRichMessage — inline RichText marks (existing coverage)", () => {
108
+ it("bold / italic / code with exact spans", () => {
109
+ const r = decodeRichMessage(
110
+ rich(
111
+ para(
112
+ concat(
113
+ plain("a "),
114
+ bold(plain("bold")),
115
+ plain(" "),
116
+ italic(plain("it")),
117
+ plain(" "),
118
+ fixed(plain("code")),
119
+ ),
120
+ ),
121
+ ),
122
+ )!;
123
+ expect(r.text).toBe("a bold it code");
124
+ expect(entOf(r, "bold")[0]).toMatchObject({ offset: 2, length: 4, text: "bold" });
125
+ expect(entOf(r, "italic")[0]).toMatchObject({ text: "it" });
126
+ expect(entOf(r, "code")[0]).toMatchObject({ text: "code" });
127
+ });
128
+
129
+ it("text_link carries the destination url; email classified", () => {
130
+ const r = decodeRichMessage(
131
+ rich(
132
+ para(
133
+ concat(
134
+ url(plain("repo"), "https://example.com/x"),
135
+ plain(" "),
136
+ email(plain("a@b.com"), "a@b.com"),
137
+ ),
138
+ ),
139
+ ),
140
+ )!;
141
+ expect(entOf(r, "text_link")[0]).toMatchObject({
142
+ text: "repo",
143
+ url: "https://example.com/x",
144
+ });
145
+ expect(entOf(r, "email")[0]).toMatchObject({ text: "a@b.com" });
146
+ });
147
+
148
+ it("pageBlockPreformatted → a single pre entity with language", () => {
149
+ const r = decodeRichMessage(rich(pre(plain('echo "hi"'), "bash")))!;
150
+ expect(r.text).toBe('echo "hi"');
151
+ expect(entOf(r, "pre")[0]).toMatchObject({
152
+ offset: 0,
153
+ length: 9,
154
+ language: "bash",
155
+ });
156
+ });
157
+ });
158
+
159
+ describe("decodeRichMessage — strikethrough / underline / spoiler marks", () => {
160
+ it("textStrike → strikethrough, textUnderline → underline", () => {
161
+ const r = decodeRichMessage(
162
+ rich(para(concat(strike(plain("gone")), plain(" "), underline(plain("keep"))))),
163
+ )!;
164
+ expect(r.text).toBe("gone keep");
165
+ expect(entOf(r, "strikethrough")[0]).toMatchObject({ text: "gone", offset: 0 });
166
+ expect(entOf(r, "underline")[0]).toMatchObject({ text: "keep", offset: 5 });
167
+ });
168
+
169
+ it("textMarked → spoiler (TL 223 has no textSpoiler)", () => {
170
+ const r = decodeRichMessage(rich(para(concat(plain("psst "), marked(plain("secret"))))))!;
171
+ expect(r.text).toBe("psst secret");
172
+ expect(entOf(r, "spoiler")[0]).toMatchObject({ text: "secret", offset: 5 });
173
+ });
174
+ });
175
+
176
+ describe("decodeRichMessage — headings", () => {
177
+ it("pageBlockHeader / Subheader render as bold over the line", () => {
178
+ const r = decodeRichMessage(
179
+ rich(header(plain("Title")), subheader(plain("Sub")), para(plain("body"))),
180
+ )!;
181
+ expect(r.text).toBe("Title\nSub\nbody");
182
+ const bolds = entOf(r, "bold");
183
+ expect(bolds.map((e) => e.text).sort()).toEqual(["Sub", "Title"]);
184
+ expect(bolds.find((e) => e.text === "Title")).toMatchObject({ offset: 0, length: 5 });
185
+ });
186
+ });
187
+
188
+ describe("decodeRichMessage — blockquotes", () => {
189
+ it("pageBlockBlockquote → blockquote entity over the body", () => {
190
+ const r = decodeRichMessage(rich(blockquote(plain("quoted line"))))!;
191
+ expect(r.text).toBe("quoted line");
192
+ expect(entOf(r, "blockquote")[0]).toMatchObject({
193
+ offset: 0,
194
+ length: 11,
195
+ text: "quoted line",
196
+ });
197
+ });
198
+
199
+ it("blockquote caption follows on its own line, unstyled", () => {
200
+ const r = decodeRichMessage(rich(blockquote(plain("body"), plain("— src"))))!;
201
+ expect(r.text).toBe("body\n— src");
202
+ expect(entOf(r, "blockquote")[0]).toMatchObject({ text: "body" });
203
+ // caption is not part of the blockquote span
204
+ expect(entOf(r, "blockquote")[0].length).toBe(4);
205
+ });
206
+ });
207
+
208
+ describe("decodeRichMessage — lists", () => {
209
+ it("unordered list → bulleted lines, inline marks preserved", () => {
210
+ const r = decodeRichMessage(
211
+ rich(list(itemText(plain("first")), itemText(bold(plain("second"))))),
212
+ )!;
213
+ expect(r.text).toBe("• first\n• second");
214
+ expect(entOf(r, "bold")[0]).toMatchObject({ text: "second" });
215
+ });
216
+
217
+ it("ordered list → numbered lines using the wire .num label", () => {
218
+ const r = decodeRichMessage(
219
+ rich(
220
+ orderedList(
221
+ ordItemText("1", plain("alpha")),
222
+ ordItemText("2", plain("beta")),
223
+ ),
224
+ ),
225
+ )!;
226
+ expect(r.text).toBe("1. alpha\n2. beta");
227
+ });
228
+ });
229
+
230
+ describe("decodeRichMessage — tables", () => {
231
+ it("table rows join with ' | ', header cells emit bold", () => {
232
+ const r = decodeRichMessage(
233
+ rich(
234
+ table({ _: "textEmpty" }, [
235
+ row(cell(plain("H1"), true), cell(plain("H2"), true)),
236
+ row(cell(plain("a")), cell(plain("b"))),
237
+ ]),
238
+ ),
239
+ )!;
240
+ expect(r.text).toBe("H1 | H2\na | b");
241
+ const bolds = entOf(r, "bold").map((e) => e.text).sort();
242
+ expect(bolds).toEqual(["H1", "H2"]);
243
+ // body cells are not bold
244
+ expect(entOf(r, "bold").some((e) => e.text === "a")).toBe(false);
245
+ });
246
+
247
+ it("table title renders on its own line above the rows", () => {
248
+ const r = decodeRichMessage(
249
+ rich(table(plain("Scores"), [row(cell(plain("x")), cell(plain("y")))])),
250
+ )!;
251
+ expect(r.text).toBe("Scores\nx | y");
252
+ });
253
+ });
254
+
255
+ describe("decodeRichMessage — divider", () => {
256
+ it("pageBlockDivider → a literal rule line, no entity", () => {
257
+ const r = decodeRichMessage(rich(para(plain("above")), divider(), para(plain("below"))))!;
258
+ expect(r.text).toBe("above\n———\nbelow");
259
+ expect(r.entities).toEqual([]);
260
+ });
261
+ });
262
+
263
+ describe("decodeRichMessage — full torture tree (offsets stay UTF-16 correct)", () => {
264
+ it("mixed blocks decode with coherent offsets across the whole body", () => {
265
+ const r = decodeRichMessage(
266
+ rich(
267
+ header(plain("Report")),
268
+ para(
269
+ concat(
270
+ bold(plain("bold")),
271
+ plain(" "),
272
+ italic(plain("it")),
273
+ plain(" "),
274
+ strike(plain("old")),
275
+ plain(" "),
276
+ marked(plain("spoil")),
277
+ ),
278
+ ),
279
+ blockquote(plain("a wise quote")),
280
+ list(itemText(plain("one")), itemText(plain("two"))),
281
+ orderedList(ordItemText("1", plain("step"))),
282
+ divider(),
283
+ pre(plain("code()"), "js"),
284
+ ),
285
+ )!;
286
+ // Every entity's recorded text must equal the slice at its offset —
287
+ // the invariant that proves offset bookkeeping is coherent.
288
+ for (const e of r.entities) {
289
+ expect(r.text.slice(e.offset, e.offset + e.length)).toBe(e.text);
290
+ }
291
+ // Sanity on the kinds present.
292
+ const kinds = new Set(r.entities.map((e) => e.kind));
293
+ for (const k of ["bold", "italic", "strikethrough", "spoiler", "blockquote", "pre"]) {
294
+ expect(kinds.has(k)).toBe(true);
295
+ }
296
+ });
297
+ });
@@ -17,6 +17,8 @@
17
17
  */
18
18
 
19
19
  import { randomBytes } from 'crypto'
20
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs'
21
+ import { dirname } from 'path'
20
22
 
21
23
  /** Reserved callback_data prefix for on-demand Listen buttons. Handled
22
24
  * INTERNALLY by the gateway's callback dispatcher (never routed to the agent
@@ -24,8 +26,14 @@ import { randomBytes } from 'crypto'
24
26
  export const VOICE_ONDEMAND_CALLBACK_PREFIX = 'voice:'
25
27
 
26
28
  /** Cache entry TTL. A button tapped after this degrades to an "expired" toast
27
- * rather than pinning reply text in memory forever. */
28
- export const VOICE_ONDEMAND_TTL_MS = 60 * 60 * 1000 // 1h
29
+ * rather than pinning reply text in memory forever.
30
+ *
31
+ * 7 days since #2763 (was 1h): eager pre-synthesis keeps the spoken file on
32
+ * disk for 7 days, and the acceptance bar is "tapping Listen on any message
33
+ * <7 days old plays instantly" — which needs the ENTRY alive that long too
34
+ * (the lazy fallback also reads it). Memory stays bounded by
35
+ * VOICE_ONDEMAND_MAX_ENTRIES; the entry is a few hundred bytes of text. */
36
+ export const VOICE_ONDEMAND_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
29
37
 
30
38
  /** Bounded cache size; oldest entries are evicted past this. */
31
39
  export const VOICE_ONDEMAND_MAX_ENTRIES = 500
@@ -37,34 +45,86 @@ export type VoiceOnDemandPayload = {
37
45
  voice?: string
38
46
  /** Resolved + clamped playback speed. */
39
47
  speed: number
48
+ /** Absolute path of the eagerly pre-synthesized audio file (#2763), set by
49
+ * the pre-synth queue after a successful background synth. Absent on
50
+ * pre-feature entries, on kill-switched gateways, and until the job runs. */
51
+ filePath?: string
52
+ /** Epoch ms the entry was stored (put time) — recorded alongside filePath
53
+ * per #2763 so the sweep/introspection can reason about entry age. */
54
+ createdAt?: number
40
55
  }
41
56
 
42
57
  type StoredEntry = VoiceOnDemandPayload & { expiresAt: number }
43
58
 
59
+ /** On-disk shape of the persisted cache: a versioned envelope around the
60
+ * token→entry map. Versioned so a future schema change can be detected and
61
+ * discarded rather than mis-parsed. */
62
+ type PersistedCache = {
63
+ version: 1
64
+ entries: Record<string, StoredEntry>
65
+ }
66
+
67
+ /**
68
+ * Options for {@link VoiceOnDemandCache}. `persistPath`, when set, backs the
69
+ * cache with a JSON file so Listen tokens survive a gateway restart — a button
70
+ * tapped after a restart still synthesizes instead of degrading to "Voice
71
+ * expired". Omit it (the default) for a pure in-memory cache.
72
+ */
73
+ export type VoiceOnDemandCacheOptions = {
74
+ ttlMs?: number
75
+ maxEntries?: number
76
+ now?: () => number
77
+ /** Absolute path to the JSON persistence file (e.g. under TELEGRAM_STATE_DIR,
78
+ * which lives on a volume that survives container recreation). When set, the
79
+ * cache loads existing (non-expired) entries on construction and rewrites the
80
+ * file on every mutation. */
81
+ persistPath?: string
82
+ }
83
+
44
84
  /**
45
85
  * Bounded, TTL'd LRU cache from Listen token → synthesis payload. Keyed by
46
86
  * token (not message_id) because the message_id isn't known when the button
47
87
  * is sent. A Map preserves insertion order, so the first key is the oldest.
88
+ *
89
+ * Optionally durable: pass `persistPath` to back the store with a JSON file so
90
+ * tokens survive a gateway restart. Persistence uses a synchronous
91
+ * write-to-temp + atomic rename on each mutation — the store is small (≤
92
+ * maxEntries) and writes are infrequent (one per reply that offers a Listen
93
+ * button), so the cost is negligible and the module stays dependency-free.
94
+ * Any load/write error is swallowed (logged to stderr): a broken persistence
95
+ * file must never take the gateway down — the cache simply degrades to the old
96
+ * in-memory behaviour.
48
97
  */
49
98
  export class VoiceOnDemandCache {
50
99
  private readonly store = new Map<string, StoredEntry>()
100
+ private readonly ttlMs: number
101
+ private readonly maxEntries: number
102
+ private readonly now: () => number
103
+ private readonly persistPath: string | undefined
51
104
 
52
- constructor(
53
- private readonly ttlMs: number = VOICE_ONDEMAND_TTL_MS,
54
- private readonly maxEntries: number = VOICE_ONDEMAND_MAX_ENTRIES,
55
- private readonly now: () => number = Date.now,
56
- ) {}
105
+ constructor(options: VoiceOnDemandCacheOptions = {}) {
106
+ this.ttlMs = options.ttlMs ?? VOICE_ONDEMAND_TTL_MS
107
+ this.maxEntries = options.maxEntries ?? VOICE_ONDEMAND_MAX_ENTRIES
108
+ this.now = options.now ?? Date.now
109
+ this.persistPath = options.persistPath
110
+ if (this.persistPath !== undefined) this.load()
111
+ }
57
112
 
58
113
  /** Store a payload under `token`, evicting the oldest entries past the cap. */
59
114
  put(token: string, payload: VoiceOnDemandPayload): void {
60
115
  // Re-insert to move an existing key to the newest (most-recent) position.
61
116
  this.store.delete(token)
62
- this.store.set(token, { ...payload, expiresAt: this.now() + this.ttlMs })
117
+ this.store.set(token, {
118
+ createdAt: this.now(),
119
+ ...payload,
120
+ expiresAt: this.now() + this.ttlMs,
121
+ })
63
122
  while (this.store.size > this.maxEntries) {
64
123
  const oldest = this.store.keys().next().value
65
124
  if (oldest === undefined) break
66
125
  this.store.delete(oldest)
67
126
  }
127
+ this.flush()
68
128
  }
69
129
 
70
130
  /** Look up a payload; returns null on miss or expiry (and evicts expired). */
@@ -73,16 +133,107 @@ export class VoiceOnDemandCache {
73
133
  if (entry == null) return null
74
134
  if (entry.expiresAt <= this.now()) {
75
135
  this.store.delete(token)
136
+ this.flush()
76
137
  return null
77
138
  }
78
- const { text, voice, speed } = entry
79
- return voice === undefined ? { text, speed } : { text, voice, speed }
139
+ const { text, voice, speed, filePath } = entry
140
+ // createdAt stays internal (persisted for sweep/introspection) so the
141
+ // returned shape only grows when a pre-synth file actually exists —
142
+ // pre-#2763 callers and tests see the exact old payload.
143
+ return {
144
+ text,
145
+ speed,
146
+ ...(voice !== undefined ? { voice } : {}),
147
+ ...(filePath !== undefined ? { filePath } : {}),
148
+ }
149
+ }
150
+
151
+ /** Record the pre-synthesized audio file path on an existing entry (#2763).
152
+ * No-op if the entry has expired or been evicted meanwhile (the file will
153
+ * simply age out via the sweep). Does NOT bump LRU position or TTL — the
154
+ * entry's lifetime is anchored at put time. */
155
+ setFilePath(token: string, filePath: string): void {
156
+ const entry = this.store.get(token)
157
+ if (entry == null || entry.expiresAt <= this.now()) return
158
+ entry.filePath = filePath
159
+ this.flush()
160
+ }
161
+
162
+ /** Drop entries whose pre-synth files the sweep deleted (#2763). Unknown
163
+ * tokens are ignored (file may belong to an already-evicted entry). */
164
+ prune(tokens: Iterable<string>): void {
165
+ let changed = false
166
+ for (const token of tokens) {
167
+ if (this.store.delete(token)) changed = true
168
+ }
169
+ if (changed) this.flush()
80
170
  }
81
171
 
82
172
  /** Current entry count (test/introspection aid). */
83
173
  get size(): number {
84
174
  return this.store.size
85
175
  }
176
+
177
+ /** Load persisted entries, dropping any already expired. Best-effort: a
178
+ * missing or corrupt file leaves the cache empty (fresh-start). */
179
+ private load(): void {
180
+ if (this.persistPath === undefined) return
181
+ let raw: string
182
+ try {
183
+ raw = readFileSync(this.persistPath, 'utf8')
184
+ } catch {
185
+ // No file yet (first boot) — nothing to load.
186
+ return
187
+ }
188
+ try {
189
+ const parsed = JSON.parse(raw) as PersistedCache
190
+ if (parsed == null || parsed.version !== 1 || typeof parsed.entries !== 'object') return
191
+ const nowMs = this.now()
192
+ // Object.entries preserves insertion order for string keys, which is the
193
+ // insertion order we wrote — so LRU ordering survives the round-trip.
194
+ for (const [token, entry] of Object.entries(parsed.entries)) {
195
+ if (
196
+ entry == null ||
197
+ typeof entry.expiresAt !== 'number' ||
198
+ typeof entry.text !== 'string' ||
199
+ typeof entry.speed !== 'number'
200
+ ) {
201
+ continue
202
+ }
203
+ if (entry.expiresAt <= nowMs) continue // already expired — drop
204
+ this.store.set(token, entry)
205
+ }
206
+ // Honour the cap in case the file predates a smaller maxEntries.
207
+ while (this.store.size > this.maxEntries) {
208
+ const oldest = this.store.keys().next().value
209
+ if (oldest === undefined) break
210
+ this.store.delete(oldest)
211
+ }
212
+ } catch (err) {
213
+ process.stderr.write(
214
+ `voice-ondemand: failed to parse persisted cache ${this.persistPath}: ${String(err)}\n`,
215
+ )
216
+ }
217
+ }
218
+
219
+ /** Write the current store to disk atomically. No-op when not persisting. */
220
+ private flush(): void {
221
+ if (this.persistPath === undefined) return
222
+ const doc: PersistedCache = {
223
+ version: 1,
224
+ entries: Object.fromEntries(this.store),
225
+ }
226
+ const tmp = `${this.persistPath}.tmp`
227
+ try {
228
+ mkdirSync(dirname(this.persistPath), { recursive: true })
229
+ writeFileSync(tmp, JSON.stringify(doc), 'utf8')
230
+ renameSync(tmp, this.persistPath)
231
+ } catch (err) {
232
+ process.stderr.write(
233
+ `voice-ondemand: failed to persist cache ${this.persistPath}: ${String(err)}\n`,
234
+ )
235
+ }
236
+ }
86
237
  }
87
238
 
88
239
  /** Mint a short, unguessable Listen token (8 hex chars). */