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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pair-programmer",
3
- "version": "0.1.6",
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",
@@ -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
- const oldLines = before.source.split("\n");
738
- const newLines = after.source.split("\n");
739
- let start = 0;
740
- while (start < oldLines.length && oldLines.at(start) === newLines.at(start)) {
741
- start += 1;
742
- }
743
- let oldEnd = oldLines.length;
744
- let newEnd = newLines.length;
745
- while (
746
- oldEnd > start &&
747
- newEnd > start &&
748
- oldLines[oldEnd - 1] === newLines[newEnd - 1]
749
- ) {
750
- oldEnd -= 1;
751
- newEnd -= 1;
752
- }
753
- const contextStart = Math.max(0, start - 3);
754
- const contextEnd = Math.min(oldLines.length - oldEnd, 3);
755
- const changes = [
756
- ...oldLines.slice(contextStart, start).map((text) => ` ${text}`),
757
- ...oldLines.slice(start, oldEnd).map((text) => `-${text}`),
758
- ...newLines.slice(start, newEnd).map((text) => `+${text}`),
759
- ...oldLines.slice(oldEnd, oldEnd + contextEnd).map((text) => ` ${text}`),
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
- evidence.diff = sourceDiff(original, evidence.after);
832
+ const diff = sourceDiff(original, evidence.after);
837
833
  evidence.status = "available";
838
- evidence.reason = null;
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
@@ -141,7 +141,6 @@ const reasonCodes = {
141
141
  too_many_findings: true,
142
142
  judged: true,
143
143
  evidence_unavailable: true,
144
- no_diff: true,
145
144
  no_origin: true,
146
145
  response_invalid: true,
147
146
  request_failed: true,
package/src/pair-stats.ts CHANGED
@@ -59,7 +59,6 @@ const Attribution = z.object({
59
59
  reason: z.enum([
60
60
  "judged",
61
61
  "evidence_unavailable",
62
- "no_diff",
63
62
  "no_origin",
64
63
  "response_invalid",
65
64
  "request_failed",
@@ -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 { ChangeEvidence } from "./change-evidence.js";
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
- `Evaluate the current edit to ${request.file} against the criterion. Line numbers refer to the numbered excerpt below. Report only criterion violations introduced by the current edit.\n\n${numbered}`,
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
  }