@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.
@@ -0,0 +1,255 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { resolve } from 'node:path'
3
+ import { acquireModelSessionRunLock, callModelStreaming } from './model-router.js'
4
+ import { emitDisplay } from './display-bus.js'
5
+ import { resolveQueryAttachments } from './query-attachments.js'
6
+ import { getMediaStore } from './media-store.js'
7
+ import { dataPath } from './data-dir.js'
8
+ import { durableQueryJobsEnabled } from './query-job-feature.js'
9
+ import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
10
+ import { QueryJobStore } from './query-job-store.js'
11
+ import {
12
+ isCodexModel,
13
+ normalizeEffortPreference,
14
+ normalizeModelPreference,
15
+ type ModelPreference,
16
+ } from '../../shared/model-preference.js'
17
+ import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
18
+ import {
19
+ findExchangesByJobIdentity,
20
+ flushConversationToDisk,
21
+ reconcileExchangeByJobIdentity,
22
+ removeExchangesByJobIdentity,
23
+ } from './conversation.js'
24
+ import {
25
+ isTerminalQueryJobStatus,
26
+ type QueryJobRequest,
27
+ type QueryJobSnapshot,
28
+ } from './query-job-types.js'
29
+
30
+ const TOOL_STATUS_MESSAGES: Record<string, string> = {
31
+ WebSearch: 'Searching web...',
32
+ WebFetch: 'Reading page...',
33
+ Read: 'Analyzing photo...',
34
+ }
35
+
36
+ /** Resolve image bytes/ids before returning 202. The journal receives only
37
+ * validated refs and ids; base64 bytes and provider-local paths are dropped by
38
+ * the strict QueryJobRequest parser before persistence. */
39
+ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<unknown> {
40
+ if (!raw || typeof raw !== 'object') return raw
41
+ const input = raw as Record<string, unknown>
42
+ const resolved = await resolveQueryAttachments(input)
43
+ return {
44
+ ...input,
45
+ attachmentIds: resolved.ids,
46
+ attachmentRefs: resolved.refs,
47
+ }
48
+ }
49
+
50
+ function providerFor(model: ModelPreference): 'claude' | 'codex' {
51
+ return isCodexModel(model) ? 'codex' : 'claude'
52
+ }
53
+
54
+ /** Project the authoritative terminal journal into the derived conversation
55
+ * cache. Journaled request/response text always wins over bridge-written
56
+ * partial rows; validated media refs may be merged because output media can
57
+ * finish immediately before a crash. Exact provenance collapses duplicates. */
58
+ async function projectPublicConversationTerminal(
59
+ job: QueryJobSnapshot,
60
+ request: QueryJobRequest,
61
+ ): Promise<void> {
62
+ if (!isTerminalQueryJobStatus(job.status)) return
63
+ const identity = { clientJobId: request.clientJobId, generation: request.generation }
64
+ if (job.status !== 'completed') {
65
+ removeExchangesByJobIdentity(request.sessionId, identity)
66
+ flushConversationToDisk()
67
+ return
68
+ }
69
+
70
+ const existing = findExchangesByJobIdentity(request.sessionId, identity)
71
+ const existingAssistant = existing.find(exchange => exchange.role === 'assistant')
72
+ const imageCount = request.attachmentRefs.length
73
+ const photoPrefix = imageCount === 1 ? '[Photo]' : imageCount > 1 ? `[${imageCount} Photos]` : ''
74
+ const userContent = photoPrefix ? `${photoPrefix} ${request.query || 'What do you see?'}` : request.query
75
+ const requestIds = new Set(request.attachmentRefs.map(ref => ref.id))
76
+ const outputAttachments = job.attachments.filter(ref => !requestIds.has(ref.id))
77
+ const existingOutputAttachments = existingAssistant?.attachments?.filter(ref => !requestIds.has(ref.id))
78
+
79
+ reconcileExchangeByJobIdentity(
80
+ request.sessionId,
81
+ identity,
82
+ 'user',
83
+ userContent,
84
+ request.globalMsgNum,
85
+ request.attachmentRefs,
86
+ )
87
+ reconcileExchangeByJobIdentity(
88
+ request.sessionId,
89
+ identity,
90
+ 'assistant',
91
+ job.response ?? job.partialText,
92
+ request.globalMsgNum,
93
+ mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
94
+ )
95
+ flushConversationToDisk()
96
+ }
97
+
98
+ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callbacks }) => {
99
+ // Resolve ids again at execution time. This closes the admission/execution
100
+ // TOCTOU window without ever putting paths or bytes in the journal.
101
+ const resolvedAttachments = await resolveQueryAttachments({
102
+ attachmentIds: request.attachmentIds,
103
+ clientQueueItemId: request.clientQueueItemId,
104
+ sessionId: request.sessionId,
105
+ })
106
+ const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
107
+ const validModel = normalizeModelPreference(request.model)
108
+ const validEffort = normalizeEffortPreference(request.effort)
109
+ let activeModel = validModel
110
+
111
+ await callModelStreaming(
112
+ request.query,
113
+ request.sessionId,
114
+ {
115
+ onStart: async (model, sessionId, cliSessionId, metadata) => {
116
+ activeModel = model
117
+ const linkage = {
118
+ provider: providerFor(model),
119
+ resolvedModel: model,
120
+ cliSessionId,
121
+ claudeRunId: metadata?.claudeRunId,
122
+ codexRunId: metadata?.codexRunId,
123
+ codexThreadId: metadata?.codexThreadId,
124
+ } as const
125
+ await callbacks.onStart({ sessionId, ...linkage })
126
+ emitDisplay({ type: 'start', data: {
127
+ jobId,
128
+ clientJobId: request.clientJobId,
129
+ generation: request.generation,
130
+ turnId,
131
+ messageEra: request.messageEra,
132
+ globalMsgNum: request.globalMsgNum,
133
+ model,
134
+ sessionId,
135
+ cliSessionId,
136
+ ...metadata,
137
+ } })
138
+ },
139
+ onProviderProcess: metadata => callbacks.onProviderProcess({
140
+ provider: metadata.provider,
141
+ ...(activeModel ? { resolvedModel: activeModel } : {}),
142
+ ...(metadata.provider === 'claude'
143
+ ? { claudeRunId: metadata.runId }
144
+ : { codexRunId: metadata.runId }),
145
+ }),
146
+ onChunk: text => { callbacks.onChunk(text) },
147
+ onToolStatus: toolName => {
148
+ const message = request.activityToolMode === 'off'
149
+ ? 'Processing...'
150
+ : TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
151
+ callbacks.onToolStatus(message)
152
+ },
153
+ ...(request.activityToolMode === 'preview' ? {
154
+ onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => callbacks.onActivityLine(line),
155
+ } : {}),
156
+ onAnswerReady: text => callbacks.onAnswerReady(text, {
157
+ ...(activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
158
+ }),
159
+ onDone: async (fullText, model, cliSessionId, metadata) => {
160
+ const attachments = mergeMediaAttachmentRefs(
161
+ resolvedAttachments.refs,
162
+ metadata?.outputAttachments,
163
+ )
164
+ const linkage = {
165
+ provider: providerFor(model),
166
+ resolvedModel: model,
167
+ cliSessionId,
168
+ claudeRunId: metadata?.claudeRunId,
169
+ codexRunId: metadata?.codexRunId,
170
+ codexThreadId: metadata?.codexThreadId,
171
+ } as const
172
+ // Publish compatibility completion only after the durable terminal is
173
+ // fsynced. Display subscribers can disappear without owning this job.
174
+ const terminalOwned = await callbacks.onDone({
175
+ text: fullText,
176
+ attachments,
177
+ outputImageStats: metadata?.outputImageStats,
178
+ ...linkage,
179
+ })
180
+ if (!terminalOwned) return
181
+ if (resolvedAttachments.ids.length > 0) {
182
+ await getMediaStore().associate(resolvedAttachments.ids, {
183
+ sessionId: request.sessionId,
184
+ ...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
185
+ }).catch(error => console.error('[query-jobs] attachment association failed:', error))
186
+ }
187
+ const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
188
+ emitDisplay({ type: 'done', data: {
189
+ jobId,
190
+ clientJobId: request.clientJobId,
191
+ generation: request.generation,
192
+ turnId,
193
+ messageEra: request.messageEra,
194
+ globalMsgNum: request.globalMsgNum,
195
+ text: fullText,
196
+ sessionId: request.sessionId,
197
+ model,
198
+ cliSessionId,
199
+ ...runMetadata,
200
+ ...(attachments.length > 0 ? { attachments } : {}),
201
+ } })
202
+ },
203
+ onError: async error => {
204
+ const terminalOwned = await callbacks.onError(error)
205
+ if (!terminalOwned) return
206
+ emitDisplay({ type: 'error', data: {
207
+ jobId,
208
+ clientJobId: request.clientJobId,
209
+ generation: request.generation,
210
+ turnId,
211
+ messageEra: request.messageEra,
212
+ globalMsgNum: request.globalMsgNum,
213
+ error,
214
+ } })
215
+ },
216
+ },
217
+ validModel,
218
+ imageInputs,
219
+ request.reference,
220
+ request.globalMsgNum,
221
+ {
222
+ abortSignal: signal,
223
+ effort: validEffort,
224
+ clientJobId: request.clientJobId,
225
+ generation: request.generation,
226
+ sessionLockHeld: true,
227
+ },
228
+ )
229
+ }
230
+
231
+ const configuredRoot = process.env.COS_QUERY_JOB_DIR?.trim()
232
+ const queryJobRoot = configuredRoot ? resolve(configuredRoot) : dataPath('query-jobs')
233
+
234
+ export const queryJobStore = new QueryJobStore({
235
+ root: queryJobRoot,
236
+ bootId: randomUUID(),
237
+ })
238
+
239
+ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner, {
240
+ projectTerminal: projectPublicConversationTerminal,
241
+ acquireSessionLock: acquireModelSessionRunLock,
242
+ })
243
+
244
+ export function initQueryJobRuntime() {
245
+ if (!durableQueryJobsEnabled()) return Promise.resolve(queryJobCoordinator.getHealth())
246
+ return queryJobCoordinator.init()
247
+ }
248
+
249
+ export function shutdownQueryJobRuntime(reason = 'server_shutdown') {
250
+ return queryJobCoordinator.shutdown(reason)
251
+ }
252
+
253
+ export function getQueryJobRuntimeHealth() {
254
+ return queryJobCoordinator.getHealth()
255
+ }