@gotcos/glasses-server 6.27.7 → 6.27.10

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,73 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 6.27.10
4
+ - **Naming an unidentified voice now creates a real profile.** `POST /api/meeting/:id/relabel`
5
+ was TEXT ONLY: it rewrote `speaker` strings in the sidecar and never touched the
6
+ voice store. Saving "Kirstyn Blum" over 109 segments labelled that one meeting and
7
+ taught the system nothing — she never appeared in `/api/voice/profiles`, the review
8
+ panel still offered her as `new name` inside the SAME meeting, no later meeting
9
+ could match her, and there was no profile to add further chunks against.
10
+ Verified on the live store: 77 profiles, no Kirstyn Blum, while both sidecars
11
+ carried her name.
12
+ - The server already had `/api/voice/enroll-ext` for exactly this and the naming flow
13
+ never called it. Relabel now enrols the embeddings of the chunks it actually
14
+ changed, tagged `source: meeting-relabel` so a bad batch can be retracted wholesale.
15
+ - **Scoped deliberately.** Enrolment runs ONLY when a placeholder (`Ext`,
16
+ `Unidentified N`, `Speaker N`, `Unknown`) becomes a real name. Correcting one real
17
+ name to another is left alone: moving a voice between existing people is
18
+ `merge-profiles`, which is explicit and confirmation-gated. A cross-roster sweep of
19
+ this store put two DISTINCT people at 0.85 similarity, so implicit re-pointing would
20
+ poison profiles.
21
+ - Enrolment happens after the sidecar and ledger are durable, and a throwing voice
22
+ store cannot undo the rename the user asked for. `enrolledEmbeddings` is returned so
23
+ the panel can confirm a profile was created.
24
+ - Four tests, both directions mutation-checked: reverting to text-only fails the
25
+ enrolment test; dropping the placeholder guard fails both safety tests.
26
+
27
+ ## 6.27.9
28
+ - **Large sessions open instead of 413ing.** `GET /api/agent-sessions/:provider/:id`
29
+ answered `413 Session too large to open` for any transcript over 32 MiB, so the
30
+ biggest sessions — the ones most worth reviewing before a follow-up — returned
31
+ nothing at all. A 67 MB transcript is not exotic; this repo's own 2026-08-13 session
32
+ is 70 MB. Oversized files are now read as a bounded **head (256 KiB) + tail
33
+ (768 KiB)**. That 70 MB session parses in **7 ms** and yields a 1,286-char digest
34
+ with both the opening ask and the most recent turns.
35
+ - Slicing at an arbitrary byte offset is safe because `parseJsonLine` returns null for
36
+ the fragmentary first line of the tail window and the loop skips it.
37
+ - **`truncated` is reported, and the counts stop pretending.** On a partial read the
38
+ message counts are counts of what was READ. The digest therefore prints
39
+ `… middle of a large session not read …` instead of a turn number it cannot know —
40
+ a confidently wrong "… 12 earlier turns …" on a 4,000-turn session is the same
41
+ dishonesty as a silent cap.
42
+ - **Slash-command scaffolding no longer eats the two best slots.** `collectTurn` now
43
+ applies the existing `isWrapperPrompt` filter and `<user_query>` stripping, so a
44
+ digest opens with the real ask rather than `<command-message>…`.
45
+ - Window sizes are injectable. With production defaults any quick-to-build fixture is
46
+ smaller than head+tail combined, so a test would read the whole file and pass
47
+ identically with windowing REMOVED — it did, until a mutation caught it. The test
48
+ now pins the read count and fails when windowing is dropped.
49
+
50
+ ## 6.27.8
51
+ - **Session bodies get a real digest.** `GET /api/agent-sessions/:provider/:id` adds
52
+ `discussion_digest`: up to **2000 chars** of what actually happened — the opening
53
+ ask, the most recent user turns in order, and where the assistant left off.
54
+ - **The list row is untouched at 180.** Miles: "it should be in the body not the
55
+ title, the row should be no more than the 180 characters." `discussion_summary`
56
+ keeps its 180-char budget for the single-line row; the digest is a separate field
57
+ the detail page reads. One shared field could not serve both — a 2000-char gist
58
+ appended to a row destroys it.
59
+ - **No LLM, no extra reads.** `parseAgentSession` already streams every line of the
60
+ transcript to count turns; it was discarding the middle. The digest is assembled
61
+ from turns it is already parsing, so it costs no tokens and no additional I/O.
62
+ - **Elision is stated, never silent.** The store keeps the opening turns plus a
63
+ 60-turn recent window so a 900-turn session cannot balloon memory, and passes the
64
+ TRUE turn count so the `… N earlier turns …` line reports what was really dropped
65
+ rather than what the buffer happened to hold.
66
+ - Opening turns are reserved BEFORE recency. Filling from the end first starved the
67
+ original ask out of a 40-turn session entirely — caught by its own test.
68
+ - Older clients ignore the field; older servers omit it and the glasses fall back to
69
+ the 180-char summary.
70
+
3
71
  ## 6.27.7
4
72
  - **`GET /api/agent-sessions` ships in the public package.** Claude Code, Codex,
5
73
  and Cursor transcripts from this Mac, last 7 days of writes. Glasses 6.8.360
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.7",
3
+ "version": "6.27.10",
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": {
@@ -148,6 +148,102 @@ export function composeDiscussionSummary(input: {
148
148
  return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`
149
149
  }
150
150
 
151
+ /** Deep digest budget. The glasses detail page paginates at 200 chars, so 2000 is
152
+ * ~10 swipes — long by G2 standards, and deliberately so: this exists to be READ
153
+ * before deciding whether to follow up on a session, the one moment depth beats
154
+ * brevity. The LIST row keeps the short `discussion_summary`; a 2000-char gist
155
+ * appended to a single row would destroy it. Two fields, two jobs. */
156
+ export const DISCUSSION_DIGEST_MAX = 2000
157
+
158
+ /** Per-turn cap, so one enormous paste cannot eat the whole budget. */
159
+ const DIGEST_TURN_MAX = 220
160
+
161
+ /** Turns kept from the START. The opening ask frames everything after it. */
162
+ const DIGEST_HEAD_TURNS = 2
163
+
164
+ /** Recent turns retained while streaming. Comfortably more than 2000 chars can
165
+ * render (~20-25 at typical length), so the budget and not the buffer decides
166
+ * what appears. */
167
+ const DIGEST_RECENT_WINDOW = 60
168
+
169
+ /**
170
+ * A readable account of what actually happened in a session.
171
+ *
172
+ * `composeDiscussionSummary` is the opening prompt plus the last assistant snippet at
173
+ * 180 chars — it says where a session started and where it stopped, and nothing about
174
+ * the turns between, which is exactly what you need to decide whether to reopen it.
175
+ *
176
+ * Shape: the opening ask, then the MOST RECENT user turns in chronological order, then
177
+ * where the assistant left off. Recency is weighted because a follow-up continues from
178
+ * the end, not the beginning.
179
+ *
180
+ * NO LLM, by design — assembled from turns `parseAgentSession` already streams, so a
181
+ * digest costs no tokens and no extra file reads.
182
+ *
183
+ * Elision is STATED, never silent: dropping 40 turns and rendering the rest as clean
184
+ * prose reads like the whole story. `… N earlier turns …` says otherwise.
185
+ */
186
+ export function composeDiscussionDigest(input: {
187
+ userTurns: string[]
188
+ latestAssistant?: string
189
+ max?: number
190
+ /** True number of user turns in the session, when `userTurns` is a bounded sample.
191
+ * The caller keeps only head + a recent window so a 900-turn session cannot balloon
192
+ * memory, and without this the elision line would report only the turns it can see
193
+ * and quietly under-count the rest — a silent cap wearing an honest label. */
194
+ totalTurns?: number
195
+ /** The transcript was read as head+tail, so the middle was never seen and the true
196
+ * turn count is unknown. Print elision WITHOUT a number rather than a wrong one. */
197
+ truncated?: boolean
198
+ }): string {
199
+ const max = input.max ?? DISCUSSION_DIGEST_MAX
200
+ const clean = (s: string): string => s.replace(/\s+/g, ' ').trim()
201
+ const cap = (s: string): string => (s.length <= DIGEST_TURN_MAX ? s : `${s.slice(0, DIGEST_TURN_MAX - 1)}…`)
202
+
203
+ const turns = input.userTurns.map(clean).filter(Boolean).map(cap)
204
+ const latest = clean(input.latestAssistant ?? '')
205
+ if (turns.length === 0 && !latest) return ''
206
+ const total = Math.max(input.totalTurns ?? turns.length, turns.length)
207
+
208
+ // Reserve room for the closing state before spending budget on asks.
209
+ const tail = latest ? `\n\nLatest: ${cap(latest)}` : ''
210
+ let budget = max - tail.length
211
+
212
+ const head = turns.slice(0, DIGEST_HEAD_TURNS)
213
+ const rest = turns.slice(DIGEST_HEAD_TURNS)
214
+
215
+ // HEAD FIRST. Claiming the opening ask "frames everything after it" and then
216
+ // spending the budget from the end left no room for it — a 40-turn session rendered
217
+ // as "… 22 earlier turns …" with the original request nowhere on the page. Reserve
218
+ // the framing, then spend what is left on recency. Caught by its own test.
219
+ const headKept: string[] = []
220
+ for (const turn of head) {
221
+ const cost = turn.length + 3
222
+ if (cost > budget) break
223
+ headKept.push(turn)
224
+ budget -= cost
225
+ }
226
+
227
+ // Then fill from the END backwards — the turns nearest a follow-up.
228
+ const kept: string[] = []
229
+ for (let i = rest.length - 1; i >= 0; i--) {
230
+ const cost = rest[i].length + 3
231
+ if (cost > budget) break
232
+ kept.unshift(rest[i])
233
+ budget -= cost
234
+ }
235
+
236
+ const dropped = total - headKept.length - kept.length
237
+ const lines: string[] = []
238
+ for (const t of headKept) lines.push(`• ${t}`)
239
+ if (input.truncated) lines.push('… middle of a large session not read …')
240
+ else if (dropped > 0) lines.push(`… ${dropped} earlier turn${dropped === 1 ? '' : 's'} …`)
241
+ for (const t of kept) lines.push(`• ${t}`)
242
+
243
+ const body = lines.join('\n') + tail
244
+ return body.length <= max ? body : `${body.slice(0, max - 1)}…`
245
+ }
246
+
151
247
  export function assistantProseFromRecord(obj: Record<string, unknown>): string | null {
152
248
  if (obj.type === 'assistant') {
153
249
  const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
@@ -931,6 +1027,54 @@ export async function findAgentSessionFile(
931
1027
  return best?.file ?? null
932
1028
  }
933
1029
 
1030
+ /** Bytes read from the START of an oversized transcript — enough for the opening ask
1031
+ * and the session_meta record that carries cwd/branch. */
1032
+ export const PARTIAL_HEAD_BYTES = 256 * 1024
1033
+ /** Bytes read from the END. Larger than the head because the recent turns are what a
1034
+ * follow-up continues from. */
1035
+ export const PARTIAL_TAIL_BYTES = 768 * 1024
1036
+
1037
+ /**
1038
+ * Lines of a transcript, reading the WHOLE file when it fits and head+tail when it
1039
+ * does not.
1040
+ *
1041
+ * WHY. `GET /api/agent-sessions/:provider/:id` used to answer 413 "Session too large
1042
+ * to open" for anything over 32 MiB, so the biggest sessions — the ones most worth
1043
+ * summarizing before a follow-up — returned nothing at all. A 67 MB transcript is
1044
+ * real; today's own session is one. Refusing was never necessary: the detail page
1045
+ * needs the opening turns, the recent turns, and the counts, and two bounded windows
1046
+ * carry all three without loading 67 MB into memory.
1047
+ *
1048
+ * The first line of the tail window is almost always a fragment of a JSON record.
1049
+ * `parseJsonLine` returns null for it and the caller's loop skips it, which is why
1050
+ * this can slice at an arbitrary byte offset and stay correct.
1051
+ */
1052
+ export async function* agentSessionLines(
1053
+ path: string,
1054
+ size: number,
1055
+ maxBytes: number,
1056
+ // Window sizes are injectable so a test can prove ELISION without writing a
1057
+ // multi-megabyte fixture. With the production 256 KiB + 768 KiB defaults, any
1058
+ // fixture small enough to build quickly is fully covered by the two windows — so
1059
+ // the test would read the whole file, see every turn, and pass identically with
1060
+ // windowing removed. It did exactly that until a mutation caught it.
1061
+ headBytes = PARTIAL_HEAD_BYTES,
1062
+ tailBytes = PARTIAL_TAIL_BYTES,
1063
+ ): AsyncGenerator<string> {
1064
+ if (size <= maxBytes) {
1065
+ yield* createInterface({ input: createReadStream(path), crlfDelay: Infinity })
1066
+ return
1067
+ }
1068
+ yield* createInterface({
1069
+ input: createReadStream(path, { start: 0, end: headBytes - 1 }),
1070
+ crlfDelay: Infinity,
1071
+ })
1072
+ yield* createInterface({
1073
+ input: createReadStream(path, { start: Math.max(headBytes, size - tailBytes) }),
1074
+ crlfDelay: Infinity,
1075
+ })
1076
+ }
1077
+
934
1078
  export interface AgentSessionDetail {
935
1079
  session_id: string
936
1080
  provider: AgentProvider
@@ -939,14 +1083,27 @@ export interface AgentSessionDetail {
939
1083
  git_branch: string
940
1084
  first_prompt: string
941
1085
  discussion_summary: string
1086
+ /** Deep, paginated body text for the detail page — up to 2000 chars. The list row
1087
+ * keeps `discussion_summary` at 180. Older clients ignore this field. */
1088
+ discussion_digest: string
942
1089
  user_message_count: number
943
1090
  assistant_message_count: number
944
1091
  omitted_tools: number
945
1092
  file_size_bytes: number
1093
+ /** True when the transcript exceeded the read ceiling and only head+tail were
1094
+ * parsed. The message counts are then counts of what was READ, not of the
1095
+ * session — the client must not present them as exact. */
1096
+ truncated: boolean
946
1097
  }
947
1098
 
948
- export async function parseAgentSession(provider: AgentProvider, path: string): Promise<AgentSessionDetail> {
1099
+ export async function parseAgentSession(
1100
+ provider: AgentProvider,
1101
+ path: string,
1102
+ opts: { maxBytes?: number; headBytes?: number; tailBytes?: number } = {},
1103
+ ): Promise<AgentSessionDetail> {
949
1104
  const st = await stat(path)
1105
+ const maxBytes = opts.maxBytes ?? AGENT_SESSION_MAX_FILE_BYTES
1106
+ const truncated = st.size > maxBytes
950
1107
  let title = ''
951
1108
  let project = ''
952
1109
  let gitBranch = ''
@@ -954,11 +1111,38 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
954
1111
  let firstPrompt = ''
955
1112
  let latestAssistant = ''
956
1113
  let userCount = 0
1114
+ // Bounded sample for the deep digest: the opening turns plus a recent window.
1115
+ // The composer only ever renders head + as many recent as fit 2000 chars, so
1116
+ // retaining the whole conversation would be memory held for nothing — a 900-turn
1117
+ // session is real. `userCount` still carries the TRUE total so the elision line
1118
+ // reports what was actually dropped rather than what this buffer happens to hold.
1119
+ const digestHead: string[] = []
1120
+ const digestRecent: string[] = []
1121
+ const collectTurn = (text: string): void => {
1122
+ // Reuse the wrapper filter the rest of this module already trusts. Without it the
1123
+ // opening turns of a slash-command session render as
1124
+ // "<command-message>cos-glasses</command-message>…" — scaffolding, not the ask,
1125
+ // and it lands in the two most valuable slots in the digest.
1126
+ const raw = (text ?? '').trim()
1127
+ if (!raw || isWrapperPrompt(raw)) return
1128
+ // Same tag-stripping firstLineTitle does, so an inline <user_query> wrapper does
1129
+ // not leak angle brackets into the body.
1130
+ const unwrapped = (() => {
1131
+ const start = raw.indexOf('<user_query>')
1132
+ const end = raw.indexOf('</user_query>')
1133
+ const inner = start >= 0 && end > start ? raw.slice(start + '<user_query>'.length, end) : raw
1134
+ return inner.replace(/<[^>]+>/g, ' ')
1135
+ })()
1136
+ const line = unwrapped.replace(/\s+/g, ' ').trim()
1137
+ if (!line) return
1138
+ if (digestHead.length < DIGEST_HEAD_TURNS) { digestHead.push(line.slice(0, DIGEST_TURN_MAX)); return }
1139
+ digestRecent.push(line.slice(0, DIGEST_TURN_MAX))
1140
+ if (digestRecent.length > DIGEST_RECENT_WINDOW) digestRecent.shift()
1141
+ }
957
1142
  let assistantCount = 0
958
1143
  let omittedTools = 0
959
1144
 
960
- const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity })
961
- for await (const line of rl) {
1145
+ for await (const line of agentSessionLines(path, st.size, maxBytes, opts.headBytes, opts.tailBytes)) {
962
1146
  const obj = parseJsonLine(line)
963
1147
  if (!obj) continue
964
1148
  if (provider === 'claude') {
@@ -980,6 +1164,7 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
980
1164
  }
981
1165
  if (obj.type === 'user') {
982
1166
  userCount += 1
1167
+ collectTurn(text)
983
1168
  if (!firstPrompt) firstPrompt = firstLineTitle(text)
984
1169
  if (!title) title = firstLineTitle(text)
985
1170
  } else {
@@ -1012,6 +1197,7 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
1012
1197
  if (snippet) latestAssistant = snippet
1013
1198
  } else {
1014
1199
  userCount += 1
1200
+ collectTurn(text)
1015
1201
  if (!firstPrompt) firstPrompt = firstLineTitle(text)
1016
1202
  if (!title) title = firstLineTitle(text)
1017
1203
  }
@@ -1026,6 +1212,7 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
1026
1212
  if (obj.role === 'user') {
1027
1213
  userCount += 1
1028
1214
  const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
1215
+ collectTurn(query ?? text)
1029
1216
  if (query) {
1030
1217
  title = query
1031
1218
  if (!firstPrompt) firstPrompt = query
@@ -1057,9 +1244,19 @@ export async function parseAgentSession(provider: AgentProvider, path: string):
1057
1244
  firstPrompt: firstPrompt || title,
1058
1245
  latestAssistant,
1059
1246
  }),
1247
+ discussion_digest: composeDiscussionDigest({
1248
+ userTurns: [...digestHead, ...digestRecent],
1249
+ latestAssistant,
1250
+ // On a truncated read `userCount` counts only the sampled windows, so passing
1251
+ // it as the total would print a confidently WRONG "… 12 earlier turns …" for a
1252
+ // session with hundreds. The digest drops the number instead of inventing one.
1253
+ totalTurns: truncated ? undefined : userCount,
1254
+ truncated,
1255
+ }),
1060
1256
  user_message_count: userCount,
1061
1257
  assistant_message_count: assistantCount,
1062
1258
  omitted_tools: omittedTools,
1063
1259
  file_size_bytes: st.size,
1260
+ truncated,
1064
1261
  }
1065
1262
  }
@@ -19,7 +19,6 @@ import { stat } from 'node:fs/promises'
19
19
  import {
20
20
  AGENT_SESSION_LIST_LIMIT,
21
21
  AGENT_SESSION_LIST_MAX,
22
- AGENT_SESSION_MAX_FILE_BYTES,
23
22
  AGENT_SESSION_WINDOW_HOURS,
24
23
  agentSessionRoots,
25
24
  findAgentSessionFile,
@@ -156,10 +155,17 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
156
155
  return
157
156
  }
158
157
  const st = await stat(found)
159
- if (st.size > AGENT_SESSION_MAX_FILE_BYTES) {
160
- res.status(413).json({ error: 'Session too large to open', reason: 'session_too_large' })
161
- return
162
- }
158
+ // Oversized transcripts are READ IN PART, not refused.
159
+ //
160
+ // This used to answer 413 "Session too large to open" above 32 MiB, which made the
161
+ // biggest sessions — the ones most worth reviewing before a follow-up — completely
162
+ // unopenable on the glasses. A 67 MB transcript is not exotic; this repo's own
163
+ // 2026-08-13 session is one. The detail page needs the opening turns, the recent
164
+ // turns, and stats, and a bounded head+tail carries all three.
165
+ //
166
+ // `parsed.truncated` says so out loud, and the counts are then counts of what was
167
+ // READ. Presenting a partial count as the session total would be the same
168
+ // dishonesty as a silent cap, so the digest omits the number entirely instead.
163
169
  const parsed = await parseAgentSession(provider, found)
164
170
  if (provider === 'cursor') {
165
171
  const names = await loadCursorComposerNames(agentSessionRoots().cursorComposerDb)
@@ -175,6 +181,11 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
175
181
  display_label: parsed.display_label,
176
182
  first_prompt: parsed.first_prompt,
177
183
  discussion_summary: parsed.discussion_summary || '',
184
+ // Detail only. The list row (line 73) deliberately stays on the 180-char
185
+ // summary — Miles: "it should be in the body not the title, the row should
186
+ // be no more than the 180 characters."
187
+ discussion_digest: parsed.discussion_digest || '',
188
+ truncated: parsed.truncated,
178
189
  project: parsed.project,
179
190
  created: modified,
180
191
  modified,
@@ -23,7 +23,8 @@ import {
23
23
  meetingAudioChunkPath,
24
24
  meetingAudioRetentionDays,
25
25
  } from '../lib/meeting-audio-archive.js'
26
- import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
26
+ import { enrollEmbedding, readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
27
+ import { chunkEmbeddingsForIndices } from '../lib/chunk-embedding-store.js'
27
28
 
28
29
  /**
29
30
  * The label a de-attributed voice takes, numbered within its meeting.
@@ -974,6 +975,46 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
974
975
  })
975
976
  })
976
977
 
978
+ /** Labels the diariser invents when it does not know who is speaking. Naming one
979
+ * of these is a FIRST TRAINING RUN for a new person, not a correction. */
980
+ const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+)$/i
981
+ const isPlaceholderLabel = (label: string): boolean => PLACEHOLDER_LABEL.test(label.trim())
982
+
983
+ /**
984
+ * Turn a named placeholder into a real voice profile.
985
+ *
986
+ * WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
987
+ * meeting sidecar and never touched the voice store. So naming an unidentified
988
+ * voice "Kirstyn Blum" labelled that one meeting and taught the system nothing —
989
+ * she never appeared in `/api/voice/profiles`, the review panel still offered her
990
+ * as `new name` inside the SAME meeting, no later meeting could match her, and
991
+ * there was no profile to accumulate further chunks against. The server already
992
+ * had `/api/voice/enroll-ext` for exactly this and the naming flow never called it.
993
+ *
994
+ * Enrolment is ADDITIVE and scoped: it runs only when a placeholder becomes a real
995
+ * name. Correcting one real name to another is left alone deliberately — moving a
996
+ * voice between existing people is `merge-profiles`, which is explicit and
997
+ * confirmation-gated, and doing it implicitly here would poison profiles.
998
+ *
999
+ * `enrollEmbedding` owns the diversity gate and the FIFO cap, so feeding it the
1000
+ * relabelled chunks cannot bloat a profile.
1001
+ */
1002
+ const enrolNamedVoice = (sessionId: string, from: string, to: string, changed: number[]): number => {
1003
+ if (!isPlaceholderLabel(from) || isPlaceholderLabel(to) || changed.length === 0) return 0
1004
+ let enrolled = 0
1005
+ try {
1006
+ for (const row of chunkEmbeddingsForIndices(sessionId, changed)) {
1007
+ if (!row.embedding) continue
1008
+ if (enrollEmbedding(to, row.embedding, 'meeting-relabel').success) enrolled += 1
1009
+ }
1010
+ } catch {
1011
+ // Enrolment is a bonus on top of the relabel. A voice store that refuses must
1012
+ // not fail the rename the user actually asked for.
1013
+ return enrolled
1014
+ }
1015
+ return enrolled
1016
+ }
1017
+
977
1018
  router.post('/meeting/:sessionId/relabel', (req, res) => {
978
1019
  res.set('Cache-Control', 'private, no-store')
979
1020
  const sessionId = String(req.params.sessionId ?? '')
@@ -1150,7 +1191,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1150
1191
  proseStale: preview.proseStale,
1151
1192
  })
1152
1193
 
1153
- res.json({ ok: true, correctionId: id, ...preview })
1194
+ // Enrol AFTER the sidecar and ledger are durable: the rename is the thing the
1195
+ // user asked for, and a voice store that refuses must not undo it. Reported so
1196
+ // the panel can say a profile was created rather than leaving the user to
1197
+ // discover, in another meeting, that it was not.
1198
+ const enrolled = enrolNamedVoice(sessionId, from, to, plan.value.changed)
1199
+ res.json({ ok: true, correctionId: id, enrolledEmbeddings: enrolled, ...preview })
1154
1200
  })
1155
1201
 
1156
1202