pi-adaptive-thinking 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Bring adaptive reasoning-effort control to Pi agents.
6
6
 
7
7
  </div>
8
8
 
9
- `pi-adaptive-thinking` is a Pi extension that lets the agent change Pi's thinking level through a tool named `set_thinking_level`.
9
+ `pi-adaptive-thinking` is a Pi extension that lets the agent inspect and change Pi's thinking level through tools named `get_thinking_level` and `set_thinking_level`.
10
10
 
11
11
  Requires Node `>=22.19.0` and Pi `>=0.84.1`.
12
12
 
@@ -27,7 +27,9 @@ pi -e ./src/index.ts
27
27
 
28
28
  ## Behavior
29
29
 
30
- The extension registers a tool with these parameters:
30
+ The extension registers two tools. `get_thinking_level` returns the current native Pi thinking level and the levels supported by the selected model. The agent should inspect status only when the level is uncertain, not poll it every turn.
31
+
32
+ `set_thinking_level` accepts these parameters:
31
33
 
32
34
  - `level`: one of the valid Pi thinking levels for the current model.
33
35
  - `persist`: optional boolean, default `false`.
@@ -48,6 +50,8 @@ Persistent changes:
48
50
 
49
51
  This changes the session baseline until another persistent change is made or the user changes thinking level manually.
50
52
 
53
+ The extension contributes only static tool guidance to Pi's base prompt. It never replaces the per-turn system prompt, so current level, supported levels, model, and session state cannot invalidate the system-prompt cache prefix.
54
+
51
55
  ## Configuration
52
56
 
53
57
  Configuration files are loaded in this order:
@@ -64,10 +68,15 @@ Project configuration takes precedence over global configuration.
64
68
  "quiet": false,
65
69
  "toolName": "set_thinking_level",
66
70
  "toolDescription": "Set your thinking level",
67
- "systemPrompt": "You MUST manage thinking level actively. Lower it before trivial or routine turns; raise it for ambiguity, debugging, risky changes, or multi-step synthesis. Reassess at turn start, after meaningful new evidence, and when the task shifts. NEVER leave the current level unchanged by inertia, and NEVER reply to a trivial turn before considering a downshift."
71
+ "statusToolName": "get_thinking_level",
72
+ "guidance": "You MUST manage thinking level actively. Lower it before trivial or routine turns; raise it for ambiguity, debugging, risky changes, or multi-step synthesis. Reassess at turn start, after meaningful new evidence, and when the task shifts. NEVER leave the current level unchanged by inertia, and NEVER reply to a trivial turn before considering a downshift."
68
73
  }
69
74
  ```
70
75
 
76
+ `guidance`, tool names, and tool descriptions are loaded once per session and remain static. Do not put runtime state in `guidance`.
77
+
78
+ The former `systemPrompt` field remains a deprecated alias for `guidance` during the `0.x` release line. Existing configurations continue to work and produce one UI warning at session start unless `quiet` is enabled. A configuration containing both fields is invalid.
79
+
71
80
  ## Development
72
81
 
73
82
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-adaptive-thinking",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Pi extension for adaptive reasoning-effort control",
6
6
  "keywords": [
@@ -0,0 +1,395 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import lockfile from "proper-lockfile";
5
+ import type {
6
+ AgentEndEvent,
7
+ AgentToolResult,
8
+ ExtensionAPI,
9
+ ExtensionContext,
10
+ SessionStartEvent,
11
+ ToolCallEvent,
12
+ ToolCallEventResult,
13
+ ToolDefinition,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { type Static, Type } from "typebox";
16
+ import { Parse } from "typebox/value";
17
+ import { loadConfig, type AdaptiveThinkingConfig } from "./config-loader.js";
18
+ import {
19
+ isThinkingLevel,
20
+ resolveSupportedThinkingLevels,
21
+ type PiThinkingLevel,
22
+ } from "./thinking-levels.js";
23
+
24
+ type NotifyType = "info" | "warning" | "error";
25
+
26
+ type RuntimeState = {
27
+ config: AdaptiveThinkingConfig;
28
+ persistedLevel?: PiThinkingLevel;
29
+ temporaryResetLevel?: PiThinkingLevel;
30
+ lastToolCallWasReasoningTool?: boolean;
31
+ reasoningToolCallBackToBackById: Map<string, boolean>;
32
+ };
33
+
34
+ const ToolParameters = Type.Object(
35
+ {
36
+ level: Type.String({
37
+ minLength: 1,
38
+ description:
39
+ "The Pi thinking level to apply. Higher levels may improve hard-task quality but may take more time and resources.",
40
+ }),
41
+ persist: Type.Optional(
42
+ Type.Boolean({
43
+ default: false,
44
+ description:
45
+ "Whether to persist the setting for this session; otherwise it applies only for the current turn.",
46
+ }),
47
+ ),
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
52
+ type ToolParameters = Static<typeof ToolParameters>;
53
+
54
+ const StatusToolParameters = Type.Object({}, { additionalProperties: false });
55
+
56
+ type ThinkingLevelStatus = {
57
+ currentLevel: PiThinkingLevel | "unknown";
58
+ supportedLevels: PiThinkingLevel[];
59
+ };
60
+
61
+ /** The extension-context fields Adaptive Thinking reads at its lifecycle and tool seams. */
62
+ export type AdaptiveThinkingContext = {
63
+ readonly cwd: string;
64
+ readonly hasUI: boolean;
65
+ readonly model: ExtensionContext["model"];
66
+ readonly ui: Pick<ExtensionContext["ui"], "notify">;
67
+ };
68
+
69
+ type AdaptiveThinkingHandler<Event, Result = undefined> = (
70
+ event: Event,
71
+ context: AdaptiveThinkingContext,
72
+ ) => Promise<Result | void> | Result | void;
73
+
74
+ type AdaptiveThinkingToolExecution<Args, Details> = (
75
+ toolCallId: string,
76
+ parameters: Args,
77
+ signal: AbortSignal | undefined,
78
+ onUpdate: globalThis.Parameters<ToolDefinition<typeof ToolParameters, Details>["execute"]>[3],
79
+ context: AdaptiveThinkingContext,
80
+ ) => Promise<AgentToolResult<Details>>;
81
+
82
+ /** Set-thinking-level parameters accepted after Pi has validated the tool schema. */
83
+ export type AdaptiveThinkingSetThinkingLevelParameters = Static<typeof ToolParameters>;
84
+
85
+ /** Current and supported thinking levels returned by the status tool. */
86
+ export type AdaptiveThinkingLevelStatus = ThinkingLevelStatus;
87
+
88
+ type SetThinkingLevelTool = Omit<ToolDefinition<typeof ToolParameters, undefined>, "execute"> & {
89
+ execute: AdaptiveThinkingToolExecution<AdaptiveThinkingSetThinkingLevelParameters, undefined>;
90
+ };
91
+ type GetThinkingLevelTool = Omit<
92
+ ToolDefinition<typeof StatusToolParameters, ThinkingLevelStatus>,
93
+ "execute"
94
+ > & {
95
+ execute: AdaptiveThinkingToolExecution<Record<string, never>, ThinkingLevelStatus>;
96
+ };
97
+
98
+ /** Tool definitions registered by the Adaptive Thinking extension. */
99
+ export type AdaptiveThinkingToolDefinition = SetThinkingLevelTool | GetThinkingLevelTool;
100
+
101
+ /** Identifies the set-thinking-level tool without relying on its configurable name. */
102
+ export const isAdaptiveThinkingSetThinkingLevelTool = (
103
+ tool: AdaptiveThinkingToolDefinition,
104
+ ): tool is SetThinkingLevelTool => tool.parameters === ToolParameters;
105
+
106
+ /** Identifies the status tool without relying on its configurable name. */
107
+ export const isAdaptiveThinkingStatusTool = (
108
+ tool: AdaptiveThinkingToolDefinition,
109
+ ): tool is GetThinkingLevelTool => tool.parameters === StatusToolParameters;
110
+
111
+ /** Minimal Pi host capability required by the Adaptive Thinking extension. */
112
+ export type AdaptiveThinkingExtensionHost = {
113
+ onSessionStart(handler: AdaptiveThinkingHandler<SessionStartEvent>): void;
114
+ onToolCall(handler: AdaptiveThinkingHandler<ToolCallEvent, ToolCallEventResult>): void;
115
+ onAgentEnd(handler: AdaptiveThinkingHandler<AgentEndEvent>): void;
116
+ registerTool(tool: AdaptiveThinkingToolDefinition): void;
117
+ getThinkingLevel(): string;
118
+ setThinkingLevel(level: PiThinkingLevel): void;
119
+ };
120
+
121
+ const textResult = (text: string): AgentToolResult<undefined> => ({
122
+ content: [{ type: "text", text }],
123
+ details: undefined,
124
+ });
125
+
126
+ const thinkingLevelStatusResult = (
127
+ currentLevel: PiThinkingLevel | "unknown",
128
+ supportedLevels: PiThinkingLevel[],
129
+ ): AgentToolResult<ThinkingLevelStatus> => ({
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `Current thinking level: ${currentLevel}. Supported thinking levels: ${supportedLevels.join(", ")}.`,
134
+ },
135
+ ],
136
+ details: { currentLevel, supportedLevels },
137
+ });
138
+
139
+ const errorMessage = (cause: Error) => cause.message;
140
+
141
+ const SettingsDocumentSchema = Type.Object(
142
+ { defaultThinkingLevel: Type.Optional(Type.String()) },
143
+ { additionalProperties: Type.Unknown() },
144
+ );
145
+
146
+ const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
147
+
148
+ const globalSettingsPath = () => join(agentDir(), "settings.json");
149
+
150
+ const withSettingsLock = async <T>(settingsPath: string, fn: () => Promise<T> | T): Promise<T> => {
151
+ mkdirSync(join(settingsPath, ".."), { recursive: true });
152
+ const lockPath = `${settingsPath}.adaptive-thinking`;
153
+ if (!existsSync(lockPath)) writeFileSync(lockPath, "");
154
+
155
+ const release = await lockfile.lock(lockPath, {
156
+ realpath: false,
157
+ retries: { retries: 99, factor: 1, minTimeout: 20, maxTimeout: 20 },
158
+ });
159
+
160
+ try {
161
+ return await fn();
162
+ } finally {
163
+ await release();
164
+ }
165
+ };
166
+
167
+ const readDefaultThinkingLevel = (settingsPath: string): PiThinkingLevel | undefined => {
168
+ if (!existsSync(settingsPath)) return undefined;
169
+
170
+ try {
171
+ const settings = Parse(SettingsDocumentSchema, JSON.parse(readFileSync(settingsPath, "utf-8")));
172
+ const level = settings.defaultThinkingLevel;
173
+ return level !== undefined && isThinkingLevel(level) ? level : undefined;
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ };
178
+
179
+ const restoreDefaultThinkingLevel = (
180
+ settingsPath: string,
181
+ previousDefaultThinkingLevel: PiThinkingLevel | undefined,
182
+ ) => {
183
+ if (!existsSync(settingsPath)) return;
184
+
185
+ try {
186
+ const settings = Parse(SettingsDocumentSchema, JSON.parse(readFileSync(settingsPath, "utf-8")));
187
+ if (previousDefaultThinkingLevel === undefined) {
188
+ delete settings.defaultThinkingLevel;
189
+ } else {
190
+ settings.defaultThinkingLevel = previousDefaultThinkingLevel;
191
+ }
192
+ writeFileSync(settingsPath, JSON.stringify(settings, undefined, 2) + "\n");
193
+ } catch {
194
+ return;
195
+ }
196
+ };
197
+
198
+ const withSessionOnlyThinkingLevelChange = async (changeThinkingLevel: () => void) => {
199
+ const settingsPath = globalSettingsPath();
200
+
201
+ await withSettingsLock(settingsPath, () => {
202
+ const previousDefaultThinkingLevel = readDefaultThinkingLevel(settingsPath);
203
+
204
+ changeThinkingLevel();
205
+
206
+ restoreDefaultThinkingLevel(settingsPath, previousDefaultThinkingLevel);
207
+ });
208
+ };
209
+
210
+ const notify = (
211
+ ctx: AdaptiveThinkingContext,
212
+ type: NotifyType,
213
+ message: string,
214
+ config?: Pick<AdaptiveThinkingConfig, "quiet">,
215
+ ) => {
216
+ if (config?.quiet) return;
217
+ if (!ctx.hasUI) return;
218
+ ctx.ui.notify(message, type);
219
+ };
220
+
221
+ /** Registers Adaptive Thinking against its narrow lifecycle, tool, and thinking-level host. */
222
+ export function registerAdaptiveThinking(pi: AdaptiveThinkingExtensionHost) {
223
+ let runtime: RuntimeState | undefined;
224
+ let runtimeHandlersRegistered = false;
225
+
226
+ const registerRuntimeHandlers = () => {
227
+ if (runtimeHandlersRegistered) return;
228
+ runtimeHandlersRegistered = true;
229
+
230
+ pi.onToolCall(async (event) => {
231
+ const state = runtime;
232
+ if (!state) return;
233
+
234
+ if (event.toolName === state.config.toolName) {
235
+ state.reasoningToolCallBackToBackById.set(
236
+ event.toolCallId,
237
+ state.lastToolCallWasReasoningTool ?? false,
238
+ );
239
+ state.lastToolCallWasReasoningTool = true;
240
+ } else {
241
+ state.lastToolCallWasReasoningTool = false;
242
+ }
243
+ });
244
+
245
+ pi.onAgentEnd(async (_event, ctx) => {
246
+ await resetTemporaryLevel(ctx);
247
+ if (!runtime) return;
248
+ runtime.lastToolCallWasReasoningTool = false;
249
+ runtime.reasoningToolCallBackToBackById.clear();
250
+ });
251
+ };
252
+
253
+ const resetTemporaryLevel = async (ctx: AdaptiveThinkingContext) => {
254
+ const state = runtime;
255
+ const resetLevel = state?.temporaryResetLevel;
256
+ if (!state || !resetLevel) return;
257
+
258
+ try {
259
+ await withSessionOnlyThinkingLevelChange(() => pi.setThinkingLevel(resetLevel));
260
+ delete state.temporaryResetLevel;
261
+ } catch (cause) {
262
+ const error = cause instanceof Error ? cause : new Error(String(cause));
263
+ notify(ctx, "error", `Failed to reset thinking level: ${errorMessage(error)}`, state.config);
264
+ }
265
+ };
266
+
267
+ const registerSetThinkingLevelTool = (tool: SetThinkingLevelTool) => pi.registerTool(tool);
268
+ const registerGetThinkingLevelTool = (tool: GetThinkingLevelTool) => pi.registerTool(tool);
269
+
270
+ pi.onSessionStart(async (_event, ctx) => {
271
+ const configResult = await loadConfig({ cwd: ctx.cwd });
272
+ if (!configResult.success) {
273
+ runtime = undefined;
274
+ notify(ctx, "error", configResult.error.message);
275
+ return;
276
+ }
277
+
278
+ const { config } = configResult;
279
+ if (!config.enabled) {
280
+ runtime = undefined;
281
+ return;
282
+ }
283
+
284
+ runtime = { config, reasoningToolCallBackToBackById: new Map() };
285
+
286
+ if (configResult.usedDeprecatedSystemPrompt) {
287
+ notify(
288
+ ctx,
289
+ "warning",
290
+ "Adaptive Thinking configuration: systemPrompt is deprecated; rename it to guidance.",
291
+ config,
292
+ );
293
+ }
294
+
295
+ registerSetThinkingLevelTool({
296
+ name: config.toolName,
297
+ label: "Set Thinking Level",
298
+ description: config.toolDescription,
299
+ promptSnippet: "Set the current Pi thinking level.",
300
+ promptGuidelines: [
301
+ config.guidance,
302
+ `Use ${config.toolName} to change the thinking level when task complexity justifies a different level.`,
303
+ `Use ${config.statusToolName} only when the current or supported thinking levels are uncertain; do not poll it routinely.`,
304
+ `Do not call ${config.toolName} twice in a row; reassess only after new evidence from other tool calls or user input.`,
305
+ ],
306
+ parameters: ToolParameters,
307
+ execute: async (toolCallId, params: ToolParameters, _signal, _onUpdate, ctx) => {
308
+ const state = runtime;
309
+ if (!state) return textResult("Adaptive Thinking is not enabled for this session.");
310
+
311
+ const level = params.level.trim();
312
+ const validLevels = resolveSupportedThinkingLevels(ctx.model);
313
+ if (!isThinkingLevel(level) || !validLevels.includes(level)) {
314
+ return textResult(
315
+ `Invalid thinking level: ${level}. Valid levels: ${validLevels.join(", ")}.`,
316
+ );
317
+ }
318
+
319
+ const persist = params.persist ?? false;
320
+ const currentLevel = pi.getThinkingLevel();
321
+ if (currentLevel === level) {
322
+ return textResult(`Thinking level is already ${level}; no change made.`);
323
+ }
324
+
325
+ if (state.reasoningToolCallBackToBackById.get(toolCallId) ?? false) {
326
+ return textResult(
327
+ `Thinking level change skipped because the previous tool call was also ${state.config.toolName}. Reassess after another tool call or new user input.`,
328
+ );
329
+ }
330
+
331
+ const resetLevel =
332
+ state.persistedLevel ?? (isThinkingLevel(currentLevel) ? currentLevel : undefined);
333
+
334
+ if (!persist && !resetLevel) {
335
+ return textResult(
336
+ "Cannot apply a temporary thinking level because the Session Baseline is unknown.",
337
+ );
338
+ }
339
+
340
+ try {
341
+ await withSessionOnlyThinkingLevelChange(() => pi.setThinkingLevel(level));
342
+ } catch (cause) {
343
+ const error = cause instanceof Error ? cause : new Error(String(cause));
344
+ return textResult(`Failed to set thinking level: ${errorMessage(error)}`);
345
+ }
346
+
347
+ if (persist) {
348
+ state.persistedLevel = level;
349
+ delete state.temporaryResetLevel;
350
+ } else if (resetLevel && resetLevel !== level) {
351
+ state.temporaryResetLevel = resetLevel;
352
+ } else {
353
+ delete state.temporaryResetLevel;
354
+ }
355
+
356
+ return textResult(`Thinking level set to ${level}`);
357
+ },
358
+ });
359
+
360
+ registerGetThinkingLevelTool({
361
+ name: config.statusToolName,
362
+ label: "Get Thinking Level",
363
+ description: "Get the current and supported Pi thinking levels",
364
+ promptSnippet: "Inspect the current and supported Pi thinking levels.",
365
+ promptGuidelines: [
366
+ `Use ${config.statusToolName} only when thinking-level state is uncertain; do not poll it routinely.`,
367
+ ],
368
+ parameters: StatusToolParameters,
369
+ execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => {
370
+ const currentLevel = pi.getThinkingLevel();
371
+ return thinkingLevelStatusResult(
372
+ isThinkingLevel(currentLevel) ? currentLevel : "unknown",
373
+ resolveSupportedThinkingLevels(ctx.model),
374
+ );
375
+ },
376
+ });
377
+
378
+ registerRuntimeHandlers();
379
+ });
380
+ }
381
+
382
+ /** Adapts Pi's complete ExtensionAPI to the Adaptive Thinking lifecycle capability. */
383
+ export default function adaptiveThinkingExtension(pi: ExtensionAPI) {
384
+ registerAdaptiveThinking({
385
+ onSessionStart: (handler) => pi.on("session_start", handler),
386
+ onToolCall: (handler) => pi.on("tool_call", handler),
387
+ onAgentEnd: (handler) => pi.on("agent_end", handler),
388
+ registerTool: (tool) => {
389
+ if (isAdaptiveThinkingSetThinkingLevelTool(tool)) pi.registerTool(tool);
390
+ else pi.registerTool(tool);
391
+ },
392
+ getThinkingLevel: () => pi.getThinkingLevel(),
393
+ setThinkingLevel: (level) => pi.setThinkingLevel(level),
394
+ });
395
+ }
@@ -1,7 +1,11 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { parseConfig, type AdaptiveThinkingConfig } from "./config.js";
4
+ import {
5
+ parseAdaptiveThinkingConfig,
6
+ type AdaptiveThinkingConfig,
7
+ type ParsedAdaptiveThinkingConfig,
8
+ } from "./config.js";
5
9
 
6
10
  export type { AdaptiveThinkingConfig } from "./config.js";
7
11
 
@@ -11,21 +15,32 @@ export type LoadConfigOptions = {
11
15
  };
12
16
 
13
17
  export type LoadConfigResult =
14
- | { success: true; config: AdaptiveThinkingConfig; source?: string }
18
+ | {
19
+ success: true;
20
+ config: AdaptiveThinkingConfig;
21
+ source?: string;
22
+ usedDeprecatedSystemPrompt?: true;
23
+ }
15
24
  | { success: false; source: string; error: Error };
16
25
 
17
- const hasCode = (error: unknown, code: string) => {
18
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
19
- };
20
-
21
- const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
26
+ const hasErrorCode = (cause: NodeJS.ErrnoException, code: string) =>
27
+ Object.hasOwn(cause, "code") && cause.code === code;
22
28
 
23
- const invalidConfig = (source: string, error: unknown): LoadConfigResult => ({
29
+ const invalidConfig = (source: string, cause: Error): LoadConfigResult => ({
24
30
  success: false,
25
31
  source,
26
- error: new Error(`Invalid Adaptive Thinking configuration in ${source}: ${errorMessage(error)}`),
32
+ error: new Error(`Invalid Adaptive Thinking configuration in ${source}: ${cause.message}`, {
33
+ cause,
34
+ }),
27
35
  });
28
36
 
37
+ const readAdaptiveThinkingConfig = async (
38
+ source: string,
39
+ ): Promise<ParsedAdaptiveThinkingConfig> => {
40
+ const raw = await readFile(source, "utf8");
41
+ return parseAdaptiveThinkingConfig(JSON.parse(raw));
42
+ };
43
+
29
44
  export const loadConfig = async ({
30
45
  cwd,
31
46
  homeDir = homedir(),
@@ -36,20 +51,26 @@ export const loadConfig = async ({
36
51
  ];
37
52
 
38
53
  for (const source of candidates) {
39
- let raw: string;
54
+ let parsedConfig: ParsedAdaptiveThinkingConfig;
40
55
  try {
41
- raw = await readFile(source, "utf8");
42
- } catch (error) {
43
- if (hasCode(error, "ENOENT")) continue;
56
+ parsedConfig = await readAdaptiveThinkingConfig(source);
57
+ } catch (cause) {
58
+ const error = cause instanceof Error ? cause : new Error(String(cause));
59
+ const fileSystemError: NodeJS.ErrnoException = error;
60
+ if (hasErrorCode(fileSystemError, "ENOENT")) continue;
44
61
  return invalidConfig(source, error);
45
62
  }
46
63
 
47
- try {
48
- return { success: true, source, config: parseConfig(JSON.parse(raw)) };
49
- } catch (error) {
50
- return invalidConfig(source, error);
64
+ const result: Extract<LoadConfigResult, { success: true }> = {
65
+ success: true,
66
+ source,
67
+ config: parsedConfig.config,
68
+ };
69
+ if (parsedConfig.usedDeprecatedSystemPrompt) {
70
+ result.usedDeprecatedSystemPrompt = true;
51
71
  }
72
+ return result;
52
73
  }
53
74
 
54
- return { success: true, config: parseConfig(undefined) };
75
+ return { success: true, config: parseAdaptiveThinkingConfig(undefined).config };
55
76
  };
package/src/config.ts CHANGED
@@ -1,34 +1,83 @@
1
- import { Type, type Static } from "typebox";
1
+ import type { JsonValue } from "@earendil-works/pi-agent-core";
2
+ import { Type } from "typebox";
2
3
  import { Parse } from "typebox/value";
3
4
 
4
- export const defaultSystemPrompt =
5
+ export const defaultGuidance =
5
6
  "You MUST manage thinking level actively. " +
6
7
  "Lower it before trivial or routine turns; raise it for ambiguity, debugging, risky changes, or multi-step synthesis. " +
7
8
  "Reassess at turn start, after meaningful new evidence, and when the task shifts. " +
8
9
  "NEVER leave the current level unchanged by inertia, and NEVER reply to a trivial turn before considering a downshift.";
9
10
 
10
- export const configDefaults = {
11
+ export type AdaptiveThinkingConfig = {
12
+ enabled: boolean;
13
+ quiet: boolean;
14
+ toolName: string;
15
+ toolDescription: string;
16
+ statusToolName: string;
17
+ guidance: string;
18
+ };
19
+
20
+ export const configDefaults: AdaptiveThinkingConfig = {
11
21
  enabled: true,
12
22
  quiet: false,
13
23
  toolName: "set_thinking_level",
14
24
  toolDescription: "Set your thinking level",
15
- systemPrompt: defaultSystemPrompt,
25
+ statusToolName: "get_thinking_level",
26
+ guidance: defaultGuidance,
16
27
  };
17
28
 
18
- export const ConfigSchema = Type.Object(
19
- {
20
- enabled: Type.Boolean(),
21
- quiet: Type.Boolean(),
22
- toolName: Type.String({ minLength: 1 }),
23
- toolDescription: Type.String({ minLength: 1 }),
24
- systemPrompt: Type.String({ minLength: 1 }),
25
- },
29
+ const ConfigValueSchema = Type.Object({
30
+ enabled: Type.Boolean(),
31
+ quiet: Type.Boolean(),
32
+ toolName: Type.String({ minLength: 1 }),
33
+ toolDescription: Type.String({ minLength: 1 }),
34
+ statusToolName: Type.String({ minLength: 1 }),
35
+ guidance: Type.String({ minLength: 1 }),
36
+ });
37
+
38
+ const ConfigOverridesSchema = Type.Partial(
39
+ Type.Intersect([
40
+ ConfigValueSchema,
41
+ Type.Object({ systemPrompt: Type.Optional(Type.String({ minLength: 1 })) }),
42
+ ]),
26
43
  { additionalProperties: false },
27
44
  );
28
45
 
29
- export type AdaptiveThinkingConfig = Static<typeof ConfigSchema>;
46
+ /** Parsed configuration and metadata retained from the configuration ingress boundary. */
47
+ export type ParsedAdaptiveThinkingConfig = {
48
+ config: AdaptiveThinkingConfig;
49
+ usedDeprecatedSystemPrompt: boolean;
50
+ };
30
51
 
31
- export const parseConfig = (input: unknown): AdaptiveThinkingConfig => {
32
- const merged = input === undefined ? configDefaults : { ...configDefaults, ...input };
33
- return Parse(ConfigSchema, merged);
52
+ /** Parses optional configuration values and normalizes the deprecated system prompt alias. */
53
+ export const parseAdaptiveThinkingConfig = (
54
+ input: JsonValue | undefined,
55
+ ): ParsedAdaptiveThinkingConfig => {
56
+ const overrides = input === undefined ? {} : Parse(ConfigOverridesSchema, input);
57
+ const usesGuidance = overrides.guidance !== undefined;
58
+ const usesSystemPrompt = overrides.systemPrompt !== undefined;
59
+ if (usesGuidance && usesSystemPrompt) {
60
+ throw new Error(
61
+ "Adaptive Thinking configuration cannot contain both guidance and systemPrompt",
62
+ );
63
+ }
64
+
65
+ const guidance = overrides.guidance ?? overrides.systemPrompt ?? configDefaults.guidance;
66
+ const config: AdaptiveThinkingConfig = {
67
+ enabled: overrides.enabled ?? configDefaults.enabled,
68
+ quiet: overrides.quiet ?? configDefaults.quiet,
69
+ toolName: overrides.toolName ?? configDefaults.toolName,
70
+ toolDescription: overrides.toolDescription ?? configDefaults.toolDescription,
71
+ statusToolName: overrides.statusToolName ?? configDefaults.statusToolName,
72
+ guidance,
73
+ };
74
+ if (config.toolName === config.statusToolName) {
75
+ throw new Error("Adaptive Thinking toolName and statusToolName must be different");
76
+ }
77
+
78
+ return { config, usedDeprecatedSystemPrompt: usesSystemPrompt };
34
79
  };
80
+
81
+ /** Parses configuration at the public configuration seam. */
82
+ export const parseConfig = (input: JsonValue | undefined): AdaptiveThinkingConfig =>
83
+ parseAdaptiveThinkingConfig(input).config;
package/src/index.ts CHANGED
@@ -1,333 +1 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import lockfile from "proper-lockfile";
5
- import type {
6
- AgentToolResult,
7
- BeforeAgentStartEvent,
8
- BeforeAgentStartEventResult,
9
- ExtensionAPI,
10
- ExtensionContext,
11
- } from "@earendil-works/pi-coding-agent";
12
- import { type Static, Type } from "typebox";
13
- import { loadConfig, type AdaptiveThinkingConfig } from "./config-loader.js";
14
- import {
15
- isThinkingLevel,
16
- resolveSupportedThinkingLevels,
17
- type PiThinkingLevel,
18
- } from "./thinking-levels.js";
19
-
20
- type NotifyType = "info" | "warning" | "error";
21
-
22
- type RuntimeState = {
23
- config: AdaptiveThinkingConfig;
24
- currentLevel?: PiThinkingLevel;
25
- persistedLevel?: PiThinkingLevel;
26
- temporaryResetLevel?: PiThinkingLevel;
27
- lastToolCallWasReasoningTool?: boolean;
28
- reasoningToolCallBackToBackById: Map<string, boolean>;
29
- };
30
-
31
- const ToolParameters = Type.Object(
32
- {
33
- level: Type.String({
34
- minLength: 1,
35
- description:
36
- "The Pi thinking level to apply. Higher levels may improve hard-task quality but may take more time and resources.",
37
- }),
38
- persist: Type.Optional(
39
- Type.Boolean({
40
- default: false,
41
- description:
42
- "Whether to persist the setting for this session; otherwise it applies only for the current turn.",
43
- }),
44
- ),
45
- },
46
- { additionalProperties: false },
47
- );
48
-
49
- type ToolParameters = Static<typeof ToolParameters>;
50
-
51
- const textResult = (text: string): AgentToolResult<undefined> => ({
52
- content: [{ type: "text", text }],
53
- details: undefined,
54
- });
55
-
56
- const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
57
-
58
- const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
59
-
60
- const globalSettingsPath = () => join(agentDir(), "settings.json");
61
-
62
- const sleepSync = (milliseconds: number) => {
63
- const end = Date.now() + milliseconds;
64
- while (Date.now() < end) {
65
- // Synchronous ExtensionAPI methods require a synchronous retry loop.
66
- }
67
- };
68
-
69
- const acquireSettingsLock = (lockPath: string) => {
70
- const maxAttempts = 100;
71
- const delayMs = 20;
72
-
73
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
74
- try {
75
- return lockfile.lockSync(lockPath, { realpath: false });
76
- } catch (error) {
77
- const code =
78
- typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
79
- if (code !== "ELOCKED" || attempt === maxAttempts) throw error;
80
- sleepSync(delayMs);
81
- }
82
- }
83
-
84
- throw new Error(`Failed to acquire settings lock: ${lockPath}`);
85
- };
86
-
87
- const withSettingsLock = <T>(settingsPath: string, fn: () => T): T => {
88
- mkdirSync(join(settingsPath, ".."), { recursive: true });
89
- const lockPath = `${settingsPath}.adaptive-thinking`;
90
- if (!existsSync(lockPath)) writeFileSync(lockPath, "");
91
-
92
- const release = acquireSettingsLock(lockPath);
93
-
94
- try {
95
- return fn();
96
- } finally {
97
- release();
98
- }
99
- };
100
-
101
- const readDefaultThinkingLevel = (settingsPath: string): PiThinkingLevel | undefined => {
102
- if (!existsSync(settingsPath)) return undefined;
103
-
104
- try {
105
- const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as {
106
- defaultThinkingLevel?: unknown;
107
- };
108
- return typeof parsed.defaultThinkingLevel === "string" &&
109
- isThinkingLevel(parsed.defaultThinkingLevel)
110
- ? parsed.defaultThinkingLevel
111
- : undefined;
112
- } catch {
113
- return undefined;
114
- }
115
- };
116
-
117
- const restoreDefaultThinkingLevel = (
118
- settingsPath: string,
119
- previousDefaultThinkingLevel: PiThinkingLevel | undefined,
120
- ) => {
121
- if (!existsSync(settingsPath)) return;
122
-
123
- try {
124
- const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
125
- if (previousDefaultThinkingLevel === undefined) {
126
- delete settings.defaultThinkingLevel;
127
- } else {
128
- settings.defaultThinkingLevel = previousDefaultThinkingLevel;
129
- }
130
- writeFileSync(settingsPath, JSON.stringify(settings, undefined, 2) + "\n");
131
- } catch {
132
- return;
133
- }
134
- };
135
-
136
- const withSessionOnlyThinkingLevelChange = (changeThinkingLevel: () => void) => {
137
- const settingsPath = globalSettingsPath();
138
-
139
- return withSettingsLock(settingsPath, () => {
140
- const previousDefaultThinkingLevel = readDefaultThinkingLevel(settingsPath);
141
-
142
- changeThinkingLevel();
143
-
144
- restoreDefaultThinkingLevel(settingsPath, previousDefaultThinkingLevel);
145
- });
146
- };
147
-
148
- const notify = (
149
- ctx: ExtensionContext,
150
- type: NotifyType,
151
- message: string,
152
- config?: Pick<AdaptiveThinkingConfig, "quiet">,
153
- ) => {
154
- if (config?.quiet) return;
155
- if (!ctx.hasUI) return;
156
- ctx.ui.notify(message, type);
157
- };
158
-
159
- const appendSystemPromptBlock = (systemPrompt: string, block: string) => {
160
- const trimmedBlock = block.trim();
161
- if (!trimmedBlock) return systemPrompt;
162
- if (!systemPrompt.trim()) return trimmedBlock;
163
- return `${systemPrompt.trimEnd()}\n\n${trimmedBlock}`;
164
- };
165
-
166
- const formatGuidance = (
167
- config: AdaptiveThinkingConfig,
168
- currentLevel: string | undefined,
169
- validLevels: string[],
170
- ) => {
171
- return (
172
- config.systemPrompt.trim() +
173
- " " +
174
- (currentLevel ? `Current thinking level: ${currentLevel}. ` : "") +
175
- `Valid thinking levels for this session: ${validLevels.join(", ")}. ` +
176
- `To change the thinking level, use the \`${config.toolName}\` tool with one of the valid levels. ` +
177
- "Only call it when the task complexity justifies changing levels. " +
178
- `Do not call ${config.toolName} if the current thinking level already matches the target level. ` +
179
- `Do not call ${config.toolName} twice in a row; reassess only after new evidence from other tool calls or user input.`
180
- );
181
- };
182
-
183
- export default function adaptiveThinking(pi: ExtensionAPI) {
184
- let runtime: RuntimeState | undefined;
185
- let runtimeHandlersRegistered = false;
186
-
187
- const registerRuntimeHandlers = () => {
188
- if (runtimeHandlersRegistered) return;
189
- runtimeHandlersRegistered = true;
190
-
191
- pi.on("thinking_level_select", async (event) => {
192
- if (!runtime) return;
193
- if (isThinkingLevel(event.level)) runtime.currentLevel = event.level;
194
- });
195
-
196
- pi.on("tool_call", async (event) => {
197
- const state = runtime;
198
- if (!state) return;
199
-
200
- if (event.toolName === state.config.toolName) {
201
- state.reasoningToolCallBackToBackById.set(
202
- event.toolCallId,
203
- state.lastToolCallWasReasoningTool ?? false,
204
- );
205
- state.lastToolCallWasReasoningTool = true;
206
- } else {
207
- state.lastToolCallWasReasoningTool = false;
208
- }
209
- });
210
-
211
- pi.on("before_agent_start", async (event, ctx) => beforeAgentStart(event, ctx));
212
-
213
- pi.on("agent_end", async (_event, ctx) => {
214
- await resetTemporaryLevel(ctx);
215
- if (!runtime) return;
216
- runtime.lastToolCallWasReasoningTool = false;
217
- runtime.reasoningToolCallBackToBackById.clear();
218
- });
219
- };
220
-
221
- const beforeAgentStart = async (
222
- event: BeforeAgentStartEvent,
223
- ctx: ExtensionContext,
224
- ): Promise<BeforeAgentStartEventResult> => {
225
- const state = runtime;
226
- if (!state) return { systemPrompt: event.systemPrompt };
227
-
228
- state.lastToolCallWasReasoningTool = false;
229
- state.reasoningToolCallBackToBackById.clear();
230
-
231
- const currentLevel = state.currentLevel ?? pi.getThinkingLevel();
232
- if (isThinkingLevel(currentLevel)) state.currentLevel = currentLevel;
233
-
234
- const validLevels = resolveSupportedThinkingLevels(ctx.model);
235
- return {
236
- systemPrompt: appendSystemPromptBlock(
237
- event.systemPrompt,
238
- formatGuidance(state.config, state.currentLevel, validLevels),
239
- ),
240
- };
241
- };
242
-
243
- const resetTemporaryLevel = async (ctx: ExtensionContext) => {
244
- const state = runtime;
245
- const resetLevel = state?.temporaryResetLevel;
246
- if (!state || !resetLevel) return;
247
-
248
- try {
249
- withSessionOnlyThinkingLevelChange(() => pi.setThinkingLevel(resetLevel));
250
- state.currentLevel = resetLevel;
251
- delete state.temporaryResetLevel;
252
- } catch (error) {
253
- notify(ctx, "error", `Failed to reset thinking level: ${errorMessage(error)}`, state.config);
254
- }
255
- };
256
-
257
- pi.on("session_start", async (_event, ctx) => {
258
- const configResult = await loadConfig({ cwd: ctx.cwd });
259
- if (!configResult.success) {
260
- runtime = undefined;
261
- notify(ctx, "error", configResult.error.message);
262
- return;
263
- }
264
-
265
- const { config } = configResult;
266
- if (!config.enabled) {
267
- runtime = undefined;
268
- return;
269
- }
270
-
271
- const initialLevel = pi.getThinkingLevel();
272
- runtime = { config, reasoningToolCallBackToBackById: new Map() };
273
- if (isThinkingLevel(initialLevel)) runtime.currentLevel = initialLevel;
274
-
275
- pi.registerTool({
276
- name: config.toolName,
277
- label: "Set Thinking Level",
278
- description: config.toolDescription,
279
- promptSnippet: "Set the current Pi thinking level.",
280
- promptGuidelines: [
281
- `Use ${config.toolName} to change the thinking level when task complexity justifies a different level.`,
282
- ],
283
- parameters: ToolParameters,
284
- execute: async (toolCallId, params: ToolParameters, _signal, _onUpdate, ctx) => {
285
- const state = runtime;
286
- if (!state) return textResult("Adaptive Thinking is not enabled for this session.");
287
-
288
- const level = params.level.trim();
289
- const validLevels = resolveSupportedThinkingLevels(ctx.model);
290
- if (!isThinkingLevel(level) || !validLevels.includes(level)) {
291
- return textResult(
292
- `Invalid thinking level: ${level}. Valid levels: ${validLevels.join(", ")}.`,
293
- );
294
- }
295
-
296
- const persist = params.persist ?? false;
297
- const currentLevel = state.currentLevel ?? pi.getThinkingLevel();
298
- if (currentLevel === level) {
299
- return textResult(`Thinking level is already ${level}; no change made.`);
300
- }
301
-
302
- if (state.reasoningToolCallBackToBackById.get(toolCallId) ?? false) {
303
- return textResult(
304
- `Thinking level change skipped because the previous tool call was also ${state.config.toolName}. Reassess after another tool call or new user input.`,
305
- );
306
- }
307
-
308
- const resetLevel =
309
- state.persistedLevel ?? (isThinkingLevel(currentLevel) ? currentLevel : undefined);
310
-
311
- try {
312
- withSessionOnlyThinkingLevelChange(() => pi.setThinkingLevel(level));
313
- } catch (error) {
314
- return textResult(`Failed to set thinking level: ${errorMessage(error)}`);
315
- }
316
-
317
- state.currentLevel = level;
318
- if (persist) {
319
- state.persistedLevel = level;
320
- delete state.temporaryResetLevel;
321
- } else if (resetLevel && resetLevel !== level) {
322
- state.temporaryResetLevel = resetLevel;
323
- } else {
324
- delete state.temporaryResetLevel;
325
- }
326
-
327
- return textResult(`Thinking level set to ${level}`);
328
- },
329
- });
330
-
331
- registerRuntimeHandlers();
332
- });
333
- }
1
+ export { default } from "./adaptive-thinking-lifecycle.js";