@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,266 @@
1
+ // Writes .session_index_cache_COS-Glasses.json to COS_SCRIPTS_DIR
2
+ // so the TUI Sessions view picks up glasses sessions alongside desktop ones.
3
+ // Transforms active sessions + archived chats into SessionHistoryEntry format.
4
+
5
+ import { writeFileSync, renameSync, readdirSync, readFileSync } from 'node:fs'
6
+ import { resolve, dirname } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import type { DailyArchive } from './archive.js'
9
+
10
+ // Lazy import to break circular dependency:
11
+ // conversation.ts imports us → we import conversation.ts → sessions Map not yet initialized
12
+ let _getActiveSessions: (() => any[]) | null = null
13
+ function getActiveSessions(): any[] {
14
+ if (!_getActiveSessions) {
15
+ // Dynamic import would be async; instead, caller injects via setSessionProvider()
16
+ return []
17
+ }
18
+ return _getActiveSessions()
19
+ }
20
+
21
+ /** Called by conversation.ts after sessions Map is initialized */
22
+ export function setSessionProvider(fn: () => any[]): void {
23
+ _getActiveSessions = fn
24
+ }
25
+
26
+ // Import COS_SCRIPTS_DIR independently — don't crash server if missing
27
+ let SCRIPTS_DIR: string | null = null
28
+ try {
29
+ if (process.env.COS_SCRIPTS_DIR) {
30
+ SCRIPTS_DIR = resolve(process.env.COS_SCRIPTS_DIR)
31
+ }
32
+ } catch { /* no-op */ }
33
+
34
+ const DEVICE_ID = 'COS-Glasses'
35
+ const __dirname = dirname(fileURLToPath(import.meta.url))
36
+ import { dataPath } from './data-dir.js'
37
+ const ARCHIVE_DIR = dataPath('archive')
38
+ const MAX_ARCHIVE_AGE_DAYS = 30
39
+
40
+ interface SessionCacheEntry {
41
+ session_id: string
42
+ glasses_session_id: string // Original UUID (e.g. "dea05c6e") — preserved for COS lookups
43
+ slug: string
44
+ created: string
45
+ modified: string
46
+ duration_minutes: number
47
+ user_message_count: number
48
+ assistant_message_count: number
49
+ message_count: number
50
+ first_prompt: string
51
+ tools_used: Record<string, number>
52
+ files_touched: string[]
53
+ domain: string
54
+ git_branch: string
55
+ has_subagents: boolean
56
+ total_input_tokens: number
57
+ total_output_tokens: number
58
+ file_size_bytes: number
59
+ device_id: string
60
+ }
61
+
62
+ // Domain classification from user message text — lightweight keyword vote.
63
+ // Customize the keyword lists for your own workspaces.
64
+ const DOMAIN_TEXT_RULES: [string[], string][] = [
65
+ [['personal', 'family', 'home', 'health', 'glasses', 'oura', 'even g2'], 'personal'],
66
+ ]
67
+
68
+ function classifyDomain(userMessages: string[]): string {
69
+ const votes: Record<string, number> = {}
70
+ const text = userMessages.join(' ').toLowerCase()
71
+
72
+ for (const [keywords, domain] of DOMAIN_TEXT_RULES) {
73
+ for (const kw of keywords) {
74
+ if (text.includes(kw)) {
75
+ votes[domain] = (votes[domain] || 0) + 1
76
+ }
77
+ }
78
+ }
79
+
80
+ let best = ''
81
+ let bestCount = 0
82
+ for (const [domain, count] of Object.entries(votes)) {
83
+ if (count > bestCount) {
84
+ best = domain
85
+ bestCount = count
86
+ }
87
+ }
88
+
89
+ return best || 'personal'
90
+ }
91
+
92
+ function buildEntryFromArchiveChat(
93
+ chat: DailyArchive['chats'][0],
94
+ date: string,
95
+ chatIndex: number,
96
+ ): SessionCacheEntry {
97
+ const userExchanges = chat.exchanges.filter(e => e.role === 'user')
98
+ const assistantExchanges = chat.exchanges.filter(e => e.role === 'assistant')
99
+ const userMessages = userExchanges.map(e => e.content)
100
+ const firstUserContent = userMessages[0] ?? ''
101
+
102
+ // Preserve original session UUID if archived (added in v3.9.0), else fallback to index-based ID
103
+ const originalId = (chat as any).sessionId || ''
104
+
105
+ return {
106
+ session_id: `glasses-${date}-${chatIndex}`,
107
+ glasses_session_id: originalId,
108
+ slug: chat.summary || firstUserContent.slice(0, 60) || 'Glasses chat',
109
+ created: new Date(chat.startedAt).toISOString(),
110
+ modified: new Date(chat.endedAt).toISOString(),
111
+ duration_minutes: Math.round((chat.endedAt - chat.startedAt) / 60_000),
112
+ user_message_count: userExchanges.length,
113
+ assistant_message_count: assistantExchanges.length,
114
+ message_count: chat.exchangeCount,
115
+ first_prompt: firstUserContent.slice(0, 200),
116
+ tools_used: {},
117
+ files_touched: [],
118
+ domain: classifyDomain(userMessages),
119
+ git_branch: 'n/a',
120
+ has_subagents: false,
121
+ total_input_tokens: 0,
122
+ total_output_tokens: 0,
123
+ file_size_bytes: 0,
124
+ device_id: DEVICE_ID,
125
+ }
126
+ }
127
+
128
+ function buildEntryFromActiveSession(session: {
129
+ id: string
130
+ exchanges: Array<{ role: string; content: string; timestamp: number }>
131
+ createdAt: number
132
+ lastActivity: number
133
+ }): SessionCacheEntry {
134
+ const userExchanges = session.exchanges.filter(e => e.role === 'user')
135
+ const assistantExchanges = session.exchanges.filter(e => e.role === 'assistant')
136
+ const userMessages = userExchanges.map(e => e.content)
137
+ const firstUserContent = userMessages[0] ?? ''
138
+
139
+ return {
140
+ session_id: `glasses-${session.id}`,
141
+ glasses_session_id: session.id,
142
+ slug: firstUserContent.slice(0, 60) || 'Active glasses session',
143
+ created: new Date(session.createdAt).toISOString(),
144
+ modified: new Date(session.lastActivity).toISOString(),
145
+ duration_minutes: Math.round((session.lastActivity - session.createdAt) / 60_000),
146
+ user_message_count: userExchanges.length,
147
+ assistant_message_count: assistantExchanges.length,
148
+ message_count: session.exchanges.length,
149
+ first_prompt: firstUserContent.slice(0, 200),
150
+ tools_used: {},
151
+ files_touched: [],
152
+ domain: classifyDomain(userMessages),
153
+ git_branch: 'n/a',
154
+ has_subagents: false,
155
+ total_input_tokens: 0,
156
+ total_output_tokens: 0,
157
+ file_size_bytes: 0,
158
+ device_id: DEVICE_ID,
159
+ }
160
+ }
161
+
162
+ function loadArchivedSessions(): SessionCacheEntry[] {
163
+ const entries: SessionCacheEntry[] = []
164
+ const cutoff = Date.now() - MAX_ARCHIVE_AGE_DAYS * 86_400_000
165
+
166
+ try {
167
+ const files = readdirSync(ARCHIVE_DIR).filter(f => f.endsWith('.json')).sort()
168
+ for (const fname of files) {
169
+ const date = fname.replace('.json', '')
170
+ // Skip archives older than 30 days
171
+ const fileDate = new Date(date + 'T00:00:00Z').getTime()
172
+ if (fileDate < cutoff) continue
173
+
174
+ try {
175
+ const raw = readFileSync(resolve(ARCHIVE_DIR, fname), 'utf-8')
176
+ const archive: DailyArchive = JSON.parse(raw)
177
+ for (let i = 0; i < archive.chats.length; i++) {
178
+ entries.push(buildEntryFromArchiveChat(archive.chats[i], date, i))
179
+ }
180
+ } catch {
181
+ // Skip corrupt archive files
182
+ }
183
+ }
184
+ } catch {
185
+ // Archive dir doesn't exist yet — fine
186
+ }
187
+
188
+ return entries
189
+ }
190
+
191
+ export function updateGlassesSessionCache(): void {
192
+ if (!SCRIPTS_DIR) {
193
+ console.warn('[session-cache] COS_SCRIPTS_DIR not set — skipping cache write')
194
+ return
195
+ }
196
+
197
+ const sessions: Record<string, SessionCacheEntry> = {}
198
+
199
+ // 1. Archived sessions
200
+ const archived = loadArchivedSessions()
201
+ for (const entry of archived) {
202
+ sessions[entry.session_id] = entry
203
+ }
204
+
205
+ // 2. Active sessions — always included (mutually exclusive with archives;
206
+ // conversation.ts deletes sessions from memory after archiving)
207
+ const active = getActiveSessions()
208
+ for (const session of active) {
209
+ const entry = buildEntryFromActiveSession(session)
210
+ sessions[entry.session_id] = entry
211
+ }
212
+
213
+ // 3. Atomic write: .tmp then rename
214
+ const cachePath = resolve(SCRIPTS_DIR, '.session_index_cache_COS-Glasses.json')
215
+ const tmpPath = cachePath + '.tmp'
216
+
217
+ try {
218
+ const cache = {
219
+ sessions,
220
+ device_id: DEVICE_ID,
221
+ updated_at: new Date().toISOString(),
222
+ session_count: Object.keys(sessions).length,
223
+ }
224
+ writeFileSync(tmpPath, JSON.stringify(cache, null, 2))
225
+ renameSync(tmpPath, cachePath)
226
+ } catch (err) {
227
+ console.error('[session-cache] Failed to write cache:', err)
228
+ }
229
+ }
230
+
231
+ // ── Debounced update ─────────────────────────────────────────
232
+
233
+ let cacheTimer: ReturnType<typeof setTimeout> | null = null
234
+
235
+ export function scheduleCacheUpdate(): void {
236
+ if (cacheTimer) return
237
+ cacheTimer = setTimeout(() => {
238
+ cacheTimer = null
239
+ updateGlassesSessionCache()
240
+ }, 5_000) // 5s debounce
241
+ }
242
+
243
+ /** Flush any pending debounced cache write immediately */
244
+ export function flushCacheWrite(): void {
245
+ if (cacheTimer) {
246
+ clearTimeout(cacheTimer)
247
+ cacheTimer = null
248
+ updateGlassesSessionCache()
249
+ }
250
+ }
251
+
252
+ // ── Init (called from index.ts after all modules are loaded) ─
253
+ export function initSessionCache(): void {
254
+ updateGlassesSessionCache()
255
+ console.log('[session-cache] Initial cache written')
256
+ }
257
+
258
+ // ── SIGTERM/SIGINT safety ────────────────────────────────────
259
+
260
+ process.on('SIGTERM', () => {
261
+ flushCacheWrite()
262
+ })
263
+
264
+ process.on('SIGINT', () => {
265
+ flushCacheWrite()
266
+ })
@@ -0,0 +1,162 @@
1
+ // Session end logger — writes .glasses_sessions.jsonl to COS_SCRIPTS_DIR
2
+ // so COS can query Glasses sessions by original UUID, date, domain, or content.
3
+ // Fires on: TTL expiry, explicit /api/sessions/:id/end, server shutdown.
4
+
5
+ import { appendFileSync, mkdirSync } from 'node:fs'
6
+ import { resolve, dirname } from 'node:path'
7
+ import type { Exchange } from './conversation.js'
8
+
9
+ // Resolve COS_SCRIPTS_DIR independently (same pattern as session-cache-writer)
10
+ let SCRIPTS_DIR: string | null = null
11
+ try {
12
+ if (process.env.COS_SCRIPTS_DIR) {
13
+ SCRIPTS_DIR = resolve(process.env.COS_SCRIPTS_DIR)
14
+ }
15
+ } catch { /* no-op */ }
16
+
17
+ const LOG_FILE_NAME = '.glasses_sessions.jsonl'
18
+
19
+ // Domain classification — lightweight keyword vote, identical to session-cache-writer.ts.
20
+ // Customize the keyword lists for your own workspaces; defaults to a work/personal split.
21
+ const DOMAIN_TEXT_RULES: [string[], string][] = [
22
+ [['personal', 'family', 'home', 'health', 'glasses', 'oura', 'even g2'], 'personal'],
23
+ ]
24
+
25
+ function classifyDomain(userMessages: string[]): string {
26
+ const votes: Record<string, number> = {}
27
+ const text = userMessages.join(' ').toLowerCase()
28
+
29
+ for (const [keywords, domain] of DOMAIN_TEXT_RULES) {
30
+ for (const kw of keywords) {
31
+ if (text.includes(kw)) {
32
+ votes[domain] = (votes[domain] || 0) + 1
33
+ }
34
+ }
35
+ }
36
+
37
+ let best = ''
38
+ let bestCount = 0
39
+ for (const [domain, count] of Object.entries(votes)) {
40
+ if (count > bestCount) {
41
+ best = domain
42
+ bestCount = count
43
+ }
44
+ }
45
+
46
+ return best || 'personal'
47
+ }
48
+
49
+ export interface SessionLogEntry {
50
+ // Identity
51
+ session_id: string // Original 8-char UUID (e.g. "dea05c6e")
52
+ device_id: string // "COS-Glasses"
53
+
54
+ // Timing
55
+ created_at: string // ISO
56
+ ended_at: string // ISO
57
+ duration_minutes: number
58
+ end_reason: 'ttl_expiry' | 'explicit_end' | 'server_shutdown' | 'explicit_clear'
59
+
60
+ // Classification
61
+ domain: string // general | personal (customizable)
62
+ slug: string // <60 char summary (first user query until archive generates LLM title)
63
+
64
+ // Model
65
+ model_preference: string | null
66
+
67
+ // Counts
68
+ user_message_count: number
69
+ assistant_message_count: number
70
+ total_message_count: number
71
+
72
+ // Content — full paired Q&A for COS queries
73
+ messages: Array<{
74
+ query: string
75
+ response: string
76
+ timestamp: string // ISO
77
+ }>
78
+ }
79
+
80
+ export interface EndSessionInput {
81
+ id: string
82
+ exchanges: Exchange[]
83
+ createdAt: number
84
+ lastActivity: number
85
+ modelPreference: string | null
86
+ endReason: 'ttl_expiry' | 'explicit_end' | 'server_shutdown' | 'explicit_clear'
87
+ slug?: string // LLM-generated summary if available
88
+ }
89
+
90
+ /** Build a log entry from session data */
91
+ export function buildSessionLogEntry(input: EndSessionInput): SessionLogEntry {
92
+ const userExchanges = input.exchanges.filter(e => e.role === 'user')
93
+ const assistantExchanges = input.exchanges.filter(e => e.role === 'assistant')
94
+ const userMessages = userExchanges.map(e => e.content)
95
+
96
+ // Pair user+assistant into messages
97
+ const messages: SessionLogEntry['messages'] = []
98
+ for (let i = 0; i < input.exchanges.length; i++) {
99
+ const ex = input.exchanges[i]
100
+ if (ex.role === 'user') {
101
+ const next = input.exchanges[i + 1]
102
+ if (next && next.role === 'assistant') {
103
+ messages.push({
104
+ query: ex.content,
105
+ response: next.content,
106
+ timestamp: new Date(next.timestamp).toISOString(),
107
+ })
108
+ i++ // skip paired assistant
109
+ }
110
+ }
111
+ }
112
+
113
+ const firstUserContent = userMessages[0] ?? ''
114
+ const slug = input.slug || (firstUserContent.length > 57
115
+ ? firstUserContent.slice(0, 57) + '...'
116
+ : firstUserContent || 'Empty session')
117
+
118
+ return {
119
+ session_id: input.id,
120
+ device_id: 'COS-Glasses',
121
+ created_at: new Date(input.createdAt).toISOString(),
122
+ ended_at: new Date(input.lastActivity).toISOString(),
123
+ duration_minutes: Math.round((input.lastActivity - input.createdAt) / 60_000),
124
+ end_reason: input.endReason,
125
+ domain: classifyDomain(userMessages),
126
+ slug,
127
+ model_preference: input.modelPreference,
128
+ user_message_count: userExchanges.length,
129
+ assistant_message_count: assistantExchanges.length,
130
+ total_message_count: input.exchanges.length,
131
+ messages,
132
+ }
133
+ }
134
+
135
+ /** Append a session log entry to .glasses_sessions.jsonl */
136
+ export function writeSessionLog(entry: SessionLogEntry): boolean {
137
+ if (!SCRIPTS_DIR) {
138
+ console.warn('[session-log] COS_SCRIPTS_DIR not set — skipping session log')
139
+ return false
140
+ }
141
+
142
+ const logPath = resolve(SCRIPTS_DIR, LOG_FILE_NAME)
143
+
144
+ try {
145
+ mkdirSync(dirname(logPath), { recursive: true })
146
+ appendFileSync(logPath, JSON.stringify(entry) + '\n')
147
+ console.log(`[session-log] Logged session ${entry.session_id} (${entry.end_reason}, ${entry.duration_minutes}m, ${entry.total_message_count} msgs)`)
148
+ return true
149
+ } catch (err) {
150
+ console.error('[session-log] Failed to write session log:', err)
151
+ return false
152
+ }
153
+ }
154
+
155
+ /** End a session: build entry and write to JSONL */
156
+ export function logSessionEnd(input: EndSessionInput): SessionLogEntry | null {
157
+ if (input.exchanges.length === 0) return null
158
+
159
+ const entry = buildSessionLogEntry(input)
160
+ writeSessionLog(entry)
161
+ return entry
162
+ }