@gotcos/glasses-server 6.45.5 → 6.46.1

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.
@@ -49,11 +49,12 @@ import { dataPath } from './data-dir.js'
49
49
  import { extAudioChunkPath, listExtAudioChunks } from './meeting-audio-archive.js'
50
50
  import { EXPECTED_EMBEDDING_DIM, decodeEmbedding, encodeEmbedding, readChunkEmbeddings } from './chunk-embedding-store.js'
51
51
  import {
52
- AUTO_ENROLL_THRESHOLD, enrollEmbedding, extractEmbedding, isEmbeddingAvailable, rawCosineSimilarity, readVoiceProfiles,
52
+ AUTO_ENROLL_THRESHOLD, MAX_EMBEDDINGS_PER_SPEAKER, enrollEmbedding, extractEmbedding, isEmbeddingAvailable, rawCosineSimilarity, readVoiceProfiles,
53
53
  type VoiceProfile,
54
54
  } from './speaker-embeddings.js'
55
- import { vouchesForIdentity } from './embedding-eviction.js'
55
+ import { chooseEviction, vouchesForIdentity } from './embedding-eviction.js'
56
56
  import { getOwnerSpeakerLabel } from './profile.js'
57
+ import { alignedSources, appendEmbedding, dropEmbeddingAt, profileSimilarity } from './voice-profile-store.js'
57
58
  import {
58
59
  MAX_ENROL_PER_CORRECTION,
59
60
  VOICE_COHERENCE_FLOOR,
@@ -112,6 +113,7 @@ export const HELD_GROUP_SOURCE = 'ext-group'
112
113
  export interface HeldSampleRef {
113
114
  sessionId: string
114
115
  chunkIndex: number
116
+ suggestion?: HeldGroupSuggestion | null
115
117
  }
116
118
 
117
119
  export interface HeldSample extends HeldSampleRef {
@@ -131,6 +133,10 @@ export interface HeldGroupSuggestion {
131
133
  of: number
132
134
  /** The provenance of the strongest agreeing sample that may vouch. */
133
135
  anchor: string
136
+ runnerUpSimilarity?: number
137
+ margin?: number
138
+ ownerSimilarity?: number
139
+ ownerCaution?: boolean
134
140
  }
135
141
 
136
142
  export interface HeldVoiceGroup {
@@ -365,6 +371,9 @@ export function foldDuplicates(sim: number[][], count: number): { reps: number[]
365
371
  return { reps: [...copies.keys()], copies }
366
372
  }
367
373
 
374
+ export const HELD_SUGGESTION_MIN_MARGIN = 0.05
375
+ export const HELD_NEAR_PROFILE_SIMILARITY = 0.95
376
+
368
377
  export interface SuggestOptions {
369
378
  /** The wearer's own label. Never offered: one click would write a stranger
370
379
  * into the profile that drives owner detection. */
@@ -386,15 +395,22 @@ export interface SuggestOptions {
386
395
  export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[], opts: SuggestOptions = {}): HeldGroupSuggestion | null {
387
396
  type Candidate = { name: string; score: number; tier: HeldSuggestionTier; agreeing: number; of: number; anchor: string | null }
388
397
  let best: Candidate | null = null
398
+ const candidates: Candidate[] = []
399
+ let ownerScore = 0
389
400
  for (const profile of profiles) {
390
- if (opts.ownerLabel && profile.name === opts.ownerLabel) continue
391
401
  const rows: Array<{ sim: number; source: string }> = []
392
402
  profile.embeddings.forEach((candidate, i) => {
393
- if (candidate.length !== embedding.length) return
403
+ if (candidate.length !== embedding.length || !candidate.every(Number.isFinite)) return
394
404
  rows.push({ sim: rawCosineSimilarity(embedding, new Float32Array(candidate)), source: profile.sources?.[i] ?? 'unknown' })
395
405
  })
396
406
  if (rows.length === 0) continue
397
407
  rows.sort((a, b) => b.sim - a.sim)
408
+ // Caution is proximity evidence, not permission to suggest/enrol the owner.
409
+ // One very close owner sample warrants listening even without two anchors.
410
+ if (opts.ownerLabel && profile.name.normalize('NFKC').toLowerCase() === opts.ownerLabel.normalize('NFKC').toLowerCase()) {
411
+ ownerScore = Math.max(ownerScore, rows[0].sim)
412
+ continue
413
+ }
398
414
  const agreeing = rows.filter(r => r.sim >= HELD_GROUP_SUGGESTION_FLOOR)
399
415
  let score: number
400
416
  let tier: HeldSuggestionTier
@@ -412,10 +428,26 @@ export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[]
412
428
  name: profile.name, score, tier, agreeing: agreeing.length, of: profile.embeddings.length,
413
429
  anchor: anchored ? anchored.source.split(':')[0] : null,
414
430
  }
431
+ candidates.push(candidate)
415
432
  if (!best || candidate.score > best.score) best = candidate
416
433
  }
417
434
  if (!best || best.anchor === null) return null
418
- return { name: best.name, similarity: Number(best.score.toFixed(4)), tier: best.tier, agreeing: best.agreeing, of: best.of, anchor: best.anchor }
435
+ const winner = profiles.find(p => p.name === best!.name)!
436
+ // Near-duplicate profile pairs represent an existing identity ambiguity, not
437
+ // independent competing acoustic evidence. Do not let that pair erase the
438
+ // margin against genuinely different voices. Never merge or rename here.
439
+ const runnerUp = candidates.filter(c => c.name !== best!.name).filter(c => {
440
+ const other = profiles.find(p => p.name === c.name)!
441
+ return profileSimilarity(winner, other) < HELD_NEAR_PROFILE_SIMILARITY
442
+ }).reduce((score, c) => Math.max(score, c.score), 0)
443
+ const margin = best.score - runnerUp
444
+ if (margin < HELD_SUGGESTION_MIN_MARGIN) return null
445
+ return {
446
+ name: best.name, similarity: Number(best.score.toFixed(4)), tier: best.tier,
447
+ agreeing: best.agreeing, of: best.of, anchor: best.anchor,
448
+ runnerUpSimilarity: Number(runnerUp.toFixed(4)), margin: Number(margin.toFixed(4)),
449
+ ownerSimilarity: Number(ownerScore.toFixed(4)), ownerCaution: ownerScore >= 0.65 || ownerScore > best.score,
450
+ }
419
451
  }
420
452
 
421
453
  export interface BuildOptions extends SuggestOptions {
@@ -469,7 +501,12 @@ export function buildHeldVoiceGroups(samples: HeldSample[], profiles: VoiceProfi
469
501
  active = active.filter(i => !taken.has(i))
470
502
  }
471
503
  groups.sort((a, b) => b.sampleCount - a.sampleCount || b.coherence - a.coherence || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
472
- return { groups, loose: refsOf(active) }
504
+ const byKey = new Map(samples.map(s => [sampleKey(s), s]))
505
+ const loose = refsOf(active).map(ref => {
506
+ const suggestion = suggestProfile(byKey.get(sampleKey(ref))!.embedding, profiles, opts)
507
+ return suggestion ? { ...ref, suggestion } : ref
508
+ })
509
+ return { groups, loose }
473
510
  }
474
511
 
475
512
  // ── Listing ────────────────────────────────────────────────────────────────
@@ -495,6 +532,7 @@ export function heldVoiceGroups(opts: { budgetMs?: number; now?: () => number }
495
532
  if (listingMemo && listingMemo.fingerprint === fingerprint && now() - listingMemo.at < HELD_LISTING_MEMO_MS && listingMemo.result.pending === 0) {
496
533
  return listingMemo.result
497
534
  }
535
+ const started = performance.now()
498
536
  const collected = collectHeldSamples({ budgetMs: opts.budgetMs, now })
499
537
  const { groups, loose } = buildHeldVoiceGroups(collected.samples, readVoiceProfiles().profiles, { ownerLabel: getOwnerSpeakerLabel() })
500
538
  if (collected.pending.length > 0) scheduleHeldEmbeddingSweep()
@@ -509,6 +547,11 @@ export function heldVoiceGroups(opts: { budgetMs?: number; now?: () => number }
509
547
  speakerModel: isEmbeddingAvailable(),
510
548
  generatedAt: new Date().toISOString(),
511
549
  }
550
+ console.log(`[held-voice] scored listing: samples=${result.samples} suggestions=${loose.filter(r => r.suggestion).length} ms=${(performance.now() - started).toFixed(1)} floor=${HELD_GROUP_SUGGESTION_FLOOR} support=${HELD_SUGGESTION_MIN_SUPPORT} margin=${HELD_SUGGESTION_MIN_MARGIN}`)
551
+ for (const ref of loose) if (ref.suggestion) {
552
+ const p = ref.suggestion
553
+ console.log(`[held-voice] suggestion: key=${sampleKey(ref)} voice=${JSON.stringify(p.name)} secondBest=${p.similarity} agreeing=${p.agreeing}/${p.of} anchor=${p.anchor} runnerUp=${p.runnerUpSimilarity} margin=${p.margin} ownerScore=${p.ownerSimilarity} ownerCaution=${p.ownerCaution}`)
554
+ }
512
555
  listingMemo = { fingerprint, at: now(), result }
513
556
  return result
514
557
  }
@@ -657,6 +700,10 @@ export interface EnrollHeldGroupPlan {
657
700
 
658
701
  export interface EnrollHeldGroupResult extends EnrollHeldGroupPlan {
659
702
  dryRun: boolean
703
+ coreMembers?: HeldSampleRef[]
704
+ /** Exact selected vectors accepted/refused by the store, not expanded duplicates. */
705
+ enrolledMembers?: HeldSampleRef[]
706
+ rejectedMembers?: HeldSampleRef[]
660
707
  enrolled: number
661
708
  /** Wavs removed: the whole core, selected or not, because all of it is now a known voice. */
662
709
  deleted: number
@@ -676,7 +723,7 @@ export interface EnrollHeldGroupResult extends EnrollHeldGroupPlan {
676
723
  * written. With the speaker model not loaded nothing can be written, so the
677
724
  * request is refused before it touches anything.
678
725
  */
679
- export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dryRun?: boolean } = {}): EnrollHeldGroupResult {
726
+ export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dryRun?: boolean; deleteAudio?: boolean } = {}): EnrollHeldGroupResult {
680
727
  const dryRun = opts.dryRun === true
681
728
  const collected = collectHeldSamples({ only: onlyMap(refs), budgetMs: HELD_ENROLL_DECODE_BUDGET_MS })
682
729
  if (collected.pending.length > 0) scheduleHeldEmbeddingSweep()
@@ -732,21 +779,65 @@ export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dry
732
779
  }
733
780
  // A dry run writes no profile and deletes no wav. It may still decode and
734
781
  // cache vectors for the samples it was asked about, and arm the sweep.
735
- if (dryRun) return { ...plan, dryRun: true, enrolled: 0, deleted: 0 }
782
+ if (dryRun) return { ...plan, coreMembers: core.map(({ sessionId, chunkIndex }) => ({ sessionId, chunkIndex })), dryRun: true, enrolled: 0, deleted: 0 }
736
783
  if (!isEmbeddingAvailable()) {
737
784
  throw new HeldGroupError(503, 'speaker_model_unavailable', 'The speaker model is not loaded on this Mac, so nothing can be enrolled. Nothing was changed.', { ...plan })
738
785
  }
739
786
 
740
787
  let enrolled = 0
788
+ const enrolledMembers: HeldSampleRef[] = [], rejectedMembers: HeldSampleRef[] = []
741
789
  for (const emb of selected) {
742
790
  const sample = bySample.get(emb)
743
791
  const source = sample ? `${HELD_GROUP_SOURCE}:${sample.sessionId}` : HELD_GROUP_SOURCE
744
- if (enrollEmbedding(name, emb, source, true).success) enrolled++
792
+ const accepted = enrollEmbedding(name, emb, source, true).success
793
+ if (accepted) enrolled++
794
+ if (sample) (accepted ? enrolledMembers : rejectedMembers).push({ sessionId: sample.sessionId, chunkIndex: sample.chunkIndex })
745
795
  }
746
796
  if (enrolled === 0) {
747
- throw new HeldGroupError(409, 'nothing_enrolled', `The profile store refused every sample for ${name}; nothing was deleted.`, { ...plan })
797
+ throw new HeldGroupError(409, 'nothing_enrolled', `The profile store refused every sample for ${name}; nothing was deleted.`, { ...plan, enrolledMembers, rejectedMembers })
748
798
  }
749
- const { removed } = discardHeldSamples(core.map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex })))
799
+ const coreMembers = core.map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex }))
800
+ const { removed } = opts.deleteAudio === false ? { removed: [] } : discardHeldSamples(coreMembers)
750
801
  console.log(`[held-voice] enroll "${name}" (${existing ? 'appended' : 'created'}): submitted=${refs.length} resolved=${resolved.length} distinct=${reps.length} coherent=${core.length} selected=${selected.length} enrolled=${enrolled} deleted=${removed.length} leftBehind=${leftBehind.length} notReady=${notReady.length} sessions=[${plan.sessions.join(',')}]`)
751
- return { ...plan, dryRun: false, enrolled, deleted: removed.length, profileEmbeddings: existingCount + enrolled }
802
+ return { ...plan, coreMembers, enrolledMembers, rejectedMembers, dryRun: false, enrolled, deleted: removed.length, profileEmbeddings: existingCount + enrolled }
803
+ }
804
+
805
+
806
+ /** Preview the exact evidence enrollment will retain, without touching the store.
807
+ * Duplicate recordings count once; selection and cap eviction match enrollment.
808
+ * Wider matching must use this projection, never every submitted held vector. */
809
+ export function projectHeldEnrollment(name: string, refs: HeldSampleRef[]): {
810
+ plan: EnrollHeldGroupResult; profiles: VoiceProfile[]; selectedMembers: HeldSampleRef[]
811
+ } {
812
+ const plan = enrollHeldGroup(name, refs, { dryRun: true })
813
+ const coreRefs = plan.coreMembers ?? []
814
+ const collected = collectHeldSamples({ only: onlyMap(coreRefs), budgetMs: HELD_ENROLL_DECODE_BUDGET_MS })
815
+ const byKey = new Map(collected.samples.map(sample => [sampleKey(sample), sample]))
816
+ const core = coreRefs.map(ref => byKey.get(sampleKey(ref)))
817
+ if (core.some(sample => !sample)) throw new HeldGroupError(409, 'samples_changed', 'The held samples changed while preparing the preview. Preview again.')
818
+ const samples = core as HeldSample[]
819
+ const matrix = pairwiseSimilarityMatrix(samples.map(sample => sample.embedding))
820
+ const { reps } = foldDuplicates(matrix, samples.length)
821
+ const representatives = reps.map(i => samples[i])
822
+ const selected = greedyDiversitySelect(representatives.map(sample => sample.embedding), MAX_ENROL_PER_CORRECTION)
823
+ const byEmbedding = new Map(representatives.map(sample => [sample.embedding, sample]))
824
+ // readVoiceProfiles shares embedding arrays with the cache. Clone every row
825
+ // before simulating append/eviction so a preview cannot alter live evidence.
826
+ const profiles = readVoiceProfiles().profiles.map(profile => ({
827
+ ...profile, embeddings: profile.embeddings.map(row => [...row]), sources: [...(profile.sources ?? [])],
828
+ }))
829
+ let target = profiles.find(profile => profile.name === name)
830
+ if (!target) { target = { name, embeddings: [], sources: [] }; profiles.push(target) }
831
+ const selectedMembers: HeldSampleRef[] = []
832
+ for (const embedding of selected) {
833
+ const sample = byEmbedding.get(embedding)!
834
+ const source = `${HELD_GROUP_SOURCE}:${sample.sessionId}`
835
+ if (target.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
836
+ const choice = chooseEviction(alignedSources(target), source, MAX_EMBEDDINGS_PER_SPEAKER)
837
+ dropEmbeddingAt(target, choice?.index ?? 0)
838
+ }
839
+ appendEmbedding(target, Array.from(embedding), source)
840
+ selectedMembers.push({ sessionId: sample.sessionId, chunkIndex: sample.chunkIndex })
841
+ }
842
+ return { plan, profiles, selectedMembers }
752
843
  }
@@ -28,6 +28,7 @@ export function persistBatchDecisionSidecar(
28
28
  if (batchResult.qualityReport) sidecar.batchQualityReport = batchResult.qualityReport
29
29
 
30
30
  if (batchApplied) {
31
+ sidecar.lifecycleRevision = Number(sidecar.lifecycleRevision ?? 0) + 1
31
32
  sidecar.batchTranscript = batchResult.batchTranscript
32
33
  sidecar.batchSegments = batchResult.batchSegments?.map(result => ({
33
34
  startChunkIdx: result.segment.startChunkIdx,
@@ -35,7 +35,7 @@ export const CORRECTIONS_DIR = 'meeting-corrections'
35
35
  * `intent` goes down before any file is touched; `applied` or `failed` closes it.
36
36
  * An unclosed intent is an incomplete correction, not a successful one.
37
37
  */
38
- export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed'
38
+ export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed' | 'confirmed-chunks' | 'reverted'
39
39
  /**
40
40
  * A human confirming the identifier was RIGHT about a label the display floor
41
41
  * demoted.
@@ -54,7 +54,7 @@ export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed'
54
54
  export const CONFIRMATION_PHASE = 'confirmed' as const
55
55
 
56
56
  /** Runtime counterpart of CorrectionPhase. Must stay in step with it. */
57
- const VALID_PHASES = new Set<string>(['intent', 'applied', 'failed', 'confirmed'])
57
+ const VALID_PHASES = new Set<string>(['intent', 'applied', 'failed', 'confirmed', 'confirmed-chunks', 'reverted'])
58
58
 
59
59
  /** Narrowing guard, so the reader keeps its type safety with one phase list. */
60
60
  function isCorrectionPhase(value: unknown): value is CorrectionPhase {
@@ -91,6 +91,9 @@ export interface CorrectionRow {
91
91
  * mistake becomes an unrecoverable one.
92
92
  */
93
93
  scope: 'meeting'
94
+ source?: string
95
+ batchId?: string
96
+ priorLabels?: Record<string, string>
94
97
  surfaces?: CorrectionSurfaces
95
98
  /** True when narrative prose still carries the old label. See the header. */
96
99
  proseStale?: boolean
@@ -160,6 +163,9 @@ export function readCorrections(sessionId: string): CorrectionReadResult {
160
163
  to: o.to,
161
164
  chunks: Array.isArray(o.chunks) ? o.chunks.filter((n): n is number => typeof n === 'number') : [],
162
165
  scope: 'meeting',
166
+ source: typeof o.source === 'string' ? o.source : undefined,
167
+ batchId: typeof o.batchId === 'string' ? o.batchId : undefined,
168
+ priorLabels: o.priorLabels && typeof o.priorLabels === 'object' ? o.priorLabels as Record<string, string> : undefined,
163
169
  surfaces: isSurfaces(o.surfaces) ? o.surfaces : undefined,
164
170
  proseStale: typeof o.proseStale === 'boolean' ? o.proseStale : undefined,
165
171
  error: typeof o.error === 'string' ? o.error : undefined,
@@ -194,8 +200,10 @@ function isSurfaces(v: unknown): v is CorrectionSurfaces {
194
200
  */
195
201
  export function confirmedLabels(sessionId: string): Set<string> {
196
202
  const confirmed = new Set<string>()
197
- for (const row of readCorrections(sessionId).rows) {
198
- if (row.phase === 'confirmed') confirmed.add(row.to)
203
+ const rows = readCorrections(sessionId).rows
204
+ const revoked = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
205
+ for (const row of rows) {
206
+ if (row.phase === 'confirmed' && row.chunks.length === 0 && !revoked.has(row.batchId ?? row.id)) confirmed.add(row.to)
199
207
  }
200
208
  return confirmed
201
209
  }
@@ -208,7 +216,9 @@ export function pendingCorrections(sessionId: string): CorrectionRow[] {
208
216
 
209
217
  /** Only the corrections that actually landed — the ones piece 3 may train on. */
210
218
  export function appliedCorrections(sessionId: string): CorrectionRow[] {
211
- return readCorrections(sessionId).rows.filter(r => r.phase === 'applied')
219
+ const rows = readCorrections(sessionId).rows
220
+ const reverted = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
221
+ return rows.filter(r => r.phase === 'applied' && !reverted.has(r.batchId ?? r.id))
212
222
  }
213
223
 
214
224
  /**
@@ -260,3 +270,31 @@ export function correctionStoreStats(): {
260
270
  } catch { /* report what we have */ }
261
271
  return { sessions, applied, pending, failed }
262
272
  }
273
+
274
+ /** Chunk-scoped vouching never promotes other positions carrying the same label. */
275
+ export function confirmedChunks(sessionId: string): Map<number, string> {
276
+ const rows = readCorrections(sessionId).rows
277
+ const revoked = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
278
+ const out = new Map<number, string>()
279
+ for (const row of rows) {
280
+ if (revoked.has(row.batchId ?? row.id)) continue
281
+ if (row.phase === 'confirmed-chunks' || row.phase === 'confirmed') {
282
+ for (const position of row.chunks) out.set(position, row.to)
283
+ }
284
+ }
285
+ return out
286
+ }
287
+
288
+ /** Boot closeout records interrupted intent explicitly; no automatic retry. */
289
+ export function closeInterruptedCorrections(): number {
290
+ const dir = dataPath(CORRECTIONS_DIR)
291
+ if (!existsSync(dir)) return 0
292
+ let count = 0
293
+ for (const file of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
294
+ const sessionId = file.slice(0, -6)
295
+ for (const row of pendingCorrections(sessionId)) {
296
+ if (appendCorrection(sessionId, { ...row, phase: 'failed', at: new Date().toISOString(), error: 'interrupted naming — review to resume or revert' })) count++
297
+ }
298
+ }
299
+ return count
300
+ }
@@ -0,0 +1,192 @@
1
+ /** Shared, position-scoped correction primitive. Transcript words are immutable. */
2
+ import { attachRawChunkIndices, type ReviewChunk } from './meeting-speaker-review.js'
3
+ import { invalidLabelReason } from './meeting-relabel.js'
4
+
5
+ export type SpeakerPatch = { path: Array<string | number>; before: unknown; after: unknown }
6
+ export type PrefixPatch = { line: number; before: string; after: string }
7
+ export interface LabelEdit {
8
+ doc: Record<string, any>
9
+ markdown: string
10
+ patches: SpeakerPatch[]
11
+ prefixes: PrefixPatch[]
12
+ changed: number[]
13
+ transcript: number
14
+ attendees: number
15
+ unresolvedTurns: number
16
+ }
17
+ const prefix = /^(\[([^\]\r\n]+)\]:|\*\*([^*\r\n]+)\*\*(?:[ \t]+_\[[^\]\r\n]*\]_)?:?)([ \t]*)/
18
+ const normalized = (value: string) => value.replace(/\s+/gu, ' ').trim()
19
+ export function strippedTranscript(markdown: string): string {
20
+ const section = /^## Transcript[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(markdown)
21
+ return (section?.[1] ?? '').split('\n').map(line => line.replace(prefix, '')).join('\n')
22
+ }
23
+ function setPatch(doc: Record<string, any>, path: Array<string | number>, value: unknown, patches: SpeakerPatch[]) {
24
+ let obj: any = doc
25
+ for (const key of path.slice(0, -1)) obj = obj[key]
26
+ const key = path[path.length - 1]
27
+ if (obj[key] === value) return
28
+ patches.push({ path, before: obj[key], after: value })
29
+ obj[key] = value
30
+ }
31
+ export function patchValue(doc: Record<string, any>, patch: SpeakerPatch, reverse = false): void {
32
+ let obj: any = doc
33
+ for (const key of patch.path.slice(0, -1)) {
34
+ if (obj?.[key] == null) throw new Error('Transcript shape changed; review before undo')
35
+ obj = obj[key]
36
+ }
37
+ const key = patch.path[patch.path.length - 1]
38
+ if (reverse && JSON.stringify(obj[key]) === JSON.stringify(patch.before)) return
39
+ if (JSON.stringify(obj[key]) !== JSON.stringify(reverse ? patch.after : patch.before)) throw new Error('A later correction touched this position')
40
+ const value = reverse ? patch.before : patch.after
41
+ if (value === undefined) delete obj[key]
42
+ else obj[key] = value
43
+ }
44
+
45
+ /** Complete compacted-to-raw mapping; no trusting chunkIndex on compacted rows. */
46
+ export function validatedRawMap(doc: Record<string, any>): number[] {
47
+ if (!Array.isArray(doc.chunks)) throw new Error('Sidecar has no chunks')
48
+ const mapped = attachRawChunkIndices(doc.chunks as ReviewChunk[], doc.chunkEntries)
49
+ if (mapped === doc.chunks) throw new Error('Chunk counts disagree or no raw index map')
50
+ const raw = mapped.map(c => c.chunkIndex)
51
+ if (raw.some(i => !Number.isInteger(i) || Number(i) < 0) || new Set(raw).size !== raw.length) throw new Error('Invalid or ambiguous raw index map')
52
+ // Counts alone are insufficient: a damaged entry ordering can have the right length.
53
+ const entries = doc.chunkEntries.filter((e: any) => e?.chunk?.text)
54
+ if (entries.some((e: any, i: number) => e.chunk.text !== doc.chunks[i].text)) throw new Error('Chunk text and raw index map disagree')
55
+ return raw as number[]
56
+ }
57
+
58
+ export function relabelSpeakerPositions(input: Record<string, any>, markdown: string, positions: number[], name: string, batchId?: string): LabelEdit {
59
+ if (invalidLabelReason(name)) throw new Error('Invalid speaker label')
60
+ const raw = validatedRawMap(input)
61
+ const wanted = new Set(positions)
62
+ if (positions.some(i => !Number.isInteger(i) || !input.chunks[i])) throw new Error('Unknown transcript position')
63
+ const doc = structuredClone(input)
64
+ const patches: SpeakerPatch[] = []
65
+ const changed = [...wanted].sort((a,b) => a-b)
66
+ for (const position of changed) {
67
+ setPatch(doc, ['chunks', position, 'speaker'], name, patches)
68
+ if (batchId) setPatch(doc, ['chunks', position, 'speakerCorrectionBatchId'], batchId, patches)
69
+ const entry = doc.chunkEntries.findIndex((e: any) => e.chunkIndex === raw[position])
70
+ setPatch(doc, ['chunkEntries', entry, 'chunk', 'speaker'], name, patches)
71
+ if (batchId) setPatch(doc, ['chunkEntries', entry, 'chunk', 'speakerCorrectionBatchId'], batchId, patches)
72
+ }
73
+ // Match capture HQ's exact nearest-chunk rule, raw segment bounds, 3.5s
74
+ // maximum. Never use compacted positions as raw capture indices (6.27.10).
75
+ for (const [si, segment] of (doc.batchSegments ?? []).entries()) {
76
+ for (const [wi, word] of (segment.speakerWords ?? []).entries()) {
77
+ if (!Number.isFinite(word.start)) continue
78
+ const absolute = Number(segment.startElapsed ?? 0) + word.start * 1000
79
+ let nearest = -1, distance = Infinity
80
+ for (let i = 0; i < raw.length; i++) {
81
+ if (raw[i] < segment.startChunkIdx || raw[i] > segment.endChunkIdx) continue
82
+ const d = Math.abs(Number(input.chunks[i].elapsed) - absolute)
83
+ if (d < distance) { distance = d; nearest = i }
84
+ }
85
+ if (distance <= 3500 && wanted.has(nearest)) {
86
+ setPatch(doc, ['batchSegments', si, 'speakerWords', wi, 'speaker'], name, patches)
87
+ if (batchId) setPatch(doc, ['batchSegments', si, 'speakerWords', wi, 'speakerCorrectionBatchId'], batchId, patches)
88
+ }
89
+ // similarity remains capture-time evidence; it is never refreshed here.
90
+ }
91
+ }
92
+ const speakers = [...new Set(doc.chunks.map((c: any) => c.speaker).filter((s: any) => typeof s === 'string' && s))]
93
+ doc.speakers = speakers
94
+ doc.correctionRevision = Number(input.correctionRevision ?? 0) + 1
95
+ doc.labelsNewerThanGraph = true
96
+
97
+ const lines = markdown.split('\n')
98
+ const prefixes: PrefixPatch[] = []
99
+ let section = '', transcript = 0, unresolvedTurns = 0, attendees = 0
100
+ const chunks: string[] = input.chunks.map((c: any) => normalized(String(c.text ?? '')))
101
+ for (let i = 0; i < lines.length; i++) {
102
+ if (/^## /.test(lines[i])) section = lines[i].slice(3).trim()
103
+ if (section !== 'Transcript') continue
104
+ const match = prefix.exec(lines[i])
105
+ if (!match) continue
106
+ let end = i + 1
107
+ while (end < lines.length && !prefix.test(lines[end]) && !/^## /.test(lines[end])) end++
108
+ const text = normalized([lines[i].slice(match[0].length), ...lines.slice(i+1,end)].join('\n').replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,''))
109
+ const runs: number[][] = []
110
+ for (let a = 0; a < chunks.length; a++) {
111
+ let joined = ''
112
+ for (let b = a; b < chunks.length; b++) {
113
+ joined = normalized(joined + ' ' + chunks[b])
114
+ if (joined === text) runs.push(Array.from({length:b-a+1},(_,k)=>a+k))
115
+ if (joined.length >= text.length) break
116
+ }
117
+ }
118
+ if (runs.length === 1 && runs[0].every(j => wanted.has(j))) {
119
+ const next = match[2] !== undefined ? `[${name}]:${match[4]}` : match[0].replace(`**${match[3]}**`, `**${name}**`)
120
+ if (next !== match[0]) { prefixes.push({line:i,before:match[0],after:next}); lines[i] = next + lines[i].slice(match[0].length); transcript++ }
121
+ } else if (runs.length !== 1 || runs[0].some(j => wanted.has(j))) unresolvedTurns++
122
+ }
123
+ // Attendees preserve every existing person and never add an unidentified label.
124
+ const att = lines.findIndex(line => /^## Attendees\s*$/.test(line))
125
+ if (att >= 0 && !/^(ext|unknown|unidentified|speaker\s*\d+)/i.test(name)) {
126
+ let end = att+1
127
+ while (end < lines.length && !/^## /.test(lines[end])) end++
128
+ if (!lines.slice(att+1,end).some(line => line.trim() === `- ${name}`)) {
129
+ const first = doc.chunks.findIndex((c: any) => c.speaker === name)
130
+ let insertion = end
131
+ for (let line = att + 1; line < end; line++) {
132
+ const listed = /^- (.+)$/.exec(lines[line])?.[1]
133
+ const appeared = listed ? doc.chunks.findIndex((c: any) => c.speaker === listed) : -1
134
+ if (appeared > first) { insertion = line; break }
135
+ }
136
+ lines.splice(insertion,0,`- ${name}`,'')
137
+ for (const p of prefixes) if (p.line >= insertion) p.line += 2
138
+ attendees = 1
139
+ }
140
+ }
141
+ if (att < 0 && !/^(ext|unknown|unidentified|speaker\s*\d+)/i.test(name) && changed.length > 0) {
142
+ const transcriptStart = lines.findIndex(line => /^## Transcript\s*$/.test(line))
143
+ if (transcriptStart >= 0) { lines.splice(transcriptStart,0,'## Attendees','',`- ${name}`,''); attendees = 1 }
144
+ }
145
+ let nextMarkdown = lines.join('\n')
146
+ if (!nextMarkdown.includes('<!-- speaker-labels-newer-than-graph -->')) nextMarkdown += '\n<!-- speaker-labels-newer-than-graph -->\n'
147
+ // Marker is metadata, outside the stripped-text comparison.
148
+ const clean = (s: string) => s.replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
149
+ if (strippedTranscript(clean(markdown)) !== strippedTranscript(clean(nextMarkdown))) throw new Error('Transcript word preservation assertion failed')
150
+ return {doc,markdown:nextMarkdown,patches,prefixes,changed,transcript,attendees,unresolvedTurns}
151
+ }
152
+
153
+ /** Restore only this batch's label prefixes, using immutable turn text as the
154
+ * join key. Attendee insertions and disjoint later corrections do not shift it. */
155
+ export function undoSpeakerMarkdown(before:string,after:string,current:string,currentSpeakers:string[]):string {
156
+ const turns=(md:string)=>{
157
+ const lines=md.split('\n'),result:Array<{line:number;label:string;text:string}>=[]
158
+ let section=''
159
+ for(let i=0;i<lines.length;i++){
160
+ if(/^## /.test(lines[i]))section=lines[i].slice(3).trim()
161
+ if(section!=='Transcript')continue
162
+ const match=prefix.exec(lines[i]);if(!match)continue
163
+ let end=i+1
164
+ while(end<lines.length&&!prefix.test(lines[end])&&!/^## /.test(lines[end]))end++
165
+ const content=[lines[i].slice(match[0].length),...lines.slice(i+1,end)].join('\n').replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
166
+ result.push({line:i,label:match[0],text:normalized(content)})
167
+ }
168
+ return result
169
+ }
170
+ const beforeTurns=turns(before),afterTurns=turns(after),currentTurns=turns(current)
171
+ if(beforeTurns.length!==afterTurns.length)throw new Error('Receipt transcript shape changed')
172
+ const lines=current.split('\n')
173
+ for(let i=0;i<beforeTurns.length;i++){
174
+ const a=afterTurns[i],b=beforeTurns[i]
175
+ if(a.label===b.label)continue
176
+ if(a.text!==b.text)throw new Error('Receipt transcript words changed')
177
+ const matches=currentTurns.filter(t=>t.text===a.text)
178
+ if(matches.length!==1)throw new Error('Transcript turn is missing or ambiguous; review before undo')
179
+ const match=matches[0]
180
+ if(match.label===b.label)continue // a prior interrupted Undo already restored it
181
+ if(match.label!==a.label)throw new Error('Later naming touched transcript prefix')
182
+ lines[match.line]=b.label+lines[match.line].slice(a.label.length)
183
+ }
184
+ const attendees=(md:string)=>{
185
+ const match=/^## Attendees\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(md)
186
+ return new Set((match?.[1]??'').split('\n').flatMap(line=>/^- (.+)$/.exec(line)?.[1]?[line.slice(2)]:[]))
187
+ }
188
+ const priorAttendees=attendees(before),added=[...attendees(after)].filter(name=>!priorAttendees.has(name)&&!currentSpeakers.includes(name))
189
+ let section=''
190
+ const out=lines.filter(line=>{if(/^## /.test(line))section=line.slice(3).trim();return !(section==='Attendees'&&added.some(name=>line===`- ${name}`))}).join('\n')
191
+ return out.includes('<!-- speaker-labels-newer-than-graph -->')?out:out+'\n<!-- speaker-labels-newer-than-graph -->\n'
192
+ }
@@ -133,6 +133,9 @@ export interface Phrase {
133
133
  export interface VoiceReview {
134
134
  label: string
135
135
  segments: number
136
+ /** Exact positions a human vouched for. A partial count does not waive the
137
+ * display floor for other positions carrying the same label. */
138
+ confirmedSegments: number
136
139
  /** Voiced milliseconds credited to this voice. See `SpeakingTimeSource` for
137
140
  * how it was measured — the two methods are not comparable. */
138
141
  speakingMs: number
@@ -242,15 +245,15 @@ export interface MeetingSpeakerReview {
242
245
  /** False when no chunk carries a real speaker — a recovered capture. */
243
246
  attributed: boolean
244
247
  /**
245
- * Segments belonging to voices this review asserts a NAME for.
248
+ * Segments belonging to asserted voices, plus individually confirmed
249
+ * positions in a voice whose remaining segments are still candidates.
246
250
  *
247
251
  * `attributed` is a boolean that only goes false at 100% unidentified, so a
248
252
  * meeting where 295 of 299 chunks matched nobody still reports `true` and
249
253
  * renders as though it were normally attributed. This is the graded version:
250
254
  * measured Ext share across 14 retained sessions ran from 24% to 100%.
251
255
  *
252
- * Counts SEGMENTS OF ASSERTED VOICES, not chunks carrying a person-shaped
253
- * label — see the derivation for why those differ. Denominator is `segments`
256
+ * A name-shaped label alone earns no credit. Denominator is `segments`
254
257
  * (every chunk, including unlabelled ones), so `assertedSegments / segments`
255
258
  * is the ratio every client should compute.
256
259
  *
@@ -581,6 +584,7 @@ export function reviewMeetingSpeakers(
581
584
  * the sidecar already carries it.
582
585
  */
583
586
  confirmed?: Set<string>
587
+ confirmedChunks?: Map<number, string>
584
588
  /**
585
589
  * The sidecar's `batchSegments`, when the HQ pass has run. Their word
586
590
  * timings give real voiced time; without them speaking time falls back to
@@ -590,7 +594,11 @@ export function reviewMeetingSpeakers(
590
594
  } = {},
591
595
  ): MeetingSpeakerReview {
592
596
  const owner = options.owner ?? 'Me'
593
- const confirmed = options.confirmed ?? new Set<string>()
597
+ const confirmed = new Set(options.confirmed ?? [])
598
+ for (const label of new Set(chunks.map(c => c.speaker ?? ''))) {
599
+ const positions = chunks.flatMap((c, i) => c.speaker === label ? [i] : [])
600
+ if (positions.length > 0 && positions.every(i => options.confirmedChunks?.get(i) === label)) confirmed.add(label)
601
+ }
594
602
  const limit = options.phrasesPerVoice ?? 3
595
603
  const sequence = chunks.map(c => c.speaker ?? '')
596
604
  // The caller's durationMs (the sidecar's own) is the meeting's true end.
@@ -637,6 +645,8 @@ export function reviewMeetingSpeakers(
637
645
  const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
638
646
  const runs = speakerRuns(sequence, label)
639
647
  const unattributed = isUnattributed(label)
648
+ const confirmedSegments = unattributed ? 0 : confirmed.has(label) ? own.length
649
+ : chunks.reduce((n, c, i) => n + Number(c.speaker === label && options.confirmedChunks?.get(i) === label), 0)
640
650
 
641
651
  const thrashesWith: ThrashPair[] = []
642
652
  if (!unattributed) {
@@ -698,6 +708,7 @@ export function reviewMeetingSpeakers(
698
708
  return {
699
709
  label,
700
710
  segments: own.length,
711
+ confirmedSegments,
701
712
  speakingMs: speakingFor(label),
702
713
  meanSimilarity: meanSim,
703
714
  meanRun: Math.round(mean(runs) * 100) / 100,
@@ -724,7 +735,9 @@ export function reviewMeetingSpeakers(
724
735
  // labels would claim three quarters of the meeting was identified above a
725
736
  // list of rows reading "Unidentified voice", which is the confusion this
726
737
  // number exists to remove.
727
- assertedSegments: voices.reduce((n, v) => (v.nameAsserted ? n + v.segments : n), 0),
738
+ // A scoped vouch adds only its exact positions, without promoting the
739
+ // candidate row or double-counting an already asserted voice.
740
+ assertedSegments: voices.reduce((n, v) => n + (v.nameAsserted ? v.segments : v.confirmedSegments), 0),
728
741
  speakingTimeSource,
729
742
  // UNION, not sum. Speakers overlap — crosstalk means two people are each
730
743
  // correctly credited for the same wall-clock second, so per-speaker times
@@ -0,0 +1,26 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { resolveCosOperationsDir } from './cos-operations-meetings.js'
5
+
6
+ /** Hold the SAME kernel flock as sync_meetings.py. Child stdin lifetime owns it. */
7
+ export async function acquireMeetingSyncLock(): Promise<{mode:'sync'|'standalone';release:()=>void}> {
8
+ const operations = resolveCosOperationsDir()
9
+ if (!operations) return {mode:'standalone',release:()=>{}}
10
+ const scripts = process.env.COS_SCRIPTS_DIR?.trim() || join(operations,'scripts')
11
+ const python = join(scripts,'cos_python')
12
+ if (!existsSync(python)) throw new Error('sync lock holder unavailable: cos_python missing')
13
+ const code = `import fcntl,sys,time\nf=open(sys.argv[1], 'a+')\nfor i in range(4):\n try:\n fcntl.flock(f, fcntl.LOCK_EX|fcntl.LOCK_NB)\n print('LOCKED',flush=True)\n sys.stdin.read()\n sys.exit(0)\n except BlockingIOError:\n time.sleep(0.15*(i+1))\nsys.exit(73)\n`
14
+ const child = spawn(python,['-c',code,join(scripts,'.sync_meetings.lock')],{stdio:['pipe','pipe','pipe']})
15
+ return await new Promise((resolve,reject) => {
16
+ let settled = false, output = '', error = ''
17
+ const timer = setTimeout(()=>{if(!settled){settled=true;child.kill();reject(new Error('sync running, retry'))}},5000)
18
+ child.stderr.on('data',d=>{error+=String(d)})
19
+ child.stdout.on('data',d=>{
20
+ output += String(d)
21
+ if (!settled && output.includes('LOCKED')) {settled=true;clearTimeout(timer);resolve({mode:'sync',release:()=>child.stdin.end()})}
22
+ })
23
+ child.on('error',err=>{if(!settled){settled=true;clearTimeout(timer);reject(err)}})
24
+ child.on('exit',()=>{if(!settled){settled=true;clearTimeout(timer);reject(new Error(error.trim() || 'sync running, retry'))}})
25
+ })
26
+ }
@@ -101,7 +101,7 @@ const AUTO_ENROLL_CONSENSUS = 2 // Must match N times in same session befo
101
101
  // at 20, 40 AND 80 samples per speaker (77 speakers, sherpa SpeakerEmbeddingManager),
102
102
  // so the old cap defended nothing — while 61 of 77 profiles sat AT it, meaning
103
103
  // every correction cost a sample. 20 extra slots per speaker is ~1.2 MB.
104
- const MAX_EMBEDDINGS_PER_SPEAKER = 40
104
+ export const MAX_EMBEDDINGS_PER_SPEAKER = 40
105
105
  const SAMPLE_RATE = 16000
106
106
 
107
107
  // Module-level state — sherpa-onnx-node is CJS with no TS types (SDK v0.0.7 interop)