@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,298 @@
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 { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
6
+ import {
7
+ CODEX_HIGH_REASONING_EFFORT,
8
+ CODEX_MODEL_ID,
9
+ type CodexModelPreference,
10
+ } from '../../shared/model-preference.js'
11
+ import { dataPath } from './data-dir.js'
12
+
13
+ const DEFAULT_MAX_RUNS = 100
14
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
15
+ const ERROR_PREVIEW_CHARS = 160
16
+ const RUNNING_STALE_MS = 30 * 60_000
17
+
18
+ function getProcessStartedAtMs(): number {
19
+ return Date.now() - Math.floor(process.uptime() * 1000)
20
+ }
21
+
22
+ export type CodexRunStatus =
23
+ | 'running'
24
+ | 'completed'
25
+ | 'failed'
26
+ | 'cancelled'
27
+ | 'client_disconnected'
28
+
29
+ export interface CodexRunRecord {
30
+ runId: string
31
+ cosSessionId: string
32
+ codexThreadId?: string
33
+ status: CodexRunStatus
34
+ createdAt: string
35
+ updatedAt: string
36
+ model: CodexModelPreference
37
+ cliModel: string
38
+ reasoningEffort: string
39
+ cwd: string
40
+ ephemeral: boolean
41
+ resumed?: boolean
42
+ trustMode: CodexTrustMode
43
+ expiresAt?: string
44
+ resumeCommand?: string
45
+ queryPreview?: string
46
+ outputPreview?: string
47
+ errorCode?: string
48
+ errorPreview?: string
49
+ durationMs?: number
50
+ exitCode?: number | null
51
+ }
52
+
53
+ interface CodexRunEvent {
54
+ runId: string
55
+ ts: string
56
+ patch: Partial<CodexRunRecord>
57
+ }
58
+
59
+ export interface CodexRunConfig {
60
+ cliModel: string
61
+ reasoningEffort: string
62
+ persistenceEnabled: boolean
63
+ cwd: string
64
+ trustMode: CodexTrustMode
65
+ engineSessionTtlMinutes: number
66
+ historyLimit: number
67
+ historyTtlDays: number
68
+ contentPreviewsEnabled: boolean
69
+ }
70
+
71
+ export function isCodexPersistenceEnabled(): boolean {
72
+ return process.env.COS_CODEX_PERSIST_SESSIONS !== '0'
73
+ }
74
+
75
+ export function areCodexContentPreviewsEnabled(): boolean {
76
+ return process.env.COS_CODEX_RUN_CONTENT_PREVIEWS === '1'
77
+ }
78
+
79
+ export function getCodexExecutionCwd(): string {
80
+ const configured = process.env.CODEX_GLASSES_WORKDIR?.trim()
81
+ if (configured) return resolve(configured)
82
+ if (COS_SCRIPTS_DIR) return resolve(COS_SCRIPTS_DIR, '..', '..')
83
+ // Last resort when neither CODEX_GLASSES_WORKDIR nor COS_SCRIPTS_DIR is set:
84
+ // the server's own working dir (codex glasses is an optional, env-configured feature).
85
+ return process.cwd()
86
+ }
87
+
88
+ export function getCodexTrustMode(): CodexTrustMode {
89
+ return process.env.COS_CODEX_SANDBOX === 'workspace-write' ? 'workspace-write' : 'read-only'
90
+ }
91
+
92
+ export function getCodexRunConfig(): CodexRunConfig {
93
+ return {
94
+ cliModel: CODEX_MODEL_ID,
95
+ reasoningEffort: CODEX_HIGH_REASONING_EFFORT,
96
+ persistenceEnabled: isCodexPersistenceEnabled(),
97
+ cwd: getCodexExecutionCwd(),
98
+ trustMode: getCodexTrustMode(),
99
+ engineSessionTtlMinutes: Math.round(CODEX_ENGINE_SESSION_TTL_MS / 60_000),
100
+ historyLimit: getMaxRuns(),
101
+ historyTtlDays: Math.round(getTtlMs() / (24 * 60 * 60_000)),
102
+ contentPreviewsEnabled: areCodexContentPreviewsEnabled(),
103
+ }
104
+ }
105
+
106
+ export function getCodexLedgerPath(): string {
107
+ return resolve(process.env.COS_CODEX_RUN_LEDGER_FILE || dataPath('codex-runs.jsonl'))
108
+ }
109
+
110
+ function getMaxRuns(): number {
111
+ const raw = Number(process.env.COS_CODEX_RUN_LEDGER_MAX ?? DEFAULT_MAX_RUNS)
112
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_RUNS
113
+ }
114
+
115
+ function getTtlMs(): number {
116
+ const rawDays = Number(process.env.COS_CODEX_RUN_LEDGER_TTL_DAYS ?? 7)
117
+ return Number.isFinite(rawDays) && rawDays > 0 ? rawDays * 24 * 60 * 60_000 : DEFAULT_TTL_MS
118
+ }
119
+
120
+ function appendEvent(event: CodexRunEvent): void {
121
+ try {
122
+ const path = getCodexLedgerPath()
123
+ mkdirSync(dirname(path), { recursive: true })
124
+ appendFileSync(path, JSON.stringify(event) + '\n')
125
+ } catch (err) {
126
+ console.warn('[codex-run-ledger] write skipped:', err)
127
+ }
128
+ }
129
+
130
+ function readEvents(): CodexRunEvent[] {
131
+ const path = getCodexLedgerPath()
132
+ if (!existsSync(path)) return []
133
+ try {
134
+ const events: CodexRunEvent[] = []
135
+ for (const line of readFileSync(path, 'utf-8')
136
+ .split('\n')
137
+ .map(line => line.trim())
138
+ .filter(Boolean)) {
139
+ try {
140
+ const event = JSON.parse(line) as CodexRunEvent
141
+ if (typeof event.runId === 'string' && typeof event.ts === 'string' && typeof event.patch === 'object') {
142
+ events.push(event)
143
+ }
144
+ } catch {
145
+ // Skip torn/corrupt JSONL rows; valid prior records should stay visible.
146
+ }
147
+ }
148
+ return events
149
+ } catch {
150
+ return []
151
+ }
152
+ }
153
+
154
+ function hydrateRuns(): CodexRunRecord[] {
155
+ const runs = new Map<string, CodexRunRecord>()
156
+ const order = new Map<string, number>()
157
+ let eventIndex = 0
158
+ for (const event of readEvents()) {
159
+ eventIndex += 1
160
+ const existing = runs.get(event.runId)
161
+ const next = { ...(existing ?? {}), ...event.patch, runId: event.runId } as CodexRunRecord
162
+ if (next.codexThreadId && !next.resumeCommand) {
163
+ next.resumeCommand = `codex exec resume ${next.codexThreadId}`
164
+ }
165
+ runs.set(event.runId, next)
166
+ order.set(event.runId, eventIndex)
167
+ }
168
+
169
+ const cutoff = Date.now() - getTtlMs()
170
+ return Array.from(runs.values())
171
+ .filter(run => run.createdAt && Date.parse(run.updatedAt || run.createdAt) >= cutoff)
172
+ .map(run => {
173
+ const updatedMs = Date.parse(run.updatedAt || run.createdAt)
174
+ const predatesCurrentProcess = updatedMs < getProcessStartedAtMs() - 1000
175
+ if (run.status === 'running' && (predatesCurrentProcess || Date.now() - updatedMs > RUNNING_STALE_MS)) {
176
+ const interruptedRun: CodexRunRecord = {
177
+ ...run,
178
+ status: 'client_disconnected',
179
+ errorCode: run.errorCode ?? 'codex.interrupted',
180
+ }
181
+ return interruptedRun
182
+ }
183
+ return run
184
+ })
185
+ .sort((a, b) => {
186
+ const byCreated = Date.parse(b.createdAt) - Date.parse(a.createdAt)
187
+ if (byCreated !== 0) return byCreated
188
+ return (order.get(b.runId) ?? 0) - (order.get(a.runId) ?? 0)
189
+ })
190
+ .slice(0, getMaxRuns())
191
+ }
192
+
193
+ export function redactForCodexLedger(value: string, maxChars = ERROR_PREVIEW_CHARS): string {
194
+ return value
195
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[email]')
196
+ .replace(/\b(?:sk|sess|ghp|github_pat|glpat)-[A-Za-z0-9_\-]{12,}\b/g, '[token]')
197
+ .replace(/\bBearer\s+[A-Za-z0-9._\-]{12,}\b/gi, 'Bearer [token]')
198
+ .replace(/[A-Za-z0-9+/=]{80,}/g, '[blob]')
199
+ .replace(/\s+/g, ' ')
200
+ .trim()
201
+ .slice(0, maxChars)
202
+ }
203
+
204
+ export function classifyCodexError(message: string): string {
205
+ const text = message.toLowerCase()
206
+ if (/command not found|enoent|not found/.test(text)) return 'codex.cli_unavailable'
207
+ if (/permission|denied|sandbox|read-only|operation not permitted/.test(text)) return 'codex.permission_denied'
208
+ if (/auth|login|sign in|unauthorized|forbidden|token/.test(text)) return 'codex.auth_error'
209
+ if (/timeout|timed out|wall clock|no output/.test(text)) return 'codex.timeout'
210
+ if (/exit\s+\d+/.test(text)) return 'codex.nonzero_exit'
211
+ return 'codex.error'
212
+ }
213
+
214
+ export function extractCodexThreadId(event: any): string | undefined {
215
+ const type = String(event?.type ?? '').toLowerCase()
216
+ if (!/(thread|session).*(started|created)|^(thread|session)\.started$/.test(type)) return undefined
217
+ const candidate = event?.thread_id ?? event?.threadId ?? event?.session_id ?? event?.sessionId ?? event?.id
218
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
219
+ }
220
+
221
+ export function startCodexRun(input: {
222
+ cosSessionId: string
223
+ model: CodexModelPreference
224
+ cwd: string
225
+ ephemeral: boolean
226
+ resumed?: boolean
227
+ trustMode?: CodexTrustMode
228
+ codexThreadId?: string
229
+ expiresAt?: string
230
+ query: string
231
+ }): CodexRunRecord {
232
+ const now = new Date().toISOString()
233
+ const run: CodexRunRecord = {
234
+ runId: `codex-${crypto.randomUUID().slice(0, 8)}`,
235
+ cosSessionId: input.cosSessionId,
236
+ status: 'running',
237
+ createdAt: now,
238
+ updatedAt: now,
239
+ model: input.model,
240
+ cliModel: CODEX_MODEL_ID,
241
+ reasoningEffort: CODEX_HIGH_REASONING_EFFORT,
242
+ cwd: input.cwd,
243
+ ephemeral: input.ephemeral,
244
+ resumed: input.resumed,
245
+ trustMode: input.trustMode ?? getCodexTrustMode(),
246
+ codexThreadId: input.codexThreadId,
247
+ expiresAt: input.expiresAt,
248
+ }
249
+ if (areCodexContentPreviewsEnabled()) {
250
+ run.queryPreview = redactForCodexLedger(input.query)
251
+ }
252
+ appendEvent({ runId: run.runId, ts: now, patch: run })
253
+ return run
254
+ }
255
+
256
+ export function updateCodexRun(runId: string, patch: Partial<Omit<CodexRunRecord, 'runId' | 'createdAt'>>): CodexRunRecord | null {
257
+ const ts = new Date().toISOString()
258
+ const safePatch = { ...patch, updatedAt: ts }
259
+ if (safePatch.codexThreadId && !safePatch.resumeCommand) {
260
+ safePatch.resumeCommand = `codex exec resume ${safePatch.codexThreadId}`
261
+ }
262
+ appendEvent({ runId, ts, patch: safePatch })
263
+ return getCodexRun(runId)
264
+ }
265
+
266
+ export function finishCodexRun(runId: string, input: {
267
+ status: Exclude<CodexRunStatus, 'running'>
268
+ startedAtMs: number
269
+ output?: string
270
+ error?: string
271
+ exitCode?: number | null
272
+ }): CodexRunRecord | null {
273
+ const patch: Partial<CodexRunRecord> = {
274
+ status: input.status,
275
+ durationMs: Math.max(0, Date.now() - input.startedAtMs),
276
+ exitCode: input.exitCode,
277
+ }
278
+ if (input.output && areCodexContentPreviewsEnabled()) {
279
+ patch.outputPreview = redactForCodexLedger(input.output)
280
+ }
281
+ if (input.error) {
282
+ patch.errorCode = classifyCodexError(input.error)
283
+ if (areCodexContentPreviewsEnabled()) {
284
+ patch.errorPreview = redactForCodexLedger(input.error)
285
+ }
286
+ }
287
+ return updateCodexRun(runId, patch)
288
+ }
289
+
290
+ export function listCodexRuns(limit = 20, cosSessionId?: string): CodexRunRecord[] {
291
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), getMaxRuns()) : 20
292
+ const runs = hydrateRuns()
293
+ return (cosSessionId ? runs.filter(run => run.cosSessionId === cosSessionId) : runs).slice(0, safeLimit)
294
+ }
295
+
296
+ export function getCodexRun(runId: string): CodexRunRecord | null {
297
+ return hydrateRuns().find(run => run.runId === runId) ?? null
298
+ }
@@ -0,0 +1,210 @@
1
+ // Context builder — assembles profile + live COS data into system prompt
2
+ // Caches context for 60s to avoid hammering Python scripts every query
3
+
4
+ import { callPython, COS_MODE } from './python-bridge.js'
5
+ import { getOwnerName } from './profile.js'
6
+
7
+ interface ContextCache {
8
+ content: string
9
+ timestamp: number
10
+ }
11
+
12
+ const CACHE_TTL_MS = 600_000 // 10 minutes — context only injected when query needs it
13
+ const CACHE_REFRESH_MS = 600_000 // 10 minutes — background refresh interval
14
+ let contextCache: ContextCache | null = null
15
+ let refreshInterval: ReturnType<typeof setInterval> | null = null
16
+
17
+ const COS_ROUTING_CONTEXT = `PIPELINE ROUTING:
18
+ This glasses header is only routing and display context.
19
+ Treat your configured COS pipeline as canonical for identity, schedule, tasks, and people context.
20
+ Do not infer facts from this glasses header. If this header conflicts with pipeline data, follow the pipeline.`
21
+
22
+ function formatCalendarContext(cal: any): string {
23
+ const lines: string[] = []
24
+
25
+ if (cal.is_in_meeting && cal.current_event) {
26
+ lines.push(`NOW: ${cal.current_event.title}`)
27
+ if (cal.current_event.matched_person) {
28
+ lines.push(` with ${cal.current_event.matched_person}`)
29
+ }
30
+ }
31
+
32
+ if (cal.next_event) {
33
+ const mins = cal.minutes_until_next
34
+ const when = mins != null && mins < 120 ? `in ${mins}m` : cal.next_event.start_time
35
+ lines.push(`NEXT: ${cal.next_event.title} (${when})`)
36
+ if (cal.next_event.matched_person) {
37
+ lines.push(` with ${cal.next_event.matched_person}`)
38
+ }
39
+ } else {
40
+ lines.push('No more meetings today')
41
+ }
42
+
43
+ lines.push(`${cal.meetings_remaining_count ?? 0} meetings remaining today`)
44
+
45
+ return lines.join('\n')
46
+ }
47
+
48
+ function formatTaskContext(tasks: Record<string, any[]>): string {
49
+ const urgent: string[] = []
50
+ let totalOpen = 0
51
+
52
+ for (const domain of Object.keys(tasks)) {
53
+ for (const t of tasks[domain]) {
54
+ if (!t.is_checked) {
55
+ totalOpen++
56
+ if (t.priority === 'high' || t.priority === 'urgent') {
57
+ urgent.push(`[${domain}] ${t.description}`)
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ const lines = [`${totalOpen} open tasks total`]
64
+ if (urgent.length > 0) {
65
+ lines.push(`Urgent/High priority:`)
66
+ for (const u of urgent.slice(0, 5)) {
67
+ lines.push(` - ${u}`)
68
+ }
69
+ }
70
+
71
+ return lines.join('\n')
72
+ }
73
+
74
+ async function fetchLiveContext(): Promise<string> {
75
+ const parts: string[] = []
76
+
77
+ // Fetch calendar and tasks in parallel
78
+ const [calResult, taskResult] = await Promise.allSettled([
79
+ callPython(['calendar']),
80
+ callPython(['tasks']),
81
+ ])
82
+
83
+ if (calResult.status === 'fulfilled') {
84
+ parts.push('CALENDAR:\n' + formatCalendarContext(calResult.value))
85
+ } else {
86
+ parts.push('CALENDAR: unavailable')
87
+ }
88
+
89
+ if (taskResult.status === 'fulfilled') {
90
+ parts.push('TASKS:\n' + formatTaskContext(taskResult.value as Record<string, any[]>))
91
+ } else {
92
+ parts.push('TASKS: unavailable')
93
+ }
94
+
95
+ return parts.join('\n\n')
96
+ }
97
+
98
+ /**
99
+ * Pre-warm the context cache at server start so the first query is instant.
100
+ * Called from index.ts after server boots.
101
+ */
102
+ export async function prewarmContext(): Promise<void> {
103
+ try {
104
+ const start = Date.now()
105
+ const content = await fetchLiveContext()
106
+ contextCache = { content, timestamp: Date.now() }
107
+ console.log(`[context] Pre-warmed context cache in ${Date.now() - start}ms (TTL: ${CACHE_TTL_MS / 1000}s)`)
108
+ } catch (err) {
109
+ console.error('[context] Pre-warm failed:', err)
110
+ }
111
+
112
+ // Background refresh — keeps cache perpetually warm so queries NEVER block on fetch
113
+ if (!refreshInterval) {
114
+ refreshInterval = setInterval(async () => {
115
+ try {
116
+ const content = await fetchLiveContext()
117
+ contextCache = { content, timestamp: Date.now() }
118
+ console.log(`[context] Background refresh complete`)
119
+ } catch (err) {
120
+ console.error('[context] Background refresh failed (keeping stale cache):', err)
121
+ }
122
+ }, CACHE_REFRESH_MS)
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get cached context instantly — NEVER blocks on Python fetch.
128
+ * Returns whatever is in cache (even stale), or a minimal fallback.
129
+ * Used by G2 agent where speed > freshness.
130
+ */
131
+ export function getCachedContextInstant(): string {
132
+ return contextCache?.content ?? 'Live context unavailable — cache warming'
133
+ }
134
+
135
+ /**
136
+ * Build the lightweight G2 system prompt — minimal for speed.
137
+ * General-purpose assistant, mostly personal life, occasional business context.
138
+ * Used by G2 agent queries and CLI pre-warm.
139
+ */
140
+ export function buildLightweightSystemPrompt(query: string, historyPrompt: string): string {
141
+ const needsContext = /\b(schedule|calendar|meeting|task|tasks|today|tomorrow|next meeting|who do i meet|what's next|direct report|team)\b/i.test(query)
142
+
143
+ const name = getOwnerName().split(' ')[0]
144
+ const base = `You are ${name}'s personal assistant on Even G2 smart glasses. You are NOT a work productivity tool — you are a general-purpose voice assistant like Siri or Alexa, but smarter. Answer anything ${name} asks: sports scores, trivia, recipes, weather, news, personal questions, recommendations, math, history, science, pop culture, or anything else. Never deflect with "let's get back to work" or steer toward business topics.
145
+ Plain text only — no markdown, no asterisks, no bullets. Aim for 300-600 chars on quick answers; up to ~2000 chars when depth helps (synthesis, multi-part questions, detailed how-to). Glasses scroll continuously — no need to over-compress. Pad nothing; if a question deserves 200 chars, give 200.
146
+ If the query seems incomplete or cut off mid-sentence (e.g. "What's my", "Can you check", "Tell me about the"), ask ${name} to repeat or finish his thought rather than guessing what he meant. This is common with voice input on glasses.`
147
+
148
+ if (needsContext) {
149
+ const cachedContext = getCachedContextInstant()
150
+ return `${base}
151
+ ${cachedContext}
152
+ ${historyPrompt}`
153
+ }
154
+
155
+ return `${base}${historyPrompt ? '\n' + historyPrompt : ''}`
156
+ }
157
+
158
+ /**
159
+ * Build the lightweight prompt for pre-warming (no query to match against, include context).
160
+ */
161
+ export function buildPrewarmSystemPrompt(): string {
162
+ const cachedContext = getCachedContextInstant()
163
+ const name = getOwnerName().split(' ')[0]
164
+ return `You are ${name}'s personal assistant on Even G2 smart glasses. You are NOT a work productivity tool — you are a general-purpose voice assistant. Answer anything: sports, trivia, news, personal questions, recommendations, whatever. Never deflect with "let's get back to work." Plain text only. Aim for 300-600 chars on quick answers; up to ~2000 chars when depth helps. Glasses scroll continuously.
165
+ If the query seems incomplete or cut off mid-sentence, ask ${name} to repeat or finish his thought rather than guessing.
166
+ ${cachedContext}`
167
+ }
168
+
169
+ export async function buildSystemPrompt(conversationHistory: string): Promise<string> {
170
+ // Check cache
171
+ let liveContext: string
172
+ if (contextCache && Date.now() - contextCache.timestamp < CACHE_TTL_MS) {
173
+ liveContext = contextCache.content
174
+ } else {
175
+ try {
176
+ liveContext = await fetchLiveContext()
177
+ contextCache = { content: liveContext, timestamp: Date.now() }
178
+ } catch {
179
+ liveContext = contextCache?.content ?? 'Live context unavailable'
180
+ }
181
+ }
182
+
183
+ const ownerName = getOwnerName()
184
+ return `You are COS (Chief of Staff), ${ownerName}'s AI assistant running on Even G2 smart glasses.
185
+
186
+ DISPLAY CONSTRAINTS:
187
+ 576x288px glasses display. Body scrolls continuously via firmware — write naturally with depth where it helps. Aim for 300-600 chars on quick answers; up to ~2000 chars when depth genuinely helps (synthesis, multi-part questions, detailed how-to). Pad nothing; if a question deserves 200 chars, give 200.
188
+ Plain text only — no markdown, no bullets, no headers, no asterisks.
189
+ Lead with the most actionable information. Be direct. Short sentences for quick answers; full sentences for explanations.
190
+ If listing items, use numbered lines (1. 2. 3.) — no upper limit but prefer 3-5 for scannability.
191
+ Never say "here is" or "I found" — just give the answer.
192
+
193
+ ${COS_MODE ? COS_ROUTING_CONTEXT + '\n\n' : ''}CURRENT CONTEXT:
194
+ ${liveContext}
195
+ ${conversationHistory}
196
+
197
+ BEHAVIOR:
198
+ - Answer questions about the user's schedule and tasks using live context above.
199
+ - For questions about team, reporting lines, roles, relationship history, or engagement style, rely on COS canonical sources instead of the glasses header.
200
+ - For current events, news, or information not in your context, search the web.
201
+ - When the user shares a photo, read the image file and describe what you see concisely. Answer any specific questions about the image. Keep descriptions under 200 characters unless asked for detail.
202
+ - Aim for the right length, not the shortest. Quick Q&A → 200-600 chars. Synthesis / multi-part → up to ~2000. Glasses body scrolls continuously; no penalty for depth that earns its keep.
203
+ - Be the best chief of staff — proactive, concise, no fluff.
204
+ - You have conversation history from this session above. Use it to maintain context across turns.
205
+ - Exchanges above are labeled with the user's global message numbers (e.g., [Msg 165]). When the user says "message 165", it refers to that exchange. Use these numbers when referencing past messages.
206
+ - Only recent exchanges are shown — gaps in numbering mean older messages are outside the context window. If asked about a message not shown, suggest the user say "recall message N" to bring it into context.
207
+ - If a REFERENCED MESSAGE section is present, use that as the authoritative content for any user-referenced message.
208
+ - When you see [Photo context] entries in conversation history, those are summaries of earlier photo analyses. Use them for continuity but note you cannot see the original image — if asked for new detail, request a new photo.
209
+ - Never say you cannot see previous messages — the history is provided above.`
210
+ }