@gotcos/glasses-server 6.95.0 → 6.98.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +763 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +572 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,268 @@
1
+ /** Paired G2/Mac diagnostics only. No remap, identity proof, meeting write or training. */
2
+ import { checkSpeakerName } from './speaker-name.js'
3
+ import { SHADOW_GATES, SHADOW_SAMPLE_RATE, isShadowVoiceLabel, type ShadowAccepted, type ShadowSegment, type ShadowTurn, type ShadowVoiceEntry } from './speaker-shadow-decide.js'
4
+
5
+ export const PAIRED_SHADOW_GATES = Object.freeze({ nameAllSpeechFraction: 0.8, nameSupportSeconds: 30, shortMinSeconds: 5, shortMaxSeconds: 15, shortAnchorRatio: 1.1 })
6
+ export type ShadowPairAlignment =
7
+ | { status: 'qualified'; evidenceId: string; offsetMs: number; maxErrorMs: number }
8
+ | { status: 'unpaired' | 'ambiguous' | 'time_only' }
9
+ export interface ShadowG2Chunk { speaker: string; elapsed: number; audioDurationMs?: number; durationMs?: number }
10
+ export interface ShadowG2Recording { startTime: string | number; chunks: readonly ShadowG2Chunk[] }
11
+ /** Raw anchorRatio is required for short nominations. Rounded compatibility values cannot qualify them. */
12
+ export type PairedShadowAcousticEvidence = ShadowVoiceEntry & { anchorRatio?: number | null }
13
+ export interface PairedShadowInput {
14
+ macStartTime: string | number
15
+ totalSamples: number
16
+ segments: readonly ShadowSegment[]
17
+ regularTurns: readonly ShadowTurn[]
18
+ /** Final accepted groups only; rejected/co-located voices must not be added here. */
19
+ acceptedGroups: readonly ShadowAccepted[]
20
+ /** Existing acoustic rows, including rejected short fragments. Never recomputed from names. */
21
+ voiceEvidence: readonly PairedShadowAcousticEvidence[]
22
+ g2?: ShadowG2Recording
23
+ alignment: ShadowPairAlignment
24
+ }
25
+ export interface PairedNameSupport { name: string; seconds: number; fractionOfAllSpeech: number; fractionOfKnownName: number }
26
+ export interface PairedShadowAgreement {
27
+ allSpeechSeconds: number
28
+ knownNameSeconds: number
29
+ unknownOrUnpairedSeconds: number
30
+ conflictingG2Seconds: number
31
+ productionConflictSeconds: number
32
+ nativeUncoveredSeconds: number
33
+ nativeConflictSeconds: number
34
+ wrongClusterSeconds: number
35
+ names: PairedNameSupport[]
36
+ diagnosticTopName: string | null
37
+ diagnosticAllSpeechGate: boolean
38
+ }
39
+ export interface PairedShadowGroup extends PairedShadowAgreement {
40
+ agreementScope: 'accepted_voice_speech'
41
+ wholeCluster: PairedShadowAgreement & { scope: 'whole_native_cluster_speech' }
42
+ cluster: string
43
+ voices: string[]
44
+ candidateName: string | null
45
+ reasons: string[]
46
+ }
47
+ export interface PairedShadowVoice extends PairedShadowAgreement { voice: string; cluster: string | null }
48
+ export interface PairedShadowSpan { startSample: number; endSample: number; cluster: string; candidateName: string }
49
+ export interface PairedShadowShortCandidate { voice: string; cluster: string; candidateName: string; seconds: number }
50
+ export interface PairedShadowReport {
51
+ mode: 'provisional_shadow_only'
52
+ alignmentStatus: ShadowPairAlignment['status']
53
+ candidateNominationAllowed: boolean
54
+ accuracyClaim: false
55
+ humanTruthClaim: false
56
+ warnings: string[]
57
+ groups: PairedShadowGroup[]
58
+ voices: PairedShadowVoice[]
59
+ extCandidates: PairedShadowSpan[]
60
+ shortCandidates: PairedShadowShortCandidate[]
61
+ shortWithheld: Array<{ voice: string; reasons: string[] }>
62
+ }
63
+
64
+ /** A plausible saved G2 name is still a machine label, never human ground truth. */
65
+ export function isPairedShadowName(label: string): boolean {
66
+ const name = label.trim().replace(/\s+/gu, ' ')
67
+ if (/^(?:ext|unknown|mu|me|speaker(?:[\s_-]*(?:\d+|n))?|voice(?:[\s_-]*(?:\d+|n))?)$/iu.test(name)) return false
68
+ return checkSpeakerName(name).ok
69
+ }
70
+
71
+ type Row = readonly [number, number, string]
72
+ type Counts = Map<string, number>
73
+ interface Stats { all: number; known: number; unknown: number; conflict: number; production: number; uncovered: number; nativeConflict: number; wrong: number; names: Counts }
74
+ const stats = (): Stats => ({ all: 0, known: 0, unknown: 0, conflict: 0, production: 0, uncovered: 0, nativeConflict: 0, wrong: 0, names: new Map() })
75
+ const add = (map: Counts, key: string, amount: number) => { const n = (map.get(key) ?? 0) + amount; if (n) map.set(key, n); else map.delete(key) }
76
+ const epoch = (value: string | number): number => typeof value === 'number' ? value : Date.parse(value)
77
+ const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
78
+ const SR = SHADOW_SAMPLE_RATE
79
+
80
+ function accumulate(s: Stats, amount: number, production: Counts, native: Counts, g2: Counts, cluster: string | null, allowUnlabelledProduction = false): void {
81
+ s.all += amount
82
+ const name = g2.size === 1 ? [...g2.keys()][0] : null
83
+ if (g2.size > 1) s.conflict += amount
84
+ else if (name && isPairedShadowName(name)) s.known += amount
85
+ else s.unknown += amount
86
+ if (production.size > 1) s.production += amount
87
+ if (!native.size) s.uncovered += amount
88
+ else if (native.size > 1) s.nativeConflict += amount
89
+ else if (cluster !== null && !native.has(cluster)) s.wrong += amount
90
+ if ((production.size === 1 || (allowUnlabelledProduction && production.size === 0)) && native.size === 1 && cluster !== null && native.has(cluster) && name && g2.size === 1 && isPairedShadowName(name)) add(s.names, name, amount)
91
+ }
92
+
93
+ function agreement(s: Stats): PairedShadowAgreement {
94
+ const names = [...s.names].map(([name, n]) => ({ name, seconds: n / SR, fractionOfAllSpeech: s.all ? n / s.all : 0, fractionOfKnownName: s.known ? n / s.known : 0 }))
95
+ .sort((a, b) => b.seconds - a.seconds || a.name.localeCompare(b.name))
96
+ const best = names[0]
97
+ const unique = !!best && (!names[1] || names[1].seconds !== best.seconds)
98
+ return { allSpeechSeconds: s.all / SR, knownNameSeconds: s.known / SR, unknownOrUnpairedSeconds: s.unknown / SR, conflictingG2Seconds: s.conflict / SR,
99
+ productionConflictSeconds: s.production / SR, nativeUncoveredSeconds: s.uncovered / SR, nativeConflictSeconds: s.nativeConflict / SR, wrongClusterSeconds: s.wrong / SR,
100
+ names, diagnosticTopName: unique ? best.name : null,
101
+ diagnosticAllSpeechGate: unique && best.fractionOfAllSpeech >= PAIRED_SHADOW_GATES.nameAllSpeechFraction && best.seconds >= PAIRED_SHADOW_GATES.nameSupportSeconds }
102
+ }
103
+
104
+ /**
105
+ * Sweep half-open sample intervals once. Duplicate same-label rows union; different
106
+ * simultaneous labels withhold votes. Every group/voice denominator includes its
107
+ * unknown, uncovered and conflicting time, so named-only percentages cannot qualify it.
108
+ * offsetMs adds to the G2 wall clock. Only caller-qualified alignment enables nominations;
109
+ * timestamp coincidence alone is diagnostic. maxErrorMs erodes G2 vote intervals.
110
+ */
111
+ export function evaluatePairedSpeakerShadow(input: PairedShadowInput): PairedShadowReport {
112
+ if (!Number.isSafeInteger(input.totalSamples) || input.totalSamples <= 0 || input.totalSamples > 4 * 3600 * SR) throw new Error('invalid paired shadow timeline')
113
+ if (input.segments.length + input.regularTurns.length + (input.g2?.chunks.length ?? 0) > 100_000) throw new Error('paired shadow interval budget exceeded')
114
+ const warnings = new Set<string>()
115
+ let safe = true
116
+ const macStart = epoch(input.macStartTime)
117
+ if (!finite(macStart)) { warnings.add('invalid Mac startTime'); safe = false }
118
+ const align = input.alignment
119
+ let qualified = align.status === 'qualified' && !!align.evidenceId.trim() && finite(align.offsetMs) && finite(align.maxErrorMs) && align.maxErrorMs >= 0
120
+ if (align.status === 'qualified' && !qualified) { warnings.add('invalid qualified alignment evidence'); safe = false }
121
+ if (align.status !== 'qualified') warnings.add(`${align.status}: time alignment is uncertain; diagnostic only`)
122
+ // Time-only still reports wall-clock overlap; it cannot transfer names.
123
+ const offset = qualified && align.status === 'qualified' ? align.offsetMs : 0
124
+ const erosion = qualified && align.status === 'qualified' ? Math.ceil(align.maxErrorMs * SR / 1000) : 0
125
+ const production: Row[] = [], native: Row[] = [], paired: Row[] = []
126
+ const clip = (rows: Row[], lo: number, hi: number, label: string, source: string) => {
127
+ if (!Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || hi <= lo || typeof label !== 'string' || !label.trim()) { warnings.add(`invalid ${source} interval`); safe = false; return }
128
+ const a = Math.max(0, lo), b = Math.min(input.totalSamples, hi)
129
+ if (a < b) rows.push([a, b, label.trim().replace(/\s+/gu, ' ')])
130
+ }
131
+ for (const [lo, hi, label] of input.segments) clip(production, lo, hi, label, 'production')
132
+ for (const turn of input.regularTurns) clip(native, Math.round(turn.start * SR), Math.round(turn.end * SR), turn.speaker, 'native')
133
+ if (!input.g2) { warnings.add('no paired G2 recording'); qualified = false }
134
+ else {
135
+ const g2Start = epoch(input.g2.startTime)
136
+ if (!finite(g2Start)) { warnings.add('invalid G2 startTime'); safe = false }
137
+ for (const chunk of input.g2.chunks) {
138
+ const duration = chunk.audioDurationMs ?? chunk.durationMs
139
+ if (!finite(chunk.elapsed) || chunk.elapsed < 0 || !finite(duration) || duration <= 0 || (chunk.audioDurationMs !== undefined && chunk.durationMs !== undefined && chunk.audioDurationMs !== chunk.durationMs)) {
140
+ warnings.add('invalid or conflicting G2 elapsed/duration'); safe = false; continue
141
+ }
142
+ const lo = Math.round((g2Start - macStart + offset + chunk.elapsed) * SR / 1000) + erosion
143
+ const hi = Math.round((g2Start - macStart + offset + chunk.elapsed + duration) * SR / 1000) - erosion
144
+ // Entire interval inside alignment uncertainty contributes no name support.
145
+ if (hi <= lo && finite(lo) && finite(hi)) continue
146
+ clip(paired, lo, hi, chunk.speaker, 'G2')
147
+ }
148
+ }
149
+ const groups = new Map<string, { voices: string[]; stats: Stats; wholeStats: Stats }>()
150
+ const groupOfVoice = new Map<string, string>()
151
+ for (const group of input.acceptedGroups) {
152
+ const voices = [group.keepLabel, ...group.merge]
153
+ if (!group.cluster || groups.has(group.cluster) || new Set(voices).size !== voices.length || voices.some(v => !isShadowVoiceLabel(v) || groupOfVoice.has(v))) throw new Error('ambiguous accepted paired group')
154
+ groups.set(group.cluster, { voices, stats: stats(), wholeStats: stats() })
155
+ for (const voice of voices) groupOfVoice.set(voice, group.cluster)
156
+ }
157
+ const evidence = new Map<string, PairedShadowAcousticEvidence>()
158
+ for (const row of input.voiceEvidence) {
159
+ if (evidence.has(row.voice)) throw new Error('duplicate paired voice evidence')
160
+ evidence.set(row.voice, row)
161
+ }
162
+ const byVoice = new Map<string, Stats>()
163
+ for (const [, , label] of production) if (isShadowVoiceLabel(label) && !byVoice.has(label)) byVoice.set(label, stats())
164
+ type Event = { at: number; kind: number; label: string; delta: number }
165
+ const events: Event[] = []
166
+ for (const [kind, rows] of [production, native, paired].entries()) for (const [lo, hi, label] of rows) {
167
+ events.push({ at: lo, kind, label, delta: 1 }, { at: hi, kind, label, delta: -1 })
168
+ }
169
+ events.sort((a, b) => a.at - b.at)
170
+ const active = [new Map<string, number>(), new Map<string, number>(), new Map<string, number>()]
171
+ const ext: Array<{ lo: number; hi: number; cluster: string; name: string }> = []
172
+ for (let i = 0; i < events.length;) {
173
+ const at = events[i].at
174
+ while (i < events.length && events[i].at === at) { const event = events[i++]; add(active[event.kind], event.label, event.delta) }
175
+ const end = events[i]?.at
176
+ if (end === undefined || end <= at) continue
177
+ const touched = new Set<string>()
178
+ for (const voice of active[0].keys()) {
179
+ const s = byVoice.get(voice)
180
+ if (s) accumulate(s, end - at, active[0], active[1], active[2], evidence.get(voice)?.topCluster ?? groupOfVoice.get(voice) ?? null)
181
+ const group = groupOfVoice.get(voice)
182
+ if (group) touched.add(group)
183
+ }
184
+ for (const cluster of touched) accumulate(groups.get(cluster)!.stats, end - at, active[0], active[1], active[2], cluster)
185
+ // Full native-cluster union includes Ext, rejected/named labels and no-ASR time.
186
+ // Concurrent different clusters/production labels remain in the denominator but cannot vote.
187
+ for (const cluster of active[1].keys()) {
188
+ const group = groups.get(cluster)
189
+ if (group) accumulate(group.wholeStats, end - at, active[0], active[1], active[2], cluster, true)
190
+ }
191
+ if (active[0].size === 1 && active[0].has('Ext') && active[1].size === 1 && active[2].size === 1) {
192
+ const cluster = [...active[1].keys()][0], name = [...active[2].keys()][0]
193
+ if (groups.has(cluster) && isPairedShadowName(name)) ext.push({ lo: at, hi: end, cluster, name })
194
+ }
195
+ }
196
+ const allow = qualified && safe
197
+ const groupRows: PairedShadowGroup[] = [...groups].map(([cluster, group]) => {
198
+ const result = agreement(group.stats), wholeCluster = { ...agreement(group.wholeStats), scope: 'whole_native_cluster_speech' as const }, reasons: string[] = []
199
+ if (!allow) reasons.push('paired alignment is not qualified')
200
+ if (!result.diagnosticAllSpeechGate) reasons.push('name requires 80% of ALL accepted-voice speech and at least 30s support')
201
+ if (!wholeCluster.diagnosticAllSpeechGate) reasons.push('name requires 80% of ALL native-cluster speech and at least 30s support')
202
+ if (result.diagnosticTopName !== wholeCluster.diagnosticTopName) reasons.push('accepted voices and whole native cluster disagree on G2 name')
203
+ const both = result.diagnosticAllSpeechGate && wholeCluster.diagnosticAllSpeechGate && result.diagnosticTopName === wholeCluster.diagnosticTopName
204
+ return { ...result, agreementScope: 'accepted_voice_speech', wholeCluster, cluster, voices: group.voices, candidateName: allow && both ? result.diagnosticTopName : null, reasons }
205
+ })
206
+ const named = new Map(groupRows.filter(g => g.candidateName).map(g => [g.cluster, g.candidateName!]))
207
+ const voices = [...byVoice].map(([voice, s]) => ({ ...agreement(s), voice, cluster: evidence.get(voice)?.topCluster ?? groupOfVoice.get(voice) ?? null }))
208
+ const extCandidates: PairedShadowSpan[] = []
209
+ if (allow) for (const span of ext) if (named.get(span.cluster) === span.name) {
210
+ const previous = extCandidates.at(-1)
211
+ if (previous && previous.endSample === span.lo && previous.cluster === span.cluster && previous.candidateName === span.name) previous.endSample = span.hi
212
+ else extCandidates.push({ startSample: span.lo, endSample: span.hi, cluster: span.cluster, candidateName: span.name })
213
+ }
214
+ const shortCandidates: PairedShadowShortCandidate[] = [], shortWithheld: PairedShadowReport['shortWithheld'] = []
215
+ for (const voice of voices) {
216
+ if (voice.allSpeechSeconds < PAIRED_SHADOW_GATES.shortMinSeconds || voice.allSpeechSeconds > PAIRED_SHADOW_GATES.shortMaxSeconds) continue
217
+ const row = evidence.get(voice.voice), reasons: string[] = []
218
+ if (!allow) reasons.push('paired alignment is not qualified')
219
+ if (!row || !finite(row.seconds) || Math.abs(row.seconds - voice.allSpeechSeconds) > 1 / SR) reasons.push('missing or inconsistent acoustic evidence')
220
+ if (!row || !finite(row.majority) || row.majority < SHADOW_GATES.majority) reasons.push('acoustic majority below 0.8')
221
+ if (!row || !finite(row.coveredFraction) || row.coveredFraction < SHADOW_GATES.minCoveredFraction) reasons.push('acoustic coverage below 0.5')
222
+ if (!row?.outlierPass) reasons.push('acoustic outlier check failed')
223
+ if (!row || !finite(row.anchorRatio) || row.anchorRatio < 0 || row.anchorRatio > PAIRED_SHADOW_GATES.shortAnchorRatio) reasons.push('raw anchor ratio unavailable or above 1.1')
224
+ const name = voice.cluster ? named.get(voice.cluster) : undefined
225
+ const support = voice.names.find(n => n.name === name)
226
+ if (!name || !support || support.fractionOfAllSpeech < PAIRED_SHADOW_GATES.nameAllSpeechFraction || voice.diagnosticTopName !== name) reasons.push('fragment and group lack same-time G2 name agreement')
227
+ if (reasons.length) shortWithheld.push({ voice: voice.voice, reasons })
228
+ else shortCandidates.push({ voice: voice.voice, cluster: voice.cluster!, candidateName: name!, seconds: voice.allSpeechSeconds })
229
+ }
230
+ return { mode: 'provisional_shadow_only', alignmentStatus: align.status, candidateNominationAllowed: allow, accuracyClaim: false, humanTruthClaim: false,
231
+ warnings: [...warnings], groups: groupRows, voices, extCandidates, shortCandidates, shortWithheld }
232
+ }
233
+
234
+ /** Readable paired-recording diagnostics. All labels here remain provisional. */
235
+ export function renderPairedSpeakerShadow(report: PairedShadowReport): string {
236
+ const esc = (value: string) => value.replace(/[|\r\n]/gu, ' ').replace(/[*`<>]/gu, '')
237
+ const seconds = (n: number) => `${n.toFixed(1)} s`
238
+ const lines = ['## Paired G2 diagnostics', '',
239
+ `Alignment: **${report.alignmentStatus}**. ${report.candidateNominationAllowed ? 'Provisional shadow nominations are allowed.' : 'Nominations withheld; overlap statistics are diagnostic only.'}`,
240
+ 'Saved G2 names are machine labels. This comparison makes no accuracy or human-truth claim.', '',
241
+ 'Accepted-voice scope: only the final accepted keep/merge voices; this is not the whole native cluster.', '',
242
+ '| Cluster | All accepted-voice speech | Unique known-name time | Top name support / all speech | Top name support / known-name time | Name nomination |',
243
+ '|---|---:|---:|---|---|---|']
244
+ for (const group of report.groups) {
245
+ const best = group.names[0]
246
+ const support = best ? `${esc(best.name)}: ${seconds(best.seconds)}` : 'None'
247
+ lines.push(`| ${esc(group.cluster)} | ${seconds(group.allSpeechSeconds)} | ${seconds(group.knownNameSeconds)} | ${support}; ${((best?.fractionOfAllSpeech ?? 0) * 100).toFixed(1)}% | ${((best?.fractionOfKnownName ?? 0) * 100).toFixed(1)}% | ${group.candidateName ? esc(group.candidateName) + ' (provisional)' : 'Withheld'} |`)
248
+ }
249
+ lines.push('', 'Whole native-cluster scope includes Ext, other source labels and native speech without ASR labels.', '',
250
+ '| Cluster | All native-cluster speech | Unique known-name time | Top name support / all speech | Top name support / known-name time |',
251
+ '|---|---:|---:|---|---|')
252
+ for (const group of report.groups) {
253
+ const whole = group.wholeCluster, best = whole.names[0]
254
+ lines.push(`| ${esc(group.cluster)} | ${seconds(whole.allSpeechSeconds)} | ${seconds(whole.knownNameSeconds)} | ${best ? `${esc(best.name)}: ${seconds(best.seconds)}` : 'None'}; ${((best?.fractionOfAllSpeech ?? 0) * 100).toFixed(1)}% | ${((best?.fractionOfKnownName ?? 0) * 100).toFixed(1)}% |`)
255
+ }
256
+ lines.push('', 'A name must pass 80% of ALL speech and 30 seconds in BOTH scopes with the same name. Known-name percentages alone cannot qualify it.', '',
257
+ '| Voice | All speech | Known-name time | Top name / all speech | Production conflict | Native conflict / uncovered |',
258
+ '|---|---:|---:|---|---:|---|')
259
+ for (const voice of report.voices) {
260
+ const best = voice.names[0]
261
+ lines.push(`| ${esc(voice.voice)} | ${seconds(voice.allSpeechSeconds)} | ${seconds(voice.knownNameSeconds)} | ${best ? `${esc(best.name)} ${((best.fractionOfAllSpeech) * 100).toFixed(1)}%` : 'None'} | ${seconds(voice.productionConflictSeconds)} | ${seconds(voice.nativeConflictSeconds)} / ${seconds(voice.nativeUncoveredSeconds)} |`)
262
+ }
263
+ lines.push('', `Ext span nominations: ${report.extCandidates.length}. Short-fragment nominations: ${report.shortCandidates.length}. These lists do not change labels or remaps.`)
264
+ for (const group of report.groups) if (group.reasons.length) lines.push(`- ${esc(group.cluster)}: ${group.reasons.map(esc).join('; ')}.`)
265
+ for (const row of report.shortWithheld) lines.push(`- ${esc(row.voice)} short fragment withheld: ${row.reasons.map(esc).join('; ')}.`)
266
+ for (const warning of report.warnings) lines.push(`- ${esc(warning)}.`)
267
+ return lines.join('\n') + '\n'
268
+ }
@@ -24,7 +24,7 @@ export interface ShadowReportInput {
24
24
  const STRIP = new RegExp(`^${PY_SPACE_CLASS}+|${PY_SPACE_CLASS}+$`, 'gu')
25
25
  const quote = (text: string | undefined) => Array.from((text ?? '').replace(STRIP, '')).slice(0, 140).join('')
26
26
  const chunkFile = (dir: string, index: number) => join(dir, `chunk_${String(index).padStart(4, '0')}.wav`)
27
- const HEADER = ['| Voice | Speech | Covered | Top cluster, majority | Outlier check | Anchor ratio (post-hoc) |', '|---|---:|---:|---|---|---|']
27
+ const HEADER = ['| Voice | Speech | Covered | Top cluster, majority | Outlier check | Anchor ratio gate |', '|---|---:|---:|---|---|---|']
28
28
 
29
29
  /** propose.py fmt_voice. */
30
30
  export function shadowVoiceRow(m: ShadowVoiceEntry): string {
@@ -49,7 +49,7 @@ export function renderShadowReport(input: ShadowReportInput): string {
49
49
  : `- Meeting: ${pyFixed(F.minutes, 1)} min, ${F.originals} originals (${F.verifiedAgainstSidecar} verified against the sidecar hashes; ${F.unhashedOriginals} had no hash, all empty-text).`,
50
50
  `- FluidAudio found ${P.fluidAudioClusters} clusters. Labels never merged: ${P.frozenOtherLabels.join(', ') || 'none'}.`,
51
51
  '- Gates: majority ≥0.8 of covered speech on one cluster (and ≥50% covered), ≥15 s of speech, embedding centroid inside the cluster spread (p95).',
52
- '- "Anchor ratio" is an UNVALIDATED extra check (fragment vs the kept voice\'s own spread); it decides nothing. Above 1.5x is worth a listen.',
52
+ '- Anchor ratio must be ≤1.5x against the kept voice\'s own spread; missing or degenerate evidence rejects a merge.',
53
53
  '- How to check: play two or three pointers for each voice in a row. Same person? The merge is right.',
54
54
  ...(F.humanProtectedVoices.length ? [`- A person already edited chunks holding ${F.humanProtectedVoices.join(', ')}. Those edits outrank any proposal here.`] : []),
55
55
  `- Session \`${input.sessionId}\`.`, '',
@@ -6,10 +6,10 @@
6
6
  // RSS cap and kills the whole process group; this side also stops the CLI on its own
7
7
  // deadline and exits if its parent goes away.
8
8
  //
9
- // READ ONLY, LOG ONLY. Reads the finalized sidecar and the retained chunk WAVs; writes
10
- // only a private temp directory (the timeline WAV and the CLI's two JSON outputs), which
11
- // is removed before the outcome is printed. No meeting file, label, name, profile,
12
- // embedding cache or correction ledger is opened for writing anywhere in this file.
9
+ // Shadow remains READ ONLY, LOG ONLY: its temporary native outputs are removed.
10
+ // A separately default-off reviewed closed-call consumer may retain the SAME verified
11
+ // execution exports in its own data directory. It never edits BASE, names, profiles or
12
+ // corrections here; later derived-view publication has an independent queue/admission.
13
13
  //
14
14
  // The CLI is FluidAudio 0.17.4 `process --mode offline --overlapping-segments
15
15
  // --export-embeddings`, the exact invocation of round3/propose.py, run through
@@ -36,6 +36,9 @@ import {
36
36
  } from './speaker-shadow-timeline.js'
37
37
  import { renderShadowReport } from './speaker-shadow-report.js'
38
38
  import { pyRound } from './speaker-shadow-numeric.js'
39
+ import { findShadowPairSource } from './speaker-shadow-pair-source.js'
40
+ import { evaluatePairedSpeakerShadow, renderPairedSpeakerShadow, type PairedShadowReport } from './speaker-shadow-paired.js'
41
+ import { beginMacSpeakerCallNative,retainMacSpeakerCallNative,type MacSpeakerCallProducerRequest,type MacSpeakerCallNativeRef } from './mac-speaker-call-producer-worker.js'
39
42
 
40
43
  export const SHADOW_TMP_PREFIX = 'cos-speaker-shadow-'
41
44
  /** The CLI's flags, verbatim from round3/propose.py (via scripts/guard.py). */
@@ -56,6 +59,10 @@ export interface ShadowJob {
56
59
  homeDir: string
57
60
  tmpRoot: string
58
61
  cliTimeoutMs: number
62
+ /** Separate default-off consumer, admitted independently from log-only shadow. */
63
+ callProducer?: MacSpeakerCallProducerRequest
64
+ shadowLogRequested?: boolean
65
+ callProducerSwapStart?:number
59
66
  }
60
67
 
61
68
  export type ShadowSkipReason =
@@ -85,7 +92,7 @@ export interface ShadowRunFacts {
85
92
  }
86
93
 
87
94
  export type ShadowJobOutcome =
88
- | { status: 'proposed'; facts: ShadowRunFacts; proposals: ShadowProposals; report: { title: string; markdown: string } }
95
+ | { status: 'proposed'; facts: ShadowRunFacts; proposals: ShadowProposals; paired?: PairedShadowReport & { pairSessionId?: string; pairSourceSha256?: string; discoveryReason: string }; report: { title: string; markdown: string };callProducerExport?:MacSpeakerCallNativeRef; callProducerFailure?:{stage:'retain';reason:string} }
89
96
  | { status: 'skipped'; reason: ShadowSkipReason; detail: string }
90
97
  | { status: 'failed'; reason: ShadowFailReason; detail: string }
91
98
 
@@ -122,17 +129,19 @@ function nsirdDirs(): Set<string> {
122
129
  const skip = (reason: ShadowSkipReason, detail: string): ShadowJobOutcome => ({ status: 'skipped', reason, detail })
123
130
  const fail = (reason: ShadowFailReason, detail: string): ShadowJobOutcome => ({ status: 'failed', reason, detail: detail.slice(0, 300) })
124
131
 
125
- function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: string, work: string, deps: ShadowWorkerDeps): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string; timedOut: boolean; error?: string }> {
132
+ function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: string, work: string, deps: ShadowWorkerDeps): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string; timedOut: boolean;closed:boolean; error?: string }> {
126
133
  const args = ['-p', parakeetSandboxProfile(work, job.homeDir), job.cliPath, 'process', wavPath, '--mode', 'offline', '--output', nativePath, '--overlapping-segments', '--export-embeddings', embPath]
127
134
  return new Promise(resolvePromise => {
135
+ if(deps.signal?.aborted){resolvePromise({code:null,signal:null,stderr:'',timedOut:false,closed:false,error:'cancelled before native admission'});return}
136
+ const deadline=Date.now()+job.cliTimeoutMs
128
137
  let child: ChildProcess
129
138
  try {
130
- child = (deps.spawn ?? (spawn as unknown as SpawnLike))(job.sandboxExec, args, { stdio: ['ignore', 'ignore', 'pipe'], env: parakeetChildEnv(job.homeDir, work) })
139
+ child = (deps.spawn ?? (spawn as unknown as SpawnLike))(job.sandboxExec, args, { stdio: ['ignore', 'ignore', 'pipe'], env: {...parakeetChildEnv(job.homeDir, work),...(job.callProducer?{OMP_NUM_THREADS:'1',VECLIB_MAXIMUM_THREADS:'1',OPENBLAS_NUM_THREADS:'1'}:{})} })
131
140
  } catch (error) {
132
- resolvePromise({ code: null, signal: null, stderr: '', timedOut: false, error: error instanceof Error ? error.message : String(error) })
141
+ resolvePromise({ code: null, signal: null, stderr: '', timedOut: false,closed:false, error: error instanceof Error ? error.message : String(error) })
133
142
  return
134
143
  }
135
- let stderr = '', timedOut = false, settled = false
144
+ let stderr = '', timedOut = false, settled = false, childError:string|undefined
136
145
  const done = (value: Parameters<typeof resolvePromise>[0]) => { if (settled) return; settled = true; clearTimeout(timer); deps.signal?.removeEventListener('abort', onAbort); resolvePromise(value) }
137
146
  const kill = () => { try { child.kill('SIGKILL') } catch { /* gone */ } }
138
147
  // SIGKILL: CoreML work does not stop for TERM.
@@ -140,8 +149,8 @@ function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: st
140
149
  const onAbort = () => kill()
141
150
  deps.signal?.addEventListener('abort', onAbort, { once: true })
142
151
  child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 65_536) stderr += d.toString() })
143
- child.on('error', error => done({ code: null, signal: null, stderr, timedOut, error: error.message }))
144
- child.on('close', (code, signal) => done({ code, signal, stderr, timedOut }))
152
+ child.on('error', error => {childError=error.message;if(!child.pid)done({ code: null, signal: null, stderr, timedOut,closed:false, error: error.message });else kill()})
153
+ child.on('close', (code, signal) => done({ code, signal, stderr, timedOut:timedOut||Date.now()>deadline,closed:true,error:childError }))
145
154
  })
146
155
  }
147
156
 
@@ -152,8 +161,8 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
152
161
  let work: string | null = null
153
162
  try {
154
163
  // 1. The finalized meeting, unchanged since it finalized.
155
- let sidecar: Record<string, unknown>
156
- try { sidecar = JSON.parse(await readFile(job.sidecarPath, 'utf8')) } catch { return skip('bad_sidecar', 'the sidecar is missing or unreadable') }
164
+ let sidecar: Record<string, unknown>,sourceJSON:string
165
+ try { sourceJSON=await readFile(job.sidecarPath, 'utf8');sidecar = JSON.parse(sourceJSON) } catch { return skip('bad_sidecar', 'the sidecar is missing or unreadable') }
157
166
  if (sidecar.sessionId !== job.sessionId) return skip('bad_sidecar', 'the sidecar belongs to another session')
158
167
  if (sidecar.finalizationState !== 'complete') return skip('not_finalized', `finalizationState=${String(sidecar.finalizationState)}`)
159
168
  const entries = shadowEntries(sidecar)
@@ -178,11 +187,19 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
178
187
  const wav = pcm16WavFile(timeline.pcm)
179
188
  const rebuildMs = now() - started
180
189
 
190
+ // The platform temp root may spell /private/var as /var. Pin the owned
191
+ // directory itself; retained-output readers keep their strict canonical path fence.
181
192
  work = mkdtempSync(join(job.tmpRoot, SHADOW_TMP_PREFIX))
193
+ work = realpathSync(work)
182
194
  const wavPath = join(work, 'timeline.wav'), nativePath = join(work, 'native.json'), embPath = join(work, 'embeddings.json')
183
195
  await writeFile(wavPath, wav, { mode: 0o600, flag: 'wx' })
184
196
  const timelineWAVSHA256 = createHash('sha256').update(wav).digest('hex')
185
197
  prepareShadowHome(job.homeDir, models.dir)
198
+ let producerContext:ReturnType<typeof beginMacSpeakerCallNative>|null=null
199
+ if(job.callProducer){
200
+ try{producerContext=beginMacSpeakerCallNative(job.callProducer,{sourceJSON,audioDir:job.audioDir,cliPath:job.cliPath,modelsDir:models.dir,timeline:{rows:timeline.rows,total:timeline.total,pcmSHA256:timeline.pcmSHA256}})}
201
+ catch(error){if(job.shadowLogRequested===false)return skip('bad_sidecar',`independent consumer withheld: ${error instanceof Error?error.message:String(error)}`)}
202
+ }
186
203
 
187
204
  // 4. One offline diarization.
188
205
  const nativeStarted = now()
@@ -205,6 +222,7 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
205
222
 
206
223
  // 5. The gates, exactly as the research tool applies them.
207
224
  let proposals: ShadowProposals, clipped: number
225
+ let paired: Extract<ShadowJobOutcome, { status: 'proposed' }>['paired']
208
226
  try {
209
227
  const turns = nativeTurns(native.segments as Array<{ speakerId: unknown; startTimeSeconds: unknown; endTimeSeconds: unknown }>, timeline.total / SHADOW_SAMPLE_RATE)
210
228
  clipped = turns.clipped
@@ -213,6 +231,19 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
213
231
  }
214
232
  const D = decide(segs, timeline.total, turns.turns, emb as ShadowEmbeddingRow[], SHADOW_GATES.floorSeconds, SHADOW_GATES.majority)
215
233
  proposals = buildShadowProposals(D, segs, sources, turns.turns)
234
+ // Pair scoring is advisory; an unavailable G2 must not suppress the acoustic report.
235
+ try {
236
+ const source = await findShadowPairSource(job.sidecarPath, sidecar, timeline.total / SHADOW_SAMPLE_RATE * 1000)
237
+ paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
238
+ segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted,
239
+ voiceEvidence: [...proposals.accepted.flatMap(group => group.voices), ...proposals.rejected],
240
+ g2: source.g2, alignment: { status: source.status } }),
241
+ pairSessionId: source.sessionId, pairSourceSha256: source.sourceSha256, discoveryReason: source.reason }
242
+ } catch {
243
+ paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
244
+ segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted, voiceEvidence: [],
245
+ alignment: { status: 'unpaired' } }), discoveryReason: 'pair discovery or scoring unavailable' }
246
+ }
216
247
  } catch (error) { return fail('bad_cli_output', error instanceof Error ? error.message : String(error)) }
217
248
 
218
249
  const humanProtectedVoices = shadowHumanProtectedVoices(entries)
@@ -233,8 +264,12 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
233
264
  }
234
265
  const title = await readShadowMeetingTitle(job.sidecarPath)
235
266
  const chunkText = new Map(entries.map(e => [e.chunkIndex, typeof e.chunk.text === 'string' ? e.chunk.text : '']))
236
- const markdown = renderShadowReport({ title, sessionId: job.sessionId, proposals, facts, audioDir: job.audioDir, chunkText })
237
- return { status: 'proposed', proposals, facts, report: { title, markdown } }
267
+ let markdown = renderShadowReport({ title, sessionId: job.sessionId, proposals, facts, audioDir: job.audioDir, chunkText })
268
+ if (paired) markdown += `\n${renderPairedSpeakerShadow(paired)}\nDiscovery: ${paired.discoveryReason}\n`
269
+ let callProducerExport:MacSpeakerCallNativeRef|undefined
270
+ let callProducerFailure:Extract<ShadowJobOutcome,{status:'proposed'}>['callProducerFailure']
271
+ if(producerContext){try{callProducerExport=retainMacSpeakerCallNative(producerContext,{nativePath,embeddingPath:embPath,exitCode:run.code,closed:run.closed,networkDenied:true})}catch(error){callProducerFailure={stage:'retain',reason:(error instanceof Error?error.message:'native export retention failed').slice(0,200)}}}
272
+ return { status: 'proposed', proposals, paired, facts, report: { title, markdown },...(callProducerExport?{callProducerExport}:{}),...(callProducerFailure?{callProducerFailure}:{}) }
238
273
  } catch (error) {
239
274
  if (error instanceof ShadowInputError) return skip(error.reason, error.message)
240
275
  return fail('internal', error instanceof Error ? error.message : String(error))