@jhizzard/termdeck 1.6.1 → 1.8.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/package.json +1 -1
- package/packages/cli/src/doctor.js +100 -0
- package/packages/cli/src/init-mnestra.js +50 -6
- package/packages/cli/src/init-rumen.js +3 -3
- package/packages/client/public/app.js +341 -30
- package/packages/client/public/index.html +0 -1
- package/packages/client/public/style.css +2 -31
- package/packages/server/src/agent-adapters/agy.js +396 -0
- package/packages/server/src/agent-adapters/gemini.js +309 -42
- package/packages/server/src/agent-adapters/grok-models.js +112 -76
- package/packages/server/src/agent-adapters/index.js +19 -0
- package/packages/server/src/agent-adapters/web-chat-grok.js +259 -0
- package/packages/server/src/index.js +572 -10
- package/packages/server/src/setup/audit-upgrade.js +3 -3
- package/packages/server/src/setup/rumen/functions/graph-inference/index.ts +1 -1
- package/packages/stack-installer/assets/hooks/memory-session-end.js +73 -32
|
@@ -106,54 +106,108 @@ async function resolveTranscriptPath(session) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// ──────────────────────────────────────────────────────────────────────────
|
|
109
|
-
// parseTranscript — Gemini CLI session
|
|
109
|
+
// parseTranscript — Gemini CLI session transcript → normalized Memory[].
|
|
110
110
|
//
|
|
111
|
-
//
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
133
|
-
try { session = JSON.parse(raw); } catch (_) { return []; }
|
|
134
|
-
if (!session || !Array.isArray(session.messages)) return [];
|
|
187
|
+
const out = [];
|
|
135
188
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
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
|
|
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
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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
|
|
1
|
+
// Grok model selection — Sprint 45 api.x.ai lineup + Sprint 70 Grok Build, MERGED (additive).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
36
|
-
'fast-reasoning': 'grok-4-1-fast-reasoning',
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'reasoning-
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
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
|
-
//
|
|
46
|
-
//
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
case 'multi-agent':
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
case '
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
case '
|
|
76
|
-
|
|
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 —
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
};
|