arisa 3.0.9 → 3.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Arisa
2
2
 
3
- Arisa is a personal Telegram assistant powered by Pi Agent.
3
+ Arisa is a personal Telegram assistant powered by [Pi Agent](https://pi.dev).
4
4
 
5
5
  ## Origin
6
6
 
@@ -64,13 +64,6 @@ That isolation is part of the architecture:
64
64
  - each tool can have its own dependencies
65
65
  - one tool can be changed or replaced without tightly coupling the rest of the system
66
66
 
67
- Each tool must support:
68
-
69
- ```bash
70
- node index.js --help
71
- node index.js run --request-file <json>
72
- ```
73
-
74
67
  ### Configuration model
75
68
  - all runtime state lives under `~/.arisa/`
76
69
  - Telegram runtime config is stored in `~/.arisa/state/config.json`
@@ -108,6 +101,17 @@ arisa install <source> # install a Pi package into Arisa's runtime
108
101
  arisa remove <source> # remove a Pi package from Arisa's runtime
109
102
  ```
110
103
 
104
+ ## Experimental features
105
+
106
+ ### Pi Agent packages
107
+
108
+ Arisa can install **Pi Agent packages** from the public registry into your user runtime (`~/.arisa/`), using the same package manager as Pi Agent. Browse and discover packages at [pi.dev/packages](https://pi.dev/packages).
109
+
110
+ - `arisa install <source>` installs a package (by registry name or other source supported by Pi).
111
+ - `arisa remove <source>` removes a previously installed package.
112
+
113
+ Treat this as **experimental**: the registry, package formats, and install behavior follow Pi Agent and may change. Not every listed package is tailored to Arisa’s Telegram transport and artifact-based tools; prefer packages you understand and verify after install.
114
+
111
115
  ## Bootstrap flow
112
116
 
113
117
  On first run, Arisa will:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "3.0.9",
3
+ "version": "3.0.11",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -27,6 +27,7 @@
27
27
  "zeroclaw",
28
28
  "jarvis",
29
29
  "AGENTS.md",
30
+ "pi.dev",
30
31
  "clasen"
31
32
  ],
32
33
  "author": "",
@@ -123,7 +123,7 @@ export class AgentManager {
123
123
  defineTool({
124
124
  name: "run_tool",
125
125
  label: "Run tool",
126
- description: "Run a CLI tool using text input or an artifactId. If config is missing, ask the user naturally and then use set_tool_config.",
126
+ description: "Run a CLI tool using text input or an artifactId. Inspect the returned status/resolution fields. If a tool reports missing config, ask the user naturally, use set_tool_config, and retry.",
127
127
  parameters: Type.Object({
128
128
  name: Type.String(),
129
129
  artifactId: Type.Optional(Type.String()),
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { getToolConfigPath, getToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
6
6
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
7
+ import { normalizeToolResult } from "./tool-result.js";
7
8
 
8
9
  const bundledToolsRoot = fileURLToPath(new URL("../../../tools", import.meta.url));
9
10
  const toolRoots = [
@@ -117,15 +118,16 @@ export class ToolRegistry {
117
118
  await unlink(requestFile).catch(() => {});
118
119
  try {
119
120
  const parsed = JSON.parse(result.stdout || result.stderr);
120
- this.logger?.log("tools", `${name} -> ${parsed.ok === false ? "error" : "ok"}`);
121
- return parsed;
121
+ const normalized = normalizeToolResult(name, parsed);
122
+ this.logger?.log("tools", `${name} -> ${normalized.ok === false ? normalized.status || "error" : "ok"}`);
123
+ return normalized;
122
124
  } catch {
123
- return {
125
+ return normalizeToolResult(name, {
124
126
  ok: false,
125
127
  error: `Invalid tool response for ${name}`,
126
128
  stdout: result.stdout,
127
129
  stderr: result.stderr
128
- };
130
+ });
129
131
  }
130
132
  }
131
133
  }
@@ -0,0 +1,71 @@
1
+ export function toolOk(output = {}, extra = {}) {
2
+ return { ok: true, output, ...extra };
3
+ }
4
+
5
+ export function toolError(error, extra = {}) {
6
+ return {
7
+ ok: false,
8
+ status: extra.status || "failed",
9
+ error,
10
+ ...extra
11
+ };
12
+ }
13
+
14
+ export function toolNeedsConfig({ tool, missingConfig = [], configPath, message } = {}) {
15
+ return {
16
+ ok: false,
17
+ status: "needs_config",
18
+ error: message || `Missing tool configuration${tool ? ` for ${tool}` : ""}.`,
19
+ missingConfig,
20
+ configPath,
21
+ resolution: {
22
+ type: "user_config_required",
23
+ tool,
24
+ missingConfig,
25
+ configPath
26
+ }
27
+ };
28
+ }
29
+
30
+ export function normalizeToolResult(name, result = {}) {
31
+ if (!result || typeof result !== "object") {
32
+ return toolError(`Invalid tool response for ${name}`);
33
+ }
34
+
35
+ if (result.ok === false && result.missingConfig?.length) {
36
+ return {
37
+ ...toolNeedsConfig({
38
+ tool: name,
39
+ missingConfig: result.missingConfig,
40
+ configPath: result.configPath,
41
+ message: result.error
42
+ }),
43
+ ...result,
44
+ status: result.status || "needs_config",
45
+ resolution: result.resolution || {
46
+ type: "user_config_required",
47
+ tool: name,
48
+ missingConfig: result.missingConfig,
49
+ configPath: result.configPath
50
+ }
51
+ };
52
+ }
53
+
54
+ if (result.ok === false) {
55
+ return {
56
+ ...toolError(result.error || `Tool failed: ${name}`),
57
+ ...result,
58
+ status: result.status || "failed"
59
+ };
60
+ }
61
+
62
+ if (result.ok === true) {
63
+ return {
64
+ ...toolOk(result.output || {}),
65
+ ...result,
66
+ status: result.status || "ok"
67
+ };
68
+ }
69
+
70
+ return toolError(`Invalid tool response for ${name}`, { rawResult: result });
71
+ }
@@ -10,7 +10,6 @@ export const serviceLogFile = path.join(stateDir, "arisa.log");
10
10
  export const artifactsDir = path.join(arisaHomeDir, "artifacts");
11
11
  export const artifactsIndexFile = path.join(stateDir, "artifacts.json");
12
12
  export const toolsDir = path.join(arisaHomeDir, "tools");
13
- export const tmpDir = path.join(arisaHomeDir, "tmp");
14
13
 
15
14
  export function getToolDir(toolName) {
16
15
  return path.join(toolsDir, toolName);
@@ -36,6 +35,5 @@ export async function ensureArisaHome() {
36
35
  await mkdir(stateDir, { recursive: true });
37
36
  await mkdir(artifactsDir, { recursive: true });
38
37
  await mkdir(toolsDir, { recursive: true });
39
- await mkdir(tmpDir, { recursive: true });
40
38
  }
41
39
 
@@ -1,7 +1,8 @@
1
1
  import { Bot, InputFile } from "grammy";
2
+ import path from "node:path";
2
3
  import { authorizeChat } from "./auth.js";
3
4
  import { captureIncomingArtifact } from "./media.js";
4
- import { renderTelegramHtml, splitTelegramText } from "./text-format.js";
5
+ import { renderTelegramHtml } from "./text-format.js";
5
6
 
6
7
  function quotedMessageSummary(message) {
7
8
  if (!message) return [];
@@ -31,13 +32,14 @@ function quotedMessageSummary(message) {
31
32
  return parts;
32
33
  }
33
34
 
34
- function buildPrompt({ ctx, artifact, transcript }) {
35
+ function buildPrompt({ ctx, artifact, transcript, toolResult }) {
35
36
  const parts = [
36
37
  `New Telegram message.`,
37
38
  `chatId: ${ctx.chat.id}`,
38
39
  `userId: ${ctx.from.id}`,
39
40
  `username: ${ctx.from.username || "(no username)"}`,
40
- `messageId: ${ctx.msg.message_id}`
41
+ `messageId: ${ctx.msg.message_id}`,
42
+ `preferredTelegramLanguageCode: ${ctx.from?.language_code || "unknown"}`
41
43
  ];
42
44
 
43
45
  if (ctx.message?.text) parts.push(`text: ${ctx.message.text}`);
@@ -51,6 +53,10 @@ function buildPrompt({ ctx, artifact, transcript }) {
51
53
  parts.push(`transcriptText: ${transcript.text}`);
52
54
  parts.push(`Important: the incoming audio has already been transcribed. Use the transcript as the user message content. Do not answer with a raw transcription unless the user explicitly asked for one.`);
53
55
  }
56
+ if (artifact?.kind === "audio" && !transcript && toolResult) {
57
+ parts.push(`audioNormalizationResult: ${JSON.stringify(toolResult)}`);
58
+ parts.push(`Important: pre-reasoning audio normalization could not be completed, so you do not have a transcript for this voice/audio message.`);
59
+ }
54
60
 
55
61
  parts.push(`If you need a CLI tool, use list_tools/tool_help/run_tool.`);
56
62
  parts.push(`If a tool config is missing, ask the user naturally and then use set_tool_config.`);
@@ -74,7 +80,7 @@ async function maybeTranscribeIncomingAudio({ artifact, toolRegistry, artifactSt
74
80
  }
75
81
 
76
82
  if (!result.output?.text) {
77
- return { transcript: null, toolResult: { ok: false, error: "Transcription returned no text." } };
83
+ return { transcript: null, toolResult: { ok: false, status: "failed", error: "Transcription returned no text." } };
78
84
  }
79
85
 
80
86
  const transcript = await artifactStore.createText({
@@ -138,19 +144,32 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, a
138
144
  const { transcript, toolResult } = await maybeTranscribeIncomingAudio({ artifact, toolRegistry, artifactStore });
139
145
  if (transcript) logger?.log("telegram", `audio transcribed to artifact ${transcript.id}`);
140
146
  if (artifact?.kind === "audio" && !transcript) {
141
- if (toolResult?.missingConfig?.includes("OPENAI_API_KEY")) {
142
- throw new Error("I need the OpenAI API key for ~/.arisa/tools/openai-transcribe/config.js before I can transcribe incoming audio.");
143
- }
144
- throw new Error(toolResult?.error || "Audio transcription failed.");
147
+ logger?.log("telegram", `audio normalization unavailable for chat ${ctx.chat.id}: ${toolResult?.error || toolResult?.missingConfig?.join(", ") || "unknown error"}`);
145
148
  }
146
- return buildPrompt({ ctx, artifact, transcript });
149
+ return buildPrompt({ ctx, artifact, transcript, toolResult });
147
150
  }
148
151
 
149
- async function sendTextReply(send, chatId, text) {
150
- logger?.log("telegram", `sending text reply for chat ${chatId}`);
151
- for (const chunk of splitTelegramText(text)) {
152
- await send(renderTelegramHtml(chunk), { parse_mode: "HTML" });
152
+ async function sendTextReply({ sendText, sendDocument, chatId, text }) {
153
+ const maxInlineReplyLength = 3500;
154
+
155
+ if (text.length > maxInlineReplyLength) {
156
+ logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
157
+ const artifact = await artifactStore.createGeneratedFile({
158
+ fileName: `reply-${Date.now()}.md`,
159
+ content: text,
160
+ kind: "document",
161
+ mimeType: "text/markdown",
162
+ source: { type: "assistant", chatId },
163
+ metadata: { delivery: "telegram-document" }
164
+ });
165
+ await sendDocument(new InputFile(artifact.path, path.basename(artifact.path)), {
166
+ caption: "Response attached as Markdown."
167
+ });
168
+ return;
153
169
  }
170
+
171
+ logger?.log("telegram", `sending text reply for chat ${chatId}`);
172
+ await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
154
173
  }
155
174
 
156
175
  async function processPrompt(ctx, prompt) {
@@ -167,7 +186,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, a
167
186
  const { session } = await agentManager.getSessionContext(ctx.chat.id, telegram);
168
187
  const text = await collectText(session, prompt);
169
188
  if (text) {
170
- await sendTextReply((message, extra) => ctx.reply(message, extra), ctx.chat.id, text);
189
+ await sendTextReply({
190
+ sendText: (message, extra) => ctx.reply(message, extra),
191
+ sendDocument: (file, extra) => ctx.replyWithDocument(file, extra),
192
+ chatId: ctx.chat.id,
193
+ text
194
+ });
171
195
  }
172
196
  });
173
197
  }
@@ -226,7 +250,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, a
226
250
  const chatState = getChatState(ctx.chat.id);
227
251
  chatState.processing = false;
228
252
  const message = error instanceof Error ? error.message : String(error);
229
- await ctx.reply(`Error: ${message}`);
253
+ await ctx.reply(message);
230
254
  }
231
255
  });
232
256
 
@@ -260,7 +284,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, a
260
284
  ].filter(Boolean).join("\n");
261
285
  const text = await collectText(session, welcomePrompt);
262
286
  if (text) {
263
- await sendTextReply((message, extra) => bot.api.sendMessage(chatId, message, extra), chatId, text);
287
+ await sendTextReply({
288
+ sendText: (message, extra) => bot.api.sendMessage(chatId, message, extra),
289
+ sendDocument: (file, extra) => bot.api.sendDocument(chatId, file, extra),
290
+ chatId,
291
+ text
292
+ });
264
293
  }
265
294
  } catch (error) {
266
295
  logger?.log("telegram", `startup message failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import defaults from "./config.js";
4
4
  import { loadToolConfig } from "../../src/core/tools/tool-config.js";
5
+ import { toolError, toolNeedsConfig, toolOk } from "../../src/core/tools/tool-result.js";
5
6
  import { getToolConfigPath } from "../../src/runtime/paths.js";
6
7
 
7
8
  const toolName = "openai-transcribe";
@@ -13,14 +14,18 @@ function printHelp() {
13
14
 
14
15
  async function run(requestFile) {
15
16
  if (!config.OPENAI_API_KEY) {
16
- console.log(JSON.stringify({ ok: false, missingConfig: ["OPENAI_API_KEY"], configPath: getToolConfigPath(toolName) }));
17
+ console.log(JSON.stringify(toolNeedsConfig({
18
+ tool: toolName,
19
+ missingConfig: ["OPENAI_API_KEY"],
20
+ configPath: getToolConfigPath(toolName)
21
+ })));
17
22
  return;
18
23
  }
19
24
 
20
25
  const request = JSON.parse(await readFile(requestFile, "utf8"));
21
26
  const artifact = request.artifact;
22
27
  if (!artifact?.path) {
23
- console.log(JSON.stringify({ ok: false, error: "artifact.path is required" }));
28
+ console.log(JSON.stringify(toolError("artifact.path is required")));
24
29
  return;
25
30
  }
26
31
 
@@ -38,11 +43,11 @@ async function run(requestFile) {
38
43
 
39
44
  const payload = await response.json();
40
45
  if (!response.ok) {
41
- console.log(JSON.stringify({ ok: false, error: payload.error?.message || "OpenAI transcription failed" }));
46
+ console.log(JSON.stringify(toolError(payload.error?.message || "OpenAI transcription failed")));
42
47
  return;
43
48
  }
44
49
 
45
- console.log(JSON.stringify({ ok: true, output: { text: payload.text || "" } }));
50
+ console.log(JSON.stringify(toolOk({ text: payload.text || "" })));
46
51
  }
47
52
 
48
53
  const args = process.argv.slice(2);
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import defaults from "./config.js";
4
4
  import { loadToolConfig } from "../../src/core/tools/tool-config.js";
5
+ import { toolError, toolNeedsConfig, toolOk } from "../../src/core/tools/tool-result.js";
5
6
  import { getToolConfigPath, getToolOutDir } from "../../src/runtime/paths.js";
6
7
 
7
8
  const toolName = "openai-tts";
@@ -13,14 +14,18 @@ function printHelp() {
13
14
 
14
15
  async function run(requestFile) {
15
16
  if (!config.OPENAI_API_KEY) {
16
- console.log(JSON.stringify({ ok: false, missingConfig: ["OPENAI_API_KEY"], configPath: getToolConfigPath(toolName) }));
17
+ console.log(JSON.stringify(toolNeedsConfig({
18
+ tool: toolName,
19
+ missingConfig: ["OPENAI_API_KEY"],
20
+ configPath: getToolConfigPath(toolName)
21
+ })));
17
22
  return;
18
23
  }
19
24
 
20
25
  const request = JSON.parse(await readFile(requestFile, "utf8"));
21
26
  const inputText = request.text || request.artifact?.text;
22
27
  if (!inputText) {
23
- console.log(JSON.stringify({ ok: false, error: "text or artifact.text is required" }));
28
+ console.log(JSON.stringify(toolError("text or artifact.text is required")));
24
29
  return;
25
30
  }
26
31
 
@@ -40,7 +45,7 @@ async function run(requestFile) {
40
45
 
41
46
  if (!response.ok) {
42
47
  const payload = await response.text();
43
- console.log(JSON.stringify({ ok: false, error: payload }));
48
+ console.log(JSON.stringify(toolError(payload)));
44
49
  return;
45
50
  }
46
51
 
@@ -49,16 +54,13 @@ async function run(requestFile) {
49
54
  const filePath = path.join(outDir, `speech-${Date.now()}.ogg`);
50
55
  const buffer = Buffer.from(await response.arrayBuffer());
51
56
  await writeFile(filePath, buffer);
52
- console.log(JSON.stringify({
53
- ok: true,
54
- output: {
55
- filePath,
56
- fileName: path.basename(filePath),
57
- mimeType: "audio/ogg",
58
- kind: "audio",
59
- delivery: { method: "voice" }
60
- }
61
- }));
57
+ console.log(JSON.stringify(toolOk({
58
+ filePath,
59
+ fileName: path.basename(filePath),
60
+ mimeType: "audio/ogg",
61
+ kind: "audio",
62
+ delivery: { method: "voice" }
63
+ })));
62
64
  }
63
65
 
64
66
  const args = process.argv.slice(2);
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { toolError, toolOk } from "../../src/core/tools/tool-result.js";
2
3
 
3
4
  function printHelp() {
4
5
  console.log(`web-browser\n\nUsage:\n node index.js --help\n node index.js run --request-file <json>\n\nExpected input:\n {\n "text": "weather toronto" | "https://example.com",\n "artifact": { "text": "weather toronto" },\n "args": {\n "mode": "search" | "open",\n "url": "https://example.com",\n "maxResults": "5"\n }\n }\n\nBehavior:\n - If the input looks like a URL, open the page.\n - Otherwise, perform a web search.\n - When possible, opening pages uses r.jina.ai with a direct fetch fallback.\n`);
@@ -121,7 +122,7 @@ async function run(requestFile) {
121
122
  const maxResults = Number.parseInt(request.args?.maxResults || "5", 10);
122
123
 
123
124
  if (!rawInput.trim()) {
124
- console.log(JSON.stringify({ ok: false, error: "text, artifact.text, or args.url is required" }));
125
+ console.log(JSON.stringify(toolError("text, artifact.text, or args.url is required")));
125
126
  return;
126
127
  }
127
128
 
@@ -129,9 +130,9 @@ async function run(requestFile) {
129
130
  const outputText = mode === "open"
130
131
  ? await openWebPage(rawInput)
131
132
  : await searchWeb(rawInput, Number.isFinite(maxResults) ? maxResults : 5);
132
- console.log(JSON.stringify({ ok: true, output: { text: outputText } }));
133
+ console.log(JSON.stringify(toolOk({ text: outputText })));
133
134
  } catch (error) {
134
- console.log(JSON.stringify({ ok: false, error: error.message || String(error) }));
135
+ console.log(JSON.stringify(toolError(error.message || String(error))));
135
136
  }
136
137
  }
137
138