@zephyrdeng/pi-review 0.7.0 → 0.8.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.
Files changed (194) hide show
  1. package/README.md +51 -7
  2. package/README.zh-CN.md +45 -5
  3. package/dist/args.d.ts +4 -0
  4. package/dist/args.d.ts.map +1 -1
  5. package/dist/args.js +82 -5
  6. package/dist/args.js.map +1 -1
  7. package/dist/args.test.js +72 -1
  8. package/dist/args.test.js.map +1 -1
  9. package/dist/child-process.d.ts +6 -0
  10. package/dist/child-process.d.ts.map +1 -1
  11. package/dist/child-process.js +48 -0
  12. package/dist/child-process.js.map +1 -1
  13. package/dist/child-process.test.js +55 -1
  14. package/dist/child-process.test.js.map +1 -1
  15. package/dist/cli-exit.test.js +14 -5
  16. package/dist/cli-exit.test.js.map +1 -1
  17. package/dist/cli.js +8 -3
  18. package/dist/cli.js.map +1 -1
  19. package/dist/index.d.ts +13 -6
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +9 -5
  22. package/dist/index.js.map +1 -1
  23. package/dist/install-args.test.js +1 -1
  24. package/dist/install-args.test.js.map +1 -1
  25. package/dist/install.d.ts.map +1 -1
  26. package/dist/install.js +2 -3
  27. package/dist/install.js.map +1 -1
  28. package/dist/json-events.d.ts +34 -2
  29. package/dist/json-events.d.ts.map +1 -1
  30. package/dist/json-events.js +156 -49
  31. package/dist/json-events.js.map +1 -1
  32. package/dist/json-events.test.js +176 -4
  33. package/dist/json-events.test.js.map +1 -1
  34. package/dist/loop.d.ts +28 -2
  35. package/dist/loop.d.ts.map +1 -1
  36. package/dist/loop.js +45 -3
  37. package/dist/loop.js.map +1 -1
  38. package/dist/loop.test.js +24 -1
  39. package/dist/loop.test.js.map +1 -1
  40. package/dist/matcher.test.js +1 -1
  41. package/dist/matcher.test.js.map +1 -1
  42. package/dist/meta-footer.d.ts +4 -8
  43. package/dist/meta-footer.d.ts.map +1 -1
  44. package/dist/meta-footer.js +19 -5
  45. package/dist/meta-footer.js.map +1 -1
  46. package/dist/meta-footer.test.js +11 -4
  47. package/dist/meta-footer.test.js.map +1 -1
  48. package/dist/panel-aggregate.test.js +1 -1
  49. package/dist/panel-aggregate.test.js.map +1 -1
  50. package/dist/panel-cancellation.test.d.ts +2 -0
  51. package/dist/panel-cancellation.test.d.ts.map +1 -0
  52. package/dist/panel-cancellation.test.js +55 -0
  53. package/dist/panel-cancellation.test.js.map +1 -0
  54. package/dist/panel-cli-ui.test.d.ts +2 -0
  55. package/dist/panel-cli-ui.test.d.ts.map +1 -0
  56. package/dist/panel-cli-ui.test.js +91 -0
  57. package/dist/panel-cli-ui.test.js.map +1 -0
  58. package/dist/panel-cli.test.js +76 -27
  59. package/dist/panel-cli.test.js.map +1 -1
  60. package/dist/panel-config.d.ts +38 -0
  61. package/dist/panel-config.d.ts.map +1 -1
  62. package/dist/panel-config.js +137 -4
  63. package/dist/panel-config.js.map +1 -1
  64. package/dist/panel-config.test.js +84 -4
  65. package/dist/panel-config.test.js.map +1 -1
  66. package/dist/panel-loop.test.js +1 -1
  67. package/dist/panel-loop.test.js.map +1 -1
  68. package/dist/panel-tools.test.d.ts +2 -0
  69. package/dist/panel-tools.test.d.ts.map +1 -0
  70. package/dist/panel-tools.test.js +12 -0
  71. package/dist/panel-tools.test.js.map +1 -0
  72. package/dist/panel-ui.d.ts +20 -0
  73. package/dist/panel-ui.d.ts.map +1 -0
  74. package/dist/panel-ui.js +84 -0
  75. package/dist/panel-ui.js.map +1 -0
  76. package/dist/panel-ui.test.d.ts +2 -0
  77. package/dist/panel-ui.test.d.ts.map +1 -0
  78. package/dist/panel-ui.test.js +122 -0
  79. package/dist/panel-ui.test.js.map +1 -0
  80. package/dist/panel-usage.d.ts +6 -0
  81. package/dist/panel-usage.d.ts.map +1 -0
  82. package/dist/panel-usage.js +21 -0
  83. package/dist/panel-usage.js.map +1 -0
  84. package/dist/panel-view.d.ts +42 -0
  85. package/dist/panel-view.d.ts.map +1 -0
  86. package/dist/panel-view.js +124 -0
  87. package/dist/panel-view.js.map +1 -0
  88. package/dist/panel-view.test.d.ts +2 -0
  89. package/dist/panel-view.test.d.ts.map +1 -0
  90. package/dist/panel-view.test.js +112 -0
  91. package/dist/panel-view.test.js.map +1 -0
  92. package/dist/panel.d.ts +22 -2
  93. package/dist/panel.d.ts.map +1 -1
  94. package/dist/panel.js +184 -60
  95. package/dist/panel.js.map +1 -1
  96. package/dist/prompt.d.ts +2 -0
  97. package/dist/prompt.d.ts.map +1 -1
  98. package/dist/prompt.js +34 -3
  99. package/dist/prompt.js.map +1 -1
  100. package/dist/prompt.test.js +21 -2
  101. package/dist/prompt.test.js.map +1 -1
  102. package/dist/review-events.d.ts +70 -0
  103. package/dist/review-events.d.ts.map +1 -0
  104. package/dist/review-events.js +52 -0
  105. package/dist/review-events.js.map +1 -0
  106. package/dist/review-events.test.d.ts +2 -0
  107. package/dist/review-events.test.d.ts.map +1 -0
  108. package/dist/review-events.test.js +47 -0
  109. package/dist/review-events.test.js.map +1 -0
  110. package/dist/review-result.test.js +1 -1
  111. package/dist/review-result.test.js.map +1 -1
  112. package/dist/review.js +3 -3
  113. package/dist/review.js.map +1 -1
  114. package/dist/review.meta.test.js +1 -1
  115. package/dist/review.meta.test.js.map +1 -1
  116. package/dist/skill-protocol.test.js +1 -1
  117. package/dist/skill-protocol.test.js.map +1 -1
  118. package/dist/skill-update.test.d.ts +2 -0
  119. package/dist/skill-update.test.d.ts.map +1 -0
  120. package/dist/skill-update.test.js +23 -0
  121. package/dist/skill-update.test.js.map +1 -0
  122. package/dist/skill.d.ts +22 -0
  123. package/dist/skill.d.ts.map +1 -1
  124. package/dist/skill.js +109 -22
  125. package/dist/skill.js.map +1 -1
  126. package/dist/types.d.ts +28 -2
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/types.js +2 -0
  129. package/dist/types.js.map +1 -1
  130. package/dist/ui-client.d.ts +13 -0
  131. package/dist/ui-client.d.ts.map +1 -0
  132. package/dist/ui-client.js +161 -0
  133. package/dist/ui-client.js.map +1 -0
  134. package/dist/ui-client.test.d.ts +2 -0
  135. package/dist/ui-client.test.d.ts.map +1 -0
  136. package/dist/ui-client.test.js +48 -0
  137. package/dist/ui-client.test.js.map +1 -0
  138. package/dist/ui-html.d.ts +7 -0
  139. package/dist/ui-html.d.ts.map +1 -0
  140. package/dist/ui-html.js +52 -0
  141. package/dist/ui-html.js.map +1 -0
  142. package/dist/ui-html.test.d.ts +2 -0
  143. package/dist/ui-html.test.d.ts.map +1 -0
  144. package/dist/ui-html.test.js +26 -0
  145. package/dist/ui-html.test.js.map +1 -0
  146. package/dist/ui-server-main.d.ts +3 -0
  147. package/dist/ui-server-main.d.ts.map +1 -0
  148. package/dist/ui-server-main.js +128 -0
  149. package/dist/ui-server-main.js.map +1 -0
  150. package/dist/ui-server-main.test.d.ts +2 -0
  151. package/dist/ui-server-main.test.d.ts.map +1 -0
  152. package/dist/ui-server-main.test.js +123 -0
  153. package/dist/ui-server-main.test.js.map +1 -0
  154. package/dist/ui-server.d.ts +42 -0
  155. package/dist/ui-server.d.ts.map +1 -0
  156. package/dist/ui-server.js +233 -0
  157. package/dist/ui-server.js.map +1 -0
  158. package/dist/ui-server.test.d.ts +2 -0
  159. package/dist/ui-server.test.d.ts.map +1 -0
  160. package/dist/ui-server.test.js +236 -0
  161. package/dist/ui-server.test.js.map +1 -0
  162. package/dist/update.d.ts.map +1 -1
  163. package/dist/update.js +48 -19
  164. package/dist/update.js.map +1 -1
  165. package/extensions/panel-tool.test.ts +388 -0
  166. package/extensions/panel-tool.ts +390 -0
  167. package/extensions/review.test.ts +79 -0
  168. package/extensions/review.ts +191 -45
  169. package/extensions/rv-completions.test.ts +177 -18
  170. package/extensions/rv-completions.ts +490 -60
  171. package/extensions/rv-interactive.test.ts +562 -0
  172. package/extensions/rv-interactive.ts +616 -0
  173. package/extensions/rv-locale.test.ts +12 -4
  174. package/extensions/rv-locale.ts +44 -11
  175. package/extensions/rv-model-picker.test.ts +172 -0
  176. package/extensions/rv-model-picker.ts +313 -0
  177. package/extensions/rv-model-priorities.test.ts +29 -9
  178. package/extensions/rv-model-priorities.ts +23 -8
  179. package/extensions/rv-prompts.test.ts +281 -2
  180. package/extensions/rv-prompts.ts +480 -32
  181. package/extensions/rv-resolve.test.ts +189 -0
  182. package/extensions/rv-resolve.ts +313 -0
  183. package/extensions/rv-semantic.test.ts +1 -1
  184. package/extensions/rv-semantic.ts +38 -4
  185. package/package.json +18 -6
  186. package/resources/review-presets.json +2 -2
  187. package/resources/rv-model-priorities.json +18 -9
  188. package/skills/pi-review/SKILL.md +84 -60
  189. package/skills/pi-review/references/codex-tools.md +18 -11
  190. package/skills/pi-review/references/model-selection.md +49 -28
  191. package/dist/extensions/review.d.ts +0 -2
  192. package/dist/extensions/review.js +0 -22
  193. package/dist/extensions/rv-prompts.d.ts +0 -19
  194. package/dist/extensions/rv-prompts.js +0 -132
@@ -0,0 +1,189 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "vitest";
3
+ import type { ModelInfo } from "./rv-completions.js";
4
+ import {
5
+ canonicalizeThinking,
6
+ extractModelThinkingFromText,
7
+ normalizeModelKey,
8
+ resolveModelFromCatalog,
9
+ resolveRvParsed,
10
+ resolveThinking,
11
+ } from "./rv-resolve.js";
12
+ import type { RvParsed } from "./rv-prompts.js";
13
+
14
+ const models: ModelInfo[] = [
15
+ {
16
+ provider: "openai-codex",
17
+ id: "gpt-5.5",
18
+ label: "openai-codex/gpt-5.5",
19
+ name: "gpt-5.5",
20
+ reasoning: true,
21
+ contextWindow: 200000,
22
+ thinkingLevels: ["low", "medium", "high", "xhigh"],
23
+ },
24
+ {
25
+ provider: "openai",
26
+ id: "gpt-5.5",
27
+ label: "openai/gpt-5.5",
28
+ name: "gpt-5.5",
29
+ reasoning: true,
30
+ contextWindow: 200000,
31
+ thinkingLevels: ["medium", "high"],
32
+ },
33
+ {
34
+ provider: "wafer.ai",
35
+ id: "Kimi-K2.6",
36
+ label: "wafer.ai/Kimi-K2.6",
37
+ name: "Kimi",
38
+ reasoning: true,
39
+ contextWindow: 128000,
40
+ thinkingLevels: ["low", "high"],
41
+ },
42
+ {
43
+ provider: "anthropic",
44
+ id: "claude-opus-4-8",
45
+ label: "anthropic/claude-opus-4-8",
46
+ name: "opus",
47
+ reasoning: true,
48
+ contextWindow: 200000,
49
+ thinkingLevels: ["high", "xhigh"],
50
+ },
51
+ {
52
+ provider: "px:anthropic",
53
+ id: "claude-opus-4-8",
54
+ label: "px:anthropic/claude-opus-4-8",
55
+ name: "opus-px",
56
+ reasoning: true,
57
+ contextWindow: 200000,
58
+ thinkingLevels: ["high", "xhigh"],
59
+ },
60
+ {
61
+ provider: "px:anthropic",
62
+ id: "claude-sonnet-4-5",
63
+ label: "px:anthropic/claude-sonnet-4-5",
64
+ name: "sonnet-px",
65
+ reasoning: true,
66
+ contextWindow: 200000,
67
+ thinkingLevels: ["medium", "high"],
68
+ },
69
+ {
70
+ provider: "px:anthropic",
71
+ id: "claude-haiku-4-5",
72
+ label: "px:anthropic/claude-haiku-4-5",
73
+ name: "haiku-px",
74
+ reasoning: true,
75
+ contextWindow: 200000,
76
+ thinkingLevels: ["low", "medium"],
77
+ },
78
+ ];
79
+
80
+ function baseParsed(over: Partial<RvParsed> = {}): RvParsed {
81
+ return {
82
+ strategy: "panel",
83
+ mode: "code",
84
+ keepSession: false,
85
+ noStream: false,
86
+ modelsOnly: false,
87
+ target: "@src",
88
+ ...over,
89
+ };
90
+ }
91
+
92
+ test("normalizeModelKey collapses separators", () => {
93
+ assert.equal(normalizeModelKey("GPT-5.5"), "gpt55");
94
+ assert.equal(normalizeModelKey("gpt_5_5"), "gpt55");
95
+ assert.equal(normalizeModelKey("openai-codex/gpt-5.5"), "openaicodex/gpt55");
96
+ });
97
+
98
+ test("thinking aliases and model-supported fallback", () => {
99
+ assert.equal(canonicalizeThinking("最高"), "xhigh");
100
+ assert.equal(canonicalizeThinking("max"), "xhigh");
101
+ assert.deepEqual(resolveThinking("xhigh", ["low", "medium", "high"]), {
102
+ status: "fallback",
103
+ level: "high",
104
+ from: "xhigh",
105
+ reason: "xhigh unsupported; using high",
106
+ });
107
+ assert.equal(resolveThinking("high", ["low", "medium", "high"]).status, "exact");
108
+ });
109
+
110
+ test("model catalog resolves short ids and prefers primary provider", () => {
111
+ const unique = resolveModelFromCatalog("gpt-5.5", models, "openai-codex");
112
+ assert.equal(unique.status, "unique");
113
+ if (unique.status === "unique") assert.equal(unique.model.label, "openai-codex/gpt-5.5");
114
+
115
+ const exact = resolveModelFromCatalog("openai/gpt-5.5", models);
116
+ assert.equal(exact.status, "exact");
117
+
118
+ const kimi = resolveModelFromCatalog("kimi", models);
119
+ assert.equal(kimi.status, "unique");
120
+ if (kimi.status === "unique") assert.equal(kimi.model.label, "wafer.ai/Kimi-K2.6");
121
+ });
122
+
123
+ test("extractModelThinkingFromText never rewrites natural-language targets", () => {
124
+ const original = "review high memory usage under @src";
125
+ const extracted = extractModelThinkingFromText(original, models, "openai-codex");
126
+ assert.equal(extracted.target, original);
127
+ assert.equal(extracted.model, undefined);
128
+ assert.equal(extracted.thinking, undefined);
129
+ });
130
+
131
+ test("resolveRvParsed normalizes flags and inline model:thinking", () => {
132
+ const resolved = resolveRvParsed(
133
+ baseParsed({ model: "gpt-5.5", thinking: "最高" }),
134
+ models,
135
+ "openai-codex",
136
+ );
137
+ assert.equal(resolved.parsed.model, "openai-codex/gpt-5.5");
138
+ assert.equal(resolved.parsed.thinking, "xhigh");
139
+ assert.ok(resolved.notes.some((note) => /model gpt-5\.5/.test(note)));
140
+
141
+ const inline = resolveRvParsed(baseParsed({ model: "openai/gpt-5.5:xhigh" }), models);
142
+ assert.equal(inline.parsed.model, "openai/gpt-5.5");
143
+ assert.equal(inline.parsed.thinking, "high"); // model only supports up to high
144
+ assert.ok(inline.notes.some((note) => /thinking/.test(note)));
145
+ });
146
+
147
+ test("resolveRvParsed keeps natural-language targets verbatim", () => {
148
+ const target = "review high memory usage under @src";
149
+ const resolved = resolveRvParsed(baseParsed({ target }), models);
150
+ assert.equal(resolved.parsed.target, target);
151
+ assert.equal(resolved.parsed.model, undefined);
152
+ assert.equal(resolved.parsed.thinking, undefined);
153
+ });
154
+
155
+ test("resolveRvParsed resolves short names inside --reviewer-model mappings", () => {
156
+ const resolved = resolveRvParsed(
157
+ baseParsed({
158
+ reviewers: 2,
159
+ reviewerModels: ["r1=gpt-5.5:最高", "r2=kimi"],
160
+ }),
161
+ models,
162
+ "openai-codex",
163
+ );
164
+ assert.deepEqual(resolved.parsed.reviewerModels, [
165
+ "r1=openai-codex/gpt-5.5:xhigh",
166
+ "r2=wafer.ai/Kimi-K2.6",
167
+ ]);
168
+ assert.ok(resolved.notes.some((note) => /reviewer-model r1/.test(note)));
169
+ });
170
+
171
+ test("px:anthropic exact catalog ids keep provider colons", () => {
172
+ for (const label of [
173
+ "px:anthropic/claude-opus-4-8",
174
+ "px:anthropic/claude-sonnet-4-5",
175
+ "px:anthropic/claude-haiku-4-5",
176
+ ]) {
177
+ const resolved = resolveModelFromCatalog(label, models);
178
+ assert.equal(resolved.status, "exact", label);
179
+ if (resolved.status === "exact") assert.equal(resolved.model.label, label);
180
+ }
181
+
182
+ const withThinking = resolveRvParsed(
183
+ baseParsed({ model: "px:anthropic/claude-opus-4-8:xhigh" }),
184
+ models,
185
+ );
186
+ assert.equal(withThinking.parsed.model, "px:anthropic/claude-opus-4-8");
187
+ assert.equal(withThinking.parsed.thinking, "xhigh");
188
+ assert.ok(withThinking.notes.some((note) => /thinking suffix/.test(note)));
189
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Human-friendly model / thinking resolution against a live Pi catalog.
3
+ * Pure and testable: completion and /rv execution share the same rules.
4
+ */
5
+
6
+ import type { ModelInfo } from "./rv-completions.js";
7
+ import { THINKING_LEVELS } from "./rv-completions.js";
8
+ import type { RvParsed } from "./rv-prompts.js";
9
+
10
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
11
+
12
+ const THINKING_ALIASES: Record<string, ThinkingLevel> = {
13
+ off: "off",
14
+ none: "off",
15
+ "0": "off",
16
+ false: "off",
17
+ 关闭: "off",
18
+ 无: "off",
19
+ 快: "off",
20
+ fast: "off",
21
+ minimal: "minimal",
22
+ min: "minimal",
23
+ 最低: "minimal",
24
+ low: "low",
25
+ 低: "low",
26
+ medium: "medium",
27
+ mid: "medium",
28
+ med: "medium",
29
+ 中: "medium",
30
+ 中等: "medium",
31
+ high: "high",
32
+ 高: "high",
33
+ 高思考: "high",
34
+ xhigh: "xhigh",
35
+ max: "xhigh",
36
+ maximum: "xhigh",
37
+ ultra: "xhigh",
38
+ 最高: "xhigh",
39
+ 最强: "xhigh",
40
+ 满思考: "xhigh",
41
+ };
42
+
43
+ /** Strip only a trailing `:thinking` suffix; preserve provider colons such as `px:anthropic/...`. */
44
+ export function stripTrailingThinkingSuffix(value: string): { model: string; thinking?: string } {
45
+ const idx = value.lastIndexOf(":");
46
+ if (idx <= 0) return { model: value };
47
+ const after = value.slice(idx + 1).trim();
48
+ // Thinking suffixes are bare levels, never path-like fragments containing `/`.
49
+ if (!after || after.includes("/") || !canonicalizeThinking(after)) return { model: value };
50
+ return { model: value.slice(0, idx), thinking: after };
51
+ }
52
+
53
+ /** Collapse separators so gpt5.5 / gpt_5_5 / GPT-5.5 share one key. Keep `/` and provider `:`. */
54
+ export function normalizeModelKey(value: string): string {
55
+ const { model } = stripTrailingThinkingSuffix(value);
56
+ return model
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9:/]+/g, "")
59
+ .replace(/\/+/g, "/");
60
+ }
61
+
62
+ export function canonicalizeThinking(raw: string | undefined): ThinkingLevel | undefined {
63
+ if (!raw) return undefined;
64
+ const key = raw.trim().toLowerCase();
65
+ if ((THINKING_LEVELS as readonly string[]).includes(key)) return key as ThinkingLevel;
66
+ return THINKING_ALIASES[key] ?? THINKING_ALIASES[raw.trim()];
67
+ }
68
+
69
+ export type ThinkingResolve =
70
+ | { status: "exact"; level: ThinkingLevel }
71
+ | { status: "alias"; level: ThinkingLevel; from: string }
72
+ | { status: "fallback"; level: ThinkingLevel; from: string; reason: string }
73
+ | { status: "unsupported"; from: string; supported: string[] }
74
+ | { status: "none" };
75
+
76
+ /** Map a user thinking token onto levels the model actually supports. */
77
+ export function resolveThinking(
78
+ raw: string | undefined,
79
+ supported: string[] = [...THINKING_LEVELS],
80
+ ): ThinkingResolve {
81
+ if (!raw?.trim()) return { status: "none" };
82
+ const canonical = canonicalizeThinking(raw);
83
+ if (!canonical) return { status: "none" };
84
+ const levels = supported.length ? supported : [...THINKING_LEVELS];
85
+ if (levels.includes(canonical)) {
86
+ const rawIsCanonical = (THINKING_LEVELS as readonly string[]).includes(raw.trim().toLowerCase());
87
+ return rawIsCanonical
88
+ ? { status: "exact", level: canonical }
89
+ : { status: "alias", level: canonical, from: raw };
90
+ }
91
+ const order = [...THINKING_LEVELS];
92
+ const start = order.indexOf(canonical);
93
+ for (let i = start; i >= 0; i--) {
94
+ const level = order[i]!;
95
+ if (levels.includes(level)) {
96
+ return { status: "fallback", level, from: raw, reason: `${canonical} unsupported; using ${level}` };
97
+ }
98
+ }
99
+ for (const level of order) {
100
+ if (levels.includes(level)) {
101
+ return { status: "fallback", level, from: raw, reason: `${canonical} unsupported; using ${level}` };
102
+ }
103
+ }
104
+ return { status: "unsupported", from: raw, supported: levels };
105
+ }
106
+
107
+ export type ModelResolve =
108
+ | { status: "exact"; model: ModelInfo; query: string }
109
+ | { status: "unique"; model: ModelInfo; query: string; reason: string }
110
+ | { status: "ambiguous"; query: string; candidates: ModelInfo[] }
111
+ | { status: "none"; query: string };
112
+
113
+ function splitProviderModel(query: string): { provider?: string; id: string } {
114
+ const cleaned = query.replace(/:.*$/, "").trim();
115
+ const slash = cleaned.indexOf("/");
116
+ if (slash === -1) return { id: cleaned };
117
+ return { provider: cleaned.slice(0, slash), id: cleaned.slice(slash + 1) };
118
+ }
119
+
120
+ function scoreModel(model: ModelInfo, query: string, primaryProvider?: string): number {
121
+ const q = normalizeModelKey(stripTrailingThinkingSuffix(query).model);
122
+ if (!q) return -1;
123
+ const label = normalizeModelKey(model.label);
124
+ const id = normalizeModelKey(model.id);
125
+ const provider = normalizeModelKey(model.provider);
126
+ const { provider: qProvider, id: qId } = (() => {
127
+ const slash = q.indexOf("/");
128
+ if (slash === -1) return { id: q };
129
+ return { provider: q.slice(0, slash), id: q.slice(slash + 1) };
130
+ })();
131
+
132
+ let score = 0;
133
+ if (label === q) score = 1000;
134
+ else if (id === q || id === qId) score = 900;
135
+ else if (label.endsWith(`/${qId}`) || id.endsWith(qId)) score = 800;
136
+ else if (id.includes(qId) || label.includes(q) || id.includes(q)) score = 600;
137
+ else return -1;
138
+
139
+ if (qProvider && provider === qProvider) score += 50;
140
+ if (primaryProvider && model.provider === primaryProvider) score += 20;
141
+ // Prefer longer/newer-looking ids when scores tie later.
142
+ score += Math.min(model.id.length, 40) / 100;
143
+ return score;
144
+ }
145
+
146
+ /** Resolve a user model token to catalog entries without inventing ids. */
147
+ export function resolveModelFromCatalog(
148
+ query: string | undefined,
149
+ models: ModelInfo[],
150
+ primaryProvider?: string,
151
+ ): ModelResolve {
152
+ if (!query?.trim()) return { status: "none", query: "" };
153
+ const raw = query.trim();
154
+ // Only strip a trailing thinking suffix; keep provider colons (px:anthropic/...).
155
+ const modelQuery = stripTrailingThinkingSuffix(raw).model;
156
+
157
+ const exactLabel = models.find((m) => m.label === modelQuery || m.label === raw);
158
+ if (exactLabel) return { status: "exact", model: exactLabel, query: modelQuery };
159
+
160
+ const exactIds = models.filter((m) => m.id === modelQuery || m.id === raw);
161
+ if (exactIds.length === 1) return { status: "exact", model: exactIds[0]!, query: modelQuery };
162
+ if (exactIds.length > 1) {
163
+ const preferred =
164
+ (primaryProvider && exactIds.find((m) => m.provider === primaryProvider)) || exactIds[0]!;
165
+ return {
166
+ status: "unique",
167
+ model: preferred,
168
+ query: modelQuery,
169
+ reason: `matched catalog id ${preferred.label}`,
170
+ };
171
+ }
172
+
173
+ const ranked = models
174
+ .map((model) => ({ model, score: scoreModel(model, modelQuery, primaryProvider) }))
175
+ .filter((row) => row.score >= 0)
176
+ .sort((a, b) => b.score - a.score || b.model.id.localeCompare(a.model.id, undefined, { numeric: true }));
177
+
178
+ if (ranked.length === 0) return { status: "none", query: modelQuery };
179
+ const best = ranked[0]!;
180
+ const top = ranked.filter((row) => Math.abs(row.score - best.score) < 1);
181
+ // Treat as unique when one clear winner, or multiple share the same id across providers with a primary-provider preference.
182
+ if (top.length === 1 || (best.score >= 800 && top.every((row) => row.model.id === best.model.id))) {
183
+ const preferred =
184
+ primaryProvider && top.some((row) => row.model.provider === primaryProvider)
185
+ ? top.find((row) => row.model.provider === primaryProvider)!.model
186
+ : best.model;
187
+ return {
188
+ status: "unique",
189
+ model: preferred,
190
+ query: modelQuery,
191
+ reason: `matched catalog id ${preferred.label}`,
192
+ };
193
+ }
194
+ // Collapse same-score different families into ambiguous list (cap 5).
195
+ const candidates = ranked.slice(0, 5).map((row) => row.model);
196
+ return { status: "ambiguous", query: modelQuery, candidates };
197
+ }
198
+
199
+ /**
200
+ * Natural-language targets must stay verbatim. Model/thinking come only from
201
+ * explicit flags or an unambiguous `--model provider/model:thinking` suffix.
202
+ * This helper is retained for tests/docs as a no-rewrite identity.
203
+ */
204
+ export function extractModelThinkingFromText(
205
+ text: string,
206
+ _models: ModelInfo[] = [],
207
+ _primaryProvider?: string,
208
+ ): { model?: string; thinking?: string; target: string; notes: string[] } {
209
+ return { target: text, notes: [] };
210
+ }
211
+
212
+ export type RvResolution = {
213
+ parsed: RvParsed;
214
+ notes: string[];
215
+ ambiguousModels?: string[];
216
+ };
217
+
218
+ /** Apply catalog-aware normalization to a parsed /rv invocation. */
219
+ export function resolveRvParsed(
220
+ parsed: RvParsed,
221
+ models: ModelInfo[] = [],
222
+ primaryProvider?: string,
223
+ ): RvResolution {
224
+ const notes: string[] = [];
225
+ let next: RvParsed = { ...parsed };
226
+ let ambiguousModels: string[] | undefined;
227
+
228
+ // Inline --model provider/model:thinking only when the suffix is a real thinking level.
229
+ // Providers such as `px:anthropic/...` must keep their colon.
230
+ if (next.model) {
231
+ const split = stripTrailingThinkingSuffix(next.model);
232
+ if (split.thinking && split.model !== next.model) {
233
+ next = { ...next, model: split.model, thinking: next.thinking ?? split.thinking };
234
+ notes.push(`split --model thinking suffix :${split.thinking}`);
235
+ }
236
+ }
237
+
238
+ // Never rewrite natural-language targets by scavenging bare thinking/model words.
239
+
240
+ if (next.model && models.length > 0) {
241
+ const resolved = resolveModelFromCatalog(next.model, models, primaryProvider);
242
+ if (resolved.status === "exact" || resolved.status === "unique") {
243
+ if (resolved.model.label !== next.model) {
244
+ notes.push(`model ${next.model} → ${resolved.model.label}`);
245
+ }
246
+ next = { ...next, model: resolved.model.label };
247
+ if (next.thinking) {
248
+ const thinking = resolveThinking(next.thinking, resolved.model.thinkingLevels);
249
+ if (thinking.status === "exact" || thinking.status === "alias" || thinking.status === "fallback") {
250
+ if (thinking.level !== next.thinking) {
251
+ notes.push(
252
+ thinking.status === "fallback"
253
+ ? `thinking ${next.thinking} → ${thinking.level} (${thinking.reason})`
254
+ : `thinking ${next.thinking} → ${thinking.level}`,
255
+ );
256
+ }
257
+ next = { ...next, thinking: thinking.level };
258
+ } else if (thinking.status === "unsupported") {
259
+ notes.push(`thinking ${next.thinking} unsupported by ${resolved.model.label}; dropped`);
260
+ const { thinking: _drop, ...rest } = next;
261
+ next = rest;
262
+ }
263
+ }
264
+ } else if (resolved.status === "ambiguous") {
265
+ ambiguousModels = resolved.candidates.map((m) => m.label);
266
+ notes.push(`model ${next.model} is ambiguous; candidates: ${ambiguousModels.join(", ")}`);
267
+ } else {
268
+ notes.push(`model ${next.model} not found in catalog; left unchanged for skill verification`);
269
+ }
270
+ } else if (next.thinking) {
271
+ const thinking = resolveThinking(next.thinking);
272
+ if (thinking.status === "exact" || thinking.status === "alias" || thinking.status === "fallback") {
273
+ if (thinking.level !== next.thinking) notes.push(`thinking ${next.thinking} → ${thinking.level}`);
274
+ next = { ...next, thinking: thinking.level };
275
+ }
276
+ }
277
+
278
+ // Resolve each --reviewer-model id=model[:thinking] the same way as --model.
279
+ if (next.reviewerModels?.length && models.length > 0) {
280
+ const resolvedMappings: string[] = [];
281
+ for (const mapping of next.reviewerModels) {
282
+ const eq = mapping.indexOf("=");
283
+ if (eq <= 0) {
284
+ resolvedMappings.push(mapping);
285
+ continue;
286
+ }
287
+ const id = mapping.slice(0, eq);
288
+ const rawModel = mapping.slice(eq + 1);
289
+ const split = stripTrailingThinkingSuffix(rawModel);
290
+ const hit = resolveModelFromCatalog(split.model, models, primaryProvider);
291
+ if (hit.status === "exact" || hit.status === "unique") {
292
+ let thinking = split.thinking;
293
+ if (thinking) {
294
+ const t = resolveThinking(thinking, hit.model.thinkingLevels);
295
+ if (t.status === "exact" || t.status === "alias" || t.status === "fallback") thinking = t.level;
296
+ else thinking = undefined;
297
+ }
298
+ const token = thinking ? `${hit.model.label}:${thinking}` : hit.model.label;
299
+ if (token !== rawModel) notes.push(`reviewer-model ${id}: ${rawModel} → ${token}`);
300
+ resolvedMappings.push(`${id}=${token}`);
301
+ } else if (hit.status === "ambiguous") {
302
+ notes.push(`reviewer-model ${id}: ${rawModel} ambiguous; left unchanged`);
303
+ resolvedMappings.push(mapping);
304
+ } else {
305
+ notes.push(`reviewer-model ${id}: ${rawModel} not in catalog; left unchanged`);
306
+ resolvedMappings.push(mapping);
307
+ }
308
+ }
309
+ next = { ...next, reviewerModels: resolvedMappings };
310
+ }
311
+
312
+ return { parsed: next, notes, ...(ambiguousModels ? { ambiguousModels } : {}) };
313
+ }
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { describe, it } from "vitest";
3
3
  import { parseRvArgs } from "./rv-prompts.js";
4
4
  import { stripSemanticPhrases } from "./rv-semantic.js";
5
5
 
@@ -77,7 +77,10 @@ export function mergeSemanticIntoParsed(parsed: RvParsed, apply: SemanticApply):
77
77
  const out = { ...parsed };
78
78
  if (apply.mode) out.mode = apply.mode;
79
79
  if (apply.keepSession) out.keepSession = true;
80
- if (apply.modelsOnly) out.modelsOnly = true;
80
+ if (apply.modelsOnly) {
81
+ out.modelsOnly = true;
82
+ out.strategy = "models";
83
+ }
81
84
  if (apply.noStream) out.noStream = true;
82
85
  return out;
83
86
  }
@@ -87,21 +90,52 @@ export function semanticCompletionItems(
87
90
  tail: string,
88
91
  headPrefix: string,
89
92
  head: string[],
93
+ rawHead: string[] = head,
90
94
  ): { value: string; label: string; description?: string }[] {
91
95
  const present = new Set(head.map((h) => h.toLowerCase()));
92
96
  const q = tail.toLowerCase().trim();
93
- const items: { value: string; label: string; description?: string }[] = [];
94
- for (const row of SEMANTIC_PHRASES) {
97
+ const prepared = SEMANTIC_PHRASES.map((row) => {
95
98
  const value = row.completionValue(locale);
99
+ const candidates = [value, ...row.phrases].map((candidate) => candidate.toLowerCase().trim());
100
+ let consumedHeadTokens = 0;
101
+ // A multi-word phrase can span completed head tokens plus the in-progress tail
102
+ // (`@src code rev`). Replace that suffix rather than appending `code review`
103
+ // and producing `@src code code review`.
104
+ for (let count = head.length; count >= 1; count -= 1) {
105
+ const typed = [...head.slice(-count), tail]
106
+ .filter(Boolean)
107
+ .join(" ")
108
+ .toLowerCase()
109
+ .trim();
110
+ if (typed && candidates.some((candidate) => candidate.startsWith(typed))) {
111
+ consumedHeadTokens = count;
112
+ break;
113
+ }
114
+ }
115
+ return { row, value, consumedHeadTokens };
116
+ });
117
+ const hasContextualPhrase = prepared.some((entry) => entry.consumedHeadTokens > 0);
118
+ const items: { value: string; label: string; description?: string }[] = [];
119
+
120
+ for (const { row, value, consumedHeadTokens } of prepared) {
96
121
  if (present.has(value.toLowerCase())) continue;
122
+ // Once a head+tail suffix identifies a phrase, suppress unrelated phrases
123
+ // that only happen to contain the short tail (e.g. every "... review").
124
+ if (hasContextualPhrase && consumedHeadTokens === 0) continue;
97
125
  const matches =
126
+ consumedHeadTokens > 0 ||
98
127
  !q ||
99
128
  value.toLowerCase().includes(q) ||
100
129
  value.toLowerCase().startsWith(q) ||
101
130
  row.phrases.some((p) => p.toLowerCase().startsWith(q) || p.toLowerCase().includes(q));
102
131
  if (!matches) continue;
132
+
133
+ const keptRawHead = consumedHeadTokens > 0
134
+ ? rawHead.slice(0, Math.max(0, rawHead.length - consumedHeadTokens))
135
+ : rawHead;
136
+ const replacementPrefix = keptRawHead.length ? `${keptRawHead.join(" ")} ` : "";
103
137
  items.push({
104
- value: `${headPrefix}${value}`,
138
+ value: `${replacementPrefix}${value}`,
105
139
  label: value,
106
140
  description: row.completionDesc?.(locale),
107
141
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zephyrdeng/pi-review",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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>",
@@ -50,29 +50,41 @@
50
50
  ]
51
51
  },
52
52
  "peerDependencies": {
53
- "@earendil-works/pi-coding-agent": "*"
53
+ "@earendil-works/pi-coding-agent": "*",
54
+ "@earendil-works/pi-tui": "*"
54
55
  },
55
56
  "peerDependenciesMeta": {
56
57
  "@earendil-works/pi-coding-agent": {
57
58
  "optional": true
59
+ },
60
+ "@earendil-works/pi-tui": {
61
+ "optional": true
58
62
  }
59
63
  },
60
64
  "devDependencies": {
65
+ "@earendil-works/pi-coding-agent": "^0.80.7",
66
+ "@earendil-works/pi-tui": "^0.80.7",
61
67
  "@types/node": "^22.0.0",
62
68
  "husky": "^9.1.7",
63
- "typescript": "^5.8.0"
69
+ "tsx": "^4.23.1",
70
+ "typescript": "^5.8.0",
71
+ "vitest": "^4.1.10"
64
72
  },
65
73
  "scripts": {
66
74
  "prepare": "husky",
67
75
  "build": "tsc",
68
- "check": "tsc --noEmit",
76
+ "check": "tsc --noEmit && tsc --noEmit -p tsconfig.extension.json",
69
77
  "smoke": "node dist/cli.js --help >/dev/null",
70
- "test": "npx --yes tsx --test src/args.test.ts src/cli-exit.test.ts src/child-process.test.ts src/review.meta.test.ts src/review-result.test.ts src/loop.test.ts src/meta-footer.test.ts src/prompt.test.ts src/skill-protocol.test.ts src/json-events.test.ts src/install-args.test.ts src/panel-aggregate.test.ts src/matcher.test.ts src/panel-config.test.ts src/panel-loop.test.ts src/panel-cli.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",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest",
80
+ "test:rv": "vitest run extensions",
72
81
  "prepublishOnly": "npm run build",
73
82
  "pack:dry": "npm pack --dry-run"
74
83
  },
75
84
  "engines": {
76
85
  "node": ">=20"
86
+ },
87
+ "dependencies": {
88
+ "typebox": "^1.1.38"
77
89
  }
78
90
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "code": {
3
3
  "description": "Code review only: inspect provided files, diffs, or paths and return review conclusions without editing.",
4
- "tools": ["read", "grep", "find", "ls", "bash"],
4
+ "tools": ["read", "grep", "find", "ls"],
5
5
  "thinking": "high",
6
- "instructions": "Review code or code changes only. Do not edit, write, patch, format, commit, or otherwise mutate files. Use bash only for read-only inspection commands such as git diff, git status, git show, tests with explicit user permission, or listing files. Focus on correctness, regressions, security, concurrency, API contracts, edge cases, maintainability, and missing tests."
6
+ "instructions": "Review code or code changes only. Do not edit, write, patch, format, commit, or otherwise mutate files. Use the enabled read-only tools for inspection. Focus on correctness, regressions, security, concurrency, API contracts, edge cases, maintainability, and missing tests."
7
7
  },
8
8
  "plan": {
9
9
  "description": "High-level plan review with multiple expert lenses; output a decisive conclusion.",
@@ -1,17 +1,26 @@
1
1
  {
2
2
  "code": [
3
- { "idContains": "gpt-5.5", "thinking": "xhigh" },
4
- { "idContains": "glm-5.2", "thinking": "high" }
3
+ { "idContains": "claude-sonnet", "versionPrefer": "5", "thinking": "xhigh" },
4
+ { "idContains": "deepseek-v4-flash", "thinking": "xhigh" },
5
+ { "idContains": "glm-5.2", "thinking": "xhigh" },
6
+ { "idContains": "minimax-m3", "thinking": "xhigh" },
7
+ { "idContains": "grok-4.5", "thinking": "xhigh" },
8
+ { "idContains": "gpt-5.6-terra" },
9
+ { "idContains": "gpt-5.6-luna", "thinking": "xhigh" }
5
10
  ],
6
11
  "frontend": [
12
+ { "idContains": "claude" },
13
+ { "idContains": "gpt" },
7
14
  { "idContains": "kimi", "versionPrefer": "2.7" },
8
- { "idContains": "claude-sonnet", "versionPrefer": "5" },
9
- { "idContains": "minimax-m3" }
15
+ { "idContains": "minimax-m3", "thinking": "xhigh" }
10
16
  ],
11
17
  "plan": [
12
- { "idContains": "claude-opus-4-8" },
13
- { "idContains": "claude-opus-4", "versionPrefer": "8" },
14
- { "idContains": "deepseek-v4-pro" },
15
- { "idContains": "deepseek-v4" }
18
+ { "idContains": "gpt-5.6-sol", "thinking": "xhigh" },
19
+ { "idContains": "claude-opus-4-8", "thinking": "xhigh" },
20
+ { "idContains": "claude-opus-4", "versionPrefer": "8", "thinking": "xhigh" },
21
+ { "idContains": "claude-fable-5", "thinking": "xhigh" },
22
+ { "idContains": "glm-5.2", "thinking": "xhigh" },
23
+ { "idContains": "deepseek-v4-pro", "thinking": "xhigh" },
24
+ { "idContains": "grok-4.5", "thinking": "xhigh" }
16
25
  ]
17
- }
26
+ }