arisa 3.0.1 → 3.0.3

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/AGENTS.md CHANGED
@@ -93,10 +93,15 @@ Prefer responses like:
93
93
 
94
94
  For example, if the user asks for live weather and no weather tool exists, the correct attitude is to propose building a weather tool for the bot rather than only saying real-time access is unavailable.
95
95
 
96
+ When creating or editing tools, follow the shared path helpers in `src/runtime/paths.js` and `src/core/tools/tool-config.js`:
97
+ - config in `~/.arisa/tools/<tool>/config.js`
98
+ - temp/runtime files in `~/.arisa/tmp/tools/<tool>/`
99
+ - durable generated files should become artifacts in `~/.arisa/artifacts/`
100
+
96
101
  Consult the local skill for that workflow when building new tools.
97
102
 
98
103
  ## Safety
99
104
  - Do not install or run arbitrary tools outside registered `cli/*` manifests in V1.
100
105
  - Prefer tool manifests and CLI help over assumptions.
101
- - Keep tool configs inside `cli/<tool>/config.js`.
106
+ - Keep tool configs inside `~/.arisa/tools/<tool>/config.js`.
102
107
  - Be proactive about extending capabilities, but do it through the project's tool architecture, not through ad hoc one-off behavior.
package/README.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Arisa
2
2
 
3
- Arisa is a modular Telegram assistant powered by Pi Agent.
3
+ Arisa is a personal Telegram assistant powered by Pi Agent.
4
+
5
+ ## Origin
6
+
7
+ The initial inspiration was [OpenClaw](https://github.com/openclaw/openclaw). OpenClaw has interesting ideas but carries too much weight: when it generates tools they end up disorganized, and the overall framework feels overloaded for personal use.
8
+
9
+ The real heart of OpenClaw is Pi Agent — a [minimal terminal coding harness](https://www.youtube.com/watch?v=Dli5slNaJu0) that lets an AI agent reason and act with very little infrastructure. That part is genuinely good.
10
+
11
+ Telegram bots, on the other hand, work extremely well as a human interface. Simple, reliable, always in your pocket.
12
+
13
+ So Arisa keeps exactly those two things — Pi Agent and Telegram — and nothing more. No pre-loaded opinions about what the agent should do or which tools it should have. The idea is that the agent builds itself around the user, not the other way around.
4
14
 
5
15
  It is designed around a simple idea:
6
16
 
@@ -10,7 +20,7 @@ It is designed around a simple idea:
10
20
  - **capabilities live in isolated CLI tools**
11
21
  - **tools can be chained through pipes**
12
22
 
13
- Arisa is meant to grow like Lego blocks. If a capability does not exist yet, the system should prefer adding a new tool instead of stopping at "I can't do that".
23
+ If a capability does not exist yet, the system adds a new tool for it. The agent grows from real use, not from assumptions.
14
24
 
15
25
  ## Core concept
16
26
 
@@ -59,8 +69,13 @@ node index.js run --request-file <json>
59
69
  ```
60
70
 
61
71
  ### Configuration model
62
- - Telegram runtime config is stored in `data/state/config.json`
63
- - tool-specific secrets/config live in `cli/<tool>/config.js`
72
+ - all runtime state lives under `~/.arisa/`
73
+ - Telegram runtime config is stored in `~/.arisa/state/config.json`
74
+ - artifact index is stored in `~/.arisa/state/artifacts.json`
75
+ - incoming Telegram attachments are stored directly in `~/.arisa/artifacts/`
76
+ - tool-specific secrets/config live in `~/.arisa/tools/<tool>/config.js`
77
+ - tool runtime temp files and generated outputs live in `~/.arisa/tmp/tools/<tool>/`
78
+ - durable files should end up in `~/.arisa/artifacts/`
64
79
  - Pi authentication can use either:
65
80
  - an API key entered during bootstrap
66
81
  - or Pi's existing OAuth login when supported, such as `openai-codex`
@@ -83,10 +98,11 @@ On first run, Arisa will:
83
98
 
84
99
  1. ask for a Telegram bot token
85
100
  2. ask for the maximum number of authorized chat ids
86
- 3. show a list of Pi models
87
- 4. resolve authentication for the selected Pi provider
88
- 5. validate that Pi Agent works
89
- 6. only then start listening to Telegram
101
+ 3. show Pi providers discovered from Pi Agent's model registry
102
+ 4. show the models available for the selected provider
103
+ 5. resolve authentication for the selected Pi provider
104
+ 6. validate that Pi Agent works
105
+ 7. only then start listening to Telegram
90
106
 
91
107
  Telegram bot tokens can be created with:
92
108
 
@@ -133,22 +149,25 @@ src/
133
149
  cli/
134
150
  openai-transcribe/
135
151
  openai-tts/
136
- data/
152
+ ~/.arisa/
137
153
  state/
138
154
  artifacts/
139
- chats/
155
+ tools/
156
+ tmp/
140
157
  ```
141
158
 
142
159
  ## Philosophy
143
160
 
144
- Arisa should not default to passive answers like "I can't do that" when a missing capability can realistically be implemented as a new tool.
161
+ The agent should not come preloaded with vices or assumptions. It starts minimal and grows through real use shaped by the user, not by the framework.
145
162
 
146
- The preferred behavior is:
163
+ When a capability is missing:
147
164
 
148
165
  1. check whether an existing tool can solve the task
149
- 2. if not, propose creating the missing tool
166
+ 2. if not, build the missing tool
150
167
  3. keep the solution inside the tool architecture
151
168
 
169
+ No "I can't do that" when the thing is realistically buildable.
170
+
152
171
  ## Notes
153
172
 
154
173
  - `AGENTS.md` defines the project-level behavioral rules for Pi Agent
@@ -1,14 +1,18 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
- import path from "node:path";
3
- import config from "./config.js";
2
+ import defaults from "./config.js";
3
+ import { loadToolConfig } from "../../src/core/tools/tool-config.js";
4
+ import { getToolConfigPath } from "../../src/runtime/paths.js";
5
+
6
+ const toolName = "openai-transcribe";
7
+ const config = await loadToolConfig(toolName, defaults);
4
8
 
5
9
  function printHelp() {
6
- console.log(`openai-transcribe\n\nUso:\n node index.js --help\n node index.js run --request-file <json>\n\nInput esperado:\n {\n \"artifact\": { \"path\": \"/abs/audio.ogg\", \"mimeType\": \"audio/ogg\" },\n \"args\": {}\n }\n\nConfig en cli/openai-transcribe/config.js:\n OPENAI_API_KEY\n MODEL\n`);
10
+ console.log(`openai-transcribe\n\nUso:\n node index.js --help\n node index.js run --request-file <json>\n\nInput esperado:\n {\n \"artifact\": { \"path\": \"/abs/audio.ogg\", \"mimeType\": \"audio/ogg\" },\n \"args\": {}\n }\n\nConfig en ${getToolConfigPath(toolName)}:\n OPENAI_API_KEY\n MODEL\n`);
7
11
  }
8
12
 
9
13
  async function run(requestFile) {
10
14
  if (!config.OPENAI_API_KEY) {
11
- console.log(JSON.stringify({ ok: false, missingConfig: ["OPENAI_API_KEY"], configPath: path.resolve("config.js") }));
15
+ console.log(JSON.stringify({ ok: false, missingConfig: ["OPENAI_API_KEY"], configPath: getToolConfigPath(toolName) }));
12
16
  return;
13
17
  }
14
18
 
@@ -1,14 +1,19 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import config from "./config.js";
3
+ import defaults from "./config.js";
4
+ import { loadToolConfig } from "../../src/core/tools/tool-config.js";
5
+ import { getToolConfigPath, getToolOutDir } from "../../src/runtime/paths.js";
6
+
7
+ const toolName = "openai-tts";
8
+ const config = await loadToolConfig(toolName, defaults);
4
9
 
5
10
  function printHelp() {
6
- console.log(`openai-tts\n\nUso:\n node index.js --help\n node index.js run --request-file <json>\n\nInput esperado:\n {\n \"text\": \"hola\",\n \"artifact\": { \"text\": \"hola\" },\n \"args\": { \"voice\": \"alloy\" }\n }\n\nConfig en cli/openai-tts/config.js:\n OPENAI_API_KEY\n MODEL\n VOICE\n`);
11
+ console.log(`openai-tts\n\nUso:\n node index.js --help\n node index.js run --request-file <json>\n\nInput esperado:\n {\n \"text\": \"hola\",\n \"artifact\": { \"text\": \"hola\" },\n \"args\": { \"voice\": \"alloy\" }\n }\n\nConfig en ${getToolConfigPath(toolName)}:\n OPENAI_API_KEY\n MODEL\n VOICE\n`);
7
12
  }
8
13
 
9
14
  async function run(requestFile) {
10
15
  if (!config.OPENAI_API_KEY) {
11
- console.log(JSON.stringify({ ok: false, missingConfig: ["OPENAI_API_KEY"], configPath: path.resolve("config.js") }));
16
+ console.log(JSON.stringify({ ok: false, missingConfig: ["OPENAI_API_KEY"], configPath: getToolConfigPath(toolName) }));
12
17
  return;
13
18
  }
14
19
 
@@ -39,7 +44,7 @@ async function run(requestFile) {
39
44
  return;
40
45
  }
41
46
 
42
- const outDir = path.resolve("out");
47
+ const outDir = getToolOutDir(toolName);
43
48
  await mkdir(outDir, { recursive: true });
44
49
  const filePath = path.join(outDir, `speech-${Date.now()}.mp3`);
45
50
  const buffer = Buffer.from(await response.arrayBuffer());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,32 +1,11 @@
1
1
  import path from "node:path";
2
- import { mkdir } from "node:fs/promises";
3
- import { AuthStorage, createAgentSession, ModelRegistry, SessionManager, defineTool } from "@mariozechner/pi-coding-agent";
2
+ import { mkdir, unlink } from "node:fs/promises";
3
+ import { createAgentSession, SessionManager, defineTool } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
+ import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
5
6
 
6
7
  const agentDir = path.resolve("data/pi-agent");
7
8
 
8
- function getOAuthProviderForModelProvider(provider) {
9
- if (provider === "openai-codex") return "openai-codex";
10
- if (provider === "anthropic") return "anthropic";
11
- if (provider === "google") return "google-gemini-cli";
12
- if (provider === "google-antigravity") return "google-antigravity";
13
- if (provider === "github-copilot") return "github-copilot";
14
- return provider;
15
- }
16
-
17
- function normalizePiConfig(pi) {
18
- const provider = pi.provider === "codex" ? "openai" : pi.provider;
19
- let model = pi.model;
20
- if (pi.provider === "codex") {
21
- if (model === "5.4") model = "gpt-5.4";
22
- else if (model === "5.4-mini") model = "gpt-5.4-mini";
23
- else if (model === "5.4-nano") model = "gpt-5.4-nano";
24
- else if (model === "5.4-pro") model = "gpt-5.4-pro";
25
- else if (!model.startsWith("gpt-")) model = `gpt-${model}`;
26
- }
27
- return { provider, model };
28
- }
29
-
30
9
  export class AgentManager {
31
10
  constructor({ config, artifactStore, toolRegistry }) {
32
11
  this.config = config;
@@ -41,20 +20,16 @@ export class AgentManager {
41
20
  }
42
21
 
43
22
  async validatePiAgent() {
44
- const authStorage = AuthStorage.create();
45
- const normalized = normalizePiConfig(this.config.pi);
46
- if (this.config.pi.apiKey) {
47
- authStorage.setRuntimeApiKey(normalized.provider, this.config.pi.apiKey);
48
- }
49
-
50
- const modelRegistry = ModelRegistry.create(authStorage);
51
- const model = modelRegistry.find(normalized.provider, normalized.model);
23
+ const { authStorage, modelRegistry } = createPiRuntime({
24
+ provider: this.config.pi.provider,
25
+ apiKey: this.config.pi.apiKey
26
+ });
27
+ const model = modelRegistry.find(this.config.pi.provider, this.config.pi.model);
52
28
  if (!model) {
53
- throw new Error(`Model not found: ${this.config.pi.provider}/${this.config.pi.model} (resolved to ${normalized.provider}/${normalized.model})`);
29
+ throw new Error(`Model not found: ${this.config.pi.provider}/${this.config.pi.model}`);
54
30
  }
55
- const oauthProvider = getOAuthProviderForModelProvider(normalized.provider);
56
- if (!this.config.pi.apiKey && !modelRegistry.hasConfiguredAuth(normalized.provider) && !authStorage.hasAuth(oauthProvider)) {
57
- throw new Error(`No auth found for ${normalized.provider}. Provide a Pi API key in bootstrap, or authenticate with the internal /login flow during bootstrap.`);
31
+ if (!this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
32
+ throw new Error(`No auth found for ${this.config.pi.provider}. Provide a Pi API key in bootstrap, or authenticate with Pi login for this provider during bootstrap.`);
58
33
  }
59
34
 
60
35
  const { session } = await createAgentSession({
@@ -70,17 +45,14 @@ export class AgentManager {
70
45
  if (this.sessions.has(chatId)) return this.sessions.get(chatId);
71
46
 
72
47
  await mkdir(agentDir, { recursive: true });
73
- const authStorage = AuthStorage.create();
74
- const normalized = normalizePiConfig(this.config.pi);
75
- if (this.config.pi.apiKey) {
76
- authStorage.setRuntimeApiKey(normalized.provider, this.config.pi.apiKey);
77
- }
78
- const modelRegistry = ModelRegistry.create(authStorage);
79
- const model = modelRegistry.find(normalized.provider, normalized.model);
80
- if (!model) throw new Error(`Model not found: ${this.config.pi.provider}/${this.config.pi.model} (resolved to ${normalized.provider}/${normalized.model})`);
81
- const oauthProvider = getOAuthProviderForModelProvider(normalized.provider);
82
- if (!this.config.pi.apiKey && !modelRegistry.hasConfiguredAuth(normalized.provider) && !authStorage.hasAuth(oauthProvider)) {
83
- throw new Error(`No auth found for ${normalized.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
48
+ const { authStorage, modelRegistry } = createPiRuntime({
49
+ provider: this.config.pi.provider,
50
+ apiKey: this.config.pi.apiKey
51
+ });
52
+ const model = modelRegistry.find(this.config.pi.provider, this.config.pi.model);
53
+ if (!model) throw new Error(`Model not found: ${this.config.pi.provider}/${this.config.pi.model}`);
54
+ if (!this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
55
+ throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
84
56
  }
85
57
 
86
58
  const cwd = path.resolve("data/chats", String(chatId));
@@ -131,7 +103,7 @@ export class AgentManager {
131
103
  defineTool({
132
104
  name: "set_tool_config",
133
105
  label: "Set tool config",
134
- description: "Write a value into cli/<tool>/config.js.",
106
+ description: "Write a value into ~/.arisa/tools/<tool>/config.js.",
135
107
  parameters: Type.Object({ name: Type.String(), field: Type.String(), value: Type.String() }),
136
108
  execute: async (_id, params) => {
137
109
  await this.toolRegistry.load();
@@ -186,6 +158,7 @@ export class AgentManager {
186
158
  metadata: { tool: params.name }
187
159
  });
188
160
  result.output.artifactId = generated.id;
161
+ await unlink(result.output.filePath).catch(() => {});
189
162
  }
190
163
 
191
164
  return {
@@ -210,6 +183,7 @@ export class AgentManager {
210
183
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
211
184
  }
212
185
  await telegram.sendAudio(result.output.filePath, params.text);
186
+ await unlink(result.output.filePath).catch(() => {});
213
187
  return { content: [{ type: "text", text: "Audio enviado por Telegram." }], details: result };
214
188
  }
215
189
  })
@@ -0,0 +1,61 @@
1
+ import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
2
+
3
+ function compareText(a, b) {
4
+ return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
5
+ }
6
+
7
+ export function createPiRuntime({ provider, apiKey } = {}) {
8
+ const authStorage = AuthStorage.create();
9
+ if (provider && apiKey) {
10
+ authStorage.setRuntimeApiKey(provider, apiKey);
11
+ }
12
+ const modelRegistry = ModelRegistry.create(authStorage);
13
+ const oauthProviders = authStorage.getOAuthProviders();
14
+ return { authStorage, modelRegistry, oauthProviders };
15
+ }
16
+
17
+ export function hasProviderAuth(provider, { authStorage, modelRegistry }) {
18
+ return modelRegistry.hasConfiguredAuth(provider) || authStorage.hasAuth(provider);
19
+ }
20
+
21
+ export function supportsProviderOAuth(provider, { oauthProviders }) {
22
+ return oauthProviders.some((item) => item.id === provider);
23
+ }
24
+
25
+ export function listPiProviders(runtime = createPiRuntime()) {
26
+ const { modelRegistry, oauthProviders } = runtime;
27
+ const allModels = modelRegistry.getAll();
28
+ const oauthIds = new Set(oauthProviders.map((item) => item.id));
29
+ const counts = new Map();
30
+ for (const model of allModels) {
31
+ counts.set(model.provider, (counts.get(model.provider) || 0) + 1);
32
+ }
33
+
34
+ return [...counts.keys()]
35
+ .map((provider) => ({
36
+ provider,
37
+ authConfigured: hasProviderAuth(provider, runtime),
38
+ supportsOAuth: oauthIds.has(provider),
39
+ modelCount: counts.get(provider) || 0
40
+ }))
41
+ .sort((a, b) => {
42
+ if (a.authConfigured !== b.authConfigured) return a.authConfigured ? -1 : 1;
43
+ if (a.supportsOAuth !== b.supportsOAuth) return a.supportsOAuth ? -1 : 1;
44
+ return compareText(a.provider, b.provider);
45
+ });
46
+ }
47
+
48
+ export function listProviderModels(provider, runtime = createPiRuntime()) {
49
+ return runtime.modelRegistry
50
+ .getAll()
51
+ .filter((model) => model.provider === provider)
52
+ .sort((a, b) => compareText(a.name || a.id, b.name || b.id));
53
+ }
54
+
55
+ export function findPiModel({ provider, model, apiKey } = {}) {
56
+ const runtime = createPiRuntime({ provider, apiKey });
57
+ return {
58
+ ...runtime,
59
+ model: provider && model ? runtime.modelRegistry.find(provider, model) : null
60
+ };
61
+ }
@@ -1,9 +1,7 @@
1
1
  import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
-
5
- const rootDir = path.resolve("data/artifacts");
6
- const indexFile = path.resolve("data/state/artifacts.json");
4
+ import { artifactsDir as rootDir, artifactsIndexFile as indexFile } from "../../runtime/paths.js";
7
5
 
8
6
  async function loadIndex() {
9
7
  try {
@@ -0,0 +1,35 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getToolConfigPath } from "../../runtime/paths.js";
4
+
5
+ export function parseConfigModule(source) {
6
+ const normalized = source.replace(/^export\s+default/, "return");
7
+ return new Function(normalized)();
8
+ }
9
+
10
+ export function serializeConfigModule(config) {
11
+ const lines = Object.entries(config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`);
12
+ return `export default {\n${lines.join(",\n")}\n};\n`;
13
+ }
14
+
15
+ export async function readConfigModule(filePath, fallback = {}) {
16
+ try {
17
+ const source = await readFile(filePath, "utf8");
18
+ return parseConfigModule(source);
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ export async function loadToolConfig(toolName, defaults = {}) {
25
+ const configPath = getToolConfigPath(toolName);
26
+ const stored = await readConfigModule(configPath, {});
27
+ return { ...defaults, ...stored };
28
+ }
29
+
30
+ export async function writeToolConfig(toolName, config) {
31
+ const configPath = getToolConfigPath(toolName);
32
+ await mkdir(path.dirname(configPath), { recursive: true });
33
+ await writeFile(configPath, serializeConfigModule(config), "utf8");
34
+ return configPath;
35
+ }
@@ -1,6 +1,8 @@
1
- import { readdir, readFile, writeFile, unlink } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
+ import { getToolConfigPath, getToolTmpDir } from "../../runtime/paths.js";
5
+ import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
4
6
 
5
7
  const cliRoot = path.resolve("cli");
6
8
 
@@ -15,16 +17,6 @@ function runProcess(command, args, options = {}) {
15
17
  });
16
18
  }
17
19
 
18
- function parseConfigModule(source) {
19
- const normalized = source.replace(/^export\s+default/, "return");
20
- return new Function(normalized)();
21
- }
22
-
23
- function serializeConfigModule(config) {
24
- const lines = Object.entries(config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`);
25
- return `export default {\n${lines.join(",\n")}\n};\n`;
26
- }
27
-
28
20
  export class ToolRegistry {
29
21
  constructor() {
30
22
  this.tools = new Map();
@@ -47,12 +39,15 @@ export class ToolRegistry {
47
39
  try {
48
40
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
49
41
  const configSource = await readFile(configPath, "utf8");
50
- const config = parseConfigModule(configSource);
42
+ const defaults = parseConfigModule(configSource);
43
+ const config = await loadToolConfig(manifest.name, defaults);
51
44
  this.tools.set(manifest.name, {
52
45
  ...manifest,
53
46
  dir: toolDir,
54
47
  entry: path.join(toolDir, manifest.entry || "index.js"),
55
- configPath,
48
+ localConfigPath: configPath,
49
+ configPath: getToolConfigPath(manifest.name),
50
+ defaults,
56
51
  config
57
52
  });
58
53
  } catch {
@@ -87,15 +82,18 @@ export class ToolRegistry {
87
82
  if (!tool) throw new Error(`Tool not found: ${name}`);
88
83
  const config = { ...(tool.config || {}) };
89
84
  config[field] = value;
90
- await writeFile(tool.configPath, serializeConfigModule(config), "utf8");
85
+ const configPath = await writeToolConfig(name, config);
91
86
  tool.config = config;
92
- return { ok: true, tool: name, field, configPath: tool.configPath };
87
+ tool.configPath = configPath;
88
+ return { ok: true, tool: name, field, configPath };
93
89
  }
94
90
 
95
91
  async run({ name, request }) {
96
92
  const tool = this.get(name);
97
93
  if (!tool) throw new Error(`Tool not found: ${name}`);
98
- const requestFile = path.join(tool.dir, `.request-${Date.now()}.json`);
94
+ const tmpDir = getToolTmpDir(name);
95
+ await mkdir(tmpDir, { recursive: true });
96
+ const requestFile = path.join(tmpDir, `.request-${Date.now()}.json`);
99
97
  await writeFile(requestFile, `${JSON.stringify(request, null, 2)}\n`, "utf8");
100
98
  const result = await runProcess("node", [tool.entry, "run", "--request-file", requestFile], {
101
99
  cwd: tool.dir,
@@ -1,12 +1,10 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import path from "node:path";
1
+ import { readFile, writeFile } from "node:fs/promises";
3
2
  import readline from "node:readline/promises";
4
3
  import { stdin as input, stdout as output } from "node:process";
5
4
  import { spawn } from "node:child_process";
6
- import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
7
-
8
- const stateDir = path.resolve("data/state");
9
- const configFile = path.join(stateDir, "config.json");
5
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
6
+ import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
7
+ import { configFile, ensureArisaHome } from "./paths.js";
10
8
 
11
9
  async function exists(file) {
12
10
  try {
@@ -17,49 +15,6 @@ async function exists(file) {
17
15
  }
18
16
  }
19
17
 
20
- function getBootstrapModels() {
21
- const authStorage = AuthStorage.create();
22
- const modelRegistry = ModelRegistry.create(authStorage);
23
- const preferred = [
24
- ["openai-codex", "gpt-5.4"],
25
- ["openai-codex", "gpt-5.4-mini"],
26
- ["openai", "gpt-4.1"],
27
- ["anthropic", "claude-sonnet-4-6"],
28
- ["anthropic", "claude-opus-4-6"],
29
- ["google", "gemini-3.1-pro-preview"],
30
- ];
31
-
32
- const models = preferred
33
- .map(([provider, model]) => modelRegistry.find(provider, model))
34
- .filter(Boolean)
35
- .map((model) => ({ provider: model.provider, id: model.id, label: `${model.provider}/${model.id}` }));
36
-
37
- if (!models.length) {
38
- return modelRegistry.getAll().slice(0, 10).map((model) => ({
39
- provider: model.provider,
40
- id: model.id,
41
- label: `${model.provider}/${model.id}`,
42
- }));
43
- }
44
-
45
- return models;
46
- }
47
-
48
- function getOAuthProviderForModelProvider(provider) {
49
- if (provider === "openai-codex") return "openai-codex";
50
- if (provider === "anthropic") return "anthropic";
51
- if (provider === "google") return "google-gemini-cli";
52
- if (provider === "google-antigravity") return "google-antigravity";
53
- if (provider === "github-copilot") return "github-copilot";
54
- return provider;
55
- }
56
-
57
- function hasExistingPiAuth(provider) {
58
- const authStorage = AuthStorage.create();
59
- const modelRegistry = ModelRegistry.create(authStorage);
60
- const oauthProvider = getOAuthProviderForModelProvider(provider);
61
- return modelRegistry.hasConfiguredAuth(provider) || authStorage.hasAuth(oauthProvider);
62
- }
63
18
 
64
19
  async function maybeOpenExternal(url) {
65
20
  if (!url) return;
@@ -79,9 +34,7 @@ async function maybeOpenExternal(url) {
79
34
 
80
35
  async function runInternalPiLogin(provider, rl) {
81
36
  const authStorage = AuthStorage.create();
82
- const oauthProvider = getOAuthProviderForModelProvider(provider);
83
- const available = authStorage.getOAuthProviders();
84
- const selected = available.find((item) => item.id === oauthProvider);
37
+ const selected = authStorage.getOAuthProviders().find((item) => item.id === provider);
85
38
  if (!selected) {
86
39
  throw new Error(`No internal OAuth login flow is available for ${provider}.`);
87
40
  }
@@ -93,7 +46,7 @@ async function runInternalPiLogin(provider, rl) {
93
46
  manualCodeReject = reject;
94
47
  });
95
48
 
96
- await authStorage.login(oauthProvider, {
49
+ await authStorage.login(provider, {
97
50
  onAuth: async ({ url, instructions }) => {
98
51
  console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
99
52
  await maybeOpenExternal(url);
@@ -127,7 +80,7 @@ async function runInternalPiLogin(provider, rl) {
127
80
  }
128
81
 
129
82
  export async function bootstrapIfNeeded({ force = false } = {}) {
130
- await mkdir(stateDir, { recursive: true });
83
+ await ensureArisaHome();
131
84
  if (!force && await exists(configFile)) return;
132
85
 
133
86
  const rl = readline.createInterface({ input, output });
@@ -137,58 +90,63 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
137
90
  return value || fallback;
138
91
  };
139
92
 
140
- const askYesNo = async (label, fallback = true) => {
141
- const hint = fallback ? "Y/n" : "y/N";
142
- const value = (await rl.question(`${label} (${hint}): `)).trim().toLowerCase();
143
- if (!value) return fallback;
144
- return value === "y" || value === "yes";
145
- };
146
-
147
93
  console.log("\n== Arisa bootstrap ==\n");
148
94
  console.log("Telegram bot token tip: get it from https://t.me/BotFather");
149
95
  const telegramApiKey = await ask("Telegram API key / bot token");
150
96
  const telegramMaxChatIds = Number(await ask("Maximum authorized chat IDs", "1"));
151
97
 
152
- const models = getBootstrapModels();
153
- console.log("\nAvailable Pi models:");
98
+ const runtime = createPiRuntime();
99
+ const providers = listPiProviders(runtime);
100
+ console.log("\nAvailable Pi providers:");
101
+ providers.forEach((item, index) => {
102
+ const authLabel = item.authConfigured ? "auth: configured" : item.supportsOAuth ? "auth: login or API key" : "auth: API key";
103
+ console.log(`${index + 1}. ${item.provider} (${item.modelCount} models, ${authLabel})`);
104
+ });
105
+
106
+ const selectedProviderIndex = Number(await ask("Select Pi provider by number", "1"));
107
+ const selectedProvider = providers[Math.max(0, Math.min(providers.length - 1, selectedProviderIndex - 1))];
108
+ const models = listProviderModels(selectedProvider.provider, runtime);
109
+ console.log(`\nAvailable models for ${selectedProvider.provider}:`);
154
110
  models.forEach((model, index) => {
155
- const authStatus = hasExistingPiAuth(model.provider) ? "auth: configured" : "auth: missing";
156
- const providerLabel = model.provider;
157
- console.log(`${index + 1}. ${providerLabel}/${model.id} (${authStatus})`);
111
+ const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
112
+ const suffix = capabilities ? ` [${capabilities}]` : "";
113
+ console.log(`${index + 1}. ${model.id}${suffix}`);
158
114
  });
159
- const selectedIndex = Number(await ask("Select Pi model by number", "1"));
160
- const selectedModel = models[Math.max(0, Math.min(models.length - 1, selectedIndex - 1))];
161
- const selectedAuthReady = hasExistingPiAuth(selectedModel.provider);
115
+
116
+ const selectedModelIndex = Number(await ask("Select Pi model by number", "1"));
117
+ const selectedModel = models[Math.max(0, Math.min(models.length - 1, selectedModelIndex - 1))];
118
+ const selectedAuthReady = hasProviderAuth(selectedProvider.provider, runtime);
119
+ const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, runtime);
162
120
  console.log(`Selected model: ${selectedModel.provider}/${selectedModel.id}`);
163
- console.log(`Existing Pi auth for ${selectedModel.provider}: ${selectedAuthReady ? "yes" : "no"}`);
164
- console.log("Pi auth tip: if this provider supports Pi login, leaving the API key empty will start the internal login flow.");
121
+ console.log(`Existing Pi auth for ${selectedProvider.provider}: ${selectedAuthReady ? "yes" : "no"}`);
122
+ if (providerSupportsOAuth) {
123
+ console.log("Pi auth tip: leaving the API key empty will start Pi's internal login flow for this provider.");
124
+ }
165
125
 
166
126
  let piApiKey = "";
167
127
  while (true) {
168
- piApiKey = (await rl.question(`Pi API key for ${selectedModel.provider} (optional): `)).trim();
128
+ piApiKey = (await rl.question(`Pi API key for ${selectedProvider.provider} (optional): `)).trim();
169
129
  if (piApiKey) break;
170
- if (hasExistingPiAuth(selectedModel.provider)) break;
130
+ if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) break;
171
131
 
172
- const oauthProvider = getOAuthProviderForModelProvider(selectedModel.provider);
173
- const supportsInternalLogin = oauthProvider !== selectedModel.provider || ["anthropic", "openai-codex", "google-gemini-cli", "google-antigravity", "github-copilot"].includes(oauthProvider);
174
- if (!supportsInternalLogin) {
175
- console.log(`No existing Pi auth found for ${selectedModel.provider}. This provider requires an API key.`);
132
+ if (!providerSupportsOAuth) {
133
+ console.log(`No existing Pi auth found for ${selectedProvider.provider}. This provider requires an API key.`);
176
134
  continue;
177
135
  }
178
136
 
179
- console.log(`No existing Pi auth found for ${selectedModel.provider}. Starting internal Pi login...`);
137
+ console.log(`No existing Pi auth found for ${selectedProvider.provider}. Starting internal Pi login...`);
180
138
  try {
181
- await runInternalPiLogin(selectedModel.provider, rl);
139
+ await runInternalPiLogin(selectedProvider.provider, rl);
182
140
  } catch (error) {
183
141
  console.log(`Internal Pi login failed: ${error instanceof Error ? error.message : String(error)}`);
184
142
  }
185
143
 
186
- if (hasExistingPiAuth(selectedModel.provider)) {
187
- console.log(`Detected Pi auth for ${selectedModel.provider}. Continuing bootstrap.`);
144
+ if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
145
+ console.log(`Detected Pi auth for ${selectedProvider.provider}. Continuing bootstrap.`);
188
146
  break;
189
147
  }
190
148
 
191
- console.log(`Pi auth for ${selectedModel.provider} is still missing after login.`);
149
+ console.log(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
192
150
  }
193
151
 
194
152
  const config = {
@@ -198,7 +156,7 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
198
156
  authorizedChatIds: []
199
157
  },
200
158
  pi: {
201
- provider: selectedModel.provider,
159
+ provider: selectedProvider.provider,
202
160
  model: selectedModel.id,
203
161
  apiKey: piApiKey
204
162
  },
@@ -0,0 +1,39 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const arisaHomeDir = path.join(os.homedir(), ".arisa");
6
+ export const stateDir = path.join(arisaHomeDir, "state");
7
+ export const configFile = path.join(stateDir, "config.json");
8
+ export const artifactsDir = path.join(arisaHomeDir, "artifacts");
9
+ export const artifactsIndexFile = path.join(stateDir, "artifacts.json");
10
+ export const toolsDir = path.join(arisaHomeDir, "tools");
11
+ export const tmpDir = path.join(arisaHomeDir, "tmp");
12
+
13
+ export function getToolDir(toolName) {
14
+ return path.join(toolsDir, toolName);
15
+ }
16
+
17
+ export function getToolConfigPath(toolName) {
18
+ return path.join(getToolDir(toolName), "config.js");
19
+ }
20
+
21
+ export function getToolRuntimeDir(toolName) {
22
+ return path.join(tmpDir, "tools", toolName);
23
+ }
24
+
25
+ export function getToolOutDir(toolName) {
26
+ return path.join(getToolRuntimeDir(toolName), "out");
27
+ }
28
+
29
+ export function getToolTmpDir(toolName) {
30
+ return path.join(getToolRuntimeDir(toolName), "tmp");
31
+ }
32
+
33
+ export async function ensureArisaHome() {
34
+ await mkdir(stateDir, { recursive: true });
35
+ await mkdir(artifactsDir, { recursive: true });
36
+ await mkdir(toolsDir, { recursive: true });
37
+ await mkdir(tmpDir, { recursive: true });
38
+ }
39
+
@@ -97,7 +97,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, a
97
97
  const { transcript, toolResult } = await maybeTranscribeIncomingAudio({ artifact, toolRegistry, artifactStore });
98
98
  if (artifact?.kind === "audio" && !transcript) {
99
99
  if (toolResult?.missingConfig?.includes("OPENAI_API_KEY")) {
100
- throw new Error("I need the OpenAI API key for cli/openai-transcribe/config.js before I can transcribe incoming audio.");
100
+ throw new Error("I need the OpenAI API key for ~/.arisa/tools/openai-transcribe/config.js before I can transcribe incoming audio.");
101
101
  }
102
102
  throw new Error(toolResult?.error || "Audio transcription failed.");
103
103
  }
@@ -1,18 +1,9 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
-
4
- const inboxDir = path.resolve("data/inbox");
5
-
6
- async function downloadToFile(ctx, fileId, fileName) {
7
- await mkdir(inboxDir, { recursive: true });
1
+ async function downloadToBuffer(ctx, fileId) {
8
2
  const file = await ctx.api.getFile(fileId);
9
3
  const url = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`;
10
4
  const response = await fetch(url);
11
5
  if (!response.ok) throw new Error(`Download failed: ${response.status}`);
12
- const buffer = Buffer.from(await response.arrayBuffer());
13
- const target = path.join(inboxDir, fileName);
14
- await writeFile(target, buffer);
15
- return target;
6
+ return Buffer.from(await response.arrayBuffer());
16
7
  }
17
8
 
18
9
  export async function captureIncomingArtifact(ctx, artifactStore) {
@@ -25,10 +16,10 @@ export async function captureIncomingArtifact(ctx, artifactStore) {
25
16
 
26
17
  if (ctx.message?.voice) {
27
18
  const fileName = `${ctx.chat.id}-${ctx.msg.message_id}.ogg`;
28
- const tempPath = await downloadToFile(ctx, ctx.message.voice.file_id, fileName);
29
- return artifactStore.createFromFile({
30
- originalPath: tempPath,
19
+ const content = await downloadToBuffer(ctx, ctx.message.voice.file_id);
20
+ return artifactStore.createGeneratedFile({
31
21
  fileName,
22
+ content,
32
23
  kind: "audio",
33
24
  mimeType: "audio/ogg",
34
25
  source: baseSource,
@@ -38,10 +29,10 @@ export async function captureIncomingArtifact(ctx, artifactStore) {
38
29
 
39
30
  if (ctx.message?.document) {
40
31
  const fileName = ctx.message.document.file_name || `${ctx.chat.id}-${ctx.msg.message_id}`;
41
- const tempPath = await downloadToFile(ctx, ctx.message.document.file_id, fileName);
42
- return artifactStore.createFromFile({
43
- originalPath: tempPath,
32
+ const content = await downloadToBuffer(ctx, ctx.message.document.file_id);
33
+ return artifactStore.createGeneratedFile({
44
34
  fileName,
35
+ content,
45
36
  kind: "document",
46
37
  mimeType: ctx.message.document.mime_type || "application/octet-stream",
47
38
  source: baseSource,
@@ -52,10 +43,10 @@ export async function captureIncomingArtifact(ctx, artifactStore) {
52
43
  if (ctx.message?.photo?.length) {
53
44
  const photo = ctx.message.photo.at(-1);
54
45
  const fileName = `${ctx.chat.id}-${ctx.msg.message_id}.jpg`;
55
- const tempPath = await downloadToFile(ctx, photo.file_id, fileName);
56
- return artifactStore.createFromFile({
57
- originalPath: tempPath,
46
+ const content = await downloadToBuffer(ctx, photo.file_id);
47
+ return artifactStore.createGeneratedFile({
58
48
  fileName,
49
+ content,
59
50
  kind: "image",
60
51
  mimeType: "image/jpeg",
61
52
  source: baseSource,