@gotcos/glasses-server 6.44.14 → 6.44.16

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,38 @@
1
+ ## 6.44.16
2
+
3
+ A G2 recording keeps its session id when its sidecar only exists in the server's own recordings folder.
4
+
5
+ - Chelsie and Queen, 2026-09-08: "Meetings to review is empty, and it says 3
6
+ recent meetings have no session id." On Miles's Mac the same three-row case
7
+ was three KC-week recordings whose markdown reached the operations tree
8
+ through git while the `.g2-chunks.json` sidecar, which is gitignored there,
9
+ did not; the row lost its session id and Speakers could not open it.
10
+ - `listCosOperationsMeetings` now reads the sidecar from the server's own
11
+ recordings root by the same stem when the copy beside the markdown is
12
+ missing (`recordingsRoot` option, default the data dir). The review lookup
13
+ already resolves a session through the standalone store, so the row opens.
14
+ A meeting with no sidecar anywhere still omits the field rather than
15
+ inventing one.
16
+
17
+ ## 6.44.15
18
+
19
+ Voice profiles named after a spoken sentence are repaired at load.
20
+
21
+ - Chelsie 2026-09-08: her `voice-profiles.json` held two profiles whose name
22
+ was the entire enrolment speech ("My Voice My Name Is Chelsie Hodgkiss And I
23
+ Am The Director…", ~600 characters), written by a client older than the
24
+ 8/25 guards. Speaker ID was "active" and could never match anything, and the
25
+ file cannot be repaired by hand because the server rewrites it from memory.
26
+ - `normalizeProfileStore` now renames a name that is too long, has more than
27
+ four words, or is three-plus words with sentence punctuation to
28
+ `Unnamed voice N`, keeps every embedding and its provenance, records
29
+ `renamedFrom` (the first 60 characters) and `needsName: true`, and counts it
30
+ in the load repairs (`profilesRenamed`). Legacy short labels ("MU",
31
+ "Speaker 2", "Luke H.") are never touched. `GET /api/voice/profiles` carries
32
+ `needsName` and `renamedFrom` so COS Control's speaker review can offer the
33
+ rename; `POST /api/voice/merge-profiles` folds the placeholder into the
34
+ right person as before.
35
+
1
36
  ## 6.44.14
2
37
 
3
38
  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.16",
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": {
@@ -19,6 +19,7 @@ import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName a
19
19
  import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
20
20
  import { MEETING_SOURCE_MAX_BYTES, meetingDayCountsFromNames, meetingListLimit } from './meeting-store.js'
21
21
  import { meetingVoiceReview, parseSidecarListHead } from './meeting-voice-review.js'
22
+ import { dataPath } from './data-dir.js'
22
23
 
23
24
  /**
24
25
  * The four domains of ONE user's COS. Retained as the documented example layout
@@ -528,9 +529,12 @@ export function listCosOperationsMeetings(options: {
528
529
  domain?: string
529
530
  month?: string
530
531
  day?: string
532
+ /** The server's own recordings root; tests point it at a temp dir. */
533
+ recordingsRoot?: string
531
534
  } = {}): CosOperationsMeetingMeta[] {
532
535
  const operationsDir = resolveCosOperationsDir()
533
536
  if (!operationsDir) return []
537
+ const recordingsRoot = options.recordingsRoot ?? dataPath('recordings')
534
538
 
535
539
  const scoped = Boolean(options.month || options.day)
536
540
  const limit = meetingListLimit(options.limit, scoped)
@@ -569,7 +573,17 @@ export function listCosOperationsMeetings(options: {
569
573
  meta.recordId = `ops:${domain}:${month}:${file}`
570
574
  meta.mutable = true
571
575
  meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
572
- const hints = sidecarListHints(monthDir, file)
576
+ // The sidecar is gitignored in the operations tree, so a G2 recording
577
+ // whose markdown arrived through git or iCloud (three of Miles's KC-week
578
+ // recordings, 2026-09-08) sits there without one and the row loses its
579
+ // session id, which reads as "cannot be reviewed". The server that saved
580
+ // it still holds the sidecar under its own recordings root by the same
581
+ // stem; read that one when the copy beside the markdown is missing.
582
+ let hints = sidecarListHints(monthDir, file)
583
+ if (!hints.sessionId && recordingsRoot) {
584
+ const fallbackDir = join(recordingsRoot, month)
585
+ if (existsSync(fallbackDir)) hints = sidecarListHints(fallbackDir, file)
586
+ }
573
587
  if (hints.sessionId) meta.sessionId = hints.sessionId
574
588
  if (hints.speakers.length > 0) {
575
589
  meta.voiceReview = meetingVoiceReview(hints.speakers, hints.sessionId)
@@ -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,