pi-critique-model 1.0.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/LICENSE +21 -0
- package/README.md +215 -0
- package/package.json +37 -0
- package/src/config.ts +89 -0
- package/src/index.ts +307 -0
- package/src/review.ts +113 -0
- package/src/work-step.ts +203 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Noguerol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
# Critique — Adversarial Code Review for pi
|
|
8
|
+
|
|
9
|
+
**Critique reviews the last work step with a separate model and feeds the review back to the working model as advisory feedback.** Two thinking machines face off — the working model produced a work step, and an independent reviewer picks it apart for correctness, robustness, maintainability and efficiency. The verdict comes back as structured Markdown: `APPROVED`, `APPROVED_WITH_SUGGESTIONS`, or `CHANGES_RECOMMENDED` — and the working model stays the final judge. The feedback is **non-mandatory**: it applies, partially applies, or rejects each point as it sees fit.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Independent reviewer** — a separate model judges the work step from the serialized context, with no tools of its own (can't modify the codebase, can't see your secrets)
|
|
16
|
+
- **Auto-detects the work step** — splits the session branch into *episodes* at user-message boundaries; the last episode containing tool calls or assistant output is the work step
|
|
17
|
+
- **Token-budgeted context** — the user prompt, assistant messages, tool calls and tool results are truncated to a self-contained block so the reviewer sees what it needs without overflow
|
|
18
|
+
- **Multi-step review** — review the last `N` episodes in one shot (`/critique 3`) to catch interactions across steps
|
|
19
|
+
- **Focus note** — `/critique check the error handling` biases the review without limiting it; the reviewer still scans for everything
|
|
20
|
+
- **Structured output** — fixed `Verdict / Issues / Suggestions / Summary` schema so the feedback is always actionable and machine-parseable
|
|
21
|
+
- **Independent model picker** — `/critique config` lets you choose the reviewer; the default is a *different* model than the working one, ensuring a genuinely independent perspective
|
|
22
|
+
- **Advisory injection** — on by default; toggle off (or use `/critique view`) to keep the review as read-only
|
|
23
|
+
- **Cancelable loader** — in TUI mode, the review runs behind a loader that you can abort with `Esc`
|
|
24
|
+
- **No provider surprises** — empty reviews are flagged, provider errors are surfaced as errors instead of silently producing nothing
|
|
25
|
+
- **Persistent config** — `~/.pi/agent/critique.json` stores the model choice and auto-inject toggle across all projects
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Critique is a [pi package](https://pi.dev/packages): one extension (`src/index.ts`) declared in `package.json`.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# From GitHub
|
|
33
|
+
pi install git:github.com/noguerol/critique
|
|
34
|
+
|
|
35
|
+
# Pin a tag/commit
|
|
36
|
+
pi install git:github.com/noguerol/critique@v1.0.0
|
|
37
|
+
|
|
38
|
+
# From npm
|
|
39
|
+
pi install npm:pi-critique-model
|
|
40
|
+
|
|
41
|
+
# Local checkout (development)
|
|
42
|
+
pi install /path/to/critique
|
|
43
|
+
|
|
44
|
+
# Try it for one run only
|
|
45
|
+
pi -e git:github.com/noguerol/critique
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pi list # show installed packages
|
|
50
|
+
pi remove npm:pi-critique-model
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Security:** pi packages run with full system access — extensions execute arbitrary code. Install only packages you trust and review the source.
|
|
54
|
+
|
|
55
|
+
**Requirements:** a working pi installation with at least two models configured (one is the *working* model, the other becomes the *reviewer*). Models can be from the same provider as long as they have different IDs.
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
/critique config # (optional) pick a reviewer model — defaults to "different from the working one"
|
|
61
|
+
... # let the main model do some work
|
|
62
|
+
/critique # review the last work step and feed the feedback back
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The main model then sees the review appended to its next turn and decides what to apply. With auto-inject off (or `/critique view`), the review is only displayed to you — useful when you're just exploring whether to apply changes.
|
|
66
|
+
|
|
67
|
+
To focus the review:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
/critique check the error handling on the retry logic
|
|
71
|
+
/critique 3 # review the last 3 work steps
|
|
72
|
+
/critique 2 look at the test coverage # combine count + focus
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---------|-------------|
|
|
79
|
+
| `/critique` | Review the last work step and inject the feedback back into the working model |
|
|
80
|
+
| `/critique <focus>` | Review the last work step with an additional focus note |
|
|
81
|
+
| `/critique N` | Review the last `N` work steps (max 5) |
|
|
82
|
+
| `/critique N <focus>` | Combine count and focus |
|
|
83
|
+
| `/critique view` | Show the review only, without injecting it |
|
|
84
|
+
| `/critique view <focus>` | View-only, with a focus note |
|
|
85
|
+
| `/critique view N` | View-only, last `N` steps |
|
|
86
|
+
| `/critique config` | Pick the critique model from pi's native active models and toggle auto-inject |
|
|
87
|
+
|
|
88
|
+
**Argument parsing:**
|
|
89
|
+
|
|
90
|
+
- A leading integer (`1`–`5`) sets the number of work steps to review.
|
|
91
|
+
- `config` or `view` after the slash sets the mode.
|
|
92
|
+
- Anything else is treated as a focus note and prepended to the reviewer prompt.
|
|
93
|
+
|
|
94
|
+
## How It Works
|
|
95
|
+
|
|
96
|
+
### 1. Extract the work step
|
|
97
|
+
|
|
98
|
+
The session branch is split into *episodes* at user-message boundaries. The last episode containing tool calls or assistant output is the work step: the user request that triggered it, every tool call (with arguments), and every tool result (diffs, command output, errors). Content is truncated to a token budget so the reviewer sees a focused, self-contained context:
|
|
99
|
+
|
|
100
|
+
| Field | Max chars |
|
|
101
|
+
|-------|-----------|
|
|
102
|
+
| User prompt | 12,000 |
|
|
103
|
+
| Assistant text | 8,000 |
|
|
104
|
+
| Tool args | 4,000 each |
|
|
105
|
+
| Tool result | 8,000 each |
|
|
106
|
+
| Total | 60,000 |
|
|
107
|
+
|
|
108
|
+
Truncated content is marked with `… [truncated]` so the reviewer can tell what it did and didn't see.
|
|
109
|
+
|
|
110
|
+
### 2. Ask the reviewer
|
|
111
|
+
|
|
112
|
+
The critique model is called directly through `ctx.modelRegistry.complete()` with **no tools** — it only judges. The reviewer is told:
|
|
113
|
+
|
|
114
|
+
- The work step inside `<work-step>` tags
|
|
115
|
+
- An optional `<focus-note>` if you passed one
|
|
116
|
+
- "Do not invent issues: if the work is correct, say so and keep suggestions minimal"
|
|
117
|
+
|
|
118
|
+
It replies in a fixed Markdown structure:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
## Verdict
|
|
122
|
+
APPROVED | APPROVED_WITH_SUGGESTIONS | CHANGES_RECOMMENDED
|
|
123
|
+
|
|
124
|
+
## Issues
|
|
125
|
+
- [severity: critical|major|minor] description
|
|
126
|
+
|
|
127
|
+
## Suggestions
|
|
128
|
+
- concrete, actionable suggestion
|
|
129
|
+
|
|
130
|
+
## Summary
|
|
131
|
+
2-4 sentence overall assessment.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The reviewer is also told explicitly to base its judgment *only* on the provided work step, so a reviewer on a smaller/cheaper model still gives useful feedback.
|
|
135
|
+
|
|
136
|
+
### 3. Inject the feedback
|
|
137
|
+
|
|
138
|
+
The review is sent back to the working model as a follow-up user message:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
[Critique — advisory review of your last work step]
|
|
142
|
+
|
|
143
|
+
A separate reviewer model (`provider/model`) reviewed the work you just
|
|
144
|
+
performed. This feedback is **advisory, not mandatory**: you are the final
|
|
145
|
+
judge. Apply only the points that genuinely improve the work, and if you
|
|
146
|
+
disagree with any of them, briefly explain why and continue.
|
|
147
|
+
|
|
148
|
+
--- Review ---
|
|
149
|
+
<the review>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The main model then has the freedom to apply, partially apply, or reject each point. If auto-inject is off, the review is only shown to you.
|
|
153
|
+
|
|
154
|
+
## Model Selection
|
|
155
|
+
|
|
156
|
+
`/critique config` opens the critic model picker. It only offers models with configured auth, **only** from pi's native model registry — the same list you see in `/model`. The picker shows at most ten entries at a time and scrolls past that.
|
|
157
|
+
|
|
158
|
+
**Auto** (the default) prefers a *different* model than the working one, so the review is genuinely independent. If no second model is available, it falls back to the working model and warns you when it runs.
|
|
159
|
+
|
|
160
|
+
If you pin a specific model that's later removed or uninstalled, critique silently falls back to Auto.
|
|
161
|
+
|
|
162
|
+
If the critique model and the working model are the same, critique warns you — pick a different reviewer to get a genuinely independent second opinion.
|
|
163
|
+
|
|
164
|
+
## Configuration
|
|
165
|
+
|
|
166
|
+
The config is persisted as JSON at `~/.pi/agent/critique.json`:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"model": "anthropic/claude-sonnet-4",
|
|
171
|
+
"autoInject": true
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- **`model`** — canonical `provider/modelId` of the reviewer. Empty string = Auto (different from working model).
|
|
176
|
+
- **`autoInject`** — when `true`, the review is injected back into the working model. When `false`, the review is only displayed.
|
|
177
|
+
|
|
178
|
+
The picker offers any model with configured auth that's available in pi's registry; the config persists per-machine (in `getAgentDir()`), shared across all projects.
|
|
179
|
+
|
|
180
|
+
## Architecture
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
critique/
|
|
184
|
+
├── package.json # pi package manifest (pi-package)
|
|
185
|
+
├── LICENSE # MIT
|
|
186
|
+
├── README.md
|
|
187
|
+
├── docs/
|
|
188
|
+
│ ├── banner.png # wide README header
|
|
189
|
+
│ └── preview.png # npm pi.dev preview card
|
|
190
|
+
├── screenshot.png # full-res master
|
|
191
|
+
└── src/
|
|
192
|
+
├── index.ts # /critique command surface, model picker, review UI (≈300 lines)
|
|
193
|
+
├── config.ts # persistence + model resolution: pinned, auto, fallback (≈90 lines)
|
|
194
|
+
├── work-step.ts # episode splitting + token-budgeted serialization (≈200 lines)
|
|
195
|
+
└── review.ts # reviewer prompt + model call + injected-message builder (≈115 lines)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Four-file extension with zero external dependencies (only pi's bundled `@earendil-works/*` + Node built-ins):
|
|
199
|
+
|
|
200
|
+
- **Episode splitter** — splits a session branch into user-message-bounded episodes, picks the last one with work
|
|
201
|
+
- **Token budgeter** — hard caps per field, marks truncations so the reviewer knows what it didn't see
|
|
202
|
+
- **Reviewer call** — tool-free `ctx.modelRegistry.complete()` with a structured system prompt
|
|
203
|
+
- **Advisory formatter** — wraps the review in a "non-mandatory" envelope before injecting as a follow-up user message
|
|
204
|
+
- **UI** — paginated TUI model picker (SelectList) + Markdown review viewer + cancelable BorderedLoader
|
|
205
|
+
|
|
206
|
+
## Notes
|
|
207
|
+
|
|
208
|
+
- The critique model runs with **no tools** and never touches the filesystem. It judges purely from the serialized work step (which includes the diffs and outputs of `edit`/`write`/`bash` calls).
|
|
209
|
+
- In TUI mode the review runs behind a cancelable loader (Esc aborts) and `/critique view` opens a scrollable Markdown viewer. In RPC mode reviews are surfaced through notifications; print mode logs them to stdout.
|
|
210
|
+
- Provider errors (bad keys, insufficient balance, rate limit) are surfaced as errors instead of silently producing empty reviews.
|
|
211
|
+
- Reviews are capped at 16,000 chars to keep the injected follow-up reasonable; longer reviews are truncated with `… [review truncated]`.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT](LICENSE) © Javier Noguerol
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-critique-model",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A pi extension that reviews the last work step with a separate model and feeds the review back to the working model as advisory feedback. Independent reviewer with tool-free analysis, structured Markdown verdict (APPROVED / APPROVED_WITH_SUGGESTIONS / CHANGES_RECOMMENDED) and a token-budgeted context.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"review",
|
|
8
|
+
"critique",
|
|
9
|
+
"feedback",
|
|
10
|
+
"code-review",
|
|
11
|
+
"second-opinion",
|
|
12
|
+
"advisory"
|
|
13
|
+
],
|
|
14
|
+
"author": "Javier Noguerol <https://github.com/noguerol>",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/noguerol/critique"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/noguerol/critique",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/noguerol/critique/issues"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./src/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-ai": "*",
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"@earendil-works/pi-tui": "*"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critique — configuration.
|
|
3
|
+
*
|
|
4
|
+
* The critique config is a small JSON file in the pi user directory
|
|
5
|
+
* (getAgentDir()/critique.json), shared across projects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface CritiqueConfig {
|
|
15
|
+
/** Canonical "provider/modelId" of the critique model. Empty string = auto. */
|
|
16
|
+
model: string;
|
|
17
|
+
/** Inject the review back into the working model automatically. */
|
|
18
|
+
autoInject: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_CONFIG: CritiqueConfig = {
|
|
22
|
+
model: "",
|
|
23
|
+
autoInject: true,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function configFilePath(): string {
|
|
27
|
+
return join(getAgentDir(), "critique.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadConfig(): CritiqueConfig {
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(readFileSync(configFilePath(), "utf8")) as Partial<CritiqueConfig>;
|
|
33
|
+
return {
|
|
34
|
+
model: typeof raw.model === "string" ? raw.model : DEFAULT_CONFIG.model,
|
|
35
|
+
autoInject:
|
|
36
|
+
typeof raw.autoInject === "boolean" ? raw.autoInject : DEFAULT_CONFIG.autoInject,
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return { ...DEFAULT_CONFIG };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveConfig(config: CritiqueConfig): void {
|
|
44
|
+
const path = configFilePath();
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function modelLabel(model: Model): string {
|
|
50
|
+
return `${model.provider}/${model.id}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Models the user can pick in /critique config: the session-scoped set when
|
|
55
|
+
* scoping is configured, otherwise the full available catalogue. Only models
|
|
56
|
+
* with configured auth are offered.
|
|
57
|
+
*/
|
|
58
|
+
export function pickableModels(ctx: ExtensionContext): Model[] {
|
|
59
|
+
const scoped = (ctx.scopedModels ?? []).map((entry) => entry.model);
|
|
60
|
+
const candidates = scoped.length > 0 ? scoped : ctx.modelRegistry.getAvailable();
|
|
61
|
+
return candidates.filter((model) => ctx.modelRegistry.hasConfiguredAuth(model));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the model that runs the critique.
|
|
66
|
+
*
|
|
67
|
+
* - Pinned ("provider/modelId"): used when it exists and has configured auth.
|
|
68
|
+
* - Auto (""): prefer a different model than the working one, so the review is
|
|
69
|
+
* independent; fall back to the working model when nothing else is usable.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveCritiqueModel(
|
|
72
|
+
ctx: ExtensionContext,
|
|
73
|
+
config: CritiqueConfig,
|
|
74
|
+
): Model | undefined {
|
|
75
|
+
if (config.model) {
|
|
76
|
+
const slash = config.model.indexOf("/");
|
|
77
|
+
const provider = slash >= 0 ? config.model.slice(0, slash) : config.model;
|
|
78
|
+
const id = slash >= 0 ? config.model.slice(slash + 1) : config.model;
|
|
79
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
80
|
+
return model && ctx.modelRegistry.hasConfiguredAuth(model) ? model : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const candidates = pickableModels(ctx);
|
|
84
|
+
const working = ctx.model;
|
|
85
|
+
const different = candidates.find(
|
|
86
|
+
(model) => !working || model.provider !== working.provider || model.id !== working.id,
|
|
87
|
+
);
|
|
88
|
+
return different ?? working ?? candidates[0];
|
|
89
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critique — a pi extension that reviews the last work step with a separate
|
|
3
|
+
* model and feeds the review back to the working model as advisory feedback.
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* /critique Review the last work step and inject the feedback
|
|
7
|
+
* /critique <focus> ... focusing the review on <focus>
|
|
8
|
+
* /critique N ... reviewing the last N work steps (max 5)
|
|
9
|
+
* /critique view [N] Show the review only, without injecting it
|
|
10
|
+
* /critique config Choose the critique model from pi's active models
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import {
|
|
15
|
+
BorderedLoader,
|
|
16
|
+
DynamicBorder,
|
|
17
|
+
getMarkdownTheme,
|
|
18
|
+
getSelectListTheme,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { AutocompleteItem, SelectItem } from "@earendil-works/pi-tui";
|
|
21
|
+
import { Container, Markdown, matchesKey, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
loadConfig,
|
|
25
|
+
modelLabel,
|
|
26
|
+
pickableModels,
|
|
27
|
+
resolveCritiqueModel,
|
|
28
|
+
saveConfig,
|
|
29
|
+
} from "./config.ts";
|
|
30
|
+
import { extractWorkSteps, formatWorkSteps } from "./work-step.ts";
|
|
31
|
+
import { buildInjectedMessage, runCritique } from "./review.ts";
|
|
32
|
+
|
|
33
|
+
type Mode = "config" | "review" | "view";
|
|
34
|
+
|
|
35
|
+
interface ParsedArgs {
|
|
36
|
+
mode: Mode;
|
|
37
|
+
count: number;
|
|
38
|
+
focus: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(raw: string): ParsedArgs {
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
let mode: Mode = "review";
|
|
44
|
+
let rest = trimmed;
|
|
45
|
+
|
|
46
|
+
const first = rest.split(/\s+/)[0];
|
|
47
|
+
if (first === "config") return { mode: "config", count: 1, focus: "" };
|
|
48
|
+
if (first === "view") {
|
|
49
|
+
mode = "view";
|
|
50
|
+
rest = rest.slice("view".length).trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let count = 1;
|
|
54
|
+
const countMatch = rest.match(/^(\d+)(?:\s+(.*))?$/);
|
|
55
|
+
if (countMatch) {
|
|
56
|
+
count = Math.min(5, Math.max(1, parseInt(countMatch[1], 10)));
|
|
57
|
+
rest = (countMatch[2] ?? "").trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { mode, count, focus: rest };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Run the review task with a cancelable loader in TUI mode. */
|
|
64
|
+
async function runWithLoader(
|
|
65
|
+
ctx: ExtensionCommandContext,
|
|
66
|
+
task: (signal: AbortSignal | undefined) => Promise<string | null>,
|
|
67
|
+
): Promise<string | null> {
|
|
68
|
+
if (ctx.mode !== "tui") {
|
|
69
|
+
try {
|
|
70
|
+
return await task(undefined);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
77
|
+
const loader = new BorderedLoader(tui, theme, "Running critique...");
|
|
78
|
+
loader.onAbort = () => done(null);
|
|
79
|
+
task(loader.signal)
|
|
80
|
+
.then((result) => done(result))
|
|
81
|
+
.catch((error) => {
|
|
82
|
+
console.error("[critique] review failed:", error);
|
|
83
|
+
done(null);
|
|
84
|
+
});
|
|
85
|
+
return loader;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How many models the picker shows at once; it scrolls beyond that. */
|
|
90
|
+
const MAX_VISIBLE_MODELS = 10;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Paginated model picker (TUI only). Shows at most MAX_VISIBLE_MODELS entries
|
|
94
|
+
* at a time with a scroll indicator; ↑/↓ move, Enter selects, Esc cancels.
|
|
95
|
+
*/
|
|
96
|
+
function pickModel(
|
|
97
|
+
ctx: ExtensionCommandContext,
|
|
98
|
+
title: string,
|
|
99
|
+
items: SelectItem[],
|
|
100
|
+
preselect?: string,
|
|
101
|
+
): Promise<string | undefined> {
|
|
102
|
+
return ctx.ui.custom<string | undefined>((_tui, theme, _kb, done) => {
|
|
103
|
+
const list = new SelectList(items, MAX_VISIBLE_MODELS, getSelectListTheme(), {
|
|
104
|
+
minPrimaryColumnWidth: 24,
|
|
105
|
+
maxPrimaryColumnWidth: 48,
|
|
106
|
+
});
|
|
107
|
+
list.onSelect = (item) => done(item.value);
|
|
108
|
+
list.onCancel = () => done(undefined);
|
|
109
|
+
if (preselect) {
|
|
110
|
+
const index = items.findIndex((item) => item.value === preselect);
|
|
111
|
+
if (index >= 0) list.setSelectedIndex(index);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const container = new Container();
|
|
115
|
+
const border = new DynamicBorder((s: string) => theme.fg("accent", s));
|
|
116
|
+
container.addChild(border);
|
|
117
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
118
|
+
container.addChild(new Text(theme.fg("dim", "↑/↓ move · Enter select · Esc cancel"), 1, 0));
|
|
119
|
+
container.addChild(new Spacer(1));
|
|
120
|
+
container.addChild(list);
|
|
121
|
+
container.addChild(new Spacer(1));
|
|
122
|
+
container.addChild(border);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
render: (width: number) => container.render(width),
|
|
126
|
+
invalidate: () => container.invalidate(),
|
|
127
|
+
handleInput: (data: string) => list.handleInput(data),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Show the review in a scrollable markdown viewer (TUI only). */
|
|
133
|
+
async function showMarkdown(ctx: ExtensionCommandContext, title: string, markdown: string): Promise<void> {
|
|
134
|
+
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
135
|
+
const container = new Container();
|
|
136
|
+
const border = new DynamicBorder((s: string) => theme.fg("accent", s));
|
|
137
|
+
const mdTheme = getMarkdownTheme();
|
|
138
|
+
|
|
139
|
+
container.addChild(border);
|
|
140
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
141
|
+
container.addChild(new Markdown(markdown, 1, 1, mdTheme));
|
|
142
|
+
container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
|
|
143
|
+
container.addChild(border);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
render: (width: number) => container.render(width),
|
|
147
|
+
invalidate: () => container.invalidate(),
|
|
148
|
+
handleInput: (data: string) => {
|
|
149
|
+
if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
|
|
150
|
+
done(undefined);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function runReview(pi: ExtensionAPI, ctx: ExtensionCommandContext, parsed: ParsedArgs): Promise<void> {
|
|
158
|
+
const config = loadConfig();
|
|
159
|
+
|
|
160
|
+
// Make sure any in-flight agent run has fully settled so the session tree
|
|
161
|
+
// contains the complete work step (resolves immediately when idle).
|
|
162
|
+
await ctx.waitForIdle();
|
|
163
|
+
|
|
164
|
+
const branch = ctx.sessionManager.getBranch();
|
|
165
|
+
if (branch.length === 0) {
|
|
166
|
+
ctx.ui.notify("No session content to critique yet.", "warning");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const steps = extractWorkSteps(branch, parsed.count);
|
|
171
|
+
if (steps.length === 0) {
|
|
172
|
+
ctx.ui.notify("No work step found — the session has no assistant tool activity to review.", "warning");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (steps.length < parsed.count) {
|
|
176
|
+
ctx.ui.notify(`Only ${steps.length} work step(s) found; reviewing those.`, "info");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const model = resolveCritiqueModel(ctx, config);
|
|
180
|
+
if (!model) {
|
|
181
|
+
ctx.ui.notify("No critique model available (none with configured auth). Run /critique config to pick one.", "error");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (ctx.model && modelLabel(model) === modelLabel(ctx.model)) {
|
|
186
|
+
ctx.ui.notify("Note: the critique model is the same as the working model. Run /critique config to pick a different one.", "warning");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const workText = formatWorkSteps(steps);
|
|
190
|
+
const review = await runWithLoader(ctx, (signal) => runCritique(ctx, model, workText, parsed.focus, signal));
|
|
191
|
+
|
|
192
|
+
if (review === null) {
|
|
193
|
+
ctx.ui.notify("Critique cancelled.", "info");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!review) {
|
|
197
|
+
ctx.ui.notify("The critique model returned an empty review.", "warning");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const label = modelLabel(model);
|
|
202
|
+
if (parsed.mode === "view" || !config.autoInject) {
|
|
203
|
+
if (ctx.mode === "tui") {
|
|
204
|
+
await showMarkdown(ctx, "Critique review", review);
|
|
205
|
+
} else if (ctx.hasUI) {
|
|
206
|
+
ctx.ui.notify(`Critique (${label}):\n${review.slice(0, 1000)}`, "info");
|
|
207
|
+
} else {
|
|
208
|
+
console.log(`[critique] Review from ${label}:\n${review}`);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const injected = buildInjectedMessage(label, review);
|
|
214
|
+
pi.sendUserMessage(injected, { deliverAs: "followUp" });
|
|
215
|
+
ctx.ui.notify(`Critique feedback from ${label} sent back to the working model.`, "info");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleConfig(ctx: ExtensionCommandContext): Promise<void> {
|
|
219
|
+
if (!ctx.hasUI) {
|
|
220
|
+
ctx.ui.notify("/critique config requires interactive or RPC mode.", "error");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const config = loadConfig();
|
|
225
|
+
const models = pickableModels(ctx);
|
|
226
|
+
|
|
227
|
+
const currentModel = config.model || "auto (a different model than the working one)";
|
|
228
|
+
ctx.ui.notify(
|
|
229
|
+
`Critique config — model: ${currentModel} | auto-inject: ${config.autoInject ? "on" : "off"}`,
|
|
230
|
+
"info",
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if (models.length === 0) {
|
|
234
|
+
ctx.ui.notify("No models with configured auth are available to pick.", "error");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const items: SelectItem[] = [
|
|
239
|
+
{
|
|
240
|
+
value: "",
|
|
241
|
+
label: "Auto",
|
|
242
|
+
description: "Different model than the working one (fallback: current model)",
|
|
243
|
+
},
|
|
244
|
+
...models.map((model) => ({
|
|
245
|
+
value: modelLabel(model),
|
|
246
|
+
label: model.name,
|
|
247
|
+
description: `${model.provider}/${model.id}`,
|
|
248
|
+
})),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
let modelChoice: string | undefined;
|
|
252
|
+
if (ctx.mode === "tui") {
|
|
253
|
+
modelChoice = await pickModel(ctx, "Critique model", items, config.model);
|
|
254
|
+
if (modelChoice === undefined) {
|
|
255
|
+
ctx.ui.notify("Config cancelled.", "info");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
// RPC mode: ctx.ui.custom() is unavailable, fall back to the built-in select.
|
|
260
|
+
const choice = await ctx.ui.select(
|
|
261
|
+
"Critique model:",
|
|
262
|
+
items.map((item) => `${item.label} (${item.description})`),
|
|
263
|
+
);
|
|
264
|
+
if (choice === undefined) {
|
|
265
|
+
ctx.ui.notify("Config cancelled.", "info");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const selected = items.find((item) => `${item.label} (${item.description})` === choice);
|
|
269
|
+
modelChoice = selected?.value ?? "";
|
|
270
|
+
}
|
|
271
|
+
config.model = modelChoice;
|
|
272
|
+
|
|
273
|
+
const autoInject = await ctx.ui.confirm(
|
|
274
|
+
"Auto-inject feedback?",
|
|
275
|
+
"Inject the critique review back into the working model automatically? Choose No to only display reviews (/critique view always only displays).",
|
|
276
|
+
);
|
|
277
|
+
config.autoInject = autoInject;
|
|
278
|
+
|
|
279
|
+
saveConfig(config);
|
|
280
|
+
ctx.ui.notify(
|
|
281
|
+
`Critique config saved — model: ${config.model || "auto"} | auto-inject: ${config.autoInject ? "on" : "off"}`,
|
|
282
|
+
"info",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export default function (pi: ExtensionAPI) {
|
|
287
|
+
pi.registerCommand("critique", {
|
|
288
|
+
description:
|
|
289
|
+
"Review the last work step with a separate model and feed the feedback back to the working model",
|
|
290
|
+
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
291
|
+
const items: AutocompleteItem[] = [
|
|
292
|
+
{ value: "config", label: "config" },
|
|
293
|
+
{ value: "view", label: "view" },
|
|
294
|
+
];
|
|
295
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix));
|
|
296
|
+
return filtered.length > 0 ? filtered : null;
|
|
297
|
+
},
|
|
298
|
+
handler: async (args, ctx) => {
|
|
299
|
+
const parsed = parseArgs(args);
|
|
300
|
+
if (parsed.mode === "config") {
|
|
301
|
+
await handleConfig(ctx);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await runReview(pi, ctx, parsed);
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critique — reviewer prompt and model call.
|
|
3
|
+
*
|
|
4
|
+
* The critique model runs with no tools: it judges the work step purely from
|
|
5
|
+
* the serialized context. Its output is structured Markdown that is then
|
|
6
|
+
* injected back into the working model as advisory feedback.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { type Message, type Model, uuidv7 } from "@earendil-works/pi-ai";
|
|
11
|
+
|
|
12
|
+
const MAX_REVIEW_CHARS = 16_000;
|
|
13
|
+
|
|
14
|
+
export const REVIEWER_SYSTEM_PROMPT = [
|
|
15
|
+
"You are an independent code reviewer. Another agent just performed work in a project session, and your job is to judge whether that work is correct and whether it can be improved.",
|
|
16
|
+
"You have no tools: base your review exclusively on the work step provided below.",
|
|
17
|
+
"",
|
|
18
|
+
"Review for:",
|
|
19
|
+
"1. Correctness — does the work satisfy the user's request? Are there bugs, errors, or gaps?",
|
|
20
|
+
"2. Robustness — edge cases, error handling, security, and failure modes.",
|
|
21
|
+
"3. Maintainability — naming, structure, duplication, readability, and consistency.",
|
|
22
|
+
"4. Efficiency — wasted work, repeated computation, or unnecessarily large changes.",
|
|
23
|
+
"",
|
|
24
|
+
"Be specific and concrete. Reference the actual tool calls and results (files, lines, commands) from the work step. Do not invent issues: if the work is correct, say so and keep suggestions minimal.",
|
|
25
|
+
"If the work step was truncated, note it and review only what is visible.",
|
|
26
|
+
"",
|
|
27
|
+
"Reply using EXACTLY this Markdown structure:",
|
|
28
|
+
"",
|
|
29
|
+
"## Verdict",
|
|
30
|
+
"APPROVED | APPROVED_WITH_SUGGESTIONS | CHANGES_RECOMMENDED",
|
|
31
|
+
"",
|
|
32
|
+
"## Issues",
|
|
33
|
+
"- [severity: critical|major|minor] description",
|
|
34
|
+
"",
|
|
35
|
+
"## Suggestions",
|
|
36
|
+
"- concrete, actionable suggestion",
|
|
37
|
+
"",
|
|
38
|
+
"## Summary",
|
|
39
|
+
"2-4 sentence overall assessment.",
|
|
40
|
+
].join("\n");
|
|
41
|
+
|
|
42
|
+
export function buildReviewPrompt(workText: string, focusNote: string): string {
|
|
43
|
+
const sections: string[] = [];
|
|
44
|
+
sections.push("Review the most recent work performed in this project session.");
|
|
45
|
+
sections.push("");
|
|
46
|
+
sections.push("<work-step>");
|
|
47
|
+
sections.push(workText);
|
|
48
|
+
sections.push("</work-step>");
|
|
49
|
+
if (focusNote) {
|
|
50
|
+
sections.push("");
|
|
51
|
+
sections.push("<focus-note>");
|
|
52
|
+
sections.push(focusNote);
|
|
53
|
+
sections.push("</focus-note>");
|
|
54
|
+
sections.push("Pay special attention to the focus note above, but do not limit your review to it.");
|
|
55
|
+
}
|
|
56
|
+
return sections.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run the critique model. Returns the review text, or null when aborted.
|
|
61
|
+
*/
|
|
62
|
+
export async function runCritique(
|
|
63
|
+
ctx: ExtensionContext,
|
|
64
|
+
model: Model,
|
|
65
|
+
workText: string,
|
|
66
|
+
focusNote: string,
|
|
67
|
+
signal?: AbortSignal,
|
|
68
|
+
): Promise<string | null> {
|
|
69
|
+
const userMessage: Message = {
|
|
70
|
+
role: "user",
|
|
71
|
+
content: [{ type: "text", text: buildReviewPrompt(workText, focusNote) }],
|
|
72
|
+
timestamp: Date.now(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const response = await ctx.modelRegistry.complete(
|
|
76
|
+
model,
|
|
77
|
+
{ systemPrompt: REVIEWER_SYSTEM_PROMPT, messages: [userMessage] },
|
|
78
|
+
{ signal, cacheRetention: "none", sessionId: uuidv7() },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (response.stopReason === "aborted") return null;
|
|
82
|
+
if (response.stopReason === "error") {
|
|
83
|
+
throw new Error(`Critique model ${model.provider}/${model.id} failed: ${response.errorMessage ?? "unknown error"}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const review = response.content
|
|
87
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
88
|
+
.map((block) => block.text)
|
|
89
|
+
.join("\n")
|
|
90
|
+
.trim();
|
|
91
|
+
|
|
92
|
+
// Cap the review so the injected message stays reasonable.
|
|
93
|
+
return review.length > MAX_REVIEW_CHARS
|
|
94
|
+
? `${review.slice(0, MAX_REVIEW_CHARS)}… [review truncated]`
|
|
95
|
+
: review;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Message injected back into the main agent. Shaped as advisory feedback:
|
|
100
|
+
* the working model is the final judge and may accept, partially accept, or
|
|
101
|
+
* reject the critique — the feedback is not a mandatory instruction.
|
|
102
|
+
*/
|
|
103
|
+
export function buildInjectedMessage(critiqueModel: string, review: string): string {
|
|
104
|
+
return [
|
|
105
|
+
"[Critique — advisory review of your last work step]",
|
|
106
|
+
"",
|
|
107
|
+
`A separate reviewer model (\`${critiqueModel}\`) reviewed the work you just performed. This feedback is **advisory, not mandatory**: you are the final judge. Apply only the points that genuinely improve the work, and if you disagree with any of them, briefly explain why and continue.`,
|
|
108
|
+
"",
|
|
109
|
+
"--- Review ---",
|
|
110
|
+
"",
|
|
111
|
+
review,
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
package/src/work-step.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critique — work-step extraction.
|
|
3
|
+
*
|
|
4
|
+
* A "work step" is an episode: everything the agent did in response to the
|
|
5
|
+
* latest user request — all assistant messages, tool calls, and tool results
|
|
6
|
+
* up to the next user message. The last work step is the last episode that
|
|
7
|
+
* contains actual work (tool calls or assistant output).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export interface WorkStepToolCall {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
args: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WorkStepToolResult {
|
|
19
|
+
callId: string;
|
|
20
|
+
toolName: string;
|
|
21
|
+
content: string;
|
|
22
|
+
isError: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WorkStep {
|
|
26
|
+
userPrompt: string;
|
|
27
|
+
assistantText: string;
|
|
28
|
+
toolCalls: WorkStepToolCall[];
|
|
29
|
+
toolResults: WorkStepToolResult[];
|
|
30
|
+
modelLabel: string | undefined;
|
|
31
|
+
/** True when the episode contains tool calls or assistant text. */
|
|
32
|
+
hasWork: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MAX_USER_PROMPT = 12_000;
|
|
36
|
+
const MAX_ASSISTANT_TEXT = 8_000;
|
|
37
|
+
const MAX_TOOL_ARGS = 4_000;
|
|
38
|
+
const MAX_TOOL_RESULT = 8_000;
|
|
39
|
+
const MAX_TOTAL = 60_000;
|
|
40
|
+
|
|
41
|
+
export function truncateText(text: string, max: number): string {
|
|
42
|
+
if (text.length <= max) return text;
|
|
43
|
+
return `${text.slice(0, max)}… [truncated]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Permissive view of an AgentMessage so extraction survives schema drift. */
|
|
47
|
+
interface LooseMessage {
|
|
48
|
+
role?: string;
|
|
49
|
+
content?: unknown;
|
|
50
|
+
toolCallId?: string;
|
|
51
|
+
toolName?: string;
|
|
52
|
+
isError?: boolean;
|
|
53
|
+
provider?: string;
|
|
54
|
+
model?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ContentBlock {
|
|
58
|
+
type?: string;
|
|
59
|
+
text?: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
id?: string;
|
|
62
|
+
arguments?: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function contentBlocks(content: unknown): ContentBlock[] {
|
|
66
|
+
if (!Array.isArray(content)) return [];
|
|
67
|
+
return content.filter((block): block is ContentBlock => !!block && typeof block === "object");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function contentText(content: unknown): string {
|
|
71
|
+
if (typeof content === "string") return content.trim();
|
|
72
|
+
return contentBlocks(content)
|
|
73
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
74
|
+
.map((block) => block.text as string)
|
|
75
|
+
.join("\n")
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toolCallsOf(content: unknown): WorkStepToolCall[] {
|
|
80
|
+
return contentBlocks(content)
|
|
81
|
+
.filter((block) => block.type === "toolCall" && typeof block.name === "string")
|
|
82
|
+
.map((block) => ({
|
|
83
|
+
id: block.id ?? "",
|
|
84
|
+
name: block.name as string,
|
|
85
|
+
args: JSON.stringify(block.arguments ?? {}),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function messageOf(entry: SessionEntry): LooseMessage {
|
|
90
|
+
return entry.message as unknown as LooseMessage;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isUserEntry(entry: SessionEntry): boolean {
|
|
94
|
+
return entry.type === "message" && messageOf(entry).role === "user";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract the most recent work steps from a session branch (root → leaf).
|
|
99
|
+
* Returns up to `count` steps, newest last. Episodes without work (e.g. a
|
|
100
|
+
* bare "/critique" prompt) are skipped.
|
|
101
|
+
*/
|
|
102
|
+
export function extractWorkSteps(branch: SessionEntry[], count: number): WorkStep[] {
|
|
103
|
+
// Split the branch into episodes at user-message boundaries.
|
|
104
|
+
const boundaries: number[] = [];
|
|
105
|
+
for (let i = 0; i < branch.length; i++) {
|
|
106
|
+
if (isUserEntry(branch[i])) boundaries.push(i);
|
|
107
|
+
}
|
|
108
|
+
boundaries.push(branch.length);
|
|
109
|
+
|
|
110
|
+
const steps: WorkStep[] = [];
|
|
111
|
+
for (let k = boundaries.length - 2; k >= 0 && steps.length < count; k--) {
|
|
112
|
+
const start = boundaries[k];
|
|
113
|
+
const end = boundaries[k + 1];
|
|
114
|
+
const step = buildStep(branch, start, end);
|
|
115
|
+
if (step.hasWork) steps.push(step);
|
|
116
|
+
}
|
|
117
|
+
return steps;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildStep(branch: SessionEntry[], start: number, end: number): WorkStep {
|
|
121
|
+
const userPrompt = contentText(messageOf(branch[start]).content);
|
|
122
|
+
const toolCalls: WorkStepToolCall[] = [];
|
|
123
|
+
const toolResults: WorkStepToolResult[] = [];
|
|
124
|
+
const textParts: string[] = [];
|
|
125
|
+
let modelLabel: string | undefined;
|
|
126
|
+
|
|
127
|
+
for (let i = start + 1; i < end; i++) {
|
|
128
|
+
const entry = branch[i];
|
|
129
|
+
if (entry.type !== "message") continue;
|
|
130
|
+
const message = messageOf(entry);
|
|
131
|
+
|
|
132
|
+
if (message.role === "assistant") {
|
|
133
|
+
toolCalls.push(...toolCallsOf(message.content));
|
|
134
|
+
const text = contentText(message.content);
|
|
135
|
+
if (text) textParts.push(text);
|
|
136
|
+
if (message.provider && message.model) {
|
|
137
|
+
modelLabel = `${message.provider}/${message.model}`;
|
|
138
|
+
} else if (message.model) {
|
|
139
|
+
modelLabel = message.model;
|
|
140
|
+
}
|
|
141
|
+
} else if (message.role === "toolResult") {
|
|
142
|
+
const content = contentText(message.content);
|
|
143
|
+
if (message.toolCallId && (content.length > 0 || message.isError)) {
|
|
144
|
+
toolResults.push({
|
|
145
|
+
callId: message.toolCallId,
|
|
146
|
+
toolName: message.toolName ?? "tool",
|
|
147
|
+
content,
|
|
148
|
+
isError: !!message.isError,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hasWork = toolCalls.length > 0 || textParts.length > 0;
|
|
155
|
+
return {
|
|
156
|
+
userPrompt,
|
|
157
|
+
assistantText: textParts.join("\n\n"),
|
|
158
|
+
toolCalls,
|
|
159
|
+
toolResults,
|
|
160
|
+
modelLabel,
|
|
161
|
+
hasWork,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Render work steps as a self-contained text block for the reviewer model. */
|
|
166
|
+
export function formatWorkSteps(steps: WorkStep[]): string {
|
|
167
|
+
const sections = steps.map((step, index) => {
|
|
168
|
+
const position = steps.length - index;
|
|
169
|
+
const lines: string[] = [];
|
|
170
|
+
lines.push(`### Work step ${position}${step.modelLabel ? ` (model: ${step.modelLabel})` : ""}`);
|
|
171
|
+
|
|
172
|
+
if (step.userPrompt) {
|
|
173
|
+
lines.push(`\n**User request:**\n${truncateText(step.userPrompt, MAX_USER_PROMPT)}`);
|
|
174
|
+
}
|
|
175
|
+
if (step.assistantText) {
|
|
176
|
+
lines.push(`\n**Assistant messages:**\n${truncateText(step.assistantText, MAX_ASSISTANT_TEXT)}`);
|
|
177
|
+
}
|
|
178
|
+
if (step.toolCalls.length > 0) {
|
|
179
|
+
lines.push(`\n**Tool calls:**`);
|
|
180
|
+
for (const call of step.toolCalls) {
|
|
181
|
+
lines.push(`- \`${call.name}(${truncateText(call.args, MAX_TOOL_ARGS)})\``);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (step.toolResults.length > 0) {
|
|
185
|
+
lines.push(`\n**Tool results:**`);
|
|
186
|
+
for (const result of step.toolResults) {
|
|
187
|
+
const flag = result.isError ? " (ERROR)" : "";
|
|
188
|
+
lines.push(`- \`${result.toolName}\`${flag}:`);
|
|
189
|
+
const body = truncateText(result.content, MAX_TOOL_RESULT);
|
|
190
|
+
for (const line of body.split("\n")) {
|
|
191
|
+
lines.push(` ${line}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
let output = sections.join("\n\n---\n\n");
|
|
199
|
+
if (output.length > MAX_TOTAL) {
|
|
200
|
+
output = `${output.slice(0, MAX_TOTAL)}… [work step truncated]`;
|
|
201
|
+
}
|
|
202
|
+
return output;
|
|
203
|
+
}
|