@zephyrdeng/pi-review 0.5.1 → 0.6.1
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 +10 -1
- package/extensions/review.ts +95 -3
- package/extensions/rv-completions.test.ts +218 -0
- package/extensions/rv-completions.ts +489 -0
- package/extensions/rv-locale.test.ts +17 -0
- package/extensions/rv-locale.ts +81 -0
- package/extensions/rv-model-priorities.test.ts +69 -0
- package/extensions/rv-model-priorities.ts +138 -0
- package/extensions/rv-prompts.test.ts +24 -0
- package/extensions/rv-prompts.ts +48 -15
- package/extensions/rv-semantic.test.ts +23 -0
- package/extensions/rv-semantic.ts +110 -0
- package/package.json +3 -3
- package/resources/rv-model-priorities.json +17 -0
- package/skills/pi-review/SKILL.md +9 -6
- package/skills/pi-review/references/codex-tools.md +2 -0
- package/skills/pi-review/references/model-selection.md +72 -0
package/README.md
CHANGED
|
@@ -222,7 +222,16 @@ The `/rv` command injects a task message for the parent agent (Pi host rules: de
|
|
|
222
222
|
/rv --mode challenge --keep-session @docs/design.md
|
|
223
223
|
```
|
|
224
224
|
|
|
225
|
-
Argument completions
|
|
225
|
+
Argument completions are context-aware. After the host session starts, `/rv` reads the live model registry and offers:
|
|
226
|
+
|
|
227
|
+
- **Model list** after `--model `: candidates come from the live Pi registry; order follows **`resources/rv-model-priorities.json`** (override with `PI_REVIEW_RV_PRIORITIES`). Presets match registry ids by substring and prefer newer version strings (e.g. kimi `2.7`, `claude-opus-4-8`). Profiles: **code** (e.g. gpt-5.5 `:xhigh`, glm-5.2 `:high`), **frontend** (vue/css/… → kimi, claude-sonnet, minimax-m3), **plan/challenge** (claude-opus-4-8, deepseek-v4-pro, …).
|
|
228
|
+
- **Thinking suffix** after `provider/model:` — only levels the chosen model actually supports.
|
|
229
|
+
- **Semantic phrases** (e.g. `code review` / 代码审核, `查看模型列表`) in addition to flags; orchestration prompts follow **session locale** (zh/en) for summaries.
|
|
230
|
+
- **Scene templates** at the top level (code / frontend / plan presets).
|
|
231
|
+
|
|
232
|
+
**Claude Code / Codex:** the bundled skill includes **[skills/pi-review/references/model-selection.md](skills/pi-review/references/model-selection.md)** — same presets as `/rv` for choosing `--model` after `pi-review models`.
|
|
233
|
+
|
|
234
|
+
Completions are a hint layer only; execution remains skill-driven. When the model registry is unavailable (e.g. non-TUI mode), `/rv` falls back to the static hint list.
|
|
226
235
|
|
|
227
236
|
## Security
|
|
228
237
|
|
package/extensions/review.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
buildRvCompletions,
|
|
4
|
+
type ModelInfo,
|
|
5
|
+
} from "./rv-completions.js";
|
|
6
|
+
import { detectRvLocale } from "./rv-locale.js";
|
|
2
7
|
import {
|
|
3
8
|
buildRvOrchestrationPrompt,
|
|
4
9
|
parseRvArgs,
|
|
@@ -6,11 +11,98 @@ import {
|
|
|
6
11
|
validateRvParsed,
|
|
7
12
|
} from "./rv-prompts.js";
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Captured at `session_start` so the synchronous-ish `getArgumentCompletions`
|
|
16
|
+
* callback (which receives only the argument prefix) can read the live model
|
|
17
|
+
* registry without ctx. Stays undefined in non-TUI/print mode; completions then
|
|
18
|
+
* gracefully degrade to the static `RV_COMPLETIONS` fallback.
|
|
19
|
+
*/
|
|
20
|
+
let capturedModels: ModelInfo[] | undefined;
|
|
21
|
+
let capturedPrimaryProvider: string | undefined;
|
|
22
|
+
let capturedLocale: ReturnType<typeof detectRvLocale> = "en";
|
|
23
|
+
|
|
24
|
+
function sampleSessionText(
|
|
25
|
+
sessionManager: { getEntries?: () => Array<{ type?: string; content?: unknown }> },
|
|
26
|
+
): string[] {
|
|
27
|
+
const out: string[] = [];
|
|
28
|
+
try {
|
|
29
|
+
const entries = sessionManager.getEntries?.() ?? [];
|
|
30
|
+
for (let i = entries.length - 1; i >= 0 && out.length < 12; i--) {
|
|
31
|
+
const e = entries[i];
|
|
32
|
+
if (e.type !== "message") continue;
|
|
33
|
+
const c = e.content;
|
|
34
|
+
if (typeof c === "string") out.push(c);
|
|
35
|
+
else if (c && typeof c === "object" && "text" in c && typeof (c as { text: string }).text === "string") {
|
|
36
|
+
out.push((c as { text: string }).text);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
/* ignore */
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toModelInfo(m: {
|
|
46
|
+
provider: string;
|
|
47
|
+
id: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
reasoning?: boolean;
|
|
50
|
+
contextWindow?: number;
|
|
51
|
+
thinkingLevelMap?: Record<string, string | null | undefined>;
|
|
52
|
+
}): ModelInfo {
|
|
53
|
+
const levels: string[] = [];
|
|
54
|
+
const map = m.thinkingLevelMap ?? {};
|
|
55
|
+
for (const k of ["off", "minimal", "low", "medium", "high", "xhigh"]) {
|
|
56
|
+
// A level is supported unless explicitly disabled (null) and is present in the map.
|
|
57
|
+
if (k in map && map[k] !== null && map[k] !== undefined) levels.push(k);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
provider: m.provider,
|
|
61
|
+
id: m.id,
|
|
62
|
+
label: `${m.provider}/${m.id}`,
|
|
63
|
+
name: m.name ?? m.id,
|
|
64
|
+
reasoning: Boolean(m.reasoning),
|
|
65
|
+
contextWindow: m.contextWindow ?? 0,
|
|
66
|
+
thinkingLevels: levels,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
9
70
|
export default function piReviewExtension(pi: ExtensionAPI) {
|
|
71
|
+
pi.on("session_start", (_event, ctx) => {
|
|
72
|
+
try {
|
|
73
|
+
const available = ctx.modelRegistry?.getAvailable?.() ?? [];
|
|
74
|
+
capturedModels = available.map((m) => toModelInfo(m as unknown as Parameters<typeof toModelInfo>[0]));
|
|
75
|
+
capturedPrimaryProvider = ctx.model?.provider;
|
|
76
|
+
capturedLocale = detectRvLocale(sampleSessionText(ctx.sessionManager));
|
|
77
|
+
} catch {
|
|
78
|
+
capturedModels = undefined;
|
|
79
|
+
capturedPrimaryProvider = undefined;
|
|
80
|
+
capturedLocale = "en";
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function localeForHandler(ctx: { sessionManager?: Parameters<typeof sampleSessionText>[0] }): typeof capturedLocale {
|
|
85
|
+
try {
|
|
86
|
+
return detectRvLocale(sampleSessionText(ctx.sessionManager ?? { getEntries: () => [] }));
|
|
87
|
+
} catch {
|
|
88
|
+
return capturedLocale;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
10
92
|
pi.registerCommand("rv", {
|
|
11
93
|
description:
|
|
12
|
-
"Delegate pi-review to the agent. Usage: /rv [--mode plan|challenge] [--model id] [--keep-session] [--no-stream] @target | /rv --continue <handle> [opts] [text] | models",
|
|
13
|
-
getArgumentCompletions: (prefix) => {
|
|
94
|
+
"Delegate pi-review to the agent. Usage: /rv [--mode plan|challenge] [--model id] [--thinking level] [--keep-session] [--no-stream] @target | /rv --continue <handle> [opts] [text] | models",
|
|
95
|
+
getArgumentCompletions: (prefix): ReturnType<typeof buildRvCompletions> => {
|
|
96
|
+
// Try dynamic completion first (models + thinking + scene templates).
|
|
97
|
+
if (capturedModels && capturedModels.length > 0) {
|
|
98
|
+
const dynamic = buildRvCompletions(prefix, {
|
|
99
|
+
models: capturedModels,
|
|
100
|
+
primaryProvider: capturedPrimaryProvider,
|
|
101
|
+
locale: capturedLocale,
|
|
102
|
+
});
|
|
103
|
+
if (dynamic && dynamic.length) return dynamic;
|
|
104
|
+
}
|
|
105
|
+
// Graceful fallback to static hint list when registry is unavailable.
|
|
14
106
|
const filtered = RV_COMPLETIONS.filter((item) => item.value.startsWith(prefix));
|
|
15
107
|
return filtered.length
|
|
16
108
|
? filtered.map(({ value, hint }) => ({ value, label: value, description: hint }))
|
|
@@ -35,7 +127,7 @@ export default function piReviewExtension(pi: ExtensionAPI) {
|
|
|
35
127
|
return;
|
|
36
128
|
}
|
|
37
129
|
|
|
38
|
-
pi.sendUserMessage(buildRvOrchestrationPrompt(parsed));
|
|
130
|
+
pi.sendUserMessage(buildRvOrchestrationPrompt(parsed, localeForHandler(ctx)));
|
|
39
131
|
},
|
|
40
132
|
});
|
|
41
133
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildRvCompletions,
|
|
5
|
+
extractSignals,
|
|
6
|
+
fuzzyMatch,
|
|
7
|
+
MODE_HINTS,
|
|
8
|
+
rankModelsForReview,
|
|
9
|
+
tokenizeArgPrefix,
|
|
10
|
+
type ModelInfo,
|
|
11
|
+
} from "./rv-completions.js";
|
|
12
|
+
import { rvUi } from "./rv-locale.js";
|
|
13
|
+
import { DEFAULT_REVIEW_MODEL_PRIORITIES } from "./rv-model-priorities.js";
|
|
14
|
+
|
|
15
|
+
function model(partial: Partial<ModelInfo> & Pick<ModelInfo, "provider" | "id">): ModelInfo {
|
|
16
|
+
return {
|
|
17
|
+
label: `${partial.provider}/${partial.id}`,
|
|
18
|
+
name: partial.id,
|
|
19
|
+
reasoning: false,
|
|
20
|
+
contextWindow: 200_000,
|
|
21
|
+
thinkingLevels: ["off", "low", "medium", "high"],
|
|
22
|
+
...partial,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MODELS: ModelInfo[] = [
|
|
27
|
+
model({ provider: "anthropic", id: "claude-opus-4-6", reasoning: true, contextWindow: 200_000, thinkingLevels: ["off", "low", "medium", "high", "xhigh"] }),
|
|
28
|
+
model({ provider: "openai", id: "gpt-5.5", reasoning: true, contextWindow: 400_000, thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh"] }),
|
|
29
|
+
model({ provider: "zenmux", id: "gemini-3-flash", reasoning: false, contextWindow: 1_000_000, thinkingLevels: [] }),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
describe("tokenizeArgPrefix", () => {
|
|
33
|
+
it("splits head and tail on the last space", () => {
|
|
34
|
+
const t = tokenizeArgPrefix("@src/foo.ts --model ope");
|
|
35
|
+
assert.deepEqual(t.head, ["@src/foo.ts", "--model"]);
|
|
36
|
+
assert.equal(t.tail, "ope");
|
|
37
|
+
assert.equal(t.prev, "--model");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("treats a single token as tail with empty head", () => {
|
|
41
|
+
const t = tokenizeArgPrefix("--mode");
|
|
42
|
+
assert.deepEqual(t.head, []);
|
|
43
|
+
assert.equal(t.tail, "--mode");
|
|
44
|
+
assert.equal(t.prev, null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("handles trailing space as empty tail", () => {
|
|
48
|
+
const t = tokenizeArgPrefix("@a.ts ");
|
|
49
|
+
assert.deepEqual(t.head, ["@a.ts"]);
|
|
50
|
+
assert.equal(t.tail, "");
|
|
51
|
+
assert.equal(t.prev, "@a.ts");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("strips surrounding quotes in head tokens", () => {
|
|
55
|
+
const t = tokenizeArgPrefix('"@a b.ts" --mode ');
|
|
56
|
+
assert.deepEqual(t.head, ["@a b.ts", "--mode"]);
|
|
57
|
+
assert.deepEqual(t.rawHead, ['"@a b.ts"', "--mode"]);
|
|
58
|
+
assert.equal(t.prev, "--mode");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("extractSignals", () => {
|
|
63
|
+
it("detects target extension and explicit mode", () => {
|
|
64
|
+
const sig = extractSignals(["@docs/design.md", "--mode", "plan"], {});
|
|
65
|
+
assert.equal(sig.mode, "plan");
|
|
66
|
+
assert.equal(sig.targetExt, "md");
|
|
67
|
+
assert.equal(sig.hasTarget, true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("defaults mode to code", () => {
|
|
71
|
+
const sig = extractSignals(["@src/foo.ts"], {});
|
|
72
|
+
assert.equal(sig.mode, "code");
|
|
73
|
+
assert.equal(sig.targetExt, "ts");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("carries primary provider on signals", () => {
|
|
77
|
+
const sig = extractSignals([], { primaryProvider: "anthropic" });
|
|
78
|
+
assert.equal(sig.primaryProvider, "anthropic");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("rankModelsForReview", () => {
|
|
83
|
+
it("puts preset code models first when registered", () => {
|
|
84
|
+
const sig = extractSignals(["@src/a.ts"], {});
|
|
85
|
+
const ranked = rankModelsForReview(MODELS, sig, DEFAULT_REVIEW_MODEL_PRIORITIES);
|
|
86
|
+
assert.equal(ranked[0].id, "gpt-5.5");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("puts preset plan models first for plan mode", () => {
|
|
90
|
+
const sig = extractSignals(["--mode", "plan"], {});
|
|
91
|
+
const ranked = rankModelsForReview(MODELS, sig, DEFAULT_REVIEW_MODEL_PRIORITIES);
|
|
92
|
+
assert.equal(ranked[0].id, "claude-opus-4-6");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("fuzzyMatch", () => {
|
|
97
|
+
it("matches subsequence case-insensitively", () => {
|
|
98
|
+
assert.equal(fuzzyMatch("anthropic/claude-opus-4-6", "aclaude"), true);
|
|
99
|
+
assert.equal(fuzzyMatch("Anthropic/Claude", "acl"), true);
|
|
100
|
+
assert.equal(fuzzyMatch("openai/gpt-5.5", "zzz"), false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("buildRvCompletions", () => {
|
|
105
|
+
const deps = {
|
|
106
|
+
models: MODELS,
|
|
107
|
+
primaryProvider: "anthropic",
|
|
108
|
+
priorities: DEFAULT_REVIEW_MODEL_PRIORITIES,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
it("returns dynamic model list after --model, with recommended marker on top items", () => {
|
|
112
|
+
const items = buildRvCompletions("@x.md --model ", deps);
|
|
113
|
+
assert.ok(items, "expected items");
|
|
114
|
+
assert.ok(items!.some((i) => i.label === "openai/gpt-5.5"));
|
|
115
|
+
const top = items!.find((i) => i.description?.includes("Suggested"));
|
|
116
|
+
assert.ok(top, "expected at least one recommended item");
|
|
117
|
+
// value carries the head so applyCompletion replaces the whole arg safely
|
|
118
|
+
assert.ok(top!.value.startsWith("@x.md --model "));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("filters models by a partial query", () => {
|
|
122
|
+
const items = buildRvCompletions("--model ope", deps);
|
|
123
|
+
assert.ok(items);
|
|
124
|
+
assert.ok(items!.every((i) => i.label.includes("openai")));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("completes thinking suffix after provider/id: for a reasoning model", () => {
|
|
128
|
+
const items = buildRvCompletions("--model openai/gpt-5.5:", deps);
|
|
129
|
+
assert.ok(items);
|
|
130
|
+
const labels = items!.map((i) => i.label);
|
|
131
|
+
assert.ok(labels.includes("high"));
|
|
132
|
+
assert.ok(labels.includes("xhigh"));
|
|
133
|
+
assert.ok(items!.every((i) => i.value.startsWith("--model openai/gpt-5.5:")));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("does not offer thinking suffix for a non-reasoning model", () => {
|
|
137
|
+
const items = buildRvCompletions("--model zenmux/gemini-3-flash:", deps);
|
|
138
|
+
// flash has empty thinkingLevels → no suffix candidates
|
|
139
|
+
assert.equal(items, null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("completes --mode values with hints", () => {
|
|
143
|
+
const items = buildRvCompletions("--mode pl", deps);
|
|
144
|
+
assert.ok(items);
|
|
145
|
+
assert.deepEqual(items!.map((i) => i.label), ["plan"]);
|
|
146
|
+
assert.equal(items![0].description, MODE_HINTS["plan"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("completes --thinking levels", () => {
|
|
150
|
+
const items = buildRvCompletions("--thinking h", deps);
|
|
151
|
+
assert.ok(items);
|
|
152
|
+
assert.deepEqual(items!.map((i) => i.label).sort(), ["high"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("completes flags when tail starts with --", () => {
|
|
156
|
+
const items = buildRvCompletions("--keep", deps);
|
|
157
|
+
assert.ok(items);
|
|
158
|
+
assert.deepEqual(items!.map((i) => i.label), ["--keep-session"]);
|
|
159
|
+
assert.ok(items![0].value === "--keep-session");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("defers to built-in file completion when tail starts with @", () => {
|
|
163
|
+
assert.equal(buildRvCompletions("@src/foo", deps), null);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("defers --continue value to the handler (no static list)", () => {
|
|
167
|
+
assert.equal(buildRvCompletions("--continue abc", deps), null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("offers scene templates + models keyword at empty top level", () => {
|
|
171
|
+
const items = buildRvCompletions("", deps);
|
|
172
|
+
const ui = rvUi("en");
|
|
173
|
+
assert.ok(items);
|
|
174
|
+
assert.ok(items!.some((i) => i.label === ui.listModels));
|
|
175
|
+
assert.ok(items!.some((i) => i.label === ui.codePreset));
|
|
176
|
+
const tmpl = items!.find((i) => i.label === ui.codePreset)!;
|
|
177
|
+
assert.ok(tmpl.value.endsWith(" @"));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("every returned value preserves the head prefix (整段替换安全)", () => {
|
|
181
|
+
const cases = ["@a.ts --mode ", "@a.ts --model ", "@a.ts --thinking ", "@a.ts --"];
|
|
182
|
+
for (const c of cases) {
|
|
183
|
+
const items = buildRvCompletions(c, deps);
|
|
184
|
+
if (!items) continue;
|
|
185
|
+
for (const it of items) {
|
|
186
|
+
assert.ok(
|
|
187
|
+
it.value.startsWith(c),
|
|
188
|
+
`value "${it.value}" must preserve head "${c}"`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("preserves quotes in headPrefix for paths with spaces (note #2 fix)", () => {
|
|
195
|
+
const items = buildRvCompletions('"@a b.ts" --model ', deps);
|
|
196
|
+
assert.ok(items);
|
|
197
|
+
for (const it of items) {
|
|
198
|
+
assert.ok(
|
|
199
|
+
it.value.startsWith('"@a b.ts" --model '),
|
|
200
|
+
`value "${it.value}" must preserve quoted head`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("omits preset scene templates when registry has no preset id match", () => {
|
|
206
|
+
const nonReasoning = [{ ...MODELS[2], thinkingLevels: [] }] as ModelInfo[];
|
|
207
|
+
const items = buildRvCompletions("", { models: nonReasoning, priorities: DEFAULT_REVIEW_MODEL_PRIORITIES });
|
|
208
|
+
assert.ok(items);
|
|
209
|
+
assert.ok(!items!.some((i) => i.label === rvUi("en").codePreset));
|
|
210
|
+
assert.ok(items!.some((i) => i.label === rvUi("en").listModels));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("falls back gracefully when models are unavailable (static flags still work)", () => {
|
|
214
|
+
const items = buildRvCompletions("--mode ", {});
|
|
215
|
+
assert.ok(items);
|
|
216
|
+
assert.ok(items!.some((i) => i.label === "code" || i.label === "plan" || i.label === "challenge"));
|
|
217
|
+
});
|
|
218
|
+
});
|