@gotcos/glasses-server 6.21.3 → 6.21.4
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 +11 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/server/lib/claude-bridge.ts +3 -1
- package/server/lib/codex-bridge.ts +3 -1
- package/server/lib/cursor-bridge.ts +1 -0
- package/server/lib/media-store.ts +53 -1
- package/server/lib/query-job-runtime.ts +1 -0
- package/server/lib/run-output-images.ts +10 -1
- package/server/routes/media.ts +4 -0
- package/server/routes/query.ts +2 -0
- package/server/routes/sessions.ts +58 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 6.21.4
|
|
2
|
+
|
|
3
|
+
- Preserve validated phone-photo references on Claude and Codex conversation
|
|
4
|
+
exchanges so Recent Glasses and message-history clients can recover the
|
|
5
|
+
original visual context instead of receiving a text-only marker.
|
|
6
|
+
- Recover validated refs at read time from the durable media association index,
|
|
7
|
+
keyed by exact session ID, global message number, and message era. Pre-6.21.4
|
|
8
|
+
unversioned refs are recovered only for the active era when both creation and
|
|
9
|
+
association occurred after its boundary; ambiguous historical refs fail
|
|
10
|
+
closed rather than risking the wrong photo.
|
|
11
|
+
|
|
1
12
|
## 6.21.3
|
|
2
13
|
|
|
3
14
|
- Route Max-tier provisional dictation preview through the resident Turbo
|
package/README.md
CHANGED
|
@@ -112,6 +112,10 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
112
112
|
authenticated query that requested it
|
|
113
113
|
- Message History + cross-day "reference message N" — your chats are archived by day
|
|
114
114
|
and every message keeps a permanent number you can recall (`/api/archive`, `/api/message/:num`)
|
|
115
|
+
- Recent/history responses preserve validated photo references. Recovery uses
|
|
116
|
+
exact session + global-message + message-era identity without exposing storage
|
|
117
|
+
paths. Ambiguous pre-version historical refs fail closed; unversioned refs are
|
|
118
|
+
recovered only inside the active era when created and associated after its boundary.
|
|
115
119
|
- Send phone photos with queued prompts, and review assistant-selected generated,
|
|
116
120
|
research, or explicitly used email images in Messages and on the G2 lens
|
|
117
121
|
- Recover long voice prompts after phone, network, or server interruptions. Audio
|
package/package.json
CHANGED
|
@@ -428,6 +428,7 @@ export async function callClaudeStreaming(
|
|
|
428
428
|
outputImagePublisher = createRunOutputImagePublisher({
|
|
429
429
|
sessionId: sid,
|
|
430
430
|
globalMsgNum,
|
|
431
|
+
messageEra: options?.messageEra,
|
|
431
432
|
maxImages: outputImageBudget,
|
|
432
433
|
})
|
|
433
434
|
} catch (err) {
|
|
@@ -473,6 +474,7 @@ export async function callClaudeStreaming(
|
|
|
473
474
|
// Record user message (with [Photo]/[N Photos] prefix for vision queries)
|
|
474
475
|
const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
|
|
475
476
|
const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
|
|
477
|
+
const inboundAttachments = imageInputs.length > 0 ? imageInputs.map(input => input.attachment) : undefined
|
|
476
478
|
const exchangeProvenance = {
|
|
477
479
|
clientJobId: options?.clientJobId,
|
|
478
480
|
generation: options?.generation,
|
|
@@ -482,7 +484,7 @@ export async function callClaudeStreaming(
|
|
|
482
484
|
'user',
|
|
483
485
|
historyQuery,
|
|
484
486
|
globalMsgNum,
|
|
485
|
-
|
|
487
|
+
inboundAttachments,
|
|
486
488
|
exchangeProvenance,
|
|
487
489
|
resolvedModel,
|
|
488
490
|
)
|
|
@@ -321,6 +321,7 @@ export async function callCodexStreaming(
|
|
|
321
321
|
sessionId: sid,
|
|
322
322
|
globalMsgNum,
|
|
323
323
|
runId: run.runId,
|
|
324
|
+
messageEra: options?.messageEra,
|
|
324
325
|
maxImages: outputImageBudget,
|
|
325
326
|
})
|
|
326
327
|
} catch (err) {
|
|
@@ -363,6 +364,7 @@ export async function callCodexStreaming(
|
|
|
363
364
|
const isFirstQuery = isNewSession(sid)
|
|
364
365
|
const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
|
|
365
366
|
const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
|
|
367
|
+
const inboundAttachments = imageInputs.length > 0 ? imageInputs.map(input => input.attachment) : undefined
|
|
366
368
|
const exchangeProvenance = {
|
|
367
369
|
clientJobId: options?.clientJobId,
|
|
368
370
|
generation: options?.generation,
|
|
@@ -372,7 +374,7 @@ export async function callCodexStreaming(
|
|
|
372
374
|
'user',
|
|
373
375
|
historyQuery,
|
|
374
376
|
globalMsgNum,
|
|
375
|
-
|
|
377
|
+
inboundAttachments,
|
|
376
378
|
exchangeProvenance,
|
|
377
379
|
model,
|
|
378
380
|
)
|
|
@@ -33,6 +33,7 @@ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
|
33
33
|
import { dataPath } from './data-dir.js'
|
|
34
34
|
import {
|
|
35
35
|
isValidMediaId,
|
|
36
|
+
mergeMediaAttachmentRefs,
|
|
36
37
|
parseMediaAttachmentRef,
|
|
37
38
|
type MediaAttachmentRef,
|
|
38
39
|
type MediaKind,
|
|
@@ -131,6 +132,7 @@ export interface MediaRecord {
|
|
|
131
132
|
clientQueueItemId?: string
|
|
132
133
|
runId?: string
|
|
133
134
|
globalMsgNum?: number
|
|
135
|
+
messageEra?: string
|
|
134
136
|
createdAtMs: number
|
|
135
137
|
updatedAtMs: number
|
|
136
138
|
reservedAtMs?: number
|
|
@@ -197,6 +199,9 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
|
|
|
197
199
|
...(typeof r.clientQueueItemId === 'string' ? { clientQueueItemId: r.clientQueueItemId } : {}),
|
|
198
200
|
...(typeof r.runId === 'string' ? { runId: r.runId } : {}),
|
|
199
201
|
...(typeof r.globalMsgNum === 'number' ? { globalMsgNum: r.globalMsgNum } : {}),
|
|
202
|
+
...(typeof r.messageEra === 'string' && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(r.messageEra)
|
|
203
|
+
? { messageEra: r.messageEra }
|
|
204
|
+
: {}),
|
|
200
205
|
createdAtMs: typeof r.createdAtMs === 'number' ? r.createdAtMs : Date.now(),
|
|
201
206
|
updatedAtMs: typeof r.updatedAtMs === 'number' ? r.updatedAtMs : Date.now(),
|
|
202
207
|
...(typeof r.reservedAtMs === 'number' ? { reservedAtMs: r.reservedAtMs } : {}),
|
|
@@ -464,6 +469,51 @@ export class MediaStore {
|
|
|
464
469
|
return this.getRecord(id)?.ref ?? null
|
|
465
470
|
}
|
|
466
471
|
|
|
472
|
+
/** Recover the public refs associated with one exact conversation turn.
|
|
473
|
+
*
|
|
474
|
+
* The media index is already the durable lifecycle authority for uploaded
|
|
475
|
+
* and generated images. Older/legacy query paths associated the media here
|
|
476
|
+
* but failed to copy the refs onto the conversation exchange. Readers may
|
|
477
|
+
* use this exact session + message key to repair that projection without
|
|
478
|
+
* exposing storage paths or inventing a second attachment database.
|
|
479
|
+
*/
|
|
480
|
+
getAssociatedRefs(target: {
|
|
481
|
+
sessionId: string
|
|
482
|
+
globalMsgNum: number
|
|
483
|
+
messageEra?: string
|
|
484
|
+
activeMessageEra: string
|
|
485
|
+
activeEraStartedAt: number
|
|
486
|
+
}): MediaAttachmentRef[] {
|
|
487
|
+
if (!target.sessionId || !Number.isSafeInteger(target.globalMsgNum) || target.globalMsgNum < 1) return []
|
|
488
|
+
const validEra = (value: unknown): value is string =>
|
|
489
|
+
typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(value)
|
|
490
|
+
if (!validEra(target.activeMessageEra) || !Number.isFinite(target.activeEraStartedAt)) return []
|
|
491
|
+
const targetEra = validEra(target.messageEra) ? target.messageEra : undefined
|
|
492
|
+
const eraMatches = (rec: MediaRecord): boolean => {
|
|
493
|
+
if (rec.messageEra) return targetEra != null && rec.messageEra === targetEra
|
|
494
|
+
|
|
495
|
+
// Pre-6.21.4 associations did not persist an era. Recover them only for
|
|
496
|
+
// the active era when both creation and association are on/after its
|
|
497
|
+
// boundary. Requiring both closes the race where a pre-reset upload was
|
|
498
|
+
// associated only after the reset. Anything older may belong to legacy
|
|
499
|
+
// or any prior named era and fails closed.
|
|
500
|
+
const associatedAt = rec.associatedAtMs ?? rec.createdAtMs
|
|
501
|
+
if (target.activeMessageEra === 'legacy') return targetEra == null || targetEra === 'legacy'
|
|
502
|
+
if (targetEra === target.activeMessageEra) {
|
|
503
|
+
return rec.createdAtMs >= target.activeEraStartedAt && associatedAt >= target.activeEraStartedAt
|
|
504
|
+
}
|
|
505
|
+
return false
|
|
506
|
+
}
|
|
507
|
+
const matching = [...this.records.values()]
|
|
508
|
+
.filter(rec => rec.lifecycle === 'associated'
|
|
509
|
+
&& rec.sessionId === target.sessionId
|
|
510
|
+
&& rec.globalMsgNum === target.globalMsgNum
|
|
511
|
+
&& eraMatches(rec))
|
|
512
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs)
|
|
513
|
+
.map(rec => rec.ref)
|
|
514
|
+
return mergeMediaAttachmentRefs(matching)
|
|
515
|
+
}
|
|
516
|
+
|
|
467
517
|
/** Resolve content for serving/model input, honoring lifecycle + TTLs. */
|
|
468
518
|
getContent(id: string, variant: 'phone' | 'thumb' | 'g2' = 'phone'): MediaContentResult {
|
|
469
519
|
const rec = this.getRecord(id)
|
|
@@ -667,7 +717,7 @@ export class MediaStore {
|
|
|
667
717
|
|
|
668
718
|
/** Bind media to its final run/message. Safe to replay; wins over a
|
|
669
719
|
* delayed release. */
|
|
670
|
-
associate(ids: string[], target: { sessionId?: string; runId?: string; globalMsgNum?: number }): Promise<void> {
|
|
720
|
+
associate(ids: string[], target: { sessionId?: string; runId?: string; globalMsgNum?: number; messageEra?: string }): Promise<void> {
|
|
671
721
|
return this.withLock(() => {
|
|
672
722
|
const now = Date.now()
|
|
673
723
|
let dirty = false
|
|
@@ -684,6 +734,8 @@ export class MediaStore {
|
|
|
684
734
|
if (target.sessionId && rec.sessionId !== target.sessionId) { rec.sessionId = target.sessionId; recDirty = true }
|
|
685
735
|
if (target.runId && rec.runId !== target.runId) { rec.runId = target.runId; recDirty = true }
|
|
686
736
|
if (target.globalMsgNum != null && rec.globalMsgNum !== target.globalMsgNum) { rec.globalMsgNum = target.globalMsgNum; recDirty = true }
|
|
737
|
+
if (target.messageEra && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(target.messageEra)
|
|
738
|
+
&& rec.messageEra !== target.messageEra) { rec.messageEra = target.messageEra; recDirty = true }
|
|
687
739
|
if (recDirty) {
|
|
688
740
|
rec.updatedAtMs = now
|
|
689
741
|
dirty = true
|
|
@@ -227,6 +227,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
227
227
|
await getMediaStore().associate(resolvedAttachments.ids, {
|
|
228
228
|
sessionId: request.sessionId,
|
|
229
229
|
...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
|
|
230
|
+
messageEra: request.messageEra,
|
|
230
231
|
}).catch(error => console.error('[query-jobs] attachment association failed:', error))
|
|
231
232
|
}
|
|
232
233
|
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
@@ -41,6 +41,7 @@ export interface RunOutputImageTarget {
|
|
|
41
41
|
sessionId: string
|
|
42
42
|
globalMsgNum?: number
|
|
43
43
|
runId?: string
|
|
44
|
+
messageEra?: string
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export interface CreateRunOutputImagePublisherOptions extends RunOutputImageTarget {
|
|
@@ -223,7 +224,15 @@ function safeTarget(options: CreateRunOutputImagePublisherOptions): RunOutputIma
|
|
|
223
224
|
const runId = typeof options.runId === 'string' && options.runId.trim()
|
|
224
225
|
? options.runId.trim().slice(0, 120)
|
|
225
226
|
: undefined
|
|
226
|
-
|
|
227
|
+
const messageEra = typeof options.messageEra === 'string' && /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(options.messageEra)
|
|
228
|
+
? options.messageEra
|
|
229
|
+
: undefined
|
|
230
|
+
return {
|
|
231
|
+
sessionId,
|
|
232
|
+
...(globalMsgNum ? { globalMsgNum } : {}),
|
|
233
|
+
...(runId ? { runId } : {}),
|
|
234
|
+
...(messageEra ? { messageEra } : {}),
|
|
235
|
+
}
|
|
227
236
|
}
|
|
228
237
|
|
|
229
238
|
function assertTempRoot(rawRoot: string): string {
|
package/server/routes/media.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getMediaStore,
|
|
29
29
|
MediaStoreError,
|
|
30
30
|
} from '../lib/media-store.js'
|
|
31
|
+
import { currentMessageEra } from '../lib/message-era.js'
|
|
31
32
|
import {
|
|
32
33
|
ImageSafetyError,
|
|
33
34
|
MAX_BATCH_BYTES,
|
|
@@ -175,6 +176,9 @@ mediaRouter.post('/media/associate', async (req: Request, res: Response) => {
|
|
|
175
176
|
sessionId: safeString(req.body?.sessionId, 64),
|
|
176
177
|
runId: safeString(req.body?.runId, 120),
|
|
177
178
|
globalMsgNum,
|
|
179
|
+
// Association always belongs to the server's active message era. Never
|
|
180
|
+
// trust a client-supplied era to make media visible in historical turns.
|
|
181
|
+
messageEra: currentMessageEra(),
|
|
178
182
|
})
|
|
179
183
|
res.json({ ok: true })
|
|
180
184
|
} catch (err) {
|
package/server/routes/query.ts
CHANGED
|
@@ -160,6 +160,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
160
160
|
await getMediaStore().associate(resolvedAttachments.ids, {
|
|
161
161
|
sessionId: sid,
|
|
162
162
|
...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
|
|
163
|
+
messageEra: activeMessageEra,
|
|
163
164
|
}).catch((err) => console.error('[query] attachment association failed:', err))
|
|
164
165
|
}
|
|
165
166
|
if (!done) {
|
|
@@ -199,6 +200,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
199
200
|
{
|
|
200
201
|
abortSignal: abortController.signal,
|
|
201
202
|
effort: validEffort,
|
|
203
|
+
messageEra: activeMessageEra,
|
|
202
204
|
...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
|
|
203
205
|
},
|
|
204
206
|
)
|
|
@@ -8,9 +8,46 @@ 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 {
|
|
11
|
+
import { currentMessageEraState, exchangeBelongsToEra, type MessageEraState } from '../lib/message-era.js'
|
|
12
|
+
import { getMediaStore } from '../lib/media-store.js'
|
|
12
13
|
|
|
13
14
|
export const sessionsRouter = Router()
|
|
15
|
+
let mediaAssociationLookupWarned = false
|
|
16
|
+
|
|
17
|
+
/** Conversation exchanges are the normal attachment projection. The media
|
|
18
|
+
* store fallback repairs turns created by the legacy query path, which did
|
|
19
|
+
* durably associate media to session + message but did not stamp the refs on
|
|
20
|
+
* the exchange. Both sources contain public refs only and remain capped by the
|
|
21
|
+
* shared merge validator. */
|
|
22
|
+
function turnAttachments(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
globalMsgNum: number | undefined,
|
|
25
|
+
messageEra: string | undefined,
|
|
26
|
+
activeEra: MessageEraState,
|
|
27
|
+
...sources: unknown[]
|
|
28
|
+
): MediaAttachmentRef[] {
|
|
29
|
+
let associated: MediaAttachmentRef[] = []
|
|
30
|
+
if (globalMsgNum != null) {
|
|
31
|
+
try {
|
|
32
|
+
associated = getMediaStore().getAssociatedRefs({
|
|
33
|
+
sessionId,
|
|
34
|
+
globalMsgNum,
|
|
35
|
+
messageEra,
|
|
36
|
+
activeMessageEra: activeEra.era,
|
|
37
|
+
activeEraStartedAt: activeEra.startedAt,
|
|
38
|
+
})
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Conversation history remains useful when optional media recovery is
|
|
41
|
+
// unavailable. Warn once per process instead of turning a text endpoint
|
|
42
|
+
// into a 500 or flooding logs once per turn.
|
|
43
|
+
if (!mediaAssociationLookupWarned) {
|
|
44
|
+
mediaAssociationLookupWarned = true
|
|
45
|
+
console.warn(`[sessions] media association lookup unavailable; serving text-only history: ${String(error)}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return mergeMediaAttachmentRefs(...sources, associated)
|
|
50
|
+
}
|
|
14
51
|
|
|
15
52
|
sessionsRouter.get('/sessions/recent', (_req, res) => {
|
|
16
53
|
const sessions = getRecentSessions(24 * 60 * 60_000)
|
|
@@ -62,19 +99,22 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
|
62
99
|
attachments?: MediaAttachmentRef[]
|
|
63
100
|
}> = []
|
|
64
101
|
const session = getSessionRaw(req.params.id)
|
|
102
|
+
const activeEra = currentMessageEraState()
|
|
65
103
|
for (let i = 0; i < exchanges.length; i++) {
|
|
66
104
|
const ex = exchanges[i]
|
|
67
105
|
if (ex.role === 'user') {
|
|
68
106
|
const next = exchanges[i + 1]
|
|
69
107
|
if (next && next.role === 'assistant') {
|
|
70
|
-
const
|
|
108
|
+
const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
|
|
109
|
+
const messageEra = ex.messageEra ?? next.messageEra
|
|
110
|
+
const attachments = turnAttachments(req.params.id, globalMsgNum, messageEra, activeEra, ex.attachments, next.attachments)
|
|
71
111
|
const modelPreference = resolveExchangePairModel(ex, next, session?.modelPreference)
|
|
72
112
|
messages.push({
|
|
73
113
|
query: ex.content,
|
|
74
114
|
text: next.content,
|
|
75
115
|
timestamp: next.timestamp,
|
|
76
116
|
sessionId: req.params.id,
|
|
77
|
-
...(
|
|
117
|
+
...(globalMsgNum != null ? { no: globalMsgNum } : {}),
|
|
78
118
|
...(modelPreference ? { modelPreference } : {}),
|
|
79
119
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
80
120
|
})
|
|
@@ -246,14 +286,23 @@ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
|
|
|
246
286
|
// back to the archived chat's sessionId via getArchiveDayMessages.
|
|
247
287
|
sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
248
288
|
const todayDate = localDay()
|
|
249
|
-
const
|
|
289
|
+
const activeEra = currentMessageEraState()
|
|
290
|
+
const era = activeEra.era
|
|
250
291
|
|
|
251
292
|
const archivedMessages = getArchiveDayMessages(todayDate)
|
|
252
293
|
.filter(m => exchangeBelongsToEra(m, era))
|
|
253
|
-
.map(m =>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
294
|
+
.map(m => {
|
|
295
|
+
const globalMsgNum = m.globalMsgNum ?? m.no
|
|
296
|
+
const attachments = turnAttachments(m.sessionId, globalMsgNum, m.messageEra, activeEra, m.attachments)
|
|
297
|
+
// Never pass an unvalidated archive attachment array through the object
|
|
298
|
+
// spread when the merge rejects it.
|
|
299
|
+
const { attachments: _rawAttachments, ...message } = m
|
|
300
|
+
return {
|
|
301
|
+
...message,
|
|
302
|
+
source: 'archive' as const,
|
|
303
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
304
|
+
}
|
|
305
|
+
})
|
|
257
306
|
|
|
258
307
|
const liveMessages: Array<{
|
|
259
308
|
query: string
|
|
@@ -278,9 +327,9 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
278
327
|
if (!exchangeBelongsToEra(ex, era)) continue
|
|
279
328
|
const next = session.exchanges[i + 1]
|
|
280
329
|
if (next && next.role === 'assistant') {
|
|
281
|
-
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
282
330
|
const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
|
|
283
331
|
const messageEra = ex.messageEra ?? next.messageEra
|
|
332
|
+
const attachments = turnAttachments(session.id, globalMsgNum, messageEra, activeEra, ex.attachments, next.attachments)
|
|
284
333
|
const modelPreference = resolveExchangePairModel(ex, next, session.modelPreference)
|
|
285
334
|
liveMessages.push({
|
|
286
335
|
query: ex.content,
|