@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,115 @@
1
+ // POST /api/diag/client — Client-side diagnostic log sink.
2
+ //
3
+ // Accepts JSON blobs from the COS Glasses WebView and appends them to a
4
+ // JSONL file for post-crash analysis. Unauth'd (same whitelist as /health)
5
+ // so it works during the boot-time heartbeat before the wizard has supplied
6
+ // an API token.
7
+ //
8
+ // Shipped in v5.3.4 to debug the "45-chunk mystery crash" (2026-04-11).
9
+ // Volume is expected to be tiny: ~6 heartbeats/minute during meetings + a
10
+ // handful of error events on failures. The server caps file size at 10 MB
11
+ // and rotates to `.1` when full (keeps only 1 rotation — this is a
12
+ // debug-only facility, not a long-term archive).
13
+
14
+ import { Router } from 'express'
15
+ import { writeFileSync, existsSync, mkdirSync, appendFileSync, statSync, renameSync } from 'node:fs'
16
+ import { resolve, dirname } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
20
+ import { dataPath } from '../lib/data-dir.js'
21
+ const DIAG_DIR = dataPath()
22
+ const DIAG_FILE = resolve(DIAG_DIR, 'client-diagnostics.jsonl')
23
+ const MAX_FILE_BYTES = 10 * 1024 * 1024 // 10 MB cap
24
+
25
+ if (!existsSync(DIAG_DIR)) mkdirSync(DIAG_DIR, { recursive: true })
26
+ if (!existsSync(DIAG_FILE)) writeFileSync(DIAG_FILE, '')
27
+
28
+ export const diagRouter = Router()
29
+
30
+ // In-memory rate limit: max 30 entries per 10 s window per sessionId (or anon).
31
+ // Prevents a runaway error loop from flooding the log.
32
+ const rateWindows = new Map<string, { windowStart: number; count: number }>()
33
+ const RATE_WINDOW_MS = 10_000
34
+ const RATE_MAX_PER_WINDOW = 30
35
+
36
+ function rateLimited(key: string): boolean {
37
+ const now = Date.now()
38
+ const bucket = rateWindows.get(key)
39
+ if (!bucket || now - bucket.windowStart > RATE_WINDOW_MS) {
40
+ rateWindows.set(key, { windowStart: now, count: 1 })
41
+ return false
42
+ }
43
+ bucket.count++
44
+ return bucket.count > RATE_MAX_PER_WINDOW
45
+ }
46
+
47
+ function rotateIfOversized(): void {
48
+ try {
49
+ const stats = statSync(DIAG_FILE)
50
+ if (stats.size > MAX_FILE_BYTES) {
51
+ const rotated = DIAG_FILE + '.1'
52
+ renameSync(DIAG_FILE, rotated)
53
+ writeFileSync(DIAG_FILE, '')
54
+ console.log(`[diag] Rotated ${DIAG_FILE} → ${rotated} (was ${stats.size} bytes)`)
55
+ }
56
+ } catch { /* best-effort */ }
57
+ }
58
+
59
+ diagRouter.post('/diag/client', async (req, res) => {
60
+ const body = req.body as Record<string, unknown>
61
+
62
+ // Must have a timestamp and an event name — everything else is optional
63
+ const ts = typeof body.ts === 'number' ? body.ts : Date.now()
64
+ const level = typeof body.level === 'string' ? body.level : 'info'
65
+ const event = typeof body.event === 'string' ? body.event : 'unknown'
66
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : null
67
+ const data = (body.data && typeof body.data === 'object') ? body.data : {}
68
+
69
+ // Reject orphan heartbeats from zombie clients (session was deleted/saved already).
70
+ // 410 Gone tells client to stop hitting this endpoint and reset its session state.
71
+ if (sessionId && event === 'heartbeat') {
72
+ try {
73
+ const { isSessionDeleted } = await import('./transcribe-stream.js')
74
+ if (isSessionDeleted(sessionId)) {
75
+ return res.status(410).json({ error: 'session_deleted', sessionId })
76
+ }
77
+ } catch { /* import failure: fall through to normal logging */ }
78
+ }
79
+
80
+ // Rate limit per sessionId (or remote address as fallback)
81
+ const rateKey = sessionId || req.ip || 'anon'
82
+ if (rateLimited(rateKey)) {
83
+ return res.status(429).json({ error: 'rate_limited' })
84
+ }
85
+
86
+ // Rotate if oversized (cheap — stat call + optional rename)
87
+ rotateIfOversized()
88
+
89
+ const line = JSON.stringify({
90
+ ts,
91
+ level,
92
+ event,
93
+ sessionId,
94
+ data,
95
+ server_received: Date.now(),
96
+ }) + '\n'
97
+
98
+ try {
99
+ appendFileSync(DIAG_FILE, line)
100
+ res.status(204).end()
101
+ } catch (err: any) {
102
+ console.error(`[diag] Failed to append: ${err?.message ?? err}`)
103
+ res.status(500).json({ error: 'write_failed' })
104
+ }
105
+ })
106
+
107
+ // GET /api/diag/health — quick check that the diag endpoint is reachable.
108
+ // Also returns current file size so clients can verify writes are landing.
109
+ diagRouter.get('/diag/health', (_req, res) => {
110
+ let size = 0
111
+ try {
112
+ size = statSync(DIAG_FILE).size
113
+ } catch { /* file missing is OK, will be created on next POST */ }
114
+ res.json({ ok: true, file: DIAG_FILE, size })
115
+ })
@@ -0,0 +1,65 @@
1
+ // GET /api/display-stream — SSE endpoint for glasses display sync
2
+ // Any connected glasses client receives real-time query responses
3
+ // regardless of which interface submitted the query
4
+
5
+ import { Router } from 'express'
6
+ import { onDisplay, emitDisplay } from '../lib/display-bus.js'
7
+
8
+ export const displayRouter = Router()
9
+
10
+ // Replay buffer — last N events so reconnecting clients don't miss in-flight data
11
+ const REPLAY_BUFFER_SIZE = 20
12
+ let eventId = 0
13
+ const replayBuffer: Array<{ id: number; type: string; data: string }> = []
14
+
15
+ displayRouter.get('/display-stream', (req, res) => {
16
+ res.writeHead(200, {
17
+ 'Content-Type': 'text/event-stream',
18
+ 'Cache-Control': 'no-cache',
19
+ 'Connection': 'keep-alive',
20
+ 'X-Accel-Buffering': 'no',
21
+ 'Access-Control-Allow-Origin': '*', // Even Hub WebView loads from file:// — needs explicit CORS
22
+ })
23
+ res.flushHeaders()
24
+
25
+ // Tell EventSource to retry quickly on disconnect (3s instead of browser default ~5-10s)
26
+ res.write('retry: 3000\n\n')
27
+
28
+ // Replay missed events if client sends Last-Event-ID (browser does this automatically)
29
+ const lastId = parseInt(req.headers['last-event-id'] as string, 10)
30
+ if (!isNaN(lastId) && lastId > 0) {
31
+ const missed = replayBuffer.filter(e => e.id > lastId)
32
+ for (const e of missed) {
33
+ res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${e.data}\n\n`)
34
+ }
35
+ if (missed.length > 0) {
36
+ console.log(`[display-bus] Replayed ${missed.length} events for reconnecting client (from id ${lastId})`)
37
+ }
38
+ }
39
+
40
+ // Keepalive ping every 15s — more aggressive to survive meshnet/proxy timeouts
41
+ const ping = setInterval(() => {
42
+ try { res.write(': keepalive\n\n') } catch { /* client gone */ }
43
+ }, 15_000)
44
+
45
+ const unsub = onDisplay((event) => {
46
+ eventId++
47
+ const data = JSON.stringify(event.data)
48
+ // Buffer for replay
49
+ replayBuffer.push({ id: eventId, type: event.type, data })
50
+ if (replayBuffer.length > REPLAY_BUFFER_SIZE) replayBuffer.shift()
51
+ // Send with id for Last-Event-ID tracking
52
+ try { res.write(`id: ${eventId}\nevent: ${event.type}\ndata: ${data}\n\n`) } catch { /* client gone */ }
53
+ })
54
+
55
+ req.on('close', () => {
56
+ clearInterval(ping)
57
+ unsub()
58
+ })
59
+ })
60
+
61
+ // POST /api/display-session — broadcast session restore to glasses (cross-surface sync)
62
+ displayRouter.post('/display-session', (req, res) => {
63
+ emitDisplay({ type: 'session_restore', data: req.body })
64
+ res.json({ ok: true })
65
+ })
@@ -0,0 +1,128 @@
1
+ import { Router } from 'express'
2
+ import { execFile } from 'node:child_process'
3
+ import { statSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
5
+ import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
6
+ import { serverMetrics } from '../index.js'
7
+ import { isSileroAvailable } from '../lib/vad-silero.js'
8
+ import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
9
+ import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
10
+ import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
11
+ import { getKeyStatus } from '../lib/openai-key.js'
12
+
13
+ export const healthRouter = Router()
14
+
15
+ healthRouter.get('/health', async (_req, res) => {
16
+ const checks: Record<string, string | number> = {
17
+ status: 'ok',
18
+ mode: COS_MODE ? 'cos' : 'standalone',
19
+ server: 'ok',
20
+ python: 'unknown',
21
+ claude: 'unknown',
22
+ codex: 'unknown',
23
+ uptime_seconds: Math.floor((Date.now() - serverMetrics.startedAt) / 1000),
24
+ request_count: serverMetrics.requestCount,
25
+ }
26
+
27
+ // Feature detection flags
28
+ let claudeAvailable = false
29
+ let codexAvailable = false
30
+
31
+ // Check Python venv (COS mode only)
32
+ if (PYTHON_BIN) {
33
+ try {
34
+ await new Promise<void>((resolve, reject) => {
35
+ execFile(PYTHON_BIN!, ['--version'], { timeout: 5000 }, (err, stdout) => {
36
+ if (err) return reject(err)
37
+ checks.python = stdout.trim()
38
+ resolve()
39
+ })
40
+ })
41
+ } catch {
42
+ checks.python = 'error'
43
+ }
44
+ } else {
45
+ checks.python = 'standalone'
46
+ }
47
+
48
+ // Check claude CLI
49
+ try {
50
+ await new Promise<void>((resolve, reject) => {
51
+ execFile('claude', ['--version'], { timeout: 5000 }, (err, stdout) => {
52
+ if (err) return reject(err)
53
+ checks.claude = stdout.trim()
54
+ claudeAvailable = true
55
+ resolve()
56
+ })
57
+ })
58
+ } catch {
59
+ checks.claude = 'error'
60
+ }
61
+
62
+ // Check Codex CLI. The desktop CLI can print benign PATH warnings to stderr,
63
+ // so version extraction uses stdout + stderr and looks for the codex-cli line.
64
+ try {
65
+ await new Promise<void>((resolve, reject) => {
66
+ execFile('codex', ['--version'], { timeout: 5000 }, (err, stdout, stderr) => {
67
+ if (err) return reject(err)
68
+ const combined = `${stdout}\n${stderr}`.trim()
69
+ const versionLine = combined.split('\n').map(line => line.trim()).find(line => /^codex(?:-cli)?\s+/i.test(line))
70
+ checks.codex = versionLine ?? combined.split('\n')[0] ?? 'available'
71
+ codexAvailable = true
72
+ resolve()
73
+ })
74
+ })
75
+ } catch {
76
+ checks.codex = 'error'
77
+ }
78
+
79
+ // Check session cache freshness (COS mode only)
80
+ if (COS_SCRIPTS_DIR) {
81
+ try {
82
+ const cacheFile = resolve(COS_SCRIPTS_DIR, '.session_index_cache_COS-Glasses.json')
83
+ checks.last_cache_write = statSync(cacheFile).mtime.toISOString()
84
+ } catch {
85
+ checks.last_cache_write = 'missing'
86
+ }
87
+ }
88
+
89
+ checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
90
+
91
+ // Include CLI session ID if available (pre-warmed or active)
92
+ const cliSid = getAvailableCliSessionId()
93
+ if (cliSid) checks.cli_session_id = cliSid
94
+
95
+ // Feature summary for client capability detection.
96
+ // v5.9.5 — voice.hasKey reflects the centralized resolver (env > saved file >
97
+ // COS scripts .env), not just process.env, so a key configured via the
98
+ // Settings panel correctly reports voice as available without a server
99
+ // restart. The nested voice block also exposes the source so future wizard
100
+ // work can decide whether to prompt for a key.
101
+ const keyStatus = getKeyStatus()
102
+ const features = {
103
+ claude: claudeAvailable,
104
+ codex: codexAvailable,
105
+ voice: keyStatus.hasKey,
106
+ cos_pipeline: COS_MODE,
107
+ whisper: isWhisperLocalAvailable(),
108
+ iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
109
+ }
110
+ const voice = {
111
+ hasKey: keyStatus.hasKey,
112
+ keySource: keyStatus.source,
113
+ }
114
+
115
+ // Whisper-server health + cloud budget — exposed so glasses + dashboards can
116
+ // see whether we're at risk of falling to cloud and how much budget remains.
117
+ const whisper_health = getWhisperHealth()
118
+ const openai_whisper_budget = getOpenAIWhisperBudgetState()
119
+
120
+ res.json({ ...checks, features, voice, whisper_health, openai_whisper_budget })
121
+ })
122
+
123
+ // GET /api/cli-session — returns current CLI session ID for cross-device resume
124
+ healthRouter.get('/cli-session', (req, res) => {
125
+ const cosSessionId = req.query.sid as string | undefined
126
+ const cliSid = getAvailableCliSessionId(cosSessionId)
127
+ res.json({ cliSessionId: cliSid ?? null })
128
+ })