@in-the-loop-labs/pair-review 1.3.2 → 1.3.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -396,24 +396,28 @@ class ClaudeProvider extends AIProvider {
396
396
  } else {
397
397
  // Regex extraction failed, try LLM-based extraction as fallback
398
398
  logger.warn(`${levelPrefix} Regex extraction failed: ${parsed.error}`);
399
- logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
399
+ // Pass extracted text content to LLM fallback (not raw JSONL stdout).
400
+ // The text content is the actual LLM response text extracted from JSONL
401
+ // events and is much smaller and more relevant than the full JSONL stream.
402
+ const llmFallbackInput = parsed.textContent || stdout;
403
+ logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
400
404
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
401
405
 
402
406
  // Use async IIFE to handle the async LLM extraction
403
407
  (async () => {
404
408
  try {
405
- const llmExtracted = await this.extractJSONWithLLM(stdout, { level, analysisId, registerProcess });
409
+ const llmExtracted = await this.extractJSONWithLLM(llmFallbackInput, { level, analysisId, registerProcess });
406
410
  if (llmExtracted.success) {
407
411
  logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
408
412
  settle(resolve, llmExtracted.data);
409
413
  } else {
410
414
  logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
411
- logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
412
- settle(resolve, { raw: stdout, parsed: false });
415
+ logger.info(`${levelPrefix} Raw response preview: ${llmFallbackInput.substring(0, 500)}...`);
416
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
413
417
  }
414
418
  } catch (llmError) {
415
419
  logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
416
- settle(resolve, { raw: stdout, parsed: false });
420
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
417
421
  }
418
422
  })();
419
423
  }
@@ -731,9 +735,10 @@ class ClaudeProvider extends AIProvider {
731
735
  return extracted;
732
736
  }
733
737
 
734
- // If no JSON found, return the raw text
738
+ // If no JSON found, return with textContent so the caller can
739
+ // pass it (not raw JSONL stdout) to the LLM extraction fallback
735
740
  logger.warn(`${levelPrefix} Text content is not JSON, treating as raw text`);
736
- return { success: false, error: 'Text content is not valid JSON' };
741
+ return { success: false, error: 'Text content is not valid JSON', textContent };
737
742
  }
738
743
 
739
744
  // No text content found - don't fall back to raw stdout extraction
@@ -271,24 +271,25 @@ class CodexProvider extends AIProvider {
271
271
  } else {
272
272
  // Regex extraction failed, try LLM-based extraction as fallback
273
273
  logger.warn(`${levelPrefix} Regex extraction failed: ${parsed.error}`);
274
- logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
274
+ const llmFallbackInput = parsed.textContent || stdout;
275
+ logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
275
276
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
276
277
 
277
278
  // Use async IIFE to handle the async LLM extraction
278
279
  (async () => {
279
280
  try {
280
- const llmExtracted = await this.extractJSONWithLLM(stdout, { level, analysisId, registerProcess });
281
+ const llmExtracted = await this.extractJSONWithLLM(llmFallbackInput, { level, analysisId, registerProcess });
281
282
  if (llmExtracted.success) {
282
283
  logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
283
284
  settle(resolve, llmExtracted.data);
284
285
  } else {
285
286
  logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
286
- logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
287
- settle(resolve, { raw: stdout, parsed: false });
287
+ logger.info(`${levelPrefix} Raw response preview: ${llmFallbackInput.substring(0, 500)}...`);
288
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
288
289
  }
289
290
  } catch (llmError) {
290
291
  logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
291
- settle(resolve, { raw: stdout, parsed: false });
292
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
292
293
  }
293
294
  })();
294
295
  }
@@ -373,9 +374,10 @@ class CodexProvider extends AIProvider {
373
374
  return extracted;
374
375
  }
375
376
 
376
- // If no JSON found, return the raw message
377
+ // If no JSON found, return with textContent so the caller can
378
+ // pass it (not raw JSONL stdout) to the LLM extraction fallback
377
379
  logger.warn(`${levelPrefix} Agent message is not JSON, treating as raw text`);
378
- return { success: false, error: 'Agent message is not valid JSON' };
380
+ return { success: false, error: 'Agent message is not valid JSON', textContent: agentMessageText };
379
381
  }
380
382
 
381
383
  // No agent message found, try extracting JSON directly from stdout
@@ -314,7 +314,8 @@ class CursorAgentProvider extends AIProvider {
314
314
  } else {
315
315
  // Regex extraction failed, try LLM-based extraction as fallback
316
316
  logger.warn(`${levelPrefix} Regex extraction failed: ${parsed.error}`);
317
- logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
317
+ const llmFallbackInput = parsed.textContent || stdout;
318
+ logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
318
319
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
319
320
 
320
321
  // Use async IIFE to handle the async LLM extraction
@@ -324,18 +325,18 @@ class CursorAgentProvider extends AIProvider {
324
325
  // orphan processes if timeout fired between close-handler entry
325
326
  // and reaching this point.
326
327
  if (settled) return;
327
- const llmExtracted = await this.extractJSONWithLLM(stdout, { level, analysisId, registerProcess });
328
+ const llmExtracted = await this.extractJSONWithLLM(llmFallbackInput, { level, analysisId, registerProcess });
328
329
  if (llmExtracted.success) {
329
330
  logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
330
331
  settle(resolve, llmExtracted.data);
331
332
  } else {
332
333
  logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
333
- logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
334
- settle(resolve, { raw: stdout, parsed: false });
334
+ logger.info(`${levelPrefix} Raw response preview: ${llmFallbackInput.substring(0, 500)}...`);
335
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
335
336
  }
336
337
  } catch (llmError) {
337
338
  logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
338
- settle(resolve, { raw: stdout, parsed: false });
339
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
339
340
  }
340
341
  })();
341
342
  }
@@ -466,7 +467,9 @@ class CursorAgentProvider extends AIProvider {
466
467
  return extracted;
467
468
  }
468
469
 
469
- return { success: false, error: 'No valid JSON found in assistant or result text' };
470
+ // Include textContent so the caller can pass it to LLM extraction fallback
471
+ const textContent = assistantText || resultText || null;
472
+ return { success: false, error: 'No valid JSON found in assistant or result text', textContent };
470
473
 
471
474
  } catch (parseError) {
472
475
  // stdout might not be valid JSONL at all, try extracting JSON from it
@@ -320,24 +320,25 @@ class GeminiProvider extends AIProvider {
320
320
  } else {
321
321
  // Regex extraction failed, try LLM-based extraction as fallback
322
322
  logger.warn(`${levelPrefix} Regex extraction failed: ${parsed.error}`);
323
- logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
323
+ const llmFallbackInput = parsed.textContent || stdout;
324
+ logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
324
325
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
325
326
 
326
327
  // Use async IIFE to handle the async LLM extraction
327
328
  (async () => {
328
329
  try {
329
- const llmExtracted = await this.extractJSONWithLLM(stdout, { level, analysisId, registerProcess });
330
+ const llmExtracted = await this.extractJSONWithLLM(llmFallbackInput, { level, analysisId, registerProcess });
330
331
  if (llmExtracted.success) {
331
332
  logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
332
333
  settle(resolve, llmExtracted.data);
333
334
  } else {
334
335
  logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
335
- logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
336
- settle(resolve, { raw: stdout, parsed: false });
336
+ logger.info(`${levelPrefix} Raw response preview: ${llmFallbackInput.substring(0, 500)}...`);
337
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
337
338
  }
338
339
  } catch (llmError) {
339
340
  logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
340
- settle(resolve, { raw: stdout, parsed: false });
341
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
341
342
  }
342
343
  })();
343
344
  }
@@ -424,9 +425,10 @@ class GeminiProvider extends AIProvider {
424
425
  return extracted;
425
426
  }
426
427
 
427
- // If no JSON found, return the raw message
428
+ // If no JSON found, return with textContent so the caller can
429
+ // pass it (not raw JSONL stdout) to the LLM extraction fallback
428
430
  logger.warn(`${levelPrefix} Assistant message is not JSON, treating as raw text`);
429
- return { success: false, error: 'Assistant message is not valid JSON' };
431
+ return { success: false, error: 'Assistant message is not valid JSON', textContent: assistantText };
430
432
  }
431
433
 
432
434
  // No assistant message found, try extracting JSON directly from stdout
@@ -255,24 +255,25 @@ class OpenCodeProvider extends AIProvider {
255
255
  } else {
256
256
  // Regex extraction failed, try LLM-based extraction as fallback
257
257
  logger.warn(`${levelPrefix} Regex extraction failed: ${parsed.error}`);
258
- logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
258
+ const llmFallbackInput = parsed.textContent || stdout;
259
+ logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
259
260
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
260
261
 
261
262
  // Use async IIFE to handle the async LLM extraction
262
263
  (async () => {
263
264
  try {
264
- const llmExtracted = await this.extractJSONWithLLM(stdout, { level, analysisId, registerProcess });
265
+ const llmExtracted = await this.extractJSONWithLLM(llmFallbackInput, { level, analysisId, registerProcess });
265
266
  if (llmExtracted.success) {
266
267
  logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
267
268
  settle(resolve, llmExtracted.data);
268
269
  } else {
269
270
  logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
270
- logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
271
- settle(resolve, { raw: stdout, parsed: false });
271
+ logger.info(`${levelPrefix} Raw response preview: ${llmFallbackInput.substring(0, 500)}...`);
272
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
272
273
  }
273
274
  } catch (llmError) {
274
275
  logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
275
- settle(resolve, { raw: stdout, parsed: false });
276
+ settle(resolve, { raw: llmFallbackInput, parsed: false });
276
277
  }
277
278
  })();
278
279
  }
@@ -495,9 +496,10 @@ class OpenCodeProvider extends AIProvider {
495
496
  return extracted;
496
497
  }
497
498
 
498
- // If no JSON found, return the raw text
499
+ // If no JSON found, return with textContent so the caller can
500
+ // pass it (not raw JSONL stdout) to the LLM extraction fallback
499
501
  logger.warn(`${levelPrefix} Text content is not JSON, treating as raw text`);
500
- return { success: false, error: 'Text content is not valid JSON' };
502
+ return { success: false, error: 'Text content is not valid JSON', textContent };
501
503
  }
502
504
 
503
505
  // No text content found, try extracting JSON directly from stdout
@@ -2,9 +2,18 @@
2
2
  const logger = require('./logger');
3
3
 
4
4
  /**
5
- * Extract JSON from text responses using multiple strategies
6
- * This is a shared utility to ensure consistent JSON extraction across the application
7
- * @param {string} response - Raw response text
5
+ * Extract JSON from text responses using multiple strategies.
6
+ * This is a shared utility to ensure consistent JSON extraction across the application.
7
+ *
8
+ * Strategies are tried in order:
9
+ * 1. Markdown code blocks (```json ... ```)
10
+ * 2. Direct JSON.parse of the trimmed response
11
+ * 3. First { to last } substring
12
+ * 4. Known JSON key anchors (e.g. {"level", {"suggestions")
13
+ * 5. Forward scan: try JSON.parse from every top-level { in the text
14
+ * 6. Bracket-matched substring from the first {
15
+ *
16
+ * @param {string} response - Raw response text (may include preamble/postamble prose)
8
17
  * @param {string|number} level - Level identifier for logging (e.g., 1, 2, 3, 'orchestration', 'unknown')
9
18
  * @returns {Object} Extraction result with success flag and data/error
10
19
  */
@@ -35,51 +44,132 @@ function extractJSON(response, level = 'unknown') {
35
44
  }
36
45
  throw new Error('No JSON code block found');
37
46
  },
38
-
39
- // Strategy 2: Look for JSON between first { and last }
47
+
48
+ // Strategy 2: Try the entire response as JSON (fast path for clean responses)
49
+ () => {
50
+ return JSON.parse(response.trim());
51
+ },
52
+
53
+ // Strategy 3: Look for JSON between first { and last }
54
+ // Works when the response is just JSON or has minimal wrapping
40
55
  () => {
41
56
  const firstBrace = response.indexOf('{');
42
57
  const lastBrace = response.lastIndexOf('}');
43
- if (firstBrace !== -1 && lastBrace !== -1 && lastBrace >= firstBrace) {
58
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
44
59
  return JSON.parse(response.substring(firstBrace, lastBrace + 1));
45
60
  }
46
61
  throw new Error('No valid JSON braces found');
47
62
  },
48
-
49
- // Strategy 3: Try to find JSON-like structure with bracket matching
63
+
64
+ // Strategy 4: Anchor-based extraction look for known JSON key patterns
65
+ // that mark the start of our expected response structures.
66
+ // This handles the common case where preamble text contains { characters
67
+ // (e.g. LLM discussing code: "the function handleEvent(event) { ... }")
68
+ // which would cause Strategy 3 to grab the wrong first brace.
50
69
  () => {
51
- const jsonMatch = response.match(/\{[\s\S]*\}/);
52
- if (jsonMatch) {
53
- // Try to find the complete JSON by matching brackets
54
- const jsonStr = jsonMatch[0];
55
- let braceCount = 0;
56
- let endIndex = -1;
57
- const maxIterations = Math.min(jsonStr.length, 100000); // Prevent infinite loops
58
-
59
- for (let i = 0; i < maxIterations; i++) {
60
- if (jsonStr[i] === '{') braceCount++;
61
- else if (jsonStr[i] === '}') {
62
- braceCount--;
63
- if (braceCount === 0) {
64
- endIndex = i;
65
- break;
70
+ // Look for patterns that start our expected JSON structures
71
+ const anchors = [
72
+ /\{"level"\s*:/,
73
+ /\{"suggestions"\s*:/,
74
+ /\{"fileLevelSuggestions"\s*:/,
75
+ /\{"summary"\s*:/,
76
+ /\{"overview"\s*:/,
77
+ ];
78
+
79
+ for (const anchor of anchors) {
80
+ const match = response.match(anchor);
81
+ if (match) {
82
+ const startIdx = match.index;
83
+ // Find the matching closing brace from the end
84
+ const lastBrace = response.lastIndexOf('}');
85
+ if (lastBrace > startIdx) {
86
+ const candidate = response.substring(startIdx, lastBrace + 1);
87
+ return JSON.parse(candidate);
88
+ }
89
+ }
90
+ }
91
+ throw new Error('No known JSON anchor found');
92
+ },
93
+
94
+ // Strategy 5: Forward scan — try JSON.parse starting from each { in the text.
95
+ // Handles arbitrary preamble text with braces by trying every { as a potential
96
+ // JSON start. Stops at the first successful parse.
97
+ () => {
98
+ let searchFrom = 0;
99
+ // Limit attempts to avoid excessive parsing on very large non-JSON text
100
+ const maxAttempts = 20;
101
+ let attempts = 0;
102
+ const lastBrace = response.lastIndexOf('}');
103
+
104
+ while (searchFrom < response.length && attempts < maxAttempts) {
105
+ const braceIdx = response.indexOf('{', searchFrom);
106
+ if (braceIdx === -1) break;
107
+
108
+ attempts++;
109
+ try {
110
+ // Try parsing from this brace to the end of the response.
111
+ // JSON.parse is lenient about trailing content only if we trim to the
112
+ // right boundary, so use lastIndexOf('}') from the end.
113
+ if (lastBrace > braceIdx) {
114
+ const candidate = response.substring(braceIdx, lastBrace + 1);
115
+ const parsed = JSON.parse(candidate);
116
+ if (parsed && typeof parsed === 'object') {
117
+ return parsed;
66
118
  }
67
119
  }
120
+ } catch {
121
+ // This { wasn't the start of valid JSON, try the next one
68
122
  }
69
-
70
- if (endIndex > -1) {
71
- return JSON.parse(jsonStr.substring(0, endIndex + 1));
123
+ searchFrom = braceIdx + 1;
124
+ }
125
+ throw new Error('Forward scan found no valid JSON');
126
+ },
127
+
128
+ // Strategy 6: Bracket-matched substring from the first {.
129
+ // Counts balanced braces (ignoring those inside JSON strings) to find
130
+ // the end of the first top-level object. No iteration cap — the loop
131
+ // runs for the full length of the matched region.
132
+ () => {
133
+ const firstBrace = response.indexOf('{');
134
+ if (firstBrace === -1) throw new Error('No opening brace found');
135
+
136
+ let braceCount = 0;
137
+ let inString = false;
138
+ let escaped = false;
139
+
140
+ for (let i = firstBrace; i < response.length; i++) {
141
+ const ch = response[i];
142
+
143
+ if (escaped) {
144
+ escaped = false;
145
+ continue;
146
+ }
147
+
148
+ if (ch === '\\' && inString) {
149
+ escaped = true;
150
+ continue;
151
+ }
152
+
153
+ if (ch === '"') {
154
+ inString = !inString;
155
+ continue;
156
+ }
157
+
158
+ if (inString) continue;
159
+
160
+ if (ch === '{') braceCount++;
161
+ else if (ch === '}') {
162
+ braceCount--;
163
+ if (braceCount === 0) {
164
+ return JSON.parse(response.substring(firstBrace, i + 1));
165
+ }
72
166
  }
73
167
  }
74
168
  throw new Error('No balanced JSON structure found');
75
169
  },
76
-
77
- // Strategy 4: Try the entire response as JSON (for simple cases)
78
- () => {
79
- return JSON.parse(response.trim());
80
- }
81
170
  ];
82
171
 
172
+ const strategyErrors = [];
83
173
  for (let i = 0; i < strategies.length; i++) {
84
174
  try {
85
175
  const data = strategies[i]();
@@ -88,17 +178,17 @@ function extractJSON(response, level = 'unknown') {
88
178
  return { success: true, data };
89
179
  }
90
180
  } catch (error) {
91
- // Continue to next strategy
92
- if (i === strategies.length - 1) {
93
- // Last strategy failed, log the error
94
- logger.warn(`${levelPrefix} All JSON extraction strategies failed`);
95
- logger.warn(`${levelPrefix} Response preview: ${response.substring(0, 200)}...`);
96
- }
181
+ strategyErrors.push(`S${i + 1}: ${error.message}`);
97
182
  }
98
183
  }
99
184
 
100
- return {
101
- success: false,
185
+ // All strategies failed — log details for debugging
186
+ logger.warn(`${levelPrefix} All JSON extraction strategies failed`);
187
+ logger.warn(`${levelPrefix} Strategy errors: ${strategyErrors.join('; ')}`);
188
+ logger.warn(`${levelPrefix} Response length: ${response.length} chars, preview: ${response.substring(0, 200)}...`);
189
+
190
+ return {
191
+ success: false,
102
192
  error: 'Failed to extract JSON from response',
103
193
  response: response.substring(0, 500) // Include preview for debugging
104
194
  };