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