@in-the-loop-labs/pair-review 3.5.1 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +603 -6
- package/public/index.html +90 -0
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/index.js +298 -25
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/github-collections.js +168 -90
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} Hunk
|
|
7
|
+
* @property {string} header - Hunk header line, e.g. "@@ -10,5 +10,7 @@".
|
|
8
|
+
* @property {string[]} lines - Diff lines including their leading marker
|
|
9
|
+
* ('+', '-', ' ', or the literal '\' marker).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const LOCKFILE_BASENAMES = new Set([
|
|
13
|
+
'package-lock.json',
|
|
14
|
+
'pnpm-lock.yaml',
|
|
15
|
+
'yarn.lock',
|
|
16
|
+
'Cargo.lock',
|
|
17
|
+
'Pipfile.lock',
|
|
18
|
+
'poetry.lock',
|
|
19
|
+
'composer.lock',
|
|
20
|
+
'go.sum'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const JS_TS_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
|
24
|
+
const PYTHON_EXTENSIONS = new Set(['.py']);
|
|
25
|
+
|
|
26
|
+
const JS_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b|(?:const|let|var)\s+\w+\s*=\s*require\()/;
|
|
27
|
+
const PY_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b)/;
|
|
28
|
+
const PACKAGE_JSON_VERSION_PATTERN = /^"([^"]+)"\s*:\s*"[~^>=<]*\d[\w.\-+*]*"\,?\s*$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SHA-256 hex of `${filePath}\n${hunkContent}`.
|
|
32
|
+
* @param {string} filePath
|
|
33
|
+
* @param {string} hunkContent
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function hashHunk(filePath, hunkContent) {
|
|
37
|
+
return crypto.createHash('sha256').update(`${filePath}\n${hunkContent}`).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getExtension(filePath) {
|
|
41
|
+
const slash = filePath.lastIndexOf('/');
|
|
42
|
+
const base = slash === -1 ? filePath : filePath.slice(slash + 1);
|
|
43
|
+
const dot = base.lastIndexOf('.');
|
|
44
|
+
if (dot <= 0) return '';
|
|
45
|
+
return base.slice(dot).toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getBasename(filePath) {
|
|
49
|
+
const slash = filePath.lastIndexOf('/');
|
|
50
|
+
return slash === -1 ? filePath : filePath.slice(slash + 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function classifyLines(lines) {
|
|
54
|
+
const added = [];
|
|
55
|
+
const removed = [];
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
if (line.startsWith('\\')) continue;
|
|
58
|
+
if (line.startsWith('+')) added.push(line.slice(1));
|
|
59
|
+
else if (line.startsWith('-')) removed.push(line.slice(1));
|
|
60
|
+
}
|
|
61
|
+
return { added, removed };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isImportOnlyReorder(added, removed, ext) {
|
|
65
|
+
let pattern;
|
|
66
|
+
if (JS_TS_EXTENSIONS.has(ext)) pattern = JS_IMPORT_PATTERN;
|
|
67
|
+
else if (PYTHON_EXTENSIONS.has(ext)) pattern = PY_IMPORT_PATTERN;
|
|
68
|
+
else return false;
|
|
69
|
+
|
|
70
|
+
if (added.length === 0 && removed.length === 0) return false;
|
|
71
|
+
|
|
72
|
+
const addedTrimmed = added.map((l) => l.trim());
|
|
73
|
+
const removedTrimmed = removed.map((l) => l.trim());
|
|
74
|
+
|
|
75
|
+
for (const line of addedTrimmed) {
|
|
76
|
+
if (!pattern.test(line)) return false;
|
|
77
|
+
}
|
|
78
|
+
for (const line of removedTrimmed) {
|
|
79
|
+
if (!pattern.test(line)) return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (addedTrimmed.length !== removedTrimmed.length) return false;
|
|
83
|
+
const a = [...addedTrimmed].sort();
|
|
84
|
+
const r = [...removedTrimmed].sort();
|
|
85
|
+
for (let i = 0; i < a.length; i++) {
|
|
86
|
+
if (a[i] !== r[i]) return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractPackageJsonVersionKey(line) {
|
|
92
|
+
const match = PACKAGE_JSON_VERSION_PATTERN.exec(line);
|
|
93
|
+
return match ? match[1] : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isVersionBumpChange(added, removed, basename) {
|
|
97
|
+
if (basename !== 'package.json' && !LOCKFILE_BASENAMES.has(basename)) return false;
|
|
98
|
+
if (added.length === 0 && removed.length === 0) return false;
|
|
99
|
+
|
|
100
|
+
if (LOCKFILE_BASENAMES.has(basename)) return true;
|
|
101
|
+
|
|
102
|
+
const addedKeys = [];
|
|
103
|
+
for (const line of added) {
|
|
104
|
+
const key = extractPackageJsonVersionKey(line.trim());
|
|
105
|
+
if (key === null) return false;
|
|
106
|
+
addedKeys.push(key);
|
|
107
|
+
}
|
|
108
|
+
const removedKeys = [];
|
|
109
|
+
for (const line of removed) {
|
|
110
|
+
const key = extractPackageJsonVersionKey(line.trim());
|
|
111
|
+
if (key === null) return false;
|
|
112
|
+
removedKeys.push(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (addedKeys.length !== removedKeys.length) return false;
|
|
116
|
+
const a = [...addedKeys].sort();
|
|
117
|
+
const r = [...removedKeys].sort();
|
|
118
|
+
for (let i = 0; i < a.length; i++) {
|
|
119
|
+
if (a[i] !== r[i]) return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify a hunk as trivial under one of several heuristics.
|
|
126
|
+
*
|
|
127
|
+
* Callers that need generated-file detection should pass
|
|
128
|
+
* isGeneratedFile: parser.isGenerated.bind(parser)
|
|
129
|
+
* where `parser` is the result of
|
|
130
|
+
* await getGeneratedFilePatterns(worktreePath)
|
|
131
|
+
* from src/git/gitattributes.js. When `isGeneratedFile` is omitted, the
|
|
132
|
+
* generated-file rule is skipped silently.
|
|
133
|
+
* @param {Hunk} hunk
|
|
134
|
+
* @param {string} filePath
|
|
135
|
+
* @param {{ isGeneratedFile?: (filePath: string) => boolean }} [options]
|
|
136
|
+
* @returns {{ trivial: boolean, reason?: 'imports'|'version_bump'|'generated' }}
|
|
137
|
+
*/
|
|
138
|
+
function isTrivialHunk(hunk, filePath, options) {
|
|
139
|
+
const opts = options || {};
|
|
140
|
+
|
|
141
|
+
if (typeof opts.isGeneratedFile === 'function' && opts.isGeneratedFile(filePath) === true) {
|
|
142
|
+
return { trivial: true, reason: 'generated' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines = hunk && Array.isArray(hunk.lines) ? hunk.lines : [];
|
|
146
|
+
const { added, removed } = classifyLines(lines);
|
|
147
|
+
|
|
148
|
+
const ext = getExtension(filePath);
|
|
149
|
+
if (isImportOnlyReorder(added, removed, ext)) {
|
|
150
|
+
return { trivial: true, reason: 'imports' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const basename = getBasename(filePath);
|
|
154
|
+
if (isVersionBumpChange(added, removed, basename)) {
|
|
155
|
+
return { trivial: true, reason: 'version_bump' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { trivial: false };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { hashHunk, isTrivialHunk };
|
package/src/ai/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
registerProvider,
|
|
14
14
|
getProviderClass,
|
|
15
15
|
getRegisteredProviderIds,
|
|
16
|
+
resolveNonExecutableProviderId,
|
|
16
17
|
getAllProvidersInfo,
|
|
17
18
|
createProvider,
|
|
18
19
|
testProviderAvailability,
|
|
@@ -60,6 +61,7 @@ module.exports = {
|
|
|
60
61
|
registerProvider,
|
|
61
62
|
getProviderClass,
|
|
62
63
|
getRegisteredProviderIds,
|
|
64
|
+
resolveNonExecutableProviderId,
|
|
63
65
|
getAllProvidersInfo,
|
|
64
66
|
|
|
65
67
|
// Factory
|
|
@@ -18,6 +18,7 @@ const logger = require('../utils/logger');
|
|
|
18
18
|
const { extractJSON } = require('../utils/json-extractor');
|
|
19
19
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
20
20
|
const { StreamParser, parseOpenCodeLine } = require('./stream-parser');
|
|
21
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
21
22
|
|
|
22
23
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
23
24
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -102,7 +103,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
102
103
|
*/
|
|
103
104
|
async execute(prompt, options = {}) {
|
|
104
105
|
return new Promise((resolve, reject) => {
|
|
105
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
106
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
106
107
|
|
|
107
108
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
108
109
|
logger.info(`${levelPrefix} Executing OpenCode CLI...`);
|
|
@@ -128,7 +129,8 @@ class OpenCodeProvider extends AIProvider {
|
|
|
128
129
|
...this.extraEnv,
|
|
129
130
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
130
131
|
},
|
|
131
|
-
shell: this.useShell
|
|
132
|
+
shell: this.useShell,
|
|
133
|
+
detached: this.useShell
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
const pid = opencode.pid;
|
|
@@ -140,6 +142,9 @@ class OpenCodeProvider extends AIProvider {
|
|
|
140
142
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
146
|
+
const abortWiring = wireAbortToChild(opencode, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
147
|
+
|
|
143
148
|
let stdout = '';
|
|
144
149
|
let stderr = '';
|
|
145
150
|
let timeoutId = null;
|
|
@@ -151,6 +156,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
151
156
|
if (settled) return;
|
|
152
157
|
settled = true;
|
|
153
158
|
if (timeoutId) clearTimeout(timeoutId);
|
|
159
|
+
abortWiring.detach();
|
|
154
160
|
fn(value);
|
|
155
161
|
};
|
|
156
162
|
|
|
@@ -201,11 +207,20 @@ class OpenCodeProvider extends AIProvider {
|
|
|
201
207
|
opencode.on('close', (code) => {
|
|
202
208
|
if (settled) return; // Already settled by timeout or error
|
|
203
209
|
|
|
210
|
+
// Detach is centralized in `settle`.
|
|
211
|
+
|
|
204
212
|
// Flush any remaining stream parser buffer
|
|
205
213
|
if (streamParser) {
|
|
206
214
|
streamParser.flush();
|
|
207
215
|
}
|
|
208
216
|
|
|
217
|
+
// BackgroundQueue-driven cancellation — mirror of claude-provider.
|
|
218
|
+
if (abortWiring.cancelled()) {
|
|
219
|
+
logger.info(`${levelPrefix} OpenCode CLI terminated by user cancel (exit code ${code})`);
|
|
220
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
209
224
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
210
225
|
const isCancellationCode = code === 143 || code === 137;
|
|
211
226
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -281,6 +296,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
281
296
|
|
|
282
297
|
// Handle errors
|
|
283
298
|
opencode.on('error', (error) => {
|
|
299
|
+
// Detach happens inside `settle`.
|
|
284
300
|
if (error.code === 'ENOENT') {
|
|
285
301
|
logger.error(`${levelPrefix} OpenCode CLI not found. Please ensure OpenCode CLI is installed.`);
|
|
286
302
|
settle(reject, new Error(`${levelPrefix} OpenCode CLI not found. ${OpenCodeProvider.getInstallInstructions()}`));
|
|
@@ -491,7 +507,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
491
507
|
|
|
492
508
|
if (textContent) {
|
|
493
509
|
// Try to extract JSON from the accumulated text content
|
|
494
|
-
const extracted = extractJSON(textContent, level);
|
|
510
|
+
const extracted = extractJSON(textContent, level, levelPrefix);
|
|
495
511
|
if (extracted.success) {
|
|
496
512
|
return extracted;
|
|
497
513
|
}
|
|
@@ -503,12 +519,12 @@ class OpenCodeProvider extends AIProvider {
|
|
|
503
519
|
}
|
|
504
520
|
|
|
505
521
|
// No text content found, try extracting JSON directly from stdout
|
|
506
|
-
const extracted = extractJSON(stdout, level);
|
|
522
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
507
523
|
return extracted;
|
|
508
524
|
|
|
509
525
|
} catch (parseError) {
|
|
510
526
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
511
|
-
const extracted = extractJSON(stdout, level);
|
|
527
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
512
528
|
if (extracted.success) {
|
|
513
529
|
return extracted;
|
|
514
530
|
}
|
package/src/ai/pi-provider.js
CHANGED
|
@@ -26,6 +26,7 @@ const logger = require('../utils/logger');
|
|
|
26
26
|
const { extractJSON } = require('../utils/json-extractor');
|
|
27
27
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
28
28
|
const { createPiLineParser } = require('./stream-parser');
|
|
29
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
29
30
|
|
|
30
31
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
31
32
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -430,7 +431,7 @@ function appendPiChunkToLineBuffer(state, chunk, levelPrefix) {
|
|
|
430
431
|
*/
|
|
431
432
|
function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated = false }, level, levelPrefix) {
|
|
432
433
|
if (textContent) {
|
|
433
|
-
const extracted = extractJSON(textContent, level);
|
|
434
|
+
const extracted = extractJSON(textContent, level, levelPrefix);
|
|
434
435
|
if (extracted.success) {
|
|
435
436
|
return extracted;
|
|
436
437
|
}
|
|
@@ -447,7 +448,7 @@ function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated
|
|
|
447
448
|
};
|
|
448
449
|
}
|
|
449
450
|
|
|
450
|
-
return extractJSON(rawOutput, level);
|
|
451
|
+
return extractJSON(rawOutput, level, levelPrefix);
|
|
451
452
|
}
|
|
452
453
|
|
|
453
454
|
class PiProvider extends AIProvider {
|
|
@@ -579,7 +580,7 @@ class PiProvider extends AIProvider {
|
|
|
579
580
|
*/
|
|
580
581
|
async execute(prompt, options = {}) {
|
|
581
582
|
return new Promise((resolve, reject) => {
|
|
582
|
-
const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
583
|
+
const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
583
584
|
|
|
584
585
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
585
586
|
logger.info(`${levelPrefix} Executing Pi CLI...`);
|
|
@@ -609,7 +610,8 @@ class PiProvider extends AIProvider {
|
|
|
609
610
|
...this.extraEnv,
|
|
610
611
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
611
612
|
},
|
|
612
|
-
shell: this.useShell
|
|
613
|
+
shell: this.useShell,
|
|
614
|
+
detached: this.useShell
|
|
613
615
|
});
|
|
614
616
|
|
|
615
617
|
// Close stdin immediately — prompt is delivered via @file, but some
|
|
@@ -626,6 +628,9 @@ class PiProvider extends AIProvider {
|
|
|
626
628
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
627
629
|
}
|
|
628
630
|
|
|
631
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
632
|
+
const abortWiring = wireAbortToChild(pi, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
633
|
+
|
|
629
634
|
const stderrCapture = {
|
|
630
635
|
head: '',
|
|
631
636
|
tail: '',
|
|
@@ -652,6 +657,7 @@ class PiProvider extends AIProvider {
|
|
|
652
657
|
if (settled) return;
|
|
653
658
|
settled = true;
|
|
654
659
|
if (timeoutId) clearTimeout(timeoutId);
|
|
660
|
+
abortWiring.detach();
|
|
655
661
|
fn(value);
|
|
656
662
|
};
|
|
657
663
|
|
|
@@ -711,6 +717,15 @@ class PiProvider extends AIProvider {
|
|
|
711
717
|
cleanupTmpFile();
|
|
712
718
|
if (settled) return; // Already settled by timeout or error
|
|
713
719
|
|
|
720
|
+
// Detach is centralized in `settle`.
|
|
721
|
+
|
|
722
|
+
// BackgroundQueue-driven cancellation — mirror of claude-provider.
|
|
723
|
+
if (abortWiring.cancelled()) {
|
|
724
|
+
logger.info(`${levelPrefix} Pi CLI terminated by user cancel (exit code ${code})`);
|
|
725
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
714
729
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
715
730
|
const isCancellationCode = code === 143 || code === 137;
|
|
716
731
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -810,6 +825,7 @@ class PiProvider extends AIProvider {
|
|
|
810
825
|
// Handle errors
|
|
811
826
|
pi.on('error', (error) => {
|
|
812
827
|
cleanupTmpFile();
|
|
828
|
+
// Detach happens inside `settle`.
|
|
813
829
|
if (error.code === 'ENOENT') {
|
|
814
830
|
logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
|
|
815
831
|
settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
|
|
@@ -994,7 +1010,7 @@ class PiProvider extends AIProvider {
|
|
|
994
1010
|
|
|
995
1011
|
} catch (parseError) {
|
|
996
1012
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
997
|
-
const extracted = extractJSON(stdout, level);
|
|
1013
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
998
1014
|
if (extracted.success) {
|
|
999
1015
|
return extracted;
|
|
1000
1016
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Hunk summary prompt builder.
|
|
4
|
+
*
|
|
5
|
+
* Pure function that produces the prompt body sent to the background provider
|
|
6
|
+
* for summarizing one file's worth of diff hunks. Output schema and length
|
|
7
|
+
* constraints are defined in plans/semantic-hunk-summaries-and-tours.md
|
|
8
|
+
* ("Prompt Design Notes" -> "Summary prompt contract").
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MAX_CHANGED_FILES_LISTED = 100;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} HunkInput
|
|
15
|
+
* @property {string} header - "@@ -10,5 +10,7 @@" line
|
|
16
|
+
* @property {string[]} lines - Diff body lines with leading +/-/space markers
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} SummaryContext
|
|
21
|
+
* @property {string} filePath - Path of the file being summarized
|
|
22
|
+
* @property {HunkInput[]} hunks - Hunks to summarize, in file order
|
|
23
|
+
* @property {string} [prTitle] - Optional PR title or local-review name
|
|
24
|
+
* @property {string} [prDescription] - Optional PR description
|
|
25
|
+
* @property {string[]} [changedFiles] - Optional list of all changed-file paths in this review (light context)
|
|
26
|
+
* @property {string} [cwd] - Optional working directory the agent is running in.
|
|
27
|
+
* When provided, the prompt invites bounded read-only file access; the path
|
|
28
|
+
* itself is NOT embedded in the prompt. Used purely as a signal flag — when
|
|
29
|
+
* omitted, the prompt does not promise read-only access at all.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns true if the value is a non-empty, non-whitespace-only string.
|
|
34
|
+
* @param {unknown} value
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function hasText(value) {
|
|
38
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the prompt body sent to the background provider for summarizing one
|
|
43
|
+
* file's worth of hunks. Returns a single string (the full prompt).
|
|
44
|
+
* @param {SummaryContext} context
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function buildHunkSummaryPrompt({ filePath, hunks, prTitle, prDescription, changedFiles, cwd } = {}) {
|
|
48
|
+
if (!hasText(filePath)) {
|
|
49
|
+
throw new TypeError('filePath is required');
|
|
50
|
+
}
|
|
51
|
+
if (hunks === undefined || hunks === null) {
|
|
52
|
+
throw new TypeError('hunks is required');
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(hunks)) {
|
|
55
|
+
throw new TypeError('hunks is required');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sections = [];
|
|
59
|
+
|
|
60
|
+
sections.push(
|
|
61
|
+
'You are summarizing changed hunks from a code review. Treat the diff text provided below as the primary source. Do NOT modify files. Do NOT run write commands (rm, mv, git commit, etc.). Produce concise natural-language summaries.'
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (hasText(cwd)) {
|
|
65
|
+
sections.push(
|
|
66
|
+
[
|
|
67
|
+
'You have read-only access to the current working directory. The diff is',
|
|
68
|
+
'your primary source. You MAY consult adjacent code ONLY when it materially',
|
|
69
|
+
'improves the description of WHAT changed:',
|
|
70
|
+
'- A symbol introduced/modified in the diff has callers or a definition',
|
|
71
|
+
' elsewhere whose existence changes the summary (e.g. "extracts a helper',
|
|
72
|
+
' now used by 4 sites" vs "adds a helper").',
|
|
73
|
+
'- The diff is locally ambiguous about what changed (e.g. a one-line',
|
|
74
|
+
' signature change whose meaning depends on the function body not in',
|
|
75
|
+
' the hunk).',
|
|
76
|
+
'',
|
|
77
|
+
'Budget per file: at most ~5 file reads, ~3 grep calls. Do not browse',
|
|
78
|
+
'broadly. Do not read tests, fixtures, or generated files unless directly',
|
|
79
|
+
'relevant. Do not modify any file.',
|
|
80
|
+
'',
|
|
81
|
+
'The summary still describes what the DIFF changes, not what the',
|
|
82
|
+
'surrounding code does. Context informs phrasing; it does not become',
|
|
83
|
+
'the subject.'
|
|
84
|
+
].join('\n')
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
sections.push(
|
|
89
|
+
[
|
|
90
|
+
'Style:',
|
|
91
|
+
'- 1–3 sentences. Aim for one; use two only when a second sentence adds',
|
|
92
|
+
' information the first cannot. Three is rare.',
|
|
93
|
+
'- Target ~200 characters; hard ceiling 400.',
|
|
94
|
+
'- State WHAT changed in the diff. Context informs phrasing; it does not',
|
|
95
|
+
' become the subject. Do not speculate beyond what code you can see makes',
|
|
96
|
+
' unambiguous.',
|
|
97
|
+
'- For mechanical changes (formatting, trivial rename), say so in one short',
|
|
98
|
+
' sentence and stop.',
|
|
99
|
+
'- Lead with a verb (Adds, Removes, Renames, Refactors, Fixes, Moves,',
|
|
100
|
+
' Inlines, Extracts).'
|
|
101
|
+
].join('\n')
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
sections.push(
|
|
105
|
+
[
|
|
106
|
+
'You MAY return summary: null for a hunk only when ALL of these hold:',
|
|
107
|
+
'- The change is purely mechanical (whitespace, import reorder, lint fix,',
|
|
108
|
+
' trivial rename) AND',
|
|
109
|
+
'- A reader scanning the diff would learn nothing from a summary.',
|
|
110
|
+
'',
|
|
111
|
+
'Default is to summarize. When in doubt, write the summary.'
|
|
112
|
+
].join('\n')
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (hasText(prTitle) || hasText(prDescription)) {
|
|
116
|
+
const contextLines = ["Author's stated intent (hint only — verify against the diff):"];
|
|
117
|
+
if (hasText(prTitle)) {
|
|
118
|
+
contextLines.push(` Title: ${prTitle.trim()}`);
|
|
119
|
+
}
|
|
120
|
+
if (hasText(prDescription)) {
|
|
121
|
+
contextLines.push(` Description: ${prDescription.trim()}`);
|
|
122
|
+
}
|
|
123
|
+
sections.push(contextLines.join('\n'));
|
|
124
|
+
|
|
125
|
+
sections.push(
|
|
126
|
+
[
|
|
127
|
+
"The author's stated intent above is a HINT — useful for orientation and",
|
|
128
|
+
'vocabulary. It is NOT verified ground truth. The diff is ground truth.',
|
|
129
|
+
'- Use the description to orient your reading and to choose vocabulary that',
|
|
130
|
+
' matches the project (e.g. domain terms).',
|
|
131
|
+
'- Do NOT repeat or paraphrase the description as the summary.',
|
|
132
|
+
'- If the diff and the description disagree, describe the diff. Do not',
|
|
133
|
+
' paper over the disagreement, and do not editorialize about it — just',
|
|
134
|
+
' state what the diff does.',
|
|
135
|
+
'- If the description is vague, templated, or empty, ignore it entirely.'
|
|
136
|
+
].join('\n')
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(changedFiles) && changedFiles.length > 0) {
|
|
141
|
+
if (changedFiles.length > MAX_CHANGED_FILES_LISTED) {
|
|
142
|
+
sections.push(
|
|
143
|
+
`Changed files in this review: ${changedFiles.length} total (list omitted for length)`
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
const fileLines = ['Changed files in this review:'];
|
|
147
|
+
for (const path of changedFiles) {
|
|
148
|
+
fileLines.push(` - ${path}`);
|
|
149
|
+
}
|
|
150
|
+
sections.push(fileLines.join('\n'));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hunkBlockLines = [`File: ${filePath}`, 'Hunks (numbered):'];
|
|
155
|
+
if (hunks.length === 0) {
|
|
156
|
+
hunkBlockLines.push('(no hunks)');
|
|
157
|
+
} else {
|
|
158
|
+
hunks.forEach((hunk, idx) => {
|
|
159
|
+
const header = hunk && typeof hunk.header === 'string' ? hunk.header : '';
|
|
160
|
+
const lines = hunk && Array.isArray(hunk.lines) ? hunk.lines : [];
|
|
161
|
+
hunkBlockLines.push(`[${idx + 1}] ${header}`);
|
|
162
|
+
if (lines.length > 0) {
|
|
163
|
+
hunkBlockLines.push(lines.join('\n'));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
sections.push(hunkBlockLines.join('\n'));
|
|
168
|
+
|
|
169
|
+
if (hunks.length === 0) {
|
|
170
|
+
sections.push(
|
|
171
|
+
[
|
|
172
|
+
'There are no hunks to summarize.',
|
|
173
|
+
'Return ONLY this JSON object:',
|
|
174
|
+
'{ "summaries": [] }',
|
|
175
|
+
'',
|
|
176
|
+
'Do not include any extra fields, explanation, or prose outside the JSON.'
|
|
177
|
+
].join('\n')
|
|
178
|
+
);
|
|
179
|
+
} else {
|
|
180
|
+
sections.push(
|
|
181
|
+
[
|
|
182
|
+
'Return ONLY a JSON object with this shape:',
|
|
183
|
+
'{ "summaries": [',
|
|
184
|
+
' { "index": 1, "summary": "Adds X to do Y." },',
|
|
185
|
+
' { "index": 2, "summary": null }',
|
|
186
|
+
'] }',
|
|
187
|
+
'',
|
|
188
|
+
'Rules:',
|
|
189
|
+
'- One entry per hunk above; index matches the [N] label.',
|
|
190
|
+
'- `summary` is `string | null` (null only per the opt-out clause above).',
|
|
191
|
+
'- Do not include any extra fields, explanation, or prose outside the JSON.'
|
|
192
|
+
].join('\n')
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return sections.join('\n\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { buildHunkSummaryPrompt };
|