@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,170 @@
1
+ // OpenAI Whisper API daily budget — hard $5/day ceiling.
2
+ //
3
+ // When whisper-server stalls, callers fall to transcribeViaCloud / transcribeCloud
4
+ // (OpenAI Whisper API, $0.006/min). Without a cap, a hung whisper-server during
5
+ // a long meeting could bill hundreds of dollars silently — every chunk shipped
6
+ // straight to OpenAI until someone notices.
7
+ //
8
+ // This module enforces a per-LOCAL-DAY hard cap. `assertOpenAIWhisperBudget()`
9
+ // throws BEFORE any OpenAI call if we're already over. `recordOpenAIWhisperUsage()`
10
+ // is called AFTER a successful call with the audio duration (seconds), so the
11
+ // ledger only counts billable audio (not retries that never reached the API).
12
+ //
13
+ // Cost: Whisper API is billed per second, rounded up, at $0.006/min
14
+ // = $0.0001 / second. $5 cap = 50,000 seconds = 833 min = 13.9 h of audio.
15
+ //
16
+ // State is persisted atomically to server/data/openai-whisper-budget.json.
17
+ // Reset is lazy: when a read finds a date != today's localDay(), it starts fresh.
18
+ // No setInterval, no rollover timers — midnight just means the next read returns
19
+ // a zeroed state.
20
+
21
+ import { existsSync } from 'node:fs'
22
+ import { resolve, dirname } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
25
+ import { localDay } from './local-day.js'
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url))
28
+ import { dataPath } from './data-dir.js'
29
+ const BUDGET_FILE = dataPath('openai-whisper-budget.json')
30
+
31
+ /** OpenAI Whisper API pricing (2024-2025): $0.006 per minute of audio, billed to the second. */
32
+ export const USD_PER_MINUTE = 0.006
33
+ export const USD_PER_SECOND = USD_PER_MINUTE / 60
34
+
35
+ /** Daily hard cap in USD. Tunable via env (OPENAI_WHISPER_DAILY_CAP_USD) — default $5. */
36
+ export const DAILY_USD_CAP = Number(process.env.OPENAI_WHISPER_DAILY_CAP_USD ?? 5)
37
+
38
+ /** Warn threshold — logs once when we cross this fraction of the cap. */
39
+ const WARN_FRACTION = 0.8
40
+
41
+ export class OpenAIWhisperBudgetExhaustedError extends Error {
42
+ public readonly spentTodayUsd: number
43
+ public readonly capUsd: number
44
+ public readonly secondsToday: number
45
+ public readonly callsToday: number
46
+
47
+ constructor(state: BudgetState) {
48
+ const msg =
49
+ `OpenAI Whisper daily budget exhausted: $${state.usdToday.toFixed(4)}/$${DAILY_USD_CAP.toFixed(2)} ` +
50
+ `(${state.secondsToday.toFixed(0)}s of audio across ${state.callsToday} calls today). ` +
51
+ `Cause: whisper-server is unhealthy — fix it before transcription resumes. ` +
52
+ `Recovery: pkill -9 -f whisper-server && restart cos-glasses server (auto-restarts model).`
53
+ super(msg)
54
+ this.name = 'OpenAIWhisperBudgetExhaustedError'
55
+ this.spentTodayUsd = state.usdToday
56
+ this.capUsd = DAILY_USD_CAP
57
+ this.secondsToday = state.secondsToday
58
+ this.callsToday = state.callsToday
59
+ }
60
+ }
61
+
62
+ interface BudgetState {
63
+ /** Local-tz YYYY-MM-DD — when this doesn't equal localDay() on next read, we reset. */
64
+ date: string
65
+ /** Cumulative audio seconds billed today. */
66
+ secondsToday: number
67
+ /** Number of successful cloud calls today (diagnostics). */
68
+ callsToday: number
69
+ /** Derived: USD spent today. Recomputed on every write from secondsToday. */
70
+ usdToday: number
71
+ /** Whether we've already logged the 80% warning today (so we don't spam). */
72
+ warnedAt80: boolean
73
+ }
74
+
75
+ function fresh(): BudgetState {
76
+ return { date: localDay(), secondsToday: 0, callsToday: 0, usdToday: 0, warnedAt80: false }
77
+ }
78
+
79
+ function read(): BudgetState {
80
+ if (!existsSync(BUDGET_FILE)) return fresh()
81
+ const r = loadJsonOrQuarantine<BudgetState>(BUDGET_FILE)
82
+ if (r.status !== 'ok') return fresh()
83
+ if (r.data.date !== localDay()) return fresh() // new day, zero ledger
84
+ return r.data
85
+ }
86
+
87
+ function write(state: BudgetState): void {
88
+ try {
89
+ atomicWriteFileSync(BUDGET_FILE, JSON.stringify(state, null, 2))
90
+ } catch (err) {
91
+ // Non-fatal — worst case we slightly under-count next read and over-spend a few cents.
92
+ console.error('[openai-whisper-budget] Failed to persist budget state:', err)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Estimate audio duration in seconds from a raw buffer. Used BEFORE the OpenAI
98
+ * call so we can reject over-budget calls without making them.
99
+ *
100
+ * Accuracy: exact for our 16 kHz / 16-bit / mono WAV (subtract 44-byte header,
101
+ * divide by 32000 bytes/sec). For WebM/other formats (rare — the dictation path
102
+ * uses WAV), approximates byteLength / 32000 which over-estimates = conservative
103
+ * for billing. Never under-estimates silently.
104
+ */
105
+ export function estimateAudioSeconds(audioBuffer: Buffer): number {
106
+ if (audioBuffer.length < 4) return 0
107
+ const isWav = audioBuffer.toString('ascii', 0, 4) === 'RIFF'
108
+ const dataBytes = isWav ? Math.max(0, audioBuffer.length - 44) : audioBuffer.length
109
+ return dataBytes / 32000 // 16 kHz × 16-bit × mono = 32000 bytes/sec
110
+ }
111
+
112
+ /**
113
+ * Throw BEFORE making any OpenAI Whisper call if today's budget is already spent.
114
+ * Caller must handle OpenAIWhisperBudgetExhaustedError — typical behaviour is to
115
+ * surface a loud error to the client (500 / "cloud transcription unavailable, fix
116
+ * whisper-server") instead of silently returning empty text.
117
+ */
118
+ export function assertOpenAIWhisperBudget(): void {
119
+ const state = read()
120
+ if (state.usdToday >= DAILY_USD_CAP) {
121
+ throw new OpenAIWhisperBudgetExhaustedError(state)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Record a successful cloud transcription. `audioSeconds` should be the
127
+ * duration of the audio we sent to OpenAI (not the latency of the response).
128
+ */
129
+ export function recordOpenAIWhisperUsage(audioSeconds: number): void {
130
+ if (audioSeconds <= 0) return
131
+ const state = read()
132
+ const before = state.usdToday
133
+ state.secondsToday += audioSeconds
134
+ state.callsToday += 1
135
+ state.usdToday = state.secondsToday * USD_PER_SECOND
136
+
137
+ const warnThreshold = DAILY_USD_CAP * WARN_FRACTION
138
+ if (before < warnThreshold && state.usdToday >= warnThreshold && !state.warnedAt80) {
139
+ console.warn(
140
+ `[openai-whisper-budget] WARN — $${state.usdToday.toFixed(4)}/$${DAILY_USD_CAP.toFixed(2)} ` +
141
+ `(${((state.usdToday / DAILY_USD_CAP) * 100).toFixed(0)}%) today across ${state.callsToday} calls. ` +
142
+ `If whisper-server is stalled, fix it now to avoid the hard cap.`,
143
+ )
144
+ state.warnedAt80 = true
145
+ }
146
+
147
+ if (state.usdToday >= DAILY_USD_CAP) {
148
+ console.error(
149
+ `[openai-whisper-budget] HARD CAP REACHED — $${state.usdToday.toFixed(4)}/$${DAILY_USD_CAP.toFixed(2)} today. ` +
150
+ `All further OpenAI Whisper calls will throw until local midnight.`,
151
+ )
152
+ }
153
+
154
+ write(state)
155
+ }
156
+
157
+ /** Status snapshot for diagnostics / health endpoints. */
158
+ export function getOpenAIWhisperBudgetState(): BudgetState & {
159
+ capUsd: number
160
+ remainingUsd: number
161
+ percentUsed: number
162
+ } {
163
+ const state = read()
164
+ return {
165
+ ...state,
166
+ capUsd: DAILY_USD_CAP,
167
+ remainingUsd: Math.max(0, DAILY_USD_CAP - state.usdToday),
168
+ percentUsed: Math.round((state.usdToday / DAILY_USD_CAP) * 100),
169
+ }
170
+ }
@@ -0,0 +1,94 @@
1
+ // Profile loader — reads user identity from .cos-profile.json (gitignored)
2
+ // Falls back to generic defaults for users who haven't configured a profile
3
+
4
+ import { readFileSync } from 'node:fs'
5
+ import { resolve } from 'node:path'
6
+ import { atomicWriteFileSync } from './atomic-fs.js'
7
+
8
+ const APP_ROOT = resolve(import.meta.dirname, '../..')
9
+ // Single canonical path — used by BOTH the reader and the writer so a glossary
10
+ // PUT can never write to a different file than the cache reads from. Lazy +
11
+ // env-overridable (COS_PROFILE_PATH) so tests can target a temp file.
12
+ function profilePath(): string {
13
+ return process.env.COS_PROFILE_PATH || resolve(APP_ROOT, '.cos-profile.json')
14
+ }
15
+
16
+ let profileCache: Record<string, unknown> | null = null
17
+
18
+ function loadProfile(): Record<string, unknown> {
19
+ if (profileCache) return profileCache
20
+ try {
21
+ profileCache = JSON.parse(readFileSync(profilePath(), 'utf-8'))
22
+ return profileCache!
23
+ } catch {
24
+ profileCache = {}
25
+ return profileCache
26
+ }
27
+ }
28
+
29
+ /** Null the in-memory profile cache so the next read reloads from disk.
30
+ * Call after any write to .cos-profile.json (e.g. the glossary PUT). This is
31
+ * the ROOT cache every getter reads through — busting it is necessary but NOT
32
+ * sufficient: the decoder snapshots in whisper-local.ts (resetDecoderCaches)
33
+ * must be cleared too. */
34
+ export function clearProfileCache(): void {
35
+ profileCache = null
36
+ }
37
+
38
+ /** Read-modify-write merge of top-level fields into .cos-profile.json.
39
+ * Reads the CURRENT file fresh (not the cache) so untouched keys
40
+ * (domain_keywords, system_prompt_context, owner_name, ...) are preserved,
41
+ * writes atomically, then busts the cache. Returns the merged profile. */
42
+ export function updateProfileFields(patch: Record<string, unknown>): Record<string, unknown> {
43
+ let current: Record<string, unknown> = {}
44
+ try {
45
+ current = JSON.parse(readFileSync(profilePath(), 'utf-8')) as Record<string, unknown>
46
+ } catch {
47
+ current = {} // missing/corrupt — start fresh; merge still proceeds
48
+ }
49
+ const merged = { ...current, ...patch }
50
+ atomicWriteFileSync(profilePath(), JSON.stringify(merged, null, 2))
51
+ clearProfileCache()
52
+ return merged
53
+ }
54
+
55
+ export function loadProfileField(field: string, fallback: string): string {
56
+ const profile = loadProfile()
57
+ const value = profile[field]
58
+ return typeof value === 'string' ? value : fallback
59
+ }
60
+
61
+ export function getOwnerName(): string {
62
+ return loadProfileField('owner_name', 'User')
63
+ }
64
+
65
+ /** Short speaker label for the glasses wearer, used by diarization to fast-path
66
+ * the owner's voiceprint. Defaults to 'Me'. Configure via owner_speaker_label. */
67
+ export function getOwnerSpeakerLabel(): string {
68
+ return loadProfileField('owner_speaker_label', 'Me')
69
+ }
70
+
71
+ export function getVocabulary(): string[] {
72
+ const profile = loadProfile()
73
+ return Array.isArray(profile.vocabulary) ? profile.vocabulary as string[] : []
74
+ }
75
+
76
+ export function getSystemContext(): string {
77
+ return loadProfileField('system_prompt_context', '')
78
+ }
79
+
80
+ export function getDomainKeywords(): Record<string, string[]> {
81
+ const profile = loadProfile()
82
+ const dk = profile.domain_keywords
83
+ return (dk && typeof dk === 'object') ? dk as Record<string, string[]> : {}
84
+ }
85
+
86
+ /** Editable negative/cleanup rules (whole:/strip:/replace:/flag:) authored via
87
+ * the glossary PUT. Parsed + applied by hallucination-filter.ts. Returns the
88
+ * raw rule lines; non-string entries are dropped defensively. */
89
+ export function getNegativeRules(): string[] {
90
+ const profile = loadProfile()
91
+ return Array.isArray(profile.negative_rules)
92
+ ? (profile.negative_rules as unknown[]).filter((r): r is string => typeof r === 'string')
93
+ : []
94
+ }
@@ -0,0 +1,84 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { resolve } from 'node:path'
3
+ import { existsSync } from 'node:fs'
4
+
5
+ // Optional COS pipeline bridge.
6
+ //
7
+ // Standalone (default): COS_SCRIPTS_DIR is unset, callPython() resolves to an
8
+ // empty/no-op result, and the server runs as glasses + Claude only.
9
+ //
10
+ // Full pipeline (optional): power users running the COS Starter Kit set
11
+ // COS_SCRIPTS_DIR to their `operations/scripts` directory. If a Python venv and
12
+ // cos_api_bridge.py are present there, live tasks/calendar/etc. are sourced from
13
+ // it. No COS source ships in this package — it shells out to the user's own.
14
+
15
+ export const COS_SCRIPTS_DIR: string | null = process.env.COS_SCRIPTS_DIR
16
+ ? resolve(process.env.COS_SCRIPTS_DIR)
17
+ : null
18
+
19
+ /** True when the full COS pipeline directory is configured. */
20
+ export const COS_MODE = !!COS_SCRIPTS_DIR
21
+
22
+ if (!COS_SCRIPTS_DIR) {
23
+ console.log('[COS] Standalone mode — glasses + Claude only (set COS_SCRIPTS_DIR for the full pipeline)')
24
+ }
25
+
26
+ export const PYTHON_BIN: string | null = COS_SCRIPTS_DIR ? resolve(COS_SCRIPTS_DIR, 'venv/bin/python3') : null
27
+ const BRIDGE_SCRIPT: string | null = COS_SCRIPTS_DIR ? resolve(COS_SCRIPTS_DIR, 'cos_api_bridge.py') : null
28
+
29
+ // The optional Python bridge is available only when the user points us at a real
30
+ // COS pipeline that ships the venv + bridge script. Standalone installs never
31
+ // have these, so callPython() degrades to a no-op.
32
+ const pythonAvailable = !!(COS_SCRIPTS_DIR && existsSync(PYTHON_BIN!) && existsSync(BRIDGE_SCRIPT!))
33
+
34
+ if (pythonAvailable) {
35
+ console.log('[python-bridge] COS pipeline detected — sourcing live context')
36
+ } else if (COS_SCRIPTS_DIR) {
37
+ console.log('[python-bridge] COS_SCRIPTS_DIR set but cos_api_bridge.py not found — running without live context')
38
+ }
39
+
40
+ /**
41
+ * Call the optional COS data bridge. Returns live data only when a full COS
42
+ * pipeline is configured; otherwise resolves to an empty/no-op result so the
43
+ * context builder degrades gracefully on a standalone install.
44
+ */
45
+ export function callPython(args: string[], timeoutMs = 30_000): Promise<unknown> {
46
+ if (pythonAvailable) {
47
+ return callPythonDirect(args, timeoutMs)
48
+ }
49
+ return Promise.resolve(standaloneNoop(args))
50
+ }
51
+
52
+ /** Empty shapes that the context builder tolerates (no crash, no live data). */
53
+ function standaloneNoop(args: string[]): unknown {
54
+ switch (args[0]) {
55
+ case 'calendar': return { events: [] }
56
+ case 'tasks': return {}
57
+ case 'threads': return []
58
+ case 'memory': return []
59
+ case 'badges': return {}
60
+ default: return {}
61
+ }
62
+ }
63
+
64
+ /** Full Python bridge — requires the user's venv + cos_api_bridge.py. */
65
+ function callPythonDirect(args: string[], timeoutMs: number): Promise<unknown> {
66
+ return new Promise((resolvePromise, reject) => {
67
+ execFile(
68
+ PYTHON_BIN!,
69
+ [BRIDGE_SCRIPT!, ...args],
70
+ { cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
71
+ (err, stdout, stderr) => {
72
+ if (err) {
73
+ const msg = stderr?.trim() || err.message
74
+ return reject(new Error(`python-bridge: ${msg}`))
75
+ }
76
+ try {
77
+ resolvePromise(JSON.parse(stdout))
78
+ } catch {
79
+ reject(new Error(`python-bridge: invalid JSON output`))
80
+ }
81
+ }
82
+ )
83
+ })
84
+ }
@@ -0,0 +1,138 @@
1
+ // Predictive response cache — instant responses for common G2 queries
2
+ // Bypasses Claude CLI entirely for pattern-matched queries using cached COS context.
3
+ // TTFB: ~10ms vs ~1-3s through Claude.
4
+
5
+ import { getCachedContextInstant } from './context-builder.js'
6
+ import { getOwnerName, loadProfileField } from './profile.js'
7
+
8
+ interface CacheResult {
9
+ text: string
10
+ pattern: string
11
+ }
12
+
13
+ // Narrow match list — one-liners only. Over-matching degrades experience.
14
+ const PATTERNS: Array<{ regex: RegExp; name: string; handler: (query: string) => string | null }> = [
15
+ {
16
+ regex: /^(what('?s| is) (the )?)?time\??$|^what time is it\??$/i,
17
+ name: 'time',
18
+ handler: () => {
19
+ const now = new Date()
20
+ return now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
21
+ },
22
+ },
23
+ {
24
+ regex: /^what('?s| is) (the )?(date|day)( today)?\??$|^what day is it\??$/i,
25
+ name: 'date',
26
+ handler: () => {
27
+ const now = new Date()
28
+ return now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
29
+ },
30
+ },
31
+ {
32
+ regex: /^(what('?s| is) my )?(next meeting|next call)\??$/i,
33
+ name: 'next_meeting',
34
+ handler: () => {
35
+ const ctx = getCachedContextInstant()
36
+ if (!ctx || ctx.includes('unavailable')) return null
37
+ const nextMatch = ctx.match(/NEXT:\s*(.+)/i)
38
+ if (!nextMatch) {
39
+ if (ctx.includes('No more meetings')) return 'No more meetings today.'
40
+ return null
41
+ }
42
+ return nextMatch[1].trim()
43
+ },
44
+ },
45
+ {
46
+ regex: /^(what('?s| is) my )?(schedule|calendar|meetings)( today)?\??$/i,
47
+ name: 'schedule',
48
+ handler: () => {
49
+ const ctx = getCachedContextInstant()
50
+ if (!ctx || ctx.includes('unavailable')) return null
51
+ // Extract the CALENDAR section
52
+ const calMatch = ctx.match(/CALENDAR:\n([\s\S]*?)(?:\n\n|$)/)
53
+ if (!calMatch) return null
54
+ return calMatch[1].trim()
55
+ },
56
+ },
57
+ {
58
+ regex: /^(how many )?(open )?tasks?\??$/i,
59
+ name: 'task_count',
60
+ handler: () => {
61
+ const ctx = getCachedContextInstant()
62
+ if (!ctx || ctx.includes('unavailable')) return null
63
+ const taskMatch = ctx.match(/(\d+) open tasks? total/)
64
+ if (!taskMatch) return null
65
+ return `${taskMatch[1]} open tasks.`
66
+ },
67
+ },
68
+ {
69
+ regex: /^(who am i|who is \w+|tell me about myself)\??$/i,
70
+ name: 'who_am_i',
71
+ handler: () => {
72
+ const name = getOwnerName()
73
+ const context = loadProfileField('system_prompt_context', '')
74
+ if (context) return `You're ${name}. ${context}`
75
+ return `You're ${name}. Configure .cos-profile.json for more context.`
76
+ },
77
+ },
78
+ {
79
+ regex: /^(hey|hi|hello|yo|sup|what's up|hey even)[\s!.]*$/i,
80
+ name: 'greeting',
81
+ handler: () => {
82
+ const name = getOwnerName().split(' ')[0] // First name only
83
+ const hour = new Date().getHours()
84
+ if (hour < 12) return `Good morning, ${name}.`
85
+ if (hour < 17) return `Good afternoon, ${name}.`
86
+ return `Good evening, ${name}.`
87
+ },
88
+ },
89
+ {
90
+ regex: /^(thanks|thank you|thx|ty|appreciate it|got it|ok|okay|cool|great|perfect)[\s!.]*$/i,
91
+ name: 'acknowledgment',
92
+ handler: () => {
93
+ return "Anytime."
94
+ },
95
+ },
96
+ {
97
+ regex: /^what('?s| is) (\d+)\s*[\+\-\*x×]\s*(\d+)\??$/i,
98
+ name: 'basic_math',
99
+ handler: (query: string) => {
100
+ const m = query.match(/(\d+)\s*([\+\-\*x×])\s*(\d+)/i)
101
+ if (!m) return null
102
+ const a = parseInt(m[1], 10)
103
+ const b = parseInt(m[3], 10)
104
+ const op = m[2]
105
+ let result: number
106
+ switch (op) {
107
+ case '+': result = a + b; break
108
+ case '-': result = a - b; break
109
+ case '*': case 'x': case '×': result = a * b; break
110
+ default: return null
111
+ }
112
+ return `${a} ${op === 'x' || op === '×' ? '×' : op} ${b} = ${result}`
113
+ },
114
+ },
115
+ ]
116
+
117
+ /**
118
+ * Try to answer a query instantly from cached context.
119
+ * Returns null if the query doesn't match any cached pattern.
120
+ */
121
+ export function tryInstantResponse(query: string): CacheResult | null {
122
+ const q = query.trim()
123
+ // Skip long queries — they're unlikely to be simple lookups
124
+ if (q.length > 60) return null
125
+
126
+ for (const { regex, name, handler } of PATTERNS) {
127
+ if (regex.test(q)) {
128
+ const text = handler(q)
129
+ if (text) {
130
+ return { text, pattern: name }
131
+ }
132
+ // Pattern matched but handler returned null (e.g., cache stale) — fall through to Claude
133
+ return null
134
+ }
135
+ }
136
+
137
+ return null
138
+ }