localpi 0.1.1 → 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.
@@ -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.
@@ -0,0 +1,192 @@
1
+ ---
2
+ title: Endless Demo Mode Plan
3
+ author: Bob <dutifulbob@gmail.com>
4
+ date: 2026-06-18
5
+ ---
6
+
7
+ # Endless Demo Mode Plan
8
+
9
+ This plan covers an endless localpi demo mode that runs inside Pi's normal TUI and repeatedly prompts Pi until the user exits the TUI or interrupts it.
10
+
11
+ ## Goal
12
+
13
+ `localpi --demo` should run a hands-free local model demo without turning localpi into an interactive chat client and without subverting Pi's native TUI.
14
+
15
+ The demo orchestration is owned by a localpi-generated Pi extension. Rendering, streaming, tok/s display, input handling, slash commands, session state, and lifecycle remain owned by Pi.
16
+
17
+ ## Target Behavior
18
+
19
+ - `localpi --demo --model <alias|id|path>` starts Pi once with a built-in initial prompt.
20
+ - Pi opens in normal TUI mode.
21
+ - The demo extension sends the initial prompt after TUI `session_start`.
22
+ - After each completed generation, the demo extension sends the followup prompt after `turn_end`.
23
+ - The followup prompt defaults to `Continue. Try to write as long as possible.`
24
+ - The loop continues until the user exits Pi, `Ctrl-C` is pressed, or Pi stops the session normally.
25
+ - Runtime discovery, Pi config generation, extensions, thinking, tools, and approval behavior match normal localpi launches.
26
+ - Demo mode requires an explicit non-`auto` model through `--model` or `LOCALPI_MODEL`; it must not auto-select a model.
27
+ - Demo mode requires interactive TTY stdin and stdout so Pi opens its normal TUI.
28
+ - Demo mode uses one live Pi session so followup prompts keep the first prompt's context.
29
+ - Demo mode works with any provider localpi already supports: LM Studio, vLLM, generic OpenAI-compatible providers, and managed `llama-server`.
30
+ - Demo mode does not parse terminal output to detect when generation stops; it relies on Pi extension events.
31
+
32
+ ## Default Prompts
33
+
34
+ Initial prompt:
35
+
36
+ ```text
37
+ 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.
38
+ ```
39
+
40
+ Followup prompt:
41
+
42
+ ```text
43
+ Continue. Try to write as long as possible.
44
+ ```
45
+
46
+ ## CLI Contract
47
+
48
+ Add these flags:
49
+
50
+ - `--demo`: enable endless demo mode.
51
+ - `--demo-initial-prompt <text>`: override the first prompt.
52
+ - `--demo-followup-prompt <text>`: override every prompt after the first.
53
+ - `--demo-initial-prompt-file <path>`: read the first prompt from a UTF-8 file.
54
+ - `--demo-followup-prompt-file <path>`: read the followup prompt from a UTF-8 file.
55
+
56
+ Add matching environment variables:
57
+
58
+ - `LOCALPI_DEMO`
59
+ - `LOCALPI_DEMO_INITIAL_PROMPT`
60
+ - `LOCALPI_DEMO_FOLLOWUP_PROMPT`
61
+ - `LOCALPI_DEMO_INITIAL_PROMPT_FILE`
62
+ - `LOCALPI_DEMO_FOLLOWUP_PROMPT_FILE`
63
+
64
+ File flags should win over text flags for the same prompt because they are the better interface for long prompts.
65
+
66
+ Explicit CLI flags should win over environment variables.
67
+
68
+ ## Incompatible Modes
69
+
70
+ Reject these combinations with clear errors:
71
+
72
+ - `--demo --status`
73
+ - `--demo --stop`
74
+ - `--demo --list`
75
+ - `--demo` without an explicit non-`auto` model
76
+ - `--demo` without interactive TTY stdin and stdout
77
+ - `--demo` with user-supplied forwarded Pi prompt flags such as `-p` or `--prompt`
78
+
79
+ Forwarded non-prompt Pi options should remain allowed.
80
+
81
+ ## Launch Design
82
+
83
+ Keep `src/pi/launch.ts` as the single-launch layer. Demo mode should use the same launch path as a normal interactive localpi session.
84
+
85
+ Demo mode should not:
86
+
87
+ - pass prompts through stdin
88
+ - pass prompts with `-p` or `--prompt`
89
+ - force print, JSON, or RPC mode
90
+ - launch repeated one-shot Pi child processes
91
+ - create or manage a parallel localpi TUI
92
+
93
+ Normal localpi launch planning should write an additional generated extension when `--demo` is enabled, then launch Pi normally.
94
+
95
+ ## Demo Extension
96
+
97
+ Add a generated Pi extension, likely `demo-mode.ts`, alongside the existing localpi extensions.
98
+
99
+ The extension owns:
100
+
101
+ - resolving already-materialized prompt text provided by localpi
102
+ - sending the initial prompt once on `session_start` when `ctx.mode === "tui"`
103
+ - sending the followup prompt after each final assistant `turn_end` when demo mode is still active
104
+ - relying on Pi's own queueing via `pi.sendUserMessage`
105
+ - optional later controls such as `/demo stop`
106
+
107
+ Example shape:
108
+
109
+ ```ts
110
+ pi.on("session_start", (event, ctx) => {
111
+ if (started || event.reason !== "startup" || ctx.mode !== "tui") {
112
+ return;
113
+ }
114
+ started = true;
115
+ pi.sendUserMessage(initialPrompt);
116
+ });
117
+
118
+ pi.on("turn_end", (event, ctx) => {
119
+ if (!started || stopped || ctx.mode !== "tui") {
120
+ return;
121
+ }
122
+ if (event.message.role !== "assistant") {
123
+ return;
124
+ }
125
+ if (event.message.stopReason === "aborted" || event.message.stopReason === "error") {
126
+ stopped = true;
127
+ return;
128
+ }
129
+ if (event.message.stopReason === "toolUse") {
130
+ return;
131
+ }
132
+ pi.sendUserMessage(followupPrompt, { deliverAs: "followUp" });
133
+ });
134
+ ```
135
+
136
+ Prompt file loading should stay in localpi before extension generation. The generated extension should contain concrete prompt strings so Pi does not need to read localpi-specific files at runtime.
137
+
138
+ ## Signal And Exit Behavior
139
+
140
+ - Pi owns `Ctrl-C`, exit, and interactive lifecycle behavior.
141
+ - If a turn ends with an aborted or error assistant message, the demo extension should stop queueing followup prompts.
142
+ - If a turn ends with tool use, the demo extension should wait for the final assistant turn before queueing a followup.
143
+ - localpi should not restart Pi after exit.
144
+ - If Pi exits non-zero, localpi should return the same exit code as a normal launch.
145
+ - No special signal-forwarding loop should be needed beyond normal `execLaunchPlan` behavior.
146
+
147
+ ## Non-Goals
148
+
149
+ - Do not scrape stdout or terminal output to infer completion.
150
+ - Do not run demo mode as a hidden headless print-mode loop.
151
+ - Do not repeatedly spawn one-shot Pi processes.
152
+ - Do not create a second localpi-owned TUI or prompt loop.
153
+ - Do not make provider-specific demo behavior.
154
+ - Do not add classifier, benchmark, dataset, or schema workflow concepts.
155
+ - Do not keep a hidden background service running after localpi exits.
156
+
157
+ ## Testing Checklist
158
+
159
+ - [x] Parse `--demo` and all demo prompt flags.
160
+ - [x] Parse matching `LOCALPI_DEMO*` environment variables.
161
+ - [x] Verify CLI prompt values override environment values.
162
+ - [x] Verify prompt files override text prompt values.
163
+ - [x] Verify `--demo --status`, `--demo --stop`, and `--demo --list` fail clearly.
164
+ - [x] Verify demo mode rejects missing or `auto` model selection.
165
+ - [x] Verify demo mode rejects non-TTY stdin/stdout.
166
+ - [x] Verify demo mode rejects forwarded Pi prompt flags.
167
+ - [x] Unit-test that demo mode writes a generated Pi extension.
168
+ - [x] Unit-test that the generated extension sends the initial prompt on TUI `session_start`.
169
+ - [x] Unit-test that the generated extension sends followup prompts after `turn_end`.
170
+ - [x] Unit-test that the generated extension does not queue followups after tool-use continuation turns.
171
+ - [x] Unit-test that demo mode uses the normal Pi launch path and does not pipe prompts over stdin.
172
+ - [x] Unit-test that normal launches are unchanged.
173
+ - [x] Use a fake `LOCALPI_PI_CMD` to prove demo launches Pi once with the demo extension path.
174
+ - [x] Verify demo mode does not pass `-p`, `--prompt`, `--mode print`, `--mode json`, or `--mode rpc`.
175
+ - [x] Run `npm run check`.
176
+
177
+ ## Documentation Checklist
178
+
179
+ - [x] Document `--demo` in README options.
180
+ - [x] Document prompt override flags.
181
+ - [x] Document `LOCALPI_DEMO*` environment variables.
182
+ - [x] Include one simple example:
183
+
184
+ ```bash
185
+ localpi --demo --model gemma-e4b
186
+ ```
187
+
188
+ - [x] Include one override example:
189
+
190
+ ```bash
191
+ localpi --demo --model gemma-e4b --demo-initial-prompt-file ./prompts/story.txt --demo-followup-prompt "Continue. Try to write as long as possible."
192
+ ```
@@ -9,7 +9,7 @@ It should make the common local-model path one command while keeping the selecte
9
9
  - Run Pi against local open-weight models without hand-editing Pi config.
10
10
  - Discover local providers by default and select from the loaded model catalog.
11
11
  - Support LM Studio and vLLM as built-in OpenAI-compatible providers.
12
- - Keep managed `llama-server` as the fallback when no external model is loaded.
12
+ - Keep managed `llama-server` as an optional fallback when no external model is loaded.
13
13
  - Keep the tool generic: no classifier prompts, topic schemas, dataset generation, or final-schema output.
14
14
  - Keep large model memory usage predictable by managing only one localpi-owned `llama-server` process at a time.
15
15
 
@@ -23,11 +23,12 @@ Localpi:
23
23
 
24
24
  - probes built-in LM Studio and vLLM endpoints
25
25
  - loads configured OpenAI-compatible providers from `--providers-file`, `LOCALPI_PROVIDERS_FILE`, or `LOCALPI_MODELS_FILE`
26
- - includes the localpi-owned `llama-server` catalog as startable fallback entries
26
+ - includes the localpi-owned `llama-server` catalog as startable fallback entries when available
27
27
  - selects the only loaded model automatically
28
28
  - opens Pi's native model selector when multiple loaded models are available in an interactive TTY
29
29
  - never prompts in non-interactive runs; automation can pin a model with concrete `--provider` and `--model` values
30
30
  - treats `--provider` without `--model` as catalog scoping, not as a concrete model choice
31
+ - skips automatic managed `llama-server` fallback when the configured `llama-server` command is unavailable
31
32
  - writes Pi config for all launch-time loaded catalog entries so Pi `/model` can switch among them
32
33
 
33
34
  ### `llama-server`
@@ -97,6 +98,32 @@ Provider registry JSON can define additional OpenAI-compatible providers:
97
98
 
98
99
  Set `discover: false` when the endpoint should not be probed during startup. Explicit `--provider <id> --model <id>` can still select that provider and generate Pi config.
99
100
 
101
+ ## Capability Profiles
102
+
103
+ OpenAI-compatible `/v1/models` responses do not reliably report local serving capabilities such as reasoning support or Pi's required thinking request format. Localpi can read a local model capability profile with `--model-profile`, `LOCALPI_MODEL_PROFILE`, or `LOCALPAGER_AGENT_PROFILE`.
104
+
105
+ Example:
106
+
107
+ ```json
108
+ {
109
+ "id": "gemma4-26b-a4b-nvfp4",
110
+ "model": "nvidia/Gemma-4-26B-A4B-NVFP4",
111
+ "base_url": "http://127.0.0.1:8000/v1",
112
+ "client": {
113
+ "context_window": 32768,
114
+ "max_tokens": 4096
115
+ },
116
+ "capabilities": {
117
+ "reasoning": true,
118
+ "thinking_format": "qwen-chat-template"
119
+ }
120
+ }
121
+ ```
122
+
123
+ When the served model id matches `model` or `id`, localpi uses the profile to generate Pi model config. `LOCALPI_MODEL_REASONING` / `LOCALPAGER_AGENT_REASONING` and `LOCALPI_MODEL_THINKING_FORMAT` / `LOCALPAGER_AGENT_THINKING_FORMAT` are explicit overrides.
124
+
125
+ Name-based capability detection remains fallback behavior. Built-in vLLM Gemma 4 model ids are treated as reasoning-capable with `qwen-chat-template`, matching vLLM Gemma servers launched with `--reasoning-parser gemma4`.
126
+
100
127
  ## Model Selection
101
128
 
102
129
  `--model` should accept:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localpi",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Pi-compatible local model launcher with managed llama-server support.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,7 @@
48
48
  "@vitest/coverage-v8": "^3.0.0",
49
49
  "eslint": "^9.0.0",
50
50
  "prettier": "^3.0.0",
51
- "slophammer-ts": "0.3.0",
51
+ "slophammer-ts": "0.4.0",
52
52
  "tsx": "^4.19.4",
53
53
  "typescript": "^5.0.0",
54
54
  "typescript-eslint": "^8.0.0",