@gotcos/glasses-server 6.94.1 → 6.94.3

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -0
  3. package/package.json +2 -1
  4. package/server/index.ts +6 -0
  5. package/server/lib/decision-provider.ts +4 -2
  6. package/server/lib/held-naming-batches.ts +8 -7
  7. package/server/lib/held-voice-groups.ts +5 -4
  8. package/server/lib/keyed-sessions.ts +21 -1
  9. package/server/lib/mac-speaker-machine-evidence.ts +111 -0
  10. package/server/lib/mac-speaker-profile-extractor.ts +41 -0
  11. package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
  12. package/server/lib/mac-speaker-protection.ts +22 -0
  13. package/server/lib/mac-speaker-reconcile.ts +300 -0
  14. package/server/lib/mac-speaker-repair-projection.ts +301 -0
  15. package/server/lib/mac-speaker-repair-render.ts +105 -0
  16. package/server/lib/mac-speaker-windows.ts +239 -0
  17. package/server/lib/mac-voice-assignment.ts +2 -1
  18. package/server/lib/meeting-corrections.ts +2 -1
  19. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  20. package/server/lib/meeting-relabel.ts +17 -10
  21. package/server/lib/meeting-scribe-content.ts +8 -1
  22. package/server/lib/meeting-speaker-review.ts +46 -7
  23. package/server/lib/nemotron-final.ts +64 -14
  24. package/server/lib/session-find-ask.ts +281 -0
  25. package/server/lib/session-find-collect.ts +715 -0
  26. package/server/lib/session-find-redact.ts +254 -0
  27. package/server/lib/session-find.ts +345 -0
  28. package/server/lib/speaker-calibration-log.ts +3 -2
  29. package/server/lib/speaker-embeddings.ts +46 -35
  30. package/server/lib/speaker-name-nfc.ts +37 -0
  31. package/server/lib/speaker-rename-fanout.ts +7 -6
  32. package/server/lib/speaker-trainer.ts +2 -1
  33. package/server/lib/voice-directory.ts +7 -2
  34. package/server/lib/voice-profile-store.ts +24 -14
  35. package/server/routes/decision-provider.ts +4 -1
  36. package/server/routes/jev.ts +5 -1
  37. package/server/routes/meeting.ts +43 -15
  38. package/server/routes/session-find.ts +73 -0
  39. package/server/routes/voice.ts +25 -18
  40. package/server/scripts/mac-speaker-repair.ts +324 -0
@@ -77,6 +77,7 @@ import {
77
77
  greedyDiversitySelect,
78
78
  } from './voice-enrolment-selection.js'
79
79
  import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
80
+ import { sameSpeakerName } from './speaker-name-nfc.js'
80
81
 
81
82
  /** A turn shorter than this is not sliced out of a mixed chunk. */
82
83
  export const MAC_ASSIGN_MIN_SLICE_SEC = 3.0
@@ -239,7 +240,7 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
239
240
  if (isPlaceholderLabel(to)) return none('unknown_profile')
240
241
  if (input.ownerLabel && sameName(to, input.ownerLabel)) return none('owner_profile')
241
242
  let profile: { name: string; embeddings: number[][]; sources?: string[] } | undefined
242
- try { profile = readVoiceProfiles().profiles.find(p => p.name === to) } catch { return none('store_unavailable') }
243
+ try { profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, to)) } catch { return none('store_unavailable') }
243
244
  if (!profile) return none('unknown_profile')
244
245
  if (!chunkEmbeddingsEnabled()) return none('disabled')
245
246
  if (!isEmbeddingAvailable()) return none('store_unavailable')
@@ -27,6 +27,7 @@
27
27
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
28
28
  import { join, resolve } from 'node:path'
29
29
  import { dataPath } from './data-dir.js'
30
+ import { sameSpeakerName } from './speaker-name-nfc.js'
30
31
 
31
32
  export const CORRECTIONS_DIR = 'meeting-corrections'
32
33
 
@@ -268,7 +269,7 @@ export function currentLabelFor(sessionId: string, originalLabel: string): strin
268
269
  for (let hop = 0; hop < applied.length; hop++) {
269
270
  // Latest applied correction FROM the current label wins: a later human
270
271
  // decision supersedes an earlier one.
271
- const next = [...applied].reverse().find(r => r.from === label)
272
+ const next = [...applied].reverse().find(r => sameSpeakerName(r.from, label))
272
273
  if (!next || seen.has(next.to)) break
273
274
  label = next.to
274
275
  seen.add(label)
@@ -37,6 +37,7 @@
37
37
  // every future meeting. A wrong write is permanent and silent. So every step below
38
38
  // fails CLOSED: no mapping means no enrolment, never a fall back to positions.
39
39
 
40
+ import { sameSpeakerName } from './speaker-name-nfc.js'
40
41
  import {
41
42
  chunkEmbeddingTtlMs,
42
43
  chunkEmbeddingsEnabled,
@@ -163,7 +164,7 @@ function missingReason(sidecar: Record<string, unknown>): EnrolmentSkipReason {
163
164
  * append as a creation. */
164
165
  function profileExists(name: string): boolean {
165
166
  try {
166
- return readVoiceProfiles().profiles.some(p => p.name === name)
167
+ return readVoiceProfiles().profiles.some(p => sameSpeakerName(p.name, name))
167
168
  } catch {
168
169
  // Unknowable is not "new". Claiming creation is the lie that matters here.
169
170
  return true
@@ -27,6 +27,7 @@
27
27
  // somebody else. Reported as `proseStale` instead.
28
28
 
29
29
  import { isVoiceLabel } from './mac-voice-labels.js'
30
+ import { nameForms, nfc, sameSpeakerName } from './speaker-name-nfc.js'
30
31
 
31
32
  /** A label that would corrupt the file formats it gets written into. */
32
33
  export function invalidLabelReason(label: string): string | null {
@@ -75,6 +76,10 @@ export function relabelSidecarJson(
75
76
  to: string,
76
77
  chunks: number[] = [],
77
78
  ): RelabelOutcome<SidecarRelabelResult> {
79
+ // Names are NFC here whatever arrived (a decomposed spelling must never be written), and matched NFC on both sides, so a chunk
80
+ // already stored decomposed still counts as carrying `from`.
81
+ from = nfc(from)
82
+ to = nfc(to)
78
83
  if (from === to) return { ok: false, error: 'from and to are the same label' }
79
84
  for (const [which, label] of [['from', from], ['to', to]] as const) {
80
85
  const bad = invalidLabelReason(label)
@@ -100,7 +105,7 @@ export function relabelSidecarJson(
100
105
  if (restrict) {
101
106
  const mismatched = chunks.filter(i => {
102
107
  const row = rows[i]
103
- return !row || typeof row !== 'object' || (row as Record<string, unknown>).speaker !== from
108
+ return !row || typeof row !== 'object' || !sameSpeakerName((row as Record<string, unknown>).speaker, from)
104
109
  })
105
110
  if (mismatched.length > 0) {
106
111
  return { ok: false, error: `chunks do not carry "${from}": ${mismatched.slice(0, 8).join(', ')}` }
@@ -111,11 +116,11 @@ export function relabelSidecarJson(
111
116
  let remainingWithFrom = 0
112
117
  rows.forEach((row, i) => {
113
118
  // The `typeof` half guards the cast below rather than any behaviour — a
114
- // truthy non-object can never satisfy `.speaker === from`, so it is
119
+ // truthy non-object can never satisfy the NFC name match below, so it is
115
120
  // deliberately not claimed as tested.
116
121
  if (!row || typeof row !== 'object') return
117
122
  const r = row as Record<string, unknown>
118
- if (r.speaker !== from) return
123
+ if (!sameSpeakerName(r.speaker, from)) return
119
124
  if (restrict && !restrict.has(i)) { remainingWithFrom++; return }
120
125
  r.speaker = to
121
126
  changed.push(i)
@@ -131,18 +136,18 @@ export function relabelSidecarJson(
131
136
  if (isVoiceLabel(to)) {
132
137
  // 6.90.0: a Mac `Voice N` placeholder never enters `speakers` (the store
133
138
  // keeps it out too), so merging into one adds nothing. Retire `from` only.
134
- const at = speakers.indexOf(from)
139
+ const at = speakers.findIndex(x => sameSpeakerName(x, from))
135
140
  if (at >= 0 && remainingWithFrom === 0) speakers.splice(at, 1)
136
- } else if (!speakers.includes(to)) {
141
+ } else if (!speakers.some(x => sameSpeakerName(x, to))) {
137
142
  // Replace in place when `from` is being fully retired, so the list keeps its
138
143
  // original ordering rather than moving the person to the end.
139
- const at = speakers.indexOf(from)
144
+ const at = speakers.findIndex(x => sameSpeakerName(x, from))
140
145
  if (at >= 0 && remainingWithFrom === 0) speakers[at] = to
141
146
  else speakers.push(to)
142
147
  } else if (remainingWithFrom === 0) {
143
148
  // `to` already listed and `from` fully retired — merging a split identity.
144
149
  // Drop the old entry rather than leaving a name nothing is attributed to.
145
- const at = speakers.indexOf(from)
150
+ const at = speakers.findIndex(x => sameSpeakerName(x, from))
146
151
  if (at >= 0) speakers.splice(at, 1)
147
152
  }
148
153
  doc.speakers = speakers
@@ -198,6 +203,8 @@ export function relabelMeetingMarkdown(
198
203
  to: string,
199
204
  options: { removeAttendee?: boolean } = {},
200
205
  ): RelabelOutcome<MarkdownRelabelResult> {
206
+ from = nfc(from)
207
+ to = nfc(to)
201
208
  if (from === to) return { ok: false, error: 'from and to are the same label' }
202
209
  for (const [which, label] of [['from', from], ['to', to]] as const) {
203
210
  const bad = invalidLabelReason(label)
@@ -215,7 +222,7 @@ export function relabelMeetingMarkdown(
215
222
  // Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
216
223
  // run past its own line. Without the end anchor, `- Theo M` matches the
217
224
  // PREFIX of `- Theo Marsh` and deleting it leaves the fragment `enry`.
218
- const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
225
+ const line = (label: string) => `^-[ \t]+${nameForms(label, escapeRegExp)}[ \t]*$`
219
226
  // A de-attribution must DELETE the attendee bullet, never rename it. Renaming
220
227
  // writes `- Unidentified 2` into the attendee list as though it were a
221
228
  // person, and the COS pipeline that BUILDS attendees deliberately excludes
@@ -241,7 +248,7 @@ export function relabelMeetingMarkdown(
241
248
  if (tr) {
242
249
  const body = out.slice(tr.start, tr.end)
243
250
  const rewritten = body.replace(
244
- new RegExp(`^\\[${escapeRegExp(from)}\\]:`, 'gm'),
251
+ new RegExp(`^\\[${nameForms(from, escapeRegExp)}\\]:`, 'gm'),
245
252
  () => { transcript++; return `[${to}]:` },
246
253
  )
247
254
  out = out.slice(0, tr.start) + rewritten + out.slice(tr.end)
@@ -276,7 +283,7 @@ export function detectProseReferences(md: string, label: string): string[] {
276
283
  prose += md.slice(cursor)
277
284
 
278
285
  const hits: string[] = []
279
- const full = new RegExp(`\\b${escapeRegExp(label)}\\b`)
286
+ const full = new RegExp(`\\b${nameForms(label, escapeRegExp)}\\b`)
280
287
  if (full.test(prose)) hits.push(label)
281
288
  const first = label.split(/\s+/)[0]
282
289
  // Only a multi-word label has a distinct bare-first-name form. 'MU' or 'Ext'
@@ -276,6 +276,9 @@ export interface AttendeeLine {
276
276
  */
277
277
  isOwner: boolean
278
278
  confirmedByHuman: boolean
279
+ /** Qualified exact-span machine evidence; raw capture scores stay unchanged. */
280
+ machineQualified?: boolean
281
+ machineQualifiedSegments?: number
279
282
  }
280
283
 
281
284
  /**
@@ -406,8 +409,12 @@ export function renderProvenance(
406
409
  const clauses: string[] = []
407
410
  const owner = named.filter(v => v.isOwner).map(q)
408
411
  const human = named.filter(v => !v.isOwner && v.confirmedByHuman).map(q)
409
- const matched = named.filter(v => !v.isOwner && !v.confirmedByHuman).map(q)
412
+ const machine = named.filter(v => !v.isOwner && !v.confirmedByHuman && v.machineQualified).map(q)
413
+ const combined = named.filter(v => !v.isOwner && !v.confirmedByHuman && !v.machineQualified && (v.machineQualifiedSegments ?? 0) > 0).map(q)
414
+ const matched = named.filter(v => !v.isOwner && !v.confirmedByHuman && !v.machineQualified && !(v.machineQualifiedSegments ?? 0)).map(q)
410
415
  if (owner.length) clauses.push(`${owner.join(', ')} by wearing the device`)
416
+ if (machine.length) clauses.push(`${machine.join(', ')} by qualified independent long-window profile evidence (not human confirmation)`)
417
+ if (combined.length) clauses.push(`${combined.join(', ')} by capture matches plus qualified independent long-window profile evidence (not human confirmation)`)
411
418
  if (matched.length) clauses.push(`${matched.join(', ')} by voice match`)
412
419
  if (human.length) clauses.push(`${human.join(', ')} because a human named that voice`)
413
420
  const lines: string[] = [
@@ -22,7 +22,9 @@
22
22
  //
23
23
  // Everything here is pure over a chunk array so it can be tested by execution.
24
24
 
25
+ import type { QualifiedMachinePosition } from './mac-speaker-machine-evidence.js'
25
26
  import { isVoiceLabel } from './mac-voice-labels.js'
27
+ import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
26
28
 
27
29
  /** One transcript chunk as stored in the `.g2-chunks.json` sidecar. */
28
30
  export interface ReviewChunk {
@@ -152,6 +154,10 @@ export interface VoiceReview {
152
154
  isOwner: boolean
153
155
  /** A human confirmed this label for this meeting; the floor is waived. */
154
156
  confirmedByHuman: boolean
157
+ /** Independent long-window profile evidence, never a human confirmation. */
158
+ machineQualified: boolean
159
+ machineQualifiedSegments: number
160
+ assertionSource: 'owner' | 'human' | 'qualified-profile' | 'capture-and-qualified-profile' | 'capture-match' | null
155
161
  reliability: Reliability
156
162
  /**
157
163
  * Whether `label` may be shown to a human AS A NAME.
@@ -588,6 +594,9 @@ export function reviewMeetingSpeakers(
588
594
  */
589
595
  confirmed?: Set<string>
590
596
  confirmedChunks?: Map<number, string>
597
+ /** Reader-validated exact compacted positions from current bound raw audio. */
598
+ machineEvidenceStatus?: 'absent' | 'qualified' | 'invalid'
599
+ machineQualifiedPositions?: ReadonlyMap<number, QualifiedMachinePosition>
591
600
  /**
592
601
  * The sidecar's `batchSegments`, when the HQ pass has run. Their word
593
602
  * timings give real voiced time; without them speaking time falls back to
@@ -597,10 +606,10 @@ export function reviewMeetingSpeakers(
597
606
  } = {},
598
607
  ): MeetingSpeakerReview {
599
608
  const owner = options.owner ?? 'Me'
600
- const confirmed = new Set(options.confirmed ?? [])
609
+ const confirmed = new Set([...(options.confirmed ?? [])].map(nfc))
601
610
  for (const label of new Set(chunks.map(c => c.speaker ?? ''))) {
602
611
  const positions = chunks.flatMap((c, i) => c.speaker === label ? [i] : [])
603
- if (positions.length > 0 && positions.every(i => options.confirmedChunks?.get(i) === label)) confirmed.add(label)
612
+ if (positions.length > 0 && positions.every(i => sameSpeakerName(options.confirmedChunks?.get(i), label))) confirmed.add(nfc(label))
604
613
  }
605
614
  const limit = options.phrasesPerVoice ?? 3
606
615
  const sequence = chunks.map(c => c.speaker ?? '')
@@ -646,10 +655,28 @@ export function reviewMeetingSpeakers(
646
655
  const voices: VoiceReview[] = labels.map(label => {
647
656
  const own = chunks.filter(c => (c.speaker ?? '') === label)
648
657
  const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
658
+ const machineQualifiedSegments = chunks.reduce((n, c, i) => {
659
+ const proof = options.machineQualifiedPositions?.get(i)
660
+ return n + Number(c.speaker === label && !!proof && proof.label === label
661
+ && (c.chunkIndex === undefined || c.chunkIndex === proof.rawChunkIndex)
662
+ && proof.independentWindows >= 2 && proof.minimumSimilarity >= .88 && proof.minimumMargin >= .05)
663
+ }, 0)
664
+ const machineQualified = own.length > 0 && machineQualifiedSegments === own.length
665
+ // Repaired records require support for every occurrence. A legacy mean
666
+ // from one positive row cannot vouch for repaired zero-score positions.
667
+ const scopedMachineRecord = (options.machineEvidenceStatus !== undefined && options.machineEvidenceStatus !== 'absent') || (options.machineQualifiedPositions?.size ?? 0) > 0
668
+ const unsupportedScopedSegments = scopedMachineRecord ? chunks.reduce((n, c, i) => {
669
+ if (c.speaker !== label) return n
670
+ const proof = options.machineQualifiedPositions?.get(i)
671
+ const machine = proof?.label === label && (c.chunkIndex === undefined || c.chunkIndex === proof.rawChunkIndex) && proof.independentWindows >= 2 && proof.minimumSimilarity >= .88 && proof.minimumMargin >= .05
672
+ const human = confirmed.has(nfc(label)) || sameSpeakerName(options.confirmedChunks?.get(i), label)
673
+ const capture = typeof c.similarity === 'number' && Number.isFinite(c.similarity) && c.similarity >= ASSERT_MIN_SIMILARITY
674
+ return n + Number(!machine && !human && !capture)
675
+ }, 0) : 0
649
676
  const runs = speakerRuns(sequence, label)
650
677
  const unattributed = isUnattributed(label)
651
- const confirmedSegments = unattributed ? 0 : confirmed.has(label) ? own.length
652
- : chunks.reduce((n, c, i) => n + Number(c.speaker === label && options.confirmedChunks?.get(i) === label), 0)
678
+ const confirmedSegments = unattributed ? 0 : confirmed.has(nfc(label)) ? own.length
679
+ : chunks.reduce((n, c, i) => n + Number(c.speaker === label && sameSpeakerName(options.confirmedChunks?.get(i), label)), 0)
653
680
 
654
681
  const thrashesWith: ThrashPair[] = []
655
682
  if (!unattributed) {
@@ -674,7 +701,7 @@ export function reviewMeetingSpeakers(
674
701
  ? 'unattributed'
675
702
  : thrashesWith.length > 0
676
703
  ? 'unreliable'
677
- : (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
704
+ : machineQualified || (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
678
705
 
679
706
  // What stops this label being presented as a name. Collected as reasons
680
707
  // rather than a bare boolean: "2 segments" and "similarity 0.58" are
@@ -682,12 +709,17 @@ export function reviewMeetingSpeakers(
682
709
  const assertionBlockers: string[] = []
683
710
  if (unattributed) {
684
711
  assertionBlockers.push('no name was ever assigned to this voice')
685
- } else if (confirmed.has(label)) {
712
+ } else if (confirmed.has(nfc(label))) {
686
713
  // A human said this is them. Blockers stay OFF the list rather than being
687
714
  // listed-then-overridden, because a reviewer reading "similarity 0.56
688
715
  // below 0.65" under a name they personally confirmed would reasonably
689
716
  // conclude the confirmation had not taken. `thrashesWith` still renders,
690
717
  // so a genuinely mixed row is still visibly mixed.
718
+ } else if (unsupportedScopedSegments) {
719
+ // System-audio repairs do not identify the wearer by device ownership.
720
+ // This gate also precedes the legacy owner exemption.
721
+ assertionBlockers.push(`${unsupportedScopedSegments} segment${unsupportedScopedSegments === 1 ? '' : 's'} lack current scoped speaker evidence`)
722
+ if (thrashesWith.length > 0) assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
691
723
  } else if (label === owner) {
692
724
  // The wearer is exempt. Their identity is established by wearing the
693
725
  // device, not by cosine — and the owner is verified at exactly this floor
@@ -696,6 +728,10 @@ export function reviewMeetingSpeakers(
696
728
  // row read "Unidentified voice" in 4 of 9 meetings, including one with 285
697
729
  // of their own segments. `thrashesWith` still renders, so a mixed row is
698
730
  // still visible — the name is asserted, the caveat is not hidden.
731
+ } else if (machineQualified) {
732
+ // Exact positions passed the independent .88/margin/long-window gate.
733
+ // Keep raw similarities unchanged and retain the mixed-row warning.
734
+ if (thrashesWith.length > 0) assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
699
735
  } else {
700
736
  if (own.length < ASSERT_MIN_SEGMENTS) {
701
737
  assertionBlockers.push(`only ${own.length} segment${own.length === 1 ? '' : 's'} (needs ${ASSERT_MIN_SEGMENTS})`)
@@ -718,7 +754,10 @@ export function reviewMeetingSpeakers(
718
754
  longestRun: runs.length ? Math.max(...runs) : 0,
719
755
  isOwner: label === owner,
720
756
  /** True when a human vouched for this label in this meeting. */
721
- confirmedByHuman: confirmed.has(label),
757
+ confirmedByHuman: confirmed.has(nfc(label)),
758
+ machineQualified: !unattributed && machineQualified,
759
+ machineQualifiedSegments: unattributed ? 0 : machineQualifiedSegments,
760
+ assertionSource: assertionBlockers.length ? null : confirmed.has(nfc(label)) ? 'human' : label === owner ? 'owner' : machineQualified ? 'qualified-profile' : machineQualifiedSegments > 0 ? 'capture-and-qualified-profile' : 'capture-match',
722
761
  reliability,
723
762
  nameAsserted: assertionBlockers.length === 0,
724
763
  assertionBlockers,
@@ -40,6 +40,7 @@ import {
40
40
  nemotronCliPath,
41
41
  prepareNemotronModelsDir,
42
42
  } from './diarizer-backend.js'
43
+ import { readCorrections } from './meeting-corrections.js'
43
44
  import { CONFIDENT_SIMILARITY, isUnattributed } from './meeting-speaker-review.js'
44
45
  import { getOwnerSpeakerLabel } from './profile.js'
45
46
  import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
@@ -370,6 +371,24 @@ function liveSpeakerAt(chunk: SidecarChunk, t: number, owner?: string | null): s
370
371
  return best && typeof best.speaker === 'string' ? guardOwnerName(best.speaker, best.similarity, chunk, owner) : null
371
372
  }
372
373
 
374
+ /** Existing strict Mac names are local immutable claims, never a name source
375
+ * for a global anonymous channel. Exact token/word labels win over chunk names.
376
+ * An unplaced named segment withholds anonymous refinement for the whole chunk. */
377
+ function protectedMacName(chunk: SidecarChunk, t: number, exact?: string): { name: string | null; hold: boolean } {
378
+ if (chunk.speakerPolicy !== 'high-confidence') return { name: null, hold: false }
379
+ if (typeof exact === 'string' && isName(exact)) return { name: exact, hold: true }
380
+ const named = (chunk.segments ?? []).filter(s => isName(s.speaker))
381
+ for (const segment of named) {
382
+ if (!Number.isFinite(segment.startSec) || !Number.isFinite(segment.endSec)
383
+ || Number(segment.startSec) < 0 || Number(segment.endSec) <= Number(segment.startSec)) return { name: null, hold: true }
384
+ if (t >= Number(segment.startSec) && t < Number(segment.endSec)) return { name: segment.speaker, hold: true }
385
+ }
386
+ // A segment-less legacy chunk has only its head claim. Explicit anonymous
387
+ // turns, however, must never inherit a real name from the majority/head label.
388
+ if (isName(chunk.speaker) && !chunk.segments?.length) return { name: chunk.speaker, hold: true }
389
+ return { name: null, hold: false }
390
+ }
391
+
373
392
  export interface RelabelStats {
374
393
  total: number
375
394
  /** Words whose label changed. */
@@ -418,13 +437,15 @@ export function relabelBatchSegments(
418
437
  const end = batchWordToTimeline(segment, Number(word.end), timeline)
419
438
  // Existing live anonymous turns are established capture evidence. A
420
439
  // meeting-wide channel may refine unknown speech, never overwrite them.
421
- const live = start ? liveSpeakerAt(start.window.chunk, start.inChunk, owner) : null
422
- let name: string | null = isVoiceLabel(live) ? live : null
423
- if (name === null && start) {
440
+ const liveCandidate = start ? liveSpeakerAt(start.window.chunk, start.inChunk, owner) : null
441
+ const protectedName = start ? protectedMacName(start.window.chunk, start.inChunk, word.speaker) : { name: null, hold: false }
442
+ const live = start?.window.chunk.speakerPolicy === 'high-confidence' && isName(liveCandidate) ? protectedName.name : liveCandidate
443
+ let name: string | null = protectedName.name ?? (isVoiceLabel(live) ? live : null)
444
+ if (name === null && !protectedName.hold && start) {
424
445
  const channel = channelForSpan(a, start.at, Math.max(start.at, end?.window === start.window ? end.at : start.at + 0.01))
425
446
  if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
426
447
  }
427
- if (isVoiceLabel(live)) stats.kept++
448
+ if (isVoiceLabel(live) || protectedName.name !== null) stats.kept++
428
449
  if (name === null) {
429
450
  stats.kept++
430
451
  name = live
@@ -479,20 +500,34 @@ export function relabelStreamingChunks(
479
500
  if (typeof chunk.text !== 'string' || !chunk.text.trim()) continue
480
501
  const tokens = splitTokens(chunk.text)
481
502
  const times = streamingTokenTimes(chunk, tokens, window, a)
482
- const base = guardOwnerName(typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker, chunk.similarity, chunk, owner)
503
+ const originalBase = guardOwnerName(typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker, chunk.similarity, chunk, owner)
504
+ const base = chunk.speakerPolicy === 'high-confidence' && chunk.segments?.length && isName(originalBase) ? 'Ext' : originalBase
483
505
  const labels: string[] = []
484
506
  const liveTokens = (chunk.segments ?? []).flatMap(segment => splitTokens(String(segment.text ?? '')).map(token => ({ token, speaker: segment.speaker })))
485
507
  const exactLive = liveTokens.length === tokens.length && liveTokens.every((part, i) => part.token === tokens[i]) ? liveTokens : null
508
+ // Without token alignment or a clock, preserve the original named chunk
509
+ // rather than spreading a nearby named turn across its unmatched text.
510
+ if (chunk.speakerPolicy === 'high-confidence' && chunk.segments?.some(s => isName(s.speaker)) && !exactLive && !times) continue
486
511
  let carry: string | null = null
487
512
  for (let i = 0; i < tokens.length; i++) {
488
513
  const established = exactLive?.[i].speaker
489
- if (!isWordToken(tokens[i]) || !times) { labels.push(isVoiceLabel(established) ? established! : carry ?? liveSpeakerAt(chunk, 0, owner) ?? base); continue }
514
+ if (!isWordToken(tokens[i]) || !times) {
515
+ const protectedName = !times && established === undefined && chunk.segments?.length ? { name: null, hold: true } : protectedMacName(chunk, times?.[i]?.start ?? 0, established)
516
+ const local = liveSpeakerAt(chunk, times?.[i]?.start ?? 0, owner)
517
+ const safeLocal = chunk.speakerPolicy === 'high-confidence' && isName(local) ? protectedName.name : local
518
+ labels.push(protectedName.name ?? (chunk.speakerPolicy === 'high-confidence' && established !== undefined ? established : isVoiceLabel(established) ? established! : carry ?? safeLocal ?? base))
519
+ continue
520
+ }
490
521
  stats.total++
491
522
  const span = times[i]
492
523
  const channel = channelForSpan(a, window.start + span.start, window.start + Math.max(span.end, span.start + 0.01))
493
524
  let name: string
494
- const live = isVoiceLabel(established) ? established! : liveSpeakerAt(chunk, span.start, owner)
495
- if (isVoiceLabel(live)) { name = live!; stats.kept++ }
525
+ const liveCandidate = isVoiceLabel(established) ? established! : liveSpeakerAt(chunk, span.start, owner)
526
+ const protectedName = protectedMacName(chunk, span.start, established)
527
+ const live = chunk.speakerPolicy === 'high-confidence' && isName(liveCandidate) ? protectedName.name : liveCandidate
528
+ if (protectedName.name !== null) { name = protectedName.name; stats.kept++ }
529
+ else if (protectedName.hold) { name = live ?? base; stats.kept++ }
530
+ else if (isVoiceLabel(live)) { name = live!; stats.kept++ }
496
531
  else if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
497
532
  else { name = live ?? base; stats.kept++ }
498
533
  labels.push(name)
@@ -787,6 +822,14 @@ function distinctSpeakers(transcript: string): number {
787
822
  return names.size
788
823
  }
789
824
 
825
+ /** Legacy corrections may have a ledger without a sidecar revision. Unknown
826
+ * or incomplete recorded history is held, never treated as automatic evidence. */
827
+ function hasHumanCorrectionHistory(sidecar: Record<string, unknown>): boolean {
828
+ if (typeof sidecar.sessionId !== 'string') return false
829
+ const corrections = readCorrections(sidecar.sessionId)
830
+ return corrections.rows.length > 0 || corrections.unusable > 0
831
+ }
832
+
790
833
  async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome> {
791
834
  const started = (options.now ?? Date.now)()
792
835
  const env = options.env ?? process.env
@@ -801,6 +844,7 @@ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome
801
844
  const sidecar = JSON.parse(sidecarText) as Record<string, any>
802
845
  const owner = options.owner !== undefined ? options.owner : ownerLabelOrNull()
803
846
  if (Number(sidecar.correctionRevision ?? 0) > 0) return { status: 'skipped', reason: 'human_corrected' }
847
+ if (hasHumanCorrectionHistory(sidecar)) return { status: 'skipped', reason: 'human_corrected' }
804
848
  const markdown = readFileSync(options.meetingPath, 'utf8')
805
849
  const transcript = readTranscriptSection(markdown)
806
850
  if (transcript === null) return { status: 'skipped', reason: 'no_transcript' }
@@ -1025,6 +1069,9 @@ function commitFinalPass(
1025
1069
  return { status: 'skipped', reason: 'changed_during_pass', detail: sidecarNow !== sidecarText ? 'sidecar changed' : 'markdown changed' }
1026
1070
  }
1027
1071
 
1072
+ // A legacy confirmation can land only in its ledger while native work runs.
1073
+ if (hasHumanCorrectionHistory(nextSidecar)) return { status: 'skipped', reason: 'changed_during_pass', detail: 'correction history changed' }
1074
+
1028
1075
  // Sidecar first, markdown second, as the store does: the markdown is the
1029
1076
  // visible commit. If the markdown write fails, put the sidecar back, and say
1030
1077
  // so loudly if even that fails (the two files then disagree).
@@ -1056,8 +1103,8 @@ function commitFinalPass(
1056
1103
  // ── 6.90.0: unnamed, meeting-stable voices for Mac recordings ───────────────────
1057
1104
  //
1058
1105
  // The named pass above maps a channel to a name from chunks the voiceprint was
1059
- // sure of. A Mac recording has no such chunks (every one is `Ext` at the 0.88
1060
- // floor) and must never borrow a name, so it skips that pass (`speaker_policy`).
1106
+ // sure of. Strict Mac recordings never borrow names across a global channel;
1107
+ // rare existing names are preserved locally while other words stay anonymous.
1061
1108
  // This pass keeps the channels and drops the names: each channel that holds the
1062
1109
  // floor in enough chunks becomes `Voice 1`, `Voice 2`, ... in order of first
1063
1110
  // speech. No profile is read or written anywhere below; `owner` is never passed,
@@ -1070,9 +1117,9 @@ export const VOICE_MIN_SUPPORT = FINAL_MIN_SUPPORT
1070
1117
 
1071
1118
  /**
1072
1119
  * Unnamed mode applies to a Mac recording, with the switch on, when every chunk
1073
- * that carries words is the Mac strict policy and none already carries a real
1074
- * name (a 0.88 match is rare, and a pass must never overwrite one with a
1075
- * placeholder).
1120
+ * that carries words is the Mac strict policy. Rare existing names are protected
1121
+ * per span by the relabellers; one match must not disable anonymous refinement
1122
+ * for the remainder of an otherwise strict recording.
1076
1123
  */
1077
1124
  export function unnamedVoicesEligible(
1078
1125
  sidecar: Record<string, unknown>,
@@ -1090,7 +1137,7 @@ export function unnamedVoicesEligible(
1090
1137
  // strict; anything else (anonymous, a malformed marker) still refuses.
1091
1138
  const silentOk = entries.filter(entry => !hasText(entry.chunk))
1092
1139
  .every(entry => entry.chunk?.speakerPolicy === undefined || entry.chunk.speakerPolicy === 'high-confidence')
1093
- return silentOk && spoken.every(chunk => chunk.speakerPolicy === 'high-confidence' && !isName(chunk.speaker))
1140
+ return silentOk && spoken.every(chunk => chunk.speakerPolicy === 'high-confidence')
1094
1141
  }
1095
1142
 
1096
1143
  export interface VoiceChannels {
@@ -1360,6 +1407,9 @@ async function unnamedVoicePass(input: UnnamedInput): Promise<FinalPassOutcome>
1360
1407
  for (const chunk of [entry.chunk, chunks[i]]) {
1361
1408
  const original = typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker
1362
1409
  if (final) chunk.finalSegments = final.segments
1410
+ // A pre-existing strict name is immutable even when a legacy chunk's
1411
+ // segments disagree. The anonymous pass never asserts a different name.
1412
+ if (chunk.speakerPolicy === 'high-confidence' && isName(chunk.speaker)) continue
1363
1413
  chunk.speaker = label
1364
1414
  if (label !== original) chunk.originalSpeaker = original
1365
1415
  else delete chunk.originalSpeaker