pi-pair-programmer 0.1.6 → 0.1.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 +2 -1
- package/src/change-evidence.ts +137 -33
- package/src/index.ts +2 -0
- package/src/logger.ts +0 -1
- package/src/pair-stats.ts +0 -1
- package/src/review-runner.ts +66 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pair-programmer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "A TypeScript extension for Pi and OMP",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@typesafe-ai/sdk": "^0.6.0",
|
|
67
|
+
"diff": "^9.0.0",
|
|
67
68
|
"minimatch": "^10.2.6",
|
|
68
69
|
"pino": "^10.3.1",
|
|
69
70
|
"typebox": "^1.3.34",
|
package/src/change-evidence.ts
CHANGED
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { promisify } from "node:util";
|
|
16
|
+
import { createTwoFilesPatch, diffArrays, FILE_HEADERS_ONLY } from "diff";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
|
|
18
19
|
const execute = promisify(execFile);
|
|
19
20
|
const NoMatchSchema = z.object({ code: z.literal(1), stderr: z.string() });
|
|
20
21
|
const MAX_FILE_BYTES = 64_000;
|
|
22
|
+
const MAX_EDIT_DISTANCE = 1000;
|
|
21
23
|
const MAX_ENTRIES = 10_000;
|
|
22
24
|
const MAX_LISTING_BYTES = 256_000_000;
|
|
23
25
|
const CAPTURE_TIMEOUT_MS = 5000;
|
|
@@ -733,32 +735,30 @@ function matchingOrigins(
|
|
|
733
735
|
return [...matches.values()];
|
|
734
736
|
}
|
|
735
737
|
|
|
736
|
-
function sourceDiff(before: SourceFile, after: SourceFile): string {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
];
|
|
761
|
-
return `--- ${before.file}\n+++ ${after.file}\n@@ -${String(contextStart + 1)},${String(oldEnd - contextStart + contextEnd)} +${String(contextStart + 1)},${String(newEnd - contextStart + contextEnd)} @@\n${changes.join("\n")}`;
|
|
738
|
+
function sourceDiff(before: SourceFile, after: SourceFile): string | undefined {
|
|
739
|
+
return createTwoFilesPatch(
|
|
740
|
+
before.file,
|
|
741
|
+
after.file,
|
|
742
|
+
before.source,
|
|
743
|
+
after.source,
|
|
744
|
+
undefined,
|
|
745
|
+
undefined,
|
|
746
|
+
{
|
|
747
|
+
context: 3,
|
|
748
|
+
maxEditLength: MAX_EDIT_DISTANCE,
|
|
749
|
+
headerOptions: FILE_HEADERS_ONLY,
|
|
750
|
+
},
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function baselineFile(
|
|
755
|
+
baseline: TaskBaseline | undefined,
|
|
756
|
+
file: string,
|
|
757
|
+
): string | undefined {
|
|
758
|
+
return baseline === undefined
|
|
759
|
+
? undefined
|
|
760
|
+
: (relativeFile(baseline.root, file) ??
|
|
761
|
+
relativeFile(baseline.requestedRoot, file));
|
|
762
762
|
}
|
|
763
763
|
|
|
764
764
|
export async function buildChangeEvidence(
|
|
@@ -769,11 +769,7 @@ export async function buildChangeEvidence(
|
|
|
769
769
|
quote: string,
|
|
770
770
|
signal?: AbortSignal,
|
|
771
771
|
): Promise<ChangeEvidence> {
|
|
772
|
-
const relative =
|
|
773
|
-
baseline === undefined
|
|
774
|
-
? undefined
|
|
775
|
-
: (relativeFile(baseline.root, file) ??
|
|
776
|
-
relativeFile(baseline.requestedRoot, file));
|
|
772
|
+
const relative = baselineFile(baseline, file);
|
|
777
773
|
const evidence: ChangeEvidence = {
|
|
778
774
|
status: "unavailable",
|
|
779
775
|
before: null,
|
|
@@ -833,8 +829,116 @@ export async function buildChangeEvidence(
|
|
|
833
829
|
"No substantial matching baseline block or same-file source";
|
|
834
830
|
return evidence;
|
|
835
831
|
}
|
|
836
|
-
|
|
832
|
+
const diff = sourceDiff(original, evidence.after);
|
|
837
833
|
evidence.status = "available";
|
|
838
|
-
evidence.
|
|
834
|
+
evidence.diff = diff ?? null;
|
|
835
|
+
evidence.reason =
|
|
836
|
+
diff === undefined
|
|
837
|
+
? "The line diff was too large to compute; compare taskStartSource with currentSource directly"
|
|
838
|
+
: null;
|
|
839
839
|
return evidence;
|
|
840
840
|
}
|
|
841
|
+
|
|
842
|
+
export interface LineHunk {
|
|
843
|
+
start: number;
|
|
844
|
+
added: number;
|
|
845
|
+
removed: readonly string[];
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export type LineChanges =
|
|
849
|
+
| { status: "available"; hunks: readonly LineHunk[] }
|
|
850
|
+
| { status: "approximate"; region: LineHunk }
|
|
851
|
+
| { status: "unavailable"; reason: string };
|
|
852
|
+
|
|
853
|
+
function compareLines(before: string, after: string): LineChanges {
|
|
854
|
+
const oldLines = before.split("\n");
|
|
855
|
+
const newLines = after.split("\n");
|
|
856
|
+
let start = 0;
|
|
857
|
+
while (
|
|
858
|
+
start < oldLines.length &&
|
|
859
|
+
start < newLines.length &&
|
|
860
|
+
oldLines.at(start) === newLines.at(start)
|
|
861
|
+
) {
|
|
862
|
+
start += 1;
|
|
863
|
+
}
|
|
864
|
+
let oldEnd = oldLines.length;
|
|
865
|
+
let newEnd = newLines.length;
|
|
866
|
+
while (
|
|
867
|
+
oldEnd > start &&
|
|
868
|
+
newEnd > start &&
|
|
869
|
+
oldLines[oldEnd - 1] === newLines[newEnd - 1]
|
|
870
|
+
) {
|
|
871
|
+
oldEnd -= 1;
|
|
872
|
+
newEnd -= 1;
|
|
873
|
+
}
|
|
874
|
+
const removed = oldLines.slice(start, oldEnd);
|
|
875
|
+
const added = newLines.slice(start, newEnd);
|
|
876
|
+
const changes = diffArrays(removed, added, {
|
|
877
|
+
maxEditLength: MAX_EDIT_DISTANCE,
|
|
878
|
+
});
|
|
879
|
+
if (changes === undefined)
|
|
880
|
+
return {
|
|
881
|
+
status: "approximate",
|
|
882
|
+
region: { start: start + 1, added: added.length, removed },
|
|
883
|
+
};
|
|
884
|
+
const hunks: LineHunk[] = [];
|
|
885
|
+
let line = start + 1;
|
|
886
|
+
let hunk: { start: number; added: number; removed: string[] } | undefined;
|
|
887
|
+
for (const change of changes) {
|
|
888
|
+
if (!change.added && !change.removed) {
|
|
889
|
+
hunk = undefined;
|
|
890
|
+
line += change.count;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (hunk === undefined) {
|
|
894
|
+
hunk = { start: line, added: 0, removed: [] };
|
|
895
|
+
hunks.push(hunk);
|
|
896
|
+
}
|
|
897
|
+
if (change.added) {
|
|
898
|
+
hunk.added += change.count;
|
|
899
|
+
line += change.count;
|
|
900
|
+
} else {
|
|
901
|
+
hunk.removed.push(...change.value);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return { status: "available", hunks };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export async function lineChanges(
|
|
908
|
+
baseline: TaskBaseline | undefined,
|
|
909
|
+
file: string,
|
|
910
|
+
source: string,
|
|
911
|
+
signal?: AbortSignal,
|
|
912
|
+
): Promise<LineChanges> {
|
|
913
|
+
if (baseline === undefined)
|
|
914
|
+
return { status: "unavailable", reason: "Task baseline is unavailable" };
|
|
915
|
+
const relative = baselineFile(baseline, file);
|
|
916
|
+
if (
|
|
917
|
+
relative === undefined ||
|
|
918
|
+
!eligible(relative) ||
|
|
919
|
+
baseline.unattributable(relative)
|
|
920
|
+
)
|
|
921
|
+
return {
|
|
922
|
+
status: "unavailable",
|
|
923
|
+
reason: "File is not covered by the task baseline",
|
|
924
|
+
};
|
|
925
|
+
if (Buffer.byteLength(source) > MAX_FILE_BYTES)
|
|
926
|
+
return { status: "unavailable", reason: "Current source is oversized" };
|
|
927
|
+
let before: BeforeState;
|
|
928
|
+
try {
|
|
929
|
+
before = await baseline.before(relative, signal);
|
|
930
|
+
} catch {
|
|
931
|
+
return { status: "unavailable", reason: "Task baseline could not be read" };
|
|
932
|
+
}
|
|
933
|
+
if (before.state === "unknown")
|
|
934
|
+
return {
|
|
935
|
+
status: "unavailable",
|
|
936
|
+
reason: "Task-start state of this file is unknown",
|
|
937
|
+
};
|
|
938
|
+
return before.state === "absent"
|
|
939
|
+
? {
|
|
940
|
+
status: "available",
|
|
941
|
+
hunks: [{ start: 1, added: source.split("\n").length, removed: [] }],
|
|
942
|
+
}
|
|
943
|
+
: compareLines(before.source, source);
|
|
944
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { z } from "zod";
|
|
|
10
10
|
import {
|
|
11
11
|
buildChangeEvidence,
|
|
12
12
|
captureBaseline,
|
|
13
|
+
lineChanges,
|
|
13
14
|
type TaskBaseline,
|
|
14
15
|
} from "./change-evidence.js";
|
|
15
16
|
import { FindingAdmission, findingId } from "./finding-admission.js";
|
|
@@ -455,6 +456,7 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
455
456
|
prompt: job.reviewer.prompt,
|
|
456
457
|
file: job.file,
|
|
457
458
|
source: job.source,
|
|
459
|
+
changes: await lineChanges(baseline, job.file, job.source, signal),
|
|
458
460
|
signal,
|
|
459
461
|
onModelCall,
|
|
460
462
|
onJudgment,
|
package/src/logger.ts
CHANGED
package/src/pair-stats.ts
CHANGED
package/src/review-runner.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { choice, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ChangeEvidence,
|
|
6
|
+
LineChanges,
|
|
7
|
+
LineHunk,
|
|
8
|
+
} from "./change-evidence.js";
|
|
5
9
|
import {
|
|
6
10
|
notify,
|
|
7
11
|
observeJudgment,
|
|
@@ -84,11 +88,69 @@ interface ReviewRequest {
|
|
|
84
88
|
prompt: string;
|
|
85
89
|
file: string;
|
|
86
90
|
source: string;
|
|
91
|
+
changes: LineChanges;
|
|
87
92
|
signal: AbortSignal;
|
|
88
93
|
onModelCall?: ModelCallObserver;
|
|
89
94
|
onJudgment?: JudgmentObserver;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
function numberedExcerpt(
|
|
98
|
+
lines: readonly string[],
|
|
99
|
+
changes: LineChanges,
|
|
100
|
+
truncated: boolean,
|
|
101
|
+
): string {
|
|
102
|
+
const changed = new Set<number>();
|
|
103
|
+
const removed = new Map<number, readonly string[]>();
|
|
104
|
+
for (const hunk of markedHunks(changes)) {
|
|
105
|
+
for (let line = hunk.start; line < hunk.start + hunk.added; line += 1)
|
|
106
|
+
changed.add(line);
|
|
107
|
+
removed.set(hunk.start, hunk.removed);
|
|
108
|
+
}
|
|
109
|
+
const marker = changes.status === "approximate" ? "~" : "+";
|
|
110
|
+
const rows: string[] = [];
|
|
111
|
+
const pushRemoved = (line: number): void => {
|
|
112
|
+
for (const text of removed.get(line) ?? []) rows.push(`-: ${text}`);
|
|
113
|
+
};
|
|
114
|
+
for (const [index, text] of lines.entries()) {
|
|
115
|
+
const line = index + 1;
|
|
116
|
+
pushRemoved(line);
|
|
117
|
+
rows.push(`${String(line)}${changed.has(line) ? marker : ""}: ${text}`);
|
|
118
|
+
}
|
|
119
|
+
if (!truncated) pushRemoved(lines.length + 1);
|
|
120
|
+
return rows.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function markedHunks(changes: LineChanges): readonly LineHunk[] {
|
|
124
|
+
if (changes.status === "available") return changes.hunks;
|
|
125
|
+
return changes.status === "approximate" ? [changes.region] : [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const changeOnlyScope = `Report only violations the changes cause. That includes violations on changed lines, and unchanged code the changes left inconsistent: code that changed lines now duplicate, contradict, or break, or code the changes updated elsewhere but missed here (cite whichever line shows it best). Violations already present at task start that the changes neither create nor worsen are out of scope, even next to changed lines.`;
|
|
129
|
+
|
|
130
|
+
function changeScope(file: string, changes: LineChanges): string {
|
|
131
|
+
if (changes.status === "unavailable")
|
|
132
|
+
return `Review ${file} against the criterion. Which lines changed since task start is unknown, so evaluate the whole file.`;
|
|
133
|
+
const review = `Review the changes made to ${file} since the task started against the criterion. The excerpt below is the current file.`;
|
|
134
|
+
if (changes.status === "approximate")
|
|
135
|
+
return `${review} The exact changed lines could not be computed. A gutter of "N~:" marks line N inside one region that contains every change since task start but may also contain unchanged lines, "N:" marks an unchanged line outside that region, and "-:" lists the task-start lines that region replaced. Removed lines no longer exist, so never cite them. Compare the "N~:" lines with the "-:" lines to tell which of them changed; a line is not changed merely because it is marked "N~:". ${changeOnlyScope}`;
|
|
136
|
+
const legend = `${review} A gutter of "N+:" marks line N as added or changed since task start, "N:" marks an unchanged line, and "-:" shows a task-start line removed at that position. Removed lines no longer exist, so never cite them.`;
|
|
137
|
+
return changes.hunks.length === 0
|
|
138
|
+
? `${legend} No line has changed since task start.`
|
|
139
|
+
: `${legend} ${changeOnlyScope}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reviewInstructions(
|
|
143
|
+
file: string,
|
|
144
|
+
changes: LineChanges,
|
|
145
|
+
lastLine: number | undefined,
|
|
146
|
+
): string {
|
|
147
|
+
const truncation =
|
|
148
|
+
lastLine === undefined
|
|
149
|
+
? ""
|
|
150
|
+
: ` The excerpt stops at line ${String(lastLine)}; the rest of the file is omitted.`;
|
|
151
|
+
return `${changeScope(file, changes)} Line numbers refer to the numbered excerpt below; cite the line number and quote code text only, never the gutter.${truncation}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
92
154
|
function invoke(
|
|
93
155
|
args: string[],
|
|
94
156
|
cwd: string,
|
|
@@ -200,10 +262,9 @@ export async function reviewFile(
|
|
|
200
262
|
request: ReviewRequest,
|
|
201
263
|
): Promise<ProposedFinding[]> {
|
|
202
264
|
const excerpt = request.source.slice(0, MAX_SOURCE_CHARACTERS);
|
|
265
|
+
const truncated = excerpt.length < request.source.length;
|
|
203
266
|
const lines = excerpt.split("\n");
|
|
204
|
-
const numbered = lines
|
|
205
|
-
.map((line, index) => `${String(index + 1)}: ${line}`)
|
|
206
|
-
.join("\n");
|
|
267
|
+
const numbered = numberedExcerpt(lines, request.changes, truncated);
|
|
207
268
|
const result = await complete(
|
|
208
269
|
request.host,
|
|
209
270
|
request.cwd,
|
|
@@ -235,7 +296,7 @@ Return only JSON: {"findings":[{"line":1,"title":"specific criterion violation",
|
|
|
235
296
|
Return at most ${String(MAX_FINDINGS)} findings.
|
|
236
297
|
Return {"findings":[]} when the criterion is satisfied or the evidence is insufficient. An empty result is a successful review.
|
|
237
298
|
Reply with the JSON object alone, without any other text.`,
|
|
238
|
-
|
|
299
|
+
`${reviewInstructions(request.file, request.changes, truncated ? lines.length : undefined)}\n\n${numbered}`,
|
|
239
300
|
request.signal,
|
|
240
301
|
request.onModelCall,
|
|
241
302
|
);
|
|
@@ -376,7 +437,6 @@ async function attribute(request: AttributionRequest): Promise<Attribution> {
|
|
|
376
437
|
evidenceReason: evidence.reason,
|
|
377
438
|
});
|
|
378
439
|
if (evidence.status !== "available") return keep("evidence_unavailable");
|
|
379
|
-
if (evidence.diff === null) return keep("no_diff");
|
|
380
440
|
if (evidence.before === null && evidence.origins.length === 0) {
|
|
381
441
|
return keep("no_origin");
|
|
382
442
|
}
|