@gotcos/glasses-server 6.21.17 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,79 @@
1
+ ## 6.21.18
2
+
3
+ Everything a human needs to correct who spoke, and to hear the voice before
4
+ deciding. Four changes that landed together after Miles reviewed the 2026-08-06
5
+ Ditto meeting and found eleven attributed voices, most of them wrong.
6
+
7
+ - A NAME MUST BE EARNED. `identifySpeaker` accepts a match at 0.55, so one
8
+ segment could arrive in the review panel wearing somebody's full name — Richard
9
+ Jenkins on 1 segment at 0.60, Luke Henry on 1 at 0.55. Voices now carry
10
+ `nameAsserted` plus `assertionBlockers`; below the floor a client must render
11
+ "unidentified" and offer the label only as a scored candidate. Floors:
12
+ similarity >= 0.65 AND >= 3 segments AND not thrashing. The owner gets no
13
+ exemption.
14
+ - `timeline` ON THE REVIEW. Consecutive same-speaker spans with start/end, so a
15
+ ribbon can be an actual timeline. The previous one drew a rectangle per voice
16
+ sized by share of segments while labelled "who spoke, in order", so a voice
17
+ that spoke twice appeared once and hover could report nothing true.
18
+ - POST /meeting/:id/deattribute — "this voice was NOT that person". Un-attributes
19
+ it for ONE meeting and retracts the training samples that meeting contributed
20
+ to that person's profile, so a false attribution stops reinforcing itself. It
21
+ reports what it cannot reach: `train-g2` used to stamp a bare `g2-training`,
22
+ discarding which meeting each sample came from, and now stamps
23
+ `g2-training:<sessionId>`. Samples written before this are relabellable but not
24
+ retractable.
25
+ - MEETING AUDIO KEPT 7 DAYS, 8 GB budget (Miles's call: review should survive to
26
+ a weekend). Chunk WAVs previously died with the batch pipeline —
27
+ `session-audio` held 0 files. Hard-linked at the single choke point before the
28
+ rename, so it costs no extra disk and outlives the pipeline's cleanup. Sized on
29
+ measurement: 3.1 h/day mean and 6.9 h peak make a week ~2.5 GB uncompressed, so
30
+ the audio stays usable for re-transcription too. Retention sweeps expiry first,
31
+ then evicts oldest whole sessions; a session whose age cannot be read is kept.
32
+ - Playback: `GET /voice/profiles/:name/sample` (needs no retention change — the
33
+ training audio already existed, and playing a stored profile is what answers
34
+ "is this really them"), `GET /voice/ext-audio/:id/sample`, and
35
+ `GET /meeting/:id/audio/:chunk` which distinguishes "the window passed" from
36
+ "that chunk is missing" and reports which chunks are playable.
37
+ - `/api/health` gains `review_audio`. `voice_provenance` from 6.21.17 already
38
+ reports `noHumanSample`; on the live store that is 67 of 77 profiles.
39
+
40
+ FIXES FOUND BY QA BEFORE PUBLISH (nothing above ever shipped):
41
+
42
+ - PLAYBACK PLAYS THE RIGHT AUDIO. `Phrase` now carries the RAW capture index,
43
+ resolved from the sidecar's `chunkEntries`. The compacted `chunks` array and
44
+ the `chunk_NNNN.wav` numbering are different sequences: on the 2026-08-06 Ditto
45
+ sidecar, 885 compacted chunks against raw indices 0..945 with 36 gaps, so array
46
+ position 884 is raw chunk 940. Playing by position would have played a
47
+ different speaker minutes earlier — on the one screen whose job is confirming
48
+ who spoke. When the counts disagree no index is emitted at all, because a
49
+ shifted index is worse than no button.
50
+ - THE OWNER IS EXEMPT FROM THE NAME FLOOR. The wearer is verified at exactly the
51
+ floor (VERIFY_THRESHOLD 0.65), so they sat permanently on the boundary and any
52
+ thrash pair flipped them: measured across the 2026-08-06 corpus, the owner row
53
+ read "Unidentified voice" in 4 of 9 meetings, once with 285 of their own
54
+ segments. `thrashesWith` still renders, so a mixed row is still visible.
55
+ - DE-ATTRIBUTION NUMBERS ITS LABELS. `Unidentified 1`, `Unidentified 2`, … rather
56
+ than one shared `Ext`. Miles found five wrong attributions in one meeting;
57
+ collapsing them into a single row would have destroyed his ability to tell
58
+ those voices apart, which is exactly what playback is for next.
59
+ - DE-ATTRIBUTION DELETES THE ATTENDEE BULLET instead of renaming it. Renaming
60
+ wrote an unidentified label into the attendee list as though it were a person,
61
+ and every downstream reader takes that bullet at face value while the pipeline
62
+ that BUILDS attendees deliberately excludes such labels.
63
+ - The timeline's LAST SPAN had zero width. `durationMs` frequently equals the
64
+ final chunk's start exactly (both 5,783,732 on the Ditto sidecar), so the
65
+ closing turn rendered as a sliver. The tail now takes one typical chunk of
66
+ width from the meeting's own median gap — measured 7,034 ms where it was 0.
67
+ - De-attribute reports `markdownSkipped`, which relabel already did and it
68
+ silently dropped. 39% of operations sidecars have no `.md` beside them, so the
69
+ response claimed segment changes with no hint the document was untouched.
70
+ - `/api/health` no longer statSyncs every retained chunk on every poll. COS
71
+ Control polls every 12s and a 7-day window is 2,000-3,000 files; stats are now
72
+ cached for 30s and invalidated when retention actually removes something. The
73
+ `/audio` listing reads the retention window as config rather than walking disk.
74
+
75
+ Requires COS Control 0.5.0+ to use the scoped correction and playback surfaces.
76
+
1
77
  ## 6.21.17
2
78
 
3
79
  - 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.18",
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,314 @@
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
+ /** Chunk indices retained for a session, ascending. */
217
+ export function listMeetingAudioChunks(sessionId: string): number[] {
218
+ const dir = sessionDir(sessionId)
219
+ if (!dir || !existsSync(dir)) return []
220
+ try {
221
+ return readdirSync(dir)
222
+ .filter(isChunkWav)
223
+ .map(n => Number(n.slice('chunk_'.length, -'.wav'.length)))
224
+ .filter(n => Number.isInteger(n))
225
+ .sort((a, b) => a - b)
226
+ } catch {
227
+ return []
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Cached stats for /api/health.
233
+ *
234
+ * The uncached walk statSyncs EVERY retained chunk: a 7-day window at the
235
+ * measured rate is roughly 2,000-3,000 files, and COS Control polls health every
236
+ * 12 seconds — thousands of synchronous stats on the same event loop that ingests
237
+ * live audio. The sibling chunkEmbeddingStoreStats is one stat per session for
238
+ * the same reason. A 30s cache keeps the number useful without the cost.
239
+ */
240
+ let statsCache: { at: number; value: ReturnType<typeof computeMeetingAudioStats> } | null = null
241
+ const STATS_TTL_MS = 30_000
242
+
243
+ export function meetingAudioStats(): ReturnType<typeof computeMeetingAudioStats> {
244
+ const now = Date.now()
245
+ if (statsCache && now - statsCache.at < STATS_TTL_MS) return statsCache.value
246
+ const value = computeMeetingAudioStats()
247
+ statsCache = { at: now, value }
248
+ return value
249
+ }
250
+
251
+ /** Invalidate after a sweep or eviction so health does not report stale usage. */
252
+ export function invalidateMeetingAudioStats(): void {
253
+ statsCache = null
254
+ }
255
+
256
+ /** Counts for /api/health, so retention can be seen rather than assumed. */
257
+ function computeMeetingAudioStats(): {
258
+ enabled: boolean
259
+ sessions: number
260
+ bytes: number
261
+ maxBytes: number
262
+ retentionDays: number
263
+ oldestAgeHours: number | null
264
+ } {
265
+ const root = dataPath(MEETING_AUDIO_DIR)
266
+ const retentionDays = Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
267
+ const base = {
268
+ enabled: meetingAudioEnabled(),
269
+ maxBytes: meetingAudioMaxBytes(),
270
+ retentionDays,
271
+ }
272
+ if (!existsSync(root)) return { ...base, sessions: 0, bytes: 0, oldestAgeHours: null }
273
+ let sessions = 0, bytes = 0, oldest = Number.POSITIVE_INFINITY
274
+ try {
275
+ for (const name of readdirSync(root)) {
276
+ const { bytes: b, mtimeMs, files } = sessionSize(join(root, name))
277
+ if (files === 0) continue
278
+ sessions++
279
+ bytes += b
280
+ if (mtimeMs > 0) oldest = Math.min(oldest, mtimeMs)
281
+ }
282
+ } catch { /* report what we have */ }
283
+ return {
284
+ ...base,
285
+ sessions,
286
+ bytes,
287
+ oldestAgeHours: Number.isFinite(oldest) ? Math.round(((Date.now() - oldest) / 3_600_000) * 10) / 10 : null,
288
+ }
289
+ }
290
+
291
+ /**
292
+ * One retention pass: expire first, then enforce the budget.
293
+ *
294
+ * ORDER IS LOAD-BEARING and that is why this is a named function rather than two
295
+ * calls inline in an interval. Enforcing the cap first would let it EVICT audio
296
+ * that the sweeper was about to expire anyway — counting those bytes against the
297
+ * budget and so evicting extra sessions that were still inside their window. Run
298
+ * the other way round, the cap only ever sees audio a human could still want.
299
+ */
300
+ /** Retention window in days — a pure config read, no filesystem. */
301
+ export function meetingAudioRetentionDays(): number {
302
+ return Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
303
+ }
304
+
305
+ export function runMeetingAudioRetention(nowMs = Date.now()): {
306
+ swept: SweepResult
307
+ capped: CapResult
308
+ } {
309
+ const swept = sweepMeetingAudio(nowMs)
310
+ const capped = enforceMeetingAudioCap()
311
+ // Usage just changed; do not let health report the pre-sweep figure.
312
+ if (swept.removed.length > 0 || capped.evicted.length > 0) invalidateMeetingAudioStats()
313
+ return { swept, capped }
314
+ }
@@ -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