@gotcos/glasses-server 6.2.1 → 6.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.3.1
4
+
5
+ Security + robustness hardening on the 6.3.0 archive routes, from a 3-agent QA pass. (6.3.0 was never published; 6.3.1 is the first release of the expanded route set.)
6
+
7
+ - **SECURITY — path traversal blocked.** The new `:date` archive routes fed the param straight into `<dir>/${date}.json`, so an encoded traversal (`/api/archive/..%2F..%2Fetc%2Fhosts`) could read arbitrary `*.json` on the host (and rename-corrupt one via the quarantine path). Auth+IP gated, but a real exposure on a shared LAN/meshnet. Fixed: `archiveRouter.param('date', …)` enforces `^\d{4}-\d{2}-\d{2}$` on every `:date` route before any fs access; defense-in-depth guard in `readArchiveChatNumbered`. Verified: traversal/bad-format → 400, valid dates → 200.
8
+ - **Reference date label (US evenings).** Live-session `reference message N` stamped the date with UTC, labeling an evening reference with tomorrow's date. Now `localDay()`.
9
+ - **Malformed day file no longer wipes History.** A valid-JSON wrong-shape day file (no `chats[]`) 500'd the readers and dropped `listArchiveDates` into its catch, hiding all history. `loadArchive` coerces `chats` to `[]`; the bad day lists as 0 chats.
10
+ - **Thrift/cosmetic:** `/api/archive/now` passes `skipLLM:true` (no surprise LLM spend on a public manual snapshot); stale path comment + unused `__dirname` removed from `lib/archive.ts`.
11
+
12
+ ## 6.3.0
13
+
14
+ Message History, cross-day references, and history recovery for public installs.
15
+ These features previously required a full COS server; now `npx @gotcos/glasses-server`
16
+ exposes them too, so the G2 app's Message History and "reference message N" work
17
+ on a vanilla install.
18
+
19
+ - **Message History** — the archive routes (`/api/archive`, `/api/archive/:date/chats`,
20
+ `/api/archive/:date/chats/:i/messages`, `/api/archive/:date/messages`, `/api/archive/now`)
21
+ are now served. The daily archive-mirror (already in this package) writes prior-day
22
+ sessions to disk; these routes browse them. Each day row shows chat count + topic.
23
+ - **Cross-day "reference message N"** — new `/api/message/:num` resolves a permanent
24
+ message number across live sessions then day archives (newest-first), and
25
+ `/api/message-counter` publishes the numbering ceiling so a fresh/cleared client
26
+ never reuses a number. Message numbers were already stored (`globalMsgNum`); this
27
+ makes them resolvable.
28
+ - **History recovery** — session routes (`/api/sessions/today/all-messages`,
29
+ `/api/sessions/:id/messages`, recent-sessions index, context-break, end-session)
30
+ let the app restore recent history and open archived chats.
31
+
32
+ No change to the public-safe model curation (Sonnet default, no pinned/unreleased
33
+ model ids) or the core query/voice/display paths. Typecheck clean; new routes
34
+ smoke-tested (message-counter, archive list, message lookup).
35
+
3
36
  ## 6.2.1
4
37
 
5
38
  Foolproofing release — driven by an adversarial onboarding QA pass.
package/README.md CHANGED
@@ -44,6 +44,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
44
44
  ## What it does
45
45
 
46
46
  - Ask anything, get a streamed answer on the lens (`/api/query`, `/v1/chat/completions`)
47
+ - Message History + cross-day "reference message N" — your chats are archived by day
48
+ and every message keeps a permanent number you can recall (`/api/archive`, `/api/message/:num`)
47
49
  - Live voice capture + transcription during meetings
48
50
  - Local whisper.cpp transcription (free) with OpenAI fallback (optional)
49
51
  - Tasks / calendar / people context **if** you run the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.2.1",
3
+ "version": "6.3.1",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -20,6 +20,9 @@ import { displayRouter } from './routes/display.js'
20
20
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
21
21
  import { openaiCompatRouter } from './routes/openai-compat.js'
22
22
  import { openaiKeyRouter } from './routes/openai-key.js'
23
+ import { messageRefRouter } from './routes/message-ref.js'
24
+ import { archiveRouter } from './routes/archive.js'
25
+ import { sessionsRouter } from './routes/sessions.js'
23
26
  import { prewarmContext } from './lib/context-builder.js'
24
27
  import { preWarmCLI } from './lib/claude-bridge.js'
25
28
  import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
@@ -131,6 +134,11 @@ app.use('/api', transcribeRouter)
131
134
  app.use('/api', displayRouter)
132
135
  app.use('/api', transcribeStreamRouter)
133
136
  app.use('/api', openaiKeyRouter)
137
+ // v6.3.0 — Message History, cross-day 'reference message N', and history
138
+ // recovery for public npx users (previously full-COS-server only).
139
+ app.use('/api', messageRefRouter)
140
+ app.use('/api', archiveRouter)
141
+ app.use('/api', sessionsRouter)
134
142
 
135
143
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
136
144
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -1,5 +1,5 @@
1
1
  // Daily archive system — persists conversation history beyond session TTL
2
- // Archives are stored as JSON files per day in server/data/archive/
2
+ // Archives are stored as JSON files per day in ~/.cos-glasses/data/archive/
3
3
  // Each day's archive contains one or more "chats" (split by context breaks)
4
4
  // Summaries are generated via `claude -p --model sonnet`, budget-capped per day.
5
5
 
@@ -15,7 +15,6 @@ import { consumeArchiveLLMBudget } from './archive-budget.js'
15
15
  const execAsync = promisify(exec)
16
16
  import type { Exchange } from './conversation.js'
17
17
 
18
- const __dirname = dirname(fileURLToPath(import.meta.url))
19
18
  import { dataPath } from './data-dir.js'
20
19
  const ARCHIVE_DIR = dataPath('archive')
21
20
 
@@ -87,7 +86,12 @@ export function loadArchive(date: string): DailyArchive | null {
87
86
  return null
88
87
  }
89
88
  if (result.status === 'missing') return null
90
- return result.data
89
+ // Defense: a valid-JSON but wrong-shape day file (no chats[]) would make the
90
+ // readers throw 500 AND drop listArchiveDates into its catch → the whole
91
+ // Message History list vanishes on one bad file. Coerce to an empty day.
92
+ const data = result.data
93
+ if (data && !Array.isArray(data.chats)) data.chats = []
94
+ return data
91
95
  }
92
96
 
93
97
  function saveArchive(archive: DailyArchive): void {
@@ -0,0 +1,79 @@
1
+ // Archive endpoints — daily conversation archive for glasses history browser
2
+ import { Router } from 'express'
3
+ import { listArchiveDates, loadArchive, getArchiveChats, getArchiveDayMessages, appendToArchive } from '../lib/archive.js'
4
+ import { getArchiveChatMessagesNumbered } from './message-ref.js'
5
+ import { getActiveSessions } from '../lib/conversation.js'
6
+
7
+ export const archiveRouter = Router()
8
+
9
+ // v5.15.6 / pkg v6.3.1 — SECURITY: :date is used to build filesystem paths
10
+ // (loadArchive/getArchiveChats/getArchiveDayMessages/getArchiveChatMessagesNumbered
11
+ // all resolve `<dir>/${date}.json`). Without validation, an encoded traversal
12
+ // (e.g. /api/archive/..%2F..%2Fetc%2Fhosts) reads/renames arbitrary *.json on
13
+ // the host. Validate the segment as a strict YYYY-MM-DD once for every :date
14
+ // route before any fs access.
15
+ archiveRouter.param('date', (req, res, next, date) => {
16
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
17
+ res.status(400).json({ error: 'Invalid date' })
18
+ return
19
+ }
20
+ next()
21
+ })
22
+
23
+ // GET /api/archive — list all archive dates with summaries
24
+ archiveRouter.get('/archive', (_req, res) => {
25
+ const archives = listArchiveDates()
26
+ res.json({ archives })
27
+ })
28
+
29
+ // POST /api/archive/now — snapshot active sessions into today's archive (non-destructive)
30
+ archiveRouter.post('/archive/now', async (_req, res) => {
31
+ const activeSessions = getActiveSessions()
32
+ if (activeSessions.length === 0) {
33
+ res.json({ archived: 0, date: new Date().toISOString().slice(0, 10) })
34
+ return
35
+ }
36
+
37
+ const todayDate = new Date().toISOString().slice(0, 10)
38
+ let archived = 0
39
+ for (const session of activeSessions) {
40
+ await appendToArchive(todayDate, session, { skipLLM: true }) // public thrift: no surprise LLM spend on a manual snapshot
41
+ archived++
42
+ }
43
+
44
+ res.json({ archived, date: todayDate })
45
+ })
46
+
47
+ // GET /api/archive/:date — full daily archive
48
+ archiveRouter.get('/archive/:date', (req, res) => {
49
+ const archive = loadArchive(req.params.date)
50
+ if (!archive) {
51
+ res.status(404).json({ error: 'Archive not found for date' })
52
+ return
53
+ }
54
+ res.json(archive)
55
+ })
56
+
57
+ // GET /api/archive/:date/chats — chat summaries for a day
58
+ archiveRouter.get('/archive/:date/chats', (req, res) => {
59
+ const chats = getArchiveChats(req.params.date)
60
+ res.json({ chats })
61
+ })
62
+
63
+ // GET /api/archive/:date/chats/:index/messages — paired Q&A for a specific chat
64
+ archiveRouter.get('/archive/:date/chats/:index/messages', (req, res) => {
65
+ const index = parseInt(req.params.index, 10)
66
+ if (isNaN(index)) {
67
+ res.status(400).json({ error: 'Invalid chat index' })
68
+ return
69
+ }
70
+ // v5.15.1 — numbered form so the browser can show the durable Msg #N
71
+ const messages = getArchiveChatMessagesNumbered(req.params.date, index)
72
+ res.json({ messages })
73
+ })
74
+
75
+ // GET /api/archive/:date/messages — all messages for a day (flat)
76
+ archiveRouter.get('/archive/:date/messages', (req, res) => {
77
+ const messages = getArchiveDayMessages(req.params.date)
78
+ res.json({ messages })
79
+ })
@@ -0,0 +1,190 @@
1
+ // Global message reference resolution (v5.15.0) — the server half of
2
+ // "reference message N" across days. Numbers are stamped at exchange time
3
+ // (client-sent, stored via conversation.addExchange) and persist durably in
4
+ // the day archives; this router resolves a number the client no longer holds
5
+ // in its local list, and publishes the numbering ceiling so a cleared or
6
+ // fresh client continues the sequence instead of reusing numbers.
7
+ //
8
+ // GET /api/message/:num → { globalMsgNum, date, query, response } (404 when unknown)
9
+ // GET /api/message-counter → { max }
10
+ //
11
+ // Resolution order (per the prompt-queue/archive plan): live in-memory
12
+ // sessions first (covers the mirror's 15-minute lag), then day archives
13
+ // newest-first. Day files are read as plain data — their write path belongs
14
+ // to the archive workstream and is not touched here.
15
+ import { Router } from 'express'
16
+ import { readdirSync, readFileSync } from 'fs'
17
+ import { resolve } from 'path'
18
+ import { getActiveSessions } from '../lib/conversation.js'
19
+ import { dataPath } from '../lib/data-dir.js'
20
+ import { localDay } from '../lib/local-day.js'
21
+
22
+ // v6.3.0 — read archives from the SAME persistent location the archive-mirror
23
+ // writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
24
+ // dir. The app repo uses server/data/archive; the public package uses dataPath,
25
+ // so this file must match the package's lib/archive.ts, or npx users' cross-day
26
+ // references + archive-chat detail read an empty/nonexistent directory.
27
+ const ARCHIVE_DIR = dataPath('archive')
28
+
29
+ export interface ResolvedGlobalMessage {
30
+ globalMsgNum: number
31
+ date: string
32
+ query: string
33
+ response: string
34
+ }
35
+
36
+ interface ExchangeLike {
37
+ role?: string
38
+ content?: string
39
+ timestamp?: number
40
+ globalMsgNum?: number
41
+ }
42
+
43
+ /** Pair the stamped exchange with its other half: a user turn pairs forward
44
+ * to the next assistant turn; an assistant turn pairs backward. */
45
+ function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; response: string } {
46
+ const hit = exchanges[i]
47
+ const user = hit.role === 'user'
48
+ ? hit
49
+ : [...exchanges.slice(0, i)].reverse().find((e) => e?.role === 'user')
50
+ const assistant = hit.role === 'assistant'
51
+ ? hit
52
+ : exchanges.slice(i + 1).find((e) => e?.role === 'assistant')
53
+ return { query: user?.content ?? '', response: assistant?.content ?? '' }
54
+ }
55
+
56
+ function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): ResolvedGlobalMessage | null {
57
+ for (let i = 0; i < exchanges.length; i++) {
58
+ if (exchanges[i]?.globalMsgNum !== num) continue
59
+ const { query, response } = pairExchange(exchanges, i)
60
+ return { globalMsgNum: num, date, query, response }
61
+ }
62
+ return null
63
+ }
64
+
65
+ /** Resolve a global message number from the day archives, newest-first.
66
+ * Exported with an explicit dir for tests. */
67
+ export function resolveFromArchiveDir(dir: string, num: number): ResolvedGlobalMessage | null {
68
+ let files: string[] = []
69
+ try {
70
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse()
71
+ } catch {
72
+ return null
73
+ }
74
+ for (const f of files) {
75
+ try {
76
+ const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
77
+ const chats = Array.isArray(day?.chats) ? day.chats : []
78
+ for (const chat of chats) {
79
+ const exchanges = Array.isArray(chat?.exchanges) ? chat.exchanges : []
80
+ const hit = scanExchanges(exchanges, num, typeof day?.date === 'string' ? day.date : f.slice(0, 10))
81
+ if (hit) return hit
82
+ }
83
+ } catch {
84
+ // Unreadable/corrupt day file — skip; the archive workstream owns repair.
85
+ }
86
+ }
87
+ return null
88
+ }
89
+
90
+ /** Read a specific archived chat's paired Q&A messages WITH their durable
91
+ * global numbers (the archive-lib read path strips globalMsgNum; the browser
92
+ * needs it so "reference message N" is self-evident from the screen). Same
93
+ * user->next-assistant pairing as the lib; the pair's number is the user
94
+ * turn's stamp (falling back to the assistant's). Dir-param form for tests. */
95
+ export function readArchiveChatNumbered(
96
+ dir: string,
97
+ date: string,
98
+ chatIndex: number,
99
+ ): Array<{ query: string; text: string; timestamp: number; no?: number }> {
100
+ // Defense-in-depth against path traversal — `date` builds a `${date}.json`
101
+ // path. The archive route also validates, but this is exported/reused.
102
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return []
103
+ let day: { chats?: Array<{ id?: number; exchanges?: ExchangeLike[] }> }
104
+ try {
105
+ day = JSON.parse(readFileSync(resolve(dir, `${date}.json`), 'utf8'))
106
+ } catch {
107
+ return []
108
+ }
109
+ const chat = (Array.isArray(day?.chats) ? day.chats : []).find((c) => c?.id === chatIndex)
110
+ if (!chat) return []
111
+ const exchanges: ExchangeLike[] = Array.isArray(chat.exchanges) ? chat.exchanges : []
112
+ const out: Array<{ query: string; text: string; timestamp: number; no?: number }> = []
113
+ for (let i = 0; i < exchanges.length; i++) {
114
+ const ex = exchanges[i]
115
+ if (ex?.role !== 'user') continue
116
+ const next = exchanges[i + 1]
117
+ if (next?.role !== 'assistant') continue
118
+ out.push({
119
+ query: ex.content ?? '',
120
+ text: next.content ?? '',
121
+ timestamp: next.timestamp ?? ex.timestamp ?? 0,
122
+ no: ex.globalMsgNum ?? next.globalMsgNum,
123
+ })
124
+ i++
125
+ }
126
+ return out
127
+ }
128
+
129
+ /** ARCHIVE_DIR-bound form for the route. */
130
+ export function getArchiveChatMessagesNumbered(date: string, chatIndex: number) {
131
+ return readArchiveChatNumbered(ARCHIVE_DIR, date, chatIndex)
132
+ }
133
+
134
+ /** Highest stamped number across the day archives (0 when none). */
135
+ export function maxGlobalMsgNumInDir(dir: string): number {
136
+ let max = 0
137
+ let files: string[] = []
138
+ try {
139
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
140
+ } catch {
141
+ return 0
142
+ }
143
+ for (const f of files) {
144
+ try {
145
+ const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
146
+ for (const chat of Array.isArray(day?.chats) ? day.chats : []) {
147
+ for (const ex of Array.isArray(chat?.exchanges) ? chat.exchanges : []) {
148
+ if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > max) max = ex.globalMsgNum
149
+ }
150
+ }
151
+ } catch { /* skip */ }
152
+ }
153
+ return max
154
+ }
155
+
156
+ function resolveFromLiveSessions(num: number): ResolvedGlobalMessage | null {
157
+ const today = localDay() // local calendar day, not UTC — a live ref in the user's evening must not label tomorrow
158
+ for (const session of getActiveSessions()) {
159
+ const exchanges = (session as { exchanges?: ExchangeLike[] }).exchanges ?? []
160
+ const hit = scanExchanges(exchanges, num, today)
161
+ if (hit) return hit
162
+ }
163
+ return null
164
+ }
165
+
166
+ export const messageRefRouter = Router()
167
+
168
+ messageRefRouter.get('/message/:num', (req, res) => {
169
+ const num = Number.parseInt(req.params.num, 10)
170
+ if (!Number.isFinite(num) || num < 1) {
171
+ res.status(400).json({ error: 'invalid message number' })
172
+ return
173
+ }
174
+ const hit = resolveFromLiveSessions(num) ?? resolveFromArchiveDir(ARCHIVE_DIR, num)
175
+ if (!hit) {
176
+ res.status(404).json({ error: `message ${num} not found` })
177
+ return
178
+ }
179
+ res.json(hit)
180
+ })
181
+
182
+ messageRefRouter.get('/message-counter', (_req, res) => {
183
+ let liveMax = 0
184
+ for (const session of getActiveSessions()) {
185
+ for (const ex of ((session as { exchanges?: ExchangeLike[] }).exchanges ?? [])) {
186
+ if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
187
+ }
188
+ }
189
+ res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR)) })
190
+ })
@@ -0,0 +1,270 @@
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
+
11
+ export const sessionsRouter = Router()
12
+
13
+ sessionsRouter.get('/sessions/recent', (_req, res) => {
14
+ const sessions = getRecentSessions(24 * 60 * 60_000)
15
+ res.json({ sessions })
16
+ })
17
+
18
+ // HEAD /api/sessions/:id — lightweight existence check for restore validation
19
+ sessionsRouter.head('/sessions/:id', (req, res) => {
20
+ res.status(sessionExists(req.params.id) ? 200 : 404).end()
21
+ })
22
+
23
+ // GET /api/sessions/:id/history — full exchange list for session resume
24
+ sessionsRouter.get('/sessions/:id/history', (req, res) => {
25
+ const exchanges = getHistory(req.params.id)
26
+ if (exchanges.length === 0) {
27
+ res.status(404).json({ error: 'Session not found or empty' })
28
+ return
29
+ }
30
+ res.json({ exchanges })
31
+ })
32
+
33
+ // POST /api/sessions/:id/context-break — insert a context break (prompt history gate)
34
+ sessionsRouter.post('/sessions/:id/context-break', (req, res) => {
35
+ const ok = addContextBreak(req.params.id)
36
+ if (!ok) {
37
+ res.status(404).json({ error: 'Session not found' })
38
+ return
39
+ }
40
+ res.json({ ok: true })
41
+ })
42
+
43
+ // GET /api/sessions/:id/messages — client-compatible format (paired Q&A)
44
+ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
45
+ const exchanges = getHistory(req.params.id)
46
+ if (exchanges.length === 0) {
47
+ res.status(404).json({ error: 'Session not found or empty' })
48
+ return
49
+ }
50
+
51
+ // Pair user+assistant exchanges into client message format
52
+ const messages: Array<{ query: string; text: string; timestamp: number }> = []
53
+ for (let i = 0; i < exchanges.length; i++) {
54
+ const ex = exchanges[i]
55
+ if (ex.role === 'user') {
56
+ const next = exchanges[i + 1]
57
+ if (next && next.role === 'assistant') {
58
+ messages.push({ query: ex.content, text: next.content, timestamp: next.timestamp })
59
+ i++ // skip the assistant exchange
60
+ }
61
+ }
62
+ }
63
+
64
+ res.json({ messages })
65
+ })
66
+
67
+ // POST /api/sessions/:id/end — Explicitly end a session (archive + log + notify)
68
+ // Called by client on "new session", "clear session", or app backgrounding.
69
+ // Prevents data loss — session gets logged to .glasses_sessions.jsonl immediately
70
+ // instead of waiting for the 2hr TTL expiry.
71
+ // POST /api/sessions/lookup — batch resolve timestamps to session IDs
72
+ // Used to retroactively stamp messages that predate the sessionId feature
73
+ sessionsRouter.post('/sessions/lookup', (req, res) => {
74
+ const { timestamps } = req.body as { timestamps: number[] }
75
+ if (!timestamps || !Array.isArray(timestamps)) {
76
+ res.status(400).json({ error: 'timestamps[] required' })
77
+ return
78
+ }
79
+
80
+ // Build time ranges from BOTH JSONL history AND live server sessions
81
+ const logPath = join(process.env.COS_SCRIPTS_DIR || '', '.glasses_sessions.jsonl')
82
+ const sessionRanges: Array<{ sid: string; start: number; end: number }> = []
83
+
84
+ // 1. Live server sessions (always current — no snapshot lag)
85
+ const recentSessions = getRecentSessions(24 * 60 * 60_000)
86
+ for (const rs of recentSessions) {
87
+ const raw = getSessionRaw(rs.id)
88
+ if (raw) {
89
+ sessionRanges.push({ sid: raw.id, start: raw.createdAt, end: Date.now() }) // extends to NOW
90
+ }
91
+ }
92
+
93
+ // 2. JSONL history (ended sessions + snapshots)
94
+ try {
95
+ const lines = readFileSync(logPath, 'utf-8').trim().split('\n')
96
+ for (const line of lines) {
97
+ const d = JSON.parse(line)
98
+ if (d.session_id && d.created_at) {
99
+ const start = new Date(d.created_at).getTime()
100
+ const end = d.ended_at ? new Date(d.ended_at).getTime() : start + 7200_000
101
+ // Live sessions take priority. For JSONL, keep the widest (latest) time range per session.
102
+ const existing = sessionRanges.find(s => s.sid === d.session_id)
103
+ if (!existing) {
104
+ sessionRanges.push({ sid: d.session_id, start, end })
105
+ } else if (end > existing.end && !getSessionRaw(d.session_id)) {
106
+ // Widen the JSONL range (but don't overwrite live sessions which extend to NOW)
107
+ existing.end = end
108
+ }
109
+ }
110
+ }
111
+ } catch { /* no log file */ }
112
+
113
+ // Match each timestamp to a session (live sessions checked first, then JSONL)
114
+ const results: Record<number, string | null> = {}
115
+ for (const ts of timestamps) {
116
+ let match: string | null = null
117
+ for (const s of sessionRanges) {
118
+ if (ts >= s.start && ts <= s.end) {
119
+ match = s.sid
120
+ break
121
+ }
122
+ }
123
+ results[ts] = match
124
+ }
125
+
126
+ res.json({ results, sessionsScanned: sessionRanges.length })
127
+ })
128
+
129
+ sessionsRouter.post('/sessions/:id/end', async (req, res) => {
130
+ try {
131
+ const result = await endSession(req.params.id)
132
+ if (!result) {
133
+ res.status(404).json({ error: 'Session not found' })
134
+ return
135
+ }
136
+ // When logged === false the archive write failed — we keep the session
137
+ // in the Map for the next mirror to retry, but signal 503 so the client
138
+ // knows NOT to wipe local messages (they're still the user's only copy).
139
+ if (!result.logged && result.exchangeCount > 0) {
140
+ res.status(503).json({
141
+ ok: false,
142
+ error: 'Archive write failed — session retained for retry',
143
+ exchange_count: result.exchangeCount,
144
+ duration_minutes: result.durationMin,
145
+ })
146
+ return
147
+ }
148
+ clearCodexEngineSession(req.params.id)
149
+ res.json({
150
+ ok: true,
151
+ logged: result.logged,
152
+ exchange_count: result.exchangeCount,
153
+ duration_minutes: result.durationMin,
154
+ })
155
+ } catch (err) {
156
+ console.error('[sessions] /end unexpected error:', err)
157
+ res.status(500).json({ error: String(err) })
158
+ }
159
+ })
160
+
161
+ // POST /api/sessions/:id/snapshot — write live session to .glasses_sessions.jsonl WITHOUT ending it
162
+ // Enables M3 Ultra TUI to read current glasses conversation while session is still active
163
+ sessionsRouter.post('/sessions/:id/snapshot', (req, res) => {
164
+ const session = getSessionRaw(req.params.id)
165
+ if (!session) {
166
+ res.status(404).json({ error: 'Session not found' })
167
+ return
168
+ }
169
+
170
+ const entry = buildSessionLogEntry({
171
+ id: session.id,
172
+ exchanges: session.exchanges,
173
+ createdAt: session.createdAt,
174
+ lastActivity: session.lastActivity,
175
+ modelPreference: session.modelPreference,
176
+ endReason: 'explicit_end', // marker — will be overwritten when session actually ends
177
+ slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
178
+ })
179
+
180
+ const logged = writeSessionLog(entry)
181
+ res.json({
182
+ ok: true,
183
+ logged,
184
+ session_id: session.id,
185
+ message_count: entry.total_message_count,
186
+ messages_logged: entry.messages.length,
187
+ })
188
+ })
189
+
190
+ // GET /api/sessions/today/live-chats — live session chat summaries for today (not yet archived).
191
+ // Date compare is LOCAL time so users chatting in CDT/PST late evening still see their
192
+ // session under "today" instead of "tomorrow UTC".
193
+ // Each summary includes `sessionId` so the client can drill down via
194
+ // `/api/sessions/:id/messages` — index=-1 is a sentinel and is NOT a valid archive chat index.
195
+ sessionsRouter.get('/sessions/today/live-chats', (_req, res) => {
196
+ const todayDate = localDay()
197
+ const liveSessions = getActiveSessions()
198
+ const chats: Array<{ index: number; summary: string; exchangeCount: number; startedAt: number; isLive: boolean; sessionId: string }> = []
199
+
200
+ for (const session of liveSessions) {
201
+ const sessionDay = localDay(session.lastActivity)
202
+ if (sessionDay !== todayDate) continue
203
+ if (session.exchanges.length === 0) continue
204
+
205
+ const firstQuery = session.exchanges.find(e => e.role === 'user')?.content ?? ''
206
+ const summary = firstQuery.length > 57 ? firstQuery.slice(0, 54) + '...' : firstQuery || 'Live session'
207
+
208
+ chats.push({
209
+ index: -1,
210
+ summary: `[LIVE] ${summary}`,
211
+ exchangeCount: session.exchanges.length,
212
+ startedAt: session.createdAt,
213
+ isLive: true,
214
+ sessionId: session.id,
215
+ })
216
+ }
217
+
218
+ res.json({ chats })
219
+ })
220
+
221
+ // GET /api/sessions/today/all-messages — merged view of today's archived + live session messages.
222
+ // Dedup key is `sessionId|timestamp` (was bare timestamp, which collided on NTP skew or
223
+ // same-ms adds). `sessionId` is always known for live exchanges; archive messages fall
224
+ // back to the archived chat's sessionId via getArchiveDayMessages.
225
+ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
226
+ const todayDate = localDay()
227
+
228
+ const archivedMessages = getArchiveDayMessages(todayDate).map(m => ({
229
+ ...m,
230
+ source: 'archive' as const,
231
+ }))
232
+
233
+ const liveMessages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; source: 'live' }> = []
234
+ const liveSessions = getActiveSessions()
235
+ for (const session of liveSessions) {
236
+ const sessionDay = localDay(session.lastActivity)
237
+ if (sessionDay !== todayDate) continue
238
+ for (let i = 0; i < session.exchanges.length; i++) {
239
+ const ex = session.exchanges[i]
240
+ if (ex.role === 'user') {
241
+ const next = session.exchanges[i + 1]
242
+ if (next && next.role === 'assistant') {
243
+ liveMessages.push({
244
+ query: ex.content,
245
+ text: next.content,
246
+ timestamp: next.timestamp,
247
+ chatIndex: -1,
248
+ sessionId: session.id,
249
+ source: 'live',
250
+ })
251
+ i++
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ // Merge, dedup by (sessionId, timestamp), sort chronologically.
258
+ const seen = new Set<string>()
259
+ const keyOf = (m: { sessionId?: string; timestamp: number }) => `${m.sessionId ?? ''}|${m.timestamp}`
260
+ const merged = [...archivedMessages, ...liveMessages]
261
+ .filter(m => {
262
+ const k = keyOf(m as any)
263
+ if (seen.has(k)) return false
264
+ seen.add(k)
265
+ return true
266
+ })
267
+ .sort((a, b) => a.timestamp - b.timestamp)
268
+
269
+ res.json({ messages: merged, date: todayDate })
270
+ })