@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.
- package/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- package/shared/model-preference.ts +81 -0
package/server/index.ts
ADDED
|
@@ -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()
|