@narumitw/pi-auto-thinking 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # 🧠 pi-auto-thinking — Automatic Thinking Level for Pi
2
+
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-auto-thinking)](https://www.npmjs.com/package/@narumitw/pi-auto-thinking) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
+
5
+ `@narumitw/pi-auto-thinking` is a native [Pi coding agent](https://pi.dev) extension that selects Pi's thinking level for each user task from the active model capability and a deterministic task-difficulty score.
6
+
7
+ Use it when you want simple prompts to stay cheap and fast, while design, debugging, migration, security, or refactor tasks automatically get more reasoning budget.
8
+
9
+ ## ✨ Features
10
+
11
+ - Automatically selects `minimal`, `low`, `medium`, `high`, or `xhigh` before each agent turn.
12
+ - Turns thinking `off` for models that do not advertise reasoning support.
13
+ - Respects model `thinkingLevelMap` entries that mark levels unsupported.
14
+ - Uses conservative defaults: enabled, `minLevel: "minimal"`, `maxLevel: "high"`.
15
+ - Avoids extra LLM calls; task difficulty is scored with local deterministic heuristics.
16
+ - Detects likely manual thinking-level changes and pauses automation for a few turns.
17
+ - Adds `/auto-thinking` commands for status, enable/disable, and explaining the last decision.
18
+ - Shows a compact statusline item after decisions without notifying every turn.
19
+
20
+ ## 📦 Install
21
+
22
+ ```bash
23
+ pi install npm:@narumitw/pi-auto-thinking
24
+ ```
25
+
26
+ Try without installing permanently:
27
+
28
+ ```bash
29
+ pi -e npm:@narumitw/pi-auto-thinking
30
+ ```
31
+
32
+ Try this package locally from the repository root:
33
+
34
+ ```bash
35
+ pi -e ./extensions/pi-auto-thinking
36
+ ```
37
+
38
+ ## 🚀 Usage
39
+
40
+ ```text
41
+ /auto-thinking status
42
+ /auto-thinking on
43
+ /auto-thinking off
44
+ /auto-thinking explain
45
+ ```
46
+
47
+ - `status` shows whether automation is enabled, the loaded config path, bounds, and warnings.
48
+ - `on` enables automatic thinking selection for subsequent user prompts.
49
+ - `off` disables automation and clears the extension statusline item.
50
+ - `explain` shows the score, selected level, and matched signals from the last decision.
51
+
52
+ ## 🧮 How scoring works
53
+
54
+ The extension scores each prompt locally before Pi starts the agent loop. The base level mapping is:
55
+
56
+ | Score | Base thinking level |
57
+ | ---: | --- |
58
+ | `<= 0` | `minimal` |
59
+ | `1..2` | `low` |
60
+ | `3..5` | `medium` |
61
+ | `6..8` | `high` |
62
+ | `>= 9` | `xhigh` |
63
+
64
+ Default weights:
65
+
66
+ | Signal | Weight |
67
+ | --- | ---: |
68
+ | Quick, brief, concise, simple, or equivalent Chinese wording | `-2` |
69
+ | Simple explanation, translation, or summary | `-1` |
70
+ | Implementation, editing, or code-change request | `+2` |
71
+ | Debugging, exception, stack trace, failure, or broken behavior | `+2` |
72
+ | Tests, lint, typecheck, TypeScript, CI, or similar | `+1` |
73
+ | Design, architecture, plan, proposal, or tradeoff work | `+3` |
74
+ | Migration, refactor, compatibility, or breaking-change work | `+3` |
75
+ | Security, auth, secrets, concurrency, transaction, rollback, or data-loss topic | `+3` |
76
+ | Explicit deep reasoning request such as “think hard”, “carefully”, “深入”, or “仔細” | `+3` |
77
+ | Code block included | `+1` |
78
+ | One file path mentioned | `+1` |
79
+ | Three or more file paths mentioned | `+2` |
80
+ | Long prompt | `+1` |
81
+ | Image input attached | `+1` |
82
+
83
+ After scoring, config bounds and model support are applied. For example, with the default `maxLevel: "high"`, a score that maps to `xhigh` is capped at `high`.
84
+
85
+ ## ⚙️ Configuration
86
+
87
+ Optional config file:
88
+
89
+ ```text
90
+ $PI_CODING_AGENT_DIR/pi-auto-thinking.json
91
+ ```
92
+
93
+ When `PI_CODING_AGENT_DIR` is unset, the extension reads:
94
+
95
+ ```text
96
+ ~/.pi/agent/pi-auto-thinking.json
97
+ ```
98
+
99
+ Example:
100
+
101
+ ```json
102
+ {
103
+ "enabled": true,
104
+ "minLevel": "minimal",
105
+ "maxLevel": "high",
106
+ "respectManualTurns": 3,
107
+ "modelOverrides": {
108
+ "anthropic/claude-haiku-4-5": {
109
+ "maxLevel": "medium"
110
+ },
111
+ "anthropic/claude-opus-4-5": {
112
+ "maxLevel": "xhigh"
113
+ },
114
+ "local/small-fast-model": {
115
+ "enabled": false
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ Supported levels are:
122
+
123
+ ```text
124
+ off, minimal, low, medium, high, xhigh
125
+ ```
126
+
127
+ `respectManualTurns` must be an integer from `0` to `20`. When a thinking level changes outside this extension, automation pauses for that many future user prompts. Pi's event does not expose the change source, so this detection is intentionally conservative.
128
+
129
+ ## 🤖 Model capability behavior
130
+
131
+ - If `ctx.model` is unavailable, the extension selects `off`.
132
+ - If `ctx.model.reasoning` is false, the extension selects `off`.
133
+ - If `ctx.model.thinkingLevelMap` marks a level as `null`, the extension skips that level.
134
+ - Pi still performs its own final clamp when `pi.setThinkingLevel()` is called.
135
+
136
+ ## 💸 Cost and latency caveats
137
+
138
+ Higher thinking levels can increase latency and token usage depending on the provider. The default maximum is `high`, not `xhigh`, to avoid unexpectedly expensive turns. Use `/auto-thinking explain` to inspect why a level was selected, or `/auto-thinking off` to disable automation for the session.
139
+
140
+ ## 🗂️ Package layout
141
+
142
+ ```txt
143
+ extensions/pi-auto-thinking/
144
+ ├── src/
145
+ │ └── auto-thinking.ts
146
+ ├── README.md
147
+ ├── LICENSE
148
+ ├── tsconfig.json
149
+ └── package.json
150
+ ```
151
+
152
+ The package exposes its Pi extension through `package.json`:
153
+
154
+ ```json
155
+ {
156
+ "pi": {
157
+ "extensions": ["./src/auto-thinking.ts"]
158
+ }
159
+ }
160
+ ```
161
+
162
+ ## 🔎 Keywords
163
+
164
+ Pi extension, Pi coding agent, automatic thinking, reasoning level, task difficulty, TypeScript Pi package, npm Pi extension.
165
+
166
+ ## 📄 License
167
+
168
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@narumitw/pi-auto-thinking",
3
+ "version": "0.1.12",
4
+ "description": "Pi extension that automatically adjusts thinking level from model capability and task difficulty.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "thinking",
13
+ "reasoning",
14
+ "automation"
15
+ ],
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/auto-thinking.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "check": "biome check . && npm run typecheck",
28
+ "format": "biome check --write .",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "devDependencies": {
32
+ "@biomejs/biome": "2.4.14",
33
+ "@earendil-works/pi-coding-agent": "0.74.0",
34
+ "@types/node": "25.6.0",
35
+ "typescript": "6.0.3"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/narumiruna/pi-extensions",
40
+ "directory": "extensions/pi-auto-thinking"
41
+ }
42
+ }
@@ -0,0 +1,666 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import process from "node:process";
4
+ import type {
5
+ ExtensionAPI,
6
+ ExtensionCommandContext,
7
+ ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+
10
+ type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
11
+ type Model = NonNullable<ExtensionContext["model"]>;
12
+
13
+ type Config = {
14
+ enabled: boolean;
15
+ minLevel: ThinkingLevel;
16
+ maxLevel: ThinkingLevel;
17
+ respectManualTurns: number;
18
+ modelOverrides: Record<string, ModelOverride>;
19
+ };
20
+
21
+ type ModelOverride = {
22
+ enabled?: boolean;
23
+ minLevel?: ThinkingLevel;
24
+ maxLevel?: ThinkingLevel;
25
+ };
26
+
27
+ type LoadedConfig = {
28
+ config: Config;
29
+ path: string;
30
+ warnings: string[];
31
+ };
32
+
33
+ type ScoreSignal = {
34
+ label: string;
35
+ weight: number;
36
+ };
37
+
38
+ type PromptScore = {
39
+ score: number;
40
+ baseLevel: ThinkingLevel;
41
+ signals: ScoreSignal[];
42
+ };
43
+
44
+ type ThinkingDecision = {
45
+ kind: "selected";
46
+ level: ThinkingLevel;
47
+ baseLevel: ThinkingLevel;
48
+ score: number;
49
+ reasons: string[];
50
+ modelKey?: string;
51
+ };
52
+
53
+ type SkippedDecision = {
54
+ kind: "skipped";
55
+ reason: string;
56
+ reasons: string[];
57
+ };
58
+
59
+ type LastDecision = ThinkingDecision | SkippedDecision;
60
+
61
+ type RuntimeState = {
62
+ config: Config;
63
+ configPath: string;
64
+ configWarnings: string[];
65
+ enabled: boolean;
66
+ lastDecision?: LastDecision;
67
+ manualSuppressionTurnsRemaining: number;
68
+ pendingManualSuppression: boolean;
69
+ expectedThinkingLevel?: ThinkingLevel;
70
+ };
71
+
72
+ const STATUS_KEY = "auto-thinking";
73
+ const COMMAND_NAME = "auto-thinking";
74
+ const CONFIG_FILE_NAME = "pi-auto-thinking.json";
75
+ const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly ThinkingLevel[];
76
+ const MAX_RESPECT_MANUAL_TURNS = 20;
77
+
78
+ const DEFAULT_CONFIG: Config = {
79
+ enabled: true,
80
+ minLevel: "minimal",
81
+ maxLevel: "high",
82
+ respectManualTurns: 3,
83
+ modelOverrides: {},
84
+ };
85
+
86
+ const RULES: Array<{
87
+ label: string;
88
+ weight: number;
89
+ patterns: RegExp[];
90
+ }> = [
91
+ {
92
+ label: "quick or brief response requested",
93
+ weight: -2,
94
+ patterns: [
95
+ /\b(quick|brief|short|concise|fast|simple|tl;dr)\b/i,
96
+ /(快速|簡短|簡潔|簡單|摘要|不用詳細)/,
97
+ ],
98
+ },
99
+ {
100
+ label: "simple explanation or translation",
101
+ weight: -1,
102
+ patterns: [
103
+ /\b(explain|translate|summari[sz]e|what is|how do i)\b/i,
104
+ /(解釋|翻譯|說明|什麼是|怎麼)/,
105
+ ],
106
+ },
107
+ {
108
+ label: "implementation or editing task",
109
+ weight: 2,
110
+ patterns: [
111
+ /\b(implement|add|update|change|modify|fix|create|write|build)\b/i,
112
+ /(實作|新增|更新|修改|修正|建立|撰寫|完成)/,
113
+ ],
114
+ },
115
+ {
116
+ label: "debugging or failure investigation",
117
+ weight: 2,
118
+ patterns: [
119
+ /\b(bug|debug|error|exception|stack trace|traceback|failing|fails|failure|broken)\b/i,
120
+ /(錯誤|除錯|失敗|例外|堆疊|壞掉|修 bug)/i,
121
+ ],
122
+ },
123
+ {
124
+ label: "tests, lint, or typechecking mentioned",
125
+ weight: 1,
126
+ patterns: [
127
+ /\b(test|tests|testing|lint|typecheck|typescript|tsc|biome|ruff|pytest|ci)\b/i,
128
+ /(測試|型別|檢查|CI|lint)/i,
129
+ ],
130
+ },
131
+ {
132
+ label: "design, architecture, or planning task",
133
+ weight: 3,
134
+ patterns: [
135
+ /\b(design|architecture|architect|plan|roadmap|proposal|trade-?off|review the plan)\b/i,
136
+ /(設計|架構|計畫|規劃|取捨|方案|審查計畫)/,
137
+ ],
138
+ },
139
+ {
140
+ label: "migration, refactor, or compatibility work",
141
+ weight: 3,
142
+ patterns: [
143
+ /\b(migrat(e|ion)|refactor|restructure|compatibility|backwards compatible|breaking change)\b/i,
144
+ /(遷移|重構|相容|破壞性變更)/,
145
+ ],
146
+ },
147
+ {
148
+ label: "security, concurrency, or data-risk topic",
149
+ weight: 3,
150
+ patterns: [
151
+ /\b(security|permission|auth|token|secret|credential|race|concurrency|transaction|data loss|rollback|recovery)\b/i,
152
+ /(安全|權限|認證|密鑰|憑證|併發|交易|資料遺失|回復|復原)/,
153
+ ],
154
+ },
155
+ {
156
+ label: "deep reasoning explicitly requested",
157
+ weight: 3,
158
+ patterns: [
159
+ /\b(think hard|think deeply|deep dive|carefully|thorough|comprehensive|ultrathink)\b/i,
160
+ /(仔細|深入|完整|全面|深度思考|認真想)/,
161
+ ],
162
+ },
163
+ ];
164
+
165
+ export default function autoThinking(pi: ExtensionAPI) {
166
+ const loaded = loadConfig();
167
+ const runtime: RuntimeState = {
168
+ config: loaded.config,
169
+ configPath: loaded.path,
170
+ configWarnings: loaded.warnings,
171
+ enabled: loaded.config.enabled,
172
+ manualSuppressionTurnsRemaining: 0,
173
+ pendingManualSuppression: false,
174
+ };
175
+
176
+ pi.on("session_start", (_event, ctx) => {
177
+ const next = loadConfig();
178
+ runtime.config = next.config;
179
+ runtime.configPath = next.path;
180
+ runtime.configWarnings = next.warnings;
181
+ runtime.enabled = next.config.enabled;
182
+ runtime.manualSuppressionTurnsRemaining = 0;
183
+ runtime.pendingManualSuppression = false;
184
+ runtime.expectedThinkingLevel = undefined;
185
+ runtime.lastDecision = undefined;
186
+ ctx.ui.setStatus(STATUS_KEY, undefined);
187
+ for (const warning of next.warnings) ctx.ui.notify(warning, "warning");
188
+ });
189
+
190
+ pi.on("session_shutdown", (_event, ctx) => {
191
+ ctx.ui.setStatus(STATUS_KEY, undefined);
192
+ });
193
+
194
+ pi.on("before_agent_start", (event, ctx) => {
195
+ if (!runtime.enabled) {
196
+ const decision = skippedDecision("Auto thinking is disabled.");
197
+ runtime.lastDecision = decision;
198
+ ctx.ui.setStatus(STATUS_KEY, undefined);
199
+ return;
200
+ }
201
+
202
+ if (runtime.manualSuppressionTurnsRemaining > 0) {
203
+ runtime.pendingManualSuppression = false;
204
+ const skippedTurns = runtime.manualSuppressionTurnsRemaining;
205
+ runtime.manualSuppressionTurnsRemaining -= 1;
206
+ const decision = skippedDecision(
207
+ `Manual thinking change detected; automation paused for ${skippedTurns} more turn${skippedTurns === 1 ? "" : "s"}.`,
208
+ );
209
+ runtime.lastDecision = decision;
210
+ ctx.ui.setStatus(
211
+ STATUS_KEY,
212
+ `auto-thinking paused ${runtime.manualSuppressionTurnsRemaining}`,
213
+ );
214
+ return;
215
+ }
216
+
217
+ const decision = decideThinkingLevel({
218
+ config: runtime.config,
219
+ hasImages: (event.images?.length ?? 0) > 0,
220
+ model: ctx.model,
221
+ prompt: event.prompt,
222
+ });
223
+ runtime.lastDecision = decision;
224
+
225
+ const currentLevel = pi.getThinkingLevel();
226
+ if (currentLevel !== decision.level) {
227
+ runtime.expectedThinkingLevel = decision.level;
228
+ pi.setThinkingLevel(decision.level);
229
+ setTimeout(() => {
230
+ if (runtime.expectedThinkingLevel === decision.level) {
231
+ runtime.expectedThinkingLevel = undefined;
232
+ }
233
+ }, 0).unref?.();
234
+ }
235
+
236
+ ctx.ui.setStatus(STATUS_KEY, formatStatus(decision));
237
+ });
238
+
239
+ pi.on("thinking_level_select", (event, ctx) => {
240
+ if (runtime.expectedThinkingLevel === event.level) {
241
+ runtime.expectedThinkingLevel = undefined;
242
+ return;
243
+ }
244
+
245
+ if (!runtime.enabled || runtime.config.respectManualTurns <= 0) return;
246
+
247
+ runtime.manualSuppressionTurnsRemaining = runtime.config.respectManualTurns;
248
+ runtime.pendingManualSuppression = true;
249
+ runtime.lastDecision = skippedDecision(
250
+ `Thinking level changed outside auto-thinking (${event.previousLevel} -> ${event.level}); automation will pause for ${runtime.manualSuppressionTurnsRemaining} turn${runtime.manualSuppressionTurnsRemaining === 1 ? "" : "s"}.`,
251
+ );
252
+ ctx.ui.setStatus(STATUS_KEY, `auto-thinking paused ${runtime.manualSuppressionTurnsRemaining}`);
253
+ });
254
+
255
+ pi.on("model_select", (_event, ctx) => {
256
+ if (runtime.pendingManualSuppression) {
257
+ runtime.manualSuppressionTurnsRemaining = 0;
258
+ runtime.pendingManualSuppression = false;
259
+ runtime.lastDecision = undefined;
260
+ ctx.ui.setStatus(STATUS_KEY, undefined);
261
+ }
262
+ if (!runtime.enabled) {
263
+ ctx.ui.setStatus(STATUS_KEY, undefined);
264
+ return;
265
+ }
266
+ if (runtime.lastDecision) ctx.ui.setStatus(STATUS_KEY, formatLastDecision(runtime.lastDecision));
267
+ });
268
+
269
+ pi.registerCommand(COMMAND_NAME, {
270
+ description: "Configure automatic thinking level selection",
271
+ handler: async (args, ctx) => {
272
+ handleCommand(args, runtime, ctx);
273
+ },
274
+ });
275
+ }
276
+
277
+ function loadConfig(): LoadedConfig {
278
+ const configPath = getConfigPath();
279
+ if (!existsSync(configPath)) return { config: { ...DEFAULT_CONFIG }, path: configPath, warnings: [] };
280
+
281
+ try {
282
+ const raw = readFileSync(configPath, "utf8");
283
+ const parsed = JSON.parse(raw) as unknown;
284
+ const warnings: string[] = [];
285
+ const config = parseConfig(parsed, warnings);
286
+ return { config, path: configPath, warnings };
287
+ } catch (error: unknown) {
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ return {
290
+ config: { ...DEFAULT_CONFIG },
291
+ path: configPath,
292
+ warnings: [
293
+ `Failed to load ${CONFIG_FILE_NAME}; using defaults. ${message}`,
294
+ ],
295
+ };
296
+ }
297
+ }
298
+
299
+ function getConfigPath(): string {
300
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? ".", ".pi", "agent");
301
+ return join(agentDir, CONFIG_FILE_NAME);
302
+ }
303
+
304
+ function parseConfig(value: unknown, warnings: string[]): Config {
305
+ if (!isRecord(value)) {
306
+ warnings.push(`${CONFIG_FILE_NAME} must contain a JSON object; using defaults.`);
307
+ return { ...DEFAULT_CONFIG };
308
+ }
309
+
310
+ return {
311
+ enabled: readBoolean(value.enabled, DEFAULT_CONFIG.enabled, "enabled", warnings),
312
+ minLevel: readThinkingLevel(value.minLevel, DEFAULT_CONFIG.minLevel, "minLevel", warnings),
313
+ maxLevel: readThinkingLevel(value.maxLevel, DEFAULT_CONFIG.maxLevel, "maxLevel", warnings),
314
+ respectManualTurns: readInteger(
315
+ value.respectManualTurns,
316
+ DEFAULT_CONFIG.respectManualTurns,
317
+ "respectManualTurns",
318
+ warnings,
319
+ ),
320
+ modelOverrides: readModelOverrides(value.modelOverrides, warnings),
321
+ };
322
+ }
323
+
324
+ function readModelOverrides(value: unknown, warnings: string[]): Record<string, ModelOverride> {
325
+ if (value === undefined) return {};
326
+ if (!isRecord(value)) {
327
+ warnings.push("modelOverrides must be an object; ignoring it.");
328
+ return {};
329
+ }
330
+
331
+ const overrides: Record<string, ModelOverride> = {};
332
+ for (const [modelKey, overrideValue] of Object.entries(value)) {
333
+ if (!isRecord(overrideValue)) {
334
+ warnings.push(`modelOverrides.${modelKey} must be an object; ignoring it.`);
335
+ continue;
336
+ }
337
+ const override: ModelOverride = {};
338
+ if (overrideValue.enabled !== undefined) {
339
+ override.enabled = readBoolean(
340
+ overrideValue.enabled,
341
+ DEFAULT_CONFIG.enabled,
342
+ `modelOverrides.${modelKey}.enabled`,
343
+ warnings,
344
+ );
345
+ }
346
+ if (overrideValue.minLevel !== undefined) {
347
+ override.minLevel = readThinkingLevel(
348
+ overrideValue.minLevel,
349
+ DEFAULT_CONFIG.minLevel,
350
+ `modelOverrides.${modelKey}.minLevel`,
351
+ warnings,
352
+ );
353
+ }
354
+ if (overrideValue.maxLevel !== undefined) {
355
+ override.maxLevel = readThinkingLevel(
356
+ overrideValue.maxLevel,
357
+ DEFAULT_CONFIG.maxLevel,
358
+ `modelOverrides.${modelKey}.maxLevel`,
359
+ warnings,
360
+ );
361
+ }
362
+ overrides[modelKey] = override;
363
+ }
364
+ return overrides;
365
+ }
366
+
367
+ function readBoolean(value: unknown, fallback: boolean, field: string, warnings: string[]): boolean {
368
+ if (value === undefined) return fallback;
369
+ if (typeof value === "boolean") return value;
370
+ warnings.push(`${field} must be a boolean; using ${fallback}.`);
371
+ return fallback;
372
+ }
373
+
374
+ function readInteger(value: unknown, fallback: number, field: string, warnings: string[]): number {
375
+ if (value === undefined) return fallback;
376
+ if (typeof value !== "number" || !Number.isInteger(value)) {
377
+ warnings.push(`${field} must be an integer; using ${fallback}.`);
378
+ return fallback;
379
+ }
380
+ if (value < 0 || value > MAX_RESPECT_MANUAL_TURNS) {
381
+ warnings.push(`${field} must be between 0 and ${MAX_RESPECT_MANUAL_TURNS}; using ${fallback}.`);
382
+ return fallback;
383
+ }
384
+ return value;
385
+ }
386
+
387
+ function readThinkingLevel(
388
+ value: unknown,
389
+ fallback: ThinkingLevel,
390
+ field: string,
391
+ warnings: string[],
392
+ ): ThinkingLevel {
393
+ if (value === undefined) return fallback;
394
+ if (typeof value === "string" && isThinkingLevel(value)) return value;
395
+ warnings.push(`${field} must be one of ${LEVELS.join(", ")}; using ${fallback}.`);
396
+ return fallback;
397
+ }
398
+
399
+ function decideThinkingLevel(input: {
400
+ config: Config;
401
+ hasImages: boolean;
402
+ model: ExtensionContext["model"];
403
+ prompt: string;
404
+ }): ThinkingDecision {
405
+ const modelKey = input.model ? getModelKey(input.model) : undefined;
406
+ const override = modelKey ? input.config.modelOverrides[modelKey] : undefined;
407
+ const enabled = override?.enabled ?? input.config.enabled;
408
+ if (!enabled) {
409
+ return {
410
+ kind: "selected",
411
+ level: "off",
412
+ baseLevel: "off",
413
+ score: 0,
414
+ reasons: [`Automation disabled for ${modelKey ?? "current model"}.`],
415
+ modelKey,
416
+ };
417
+ }
418
+
419
+ const minLevel = override?.minLevel ?? input.config.minLevel;
420
+ const maxLevel = override?.maxLevel ?? input.config.maxLevel;
421
+ const promptScore = scorePrompt(input.prompt, input.hasImages);
422
+ const boundedLevel = clampLevel(promptScore.baseLevel, minLevel, maxLevel);
423
+ const level = selectSupportedLevel(input.model, boundedLevel, minLevel, maxLevel);
424
+ const reasons = buildDecisionReasons({
425
+ boundedLevel,
426
+ level,
427
+ maxLevel,
428
+ minLevel,
429
+ model: input.model,
430
+ modelKey,
431
+ promptScore,
432
+ });
433
+
434
+ return {
435
+ kind: "selected",
436
+ level,
437
+ baseLevel: promptScore.baseLevel,
438
+ score: promptScore.score,
439
+ reasons,
440
+ modelKey,
441
+ };
442
+ }
443
+
444
+ function scorePrompt(prompt: string, hasImages: boolean): PromptScore {
445
+ const signals: ScoreSignal[] = [];
446
+ const normalizedPrompt = prompt.trim();
447
+
448
+ for (const rule of RULES) {
449
+ if (rule.patterns.some((pattern) => pattern.test(normalizedPrompt))) {
450
+ signals.push({ label: rule.label, weight: rule.weight });
451
+ }
452
+ }
453
+
454
+ const codeBlockCount = countMatches(normalizedPrompt, /```/g) / 2;
455
+ if (codeBlockCount >= 1) signals.push({ label: "code block included", weight: 1 });
456
+
457
+ const filePathCount = countFilePaths(normalizedPrompt);
458
+ if (filePathCount >= 3) signals.push({ label: "multiple file paths mentioned", weight: 2 });
459
+ else if (filePathCount >= 1) signals.push({ label: "file path mentioned", weight: 1 });
460
+
461
+ const longPromptWords = normalizedPrompt.split(/\s+/).filter(Boolean).length;
462
+ if (longPromptWords >= 180) signals.push({ label: "long prompt", weight: 1 });
463
+
464
+ if (hasImages) signals.push({ label: "image input attached", weight: 1 });
465
+
466
+ const score = signals.reduce((total, signal) => total + signal.weight, 0);
467
+ return { score, baseLevel: levelForScore(score), signals };
468
+ }
469
+
470
+ function levelForScore(score: number): ThinkingLevel {
471
+ if (score <= 0) return "minimal";
472
+ if (score <= 2) return "low";
473
+ if (score <= 5) return "medium";
474
+ if (score <= 8) return "high";
475
+ return "xhigh";
476
+ }
477
+
478
+ function selectSupportedLevel(
479
+ model: ExtensionContext["model"],
480
+ candidate: ThinkingLevel,
481
+ minLevel: ThinkingLevel,
482
+ maxLevel: ThinkingLevel,
483
+ ): ThinkingLevel {
484
+ if (!model) return "off";
485
+ if (!model.reasoning) return "off";
486
+
487
+ const supported = LEVELS.filter((level) => isModelLevelSupported(model, level));
488
+ const boundedSupported = supported.filter(
489
+ (level) => compareLevels(level, minLevel) >= 0 && compareLevels(level, maxLevel) <= 0,
490
+ );
491
+ const candidates = boundedSupported.length > 0 ? boundedSupported : supported;
492
+ if (candidates.length === 0) return "off";
493
+ if (candidates.includes(candidate)) return candidate;
494
+
495
+ const lowerOrEqual = candidates
496
+ .filter((level) => compareLevels(level, candidate) <= 0)
497
+ .sort((a, b) => compareLevels(b, a));
498
+ if (lowerOrEqual[0]) return lowerOrEqual[0];
499
+
500
+ return [...candidates].sort(compareLevels)[0] ?? "off";
501
+ }
502
+
503
+ function isModelLevelSupported(model: Model, level: ThinkingLevel): boolean {
504
+ if (!model.reasoning) return level === "off";
505
+ return model.thinkingLevelMap?.[level] !== null;
506
+ }
507
+
508
+ function clampLevel(level: ThinkingLevel, minLevel: ThinkingLevel, maxLevel: ThinkingLevel): ThinkingLevel {
509
+ const low = Math.min(levelIndex(minLevel), levelIndex(maxLevel));
510
+ const high = Math.max(levelIndex(minLevel), levelIndex(maxLevel));
511
+ return LEVELS[Math.min(Math.max(levelIndex(level), low), high)];
512
+ }
513
+
514
+ function compareLevels(a: ThinkingLevel, b: ThinkingLevel): number {
515
+ return levelIndex(a) - levelIndex(b);
516
+ }
517
+
518
+ function levelIndex(level: ThinkingLevel): number {
519
+ return LEVELS.indexOf(level);
520
+ }
521
+
522
+ function isThinkingLevel(value: string): value is ThinkingLevel {
523
+ return LEVELS.includes(value as ThinkingLevel);
524
+ }
525
+
526
+ function getModelKey(model: Model): string {
527
+ return `${model.provider}/${model.id}`;
528
+ }
529
+
530
+ function buildDecisionReasons(input: {
531
+ boundedLevel: ThinkingLevel;
532
+ level: ThinkingLevel;
533
+ maxLevel: ThinkingLevel;
534
+ minLevel: ThinkingLevel;
535
+ model: ExtensionContext["model"];
536
+ modelKey?: string;
537
+ promptScore: PromptScore;
538
+ }): string[] {
539
+ const reasons: string[] = [];
540
+ if (!input.model) {
541
+ reasons.push("No active model is available, so auto-thinking selected off.");
542
+ return reasons;
543
+ }
544
+
545
+ reasons.push(`Model: ${input.modelKey}.`);
546
+ if (!input.model.reasoning) {
547
+ reasons.push("The active model does not advertise reasoning support, so thinking is off.");
548
+ return reasons;
549
+ }
550
+
551
+ reasons.push(`Task score ${input.promptScore.score} mapped to ${input.promptScore.baseLevel}.`);
552
+ for (const signal of input.promptScore.signals) {
553
+ const sign = signal.weight > 0 ? "+" : "";
554
+ reasons.push(`${sign}${signal.weight}: ${signal.label}.`);
555
+ }
556
+ if (input.promptScore.signals.length === 0) reasons.push("No complexity signals matched.");
557
+
558
+ if (input.boundedLevel !== input.promptScore.baseLevel) {
559
+ reasons.push(
560
+ `Config bounds ${input.minLevel}..${input.maxLevel} changed ${input.promptScore.baseLevel} to ${input.boundedLevel}.`,
561
+ );
562
+ } else {
563
+ reasons.push(`Config bounds ${input.minLevel}..${input.maxLevel} kept ${input.boundedLevel}.`);
564
+ }
565
+
566
+ if (input.level !== input.boundedLevel) {
567
+ reasons.push(`Model thinkingLevelMap changed ${input.boundedLevel} to ${input.level}.`);
568
+ }
569
+
570
+ return reasons;
571
+ }
572
+
573
+ function skippedDecision(reason: string): SkippedDecision {
574
+ return { kind: "skipped", reason, reasons: [reason] };
575
+ }
576
+
577
+ function handleCommand(args: string, runtime: RuntimeState, ctx: ExtensionCommandContext): void {
578
+ const command = args.trim().toLowerCase();
579
+ switch (command || "status") {
580
+ case "on":
581
+ runtime.enabled = true;
582
+ runtime.manualSuppressionTurnsRemaining = 0;
583
+ runtime.pendingManualSuppression = false;
584
+ ctx.ui.notify("Auto thinking enabled.", "info");
585
+ return;
586
+ case "off":
587
+ runtime.enabled = false;
588
+ runtime.manualSuppressionTurnsRemaining = 0;
589
+ runtime.pendingManualSuppression = false;
590
+ ctx.ui.setStatus(STATUS_KEY, undefined);
591
+ ctx.ui.notify("Auto thinking disabled.", "info");
592
+ return;
593
+ case "status":
594
+ ctx.ui.notify(formatRuntimeStatus(runtime), "info");
595
+ return;
596
+ case "explain":
597
+ ctx.ui.notify(formatExplanation(runtime.lastDecision), "info");
598
+ return;
599
+ case "help":
600
+ ctx.ui.notify(formatHelp(), "info");
601
+ return;
602
+ default:
603
+ ctx.ui.notify(`Unknown /${COMMAND_NAME} command: ${args.trim()}\n\n${formatHelp()}`, "warning");
604
+ }
605
+ }
606
+
607
+ function formatRuntimeStatus(runtime: RuntimeState): string {
608
+ return [
609
+ `Auto thinking: ${runtime.enabled ? "on" : "off"}`,
610
+ `Config: ${runtime.configPath}`,
611
+ `Bounds: ${runtime.config.minLevel}..${runtime.config.maxLevel}`,
612
+ `Respect manual turns: ${runtime.config.respectManualTurns}`,
613
+ `Manual suppression remaining: ${runtime.manualSuppressionTurnsRemaining}`,
614
+ runtime.configWarnings.length > 0
615
+ ? `Config warnings:\n${runtime.configWarnings.map((warning) => `- ${warning}`).join("\n")}`
616
+ : "Config warnings: none",
617
+ ].join("\n");
618
+ }
619
+
620
+ function formatExplanation(decision: LastDecision | undefined): string {
621
+ if (!decision) return "No auto-thinking decision has been made yet.";
622
+ if (decision.kind === "skipped") return decision.reasons.join("\n");
623
+ return [
624
+ `Selected: ${decision.level}`,
625
+ `Base level: ${decision.baseLevel}`,
626
+ `Score: ${decision.score}`,
627
+ ...decision.reasons,
628
+ ].join("\n");
629
+ }
630
+
631
+ function formatHelp(): string {
632
+ return [
633
+ `/${COMMAND_NAME} status - show current auto-thinking state`,
634
+ `/${COMMAND_NAME} on - enable automatic thinking selection`,
635
+ `/${COMMAND_NAME} off - disable automatic thinking selection`,
636
+ `/${COMMAND_NAME} explain - explain the last decision`,
637
+ ].join("\n");
638
+ }
639
+
640
+ function formatStatus(decision: ThinkingDecision): string {
641
+ if (decision.level === "off") return "auto-thinking off";
642
+ return `auto-thinking ${decision.level}`;
643
+ }
644
+
645
+ function formatLastDecision(decision: LastDecision): string | undefined {
646
+ if (decision.kind === "selected") return formatStatus(decision);
647
+ return decision.reason.includes("disabled") ? undefined : "auto-thinking paused";
648
+ }
649
+
650
+ function countMatches(value: string, pattern: RegExp): number {
651
+ return [...value.matchAll(pattern)].length;
652
+ }
653
+
654
+ function countFilePaths(value: string): number {
655
+ const pathMatches = value.match(
656
+ /(?:^|[\s"'`(])(?:\.?\.?\/|~\/)?(?:[\w.-]+\/)+[\w.-]+(?:\.[\w-]+)?/g,
657
+ );
658
+ const filenameMatches = value.match(
659
+ /\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|css|html|py|rs|go|java|kt|swift|sh|yml|yaml|toml)\b/g,
660
+ );
661
+ return new Set([...(pathMatches ?? []), ...(filenameMatches ?? [])]).size;
662
+ }
663
+
664
+ function isRecord(value: unknown): value is Record<string, unknown> {
665
+ return typeof value === "object" && value !== null && !Array.isArray(value);
666
+ }