@in-the-loop-labs/pair-review 3.9.1 → 4.1.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.
Files changed (53) hide show
  1. package/README.md +92 -17
  2. package/package.json +6 -9
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
  5. package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
  6. package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
  7. package/public/js/components/AdvancedConfigTab.js +1 -1
  8. package/public/js/components/AnalysisConfigModal.js +2 -2
  9. package/public/js/index.js +87 -10
  10. package/public/js/local.js +20 -10
  11. package/public/js/pr.js +67 -39
  12. package/public/js/repo-links.js +11 -3
  13. package/public/js/utils/analyze-params.js +69 -0
  14. package/public/js/utils/provider-model.js +67 -2
  15. package/public/js/vendor/pierre-diffs-worker.js +16158 -0
  16. package/public/js/vendor/pierre-diffs.js +1880 -0
  17. package/public/local.html +1 -0
  18. package/public/pr.html +1 -0
  19. package/public/setup.html +35 -16
  20. package/src/ai/analyzer.js +4 -4
  21. package/src/ai/antigravity-provider.js +594 -0
  22. package/src/ai/index.js +1 -1
  23. package/src/ai/opencode-provider.js +1 -1
  24. package/src/ai/provider.js +5 -5
  25. package/src/ai/stream-parser.js +1 -52
  26. package/src/chat/acp-bridge.js +1 -1
  27. package/src/chat/chat-providers.js +1 -9
  28. package/src/config.js +153 -31
  29. package/src/database.js +128 -4
  30. package/src/external/github-adapter.js +18 -3
  31. package/src/github/client.js +37 -0
  32. package/src/github/parser.js +41 -7
  33. package/src/interactive-analysis-config.js +2 -2
  34. package/src/links/repo-links.js +66 -28
  35. package/src/local-review.js +134 -5
  36. package/src/local-scope.js +38 -0
  37. package/src/main.js +203 -37
  38. package/src/routes/config.js +49 -14
  39. package/src/routes/external-comments.js +13 -1
  40. package/src/routes/github-collections.js +175 -13
  41. package/src/routes/local.js +11 -20
  42. package/src/routes/pr.js +63 -36
  43. package/src/routes/setup.js +63 -8
  44. package/src/routes/shared.js +85 -0
  45. package/src/routes/stack-analysis.js +39 -3
  46. package/src/server.js +74 -3
  47. package/src/setup/local-setup.js +23 -7
  48. package/src/setup/pr-setup.js +237 -39
  49. package/src/setup/stack-setup.js +7 -2
  50. package/src/single-port.js +73 -18
  51. package/src/utils/host-resolution.js +157 -0
  52. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
  53. 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;