@gotcos/glasses-server 6.3.1 → 6.6.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 (37) hide show
  1. package/.env.example +23 -7
  2. package/CHANGELOG.md +108 -0
  3. package/README.md +28 -8
  4. package/bin/cli.cjs +22 -10
  5. package/package.json +18 -6
  6. package/server/bin/cos-output-image-publisher.mjs +324 -0
  7. package/server/bootstrap.ts +16 -0
  8. package/server/index.ts +61 -21
  9. package/server/lib/activity-preview.ts +168 -0
  10. package/server/lib/archive.ts +20 -6
  11. package/server/lib/claude-bridge.ts +215 -60
  12. package/server/lib/claude-run-ledger.ts +7 -2
  13. package/server/lib/codex-bridge.ts +186 -71
  14. package/server/lib/codex-engine-sessions.ts +24 -2
  15. package/server/lib/codex-model-catalog.ts +450 -0
  16. package/server/lib/codex-run-ledger.ts +20 -4
  17. package/server/lib/conversation.ts +64 -2
  18. package/server/lib/display-bus.ts +61 -3
  19. package/server/lib/image-safety.ts +458 -0
  20. package/server/lib/listener-startup.ts +29 -0
  21. package/server/lib/media-store.ts +833 -0
  22. package/server/lib/model-image-input.ts +27 -0
  23. package/server/lib/model-router.ts +67 -8
  24. package/server/lib/query-attachments.ts +132 -0
  25. package/server/lib/run-output-images.ts +442 -0
  26. package/server/lib/server-instance-id.ts +55 -0
  27. package/server/lib/server-instance-lock.ts +122 -0
  28. package/server/lib/server-metrics.ts +7 -0
  29. package/server/routes/display.ts +43 -22
  30. package/server/routes/health.ts +19 -2
  31. package/server/routes/media.ts +285 -0
  32. package/server/routes/message-ref.ts +18 -6
  33. package/server/routes/openai-compat.ts +44 -11
  34. package/server/routes/query.ts +51 -16
  35. package/server/routes/sessions.ts +33 -4
  36. package/shared/media-attachment.ts +126 -0
  37. package/shared/model-preference.ts +140 -17
@@ -0,0 +1,168 @@
1
+ // Safe, bounded activity previews for the live job monitor. This module only
2
+ // surfaces observable tool actions/results; it never exposes model reasoning.
3
+
4
+ export interface ActivityPreviewLine {
5
+ kind: 'input' | 'output'
6
+ text: string
7
+ }
8
+
9
+ const ANSI_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g
10
+ const PRIVATE_MATERIAL_BLOCK_RE = /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----[\s\S]*?(?:-----END [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----|$)/gi
11
+
12
+ const SECRET_PATTERNS: Array<[RegExp, string]> = [
13
+ // Cookie headers are credential containers; partial parsing is unsafe.
14
+ [/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/gi, '$1[redacted]'],
15
+ // Authorization and proxy-authorization headers, including Basic credentials.
16
+ [/\b((?:proxy-)?authorization\s*[:=]\s*)(?:bearer|basic|digest)\s+[^\s,;"']+/gi, '$1[redacted]'],
17
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [redacted]'],
18
+ // Structured assignments and JSON/env values. Key names are intentionally
19
+ // broad; a false-positive here is safer than putting a credential on a lens.
20
+ [/\b((?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth(?:orization)?|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie|phpsessid|jsessionid|sid)(?:[_-][a-z0-9]+)*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}&]+)/gi, '$1[redacted]'],
21
+ // Shell/env whitespace assignments such as `export TOKEN value`.
22
+ [/\b((?:export|set|env)\s+(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie)\s+)(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
23
+ // Command-line flags commonly used for credentials.
24
+ [/(\s--?(?:api-key|token|access-token|refresh-token|password|passwd|secret|client-secret)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
25
+ [/(\s(?:-u|--user)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
26
+ // URL userinfo and secret-bearing query parameters.
27
+ [/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]*:[^\s/@]+@/gi, '$1[redacted]@'],
28
+ [/([?&](?:api[_-]?key|access[_-]?token|token|auth|password|secret)=)[^&#\s]+/gi, '$1[redacted]'],
29
+ // Common provider token formats.
30
+ [/\b(?:sk-(?:proj-|live-|test-)?|sk_(?:live|test)_|sess-|pat-|gh[pousr]_|github_pat_|glpat-|npm_|pypi-|shpat_|xox[baprs]-)[A-Za-z0-9._-]{8,}\b/gi, '[redacted-token]'],
31
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[redacted-aws-key]'],
32
+ [/\bAIza[A-Za-z0-9_-]{30,}\b/g, '[redacted-google-key]'],
33
+ // JWTs and PEM material.
34
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted-jwt]'],
35
+ [/-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----/gi, '[redacted-private-material]'],
36
+ [/-----END [A-Z0-9 ]*(?:PRIVATE KEY|CREDENTIALS?)-----/gi, '[redacted-private-material]'],
37
+ ]
38
+
39
+ function looksOpaqueSecret(text: string): boolean {
40
+ const compact = text.replace(/\s/g, '')
41
+ if (compact.length < 40) return false
42
+ if (!/^[A-Za-z0-9+/=_:.-]+$/.test(compact)) return false
43
+
44
+ // PEM bodies are conventionally wrapped into 64-character base64 lines,
45
+ // but exporters commonly use widths from 40–72. Suppress every standalone
46
+ // base64/hex chunk in that range, including low-variety padding lines.
47
+ if (!/\s/.test(text) && /^(?:[A-Za-z0-9+/]{40,}={0,2}|[A-Fa-f0-9]{40,})$/.test(compact)) return true
48
+
49
+ // Ordinary prose rarely has this mix without spaces. Requiring several
50
+ // character classes avoids hiding long paths or repeated divider lines.
51
+ let classes = 0
52
+ if (/[a-z]/.test(compact)) classes++
53
+ if (/[A-Z]/.test(compact)) classes++
54
+ if (/\d/.test(compact)) classes++
55
+ if (/[+/=_:.-]/.test(compact)) classes++
56
+ return classes >= 3
57
+ }
58
+
59
+ export function sanitizeActivityPreview(raw: unknown, max = 180): string | null {
60
+ if (typeof raw !== 'string') return null
61
+ let text = raw.replace(ANSI_RE, '').replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]')
62
+ for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement)
63
+ text = text.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
64
+ if (!text) return null
65
+ if (looksOpaqueSecret(text)) return '[opaque output hidden]'
66
+ const safeMax = Number.isFinite(max) && max >= 8 ? Math.floor(max) : 180
67
+ return text.length > safeMax ? `${text.slice(0, safeMax - 1)}…` : text
68
+ }
69
+
70
+ export function textPreviewLines(raw: unknown, maxLines = 3): string[] {
71
+ if (typeof raw !== 'string') return []
72
+ const safeMaxLines = Number.isFinite(maxLines) && maxLines > 0 ? Math.min(Math.floor(maxLines), 5) : 3
73
+ const lines = raw.replace(PRIVATE_MATERIAL_BLOCK_RE, '[private material hidden]').replace(/\r\n?/g, '\n').split('\n')
74
+ .map(line => sanitizeActivityPreview(line))
75
+ .filter((line): line is string => !!line)
76
+ return lines.slice(-safeMaxLines)
77
+ }
78
+
79
+ function resultText(value: unknown): string | null {
80
+ if (typeof value === 'string') return value
81
+ if (Array.isArray(value)) {
82
+ const parts = value.flatMap((part) => {
83
+ if (typeof part === 'string') return [part]
84
+ if (typeof part?.text === 'string') return [part.text]
85
+ if (typeof part?.content === 'string') return [part.content]
86
+ return []
87
+ })
88
+ return parts.length ? parts.join('\n') : null
89
+ }
90
+ if (value && typeof value === 'object') {
91
+ const record = value as Record<string, unknown>
92
+ return resultText(record.content) ?? resultText(record.text) ?? resultText(record.output)
93
+ }
94
+ return null
95
+ }
96
+
97
+ /** Extract observable Codex tool activity from `codex exec --json` events. */
98
+ export function codexActivityPreviewLines(event: any): ActivityPreviewLine[] {
99
+ const eventType = String(event?.type ?? '')
100
+ const item = event?.item ?? event?.payload ?? {}
101
+ const itemType = String(item?.type ?? '')
102
+ const lines: ActivityPreviewLine[] = []
103
+
104
+ if (/command_execution|command|shell|exec/i.test(itemType)) {
105
+ if (/started/i.test(eventType)) {
106
+ const command = sanitizeActivityPreview(item.command ?? item.input)
107
+ if (command) lines.push({ kind: 'input', text: `$ ${command}` })
108
+ }
109
+ const output = item.aggregated_output ?? item.output ?? item.stdout ?? item.stderr
110
+ for (const text of textPreviewLines(output)) lines.push({ kind: 'output', text })
111
+ if (/completed/i.test(eventType) && Number.isInteger(item.exit_code)) {
112
+ lines.push({ kind: 'output', text: `exit ${item.exit_code}` })
113
+ }
114
+ return lines
115
+ }
116
+
117
+ if (/file_change|patch/i.test(itemType)) {
118
+ const changes = Array.isArray(item.changes) ? item.changes : []
119
+ for (const change of changes.slice(-3)) {
120
+ const path = sanitizeActivityPreview(change?.path ?? change?.file)
121
+ if (path) lines.push({ kind: 'output', text: `${change?.kind ?? 'updated'} ${path}` })
122
+ }
123
+ return lines
124
+ }
125
+
126
+ if (/web_search/i.test(itemType)) {
127
+ const query = sanitizeActivityPreview(item.query)
128
+ if (query) lines.push({ kind: 'input', text: `Search: ${query}` })
129
+ return lines
130
+ }
131
+
132
+ if (/mcp_tool_call|tool_call|tool/i.test(itemType)) {
133
+ const name = sanitizeActivityPreview([item.server, item.tool ?? item.name].filter(Boolean).join('.'))
134
+ if (name && /started/i.test(eventType)) lines.push({ kind: 'input', text: name })
135
+ const text = resultText(item.result ?? item.output)
136
+ for (const preview of textPreviewLines(text)) lines.push({ kind: 'output', text: preview })
137
+ }
138
+
139
+ return lines
140
+ }
141
+
142
+ /** Extract text from Claude tool_result events, never assistant reasoning. */
143
+ export function claudeToolResultPreviewLines(event: any): ActivityPreviewLine[] {
144
+ if (event?.type !== 'user') return []
145
+ const content = event?.message?.content ?? event?.content
146
+ if (!Array.isArray(content)) return []
147
+ const lines: ActivityPreviewLine[] = []
148
+ for (const block of content) {
149
+ if (block?.type !== 'tool_result') continue
150
+ const text = resultText(block.content)
151
+ for (const preview of textPreviewLines(text)) lines.push({ kind: 'output', text: preview })
152
+ }
153
+ return lines.slice(-3)
154
+ }
155
+
156
+ /** Turn completed Claude tool input JSON into one allowlisted useful line. */
157
+ export function claudeToolInputPreview(name: string, rawJson: string): ActivityPreviewLine | null {
158
+ let input: Record<string, unknown> = {}
159
+ try { input = JSON.parse(rawJson || '{}') } catch { return null }
160
+ const candidate = input.query ?? input.url ?? input.file_path ?? input.path ?? input.command
161
+ const preview = sanitizeActivityPreview(candidate)
162
+ if (!preview) return null
163
+ const label = name === 'WebSearch' ? 'Search'
164
+ : name === 'WebFetch' ? 'Read'
165
+ : name === 'Read' ? 'File'
166
+ : name
167
+ return { kind: 'input', text: `${label}: ${preview}` }
168
+ }
@@ -11,6 +11,7 @@ import { promisify } from 'node:util'
11
11
  import { logTokenAudit } from './token-audit.js'
12
12
  import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
13
  import { consumeArchiveLLMBudget } from './archive-budget.js'
14
+ import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
14
15
 
15
16
  const execAsync = promisify(exec)
16
17
  import type { Exchange } from './conversation.js'
@@ -348,20 +349,30 @@ export function getArchiveChats(date: string): ArchiveChatSummary[] {
348
349
  }))
349
350
  }
350
351
 
351
- /** Get paired Q&A messages for a specific chat within a day */
352
- export function getArchiveChatMessages(date: string, chatIndex: number): Array<{ query: string; text: string; timestamp: number }> {
352
+ /** Get paired Q&A messages for a specific chat within a day. Request refs on
353
+ * the user turn and model-output refs on the assistant turn surface together. */
354
+ export function getArchiveChatMessages(
355
+ date: string,
356
+ chatIndex: number,
357
+ ): Array<{ query: string; text: string; timestamp: number; attachments?: MediaAttachmentRef[] }> {
353
358
  const archive = loadArchive(date)
354
359
  if (!archive) return []
355
360
  const chat = archive.chats.find(c => c.id === chatIndex)
356
361
  if (!chat) return []
357
362
 
358
- const messages: Array<{ query: string; text: string; timestamp: number }> = []
363
+ const messages: Array<{ query: string; text: string; timestamp: number; attachments?: MediaAttachmentRef[] }> = []
359
364
  for (let i = 0; i < chat.exchanges.length; i++) {
360
365
  const ex = chat.exchanges[i]
361
366
  if (ex.role === 'user') {
362
367
  const next = chat.exchanges[i + 1]
363
368
  if (next && next.role === 'assistant') {
364
- messages.push({ query: ex.content, text: next.content, timestamp: next.timestamp })
369
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
370
+ messages.push({
371
+ query: ex.content,
372
+ text: next.content,
373
+ timestamp: next.timestamp,
374
+ ...(attachments.length > 0 ? { attachments } : {}),
375
+ })
365
376
  i++ // skip assistant
366
377
  }
367
378
  }
@@ -374,23 +385,26 @@ export function getArchiveChatMessages(date: string, chatIndex: number): Array<{
374
385
  * (sessionId, timestamp) instead of bare timestamp (collision-prone). */
375
386
  export function getArchiveDayMessages(
376
387
  date: string,
377
- ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> {
388
+ ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> {
378
389
  const archive = loadArchive(date)
379
390
  if (!archive) return []
380
391
 
381
- const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> = []
392
+ const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> = []
382
393
  for (const chat of archive.chats) {
383
394
  for (let i = 0; i < chat.exchanges.length; i++) {
384
395
  const ex = chat.exchanges[i]
385
396
  if (ex.role === 'user') {
386
397
  const next = chat.exchanges[i + 1]
387
398
  if (next && next.role === 'assistant') {
399
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
388
400
  messages.push({
389
401
  query: ex.content,
390
402
  text: next.content,
391
403
  timestamp: next.timestamp,
392
404
  chatIndex: chat.id,
393
405
  sessionId: chat.sessionId,
406
+ ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
407
+ ...(attachments.length > 0 ? { attachments } : {}),
394
408
  })
395
409
  i++
396
410
  }