@zephyrdeng/pi-review 0.6.0 → 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 CHANGED
@@ -224,9 +224,12 @@ The `/rv` command injects a task message for the parent agent (Pi host rules: de
224
224
 
225
225
  Argument completions are context-aware. After the host session starts, `/rv` reads the live model registry and offers:
226
226
 
227
- - **Model list** after `--model `, ranked by review fit (reasoning, context window, cross-vendor bonus for adversarial review) with a `★ 推荐` marker and a one-line reason.
228
- - **Thinking suffix** after `provider/model:` — only levels the chosen model actually supports (e.g. `:high`, `:xhigh`); non-reasoning models offer none.
229
- - **`--mode` / `--thinking` / flag** completions with hints, and **scene templates** (e.g. “审代码改动(推荐配置)”) at the top level that pre-fill a recommended command.
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`.
230
233
 
231
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.
232
235
 
@@ -3,6 +3,7 @@ import {
3
3
  buildRvCompletions,
4
4
  type ModelInfo,
5
5
  } from "./rv-completions.js";
6
+ import { detectRvLocale } from "./rv-locale.js";
6
7
  import {
7
8
  buildRvOrchestrationPrompt,
8
9
  parseRvArgs,
@@ -18,6 +19,28 @@ import {
18
19
  */
19
20
  let capturedModels: ModelInfo[] | undefined;
20
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
+ }
21
44
 
22
45
  function toModelInfo(m: {
23
46
  provider: string;
@@ -50,12 +73,22 @@ export default function piReviewExtension(pi: ExtensionAPI) {
50
73
  const available = ctx.modelRegistry?.getAvailable?.() ?? [];
51
74
  capturedModels = available.map((m) => toModelInfo(m as unknown as Parameters<typeof toModelInfo>[0]));
52
75
  capturedPrimaryProvider = ctx.model?.provider;
76
+ capturedLocale = detectRvLocale(sampleSessionText(ctx.sessionManager));
53
77
  } catch {
54
78
  capturedModels = undefined;
55
79
  capturedPrimaryProvider = undefined;
80
+ capturedLocale = "en";
56
81
  }
57
82
  });
58
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
+
59
92
  pi.registerCommand("rv", {
60
93
  description:
61
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",
@@ -65,6 +98,7 @@ export default function piReviewExtension(pi: ExtensionAPI) {
65
98
  const dynamic = buildRvCompletions(prefix, {
66
99
  models: capturedModels,
67
100
  primaryProvider: capturedPrimaryProvider,
101
+ locale: capturedLocale,
68
102
  });
69
103
  if (dynamic && dynamic.length) return dynamic;
70
104
  }
@@ -93,7 +127,7 @@ export default function piReviewExtension(pi: ExtensionAPI) {
93
127
  return;
94
128
  }
95
129
 
96
- pi.sendUserMessage(buildRvOrchestrationPrompt(parsed));
130
+ pi.sendUserMessage(buildRvOrchestrationPrompt(parsed, localeForHandler(ctx)));
97
131
  },
98
132
  });
99
133
  }
@@ -6,10 +6,11 @@ import {
6
6
  fuzzyMatch,
7
7
  MODE_HINTS,
8
8
  rankModelsForReview,
9
- scoreModelForReview,
10
9
  tokenizeArgPrefix,
11
10
  type ModelInfo,
12
11
  } from "./rv-completions.js";
12
+ import { rvUi } from "./rv-locale.js";
13
+ import { DEFAULT_REVIEW_MODEL_PRIORITIES } from "./rv-model-priorities.js";
13
14
 
14
15
  function model(partial: Partial<ModelInfo> & Pick<ModelInfo, "provider" | "id">): ModelInfo {
15
16
  return {
@@ -72,28 +73,23 @@ describe("extractSignals", () => {
72
73
  assert.equal(sig.targetExt, "ts");
73
74
  });
74
75
 
75
- it("carries primary provider for cross-vendor bonus", () => {
76
+ it("carries primary provider on signals", () => {
76
77
  const sig = extractSignals([], { primaryProvider: "anthropic" });
77
78
  assert.equal(sig.primaryProvider, "anthropic");
78
79
  });
79
80
  });
80
81
 
81
- describe("scoreModelForReview / rankModelsForReview", () => {
82
- it("scores reasoning + cross-vendor + xhigh higher than a cheap non-reasoning model", () => {
83
- const sig = { mode: "challenge", hasTarget: true, targetExt: "md", primaryProvider: "anthropic" };
84
- const opus = scoreModelForReview(MODELS[0], sig);
85
- const gpt = scoreModelForReview(MODELS[1], sig);
86
- const flash = scoreModelForReview(MODELS[2], sig);
87
- assert.ok(gpt.score > opus.score, `gpt (${gpt.score}) should beat opus (${opus.score}) for challenge+cross-vendor`);
88
- assert.ok(opus.score > flash.score, "opus should beat the non-reasoning flash");
89
- assert.ok(flash.score >= 2, "flash still gets context-window points");
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");
90
87
  });
91
88
 
92
- it("ranks descending by score", () => {
93
- const sig = { mode: "plan", hasTarget: false, primaryProvider: "anthropic" };
94
- const ranked = rankModelsForReview(MODELS, sig);
95
- assert.equal(ranked[0].id, "gpt-5.5");
96
- assert.equal(ranked[ranked.length - 1].id, "gemini-3-flash");
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");
97
93
  });
98
94
  });
99
95
 
@@ -106,13 +102,17 @@ describe("fuzzyMatch", () => {
106
102
  });
107
103
 
108
104
  describe("buildRvCompletions", () => {
109
- const deps = { models: MODELS, primaryProvider: "anthropic" };
105
+ const deps = {
106
+ models: MODELS,
107
+ primaryProvider: "anthropic",
108
+ priorities: DEFAULT_REVIEW_MODEL_PRIORITIES,
109
+ };
110
110
 
111
111
  it("returns dynamic model list after --model, with recommended marker on top items", () => {
112
112
  const items = buildRvCompletions("@x.md --model ", deps);
113
113
  assert.ok(items, "expected items");
114
114
  assert.ok(items!.some((i) => i.label === "openai/gpt-5.5"));
115
- const top = items!.find((i) => i.description?.includes("★ 推荐"));
115
+ const top = items!.find((i) => i.description?.includes("Suggested"));
116
116
  assert.ok(top, "expected at least one recommended item");
117
117
  // value carries the head so applyCompletion replaces the whole arg safely
118
118
  assert.ok(top!.value.startsWith("@x.md --model "));
@@ -169,11 +169,11 @@ describe("buildRvCompletions", () => {
169
169
 
170
170
  it("offers scene templates + models keyword at empty top level", () => {
171
171
  const items = buildRvCompletions("", deps);
172
+ const ui = rvUi("en");
172
173
  assert.ok(items);
173
- assert.ok(items!.some((i) => i.label === "models"));
174
- assert.ok(items!.some((i) => i.label.startsWith("审代码改动")));
175
- // template values end with " @" so the user can keep typing a path
176
- const tmpl = items!.find((i) => i.label.startsWith("审代码改动"))!;
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
177
  assert.ok(tmpl.value.endsWith(" @"));
178
178
  });
179
179
 
@@ -202,15 +202,12 @@ describe("buildRvCompletions", () => {
202
202
  }
203
203
  });
204
204
 
205
- it("scene templates omit the thinking colon for non-reasoning models (note #1 fix)", () => {
205
+ it("omits preset scene templates when registry has no preset id match", () => {
206
206
  const nonReasoning = [{ ...MODELS[2], thinkingLevels: [] }] as ModelInfo[];
207
- const items = buildRvCompletions("", { models: nonReasoning });
207
+ const items = buildRvCompletions("", { models: nonReasoning, priorities: DEFAULT_REVIEW_MODEL_PRIORITIES });
208
208
  assert.ok(items);
209
- const tmpl = items!.find((i) => i.label.startsWith("审代码改动"));
210
- assert.ok(tmpl, "expected a scene template even for non-reasoning models");
211
- // Must NOT contain a dangling colon like `gemini-3-flash: @`
212
- assert.doesNotMatch(tmpl!.value, /flash:\s@/);
213
- assert.match(tmpl!.value, /flash @/);
209
+ assert.ok(!items!.some((i) => i.label === rvUi("en").codePreset));
210
+ assert.ok(items!.some((i) => i.label === rvUi("en").listModels));
214
211
  });
215
212
 
216
213
  it("falls back gracefully when models are unavailable (static flags still work)", () => {
@@ -1,16 +1,22 @@
1
1
  /**
2
2
  * Pure, testable /rv argument-completion logic.
3
3
  *
4
- * This module deliberately avoids importing pi types so the tokenizer, state
5
- * machine, and model scorer can be unit-tested with `node:test` and reused
6
- * across hosts. The thin runtime adapter (registry capture + AutocompleteItem
7
- * shaping) lives in `review.ts`.
8
- *
9
- * Completion items are returned "续写式": `value` always carries the already-typed
10
- * head tokens because pi-tui's `applyCompletion` replaces the *entire* argument
11
- * text after the slash command with `item.value`.
4
+ * Avoids pi types so tokenizer + preset ordering can be unit-tested.
5
+ * Completion `value` must include the typed head (pi-tui replaces the whole arg).
12
6
  */
13
7
 
8
+ import { type RvLocale, rvUi } from "./rv-locale.js";
9
+ import { semanticCompletionItems, SEMANTIC_PHRASES } from "./rv-semantic.js";
10
+ import {
11
+ loadReviewModelPriorities,
12
+ primaryPresetForProfile,
13
+ rankModelsWithPresets,
14
+ resolvePresetOrderedModels,
15
+ resolveReviewProfile,
16
+ type ReviewModelPriorities,
17
+ type ReviewProfile,
18
+ } from "./rv-model-priorities.js";
19
+
14
20
  export type AutocompleteItem = {
15
21
  value: string;
16
22
  label: string;
@@ -36,18 +42,18 @@ export const RV_FLAGS = [
36
42
  ] as const;
37
43
 
38
44
  export const MODE_HINTS: Record<string, string> = {
39
- code: "代码 / diff / MR 审核",
40
- plan: "架构 / 计划评审",
41
- challenge: "对抗性方案评审",
45
+ code: "Code / diff / MR review",
46
+ plan: "Architecture or plan review",
47
+ challenge: "Adversarial plan review",
42
48
  };
43
49
 
44
50
  export const FLAG_HINTS: Record<string, string> = {
45
- "--mode": "审核模式 code|plan|challenge",
51
+ "--mode": "Review mode: code | plan | challenge",
46
52
  "--model": "provider/model[:thinking]",
47
- "--thinking": "off|minimal|low|medium|high|xhigh",
48
- "--keep-session": "保留会话,可用 /rv --continue 追问",
49
- "--no-stream": "缓冲输出到结束(非默认)",
50
- "--continue": "继续既有 review 会话",
53
+ "--thinking": "off | minimal | low | medium | high | xhigh",
54
+ "--keep-session": "Keep session for /rv --continue follow-up",
55
+ "--no-stream": "Buffer output until exit (not default)",
56
+ "--continue": "Resume an existing review session",
51
57
  };
52
58
 
53
59
  /** Minimal model metadata we need; converted from the host's Model registry. */
@@ -74,6 +80,31 @@ export interface ReviewSignals {
74
80
  export interface CompletionDeps {
75
81
  models?: ModelInfo[];
76
82
  primaryProvider?: string;
83
+ priorities?: ReviewModelPriorities;
84
+ locale?: RvLocale;
85
+ }
86
+
87
+ function localeOf(deps: CompletionDeps): RvLocale {
88
+ return deps.locale ?? "en";
89
+ }
90
+
91
+ function modeFromSemanticHead(head: string[]): string | undefined {
92
+ const joined = head.join(" ").toLowerCase();
93
+ for (const row of SEMANTIC_PHRASES) {
94
+ if (!row.apply.mode) continue;
95
+ for (const p of row.phrases) {
96
+ if (joined.includes(p.toLowerCase())) return row.apply.mode;
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function getPriorities(deps: CompletionDeps): ReviewModelPriorities {
103
+ return deps.priorities ?? loadReviewModelPriorities();
104
+ }
105
+
106
+ export function reviewProfileForSignals(sig: ReviewSignals): ReviewProfile {
107
+ return resolveReviewProfile(sig.mode, sig.targetExt);
77
108
  }
78
109
 
79
110
  // ---------------------------------------------------------------------------
@@ -111,12 +142,6 @@ export function tokenizeArgPrefix(prefix: string): TokenizedArgs {
111
142
  // Signal extraction
112
143
  // ---------------------------------------------------------------------------
113
144
 
114
- const CODE_EXTS = new Set([
115
- "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java", "kt",
116
- "c", "cc", "cpp", "h", "hpp", "cs", "php", "swift", "scala", "sh", "sql",
117
- "vue", "svelte",
118
- ]);
119
- const PLAN_EXTS = new Set(["md", "markdown", "txt", "rst", "adoc"]);
120
145
 
121
146
  function extOf(token: string): string | undefined {
122
147
  const cleaned = token.replace(/^['"]|['"]$/g, "").replace(/^@/, "");
@@ -127,7 +152,8 @@ function extOf(token: string): string | undefined {
127
152
  export function extractSignals(head: string[], deps: CompletionDeps): ReviewSignals {
128
153
  const target = head.find((t) => t.startsWith("@"));
129
154
  const modeIdx = head.indexOf("--mode");
130
- const mode = modeIdx >= 0 ? head[modeIdx + 1] : "code";
155
+ const mode =
156
+ (modeIdx >= 0 ? head[modeIdx + 1] : undefined) ?? modeFromSemanticHead(head) ?? "code";
131
157
  return {
132
158
  mode,
133
159
  hasTarget: Boolean(target),
@@ -137,46 +163,27 @@ export function extractSignals(head: string[], deps: CompletionDeps): ReviewSign
137
163
  }
138
164
 
139
165
  // ---------------------------------------------------------------------------
140
- // Model scoring (P2 rule engine — pure, no LLM in the completion hot path)
166
+ // Model ordering: registry + preset priorities (no LLM)
141
167
  // ---------------------------------------------------------------------------
142
168
 
143
- export function scoreModelForReview(
144
- m: ModelInfo,
145
- sig: ReviewSignals,
146
- ): { score: number; reason: string } {
147
- let score = 0;
148
- const reasons: string[] = [];
149
- if (m.reasoning) {
150
- score += 3;
151
- reasons.push("reasoning");
152
- }
153
- if (m.contextWindow >= 200_000) score += 2;
154
- else if (m.contextWindow >= 100_000) score += 1;
155
- if (sig.primaryProvider && m.provider !== sig.primaryProvider) {
156
- score += 1;
157
- reasons.push("跨厂商交叉审核");
158
- }
159
- if ((sig.mode === "plan" || sig.mode === "challenge") && m.thinkingLevels.includes("xhigh")) {
160
- score += 2;
161
- reasons.push("xhigh 思考");
162
- } else if (m.thinkingLevels.includes("high")) {
163
- score += 1;
164
- }
165
- if (sig.targetExt && PLAN_EXTS.has(sig.targetExt) && m.reasoning) {
166
- score += 1;
167
- reasons.push("适合文档评审");
168
- }
169
- return { score, reason: reasons.filter(Boolean).join(" · ") };
170
- }
171
-
172
169
  export function rankModelsForReview(
173
170
  models: ModelInfo[],
174
171
  sig: ReviewSignals,
172
+ priorities?: ReviewModelPriorities,
175
173
  ): ModelInfo[] {
176
- return models
177
- .map((m) => ({ m, s: scoreModelForReview(m, sig).score }))
178
- .sort((a, b) => b.s - a.s)
179
- .map((x) => x.m);
174
+ const profile = reviewProfileForSignals(sig);
175
+ return rankModelsWithPresets(models, profile, priorities ?? loadReviewModelPriorities());
176
+ }
177
+
178
+ function presetDescription(
179
+ presetLabel: string,
180
+ profile: ReviewProfile,
181
+ presetRank: number | undefined,
182
+ locale: RvLocale,
183
+ ): string {
184
+ if (presetRank === undefined) return "";
185
+ const ui = rvUi(locale);
186
+ return `${ui.presetTier(presetRank)} · ${profile} · ${presetLabel}`;
180
187
  }
181
188
 
182
189
  // ---------------------------------------------------------------------------
@@ -234,16 +241,16 @@ function wrap(
234
241
  }));
235
242
  }
236
243
 
237
- function thinkingDescription(level: string, sig: ReviewSignals): string {
244
+ function thinkingDescription(level: string, sig: ReviewSignals, locale: RvLocale): string {
238
245
  const recommended = sig.mode === "code" ? "high" : "xhigh";
239
- const star = level === recommended ? "★ 推荐 " : "";
246
+ const star = level === recommended ? rvUi(locale).thinkingSuggested : "";
240
247
  const map: Record<string, string> = {
241
- off: "不思考(最快)",
242
- minimal: "极简思考",
243
- low: "低强度",
244
- medium: "中强度",
245
- high: "高强度(review 默认)",
246
- xhigh: "极致思考(plan/challenge 推荐)",
248
+ off: "No thinking (fastest)",
249
+ minimal: "Minimal thinking",
250
+ low: "Low effort",
251
+ medium: "Medium effort",
252
+ high: "High effort (common for review)",
253
+ xhigh: "Max effort (plan/challenge)",
247
254
  };
248
255
  return `${star}${map[level] ?? level}`;
249
256
  }
@@ -259,22 +266,25 @@ function modelItems(
259
266
  sig: ReviewSignals,
260
267
  headPrefix: string,
261
268
  tail: string,
269
+ deps: CompletionDeps,
262
270
  ): AutocompleteItem[] | null {
263
- const ranked = rankModelsForReview(models, sig);
264
- const items = ranked.map((m, i) => {
265
- const { score, reason } = scoreModelForReview(m, sig);
266
- const recommended = i < 3 && score > 0;
267
- const tag =
268
- sig.mode === "plan" || sig.mode === "challenge" ? "plan/challenge" : "review";
271
+ const priorities = getPriorities(deps);
272
+ const profile = reviewProfileForSignals(sig);
273
+ const presetRows = resolvePresetOrderedModels(models, profile, priorities);
274
+ const presetByLabel = new Map(presetRows.map((p) => [p.model.label, p]));
275
+ const ranked = rankModelsWithPresets(models, profile, priorities);
276
+ const items = ranked.map((m) => {
277
+ const preset = presetByLabel.get(m.label);
278
+ const tag = profile;
269
279
  return {
270
280
  value: `${headPrefix}${m.label}`,
271
281
  label: m.label,
272
282
  description: [
273
- recommended ? "★ 推荐" : "",
283
+ preset ? presetDescription(preset.presetLabel, profile, preset.presetRank, localeOf(deps)) : "",
274
284
  tag,
275
285
  m.reasoning ? "reasoning" : "",
276
286
  contextLabel(m.contextWindow),
277
- reason,
287
+ preset?.thinking ? `preset thinking ${preset.thinking}` : "",
278
288
  ]
279
289
  .filter(Boolean)
280
290
  .join(" · "),
@@ -301,6 +311,7 @@ function thinkingSuffixItems(
301
311
  models: ModelInfo[],
302
312
  headPrefix: string,
303
313
  sig: ReviewSignals,
314
+ deps: CompletionDeps,
304
315
  ): AutocompleteItem[] | null {
305
316
  const colonIdx = tail.lastIndexOf(":");
306
317
  const modelPart = tail.slice(0, colonIdx);
@@ -319,40 +330,64 @@ function thinkingSuffixItems(
319
330
  return filtered.map((level) => ({
320
331
  value: `${headPrefix}${modelPart}:${level}`,
321
332
  label: level,
322
- description: thinkingDescription(level, sig),
333
+ description: thinkingDescription(level, sig, localeOf(deps)),
323
334
  }));
324
335
  }
325
336
 
337
+ function thinkingSuffixForModel(m: ModelInfo, want?: string): string {
338
+ const lvl =
339
+ want && m.thinkingLevels.includes(want)
340
+ ? want
341
+ : want && m.thinkingLevels.includes("high")
342
+ ? "high"
343
+ : m.thinkingLevels.includes("xhigh")
344
+ ? "xhigh"
345
+ : m.thinkingLevels.includes("high")
346
+ ? "high"
347
+ : (m.thinkingLevels[0] ?? "");
348
+ return lvl ? `:${lvl}` : "";
349
+ }
350
+
326
351
  function sceneTemplates(
327
- top: ModelInfo | undefined,
352
+ models: ModelInfo[],
353
+ sig: ReviewSignals,
328
354
  headPrefix: string,
329
355
  tail: string,
356
+ deps: CompletionDeps,
330
357
  ): AutocompleteItem[] {
331
- if (!top) return [];
332
- const pref = (m: ModelInfo, want: string): string => {
333
- if (m.thinkingLevels.includes(want)) return want;
334
- if (m.thinkingLevels.includes("high")) return "high";
335
- return m.thinkingLevels[0] ?? "";
336
- };
337
- const suffix = (want: string): string => {
338
- const lvl = pref(top, want);
339
- return lvl ? `:${lvl}` : "";
340
- };
358
+ const priorities = getPriorities(deps);
359
+ const codePrimary = primaryPresetForProfile(models, "code", priorities);
360
+ const planPrimary = primaryPresetForProfile(models, "plan", priorities);
361
+ const frontendPrimary = primaryPresetForProfile(models, "frontend", priorities);
362
+
363
+ const ui = rvUi(localeOf(deps));
364
+ const templates: { cmd: string; label: string }[] = [];
365
+ if (codePrimary) {
366
+ const m = codePrimary.model;
367
+ templates.push({
368
+ cmd: `${ui.modeCode} --model ${m.label}${thinkingSuffixForModel(m, codePrimary.thinking ?? "xhigh")} @`,
369
+ label: ui.codePreset,
370
+ });
371
+ }
372
+ if (frontendPrimary) {
373
+ const m = frontendPrimary.model;
374
+ templates.push({
375
+ cmd: `${ui.modeCode} --model ${m.label}${thinkingSuffixForModel(m, frontendPrimary.thinking)} @`,
376
+ label: ui.frontendPreset,
377
+ });
378
+ }
379
+ if (planPrimary) {
380
+ const m = planPrimary.model;
381
+ templates.push({
382
+ cmd: `${ui.modePlan} --model ${m.label}${thinkingSuffixForModel(m, planPrimary.thinking ?? "high")} @`,
383
+ label: ui.planPreset,
384
+ });
385
+ templates.push({
386
+ cmd: `${ui.modeChallenge} ${ui.keepSession} --model ${m.label}${thinkingSuffixForModel(m, "xhigh")} @`,
387
+ label: ui.challengePreset,
388
+ });
389
+ }
341
390
 
342
- const templates = [
343
- {
344
- cmd: `--mode code --model ${top.label}${suffix("high")} @`,
345
- label: "审代码改动(推荐配置)",
346
- },
347
- {
348
- cmd: `--mode plan --model ${top.label}${suffix("high")} @`,
349
- label: "架构 / 计划评审",
350
- },
351
- {
352
- cmd: `--mode challenge --keep-session --model ${top.label}${suffix("xhigh")} @`,
353
- label: "对抗性方案评审(可追问)",
354
- },
355
- ];
356
391
  return templates
357
392
  .filter((t) => fuzzyMatch(t.label, tail) || t.cmd.includes(tail))
358
393
  .map((t) => ({
@@ -382,21 +417,25 @@ function topLevelCompletions(
382
417
  const items: AutocompleteItem[] = [];
383
418
 
384
419
  // Scene templates (only at top level, before any target/mode chosen is fine).
385
- const ranked = deps.models?.length ? rankModelsForReview(deps.models, sig) : [];
386
- items.push(...sceneTemplates(ranked[0], headPrefix, tail));
420
+ if (deps.models?.length) {
421
+ items.push(...sceneTemplates(deps.models, sig, headPrefix, tail, deps));
422
+ }
423
+
424
+ items.push(...semanticCompletionItems(localeOf(deps), tail, headPrefix, head));
387
425
 
388
- // `models` keyword.
389
- if (fuzzyMatch("models", tail) || "models".startsWith(tail)) {
426
+ const ui = rvUi(localeOf(deps));
427
+ if (fuzzyMatch(ui.modelsKeyword, tail) || ui.modelsKeyword.startsWith(tail) || fuzzyMatch("models", tail)) {
390
428
  items.push({
391
- value: `${headPrefix}models`,
392
- label: "models",
393
- description: "仅列出 pi-review 可用模型",
429
+ value: `${headPrefix}${ui.listModels}`,
430
+ label: ui.listModels,
431
+ description: ui.listModelsDesc,
394
432
  });
395
433
  }
396
434
 
397
- // Flags.
398
- const flags = flagItems(head, tail, headPrefix);
399
- if (flags) items.push(...flags);
435
+ if (tail.startsWith("--")) {
436
+ const flags = flagItems(head, tail, headPrefix);
437
+ if (flags) items.push(...flags);
438
+ }
400
439
 
401
440
  return items.length ? items : null;
402
441
  }
@@ -418,10 +457,10 @@ export function buildRvCompletions(
418
457
  if (prev === "--model") {
419
458
  const colonIdx = tail.lastIndexOf(":");
420
459
  if (colonIdx !== -1 && tail.slice(0, colonIdx).includes("/")) {
421
- const items = thinkingSuffixItems(tail, deps.models ?? [], headPrefix, sig);
460
+ const items = thinkingSuffixItems(tail, deps.models ?? [], headPrefix, sig, deps);
422
461
  return items;
423
462
  }
424
- return modelItems(deps.models ?? [], sig, headPrefix, tail);
463
+ return modelItems(deps.models ?? [], sig, headPrefix, tail, deps);
425
464
  }
426
465
 
427
466
  // 2) --mode value position.
@@ -0,0 +1,17 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { detectRvLocale } from "./rv-locale.js";
4
+
5
+ describe("detectRvLocale", () => {
6
+ it("returns zh when recent messages are mostly Chinese", () => {
7
+ assert.equal(detectRvLocale(["帮我看一下这个方案", "好的"]), "zh");
8
+ });
9
+
10
+ it("returns en for English-only samples", () => {
11
+ assert.equal(detectRvLocale(["Please review this diff", "Thanks"]), "en");
12
+ });
13
+
14
+ it("defaults to en when empty", () => {
15
+ assert.equal(detectRvLocale([]), "en");
16
+ });
17
+ });
@@ -0,0 +1,81 @@
1
+ /** Lightweight locale for /rv UI strings (no LLM). */
2
+
3
+ export type RvLocale = "en" | "zh";
4
+
5
+ const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/;
6
+
7
+ export function detectRvLocale(textSamples: string[]): RvLocale {
8
+ let cjk = 0;
9
+ let latin = 0;
10
+ for (const sample of textSamples) {
11
+ const t = sample.slice(0, 2000);
12
+ for (const ch of t) {
13
+ if (CJK_RE.test(ch)) cjk++;
14
+ else if (/[a-zA-Z]/.test(ch)) latin++;
15
+ }
16
+ }
17
+ if (cjk === 0 && latin === 0) return "en";
18
+ return cjk >= latin * 0.15 ? "zh" : "en";
19
+ }
20
+
21
+ export type RvUiStrings = {
22
+ modelsKeyword: string;
23
+ modelsDesc: string;
24
+ modeCode: string;
25
+ modePlan: string;
26
+ modeChallenge: string;
27
+ keepSession: string;
28
+ keepSessionDesc: string;
29
+ listModels: string;
30
+ listModelsDesc: string;
31
+ codePreset: string;
32
+ frontendPreset: string;
33
+ planPreset: string;
34
+ challengePreset: string;
35
+ suggested: string;
36
+ presetTier: (n: number) => string;
37
+ thinkingSuggested: string;
38
+ };
39
+
40
+ const UI: Record<RvLocale, RvUiStrings> = {
41
+ en: {
42
+ modelsKeyword: "models",
43
+ modelsDesc: "List pi-review models only",
44
+ modeCode: "code review",
45
+ modePlan: "plan review",
46
+ modeChallenge: "challenge review",
47
+ keepSession: "keep session for follow-up",
48
+ keepSessionDesc: "Persist review session for /rv --continue",
49
+ listModels: "list models",
50
+ listModelsDesc: "Show provider/model catalog",
51
+ codePreset: "Code review (preset)",
52
+ frontendPreset: "Frontend / multimodal review (preset)",
53
+ planPreset: "Plan review (preset)",
54
+ challengePreset: "Challenge review (preset)",
55
+ suggested: "Suggested",
56
+ presetTier: (n) => (n === 0 ? "Suggested" : `Preset #${n + 1}`),
57
+ thinkingSuggested: "Suggested ",
58
+ },
59
+ zh: {
60
+ modelsKeyword: "models",
61
+ modelsDesc: "仅列出 pi-review 可用模型",
62
+ modeCode: "代码审核",
63
+ modePlan: "方案审核",
64
+ modeChallenge: "对抗性审核",
65
+ keepSession: "保留会话可追问",
66
+ keepSessionDesc: "保留 review 会话,便于 /rv --continue",
67
+ listModels: "查看模型列表",
68
+ listModelsDesc: "列出当前可用 provider/model",
69
+ codePreset: "代码审核(推荐配置)",
70
+ frontendPreset: "前端 / 多模态审核(推荐)",
71
+ planPreset: "方案 / 架构审核(推荐)",
72
+ challengePreset: "对抗性方案审核(可追问)",
73
+ suggested: "推荐",
74
+ presetTier: (n) => (n === 0 ? "推荐" : `预设 #${n + 1}`),
75
+ thinkingSuggested: "推荐 ",
76
+ },
77
+ };
78
+
79
+ export function rvUi(locale: RvLocale): RvUiStrings {
80
+ return UI[locale];
81
+ }
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ DEFAULT_REVIEW_MODEL_PRIORITIES,
5
+ matchPresetEntry,
6
+ rankModelsWithPresets,
7
+ resolveReviewProfile,
8
+ type PresetModelEntry,
9
+ } from "./rv-model-priorities.js";
10
+ import type { ModelInfo } from "./rv-completions.js";
11
+
12
+ function m(provider: string, id: string, extra?: Partial<ModelInfo>): ModelInfo {
13
+ return {
14
+ provider,
15
+ id,
16
+ label: `${provider}/${id}`,
17
+ name: id,
18
+ reasoning: true,
19
+ contextWindow: 200_000,
20
+ thinkingLevels: ["high", "xhigh"],
21
+ ...extra,
22
+ };
23
+ }
24
+
25
+ describe("resolveReviewProfile", () => {
26
+ it("uses plan profile for plan/challenge mode", () => {
27
+ assert.equal(resolveReviewProfile("plan", "ts"), "plan");
28
+ assert.equal(resolveReviewProfile("challenge", "md"), "plan");
29
+ });
30
+
31
+ it("uses frontend profile for vue/svelte/css targets", () => {
32
+ assert.equal(resolveReviewProfile("code", "vue"), "frontend");
33
+ assert.equal(resolveReviewProfile("code", "css"), "frontend");
34
+ });
35
+
36
+ it("defaults to code profile", () => {
37
+ assert.equal(resolveReviewProfile("code", "ts"), "code");
38
+ });
39
+ });
40
+
41
+ describe("matchPresetEntry", () => {
42
+ const registry = [
43
+ m("openai", "gpt-5.5"),
44
+ m("zhipu", "glm-5.2-chat"),
45
+ m("moonshot", "kimi-k2.7-preview"),
46
+ m("moonshot", "kimi-k2.5"),
47
+ m("anthropic", "claude-opus-4-8"),
48
+ m("anthropic", "claude-opus-4-6"),
49
+ m("deepseek", "deepseek-v4-pro"),
50
+ ];
51
+
52
+ it("picks newest kimi when versionPrefer 2.7", () => {
53
+ const entry: PresetModelEntry = { idContains: "kimi", versionPrefer: "2.7" };
54
+ const hit = matchPresetEntry(registry, entry);
55
+ assert.ok(hit);
56
+ assert.match(hit!.id, /2\.7|k2\.7/i);
57
+ });
58
+
59
+ it("ranks gpt-5.5 first for code presets", () => {
60
+ const ordered = rankModelsWithPresets(registry, "code", DEFAULT_REVIEW_MODEL_PRIORITIES);
61
+ assert.equal(ordered[0].id, "gpt-5.5");
62
+ assert.equal(ordered[1].id, "glm-5.2-chat");
63
+ });
64
+
65
+ it("ranks claude-opus-4-8 before older opus for plan", () => {
66
+ const ordered = rankModelsWithPresets(registry, "plan", DEFAULT_REVIEW_MODEL_PRIORITIES);
67
+ assert.equal(ordered[0].id, "claude-opus-4-8");
68
+ });
69
+ });
@@ -0,0 +1,138 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ModelInfo } from "./rv-completions.js";
5
+
6
+ export type PresetModelEntry = {
7
+ idContains: string;
8
+ thinking?: string;
9
+ versionPrefer?: string;
10
+ };
11
+
12
+ export type ReviewModelPriorities = {
13
+ code: PresetModelEntry[];
14
+ frontend: PresetModelEntry[];
15
+ plan: PresetModelEntry[];
16
+ };
17
+
18
+ export const DEFAULT_REVIEW_MODEL_PRIORITIES: ReviewModelPriorities = {
19
+ code: [
20
+ { idContains: "gpt-5.5", thinking: "xhigh" },
21
+ { idContains: "glm-5.2", thinking: "high" },
22
+ ],
23
+ frontend: [
24
+ { idContains: "kimi", versionPrefer: "2.7" },
25
+ { idContains: "claude-sonnet", versionPrefer: "5" },
26
+ { idContains: "minimax-m3" },
27
+ ],
28
+ plan: [
29
+ { idContains: "claude-opus-4-8" },
30
+ { idContains: "claude-opus-4", versionPrefer: "8" },
31
+ { idContains: "deepseek-v4-pro" },
32
+ { idContains: "deepseek-v4" },
33
+ ],
34
+ };
35
+
36
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
+ const DEFAULT_PRIORITIES_PATH = path.resolve(__dirname, "../resources/rv-model-priorities.json");
38
+
39
+ export function loadReviewModelPriorities(
40
+ filePath: string = process.env.PI_REVIEW_RV_PRIORITIES || DEFAULT_PRIORITIES_PATH,
41
+ ): ReviewModelPriorities {
42
+ try {
43
+ const raw = fs.readFileSync(filePath, "utf8");
44
+ const parsed = JSON.parse(raw) as Partial<ReviewModelPriorities>;
45
+ return {
46
+ code: parsed.code?.length ? parsed.code : DEFAULT_REVIEW_MODEL_PRIORITIES.code,
47
+ frontend: parsed.frontend?.length ? parsed.frontend : DEFAULT_REVIEW_MODEL_PRIORITIES.frontend,
48
+ plan: parsed.plan?.length ? parsed.plan : DEFAULT_REVIEW_MODEL_PRIORITIES.plan,
49
+ };
50
+ } catch {
51
+ return DEFAULT_REVIEW_MODEL_PRIORITIES;
52
+ }
53
+ }
54
+
55
+ export type ReviewProfile = "code" | "frontend" | "plan";
56
+
57
+ const FRONTEND_EXTS = new Set([
58
+ "vue", "svelte", "css", "scss", "sass", "less", "styl", "html", "htm",
59
+ ]);
60
+
61
+ export function resolveReviewProfile(mode: string, targetExt?: string): ReviewProfile {
62
+ if (mode === "plan" || mode === "challenge") return "plan";
63
+ if (targetExt && FRONTEND_EXTS.has(targetExt)) return "frontend";
64
+ return "code";
65
+ }
66
+
67
+ function idVersionSortKey(id: string): string {
68
+ return id.toLowerCase();
69
+ }
70
+
71
+ export function matchPresetEntry(
72
+ models: ModelInfo[],
73
+ entry: PresetModelEntry,
74
+ ): ModelInfo | undefined {
75
+ const needle = entry.idContains.toLowerCase();
76
+ let candidates = models.filter((m) => m.id.toLowerCase().includes(needle));
77
+ if (entry.versionPrefer) {
78
+ const vp = entry.versionPrefer.toLowerCase();
79
+ const withVersion = candidates.filter((m) => m.id.toLowerCase().includes(vp));
80
+ if (withVersion.length) candidates = withVersion;
81
+ }
82
+ if (!candidates.length) return undefined;
83
+ candidates.sort((a, b) =>
84
+ idVersionSortKey(b.id).localeCompare(idVersionSortKey(a.id), undefined, { numeric: true }),
85
+ );
86
+ return candidates[0];
87
+ }
88
+
89
+ export type ResolvedPresetModel = {
90
+ model: ModelInfo;
91
+ thinking?: string;
92
+ presetRank: number;
93
+ presetLabel: string;
94
+ };
95
+
96
+ export function resolvePresetOrderedModels(
97
+ models: ModelInfo[],
98
+ profile: ReviewProfile,
99
+ priorities: ReviewModelPriorities,
100
+ ): ResolvedPresetModel[] {
101
+ const rows = priorities[profile];
102
+ const seen = new Set<string>();
103
+ const out: ResolvedPresetModel[] = [];
104
+ for (let i = 0; i < rows.length; i++) {
105
+ const entry = rows[i];
106
+ const matched = matchPresetEntry(models, entry);
107
+ if (!matched || seen.has(matched.label)) continue;
108
+ seen.add(matched.label);
109
+ out.push({
110
+ model: matched,
111
+ thinking: entry.thinking,
112
+ presetRank: i,
113
+ presetLabel: entry.idContains,
114
+ });
115
+ }
116
+ return out;
117
+ }
118
+
119
+ export function rankModelsWithPresets(
120
+ models: ModelInfo[],
121
+ profile: ReviewProfile,
122
+ priorities: ReviewModelPriorities,
123
+ ): ModelInfo[] {
124
+ const preset = resolvePresetOrderedModels(models, profile, priorities);
125
+ const presetLabels = new Set(preset.map((p) => p.model.label));
126
+ const rest = models
127
+ .filter((m) => !presetLabels.has(m.label))
128
+ .sort((a, b) => a.label.localeCompare(b.label));
129
+ return [...preset.map((p) => p.model), ...rest];
130
+ }
131
+
132
+ export function primaryPresetForProfile(
133
+ models: ModelInfo[],
134
+ profile: ReviewProfile,
135
+ priorities: ReviewModelPriorities,
136
+ ): ResolvedPresetModel | undefined {
137
+ return resolvePresetOrderedModels(models, profile, priorities)[0];
138
+ }
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  buildPiReviewArgv,
5
5
  buildRvOrchestrationPrompt,
6
+ orchestrationLocaleNote,
6
7
  parseRvArgs,
7
8
  validateRvParsed,
8
9
  } from "./rv-prompts.js";
@@ -57,6 +58,14 @@ describe("parseRvArgs / validateRvParsed", () => {
57
58
  ]);
58
59
  });
59
60
 
61
+ it("models orchestration includes locale note and model-selection reference", () => {
62
+ const p = parseRvArgs("models");
63
+ const zh = buildRvOrchestrationPrompt(p, "zh");
64
+ assert.match(zh, /用中文向用户总结/);
65
+ assert.match(zh, /model-selection/);
66
+ assert.match(orchestrationLocaleNote("en"), /English/);
67
+ });
68
+
60
69
  it("orchestration prompt forbids default no-stream and progress-log in Pi", () => {
61
70
  const p = parseRvArgs("@src/foo.ts");
62
71
  const prompt = buildRvOrchestrationPrompt(p);
@@ -1,5 +1,8 @@
1
1
  /** /rv → parent-agent task text; child review uses resources/review-presets.json */
2
2
 
3
+ import type { RvLocale } from "./rv-locale.js";
4
+ import { mergeSemanticIntoParsed, stripSemanticPhrases } from "./rv-semantic.js";
5
+
3
6
  export const RV_MODES = ["code", "plan", "challenge"] as const;
4
7
  export type RvMode = (typeof RV_MODES)[number];
5
8
 
@@ -21,7 +24,8 @@ export type RvValidation =
21
24
  | { ok: false; message: string };
22
25
 
23
26
  export function parseRvArgs(raw: string): RvParsed {
24
- const tokens = raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
27
+ const { remainder, apply: semanticApply } = stripSemanticPhrases(raw);
28
+ const tokens = remainder.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
25
29
  const cleaned = tokens.map((t) => t.replace(/^['"]|['"]$/g, ""));
26
30
 
27
31
  let mode = "code";
@@ -86,16 +90,19 @@ export function parseRvArgs(raw: string): RvParsed {
86
90
  rest.push(t);
87
91
  }
88
92
 
89
- return {
90
- mode,
91
- model,
92
- thinking,
93
- keepSession,
94
- noStream,
95
- continueHandle,
96
- modelsOnly,
97
- target: rest.join(" ").trim(),
98
- };
93
+ return mergeSemanticIntoParsed(
94
+ {
95
+ mode,
96
+ model,
97
+ thinking,
98
+ keepSession,
99
+ noStream,
100
+ continueHandle,
101
+ modelsOnly,
102
+ target: rest.join(" ").trim(),
103
+ },
104
+ semanticApply,
105
+ );
99
106
  }
100
107
 
101
108
  export function validateRvParsed(parsed: RvParsed): RvValidation {
@@ -126,6 +133,13 @@ export function buildPiReviewArgv(parsed: RvParsed, target: string): string[] {
126
133
  return parts;
127
134
  }
128
135
 
136
+ export function orchestrationLocaleNote(locale: RvLocale = "en"): string {
137
+ if (locale === "zh") {
138
+ return "用中文向用户总结(模型目录、选型理由、review 结论);技术 id 如 provider/model 可保留英文。";
139
+ }
140
+ return "Summarize for the user in English (catalog, model rationale, review conclusion); keep technical ids such as provider/model as listed.";
141
+ }
142
+
129
143
  function piHostRules(): string {
130
144
  return [
131
145
  "Follow the pi-review skill.",
@@ -137,13 +151,15 @@ function piHostRules(): string {
137
151
  ].join(" ");
138
152
  }
139
153
 
140
- export function buildRvOrchestrationPrompt(parsed: RvParsed): string {
154
+ export function buildRvOrchestrationPrompt(parsed: RvParsed, locale: RvLocale = "en"): string {
155
+ const localeNote = orchestrationLocaleNote(locale);
141
156
  if (parsed.modelsOnly) {
142
157
  return [
143
158
  "Run the pi-review model catalog step.",
144
159
  piHostRules(),
160
+ localeNote,
145
161
  "Execute: pi-review models",
146
- "Summarize briefly: provider count and 2–3 model IDs that fit code review vs plan/challenge.",
162
+ "Summarize briefly: provider count and 2–3 model IDs that fit code vs frontend vs plan/challenge (see pi-review skill references/model-selection.md).",
147
163
  "Do not start a review until the user supplies a target (@path or text).",
148
164
  ].join("\n\n");
149
165
  }
@@ -183,8 +199,8 @@ export function buildRvOrchestrationPrompt(parsed: RvParsed): string {
183
199
  const modelStep = parsed.model
184
200
  ? `Use model exactly as given: ${parsed.model}${parsed.thinking ? ` at thinking ${parsed.thinking}` : ""} (model must appear in pi-review models output).`
185
201
  : parsed.thinking
186
- ? `Run pi-review models first if needed; pick a suitable listed ID or omit --model for Pi default. Use thinking ${parsed.thinking}.`
187
- : "Run pi-review models first if needed; pick a suitable listed ID or omit --model for Pi default.";
202
+ ? `Run pi-review models first if needed; pick using skill references/model-selection.md (code / frontend / plan) or omit --model for Pi default. Use thinking ${parsed.thinking}.`
203
+ : "Run pi-review models first if needed; pick using skill references/model-selection.md (code / frontend / plan) or omit --model for Pi default.";
188
204
 
189
205
  const continueNote = parsed.continueHandle
190
206
  ? "Continuing an existing review session. Optional --mode and --model match the same flags as an initial /rv run."
@@ -195,6 +211,7 @@ export function buildRvOrchestrationPrompt(parsed: RvParsed): string {
195
211
  return [
196
212
  parsed.continueHandle ? "Continue a prior pi-review session." : "Run pi-review for the target below.",
197
213
  piHostRules(),
214
+ localeNote,
198
215
  modeBlock[parsed.mode] ??
199
216
  `Mode: ${parsed.mode}. Follow the preset named in review-presets.json when present.`,
200
217
  modelStep,
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { parseRvArgs } from "./rv-prompts.js";
4
+ import { stripSemanticPhrases } from "./rv-semantic.js";
5
+
6
+ describe("stripSemanticPhrases / parseRvArgs", () => {
7
+ it("maps 方案审核 to plan mode", () => {
8
+ const p = parseRvArgs("方案审核 @docs/plan.md");
9
+ assert.equal(p.mode, "plan");
10
+ assert.equal(p.target, "@docs/plan.md");
11
+ });
12
+
13
+ it("maps list models phrase to modelsOnly", () => {
14
+ const p = parseRvArgs("查看模型列表");
15
+ assert.equal(p.modelsOnly, true);
16
+ });
17
+
18
+ it("strips code review before flag parse", () => {
19
+ const { remainder, apply } = stripSemanticPhrases("code review @src/a.ts");
20
+ assert.equal(apply.mode, "code");
21
+ assert.match(remainder, /@src\/a\.ts/);
22
+ });
23
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Semantic /rv phrases (localized labels, not raw CLI flags in the input box).
3
+ */
4
+
5
+ import type { RvParsed } from "./rv-prompts.js";
6
+ import type { RvLocale } from "./rv-locale.js";
7
+ import { rvUi } from "./rv-locale.js";
8
+
9
+ export type SemanticApply = {
10
+ mode?: string;
11
+ keepSession?: boolean;
12
+ modelsOnly?: boolean;
13
+ noStream?: boolean;
14
+ };
15
+
16
+ export type SemanticPhrase = {
17
+ phrases: string[];
18
+ apply: SemanticApply;
19
+ completionValue: (locale: RvLocale) => string;
20
+ completionDesc?: (locale: RvLocale) => string;
21
+ };
22
+
23
+ export const SEMANTIC_PHRASES: SemanticPhrase[] = [
24
+ {
25
+ phrases: ["code review", "代码审核", "代码审查", "审代码"],
26
+ apply: { mode: "code" },
27
+ completionValue: (l) => rvUi(l).modeCode,
28
+ completionDesc: (l) => (l === "zh" ? "聚焦 diff / 正确性 / 测试" : "Diff, correctness, tests"),
29
+ },
30
+ {
31
+ phrases: ["plan review", "方案审核", "方案评审", "架构审核", "审方案"],
32
+ apply: { mode: "plan" },
33
+ completionValue: (l) => rvUi(l).modePlan,
34
+ completionDesc: (l) => (l === "zh" ? "架构 / 设计 / 产品方案" : "Architecture and design"),
35
+ },
36
+ {
37
+ phrases: ["challenge review", "对抗性审核", "对抗审核", "挑战审核"],
38
+ apply: { mode: "challenge" },
39
+ completionValue: (l) => rvUi(l).modeChallenge,
40
+ completionDesc: (l) => (l === "zh" ? "压力测试假设与风险" : "Adversarial plan pressure-test"),
41
+ },
42
+ {
43
+ phrases: ["keep session for follow-up", "保留会话可追问", "保留会话", "keep session"],
44
+ apply: { keepSession: true },
45
+ completionValue: (l) => rvUi(l).keepSession,
46
+ completionDesc: (l) => rvUi(l).keepSessionDesc,
47
+ },
48
+ {
49
+ phrases: ["list models", "查看模型列表", "模型列表", "list available models"],
50
+ apply: { modelsOnly: true },
51
+ completionValue: (l) => rvUi(l).listModels,
52
+ completionDesc: (l) => rvUi(l).listModelsDesc,
53
+ },
54
+ ];
55
+
56
+ export function stripSemanticPhrases(raw: string): { remainder: string; apply: SemanticApply } {
57
+ let remainder = raw;
58
+ const merged: SemanticApply = {};
59
+ const sorted = [...SEMANTIC_PHRASES].flatMap((row) =>
60
+ row.phrases.map((p) => ({ phrase: p, apply: row.apply })),
61
+ );
62
+ sorted.sort((a, b) => b.phrase.length - a.phrase.length);
63
+
64
+ for (const { phrase, apply } of sorted) {
65
+ const re = new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
66
+ if (!re.test(remainder)) continue;
67
+ remainder = remainder.replace(re, " ").replace(/\s+/g, " ").trim();
68
+ if (apply.mode) merged.mode = apply.mode;
69
+ if (apply.keepSession) merged.keepSession = true;
70
+ if (apply.modelsOnly) merged.modelsOnly = true;
71
+ if (apply.noStream) merged.noStream = true;
72
+ }
73
+ return { remainder, apply: merged };
74
+ }
75
+
76
+ export function mergeSemanticIntoParsed(parsed: RvParsed, apply: SemanticApply): RvParsed {
77
+ const out = { ...parsed };
78
+ if (apply.mode) out.mode = apply.mode;
79
+ if (apply.keepSession) out.keepSession = true;
80
+ if (apply.modelsOnly) out.modelsOnly = true;
81
+ if (apply.noStream) out.noStream = true;
82
+ return out;
83
+ }
84
+
85
+ export function semanticCompletionItems(
86
+ locale: RvLocale,
87
+ tail: string,
88
+ headPrefix: string,
89
+ head: string[],
90
+ ): { value: string; label: string; description?: string }[] {
91
+ const present = new Set(head.map((h) => h.toLowerCase()));
92
+ const q = tail.toLowerCase().trim();
93
+ const items: { value: string; label: string; description?: string }[] = [];
94
+ for (const row of SEMANTIC_PHRASES) {
95
+ const value = row.completionValue(locale);
96
+ if (present.has(value.toLowerCase())) continue;
97
+ const matches =
98
+ !q ||
99
+ value.toLowerCase().includes(q) ||
100
+ value.toLowerCase().startsWith(q) ||
101
+ row.phrases.some((p) => p.toLowerCase().startsWith(q) || p.toLowerCase().includes(q));
102
+ if (!matches) continue;
103
+ items.push({
104
+ value: `${headPrefix}${value}`,
105
+ label: value,
106
+ description: row.completionDesc?.(locale),
107
+ });
108
+ }
109
+ return items;
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zephyrdeng/pi-review",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Isolated AI-powered code and plan reviews from the command line",
5
5
  "type": "module",
6
6
  "author": "ZephyrDeng <zephyrTang@aliyun.com>",
@@ -67,8 +67,8 @@
67
67
  "build": "tsc",
68
68
  "check": "tsc --noEmit",
69
69
  "smoke": "node dist/cli.js --help >/dev/null",
70
- "test": "npx --yes tsx --test src/child-process.test.ts src/review.meta.test.ts src/meta-footer.test.ts src/json-events.test.ts src/install-args.test.ts extensions/rv-prompts.test.ts extensions/rv-completions.test.ts",
71
- "test:rv": "npx --yes tsx --test extensions/rv-prompts.test.ts extensions/rv-completions.test.ts",
70
+ "test": "npx --yes tsx --test src/child-process.test.ts src/review.meta.test.ts src/meta-footer.test.ts src/json-events.test.ts src/install-args.test.ts extensions/rv-prompts.test.ts extensions/rv-completions.test.ts extensions/rv-model-priorities.test.ts extensions/rv-locale.test.ts extensions/rv-semantic.test.ts",
71
+ "test:rv": "npx --yes tsx --test extensions/rv-prompts.test.ts extensions/rv-completions.test.ts extensions/rv-model-priorities.test.ts extensions/rv-locale.test.ts extensions/rv-semantic.test.ts",
72
72
  "prepublishOnly": "npm run build",
73
73
  "pack:dry": "npm pack --dry-run"
74
74
  },
@@ -0,0 +1,17 @@
1
+ {
2
+ "code": [
3
+ { "idContains": "gpt-5.5", "thinking": "xhigh" },
4
+ { "idContains": "glm-5.2", "thinking": "high" }
5
+ ],
6
+ "frontend": [
7
+ { "idContains": "kimi", "versionPrefer": "2.7" },
8
+ { "idContains": "claude-sonnet", "versionPrefer": "5" },
9
+ { "idContains": "minimax-m3" }
10
+ ],
11
+ "plan": [
12
+ { "idContains": "claude-opus-4-8" },
13
+ { "idContains": "claude-opus-4", "versionPrefer": "8" },
14
+ { "idContains": "deepseek-v4-pro" },
15
+ { "idContains": "deepseek-v4" }
16
+ ]
17
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pi-review
3
- description: Use pi-review to delegate isolated code, diff, repository, architecture, or plan reviews to a fresh Pi session and return only the review conclusion. On Claude Code or Codex, default to --progress-log with a background shell run and tail the log (chat tools buffer stdout). Use when the user asks for Pi review, isolated review, code review, plan review, challenge review, review status/progress, or wants to avoid context pollution.
3
+ description: Use pi-review to delegate isolated code, diff, repository, architecture, or plan reviews to a fresh Pi session and return only the review conclusion. Model selection presets for code, frontend, and plan review (see references/model-selection.md). On Claude Code or Codex, default to --progress-log with a background shell run and tail the log (chat tools buffer stdout). Use when the user asks for Pi review, isolated review, code review, plan review, challenge review, review status/progress, or wants to avoid context pollution.
4
4
  ---
5
5
 
6
6
  # Pi Review
@@ -26,7 +26,8 @@ When resolving this fallback, use the actual directory that contains this `SKILL
26
26
  ## Codex / Claude Code
27
27
 
28
28
  - Map skill mentions of `Bash` to your host shell tool.
29
- - Read **[references/codex-tools.md](./references/codex-tools.md)** (next to this `SKILL.md`) for Codex/Claude Code defaults: `--progress-log` + background run + tail.
29
+ - Read **[references/codex-tools.md](./references/codex-tools.md)** for `--progress-log` + background run + tail.
30
+ - Read **[references/model-selection.md](./references/model-selection.md)** when choosing `--model` after `pi-review models` (code / frontend / plan presets).
30
31
  - Do not use a blocking foreground shell alone for live progress; follow **Default workflow by host** below.
31
32
 
32
33
  ## Default workflow by host
@@ -79,15 +80,17 @@ Do not implement findings from a status check; status is informational only.
79
80
 
80
81
  ## Steps
81
82
 
82
- 1. Prime the model catalog:
83
+ 1. Prime the model catalog and pick a model:
83
84
  ```bash
84
85
  pi-review models [search]
85
86
  ```
86
87
  Rules:
87
88
  - Run this before building the review command.
88
- - If the user named a model, use that provider/model text as `[search]` and verify the exact ID from the output.
89
- - If the user did not name a model, run `pi-review models` and either choose an exact suitable ID from the output or deliberately omit `--model` to use Pi's default.
90
- Completion criterion: the model choice is either an exact listed `provider/model[:thinking]` or an explicit decision to use Pi's default.
89
+ - If the user named a model, use that text as `[search]` and verify the **exact** listed `provider/model` from the output.
90
+ - If the user did not name a model, follow **[references/model-selection.md](./references/model-selection.md)**: infer profile (code / frontend / plan), match the priority list against catalog ids, then set `--model` with optional `:thinking` when supported.
91
+ - On **Claude Code / Codex / Cursor**, state the chosen model in one line (match the user's language) before running the review command.
92
+ - If nothing in the priority list matches, omit `--model` (Pi default) or pick a listed reasoning model with large context—say which and why.
93
+ Completion criterion: exact listed `provider/model[:thinking]` or an explicit default; never invented ids.
91
94
 
92
95
  2. Choose one mode:
93
96
  - default `code` — code, diff, MR, file, or repository review.
@@ -25,6 +25,8 @@ When the **pi-review** skill applies on Codex, Claude Code, or Cursor:
25
25
 
26
26
  See the parent skill: sections *Default workflow by host* and *Streaming vs agent hosts (Claude Code / Codex)*.
27
27
 
28
+ **Model choice:** after `pi-review models`, use **[model-selection.md](./model-selection.md)** (code / frontend / plan presets)—same guidance as Pi `/rv` completions.
29
+
28
30
  Example tail (milestone events only):
29
31
 
30
32
  ```bash
@@ -0,0 +1,72 @@
1
+ # Model selection for pi-review (all hosts)
2
+
3
+ Use this after `pi-review models` (or `pi-review models <search>`). **Never invent** `provider/model` IDs—only IDs that appear in that output.
4
+
5
+ ## Workflow (Claude Code, Codex, Cursor, Pi parent agent)
6
+
7
+ 1. Run `pi-review models` (narrow with search if the user named a vendor or model family).
8
+ 2. Infer **review profile** from the target (see table below).
9
+ 3. Walk the **priority list** for that profile; pick the **first** row whose `idContains` matches some listed model id (case-insensitive substring). If several ids match one row, prefer the **newest** id (higher version suffix, e.g. `2.7` over `2.5`, `4-8` over `4-6`).
10
+ 4. Set `--model <exact-listed-provider/model>` from the catalog. Add `:thinking` only if that model supports it in Pi and the preset suggests it (see thinking column).
11
+ 5. If **no** priority row matches anything in the catalog, either omit `--model` (Pi default) or pick the best **reasoning** model with a large context window from the list—state the choice in one line to the user.
12
+
13
+ ## Profiles
14
+
15
+ | Profile | When to use |
16
+ |---------|-------------|
17
+ | **code** | Default: diffs, MRs, backend/ general code paths (`@src/...`, `.ts`, `.go`, …). |
18
+ | **frontend** | UI-heavy targets: `.vue`, `.svelte`, `.css`, `.scss`, `.html`, or user says frontend / UI / multimodal. |
19
+ | **plan** | `--mode plan` or `--mode challenge`, or reviewing `.md` / design / architecture docs (not line-by-line code only). |
20
+
21
+ ## Priority lists (same as Pi `/rv` package preset)
22
+
23
+ Configurable on disk for Pi: `resources/rv-model-priorities.json` or `PI_REVIEW_RV_PRIORITIES`. Agents should follow this table unless the user overrides.
24
+
25
+ ### Code review
26
+
27
+ | Order | Match model id containing | Suggested thinking |
28
+ |-------|---------------------------|------------------|
29
+ | 1 | `gpt-5.5` | `xhigh` |
30
+ | 2 | `glm-5.2` | `high` |
31
+
32
+ ### Frontend / multimodal review
33
+
34
+ | Order | Match model id containing | Notes |
35
+ |-------|---------------------------|--------|
36
+ | 1 | `kimi` | Prefer id that also contains `2.7` when multiple Kimi ids exist |
37
+ | 2 | `claude-sonnet` | Prefer id containing `5` when multiple |
38
+ | 3 | `minimax-m3` | Multimodal-friendly |
39
+
40
+ Use **code** mode (`pi-review` default) unless the user asked for plan/challenge.
41
+
42
+ ### Plan / challenge review
43
+
44
+ | Order | Match model id containing |
45
+ |-------|---------------------------|
46
+ | 1 | `claude-opus-4-8` |
47
+ | 2 | `claude-opus-4` (+ prefer `8` in id if several) |
48
+ | 3 | `deepseek-v4-pro` |
49
+ | 4 | `deepseek-v4` |
50
+
51
+ Use `--mode plan` or `--mode challenge` as appropriate.
52
+
53
+ ## Examples (illustrative—always substitute exact catalog strings)
54
+
55
+ ```bash
56
+ # Code
57
+ pi-review --model openai-codex/gpt-5.5:xhigh -- @src/auth.ts
58
+
59
+ # Frontend
60
+ pi-review --model wafer.ai/Kimi-K2.6 -- @src/App.vue
61
+
62
+ # Plan
63
+ pi-review --mode plan --model px:anthropic/claude-opus-4-8 -- @docs/architecture.md
64
+ ```
65
+
66
+ ## User-named model
67
+
68
+ If the user specifies a model, run `pi-review models <fragment>` and use the **exact** listed id. Do not normalize or guess provider prefixes.
69
+
70
+ ## Language
71
+
72
+ Explain your model choice briefly in the **same language** as the user's request (Chinese ↔ English). The review body language is independent; this rule is only for the one-line rationale before/after invoking `pi-review`.