@jhizzard/termdeck 1.6.1 → 1.7.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.
@@ -106,54 +106,108 @@ async function resolveTranscriptPath(session) {
106
106
  }
107
107
 
108
108
  // ──────────────────────────────────────────────────────────────────────────
109
- // parseTranscript — Gemini CLI session JSON format (NOT JSONL).
109
+ // parseTranscript — Gemini CLI session transcript normalized Memory[].
110
110
  //
111
- // Captured shape (from `gemini -p "say hi"` 2026-05-01):
112
- // {
113
- // sessionId, projectHash, startTime, lastUpdated, kind,
114
- // messages: [
115
- // { id, timestamp, type: 'user', content: [{ text: '...' }] },
116
- // { id, timestamp, type: 'gemini', content: '...', thoughts, tokens, model },
117
- // ...
118
- // ]
119
- // }
111
+ // TWO on-disk shapes, both handled (verified 2026-06-07 against real files in
112
+ // `~/.gemini/tmp/<proj>/chats/`):
120
113
  //
121
- // The user role carries a content ARRAY of `{text}` parts; the gemini
122
- // (assistant) role carries a STRING. We normalize both to the Claude
123
- // adapter's output shape — `{ role: 'user'|'assistant', content: string }`
124
- // truncated to 400 chars so the memory-hook summary builder doesn't have
125
- // to branch on adapter type.
114
+ // (A) LEGACY single-JSON object (`.json`, Gemini CLI ~2026-05-02)
115
+ // pretty-printed across many lines:
116
+ // { sessionId, projectHash, startTime, lastUpdated, kind,
117
+ // messages: [ { id, timestamp, type:'user'|'gemini', content }, ... ] }
126
118
  //
127
- // `type: 'gemini'` maps to `role: 'assistant'` for cross-adapter parity.
119
+ // (B) MODERN JSONL (`.jsonl`, Gemini CLI ~2026-05-08 — what ships today) —
120
+ // one JSON object per line, heterogeneous:
121
+ // line 0 → session header { sessionId, projectHash, ... } (no messages/type → skipped)
122
+ // { "$set": {...} } → incremental mutation deltas (no type → skipped)
123
+ // { id, timestamp, type:'user'|'gemini'|'info', content } → a message (extracted)
124
+ //
125
+ // In BOTH shapes a `type:'user'` message carries a content ARRAY of `{text}`
126
+ // parts and a `type:'gemini'` message carries a STRING. We normalize both to
127
+ // the Claude adapter's output shape — `{ role:'user'|'assistant', content }`
128
+ // truncated to 400 chars — so the memory-hook summary builder never branches
129
+ // on adapter type. `type:'gemini'` → `role:'assistant'`; any other type
130
+ // (info / system / tool) is skipped.
131
+ //
132
+ // Pre-Sprint-70 this did a single `JSON.parse(raw)` and `return []` on throw,
133
+ // so EVERY modern `.jsonl` session threw `Extra data: line 2` and captured
134
+ // NOTHING (silent data loss). Strategy now: try a whole-blob parse first — it
135
+ // succeeds only for shape (A) and any genuinely single-line input, keeping the
136
+ // Sprint-45 fixtures green — then fall back to line-by-line JSONL for shape
137
+ // (B), tolerating blank lines, a trailing newline, and a partial last line,
138
+ // and skipping any line that isn't a well-formed transcript turn.
139
+ //
140
+ // CROSS-FILE CONTRACT: the parser the LIVE capture path actually invokes is
141
+ // the hook-side mirror `parseGeminiJson` in `~/.claude/hooks/memory-session-
142
+ // end.js` (+ its bundled copy `packages/stack-installer/assets/hooks/memory-
143
+ // session-end.js`); the bundled comment there mandates "keep the two in sync."
144
+ // Those copies need the same whole-blob→JSONL fix to close the capture gap
145
+ // end-to-end — that file is Sprint-70 T3-owned (see STATUS.md T2 cross-lane
146
+ // FINDING). This adapter copy is the canonical reference they mirror.
128
147
  // ──────────────────────────────────────────────────────────────────────────
129
148
 
149
+ // Normalize one parsed Gemini message object into the cross-adapter
150
+ // `{ role, content }` shape and push it onto `out`. Non-message objects
151
+ // (the session header, `$set` deltas, info/system/tool roles, empty content)
152
+ // contribute nothing.
153
+ function pushGeminiMessage(msg, out) {
154
+ if (!msg || typeof msg !== 'object') return;
155
+ let role;
156
+ if (msg.type === 'user') role = 'user';
157
+ else if (msg.type === 'gemini' || msg.type === 'assistant') role = 'assistant';
158
+ else return; // header line, $set delta, info/system/tool — not a transcript turn
159
+
160
+ const content = msg.content;
161
+ let text = '';
162
+ if (typeof content === 'string') {
163
+ text = content;
164
+ } else if (Array.isArray(content)) {
165
+ text = content
166
+ .filter((c) => c && typeof c.text === 'string')
167
+ .map((c) => c.text)
168
+ .join(' ');
169
+ }
170
+ if (text) out.push({ role, content: text.slice(0, 400) });
171
+ }
172
+
173
+ // Collect messages from one parsed JSON node, whether it's a session wrapper
174
+ // (shape A — carries a `messages` array) or a single bare message (shape B —
175
+ // one JSONL line). A node that is neither contributes nothing.
176
+ function collectGeminiNode(node, out) {
177
+ if (!node || typeof node !== 'object') return;
178
+ if (Array.isArray(node.messages)) {
179
+ for (const msg of node.messages) pushGeminiMessage(msg, out);
180
+ } else {
181
+ pushGeminiMessage(node, out);
182
+ }
183
+ }
184
+
130
185
  function parseTranscript(raw) {
131
186
  if (typeof raw !== 'string' || raw.length === 0) return [];
132
- let session;
133
- try { session = JSON.parse(raw); } catch (_) { return []; }
134
- if (!session || !Array.isArray(session.messages)) return [];
187
+ const out = [];
135
188
 
136
- const messages = [];
137
- for (const msg of session.messages) {
138
- if (!msg || typeof msg !== 'object') continue;
139
- let role;
140
- if (msg.type === 'user') role = 'user';
141
- else if (msg.type === 'gemini' || msg.type === 'assistant') role = 'assistant';
142
- else continue;
189
+ // Shape (A): a single (possibly pretty-printed, multi-line) JSON object.
190
+ // Succeeds only when the WHOLE blob is valid JSON — the legacy `.json`
191
+ // format or a 1-line `.jsonl`. A multi-line `.jsonl` throws here
192
+ // ("Extra data: line 2") and falls through to the JSONL path below.
193
+ try {
194
+ collectGeminiNode(JSON.parse(raw), out);
195
+ if (out.length) return out;
196
+ } catch (_) { /* not a single JSON blob → try JSONL */ }
143
197
 
144
- const content = msg.content;
145
- let text = '';
146
- if (typeof content === 'string') {
147
- text = content;
148
- } else if (Array.isArray(content)) {
149
- text = content
150
- .filter((c) => c && typeof c.text === 'string')
151
- .map((c) => c.text)
152
- .join(' ');
153
- }
154
- if (text) messages.push({ role, content: text.slice(0, 400) });
198
+ // Shape (B): JSONL — one object per line. Tolerate blank lines, a trailing
199
+ // newline, and a partial/truncated final line (skip unparseable lines rather
200
+ // than aborting the whole transcript). Only reached when the whole-blob parse
201
+ // threw OR yielded zero messages (e.g. a header-only single object), so `out`
202
+ // is still empty here and there is no double-collection.
203
+ for (const line of raw.split(/\r?\n/)) {
204
+ const trimmed = line.trim();
205
+ if (!trimmed) continue;
206
+ let node;
207
+ try { node = JSON.parse(trimmed); } catch (_) { continue; }
208
+ collectGeminiNode(node, out);
155
209
  }
156
- return messages;
210
+ return out;
157
211
  }
158
212
 
159
213
  // ──────────────────────────────────────────────────────────────────────────
@@ -233,6 +287,202 @@ function buildMnestraBlock({ secrets } = {}) {
233
287
  };
234
288
  }
235
289
 
290
+ // ──────────────────────────────────────────────────────────────────────────
291
+ // Auth — API-key mode + doctor probe (Sprint 70 T2)
292
+ //
293
+ // WHY THIS EXISTS: Google ends the Gemini CLI's OAuth / subscription serving
294
+ // path on JUNE 18 2026. After that date the `gemini` binary authenticates
295
+ // ONLY via a billing-enabled API key, which requires BOTH:
296
+ // • `GEMINI_API_KEY` in the environment — TermDeck loads it from
297
+ // ~/.termdeck/secrets.env at server boot and merges it into the panel PTY
298
+ // env (see spawn.env note above); and
299
+ // • ~/.gemini/settings.json → `security.auth.selectedType: "gemini-api-key"`
300
+ // (the *mode* switch — a present key is ignored while the mode is still
301
+ // `oauth-personal`).
302
+ // Antigravity (`agy`) deliberately stays on OAuth, so the two coexist:
303
+ // agy = OAuth, gemini = API-key. A future operator must NOT have to reverse-
304
+ // engineer why Gemini panels went dark after 2026-06-18 — `checkAuth()` makes
305
+ // every failure mode loud and actionable.
306
+ //
307
+ // `checkAuth(opts)` returns a structured verdict; it never throws and never
308
+ // blocks by default:
309
+ // { ok, state, keyPresent, keySource, selectedType, detail, hint, live }
310
+ // state ∈
311
+ // 'valid' key present + selectedType === 'gemini-api-key'
312
+ // (+ live AUTHOK appended when opts.live confirmed it)
313
+ // 'missing-key' GEMINI_API_KEY absent from env AND secrets.env → the
314
+ // binary cannot authenticate at all post-2026-06-18
315
+ // 'wrong-mode' key present but selectedType !== 'gemini-api-key'
316
+ // (e.g. still 'oauth-personal' — works NOW, BREAKS 06-18)
317
+ // 'settings-missing' ~/.gemini/settings.json absent/unparseable → mode unknown
318
+ // 'unverified' static config is correct but the live probe couldn't
319
+ // confirm (offline / binary absent / timeout) — soft-OK
320
+ //
321
+ // The static checks (env + settings.json) are pure and always run. The LIVE
322
+ // probe — actually invoking `gemini` non-interactively to confirm the key is
323
+ // accepted, the "AUTHOK" model the prior session validated — is gated behind
324
+ // `opts.live` and routed through the monkey-patchable `_liveAuthProbe` seam so
325
+ // unit tests stay offline and a future `termdeck doctor` wiring never hangs on
326
+ // it. The seams (`_geminiApiKeyState` / `_readGeminiSettings` /
327
+ // `_liveAuthProbe`) are attached to the adapter object below for the same
328
+ // stub-ability the stack doctor uses (cli/src/doctor.js `_fetchLatest`).
329
+ // ──────────────────────────────────────────────────────────────────────────
330
+
331
+ // GEMINI_API_KEY presence — env first, then the canonical ~/.termdeck/
332
+ // secrets.env store (the server merges that file into the PTY env at boot, but
333
+ // a standalone probe may run before that merge). PRESENCE ONLY — the key value
334
+ // is never read into a variable, returned, or logged.
335
+ function _geminiApiKeyState({ env, secretsPath } = {}) {
336
+ const e = env || process.env;
337
+ if (e && typeof e.GEMINI_API_KEY === 'string' && e.GEMINI_API_KEY.trim()) {
338
+ return { present: true, source: 'env' };
339
+ }
340
+ const fs = require('fs');
341
+ const os = require('os');
342
+ const path = require('path');
343
+ const p = secretsPath || path.join(os.homedir(), '.termdeck', 'secrets.env');
344
+ try {
345
+ const txt = fs.readFileSync(p, 'utf8');
346
+ // Match a non-empty assignment without ever capturing the value.
347
+ if (/^\s*(?:export\s+)?GEMINI_API_KEY=\s*\S/m.test(txt)) {
348
+ return { present: true, source: 'secrets.env' };
349
+ }
350
+ } catch (_) { /* no secrets.env / unreadable */ }
351
+ return { present: false, source: null };
352
+ }
353
+
354
+ // Read ~/.gemini/settings.json and return { selectedType } (or null when the
355
+ // file is absent or unparseable — the caller maps null to 'settings-missing').
356
+ function _readGeminiSettings({ settingsPath } = {}) {
357
+ const fs = require('fs');
358
+ const os = require('os');
359
+ const path = require('path');
360
+ const p = settingsPath || path.join(os.homedir(), '.gemini', 'settings.json');
361
+ let txt;
362
+ try { txt = fs.readFileSync(p, 'utf8'); } catch (_) { return null; }
363
+ try {
364
+ const j = JSON.parse(txt);
365
+ const sel = j && j.security && j.security.auth && j.security.auth.selectedType;
366
+ return { selectedType: typeof sel === 'string' ? sel : null };
367
+ } catch (_) { return null; }
368
+ }
369
+
370
+ // Live auth probe — invoke `gemini` non-interactively and resolve
371
+ // { ran:true, ok:boolean, note:string }
372
+ // Success = exit 0 with non-empty stdout (the binary only emits a response once
373
+ // the key is accepted); the AUTHOK token, when echoed, is surfaced in `note`.
374
+ // Any spawn error / timeout / non-zero exit resolves ok:false — the caller
375
+ // keeps the static verdict and downgrades 'valid' → 'unverified' (never RED) to
376
+ // avoid false negatives on offline / rate-limited runs. Replaceable for tests.
377
+ function _liveAuthProbe({ timeoutMs = 8000 } = {}) {
378
+ const { spawn } = require('child_process');
379
+ return new Promise((resolve) => {
380
+ let child;
381
+ try {
382
+ child = spawn('gemini', ['-p', 'Reply with exactly: AUTHOK'], {
383
+ stdio: ['ignore', 'pipe', 'pipe'],
384
+ });
385
+ } catch (e) {
386
+ return resolve({ ran: true, ok: false, note: `spawn failed: ${e && e.message || e}` });
387
+ }
388
+ let out = '';
389
+ let timedOut = false;
390
+ const t = setTimeout(() => {
391
+ timedOut = true;
392
+ try { child.kill('SIGKILL'); } catch (_) { /* already gone */ }
393
+ }, timeoutMs);
394
+ child.stdout.on('data', (b) => { out += b.toString('utf8'); });
395
+ child.stderr.on('data', () => { /* auth errors land here; intentionally not logged */ });
396
+ child.on('error', (e) => {
397
+ clearTimeout(t);
398
+ resolve({ ran: true, ok: false, note: `error: ${e && e.message || e}` });
399
+ });
400
+ child.on('close', (code) => {
401
+ clearTimeout(t);
402
+ if (timedOut) return resolve({ ran: true, ok: false, note: `timed out after ${timeoutMs}ms` });
403
+ const responded = code === 0 && out.trim().length > 0;
404
+ const sawToken = /AUTHOK/i.test(out);
405
+ resolve({
406
+ ran: true,
407
+ ok: responded,
408
+ note: responded
409
+ ? (sawToken ? 'AUTHOK' : 'gemini responded (exit 0)')
410
+ : `gemini exited ${code} without a response`,
411
+ });
412
+ });
413
+ });
414
+ }
415
+
416
+ // See the WHY / contract block above. Async because the optional live probe
417
+ // awaits a spawn; the static-only path (default) resolves immediately. Seams
418
+ // are dereferenced via `geminiAdapter.*` so tests can monkey-patch them.
419
+ async function checkAuth(opts = {}) {
420
+ const options = opts || {};
421
+ const keyState = geminiAdapter._geminiApiKeyState(options);
422
+ const settings = geminiAdapter._readGeminiSettings(options);
423
+ const selectedType = settings ? settings.selectedType : null;
424
+
425
+ let state;
426
+ let ok;
427
+ let detail;
428
+ let hint;
429
+ if (!keyState.present) {
430
+ state = 'missing-key';
431
+ ok = false;
432
+ detail = 'GEMINI_API_KEY is not set (checked process env + ~/.termdeck/secrets.env).';
433
+ hint = 'Add GEMINI_API_KEY=<billing-enabled key> to ~/.termdeck/secrets.env (mode 600). '
434
+ + 'After 2026-06-18 the Gemini CLI authenticates ONLY via an API key.';
435
+ } else if (settings === null) {
436
+ state = 'settings-missing';
437
+ ok = false;
438
+ detail = 'GEMINI_API_KEY is present, but ~/.gemini/settings.json is missing or '
439
+ + 'unparseable — cannot confirm the auth mode.';
440
+ hint = 'Create ~/.gemini/settings.json with '
441
+ + '{"security":{"auth":{"selectedType":"gemini-api-key"}}}.';
442
+ } else if (selectedType !== 'gemini-api-key') {
443
+ state = 'wrong-mode';
444
+ ok = false;
445
+ detail = `GEMINI_API_KEY is present, but settings.json security.auth.selectedType is `
446
+ + `${selectedType ? `"${selectedType}"` : 'unset'} — not "gemini-api-key", so the key `
447
+ + `is ignored. This still works until 2026-06-18, then breaks.`;
448
+ hint = 'Set ~/.gemini/settings.json security.auth.selectedType to "gemini-api-key" '
449
+ + '(Antigravity `agy` keeps OAuth separately).';
450
+ } else {
451
+ state = 'valid';
452
+ ok = true;
453
+ detail = `GEMINI_API_KEY present (${keyState.source}) and settings.json `
454
+ + `selectedType="gemini-api-key".`;
455
+ hint = '';
456
+ }
457
+
458
+ // Optional live confirmation — only when static config is already valid AND
459
+ // the caller asked for it. A live miss is a soft downgrade, never a RED.
460
+ let live = { ran: false, ok: false, note: 'not run (static check only)' };
461
+ if (state === 'valid' && options.live) {
462
+ live = await geminiAdapter._liveAuthProbe(options);
463
+ if (live.ok) {
464
+ detail += ` Live probe confirmed (${live.note}).`;
465
+ } else {
466
+ state = 'unverified';
467
+ ok = true; // config is correct; the probe just couldn't confirm
468
+ detail += ` Live probe could not confirm (${live.note}); static config looks correct.`;
469
+ hint = 'If Gemini panels fail, check the key is billing-enabled and not rate-limited, '
470
+ + 'and that `gemini` is on PATH.';
471
+ }
472
+ }
473
+
474
+ return {
475
+ ok,
476
+ state,
477
+ keyPresent: keyState.present,
478
+ keySource: keyState.source,
479
+ selectedType,
480
+ detail,
481
+ hint,
482
+ live,
483
+ };
484
+ }
485
+
236
486
  const geminiAdapter = {
237
487
  name: 'gemini',
238
488
  sessionType: 'gemini',
@@ -242,10 +492,17 @@ const geminiAdapter = {
242
492
  spawn: {
243
493
  binary: 'gemini',
244
494
  defaultArgs: [],
245
- // GEMINI_API_KEY is read via `process.env` at spawn time by index.js'
246
- // PTY env merge declared here for documentation / discoverability,
247
- // not for in-adapter overriding. OAuth-personal is the typical auth
248
- // path (settings.json `security.auth.selectedType: 'oauth-personal'`).
495
+ // AUTH (Sprint 70 T2): the Gemini CLI now requires API-KEY auth — Google
496
+ // ends the OAuth / subscription serving path on 2026-06-18. `GEMINI_API_KEY`
497
+ // is read via `process.env` at spawn time by index.js' PTY env merge
498
+ // (loaded from ~/.termdeck/secrets.env at server boot) — declared here for
499
+ // documentation / discoverability, not for in-adapter overriding — AND
500
+ // ~/.gemini/settings.json must set `security.auth.selectedType:
501
+ // 'gemini-api-key'` (the mode switch; a present key is ignored while the
502
+ // mode is still 'oauth-personal'). Antigravity (`agy`) stays on OAuth — the
503
+ // two are deliberately segregated. `checkAuth()` below makes a misconfig
504
+ // loud. (Pre-2026-06-18 the typical path was 'oauth-personal'; it stops
505
+ // working after the cutoff.)
249
506
  env: {},
250
507
  // Sprint 64 T2 (carve-out 2.4) — direct spawn (no `zsh -c` wrapper) when
251
508
  // the launching command is exactly the binary name. See claude.js for the
@@ -267,6 +524,9 @@ const geminiAdapter = {
267
524
  // Sprint 50 T1 — 10th adapter field. Walks ~/.gemini/tmp/<proj>/chats.
268
525
  resolveTranscriptPath,
269
526
  bootPromptTemplate,
527
+ // Sprint 70 T2 — API-key auth doctor probe. See the Auth section above for
528
+ // states + the live-probe seam. async (raw, opts) -> verdict object.
529
+ checkAuth,
270
530
  costBand: 'pay-per-token',
271
531
  // Sprint 47 T3 — Gemini's CLI is paste-friendly per the single-JSON-object
272
532
  // session shape captured in Sprint 45 T2; bracketed-paste injects cleanly.
@@ -280,4 +540,11 @@ const geminiAdapter = {
280
540
  },
281
541
  };
282
542
 
543
+ // Sprint 70 T2 — monkey-patchable test seams for `checkAuth` (same pattern as
544
+ // cli/src/doctor.js `_fetchLatest`). Attached to the adapter object so unit
545
+ // tests can stub the live spawn / filesystem reads and stay hermetic.
546
+ geminiAdapter._geminiApiKeyState = _geminiApiKeyState;
547
+ geminiAdapter._readGeminiSettings = _readGeminiSettings;
548
+ geminiAdapter._liveAuthProbe = _liveAuthProbe;
549
+
283
550
  module.exports = geminiAdapter;
@@ -1,49 +1,61 @@
1
- // Grok model selection — Sprint 45 T3
1
+ // Grok model selection — Sprint 45 api.x.ai lineup + Sprint 70 Grok Build, MERGED (additive).
2
2
  //
3
- // `grok-dev` (the superagent-ai CLI) ships an 11-model lineup spanning
4
- // $0.2/$0.5 cheap-fast tiers up to $3/$15 flagship. The wrong default
5
- // silently 10x's a bill on routine tasks: a "look at this file and tell me
6
- // what's wrong" lane on `grok-4.20-0309-reasoning` (Heavy, $2/$6) costs the
7
- // same as ten lanes on `grok-4-1-fast-non-reasoning`. The orchestrator picks
8
- // per-lane via `chooseModel(taskHint)` at boot-prompt construction time
9
- // (see SPRINT-45-PREP-NOTES.md § "Concern 2: Model selection heuristic").
10
- // The adapter's `spawn.env.GROK_MODEL` defaults to the cheap-fast model and
11
- // is overridden per-lane by the launcher.
3
+ // TWO Grok families are BOTH retained on purpose (per Joshua's directive: do NOT
4
+ // drop the reasoning models keep the legacy lineup, add Grok Build as an option):
12
5
  //
13
- // Tier table (price = USD per 1M tokens, in/out):
6
+ // A) api.x.ai models — the Sprint-45 lineup INCLUDING the reasoning tiers.
7
+ // Auth: GROK_API_KEY / XAI_API_KEY (per-token billing). These ACCEPT a
8
+ // `reasoningEffort` knob. Reachable via the raw xAI REST API or a CLI that
9
+ // honors GROK_MODEL + GROK_API_KEY (the older `grok-dev`).
10
+ // B) Grok Build models — `grok-build` (coding) + `grok-composer-2.5-fast`.
11
+ // Auth: grok.com login (subscription). These REJECT `reasoningEffort`
12
+ // (grok-build → HTTP 400). The current `grok` binary (Grok Build 0.2.33)
13
+ // exposes ONLY these two.
14
14
  //
15
- // tier | model id | price | use case
16
- // ───────────────────┼───────────────────────────────────┼──────────┼──────────────────────
17
- // fast-non-reasoning | grok-4-1-fast-non-reasoning | $0.2/0.5 | DEFAULT — routine
18
- // fast-reasoning | grok-4-1-fast-reasoning | $0.2/0.5 | light CoT under budget
19
- // code | grok-code-fast-1 | $0.2/1.5 | code gen / refactor
20
- // reasoning-deep | grok-4.20-0309-reasoning | $2/6 | hard problems, audit
21
- // reasoning-non-cot | grok-4.20-0309-non-reasoning | $2/6 | high-quality non-CoT
22
- // multi-agent | grok-4.20-multi-agent-0309 | $2/6 | parallel sub-agent fan-out
23
- // flagship | grok-4-0709 | $3/15 | when Heavy isn't enough
24
- // budget-compact | grok-3-mini | $0.3/0.5 | rare — usually wrong
15
+ // ─── REACHABILITY CAVEAT (read before assuming a model just "works") ──────────
16
+ // The adapter (grok.js) currently spawns the `grok` binary, which on this machine
17
+ // is Grok Build — so out of the box only family (B) actually runs. To EXECUTE a
18
+ // family-(A) reasoning model as a lane, the adapter must dispatch it to the
19
+ // api.x.ai path / `grok-dev` instead of the Grok Build CLI. That family-dispatch
20
+ // is a follow-up; this module restores the model OPTIONS, not the wiring that
21
+ // routes each family to the right runtime.
25
22
  //
26
- // `grok-4-fast-non-reasoning`, `grok-4-fast-reasoning`, and `grok-3` are
27
- // legacy aliases retained for completeness but not in the heuristic switch.
23
+ // AUTH do-nots: don't pipe GROK_API_KEY into a Grok Build spawn (it ignores it —
24
+ // log into grok.com); conversely the reasoning models need GROK_API_KEY and an
25
+ // api.x.ai-targeting caller — the Grok Build CLI will not run them.
26
+ //
27
+ // NOTE: the family-(A) ids are the Sprint-45 lineup (~2 months old). xAI rotates
28
+ // model ids; validate against the current api.x.ai model list before relying on a
29
+ // specific reasoning id.
28
30
 
29
31
  'use strict';
30
32
 
31
- // Canonical model ids. Use the symbolic key in code; the heuristic resolves
32
- // to the live id below. Keep these as data, not constants Sprint 46+ may
33
- // gain a `taskHint -> model` override file in `~/.termdeck/`.
33
+ // Canonical model ids, keyed by a short symbolic name. Use the key in code; the
34
+ // live id is the value. Kept as data so a future ~/.termdeck/ override file can
35
+ // extend it without touching call sites.
34
36
  const MODELS = {
35
- 'fast-non-reasoning': 'grok-4-1-fast-non-reasoning',
36
- 'fast-reasoning': 'grok-4-1-fast-reasoning',
37
- 'code': 'grok-code-fast-1',
38
- 'reasoning-deep': 'grok-4.20-0309-reasoning',
39
- 'reasoning-non-cot': 'grok-4.20-0309-non-reasoning',
40
- 'multi-agent': 'grok-4.20-multi-agent-0309',
41
- 'flagship': 'grok-4-0709',
42
- 'budget-compact': 'grok-3-mini',
37
+ // ── A) api.x.ai tiers (per-token billing; reasoningEffort-capable) ──
38
+ 'fast-non-reasoning': 'grok-4-1-fast-non-reasoning', // DEFAULT — routine
39
+ 'fast-reasoning': 'grok-4-1-fast-reasoning', // light CoT under budget
40
+ 'code': 'grok-code-fast-1', // code gen / refactor
41
+ 'reasoning-deep': 'grok-4.20-0309-reasoning', // hard problems, audit
42
+ 'reasoning-non-cot': 'grok-4.20-0309-non-reasoning', // high-quality non-CoT
43
+ 'multi-agent': 'grok-4.20-multi-agent-0309', // parallel sub-agent fan-out
44
+ 'flagship': 'grok-4-0709', // when Heavy isn't enough
45
+ 'budget-compact': 'grok-3-mini', // rare — usually wrong
46
+ // ── B) Grok Build (grok.com subscription; reasoningEffort REJECTED → 400) ──
47
+ 'build': 'grok-build', // Grok Build coding model
48
+ 'composer-fast': 'grok-composer-2.5-fast', // fast / lightweight compose
43
49
  };
44
50
 
45
- // Legacy aliases accepted as input to chooseModel for back-compat with
46
- // Joshua's earlier `grok models` outputs. Resolution table:
51
+ // Default stays the cheap-fast api.x.ai model (the Sprint-45 default) legacy
52
+ // remains the base; Grok Build is opt-in via a `build`/`composer` hint or an
53
+ // explicit GROK_MODEL. (If you'd rather default to grok-build now that the
54
+ // installed binary is Grok Build, flip this one line — flagged for Joshua.)
55
+ const DEFAULT_MODEL = MODELS['fast-non-reasoning'];
56
+
57
+ // Legacy aliases accepted as chooseModel input for back-compat with earlier
58
+ // `grok models` outputs.
47
59
  const LEGACY_ALIASES = {
48
60
  'grok-4-fast-non-reasoning': MODELS['fast-non-reasoning'],
49
61
  'grok-4-fast-reasoning': MODELS['fast-reasoning'],
@@ -52,64 +64,88 @@ const LEGACY_ALIASES = {
52
64
  'grok-3': MODELS['flagship'],
53
65
  };
54
66
 
55
- // chooseModel orchestrator-side heuristic. Pass `taskHint` from the lane
56
- // brief (Sprint 46 frontmatter `model-hint: code|reasoning-deep|...`) or omit
57
- // for the cheap-fast default. Unknown hints fall back to the default rather
58
- // than throwing the bill consequence of a typo silently routing to Heavy
59
- // is worse than the latency hit of cheap-fast on a hard task.
67
+ // The Grok Build family rejects a reasoning-effort knob (grok-build HTTP 400).
68
+ // Every api.x.ai model accepts it. Unknown ids default to "accepts" (legacy-
69
+ // permissive) — only the explicitly-known Grok Build models are stripped.
70
+ const NO_REASONING_EFFORT = new Set([MODELS['build'], MODELS['composer-fast']]);
71
+
72
+ // chooseModel — resolve a coarse task hint to a model id. Defaults to the
73
+ // cheap-fast api.x.ai model for anything unrecognized (incl. no/empty/null hint).
74
+ // Signature-compatible with the Sprint-45 chooseModel() grok.js calls no-arg.
60
75
  function chooseModel(taskHint) {
61
76
  switch (taskHint) {
62
- case 'code':
63
- return MODELS.code;
64
- case 'multi-agent':
65
- return MODELS['multi-agent'];
66
- case 'reasoning-deep':
67
- return MODELS['reasoning-deep'];
77
+ // family A — api.x.ai
78
+ case 'code': return MODELS.code;
79
+ case 'multi-agent': return MODELS['multi-agent'];
80
+ case 'reasoning-deep': return MODELS['reasoning-deep'];
68
81
  case 'reasoning-quick':
69
- case 'fast-reasoning':
70
- return MODELS['fast-reasoning'];
71
- case 'reasoning-non-cot':
72
- return MODELS['reasoning-non-cot'];
73
- case 'flagship':
74
- return MODELS.flagship;
75
- case 'budget-compact':
76
- return MODELS['budget-compact'];
82
+ case 'fast-reasoning': return MODELS['fast-reasoning'];
83
+ case 'reasoning-non-cot': return MODELS['reasoning-non-cot'];
84
+ case 'flagship': return MODELS.flagship;
85
+ case 'budget-compact': return MODELS['budget-compact'];
86
+ // family B — Grok Build (opt-in)
87
+ case 'build':
88
+ case 'grok-build': return MODELS['build'];
89
+ case 'composer':
90
+ case 'composer-fast':
91
+ case 'compose':
92
+ case 'fast':
93
+ case 'grok-composer-2.5-fast': return MODELS['composer-fast'];
94
+ // default
77
95
  case 'fast-non-reasoning':
78
96
  case undefined:
79
97
  case null:
80
- case '':
81
- return MODELS['fast-non-reasoning'];
98
+ case '': return MODELS['fast-non-reasoning'];
82
99
  default:
83
- // Accept legacy aliases verbatim; otherwise fall back to cheap-fast.
84
100
  if (LEGACY_ALIASES[taskHint]) return LEGACY_ALIASES[taskHint];
85
101
  return MODELS['fast-non-reasoning'];
86
102
  }
87
103
  }
88
104
 
89
- // getModelInfo — for the launcher / dashboard cost annotations (Sprint 46).
90
- // Returns the price band so the UI can render a $-tier indicator alongside
91
- // the model name without each caller knowing the table.
105
+ // getModelInfo — capability + cost lookup for callers/dashboards. Returns
106
+ // { tier, priceIn, priceOut, reasoningEffort, role, known }. Grok Build models
107
+ // are subscription-billed (priceIn/priceOut null). Unknown ids return a safe
108
+ // record. (Back-compatible with the Sprint-45 shape: tier/priceIn/priceOut are
109
+ // still present for any existing cost-annotation caller.)
92
110
  function getModelInfo(modelId) {
93
- const cheap = new Set([
94
- MODELS['fast-non-reasoning'],
95
- MODELS['fast-reasoning'],
96
- MODELS.code,
97
- ]);
98
- const heavy = new Set([
99
- MODELS['reasoning-deep'],
100
- MODELS['reasoning-non-cot'],
101
- MODELS['multi-agent'],
102
- ]);
103
- if (cheap.has(modelId)) return { tier: 'cheap', priceIn: 0.2, priceOut: modelId === MODELS.code ? 1.5 : 0.5 };
104
- if (heavy.has(modelId)) return { tier: 'heavy', priceIn: 2, priceOut: 6 };
105
- if (modelId === MODELS.flagship) return { tier: 'flagship', priceIn: 3, priceOut: 15 };
106
- if (modelId === MODELS['budget-compact']) return { tier: 'budget', priceIn: 0.3, priceOut: 0.5 };
107
- return { tier: 'unknown', priceIn: null, priceOut: null };
111
+ const reasoningEffort = !NO_REASONING_EFFORT.has(modelId);
112
+ const cheap = new Set([MODELS['fast-non-reasoning'], MODELS['fast-reasoning'], MODELS.code]);
113
+ const heavy = new Set([MODELS['reasoning-deep'], MODELS['reasoning-non-cot'], MODELS['multi-agent']]);
114
+ if (cheap.has(modelId)) return { tier: 'cheap', priceIn: 0.2, priceOut: modelId === MODELS.code ? 1.5 : 0.5, reasoningEffort, role: 'api.x.ai cheap-fast', known: true };
115
+ if (heavy.has(modelId)) return { tier: 'heavy', priceIn: 2, priceOut: 6, reasoningEffort, role: 'api.x.ai reasoning/heavy', known: true };
116
+ if (modelId === MODELS.flagship) return { tier: 'flagship', priceIn: 3, priceOut: 15, reasoningEffort, role: 'api.x.ai flagship', known: true };
117
+ if (modelId === MODELS['budget-compact']) return { tier: 'budget', priceIn: 0.3, priceOut: 0.5, reasoningEffort, role: 'api.x.ai budget', known: true };
118
+ if (modelId === MODELS['build']) return { tier: 'subscription', priceIn: null, priceOut: null, reasoningEffort: false, role: 'Grok Build coding', known: true };
119
+ if (modelId === MODELS['composer-fast']) return { tier: 'subscription', priceIn: null, priceOut: null, reasoningEffort: false, role: 'Grok Build fast-compose', known: true };
120
+ return { tier: 'unknown', priceIn: null, priceOut: null, reasoningEffort, role: 'unknown', known: false };
121
+ }
122
+
123
+ // acceptsReasoningEffort true only if the model supports a reasoning-effort
124
+ // knob. Use it to GUARD request construction so a Grok Build model never gets a
125
+ // reasoningEffort field (grok-build 400).
126
+ function acceptsReasoningEffort(modelId) {
127
+ return getModelInfo(modelId).reasoningEffort === true;
128
+ }
129
+
130
+ // sanitizeModelOptions — strips reasoning-effort fields (both spellings) when the
131
+ // target model rejects them (the Grok Build family), so a caller that blindly
132
+ // forwards options can't trigger the grok-build 400. Shallow copy; never mutates.
133
+ function sanitizeModelOptions(modelId, options) {
134
+ const opts = { ...(options || {}) };
135
+ if (!acceptsReasoningEffort(modelId)) {
136
+ delete opts.reasoningEffort;
137
+ delete opts.reasoning_effort;
138
+ }
139
+ return opts;
108
140
  }
109
141
 
110
142
  module.exports = {
111
143
  MODELS,
144
+ DEFAULT_MODEL,
112
145
  LEGACY_ALIASES,
146
+ NO_REASONING_EFFORT,
113
147
  chooseModel,
114
148
  getModelInfo,
149
+ acceptsReasoningEffort,
150
+ sanitizeModelOptions,
115
151
  };