arisa 3.0.14 → 3.1.4

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,10 +1,11 @@
1
- import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
- import { getToolConfigPath, getToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
+ import { getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
6
6
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
7
7
  import { normalizeToolResult } from "./tool-result.js";
8
+ import { SkillRegistry } from "../skills/skill-registry.js";
8
9
 
9
10
  const bundledToolsRoot = fileURLToPath(new URL("../../../tools", import.meta.url));
10
11
  const toolRoots = [
@@ -27,6 +28,7 @@ export class ToolRegistry {
27
28
  constructor({ logger } = {}) {
28
29
  this.logger = logger;
29
30
  this.tools = new Map();
31
+ this.skillRegistry = new SkillRegistry();
30
32
  }
31
33
 
32
34
  async load() {
@@ -52,8 +54,10 @@ export class ToolRegistry {
52
54
  const configSource = await readFile(configPath, "utf8");
53
55
  const defaults = parseConfigModule(configSource);
54
56
  const config = await loadToolConfig(manifest.name, defaults);
57
+ const skillHints = this.skillRegistry.normalizeHints(manifest);
55
58
  this.tools.set(manifest.name, {
56
59
  ...manifest,
60
+ skillHints,
57
61
  dir: toolDir,
58
62
  entry: path.join(toolDir, manifest.entry || "index.js"),
59
63
  localConfigPath: configPath,
@@ -77,7 +81,8 @@ export class ToolRegistry {
77
81
  description: tool.description,
78
82
  input: tool.input,
79
83
  output: tool.output,
80
- configSchema: tool.configSchema || {}
84
+ configSchema: tool.configSchema || {},
85
+ skillHints: tool.skillHints || []
81
86
  }));
82
87
  }
83
88
 
@@ -89,33 +94,73 @@ export class ToolRegistry {
89
94
  const tool = this.get(name);
90
95
  if (!tool) throw new Error(`Tool not found: ${name}`);
91
96
  const result = await runProcess("node", [tool.entry, "--help"], { cwd: tool.dir, env: process.env });
92
- return result.stdout || result.stderr;
97
+ const help = result.stdout || result.stderr;
98
+ const skills = await this.resolveSkills(name);
99
+ if (!skills.length) return help;
100
+ const skillHelp = skills.map((item) => [
101
+ `- ${item.name}${item.when ? ` (${item.when})` : ""}`,
102
+ item.description ? ` ${item.description}` : null,
103
+ item.found ? ` path: ${item.path}` : " warning: skill not found"
104
+ ].filter(Boolean).join("\n")).join("\n");
105
+ return `${help}\n\nAssigned skills:\n${skillHelp}\n`;
93
106
  }
94
107
 
95
- async setConfig(name, field, value) {
108
+ async resolveSkills(name) {
96
109
  const tool = this.get(name);
97
110
  if (!tool) throw new Error(`Tool not found: ${name}`);
98
- const config = { ...(tool.config || {}) };
99
- config[field] = value;
100
- const configPath = await writeToolConfig(name, config);
101
- tool.config = config;
102
- tool.configPath = configPath;
111
+ const hints = await this.skillRegistry.resolveHints(tool.skillHints || []);
112
+ return hints.map((hint) => ({
113
+ name: hint.name,
114
+ when: hint.when,
115
+ found: hint.found,
116
+ description: hint.skill?.description || "",
117
+ path: hint.skill?.path || "",
118
+ content: hint.skill?.content || ""
119
+ }));
120
+ }
121
+
122
+ async resolveConfigForChat(name, chatId) {
123
+ const tool = this.get(name);
124
+ if (!tool) throw new Error(`Tool not found: ${name}`);
125
+ if (chatId == null) return tool.config || {};
126
+ return loadToolConfig(name, tool.defaults || {}, chatId);
127
+ }
128
+
129
+ async setConfig(name, field, value, chatId = null) {
130
+ const tool = this.get(name);
131
+ if (!tool) throw new Error(`Tool not found: ${name}`);
132
+ const current = chatId != null
133
+ ? await this.resolveConfigForChat(name, chatId)
134
+ : { ...(tool.config || {}) };
135
+ current[field] = value;
136
+ const configPath = await writeToolConfig(name, current, chatId);
137
+ if (chatId == null) {
138
+ tool.config = current;
139
+ tool.configPath = configPath;
140
+ }
103
141
  return { ok: true, tool: name, field, configPath };
104
142
  }
105
143
 
106
- async run({ name, request }) {
144
+ async run({ name, request, chatId = null }) {
107
145
  const tool = this.get(name);
108
146
  if (!tool) throw new Error(`Tool not found: ${name}`);
109
147
  this.logger?.log("tools", `running ${name}`);
110
- const tmpDir = getToolTmpDir(name);
148
+ const tmpDir = chatId != null ? getChatToolTmpDir(chatId, name) : getToolTmpDir(name);
111
149
  await mkdir(tmpDir, { recursive: true });
112
150
  const requestFile = path.join(tmpDir, `.request-${Date.now()}.json`);
113
- await writeFile(requestFile, `${JSON.stringify(request, null, 2)}\n`, "utf8");
151
+ const skills = await this.resolveSkills(name);
152
+ const enrichedRequest = { ...request, chatId, skills };
153
+ await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
114
154
  const result = await runProcess("node", [tool.entry, "run", "--request-file", requestFile], {
115
155
  cwd: tool.dir,
116
156
  env: process.env
117
157
  });
118
158
  await unlink(requestFile).catch(() => {});
159
+ await rmdir(tmpDir).catch(() => {});
160
+ if (chatId != null) {
161
+ await rmdir(path.dirname(tmpDir)).catch(() => {});
162
+ await rmdir(path.dirname(path.dirname(tmpDir))).catch(() => {});
163
+ }
119
164
  try {
120
165
  const parsed = JSON.parse(result.stdout || result.stderr);
121
166
  const normalized = normalizeToolResult(name, parsed);
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createServer } from "node:http";
3
4
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
4
5
  import { createApp } from "./runtime/create-app.js";
5
6
  import { createLogger } from "./runtime/logger.js";
@@ -8,25 +9,117 @@ import { flushArisaHome } from "./runtime/flush.js";
8
9
  import { installPiPackage, removePiPackage } from "./runtime/pi-package-manager.js";
9
10
 
10
11
  const args = process.argv.slice(2);
11
- const command = args.find((arg) => !arg.startsWith("--")) || "run";
12
- const forceBootstrap = args.includes("--bootstrap");
13
- const verbose = args.includes("--verbose");
14
- const serviceRunner = args.includes("--service-runner");
12
+ const cli = parseCliArgs(args);
13
+ const command = cli.positionals[0] || "run";
14
+ const forceBootstrap = Boolean(cli.flags.bootstrap);
15
+ const verbose = Boolean(cli.flags.verbose);
16
+ const serviceRunner = Boolean(cli.flags["service-runner"]);
17
+ const bootstrapOverrides = toBootstrapOverrides(cli.nestedFlags);
18
+ const runtimeOverrides = toRuntimeOverrides(cli.nestedFlags);
15
19
  const logger = createLogger({ verbose });
16
20
 
21
+ const httpPort = Number(process.env.PORT);
22
+ let httpRequestHandler = null;
23
+
24
+ function setHttpRequestHandler(handler) {
25
+ httpRequestHandler = handler;
26
+ }
27
+
28
+ if (httpPort && bootstrapOverrides.telegram) {
29
+ createServer((req, res) => {
30
+ if (httpRequestHandler) return httpRequestHandler(req, res);
31
+ res.writeHead(200, { "Content-Type": "text/plain" });
32
+ res.end("ok");
33
+ }).listen(httpPort)
34
+ .on("listening", () => logger.log("http", `health server on port ${httpPort}`));
35
+ }
36
+
37
+ function parseCliArgs(rawArgs) {
38
+ const flags = {};
39
+ const nestedFlags = {};
40
+ const positionals = [];
41
+
42
+ for (let index = 0; index < rawArgs.length; index += 1) {
43
+ const token = rawArgs[index];
44
+ if (!token.startsWith("--")) {
45
+ positionals.push(token);
46
+ continue;
47
+ }
48
+
49
+ const flagName = token.slice(2);
50
+ if (!flagName) continue;
51
+ if (flagName.includes(".")) {
52
+ const next = rawArgs[index + 1];
53
+ const hasValue = typeof next === "string" && !next.startsWith("--");
54
+ if (hasValue) {
55
+ nestedFlags[flagName] = next;
56
+ index += 1;
57
+ }
58
+ continue;
59
+ }
60
+
61
+ flags[flagName] = true;
62
+ }
63
+
64
+ return { flags, nestedFlags, positionals };
65
+ }
66
+
67
+ function toBootstrapOverrides(nestedFlags) {
68
+ const overrides = {};
69
+ for (const [flatKey, value] of Object.entries(nestedFlags)) {
70
+ const parts = flatKey.split(".");
71
+ let cursor = overrides;
72
+ for (let index = 0; index < parts.length - 1; index += 1) {
73
+ const key = parts[index];
74
+ if (!cursor[key] || typeof cursor[key] !== "object") {
75
+ cursor[key] = {};
76
+ }
77
+ cursor = cursor[key];
78
+ }
79
+ cursor[parts[parts.length - 1]] = value;
80
+ }
81
+ return overrides;
82
+ }
83
+
84
+ function toRuntimeOverrides(nestedFlags) {
85
+ return toBootstrapOverrides(nestedFlags);
86
+ }
87
+
88
+ function toServiceRunnerArgs(nestedFlags) {
89
+ const args = [];
90
+ if (nestedFlags["pi.model"]) {
91
+ args.push("--pi.model", nestedFlags["pi.model"]);
92
+ }
93
+ return args;
94
+ }
95
+
96
+ const bootstrapHttpOptions = httpPort ? { httpPort, setHttpRequestHandler } : {};
97
+ const webhookUrl = bootstrapOverrides.webhook?.url || "";
98
+ const appHttpOptions = httpPort ? { webhookUrl, setHttpRequestHandler } : {};
99
+
17
100
  async function runForeground() {
101
+ const hasRuntimePiOverrides = Boolean(
102
+ runtimeOverrides?.pi?.model
103
+ || runtimeOverrides?.pi?.provider
104
+ || runtimeOverrides?.pi?.apiKey
105
+ );
18
106
  logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
19
- await bootstrapIfNeeded({ force: forceBootstrap });
107
+ await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
20
108
  try {
21
- const app = await createApp({ logger });
109
+ const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
22
110
  await app.start();
23
111
  } catch (error) {
24
112
  const message = error instanceof Error ? error.message : String(error);
25
113
  if (message.includes("No auth found")) {
26
114
  console.log(`\n${message}\n`);
115
+ if (hasRuntimePiOverrides) {
116
+ console.log("Skipping automatic bootstrap because Pi runtime overrides were provided.");
117
+ console.log("Keeping existing Telegram config. Run `arisa --bootstrap` manually if you want to update persisted auth/config.\n");
118
+ throw error;
119
+ }
27
120
  console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
28
- await bootstrapIfNeeded({ force: true });
29
- const app = await createApp({ logger });
121
+ await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
122
+ const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
30
123
  await app.start();
31
124
  return;
32
125
  }
@@ -42,8 +135,8 @@ async function main() {
42
135
  }
43
136
 
44
137
  if (command === "start") {
45
- await bootstrapIfNeeded({ force: forceBootstrap });
46
- const result = await startService({ verbose });
138
+ await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
139
+ const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
47
140
  if (!result.ok) {
48
141
  console.log(`Arisa is already running in background (pid ${result.pid}).`);
49
142
  return;
@@ -85,7 +178,7 @@ async function main() {
85
178
  }
86
179
 
87
180
  if (command === "install") {
88
- const source = args.filter((arg) => !arg.startsWith("--")).slice(1)[0];
181
+ const source = cli.positionals[1];
89
182
  if (!source) {
90
183
  console.log("Usage: arisa install <pi-package-source>");
91
184
  return;
@@ -96,7 +189,7 @@ async function main() {
96
189
  }
97
190
 
98
191
  if (command === "remove") {
99
- const source = args.filter((arg) => !arg.startsWith("--")).slice(1)[0];
192
+ const source = cli.positionals[1];
100
193
  if (!source) {
101
194
  console.log("Usage: arisa remove <pi-package-source>");
102
195
  return;
@@ -15,9 +15,82 @@ async function exists(file) {
15
15
  }
16
16
  }
17
17
 
18
+ function normalizeString(value) {
19
+ if (typeof value !== "string") return "";
20
+ return value.trim();
21
+ }
22
+
23
+ function parseMaxChatIds(value, fallback = 1) {
24
+ const parsed = Number(value);
25
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
26
+ return parsed;
27
+ }
28
+
29
+ function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piApiKey }) {
30
+ return {
31
+ telegram: {
32
+ token: telegramApiKey,
33
+ maxChatIds: telegramMaxChatIds,
34
+ authorizedChatIds: [],
35
+ chatMeta: {}
36
+ },
37
+ pi: {
38
+ provider,
39
+ model,
40
+ apiKey: piApiKey
41
+ },
42
+ createdAt: new Date().toISOString()
43
+ };
44
+ }
45
+
46
+ function resolvePiDefaults(runtime, { provider: preferredProvider = "", model: preferredModel = "" } = {}) {
47
+ const providers = sortBootstrapProviders(listPiProviders(runtime));
48
+ if (!providers.length) {
49
+ throw new Error("No Pi providers are available for bootstrap.");
50
+ }
51
+
52
+ const preferredProviderValue = normalizeString(preferredProvider);
53
+ const providerExists = providers.some((item) => item.provider === preferredProviderValue);
54
+ if (preferredProviderValue && !providerExists) {
55
+ console.log(`Ignoring unknown Pi provider override: ${preferredProviderValue}`);
56
+ }
57
+
58
+ const selectedProvider = providerExists
59
+ ? preferredProviderValue
60
+ : providers[0].provider;
61
+
62
+ const models = sortBootstrapModels(selectedProvider, listProviderModels(selectedProvider, runtime));
63
+ if (!models.length) {
64
+ throw new Error(`No Pi models are available for provider ${selectedProvider}.`);
65
+ }
66
+
67
+ const preferredModelValue = normalizeString(preferredModel);
68
+ const modelExists = models.some((item) => item.id === preferredModelValue);
69
+ if (preferredModelValue && !modelExists) {
70
+ console.log(`Ignoring unknown Pi model override for ${selectedProvider}: ${preferredModelValue}`);
71
+ }
72
+
73
+ const selectedModel = modelExists ? preferredModelValue : models[0].id;
74
+ return { provider: selectedProvider, model: selectedModel };
75
+ }
76
+
77
+ function sortBootstrapProviders(providers) {
78
+ const preferredOrder = ["openai-codex"];
79
+ const positions = new Map(providers.map((provider, index) => [provider.provider, index]));
80
+
81
+ return [...providers].sort((a, b) => {
82
+ const aPref = preferredOrder.indexOf(a.provider);
83
+ const bPref = preferredOrder.indexOf(b.provider);
84
+ const aRank = aPref === -1 ? Number.MAX_SAFE_INTEGER : aPref;
85
+ const bRank = bPref === -1 ? Number.MAX_SAFE_INTEGER : bPref;
86
+ if (aRank !== bRank) return aRank - bRank;
87
+ return (positions.get(a.provider) || 0) - (positions.get(b.provider) || 0);
88
+ });
89
+ }
90
+
18
91
  function sortBootstrapModels(provider, models) {
19
92
  const preferred = {
20
- "openai-codex": ["gpt-5.4"]
93
+ "openai-codex": ["gpt-5.5"]
21
94
  };
22
95
 
23
96
  const priority = preferred[provider] || [];
@@ -49,7 +122,78 @@ async function maybeOpenExternal(url) {
49
122
  });
50
123
  }
51
124
 
52
- async function runInternalPiLogin(provider, rl) {
125
+ function installAuthRelay(httpPort, setHttpRequestHandler) {
126
+ let authUrl = "";
127
+ let resolveRedirectUrl;
128
+ const redirectUrlPromise = new Promise((resolve) => {
129
+ resolveRedirectUrl = resolve;
130
+ });
131
+
132
+ const page = (body) => [
133
+ "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width'>",
134
+ "<title>Arisa Auth</title>",
135
+ "<style>body{font-family:system-ui,sans-serif;max-width:600px;margin:40px auto;padding:0 20px;line-height:1.6}",
136
+ "input[type=text]{width:100%;padding:8px;box-sizing:border-box;margin:8px 0}",
137
+ "button{padding:8px 24px;cursor:pointer}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style>",
138
+ "</head><body>",
139
+ body,
140
+ "</body></html>"
141
+ ].join("");
142
+
143
+ setHttpRequestHandler((req, res) => {
144
+ const parsed = new URL(req.url, `http://localhost:${httpPort}`);
145
+
146
+ if (req.method === "GET" && parsed.pathname === "/auth/callback" && parsed.searchParams.has("code")) {
147
+ const callbackUrl = `http://localhost:1455${parsed.pathname}${parsed.search}`;
148
+ resolveRedirectUrl(callbackUrl);
149
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
150
+ res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting&hellip;</p>"));
151
+ return;
152
+ }
153
+
154
+ if (req.method === "GET" && parsed.pathname === "/") {
155
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
156
+ res.end(page([
157
+ "<h2>Arisa &mdash; Pi Authentication</h2>",
158
+ authUrl
159
+ ? `<p><strong>1.</strong> <a href="${authUrl}" target="_blank">Click here to log in with Pi</a></p>`
160
+ : "<p>Waiting for authentication URL&hellip;</p>",
161
+ "<p><strong>2.</strong> After login your browser will redirect to a <code>localhost</code> URL that won't load. That's expected.</p>",
162
+ "<p><strong>3.</strong> In your browser's address bar, replace <code>localhost:1455</code> with your server's domain and press Enter.</p>",
163
+ "<hr>",
164
+ "<p><em>Or paste the full redirect URL here:</em></p>",
165
+ '<form method="POST" action="/auth/relay">',
166
+ '<input type="text" name="url" placeholder="Paste the localhost redirect URL here&hellip;" required />',
167
+ "<button type='submit'>Submit</button>",
168
+ "</form>"
169
+ ].join("\n")));
170
+ return;
171
+ }
172
+
173
+ if (req.method === "POST" && parsed.pathname === "/auth/relay") {
174
+ let body = "";
175
+ req.on("data", (chunk) => { body += chunk; });
176
+ req.on("end", () => {
177
+ const url = (new URLSearchParams(body).get("url") || "").trim();
178
+ if (url) resolveRedirectUrl(url);
179
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
180
+ res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting&hellip;</p>"));
181
+ });
182
+ return;
183
+ }
184
+
185
+ res.writeHead(200, { "Content-Type": "text/plain" });
186
+ res.end("ok");
187
+ });
188
+
189
+ return {
190
+ setAuthUrl(url) { authUrl = url; },
191
+ waitForRedirectUrl() { return redirectUrlPromise; },
192
+ uninstall() { setHttpRequestHandler(null); }
193
+ };
194
+ }
195
+
196
+ async function runInternalPiLogin(provider, { rl = null, authRelay = null } = {}) {
53
197
  const authStorage = AuthStorage.create();
54
198
  const selected = authStorage.getOAuthProviders().find((item) => item.id === provider);
55
199
  if (!selected) {
@@ -67,7 +211,15 @@ async function runInternalPiLogin(provider, rl) {
67
211
  onAuth: async ({ url, instructions }) => {
68
212
  console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
69
213
  await maybeOpenExternal(url);
70
- if (selected.usesCallbackServer) {
214
+ if (authRelay) {
215
+ authRelay.setAuthUrl(url);
216
+ console.log("Waiting for authentication via the web relay...");
217
+ const redirectUrl = await authRelay.waitForRedirectUrl();
218
+ if (redirectUrl && manualCodeResolve) {
219
+ manualCodeResolve(redirectUrl);
220
+ manualCodeResolve = undefined;
221
+ }
222
+ } else if (selected.usesCallbackServer && rl) {
71
223
  const pasted = (await rl.question("Paste the redirect URL here if the browser does not return automatically, or press Enter to keep waiting: ")).trim();
72
224
  if (pasted && manualCodeResolve) {
73
225
  manualCodeResolve(pasted);
@@ -81,6 +233,9 @@ async function runInternalPiLogin(provider, rl) {
81
233
  await maybeOpenExternal(verificationUri);
82
234
  },
83
235
  onPrompt: async ({ message }) => {
236
+ if (!rl) {
237
+ throw new Error(`Pi login for ${provider} requires interactive input: ${message}`);
238
+ }
84
239
  return (await rl.question(`${message} `)).trim();
85
240
  },
86
241
  onProgress: (message) => {
@@ -96,10 +251,54 @@ async function runInternalPiLogin(provider, rl) {
96
251
  });
97
252
  }
98
253
 
99
- export async function bootstrapIfNeeded({ force = false } = {}) {
254
+ export async function bootstrapIfNeeded({ force = false, cliConfigOverrides = {}, httpPort = 0, setHttpRequestHandler } = {}) {
100
255
  await ensureArisaHome();
101
256
  if (!force && await exists(configFile)) return;
102
257
 
258
+ const telegramApiKeyFromCli = normalizeString(cliConfigOverrides?.telegram?.token);
259
+ if (telegramApiKeyFromCli) {
260
+ const runtime = createPiRuntime();
261
+ const resolvedPi = resolvePiDefaults(runtime, cliConfigOverrides?.pi || {});
262
+ const telegramMaxChatIds = parseMaxChatIds(cliConfigOverrides?.telegram?.maxChatIds, 1);
263
+ const piApiKey = normalizeString(cliConfigOverrides?.pi?.apiKey);
264
+ if (!piApiKey && !hasProviderAuth(resolvedPi.provider, runtime)) {
265
+ if (!supportsProviderOAuth(resolvedPi.provider, runtime)) {
266
+ throw new Error(
267
+ `No auth found for ${resolvedPi.provider}. Provide --pi.apiKey for non-interactive bootstrap, or use a provider that supports OAuth.`
268
+ );
269
+ }
270
+ if (!httpPort || !setHttpRequestHandler) {
271
+ throw new Error(
272
+ `No auth found for ${resolvedPi.provider}. Auth relay requires an HTTP server on PORT. Provide --pi.apiKey or set the PORT environment variable.`
273
+ );
274
+ }
275
+ const authRelay = installAuthRelay(httpPort, setHttpRequestHandler);
276
+ console.log(`No existing Pi auth found for ${resolvedPi.provider}. Auth relay active on port ${httpPort}.`);
277
+ console.log(`Open your server URL in a browser to complete Pi authentication.\n`);
278
+ try {
279
+ await runInternalPiLogin(resolvedPi.provider, { authRelay });
280
+ } finally {
281
+ authRelay.uninstall();
282
+ }
283
+ if (!hasProviderAuth(resolvedPi.provider, createPiRuntime())) {
284
+ throw new Error(
285
+ `Pi login did not complete for ${resolvedPi.provider}. Retry or provide --pi.apiKey.`
286
+ );
287
+ }
288
+ console.log(`Detected Pi auth for ${resolvedPi.provider}. Continuing bootstrap.`);
289
+ }
290
+ const config = buildConfig({
291
+ telegramApiKey: telegramApiKeyFromCli,
292
+ telegramMaxChatIds,
293
+ provider: resolvedPi.provider,
294
+ model: resolvedPi.model,
295
+ piApiKey
296
+ });
297
+ await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
298
+ console.log(`\nConfig saved to ${configFile} (non-interactive bootstrap)\n`);
299
+ return;
300
+ }
301
+
103
302
  const rl = readline.createInterface({ input, output });
104
303
  const ask = async (label, fallback = "") => {
105
304
  const suffix = fallback ? ` (${fallback})` : "";
@@ -113,7 +312,7 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
113
312
  const telegramMaxChatIds = Number(await ask("Maximum authorized chat IDs", "1"));
114
313
 
115
314
  const runtime = createPiRuntime();
116
- const providers = listPiProviders(runtime);
315
+ const providers = sortBootstrapProviders(listPiProviders(runtime));
117
316
  console.log("\nAvailable Pi providers:");
118
317
  providers.forEach((item, index) => {
119
318
  const authLabel = item.authConfigured ? "auth: configured" : item.supportsOAuth ? "auth: login or API key" : "auth: API key";
@@ -153,7 +352,7 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
153
352
 
154
353
  console.log(`No existing Pi auth found for ${selectedProvider.provider}. Starting internal Pi login...`);
155
354
  try {
156
- await runInternalPiLogin(selectedProvider.provider, rl);
355
+ await runInternalPiLogin(selectedProvider.provider, { rl });
157
356
  } catch (error) {
158
357
  console.log(`Internal Pi login failed: ${error instanceof Error ? error.message : String(error)}`);
159
358
  }
@@ -166,20 +365,13 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
166
365
  console.log(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
167
366
  }
168
367
 
169
- const config = {
170
- telegram: {
171
- apiKey: telegramApiKey,
172
- maxChatIds: telegramMaxChatIds,
173
- authorizedChatIds: [],
174
- chatMeta: {}
175
- },
176
- pi: {
177
- provider: selectedProvider.provider,
178
- model: selectedModel.id,
179
- apiKey: piApiKey
180
- },
181
- createdAt: new Date().toISOString()
182
- };
368
+ const config = buildConfig({
369
+ telegramApiKey,
370
+ telegramMaxChatIds,
371
+ provider: selectedProvider.provider,
372
+ model: selectedModel.id,
373
+ piApiKey
374
+ });
183
375
 
184
376
  await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
185
377
  rl.close();
@@ -5,9 +5,51 @@ import { TaskStore } from "../core/tasks/task-store.js";
5
5
  import { AgentManager } from "../core/agent/agent-manager.js";
6
6
  import { createTelegramBot } from "../transport/telegram/bot.js";
7
7
 
8
- export async function createApp({ logger } = {}) {
8
+ function normalizeString(value) {
9
+ const text = String(value ?? "").trim();
10
+ return text ? text : "";
11
+ }
12
+
13
+ function splitModelOverride(modelOverride) {
14
+ const separatorIndex = modelOverride.indexOf("/");
15
+ if (separatorIndex <= 0 || separatorIndex === modelOverride.length - 1) {
16
+ return null;
17
+ }
18
+ return {
19
+ provider: modelOverride.slice(0, separatorIndex),
20
+ model: modelOverride.slice(separatorIndex + 1)
21
+ };
22
+ }
23
+
24
+ function applyRuntimeOverrides(config, runtimeOverrides) {
25
+ const providerOverride = normalizeString(runtimeOverrides?.pi?.provider);
26
+ const modelOverride = normalizeString(runtimeOverrides?.pi?.model);
27
+ if (!providerOverride && !modelOverride) return config;
28
+
29
+ const splitOverride = modelOverride ? splitModelOverride(modelOverride) : null;
30
+ const provider = providerOverride || splitOverride?.provider || config.pi.provider;
31
+ const model = splitOverride && (!providerOverride || providerOverride === splitOverride.provider)
32
+ ? splitOverride.model
33
+ : (modelOverride || config.pi.model);
34
+
35
+ return {
36
+ ...config,
37
+ pi: {
38
+ ...config.pi,
39
+ provider,
40
+ model
41
+ }
42
+ };
43
+ }
44
+
45
+ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpRequestHandler } = {}) {
9
46
  logger?.log("app", "loading config");
10
- const config = await loadConfig();
47
+ const persistedConfig = await loadConfig();
48
+ const config = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
49
+ if (config.pi.provider !== persistedConfig.pi.provider || config.pi.model !== persistedConfig.pi.model) {
50
+ logger?.log("app", `applying runtime model override: ${persistedConfig.pi.provider}/${persistedConfig.pi.model} -> ${config.pi.provider}/${config.pi.model}`);
51
+ }
52
+
11
53
  const artifactStore = new ArtifactStore();
12
54
  const toolRegistry = new ToolRegistry({ logger });
13
55
  const taskStore = new TaskStore();
@@ -15,7 +57,7 @@ export async function createApp({ logger } = {}) {
15
57
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
16
58
 
17
59
  const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
18
- const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger });
60
+ const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler });
19
61
 
20
62
  return {
21
63
  async start() {