@gotcos/glasses-server 6.21.17 → 6.21.19

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,95 @@
1
+ ## 6.21.19
2
+
3
+ - Review playback falls back to ext-audio. The 7-day archive introduced in
4
+ 6.21.18 is FORWARD-ONLY — it starts filling when a meeting is saved under that
5
+ version — so on upgrade day the panel had no play buttons at all, which is what
6
+ Miles hit. ext-audio already holds 72 hours of unrecognised-speaker audio keyed
7
+ by the same raw capture index; measured across 14 real meetings, 90-100% of
8
+ those files correspond to a chunk the sidecar labels `Ext`. That is exactly the
9
+ set a reviewer most needs to hear.
10
+ - `GET /meeting/:id/audio` merges both sources and reports `archivedChunks` and
11
+ `extAudioChunks` separately, because the windows differ (7 days vs 72 hours)
12
+ and a single retention figure would be wrong for half the list.
13
+ - Published under its own version rather than re-cutting 6.21.18: that version is
14
+ already on npm, and two different artifacts sharing a version number is a
15
+ defect in its own right.
16
+
17
+ ## 6.21.18
18
+
19
+ Everything a human needs to correct who spoke, and to hear the voice before
20
+ deciding. Four changes that landed together after Miles reviewed the 2026-08-06
21
+ Ditto meeting and found eleven attributed voices, most of them wrong.
22
+
23
+ - A NAME MUST BE EARNED. `identifySpeaker` accepts a match at 0.55, so one
24
+ segment could arrive in the review panel wearing somebody's full name — Richard
25
+ Jenkins on 1 segment at 0.60, Luke Henry on 1 at 0.55. Voices now carry
26
+ `nameAsserted` plus `assertionBlockers`; below the floor a client must render
27
+ "unidentified" and offer the label only as a scored candidate. Floors:
28
+ similarity >= 0.65 AND >= 3 segments AND not thrashing. The owner gets no
29
+ exemption.
30
+ - `timeline` ON THE REVIEW. Consecutive same-speaker spans with start/end, so a
31
+ ribbon can be an actual timeline. The previous one drew a rectangle per voice
32
+ sized by share of segments while labelled "who spoke, in order", so a voice
33
+ that spoke twice appeared once and hover could report nothing true.
34
+ - POST /meeting/:id/deattribute — "this voice was NOT that person". Un-attributes
35
+ it for ONE meeting and retracts the training samples that meeting contributed
36
+ to that person's profile, so a false attribution stops reinforcing itself. It
37
+ reports what it cannot reach: `train-g2` used to stamp a bare `g2-training`,
38
+ discarding which meeting each sample came from, and now stamps
39
+ `g2-training:<sessionId>`. Samples written before this are relabellable but not
40
+ retractable.
41
+ - MEETING AUDIO KEPT 7 DAYS, 8 GB budget (Miles's call: review should survive to
42
+ a weekend). Chunk WAVs previously died with the batch pipeline —
43
+ `session-audio` held 0 files. Hard-linked at the single choke point before the
44
+ rename, so it costs no extra disk and outlives the pipeline's cleanup. Sized on
45
+ measurement: 3.1 h/day mean and 6.9 h peak make a week ~2.5 GB uncompressed, so
46
+ the audio stays usable for re-transcription too. Retention sweeps expiry first,
47
+ then evicts oldest whole sessions; a session whose age cannot be read is kept.
48
+ - Playback: `GET /voice/profiles/:name/sample` (needs no retention change — the
49
+ training audio already existed, and playing a stored profile is what answers
50
+ "is this really them"), `GET /voice/ext-audio/:id/sample`, and
51
+ `GET /meeting/:id/audio/:chunk` which distinguishes "the window passed" from
52
+ "that chunk is missing" and reports which chunks are playable.
53
+ - `/api/health` gains `review_audio`. `voice_provenance` from 6.21.17 already
54
+ reports `noHumanSample`; on the live store that is 67 of 77 profiles.
55
+
56
+ FIXES FOUND BY QA BEFORE PUBLISH (nothing above ever shipped):
57
+
58
+ - PLAYBACK PLAYS THE RIGHT AUDIO. `Phrase` now carries the RAW capture index,
59
+ resolved from the sidecar's `chunkEntries`. The compacted `chunks` array and
60
+ the `chunk_NNNN.wav` numbering are different sequences: on the 2026-08-06 Ditto
61
+ sidecar, 885 compacted chunks against raw indices 0..945 with 36 gaps, so array
62
+ position 884 is raw chunk 940. Playing by position would have played a
63
+ different speaker minutes earlier — on the one screen whose job is confirming
64
+ who spoke. When the counts disagree no index is emitted at all, because a
65
+ shifted index is worse than no button.
66
+ - THE OWNER IS EXEMPT FROM THE NAME FLOOR. The wearer is verified at exactly the
67
+ floor (VERIFY_THRESHOLD 0.65), so they sat permanently on the boundary and any
68
+ thrash pair flipped them: measured across the 2026-08-06 corpus, the owner row
69
+ read "Unidentified voice" in 4 of 9 meetings, once with 285 of their own
70
+ segments. `thrashesWith` still renders, so a mixed row is still visible.
71
+ - DE-ATTRIBUTION NUMBERS ITS LABELS. `Unidentified 1`, `Unidentified 2`, … rather
72
+ than one shared `Ext`. Miles found five wrong attributions in one meeting;
73
+ collapsing them into a single row would have destroyed his ability to tell
74
+ those voices apart, which is exactly what playback is for next.
75
+ - DE-ATTRIBUTION DELETES THE ATTENDEE BULLET instead of renaming it. Renaming
76
+ wrote an unidentified label into the attendee list as though it were a person,
77
+ and every downstream reader takes that bullet at face value while the pipeline
78
+ that BUILDS attendees deliberately excludes such labels.
79
+ - The timeline's LAST SPAN had zero width. `durationMs` frequently equals the
80
+ final chunk's start exactly (both 5,783,732 on the Ditto sidecar), so the
81
+ closing turn rendered as a sliver. The tail now takes one typical chunk of
82
+ width from the meeting's own median gap — measured 7,034 ms where it was 0.
83
+ - De-attribute reports `markdownSkipped`, which relabel already did and it
84
+ silently dropped. 39% of operations sidecars have no `.md` beside them, so the
85
+ response claimed segment changes with no hint the document was untouched.
86
+ - `/api/health` no longer statSyncs every retained chunk on every poll. COS
87
+ Control polls every 12s and a 7-day window is 2,000-3,000 files; stats are now
88
+ cached for 30s and invalidated when retention actually removes something. The
89
+ `/audio` listing reads the retention window as config rather than walking disk.
90
+
91
+ Requires COS Control 0.5.0+ to use the scoped correction and playback surfaces.
92
+
1
93
  ## 6.21.17
2
94
 
3
95
  - Voice profiles now give up their WEAKEST sample, not their oldest. Eviction is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.17",
3
+ "version": "6.21.19",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,364 @@
1
+ // Meeting audio kept long enough for a human to review who was speaking.
2
+ //
3
+ // WHY IT DID NOT EXIST. Chunk WAVs lived in `session-audio` only until a meeting
4
+ // was saved, then moved to `pending-batch` for HQ re-transcription and deleted
5
+ // when that finished. Measured 2026-08-06: `session-audio` held 0 files. So the
6
+ // review panel could show a phrase but never let anyone HEAR it, and its own
7
+ // copy said "naming this needs its audio, which is no longer held."
8
+ //
9
+ // Miles's decision: keep a week, so review can happen on a weekend rather than
10
+ // only within hours of the meeting, and stay under 8 GB.
11
+ //
12
+ // SIZING IS MEASURED, NOT GUESSED. Real recording volume over the 14 days to
13
+ // 2026-08-06 was 3.1 h/day mean, 6.9 h peak. A 7-day window is ~22 hours, which
14
+ // at 16 kHz mono 16-bit (32 KB/s) is ~2.5 GB — comfortably inside 8 GB. So the
15
+ // audio is kept UNCOMPRESSED and stays usable for re-transcription, not just
16
+ // playback. The cap is a runaway backstop: it would take a sustained 10 h/day
17
+ // week to reach it.
18
+ //
19
+ // HARD LINKS, NOT COPIES. Archiving happens at the moment audio moves to
20
+ // `pending-batch`, and links the same inodes rather than duplicating them. A
21
+ // copy would double disk for the whole batch window — 260 MB for a two-hour
22
+ // meeting — and introduce an ordering hazard against the batch purge. With links
23
+ // the pipeline can delete its directory whenever it likes and the bytes survive.
24
+
25
+ import {
26
+ copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, statSync,
27
+ } from 'node:fs'
28
+ import { join, resolve } from 'node:path'
29
+ import { dataPath } from './data-dir.js'
30
+
31
+ export const MEETING_AUDIO_DIR = 'meeting-audio'
32
+
33
+ /** One week, per Miles: review should survive until a weekend. */
34
+ export function meetingAudioTtlMs(): number {
35
+ const raw = Number(process.env.COS_MEETING_AUDIO_RETENTION_DAYS)
36
+ const days = Number.isFinite(raw) && raw > 0 ? raw : 7
37
+ return days * 24 * 60 * 60 * 1000
38
+ }
39
+
40
+ /** Total budget for retained meeting audio. Miles: stay under 8 GB. */
41
+ export function meetingAudioMaxBytes(): number {
42
+ const raw = Number(process.env.COS_MEETING_AUDIO_MAX_BYTES)
43
+ return Number.isFinite(raw) && raw > 0 ? raw : 8 * 1024 * 1024 * 1024
44
+ }
45
+
46
+ /** Master switch. Default ON — with it off, review has no audio at all. */
47
+ export function meetingAudioEnabled(): boolean {
48
+ return process.env.COS_MEETING_AUDIO !== '0'
49
+ }
50
+
51
+ function sessionDir(sessionId: string): string | null {
52
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
53
+ const root = dataPath(MEETING_AUDIO_DIR)
54
+ const path = join(root, sessionId.replace(/:/g, '_'))
55
+ return resolve(path).startsWith(resolve(root) + '/') ? path : null
56
+ }
57
+
58
+ /** Chunk WAVs as written by the capture path (`chunk_0000.wav`). */
59
+ function isChunkWav(name: string): boolean {
60
+ return /^chunk_\d+\.wav$/.test(name)
61
+ }
62
+
63
+ export interface ArchiveResult {
64
+ linked: number
65
+ /** Files that had to be copied because the link failed (e.g. cross-device). */
66
+ copied: number
67
+ failed: number
68
+ bytes: number
69
+ }
70
+
71
+ /**
72
+ * Bring one session's chunk WAVs into the archive.
73
+ *
74
+ * Never throws: this runs on the save path, and losing review audio must never
75
+ * cost a meeting. Individual failures are counted rather than aborting the rest,
76
+ * because a partially-archived meeting is still partially reviewable.
77
+ */
78
+ export function archiveSessionAudio(sessionId: string, sourceDir: string): ArchiveResult {
79
+ const out: ArchiveResult = { linked: 0, copied: 0, failed: 0, bytes: 0 }
80
+ if (!meetingAudioEnabled()) return out
81
+ const dest = sessionDir(sessionId)
82
+ if (!dest || !existsSync(sourceDir)) return out
83
+ let names: string[]
84
+ try {
85
+ names = readdirSync(sourceDir).filter(isChunkWav)
86
+ } catch {
87
+ return out
88
+ }
89
+ if (names.length === 0) return out
90
+ try {
91
+ mkdirSync(dest, { recursive: true, mode: 0o700 })
92
+ } catch {
93
+ return out
94
+ }
95
+ for (const name of names) {
96
+ const from = resolve(sourceDir, name)
97
+ const to = resolve(dest, name)
98
+ if (existsSync(to)) continue
99
+ try {
100
+ linkSync(from, to)
101
+ out.linked++
102
+ } catch {
103
+ // A link can fail across filesystems or if the source vanished mid-sweep.
104
+ // Copy rather than skip: the point is that the audio survives.
105
+ try { copyFileSync(from, to); out.copied++ } catch { out.failed++; continue }
106
+ }
107
+ try { out.bytes += statSync(to).size } catch { /* counted as linked regardless */ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ /** Bytes and age for one archived session. */
113
+ function sessionSize(dir: string): { bytes: number; mtimeMs: number; files: number } {
114
+ let bytes = 0, mtimeMs = 0, files = 0
115
+ try {
116
+ for (const name of readdirSync(dir).filter(isChunkWav)) {
117
+ try {
118
+ const st = statSync(join(dir, name))
119
+ bytes += st.size
120
+ files++
121
+ mtimeMs = Math.max(mtimeMs, st.mtimeMs)
122
+ } catch { /* skip unreadable */ }
123
+ }
124
+ } catch { /* unreadable dir reports zero */ }
125
+ return { bytes, mtimeMs, files }
126
+ }
127
+
128
+ export interface SweepResult {
129
+ removed: string[]
130
+ retained: string[]
131
+ bytesFreed: number
132
+ }
133
+
134
+ /**
135
+ * Drop sessions past the retention window.
136
+ *
137
+ * A session whose age cannot be read is RETAINED — treating an unreadable stat
138
+ * as ancient would delete the audio a pending review depends on.
139
+ */
140
+ export function sweepMeetingAudio(nowMs: number, ttlMs = meetingAudioTtlMs()): SweepResult {
141
+ const root = dataPath(MEETING_AUDIO_DIR)
142
+ const out: SweepResult = { removed: [], retained: [], bytesFreed: 0 }
143
+ if (!existsSync(root)) return out
144
+ let names: string[]
145
+ try { names = readdirSync(root) } catch { return out }
146
+ for (const name of names) {
147
+ const dir = join(root, name)
148
+ const { bytes, mtimeMs } = sessionSize(dir)
149
+ if (mtimeMs <= 0) { out.retained.push(name); continue }
150
+ if (nowMs - mtimeMs > ttlMs) {
151
+ try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
152
+ catch { out.retained.push(name) }
153
+ } else {
154
+ out.retained.push(name)
155
+ }
156
+ }
157
+ return out
158
+ }
159
+
160
+ export interface CapResult {
161
+ evicted: string[]
162
+ bytesBefore: number
163
+ bytesAfter: number
164
+ }
165
+
166
+ /**
167
+ * Evict OLDEST SESSIONS FIRST until the archive fits its budget.
168
+ *
169
+ * Whole sessions, not individual chunks: half a meeting's audio is a confusing
170
+ * artefact, and predictable eviction beats squeezing in a few more megabytes.
171
+ *
172
+ * Note on accounting: while `pending-batch` still holds the same inodes, these
173
+ * hard-linked files are counted at full size in BOTH places, so the total reads
174
+ * high during the batch window. That is deliberately conservative — it can sweep
175
+ * slightly early, never late.
176
+ */
177
+ export function enforceMeetingAudioCap(maxBytes = meetingAudioMaxBytes()): CapResult {
178
+ const root = dataPath(MEETING_AUDIO_DIR)
179
+ const out: CapResult = { evicted: [], bytesBefore: 0, bytesAfter: 0 }
180
+ if (!existsSync(root)) return out
181
+ let names: string[]
182
+ try { names = readdirSync(root) } catch { return out }
183
+
184
+ const entries = names.map(name => ({ name, ...sessionSize(join(root, name)) }))
185
+ out.bytesBefore = entries.reduce((n, e) => n + e.bytes, 0)
186
+ out.bytesAfter = out.bytesBefore
187
+ if (out.bytesAfter <= maxBytes) return out
188
+
189
+ // Oldest first. A session with an unreadable mtime sorts LAST so it is evicted
190
+ // only as a final resort, matching the sweeper's bias toward keeping evidence.
191
+ entries.sort((a, b) => (a.mtimeMs || Number.MAX_SAFE_INTEGER) - (b.mtimeMs || Number.MAX_SAFE_INTEGER))
192
+ for (const e of entries) {
193
+ if (out.bytesAfter <= maxBytes) break
194
+ try {
195
+ rmSync(join(root, e.name), { recursive: true, force: true })
196
+ out.evicted.push(e.name)
197
+ out.bytesAfter -= e.bytes
198
+ } catch { /* leave it counted; the next pass will try again */ }
199
+ }
200
+ return out
201
+ }
202
+
203
+ /** Absolute path to one chunk's WAV, or null when it is not retained. */
204
+ export function meetingAudioChunkPath(sessionId: string, chunkIndex: number): string | null {
205
+ const dir = sessionDir(sessionId)
206
+ if (!dir) return null
207
+ // No integer guard: `chunkIndex` is a number, so the interpolation can never
208
+ // contain a path separator, and a nonsense value simply names a file that does
209
+ // not exist. Mutation confirmed an explicit check here is unreachable behind
210
+ // the existence test below.
211
+ const path = resolve(dir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
212
+ if (!resolve(path).startsWith(resolve(dir) + '/')) return null
213
+ return existsSync(path) ? path : null
214
+ }
215
+
216
+ /**
217
+ * Fallback source: audio the capture path saved for an UNRECOGNISED speaker.
218
+ *
219
+ * `ext-audio/<sessionId>/ext_chunk<N>_<ts>.wav` is written live whenever the
220
+ * identifier finds no match, and `<N>` is the same RAW capture index the review
221
+ * addresses. Verified across 14 real meetings: 90-100% of these files correspond
222
+ * to a chunk the sidecar labels `Ext`.
223
+ *
224
+ * This matters because the 7-day archive is FORWARD-ONLY — it starts filling
225
+ * when a meeting is saved under 6.21.18, so on the day of that upgrade there is
226
+ * nothing to play. ext-audio already holds the unidentified voices from the last
227
+ * 72 hours, which is exactly the set a reviewer most needs to hear.
228
+ *
229
+ * Shorter window than the archive (72h vs 7 days), so the listing reports which
230
+ * source a chunk came from rather than implying one retention rule.
231
+ */
232
+ export function extAudioChunkPath(sessionId: string, chunkIndex: number): string | null {
233
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
234
+ const dir = join(dataPath('ext-audio'), sessionId.replace(/:/g, '_'))
235
+ if (!resolve(dir).startsWith(resolve(dataPath('ext-audio')) + '/') || !existsSync(dir)) return null
236
+ try {
237
+ // Timestamped suffix, so match on the index and take the newest.
238
+ const prefix = `ext_chunk${chunkIndex}_`
239
+ const hits = readdirSync(dir).filter(n => n.startsWith(prefix) && n.endsWith('.wav')).sort()
240
+ const pick = hits[hits.length - 1]
241
+ if (!pick) return null
242
+ const path = resolve(dir, pick)
243
+ return resolve(path).startsWith(resolve(dir) + '/') ? path : null
244
+ } catch {
245
+ return null
246
+ }
247
+ }
248
+
249
+ /** Raw chunk indices this session has ext-audio for, ascending. */
250
+ export function listExtAudioChunks(sessionId: string): number[] {
251
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return []
252
+ const dir = join(dataPath('ext-audio'), sessionId.replace(/:/g, '_'))
253
+ if (!existsSync(dir)) return []
254
+ try {
255
+ const out = new Set<number>()
256
+ for (const n of readdirSync(dir)) {
257
+ const m = /^ext_chunk(\d+)_.*\.wav$/.exec(n)
258
+ if (m) out.add(Number(m[1]))
259
+ }
260
+ return [...out].sort((a, b) => a - b)
261
+ } catch {
262
+ return []
263
+ }
264
+ }
265
+
266
+ /** Chunk indices retained for a session, ascending. */
267
+ export function listMeetingAudioChunks(sessionId: string): number[] {
268
+ const dir = sessionDir(sessionId)
269
+ if (!dir || !existsSync(dir)) return []
270
+ try {
271
+ return readdirSync(dir)
272
+ .filter(isChunkWav)
273
+ .map(n => Number(n.slice('chunk_'.length, -'.wav'.length)))
274
+ .filter(n => Number.isInteger(n))
275
+ .sort((a, b) => a - b)
276
+ } catch {
277
+ return []
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Cached stats for /api/health.
283
+ *
284
+ * The uncached walk statSyncs EVERY retained chunk: a 7-day window at the
285
+ * measured rate is roughly 2,000-3,000 files, and COS Control polls health every
286
+ * 12 seconds — thousands of synchronous stats on the same event loop that ingests
287
+ * live audio. The sibling chunkEmbeddingStoreStats is one stat per session for
288
+ * the same reason. A 30s cache keeps the number useful without the cost.
289
+ */
290
+ let statsCache: { at: number; value: ReturnType<typeof computeMeetingAudioStats> } | null = null
291
+ const STATS_TTL_MS = 30_000
292
+
293
+ export function meetingAudioStats(): ReturnType<typeof computeMeetingAudioStats> {
294
+ const now = Date.now()
295
+ if (statsCache && now - statsCache.at < STATS_TTL_MS) return statsCache.value
296
+ const value = computeMeetingAudioStats()
297
+ statsCache = { at: now, value }
298
+ return value
299
+ }
300
+
301
+ /** Invalidate after a sweep or eviction so health does not report stale usage. */
302
+ export function invalidateMeetingAudioStats(): void {
303
+ statsCache = null
304
+ }
305
+
306
+ /** Counts for /api/health, so retention can be seen rather than assumed. */
307
+ function computeMeetingAudioStats(): {
308
+ enabled: boolean
309
+ sessions: number
310
+ bytes: number
311
+ maxBytes: number
312
+ retentionDays: number
313
+ oldestAgeHours: number | null
314
+ } {
315
+ const root = dataPath(MEETING_AUDIO_DIR)
316
+ const retentionDays = Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
317
+ const base = {
318
+ enabled: meetingAudioEnabled(),
319
+ maxBytes: meetingAudioMaxBytes(),
320
+ retentionDays,
321
+ }
322
+ if (!existsSync(root)) return { ...base, sessions: 0, bytes: 0, oldestAgeHours: null }
323
+ let sessions = 0, bytes = 0, oldest = Number.POSITIVE_INFINITY
324
+ try {
325
+ for (const name of readdirSync(root)) {
326
+ const { bytes: b, mtimeMs, files } = sessionSize(join(root, name))
327
+ if (files === 0) continue
328
+ sessions++
329
+ bytes += b
330
+ if (mtimeMs > 0) oldest = Math.min(oldest, mtimeMs)
331
+ }
332
+ } catch { /* report what we have */ }
333
+ return {
334
+ ...base,
335
+ sessions,
336
+ bytes,
337
+ oldestAgeHours: Number.isFinite(oldest) ? Math.round(((Date.now() - oldest) / 3_600_000) * 10) / 10 : null,
338
+ }
339
+ }
340
+
341
+ /**
342
+ * One retention pass: expire first, then enforce the budget.
343
+ *
344
+ * ORDER IS LOAD-BEARING and that is why this is a named function rather than two
345
+ * calls inline in an interval. Enforcing the cap first would let it EVICT audio
346
+ * that the sweeper was about to expire anyway — counting those bytes against the
347
+ * budget and so evicting extra sessions that were still inside their window. Run
348
+ * the other way round, the cap only ever sees audio a human could still want.
349
+ */
350
+ /** Retention window in days — a pure config read, no filesystem. */
351
+ export function meetingAudioRetentionDays(): number {
352
+ return Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
353
+ }
354
+
355
+ export function runMeetingAudioRetention(nowMs = Date.now()): {
356
+ swept: SweepResult
357
+ capped: CapResult
358
+ } {
359
+ const swept = sweepMeetingAudio(nowMs)
360
+ const capped = enforceMeetingAudioCap()
361
+ // Usage just changed; do not let health report the pre-sweep figure.
362
+ if (swept.removed.length > 0 || capped.evicted.length > 0) invalidateMeetingAudioStats()
363
+ return { swept, capped }
364
+ }
@@ -185,7 +185,12 @@ function sectionRange(md: string, heading: string): { start: number; end: number
185
185
  * by label, not by index, and the markdown's turn segmentation does not match
186
186
  * the sidecar's. See the file header.
187
187
  */
188
- export function relabelMeetingMarkdown(md: string, from: string, to: string): RelabelOutcome<MarkdownRelabelResult> {
188
+ export function relabelMeetingMarkdown(
189
+ md: string,
190
+ from: string,
191
+ to: string,
192
+ options: { removeAttendee?: boolean } = {},
193
+ ): RelabelOutcome<MarkdownRelabelResult> {
189
194
  if (from === to) return { ok: false, error: 'from and to are the same label' }
190
195
  for (const [which, label] of [['from', from], ['to', to]] as const) {
191
196
  const bad = invalidLabelReason(label)
@@ -204,7 +209,14 @@ export function relabelMeetingMarkdown(md: string, from: string, to: string): Re
204
209
  // run past its own line. Without the end anchor, `- Luke H` matches the
205
210
  // PREFIX of `- Luke Henry` and deleting it leaves the fragment `enry`.
206
211
  const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
207
- const alreadyListed = new RegExp(line(to), 'm').test(body)
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)
208
220
  let rewritten: string
209
221
  if (alreadyListed) {
210
222
  // Both names present: drop the old bullet instead of creating a duplicate