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,98 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { asObject, requiredString } from "../common/json.js";
|
|
5
|
+
import { normalizeBaseUrl } from "../llm/openai.js";
|
|
6
|
+
export async function loadLocalModelProfile(options) {
|
|
7
|
+
if (options.modelProfileFile === undefined) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const raw = await readFile(expandHome(options.modelProfileFile), "utf8");
|
|
11
|
+
return parseLocalModelProfile(JSON.parse(raw), options.modelProfileFile);
|
|
12
|
+
}
|
|
13
|
+
export function profileMatchesModel(profile, modelId) {
|
|
14
|
+
return modelId === profile.model || modelId === profile.id;
|
|
15
|
+
}
|
|
16
|
+
export function profileMatchesBaseUrl(profile, baseUrl) {
|
|
17
|
+
return (profile.baseUrl === undefined || normalizeBaseUrl(profile.baseUrl) === normalizeBaseUrl(baseUrl));
|
|
18
|
+
}
|
|
19
|
+
function parseLocalModelProfile(value, source) {
|
|
20
|
+
const root = asObject(value, `model profile ${source}`);
|
|
21
|
+
const client = optionalObject(root["client"], `model profile ${source} client`);
|
|
22
|
+
const capabilities = optionalObject(root["capabilities"], `model profile ${source} capabilities`);
|
|
23
|
+
const contextWindow = client === undefined
|
|
24
|
+
? undefined
|
|
25
|
+
: optionalPositiveInteger(client["context_window"], `model profile ${source} client.context_window`);
|
|
26
|
+
const maxTokens = client === undefined
|
|
27
|
+
? undefined
|
|
28
|
+
: optionalPositiveInteger(client["max_tokens"], `model profile ${source} client.max_tokens`);
|
|
29
|
+
const reasoning = capabilities === undefined
|
|
30
|
+
? undefined
|
|
31
|
+
: optionalBoolean(capabilities["reasoning"], `model profile ${source} capabilities.reasoning`);
|
|
32
|
+
const thinkingFormat = capabilities === undefined
|
|
33
|
+
? undefined
|
|
34
|
+
: optionalThinkingFormat(optionalProfileString(capabilities["thinking_format"], `model profile ${source} capabilities.thinking_format`), `model profile ${source} capabilities.thinking_format`);
|
|
35
|
+
return withoutUndefined({
|
|
36
|
+
id: requiredString(root["id"], `model profile ${source} id`),
|
|
37
|
+
model: requiredString(root["model"], `model profile ${source} model`),
|
|
38
|
+
baseUrl: optionalProfileString(root["base_url"], `model profile ${source} base_url`),
|
|
39
|
+
client: client === undefined
|
|
40
|
+
? undefined
|
|
41
|
+
: withoutUndefined({
|
|
42
|
+
contextWindow,
|
|
43
|
+
maxTokens
|
|
44
|
+
}),
|
|
45
|
+
capabilities: capabilities === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: withoutUndefined({
|
|
48
|
+
reasoning,
|
|
49
|
+
thinkingFormat
|
|
50
|
+
})
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function optionalObject(value, context) {
|
|
54
|
+
return value === undefined ? undefined : asObject(value, context);
|
|
55
|
+
}
|
|
56
|
+
function optionalProfileString(value, context) {
|
|
57
|
+
if (value === undefined) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (typeof value !== "string") {
|
|
61
|
+
throw new Error(`${context} must be a string`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function optionalBoolean(value, context) {
|
|
66
|
+
if (value === undefined) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== "boolean") {
|
|
70
|
+
throw new Error(`${context} must be a boolean`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function optionalPositiveInteger(value, context) {
|
|
75
|
+
if (value === undefined) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
79
|
+
throw new Error(`${context} must be a positive integer`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function optionalThinkingFormat(value, context) {
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (value === "deepseek" || value === "qwen-chat-template") {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`${context} must be deepseek or qwen-chat-template`);
|
|
91
|
+
}
|
|
92
|
+
function expandHome(value) {
|
|
93
|
+
const home = os.homedir();
|
|
94
|
+
return value === "~" || value.startsWith("~/") ? path.join(home, value.slice(2)) : value;
|
|
95
|
+
}
|
|
96
|
+
function withoutUndefined(value) {
|
|
97
|
+
return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
|
|
98
|
+
}
|
|
@@ -18,10 +18,13 @@ export function defaultOptions() {
|
|
|
18
18
|
provider: process.env["LOCALPI_PROVIDER"],
|
|
19
19
|
customProviderId: envString("LOCALPI_PROVIDER_ID", "local-openai"),
|
|
20
20
|
providersFile: process.env["LOCALPI_PROVIDERS_FILE"],
|
|
21
|
+
modelProfileFile: process.env["LOCALPI_MODEL_PROFILE"] ?? process.env["LOCALPAGER_AGENT_PROFILE"],
|
|
22
|
+
modelReasoning: envOptionalBoolean("LOCALPI_MODEL_REASONING", "LOCALPAGER_AGENT_REASONING"),
|
|
23
|
+
modelThinkingFormat: envOptionalThinkingFormat("LOCALPI_MODEL_THINKING_FORMAT", "LOCALPAGER_AGENT_THINKING_FORMAT"),
|
|
21
24
|
stateDir,
|
|
22
25
|
sessionDir: defaultSessionDir(stateDir),
|
|
23
26
|
piCommand: envString("LOCALPI_PI_CMD", "npx -y @earendil-works/pi-coding-agent@latest"),
|
|
24
|
-
thinking: parseThinkingLevel(envString("LOCALPI_THINKING", "
|
|
27
|
+
thinking: parseThinkingLevel(envString("LOCALPI_THINKING", "medium")),
|
|
25
28
|
contextWindow: envOptionalPositiveInteger("LOCALPI_CONTEXT_WINDOW"),
|
|
26
29
|
maxTokens: envPositiveInteger("LOCALPI_MAX_TOKENS", "8192"),
|
|
27
30
|
timeoutMs: envPositiveInteger("LOCALPI_TIMEOUT_MS", "3000"),
|
|
@@ -34,6 +37,12 @@ export function defaultOptions() {
|
|
|
34
37
|
tools: envString("LOCALPI_TOOLS", "read,bash,edit,write,grep,find,ls"),
|
|
35
38
|
approval: envBoolean("LOCALPI_APPROVAL", true),
|
|
36
39
|
tokenStatus: envBoolean("LOCALPI_TOKEN_STATUS", true),
|
|
40
|
+
demo: envBoolean("LOCALPI_DEMO", false),
|
|
41
|
+
demoFromCli: false,
|
|
42
|
+
demoInitialPrompt: process.env["LOCALPI_DEMO_INITIAL_PROMPT"],
|
|
43
|
+
demoInitialPromptFile: process.env["LOCALPI_DEMO_INITIAL_PROMPT_FILE"],
|
|
44
|
+
demoFollowupPrompt: process.env["LOCALPI_DEMO_FOLLOWUP_PROMPT"],
|
|
45
|
+
demoFollowupPromptFile: process.env["LOCALPI_DEMO_FOLLOWUP_PROMPT_FILE"],
|
|
37
46
|
status: false,
|
|
38
47
|
stop: false,
|
|
39
48
|
list: false,
|
|
@@ -43,6 +52,7 @@ export function defaultOptions() {
|
|
|
43
52
|
export function parseLocalpiArgs(args) {
|
|
44
53
|
let options = defaultOptions();
|
|
45
54
|
const forwardedArgs = [];
|
|
55
|
+
const demoPromptFlags = demoPromptFlagTracker();
|
|
46
56
|
for (let index = 0; index < args.length; index += 1) {
|
|
47
57
|
const arg = args[index];
|
|
48
58
|
if (arg === undefined) {
|
|
@@ -55,6 +65,7 @@ export function parseLocalpiArgs(args) {
|
|
|
55
65
|
if (arg === "-h" || arg === "--help") {
|
|
56
66
|
return { ...options, forwardedArgs: ["--help"] };
|
|
57
67
|
}
|
|
68
|
+
trackDemoPromptFlag(demoPromptFlags, arg);
|
|
58
69
|
const parsed = parseLocalpiFlag(options, args, index);
|
|
59
70
|
if (parsed !== undefined) {
|
|
60
71
|
options = parsed.options;
|
|
@@ -63,7 +74,7 @@ export function parseLocalpiArgs(args) {
|
|
|
63
74
|
}
|
|
64
75
|
forwardedArgs.push(arg);
|
|
65
76
|
}
|
|
66
|
-
return { ...options, forwardedArgs };
|
|
77
|
+
return normalizeDemoPromptPrecedence({ ...options, forwardedArgs }, demoPromptFlags);
|
|
67
78
|
}
|
|
68
79
|
export function usage() {
|
|
69
80
|
return `${[
|
|
@@ -89,8 +100,21 @@ export function usage() {
|
|
|
89
100
|
" --chat-template <path> llama.cpp chat template file",
|
|
90
101
|
" --tools <list> Pi tools allow list",
|
|
91
102
|
" --providers-file <path> localpi provider registry JSON",
|
|
103
|
+
" --model-profile <path> local model capability profile JSON",
|
|
104
|
+
" --model-reasoning <bool> override generated Pi reasoning capability",
|
|
105
|
+
" --model-thinking-format <format>",
|
|
106
|
+
" override generated Pi thinking format",
|
|
92
107
|
" --no-approval do not ask before tool calls",
|
|
93
108
|
" --no-token-status do not install token status extension",
|
|
109
|
+
" --demo endlessly run Pi prompts for demo mode",
|
|
110
|
+
" --demo-initial-prompt <text>",
|
|
111
|
+
" first demo prompt",
|
|
112
|
+
" --demo-followup-prompt <text>",
|
|
113
|
+
" repeated demo prompt after the first run",
|
|
114
|
+
" --demo-initial-prompt-file <path>",
|
|
115
|
+
" UTF-8 file for the first demo prompt",
|
|
116
|
+
" --demo-followup-prompt-file <path>",
|
|
117
|
+
" UTF-8 file for repeated demo prompts",
|
|
94
118
|
" --status print runtime status and exit",
|
|
95
119
|
" --stop stop the localpi-owned llama-server",
|
|
96
120
|
" --list list model aliases",
|
|
@@ -132,7 +156,8 @@ const booleanFlagUpdaters = {
|
|
|
132
156
|
"--stop": (options) => ({ ...options, stop: true }),
|
|
133
157
|
"--list": (options) => ({ ...options, list: true }),
|
|
134
158
|
"--no-approval": (options) => ({ ...options, approval: false }),
|
|
135
|
-
"--no-token-status": (options) => ({ ...options, tokenStatus: false })
|
|
159
|
+
"--no-token-status": (options) => ({ ...options, tokenStatus: false }),
|
|
160
|
+
"--demo": (options) => ({ ...options, demo: true, demoFromCli: true })
|
|
136
161
|
};
|
|
137
162
|
const valueFlagUpdaters = {
|
|
138
163
|
"--runtime": (options, value) => ({ ...options, runtime: parseRuntime(value) }),
|
|
@@ -141,6 +166,12 @@ const valueFlagUpdaters = {
|
|
|
141
166
|
"--provider": (options, value) => ({ ...options, provider: value }),
|
|
142
167
|
"--provider-id": (options, value) => ({ ...options, customProviderId: value }),
|
|
143
168
|
"--providers-file": (options, value) => ({ ...options, providersFile: value }),
|
|
169
|
+
"--model-profile": (options, value) => ({ ...options, modelProfileFile: value }),
|
|
170
|
+
"--model-reasoning": (options, value) => ({ ...options, modelReasoning: parseBoolean(value) }),
|
|
171
|
+
"--model-thinking-format": (options, value) => ({
|
|
172
|
+
...options,
|
|
173
|
+
modelThinkingFormat: parseModelThinkingFormat(value)
|
|
174
|
+
}),
|
|
144
175
|
"--state-dir": (options, value) => ({ ...options, stateDir: value }),
|
|
145
176
|
"--session-dir": (options, value) => ({ ...options, sessionDir: value }),
|
|
146
177
|
"--pi-command": (options, value) => ({ ...options, piCommand: value }),
|
|
@@ -159,7 +190,17 @@ const valueFlagUpdaters = {
|
|
|
159
190
|
"--gpu-layers": (options, value) => ({ ...options, gpuLayers: parseNonNegativeInteger(value) }),
|
|
160
191
|
"--parallel": (options, value) => ({ ...options, parallel: parsePositiveInteger(value) }),
|
|
161
192
|
"--chat-template": (options, value) => ({ ...options, chatTemplate: value }),
|
|
162
|
-
"--tools": (options, value) => ({ ...options, tools: value })
|
|
193
|
+
"--tools": (options, value) => ({ ...options, tools: value }),
|
|
194
|
+
"--demo-initial-prompt": (options, value) => ({ ...options, demoInitialPrompt: value }),
|
|
195
|
+
"--demo-followup-prompt": (options, value) => ({ ...options, demoFollowupPrompt: value }),
|
|
196
|
+
"--demo-initial-prompt-file": (options, value) => ({
|
|
197
|
+
...options,
|
|
198
|
+
demoInitialPromptFile: value
|
|
199
|
+
}),
|
|
200
|
+
"--demo-followup-prompt-file": (options, value) => ({
|
|
201
|
+
...options,
|
|
202
|
+
demoFollowupPromptFile: value
|
|
203
|
+
})
|
|
163
204
|
};
|
|
164
205
|
function parseValueFlag(options, args, index, flag) {
|
|
165
206
|
const updater = valueFlagUpdaters[flag];
|
|
@@ -168,6 +209,37 @@ function parseValueFlag(options, args, index, flag) {
|
|
|
168
209
|
}
|
|
169
210
|
return { options: updater(options, requiredValue(args, index + 1, flag)), advance: 1 };
|
|
170
211
|
}
|
|
212
|
+
function demoPromptFlagTracker() {
|
|
213
|
+
return {
|
|
214
|
+
initialText: false,
|
|
215
|
+
initialFile: false,
|
|
216
|
+
followupText: false,
|
|
217
|
+
followupFile: false
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function trackDemoPromptFlag(tracker, arg) {
|
|
221
|
+
switch (arg) {
|
|
222
|
+
case "--demo-initial-prompt":
|
|
223
|
+
tracker.initialText = true;
|
|
224
|
+
return;
|
|
225
|
+
case "--demo-initial-prompt-file":
|
|
226
|
+
tracker.initialFile = true;
|
|
227
|
+
return;
|
|
228
|
+
case "--demo-followup-prompt":
|
|
229
|
+
tracker.followupText = true;
|
|
230
|
+
return;
|
|
231
|
+
case "--demo-followup-prompt-file":
|
|
232
|
+
tracker.followupFile = true;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function normalizeDemoPromptPrecedence(options, tracker) {
|
|
237
|
+
return {
|
|
238
|
+
...options,
|
|
239
|
+
demoInitialPromptFile: tracker.initialText && !tracker.initialFile ? undefined : options.demoInitialPromptFile,
|
|
240
|
+
demoFollowupPromptFile: tracker.followupText && !tracker.followupFile ? undefined : options.demoFollowupPromptFile
|
|
241
|
+
};
|
|
242
|
+
}
|
|
171
243
|
function parseRuntime(value) {
|
|
172
244
|
if (value === "auto" ||
|
|
173
245
|
value === "llama-server" ||
|
|
@@ -186,6 +258,12 @@ export function parseThinkingLevel(value) {
|
|
|
186
258
|
}
|
|
187
259
|
throw new Error(`unknown thinking level ${value}; expected off, minimal, low, medium, high, or xhigh`);
|
|
188
260
|
}
|
|
261
|
+
function parseModelThinkingFormat(value) {
|
|
262
|
+
if (value === "deepseek" || value === "qwen-chat-template") {
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
throw new Error(`unknown model thinking format ${value}; expected deepseek or qwen-chat-template`);
|
|
266
|
+
}
|
|
189
267
|
function envString(name, fallback) {
|
|
190
268
|
return process.env[name] ?? fallback;
|
|
191
269
|
}
|
|
@@ -203,11 +281,22 @@ function envOptionalPositiveInteger(name) {
|
|
|
203
281
|
const value = process.env[name];
|
|
204
282
|
return value === undefined ? undefined : parsePositiveInteger(value);
|
|
205
283
|
}
|
|
284
|
+
function envOptionalBoolean(primaryName, fallbackName) {
|
|
285
|
+
const [name, value] = envFirst([primaryName, fallbackName]);
|
|
286
|
+
return value === undefined ? undefined : parseBoolean(value, name);
|
|
287
|
+
}
|
|
288
|
+
function envOptionalThinkingFormat(primaryName, fallbackName) {
|
|
289
|
+
const [, value] = envFirst([primaryName, fallbackName]);
|
|
290
|
+
return value === undefined ? undefined : parseModelThinkingFormat(value);
|
|
291
|
+
}
|
|
206
292
|
function envBoolean(name, fallback) {
|
|
207
293
|
const value = process.env[name];
|
|
208
294
|
if (value === undefined) {
|
|
209
295
|
return fallback;
|
|
210
296
|
}
|
|
297
|
+
return parseBoolean(value, name);
|
|
298
|
+
}
|
|
299
|
+
function parseBoolean(value, name = "value") {
|
|
211
300
|
if (["1", "true", "yes", "on"].includes(value.toLowerCase())) {
|
|
212
301
|
return true;
|
|
213
302
|
}
|
|
@@ -216,6 +305,15 @@ function envBoolean(name, fallback) {
|
|
|
216
305
|
}
|
|
217
306
|
throw new Error(`${name} must be boolean-like, got ${value}`);
|
|
218
307
|
}
|
|
308
|
+
function envFirst(names) {
|
|
309
|
+
for (const name of names) {
|
|
310
|
+
const value = process.env[name];
|
|
311
|
+
if (value !== undefined) {
|
|
312
|
+
return [name, value];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return [names[0] ?? "", undefined];
|
|
316
|
+
}
|
|
219
317
|
function defaultSessionDir(stateDir) {
|
|
220
318
|
return envString("LOCALPI_SESSION_DIR", envString("PI_CODING_AGENT_SESSION_DIR", path.join(stateDir, "sessions")));
|
|
221
319
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatCatalogWarning,
|
|
1
|
+
import { formatCatalogWarning, managedCapabilityConfig } from "./catalog.js";
|
|
2
2
|
export function connectionStatus(connection) {
|
|
3
3
|
return ([
|
|
4
4
|
`runtime: ${connection.runtime}`,
|
|
@@ -39,9 +39,7 @@ export function catalogModelFromModelInfo(providerId, providerName, runtime, bas
|
|
|
39
39
|
aliases: [],
|
|
40
40
|
displayName: `${providerName} / ${model.id}`,
|
|
41
41
|
maxTokens: options.maxTokens,
|
|
42
|
-
...(runtime === "managed-llama-server"
|
|
43
|
-
? { reasoning: managedModelSupportsReasoning(model.id) }
|
|
44
|
-
: {}),
|
|
42
|
+
...(runtime === "managed-llama-server" ? managedCapabilityConfig(model.id, options) : {}),
|
|
45
43
|
capabilities: ["text"],
|
|
46
44
|
availability: "loaded",
|
|
47
45
|
...optionalContextWindow(contextWindow ?? model.contextWindow)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { asObject, optionalString } from "../common/json.js";
|
|
4
|
+
import { parseThinkingLevel } from "./options.js";
|
|
5
|
+
export function localpiSettingsPath(options) {
|
|
6
|
+
return path.join(options.stateDir, "settings.json");
|
|
7
|
+
}
|
|
8
|
+
export async function applyRememberedSettings(options, explicit) {
|
|
9
|
+
const settings = await readLocalpiSettings(options);
|
|
10
|
+
return {
|
|
11
|
+
...options,
|
|
12
|
+
thinking: explicit.thinking || settings.thinking === undefined ? options.thinking : settings.thinking
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function readLocalpiSettings(options) {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = await readFile(localpiSettingsPath(options), "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (isMissingFile(error)) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const root = asObject(JSON.parse(raw), "localpi settings");
|
|
28
|
+
const thinking = optionalString(root["thinking"]);
|
|
29
|
+
return thinking === undefined ? {} : { thinking: parseThinkingLevel(thinking) };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function isMissingFile(error) {
|
|
36
|
+
return (error instanceof Error && "code" in error && error.code === "ENOENT");
|
|
37
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { localpiVersion } from "./version.js";
|
|
2
|
+
const localpiAppIdentity = {
|
|
3
|
+
id: "localpi",
|
|
4
|
+
name: "localpi",
|
|
5
|
+
version: localpiVersion
|
|
6
|
+
};
|
|
7
|
+
export function createLocalpiAppDefinition(options, connection, extensions) {
|
|
8
|
+
return {
|
|
9
|
+
...localpiAppIdentity,
|
|
10
|
+
...appDirectories(options),
|
|
11
|
+
...piCommand(options),
|
|
12
|
+
...runtimeSelection(options, connection),
|
|
13
|
+
...extensionConfig(extensions)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function appDirectories(options) {
|
|
17
|
+
return {
|
|
18
|
+
stateDir: options.stateDir,
|
|
19
|
+
sessionDir: options.sessionDir
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function piCommand(options) {
|
|
23
|
+
return {
|
|
24
|
+
piCommand: options.piCommand,
|
|
25
|
+
forwardedArgs: options.forwardedArgs
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function runtimeSelection(options, connection) {
|
|
29
|
+
const selection = {
|
|
30
|
+
providers: providersForConnection(options, connection),
|
|
31
|
+
defaultProvider: connection.providerId,
|
|
32
|
+
defaultModel: connection.model,
|
|
33
|
+
thinking: options.thinking
|
|
34
|
+
};
|
|
35
|
+
return options.tools === undefined ? selection : { ...selection, tools: options.tools };
|
|
36
|
+
}
|
|
37
|
+
function providersForConnection(options, connection) {
|
|
38
|
+
const models = connection.catalogModels.length === 0 ? fallbackCatalog(connection) : connection.catalogModels;
|
|
39
|
+
return groupedByProvider(models).map((group) => ({
|
|
40
|
+
id: group.providerId,
|
|
41
|
+
baseUrl: group.baseUrl,
|
|
42
|
+
api: "openai-completions",
|
|
43
|
+
apiKey: "local",
|
|
44
|
+
compat: {
|
|
45
|
+
supportsDeveloperRole: false,
|
|
46
|
+
supportsReasoningEffort: false
|
|
47
|
+
},
|
|
48
|
+
models: group.models.map((model) => modelDefinition(options, model))
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
function groupedByProvider(models) {
|
|
52
|
+
const groups = new Map();
|
|
53
|
+
for (const model of models) {
|
|
54
|
+
const existing = groups.get(model.providerId);
|
|
55
|
+
groups.set(model.providerId, existing === undefined
|
|
56
|
+
? { providerId: model.providerId, baseUrl: model.baseUrl, models: [model] }
|
|
57
|
+
: { ...existing, models: [...existing.models, model] });
|
|
58
|
+
}
|
|
59
|
+
return [...groups.values()];
|
|
60
|
+
}
|
|
61
|
+
function modelDefinition(options, model) {
|
|
62
|
+
const contextWindow = modelContextWindow(options, model);
|
|
63
|
+
return {
|
|
64
|
+
id: model.modelId,
|
|
65
|
+
name: model.displayName,
|
|
66
|
+
reasoning: model.reasoning ?? false,
|
|
67
|
+
...(model.thinkingFormat === undefined ? {} : { thinkingFormat: model.thinkingFormat }),
|
|
68
|
+
input: ["text"],
|
|
69
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
70
|
+
...(model.maxTokens === undefined
|
|
71
|
+
? { maxTokens: options.maxTokens }
|
|
72
|
+
: { maxTokens: model.maxTokens })
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function modelContextWindow(options, model) {
|
|
76
|
+
if (options.contextWindow !== undefined) {
|
|
77
|
+
return options.contextWindow;
|
|
78
|
+
}
|
|
79
|
+
return model.contextWindow;
|
|
80
|
+
}
|
|
81
|
+
function fallbackCatalog(connection) {
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
providerId: connection.providerId,
|
|
85
|
+
providerName: connection.providerName,
|
|
86
|
+
runtime: connection.runtime.startsWith("llama-server")
|
|
87
|
+
? "managed-llama-server"
|
|
88
|
+
: "openai-compatible",
|
|
89
|
+
baseUrl: connection.baseUrl,
|
|
90
|
+
modelId: connection.model,
|
|
91
|
+
aliases: [],
|
|
92
|
+
displayName: `Local model (${connection.model})`,
|
|
93
|
+
reasoning: false,
|
|
94
|
+
capabilities: ["text"],
|
|
95
|
+
availability: "loaded",
|
|
96
|
+
...(connection.contextWindow === undefined ? {} : { contextWindow: connection.contextWindow })
|
|
97
|
+
}
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
function extensionConfig(extensions) {
|
|
101
|
+
if (extensions === undefined) {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
extensions: extensions.paths.map((extensionPath) => ({ path: extensionPath })),
|
|
106
|
+
appendSystemPrompts: [extensions.systemPrompt]
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
export const defaultDemoInitialPrompt = "You are narrating a never-ending sci-fi adventure. Continue in short paragraphs. Whenever the user sends a message, treat it as a live director note and incorporate it immediately. Never end the story.";
|
|
3
|
+
export const defaultDemoFollowupPrompt = "Continue. Try to write as long as possible.";
|
|
4
|
+
export async function resolveDemoPrompts(options) {
|
|
5
|
+
return {
|
|
6
|
+
initial: await resolvePrompt(options.demoInitialPrompt, options.demoInitialPromptFile, defaultDemoInitialPrompt),
|
|
7
|
+
followup: await resolvePrompt(options.demoFollowupPrompt, options.demoFollowupPromptFile, defaultDemoFollowupPrompt)
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
async function resolvePrompt(text, file, fallback) {
|
|
11
|
+
return file === undefined ? (text ?? fallback) : await readFile(file, "utf8");
|
|
12
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function demoModeExtensionSource(prompts) {
|
|
2
|
+
const initialPromptSource = JSON.stringify(prompts.initial);
|
|
3
|
+
const followupPromptSource = JSON.stringify(prompts.followup);
|
|
4
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const initialPrompt = ${initialPromptSource};
|
|
7
|
+
const followupPrompt = ${followupPromptSource};
|
|
8
|
+
|
|
9
|
+
export default function localpiDemoMode(pi: ExtensionAPI): void {
|
|
10
|
+
let started = false;
|
|
11
|
+
let stopped = false;
|
|
12
|
+
|
|
13
|
+
pi.on("session_start", (event, ctx) => {
|
|
14
|
+
if (started || stopped || event.reason !== "startup" || ctx.mode !== "tui") {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
started = true;
|
|
18
|
+
queueMicrotask(() => {
|
|
19
|
+
if (!stopped) {
|
|
20
|
+
pi.sendUserMessage(initialPrompt);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
pi.on("turn_end", (event, ctx) => {
|
|
26
|
+
if (!started || stopped || ctx.mode !== "tui") {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (event.message.role !== "assistant") {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
switch (event.message.stopReason) {
|
|
33
|
+
case "aborted":
|
|
34
|
+
case "error":
|
|
35
|
+
stopped = true;
|
|
36
|
+
return;
|
|
37
|
+
case "toolUse":
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
queueMicrotask(() => {
|
|
41
|
+
if (!stopped) {
|
|
42
|
+
pi.sendUserMessage(followupPrompt, { deliverAs: "followUp" });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
pi.on("session_shutdown", () => {
|
|
48
|
+
stopped = true;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|