@gotcos/glasses-server 6.2.1 → 6.5.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.
@@ -1,15 +1,15 @@
1
- // Codex bridge — streaming-compatible interface to `codex exec --json`
2
- // MVP target: local subscription-authenticated GPT-5.5 High via Codex CLI.
1
+ // Codex bridge — streaming-compatible interface to `codex exec --json`.
2
+ // Concrete GPT ids resolve from Codex's live model catalog at run time.
3
3
 
4
- import { spawn } from 'node:child_process'
5
- import { writeFileSync, unlinkSync } from 'node:fs'
6
- import { join } from 'node:path'
7
- import crypto from 'node:crypto'
4
+ import { spawn, spawnSync } from 'node:child_process'
8
5
  import { logTokenAudit } from './token-audit.js'
6
+ import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
9
7
  import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
10
8
  import {
11
9
  getHistory,
12
10
  addExchange,
11
+ setExchangeAttachments,
12
+ removeExchange,
13
13
  formatHistoryForPrompt,
14
14
  getOrCreateSession,
15
15
  isNewSession,
@@ -27,10 +27,16 @@ import {
27
27
  } from './codex-engine-sessions.js'
28
28
  import {
29
29
  CODEX_HIGH_MODEL,
30
- CODEX_HIGH_REASONING_EFFORT,
31
- CODEX_MODEL_ID,
32
30
  type CodexModelPreference,
31
+ type EffortPreference,
33
32
  } from '../../shared/model-preference.js'
33
+ import {
34
+ getCodexModelCatalog,
35
+ resolveCodexEffortForModel,
36
+ resolveCodexModelOption,
37
+ resolveCodexServiceTier,
38
+ type CodexModelOption,
39
+ } from './codex-model-catalog.js'
34
40
  import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
35
41
  import {
36
42
  classifyCodexError,
@@ -43,6 +49,16 @@ import {
43
49
  updateCodexRun,
44
50
  type CodexRunStatus,
45
51
  } from './codex-run-ledger.js'
52
+ import { codexActivityPreviewLines } from './activity-preview.js'
53
+ import {
54
+ createRunOutputImagePublisher,
55
+ isRunOutputImagePublisherCommand,
56
+ type RunOutputImageCollectionStats,
57
+ } from './run-output-images.js'
58
+ import {
59
+ MAX_ATTACHMENTS_PER_PROMPT,
60
+ type MediaAttachmentRef,
61
+ } from '../../shared/media-attachment.js'
46
62
 
47
63
  const INACTIVITY_MS = 180_000
48
64
  const WALL_MAX_MS = 900_000
@@ -66,23 +82,61 @@ function codexSandboxArgs(): string[] {
66
82
  return ['--sandbox', mode, '--skip-git-repo-check']
67
83
  }
68
84
 
85
+ let addDirSupported: boolean | undefined
86
+
87
+ /** Older Codex CLIs do not expose --add-dir. Probe lazily and disable only
88
+ * output publishing when unavailable; chat remains read-only and functional. */
89
+ export function codexSupportsAdditionalDir(): boolean {
90
+ if (addDirSupported !== undefined) return addDirSupported
91
+ try {
92
+ const result = spawnSync('codex', ['exec', '--help'], {
93
+ encoding: 'utf8',
94
+ timeout: 5_000,
95
+ stdio: ['ignore', 'pipe', 'pipe'],
96
+ })
97
+ addDirSupported = result.status === 0 && `${result.stdout}\n${result.stderr}`.includes('--add-dir')
98
+ } catch {
99
+ addDirSupported = false
100
+ }
101
+ return addDirSupported
102
+ }
103
+
69
104
  export function buildCodexExecArgs(input: {
70
105
  codexCwd: string
71
106
  imagePaths?: string[]
72
107
  persistentCodexSession: boolean
73
108
  codexThreadId?: string
109
+ model?: CodexModelPreference
110
+ resolvedModel?: CodexModelOption
111
+ effort?: EffortPreference
112
+ publisherWritableDirectory?: string
74
113
  }): string[] {
75
114
  const imagePaths = input.imagePaths ?? []
76
- const args = ['exec']
115
+ const resolvedModel = input.resolvedModel
116
+ ?? resolveCodexModelOption(input.model ?? CODEX_HIGH_MODEL)
117
+ const reasoningEffort = resolveCodexEffortForModel(resolvedModel, input.effort)
118
+ const serviceTier = resolveCodexServiceTier(resolvedModel)
119
+ // Sandbox + publisher capability are global `codex exec` options and MUST
120
+ // appear before the `resume` subcommand. The publisher grants write access
121
+ // only to its random run directory; the rest of the host stays read-only.
122
+ const args = ['exec', ...codexSandboxArgs()]
123
+ if (input.publisherWritableDirectory) {
124
+ args.push('--add-dir', input.publisherWritableDirectory)
125
+ }
126
+
127
+ const appendModelConfig = () => {
128
+ if (resolvedModel.id) args.push('--model', resolvedModel.id)
129
+ args.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
130
+ if (serviceTier) args.push('-c', `service_tier="${serviceTier}"`)
131
+ }
132
+
77
133
  if (input.codexThreadId) {
78
134
  args.push(
79
135
  'resume',
80
136
  '--json',
81
137
  '--all',
82
- ...codexSandboxArgs(),
83
- '-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
84
138
  )
85
- if (CODEX_MODEL_ID) args.push('--model', CODEX_MODEL_ID)
139
+ appendModelConfig()
86
140
  for (const p of imagePaths) args.push('--image', p)
87
141
  args.push(input.codexThreadId, '-')
88
142
  return args
@@ -91,10 +145,8 @@ export function buildCodexExecArgs(input: {
91
145
  args.push(
92
146
  '--json',
93
147
  '--cd', input.codexCwd,
94
- ...codexSandboxArgs(),
95
- '-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
96
148
  )
97
- if (CODEX_MODEL_ID) args.push('--model', CODEX_MODEL_ID)
149
+ appendModelConfig()
98
150
  if (!input.persistentCodexSession) args.push('--ephemeral')
99
151
  for (const p of imagePaths) args.push('--image', p)
100
152
  args.push('-')
@@ -111,26 +163,36 @@ function buildCodexPrompt(systemPrompt: string, fullQuery: string): string {
111
163
  ].join('\n')
112
164
  }
113
165
 
114
- function eventText(event: any): string {
166
+ /** Extract only observable assistant response text. Reasoning/tool payloads
167
+ * must remain invisible even if a future Codex JSON shape also has delta or
168
+ * content fields. */
169
+ export function extractCodexResponseText(event: any): string {
115
170
  const item = event?.item ?? event?.payload ?? event?.message ?? event
171
+ const eventType = String(event?.type ?? '').toLowerCase()
172
+ const itemType = String(item?.type ?? '').toLowerCase()
173
+ const assistantEvent = /(?:^|[._-])(agent_message|assistant_message|output_text)(?:$|[._-])/.test(eventType)
174
+ || /^(?:agent_message|assistant_message|output_text)$/.test(itemType)
175
+ if (!assistantEvent) return ''
116
176
 
117
177
  if (typeof event?.delta === 'string') return event.delta
118
- if (typeof event?.text === 'string' && /message|delta|answer/i.test(String(event.type ?? ''))) return event.text
119
- if (typeof item?.text === 'string' && /agent_message|message|assistant/i.test(String(item.type ?? event?.type ?? ''))) return item.text
178
+ if (typeof event?.text === 'string') return event.text
179
+ if (typeof item?.text === 'string') return item.text
120
180
 
121
181
  const content = item?.content ?? event?.content
122
182
  if (Array.isArray(content)) {
123
183
  let text = ''
124
184
  for (const block of content) {
125
185
  if (typeof block === 'string') text += block
126
- if (typeof block?.text === 'string') text += block.text
127
- if (typeof block?.content === 'string') text += block.content
186
+ const blockType = String(block?.type ?? '').toLowerCase()
187
+ if (/(?:reasoning|thinking|tool|command|input)/.test(blockType)) continue
128
188
  if (typeof block?.output_text === 'string') text += block.output_text
189
+ else if (typeof block?.text === 'string') text += block.text
190
+ else if (typeof block?.content === 'string') text += block.content
129
191
  }
130
192
  return text
131
193
  }
132
194
 
133
- if (typeof item?.result === 'string' && /result|completed|answer/i.test(String(event?.type ?? ''))) return item.result
195
+ if (typeof item?.result === 'string') return item.result
134
196
  return ''
135
197
  }
136
198
 
@@ -156,7 +218,7 @@ function safeCodexUserError(message: string): string {
156
218
  if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
157
219
  if (code === 'codex.auth_error') return 'Codex auth failed. Run codex login on the Mac.'
158
220
  if (code === 'codex.timeout') return 'Codex timed out. Retry or start a new chat.'
159
- if (code === 'codex.permission_denied') return 'Codex permission failed. Check full-access configuration.'
221
+ if (code === 'codex.permission_denied') return 'Codex permission failed. Check COS_CODEX_SANDBOX and the work directory.'
160
222
  return `Codex failed (${code}). Retry or check Codex Debug.`
161
223
  }
162
224
 
@@ -165,12 +227,18 @@ export async function callCodexStreaming(
165
227
  sessionId: string | undefined,
166
228
  callbacks: StreamCallbacks,
167
229
  model: CodexModelPreference = CODEX_HIGH_MODEL,
168
- images?: string[],
230
+ images?: ModelImageInput[],
169
231
  reference?: PromptReference,
170
232
  globalMsgNum?: number,
171
233
  options?: CallOptions,
172
234
  ): Promise<string> {
173
235
  const sid = getOrCreateSession(sessionId)
236
+ // Refresh is TTL-cached and coalesced, so every run sees the newest known
237
+ // catalog without creating duplicate app-server discovery processes.
238
+ await getCodexModelCatalog()
239
+ if (options?.abortSignal?.aborted) {
240
+ throw new Error('codex-bridge: client disconnected before Codex started.')
241
+ }
174
242
  const history = getHistory(sid)
175
243
  const session = getSessionRaw(sid)
176
244
  const contextBreaks = session?.contextBreaks ?? []
@@ -179,9 +247,14 @@ export async function callCodexStreaming(
179
247
  const persistentCodexSession = isCodexPersistenceEnabled()
180
248
  const codexCwd = getCodexExecutionCwd()
181
249
  const codexTrustMode = getCodexTrustMode()
250
+ const resolvedCodexModel = resolveCodexModelOption(model)
251
+ const resolvedCodexEffort = resolveCodexEffortForModel(resolvedCodexModel, options?.effort)
182
252
  const engineSession = persistentCodexSession
183
253
  ? getCodexEngineSession({ cosSessionId: sid, model, cwd: codexCwd, trustMode: codexTrustMode })
184
254
  : null
255
+ const imageInputs: ModelImageInput[] = images ?? []
256
+ const imagePaths = imageInputs.map(input => input.path)
257
+ const outputImageBudget = Math.max(0, MAX_ATTACHMENTS_PER_PROMPT - imageInputs.length)
185
258
  const startTime = Date.now()
186
259
  const run = startCodexRun({
187
260
  cosSessionId: sid,
@@ -192,8 +265,23 @@ export async function callCodexStreaming(
192
265
  trustMode: codexTrustMode,
193
266
  codexThreadId: engineSession?.codexThreadId,
194
267
  expiresAt: engineSession?.expiresAt,
268
+ cliModel: resolvedCodexModel.id || 'codex-cli-default',
269
+ reasoningEffort: resolvedCodexEffort,
195
270
  query,
196
271
  })
272
+ let outputImagePublisher: ReturnType<typeof createRunOutputImagePublisher> | null = null
273
+ if (!options?.lightweight && outputImageBudget > 0 && codexSupportsAdditionalDir()) {
274
+ try {
275
+ outputImagePublisher = createRunOutputImagePublisher({
276
+ sessionId: sid,
277
+ globalMsgNum,
278
+ runId: run.runId,
279
+ maxImages: outputImageBudget,
280
+ })
281
+ } catch (err) {
282
+ console.error('[codex-bridge] output image publisher unavailable:', err)
283
+ }
284
+ }
197
285
  let codexThreadId: string | undefined = engineSession?.codexThreadId
198
286
  callbacks.onStart?.(model, sid, undefined, { codexRunId: run.runId, codexThreadId })
199
287
 
@@ -207,6 +295,7 @@ export async function callCodexStreaming(
207
295
  systemPrompt = await buildSystemPrompt(contextPrompt)
208
296
  }
209
297
  } catch (err: any) {
298
+ outputImagePublisher?.cleanup()
210
299
  finishCodexRun(run.runId, {
211
300
  status: 'failed',
212
301
  startedAtMs: startTime,
@@ -215,37 +304,15 @@ export async function callCodexStreaming(
215
304
  })
216
305
  throw err
217
306
  }
307
+ if (outputImagePublisher) systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
218
308
 
219
309
  phase = 'thinking'
220
310
  callbacks.onToolStatus?.('Reasoning...')
221
311
 
222
- const imagePaths: string[] = []
223
- try {
224
- if (images && images.length > 0) {
225
- for (const img of images) {
226
- const id = crypto.randomUUID().slice(0, 8)
227
- const p = join('/tmp', `cos-vision-${id}.jpg`)
228
- writeFileSync(p, Buffer.from(img, 'base64'))
229
- imagePaths.push(p)
230
- }
231
- }
232
- } catch (err: any) {
233
- for (const p of imagePaths) {
234
- try { unlinkSync(p) } catch { /* ignore */ }
235
- }
236
- finishCodexRun(run.runId, {
237
- status: 'failed',
238
- startedAtMs: startTime,
239
- error: `codex-bridge: image staging failed — ${err?.message ?? 'unknown error'}`,
240
- exitCode: null,
241
- })
242
- throw err
243
- }
244
-
245
312
  const isFirstQuery = isNewSession(sid)
246
313
  const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
247
314
  const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
248
- addExchange(sid, 'user', historyQuery, globalMsgNum)
315
+ const pendingUserExchange = addExchange(sid, 'user', historyQuery, globalMsgNum)
249
316
 
250
317
  let fullQuery: string
251
318
  if (imagePaths.length === 1) {
@@ -262,10 +329,15 @@ export async function callCodexStreaming(
262
329
  imagePaths,
263
330
  persistentCodexSession,
264
331
  codexThreadId: engineSession?.codexThreadId,
332
+ model,
333
+ resolvedModel: resolvedCodexModel,
334
+ effort: options?.effort,
335
+ publisherWritableDirectory: outputImagePublisher?.writableDirectory,
265
336
  })
266
337
 
267
338
  const env = { ...process.env }
268
339
  delete env.CLAUDECODE
340
+ if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
269
341
 
270
342
  const proc = spawn('codex', args, {
271
343
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -281,9 +353,7 @@ export async function callCodexStreaming(
281
353
  const emittedBlocks = new Set<string>()
282
354
 
283
355
  function cleanupImages() {
284
- for (const p of imagePaths) {
285
- try { unlinkSync(p) } catch { /* ignore */ }
286
- }
356
+ cleanupModelImageInputs(imageInputs)
287
357
  }
288
358
 
289
359
  function cleanup() {
@@ -301,12 +371,42 @@ export async function callCodexStreaming(
301
371
  callbacks.onChunk(text)
302
372
  }
303
373
 
304
- function finalize(text: string) {
374
+ async function finalize(text: string) {
305
375
  if (finalized) return
306
376
  finalized = true
307
377
  cleanup()
308
378
  cleanupImages()
309
379
 
380
+ const assistantExchange = addExchange(sid, 'assistant', text, globalMsgNum)
381
+ if (imagePaths.length > 0) {
382
+ replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
383
+ }
384
+
385
+ let outputAttachments: MediaAttachmentRef[] = []
386
+ let outputImageStats: RunOutputImageCollectionStats | undefined
387
+ if (outputImagePublisher) {
388
+ callbacks.onToolStatus?.('Preparing images...')
389
+ const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
390
+ preparingHeartbeat.unref?.()
391
+ try {
392
+ outputAttachments = await outputImagePublisher.collect()
393
+ } catch (err) {
394
+ console.error('[codex-bridge] output image collection failed:', err)
395
+ } finally {
396
+ clearInterval(preparingHeartbeat)
397
+ outputImageStats = outputImagePublisher.stats
398
+ outputImagePublisher.cleanup()
399
+ }
400
+ if (outputAttachments.length > 0) {
401
+ setExchangeAttachments(sid, assistantExchange, outputAttachments)
402
+ }
403
+ if (outputImageStats && outputImageStats.rejected > 0) {
404
+ callbacks.onToolStatus?.(outputImageStats.attached > 0
405
+ ? 'Some images could not be attached'
406
+ : 'Image attachment unavailable')
407
+ }
408
+ }
409
+
310
410
  const totalMs = Date.now() - startTime
311
411
  logTokenAudit({
312
412
  source: options?.lightweight ? 'g2-voice' : 'g2-query',
@@ -333,12 +433,12 @@ export async function callCodexStreaming(
333
433
  output: text,
334
434
  exitCode: 0,
335
435
  })
336
- addExchange(sid, 'assistant', text, globalMsgNum)
337
- if (imagePaths.length > 0) {
338
- replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
339
- }
340
-
341
- callbacks.onDone(text, model, undefined, { codexRunId: run.runId, codexThreadId })
436
+ callbacks.onDone(text, model, undefined, {
437
+ codexRunId: run.runId,
438
+ codexThreadId,
439
+ ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
440
+ ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
441
+ })
342
442
 
343
443
  if (isFirstQuery) {
344
444
  notifySessionStart(sid, query)
@@ -352,6 +452,8 @@ export async function callCodexStreaming(
352
452
  finalized = true
353
453
  cleanup()
354
454
  cleanupImages()
455
+ outputImagePublisher?.cleanup()
456
+ removeExchange(sid, pendingUserExchange)
355
457
  if (engineSession) {
356
458
  clearCodexEngineSession(sid, model)
357
459
  }
@@ -394,20 +496,12 @@ export async function callCodexStreaming(
394
496
  const wallTimer = setTimeout(() => {
395
497
  proc.kill('SIGTERM')
396
498
  if (fullText) {
397
- finalize(fullText)
499
+ void finalize(fullText)
398
500
  } else {
399
501
  finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`)
400
502
  }
401
503
  }, WALL_MAX_MS)
402
504
 
403
- if (options?.abortSignal) {
404
- if (options.abortSignal.aborted) {
405
- handleAbort()
406
- } else {
407
- options.abortSignal.addEventListener('abort', handleAbort, { once: true })
408
- }
409
- }
410
-
411
505
  function handleEvent(event: any) {
412
506
  const nextThreadId = extractCodexThreadId(event)
413
507
  if (nextThreadId && nextThreadId !== codexThreadId) {
@@ -418,12 +512,17 @@ export async function callCodexStreaming(
418
512
  const status = toolStatus(event)
419
513
  if (status) callbacks.onToolStatus?.(status)
420
514
 
421
- const text = eventText(event)
515
+ const command = event?.item?.command ?? event?.item?.input ?? event?.payload?.command ?? event?.payload?.input
516
+ if (!isRunOutputImagePublisherCommand(command)) {
517
+ for (const preview of codexActivityPreviewLines(event)) callbacks.onActivityLine?.(preview)
518
+ }
519
+
520
+ const text = extractCodexResponseText(event)
422
521
  if (text) emitText(text)
423
522
 
424
523
  const type = String(event?.type ?? '')
425
524
  if (type === 'turn.completed') {
426
- finalize(fullText)
525
+ void finalize(fullText)
427
526
  } else if (type === 'turn.failed' || type === 'error') {
428
527
  finalizeError(`codex-bridge: ${event?.error ?? event?.message ?? 'unknown error'}`)
429
528
  }
@@ -459,7 +558,7 @@ export async function callCodexStreaming(
459
558
  if (code !== 0) {
460
559
  finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
461
560
  } else if (fullText) {
462
- finalize(fullText)
561
+ void finalize(fullText)
463
562
  } else {
464
563
  finalizeError('codex-bridge: Codex completed without a response.')
465
564
  }
@@ -468,9 +567,25 @@ export async function callCodexStreaming(
468
567
  proc.on('error', (err) => {
469
568
  finalizeError(`codex-bridge: ${err.message}`)
470
569
  })
570
+ proc.stdin.on('error', (err) => {
571
+ finalizeError(`codex-bridge: stdin failed — ${err.message}`)
572
+ })
471
573
 
472
- proc.stdin.write(prompt)
473
- proc.stdin.end()
574
+ if (options?.abortSignal) {
575
+ if (options.abortSignal.aborted) {
576
+ handleAbort()
577
+ return sid
578
+ }
579
+ options.abortSignal.addEventListener('abort', handleAbort, { once: true })
580
+ }
581
+
582
+ try {
583
+ proc.stdin.write(prompt)
584
+ proc.stdin.end()
585
+ } catch (err) {
586
+ const message = err instanceof Error ? err.message : String(err)
587
+ finalizeError(`codex-bridge: stdin failed — ${message}`)
588
+ }
474
589
 
475
590
  return sid
476
591
  }
@@ -1,6 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, resolve } from 'node:path'
3
- import type { CodexModelPreference } from '../../shared/model-preference.js'
3
+ import {
4
+ isCodexModel,
5
+ normalizeModelPreference,
6
+ type CodexModelPreference,
7
+ } from '../../shared/model-preference.js'
4
8
 
5
9
  export const CODEX_ENGINE_SESSION_TTL_MS = 2 * 60 * 60_000
6
10
 
@@ -40,8 +44,26 @@ function readStore(): CodexEngineSessionFile {
40
44
  if (!existsSync(path)) return { sessions: {}, savedAt: new Date().toISOString() }
41
45
  try {
42
46
  const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<CodexEngineSessionFile>
47
+ const sessions: Record<string, CodexEngineSession> = {}
48
+ for (const raw of Object.values(parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {})) {
49
+ const model = normalizeModelPreference(raw?.model)
50
+ if (
51
+ !model ||
52
+ !isCodexModel(model) ||
53
+ typeof raw?.cosSessionId !== 'string' ||
54
+ typeof raw?.codexThreadId !== 'string' ||
55
+ typeof raw?.cwd !== 'string' ||
56
+ (raw?.trustMode !== 'read-only' && raw?.trustMode !== 'workspace-write') ||
57
+ typeof raw?.lastUsedAt !== 'number' ||
58
+ typeof raw?.expiresAt !== 'string'
59
+ ) continue
60
+ const key = sessionKey(raw.cosSessionId, model)
61
+ const normalized = { ...raw, key, model } as CodexEngineSession
62
+ const prior = sessions[key]
63
+ if (!prior || normalized.lastUsedAt > prior.lastUsedAt) sessions[key] = normalized
64
+ }
43
65
  return {
44
- sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
66
+ sessions,
45
67
  savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : new Date().toISOString(),
46
68
  }
47
69
  } catch {