localpi 0.3.0 → 0.5.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 +2 -2
- package/dist/src/cli/cli.js +6 -6
- package/dist/src/localpi/settings-state.js +2 -2
- package/dist/src/pi/app.js +148 -0
- package/dist/src/pi/extension-sources/demo-mode.js +110 -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 +177 -0
- package/dist/src/pi/extension-sources/tool-approval.js +45 -0
- package/dist/src/pi/extensions.js +7 -389
- package/dist/src/pi/version.js +25 -0
- package/docs/runtime-specification.md +1 -1
- package/package.json +5 -2
- package/dist/src/pi/config.js +0 -108
- package/dist/src/pi/launch.js +0 -64
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# localpi
|
|
2
2
|
|
|
3
|
-
Localpi is a
|
|
3
|
+
Localpi is a Swiss army knife for running Pi with local inference engines.
|
|
4
4
|
|
|
5
5
|
By default, Localpi discovers available local providers, lets you choose when more than one model is loaded, points Pi at the selected model, and writes Pi config for the other discovered models so `/model` can switch among them during the session.
|
|
6
6
|
|
|
7
|
-
Localpi
|
|
7
|
+
Localpi is meant to be the practical bridge from Pi to local inference stacks such as llama.cpp/`llama-server`, vLLM, SGLang, LM Studio, Ollama, and custom provider endpoints.
|
|
8
8
|
|
|
9
9
|
Localpi is intentionally generic. It does not contain classifier prompts, dataset workflows, GitHub routing logic, or final-schema output machinery. Structured classifier runs belong in caller tools such as `localpager-agent`.
|
|
10
10
|
|
package/dist/src/cli/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { runPiApp } from "@dutifuldev/pi-factory";
|
|
1
2
|
import { errorMessage, fail, ok } from "../common/result.js";
|
|
2
3
|
import { parseLocalpiArgs, usage } from "../localpi/options.js";
|
|
3
4
|
import { aliasListOutput, connectionStatus, resolveRuntime, statusOutput, stopRuntime } from "../localpi/runtime.js";
|
|
4
5
|
import { applyRememberedSettings } from "../localpi/settings-state.js";
|
|
5
|
-
import {
|
|
6
|
+
import { createLocalpiAppDefinition } from "../pi/app.js";
|
|
6
7
|
import { writeDefaultExtensions } from "../pi/extensions.js";
|
|
7
|
-
import { createLaunchPlan, execLaunchPlan } from "../pi/launch.js";
|
|
8
8
|
export async function run(args) {
|
|
9
9
|
try {
|
|
10
10
|
let options = parseLocalpiArgs(args);
|
|
@@ -21,10 +21,10 @@ export async function run(args) {
|
|
|
21
21
|
thinking: hasExplicitThinkingOverride(args)
|
|
22
22
|
});
|
|
23
23
|
const connection = await resolveRuntime(options);
|
|
24
|
-
const runtimeConfig = await writeRuntimeConfig(options, connection);
|
|
25
24
|
const selectorOptions = startupModelSelectorOptions(options, connection);
|
|
26
25
|
const extensions = await writeDefaultExtensions(options, selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions });
|
|
27
|
-
|
|
26
|
+
const app = createLocalpiAppDefinition(options, connection, extensions);
|
|
27
|
+
return await launchResolvedRuntime(app, connection);
|
|
28
28
|
}
|
|
29
29
|
catch (error) {
|
|
30
30
|
return fail(`localpi: ${errorMessage(error)}`);
|
|
@@ -237,8 +237,8 @@ function isPiUnknownLongFlagValue(arg, next) {
|
|
|
237
237
|
function isPiUnknownLongFlagNextValue(arg) {
|
|
238
238
|
return arg !== undefined && !arg.startsWith("-") && !arg.startsWith("@");
|
|
239
239
|
}
|
|
240
|
-
async function launchResolvedRuntime(
|
|
241
|
-
const code = await
|
|
240
|
+
async function launchResolvedRuntime(app, connection) {
|
|
241
|
+
const code = await runPiApp(app);
|
|
242
242
|
if (code !== 0) {
|
|
243
243
|
return { code, stdout: "", stderr: "" };
|
|
244
244
|
}
|
|
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { asObject, optionalString } from "../common/json.js";
|
|
4
4
|
import { parseThinkingLevel } from "./options.js";
|
|
5
|
-
export function
|
|
5
|
+
export function localpiSettingsPath(options) {
|
|
6
6
|
return path.join(options.stateDir, "settings.json");
|
|
7
7
|
}
|
|
8
8
|
export async function applyRememberedSettings(options, explicit) {
|
|
@@ -15,7 +15,7 @@ export async function applyRememberedSettings(options, explicit) {
|
|
|
15
15
|
async function readLocalpiSettings(options) {
|
|
16
16
|
let raw;
|
|
17
17
|
try {
|
|
18
|
-
raw = await readFile(
|
|
18
|
+
raw = await readFile(localpiSettingsPath(options), "utf8");
|
|
19
19
|
}
|
|
20
20
|
catch (error) {
|
|
21
21
|
if (isMissingFile(error)) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { localpiVersion } from "./version.js";
|
|
2
|
+
const localpiAppIdentity = {
|
|
3
|
+
id: "localpi",
|
|
4
|
+
name: "localpi",
|
|
5
|
+
version: localpiVersion
|
|
6
|
+
};
|
|
7
|
+
const demoLaunchOverrideBooleanFlags = new Set([
|
|
8
|
+
"--no-tools",
|
|
9
|
+
"-nt",
|
|
10
|
+
"--no-builtin-tools",
|
|
11
|
+
"-nbt",
|
|
12
|
+
"--approve",
|
|
13
|
+
"-a",
|
|
14
|
+
"--no-approve",
|
|
15
|
+
"-na"
|
|
16
|
+
]);
|
|
17
|
+
const demoLaunchOverrideValueFlags = new Set(["--tools", "-t", "--exclude-tools", "-xt"]);
|
|
18
|
+
const demoLaunchOverrideEqualsFlags = ["--tools=", "--exclude-tools="];
|
|
19
|
+
export function createLocalpiAppDefinition(options, connection, extensions) {
|
|
20
|
+
return {
|
|
21
|
+
...localpiAppIdentity,
|
|
22
|
+
...appDirectories(options),
|
|
23
|
+
...piCommand(options),
|
|
24
|
+
...runtimeSelection(options, connection),
|
|
25
|
+
...extensionConfig(extensions)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function appDirectories(options) {
|
|
29
|
+
return {
|
|
30
|
+
stateDir: options.stateDir,
|
|
31
|
+
sessionDir: options.sessionDir
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function piCommand(options) {
|
|
35
|
+
return {
|
|
36
|
+
piCommand: options.piCommand,
|
|
37
|
+
forwardedArgs: options.demo ? demoForwardedArgs(options.forwardedArgs) : options.forwardedArgs
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function demoForwardedArgs(args) {
|
|
41
|
+
return [...withoutConflictingDemoFlags(args), "--no-tools", "--no-approve"];
|
|
42
|
+
}
|
|
43
|
+
function withoutConflictingDemoFlags(args) {
|
|
44
|
+
const filtered = [];
|
|
45
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
46
|
+
const arg = args[index];
|
|
47
|
+
if (arg === undefined) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (isDemoLaunchOverrideFlag(arg)) {
|
|
51
|
+
if (isValueFlag(arg) && !arg.includes("=")) {
|
|
52
|
+
index += 1;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
filtered.push(arg);
|
|
57
|
+
}
|
|
58
|
+
return filtered;
|
|
59
|
+
}
|
|
60
|
+
function isDemoLaunchOverrideFlag(arg) {
|
|
61
|
+
return (demoLaunchOverrideBooleanFlags.has(arg) ||
|
|
62
|
+
isValueFlag(arg) ||
|
|
63
|
+
demoLaunchOverrideEqualsFlags.some((flag) => arg.startsWith(flag)));
|
|
64
|
+
}
|
|
65
|
+
function isValueFlag(arg) {
|
|
66
|
+
return demoLaunchOverrideValueFlags.has(arg);
|
|
67
|
+
}
|
|
68
|
+
function runtimeSelection(options, connection) {
|
|
69
|
+
const selection = {
|
|
70
|
+
providers: providersForConnection(options, connection),
|
|
71
|
+
defaultProvider: connection.providerId,
|
|
72
|
+
defaultModel: connection.model,
|
|
73
|
+
thinking: options.thinking
|
|
74
|
+
};
|
|
75
|
+
return options.tools === undefined ? selection : { ...selection, tools: options.tools };
|
|
76
|
+
}
|
|
77
|
+
function providersForConnection(options, connection) {
|
|
78
|
+
const models = connection.catalogModels.length === 0 ? fallbackCatalog(connection) : connection.catalogModels;
|
|
79
|
+
return groupedByProvider(models).map((group) => ({
|
|
80
|
+
id: group.providerId,
|
|
81
|
+
baseUrl: group.baseUrl,
|
|
82
|
+
api: "openai-completions",
|
|
83
|
+
apiKey: "local",
|
|
84
|
+
compat: {
|
|
85
|
+
supportsDeveloperRole: false,
|
|
86
|
+
supportsReasoningEffort: false
|
|
87
|
+
},
|
|
88
|
+
models: group.models.map((model) => modelDefinition(options, model))
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
function groupedByProvider(models) {
|
|
92
|
+
const groups = new Map();
|
|
93
|
+
for (const model of models) {
|
|
94
|
+
const existing = groups.get(model.providerId);
|
|
95
|
+
groups.set(model.providerId, existing === undefined
|
|
96
|
+
? { providerId: model.providerId, baseUrl: model.baseUrl, models: [model] }
|
|
97
|
+
: { ...existing, models: [...existing.models, model] });
|
|
98
|
+
}
|
|
99
|
+
return [...groups.values()];
|
|
100
|
+
}
|
|
101
|
+
function modelDefinition(options, model) {
|
|
102
|
+
const contextWindow = modelContextWindow(options, model);
|
|
103
|
+
return {
|
|
104
|
+
id: model.modelId,
|
|
105
|
+
name: model.displayName,
|
|
106
|
+
reasoning: model.reasoning ?? false,
|
|
107
|
+
...(model.thinkingFormat === undefined ? {} : { thinkingFormat: model.thinkingFormat }),
|
|
108
|
+
input: ["text"],
|
|
109
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
110
|
+
...(model.maxTokens === undefined
|
|
111
|
+
? { maxTokens: options.maxTokens }
|
|
112
|
+
: { maxTokens: model.maxTokens })
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function modelContextWindow(options, model) {
|
|
116
|
+
if (options.contextWindow !== undefined) {
|
|
117
|
+
return options.contextWindow;
|
|
118
|
+
}
|
|
119
|
+
return model.contextWindow;
|
|
120
|
+
}
|
|
121
|
+
function fallbackCatalog(connection) {
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
providerId: connection.providerId,
|
|
125
|
+
providerName: connection.providerName,
|
|
126
|
+
runtime: connection.runtime.startsWith("llama-server")
|
|
127
|
+
? "managed-llama-server"
|
|
128
|
+
: "openai-compatible",
|
|
129
|
+
baseUrl: connection.baseUrl,
|
|
130
|
+
modelId: connection.model,
|
|
131
|
+
aliases: [],
|
|
132
|
+
displayName: `Local model (${connection.model})`,
|
|
133
|
+
reasoning: false,
|
|
134
|
+
capabilities: ["text"],
|
|
135
|
+
availability: "loaded",
|
|
136
|
+
...(connection.contextWindow === undefined ? {} : { contextWindow: connection.contextWindow })
|
|
137
|
+
}
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
function extensionConfig(extensions) {
|
|
141
|
+
if (extensions === undefined) {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
extensions: extensions.paths.map((extensionPath) => ({ path: extensionPath })),
|
|
146
|
+
appendSystemPrompts: [extensions.systemPrompt]
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export function demoModeExtensionSource(prompts) {
|
|
2
|
+
const initialPromptSource = JSON.stringify(prompts.initial);
|
|
3
|
+
const followupPromptSource = JSON.stringify(prompts.followup);
|
|
4
|
+
return `import type { ExtensionAPI, ExtensionContext, TurnEndEvent } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const initialPrompt = ${initialPromptSource};
|
|
7
|
+
const followupPrompt = ${followupPromptSource};
|
|
8
|
+
const compactAtContextPercent = 70;
|
|
9
|
+
const demoCompactionInstructions = [
|
|
10
|
+
"Preserve the demo narrative state, named entities, current setting,",
|
|
11
|
+
"unresolved plot threads, and latest user direction.",
|
|
12
|
+
"Keep the summary concise so the story can continue after compaction."
|
|
13
|
+
].join(" ");
|
|
14
|
+
|
|
15
|
+
export default function localpiDemoMode(pi: ExtensionAPI): void {
|
|
16
|
+
let started = false;
|
|
17
|
+
let stopped = false;
|
|
18
|
+
let compacting = false;
|
|
19
|
+
|
|
20
|
+
function queueInitialPrompt(): void {
|
|
21
|
+
queueMicrotask(() => {
|
|
22
|
+
if (!stopped) {
|
|
23
|
+
pi.sendUserMessage(initialPrompt);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function queueFollowup(): void {
|
|
29
|
+
queueMicrotask(() => {
|
|
30
|
+
if (!stopped && !compacting) {
|
|
31
|
+
pi.sendUserMessage(followupPrompt, { deliverAs: "followUp" });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function compactThenFollowup(ctx: ExtensionContext): void {
|
|
37
|
+
if (compacting) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
compacting = true;
|
|
41
|
+
ctx.compact({
|
|
42
|
+
customInstructions: demoCompactionInstructions,
|
|
43
|
+
onComplete: () => {
|
|
44
|
+
compacting = false;
|
|
45
|
+
queueFollowup();
|
|
46
|
+
},
|
|
47
|
+
onError: (error) => {
|
|
48
|
+
compacting = false;
|
|
49
|
+
stopped = true;
|
|
50
|
+
ctx.ui.notify("Demo compaction failed: " + error.message, "error");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pi.on("session_start", (event, ctx) => {
|
|
56
|
+
if (started || stopped || event.reason !== "startup" || ctx.mode !== "tui") {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
started = true;
|
|
60
|
+
queueInitialPrompt();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pi.on("turn_end", (event, ctx) => {
|
|
64
|
+
if (!started || stopped || compacting || ctx.mode !== "tui") {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (event.message.role !== "assistant") {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
switch (event.message.stopReason) {
|
|
71
|
+
case "aborted":
|
|
72
|
+
case "error":
|
|
73
|
+
stopped = true;
|
|
74
|
+
return;
|
|
75
|
+
case "toolUse":
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (shouldCompactBeforeFollowup(event, ctx)) {
|
|
79
|
+
compactThenFollowup(ctx);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
queueFollowup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
pi.on("session_shutdown", () => {
|
|
86
|
+
stopped = true;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shouldCompactBeforeFollowup(event: TurnEndEvent, ctx: ExtensionContext): boolean {
|
|
91
|
+
const contextPercent = currentContextPercent(event, ctx);
|
|
92
|
+
return contextPercent !== undefined && contextPercent >= compactAtContextPercent;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function currentContextPercent(event: TurnEndEvent, ctx: ExtensionContext): number | undefined {
|
|
96
|
+
const usage = ctx.getContextUsage();
|
|
97
|
+
if (usage?.percent !== undefined && usage.percent !== null) {
|
|
98
|
+
return usage.percent;
|
|
99
|
+
}
|
|
100
|
+
if (event.message.role !== "assistant") {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const contextWindow = ctx.model?.contextWindow;
|
|
104
|
+
if (contextWindow === undefined || contextWindow <= 0) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return (event.message.usage.totalTokens / contextWindow) * 100;
|
|
108
|
+
}
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function startupModelSelectorExtensionSource(options) {
|
|
2
|
+
const startupModelsSource = JSON.stringify(options.models);
|
|
3
|
+
return `import type { ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
type SelectedModel = Parameters<ExtensionAPI["setModel"]>[0];
|
|
7
|
+
const startupModels = ${startupModelsSource} as const;
|
|
8
|
+
const startupModelKeys = new Set(startupModels.map((model) => modelKey(model)));
|
|
9
|
+
|
|
10
|
+
export default function localpiStartupModelSelector(pi: ExtensionAPI): void {
|
|
11
|
+
let opened = false;
|
|
12
|
+
|
|
13
|
+
pi.on("session_start", async (event, ctx) => {
|
|
14
|
+
if (opened || event.reason !== "startup" || ctx.mode !== "tui") {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const selectableModels = startupAvailableModels(ctx.modelRegistry);
|
|
19
|
+
if (selectableModels.length <= 1) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const scopedModels = selectableModels.map((model) => ({ model }));
|
|
23
|
+
|
|
24
|
+
opened = true;
|
|
25
|
+
const selected = await ctx.ui.custom<SelectedModel | undefined>((tui, _theme, _keybindings, done) => {
|
|
26
|
+
const settings = {
|
|
27
|
+
setDefaultModelAndProvider: () => {}
|
|
28
|
+
} as unknown as SettingsManager;
|
|
29
|
+
return new ModelSelectorComponent(
|
|
30
|
+
tui,
|
|
31
|
+
ctx.model,
|
|
32
|
+
settings,
|
|
33
|
+
startupModelRegistry(ctx.modelRegistry) as typeof ctx.modelRegistry,
|
|
34
|
+
scopedModels,
|
|
35
|
+
(model) => done(model),
|
|
36
|
+
() => done(undefined)
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (selected === undefined) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ok = await pi.setModel(selected);
|
|
45
|
+
if (!ok) {
|
|
46
|
+
ctx.ui.notify(\`No API key for \${selected.provider}/\${selected.id}\`, "error");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function startupAvailableModels(registry: {
|
|
52
|
+
getAvailable(): SelectedModel[];
|
|
53
|
+
}): SelectedModel[] {
|
|
54
|
+
return registry.getAvailable().filter((model) => startupModelKeys.has(modelKey(model)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function startupModelRegistry(registry: {
|
|
58
|
+
refresh(): void;
|
|
59
|
+
getError(): string | undefined;
|
|
60
|
+
getAvailable(): SelectedModel[];
|
|
61
|
+
find(provider: string, modelId: string): SelectedModel | undefined;
|
|
62
|
+
}): typeof registry {
|
|
63
|
+
return {
|
|
64
|
+
refresh: () => registry.refresh(),
|
|
65
|
+
getError: () => registry.getError(),
|
|
66
|
+
getAvailable: () => startupAvailableModels(registry),
|
|
67
|
+
find: (provider, modelId) => {
|
|
68
|
+
const model = registry.find(provider, modelId);
|
|
69
|
+
return model !== undefined && startupModelKeys.has(modelKey(model)) ? model : undefined;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function modelKey(model: { readonly provider: string; readonly id: string }): string {
|
|
75
|
+
return \`\${model.provider}\\u0000\${model.id}\`;
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
@@ -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,177 @@
|
|
|
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
|
+
firstOutputAt?: number;
|
|
15
|
+
outputText: string;
|
|
16
|
+
estimatedOutputTokens: number;
|
|
17
|
+
lastStatusAt: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default function localpiTokenStatus(pi: ExtensionAPI): void {
|
|
21
|
+
let currentTurn: TurnState | undefined;
|
|
22
|
+
|
|
23
|
+
pi.on("turn_start", () => {
|
|
24
|
+
currentTurn = {
|
|
25
|
+
startedAt: Date.now(),
|
|
26
|
+
outputText: "",
|
|
27
|
+
estimatedOutputTokens: 0,
|
|
28
|
+
lastStatusAt: 0
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.on("message_update", (event, ctx) => {
|
|
33
|
+
const state = currentTurn;
|
|
34
|
+
if (!ctx.hasUI || state === undefined) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event);
|
|
39
|
+
if (update.kind === "delta") {
|
|
40
|
+
state.outputText += update.text;
|
|
41
|
+
} else if (update.text.length > state.outputText.length) {
|
|
42
|
+
state.outputText = update.text;
|
|
43
|
+
}
|
|
44
|
+
state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4);
|
|
45
|
+
if (state.firstOutputAt === undefined && state.outputText.length > 0) {
|
|
46
|
+
state.firstOutputAt = now;
|
|
47
|
+
}
|
|
48
|
+
if (now - state.lastStatusAt < 250) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
state.lastStatusAt = now;
|
|
52
|
+
ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state, now)));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
pi.on("turn_end", (event, ctx) => {
|
|
56
|
+
const state = currentTurn ?? {
|
|
57
|
+
startedAt: Date.now(),
|
|
58
|
+
outputText: "",
|
|
59
|
+
estimatedOutputTokens: 0,
|
|
60
|
+
lastStatusAt: 0
|
|
61
|
+
};
|
|
62
|
+
currentTurn = undefined;
|
|
63
|
+
|
|
64
|
+
if (!ctx.hasUI || event.message.role !== "assistant") {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const usage = event.message.usage as Usage | undefined;
|
|
69
|
+
const output = usage?.output ?? state.estimatedOutputTokens;
|
|
70
|
+
const input = usage?.input ?? 0;
|
|
71
|
+
const cacheRead = usage?.cacheRead ?? 0;
|
|
72
|
+
const cacheWrite = usage?.cacheWrite ?? 0;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const elapsedSeconds = elapsed(state, now);
|
|
75
|
+
const decodeSeconds = generationElapsed(state, now);
|
|
76
|
+
const prefillText = prefillStatusText(state, input, cacheWrite);
|
|
77
|
+
const context = ctx.getContextUsage();
|
|
78
|
+
const contextText =
|
|
79
|
+
context && context.percent !== null
|
|
80
|
+
? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
|
|
81
|
+
: "ctx ?";
|
|
82
|
+
|
|
83
|
+
ctx.ui.setStatus(
|
|
84
|
+
"localpi-perf",
|
|
85
|
+
ctx.ui.theme.fg(
|
|
86
|
+
"dim",
|
|
87
|
+
[
|
|
88
|
+
\`gen \${(output / decodeSeconds).toFixed(1)} tok/s\`,
|
|
89
|
+
prefillText,
|
|
90
|
+
\`out \${output}\`,
|
|
91
|
+
\`in \${input}\`,
|
|
92
|
+
cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
|
|
93
|
+
cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
|
|
94
|
+
\`\${elapsedSeconds.toFixed(1)}s\`,
|
|
95
|
+
contextText
|
|
96
|
+
]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join(" | ")
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
104
|
+
if (ctx.hasUI) {
|
|
105
|
+
ctx.ui.setStatus("localpi-perf", "");
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusText(state: TurnState, now: number): string {
|
|
111
|
+
const elapsedSeconds = elapsed(state, now);
|
|
112
|
+
if (state.firstOutputAt === undefined) {
|
|
113
|
+
return \`prefill \${elapsedSeconds.toFixed(1)}s | out ~\${state.estimatedOutputTokens}\`;
|
|
114
|
+
}
|
|
115
|
+
const decodeSeconds = generationElapsed(state, now);
|
|
116
|
+
const prefillSeconds = secondsBetween(state.startedAt, state.firstOutputAt);
|
|
117
|
+
return [
|
|
118
|
+
\`gen \${(state.estimatedOutputTokens / decodeSeconds).toFixed(1)} tok/s\`,
|
|
119
|
+
\`out ~\${state.estimatedOutputTokens}\`,
|
|
120
|
+
\`prefill \${prefillSeconds.toFixed(1)}s\`,
|
|
121
|
+
\`total \${elapsedSeconds.toFixed(1)}s\`
|
|
122
|
+
].join(" | ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function prefillStatusText(
|
|
126
|
+
state: TurnState,
|
|
127
|
+
input: number,
|
|
128
|
+
cacheWrite: number
|
|
129
|
+
): string | undefined {
|
|
130
|
+
const tokens = prefillTokenCount(input, cacheWrite);
|
|
131
|
+
if (state.firstOutputAt === undefined || tokens <= 0) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const seconds = secondsBetween(state.startedAt, state.firstOutputAt);
|
|
135
|
+
return \`prefill \${(tokens / seconds).toFixed(1)} tok/s\`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function prefillTokenCount(input: number, cacheWrite: number): number {
|
|
139
|
+
return Math.max(input + cacheWrite, 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function generationElapsed(state: TurnState, now: number): number {
|
|
143
|
+
return secondsBetween(state.firstOutputAt ?? state.startedAt, now);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function elapsed(state: TurnState, now: number): number {
|
|
147
|
+
return secondsBetween(state.startedAt, now);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function secondsBetween(start: number, end: number): number {
|
|
151
|
+
return Math.max((end - start) / 1000, 0.001);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type TextUpdate = {
|
|
155
|
+
kind: "delta" | "snapshot";
|
|
156
|
+
text: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
function textUpdateFromUnknown(value: unknown): TextUpdate {
|
|
160
|
+
if (typeof value === "string") {
|
|
161
|
+
return { kind: "snapshot", text: value };
|
|
162
|
+
}
|
|
163
|
+
if (value && typeof value === "object") {
|
|
164
|
+
const object = value as Record<string, unknown>;
|
|
165
|
+
const delta = object["delta"];
|
|
166
|
+
const text = object["text"] ?? object["content"];
|
|
167
|
+
if (typeof delta === "string") {
|
|
168
|
+
return { kind: "delta", text: delta };
|
|
169
|
+
}
|
|
170
|
+
if (typeof text === "string") {
|
|
171
|
+
return { kind: "snapshot", text };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { kind: "snapshot", text: "" };
|
|
175
|
+
}
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
@@ -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,7 +1,12 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { localpiSettingsPath } from "../localpi/settings-state.js";
|
|
4
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";
|
|
5
10
|
export async function writeDefaultExtensions(options, extensionOptions = {}) {
|
|
6
11
|
const extensionDir = path.join(options.stateDir, "pi-extensions");
|
|
7
12
|
await mkdir(extensionDir, { recursive: true });
|
|
@@ -12,7 +17,7 @@ export async function writeDefaultExtensions(options, extensionOptions = {}) {
|
|
|
12
17
|
if (options.demo) {
|
|
13
18
|
paths.push(await writeExtension(extensionDir, "demo-mode.ts", demoModeExtensionSource(await resolveDemoPrompts(options))));
|
|
14
19
|
}
|
|
15
|
-
paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource(
|
|
20
|
+
paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource(localpiSettingsPath(options))));
|
|
16
21
|
if (options.approval) {
|
|
17
22
|
paths.push(await writeExtension(extensionDir, "tool-approval.ts", approvalExtensionSource()));
|
|
18
23
|
}
|
|
@@ -29,136 +34,6 @@ async function writeExtension(extensionDir, name, source) {
|
|
|
29
34
|
await writeFile(extensionPath, source, "utf8");
|
|
30
35
|
return extensionPath;
|
|
31
36
|
}
|
|
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
|
-
}
|
|
84
|
-
function startupModelSelectorExtensionSource(options) {
|
|
85
|
-
const startupModelsSource = JSON.stringify(options.models);
|
|
86
|
-
return `import type { ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
87
|
-
import { ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
88
|
-
|
|
89
|
-
type SelectedModel = Parameters<ExtensionAPI["setModel"]>[0];
|
|
90
|
-
const startupModels = ${startupModelsSource} as const;
|
|
91
|
-
const startupModelKeys = new Set(startupModels.map((model) => modelKey(model)));
|
|
92
|
-
|
|
93
|
-
export default function localpiStartupModelSelector(pi: ExtensionAPI): void {
|
|
94
|
-
let opened = false;
|
|
95
|
-
|
|
96
|
-
pi.on("session_start", async (event, ctx) => {
|
|
97
|
-
if (opened || event.reason !== "startup" || ctx.mode !== "tui") {
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const selectableModels = startupAvailableModels(ctx.modelRegistry);
|
|
102
|
-
if (selectableModels.length <= 1) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const scopedModels = selectableModels.map((model) => ({ model }));
|
|
106
|
-
|
|
107
|
-
opened = true;
|
|
108
|
-
const selected = await ctx.ui.custom<SelectedModel | undefined>((tui, _theme, _keybindings, done) => {
|
|
109
|
-
const settings = {
|
|
110
|
-
setDefaultModelAndProvider: () => {}
|
|
111
|
-
} as unknown as SettingsManager;
|
|
112
|
-
return new ModelSelectorComponent(
|
|
113
|
-
tui,
|
|
114
|
-
ctx.model,
|
|
115
|
-
settings,
|
|
116
|
-
startupModelRegistry(ctx.modelRegistry) as typeof ctx.modelRegistry,
|
|
117
|
-
scopedModels,
|
|
118
|
-
(model) => done(model),
|
|
119
|
-
() => done(undefined)
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
if (selected === undefined) {
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const ok = await pi.setModel(selected);
|
|
128
|
-
if (!ok) {
|
|
129
|
-
ctx.ui.notify(\`No API key for \${selected.provider}/\${selected.id}\`, "error");
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function startupAvailableModels(registry: {
|
|
135
|
-
getAvailable(): SelectedModel[];
|
|
136
|
-
}): SelectedModel[] {
|
|
137
|
-
return registry.getAvailable().filter((model) => startupModelKeys.has(modelKey(model)));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function startupModelRegistry(registry: {
|
|
141
|
-
refresh(): void;
|
|
142
|
-
getError(): string | undefined;
|
|
143
|
-
getAvailable(): SelectedModel[];
|
|
144
|
-
find(provider: string, modelId: string): SelectedModel | undefined;
|
|
145
|
-
}): typeof registry {
|
|
146
|
-
return {
|
|
147
|
-
refresh: () => registry.refresh(),
|
|
148
|
-
getError: () => registry.getError(),
|
|
149
|
-
getAvailable: () => startupAvailableModels(registry),
|
|
150
|
-
find: (provider, modelId) => {
|
|
151
|
-
const model = registry.find(provider, modelId);
|
|
152
|
-
return model !== undefined && startupModelKeys.has(modelKey(model)) ? model : undefined;
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function modelKey(model: { readonly provider: string; readonly id: string }): string {
|
|
158
|
-
return \`\${model.provider}\\u0000\${model.id}\`;
|
|
159
|
-
}
|
|
160
|
-
`;
|
|
161
|
-
}
|
|
162
37
|
function localpiSystemPrompt(approval) {
|
|
163
38
|
return [
|
|
164
39
|
"You are running through localpi, a local Pi launcher for local models.",
|
|
@@ -169,260 +44,3 @@ function localpiSystemPrompt(approval) {
|
|
|
169
44
|
"Prefer answering directly when tools are not needed."
|
|
170
45
|
].join("\n");
|
|
171
46
|
}
|
|
172
|
-
function approvalExtensionSource() {
|
|
173
|
-
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
174
|
-
|
|
175
|
-
export default function localpiToolApproval(pi: ExtensionAPI): void {
|
|
176
|
-
pi.on("before_agent_start", (event) => ({
|
|
177
|
-
systemPrompt:
|
|
178
|
-
event.systemPrompt +
|
|
179
|
-
"\\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."
|
|
180
|
-
}));
|
|
181
|
-
|
|
182
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
183
|
-
const input = formatInput(event.input);
|
|
184
|
-
|
|
185
|
-
if (!ctx.hasUI) {
|
|
186
|
-
return {
|
|
187
|
-
block: true,
|
|
188
|
-
reason: \`Tool call "\${event.toolName}" was blocked and did not run because interactive approval is required.\`
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const ok = await ctx.ui.confirm(\`Allow tool call: \${event.toolName}?\`, input);
|
|
193
|
-
if (!ok) {
|
|
194
|
-
return { block: true, reason: "Tool call was blocked by the user and did not run." };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return undefined;
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function formatInput(input: unknown): string {
|
|
202
|
-
let text: string;
|
|
203
|
-
try {
|
|
204
|
-
text = JSON.stringify(input, null, 2);
|
|
205
|
-
} catch {
|
|
206
|
-
text = String(input);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const maxLength = 4000;
|
|
210
|
-
if (text.length <= maxLength) {
|
|
211
|
-
return text;
|
|
212
|
-
}
|
|
213
|
-
return \`\${text.slice(0, maxLength)}\\n... truncated ...\`;
|
|
214
|
-
}
|
|
215
|
-
`;
|
|
216
|
-
}
|
|
217
|
-
function tokenStatusExtensionSource() {
|
|
218
|
-
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
219
|
-
|
|
220
|
-
type Usage = {
|
|
221
|
-
input?: number;
|
|
222
|
-
output?: number;
|
|
223
|
-
cacheRead?: number;
|
|
224
|
-
cacheWrite?: number;
|
|
225
|
-
totalTokens?: number;
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
type TurnState = {
|
|
229
|
-
startedAt: number;
|
|
230
|
-
outputText: string;
|
|
231
|
-
estimatedOutputTokens: number;
|
|
232
|
-
lastStatusAt: number;
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
export default function localpiTokenStatus(pi: ExtensionAPI): void {
|
|
236
|
-
let currentTurn: TurnState | undefined;
|
|
237
|
-
|
|
238
|
-
pi.on("turn_start", () => {
|
|
239
|
-
currentTurn = {
|
|
240
|
-
startedAt: Date.now(),
|
|
241
|
-
outputText: "",
|
|
242
|
-
estimatedOutputTokens: 0,
|
|
243
|
-
lastStatusAt: 0
|
|
244
|
-
};
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
pi.on("message_update", (event, ctx) => {
|
|
248
|
-
const state = currentTurn;
|
|
249
|
-
if (!ctx.hasUI || state === undefined) {
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event);
|
|
253
|
-
if (update.kind === "delta") {
|
|
254
|
-
state.outputText += update.text;
|
|
255
|
-
} else if (update.text.length > state.outputText.length) {
|
|
256
|
-
state.outputText = update.text;
|
|
257
|
-
}
|
|
258
|
-
state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4);
|
|
259
|
-
if (Date.now() - state.lastStatusAt < 250) {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
state.lastStatusAt = Date.now();
|
|
263
|
-
ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state)));
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
pi.on("turn_end", (event, ctx) => {
|
|
267
|
-
const state = currentTurn ?? {
|
|
268
|
-
startedAt: Date.now(),
|
|
269
|
-
outputText: "",
|
|
270
|
-
estimatedOutputTokens: 0,
|
|
271
|
-
lastStatusAt: 0
|
|
272
|
-
};
|
|
273
|
-
currentTurn = undefined;
|
|
274
|
-
|
|
275
|
-
if (!ctx.hasUI || event.message.role !== "assistant") {
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const usage = event.message.usage as Usage | undefined;
|
|
280
|
-
const output = usage?.output ?? state.estimatedOutputTokens;
|
|
281
|
-
const input = usage?.input ?? 0;
|
|
282
|
-
const cacheRead = usage?.cacheRead ?? 0;
|
|
283
|
-
const cacheWrite = usage?.cacheWrite ?? 0;
|
|
284
|
-
const elapsedSeconds = elapsed(state);
|
|
285
|
-
const context = ctx.getContextUsage();
|
|
286
|
-
const contextText =
|
|
287
|
-
context && context.percent !== null
|
|
288
|
-
? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
|
|
289
|
-
: "ctx ?";
|
|
290
|
-
|
|
291
|
-
ctx.ui.setStatus(
|
|
292
|
-
"localpi-perf",
|
|
293
|
-
ctx.ui.theme.fg(
|
|
294
|
-
"dim",
|
|
295
|
-
[
|
|
296
|
-
\`\${(output / elapsedSeconds).toFixed(1)} tok/s\`,
|
|
297
|
-
\`out \${output}\`,
|
|
298
|
-
\`in \${input}\`,
|
|
299
|
-
cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
|
|
300
|
-
cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
|
|
301
|
-
\`\${elapsedSeconds.toFixed(1)}s\`,
|
|
302
|
-
contextText
|
|
303
|
-
]
|
|
304
|
-
.filter(Boolean)
|
|
305
|
-
.join(" | ")
|
|
306
|
-
)
|
|
307
|
-
);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
311
|
-
if (ctx.hasUI) {
|
|
312
|
-
ctx.ui.setStatus("localpi-perf", "");
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function statusText(state: TurnState): string {
|
|
318
|
-
const elapsedSeconds = elapsed(state);
|
|
319
|
-
return \`\${(state.estimatedOutputTokens / elapsedSeconds).toFixed(1)} tok/s | out ~\${state.estimatedOutputTokens} | \${elapsedSeconds.toFixed(1)}s\`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function elapsed(state: TurnState): number {
|
|
323
|
-
return Math.max((Date.now() - state.startedAt) / 1000, 0.001);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
type TextUpdate = {
|
|
327
|
-
kind: "delta" | "snapshot";
|
|
328
|
-
text: string;
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
function textUpdateFromUnknown(value: unknown): TextUpdate {
|
|
332
|
-
if (typeof value === "string") {
|
|
333
|
-
return { kind: "snapshot", text: value };
|
|
334
|
-
}
|
|
335
|
-
if (value && typeof value === "object") {
|
|
336
|
-
const object = value as Record<string, unknown>;
|
|
337
|
-
const delta = object["delta"];
|
|
338
|
-
const text = object["text"] ?? object["content"];
|
|
339
|
-
if (typeof delta === "string") {
|
|
340
|
-
return { kind: "delta", text: delta };
|
|
341
|
-
}
|
|
342
|
-
if (typeof text === "string") {
|
|
343
|
-
return { kind: "snapshot", text };
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return { kind: "snapshot", text: "" };
|
|
347
|
-
}
|
|
348
|
-
`;
|
|
349
|
-
}
|
|
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";
|
|
355
|
-
|
|
356
|
-
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
357
|
-
|
|
358
|
-
const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
359
|
-
const settingsPath = ${settingsPathSource};
|
|
360
|
-
|
|
361
|
-
export default function localpiThinkingControl(pi: ExtensionAPI): void {
|
|
362
|
-
pi.registerCommand("thinking", {
|
|
363
|
-
description: "Set localpi thinking level",
|
|
364
|
-
getArgumentCompletions: (prefix) => {
|
|
365
|
-
const trimmed = prefix.trim().toLowerCase();
|
|
366
|
-
const matches = levels.filter((level) => level.startsWith(trimmed));
|
|
367
|
-
return matches.length === 0 ? null : matches.map((level) => ({ value: level, label: level }));
|
|
368
|
-
},
|
|
369
|
-
handler: async (args, ctx) => {
|
|
370
|
-
const requested = parseThinkingLevel(args);
|
|
371
|
-
const level = requested ?? (await promptThinkingLevel(pi.getThinkingLevel(), ctx));
|
|
372
|
-
if (level === undefined) {
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
pi.setThinkingLevel(level);
|
|
376
|
-
const actual = pi.getThinkingLevel();
|
|
377
|
-
await persistThinking(actual);
|
|
378
|
-
ctx.ui.notify(
|
|
379
|
-
actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`,
|
|
380
|
-
actual === level ? "info" : "warning"
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
pi.on("thinking_level_select", async (event, ctx) => {
|
|
386
|
-
await persistThinking(event.level);
|
|
387
|
-
ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
391
|
-
await persistThinking(pi.getThinkingLevel());
|
|
392
|
-
ctx.ui.setStatus("localpi-thinking", undefined);
|
|
393
|
-
});
|
|
394
|
-
}
|
|
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
|
-
|
|
412
|
-
async function promptThinkingLevel(
|
|
413
|
-
current: ThinkingLevel,
|
|
414
|
-
ctx: { readonly ui: { select(title: string, options: string[]): Promise<string | undefined> } }
|
|
415
|
-
): Promise<ThinkingLevel | undefined> {
|
|
416
|
-
const selected = await ctx.ui.select(
|
|
417
|
-
"Thinking level",
|
|
418
|
-
levels.map((level) => (level === current ? \`\${level} (current)\` : level))
|
|
419
|
-
);
|
|
420
|
-
return selected === undefined ? undefined : parseThinkingLevel(selected);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function parseThinkingLevel(value: string): ThinkingLevel | undefined {
|
|
424
|
-
const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
|
|
425
|
-
return levels.find((level) => level === normalized);
|
|
426
|
-
}
|
|
427
|
-
`;
|
|
428
|
-
}
|
|
@@ -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
|
+
}
|
|
@@ -150,7 +150,7 @@ session dir: ~/.local/state/localpi/sessions
|
|
|
150
150
|
Localpi installs two default extensions:
|
|
151
151
|
|
|
152
152
|
- tool approval gate: ask before each tool call, and tell the model clearly when a tool call was blocked
|
|
153
|
-
- token status: show live
|
|
153
|
+
- token status: show live generation speed while streaming, then final prefill and generation rates when usage data is available
|
|
154
154
|
|
|
155
155
|
## System Prompt
|
|
156
156
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "localpi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Swiss army knife for running Pi with local inference engines.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -53,5 +53,8 @@
|
|
|
53
53
|
"typescript": "^5.0.0",
|
|
54
54
|
"typescript-eslint": "^8.0.0",
|
|
55
55
|
"vitest": "^3.0.0"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@dutifuldev/pi-factory": "^0.1.1"
|
|
56
59
|
}
|
|
57
60
|
}
|
package/dist/src/pi/config.js
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
export async function writeRuntimeConfig(options, connection) {
|
|
4
|
-
const configDir = path.join(options.stateDir, "pi-config-runtime");
|
|
5
|
-
await mkdir(configDir, { recursive: true });
|
|
6
|
-
const modelsPath = path.join(configDir, "models.json");
|
|
7
|
-
const settingsPath = path.join(configDir, "settings.json");
|
|
8
|
-
await writeFile(modelsPath, `${JSON.stringify(modelsConfig(options, connection), null, 2)}\n`);
|
|
9
|
-
await writeFile(settingsPath, `${JSON.stringify(settingsConfig(options, connection), null, 2)}\n`);
|
|
10
|
-
return { configDir, modelsPath, settingsPath };
|
|
11
|
-
}
|
|
12
|
-
function modelsConfig(options, connection) {
|
|
13
|
-
const models = connection.catalogModels.length === 0 ? fallbackCatalog(connection) : connection.catalogModels;
|
|
14
|
-
return {
|
|
15
|
-
providers: Object.fromEntries(groupedByProvider(models).map((entry) => [
|
|
16
|
-
entry.providerId,
|
|
17
|
-
providerConfig(options, connection, entry)
|
|
18
|
-
]))
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
function groupedByProvider(models) {
|
|
22
|
-
const groups = new Map();
|
|
23
|
-
for (const model of models) {
|
|
24
|
-
const existing = groups.get(model.providerId);
|
|
25
|
-
groups.set(model.providerId, existing === undefined
|
|
26
|
-
? { providerId: model.providerId, baseUrl: model.baseUrl, models: [model] }
|
|
27
|
-
: { ...existing, models: [...existing.models, model] });
|
|
28
|
-
}
|
|
29
|
-
return [...groups.values()];
|
|
30
|
-
}
|
|
31
|
-
function providerConfig(options, connection, group) {
|
|
32
|
-
return {
|
|
33
|
-
baseUrl: group.baseUrl,
|
|
34
|
-
api: "openai-completions",
|
|
35
|
-
apiKey: "local",
|
|
36
|
-
compat: {
|
|
37
|
-
supportsDeveloperRole: false,
|
|
38
|
-
supportsReasoningEffort: false
|
|
39
|
-
},
|
|
40
|
-
models: group.models.map((model) => modelConfig(options, connection, model))
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
function modelConfig(options, connection, model) {
|
|
44
|
-
return withoutUndefined({
|
|
45
|
-
id: model.modelId,
|
|
46
|
-
name: model.displayName,
|
|
47
|
-
reasoning: model.reasoning ?? false,
|
|
48
|
-
compat: model.thinkingFormat === undefined ? undefined : { thinkingFormat: model.thinkingFormat },
|
|
49
|
-
input: ["text"],
|
|
50
|
-
contextWindow: modelContextWindow(options, model),
|
|
51
|
-
maxTokens: model.maxTokens ?? options.maxTokens,
|
|
52
|
-
cost: {
|
|
53
|
-
input: 0,
|
|
54
|
-
output: 0,
|
|
55
|
-
cacheRead: 0,
|
|
56
|
-
cacheWrite: 0
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
function modelContextWindow(options, model) {
|
|
61
|
-
if (options.contextWindow !== undefined) {
|
|
62
|
-
return options.contextWindow;
|
|
63
|
-
}
|
|
64
|
-
return model.contextWindow;
|
|
65
|
-
}
|
|
66
|
-
function fallbackCatalog(connection) {
|
|
67
|
-
return [
|
|
68
|
-
{
|
|
69
|
-
providerId: connection.providerId,
|
|
70
|
-
providerName: connection.providerName,
|
|
71
|
-
runtime: connection.runtime.startsWith("llama-server")
|
|
72
|
-
? "managed-llama-server"
|
|
73
|
-
: "openai-compatible",
|
|
74
|
-
baseUrl: connection.baseUrl,
|
|
75
|
-
modelId: connection.model,
|
|
76
|
-
aliases: [],
|
|
77
|
-
displayName: `Local model (${connection.model})`,
|
|
78
|
-
reasoning: false,
|
|
79
|
-
capabilities: ["text"],
|
|
80
|
-
availability: "loaded",
|
|
81
|
-
...(connection.contextWindow === undefined ? {} : { contextWindow: connection.contextWindow })
|
|
82
|
-
}
|
|
83
|
-
];
|
|
84
|
-
}
|
|
85
|
-
function withoutUndefined(value) {
|
|
86
|
-
return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
|
|
87
|
-
}
|
|
88
|
-
function settingsConfig(options, connection) {
|
|
89
|
-
const contextWindow = options.contextWindow ?? connection.contextWindow;
|
|
90
|
-
return {
|
|
91
|
-
defaultProvider: connection.providerId,
|
|
92
|
-
defaultModel: connection.model,
|
|
93
|
-
defaultThinkingLevel: options.thinking,
|
|
94
|
-
enableInstallTelemetry: false,
|
|
95
|
-
quietStartup: true,
|
|
96
|
-
compaction: compactionConfig(contextWindow)
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
function compactionConfig(contextWindow) {
|
|
100
|
-
if (contextWindow === undefined) {
|
|
101
|
-
return { enabled: false };
|
|
102
|
-
}
|
|
103
|
-
return {
|
|
104
|
-
enabled: true,
|
|
105
|
-
reserveTokens: Math.max(256, Math.min(16384, Math.floor(contextWindow / 4))),
|
|
106
|
-
keepRecentTokens: Math.max(512, Math.min(20000, Math.floor(contextWindow / 2)))
|
|
107
|
-
};
|
|
108
|
-
}
|
package/dist/src/pi/launch.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
3
|
-
export async function createLaunchPlan(options, runtimeConfig, connection, extensions) {
|
|
4
|
-
await mkdir(options.sessionDir, { recursive: true });
|
|
5
|
-
return {
|
|
6
|
-
command: options.piCommand,
|
|
7
|
-
args: [
|
|
8
|
-
"--provider",
|
|
9
|
-
connection.providerId,
|
|
10
|
-
"--model",
|
|
11
|
-
connection.model,
|
|
12
|
-
"--thinking",
|
|
13
|
-
options.thinking,
|
|
14
|
-
...extensionArgs(extensions),
|
|
15
|
-
"--append-system-prompt",
|
|
16
|
-
extensions.systemPrompt,
|
|
17
|
-
...withDefaultTools(options.forwardedArgs, options.tools)
|
|
18
|
-
],
|
|
19
|
-
env: {
|
|
20
|
-
PI_CODING_AGENT_DIR: runtimeConfig.configDir,
|
|
21
|
-
PI_CODING_AGENT_SESSION_DIR: options.sessionDir,
|
|
22
|
-
PI_OFFLINE: process.env["PI_OFFLINE"] ?? "1",
|
|
23
|
-
PI_TELEMETRY: process.env["PI_TELEMETRY"] ?? "0",
|
|
24
|
-
PI_SKIP_VERSION_CHECK: process.env["PI_SKIP_VERSION_CHECK"] ?? "1"
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
export async function execLaunchPlan(plan) {
|
|
29
|
-
const stdio = "inherit";
|
|
30
|
-
const child = spawn(shellCommand(plan.command, plan.args), {
|
|
31
|
-
shell: true,
|
|
32
|
-
stdio,
|
|
33
|
-
env: { ...process.env, ...plan.env }
|
|
34
|
-
});
|
|
35
|
-
child.stdout?.resume();
|
|
36
|
-
return await new Promise((resolve, reject) => {
|
|
37
|
-
child.on("error", reject);
|
|
38
|
-
child.on("exit", (code, signal) => {
|
|
39
|
-
if (signal !== null) {
|
|
40
|
-
process.kill(process.pid, signal);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
resolve(code ?? 0);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
function shellCommand(command, args) {
|
|
48
|
-
return [command, ...args.map(shellQuote)].join(" ");
|
|
49
|
-
}
|
|
50
|
-
function shellQuote(value) {
|
|
51
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
52
|
-
}
|
|
53
|
-
function extensionArgs(extensions) {
|
|
54
|
-
return extensions.paths.flatMap((extensionPath) => ["--extension", extensionPath]);
|
|
55
|
-
}
|
|
56
|
-
function withDefaultTools(args, tools) {
|
|
57
|
-
if (tools === undefined || hasToolFlag(args)) {
|
|
58
|
-
return args;
|
|
59
|
-
}
|
|
60
|
-
return ["--tools", tools, ...args];
|
|
61
|
-
}
|
|
62
|
-
function hasToolFlag(args) {
|
|
63
|
-
return args.some((arg) => arg === "--tools" || arg === "-t" || arg === "--no-tools" || arg === "-nt");
|
|
64
|
-
}
|