@gotcos/glasses-server 6.36.4 → 6.36.5

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,5 +1,30 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.36.5
4
+ - **A speaker merge now reaches the meetings, not just the voice store.**
5
+ `merge-profiles` folded two profiles together and relabelled the calibration log,
6
+ and stopped there. Meetings keep the speaker strings written at transcription time
7
+ and the review panel re-reads them from disk, so every meeting recorded before a
8
+ merge kept rendering two people forever — the merge fixed identification going
9
+ forward and nothing behind it. Measured on the live library for one real merge:
10
+ 24 sidecars (111 labels) and 18 transcripts (70 labels) were stranded.
11
+ - The fan-out is a **pure string rewrite** — the same `relabelSidecarJson` and
12
+ `relabelMeetingMarkdown` primitives the per-meeting relabel route uses, minus its
13
+ `enrolNamedVoice` step. Re-enrolling would double-count audio the merge has already
14
+ absorbed and drag the centroid, which matters at the margin: one recent merge moved
15
+ a neighbouring speaker from 0.818 to 0.842.
16
+ - **The confirm gate now shows the blast radius before you approve it**, scoped to the
17
+ names that would actually merge. Dry run is the default, writes are atomic, and the
18
+ response names every file touched so the rewrite can be audited and diffed.
19
+ - Renames only what the store actually absorbed. A requested name that matched no
20
+ profile is reported as missing and left alone everywhere — fanning out the requested
21
+ list instead would have renamed a real person off the back of a typo.
22
+ - Reports what it cannot fix rather than passing over it: transcripts in the older
23
+ `**Name**` label form are counted and surfaced, not silently skipped. On the live
24
+ library that is 12 files carrying 58 labels a rename leaves behind.
25
+ - iCloud conflict copies are skipped by construction, including the compound-extension
26
+ form (`sync 2.g2-chunks.json`) an earlier pass let through.
27
+
3
28
  ## 6.28.0
4
29
  - **Continue Original Agent Thread — attach to a live desktop thread and append a turn
5
30
  to it.** From the glasses you can now continue a Claude Code or Codex conversation that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.4",
3
+ "version": "6.36.5",
4
4
  "description": "COS Glasses \u2014 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,311 @@
1
+ // Carry a speaker rename out to the meetings that already have the old name baked in.
2
+ //
3
+ // ---------------------------------------------------------------------------
4
+ // WHY THIS EXISTS
5
+ // ---------------------------------------------------------------------------
6
+ // `merge-profiles` folds two voice profiles together and relabels the calibration log.
7
+ // That is ALL it has ever touched -- verified in the handler, not assumed. Meetings keep
8
+ // the speaker strings that were written at transcription time, and the review panel
9
+ // re-reads those strings from disk on every request, so a merge is invisible to every
10
+ // meeting that already exists.
11
+ //
12
+ // Measured on this machine when the Luke H / Luke Henry merge ran: the split had been
13
+ // live since 2026-03-25, and every affected meeting would have rendered two Lukes
14
+ // forever. The merge fixed identification going FORWARD and nothing behind it.
15
+ //
16
+ // ---------------------------------------------------------------------------
17
+ // WHY IT DOES NOT REUSE THE PER-MEETING RELABEL ROUTE
18
+ // ---------------------------------------------------------------------------
19
+ // `POST /api/meeting/:id/relabel` also calls `enrolNamedVoice`, which folds that
20
+ // meeting's audio into the target profile. That is correct when a human names a voice:
21
+ // the embedding genuinely should learn it.
22
+ //
23
+ // It is WRONG for a merge fan-out, and not by a little. The merge has already absorbed
24
+ // those embeddings; re-enrolling the same audio across every affected meeting would
25
+ // double-count it and drag the centroid further. That matters here specifically because
26
+ // the Luke merge already moved a neighbouring speaker's similarity from 0.818 to 0.842,
27
+ // and 0.842 is close enough to the identification threshold to start producing wrong
28
+ // attributions.
29
+ //
30
+ // So this is a PURE STRING REWRITE. Same two primitives the relabel route uses --
31
+ // `relabelSidecarJson` and `relabelMeetingMarkdown` -- minus the enrolment.
32
+ //
33
+ // ---------------------------------------------------------------------------
34
+ // SAFETY
35
+ // ---------------------------------------------------------------------------
36
+ // This rewrites production meeting records, so:
37
+ // - DRY RUN IS THE DEFAULT. Writing requires asking for it.
38
+ // - Atomic writes only, via the same helper the rest of the server uses.
39
+ // - iCloud conflict copies are skipped by construction. Desktop-and-Documents sync
40
+ // creates `2026-08 2/` and `meeting 2.md`; rewriting one of those would edit a file
41
+ // nothing reads while leaving the real one stale. Month directories must match
42
+ // `YYYY-MM` exactly.
43
+ // - Every skip is REPORTED rather than silently dropped, because a fan-out that
44
+ // quietly missed files is worse than one that refused.
45
+
46
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
47
+ import { join } from 'node:path'
48
+ import { atomicWriteFileSync } from './atomic-fs.js'
49
+ import {
50
+ relabelSidecarJson,
51
+ relabelMeetingMarkdown,
52
+ type SidecarRelabelResult,
53
+ type MarkdownRelabelResult,
54
+ } from './meeting-relabel.js'
55
+
56
+ /** A month directory, and nothing that merely looks like one. */
57
+ const CANONICAL_MONTH = /^\d{4}-\d{2}$/
58
+
59
+ /** The chunk sidecar that drives the speaker review panel. */
60
+ const SIDECAR_SUFFIX = '.g2-chunks.json'
61
+
62
+ export interface FanOutFile {
63
+ path: string
64
+ /** Labels rewritten in this file. */
65
+ labels: number
66
+ /**
67
+ * Something true about this file the operator should know: chunks that still carry
68
+ * the old name after a partial relabel, or narrative prose the primitive leaves alone
69
+ * on purpose. Absent when the rewrite was total.
70
+ */
71
+ note?: string
72
+ }
73
+
74
+ export interface FanOutSkip {
75
+ path: string
76
+ reason: string
77
+ }
78
+
79
+ export interface SpeakerRenameFanOut {
80
+ from: string
81
+ to: string
82
+ dryRun: boolean
83
+ sidecars: FanOutFile[]
84
+ markdown: FanOutFile[]
85
+ /** Files opened and examined, whether or not they matched. */
86
+ scanned: number
87
+ /** Anything deliberately not touched, and why. */
88
+ skipped: FanOutSkip[]
89
+ }
90
+
91
+ export interface FanOutOptions {
92
+ /** Write. Omitted or false means report what WOULD change and touch nothing. */
93
+ apply?: boolean
94
+ /**
95
+ * Include the hidden `.meeting_archive` tree.
96
+ *
97
+ * Off by default: it is a separate store with its own lifecycle, and sweeping it in
98
+ * silently would triple the blast radius of a rename without anyone asking for it.
99
+ */
100
+ includeArchive?: boolean
101
+ }
102
+
103
+ function safeReaddir(dir: string): string[] {
104
+ try {
105
+ return readdirSync(dir)
106
+ } catch {
107
+ return []
108
+ }
109
+ }
110
+
111
+ function isDir(path: string): boolean {
112
+ try {
113
+ return statSync(path).isDirectory()
114
+ } catch {
115
+ return false
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Every `{domain}/meetings/{YYYY-MM}` directory under an operations root.
121
+ *
122
+ * Walked explicitly rather than recursively: the operations tree also holds weekly work
123
+ * folders, intelligence libraries and caches, and a rename has no business in any of
124
+ * them. Three known levels, and nothing else is opened.
125
+ */
126
+ export function meetingMonthDirs(operationsDir: string, includeArchive = false): string[] {
127
+ const out: string[] = []
128
+ for (const domain of safeReaddir(operationsDir)) {
129
+ if (domain.startsWith('.') && !(includeArchive && domain === '.meeting_archive')) continue
130
+ const meetingsDir = join(operationsDir, domain, 'meetings')
131
+ if (!isDir(meetingsDir)) {
132
+ // `.meeting_archive` nests one level deeper: `.meeting_archive/{name}/meetings`.
133
+ if (includeArchive && domain === '.meeting_archive') {
134
+ for (const sub of safeReaddir(join(operationsDir, domain))) {
135
+ const nested = join(operationsDir, domain, sub, 'meetings')
136
+ if (isDir(nested)) out.push(...monthsIn(nested))
137
+ }
138
+ }
139
+ continue
140
+ }
141
+ out.push(...monthsIn(meetingsDir))
142
+ }
143
+ return out
144
+ }
145
+
146
+ function monthsIn(meetingsDir: string): string[] {
147
+ const out: string[] = []
148
+ for (const month of safeReaddir(meetingsDir)) {
149
+ // STRICT. `2026-08 2` is an iCloud conflict copy; rewriting it would edit a file
150
+ // nothing reads and leave the real one carrying the old name.
151
+ if (!CANONICAL_MONTH.test(month)) continue
152
+ const dir = join(meetingsDir, month)
153
+ if (isDir(dir)) out.push(dir)
154
+ }
155
+ return out
156
+ }
157
+
158
+ /**
159
+ * Rewrite `from` to `to` across every meeting sidecar and transcript.
160
+ *
161
+ * Returns what changed, or what WOULD change when `apply` is not set. Never throws for
162
+ * one bad file: an unreadable or unparsable record is reported as a skip and the sweep
163
+ * continues, because stopping halfway would leave the library in a half-renamed state
164
+ * that is worse than either end.
165
+ */
166
+ export function fanOutSpeakerRename(
167
+ operationsDir: string,
168
+ from: string,
169
+ to: string,
170
+ options: FanOutOptions = {},
171
+ ): SpeakerRenameFanOut {
172
+ const apply = options.apply === true
173
+ const result: SpeakerRenameFanOut = {
174
+ from, to, dryRun: !apply, sidecars: [], markdown: [], scanned: 0, skipped: [],
175
+ }
176
+ if (!from.trim() || !to.trim() || from === to) return result
177
+
178
+ for (const monthDir of meetingMonthDirs(operationsDir, options.includeArchive === true)) {
179
+ for (const name of safeReaddir(monthDir)) {
180
+ // A conflict copy of a FILE, same reasoning as the month directory.
181
+ //
182
+ // The marker sits before the EXTENSION CHAIN, not just before the last dot.
183
+ // iCloud writes `sync.g2-chunks 2.json` for a sidecar and `sync 2.md` for a
184
+ // transcript, and a sidecar belonging to an already-conflicted meeting comes
185
+ // out as `sync 2.g2-chunks.json`. A first cut anchored the digit to the final
186
+ // extension and let that third form straight through -- it would have rewritten
187
+ // a duplicate nobody reads while the real file kept the old name.
188
+ if (/ \d+(\.[A-Za-z0-9-]+)*\.(md|json)$/.test(name)) {
189
+ result.skipped.push({ path: join(monthDir, name), reason: 'icloud conflict copy' })
190
+ continue
191
+ }
192
+ const isSidecar = name.endsWith(SIDECAR_SUFFIX)
193
+ const isMarkdown = name.endsWith('.md')
194
+ if (!isSidecar && !isMarkdown) continue
195
+
196
+ const path = join(monthDir, name)
197
+ result.scanned += 1
198
+ let raw: string
199
+ try {
200
+ raw = readFileSync(path, 'utf-8')
201
+ } catch (error) {
202
+ result.skipped.push({ path, reason: `unreadable: ${(error as Error).message}` })
203
+ continue
204
+ }
205
+ // Cheap pre-filter, and deliberately loose: `from` is a SUBSTRING of the name it
206
+ // is being merged into ("Luke H" inside "Luke Henry"), so a file holding only the
207
+ // new name still passes here. The primitives below are exact and reject it, which
208
+ // is why a plain no-match must not be reported as a skip -- see below.
209
+ if (!raw.includes(from)) continue
210
+
211
+ // TYPED, NOT DUCK-TYPED. A first cut read `changed` as a number and the field is
212
+ // an ARRAY of chunk indices, so every sidecar would have counted zero labels and
213
+ // been skipped -- a fan-out that reported success while rewriting nothing.
214
+ let next: string
215
+ let labels: number
216
+ let note: string | undefined
217
+
218
+ if (isSidecar) {
219
+ const outcome = relabelSidecarJson(raw, from, to)
220
+ if (!outcome.ok) {
221
+ if (!isNoMatch(outcome.error)) result.skipped.push({ path, reason: outcome.error })
222
+ continue
223
+ }
224
+ const value: SidecarRelabelResult = outcome.value
225
+ next = value.json
226
+ labels = value.changed.length
227
+ // The primitive's own invariant, carried rather than assumed: a partial relabel
228
+ // means chunks still hold the old name, and the markdown must NOT then be
229
+ // rewritten by label. Reported so the operator sees it.
230
+ if (value.remainingWithFrom > 0) {
231
+ note = `${value.remainingWithFrom} chunk(s) still carry "${from}"`
232
+ }
233
+ } else {
234
+ // A SECOND TRANSCRIPT FORMAT EXISTS AND THE PRIMITIVE DOES NOT HANDLE IT.
235
+ //
236
+ // `relabelMeetingMarkdown` rewrites `[Name]:` turn labels, anchored to line
237
+ // start. Measured across the real library for the Luke rename: 17 files and 97
238
+ // labels in that form -- and 20 files, 77 labels in a `**Name**` form it does
239
+ // not match, 13 of those files in LIVE quilt meetings, not the archive.
240
+ //
241
+ // That is a 39% silent miss. Detected and REPORTED rather than fixed here: the
242
+ // primitive is shared with the live per-meeting relabel route, and widening its
243
+ // matcher changes behaviour for a path nobody asked me to touch. The operator
244
+ // gets a number instead of a surprise.
245
+ const unhandled = countBoldLabels(raw, from)
246
+ const outcome = relabelMeetingMarkdown(raw, from, to)
247
+ if (!outcome.ok) {
248
+ if (!isNoMatch(outcome.error)) result.skipped.push({ path, reason: outcome.error })
249
+ continue
250
+ }
251
+ const value: MarkdownRelabelResult = outcome.value
252
+ next = value.markdown
253
+ labels = value.attendees + value.transcript
254
+ // Narrative prose is deliberately untouched by the primitive. Surfaced here so a
255
+ // stale summary is something the operator knows about rather than discovers.
256
+ const notes: string[] = []
257
+ if (unhandled > 0) notes.push(`${unhandled} label(s) in an unhandled **${from}** format`)
258
+ if (value.proseStale) {
259
+ notes.push(`prose still mentions "${from}"${value.proseHits.length ? `: ${value.proseHits.slice(0, 3).join(', ')}` : ''}`)
260
+ }
261
+ if (notes.length > 0) note = notes.join('; ')
262
+
263
+ // A file whose ONLY labels are in the unhandled form changes nothing, and would
264
+ // otherwise fall out of the report entirely -- the exact silent miss this guard
265
+ // exists to prevent. Recorded as a skip so it is visible.
266
+ if (labels === 0 && unhandled > 0) {
267
+ result.skipped.push({ path, reason: `${unhandled} label(s) in an unhandled **${from}** format` })
268
+ continue
269
+ }
270
+ }
271
+
272
+ if (labels === 0) continue
273
+ if (apply) {
274
+ try {
275
+ atomicWriteFileSync(path, next)
276
+ } catch (error) {
277
+ result.skipped.push({ path, reason: `write failed: ${(error as Error).message}` })
278
+ continue
279
+ }
280
+ }
281
+ ;(isSidecar ? result.sidecars : result.markdown).push({ path, labels, note })
282
+ }
283
+ }
284
+ return result
285
+ }
286
+
287
+ /**
288
+ * Is this refusal simply "the name is not in here", rather than a problem?
289
+ *
290
+ * SKIPS ARE FOR THINGS A HUMAN SHOULD LOOK AT. The loose pre-filter above lets through
291
+ * every file holding the NEW name, because the old one is a substring of it -- measured
292
+ * on the real library, that padded the skip list with 15 files that were never affected.
293
+ * A report where most entries are non-issues is one nobody reads, and it hides the two
294
+ * that matter.
295
+ */
296
+ function isNoMatch(error: string): boolean {
297
+ return /no chunk carries|not found|does not appear|no .* labelled/i.test(error)
298
+ }
299
+
300
+ /**
301
+ * Speaker labels in the `**Name**` transcript form, which the primitive does not rewrite.
302
+ *
303
+ * Counted so the report can say how much a rename LEAVES BEHIND. Anchored to line start
304
+ * for the same reason the primitive anchors its own matcher: a bolded name inside spoken
305
+ * text is a quote, not a label.
306
+ */
307
+ export function countBoldLabels(markdown: string, name: string): number {
308
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
309
+ return (markdown.match(new RegExp(`^\\*\\*${escaped}\\*\\*`, 'gm')) ?? []).length
310
+ }
311
+
@@ -14,6 +14,8 @@ import { trainingSourceFor } from '../lib/training-audio-provenance.js'
14
14
  import { sendAudioFile } from '../lib/send-audio.js'
15
15
  import { getVoiceDirectorySnapshot, invalidateVoiceDirectory } from '../lib/voice-directory.js'
16
16
  import { greedyDiversitySelect } from '../lib/voice-enrolment-selection.js'
17
+ import { fanOutSpeakerRename, type SpeakerRenameFanOut } from '../lib/speaker-rename-fanout.js'
18
+ import { resolveCosOperationsDir } from '../lib/cos-operations-meetings.js'
17
19
 
18
20
  // These MUST match the writer in transcribe-stream.ts, which saves under
19
21
  // dataPath(). They previously resolved relative to __dirname — i.e. inside the
@@ -572,6 +574,57 @@ voiceRouter.get('/voice/directory', async (req, res) => {
572
574
  // 20 samples independently — 40 samples of one person, split, each half a
573
575
  // weaker representation of them than the union would be.
574
576
  //
577
+ /**
578
+ * Carry a merge out to the meetings that already carry the absorbed name.
579
+ *
580
+ * WHY THE ROUTE DOES THIS AT ALL. A merge folds two voice profiles together and
581
+ * relabels the calibration log, and that is where it stopped. Meetings keep the
582
+ * strings written at transcription time and the review panel re-reads them from disk,
583
+ * so every meeting recorded before the merge kept rendering two people forever. The
584
+ * merge fixed identification going FORWARD and nothing behind it.
585
+ *
586
+ * PURE STRING REWRITE, no enrolment. The per-meeting relabel route also calls
587
+ * `enrolNamedVoice`, which is right when a human names a voice and wrong here: the
588
+ * merge has ALREADY absorbed those embeddings, so re-enrolling the same audio across
589
+ * every affected meeting would double-count it and drag the centroid. That matters
590
+ * concretely -- the Luke merge moved a neighbouring speaker from 0.818 to 0.842, close
591
+ * enough to the identification threshold to start producing wrong attributions.
592
+ *
593
+ * NEVER THROWS. A merge that succeeded in the store must not report failure because a
594
+ * meeting file was unreadable; the failure is reported in the payload instead.
595
+ */
596
+ function fanOutMergeToMeetings(
597
+ merged: readonly string[],
598
+ into: string,
599
+ apply: boolean,
600
+ ): { operationsDir: string | null; runs: SpeakerRenameFanOut[]; error?: string } {
601
+ const operationsDir = resolveCosOperationsDir()
602
+ // Not an error. A server with no COS library is a supported install; it simply has
603
+ // no meetings to carry the rename to.
604
+ if (!operationsDir || merged.length === 0) return { operationsDir, runs: [] }
605
+ try {
606
+ return { operationsDir, runs: merged.map(name => fanOutSpeakerRename(operationsDir, name, into, { apply })) }
607
+ } catch (err: unknown) {
608
+ return { operationsDir, runs: [], error: errMsg(err) }
609
+ }
610
+ }
611
+
612
+ /** The one number a human needs before approving: how many files this touches. */
613
+ function fanOutTotals(runs: readonly SpeakerRenameFanOut[]): {
614
+ files: number; labels: number; unhandled: number
615
+ } {
616
+ let files = 0, labels = 0, unhandled = 0
617
+ for (const run of runs) {
618
+ for (const f of [...run.sidecars, ...run.markdown]) {
619
+ files += 1
620
+ labels += f.labels
621
+ if (f.note && /unhandled/.test(f.note)) unhandled += 1
622
+ }
623
+ unhandled += run.skipped.filter(s => /unhandled/.test(s.reason)).length
624
+ }
625
+ return { files, labels, unhandled }
626
+ }
627
+
575
628
  // Fails closed below the search-accept threshold. A wrong merge destroys BOTH
576
629
  // identities at once and cannot be undone from the store alone, so the only
577
630
  // acceptable evidence is acoustic. `force` exists for the case where Miles
@@ -605,10 +658,18 @@ voiceRouter.post('/voice/merge-profiles', (req, res) => {
605
658
 
606
659
  if (req.body?.confirm !== true && !dryRun) {
607
660
  const preview = mergeSpeakerProfiles(into, from, { force, dryRun: true })
661
+ // BLAST RADIUS IN THE PREVIEW, not after the fact. The confirm gate exists so a
662
+ // human sees what a merge does before it happens, and rewriting production
663
+ // meeting records is the largest thing it does. Scoped to the names that would
664
+ // ACTUALLY merge, so a refused pair does not advertise a rewrite that will not
665
+ // run. This walks the meeting library, which is why it happens here -- once, on a
666
+ // deliberate human action -- and not on any hot path.
667
+ const fan = fanOutMergeToMeetings(preview.merged, into, false)
608
668
  return res.status(400).json({
609
669
  error: 'confirmation required',
610
670
  message: `Merging is not reversible from the store alone. Review the similarity scores, then pass { confirm: true }.`,
611
671
  preview,
672
+ meetingRewrite: { ...fanOutTotals(fan.runs), dryRun: true, error: fan.error },
612
673
  })
613
674
  }
614
675
 
@@ -638,7 +699,27 @@ voiceRouter.post('/voice/merge-profiles', (req, res) => {
638
699
  }
639
700
 
640
701
  if (!dryRun) invalidateVoiceDirectory()
641
- res.json({ ...report, dryRun, forced: force, calibrationRowsRelabeled: calibration })
702
+
703
+ // The fan-out honours `dryRun` for the same reason the merge does: a dry run must
704
+ // stay a dry run all the way down, or `dryRun: true` becomes the most dangerous
705
+ // parameter in the API.
706
+ const fan = fanOutMergeToMeetings(report.merged, into, !dryRun)
707
+ res.json({
708
+ ...report,
709
+ dryRun,
710
+ forced: force,
711
+ calibrationRowsRelabeled: calibration,
712
+ meetingRewrite: {
713
+ ...fanOutTotals(fan.runs),
714
+ dryRun,
715
+ error: fan.error,
716
+ // Paths, not just counts: this rewrote production records and the operator
717
+ // should be able to see exactly which, and diff them.
718
+ sidecars: fan.runs.flatMap(r => r.sidecars),
719
+ markdown: fan.runs.flatMap(r => r.markdown),
720
+ skipped: fan.runs.flatMap(r => r.skipped),
721
+ },
722
+ })
642
723
  } catch (err: unknown) {
643
724
  res.status(500).json({ error: errMsg(err) })
644
725
  }