@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.
- package/.env.example +23 -7
- package/CHANGELOG.md +108 -0
- package/README.md +28 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +61 -21
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +20 -6
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/display-bus.ts +61 -3
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-id.ts +55 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/lib/server-metrics.ts +7 -0
- package/server/routes/display.ts +43 -22
- package/server/routes/health.ts +19 -2
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +18 -6
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +33 -4
- package/shared/media-attachment.ts +126 -0
- 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
|
+
}
|
package/server/lib/archive.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|