@diegopetrucci/pi-extensions 0.1.51 → 0.1.52
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.
|
@@ -0,0 +1,1463 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
+
import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
11
|
+
type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
|
|
12
|
+
|
|
13
|
+
type PiModel = {
|
|
14
|
+
provider: string;
|
|
15
|
+
id: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
reasoning?: boolean;
|
|
18
|
+
contextWindow?: number;
|
|
19
|
+
maxTokens?: number;
|
|
20
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface UsageStats {
|
|
24
|
+
input: number;
|
|
25
|
+
output: number;
|
|
26
|
+
cacheRead: number;
|
|
27
|
+
cacheWrite: number;
|
|
28
|
+
cost: number;
|
|
29
|
+
turns: number;
|
|
30
|
+
contextTokens: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ContrarianSelection {
|
|
34
|
+
modelRef: string;
|
|
35
|
+
provider: string;
|
|
36
|
+
modelId: string;
|
|
37
|
+
modelName?: string;
|
|
38
|
+
thinkingLevel: ThinkingLevel;
|
|
39
|
+
requestedThinkingLevel?: ThinkingLevel;
|
|
40
|
+
thinkingLevelClamped?: boolean;
|
|
41
|
+
autoSelected: boolean;
|
|
42
|
+
selectionReason: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ContrarianDetails extends ContrarianSelection {
|
|
46
|
+
includeBash: boolean;
|
|
47
|
+
usage: UsageStats;
|
|
48
|
+
stderr: string;
|
|
49
|
+
exitCode: number;
|
|
50
|
+
durationMs: number;
|
|
51
|
+
cwd: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ContrarianUiRun {
|
|
55
|
+
task: string;
|
|
56
|
+
includeBash: boolean;
|
|
57
|
+
startedAt: number;
|
|
58
|
+
selection?: ContrarianSelection;
|
|
59
|
+
preview?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ContrarianPreferences {
|
|
63
|
+
model?: string;
|
|
64
|
+
thinkingLevel?: ThinkingLevel;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
68
|
+
const READ_ONLY_PLUS_BASH_TOOLS = [...READ_ONLY_TOOLS, "bash"];
|
|
69
|
+
const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
|
|
70
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
71
|
+
const COLLAPSED_LINE_LIMIT = 8;
|
|
72
|
+
const CONTRARIAN_STATUS_ID = "contrarian";
|
|
73
|
+
const CONTRARIAN_WIDGET_ID = "contrarian";
|
|
74
|
+
const CONTRARIAN_CONFIG_FILE = "contrarian.json";
|
|
75
|
+
const CONTRARIAN_MODEL_PREFERENCES = ["gpt-5.5", "claude-opus-4-8", "claude-opus-4.8"];
|
|
76
|
+
|
|
77
|
+
const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
|
|
78
|
+
"amazon-bedrock": [
|
|
79
|
+
"claude-fable-5",
|
|
80
|
+
"claude-opus-4-8",
|
|
81
|
+
"claude-opus-4-7",
|
|
82
|
+
"claude-opus-4-6",
|
|
83
|
+
"claude-opus-4-5",
|
|
84
|
+
"claude-opus-4-1",
|
|
85
|
+
"claude-opus-4",
|
|
86
|
+
"claude-sonnet-4-6",
|
|
87
|
+
"claude-sonnet-4-5",
|
|
88
|
+
"claude-sonnet-4",
|
|
89
|
+
"deepseek.v3.2",
|
|
90
|
+
"deepseek.r1",
|
|
91
|
+
"kimi-k2.5",
|
|
92
|
+
"minimax-m2.5",
|
|
93
|
+
"minimax-m2.1",
|
|
94
|
+
"zai.glm-5",
|
|
95
|
+
],
|
|
96
|
+
anthropic: [
|
|
97
|
+
"claude-fable-5",
|
|
98
|
+
"claude-opus-4-8",
|
|
99
|
+
"claude-opus-4.8",
|
|
100
|
+
"claude-opus-4-7",
|
|
101
|
+
"claude-opus-4.7",
|
|
102
|
+
"claude-opus-4-6",
|
|
103
|
+
"claude-opus-4.6",
|
|
104
|
+
"claude-opus-4-5",
|
|
105
|
+
"claude-opus-4.5",
|
|
106
|
+
"claude-opus-4-1",
|
|
107
|
+
"claude-opus-4.1",
|
|
108
|
+
"claude-opus-4-0",
|
|
109
|
+
"claude-opus-4",
|
|
110
|
+
"claude-sonnet-4-6",
|
|
111
|
+
"claude-sonnet-4.6",
|
|
112
|
+
"claude-sonnet-4-5",
|
|
113
|
+
"claude-sonnet-4.5",
|
|
114
|
+
"claude-sonnet-4-0",
|
|
115
|
+
"claude-sonnet-4",
|
|
116
|
+
"claude-3-7-sonnet",
|
|
117
|
+
],
|
|
118
|
+
"ant-ling": ["Ling-2.6-1T", "Ling-2.6-flash"],
|
|
119
|
+
"azure-openai-responses": [
|
|
120
|
+
"gpt-5.5-pro",
|
|
121
|
+
"gpt-5.5",
|
|
122
|
+
"gpt-5.4-pro",
|
|
123
|
+
"gpt-5.4",
|
|
124
|
+
"gpt-5.3-codex",
|
|
125
|
+
"gpt-5-pro",
|
|
126
|
+
"gpt-5.2-pro",
|
|
127
|
+
"gpt-5.2",
|
|
128
|
+
"gpt-5.2-codex",
|
|
129
|
+
"gpt-5.1-codex-max",
|
|
130
|
+
"gpt-5.1-chat-latest",
|
|
131
|
+
"o3-pro",
|
|
132
|
+
"o3-deep-research",
|
|
133
|
+
"o1-pro",
|
|
134
|
+
"gpt-5.4-mini",
|
|
135
|
+
"gpt-5-mini",
|
|
136
|
+
],
|
|
137
|
+
cerebras: ["gpt-oss-120b", "zai-glm-4.7", "llama3.1-8b"],
|
|
138
|
+
"cloudflare-ai-gateway": [
|
|
139
|
+
"claude-fable-5",
|
|
140
|
+
"claude-opus-4-7",
|
|
141
|
+
"claude-opus-4-6",
|
|
142
|
+
"claude-opus-4-5",
|
|
143
|
+
"gpt-5.5",
|
|
144
|
+
"gpt-5.4",
|
|
145
|
+
"gpt-5.3-codex",
|
|
146
|
+
"workers-ai/@cf/moonshotai/kimi-k2.6",
|
|
147
|
+
"workers-ai/@cf/nvidia/nemotron-3-120b-a12b",
|
|
148
|
+
"workers-ai/@cf/zai-org/glm-4.7-flash",
|
|
149
|
+
],
|
|
150
|
+
"cloudflare-workers-ai": [
|
|
151
|
+
"@cf/moonshotai/kimi-k2.7-code",
|
|
152
|
+
"@cf/zai-org/glm-5.2",
|
|
153
|
+
"@cf/moonshotai/kimi-k2.6",
|
|
154
|
+
"@cf/nvidia/nemotron-3-120b-a12b",
|
|
155
|
+
"@cf/moonshotai/kimi-k2.5",
|
|
156
|
+
"@cf/openai/gpt-oss-120b",
|
|
157
|
+
"@cf/zai-org/glm-4.7-flash",
|
|
158
|
+
],
|
|
159
|
+
deepseek: ["deepseek-v4-pro", "deepseek-v4-flash"],
|
|
160
|
+
fireworks: [
|
|
161
|
+
"accounts/fireworks/models/deepseek-v4-pro",
|
|
162
|
+
"accounts/fireworks/models/kimi-k2p7-code",
|
|
163
|
+
"accounts/fireworks/routers/kimi-k2p7-code-fast",
|
|
164
|
+
"accounts/fireworks/models/glm-5p2",
|
|
165
|
+
"accounts/fireworks/models/minimax-m3",
|
|
166
|
+
"accounts/fireworks/models/glm-5p1",
|
|
167
|
+
"accounts/fireworks/models/kimi-k2p6",
|
|
168
|
+
"accounts/fireworks/models/minimax-m2p7",
|
|
169
|
+
"accounts/fireworks/models/qwen3p7-plus",
|
|
170
|
+
"accounts/fireworks/models/qwen3p6-plus",
|
|
171
|
+
"accounts/fireworks/models/gpt-oss-120b",
|
|
172
|
+
],
|
|
173
|
+
"github-copilot": [
|
|
174
|
+
"claude-fable-5",
|
|
175
|
+
"claude-opus-4.8",
|
|
176
|
+
"claude-opus-4.7",
|
|
177
|
+
"claude-opus-4.6",
|
|
178
|
+
"claude-opus-4.5",
|
|
179
|
+
"gpt-5.5",
|
|
180
|
+
"gpt-5.4",
|
|
181
|
+
"gpt-5.3-codex",
|
|
182
|
+
"gpt-5.2",
|
|
183
|
+
"gpt-5.1-codex-max",
|
|
184
|
+
"gpt-5.1",
|
|
185
|
+
"gpt-5",
|
|
186
|
+
"gemini-3.1-pro-preview",
|
|
187
|
+
"gemini-3-pro-preview",
|
|
188
|
+
"claude-sonnet-4.6",
|
|
189
|
+
"claude-sonnet-4.5",
|
|
190
|
+
"gemini-2.5-pro",
|
|
191
|
+
],
|
|
192
|
+
google: [
|
|
193
|
+
"gemini-3.1-pro-preview-customtools",
|
|
194
|
+
"gemini-3.1-pro-preview",
|
|
195
|
+
"gemini-3-pro-preview",
|
|
196
|
+
"gemini-2.5-pro-preview",
|
|
197
|
+
"gemini-2.5-pro",
|
|
198
|
+
"gemini-2.5-flash-preview",
|
|
199
|
+
"gemini-2.5-flash-lite-preview",
|
|
200
|
+
"gemini-2.5-flash-lite",
|
|
201
|
+
],
|
|
202
|
+
"google-vertex": [
|
|
203
|
+
"gemini-3.1-pro-preview-customtools",
|
|
204
|
+
"gemini-3.1-pro-preview",
|
|
205
|
+
"gemini-3-pro-preview",
|
|
206
|
+
"gemini-2.5-pro",
|
|
207
|
+
"gemini-2.5-flash-lite",
|
|
208
|
+
"gemini-2.0-flash-lite",
|
|
209
|
+
],
|
|
210
|
+
groq: [
|
|
211
|
+
"openai/gpt-oss-120b",
|
|
212
|
+
"groq/compound-mini",
|
|
213
|
+
"qwen/qwen3-32b",
|
|
214
|
+
"moonshotai/kimi-k2-instruct",
|
|
215
|
+
"meta-llama/llama-4-scout",
|
|
216
|
+
],
|
|
217
|
+
huggingface: [
|
|
218
|
+
"zai-org/GLM-5.1",
|
|
219
|
+
"deepseek-ai/DeepSeek-V4-Pro",
|
|
220
|
+
"moonshotai/Kimi-K2.6",
|
|
221
|
+
"MiniMaxAI/MiniMax-M2.7",
|
|
222
|
+
"Qwen/Qwen3.5-397B-A17B",
|
|
223
|
+
"Qwen/Qwen3-235B-A22B-Thinking-2507",
|
|
224
|
+
"moonshotai/Kimi-K2.5",
|
|
225
|
+
"deepseek-ai/DeepSeek-V3.2",
|
|
226
|
+
"MiniMaxAI/MiniMax-M2.5",
|
|
227
|
+
"Qwen/Qwen3-Coder-Next",
|
|
228
|
+
],
|
|
229
|
+
"kimi-coding": ["k2p7", "kimi-k2-thinking", "kimi-for-coding"],
|
|
230
|
+
minimax: ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"],
|
|
231
|
+
"minimax-cn": ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"],
|
|
232
|
+
mistral: [
|
|
233
|
+
"mistral-medium-2604",
|
|
234
|
+
"mistral-medium-3.5",
|
|
235
|
+
"mistral-medium-latest",
|
|
236
|
+
"magistral-medium-latest",
|
|
237
|
+
"devstral-medium-latest",
|
|
238
|
+
"mistral-large-latest",
|
|
239
|
+
"mistral-large-2411",
|
|
240
|
+
"mistral-medium-2508",
|
|
241
|
+
"mistral-small-2603",
|
|
242
|
+
"devstral-2512",
|
|
243
|
+
],
|
|
244
|
+
openai: [
|
|
245
|
+
"gpt-5.5-pro",
|
|
246
|
+
"gpt-5.5",
|
|
247
|
+
"gpt-5.4-pro",
|
|
248
|
+
"gpt-5.4",
|
|
249
|
+
"gpt-5.3-codex",
|
|
250
|
+
"gpt-5-pro",
|
|
251
|
+
"gpt-5.2-pro",
|
|
252
|
+
"gpt-5.2",
|
|
253
|
+
"gpt-5.2-codex",
|
|
254
|
+
"gpt-5.1-codex-max",
|
|
255
|
+
"gpt-5.1-chat-latest",
|
|
256
|
+
"o3-pro",
|
|
257
|
+
"o3-deep-research",
|
|
258
|
+
"o1-pro",
|
|
259
|
+
"gpt-5.4-mini",
|
|
260
|
+
"gpt-5-mini",
|
|
261
|
+
],
|
|
262
|
+
"openai-codex": [
|
|
263
|
+
"gpt-5.5",
|
|
264
|
+
"gpt-5.4",
|
|
265
|
+
"gpt-5.3-codex",
|
|
266
|
+
"gpt-5.2",
|
|
267
|
+
"gpt-5.1-codex-max",
|
|
268
|
+
"gpt-5.4-mini",
|
|
269
|
+
"gpt-5.1-codex-mini",
|
|
270
|
+
"big-pickle",
|
|
271
|
+
],
|
|
272
|
+
opencode: [
|
|
273
|
+
"claude-fable-5",
|
|
274
|
+
"gpt-5.5-pro",
|
|
275
|
+
"gpt-5.5",
|
|
276
|
+
"gpt-5.4-pro",
|
|
277
|
+
"gpt-5.4",
|
|
278
|
+
"claude-opus-4-8",
|
|
279
|
+
"claude-opus-4-7",
|
|
280
|
+
"claude-opus-4-6",
|
|
281
|
+
"claude-opus-4-5",
|
|
282
|
+
"gpt-5.3-codex",
|
|
283
|
+
"gpt-5.2-codex",
|
|
284
|
+
"gpt-5.1-codex",
|
|
285
|
+
"gemini-3.1-pro",
|
|
286
|
+
"glm-5.1",
|
|
287
|
+
"glm-5",
|
|
288
|
+
"kimi-k2.6",
|
|
289
|
+
"kimi-k2.5",
|
|
290
|
+
"qwen3.6-plus",
|
|
291
|
+
"qwen3.5-plus",
|
|
292
|
+
"minimax-m2.7",
|
|
293
|
+
"minimax-m2.5-free",
|
|
294
|
+
],
|
|
295
|
+
"opencode-go": [
|
|
296
|
+
"deepseek-v4-pro",
|
|
297
|
+
"glm-5.2",
|
|
298
|
+
"glm-5.1",
|
|
299
|
+
"qwen3.7-max",
|
|
300
|
+
"qwen3.7-plus",
|
|
301
|
+
"qwen3.6-plus",
|
|
302
|
+
"mimo-v2.5-pro",
|
|
303
|
+
"mimo-v2-pro",
|
|
304
|
+
"minimax-m3",
|
|
305
|
+
"minimax-m2.7",
|
|
306
|
+
"kimi-k2.7-code",
|
|
307
|
+
"kimi-k2.6",
|
|
308
|
+
"kimi-k2.5",
|
|
309
|
+
],
|
|
310
|
+
openrouter: [
|
|
311
|
+
"anthropic/claude-fable-5",
|
|
312
|
+
"~anthropic/claude-fable-latest",
|
|
313
|
+
"anthropic/claude-opus-4.8",
|
|
314
|
+
"anthropic/claude-opus-4.8-fast",
|
|
315
|
+
"anthropic/claude-opus-4.7",
|
|
316
|
+
"anthropic/claude-opus-4.6-fast",
|
|
317
|
+
"anthropic/claude-opus-4.6",
|
|
318
|
+
"anthropic/claude-opus-4.5",
|
|
319
|
+
"anthropic/claude-opus-4",
|
|
320
|
+
"openai/gpt-5.5-pro",
|
|
321
|
+
"openai/gpt-5.5",
|
|
322
|
+
"openai/gpt-5.4-pro",
|
|
323
|
+
"openai/gpt-5.4",
|
|
324
|
+
"google/gemini-3.1-pro-preview-customtools",
|
|
325
|
+
"google/gemini-3.1-pro-preview",
|
|
326
|
+
"google/gemini-2.5-pro",
|
|
327
|
+
"moonshotai/kimi-k2.7-code",
|
|
328
|
+
"moonshotai/kimi-k2.6",
|
|
329
|
+
"moonshotai/kimi-k2-thinking",
|
|
330
|
+
"deepseek/deepseek-v4-pro",
|
|
331
|
+
"deepseek/deepseek-r1",
|
|
332
|
+
"deepseek/deepseek-v3.2",
|
|
333
|
+
"minimax/minimax-m3",
|
|
334
|
+
"minimax/minimax-m2.7",
|
|
335
|
+
"minimax/minimax-m2.1",
|
|
336
|
+
"z-ai/glm-5.2",
|
|
337
|
+
"z-ai/glm-5.1",
|
|
338
|
+
],
|
|
339
|
+
together: [
|
|
340
|
+
"deepseek-ai/DeepSeek-V4-Pro",
|
|
341
|
+
"zai-org/GLM-5.1",
|
|
342
|
+
"moonshotai/Kimi-K2.7-Code",
|
|
343
|
+
"moonshotai/Kimi-K2.6",
|
|
344
|
+
"Qwen/Qwen3.7-Max",
|
|
345
|
+
"Qwen/Qwen3.6-Plus",
|
|
346
|
+
"MiniMaxAI/MiniMax-M3",
|
|
347
|
+
"MiniMaxAI/MiniMax-M2.7",
|
|
348
|
+
"Qwen/Qwen3.5-397B-A17B",
|
|
349
|
+
"Qwen/Qwen3-Coder-Next-FP8",
|
|
350
|
+
"Qwen/Qwen3-235B-A22B-Instruct-2507-tput",
|
|
351
|
+
"openai/gpt-oss-120b",
|
|
352
|
+
"moonshotai/Kimi-K2.5",
|
|
353
|
+
"deepseek-ai/DeepSeek-V3-1",
|
|
354
|
+
"MiniMaxAI/MiniMax-M2.5",
|
|
355
|
+
],
|
|
356
|
+
"vercel-ai-gateway": [
|
|
357
|
+
"anthropic/claude-fable-5",
|
|
358
|
+
"anthropic/claude-opus-4.8",
|
|
359
|
+
"anthropic/claude-opus-4.7",
|
|
360
|
+
"anthropic/claude-opus-4.6",
|
|
361
|
+
"anthropic/claude-opus-4.5",
|
|
362
|
+
"anthropic/claude-opus-4.1",
|
|
363
|
+
"anthropic/claude-sonnet-4.6",
|
|
364
|
+
"openai/gpt-5.5-pro",
|
|
365
|
+
"openai/gpt-5.5",
|
|
366
|
+
"openai/gpt-5.4-pro",
|
|
367
|
+
"openai/gpt-5.4",
|
|
368
|
+
"openai/gpt-5.1-codex",
|
|
369
|
+
"openai/gpt-5-codex",
|
|
370
|
+
"moonshotai/kimi-k2.7-code",
|
|
371
|
+
"moonshotai/kimi-k2.7-code-highspeed",
|
|
372
|
+
"moonshotai/kimi-k2.6",
|
|
373
|
+
"moonshotai/kimi-k2-thinking",
|
|
374
|
+
"deepseek/deepseek-v4-pro",
|
|
375
|
+
"deepseek/deepseek-v3.2-thinking",
|
|
376
|
+
"alibaba/qwen3.7-max",
|
|
377
|
+
"alibaba/qwen3.7-plus",
|
|
378
|
+
"alibaba/qwen3.5-plus",
|
|
379
|
+
"alibaba/qwen3-max-thinking",
|
|
380
|
+
"google/gemini-3.1-pro-preview",
|
|
381
|
+
"google/gemini-3-flash",
|
|
382
|
+
"xai/grok-4.3",
|
|
383
|
+
"minimax/minimax-m3",
|
|
384
|
+
"zai/glm-5.2",
|
|
385
|
+
"zai/glm-5.1",
|
|
386
|
+
],
|
|
387
|
+
xai: [
|
|
388
|
+
"grok-4.3",
|
|
389
|
+
"grok-4.20-0309-reasoning",
|
|
390
|
+
"grok-4-1-fast",
|
|
391
|
+
"grok-4-fast",
|
|
392
|
+
"grok-4",
|
|
393
|
+
"grok-3-mini-latest",
|
|
394
|
+
"grok-3-mini-fast",
|
|
395
|
+
"grok-3-latest",
|
|
396
|
+
],
|
|
397
|
+
xiaomi: ["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"],
|
|
398
|
+
"xiaomi-token-plan-ams": [
|
|
399
|
+
"mimo-v2.5-pro",
|
|
400
|
+
"mimo-v2.5",
|
|
401
|
+
"mimo-v2-pro",
|
|
402
|
+
"mimo-v2-omni",
|
|
403
|
+
"mimo-v2-flash",
|
|
404
|
+
],
|
|
405
|
+
"xiaomi-token-plan-cn": [
|
|
406
|
+
"mimo-v2.5-pro",
|
|
407
|
+
"mimo-v2.5",
|
|
408
|
+
"mimo-v2-pro",
|
|
409
|
+
"mimo-v2-omni",
|
|
410
|
+
"mimo-v2-flash",
|
|
411
|
+
],
|
|
412
|
+
"xiaomi-token-plan-sgp": [
|
|
413
|
+
"mimo-v2.5-pro",
|
|
414
|
+
"mimo-v2.5",
|
|
415
|
+
"mimo-v2-pro",
|
|
416
|
+
"mimo-v2-omni",
|
|
417
|
+
"mimo-v2-flash",
|
|
418
|
+
],
|
|
419
|
+
nvidia: [
|
|
420
|
+
"nvidia/nemotron-3-ultra-550b-a55b",
|
|
421
|
+
"nvidia/nemotron-3-super-120b-a12b",
|
|
422
|
+
"moonshotai/kimi-k2.6",
|
|
423
|
+
"z-ai/glm-5.1",
|
|
424
|
+
"qwen/qwen3.5-122b-a10b",
|
|
425
|
+
"openai/gpt-oss-120b",
|
|
426
|
+
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
|
|
427
|
+
"nvidia/nemotron-3-nano-30b-a3b",
|
|
428
|
+
],
|
|
429
|
+
zai: [
|
|
430
|
+
"glm-5.2",
|
|
431
|
+
"glm-5.1",
|
|
432
|
+
"glm-5-turbo",
|
|
433
|
+
"glm-5v-turbo",
|
|
434
|
+
"glm-5",
|
|
435
|
+
"glm-4.7",
|
|
436
|
+
"glm-4.7-flash",
|
|
437
|
+
"glm-4.6v",
|
|
438
|
+
"glm-4.5v",
|
|
439
|
+
"glm-4.5-air",
|
|
440
|
+
],
|
|
441
|
+
"zai-coding-cn": [
|
|
442
|
+
"glm-5.2",
|
|
443
|
+
"glm-5.1",
|
|
444
|
+
"glm-5-turbo",
|
|
445
|
+
"glm-5v-turbo",
|
|
446
|
+
"glm-5",
|
|
447
|
+
"glm-4.7",
|
|
448
|
+
"glm-4.5-air",
|
|
449
|
+
],
|
|
450
|
+
moonshotai: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2-thinking-turbo", "kimi-k2-thinking", "kimi-k2.5"],
|
|
451
|
+
"moonshotai-cn": ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2-thinking-turbo", "kimi-k2-thinking", "kimi-k2.5"],
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const CONTRARIAN_SYSTEM_PROMPT = [
|
|
455
|
+
"You are the Contrarian: an independent, read-only adversarial analyst.",
|
|
456
|
+
"Your job is to stress-test a proposal, plan, design, assumption, bug hypothesis, review conclusion, product direction, or decision by developing the strongest credible opposing case for the delegating primary agent.",
|
|
457
|
+
"You are read-only. Never modify files, create patches, install dependencies, change configuration, implement fixes, or delegate work to other agents. Your output is adversarial analysis, evidence, and recommendations only.",
|
|
458
|
+
"Use the available tools to inspect the repository and gather evidence. If bash is available, use it only for non-mutating inspection commands.",
|
|
459
|
+
"Analysis process:",
|
|
460
|
+
"1. Identify the core claim, decision, or direction being challenged.",
|
|
461
|
+
"2. Gather the local read-only context needed to evaluate it accurately.",
|
|
462
|
+
"3. Steelman the strongest credible opposing position before judging whether it holds.",
|
|
463
|
+
"4. Identify hidden assumptions, failure modes, alternative interpretations, tradeoffs, and disconfirming evidence.",
|
|
464
|
+
"5. Clearly separate confirmed objections, plausible concerns, and unresolved unknowns.",
|
|
465
|
+
"6. Do not implement fixes or produce patches.",
|
|
466
|
+
"If blocked by missing context, missing access, or a decision the delegating primary agent must make, state the blocker explicitly and continue with caveated analysis where possible.",
|
|
467
|
+
"Return a concise markdown report with: Core claim or proposal being challenged; Strongest opposing case; Evidence reviewed; Which objections are confirmed, speculative, or unresolved; Residual risks, caveats, and recommended next steps, without implementing fixes.",
|
|
468
|
+
].join("\n");
|
|
469
|
+
|
|
470
|
+
const ContrarianParams = Type.Object({
|
|
471
|
+
task: Type.String({
|
|
472
|
+
description: "Question or task for the contrarian. Include enough context for a stand-alone review or analysis.",
|
|
473
|
+
}),
|
|
474
|
+
includeBash: Type.Optional(
|
|
475
|
+
Type.Boolean({
|
|
476
|
+
description: "Allow non-mutating bash inspection in addition to read/grep/find/ls. Default: false.",
|
|
477
|
+
default: false,
|
|
478
|
+
}),
|
|
479
|
+
),
|
|
480
|
+
model: Type.Optional(
|
|
481
|
+
Type.String({
|
|
482
|
+
description:
|
|
483
|
+
"Optional exact model or model pattern override. If omitted, the extension prefers a strong reasoning model on the opposite model family/provider when available, then falls back to the current provider/subscription.",
|
|
484
|
+
}),
|
|
485
|
+
),
|
|
486
|
+
thinkingLevel: Type.Optional(
|
|
487
|
+
StringEnum(THINKING_LEVELS, {
|
|
488
|
+
description:
|
|
489
|
+
"Optional reasoning level override for the contrarian subprocess. Defaults request high for reasoning models and off for non-reasoning models, then clamp to matched model capabilities.",
|
|
490
|
+
}),
|
|
491
|
+
),
|
|
492
|
+
cwd: Type.Optional(Type.String({ description: "Optional working directory for the contrarian subprocess." })),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
function createEmptyUsage(): UsageStats {
|
|
496
|
+
return {
|
|
497
|
+
input: 0,
|
|
498
|
+
output: 0,
|
|
499
|
+
cacheRead: 0,
|
|
500
|
+
cacheWrite: 0,
|
|
501
|
+
cost: 0,
|
|
502
|
+
turns: 0,
|
|
503
|
+
contextTokens: 0,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function getContrarianConfigPath(): string {
|
|
508
|
+
return path.join(getAgentDir(), "extensions", CONTRARIAN_CONFIG_FILE);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
|
|
512
|
+
if (typeof value !== "string") return undefined;
|
|
513
|
+
const normalized = value.trim().toLowerCase();
|
|
514
|
+
return (THINKING_LEVELS as readonly string[]).includes(normalized) ? (normalized as ThinkingLevel) : undefined;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function normalizeModelPreference(value: unknown): string | undefined {
|
|
518
|
+
if (typeof value !== "string") return undefined;
|
|
519
|
+
const trimmed = value.trim();
|
|
520
|
+
return trimmed ? trimmed : undefined;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function parseModelPreference(value: unknown): { model?: string; thinkingLevel?: ThinkingLevel } {
|
|
524
|
+
const model = normalizeModelPreference(value);
|
|
525
|
+
if (!model) return {};
|
|
526
|
+
const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh)$/i);
|
|
527
|
+
if (!match?.[1]) return { model };
|
|
528
|
+
return { model: match[1], thinkingLevel: parseThinkingLevel(match[2]) };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function readContrarianPreferences(): Promise<ContrarianPreferences> {
|
|
532
|
+
try {
|
|
533
|
+
const raw = await fs.readFile(getContrarianConfigPath(), "utf8");
|
|
534
|
+
const parsed = JSON.parse(raw) as {
|
|
535
|
+
model?: unknown;
|
|
536
|
+
defaultModel?: unknown;
|
|
537
|
+
thinkingLevel?: unknown;
|
|
538
|
+
defaultThinkingLevel?: unknown;
|
|
539
|
+
};
|
|
540
|
+
return {
|
|
541
|
+
model: parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
|
|
542
|
+
thinkingLevel:
|
|
543
|
+
parseThinkingLevel(parsed.thinkingLevel) ??
|
|
544
|
+
parseThinkingLevel(parsed.defaultThinkingLevel) ??
|
|
545
|
+
parseModelPreference(parsed.model).thinkingLevel ??
|
|
546
|
+
parseModelPreference(parsed.defaultModel).thinkingLevel,
|
|
547
|
+
};
|
|
548
|
+
} catch {
|
|
549
|
+
return {};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function writeContrarianPreferences(preferences: ContrarianPreferences): Promise<void> {
|
|
554
|
+
const configPath = getContrarianConfigPath();
|
|
555
|
+
const config = {
|
|
556
|
+
...(preferences.model ? { model: preferences.model } : {}),
|
|
557
|
+
...(preferences.thinkingLevel ? { thinkingLevel: preferences.thinkingLevel } : {}),
|
|
558
|
+
updatedAt: new Date().toISOString(),
|
|
559
|
+
};
|
|
560
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
561
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function formatContrarianPreferences(preferences: ContrarianPreferences): string {
|
|
565
|
+
const model = preferences.model ?? "auto";
|
|
566
|
+
const thinkingLevel = preferences.thinkingLevel ?? "auto";
|
|
567
|
+
return `Contrarian defaults: model=${model}, thinkingLevel=${thinkingLevel}. Config: ${getContrarianConfigPath()}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function notifyCommand(ctx: any, message: string, kind = "info"): void {
|
|
571
|
+
if (ctx.hasUI && ctx.ui) ctx.ui.notify(message, kind);
|
|
572
|
+
else console.log(message);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function formatTokens(count: number): string {
|
|
576
|
+
if (count < 1000) return count.toString();
|
|
577
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
578
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
579
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function formatDuration(durationMs: number): string {
|
|
583
|
+
if (durationMs < 1000) return `${durationMs}ms`;
|
|
584
|
+
if (durationMs < 60_000) return `${(durationMs / 1000).toFixed(1)}s`;
|
|
585
|
+
return `${(durationMs / 60_000).toFixed(1)}m`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function formatUsage(stats: UsageStats): string {
|
|
589
|
+
const parts: string[] = [];
|
|
590
|
+
if (stats.turns) parts.push(`${stats.turns} turn${stats.turns === 1 ? "" : "s"}`);
|
|
591
|
+
if (stats.input) parts.push(`↑${formatTokens(stats.input)}`);
|
|
592
|
+
if (stats.output) parts.push(`↓${formatTokens(stats.output)}`);
|
|
593
|
+
if (stats.cacheRead) parts.push(`R${formatTokens(stats.cacheRead)}`);
|
|
594
|
+
if (stats.cacheWrite) parts.push(`W${formatTokens(stats.cacheWrite)}`);
|
|
595
|
+
if (stats.cost) parts.push(`$${stats.cost.toFixed(4)}`);
|
|
596
|
+
if (stats.contextTokens) parts.push(`ctx:${formatTokens(stats.contextTokens)}`);
|
|
597
|
+
return parts.join(" ");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function extractTextFromContent(content: unknown): string {
|
|
601
|
+
if (!Array.isArray(content)) return "";
|
|
602
|
+
const parts: string[] = [];
|
|
603
|
+
for (const part of content) {
|
|
604
|
+
if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
|
|
605
|
+
const text = (part as { text?: string }).text;
|
|
606
|
+
if (typeof text === "string" && text.trim()) parts.push(text);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return parts.join("\n\n").trim();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Errors that typically resolve on retry: provider overload, rate limiting,
|
|
613
|
+
// transient 5xx, gateway/network failures, and request timeouts. Keep this list
|
|
614
|
+
// pattern-based so we recognize variants across providers without enumerating them.
|
|
615
|
+
const TRANSIENT_ERROR_PATTERN =
|
|
616
|
+
/\b(overload(?:ed)?|rate[ _-]?limit(?:ed)?|too many requests|429|500|502|503|504|bad gateway|service unavailable|gateway timeout|temporarily unavailable|timed?[ _-]?out|timeout|econnreset|econnrefused|etimedout|enetunreach|socket hang up|fetch failed)\b/i;
|
|
617
|
+
|
|
618
|
+
function isTransientErrorMessage(message: string): boolean {
|
|
619
|
+
return TRANSIENT_ERROR_PATTERN.test(message);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function formatContrarianModelError(stopReason: "error" | "aborted", errorMessage: string | undefined): string {
|
|
623
|
+
const trimmed = errorMessage?.trim();
|
|
624
|
+
if (stopReason === "aborted") {
|
|
625
|
+
return trimmed ? `Contrarian model turn aborted: ${trimmed}` : "Contrarian model turn aborted.";
|
|
626
|
+
}
|
|
627
|
+
if (!trimmed) return "Contrarian model error (no detail provided by provider).";
|
|
628
|
+
const base = `Contrarian model error: ${trimmed}`;
|
|
629
|
+
return isTransientErrorMessage(trimmed) ? `${base} (transient; retry may succeed)` : base;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function parseVersionScore(text: string): number {
|
|
633
|
+
const matches = text.match(/\d+(?:\.\d+){0,2}/g) ?? [];
|
|
634
|
+
let best = 0;
|
|
635
|
+
for (const match of matches) {
|
|
636
|
+
const [major = "0", minor = "0", patch = "0"] = match.split(".");
|
|
637
|
+
const score = Number(major) * 1_000_000 + Number(minor) * 1_000 + Number(patch);
|
|
638
|
+
if (score > best) best = score;
|
|
639
|
+
}
|
|
640
|
+
return best;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function rankModel(model: PiModel): number {
|
|
644
|
+
const text = `${model.id} ${model.name ?? ""}`.toLowerCase();
|
|
645
|
+
const has = (regex: RegExp): boolean => regex.test(text);
|
|
646
|
+
let score = 0;
|
|
647
|
+
|
|
648
|
+
if (model.reasoning) score += 10_000_000;
|
|
649
|
+
score += parseVersionScore(text) * 1_000;
|
|
650
|
+
score += Math.min(model.maxTokens ?? 0, 200_000);
|
|
651
|
+
score += Math.floor(Math.min(model.contextWindow ?? 0, 1_000_000) / 100);
|
|
652
|
+
|
|
653
|
+
if (has(/\bopus\b/)) score += 350_000;
|
|
654
|
+
if (has(/\bpro\b/)) score += 180_000;
|
|
655
|
+
if (has(/\bmax\b/)) score += 60_000;
|
|
656
|
+
if (has(/\bultra\b/)) score += 150_000;
|
|
657
|
+
if (has(/\bsonnet\b/)) score += 120_000;
|
|
658
|
+
if (has(/\bcodex\b|\bcoder\b|\bcode\b/)) score += 40_000;
|
|
659
|
+
if (has(/\breasoning\b|\bthink(?:ing)?\b/)) score += 60_000;
|
|
660
|
+
|
|
661
|
+
if (has(/\bhaiku\b/)) score -= 420_000;
|
|
662
|
+
if (has(/\bmini\b/)) score -= 520_000;
|
|
663
|
+
if (has(/\bnano\b/)) score -= 700_000;
|
|
664
|
+
if (has(/\bflash\b/)) score -= 520_000;
|
|
665
|
+
if (has(/\bspark\b/)) score -= 650_000;
|
|
666
|
+
if (has(/\blite\b|\bsmall\b|\bfast\b|\binstant\b/)) score -= 300_000;
|
|
667
|
+
|
|
668
|
+
return score;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function shorten(text: string, max: number): string {
|
|
672
|
+
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
673
|
+
if (singleLine.length <= max) return singleLine;
|
|
674
|
+
return `${singleLine.slice(0, Math.max(1, max - 3))}...`;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function getProviderPreferenceList(provider: string | undefined): string[] | undefined {
|
|
678
|
+
if (!provider) return undefined;
|
|
679
|
+
return PROVIDER_MODEL_PREFERENCES[provider.toLowerCase()];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function selectPreferredModel(models: PiModel[], provider: string | undefined): PiModel | undefined {
|
|
683
|
+
const preferences = getProviderPreferenceList(provider);
|
|
684
|
+
if (!preferences || preferences.length === 0) return undefined;
|
|
685
|
+
const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
|
|
686
|
+
for (const pattern of preferences) {
|
|
687
|
+
const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
|
|
688
|
+
if (match) return match.model;
|
|
689
|
+
}
|
|
690
|
+
return undefined;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function modelText(model: PiModel): string {
|
|
694
|
+
return `${model.provider} ${model.id} ${model.name ?? ""}`.toLowerCase();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function isAnthropicFamily(model: PiModel): boolean {
|
|
698
|
+
return /\b(anthropic|claude|opus|sonnet|haiku|fable)\b/.test(modelText(model));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function isOpenAiFamily(model: PiModel): boolean {
|
|
702
|
+
return /\b(openai|codex|chatgpt|gpt[-_. ]?\d|o[1345](?:\b|-|_))\b/.test(modelText(model));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function selectPreferredAcrossProviders(models: PiModel[]): PiModel | undefined {
|
|
706
|
+
const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
|
|
707
|
+
for (const pattern of CONTRARIAN_MODEL_PREFERENCES) {
|
|
708
|
+
const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
|
|
709
|
+
if (match) return match.model;
|
|
710
|
+
}
|
|
711
|
+
for (const preferences of Object.values(PROVIDER_MODEL_PREFERENCES)) {
|
|
712
|
+
for (const pattern of preferences) {
|
|
713
|
+
const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
|
|
714
|
+
if (match) return match.model;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
721
|
+
const currentScript = process.argv[1];
|
|
722
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
723
|
+
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
724
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
728
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
729
|
+
if (!isGenericRuntime) {
|
|
730
|
+
return { command: process.execPath, args };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return { command: "pi", args };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function withThinking(modelRef: string, thinkingLevel: ThinkingLevel): string {
|
|
737
|
+
if (/(?:^|\/)[^:]+:(off|minimal|low|medium|high|xhigh)$/i.test(modelRef)) return modelRef;
|
|
738
|
+
return `${modelRef}:${thinkingLevel}`;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Keep these local so the extension stays compatible with older pi peer installs that do not export clamp helpers.
|
|
742
|
+
function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
|
|
743
|
+
if (!model.reasoning) return level === "off";
|
|
744
|
+
|
|
745
|
+
const map = model.thinkingLevelMap;
|
|
746
|
+
if (level === "xhigh") {
|
|
747
|
+
return !!map && Object.prototype.hasOwnProperty.call(map, "xhigh") && map.xhigh != null;
|
|
748
|
+
}
|
|
749
|
+
return map?.[level] !== null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function clampThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
|
|
753
|
+
if (isThinkingLevelSupported(model, requested)) return requested;
|
|
754
|
+
|
|
755
|
+
const requestedIndex = THINKING_LEVELS.indexOf(requested);
|
|
756
|
+
for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index++) {
|
|
757
|
+
const level = THINKING_LEVELS[index];
|
|
758
|
+
if (isThinkingLevelSupported(model, level)) return level;
|
|
759
|
+
}
|
|
760
|
+
for (let index = requestedIndex - 1; index >= 0; index--) {
|
|
761
|
+
const level = THINKING_LEVELS[index];
|
|
762
|
+
if (isThinkingLevelSupported(model, level)) return level;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return "off";
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function resolveThinkingLevel(
|
|
769
|
+
model: PiModel | undefined,
|
|
770
|
+
override: ThinkingLevel | undefined,
|
|
771
|
+
): { requested: ThinkingLevel; effective: ThinkingLevel; clamped: boolean } {
|
|
772
|
+
const requested = override ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
|
|
773
|
+
const effective = model ? clampThinkingLevel(model, requested) : requested;
|
|
774
|
+
return { requested, effective, clamped: effective !== requested };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function appendThinkingLevelClampReason(
|
|
778
|
+
reason: string,
|
|
779
|
+
resolution: { requested: ThinkingLevel; effective: ThinkingLevel; clamped: boolean },
|
|
780
|
+
): string {
|
|
781
|
+
if (!resolution.clamped) return reason;
|
|
782
|
+
return `${reason} Requested thinking level ${resolution.requested} was clamped to ${resolution.effective} based on the matched model's capabilities.`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function findAvailableModel(
|
|
786
|
+
ctx: { model?: PiModel; modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> } },
|
|
787
|
+
modelRef: string,
|
|
788
|
+
): Promise<PiModel | undefined> {
|
|
789
|
+
const available = await ctx.modelRegistry.getAvailable();
|
|
790
|
+
const trimmed = modelRef.trim();
|
|
791
|
+
const provider = trimmed.includes("/") ? trimmed.split("/")[0].toLowerCase() : ctx.model?.provider?.toLowerCase();
|
|
792
|
+
const id = trimmed.includes("/") ? trimmed.split("/").slice(1).join("/").toLowerCase() : trimmed.toLowerCase();
|
|
793
|
+
|
|
794
|
+
const exact = available.find(
|
|
795
|
+
(model) => model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
|
|
796
|
+
);
|
|
797
|
+
if (exact) return exact;
|
|
798
|
+
|
|
799
|
+
const partial = available.find(
|
|
800
|
+
(model) =>
|
|
801
|
+
model.id.toLowerCase().includes(id) && (!provider || model.provider.toLowerCase() === provider),
|
|
802
|
+
);
|
|
803
|
+
if (partial) return partial;
|
|
804
|
+
|
|
805
|
+
if (!provider) {
|
|
806
|
+
const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
|
|
807
|
+
if (uniqueById.length === 1) return uniqueById[0];
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function selectContrarianModel(
|
|
814
|
+
ctx: { model?: PiModel; modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> } },
|
|
815
|
+
thinkingLevelOverride?: ThinkingLevel,
|
|
816
|
+
): Promise<{ ok: true; selection: ContrarianSelection } | { ok: false; error: string }> {
|
|
817
|
+
const available = await ctx.modelRegistry.getAvailable();
|
|
818
|
+
if (available.length === 0) {
|
|
819
|
+
return {
|
|
820
|
+
ok: false,
|
|
821
|
+
error: "No authenticated models are available. Log in or configure an API key first.",
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const currentModel = ctx.model;
|
|
826
|
+
const currentProvider = currentModel?.provider;
|
|
827
|
+
const sameProvider = currentProvider ? available.filter((model) => model.provider === currentProvider) : [];
|
|
828
|
+
const sameProviderReasoning = sameProvider.filter((model) => model.reasoning);
|
|
829
|
+
const allReasoning = available.filter((model) => model.reasoning);
|
|
830
|
+
const oppositeFamily = currentModel
|
|
831
|
+
? isAnthropicFamily(currentModel)
|
|
832
|
+
? available.filter(isOpenAiFamily)
|
|
833
|
+
: isOpenAiFamily(currentModel)
|
|
834
|
+
? available.filter(isAnthropicFamily)
|
|
835
|
+
: []
|
|
836
|
+
: [];
|
|
837
|
+
const oppositeProvider = currentProvider ? available.filter((model) => model.provider !== currentProvider) : [];
|
|
838
|
+
const oppositeProviderFamily = currentProvider
|
|
839
|
+
? oppositeFamily.filter((model) => model.provider !== currentProvider)
|
|
840
|
+
: oppositeFamily;
|
|
841
|
+
const sameProviderOppositeFamily = currentProvider
|
|
842
|
+
? oppositeFamily.filter((model) => model.provider === currentProvider)
|
|
843
|
+
: [];
|
|
844
|
+
const oppositeProviderFamilyReasoning = oppositeProviderFamily.filter((model) => model.reasoning);
|
|
845
|
+
const sameProviderOppositeFamilyReasoning = sameProviderOppositeFamily.filter((model) => model.reasoning);
|
|
846
|
+
const oppositeFamilyReasoning = oppositeFamily.filter((model) => model.reasoning);
|
|
847
|
+
const oppositeProviderReasoning = oppositeProvider.filter((model) => model.reasoning);
|
|
848
|
+
|
|
849
|
+
let candidates = oppositeProviderFamilyReasoning;
|
|
850
|
+
let reason = "Selected the top-ranked reasoning model from the opposite model family on a different provider for an independent adversarial pass.";
|
|
851
|
+
let providerForPreferences: string | undefined = undefined;
|
|
852
|
+
|
|
853
|
+
if (candidates.length === 0 && oppositeProviderFamily.length > 0) {
|
|
854
|
+
candidates = oppositeProviderFamily;
|
|
855
|
+
reason = "No reasoning models were available in the opposite model family on a different provider, so the top-ranked opposite-family model on a different provider was used.";
|
|
856
|
+
} else if (candidates.length === 0 && oppositeProviderReasoning.length > 0) {
|
|
857
|
+
candidates = oppositeProviderReasoning;
|
|
858
|
+
reason = "No recognized opposite-family model was available, so the top-ranked reasoning model on a different provider was used.";
|
|
859
|
+
} else if (candidates.length === 0 && oppositeProvider.length > 0) {
|
|
860
|
+
candidates = oppositeProvider;
|
|
861
|
+
reason = "No recognized opposite-family reasoning model was available, so the top-ranked model on a different provider was used.";
|
|
862
|
+
} else if (candidates.length === 0 && sameProviderOppositeFamilyReasoning.length > 0) {
|
|
863
|
+
candidates = sameProviderOppositeFamilyReasoning;
|
|
864
|
+
reason = "No different provider was available, so the top-ranked reasoning model from the opposite model family on the current provider was used.";
|
|
865
|
+
} else if (candidates.length === 0 && sameProviderOppositeFamily.length > 0) {
|
|
866
|
+
candidates = sameProviderOppositeFamily;
|
|
867
|
+
reason = "No different provider or opposite-family reasoning model was available, so the top-ranked opposite-family model on the current provider was used.";
|
|
868
|
+
} else if (candidates.length === 0 && oppositeFamilyReasoning.length > 0) {
|
|
869
|
+
candidates = oppositeFamilyReasoning;
|
|
870
|
+
reason = "Selected the top-ranked reasoning model from the opposite model family for an independent adversarial pass.";
|
|
871
|
+
} else if (candidates.length === 0 && sameProviderReasoning.length > 0) {
|
|
872
|
+
candidates = sameProviderReasoning;
|
|
873
|
+
providerForPreferences = currentProvider;
|
|
874
|
+
reason = "No opposite provider/model family was available, so the top-ranked reasoning model on the current provider was used.";
|
|
875
|
+
} else if (candidates.length === 0 && sameProvider.length > 0) {
|
|
876
|
+
candidates = sameProvider;
|
|
877
|
+
providerForPreferences = currentProvider;
|
|
878
|
+
reason = "No opposite provider/model family or current-provider reasoning model was available, so the top-ranked model on the current provider was used.";
|
|
879
|
+
} else if (candidates.length === 0 && allReasoning.length > 0) {
|
|
880
|
+
candidates = allReasoning;
|
|
881
|
+
reason = "No current model/provider was active, so the top-ranked reasoning model across all available providers was used.";
|
|
882
|
+
} else if (candidates.length === 0) {
|
|
883
|
+
candidates = available;
|
|
884
|
+
reason = "No reasoning models were available, so the top-ranked model across all available providers was used.";
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const preferred = providerForPreferences
|
|
888
|
+
? selectPreferredModel(candidates, providerForPreferences)
|
|
889
|
+
: selectPreferredAcrossProviders(candidates);
|
|
890
|
+
const winner = preferred ?? [...candidates].sort((a, b) => rankModel(b) - rankModel(a))[0];
|
|
891
|
+
const thinking = resolveThinkingLevel(winner, thinkingLevelOverride);
|
|
892
|
+
const selectionReason = appendThinkingLevelClampReason(
|
|
893
|
+
preferred ? `Selected ${winner.id} via the hardcoded preference lists while preferring an opposite provider/model family.` : reason,
|
|
894
|
+
thinking,
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
ok: true,
|
|
899
|
+
selection: {
|
|
900
|
+
modelRef: `${winner.provider}/${winner.id}`,
|
|
901
|
+
provider: winner.provider,
|
|
902
|
+
modelId: winner.id,
|
|
903
|
+
modelName: winner.name,
|
|
904
|
+
thinkingLevel: thinking.effective,
|
|
905
|
+
...(thinking.clamped ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true } : {}),
|
|
906
|
+
autoSelected: true,
|
|
907
|
+
selectionReason,
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function updateContrarianUi(ctx: ExtensionContext, activeRuns: Map<string, ContrarianUiRun>): void {
|
|
913
|
+
if (!ctx.hasUI) return;
|
|
914
|
+
const theme = ctx.ui.theme;
|
|
915
|
+
if (activeRuns.size === 0) {
|
|
916
|
+
ctx.ui.setStatus(CONTRARIAN_STATUS_ID, undefined);
|
|
917
|
+
ctx.ui.setWidget(CONTRARIAN_WIDGET_ID, undefined);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const runs = [...activeRuns.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
922
|
+
const primary = runs[0];
|
|
923
|
+
const activeCount = runs.length;
|
|
924
|
+
const modelText = primary.selection?.modelId ?? "selecting…";
|
|
925
|
+
const thinkingText = primary.selection?.thinkingLevel && primary.selection.thinkingLevel !== "off"
|
|
926
|
+
? ` ${primary.selection.thinkingLevel}`
|
|
927
|
+
: "";
|
|
928
|
+
const elapsed = formatDuration(Date.now() - primary.startedAt);
|
|
929
|
+
const mode = primary.includeBash ? "read-only+bash" : "read-only";
|
|
930
|
+
const status =
|
|
931
|
+
theme.fg("accent", "🔮 contrarian") +
|
|
932
|
+
theme.fg("dim", ` ${modelText}${thinkingText} · ${mode} · ${elapsed}`) +
|
|
933
|
+
(activeCount > 1 ? theme.fg("warning", ` · ${activeCount} active`) : "");
|
|
934
|
+
ctx.ui.setStatus(CONTRARIAN_STATUS_ID, status);
|
|
935
|
+
|
|
936
|
+
const lines = [
|
|
937
|
+
`🔮 Contrarian ${activeCount > 1 ? `(${activeCount} active)` : ""}`.trim(),
|
|
938
|
+
`${primary.selection?.modelRef ?? "selecting model…"} · ${mode} · ${elapsed}`,
|
|
939
|
+
`task: ${shorten(primary.task, 110)}`,
|
|
940
|
+
];
|
|
941
|
+
if (primary.preview && primary.preview.trim()) lines.push(`preview: ${shorten(primary.preview, 110)}`);
|
|
942
|
+
ctx.ui.setWidget(CONTRARIAN_WIDGET_ID, lines, { placement: "belowEditor" });
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function runContrarian(
|
|
946
|
+
selection: ContrarianSelection,
|
|
947
|
+
params: { task: string; includeBash?: boolean; cwd?: string },
|
|
948
|
+
signal: AbortSignal | undefined,
|
|
949
|
+
onUpdate: ((result: { content: Array<{ type: "text"; text: string }>; details: ContrarianDetails }) => void) | undefined,
|
|
950
|
+
defaultCwd: string,
|
|
951
|
+
): Promise<{ ok: true; output: string; details: ContrarianDetails } | { ok: false; error: string; details: ContrarianDetails }> {
|
|
952
|
+
const cwd = params.cwd ?? defaultCwd;
|
|
953
|
+
const includeBash = params.includeBash ?? false;
|
|
954
|
+
const tools = includeBash ? READ_ONLY_PLUS_BASH_TOOLS : READ_ONLY_TOOLS;
|
|
955
|
+
const startedAt = Date.now();
|
|
956
|
+
const usage = createEmptyUsage();
|
|
957
|
+
let currentText = "";
|
|
958
|
+
let finalOutput = "";
|
|
959
|
+
let stderr = "";
|
|
960
|
+
|
|
961
|
+
let lastStopReason: string | undefined;
|
|
962
|
+
let lastErrorMessage: string | undefined;
|
|
963
|
+
|
|
964
|
+
const details: ContrarianDetails = {
|
|
965
|
+
...selection,
|
|
966
|
+
includeBash,
|
|
967
|
+
usage,
|
|
968
|
+
stderr,
|
|
969
|
+
exitCode: 0,
|
|
970
|
+
durationMs: 0,
|
|
971
|
+
cwd,
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const emit = () => {
|
|
975
|
+
details.stderr = stderr;
|
|
976
|
+
details.durationMs = Date.now() - startedAt;
|
|
977
|
+
onUpdate?.({
|
|
978
|
+
content: [{ type: "text", text: currentText || finalOutput || "Consulting contrarian..." }],
|
|
979
|
+
details,
|
|
980
|
+
});
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const modelArg = withThinking(selection.modelRef, selection.thinkingLevel);
|
|
984
|
+
const args = [
|
|
985
|
+
"--mode",
|
|
986
|
+
"json",
|
|
987
|
+
"-p",
|
|
988
|
+
"--no-session",
|
|
989
|
+
"--model",
|
|
990
|
+
modelArg,
|
|
991
|
+
"--tools",
|
|
992
|
+
tools.join(","),
|
|
993
|
+
"--append-system-prompt",
|
|
994
|
+
CONTRARIAN_SYSTEM_PROMPT,
|
|
995
|
+
params.task,
|
|
996
|
+
];
|
|
997
|
+
|
|
998
|
+
const invocation = getPiInvocation(args);
|
|
999
|
+
let wasAborted = false;
|
|
1000
|
+
|
|
1001
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
1002
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
1003
|
+
cwd,
|
|
1004
|
+
shell: false,
|
|
1005
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1006
|
+
});
|
|
1007
|
+
let buffer = "";
|
|
1008
|
+
|
|
1009
|
+
const processLine = (line: string) => {
|
|
1010
|
+
if (!line.trim()) return;
|
|
1011
|
+
let event: any;
|
|
1012
|
+
try {
|
|
1013
|
+
event = JSON.parse(line);
|
|
1014
|
+
} catch {
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (event.type === "message_start" && event.message?.role === "assistant") {
|
|
1019
|
+
currentText = "";
|
|
1020
|
+
emit();
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
|
|
1025
|
+
currentText += event.assistantMessageEvent.delta ?? "";
|
|
1026
|
+
emit();
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
1031
|
+
const text = extractTextFromContent(event.message.content) || currentText;
|
|
1032
|
+
if (text) finalOutput = text;
|
|
1033
|
+
currentText = "";
|
|
1034
|
+
|
|
1035
|
+
const stopReason = event.message.stopReason;
|
|
1036
|
+
lastStopReason = typeof stopReason === "string" ? stopReason : undefined;
|
|
1037
|
+
const errorMessageField = event.message.errorMessage;
|
|
1038
|
+
lastErrorMessage = typeof errorMessageField === "string" ? errorMessageField : undefined;
|
|
1039
|
+
|
|
1040
|
+
const messageUsage = event.message.usage;
|
|
1041
|
+
if (messageUsage) {
|
|
1042
|
+
usage.turns += 1;
|
|
1043
|
+
usage.input += messageUsage.input || 0;
|
|
1044
|
+
usage.output += messageUsage.output || 0;
|
|
1045
|
+
usage.cacheRead += messageUsage.cacheRead || 0;
|
|
1046
|
+
usage.cacheWrite += messageUsage.cacheWrite || 0;
|
|
1047
|
+
usage.cost += messageUsage.cost?.total || 0;
|
|
1048
|
+
usage.contextTokens = messageUsage.totalTokens || usage.contextTokens;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
emit();
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
proc.stdout.on("data", (data) => {
|
|
1056
|
+
buffer += data.toString();
|
|
1057
|
+
const lines = buffer.split("\n");
|
|
1058
|
+
buffer = lines.pop() || "";
|
|
1059
|
+
for (const line of lines) processLine(line);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
proc.stderr.on("data", (data) => {
|
|
1063
|
+
stderr += data.toString();
|
|
1064
|
+
emit();
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
proc.on("close", (code) => {
|
|
1068
|
+
if (buffer.trim()) processLine(buffer);
|
|
1069
|
+
resolve(code ?? 0);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
proc.on("error", (error) => {
|
|
1073
|
+
stderr += `${error instanceof Error ? error.message : String(error)}\n`;
|
|
1074
|
+
resolve(1);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
if (signal) {
|
|
1078
|
+
const killProc = () => {
|
|
1079
|
+
wasAborted = true;
|
|
1080
|
+
proc.kill("SIGTERM");
|
|
1081
|
+
setTimeout(() => {
|
|
1082
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
1083
|
+
}, 5000);
|
|
1084
|
+
};
|
|
1085
|
+
if (signal.aborted) killProc();
|
|
1086
|
+
else signal.addEventListener("abort", killProc, { once: true });
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
details.stderr = stderr.trim();
|
|
1091
|
+
details.exitCode = exitCode;
|
|
1092
|
+
details.durationMs = Date.now() - startedAt;
|
|
1093
|
+
|
|
1094
|
+
if (wasAborted) {
|
|
1095
|
+
return { ok: false, error: "Contrarian was aborted.", details };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// The pi subprocess in `--mode json` does not promote an errored assistant
|
|
1099
|
+
// turn to a non-zero exit code or stderr; the error is only carried on the
|
|
1100
|
+
// streamed assistant message via stopReason/errorMessage. Surface that here
|
|
1101
|
+
// so callers can distinguish transient provider errors from a genuinely
|
|
1102
|
+
// empty response.
|
|
1103
|
+
if (lastStopReason === "error" || lastStopReason === "aborted") {
|
|
1104
|
+
return {
|
|
1105
|
+
ok: false,
|
|
1106
|
+
error: formatContrarianModelError(lastStopReason, lastErrorMessage),
|
|
1107
|
+
details,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (exitCode !== 0) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
error: details.stderr || finalOutput || "Contrarian subprocess failed.",
|
|
1115
|
+
details,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (!finalOutput.trim()) {
|
|
1120
|
+
return {
|
|
1121
|
+
ok: false,
|
|
1122
|
+
error: details.stderr || "Contrarian finished without returning any text.",
|
|
1123
|
+
details,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return {
|
|
1128
|
+
ok: true,
|
|
1129
|
+
output: finalOutput.trim(),
|
|
1130
|
+
details,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function renderCollapsedText(text: string, lineLimit = COLLAPSED_LINE_LIMIT): string {
|
|
1135
|
+
const lines = text.trim().split("\n");
|
|
1136
|
+
if (lines.length <= lineLimit) return lines.join("\n");
|
|
1137
|
+
return [...lines.slice(0, lineLimit), `... (${lines.length - lineLimit} more lines)`].join("\n");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
export default function contrarianExtension(pi: ExtensionAPI) {
|
|
1141
|
+
const activeRuns = new Map<string, ContrarianUiRun>();
|
|
1142
|
+
let preferences: ContrarianPreferences = {};
|
|
1143
|
+
|
|
1144
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1145
|
+
preferences = await readContrarianPreferences();
|
|
1146
|
+
activeRuns.clear();
|
|
1147
|
+
updateContrarianUi(ctx, activeRuns);
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
pi.registerCommand("contrarian", {
|
|
1151
|
+
description: "Configure Contrarian default model and thinking level for future contrarian tool calls",
|
|
1152
|
+
getArgumentCompletions: (prefix) => {
|
|
1153
|
+
const parts = prefix.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1154
|
+
const first = parts[0] ?? "";
|
|
1155
|
+
if (parts.length <= 1) {
|
|
1156
|
+
const commands = ["status", "model", "thinking", "clear"];
|
|
1157
|
+
const matches = commands.filter((command) => command.startsWith(first));
|
|
1158
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
1159
|
+
}
|
|
1160
|
+
if (parts[0] === "thinking") {
|
|
1161
|
+
const query = parts[1] ?? "";
|
|
1162
|
+
const values = ["auto", ...THINKING_LEVELS];
|
|
1163
|
+
const matches = values.filter((value) => value.startsWith(query));
|
|
1164
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
1165
|
+
}
|
|
1166
|
+
if (parts[0] === "clear") {
|
|
1167
|
+
const query = parts[1] ?? "";
|
|
1168
|
+
const values = ["all", "model", "thinking"];
|
|
1169
|
+
const matches = values.filter((value) => value.startsWith(query));
|
|
1170
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
1171
|
+
}
|
|
1172
|
+
return null;
|
|
1173
|
+
},
|
|
1174
|
+
handler: async (args, ctx) => {
|
|
1175
|
+
const raw = args.trim();
|
|
1176
|
+
const tokens = raw ? raw.split(/\s+/) : [];
|
|
1177
|
+
const [command = "status", ...rest] = tokens;
|
|
1178
|
+
const action = command.toLowerCase();
|
|
1179
|
+
const configPath = getContrarianConfigPath();
|
|
1180
|
+
|
|
1181
|
+
const save = async (next: ContrarianPreferences): Promise<string | undefined> => {
|
|
1182
|
+
preferences = next;
|
|
1183
|
+
try {
|
|
1184
|
+
await writeContrarianPreferences(preferences);
|
|
1185
|
+
return undefined;
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1188
|
+
return `Preference changed for this process, but could not save ${configPath}: ${message}`;
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
if (action === "status" || action === "show") {
|
|
1193
|
+
notifyCommand(ctx, formatContrarianPreferences(preferences));
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (action === "model") {
|
|
1198
|
+
const model = rest.join(" ").trim();
|
|
1199
|
+
const normalizedModelAction = model.toLowerCase();
|
|
1200
|
+
if (!model) {
|
|
1201
|
+
notifyCommand(ctx, "Usage: /contrarian model <provider/model|auto>", "warning");
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (normalizedModelAction === "auto" || normalizedModelAction === "clear" || normalizedModelAction === "default") {
|
|
1205
|
+
const warning = await save({ ...preferences, model: undefined });
|
|
1206
|
+
notifyCommand(ctx, `Contrarian default model cleared; future contrarian calls will auto-select. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const parsedModel = parseModelPreference(model);
|
|
1210
|
+
const next = {
|
|
1211
|
+
...preferences,
|
|
1212
|
+
model: parsedModel.model,
|
|
1213
|
+
thinkingLevel: parsedModel.thinkingLevel ?? preferences.thinkingLevel,
|
|
1214
|
+
};
|
|
1215
|
+
const warning = await save(next);
|
|
1216
|
+
notifyCommand(ctx, `Contrarian default model set to ${parsedModel.model}. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (action === "thinking" || action === "think" || action === "thinking-level") {
|
|
1221
|
+
const value = rest[0]?.trim().toLowerCase();
|
|
1222
|
+
if (!value) {
|
|
1223
|
+
notifyCommand(ctx, `Usage: /contrarian thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (value === "auto" || value === "clear" || value === "default") {
|
|
1227
|
+
const warning = await save({ ...preferences, thinkingLevel: undefined });
|
|
1228
|
+
notifyCommand(ctx, `Contrarian default thinking level cleared; future contrarian calls will use built-in defaults. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const thinkingLevel = parseThinkingLevel(value);
|
|
1232
|
+
if (!thinkingLevel) {
|
|
1233
|
+
notifyCommand(ctx, `Usage: /contrarian thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
const warning = await save({ ...preferences, thinkingLevel });
|
|
1237
|
+
notifyCommand(ctx, `Contrarian default thinking level set to ${thinkingLevel}. ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (action === "clear" || action === "reset") {
|
|
1242
|
+
const target = rest[0]?.trim().toLowerCase() || "all";
|
|
1243
|
+
let next: ContrarianPreferences;
|
|
1244
|
+
if (target === "all") next = {};
|
|
1245
|
+
else if (target === "model") next = { ...preferences, model: undefined };
|
|
1246
|
+
else if (target === "thinking" || target === "thinking-level") next = { ...preferences, thinkingLevel: undefined };
|
|
1247
|
+
else {
|
|
1248
|
+
notifyCommand(ctx, "Usage: /contrarian clear [all|model|thinking]", "warning");
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const warning = await save(next);
|
|
1252
|
+
notifyCommand(ctx, `Contrarian defaults cleared (${target}). ${warning ?? formatContrarianPreferences(preferences)}`, warning ? "warning" : "info");
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
notifyCommand(ctx, "Usage: /contrarian status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|auto> | clear [all|model|thinking]", "warning");
|
|
1257
|
+
},
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
pi.registerCommand("contrarian-model", {
|
|
1261
|
+
description: "Show which model the contrarian would use right now",
|
|
1262
|
+
handler: async (_args, ctx) => {
|
|
1263
|
+
const defaultModel = parseModelPreference(preferences.model);
|
|
1264
|
+
if (defaultModel.model) {
|
|
1265
|
+
const matched = await findAvailableModel(ctx, defaultModel.model);
|
|
1266
|
+
const thinking = resolveThinkingLevel(matched, preferences.thinkingLevel ?? defaultModel.thinkingLevel);
|
|
1267
|
+
const modelRef = matched ? `${matched.provider}/${matched.id}` : defaultModel.model;
|
|
1268
|
+
const suffix = matched
|
|
1269
|
+
? appendThinkingLevelClampReason("Configured default contrarian model is active.", thinking)
|
|
1270
|
+
: "Configured default contrarian model is active, but it was not matched against the authenticated model list.";
|
|
1271
|
+
notifyCommand(ctx, `Contrarian: ${modelRef} (${thinking.effective}) — ${suffix}`);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const selectionResult = await selectContrarianModel(ctx, preferences.thinkingLevel);
|
|
1275
|
+
if (!selectionResult.ok) {
|
|
1276
|
+
notifyCommand(ctx, selectionResult.error, "error");
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const { selection } = selectionResult;
|
|
1281
|
+
const message = `Contrarian: ${selection.modelRef} (${selection.thinkingLevel}) — ${selection.selectionReason}`;
|
|
1282
|
+
notifyCommand(ctx, message);
|
|
1283
|
+
},
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
pi.registerTool({
|
|
1287
|
+
name: "contrarian",
|
|
1288
|
+
label: "Contrarian",
|
|
1289
|
+
description:
|
|
1290
|
+
"Consult a separate read-only contrarian subprocess that stress-tests plans, designs, assumptions, bug hypotheses, and conclusions by steelmanning the strongest opposing case.",
|
|
1291
|
+
promptSnippet:
|
|
1292
|
+
"Consult a read-only contrarian that prefers a strong opposite-family/provider model for adversarial analysis.",
|
|
1293
|
+
promptGuidelines: [
|
|
1294
|
+
"Use contrarian when you need to stress-test a proposal, implementation plan, design, assumption, bug hypothesis, review conclusion, or product direction.",
|
|
1295
|
+
"Ask contrarian to steelman the strongest opposing case and separate confirmed objections, plausible concerns, and unresolved unknowns.",
|
|
1296
|
+
"Do not use it for routine low-value work; it is slower than the main agent.",
|
|
1297
|
+
"The contrarian is read-only by default. Set includeBash only when shell-based inspection is genuinely useful.",
|
|
1298
|
+
"The contrarian requests high by default for reasoning models; defaults and explicit thinkingLevel overrides are clamped to the effective model-supported level when the model is matched.",
|
|
1299
|
+
],
|
|
1300
|
+
parameters: ContrarianParams,
|
|
1301
|
+
|
|
1302
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1303
|
+
const explicitModel = parseModelPreference(params.model);
|
|
1304
|
+
const defaultModel = parseModelPreference(preferences.model);
|
|
1305
|
+
const configuredModel = explicitModel.model ?? defaultModel.model;
|
|
1306
|
+
const configuredThinkingLevel =
|
|
1307
|
+
params.thinkingLevel ?? explicitModel.thinkingLevel ?? preferences.thinkingLevel ?? defaultModel.thinkingLevel;
|
|
1308
|
+
const uiRun: ContrarianUiRun = {
|
|
1309
|
+
task: params.task,
|
|
1310
|
+
includeBash: params.includeBash ?? false,
|
|
1311
|
+
startedAt: Date.now(),
|
|
1312
|
+
};
|
|
1313
|
+
activeRuns.set(toolCallId, uiRun);
|
|
1314
|
+
updateContrarianUi(ctx, activeRuns);
|
|
1315
|
+
|
|
1316
|
+
try {
|
|
1317
|
+
let selection: ContrarianSelection;
|
|
1318
|
+
if (configuredModel) {
|
|
1319
|
+
const modelRef = configuredModel;
|
|
1320
|
+
const matched = await findAvailableModel(ctx, modelRef);
|
|
1321
|
+
const provider =
|
|
1322
|
+
matched?.provider ?? (modelRef.includes("/") ? modelRef.split("/")[0] : ctx.model?.provider ?? "unknown");
|
|
1323
|
+
const modelId = matched?.id ?? (modelRef.includes("/") ? modelRef.split("/").slice(1).join("/") : modelRef);
|
|
1324
|
+
const thinking = resolveThinkingLevel(matched, configuredThinkingLevel);
|
|
1325
|
+
const usedToolOverride = !!explicitModel.model;
|
|
1326
|
+
const selectionReason = matched
|
|
1327
|
+
? appendThinkingLevelClampReason(
|
|
1328
|
+
usedToolOverride
|
|
1329
|
+
? "Used the explicit model override provided in the tool call."
|
|
1330
|
+
: "Used the configured default contrarian model.",
|
|
1331
|
+
thinking,
|
|
1332
|
+
)
|
|
1333
|
+
: usedToolOverride
|
|
1334
|
+
? "Used the explicit model override provided in the tool call. The model was not matched against the authenticated model list, so the reasoning level fallback was applied."
|
|
1335
|
+
: "Used the configured default contrarian model. The model was not matched against the authenticated model list, so the reasoning level fallback was applied.";
|
|
1336
|
+
selection = {
|
|
1337
|
+
modelRef: matched ? `${matched.provider}/${matched.id}` : modelRef,
|
|
1338
|
+
provider,
|
|
1339
|
+
modelId,
|
|
1340
|
+
modelName: matched?.name,
|
|
1341
|
+
thinkingLevel: thinking.effective,
|
|
1342
|
+
...(matched && thinking.clamped
|
|
1343
|
+
? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true }
|
|
1344
|
+
: {}),
|
|
1345
|
+
autoSelected: false,
|
|
1346
|
+
selectionReason,
|
|
1347
|
+
};
|
|
1348
|
+
} else {
|
|
1349
|
+
const selectionResult = await selectContrarianModel(ctx, configuredThinkingLevel);
|
|
1350
|
+
if (!selectionResult.ok) {
|
|
1351
|
+
return {
|
|
1352
|
+
content: [{ type: "text", text: selectionResult.error }],
|
|
1353
|
+
details: {
|
|
1354
|
+
modelRef: "",
|
|
1355
|
+
provider: ctx.model?.provider ?? "unknown",
|
|
1356
|
+
modelId: "",
|
|
1357
|
+
modelName: undefined,
|
|
1358
|
+
thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
|
|
1359
|
+
autoSelected: true,
|
|
1360
|
+
selectionReason: selectionResult.error,
|
|
1361
|
+
includeBash: params.includeBash ?? false,
|
|
1362
|
+
usage: createEmptyUsage(),
|
|
1363
|
+
stderr: "",
|
|
1364
|
+
exitCode: 1,
|
|
1365
|
+
durationMs: 0,
|
|
1366
|
+
cwd: params.cwd ?? ctx.cwd,
|
|
1367
|
+
},
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
selection = selectionResult.selection;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
uiRun.selection = selection;
|
|
1374
|
+
updateContrarianUi(ctx, activeRuns);
|
|
1375
|
+
|
|
1376
|
+
const handleUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: ContrarianDetails }) => {
|
|
1377
|
+
uiRun.preview = partial.content[0]?.text ?? uiRun.preview;
|
|
1378
|
+
updateContrarianUi(ctx, activeRuns);
|
|
1379
|
+
onUpdate?.(partial);
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
const result = await runContrarian(selection, params, signal, handleUpdate, ctx.cwd);
|
|
1383
|
+
if (!result.ok) {
|
|
1384
|
+
return {
|
|
1385
|
+
content: [{ type: "text", text: result.error }],
|
|
1386
|
+
details: result.details,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
return {
|
|
1391
|
+
content: [{ type: "text", text: result.output }],
|
|
1392
|
+
details: result.details,
|
|
1393
|
+
};
|
|
1394
|
+
} finally {
|
|
1395
|
+
activeRuns.delete(toolCallId);
|
|
1396
|
+
updateContrarianUi(ctx, activeRuns);
|
|
1397
|
+
}
|
|
1398
|
+
},
|
|
1399
|
+
|
|
1400
|
+
renderCall(args, theme) {
|
|
1401
|
+
const task = args.task.length > 90 ? `${args.task.slice(0, 90)}...` : args.task;
|
|
1402
|
+
const mode = args.includeBash ? "read-only+bash" : "read-only";
|
|
1403
|
+
const override = args.model ? ` ${theme.fg("muted", `[${args.model}]`)}` : "";
|
|
1404
|
+
const thinking = args.thinkingLevel ? ` ${theme.fg("warning", `(${args.thinkingLevel})`)}` : "";
|
|
1405
|
+
return new Text(
|
|
1406
|
+
`${theme.fg("toolTitle", theme.bold("contrarian "))}${theme.fg("accent", mode)}${override}${thinking}\n ${theme.fg("dim", task)}`,
|
|
1407
|
+
0,
|
|
1408
|
+
0,
|
|
1409
|
+
);
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
renderResult(result, { expanded }, theme) {
|
|
1413
|
+
const details = result.details as ContrarianDetails | undefined;
|
|
1414
|
+
const body = result.content[0]?.type === "text" ? result.content[0].text : "(no output)";
|
|
1415
|
+
if (!details) return new Text(body, 0, 0);
|
|
1416
|
+
|
|
1417
|
+
const isError = (details.exitCode ?? 0) !== 0;
|
|
1418
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
1419
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold("contrarian "))}${theme.fg("accent", details.modelRef || "(auto)")}`;
|
|
1420
|
+
const subheader = [
|
|
1421
|
+
details.thinkingLevel !== "off" ? details.thinkingLevel : undefined,
|
|
1422
|
+
details.includeBash ? "read-only+bash" : "read-only",
|
|
1423
|
+
formatDuration(details.durationMs),
|
|
1424
|
+
]
|
|
1425
|
+
.filter(Boolean)
|
|
1426
|
+
.join(" · ");
|
|
1427
|
+
const usage = formatUsage(details.usage);
|
|
1428
|
+
|
|
1429
|
+
if (!expanded) {
|
|
1430
|
+
let text = header;
|
|
1431
|
+
if (subheader) text += `\n${theme.fg("dim", subheader)}`;
|
|
1432
|
+
text += `\n\n${theme.fg("toolOutput", renderCollapsedText(body))}`;
|
|
1433
|
+
if (usage) text += `\n\n${theme.fg("dim", usage)}`;
|
|
1434
|
+
if (isError && details.stderr) text += `\n${theme.fg("error", renderCollapsedText(details.stderr, 4))}`;
|
|
1435
|
+
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
1436
|
+
return new Text(text, 0, 0);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const container = new Container();
|
|
1440
|
+
container.addChild(new Text(header, 0, 0));
|
|
1441
|
+
if (subheader) container.addChild(new Text(theme.fg("dim", subheader), 0, 0));
|
|
1442
|
+
container.addChild(new Spacer(1));
|
|
1443
|
+
container.addChild(new Text(theme.fg("muted", "Selection"), 0, 0));
|
|
1444
|
+
container.addChild(new Text(theme.fg("dim", details.selectionReason), 0, 0));
|
|
1445
|
+
container.addChild(new Spacer(1));
|
|
1446
|
+
container.addChild(new Text(theme.fg("muted", "Output"), 0, 0));
|
|
1447
|
+
container.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme()));
|
|
1448
|
+
if (usage) {
|
|
1449
|
+
container.addChild(new Spacer(1));
|
|
1450
|
+
container.addChild(new Text(theme.fg("muted", "Usage"), 0, 0));
|
|
1451
|
+
container.addChild(new Text(theme.fg("dim", usage), 0, 0));
|
|
1452
|
+
}
|
|
1453
|
+
if (details.stderr) {
|
|
1454
|
+
container.addChild(new Spacer(1));
|
|
1455
|
+
container.addChild(new Text(theme.fg("muted", "stderr"), 0, 0));
|
|
1456
|
+
container.addChild(
|
|
1457
|
+
new Text(isError ? theme.fg("error", details.stderr) : theme.fg("dim", details.stderr), 0, 0),
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
return container;
|
|
1461
|
+
},
|
|
1462
|
+
});
|
|
1463
|
+
}
|