localpi 0.2.0 → 0.4.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.
- package/README.md +57 -4
- package/dist/src/cli/cli.js +248 -14
- package/dist/src/localpi/catalog.js +79 -18
- package/dist/src/localpi/managed-runtime.js +2 -2
- package/dist/src/localpi/model-profile.js +98 -0
- package/dist/src/localpi/options.js +102 -4
- package/dist/src/localpi/runtime-connection.js +2 -4
- package/dist/src/localpi/settings-state.js +37 -0
- package/dist/src/pi/app.js +108 -0
- package/dist/src/pi/demo.js +12 -0
- package/dist/src/pi/extension-sources/demo-mode.js +52 -0
- package/dist/src/pi/extension-sources/startup-model-selector.js +78 -0
- package/dist/src/pi/extension-sources/thinking-control.js +79 -0
- package/dist/src/pi/extension-sources/token-status.js +133 -0
- package/dist/src/pi/extension-sources/tool-approval.js +45 -0
- package/dist/src/pi/extensions.js +11 -313
- package/dist/src/pi/version.js +25 -0
- package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +6 -4
- package/docs/2026-06-18-endless-demo-mode-plan.md +192 -0
- package/docs/runtime-specification.md +26 -0
- package/package.json +4 -1
- package/dist/src/pi/config.js +0 -108
- package/dist/src/pi/launch.js +0 -64
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export function thinkingControlExtensionSource(settingsPath) {
|
|
2
|
+
const settingsPathSource = JSON.stringify(settingsPath);
|
|
3
|
+
return `import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
8
|
+
|
|
9
|
+
const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
10
|
+
const settingsPath = ${settingsPathSource};
|
|
11
|
+
|
|
12
|
+
export default function localpiThinkingControl(pi: ExtensionAPI): void {
|
|
13
|
+
pi.registerCommand("thinking", {
|
|
14
|
+
description: "Set localpi thinking level",
|
|
15
|
+
getArgumentCompletions: (prefix) => {
|
|
16
|
+
const trimmed = prefix.trim().toLowerCase();
|
|
17
|
+
const matches = levels.filter((level) => level.startsWith(trimmed));
|
|
18
|
+
return matches.length === 0 ? null : matches.map((level) => ({ value: level, label: level }));
|
|
19
|
+
},
|
|
20
|
+
handler: async (args, ctx) => {
|
|
21
|
+
const requested = parseThinkingLevel(args);
|
|
22
|
+
const level = requested ?? (await promptThinkingLevel(pi.getThinkingLevel(), ctx));
|
|
23
|
+
if (level === undefined) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
pi.setThinkingLevel(level);
|
|
27
|
+
const actual = pi.getThinkingLevel();
|
|
28
|
+
await persistThinking(actual);
|
|
29
|
+
ctx.ui.notify(
|
|
30
|
+
actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`,
|
|
31
|
+
actual === level ? "info" : "warning"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.on("thinking_level_select", async (event, ctx) => {
|
|
37
|
+
await persistThinking(event.level);
|
|
38
|
+
ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
42
|
+
await persistThinking(pi.getThinkingLevel());
|
|
43
|
+
ctx.ui.setStatus("localpi-thinking", undefined);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function persistThinking(level: ThinkingLevel): Promise<void> {
|
|
48
|
+
const settings = await readSettings();
|
|
49
|
+
settings.thinking = level;
|
|
50
|
+
await mkdir(dirname(settingsPath), { recursive: true });
|
|
51
|
+
await writeFile(settingsPath, \`\${JSON.stringify(settings, null, 2)}\\n\`, "utf8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readSettings(): Promise<Record<string, unknown>> {
|
|
55
|
+
try {
|
|
56
|
+
const value = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
57
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
58
|
+
} catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function promptThinkingLevel(
|
|
64
|
+
current: ThinkingLevel,
|
|
65
|
+
ctx: { readonly ui: { select(title: string, options: string[]): Promise<string | undefined> } }
|
|
66
|
+
): Promise<ThinkingLevel | undefined> {
|
|
67
|
+
const selected = await ctx.ui.select(
|
|
68
|
+
"Thinking level",
|
|
69
|
+
levels.map((level) => (level === current ? \`\${level} (current)\` : level))
|
|
70
|
+
);
|
|
71
|
+
return selected === undefined ? undefined : parseThinkingLevel(selected);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseThinkingLevel(value: string): ThinkingLevel | undefined {
|
|
75
|
+
const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
|
|
76
|
+
return levels.find((level) => level === normalized);
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export function tokenStatusExtensionSource() {
|
|
2
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
type Usage = {
|
|
5
|
+
input?: number;
|
|
6
|
+
output?: number;
|
|
7
|
+
cacheRead?: number;
|
|
8
|
+
cacheWrite?: number;
|
|
9
|
+
totalTokens?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type TurnState = {
|
|
13
|
+
startedAt: number;
|
|
14
|
+
outputText: string;
|
|
15
|
+
estimatedOutputTokens: number;
|
|
16
|
+
lastStatusAt: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default function localpiTokenStatus(pi: ExtensionAPI): void {
|
|
20
|
+
let currentTurn: TurnState | undefined;
|
|
21
|
+
|
|
22
|
+
pi.on("turn_start", () => {
|
|
23
|
+
currentTurn = {
|
|
24
|
+
startedAt: Date.now(),
|
|
25
|
+
outputText: "",
|
|
26
|
+
estimatedOutputTokens: 0,
|
|
27
|
+
lastStatusAt: 0
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.on("message_update", (event, ctx) => {
|
|
32
|
+
const state = currentTurn;
|
|
33
|
+
if (!ctx.hasUI || state === undefined) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event);
|
|
37
|
+
if (update.kind === "delta") {
|
|
38
|
+
state.outputText += update.text;
|
|
39
|
+
} else if (update.text.length > state.outputText.length) {
|
|
40
|
+
state.outputText = update.text;
|
|
41
|
+
}
|
|
42
|
+
state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4);
|
|
43
|
+
if (Date.now() - state.lastStatusAt < 250) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
state.lastStatusAt = Date.now();
|
|
47
|
+
ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state)));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.on("turn_end", (event, ctx) => {
|
|
51
|
+
const state = currentTurn ?? {
|
|
52
|
+
startedAt: Date.now(),
|
|
53
|
+
outputText: "",
|
|
54
|
+
estimatedOutputTokens: 0,
|
|
55
|
+
lastStatusAt: 0
|
|
56
|
+
};
|
|
57
|
+
currentTurn = undefined;
|
|
58
|
+
|
|
59
|
+
if (!ctx.hasUI || event.message.role !== "assistant") {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const usage = event.message.usage as Usage | undefined;
|
|
64
|
+
const output = usage?.output ?? state.estimatedOutputTokens;
|
|
65
|
+
const input = usage?.input ?? 0;
|
|
66
|
+
const cacheRead = usage?.cacheRead ?? 0;
|
|
67
|
+
const cacheWrite = usage?.cacheWrite ?? 0;
|
|
68
|
+
const elapsedSeconds = elapsed(state);
|
|
69
|
+
const context = ctx.getContextUsage();
|
|
70
|
+
const contextText =
|
|
71
|
+
context && context.percent !== null
|
|
72
|
+
? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
|
|
73
|
+
: "ctx ?";
|
|
74
|
+
|
|
75
|
+
ctx.ui.setStatus(
|
|
76
|
+
"localpi-perf",
|
|
77
|
+
ctx.ui.theme.fg(
|
|
78
|
+
"dim",
|
|
79
|
+
[
|
|
80
|
+
\`\${(output / elapsedSeconds).toFixed(1)} tok/s\`,
|
|
81
|
+
\`out \${output}\`,
|
|
82
|
+
\`in \${input}\`,
|
|
83
|
+
cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
|
|
84
|
+
cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
|
|
85
|
+
\`\${elapsedSeconds.toFixed(1)}s\`,
|
|
86
|
+
contextText
|
|
87
|
+
]
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.join(" | ")
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
95
|
+
if (ctx.hasUI) {
|
|
96
|
+
ctx.ui.setStatus("localpi-perf", "");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function statusText(state: TurnState): string {
|
|
102
|
+
const elapsedSeconds = elapsed(state);
|
|
103
|
+
return \`\${(state.estimatedOutputTokens / elapsedSeconds).toFixed(1)} tok/s | out ~\${state.estimatedOutputTokens} | \${elapsedSeconds.toFixed(1)}s\`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function elapsed(state: TurnState): number {
|
|
107
|
+
return Math.max((Date.now() - state.startedAt) / 1000, 0.001);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type TextUpdate = {
|
|
111
|
+
kind: "delta" | "snapshot";
|
|
112
|
+
text: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function textUpdateFromUnknown(value: unknown): TextUpdate {
|
|
116
|
+
if (typeof value === "string") {
|
|
117
|
+
return { kind: "snapshot", text: value };
|
|
118
|
+
}
|
|
119
|
+
if (value && typeof value === "object") {
|
|
120
|
+
const object = value as Record<string, unknown>;
|
|
121
|
+
const delta = object["delta"];
|
|
122
|
+
const text = object["text"] ?? object["content"];
|
|
123
|
+
if (typeof delta === "string") {
|
|
124
|
+
return { kind: "delta", text: delta };
|
|
125
|
+
}
|
|
126
|
+
if (typeof text === "string") {
|
|
127
|
+
return { kind: "snapshot", text };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { kind: "snapshot", text: "" };
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function approvalExtensionSource() {
|
|
2
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export default function localpiToolApproval(pi: ExtensionAPI): void {
|
|
5
|
+
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."
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
12
|
+
const input = formatInput(event.input);
|
|
13
|
+
|
|
14
|
+
if (!ctx.hasUI) {
|
|
15
|
+
return {
|
|
16
|
+
block: true,
|
|
17
|
+
reason: \`Tool call "\${event.toolName}" was blocked and did not run because interactive approval is required.\`
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ok = await ctx.ui.confirm(\`Allow tool call: \${event.toolName}?\`, input);
|
|
22
|
+
if (!ok) {
|
|
23
|
+
return { block: true, reason: "Tool call was blocked by the user and did not run." };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return undefined;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatInput(input: unknown): string {
|
|
31
|
+
let text: string;
|
|
32
|
+
try {
|
|
33
|
+
text = JSON.stringify(input, null, 2);
|
|
34
|
+
} catch {
|
|
35
|
+
text = String(input);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const maxLength = 4000;
|
|
39
|
+
if (text.length <= maxLength) {
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
return \`\${text.slice(0, maxLength)}\\n... truncated ...\`;
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { localpiSettingsPath } from "../localpi/settings-state.js";
|
|
4
|
+
import { resolveDemoPrompts } from "./demo.js";
|
|
5
|
+
import { demoModeExtensionSource } from "./extension-sources/demo-mode.js";
|
|
6
|
+
import { startupModelSelectorExtensionSource } from "./extension-sources/startup-model-selector.js";
|
|
7
|
+
import { thinkingControlExtensionSource } from "./extension-sources/thinking-control.js";
|
|
8
|
+
import { tokenStatusExtensionSource } from "./extension-sources/token-status.js";
|
|
9
|
+
import { approvalExtensionSource } from "./extension-sources/tool-approval.js";
|
|
3
10
|
export async function writeDefaultExtensions(options, extensionOptions = {}) {
|
|
4
11
|
const extensionDir = path.join(options.stateDir, "pi-extensions");
|
|
5
12
|
await mkdir(extensionDir, { recursive: true });
|
|
@@ -7,7 +14,10 @@ export async function writeDefaultExtensions(options, extensionOptions = {}) {
|
|
|
7
14
|
if (extensionOptions.startupModelSelector !== undefined) {
|
|
8
15
|
paths.push(await writeExtension(extensionDir, "startup-model-selector.ts", startupModelSelectorExtensionSource(extensionOptions.startupModelSelector)));
|
|
9
16
|
}
|
|
10
|
-
|
|
17
|
+
if (options.demo) {
|
|
18
|
+
paths.push(await writeExtension(extensionDir, "demo-mode.ts", demoModeExtensionSource(await resolveDemoPrompts(options))));
|
|
19
|
+
}
|
|
20
|
+
paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource(localpiSettingsPath(options))));
|
|
11
21
|
if (options.approval) {
|
|
12
22
|
paths.push(await writeExtension(extensionDir, "tool-approval.ts", approvalExtensionSource()));
|
|
13
23
|
}
|
|
@@ -24,84 +34,6 @@ async function writeExtension(extensionDir, name, source) {
|
|
|
24
34
|
await writeFile(extensionPath, source, "utf8");
|
|
25
35
|
return extensionPath;
|
|
26
36
|
}
|
|
27
|
-
function startupModelSelectorExtensionSource(options) {
|
|
28
|
-
const startupModelsSource = JSON.stringify(options.models);
|
|
29
|
-
return `import type { ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
30
|
-
import { ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
|
|
32
|
-
type SelectedModel = Parameters<ExtensionAPI["setModel"]>[0];
|
|
33
|
-
const startupModels = ${startupModelsSource} as const;
|
|
34
|
-
const startupModelKeys = new Set(startupModels.map((model) => modelKey(model)));
|
|
35
|
-
|
|
36
|
-
export default function localpiStartupModelSelector(pi: ExtensionAPI): void {
|
|
37
|
-
let opened = false;
|
|
38
|
-
|
|
39
|
-
pi.on("session_start", async (event, ctx) => {
|
|
40
|
-
if (opened || event.reason !== "startup" || ctx.mode !== "tui") {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const selectableModels = startupAvailableModels(ctx.modelRegistry);
|
|
45
|
-
if (selectableModels.length <= 1) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
const scopedModels = selectableModels.map((model) => ({ model }));
|
|
49
|
-
|
|
50
|
-
opened = true;
|
|
51
|
-
const selected = await ctx.ui.custom<SelectedModel | undefined>((tui, _theme, _keybindings, done) => {
|
|
52
|
-
const settings = {
|
|
53
|
-
setDefaultModelAndProvider: () => {}
|
|
54
|
-
} as unknown as SettingsManager;
|
|
55
|
-
return new ModelSelectorComponent(
|
|
56
|
-
tui,
|
|
57
|
-
ctx.model,
|
|
58
|
-
settings,
|
|
59
|
-
startupModelRegistry(ctx.modelRegistry) as typeof ctx.modelRegistry,
|
|
60
|
-
scopedModels,
|
|
61
|
-
(model) => done(model),
|
|
62
|
-
() => done(undefined)
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
if (selected === undefined) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const ok = await pi.setModel(selected);
|
|
71
|
-
if (!ok) {
|
|
72
|
-
ctx.ui.notify(\`No API key for \${selected.provider}/\${selected.id}\`, "error");
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function startupAvailableModels(registry: {
|
|
78
|
-
getAvailable(): SelectedModel[];
|
|
79
|
-
}): SelectedModel[] {
|
|
80
|
-
return registry.getAvailable().filter((model) => startupModelKeys.has(modelKey(model)));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function startupModelRegistry(registry: {
|
|
84
|
-
refresh(): void;
|
|
85
|
-
getError(): string | undefined;
|
|
86
|
-
getAvailable(): SelectedModel[];
|
|
87
|
-
find(provider: string, modelId: string): SelectedModel | undefined;
|
|
88
|
-
}): typeof registry {
|
|
89
|
-
return {
|
|
90
|
-
refresh: () => registry.refresh(),
|
|
91
|
-
getError: () => registry.getError(),
|
|
92
|
-
getAvailable: () => startupAvailableModels(registry),
|
|
93
|
-
find: (provider, modelId) => {
|
|
94
|
-
const model = registry.find(provider, modelId);
|
|
95
|
-
return model !== undefined && startupModelKeys.has(modelKey(model)) ? model : undefined;
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function modelKey(model: { readonly provider: string; readonly id: string }): string {
|
|
101
|
-
return \`\${model.provider}\\u0000\${model.id}\`;
|
|
102
|
-
}
|
|
103
|
-
`;
|
|
104
|
-
}
|
|
105
37
|
function localpiSystemPrompt(approval) {
|
|
106
38
|
return [
|
|
107
39
|
"You are running through localpi, a local Pi launcher for local models.",
|
|
@@ -112,237 +44,3 @@ function localpiSystemPrompt(approval) {
|
|
|
112
44
|
"Prefer answering directly when tools are not needed."
|
|
113
45
|
].join("\n");
|
|
114
46
|
}
|
|
115
|
-
function approvalExtensionSource() {
|
|
116
|
-
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
117
|
-
|
|
118
|
-
export default function localpiToolApproval(pi: ExtensionAPI): void {
|
|
119
|
-
pi.on("before_agent_start", (event) => ({
|
|
120
|
-
systemPrompt:
|
|
121
|
-
event.systemPrompt +
|
|
122
|
-
"\\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."
|
|
123
|
-
}));
|
|
124
|
-
|
|
125
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
126
|
-
const input = formatInput(event.input);
|
|
127
|
-
|
|
128
|
-
if (!ctx.hasUI) {
|
|
129
|
-
return {
|
|
130
|
-
block: true,
|
|
131
|
-
reason: \`Tool call "\${event.toolName}" was blocked and did not run because interactive approval is required.\`
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const ok = await ctx.ui.confirm(\`Allow tool call: \${event.toolName}?\`, input);
|
|
136
|
-
if (!ok) {
|
|
137
|
-
return { block: true, reason: "Tool call was blocked by the user and did not run." };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return undefined;
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function formatInput(input: unknown): string {
|
|
145
|
-
let text: string;
|
|
146
|
-
try {
|
|
147
|
-
text = JSON.stringify(input, null, 2);
|
|
148
|
-
} catch {
|
|
149
|
-
text = String(input);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const maxLength = 4000;
|
|
153
|
-
if (text.length <= maxLength) {
|
|
154
|
-
return text;
|
|
155
|
-
}
|
|
156
|
-
return \`\${text.slice(0, maxLength)}\\n... truncated ...\`;
|
|
157
|
-
}
|
|
158
|
-
`;
|
|
159
|
-
}
|
|
160
|
-
function tokenStatusExtensionSource() {
|
|
161
|
-
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
162
|
-
|
|
163
|
-
type Usage = {
|
|
164
|
-
input?: number;
|
|
165
|
-
output?: number;
|
|
166
|
-
cacheRead?: number;
|
|
167
|
-
cacheWrite?: number;
|
|
168
|
-
totalTokens?: number;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
type TurnState = {
|
|
172
|
-
startedAt: number;
|
|
173
|
-
outputText: string;
|
|
174
|
-
estimatedOutputTokens: number;
|
|
175
|
-
lastStatusAt: number;
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
export default function localpiTokenStatus(pi: ExtensionAPI): void {
|
|
179
|
-
let currentTurn: TurnState | undefined;
|
|
180
|
-
|
|
181
|
-
pi.on("turn_start", () => {
|
|
182
|
-
currentTurn = {
|
|
183
|
-
startedAt: Date.now(),
|
|
184
|
-
outputText: "",
|
|
185
|
-
estimatedOutputTokens: 0,
|
|
186
|
-
lastStatusAt: 0
|
|
187
|
-
};
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
pi.on("message_update", (event, ctx) => {
|
|
191
|
-
const state = currentTurn;
|
|
192
|
-
if (!ctx.hasUI || state === undefined) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event);
|
|
196
|
-
if (update.kind === "delta") {
|
|
197
|
-
state.outputText += update.text;
|
|
198
|
-
} else if (update.text.length > state.outputText.length) {
|
|
199
|
-
state.outputText = update.text;
|
|
200
|
-
}
|
|
201
|
-
state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4);
|
|
202
|
-
if (Date.now() - state.lastStatusAt < 250) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
state.lastStatusAt = Date.now();
|
|
206
|
-
ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state)));
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
pi.on("turn_end", (event, ctx) => {
|
|
210
|
-
const state = currentTurn ?? {
|
|
211
|
-
startedAt: Date.now(),
|
|
212
|
-
outputText: "",
|
|
213
|
-
estimatedOutputTokens: 0,
|
|
214
|
-
lastStatusAt: 0
|
|
215
|
-
};
|
|
216
|
-
currentTurn = undefined;
|
|
217
|
-
|
|
218
|
-
if (!ctx.hasUI || event.message.role !== "assistant") {
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const usage = event.message.usage as Usage | undefined;
|
|
223
|
-
const output = usage?.output ?? state.estimatedOutputTokens;
|
|
224
|
-
const input = usage?.input ?? 0;
|
|
225
|
-
const cacheRead = usage?.cacheRead ?? 0;
|
|
226
|
-
const cacheWrite = usage?.cacheWrite ?? 0;
|
|
227
|
-
const elapsedSeconds = elapsed(state);
|
|
228
|
-
const context = ctx.getContextUsage();
|
|
229
|
-
const contextText =
|
|
230
|
-
context && context.percent !== null
|
|
231
|
-
? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
|
|
232
|
-
: "ctx ?";
|
|
233
|
-
|
|
234
|
-
ctx.ui.setStatus(
|
|
235
|
-
"localpi-perf",
|
|
236
|
-
ctx.ui.theme.fg(
|
|
237
|
-
"dim",
|
|
238
|
-
[
|
|
239
|
-
\`\${(output / elapsedSeconds).toFixed(1)} tok/s\`,
|
|
240
|
-
\`out \${output}\`,
|
|
241
|
-
\`in \${input}\`,
|
|
242
|
-
cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
|
|
243
|
-
cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
|
|
244
|
-
\`\${elapsedSeconds.toFixed(1)}s\`,
|
|
245
|
-
contextText
|
|
246
|
-
]
|
|
247
|
-
.filter(Boolean)
|
|
248
|
-
.join(" | ")
|
|
249
|
-
)
|
|
250
|
-
);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
254
|
-
if (ctx.hasUI) {
|
|
255
|
-
ctx.ui.setStatus("localpi-perf", "");
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function statusText(state: TurnState): string {
|
|
261
|
-
const elapsedSeconds = elapsed(state);
|
|
262
|
-
return \`\${(state.estimatedOutputTokens / elapsedSeconds).toFixed(1)} tok/s | out ~\${state.estimatedOutputTokens} | \${elapsedSeconds.toFixed(1)}s\`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function elapsed(state: TurnState): number {
|
|
266
|
-
return Math.max((Date.now() - state.startedAt) / 1000, 0.001);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
type TextUpdate = {
|
|
270
|
-
kind: "delta" | "snapshot";
|
|
271
|
-
text: string;
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
function textUpdateFromUnknown(value: unknown): TextUpdate {
|
|
275
|
-
if (typeof value === "string") {
|
|
276
|
-
return { kind: "snapshot", text: value };
|
|
277
|
-
}
|
|
278
|
-
if (value && typeof value === "object") {
|
|
279
|
-
const object = value as Record<string, unknown>;
|
|
280
|
-
const delta = object["delta"];
|
|
281
|
-
const text = object["text"] ?? object["content"];
|
|
282
|
-
if (typeof delta === "string") {
|
|
283
|
-
return { kind: "delta", text: delta };
|
|
284
|
-
}
|
|
285
|
-
if (typeof text === "string") {
|
|
286
|
-
return { kind: "snapshot", text };
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return { kind: "snapshot", text: "" };
|
|
290
|
-
}
|
|
291
|
-
`;
|
|
292
|
-
}
|
|
293
|
-
function thinkingControlExtensionSource() {
|
|
294
|
-
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
295
|
-
|
|
296
|
-
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
297
|
-
|
|
298
|
-
const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
299
|
-
|
|
300
|
-
export default function localpiThinkingControl(pi: ExtensionAPI): void {
|
|
301
|
-
pi.registerCommand("thinking", {
|
|
302
|
-
description: "Set localpi thinking level",
|
|
303
|
-
getArgumentCompletions: (prefix) => {
|
|
304
|
-
const trimmed = prefix.trim().toLowerCase();
|
|
305
|
-
const matches = levels.filter((level) => level.startsWith(trimmed));
|
|
306
|
-
return matches.length === 0 ? null : matches.map((level) => ({ value: level, label: level }));
|
|
307
|
-
},
|
|
308
|
-
handler: async (args, ctx) => {
|
|
309
|
-
const requested = parseThinkingLevel(args);
|
|
310
|
-
const level = requested ?? (await promptThinkingLevel(pi.getThinkingLevel(), ctx));
|
|
311
|
-
if (level === undefined) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
pi.setThinkingLevel(level);
|
|
315
|
-
const actual = pi.getThinkingLevel();
|
|
316
|
-
ctx.ui.notify(
|
|
317
|
-
actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`,
|
|
318
|
-
actual === level ? "info" : "warning"
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
pi.on("thinking_level_select", (event, ctx) => {
|
|
324
|
-
ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
328
|
-
ctx.ui.setStatus("localpi-thinking", undefined);
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
async function promptThinkingLevel(
|
|
333
|
-
current: ThinkingLevel,
|
|
334
|
-
ctx: { readonly ui: { select(title: string, options: string[]): Promise<string | undefined> } }
|
|
335
|
-
): Promise<ThinkingLevel | undefined> {
|
|
336
|
-
const selected = await ctx.ui.select(
|
|
337
|
-
"Thinking level",
|
|
338
|
-
levels.map((level) => (level === current ? \`\${level} (current)\` : level))
|
|
339
|
-
);
|
|
340
|
-
return selected === undefined ? undefined : parseThinkingLevel(selected);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function parseThinkingLevel(value: string): ThinkingLevel | undefined {
|
|
344
|
-
const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
|
|
345
|
-
return levels.find((level) => level === normalized);
|
|
346
|
-
}
|
|
347
|
-
`;
|
|
348
|
-
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const packageMetadata = loadPackageMetadata();
|
|
4
|
+
if (typeof packageMetadata.version !== "string") {
|
|
5
|
+
throw new Error("localpi package metadata is missing a string version");
|
|
6
|
+
}
|
|
7
|
+
export const localpiVersion = packageMetadata.version;
|
|
8
|
+
function loadPackageMetadata() {
|
|
9
|
+
for (const packagePath of ["../../package.json", "../../../package.json"]) {
|
|
10
|
+
try {
|
|
11
|
+
return require(packagePath);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (!isModuleNotFound(error)) {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
throw new Error("localpi package metadata could not be loaded");
|
|
20
|
+
}
|
|
21
|
+
function isModuleNotFound(error) {
|
|
22
|
+
return (error instanceof Error &&
|
|
23
|
+
"code" in error &&
|
|
24
|
+
error.code === "MODULE_NOT_FOUND");
|
|
25
|
+
}
|
|
@@ -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
|
|
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 `
|
|
90
|
-
- `LOCALPI_THINKING=<level>`
|
|
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.
|