@gotcos/glasses-server 6.2.0 → 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,60 @@
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
+
36
+ ## 6.2.1
37
+
38
+ Foolproofing release — driven by an adversarial onboarding QA pass.
39
+
40
+ - **The server now prints URLs the phone can actually use.** Boot output lists
41
+ your real addresses (`http://100.x.x.x:3141` labeled Tailscale, LAN IPs labeled
42
+ same-Wi-Fi) instead of only the un-pasteable bind address `0.0.0.0`.
43
+ - **Auto-generated API tokens survive restarts.** First boot saves the token to
44
+ `~/.cos-glasses/.env`, so re-running the server no longer silently rotates the
45
+ credential your app already saved (the "worked yesterday, 401 today" trap).
46
+ - **Starter-Kit COS inheritance is real now.** Run `npx @gotcos/glasses-server`
47
+ from your COS folder and glasses chat loads its brain: the launcher records
48
+ your launch directory, and when it contains `.cos/manifest.json`, `AGENTS.md`,
49
+ or `CLAUDE.md`, Claude/Codex spawn there (explicit `COS_SCRIPTS_DIR` still wins).
50
+ - **Transfer-integrity report actually surfaces.** 6.2.0 recorded lost chunks but
51
+ never returned them; the offline-session `finalize` response now includes
52
+ `transferIntegrity` (received/expected/missing/completeness) and a gap-aware
53
+ `transcript` with inline `[… audio gap …]` markers.
54
+ - **One default model everywhere: Sonnet.** The query router, the OpenAI-compat
55
+ surface, and CLI pre-warm all default to Sonnet (was a mix of Opus and Haiku).
56
+ Set `COS_G2_DEFAULT_MODEL` to override; per-query picks unchanged.
57
+
3
58
  ## 6.2.0
4
59
 
5
60
  Reliability release — ports the hardening the full COS Glasses app shipped in June.
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/bin/cli.cjs CHANGED
@@ -13,6 +13,12 @@ const { homedir } = require('os')
13
13
  const PKG_ROOT = resolve(__dirname, '..')
14
14
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
15
15
 
16
+ // Record where the user ran `npx @gotcos/glasses-server` from. The server spawns
17
+ // with cwd = PKG_ROOT (the npx cache), so without this the user's Starter-Kit COS
18
+ // (AGENTS.md / CLAUDE.md / .cos/) in their launch folder would never be seen.
19
+ // Chat spawns of claude/codex use this dir when it contains a COS brain.
20
+ if (!process.env.COS_LAUNCH_DIR) process.env.COS_LAUNCH_DIR = process.cwd()
21
+
16
22
  const green = (s) => `\x1b[32m${s}\x1b[0m`
17
23
  const red = (s) => `\x1b[31m${s}\x1b[0m`
18
24
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`
@@ -185,6 +191,12 @@ if (!process.env.BIND_HOST) {
185
191
  }
186
192
 
187
193
  // Step 7: start the bundled server
194
+ try {
195
+ const ld = process.env.COS_LAUNCH_DIR
196
+ if (ld && (existsSync(join(ld, '.cos', 'manifest.json')) || existsSync(join(ld, 'AGENTS.md')) || existsSync(join(ld, 'CLAUDE.md')))) {
197
+ console.log(green(' ✓') + ` COS detected in ${dim(ld)} — glasses chat will load its brain`)
198
+ }
199
+ } catch { /* detection is best-effort */ }
188
200
  console.log('')
189
201
  console.log(dim(' Starting server...'))
190
202
  console.log('')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.2.0",
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
@@ -7,7 +7,9 @@ import cors from 'cors'
7
7
  import path from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { createServer as createHttpsServer } from 'node:https'
10
- import { readFileSync, existsSync } from 'node:fs'
10
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
+ import { networkInterfaces, homedir } from 'node:os'
12
+ import { join } from 'node:path'
11
13
  import { execSync } from 'node:child_process'
12
14
  import { randomBytes } from 'node:crypto'
13
15
  import { healthRouter } from './routes/health.js'
@@ -18,6 +20,9 @@ import { displayRouter } from './routes/display.js'
18
20
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
19
21
  import { openaiCompatRouter } from './routes/openai-compat.js'
20
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'
21
26
  import { prewarmContext } from './lib/context-builder.js'
22
27
  import { preWarmCLI } from './lib/claude-bridge.js'
23
28
  import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
@@ -43,6 +48,18 @@ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
43
48
  const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
44
49
  const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
45
50
  process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
51
+ // Persist an auto-generated token to ~/.cos-glasses/.env so it SURVIVES restarts.
52
+ // Without this, every re-run mints a new token and the app's saved token starts
53
+ // returning 401 — the symptom looks like a broken app, not a rotated credential.
54
+ let API_TOKEN_PERSISTED = false
55
+ if (API_TOKEN_AUTO) {
56
+ try {
57
+ const envDir = join(homedir(), '.cos-glasses')
58
+ mkdirSync(envDir, { recursive: true })
59
+ appendFileSync(join(envDir, '.env'), `\n# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`)
60
+ API_TOKEN_PERSISTED = true
61
+ } catch { /* read-only home — token stays per-session */ }
62
+ }
46
63
 
47
64
  // Server metrics — shared with /api/health for monitoring
48
65
  export const serverMetrics = {
@@ -117,6 +134,11 @@ app.use('/api', transcribeRouter)
117
134
  app.use('/api', displayRouter)
118
135
  app.use('/api', transcribeStreamRouter)
119
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)
120
142
 
121
143
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
122
144
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -178,11 +200,31 @@ app.listen(PORT, BIND_HOST, () => {
178
200
  console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
179
201
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
180
202
 
203
+ // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
204
+ // not paste-able — enumerate real interfaces and label the Tailscale one.
205
+ try {
206
+ const nets = networkInterfaces()
207
+ const addrs: Array<{ ip: string; label: string }> = []
208
+ for (const [name, infos] of Object.entries(nets)) {
209
+ for (const info of infos ?? []) {
210
+ if (info.family !== 'IPv4' || info.internal) continue
211
+ const isTailscale = info.address.startsWith('100.') || name.startsWith('utun') || name.startsWith('tailscale')
212
+ addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
213
+ }
214
+ }
215
+ if (addrs.length > 0) {
216
+ addrs.sort((a, b) => Number(b.label.startsWith('Tailscale')) - Number(a.label.startsWith('Tailscale')))
217
+ console.log('')
218
+ console.log('[COS API] Server URL for the COS Glasses app:')
219
+ for (const a of addrs) console.log(`[COS API] http://${a.ip}:${PORT} (${a.label})`)
220
+ }
221
+ } catch { /* interface enumeration is best-effort */ }
222
+
181
223
  // Print the full API token when auto-generated — the user pastes it into the app.
182
224
  if (API_TOKEN_AUTO) {
183
225
  console.log('')
184
226
  console.log(`[COS API] API Token: ${API_TOKEN}`)
185
- console.log('[COS API] ^ paste this into the COS Glasses app (set COS_API_TOKEN in .env for a fixed token)')
227
+ console.log('[COS API] ^ paste this into the COS Glasses app' + (API_TOKEN_PERSISTED ? ' — saved to ~/.cos-glasses/.env so it stays the same across restarts' : ' (set COS_API_TOKEN in .env for a fixed token)'))
186
228
  console.log('')
187
229
  }
188
230
 
@@ -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 {
@@ -12,11 +12,12 @@ import { join } from 'node:path'
12
12
  import { appendFileSync } from 'node:fs'
13
13
  import crypto from 'node:crypto'
14
14
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
15
+ import { cosBrainDir } from './launch-dir.js'
15
16
  import { logTokenAudit } from './token-audit.js'
16
17
  import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
17
18
  import { getHistory, addExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
18
19
  import { notifySessionStart, notifyExchange } from './telegram-notify.js'
19
- import { isClaudeModel, type ClaudeModelPreference } from '../../shared/model-preference.js'
20
+ import { isClaudeModel, DEFAULT_MODEL, type ClaudeModelPreference } from '../../shared/model-preference.js'
20
21
  import {
21
22
  finishClaudeRun,
22
23
  getClaudeEffortLevel,
@@ -182,7 +183,7 @@ export async function preWarmCLI(): Promise<void> {
182
183
 
183
184
  const proc = spawn('claude', [
184
185
  '-p',
185
- '--model', 'opus', // Must match default query model — --resume inherits session model
186
+ '--model', DEFAULT_MODEL, // Must match default query model — --resume inherits session model
186
187
  '--effort', getClaudeEffortLevel(),
187
188
  '--output-format', 'stream-json',
188
189
  '--verbose',
@@ -191,7 +192,7 @@ export async function preWarmCLI(): Promise<void> {
191
192
  ], {
192
193
  stdio: ['pipe', 'pipe', 'pipe'],
193
194
  env,
194
- cwd: COS_SCRIPTS_DIR ?? process.cwd(),
195
+ cwd: COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd(),
195
196
  })
196
197
 
197
198
  let buffer = ''
@@ -419,7 +420,7 @@ export async function callClaudeStreaming(
419
420
  // Strip CLAUDECODE env var so claude -p doesn't think it's nested
420
421
  const env = { ...process.env }
421
422
  delete env.CLAUDECODE
422
- const cliCwd = COS_SCRIPTS_DIR ?? process.cwd()
423
+ const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
423
424
  const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
424
425
  const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
425
426
  const wallMax = isExtendedQuery(query) ? WALL_MAX_EXTENDED_MS : defaultWallMax
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
+ import { cosBrainDir } from './launch-dir.js'
5
6
  import { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
6
7
  import {
7
8
  CODEX_HIGH_REASONING_EFFORT,
@@ -80,6 +81,10 @@ export function getCodexExecutionCwd(): string {
80
81
  const configured = process.env.CODEX_GLASSES_WORKDIR?.trim()
81
82
  if (configured) return resolve(configured)
82
83
  if (COS_SCRIPTS_DIR) return resolve(COS_SCRIPTS_DIR, '..', '..')
84
+ // A Starter-Kit COS in the directory the user launched npx from — Codex
85
+ // loads its AGENTS.md brain natively when run there.
86
+ const brain = cosBrainDir()
87
+ if (brain) return brain
83
88
  // Last resort when neither CODEX_GLASSES_WORKDIR nor COS_SCRIPTS_DIR is set:
84
89
  // the server's own working dir (codex glasses is an optional, env-configured feature).
85
90
  return process.cwd()
@@ -0,0 +1,29 @@
1
+ // COS brain auto-detection for the "glasses inherit your COS" promise.
2
+ //
3
+ // The npx launcher (bin/cli.cjs) records the directory the user ran `npx
4
+ // @gotcos/glasses-server` from as COS_LAUNCH_DIR before re-spawning the server
5
+ // with cwd = the package root. If that launch directory contains a COS brain
6
+ // (a Starter-Kit scaffold: .cos/manifest.json, AGENTS.md, or CLAUDE.md), chat
7
+ // spawns of claude/codex use IT as their working directory — so the CLIs load
8
+ // the user's brain exactly as they would in a terminal session in that folder.
9
+ //
10
+ // Precedence stays: COS_SCRIPTS_DIR (full COS pipeline) > detected brain dir >
11
+ // process.cwd(). Explicit config always wins.
12
+ import { existsSync } from 'node:fs'
13
+ import { join, resolve } from 'node:path'
14
+
15
+ let cached: string | null | undefined
16
+
17
+ /** The user's launch directory IF it contains a COS brain; otherwise null. */
18
+ export function cosBrainDir(): string | null {
19
+ if (cached !== undefined) return cached
20
+ const raw = process.env.COS_LAUNCH_DIR?.trim()
21
+ if (!raw) { cached = null; return cached }
22
+ const dir = resolve(raw)
23
+ const hasBrain =
24
+ existsSync(join(dir, '.cos', 'manifest.json')) ||
25
+ existsSync(join(dir, 'AGENTS.md')) ||
26
+ existsSync(join(dir, 'CLAUDE.md'))
27
+ cached = hasBrain ? dir : null
28
+ return cached
29
+ }
@@ -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
+ })
@@ -6,7 +6,7 @@
6
6
  import { Router } from 'express'
7
7
  import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
8
8
  import { callModelStreaming } from '../lib/model-router.js'
9
- import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
9
+ import { normalizeModelPreference, DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
10
10
  import { tryInstantResponse } from '../lib/response-cache.js'
11
11
  import crypto from 'node:crypto'
12
12
 
@@ -79,7 +79,7 @@ function resolveModel(model?: string, _query?: string): ModelPreference {
79
79
  if (model === 'cos-sonnet') return 'sonnet'
80
80
  if (model === 'cos-haiku') return 'haiku'
81
81
  if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-high'
82
- return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? 'haiku'
82
+ return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
83
83
  }
84
84
 
85
85
  const MODEL_NAMES: Record<ModelPreference, string> = {
@@ -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
+ })
@@ -1170,11 +1170,16 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
1170
1170
  const chunks = getSessionChunks(sessionId)
1171
1171
  if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
1172
1172
  await drainSessionAudioWrites(sessionId)
1173
- const transcript = getSessionTranscript(sessionId) ?? ''
1173
+ // Gap-aware assembly: chunks lost in transit surface as inline
1174
+ // "[… audio gap …]" markers instead of being silently stitched over.
1175
+ const transcript = getSessionTranscript(sessionId, { withGaps: true }) ?? ''
1176
+ const transferIntegrity = analyzeTranscriptGaps(sessionId)
1174
1177
  res.json({
1175
1178
  sessionId,
1176
1179
  chunks: chunks.length,
1177
1180
  transcriptChars: transcript.length,
1181
+ transcript,
1182
+ transferIntegrity,
1178
1183
  readyToSave: true,
1179
1184
  })
1180
1185
  } catch (err: unknown) {
@@ -2,7 +2,7 @@ export type ClaudeModelPreference = 'opus' | 'sonnet' | 'haiku'
2
2
  export type CodexModelPreference = 'codex-high'
3
3
  export type ModelPreference = ClaudeModelPreference | CodexModelPreference
4
4
 
5
- export const DEFAULT_MODEL = 'opus' as const
5
+ export const DEFAULT_MODEL = 'sonnet' as const
6
6
  export const CODEX_HIGH_MODEL: CodexModelPreference = 'codex-high'
7
7
 
8
8
  // Optional codex model passed to `codex exec --model`. Empty (the default) means