@gotcos/glasses-server 6.16.4 → 6.16.9

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
@@ -91,6 +91,16 @@ BIND_HOST=0.0.0.0
91
91
  # COS_TTS_LOCAL_DISABLE=1 # disable the local sidecar explicitly
92
92
  # COS_TTS_PRONUNCIATIONS_JSON={"Exampleco":{"local":"[Exampleco](/ɪgzˈæmpəlkoʊ/)","openai":"ig-ZAM-pul-co"}}
93
93
 
94
+ # ── WELCOME WEATHER (optional) ──────────────────────────────────────────
95
+ # Phone GPS (Even Hub location permission) is the primary source. The Mac only
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):
100
+ # COS_WEATHER_DEFAULT_LAT=30.5788
101
+ # COS_WEATHER_DEFAULT_LON=-97.8531
102
+ # COS_WEATHER_DEFAULT_CITY=Home
103
+
94
104
  # ── FULL COS PIPELINE (optional — leave unset for standalone) ────────────
95
105
  # Power users running the COS Starter Kit can point the glasses at their
96
106
  # pipeline to inherit live tasks/calendar/people context. Omit for standalone.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,53 @@
1
+ ## 6.16.9
2
+
3
+ - **Message-era reset visible to a running server.** `currentMessageEraState`
4
+ re-reads `message-era.json` when mtime changes, so
5
+ `reset-message-era.ts --confirm` (separate process) is picked up without
6
+ depending on a stale in-memory `legacy` cache. Restart still recommended
7
+ so live sessions stop serving pre-reset stamps; reset script prints verify
8
+ steps.
9
+
10
+ ## 6.16.8
11
+
12
+ - **Per-exchange model stamps on managed installs.** History badges were
13
+ defaulting to Opus whenever the server pair had no `modelPreference`
14
+ (Jul 19 `#228`–`#231` archives are unstamped — client `DEFAULT_MODEL`).
15
+ Bridges now stamp the actual run model on each exchange; archive +
16
+ today/all-messages emit via `resolveExchangePairModel`. Unstamped
17
+ legacy rows stay unlabeled server-side (phone still defaults those to
18
+ Opus — cannot invent Grok without evidence). New Cursor/Codex/Claude
19
+ turns keep truthful badges after reconnect/merge.
20
+
21
+ ## 6.16.7
22
+
23
+ - **Message-number era reset on managed public installs.** Companion burn
24
+ fixes never lowered the archive ceiling (`/api/message-counter` still
25
+ reported `max: 17931` with no `era`). Ported `message-era` under
26
+ `~/.cos-glasses/data`, era-scoped counter + lookup, stamp `messageEra` on
27
+ exchanges, and `409 message_era_mismatch` on legacy `/api/query` + durable
28
+ admission after a reset. Ops: `npx tsx server/scripts/reset-message-era.ts
29
+ --confirm`, restart LaunchAgent, phone reconnect → live list clears and
30
+ numbering restarts near #1. Archives retained.
31
+
32
+ ## 6.16.6
33
+
34
+ - **HQ finalize no longer loses to late Fast warm.** Fast warm and speculative
35
+ HQ warm share `warmTranscripts[chunk]`; a slow turbo decode could overwrite
36
+ large-v3 after HQ had already won. Warm/final writes are quality-monotonic
37
+ (hq > cloud > fast) for the same audio hash. Finalize reuses HQ/cloud only;
38
+ when Settings HQ is available, a degraded turbo warm is retried with
39
+ `automatic` instead of becoming the finished-chat question text.
40
+
41
+ ## 6.16.5
42
+
43
+ - **Welcome weather restored on managed installs.** Ported authenticated
44
+ `GET /api/welcome-context` (Open-Meteo + reverse-geocode). Phone sends
45
+ `lat`/`lon` from Even Hub geolocation; Mac never invents a home city.
46
+ Without coords: last process coords → optional `COS_WEATHER_DEFAULT_*` →
47
+ omit weather. New coords await reverse-geocode (3s) before first JSON so
48
+ the city label is not stale. Calendar `nextEvent` stays optional via the
49
+ COS python bridge with public-safe OOO filtering only.
50
+
1
51
  ## 6.16.4
2
52
 
3
53
  - **Cursor Agent on legacy `/api/query`.** 6.16.3 only forwarded
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.16.4",
3
+ "version": "6.16.9",
4
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",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -37,6 +37,7 @@ import { handoffsRouter } from './routes/handoffs.js'
37
37
  import { recoveryRouter } from './routes/recovery.js'
38
38
  import { promptEditRouter } from './routes/prompt-edit.js'
39
39
  import { bookmarksRouter } from './routes/bookmarks.js'
40
+ import { welcomeContextRouter } from './routes/welcome-context.js'
40
41
  import { prewarmContext } from './lib/context-builder.js'
41
42
  import { preWarmCLI } from './lib/claude-bridge.js'
42
43
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -245,6 +246,7 @@ app.use('/api', handoffsRouter)
245
246
  app.use('/api', recoveryRouter)
246
247
  app.use('/api', promptEditRouter)
247
248
  app.use('/api', bookmarksRouter)
249
+ app.use('/api', welcomeContextRouter)
248
250
 
249
251
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
250
252
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -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
+ }
@@ -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
  }
@@ -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++
@@ -0,0 +1,293 @@
1
+ // GET /api/welcome-context — weather + optional next event for glasses home.
2
+ // Weather uses phone-supplied lat/lon (Even Hub WebView geolocation). The Mac
3
+ // only proxies Open-Meteo / reverse-geocode — it is not "server location".
4
+
5
+ import { Router } from 'express'
6
+ import { callPython } from '../lib/python-bridge.js'
7
+
8
+ export const welcomeContextRouter = Router()
9
+
10
+ const WEATHER_TTL_MS = 30 * 60_000
11
+ const GEOCODE_TIMEOUT_MS = 3_000
12
+ const FORECAST_TIMEOUT_MS = 3_000
13
+ const CALENDAR_TTL_MS = 2 * 60_000
14
+ const COORD_MOVE_DEG = 0.05 // ~5km
15
+
16
+ type WeatherPayload = { temp: string; desc: string; location: string }
17
+ type NextEventPayload = { title: string; time: string; inMinutes?: number }
18
+
19
+ let cachedWeather: WeatherPayload | null = null
20
+ let weatherFetchedAt = 0
21
+ let lastKnownLat: number | undefined
22
+ let lastKnownLon: number | undefined
23
+ let lastKnownCity = ''
24
+
25
+ let cachedNextEvent: NextEventPayload | null = null
26
+ let calendarFetchedAt = 0
27
+
28
+ /** Full WMO 4677 — discrete codes, exact match required. */
29
+ export const WMO_CODES: Record<number, string> = {
30
+ 0: 'Clear',
31
+ 1: 'Mostly Clear',
32
+ 2: 'Partly Cloudy',
33
+ 3: 'Overcast',
34
+ 45: 'Foggy',
35
+ 48: 'Rime Fog',
36
+ 51: 'Light Drizzle',
37
+ 53: 'Drizzle',
38
+ 55: 'Heavy Drizzle',
39
+ 56: 'Freezing Drizzle',
40
+ 57: 'Icy Drizzle',
41
+ 61: 'Light Rain',
42
+ 63: 'Rain',
43
+ 65: 'Heavy Rain',
44
+ 66: 'Freezing Rain',
45
+ 67: 'Icy Rain',
46
+ 71: 'Light Snow',
47
+ 73: 'Snow',
48
+ 75: 'Heavy Snow',
49
+ 77: 'Snow Grains',
50
+ 80: 'Light Showers',
51
+ 81: 'Showers',
52
+ 82: 'Violent Showers',
53
+ 85: 'Snow Showers',
54
+ 86: 'Heavy Snow Showers',
55
+ 95: 'Thunderstorm',
56
+ 96: 'T-Storm + Hail',
57
+ 99: 'Severe T-Storm',
58
+ }
59
+
60
+ export function wmoDescription(code: number): string {
61
+ if (code in WMO_CODES) return WMO_CODES[code]
62
+ if (code >= 95) return 'Thunderstorm'
63
+ if (code >= 80) return 'Showers'
64
+ if (code >= 71) return 'Snow'
65
+ if (code >= 61) return 'Rain'
66
+ if (code >= 51) return 'Drizzle'
67
+ if (code >= 45) return 'Foggy'
68
+ if (code >= 1) return 'Cloudy'
69
+ return 'Clear'
70
+ }
71
+
72
+ export function parseCoord(raw: unknown, kind: 'lat' | 'lon'): number | undefined {
73
+ if (raw == null || raw === '') return undefined
74
+ const n = typeof raw === 'number' ? raw : parseFloat(String(raw))
75
+ if (!Number.isFinite(n)) return undefined
76
+ if (kind === 'lat' && (n < -90 || n > 90)) return undefined
77
+ if (kind === 'lon' && (n < -180 || n > 180)) return undefined
78
+ return n
79
+ }
80
+
81
+ function envDefaultCoords(): { lat: number; lon: number; city: string } | null {
82
+ const lat = parseCoord(process.env.COS_WEATHER_DEFAULT_LAT, 'lat')
83
+ const lon = parseCoord(process.env.COS_WEATHER_DEFAULT_LON, 'lon')
84
+ if (lat == null || lon == null) return null
85
+ const city = (process.env.COS_WEATHER_DEFAULT_CITY || '').trim()
86
+ return { lat, lon, city }
87
+ }
88
+
89
+ /** Resolve coords: phone query → last successful this process → optional env home. */
90
+ export function resolveWeatherCoords(
91
+ queryLat?: number,
92
+ queryLon?: number,
93
+ ): { lat: number; lon: number; source: 'query' | 'last' | 'env' } | null {
94
+ if (queryLat != null && queryLon != null) {
95
+ return { lat: queryLat, lon: queryLon, source: 'query' }
96
+ }
97
+ if (lastKnownLat != null && lastKnownLon != null) {
98
+ return { lat: lastKnownLat, lon: lastKnownLon, source: 'last' }
99
+ }
100
+ const env = envDefaultCoords()
101
+ if (env) return { lat: env.lat, lon: env.lon, source: 'env' }
102
+ return null
103
+ }
104
+
105
+ async function reverseGeocode(lat: number, lon: number): Promise<string> {
106
+ try {
107
+ const res = await fetch(
108
+ `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`,
109
+ { signal: AbortSignal.timeout(GEOCODE_TIMEOUT_MS) },
110
+ )
111
+ if (!res.ok) return lastKnownCity
112
+ const data = await res.json() as {
113
+ city?: string
114
+ locality?: string
115
+ principalSubdivision?: string
116
+ principalSubdivisionCode?: string
117
+ countryCode?: string
118
+ }
119
+ const city = data.city || data.locality || data.principalSubdivision || ''
120
+ const region = data.principalSubdivisionCode?.split('-')[1] || data.countryCode || ''
121
+ if (city) {
122
+ lastKnownCity = region ? `${city}, ${region}` : city
123
+ }
124
+ return lastKnownCity
125
+ } catch {
126
+ return lastKnownCity
127
+ }
128
+ }
129
+
130
+ /** Ensure a city label before any weather JSON leaves the server (3s capped). */
131
+ async function ensureCityLabel(lat: number, lon: number, source: 'query' | 'last' | 'env'): Promise<void> {
132
+ if (lastKnownCity) return
133
+ if (source === 'env') {
134
+ const env = envDefaultCoords()
135
+ if (env?.city) {
136
+ lastKnownCity = env.city
137
+ return
138
+ }
139
+ }
140
+ await reverseGeocode(lat, lon)
141
+ }
142
+
143
+ export async function fetchWeather(
144
+ queryLat?: number,
145
+ queryLon?: number,
146
+ ): Promise<WeatherPayload | null> {
147
+ const resolved = resolveWeatherCoords(queryLat, queryLon)
148
+ if (!resolved) return null
149
+
150
+ const { lat: useLat, lon: useLon, source } = resolved
151
+ const coordsChanged = source === 'query'
152
+ && (
153
+ lastKnownLat == null
154
+ || lastKnownLon == null
155
+ || Math.abs(useLat - lastKnownLat) > COORD_MOVE_DEG
156
+ || Math.abs(useLon - lastKnownLon) > COORD_MOVE_DEG
157
+ )
158
+
159
+ if (coordsChanged) {
160
+ lastKnownLat = useLat
161
+ lastKnownLon = useLon
162
+ weatherFetchedAt = 0
163
+ lastKnownCity = '' // force fresh reverse-geocode for the new place
164
+ } else if (lastKnownLat == null || lastKnownLon == null) {
165
+ lastKnownLat = useLat
166
+ lastKnownLon = useLon
167
+ }
168
+
169
+ // Warning fix: never return weather (cached or fresh) without a city when
170
+ // we can still resolve one — closes reverse-geocode race on first paint.
171
+ await ensureCityLabel(useLat, useLon, source)
172
+
173
+ if (cachedWeather && Date.now() - weatherFetchedAt < WEATHER_TTL_MS) {
174
+ // Refresh location string if geocode caught up after an older cache write.
175
+ if (lastKnownCity && cachedWeather.location !== lastKnownCity) {
176
+ cachedWeather = { ...cachedWeather, location: lastKnownCity }
177
+ }
178
+ return cachedWeather
179
+ }
180
+
181
+ try {
182
+ const res = await fetch(
183
+ `https://api.open-meteo.com/v1/forecast?latitude=${useLat}&longitude=${useLon}&current=temperature_2m,weather_code&temperature_unit=fahrenheit&timezone=auto`,
184
+ { signal: AbortSignal.timeout(FORECAST_TIMEOUT_MS) },
185
+ )
186
+ if (!res.ok) return cachedWeather
187
+
188
+ const data = await res.json() as {
189
+ current?: { temperature_2m?: number; weather_code?: number }
190
+ }
191
+ const temp = data.current?.temperature_2m
192
+ const code = data.current?.weather_code
193
+ if (typeof temp !== 'number' || typeof code !== 'number') return cachedWeather
194
+
195
+ await ensureCityLabel(useLat, useLon, source)
196
+
197
+ cachedWeather = {
198
+ temp: `${Math.round(temp)}\u00B0F`,
199
+ desc: wmoDescription(code),
200
+ location: lastKnownCity || `${useLat.toFixed(2)}, ${useLon.toFixed(2)}`,
201
+ }
202
+ weatherFetchedAt = Date.now()
203
+ return cachedWeather
204
+ } catch {
205
+ return cachedWeather
206
+ }
207
+ }
208
+
209
+ /** Public-safe filter only — no operator-specific title rules. */
210
+ function isGenericNoiseEvent(title: string): boolean {
211
+ const t = (title || '').toLowerCase()
212
+ return /\booo\b|out of office|vacation|\bpto\b/i.test(t)
213
+ }
214
+
215
+ async function fetchNextEvent(): Promise<NextEventPayload | null> {
216
+ if (cachedNextEvent && Date.now() - calendarFetchedAt < CALENDAR_TTL_MS) {
217
+ return cachedNextEvent
218
+ }
219
+
220
+ try {
221
+ const cal = await callPython(['calendar']) as {
222
+ today_remaining?: Array<{
223
+ title?: string
224
+ start_time?: string
225
+ start?: string
226
+ is_all_day?: boolean
227
+ }>
228
+ next_event?: { title?: string; start_time?: string }
229
+ minutes_until_next?: number
230
+ } | null
231
+
232
+ const remaining = cal?.today_remaining ?? []
233
+ const meaningful = remaining.find((evt) => {
234
+ if (evt.is_all_day) return false
235
+ return !isGenericNoiseEvent(evt.title || '')
236
+ })
237
+
238
+ if (meaningful) {
239
+ const now = Date.now()
240
+ const eventTime = new Date(meaningful.start_time || meaningful.start || '').getTime()
241
+ const minsUntil = Number.isFinite(eventTime)
242
+ ? Math.max(0, Math.round((eventTime - now) / 60_000))
243
+ : undefined
244
+ cachedNextEvent = {
245
+ title: meaningful.title || 'Meeting',
246
+ time: meaningful.start_time || meaningful.start || '',
247
+ ...(minsUntil != null ? { inMinutes: minsUntil } : {}),
248
+ }
249
+ } else if (cal?.next_event && !isGenericNoiseEvent(cal.next_event.title || '')) {
250
+ cachedNextEvent = {
251
+ title: cal.next_event.title || 'Meeting',
252
+ time: cal.next_event.start_time || '',
253
+ ...(cal.minutes_until_next != null ? { inMinutes: cal.minutes_until_next } : {}),
254
+ }
255
+ } else {
256
+ cachedNextEvent = null
257
+ }
258
+
259
+ calendarFetchedAt = Date.now()
260
+ return cachedNextEvent
261
+ } catch {
262
+ return cachedNextEvent
263
+ }
264
+ }
265
+
266
+ /** Test helper — reset module caches between cases. */
267
+ export function _resetWelcomeContextCachesForTests(): void {
268
+ cachedWeather = null
269
+ weatherFetchedAt = 0
270
+ lastKnownLat = undefined
271
+ lastKnownLon = undefined
272
+ lastKnownCity = ''
273
+ cachedNextEvent = null
274
+ calendarFetchedAt = 0
275
+ }
276
+
277
+ welcomeContextRouter.get('/welcome-context', async (req, res) => {
278
+ const lat = parseCoord(req.query.lat, 'lat')
279
+ const lon = parseCoord(req.query.lon, 'lon')
280
+ // Both required together; one alone is ignored.
281
+ const pairLat = lat != null && lon != null ? lat : undefined
282
+ const pairLon = lat != null && lon != null ? lon : undefined
283
+
284
+ const [weather, nextEvent] = await Promise.all([
285
+ fetchWeather(pairLat, pairLon),
286
+ fetchNextEvent(),
287
+ ])
288
+
289
+ res.json({
290
+ ...(weather ? { weather } : {}),
291
+ ...(nextEvent ? { nextEvent } : {}),
292
+ })
293
+ })
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env tsx
2
+ // Creates a fresh short-number namespace without touching sessions or archives.
3
+ // Restart the server afterward so every new exchange is stamped into the era.
4
+ //
5
+ // npx tsx server/scripts/reset-message-era.ts --confirm
6
+
7
+ import { createMessageEra, currentMessageEraState } from '../lib/message-era.js'
8
+
9
+ if (!process.argv.includes('--confirm')) {
10
+ const current = currentMessageEraState()
11
+ console.error(`Current message era: ${current.era}`)
12
+ console.error('Refusing to reset without --confirm. History will be retained.')
13
+ process.exit(2)
14
+ }
15
+
16
+ const next = createMessageEra()
17
+ console.log(JSON.stringify({
18
+ status: 'created',
19
+ ...next,
20
+ history: 'retained',
21
+ restartRequired: true,
22
+ verify: 'GET /api/message-counter should return { max: 0 or small, era: "<era above>" } after server restart',
23
+ nextSteps: [
24
+ 'Restart LaunchAgent / Control Update Server generation',
25
+ 'Phone reconnect so syncMessageEra clears the live list',
26
+ 'Send a test message — expect #1 (or low single digits)',
27
+ ],
28
+ }, null, 2))