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