@gotcos/glasses-server 6.13.0 → 6.14.0

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.
@@ -0,0 +1,596 @@
1
+ // In-memory + disk cache and session manager for the TTS progressive-download
2
+ // path.
3
+ //
4
+ // Why this exists: the v5.9.2 voice playback path replaces the old "fetch then
5
+ // play full Blob" model with a server-prepared session URL the browser sets as
6
+ // audio.src. iOS WKWebView progressive-decodes the chunked MP3, dropping
7
+ // time-to-first-audio from up to 15s to ~1s on long messages.
8
+ //
9
+ // Three responsibilities, one module:
10
+ // 1. Sessions: a UUID minted by POST /api/tts/prepare, peeked (not consumed)
11
+ // by GET /api/tts/play so iOS can issue HTTP Range refills against the
12
+ // same URL. Short-lived (60s TTL), reaped periodically.
13
+ // 2. Memory cache: completed audio bodies keyed by sha256(text+voice+format).
14
+ // Repeat REPLAYs of the same message hit the cache and serve in ~50ms
15
+ // with no OpenAI round-trip. LRU-evicted, byte-bounded.
16
+ // 3. Disk mirror (v5.9.5): every completed entry is also written to
17
+ // server/data/tts-cache/<hash>.{mp3,json} so cache state SURVIVES server
18
+ // restarts. On startup we scan sidecars and rebuild the LRU index
19
+ // lazily — bodies are only read on first hit, not at boot. Two
20
+ // independent eviction policies cap the on-disk footprint:
21
+ // - Size LRU at TTS_DISK_CACHE_MAX_MB (default 1250, ~1.25 GB)
22
+ // - Rolling age TTL at TTS_DISK_CACHE_MAX_AGE_DAYS (default 30)
23
+ //
24
+ // Bounds tuned for our usage pattern (one user, sequential clicks, replies
25
+ // roughly 200 KB-2 MB of MP3): in-memory 50 entries / 100 MB total. Disk is
26
+ // far larger. In-flight memory entries are pinned (never evicted) so the
27
+ // response stream and cache writer can't be pulled out from under each other.
28
+
29
+ import { createHash, randomUUID } from 'node:crypto'
30
+ import {
31
+ existsSync,
32
+ mkdirSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ statSync,
36
+ unlinkSync,
37
+ } from 'node:fs'
38
+ import { dirname, resolve } from 'node:path'
39
+ import { fileURLToPath } from 'node:url'
40
+ import { atomicWriteFileSync } from './atomic-fs.js'
41
+
42
+ const __dirname = dirname(fileURLToPath(import.meta.url))
43
+
44
+ interface CacheEntry {
45
+ /** The audio body. NULL when the entry exists in the disk index but has not
46
+ * been hydrated from disk yet — first getCached() call lazily reads the
47
+ * MP3 file and populates this. */
48
+ bytes: Buffer | null
49
+ sizeBytes: number
50
+ complete: boolean
51
+ lastAccess: number
52
+ /** When the entry was first written to disk (epoch ms). Used for age TTL
53
+ * eviction. Memory-only entries (in-flight or never persisted) carry the
54
+ * creation timestamp so they don't get spuriously evicted as "stale". */
55
+ completedAt: number
56
+ /** Voice + format are recorded so a future stats endpoint or admin tool can
57
+ * surface what's actually in the cache without re-deriving from the hash. */
58
+ voice: string
59
+ format: string
60
+ /** True iff the body is mirrored to disk. Hot in-flight entries flip this
61
+ * to true once completeEntry() finishes the disk write. */
62
+ onDisk: boolean
63
+ }
64
+
65
+ interface SessionEntry {
66
+ hash: string
67
+ text: string
68
+ voice: string
69
+ format: string
70
+ expiresAt: number
71
+ }
72
+
73
+ interface DiskSidecar {
74
+ voice: string
75
+ format: string
76
+ sizeBytes: number
77
+ completedAt: number
78
+ }
79
+
80
+ const MAX_ENTRIES = 50
81
+ const MAX_TOTAL_BYTES = 100 * 1024 * 1024 // 100 MB — in-memory cap
82
+ const SESSION_TTL_MS = 60_000
83
+
84
+ /** Disk cache configuration (env-overridable). Defaults sized for "I run this
85
+ * on my laptop and forget about it for months" rather than a service tier.
86
+ * When TTS_DISK_CACHE_DIR is an absolute path it's used as-is; relative
87
+ * values resolve against server/data/. */
88
+ const DISK_DIR = (() => {
89
+ const override = process.env.TTS_DISK_CACHE_DIR
90
+ if (override && override.startsWith('/')) return override
91
+ return resolve(__dirname, '..', 'data', override || 'tts-cache')
92
+ })()
93
+ const MAX_DISK_BYTES = Number(process.env.TTS_DISK_CACHE_MAX_MB ?? 1250) * 1024 * 1024
94
+ const MAX_AGE_DAYS = Number(process.env.TTS_DISK_CACHE_MAX_AGE_DAYS ?? 30)
95
+ const MAX_AGE_MS = Math.max(0, MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
96
+ /** Sweeper cadence — once per 24 hours. unref()'d below so an idle server can
97
+ * still exit cleanly. */
98
+ const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000
99
+
100
+ const cache = new Map<string, CacheEntry>()
101
+ const sessions = new Map<string, SessionEntry>()
102
+
103
+ /** Per-hash list of pending waiters. Each waiter is `{ resolve }` only — a
104
+ * null resolve means "treat as miss" so we never throw across cache code.
105
+ * Drained by completeEntry (resolve with served entry) and abortEntry
106
+ * (resolve null). v5.9.6 — needed so racing GETs against the same in-flight
107
+ * hash piggyback on the first OpenAI call instead of double-billing. */
108
+ const inFlightWaiters = new Map<string, Array<(e: ServedCacheEntry | null) => void>>()
109
+
110
+ let totalBytes = 0 // in-memory bytes (excludes disk-only entries)
111
+ let totalDiskBytes = 0 // on-disk bytes (sum of all sidecar sizeBytes)
112
+
113
+ /** Stable, content-addressed cache key. Includes voice and format so picking
114
+ * a different voice for the same text correctly misses (and gets its own
115
+ * entry). Hash is sha256 truncated to 16 hex chars — collision probability is
116
+ * ~negligible for our scale. */
117
+ export function hashKey(text: string, voice: string, format: string): string {
118
+ return createHash('sha256').update(`${voice}\0${format}\0${text}`).digest('hex').slice(0, 16)
119
+ }
120
+
121
+ function ensureDiskDir(): void {
122
+ try {
123
+ if (!existsSync(DISK_DIR)) mkdirSync(DISK_DIR, { recursive: true })
124
+ } catch (err) {
125
+ console.error('[tts-cache] Failed to create disk cache dir:', DISK_DIR, err)
126
+ }
127
+ }
128
+
129
+ function bodyPath(hash: string): string { return resolve(DISK_DIR, `${hash}.mp3`) }
130
+ function sidecarPath(hash: string): string { return resolve(DISK_DIR, `${hash}.json`) }
131
+
132
+ /** Read a sidecar from disk. Returns null on missing/corrupt — caller treats
133
+ * that as "no entry" and either falls through to OpenAI or skips the file. */
134
+ function readSidecar(hash: string): DiskSidecar | null {
135
+ try {
136
+ const raw = readFileSync(sidecarPath(hash), 'utf-8')
137
+ const parsed = JSON.parse(raw) as Partial<DiskSidecar>
138
+ if (
139
+ typeof parsed.voice !== 'string' ||
140
+ typeof parsed.format !== 'string' ||
141
+ typeof parsed.sizeBytes !== 'number' ||
142
+ typeof parsed.completedAt !== 'number'
143
+ ) return null
144
+ return {
145
+ voice: parsed.voice,
146
+ format: parsed.format,
147
+ sizeBytes: parsed.sizeBytes,
148
+ completedAt: parsed.completedAt,
149
+ }
150
+ } catch {
151
+ return null
152
+ }
153
+ }
154
+
155
+ function deleteDiskEntry(hash: string): number {
156
+ let freed = 0
157
+ try {
158
+ if (existsSync(bodyPath(hash))) {
159
+ try { freed = statSync(bodyPath(hash)).size } catch { /* ignore */ }
160
+ unlinkSync(bodyPath(hash))
161
+ }
162
+ } catch (err) {
163
+ console.warn('[tts-cache] Failed to unlink mp3 for', hash, err)
164
+ }
165
+ try {
166
+ if (existsSync(sidecarPath(hash))) unlinkSync(sidecarPath(hash))
167
+ } catch (err) {
168
+ console.warn('[tts-cache] Failed to unlink sidecar for', hash, err)
169
+ }
170
+ return freed
171
+ }
172
+
173
+ /** On startup, scan the disk cache directory and build the in-memory index
174
+ * from sidecars only. Bodies (potentially up to ~1 GB total) stay on disk
175
+ * and are read lazily on first hit — startup stays fast. */
176
+ function loadFromDisk(): void {
177
+ ensureDiskDir()
178
+ let scanned = 0
179
+ let indexed = 0
180
+ try {
181
+ const files = readdirSync(DISK_DIR)
182
+ for (const fname of files) {
183
+ if (!fname.endsWith('.json')) continue
184
+ scanned++
185
+ const hash = fname.slice(0, -'.json'.length)
186
+ const side = readSidecar(hash)
187
+ if (!side) continue
188
+ // Trust the sidecar's recorded size — it's what was streamed when the
189
+ // entry was first written. Re-stat'ing every body at boot would slow
190
+ // startup linearly with cache size for no useful win.
191
+ cache.set(hash, {
192
+ bytes: null,
193
+ sizeBytes: side.sizeBytes,
194
+ complete: true,
195
+ lastAccess: side.completedAt,
196
+ completedAt: side.completedAt,
197
+ voice: side.voice,
198
+ format: side.format,
199
+ onDisk: true,
200
+ })
201
+ totalDiskBytes += side.sizeBytes
202
+ indexed++
203
+ }
204
+ if (scanned > 0) {
205
+ console.log(`[tts-cache] Disk index loaded: ${indexed}/${scanned} sidecars (${(totalDiskBytes / (1024 * 1024)).toFixed(1)} MB on disk)`)
206
+ }
207
+ } catch (err) {
208
+ console.warn('[tts-cache] Disk scan failed:', err)
209
+ }
210
+ }
211
+
212
+ /** Persist a completed entry to disk. Best-effort — disk failures are logged
213
+ * but never block the response, since the in-memory cache still serves. */
214
+ function persistEntry(hash: string, entry: CacheEntry): void {
215
+ if (!entry.bytes) return
216
+ ensureDiskDir()
217
+ const sidecar: DiskSidecar = {
218
+ voice: entry.voice,
219
+ format: entry.format,
220
+ sizeBytes: entry.sizeBytes,
221
+ completedAt: entry.completedAt,
222
+ }
223
+ try {
224
+ atomicWriteFileSync(bodyPath(hash), entry.bytes)
225
+ atomicWriteFileSync(sidecarPath(hash), JSON.stringify(sidecar))
226
+ if (!entry.onDisk) totalDiskBytes += entry.sizeBytes
227
+ entry.onDisk = true
228
+ } catch (err) {
229
+ console.warn('[tts-cache] Disk persist failed for', hash, err)
230
+ }
231
+ }
232
+
233
+ /** A served (post-hydration) view of a cache entry — guaranteed non-null body. */
234
+ export interface ServedCacheEntry {
235
+ bytes: Buffer
236
+ sizeBytes: number
237
+ voice: string
238
+ format: string
239
+ completedAt: number
240
+ }
241
+
242
+ /** Look up a cached entry; bumps lastAccess for LRU on hit. Returns null on
243
+ * miss OR if the entry exists but is still in-flight (incomplete).
244
+ *
245
+ * Disk-hydration: an entry whose bytes are NULL exists in the index from a
246
+ * previous server run. We read the body lazily here so startup stays fast.
247
+ * After hydration the entry counts toward the in-memory cap and gets the
248
+ * same LRU treatment as a freshly-generated one. The returned ServedCacheEntry
249
+ * has a non-null `bytes` so callers don't need to re-narrow. */
250
+ export function getCached(hash: string): ServedCacheEntry | null {
251
+ const entry = cache.get(hash)
252
+ if (!entry || !entry.complete) return null
253
+
254
+ if (!entry.bytes) {
255
+ // Disk-only entry — hydrate. If the body is gone (manual delete, disk
256
+ // corruption) drop the index entry and force a regenerate.
257
+ try {
258
+ if (!existsSync(bodyPath(hash))) {
259
+ cache.delete(hash)
260
+ totalDiskBytes -= entry.sizeBytes
261
+ return null
262
+ }
263
+ entry.bytes = readFileSync(bodyPath(hash))
264
+ entry.sizeBytes = entry.bytes.length
265
+ totalBytes += entry.sizeBytes
266
+ } catch (err) {
267
+ console.warn('[tts-cache] Failed to hydrate', hash, 'from disk:', err)
268
+ cache.delete(hash)
269
+ return null
270
+ }
271
+ }
272
+
273
+ entry.lastAccess = Date.now()
274
+ const bytes = entry.bytes
275
+ if (!bytes) return null
276
+ const served: ServedCacheEntry = {
277
+ bytes,
278
+ sizeBytes: entry.sizeBytes,
279
+ voice: entry.voice,
280
+ format: entry.format,
281
+ completedAt: entry.completedAt,
282
+ }
283
+ evictIfNeeded()
284
+ return served
285
+ }
286
+
287
+ /** Reserve a new in-flight cache slot. Returns the entry the caller will write
288
+ * bytes into, or NULL if another caller already has an in-flight entry for
289
+ * this hash — in which case the caller should await waitForInFlight() and
290
+ * serve from the resulting cache rather than running its own OpenAI call.
291
+ *
292
+ * Why null-on-conflict: v5.9.6 introduces parallel pre-warm from /prepare
293
+ * AND the legacy on-demand path from /play. Without this guard, two
294
+ * concurrent calls for the same hash would each start their own OpenAI
295
+ * request, double-bill, and race to write garbled bytes into the cache.
296
+ * The null return lets generateIntoCache cleanly piggyback on the existing
297
+ * in-flight entry.
298
+ *
299
+ * Already-complete entries DO get clobbered (the caller explicitly wants to
300
+ * regenerate, e.g. after a hash collision unlikely though it is — keeps
301
+ * startEntry's contract close to its v5.9.5 behavior for that case). */
302
+ export function startEntry(hash: string, voice = 'unknown', format = 'mp3'): CacheEntry | null {
303
+ const existing = cache.get(hash)
304
+ if (existing && !existing.complete) {
305
+ // Another writer already owns the slot — refuse to clobber. Caller will
306
+ // waitForInFlight on this hash to receive the bytes once they finish.
307
+ return null
308
+ }
309
+ if (existing) {
310
+ if (existing.bytes) totalBytes -= existing.sizeBytes
311
+ if (existing.onDisk) {
312
+ totalDiskBytes -= existing.sizeBytes
313
+ deleteDiskEntry(hash)
314
+ }
315
+ cache.delete(hash)
316
+ }
317
+ const now = Date.now()
318
+ const entry: CacheEntry = {
319
+ bytes: Buffer.alloc(0),
320
+ sizeBytes: 0,
321
+ complete: false,
322
+ lastAccess: now,
323
+ completedAt: now,
324
+ voice,
325
+ format,
326
+ onDisk: false,
327
+ }
328
+ cache.set(hash, entry)
329
+ return entry
330
+ }
331
+
332
+ /** Append a chunk to an in-flight entry. Cheap concat; small chunk sizes from
333
+ * OpenAI streaming (typically 1-4 KB) keep this O(N) over the response. */
334
+ export function appendBytes(hash: string, chunk: Buffer): void {
335
+ const entry = cache.get(hash)
336
+ if (!entry || entry.complete || !entry.bytes) return
337
+ entry.bytes = Buffer.concat([entry.bytes, chunk])
338
+ entry.sizeBytes = entry.bytes.length
339
+ }
340
+
341
+ /** Mark an in-flight entry as complete, mirror to disk, and run eviction.
342
+ * Also drains any pending waitForInFlight promises with the served entry. */
343
+ export function completeEntry(hash: string): void {
344
+ const entry = cache.get(hash)
345
+ if (!entry || entry.complete) return
346
+ entry.complete = true
347
+ entry.lastAccess = Date.now()
348
+ entry.completedAt = entry.lastAccess
349
+ totalBytes += entry.sizeBytes
350
+ const served = toServed(hash, entry)
351
+ persistEntry(hash, entry)
352
+ evictIfNeeded()
353
+ drainWaiters(hash, served)
354
+ }
355
+
356
+ /** Discard an in-flight entry that was aborted (client disconnect, OpenAI
357
+ * error, etc.). Prevents serving partial data on the next request for the
358
+ * same hash — instead the next request regenerates from scratch. */
359
+ export function abortEntry(hash: string): void {
360
+ const entry = cache.get(hash)
361
+ if (!entry) return
362
+ if (entry.complete && entry.bytes) totalBytes -= entry.sizeBytes
363
+ if (entry.onDisk) {
364
+ totalDiskBytes -= entry.sizeBytes
365
+ deleteDiskEntry(hash)
366
+ }
367
+ cache.delete(hash)
368
+ drainWaiters(hash, null)
369
+ }
370
+
371
+ function toServed(hash: string, entry: CacheEntry): ServedCacheEntry | null {
372
+ if (!entry.bytes) return null
373
+ return {
374
+ bytes: entry.bytes,
375
+ sizeBytes: entry.sizeBytes,
376
+ voice: entry.voice,
377
+ format: entry.format,
378
+ completedAt: entry.completedAt,
379
+ }
380
+ }
381
+
382
+ function drainWaiters(hash: string, served: ServedCacheEntry | null): void {
383
+ const waiters = inFlightWaiters.get(hash)
384
+ if (!waiters || waiters.length === 0) return
385
+ inFlightWaiters.delete(hash)
386
+ for (const r of waiters) {
387
+ try { r(served) } catch (err) { console.warn('[tts-cache] waiter threw:', err) }
388
+ }
389
+ }
390
+
391
+ /** Wait for an in-flight entry to complete. Returns the served entry on
392
+ * success, null on miss/abort/timeout. Idempotent for already-complete
393
+ * entries — resolves immediately with the cached value.
394
+ *
395
+ * Why this exists (v5.9.6): the new fast-prefix path pre-warms OpenAI from
396
+ * inside POST /api/tts/prepare. The very next GET /api/tts/play/<session>
397
+ * for the same hash would see an incomplete entry, fall through, and bill
398
+ * OpenAI a SECOND time for the same audio. waitForInFlight lets the GET
399
+ * piggyback on the in-flight pre-warm — only one billable call per hash. */
400
+ export function waitForInFlight(
401
+ hash: string,
402
+ timeoutMs = 30_000,
403
+ ): Promise<ServedCacheEntry | null> {
404
+ const entry = cache.get(hash)
405
+ if (!entry) return Promise.resolve(null)
406
+ // Already complete — same fast path as getCached (also bumps lastAccess).
407
+ if (entry.complete) return Promise.resolve(getCached(hash))
408
+ // Truly in-flight — register a waiter, race a timeout.
409
+ return new Promise((resolve) => {
410
+ let done = false
411
+ const finish = (v: ServedCacheEntry | null) => {
412
+ if (done) return
413
+ done = true
414
+ resolve(v)
415
+ }
416
+ const list = inFlightWaiters.get(hash) ?? []
417
+ list.push(finish)
418
+ inFlightWaiters.set(hash, list)
419
+ setTimeout(() => finish(null), timeoutMs).unref()
420
+ })
421
+ }
422
+
423
+ /** Evict completed entries (LRU by lastAccess) until under both caps:
424
+ * - In-memory: MAX_ENTRIES + MAX_TOTAL_BYTES (drops bytes only — disk copy
425
+ * survives, lazily re-hydrated on next hit).
426
+ * - On-disk: MAX_DISK_BYTES (deletes the body + sidecar AND drops the entry
427
+ * from the index so we don't 404 phantom hits).
428
+ * NEVER touches in-flight entries — they're pinned until completeEntry/abortEntry. */
429
+ function evictIfNeeded(): void {
430
+ // 1. Trim in-memory bytes only (disk survives).
431
+ const completed = [...cache.entries()]
432
+ .filter(([, e]) => e.complete && e.bytes != null)
433
+ .sort((a, b) => a[1].lastAccess - b[1].lastAccess)
434
+ let memoryEntryCount = completed.length
435
+ if (memoryEntryCount > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES) {
436
+ for (const [, entry] of completed) {
437
+ if (memoryEntryCount <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break
438
+ if (!entry.bytes) continue
439
+ totalBytes -= entry.sizeBytes
440
+ memoryEntryCount--
441
+ entry.bytes = null // keep index/sidecar alive — disk copy survives
442
+ }
443
+ }
444
+ // 2. Trim disk bytes if over the on-disk cap.
445
+ if (totalDiskBytes > MAX_DISK_BYTES) {
446
+ const onDisk = [...cache.entries()]
447
+ .filter(([, e]) => e.onDisk)
448
+ .sort((a, b) => a[1].completedAt - b[1].completedAt)
449
+ for (const [hash, entry] of onDisk) {
450
+ if (totalDiskBytes <= MAX_DISK_BYTES) break
451
+ if (entry.bytes) totalBytes -= entry.sizeBytes
452
+ totalDiskBytes -= entry.sizeBytes
453
+ deleteDiskEntry(hash)
454
+ cache.delete(hash)
455
+ }
456
+ }
457
+ }
458
+
459
+ /** Rolling age TTL — unconditionally remove entries older than MAX_AGE_DAYS
460
+ * regardless of cache fullness. Run once on startup and every 24h. Set
461
+ * TTS_DISK_CACHE_MAX_AGE_DAYS=0 to disable. */
462
+ function sweepStaleByAge(): void {
463
+ if (MAX_AGE_MS <= 0) return
464
+ const cutoff = Date.now() - MAX_AGE_MS
465
+ let evicted = 0
466
+ let freedBytes = 0
467
+ for (const [hash, entry] of [...cache.entries()]) {
468
+ if (!entry.complete) continue // never evict in-flight
469
+ if (entry.completedAt > cutoff) continue
470
+ if (entry.bytes) totalBytes -= entry.sizeBytes
471
+ if (entry.onDisk) {
472
+ totalDiskBytes -= entry.sizeBytes
473
+ deleteDiskEntry(hash)
474
+ }
475
+ cache.delete(hash)
476
+ evicted++
477
+ freedBytes += entry.sizeBytes
478
+ }
479
+ if (evicted > 0) {
480
+ console.log(`[tts-cache] Age sweep: evicted ${evicted} entr${evicted === 1 ? 'y' : 'ies'} older than ${MAX_AGE_DAYS}d (${(freedBytes / (1024 * 1024)).toFixed(1)} MB freed)`)
481
+ }
482
+ }
483
+
484
+ /** Allocate a new session UUID pointing at a (hash, text, voice, format)
485
+ * bundle. The play route consumes the session; expired sessions are reaped
486
+ * by the periodic sweeper below. */
487
+ export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
488
+ const uuid = randomUUID()
489
+ sessions.set(uuid, { ...s, expiresAt: Date.now() + SESSION_TTL_MS })
490
+ return uuid
491
+ }
492
+
493
+ /** Look up a session WITHOUT deleting it. Returns null if unknown or expired.
494
+ *
495
+ * Why non-destructive (changed in v5.9.4): iOS WKWebView's HTML5 audio engine
496
+ * issues HTTP Range requests against `audio.src` to refill its play buffer
497
+ * every few seconds during longer responses. The original one-shot design
498
+ * caused the second (and every subsequent) request to 404, freezing playback
499
+ * partway through. Sessions still expire on the existing 60s TTL, so the
500
+ * practical exposure window is unchanged — they're just re-readable inside
501
+ * that window.
502
+ *
503
+ * An explicit consumeSession() variant remains below for any future caller
504
+ * that wants strict one-shot semantics; today's /play handler does not. */
505
+ export function peekSession(uuid: string): SessionEntry | null {
506
+ const s = sessions.get(uuid)
507
+ if (!s) return null
508
+ if (s.expiresAt < Date.now()) {
509
+ sessions.delete(uuid)
510
+ return null
511
+ }
512
+ return s
513
+ }
514
+
515
+ /** Strict one-shot lookup — kept for callers that want to invalidate the
516
+ * session immediately on first read. Not used by /play in v5.9.4+. */
517
+ export function consumeSession(uuid: string): SessionEntry | null {
518
+ const s = peekSession(uuid)
519
+ if (s) sessions.delete(uuid)
520
+ return s
521
+ }
522
+
523
+ /** Periodic sweeper — run on a setInterval to clear out sessions the client
524
+ * never followed up on. Called by the route module on startup. */
525
+ export function reapExpiredSessions(): void {
526
+ const now = Date.now()
527
+ for (const [uuid, s] of sessions) {
528
+ if (s.expiresAt < now) sessions.delete(uuid)
529
+ }
530
+ }
531
+
532
+ /** Diagnostics — exposed via GET /api/tts/budget for at-a-glance monitoring. */
533
+ export interface CacheStats {
534
+ entries: number
535
+ completed: number
536
+ totalBytes: number
537
+ totalMB: number
538
+ sessions: number
539
+ capEntries: number
540
+ capBytes: number
541
+ disk: {
542
+ entries: number
543
+ totalBytes: number
544
+ totalMB: number
545
+ capMB: number
546
+ /** Days since the oldest disk entry was completed. NaN-safe: 0 when empty. */
547
+ oldestAgeDays: number
548
+ ttlDays: number
549
+ }
550
+ }
551
+
552
+ export function getCacheStats(): CacheStats {
553
+ let completed = 0
554
+ let diskEntries = 0
555
+ let oldestCompletedAt: number | null = null
556
+ for (const e of cache.values()) {
557
+ if (e.complete) completed++
558
+ if (e.onDisk) {
559
+ diskEntries++
560
+ if (oldestCompletedAt === null || e.completedAt < oldestCompletedAt) {
561
+ oldestCompletedAt = e.completedAt
562
+ }
563
+ }
564
+ }
565
+ const oldestAgeDays = oldestCompletedAt
566
+ ? Math.round(((Date.now() - oldestCompletedAt) / (24 * 60 * 60 * 1000)) * 10) / 10
567
+ : 0
568
+ return {
569
+ entries: cache.size,
570
+ completed,
571
+ totalBytes,
572
+ totalMB: Math.round((totalBytes / (1024 * 1024)) * 100) / 100,
573
+ sessions: sessions.size,
574
+ capEntries: MAX_ENTRIES,
575
+ capBytes: MAX_TOTAL_BYTES,
576
+ disk: {
577
+ entries: diskEntries,
578
+ totalBytes: totalDiskBytes,
579
+ totalMB: Math.round((totalDiskBytes / (1024 * 1024)) * 100) / 100,
580
+ capMB: Math.round(MAX_DISK_BYTES / (1024 * 1024)),
581
+ oldestAgeDays,
582
+ ttlDays: MAX_AGE_DAYS,
583
+ },
584
+ }
585
+ }
586
+
587
+ // ── Module bootstrap ────────────────────────────────────────────
588
+ // Hydrate the disk index immediately so first GET /api/tts/play/:session
589
+ // after a restart can see the index and serve from disk. Then schedule the
590
+ // rolling age sweeper (unref()'d so it never blocks process exit).
591
+
592
+ loadFromDisk()
593
+ sweepStaleByAge()
594
+ if (MAX_AGE_MS > 0) {
595
+ setInterval(sweepStaleByAge, SWEEP_INTERVAL_MS).unref()
596
+ }
@@ -0,0 +1,59 @@
1
+ // Bookmark endpoints — save/retrieve individual messages
2
+ import { Router } from 'express'
3
+ import { loadBookmarks, addBookmark, deleteBookmark, getBookmark } from '../lib/bookmarks.js'
4
+
5
+ export const bookmarksRouter = Router()
6
+
7
+ // GET /api/bookmarks — list all bookmarks (newest first)
8
+ bookmarksRouter.get('/bookmarks', (_req, res) => {
9
+ const bookmarks = loadBookmarks()
10
+ // Return newest first
11
+ res.json({ bookmarks: [...bookmarks].reverse() })
12
+ })
13
+
14
+ // GET /api/bookmarks/:id — get a single bookmark
15
+ bookmarksRouter.get('/bookmarks/:id', (req, res) => {
16
+ const id = parseInt(req.params.id, 10)
17
+ if (isNaN(id)) {
18
+ res.status(400).json({ error: 'Invalid bookmark ID' })
19
+ return
20
+ }
21
+ const bookmark = getBookmark(id)
22
+ if (!bookmark) {
23
+ res.status(404).json({ error: 'Bookmark not found' })
24
+ return
25
+ }
26
+ res.json(bookmark)
27
+ })
28
+
29
+ // POST /api/bookmarks — save a new bookmark (optional attachment refs)
30
+ bookmarksRouter.post('/bookmarks', (req, res) => {
31
+ const { query, text, messageIndex, originalTimestamp, attachments } = req.body
32
+ if (!query || !text) {
33
+ res.status(400).json({ error: 'query and text are required' })
34
+ return
35
+ }
36
+ const bookmark = addBookmark(
37
+ query,
38
+ text,
39
+ messageIndex ?? 0,
40
+ originalTimestamp ?? Date.now(),
41
+ attachments,
42
+ )
43
+ res.json({ bookmark })
44
+ })
45
+
46
+ // DELETE /api/bookmarks/:id — delete a bookmark
47
+ bookmarksRouter.delete('/bookmarks/:id', (req, res) => {
48
+ const id = parseInt(req.params.id, 10)
49
+ if (isNaN(id)) {
50
+ res.status(400).json({ error: 'Invalid bookmark ID' })
51
+ return
52
+ }
53
+ const deleted = deleteBookmark(id)
54
+ if (!deleted) {
55
+ res.status(404).json({ error: 'Bookmark not found' })
56
+ return
57
+ }
58
+ res.json({ deleted: true })
59
+ })