@gotcos/glasses-server 6.53.4 → 6.55.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 +15 -0
- package/CHANGELOG.md +27 -0
- package/README.md +70 -0
- package/managed-runtime-contract.json +4 -1
- package/package.json +1 -1
- package/server/index.ts +7 -0
- package/server/lib/lens-gist-engines.ts +416 -0
- package/server/lib/lens-gist.ts +987 -0
- package/server/lib/provider-proof.ts +4 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/lens-gist.ts +58 -0
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,30 @@
|
|
|
1
|
+
## 6.55.0
|
|
2
|
+
|
|
3
|
+
### Longer lines behind the prompt box, and a card for a meeting
|
|
4
|
+
|
|
5
|
+
COS Glasses 6.9.546 docks the card behind the prompt box and gives its first two lines two rows each. The server now writes each line to the length the phone asks for, and writes a card for a meeting from the meeting's own notes. The older app is unaffected: 6.9.545 sends neither, and gets exactly what 6.54.0 gave it.
|
|
6
|
+
|
|
7
|
+
- **`chars` on `POST /api/lens-gist`:** `{"first": 100, "soWhat": 100, "third": 50}` asks for lines up to that many characters. Each must be a whole number and is held to its bounds (`first` and `soWhat` 20 to 150, `third` 20 to 80, all under the 160 characters a card line may hold); anything else (a string, an array, a fraction, a missing number) is a 400. `null` is the same as leaving it out.
|
|
8
|
+
- **No change for the older app.** Without `chars` the prompt is byte for byte what 6.54.0 sent (a test holds it to 6.54.0's text and passes against 6.54.0's own code), and the cache key is 6.54.0's, so cards already cached still answer. A budget is part of the key, so a long card never answers a phone that asked for a short one.
|
|
9
|
+
- **`kind: "meeting"`:** the phone sends a meeting's own notes (title, time, summary, decisions and action items) as `reply`, with no `ask`. The card is `Gist` (what the meeting settled or covered, returned as `outcome`), `So what` (what it means for you; empty when nothing is needed) and `Open` (one item still unresolved, returned as the new optional `open`; "nothing open", "none" and "no open items" leave it out). An `ask` sent with a meeting is dropped, the notes are data the model is told not to follow, and text in them cannot close their `<meeting>` tag. An `Open` line a model adds to a session or message card is dropped, so those cards are unchanged.
|
|
10
|
+
- Checks: 15 new tests (13 on the library, 2 on the route). 14 new mutation cases (the budget's bounds, `null` as absent, the budget reaching the prompt and the cache key, the 6.54.0 escape, and the meeting's prompt, ask, tag, `Gist` and `Open` lines), run with the 50 from 6.54.0: 64 of 64 killed from a green baseline. Full suite 5,229 passed, 2 skipped (343 files); typecheck clean; `npm audit --omit=dev` 0 vulnerabilities.
|
|
11
|
+
|
|
12
|
+
## 6.54.0
|
|
13
|
+
|
|
14
|
+
### The summary behind the G2 prompt box, from the engine you choose
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
- **`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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- 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`.
|
|
26
|
+
- 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.
|
|
27
|
+
|
|
1
28
|
## 6.53.4
|
|
2
29
|
|
|
3
30
|
### The fixes from 6.53.3's review
|
package/README.md
CHANGED
|
@@ -300,6 +300,76 @@ 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
|
+
**Longer lines, and meetings (6.55.0).** A phone with more room can say how long each line
|
|
312
|
+
may run, with `"chars": {"first": 100, "soWhat": 100, "third": 50}` in the request (COS
|
|
313
|
+
Glasses 6.9.546 asks for these, giving the first two lines two rows each). Each number must
|
|
314
|
+
be a whole number and is held to its bounds: 20 to 150 for the first line and `So what`, 20
|
|
315
|
+
to 80 for the third. Anything else is a 400. Without `chars`, or with `null`, the lines keep
|
|
316
|
+
their 6.54.0 lengths and the prompt is word for word what 6.54.0 sent, so 6.9.545 sees no
|
|
317
|
+
change. The budget is part of the cache key, so a card written long is never handed to a
|
|
318
|
+
phone that asked for a short one.
|
|
319
|
+
|
|
320
|
+
`"kind": "meeting"` writes a meeting's card from the meeting's own notes (title, time,
|
|
321
|
+
summary, decisions and action items), sent as `reply` with no `ask`: `Gist` (what the meeting
|
|
322
|
+
settled or covered, returned as `outcome`), `So what` (your next steps, left empty when
|
|
323
|
+
nothing is needed) and `Open` (one question still unresolved, returned as `open` and left out
|
|
324
|
+
when nothing is open). An `ask` sent with a meeting is ignored, and the notes are data the
|
|
325
|
+
model is told not to follow, the same as a reply.
|
|
326
|
+
|
|
327
|
+
**Pick the engine** the way the COS indexer does: `COS_LENS_GIST_ENGINE` wins, then the
|
|
328
|
+
choice saved with `PUT /api/lens-gist/config`, then the default. One engine never falls back
|
|
329
|
+
to another; `off` turns the summary off.
|
|
330
|
+
|
|
331
|
+
**Or chain them, for resilience.** `claude:sonnet,codex:gpt-5.6-terra` asks Claude first and
|
|
332
|
+
Codex when Claude fails, is cooling down, or says its session or usage limit is spent (a
|
|
333
|
+
limit, read from the provider's own error and never from an answer, skips that engine for 30
|
|
334
|
+
minutes at once). Running low on one plan no longer turns the summary off. A chain gets 70
|
|
335
|
+
seconds in all, shared in order. It is opted into, never the default: it sends a session's
|
|
336
|
+
reply to every provider in it, and an installed CLI is not consent (ChatGPT.app ships
|
|
337
|
+
`codex`). `PUT` takes either `engine` or `chain`, never both, and a saved `off` wins.
|
|
338
|
+
|
|
339
|
+
| Engine | Default model | Measured (2026-09-25, one call) | Tools it keeps |
|
|
340
|
+
|---|---|---|---|
|
|
341
|
+
| `claude` (default) | `sonnet` | 2 to 5 s, about 1.2k tokens | none (`--tools ""`, hooks off) |
|
|
342
|
+
| `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 |
|
|
343
|
+
| `cursor` | `grok-4.7-low-fast` | 11 to 13 s, about 11k to 17k tokens | ask mode: read-only tools in an empty workspace |
|
|
344
|
+
| `ollama` | the local model lens queries use | 2.5 s warm, 14 s cold | none |
|
|
345
|
+
|
|
346
|
+
With nothing chosen and no Claude CLI installed, the summary is off rather than sent to a
|
|
347
|
+
provider nobody picked. Claude `haiku` timed out at 30 s in testing and is not a default.
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
curl -s -X PUT -H "X-COS-Token: $COS_API_TOKEN" -H 'content-type: application/json' \
|
|
351
|
+
-d '{"chain":"claude:sonnet,codex:gpt-5.6-terra","dailyCap":150}' http://127.0.0.1:3141/api/lens-gist/config
|
|
352
|
+
curl -s -X PUT -H "X-COS-Token: $COS_API_TOKEN" -H 'content-type: application/json' \
|
|
353
|
+
-d '{"engine":"codex","model":"gpt-6-sol"}' http://127.0.0.1:3141/api/lens-gist/config
|
|
354
|
+
curl -s -H "X-COS-Token: $COS_API_TOKEN" http://127.0.0.1:3141/api/lens-gist/config
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`GET /api/lens-gist/config` lists every engine, whether it is installed, and the models it
|
|
358
|
+
can run here. The saved choice lives in `<data>/lens-gist.json` and survives Update
|
|
359
|
+
Server; a file that cannot be read turns the summary off until it is saved again.
|
|
360
|
+
|
|
361
|
+
**How it stops.** The phone asks once per finished reply it has open (a session that is
|
|
362
|
+
still running or waiting on an approval asks for nothing). Answers are cached on disk per
|
|
363
|
+
engine and model. A daily cap (`COS_LENS_GIST_DAILY_CAP` or the saved `dailyCap`, default
|
|
364
|
+
150) counts answers, and attempts stop at twice that. A reply that failed is refused for 10
|
|
365
|
+
minutes, then 6 hours (a spent provider limit does not count against the reply). Three
|
|
366
|
+
failures in a row, or one limit answer, pause that engine for 30 minutes, then one trial call
|
|
367
|
+
runs. At most 2 calls run at once with 4 waiting. A drain for Update Server stops queued
|
|
368
|
+
calls before each engine. Every call writes a `g2-lens-gist` row to the token audit and a line
|
|
369
|
+
to `<data>/lens-gist-runs.jsonl` (with `fallbackFrom` when a later engine answered).
|
|
370
|
+
`/api/health` shows `lens_gist` (engine, model, calls today, cap, open breakers; no error text
|
|
371
|
+
and no chain); `GET /api/lens-gist/config` shows the chain.
|
|
372
|
+
|
|
303
373
|
## Configuration
|
|
304
374
|
|
|
305
375
|
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.
|
|
3
|
+
"version": "6.55.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
|
+
}
|