@gotcos/glasses-server 6.3.1 → 6.5.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 +18 -7
- package/CHANGELOG.md +83 -0
- package/README.md +24 -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 +57 -15
- 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/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-lock.ts +122 -0
- package/server/routes/health.ts +17 -1
- 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
package/server/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Load
|
|
2
|
-
|
|
3
|
-
import './env.js'
|
|
1
|
+
// Load env and claim the one server slot before any mutable module initializes.
|
|
2
|
+
import './bootstrap.js'
|
|
4
3
|
|
|
5
4
|
import express from 'express'
|
|
6
5
|
import cors from 'cors'
|
|
7
6
|
import path from 'node:path'
|
|
8
7
|
import { fileURLToPath } from 'node:url'
|
|
9
8
|
import { createServer as createHttpsServer } from 'node:https'
|
|
9
|
+
import { createServer as createHttpServer } from 'node:http'
|
|
10
10
|
import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
|
|
11
11
|
import { networkInterfaces, homedir } from 'node:os'
|
|
12
12
|
import { join } from 'node:path'
|
|
@@ -23,13 +23,21 @@ import { openaiKeyRouter } from './routes/openai-key.js'
|
|
|
23
23
|
import { messageRefRouter } from './routes/message-ref.js'
|
|
24
24
|
import { archiveRouter } from './routes/archive.js'
|
|
25
25
|
import { sessionsRouter } from './routes/sessions.js'
|
|
26
|
+
import { mediaRouter, mediaBodyParser } from './routes/media.js'
|
|
26
27
|
import { prewarmContext } from './lib/context-builder.js'
|
|
27
28
|
import { preWarmCLI } from './lib/claude-bridge.js'
|
|
29
|
+
import { getCodexRunConfig } from './lib/codex-run-ledger.js'
|
|
30
|
+
import {
|
|
31
|
+
startCodexModelCatalogRefresh,
|
|
32
|
+
stopCodexModelCatalogRefresh,
|
|
33
|
+
} from './lib/codex-model-catalog.js'
|
|
28
34
|
import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
|
|
29
35
|
import { initSileroVAD } from './lib/vad-silero.js'
|
|
30
36
|
import { initSessionCache } from './lib/session-cache-writer.js'
|
|
31
37
|
import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
|
|
32
38
|
import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
|
|
39
|
+
import { getMediaStore } from './lib/media-store.js'
|
|
40
|
+
import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
|
|
33
41
|
|
|
34
42
|
const app = express()
|
|
35
43
|
const PORT = parseInt(process.env.PORT ?? '3141', 10)
|
|
@@ -99,8 +107,6 @@ app.use(cors({
|
|
|
99
107
|
cb(new Error('CORS blocked'))
|
|
100
108
|
},
|
|
101
109
|
}))
|
|
102
|
-
app.use(express.json({ limit: '10mb' }))
|
|
103
|
-
|
|
104
110
|
// Auth middleware — always active (token is auto-generated if not set)
|
|
105
111
|
app.use('/api', (req, res, next) => {
|
|
106
112
|
// Allow health checks, display stream, and client diagnostics without auth.
|
|
@@ -120,6 +126,11 @@ app.use('/api', (req, res, next) => {
|
|
|
120
126
|
next()
|
|
121
127
|
})
|
|
122
128
|
|
|
129
|
+
// Authenticate before parsing large upload bodies. The 16 MB allowance stays
|
|
130
|
+
// scoped to /api/media; every other route retains the 10 MB ceiling.
|
|
131
|
+
app.use('/api/media', mediaBodyParser)
|
|
132
|
+
app.use(express.json({ limit: '10mb' }))
|
|
133
|
+
|
|
123
134
|
// Request counter — must be before route registrations
|
|
124
135
|
app.use((_req, _res, next) => {
|
|
125
136
|
serverMetrics.requestCount++
|
|
@@ -139,6 +150,7 @@ app.use('/api', openaiKeyRouter)
|
|
|
139
150
|
app.use('/api', messageRefRouter)
|
|
140
151
|
app.use('/api', archiveRouter)
|
|
141
152
|
app.use('/api', sessionsRouter)
|
|
153
|
+
app.use('/api', mediaRouter)
|
|
142
154
|
|
|
143
155
|
// OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
|
|
144
156
|
// Mounted at root — routes are /v1/chat/completions and /v1/models
|
|
@@ -164,12 +176,19 @@ process.on('SIGTERM', () => {
|
|
|
164
176
|
// Production stops (kill, service managers) send SIGTERM — flush session logs
|
|
165
177
|
// exactly like SIGINT so active conversations aren't lost on shutdown.
|
|
166
178
|
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
179
|
+
stopCodexModelCatalogRefresh()
|
|
180
|
+
stopWhisperServer()
|
|
181
|
+
process.exit(0)
|
|
182
|
+
})
|
|
183
|
+
process.on('SIGINT', () => {
|
|
184
|
+
logActiveSessionsOnShutdown()
|
|
185
|
+
stopCodexModelCatalogRefresh()
|
|
167
186
|
stopWhisperServer()
|
|
168
187
|
process.exit(0)
|
|
169
188
|
})
|
|
170
|
-
process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
|
|
171
189
|
|
|
172
|
-
// Crash protection
|
|
190
|
+
// Crash protection for runtime work. Listener failures are handled separately
|
|
191
|
+
// and exit immediately so a supervisor can restart a clean, unified process.
|
|
173
192
|
process.on('uncaughtException', (err) => {
|
|
174
193
|
console.error('[CRASH GUARD] Uncaught exception (server stays alive):', err.message)
|
|
175
194
|
console.error(err.stack)
|
|
@@ -184,19 +203,24 @@ process.on('unhandledRejection', (reason: any) => {
|
|
|
184
203
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
185
204
|
const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
|
|
186
205
|
const certDir = path.join(__dirname, 'certs')
|
|
206
|
+
const listeners: RequiredListener[] = []
|
|
187
207
|
if (existsSync(path.join(certDir, 'cert.pem'))) {
|
|
188
208
|
const httpsServer = createHttpsServer({
|
|
189
209
|
cert: readFileSync(path.join(certDir, 'cert.pem')),
|
|
190
210
|
key: readFileSync(path.join(certDir, 'key.pem')),
|
|
191
211
|
}, app)
|
|
192
|
-
|
|
193
|
-
console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
|
|
194
|
-
})
|
|
212
|
+
listeners.push({ server: httpsServer, port: HTTPS_PORT, host: BIND_HOST, label: 'HTTPS' })
|
|
195
213
|
} else {
|
|
196
214
|
console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
|
|
197
215
|
}
|
|
198
216
|
|
|
199
|
-
|
|
217
|
+
const httpServer = createHttpServer(app)
|
|
218
|
+
listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
|
|
219
|
+
|
|
220
|
+
listenRequiredServers(listeners).then(() => {
|
|
221
|
+
if (listeners.some(listener => listener.label === 'HTTPS')) {
|
|
222
|
+
console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
|
|
223
|
+
}
|
|
200
224
|
console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
|
|
201
225
|
console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
|
|
202
226
|
|
|
@@ -228,15 +252,25 @@ app.listen(PORT, BIND_HOST, () => {
|
|
|
228
252
|
console.log('')
|
|
229
253
|
}
|
|
230
254
|
|
|
231
|
-
// Check Claude CLI availability
|
|
255
|
+
// Check Claude CLI availability. Codex-only installs remain fully valid.
|
|
256
|
+
let claudeAvailable = false
|
|
232
257
|
try {
|
|
233
258
|
execSync('claude --version', { timeout: 5000, stdio: 'pipe' })
|
|
259
|
+
claudeAvailable = true
|
|
234
260
|
console.log('[COS API] Claude Code CLI detected')
|
|
235
261
|
} catch {
|
|
236
262
|
console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
|
|
237
|
-
console.warn('[COS API]
|
|
263
|
+
console.warn('[COS API] Claude models unavailable; Codex models still work when Codex CLI is installed')
|
|
238
264
|
}
|
|
239
265
|
|
|
266
|
+
const codexConfig = getCodexRunConfig()
|
|
267
|
+
console.log(`[COS API] Codex mode: ${codexConfig.persistenceEnabled ? 'persistent' : 'ephemeral'} · ${codexConfig.reasoningEffort} · ${codexConfig.trustMode}`)
|
|
268
|
+
console.log(`[COS API] Codex models (${codexConfig.catalogSource}): ${codexConfig.availableModels.map(item => `${item.displayName}=${item.model}`).join(' · ')}`)
|
|
269
|
+
console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
|
|
270
|
+
// Refresh immediately and then periodically. The catalog module retains the
|
|
271
|
+
// last-known-good snapshot if Codex is temporarily unavailable.
|
|
272
|
+
startCodexModelCatalogRefresh()
|
|
273
|
+
|
|
240
274
|
if (COS_MODE) {
|
|
241
275
|
initSessionCache()
|
|
242
276
|
// Pre-warm context cache so first query doesn't wait for the pipeline
|
|
@@ -251,9 +285,17 @@ app.listen(PORT, BIND_HOST, () => {
|
|
|
251
285
|
const vadOk = initSileroVAD()
|
|
252
286
|
console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
|
|
253
287
|
|
|
254
|
-
// Pre-warm
|
|
255
|
-
|
|
288
|
+
// Pre-warm Claude only when installed so Codex-only startup stays quiet.
|
|
289
|
+
if (claudeAvailable) {
|
|
290
|
+
preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
|
|
291
|
+
}
|
|
256
292
|
|
|
257
293
|
// Auto-snapshot active sessions every 5 min (survives restarts)
|
|
258
294
|
startAutoSnapshot(5 * 60_000)
|
|
295
|
+
|
|
296
|
+
// Durable media GC (staged/reserved expiry + generated-image content TTL).
|
|
297
|
+
getMediaStore().startGC()
|
|
298
|
+
}).catch((error: NodeJS.ErrnoException) => {
|
|
299
|
+
console.error(`[COS API] Fatal listener startup: ${error.message}`)
|
|
300
|
+
process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
|
|
259
301
|
})
|
|
@@ -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
|
}
|