@gotcos/glasses-server 6.2.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 +116 -0
- package/README.md +26 -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 +65 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +27 -9
- 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/archive.ts +79 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +202 -0
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +299 -0
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
package/server/routes/query.ts
CHANGED
|
@@ -5,7 +5,10 @@ import { Router } from 'express'
|
|
|
5
5
|
import { callModelStreaming } from '../lib/model-router.js'
|
|
6
6
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
7
7
|
import { errMsg } from '../lib/utils.js'
|
|
8
|
-
import { normalizeModelPreference } from '../../shared/model-preference.js'
|
|
8
|
+
import { normalizeEffortPreference, normalizeModelPreference } from '../../shared/model-preference.js'
|
|
9
|
+
import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
|
|
10
|
+
import { getMediaStore } from '../lib/media-store.js'
|
|
11
|
+
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
9
12
|
|
|
10
13
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
11
14
|
WebSearch: 'Searching web...',
|
|
@@ -16,28 +19,35 @@ const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
|
16
19
|
export const queryRouter = Router()
|
|
17
20
|
|
|
18
21
|
queryRouter.post('/query', async (req, res) => {
|
|
19
|
-
const { query, sessionId, model,
|
|
22
|
+
const { query, sessionId, model, effort, reference, globalMsgNum } = req.body
|
|
23
|
+
const activityToolMode = req.body.activityToolMode === 'off' || req.body.activityToolMode === 'preview'
|
|
24
|
+
? req.body.activityToolMode
|
|
25
|
+
: 'status'
|
|
20
26
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Resolve durable attachment ids and legacy base64 images through one
|
|
28
|
+
// validation/normalization path before opening SSE.
|
|
29
|
+
let resolvedAttachments
|
|
30
|
+
try {
|
|
31
|
+
resolvedAttachments = await resolveQueryAttachments(req.body)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err instanceof QueryAttachmentError) {
|
|
34
|
+
return res.status(err.status).json({ error: err.code, detail: err.message })
|
|
35
|
+
}
|
|
36
|
+
return res.status(500).json({ error: errMsg(err) })
|
|
30
37
|
}
|
|
38
|
+
const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
|
|
39
|
+
const attachmentRefs = resolvedAttachments.refs
|
|
31
40
|
|
|
32
41
|
const resolvedQuery = typeof query === 'string' ? query : ''
|
|
33
42
|
|
|
34
43
|
// Vision queries can have an empty query (default to "describe what you see")
|
|
35
|
-
if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !
|
|
44
|
+
if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !imageInputs) {
|
|
36
45
|
return res.status(400).json({ error: 'query string or image required' })
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
// Validate model if provided
|
|
40
49
|
const validModel = normalizeModelPreference(model)
|
|
50
|
+
const validEffort = normalizeEffortPreference(effort)
|
|
41
51
|
|
|
42
52
|
// Validate globalMsgNum if provided
|
|
43
53
|
const validGlobalMsgNum = typeof globalMsgNum === 'number' && globalMsgNum > 0
|
|
@@ -80,15 +90,40 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
80
90
|
},
|
|
81
91
|
onToolStatus: (toolName) => {
|
|
82
92
|
if (!done) {
|
|
83
|
-
const message =
|
|
93
|
+
const message = activityToolMode === 'off'
|
|
94
|
+
? 'Processing...'
|
|
95
|
+
: TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
|
|
84
96
|
res.write(`event: tool_status\ndata: ${JSON.stringify({ message })}\n\n`)
|
|
85
97
|
emitDisplay({ type: 'tool_status', data: { message } })
|
|
86
98
|
}
|
|
87
99
|
},
|
|
100
|
+
// Activity lines stay on this authenticated request stream. The global
|
|
101
|
+
// display stream is intentionally unauthenticated for Even Hub recovery,
|
|
102
|
+
// so observable command/output text must never be broadcast there.
|
|
103
|
+
...(activityToolMode === 'preview' ? {
|
|
104
|
+
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => {
|
|
105
|
+
if (!done) {
|
|
106
|
+
res.write(`event: activity_line\ndata: ${JSON.stringify(line)}\n\n`)
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
} : {}),
|
|
88
110
|
onDone: (fullText, model, cliSessionId, metadata) => {
|
|
111
|
+
// Durable association is independent of the SSE socket. Backgrounding
|
|
112
|
+
// the phone cannot leave request media reserved until expiry.
|
|
113
|
+
if (resolvedAttachments.ids.length > 0) {
|
|
114
|
+
getMediaStore().associate(resolvedAttachments.ids, {
|
|
115
|
+
sessionId: sid,
|
|
116
|
+
...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
|
|
117
|
+
}).catch((err) => console.error('[query] attachment association failed:', err))
|
|
118
|
+
}
|
|
89
119
|
if (!done) {
|
|
90
120
|
done = true
|
|
91
|
-
const
|
|
121
|
+
const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
|
|
122
|
+
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
123
|
+
const payload = {
|
|
124
|
+
text: fullText, sessionId: sid, model, cliSessionId, ...runMetadata,
|
|
125
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
126
|
+
}
|
|
92
127
|
res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
93
128
|
emitDisplay({ type: 'done', data: payload })
|
|
94
129
|
res.end()
|
|
@@ -102,13 +137,13 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
102
137
|
res.end()
|
|
103
138
|
}
|
|
104
139
|
},
|
|
105
|
-
}, validModel,
|
|
140
|
+
}, validModel, imageInputs,
|
|
106
141
|
// Pass reference if provided (for "recall message N" feature)
|
|
107
142
|
reference && typeof reference === 'object' && reference.query && reference.response
|
|
108
143
|
? { query: String(reference.query), response: String(reference.response) }
|
|
109
144
|
: undefined,
|
|
110
145
|
validGlobalMsgNum,
|
|
111
|
-
{ abortSignal: abortController.signal },
|
|
146
|
+
{ abortSignal: abortController.signal, effort: validEffort },
|
|
112
147
|
)
|
|
113
148
|
} catch (err: unknown) {
|
|
114
149
|
if (!done) {
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Session endpoints — recent list, full history, existence check, client-format messages, context breaks, end
|
|
2
|
+
import { Router } from 'express'
|
|
3
|
+
import { readFileSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions } from '../lib/conversation.js'
|
|
6
|
+
import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
|
|
7
|
+
import { getArchiveDayMessages } from '../lib/archive.js'
|
|
8
|
+
import { localDay } from '../lib/local-day.js'
|
|
9
|
+
import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
|
|
10
|
+
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
11
|
+
|
|
12
|
+
export const sessionsRouter = Router()
|
|
13
|
+
|
|
14
|
+
sessionsRouter.get('/sessions/recent', (_req, res) => {
|
|
15
|
+
const sessions = getRecentSessions(24 * 60 * 60_000)
|
|
16
|
+
res.json({ sessions })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// HEAD /api/sessions/:id — lightweight existence check for restore validation
|
|
20
|
+
sessionsRouter.head('/sessions/:id', (req, res) => {
|
|
21
|
+
res.status(sessionExists(req.params.id) ? 200 : 404).end()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// GET /api/sessions/:id/history — full exchange list for session resume
|
|
25
|
+
sessionsRouter.get('/sessions/:id/history', (req, res) => {
|
|
26
|
+
const exchanges = getHistory(req.params.id)
|
|
27
|
+
if (exchanges.length === 0) {
|
|
28
|
+
res.status(404).json({ error: 'Session not found or empty' })
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
res.json({ exchanges })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// POST /api/sessions/:id/context-break — insert a context break (prompt history gate)
|
|
35
|
+
sessionsRouter.post('/sessions/:id/context-break', (req, res) => {
|
|
36
|
+
const ok = addContextBreak(req.params.id)
|
|
37
|
+
if (!ok) {
|
|
38
|
+
res.status(404).json({ error: 'Session not found' })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
res.json({ ok: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// GET /api/sessions/:id/messages — client-compatible format (paired Q&A)
|
|
45
|
+
sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
46
|
+
const exchanges = getHistory(req.params.id)
|
|
47
|
+
if (exchanges.length === 0) {
|
|
48
|
+
res.status(404).json({ error: 'Session not found or empty' })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Pair user+assistant exchanges into client message format. Request refs on
|
|
53
|
+
// the user turn and output refs on the assistant turn become one safe list.
|
|
54
|
+
const messages: Array<{
|
|
55
|
+
query: string
|
|
56
|
+
text: string
|
|
57
|
+
timestamp: number
|
|
58
|
+
no?: number
|
|
59
|
+
sessionId: string
|
|
60
|
+
attachments?: MediaAttachmentRef[]
|
|
61
|
+
}> = []
|
|
62
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
63
|
+
const ex = exchanges[i]
|
|
64
|
+
if (ex.role === 'user') {
|
|
65
|
+
const next = exchanges[i + 1]
|
|
66
|
+
if (next && next.role === 'assistant') {
|
|
67
|
+
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
68
|
+
messages.push({
|
|
69
|
+
query: ex.content,
|
|
70
|
+
text: next.content,
|
|
71
|
+
timestamp: next.timestamp,
|
|
72
|
+
sessionId: req.params.id,
|
|
73
|
+
...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
|
|
74
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
75
|
+
})
|
|
76
|
+
i++ // skip the assistant exchange
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
res.json({ messages })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// POST /api/sessions/:id/end — Explicitly end a session (archive + log + notify)
|
|
85
|
+
// Called by client on "new session", "clear session", or app backgrounding.
|
|
86
|
+
// Prevents data loss — session gets logged to .glasses_sessions.jsonl immediately
|
|
87
|
+
// instead of waiting for the 2hr TTL expiry.
|
|
88
|
+
// POST /api/sessions/lookup — batch resolve timestamps to session IDs
|
|
89
|
+
// Used to retroactively stamp messages that predate the sessionId feature
|
|
90
|
+
sessionsRouter.post('/sessions/lookup', (req, res) => {
|
|
91
|
+
const { timestamps } = req.body as { timestamps: number[] }
|
|
92
|
+
if (!timestamps || !Array.isArray(timestamps)) {
|
|
93
|
+
res.status(400).json({ error: 'timestamps[] required' })
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Build time ranges from BOTH JSONL history AND live server sessions
|
|
98
|
+
const logPath = join(process.env.COS_SCRIPTS_DIR || '', '.glasses_sessions.jsonl')
|
|
99
|
+
const sessionRanges: Array<{ sid: string; start: number; end: number }> = []
|
|
100
|
+
|
|
101
|
+
// 1. Live server sessions (always current — no snapshot lag)
|
|
102
|
+
const recentSessions = getRecentSessions(24 * 60 * 60_000)
|
|
103
|
+
for (const rs of recentSessions) {
|
|
104
|
+
const raw = getSessionRaw(rs.id)
|
|
105
|
+
if (raw) {
|
|
106
|
+
sessionRanges.push({ sid: raw.id, start: raw.createdAt, end: Date.now() }) // extends to NOW
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2. JSONL history (ended sessions + snapshots)
|
|
111
|
+
try {
|
|
112
|
+
const lines = readFileSync(logPath, 'utf-8').trim().split('\n')
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
const d = JSON.parse(line)
|
|
115
|
+
if (d.session_id && d.created_at) {
|
|
116
|
+
const start = new Date(d.created_at).getTime()
|
|
117
|
+
const end = d.ended_at ? new Date(d.ended_at).getTime() : start + 7200_000
|
|
118
|
+
// Live sessions take priority. For JSONL, keep the widest (latest) time range per session.
|
|
119
|
+
const existing = sessionRanges.find(s => s.sid === d.session_id)
|
|
120
|
+
if (!existing) {
|
|
121
|
+
sessionRanges.push({ sid: d.session_id, start, end })
|
|
122
|
+
} else if (end > existing.end && !getSessionRaw(d.session_id)) {
|
|
123
|
+
// Widen the JSONL range (but don't overwrite live sessions which extend to NOW)
|
|
124
|
+
existing.end = end
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch { /* no log file */ }
|
|
129
|
+
|
|
130
|
+
// Match each timestamp to a session (live sessions checked first, then JSONL)
|
|
131
|
+
const results: Record<number, string | null> = {}
|
|
132
|
+
for (const ts of timestamps) {
|
|
133
|
+
let match: string | null = null
|
|
134
|
+
for (const s of sessionRanges) {
|
|
135
|
+
if (ts >= s.start && ts <= s.end) {
|
|
136
|
+
match = s.sid
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
results[ts] = match
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
res.json({ results, sessionsScanned: sessionRanges.length })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
sessionsRouter.post('/sessions/:id/end', async (req, res) => {
|
|
147
|
+
try {
|
|
148
|
+
const result = await endSession(req.params.id)
|
|
149
|
+
if (!result) {
|
|
150
|
+
res.status(404).json({ error: 'Session not found' })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
// When logged === false the archive write failed — we keep the session
|
|
154
|
+
// in the Map for the next mirror to retry, but signal 503 so the client
|
|
155
|
+
// knows NOT to wipe local messages (they're still the user's only copy).
|
|
156
|
+
if (!result.logged && result.exchangeCount > 0) {
|
|
157
|
+
res.status(503).json({
|
|
158
|
+
ok: false,
|
|
159
|
+
error: 'Archive write failed — session retained for retry',
|
|
160
|
+
exchange_count: result.exchangeCount,
|
|
161
|
+
duration_minutes: result.durationMin,
|
|
162
|
+
})
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
clearCodexEngineSession(req.params.id)
|
|
166
|
+
res.json({
|
|
167
|
+
ok: true,
|
|
168
|
+
logged: result.logged,
|
|
169
|
+
exchange_count: result.exchangeCount,
|
|
170
|
+
duration_minutes: result.durationMin,
|
|
171
|
+
})
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error('[sessions] /end unexpected error:', err)
|
|
174
|
+
res.status(500).json({ error: String(err) })
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// POST /api/sessions/:id/snapshot — write live session to .glasses_sessions.jsonl WITHOUT ending it
|
|
179
|
+
// Enables M3 Ultra TUI to read current glasses conversation while session is still active
|
|
180
|
+
sessionsRouter.post('/sessions/:id/snapshot', (req, res) => {
|
|
181
|
+
const session = getSessionRaw(req.params.id)
|
|
182
|
+
if (!session) {
|
|
183
|
+
res.status(404).json({ error: 'Session not found' })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const entry = buildSessionLogEntry({
|
|
188
|
+
id: session.id,
|
|
189
|
+
exchanges: session.exchanges,
|
|
190
|
+
createdAt: session.createdAt,
|
|
191
|
+
lastActivity: session.lastActivity,
|
|
192
|
+
modelPreference: session.modelPreference,
|
|
193
|
+
endReason: 'explicit_end', // marker — will be overwritten when session actually ends
|
|
194
|
+
slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const logged = writeSessionLog(entry)
|
|
198
|
+
res.json({
|
|
199
|
+
ok: true,
|
|
200
|
+
logged,
|
|
201
|
+
session_id: session.id,
|
|
202
|
+
message_count: entry.total_message_count,
|
|
203
|
+
messages_logged: entry.messages.length,
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// GET /api/sessions/today/live-chats — live session chat summaries for today (not yet archived).
|
|
208
|
+
// Date compare is LOCAL time so users chatting in CDT/PST late evening still see their
|
|
209
|
+
// session under "today" instead of "tomorrow UTC".
|
|
210
|
+
// Each summary includes `sessionId` so the client can drill down via
|
|
211
|
+
// `/api/sessions/:id/messages` — index=-1 is a sentinel and is NOT a valid archive chat index.
|
|
212
|
+
sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
|
|
213
|
+
const todayDate = localDay()
|
|
214
|
+
const liveSessions = getActiveSessions()
|
|
215
|
+
const chats: Array<{ index: number; summary: string; exchangeCount: number; startedAt: number; isLive: boolean; sessionId: string }> = []
|
|
216
|
+
|
|
217
|
+
for (const session of liveSessions) {
|
|
218
|
+
const sessionDay = localDay(session.lastActivity)
|
|
219
|
+
if (sessionDay !== todayDate) continue
|
|
220
|
+
if (session.exchanges.length === 0) continue
|
|
221
|
+
|
|
222
|
+
const firstQuery = session.exchanges.find(e => e.role === 'user')?.content ?? ''
|
|
223
|
+
const summary = firstQuery.length > 57 ? firstQuery.slice(0, 54) + '...' : firstQuery || 'Live session'
|
|
224
|
+
|
|
225
|
+
chats.push({
|
|
226
|
+
index: -1,
|
|
227
|
+
summary: `[LIVE] ${summary}`,
|
|
228
|
+
exchangeCount: session.exchanges.length,
|
|
229
|
+
startedAt: session.createdAt,
|
|
230
|
+
isLive: true,
|
|
231
|
+
sessionId: session.id,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
res.json({ chats })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// GET /api/sessions/today/all-messages — merged view of today's archived + live session messages.
|
|
239
|
+
// Dedup key is `sessionId|timestamp` (was bare timestamp, which collided on NTP skew or
|
|
240
|
+
// same-ms adds). `sessionId` is always known for live exchanges; archive messages fall
|
|
241
|
+
// back to the archived chat's sessionId via getArchiveDayMessages.
|
|
242
|
+
sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
243
|
+
const todayDate = localDay()
|
|
244
|
+
|
|
245
|
+
const archivedMessages = getArchiveDayMessages(todayDate).map(m => ({
|
|
246
|
+
...m,
|
|
247
|
+
source: 'archive' as const,
|
|
248
|
+
}))
|
|
249
|
+
|
|
250
|
+
const liveMessages: Array<{
|
|
251
|
+
query: string
|
|
252
|
+
text: string
|
|
253
|
+
timestamp: number
|
|
254
|
+
chatIndex: number
|
|
255
|
+
sessionId: string
|
|
256
|
+
source: 'live'
|
|
257
|
+
no?: number
|
|
258
|
+
attachments?: MediaAttachmentRef[]
|
|
259
|
+
}> = []
|
|
260
|
+
const liveSessions = getActiveSessions()
|
|
261
|
+
for (const session of liveSessions) {
|
|
262
|
+
const sessionDay = localDay(session.lastActivity)
|
|
263
|
+
if (sessionDay !== todayDate) continue
|
|
264
|
+
for (let i = 0; i < session.exchanges.length; i++) {
|
|
265
|
+
const ex = session.exchanges[i]
|
|
266
|
+
if (ex.role === 'user') {
|
|
267
|
+
const next = session.exchanges[i + 1]
|
|
268
|
+
if (next && next.role === 'assistant') {
|
|
269
|
+
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
270
|
+
liveMessages.push({
|
|
271
|
+
query: ex.content,
|
|
272
|
+
text: next.content,
|
|
273
|
+
timestamp: next.timestamp,
|
|
274
|
+
chatIndex: -1,
|
|
275
|
+
sessionId: session.id,
|
|
276
|
+
source: 'live',
|
|
277
|
+
...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
|
|
278
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
279
|
+
})
|
|
280
|
+
i++
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Merge, dedup by (sessionId, timestamp), sort chronologically.
|
|
287
|
+
const seen = new Set<string>()
|
|
288
|
+
const keyOf = (m: { sessionId?: string; timestamp: number }) => `${m.sessionId ?? ''}|${m.timestamp}`
|
|
289
|
+
const merged = [...archivedMessages, ...liveMessages]
|
|
290
|
+
.filter(m => {
|
|
291
|
+
const k = keyOf(m as any)
|
|
292
|
+
if (seen.has(k)) return false
|
|
293
|
+
seen.add(k)
|
|
294
|
+
return true
|
|
295
|
+
})
|
|
296
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
297
|
+
|
|
298
|
+
res.json({ messages: merged, date: todayDate })
|
|
299
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Media attachment contract — the ONE shape both the browser client and the
|
|
2
|
+
// server exchange for image attachments (Release A of the image-attachments
|
|
3
|
+
// plan). The public ref deliberately carries NO storage path, URL, token,
|
|
4
|
+
// base64, checksum, or internal lifecycle state — those live only in the
|
|
5
|
+
// server media index. Anything that persists or transmits an attachment
|
|
6
|
+
// persists THIS shape (or just the id) and nothing else.
|
|
7
|
+
|
|
8
|
+
export type MediaKind = 'user_photo' | 'traffic_frame' | 'generated_visual'
|
|
9
|
+
|
|
10
|
+
export type MediaMime = 'image/jpeg' | 'image/png'
|
|
11
|
+
|
|
12
|
+
export interface MediaAttachmentRef {
|
|
13
|
+
id: string
|
|
14
|
+
kind: MediaKind
|
|
15
|
+
mime: MediaMime
|
|
16
|
+
width: number
|
|
17
|
+
height: number
|
|
18
|
+
createdAt: string
|
|
19
|
+
label?: string
|
|
20
|
+
capturedAt?: string
|
|
21
|
+
expiresAt?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Hard cap on attachments per prompt — mirrored by upload validation,
|
|
25
|
+
* query resolution, and the phone composer. */
|
|
26
|
+
export const MAX_ATTACHMENTS_PER_PROMPT = 5
|
|
27
|
+
|
|
28
|
+
// ── Media IDs ────────────────────────────────────────────────────────────────
|
|
29
|
+
// One strict generated format, one strict validator. The id builds filesystem
|
|
30
|
+
// paths on the server, so the validator rejects anything that isn't exactly
|
|
31
|
+
// `m_` + 24 lowercase hex chars — no path characters can ever pass.
|
|
32
|
+
|
|
33
|
+
export const MEDIA_ID_RE = /^m_[a-f0-9]{24}$/
|
|
34
|
+
|
|
35
|
+
export function isValidMediaId(id: unknown): id is string {
|
|
36
|
+
return typeof id === 'string' && MEDIA_ID_RE.test(id)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const VALID_KINDS: ReadonlySet<string> = new Set(['user_photo', 'traffic_frame', 'generated_visual'])
|
|
40
|
+
const VALID_MIMES: ReadonlySet<string> = new Set(['image/jpeg', 'image/png'])
|
|
41
|
+
const MAX_LABEL_LEN = 120
|
|
42
|
+
// ISO-8601 subset — what `new Date().toISOString()` emits.
|
|
43
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/
|
|
44
|
+
|
|
45
|
+
function isIsoTimestamp(v: unknown): v is string {
|
|
46
|
+
return typeof v === 'string' && ISO_RE.test(v) && Number.isFinite(new Date(v).getTime())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isDimension(v: unknown): v is number {
|
|
50
|
+
return typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= 65_535
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Validate an UNTRUSTED value into a MediaAttachmentRef, or null.
|
|
54
|
+
* TypeScript types alone are not validation — every persistence and API
|
|
55
|
+
* boundary that accepts a ref from outside must run it through here.
|
|
56
|
+
* Returns a fresh object containing only the known fields (drops extras). */
|
|
57
|
+
export function parseMediaAttachmentRef(raw: unknown): MediaAttachmentRef | null {
|
|
58
|
+
if (!raw || typeof raw !== 'object') return null
|
|
59
|
+
const r = raw as Record<string, unknown>
|
|
60
|
+
if (!isValidMediaId(r.id)) return null
|
|
61
|
+
if (typeof r.kind !== 'string' || !VALID_KINDS.has(r.kind)) return null
|
|
62
|
+
if (typeof r.mime !== 'string' || !VALID_MIMES.has(r.mime)) return null
|
|
63
|
+
if (!isDimension(r.width) || !isDimension(r.height)) return null
|
|
64
|
+
if (!isIsoTimestamp(r.createdAt)) return null
|
|
65
|
+
const ref: MediaAttachmentRef = {
|
|
66
|
+
id: r.id,
|
|
67
|
+
kind: r.kind as MediaKind,
|
|
68
|
+
mime: r.mime as MediaMime,
|
|
69
|
+
width: r.width,
|
|
70
|
+
height: r.height,
|
|
71
|
+
createdAt: r.createdAt,
|
|
72
|
+
}
|
|
73
|
+
if (typeof r.label === 'string' && r.label.length > 0) {
|
|
74
|
+
ref.label = r.label.slice(0, MAX_LABEL_LEN)
|
|
75
|
+
}
|
|
76
|
+
if (isIsoTimestamp(r.capturedAt)) ref.capturedAt = r.capturedAt
|
|
77
|
+
if (isIsoTimestamp(r.expiresAt)) ref.expiresAt = r.expiresAt
|
|
78
|
+
return ref
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Validate an untrusted array of refs, dropping only the invalid entries
|
|
82
|
+
* (a bad ref must never take the whole conversation record with it). */
|
|
83
|
+
export function parseMediaAttachmentRefs(raw: unknown): MediaAttachmentRef[] {
|
|
84
|
+
if (!Array.isArray(raw)) return []
|
|
85
|
+
const out: MediaAttachmentRef[] = []
|
|
86
|
+
for (const item of raw) {
|
|
87
|
+
const ref = parseMediaAttachmentRef(item)
|
|
88
|
+
if (ref) out.push(ref)
|
|
89
|
+
if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) break
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Validate, merge, and de-duplicate attachment refs from multiple untrusted
|
|
95
|
+
* exchange surfaces. A completed Q&A pair can carry request refs on the user
|
|
96
|
+
* turn and generated/research refs on the assistant turn; readers should see
|
|
97
|
+
* one bounded list without trusting either persisted shape. First occurrence
|
|
98
|
+
* wins so the request-side ref remains stable when the server echoes it back
|
|
99
|
+
* in completion metadata. */
|
|
100
|
+
export function mergeMediaAttachmentRefs(...sources: unknown[]): MediaAttachmentRef[] {
|
|
101
|
+
const out: MediaAttachmentRef[] = []
|
|
102
|
+
const seen = new Set<string>()
|
|
103
|
+
for (const source of sources) {
|
|
104
|
+
if (!Array.isArray(source)) continue
|
|
105
|
+
for (const item of source) {
|
|
106
|
+
const ref = parseMediaAttachmentRef(item)
|
|
107
|
+
if (!ref || seen.has(ref.id)) continue
|
|
108
|
+
seen.add(ref.id)
|
|
109
|
+
out.push(ref)
|
|
110
|
+
if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) return out
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Validate an untrusted list of media IDs (dedup, cap, strict format). */
|
|
117
|
+
export function parseMediaIdList(raw: unknown): string[] {
|
|
118
|
+
if (!Array.isArray(raw)) return []
|
|
119
|
+
const out: string[] = []
|
|
120
|
+
for (const item of raw) {
|
|
121
|
+
if (!isValidMediaId(item) || out.includes(item)) continue
|
|
122
|
+
out.push(item)
|
|
123
|
+
if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) break
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|