@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,50 @@
1
+ // Token audit logger — zero-cost file I/O tracking for all claude -p calls.
2
+ // Both Python (COS scripts) and TypeScript (G2 glasses) write to the same JSONL.
3
+ // No LLM calls. Pure file append.
4
+
5
+ import { appendFileSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+
9
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
10
+
11
+ // Shared JSONL file — same path as Python's llm_client.py uses
12
+ // Uses COS_SCRIPTS_DIR when available (server mode), falls back to user home
13
+ const AUDIT_FILE = COS_SCRIPTS_DIR
14
+ ? resolve(COS_SCRIPTS_DIR, '.cos_token_audit.jsonl')
15
+ : resolve(homedir(), '.cos-glasses', 'token-audit.jsonl')
16
+
17
+ // Rough char-to-token ratio (1 token ~ 4 chars for English)
18
+ const CHARS_PER_TOKEN = 4
19
+
20
+ function estimateTokens(chars: number): number {
21
+ return Math.max(1, Math.floor(chars / CHARS_PER_TOKEN))
22
+ }
23
+
24
+ export interface TokenAuditEntry {
25
+ source: string // "g2-voice", "g2-prewarm", "g2-archive", "g2-query"
26
+ model: string // "opus", "sonnet", "haiku"
27
+ inputChars: number
28
+ outputChars: number
29
+ durationMs: number
30
+ caller: string // "voice_query", "prewarm", "chat_summary", "day_summary"
31
+ }
32
+
33
+ export function logTokenAudit(entry: TokenAuditEntry): void {
34
+ const record = {
35
+ ts: new Date().toISOString(),
36
+ source: entry.source,
37
+ model: entry.model,
38
+ input_chars: entry.inputChars,
39
+ output_chars: entry.outputChars,
40
+ est_input_tokens: estimateTokens(entry.inputChars),
41
+ est_output_tokens: estimateTokens(entry.outputChars),
42
+ duration_ms: entry.durationMs,
43
+ caller: entry.caller,
44
+ }
45
+ try {
46
+ appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n')
47
+ } catch {
48
+ // Never let logging break the actual call
49
+ }
50
+ }
@@ -0,0 +1,187 @@
1
+ import {
2
+ isWhisperLocalAvailable,
3
+ transcribeLocal,
4
+ transcribeHighQuality,
5
+ getWhisperBackend,
6
+ } from './whisper-local.js'
7
+ import { getVocabulary, getOwnerName } from './profile.js'
8
+ import { applyFuzzyCorrections } from './fuzzy-correct.js'
9
+ import { getAllSpeakerNames } from './speaker-embeddings.js'
10
+ import {
11
+ assertOpenAIWhisperBudget,
12
+ recordOpenAIWhisperUsage,
13
+ estimateAudioSeconds,
14
+ OpenAIWhisperBudgetExhaustedError,
15
+ } from './openai-whisper-budget.js'
16
+ import { enhanceAudio } from './audio-enhance.js'
17
+ import {
18
+ stripInlineHallucinationsOneShot,
19
+ isFullHallucination,
20
+ } from './hallucination-filter.js'
21
+ import { getOpenAIKey } from './openai-key.js'
22
+
23
+ export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
24
+
25
+ export type TranscribeMode = 'hq' | 'fast'
26
+
27
+ export interface TranscribeAudioResult {
28
+ text: string
29
+ backend: string
30
+ mode: TranscribeMode
31
+ elapsedMs: number
32
+ audioBytes: number
33
+ }
34
+
35
+ export class NoSpeechDetectedError extends Error {
36
+ readonly reason = 'no_speech'
37
+
38
+ constructor(readonly rawText = '') {
39
+ super('No speech detected')
40
+ this.name = 'NoSpeechDetectedError'
41
+ }
42
+ }
43
+
44
+ // HQ clips > this fall back to fast mode — large-v3 latency scales roughly linearly
45
+ // with audio length and beam-search × best-of amplifies that. 60s is the user-perceived
46
+ // ceiling (anything longer is a dictation, not a query — use meetings instead).
47
+ const HQ_MAX_SECONDS = 60
48
+
49
+ /** Transcribe via OpenAI Whisper API (cloud fallback).
50
+ * Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
51
+ * Ledger only ticks on SUCCESSFUL responses so retries that never reach the API
52
+ * aren't double-counted. */
53
+ async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
54
+ assertOpenAIWhisperBudget()
55
+
56
+ const key = getOpenAIKey()
57
+ const audioSeconds = estimateAudioSeconds(audioBuffer)
58
+
59
+ // Detect format from magic bytes.
60
+ const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
61
+ const filename = isWav ? 'recording.wav' : 'recording.webm'
62
+ const mimeType = isWav ? 'audio/wav' : 'audio/webm'
63
+
64
+ const boundary = '----COS' + Date.now()
65
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`
66
+ const modelPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1`
67
+ const languagePart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nen`
68
+ const vocab = getVocabulary()
69
+ const cloudPrompt = vocab.length > 0
70
+ ? [getOwnerName(), ...vocab].join(', ')
71
+ : `${getOwnerName()}, COS Glasses, Even G2`
72
+ const promptPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="prompt"\r\n\r\n${cloudPrompt}`
73
+ const footer = `\r\n--${boundary}--\r\n`
74
+
75
+ const body = Buffer.concat([
76
+ Buffer.from(header),
77
+ audioBuffer,
78
+ Buffer.from(modelPart),
79
+ Buffer.from(languagePart),
80
+ Buffer.from(promptPart),
81
+ Buffer.from(footer),
82
+ ])
83
+
84
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Authorization': `Bearer ${key}`,
88
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
89
+ },
90
+ body,
91
+ })
92
+
93
+ if (!response.ok) {
94
+ const errText = await response.text()
95
+ throw new Error(`Whisper API ${response.status}: ${errText.slice(0, 200)}`)
96
+ }
97
+
98
+ const result = await response.json() as { text: string }
99
+ recordOpenAIWhisperUsage(audioSeconds)
100
+ return result.text
101
+ }
102
+
103
+ export function resolveTranscribeMode(raw: unknown): TranscribeMode {
104
+ return String(raw ?? '').toLowerCase() === 'fast' ? 'fast' : 'hq'
105
+ }
106
+
107
+ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?: TranscribeMode } = {}): Promise<TranscribeAudioResult> {
108
+ const requestedMode = opts.mode ?? 'hq'
109
+ const audioSeconds = estimateAudioSeconds(audioBuffer)
110
+ const effectiveMode: TranscribeMode =
111
+ requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
112
+
113
+ if (effectiveMode !== requestedMode) {
114
+ console.log(`[transcribe] mode downgrade: hq → fast (audio ${audioSeconds.toFixed(1)}s > cap ${HQ_MAX_SECONDS}s)`)
115
+ }
116
+
117
+ let text: string
118
+ let backend: string
119
+ const tStart = performance.now()
120
+
121
+ if (effectiveMode === 'hq' && isWhisperLocalAvailable()) {
122
+ try {
123
+ const enhanced = await enhanceAudio(audioBuffer)
124
+ const result = await transcribeHighQuality(enhanced)
125
+ text = result.text
126
+ backend = 'hq-large-v3'
127
+ } catch (hqErr: any) {
128
+ console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
129
+ try {
130
+ const result = await transcribeLocal(audioBuffer)
131
+ text = result.text
132
+ backend = `fast-local-${result.backend}`
133
+ } catch (localErr: any) {
134
+ console.warn(`[transcribe] Fast local also failed, falling back to cloud: ${localErr.message}`)
135
+ text = await transcribeCloud(audioBuffer)
136
+ backend = 'cloud'
137
+ }
138
+ }
139
+ } else if (effectiveMode === 'fast' && isWhisperLocalAvailable()) {
140
+ try {
141
+ const result = await transcribeLocal(audioBuffer)
142
+ text = result.text
143
+ backend = `fast-local-${result.backend}`
144
+ } catch (localErr: any) {
145
+ console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}), falling back to cloud: ${localErr.message}`)
146
+ text = await transcribeCloud(audioBuffer)
147
+ backend = 'cloud'
148
+ }
149
+ } else {
150
+ text = await transcribeCloud(audioBuffer)
151
+ backend = 'cloud'
152
+ }
153
+
154
+ if (text && text.length > 0) {
155
+ try {
156
+ text = stripInlineHallucinationsOneShot(text)
157
+ } catch (stripErr: any) {
158
+ console.warn(`[transcribe] Hallucination strip failed (non-fatal): ${stripErr.message}`)
159
+ }
160
+ }
161
+
162
+ if (text && text.length > 0) {
163
+ try {
164
+ const fuzzyTargets = [...getAllSpeakerNames(), ...getVocabulary()]
165
+ const { text: corrected, replacements } = applyFuzzyCorrections(text, fuzzyTargets)
166
+ if (replacements > 0) {
167
+ console.log(`[transcribe] Fuzzy corrected ${replacements} word(s)`)
168
+ text = corrected
169
+ }
170
+ } catch (fuzzyErr: any) {
171
+ console.warn(`[transcribe] Fuzzy correction failed (non-fatal): ${fuzzyErr.message}`)
172
+ }
173
+ }
174
+
175
+ const elapsedMs = performance.now() - tStart
176
+ if (!text || isFullHallucination(text)) {
177
+ throw new NoSpeechDetectedError(text || '')
178
+ }
179
+
180
+ return {
181
+ text: text.trim(),
182
+ backend,
183
+ mode: effectiveMode,
184
+ elapsedMs,
185
+ audioBytes: audioBuffer.length,
186
+ }
187
+ }
@@ -0,0 +1,5 @@
1
+ /** Safely extract error message from unknown catch value */
2
+ export function errMsg(err: unknown): string {
3
+ if (err instanceof Error) return err.message
4
+ return String(err)
5
+ }
@@ -0,0 +1,179 @@
1
+ // Silero VAD — trims silence from audio chunks before Whisper inference.
2
+ // Uses sherpa-onnx-node's built-in Silero VAD (same package as speaker embeddings).
3
+ // Graceful fallback: if model missing or init fails, returns audio unchanged.
4
+ //
5
+ // Architecture: client keeps RMS-based chunking (decides WHEN to flush).
6
+ // Server receives chunk, runs Silero VAD to extract speech segments,
7
+ // trims silence edges, sends only speech to Whisper.
8
+
9
+ import { resolve } from 'node:path'
10
+ import { existsSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ // sherpa-onnx-node is CJS — use createRequire for ESM compat
14
+ import { createRequire } from 'node:module'
15
+ const require = createRequire(import.meta.url)
16
+
17
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
18
+ const MODEL_PATH = resolve(__dirname, '..', 'models', 'silero_vad.onnx')
19
+
20
+ const SAMPLE_RATE = 16000
21
+ const BYTES_PER_SAMPLE = 2 // 16-bit PCM
22
+ const WAV_HEADER_SIZE = 44
23
+ // Minimum samples for Silero VAD to produce meaningful results (512 = one window)
24
+ const MIN_SAMPLES = 512
25
+
26
+ // Module-level state — single Vad instance reused via reset() (~11ms vs 45ms per-call)
27
+ let sherpaOnnx: any = null
28
+ let available = false
29
+ let vadInstance: any = null
30
+
31
+ export interface TrimResult {
32
+ trimmedWav: Buffer
33
+ speechRatio: number
34
+ segments: Array<{ startSample: number; sampleCount: number }>
35
+ }
36
+
37
+ /** Initialize Silero VAD. Returns true if model loaded, false otherwise. */
38
+ export function initSileroVAD(): boolean {
39
+ if (!existsSync(MODEL_PATH)) {
40
+ console.log('[silero-vad] Model not found at', MODEL_PATH, '— VAD disabled, audio passes through untrimmed')
41
+ return false
42
+ }
43
+
44
+ try {
45
+ sherpaOnnx = require('sherpa-onnx-node')
46
+ // Create the reusable Vad instance (kept alive for server lifetime)
47
+ // bufferSizeInSeconds: 10 — chunks are max 6s, no need for 60s buffer
48
+ vadInstance = new sherpaOnnx.Vad(
49
+ {
50
+ sileroVad: { model: MODEL_PATH, threshold: 0.5, minSilenceDuration: 0.3, minSpeechDuration: 0.25, windowSize: 512 },
51
+ sampleRate: SAMPLE_RATE,
52
+ numThreads: 1,
53
+ provider: 'cpu',
54
+ },
55
+ 10, // bufferSizeInSeconds — chunks are max 6s
56
+ )
57
+ vadInstance.reset() // verify it works
58
+ available = true
59
+ console.log('[silero-vad] Initialized: Silero VAD active (reused instance, bufferSize=10s)')
60
+ return true
61
+ } catch (err: any) {
62
+ console.error('[silero-vad] Init failed:', err.message)
63
+ available = false
64
+ return false
65
+ }
66
+ }
67
+
68
+ /** Check if Silero VAD is available */
69
+ export function isSileroAvailable(): boolean {
70
+ return available
71
+ }
72
+
73
+ /**
74
+ * Trim silence from a WAV buffer using Silero VAD.
75
+ * Returns the trimmed WAV + speech ratio for logging.
76
+ *
77
+ * Graceful: if anything fails, returns original buffer unchanged.
78
+ */
79
+ export function trimSilence(wavBuffer: Buffer): TrimResult {
80
+ const fallback: TrimResult = { trimmedWav: wavBuffer, speechRatio: 0.0, segments: [] }
81
+
82
+ if (!available || !vadInstance) return fallback
83
+
84
+ try {
85
+ // Parse WAV: extract raw PCM from after the 44-byte header
86
+ if (wavBuffer.length <= WAV_HEADER_SIZE + MIN_SAMPLES * BYTES_PER_SAMPLE) {
87
+ // Audio too short for VAD — return unchanged
88
+ return fallback
89
+ }
90
+
91
+ // Safe Int16Array extraction — slice guarantees alignment
92
+ const pcmBytes = wavBuffer.buffer.slice(
93
+ wavBuffer.byteOffset + WAV_HEADER_SIZE,
94
+ wavBuffer.byteOffset + wavBuffer.length,
95
+ )
96
+ const int16 = new Int16Array(pcmBytes)
97
+
98
+ if (int16.length < MIN_SAMPLES) return fallback
99
+
100
+ // Convert Int16 → Float32 (Silero expects [-1, 1] range)
101
+ const float32 = new Float32Array(int16.length)
102
+ for (let i = 0; i < int16.length; i++) {
103
+ float32[i] = int16[i] / 32768
104
+ }
105
+
106
+ // Reuse module-level Vad instance — reset() clears state between calls
107
+ // Benchmark: 11ms avg vs 45ms with per-call construction
108
+ vadInstance.reset()
109
+ vadInstance.acceptWaveform(float32)
110
+ vadInstance.flush()
111
+
112
+ // Collect speech segments
113
+ const speechSegments: Array<{ startSample: number; samples: Float32Array }> = []
114
+ let totalSpeechSamples = 0
115
+
116
+ while (!vadInstance.isEmpty()) {
117
+ const segment = vadInstance.front()
118
+ speechSegments.push({ startSample: segment.start, samples: segment.samples })
119
+ totalSpeechSamples += segment.samples.length
120
+ vadInstance.pop()
121
+ }
122
+
123
+ const speechRatio = int16.length > 0 ? totalSpeechSamples / int16.length : 0.0
124
+
125
+ if (speechSegments.length === 0 || totalSpeechSamples === 0) {
126
+ // No speech detected — return original unchanged
127
+ return { trimmedWav: wavBuffer, speechRatio: 0.0, segments: [] }
128
+ }
129
+
130
+ // Concatenate speech segments into a single Float32Array
131
+ const speechFloat32 = new Float32Array(totalSpeechSamples)
132
+ let offset = 0
133
+ const segmentInfo: TrimResult['segments'] = []
134
+ for (const seg of speechSegments) {
135
+ speechFloat32.set(seg.samples, offset)
136
+ segmentInfo.push({ startSample: seg.startSample, sampleCount: seg.samples.length })
137
+ offset += seg.samples.length
138
+ }
139
+
140
+ // Convert Float32 back to Int16 PCM
141
+ const speechInt16 = new Int16Array(totalSpeechSamples)
142
+ for (let i = 0; i < totalSpeechSamples; i++) {
143
+ // Clamp to [-1, 1] then scale to Int16 range
144
+ const clamped = Math.max(-1, Math.min(1, speechFloat32[i]))
145
+ speechInt16[i] = clamped < 0 ? clamped * 32768 : clamped * 32767
146
+ }
147
+
148
+ const pcmLength = speechInt16.length * BYTES_PER_SAMPLE
149
+ const trimmedWav = Buffer.alloc(WAV_HEADER_SIZE + pcmLength)
150
+
151
+ // Write WAV header (same format as audio-pipeline.ts pcmToWav)
152
+ trimmedWav.write('RIFF', 0)
153
+ trimmedWav.writeUInt32LE(WAV_HEADER_SIZE - 8 + pcmLength, 4)
154
+ trimmedWav.write('WAVE', 8)
155
+ trimmedWav.write('fmt ', 12)
156
+ trimmedWav.writeUInt32LE(16, 16) // sub-chunk size
157
+ trimmedWav.writeUInt16LE(1, 20) // PCM format
158
+ trimmedWav.writeUInt16LE(1, 22) // mono
159
+ trimmedWav.writeUInt32LE(SAMPLE_RATE, 24)
160
+ trimmedWav.writeUInt32LE(SAMPLE_RATE * BYTES_PER_SAMPLE, 28) // byte rate
161
+ trimmedWav.writeUInt16LE(BYTES_PER_SAMPLE, 32) // block align
162
+ trimmedWav.writeUInt16LE(16, 34) // bits per sample
163
+ trimmedWav.write('data', 36)
164
+ trimmedWav.writeUInt32LE(pcmLength, 40)
165
+
166
+ // Copy PCM data after header
167
+ Buffer.from(speechInt16.buffer).copy(trimmedWav, WAV_HEADER_SIZE)
168
+
169
+ // Guard: if trimmed result is too small for Whisper, fall back to original
170
+ if (trimmedWav.length < 100) {
171
+ return { trimmedWav: wavBuffer, speechRatio, segments: segmentInfo }
172
+ }
173
+
174
+ return { trimmedWav, speechRatio, segments: segmentInfo }
175
+ } catch (err: any) {
176
+ console.error('[silero-vad] trimSilence error:', err.message)
177
+ return fallback
178
+ }
179
+ }