@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,587 @@
1
+ // Conversation session manager — disk-persisted multi-turn history
2
+ // Rolling buffer of last N exchanges per session, auto-expire after inactivity
3
+ // Sessions survive server restarts via JSON file on disk
4
+
5
+ import { randomUUID } from 'node:crypto'
6
+ import { mkdirSync } from 'node:fs'
7
+ import { resolve, dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { notifySessionStart, notifySessionEnd } from './telegram-notify.js'
10
+ import { appendToArchive, type SessionToArchive } from './archive.js'
11
+ import { updateGlassesSessionCache, scheduleCacheUpdate, setSessionProvider } from './session-cache-writer.js'
12
+ import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-log.js'
13
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
14
+ import { localDay } from './local-day.js'
15
+ import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
16
+
17
+ export type { ModelPreference }
18
+
19
+ export interface Exchange {
20
+ role: 'user' | 'assistant'
21
+ content: string
22
+ timestamp: number
23
+ globalMsgNum?: number // Client's global message number for this Q&A pair
24
+ }
25
+
26
+ interface Session {
27
+ id: string
28
+ exchanges: Exchange[]
29
+ lastActivity: number
30
+ createdAt: number
31
+ modelPreference: ModelPreference | null
32
+ contextBreaks: number[] // timestamps where user said "new chat" — prompt history only sees exchanges after last break
33
+ }
34
+
35
+ const MAX_EXCHANGES = 100 // 50 Q&A pairs — deep history for browsing
36
+ const PROMPT_HISTORY_LIMIT = 20 // Only last 20 exchanges sent to Claude prompt
37
+ const CONTEXT_WINDOW_MS = 2 * 60 * 60_000 // 2 hours — prompt history window (no longer used for session deletion)
38
+
39
+ // Disk persistence
40
+ const __dirname = dirname(fileURLToPath(import.meta.url))
41
+ import { dataPath } from './data-dir.js'
42
+ const SESSION_FILE = dataPath('sessions.json')
43
+
44
+ const sessions = new Map<string, Session>()
45
+
46
+ // Wire session provider for cache writer (breaks circular import)
47
+ setSessionProvider(getActiveSessions)
48
+
49
+ // ── Disk I/O ───────────────────────────────────────────────
50
+
51
+ interface SessionsFile {
52
+ sessions: Record<string, Session>
53
+ savedAt: string
54
+ }
55
+
56
+ function loadFromDisk(): void {
57
+ const result = loadJsonOrQuarantine<SessionsFile>(SESSION_FILE)
58
+ if (result.status === 'missing') return // fresh start
59
+
60
+ if (result.status === 'corrupt') {
61
+ // Loud: previous behaviour silently discarded the corrupt file, which was
62
+ // indistinguishable from "no sessions" — the exact fingerprint of the
63
+ // Apr 15 loss. Always surface.
64
+ console.error(
65
+ `[conversation] CORRUPT sessions.json quarantined to ${result.quarantinedAs}. ` +
66
+ `Starting empty. Recover by hand from that file if needed.`,
67
+ result.error,
68
+ )
69
+ return
70
+ }
71
+
72
+ let loaded = 0
73
+ for (const [id, session] of Object.entries(result.data.sessions)) {
74
+ if (!session.contextBreaks) session.contextBreaks = []
75
+ session.modelPreference = normalizeModelPreference(session.modelPreference) ?? null
76
+ sessions.set(id, session)
77
+ loaded++
78
+ }
79
+
80
+ if (loaded > 0) {
81
+ console.log(`[conversation] Restored ${loaded} session(s) from disk (all ages preserved)`)
82
+ }
83
+ }
84
+
85
+ let saveTimer: ReturnType<typeof setTimeout> | null = null
86
+
87
+ function scheduleSave(): void {
88
+ if (saveTimer) return
89
+ saveTimer = setTimeout(() => {
90
+ saveTimer = null
91
+ saveToDisk()
92
+ }, 500)
93
+ }
94
+
95
+ function saveToDisk(): void {
96
+ try {
97
+ mkdirSync(dirname(SESSION_FILE), { recursive: true })
98
+ const data: SessionsFile = {
99
+ sessions: Object.fromEntries(sessions),
100
+ savedAt: new Date().toISOString(),
101
+ }
102
+ // Atomic: tmp + rename. Guards against power loss / SIGKILL / disk-full
103
+ // producing a torn JSON that `loadFromDisk` would quarantine on next boot.
104
+ atomicWriteFileSync(SESSION_FILE, JSON.stringify(data, null, 2))
105
+ } catch (err) {
106
+ console.error('[conversation] Failed to save sessions:', err)
107
+ }
108
+ }
109
+
110
+ // Restore on module load
111
+ loadFromDisk()
112
+
113
+ // Daily archive-mirror — copies prior-day sessions to archive WITHOUT deleting from sessions.json.
114
+ // Runs once at boot + every 24h. Guarantees every session has an archived copy.
115
+ //
116
+ // Budget-safe: passes `skipLLM: true` to `appendToArchive` so the mirror never
117
+ // triggers `claude -p` — boot after a long idle period would otherwise spawn
118
+ // N × (chats+1) Sonnet calls. The mirror uses deterministic string fallbacks;
119
+ // explicit `endSession` / `clearSession` paths keep the LLM summaries.
120
+ //
121
+ // Serialized via `withArchiveLock` inside appendToArchive so parallel mirrors
122
+ // on the same date cannot clobber each other. `mirrored` is now counted from
123
+ // settled promises — the previous sync-after-async counter was always 0.
124
+ async function runDailyArchiveMirror(): Promise<void> {
125
+ const todayLocal = localDay()
126
+ const promises: Promise<string | null>[] = []
127
+
128
+ for (const session of sessions.values()) {
129
+ if (session.exchanges.length === 0) continue
130
+ const sessionDay = localDay(session.lastActivity)
131
+ if (sessionDay >= todayLocal) continue // today's sessions are still live — skip
132
+
133
+ const p: Promise<string | null> = appendToArchive(sessionDay, {
134
+ id: session.id,
135
+ exchanges: session.exchanges,
136
+ contextBreaks: session.contextBreaks,
137
+ createdAt: session.createdAt,
138
+ lastActivity: session.lastActivity,
139
+ }, { skipLLM: true })
140
+ .then(() => {
141
+ console.log(`[conversation] Archive-mirrored session ${session.id} → ${sessionDay}`)
142
+ return session.id
143
+ })
144
+ .catch((err) => {
145
+ console.error(`[conversation] Archive-mirror failed for ${session.id}:`, err)
146
+ return null
147
+ })
148
+ promises.push(p)
149
+ }
150
+
151
+ const results = await Promise.allSettled(promises)
152
+ const mirrored = results.filter(
153
+ (r): r is PromiseFulfilledResult<string> => r.status === 'fulfilled' && r.value !== null,
154
+ ).length
155
+
156
+ if (mirrored > 0) updateGlassesSessionCache()
157
+ }
158
+
159
+ // Fire-and-forget at boot so module load isn't blocked on disk + LLM fallback I/O.
160
+ runDailyArchiveMirror().catch(err => console.error('[conversation] mirror boot error:', err))
161
+ setInterval(() => {
162
+ runDailyArchiveMirror().catch(err => console.error('[conversation] mirror interval error:', err))
163
+ }, 24 * 60 * 60_000)
164
+
165
+ // Track whether session is brand new (for first-query notification)
166
+ const newSessions = new Set<string>()
167
+
168
+ export function createSession(): string {
169
+ const now = Date.now()
170
+ const id = randomUUID().slice(0, 8)
171
+ sessions.set(id, { id, exchanges: [], lastActivity: now, createdAt: now, modelPreference: null, contextBreaks: [] })
172
+ newSessions.add(id)
173
+ scheduleSave()
174
+ scheduleCacheUpdate()
175
+ return id
176
+ }
177
+
178
+ /** Get raw session object — needed for contextBreaks access */
179
+ export function getSessionRaw(sessionId: string): Session | undefined {
180
+ return sessions.get(sessionId)
181
+ }
182
+
183
+ /** Insert a context break — prompt history will only see exchanges after this timestamp */
184
+ export function addContextBreak(sessionId: string): boolean {
185
+ const session = sessions.get(sessionId)
186
+ if (!session) return false
187
+ session.contextBreaks.push(Date.now())
188
+ session.lastActivity = Date.now()
189
+ scheduleSave()
190
+ scheduleCacheUpdate()
191
+ return true
192
+ }
193
+
194
+ export function isNewSession(sessionId: string): boolean {
195
+ return newSessions.has(sessionId)
196
+ }
197
+
198
+ export function markSessionNotified(sessionId: string): void {
199
+ newSessions.delete(sessionId)
200
+ }
201
+
202
+ export function getOrCreateSession(sessionId?: string): string {
203
+ if (sessionId && sessions.has(sessionId)) {
204
+ return sessionId
205
+ }
206
+ return createSession()
207
+ }
208
+
209
+ export function getHistory(sessionId: string): Exchange[] {
210
+ const session = sessions.get(sessionId)
211
+ if (!session) return []
212
+ session.lastActivity = Date.now()
213
+ return [...session.exchanges]
214
+ }
215
+
216
+ /**
217
+ * Remove the most recent user+assistant exchange pair from a session.
218
+ * Called after photo analysis completes — the client already has the response,
219
+ * but we strip it from the session so photo context doesn't leak into future prompts.
220
+ */
221
+ export function removeLastExchangePair(sessionId: string): void {
222
+ const session = sessions.get(sessionId)
223
+ if (!session || session.exchanges.length < 2) return
224
+
225
+ const last = session.exchanges[session.exchanges.length - 1]
226
+ const secondLast = session.exchanges[session.exchanges.length - 2]
227
+
228
+ if (last.role === 'assistant' && secondLast.role === 'user') {
229
+ session.exchanges.splice(-2, 2)
230
+ scheduleSave()
231
+ scheduleCacheUpdate()
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Extract the first complete sentence from text, capped at maxLen.
237
+ * Returns empty string for empty/whitespace input.
238
+ */
239
+ function extractFirstSentence(text: string, maxLen: number): string {
240
+ const trimmed = text.trim()
241
+ if (!trimmed) return ''
242
+
243
+ // Find first sentence boundary — require 15+ chars before the period
244
+ // to avoid false matches on numbered lists like "1. Menu board"
245
+ const match = trimmed.match(/^(.{15,}?[.!?])(?:\s|$)/)
246
+ if (match && match[1].length <= maxLen) {
247
+ return match[1]
248
+ }
249
+
250
+ // No clean sentence boundary — truncate at last word boundary
251
+ if (trimmed.length <= maxLen) return trimmed
252
+ const truncated = trimmed.slice(0, maxLen)
253
+ const lastSpace = truncated.lastIndexOf(' ')
254
+ if (lastSpace > 0) {
255
+ return truncated.slice(0, lastSpace) + '...'
256
+ }
257
+ return truncated + '...'
258
+ }
259
+
260
+ /**
261
+ * Replace the last user+assistant exchange pair with condensed photo summaries.
262
+ * Preserves original timestamps so time-window filters and archive ordering are unaffected.
263
+ * Falls back to deletion if no usable summary can be extracted.
264
+ */
265
+ export function replaceLastExchangeWithSummary(
266
+ sessionId: string,
267
+ originalQuery: string,
268
+ fullResponse: string,
269
+ imageCount: number,
270
+ ): void {
271
+ const session = sessions.get(sessionId)
272
+ if (!session || session.exchanges.length < 2) return
273
+
274
+ const last = session.exchanges[session.exchanges.length - 1]
275
+ const secondLast = session.exchanges[session.exchanges.length - 2]
276
+
277
+ if (last.role !== 'assistant' || secondLast.role !== 'user') return
278
+
279
+ const summary = extractFirstSentence(fullResponse, 250)
280
+
281
+ // No usable summary — fall back to deletion
282
+ if (!summary) {
283
+ session.exchanges.splice(-2, 2)
284
+ scheduleSave()
285
+ scheduleCacheUpdate()
286
+ return
287
+ }
288
+
289
+ // Build condensed replacements
290
+ const photoLabel = imageCount === 1 ? 'image' : `${imageCount} images`
291
+ const queryText = originalQuery || 'What do you see?'
292
+ secondLast.content = `[Photo context: ${photoLabel}] ${queryText}`
293
+ last.content = `[Photo context] ${summary}`
294
+
295
+ scheduleSave()
296
+ scheduleCacheUpdate()
297
+ }
298
+
299
+ export function addExchange(sessionId: string, role: 'user' | 'assistant', content: string, globalMsgNum?: number): void {
300
+ let session = sessions.get(sessionId)
301
+ if (!session) {
302
+ session = { id: sessionId, exchanges: [], lastActivity: Date.now(), createdAt: Date.now(), modelPreference: null, contextBreaks: [] }
303
+ sessions.set(sessionId, session)
304
+ }
305
+
306
+ session.exchanges.push({ role, content, timestamp: Date.now(), globalMsgNum })
307
+ session.lastActivity = Date.now()
308
+
309
+ // Trim to rolling buffer
310
+ while (session.exchanges.length > MAX_EXCHANGES) {
311
+ session.exchanges.shift()
312
+ }
313
+
314
+ scheduleSave()
315
+ scheduleCacheUpdate()
316
+ }
317
+
318
+ /** Clear a session — archive + log BEFORE deleting from the live Map.
319
+ * Async so callers can `await` archive completion before considering the
320
+ * session durable. If archive fails the session stays in the Map (retryable
321
+ * on next boot via `runDailyArchiveMirror`); previously the Map was deleted
322
+ * unconditionally even on archive failure — silent data loss. */
323
+ export async function clearSession(sessionId: string): Promise<void> {
324
+ const session = sessions.get(sessionId)
325
+ if (session && session.exchanges.length > 0) {
326
+ const dateStr = localDay(session.lastActivity)
327
+ try {
328
+ await appendToArchive(dateStr, {
329
+ id: session.id,
330
+ exchanges: session.exchanges,
331
+ contextBreaks: session.contextBreaks,
332
+ createdAt: session.createdAt,
333
+ lastActivity: session.lastActivity,
334
+ })
335
+ updateGlassesSessionCache()
336
+ } catch (err) {
337
+ console.error(
338
+ `[conversation] clearSession archive failed for ${sessionId} — keeping in Map for retry:`,
339
+ err,
340
+ )
341
+ return // do NOT delete — let the mirror retry next cycle
342
+ }
343
+
344
+ logSessionEnd({
345
+ id: session.id,
346
+ exchanges: session.exchanges,
347
+ createdAt: session.createdAt,
348
+ lastActivity: session.lastActivity,
349
+ modelPreference: session.modelPreference,
350
+ endReason: 'explicit_clear',
351
+ })
352
+ }
353
+
354
+ sessions.delete(sessionId)
355
+ scheduleSave()
356
+ scheduleCacheUpdate()
357
+ }
358
+
359
+ export function getSessionModel(sessionId: string): ModelPreference | null {
360
+ return sessions.get(sessionId)?.modelPreference ?? null
361
+ }
362
+
363
+ export function setSessionModel(sessionId: string, model: ModelPreference | null): void {
364
+ const session = sessions.get(sessionId)
365
+ if (session) {
366
+ session.modelPreference = model
367
+ scheduleSave()
368
+ }
369
+ }
370
+
371
+ export interface PromptReference {
372
+ query: string
373
+ response: string
374
+ }
375
+
376
+ /**
377
+ * Format conversation history for Claude's prompt.
378
+ * Applies three filters:
379
+ * 1. Context break — only exchanges AFTER the last break timestamp
380
+ * 2. Time window — only exchanges within CONTEXT_WINDOW_MS of now
381
+ * 3. Photo filtering — skip [Photo] user exchanges AND their paired assistant response
382
+ * Then caps at PROMPT_HISTORY_LIMIT and optionally appends a referenced message.
383
+ */
384
+ export function formatHistoryForPrompt(
385
+ exchanges: Exchange[],
386
+ contextBreaks: number[] = [],
387
+ reference?: PromptReference,
388
+ ): string {
389
+ if (exchanges.length === 0 && !reference) return ''
390
+
391
+ const now = Date.now()
392
+ const lastBreak = contextBreaks.length > 0 ? contextBreaks[contextBreaks.length - 1] : 0
393
+ const windowStart = now - CONTEXT_WINDOW_MS
394
+
395
+ // Effective cutoff: whichever is more recent — last break or time window
396
+ const cutoff = Math.max(lastBreak, windowStart)
397
+
398
+ // Filter exchanges: after cutoff, skip photo exchanges
399
+ const filtered: Exchange[] = []
400
+ for (let i = 0; i < exchanges.length; i++) {
401
+ const ex = exchanges[i]
402
+ if (ex.timestamp < cutoff) continue
403
+
404
+ // Skip photo user messages and their paired assistant response
405
+ // Matches [Photo], [2 Photos], [3 Photos], etc.
406
+ if (ex.role === 'user' && /^\[(?:\d+ )?Photos?\]/.test(ex.content)) {
407
+ // Also skip the next exchange if it's an assistant response
408
+ if (i + 1 < exchanges.length && exchanges[i + 1].role === 'assistant') {
409
+ i++ // skip paired response
410
+ }
411
+ continue
412
+ }
413
+
414
+ filtered.push(ex)
415
+ }
416
+
417
+ // Cap at limit
418
+ const recent = filtered.slice(-PROMPT_HISTORY_LIMIT)
419
+
420
+ const parts: string[] = []
421
+
422
+ if (recent.length > 0) {
423
+ const lines = recent.map((e, i) => {
424
+ const prefix = e.role === 'user' ? 'User' : 'COS'
425
+ const num = e.globalMsgNum != null ? `Msg ${e.globalMsgNum}` : `${i + 1}`
426
+ return `[${num}] ${prefix}: ${e.content}`
427
+ })
428
+ parts.push(`CONVERSATION HISTORY (recent exchanges):\n${lines.join('\n')}`)
429
+ }
430
+
431
+ if (reference) {
432
+ parts.push(`REFERENCED MESSAGE:\nUser asked: ${reference.query}\nCOS responded: ${reference.response}`)
433
+ }
434
+
435
+ return parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''
436
+ }
437
+
438
+ // ── Session queries ──────────────────────────────────────────
439
+
440
+ export interface SessionSummary {
441
+ id: string
442
+ exchangeCount: number
443
+ lastActivity: number
444
+ createdAt: number
445
+ modelPreference: ModelPreference | null
446
+ lastQuery: string | null
447
+ }
448
+
449
+ export function getRecentSessions(withinMs: number): SessionSummary[] {
450
+ const now = Date.now()
451
+ const results: SessionSummary[] = []
452
+
453
+ for (const session of sessions.values()) {
454
+ if (now - session.lastActivity > withinMs) continue
455
+ const lastUserExchange = [...session.exchanges].reverse().find(e => e.role === 'user')
456
+ results.push({
457
+ id: session.id,
458
+ exchangeCount: session.exchanges.length,
459
+ lastActivity: session.lastActivity,
460
+ createdAt: session.createdAt,
461
+ modelPreference: session.modelPreference,
462
+ lastQuery: lastUserExchange?.content ?? null,
463
+ })
464
+ }
465
+
466
+ return results.sort((a, b) => b.lastActivity - a.lastActivity)
467
+ }
468
+
469
+ export function sessionExists(sessionId: string): boolean {
470
+ return sessions.has(sessionId)
471
+ }
472
+
473
+ /** Explicitly end a session — archive, log, notify, and remove.
474
+ * Called by POST /api/sessions/:id/end (client "new session" / app background).
475
+ * Returns archive stats or null if session not found.
476
+ *
477
+ * Archive-first: we AWAIT `appendToArchive` before `sessions.delete`. If the
478
+ * archive write fails, the session stays in the Map (and on disk) so the
479
+ * next mirror cycle retries it — this is the core guarantee v5.4.5 makes:
480
+ * no session leaves the Map without a successful archive write behind it. */
481
+ export async function endSession(sessionId: string): Promise<{ logged: boolean; exchangeCount: number; durationMin: number } | null> {
482
+ const session = sessions.get(sessionId)
483
+ if (!session) return null
484
+
485
+ const durationMin = Math.round((session.lastActivity - session.createdAt) / 60_000)
486
+ const exchangeCount = session.exchanges.length
487
+
488
+ if (session.exchanges.length > 0) {
489
+ const dateStr = localDay(session.lastActivity)
490
+ try {
491
+ await appendToArchive(dateStr, {
492
+ id: session.id,
493
+ exchanges: session.exchanges,
494
+ contextBreaks: session.contextBreaks,
495
+ createdAt: session.createdAt,
496
+ lastActivity: session.lastActivity,
497
+ })
498
+ updateGlassesSessionCache()
499
+ } catch (err) {
500
+ console.error(
501
+ `[conversation] endSession archive failed for ${sessionId} — keeping in Map for retry:`,
502
+ err,
503
+ )
504
+ // Signal failure to the caller but DO NOT delete the session.
505
+ return { logged: false, exchangeCount, durationMin }
506
+ }
507
+
508
+ // Log to JSONL (fire-and-forget — not on the data-durability path)
509
+ logSessionEnd({
510
+ id: session.id,
511
+ exchanges: session.exchanges,
512
+ createdAt: session.createdAt,
513
+ lastActivity: session.lastActivity,
514
+ modelPreference: session.modelPreference,
515
+ endReason: 'explicit_end',
516
+ })
517
+ }
518
+
519
+ notifySessionEnd(sessionId, exchangeCount, durationMin)
520
+ sessions.delete(sessionId)
521
+ scheduleSave()
522
+ scheduleCacheUpdate()
523
+
524
+ return { logged: exchangeCount > 0, exchangeCount, durationMin }
525
+ }
526
+
527
+ /** Log all active sessions on server shutdown — prevents data loss on restart */
528
+ export function logActiveSessionsOnShutdown(): void {
529
+ for (const session of sessions.values()) {
530
+ if (session.exchanges.length === 0) continue
531
+ logSessionEnd({
532
+ id: session.id,
533
+ exchanges: session.exchanges,
534
+ createdAt: session.createdAt,
535
+ lastActivity: session.lastActivity,
536
+ modelPreference: session.modelPreference,
537
+ endReason: 'server_shutdown',
538
+ })
539
+ }
540
+ }
541
+
542
+ // ── Auto-snapshot: periodically write active sessions to .glasses_sessions.jsonl ──
543
+ // Runs every 5 minutes. No LLM calls, no network — just JSON serialize + file append.
544
+ // Cost: ~0 CPU, ~0 RAM, ~50KB disk per snapshot.
545
+ let snapshotTimer: ReturnType<typeof setInterval> | null = null
546
+ let lastSnapshotCounts: Record<string, number> = {} // track exchange counts to skip unchanged sessions
547
+
548
+ export function startAutoSnapshot(intervalMs = 5 * 60_000): void {
549
+ if (snapshotTimer) return
550
+ snapshotTimer = setInterval(() => {
551
+ for (const session of sessions.values()) {
552
+ if (session.exchanges.length === 0) continue
553
+ // Skip if exchange count hasn't changed since last snapshot
554
+ const prevCount = lastSnapshotCounts[session.id] ?? 0
555
+ if (session.exchanges.length === prevCount) continue
556
+
557
+ const entry = buildSessionLogEntry({
558
+ id: session.id,
559
+ exchanges: session.exchanges,
560
+ createdAt: session.createdAt,
561
+ lastActivity: Date.now(), // Use NOW, not lastActivity — ensures lookup covers up to this moment
562
+ modelPreference: session.modelPreference,
563
+ endReason: 'explicit_end',
564
+ slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
565
+ })
566
+ writeSessionLog(entry)
567
+ lastSnapshotCounts[session.id] = session.exchanges.length
568
+ }
569
+ }, intervalMs)
570
+ console.log(`[conversation] Auto-snapshot started (every ${intervalMs / 1000}s)`)
571
+ }
572
+
573
+ /** Return all active sessions with exchanges, shaped for archiving */
574
+ export function getActiveSessions(): SessionToArchive[] {
575
+ const result: SessionToArchive[] = []
576
+ for (const session of sessions.values()) {
577
+ if (session.exchanges.length === 0) continue
578
+ result.push({
579
+ id: session.id,
580
+ exchanges: session.exchanges,
581
+ contextBreaks: session.contextBreaks,
582
+ createdAt: session.createdAt,
583
+ lastActivity: session.lastActivity,
584
+ })
585
+ }
586
+ return result
587
+ }
@@ -0,0 +1,20 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, resolve } from 'node:path'
3
+ import { mkdirSync } from 'node:fs'
4
+
5
+ // Runtime state directory. Defaults to ~/.cos-glasses/data — a writable location
6
+ // that survives `npx` cache churn and works on global/Docker installs. (Writing
7
+ // inside the package dir would crash on a read-only install and lose state +
8
+ // the saved OpenAI key across upgrades.) Override with COS_DATA_DIR.
9
+ export const DATA_DIR = process.env.COS_DATA_DIR
10
+ ? resolve(process.env.COS_DATA_DIR)
11
+ : join(homedir(), '.cos-glasses', 'data')
12
+
13
+ try {
14
+ mkdirSync(DATA_DIR, { recursive: true })
15
+ } catch { /* best effort — individual writers also tolerate a missing dir */ }
16
+
17
+ /** Build a path under the runtime data directory. */
18
+ export function dataPath(...parts: string[]): string {
19
+ return join(DATA_DIR, ...parts)
20
+ }
@@ -0,0 +1,21 @@
1
+ // Display bus — server-side pub/sub for broadcasting query responses
2
+ // to all connected glasses clients (SSE display-stream subscribers)
3
+
4
+ import { EventEmitter } from 'node:events'
5
+
6
+ const bus = new EventEmitter()
7
+ bus.setMaxListeners(20) // Multiple glasses clients
8
+
9
+ export interface DisplayEvent {
10
+ type: 'chunk' | 'done' | 'error' | 'tool_status' | 'start' | 'session_restore' | 'transcript_chunk' | 'recording_start' | 'recording_stop' | 'coaching_nudge'
11
+ data: Record<string, unknown>
12
+ }
13
+
14
+ export function emitDisplay(event: DisplayEvent): void {
15
+ bus.emit('display', event)
16
+ }
17
+
18
+ export function onDisplay(listener: (event: DisplayEvent) => void): () => void {
19
+ bus.on('display', listener)
20
+ return () => { bus.off('display', listener) }
21
+ }
@@ -0,0 +1,23 @@
1
+ // Display formatting constants for Even G2 glasses
2
+ // Screen: 576x288 pixels, monospace font
3
+ export const MAX_TEXT_CHARS = 400
4
+ export const MAX_LIST_ITEMS = 20
5
+ export const MAX_LIST_ITEM_CHARS = 64
6
+
7
+ export function truncate(text: string, max: number): string {
8
+ if (text.length <= max) return text
9
+ return text.slice(0, max - 3) + '...'
10
+ }
11
+
12
+ export function truncateLines(text: string, maxChars: number): string {
13
+ if (text.length <= maxChars) return text
14
+ // Truncate at a line boundary if possible
15
+ const lines = text.split('\n')
16
+ let result = ''
17
+ for (const line of lines) {
18
+ const next = result ? result + '\n' + line : line
19
+ if (next.length > maxChars - 3) break
20
+ result = next
21
+ }
22
+ return result || text.slice(0, maxChars - 3) + '...'
23
+ }