@gotcos/glasses-server 6.52.0 → 6.52.3
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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## 6.52.3
|
|
2
|
+
|
|
3
|
+
### A quiet ring stays with its owner; a zombie never takes it on waking
|
|
4
|
+
|
|
5
|
+
2026-09-20: a hidden copy of the glasses app from the night before took the ring after the live copy went quiet, and answered a hold-to-talk 27 minutes later with a stale session. Under 6.50.4 an older boot could take a ring whose owner had been quiet for 45 s, and a locked phone is quiet for longer than that.
|
|
6
|
+
|
|
7
|
+
- **An older boot takes a quiet ring back only after ten minutes** (`CLIENT_INSTANCE_RECLAIM_MS`), **and only while it has itself been claiming** (`CLIENT_INSTANCE_AWAKE_MS`, 45 s: its own previous claim must be that recent). Both copies sleep when the phone does, so a zombie that wakes beside the live copy has a gap of its own and is refused; the live copy's refresh then lands within a tick. A copy never seen before is not awake. A NEWER boot still takes at once, however quiet the owner: reopening COS remains the fix for a dead copy. The route remembers each copy's last claim time (`CLIENT_INSTANCE_MAX_SEEN` 64, least recent dropped).
|
|
8
|
+
- Everything else in the referee is 6.50.4: one owner per device, the recording holds (own tagged chunks, `recording` in the claim), `check: true` never takes, no protocol change. Glasses 6.9.510 and 6.9.516 see only the longer takeover window. No new client is required; glasses 6.9.518 adds the matching client-side check.
|
|
9
|
+
- **The 6.52.1 Codex fixtures now expect `turn_started_at`.** 6.52.1 stamped the prompt line with the user record's timestamp and left three `session-stream-codex` expectations on the old shape, so the suite was red. The behaviour is unchanged; the expectations were updated.
|
|
10
|
+
- Tests: the reclaim edge (10 min, awake), the overnight zombie (hours-old own claim, never seen), the awake edge to the millisecond, the newer boot still taking, and over HTTP: checks past the window say owner without taking, a first claim after sleep is refused and the next tick takes. Gate: `referee-zombie-reclaims-after-45s`, `referee-asleep-zombie-reclaims`, `referee-never-seen-counts-as-awake`, `referee-last-seen-unnoted`.
|
|
11
|
+
|
|
12
|
+
## 6.52.1
|
|
13
|
+
|
|
14
|
+
### Session turn clock
|
|
15
|
+
|
|
16
|
+
Sessions prompt events now carry additive `turn_started_at` (ISO), the timestamp on the user record that started the turn. Seed, reconnect, and live frames keep that value; transport `at` is still `Date.now()` at emit time, so a reconnect no longer resets the glasses elapsed clock. `/api/models` advertises `capabilities.runningTurnMeta.protocolVersion = 1`. A 6.52.0 client strips the unknown field and renders as before. Messages jobs already have durable `startedAt`; this is Sessions only.
|
|
17
|
+
|
|
18
|
+
- Tests: a Claude user row with `timestamp` stamps the prompt draft; a row without one is byte-identical to 6.52.0; the models capability is present beside `sessionQuestions`.
|
|
19
|
+
|
|
1
20
|
## 6.52.0
|
|
2
21
|
|
|
3
22
|
### Messages trail
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.52.
|
|
3
|
+
"version": "6.52.3",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,20 @@
|
|
|
16
16
|
export const CLIENT_INSTANCE_TICK_MS = 15_000
|
|
17
17
|
/** Three missed ticks: the owner is gone. */
|
|
18
18
|
export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
|
|
19
|
+
/**
|
|
20
|
+
* 6.52.3: how long the owner must be quiet before an OLDER boot may take the ring back.
|
|
21
|
+
* On 2026-09-20 a hidden copy from the night before took the ring after the live copy
|
|
22
|
+
* went quiet (a locked phone throttles its timers) and answered a hold-to-talk 27 minutes
|
|
23
|
+
* later with a stale session. 45 s is a normal quiet spell for a locked phone; a dead
|
|
24
|
+
* copy stays dead, so waiting ten minutes costs nothing.
|
|
25
|
+
*/
|
|
26
|
+
export const CLIENT_INSTANCE_RECLAIM_MS = 10 * 60_000
|
|
27
|
+
/**
|
|
28
|
+
* 6.52.3: an older boot may take the ring back only when ITS OWN previous claim is this
|
|
29
|
+
* recent. Both copies sleep when the phone does; a zombie that woke up alongside the live
|
|
30
|
+
* copy has a gap of its own, and the live copy's refresh lands within a tick.
|
|
31
|
+
*/
|
|
32
|
+
export const CLIENT_INSTANCE_AWAKE_MS = CLIENT_INSTANCE_LIVE_MS
|
|
19
33
|
/**
|
|
20
34
|
* 6.50.4: a meeting chunk inside this window means that copy is recording. Five chunk
|
|
21
35
|
* intervals (~6 s each), so a stretch of silence or a slow upload does not end the hold.
|
|
@@ -126,6 +140,8 @@ export function arbitrateClientInstance(
|
|
|
126
140
|
claim: ClientInstanceClaim,
|
|
127
141
|
now: number,
|
|
128
142
|
evidence: CaptureEvidence = NO_CAPTURE,
|
|
143
|
+
/** When this claimant last claimed (any verdict), or undefined: never seen. */
|
|
144
|
+
claimantLastSeenAt?: number,
|
|
129
145
|
): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
|
|
130
146
|
const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
|
|
131
147
|
if (!owner) return { owner: fresh, verdict: 'owner', took: true }
|
|
@@ -142,6 +158,11 @@ export function arbitrateClientInstance(
|
|
|
142
158
|
// The ring moves on the newer copy's first claim after the meeting stops.
|
|
143
159
|
if (ownerIsRecording(owner, evidence, now)) return { owner, verdict: 'yield', took: false }
|
|
144
160
|
if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
|
|
145
|
-
|
|
161
|
+
// 6.52.3: an older boot takes a quiet ring back only after CLIENT_INSTANCE_RECLAIM_MS,
|
|
162
|
+
// and only while it has itself been claiming (awake), never on the first tick after a
|
|
163
|
+
// gap of its own. A dead newer copy is replaced when the user reopens COS (a newer boot
|
|
164
|
+
// still takes at once); this rule is for the copy that is merely quiet.
|
|
165
|
+
const claimantAwake = claimantLastSeenAt !== undefined && now - claimantLastSeenAt <= CLIENT_INSTANCE_AWAKE_MS
|
|
166
|
+
if (now - owner.seenAt > CLIENT_INSTANCE_RECLAIM_MS && claimantAwake) return { owner: fresh, verdict: 'owner', took: true }
|
|
146
167
|
return { owner, verdict: 'yield', took: false }
|
|
147
168
|
}
|
|
@@ -37,7 +37,7 @@ export type SessionStreamDraft =
|
|
|
37
37
|
// ADDITIVE TO A CLOSED SET, AND SAFE BY CONSTRUCTION: the client validates `kind`
|
|
38
38
|
// against its own table and ignores anything it does not know, so a build that
|
|
39
39
|
// predates this renders exactly as it did before rather than breaking.
|
|
40
|
-
| { kind: 'prompt'; text: string }
|
|
40
|
+
| { kind: 'prompt'; text: string; turn_started_at?: string }
|
|
41
41
|
| { kind: 'prose'; text: string }
|
|
42
42
|
// 6.49.0: `tool_outcome` rides on a status draft, never on a new kind. A shipped
|
|
43
43
|
// client drops an unknown kind BEFORE seq accounting and paints a gap row for it
|
|
@@ -173,6 +173,35 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
173
173
|
: null
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/** ISO time the current turn actually started, from the transcript record. */
|
|
177
|
+
export function recordTimestampIso(record: Record<string, unknown>): string | undefined {
|
|
178
|
+
const nested = asRecord(record.payload)
|
|
179
|
+
const item = asRecord(record.item)
|
|
180
|
+
const candidates = [record.timestamp, record.ts, nested?.timestamp, nested?.ts, item?.timestamp]
|
|
181
|
+
for (const c of candidates) {
|
|
182
|
+
if (typeof c === 'string' && c.trim()) {
|
|
183
|
+
const t = Date.parse(c)
|
|
184
|
+
if (Number.isFinite(t) && t > 0) return new Date(t).toISOString()
|
|
185
|
+
}
|
|
186
|
+
if (typeof c === 'number' && Number.isFinite(c) && c > 0) {
|
|
187
|
+
const ms = c < 1e12 ? c * 1000 : c
|
|
188
|
+
if (Number.isFinite(ms) && ms > 0) return new Date(ms).toISOString()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stampPromptTurnStart(drafts: SessionStreamDraft[], record: Record<string, unknown>): SessionStreamDraft[] {
|
|
195
|
+
const iso = recordTimestampIso(record)
|
|
196
|
+
if (!iso) return drafts
|
|
197
|
+
let stamped = false
|
|
198
|
+
return drafts.map((draft) => {
|
|
199
|
+
if (draft.kind !== 'prompt' || stamped) return draft
|
|
200
|
+
stamped = true
|
|
201
|
+
return { ...draft, turn_started_at: iso }
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
176
205
|
function countLines(value: unknown): number {
|
|
177
206
|
if (typeof value !== 'string' || value.length === 0) return 0
|
|
178
207
|
return value.split('\n').length
|
|
@@ -1233,9 +1262,8 @@ export function draftsFromRecord(
|
|
|
1233
1262
|
const obj = asRecord(record)
|
|
1234
1263
|
if (!obj) return []
|
|
1235
1264
|
try {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
return draftsFromClaudeRecord(obj)
|
|
1265
|
+
const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : draftsFromClaudeRecord(obj)
|
|
1266
|
+
return stampPromptTurnStart(drafts, obj)
|
|
1239
1267
|
} catch {
|
|
1240
1268
|
// A malformed record costs one line of the trail. It must never cost the stream.
|
|
1241
1269
|
return []
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
|
|
28
28
|
/** Owners kept at once; the least recently seen device is dropped past this. */
|
|
29
29
|
export const CLIENT_INSTANCE_MAX_DEVICES = 16
|
|
30
|
+
/** Copies whose last claim time is remembered (6.52.3); the least recent is dropped past this. */
|
|
31
|
+
export const CLIENT_INSTANCE_MAX_SEEN = 64
|
|
30
32
|
|
|
31
33
|
export interface ClientInstanceRouterDeps {
|
|
32
34
|
now?: () => number
|
|
@@ -49,6 +51,13 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
49
51
|
const router = Router()
|
|
50
52
|
const owners = new Map<string, ClientInstanceOwner>()
|
|
51
53
|
const chunks = new ClientChunkLedger()
|
|
54
|
+
// 6.52.3: when each copy last claimed, so an older boot proves it was awake before it
|
|
55
|
+
// may take a quiet ring back. Bounded like the chunk ledger.
|
|
56
|
+
const lastSeen = new Map<string, number>()
|
|
57
|
+
const noteSeen = (id: string, at: number) => {
|
|
58
|
+
lastSeen.delete(id); lastSeen.set(id, at)
|
|
59
|
+
while (lastSeen.size > CLIENT_INSTANCE_MAX_SEEN) lastSeen.delete(lastSeen.keys().next().value as string)
|
|
60
|
+
}
|
|
52
61
|
router.use((req, _res, next) => {
|
|
53
62
|
if (isMeetingChunkPost(req.method, req.path)) chunks.note(deviceKey(req.socket?.remoteAddress ?? req.ip), parseInstanceTag(req.query?.clientInstance), now())
|
|
54
63
|
next()
|
|
@@ -61,7 +70,9 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
61
70
|
const previous = owners.get(device) ?? null
|
|
62
71
|
const evidence = previous ? chunks.evidence(device, previous.id, claim.id, at) : NO_CAPTURE
|
|
63
72
|
const captureLive = previous ? ownerIsRecording(previous, evidence, at) : false
|
|
64
|
-
const
|
|
73
|
+
const claimantLastSeenAt = lastSeen.get(claim.id)
|
|
74
|
+
noteSeen(claim.id, at)
|
|
75
|
+
const result = arbitrateClientInstance(previous, claim, at, evidence, claimantLastSeenAt)
|
|
65
76
|
if (req.body?.check === true) {
|
|
66
77
|
const shown = previous ?? result.owner
|
|
67
78
|
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true, captureLive })
|
package/server/routes/health.ts
CHANGED
|
@@ -588,6 +588,10 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
588
588
|
// `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
|
|
589
589
|
// quiet for `liveWindowMs` hands every held request back to the Mac.
|
|
590
590
|
sessionQuestions: sessionQuestionsCapability(),
|
|
591
|
+
// 6.52.1: Sessions prompt events carry additive `turn_started_at` (ISO), the
|
|
592
|
+
// real user-record time. Transport `at` stays the seed/live stamp. Absent on
|
|
593
|
+
// 6.52.0, the client falls back to a local pin.
|
|
594
|
+
runningTurnMeta: { protocolVersion: 1 },
|
|
591
595
|
transcription: {
|
|
592
596
|
...transcription,
|
|
593
597
|
live: transcriptionLive,
|