@gotcos/glasses-server 6.16.5 → 6.17.0

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.
@@ -8,18 +8,62 @@
8
8
  import { resolve } from 'node:path'
9
9
  import { errMsg } from './utils.js'
10
10
  import { getOwnerSpeakerLabel } from './profile.js'
11
- import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { spawnSync } from 'node:child_process'
12
14
  import { fileURLToPath } from 'node:url'
13
15
 
16
+ /** A real voiceprint model is ~26 MB; anything this small is a bad download. */
17
+ const MIN_MODEL_BYTES = 1_000_000
18
+ const PROBE_TIMEOUT_MS = 30_000
19
+
14
20
  // sherpa-onnx-node is CJS — use createRequire for ESM compat
15
21
  import { createRequire } from 'node:module'
16
22
  const require = createRequire(import.meta.url)
17
23
 
18
24
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
25
 
20
- const MODEL_PATH = resolve(__dirname, '..', 'models',
21
- '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx')
22
26
  import { DATA_DIR } from './data-dir.js'
27
+
28
+ export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
29
+
30
+ /** Where the voiceprint model may live, in priority order:
31
+ *
32
+ * 1. COS_SPEAKER_MODEL_PATH — explicit override (full path to the .onnx).
33
+ * 2. ~/.cos-glasses/models/ — the bolt-on location. The model is ~26 MB and is
34
+ * deliberately NOT in the npm tarball, so a managed install has no bundled
35
+ * copy. The data home survives generation swaps; anything inside the
36
+ * installed package does not, and a model dropped there is destroyed by the
37
+ * next update.
38
+ * 3. server/models/ — bundled, which only exists in a source checkout.
39
+ *
40
+ * Anchored on homedir() rather than DATA_DIR/'..' on purpose: path.resolve is
41
+ * purely lexical, so deriving a sibling of a relocated COS_DATA_DIR could point
42
+ * the "durable" candidate at an unwritable root, or — if COS_DATA_DIR were ever
43
+ * set inside the package — collapse it back onto the very directory an update
44
+ * destroys, silently reintroducing the bug this ordering exists to fix.
45
+ *
46
+ * Diarization is opt-in by design: with no model the system stays on amplitude
47
+ * fallback (wearer vs Ext) rather than failing. speakerModelState() exists so
48
+ * that choice is VISIBLE in /api/health instead of silently degrading.
49
+ */
50
+ export function speakerModelCandidates(): string[] {
51
+ const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
52
+ return [
53
+ ...(override ? [resolve(override)] : []),
54
+ resolve(homedir(), '.cos-glasses', 'models', SPEAKER_MODEL_FILENAME),
55
+ resolve(__dirname, '..', 'models', SPEAKER_MODEL_FILENAME),
56
+ ]
57
+ }
58
+
59
+ function resolveSpeakerModelPath(): string | null {
60
+ // isFile, not existsSync: a directory passes an existence check and would be
61
+ // handed to the native loader as a model.
62
+ return speakerModelCandidates().find(p => {
63
+ try { return statSync(p).isFile() } catch { return false }
64
+ }) ?? null
65
+ }
66
+
23
67
  const PROFILES_PATH = resolve(DATA_DIR, 'voice-profiles.json')
24
68
  const CALIBRATION_LOG = resolve(DATA_DIR, 'speaker-calibration.jsonl')
25
69
 
@@ -58,14 +102,109 @@ interface ProfileStore {
58
102
  profiles: VoiceProfile[]
59
103
  }
60
104
 
105
+ /** Cheap structural screen for a downloaded model.
106
+ *
107
+ * Catches the common bad downloads — an HTML error/redirect page saved as
108
+ * .onnx, or a truncated transfer — before the file reaches the native runtime.
109
+ * ONNX is protobuf: field 1 (ir_version, varint) encodes as a leading 0x08.
110
+ * This is a screen, not validation; probeModelSafely() is the real gate. */
111
+ function looksLikeOnnxModel(path: string): boolean {
112
+ try {
113
+ if (statSync(path).size < MIN_MODEL_BYTES) return false
114
+ const head = Buffer.alloc(1)
115
+ const fd = openSync(path, 'r')
116
+ try { readSync(fd, head, 0, 1, 0) } finally { closeSync(fd) }
117
+ return head[0] === 0x08
118
+ } catch { return false }
119
+ }
120
+
121
+ /** Load the model in a throwaway child process first.
122
+ *
123
+ * onnxruntime does not throw on a malformed or mismatched model — it calls
124
+ * std::terminate, so the process dies with SIGABRT (or SIGSEGV for a valid-but-
125
+ * wrong model). No try/catch can intercept that. In a managed install the
126
+ * LaunchAgent has KeepAlive, so the death becomes a permanent restart loop that
127
+ * takes down queries, meetings, and transcription — the whole server, over an
128
+ * optional feature.
129
+ *
130
+ * Absorbing that crash in a child keeps a bad file a diarization problem
131
+ * instead of an outage. Cost is one short-lived process, once, and only when a
132
+ * model is actually present. */
133
+ function probeModelSafely(modelPath: string): { ok: true } | { ok: false; reason: string } {
134
+ const script =
135
+ "const{SpeakerEmbeddingExtractor}=require('sherpa-onnx-node');" +
136
+ "new SpeakerEmbeddingExtractor({model:process.argv[1],numThreads:1,provider:'cpu'});"
137
+ const probe = spawnSync(process.execPath, ['-e', script, modelPath], {
138
+ cwd: resolve(__dirname, '..', '..'), // package root, so sherpa-onnx-node resolves
139
+ timeout: PROBE_TIMEOUT_MS,
140
+ stdio: ['ignore', 'ignore', 'pipe'],
141
+ })
142
+ if (probe.signal) return { ok: false, reason: `native runtime aborted (${probe.signal})` }
143
+ if (probe.error) return { ok: false, reason: probe.error.message }
144
+ if (probe.status !== 0) {
145
+ const stderr = String(probe.stderr ?? '').trim().split('\n').pop() ?? ''
146
+ return { ok: false, reason: stderr || `probe exited ${probe.status}` }
147
+ }
148
+ return { ok: true }
149
+ }
150
+
61
151
  /** Initialize speaker embedding system. Returns false if model missing (graceful degradation). */
62
152
  export function initSpeakerEmbeddings(): boolean {
63
153
  if (initialized) return extractor !== null
64
154
 
65
155
  initialized = true
66
156
 
67
- if (!existsSync(MODEL_PATH)) {
68
- console.log('[speaker] Model not found at', MODEL_PATH, '— embedding disabled, using amplitude fallback')
157
+ const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
158
+ if (override) {
159
+ const overridePath = resolve(override)
160
+ let usable = false
161
+ try { usable = statSync(overridePath).isFile() } catch { usable = false }
162
+ if (!usable) {
163
+ // Falling through silently would leave the operator believing an override
164
+ // they mistyped (or pointed at a directory) is in effect.
165
+ console.warn(
166
+ `[speaker] COS_SPEAKER_MODEL_PATH=${overridePath} is not a readable file — ignoring it`,
167
+ 'and falling back to the remaining candidates.',
168
+ )
169
+ }
170
+ if (override !== overridePath) {
171
+ // Relative paths resolve against cwd, which under launchd is the installed
172
+ // package — a location the next update deletes.
173
+ console.warn(
174
+ `[speaker] COS_SPEAKER_MODEL_PATH is relative; resolved against the working directory to ${overridePath}.`,
175
+ 'Use an absolute path so it cannot move with the process.',
176
+ )
177
+ }
178
+ }
179
+
180
+ const modelPath = resolveSpeakerModelPath()
181
+ if (!modelPath) {
182
+ const boltOn = resolve(homedir(), '.cos-glasses', 'models')
183
+ console.log(
184
+ '[speaker] Voiceprint model not found — embedding disabled, speaker labels come from the client.',
185
+ `Searched: ${speakerModelCandidates().join(', ')}.`,
186
+ // Name the durable directory outright. An ordinal ("the second path")
187
+ // shifts with the override and pointed users at the package copy, which
188
+ // the next update deletes.
189
+ `To enable diarization put ${SPEAKER_MODEL_FILENAME} in ${boltOn}/ and restart.`,
190
+ )
191
+ return false
192
+ }
193
+
194
+ if (!looksLikeOnnxModel(modelPath)) {
195
+ console.error(
196
+ `[speaker] ${modelPath} does not look like an ONNX model (too small, or not protobuf) —`,
197
+ 'embedding disabled. A partial download or an HTML error page saved as .onnx does this.',
198
+ )
199
+ return false
200
+ }
201
+
202
+ const probe = probeModelSafely(modelPath)
203
+ if (!probe.ok) {
204
+ console.error(
205
+ `[speaker] ${modelPath} failed to load — embedding disabled. Reason: ${probe.reason}.`,
206
+ 'The server is otherwise unaffected; replace the model file and restart.',
207
+ )
69
208
  return false
70
209
  }
71
210
 
@@ -73,7 +212,7 @@ export function initSpeakerEmbeddings(): boolean {
73
212
  sherpaOnnx = require('sherpa-onnx-node')
74
213
 
75
214
  extractor = new sherpaOnnx.SpeakerEmbeddingExtractor({
76
- model: MODEL_PATH,
215
+ model: modelPath,
77
216
  numThreads: 2,
78
217
  provider: 'cpu',
79
218
  })
@@ -332,6 +471,30 @@ export function isEmbeddingAvailable(): boolean {
332
471
  return extractor !== null && manager !== null
333
472
  }
334
473
 
474
+ /** Reported on /api/health so an amplitude fallback is never mistaken for real
475
+ * diarization.
476
+ *
477
+ * `state` is the RUNTIME truth (isEmbeddingAvailable), not "is a model file on
478
+ * disk". Those diverge in both directions: a model deleted after a successful
479
+ * load leaves diarization working from memory, and a model present alongside a
480
+ * broken/ABI-mismatched sherpa-onnx never loads at all. `error` distinguishes
481
+ * that second case — a model is installed but the runtime rejected it — from a
482
+ * simply unconfigured install, which otherwise look identical to an operator.
483
+ *
484
+ * Declared after the module state it reads: hoisting it above `extractor`
485
+ * would make any import-time caller throw a ReferenceError on an
486
+ * unauthenticated endpoint. */
487
+ export function speakerModelState(): {
488
+ state: 'active' | 'unavailable' | 'error'
489
+ path: string | null
490
+ searched: string[]
491
+ } {
492
+ const path = resolveSpeakerModelPath()
493
+ const running = isEmbeddingAvailable()
494
+ const state = running ? 'active' : (path && initialized ? 'error' : 'unavailable')
495
+ return { state, path, searched: speakerModelCandidates() }
496
+ }
497
+
335
498
  /** Compute actual cosine similarity between two raw embedding vectors */
336
499
  export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
337
500
  if (a.length !== b.length) return 0
@@ -7,6 +7,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
7
7
  import { getServerInstanceId } from '../lib/server-instance-id.js'
8
8
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
9
9
  import { isSileroAvailable } from '../lib/vad-silero.js'
10
+ import { speakerModelState } from '../lib/speaker-embeddings.js'
10
11
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
12
  import {
12
13
  isWhisperLocalAvailable,
@@ -176,6 +177,12 @@ healthRouter.get('/health', async (_req, res) => {
176
177
 
177
178
  checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
178
179
 
180
+ // Speaker diarization is opt-in (the ~26 MB voiceprint model ships outside the
181
+ // npm tarball), so publish its state rather than letting the amplitude
182
+ // fallback masquerade as working diarization. Availability only — the resolved
183
+ // path is a local filesystem detail and health is unauthenticated.
184
+ checks.speaker_id = speakerModelState().state
185
+
179
186
  // Health is unauthenticated. Publish only availability; the actual CLI
180
187
  // session id is a resumable runtime handle and belongs on authenticated
181
188
  // query/debug surfaces.
@@ -6,7 +6,7 @@
6
6
  // fresh client continues the sequence instead of reusing numbers.
7
7
  //
8
8
  // GET /api/message/:num → { globalMsgNum, date, query, response } (404 when unknown)
9
- // GET /api/message-counter → { max }
9
+ // GET /api/message-counter → { max, era }
10
10
  //
11
11
  // Resolution order (per the prompt-queue/archive plan): live in-memory
12
12
  // sessions first (covers the mirror's 15-minute lag), then day archives
@@ -19,6 +19,11 @@ import { getActiveSessions } from '../lib/conversation.js'
19
19
  import { dataPath } from '../lib/data-dir.js'
20
20
  import { localDay } from '../lib/local-day.js'
21
21
  import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
22
+ import {
23
+ LEGACY_MESSAGE_ERA,
24
+ currentMessageEra,
25
+ exchangeBelongsToEra,
26
+ } from '../lib/message-era.js'
22
27
 
23
28
  // v6.3.0 — read archives from the SAME persistent location the archive-mirror
24
29
  // writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
@@ -40,6 +45,7 @@ interface ExchangeLike {
40
45
  content?: string
41
46
  timestamp?: number
42
47
  globalMsgNum?: number
48
+ messageEra?: string
43
49
  attachments?: unknown
44
50
  }
45
51
 
@@ -60,9 +66,16 @@ function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; re
60
66
  }
61
67
  }
62
68
 
63
- function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): ResolvedGlobalMessage | null {
69
+ function scanExchanges(
70
+ exchanges: ExchangeLike[],
71
+ num: number,
72
+ date: string,
73
+ /** Pass null to match any era (post-reset short-ref fallback). */
74
+ era: string | null = currentMessageEra(),
75
+ ): ResolvedGlobalMessage | null {
64
76
  for (let i = 0; i < exchanges.length; i++) {
65
77
  if (exchanges[i]?.globalMsgNum !== num) continue
78
+ if (era != null && !exchangeBelongsToEra(exchanges[i] ?? {}, era)) continue
66
79
  const { query, response, attachments } = pairExchange(exchanges, i)
67
80
  return {
68
81
  globalMsgNum: num, date, query, response,
@@ -74,7 +87,11 @@ function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): Re
74
87
 
75
88
  /** Resolve a global message number from the day archives, newest-first.
76
89
  * Exported with an explicit dir for tests. */
77
- export function resolveFromArchiveDir(dir: string, num: number): ResolvedGlobalMessage | null {
90
+ export function resolveFromArchiveDir(
91
+ dir: string,
92
+ num: number,
93
+ era: string | null = LEGACY_MESSAGE_ERA,
94
+ ): ResolvedGlobalMessage | null {
78
95
  let files: string[] = []
79
96
  try {
80
97
  files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse()
@@ -87,7 +104,12 @@ export function resolveFromArchiveDir(dir: string, num: number): ResolvedGlobalM
87
104
  const chats = Array.isArray(day?.chats) ? day.chats : []
88
105
  for (const chat of chats) {
89
106
  const exchanges = Array.isArray(chat?.exchanges) ? chat.exchanges : []
90
- const hit = scanExchanges(exchanges, num, typeof day?.date === 'string' ? day.date : f.slice(0, 10))
107
+ const hit = scanExchanges(
108
+ exchanges,
109
+ num,
110
+ typeof day?.date === 'string' ? day.date : f.slice(0, 10),
111
+ era,
112
+ )
91
113
  if (hit) return hit
92
114
  }
93
115
  } catch {
@@ -143,8 +165,8 @@ export function getArchiveChatMessagesNumbered(date: string, chatIndex: number)
143
165
  return readArchiveChatNumbered(ARCHIVE_DIR, date, chatIndex)
144
166
  }
145
167
 
146
- /** Highest stamped number across the day archives (0 when none). */
147
- export function maxGlobalMsgNumInDir(dir: string): number {
168
+ /** Highest stamped number across the day archives for one era (0 when none). */
169
+ export function maxGlobalMsgNumInDir(dir: string, era = LEGACY_MESSAGE_ERA): number {
148
170
  let max = 0
149
171
  let files: string[] = []
150
172
  try {
@@ -157,6 +179,7 @@ export function maxGlobalMsgNumInDir(dir: string): number {
157
179
  const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
158
180
  for (const chat of Array.isArray(day?.chats) ? day.chats : []) {
159
181
  for (const ex of Array.isArray(chat?.exchanges) ? chat.exchanges : []) {
182
+ if (!exchangeBelongsToEra(ex ?? {}, era)) continue
160
183
  if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > max) max = ex.globalMsgNum
161
184
  }
162
185
  }
@@ -165,11 +188,14 @@ export function maxGlobalMsgNumInDir(dir: string): number {
165
188
  return max
166
189
  }
167
190
 
168
- function resolveFromLiveSessions(num: number): ResolvedGlobalMessage | null {
191
+ function resolveFromLiveSessions(
192
+ num: number,
193
+ era: string | null = currentMessageEra(),
194
+ ): ResolvedGlobalMessage | null {
169
195
  const today = localDay() // local calendar day, not UTC — a live ref in the user's evening must not label tomorrow
170
196
  for (const session of getActiveSessions()) {
171
197
  const exchanges = (session as { exchanges?: ExchangeLike[] }).exchanges ?? []
172
- const hit = scanExchanges(exchanges, num, today)
198
+ const hit = scanExchanges(exchanges, num, today, era)
173
199
  if (hit) return hit
174
200
  }
175
201
  return null
@@ -183,7 +209,13 @@ messageRefRouter.get('/message/:num', (req, res) => {
183
209
  res.status(400).json({ error: 'invalid message number' })
184
210
  return
185
211
  }
186
- const hit = resolveFromLiveSessions(num) ?? resolveFromArchiveDir(ARCHIVE_DIR, num)
212
+ // Prefer the current era so short refs stay unambiguous after a reset.
213
+ // Fall back across eras so "recall message 562" still works for pre-reset
214
+ // stamps once numbering restarts at #1.
215
+ const hit = resolveFromLiveSessions(num)
216
+ ?? resolveFromArchiveDir(ARCHIVE_DIR, num, currentMessageEra())
217
+ ?? resolveFromLiveSessions(num, null)
218
+ ?? resolveFromArchiveDir(ARCHIVE_DIR, num, null)
187
219
  if (!hit) {
188
220
  res.status(404).json({ error: `message ${num} not found` })
189
221
  return
@@ -192,11 +224,13 @@ messageRefRouter.get('/message/:num', (req, res) => {
192
224
  })
193
225
 
194
226
  messageRefRouter.get('/message-counter', (_req, res) => {
227
+ const era = currentMessageEra()
195
228
  let liveMax = 0
196
229
  for (const session of getActiveSessions()) {
197
230
  for (const ex of ((session as { exchanges?: ExchangeLike[] }).exchanges ?? [])) {
231
+ if (!exchangeBelongsToEra(ex, era)) continue
198
232
  if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
199
233
  }
200
234
  }
201
- res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR)) })
235
+ res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR, era)), era })
202
236
  })
@@ -13,6 +13,7 @@ import {
13
13
  markPromptDraftError,
14
14
  getMissingChunkIndexes,
15
15
  prunePromptDrafts,
16
+ transcriptQualityRank,
16
17
  type PromptDraftTranscriptRecord,
17
18
  } from '../lib/prompt-draft-store.js'
18
19
  import {
@@ -22,6 +23,7 @@ import {
22
23
  OpenAIWhisperBudgetExhaustedError,
23
24
  TranscriptionUnavailableError,
24
25
  } from '../lib/transcribe-audio.js'
26
+ import { getHighQualityTranscriptionCapability } from '../lib/whisper-local.js'
25
27
  import {
26
28
  stripInlineHallucinationsOneShot,
27
29
  stripInlineHallucinations,
@@ -191,16 +193,31 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
191
193
  const current = loadPromptDraftMeta(draftId)
192
194
  const warm = current?.warmTranscripts?.[String(chunkIndex)]
193
195
  const cachedFinal = current?.finalTranscripts?.[String(chunkIndex)]
194
- if (!cachedFinal || cachedFinal.hash !== hash) {
195
- await markPromptDraftChunkTranscript(draftId, chunkIndex, {
196
- text,
197
- hash,
198
- requestedMode: warm?.requestedMode ?? mode,
199
- actualQuality: warm?.actualQuality ?? (mode === 'hq' ? 'hq' : 'fast'),
200
- backend: warm?.backend ?? 'shared-inflight',
201
- degraded: warm?.degraded ?? false,
202
- ...(warm?.degradationReason ? { degradationReason: warm.degradationReason } : {}),
203
- }, 'final')
196
+ // Trust the shared job text + mode key. Warm metadata is only used when
197
+ // it still matches this decode — a late Fast warm must not relabel HQ.
198
+ const warmMatches = Boolean(
199
+ warm
200
+ && warm.hash === hash
201
+ && warm.requestedMode === mode
202
+ && warm.text === text,
203
+ )
204
+ const record: PromptDraftTranscriptRecord = {
205
+ text,
206
+ hash,
207
+ requestedMode: mode,
208
+ actualQuality: warmMatches
209
+ ? warm!.actualQuality
210
+ : (mode === 'hq' ? 'hq' : 'fast'),
211
+ backend: warmMatches ? warm!.backend : 'shared-inflight',
212
+ degraded: warmMatches ? warm!.degraded : false,
213
+ ...(warmMatches && warm!.degradationReason ? { degradationReason: warm!.degradationReason } : {}),
214
+ }
215
+ if (
216
+ !cachedFinal
217
+ || cachedFinal.hash !== hash
218
+ || transcriptQualityRank(cachedFinal.actualQuality) < transcriptQualityRank(record.actualQuality)
219
+ ) {
220
+ await markPromptDraftChunkTranscript(draftId, chunkIndex, record, 'final')
204
221
  }
205
222
  }
206
223
  return text
@@ -245,6 +262,22 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
245
262
  return job
246
263
  }
247
264
 
265
+ function isReusableTranscript(
266
+ cached: PromptDraftTranscriptRecord | undefined,
267
+ hash: string,
268
+ mode: 'hq' | 'fast',
269
+ ): boolean {
270
+ if (!cached || cached.hash !== hash || cached.requestedMode !== mode) return false
271
+ if (cached.backend.startsWith('legacy')) return false
272
+ if (mode === 'fast') return cached.actualQuality === 'fast' || cached.actualQuality === 'cloud'
273
+ // mode === 'hq'
274
+ if (cached.actualQuality === 'hq' || cached.actualQuality === 'cloud') return true
275
+ // Degraded turbo after an HQ request: retry when large-v3 can still run.
276
+ // If HQ is unavailable, reuse to avoid a pointless second turbo decode.
277
+ if (cached.actualQuality === 'fast') return !getHighQualityTranscriptionCapability().hqAvailable
278
+ return false
279
+ }
280
+
248
281
  async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: AutoCleanRequest, signal?: AbortSignal) {
249
282
  const meta = loadPromptDraftMeta(draftId)
250
283
  if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
@@ -262,17 +295,11 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
262
295
  }
263
296
  const current = loadPromptDraftMeta(draftId)
264
297
  const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
265
- // Reuse the exact requested-mode decode even when HQ truthfully degraded
266
- // to turbo. Finalize's automatic policy would make the same local choice
267
- // again after a successful turbo result, so a second decode adds latency
268
- // without improving quality. Legacy records stay excluded because their
269
- // decoder provenance was reconstructed during migration.
270
- const reusable = Boolean(
271
- cached
272
- && cached.hash === hash
273
- && cached.requestedMode === mode
274
- && !cached.backend.startsWith('legacy'),
275
- )
298
+ // Reuse only a decode that still matches the requested mode's quality bar.
299
+ // When Settings HQ is available, never treat a degraded turbo warm as final
300
+ // that left finished chats on Fast text while REVIEW looked "HQ ready".
301
+ // Legacy records stay excluded (reconstructed provenance).
302
+ const reusable = isReusableTranscript(cached, hash, mode)
276
303
  const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
277
304
  const text = sanitizeTranscript(draftId, raw, !reusable)
278
305
  if (text.trim()) {
@@ -18,6 +18,7 @@ import {
18
18
  MaintenanceLifecycleError,
19
19
  maintenanceErrorPayload,
20
20
  } from '../lib/maintenance-lifecycle.js'
21
+ import { currentMessageEra, LEGACY_MESSAGE_ERA } from '../lib/message-era.js'
21
22
 
22
23
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
23
24
  WebSearch: 'Searching web...',
@@ -38,7 +39,7 @@ queryRouter.post('/query', async (req, res) => {
38
39
  }
39
40
  throw error
40
41
  }
41
- const { query, sessionId, model, effort, reference, globalMsgNum } = req.body
42
+ const { query, sessionId, model, effort, reference, globalMsgNum, messageEra } = req.body
42
43
  const activityToolMode = req.body.activityToolMode === 'off' || req.body.activityToolMode === 'preview'
43
44
  ? req.body.activityToolMode
44
45
  : 'status'
@@ -46,6 +47,24 @@ queryRouter.post('/query', async (req, res) => {
46
47
  // omit/unknown must not silently force Ask (6.16.3 durable-only gap).
47
48
  const cursorExecutionMode = req.body.cursorExecutionMode === 'ask' ? 'ask' as const : 'agent' as const
48
49
 
50
+ // Once a reset era exists, reject pre-era clients before they can stamp a
51
+ // five-digit number into the fresh namespace. Companion learns the era from
52
+ // /api/message-counter and includes it on every query.
53
+ const activeMessageEra = currentMessageEra()
54
+ if (activeMessageEra !== LEGACY_MESSAGE_ERA && messageEra !== activeMessageEra) {
55
+ console.warn('[query] message era mismatch', {
56
+ sessionId: typeof sessionId === 'string' ? sessionId : undefined,
57
+ sentEra: typeof messageEra === 'string' ? messageEra : '(missing)',
58
+ expectedEra: activeMessageEra,
59
+ })
60
+ maintenanceLease.release()
61
+ return res.status(409).json({
62
+ error: 'message_era_mismatch',
63
+ detail: 'Reopen or update COS Glasses to start the fresh message list.',
64
+ era: activeMessageEra,
65
+ })
66
+ }
67
+
49
68
  // Resolve durable attachment ids and legacy base64 images through one
50
69
  // validation/normalization path before opening SSE.
51
70
  let resolvedAttachments
@@ -2,12 +2,13 @@
2
2
  import { Router } from 'express'
3
3
  import { readFileSync } from 'fs'
4
4
  import { join } from 'path'
5
- import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions } from '../lib/conversation.js'
5
+ import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel } from '../lib/conversation.js'
6
6
  import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
7
7
  import { getArchiveDayMessages } from '../lib/archive.js'
8
8
  import { localDay } from '../lib/local-day.js'
9
9
  import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
10
10
  import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
11
+ import { currentMessageEra, exchangeBelongsToEra } from '../lib/message-era.js'
11
12
 
12
13
  export const sessionsRouter = Router()
13
14
 
@@ -57,20 +58,24 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
57
58
  timestamp: number
58
59
  no?: number
59
60
  sessionId: string
61
+ modelPreference?: string
60
62
  attachments?: MediaAttachmentRef[]
61
63
  }> = []
64
+ const session = getSessionRaw(req.params.id)
62
65
  for (let i = 0; i < exchanges.length; i++) {
63
66
  const ex = exchanges[i]
64
67
  if (ex.role === 'user') {
65
68
  const next = exchanges[i + 1]
66
69
  if (next && next.role === 'assistant') {
67
70
  const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
71
+ const modelPreference = resolveExchangePairModel(ex, next, session?.modelPreference)
68
72
  messages.push({
69
73
  query: ex.content,
70
74
  text: next.content,
71
75
  timestamp: next.timestamp,
72
76
  sessionId: req.params.id,
73
77
  ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
78
+ ...(modelPreference ? { modelPreference } : {}),
74
79
  ...(attachments.length > 0 ? { attachments } : {}),
75
80
  })
76
81
  i++ // skip the assistant exchange
@@ -241,11 +246,14 @@ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
241
246
  // back to the archived chat's sessionId via getArchiveDayMessages.
242
247
  sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
243
248
  const todayDate = localDay()
249
+ const era = currentMessageEra()
244
250
 
245
- const archivedMessages = getArchiveDayMessages(todayDate).map(m => ({
246
- ...m,
247
- source: 'archive' as const,
248
- }))
251
+ const archivedMessages = getArchiveDayMessages(todayDate)
252
+ .filter(m => exchangeBelongsToEra(m, era))
253
+ .map(m => ({
254
+ ...m,
255
+ source: 'archive' as const,
256
+ }))
249
257
 
250
258
  const liveMessages: Array<{
251
259
  query: string
@@ -255,6 +263,9 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
255
263
  sessionId: string
256
264
  source: 'live'
257
265
  no?: number
266
+ globalMsgNum?: number
267
+ messageEra?: string
268
+ modelPreference?: string
258
269
  attachments?: MediaAttachmentRef[]
259
270
  }> = []
260
271
  const liveSessions = getActiveSessions()
@@ -264,9 +275,13 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
264
275
  for (let i = 0; i < session.exchanges.length; i++) {
265
276
  const ex = session.exchanges[i]
266
277
  if (ex.role === 'user') {
278
+ if (!exchangeBelongsToEra(ex, era)) continue
267
279
  const next = session.exchanges[i + 1]
268
280
  if (next && next.role === 'assistant') {
269
281
  const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
282
+ const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
283
+ const messageEra = ex.messageEra ?? next.messageEra
284
+ const modelPreference = resolveExchangePairModel(ex, next, session.modelPreference)
270
285
  liveMessages.push({
271
286
  query: ex.content,
272
287
  text: next.content,
@@ -274,7 +289,9 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
274
289
  chatIndex: -1,
275
290
  sessionId: session.id,
276
291
  source: 'live',
277
- ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
292
+ ...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
293
+ ...(messageEra ? { messageEra } : {}),
294
+ ...(modelPreference ? { modelPreference } : {}),
278
295
  ...(attachments.length > 0 ? { attachments } : {}),
279
296
  })
280
297
  i++
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
9
9
  import { statSync } from 'node:fs'
10
10
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
11
+ import { getOwnerSpeakerLabel } from '../lib/profile.js'
11
12
 
12
13
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
13
14
  const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
@@ -18,7 +19,10 @@ export const voiceRouter = Router()
18
19
  // POST /api/voice/enroll — accept WAV audio, extract embedding, store as profile
19
20
  voiceRouter.post('/voice/enroll', async (req, res) => {
20
21
  try {
21
- const name = (req.query.name as string) || 'MU'
22
+ // Default to the configured wearer label ('Me' unless owner_speaker_label
23
+ // is set). Hardcoding one user's initials here enrolled every other install
24
+ // under a stranger's name.
25
+ const name = (req.query.name as string) || getOwnerSpeakerLabel()
22
26
 
23
27
  // Collect raw audio body
24
28
  const buffers: Buffer[] = []
@@ -38,10 +42,12 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
38
42
  }
39
43
  })
40
44
 
41
- // GET /api/voice/status — is MU enrolled?
45
+ // GET /api/voice/status — is the wearer enrolled?
42
46
  voiceRouter.get('/voice/status', (_req, res) => {
47
+ const owner = getOwnerSpeakerLabel()
43
48
  res.json({
44
- enrolled: isEnrolled('MU'),
49
+ owner,
50
+ enrolled: isEnrolled(owner),
45
51
  speakers: getAllSpeakerNames(),
46
52
  })
47
53
  })