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.
@@ -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 { managedModelSupportsReasoning } from "./catalog.js";
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: [...catalog.warnings, ...existing.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
- reasoning: managedModelSupportsReasoning(resolved.id),
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: [...catalog.warnings, ...runtime.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", "off")),
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 { managedModelSupportsReasoning } from "./catalog.js";
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(`no loaded models available${warnings.length === 0 ? "" : `; ${warnings.join("; ")}`}`);
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
+ }