@gotcos/glasses-server 6.53.4 → 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.
package/.env.example CHANGED
@@ -252,6 +252,21 @@ BIND_HOST=0.0.0.0
252
252
  # tier for the rest of the day. Default 40.
253
253
  # COS_MEETING_SUMMARY_DAILY_CAP=40
254
254
 
255
+ # ── LENS GIST: the summary behind the G2 prompt box (6.54.0) ────────────
256
+ # Outcome / So what / You asked for a finished session or message, written by the
257
+ # engine you pick: claude (default, when the Claude CLI is installed), codex, cursor,
258
+ # ollama, or off. A mistyped engine is off. PUT /api/lens-gist/config saves the same
259
+ # choice in the data dir; this setting wins over it.
260
+ # COS_LENS_GIST_ENGINE=claude
261
+ # COS_LENS_GIST_MODEL=sonnet
262
+ #
263
+ # Or a chain, asked in order when one fails, cools down or reports a usage limit.
264
+ # Each engine in it sees the reply. COS_LENS_GIST_MODEL does not apply to a chain.
265
+ # COS_LENS_GIST_ENGINE=claude:sonnet,codex:gpt-5.6-terra
266
+ #
267
+ # Answers per local day (attempts stop at twice this). Default 150.
268
+ # COS_LENS_GIST_DAILY_CAP=150
269
+
255
270
 
256
271
  # ── RETAINED MEETING-AUDIO PLAYBACK CLEANUP (6.21.32 canary) ────────────
257
272
  # Default OFF. When enabled, the first authenticated Play of a retained raw
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 6.54.0
2
+
3
+ ### The summary behind the G2 prompt box, from the engine you choose
4
+
5
+ COS Glasses 6.9.545 floats your words over a dimmed card of the session or message you are replying to. The server now writes that card: `Outcome` (`Answer` for a message), `So what` and `You asked`, from a reply the phone already holds. The engine is swappable the way the COS indexer's is: Claude, Codex, Cursor or a local Ollama model.
6
+
7
+ - **`POST /api/lens-gist`** `{kind, ask, reply}` answers `ready` with the three lines, or `off`, `unavailable` (`cap`, `breaker`, `busy`, `retry-later`, `maintenance`) or `failed`, always as a 200 with a status; only a malformed request is a 400. **`GET /api/lens-gist/config`** lists the engines, whether each is installed, and the models it can run; **`PUT`** saves `{engine, model, dailyCap}` to `<data>/lens-gist.json`, which survives Update Server.
8
+ - **The engine:** `COS_LENS_GIST_ENGINE` and `COS_LENS_GIST_MODEL`, then the saved choice, then Claude Sonnet when the Claude CLI is installed (off otherwise). One engine never falls back to another; a mistyped one, or a saved file that cannot be read, is off.
9
+ - **A chain, for resilience:** `claude:sonnet,codex:gpt-5.6-terra` (env, or `PUT {"chain": ...}`) asks the next engine when one fails, is cooling down, or reports a spent session or usage limit, which opens that engine's breaker at once. A limit is read only from the provider's own error (the same pattern as the provider proof, including Claude Code's "You've hit your session limit", which it prints on stdout), never from the words of an answer. The chain shares one 70 s budget, and no engine starts on its last 3 s. A refusal from every engine reads as `breaker` only when every engine's breaker is open, so one engine's hiccup never pauses the phone. Opt-in only: a chain sends the reply to every provider in it; `PUT` takes either `engine` or `chain`, and a saved `off` wins. An answer any engine wrote is reused for the whole chain. Tested live: with Claude failing fast (a model it does not have), Codex Terra answered in 4.9 s end to end.
10
+ - **Measured with the shipped prompt** on real replies: Claude Sonnet 2 to 5 s on about 1.2k tokens; Codex `gpt-5.6-terra` (the Codex default, the peer of Sonnet) 3.2 to 3.7 s on about 11.4k, `gpt-6-luna` and `gpt-6-sol` about 3 s on about 12k (Codex's own harness); Cursor `grok-4.7-low-fast` and `composer-2.5-fast` 11 to 13 s on about 11k to 17k; Ollama `qwen3.8:27b` 14 s cold. Claude Haiku timed out at 30 s twice.
11
+ - **The reply is untrusted text.** Claude runs with no tools and every hook disabled; Codex with its shell, exec, image, agent, browser, computer, app and plugin tools disabled, web search off, no user config and no rules files; Cursor in ask mode (read-only tools) in an empty workspace; Ollama as a bare completion. Each call gets a private empty workspace, removed after it, and no server token in its environment. Text inside the exchange cannot close its own tag. Keys and tokens (OpenAI, GitHub, Slack, AWS, HubSpot, JWT, private keys and the server's own token) are redacted from cards, logs and the ledger.
12
+ - **How it stops:** answers cached on disk per reply, engine and model; a daily cap of 150 answers (`COS_LENS_GIST_DAILY_CAP` or the saved `dailyCap`, kept when a save does not mention it) and twice that in attempts; a failed reply refused for 10 minutes, then 6 hours (a spent provider limit does not count against it); a breaker per engine (3 failures in a row or one limit answer, 30 minutes, then one trial call); at most 2 running and 4 waiting, with the cap, breaker and drain checked again before each engine. The route sits outside the request lease, so a slow model never holds up Update Server, and it answers `maintenance` during a drain.
13
+ - **Visible:** a `g2-lens-gist` row in the token audit for every call, `<data>/lens-gist-runs.jsonl` (with `fallbackFrom` and `limit` when they apply), and `lens_gist` on `/api/health` (engine, model, calls today, cap, open breakers; no error text and no chain on the unauthenticated slice). Error text is redacted before it is cut, so a cut cannot split a token past the redaction.
14
+ - Stated limits: a session reply reaches the phone cut to its first 4,000 characters, so a long reply's ending is not summarized. Cursor's ask mode keeps read-only tools; the CLI has no switch to remove them. COS Control 0.5.239 drops a `COS_LENS_GIST_*` value set only in the plist; use `~/.cos-glasses/.env` or `PUT /api/lens-gist/config`.
15
+ - Checks: three /qa rounds (four reviewers; one per repo on the fixes; two on the chain before release). The limit reading is tested against real processes printing Claude Code's own failure shapes. 50 mutation cases, all killed from a green baseline. Full suite 5,214 passed, 2 skipped (343 files); typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
16
+
1
17
  ## 6.53.4
2
18
 
3
19
  ### The fixes from 6.53.3's review
package/README.md CHANGED
@@ -300,6 +300,60 @@ reads `missing` and the phone stops asking to save it. Saving a known meeting th
300
300
  transcript answers **404 `nothing_to_save`** (with its state); only an unknown id answers
301
301
  `session_not_found`.
302
302
 
303
+ ## The summary behind the prompt box (6.54.0)
304
+
305
+ When you hold the ring on a finished session or message, COS Glasses 6.9.545 floats your
306
+ live words over a dimmed card of what you are replying to. From 6.54.0 the server writes
307
+ that card: `Outcome` (`Answer` for a message), `So what` and `You asked`, one lens row
308
+ each, instead of the reply's first lines. Older phones and older servers keep the
309
+ 6.9.544 card.
310
+
311
+ **Pick the engine** the way the COS indexer does: `COS_LENS_GIST_ENGINE` wins, then the
312
+ choice saved with `PUT /api/lens-gist/config`, then the default. One engine never falls back
313
+ to another; `off` turns the summary off.
314
+
315
+ **Or chain them, for resilience.** `claude:sonnet,codex:gpt-5.6-terra` asks Claude first and
316
+ Codex when Claude fails, is cooling down, or says its session or usage limit is spent (a
317
+ limit, read from the provider's own error and never from an answer, skips that engine for 30
318
+ minutes at once). Running low on one plan no longer turns the summary off. A chain gets 70
319
+ seconds in all, shared in order. It is opted into, never the default: it sends a session's
320
+ reply to every provider in it, and an installed CLI is not consent (ChatGPT.app ships
321
+ `codex`). `PUT` takes either `engine` or `chain`, never both, and a saved `off` wins.
322
+
323
+ | Engine | Default model | Measured (2026-09-25, one call) | Tools it keeps |
324
+ |---|---|---|---|
325
+ | `claude` (default) | `sonnet` | 2 to 5 s, about 1.2k tokens | none (`--tools ""`, hooks off) |
326
+ | `codex` | `gpt-5.6-terra` (`gpt-6-luna`, `gpt-6-sol` work as well) | 3.2 to 3.7 s, about 11.4k tokens | shell, browser, image, agent, app and plugin tools off; web search off |
327
+ | `cursor` | `grok-4.7-low-fast` | 11 to 13 s, about 11k to 17k tokens | ask mode: read-only tools in an empty workspace |
328
+ | `ollama` | the local model lens queries use | 2.5 s warm, 14 s cold | none |
329
+
330
+ With nothing chosen and no Claude CLI installed, the summary is off rather than sent to a
331
+ provider nobody picked. Claude `haiku` timed out at 30 s in testing and is not a default.
332
+
333
+ ```bash
334
+ curl -s -X PUT -H "X-COS-Token: $COS_API_TOKEN" -H 'content-type: application/json' \
335
+ -d '{"chain":"claude:sonnet,codex:gpt-5.6-terra","dailyCap":150}' http://127.0.0.1:3141/api/lens-gist/config
336
+ curl -s -X PUT -H "X-COS-Token: $COS_API_TOKEN" -H 'content-type: application/json' \
337
+ -d '{"engine":"codex","model":"gpt-6-sol"}' http://127.0.0.1:3141/api/lens-gist/config
338
+ curl -s -H "X-COS-Token: $COS_API_TOKEN" http://127.0.0.1:3141/api/lens-gist/config
339
+ ```
340
+
341
+ `GET /api/lens-gist/config` lists every engine, whether it is installed, and the models it
342
+ can run here. The saved choice lives in `<data>/lens-gist.json` and survives Update
343
+ Server; a file that cannot be read turns the summary off until it is saved again.
344
+
345
+ **How it stops.** The phone asks once per finished reply it has open (a session that is
346
+ still running or waiting on an approval asks for nothing). Answers are cached on disk per
347
+ engine and model. A daily cap (`COS_LENS_GIST_DAILY_CAP` or the saved `dailyCap`, default
348
+ 150) counts answers, and attempts stop at twice that. A reply that failed is refused for 10
349
+ minutes, then 6 hours (a spent provider limit does not count against the reply). Three
350
+ failures in a row, or one limit answer, pause that engine for 30 minutes, then one trial call
351
+ runs. At most 2 calls run at once with 4 waiting. A drain for Update Server stops queued
352
+ calls before each engine. Every call writes a `g2-lens-gist` row to the token audit and a line
353
+ to `<data>/lens-gist-runs.jsonl` (with `fallbackFrom` when a later engine answered).
354
+ `/api/health` shows `lens_gist` (engine, model, calls today, cap, open breakers; no error text
355
+ and no chain); `GET /api/lens-gist/config` shows the chain.
356
+
303
357
  ## Configuration
304
358
 
305
359
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
@@ -34,7 +34,10 @@
34
34
  "COS_SESSION_HOOK_SSE",
35
35
  "COS_PERMISSION_BROKER",
36
36
  "COS_PERMISSION_BROKER_DESK_IDLE_S",
37
- "COS_PERMISSION_BROKER_TIMEOUT_S"
37
+ "COS_PERMISSION_BROKER_TIMEOUT_S",
38
+ "COS_LENS_GIST_ENGINE",
39
+ "COS_LENS_GIST_MODEL",
40
+ "COS_LENS_GIST_DAILY_CAP"
38
41
  ],
39
42
  "maintenance": {
40
43
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.53.4",
3
+ "version": "6.54.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -92,6 +92,7 @@ import { glossaryRouter } from './routes/glossary.js'
92
92
  import { handoffsRouter } from './routes/handoffs.js'
93
93
  import { recoveryRouter } from './routes/recovery.js'
94
94
  import { promptEditRouter } from './routes/prompt-edit.js'
95
+ import { lensGistRouter } from './routes/lens-gist.js'
95
96
  import { bookmarksRouter } from './routes/bookmarks.js'
96
97
  import { welcomeContextRouter } from './routes/welcome-context.js'
97
98
  import { liveCuesRouter } from './routes/live-cues.js'
@@ -311,6 +312,11 @@ app.use('/api', (req, res, next) => {
311
312
  || req.path.startsWith('/meeting-actions/')
312
313
  || req.path === '/meeting-engine/mode'
313
314
  || req.path.startsWith('/prompt-drafts')
315
+ // 6.54.0: a gist can wait behind two others and then run a chain for up to 70 s
316
+ // (LENS_GIST_CHAIN_BUDGET_MS), which could keep Control's 90 s drain waiting if it held
317
+ // the request lease. It refuses on its own while admissions are closed, and a queued
318
+ // chain checks again before each engine (routes/lens-gist.ts, lib/lens-gist.ts).
319
+ || req.path === '/lens-gist'
314
320
  || req.path.startsWith('/maintenance/drain')
315
321
  if (lifecycleOwned) return next()
316
322
 
@@ -889,6 +895,7 @@ app.use('/api', glossaryRouter)
889
895
  app.use('/api', handoffsRouter)
890
896
  app.use('/api', recoveryRouter)
891
897
  app.use('/api', promptEditRouter)
898
+ app.use('/api', lensGistRouter)
892
899
  app.use('/api', bookmarksRouter)
893
900
  app.use('/api', welcomeContextRouter)
894
901
  app.use('/api', liveCuesRouter)
@@ -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
+ }