@mrclrchtr/supi-review 1.4.0 → 1.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/README.md +53 -59
- package/package.json +2 -9
- package/src/git.ts +107 -1
- package/src/history/collect.ts +210 -0
- package/src/history/synthesize.ts +109 -0
- package/src/model.ts +100 -0
- package/src/review.ts +204 -342
- package/src/target/packet.ts +216 -0
- package/src/tool/brief-runner.ts +269 -0
- package/src/tool/review-runner.ts +434 -0
- package/src/tool/runner-types.ts +29 -12
- package/src/tool/schemas.ts +31 -0
- package/src/types.ts +87 -48
- package/src/ui/flow.ts +302 -0
- package/src/ui/format-content.ts +22 -16
- package/src/ui/progress-widget.ts +40 -40
- package/src/ui/renderer.ts +38 -21
- package/src/ui/theme-type.ts +16 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +0 -107
- package/node_modules/@mrclrchtr/supi-core/package.json +0 -44
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +0 -83
- package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +0 -76
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +0 -186
- package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +0 -119
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +0 -36
- package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +0 -31
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -255
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +0 -1
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +0 -83
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +0 -170
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +0 -54
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +0 -29
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +0 -15
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +0 -41
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +0 -226
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +0 -60
- package/src/briefs.ts +0 -101
- package/src/profiles.ts +0 -52
- package/src/prompts.ts +0 -116
- package/src/settings.ts +0 -246
- package/src/tool/runner.ts +0 -432
- package/src/tool/target-resolution.ts +0 -102
- package/src/ui/ui.ts +0 -208
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mrclrchtr/supi-review
|
|
2
2
|
|
|
3
|
-
Adds
|
|
3
|
+
Adds an interactive `/supi-review` command to the [pi coding agent](https://github.com/earendil-works/pi) for session-aware code review.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -22,58 +22,63 @@ After editing the source, run `/reload`.
|
|
|
22
22
|
|
|
23
23
|
After install, pi gets one command:
|
|
24
24
|
|
|
25
|
-
- `/supi-review` — launch
|
|
25
|
+
- `/supi-review` — launch a guided review flow over a concrete git snapshot
|
|
26
26
|
|
|
27
|
-
The reviewer runs in
|
|
27
|
+
The reviewer runs in managed child agent sessions:
|
|
28
28
|
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
- `find`
|
|
32
|
-
- `ls`
|
|
33
|
-
- `submit_review` (internal result-submission tool)
|
|
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
|
|
34
31
|
|
|
35
32
|
## Review flow
|
|
36
33
|
|
|
37
34
|
`/supi-review` walks you through:
|
|
38
35
|
|
|
39
|
-
1. choose a review
|
|
40
|
-
2. choose
|
|
41
|
-
3.
|
|
42
|
-
4.
|
|
43
|
-
5.
|
|
44
|
-
6.
|
|
45
|
-
7.
|
|
36
|
+
1. choose a review target
|
|
37
|
+
2. choose the reviewer model
|
|
38
|
+
3. optionally add a short note
|
|
39
|
+
4. resolve the snapshot
|
|
40
|
+
5. synthesize a review brief from the current session history
|
|
41
|
+
6. preview the synthesized brief + prompt coverage
|
|
42
|
+
7. run the review with a live progress widget
|
|
43
|
+
8. show the structured result as a custom message
|
|
44
|
+
9. if findings exist, hand off to the main agent so it can ask what to do next
|
|
46
45
|
|
|
47
|
-
## Review
|
|
46
|
+
## Review targets
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
Current targets:
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
- working tree
|
|
51
|
+
- branch diff vs a selected local base branch
|
|
52
|
+
- one recent commit
|
|
52
53
|
|
|
53
|
-
-
|
|
54
|
-
- the intended outcome
|
|
55
|
-
- what the reviewer should focus on
|
|
54
|
+
## Session-aware brief synthesis
|
|
56
55
|
|
|
57
|
-
The
|
|
56
|
+
The generated review prompt is **not** just a static diff wrapper.
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
Before the actual review starts, the package:
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
- resolves the **active session branch into the current LLM-visible context**
|
|
61
|
+
- extracts high-signal user/assistant/custom/compaction context from that resolved view
|
|
62
|
+
- synthesizes a structured brief with:
|
|
63
|
+
- summary
|
|
64
|
+
- intended outcome
|
|
65
|
+
- constraints to preserve
|
|
66
|
+
- focus areas
|
|
67
|
+
- risky files
|
|
68
|
+
- unresolved questions
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
- `security`
|
|
65
|
-
- `api-maintainability`
|
|
70
|
+
The synthesizer also receives a bounded diff excerpt from the snapshot so it can reason about actual code changes, not just filenames.
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
That synthesized brief is then combined with the git snapshot into the final reviewer prompt.
|
|
68
73
|
|
|
69
|
-
##
|
|
74
|
+
## Model selection
|
|
70
75
|
|
|
71
|
-
|
|
76
|
+
Every `/supi-review` run asks you to choose the reviewer model.
|
|
72
77
|
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
-
|
|
78
|
+
- the picker only shows **scoped models** from Pi's `enabledModels` configuration
|
|
79
|
+
- the current session model is preselected only when it is inside that scoped set
|
|
80
|
+
- the selected model is used for both brief synthesis and the final review
|
|
81
|
+
- no review model is persisted in settings
|
|
77
82
|
|
|
78
83
|
## Result shape
|
|
79
84
|
|
|
@@ -83,36 +88,25 @@ A successful review includes:
|
|
|
83
88
|
- overall explanation
|
|
84
89
|
- overall confidence score
|
|
85
90
|
- structured findings with title, body, priority, confidence score, and code location
|
|
91
|
+
- the synthesized brief that drove the review
|
|
86
92
|
|
|
87
93
|
The renderer also handles failed, canceled, and timed-out reviews.
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
This package registers a **Review** section in `/supi-settings`.
|
|
92
|
-
|
|
93
|
-
Available settings:
|
|
95
|
+
When a successful review contains findings, `supi-review` also injects an agent-visible hidden follow-up message that asks the main agent to decide the next step with the user. If `ask_user` is available, the main agent is instructed to use it and offer:
|
|
94
96
|
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
|
|
99
|
-
Defaults:
|
|
100
|
-
|
|
101
|
-
```json
|
|
102
|
-
{
|
|
103
|
-
"review": {
|
|
104
|
-
"reviewModel": "",
|
|
105
|
-
"maxDiffBytes": 100000,
|
|
106
|
-
"autoFix": false
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
```
|
|
97
|
+
- Done
|
|
98
|
+
- Fix all
|
|
99
|
+
- Fix selected
|
|
100
|
+
- Verify findings
|
|
110
101
|
|
|
111
102
|
## Source
|
|
112
103
|
|
|
113
104
|
- `src/review.ts` — command orchestration and interactive flow
|
|
114
|
-
- `src/
|
|
115
|
-
- `src/
|
|
116
|
-
- `src/
|
|
117
|
-
- `src/
|
|
118
|
-
- `src/
|
|
105
|
+
- `src/model.ts` — explicit model selection helpers
|
|
106
|
+
- `src/git.ts` — git snapshot resolution
|
|
107
|
+
- `src/history/collect.ts` — active-branch evidence extraction
|
|
108
|
+
- `src/history/synthesize.ts` — brief synthesis orchestration
|
|
109
|
+
- `src/target/packet.ts` — final reviewer packet builder
|
|
110
|
+
- `src/tool/brief-runner.ts` — brief synthesis child session
|
|
111
|
+
- `src/tool/review-runner.ts` — read-only reviewer child session
|
|
112
|
+
- `src/ui/renderer.ts` — structured result rendering
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-review",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "SuPi Review extension — structured code review via /supi-review command",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,12 +19,6 @@
|
|
|
19
19
|
"src/**/*.ts",
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
22
|
-
"dependencies": {
|
|
23
|
-
"@mrclrchtr/supi-core": "1.4.0"
|
|
24
|
-
},
|
|
25
|
-
"bundledDependencies": [
|
|
26
|
-
"@mrclrchtr/supi-core"
|
|
27
|
-
],
|
|
28
22
|
"peerDependencies": {
|
|
29
23
|
"@earendil-works/pi-ai": "*",
|
|
30
24
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -47,8 +41,7 @@
|
|
|
47
41
|
},
|
|
48
42
|
"pi": {
|
|
49
43
|
"extensions": [
|
|
50
|
-
"./src/extension.ts"
|
|
51
|
-
"node_modules/@mrclrchtr/supi-core/src/extension.ts"
|
|
44
|
+
"./src/extension.ts"
|
|
52
45
|
]
|
|
53
46
|
},
|
|
54
47
|
"main": "src/api.ts",
|
package/src/git.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
2
3
|
import { promisify } from "node:util";
|
|
4
|
+
import type { DiffStats, ReviewSnapshot } from "./types.ts";
|
|
3
5
|
|
|
4
6
|
const execFileAsync = promisify(execFile);
|
|
5
7
|
|
|
@@ -23,6 +25,32 @@ function gitExecOptions(repoPath: string) {
|
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/** Parse simple git diff statistics from diff/show text. */
|
|
29
|
+
export function parseDiffStats(text: string): DiffStats {
|
|
30
|
+
let files = 0;
|
|
31
|
+
let additions = 0;
|
|
32
|
+
let deletions = 0;
|
|
33
|
+
let inDiff = false;
|
|
34
|
+
|
|
35
|
+
for (const line of text.split("\n")) {
|
|
36
|
+
if (line.startsWith("diff --git ")) {
|
|
37
|
+
files++;
|
|
38
|
+
inDiff = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!inDiff) continue;
|
|
43
|
+
|
|
44
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
45
|
+
additions++;
|
|
46
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
47
|
+
deletions++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { files, additions, deletions };
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
export async function getMergeBase(repoPath: string, branch: string): Promise<string | undefined> {
|
|
27
55
|
try {
|
|
28
56
|
const { stdout } = await execFileAsync(
|
|
@@ -99,7 +127,7 @@ export async function getRecentCommits(repoPath: string, limit = 20): Promise<Co
|
|
|
99
127
|
if (idx <= 0) return undefined;
|
|
100
128
|
return { sha: line.slice(0, idx), subject: line.slice(idx + 1) };
|
|
101
129
|
})
|
|
102
|
-
.filter((
|
|
130
|
+
.filter((entry): entry is CommitEntry => entry !== undefined);
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
export async function getCommitShow(repoPath: string, sha: string): Promise<string> {
|
|
@@ -200,3 +228,81 @@ export async function getLocalBranches(repoPath: string): Promise<string[]> {
|
|
|
200
228
|
sorted.push(...remaining);
|
|
201
229
|
return sorted;
|
|
202
230
|
}
|
|
231
|
+
|
|
232
|
+
/** Resolve the current working tree into a concrete review snapshot. */
|
|
233
|
+
export async function resolveWorkingTreeSnapshot(
|
|
234
|
+
repoPath: string,
|
|
235
|
+
): Promise<ReviewSnapshot | undefined> {
|
|
236
|
+
const [diffText, changedFiles] = await Promise.all([
|
|
237
|
+
getUncommittedDiff(repoPath),
|
|
238
|
+
getUncommittedFileNames(repoPath),
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
if (!diffText.trim() && changedFiles.length === 0) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
target: { kind: "working-tree" },
|
|
247
|
+
title: "Working tree changes",
|
|
248
|
+
changedFiles,
|
|
249
|
+
diffText,
|
|
250
|
+
stats: parseDiffStats(diffText),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Resolve a branch-vs-base diff into a concrete review snapshot. */
|
|
255
|
+
export async function resolveBranchSnapshot(
|
|
256
|
+
repoPath: string,
|
|
257
|
+
base: string,
|
|
258
|
+
): Promise<ReviewSnapshot | undefined> {
|
|
259
|
+
const baseSha = await getMergeBase(repoPath, base);
|
|
260
|
+
if (!baseSha) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const [diffText, changedFiles] = await Promise.all([
|
|
265
|
+
getDiff(repoPath, baseSha),
|
|
266
|
+
getDiffFileNames(repoPath, baseSha),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
if (!diffText.trim() && changedFiles.length === 0) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
target: { kind: "branch", base },
|
|
275
|
+
title: `Changes vs ${base}`,
|
|
276
|
+
changedFiles,
|
|
277
|
+
diffText,
|
|
278
|
+
stats: parseDiffStats(diffText),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Resolve one commit into a concrete review snapshot. */
|
|
283
|
+
export async function resolveCommitSnapshot(
|
|
284
|
+
repoPath: string,
|
|
285
|
+
sha: string,
|
|
286
|
+
): Promise<ReviewSnapshot | undefined> {
|
|
287
|
+
const [diffText, changedFiles] = await Promise.all([
|
|
288
|
+
getCommitShow(repoPath, sha),
|
|
289
|
+
getCommitFileNames(repoPath, sha),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
if (!diffText.trim() && changedFiles.length === 0) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
target: { kind: "commit", sha },
|
|
298
|
+
title: `Commit ${sha.slice(0, 7)}`,
|
|
299
|
+
changedFiles,
|
|
300
|
+
diffText,
|
|
301
|
+
stats: parseDiffStats(diffText),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Convenience label for one changed file, used in synthesized prompts/UI. */
|
|
306
|
+
export function formatChangedFileLabel(file: string): string {
|
|
307
|
+
return basename(file) === file ? file : `${basename(file)} (${file})`;
|
|
308
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
|
|
7
|
+
type ResolvedSessionMessage = SessionContext["messages"][number];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract the highest-signal evidence from the resolved LLM-visible session context.
|
|
11
|
+
*
|
|
12
|
+
* The collector intentionally favors user intent, assistant plans, compaction
|
|
13
|
+
* summaries, and custom extension messages over raw tool chatter.
|
|
14
|
+
*/
|
|
15
|
+
export function collectHistoryEvidence(
|
|
16
|
+
messages: ResolvedSessionMessage[],
|
|
17
|
+
snapshot: ReviewSnapshot,
|
|
18
|
+
note?: string,
|
|
19
|
+
): HistoryEvidence[] {
|
|
20
|
+
const scoringContext = {
|
|
21
|
+
changedPathTokens: collectPathTokens(snapshot.changedFiles),
|
|
22
|
+
noteTokens: collectFreeformTokens(note),
|
|
23
|
+
total: messages.length,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const scored = messages
|
|
27
|
+
.map((message) => toEvidence(message))
|
|
28
|
+
.filter(
|
|
29
|
+
(evidence): evidence is Omit<HistoryEvidence, "score" | "reason"> => evidence !== undefined,
|
|
30
|
+
)
|
|
31
|
+
.map((evidence, index) => scoreEvidence(evidence, index, scoringContext))
|
|
32
|
+
.sort((a, b) => b.score - a.score || b.text.length - a.text.length);
|
|
33
|
+
|
|
34
|
+
return capEvidence(scored, 10, 4_500);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toEvidence(
|
|
38
|
+
message: ResolvedSessionMessage,
|
|
39
|
+
): Omit<HistoryEvidence, "score" | "reason"> | undefined {
|
|
40
|
+
switch (message.role) {
|
|
41
|
+
case "user":
|
|
42
|
+
case "assistant": {
|
|
43
|
+
return {
|
|
44
|
+
kind: message.role,
|
|
45
|
+
text: normalizeText(extractMessageText(message.content)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
case "custom": {
|
|
49
|
+
return {
|
|
50
|
+
kind: "custom",
|
|
51
|
+
text: normalizeText(extractMessageText(message.content)),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
case "compactionSummary": {
|
|
55
|
+
return {
|
|
56
|
+
kind: "compaction",
|
|
57
|
+
text: normalizeText(message.summary),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
case "branchSummary": {
|
|
61
|
+
return {
|
|
62
|
+
kind: "branch-summary",
|
|
63
|
+
text: normalizeText(message.summary),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
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
|
+
function extractMessageText(content: unknown): string {
|
|
113
|
+
if (typeof content === "string") {
|
|
114
|
+
return content;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!Array.isArray(content)) {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return content
|
|
122
|
+
.map((part) => {
|
|
123
|
+
if (typeof part !== "object" || !part) return "";
|
|
124
|
+
const text = (part as { text?: unknown }).text;
|
|
125
|
+
return typeof text === "string" ? text : "";
|
|
126
|
+
})
|
|
127
|
+
.filter((text) => text.length > 0)
|
|
128
|
+
.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeText(text: string): string {
|
|
132
|
+
return text.replace(/\s+/g, " ").trim();
|
|
133
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { runBriefSynthesis } from "../tool/brief-runner.ts";
|
|
3
|
+
import type { BriefSynthesisRunResult, ReviewProgress } from "../tool/runner-types.ts";
|
|
4
|
+
import type { HistoryEvidence, ReviewModelSelection, ReviewSnapshot } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
const DIFF_EXCERPT_CHAR_BUDGET = 12_000;
|
|
7
|
+
|
|
8
|
+
export interface SynthesizeReviewBriefOptions {
|
|
9
|
+
model: ReviewModelSelection;
|
|
10
|
+
modelRegistry: ModelRegistry;
|
|
11
|
+
cwd: string;
|
|
12
|
+
snapshot: ReviewSnapshot;
|
|
13
|
+
evidence: HistoryEvidence[];
|
|
14
|
+
note?: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
onProgress?: (progress: ReviewProgress) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Synthesize a structured review brief from the current snapshot and session evidence. */
|
|
20
|
+
export function synthesizeReviewBrief(
|
|
21
|
+
options: SynthesizeReviewBriefOptions,
|
|
22
|
+
): Promise<BriefSynthesisRunResult> {
|
|
23
|
+
const { model, modelRegistry, cwd, snapshot, evidence, note, signal, onProgress } = options;
|
|
24
|
+
|
|
25
|
+
return runBriefSynthesis({
|
|
26
|
+
prompt: buildBriefSynthesisPrompt(snapshot, evidence, note),
|
|
27
|
+
model: model.model,
|
|
28
|
+
modelRegistry,
|
|
29
|
+
cwd,
|
|
30
|
+
signal,
|
|
31
|
+
onProgress,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildBriefSynthesisPrompt(
|
|
36
|
+
snapshot: ReviewSnapshot,
|
|
37
|
+
evidence: HistoryEvidence[],
|
|
38
|
+
note?: string,
|
|
39
|
+
): string {
|
|
40
|
+
const diffExcerpt = buildDiffExcerpt(snapshot.diffText);
|
|
41
|
+
const parts: string[] = [
|
|
42
|
+
"# Review Brief Synthesis Input",
|
|
43
|
+
"",
|
|
44
|
+
"You are preparing a review brief for a second code-reviewing agent.",
|
|
45
|
+
"Infer the likely goal, constraints, and focus areas from the active session history.",
|
|
46
|
+
"Be concise, evidence-based, and avoid inventing requirements that are not supported by the input.",
|
|
47
|
+
"",
|
|
48
|
+
"## Snapshot",
|
|
49
|
+
`Target: ${snapshot.title}`,
|
|
50
|
+
`Files changed: ${snapshot.changedFiles.length}`,
|
|
51
|
+
`Diff stats: +${snapshot.stats.additions} / -${snapshot.stats.deletions}`,
|
|
52
|
+
"",
|
|
53
|
+
"### Changed files",
|
|
54
|
+
...snapshot.changedFiles.map((file) => `- ${file}`),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
if (diffExcerpt.text) {
|
|
58
|
+
parts.push("", "### Diff excerpt", "```diff", diffExcerpt.text, "```");
|
|
59
|
+
if (diffExcerpt.truncated) {
|
|
60
|
+
parts.push(`> Note: diff excerpt truncated to ${diffExcerpt.text.length} characters.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (note?.trim()) {
|
|
65
|
+
parts.push("", "## User note", note.trim());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
parts.push("", "## Session evidence");
|
|
69
|
+
if (evidence.length === 0) {
|
|
70
|
+
parts.push(
|
|
71
|
+
"- No strong session evidence was extracted. Derive a minimal brief from the snapshot only.",
|
|
72
|
+
);
|
|
73
|
+
} else {
|
|
74
|
+
parts.push(
|
|
75
|
+
...evidence.flatMap((item, index) => [
|
|
76
|
+
`${index + 1}. [${item.kind}] ${item.reason}`,
|
|
77
|
+
` ${item.text}`,
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
parts.push(
|
|
83
|
+
"",
|
|
84
|
+
"## Output requirements",
|
|
85
|
+
"Call submit_review_brief with:",
|
|
86
|
+
"- summary: one-sentence summary of the likely change",
|
|
87
|
+
"- intendedOutcome: what the session seems to be trying to achieve",
|
|
88
|
+
"- constraints: invariants or requirements to preserve",
|
|
89
|
+
"- focusAreas: what the reviewer should inspect carefully",
|
|
90
|
+
"- riskyFiles: changed files that seem especially important or risky",
|
|
91
|
+
"- unresolvedQuestions: ambiguities or concerns that remain unclear",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return parts.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildDiffExcerpt(diffText: string): { text: string; truncated: boolean } {
|
|
98
|
+
const trimmed = diffText.trim();
|
|
99
|
+
if (!trimmed) {
|
|
100
|
+
return { text: "", truncated: false };
|
|
101
|
+
}
|
|
102
|
+
if (trimmed.length <= DIFF_EXCERPT_CHAR_BUDGET) {
|
|
103
|
+
return { text: trimmed, truncated: false };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
text: `${trimmed.slice(0, DIFF_EXCERPT_CHAR_BUDGET)}\n[... diff excerpt truncated ...]`,
|
|
107
|
+
truncated: true,
|
|
108
|
+
};
|
|
109
|
+
}
|