@gotcos/glasses-server 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.cos-profile.example.json +7 -0
  2. package/.env.example +44 -0
  3. package/CHANGELOG.md +25 -0
  4. package/LICENSE +21 -0
  5. package/README.md +78 -0
  6. package/bin/cli.cjs +203 -0
  7. package/package.json +53 -0
  8. package/server/env.ts +26 -0
  9. package/server/index.ts +211 -0
  10. package/server/lib/archive-budget.ts +65 -0
  11. package/server/lib/archive.ts +414 -0
  12. package/server/lib/atomic-fs.ts +50 -0
  13. package/server/lib/audio-enhance.ts +87 -0
  14. package/server/lib/claude-bridge.ts +682 -0
  15. package/server/lib/claude-circuit.ts +52 -0
  16. package/server/lib/claude-run-ledger.ts +279 -0
  17. package/server/lib/codex-bridge.ts +476 -0
  18. package/server/lib/codex-engine-sessions.ts +140 -0
  19. package/server/lib/codex-run-ledger.ts +298 -0
  20. package/server/lib/context-builder.ts +210 -0
  21. package/server/lib/conversation.ts +587 -0
  22. package/server/lib/data-dir.ts +20 -0
  23. package/server/lib/display-bus.ts +21 -0
  24. package/server/lib/display-format.ts +23 -0
  25. package/server/lib/fuzzy-correct.ts +286 -0
  26. package/server/lib/hallucination-filter.ts +469 -0
  27. package/server/lib/local-day.ts +13 -0
  28. package/server/lib/model-router.ts +38 -0
  29. package/server/lib/openai-key.ts +155 -0
  30. package/server/lib/openai-whisper-budget.ts +170 -0
  31. package/server/lib/profile.ts +94 -0
  32. package/server/lib/python-bridge.ts +84 -0
  33. package/server/lib/response-cache.ts +138 -0
  34. package/server/lib/session-cache-writer.ts +266 -0
  35. package/server/lib/session-log.ts +162 -0
  36. package/server/lib/speaker-embeddings.ts +578 -0
  37. package/server/lib/telegram-notify.ts +85 -0
  38. package/server/lib/token-audit.ts +50 -0
  39. package/server/lib/transcribe-audio.ts +187 -0
  40. package/server/lib/utils.ts +5 -0
  41. package/server/lib/vad-silero.ts +179 -0
  42. package/server/lib/whisper-local.ts +697 -0
  43. package/server/routes/diag.ts +115 -0
  44. package/server/routes/display.ts +65 -0
  45. package/server/routes/health.ts +128 -0
  46. package/server/routes/openai-compat.ts +446 -0
  47. package/server/routes/openai-key.ts +121 -0
  48. package/server/routes/query.ts +121 -0
  49. package/server/routes/transcribe-stream.ts +1090 -0
  50. package/server/routes/transcribe.ts +55 -0
  51. package/shared/model-preference.ts +81 -0
@@ -0,0 +1,211 @@
1
+ // Load .env FIRST — ESM evaluates this module before subsequent imports,
2
+ // ensuring process.env is populated before python-bridge.ts reads COS_SCRIPTS_DIR.
3
+ import './env.js'
4
+
5
+ import express from 'express'
6
+ import cors from 'cors'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { createServer as createHttpsServer } from 'node:https'
10
+ import { readFileSync, existsSync } from 'node:fs'
11
+ import { execSync } from 'node:child_process'
12
+ import { randomBytes } from 'node:crypto'
13
+ import { healthRouter } from './routes/health.js'
14
+ import { diagRouter } from './routes/diag.js'
15
+ import { queryRouter } from './routes/query.js'
16
+ import { transcribeRouter } from './routes/transcribe.js'
17
+ import { displayRouter } from './routes/display.js'
18
+ import { transcribeStreamRouter } from './routes/transcribe-stream.js'
19
+ import { openaiCompatRouter } from './routes/openai-compat.js'
20
+ import { openaiKeyRouter } from './routes/openai-key.js'
21
+ import { prewarmContext } from './lib/context-builder.js'
22
+ import { preWarmCLI } from './lib/claude-bridge.js'
23
+ import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
24
+ import { initSileroVAD } from './lib/vad-silero.js'
25
+ import { initSessionCache } from './lib/session-cache-writer.js'
26
+ import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
27
+ import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
28
+
29
+ const app = express()
30
+ const PORT = parseInt(process.env.PORT ?? '3141', 10)
31
+
32
+ // Mode detection — COS mode when a full pipeline directory is configured.
33
+ // Standalone (default): glasses + your local Claude Code CLI only.
34
+ export const COS_MODE = !!process.env.COS_SCRIPTS_DIR
35
+
36
+ // Bind host: the glasses' phone app must reach this server over your network or
37
+ // mesh (e.g. Tailscale), so the default is 0.0.0.0 (all interfaces). The IP
38
+ // allowlist below still blocks untrusted public traffic. Set BIND_HOST=127.0.0.1
39
+ // to restrict to localhost only.
40
+ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
41
+
42
+ // API token — auto-generate if not set so every session is authenticated.
43
+ const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
44
+ const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
45
+ process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
46
+
47
+ // Server metrics — shared with /api/health for monitoring
48
+ export const serverMetrics = {
49
+ startedAt: Date.now(),
50
+ requestCount: 0,
51
+ }
52
+
53
+ // IP allowlist — only accept connections from localhost, meshnet, and private networks.
54
+ // Blocks untrusted public access (coffee-shop WiFi, the open internet) while keeping all
55
+ // local + meshnet (Tailscale/CGNAT) + LAN consumers working.
56
+ app.use((req, res, next) => {
57
+ const ip = req.ip || req.socket.remoteAddress || ''
58
+ // Normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 → 127.0.0.1)
59
+ const cleanIp = ip.replace(/^::ffff:/, '')
60
+ const allowed =
61
+ cleanIp === '127.0.0.1' || cleanIp === '::1' || // localhost
62
+ /^100\./.test(cleanIp) || // meshnet (CGNAT)
63
+ /^10\./.test(cleanIp) || // private 10.x
64
+ /^172\.(1[6-9]|2\d|3[01])\./.test(cleanIp) || // private 172.16-31.x
65
+ /^192\.168\./.test(cleanIp) // private 192.168.x
66
+ if (!allowed) {
67
+ res.status(403).json({ error: 'forbidden — not on allowed network' })
68
+ return
69
+ }
70
+ next()
71
+ })
72
+
73
+ // Allow localhost + LAN IPs (glasses WebView accesses server over LAN)
74
+ app.use(cors({
75
+ origin: (origin, cb) => {
76
+ // Allow requests with no origin (same-origin, curl, SSE) or "null" origin (file:// WebViews like Even Hub)
77
+ if (!origin || origin === 'null') return cb(null, true)
78
+ // Allow localhost variants
79
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return cb(null, true)
80
+ // Allow private network IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 100.x.x.x for Meshnet/CGNAT)
81
+ if (/^https?:\/\/(10|172\.(1[6-9]|2\d|3[01])|192\.168|100)(\.\d+){2,3}(:\d+)?$/.test(origin)) return cb(null, true)
82
+ cb(new Error('CORS blocked'))
83
+ },
84
+ }))
85
+ app.use(express.json({ limit: '10mb' }))
86
+
87
+ // Auth middleware — always active (token is auto-generated if not set)
88
+ app.use('/api', (req, res, next) => {
89
+ // Allow health checks, display stream, and client diagnostics without auth.
90
+ // Diagnostics are whitelisted because the client needs to report crashes
91
+ // that may happen before the wizard has supplied an API token, and
92
+ // enforcing auth on a debug telemetry endpoint adds risk during the exact
93
+ // boot window we're trying to observe.
94
+ if (
95
+ req.path === '/health' ||
96
+ req.path === '/display-stream' ||
97
+ req.path === '/diag/client' ||
98
+ req.path === '/diag/health'
99
+ ) return next()
100
+ if (req.headers['x-cos-token'] !== API_TOKEN) {
101
+ return res.status(401).json({ error: 'unauthorized' })
102
+ }
103
+ next()
104
+ })
105
+
106
+ // Request counter — must be before route registrations
107
+ app.use((_req, _res, next) => {
108
+ serverMetrics.requestCount++
109
+ next()
110
+ })
111
+
112
+ // API routes
113
+ app.use('/api', healthRouter)
114
+ app.use('/api', diagRouter)
115
+ app.use('/api', queryRouter)
116
+ app.use('/api', transcribeRouter)
117
+ app.use('/api', displayRouter)
118
+ app.use('/api', transcribeStreamRouter)
119
+ app.use('/api', openaiKeyRouter)
120
+
121
+ // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
122
+ // Mounted at root — routes are /v1/chat/completions and /v1/models
123
+ app.use(openaiCompatRouter)
124
+
125
+ // Friendly root — this is an API server. The glasses client ships as the Even Hub
126
+ // app (the .ehpk), not from here. A browser hitting the host sees a status page.
127
+ app.get('/', (_req, res) => {
128
+ res.type('html').send(
129
+ '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
130
+ '<title>COS Glasses Server</title>' +
131
+ '<body style="font-family:system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1.25rem;line-height:1.6;color:#111">' +
132
+ '<h1 style="font-size:1.4rem">COS Glasses Server</h1>' +
133
+ '<p>Running. This is the local API for the <strong>COS Glasses</strong> app on Even G2 smart glasses.</p>' +
134
+ '<ul><li>Health: <a href="/api/health">/api/health</a></li>' +
135
+ '<li>Setup guide: <a href="https://www.gotcos.com">gotcos.com</a></li></ul>' +
136
+ '<p style="color:#666;font-size:.9rem">Install the client from the Even Hub, then point it at this server\'s address + token.</p></body>'
137
+ )
138
+ })
139
+
140
+ // Graceful shutdown — stop whisper-server child process
141
+ process.on('SIGTERM', () => { stopWhisperServer(); process.exit(0) })
142
+ process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
143
+
144
+ // Crash protection — log and survive instead of dying mid-meeting
145
+ process.on('uncaughtException', (err) => {
146
+ console.error('[CRASH GUARD] Uncaught exception (server stays alive):', err.message)
147
+ console.error(err.stack)
148
+ })
149
+ process.on('unhandledRejection', (reason: any) => {
150
+ console.error('[CRASH GUARD] Unhandled rejection (server stays alive):', reason?.message ?? reason)
151
+ if (reason?.stack) console.error(reason.stack)
152
+ })
153
+
154
+ // Start HTTPS alongside HTTP — Even Hub WebView prefers HTTPS (iOS ATS).
155
+ // Optional: drop cert.pem + key.pem in server/certs/ (e.g. via mkcert) to enable.
156
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
157
+ const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
158
+ const certDir = path.join(__dirname, 'certs')
159
+ if (existsSync(path.join(certDir, 'cert.pem'))) {
160
+ const httpsServer = createHttpsServer({
161
+ cert: readFileSync(path.join(certDir, 'cert.pem')),
162
+ key: readFileSync(path.join(certDir, 'key.pem')),
163
+ }, app)
164
+ httpsServer.listen(HTTPS_PORT, BIND_HOST, () => {
165
+ console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
166
+ })
167
+ } else {
168
+ console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
169
+ }
170
+
171
+ app.listen(PORT, BIND_HOST, () => {
172
+ console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
173
+ console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
174
+
175
+ // Print the full API token when auto-generated — the user pastes it into the app.
176
+ if (API_TOKEN_AUTO) {
177
+ console.log('')
178
+ console.log(`[COS API] API Token: ${API_TOKEN}`)
179
+ console.log('[COS API] ^ paste this into the COS Glasses app (set COS_API_TOKEN in .env for a fixed token)')
180
+ console.log('')
181
+ }
182
+
183
+ // Check Claude CLI availability — the chat backend
184
+ try {
185
+ execSync('claude --version', { timeout: 5000, stdio: 'pipe' })
186
+ console.log('[COS API] Claude Code CLI detected')
187
+ } catch {
188
+ console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
189
+ console.warn('[COS API] AI queries will not work without the Claude Code CLI')
190
+ }
191
+
192
+ if (COS_MODE) {
193
+ initSessionCache()
194
+ // Pre-warm context cache so first query doesn't wait for the pipeline
195
+ prewarmContext()
196
+ }
197
+ // Start local whisper-server (model stays in RAM for ~50ms transcription)
198
+ startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
199
+ // Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
200
+ const embeddingOk = initSpeakerEmbeddings()
201
+ console.log(`[startup] Speaker embeddings: ${embeddingOk ? 'active' : 'disabled (model not found)'}`)
202
+ // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
203
+ const vadOk = initSileroVAD()
204
+ console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
205
+
206
+ // Pre-warm the Claude CLI so the first query doesn't eat a 2-15s cold start
207
+ preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
208
+
209
+ // Auto-snapshot active sessions every 5 min (survives restarts)
210
+ startAutoSnapshot(5 * 60_000)
211
+ })
@@ -0,0 +1,65 @@
1
+ // Daily LLM call budget for archive summary generation.
2
+ // `claude -p` calls count against the user's Claude plan allocation. A boot
3
+ // after a long offline period could trigger N × (chats + 1) Sonnet calls via
4
+ // `runDailyArchiveMirror` — easily hundreds in one burst. This cap (checked
5
+ // before EVERY claude -p call from archive.ts) forces the system to degrade
6
+ // to string-fallback summaries once the daily budget is spent.
7
+ //
8
+ // You can raise the cap or clear the counter manually if needed.
9
+
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { resolve, dirname } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { localDay } from './local-day.js'
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url))
16
+ import { dataPath } from './data-dir.js'
17
+ const BUDGET_FILE = dataPath('archive-budget.json')
18
+
19
+ /** Max claude -p calls per local day across all archive summary generation. */
20
+ export const MAX_DAILY_ARCHIVE_LLM_CALLS = 30
21
+
22
+ interface BudgetState {
23
+ date: string // local YYYY-MM-DD
24
+ calls: number
25
+ }
26
+
27
+ function readBudget(): BudgetState {
28
+ const today = localDay()
29
+ if (!existsSync(BUDGET_FILE)) return { date: today, calls: 0 }
30
+ try {
31
+ const raw = readFileSync(BUDGET_FILE, 'utf-8')
32
+ const data = JSON.parse(raw) as BudgetState
33
+ if (data.date !== today) return { date: today, calls: 0 } // new day, reset
34
+ return data
35
+ } catch {
36
+ return { date: today, calls: 0 }
37
+ }
38
+ }
39
+
40
+ function writeBudget(state: BudgetState): void {
41
+ try {
42
+ writeFileSync(BUDGET_FILE, JSON.stringify(state))
43
+ } catch {
44
+ /* non-fatal — worst case we might double-count next read */
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Attempt to consume one LLM call from today's budget.
50
+ * Returns true if the call is allowed and budget was decremented.
51
+ * Returns false if budget is exhausted — caller must use a non-LLM fallback.
52
+ */
53
+ export function consumeArchiveLLMBudget(): boolean {
54
+ const state = readBudget()
55
+ if (state.calls >= MAX_DAILY_ARCHIVE_LLM_CALLS) return false
56
+ state.calls++
57
+ writeBudget(state)
58
+ return true
59
+ }
60
+
61
+ /** For diagnostics / dashboards. */
62
+ export function getArchiveLLMBudgetState(): BudgetState & { max: number; remaining: number } {
63
+ const state = readBudget()
64
+ return { ...state, max: MAX_DAILY_ARCHIVE_LLM_CALLS, remaining: Math.max(0, MAX_DAILY_ARCHIVE_LLM_CALLS - state.calls) }
65
+ }
@@ -0,0 +1,414 @@
1
+ // Daily archive system — persists conversation history beyond session TTL
2
+ // Archives are stored as JSON files per day in server/data/archive/
3
+ // Each day's archive contains one or more "chats" (split by context breaks)
4
+ // Summaries are generated via `claude -p --model sonnet`, budget-capped per day.
5
+
6
+ import { mkdirSync, readdirSync } from 'node:fs'
7
+ import { resolve, dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { exec } from 'node:child_process'
10
+ import { promisify } from 'node:util'
11
+ import { logTokenAudit } from './token-audit.js'
12
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
+ import { consumeArchiveLLMBudget } from './archive-budget.js'
14
+
15
+ const execAsync = promisify(exec)
16
+ import type { Exchange } from './conversation.js'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ import { dataPath } from './data-dir.js'
20
+ const ARCHIVE_DIR = dataPath('archive')
21
+
22
+ // ── Interfaces ──────────────────────────────────────────────
23
+
24
+ export interface ArchivedChat {
25
+ id: number
26
+ sessionId: string // Original 8-char UUID from conversation.ts (added v3.9.0)
27
+ exchanges: Exchange[]
28
+ startedAt: number
29
+ endedAt: number
30
+ exchangeCount: number
31
+ summary: string
32
+ }
33
+
34
+ export interface DailyArchive {
35
+ date: string // YYYY-MM-DD
36
+ summary: string // <60 char day summary
37
+ chats: ArchivedChat[]
38
+ archivedAt: string // ISO timestamp
39
+ }
40
+
41
+ export interface ArchiveDateSummary {
42
+ date: string
43
+ summary: string
44
+ chatCount: number
45
+ exchangeCount: number
46
+ }
47
+
48
+ export interface ArchiveChatSummary {
49
+ index: number
50
+ summary: string
51
+ exchangeCount: number
52
+ startedAt: number
53
+ }
54
+
55
+ // ── Internal: session structure for archiving ────────────────
56
+
57
+ export interface SessionToArchive {
58
+ id: string
59
+ exchanges: Exchange[]
60
+ contextBreaks: number[]
61
+ createdAt: number
62
+ lastActivity: number
63
+ }
64
+
65
+ // ── Directory management ────────────────────────────────────
66
+
67
+ function ensureArchiveDir(): string {
68
+ mkdirSync(ARCHIVE_DIR, { recursive: true })
69
+ return ARCHIVE_DIR
70
+ }
71
+
72
+ function archivePath(date: string): string {
73
+ return resolve(ensureArchiveDir(), `${date}.json`)
74
+ }
75
+
76
+ // ── Read/Write ──────────────────────────────────────────────
77
+
78
+ export function loadArchive(date: string): DailyArchive | null {
79
+ const result = loadJsonOrQuarantine<DailyArchive>(archivePath(date))
80
+ if (result.status === 'corrupt') {
81
+ // Loud — a silent return masked archive corruption as "day unavailable"
82
+ console.error(
83
+ `[archive] Corrupt archive for ${date} quarantined to ${result.quarantinedAs}. ` +
84
+ `Raw bytes are recoverable by hand from that file.`,
85
+ result.error,
86
+ )
87
+ return null
88
+ }
89
+ if (result.status === 'missing') return null
90
+ return result.data
91
+ }
92
+
93
+ function saveArchive(archive: DailyArchive): void {
94
+ ensureArchiveDir()
95
+ atomicWriteFileSync(archivePath(archive.date), JSON.stringify(archive, null, 2))
96
+ }
97
+
98
+ // Per-date write lock — prevents `runDailyArchiveMirror` from racing parallel
99
+ // appends on the same day file. Each appendToArchive chains behind the current
100
+ // in-flight write for that date. Lock map is cleaned up when the chain resolves.
101
+ const archiveWriteLocks = new Map<string, Promise<void>>()
102
+
103
+ function withArchiveLock<T>(date: string, op: () => Promise<T>): Promise<T> {
104
+ const prev = archiveWriteLocks.get(date) ?? Promise.resolve()
105
+ const next = prev.then(op, op) // run op regardless of prior success/failure
106
+ // Store a void-typed tail in the lock map so a rejection here doesn't cause
107
+ // unhandled-rejection noise — op's result is returned to the caller via `next`.
108
+ const tail: Promise<void> = next.then(() => undefined, () => undefined)
109
+ archiveWriteLocks.set(date, tail)
110
+ tail.finally(() => {
111
+ if (archiveWriteLocks.get(date) === tail) archiveWriteLocks.delete(date)
112
+ })
113
+ return next
114
+ }
115
+
116
+ // ── Chat splitting ──────────────────────────────────────────
117
+
118
+ /** Split a session's exchanges into chats using contextBreaks[] timestamps */
119
+ export function splitSessionIntoChats(session: SessionToArchive): ArchivedChat[] {
120
+ const { exchanges, contextBreaks } = session
121
+ if (exchanges.length === 0) return []
122
+
123
+ // Build break points (sorted)
124
+ const breaks = [...contextBreaks].sort((a, b) => a - b)
125
+ const chats: ArchivedChat[] = []
126
+ let chatId = 0
127
+
128
+ // Find chat boundaries
129
+ const boundaries: number[] = [0] // start index of each chat
130
+ for (const breakTs of breaks) {
131
+ // Find first exchange after this break
132
+ const idx = exchanges.findIndex(e => e.timestamp > breakTs)
133
+ if (idx > 0 && !boundaries.includes(idx)) {
134
+ boundaries.push(idx)
135
+ }
136
+ }
137
+
138
+ // Build chats from boundaries
139
+ for (let b = 0; b < boundaries.length; b++) {
140
+ const startIdx = boundaries[b]
141
+ const endIdx = b + 1 < boundaries.length ? boundaries[b + 1] : exchanges.length
142
+ const chatExchanges = exchanges.slice(startIdx, endIdx)
143
+ if (chatExchanges.length === 0) continue
144
+
145
+ chats.push({
146
+ id: chatId++,
147
+ sessionId: session.id,
148
+ exchanges: chatExchanges,
149
+ startedAt: chatExchanges[0].timestamp,
150
+ endedAt: chatExchanges[chatExchanges.length - 1].timestamp,
151
+ exchangeCount: chatExchanges.length,
152
+ summary: '', // filled by generateChatSummary
153
+ })
154
+ }
155
+
156
+ return chats
157
+ }
158
+
159
+ // ── Summary generation ──────────────────────────────────────
160
+
161
+ /** Deterministic fallback used when LLM is budget-skipped or errors out. */
162
+ function fallbackChatSummary(exchanges: Exchange[]): string {
163
+ const firstQuery = exchanges.find(e => e.role === 'user')?.content ?? ''
164
+ if (!firstQuery.trim()) return 'Empty chat'
165
+ return firstQuery.length > 57 ? firstQuery.slice(0, 57) + '...' : firstQuery
166
+ }
167
+
168
+ function fallbackDaySummary(chats: ArchivedChat[]): string {
169
+ const summaries = chats.map(c => {
170
+ const first = c.exchanges.find(e => e.role === 'user')?.content ?? ''
171
+ return first.length > 30 ? first.slice(0, 27) + '...' : first
172
+ }).filter(Boolean)
173
+ return summaries.slice(0, 2).join(', ').slice(0, 60) || 'Day activity'
174
+ }
175
+
176
+ /** Generate a <60 char summary for a single chat via claude -p.
177
+ * Budget-capped: if MAX_DAILY_ARCHIVE_LLM_CALLS is exhausted or `skipLLM` is
178
+ * passed, returns a deterministic string fallback. */
179
+ export async function generateChatSummary(exchanges: Exchange[], skipLLM = false): Promise<string> {
180
+ if (skipLLM || !consumeArchiveLLMBudget()) {
181
+ return fallbackChatSummary(exchanges)
182
+ }
183
+
184
+ const userQueries = exchanges
185
+ .filter(e => e.role === 'user')
186
+ .map(e => e.content)
187
+ .slice(0, 10) // cap to avoid token overflow
188
+ .join('\n')
189
+
190
+ if (!userQueries.trim()) return 'Empty chat'
191
+
192
+ const startMs = Date.now()
193
+ try {
194
+ const { stdout } = await execAsync(
195
+ `echo ${JSON.stringify(userQueries)} | claude -p --model sonnet "Summarize these COS Glasses queries into a single title under 60 characters. Just the title, no quotes, no explanation."`,
196
+ { timeout: 15_000 }
197
+ )
198
+ const result = stdout.trim()
199
+
200
+ logTokenAudit({
201
+ source: 'g2-archive',
202
+ model: 'sonnet',
203
+ inputChars: userQueries.length + 100,
204
+ outputChars: result.length,
205
+ durationMs: Date.now() - startMs,
206
+ caller: 'chat_summary',
207
+ })
208
+
209
+ // Validate: must be reasonable length
210
+ if (result.length > 0 && result.length <= 80) {
211
+ return result.slice(0, 60)
212
+ }
213
+ } catch {
214
+ // Fall through to fallback
215
+ }
216
+
217
+ return fallbackChatSummary(exchanges)
218
+ }
219
+
220
+ /** Generate a <60 char summary for a full day's chats. Same budget semantics. */
221
+ export async function generateDaySummary(chats: ArchivedChat[], skipLLM = false): Promise<string> {
222
+ if (skipLLM || !consumeArchiveLLMBudget()) {
223
+ return fallbackDaySummary(chats)
224
+ }
225
+
226
+ const allQueries = chats
227
+ .flatMap(c => c.exchanges.filter(e => e.role === 'user').map(e => e.content))
228
+ .slice(0, 15)
229
+ .join('\n')
230
+
231
+ if (!allQueries.trim()) return 'No activity'
232
+
233
+ const startMs = Date.now()
234
+ try {
235
+ const { stdout } = await execAsync(
236
+ `echo ${JSON.stringify(allQueries)} | claude -p --model sonnet "Summarize these COS Glasses queries from one day into a daily title under 60 characters. Just the title, no quotes."`,
237
+ { timeout: 15_000 }
238
+ )
239
+ const result = stdout.trim()
240
+
241
+ logTokenAudit({
242
+ source: 'g2-archive',
243
+ model: 'sonnet',
244
+ inputChars: allQueries.length + 100,
245
+ outputChars: result.length,
246
+ durationMs: Date.now() - startMs,
247
+ caller: 'day_summary',
248
+ })
249
+
250
+ if (result.length > 0 && result.length <= 80) {
251
+ return result.slice(0, 60)
252
+ }
253
+ } catch {
254
+ // Fall through to fallback
255
+ }
256
+
257
+ return fallbackDaySummary(chats)
258
+ }
259
+
260
+ // ── Archive operations ──────────────────────────────────────
261
+
262
+ export interface AppendToArchiveOpts {
263
+ /** When true, skip all `claude -p` summary calls — use deterministic string
264
+ * fallbacks instead. Used by the daily archive-mirror path to prevent a
265
+ * cost spike on boot after long idle periods. */
266
+ skipLLM?: boolean
267
+ }
268
+
269
+ /** Append an archived session (split into chats) to a daily archive file.
270
+ * Serialized per date via `withArchiveLock` so concurrent callers cannot
271
+ * race on load→mutate→save and clobber each other's writes. */
272
+ export async function appendToArchive(
273
+ date: string,
274
+ session: SessionToArchive,
275
+ opts: AppendToArchiveOpts = {},
276
+ ): Promise<void> {
277
+ return withArchiveLock(date, async () => {
278
+ const existing = loadArchive(date)
279
+ const newChats = splitSessionIntoChats(session)
280
+
281
+ // Generate summaries for new chats
282
+ for (const chat of newChats) {
283
+ chat.summary = await generateChatSummary(chat.exchanges, opts.skipLLM)
284
+ }
285
+
286
+ if (existing) {
287
+ // Merge: re-number chat IDs
288
+ const nextId = existing.chats.length
289
+ for (let i = 0; i < newChats.length; i++) {
290
+ newChats[i].id = nextId + i
291
+ }
292
+ existing.chats.push(...newChats)
293
+ existing.summary = await generateDaySummary(existing.chats, opts.skipLLM)
294
+ existing.archivedAt = new Date().toISOString()
295
+ saveArchive(existing)
296
+ } else {
297
+ const archive: DailyArchive = {
298
+ date,
299
+ summary: await generateDaySummary(newChats, opts.skipLLM),
300
+ chats: newChats,
301
+ archivedAt: new Date().toISOString(),
302
+ }
303
+ saveArchive(archive)
304
+ }
305
+ })
306
+ }
307
+
308
+ // ── Query functions ─────────────────────────────────────────
309
+
310
+ /** List all archive dates with summaries */
311
+ export function listArchiveDates(): ArchiveDateSummary[] {
312
+ try {
313
+ ensureArchiveDir()
314
+ const files = readdirSync(ARCHIVE_DIR)
315
+ .filter(f => f.endsWith('.json'))
316
+ .sort()
317
+ .reverse() // newest first
318
+
319
+ return files.map(f => {
320
+ const date = f.replace('.json', '')
321
+ const archive = loadArchive(date)
322
+ if (!archive) return null
323
+ return {
324
+ date: archive.date,
325
+ summary: archive.summary,
326
+ chatCount: archive.chats.length,
327
+ exchangeCount: archive.chats.reduce((sum, c) => sum + c.exchangeCount, 0),
328
+ }
329
+ }).filter(Boolean) as ArchiveDateSummary[]
330
+ } catch {
331
+ return []
332
+ }
333
+ }
334
+
335
+ /** Get chat summaries for a specific day */
336
+ export function getArchiveChats(date: string): ArchiveChatSummary[] {
337
+ const archive = loadArchive(date)
338
+ if (!archive) return []
339
+ return archive.chats.map(c => ({
340
+ index: c.id,
341
+ summary: c.summary,
342
+ exchangeCount: c.exchangeCount,
343
+ startedAt: c.startedAt,
344
+ }))
345
+ }
346
+
347
+ /** Get paired Q&A messages for a specific chat within a day */
348
+ export function getArchiveChatMessages(date: string, chatIndex: number): Array<{ query: string; text: string; timestamp: number }> {
349
+ const archive = loadArchive(date)
350
+ if (!archive) return []
351
+ const chat = archive.chats.find(c => c.id === chatIndex)
352
+ if (!chat) return []
353
+
354
+ const messages: Array<{ query: string; text: string; timestamp: number }> = []
355
+ for (let i = 0; i < chat.exchanges.length; i++) {
356
+ const ex = chat.exchanges[i]
357
+ if (ex.role === 'user') {
358
+ const next = chat.exchanges[i + 1]
359
+ if (next && next.role === 'assistant') {
360
+ messages.push({ query: ex.content, text: next.content, timestamp: next.timestamp })
361
+ i++ // skip assistant
362
+ }
363
+ }
364
+ }
365
+ return messages
366
+ }
367
+
368
+ /** Get all messages for a day (flat, across all chats) — for phone browser.
369
+ * `sessionId` is included so the today/all-messages endpoint can dedup on
370
+ * (sessionId, timestamp) instead of bare timestamp (collision-prone). */
371
+ export function getArchiveDayMessages(
372
+ date: string,
373
+ ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> {
374
+ const archive = loadArchive(date)
375
+ if (!archive) return []
376
+
377
+ const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> = []
378
+ for (const chat of archive.chats) {
379
+ for (let i = 0; i < chat.exchanges.length; i++) {
380
+ const ex = chat.exchanges[i]
381
+ if (ex.role === 'user') {
382
+ const next = chat.exchanges[i + 1]
383
+ if (next && next.role === 'assistant') {
384
+ messages.push({
385
+ query: ex.content,
386
+ text: next.content,
387
+ timestamp: next.timestamp,
388
+ chatIndex: chat.id,
389
+ sessionId: chat.sessionId,
390
+ })
391
+ i++
392
+ }
393
+ }
394
+ }
395
+ }
396
+ return messages
397
+ }
398
+
399
+ // ── Startup check ───────────────────────────────────────────
400
+
401
+ /** Check if yesterday needs archiving (handles overnight server restarts) */
402
+ export function checkYesterdayArchive(): void {
403
+ const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
404
+ const existing = loadArchive(yesterday)
405
+ if (!existing) {
406
+ // No yesterday archive exists — but we can't archive sessions that are already expired
407
+ // This is a no-op unless we add persistent session storage beyond TTL
408
+ // The main trigger is the session expiry hook in conversation.ts
409
+ console.log(`[archive] No archive for ${yesterday} — sessions may have already expired`)
410
+ }
411
+ }
412
+
413
+ // Run on module load
414
+ checkYesterdayArchive()