@gotcos/glasses-server 6.16.5 → 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 +4 -1
- package/CHANGELOG.md +40 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/server/lib/archive.ts +9 -3
- package/server/lib/claude-bridge.ts +2 -0
- package/server/lib/codex-bridge.ts +2 -0
- package/server/lib/conversation.ts +38 -0
- package/server/lib/cursor-bridge.ts +6 -3
- package/server/lib/message-era.ts +94 -0
- package/server/lib/prompt-draft-store.ts +39 -3
- package/server/lib/query-job-runtime.ts +36 -6
- package/server/routes/message-ref.ts +44 -10
- package/server/routes/prompt-drafts.ts +48 -21
- package/server/routes/query.ts +20 -1
- package/server/routes/sessions.ts +23 -6
- package/server/routes/welcome-context.ts +24 -15
- package/server/scripts/reset-message-era.ts +28 -0
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
|
|
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,43 @@
|
|
|
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
|
+
|
|
1
41
|
## 6.16.5
|
|
2
42
|
|
|
3
43
|
- **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
|
package/package.json
CHANGED
package/server/lib/archive.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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(
|
|
450
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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.
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
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()) {
|
package/server/routes/query.ts
CHANGED
|
@@ -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)
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
...(
|
|
292
|
+
...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
|
|
293
|
+
...(messageEra ? { messageEra } : {}),
|
|
294
|
+
...(modelPreference ? { modelPreference } : {}),
|
|
278
295
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
279
296
|
})
|
|
280
297
|
i++
|
|
@@ -127,6 +127,19 @@ async function reverseGeocode(lat: number, lon: number): Promise<string> {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
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
|
+
|
|
130
143
|
export async function fetchWeather(
|
|
131
144
|
queryLat?: number,
|
|
132
145
|
queryLon?: number,
|
|
@@ -147,19 +160,21 @@ export async function fetchWeather(
|
|
|
147
160
|
lastKnownLat = useLat
|
|
148
161
|
lastKnownLon = useLon
|
|
149
162
|
weatherFetchedAt = 0
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
} else if (source === 'env' && !lastKnownCity) {
|
|
153
|
-
const env = envDefaultCoords()
|
|
154
|
-
if (env?.city) lastKnownCity = env.city
|
|
155
|
-
lastKnownLat = useLat
|
|
156
|
-
lastKnownLon = useLon
|
|
157
|
-
} else if (lastKnownLat == null) {
|
|
163
|
+
lastKnownCity = '' // force fresh reverse-geocode for the new place
|
|
164
|
+
} else if (lastKnownLat == null || lastKnownLon == null) {
|
|
158
165
|
lastKnownLat = useLat
|
|
159
166
|
lastKnownLon = useLon
|
|
160
167
|
}
|
|
161
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
|
+
|
|
162
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
|
+
}
|
|
163
178
|
return cachedWeather
|
|
164
179
|
}
|
|
165
180
|
|
|
@@ -177,13 +192,7 @@ export async function fetchWeather(
|
|
|
177
192
|
const code = data.current?.weather_code
|
|
178
193
|
if (typeof temp !== 'number' || typeof code !== 'number') return cachedWeather
|
|
179
194
|
|
|
180
|
-
|
|
181
|
-
const env = envDefaultCoords()
|
|
182
|
-
if (env?.city) lastKnownCity = env.city
|
|
183
|
-
}
|
|
184
|
-
if (!lastKnownCity) {
|
|
185
|
-
await reverseGeocode(useLat, useLon)
|
|
186
|
-
}
|
|
195
|
+
await ensureCityLabel(useLat, useLon, source)
|
|
187
196
|
|
|
188
197
|
cachedWeather = {
|
|
189
198
|
temp: `${Math.round(temp)}\u00B0F`,
|
|
@@ -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))
|