localpi 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -5
- package/dist/src/cli/cli.js +245 -11
- package/dist/src/localpi/catalog.js +117 -21
- package/dist/src/localpi/llama-server.js +39 -2
- package/dist/src/localpi/managed-runtime.js +13 -4
- package/dist/src/localpi/model-profile.js +98 -0
- package/dist/src/localpi/options.js +102 -4
- package/dist/src/localpi/runtime-connection.js +3 -5
- package/dist/src/localpi/runtime-selection.js +71 -5
- package/dist/src/localpi/runtime.js +2 -2
- package/dist/src/localpi/settings-state.js +37 -0
- package/dist/src/pi/demo.js +12 -0
- package/dist/src/pi/extensions.js +85 -5
- 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 +29 -2
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, openSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { closeSync, constants, openSync } from "node:fs";
|
|
3
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { listModels, normalizeBaseUrl } from "../llm/openai.js";
|
|
6
6
|
export async function ensureLlamaServer(options, model) {
|
|
@@ -23,6 +23,12 @@ export async function ensureLlamaServer(options, model) {
|
|
|
23
23
|
contextWindow: requestedContextWindow(options, model)
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
export async function managedLlamaServerUnavailableMessage(options) {
|
|
27
|
+
if (await executableExists(options.serverCommand)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return `llama-server command ${options.serverCommand} is not available; managed llama-server fallback disabled`;
|
|
31
|
+
}
|
|
26
32
|
async function handleExistingServer(options, model, baseUrl, warnings) {
|
|
27
33
|
const state = await existingServerState(options, baseUrl);
|
|
28
34
|
if (state.existing === undefined) {
|
|
@@ -118,6 +124,7 @@ function rejectExternalModelConflict(baseUrl, existing, requestedModel) {
|
|
|
118
124
|
throw new Error(`server at ${baseUrl} is already serving ${ids.join(", ")}; stop it or choose that model before starting ${requestedModel}`);
|
|
119
125
|
}
|
|
120
126
|
async function startManagedServer(options, model) {
|
|
127
|
+
await assertManagedLlamaServerAvailable(options);
|
|
121
128
|
await mkdir(serverDir(options), { recursive: true });
|
|
122
129
|
const logPath = path.join(serverDir(options), "llama-server.log");
|
|
123
130
|
const logFd = openSync(logPath, "a");
|
|
@@ -158,6 +165,36 @@ async function startManagedServer(options, model) {
|
|
|
158
165
|
closeSync(logFd);
|
|
159
166
|
}
|
|
160
167
|
}
|
|
168
|
+
async function assertManagedLlamaServerAvailable(options) {
|
|
169
|
+
if (await executableExists(options.serverCommand)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`failed to start llama-server: executable not found: ${options.serverCommand}; install llama-server or pass --server-command`);
|
|
173
|
+
}
|
|
174
|
+
async function executableExists(command) {
|
|
175
|
+
if (command.length === 0) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
179
|
+
return canExecute(command);
|
|
180
|
+
}
|
|
181
|
+
const entries = (process.env["PATH"] ?? "").split(path.delimiter).filter((entry) => entry !== "");
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
if (await canExecute(path.join(entry, command))) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
async function canExecute(filePath) {
|
|
190
|
+
try {
|
|
191
|
+
await access(filePath, constants.X_OK);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
161
198
|
function serverArgs(options, model) {
|
|
162
199
|
const endpoint = managedEndpoint(options);
|
|
163
200
|
return [
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ensureLlamaServer, getManagedLlamaServerMetadata, getLlamaServerModels, llamaBaseUrl, managedLlamaServerNeedsRestart, stopManagedLlamaServer } from "./llama-server.js";
|
|
2
|
-
import {
|
|
2
|
+
import { managedCapabilityConfig, runtimeCatalogWarning } from "./catalog.js";
|
|
3
3
|
import { catalogModelFromModelInfo, catalogRuntimeConnection, connectionCatalogModels, modelChoiceList, optionalContextWindow, replaceManagedLoadedModels } from "./runtime-connection.js";
|
|
4
4
|
import { defaultLlamaModelName, findModelAlias, resolveLlamaModel } from "./models.js";
|
|
5
5
|
export async function resolveLlamaRuntime(options) {
|
|
@@ -32,7 +32,10 @@ export async function resolveSelectedLlamaRuntime(options, selected, catalog) {
|
|
|
32
32
|
const selectedModel = managedCatalogModelFromConnection(options, selected, existing);
|
|
33
33
|
return catalogRuntimeConnection(options, selectedModel, {
|
|
34
34
|
models: replaceManagedLoadedModels(catalog.models, selected, existing.catalogModels),
|
|
35
|
-
warnings: [
|
|
35
|
+
warnings: [
|
|
36
|
+
...catalog.warnings,
|
|
37
|
+
...runtimeWarnings("llama-server", "llama-server", existing.warnings)
|
|
38
|
+
]
|
|
36
39
|
});
|
|
37
40
|
}
|
|
38
41
|
return startSelectedLlamaRuntime(options, selected, catalog);
|
|
@@ -53,7 +56,7 @@ export async function customPathCatalogModel(options, provider, requested) {
|
|
|
53
56
|
aliases: [requested],
|
|
54
57
|
displayName: `llama-server / ${resolved.name}`,
|
|
55
58
|
maxTokens: options.maxTokens,
|
|
56
|
-
|
|
59
|
+
...managedCapabilityConfig(resolved.id, options),
|
|
57
60
|
capabilities: ["text"],
|
|
58
61
|
availability: "startable",
|
|
59
62
|
...optionalContextWindow(options.contextWindow ?? resolved.contextWindow)
|
|
@@ -175,7 +178,10 @@ async function startSelectedLlamaRuntime(options, selected, catalog) {
|
|
|
175
178
|
const loadedSelected = runtimeSelectedCatalogModel(options, selected, runtime);
|
|
176
179
|
return catalogRuntimeConnection(options, loadedSelected, {
|
|
177
180
|
models: replaceManagedLoadedModels(catalog.models, selected, [loadedSelected]),
|
|
178
|
-
warnings: [
|
|
181
|
+
warnings: [
|
|
182
|
+
...catalog.warnings,
|
|
183
|
+
...runtimeWarnings("llama-server", "llama-server", runtime.warnings)
|
|
184
|
+
]
|
|
179
185
|
});
|
|
180
186
|
}
|
|
181
187
|
async function assertDirectLlamaStartIsSafe(options, model) {
|
|
@@ -217,6 +223,9 @@ function assertNoLoadedExternalModels(catalog) {
|
|
|
217
223
|
}
|
|
218
224
|
throw new Error(`external local models are already loaded; choose one or unload them before starting llama-server:\n${modelChoiceList(external)}`);
|
|
219
225
|
}
|
|
226
|
+
function runtimeWarnings(providerId, providerName, warnings) {
|
|
227
|
+
return warnings.map((warning) => runtimeCatalogWarning(providerId, providerName, warning));
|
|
228
|
+
}
|
|
220
229
|
function isGgufPathRequest(value) {
|
|
221
230
|
return value.endsWith(".gguf") || value.includes("/") || value.includes("\\");
|
|
222
231
|
}
|
|
@@ -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 {
|
|
1
|
+
import { formatCatalogWarning, managedCapabilityConfig } from "./catalog.js";
|
|
2
2
|
export function connectionStatus(connection) {
|
|
3
3
|
return ([
|
|
4
4
|
`runtime: ${connection.runtime}`,
|
|
@@ -25,7 +25,7 @@ export function catalogRuntimeConnection(options, selected, catalog) {
|
|
|
25
25
|
model: selected.modelId,
|
|
26
26
|
availableModels: providerModels.map((model) => model.modelId),
|
|
27
27
|
catalogModels: catalog.models.filter((model) => model.availability === "loaded"),
|
|
28
|
-
warnings: catalog.warnings,
|
|
28
|
+
warnings: catalog.warnings.map(formatCatalogWarning),
|
|
29
29
|
...optionalContextWindow(options.contextWindow ?? selected.contextWindow)
|
|
30
30
|
};
|
|
31
31
|
}
|
|
@@ -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)
|
|
@@ -16,7 +16,7 @@ export async function selectCatalogModel(options, catalog) {
|
|
|
16
16
|
if (selection.model !== "auto") {
|
|
17
17
|
return selectExplicitCatalogModel(options, providerFiltered, selection.provider, selection.model);
|
|
18
18
|
}
|
|
19
|
-
return selectAutomaticCatalogModel(providerFiltered, catalog.warnings);
|
|
19
|
+
return selectAutomaticCatalogModel(providerFiltered, warningsForProvider(catalog.warnings, selection.provider));
|
|
20
20
|
}
|
|
21
21
|
async function selectExplicitCatalogModel(options, models, provider, requested) {
|
|
22
22
|
const matches = matchingCatalogModels(models, requested);
|
|
@@ -39,11 +39,11 @@ function selectAutomaticCatalogModel(models, warnings) {
|
|
|
39
39
|
if (onlyLoaded !== undefined) {
|
|
40
40
|
return onlyLoaded;
|
|
41
41
|
}
|
|
42
|
-
const fallback = startableFallback(models);
|
|
42
|
+
const fallback = startableFallback(models, warnings);
|
|
43
43
|
if (fallback !== undefined) {
|
|
44
44
|
return fallback;
|
|
45
45
|
}
|
|
46
|
-
throw new Error(
|
|
46
|
+
throw new Error(noLoadedModelsMessage(models, warnings));
|
|
47
47
|
}
|
|
48
48
|
function modelsForProvider(models, provider) {
|
|
49
49
|
return provider === undefined ? models : models.filter((model) => model.providerId === provider);
|
|
@@ -69,7 +69,73 @@ function matchingCatalogModels(models, requested) {
|
|
|
69
69
|
function isGgufFilePathRequest(value) {
|
|
70
70
|
return value.toLowerCase().endsWith(".gguf") || value.includes("\\");
|
|
71
71
|
}
|
|
72
|
-
function startableFallback(models) {
|
|
73
|
-
const startable = models.filter((model) => model.availability === "startable"
|
|
72
|
+
function startableFallback(models, warnings) {
|
|
73
|
+
const startable = models.filter((model) => model.availability === "startable" &&
|
|
74
|
+
(model.runtime !== "managed-llama-server" || managedLlamaFallbackAvailable(warnings)));
|
|
74
75
|
return (startable.find((model) => model.aliases.includes(defaultLlamaModelName()) || model.modelId === defaultLlamaModelName()) ?? startable[0]);
|
|
75
76
|
}
|
|
77
|
+
function managedLlamaFallbackAvailable(warnings) {
|
|
78
|
+
return !warnings.some((warning) => warning.providerId === "llama-server" && warning.code === "managed-command-unavailable");
|
|
79
|
+
}
|
|
80
|
+
function noLoadedModelsMessage(models, warnings) {
|
|
81
|
+
const sections = engineSections(models, warnings);
|
|
82
|
+
if (sections.length === 0) {
|
|
83
|
+
return "no loaded models available\n\nTried engines:\n\n- none reported usable models";
|
|
84
|
+
}
|
|
85
|
+
return [
|
|
86
|
+
"no loaded models available",
|
|
87
|
+
"",
|
|
88
|
+
"Tried engines:",
|
|
89
|
+
"",
|
|
90
|
+
sections.map(formatEngineSection).join("\n\n")
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
function engineSections(models, warnings) {
|
|
94
|
+
const sections = new Map();
|
|
95
|
+
for (const model of models) {
|
|
96
|
+
const section = ensureEngineSection(sections, model.providerId, model.providerName);
|
|
97
|
+
const entry = `${model.providerId}/${model.modelId}`;
|
|
98
|
+
const updated = model.availability === "loaded"
|
|
99
|
+
? { ...section, loaded: [...section.loaded, entry] }
|
|
100
|
+
: { ...section, startable: [...section.startable, entry] };
|
|
101
|
+
sections.set(model.providerId, updated);
|
|
102
|
+
}
|
|
103
|
+
for (const warning of warnings) {
|
|
104
|
+
const section = ensureEngineSection(sections, warning.providerId, warning.providerName);
|
|
105
|
+
sections.set(warning.providerId, {
|
|
106
|
+
...section,
|
|
107
|
+
warnings: [...section.warnings, warning.message]
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return [...sections.values()];
|
|
111
|
+
}
|
|
112
|
+
function ensureEngineSection(sections, key, title) {
|
|
113
|
+
const existing = sections.get(key);
|
|
114
|
+
if (existing !== undefined) {
|
|
115
|
+
return existing;
|
|
116
|
+
}
|
|
117
|
+
const section = { title, loaded: [], startable: [], warnings: [] };
|
|
118
|
+
sections.set(key, section);
|
|
119
|
+
return section;
|
|
120
|
+
}
|
|
121
|
+
function formatEngineSection(section) {
|
|
122
|
+
const lines = [`${section.title}:`];
|
|
123
|
+
if (section.loaded.length === 0) {
|
|
124
|
+
lines.push("- loaded models: none");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
lines.push(`- loaded models: ${section.loaded.join(", ")}`);
|
|
128
|
+
}
|
|
129
|
+
if (section.startable.length > 0) {
|
|
130
|
+
lines.push(`- startable models: ${section.startable.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
for (const warning of section.warnings) {
|
|
133
|
+
lines.push(`- ${warning}`);
|
|
134
|
+
}
|
|
135
|
+
return lines.join("\n");
|
|
136
|
+
}
|
|
137
|
+
function warningsForProvider(warnings, provider) {
|
|
138
|
+
return provider === undefined
|
|
139
|
+
? warnings
|
|
140
|
+
: warnings.filter((warning) => warning.providerId === provider);
|
|
141
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { discoverModelCatalog } from "./catalog.js";
|
|
1
|
+
import { discoverModelCatalog, formatCatalogWarning } from "./catalog.js";
|
|
2
2
|
import { llamaBaseUrl, llamaServerStatus, stopManagedLlamaServer } from "./llama-server.js";
|
|
3
3
|
import { listModelAliases } from "./models.js";
|
|
4
4
|
import { catalogRuntimeConnection, connectionStatus, statusModelList } from "./runtime-connection.js";
|
|
@@ -75,7 +75,7 @@ async function catalogStatusOutput(options) {
|
|
|
75
75
|
`runtime: ${options.runtime}`,
|
|
76
76
|
`loaded models: ${statusModelList(loaded)}`,
|
|
77
77
|
`startable models: ${statusModelList(startable)}`,
|
|
78
|
-
...catalog.warnings.map((warning) => `warning: ${warning}`)
|
|
78
|
+
...catalog.warnings.map((warning) => `warning: ${formatCatalogWarning(warning)}`)
|
|
79
79
|
].join("\n") + "\n");
|
|
80
80
|
}
|
|
81
81
|
function requiredOpenAiBaseUrl(options) {
|
|
@@ -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 settingsStatePath(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(settingsStatePath(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,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
|
+
}
|