@in-the-loop-labs/pair-review 3.3.5 → 3.3.7

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.
@@ -25,7 +25,7 @@ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
25
25
  const logger = require('../utils/logger');
26
26
  const { extractJSON } = require('../utils/json-extractor');
27
27
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
28
- const { StreamParser, parsePiLine, createPiLineParser } = require('./stream-parser');
28
+ const { createPiLineParser } = require('./stream-parser');
29
29
 
30
30
  // Directory containing bin scripts (git-diff-lines, etc.)
31
31
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -42,6 +42,16 @@ const REVIEW_SKILL_PATH = path.join(__dirname, '..', '..', '.pi', 'skills', 'rev
42
42
  // in parallel for diverse multi-perspective code review
43
43
  const ROULETTE_SKILL_PATH = path.join(__dirname, '..', '..', '.pi', 'skills', 'review-roulette', 'SKILL.md');
44
44
 
45
+ // Keep raw stream capture bounded so large JSONL sessions cannot exhaust V8's
46
+ // maximum string size. Assistant text is still extracted incrementally from all
47
+ // complete JSONL lines and used as the primary parse/fallback input.
48
+ const MAX_PI_CAPTURED_STDOUT_CHARS = 5 * 1024 * 1024;
49
+ const MAX_PI_CAPTURED_STDERR_CHARS = 1 * 1024 * 1024;
50
+ const MAX_PI_LINE_CHARS = 2 * 1024 * 1024;
51
+ const PI_STDERR_HEAD_CHARS = 128 * 1024;
52
+ const PI_STDERR_TAIL_CHARS = MAX_PI_CAPTURED_STDERR_CHARS - PI_STDERR_HEAD_CHARS;
53
+ const PI_TRUNCATED_LINE_MARKER = '...[line truncated]...';
54
+
45
55
  /**
46
56
  * Pi model definitions
47
57
  *
@@ -122,6 +132,324 @@ function extractAssistantText(content, seenTexts) {
122
132
  return text;
123
133
  }
124
134
 
135
+ /**
136
+ * Determine whether a parsed JSON object looks like a Pi JSONL event envelope
137
+ * rather than a final review result payload.
138
+ *
139
+ * @param {Object} value - Parsed JSON object
140
+ * @returns {boolean} True when the object appears to be a Pi event
141
+ */
142
+ function isPiEventEnvelope(value) {
143
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
144
+ return false;
145
+ }
146
+
147
+ if (typeof value.type !== 'string') {
148
+ return false;
149
+ }
150
+
151
+ if (
152
+ value.message ||
153
+ Array.isArray(value.messages) ||
154
+ value.assistantMessageEvent ||
155
+ value.toolName ||
156
+ value.toolCallId ||
157
+ Object.hasOwn(value, 'partialResult') ||
158
+ Object.hasOwn(value, 'result') ||
159
+ Object.hasOwn(value, 'version')
160
+ ) {
161
+ return true;
162
+ }
163
+
164
+ return /_(start|update|end)$/.test(value.type) || value.type === 'session';
165
+ }
166
+
167
+ /**
168
+ * Append a chunk to a captured stream buffer without exceeding the configured
169
+ * maximum. Returns the updated buffer and whether truncation occurred.
170
+ *
171
+ * @param {string} existing - Existing captured output
172
+ * @param {string} chunk - New output chunk
173
+ * @param {number} maxChars - Maximum number of chars to retain
174
+ * @returns {{value: string, truncated: boolean}}
175
+ */
176
+ function appendWithLimit(existing, chunk, maxChars) {
177
+ if (!chunk || maxChars <= 0) {
178
+ return { value: existing, truncated: false };
179
+ }
180
+
181
+ const remaining = maxChars - existing.length;
182
+ if (remaining <= 0) {
183
+ return { value: existing, truncated: true };
184
+ }
185
+
186
+ if (chunk.length <= remaining) {
187
+ return { value: existing + chunk, truncated: false };
188
+ }
189
+
190
+ return {
191
+ value: existing + chunk.slice(0, remaining),
192
+ truncated: true
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Append a chunk to a bounded head+tail buffer so error logs preserve both the
198
+ * start and end of noisy stderr output.
199
+ *
200
+ * @param {{head: string, tail: string, headFull: boolean, omittedChars: number}} buffer - Buffer state
201
+ * @param {string} chunk - New stderr chunk
202
+ * @param {number} maxHeadChars - Max chars to retain from the start
203
+ * @param {number} maxTailChars - Max chars to retain from the end
204
+ */
205
+ function appendHeadTailBuffer(buffer, chunk, maxHeadChars, maxTailChars) {
206
+ if (!chunk) return;
207
+
208
+ if (!buffer.headFull) {
209
+ const remainingHead = maxHeadChars - buffer.head.length;
210
+ if (chunk.length <= remainingHead) {
211
+ buffer.head += chunk;
212
+ if (buffer.head.length >= maxHeadChars) {
213
+ buffer.headFull = true;
214
+ }
215
+ return;
216
+ }
217
+
218
+ const safeHead = Math.max(remainingHead, 0);
219
+ buffer.head += chunk.slice(0, safeHead);
220
+ buffer.headFull = true;
221
+
222
+ const overflow = chunk.slice(safeHead);
223
+ if (overflow.length > maxTailChars) {
224
+ buffer.omittedChars += overflow.length - maxTailChars;
225
+ buffer.tail = overflow.slice(-maxTailChars);
226
+ } else {
227
+ buffer.tail = overflow;
228
+ }
229
+ return;
230
+ }
231
+
232
+ const combinedTail = buffer.tail + chunk;
233
+ if (combinedTail.length > maxTailChars) {
234
+ buffer.omittedChars += combinedTail.length - maxTailChars;
235
+ buffer.tail = combinedTail.slice(-maxTailChars);
236
+ } else {
237
+ buffer.tail = combinedTail;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Render a bounded head+tail buffer as a string for logs and error messages.
243
+ *
244
+ * @param {{head: string, tail: string, headFull: boolean, omittedChars: number}} buffer - Buffer state
245
+ * @returns {string} Formatted stderr capture
246
+ */
247
+ function formatHeadTailBuffer(buffer) {
248
+ if (buffer.omittedChars === 0) {
249
+ return `${buffer.head}${buffer.tail}`;
250
+ }
251
+
252
+ return `${buffer.head}\n...[${buffer.omittedChars} chars omitted]...\n${buffer.tail}`;
253
+ }
254
+
255
+ /**
256
+ * Extract final assistant text from a Pi JSONL event.
257
+ *
258
+ * @param {Object} event - Parsed Pi event object
259
+ * @param {Set<string>} seenTexts - Set tracking already-seen text blocks
260
+ * @returns {string} Extracted text from the event
261
+ */
262
+ function extractPiEventText(event, seenTexts) {
263
+ let text = '';
264
+
265
+ if (event.type === 'message_end' && event.message?.role === 'assistant') {
266
+ text += extractAssistantText(event.message.content, seenTexts);
267
+ }
268
+
269
+ if (event.type === 'turn_end' && event.message?.role === 'assistant') {
270
+ text += extractAssistantText(event.message.content, seenTexts);
271
+ }
272
+
273
+ if (event.type === 'agent_end' && Array.isArray(event.messages)) {
274
+ for (const msg of event.messages) {
275
+ if (msg.role === 'assistant') {
276
+ text += extractAssistantText(msg.content, seenTexts);
277
+ }
278
+ }
279
+ }
280
+
281
+ return text;
282
+ }
283
+
284
+ /**
285
+ * Accumulate only raw lines that could plausibly help the direct JSON fallback.
286
+ * Pi JSONL event envelopes are intentionally excluded because they are noisy
287
+ * transport records, not the final review result.
288
+ *
289
+ * @param {string} line - One stdout line
290
+ * @param {{rawOutput: string, rawOutputTruncated: boolean}} state - Parse state
291
+ * @param {string} levelPrefix - Prefix used in logs
292
+ * @param {{status: 'parsed', value: Object} | {status: 'failed'}} [parseResult] - Optional parse result reuse
293
+ */
294
+ function accumulatePiRawFallbackLine(line, state, levelPrefix, parseResult) {
295
+ if (!line?.trim()) return;
296
+
297
+ let parsed;
298
+ let parseFailed = false;
299
+
300
+ if (parseResult?.status === 'parsed') {
301
+ parsed = parseResult.value;
302
+ } else if (parseResult?.status === 'failed') {
303
+ parseFailed = true;
304
+ } else {
305
+ try {
306
+ parsed = JSON.parse(line);
307
+ } catch {
308
+ parseFailed = true;
309
+ }
310
+ }
311
+
312
+ if (!parseFailed && isPiEventEnvelope(parsed)) {
313
+ return;
314
+ }
315
+
316
+ const capture = appendWithLimit(state.rawOutput, `${line}\n`, MAX_PI_CAPTURED_STDOUT_CHARS);
317
+ state.rawOutput = capture.value;
318
+
319
+ if (capture.truncated && !state.rawOutputTruncated) {
320
+ state.rawOutputTruncated = true;
321
+ logger.warn(
322
+ `${levelPrefix} Pi CLI raw-output fallback exceeded ${MAX_PI_CAPTURED_STDOUT_CHARS} chars; retaining only the first ${MAX_PI_CAPTURED_STDOUT_CHARS} chars`
323
+ );
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Parse a single Pi JSONL line into accumulated assistant text.
329
+ *
330
+ * @param {string} line - One JSONL line
331
+ * @param {{textContent: string, seenTexts: Set<string>, rawOutput: string, rawOutputTruncated: boolean}} state - Parse state
332
+ * @param {string} levelPrefix - Prefix used in logs
333
+ */
334
+ function accumulatePiResponseLine(line, state, levelPrefix) {
335
+ if (!line?.trim()) return;
336
+
337
+ let parseResult;
338
+ try {
339
+ const event = JSON.parse(line);
340
+ state.textContent += extractPiEventText(event, state.seenTexts);
341
+ parseResult = { status: 'parsed', value: event };
342
+ } catch {
343
+ logger.debug(`${levelPrefix} Skipping malformed JSONL line: ${line.substring(0, 100)}`);
344
+ parseResult = { status: 'failed' };
345
+ }
346
+
347
+ accumulatePiRawFallbackLine(line, state, levelPrefix, parseResult);
348
+ }
349
+
350
+ /**
351
+ * Append stdout data to the pending JSONL line buffer while capping any single
352
+ * unterminated line to avoid retaining multi-megabyte tool payloads in memory.
353
+ *
354
+ * @param {{buffer: string, lineTruncated: boolean, warningLogged: boolean}} state - Pending line state
355
+ * @param {string} chunk - New stdout chunk
356
+ * @param {string} levelPrefix - Prefix used in logs
357
+ * @returns {string[]} Complete lines extracted from the chunk
358
+ */
359
+ function appendPiChunkToLineBuffer(state, chunk, levelPrefix) {
360
+ if (!chunk) return [];
361
+
362
+ const lines = [];
363
+ let cursor = 0;
364
+
365
+ while (cursor < chunk.length) {
366
+ if (state.lineTruncated) {
367
+ const nextNewline = chunk.indexOf('\n', cursor);
368
+ if (nextNewline === -1) {
369
+ return lines;
370
+ }
371
+
372
+ lines.push(state.buffer);
373
+ state.buffer = '';
374
+ state.lineTruncated = false;
375
+ cursor = nextNewline + 1;
376
+ continue;
377
+ }
378
+
379
+ const nextNewline = chunk.indexOf('\n', cursor);
380
+ const segmentEnd = nextNewline === -1 ? chunk.length : nextNewline;
381
+ const segment = chunk.slice(cursor, segmentEnd);
382
+ const remainingCapacity = MAX_PI_LINE_CHARS - state.buffer.length;
383
+
384
+ if (segment.length <= remainingCapacity) {
385
+ state.buffer += segment;
386
+ if (nextNewline !== -1) {
387
+ lines.push(state.buffer);
388
+ state.buffer = '';
389
+ }
390
+ cursor = segmentEnd + (nextNewline === -1 ? 0 : 1);
391
+ continue;
392
+ }
393
+
394
+ const safeCapacity = Math.max(remainingCapacity, 0);
395
+ state.buffer += segment.slice(0, safeCapacity) + PI_TRUNCATED_LINE_MARKER;
396
+ state.lineTruncated = true;
397
+
398
+ if (!state.warningLogged) {
399
+ state.warningLogged = true;
400
+ logger.warn(
401
+ `${levelPrefix} Pi CLI emitted a JSONL event longer than ${MAX_PI_LINE_CHARS} chars; truncating the pending line buffer until the next newline`
402
+ );
403
+ }
404
+
405
+ if (nextNewline !== -1) {
406
+ lines.push(state.buffer);
407
+ state.buffer = '';
408
+ state.lineTruncated = false;
409
+ cursor = nextNewline + 1;
410
+ continue;
411
+ }
412
+
413
+ return lines;
414
+ }
415
+
416
+ return lines;
417
+ }
418
+
419
+ /**
420
+ * Finalize Pi response parsing from incrementally extracted assistant text and
421
+ * a bounded raw-output fallback buffer.
422
+ *
423
+ * @param {Object} input - Parse inputs
424
+ * @param {string} input.textContent - Assistant text extracted from JSONL events
425
+ * @param {string} input.rawOutput - Bounded raw stdout capture
426
+ * @param {boolean} [input.rawOutputTruncated=false] - Whether raw stdout was truncated
427
+ * @param {string|number} level - Analysis level for logging
428
+ * @param {string} levelPrefix - Prefix used in logs
429
+ * @returns {{success: boolean, data?: Object, error?: string, textContent?: string}}
430
+ */
431
+ function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated = false }, level, levelPrefix) {
432
+ if (textContent) {
433
+ const extracted = extractJSON(textContent, level);
434
+ if (extracted.success) {
435
+ return extracted;
436
+ }
437
+
438
+ logger.warn(`${levelPrefix} Text content is not JSON, treating as raw text`);
439
+ return { success: false, error: 'Text content is not valid JSON', textContent };
440
+ }
441
+
442
+ if (rawOutputTruncated) {
443
+ logger.warn(`${levelPrefix} Pi CLI raw-output fallback was truncated before assistant text could be recovered`);
444
+ return {
445
+ success: false,
446
+ error: 'Pi CLI raw-output fallback was truncated before assistant text could be recovered'
447
+ };
448
+ }
449
+
450
+ return extractJSON(rawOutput, level);
451
+ }
452
+
125
453
  class PiProvider extends AIProvider {
126
454
  /**
127
455
  * @param {string|null} [model='default'] - Model identifier or null/undefined for default mode
@@ -298,12 +626,27 @@ class PiProvider extends AIProvider {
298
626
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
299
627
  }
300
628
 
301
- let stdout = '';
302
- let stderr = '';
629
+ const stderrCapture = {
630
+ head: '',
631
+ tail: '',
632
+ headFull: false,
633
+ omittedChars: 0
634
+ };
635
+ let stderrTruncated = false;
303
636
  let timeoutId = null;
304
637
  let settled = false; // Guard against multiple resolve/reject calls
305
- let lineBuffer = ''; // Buffer for incomplete JSONL lines
306
638
  let lineCount = 0; // Count of JSONL lines received
639
+ const lineBufferState = {
640
+ buffer: '',
641
+ lineTruncated: false,
642
+ warningLogged: false
643
+ };
644
+ const responseState = {
645
+ textContent: '',
646
+ seenTexts: new Set(),
647
+ rawOutput: '',
648
+ rawOutputTruncated: false
649
+ };
307
650
 
308
651
  const settle = (fn, value) => {
309
652
  if (settled) return;
@@ -312,12 +655,21 @@ class PiProvider extends AIProvider {
312
655
  fn(value);
313
656
  };
314
657
 
315
- // Set up side-channel stream parser for live progress events.
316
658
  // Use the buffered Pi line parser to accumulate text_delta fragments
317
659
  // before emitting, preventing the UI from being flooded with tiny updates.
318
- const streamParser = onStreamEvent
319
- ? new StreamParser(createPiLineParser(), onStreamEvent, { cwd })
320
- : null;
660
+ const streamLineParser = onStreamEvent ? createPiLineParser() : null;
661
+ const emitStreamLine = (line) => {
662
+ if (!streamLineParser || !line?.trim()) return;
663
+
664
+ const event = streamLineParser(line, { cwd });
665
+ if (!event) return;
666
+
667
+ try {
668
+ onStreamEvent(event);
669
+ } catch (error) {
670
+ logger.warn(`${levelPrefix} Pi stream event callback error: ${error.message}`);
671
+ }
672
+ };
321
673
 
322
674
  // Set timeout
323
675
  if (timeout) {
@@ -331,30 +683,27 @@ class PiProvider extends AIProvider {
331
683
  // Stream and log JSONL lines as they arrive for debugging visibility
332
684
  pi.stdout.on('data', (data) => {
333
685
  const chunk = data.toString();
334
- stdout += chunk;
335
-
336
- // Feed side-channel stream parser for live progress events
337
- if (streamParser) {
338
- streamParser.feed(chunk);
339
- }
340
-
341
- lineBuffer += chunk;
342
-
343
- // Process complete lines (JSONL - each line is a complete JSON object)
344
- const lines = lineBuffer.split('\n');
345
- // Keep the last incomplete line in the buffer
346
- lineBuffer = lines.pop() || '';
686
+ const lines = appendPiChunkToLineBuffer(lineBufferState, chunk, levelPrefix);
347
687
 
348
688
  for (const line of lines) {
349
689
  if (!line.trim()) continue;
350
690
  lineCount++;
691
+ emitStreamLine(line);
351
692
  this.logStreamLine(line, lineCount, levelPrefix);
693
+ accumulatePiResponseLine(line, responseState, levelPrefix);
352
694
  }
353
695
  });
354
696
 
355
697
  // Collect stderr
356
698
  pi.stderr.on('data', (data) => {
357
- stderr += data.toString();
699
+ const chunk = data.toString();
700
+ appendHeadTailBuffer(stderrCapture, chunk, PI_STDERR_HEAD_CHARS, PI_STDERR_TAIL_CHARS);
701
+ if (stderrCapture.omittedChars > 0 && !stderrTruncated) {
702
+ stderrTruncated = true;
703
+ logger.warn(
704
+ `${levelPrefix} Pi CLI stderr exceeded ${MAX_PI_CAPTURED_STDERR_CHARS} chars; retaining a head+tail excerpt (${stderrCapture.omittedChars} chars omitted so far)`
705
+ );
706
+ }
358
707
  });
359
708
 
360
709
  // Handle completion
@@ -362,11 +711,6 @@ class PiProvider extends AIProvider {
362
711
  cleanupTmpFile();
363
712
  if (settled) return; // Already settled by timeout or error
364
713
 
365
- // Flush any remaining stream parser buffer
366
- if (streamParser) {
367
- streamParser.flush();
368
- }
369
-
370
714
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
371
715
  const isCancellationCode = code === 143 || code === 137;
372
716
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -383,6 +727,8 @@ class PiProvider extends AIProvider {
383
727
  return;
384
728
  }
385
729
 
730
+ const stderr = formatHeadTailBuffer(stderrCapture);
731
+
386
732
  // Always log stderr if present
387
733
  if (stderr.trim()) {
388
734
  if (code !== 0) {
@@ -399,15 +745,21 @@ class PiProvider extends AIProvider {
399
745
  }
400
746
 
401
747
  // Process any remaining buffered line
402
- if (lineBuffer.trim()) {
748
+ if (lineBufferState.buffer.trim()) {
403
749
  lineCount++;
404
- this.logStreamLine(lineBuffer, lineCount, levelPrefix);
750
+ emitStreamLine(lineBufferState.buffer);
751
+ this.logStreamLine(lineBufferState.buffer, lineCount, levelPrefix);
752
+ accumulatePiResponseLine(lineBufferState.buffer, responseState, levelPrefix);
405
753
  }
406
754
 
407
755
  logger.info(`${levelPrefix} Pi CLI completed - received ${lineCount} JSONL events`);
408
756
 
409
757
  // Parse the Pi JSONL response
410
- const parsed = this.parsePiResponse(stdout, level, levelPrefix);
758
+ const parsed = finalizePiResponseParsing({
759
+ textContent: responseState.textContent,
760
+ rawOutput: responseState.rawOutput,
761
+ rawOutputTruncated: responseState.rawOutputTruncated
762
+ }, level, levelPrefix);
411
763
  if (parsed.success) {
412
764
  logger.success(`${levelPrefix} Successfully parsed JSON response`);
413
765
 
@@ -427,8 +779,8 @@ class PiProvider extends AIProvider {
427
779
  // Pass extracted text content to LLM fallback (not raw JSONL stdout).
428
780
  // The text content is the actual LLM response text extracted from JSONL
429
781
  // events and is much smaller and more relevant than the full JSONL stream.
430
- const llmFallbackInput = parsed.textContent || stdout;
431
- logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw stdout'})`);
782
+ const llmFallbackInput = parsed.textContent || responseState.rawOutput;
783
+ logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw fallback output'})`);
432
784
  logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
433
785
 
434
786
  // Use async IIFE to handle the async LLM extraction
@@ -623,55 +975,22 @@ class PiProvider extends AIProvider {
623
975
  try {
624
976
  // Split by newlines and parse each JSON line
625
977
  const lines = stdout.trim().split('\n').filter(line => line.trim());
626
- let textContent = '';
627
- const seenTexts = new Set();
978
+ const responseState = {
979
+ textContent: '',
980
+ seenTexts: new Set(),
981
+ rawOutput: '',
982
+ rawOutputTruncated: false
983
+ };
628
984
 
629
985
  for (const line of lines) {
630
- try {
631
- const event = JSON.parse(line);
632
-
633
- // Extract text from message_end events (complete assistant messages)
634
- // These contain the full message with content blocks
635
- if (event.type === 'message_end' && event.message?.role === 'assistant') {
636
- textContent += extractAssistantText(event.message.content, seenTexts);
637
- }
638
-
639
- // Also collect text from turn_end events which include the message
640
- // (dedup handled by the shared seenTexts Set)
641
- if (event.type === 'turn_end' && event.message?.role === 'assistant') {
642
- textContent += extractAssistantText(event.message.content, seenTexts);
643
- }
644
-
645
- // Fallback: agent_end events contain the full messages array
646
- if (event.type === 'agent_end' && Array.isArray(event.messages)) {
647
- for (const msg of event.messages) {
648
- if (msg.role === 'assistant') {
649
- textContent += extractAssistantText(msg.content, seenTexts);
650
- }
651
- }
652
- }
653
- } catch (lineError) {
654
- // Skip malformed lines
655
- logger.debug(`${levelPrefix} Skipping malformed JSONL line: ${line.substring(0, 100)}`);
656
- }
986
+ accumulatePiResponseLine(line, responseState, levelPrefix);
657
987
  }
658
988
 
659
- if (textContent) {
660
- // Try to extract JSON from the accumulated text content
661
- const extracted = extractJSON(textContent, level);
662
- if (extracted.success) {
663
- return extracted;
664
- }
665
-
666
- // If no JSON found, return with textContent so the caller can
667
- // pass it (not raw JSONL stdout) to the LLM extraction fallback
668
- logger.warn(`${levelPrefix} Text content is not JSON, treating as raw text`);
669
- return { success: false, error: 'Text content is not valid JSON', textContent };
670
- }
671
-
672
- // No text content found, try extracting JSON directly from stdout
673
- const extracted = extractJSON(stdout, level);
674
- return extracted;
989
+ return finalizePiResponseParsing({
990
+ textContent: responseState.textContent,
991
+ rawOutput: responseState.rawOutput,
992
+ rawOutputTruncated: responseState.rawOutputTruncated
993
+ }, level, levelPrefix);
675
994
 
676
995
  } catch (parseError) {
677
996
  // stdout might not be valid JSONL at all, try extracting JSON from it
@@ -862,4 +1181,11 @@ class PiProvider extends AIProvider {
862
1181
  registerProvider('pi', PiProvider);
863
1182
 
864
1183
  module.exports = PiProvider;
1184
+ // Test-only exports. Underscore prefix signals internal helpers that should
1185
+ // not be consumed from production code paths.
865
1186
  module.exports._extractAssistantText = extractAssistantText;
1187
+ module.exports._isPiEventEnvelope = isPiEventEnvelope;
1188
+ module.exports._appendWithLimit = appendWithLimit;
1189
+ module.exports._appendHeadTailBuffer = appendHeadTailBuffer;
1190
+ module.exports._formatHeadTailBuffer = formatHeadTailBuffer;
1191
+ module.exports._finalizePiResponseParsing = finalizePiResponseParsing;
@@ -738,8 +738,11 @@ async function testProviderAvailability(providerId, timeout = 10000) {
738
738
  }
739
739
 
740
740
  /**
741
- * Get tier for a specific model from a provider
742
- * Queries the provider's model definitions (or config overrides) to find the tier
741
+ * Get tier for a specific model from a provider.
742
+ * Queries the provider's model definitions (or config overrides) to find the tier.
743
+ * Matches against both the canonical model `id` and any `aliases` so legacy
744
+ * model IDs (e.g. `gpt-5.4` before reasoning-effort variants were introduced)
745
+ * still resolve their tier for historical analysis runs.
743
746
  * @param {string} providerId - Provider ID (e.g., 'claude', 'gemini')
744
747
  * @param {string} modelId - Model ID (e.g., 'sonnet', 'gemini-2.5-pro')
745
748
  * @returns {string|null} Tier name or null if provider or model not found
@@ -754,7 +757,7 @@ function getTierForModel(providerId, modelId) {
754
757
  const overrides = providerConfigOverrides.get(providerId);
755
758
  const models = mergeModels(ProviderClass.getModels(), overrides?.models);
756
759
 
757
- const model = models.find(m => m.id === modelId);
760
+ const model = models.find(m => m.id === modelId || m.aliases?.includes(modelId));
758
761
  return model?.tier || null;
759
762
  }
760
763
 
package/src/config.js CHANGED
@@ -517,6 +517,17 @@ function getRepoResetScript(config, repository) {
517
517
  return repoConfig?.reset_script || null;
518
518
  }
519
519
 
520
+ /**
521
+ * Gets whether updateWorktree should skip the bulk `git fetch <remote> --prune`
522
+ * @param {Object} config - Configuration object from loadConfig()
523
+ * @param {string} repository - Repository in "owner/repo" format
524
+ * @returns {boolean} - true if the bulk fetch should be skipped (default: false)
525
+ */
526
+ function getRepoSkipBulkFetch(config, repository) {
527
+ const repoConfig = getRepoConfig(config, repository);
528
+ return repoConfig?.skip_bulk_fetch === true;
529
+ }
530
+
520
531
  /**
521
532
  * Gets the configured pool size for a repository from file config only.
522
533
  * Prefer resolvePoolConfig() when DB repo_settings are available.
@@ -750,6 +761,7 @@ module.exports = {
750
761
  getRepoCheckoutTimeout,
751
762
  resolveRepoOptions,
752
763
  getRepoResetScript,
764
+ getRepoSkipBulkFetch,
753
765
  getRepoPoolSize,
754
766
  getRepoPoolFetchInterval,
755
767
  getRepoLoadSkills,
@@ -30,12 +30,16 @@ const GIT_DIFF_FLAGS_ARRAY = [
30
30
 
31
31
  /**
32
32
  * Array form for simple-git .diffSummary() calls.
33
+ * Use --numstat so simple-git parses machine-readable output with exact file paths.
34
+ * The default --stat output is display-oriented and may abbreviate long paths,
35
+ * which breaks downstream matching for generated route files and similar cases.
33
36
  * Omits --src-prefix/--dst-prefix since diffSummary doesn't output file content with prefixes.
34
37
  */
35
38
  const GIT_DIFF_SUMMARY_FLAGS_ARRAY = [
36
39
  '--no-color',
37
40
  '--no-ext-diff',
38
- '--no-relative'
41
+ '--no-relative',
42
+ '--numstat'
39
43
  ];
40
44
 
41
45
  module.exports = {
@@ -733,9 +733,11 @@ class GitWorktreeManager {
733
733
  * @param {string} repo - Repository name
734
734
  * @param {number} number - PR number
735
735
  * @param {Object} prData - PR data from GitHub API (for remote resolution)
736
+ * @param {Object} [options]
737
+ * @param {boolean} [options.skipBulkFetch=false] - Skip the bulk `git fetch <remote> --prune`; targeted base-SHA and PR-head fetches still run
736
738
  * @returns {Promise<string>} Path to updated worktree
737
739
  */
738
- async updateWorktree(owner, repo, number, prData) {
740
+ async updateWorktree(owner, repo, number, prData, options = {}) {
739
741
  const prInfo = { owner, repo, number };
740
742
  const headSha = this.getPRHeadSha(prData);
741
743
  const worktreePath = await this.getWorktreePath(prInfo);
@@ -756,9 +758,26 @@ class GitWorktreeManager {
756
758
  const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
757
759
 
758
760
  // Fetch the latest from the resolved remote (--prune removes stale
759
- // tracking refs that would otherwise block fetch on ref hierarchy conflicts)
760
- console.log(`Fetching latest changes from ${remote}...`);
761
- await worktreeGit.fetch([remote, '--prune']);
761
+ // tracking refs that would otherwise block fetch on ref hierarchy conflicts).
762
+ // Opt out via skip_bulk_fetch on very large monorepos where this is too slow;
763
+ // the targeted base-SHA and PR-head ref fetches below still run.
764
+ if (options.skipBulkFetch) {
765
+ console.log(`Skipping bulk fetch from ${remote} (skip_bulk_fetch enabled)`);
766
+ // Still fetch only the PR's base branch so ensureBaseShaAvailable does not
767
+ // have to fall back to `git fetch <remote> <sha>`, which some Git servers
768
+ // and mirrors reject (they require uploadpack.allowReachableSHA1InWant).
769
+ // This mirrors the targeted fetch used in createWorktreeForPR.
770
+ if (prData?.base_branch) {
771
+ try {
772
+ await worktreeGit.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
773
+ } catch (fetchError) {
774
+ console.warn(`Targeted base-branch fetch failed, will rely on existing refs: ${fetchError.message}`);
775
+ }
776
+ }
777
+ } else {
778
+ console.log(`Fetching latest changes from ${remote}...`);
779
+ await worktreeGit.fetch([remote, '--prune']);
780
+ }
762
781
 
763
782
  await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
764
783