localpi 0.5.2 → 0.6.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 +377 -10
- package/dist/src/cli/cli.js +112 -5
- package/dist/src/llm/openai.js +65 -4
- package/dist/src/localpi/acp.js +118 -0
- package/dist/src/localpi/catalog.js +79 -7
- package/dist/src/localpi/catppuccin.js +64 -0
- package/dist/src/localpi/llama-server.js +72 -39
- package/dist/src/localpi/model-profile.js +4 -0
- package/dist/src/localpi/options.js +129 -9
- package/dist/src/localpi/provider-registry.js +51 -3
- package/dist/src/localpi/runtime-connection.js +11 -8
- package/dist/src/localpi/runtime.js +12 -7
- package/dist/src/localpi/settings-state.js +13 -3
- package/dist/src/pi/app.js +11 -5
- package/dist/src/pi/extension-sources/continue-on-truncation.js +55 -0
- package/dist/src/pi/extension-sources/settings-file.js +31 -0
- package/dist/src/pi/extension-sources/startup-model-selector.js +18 -23
- package/dist/src/pi/extension-sources/status-line.js +424 -0
- package/dist/src/pi/extension-sources/thinking-control.js +4 -47
- package/dist/src/pi/extension-sources/token-status.js +544 -119
- package/dist/src/pi/extension-sources/tool-approval.js +155 -14
- package/dist/src/pi/extensions.js +37 -10
- package/dist/src/pi/skills.js +24 -0
- package/dist/src/pi/theme.js +107 -0
- package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +39 -11
- package/docs/2026-09-23-acp-mode-plan.md +111 -0
- package/docs/2026-09-24-continue-on-truncation-plan.md +115 -0
- package/docs/design-principles.md +114 -0
- package/docs/implementation-plan.md +33 -0
- package/docs/runtime-specification.md +98 -4
- package/package.json +4 -2
|
@@ -1,45 +1,186 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { settingsFileSource } from "./settings-file.js";
|
|
2
|
+
export function approvalExtensionSource(config) {
|
|
3
|
+
const initialEnabledSource = JSON.stringify(config.enabled);
|
|
4
|
+
const approveReadToolsSource = JSON.stringify(config.approveReadTools);
|
|
5
|
+
return `import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
type PermissionMode = "ask" | "allow";
|
|
10
|
+
|
|
11
|
+
type CommandContext = {
|
|
12
|
+
readonly hasUI: boolean;
|
|
13
|
+
readonly ui: {
|
|
14
|
+
setStatus(key: string, text: string | undefined): void;
|
|
15
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
16
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const approvalRule =
|
|
21
|
+
"\\n\\nTool approval rule: if any tool result says the tool was blocked, denied, or requires approval, the tool did not run. Do not claim blocked tools ran.";
|
|
22
|
+
const modes: readonly PermissionMode[] = ["ask", "allow"];
|
|
23
|
+
const aliases: readonly string[] = ["on", "off"];
|
|
24
|
+
// Read-only Pi tools cannot change the workspace, so the gate lets them run. bash is not in this
|
|
25
|
+
// list, because a bash command can write. An unknown tool also stays behind the gate.
|
|
26
|
+
const readOnlyTools: readonly string[] = ["read", "grep", "find", "ls"];
|
|
27
|
+
const statusKey = "localpi-approval";
|
|
28
|
+
const allowOnce = "Allow once";
|
|
29
|
+
const allowSession = "Allow all tools for this session";
|
|
30
|
+
const deny = "Deny and stop";
|
|
31
|
+
const blockedReason = "Tool call was blocked by the user and did not run.";
|
|
32
|
+
const noUiReason = " was blocked and did not run because interactive approval is required.";
|
|
33
|
+
// A deny stops the turn instead of letting the model try again. Pi terminates the turn when every
|
|
34
|
+
// blocked result in the tool batch asks for it, and a cancelled dialog counts as a deny.
|
|
35
|
+
// The permission setting is the launch default for new sessions. The /approval command writes it;
|
|
36
|
+
// the session-only choice in the tool call dialog never writes it.
|
|
37
|
+
${settingsFileSource(config.settingsPath)}
|
|
38
|
+
const initialEnabled: boolean = ${initialEnabledSource};
|
|
39
|
+
const gateReadTools: boolean = ${approveReadToolsSource};
|
|
3
40
|
|
|
4
41
|
export default function localpiToolApproval(pi: ExtensionAPI): void {
|
|
42
|
+
let enabled = initialEnabled;
|
|
43
|
+
|
|
5
44
|
pi.on("before_agent_start", (event) => ({
|
|
6
|
-
systemPrompt:
|
|
7
|
-
event.systemPrompt +
|
|
8
|
-
"\\n\\nTool approval rule: if any tool result says the tool was blocked, denied, or requires approval, the tool did not run. Do not claim blocked tools ran."
|
|
45
|
+
systemPrompt: event.systemPrompt + approvalRule
|
|
9
46
|
}));
|
|
10
47
|
|
|
48
|
+
pi.on("session_start", (_event, ctx) => {
|
|
49
|
+
showStatus(ctx);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.registerCommand("approval", {
|
|
53
|
+
description: "Choose whether tool calls ask for approval",
|
|
54
|
+
getArgumentCompletions: (prefix: string) => {
|
|
55
|
+
const trimmed = prefix.trim().toLowerCase();
|
|
56
|
+
const names = [...modes, ...aliases];
|
|
57
|
+
const matches = names.filter((name) => name.startsWith(trimmed));
|
|
58
|
+
return matches.length === 0 ? null : matches.map((name) => ({ value: name, label: name }));
|
|
59
|
+
},
|
|
60
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
61
|
+
const requested = parseMode(args);
|
|
62
|
+
if (requested !== undefined) {
|
|
63
|
+
await apply(requested, ctx, true);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!ctx.hasUI) {
|
|
67
|
+
ctx.ui.notify(
|
|
68
|
+
"permission: " + label(enabled) + "; run localpi in a terminal to change it",
|
|
69
|
+
"info"
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const selected = await promptMode(enabled, ctx);
|
|
74
|
+
if (selected === undefined) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await apply(selected, ctx, true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
11
81
|
pi.on("tool_call", async (event, ctx) => {
|
|
12
|
-
|
|
82
|
+
if (!enabled) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!gateReadTools && readOnlyTools.includes(event.toolName)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
13
89
|
|
|
14
90
|
if (!ctx.hasUI) {
|
|
15
91
|
return {
|
|
16
92
|
block: true,
|
|
17
|
-
reason:
|
|
93
|
+
reason: 'Tool call "' + event.toolName + '"' + noUiReason,
|
|
94
|
+
terminate: true
|
|
18
95
|
};
|
|
19
96
|
}
|
|
20
97
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
98
|
+
const choice = await ctx.ui.select(
|
|
99
|
+
"Allow tool call: " + event.toolName + "?\\n" + previewInput(event.input),
|
|
100
|
+
[allowOnce, allowSession, deny]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (choice === allowSession) {
|
|
104
|
+
await apply("allow", ctx, false);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (choice !== allowOnce) {
|
|
109
|
+
return { block: true, reason: blockedReason, terminate: true };
|
|
24
110
|
}
|
|
25
111
|
|
|
26
112
|
return undefined;
|
|
27
113
|
});
|
|
114
|
+
|
|
115
|
+
function showStatus(ctx: CommandContext): void {
|
|
116
|
+
if (ctx.hasUI) {
|
|
117
|
+
ctx.ui.setStatus(statusKey, enabled ? undefined : "permission: allow");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function apply(mode: PermissionMode, ctx: CommandContext, save: boolean): Promise<void> {
|
|
122
|
+
enabled = mode === "ask";
|
|
123
|
+
showStatus(ctx);
|
|
124
|
+
ctx.ui.notify(notification(mode, save), enabled ? "info" : "warning");
|
|
125
|
+
if (save) {
|
|
126
|
+
await persist(mode);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function notification(mode: PermissionMode, save: boolean): string {
|
|
132
|
+
const scope = save ? ", saved for new sessions" : " for this session";
|
|
133
|
+
return mode === "ask"
|
|
134
|
+
? "permission: ask" + scope + "; tool calls ask before they run"
|
|
135
|
+
: "permission: allow" + scope + "; tool calls run without asking";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function persist(mode: PermissionMode): Promise<void> {
|
|
139
|
+
const settings = await readSettings();
|
|
140
|
+
settings["permission"] = mode;
|
|
141
|
+
await writeSettings(settings);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function label(enabled: boolean): PermissionMode {
|
|
145
|
+
return enabled ? "ask" : "allow";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseMode(value: string): PermissionMode | undefined {
|
|
149
|
+
const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
|
|
150
|
+
if (normalized === "on") {
|
|
151
|
+
return "ask";
|
|
152
|
+
}
|
|
153
|
+
if (normalized === "off") {
|
|
154
|
+
return "allow";
|
|
155
|
+
}
|
|
156
|
+
return modes.find((mode) => mode === normalized);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function promptMode(
|
|
160
|
+
current: boolean,
|
|
161
|
+
ctx: CommandContext
|
|
162
|
+
): Promise<PermissionMode | undefined> {
|
|
163
|
+
const currentMode = label(current);
|
|
164
|
+
const selected = await ctx.ui.select(
|
|
165
|
+
"Tool approval",
|
|
166
|
+
modes.map((mode) => (mode === currentMode ? mode + " (current)" : mode))
|
|
167
|
+
);
|
|
168
|
+
return selected === undefined ? undefined : parseMode(selected);
|
|
28
169
|
}
|
|
29
170
|
|
|
30
|
-
function
|
|
171
|
+
function previewInput(input: unknown): string {
|
|
31
172
|
let text: string;
|
|
32
173
|
try {
|
|
33
|
-
text = JSON.stringify(input, null, 2);
|
|
174
|
+
text = JSON.stringify(input, null, 2) ?? String(input);
|
|
34
175
|
} catch {
|
|
35
176
|
text = String(input);
|
|
36
177
|
}
|
|
37
178
|
|
|
38
|
-
const maxLength =
|
|
179
|
+
const maxLength = 1200;
|
|
39
180
|
if (text.length <= maxLength) {
|
|
40
181
|
return text;
|
|
41
182
|
}
|
|
42
|
-
return
|
|
183
|
+
return text.slice(0, maxLength) + "\\n... truncated ...";
|
|
43
184
|
}
|
|
44
185
|
`;
|
|
45
186
|
}
|
|
@@ -3,7 +3,9 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { localpiSettingsPath } from "../localpi/settings-state.js";
|
|
5
5
|
import { resolveDemoPrompts } from "./demo.js";
|
|
6
|
+
import { continueOnTruncationExtensionSource } from "./extension-sources/continue-on-truncation.js";
|
|
6
7
|
import { startupModelSelectorExtensionSource } from "./extension-sources/startup-model-selector.js";
|
|
8
|
+
import { statusLineExtensionSource } from "./extension-sources/status-line.js";
|
|
7
9
|
import { thinkingControlExtensionSource } from "./extension-sources/thinking-control.js";
|
|
8
10
|
import { tokenStatusExtensionSource } from "./extension-sources/token-status.js";
|
|
9
11
|
import { approvalExtensionSource } from "./extension-sources/tool-approval.js";
|
|
@@ -26,16 +28,22 @@ export async function writeDefaultExtensions(options, extensionOptions = {}) {
|
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource(localpiSettingsPath(options))));
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
paths.push(await writeExtension(extensionDir, "tool-approval.ts", approvalExtensionSource({
|
|
32
|
+
enabled: options.approval,
|
|
33
|
+
settingsPath: localpiSettingsPath(options),
|
|
34
|
+
approveReadTools: options.approveReadTools
|
|
35
|
+
})));
|
|
36
|
+
if (options.continueOnTruncation > 0) {
|
|
37
|
+
paths.push(await writeExtension(extensionDir, "continue-on-truncation.ts", continueOnTruncationExtensionSource(options.continueOnTruncation)));
|
|
31
38
|
}
|
|
32
|
-
if (options.
|
|
33
|
-
paths.push(await writeExtension(extensionDir, "token-status.ts", tokenStatusExtensionSource(
|
|
39
|
+
if (options.stats !== "off") {
|
|
40
|
+
paths.push(await writeExtension(extensionDir, "token-status.ts", tokenStatusExtensionSource(tokenStatusConfig(options, extensionOptions.runtime))));
|
|
41
|
+
paths.push(await writeExtension(extensionDir, "status-line.ts", statusLineExtensionSource({ engines: extensionOptions.engines ?? [] })));
|
|
34
42
|
}
|
|
35
43
|
return {
|
|
36
44
|
paths,
|
|
37
45
|
env,
|
|
38
|
-
systemPrompt: localpiSystemPrompt(
|
|
46
|
+
systemPrompt: localpiSystemPrompt()
|
|
39
47
|
};
|
|
40
48
|
}
|
|
41
49
|
// Demo mode is the shared pi-demo-mode package (a git dependency), loaded
|
|
@@ -50,13 +58,32 @@ async function writeExtension(extensionDir, name, source) {
|
|
|
50
58
|
await writeFile(extensionPath, source, "utf8");
|
|
51
59
|
return extensionPath;
|
|
52
60
|
}
|
|
53
|
-
function
|
|
61
|
+
function tokenStatusConfig(options, runtime) {
|
|
62
|
+
return {
|
|
63
|
+
settingsPath: localpiSettingsPath(options),
|
|
64
|
+
mode: options.stats,
|
|
65
|
+
...runtimeConfig(runtime)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// The managed llama-server and the built-in llama.cpp provider are both llama.cpp, and both
|
|
69
|
+
// expose live prefill progress on /slots. Other engines fall back to elapsed-time prefill display.
|
|
70
|
+
function runtimeConfig(runtime) {
|
|
71
|
+
if (runtime === undefined) {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
const llamaCpp = runtime.providerId === "llama-cpp" || runtime.providerId === "llama-server";
|
|
75
|
+
return {
|
|
76
|
+
...(llamaCpp ? { engine: "llama-cpp" } : {}),
|
|
77
|
+
baseUrl: runtime.baseUrl,
|
|
78
|
+
modelId: runtime.model
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// The tool approval gate appends its own detailed rule. This base prompt keeps the same warning
|
|
82
|
+
// when a user disables localpi extensions, and stays true whether approval is on or off.
|
|
83
|
+
function localpiSystemPrompt() {
|
|
54
84
|
return [
|
|
55
85
|
"You are running through localpi, a local Pi launcher for local models.",
|
|
56
|
-
approval
|
|
57
|
-
? "Tool calls require user approval. If a tool result says it was blocked, denied, or requires approval, the tool did not run."
|
|
58
|
-
: "Tool approval is disabled for this session.",
|
|
59
|
-
"Do not claim that a blocked tool call ran.",
|
|
86
|
+
"Tool calls may require user approval. Never claim that a tool call ran when its result says it was blocked or denied.",
|
|
60
87
|
"Prefer answering directly when tools are not needed."
|
|
61
88
|
].join("\n");
|
|
62
89
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// Pi loads skills from shared directories such as ~/.agents/skills. A localpi session keeps its own
|
|
4
|
+
// skills instead, so a small local model does not carry skill lists it cannot use.
|
|
5
|
+
export function localpiSkillsDir(stateDir) {
|
|
6
|
+
return path.join(stateDir, "pi-skills");
|
|
7
|
+
}
|
|
8
|
+
export async function ensureLocalpiSkillsDir(options) {
|
|
9
|
+
const dir = localpiSkillsDir(options.stateDir);
|
|
10
|
+
if (options.skills === "own") {
|
|
11
|
+
await mkdir(dir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
export function localpiSkillsArgs(mode, stateDir) {
|
|
16
|
+
switch (mode) {
|
|
17
|
+
case "ambient":
|
|
18
|
+
return [];
|
|
19
|
+
case "off":
|
|
20
|
+
return ["--no-skills"];
|
|
21
|
+
case "own":
|
|
22
|
+
return ["--no-skills", "--skill", localpiSkillsDir(stateDir)];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { catppuccinMocha } from "../localpi/catppuccin.js";
|
|
4
|
+
export const localpiThemeName = "catppuccin-mocha";
|
|
5
|
+
const themeSchema = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json";
|
|
6
|
+
// Tool status cards need a dark surface with a visible success or failure cast. Both values are the
|
|
7
|
+
// Catppuccin base surface tinted with green and red.
|
|
8
|
+
const toolSuccessSurface = "#24352f";
|
|
9
|
+
const toolErrorSurface = "#3b2633";
|
|
10
|
+
export function localpiThemePath(stateDir) {
|
|
11
|
+
return path.join(stateDir, "pi-themes", `${localpiThemeName}.json`);
|
|
12
|
+
}
|
|
13
|
+
export function localpiThemeArgs(themePath, forwardedArgs) {
|
|
14
|
+
if (themePath === undefined) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
// The theme file is always loaded, so /settings can offer it. Catppuccin is only selected when
|
|
18
|
+
// the user did not ask for another theme in the same command line.
|
|
19
|
+
return hasForwardedFlag(forwardedArgs, "--use-theme")
|
|
20
|
+
? ["--theme", themePath]
|
|
21
|
+
: ["--theme", themePath, "--use-theme", localpiThemeName];
|
|
22
|
+
}
|
|
23
|
+
export async function writeLocalpiTheme(stateDir, forwardedArgs) {
|
|
24
|
+
if (hasForwardedFlag(forwardedArgs, "--no-themes")) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const themePath = localpiThemePath(stateDir);
|
|
28
|
+
await mkdir(path.dirname(themePath), { recursive: true });
|
|
29
|
+
await writeFile(themePath, catppuccinThemeSource(), "utf8");
|
|
30
|
+
return themePath;
|
|
31
|
+
}
|
|
32
|
+
export function catppuccinThemeSource() {
|
|
33
|
+
return `${JSON.stringify(catppuccinTheme(), null, 2)}\n`;
|
|
34
|
+
}
|
|
35
|
+
export function catppuccinTheme() {
|
|
36
|
+
return {
|
|
37
|
+
$schema: themeSchema,
|
|
38
|
+
name: localpiThemeName,
|
|
39
|
+
vars: { ...catppuccinMocha },
|
|
40
|
+
colors: {
|
|
41
|
+
accent: "lavender",
|
|
42
|
+
border: "surface2",
|
|
43
|
+
borderAccent: "blue",
|
|
44
|
+
borderMuted: "surface1",
|
|
45
|
+
success: "green",
|
|
46
|
+
error: "red",
|
|
47
|
+
warning: "peach",
|
|
48
|
+
muted: "subtext0",
|
|
49
|
+
dim: "overlay1",
|
|
50
|
+
text: "text",
|
|
51
|
+
thinkingText: "overlay2",
|
|
52
|
+
scrollbarTrack: "surface0",
|
|
53
|
+
scrollbarThumb: "overlay1",
|
|
54
|
+
selectedBg: "surface1",
|
|
55
|
+
searchMatchBg: "surface2",
|
|
56
|
+
searchMatchText: "text",
|
|
57
|
+
userMessageBg: "surface0",
|
|
58
|
+
userMessageText: "text",
|
|
59
|
+
customMessageBg: "mantle",
|
|
60
|
+
customMessageText: "text",
|
|
61
|
+
customMessageLabel: "mauve",
|
|
62
|
+
toolPendingBg: "mantle",
|
|
63
|
+
toolSuccessBg: toolSuccessSurface,
|
|
64
|
+
toolErrorBg: toolErrorSurface,
|
|
65
|
+
toolTitle: "blue",
|
|
66
|
+
toolOutput: "subtext0",
|
|
67
|
+
mdHeading: "peach",
|
|
68
|
+
mdLink: "blue",
|
|
69
|
+
mdLinkUrl: "overlay1",
|
|
70
|
+
mdCode: "teal",
|
|
71
|
+
mdCodeBlock: "green",
|
|
72
|
+
mdCodeBlockBorder: "surface2",
|
|
73
|
+
mdQuote: "subtext0",
|
|
74
|
+
mdQuoteBorder: "mauve",
|
|
75
|
+
mdHr: "surface2",
|
|
76
|
+
mdListBullet: "peach",
|
|
77
|
+
toolDiffAdded: "green",
|
|
78
|
+
toolDiffRemoved: "red",
|
|
79
|
+
toolDiffContext: "overlay2",
|
|
80
|
+
syntaxComment: "overlay1",
|
|
81
|
+
syntaxKeyword: "mauve",
|
|
82
|
+
syntaxFunction: "blue",
|
|
83
|
+
syntaxVariable: "lavender",
|
|
84
|
+
syntaxString: "green",
|
|
85
|
+
syntaxNumber: "peach",
|
|
86
|
+
syntaxType: "yellow",
|
|
87
|
+
syntaxOperator: "sky",
|
|
88
|
+
syntaxPunctuation: "overlay2",
|
|
89
|
+
thinkingOff: "overlay0",
|
|
90
|
+
thinkingMinimal: "overlay1",
|
|
91
|
+
thinkingLow: "sapphire",
|
|
92
|
+
thinkingMedium: "blue",
|
|
93
|
+
thinkingHigh: "mauve",
|
|
94
|
+
thinkingXhigh: "pink",
|
|
95
|
+
thinkingMax: "red",
|
|
96
|
+
bashMode: "green"
|
|
97
|
+
},
|
|
98
|
+
export: {
|
|
99
|
+
pageBg: "#11111b",
|
|
100
|
+
cardBg: "#1e1e2e",
|
|
101
|
+
infoBg: "#313244"
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function hasForwardedFlag(args, flag) {
|
|
106
|
+
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
107
|
+
}
|
|
@@ -93,16 +93,39 @@ Startup defaults:
|
|
|
93
93
|
|
|
94
94
|
In-session control:
|
|
95
95
|
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
96
|
+
- Pi provides the `/thinking` command. Pi owns runtime mutation of the thinking level.
|
|
97
|
+
- The localpi extension saves the actual Pi thinking level to localpi state for the next launch.
|
|
98
|
+
|
|
99
|
+
Superseded: localpi first registered its own `/thinking` extension command. Pi already owns that
|
|
100
|
+
name, so Pi skipped the localpi command in autocomplete. Localpi now keeps only the persistence
|
|
101
|
+
hooks (`thinking_level_select` and `session_shutdown`) and leaves the command to Pi.
|
|
102
|
+
|
|
103
|
+
Managed `llama-server` thinking budget:
|
|
104
|
+
|
|
105
|
+
A reasoning model can loop in its thinking and never answer. The managed server bounds that by
|
|
106
|
+
cutting the thinking at a token budget and forcing the end-of-thinking tag, so the turn still
|
|
107
|
+
produces an answer.
|
|
108
|
+
|
|
109
|
+
- Server-side reasoning budget is still chosen at startup. Changing it later restarts the
|
|
110
|
+
localpi-owned server process.
|
|
111
|
+
- Thinking levels map to token budgets: `off` passes `--reasoning off` and no budget, `minimal` 32,
|
|
112
|
+
`low` 128, `medium` 512, `high` 2048, and `xhigh` 16384.
|
|
113
|
+
- `--thinking-budget <n>` and `LOCALPI_THINKING_BUDGET` set the budget directly. `-1` means
|
|
114
|
+
unrestricted, and a positive value replaces the budget of the level table. Any other value fails
|
|
115
|
+
with a clear message.
|
|
116
|
+
- When the budget is finite, localpi passes a default message that the server injects before the
|
|
117
|
+
end-of-thinking tag, so the model knows why the thinking stopped. `--thinking-budget-message
|
|
118
|
+
<text>` and `LOCALPI_THINKING_BUDGET_MESSAGE` reword that default, and an empty value passes no
|
|
119
|
+
message flag at all.
|
|
120
|
+
- The message override is optional text with no validation, so the default still has its escape
|
|
121
|
+
hatch and a model that needs different wording can get it.
|
|
122
|
+
- The engine detects the thinking tags from the model template. Localpi does not hardcode a tag, and
|
|
123
|
+
it does not pass reasoning effort levels that the template rejects.
|
|
124
|
+
- The managed server metadata records the reasoning mode, the budget, and the message. A changed
|
|
125
|
+
value restarts the owned server, as the budget check already does.
|
|
126
|
+
- The reasoning translation stays in the `llama-server` adapter. There is no engine-agnostic
|
|
127
|
+
reasoning layer, because localpi starts only the managed `llama-server`; LM Studio and vLLM are
|
|
128
|
+
external servers that localpi only connects to.
|
|
106
129
|
|
|
107
130
|
## Implementation Checklist
|
|
108
131
|
|
|
@@ -112,10 +135,15 @@ Managed `llama-server` caveat:
|
|
|
112
135
|
- [x] Pass the launch-time catalog into generated Pi model config.
|
|
113
136
|
- [x] Keep `/model` owned by Pi.
|
|
114
137
|
- [x] Add reasoning and thinking-format metadata for known local reasoning models.
|
|
115
|
-
- [x] Add `/thinking` as a Pi extension command.
|
|
138
|
+
- [x] Add `/thinking` as a Pi extension command. Superseded: Pi owns the name, so the extension now only remembers the level.
|
|
116
139
|
- [x] Keep startup thinking non-interactive.
|
|
117
140
|
- [x] Keep `--thinking` and `LOCALPI_THINKING` as automation-safe startup controls.
|
|
118
141
|
- [x] Remember the last Pi thinking level for future localpi launches.
|
|
142
|
+
- [x] Add `--thinking-budget` and `LOCALPI_THINKING_BUDGET` for the managed `llama-server`.
|
|
143
|
+
- [x] Inject a default message before the end-of-thinking tag when the budget is finite.
|
|
144
|
+
- [x] Add `--thinking-budget-message` and `LOCALPI_THINKING_BUDGET_MESSAGE` to reword the message, or to pass none.
|
|
145
|
+
- [x] Record the reasoning mode, the budget, and the message in the managed server metadata.
|
|
146
|
+
- [x] Test the level table, the budget override, an invalid value, and the restart trigger.
|
|
119
147
|
- [ ] Manually verify model picker behavior in an interactive terminal with multiple loaded providers.
|
|
120
148
|
- [ ] Manually verify Pi `/model` can switch among generated catalog entries.
|
|
121
149
|
- [ ] Manually verify Pi `/thinking` picker and direct `/thinking <level>` command.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Plan: ACP mode for localpi
|
|
2
|
+
|
|
3
|
+
Date: 2026-09-23
|
|
4
|
+
Status: selected for implementation
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Make localpi able to act as an ACP agent. Add `localpi --acp` (and
|
|
9
|
+
`LOCALPI_ACP=1`) that resolves and writes the same Pi configuration a normal
|
|
10
|
+
launch writes, then starts the ACP server on stdio and gets out of the way. Pin
|
|
11
|
+
`pi-acp` as a dependency instead of vendoring its source.
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
`pi-acp` is the maintained ACP adapter for Pi (owner `svkozak`, MIT, npm
|
|
16
|
+
`pi-acp@0.0.33`, published with provenance). localpi already owns model
|
|
17
|
+
resolution, Pi configuration, and launching. Adding ACP on top of that turns
|
|
18
|
+
localpi into an agent that ACP clients can drive, including the Harbor harness,
|
|
19
|
+
so a benchmark can measure this launcher instead of a purpose-built adapter.
|
|
20
|
+
|
|
21
|
+
## Verified facts
|
|
22
|
+
|
|
23
|
+
These were read from the pinned sources, not assumed.
|
|
24
|
+
|
|
25
|
+
- `pi-acp` spawns Pi as `pi --mode rpc --no-themes`, plus `--session <path>`
|
|
26
|
+
when a session path exists, and passes the current environment to the child.
|
|
27
|
+
- `pi-acp` uses `PI_ACP_PI_COMMAND` as the executable name, so localpi can point
|
|
28
|
+
it at the Pi it resolved.
|
|
29
|
+
- `pi-acp` reads settings from `$PI_CODING_AGENT_DIR/settings.json` and from
|
|
30
|
+
`<cwd>/.pi/settings.json`, and it does not pass `--no-extensions`, so Pi
|
|
31
|
+
extension discovery stays enabled.
|
|
32
|
+
- localpi is at version 0.5.2, is a strict TypeScript CLI, and declares
|
|
33
|
+
`@osolmaz/pi-factory` and `pi-demo-mode` as its only dependencies. Its bin is
|
|
34
|
+
`localpi` mapped to `dist/src/cli/main.js`.
|
|
35
|
+
- localpi has no ACP support today. A search for ACP over its source, docs,
|
|
36
|
+
README, and manifest returns only `llamaCpp` substrings.
|
|
37
|
+
- localpi already forwards `--mode` to Pi and only rejects it together with
|
|
38
|
+
`--demo`.
|
|
39
|
+
- Pi has no ACP mode of its own, so the ACP server must be a separate process.
|
|
40
|
+
|
|
41
|
+
## Design
|
|
42
|
+
|
|
43
|
+
1. Pin, do not vendor. Add `pi-acp` to `dependencies` at a fixed version with a
|
|
44
|
+
lockfile entry. Do not copy its source into this repository.
|
|
45
|
+
2. One new entry path. `localpi --acp` and `LOCALPI_ACP=1` start the ACP server.
|
|
46
|
+
Follow the existing precedence: flag, environment, saved setting, default.
|
|
47
|
+
The default stays a normal launch, so nothing changes for current users.
|
|
48
|
+
3. Configure first, then hand off. Run the existing resolution and Pi config
|
|
49
|
+
writing, then start the pinned `pi-acp` with inherited stdio. Reuse the
|
|
50
|
+
existing resolution path. Do not duplicate discovery or config writing.
|
|
51
|
+
4. No interactive selection in ACP mode. There is no TTY, so require an explicit
|
|
52
|
+
model from the flag, environment, or model profile, and fail with a clear
|
|
53
|
+
message when none is given. Never print a picker.
|
|
54
|
+
5. Keep stdout pure. In ACP mode, stdout carries protocol bytes only. Send
|
|
55
|
+
diagnostics, warnings, and startup notes to stderr. Add a test that fails if
|
|
56
|
+
anything else reaches stdout.
|
|
57
|
+
6. Point the adapter at the resolved Pi. Set `PI_ACP_PI_COMMAND` to the Pi
|
|
58
|
+
executable localpi would launch, and pass the same environment the normal
|
|
59
|
+
launch uses: the Pi config directory, provider base URL, the API key name,
|
|
60
|
+
the thinking level, and the session directory.
|
|
61
|
+
7. Do not select ACP again inside the child. The ACP path must set the adapter
|
|
62
|
+
command to Pi itself, never back to localpi, so a spawned child cannot
|
|
63
|
+
re-enter ACP mode. Add a test for that.
|
|
64
|
+
8. Keep the existing rules. Settings stay in `<state-dir>/settings.json`. Model
|
|
65
|
+
profile limits (`client.context_window`, `client.max_tokens`) and the managed
|
|
66
|
+
llama-server thinking budget keep their current meaning. Never invent a
|
|
67
|
+
smaller reply cap than the declared one.
|
|
68
|
+
|
|
69
|
+
## Deliverables
|
|
70
|
+
|
|
71
|
+
- `localpi --acp` and `LOCALPI_ACP=1`, implemented in the existing launcher
|
|
72
|
+
structure with the ACP start kept in its own module.
|
|
73
|
+
- `pi-acp` pinned in `package.json` and the lockfile.
|
|
74
|
+
- Unit tests with a fake ACP child: correct command and environment, pure
|
|
75
|
+
stdout, a clear failure when no explicit model is given, exit-code
|
|
76
|
+
propagation, and no re-entry into ACP mode.
|
|
77
|
+
- A README section for ACP mode and an entry in
|
|
78
|
+
`docs/runtime-specification.md`.
|
|
79
|
+
|
|
80
|
+
## Acceptance checks
|
|
81
|
+
|
|
82
|
+
1. `npm run check` passes, including the generated-extension typecheck test.
|
|
83
|
+
2. `localpi --acp` with a fake adapter child starts it with the expected
|
|
84
|
+
environment, and stdout carries only protocol bytes.
|
|
85
|
+
3. `localpi --acp` without an explicit model fails fast with one clear message
|
|
86
|
+
and no picker.
|
|
87
|
+
4. A normal launch, without the ACP flag, behaves exactly as before.
|
|
88
|
+
5. No new state file appears; every new setting lives in the settings object.
|
|
89
|
+
|
|
90
|
+
## Out of scope
|
|
91
|
+
|
|
92
|
+
- Harbor manifests, harness wheels, and benchmark arms. Those belong to
|
|
93
|
+
`osolmaz/harbor-custom-harnesses`.
|
|
94
|
+
- Vendoring `pi-acp` source, or building anything from source.
|
|
95
|
+
- Publishing to npm, merging, or any release. Those need a separate instruction.
|
|
96
|
+
- Real model inference in tests.
|
|
97
|
+
|
|
98
|
+
## Authority
|
|
99
|
+
|
|
100
|
+
- Repository: `osolmaz/localpi` only.
|
|
101
|
+
- Allowed: edit, test, commit, push, open a pull request.
|
|
102
|
+
- Not allowed: merge without an explicit instruction, publish a package or a
|
|
103
|
+
release, install or copy credentials, run paid remote work, or push to another
|
|
104
|
+
repository.
|
|
105
|
+
|
|
106
|
+
## Risks
|
|
107
|
+
|
|
108
|
+
- Two Pi processes could blink in and out if the adapter command ever points
|
|
109
|
+
back at localpi. The re-entry test covers this.
|
|
110
|
+
- Any startup chatter on stdout breaks the protocol handshake, so the purity
|
|
111
|
+
test is part of the gate, not an afterthought.
|