@gotcos/glasses-server 6.21.14 → 6.21.18

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.
@@ -0,0 +1,279 @@
1
+ // Rewriting a speaker label inside one meeting's files.
2
+ //
3
+ // Pure over strings: every function takes file content and returns file content,
4
+ // so each rule below is testable by execution rather than by reading it.
5
+ //
6
+ // THE CONSTRAINT THAT SHAPES THIS FILE. The markdown transcript is NOT an
7
+ // index-parallel rendering of the chunk sidecar. Measured on a real meeting
8
+ // (2026-08-06 Health Score V2): 135 sidecar chunks collapse to 46 speaker runs,
9
+ // while the markdown carries 70 turns — and they disagree on who spoke, with the
10
+ // markdown's 4th turn attributed to Joe Karbowski where the sidecar's 4th run
11
+ // says Richard Jenkins. The transcript was rendered from a different
12
+ // segmentation pass. So:
13
+ //
14
+ // * There is NO mapping from a chunk index to a transcript turn.
15
+ // * A relabel of EVERY chunk carrying a label can still be applied to the
16
+ // markdown, because "all X becomes Y" needs no index alignment.
17
+ // * A relabel of a SUBSET of chunks cannot touch the markdown transcript at
18
+ // all. Rewriting by label would relabel turns the human never selected.
19
+ //
20
+ // `coveredAllWithLabel` on the sidecar result is how the caller knows which case
21
+ // it is. It is returned as DATA rather than left as a rule to remember.
22
+ //
23
+ // AND WHAT IS NEVER REWRITTEN: the Summary / Topics / Decisions / Action Items
24
+ // prose. It refers to people by bare first name — 6 of 12 speakers did so on the
25
+ // meeting above — and this org has two Kyles, two Jacobuses and two Chrises, so
26
+ // a first-name substitution in narrative text would rewrite sentences about
27
+ // somebody else. Reported as `proseStale` instead.
28
+
29
+ /** A label that would corrupt the file formats it gets written into. */
30
+ export function invalidLabelReason(label: string): string | null {
31
+ if (label.trim() === '') return 'label is empty'
32
+ if (label !== label.trim()) return 'label has leading or trailing whitespace'
33
+ if (label.length > 120) return 'label is longer than 120 characters'
34
+ // `[Name]:` delimits transcript turns and `- Name` delimits attendees; a
35
+ // label containing these would make the file unparseable by its own readers.
36
+ if (/[\[\]\n\r]/.test(label)) return 'label contains a bracket or newline'
37
+ return null
38
+ }
39
+
40
+ function escapeRegExp(s: string): string {
41
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
42
+ }
43
+
44
+ export interface SidecarRelabelResult {
45
+ /** Serialized in the same shape the pipeline writes: 2-space indent, trailing newline. */
46
+ json: string
47
+ /** Chunk indices whose speaker actually changed. */
48
+ changed: number[]
49
+ /**
50
+ * True when no chunk still carries `from` after this relabel. ONLY then may the
51
+ * caller rewrite the markdown transcript by label. See the file header.
52
+ */
53
+ coveredAllWithLabel: boolean
54
+ /** The top-level `speakers` array after the relabel. */
55
+ speakers: string[]
56
+ /** Chunks still carrying `from` — non-zero for a deliberate partial relabel. */
57
+ remainingWithFrom: number
58
+ }
59
+
60
+ export type RelabelOutcome<T> = { ok: true; value: T } | { ok: false; error: string }
61
+
62
+ /**
63
+ * Relabel chunks in a `.g2-chunks.json` sidecar.
64
+ *
65
+ * `chunks` empty means every chunk carrying `from`. An explicit list restricts
66
+ * the change to those indices — the partial case, which exists because the
67
+ * identifier can mishear one stretch of a meeting without being wrong about the
68
+ * rest.
69
+ */
70
+ export function relabelSidecarJson(
71
+ raw: string,
72
+ from: string,
73
+ to: string,
74
+ chunks: number[] = [],
75
+ ): RelabelOutcome<SidecarRelabelResult> {
76
+ if (from === to) return { ok: false, error: 'from and to are the same label' }
77
+ for (const [which, label] of [['from', from], ['to', to]] as const) {
78
+ const bad = invalidLabelReason(label)
79
+ if (bad) return { ok: false, error: `${which}: ${bad}` }
80
+ }
81
+
82
+ let parsed: unknown
83
+ try {
84
+ parsed = JSON.parse(raw)
85
+ } catch {
86
+ return { ok: false, error: 'sidecar is not valid JSON' }
87
+ }
88
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
89
+ return { ok: false, error: 'sidecar is not an object' }
90
+ }
91
+ const doc = parsed as Record<string, unknown>
92
+ const rows = doc.chunks
93
+ if (!Array.isArray(rows)) return { ok: false, error: 'sidecar has no chunks array' }
94
+
95
+ const restrict = chunks.length > 0 ? new Set(chunks) : null
96
+ // A caller naming indices that do not carry `from` is confused about what it
97
+ // is correcting; failing loudly beats silently relabelling nothing.
98
+ if (restrict) {
99
+ const mismatched = chunks.filter(i => {
100
+ const row = rows[i]
101
+ return !row || typeof row !== 'object' || (row as Record<string, unknown>).speaker !== from
102
+ })
103
+ if (mismatched.length > 0) {
104
+ return { ok: false, error: `chunks do not carry "${from}": ${mismatched.slice(0, 8).join(', ')}` }
105
+ }
106
+ }
107
+
108
+ const changed: number[] = []
109
+ let remainingWithFrom = 0
110
+ rows.forEach((row, i) => {
111
+ // The `typeof` half guards the cast below rather than any behaviour — a
112
+ // truthy non-object can never satisfy `.speaker === from`, so it is
113
+ // deliberately not claimed as tested.
114
+ if (!row || typeof row !== 'object') return
115
+ const r = row as Record<string, unknown>
116
+ if (r.speaker !== from) return
117
+ if (restrict && !restrict.has(i)) { remainingWithFrom++; return }
118
+ r.speaker = to
119
+ changed.push(i)
120
+ })
121
+
122
+ if (changed.length === 0) return { ok: false, error: `no chunk carries "${from}"` }
123
+
124
+ // Keep the top-level speaker list truthful: it is what the review panel and
125
+ // the attendee renderer read, so a stale entry shows a person who is no longer
126
+ // attributed anything.
127
+ const existing = Array.isArray(doc.speakers) ? doc.speakers.filter((s): s is string => typeof s === 'string') : []
128
+ const speakers = [...existing]
129
+ if (!speakers.includes(to)) {
130
+ // Replace in place when `from` is being fully retired, so the list keeps its
131
+ // original ordering rather than moving the person to the end.
132
+ const at = speakers.indexOf(from)
133
+ if (at >= 0 && remainingWithFrom === 0) speakers[at] = to
134
+ else speakers.push(to)
135
+ } else if (remainingWithFrom === 0) {
136
+ // `to` already listed and `from` fully retired — merging a split identity.
137
+ // Drop the old entry rather than leaving a name nothing is attributed to.
138
+ const at = speakers.indexOf(from)
139
+ if (at >= 0) speakers.splice(at, 1)
140
+ }
141
+ doc.speakers = speakers
142
+
143
+ return {
144
+ ok: true,
145
+ value: {
146
+ json: `${JSON.stringify(doc, null, 2)}\n`,
147
+ changed,
148
+ coveredAllWithLabel: remainingWithFrom === 0,
149
+ speakers,
150
+ remainingWithFrom,
151
+ },
152
+ }
153
+ }
154
+
155
+ export interface MarkdownRelabelResult {
156
+ markdown: string
157
+ /** Attendee bullet lines changed or removed. */
158
+ attendees: number
159
+ /** `[Name]:` turn labels rewritten. */
160
+ transcript: number
161
+ /**
162
+ * True when the old label still appears in narrative prose, which is left
163
+ * untouched on purpose. The caller records this so a human can be told the
164
+ * summary predates their correction.
165
+ */
166
+ proseStale: boolean
167
+ /** The prose forms found, for an honest message rather than a bare boolean. */
168
+ proseHits: string[]
169
+ }
170
+
171
+ /** Byte range of a `## Heading` section, or null when absent. */
172
+ function sectionRange(md: string, heading: string): { start: number; end: number } | null {
173
+ const re = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'm')
174
+ const m = re.exec(md)
175
+ if (!m) return null
176
+ const start = m.index + m[0].length
177
+ const next = /^##\s+/m.exec(md.slice(start))
178
+ return { start, end: next ? start + next.index : md.length }
179
+ }
180
+
181
+ /**
182
+ * Rewrite the structured label positions in a meeting markdown file.
183
+ *
184
+ * ONLY sound when every chunk carrying `from` is being relabelled — this works
185
+ * by label, not by index, and the markdown's turn segmentation does not match
186
+ * the sidecar's. See the file header.
187
+ */
188
+ export function relabelMeetingMarkdown(
189
+ md: string,
190
+ from: string,
191
+ to: string,
192
+ options: { removeAttendee?: boolean } = {},
193
+ ): RelabelOutcome<MarkdownRelabelResult> {
194
+ if (from === to) return { ok: false, error: 'from and to are the same label' }
195
+ for (const [which, label] of [['from', from], ['to', to]] as const) {
196
+ const bad = invalidLabelReason(label)
197
+ if (bad) return { ok: false, error: `${which}: ${bad}` }
198
+ }
199
+
200
+ let out = md
201
+ let attendees = 0
202
+ let transcript = 0
203
+
204
+ // --- Attendees: exact bullet lines only, inside the Attendees section only.
205
+ const att = sectionRange(out, 'Attendees')
206
+ if (att) {
207
+ const body = out.slice(att.start, att.end)
208
+ // Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
209
+ // run past its own line. Without the end anchor, `- Luke H` matches the
210
+ // PREFIX of `- Luke Henry` and deleting it leaves the fragment `enry`.
211
+ const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
212
+ // A de-attribution must DELETE the attendee bullet, never rename it. Renaming
213
+ // writes `- Unidentified 2` into the attendee list as though it were a
214
+ // person, and the COS pipeline that BUILDS attendees deliberately excludes
215
+ // unidentified labels (sync_meetings.py filters them) while every reader —
216
+ // parseAttendees, extractAttendees, the Python prep generators — takes the
217
+ // bullet at face value. So a rename here injects a phantom attendee that
218
+ // downstream treats as real.
219
+ const alreadyListed = options.removeAttendee || new RegExp(line(to), 'm').test(body)
220
+ let rewritten: string
221
+ if (alreadyListed) {
222
+ // Both names present: drop the old bullet instead of creating a duplicate
223
+ // attendee. This is the normal case when merging a split identity.
224
+ rewritten = body.replace(new RegExp(`${line(from)}\\n?`, 'gm'), () => { attendees++; return '' })
225
+ } else {
226
+ rewritten = body.replace(new RegExp(line(from), 'gm'), () => { attendees++; return `- ${to}` })
227
+ }
228
+ out = out.slice(0, att.start) + rewritten + out.slice(att.end)
229
+ }
230
+
231
+ // --- Transcript: `[Name]:` at line start only. A name appearing inside spoken
232
+ // text is a quote, not a label, and must not be touched.
233
+ const tr = sectionRange(out, 'Transcript')
234
+ if (tr) {
235
+ const body = out.slice(tr.start, tr.end)
236
+ const rewritten = body.replace(
237
+ new RegExp(`^\\[${escapeRegExp(from)}\\]:`, 'gm'),
238
+ () => { transcript++; return `[${to}]:` },
239
+ )
240
+ out = out.slice(0, tr.start) + rewritten + out.slice(tr.end)
241
+ }
242
+
243
+ // --- Prose: detect, never rewrite.
244
+ const proseHits = detectProseReferences(out, from)
245
+
246
+ return {
247
+ ok: true,
248
+ value: { markdown: out, attendees, transcript, proseStale: proseHits.length > 0, proseHits },
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Forms of `label` still present in narrative prose.
254
+ *
255
+ * Searches everything except the attendee list and the transcript — the
256
+ * transcript's body is spoken words, and a name said aloud is a quote rather
257
+ * than a stale attribution.
258
+ */
259
+ export function detectProseReferences(md: string, label: string): string[] {
260
+ const ranges = [sectionRange(md, 'Attendees'), sectionRange(md, 'Transcript')].filter(
261
+ (r): r is { start: number; end: number } => r !== null,
262
+ )
263
+ let prose = ''
264
+ let cursor = 0
265
+ for (const r of ranges.sort((a, b) => a.start - b.start)) {
266
+ prose += md.slice(cursor, r.start)
267
+ cursor = Math.max(cursor, r.end)
268
+ }
269
+ prose += md.slice(cursor)
270
+
271
+ const hits: string[] = []
272
+ const full = new RegExp(`\\b${escapeRegExp(label)}\\b`)
273
+ if (full.test(prose)) hits.push(label)
274
+ const first = label.split(/\s+/)[0]
275
+ // Only a multi-word label has a distinct bare-first-name form. 'MU' or 'Ext'
276
+ // would otherwise be reported twice for the same match.
277
+ if (first && first !== label && new RegExp(`\\b${escapeRegExp(first)}\\b`).test(prose)) hits.push(first)
278
+ return hits
279
+ }
@@ -26,6 +26,19 @@
26
26
  export interface ReviewChunk {
27
27
  text?: string
28
28
  speaker?: string
29
+ /**
30
+ * RAW capture index — the number in `chunk_NNNN.wav`, NOT this chunk's position
31
+ * in the array.
32
+ *
33
+ * These differ and the gap grows through a meeting. Measured on the 2026-08-06
34
+ * Ditto sidecar: 885 compacted chunks against raw indices 0..945 with 36 gaps,
35
+ * so array position 884 is really raw chunk 940 — a 56-chunk error, minutes of
36
+ * audio. `chunks` is filtered to text-bearing entries (transcribe-stream's
37
+ * getSessionChunks) while the WAV is written for EVERY received chunk before
38
+ * ASR, so the position can never address the audio. Populated from the
39
+ * sidecar's `chunkEntries`, which exists precisely to preserve these.
40
+ */
41
+ chunkIndex?: number
29
42
  /** MILLISECONDS from meeting start. Confirmed against the writer:
30
43
  * meeting.ts accumulates `elapsed += row.durationMs`. Treating this as
31
44
  * seconds reports a 32-minute meeting as 30,936 minutes. */
@@ -36,6 +49,24 @@ export interface ReviewChunk {
36
49
  /** Labels that mean "nobody was identified", not a person. */
37
50
  export const UNATTRIBUTED = new Set(['Unknown', 'Ext', '', 'Speaker 1', 'Speaker 2', 'Speaker 3'])
38
51
 
52
+ /**
53
+ * Prefix for a voice a human de-attributed, numbered so distinct people stay
54
+ * distinct.
55
+ *
56
+ * De-attributing to a single shared `Ext` folded every corrected voice into one
57
+ * row: on the 2026-08-06 Ditto meeting Miles named five wrong attributions, and
58
+ * collapsing them would have destroyed his ability to tell those five voices
59
+ * apart afterwards — which is exactly what he then needs playback for. Numbering
60
+ * keeps them separable while asserting no identity.
61
+ */
62
+ export const DEATTRIBUTED_PREFIX = 'Unidentified'
63
+
64
+ /** True when a label asserts no identity — the exact set, or a numbered
65
+ * de-attribution. Prefix-aware so `Unidentified 3` is treated as unnamed. */
66
+ export function isUnattributed(label: string): boolean {
67
+ return UNATTRIBUTED.has(label) || new RegExp(`^${DEATTRIBUTED_PREFIX} \\d+$`).test(label)
68
+ }
69
+
39
70
  /** Calibrated from the control pair above. A pair must be BOTH flip-happy and
40
71
  * short-run to be called unreliable — either alone has honest explanations
41
72
  * (a rapid-fire exchange is flip-happy; a brief interjection is short-run). */
@@ -44,6 +75,27 @@ export const THRASH_MEAN_RUN = 8
44
75
  /** Below the search-accept threshold a name was never asserted with confidence. */
45
76
  export const CONFIDENT_SIMILARITY = 0.65
46
77
 
78
+ /**
79
+ * FLOOR FOR PRESENTING A NAME AT ALL.
80
+ *
81
+ * The identifier accepts a match at SEARCH_THRESHOLD = 0.55, so a single segment
82
+ * scoring 0.55 currently arrives in the panel wearing somebody's full name. On
83
+ * Miles's 2026-08-06 Ditto meeting that produced Richard Jenkins (1 segment,
84
+ * 0.60), Luke Henry (1 segment, 0.55), Dylan Jackson (2 segments, 0.58) and
85
+ * Navaz Sharif (3 segments, 0.58) — and he confirmed none of them were in the
86
+ * room. Presenting those as names is the defect; the reviewer then has to undo
87
+ * an assertion the system should never have made.
88
+ *
89
+ * Standing rule this enforces: speaker identity is a SUGGESTION, never an
90
+ * assertion. Below the floor the row is "unidentified" and the label survives
91
+ * only as a scored candidate.
92
+ *
93
+ * A floor cannot catch everything — a wrong match can still score well — so this
94
+ * removes obvious noise rather than guaranteeing correctness.
95
+ */
96
+ export const ASSERT_MIN_SIMILARITY = CONFIDENT_SIMILARITY
97
+ export const ASSERT_MIN_SEGMENTS = 3
98
+
47
99
  export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
48
100
 
49
101
  export interface ThrashPair {
@@ -58,6 +110,13 @@ export interface Phrase {
58
110
  /** Milliseconds from meeting start, matching the sidecar. */
59
111
  atMs: number
60
112
  similarity: number | null
113
+ /**
114
+ * Raw capture index for playback, or null when the sidecar cannot supply one
115
+ * (pre-`chunkEntries` captures). Null means "do not offer playback" — a
116
+ * guessed index plays somebody else's voice, which is worse than no button
117
+ * on a screen whose whole purpose is confirming identity.
118
+ */
119
+ chunkIndex: number | null
61
120
  }
62
121
 
63
122
  export interface VoiceReview {
@@ -72,22 +131,138 @@ export interface VoiceReview {
72
131
  longestRun: number
73
132
  isOwner: boolean
74
133
  reliability: Reliability
134
+ /**
135
+ * Whether `label` may be shown to a human AS A NAME.
136
+ *
137
+ * False means the UI must render the row as unidentified and offer `label`
138
+ * only as a scored candidate. Carried as data rather than left to each client
139
+ * to re-derive, so the phone, the lens and Control cannot disagree about
140
+ * whether a name was earned.
141
+ */
142
+ nameAsserted: boolean
143
+ /** Why the name is not asserted — so a UI can explain rather than just hide. */
144
+ assertionBlockers: string[]
75
145
  thrashesWith: ThrashPair[]
76
146
  phrases: Phrase[]
77
147
  }
78
148
 
149
+ /** One stretch of the meeting held by a single label. */
150
+ export interface TimelineSpan {
151
+ /** The label as stored. Whether it may be SHOWN as a name is still governed by
152
+ * the matching voice row's `nameAsserted` — a span is not a second opinion. */
153
+ speaker: string
154
+ /** Milliseconds from meeting start. `elapsed` on a chunk is its START offset:
155
+ * meeting.ts assigns `elapsed` and only then does `elapsed += durationMs`. */
156
+ startMs: number
157
+ endMs: number
158
+ segments: number
159
+ }
160
+
161
+ /**
162
+ * Collapse the chunk sequence into consecutive same-speaker spans.
163
+ *
164
+ * This exists because the ribbon needs a TIME axis. The previous ribbon drew one
165
+ * rectangle per voice sized by share of segments and labelled itself "who spoke,
166
+ * in order" — there was no ordering in it at all, so hovering could not report
167
+ * anything true.
168
+ *
169
+ * Non-monotonic or missing `elapsed` values are carried forward rather than
170
+ * trusted: a span with a negative width would render as an invisible or
171
+ * inverted block, which is worse than a slightly wrong boundary.
172
+ */
173
+ export function speakerTimeline(chunks: ReviewChunk[], durationMs: number): TimelineSpan[] {
174
+ const spans: TimelineSpan[] = []
175
+ let cursor = 0
176
+ for (const c of chunks) {
177
+ const label = c.speaker ?? ''
178
+ const raw = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : 0
179
+ // ONE clamp does all three jobs, because `cursor` is monotonic and never
180
+ // negative: it recovers a missing value, a negative value, and a value that
181
+ // goes backwards. Mutation showed the extra Math.max(0, raw) and the
182
+ // `: cursor` fallback were both unreachable behind it.
183
+ const startMs = Math.max(cursor, raw)
184
+ cursor = startMs
185
+ const last = spans[spans.length - 1]
186
+ if (last && last.speaker === label) {
187
+ last.segments++
188
+ } else {
189
+ spans.push({ speaker: label, startMs, endMs: startMs, segments: 1 })
190
+ }
191
+ }
192
+ // Each span ends where the next begins. The LAST one is the problem: `elapsed`
193
+ // is a start offset, and on real sidecars `durationMs` frequently equals the
194
+ // final chunk's start exactly (measured on 2026-08-06 Ditto: both 5,783,732),
195
+ // so taking the meeting end verbatim leaves the closing turn zero-width — a
196
+ // 1.5pt sliver for what may be a long monologue.
197
+ //
198
+ // The tail gets ONE TYPICAL CHUNK of width, derived from the median gap between
199
+ // this meeting's own chunk starts. That is measured from the data rather than
200
+ // invented, and it is the shortest defensible non-zero answer.
201
+ const gaps: number[] = []
202
+ for (let i = 1; i < spans.length; i++) {
203
+ const d = spans[i].startMs - spans[i - 1].startMs
204
+ if (d > 0) gaps.push(d)
205
+ }
206
+ gaps.sort((a, b) => a - b)
207
+ const typicalGap = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0
208
+ for (let i = 0; i < spans.length; i++) {
209
+ const next = spans[i + 1]
210
+ if (next) {
211
+ spans[i].endMs = Math.max(spans[i].startMs, next.startMs)
212
+ } else if (durationMs > spans[i].startMs) {
213
+ // A real meeting end, later than this span's start: use it verbatim.
214
+ spans[i].endMs = durationMs
215
+ } else {
216
+ // durationMs is absent, or equals the final chunk's start (the common real
217
+ // case). Fall back to one typical chunk so the closing turn has width.
218
+ spans[i].endMs = spans[i].startMs + typicalGap
219
+ }
220
+ }
221
+ return spans
222
+ }
223
+
79
224
  export interface MeetingSpeakerReview {
80
225
  segments: number
81
226
  /** False when no chunk carries a real speaker — a recovered capture. */
82
227
  attributed: boolean
83
228
  durationMs: number
84
229
  voices: VoiceReview[]
230
+ /** Chronological spans, so a ribbon can be a timeline instead of a share bar. */
231
+ timeline: TimelineSpan[]
85
232
  }
86
233
 
87
234
  function mean(xs: number[]): number {
88
235
  return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
89
236
  }
90
237
 
238
+ /**
239
+ * Attach raw capture indices to a compacted chunk array.
240
+ *
241
+ * The i-th compacted chunk is the i-th TEXT-BEARING `chunkEntries` row, whose
242
+ * `chunkIndex` is the raw WAV number. Verified against every 2026-07/08 sidecar
243
+ * that carries chunkEntries: the text-bearing count always equals the compacted
244
+ * count and the text lines up positionally.
245
+ *
246
+ * Returns the chunks UNCHANGED when the counts disagree or chunkEntries is
247
+ * absent. A partial or shifted mapping is worse than none: it would silently
248
+ * point playback at a neighbouring speaker, and this screen exists to confirm
249
+ * identity.
250
+ */
251
+ export function attachRawChunkIndices(chunks: ReviewChunk[], chunkEntries: unknown): ReviewChunk[] {
252
+ if (!Array.isArray(chunkEntries)) return chunks
253
+ const textBearing = chunkEntries.filter(e => {
254
+ const chunk = (e as { chunk?: { text?: unknown } } | null)?.chunk
255
+ return typeof chunk?.text === 'string' && chunk.text.trim() !== ''
256
+ })
257
+ if (textBearing.length !== chunks.length) return chunks
258
+ return chunks.map((c, i) => {
259
+ const raw = (textBearing[i] as { chunkIndex?: unknown }).chunkIndex
260
+ return typeof raw === 'number' && Number.isInteger(raw) && raw >= 0
261
+ ? { ...c, chunkIndex: raw }
262
+ : c
263
+ })
264
+ }
265
+
91
266
  /** Consecutive-run lengths for one speaker across the whole meeting. */
92
267
  export function speakerRuns(sequence: string[], speaker: string): number[] {
93
268
  const runs: number[] = []
@@ -179,6 +354,7 @@ export function selectPhrases(
179
354
  text: (c.text ?? '').trim(),
180
355
  atMs: typeof c.elapsed === 'number' ? c.elapsed : 0,
181
356
  similarity: typeof c.similarity === 'number' ? c.similarity : null,
357
+ chunkIndex: typeof c.chunkIndex === 'number' ? c.chunkIndex : null,
182
358
  score: phraseScore(c.text ?? ''),
183
359
  }))
184
360
  .filter(p => p.score > 0)
@@ -203,27 +379,34 @@ export function selectPhrases(
203
379
  }
204
380
  return picked
205
381
  .sort((a, b) => a.atMs - b.atMs)
206
- .map(({ text, atMs, similarity }) => ({ text, atMs, similarity }))
382
+ .map(({ text, atMs, similarity, chunkIndex }) => ({ text, atMs, similarity, chunkIndex }))
207
383
  }
208
384
 
209
385
  /** Build the whole review for one meeting's chunks. */
210
386
  export function reviewMeetingSpeakers(
211
387
  chunks: ReviewChunk[],
212
- options: { owner?: string; phrasesPerVoice?: number } = {},
388
+ options: { owner?: string; phrasesPerVoice?: number; durationMs?: number } = {},
213
389
  ): MeetingSpeakerReview {
214
390
  const owner = options.owner ?? 'Me'
215
391
  const limit = options.phrasesPerVoice ?? 3
216
392
  const sequence = chunks.map(c => c.speaker ?? '')
217
- const durationMs = chunks.reduce((max, c) => Math.max(max, typeof c.elapsed === 'number' ? c.elapsed : 0), 0)
393
+ // The caller's durationMs (the sidecar's own) is the meeting's true end.
394
+ // Falling back to max(elapsed) uses the START of the last chunk, which makes
395
+ // the final timeline span zero-width — so prefer the real value and only
396
+ // derive when it is absent.
397
+ const lastStart = chunks.reduce((max, c) => Math.max(max, typeof c.elapsed === 'number' ? c.elapsed : 0), 0)
398
+ const durationMs = typeof options.durationMs === 'number' && options.durationMs > lastStart
399
+ ? options.durationMs
400
+ : lastStart
218
401
 
219
402
  const labels = [...new Set(sequence)].filter(s => s.length > 0)
220
- const named = labels.filter(l => !UNATTRIBUTED.has(l))
403
+ const named = labels.filter(l => !isUnattributed(l))
221
404
 
222
405
  const voices: VoiceReview[] = labels.map(label => {
223
406
  const own = chunks.filter(c => (c.speaker ?? '') === label)
224
407
  const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
225
408
  const runs = speakerRuns(sequence, label)
226
- const unattributed = UNATTRIBUTED.has(label)
409
+ const unattributed = isUnattributed(label)
227
410
 
228
411
  const thrashesWith: ThrashPair[] = []
229
412
  if (!unattributed) {
@@ -250,6 +433,32 @@ export function reviewMeetingSpeakers(
250
433
  ? 'unreliable'
251
434
  : (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
252
435
 
436
+ // What stops this label being presented as a name. Collected as reasons
437
+ // rather than a bare boolean: "2 segments" and "similarity 0.58" are
438
+ // different problems and a reviewer deserves to see which one applies.
439
+ const assertionBlockers: string[] = []
440
+ if (unattributed) {
441
+ assertionBlockers.push('no name was ever assigned to this voice')
442
+ } else if (label === owner) {
443
+ // The wearer is exempt. Their identity is established by wearing the
444
+ // device, not by cosine — and the owner is verified at exactly this floor
445
+ // (VERIFY_THRESHOLD 0.65), so they sit permanently on the boundary and any
446
+ // thrash pair flips them. Measured across the 2026-08-06 corpus: the owner
447
+ // row read "Unidentified voice" in 4 of 9 meetings, including one with 285
448
+ // of their own segments. `thrashesWith` still renders, so a mixed row is
449
+ // still visible — the name is asserted, the caveat is not hidden.
450
+ } else {
451
+ if (own.length < ASSERT_MIN_SEGMENTS) {
452
+ assertionBlockers.push(`only ${own.length} segment${own.length === 1 ? '' : 's'} (needs ${ASSERT_MIN_SEGMENTS})`)
453
+ }
454
+ if ((meanSim ?? 0) < ASSERT_MIN_SIMILARITY) {
455
+ assertionBlockers.push(`similarity ${(meanSim ?? 0).toFixed(2)} below ${ASSERT_MIN_SIMILARITY}`)
456
+ }
457
+ if (thrashesWith.length > 0) {
458
+ assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
459
+ }
460
+ }
461
+
253
462
  return {
254
463
  label,
255
464
  segments: own.length,
@@ -258,11 +467,19 @@ export function reviewMeetingSpeakers(
258
467
  longestRun: runs.length ? Math.max(...runs) : 0,
259
468
  isOwner: label === owner,
260
469
  reliability,
470
+ nameAsserted: assertionBlockers.length === 0,
471
+ assertionBlockers,
261
472
  thrashesWith,
262
473
  phrases: selectPhrases(chunks, label, limit, durationMs),
263
474
  }
264
475
  })
265
476
 
266
477
  voices.sort((a, b) => b.segments - a.segments)
267
- return { segments: chunks.length, attributed: named.length > 0, durationMs, voices }
478
+ return {
479
+ segments: chunks.length,
480
+ attributed: named.length > 0,
481
+ durationMs,
482
+ voices,
483
+ timeline: speakerTimeline(chunks, durationMs),
484
+ }
268
485
  }