@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 +33 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/server/index.ts +8 -0
- package/server/lib/archive.ts +7 -3
- package/server/routes/archive.ts +79 -0
- package/server/routes/message-ref.ts +190 -0
- package/server/routes/sessions.ts +270 -0
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
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
|
package/server/lib/archive.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
})
|