@gotcos/glasses-server 6.9.0 → 6.10.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.
@@ -10,7 +10,7 @@ import { notifySessionStart, notifySessionEnd } from './telegram-notify.js'
10
10
  import { appendToArchive, type SessionToArchive } from './archive.js'
11
11
  import { updateGlassesSessionCache, scheduleCacheUpdate, setSessionProvider } from './session-cache-writer.js'
12
12
  import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-log.js'
13
- import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
+ import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
14
14
  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'
@@ -22,12 +22,32 @@ export interface Exchange {
22
22
  content: string
23
23
  timestamp: number
24
24
  globalMsgNum?: number // Client's global message number for this Q&A pair
25
+ /** Durable query provenance. Legacy exchanges omit both fields. */
26
+ clientJobId?: string
27
+ generation?: number
25
28
  /** Public attachment refs may live on either half of a Q&A pair: request
26
29
  * photos on the user exchange, model-published images on the assistant
27
30
  * exchange. Bytes, filesystem paths, and capabilities never persist here. */
28
31
  attachments?: MediaAttachmentRef[]
29
32
  }
30
33
 
34
+ export interface ExchangeJobProvenance {
35
+ clientJobId?: string
36
+ generation?: number
37
+ }
38
+
39
+ /** A durable job generation is the exact reconciliation/removal identity.
40
+ * Requiring both fields prevents a stale generation from mutating its retry. */
41
+ export interface ExchangeJobIdentity {
42
+ clientJobId: string
43
+ generation: number
44
+ }
45
+
46
+ export interface ReconciledExchange {
47
+ exchange: Exchange
48
+ created: boolean
49
+ }
50
+
31
51
  interface Session {
32
52
  id: string
33
53
  exchanges: Exchange[]
@@ -58,6 +78,46 @@ interface SessionsFile {
58
78
  savedAt: string
59
79
  }
60
80
 
81
+ const MAX_CLIENT_JOB_ID_LENGTH = 128
82
+ const SAFE_CLIENT_JOB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
83
+
84
+ function validClientJobId(value: unknown): value is string {
85
+ return typeof value === 'string'
86
+ && value.length > 0
87
+ && value.length <= MAX_CLIENT_JOB_ID_LENGTH
88
+ && SAFE_CLIENT_JOB_ID_RE.test(value)
89
+ }
90
+
91
+ function validGeneration(value: unknown): value is number {
92
+ return typeof value === 'number'
93
+ && Number.isSafeInteger(value)
94
+ && value > 0
95
+ }
96
+
97
+ function normalizedExchangeProvenance(
98
+ provenance: ExchangeJobProvenance | undefined,
99
+ ): ExchangeJobProvenance {
100
+ if (!provenance || !validClientJobId(provenance.clientJobId)) return {}
101
+ return {
102
+ clientJobId: provenance.clientJobId,
103
+ ...(validGeneration(provenance.generation) ? { generation: provenance.generation } : {}),
104
+ }
105
+ }
106
+
107
+ function validateLoadedExchangeProvenance(exchange: Exchange): void {
108
+ if (!validClientJobId(exchange.clientJobId)) {
109
+ delete exchange.clientJobId
110
+ delete exchange.generation
111
+ return
112
+ }
113
+ if (!validGeneration(exchange.generation)) delete exchange.generation
114
+ }
115
+
116
+ function matchesJobIdentity(exchange: Exchange, identity: ExchangeJobIdentity): boolean {
117
+ return exchange.clientJobId === identity.clientJobId
118
+ && exchange.generation === identity.generation
119
+ }
120
+
61
121
  function loadFromDisk(): void {
62
122
  const result = loadJsonOrQuarantine<SessionsFile>(SESSION_FILE)
63
123
  if (result.status === 'missing') return // fresh start
@@ -81,6 +141,7 @@ function loadFromDisk(): void {
81
141
  // Persistence boundary: malformed refs are dropped without sacrificing
82
142
  // the surrounding exchange or the rest of the recovered session.
83
143
  for (const exchange of session.exchanges) {
144
+ validateLoadedExchangeProvenance(exchange)
84
145
  if ('attachments' in exchange && exchange.attachments !== undefined) {
85
146
  const refs = parseMediaAttachmentRefs(exchange.attachments)
86
147
  if (refs.length > 0) exchange.attachments = refs
@@ -121,6 +182,29 @@ function saveToDisk(): void {
121
182
  }
122
183
  }
123
184
 
185
+ /** Force the current in-memory conversation projection across an fsync-backed
186
+ * durability boundary. Durable query jobs call this only at terminal/recovery
187
+ * boundaries; the existing 500ms coalesced writer remains the normal hot path.
188
+ * Unlike saveToDisk(), failures are surfaced to the caller so it can suppress
189
+ * a compatibility notification and leave boot reconciliation to repair it. */
190
+ export function flushConversationToDisk(): void {
191
+ if (saveTimer) {
192
+ clearTimeout(saveTimer)
193
+ saveTimer = null
194
+ }
195
+ try {
196
+ mkdirSync(dirname(SESSION_FILE), { recursive: true })
197
+ const data: SessionsFile = {
198
+ sessions: Object.fromEntries(sessions),
199
+ savedAt: new Date().toISOString(),
200
+ }
201
+ durableAtomicWriteFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 })
202
+ } catch (err) {
203
+ console.error('[conversation] Failed durable terminal flush:', err)
204
+ throw err
205
+ }
206
+ }
207
+
124
208
  // Restore on module load
125
209
  loadFromDisk()
126
210
 
@@ -316,6 +400,7 @@ export function addExchange(
316
400
  content: string,
317
401
  globalMsgNum?: number,
318
402
  attachments?: MediaAttachmentRef[],
403
+ provenance?: ExchangeJobProvenance,
319
404
  ): Exchange {
320
405
  let session = sessions.get(sessionId)
321
406
  if (!session) {
@@ -328,6 +413,7 @@ export function addExchange(
328
413
  content,
329
414
  timestamp: Date.now(),
330
415
  globalMsgNum,
416
+ ...normalizedExchangeProvenance(provenance),
331
417
  ...(attachments && attachments.length > 0 ? { attachments } : {}),
332
418
  }
333
419
  session.exchanges.push(exchange)
@@ -377,6 +463,113 @@ export function removeExchange(sessionId: string, exchange: Exchange): boolean {
377
463
  return true
378
464
  }
379
465
 
466
+ /** Return the exchanges owned by one exact durable job generation. The
467
+ * returned objects are the live exchange objects, matching getHistory's
468
+ * existing shallow-copy behavior. */
469
+ export function findExchangesByJobIdentity(
470
+ sessionId: string,
471
+ identity: ExchangeJobIdentity,
472
+ ): Exchange[] {
473
+ const session = sessions.get(sessionId)
474
+ if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return []
475
+ return session.exchanges.filter(exchange => matchesJobIdentity(exchange, identity))
476
+ }
477
+
478
+ /** Remove only exchanges owned by one exact job generation. A role filter is
479
+ * used by failed runs to roll back their pending user turn without touching a
480
+ * completed assistant exchange or a newer retry generation. */
481
+ export function removeExchangesByJobIdentity(
482
+ sessionId: string,
483
+ identity: ExchangeJobIdentity,
484
+ role?: Exchange['role'],
485
+ ): number {
486
+ const session = sessions.get(sessionId)
487
+ if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return 0
488
+
489
+ const before = session.exchanges.length
490
+ session.exchanges = session.exchanges.filter(exchange => (
491
+ !matchesJobIdentity(exchange, identity) || (role !== undefined && exchange.role !== role)
492
+ ))
493
+ const removed = before - session.exchanges.length
494
+ if (removed === 0) return 0
495
+
496
+ session.lastActivity = Date.now()
497
+ scheduleSave()
498
+ scheduleCacheUpdate()
499
+ return removed
500
+ }
501
+
502
+ /** Idempotently create or update one side of a durable job exchange pair.
503
+ * Duplicate same-role rows for the exact identity are collapsed in the same
504
+ * synchronous mutation before the existing coalesced save/cache path runs.
505
+ * Other jobs, generations, and the opposite role are never changed. */
506
+ export function reconcileExchangeByJobIdentity(
507
+ sessionId: string,
508
+ identity: ExchangeJobIdentity,
509
+ role: Exchange['role'],
510
+ content: string,
511
+ globalMsgNum?: number,
512
+ attachments?: MediaAttachmentRef[],
513
+ ): ReconciledExchange {
514
+ if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
515
+ throw new Error('conversation: invalid durable job identity')
516
+ }
517
+
518
+ let session = sessions.get(sessionId)
519
+ if (!session) {
520
+ const now = Date.now()
521
+ session = {
522
+ id: sessionId,
523
+ exchanges: [],
524
+ lastActivity: now,
525
+ createdAt: now,
526
+ modelPreference: null,
527
+ contextBreaks: [],
528
+ }
529
+ sessions.set(sessionId, session)
530
+ }
531
+
532
+ const matchingIndexes: number[] = []
533
+ for (let i = 0; i < session.exchanges.length; i++) {
534
+ const exchange = session.exchanges[i]
535
+ if (exchange.role === role && matchesJobIdentity(exchange, identity)) matchingIndexes.push(i)
536
+ }
537
+
538
+ const validatedAttachments = parseMediaAttachmentRefs(attachments)
539
+ let exchange: Exchange
540
+ const created = matchingIndexes.length === 0
541
+ if (created) {
542
+ exchange = {
543
+ role,
544
+ content,
545
+ timestamp: Date.now(),
546
+ globalMsgNum,
547
+ clientJobId: identity.clientJobId,
548
+ generation: identity.generation,
549
+ ...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
550
+ }
551
+ session.exchanges.push(exchange)
552
+ } else {
553
+ exchange = session.exchanges[matchingIndexes[0]]
554
+ exchange.content = content
555
+ exchange.globalMsgNum = globalMsgNum
556
+ if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
557
+ else delete exchange.attachments
558
+
559
+ // Remove from the end so earlier indexes stay stable; retain the oldest
560
+ // row's timestamp and position to preserve conversation ordering.
561
+ for (let i = matchingIndexes.length - 1; i >= 1; i--) {
562
+ session.exchanges.splice(matchingIndexes[i], 1)
563
+ }
564
+ }
565
+
566
+ while (session.exchanges.length > MAX_EXCHANGES) session.exchanges.shift()
567
+ session.lastActivity = Date.now()
568
+ scheduleSave()
569
+ scheduleCacheUpdate()
570
+ return { exchange, created }
571
+ }
572
+
380
573
  /** Clear a session — archive + log BEFORE deleting from the live Map.
381
574
  * Async so callers can `await` archive completion before considering the
382
575
  * session durable. If archive fails the session stays in the Map (retryable
@@ -16,7 +16,7 @@ import type { ModelImageInput } from './model-image-input.js'
16
16
  // mutating the same conversation/CLI session concurrently.
17
17
  const sessionRunTails = new Map<string, Promise<void>>()
18
18
 
19
- async function acquireSessionRunLock(sessionId: string): Promise<() => void> {
19
+ export async function acquireModelSessionRunLock(sessionId: string): Promise<() => void> {
20
20
  const previous = sessionRunTails.get(sessionId) ?? Promise.resolve()
21
21
  let openGate!: () => void
22
22
  const gate = new Promise<void>(resolve => { openGate = resolve })
@@ -47,7 +47,7 @@ export async function callModelStreaming(
47
47
  options?: CallOptions,
48
48
  ): Promise<string> {
49
49
  const sid = getOrCreateSession(sessionId)
50
- const release = await acquireSessionRunLock(sid)
50
+ const release = options?.sessionLockHeld ? (() => {}) : await acquireModelSessionRunLock(sid)
51
51
  if (options?.abortSignal?.aborted) {
52
52
  release()
53
53
  throw new Error('model-router: request aborted before the model run started.')
@@ -70,16 +70,16 @@ export async function callModelStreaming(
70
70
  }
71
71
  const lockedCallbacks: StreamCallbacks = {
72
72
  ...callbacks,
73
- onDone: (fullText, completedModel, cliSessionId, metadata) => {
73
+ onDone: async (fullText, completedModel, cliSessionId, metadata) => {
74
74
  try {
75
- callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
75
+ return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
76
76
  } finally {
77
77
  releaseTerminal()
78
78
  }
79
79
  },
80
- onError: (error) => {
80
+ onError: async (error) => {
81
81
  try {
82
- callbacks.onError(error)
82
+ await callbacks.onError(error)
83
83
  } finally {
84
84
  releaseTerminal()
85
85
  }