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 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
+ ![Critique banner](docs/banner.png)
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
+ }
@@ -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
+ }