@mrclrchtr/supi-review 1.6.0 → 1.8.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/README.md +12 -8
- package/package.json +1 -1
- package/src/git.ts +124 -22
- package/src/history/collect.ts +101 -158
- package/src/history/synthesize.ts +14 -18
- package/src/review.ts +56 -17
- package/src/target/packet.ts +136 -86
- package/src/tool/brief-runner.ts +2 -3
- package/src/tool/review-runner.ts +98 -14
- package/src/tool/runner-types.ts +0 -6
- package/src/tool/snapshot-tools.ts +116 -0
- package/src/types.ts +0 -9
- package/src/ui/flow.ts +57 -22
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Adds an interactive `/supi-review` command to the [pi coding agent](https://gith
|
|
|
8
8
|
pi install npm:@mrclrchtr/supi-review
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
This is a **beta** package.
|
|
11
|
+
This is a **beta** package. Install individually.
|
|
12
12
|
|
|
13
13
|
For local development:
|
|
14
14
|
|
|
@@ -27,7 +27,7 @@ After install, pi gets one command:
|
|
|
27
27
|
The reviewer runs in managed child agent sessions:
|
|
28
28
|
|
|
29
29
|
- a **brief synthesizer** creates a structured review brief from the active session branch
|
|
30
|
-
- a **read-only reviewer** inspects the selected snapshot and submits structured findings
|
|
30
|
+
- a **read-only reviewer** inspects the selected snapshot (without receiving bulk inline diffs) and submits structured findings
|
|
31
31
|
|
|
32
32
|
## Review flow
|
|
33
33
|
|
|
@@ -38,8 +38,8 @@ The reviewer runs in managed child agent sessions:
|
|
|
38
38
|
3. optionally add a short note
|
|
39
39
|
4. resolve the snapshot
|
|
40
40
|
5. synthesize a review brief from the current session history
|
|
41
|
-
6. preview the synthesized brief + prompt
|
|
42
|
-
7.
|
|
41
|
+
6. preview the synthesized brief + compact prompt preview
|
|
42
|
+
7. the reviewer fetches per-file diffs on demand via snapshot-aware tools; live progress widget shows activity
|
|
43
43
|
8. show the structured result as a custom message
|
|
44
44
|
9. if findings exist, hand off to the main agent so it can ask what to do next
|
|
45
45
|
|
|
@@ -58,7 +58,8 @@ The generated review prompt is **not** just a static diff wrapper.
|
|
|
58
58
|
Before the actual review starts, the package:
|
|
59
59
|
|
|
60
60
|
- resolves the **active session branch into the current LLM-visible context**
|
|
61
|
-
-
|
|
61
|
+
- **serializes** that resolved context into a compaction-style transcript
|
|
62
|
+
- feeds the serialized transcript (plus snapshot + optional note) to a dedicated brief synthesizer
|
|
62
63
|
- synthesizes a structured brief with:
|
|
63
64
|
- summary
|
|
64
65
|
- intended outcome
|
|
@@ -69,7 +70,9 @@ Before the actual review starts, the package:
|
|
|
69
70
|
|
|
70
71
|
The synthesizer also receives a bounded diff excerpt from the snapshot so it can reason about actual code changes, not just filenames.
|
|
71
72
|
|
|
72
|
-
That synthesized brief is then combined with the git snapshot into
|
|
73
|
+
That synthesized brief is then combined with the git snapshot into a compact reviewer prompt. The prompt contains the brief, file manifest, and per-file overview, but no large inline diffs. Instead, the reviewer session gets snapshot-aware tools (`read_snapshot_diff`, `read_snapshot_file`) to fetch exact per-file diffs and before/after file contents on demand.
|
|
74
|
+
|
|
75
|
+
The session-transcript approach mirrors how Pi summarizes context for compaction: the entire resolved conversation is rendered in a readable label format and sent to the model as a whole, rather than relying on heuristic excerpt ranking.
|
|
73
76
|
|
|
74
77
|
## Model selection
|
|
75
78
|
|
|
@@ -104,9 +107,10 @@ When a successful review contains findings, `supi-review` also injects an agent-
|
|
|
104
107
|
- `src/review.ts` — command orchestration and interactive flow
|
|
105
108
|
- `src/model.ts` — explicit model selection helpers
|
|
106
109
|
- `src/git.ts` — git snapshot resolution
|
|
107
|
-
- `src/history/collect.ts` —
|
|
110
|
+
- `src/history/collect.ts` — compaction-style session-context serialization
|
|
108
111
|
- `src/history/synthesize.ts` — brief synthesis orchestration
|
|
109
112
|
- `src/target/packet.ts` — final reviewer packet builder
|
|
110
113
|
- `src/tool/brief-runner.ts` — brief synthesis child session
|
|
111
|
-
- `src/tool/review-runner.ts` — read-only reviewer child session
|
|
114
|
+
- `src/tool/review-runner.ts` — read-only reviewer child session with snapshot-aware tools
|
|
115
|
+
- `src/tool/snapshot-tools.ts` — per-file diff and before/after content tools scoped to the selected snapshot
|
|
112
116
|
- `src/ui/renderer.ts` — structured result rendering
|
package/package.json
CHANGED
package/src/git.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
// biome-ignore lint/nursery/noExcessiveLinesPerFile: many tightly-coupled git helpers; splitting would create cross-ref overhead
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
3
5
|
import { promisify } from "node:util";
|
|
4
|
-
import type { DiffStats, ReviewSnapshot } from "./types.ts";
|
|
6
|
+
import type { DiffStats, ReviewSnapshot, ReviewTargetSpec } from "./types.ts";
|
|
5
7
|
|
|
6
8
|
const execFileAsync = promisify(execFile);
|
|
7
|
-
|
|
8
9
|
const GIT_TIMEOUT_MS = 30_000;
|
|
9
10
|
|
|
10
11
|
function scrubGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
@@ -237,11 +238,7 @@ export async function resolveWorkingTreeSnapshot(
|
|
|
237
238
|
getUncommittedDiff(repoPath),
|
|
238
239
|
getUncommittedFileNames(repoPath),
|
|
239
240
|
]);
|
|
240
|
-
|
|
241
|
-
if (!diffText.trim() && changedFiles.length === 0) {
|
|
242
|
-
return undefined;
|
|
243
|
-
}
|
|
244
|
-
|
|
241
|
+
if (!diffText.trim() && changedFiles.length === 0) return undefined;
|
|
245
242
|
return {
|
|
246
243
|
target: { kind: "working-tree" },
|
|
247
244
|
title: "Working tree changes",
|
|
@@ -257,19 +254,12 @@ export async function resolveBranchSnapshot(
|
|
|
257
254
|
base: string,
|
|
258
255
|
): Promise<ReviewSnapshot | undefined> {
|
|
259
256
|
const baseSha = await getMergeBase(repoPath, base);
|
|
260
|
-
if (!baseSha)
|
|
261
|
-
return undefined;
|
|
262
|
-
}
|
|
263
|
-
|
|
257
|
+
if (!baseSha) return undefined;
|
|
264
258
|
const [diffText, changedFiles] = await Promise.all([
|
|
265
259
|
getDiff(repoPath, baseSha),
|
|
266
260
|
getDiffFileNames(repoPath, baseSha),
|
|
267
261
|
]);
|
|
268
|
-
|
|
269
|
-
if (!diffText.trim() && changedFiles.length === 0) {
|
|
270
|
-
return undefined;
|
|
271
|
-
}
|
|
272
|
-
|
|
262
|
+
if (!diffText.trim() && changedFiles.length === 0) return undefined;
|
|
273
263
|
return {
|
|
274
264
|
target: { kind: "branch", base },
|
|
275
265
|
title: `Changes vs ${base}`,
|
|
@@ -288,11 +278,7 @@ export async function resolveCommitSnapshot(
|
|
|
288
278
|
getCommitShow(repoPath, sha),
|
|
289
279
|
getCommitFileNames(repoPath, sha),
|
|
290
280
|
]);
|
|
291
|
-
|
|
292
|
-
if (!diffText.trim() && changedFiles.length === 0) {
|
|
293
|
-
return undefined;
|
|
294
|
-
}
|
|
295
|
-
|
|
281
|
+
if (!diffText.trim() && changedFiles.length === 0) return undefined;
|
|
296
282
|
return {
|
|
297
283
|
target: { kind: "commit", sha },
|
|
298
284
|
title: `Commit ${sha.slice(0, 7)}`,
|
|
@@ -302,6 +288,122 @@ export async function resolveCommitSnapshot(
|
|
|
302
288
|
};
|
|
303
289
|
}
|
|
304
290
|
|
|
291
|
+
/** Get the per-file diff for a single changed file in the snapshot. */
|
|
292
|
+
export async function getSnapshotFileDiff(
|
|
293
|
+
repoPath: string,
|
|
294
|
+
snapshot: ReviewSnapshot,
|
|
295
|
+
file: string,
|
|
296
|
+
): Promise<string> {
|
|
297
|
+
const { target } = snapshot;
|
|
298
|
+
switch (target.kind) {
|
|
299
|
+
case "working-tree": {
|
|
300
|
+
const { stdout } = await execFileAsync(
|
|
301
|
+
"git",
|
|
302
|
+
["diff", "HEAD", "--", file],
|
|
303
|
+
gitExecOptions(repoPath),
|
|
304
|
+
);
|
|
305
|
+
return stdout;
|
|
306
|
+
}
|
|
307
|
+
case "branch": {
|
|
308
|
+
const baseSha = await getMergeBase(repoPath, target.base);
|
|
309
|
+
if (!baseSha) return "";
|
|
310
|
+
const { stdout } = await execFileAsync(
|
|
311
|
+
"git",
|
|
312
|
+
["diff", baseSha, "HEAD", "--", file],
|
|
313
|
+
gitExecOptions(repoPath),
|
|
314
|
+
);
|
|
315
|
+
return stdout;
|
|
316
|
+
}
|
|
317
|
+
case "commit": {
|
|
318
|
+
const { stdout } = await execFileAsync(
|
|
319
|
+
"git",
|
|
320
|
+
["show", target.sha, "--", file],
|
|
321
|
+
gitExecOptions(repoPath),
|
|
322
|
+
);
|
|
323
|
+
return stdout;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Run `git show <ref>:<file>` and return the blob content. */
|
|
329
|
+
async function showGitBlob(repoPath: string, ref: string, file: string): Promise<string> {
|
|
330
|
+
const { stdout } = await execFileAsync(
|
|
331
|
+
"git",
|
|
332
|
+
["show", `${ref}:${file}`],
|
|
333
|
+
gitExecOptions(repoPath),
|
|
334
|
+
);
|
|
335
|
+
return stdout;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function resolveWorkingTreeContent(
|
|
339
|
+
repoPath: string,
|
|
340
|
+
file: string,
|
|
341
|
+
side: "before" | "after",
|
|
342
|
+
): Promise<string | undefined> {
|
|
343
|
+
if (side === "before") {
|
|
344
|
+
try {
|
|
345
|
+
return await showGitBlob(repoPath, "HEAD", file);
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
return await readFile(join(repoPath, file), "utf-8");
|
|
352
|
+
} catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function resolveBranchContent(
|
|
358
|
+
repoPath: string,
|
|
359
|
+
target: ReviewTargetSpec & { kind: "branch" },
|
|
360
|
+
file: string,
|
|
361
|
+
side: "before" | "after",
|
|
362
|
+
): Promise<string | undefined> {
|
|
363
|
+
const baseSha = await getMergeBase(repoPath, target.base);
|
|
364
|
+
if (!baseSha) return undefined;
|
|
365
|
+
const ref = side === "before" ? baseSha : "HEAD";
|
|
366
|
+
try {
|
|
367
|
+
return await showGitBlob(repoPath, ref, file);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (side === "before") return undefined;
|
|
370
|
+
throw err;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function resolveCommitContent(
|
|
375
|
+
repoPath: string,
|
|
376
|
+
target: ReviewTargetSpec & { kind: "commit" },
|
|
377
|
+
file: string,
|
|
378
|
+
side: "before" | "after",
|
|
379
|
+
): Promise<string | undefined> {
|
|
380
|
+
const ref = side === "before" ? `${target.sha}^` : target.sha;
|
|
381
|
+
try {
|
|
382
|
+
return await showGitBlob(repoPath, ref, file);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
if (side === "before") return undefined;
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Get before or after content for a single changed file in the snapshot. Returns undefined when legitimately unavailable; propagates unexpected errors. */
|
|
390
|
+
export async function getSnapshotFileContent(
|
|
391
|
+
repoPath: string,
|
|
392
|
+
snapshot: ReviewSnapshot,
|
|
393
|
+
file: string,
|
|
394
|
+
side: "before" | "after",
|
|
395
|
+
): Promise<string | undefined> {
|
|
396
|
+
const { target } = snapshot;
|
|
397
|
+
switch (target.kind) {
|
|
398
|
+
case "working-tree":
|
|
399
|
+
return resolveWorkingTreeContent(repoPath, file, side);
|
|
400
|
+
case "branch":
|
|
401
|
+
return resolveBranchContent(repoPath, target, file, side);
|
|
402
|
+
case "commit":
|
|
403
|
+
return resolveCommitContent(repoPath, target, file, side);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
305
407
|
/** Convenience label for one changed file, used in synthesized prompts/UI. */
|
|
306
408
|
export function formatChangedFileLabel(file: string): string {
|
|
307
409
|
return basename(file) === file ? file : `${basename(file)} (${file})`;
|
package/src/history/collect.ts
CHANGED
|
@@ -1,114 +1,134 @@
|
|
|
1
1
|
import type { SessionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { HistoryEvidence, ReviewSnapshot } from "../types.ts";
|
|
3
|
-
|
|
4
|
-
const INTENT_PATTERN =
|
|
5
|
-
/\b(fix|refactor|rename|preserve|avoid|should|must|ensure|intended|goal|risk|concern|regression|security|correctness|performance|api|breaking|review)\b/i;
|
|
6
2
|
|
|
7
3
|
type ResolvedSessionMessage = SessionContext["messages"][number];
|
|
8
4
|
|
|
5
|
+
const DEFAULT_MAX_CHARS = 8_000;
|
|
6
|
+
|
|
7
|
+
export interface SerializeSessionContextOptions {
|
|
8
|
+
maxChars?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
|
-
*
|
|
12
|
+
* Serialize the resolved LLM-visible session context into a compaction-style
|
|
13
|
+
* readable transcript for the brief synthesizer.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
15
|
+
* This mirrors the overall approach Pi uses for compaction:
|
|
16
|
+
* - messages are labeled by role and rendered in chronological order
|
|
17
|
+
* - compaction and branch summaries appear with their own labels
|
|
18
|
+
* - the output is bounded so the synthesizer stays predictable on long sessions
|
|
19
|
+
* - no heuristic ranking or scoring is applied
|
|
14
20
|
*/
|
|
15
|
-
export function
|
|
21
|
+
export function serializeSessionContext(
|
|
16
22
|
messages: ResolvedSessionMessage[],
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const scored = messages
|
|
27
|
-
.map((message) => toEvidence(message))
|
|
23
|
+
options?: SerializeSessionContextOptions,
|
|
24
|
+
): string {
|
|
25
|
+
const maxChars = options?.maxChars ?? DEFAULT_MAX_CHARS;
|
|
26
|
+
if (messages.length === 0) return "";
|
|
27
|
+
|
|
28
|
+
const entries = messages
|
|
29
|
+
.map((message) => serializeEntry(message))
|
|
28
30
|
.filter(
|
|
29
|
-
(
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
(entry): entry is { label: string; text: string; isSummary: boolean } => entry !== undefined,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (entries.length === 0) return "";
|
|
35
|
+
|
|
36
|
+
// Separate summary entries (compaction/branch summaries) from regular entries
|
|
37
|
+
const summaryEntries = entries.filter((e) => e.isSummary);
|
|
38
|
+
const regularEntries = entries.filter((e) => !e.isSummary);
|
|
39
|
+
|
|
40
|
+
// Estimate the total size if we keep everything
|
|
41
|
+
const allLines = entries.map(formatEntry);
|
|
42
|
+
const totalSize = measureLines(allLines);
|
|
43
|
+
|
|
44
|
+
if (totalSize <= maxChars) {
|
|
45
|
+
return allLines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Build the output: always include summaries, then as many recent regular entries as fit
|
|
49
|
+
const summaryLines = summaryEntries.map(formatEntry);
|
|
50
|
+
|
|
51
|
+
// Walk regular entries from newest to oldest, keeping as many as fit
|
|
52
|
+
const keptRegular: typeof regularEntries = [];
|
|
53
|
+
let size = measureLines(summaryLines);
|
|
54
|
+
|
|
55
|
+
for (let i = regularEntries.length - 1; i >= 0; i--) {
|
|
56
|
+
const line = formatEntry(regularEntries[i]);
|
|
57
|
+
const lineLen = line.length + 1; // +1 for newline
|
|
58
|
+
if (keptRegular.length > 0 && size + lineLen > maxChars) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
keptRegular.unshift(regularEntries[i]);
|
|
62
|
+
size += lineLen;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build final output: summaries first, then kept recent entries
|
|
66
|
+
const keptLines = [...summaryLines, ...keptRegular.map(formatEntry)];
|
|
33
67
|
|
|
34
|
-
|
|
68
|
+
// If still nothing fits with summaries alone, truncate the summaries
|
|
69
|
+
if (keptLines.length > 0) {
|
|
70
|
+
const output = keptLines.join("\n");
|
|
71
|
+
if (output.length <= maxChars) return output;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Last resort: truncate the full output
|
|
75
|
+
return keepFirstLines(keptLines, maxChars);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatEntry(entry: { label: string; text: string }): string {
|
|
79
|
+
return `[${entry.label}]\n${entry.text}`;
|
|
35
80
|
}
|
|
36
81
|
|
|
37
|
-
function
|
|
82
|
+
function measureLines(lines: string[]): number {
|
|
83
|
+
return lines.reduce((acc, line) => acc + line.length + 1, 0); // +1 for newline
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function keepFirstLines(lines: string[], maxChars: number): string {
|
|
87
|
+
const kept: string[] = [];
|
|
88
|
+
let size = 0;
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
const lineLen = line.length + 1;
|
|
91
|
+
if (size + lineLen > maxChars) break;
|
|
92
|
+
kept.push(line);
|
|
93
|
+
size += lineLen;
|
|
94
|
+
}
|
|
95
|
+
return kept.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serializeEntry(
|
|
38
99
|
message: ResolvedSessionMessage,
|
|
39
|
-
):
|
|
100
|
+
): { label: string; text: string; isSummary: boolean } | undefined {
|
|
40
101
|
switch (message.role) {
|
|
41
102
|
case "user":
|
|
42
103
|
case "assistant": {
|
|
104
|
+
const text = normalizeText(extractMessageText(message.content));
|
|
105
|
+
if (!text) return undefined;
|
|
43
106
|
return {
|
|
44
|
-
|
|
45
|
-
text
|
|
107
|
+
label: message.role === "user" ? "User" : "Assistant",
|
|
108
|
+
text,
|
|
109
|
+
isSummary: false,
|
|
46
110
|
};
|
|
47
111
|
}
|
|
48
112
|
case "custom": {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
113
|
+
const text = normalizeText(extractMessageText(message.content));
|
|
114
|
+
if (!text) return undefined;
|
|
115
|
+
return { label: "Custom", text, isSummary: false };
|
|
53
116
|
}
|
|
54
117
|
case "compactionSummary": {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
};
|
|
118
|
+
const text = normalizeText(message.summary);
|
|
119
|
+
if (!text) return undefined;
|
|
120
|
+
return { label: "Compaction summary", text, isSummary: true };
|
|
59
121
|
}
|
|
60
122
|
case "branchSummary": {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
};
|
|
123
|
+
const text = normalizeText(message.summary);
|
|
124
|
+
if (!text) return undefined;
|
|
125
|
+
return { label: "Branch summary", text, isSummary: true };
|
|
65
126
|
}
|
|
66
127
|
default:
|
|
67
128
|
return undefined;
|
|
68
129
|
}
|
|
69
130
|
}
|
|
70
131
|
|
|
71
|
-
function scoreEvidence(
|
|
72
|
-
evidence: Omit<HistoryEvidence, "score" | "reason">,
|
|
73
|
-
index: number,
|
|
74
|
-
context: { total: number; changedPathTokens: string[]; noteTokens: string[] },
|
|
75
|
-
): HistoryEvidence {
|
|
76
|
-
const recencyBoost = context.total > 0 ? (index / context.total) * 3 : 0;
|
|
77
|
-
const pathMatches = countTokenMatches(evidence.text, context.changedPathTokens);
|
|
78
|
-
const noteMatches = countTokenMatches(evidence.text, context.noteTokens);
|
|
79
|
-
const intentBoost = INTENT_PATTERN.test(evidence.text) ? 4 : 0;
|
|
80
|
-
const shortTextPenalty = evidence.text.length < 24 ? -2 : 0;
|
|
81
|
-
const longTextPenalty = evidence.text.length > 900 ? -1 : 0;
|
|
82
|
-
const score =
|
|
83
|
-
baseKindScore(evidence.kind) +
|
|
84
|
-
recencyBoost +
|
|
85
|
-
pathMatches * 3 +
|
|
86
|
-
noteMatches * 2 +
|
|
87
|
-
intentBoost +
|
|
88
|
-
shortTextPenalty +
|
|
89
|
-
longTextPenalty;
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
...evidence,
|
|
93
|
-
score,
|
|
94
|
-
reason: buildReason(pathMatches, noteMatches, intentBoost),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function buildReason(pathMatches: number, noteMatches: number, intentBoost: number): string {
|
|
99
|
-
const reasons: string[] = [];
|
|
100
|
-
if (pathMatches > 0) {
|
|
101
|
-
reasons.push(`mentions ${pathMatches} changed-path token${pathMatches === 1 ? "" : "s"}`);
|
|
102
|
-
}
|
|
103
|
-
if (noteMatches > 0) {
|
|
104
|
-
reasons.push(`matches ${noteMatches} note token${noteMatches === 1 ? "" : "s"}`);
|
|
105
|
-
}
|
|
106
|
-
if (intentBoost > 0) {
|
|
107
|
-
reasons.push("contains intent/constraint language");
|
|
108
|
-
}
|
|
109
|
-
return reasons.join(", ") || "high-signal session context";
|
|
110
|
-
}
|
|
111
|
-
|
|
112
132
|
function extractMessageText(content: unknown): string {
|
|
113
133
|
if (typeof content === "string") {
|
|
114
134
|
return content;
|
|
@@ -131,80 +151,3 @@ function extractMessageText(content: unknown): string {
|
|
|
131
151
|
function normalizeText(text: string): string {
|
|
132
152
|
return text.replace(/\s+/g, " ").trim();
|
|
133
153
|
}
|
|
134
|
-
|
|
135
|
-
function collectPathTokens(paths: string[]): string[] {
|
|
136
|
-
const tokens = new Set<string>();
|
|
137
|
-
|
|
138
|
-
for (const path of paths) {
|
|
139
|
-
for (const part of path.split(/[\\/]/)) {
|
|
140
|
-
addToken(tokens, part);
|
|
141
|
-
for (const inner of part.split(/[-_.]/)) {
|
|
142
|
-
addToken(tokens, inner);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
addToken(tokens, path);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return Array.from(tokens);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function collectFreeformTokens(text: string | undefined): string[] {
|
|
152
|
-
if (!text) return [];
|
|
153
|
-
|
|
154
|
-
const tokens = new Set<string>();
|
|
155
|
-
for (const part of text.split(/\W+/)) {
|
|
156
|
-
addToken(tokens, part);
|
|
157
|
-
}
|
|
158
|
-
return Array.from(tokens);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function addToken(tokens: Set<string>, token: string): void {
|
|
162
|
-
const normalized = token.trim().toLowerCase();
|
|
163
|
-
if (normalized.length < 3) return;
|
|
164
|
-
tokens.add(normalized);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function countTokenMatches(text: string, tokens: string[]): number {
|
|
168
|
-
if (tokens.length === 0 || text.length === 0) return 0;
|
|
169
|
-
const lower = text.toLowerCase();
|
|
170
|
-
return tokens.reduce((count, token) => count + (lower.includes(token) ? 1 : 0), 0);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function baseKindScore(kind: HistoryEvidence["kind"]): number {
|
|
174
|
-
switch (kind) {
|
|
175
|
-
case "user":
|
|
176
|
-
return 8;
|
|
177
|
-
case "assistant":
|
|
178
|
-
return 5;
|
|
179
|
-
case "compaction":
|
|
180
|
-
case "branch-summary":
|
|
181
|
-
return 6;
|
|
182
|
-
case "custom":
|
|
183
|
-
return 4;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function capEvidence(
|
|
188
|
-
evidence: HistoryEvidence[],
|
|
189
|
-
maxItems: number,
|
|
190
|
-
maxChars: number,
|
|
191
|
-
): HistoryEvidence[] {
|
|
192
|
-
const kept: HistoryEvidence[] = [];
|
|
193
|
-
let used = 0;
|
|
194
|
-
|
|
195
|
-
for (const item of evidence) {
|
|
196
|
-
if (kept.length >= maxItems) break;
|
|
197
|
-
const text = truncate(item.text, 600);
|
|
198
|
-
const nextUsed = used + text.length;
|
|
199
|
-
if (kept.length > 0 && nextUsed > maxChars) break;
|
|
200
|
-
kept.push({ ...item, text });
|
|
201
|
-
used = nextUsed;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return kept;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function truncate(text: string, maxChars: number): string {
|
|
208
|
-
if (text.length <= maxChars) return text;
|
|
209
|
-
return `${text.slice(0, maxChars)}…`;
|
|
210
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { runBriefSynthesis } from "../tool/brief-runner.ts";
|
|
3
3
|
import type { BriefSynthesisRunResult, ReviewProgress } from "../tool/runner-types.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ReviewModelSelection, ReviewSnapshot } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
const DIFF_EXCERPT_CHAR_BUDGET = 12_000;
|
|
7
7
|
|
|
@@ -10,20 +10,21 @@ export interface SynthesizeReviewBriefOptions {
|
|
|
10
10
|
modelRegistry: ModelRegistry;
|
|
11
11
|
cwd: string;
|
|
12
12
|
snapshot: ReviewSnapshot;
|
|
13
|
-
|
|
13
|
+
serializedContext: string;
|
|
14
14
|
note?: string;
|
|
15
15
|
signal?: AbortSignal;
|
|
16
16
|
onProgress?: (progress: ReviewProgress) => void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Synthesize a structured review brief from the current snapshot and session
|
|
19
|
+
/** Synthesize a structured review brief from the current snapshot and session context. */
|
|
20
20
|
export function synthesizeReviewBrief(
|
|
21
21
|
options: SynthesizeReviewBriefOptions,
|
|
22
22
|
): Promise<BriefSynthesisRunResult> {
|
|
23
|
-
const { model, modelRegistry, cwd, snapshot,
|
|
23
|
+
const { model, modelRegistry, cwd, snapshot, serializedContext, note, signal, onProgress } =
|
|
24
|
+
options;
|
|
24
25
|
|
|
25
26
|
return runBriefSynthesis({
|
|
26
|
-
prompt: buildBriefSynthesisPrompt(snapshot,
|
|
27
|
+
prompt: buildBriefSynthesisPrompt(snapshot, serializedContext, note),
|
|
27
28
|
model: model.model,
|
|
28
29
|
modelRegistry,
|
|
29
30
|
cwd,
|
|
@@ -34,7 +35,7 @@ export function synthesizeReviewBrief(
|
|
|
34
35
|
|
|
35
36
|
export function buildBriefSynthesisPrompt(
|
|
36
37
|
snapshot: ReviewSnapshot,
|
|
37
|
-
|
|
38
|
+
serializedContext: string,
|
|
38
39
|
note?: string,
|
|
39
40
|
): string {
|
|
40
41
|
const diffExcerpt = buildDiffExcerpt(snapshot.diffText);
|
|
@@ -65,18 +66,11 @@ export function buildBriefSynthesisPrompt(
|
|
|
65
66
|
parts.push("", "## User note", note.trim());
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
parts.push("", "##
|
|
69
|
-
if (
|
|
70
|
-
parts.push(
|
|
71
|
-
"- No strong session evidence was extracted. Derive a minimal brief from the snapshot only.",
|
|
72
|
-
);
|
|
69
|
+
parts.push("", "## Serialized session context");
|
|
70
|
+
if (serializedContext.trim()) {
|
|
71
|
+
parts.push(serializedContext.trim());
|
|
73
72
|
} else {
|
|
74
|
-
parts.push(
|
|
75
|
-
...evidence.flatMap((item, index) => [
|
|
76
|
-
`${index + 1}. [${item.kind}] ${item.reason}`,
|
|
77
|
-
` ${item.text}`,
|
|
78
|
-
]),
|
|
79
|
-
);
|
|
73
|
+
parts.push("No session context was available. Derive the brief from the snapshot only.");
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
parts.push(
|
|
@@ -87,7 +81,9 @@ export function buildBriefSynthesisPrompt(
|
|
|
87
81
|
"- intendedOutcome: what the session seems to be trying to achieve",
|
|
88
82
|
"- constraints: invariants or requirements to preserve",
|
|
89
83
|
"- focusAreas: what the reviewer should inspect carefully",
|
|
90
|
-
"- riskyFiles: changed files that seem especially important or risky",
|
|
84
|
+
"- riskyFiles: changed files that seem especially important or risky.",
|
|
85
|
+
" Prioritize files touching auth, data handling, core logic, error handling, or public APIs.",
|
|
86
|
+
" Include only files from the changed-files list above.",
|
|
91
87
|
"- unresolvedQuestions: ambiguities or concerns that remain unclear",
|
|
92
88
|
);
|
|
93
89
|
|