@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
|
@@ -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
|
+
})
|