@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.
- package/CHANGELOG.md +4 -0
- package/package.json +2 -2
- package/server/index.ts +14 -0
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- package/server/lib/openai-tts-budget.ts +142 -0
- package/server/lib/prompt-edit.ts +224 -0
- package/server/lib/recovery-activity.ts +168 -0
- package/server/lib/speaker-trainer.ts +532 -0
- package/server/lib/tts-cache.ts +596 -0
- package/server/routes/bookmarks.ts +59 -0
- package/server/routes/glossary.ts +153 -0
- package/server/routes/handoffs.ts +101 -0
- package/server/routes/prompt-edit.ts +33 -0
- package/server/routes/recovery.ts +76 -0
- package/server/routes/tts.ts +709 -0
- package/server/routes/voice.ts +317 -0
- package/shared/handoff-intent.ts +90 -0
|
@@ -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
|
+
})
|