@gotcos/glasses-server 6.53.3 → 6.54.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.
@@ -0,0 +1,416 @@
1
+ // The engines behind the lens gist (lens-gist.ts): one text-in, text-out call each, with no
2
+ // session, no history and no MCP servers, in an empty workspace made for the call and
3
+ // removed after it. Every runner rejects on any failure so the caller can count it against
4
+ // that engine's breaker; the next engine of a chain answers, or the phone keeps its
5
+ // verbatim card. A provider that says its limit is spent rejects with LensGistLimitError.
6
+ //
7
+ // WHAT EACH ENGINE CAN STILL DO (the reply is untrusted text: a web page an agent quoted
8
+ // can carry instructions, /qa round 1 B1):
9
+ // claude no tools at all (`--tools ""`), no hooks (`disableAllHooks`).
10
+ // codex its shell, browser, computer-use, app and plugin tools disabled, web search
11
+ // off, no user config and no rules files.
12
+ // cursor ask mode, which is read-only but keeps its read tools; the CLI has no switch to
13
+ // remove them. It reads an empty workspace, and what it answers is redacted.
14
+ // ollama a bare completion: no tools.
15
+ //
16
+ // Measured 2026-09-25 (operations/personal/wk39_2026 in the COS repo): Claude Sonnet with the
17
+ // tools off answered in 2 to 5 s on about 1.2k tokens; Claude Haiku with the flags the
18
+ // dictation cleaner ships (no `--tools ""`) wrote a 26.5k-token cache of tool definitions.
19
+ // Codex and Cursor carry 11 to 17k tokens of their own harness on every call.
20
+
21
+ import { spawn } from 'node:child_process'
22
+ import { mkdtempSync, rmSync } from 'node:fs'
23
+ import { tmpdir } from 'node:os'
24
+ import { dirname, join } from 'node:path'
25
+ import { resolveProviderBinary } from './provider-binary.js'
26
+ import { getOllamaCatalog, ollamaFetch, resolveOllamaOrigin } from './ollama-catalog.js'
27
+ import { PROVIDER_QUOTA_RE } from './provider-proof.js'
28
+ import { logTokenAudit } from './token-audit.js'
29
+
30
+ /**
31
+ * The provider itself said its plan, session, rate or capacity limit is spent. Raised only
32
+ * from the provider's OWN output (its exit text, its JSON error, its failed-turn event),
33
+ * never from the words of an answer: /qa round 3 found a reply that mentioned "429s" or a
34
+ * "daily quota" opening a breaker. The caller opens that engine's breaker at once.
35
+ */
36
+ export class LensGistLimitError extends Error {
37
+ readonly limit = true
38
+ }
39
+
40
+ /** An error for this failure: a limit when the provider's raw words say so. */
41
+ export function engineFailure(message: string, providerText: string): Error {
42
+ return PROVIDER_QUOTA_RE.test(providerText) ? new LensGistLimitError(message) : new Error(message)
43
+ }
44
+
45
+ export type LensGistEngineName = 'claude' | 'codex' | 'cursor' | 'ollama'
46
+
47
+ export interface LensGistUsage {
48
+ inputTokens?: number
49
+ outputTokens?: number
50
+ cachedInputTokens?: number
51
+ costUsd?: number
52
+ }
53
+
54
+ export interface LensGistEngineRun {
55
+ text: string
56
+ usage?: LensGistUsage
57
+ /** The model the engine reports it ran, when it says. */
58
+ model?: string
59
+ }
60
+
61
+ export interface LensGistRunOptions {
62
+ timeoutMs: number
63
+ system: string
64
+ }
65
+
66
+ /** Replaces Claude Code's own system prompt, so the call carries none of it. */
67
+ export const LENS_GIST_SYSTEM_PROMPT = 'You write three-line summaries for a smart-glasses card. Output only the lines asked for.'
68
+
69
+ const SECRET_PATTERNS: Array<[RegExp, string]> = [
70
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '[private key]'],
71
+ [/\bsk-[A-Za-z0-9_*.-]{6,}/g, 'sk-REDACTED'],
72
+ [/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi, '$1REDACTED'],
73
+ [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, 'gh-REDACTED'],
74
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'gh-REDACTED'],
75
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g, 'xox-REDACTED'],
76
+ [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, 'AWS-REDACTED'],
77
+ [/\bpat-(?:na|eu|ap)\d-[0-9a-f-]{20,}\b/gi, 'pat-REDACTED'],
78
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt-REDACTED'],
79
+ ]
80
+
81
+ /** A key or token never reaches a log, the cache or the lens, whichever side echoed it. */
82
+ export function redactSecrets(text: string): string {
83
+ let out = text
84
+ for (const [re, to] of SECRET_PATTERNS) out = out.replace(re, to)
85
+ const own = process.env.COS_API_TOKEN
86
+ if (own && own.length >= 12) out = out.split(own).join('COS-TOKEN-REDACTED')
87
+ return out
88
+ }
89
+
90
+ /** A fresh, private, empty directory for one call: nothing planted in it, nothing to read. */
91
+ function withScratchWorkspace<T>(fn: (dir: string) => Promise<T>): Promise<T> {
92
+ const dir = mkdtempSync(join(tmpdir(), 'cos-lens-gist-'))
93
+ return fn(dir).finally(() => {
94
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* best effort */ }
95
+ })
96
+ }
97
+
98
+ /** Nothing the server itself holds reaches a model's process. */
99
+ const STRIPPED_ENV = ['CLAUDECODE', 'COS_API_TOKEN']
100
+
101
+ export function lensGistChildEnv(binary: string): NodeJS.ProcessEnv {
102
+ const env = { ...process.env }
103
+ for (const key of STRIPPED_ENV) delete env[key]
104
+ // A launchd- or Finder-spawned server has a minimal PATH, and `claude` is a node script:
105
+ // resolving the binary is not enough, its interpreter must be findable too.
106
+ const dirs = [dirname(binary), '/opt/homebrew/bin', '/usr/local/bin']
107
+ const path = env.PATH ?? ''
108
+ env.PATH = [...dirs.filter(dir => !path.split(':').includes(dir)), path].filter(Boolean).join(':')
109
+ return env
110
+ }
111
+
112
+ interface ProcessResult {
113
+ code: number | null
114
+ stdout: string
115
+ stderr: string
116
+ }
117
+
118
+ /** Spawn, write the prompt to stdin, collect both streams; SIGTERM then SIGKILL on timeout. */
119
+ export function runProcess(
120
+ binary: string,
121
+ args: string[],
122
+ opts: { stdin: string; cwd: string; timeoutMs: number; label: string },
123
+ ): Promise<ProcessResult> {
124
+ return new Promise((resolve, reject) => {
125
+ let stdout = ''
126
+ let stderr = ''
127
+ let settled = false
128
+ let killTimer: NodeJS.Timeout | null = null
129
+ const proc = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: lensGistChildEnv(binary), cwd: opts.cwd })
130
+ const finish = (fn: () => void) => {
131
+ if (settled) return
132
+ settled = true
133
+ clearTimeout(timer)
134
+ fn()
135
+ }
136
+ const terminate = () => {
137
+ try { proc.kill('SIGTERM') } catch { /* already gone */ }
138
+ killTimer = setTimeout(() => { try { proc.kill('SIGKILL') } catch { /* already gone */ } }, 2_000)
139
+ killTimer.unref?.()
140
+ }
141
+ const timer = setTimeout(() => finish(() => {
142
+ terminate()
143
+ reject(new Error(`${opts.label} timed out after ${opts.timeoutMs} ms${stderr ? `: ${redactSecrets(stderr).slice(-200)}` : ''}`))
144
+ }), opts.timeoutMs)
145
+ proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
146
+ proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
147
+ proc.on('error', err => finish(() => reject(err)))
148
+ proc.on('close', code => {
149
+ if (killTimer) clearTimeout(killTimer)
150
+ finish(() => resolve({ code, stdout, stderr }))
151
+ })
152
+ proc.stdin.on('error', err => finish(() => { terminate(); reject(err) }))
153
+ try {
154
+ proc.stdin.end(opts.stdin)
155
+ } catch (err) {
156
+ finish(() => { terminate(); reject(err instanceof Error ? err : new Error(String(err))) })
157
+ }
158
+ })
159
+ }
160
+
161
+ function binaryFor(provider: 'claude' | 'codex' | 'cursor'): string {
162
+ const resolved = resolveProviderBinary(provider)
163
+ if (!resolved.ok) throw new Error(`${provider} binary not found (${resolved.detail})`)
164
+ return resolved.path
165
+ }
166
+
167
+ function num(value: unknown): number | undefined {
168
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
169
+ }
170
+
171
+ function lastJsonObject(stdout: string): any {
172
+ const lines = stdout.trim().split('\n').reverse()
173
+ for (const line of lines) {
174
+ const trimmed = line.trim()
175
+ if (!trimmed.startsWith('{')) continue
176
+ try { return JSON.parse(trimmed) } catch { /* keep looking */ }
177
+ }
178
+ throw new Error('no JSON result in the output')
179
+ }
180
+
181
+ // --- Claude ---------------------------------------------------------------------------
182
+
183
+ export function buildClaudeGistArgs(model: string, system: string): string[] {
184
+ return [
185
+ '-p', '--model', model, '--effort', 'low',
186
+ // No tools: nothing to act on an instruction hidden in the reply, and ~1.2k tokens a
187
+ // call instead of a cache of tool definitions. claude-permissions.ts records an empty
188
+ // --tools causing a spurious compaction and a synthetic 400 on the 2026-08-26 CLI, in
189
+ // long bridge sessions; this one-shot call was measured clean on the 2026-09-25 CLI, and
190
+ // a regression lands on this engine's breaker, not on the lens.
191
+ '--tools', '',
192
+ '--output-format', 'json', '--no-session-persistence',
193
+ '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
194
+ // The user's own hooks (session trackers, prompt loggers) would otherwise spool every
195
+ // reply this summarizes. Proven 2026-09-25: a hook set in the same call did not fire.
196
+ '--settings', '{"disableAllHooks":true}',
197
+ '--system-prompt', system,
198
+ ]
199
+ }
200
+
201
+ /** `claude -p --output-format json`: one result object. */
202
+ export function parseClaudeGistOutput(stdout: string): LensGistEngineRun {
203
+ const result = lastJsonObject(stdout)
204
+ if (result?.is_error || (result?.subtype && result.subtype !== 'success')) {
205
+ throw new Error(`claude: ${redactSecrets(String(result?.result ?? result?.subtype ?? 'error')).slice(0, 200)}`)
206
+ }
207
+ const text = typeof result?.result === 'string' ? result.result : ''
208
+ const usage = result?.usage ?? {}
209
+ const cacheIn = (num(usage.cache_creation_input_tokens) ?? 0) + (num(usage.cache_read_input_tokens) ?? 0)
210
+ // `sonnet` is an alias; modelUsage names what it resolved to.
211
+ const resolved = result?.modelUsage && typeof result.modelUsage === 'object' ? Object.keys(result.modelUsage)[0] : undefined
212
+ return {
213
+ text,
214
+ ...(resolved ? { model: resolved } : {}),
215
+ usage: {
216
+ inputTokens: (num(usage.input_tokens) ?? 0) + cacheIn,
217
+ cachedInputTokens: num(usage.cache_read_input_tokens),
218
+ outputTokens: num(usage.output_tokens),
219
+ costUsd: num(result?.total_cost_usd),
220
+ },
221
+ }
222
+ }
223
+
224
+ // --- Codex ----------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Codex tools switched off for a gist (`codex features list`, CLI 0.155; /qa round 2 found
228
+ * view_image, multi_agent, image_generation, unified_exec and hooks still on after round 1).
229
+ * An unknown name is an error on this CLI, so a renamed feature fails loudly on the breaker.
230
+ */
231
+ export const CODEX_GIST_DISABLED_FEATURES = [
232
+ 'shell_tool', 'unified_exec', 'view_image', 'multi_agent', 'image_generation', 'hooks',
233
+ 'browser_use', 'browser_use_external', 'computer_use', 'in_app_browser', 'apps', 'plugins',
234
+ ] as const
235
+
236
+ export function buildCodexGistArgs(model: string, workspace: string): string[] {
237
+ return [
238
+ 'exec',
239
+ // No config.toml (its MCP servers, profiles, model settings) and no rules files. Auth
240
+ // still comes from CODEX_HOME, and AGENTS.md discovery starts at the empty workspace.
241
+ '--ignore-user-config',
242
+ '--ignore-rules',
243
+ // A read-only sandbox still lets the shell read the whole disk; the shell is off.
244
+ ...CODEX_GIST_DISABLED_FEATURES.flatMap(feature => ['--disable', feature]),
245
+ '-c', 'web_search="disabled"',
246
+ '--sandbox', 'read-only',
247
+ '--skip-git-repo-check',
248
+ '--ephemeral',
249
+ '--json',
250
+ '--cd', workspace,
251
+ '--model', model,
252
+ '-c', 'model_reasoning_effort="low"',
253
+ '-',
254
+ ]
255
+ }
256
+
257
+ /** `codex exec --json`: JSON lines; the answer is the last agent message. */
258
+ export function parseCodexGistOutput(stdout: string): LensGistEngineRun {
259
+ let text = ''
260
+ let usage: LensGistUsage | undefined
261
+ let failure = ''
262
+ for (const line of stdout.split('\n')) {
263
+ const trimmed = line.trim()
264
+ if (!trimmed.startsWith('{')) continue
265
+ let event: any
266
+ try { event = JSON.parse(trimmed) } catch { continue }
267
+ const item = event?.item
268
+ if (event?.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string') text = item.text
269
+ if (event?.type === 'turn.completed') {
270
+ const u = event.usage ?? {}
271
+ usage = { inputTokens: num(u.input_tokens), cachedInputTokens: num(u.cached_input_tokens), outputTokens: num(u.output_tokens) }
272
+ }
273
+ if (event?.type === 'turn.failed') failure = String(event?.error?.message ?? 'turn failed')
274
+ }
275
+ if (failure) throw new Error(`codex: ${redactSecrets(failure).slice(0, 200)}`)
276
+ return { text, usage }
277
+ }
278
+
279
+ // --- Cursor ---------------------------------------------------------------------------
280
+
281
+ export function buildCursorGistArgs(model: string, workspace: string): string[] {
282
+ // Ask mode is read-only; the workspace is an empty directory, so there is nothing to read.
283
+ return ['-p', '--mode', 'ask', '--model', model, '--output-format', 'json', '--trust', '--workspace', workspace]
284
+ }
285
+
286
+ /** `agent -p --output-format json`: one result object. */
287
+ export function parseCursorGistOutput(stdout: string): LensGistEngineRun {
288
+ const result = lastJsonObject(stdout)
289
+ if (result?.is_error || (result?.subtype && result.subtype !== 'success')) {
290
+ throw new Error(`cursor: ${redactSecrets(String(result?.result ?? result?.subtype ?? 'error')).slice(0, 200)}`)
291
+ }
292
+ const usage = result?.usage ?? {}
293
+ return {
294
+ text: typeof result?.result === 'string' ? result.result : '',
295
+ usage: {
296
+ inputTokens: num(usage.inputTokens) ?? num(usage.input_tokens),
297
+ outputTokens: num(usage.outputTokens) ?? num(usage.output_tokens),
298
+ cachedInputTokens: num(usage.cacheReadTokens) ?? num(usage.cache_read_input_tokens),
299
+ },
300
+ }
301
+ }
302
+
303
+ // --- Dispatch -------------------------------------------------------------------------
304
+
305
+ /** The failure text of a non-zero exit, redacted BEFORE it is cut (a cut token escapes). */
306
+ function failedExitMessage(label: string, result: ProcessResult): string {
307
+ // Claude Code prints a spent limit on STDOUT and exits 1; stderr may carry other noise.
308
+ const text = redactSecrets([result.stderr.trim(), result.stdout.trim()].filter(Boolean).join('\n'))
309
+ const detail = text.split('\n').slice(-3).join(' ').slice(0, 240)
310
+ return `${label} exited ${result.code ?? 'by signal'}${detail ? `: ${detail}` : ''}`
311
+ }
312
+
313
+ async function runCli(
314
+ provider: 'claude' | 'codex' | 'cursor',
315
+ args: (workspace: string) => string[],
316
+ prompt: string,
317
+ opts: LensGistRunOptions,
318
+ parse: (stdout: string) => LensGistEngineRun,
319
+ ): Promise<LensGistEngineRun> {
320
+ const binary = binaryFor(provider)
321
+ const result = await withScratchWorkspace(workspace =>
322
+ runProcess(binary, args(workspace), { stdin: prompt, cwd: workspace, timeoutMs: opts.timeoutMs, label: provider }))
323
+ const providerText = `${result.stdout}\n${result.stderr}`
324
+ if (result.code !== 0) {
325
+ // Codex reports its failure as a JSON event AND a non-zero exit; the event says why.
326
+ if (provider === 'codex') {
327
+ try { parse(result.stdout) } catch (err) { throw engineFailure(err instanceof Error ? err.message : String(err), providerText) }
328
+ }
329
+ throw engineFailure(failedExitMessage(provider, result), providerText)
330
+ }
331
+ let run: LensGistEngineRun
332
+ try {
333
+ run = parse(result.stdout)
334
+ } catch (err) {
335
+ // A JSON result marked as an error (Claude, Cursor) can be a limit too.
336
+ throw engineFailure(err instanceof Error ? err.message : String(err), providerText)
337
+ }
338
+ if (!run.text.trim()) throw new Error(`${provider} returned no text`)
339
+ return run
340
+ }
341
+
342
+ async function runOllama(model: string, prompt: string, opts: LensGistRunOptions): Promise<LensGistEngineRun> {
343
+ const origin = resolveOllamaOrigin()
344
+ if (!origin.ok) throw new Error(`ollama: ${origin.error}`)
345
+ const chosen = model || (await getOllamaCatalog()).model
346
+ if (!chosen) throw new Error('ollama: no local model installed')
347
+ const abort = new AbortController()
348
+ const timer = setTimeout(() => abort.abort(), opts.timeoutMs)
349
+ try {
350
+ const response = await ollamaFetch(`${origin.origin}/api/generate`, {
351
+ method: 'POST',
352
+ headers: { 'content-type': 'application/json' },
353
+ body: JSON.stringify({
354
+ model: chosen,
355
+ system: opts.system,
356
+ prompt,
357
+ stream: false,
358
+ think: false,
359
+ // Loading a 27B model cold took 14 s of a 16.5 s call; kept warm it answered in 2.5 s.
360
+ keep_alive: '30m',
361
+ options: { temperature: 0.2, num_predict: 200 },
362
+ }),
363
+ signal: abort.signal,
364
+ })
365
+ if (!response.ok) throw new Error(`ollama answered ${response.status}`)
366
+ const body = await response.json() as any
367
+ const text = typeof body?.response === 'string' ? body.response : ''
368
+ if (!text.trim()) throw new Error('ollama returned no text')
369
+ return { text, model: chosen, usage: { inputTokens: num(body?.prompt_eval_count), outputTokens: num(body?.eval_count) } }
370
+ } catch (err) {
371
+ if (abort.signal.aborted) throw new Error(`ollama timed out after ${opts.timeoutMs} ms`)
372
+ throw err
373
+ } finally {
374
+ clearTimeout(timer)
375
+ }
376
+ }
377
+
378
+ function dispatch(engine: LensGistEngineName, model: string, prompt: string, opts: LensGistRunOptions): Promise<LensGistEngineRun> {
379
+ switch (engine) {
380
+ case 'claude':
381
+ return runCli('claude', () => buildClaudeGistArgs(model, opts.system), prompt, opts, parseClaudeGistOutput)
382
+ case 'codex':
383
+ return runCli('codex', workspace => buildCodexGistArgs(model, workspace), prompt, opts, parseCodexGistOutput)
384
+ case 'cursor':
385
+ return runCli('cursor', workspace => buildCursorGistArgs(model, workspace), prompt, opts, parseCursorGistOutput)
386
+ case 'ollama':
387
+ return runOllama(model, prompt, opts)
388
+ }
389
+ }
390
+
391
+ /** One engine call, and one row in the shared token audit either way (source g2-lens-gist). */
392
+ export async function runLensGistEngine(
393
+ engine: LensGistEngineName,
394
+ model: string,
395
+ prompt: string,
396
+ opts: LensGistRunOptions,
397
+ ): Promise<LensGistEngineRun> {
398
+ const started = Date.now()
399
+ let outputChars = 0
400
+ let resolvedModel = model
401
+ try {
402
+ const run = await dispatch(engine, model, prompt, opts)
403
+ outputChars = run.text.length
404
+ resolvedModel = run.model || model
405
+ return run
406
+ } finally {
407
+ logTokenAudit({
408
+ source: 'g2-lens-gist',
409
+ caller: 'lens_gist',
410
+ model: `${engine}:${resolvedModel || 'default'}`,
411
+ inputChars: prompt.length + opts.system.length,
412
+ outputChars,
413
+ durationMs: Date.now() - started,
414
+ })
415
+ }
416
+ }