@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.
package/.env.example CHANGED
@@ -93,7 +93,10 @@ BIND_HOST=0.0.0.0
93
93
 
94
94
  # ── WELCOME WEATHER (optional) ──────────────────────────────────────────
95
95
  # Phone GPS (Even Hub location permission) is the primary source. The Mac only
96
- # proxies Open-Meteo. If the phone denies location, set a home fallback:
96
+ # proxies Open-Meteo via GET /api/welcome-context?lat=&lon= (auth required).
97
+ # Prove: curl -H "x-cos-token: $COS_API_TOKEN" \
98
+ # "http://127.0.0.1:3141/api/welcome-context?lat=30.27&lon=-97.74"
99
+ # If the phone denies location, set a home fallback (omit weather otherwise):
97
100
  # COS_WEATHER_DEFAULT_LAT=30.5788
98
101
  # COS_WEATHER_DEFAULT_LON=-97.8531
99
102
  # COS_WEATHER_DEFAULT_CITY=Home
package/CHANGELOG.md CHANGED
@@ -1,3 +1,74 @@
1
+ ## 6.17.0
2
+
3
+ - **Speaker diarization is now a bolt-on any install can enable.** The ~26 MB
4
+ voiceprint model stays out of the npm package, but the loader no longer looks
5
+ only inside the package (where a managed install has no copy, and where a
6
+ hand-placed one is destroyed by the next update). Resolution order:
7
+ `COS_SPEAKER_MODEL_PATH` → `~/.cos-glasses/models/` → bundled `server/models/`
8
+ (source checkouts). Put the model in `~/.cos-glasses/models/` and restart.
9
+ - **A bad model file can no longer take the server down.** onnxruntime aborts
10
+ the process on a malformed or mismatched model rather than throwing, which
11
+ under a KeepAlive LaunchAgent became a permanent restart loop that killed
12
+ queries, meetings, and transcription over an optional feature. The file is now
13
+ screened structurally and loaded in a throwaway child process first, so a
14
+ truncated download or an HTML error page saved as `.onnx` disables diarization
15
+ and nothing else.
16
+ - **`/api/health` reports `speaker_id`** as `active` / `unavailable` / `error`,
17
+ so a server running without voiceprints is no longer indistinguishable from
18
+ one doing real diarization. `error` means a model is installed but the runtime
19
+ rejected it.
20
+ - **Profile config is read from the data home.** `.cos-profile.json` is now
21
+ loaded from `~/.cos-glasses/` when present, ahead of the package-root copy.
22
+ Managed installs previously resolved it inside the generation directory, where
23
+ no profile exists and every field silently took its default — losing
24
+ transcription vocabulary, whisper corrections, and the wearer label.
25
+ - **`/api/voice/enroll` and `/api/voice/status` no longer hardcode `MU`.** Both
26
+ use `owner_speaker_label` (default `Me`), matching what identification already
27
+ used. **Upgrade note:** an install that enrolled under the old default holds a
28
+ profile named `MU`; `/api/voice/status` will report `enrolled: false` until you
29
+ set `owner_speaker_label` to `MU`. Do that rather than re-enrolling, which
30
+ splits one voice across two profiles.
31
+
32
+ ## 6.16.9
33
+
34
+ - **Message-era reset visible to a running server.** `currentMessageEraState`
35
+ re-reads `message-era.json` when mtime changes, so
36
+ `reset-message-era.ts --confirm` (separate process) is picked up without
37
+ depending on a stale in-memory `legacy` cache. Restart still recommended
38
+ so live sessions stop serving pre-reset stamps; reset script prints verify
39
+ steps.
40
+
41
+ ## 6.16.8
42
+
43
+ - **Per-exchange model stamps on managed installs.** History badges were
44
+ defaulting to Opus whenever the server pair had no `modelPreference`
45
+ (Jul 19 `#228`–`#231` archives are unstamped — client `DEFAULT_MODEL`).
46
+ Bridges now stamp the actual run model on each exchange; archive +
47
+ today/all-messages emit via `resolveExchangePairModel`. Unstamped
48
+ legacy rows stay unlabeled server-side (phone still defaults those to
49
+ Opus — cannot invent Grok without evidence). New Cursor/Codex/Claude
50
+ turns keep truthful badges after reconnect/merge.
51
+
52
+ ## 6.16.7
53
+
54
+ - **Message-number era reset on managed public installs.** Companion burn
55
+ fixes never lowered the archive ceiling (`/api/message-counter` still
56
+ reported `max: 17931` with no `era`). Ported `message-era` under
57
+ `~/.cos-glasses/data`, era-scoped counter + lookup, stamp `messageEra` on
58
+ exchanges, and `409 message_era_mismatch` on legacy `/api/query` + durable
59
+ admission after a reset. Ops: `npx tsx server/scripts/reset-message-era.ts
60
+ --confirm`, restart LaunchAgent, phone reconnect → live list clears and
61
+ numbering restarts near #1. Archives retained.
62
+
63
+ ## 6.16.6
64
+
65
+ - **HQ finalize no longer loses to late Fast warm.** Fast warm and speculative
66
+ HQ warm share `warmTranscripts[chunk]`; a slow turbo decode could overwrite
67
+ large-v3 after HQ had already won. Warm/final writes are quality-monotonic
68
+ (hq > cloud > fast) for the same audio hash. Finalize reuses HQ/cloud only;
69
+ when Settings HQ is available, a degraded turbo warm is retried with
70
+ `automatic` instead of becoming the finished-chat question text.
71
+
1
72
  ## 6.16.5
2
73
 
3
74
  - **Welcome weather restored on managed installs.** Ported authenticated
package/README.md CHANGED
@@ -130,6 +130,12 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
130
130
  - Tasks / calendar / people context **if** you run the
131
131
  [COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
132
132
  glasses + AI only
133
+ - Welcome weather on the glasses home screen via authenticated
134
+ `GET /api/welcome-context?lat=&lon=`. The phone supplies GPS (Even Hub
135
+ location permission); this server only proxies Open-Meteo. Without phone
136
+ coords the route uses last-known process coords, then optional
137
+ `COS_WEATHER_DEFAULT_*`, otherwise omits weather. Optional `nextEvent`
138
+ appears when `COS_SCRIPTS_DIR` calendar data is available.
133
139
 
134
140
  ## Configuration
135
141
 
@@ -147,6 +153,8 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
147
153
  in the managed CLI working directory),
148
154
  `COS_CURSOR_AGENT_BIN` (optional absolute Cursor `agent` binary),
149
155
  `COS_CURSOR_PERSIST_SESSIONS=0` (disable Cursor session resume),
156
+ `COS_WEATHER_DEFAULT_LAT` / `COS_WEATHER_DEFAULT_LON` /
157
+ `COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
150
158
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
151
159
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
152
160
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
@@ -155,6 +163,55 @@ Telegram activity export is disabled by default even when a private COS
155
163
  pipeline contains `.telegram_config.json`; enable it only with the explicit
156
164
  `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
157
165
 
166
+ ## Speaker diarization (opt-in)
167
+
168
+ Without a voiceprint model this server does not classify speakers at all — it
169
+ passes through whatever label the client sends (`Unknown` when the client sends
170
+ nothing; the COS companion sends its own wearer/`Ext` labels). Named
171
+ per-speaker diarization needs a ~26 MB voiceprint model that is deliberately
172
+ **not** shipped in the npm package, so it is a bolt-on:
173
+
174
+ ```bash
175
+ mkdir -p ~/.cos-glasses/models
176
+ # put 3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx there, then restart
177
+ ```
178
+
179
+ The server searches, in order: `COS_SPEAKER_MODEL_PATH` (explicit full path to
180
+ the `.onnx`), `~/.cos-glasses/models/`, then a bundled `server/models/` copy
181
+ (source checkouts only). **Use the data home, not the installed package** —
182
+ anything inside the package is destroyed by the next update, while
183
+ `~/.cos-glasses/` survives.
184
+
185
+ Use an **absolute** path if you set `COS_SPEAKER_MODEL_PATH` — a relative one
186
+ resolves against the working directory, which under the managed LaunchAgent is
187
+ the installed package.
188
+
189
+ Verify with `/api/health` → `speaker_id`:
190
+
191
+ | Value | Meaning |
192
+ |---|---|
193
+ | `active` | model loaded, diarization running |
194
+ | `unavailable` | no model found — labels come from the client |
195
+ | `error` | a model is present but the runtime rejected it (see the startup log) |
196
+
197
+ The model is read once at startup, so restart after adding it. A corrupt or
198
+ mismatched `.onnx` is screened and probed in a child process first, so a bad
199
+ download disables diarization instead of taking the server down — but it does
200
+ mean a wrong file fails silently apart from that log line.
201
+
202
+ The wearer's label comes from `owner_speaker_label` in
203
+ `~/.cos-glasses/.cos-profile.json` (default `Me`); set it to match the profile
204
+ name you enrol under. Train voices via `/api/voice/enroll?name=…` (the default
205
+ name is `owner_speaker_label`); profiles persist in
206
+ `~/.cos-glasses/data/voice-profiles.json`.
207
+
208
+ **Upgrading from an older server:** before this release the enrollment default
209
+ was hardcoded to `MU`, so an existing install may hold a profile under that name
210
+ while `owner_speaker_label` resolves to `Me`. `/api/voice/status` will then
211
+ report `enrolled: false`. Set `owner_speaker_label` to `MU` to keep the existing
212
+ voiceprints rather than re-enrolling, which would split the same voice across two
213
+ profiles.
214
+
158
215
  ## HQ dictation
159
216
 
160
217
  Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.16.5",
4
- "description": "COS Glasses self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
3
+ "version": "6.17.0",
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": {
7
7
  "glasses-server": "bin/cli.cjs",
@@ -14,6 +14,7 @@ import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/
14
14
  import { secureExistingPrivateFile } from './secure-user-config.js'
15
15
 
16
16
  import type { Exchange } from './conversation.js'
17
+ import { resolveExchangePairModel } from './conversation.js'
17
18
 
18
19
  import { dataPath } from './data-dir.js'
19
20
  const ARCHIVE_DIR = dataPath('archive')
@@ -435,11 +436,11 @@ export function getArchiveChatMessages(
435
436
  * (sessionId, timestamp) instead of bare timestamp (collision-prone). */
436
437
  export function getArchiveDayMessages(
437
438
  date: string,
438
- ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> {
439
+ ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> {
439
440
  const archive = loadArchive(date)
440
441
  if (!archive) return []
441
442
 
442
- const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> = []
443
+ const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> = []
443
444
  for (const chat of archive.chats) {
444
445
  for (let i = 0; i < chat.exchanges.length; i++) {
445
446
  const ex = chat.exchanges[i]
@@ -447,13 +448,18 @@ export function getArchiveDayMessages(
447
448
  const next = chat.exchanges[i + 1]
448
449
  if (next && next.role === 'assistant') {
449
450
  const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
451
+ const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
452
+ const messageEra = ex.messageEra ?? next.messageEra
453
+ const modelPreference = resolveExchangePairModel(ex, next, null)
450
454
  messages.push({
451
455
  query: ex.content,
452
456
  text: next.content,
453
457
  timestamp: next.timestamp,
454
458
  chatIndex: chat.id,
455
459
  sessionId: chat.sessionId,
456
- ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
460
+ ...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
461
+ ...(messageEra ? { messageEra } : {}),
462
+ ...(modelPreference ? { modelPreference } : {}),
457
463
  ...(attachments.length > 0 ? { attachments } : {}),
458
464
  })
459
465
  i++
@@ -484,6 +484,7 @@ export async function callClaudeStreaming(
484
484
  globalMsgNum,
485
485
  undefined,
486
486
  exchangeProvenance,
487
+ resolvedModel,
487
488
  )
488
489
 
489
490
  // Vision queries need the Read tool to see the image files
@@ -646,6 +647,7 @@ export async function callClaudeStreaming(
646
647
  globalMsgNum,
647
648
  undefined,
648
649
  exchangeProvenance,
650
+ resolvedModel,
649
651
  )
650
652
  if (imagePaths.length > 0) {
651
653
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
@@ -331,6 +331,7 @@ export async function callCodexStreaming(
331
331
  globalMsgNum,
332
332
  undefined,
333
333
  exchangeProvenance,
334
+ model,
334
335
  )
335
336
 
336
337
  let fullQuery: string
@@ -483,6 +484,7 @@ export async function callCodexStreaming(
483
484
  globalMsgNum,
484
485
  undefined,
485
486
  exchangeProvenance,
487
+ model,
486
488
  )
487
489
  if (imagePaths.length > 0) {
488
490
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
@@ -15,6 +15,7 @@ import { localDay } from './local-day.js'
15
15
  import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
16
16
  import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
17
17
  import { secureExistingPrivateFile } from './secure-user-config.js'
18
+ import { currentMessageEra } from './message-era.js'
18
19
 
19
20
  export type { ModelPreference }
20
21
 
@@ -26,6 +27,11 @@ export interface Exchange {
26
27
  /** Durable query provenance. Legacy exchanges omit both fields. */
27
28
  clientJobId?: string
28
29
  generation?: number
30
+ /** Short-number namespace. Missing means the original legacy era. */
31
+ messageEra?: string
32
+ /** Per-exchange model badge. Prefer this over session.modelPreference when
33
+ * rendering history so Cursor/Codex turns do not inherit an Opus default. */
34
+ modelPreference?: ModelPreference
29
35
  /** Public attachment refs may live on either half of a Q&A pair: request
30
36
  * photos on the user exchange, model-published images on the assistant
31
37
  * exchange. Bytes, filesystem paths, and capabilities never persist here. */
@@ -144,6 +150,9 @@ function loadFromDisk(): void {
144
150
  // the surrounding exchange or the rest of the recovered session.
145
151
  for (const exchange of session.exchanges) {
146
152
  validateLoadedExchangeProvenance(exchange)
153
+ const exchangeModel = normalizeModelPreference(exchange.modelPreference)
154
+ if (exchangeModel) exchange.modelPreference = exchangeModel
155
+ else delete exchange.modelPreference
147
156
  if ('attachments' in exchange && exchange.attachments !== undefined) {
148
157
  const refs = parseMediaAttachmentRefs(exchange.attachments)
149
158
  if (refs.length > 0) exchange.attachments = refs
@@ -403,6 +412,7 @@ export function addExchange(
403
412
  globalMsgNum?: number,
404
413
  attachments?: MediaAttachmentRef[],
405
414
  provenance?: ExchangeJobProvenance,
415
+ modelPreference?: ModelPreference | null,
406
416
  ): Exchange {
407
417
  let session = sessions.get(sessionId)
408
418
  if (!session) {
@@ -410,12 +420,15 @@ export function addExchange(
410
420
  sessions.set(sessionId, session)
411
421
  }
412
422
 
423
+ const stampedModel = normalizeModelPreference(modelPreference)
413
424
  const exchange: Exchange = {
414
425
  role,
415
426
  content,
416
427
  timestamp: Date.now(),
417
428
  globalMsgNum,
429
+ messageEra: currentMessageEra(),
418
430
  ...normalizedExchangeProvenance(provenance),
431
+ ...(stampedModel ? { modelPreference: stampedModel } : {}),
419
432
  ...(attachments && attachments.length > 0 ? { attachments } : {}),
420
433
  }
421
434
  session.exchanges.push(exchange)
@@ -512,6 +525,8 @@ export function reconcileExchangeByJobIdentity(
512
525
  content: string,
513
526
  globalMsgNum?: number,
514
527
  attachments?: MediaAttachmentRef[],
528
+ messageEra?: string,
529
+ modelPreference?: ModelPreference | null,
515
530
  ): ReconciledExchange {
516
531
  if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
517
532
  throw new Error('conversation: invalid durable job identity')
@@ -538,6 +553,11 @@ export function reconcileExchangeByJobIdentity(
538
553
  }
539
554
 
540
555
  const validatedAttachments = parseMediaAttachmentRefs(attachments)
556
+ const projectedMessageEra = typeof messageEra === 'string'
557
+ && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(messageEra)
558
+ ? messageEra
559
+ : currentMessageEra()
560
+ const stampedModel = normalizeModelPreference(modelPreference)
541
561
  let exchange: Exchange
542
562
  const created = matchingIndexes.length === 0
543
563
  if (created) {
@@ -546,8 +566,10 @@ export function reconcileExchangeByJobIdentity(
546
566
  content,
547
567
  timestamp: Date.now(),
548
568
  globalMsgNum,
569
+ messageEra: projectedMessageEra,
549
570
  clientJobId: identity.clientJobId,
550
571
  generation: identity.generation,
572
+ ...(stampedModel ? { modelPreference: stampedModel } : {}),
551
573
  ...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
552
574
  }
553
575
  session.exchanges.push(exchange)
@@ -555,6 +577,8 @@ export function reconcileExchangeByJobIdentity(
555
577
  exchange = session.exchanges[matchingIndexes[0]]
556
578
  exchange.content = content
557
579
  exchange.globalMsgNum = globalMsgNum
580
+ exchange.messageEra = projectedMessageEra
581
+ if (stampedModel) exchange.modelPreference = stampedModel
558
582
  if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
559
583
  else delete exchange.attachments
560
584
 
@@ -617,6 +641,20 @@ export function getSessionModel(sessionId: string): ModelPreference | null {
617
641
  return sessions.get(sessionId)?.modelPreference ?? null
618
642
  }
619
643
 
644
+ /** Prefer per-exchange stamps; fall back to session model for pre-stamp rows. */
645
+ export function resolveExchangePairModel(
646
+ user: Pick<Exchange, 'modelPreference'> | undefined,
647
+ assistant: Pick<Exchange, 'modelPreference'> | undefined,
648
+ sessionModel: ModelPreference | null | undefined,
649
+ ): ModelPreference | undefined {
650
+ return normalizeModelPreference(
651
+ assistant?.modelPreference
652
+ ?? user?.modelPreference
653
+ ?? sessionModel
654
+ ?? undefined,
655
+ )
656
+ }
657
+
620
658
  export function setSessionModel(sessionId: string, model: ModelPreference | null): void {
621
659
  const session = sessions.get(sessionId)
622
660
  if (session) {
@@ -310,9 +310,10 @@ export async function callCursorStreaming(
310
310
  const pendingUserExchange = durableIdentity
311
311
  ? reconcileExchangeByJobIdentity(
312
312
  sid, durableIdentity, 'user', historyQuery, globalMsgNum, inboundAttachments,
313
+ undefined, model,
313
314
  ).exchange
314
315
  : addExchange(
315
- sid, 'user', historyQuery, globalMsgNum, inboundAttachments, durableIdentity,
316
+ sid, 'user', historyQuery, globalMsgNum, inboundAttachments, durableIdentity, model,
316
317
  )
317
318
 
318
319
  let fullQuery = query
@@ -446,8 +447,10 @@ export async function callCursorStreaming(
446
447
  }
447
448
 
448
449
  const assistantExchange = durableIdentity
449
- ? reconcileExchangeByJobIdentity(sid, durableIdentity, 'assistant', text, globalMsgNum).exchange
450
- : addExchange(sid, 'assistant', text, globalMsgNum, undefined, durableIdentity)
450
+ ? reconcileExchangeByJobIdentity(
451
+ sid, durableIdentity, 'assistant', text, globalMsgNum, undefined, undefined, model,
452
+ ).exchange
453
+ : addExchange(sid, 'assistant', text, globalMsgNum, undefined, durableIdentity, model)
451
454
  if (imagePaths.length > 0) {
452
455
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
453
456
  }
@@ -0,0 +1,94 @@
1
+ // Message-number eras let Miles start again at #1 without deleting history.
2
+ // Old sessions/day archives remain immutable and browseable; the current era
3
+ // alone owns short voice references such as "reference message 4".
4
+ //
5
+ // Public package stores state under ~/.cos-glasses/data (dataPath), matching
6
+ // sessions/archive — never a package-relative path that vanishes on npx upgrade.
7
+ //
8
+ // Disk is authoritative across processes: reset-message-era.ts writes the file
9
+ // in a one-shot CLI; the long-lived server re-reads when mtime changes so a
10
+ // reset is visible without relying solely on an in-process cache flush.
11
+
12
+ import { statSync } from 'node:fs'
13
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
14
+ import { dataPath } from './data-dir.js'
15
+
16
+ export const LEGACY_MESSAGE_ERA = 'legacy'
17
+
18
+ export interface MessageEraState {
19
+ v: 1
20
+ era: string
21
+ startedAt: number
22
+ }
23
+
24
+ const MESSAGE_ERA_FILE = dataPath('message-era.json')
25
+
26
+ let cached: MessageEraState | null = null
27
+ let cachedMtimeMs = Number.NaN
28
+
29
+ function fileMtimeMs(): number {
30
+ try {
31
+ return statSync(MESSAGE_ERA_FILE).mtimeMs
32
+ } catch {
33
+ return Number.NaN
34
+ }
35
+ }
36
+
37
+ function validState(value: unknown): value is MessageEraState {
38
+ if (!value || typeof value !== 'object') return false
39
+ const raw = value as Partial<MessageEraState>
40
+ return raw.v === 1
41
+ && typeof raw.era === 'string'
42
+ && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(raw.era)
43
+ && typeof raw.startedAt === 'number'
44
+ && Number.isFinite(raw.startedAt)
45
+ && raw.startedAt >= 0
46
+ }
47
+
48
+ export function currentMessageEraState(): MessageEraState {
49
+ const mtimeMs = fileMtimeMs()
50
+ if (cached && Object.is(mtimeMs, cachedMtimeMs)) return cached
51
+
52
+ const loaded = loadJsonOrQuarantine<unknown>(MESSAGE_ERA_FILE)
53
+ if (loaded.status === 'ok' && validState(loaded.data)) {
54
+ cached = loaded.data
55
+ cachedMtimeMs = mtimeMs
56
+ return cached
57
+ }
58
+ cached = { v: 1, era: LEGACY_MESSAGE_ERA, startedAt: 0 }
59
+ cachedMtimeMs = mtimeMs
60
+ return cached
61
+ }
62
+
63
+ export function currentMessageEra(): string {
64
+ return currentMessageEraState().era
65
+ }
66
+
67
+ export function exchangeBelongsToEra(
68
+ exchange: { messageEra?: unknown },
69
+ era = currentMessageEra(),
70
+ ): boolean {
71
+ if (era === LEGACY_MESSAGE_ERA) {
72
+ return exchange.messageEra == null || exchange.messageEra === LEGACY_MESSAGE_ERA
73
+ }
74
+ return exchange.messageEra === era
75
+ }
76
+
77
+ export function createMessageEra(now = Date.now()): MessageEraState {
78
+ const stamp = new Date(now).toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
79
+ const next: MessageEraState = {
80
+ v: 1,
81
+ era: `era-${stamp}`,
82
+ startedAt: now,
83
+ }
84
+ atomicWriteFileSync(MESSAGE_ERA_FILE, JSON.stringify(next, null, 2), { mode: 0o600 })
85
+ cached = next
86
+ cachedMtimeMs = fileMtimeMs()
87
+ return next
88
+ }
89
+
90
+ /** Test/helper: drop in-memory cache so the next read hits disk. */
91
+ export function __resetMessageEraCacheForTests(): void {
92
+ cached = null
93
+ cachedMtimeMs = Number.NaN
94
+ }
@@ -1,16 +1,38 @@
1
1
  // Profile loader — reads user identity from .cos-profile.json (gitignored)
2
2
  // Falls back to generic defaults for users who haven't configured a profile
3
3
 
4
- import { readFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
5
6
  import { resolve } from 'node:path'
6
7
  import { atomicWriteFileSync } from './atomic-fs.js'
7
8
 
8
9
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
+
11
+ /** The profile in the data home. Survives updates; the APP_ROOT copy does not. */
12
+ export function homeProfilePath(): string {
13
+ return resolve(homedir(), '.cos-glasses', '.cos-profile.json')
14
+ }
15
+
9
16
  // Single canonical path — used by BOTH the reader and the writer so a glossary
10
17
  // PUT can never write to a different file than the cache reads from. Lazy +
11
18
  // env-overridable (COS_PROFILE_PATH) so tests can target a temp file.
19
+ //
20
+ // APP_ROOT is the INSTALLED PACKAGE root. For a managed install that is inside
21
+ // the generation directory, which an update replaces wholesale — so a profile
22
+ // there is destroyed on every upgrade, and a fresh managed install has no
23
+ // profile at all (loadProfile() catches to {} and every field silently takes
24
+ // its default). Relying on COS_PROFILE_PATH alone is not enough either: the
25
+ // launcher rebuilds its environment from the release manifest rather than the
26
+ // existing plist, so an operator-set value is dropped by the next
27
+ // install/update/repair. Prefer the data home whenever a profile lives there.
28
+ //
29
+ // Order: explicit override → ~/.cos-glasses/.cos-profile.json → package root.
12
30
  function profilePath(): string {
13
- return process.env.COS_PROFILE_PATH || resolve(APP_ROOT, '.cos-profile.json')
31
+ const override = process.env.COS_PROFILE_PATH?.trim()
32
+ if (override) return resolve(override)
33
+ const home = homeProfilePath()
34
+ if (existsSync(home)) return home
35
+ return resolve(APP_ROOT, '.cos-profile.json')
14
36
  }
15
37
 
16
38
  let profileCache: Record<string, unknown> | null = null
@@ -24,6 +24,13 @@ export interface PromptDraftTranscriptRecord {
24
24
  acceptedDegraded?: boolean
25
25
  }
26
26
 
27
+ /** Higher wins. Fast warm must never replace an HQ/cloud decode for the same audio. */
28
+ export function transcriptQualityRank(quality: PromptDraftTranscriptRecord['actualQuality']): number {
29
+ if (quality === 'hq') return 3
30
+ if (quality === 'cloud') return 2
31
+ return 1
32
+ }
33
+
27
34
  export interface PromptDraftMeta {
28
35
  v: 2
29
36
  draftId: string
@@ -224,10 +231,39 @@ export async function markPromptDraftChunkTranscript(
224
231
  ? { text: record, hash: meta.chunkHashes[key] ?? '', requestedMode: 'hq', actualQuality: 'fast', backend: 'legacy', degraded: true }
225
232
  : record
226
233
  if (meta.chunkHashes[key] && normalized.hash && meta.chunkHashes[key] !== normalized.hash) return meta
227
- if (purpose === 'final') meta.finalTranscripts[key] = normalized
228
- else meta.warmTranscripts[key] = normalized
234
+
235
+ if (purpose === 'final') {
236
+ const existingFinal = meta.finalTranscripts[key]
237
+ // Final is authoritative for its mode, but never downgrade a better decode
238
+ // of the same audio (late Fast warm finishing after HQ finalize).
239
+ if (
240
+ existingFinal
241
+ && existingFinal.hash === normalized.hash
242
+ && transcriptQualityRank(existingFinal.actualQuality) > transcriptQualityRank(normalized.actualQuality)
243
+ ) {
244
+ return meta
245
+ }
246
+ meta.finalTranscripts[key] = normalized
247
+ } else {
248
+ const existingWarm = meta.warmTranscripts[key]
249
+ // Speculative HQ and Fast warm share warmTranscripts[chunk]. Whichever
250
+ // finishes last used to win — a slow Fast warm could clobber HQ and make
251
+ // the next reader (or a racy finalize) prefer turbo text.
252
+ if (
253
+ existingWarm
254
+ && existingWarm.hash === normalized.hash
255
+ && transcriptQualityRank(existingWarm.actualQuality) > transcriptQualityRank(normalized.actualQuality)
256
+ ) {
257
+ return meta
258
+ }
259
+ meta.warmTranscripts[key] = normalized
260
+ }
261
+
229
262
  if (!meta.chunkTranscripts) meta.chunkTranscripts = {}
230
- meta.chunkTranscripts[key] = normalized.text
263
+ const published = meta.finalTranscripts[key] ?? meta.warmTranscripts[key]
264
+ if (published && published.hash === (meta.chunkHashes[key] ?? published.hash)) {
265
+ meta.chunkTranscripts[key] = published.text
266
+ }
231
267
  return writeMeta(touchMeta(meta))
232
268
  })
233
269
  }
@@ -5,6 +5,7 @@ import { emitDisplay } from './display-bus.js'
5
5
  import { resolveQueryAttachments } from './query-attachments.js'
6
6
  import { getMediaStore } from './media-store.js'
7
7
  import { dataPath } from './data-dir.js'
8
+ import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
8
9
  import { durableQueryJobsEnabled } from './query-job-feature.js'
9
10
  import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
10
11
  import { QueryJobStore } from './query-job-store.js'
@@ -36,17 +37,42 @@ const TOOL_STATUS_MESSAGES: Record<string, string> = {
36
37
  Read: 'Analyzing photo...',
37
38
  }
38
39
 
40
+ export class QueryJobAdmissionPreparationError extends Error {
41
+ constructor(readonly status: number, readonly code: string, message: string) {
42
+ super(message)
43
+ this.name = 'QueryJobAdmissionPreparationError'
44
+ }
45
+ }
46
+
39
47
  /** Resolve image bytes/ids before returning 202. The journal receives only
40
48
  * validated refs and ids; base64 bytes and provider-local paths are dropped by
41
- * the strict QueryJobRequest parser before persistence. */
49
+ * the strict QueryJobRequest parser before persistence. Once a reset era
50
+ * exists, reject pre-era clients before they stamp five-digit numbers into
51
+ * the fresh namespace. */
42
52
  export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<unknown> {
43
53
  if (!raw || typeof raw !== 'object') return raw
44
54
  const input = raw as Record<string, unknown>
45
- const resolved = await resolveQueryAttachments(input)
46
- return {
47
- ...input,
48
- attachmentIds: resolved.ids,
49
- attachmentRefs: resolved.refs,
55
+ const activeEra = currentMessageEra()
56
+ if (activeEra !== LEGACY_MESSAGE_ERA && input.messageEra !== activeEra) {
57
+ throw new QueryJobAdmissionPreparationError(
58
+ 409,
59
+ 'message_era_mismatch',
60
+ 'Reopen or update COS Glasses to start the fresh message list.',
61
+ )
62
+ }
63
+ try {
64
+ const resolved = await resolveQueryAttachments(input)
65
+ return {
66
+ ...input,
67
+ messageEra: activeEra,
68
+ attachmentIds: resolved.ids,
69
+ attachmentRefs: resolved.refs,
70
+ }
71
+ } catch (error: any) {
72
+ if (Number.isInteger(error?.status) && typeof error?.code === 'string') {
73
+ throw new QueryJobAdmissionPreparationError(error.status, error.code, error.message)
74
+ }
75
+ throw error
50
76
  }
51
77
  }
52
78
 
@@ -87,6 +113,8 @@ async function projectPublicConversationTerminal(
87
113
  userContent,
88
114
  request.globalMsgNum,
89
115
  request.attachmentRefs,
116
+ request.messageEra,
117
+ request.model,
90
118
  )
91
119
  reconcileExchangeByJobIdentity(
92
120
  request.sessionId,
@@ -95,6 +123,8 @@ async function projectPublicConversationTerminal(
95
123
  job.response ?? job.partialText,
96
124
  request.globalMsgNum,
97
125
  mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
126
+ request.messageEra,
127
+ request.model,
98
128
  )
99
129
  flushConversationToDisk()
100
130
  }