@gotcos/glasses-server 6.59.0 → 6.61.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.
@@ -335,6 +335,13 @@ export interface SessionHooksHealth {
335
335
  state: HookStatus['state']
336
336
  installed: boolean
337
337
  scriptSha: string | null
338
+ /**
339
+ * 6.60.0: our PermissionRequest hook's `timeout` in the Claude settings file (630 after
340
+ * Install hooks, 130 on an install from before 6.60.0), or null when it cannot be read.
341
+ */
342
+ permissionHookTimeoutS: number | null
343
+ /** 6.60.0: the only drift is that earlier PermissionRequest wait (`HookStatus.priorWaitOnly`). */
344
+ priorWaitOnly: boolean
338
345
  tokenPresent: boolean
339
346
  lastEventAt: string | null
340
347
  spoolBacklog: number
@@ -377,6 +384,8 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
377
384
  state: status.state,
378
385
  installed: status.installed,
379
386
  scriptSha: status.scriptSha,
387
+ permissionHookTimeoutS: status.permissionHookTimeoutS,
388
+ priorWaitOnly: status.priorWaitOnly,
380
389
  tokenPresent: status.tokenPresent,
381
390
  lastEventAt: newest ? new Date(newest).toISOString() : null,
382
391
  spoolBacklog: spool?.backlog ?? 0,
@@ -398,6 +407,14 @@ export function signalFor(sessionId: string): SessionSignal | undefined {
398
407
  return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
399
408
  }
400
409
 
410
+ /** 6.60.0: told when a row derives `ended`, so the broker stops saying the Mac is waiting on it. */
411
+ const rowEndedListeners = new Set<(sessionId: string) => void>()
412
+
413
+ export function onSessionRowEnded(listener: (sessionId: string) => void): () => void {
414
+ rowEndedListeners.add(listener)
415
+ return () => { rowEndedListeners.delete(listener) }
416
+ }
417
+
401
418
  /**
402
419
  * Derive with the two-scan memory kept here, keyed by full id when known. Rows the
403
420
  * caller has no facts for at all get nothing, so an older payload stays byte-identical.
@@ -419,6 +436,11 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
419
436
  prevDeadSince: prev?.since ?? null,
420
437
  attachedTurn: input.attachedTurn === true,
421
438
  })
439
+ if (derived.agent_state === 'ended') {
440
+ for (const listener of rowEndedListeners) {
441
+ try { listener(key) } catch { /* a listener never breaks a row */ }
442
+ }
443
+ }
422
444
  // A reader with no registry facts (the live feed derives from the signal alone) must
423
445
  // not touch the two-scan memory the row readers keep: a derive that saw no registry
424
446
  // would reset a dead pid's count between two list requests.
@@ -431,6 +453,7 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
431
453
  }
432
454
 
433
455
  export function __resetSessionHooksForTests(): void {
456
+ rowEndedListeners.clear()
434
457
  sessionSignalStore.__resetForTests()
435
458
  deadById.clear()
436
459
  parentCache.clear()
@@ -372,6 +372,9 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
372
372
  export function identifySpeaker(
373
373
  wavBuffer: Buffer,
374
374
  expectedSpeakers?: string[],
375
+ /** 6.61.0: `event` tags the calibration rows, e.g. 'nemotron-track' for one
376
+ * Nemotron track's audio, so they are never read as whole-chunk checks. */
377
+ options: { event?: string } = {},
375
378
  ): { speaker: string; similarity: number; embedding?: Float32Array } | null {
376
379
  if (!extractor || !manager) return null
377
380
 
@@ -385,7 +388,7 @@ export function identifySpeaker(
385
388
  const isOwner = manager.verify({ name: owner, v: embedding, threshold: VERIFY_THRESHOLD })
386
389
  if (isOwner) {
387
390
  const similarity = computeCosineSimilarity(embedding, owner)
388
- logCalibration(owner, similarity, true)
391
+ logCalibration(owner, similarity, true, options.event)
389
392
  return { speaker: owner, similarity, embedding }
390
393
  }
391
394
  }
@@ -398,7 +401,7 @@ export function identifySpeaker(
398
401
  const matches = manager.verify({ name, v: embedding, threshold: SEARCH_THRESHOLD })
399
402
  if (matches) {
400
403
  const similarity = computeCosineSimilarity(embedding, name)
401
- logCalibration(name, similarity, true)
404
+ logCalibration(name, similarity, true, options.event)
402
405
  return { speaker: name, similarity, embedding }
403
406
  }
404
407
  }
@@ -408,14 +411,14 @@ export function identifySpeaker(
408
411
  const found = manager.search({ v: embedding, threshold: SEARCH_THRESHOLD })
409
412
  if (found && found.length > 0) {
410
413
  const similarity = computeCosineSimilarity(embedding, found)
411
- logCalibration(found, similarity, true)
414
+ logCalibration(found, similarity, true, options.event)
412
415
  return { speaker: found, similarity, embedding }
413
416
  }
414
417
 
415
418
  // No match — external speaker. The embedding still goes back: an
416
419
  // unidentified voice's vector is the most valuable thing to retain, because
417
420
  // naming it later is exactly the correction that has no other evidence.
418
- logCalibration('Ext', 0, false)
421
+ logCalibration('Ext', 0, false, options.event)
419
422
  return { speaker: 'Ext', similarity: 0, embedding }
420
423
  } catch (err: unknown) {
421
424
  console.error('[speaker] Identification error:', errMsg(err))
@@ -0,0 +1,4 @@
1
+ // The least audio the voiceprint names a voice from. One source for the
2
+ // whole-chunk check in transcribe-stream and the per-track check in Nemotron
3
+ // naming, so the two can never drift apart.
4
+ export const VOICEPRINT_MIN_AUDIO_SEC = 2.0
@@ -10,6 +10,8 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
10
10
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
11
11
  import { isSileroAvailable } from '../lib/vad-silero.js'
12
12
  import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
13
+ import { nemotronLive } from '../lib/nemotron-live.js'
14
+ import { finalPassSnapshot } from '../lib/nemotron-final.js'
13
15
  import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
14
16
  import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
15
17
  import { correctionStoreStats } from '../lib/meeting-corrections.js'
@@ -336,6 +338,14 @@ healthRouter.get('/health', async (_req, res) => {
336
338
  // Asserted, not merely reported: `speaker_id` above says what the state IS,
337
339
  // this says whether that state is acceptable.
338
340
  speakerId: speakerReadinessState,
341
+ // 6.61.0: what is labelling chunks RIGHT NOW. `active` reads `nemotron` only
342
+ // while the model is warm, the breaker is closed and most of the last five
343
+ // chunks really were labelled by Nemotron; otherwise `warming` or `embedding`
344
+ // with the reason. Plus the per-chunk outcome counts since start and the last
345
+ // save-time final pass, as codes and counts only: this route is public and
346
+ // must never carry a speaker name, a path or raw error text. A Nemotron
347
+ // fallback is the designed voiceprint path, so it never degrades `status`.
348
+ diarizer: { ...nemotronLive.snapshot(), final: finalPassSnapshot() },
339
349
  }
340
350
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
341
351
  const meeting_summary = {
@@ -160,6 +160,10 @@ import {
160
160
  type MeetingFinalizationJob,
161
161
  } from '../lib/meeting-finalization-jobs.js'
162
162
  import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
163
+ import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
164
+
165
+ /** 6.61.0: the save-time Nemotron pass. Injectable so tests never spawn the CLI. */
166
+ export type FinalDiarize = (options: Pick<FinalPassOptions, 'meetingPath' | 'sidecarPath' | 'audioDir'>) => Promise<FinalPassOutcome>
163
167
 
164
168
  function cosOpsPipelineConfigured(): boolean {
165
169
  // Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
@@ -200,6 +204,7 @@ export interface MeetingRouteDependencies {
200
204
  scheduleBackground?: (task: Promise<void>) => void
201
205
  emit?: typeof emitDisplay
202
206
  finalizationJobs?: MeetingFinalizationJobStore
207
+ finalDiarize?: FinalDiarize
203
208
  }
204
209
 
205
210
  const activeFinalizationJobs = new Set<string>()
@@ -210,6 +215,7 @@ interface FinalizationRuntime {
210
215
  runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
211
216
  scheduleBackground: (task: Promise<void>) => void
212
217
  sessions?: MeetingSessionSource
218
+ finalDiarize?: FinalDiarize
213
219
  }
214
220
 
215
221
  function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
@@ -311,6 +317,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
311
317
  sidecarPath: current.sidecarPath,
312
318
  sessionId: current.sessionId,
313
319
  runBatch: runtime.runBatch,
320
+ finalDiarize: runtime.finalDiarize ?? runNemotronFinalPass,
314
321
  })
315
322
  } else {
316
323
  console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
@@ -469,6 +476,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
469
476
  const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
470
477
  const emit = deps.emit ?? emitDisplay
471
478
  const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
479
+ const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
472
480
  const router = Router()
473
481
  const savingSessions = new Set<string>()
474
482
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
@@ -553,6 +561,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
553
561
  runBatch,
554
562
  scheduleBackground,
555
563
  sessions,
564
+ finalDiarize,
556
565
  })
557
566
  res.set('Cache-Control', 'private, no-store')
558
567
  res.json(publicSaveResponse(alreadySaved, true))
@@ -717,6 +726,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
717
726
  runBatch,
718
727
  scheduleBackground,
719
728
  sessions,
729
+ finalDiarize,
720
730
  })
721
731
  }
722
732
  } catch (error) {
@@ -797,6 +807,25 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
797
807
  }
798
808
  }
799
809
 
810
+ /**
811
+ * 6.61.0 (QA W2): refuse a correction while the meeting is finalizing.
812
+ *
813
+ * The Nemotron final pass rewrites the sidecar and the transcript inside the
814
+ * finalization job, after a CLI run of tens of seconds. A relabel or a
815
+ * deattribution landing in that window would race it (the pass also re-reads
816
+ * both files and stands down, but the edit and the pass should not interleave
817
+ * at all). Same rule held naming uses: while a finalization job exists for the
818
+ * session the answer is 409, and a retry after it finishes works.
819
+ */
820
+ function finalizingRefusal(sessionId: string): { status: number; body: Record<string, unknown> } | null {
821
+ const running = [...activeFinalizationJobs].some(key => key.endsWith(`:${sessionId}`))
822
+ if (!running && !finalizationJobs.get(sessionId)) return null
823
+ return {
824
+ status: 409,
825
+ body: { error: 'Meeting is finalizing; retry after it finishes', reason: 'meeting_finalizing', retryable: true },
826
+ }
827
+ }
828
+
800
829
  // ── Speaker review (6.21.12) ──────────────────────────────────────────
801
830
  // Backs COS Control's naming panel. Read-only: it reports what a saved
802
831
  // meeting's sidecar already contains and never writes. Naming, merging, and
@@ -1191,7 +1220,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1191
1220
  // A merged G2 session is NOT refused here: it is a real capture with real
1192
1221
  // audio, and relabelling it is how its profile gets better. Only an imported
1193
1222
  // or derived IDENTITY is refused.
1194
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1223
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
1195
1224
  if (evidenceRefusal) {
1196
1225
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1197
1226
  return
@@ -1525,7 +1554,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1525
1554
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1526
1555
  return
1527
1556
  }
1528
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1557
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
1529
1558
  if (evidenceRefusal) {
1530
1559
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1531
1560
  return
@@ -2194,6 +2223,7 @@ async function finalizeBatch(options: {
2194
2223
  sidecarPath: string
2195
2224
  sessionId?: string
2196
2225
  runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
2226
+ finalDiarize?: FinalDiarize
2197
2227
  }): Promise<void> {
2198
2228
  const result = await options.runBatch(
2199
2229
  options.audioDir,
@@ -2236,6 +2266,29 @@ async function finalizeBatch(options: {
2236
2266
  )
2237
2267
  }
2238
2268
 
2269
+ // 6.61.0: the Nemotron final pass relabels the transcript just persisted,
2270
+ // while the raw chunk audio still exists and BEFORE this job reaches
2271
+ // ops_pending, so COS sync (sync_meetings.py --g2-import-file, run by the
2272
+ // handoff after this function returns) only ever imports the relabelled
2273
+ // meeting. It never throws and writes nothing unless every word survives;
2274
+ // on any failure the meeting stays exactly as saved above.
2275
+ // Skipped when the batch text landed but its sidecar did not: the two would
2276
+ // disagree about which transcript is canonical. Counted and logged like any
2277
+ // other skip, so health shows it.
2278
+ if (options.finalDiarize && transcriptApplied && !metadataPersisted) {
2279
+ recordFinalPassSkip('metadata_not_persisted', 'the batch transcript was applied but its sidecar was not written')
2280
+ } else if (options.finalDiarize) {
2281
+ try {
2282
+ await options.finalDiarize({
2283
+ meetingPath: options.meetingPath,
2284
+ sidecarPath: options.sidecarPath,
2285
+ audioDir: options.audioDir,
2286
+ })
2287
+ } catch (error) {
2288
+ console.warn(`[diarizer] final pass threw; the saved transcript is unchanged: ${error instanceof Error ? error.message : String(error)}`)
2289
+ }
2290
+ }
2291
+
2239
2292
  if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
2240
2293
  rmSync(options.audioDir, { recursive: true, force: true })
2241
2294
  } else {
@@ -25,6 +25,10 @@
25
25
  // so a surface that only shows a count can poll without making the Mac hold questions it
26
26
  // cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
27
27
  // without it is refused and never counted, whatever sits in front of this router.
28
+ // 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
29
+ // wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
30
+ // a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
31
+ // `awayHoldMs`, `awayRecentClientMs`, `settled`, `waitingAtMac` and `serverStartedAt`.
28
32
 
29
33
  import express, { Router, type NextFunction, type Request, type Response } from 'express'
30
34
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
@@ -37,7 +41,7 @@ import {
37
41
  type HookReplyChannel,
38
42
  type PermissionBroker,
39
43
  } from '../lib/permission-broker.js'
40
- import { noteQuestionsPoll } from '../lib/client-liveness.js'
44
+ import { noteQuestionsPoll, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
41
45
 
42
46
  export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
43
47
  /** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
@@ -133,8 +137,26 @@ export interface SessionQuestionsRouterDeps {
133
137
  /** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
134
138
  apiToken: () => string
135
139
  /** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
136
- notePoll?: (at: number) => void
140
+ notePoll?: (at: number, poll: QuestionsPollFacts) => void
137
141
  now?: () => number
142
+ /** 6.60.0: when this server process started (ISO); defaults to the process's own start. */
143
+ serverStartedAt?: string
144
+ }
145
+
146
+ /** 6.60.0: this process's start, so a client can tell a restart from "nothing waiting". */
147
+ export const SERVER_STARTED_AT = new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString()
148
+
149
+ /**
150
+ * 6.60.0, the contract shared with COS Glasses 6.9.563: `hold=1` (this client shows held
151
+ * questions) and `presenceAgeMs`, a whole number of ms within [0, 24 h] since the latest real
152
+ * wearer evidence. Anything else is no presence: the poll then counts only as a 6.59.0 poll
153
+ * (without `hold=1`) or as a `hold=1` poll with no presence.
154
+ */
155
+ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPollFacts {
156
+ const hold = query.hold === '1'
157
+ const raw = typeof query.presenceAgeMs === 'string' ? query.presenceAgeMs : ''
158
+ const parsed = /^\d{1,8}$/.test(raw) ? Number(raw) : Number.NaN
159
+ return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
138
160
  }
139
161
 
140
162
  export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
@@ -152,7 +174,7 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
152
174
  }
153
175
  // Only a client that says it can answer (and shows the cards) counts as live.
154
176
  const client = typeof req.query.client === 'string' ? req.query.client : ''
155
- if (ANSWERING_CLIENTS.has(client)) notePoll(now())
177
+ if (ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
156
178
  const health = deps.broker.health()
157
179
  // Listed first: a deadline the timer has not caught yet is settled by the listing.
158
180
  const items = deps.broker.list()
@@ -165,6 +187,18 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
165
187
  pending: deps.broker.pendingCount(),
166
188
  stats: deps.broker.stats(),
167
189
  items,
190
+ // 6.60.0, additive: the away hold now (0 when off) and the recent-client window it needs,
191
+ // recently settled items (codes and times only, newest first), and how many of them the
192
+ // Mac's own dialog still waits on.
193
+ awayHoldMs: health.enabled ? deps.broker.awayHoldNowMs() : 0,
194
+ awayRecentClientMs: health.enabled && deps.broker.awayHoldNowMs() > 0 ? deps.broker.awayRecentClientWindowMs() : 0,
195
+ settled: deps.broker.settledList(),
196
+ waitingAtMac: deps.broker.waitingAtMacCount(),
197
+ serverStartedAt: deps.serverStartedAt ?? SERVER_STARTED_AT,
198
+ // 6.60.0 (Ghost Hunter W1): a waiting entry settled at or before this time may have been
199
+ // forgotten (the server started then, or dropped it while it still waited). Missing from
200
+ // `settled` and settled after it: the session moved past it.
201
+ waitingAtMacForgottenBefore: new Date(deps.broker.waitingAtMacForgottenBeforeMs()).toISOString(),
168
202
  })
169
203
  })
170
204
  router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
@@ -40,6 +40,8 @@ import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whis
40
40
  import { enhanceAudio } from '../lib/audio-enhance.js'
41
41
  import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
42
42
  import { identifySpeaker, isEmbeddingAvailable, autoEnroll, AUTO_ENROLL_CANDIDATE_SIMILARITY } from '../lib/speaker-embeddings.js'
43
+ import { nemotronLive, resolveLiveLabels, resplitSegments, type ChunkSpeakerSegment } from '../lib/nemotron-live.js'
44
+ import { VOICEPRINT_MIN_AUDIO_SEC } from '../lib/voiceprint-floor.js'
43
45
  import {
44
46
  assertOpenAIWhisperBudget,
45
47
  recordOpenAIWhisperUsage,
@@ -266,6 +268,23 @@ export interface TranscriptChunk {
266
268
  audioSha256?: string
267
269
  canonical?: boolean
268
270
  evenHubSpeakerRole?: EvenSpeakerRoleHistogram
271
+ /** 6.61.0: who spoke when inside this chunk, in time order. Present when
272
+ * Nemotron separated the chunk's voices; `speaker` is then the dominant name. */
273
+ segments?: ChunkSpeakerSegment[]
274
+ /** 6.61.0: which path labelled the chunk. Absent when COS_DIARIZER selects the voiceprint. */
275
+ diarizer?: ChunkDiarizerInfo
276
+ }
277
+
278
+ export interface ChunkDiarizerInfo {
279
+ engine: 'nemotron' | 'voiceprint'
280
+ /** Why Nemotron did not label this chunk although it was requested. */
281
+ fallback?: string
282
+ /** The whole-chunk voiceprint label, kept when the dominant track's name differs. */
283
+ voiceprintSpeaker?: string
284
+ voiceprintSimilarity?: number
285
+ tracks?: number
286
+ timing?: 'words' | 'estimated'
287
+ ms?: number
269
288
  }
270
289
 
271
290
  export interface ProviderCandidateRecord {
@@ -345,6 +364,9 @@ interface StreamChunkCompletionResponse {
345
364
  backend?: string
346
365
  asrProvider?: string
347
366
  fallbackReason?: string
367
+ /** 6.61.0, additive: the chunk's speaker turns in time order. Their texts
368
+ * joined with one space equal `text`; `speaker` stays the dominant name. */
369
+ segments?: ChunkSpeakerSegment[]
348
370
  }
349
371
 
350
372
  const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
@@ -772,6 +794,12 @@ function recoverSessions(): void {
772
794
  const stripped = stripInlineHallucinations(chunk.text, data.sessionId)
773
795
  if (stripped !== chunk.text) {
774
796
  chunk.text = stripped
797
+ // 6.61.0: the stored turns must still join to the text.
798
+ if (chunk.segments) {
799
+ const refit = resplitSegments(chunk.segments, stripped)
800
+ if (refit?.length) chunk.segments = refit
801
+ else delete chunk.segments
802
+ }
775
803
  cleaned++
776
804
  }
777
805
  }
@@ -1837,6 +1865,7 @@ function canonicalChunkResponse(
1837
1865
  backend: existing.backend,
1838
1866
  asrProvider: existing.asrProvider,
1839
1867
  fallbackReason: existing.fallbackReason,
1868
+ ...(existing.segments?.length ? { segments: existing.segments } : {}),
1840
1869
  }
1841
1870
  }
1842
1871
 
@@ -1882,7 +1911,7 @@ async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Bu
1882
1911
  function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
1883
1912
  const expectedSpeakers = undefined
1884
1913
  const audioDurationSec = Math.max(0, (audioBuffer.length - 44)) / 32000
1885
- if (!isEmbeddingAvailable() || audioDurationSec < 2.0) return { speaker: clientSpeaker, similarity: 0 }
1914
+ if (!isEmbeddingAvailable() || audioDurationSec < VOICEPRINT_MIN_AUDIO_SEC) return { speaker: clientSpeaker, similarity: 0 }
1886
1915
 
1887
1916
  const tEmb = performance.now()
1888
1917
  const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers)
@@ -2033,6 +2062,15 @@ async function processStreamChunk(opts: {
2033
2062
  recordReceivedChunk(session, chunkIndex)
2034
2063
  persistSessionRequired(sessionId)
2035
2064
 
2065
+ // 6.61.0: Nemotron reads the durable WAV just written, in parallel with
2066
+ // Whisper, on the Neural Engine, inside its own budget. It is started before
2067
+ // the synchronous voiceprint so the process is already running while that
2068
+ // blocks. diarize() never throws; a fallback returns a reason.
2069
+ const diarizeStartedAt = performance.now()
2070
+ const diarizePromise = nemotronLive.diarize(
2071
+ resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
2072
+ )
2073
+
2036
2074
  const pcmData = audioBuffer.subarray(44)
2037
2075
  let sumSq = 0
2038
2076
  const nSamples = Math.floor(pcmData.length / 2)
@@ -2090,14 +2128,14 @@ async function processStreamChunk(opts: {
2090
2128
  sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
2091
2129
  }
2092
2130
 
2093
- const { speaker, similarity } = await speakerPromise
2131
+ const voiceprint = await speakerPromise
2094
2132
  if (evenHubSpeakerRole) {
2095
2133
  console.log(formatEvenRoleAgreement({
2096
2134
  chunkIndex,
2097
2135
  even: evenHubSpeakerRole,
2098
2136
  amp: clientSpeaker,
2099
- emb: speaker,
2100
- similarity,
2137
+ emb: voiceprint.speaker,
2138
+ similarity: voiceprint.similarity,
2101
2139
  }))
2102
2140
  }
2103
2141
  // Client time is authoritative for live network jitter and deferred replay.
@@ -2106,6 +2144,26 @@ async function processStreamChunk(opts: {
2106
2144
  : Date.now() - session.startTime
2107
2145
  const trimmedText = sanitized.text
2108
2146
 
2147
+ // Nemotron separates the voices; the voiceprint names each one. Any fallback
2148
+ // keeps today's whole-chunk voiceprint label. Only chunks with text count.
2149
+ const labels = trimmedText
2150
+ ? await resolveLiveLabels({
2151
+ diarize: diarizePromise,
2152
+ startedAt: diarizeStartedAt,
2153
+ text: trimmedText,
2154
+ words,
2155
+ audio: audioBuffer,
2156
+ voiceprint,
2157
+ clientSpeaker,
2158
+ identify: wav => {
2159
+ const named = identifySpeaker(wav, undefined, { event: 'nemotron-track' })
2160
+ return named ? { speaker: named.speaker, similarity: named.similarity } : null
2161
+ },
2162
+ })
2163
+ : { speaker: voiceprint.speaker, similarity: voiceprint.similarity }
2164
+ if (labels.record) nemotronLive.record({ sessionId, chunkIndex }, labels.record)
2165
+ const { speaker, similarity } = labels
2166
+
2109
2167
  if (!trimmedText) {
2110
2168
  console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
2111
2169
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
@@ -2145,6 +2203,8 @@ async function processStreamChunk(opts: {
2145
2203
  audioSha256,
2146
2204
  canonical: true,
2147
2205
  evenHubSpeakerRole,
2206
+ ...(labels.segments?.length ? { segments: labels.segments } : {}),
2207
+ ...(labels.diarizer ? { diarizer: labels.diarizer } : {}),
2148
2208
  }
2149
2209
  const finalExisting = session.chunks[chunkIndex]
2150
2210
  if (finalExisting?.text) {
@@ -2174,7 +2234,10 @@ async function processStreamChunk(opts: {
2174
2234
 
2175
2235
  scheduleSessionProgressiveHq(sessionId)
2176
2236
 
2177
- emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
2237
+ emitDisplay({
2238
+ type: 'transcript_chunk',
2239
+ data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId, ...(chunk.segments ? { segments: chunk.segments } : {}) },
2240
+ })
2178
2241
  // Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
2179
2242
  // block on a cue, and no LLM runs on this path. .catch() is required — a
2180
2243
  // bare `void` on a rejecting promise is an unhandled rejection, which Node