@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,52 @@
1
+ // Reusable circuit breaker for `claude -p` callers. After `maxFailures`
2
+ // consecutive failures the breaker OPENS for `cooldownMs`, then HALF-OPENS
3
+ // (allows one trial call); a success CLOSES it. Mirrors the inline breaker in
4
+ // routes/meeting.ts:38-71, but as a standalone instance so independent callers
5
+ // (e.g. the outbound-dictation auto-clean) keep SEPARATE failure accounting —
6
+ // a meeting-correction failure must not open the dictation breaker, and vice
7
+ // versa.
8
+
9
+ export interface ClaudeBreaker {
10
+ /** True when the breaker is OPEN — skip the call. */
11
+ isOpen(): boolean
12
+ recordFailure(): void
13
+ recordSuccess(): void
14
+ }
15
+
16
+ export function createBreaker(opts: {
17
+ label: string
18
+ maxFailures?: number
19
+ cooldownMs?: number
20
+ }): ClaudeBreaker {
21
+ const maxFailures = opts.maxFailures ?? 2
22
+ const cooldownMs = opts.cooldownMs ?? 30 * 60 * 1000 // 30 minutes
23
+ let consecutiveFailures = 0
24
+ let openedAt = 0
25
+
26
+ return {
27
+ isOpen(): boolean {
28
+ if (consecutiveFailures < maxFailures) return false
29
+ const elapsed = Date.now() - openedAt
30
+ if (elapsed >= cooldownMs) {
31
+ // Half-open: allow one attempt to see if it recovered.
32
+ console.log(`[${opts.label}] circuit HALF-OPEN — cooldown elapsed (${(elapsed / 60000).toFixed(0)}min), trying one call`)
33
+ return false
34
+ }
35
+ return true
36
+ },
37
+ recordFailure(): void {
38
+ consecutiveFailures++
39
+ if (consecutiveFailures >= maxFailures) {
40
+ openedAt = Date.now()
41
+ console.error(`[${opts.label}] ⚠ circuit OPEN — ${consecutiveFailures} consecutive failures. Retry in ${(cooldownMs / 60000).toFixed(0)}min`)
42
+ }
43
+ },
44
+ recordSuccess(): void {
45
+ if (consecutiveFailures > 0) {
46
+ console.log(`[${opts.label}] circuit CLOSED — recovered after ${consecutiveFailures} failure(s)`)
47
+ consecutiveFailures = 0
48
+ openedAt = 0
49
+ }
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,279 @@
1
+ import crypto from 'node:crypto'
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
+ import { dataPath } from './data-dir.js'
6
+ import type { ClaudeModelPreference } from '../../shared/model-preference.js'
7
+
8
+ const DEFAULT_MAX_RUNS = 100
9
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
10
+ const ERROR_PREVIEW_CHARS = 160
11
+ const RUNNING_STALE_MS = 30 * 60_000
12
+ export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
13
+ export type ClaudeEffortLevel = typeof CLAUDE_EFFORT_LEVELS[number]
14
+ export const DEFAULT_CLAUDE_EFFORT_LEVEL: ClaudeEffortLevel = 'high'
15
+
16
+ function getProcessStartedAtMs(): number {
17
+ return Date.now() - Math.floor(process.uptime() * 1000)
18
+ }
19
+
20
+ export type ClaudeRunStatus =
21
+ | 'running'
22
+ | 'completed'
23
+ | 'failed'
24
+ | 'cancelled'
25
+ | 'client_disconnected'
26
+
27
+ export interface ClaudeRunRecord {
28
+ runId: string
29
+ cosSessionId: string
30
+ cliSessionId?: string
31
+ status: ClaudeRunStatus
32
+ createdAt: string
33
+ updatedAt: string
34
+ model: ClaudeModelPreference
35
+ cliCommand: string
36
+ effortLevel: ClaudeEffortLevel
37
+ cwd: string
38
+ resumed: boolean
39
+ trustMode: 'full-access'
40
+ timeoutMs: number
41
+ wallMaxMs: number
42
+ queryPreview?: string
43
+ outputPreview?: string
44
+ errorCode?: string
45
+ errorPreview?: string
46
+ durationMs?: number
47
+ exitCode?: number | null
48
+ }
49
+
50
+ interface ClaudeRunEvent {
51
+ runId: string
52
+ ts: string
53
+ patch: Partial<ClaudeRunRecord>
54
+ }
55
+
56
+ export interface ClaudeRunConfig {
57
+ cliCommand: string
58
+ persistenceEnabled: boolean
59
+ cwd: string
60
+ trustMode: 'full-access'
61
+ defaultEffortLevel: ClaudeEffortLevel
62
+ historyLimit: number
63
+ historyTtlDays: number
64
+ contentPreviewsEnabled: boolean
65
+ }
66
+
67
+ export function areClaudeContentPreviewsEnabled(): boolean {
68
+ return process.env.COS_CLAUDE_RUN_CONTENT_PREVIEWS === '1'
69
+ }
70
+
71
+ export function getClaudeExecutionCwd(): string {
72
+ return resolve(COS_SCRIPTS_DIR ?? process.cwd())
73
+ }
74
+
75
+ export function getClaudeEffortLevel(): ClaudeEffortLevel {
76
+ const raw = process.env.COS_CLAUDE_EFFORT_LEVEL?.trim()
77
+ if (!raw) return DEFAULT_CLAUDE_EFFORT_LEVEL
78
+ if ((CLAUDE_EFFORT_LEVELS as readonly string[]).includes(raw)) {
79
+ return raw as ClaudeEffortLevel
80
+ }
81
+ console.warn(
82
+ `[claude-run-ledger] Invalid COS_CLAUDE_EFFORT_LEVEL="${raw}"; ` +
83
+ `using ${DEFAULT_CLAUDE_EFFORT_LEVEL}. Valid: ${CLAUDE_EFFORT_LEVELS.join(', ')}`,
84
+ )
85
+ return DEFAULT_CLAUDE_EFFORT_LEVEL
86
+ }
87
+
88
+ export function getClaudeRunConfig(): ClaudeRunConfig {
89
+ return {
90
+ cliCommand: 'claude -p',
91
+ persistenceEnabled: true,
92
+ cwd: getClaudeExecutionCwd(),
93
+ trustMode: 'full-access',
94
+ defaultEffortLevel: getClaudeEffortLevel(),
95
+ historyLimit: getMaxRuns(),
96
+ historyTtlDays: Math.round(getTtlMs() / (24 * 60 * 60_000)),
97
+ contentPreviewsEnabled: areClaudeContentPreviewsEnabled(),
98
+ }
99
+ }
100
+
101
+ export function getClaudeLedgerPath(): string {
102
+ return resolve(process.env.COS_CLAUDE_RUN_LEDGER_FILE || dataPath('claude-runs.jsonl'))
103
+ }
104
+
105
+ function getMaxRuns(): number {
106
+ const raw = Number(process.env.COS_CLAUDE_RUN_LEDGER_MAX ?? DEFAULT_MAX_RUNS)
107
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_RUNS
108
+ }
109
+
110
+ function getTtlMs(): number {
111
+ const rawDays = Number(process.env.COS_CLAUDE_RUN_LEDGER_TTL_DAYS ?? 7)
112
+ return Number.isFinite(rawDays) && rawDays > 0 ? rawDays * 24 * 60 * 60_000 : DEFAULT_TTL_MS
113
+ }
114
+
115
+ function appendEvent(event: ClaudeRunEvent): void {
116
+ try {
117
+ const path = getClaudeLedgerPath()
118
+ mkdirSync(dirname(path), { recursive: true })
119
+ appendFileSync(path, JSON.stringify(event) + '\n')
120
+ } catch (err) {
121
+ console.warn('[claude-run-ledger] write skipped:', err)
122
+ }
123
+ }
124
+
125
+ function readEvents(): ClaudeRunEvent[] {
126
+ const path = getClaudeLedgerPath()
127
+ if (!existsSync(path)) return []
128
+ try {
129
+ const events: ClaudeRunEvent[] = []
130
+ for (const line of readFileSync(path, 'utf-8')
131
+ .split('\n')
132
+ .map(line => line.trim())
133
+ .filter(Boolean)) {
134
+ try {
135
+ const event = JSON.parse(line) as ClaudeRunEvent
136
+ if (typeof event.runId === 'string' && typeof event.ts === 'string' && typeof event.patch === 'object') {
137
+ events.push(event)
138
+ }
139
+ } catch {
140
+ // Skip torn/corrupt JSONL rows; valid prior records should stay visible.
141
+ }
142
+ }
143
+ return events
144
+ } catch {
145
+ return []
146
+ }
147
+ }
148
+
149
+ function hydrateRuns(): ClaudeRunRecord[] {
150
+ const runs = new Map<string, ClaudeRunRecord>()
151
+ const order = new Map<string, number>()
152
+ let eventIndex = 0
153
+ for (const event of readEvents()) {
154
+ eventIndex += 1
155
+ const existing = runs.get(event.runId)
156
+ const next = { ...(existing ?? {}), ...event.patch, runId: event.runId } as ClaudeRunRecord
157
+ runs.set(event.runId, next)
158
+ order.set(event.runId, eventIndex)
159
+ }
160
+
161
+ const cutoff = Date.now() - getTtlMs()
162
+ return Array.from(runs.values())
163
+ .filter(run => run.createdAt && Date.parse(run.updatedAt || run.createdAt) >= cutoff)
164
+ .map(run => {
165
+ const updatedMs = Date.parse(run.updatedAt || run.createdAt)
166
+ const predatesCurrentProcess = updatedMs < getProcessStartedAtMs() - 1000
167
+ if (run.status === 'running' && (predatesCurrentProcess || Date.now() - updatedMs > RUNNING_STALE_MS)) {
168
+ return {
169
+ ...run,
170
+ status: 'client_disconnected' as ClaudeRunStatus,
171
+ errorCode: run.errorCode ?? 'claude.interrupted',
172
+ }
173
+ }
174
+ return run
175
+ })
176
+ .sort((a, b) => {
177
+ const byCreated = Date.parse(b.createdAt) - Date.parse(a.createdAt)
178
+ if (byCreated !== 0) return byCreated
179
+ return (order.get(b.runId) ?? 0) - (order.get(a.runId) ?? 0)
180
+ })
181
+ .slice(0, getMaxRuns())
182
+ }
183
+
184
+ export function redactForClaudeLedger(value: string, maxChars = ERROR_PREVIEW_CHARS): string {
185
+ return value
186
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[email]')
187
+ .replace(/\b(?:sk|sess|ghp|github_pat|glpat)-[A-Za-z0-9_\-]{12,}\b/g, '[token]')
188
+ .replace(/\bBearer\s+[A-Za-z0-9._\-]{12,}\b/gi, 'Bearer [token]')
189
+ .replace(/[A-Za-z0-9+/=]{80,}/g, '[blob]')
190
+ .replace(/\s+/g, ' ')
191
+ .trim()
192
+ .slice(0, maxChars)
193
+ }
194
+
195
+ export function classifyClaudeError(message: string): string {
196
+ const text = message.toLowerCase()
197
+ if (/command not found|enoent|not found/.test(text)) return 'claude.cli_unavailable'
198
+ if (/permission|denied|sandbox|read-only|operation not permitted/.test(text)) return 'claude.permission_denied'
199
+ if (/auth|login|sign in|unauthorized|forbidden|token/.test(text)) return 'claude.auth_error'
200
+ if (/timeout|timed out|wall clock|no output/.test(text)) return 'claude.timeout'
201
+ if (/exit\s+\d+/.test(text)) return 'claude.nonzero_exit'
202
+ return 'claude.error'
203
+ }
204
+
205
+ export function startClaudeRun(input: {
206
+ cosSessionId: string
207
+ model: ClaudeModelPreference
208
+ cwd: string
209
+ resumed: boolean
210
+ cliSessionId?: string
211
+ timeoutMs: number
212
+ wallMaxMs: number
213
+ query: string
214
+ }): ClaudeRunRecord {
215
+ const now = new Date().toISOString()
216
+ const run: ClaudeRunRecord = {
217
+ runId: `claude-${crypto.randomUUID().slice(0, 8)}`,
218
+ cosSessionId: input.cosSessionId,
219
+ cliSessionId: input.cliSessionId,
220
+ status: 'running',
221
+ createdAt: now,
222
+ updatedAt: now,
223
+ model: input.model,
224
+ cliCommand: 'claude -p',
225
+ effortLevel: getClaudeEffortLevel(),
226
+ cwd: input.cwd,
227
+ resumed: input.resumed,
228
+ trustMode: 'full-access',
229
+ timeoutMs: input.timeoutMs,
230
+ wallMaxMs: input.wallMaxMs,
231
+ }
232
+ if (areClaudeContentPreviewsEnabled()) {
233
+ run.queryPreview = redactForClaudeLedger(input.query)
234
+ }
235
+ appendEvent({ runId: run.runId, ts: now, patch: run })
236
+ return run
237
+ }
238
+
239
+ export function updateClaudeRun(runId: string, patch: Partial<Omit<ClaudeRunRecord, 'runId' | 'createdAt'>>): ClaudeRunRecord | null {
240
+ const ts = new Date().toISOString()
241
+ appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } })
242
+ return getClaudeRun(runId)
243
+ }
244
+
245
+ export function finishClaudeRun(runId: string, input: {
246
+ status: Exclude<ClaudeRunStatus, 'running'>
247
+ startedAtMs: number
248
+ output?: string
249
+ error?: string
250
+ exitCode?: number | null
251
+ }): ClaudeRunRecord | null {
252
+ const patch: Partial<ClaudeRunRecord> = {
253
+ status: input.status,
254
+ durationMs: Math.max(0, Date.now() - input.startedAtMs),
255
+ exitCode: input.exitCode,
256
+ }
257
+ if (input.output && areClaudeContentPreviewsEnabled()) {
258
+ patch.outputPreview = redactForClaudeLedger(input.output)
259
+ }
260
+ if (input.error) {
261
+ patch.errorCode = classifyClaudeError(input.error)
262
+ if (areClaudeContentPreviewsEnabled()) {
263
+ patch.errorPreview = redactForClaudeLedger(input.error)
264
+ }
265
+ }
266
+ return updateClaudeRun(runId, patch)
267
+ }
268
+
269
+ export function listClaudeRuns(limit = 20, cosSessionId?: string, model?: ClaudeModelPreference): ClaudeRunRecord[] {
270
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), getMaxRuns()) : 20
271
+ return hydrateRuns()
272
+ .filter(run => !cosSessionId || run.cosSessionId === cosSessionId)
273
+ .filter(run => !model || run.model === model)
274
+ .slice(0, safeLimit)
275
+ }
276
+
277
+ export function getClaudeRun(runId: string): ClaudeRunRecord | null {
278
+ return hydrateRuns().find(run => run.runId === runId) ?? null
279
+ }