localpi 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { asObject, requiredString } from "../common/json.js";
5
+ import { normalizeBaseUrl } from "../llm/openai.js";
6
+ export async function loadLocalModelProfile(options) {
7
+ if (options.modelProfileFile === undefined) {
8
+ return undefined;
9
+ }
10
+ const raw = await readFile(expandHome(options.modelProfileFile), "utf8");
11
+ return parseLocalModelProfile(JSON.parse(raw), options.modelProfileFile);
12
+ }
13
+ export function profileMatchesModel(profile, modelId) {
14
+ return modelId === profile.model || modelId === profile.id;
15
+ }
16
+ export function profileMatchesBaseUrl(profile, baseUrl) {
17
+ return (profile.baseUrl === undefined || normalizeBaseUrl(profile.baseUrl) === normalizeBaseUrl(baseUrl));
18
+ }
19
+ function parseLocalModelProfile(value, source) {
20
+ const root = asObject(value, `model profile ${source}`);
21
+ const client = optionalObject(root["client"], `model profile ${source} client`);
22
+ const capabilities = optionalObject(root["capabilities"], `model profile ${source} capabilities`);
23
+ const contextWindow = client === undefined
24
+ ? undefined
25
+ : optionalPositiveInteger(client["context_window"], `model profile ${source} client.context_window`);
26
+ const maxTokens = client === undefined
27
+ ? undefined
28
+ : optionalPositiveInteger(client["max_tokens"], `model profile ${source} client.max_tokens`);
29
+ const reasoning = capabilities === undefined
30
+ ? undefined
31
+ : optionalBoolean(capabilities["reasoning"], `model profile ${source} capabilities.reasoning`);
32
+ const thinkingFormat = capabilities === undefined
33
+ ? undefined
34
+ : optionalThinkingFormat(optionalProfileString(capabilities["thinking_format"], `model profile ${source} capabilities.thinking_format`), `model profile ${source} capabilities.thinking_format`);
35
+ return withoutUndefined({
36
+ id: requiredString(root["id"], `model profile ${source} id`),
37
+ model: requiredString(root["model"], `model profile ${source} model`),
38
+ baseUrl: optionalProfileString(root["base_url"], `model profile ${source} base_url`),
39
+ client: client === undefined
40
+ ? undefined
41
+ : withoutUndefined({
42
+ contextWindow,
43
+ maxTokens
44
+ }),
45
+ capabilities: capabilities === undefined
46
+ ? undefined
47
+ : withoutUndefined({
48
+ reasoning,
49
+ thinkingFormat
50
+ })
51
+ });
52
+ }
53
+ function optionalObject(value, context) {
54
+ return value === undefined ? undefined : asObject(value, context);
55
+ }
56
+ function optionalProfileString(value, context) {
57
+ if (value === undefined) {
58
+ return undefined;
59
+ }
60
+ if (typeof value !== "string") {
61
+ throw new Error(`${context} must be a string`);
62
+ }
63
+ return value;
64
+ }
65
+ function optionalBoolean(value, context) {
66
+ if (value === undefined) {
67
+ return undefined;
68
+ }
69
+ if (typeof value !== "boolean") {
70
+ throw new Error(`${context} must be a boolean`);
71
+ }
72
+ return value;
73
+ }
74
+ function optionalPositiveInteger(value, context) {
75
+ if (value === undefined) {
76
+ return undefined;
77
+ }
78
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
79
+ throw new Error(`${context} must be a positive integer`);
80
+ }
81
+ return value;
82
+ }
83
+ function optionalThinkingFormat(value, context) {
84
+ if (value === undefined) {
85
+ return undefined;
86
+ }
87
+ if (value === "deepseek" || value === "qwen-chat-template") {
88
+ return value;
89
+ }
90
+ throw new Error(`${context} must be deepseek or qwen-chat-template`);
91
+ }
92
+ function expandHome(value) {
93
+ const home = os.homedir();
94
+ return value === "~" || value.startsWith("~/") ? path.join(home, value.slice(2)) : value;
95
+ }
96
+ function withoutUndefined(value) {
97
+ return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
98
+ }
@@ -18,10 +18,13 @@ export function defaultOptions() {
18
18
  provider: process.env["LOCALPI_PROVIDER"],
19
19
  customProviderId: envString("LOCALPI_PROVIDER_ID", "local-openai"),
20
20
  providersFile: process.env["LOCALPI_PROVIDERS_FILE"],
21
+ modelProfileFile: process.env["LOCALPI_MODEL_PROFILE"] ?? process.env["LOCALPAGER_AGENT_PROFILE"],
22
+ modelReasoning: envOptionalBoolean("LOCALPI_MODEL_REASONING", "LOCALPAGER_AGENT_REASONING"),
23
+ modelThinkingFormat: envOptionalThinkingFormat("LOCALPI_MODEL_THINKING_FORMAT", "LOCALPAGER_AGENT_THINKING_FORMAT"),
21
24
  stateDir,
22
25
  sessionDir: defaultSessionDir(stateDir),
23
26
  piCommand: envString("LOCALPI_PI_CMD", "npx -y @earendil-works/pi-coding-agent@latest"),
24
- thinking: parseThinkingLevel(envString("LOCALPI_THINKING", "off")),
27
+ thinking: parseThinkingLevel(envString("LOCALPI_THINKING", "medium")),
25
28
  contextWindow: envOptionalPositiveInteger("LOCALPI_CONTEXT_WINDOW"),
26
29
  maxTokens: envPositiveInteger("LOCALPI_MAX_TOKENS", "8192"),
27
30
  timeoutMs: envPositiveInteger("LOCALPI_TIMEOUT_MS", "3000"),
@@ -34,6 +37,12 @@ export function defaultOptions() {
34
37
  tools: envString("LOCALPI_TOOLS", "read,bash,edit,write,grep,find,ls"),
35
38
  approval: envBoolean("LOCALPI_APPROVAL", true),
36
39
  tokenStatus: envBoolean("LOCALPI_TOKEN_STATUS", true),
40
+ demo: envBoolean("LOCALPI_DEMO", false),
41
+ demoFromCli: false,
42
+ demoInitialPrompt: process.env["LOCALPI_DEMO_INITIAL_PROMPT"],
43
+ demoInitialPromptFile: process.env["LOCALPI_DEMO_INITIAL_PROMPT_FILE"],
44
+ demoFollowupPrompt: process.env["LOCALPI_DEMO_FOLLOWUP_PROMPT"],
45
+ demoFollowupPromptFile: process.env["LOCALPI_DEMO_FOLLOWUP_PROMPT_FILE"],
37
46
  status: false,
38
47
  stop: false,
39
48
  list: false,
@@ -43,6 +52,7 @@ export function defaultOptions() {
43
52
  export function parseLocalpiArgs(args) {
44
53
  let options = defaultOptions();
45
54
  const forwardedArgs = [];
55
+ const demoPromptFlags = demoPromptFlagTracker();
46
56
  for (let index = 0; index < args.length; index += 1) {
47
57
  const arg = args[index];
48
58
  if (arg === undefined) {
@@ -55,6 +65,7 @@ export function parseLocalpiArgs(args) {
55
65
  if (arg === "-h" || arg === "--help") {
56
66
  return { ...options, forwardedArgs: ["--help"] };
57
67
  }
68
+ trackDemoPromptFlag(demoPromptFlags, arg);
58
69
  const parsed = parseLocalpiFlag(options, args, index);
59
70
  if (parsed !== undefined) {
60
71
  options = parsed.options;
@@ -63,7 +74,7 @@ export function parseLocalpiArgs(args) {
63
74
  }
64
75
  forwardedArgs.push(arg);
65
76
  }
66
- return { ...options, forwardedArgs };
77
+ return normalizeDemoPromptPrecedence({ ...options, forwardedArgs }, demoPromptFlags);
67
78
  }
68
79
  export function usage() {
69
80
  return `${[
@@ -89,8 +100,21 @@ export function usage() {
89
100
  " --chat-template <path> llama.cpp chat template file",
90
101
  " --tools <list> Pi tools allow list",
91
102
  " --providers-file <path> localpi provider registry JSON",
103
+ " --model-profile <path> local model capability profile JSON",
104
+ " --model-reasoning <bool> override generated Pi reasoning capability",
105
+ " --model-thinking-format <format>",
106
+ " override generated Pi thinking format",
92
107
  " --no-approval do not ask before tool calls",
93
108
  " --no-token-status do not install token status extension",
109
+ " --demo endlessly run Pi prompts for demo mode",
110
+ " --demo-initial-prompt <text>",
111
+ " first demo prompt",
112
+ " --demo-followup-prompt <text>",
113
+ " repeated demo prompt after the first run",
114
+ " --demo-initial-prompt-file <path>",
115
+ " UTF-8 file for the first demo prompt",
116
+ " --demo-followup-prompt-file <path>",
117
+ " UTF-8 file for repeated demo prompts",
94
118
  " --status print runtime status and exit",
95
119
  " --stop stop the localpi-owned llama-server",
96
120
  " --list list model aliases",
@@ -132,7 +156,8 @@ const booleanFlagUpdaters = {
132
156
  "--stop": (options) => ({ ...options, stop: true }),
133
157
  "--list": (options) => ({ ...options, list: true }),
134
158
  "--no-approval": (options) => ({ ...options, approval: false }),
135
- "--no-token-status": (options) => ({ ...options, tokenStatus: false })
159
+ "--no-token-status": (options) => ({ ...options, tokenStatus: false }),
160
+ "--demo": (options) => ({ ...options, demo: true, demoFromCli: true })
136
161
  };
137
162
  const valueFlagUpdaters = {
138
163
  "--runtime": (options, value) => ({ ...options, runtime: parseRuntime(value) }),
@@ -141,6 +166,12 @@ const valueFlagUpdaters = {
141
166
  "--provider": (options, value) => ({ ...options, provider: value }),
142
167
  "--provider-id": (options, value) => ({ ...options, customProviderId: value }),
143
168
  "--providers-file": (options, value) => ({ ...options, providersFile: value }),
169
+ "--model-profile": (options, value) => ({ ...options, modelProfileFile: value }),
170
+ "--model-reasoning": (options, value) => ({ ...options, modelReasoning: parseBoolean(value) }),
171
+ "--model-thinking-format": (options, value) => ({
172
+ ...options,
173
+ modelThinkingFormat: parseModelThinkingFormat(value)
174
+ }),
144
175
  "--state-dir": (options, value) => ({ ...options, stateDir: value }),
145
176
  "--session-dir": (options, value) => ({ ...options, sessionDir: value }),
146
177
  "--pi-command": (options, value) => ({ ...options, piCommand: value }),
@@ -159,7 +190,17 @@ const valueFlagUpdaters = {
159
190
  "--gpu-layers": (options, value) => ({ ...options, gpuLayers: parseNonNegativeInteger(value) }),
160
191
  "--parallel": (options, value) => ({ ...options, parallel: parsePositiveInteger(value) }),
161
192
  "--chat-template": (options, value) => ({ ...options, chatTemplate: value }),
162
- "--tools": (options, value) => ({ ...options, tools: value })
193
+ "--tools": (options, value) => ({ ...options, tools: value }),
194
+ "--demo-initial-prompt": (options, value) => ({ ...options, demoInitialPrompt: value }),
195
+ "--demo-followup-prompt": (options, value) => ({ ...options, demoFollowupPrompt: value }),
196
+ "--demo-initial-prompt-file": (options, value) => ({
197
+ ...options,
198
+ demoInitialPromptFile: value
199
+ }),
200
+ "--demo-followup-prompt-file": (options, value) => ({
201
+ ...options,
202
+ demoFollowupPromptFile: value
203
+ })
163
204
  };
164
205
  function parseValueFlag(options, args, index, flag) {
165
206
  const updater = valueFlagUpdaters[flag];
@@ -168,6 +209,37 @@ function parseValueFlag(options, args, index, flag) {
168
209
  }
169
210
  return { options: updater(options, requiredValue(args, index + 1, flag)), advance: 1 };
170
211
  }
212
+ function demoPromptFlagTracker() {
213
+ return {
214
+ initialText: false,
215
+ initialFile: false,
216
+ followupText: false,
217
+ followupFile: false
218
+ };
219
+ }
220
+ function trackDemoPromptFlag(tracker, arg) {
221
+ switch (arg) {
222
+ case "--demo-initial-prompt":
223
+ tracker.initialText = true;
224
+ return;
225
+ case "--demo-initial-prompt-file":
226
+ tracker.initialFile = true;
227
+ return;
228
+ case "--demo-followup-prompt":
229
+ tracker.followupText = true;
230
+ return;
231
+ case "--demo-followup-prompt-file":
232
+ tracker.followupFile = true;
233
+ return;
234
+ }
235
+ }
236
+ function normalizeDemoPromptPrecedence(options, tracker) {
237
+ return {
238
+ ...options,
239
+ demoInitialPromptFile: tracker.initialText && !tracker.initialFile ? undefined : options.demoInitialPromptFile,
240
+ demoFollowupPromptFile: tracker.followupText && !tracker.followupFile ? undefined : options.demoFollowupPromptFile
241
+ };
242
+ }
171
243
  function parseRuntime(value) {
172
244
  if (value === "auto" ||
173
245
  value === "llama-server" ||
@@ -186,6 +258,12 @@ export function parseThinkingLevel(value) {
186
258
  }
187
259
  throw new Error(`unknown thinking level ${value}; expected off, minimal, low, medium, high, or xhigh`);
188
260
  }
261
+ function parseModelThinkingFormat(value) {
262
+ if (value === "deepseek" || value === "qwen-chat-template") {
263
+ return value;
264
+ }
265
+ throw new Error(`unknown model thinking format ${value}; expected deepseek or qwen-chat-template`);
266
+ }
189
267
  function envString(name, fallback) {
190
268
  return process.env[name] ?? fallback;
191
269
  }
@@ -203,11 +281,22 @@ function envOptionalPositiveInteger(name) {
203
281
  const value = process.env[name];
204
282
  return value === undefined ? undefined : parsePositiveInteger(value);
205
283
  }
284
+ function envOptionalBoolean(primaryName, fallbackName) {
285
+ const [name, value] = envFirst([primaryName, fallbackName]);
286
+ return value === undefined ? undefined : parseBoolean(value, name);
287
+ }
288
+ function envOptionalThinkingFormat(primaryName, fallbackName) {
289
+ const [, value] = envFirst([primaryName, fallbackName]);
290
+ return value === undefined ? undefined : parseModelThinkingFormat(value);
291
+ }
206
292
  function envBoolean(name, fallback) {
207
293
  const value = process.env[name];
208
294
  if (value === undefined) {
209
295
  return fallback;
210
296
  }
297
+ return parseBoolean(value, name);
298
+ }
299
+ function parseBoolean(value, name = "value") {
211
300
  if (["1", "true", "yes", "on"].includes(value.toLowerCase())) {
212
301
  return true;
213
302
  }
@@ -216,6 +305,15 @@ function envBoolean(name, fallback) {
216
305
  }
217
306
  throw new Error(`${name} must be boolean-like, got ${value}`);
218
307
  }
308
+ function envFirst(names) {
309
+ for (const name of names) {
310
+ const value = process.env[name];
311
+ if (value !== undefined) {
312
+ return [name, value];
313
+ }
314
+ }
315
+ return [names[0] ?? "", undefined];
316
+ }
219
317
  function defaultSessionDir(stateDir) {
220
318
  return envString("LOCALPI_SESSION_DIR", envString("PI_CODING_AGENT_SESSION_DIR", path.join(stateDir, "sessions")));
221
319
  }
@@ -1,4 +1,4 @@
1
- import { formatCatalogWarning, managedModelSupportsReasoning } from "./catalog.js";
1
+ import { formatCatalogWarning, managedCapabilityConfig } from "./catalog.js";
2
2
  export function connectionStatus(connection) {
3
3
  return ([
4
4
  `runtime: ${connection.runtime}`,
@@ -39,9 +39,7 @@ export function catalogModelFromModelInfo(providerId, providerName, runtime, bas
39
39
  aliases: [],
40
40
  displayName: `${providerName} / ${model.id}`,
41
41
  maxTokens: options.maxTokens,
42
- ...(runtime === "managed-llama-server"
43
- ? { reasoning: managedModelSupportsReasoning(model.id) }
44
- : {}),
42
+ ...(runtime === "managed-llama-server" ? managedCapabilityConfig(model.id, options) : {}),
45
43
  capabilities: ["text"],
46
44
  availability: "loaded",
47
45
  ...optionalContextWindow(contextWindow ?? model.contextWindow)
@@ -0,0 +1,37 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { asObject, optionalString } from "../common/json.js";
4
+ import { parseThinkingLevel } from "./options.js";
5
+ export function settingsStatePath(options) {
6
+ return path.join(options.stateDir, "settings.json");
7
+ }
8
+ export async function applyRememberedSettings(options, explicit) {
9
+ const settings = await readLocalpiSettings(options);
10
+ return {
11
+ ...options,
12
+ thinking: explicit.thinking || settings.thinking === undefined ? options.thinking : settings.thinking
13
+ };
14
+ }
15
+ async function readLocalpiSettings(options) {
16
+ let raw;
17
+ try {
18
+ raw = await readFile(settingsStatePath(options), "utf8");
19
+ }
20
+ catch (error) {
21
+ if (isMissingFile(error)) {
22
+ return {};
23
+ }
24
+ throw error;
25
+ }
26
+ try {
27
+ const root = asObject(JSON.parse(raw), "localpi settings");
28
+ const thinking = optionalString(root["thinking"]);
29
+ return thinking === undefined ? {} : { thinking: parseThinkingLevel(thinking) };
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ function isMissingFile(error) {
36
+ return (error instanceof Error && "code" in error && error.code === "ENOENT");
37
+ }
@@ -0,0 +1,12 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export const defaultDemoInitialPrompt = "You are narrating a never-ending sci-fi adventure. Continue in short paragraphs. Whenever the user sends a message, treat it as a live director note and incorporate it immediately. Never end the story.";
3
+ export const defaultDemoFollowupPrompt = "Continue. Try to write as long as possible.";
4
+ export async function resolveDemoPrompts(options) {
5
+ return {
6
+ initial: await resolvePrompt(options.demoInitialPrompt, options.demoInitialPromptFile, defaultDemoInitialPrompt),
7
+ followup: await resolvePrompt(options.demoFollowupPrompt, options.demoFollowupPromptFile, defaultDemoFollowupPrompt)
8
+ };
9
+ }
10
+ async function resolvePrompt(text, file, fallback) {
11
+ return file === undefined ? (text ?? fallback) : await readFile(file, "utf8");
12
+ }
@@ -1,5 +1,7 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { settingsStatePath } from "../localpi/settings-state.js";
4
+ import { resolveDemoPrompts } from "./demo.js";
3
5
  export async function writeDefaultExtensions(options, extensionOptions = {}) {
4
6
  const extensionDir = path.join(options.stateDir, "pi-extensions");
5
7
  await mkdir(extensionDir, { recursive: true });
@@ -7,7 +9,10 @@ export async function writeDefaultExtensions(options, extensionOptions = {}) {
7
9
  if (extensionOptions.startupModelSelector !== undefined) {
8
10
  paths.push(await writeExtension(extensionDir, "startup-model-selector.ts", startupModelSelectorExtensionSource(extensionOptions.startupModelSelector)));
9
11
  }
10
- paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource()));
12
+ if (options.demo) {
13
+ paths.push(await writeExtension(extensionDir, "demo-mode.ts", demoModeExtensionSource(await resolveDemoPrompts(options))));
14
+ }
15
+ paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource(settingsStatePath(options))));
11
16
  if (options.approval) {
12
17
  paths.push(await writeExtension(extensionDir, "tool-approval.ts", approvalExtensionSource()));
13
18
  }
@@ -24,6 +29,58 @@ async function writeExtension(extensionDir, name, source) {
24
29
  await writeFile(extensionPath, source, "utf8");
25
30
  return extensionPath;
26
31
  }
32
+ function demoModeExtensionSource(prompts) {
33
+ const initialPromptSource = JSON.stringify(prompts.initial);
34
+ const followupPromptSource = JSON.stringify(prompts.followup);
35
+ return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
36
+
37
+ const initialPrompt = ${initialPromptSource};
38
+ const followupPrompt = ${followupPromptSource};
39
+
40
+ export default function localpiDemoMode(pi: ExtensionAPI): void {
41
+ let started = false;
42
+ let stopped = false;
43
+
44
+ pi.on("session_start", (event, ctx) => {
45
+ if (started || stopped || event.reason !== "startup" || ctx.mode !== "tui") {
46
+ return;
47
+ }
48
+ started = true;
49
+ queueMicrotask(() => {
50
+ if (!stopped) {
51
+ pi.sendUserMessage(initialPrompt);
52
+ }
53
+ });
54
+ });
55
+
56
+ pi.on("turn_end", (event, ctx) => {
57
+ if (!started || stopped || ctx.mode !== "tui") {
58
+ return;
59
+ }
60
+ if (event.message.role !== "assistant") {
61
+ return;
62
+ }
63
+ switch (event.message.stopReason) {
64
+ case "aborted":
65
+ case "error":
66
+ stopped = true;
67
+ return;
68
+ case "toolUse":
69
+ return;
70
+ }
71
+ queueMicrotask(() => {
72
+ if (!stopped) {
73
+ pi.sendUserMessage(followupPrompt, { deliverAs: "followUp" });
74
+ }
75
+ });
76
+ });
77
+
78
+ pi.on("session_shutdown", () => {
79
+ stopped = true;
80
+ });
81
+ }
82
+ `;
83
+ }
27
84
  function startupModelSelectorExtensionSource(options) {
28
85
  const startupModelsSource = JSON.stringify(options.models);
29
86
  return `import type { ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
@@ -290,12 +347,16 @@ function textUpdateFromUnknown(value: unknown): TextUpdate {
290
347
  }
291
348
  `;
292
349
  }
293
- function thinkingControlExtensionSource() {
294
- return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
350
+ function thinkingControlExtensionSource(settingsPath) {
351
+ const settingsPathSource = JSON.stringify(settingsPath);
352
+ return `import { mkdir, readFile, writeFile } from "node:fs/promises";
353
+ import { dirname } from "node:path";
354
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
295
355
 
296
356
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
297
357
 
298
358
  const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
359
+ const settingsPath = ${settingsPathSource};
299
360
 
300
361
  export default function localpiThinkingControl(pi: ExtensionAPI): void {
301
362
  pi.registerCommand("thinking", {
@@ -313,6 +374,7 @@ export default function localpiThinkingControl(pi: ExtensionAPI): void {
313
374
  }
314
375
  pi.setThinkingLevel(level);
315
376
  const actual = pi.getThinkingLevel();
377
+ await persistThinking(actual);
316
378
  ctx.ui.notify(
317
379
  actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`,
318
380
  actual === level ? "info" : "warning"
@@ -320,15 +382,33 @@ export default function localpiThinkingControl(pi: ExtensionAPI): void {
320
382
  }
321
383
  });
322
384
 
323
- pi.on("thinking_level_select", (event, ctx) => {
385
+ pi.on("thinking_level_select", async (event, ctx) => {
386
+ await persistThinking(event.level);
324
387
  ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`);
325
388
  });
326
389
 
327
- pi.on("session_shutdown", (_event, ctx) => {
390
+ pi.on("session_shutdown", async (_event, ctx) => {
391
+ await persistThinking(pi.getThinkingLevel());
328
392
  ctx.ui.setStatus("localpi-thinking", undefined);
329
393
  });
330
394
  }
331
395
 
396
+ async function persistThinking(level: ThinkingLevel): Promise<void> {
397
+ const settings = await readSettings();
398
+ settings.thinking = level;
399
+ await mkdir(dirname(settingsPath), { recursive: true });
400
+ await writeFile(settingsPath, \`\${JSON.stringify(settings, null, 2)}\\n\`, "utf8");
401
+ }
402
+
403
+ async function readSettings(): Promise<Record<string, unknown>> {
404
+ try {
405
+ const value = JSON.parse(await readFile(settingsPath, "utf8"));
406
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
407
+ } catch {
408
+ return {};
409
+ }
410
+ }
411
+
332
412
  async function promptThinkingLevel(
333
413
  current: ThinkingLevel,
334
414
  ctx: { readonly ui: { select(title: string, options: string[]): Promise<string | undefined> } }
@@ -24,7 +24,7 @@ Startup selection is for models only. There is no startup thinking picker.
24
24
  - Explicit `--runtime` values scope discovery but do not disable the startup selector by themselves.
25
25
  - Non-interactive runs never show a picker.
26
26
  - Pi receives the launch-time model catalog so `/model` can switch across discovered providers and models.
27
- - Thinking starts as `off` unless `--thinking` or `LOCALPI_THINKING` sets another startup level.
27
+ - Thinking starts from `--thinking`, `LOCALPI_THINKING`, the last saved Pi thinking level, or `medium`.
28
28
  - In-session thinking changes happen through `/thinking` inside Pi.
29
29
 
30
30
  ## Provider Coverage
@@ -86,9 +86,9 @@ Thinking is not selected at startup through a picker.
86
86
 
87
87
  Startup defaults:
88
88
 
89
- - `localpi` starts with thinking `off`.
90
- - `LOCALPI_THINKING=<level>` changes the startup default.
91
- - `localpi --thinking <level>` overrides the startup default.
89
+ - `localpi` starts with the last saved thinking level, or `medium` if none is saved.
90
+ - `LOCALPI_THINKING=<level>` overrides the saved startup default.
91
+ - `localpi --thinking <level>` overrides the saved startup default.
92
92
  - The chosen startup value is passed to Pi as `--thinking <level>` and written to `settings.json.defaultThinkingLevel`.
93
93
 
94
94
  In-session control:
@@ -97,6 +97,7 @@ In-session control:
97
97
  - `/thinking` opens Pi's selector UI.
98
98
  - `/thinking high` sets the level directly.
99
99
  - The extension calls Pi's thinking API, so Pi owns runtime mutation.
100
+ - The extension saves the actual Pi thinking level to localpi state for the next launch.
100
101
 
101
102
  Managed `llama-server` caveat:
102
103
 
@@ -114,6 +115,7 @@ Managed `llama-server` caveat:
114
115
  - [x] Add `/thinking` as a Pi extension command.
115
116
  - [x] Keep startup thinking non-interactive.
116
117
  - [x] Keep `--thinking` and `LOCALPI_THINKING` as automation-safe startup controls.
118
+ - [x] Remember the last Pi thinking level for future localpi launches.
117
119
  - [ ] Manually verify model picker behavior in an interactive terminal with multiple loaded providers.
118
120
  - [ ] Manually verify Pi `/model` can switch among generated catalog entries.
119
121
  - [ ] Manually verify Pi `/thinking` picker and direct `/thinking <level>` command.