@gotcos/glasses-server 6.27.12 → 6.27.13

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,32 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.27.13
4
+ - **`POST /api/meeting/:sessionId/backfill-enrolment`** — train a profile from a voice
5
+ that was named BEFORE enrolment shipped. Those meetings have a correct transcript and
6
+ no profile, and re-running the rename cannot help: the voice is already a real name
7
+ there, so the placeholder guard correctly declines. The live case is Kirstyn Blum —
8
+ 60 and 109 chunks across two meetings, 182 sidecar mentions, absent from a 77-profile
9
+ store.
10
+ - The correction LEDGER already holds what enrolment needs: the original `from`, the
11
+ `to`, and the exact chunk indices written at apply time. This replays those rows
12
+ through the SAME `enrolNamedVoice` — raw-index mapping, refusal when unmappable,
13
+ coherence, the diversity cap and the `correction:<sessionId>` tag all apply
14
+ identically. No second implementation to drift.
15
+ - **Named-source corrections are excluded, by the existing rule rather than a new one.**
16
+ `Ext -> Kirstyn` is training data; `Allison Wheeler -> Kirstyn` is a mis-attribution
17
+ fix, and training on it would put Allison's voice into Kirstyn's profile. Both rows
18
+ are reported; only the placeholder one attempts anything. Mutation-verified: dropping
19
+ the placeholder guard fails that test.
20
+ - **Runs in-process, which is the point.** The voice store is owned by the running
21
+ server and rewritten wholesale, so an external process that enrols directly has its
22
+ work silently clobbered. Not hypothetical — an attempt on 2026-08-13 validated
23
+ cleanly, selected 20 samples, and left the store untouched at its Aug 7 mtime.
24
+ - **Fails closed.** Without `confirm: true` it reports what it would enrol and writes
25
+ nothing. The preview runs every gate — a preview that skipped them would be a guess
26
+ about what the real call does — so `enrolNamedVoice` gained a `dryRun` flag. The
27
+ projection can land HIGHER than reality, because only `enrollEmbedding` can judge
28
+ near-duplicates against the live profile; that is documented on the flag.
29
+
3
30
  ## 6.27.12
4
31
  - **Naming an unidentified voice creates a real speaker profile — correctly this time.**
5
32
  Re-enables what 6.27.10 shipped broken and 6.27.11 disabled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.12",
3
+ "version": "6.27.13",
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": {
@@ -169,6 +169,11 @@ export interface EnrolNamedVoiceInput {
169
169
  changed: number[]
170
170
  /** The parsed sidecar, for `chunks` + `chunkEntries` + `startTime`. */
171
171
  sidecar: Record<string, unknown>
172
+ /** Run every gate but write nothing. `enrolled` then counts what WOULD be enrolled;
173
+ * the real call can land LOWER, because `enrollEmbedding`'s dedup gate rejects
174
+ * near-duplicates and only it can judge that against the live profile. A preview
175
+ * that skipped the gates would be a guess about what the real call does. */
176
+ dryRun?: boolean
172
177
  }
173
178
 
174
179
  /**
@@ -238,7 +243,11 @@ export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
238
243
  const source = `correction:${sessionId}`
239
244
  let enrolled = 0
240
245
  try {
241
- for (const embedding of selected) {
246
+ if (input.dryRun) {
247
+ // Projection, not a promise — see the dryRun docblock. Every gate above has
248
+ // already run, so this is what the real call will attempt.
249
+ enrolled = selected.length
250
+ } else for (const embedding of selected) {
242
251
  if (enrollEmbedding(to, embedding, source).success) enrolled += 1
243
252
  }
244
253
  } catch {
@@ -975,6 +975,96 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
975
975
  })
976
976
  })
977
977
 
978
+ /**
979
+ * Replay a recorded correction's enrolment — the retroactive path.
980
+ *
981
+ * WHY. Enrolment fires inside `POST /relabel`, so a voice named BEFORE that shipped
982
+ * has a correct transcript and no profile. Kirstyn Blum is the live case: named
983
+ * across two meetings (60 and 109 chunks), 182 mentions in the sidecars, absent from
984
+ * a 77-profile store. Re-running the rename cannot help — she is already a real
985
+ * name there, so the placeholder guard correctly declines.
986
+ *
987
+ * The correction LEDGER already holds exactly what enrolment needs: the original
988
+ * `from`, the `to`, and the precise chunk indices, written at apply time.
989
+ *
990
+ * This runs IN-PROCESS on purpose. The voice store is owned by the running server,
991
+ * which holds it in memory and rewrites it wholesale; an external process that
992
+ * enrols directly has its work silently clobbered on the next persist. That is not
993
+ * hypothetical — an attempt on 2026-08-13 validated cleanly, selected 20 samples,
994
+ * and left the store untouched at its Aug 7 mtime.
995
+ *
996
+ * SAME GATES, no exceptions. It calls `enrolNamedVoice`, so raw-index mapping,
997
+ * refusal when unmappable, voice coherence, the diversity cap and the
998
+ * `correction:<sessionId>` tag all apply identically. Rows whose `from` is a real
999
+ * person are skipped by that function's own placeholder rule, which is what keeps a
1000
+ * mis-attribution correction (Allison Wheeler -> Kirstyn) out of the training set.
1001
+ *
1002
+ * FAILS CLOSED. Without `confirm: true` it reports what it would enrol and writes
1003
+ * nothing.
1004
+ */
1005
+ router.post('/meeting/:sessionId/backfill-enrolment', (req, res) => {
1006
+ res.set('Cache-Control', 'private, no-store')
1007
+ const sessionId = String(req.params.sessionId ?? '')
1008
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1009
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1010
+ return
1011
+ }
1012
+ const speaker = typeof req.body?.speaker === 'string' ? req.body.speaker.trim() : ''
1013
+ if (!speaker) {
1014
+ res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
1015
+ return
1016
+ }
1017
+
1018
+ const operations = cosOperationsMeetingsConfigured()
1019
+ ? findCosOperationsMeetingBySessionId(sessionId)
1020
+ : null
1021
+ const saved = operations ? null : store.findBySessionId(sessionId)
1022
+ if (!operations && !saved) {
1023
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
1024
+ return
1025
+ }
1026
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
1027
+ let parsedSidecar: Record<string, unknown> | null = null
1028
+ try {
1029
+ const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as unknown
1030
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
1031
+ } catch {
1032
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
1033
+ return
1034
+ }
1035
+
1036
+ // Only rows that ACTUALLY landed, and only those that named this speaker.
1037
+ const rows = appliedCorrections(sessionId).filter(r => r.to === speaker && r.chunks.length > 0)
1038
+ if (rows.length === 0) {
1039
+ res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
1040
+ return
1041
+ }
1042
+
1043
+ const confirm = req.body?.confirm === true
1044
+ const reports = rows.map(row => ({
1045
+ correctionId: row.id,
1046
+ from: row.from,
1047
+ chunks: row.chunks.length,
1048
+ // Dry run still evaluates every gate — a preview that skips them would be a
1049
+ // guess about what the real call is going to do.
1050
+ report: enrolNamedVoice({
1051
+ sessionId, from: row.from, to: speaker, changed: row.chunks, sidecar: parsedSidecar!, dryRun: !confirm,
1052
+ }),
1053
+ }))
1054
+
1055
+ res.json({
1056
+ ok: true,
1057
+ speaker,
1058
+ confirmed: confirm,
1059
+ corrections: reports,
1060
+ totals: {
1061
+ eligible: reports.filter(r => r.report.attempted > 0).length,
1062
+ skippedNamedSource: reports.filter(r => r.report.attempted === 0 && !r.report.skipped).length,
1063
+ enrolled: reports.reduce((n, r) => n + r.report.enrolled, 0),
1064
+ },
1065
+ })
1066
+ })
1067
+
978
1068
  router.post('/meeting/:sessionId/relabel', (req, res) => {
979
1069
  res.set('Cache-Control', 'private, no-store')
980
1070
  const sessionId = String(req.params.sessionId ?? '')