@gotcos/glasses-server 6.16.5 → 6.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -1
- package/CHANGELOG.md +71 -0
- package/README.md +57 -0
- package/package.json +2 -2
- 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/profile.ts +24 -2
- package/server/lib/prompt-draft-store.ts +39 -3
- package/server/lib/query-job-runtime.ts +36 -6
- package/server/lib/speaker-embeddings.ts +169 -6
- package/server/routes/health.ts +7 -0
- 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/voice.ts +9 -3
- 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,74 @@
|
|
|
1
|
+
## 6.17.0
|
|
2
|
+
|
|
3
|
+
- **Speaker diarization is now a bolt-on any install can enable.** The ~26 MB
|
|
4
|
+
voiceprint model stays out of the npm package, but the loader no longer looks
|
|
5
|
+
only inside the package (where a managed install has no copy, and where a
|
|
6
|
+
hand-placed one is destroyed by the next update). Resolution order:
|
|
7
|
+
`COS_SPEAKER_MODEL_PATH` → `~/.cos-glasses/models/` → bundled `server/models/`
|
|
8
|
+
(source checkouts). Put the model in `~/.cos-glasses/models/` and restart.
|
|
9
|
+
- **A bad model file can no longer take the server down.** onnxruntime aborts
|
|
10
|
+
the process on a malformed or mismatched model rather than throwing, which
|
|
11
|
+
under a KeepAlive LaunchAgent became a permanent restart loop that killed
|
|
12
|
+
queries, meetings, and transcription over an optional feature. The file is now
|
|
13
|
+
screened structurally and loaded in a throwaway child process first, so a
|
|
14
|
+
truncated download or an HTML error page saved as `.onnx` disables diarization
|
|
15
|
+
and nothing else.
|
|
16
|
+
- **`/api/health` reports `speaker_id`** as `active` / `unavailable` / `error`,
|
|
17
|
+
so a server running without voiceprints is no longer indistinguishable from
|
|
18
|
+
one doing real diarization. `error` means a model is installed but the runtime
|
|
19
|
+
rejected it.
|
|
20
|
+
- **Profile config is read from the data home.** `.cos-profile.json` is now
|
|
21
|
+
loaded from `~/.cos-glasses/` when present, ahead of the package-root copy.
|
|
22
|
+
Managed installs previously resolved it inside the generation directory, where
|
|
23
|
+
no profile exists and every field silently took its default — losing
|
|
24
|
+
transcription vocabulary, whisper corrections, and the wearer label.
|
|
25
|
+
- **`/api/voice/enroll` and `/api/voice/status` no longer hardcode `MU`.** Both
|
|
26
|
+
use `owner_speaker_label` (default `Me`), matching what identification already
|
|
27
|
+
used. **Upgrade note:** an install that enrolled under the old default holds a
|
|
28
|
+
profile named `MU`; `/api/voice/status` will report `enrolled: false` until you
|
|
29
|
+
set `owner_speaker_label` to `MU`. Do that rather than re-enrolling, which
|
|
30
|
+
splits one voice across two profiles.
|
|
31
|
+
|
|
32
|
+
## 6.16.9
|
|
33
|
+
|
|
34
|
+
- **Message-era reset visible to a running server.** `currentMessageEraState`
|
|
35
|
+
re-reads `message-era.json` when mtime changes, so
|
|
36
|
+
`reset-message-era.ts --confirm` (separate process) is picked up without
|
|
37
|
+
depending on a stale in-memory `legacy` cache. Restart still recommended
|
|
38
|
+
so live sessions stop serving pre-reset stamps; reset script prints verify
|
|
39
|
+
steps.
|
|
40
|
+
|
|
41
|
+
## 6.16.8
|
|
42
|
+
|
|
43
|
+
- **Per-exchange model stamps on managed installs.** History badges were
|
|
44
|
+
defaulting to Opus whenever the server pair had no `modelPreference`
|
|
45
|
+
(Jul 19 `#228`–`#231` archives are unstamped — client `DEFAULT_MODEL`).
|
|
46
|
+
Bridges now stamp the actual run model on each exchange; archive +
|
|
47
|
+
today/all-messages emit via `resolveExchangePairModel`. Unstamped
|
|
48
|
+
legacy rows stay unlabeled server-side (phone still defaults those to
|
|
49
|
+
Opus — cannot invent Grok without evidence). New Cursor/Codex/Claude
|
|
50
|
+
turns keep truthful badges after reconnect/merge.
|
|
51
|
+
|
|
52
|
+
## 6.16.7
|
|
53
|
+
|
|
54
|
+
- **Message-number era reset on managed public installs.** Companion burn
|
|
55
|
+
fixes never lowered the archive ceiling (`/api/message-counter` still
|
|
56
|
+
reported `max: 17931` with no `era`). Ported `message-era` under
|
|
57
|
+
`~/.cos-glasses/data`, era-scoped counter + lookup, stamp `messageEra` on
|
|
58
|
+
exchanges, and `409 message_era_mismatch` on legacy `/api/query` + durable
|
|
59
|
+
admission after a reset. Ops: `npx tsx server/scripts/reset-message-era.ts
|
|
60
|
+
--confirm`, restart LaunchAgent, phone reconnect → live list clears and
|
|
61
|
+
numbering restarts near #1. Archives retained.
|
|
62
|
+
|
|
63
|
+
## 6.16.6
|
|
64
|
+
|
|
65
|
+
- **HQ finalize no longer loses to late Fast warm.** Fast warm and speculative
|
|
66
|
+
HQ warm share `warmTranscripts[chunk]`; a slow turbo decode could overwrite
|
|
67
|
+
large-v3 after HQ had already won. Warm/final writes are quality-monotonic
|
|
68
|
+
(hq > cloud > fast) for the same audio hash. Finalize reuses HQ/cloud only;
|
|
69
|
+
when Settings HQ is available, a degraded turbo warm is retried with
|
|
70
|
+
`automatic` instead of becoming the finished-chat question text.
|
|
71
|
+
|
|
1
72
|
## 6.16.5
|
|
2
73
|
|
|
3
74
|
- **Welcome weather restored on managed installs.** Ported authenticated
|
package/README.md
CHANGED
|
@@ -130,6 +130,12 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
130
130
|
- Tasks / calendar / people context **if** you run the
|
|
131
131
|
[COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
|
|
132
132
|
glasses + AI only
|
|
133
|
+
- Welcome weather on the glasses home screen via authenticated
|
|
134
|
+
`GET /api/welcome-context?lat=&lon=`. The phone supplies GPS (Even Hub
|
|
135
|
+
location permission); this server only proxies Open-Meteo. Without phone
|
|
136
|
+
coords the route uses last-known process coords, then optional
|
|
137
|
+
`COS_WEATHER_DEFAULT_*`, otherwise omits weather. Optional `nextEvent`
|
|
138
|
+
appears when `COS_SCRIPTS_DIR` calendar data is available.
|
|
133
139
|
|
|
134
140
|
## Configuration
|
|
135
141
|
|
|
@@ -147,6 +153,8 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
|
|
|
147
153
|
in the managed CLI working directory),
|
|
148
154
|
`COS_CURSOR_AGENT_BIN` (optional absolute Cursor `agent` binary),
|
|
149
155
|
`COS_CURSOR_PERSIST_SESSIONS=0` (disable Cursor session resume),
|
|
156
|
+
`COS_WEATHER_DEFAULT_LAT` / `COS_WEATHER_DEFAULT_LON` /
|
|
157
|
+
`COS_WEATHER_DEFAULT_CITY` (optional home fallback when phone GPS is denied),
|
|
150
158
|
`COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
|
|
151
159
|
server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
|
|
152
160
|
location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
|
|
@@ -155,6 +163,55 @@ Telegram activity export is disabled by default even when a private COS
|
|
|
155
163
|
pipeline contains `.telegram_config.json`; enable it only with the explicit
|
|
156
164
|
`COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
|
|
157
165
|
|
|
166
|
+
## Speaker diarization (opt-in)
|
|
167
|
+
|
|
168
|
+
Without a voiceprint model this server does not classify speakers at all — it
|
|
169
|
+
passes through whatever label the client sends (`Unknown` when the client sends
|
|
170
|
+
nothing; the COS companion sends its own wearer/`Ext` labels). Named
|
|
171
|
+
per-speaker diarization needs a ~26 MB voiceprint model that is deliberately
|
|
172
|
+
**not** shipped in the npm package, so it is a bolt-on:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
mkdir -p ~/.cos-glasses/models
|
|
176
|
+
# put 3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx there, then restart
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The server searches, in order: `COS_SPEAKER_MODEL_PATH` (explicit full path to
|
|
180
|
+
the `.onnx`), `~/.cos-glasses/models/`, then a bundled `server/models/` copy
|
|
181
|
+
(source checkouts only). **Use the data home, not the installed package** —
|
|
182
|
+
anything inside the package is destroyed by the next update, while
|
|
183
|
+
`~/.cos-glasses/` survives.
|
|
184
|
+
|
|
185
|
+
Use an **absolute** path if you set `COS_SPEAKER_MODEL_PATH` — a relative one
|
|
186
|
+
resolves against the working directory, which under the managed LaunchAgent is
|
|
187
|
+
the installed package.
|
|
188
|
+
|
|
189
|
+
Verify with `/api/health` → `speaker_id`:
|
|
190
|
+
|
|
191
|
+
| Value | Meaning |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `active` | model loaded, diarization running |
|
|
194
|
+
| `unavailable` | no model found — labels come from the client |
|
|
195
|
+
| `error` | a model is present but the runtime rejected it (see the startup log) |
|
|
196
|
+
|
|
197
|
+
The model is read once at startup, so restart after adding it. A corrupt or
|
|
198
|
+
mismatched `.onnx` is screened and probed in a child process first, so a bad
|
|
199
|
+
download disables diarization instead of taking the server down — but it does
|
|
200
|
+
mean a wrong file fails silently apart from that log line.
|
|
201
|
+
|
|
202
|
+
The wearer's label comes from `owner_speaker_label` in
|
|
203
|
+
`~/.cos-glasses/.cos-profile.json` (default `Me`); set it to match the profile
|
|
204
|
+
name you enrol under. Train voices via `/api/voice/enroll?name=…` (the default
|
|
205
|
+
name is `owner_speaker_label`); profiles persist in
|
|
206
|
+
`~/.cos-glasses/data/voice-profiles.json`.
|
|
207
|
+
|
|
208
|
+
**Upgrading from an older server:** before this release the enrollment default
|
|
209
|
+
was hardcoded to `MU`, so an existing install may hold a profile under that name
|
|
210
|
+
while `owner_speaker_label` resolves to `Me`. `/api/voice/status` will then
|
|
211
|
+
report `enrolled: false`. Set `owner_speaker_label` to `MU` to keep the existing
|
|
212
|
+
voiceprints rather than re-enrolling, which would split the same voice across two
|
|
213
|
+
profiles.
|
|
214
|
+
|
|
158
215
|
## HQ dictation
|
|
159
216
|
|
|
160
217
|
Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.17.0",
|
|
4
|
+
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
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
|
+
}
|
package/server/lib/profile.ts
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
// Profile loader — reads user identity from .cos-profile.json (gitignored)
|
|
2
2
|
// Falls back to generic defaults for users who haven't configured a profile
|
|
3
3
|
|
|
4
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
5
6
|
import { resolve } from 'node:path'
|
|
6
7
|
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
7
8
|
|
|
8
9
|
const APP_ROOT = resolve(import.meta.dirname, '../..')
|
|
10
|
+
|
|
11
|
+
/** The profile in the data home. Survives updates; the APP_ROOT copy does not. */
|
|
12
|
+
export function homeProfilePath(): string {
|
|
13
|
+
return resolve(homedir(), '.cos-glasses', '.cos-profile.json')
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
// Single canonical path — used by BOTH the reader and the writer so a glossary
|
|
10
17
|
// PUT can never write to a different file than the cache reads from. Lazy +
|
|
11
18
|
// env-overridable (COS_PROFILE_PATH) so tests can target a temp file.
|
|
19
|
+
//
|
|
20
|
+
// APP_ROOT is the INSTALLED PACKAGE root. For a managed install that is inside
|
|
21
|
+
// the generation directory, which an update replaces wholesale — so a profile
|
|
22
|
+
// there is destroyed on every upgrade, and a fresh managed install has no
|
|
23
|
+
// profile at all (loadProfile() catches to {} and every field silently takes
|
|
24
|
+
// its default). Relying on COS_PROFILE_PATH alone is not enough either: the
|
|
25
|
+
// launcher rebuilds its environment from the release manifest rather than the
|
|
26
|
+
// existing plist, so an operator-set value is dropped by the next
|
|
27
|
+
// install/update/repair. Prefer the data home whenever a profile lives there.
|
|
28
|
+
//
|
|
29
|
+
// Order: explicit override → ~/.cos-glasses/.cos-profile.json → package root.
|
|
12
30
|
function profilePath(): string {
|
|
13
|
-
|
|
31
|
+
const override = process.env.COS_PROFILE_PATH?.trim()
|
|
32
|
+
if (override) return resolve(override)
|
|
33
|
+
const home = homeProfilePath()
|
|
34
|
+
if (existsSync(home)) return home
|
|
35
|
+
return resolve(APP_ROOT, '.cos-profile.json')
|
|
14
36
|
}
|
|
15
37
|
|
|
16
38
|
let profileCache: Record<string, unknown> | null = null
|
|
@@ -24,6 +24,13 @@ export interface PromptDraftTranscriptRecord {
|
|
|
24
24
|
acceptedDegraded?: boolean
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Higher wins. Fast warm must never replace an HQ/cloud decode for the same audio. */
|
|
28
|
+
export function transcriptQualityRank(quality: PromptDraftTranscriptRecord['actualQuality']): number {
|
|
29
|
+
if (quality === 'hq') return 3
|
|
30
|
+
if (quality === 'cloud') return 2
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export interface PromptDraftMeta {
|
|
28
35
|
v: 2
|
|
29
36
|
draftId: string
|
|
@@ -224,10 +231,39 @@ export async function markPromptDraftChunkTranscript(
|
|
|
224
231
|
? { text: record, hash: meta.chunkHashes[key] ?? '', requestedMode: 'hq', actualQuality: 'fast', backend: 'legacy', degraded: true }
|
|
225
232
|
: record
|
|
226
233
|
if (meta.chunkHashes[key] && normalized.hash && meta.chunkHashes[key] !== normalized.hash) return meta
|
|
227
|
-
|
|
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
|
}
|