@in-the-loop-labs/pair-review 3.9.0 → 4.0.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/README.md +10 -10
- package/package.json +5 -9
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +12 -60
- package/public/js/components/AdvancedConfigTab.js +1 -1
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/CouncilProgressModal.js +0 -10
- package/public/js/local.js +1 -50
- package/public/js/pr.js +3 -117
- package/public/js/utils/provider-model.js +1 -1
- package/public/local.html +0 -7
- package/public/pr.html +0 -7
- package/src/ai/analyzer.js +4 -4
- package/src/ai/antigravity-provider.js +594 -0
- package/src/ai/claude-provider.js +29 -7
- package/src/ai/index.js +1 -1
- package/src/ai/opencode-provider.js +1 -1
- package/src/ai/provider.js +5 -5
- package/src/ai/stream-parser.js +1 -52
- package/src/chat/acp-bridge.js +1 -1
- package/src/chat/chat-providers.js +1 -9
- package/src/config.js +3 -3
- package/src/database.js +1 -1
- package/src/main.js +6 -5
- package/src/routes/config.js +2 -2
- package/src/ai/gemini-provider.js +0 -752
|
@@ -1,752 +0,0 @@
|
|
|
1
|
-
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
/**
|
|
3
|
-
* Gemini AI Provider
|
|
4
|
-
*
|
|
5
|
-
* Implements the AI provider interface for Google's Gemini CLI.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const { spawn } = require('child_process');
|
|
10
|
-
const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
|
|
11
|
-
const logger = require('../utils/logger');
|
|
12
|
-
const { extractJSON } = require('../utils/json-extractor');
|
|
13
|
-
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
14
|
-
const { StreamParser, parseGeminiLine } = require('./stream-parser');
|
|
15
|
-
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
16
|
-
|
|
17
|
-
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
18
|
-
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Gemini model definitions with tier mappings
|
|
22
|
-
*/
|
|
23
|
-
const GEMINI_MODELS = [
|
|
24
|
-
{
|
|
25
|
-
id: 'gemini-3.1-flash-lite-preview',
|
|
26
|
-
aliases: ['gemini-3.1-flash-lite'],
|
|
27
|
-
name: '3.1 Flash Lite',
|
|
28
|
-
tier: 'fast',
|
|
29
|
-
tagline: 'Cheapest',
|
|
30
|
-
description: 'Ultra-efficient model for high-volume cost-conscious scans',
|
|
31
|
-
badge: 'Cheapest',
|
|
32
|
-
badgeClass: 'badge-speed'
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
id: 'gemini-3-flash-preview',
|
|
36
|
-
aliases: ['gemini-3-flash'],
|
|
37
|
-
name: '3.0 Flash',
|
|
38
|
-
tier: 'fast',
|
|
39
|
-
tagline: 'Rapid Sanity Check',
|
|
40
|
-
description: 'Fast and capable at a fraction of the cost of larger models',
|
|
41
|
-
badge: 'Quick Look',
|
|
42
|
-
badgeClass: 'badge-speed'
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
id: 'gemini-2.5-pro',
|
|
46
|
-
name: '2.5 Pro',
|
|
47
|
-
tier: 'balanced',
|
|
48
|
-
tagline: 'Standard PR Review',
|
|
49
|
-
description: 'Strong reasoning with large context window—reliable for everyday code reviews',
|
|
50
|
-
badge: 'Daily Driver',
|
|
51
|
-
badgeClass: 'badge-recommended',
|
|
52
|
-
default: true
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
id: 'gemini-3.1-pro-preview',
|
|
56
|
-
aliases: ['gemini-3.1-pro'],
|
|
57
|
-
name: '3.1 Pro',
|
|
58
|
-
tier: 'thorough',
|
|
59
|
-
tagline: 'Latest & Greatest',
|
|
60
|
-
description: 'Newest Gemini model—cutting-edge reasoning for complex architectural reviews',
|
|
61
|
-
badge: 'Deep Dive',
|
|
62
|
-
badgeClass: 'badge-power'
|
|
63
|
-
}
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
|
|
67
|
-
|
|
68
|
-
class GeminiProvider extends AIProvider {
|
|
69
|
-
/**
|
|
70
|
-
* @param {string} model - Model identifier
|
|
71
|
-
* @param {Object} configOverrides - Config overrides from providers config
|
|
72
|
-
* @param {string} configOverrides.command - Custom CLI command
|
|
73
|
-
* @param {string[]} configOverrides.extra_args - Additional CLI arguments
|
|
74
|
-
* @param {Object} configOverrides.env - Additional environment variables
|
|
75
|
-
* @param {Object[]} configOverrides.models - Custom model definitions
|
|
76
|
-
*/
|
|
77
|
-
constructor(model = DEFAULT_GEMINI_MODEL, configOverrides = {}) {
|
|
78
|
-
super(model);
|
|
79
|
-
|
|
80
|
-
// Command precedence: ENV > config > default
|
|
81
|
-
const envCmd = process.env.PAIR_REVIEW_GEMINI_CMD;
|
|
82
|
-
const configCmd = configOverrides.command;
|
|
83
|
-
const geminiCmd = envCmd || configCmd || 'gemini';
|
|
84
|
-
|
|
85
|
-
// Store for use in getExtractionConfig and testAvailability
|
|
86
|
-
this.geminiCmd = geminiCmd;
|
|
87
|
-
this.configOverrides = configOverrides;
|
|
88
|
-
|
|
89
|
-
// For multi-word commands, use shell mode (same pattern as Claude provider)
|
|
90
|
-
this.useShell = geminiCmd.includes(' ');
|
|
91
|
-
|
|
92
|
-
// ============================================================================
|
|
93
|
-
// SECURITY LIMITATION - READ CAREFULLY
|
|
94
|
-
// ============================================================================
|
|
95
|
-
//
|
|
96
|
-
// IMPORTANT: Unlike Claude and Copilot providers, Gemini CLI does NOT have a
|
|
97
|
-
// mechanism to restrict which tools the model can request. The --allowed-tools
|
|
98
|
-
// flag only controls which tools are AUTO-APPROVED (no interactive prompt), but
|
|
99
|
-
// all tools remain available to the model.
|
|
100
|
-
//
|
|
101
|
-
// Gemini tool names (from asking the CLI):
|
|
102
|
-
// - list_directory, read_file, search_file_content, glob: File system read operations
|
|
103
|
-
// - run_shell_command: Execute shell commands (needed for git, git-diff-lines)
|
|
104
|
-
// - google_web_search: Web search
|
|
105
|
-
// - write_file, replace: Write operations (NOT auto-approved but still available)
|
|
106
|
-
//
|
|
107
|
-
// In non-interactive mode (-o json), if the model requests a tool not in --allowed-tools,
|
|
108
|
-
// the operation may fail or the tool may still execute without explicit user approval.
|
|
109
|
-
//
|
|
110
|
-
// MITIGATION STRATEGY:
|
|
111
|
-
// 1. Prompt engineering: The analysis prompts in analyzer.js explicitly instruct
|
|
112
|
-
// the AI to only use read-only operations and never modify files
|
|
113
|
-
// 2. Worktree isolation: Analysis runs in a git worktree, limiting blast radius
|
|
114
|
-
// 3. Shell command restrictions: Use prefix-based allowlist for shell commands
|
|
115
|
-
//
|
|
116
|
-
// If a mechanism to restrict tool visibility becomes available in Gemini CLI,
|
|
117
|
-
// it should be added here similar to Copilot's --excluded-tools flag.
|
|
118
|
-
// ============================================================================
|
|
119
|
-
//
|
|
120
|
-
// SHELL COMMAND PREFIX SYNTAX:
|
|
121
|
-
// The --allowed-tools flag supports prefix matching via run_shell_command(prefix).
|
|
122
|
-
// E.g., run_shell_command(git) allows "git status", "git diff", etc.
|
|
123
|
-
// Commands NOT matching any prefix will be denied in non-interactive mode.
|
|
124
|
-
// ============================================================================
|
|
125
|
-
// Build args: base args + provider extra_args + model extra_args
|
|
126
|
-
// Use --output-format stream-json for JSONL streaming output (better debugging visibility)
|
|
127
|
-
let baseArgs;
|
|
128
|
-
if (configOverrides.yolo) {
|
|
129
|
-
// In yolo mode, auto-approve all tools (including write operations and destructive shell commands)
|
|
130
|
-
// Note: --yolo is deprecated in favor of --approval-mode=yolo
|
|
131
|
-
baseArgs = ['-m', model, '-o', 'stream-json', '--approval-mode', 'yolo'];
|
|
132
|
-
} else {
|
|
133
|
-
const readOnlyTools = [
|
|
134
|
-
// File system tools (read-only)
|
|
135
|
-
'list_directory',
|
|
136
|
-
'read_file',
|
|
137
|
-
'glob',
|
|
138
|
-
'search_file_content',
|
|
139
|
-
// Specific read-only git commands (not blanket 'git' to avoid git commit, push, etc.)
|
|
140
|
-
'run_shell_command(git diff)',
|
|
141
|
-
'run_shell_command(git log)',
|
|
142
|
-
'run_shell_command(git show)',
|
|
143
|
-
'run_shell_command(git status)',
|
|
144
|
-
'run_shell_command(git branch)',
|
|
145
|
-
'run_shell_command(git rev-parse)',
|
|
146
|
-
'run_shell_command(git sparse-checkout)',
|
|
147
|
-
// Read-only shell commands
|
|
148
|
-
'run_shell_command(ls)', // Directory listing
|
|
149
|
-
'run_shell_command(cat)', // File content viewing
|
|
150
|
-
'run_shell_command(pwd)', // Current directory
|
|
151
|
-
'run_shell_command(head)', // File head viewing
|
|
152
|
-
'run_shell_command(tail)', // File tail viewing
|
|
153
|
-
'run_shell_command(wc)', // Word/line count
|
|
154
|
-
'run_shell_command(find)', // File finding
|
|
155
|
-
'run_shell_command(grep)', // Pattern searching
|
|
156
|
-
'run_shell_command(rg)', // Ripgrep (fast pattern searching)
|
|
157
|
-
'run_shell_command(gh)', // GitHub CLI (fetch previous findings for dedup)
|
|
158
|
-
'run_shell_command(curl)', // HTTP requests (fetch previous findings for dedup)
|
|
159
|
-
// git-diff-lines is added to PATH via BIN_DIR so bare command works
|
|
160
|
-
'run_shell_command(git-diff-lines)', // Custom annotated diff tool
|
|
161
|
-
].join(',');
|
|
162
|
-
baseArgs = ['-m', model, '-o', 'stream-json', '--allowed-tools', readOnlyTools];
|
|
163
|
-
}
|
|
164
|
-
const providerArgs = configOverrides.extra_args || [];
|
|
165
|
-
const modelConfig = configOverrides.models?.find(m => m.id === model);
|
|
166
|
-
const modelArgs = modelConfig?.extra_args || [];
|
|
167
|
-
|
|
168
|
-
// Merge env: provider env + model env
|
|
169
|
-
this.extraEnv = {
|
|
170
|
-
...(configOverrides.env || {}),
|
|
171
|
-
...(modelConfig?.env || {})
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
if (this.useShell) {
|
|
175
|
-
// In shell mode, build full command string with args
|
|
176
|
-
// Quote all args to prevent shell interpretation of special characters
|
|
177
|
-
// (commas, parentheses in patterns like "run_shell_command(git diff)")
|
|
178
|
-
this.command = `${geminiCmd} ${quoteShellArgs([...baseArgs, ...providerArgs, ...modelArgs]).join(' ')}`;
|
|
179
|
-
this.args = [];
|
|
180
|
-
} else {
|
|
181
|
-
this.command = geminiCmd;
|
|
182
|
-
this.args = [...baseArgs, ...providerArgs, ...modelArgs];
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Execute Gemini CLI with a prompt
|
|
188
|
-
* @param {string} prompt - The prompt to send to Gemini
|
|
189
|
-
* @param {Object} options - Optional configuration
|
|
190
|
-
* @returns {Promise<Object>} Parsed response or error
|
|
191
|
-
*/
|
|
192
|
-
async execute(prompt, options = {}) {
|
|
193
|
-
return new Promise((resolve, reject) => {
|
|
194
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
195
|
-
|
|
196
|
-
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
197
|
-
logger.info(`${levelPrefix} Executing Gemini CLI...`);
|
|
198
|
-
logger.info(`${levelPrefix} Writing prompt: ${prompt.length} bytes`);
|
|
199
|
-
|
|
200
|
-
const gemini = spawn(this.command, this.args, {
|
|
201
|
-
cwd,
|
|
202
|
-
env: {
|
|
203
|
-
...process.env,
|
|
204
|
-
...this.extraEnv,
|
|
205
|
-
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
206
|
-
},
|
|
207
|
-
shell: this.useShell,
|
|
208
|
-
detached: this.useShell
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
const pid = gemini.pid;
|
|
212
|
-
logger.info(`${levelPrefix} Spawned Gemini CLI process: PID ${pid}`);
|
|
213
|
-
|
|
214
|
-
// Register process for cancellation tracking if analysisId provided
|
|
215
|
-
if (analysisId && registerProcess) {
|
|
216
|
-
registerProcess(analysisId, gemini);
|
|
217
|
-
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
221
|
-
const abortWiring = wireAbortToChild(gemini, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
222
|
-
|
|
223
|
-
let stdout = '';
|
|
224
|
-
let stderr = '';
|
|
225
|
-
let timeoutId = null;
|
|
226
|
-
let settled = false; // Guard against multiple resolve/reject calls
|
|
227
|
-
let lineBuffer = ''; // Buffer for incomplete JSONL lines
|
|
228
|
-
let lineCount = 0; // Count of JSONL events for progress tracking
|
|
229
|
-
|
|
230
|
-
const settle = (fn, value) => {
|
|
231
|
-
if (settled) return;
|
|
232
|
-
settled = true;
|
|
233
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
234
|
-
abortWiring.detach();
|
|
235
|
-
fn(value);
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
// Set up side-channel stream parser for live progress events
|
|
239
|
-
const streamParser = onStreamEvent
|
|
240
|
-
? new StreamParser(parseGeminiLine, onStreamEvent, { cwd })
|
|
241
|
-
: null;
|
|
242
|
-
|
|
243
|
-
// Set timeout
|
|
244
|
-
if (timeout) {
|
|
245
|
-
timeoutId = setTimeout(() => {
|
|
246
|
-
logger.error(`${levelPrefix} Process ${pid} timed out after ${timeout}ms`);
|
|
247
|
-
gemini.kill('SIGTERM');
|
|
248
|
-
settle(reject, new Error(`${levelPrefix} Gemini CLI timed out after ${timeout}ms`));
|
|
249
|
-
}, timeout);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Collect stdout with streaming JSONL parsing for debug visibility
|
|
253
|
-
gemini.stdout.on('data', (data) => {
|
|
254
|
-
const chunk = data.toString();
|
|
255
|
-
stdout += chunk;
|
|
256
|
-
|
|
257
|
-
// Feed side-channel stream parser for live progress events
|
|
258
|
-
if (streamParser) {
|
|
259
|
-
streamParser.feed(chunk);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Parse JSONL lines as they arrive for streaming debug output
|
|
263
|
-
lineBuffer += chunk;
|
|
264
|
-
const lines = lineBuffer.split('\n');
|
|
265
|
-
// Keep the last incomplete line in buffer
|
|
266
|
-
lineBuffer = lines.pop() || '';
|
|
267
|
-
|
|
268
|
-
for (const line of lines) {
|
|
269
|
-
if (line.trim()) {
|
|
270
|
-
lineCount++;
|
|
271
|
-
this.logStreamLine(line, lineCount, levelPrefix);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// Collect stderr
|
|
277
|
-
gemini.stderr.on('data', (data) => {
|
|
278
|
-
stderr += data.toString();
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// Handle completion
|
|
282
|
-
gemini.on('close', (code) => {
|
|
283
|
-
if (settled) return; // Already settled by timeout or error
|
|
284
|
-
|
|
285
|
-
// Detach is centralized in `settle`.
|
|
286
|
-
|
|
287
|
-
// Flush any remaining stream parser buffer
|
|
288
|
-
if (streamParser) {
|
|
289
|
-
streamParser.flush();
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// BackgroundQueue-driven cancellation — see claude-provider for
|
|
293
|
-
// the rationale; pattern is mirrored here.
|
|
294
|
-
if (abortWiring.cancelled()) {
|
|
295
|
-
logger.info(`${levelPrefix} Gemini CLI terminated by user cancel (exit code ${code})`);
|
|
296
|
-
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
301
|
-
const isCancellationCode = code === 143 || code === 137;
|
|
302
|
-
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
303
|
-
logger.info(`${levelPrefix} Gemini CLI terminated due to analysis cancellation (exit code ${code})`);
|
|
304
|
-
settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Always log stderr if present
|
|
309
|
-
if (stderr.trim()) {
|
|
310
|
-
if (code !== 0) {
|
|
311
|
-
logger.error(`${levelPrefix} Gemini CLI stderr (exit code ${code}): ${stderr}`);
|
|
312
|
-
} else {
|
|
313
|
-
logger.warn(`${levelPrefix} Gemini CLI stderr (success): ${stderr}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (code !== 0) {
|
|
318
|
-
logger.error(`${levelPrefix} Gemini CLI exited with code ${code}`);
|
|
319
|
-
settle(reject, new Error(`${levelPrefix} Gemini CLI exited with code ${code}: ${stderr}`));
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Log completion with event count (only for successful completion)
|
|
324
|
-
logger.info(`${levelPrefix} Gemini CLI completed: ${lineCount} JSONL events received`);
|
|
325
|
-
|
|
326
|
-
// Process any remaining buffered line
|
|
327
|
-
if (lineBuffer.trim()) {
|
|
328
|
-
lineCount++;
|
|
329
|
-
this.logStreamLine(lineBuffer, lineCount, levelPrefix);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Parse the Gemini JSONL stream response
|
|
333
|
-
const parsed = this.parseGeminiResponse(stdout, level, levelPrefix);
|
|
334
|
-
if (parsed.success) {
|
|
335
|
-
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
336
|
-
// Dump the parsed data for debugging
|
|
337
|
-
const dataPreview = JSON.stringify(parsed.data, null, 2);
|
|
338
|
-
logger.debug(`${levelPrefix} [parsed_data] ${dataPreview.substring(0, 3000)}${dataPreview.length > 3000 ? '...' : ''}`);
|
|
339
|
-
// Log suggestion count if present
|
|
340
|
-
if (parsed.data?.suggestions) {
|
|
341
|
-
const count = Array.isArray(parsed.data.suggestions) ? parsed.data.suggestions.length : 0;
|
|
342
|
-
logger.info(`${levelPrefix} [response] ${count} suggestions in parsed response`);
|
|
343
|
-
}
|
|
344
|
-
settle(resolve, parsed.data);
|
|
345
|
-
} else {
|
|
346
|
-
// Regex extraction failed, try LLM-based extraction as fallback
|
|
347
|
-
logger.warn(`${levelPrefix} Regex extraction failed: ${parsed.error}`);
|
|
348
|
-
const llmFallbackInput = parsed.textContent || stdout;
|
|
349
|
-
logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
|
|
350
|
-
logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
|
|
351
|
-
|
|
352
|
-
// Use async IIFE to handle the async LLM extraction
|
|
353
|
-
(async () => {
|
|
354
|
-
try {
|
|
355
|
-
const llmExtracted = await this.extractJSONWithLLM(llmFallbackInput, { level, analysisId, registerProcess, logPrefix: levelPrefix });
|
|
356
|
-
if (llmExtracted.success) {
|
|
357
|
-
logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
|
|
358
|
-
settle(resolve, llmExtracted.data);
|
|
359
|
-
} else {
|
|
360
|
-
logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
|
|
361
|
-
logger.info(`${levelPrefix} Raw response preview: ${llmFallbackInput.substring(0, 500)}...`);
|
|
362
|
-
settle(resolve, { raw: llmFallbackInput, parsed: false });
|
|
363
|
-
}
|
|
364
|
-
} catch (llmError) {
|
|
365
|
-
logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
|
|
366
|
-
settle(resolve, { raw: llmFallbackInput, parsed: false });
|
|
367
|
-
}
|
|
368
|
-
})();
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// Handle errors
|
|
373
|
-
gemini.on('error', (error) => {
|
|
374
|
-
// Detach happens inside `settle`.
|
|
375
|
-
if (error.code === 'ENOENT') {
|
|
376
|
-
logger.error(`${levelPrefix} Gemini CLI not found. Please ensure Gemini CLI is installed.`);
|
|
377
|
-
settle(reject, new Error(`${levelPrefix} Gemini CLI not found. ${GeminiProvider.getInstallInstructions()}`));
|
|
378
|
-
} else {
|
|
379
|
-
logger.error(`${levelPrefix} Gemini process error: ${error}`);
|
|
380
|
-
settle(reject, error);
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// Handle stdin errors (e.g., EPIPE if process exits before write completes)
|
|
385
|
-
gemini.stdin.on('error', (err) => {
|
|
386
|
-
logger.error(`${levelPrefix} stdin error: ${err.message}`);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// Send the prompt to stdin
|
|
390
|
-
gemini.stdin.write(prompt, (err) => {
|
|
391
|
-
if (err) {
|
|
392
|
-
logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
|
|
393
|
-
gemini.kill('SIGTERM');
|
|
394
|
-
settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
|
|
395
|
-
}
|
|
396
|
-
});
|
|
397
|
-
gemini.stdin.end();
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Parse Gemini CLI JSONL stream response
|
|
403
|
-
* Gemini with -o stream-json outputs JSONL with multiple event types:
|
|
404
|
-
* - init: Session initialization with model info
|
|
405
|
-
* - message (role: "user"): User message echo
|
|
406
|
-
* - message (role: "assistant"): Assistant text (may have delta: true for streaming chunks,
|
|
407
|
-
* but we accumulate ALL assistant messages with content regardless of delta flag)
|
|
408
|
-
* - tool_use: Tool invocation with tool_name, tool_id, parameters
|
|
409
|
-
* - tool_result: Tool result with status and output
|
|
410
|
-
* - result: Final result with stats (total_tokens, input_tokens, output_tokens)
|
|
411
|
-
*
|
|
412
|
-
* We need to accumulate text from assistant messages and extract JSON from them.
|
|
413
|
-
*
|
|
414
|
-
* @param {string} stdout - Raw stdout from Gemini CLI (JSONL format)
|
|
415
|
-
* @param {string|number} level - Analysis level for logging
|
|
416
|
-
* @returns {{success: boolean, data?: Object, error?: string}}
|
|
417
|
-
*/
|
|
418
|
-
parseGeminiResponse(stdout, level, logPrefix) {
|
|
419
|
-
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
420
|
-
|
|
421
|
-
try {
|
|
422
|
-
// Split by newlines and parse each JSON line
|
|
423
|
-
const lines = stdout.trim().split('\n').filter(line => line.trim());
|
|
424
|
-
// Accumulate text from ALL assistant message events with delta: true
|
|
425
|
-
// The AI's response may be spread across multiple streaming chunks
|
|
426
|
-
let assistantText = '';
|
|
427
|
-
|
|
428
|
-
for (const line of lines) {
|
|
429
|
-
try {
|
|
430
|
-
const event = JSON.parse(line);
|
|
431
|
-
|
|
432
|
-
// Accumulate text from assistant messages which contain the AI response
|
|
433
|
-
// Multiple message events can occur as streaming chunks arrive
|
|
434
|
-
if (event.type === 'message' &&
|
|
435
|
-
event.role === 'assistant' &&
|
|
436
|
-
event.content) {
|
|
437
|
-
assistantText += event.content;
|
|
438
|
-
}
|
|
439
|
-
} catch (lineError) {
|
|
440
|
-
// Skip malformed lines
|
|
441
|
-
logger.debug(`${levelPrefix} Skipping malformed JSONL line: ${line.substring(0, 100)}`);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (assistantText) {
|
|
446
|
-
// The accumulated assistant text contains the AI's response
|
|
447
|
-
// Try to extract JSON from it (the AI was asked to output JSON)
|
|
448
|
-
logger.debug(`${levelPrefix} Extracted ${assistantText.length} chars of assistant message text from JSONL`);
|
|
449
|
-
const extracted = extractJSON(assistantText, level, levelPrefix);
|
|
450
|
-
if (extracted.success) {
|
|
451
|
-
return extracted;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// If no JSON found, return with textContent so the caller can
|
|
455
|
-
// pass it (not raw JSONL stdout) to the LLM extraction fallback
|
|
456
|
-
logger.warn(`${levelPrefix} Assistant message is not JSON, treating as raw text`);
|
|
457
|
-
return { success: false, error: 'Assistant message is not valid JSON', textContent: assistantText };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// No assistant message found, try extracting JSON directly from stdout
|
|
461
|
-
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
462
|
-
return extracted;
|
|
463
|
-
|
|
464
|
-
} catch (parseError) {
|
|
465
|
-
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
466
|
-
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
467
|
-
if (extracted.success) {
|
|
468
|
-
return extracted;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return { success: false, error: `JSONL parse error: ${parseError.message}` };
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Log a streaming JSONL line for debugging visibility
|
|
477
|
-
* Gemini stream-json format event types:
|
|
478
|
-
* - init: Session initialization with model info
|
|
479
|
-
* - message (role: "user"): User message echo
|
|
480
|
-
* - message (role: "assistant", delta: true): Streaming assistant text chunks
|
|
481
|
-
* - tool_use: Tool invocation with tool_name, tool_id, parameters
|
|
482
|
-
* - tool_result: Tool result with status and output
|
|
483
|
-
* - result: Final result with stats
|
|
484
|
-
*
|
|
485
|
-
* Uses logger.streamDebug() which only logs when --debug-stream flag is enabled.
|
|
486
|
-
*
|
|
487
|
-
* @param {string} line - A single JSONL line
|
|
488
|
-
* @param {number} lineNum - Line number for reference
|
|
489
|
-
* @param {string} levelPrefix - Logging prefix
|
|
490
|
-
*/
|
|
491
|
-
logStreamLine(line, lineNum, levelPrefix) {
|
|
492
|
-
// Check stream debug status - branches exit early if disabled
|
|
493
|
-
const streamEnabled = logger.isStreamDebugEnabled();
|
|
494
|
-
|
|
495
|
-
try {
|
|
496
|
-
const event = JSON.parse(line);
|
|
497
|
-
const eventType = event.type;
|
|
498
|
-
|
|
499
|
-
if (eventType === 'init') {
|
|
500
|
-
if (!streamEnabled) return;
|
|
501
|
-
const sessionId = event.session_id || '';
|
|
502
|
-
const model = event.model || '';
|
|
503
|
-
const sessionPart = sessionId ? ` session=${sessionId.substring(0, 12)}` : '';
|
|
504
|
-
const modelPart = model ? ` model=${model}` : '';
|
|
505
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] init${sessionPart}${modelPart}`);
|
|
506
|
-
|
|
507
|
-
} else if (eventType === 'message') {
|
|
508
|
-
if (!streamEnabled) return;
|
|
509
|
-
const role = event.role || 'unknown';
|
|
510
|
-
const content = event.content || '';
|
|
511
|
-
const isDelta = event.delta === true;
|
|
512
|
-
|
|
513
|
-
if (role === 'assistant') {
|
|
514
|
-
// Assistant message - this is the AI's text response
|
|
515
|
-
if (content) {
|
|
516
|
-
const preview = content.replace(/\n/g, '\\n').substring(0, 60);
|
|
517
|
-
const deltaPart = isDelta ? ' (delta)' : '';
|
|
518
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] message[assistant]${deltaPart}: ${preview}${content.length > 60 ? '...' : ''}`);
|
|
519
|
-
} else {
|
|
520
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] message[assistant] (empty)`);
|
|
521
|
-
}
|
|
522
|
-
} else if (role === 'user') {
|
|
523
|
-
// User message echo - brief log
|
|
524
|
-
const preview = content.replace(/\n/g, '\\n').substring(0, 40);
|
|
525
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] message[user]: ${preview}${content.length > 40 ? '...' : ''}`);
|
|
526
|
-
} else {
|
|
527
|
-
// Unknown role
|
|
528
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] message[${role}]`);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
} else if (eventType === 'tool_use') {
|
|
532
|
-
if (!streamEnabled) return;
|
|
533
|
-
// Tool invocation - extract name and parameters
|
|
534
|
-
const toolName = event.tool_name || 'unknown';
|
|
535
|
-
const toolId = event.tool_id || '';
|
|
536
|
-
const params = event.parameters || null;
|
|
537
|
-
|
|
538
|
-
let paramsPreview = '';
|
|
539
|
-
if (params && typeof params === 'object') {
|
|
540
|
-
const keys = Object.keys(params);
|
|
541
|
-
if (params.command) {
|
|
542
|
-
const cmd = params.command;
|
|
543
|
-
paramsPreview = `cmd="${cmd.substring(0, 50)}${cmd.length > 50 ? '...' : ''}"`;
|
|
544
|
-
} else if (params.file_path || params.path) {
|
|
545
|
-
paramsPreview = `path="${params.file_path || params.path}"`;
|
|
546
|
-
} else if (keys.length === 1 && typeof params[keys[0]] === 'string') {
|
|
547
|
-
const val = params[keys[0]];
|
|
548
|
-
paramsPreview = `${keys[0]}="${val.length > 40 ? val.substring(0, 40) + '...' : val}"`;
|
|
549
|
-
} else if (keys.length > 0) {
|
|
550
|
-
paramsPreview = `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}}`;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const idPart = toolId ? ` [${toolId.substring(0, 8)}]` : '';
|
|
555
|
-
const paramsPart = paramsPreview ? ` ${paramsPreview}` : '';
|
|
556
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] tool_use: ${toolName}${idPart}${paramsPart}`);
|
|
557
|
-
|
|
558
|
-
} else if (eventType === 'tool_result') {
|
|
559
|
-
if (!streamEnabled) return;
|
|
560
|
-
// Tool result
|
|
561
|
-
const toolId = event.tool_id || '';
|
|
562
|
-
const status = event.status || 'unknown';
|
|
563
|
-
const output = event.output || '';
|
|
564
|
-
const isError = status === 'error';
|
|
565
|
-
|
|
566
|
-
let resultPreview = '';
|
|
567
|
-
if (typeof output === 'string' && output.length > 0) {
|
|
568
|
-
resultPreview = output.length > 60 ? output.substring(0, 60) + '...' : output;
|
|
569
|
-
resultPreview = resultPreview.replace(/\n/g, '\\n');
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const idPart = toolId ? ` [${toolId.substring(0, 8)}]` : '';
|
|
573
|
-
const statusPart = isError ? ' ERROR' : ' OK';
|
|
574
|
-
const previewPart = resultPreview ? ` ${resultPreview}` : '';
|
|
575
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] tool_result${idPart}${statusPart}${previewPart}`);
|
|
576
|
-
|
|
577
|
-
} else if (eventType === 'result') {
|
|
578
|
-
// Final result - always log this at info level (not stream debug)
|
|
579
|
-
const stats = event.stats || {};
|
|
580
|
-
const inputTokens = stats.input_tokens || 0;
|
|
581
|
-
const outputTokens = stats.output_tokens || 0;
|
|
582
|
-
const totalTokens = stats.total_tokens || (inputTokens + outputTokens);
|
|
583
|
-
const durationMs = stats.duration_ms || 0;
|
|
584
|
-
const toolCalls = stats.tool_calls || 0;
|
|
585
|
-
|
|
586
|
-
const durationPart = durationMs ? ` duration=${durationMs}ms` : '';
|
|
587
|
-
const toolsPart = toolCalls ? ` tools=${toolCalls}` : '';
|
|
588
|
-
logger.info(`${levelPrefix} [result] tokens: ${inputTokens}in/${outputTokens}out (total: ${totalTokens})${durationPart}${toolsPart}`);
|
|
589
|
-
|
|
590
|
-
} else if (eventType && streamEnabled) {
|
|
591
|
-
// Unknown event type - only log if we have an actual type and stream debug is on
|
|
592
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] ${eventType}`);
|
|
593
|
-
}
|
|
594
|
-
// Silently ignore events with no type
|
|
595
|
-
|
|
596
|
-
} catch (parseError) {
|
|
597
|
-
if (streamEnabled) {
|
|
598
|
-
// Skip malformed lines
|
|
599
|
-
logger.streamDebug(`${levelPrefix} [#${lineNum}] (malformed: ${line.substring(0, 50)}${line.length > 50 ? '...' : ''})`);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* Build args for Gemini CLI extraction, applying provider and model extra_args.
|
|
606
|
-
* This ensures consistent arg construction for getExtractionConfig().
|
|
607
|
-
*
|
|
608
|
-
* Note: For extraction, we use text output (-o text) to get raw JSON without wrapper.
|
|
609
|
-
* This avoids needing parseGeminiResponse which expects the JSONL stream format.
|
|
610
|
-
* No --allowed-tools needed since extraction doesn't use tools.
|
|
611
|
-
*
|
|
612
|
-
* @param {string} model - The model identifier to use
|
|
613
|
-
* @returns {string[]} Complete args array for the CLI
|
|
614
|
-
*/
|
|
615
|
-
buildArgsForModel(model) {
|
|
616
|
-
// Base args for extraction (text output, no tools needed)
|
|
617
|
-
// Use text format for simpler JSON parsing; analysis uses stream-json for progress feedback and tool visibility
|
|
618
|
-
const baseArgs = ['-m', model, '-o', 'text'];
|
|
619
|
-
// Provider-level extra_args (from configOverrides)
|
|
620
|
-
const providerArgs = this.configOverrides?.extra_args || [];
|
|
621
|
-
// Model-specific extra_args (from the model config for the given model)
|
|
622
|
-
const modelConfig = this.configOverrides?.models?.find(m => m.id === model);
|
|
623
|
-
const modelArgs = modelConfig?.extra_args || [];
|
|
624
|
-
|
|
625
|
-
return [...baseArgs, ...providerArgs, ...modelArgs];
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/**
|
|
629
|
-
* Get CLI configuration for LLM extraction
|
|
630
|
-
* @param {string} model - The model to use for extraction
|
|
631
|
-
* @returns {Object} Configuration for spawning extraction process
|
|
632
|
-
*/
|
|
633
|
-
getExtractionConfig(model) {
|
|
634
|
-
// Use the already-resolved command from the constructor (this.geminiCmd)
|
|
635
|
-
// which respects: ENV > config > default precedence
|
|
636
|
-
const geminiCmd = this.geminiCmd;
|
|
637
|
-
const useShell = this.useShell;
|
|
638
|
-
|
|
639
|
-
// Build args consistently using the shared method, applying provider and model extra_args
|
|
640
|
-
const args = this.buildArgsForModel(model);
|
|
641
|
-
|
|
642
|
-
// For extraction, we pass the prompt via stdin
|
|
643
|
-
if (useShell) {
|
|
644
|
-
return {
|
|
645
|
-
command: `${geminiCmd} ${quoteShellArgs(args).join(' ')}`,
|
|
646
|
-
args: [],
|
|
647
|
-
useShell: true,
|
|
648
|
-
promptViaStdin: true
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
return {
|
|
652
|
-
command: geminiCmd,
|
|
653
|
-
args,
|
|
654
|
-
useShell: false,
|
|
655
|
-
promptViaStdin: true
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* Test if Gemini CLI is available
|
|
661
|
-
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
662
|
-
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe.
|
|
663
|
-
* Production passes the per-provider resolved value; the default is only hit by tests.
|
|
664
|
-
* @returns {Promise<boolean>}
|
|
665
|
-
*/
|
|
666
|
-
async testAvailability(timeoutMs = 10000) {
|
|
667
|
-
return new Promise((resolve) => {
|
|
668
|
-
// For availability test, we just need to check --version
|
|
669
|
-
// Use the already-resolved command from the constructor (this.geminiCmd)
|
|
670
|
-
// which respects: ENV > config > default precedence
|
|
671
|
-
const useShell = this.useShell;
|
|
672
|
-
const command = useShell ? `${this.geminiCmd} --version` : this.geminiCmd;
|
|
673
|
-
const args = useShell ? [] : ['--version'];
|
|
674
|
-
|
|
675
|
-
// Log the actual command for debugging config/override issues
|
|
676
|
-
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
677
|
-
logger.debug(`Gemini availability check: ${fullCmd}`);
|
|
678
|
-
|
|
679
|
-
const gemini = spawn(command, args, {
|
|
680
|
-
env: {
|
|
681
|
-
...process.env,
|
|
682
|
-
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
683
|
-
},
|
|
684
|
-
shell: useShell
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
let stdout = '';
|
|
688
|
-
let settled = false;
|
|
689
|
-
|
|
690
|
-
// Timeout guard: if the CLI hangs, kill it and resolve false so the probe
|
|
691
|
-
// does not leak a child process.
|
|
692
|
-
const availabilityTimeout = setTimeout(() => {
|
|
693
|
-
if (settled) return;
|
|
694
|
-
settled = true;
|
|
695
|
-
logger.warn(`Gemini CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
696
|
-
try { gemini.kill(); } catch { /* ignore */ }
|
|
697
|
-
resolve(false);
|
|
698
|
-
}, timeoutMs);
|
|
699
|
-
|
|
700
|
-
gemini.stdout.on('data', (data) => {
|
|
701
|
-
stdout += data.toString();
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
gemini.on('close', (code) => {
|
|
705
|
-
if (settled) return;
|
|
706
|
-
settled = true;
|
|
707
|
-
clearTimeout(availabilityTimeout);
|
|
708
|
-
if (code === 0 && stdout.includes('.')) {
|
|
709
|
-
logger.info(`Gemini CLI available: ${stdout.trim()}`);
|
|
710
|
-
resolve(true);
|
|
711
|
-
} else {
|
|
712
|
-
logger.warn('Gemini CLI not available or returned unexpected output');
|
|
713
|
-
resolve(false);
|
|
714
|
-
}
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
gemini.on('error', (error) => {
|
|
718
|
-
if (settled) return;
|
|
719
|
-
settled = true;
|
|
720
|
-
clearTimeout(availabilityTimeout);
|
|
721
|
-
logger.warn(`Gemini CLI not available: ${error.message}`);
|
|
722
|
-
resolve(false);
|
|
723
|
-
});
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
static getProviderName() {
|
|
728
|
-
return 'Gemini';
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
static getProviderId() {
|
|
732
|
-
return 'gemini';
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
static getModels() {
|
|
736
|
-
return GEMINI_MODELS;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
static getDefaultModel() {
|
|
740
|
-
return DEFAULT_GEMINI_MODEL;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
static getInstallInstructions() {
|
|
744
|
-
return 'Install Gemini CLI: npm install -g @google/gemini-cli\n' +
|
|
745
|
-
'Or visit: https://github.com/google-gemini/gemini-cli';
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// Register this provider
|
|
750
|
-
registerProvider('gemini', GeminiProvider);
|
|
751
|
-
|
|
752
|
-
module.exports = GeminiProvider;
|