@gotcos/glasses-server 6.44.14 → 6.44.15

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,22 @@
1
+ ## 6.44.15
2
+
3
+ Voice profiles named after a spoken sentence are repaired at load.
4
+
5
+ - Chelsie 2026-09-08: her `voice-profiles.json` held two profiles whose name
6
+ was the entire enrolment speech ("My Voice My Name Is Chelsie Hodgkiss And I
7
+ Am The Director…", ~600 characters), written by a client older than the
8
+ 8/25 guards. Speaker ID was "active" and could never match anything, and the
9
+ file cannot be repaired by hand because the server rewrites it from memory.
10
+ - `normalizeProfileStore` now renames a name that is too long, has more than
11
+ four words, or is three-plus words with sentence punctuation to
12
+ `Unnamed voice N`, keeps every embedding and its provenance, records
13
+ `renamedFrom` (the first 60 characters) and `needsName: true`, and counts it
14
+ in the load repairs (`profilesRenamed`). Legacy short labels ("MU",
15
+ "Speaker 2", "Luke H.") are never touched. `GET /api/voice/profiles` carries
16
+ `needsName` and `renamedFrom` so COS Control's speaker review can offer the
17
+ rename; `POST /api/voice/merge-profiles` folds the placeholder into the
18
+ right person as before.
19
+
1
20
  ## 6.44.14
2
21
 
3
22
  The Manage sheet's merge, with a preview, a worker and a receipt, plus a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.14",
3
+ "version": "6.44.15",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  // by execution rather than by asserting on source text. speaker-embeddings.ts
12
12
  // delegates to it and keeps the sherpa-onnx manager in sync.
13
13
 
14
+ import { checkSpeakerName, type SpeakerNameRejection } from './speaker-name.js'
14
15
  import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
15
16
  import { basename, dirname, join, resolve } from 'node:path'
16
17
  import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
@@ -21,6 +22,12 @@ export interface VoiceProfile {
21
22
  embeddings: number[][]
22
23
  /** Provenance: 'manual' | 'fireflies' | 'g2-training' | 'auto:<sessionId>' | … */
23
24
  sources?: string[]
25
+ /** The name on disk was a spoken sentence, not a name (an old client's
26
+ * "enroll my voice" fall-through); it was renamed at load and the training
27
+ * kept. COS Control's speaker review shows it for renaming. */
28
+ needsName?: boolean
29
+ /** The first 60 characters of the name it was loaded with. */
30
+ renamedFrom?: string
24
31
  }
25
32
 
26
33
  export interface ProfileStore {
@@ -42,6 +49,9 @@ export interface StoreRepairs {
42
49
  dimensionMismatch: number
43
50
  /** Entries with no usable name, or duplicate names collapsed. */
44
51
  profilesDropped: number
52
+ /** Names that were a whole spoken sentence (too long, too many words, or
53
+ * sentence punctuation), renamed to `Unnamed voice N` with the training kept. */
54
+ profilesRenamed: number
45
55
  }
46
56
 
47
57
  export function emptyRepairs(): StoreRepairs {
@@ -51,17 +61,19 @@ export function emptyRepairs(): StoreRepairs {
51
61
  embeddingsDropped: 0,
52
62
  dimensionMismatch: 0,
53
63
  profilesDropped: 0,
64
+ profilesRenamed: 0,
54
65
  }
55
66
  }
56
67
 
57
68
  export function hasRepairs(r: StoreRepairs): boolean {
58
69
  return r.sourcesRealigned > 0 || r.sourcesCoerced > 0 || r.embeddingsDropped > 0
59
- || r.dimensionMismatch > 0 || r.profilesDropped > 0
70
+ || r.dimensionMismatch > 0 || r.profilesDropped > 0 || r.profilesRenamed > 0
60
71
  }
61
72
 
62
73
  export function describeRepairs(r: StoreRepairs): string {
63
74
  const parts: string[] = []
64
75
  if (r.profilesDropped) parts.push(`${r.profilesDropped} unusable profile(s)`)
76
+ if (r.profilesRenamed) parts.push(`${r.profilesRenamed} profile(s) named after a spoken sentence, renamed for review`)
65
77
  if (r.embeddingsDropped) parts.push(`${r.embeddingsDropped} unusable embedding row(s)`)
66
78
  if (r.sourcesRealigned) parts.push(`${r.sourcesRealigned} profile(s) with misaligned sources[]`)
67
79
  if (r.sourcesCoerced) parts.push(`${r.sourcesCoerced} null/non-string source slot(s)`)
@@ -106,11 +118,27 @@ export function normalizeProfileStore(raw: unknown): { store: ProfileStore; repa
106
118
  if (!Array.isArray(rawProfiles)) return { store: { profiles: [] }, repairs }
107
119
 
108
120
  const byName = new Map<string, VoiceProfile>()
121
+ let unnamed = 0
109
122
  for (const candidate of rawProfiles) {
110
- const name = typeof (candidate as VoiceProfile)?.name === 'string'
123
+ const rawName = typeof (candidate as VoiceProfile)?.name === 'string'
111
124
  ? (candidate as VoiceProfile).name.trim()
112
125
  : ''
113
- if (!name) { repairs.profilesDropped++; continue }
126
+ if (!rawName) { repairs.profilesDropped++; continue }
127
+ // Chelsie's store (2026-09-08) held two profiles named with the entire
128
+ // enrolment speech (~600 characters), written by a client older than the
129
+ // 6.8.433 / server 8/25 guards. A name like that can never match a speaker
130
+ // label, and the file cannot be repaired by hand because the server
131
+ // rewrites it from memory. Keep the training, give it a placeholder name,
132
+ // and flag it so COS Control offers a rename. Only the sentence shapes are
133
+ // renamed; legacy short labels ("MU", "Speaker 2") are left exactly as is.
134
+ const junk = junkNameReason(rawName)
135
+ let renamedFrom: string | undefined
136
+ let name = rawName
137
+ if (junk) {
138
+ do { name = `Unnamed voice ${++unnamed}` } while (byName.has(name) || rawProfiles.some(p => (p as VoiceProfile)?.name === name))
139
+ renamedFrom = rawName.slice(0, 60)
140
+ repairs.profilesRenamed++
141
+ }
114
142
 
115
143
  const rawEmbeddings = Array.isArray((candidate as VoiceProfile).embeddings)
116
144
  ? (candidate as VoiceProfile).embeddings as unknown[]
@@ -154,12 +182,23 @@ export function normalizeProfileStore(raw: unknown): { store: ProfileStore; repa
154
182
  existing.sources!.push(...sources)
155
183
  continue
156
184
  }
157
- byName.set(name, { name, embeddings, sources })
185
+ byName.set(name, { name, embeddings, sources, ...(renamedFrom ? { needsName: true, renamedFrom } : {}) })
158
186
  }
159
187
 
160
188
  return { store: { profiles: [...byName.values()] }, repairs }
161
189
  }
162
190
 
191
+ /** The sentence shapes only: too long, too many words, or sentence punctuation. */
192
+ export function junkNameReason(name: string): SpeakerNameRejection | null {
193
+ const check = checkSpeakerName(name, { ownerLabel: name })
194
+ if (check.ok) return null
195
+ if (check.reason === 'too_long' || check.reason === 'too_many_words') return check.reason
196
+ // Sentence punctuation alone is not enough: "Luke H." is a legitimate legacy
197
+ // alias. Three or more words with punctuation is speech, not a name.
198
+ if (check.reason === 'sentence_like' && name.trim().split(/\s+/).length >= 3) return check.reason
199
+ return null
200
+ }
201
+
163
202
  export type StoreLoad = {
164
203
  store: ProfileStore
165
204
  repairs: StoreRepairs
@@ -540,6 +540,10 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
540
540
  embeddings: p.embeddings.length,
541
541
  isOwner: p.name === owner,
542
542
  sources: bySource,
543
+ // 6.44.15: a profile whose name on disk was a spoken sentence was
544
+ // renamed at load; the client offers a rename.
545
+ needsName: p.needsName === true,
546
+ ...(p.renamedFrom ? { renamedFrom: p.renamedFrom } : {}),
543
547
  // Provenance alignment is now an invariant; surfacing it makes a
544
548
  // future regression visible instead of silent.
545
549
  sourcesAligned: (p.sources?.length ?? 0) === p.embeddings.length,