@gotcos/glasses-server 6.36.27 → 6.37.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.
@@ -101,25 +101,68 @@ const MAX_TOTAL_BYTES = 100 * 1024 * 1024 // 100 MB — in-memory cap
101
101
  * practical exposure window is unchanged". That was true, and it is also what
102
102
  * left the ceiling in place.
103
103
  */
104
+ /**
105
+ * SHARED PHYSICAL CONSTANTS for every TTS deadline in this file.
106
+ *
107
+ * These sit at the top because three separate deadlines are derived from them,
108
+ * and on 2026-08-23 two of those deadlines were derived from a DIFFERENT
109
+ * speech rate than the third. Both cannot be right, and the tests could not see
110
+ * the contradiction because each restated its own copy of the rate.
111
+ */
112
+
113
+ /** The longest a single chunk can be. Mirrors the chunker in routes/tts.ts. */
114
+ export const LATER_CHUNK_CHARS = 900
115
+
116
+ /** The most text one reply can be asked to speak locally. */
117
+ export const MAX_LOCAL_TTS_CHARS = 40_000
118
+
119
+ /**
120
+ * Characters spoken per second by the SLOWEST voice, not the average.
121
+ *
122
+ * Measured on device 2026-08-23: am_echo ~19 chars/sec, bm_george as low as
123
+ * 10.6 on one segment. Every deadline below uses 10, because a window sized on
124
+ * the fast voice does not cover the slow one -- which is the entire class of
125
+ * bug these constants exist to end.
126
+ *
127
+ * CAVEAT, stated because it is load-bearing: this is one segment of one voice
128
+ * out of 28 shipped Kokoro voices. It errs safe for a WINDOW (too slow means
129
+ * too generous) but it has not been censused, and a voice slower than 10 would
130
+ * undersize every deadline here at once.
131
+ */
132
+ export const SLOWEST_SPEECH_CHARS_PER_SEC = 10
133
+
134
+ /**
135
+ * The slowest rate playback can actually run at.
136
+ *
137
+ * NOT the slowest option the picker offers -- that is 0.75x. This is the clamp
138
+ * floor in the client's getPreferredSpeed(), which exists so a poisoned
139
+ * localStorage value cannot produce an absurd rate. Deadlines must survive the
140
+ * clamp, not just the menu.
141
+ */
142
+ export const MIN_PLAYBACK_RATE = 0.5
143
+
144
+ /** Wall-clock ms to speak `chars` at the slowest voice and slowest rate. */
145
+ export function worstCaseSpeechMs(chars: number): number {
146
+ return (chars / SLOWEST_SPEECH_CHARS_PER_SEC / MIN_PLAYBACK_RATE) * 1000
147
+ }
148
+
104
149
  export const SESSION_IDLE_MS = (() => {
105
- // DERIVED, like the ceiling below, and for the same reason: a window that does
106
- // not cover its own inputs is the bug this replaced.
150
+ // COMPUTED from the shared constants, not written down.
151
+ //
152
+ // A session that is being read must outlast the gap between two reads. The
153
+ // client warms segment i+1 at the START of segment i and does not touch it
154
+ // again until segment i FINISHES, so that gap is one full segment:
107
155
  //
108
- // Every segment's session is minted at /prepare. The client warms segment i+1
109
- // at the START of segment i, so the gap between that touch and the real request
110
- // is ONE FULL SEGMENT of playback. The window has to outlast that gap at the
111
- // slowest speed the client offers:
156
+ // worstCaseSpeechMs(LATER_CHUNK_CHARS) = 900 / 10 / 0.5 = 180s
112
157
  //
113
- // LATER_CHUNK_CHARS 900 chars
114
- // speech rate ~19 chars/sec (measured)
115
- // MIN_SPEED 0.5x (voice-output.ts clamps here)
116
- // => 900 / 19 / 0.5 = 94.7s
158
+ // The previous value, 120s, was derived from ~19 chars/sec -- the FAST voice.
159
+ // Once bm_george was measured at 10.6 the derivation was stale, and 900 chars
160
+ // at 0.5x is 180s against a 120s window. That is the same defect this file
161
+ // has now hit three times, so the value is computed here rather than chosen.
117
162
  //
118
- // 60s covered 1x (47.4s) and 1.25x (37.9s) but NOT 0.75x (63.2s) -- a shipped
119
- // option in the Settings picker. At 0.75x every other segment would 404, and
120
- // because onError resolves rather than rejects, playback would skip on and
121
- // sound complete while dropping half the reply. 120s covers 0.5x with margin.
122
- return 120_000
163
+ // x1.5 of margin absorbs a stalled segment or a slow refill without letting an
164
+ // abandoned capability linger: 4.5 minutes, not 90.
165
+ return Math.ceil(worstCaseSpeechMs(LATER_CHUNK_CHARS) * 1.5)
123
166
  })()
124
167
 
125
168
  /**
@@ -129,24 +172,32 @@ export const SESSION_IDLE_MS = (() => {
129
172
  * sliding window could be kept alive indefinitely by polling. This bounds the
130
173
  * exposure of a leaked URL.
131
174
  *
132
- * DERIVED, not picked. Every segment of a reply is minted at prepare time, so
133
- * the ceiling has to outlast the WHOLE reply played at the SLOWEST speed the
134
- * client offers -- otherwise the last segments expire before playback reaches
135
- * them, which is the same class of bug as the 60s deadline this replaced:
175
+ * COMPUTED from the largest reply the system can be asked to speak, at the
176
+ * slowest voice and slowest rate, plus one segment of margin -- which is exactly
177
+ * what initialGraceMs computes, so the ceiling is defined as "the largest grace
178
+ * that can legally be issued":
136
179
  *
137
- * MAX_LOCAL_TTS_CHARS 40,000 chars
138
- * speech rate ~19 chars per second (measured)
139
- * MIN_SPEED 0.5x (voice-output.ts clamps here)
140
- * => 40000 / 19 / 0.5 = 70.2 minutes of audio
180
+ * worstCaseSpeechMs(40,000) + worstCaseSpeechMs(900)
181
+ * = 40000/10/0.5 + 900/10/0.5 = 8000s + 180s = 136.3 minutes
141
182
  *
142
- * 30 minutes was shorter than both that and the 1x case (35.1 min), so a maximal
143
- * reply would have cut off near the end. 90 minutes covers the worst case with
144
- * margin and is still a bounded window.
183
+ * The previous 90 minutes was derived from ~19 chars/sec. After the slow voice
184
+ * was measured at 10.6 that stopped covering its own input: initialGraceMs
185
+ * exceeded the ceiling for any reply over ~26,400 characters, so the ceiling
186
+ * silently truncated the grace and a maximal reply's last segments died before
187
+ * playback reached them -- the segment-5 failure relocated to segment 31 of 46.
145
188
  *
146
- * If MAX_LOCAL_TTS_CHARS or MIN_SPEED changes, re-derive this. A ceiling that
147
- * silently stops covering its own inputs is exactly what went wrong before.
189
+ * This is a long-lived bearer capability and that is a real trade, stated rather
190
+ * than buried: a 40,000-character reply genuinely takes over two hours to speak
191
+ * at 0.5x, and the capability must outlive the audio it serves. The bound is
192
+ * still absolute and still unextendable by reading.
193
+ *
194
+ * There is no separate arithmetic to keep in sync. Change a shared constant and
195
+ * both this and the grace move together, and the test below asserts the ceiling
196
+ * covers the largest grace.
148
197
  */
149
- export const SESSION_MAX_LIFETIME_MS = 90 * 60_000
198
+ export const SESSION_MAX_LIFETIME_MS = Math.ceil(
199
+ worstCaseSpeechMs(MAX_LOCAL_TTS_CHARS) + worstCaseSpeechMs(LATER_CHUNK_CHARS),
200
+ )
150
201
 
151
202
  /** Disk cache configuration (env-overridable). Defaults sized for "I run this
152
203
  * on my laptop and forget about it for months" rather than a service tier.
@@ -562,12 +613,60 @@ function sweepStaleByAge(): void {
562
613
  * (hash, text, voice, format) bundle. The play route may reread it for native
563
614
  * Range refills during the 60-second TTL; expired sessions are rejected and
564
615
  * reaped by the periodic sweeper below. */
565
- export function createSession(s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>): string {
616
+ /**
617
+ * How long a session that has NEVER been read stays alive.
618
+ *
619
+ * WHY THIS IS NOT SESSION_IDLE_MS. Every segment of a reply is minted at
620
+ * /prepare, but the client only touches segment k when segment k-1 STARTS
621
+ * playing. For a 9-segment reply that first touch can be minutes away:
622
+ *
623
+ * measured on device, 6,781 chars at 1.25x
624
+ * seg 4 first touched at t+101s played
625
+ * seg 5 first touched at t+147s FAILED
626
+ * seg 6 first touched at t+215s FAILED
627
+ * seg 7 never reached FAILED
628
+ *
629
+ * With a flat 120s idle deadline running from MINT, segment 5 was already dead
630
+ * when the client first asked for it, and the 404 arrived as
631
+ * NotSupportedError. Playback stopped at exactly segment 5 on every run.
632
+ *
633
+ * So the idle clock must not start before anyone could reasonably read it. An
634
+ * unread session gets a grace window derived from how long the WHOLE reply takes
635
+ * to speak at the slowest voice and slowest rate; the 120s idle window applies
636
+ * from the first read onward, when it means what it says.
637
+ */
638
+ export function initialGraceMs(totalChars: number): number {
639
+ // The margin is ONE SEGMENT, not one idle window. The last segment is first
640
+ // touched when the second-to-last STARTS playing, so the window has to reach
641
+ // one segment past the end of the reply. An earlier version added
642
+ // SESSION_IDLE_MS here and described it as covering that gap -- which it does
643
+ // not, since a segment can be 180s at this file's own slowest rate.
644
+ const lastSegmentMs = worstCaseSpeechMs(LATER_CHUNK_CHARS)
645
+ return Math.max(SESSION_IDLE_MS, worstCaseSpeechMs(totalChars) + lastSegmentMs)
646
+ }
647
+
648
+ export function createSession(
649
+ s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>,
650
+ opts: { graceMs?: number } = {},
651
+ ): string {
566
652
  const uuid = randomUUID()
567
653
  const now = Date.now()
654
+ // Number.isFinite, not just ??. Math.max(120000, NaN) is NaN, and NaN fails
655
+ // every comparison in peekSession, so a NaN grace would leave the session
656
+ // governed only by the ceiling. Cheap to close, silent if left open.
657
+ const requested = opts.graceMs
658
+ const grace = Number.isFinite(requested)
659
+ ? Math.max(SESSION_IDLE_MS, requested as number)
660
+ : SESSION_IDLE_MS
568
661
  sessions.set(uuid, {
569
662
  ...s,
570
- expiresAt: now + SESSION_IDLE_MS,
663
+ // Belt and braces, NOT the thing that enforces the ceiling. Mutation shows
664
+ // removing this Math.min changes nothing observable: peekSession and
665
+ // reapExpiredSessions both test hardExpiresAt independently, so a grace
666
+ // beyond the ceiling is already unreachable. Kept because a stored deadline
667
+ // that lies about its own limit invites a future reader to trust it -- but
668
+ // do not mistake it for the guard.
669
+ expiresAt: Math.min(now + grace, now + SESSION_MAX_LIFETIME_MS),
571
670
  hardExpiresAt: now + SESSION_MAX_LIFETIME_MS,
572
671
  })
573
672
  return uuid
@@ -594,10 +693,27 @@ export function peekSession(uuid: string): SessionEntry | null {
594
693
  sessions.delete(uuid)
595
694
  return null
596
695
  }
597
- // SLIDING. Every Range refill during playback pushes the idle deadline out, so
598
- // a session lives as long as audio is actively being played and dies a minute
599
- // after it stops -- never past the hard ceiling.
600
- s.expiresAt = Math.min(now + SESSION_IDLE_MS, s.hardExpiresAt)
696
+ // SLIDING, and it may only ever EXTEND a deadline -- never shorten one.
697
+ //
698
+ // The Math.max is the whole point, and its absence was a defect that survived
699
+ // into review. `warmNext(i)` reads segment i+1 at the START of segment i, and
700
+ // then nothing touches it again until segment i FINISHES, one full segment
701
+ // later. If that first read collapsed the derived grace to SESSION_IDLE_MS,
702
+ // the warm would SPEND the grace instead of using it, and any segment whose
703
+ // playback exceeds 120s would expire before its turn:
704
+ //
705
+ // 900 chars at 10.6 chars/sec = 85s of audio
706
+ // at 0.5x = 170s of wall time > 120s
707
+ //
708
+ // Verified by replaying the real warm pattern: with a plain assignment the
709
+ // 6,781-char reply lost segment 1 at t+170s while holding a 1,476s grace.
710
+ //
711
+ // Every security property is unchanged. The ceiling still binds independently
712
+ // below. Reading still buys nothing back -- for a session with a long grace,
713
+ // max() returns the grace deadline it already had, so a read cannot extend a
714
+ // capability's life by even a millisecond. Once now + SESSION_IDLE_MS passes
715
+ // the original grace, this becomes an ordinary sliding window again.
716
+ s.expiresAt = Math.min(Math.max(s.expiresAt, now + SESSION_IDLE_MS), s.hardExpiresAt)
601
717
  return s
602
718
  }
603
719
 
@@ -54,7 +54,11 @@ const CLIENT_REQUEST_RE = /^[A-Za-z0-9._:-]{16,160}$/
54
54
  const MEDIA_ID_RE = /^m_[0-9a-f]{24}$/
55
55
 
56
56
  export function videoUploadV2Enabled(): boolean {
57
- return process.env.COS_VIDEO_UPLOAD_V2 === '1'
57
+ // Default ON since 6.37.0 (Miles 2026-08-25). Absent key = on; only a
58
+ // literal '0' disables. NOTE: an in-flight upload sets blocksRestart, so a
59
+ // drain caught mid-upload waits for it — that is the intended contract, not
60
+ // a stuck gate, and must never be --forced.
61
+ return process.env.COS_VIDEO_UPLOAD_V2 !== '0'
58
62
  }
59
63
 
60
64
  export function phoneVideoFramesEnabled(): boolean {
@@ -1302,7 +1302,9 @@ export const TURN_UNKNOWN_COPY = 'COS has no record of that turn. Nothing was se
1302
1302
  export const CLIENT_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
1303
1303
 
1304
1304
  export function threadAttachEnabled(): boolean {
1305
- return process.env.COS_THREAD_ATTACH_ENABLED === '1'
1305
+ // Default ON since 6.37.0 (Miles 2026-08-25): ship the capability active and
1306
+ // let users opt out. Absent key = on; only a literal '0' disables.
1307
+ return process.env.COS_THREAD_ATTACH_ENABLED !== '0'
1306
1308
  }
1307
1309
 
1308
1310
  export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps): Router {
@@ -20,6 +20,8 @@ import {
20
20
  getHighQualityTranscriptionCapability,
21
21
  } from '../lib/whisper-local.js'
22
22
  import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
23
+ import { getMeetingSummaryBudgetState } from '../lib/meeting-summary-budget.js'
24
+ import { meetingSummaryLLMEnabled } from '../lib/meeting-summary.js'
23
25
  import { getKeyStatus } from '../lib/openai-key.js'
24
26
  import {
25
27
  getCodexModelCatalog,
@@ -256,6 +258,10 @@ healthRouter.get('/health', async (_req, res) => {
256
258
  speakerId: speakerReadinessState,
257
259
  }
258
260
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
261
+ const meeting_summary = {
262
+ enabled: meetingSummaryLLMEnabled(),
263
+ ...getMeetingSummaryBudgetState(),
264
+ }
259
265
  const codex_models = getCodexModelCatalogSnapshot()
260
266
  // Unauthenticated /api/health publishes Cursor slot capability only; concrete
261
267
  // agent binary paths stay on the authenticated /api/models surface.
@@ -317,6 +323,7 @@ healthRouter.get('/health', async (_req, res) => {
317
323
  readiness,
318
324
  whisper_health,
319
325
  openai_whisper_budget,
326
+ meeting_summary,
320
327
  tts_local,
321
328
  codex_models,
322
329
  cursor_models,
@@ -153,6 +153,7 @@ import {
153
153
  readFinalizationChunkEntries,
154
154
  type MeetingFinalizationJob,
155
155
  } from '../lib/meeting-finalization-jobs.js'
156
+ import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
156
157
 
157
158
  function cosOpsPipelineConfigured(): boolean {
158
159
  // Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
@@ -228,6 +229,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
228
229
  allowDuringDrain: true,
229
230
  phase: 'queued',
230
231
  })
232
+ const jobStartedAt = Date.now()
231
233
  const task = Promise.resolve().then(async () => {
232
234
  lease.setPhase('active')
233
235
  let current = runtime.finalizationJobs.get(job.sessionId) ?? job
@@ -317,6 +319,12 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
317
319
 
318
320
  if (cosOpsPipelineConfigured()) {
319
321
  await handoffMeetingToOperations(current.meetingPath)
322
+ } else {
323
+ // Standalone: no sync_meetings.py to produce summary/topics/decisions.
324
+ // Runs HERE, after finalizeBatch, because batch HQ replaces the whole
325
+ // transcript (meeting-batch-persistence.ts:7-12) — summarising earlier
326
+ // would describe text the file no longer contains.
327
+ await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
320
328
  }
321
329
  markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
322
330
  runtime.finalizationJobs.remove(current.sessionId)
@@ -567,7 +575,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
567
575
  : Math.max(0, Date.now() - startTime)
568
576
  const integrity = sessions.getIntegrity(sessionId)
569
577
  const needsOperations = cosOpsPipelineConfigured()
570
- const finalizationRequired = sessions.hasAudio(sessionId) || needsOperations
578
+ // Standalone saves need a finalization pass too, for summary enrichment.
579
+ // Before 6.37 a standalone save with no audio nulled the job below and
580
+ // never reached the ops_pending slot, so enrichment could never run.
581
+ const standaloneEnrichmentRequired = !needsOperations
582
+ const finalizationRequired =
583
+ sessions.hasAudio(sessionId) || needsOperations || standaloneEnrichmentRequired
571
584
  const claimPending = needsOperations && earlyMeetingSyncEnabled()
572
585
 
573
586
  // Initial canonical text + structured metadata are published before any
@@ -1948,6 +1961,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1948
1961
  } catch { /* display is best-effort */ }
1949
1962
  if (cosOpsPipelineConfigured()) {
1950
1963
  await handoffMeetingToOperations(saved.filepath)
1964
+ } else {
1965
+ // Orphan recovery is its own path and never reaches
1966
+ // scheduleFinalizationJob, so a recovered standalone meeting would
1967
+ // otherwise stay permanently un-enriched.
1968
+ // Full wall from here, not a shared finalization budget: recovery
1969
+ // already holds the long-running orphan_recovery lease that COS
1970
+ // Control surfaces and warns on before committing a drain.
1971
+ await enrichStandaloneMeeting(saved.filepath, Date.now())
1951
1972
  }
1952
1973
  }).catch(error => {
1953
1974
  // The quarantined audio is untouched on failure — retry stays possible
@@ -61,6 +61,14 @@ import {
61
61
  appendChunkEmbedding,
62
62
  sweepExpiredChunkEmbeddings,
63
63
  } from '../lib/chunk-embedding-store.js'
64
+ import {
65
+ evenSpeakerRoleMode,
66
+ formatEvenRoleAgreement,
67
+ parseEvenHubSpeakerRoleBody,
68
+ parseEvenHubSpeakerRoleQuery,
69
+ warnEvenSpeakerRoleApplyNotImplemented,
70
+ type EvenSpeakerRoleHistogram,
71
+ } from '../lib/even-hub-speaker-role.js'
64
72
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
65
73
  import {
66
74
  countChunkWavs,
@@ -276,6 +284,7 @@ export interface TranscriptChunk {
276
284
  latencyMs?: number
277
285
  audioSha256?: string
278
286
  canonical?: boolean
287
+ evenHubSpeakerRole?: EvenSpeakerRoleHistogram
279
288
  }
280
289
 
281
290
  export interface ProviderCandidateRecord {
@@ -1945,8 +1954,11 @@ async function processStreamChunk(opts: {
1945
1954
  clientElapsed?: number
1946
1955
  /** Original client recording start, applied only before canonical chunks. */
1947
1956
  startTimeOverride?: number
1957
+ evenHubSpeakerRole?: EvenSpeakerRoleHistogram
1948
1958
  }): Promise<StreamChunkCompletionResponse> {
1949
1959
  const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
1960
+ const evenHubSpeakerRole = evenSpeakerRoleMode() === 'off' ? undefined : opts.evenHubSpeakerRole
1961
+ if (evenHubSpeakerRole) warnEvenSpeakerRoleApplyNotImplemented()
1950
1962
  const tReq = performance.now()
1951
1963
  validateSessionId(sessionId)
1952
1964
  validateChunkIndex(chunkIndex)
@@ -2066,6 +2078,15 @@ async function processStreamChunk(opts: {
2066
2078
  }
2067
2079
 
2068
2080
  const { speaker, similarity } = await speakerPromise
2081
+ if (evenHubSpeakerRole) {
2082
+ console.log(formatEvenRoleAgreement({
2083
+ chunkIndex,
2084
+ even: evenHubSpeakerRole,
2085
+ amp: clientSpeaker,
2086
+ emb: speaker,
2087
+ similarity,
2088
+ }))
2089
+ }
2069
2090
  // Client time is authoritative for live network jitter and deferred replay.
2070
2091
  const elapsed = Number.isFinite(opts.clientElapsed) && (opts.clientElapsed as number) >= 0
2071
2092
  ? Math.round(opts.clientElapsed as number)
@@ -2110,6 +2131,7 @@ async function processStreamChunk(opts: {
2110
2131
  latencyMs,
2111
2132
  audioSha256,
2112
2133
  canonical: true,
2134
+ evenHubSpeakerRole,
2113
2135
  }
2114
2136
  const finalExisting = session.chunks[chunkIndex]
2115
2137
  if (finalExisting?.text) {
@@ -2263,6 +2285,7 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
2263
2285
  audioBuffer,
2264
2286
  clientElapsed,
2265
2287
  startTimeOverride,
2288
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleQuery(req.query.eh),
2266
2289
  }))
2267
2290
  } catch (err: unknown) {
2268
2291
  sendStreamError(res, err)
@@ -2327,6 +2350,7 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
2327
2350
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2328
2351
  audioBuffer,
2329
2352
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2353
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2330
2354
  candidate: {
2331
2355
  provider: 'iphone-whisperkit-beta',
2332
2356
  text: normalizeCandidateText(candidate.text),
@@ -2405,6 +2429,7 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
2405
2429
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2406
2430
  audioBuffer,
2407
2431
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2432
+ evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2408
2433
  candidate: {
2409
2434
  provider: 'iphone-whisperkit-beta',
2410
2435
  text: normalizeCandidateText(candidate.text),
@@ -28,6 +28,7 @@ import {
28
28
  completeEntry,
29
29
  abortEntry,
30
30
  createSession,
31
+ initialGraceMs,
31
32
  peekSession,
32
33
  rebindSessionHash,
33
34
  reapExpiredSessions,
@@ -868,12 +869,12 @@ ttsRouter.post('/tts/stream', async (req, res) => {
868
869
  // hash the (text, voice, format) tuple, and return a session URL.
869
870
  // 2. Client sets audio.src = `${apiBase}${sessionUrl}` and calls .play().
870
871
  // 3. The browser GETs /api/tts/play/:session using the session as a bearer
871
- // capability. Range refills may reuse it during its 60-second lifetime;
872
+ // capability. Range refills may reuse it throughout its lifetime (see SESSION_IDLE_MS / initialGraceMs);
872
873
  // the route serves cached bytes or starts live generation on a cold miss.
873
874
  //
874
875
  // The two-step pattern is required because authentication on the play route
875
876
  // would force XHR (no Range support, no progressive decoding). The session
876
- // UUID IS the auth — cryptographically random, short-lived (60s), and scoped
877
+ // UUID IS the auth — cryptographically random, bounded by SESSION_MAX_LIFETIME_MS, and scoped
877
878
  // to one prepared audio item. It is re-readable only for native Range refills.
878
879
  ttsRouter.post('/tts/prepare', async (req, res) => {
879
880
  try {
@@ -917,6 +918,13 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
917
918
  const preferOpenAI = enginePreference === 'openai'
918
919
  const forceLocal = enginePreference === 'local'
919
920
 
921
+ // Every segment is minted here, but the client only touches segment k when
922
+ // segment k-1 starts playing -- minutes later for a long reply. So each
923
+ // session's first deadline is derived from the WHOLE reply's speaking time,
924
+ // not from a flat idle window that starts before anyone could read it. See
925
+ // initialGraceMs; this is the bug that stopped playback at segment 5 of 9.
926
+ const graceMs = initialGraceMs(capped.length)
927
+
920
928
  const mintAndWarm = (chunk: string) => {
921
929
  const hash = hashForDecision(decision, requestedFormat, chunk, requestedInstructions)
922
930
  const uuid = createSession({
@@ -926,7 +934,7 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
926
934
  format: requestedFormat,
927
935
  preferOpenAI,
928
936
  forceLocal,
929
- })
937
+ }, { graceMs })
930
938
  // Detached preparation is deliberately local-only. It must never retain
931
939
  // authority to spend cloud budget after the client cancels or closes.
932
940
  // OpenAI generation (including Kokoro fallback) begins only from the
@@ -1076,9 +1084,14 @@ function serveCachedBody(
1076
1084
  ttsRouter.get('/tts/play/:session', async (req, res) => {
1077
1085
  // peekSession (v5.9.4) — non-destructive lookup so iOS WKWebView can issue
1078
1086
  // its routine HTTP Range requests for audio buffer refill without 404ing
1079
- // halfway through a long playback. Sessions still TTL out at 60s.
1087
+ // halfway through a long playback. Sessions TTL out on the idle window, or on their derived grace if never read.
1080
1088
  const session = peekSession(req.params.session)
1081
1089
  if (!session) {
1090
+ // LOG IT. The server produced this 404 and recorded nothing, so the only
1091
+ // reporter was the client -- whose report is fire-and-forget, 3s-aborted and
1092
+ // error-deduped. Four builds on 2026-08-23 were spent inferring a fact the
1093
+ // server held the whole time.
1094
+ console.warn('[tts/play] 404 session expired or unknown:', req.params.session)
1082
1095
  return res.status(404).json({ error: 'session expired or unknown' })
1083
1096
  }
1084
1097
 
@@ -4,6 +4,7 @@ import { Router } from 'express'
4
4
  import { errMsg } from '../lib/utils.js'
5
5
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
+ import { checkSpeakerName } from '../lib/speaker-name.js'
7
8
  import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
8
9
  import { statSync } from 'node:fs'
9
10
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
@@ -48,6 +49,20 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
48
49
  // under a stranger's name.
49
50
  const name = (req.query.name as string) || getOwnerSpeakerLabel()
50
51
 
52
+ // An old client can still send a whole spoken sentence as the name (the
53
+ // "enroll my voice" fall-through). Refuse it here: a junk profile can
54
+ // never match owner_speaker_label, so /voice/status would report
55
+ // enrolled:false forever, and the store cannot be repaired by editing
56
+ // voice-profiles.json — the server rewrites it from memory.
57
+ const nameCheck = checkSpeakerName(name, { ownerLabel: getOwnerSpeakerLabel() })
58
+ if (!nameCheck.ok) {
59
+ return res.status(400).json({
60
+ success: false,
61
+ error: nameCheck.message,
62
+ reason: nameCheck.reason,
63
+ })
64
+ }
65
+
51
66
  // Collect raw audio body
52
67
  const buffers: Buffer[] = []
53
68
  for await (const chunk of req) {