localpi 0.5.2 → 0.6.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.
@@ -1,5 +1,10 @@
1
1
  import path from "node:path";
2
2
  import { normalizeBaseUrl } from "../llm/openai.js";
3
+ export const statsModes = ["off", "line", "full"];
4
+ export const skillsModes = ["own", "ambient", "off"];
5
+ export const permissionModes = ["ask", "allow"];
6
+ // localpi launches Pi through npx, so a normal launch always runs the newest Pi release.
7
+ const defaultPiCommand = "npx -y @earendil-works/pi-coding-agent@latest";
3
8
  export const thinkingLevels = [
4
9
  "off",
5
10
  "minimal",
@@ -23,10 +28,13 @@ export function defaultOptions() {
23
28
  modelThinkingFormat: envOptionalThinkingFormat("LOCALPI_MODEL_THINKING_FORMAT", "LOCALPAGER_AGENT_THINKING_FORMAT"),
24
29
  stateDir,
25
30
  sessionDir: defaultSessionDir(stateDir),
26
- piCommand: envString("LOCALPI_PI_CMD", "npx -y @earendil-works/pi-coding-agent@latest"),
31
+ piCommand: parsePiCommand(envString("LOCALPI_PI_CMD", defaultPiCommand)),
27
32
  thinking: parseThinkingLevel(envString("LOCALPI_THINKING", "medium")),
33
+ thinkingBudget: envOptionalThinkingBudget("LOCALPI_THINKING_BUDGET"),
34
+ thinkingBudgetMessage: process.env["LOCALPI_THINKING_BUDGET_MESSAGE"],
28
35
  contextWindow: envOptionalPositiveInteger("LOCALPI_CONTEXT_WINDOW"),
29
36
  maxTokens: envPositiveInteger("LOCALPI_MAX_TOKENS", "8192"),
37
+ continueOnTruncation: envContinuationLimit("LOCALPI_CONTINUE_ON_TRUNCATION"),
30
38
  timeoutMs: envPositiveInteger("LOCALPI_TIMEOUT_MS", "3000"),
31
39
  serverCommand: envString("LOCALPI_LLAMA_SERVER", "llama-server"),
32
40
  host: envString("LOCALPI_HOST", "127.0.0.1"),
@@ -36,13 +44,17 @@ export function defaultOptions() {
36
44
  chatTemplate: process.env["LOCALPI_CHAT_TEMPLATE"],
37
45
  tools: envString("LOCALPI_TOOLS", "read,bash,edit,write,grep,find,ls"),
38
46
  approval: envBoolean("LOCALPI_APPROVAL", true),
39
- tokenStatus: envBoolean("LOCALPI_TOKEN_STATUS", true),
47
+ approveReadTools: envBoolean("LOCALPI_APPROVE_READ_TOOLS", false),
48
+ stats: defaultStatsMode(),
49
+ skills: parseSkillsMode(envString("LOCALPI_SKILLS", "own")),
40
50
  demo: envBoolean("LOCALPI_DEMO", false),
41
51
  demoFromCli: false,
42
52
  demoInitialPrompt: process.env["LOCALPI_DEMO_INITIAL_PROMPT"],
43
53
  demoInitialPromptFile: process.env["LOCALPI_DEMO_INITIAL_PROMPT_FILE"],
44
54
  demoFollowupPrompt: process.env["LOCALPI_DEMO_FOLLOWUP_PROMPT"],
45
55
  demoFollowupPromptFile: process.env["LOCALPI_DEMO_FOLLOWUP_PROMPT_FILE"],
56
+ acp: envBoolean("LOCALPI_ACP", false),
57
+ acpFromCli: false,
46
58
  status: false,
47
59
  stop: false,
48
60
  list: false,
@@ -84,13 +96,16 @@ export function usage() {
84
96
  " localpi [localpi options] [pi options/messages]",
85
97
  "",
86
98
  "localpi options:",
87
- " --runtime <kind> auto, llama-server, lmstudio, vllm, or openai-compatible",
99
+ " --runtime <kind> auto, llama-server, llama-cpp, lmstudio, vllm, or openai-compatible",
88
100
  " --provider <id> catalog provider id to use",
89
101
  " --model <alias|id|path> model alias, backend id, or GGUF path",
90
102
  " --base-url <url> OpenAI-compatible endpoint",
91
103
  " --ctx <n> model context window",
92
104
  " --context-window <n> alias for --ctx",
93
105
  " --max-tokens <n> generated model max output tokens",
106
+ " --continue-on-truncation <n>",
107
+ " continue a reply cut off by the output limit, up to n times",
108
+ " (LOCALPI_CONTINUE_ON_TRUNCATION=<n>)",
94
109
  " --server-command <path> llama-server executable",
95
110
  " --llama-server <path> alias for --server-command",
96
111
  " --host <host> managed llama-server host",
@@ -99,13 +114,18 @@ export function usage() {
99
114
  " --parallel <n> llama-server parallel slots",
100
115
  " --chat-template <path> llama.cpp chat template file",
101
116
  " --tools <list> Pi tools allow list",
117
+ " --stats <mode> stats: off, line, or full (default: full)",
118
+ " --skills <mode> skills: own, ambient, or off (default: own)",
102
119
  " --providers-file <path> localpi provider registry JSON",
103
120
  " --model-profile <path> local model capability profile JSON",
104
121
  " --model-reasoning <bool> override generated Pi reasoning capability",
105
122
  " --model-thinking-format <format>",
106
123
  " override generated Pi thinking format",
107
- " --no-approval do not ask before tool calls",
108
- " --no-token-status do not install token status extension",
124
+ " --no-approval start with tool approval off for this session",
125
+ " --approve-read-tools also ask before read-only tools (read, grep, find, ls)",
126
+ " --no-token-status alias for --stats off",
127
+ " --acp serve ACP on stdio through the pinned pi-acp adapter",
128
+ " (LOCALPI_ACP=1); requires an explicit --model",
109
129
  " --demo endlessly run Pi prompts for demo mode",
110
130
  " --demo-initial-prompt <text>",
111
131
  " first demo prompt",
@@ -120,8 +140,11 @@ export function usage() {
120
140
  " --list list model aliases",
121
141
  " --state-dir <path> localpi runtime state directory",
122
142
  " --session-dir <path> Pi session directory",
123
- " --pi-command <command> Pi launch command",
143
+ " --pi-command <command> Pi launch command, split on whitespace and quotes",
124
144
  " --thinking <level> thinking level: off, minimal, low, medium, high, xhigh",
145
+ " --thinking-budget <n> managed llama-server thinking cap in tokens, -1 for unrestricted",
146
+ " --thinking-budget-message <text>",
147
+ " text before the end-of-thinking tag; empty text passes none",
125
148
  " --timeout-ms <n> backend probe timeout",
126
149
  " -h, --help show this help",
127
150
  "",
@@ -156,7 +179,11 @@ const booleanFlagUpdaters = {
156
179
  "--stop": (options) => ({ ...options, stop: true }),
157
180
  "--list": (options) => ({ ...options, list: true }),
158
181
  "--no-approval": (options) => ({ ...options, approval: false }),
159
- "--no-token-status": (options) => ({ ...options, tokenStatus: false }),
182
+ "--approve-read-tools": (options) => ({ ...options, approveReadTools: true }),
183
+ "--no-token-status": (options) => ({ ...options, stats: "off" }),
184
+ "--no-skills": (options) => ({ ...options, skills: "off" }),
185
+ "-ns": (options) => ({ ...options, skills: "off" }),
186
+ "--acp": (options) => ({ ...options, acp: true, acpFromCli: true }),
160
187
  "--demo": (options) => ({ ...options, demo: true, demoFromCli: true })
161
188
  };
162
189
  const valueFlagUpdaters = {
@@ -174,14 +201,28 @@ const valueFlagUpdaters = {
174
201
  }),
175
202
  "--state-dir": (options, value) => ({ ...options, stateDir: value }),
176
203
  "--session-dir": (options, value) => ({ ...options, sessionDir: value }),
177
- "--pi-command": (options, value) => ({ ...options, piCommand: value }),
204
+ "--pi-command": (options, value) => ({ ...options, piCommand: parsePiCommand(value) }),
178
205
  "--thinking": (options, value) => ({ ...options, thinking: parseThinkingLevel(value) }),
206
+ "--thinking-budget": (options, value) => ({
207
+ ...options,
208
+ thinkingBudget: parseThinkingBudget(value)
209
+ }),
210
+ "--thinking-budget-message": (options, value) => ({
211
+ ...options,
212
+ thinkingBudgetMessage: value
213
+ }),
214
+ "--stats": (options, value) => ({ ...options, stats: parseStatsMode(value) }),
215
+ "--skills": (options, value) => ({ ...options, skills: parseSkillsMode(value) }),
179
216
  "--ctx": (options, value) => ({ ...options, contextWindow: parsePositiveInteger(value) }),
180
217
  "--context-window": (options, value) => ({
181
218
  ...options,
182
219
  contextWindow: parsePositiveInteger(value)
183
220
  }),
184
221
  "--max-tokens": (options, value) => ({ ...options, maxTokens: parsePositiveInteger(value) }),
222
+ "--continue-on-truncation": (options, value) => ({
223
+ ...options,
224
+ continueOnTruncation: parsePositiveInteger(value)
225
+ }),
185
226
  "--timeout-ms": (options, value) => ({ ...options, timeoutMs: parsePositiveInteger(value) }),
186
227
  "--server-command": (options, value) => ({ ...options, serverCommand: value }),
187
228
  "--llama-server": (options, value) => ({ ...options, serverCommand: value }),
@@ -243,12 +284,13 @@ function normalizeDemoPromptPrecedence(options, tracker) {
243
284
  function parseRuntime(value) {
244
285
  if (value === "auto" ||
245
286
  value === "llama-server" ||
287
+ value === "llama-cpp" ||
246
288
  value === "lmstudio" ||
247
289
  value === "vllm" ||
248
290
  value === "openai-compatible") {
249
291
  return value;
250
292
  }
251
- throw new Error(`unknown runtime ${value}; expected auto, llama-server, lmstudio, vllm, or openai-compatible`);
293
+ throw new Error(`unknown runtime ${value}; expected auto, llama-server, llama-cpp, lmstudio, vllm, or openai-compatible`);
252
294
  }
253
295
  export function parseThinkingLevel(value) {
254
296
  for (const level of thinkingLevels) {
@@ -258,6 +300,66 @@ export function parseThinkingLevel(value) {
258
300
  }
259
301
  throw new Error(`unknown thinking level ${value}; expected off, minimal, low, medium, high, or xhigh`);
260
302
  }
303
+ /**
304
+ * A thinking budget is -1 for unrestricted thinking, or a positive token count. Zero is rejected,
305
+ * because the managed server reads it as an immediate end of thinking.
306
+ */
307
+ export function parseThinkingBudget(value) {
308
+ if (/^[1-9]\d*$/u.test(value)) {
309
+ return Number.parseInt(value, 10);
310
+ }
311
+ if (value === "-1") {
312
+ return -1;
313
+ }
314
+ throw new Error(`unknown thinking budget ${value}; expected -1 or a positive integer`);
315
+ }
316
+ export function parseStatsMode(value) {
317
+ for (const mode of statsModes) {
318
+ if (value === mode) {
319
+ return mode;
320
+ }
321
+ }
322
+ throw new Error(`unknown stats mode ${value}; expected off, line, or full`);
323
+ }
324
+ export function parseSkillsMode(value) {
325
+ for (const mode of skillsModes) {
326
+ if (value === mode) {
327
+ return mode;
328
+ }
329
+ }
330
+ throw new Error(`unknown skills mode ${value}; expected own, ambient, or off`);
331
+ }
332
+ export function parsePermissionMode(value) {
333
+ for (const mode of permissionModes) {
334
+ if (value === mode) {
335
+ return mode;
336
+ }
337
+ }
338
+ throw new Error(`unknown permission mode ${value}; expected ask or allow`);
339
+ }
340
+ /**
341
+ * Split a Pi launch command into a program and its arguments. Quotes group words, so a quoted
342
+ * argument stays one piece.
343
+ */
344
+ export function parsePiCommand(value) {
345
+ const parts = (value.match(/"[^"]*"|'[^']*'|\S+/gu) ?? []).map(unquotePiCommandPart);
346
+ if (parts.length === 0) {
347
+ throw new Error("Pi launch command must not be empty");
348
+ }
349
+ return parts;
350
+ }
351
+ function unquotePiCommandPart(part) {
352
+ const quote = part[0];
353
+ const quoted = part.length > 1 && (quote === '"' || quote === "'");
354
+ return quoted && part.endsWith(quote) ? part.slice(1, -1) : part;
355
+ }
356
+ function defaultStatsMode() {
357
+ const explicit = process.env["LOCALPI_STATS"];
358
+ if (explicit !== undefined) {
359
+ return parseStatsMode(explicit);
360
+ }
361
+ return envBoolean("LOCALPI_TOKEN_STATUS", true) ? "full" : "off";
362
+ }
261
363
  function parseModelThinkingFormat(value) {
262
364
  if (value === "deepseek" || value === "qwen-chat-template") {
263
365
  return value;
@@ -281,6 +383,24 @@ function envOptionalPositiveInteger(name) {
281
383
  const value = process.env[name];
282
384
  return value === undefined ? undefined : parsePositiveInteger(value);
283
385
  }
386
+ /**
387
+ * The continuation limit allows zero, because zero turns the feature off. An inherited
388
+ * environment value must be disableable without dropping the variable.
389
+ */
390
+ function envContinuationLimit(name) {
391
+ const value = process.env[name];
392
+ if (value === undefined) {
393
+ return 0;
394
+ }
395
+ if (!/^(0|[1-9]\d*)$/u.test(value)) {
396
+ throw new Error(`${name} must be a nonnegative integer, got ${value}`);
397
+ }
398
+ return Number.parseInt(value, 10);
399
+ }
400
+ function envOptionalThinkingBudget(name) {
401
+ const value = process.env[name];
402
+ return value === undefined ? undefined : parseThinkingBudget(value);
403
+ }
284
404
  function envOptionalBoolean(primaryName, fallbackName) {
285
405
  const [name, value] = envFirst([primaryName, fallbackName]);
286
406
  return value === undefined ? undefined : parseBoolean(value, name);
@@ -12,6 +12,8 @@ export async function providerConfigs(options) {
12
12
  return [lmStudioProvider(options.baseUrl)];
13
13
  case "vllm":
14
14
  return [vllmProvider(options.baseUrl)];
15
+ case "llama-cpp":
16
+ return [llamaCppProvider(options.baseUrl)];
15
17
  case "openai-compatible": {
16
18
  const providerId = options.provider ?? options.customProviderId;
17
19
  return [
@@ -31,6 +33,7 @@ export async function providerConfigs(options) {
31
33
  function autoProviderConfigs(options, configured) {
32
34
  const managedBaseUrl = llamaBaseUrl(options);
33
35
  return dedupeProviderConfigs([
36
+ llamaCppProvider(),
34
37
  lmStudioProvider(),
35
38
  vllmProvider(),
36
39
  ...configured,
@@ -60,6 +63,50 @@ function vllmProvider(baseUrl = "http://127.0.0.1:8000/v1") {
60
63
  discover: true
61
64
  };
62
65
  }
66
+ function llamaCppProvider(baseUrl = defaultLlamaCppBaseUrl()) {
67
+ return {
68
+ id: "llama-cpp",
69
+ name: "llama.cpp",
70
+ type: "llama-cpp",
71
+ baseUrl: normalizeBaseUrl(baseUrl),
72
+ discover: true
73
+ };
74
+ }
75
+ // llama.cpp is localpi's default engine. Its endpoint is probed first so a loaded
76
+ // llama.cpp model wins the automatic selection ahead of other local engines.
77
+ export function defaultLlamaCppBaseUrl() {
78
+ return "http://127.0.0.1:8080/v1";
79
+ }
80
+ // Known engines for the built-in openai-compatible providers. A configured openai-compatible
81
+ // provider gets no entry, because its engine is unknown.
82
+ const builtInEngineLabels = {
83
+ lmstudio: "LM Studio",
84
+ vllm: "vLLM"
85
+ };
86
+ /**
87
+ * Map localpi providers to the inference engine that serves them. Only providers whose engine is
88
+ * known appear in the result, so callers never show a guessed engine name.
89
+ */
90
+ export function engineEntries(providers) {
91
+ const engines = new Map();
92
+ for (const provider of providers) {
93
+ const engine = engineLabel(provider);
94
+ if (engine !== undefined && !engines.has(provider.id)) {
95
+ engines.set(provider.id, engine);
96
+ }
97
+ }
98
+ return [...engines].map(([provider, engine]) => ({ provider, engine }));
99
+ }
100
+ function engineLabel(provider) {
101
+ switch (provider.type) {
102
+ case "llama-cpp":
103
+ return "llama.cpp";
104
+ case "managed-llama-server":
105
+ return "llama-server";
106
+ case "openai-compatible":
107
+ return builtInEngineLabels[provider.id];
108
+ }
109
+ }
63
110
  function managedLlamaProvider() {
64
111
  return {
65
112
  id: "llama-server",
@@ -84,10 +131,11 @@ async function configuredProviderConfigs(options) {
84
131
  function configuredProvider(id, value) {
85
132
  const entry = asObject(value, `provider ${id}`);
86
133
  const type = optionalString(entry["type"]);
87
- if (type !== "openai-compatible") {
88
- throw new Error(`provider ${id} type must be openai-compatible`);
134
+ if (type !== "openai-compatible" && type !== "llama-cpp") {
135
+ throw new Error(`provider ${id} type must be openai-compatible or llama-cpp`);
89
136
  }
90
- const baseUrl = optionalString(entry["baseUrl"]);
137
+ const baseUrl = optionalString(entry["baseUrl"]) ??
138
+ (type === "llama-cpp" ? defaultLlamaCppBaseUrl() : undefined);
91
139
  if (baseUrl === undefined) {
92
140
  throw new Error(`provider ${id} must define baseUrl`);
93
141
  }
@@ -1,13 +1,14 @@
1
+ import { paint } from "./catppuccin.js";
1
2
  import { formatCatalogWarning, managedCapabilityConfig } from "./catalog.js";
2
3
  export function connectionStatus(connection) {
3
4
  return ([
4
- `runtime: ${connection.runtime}`,
5
- `provider: ${connection.providerId}`,
6
- `base url: ${connection.baseUrl}`,
7
- `model: ${connection.model}`,
8
- `available models: ${connection.availableModels.join(", ")}`,
9
- `context window: ${String(connection.contextWindow ?? "unspecified")}`,
10
- ...connection.warnings.map((warning) => `warning: ${warning}`)
5
+ `${paint("runtime:", "overlay1")} ${connection.runtime}`,
6
+ `${paint("provider:", "overlay1")} ${connection.providerId}`,
7
+ `${paint("base url:", "overlay1")} ${connection.baseUrl}`,
8
+ `${paint("model:", "overlay1")} ${connection.model}`,
9
+ `${paint("available models:", "overlay1")} ${connection.availableModels.join(", ")}`,
10
+ `${paint("context window:", "overlay1")} ${String(connection.contextWindow ?? "unspecified")}`,
11
+ ...connection.warnings.map((warning) => `${paint("warning:", "peach")} ${warning}`)
11
12
  ].join("\n") + "\n");
12
13
  }
13
14
  export function statusModelList(models) {
@@ -67,7 +68,9 @@ function connectionRuntimeName(selected) {
67
68
  if (selected.runtime === "managed-llama-server") {
68
69
  return "llama-server";
69
70
  }
70
- return selected.providerId === "lmstudio" || selected.providerId === "vllm"
71
+ return selected.providerId === "lmstudio" ||
72
+ selected.providerId === "vllm" ||
73
+ selected.providerId === "llama-cpp"
71
74
  ? selected.providerId
72
75
  : selected.runtime;
73
76
  }
@@ -1,8 +1,10 @@
1
1
  import { discoverModelCatalog, formatCatalogWarning } from "./catalog.js";
2
+ import { paint } from "./catppuccin.js";
2
3
  import { llamaBaseUrl, llamaServerStatus, stopManagedLlamaServer } from "./llama-server.js";
3
4
  import { listModelAliases } from "./models.js";
4
5
  import { catalogRuntimeConnection, connectionStatus, statusModelList } from "./runtime-connection.js";
5
6
  import { resolveLlamaRuntime, resolveSelectedLlamaRuntime } from "./managed-runtime.js";
7
+ import { defaultLlamaCppBaseUrl } from "./provider-registry.js";
6
8
  import { selectCatalogModel } from "./runtime-selection.js";
7
9
  export { connectionStatus } from "./runtime-connection.js";
8
10
  export async function resolveRuntime(options) {
@@ -12,10 +14,9 @@ export async function resolveRuntime(options) {
12
14
  switch (options.runtime) {
13
15
  case "llama-server":
14
16
  return resolveLlamaRuntime(options);
17
+ case "llama-cpp":
15
18
  case "lmstudio":
16
- return resolveCatalogRuntime(options);
17
19
  case "vllm":
18
- return resolveCatalogRuntime(options);
19
20
  case "openai-compatible":
20
21
  return resolveCatalogRuntime(options);
21
22
  }
@@ -23,6 +24,7 @@ export async function resolveRuntime(options) {
23
24
  export async function stopRuntime(options) {
24
25
  if (options.runtime === "lmstudio" ||
25
26
  options.runtime === "vllm" ||
27
+ options.runtime === "llama-cpp" ||
26
28
  options.runtime === "openai-compatible") {
27
29
  return `runtime ${options.runtime} is externally managed; nothing stopped`;
28
30
  }
@@ -72,10 +74,10 @@ async function catalogStatusOutput(options) {
72
74
  const loaded = catalog.models.filter((model) => model.availability === "loaded");
73
75
  const startable = catalog.models.filter((model) => model.availability === "startable");
74
76
  return ([
75
- `runtime: ${options.runtime}`,
76
- `loaded models: ${statusModelList(loaded)}`,
77
- `startable models: ${statusModelList(startable)}`,
78
- ...catalog.warnings.map((warning) => `warning: ${formatCatalogWarning(warning)}`)
77
+ `${paint("runtime:", "overlay1")} ${options.runtime}`,
78
+ `${paint("loaded models:", "overlay1")} ${statusModelList(loaded)}`,
79
+ `${paint("startable models:", "overlay1")} ${statusModelList(startable)}`,
80
+ ...catalog.warnings.map((warning) => `${paint("warning:", "peach")} ${formatCatalogWarning(warning)}`)
79
81
  ].join("\n") + "\n");
80
82
  }
81
83
  function requiredOpenAiBaseUrl(options) {
@@ -85,5 +87,8 @@ function requiredOpenAiBaseUrl(options) {
85
87
  return options.baseUrl;
86
88
  }
87
89
  function defaultExternalBaseUrl(runtime) {
88
- return runtime === "vllm" ? "http://127.0.0.1:8000/v1" : "http://127.0.0.1:1234/v1";
90
+ if (runtime === "vllm") {
91
+ return "http://127.0.0.1:8000/v1";
92
+ }
93
+ return runtime === "llama-cpp" ? defaultLlamaCppBaseUrl() : "http://127.0.0.1:1234/v1";
89
94
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { asObject, optionalString } from "../common/json.js";
4
- import { parseThinkingLevel } from "./options.js";
4
+ import { parsePermissionMode, parseStatsMode, parseThinkingLevel } from "./options.js";
5
5
  export function localpiSettingsPath(options) {
6
6
  return path.join(options.stateDir, "settings.json");
7
7
  }
@@ -9,7 +9,11 @@ export async function applyRememberedSettings(options, explicit) {
9
9
  const settings = await readLocalpiSettings(options);
10
10
  return {
11
11
  ...options,
12
- thinking: explicit.thinking || settings.thinking === undefined ? options.thinking : settings.thinking
12
+ thinking: explicit.thinking || settings.thinking === undefined ? options.thinking : settings.thinking,
13
+ stats: explicit.stats || settings.stats === undefined ? options.stats : settings.stats,
14
+ approval: explicit.permission || settings.permission === undefined
15
+ ? options.approval
16
+ : settings.permission === "ask"
13
17
  };
14
18
  }
15
19
  async function readLocalpiSettings(options) {
@@ -26,7 +30,13 @@ async function readLocalpiSettings(options) {
26
30
  try {
27
31
  const root = asObject(JSON.parse(raw), "localpi settings");
28
32
  const thinking = optionalString(root["thinking"]);
29
- return thinking === undefined ? {} : { thinking: parseThinkingLevel(thinking) };
33
+ const stats = optionalString(root["stats"]);
34
+ const permission = optionalString(root["permission"]);
35
+ return {
36
+ ...(thinking === undefined ? {} : { thinking: parseThinkingLevel(thinking) }),
37
+ ...(stats === undefined ? {} : { stats: parseStatsMode(stats) }),
38
+ ...(permission === undefined ? {} : { permission: parsePermissionMode(permission) })
39
+ };
30
40
  }
31
41
  catch {
32
42
  return {};
@@ -1,3 +1,5 @@
1
+ import { localpiThemeArgs } from "./theme.js";
2
+ import { localpiSkillsArgs } from "./skills.js";
1
3
  import { localpiVersion } from "./version.js";
2
4
  const localpiAppIdentity = {
3
5
  id: "localpi",
@@ -16,11 +18,11 @@ const demoLaunchOverrideBooleanFlags = new Set([
16
18
  ]);
17
19
  const demoLaunchOverrideValueFlags = new Set(["--tools", "-t", "--exclude-tools", "-xt"]);
18
20
  const demoLaunchOverrideEqualsFlags = ["--tools=", "--exclude-tools="];
19
- export function createLocalpiAppDefinition(options, connection, extensions) {
21
+ export function createLocalpiAppDefinition(options, connection, extensions, themePath) {
20
22
  return {
21
23
  ...localpiAppIdentity,
22
24
  ...appDirectories(options),
23
- ...piCommand(options),
25
+ ...piCommand(options, themePath),
24
26
  ...runtimeSelection(options, connection),
25
27
  ...extensionConfig(extensions)
26
28
  };
@@ -31,10 +33,14 @@ function appDirectories(options) {
31
33
  sessionDir: options.sessionDir
32
34
  };
33
35
  }
34
- function piCommand(options) {
36
+ function piCommand(options, themePath) {
35
37
  return {
36
38
  piCommand: options.piCommand,
37
- forwardedArgs: options.demo ? demoForwardedArgs(options.forwardedArgs) : options.forwardedArgs
39
+ forwardedArgs: [
40
+ ...localpiSkillsArgs(options.skills, options.stateDir),
41
+ ...localpiThemeArgs(themePath, options.forwardedArgs),
42
+ ...(options.demo ? demoForwardedArgs(options.forwardedArgs) : options.forwardedArgs)
43
+ ]
38
44
  };
39
45
  }
40
46
  function demoForwardedArgs(args) {
@@ -105,7 +111,7 @@ function modelDefinition(options, model) {
105
111
  name: model.displayName,
106
112
  reasoning: model.reasoning ?? false,
107
113
  ...(model.thinkingFormat === undefined ? {} : { thinkingFormat: model.thinkingFormat }),
108
- input: ["text"],
114
+ input: model.capabilities.includes("image") ? ["text", "image"] : ["text"],
109
115
  ...(contextWindow === undefined ? {} : { contextWindow }),
110
116
  ...(model.maxTokens === undefined
111
117
  ? { maxTokens: options.maxTokens }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Continue a reply that the model cut off at the declared output limit.
3
+ *
4
+ * Pi ends the agent run when a turn stops at the output cap without asking for a tool, so a
5
+ * half-written reply becomes the whole answer. This extension asks the model to continue after
6
+ * such a turn, at most `limit` times per session. The limit lives in the generated source, so the
7
+ * extension carries no extra environment variable into the Pi child.
8
+ */
9
+ export function continueOnTruncationExtensionSource(limit) {
10
+ return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+
12
+ const limit = ${String(limit)};
13
+
14
+ const nudge = [
15
+ "You hit the output limit before finishing.",
16
+ "Continue from where you stopped.",
17
+ "Do not repeat earlier text. Take the next tool call or write the answer."
18
+ ].join(" ");
19
+
20
+ export default function localpiContinueOnTruncation(pi: ExtensionAPI): void {
21
+ let continued = 0;
22
+ let limitReported = false;
23
+
24
+ pi.on("session_start", () => {
25
+ continued = 0;
26
+ limitReported = false;
27
+ });
28
+
29
+ pi.on("turn_end", (event) => {
30
+ if (event.message.role !== "assistant" || event.message.stopReason !== "length") {
31
+ return;
32
+ }
33
+ // A turn that already asked for tools keeps running on its own.
34
+ if (event.toolResults.length > 0) {
35
+ return;
36
+ }
37
+ if (continued >= limit) {
38
+ if (!limitReported) {
39
+ limitReported = true;
40
+ const plural = continued === 1 ? "" : "s";
41
+ process.stderr.write(
42
+ \`localpi: reply was cut off again after \${continued} continuation\${plural}; stopping now\\n\`
43
+ );
44
+ }
45
+ return;
46
+ }
47
+ continued += 1;
48
+ process.stderr.write(
49
+ \`localpi: reply was cut off by the output limit; continuing (\${continued}/\${limit})\\n\`
50
+ );
51
+ pi.sendUserMessage(nudge, { deliverAs: "followUp" });
52
+ });
53
+ }
54
+ `;
55
+ }
@@ -0,0 +1,31 @@
1
+ // Generated Pi extensions are standalone files, so they cannot import from each other. Both the
2
+ // stats extension and the approval extension read and write the localpi settings file, so they
3
+ // embed this one snippet instead of keeping two copies of the same code.
4
+ //
5
+ // The embedding extension must import `mkdir`, `readFile`, and `writeFile` from `node:fs/promises`
6
+ // and `dirname` from `node:path`.
7
+ export function settingsFileSource(settingsPath) {
8
+ const settingsPathSource = JSON.stringify(settingsPath);
9
+ return `const settingsPath = ${settingsPathSource};
10
+
11
+ async function readSettings(): Promise<Record<string, unknown>> {
12
+ try {
13
+ const parsed: unknown = JSON.parse(await readFile(settingsPath, "utf8"));
14
+ return settingsRecord(parsed) ?? {};
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ async function writeSettings(settings: Record<string, unknown>): Promise<void> {
21
+ await mkdir(dirname(settingsPath), { recursive: true });
22
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2), "utf8");
23
+ }
24
+
25
+ function settingsRecord(value: unknown): Record<string, unknown> | undefined {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value)
27
+ ? (value as Record<string, unknown>)
28
+ : undefined;
29
+ }
30
+ `;
31
+ }