@gotcos/glasses-server 6.36.23 → 6.36.24

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 CHANGED
@@ -1,3 +1,37 @@
1
+ ## 6.36.24
2
+
3
+ **Playback stopped after about a minute, whatever the reply length.**
4
+
5
+ The TTS play session held a deadline fixed 60 seconds from creation. iOS WKWebView
6
+ re-requests `audio.src` every few seconds to refill its decode buffer, so once the
7
+ session expired those refills 404'd and the audio simply stopped mid-sentence.
8
+
9
+ Measured on this machine: 250 characters is 14 seconds of speech, 4,000 characters
10
+ is 211. So any reply over roughly 1,100 characters outlived its own session. The
11
+ symptom was "the first ten seconds play and nothing else comes" -- the fast-path
12
+ prefix is 250 characters, which is that 14 seconds exactly.
13
+
14
+ 6.36.23 removed a character cap that was also real, but the cap truncated the
15
+ TEXT; this truncated the PLAYBACK. The ceiling was time, not length, which is what
16
+ "caps out at a max duration" meant literally.
17
+
18
+ `SESSION_TTL_MS` becomes `SESSION_IDLE_MS`, refreshed on every read, so a session
19
+ stays alive while audio is actively playing and dies a minute after it stops.
20
+
21
+ Because the session UUID IS the auth for an unauthenticated play route, a purely
22
+ sliding window could be held open indefinitely by polling. `SESSION_MAX_LIFETIME_MS`
23
+ (30 minutes) is an absolute ceiling that reading never extends -- far longer than
24
+ any plausible reply, and still a bounded exposure window for a leaked URL. The
25
+ periodic reaper honours it too.
26
+
27
+ v5.9.4 made these reads non-destructive for exactly this symptom and stopped one
28
+ step short, noting "sessions still expire on the existing 60s TTL, so the practical
29
+ exposure window is unchanged". True, and also what left the ceiling in place.
30
+
31
+ Suite 3013 / 214, tsc 0. Both halves mutation-verified: removing the refresh fails
32
+ the playback tests, and letting a read extend past the ceiling fails the security
33
+ tests.
34
+
1
35
  ## 6.36.23
2
36
 
3
37
  **Long replies stopped speaking at about three or four pages.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.23",
3
+ "version": "6.36.24",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,7 +71,10 @@ interface SessionEntry {
71
71
  preferOpenAI?: boolean
72
72
  /** Settings forced Local/Kokoro — do not auto-escape to OpenAI on play miss. */
73
73
  forceLocal?: boolean
74
+ /** Idle deadline. Pushed out on every read; never past `hardExpiresAt`. */
74
75
  expiresAt: number
76
+ /** Absolute deadline, fixed at creation. Reading never extends it. */
77
+ hardExpiresAt: number
75
78
  }
76
79
 
77
80
  interface DiskSidecar {
@@ -83,7 +86,32 @@ interface DiskSidecar {
83
86
 
84
87
  const MAX_ENTRIES = 50
85
88
  const MAX_TOTAL_BYTES = 100 * 1024 * 1024 // 100 MB — in-memory cap
86
- const SESSION_TTL_MS = 60_000
89
+ /**
90
+ * IDLE timeout, not a lifetime. Refreshed on every read.
91
+ *
92
+ * It was a fixed 60s from creation, which silently capped PLAYBACK at 60
93
+ * seconds: iOS WKWebView re-requests `audio.src` every few seconds to refill
94
+ * its decode buffer, and once the session expired those refills 404'd and the
95
+ * audio simply stopped. Measured on this machine, 250 characters is 14 seconds
96
+ * of speech and 4,000 characters is 211 -- so any reply over roughly 1,100
97
+ * characters outlived its own session and cut off mid-sentence.
98
+ *
99
+ * v5.9.4 made reads non-destructive for exactly this reason and stopped one
100
+ * step short, noting "sessions still expire on the existing 60s TTL, so the
101
+ * practical exposure window is unchanged". That was true, and it is also what
102
+ * left the ceiling in place.
103
+ */
104
+ const SESSION_IDLE_MS = 60_000
105
+
106
+ /**
107
+ * Absolute ceiling, never refreshed.
108
+ *
109
+ * The session UUID IS the auth for an unauthenticated play route, so a purely
110
+ * sliding window could be kept alive indefinitely by polling. Thirty minutes is
111
+ * far longer than any plausible single reply (211 seconds for 4,000 characters)
112
+ * and still bounds the exposure of a leaked URL.
113
+ */
114
+ const SESSION_MAX_LIFETIME_MS = 30 * 60_000
87
115
 
88
116
  /** Disk cache configuration (env-overridable). Defaults sized for "I run this
89
117
  * on my laptop and forget about it for months" rather than a service tier.
@@ -499,9 +527,14 @@ function sweepStaleByAge(): void {
499
527
  * (hash, text, voice, format) bundle. The play route may reread it for native
500
528
  * Range refills during the 60-second TTL; expired sessions are rejected and
501
529
  * reaped by the periodic sweeper below. */
502
- export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
530
+ export function createSession(s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>): string {
503
531
  const uuid = randomUUID()
504
- sessions.set(uuid, { ...s, expiresAt: Date.now() + SESSION_TTL_MS })
532
+ const now = Date.now()
533
+ sessions.set(uuid, {
534
+ ...s,
535
+ expiresAt: now + SESSION_IDLE_MS,
536
+ hardExpiresAt: now + SESSION_MAX_LIFETIME_MS,
537
+ })
505
538
  return uuid
506
539
  }
507
540
 
@@ -520,10 +553,16 @@ export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
520
553
  export function peekSession(uuid: string): SessionEntry | null {
521
554
  const s = sessions.get(uuid)
522
555
  if (!s) return null
523
- if (s.expiresAt < Date.now()) {
556
+ const now = Date.now()
557
+ // Absolute ceiling first: a hard expiry must not be extendable by reading.
558
+ if (s.hardExpiresAt <= now || s.expiresAt < now) {
524
559
  sessions.delete(uuid)
525
560
  return null
526
561
  }
562
+ // SLIDING. Every Range refill during playback pushes the idle deadline out, so
563
+ // a session lives as long as audio is actively being played and dies a minute
564
+ // after it stops -- never past the hard ceiling.
565
+ s.expiresAt = Math.min(now + SESSION_IDLE_MS, s.hardExpiresAt)
527
566
  return s
528
567
  }
529
568
 
@@ -552,7 +591,7 @@ export function consumeSession(uuid: string): SessionEntry | null {
552
591
  export function reapExpiredSessions(): void {
553
592
  const now = Date.now()
554
593
  for (const [uuid, s] of sessions) {
555
- if (s.expiresAt < now) sessions.delete(uuid)
594
+ if (s.hardExpiresAt <= now || s.expiresAt < now) sessions.delete(uuid)
556
595
  }
557
596
  }
558
597