@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/AIPanel.js +3 -14
- package/public/js/modules/diff-renderer.js +100 -7
- package/public/js/modules/file-comment-manager.js +34 -13
- package/public/js/modules/suggestion-manager.js +22 -6
- package/public/js/pr.js +3 -3
- package/public/js/repo-settings.js +37 -7
- package/src/ai/claude-provider.js +12 -1
- package/src/ai/codex-provider.js +134 -44
- package/src/ai/index.js +3 -1
- package/src/ai/pi-provider.js +403 -77
- package/src/ai/provider.js +6 -3
- package/src/config.js +12 -0
- package/src/git/diff-flags.js +5 -1
- package/src/git/worktree.js +23 -4
- package/src/local-review.js +26 -10
- package/src/main.js +1 -1
- package/src/routes/pr.js +26 -10
- package/src/utils/diff-file-list.js +111 -2
- package/src/utils/paths.js +4 -0
package/src/ai/pi-provider.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
748
|
+
if (lineBufferState.buffer.trim()) {
|
|
403
749
|
lineCount++;
|
|
404
|
-
|
|
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 =
|
|
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 ||
|
|
431
|
-
logger.info(`${levelPrefix} LLM fallback input length: ${llmFallbackInput.length} characters (${parsed.textContent ? 'text content' : 'raw
|
|
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
|
-
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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;
|
package/src/ai/provider.js
CHANGED
|
@@ -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,
|
package/src/git/diff-flags.js
CHANGED
|
@@ -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 = {
|
package/src/git/worktree.js
CHANGED
|
@@ -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
|
-
|
|
761
|
-
|
|
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
|
|