@gotcos/glasses-server 6.94.4 → 6.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +52 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +19 -1
  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/dictation-clean-models.ts +2 -0
  12. package/server/lib/dictation-clean.ts +2 -2
  13. package/server/lib/dictation-polish-pipeline.ts +22 -2
  14. package/server/lib/dictation-prewarm.ts +16 -0
  15. package/server/lib/direct-bridge.ts +14 -2
  16. package/server/lib/direct-sse.ts +6 -1
  17. package/server/lib/display-bus.ts +1 -1
  18. package/server/lib/fork-thread.ts +32 -1
  19. package/server/lib/held-naming-batches.ts +36 -0
  20. package/server/lib/job-audit-caller.ts +20 -0
  21. package/server/lib/lens-gist-engines.ts +17 -5
  22. package/server/lib/live-cos-ask.ts +29 -6
  23. package/server/lib/live-cue-verdicts.ts +43 -8
  24. package/server/lib/live-cues-engine.ts +4 -1
  25. package/server/lib/live-cues-filter.ts +50 -0
  26. package/server/lib/live-cues-general-prompts.ts +137 -0
  27. package/server/lib/live-cues-general-topic.ts +245 -0
  28. package/server/lib/live-cues-general.ts +715 -0
  29. package/server/lib/live-cues-v2-data.ts +453 -0
  30. package/server/lib/live-cues-v2-grounded.ts +262 -0
  31. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  32. package/server/lib/live-cues-v2-names.ts +49 -0
  33. package/server/lib/live-cues-v2-notes.ts +8 -4
  34. package/server/lib/live-cues-v2-open-items.ts +132 -0
  35. package/server/lib/live-cues-v2-pacing.ts +45 -0
  36. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  37. package/server/lib/live-cues-v2-settings.ts +89 -10
  38. package/server/lib/live-cues-v2.ts +570 -47
  39. package/server/lib/mac-recording-asr.ts +6 -3
  40. package/server/lib/mac-speaker-call-human.ts +371 -0
  41. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  42. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  43. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  44. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  45. package/server/lib/mac-speaker-call-producer.ts +539 -0
  46. package/server/lib/mac-speaker-call-store.ts +264 -0
  47. package/server/lib/mac-speaker-call-view.ts +373 -0
  48. package/server/lib/mac-speaker-live-compute.ts +72 -0
  49. package/server/lib/mac-speaker-live-memory.ts +414 -0
  50. package/server/lib/mac-speaker-live-projection.ts +126 -0
  51. package/server/lib/mac-speaker-live-save.ts +90 -0
  52. package/server/lib/mac-speaker-live-store.ts +71 -0
  53. package/server/lib/mac-speaker-live-worker.ts +58 -0
  54. package/server/lib/mac-speaker-live.ts +175 -0
  55. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  56. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  57. package/server/lib/mac-voice-assignment.ts +44 -26
  58. package/server/lib/mac-voice-display.ts +195 -0
  59. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  60. package/server/lib/mac-voice-suggestions.ts +548 -0
  61. package/server/lib/maintenance-lifecycle.ts +2 -0
  62. package/server/lib/media-store.ts +2 -1
  63. package/server/lib/meeting-ask-pasted.ts +135 -0
  64. package/server/lib/meeting-ask-review.ts +8 -1
  65. package/server/lib/meeting-ask-runner.ts +4 -1
  66. package/server/lib/meeting-ask-runtime.ts +189 -17
  67. package/server/lib/meeting-ask-store.ts +2 -0
  68. package/server/lib/meeting-ask-web.ts +284 -13
  69. package/server/lib/meeting-batch-progress.ts +51 -5
  70. package/server/lib/meeting-batch-transcribe.ts +35 -2
  71. package/server/lib/meeting-fact-check.ts +3 -0
  72. package/server/lib/meeting-finalization-jobs.ts +69 -2
  73. package/server/lib/meeting-public-topic.ts +6 -1
  74. package/server/lib/meeting-speaker-review.ts +182 -17
  75. package/server/lib/meeting-store.ts +6 -1
  76. package/server/lib/parakeet.ts +51 -5
  77. package/server/lib/pending-batch-retention.ts +58 -0
  78. package/server/lib/private-reference.ts +38 -0
  79. package/server/lib/provider-usage.ts +561 -0
  80. package/server/lib/public-https-url.ts +32 -0
  81. package/server/lib/query-job-runtime.ts +3 -0
  82. package/server/lib/rich-media-safety.ts +23 -2
  83. package/server/lib/speaker-shadow-decide.ts +503 -0
  84. package/server/lib/speaker-shadow-models.ts +150 -0
  85. package/server/lib/speaker-shadow-numeric.ts +143 -0
  86. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  87. package/server/lib/speaker-shadow-paired.ts +268 -0
  88. package/server/lib/speaker-shadow-report.ts +113 -0
  89. package/server/lib/speaker-shadow-timeline.ts +210 -0
  90. package/server/lib/speaker-shadow-worker.ts +312 -0
  91. package/server/lib/speaker-shadow.ts +788 -0
  92. package/server/lib/token-audit.ts +36 -6
  93. package/server/lib/transcript-cleanup.ts +3 -2
  94. package/server/lib/voice-directory.ts +8 -4
  95. package/server/lib/web-lookup-ledger.ts +151 -0
  96. package/server/lib/work-review-runtime.ts +2 -0
  97. package/server/lib/work-search.ts +3 -1
  98. package/server/lib/xlsx-text.ts +277 -0
  99. package/server/routes/health.ts +5 -0
  100. package/server/routes/live-cues.ts +36 -14
  101. package/server/routes/meeting.ts +506 -49
  102. package/server/routes/speaker-shadow.ts +79 -0
  103. package/server/routes/transcribe-stream.ts +114 -33
  104. package/server/routes/voice.ts +3 -0
  105. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  106. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  107. package/shared/live-cues-v2.ts +38 -5
  108. package/shared/media-attachment.ts +2 -0
  109. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,143 @@
1
+ // Python-compatible numerics for the speaker shadow port (6.95.0).
2
+ //
3
+ // The shadow gates are a byte-for-byte port of the research tool
4
+ // (community-1-fluidaudio-v1/scripts/hybrid.py and round3/propose.py), which ran on
5
+ // CPython 3.15. Four Python behaviours differ from the obvious JavaScript and each one
6
+ // changes a gate or a reason string on real meetings, so each is replicated here:
7
+ //
8
+ // 1. `round(x)` and `round(x, n)` round half to EVEN on the exact binary value.
9
+ // Math.round and toFixed round half up, and `coveredFraction`, `majority` and
10
+ // `seconds` are compared AFTER rounding (0.7996 rounds to 0.8 and passes 0.8).
11
+ // 2. `'%.2f' % x` is correctly rounded half to even as well: 0.125 formats as 0.12,
12
+ // where (0.125).toFixed(2) is 0.13.
13
+ // 3. `sum()` of floats is Neumaier-compensated since CPython 3.12, so a plain loop
14
+ // drifts in the last bits of every cosine distance and norm.
15
+ // 4. String order is by code point, not UTF-16 unit.
16
+ //
17
+ // No I/O and no clock. Verified against CPython 3.15 vectors in speaker-shadow-numeric.test.ts.
18
+
19
+ const view = new DataView(new ArrayBuffer(8))
20
+
21
+ /** x = (neg ? -1 : 1) * mant * 2^exp, exactly. Finite x only. */
22
+ function decompose(x: number): { neg: boolean; mant: bigint; exp: number } {
23
+ view.setFloat64(0, x)
24
+ const hi = view.getUint32(0), lo = view.getUint32(4)
25
+ const neg = hi >>> 31 === 1
26
+ const biased = (hi >>> 20) & 0x7ff
27
+ let mant = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo)
28
+ let exp: number
29
+ if (biased === 0) exp = -1074
30
+ else { mant |= 1n << 52n; exp = biased - 1075 }
31
+ return { neg, mant, exp }
32
+ }
33
+
34
+ /** |x| * 10^digits rounded half to even, as an exact integer. */
35
+ function scaledHalfEven(x: number, digits: number): { neg: boolean; q: bigint } {
36
+ const { neg, mant, exp } = decompose(x)
37
+ let num = mant * 10n ** BigInt(digits)
38
+ let den = 1n
39
+ if (exp >= 0) num <<= BigInt(exp)
40
+ else den <<= BigInt(-exp)
41
+ let q = num / den
42
+ const twice = (num % den) * 2n
43
+ if (twice > den || (twice === den && (q & 1n) === 1n)) q += 1n
44
+ return { neg, q }
45
+ }
46
+
47
+ function decimalString(neg: boolean, q: bigint, digits: number): string {
48
+ let text = q.toString()
49
+ if (digits > 0) {
50
+ text = text.padStart(digits + 1, '0')
51
+ text = `${text.slice(0, -digits)}.${text.slice(-digits)}`
52
+ }
53
+ return neg ? `-${text}` : text
54
+ }
55
+
56
+ /** Python `'%.{digits}f' % x`. */
57
+ export function pyFixed(x: number, digits: number): string {
58
+ if (Number.isNaN(x)) return 'nan'
59
+ if (!Number.isFinite(x)) return x > 0 ? 'inf' : '-inf'
60
+ const { neg, q } = scaledHalfEven(x, digits)
61
+ return decimalString(neg, q, digits)
62
+ }
63
+
64
+ /** Python `round(x, digits)` for digits >= 1 (a float). */
65
+ export function pyRound(x: number, digits: number): number {
66
+ if (!Number.isFinite(x)) return x
67
+ const { neg, q } = scaledHalfEven(x, digits)
68
+ return Number.parseFloat(decimalString(neg, q, digits))
69
+ }
70
+
71
+ /** Python `round(x)` with no digits (an int, half to even). */
72
+ export function pyRoundInt(x: number): number {
73
+ const floor = Math.floor(x)
74
+ const fraction = x - floor
75
+ if (fraction > 0.5) return floor + 1
76
+ if (fraction < 0.5) return floor
77
+ return floor % 2 === 0 ? floor : floor + 1
78
+ }
79
+
80
+ /**
81
+ * CPython (>= 3.12) `sum()` of floats from the int start 0: the first item is added to
82
+ * 0 exactly, then Neumaier's improved Kahan-Babuska compensation, and the compensation is
83
+ * added once at the end only when it is finite and non-zero (Python/bltinmodule.c).
84
+ * Integral JSON values (only exact zeros occur in FluidAudio output) contribute nothing
85
+ * under either of CPython's int and float paths, so treating them as floats is exact.
86
+ */
87
+ export function pySum(values: ArrayLike<number>): number {
88
+ const n = values.length
89
+ if (n === 0) return 0
90
+ let total = 0 + values[0]
91
+ let compensation = 0
92
+ for (let i = 1; i < n; i++) {
93
+ const x = values[i]
94
+ const t = total + x
95
+ if (Math.abs(total) >= Math.abs(x)) compensation += (total - t) + x
96
+ else compensation += (x - t) + total
97
+ total = t
98
+ }
99
+ if (compensation !== 0 && Number.isFinite(compensation)) total += compensation
100
+ return total
101
+ }
102
+
103
+ /** Python str ordering (by code point). */
104
+ export function codePointCompare(a: string, b: string): number {
105
+ if (a === b) return 0
106
+ const n = Math.min(a.length, b.length)
107
+ for (let i = 0; i < n;) {
108
+ const ca = a.codePointAt(i)!, cb = b.codePointAt(i)!
109
+ if (ca !== cb) return ca < cb ? -1 : 1
110
+ i += ca > 0xffff ? 2 : 1
111
+ }
112
+ return a.length < b.length ? -1 : a.length > b.length ? 1 : 0
113
+ }
114
+
115
+ /** Python `str.isspace()` characters, which is what `\s` matches in a str pattern. */
116
+ export const PY_SPACE_CLASS = '[\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]'
117
+ /** Python `re.compile(r'Voice\s*\d+').fullmatch` (Unicode `\s` and `\d`). */
118
+ export const PY_VOICE_LABEL = new RegExp(`^Voice${PY_SPACE_CLASS}*\\p{Nd}+$`, 'u')
119
+
120
+ const ND = /^\p{Nd}$/u
121
+ /** Python `int(re.findall(r'\d+', label)[0])`, or null when the label has no digit. */
122
+ export function pyFirstInt(label: string): number | null {
123
+ const run = /\p{Nd}+/u.exec(label)
124
+ if (!run) return null
125
+ let value = 0
126
+ for (const ch of run[0]) {
127
+ const cp = ch.codePointAt(0)!
128
+ // Unicode keeps every decimal digit set as ten contiguous code points from its zero,
129
+ // and adjacent sets (mathematical digits) are whole multiples of ten.
130
+ let zero = cp
131
+ while (zero > 0 && ND.test(String.fromCodePoint(zero - 1))) zero--
132
+ value = value * 10 + ((cp - zero) % 10)
133
+ }
134
+ return value
135
+ }
136
+
137
+ /** Python `'%d:%02d:%02d' % (h, m, s) if h else '%d:%02d' % (m, s)` after `int(round(sec))`. */
138
+ export function pyClock(seconds: number): string {
139
+ const sec = pyRoundInt(seconds)
140
+ const h = Math.floor(sec / 3600), m = Math.floor(sec / 60) % 60, s = sec % 60
141
+ const two = (n: number) => String(n).padStart(2, '0')
142
+ return h ? `${h}:${two(m)}:${two(s)}` : `${m}:${two(s)}`
143
+ }
@@ -0,0 +1,91 @@
1
+ // Bounded read-only clock diagnostics. File dates and timestamps are not alignment proof.
2
+ import { open, readdir, lstat } from 'node:fs/promises'
3
+ import { constants } from 'node:fs'
4
+ import { basename, dirname, join } from 'node:path'
5
+ import { createHash } from 'node:crypto'
6
+
7
+ export interface ShadowPairSource {
8
+ status: 'time_only' | 'ambiguous' | 'unpaired'
9
+ reason: string
10
+ sessionId?: string
11
+ sourceSha256?: string
12
+ g2?: { startTime: number; chunks: Array<{ speaker: string; elapsed: number; audioDurationMs: number }> }
13
+ }
14
+ const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
15
+ function bounds(value: Record<string, unknown>, durationOverride?: number): [number, number] | null {
16
+ if (!finite(value.startTime) || !Array.isArray(value.chunks)) return null
17
+ const duration = durationOverride ?? value.durationMs
18
+ if (!finite(duration) || duration <= 0 || duration > 4 * 3600_000) return null
19
+ return [value.startTime, value.startTime + duration]
20
+ }
21
+ const day = (ms: number) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` }
22
+ /** Only the current recording directory is searched; absent cross-month pairs remain unpaired. */
23
+ export async function findShadowPairSource(sidecarPath: string, mac: Record<string, unknown>, durationMs?: number): Promise<ShadowPairSource> {
24
+ const target = bounds(mac, durationMs)
25
+ if (!target) return { status: 'unpaired', reason: 'invalid Mac clock or duration' }
26
+ const dir = dirname(sidecarPath)
27
+ // Official sidecars use this Mac's local calendar-day prefix. Include a four-hour
28
+ // lookback for a G2 capture that started before this Mac capture (maximum call length).
29
+ const days = new Set([day(target[0] - 4 * 3600_000), day(target[0]), day(target[1])])
30
+ const all = await readdir(dir)
31
+ const names = all.filter(n => n.endsWith('.g2-chunks.json') && n !== basename(sidecarPath))
32
+ .filter(n => { const prefix = n.match(/^(\d{4}-\d{2}-\d{2})_/); return !prefix || days.has(prefix[1]) }).sort()
33
+ if (names.length > 256) return { status: 'ambiguous', reason: 'day-window discovery file limit exceeded; no pair selected' }
34
+ let bytes = 0, estimated = false
35
+ const candidates: ShadowPairSource[] = []
36
+ for (const name of names) {
37
+ const path = join(dir, name)
38
+ let raw: string, value: Record<string, unknown>
39
+ try {
40
+ const stat = await lstat(path)
41
+ if (!stat.isFile() || stat.isSymbolicLink()) return { status: 'ambiguous', reason: 'unreadable day-window candidate; no pair selected' }
42
+ if (stat.size > 16 * 1024 * 1024 || bytes + stat.size > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery byte limit exceeded; no pair selected' }
43
+ const parts: Buffer[] = []
44
+ let size = 0
45
+ // A candidate can change between lstat and open. Never wait on a FIFO/device
46
+ // substituted in that interval; verify the actual descriptor before reading.
47
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
48
+ try {
49
+ if (!(await handle.stat()).isFile()) throw new Error('candidate is no longer a regular file')
50
+ const buffer = Buffer.alloc(64 * 1024)
51
+ while (true) {
52
+ const { bytesRead } = await handle.read(buffer)
53
+ if (!bytesRead) break
54
+ size += bytesRead
55
+ if (size > 16 * 1024 * 1024 || bytes + size > 128 * 1024 * 1024) throw new Error('candidate grew beyond byte budget')
56
+ parts.push(Buffer.from(buffer.subarray(0, bytesRead)))
57
+ }
58
+ } finally { await handle.close() }
59
+ raw = Buffer.concat(parts).toString('utf8')
60
+ bytes += size
61
+ if (Buffer.byteLength(raw) > 16 * 1024 * 1024 || bytes > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery changed beyond byte limit; no pair selected' }
62
+ const parsed: unknown = JSON.parse(raw)
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid sidecar')
64
+ value = parsed as Record<string, unknown>
65
+ if (typeof value.sessionId !== 'string') throw new Error('missing source identity')
66
+ } catch { return { status: 'ambiguous', reason: 'unreadable or malformed day-window candidate; no pair selected' } }
67
+ if (typeof value.sessionId !== 'string' || !value.sessionId.startsWith('meeting_') || value.finalizationState !== 'complete') continue
68
+ const span = bounds(value)
69
+ if (!span) return { status: 'ambiguous', reason: 'complete G2 candidate lacks valid clock/duration; no pair selected' }
70
+ const overlap = Math.max(0, Math.min(target[1], span[1]) - Math.max(target[0], span[0]))
71
+ const macDuration = target[1] - target[0], g2Duration = span[1] - span[0]
72
+ if (overlap / Math.max(macDuration, g2Duration) < .5 || Math.min(macDuration, g2Duration) / Math.max(macDuration, g2Duration) < .5) continue
73
+ const chunks: NonNullable<ShadowPairSource['g2']>['chunks'] = []
74
+ for (const item of value.chunks as unknown[]) {
75
+ if (!item || typeof item !== 'object') return { status: 'ambiguous', reason: 'invalid G2 chunk; no pair selected' }
76
+ const row = item as Record<string, unknown>
77
+ if (typeof row.speaker !== 'string' || !finite(row.elapsed) || row.elapsed < 0) return { status: 'ambiguous', reason: 'invalid G2 timing or label; no pair selected' }
78
+ const exactDuration = row.audioDurationMs ?? row.durationMs
79
+ if (exactDuration !== undefined && (!finite(exactDuration) || exactDuration <= 0 || (row.audioDurationMs !== undefined && row.durationMs !== undefined && row.audioDurationMs !== row.durationMs))) return { status: 'ambiguous', reason: 'invalid G2 duration; no pair selected' }
80
+ // Historical G2 sidecars lack chunk durations: disclose the fixed-window
81
+ // approximation. This function can NEVER qualify alignment or transfer a name.
82
+ const duration = exactDuration === undefined ? 6000 : exactDuration as number
83
+ estimated ||= exactDuration === undefined
84
+ chunks.push({ speaker: row.speaker, elapsed: row.elapsed, audioDurationMs: duration })
85
+ }
86
+ candidates.push({ status: 'time_only', reason: '', sessionId: value.sessionId, sourceSha256: createHash('sha256').update(raw).digest('hex'), g2: { startTime: span[0], chunks } })
87
+ }
88
+ const scope = `Day-window scan: ${names.length} files, ${bytes} bytes, current directory only.`
89
+ return candidates.length === 1 ? { ...candidates[0], reason: `Clock overlap only; text offset/drift alignment has not been qualified. G2 names are estimates.${estimated ? ' G2 chunk durations use an approximate 6-second window where absent.' : ''} ${scope}` }
90
+ : { status: candidates.length ? 'ambiguous' : 'unpaired', reason: `${candidates.length ? `${candidates.length} overlapping G2 candidates; no pair chosen` : 'no complete overlapping G2 capture'}. ${scope}` }
91
+ }
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ // Speaker shadow mode (6.95.0): the readable per-meeting report Miles checks by ear.
2
+ //
3
+ // A port of round3/propose.py `render()`: "N voices would become M", every accepted merge
4
+ // with its seconds, gate values and two or three listen pointers (file, time, text),
5
+ // every rejection with its reasons, and the named labels sharing a cluster as a hint
6
+ // ("this group also holds 65 s labelled ..."). Pure: it returns text, and the runner
7
+ // writes it under <data>/speaker-shadow/reports/. It proposes; it never applies anything.
8
+
9
+ import { join } from 'node:path'
10
+ import { SHADOW_GATES, type ShadowProposals, type ShadowVoiceEntry } from './speaker-shadow-decide.js'
11
+ import { PY_SPACE_CLASS, pyFixed } from './speaker-shadow-numeric.js'
12
+
13
+ export interface ShadowReportInput {
14
+ title: string
15
+ sessionId: string
16
+ proposals: ShadowProposals
17
+ facts: { minutes: number; originals: number; verifiedAgainstSidecar: number; unhashedOriginals: number; unhashedWithText: number[]; humanProtectedVoices: string[] }
18
+ /** Where the chunk WAVs are, so each pointer names a file Miles can play. */
19
+ audioDir: string
20
+ /** The original chunk text by raw chunk index (the first 140 characters are quoted). */
21
+ chunkText: ReadonlyMap<number, string>
22
+ }
23
+
24
+ const STRIP = new RegExp(`^${PY_SPACE_CLASS}+|${PY_SPACE_CLASS}+$`, 'gu')
25
+ const quote = (text: string | undefined) => Array.from((text ?? '').replace(STRIP, '')).slice(0, 140).join('')
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 gate |', '|---|---:|---:|---|---|---|']
28
+
29
+ /** propose.py fmt_voice. */
30
+ export function shadowVoiceRow(m: ShadowVoiceEntry): string {
31
+ const o = m.outlierPass && m.outlierDistance !== null
32
+ ? `outlier ${pyFixed(m.outlierDistance, 3)} ≤ p95 ${pyFixed(m.outlierP95!, 3)}`
33
+ : m.outlierDistance !== null ? `outlier ${pyFixed(m.outlierDistance, 3)} > p95 ${pyFixed(m.outlierP95!, 3)}` : `outlier: ${m.outlierNote}`
34
+ const ar = m.anchorRatioPostHoc === null ? '—' : `${pyFixed(m.anchorRatioPostHoc, 2)}x${m.anchorRatioPostHoc > SHADOW_GATES.anchorRatioPostHoc ? ' (above 1.5x)' : ''}`
35
+ return `| ${m.voice} | ${pyFixed(m.seconds, 1)} s | ${pyFixed(100 * m.coveredFraction, 0)}% | ${m.topCluster ?? 'None'} ${pyFixed(m.majority, 2)} | ${o} | ${ar} |`
36
+ }
37
+
38
+ function listenLines(m: ShadowVoiceEntry, input: ShadowReportInput): string[] {
39
+ return m.listen.map(p => ` - ${m.voice}: \`${chunkFile(input.audioDir, p.chunkIndex)}\` ${pyFixed(p.inFileStart, 1)} to ${pyFixed(p.inFileEnd, 1)} s (call time ${p.callTime}) "${quote(input.chunkText.get(p.chunkIndex))}"`)
40
+ }
41
+
42
+ export function renderShadowReport(input: ShadowReportInput): string {
43
+ const P = input.proposals, F = input.facts
44
+ const L: string[] = [
45
+ `# ${input.title}`, '',
46
+ `**${P.productionVoices} voices would become ${P.voicesAfter}.** FluidAudio proposed ${P.proposedMerges} merges; ${P.acceptedMerges} pass every gate, ${P.rejectedMerges} do not. Shadow mode only: nothing was renamed or written.`, '',
47
+ F.unhashedWithText.length
48
+ ? `- Meeting: ${pyFixed(F.minutes, 1)} min, ${F.originals} originals (${F.verifiedAgainstSidecar} verified; ${F.unhashedOriginals} unhashed, of which ${F.unhashedWithText.length} have text: check them).`
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
+ `- FluidAudio found ${P.fluidAudioClusters} clusters. Labels never merged: ${P.frozenOtherLabels.join(', ') || 'none'}.`,
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 must be ≤1.5x against the kept voice\'s own spread; missing or degenerate evidence rejects a merge.',
53
+ '- How to check: play two or three pointers for each voice in a row. Same person? The merge is right.',
54
+ ...(F.humanProtectedVoices.length ? [`- A person already edited chunks holding ${F.humanProtectedVoices.join(', ')}. Those edits outrank any proposal here.`] : []),
55
+ `- Session \`${input.sessionId}\`.`, '',
56
+ '## Accepted proposals', '',
57
+ ]
58
+ if (!P.accepted.length) L.push('None.')
59
+ for (const a of P.accepted) {
60
+ L.push(`### ${a.cluster}: merge ${a.merge.join(', ')} into **${a.keepLabel}**`)
61
+ // Names first: Ext is speech no one was attributed to, not a label anyone gave.
62
+ const named = a.otherLabelsInCluster.filter(o => o.label !== 'Ext'), ext = a.otherLabelsInCluster.find(o => o.label === 'Ext')
63
+ const extText = ext ? `${pyFixed(ext.seconds, 0)} s of Ext speech (no voice label)` : ''
64
+ if (named.length) {
65
+ L.push(`Caution: this group also holds ${named.map(o => `${pyFixed(o.seconds, 0)} s labelled ${o.label}`).join(', ')}. Those labels stay as they are.${ext ? ` It also holds ${extText}.` : ''}`)
66
+ } else if (ext) {
67
+ L.push(`Note: this group also holds ${extText}.`)
68
+ }
69
+ const touched = a.voices.map(m => m.voice).filter(v => F.humanProtectedVoices.includes(v))
70
+ if (touched.length) L.push(`Human edit: ${touched.join(', ')} carries a person's correction on some chunks.`)
71
+ L.push('', ...HEADER, ...a.voices.map(shadowVoiceRow), '', 'Listen:')
72
+ for (const m of a.voices) L.push(...listenLines(m, input))
73
+ L.push('')
74
+ }
75
+ L.push('## Rejected proposals', '')
76
+ const rejected = P.rejected.filter(r => r.wouldJoin)
77
+ if (!rejected.length) L.push('None.')
78
+ for (const r of rejected) {
79
+ L.push(`- **${r.voice}** would have joined ${r.wouldJoin} on ${r.cluster}. Rejected: ${r.reasons.join('; ')}.`)
80
+ L.push('', HEADER[0], HEADER[1], shadowVoiceRow(r), '', ...listenLines(r, input), '')
81
+ }
82
+ L.push('## Untouched voices', '')
83
+ const kept = P.rejected.filter(r => !r.wouldJoin)
84
+ for (const r of kept) {
85
+ const fails = r.eligible ? '' : ` (it also fails: ${r.reasons.filter(x => !x.startsWith('no other')).join('; ')})`
86
+ L.push(`- ${r.voice} (${pyFixed(r.seconds, 1)} s): kept as is; it shares ${r.cluster} with other voices, but none of them passed every gate${fails}.`)
87
+ }
88
+ for (const u of P.untouched) L.push(`- ${u.voice} (${pyFixed(u.seconds, 1)} s): ${u.why}.`)
89
+ if (!kept.length && !P.untouched.length) L.push('None.')
90
+ L.push('')
91
+ return L.join('\n')
92
+ }
93
+
94
+ /** A meeting the run skipped or failed: one short page, so every finished meeting has a report. */
95
+ export function renderShadowSkipReport(input: { title: string; sessionId: string; status: 'skipped' | 'failed'; reason: string | null; detail: string | null }): string {
96
+ return [`# ${input.title}`, '', `**No proposals: ${input.status}** (\`${input.reason ?? 'unknown'}\`). ${input.detail ?? ''}`.trimEnd(), '', `- Session \`${input.sessionId}\`. Shadow mode only: nothing was renamed or written.`, ''].join('\n')
97
+ }
98
+
99
+ /** The run's own facts, appended by the runner (it alone knows wall time and memory). */
100
+ export function renderShadowRunFacts(input: { rebuildMs: number; nativeMs: number; wallMs: number; peakRssBytes: number | null; osTempDirsLeft: number; sidecarPath: string; pcmSHA256: string; cliTrust: string | null }): string {
101
+ return ['## Run facts', '',
102
+ `- Rebuild ${pyFixed(input.rebuildMs / 1000, 1)} s, diarization ${pyFixed(input.nativeMs / 1000, 1)} s, total ${pyFixed(input.wallMs / 1000, 1)} s. Peak memory ${input.peakRssBytes === null ? 'not sampled' : `${Math.round(input.peakRssBytes / 1048576)} MB`}.`,
103
+ `- fluidaudiocli trust: ${input.cliTrust ?? 'unknown'}. Network denied by sandbox-exec. Empty OS temp dirs left: ${input.osTempDirsLeft}.`,
104
+ `- Sidecar: \`${input.sidecarPath}\`. The timeline WAV was a temporary input and was deleted (PCM SHA ${input.pcmSHA256.slice(0, 12)}…).`, ''].join('\n')
105
+ }
106
+
107
+ /** The report's place: reports/<YYYY-MM-DD>/<title stem>__<session>.md, named like meeting-store names meetings. */
108
+ export function shadowReportRelativePath(title: string, sessionId: string, day: string): string {
109
+ const stem = title.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^[_-]+|[_-]+$/g, '').replace(/_{2,}/g, '_').slice(0, 72) || 'Mac_recording'
110
+ const safeDay = /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : 'undated'
111
+ // The whole session id: two meetings can share a title and a long id prefix.
112
+ return join('reports', safeDay, `${stem}__${sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 96)}.md`)
113
+ }