@gotcos/glasses-server 6.1.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.
Files changed (51) hide show
  1. package/.cos-profile.example.json +7 -0
  2. package/.env.example +44 -0
  3. package/CHANGELOG.md +25 -0
  4. package/LICENSE +21 -0
  5. package/README.md +78 -0
  6. package/bin/cli.cjs +203 -0
  7. package/package.json +53 -0
  8. package/server/env.ts +26 -0
  9. package/server/index.ts +211 -0
  10. package/server/lib/archive-budget.ts +65 -0
  11. package/server/lib/archive.ts +414 -0
  12. package/server/lib/atomic-fs.ts +50 -0
  13. package/server/lib/audio-enhance.ts +87 -0
  14. package/server/lib/claude-bridge.ts +682 -0
  15. package/server/lib/claude-circuit.ts +52 -0
  16. package/server/lib/claude-run-ledger.ts +279 -0
  17. package/server/lib/codex-bridge.ts +476 -0
  18. package/server/lib/codex-engine-sessions.ts +140 -0
  19. package/server/lib/codex-run-ledger.ts +298 -0
  20. package/server/lib/context-builder.ts +210 -0
  21. package/server/lib/conversation.ts +587 -0
  22. package/server/lib/data-dir.ts +20 -0
  23. package/server/lib/display-bus.ts +21 -0
  24. package/server/lib/display-format.ts +23 -0
  25. package/server/lib/fuzzy-correct.ts +286 -0
  26. package/server/lib/hallucination-filter.ts +469 -0
  27. package/server/lib/local-day.ts +13 -0
  28. package/server/lib/model-router.ts +38 -0
  29. package/server/lib/openai-key.ts +155 -0
  30. package/server/lib/openai-whisper-budget.ts +170 -0
  31. package/server/lib/profile.ts +94 -0
  32. package/server/lib/python-bridge.ts +84 -0
  33. package/server/lib/response-cache.ts +138 -0
  34. package/server/lib/session-cache-writer.ts +266 -0
  35. package/server/lib/session-log.ts +162 -0
  36. package/server/lib/speaker-embeddings.ts +578 -0
  37. package/server/lib/telegram-notify.ts +85 -0
  38. package/server/lib/token-audit.ts +50 -0
  39. package/server/lib/transcribe-audio.ts +187 -0
  40. package/server/lib/utils.ts +5 -0
  41. package/server/lib/vad-silero.ts +179 -0
  42. package/server/lib/whisper-local.ts +697 -0
  43. package/server/routes/diag.ts +115 -0
  44. package/server/routes/display.ts +65 -0
  45. package/server/routes/health.ts +128 -0
  46. package/server/routes/openai-compat.ts +446 -0
  47. package/server/routes/openai-key.ts +121 -0
  48. package/server/routes/query.ts +121 -0
  49. package/server/routes/transcribe-stream.ts +1090 -0
  50. package/server/routes/transcribe.ts +55 -0
  51. package/shared/model-preference.ts +81 -0
@@ -0,0 +1,446 @@
1
+ // OpenAI-compatible /v1/chat/completions endpoint
2
+ // Adapter for Even Realities "Add Agent" and any OpenAI-compatible client.
3
+ // Accepts standard OpenAI format, routes through COS model-router, returns OpenAI format.
4
+ // Supports both streaming (SSE) and non-streaming responses.
5
+
6
+ import { Router } from 'express'
7
+ import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
8
+ import { callModelStreaming } from '../lib/model-router.js'
9
+ import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
10
+ import { tryInstantResponse } from '../lib/response-cache.js'
11
+ import crypto from 'node:crypto'
12
+
13
+ export const openaiCompatRouter = Router()
14
+
15
+ // ─── Dedup guard — prevent Even from doubling server load ───
16
+ // Maps query text → { promise, timestamp } for in-flight requests.
17
+ // If the same query arrives within 2s of a pending request, reuse the result.
18
+ const inflightQueries = new Map<string, { promise: Promise<string>; timestamp: number }>()
19
+ const DEDUP_WINDOW_MS = 2000
20
+
21
+ function cleanupInflight() {
22
+ const now = Date.now()
23
+ for (const [key, entry] of inflightQueries) {
24
+ if (now - entry.timestamp > 30000) inflightQueries.delete(key) // 30s max
25
+ }
26
+ }
27
+
28
+ // ─── Daily persistent session ───
29
+ // Session persists across all G2 queries for the entire day, building context
30
+ // as the user explores concepts, checks facts, follows threads.
31
+ // Auto-resets at midnight to prevent context rot.
32
+ // Say "reset session" or "new session" to force reset.
33
+ let g2SessionId: string | undefined = undefined
34
+ let g2SessionDate: string | undefined = undefined // YYYY-MM-DD of current session
35
+
36
+ function getOrResetG2Session(): string | undefined {
37
+ const today = new Date().toISOString().slice(0, 10)
38
+ if (g2SessionDate !== today) {
39
+ // New day — reset session to prevent context rot
40
+ g2SessionId = undefined
41
+ g2SessionDate = today
42
+ console.log(`[g2] Daily session reset (${today})`)
43
+ // Lazy pre-warm: fire-and-forget so next query has a warm CLI session
44
+ preWarmCLI().catch(() => {})
45
+ }
46
+ return g2SessionId
47
+ }
48
+
49
+ function shouldResetSession(query: string): boolean {
50
+ return /\b(reset session|new session|fresh start|clear context|start over)\b/i.test(query)
51
+ }
52
+
53
+ // Optional: validate Bearer token against COS_API_TOKEN
54
+ function validateAuth(req: any, res: any): boolean {
55
+ const cosToken = process.env.COS_API_TOKEN
56
+ if (!cosToken) return true // No token configured = open access
57
+
58
+ const auth = req.headers['authorization']
59
+ if (!auth || !auth.startsWith('Bearer ')) {
60
+ res.status(401).json({ error: { message: 'Missing Bearer token', type: 'invalid_request_error' } })
61
+ return false
62
+ }
63
+
64
+ const token = auth.slice(7)
65
+ if (token !== cosToken) {
66
+ res.status(401).json({ error: { message: 'Invalid token', type: 'invalid_request_error' } })
67
+ return false
68
+ }
69
+
70
+ return true
71
+ }
72
+
73
+ // Resolve model from OpenAI-compatible model ids. Defaults to Haiku for
74
+ // "Hey Even" speed; Opus/Sonnet/Haiku/Codex High can be explicitly selected.
75
+ function resolveModel(model?: string, _query?: string): ModelPreference {
76
+ const normalized = normalizeModelPreference(model)
77
+ if (normalized) return normalized
78
+ if (model === 'cos-opus') return 'opus'
79
+ if (model === 'cos-sonnet') return 'sonnet'
80
+ if (model === 'cos-haiku') return 'haiku'
81
+ if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-high'
82
+ return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? 'haiku'
83
+ }
84
+
85
+ const MODEL_NAMES: Record<ModelPreference, string> = {
86
+ opus: 'cos-opus',
87
+ sonnet: 'cos-sonnet',
88
+ haiku: 'cos-haiku',
89
+ 'codex-high': 'cos-codex-high',
90
+ }
91
+
92
+ // Extract the user's latest message from the OpenAI messages array
93
+ function extractUserQuery(messages: Array<{ role: string; content: string }>): string {
94
+ // Find the last user message
95
+ for (let i = messages.length - 1; i >= 0; i--) {
96
+ if (messages[i].role === 'user' && messages[i].content) {
97
+ return messages[i].content
98
+ }
99
+ }
100
+ return ''
101
+ }
102
+
103
+ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
104
+ if (!validateAuth(req, res)) return
105
+
106
+ const { messages, stream, model } = req.body
107
+
108
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
109
+ return res.status(400).json({
110
+ error: { message: 'messages array required', type: 'invalid_request_error' },
111
+ })
112
+ }
113
+
114
+ let query = extractUserQuery(messages)
115
+ if (!query) {
116
+ return res.status(400).json({
117
+ error: { message: 'No user message found', type: 'invalid_request_error' },
118
+ })
119
+ }
120
+
121
+
122
+ // Log request entry — tells us if Even sends stream: true or false
123
+ console.log(`[g2] Request: stream=${!!stream}, query="${query.slice(0, 50)}"`)
124
+
125
+ const requestReceivedAt = Date.now()
126
+ const resolvedModel = resolveModel(model, query)
127
+ const completionId = `chatcmpl-${crypto.randomUUID().slice(0, 12)}`
128
+ const timestamp = Math.floor(Date.now() / 1000)
129
+ const responseModel = MODEL_NAMES[resolvedModel]
130
+
131
+ // ── Predictive response cache — bypass Claude for common queries ──
132
+ const cached = tryInstantResponse(query)
133
+ if (cached) {
134
+ const ttfb = Date.now() - requestReceivedAt
135
+ logLatency({
136
+ timestamp: new Date().toISOString(),
137
+ query: query.slice(0, 50),
138
+ ttfb_ms: ttfb,
139
+ total_ms: ttfb,
140
+ model: 'cache',
141
+ resumed: false,
142
+ contextInjected: false,
143
+ cacheHit: true,
144
+ })
145
+ console.log(`[g2] Cache hit (${cached.pattern}): "${query}" → ${ttfb}ms`)
146
+
147
+ if (stream) {
148
+ res.writeHead(200, {
149
+ 'Content-Type': 'text/event-stream',
150
+ 'Cache-Control': 'no-cache',
151
+ 'Connection': 'keep-alive',
152
+ 'X-Accel-Buffering': 'no',
153
+ 'Access-Control-Allow-Origin': '*',
154
+ })
155
+ res.flushHeaders()
156
+ const chunk = {
157
+ id: completionId,
158
+ object: 'chat.completion.chunk',
159
+ created: timestamp,
160
+ model: responseModel,
161
+ choices: [{ index: 0, delta: { content: cached.text }, finish_reason: null }],
162
+ }
163
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`)
164
+ const finalChunk = {
165
+ id: completionId,
166
+ object: 'chat.completion.chunk',
167
+ created: timestamp,
168
+ model: responseModel,
169
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
170
+ }
171
+ res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
172
+ res.write('data: [DONE]\n\n')
173
+ res.end()
174
+ return
175
+ }
176
+
177
+ // Non-streaming cache response
178
+ return res.json({
179
+ id: completionId,
180
+ object: 'chat.completion',
181
+ created: timestamp,
182
+ model: responseModel,
183
+ choices: [{
184
+ index: 0,
185
+ message: { role: 'assistant', content: cached.text },
186
+ finish_reason: 'stop',
187
+ }],
188
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
189
+ })
190
+ }
191
+
192
+ // ── Dedup guard — if same query is already in-flight within 2s, reuse result ──
193
+ cleanupInflight()
194
+ const dedupKey = `${resolvedModel}:${query.trim().toLowerCase()}`
195
+ const inflight = inflightQueries.get(dedupKey)
196
+ if (inflight && (Date.now() - inflight.timestamp) < DEDUP_WINDOW_MS) {
197
+ console.log(`[g2] Dedup hit: "${query.slice(0, 50)}" (waiting for in-flight result)`)
198
+ try {
199
+ const dedupResult = await inflight.promise
200
+ logLatency({
201
+ timestamp: new Date().toISOString(),
202
+ query: query.slice(0, 50),
203
+ ttfb_ms: Date.now() - requestReceivedAt,
204
+ total_ms: Date.now() - requestReceivedAt,
205
+ model: resolvedModel,
206
+ resumed: false,
207
+ contextInjected: false,
208
+ cacheHit: false,
209
+ deduped: true,
210
+ })
211
+ if (stream) {
212
+ res.writeHead(200, {
213
+ 'Content-Type': 'text/event-stream',
214
+ 'Cache-Control': 'no-cache',
215
+ 'Connection': 'keep-alive',
216
+ 'X-Accel-Buffering': 'no',
217
+ 'Access-Control-Allow-Origin': '*',
218
+ })
219
+ res.flushHeaders()
220
+ const chunk = {
221
+ id: completionId,
222
+ object: 'chat.completion.chunk',
223
+ created: timestamp,
224
+ model: responseModel,
225
+ choices: [{ index: 0, delta: { content: dedupResult }, finish_reason: null }],
226
+ }
227
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`)
228
+ res.write(`data: ${JSON.stringify({ id: completionId, object: 'chat.completion.chunk', created: timestamp, model: responseModel, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`)
229
+ res.write('data: [DONE]\n\n')
230
+ res.end()
231
+ return
232
+ }
233
+ return res.json({
234
+ id: completionId,
235
+ object: 'chat.completion',
236
+ created: timestamp,
237
+ model: responseModel,
238
+ choices: [{ index: 0, message: { role: 'assistant', content: dedupResult }, finish_reason: 'stop' }],
239
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
240
+ })
241
+ } catch {
242
+ // Original request failed — fall through to make a fresh request
243
+ }
244
+ }
245
+
246
+ // Register this query as in-flight for dedup
247
+ let resolveInflight: (text: string) => void
248
+ let rejectInflight: (err: any) => void
249
+ const inflightPromise = new Promise<string>((res, rej) => { resolveInflight = res; rejectInflight = rej })
250
+ inflightQueries.set(dedupKey, { promise: inflightPromise, timestamp: Date.now() })
251
+
252
+ // ── Streaming response (SSE) ──
253
+ if (stream) {
254
+ res.writeHead(200, {
255
+ 'Content-Type': 'text/event-stream',
256
+ 'Cache-Control': 'no-cache',
257
+ 'Connection': 'keep-alive',
258
+ 'X-Accel-Buffering': 'no',
259
+ 'Access-Control-Allow-Origin': '*',
260
+ })
261
+ res.flushHeaders()
262
+
263
+ // Immediate keepalive — prevents ER app timeout while Claude processes
264
+ res.write(': keepalive\n\n')
265
+
266
+ let done = false
267
+ let firstChunkLogged = false
268
+ let actualTtfbMs = -1 // Captured at first chunk arrival
269
+
270
+ // Check for session reset command
271
+ if (shouldResetSession(query)) {
272
+ g2SessionId = undefined
273
+ g2SessionDate = new Date().toISOString().slice(0, 10)
274
+ console.log(`[g2] Manual session reset`)
275
+ }
276
+
277
+ const currentSessionId = getOrResetG2Session()
278
+
279
+ // Pass query directly — conciseness instruction is in the system prompt now
280
+ try {
281
+ const returnedSid = await callModelStreaming(query, currentSessionId, {
282
+ onChunk: (text) => {
283
+ if (!done) {
284
+ if (!firstChunkLogged) {
285
+ firstChunkLogged = true
286
+ actualTtfbMs = Date.now() - requestReceivedAt
287
+ console.log(`[g2] TTFB: ${actualTtfbMs}ms (${resolvedModel}, session: ${currentSessionId ? 'resumed' : 'new'})`)
288
+ }
289
+ const chunk = {
290
+ id: completionId,
291
+ object: 'chat.completion.chunk',
292
+ created: timestamp,
293
+ model: responseModel,
294
+ choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
295
+ }
296
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`)
297
+ }
298
+ },
299
+ onDone: (fullText) => {
300
+ if (!done) {
301
+ done = true
302
+ resolveInflight!(fullText || '')
303
+ inflightQueries.delete(dedupKey)
304
+ logLatency({
305
+ timestamp: new Date().toISOString(),
306
+ query: query.slice(0, 50),
307
+ ttfb_ms: actualTtfbMs,
308
+ total_ms: Date.now() - requestReceivedAt,
309
+ model: resolvedModel,
310
+ resumed: !!currentSessionId,
311
+ contextInjected: /\b(schedule|calendar|meeting|task|tasks|today|tomorrow|next meeting|who do i meet|what's next)\b/i.test(query),
312
+ cacheHit: false,
313
+ stream_requested: true,
314
+ })
315
+ // Final chunk with finish_reason
316
+ const finalChunk = {
317
+ id: completionId,
318
+ object: 'chat.completion.chunk',
319
+ created: timestamp,
320
+ model: responseModel,
321
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
322
+ }
323
+ res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
324
+ res.write('data: [DONE]\n\n')
325
+ res.end()
326
+ }
327
+ },
328
+ onError: (error) => {
329
+ if (!done) {
330
+ done = true
331
+ rejectInflight!(new Error(error))
332
+ inflightQueries.delete(dedupKey)
333
+ const errChunk = {
334
+ id: completionId,
335
+ object: 'chat.completion.chunk',
336
+ created: timestamp,
337
+ model: responseModel,
338
+ choices: [{ index: 0, delta: { content: `Error: ${error}` }, finish_reason: 'stop' }],
339
+ }
340
+ res.write(`data: ${JSON.stringify(errChunk)}\n\n`)
341
+ res.write('data: [DONE]\n\n')
342
+ res.end()
343
+ }
344
+ },
345
+ onToolStatus: (status) => {
346
+ if (!done) {
347
+ // SSE comment — invisible to JSON parsers but keeps connection alive
348
+ res.write(`: ${status}\n\n`)
349
+ }
350
+ },
351
+ onStart: () => {},
352
+ }, resolvedModel, undefined, undefined, undefined, { lightweight: true })
353
+ // Persist session ID for multi-turn context on subsequent G2 queries
354
+ g2SessionId = returnedSid
355
+ } catch (err: any) {
356
+ if (!done) {
357
+ done = true
358
+ rejectInflight!(err)
359
+ inflightQueries.delete(dedupKey)
360
+ res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`)
361
+ res.write('data: [DONE]\n\n')
362
+ res.end()
363
+ }
364
+ }
365
+
366
+ req.on('close', () => { done = true })
367
+ return
368
+ }
369
+
370
+ // ── Non-streaming response ──
371
+ // Check for session reset command
372
+ if (shouldResetSession(query)) {
373
+ g2SessionId = undefined
374
+ g2SessionDate = new Date().toISOString().slice(0, 10)
375
+ console.log(`[g2] Manual session reset (non-stream)`)
376
+ }
377
+
378
+ const currentSessionIdNS = getOrResetG2Session()
379
+
380
+ try {
381
+ const fullText = await new Promise<string>((resolve, reject) => {
382
+ let result = ''
383
+ let nsFirstChunkMs = -1
384
+ callModelStreaming(query, currentSessionIdNS, {
385
+ onChunk: (text) => {
386
+ if (nsFirstChunkMs < 0) nsFirstChunkMs = Date.now() - requestReceivedAt
387
+ result += text
388
+ },
389
+ onDone: (fullText) => {
390
+ const text = fullText || result
391
+ resolveInflight!(text)
392
+ inflightQueries.delete(dedupKey)
393
+ logLatency({
394
+ timestamp: new Date().toISOString(),
395
+ query: query.slice(0, 50),
396
+ ttfb_ms: nsFirstChunkMs,
397
+ total_ms: Date.now() - requestReceivedAt,
398
+ model: resolvedModel,
399
+ resumed: !!currentSessionIdNS,
400
+ contextInjected: /\b(schedule|calendar|meeting|task|tasks|today|tomorrow)\b/i.test(query),
401
+ cacheHit: false,
402
+ stream_requested: false,
403
+ })
404
+ resolve(text)
405
+ },
406
+ onError: (error) => {
407
+ rejectInflight!(new Error(error))
408
+ inflightQueries.delete(dedupKey)
409
+ reject(new Error(error))
410
+ },
411
+ onToolStatus: () => {},
412
+ onStart: () => {},
413
+ }, resolvedModel, undefined, undefined, undefined, { lightweight: true }).then(sid => { g2SessionId = sid })
414
+ })
415
+
416
+ res.json({
417
+ id: completionId,
418
+ object: 'chat.completion',
419
+ created: timestamp,
420
+ model: responseModel,
421
+ choices: [{
422
+ index: 0,
423
+ message: { role: 'assistant', content: fullText },
424
+ finish_reason: 'stop',
425
+ }],
426
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
427
+ })
428
+ } catch (err: any) {
429
+ res.status(500).json({
430
+ error: { message: err.message, type: 'server_error' },
431
+ })
432
+ }
433
+ })
434
+
435
+ // GET /v1/models — required by some clients for model discovery
436
+ openaiCompatRouter.get('/v1/models', (_req, res) => {
437
+ res.json({
438
+ object: 'list',
439
+ data: [
440
+ { id: 'cos-opus', object: 'model', created: 1709251200, owned_by: 'cos' },
441
+ { id: 'cos-sonnet', object: 'model', created: 1709251200, owned_by: 'cos' },
442
+ { id: 'cos-haiku', object: 'model', created: 1709251200, owned_by: 'cos' },
443
+ { id: 'cos-codex-high', object: 'model', created: 1709251200, owned_by: 'cos' },
444
+ ],
445
+ })
446
+ })
@@ -0,0 +1,121 @@
1
+ // OpenAI key management endpoints — phone-side Settings panel writes the key
2
+ // here so users never have to edit a .env file or rerun the wizard.
3
+ //
4
+ // Wire-protocol contract: the key is push-only. The phone POSTs it to /set;
5
+ // the server validates against OpenAI, persists to server/data/openai-key.json,
6
+ // and from then on only ever returns metadata (hasKey/source/savedAt/...). The
7
+ // raw key value is never echoed back to any client. Same intent as the
8
+ // existing /api/tts/* contract that the OPENAI_API_KEY must never reach the
9
+ // client.
10
+ //
11
+ // Resolution priority is enforced upstream by openai-key.ts:
12
+ // env > server/data/openai-key.json > COS scripts .env regex
13
+ // So saving via Settings is a no-op when an env-level OPENAI_API_KEY exists.
14
+ // /status reflects that honestly so the UI can render "Active (env)" instead
15
+ // of pretending the saved value is in use.
16
+
17
+ import { Router } from 'express'
18
+ import { existsSync, mkdirSync, unlinkSync } from 'node:fs'
19
+ import { dirname } from 'node:path'
20
+ import { errMsg } from '../lib/utils.js'
21
+ import { atomicWriteFileSync } from '../lib/atomic-fs.js'
22
+ import { KEY_FILE_PATH, clearCachedKey, getKeyStatus } from '../lib/openai-key.js'
23
+
24
+ export const openaiKeyRouter = Router()
25
+
26
+ /** Validate a candidate key by listing models. The /v1/models endpoint is
27
+ * cheap (returns ~50 model IDs in JSON), supported by every OpenAI-compatible
28
+ * proxy, and returns 401 on a bad key — so a successful 200 confirms the key
29
+ * works for our usage without billing anything. */
30
+ async function validateKey(key: string): Promise<{ ok: true } | { ok: false; status: number; reason: string }> {
31
+ try {
32
+ const res = await fetch('https://api.openai.com/v1/models', {
33
+ method: 'GET',
34
+ headers: { Authorization: `Bearer ${key}` },
35
+ signal: AbortSignal.timeout(8_000),
36
+ })
37
+ if (res.ok) return { ok: true }
38
+ const body = await res.text().catch(() => '')
39
+ return {
40
+ ok: false,
41
+ status: res.status,
42
+ reason: body.slice(0, 200) || `OpenAI returned ${res.status}`,
43
+ }
44
+ } catch (err) {
45
+ return { ok: false, status: 0, reason: errMsg(err) }
46
+ }
47
+ }
48
+
49
+ // POST /api/openai-key/set — body { key }. Validates against OpenAI then
50
+ // writes server/data/openai-key.json. Returns metadata only.
51
+ openaiKeyRouter.post('/openai-key/set', async (req, res) => {
52
+ try {
53
+ const raw = req.body?.key
54
+ if (typeof raw !== 'string') {
55
+ return res.status(400).json({ error: 'key is required (string)' })
56
+ }
57
+ const key = raw.trim()
58
+ if (!key) {
59
+ return res.status(400).json({ error: 'key is empty after trim' })
60
+ }
61
+ // Light shape check — sk- prefix is the historical OpenAI convention but
62
+ // proxies (Azure, third-party gateways) use other prefixes too. Length is
63
+ // the safer guard. We rely on the live /v1/models call to reject bad keys.
64
+ if (key.length < 16 || key.length > 512) {
65
+ return res.status(400).json({ error: 'key length looks wrong (expected 16-512 chars)' })
66
+ }
67
+
68
+ const validation = await validateKey(key)
69
+ if (!validation.ok) {
70
+ return res.status(400).json({
71
+ error: `validation failed: ${validation.reason}`,
72
+ upstreamStatus: validation.status,
73
+ })
74
+ }
75
+
76
+ const now = new Date().toISOString()
77
+ const payload = JSON.stringify({ key, savedAt: now, validatedAt: now }, null, 2)
78
+
79
+ // Ensure parent dir exists (server/data/ is gitignored but may not exist
80
+ // on a fresh checkout that's never run a budget write).
81
+ const parent = dirname(KEY_FILE_PATH)
82
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true })
83
+
84
+ atomicWriteFileSync(KEY_FILE_PATH, payload)
85
+ clearCachedKey()
86
+
87
+ const status = getKeyStatus()
88
+ return res.json({
89
+ ok: true,
90
+ validatedAt: now,
91
+ // Echo back the resolved source — if env is set, source will still be
92
+ // 'env' here (env wins over the file we just wrote). The client uses
93
+ // this to surface "Saved (but env override is active)" when relevant.
94
+ activeSource: status.source,
95
+ })
96
+ } catch (err) {
97
+ return res.status(500).json({ error: errMsg(err) })
98
+ }
99
+ })
100
+
101
+ // GET /api/openai-key/status — { hasKey, source, savedAt?, validatedAt? }.
102
+ // Never returns the key value.
103
+ openaiKeyRouter.get('/openai-key/status', (_req, res) => {
104
+ try {
105
+ return res.json(getKeyStatus())
106
+ } catch (err) {
107
+ return res.status(500).json({ error: errMsg(err) })
108
+ }
109
+ })
110
+
111
+ // DELETE /api/openai-key — removes server/data/openai-key.json. The env or
112
+ // scripts-env source (if set) takes over on the next resolve.
113
+ openaiKeyRouter.delete('/openai-key', (_req, res) => {
114
+ try {
115
+ if (existsSync(KEY_FILE_PATH)) unlinkSync(KEY_FILE_PATH)
116
+ clearCachedKey()
117
+ return res.json({ ok: true, ...getKeyStatus() })
118
+ } catch (err) {
119
+ return res.status(500).json({ error: errMsg(err) })
120
+ }
121
+ })
@@ -0,0 +1,121 @@
1
+ // POST /api/query — streaming SSE endpoint for Claude queries
2
+ // Returns text/event-stream with chunk, done, and error events
3
+
4
+ import { Router } from 'express'
5
+ import { callModelStreaming } from '../lib/model-router.js'
6
+ import { emitDisplay } from '../lib/display-bus.js'
7
+ import { errMsg } from '../lib/utils.js'
8
+ import { normalizeModelPreference } from '../../shared/model-preference.js'
9
+
10
+ const TOOL_STATUS_MESSAGES: Record<string, string> = {
11
+ WebSearch: 'Searching web...',
12
+ WebFetch: 'Reading page...',
13
+ Read: 'Analyzing photo...',
14
+ }
15
+
16
+ export const queryRouter = Router()
17
+
18
+ queryRouter.post('/query', async (req, res) => {
19
+ const { query, sessionId, model, image, images, reference, globalMsgNum } = req.body
20
+
21
+ // Normalize: accept `images` array or legacy `image` string
22
+ let validImages: string[] | undefined
23
+ if (Array.isArray(images) && images.length > 0) {
24
+ // Filter to valid non-empty strings, cap at 5
25
+ validImages = images.filter((img: unknown) => typeof img === 'string' && img.length > 0).slice(0, 5)
26
+ if (validImages.length === 0) validImages = undefined
27
+ } else if (typeof image === 'string' && image.length > 0) {
28
+ // Backward compat: wrap single image as array
29
+ validImages = [image]
30
+ }
31
+
32
+ const resolvedQuery = typeof query === 'string' ? query : ''
33
+
34
+ // Vision queries can have an empty query (default to "describe what you see")
35
+ if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !validImages) {
36
+ return res.status(400).json({ error: 'query string or image required' })
37
+ }
38
+
39
+ // Validate model if provided
40
+ const validModel = normalizeModelPreference(model)
41
+
42
+ // Validate globalMsgNum if provided
43
+ const validGlobalMsgNum = typeof globalMsgNum === 'number' && globalMsgNum > 0
44
+ ? globalMsgNum : undefined
45
+
46
+ // Set up SSE headers
47
+ res.writeHead(200, {
48
+ 'Content-Type': 'text/event-stream',
49
+ 'Cache-Control': 'no-cache',
50
+ 'Connection': 'keep-alive',
51
+ 'X-Accel-Buffering': 'no', // Disable nginx buffering if proxied
52
+ 'Access-Control-Allow-Origin': '*', // Even Hub WebView loads from file://
53
+ })
54
+
55
+ // Flush headers immediately
56
+ res.flushHeaders()
57
+ res.write(': keepalive\n\n')
58
+
59
+ let done = false
60
+ const abortController = new AbortController()
61
+ res.on('close', () => {
62
+ if (!done) abortController.abort()
63
+ done = true
64
+ })
65
+
66
+ try {
67
+ const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
68
+ onStart: (model, sid, cliSessionId, metadata) => {
69
+ if (!done) {
70
+ const payload = { model, sessionId: sid, cliSessionId, ...metadata }
71
+ res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
72
+ emitDisplay({ type: 'start', data: payload })
73
+ }
74
+ },
75
+ onChunk: (text) => {
76
+ if (!done) {
77
+ res.write(`event: chunk\ndata: ${JSON.stringify({ text })}\n\n`)
78
+ emitDisplay({ type: 'chunk', data: { text } })
79
+ }
80
+ },
81
+ onToolStatus: (toolName) => {
82
+ if (!done) {
83
+ const message = TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
84
+ res.write(`event: tool_status\ndata: ${JSON.stringify({ message })}\n\n`)
85
+ emitDisplay({ type: 'tool_status', data: { message } })
86
+ }
87
+ },
88
+ onDone: (fullText, model, cliSessionId, metadata) => {
89
+ if (!done) {
90
+ done = true
91
+ const payload = { text: fullText, sessionId: sid, model, cliSessionId, ...metadata }
92
+ res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
93
+ emitDisplay({ type: 'done', data: payload })
94
+ res.end()
95
+ }
96
+ },
97
+ onError: (error) => {
98
+ if (!done) {
99
+ done = true
100
+ res.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`)
101
+ emitDisplay({ type: 'error', data: { error } })
102
+ res.end()
103
+ }
104
+ },
105
+ }, validModel, validImages,
106
+ // Pass reference if provided (for "recall message N" feature)
107
+ reference && typeof reference === 'object' && reference.query && reference.response
108
+ ? { query: String(reference.query), response: String(reference.response) }
109
+ : undefined,
110
+ validGlobalMsgNum,
111
+ { abortSignal: abortController.signal },
112
+ )
113
+ } catch (err: unknown) {
114
+ if (!done) {
115
+ done = true
116
+ res.write(`event: error\ndata: ${JSON.stringify({ error: errMsg(err) })}\n\n`)
117
+ res.end()
118
+ }
119
+ }
120
+
121
+ })