arisa 4.0.22 → 4.1.2

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,7 +1,9 @@
1
+ import crypto from "node:crypto";
1
2
  import { readFile, writeFile } from "node:fs/promises";
2
3
  import readline from "node:readline/promises";
3
4
  import { stdin as input, stdout as output } from "node:process";
4
5
  import { spawn } from "node:child_process";
6
+ import { Bot } from "grammy";
5
7
  import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
6
8
  import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
7
9
  import { configFile, ensureArisaHome } from "./paths.js";
@@ -24,13 +26,13 @@ async function exists(file) {
24
26
  }
25
27
  }
26
28
 
27
- function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piApiKey }) {
29
+ function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
28
30
  return {
29
31
  telegram: {
30
32
  token: telegramApiKey,
31
33
  maxChatIds: telegramMaxChatIds,
32
- authorizedChatIds: [],
33
- chatMeta: {}
34
+ authorizedChatIds,
35
+ chatMeta
34
36
  },
35
37
  pi: {
36
38
  provider,
@@ -73,6 +75,64 @@ function sortBootstrapModels(provider, models) {
73
75
  });
74
76
  }
75
77
 
78
+ function createSetupToken() {
79
+ return crypto.randomBytes(18).toString("base64url");
80
+ }
81
+
82
+ function parsePositiveInteger(value, fallback = null) {
83
+ const number = Number(value);
84
+ if (!Number.isFinite(number) || number <= 0) return fallback;
85
+ return Math.floor(number);
86
+ }
87
+
88
+ function selectByIndex(items, value, fallbackIndex = 0) {
89
+ const index = parsePositiveInteger(value, fallbackIndex + 1) - 1;
90
+ return items[Math.max(0, Math.min(items.length - 1, index))];
91
+ }
92
+
93
+ function parseYesNo(value, fallback = true) {
94
+ const text = String(value ?? "").trim().toLowerCase();
95
+ if (!text) return fallback;
96
+ if (["y", "yes", "s", "si", "sí"].includes(text)) return true;
97
+ if (["n", "no"].includes(text)) return false;
98
+ return null;
99
+ }
100
+
101
+ function buildInlineKeyboard(action, items) {
102
+ return {
103
+ inline_keyboard: items.map((item, index) => ([{
104
+ text: item.text,
105
+ callback_data: `${action}:${index}`
106
+ }]))
107
+ };
108
+ }
109
+
110
+ function getIncomingChatMeta(ctx) {
111
+ return {
112
+ languageCode: ctx.from?.language_code || "",
113
+ username: ctx.from?.username || "",
114
+ firstName: ctx.from?.first_name || "",
115
+ lastName: ctx.from?.last_name || ""
116
+ };
117
+ }
118
+
119
+ function formatProviderOption(item) {
120
+ const authLabel = item.authConfigured ? "auth configured" : item.supportsOAuth ? "login or API key" : "API key";
121
+ return `${item.provider} (${item.modelCount} models, ${authLabel})`;
122
+ }
123
+
124
+ function formatModelOption(model) {
125
+ const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
126
+ return capabilities ? `${model.id} [${capabilities}]` : model.id;
127
+ }
128
+
129
+ function selectPiLoginOption(options = []) {
130
+ return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
131
+ || options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
132
+ || options[0]
133
+ || null;
134
+ }
135
+
76
136
  async function maybeOpenExternal(url) {
77
137
  if (!url) return;
78
138
  await new Promise((resolve) => {
@@ -92,6 +152,12 @@ async function maybeOpenExternal(url) {
92
152
  async function runInternalPiLogin(provider, { rl = null } = {}) {
93
153
  const login = createPiOAuthLogin({
94
154
  provider,
155
+ onSelect: async ({ message, options }) => {
156
+ const selected = selectPiLoginOption(options);
157
+ if (!selected) return undefined;
158
+ console.log(`${message}\nUsing: ${selected.label || selected.id}\n`);
159
+ return selected.id;
160
+ },
95
161
  onAuth: async ({ url, instructions, controller }) => {
96
162
  console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
97
163
  await maybeOpenExternal(url);
@@ -118,43 +184,24 @@ async function runInternalPiLogin(provider, { rl = null } = {}) {
118
184
  await login.promise;
119
185
  }
120
186
 
121
- export async function bootstrapIfNeeded({ force = false } = {}) {
122
- await ensureArisaHome();
123
- if (!force && await exists(configFile)) return;
124
-
125
- const rl = readline.createInterface({ input, output });
126
- const ask = async (label, fallback = "") => {
127
- const suffix = fallback ? ` (${fallback})` : "";
128
- const value = (await rl.question(`${label}${suffix}: `)).trim();
129
- return value || fallback;
130
- };
131
-
132
- console.log(`\n${ARISA_BANNER}`);
133
- console.log("-------- https://arisa.sh --------\n");
134
- console.log("Get Telegram bot token from https://t.me/BotFather");
135
- const telegramApiKey = await ask("Telegram bot token");
187
+ async function collectCliBootstrapChoices({ telegramApiKey, rl, ask }) {
136
188
  const telegramMaxChatIds = Number(await ask("Maximum authorized chat IDs", "1"));
137
189
 
138
190
  const runtime = createPiRuntime();
139
191
  const providers = sortBootstrapProviders(listPiProviders(runtime));
140
192
  console.log("\nAvailable Pi providers:");
141
193
  providers.forEach((item, index) => {
142
- const authLabel = item.authConfigured ? "auth: configured" : item.supportsOAuth ? "auth: login or API key" : "auth: API key";
143
- console.log(`${index + 1}. ${item.provider} (${item.modelCount} models, ${authLabel})`);
194
+ console.log(`${index + 1}. ${formatProviderOption(item)}`);
144
195
  });
145
196
 
146
- const selectedProviderIndex = Number(await ask("Select Pi provider by number", "1"));
147
- const selectedProvider = providers[Math.max(0, Math.min(providers.length - 1, selectedProviderIndex - 1))];
197
+ const selectedProvider = selectByIndex(providers, await ask("Select Pi provider by number", "1"));
148
198
  const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, runtime));
149
199
  console.log(`\nAvailable models for ${selectedProvider.provider}:`);
150
200
  models.forEach((model, index) => {
151
- const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
152
- const suffix = capabilities ? ` [${capabilities}]` : "";
153
- console.log(`${index + 1}. ${model.id}${suffix}`);
201
+ console.log(`${index + 1}. ${formatModelOption(model)}`);
154
202
  });
155
203
 
156
- const selectedModelIndex = Number(await ask("Select Pi model by number", "1"));
157
- const selectedModel = models[Math.max(0, Math.min(models.length - 1, selectedModelIndex - 1))];
204
+ const selectedModel = selectByIndex(models, await ask("Select Pi model by number", "1"));
158
205
  const selectedAuthReady = hasProviderAuth(selectedProvider.provider, runtime);
159
206
  const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, runtime);
160
207
  console.log(`Selected model: ${selectedModel.provider}/${selectedModel.id}`);
@@ -189,17 +236,346 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
189
236
  console.log(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
190
237
  }
191
238
 
192
- const config = buildConfig({
193
- telegramApiKey,
194
- telegramMaxChatIds,
195
- provider: selectedProvider.provider,
196
- model: selectedModel.id,
197
- piApiKey
239
+ return {
240
+ config: buildConfig({
241
+ telegramApiKey,
242
+ telegramMaxChatIds,
243
+ provider: selectedProvider.provider,
244
+ model: selectedModel.id,
245
+ piApiKey
246
+ }),
247
+ startInBackground: false,
248
+ viaTelegram: false
249
+ };
250
+ }
251
+
252
+ async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
253
+ const bot = new Bot(telegramApiKey);
254
+ const runtime = createPiRuntime();
255
+ const providers = sortBootstrapProviders(listPiProviders(runtime));
256
+ let setupChatId = null;
257
+ let chatMeta = {};
258
+ let state = "await-start";
259
+ let telegramMaxChatIds = 1;
260
+ let selectedProvider = null;
261
+ let selectedModel = null;
262
+ let piApiKey = "";
263
+ let activeLogin = null;
264
+ let completed = false;
265
+ let resolveResult;
266
+ let rejectResult;
267
+
268
+ const resultPromise = new Promise((resolve, reject) => {
269
+ resolveResult = resolve;
270
+ rejectResult = reject;
271
+ });
272
+
273
+ const sendSetupMessage = async (text, extra = {}) => {
274
+ if (!setupChatId) return;
275
+ await bot.api.sendMessage(setupChatId, text, extra);
276
+ };
277
+
278
+ const isSetupChat = (ctx) => setupChatId && ctx.chat?.id === setupChatId;
279
+
280
+ const complete = (startInBackground) => {
281
+ if (completed) return;
282
+ completed = true;
283
+ resolveResult({
284
+ config: buildConfig({
285
+ telegramApiKey,
286
+ telegramMaxChatIds,
287
+ authorizedChatIds: [setupChatId],
288
+ chatMeta: { [setupChatId]: chatMeta },
289
+ provider: selectedProvider.provider,
290
+ model: selectedModel.id,
291
+ piApiKey
292
+ }),
293
+ startInBackground,
294
+ viaTelegram: true
295
+ });
296
+ };
297
+
298
+ const askProvider = async () => {
299
+ state = "provider";
300
+ await sendSetupMessage("Select the Pi provider Arisa should use:", {
301
+ reply_markup: buildInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })))
302
+ });
303
+ };
304
+
305
+ const askModel = async () => {
306
+ state = "model";
307
+ const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
308
+ await sendSetupMessage(`Select the model for ${selectedProvider.provider}:`, {
309
+ reply_markup: buildInlineKeyboard("model", models.map((model) => ({ text: formatModelOption(model) })))
310
+ });
311
+ };
312
+
313
+ const askBackground = async () => {
314
+ state = "background";
315
+ await sendSetupMessage("Bootstrap complete. Keep Arisa running in background now?", {
316
+ reply_markup: {
317
+ inline_keyboard: [
318
+ [{ text: "Yes, start in background", callback_data: "background:yes" }],
319
+ [{ text: "No, continue in foreground", callback_data: "background:no" }]
320
+ ]
321
+ }
322
+ });
323
+ };
324
+
325
+ const askApiKey = async () => {
326
+ state = "pi-api-key";
327
+ await sendSetupMessage(`Send the Pi API key for ${selectedProvider.provider}.`);
328
+ };
329
+
330
+ const askAuthMethod = async () => {
331
+ const providerRuntime = createPiRuntime();
332
+ const selectedAuthReady = hasProviderAuth(selectedProvider.provider, providerRuntime);
333
+ const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, providerRuntime);
334
+ const buttons = [];
335
+
336
+ if (selectedAuthReady) {
337
+ buttons.push([{ text: "Use existing Pi auth", callback_data: "auth:existing" }]);
338
+ }
339
+ if (providerSupportsOAuth) {
340
+ buttons.push([{ text: selectedAuthReady ? "Run Pi login again" : "Start Pi login", callback_data: "auth:login" }]);
341
+ }
342
+ buttons.push([{ text: "Enter API key", callback_data: "auth:key" }]);
343
+
344
+ state = "auth-method";
345
+ await sendSetupMessage([
346
+ `Selected model: ${selectedProvider.provider}/${selectedModel.id}`,
347
+ `Existing Pi auth for ${selectedProvider.provider}: ${selectedAuthReady ? "yes" : "no"}`,
348
+ "Choose how Arisa should authenticate Pi."
349
+ ].join("\n"), {
350
+ reply_markup: { inline_keyboard: buttons }
351
+ });
352
+ };
353
+
354
+ const finishPiLogin = async (login) => {
355
+ try {
356
+ await login.promise;
357
+ activeLogin = null;
358
+ if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
359
+ await sendSetupMessage(`Detected Pi auth for ${selectedProvider.provider}.`);
360
+ await askBackground();
361
+ return;
362
+ }
363
+ await sendSetupMessage(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
364
+ await askAuthMethod();
365
+ } catch (error) {
366
+ activeLogin = null;
367
+ await sendSetupMessage(`Pi login failed: ${error instanceof Error ? error.message : String(error)}`);
368
+ await askAuthMethod();
369
+ }
370
+ };
371
+
372
+ const startPiLogin = async () => {
373
+ if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
374
+ await sendSetupMessage(`Existing Pi auth for ${selectedProvider.provider} detected.`);
375
+ await askBackground();
376
+ return;
377
+ }
378
+
379
+ state = "pi-login";
380
+ const login = createPiOAuthLogin({
381
+ provider: selectedProvider.provider,
382
+ onSelect: async ({ message, options }) => {
383
+ const selected = selectPiLoginOption(options);
384
+ if (!selected) return undefined;
385
+ await sendSetupMessage(`${message}\nUsing: ${selected.label || selected.id}`);
386
+ return selected.id;
387
+ },
388
+ onAuth: async ({ url, instructions }) => {
389
+ await sendSetupMessage([
390
+ instructions || "Open this URL to continue Pi authentication:",
391
+ url,
392
+ "After login, paste the full redirect URL back here."
393
+ ].join("\n"));
394
+ },
395
+ onDeviceCode: async ({ userCode, verificationUri, expiresInSeconds }) => {
396
+ const expiry = expiresInSeconds ? `\nExpires in ${Math.round(expiresInSeconds / 60)} minute(s).` : "";
397
+ await sendSetupMessage(`Open this URL: ${verificationUri}\nThen enter code: ${userCode}${expiry}`);
398
+ },
399
+ onPrompt: async ({ message, controller }) => {
400
+ await sendSetupMessage(`${message}\nReply here with the value.`);
401
+ return controller.waitForManualCode();
402
+ },
403
+ onProgress: (message) => {
404
+ if (message) console.log(`[bootstrap] Pi auth progress: ${message}`);
405
+ }
406
+ });
407
+
408
+ activeLogin = login;
409
+ finishPiLogin(login);
410
+ };
411
+
412
+ bot.catch((error) => {
413
+ console.error("Telegram setup bot error:", error);
198
414
  });
199
415
 
200
- await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
201
- rl.close();
202
- console.log(`\nConfig saved to ${configFile}\n`);
416
+ bot.command("start", async (ctx) => {
417
+ if (String(ctx.match || "").trim() !== setupToken) {
418
+ await ctx.reply("Invalid setup link. Use the link shown in the Arisa bootstrap terminal.");
419
+ return;
420
+ }
421
+
422
+ if (setupChatId && ctx.chat.id !== setupChatId) {
423
+ await ctx.reply("This setup session is already connected to another chat.");
424
+ return;
425
+ }
426
+
427
+ setupChatId = ctx.chat.id;
428
+ chatMeta = getIncomingChatMeta(ctx);
429
+ await ctx.reply([
430
+ `Connected to @${botInfo.username}.`,
431
+ "This chat will be the only Telegram chat authorized during setup."
432
+ ].join("\n"));
433
+ await askProvider();
434
+ });
435
+
436
+ bot.on("callback_query:data", async (ctx) => {
437
+ await ctx.answerCallbackQuery().catch(() => {});
438
+ if (!isSetupChat(ctx)) return;
439
+
440
+ const data = String(ctx.callbackQuery.data || "");
441
+ const [action, rawValue] = data.split(":");
442
+
443
+ if (action === "provider" && state === "provider") {
444
+ selectedProvider = providers[Number(rawValue)];
445
+ if (!selectedProvider) return;
446
+ await askModel();
447
+ return;
448
+ }
449
+
450
+ if (action === "model" && state === "model") {
451
+ const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
452
+ selectedModel = models[Number(rawValue)];
453
+ if (!selectedModel) return;
454
+ await askAuthMethod();
455
+ return;
456
+ }
457
+
458
+ if (action === "auth" && state === "auth-method") {
459
+ if (rawValue === "existing") {
460
+ if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
461
+ await askBackground();
462
+ } else {
463
+ await sendSetupMessage(`No existing Pi auth found for ${selectedProvider.provider}.`);
464
+ await askAuthMethod();
465
+ }
466
+ return;
467
+ }
468
+ if (rawValue === "login") {
469
+ await startPiLogin();
470
+ return;
471
+ }
472
+ if (rawValue === "key") {
473
+ await askApiKey();
474
+ return;
475
+ }
476
+ }
477
+
478
+ if (action === "background" && state === "background") {
479
+ await sendSetupMessage(rawValue === "yes"
480
+ ? "Saving config. Arisa will start in background now."
481
+ : "Saving config. Arisa will continue in foreground.");
482
+ complete(rawValue === "yes");
483
+ }
484
+ });
485
+
486
+ bot.on("message:text", async (ctx) => {
487
+ if (!isSetupChat(ctx)) return;
488
+ const text = String(ctx.message.text || "").trim();
489
+ if (!text || text.startsWith("/")) return;
490
+
491
+ if (state === "pi-api-key") {
492
+ piApiKey = text;
493
+ await askBackground();
494
+ return;
495
+ }
496
+
497
+ if (state === "pi-login" && activeLogin?.manualInputRequested) {
498
+ if (activeLogin.submitManualCode(text)) {
499
+ await ctx.reply("Got it. Finishing Pi login now...");
500
+ }
501
+ return;
502
+ }
503
+
504
+ if (state === "background") {
505
+ const answer = parseYesNo(text, true);
506
+ if (answer === null) {
507
+ await ctx.reply("Please answer yes or no.");
508
+ return;
509
+ }
510
+ await ctx.reply(answer
511
+ ? "Saving config. Arisa will start in background now."
512
+ : "Saving config. Arisa will continue in foreground.");
513
+ complete(answer);
514
+ }
515
+ });
516
+
517
+ await bot.api.deleteWebhook({ drop_pending_updates: true });
518
+ console.log("Waiting for Telegram setup to complete...");
519
+ const polling = bot.start().then(() => {
520
+ if (!completed) throw new Error("Telegram setup bot stopped before bootstrap completed.");
521
+ });
522
+
523
+ try {
524
+ return await Promise.race([resultPromise, polling]);
525
+ } catch (error) {
526
+ rejectResult(error);
527
+ throw error;
528
+ } finally {
529
+ bot.stop();
530
+ await polling.catch(() => {});
531
+ }
532
+ }
533
+
534
+ export async function bootstrapIfNeeded({ force = false } = {}) {
535
+ await ensureArisaHome();
536
+ if (!force && await exists(configFile)) {
537
+ return { configCreated: false, viaTelegram: false, startInBackground: false };
538
+ }
539
+
540
+ const rl = readline.createInterface({ input, output });
541
+ const ask = async (label, fallback = "") => {
542
+ const suffix = fallback ? ` (${fallback})` : "";
543
+ const value = (await rl.question(`${label}${suffix}: `)).trim();
544
+ return value || fallback;
545
+ };
546
+
547
+ console.log(`\n${ARISA_BANNER}`);
548
+ console.log("-------- https://arisa.sh --------\n");
549
+ console.log("Get Telegram bot token from https://t.me/BotFather");
550
+ const telegramApiKey = await ask("Telegram bot token");
551
+
552
+ try {
553
+ const setupProbeBot = new Bot(telegramApiKey);
554
+ const botInfo = await setupProbeBot.api.getMe();
555
+ const answer = parseYesNo(await ask("Continue bootstrap from Telegram?", "Y"), true);
556
+ const continueFromTelegram = answer === null ? true : answer;
557
+
558
+ let result;
559
+ if (continueFromTelegram) {
560
+ const setupToken = createSetupToken();
561
+ const setupLink = `https://t.me/${botInfo.username}?start=${setupToken}`;
562
+ console.log(`\nOpen this link to continue setup in Telegram:\n${setupLink}\n`);
563
+ await maybeOpenExternal(setupLink);
564
+ result = await runTelegramBootstrap({ telegramApiKey, setupToken, botInfo });
565
+ } else {
566
+ result = await collectCliBootstrapChoices({ telegramApiKey, rl, ask });
567
+ }
568
+
569
+ await writeFile(configFile, `${JSON.stringify(result.config, null, 2)}\n`, "utf8");
570
+ console.log(`\nConfig saved to ${configFile}\n`);
571
+ return {
572
+ configCreated: true,
573
+ viaTelegram: result.viaTelegram,
574
+ startInBackground: result.startInBackground
575
+ };
576
+ } finally {
577
+ rl.close();
578
+ }
203
579
  }
204
580
 
205
581
  export { configFile };
@@ -14,6 +14,31 @@ function normalizeString(value) {
14
14
  return text ? text : "";
15
15
  }
16
16
 
17
+ function normalizeStringList(value) {
18
+ if (Array.isArray(value)) {
19
+ const items = value
20
+ .map((item) => normalizeString(item))
21
+ .filter(Boolean);
22
+ return items.length ? [...new Set(items)] : undefined;
23
+ }
24
+
25
+ if (typeof value === "string") {
26
+ const items = value
27
+ .split(",")
28
+ .map((item) => item.trim())
29
+ .filter(Boolean);
30
+ return items.length ? [...new Set(items)] : undefined;
31
+ }
32
+
33
+ return undefined;
34
+ }
35
+
36
+ function normalizePositiveInteger(value) {
37
+ const number = Number(value);
38
+ if (!Number.isFinite(number) || number <= 0) return undefined;
39
+ return Math.floor(number);
40
+ }
41
+
17
42
  function splitModelOverride(modelOverride) {
18
43
  const separatorIndex = modelOverride.indexOf("/");
19
44
  if (separatorIndex <= 0 || separatorIndex === modelOverride.length - 1) {
@@ -25,23 +50,41 @@ function splitModelOverride(modelOverride) {
25
50
  };
26
51
  }
27
52
 
28
- function applyRuntimeOverrides(config, runtimeOverrides) {
53
+ export function applyRuntimeOverrides(config, runtimeOverrides) {
54
+ const piRuntimeOverrides = runtimeOverrides?.pi || {};
55
+ const pi = {};
29
56
  const providerOverride = normalizeString(runtimeOverrides?.pi?.provider);
30
57
  const modelOverride = normalizeString(runtimeOverrides?.pi?.model);
31
- if (!providerOverride && !modelOverride) return config;
32
58
 
33
- const splitOverride = modelOverride ? splitModelOverride(modelOverride) : null;
34
- const provider = providerOverride || splitOverride?.provider || config.pi.provider;
35
- const model = splitOverride && (!providerOverride || providerOverride === splitOverride.provider)
36
- ? splitOverride.model
37
- : (modelOverride || config.pi.model);
59
+ if (providerOverride || modelOverride) {
60
+ const splitOverride = modelOverride ? splitModelOverride(modelOverride) : null;
61
+ pi.provider = providerOverride || splitOverride?.provider || config.pi.provider;
62
+ pi.model = splitOverride && (!providerOverride || providerOverride === splitOverride.provider)
63
+ ? splitOverride.model
64
+ : (modelOverride || config.pi.model);
65
+ }
66
+
67
+ for (const key of ["apiKey", "workspaceDir", "shellPath"]) {
68
+ const value = normalizeString(piRuntimeOverrides[key]);
69
+ if (value) pi[key] = value;
70
+ }
71
+
72
+ const tools = normalizeStringList(piRuntimeOverrides.tools);
73
+ if (tools) pi.tools = tools;
74
+
75
+ const excludeTools = normalizeStringList(piRuntimeOverrides.excludeTools);
76
+ if (excludeTools) pi.excludeTools = excludeTools;
77
+
78
+ const shellTimeoutMs = normalizePositiveInteger(piRuntimeOverrides.shellTimeoutMs);
79
+ if (shellTimeoutMs) pi.shellTimeoutMs = shellTimeoutMs;
80
+
81
+ if (!Object.keys(pi).length) return config;
38
82
 
39
83
  return {
40
84
  ...config,
41
85
  pi: {
42
86
  ...config.pi,
43
- provider,
44
- model
87
+ ...pi
45
88
  }
46
89
  };
47
90
  }
@@ -74,7 +74,8 @@ function buildPrompt({ ctx, artifact, transcript, toolResult }) {
74
74
  parts.push(`Important: pre-reasoning media normalization could not be completed, so you do not have a transcript for this audio/video message.`);
75
75
  }
76
76
 
77
- parts.push(`If you need a CLI tool, use list_tools/tool_help/run_tool.`);
77
+ parts.push(`Use read/write/edit for file work in the active workspace, bash for bash-compatible commands, and system_shell for native system commands such as PowerShell on Windows.`);
78
+ parts.push(`If you need an Arisa modular CLI tool, use list_tools/tool_help/run_tool.`);
78
79
  parts.push(`If a tool config is missing, ask the user naturally and then use set_tool_config.`);
79
80
  parts.push(`If the user wants a generated media reply, use send_media_reply.`);
80
81
  return parts.join("\n");
@@ -130,7 +131,7 @@ async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger
130
131
  }
131
132
 
132
133
  parts.push("Treat this as a new request for the chat and fulfill it now.");
133
- parts.push("If needed, use tools.");
134
+ parts.push("If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool.");
134
135
  return parts.filter(Boolean).join("\n");
135
136
  }
136
137
 
@@ -142,7 +143,7 @@ function buildAsyncEventPrompt(task) {
142
143
  task.payload.prompt ? `event: ${task.payload.prompt}` : null,
143
144
  "A polling checker detected this external event. Evaluate it and decide the next action.",
144
145
  "If it warrants no action, you may stay silent.",
145
- "If needed, use tools."
146
+ "If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool."
146
147
  ].filter(Boolean).join("\n");
147
148
  }
148
149