arisa 3.2.2 → 4.0.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.
package/AGENTS.md CHANGED
@@ -6,6 +6,7 @@
6
6
  - Incoming messages and files (text, voice, photo, document) and generated files become artifacts.
7
7
  - A tool registry handles tool discovery, help lookup, config writes, and execution.
8
8
  - Tools are isolated and each one has its own manifest, entrypoint, and config defaults.
9
+ - No tools ship with the core. All installed tools live under `~/.arisa/tools/<toolName>`; the install directory of Arisa (your working directory) contains only the core. Never create or install tools inside the install directory.
9
10
 
10
11
  ## Runtime directory rules
11
12
  Do not build runtime paths by hand. Use `src/runtime/paths.js`:
@@ -56,6 +57,11 @@ Not every pipe should be decided by Pi Agent at runtime. Some pipes are part of
56
57
  If inbound media was normalized before reasoning, Pi Agent should use the normalized result as the actual message content.
57
58
  For example, if a voice note was transcribed, Pi Agent should answer the meaning of the transcript, not simply return the raw transcript unless the user explicitly asked for transcription.
58
59
 
60
+ ## Telegram outbound replies
61
+ - Short textual replies are sent inline as a normal Telegram message.
62
+ - When a textual reply is too large to read comfortably inline, it is delivered as a generated Markdown artifact instead of a long inline message. The transport handles this automatically in `sendTextReply`: replies over the inline length limit become a `reply-<timestamp>.md` artifact sent as a document.
63
+ - This is a transport-layer concern. Pi Agent should write the full answer it wants to deliver and not pre-split or truncate it to fit the chat; the transport decides between inline text and a Markdown attachment.
64
+
59
65
  ## How to inspect CLI tools
60
66
  Before using a tool, inspect its help:
61
67
  - via the custom tool: `tool_help`
@@ -66,7 +72,7 @@ Every CLI must support (the entrypoint comes from `manifest.entry`, currently al
66
72
  - `node index.js run --request-file <json>`
67
73
 
68
74
  ### Tools that need daemons
69
- A future tool may need a persistent process, for example to keep a browser session alive or a local model warm. The shared daemon runtime exists for this, but no bundled tool uses it yet.
75
+ A tool may need a persistent process, for example to keep a browser session alive or a local model warm. The shared daemon runtime exists for this (the `whispermix-transcribe` catalog tool uses it).
70
76
  When such a tool is built, implement it with the shared daemon runtime instead of custom ad hoc process management:
71
77
  - use `src/core/tools/daemon-runtime.js`
72
78
  - keep runtime files under the tool state directory (`~/.arisa/state/tools/<toolName>`)
@@ -98,7 +104,7 @@ Beyond time-based scheduling, tools can drive an event queue that wakes the agen
98
104
 
99
105
  Tasks without a `runAt` fire immediately, so `agent_event` and the first `poll_tool` run on the next tick.
100
106
 
101
- The poller dispatches all three kinds, but only `agent_task` is exercised by a bundled tool today (`schedule-agent-task`). The following is the pattern to follow when a checker tool is built:
107
+ The poller dispatches all three kinds, but only `agent_task` is exercised by a catalog tool today (`schedule-agent-task`). The following is the pattern to follow when a checker tool is built:
102
108
 
103
109
  How a tool wires its own polling:
104
110
  1. From any tool `run`, start the poll by returning an `asyncTask` (or several in `asyncTasks`):
@@ -118,23 +124,45 @@ If `run_tool` returns `missingConfig`, the agent should:
118
124
 
119
125
  Do not assume a rigid question/answer protocol. Continue the conversation naturally and infer the config value from the user reply when possible.
120
126
 
127
+ ## Official tool catalog
128
+ The official tool catalog lives at `https://github.com/clasen/Arisa/tree/main/tools`. Each subdirectory is an installable tool following the standard tool contract.
129
+
130
+ When a capability is missing, check the catalog before building anything:
131
+ 1. List the catalog: `curl -s https://api.github.com/repos/clasen/Arisa/contents/tools`.
132
+ 2. Read the manifest of any candidate: `https://raw.githubusercontent.com/clasen/Arisa/main/tools/<name>/tool.manifest.json` (the `description`, `input`, and `output` fields tell you whether it solves the need). Also read the catalog `README.md` to check the tool's install footprint.
133
+ 3. If a catalog tool solves the problem, decide by install footprint (the `Install footprint` column in the catalog `README.md`):
134
+ - **Low footprint** (no extra npm dependencies, no external binaries): install it and resolve the user's request in the same turn, without asking first. Favor autonomy here.
135
+ - **Medium/High footprint** (heavy dependency trees, external binaries, or interactive setup such as a login): propose it to the user, say which tool it is and what it does, and wait for their confirmation in the chat before installing.
136
+ - A missing config secret (for example an API key) is handled by the missing-config flow and is not, on its own, a reason to ask before installing.
137
+ 4. Only when nothing in the catalog fits, fall back to creating a new tool.
138
+
139
+ ### Installing a tool
140
+ After deciding to install (low footprint), after the user confirms, or when the user directly asks to install a tool from any source they choose:
141
+ 1. Download the tool directory into `~/.arisa/tools/<name>`. For the official catalog, clone shallowly and copy the subdirectory, for example:
142
+ `git clone --depth 1 https://github.com/clasen/Arisa /tmp/arisa-catalog && cp -R /tmp/arisa-catalog/tools/<name> ~/.arisa/tools/<name>`.
143
+ 2. Install dependencies inside the tool directory (`pnpm install`, fall back to `npm install`).
144
+ 3. Run it through `run_tool`; the registry picks up new tools automatically and exposes the Arisa package root through `ARISA_PACKAGE_DIR`. If it returns `missingConfig`, follow the missing config flow.
145
+
121
146
  ## Tool creation
122
147
  Reason in terms of capabilities, not tool names.
123
148
 
124
149
  When the user asks for something new:
125
150
  1. check whether an existing registered tool can already satisfy the task
126
151
  2. also check whether the task can be satisfied indirectly through an existing capability
127
- 3. only propose creating a new tool when the needed capability is truly missing
152
+ 3. check whether a tool in the official catalog satisfies the task and propose installing it
153
+ 4. only propose creating a new tool when the needed capability is truly missing
128
154
 
129
155
  Do not stop at "I cannot do that" when the task is realistically implementable through the tool architecture.
130
156
  The default attitude is:
131
157
  - identify that no current tool satisfies the request
132
158
  - state that the missing capability can be added
133
- - propose or start creating the needed tool
159
+ - propose installing from the official catalog, or propose or start creating the needed tool
134
160
 
135
161
  When creating or editing tools:
162
+ - always create tools under `~/.arisa/tools/<toolName>`, never inside the Arisa install directory
136
163
  - use the path helpers in `src/runtime/paths.js`
137
- - follow the existing bundled tools under `tools/` as the reference pattern for new tools
164
+ - import Arisa core helpers dynamically through `ARISA_PACKAGE_DIR` (for example `await importCore("core/tools/tool-result.js")`); never use `../../src/...` or rewritten absolute paths
165
+ - follow the tools in the official catalog as the reference pattern for new tools
138
166
  - keep all help text, usage instructions, manifests, and user-facing operational strings in English
139
167
  - follow the One Thing Rule: each function or method should do one thing well; if it mixes low-level operations with high-level policy, split it into smaller focused units
140
168
 
@@ -158,7 +186,6 @@ Tool dependencies are installed as part of building or running the tool, not del
158
186
  - Do not ask the user to do it manually.
159
187
 
160
188
  ## Safety
161
- - Do not install or run arbitrary tools outside registered tool manifests in V1.
162
189
  - Prefer tool manifests and CLI help over assumptions.
163
190
  - Keep tool config and runtime data inside the user runtime area.
164
191
  - 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
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Origin
6
6
 
7
- The initial inspiration was OpenClaw, which has interesting ideas but carries a lot of weight (about **185 MB**) compared to Arisa (**76.7 kB**): when it generates tools they end up disorganized, and the overall framework feels overloaded.
7
+ The initial inspiration was OpenClaw, which has interesting ideas but carries a lot of weight (about **85 MB**, **55 dependencies**) compared to Arisa (**37 kB**, **3 dependencies**): when it generates tools they end up disorganized, and the overall framework feels overloaded.
8
8
 
9
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
10
 
@@ -38,15 +38,31 @@ Arisa separates two different kinds of pipes:
38
38
 
39
39
  This distinction is important. Some transformations belong to the transport/input layer, not to the agent's runtime decision making.
40
40
 
41
+ ## Zero tools, assembled on demand
42
+
43
+ A fresh install ships with **zero tools**. The core is only Telegram transport, the Pi Agent reasoning loop, the artifact store, and the tool registry. Out of the box Arisa cannot transcribe audio, browse the web, or speak; it gains each capability only once a tool that provides it is installed.
44
+
45
+ Arisa assembles its own toolset from real use:
46
+
47
+ 1. A request arrives that the current tools cannot satisfy.
48
+ 2. Arisa checks the [official catalog](https://github.com/clasen/Arisa/tree/main/tools) for a tool that fits the need.
49
+ 3. If one fits, it installs it: autonomously for low-footprint tools, or after asking you to confirm for heavier ones (extra dependencies, external binaries, or interactive setup such as a login).
50
+ 4. If nothing fits, it builds a new tool for the missing capability.
51
+ 5. The installed tool stays in `~/.arisa/tools/` and is reused from then on.
52
+
53
+ The result is a toolset shaped by how you actually use the assistant, not by defaults someone else chose. Two people running the same Arisa build can end up with completely different capabilities.
54
+
41
55
  ## Current behavior
42
56
 
43
57
  ### Telegram input
44
58
  - text messages go directly to Pi Agent
45
- - audio/voice messages are transcribed first, then passed to Pi Agent as text
59
+ - audio/voice messages are transcribed first when a transcription tool is installed, then passed to Pi Agent as text; otherwise Arisa offers to install one
46
60
  - media is stored as artifacts
47
61
 
48
62
  ### Tool model
49
- Bundled tools live under `<arisa-install-dir>/tools/<tool-name>` and user-created tools live under `~/.arisa/tools/<tool-name>`.
63
+ No tools ship with the core. All installed tools live under `~/.arisa/tools/<tool-name>`, whether they come from the [official catalog](https://github.com/clasen/Arisa/tree/main/tools), from another source the user chooses, or are created by the agent itself.
64
+
65
+ When the agent needs a capability it does not have, it checks the official catalog first. Low-footprint tools (no extra dependencies) are installed on the spot so the request is resolved in the same turn; heavier tools (extra dependencies, external binaries, or interactive setup) are proposed for you to confirm before anything is installed.
50
66
 
51
67
  Each tool folder contains:
52
68
 
@@ -59,8 +75,7 @@ Each tool is isolated from the root project and from other tools.
59
75
  That isolation is part of the architecture:
60
76
 
61
77
  - each tool has its own folder
62
- - bundled tools have a local `config.js` for defaults/template values
63
- - user-created tools can live entirely inside `~/.arisa/tools/<tool>/`
78
+ - each tool has a local `config.js` for defaults/template values
64
79
  - each tool can have its own dependencies
65
80
  - one tool can be changed or replaced without tightly coupling the rest of the system
66
81
 
@@ -97,8 +112,6 @@ arisa start # start in background
97
112
  arisa stop # stop background service
98
113
  arisa status # show background service status
99
114
  arisa flush # remove ~/.arisa
100
- arisa install <source> # install a Pi package into Arisa's runtime
101
- arisa remove <source> # remove a Pi package from Arisa's runtime
102
115
  ```
103
116
 
104
117
  Runtime model override (current process only):
@@ -111,17 +124,6 @@ Notes:
111
124
 
112
125
  - it only affects the current Arisa process and does not update `~/.arisa/state/config.json`
113
126
 
114
- ## Experimental features
115
-
116
- ### Pi Agent packages
117
-
118
- 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).
119
-
120
- - `arisa install <source>` installs a package (by registry name or other source supported by Pi).
121
- - `arisa remove <source>` removes a previously installed package.
122
-
123
- 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.
124
-
125
127
  ## Bootstrap flow
126
128
 
127
129
  On first run, Arisa will:
@@ -203,16 +205,15 @@ src/
203
205
  runtime/ bootstrap + app startup
204
206
  transport/ Telegram integration
205
207
  core/ agent, tools, artifacts, config
206
- tools/
207
- openai-transcribe/
208
- openai-tts/
209
208
  ~/.arisa/
210
209
  state/
211
210
  artifacts/
212
- tools/
211
+ tools/ installed tools (catalog, user-chosen, or agent-created)
213
212
  tmp/
214
213
  ```
215
214
 
215
+ The official tool catalog lives in the repository under [`tools/`](https://github.com/clasen/Arisa/tree/main/tools) and is not part of the npm package.
216
+
216
217
  ## Philosophy
217
218
 
218
219
  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.
@@ -221,9 +222,10 @@ For consistency, the entire Arisa codebase was built using Pi Agent itself, runn
221
222
 
222
223
  When a capability is missing:
223
224
 
224
- 1. check whether an existing tool can solve the task
225
- 2. if not, build the missing tool
226
- 3. keep the solution inside the tool architecture
225
+ 1. check whether an installed tool can solve the task
226
+ 2. if not, check the official catalog and propose installing the matching tool
227
+ 3. if nothing fits, build the missing tool
228
+ 4. keep the solution inside the tool architecture
227
229
 
228
230
  No "I can't do that" when the thing is realistically buildable.
229
231
 
@@ -235,14 +237,15 @@ No "I can't do that" when the thing is realistically buildable.
235
237
 
236
238
  ## Status
237
239
 
238
- This is currently a functional V1 focused on:
240
+ This is currently a functional V1. The core provides:
239
241
 
240
242
  - Telegram transport
241
243
  - Pi Agent integration
242
244
  - artifact-based message handling
243
- - isolated CLI tools
244
- - audio transcription before reasoning
245
- - text-to-speech replies
245
+ - the isolated CLI tool registry (starts empty)
246
+ - pre-reasoning and post-reasoning pipes
246
247
  - queued follow-up message batching
247
248
 
249
+ Concrete capabilities such as audio transcription or text-to-speech are not built in; they are added as tools from the catalog (or built on demand) when a request needs them.
250
+
248
251
  Future capabilities should be added as new tools and pipes, not as tightly coupled one-off code paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "3.2.2",
3
+ "version": "4.0.2",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -28,6 +28,14 @@
28
28
  ],
29
29
  "author": "Martin Clasen",
30
30
  "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/clasen/Arisa.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/clasen/Arisa/issues"
37
+ },
38
+ "homepage": "https://github.com/clasen/Arisa#readme",
31
39
  "dependencies": {
32
40
  "@mariozechner/pi-coding-agent": "^0.73.1",
33
41
  "@sinclair/typebox": "^0.34.41",
@@ -0,0 +1,79 @@
1
+ import { createPiRuntime, hasProviderAuth, supportsProviderOAuth } from "./pi-runtime.js";
2
+
3
+ const authInvalidatedPatterns = [
4
+ /authentication token has been invalidated/i,
5
+ /token (?:has been )?invalidated/i,
6
+ /try signing in again/i,
7
+ /auth(?:entication)? token (?:expired|revoked|invalid)/i
8
+ ];
9
+
10
+ const missingAuthPatterns = [
11
+ /no auth found/i,
12
+ /auth(?:entication)? .*missing/i
13
+ ];
14
+
15
+ export function getErrorMessage(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+
19
+ export function getPiAuthIssue(error) {
20
+ const message = getErrorMessage(error);
21
+ if (!message) return null;
22
+
23
+ if (authInvalidatedPatterns.some((pattern) => pattern.test(message))) {
24
+ return { kind: "invalidated-token", message };
25
+ }
26
+
27
+ if (missingAuthPatterns.some((pattern) => pattern.test(message))) {
28
+ return { kind: "missing-auth", message };
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ export function getPiAuthStatus(config) {
35
+ const runtime = createPiRuntime({
36
+ provider: config.pi.provider,
37
+ apiKey: config.pi.apiKey
38
+ });
39
+
40
+ return {
41
+ provider: config.pi.provider,
42
+ model: config.pi.model,
43
+ hasApiKey: Boolean(config.pi.apiKey),
44
+ hasStoredAuth: hasProviderAuth(config.pi.provider, runtime),
45
+ supportsOAuth: supportsProviderOAuth(config.pi.provider, runtime)
46
+ };
47
+ }
48
+
49
+ export function buildPiAuthTelegramMessage({ config, issue = null }) {
50
+ const status = getPiAuthStatus(config);
51
+ const lines = [
52
+ issue
53
+ ? `Pi authentication needs attention for ${status.provider}/${status.model}.`
54
+ : `Pi authentication status for ${status.provider}/${status.model}.`
55
+ ];
56
+
57
+ if (issue?.kind === "invalidated-token") {
58
+ lines.push("The provider says the current authentication token was invalidated.");
59
+ } else if (issue?.kind === "missing-auth") {
60
+ lines.push("Arisa could not find usable authentication for this provider.");
61
+ } else {
62
+ lines.push(`Stored auth: ${status.hasStoredAuth ? "detected" : "not detected"}.`);
63
+ }
64
+
65
+ if (issue?.message) {
66
+ lines.push(`Details: ${issue.message}`);
67
+ }
68
+
69
+ if (status.hasApiKey) {
70
+ lines.push("A Pi API key is configured, but the provider rejected the current request. Update the key and restart Arisa.");
71
+ } else if (status.supportsOAuth) {
72
+ lines.push("For now, re-run `arisa --bootstrap` on the host and complete Pi login again.");
73
+ } else {
74
+ lines.push("This provider needs a Pi API key. Re-run `arisa --bootstrap`, provide a key, and restart Arisa.");
75
+ }
76
+
77
+ lines.push("Telegram-based renewal is not wired yet, but this /auth path is ready for that flow.");
78
+ return lines.join("\n");
79
+ }
@@ -2,13 +2,11 @@ import { fileURLToPath } from "node:url";
2
2
  import { arisaHomeDir, chatsDir, stateDir, toolStateDir, toolsDir } from "../../runtime/paths.js";
3
3
 
4
4
  export const arisaInstallDir = fileURLToPath(new URL("../../..", import.meta.url));
5
- export const bundledToolsDir = fileURLToPath(new URL("../../../tools", import.meta.url));
6
5
 
7
6
  export function buildAgentRuntimeContext() {
8
7
  return [
9
8
  `arisaHomeDir: ${arisaHomeDir}`,
10
9
  `arisaInstallDir: ${arisaInstallDir}`,
11
- `bundledToolsDir: ${bundledToolsDir}`,
12
10
  `userToolsDir: ${toolsDir}`,
13
11
  `toolStateDir: ${toolStateDir}`,
14
12
  `chatsDir: ${chatsDir}`,
@@ -1,17 +1,14 @@
1
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
- import { fileURLToPath } from "node:url";
5
- import { getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
4
+ import { arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
6
5
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
7
6
  import { normalizeToolResult } from "./tool-result.js";
8
7
  import { SkillRegistry } from "../skills/skill-registry.js";
9
8
 
10
- const bundledToolsRoot = fileURLToPath(new URL("../../../tools", import.meta.url));
11
- const toolRoots = [
12
- { root: userToolsRoot, kind: "user" },
13
- { root: bundledToolsRoot, kind: "bundled" }
14
- ];
9
+ function toolEnv() {
10
+ return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir };
11
+ }
15
12
 
16
13
  function runProcess(command, args, options = {}) {
17
14
  return new Promise((resolve) => {
@@ -34,41 +31,37 @@ export class ToolRegistry {
34
31
  async load() {
35
32
  this.tools.clear();
36
33
 
37
- for (const { root, kind } of toolRoots) {
38
- let entries = [];
34
+ let entries = [];
35
+ try {
36
+ entries = await readdir(userToolsRoot, { withFileTypes: true });
37
+ } catch {
38
+ this.logger?.log("tools", `tools directory not found: ${userToolsRoot}`);
39
+ }
40
+
41
+ for (const entry of entries) {
42
+ if (!entry.isDirectory()) continue;
43
+ const toolDir = path.join(userToolsRoot, entry.name);
44
+ const manifestPath = path.join(toolDir, "tool.manifest.json");
45
+ const configPath = path.join(toolDir, "config.js");
39
46
  try {
40
- entries = await readdir(root, { withFileTypes: true });
47
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
48
+ if (this.tools.has(manifest.name)) continue;
49
+ const configSource = await readFile(configPath, "utf8");
50
+ const defaults = parseConfigModule(configSource);
51
+ const config = await loadToolConfig(manifest.name, defaults);
52
+ const skillHints = this.skillRegistry.normalizeHints(manifest);
53
+ this.tools.set(manifest.name, {
54
+ ...manifest,
55
+ skillHints,
56
+ dir: toolDir,
57
+ entry: path.join(toolDir, manifest.entry || "index.js"),
58
+ localConfigPath: configPath,
59
+ configPath: getToolConfigPath(manifest.name),
60
+ defaults,
61
+ config
62
+ });
41
63
  } catch {
42
- this.logger?.log("tools", `${kind} tools directory not found: ${root}`);
43
- continue;
44
- }
45
-
46
- for (const entry of entries) {
47
- if (!entry.isDirectory()) continue;
48
- const toolDir = path.join(root, entry.name);
49
- const manifestPath = path.join(toolDir, "tool.manifest.json");
50
- const configPath = path.join(toolDir, "config.js");
51
- try {
52
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
53
- if (this.tools.has(manifest.name)) continue;
54
- const configSource = await readFile(configPath, "utf8");
55
- const defaults = parseConfigModule(configSource);
56
- const config = await loadToolConfig(manifest.name, defaults);
57
- const skillHints = this.skillRegistry.normalizeHints(manifest);
58
- this.tools.set(manifest.name, {
59
- ...manifest,
60
- skillHints,
61
- dir: toolDir,
62
- entry: path.join(toolDir, manifest.entry || "index.js"),
63
- localConfigPath: configPath,
64
- configPath: getToolConfigPath(manifest.name),
65
- defaults,
66
- config,
67
- sourceKind: kind
68
- });
69
- } catch {
70
- // ignore invalid tool dirs in v1
71
- }
64
+ // ignore invalid tool dirs in v1
72
65
  }
73
66
  }
74
67
 
@@ -93,7 +86,7 @@ export class ToolRegistry {
93
86
  async help(name) {
94
87
  const tool = this.get(name);
95
88
  if (!tool) throw new Error(`Tool not found: ${name}`);
96
- const result = await runProcess("node", [tool.entry, "--help"], { cwd: tool.dir, env: process.env });
89
+ const result = await runProcess("node", [tool.entry, "--help"], { cwd: tool.dir, env: toolEnv() });
97
90
  const help = result.stdout || result.stderr;
98
91
  const skills = await this.resolveSkills(name);
99
92
  if (!skills.length) return help;
@@ -153,7 +146,7 @@ export class ToolRegistry {
153
146
  await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
154
147
  const result = await runProcess("node", [tool.entry, "run", "--request-file", requestFile], {
155
148
  cwd: tool.dir,
156
- env: process.env
149
+ env: toolEnv()
157
150
  });
158
151
  await unlink(requestFile).catch(() => {});
159
152
  await rmdir(tmpDir).catch(() => {});
package/src/index.js CHANGED
@@ -6,7 +6,9 @@ import { createApp } from "./runtime/create-app.js";
6
6
  import { createLogger } from "./runtime/logger.js";
7
7
  import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
8
8
  import { flushArisaHome } from "./runtime/flush.js";
9
- import { installPiPackage, removePiPackage } from "./runtime/pi-package-manager.js";
9
+ import { arisaPackageDir } from "./runtime/paths.js";
10
+
11
+ process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
10
12
 
11
13
  const args = process.argv.slice(2);
12
14
  const cli = parseCliArgs(args);
@@ -208,28 +210,6 @@ async function main() {
208
210
  return;
209
211
  }
210
212
 
211
- if (command === "install") {
212
- const source = cli.positionals[1];
213
- if (!source) {
214
- console.log("Usage: arisa install <pi-package-source>");
215
- return;
216
- }
217
- const result = await installPiPackage(source);
218
- process.exitCode = result.ok ? 0 : result.code;
219
- return;
220
- }
221
-
222
- if (command === "remove") {
223
- const source = cli.positionals[1];
224
- if (!source) {
225
- console.log("Usage: arisa remove <pi-package-source>");
226
- return;
227
- }
228
- const result = await removePiPackage(source);
229
- process.exitCode = result.ok ? 0 : result.code;
230
- return;
231
- }
232
-
233
213
  await runForeground();
234
214
  }
235
215
 
@@ -64,7 +64,12 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
64
64
  return {
65
65
  async start() {
66
66
  logger?.log("app", `validating Pi model ${config.pi.provider}/${config.pi.model}`);
67
- await agentManager.validatePiAgent();
67
+ try {
68
+ await agentManager.validatePiAgent();
69
+ } catch (error) {
70
+ await bot.notifyPiAuthIssue?.(error);
71
+ throw error;
72
+ }
68
73
  await toolProcessSupervisor.start();
69
74
  logger?.log("app", "starting Telegram bot");
70
75
  try {
@@ -1,7 +1,9 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
 
6
+ export const arisaPackageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
5
7
  export const arisaHomeDir = path.join(os.homedir(), ".arisa");
6
8
  export const stateDir = path.join(arisaHomeDir, "state");
7
9
  export const configFile = path.join(stateDir, "config.json");
@@ -3,10 +3,10 @@ import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact } from "./media.js";
5
5
  import { renderTelegramHtml } from "./text-format.js";
6
- import { withTimeout } from "../../core/agent/prompt-timeout.js";
6
+ import { buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue } from "../../core/agent/auth-flow.js";
7
7
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
8
8
 
9
- const promptTimeoutMs = 300_000;
9
+ const slowPromptNoticeMs = 300_000;
10
10
 
11
11
  function quotedMessageSummary(message) {
12
12
  if (!message) return [];
@@ -184,9 +184,11 @@ function sessionEventLogMessage(event) {
184
184
  return "";
185
185
  }
186
186
 
187
- async function collectText(session, prompt, { logger, chatId } = {}) {
187
+ async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {}) {
188
188
  let text = "";
189
+ let assistantErrorMessage = "";
189
190
  let shouldSeparateAssistantMessage = false;
191
+ let slowPromptTimer = null;
190
192
  const unsubscribe = session.subscribe((event) => {
191
193
  if (event.type === "message_start" && event.message.role === "assistant") {
192
194
  shouldSeparateAssistantMessage = text.trim().length > 0;
@@ -198,19 +200,33 @@ async function collectText(session, prompt, { logger, chatId } = {}) {
198
200
  }
199
201
  text += event.assistantMessageEvent.delta;
200
202
  }
203
+ if (event.type === "message_end" && event.message?.stopReason === "error") {
204
+ assistantErrorMessage = event.message.errorMessage || "assistant message ended with error";
205
+ }
201
206
  const logMessage = sessionEventLogMessage(event);
202
207
  if (logMessage) logger?.log("agent", `chat ${chatId} ${logMessage}`);
203
208
  });
204
209
 
210
+ if (onSlowPrompt) {
211
+ slowPromptTimer = setTimeout(() => {
212
+ logger?.log("telegram", `prompt for chat ${chatId} is still running after ${slowPromptNoticeMs}ms`);
213
+ onSlowPrompt().catch((error) => {
214
+ logger?.error("telegram", `slow prompt notice failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
215
+ });
216
+ }, slowPromptNoticeMs);
217
+ }
218
+
205
219
  try {
206
- await withTimeout(session.prompt(prompt), {
207
- timeoutMs: promptTimeoutMs,
208
- label: `Telegram prompt for chat ${chatId}`
209
- });
220
+ await session.prompt(prompt);
210
221
  } finally {
222
+ if (slowPromptTimer) clearTimeout(slowPromptTimer);
211
223
  unsubscribe();
212
224
  }
213
225
 
226
+ if (assistantErrorMessage) {
227
+ throw new Error(assistantErrorMessage);
228
+ }
229
+
214
230
  return text.trim();
215
231
  }
216
232
 
@@ -230,8 +246,31 @@ async function withTyping(ctx, work) {
230
246
  export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler }) {
231
247
  const bot = new Bot(config.telegram.token);
232
248
  const perChatState = new Map();
249
+ const notifiedPromptErrors = new WeakSet();
233
250
  let taskTimer = null;
234
251
 
252
+ function wasPromptErrorNotified(error) {
253
+ return error instanceof Error && notifiedPromptErrors.has(error);
254
+ }
255
+
256
+ function markPromptErrorNotified(error) {
257
+ if (error instanceof Error) notifiedPromptErrors.add(error);
258
+ }
259
+
260
+ async function notifyPiAuthIssueIfNeeded(chatId, error) {
261
+ const issue = getPiAuthIssue(error);
262
+ if (!issue) return false;
263
+
264
+ try {
265
+ await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, issue }));
266
+ markPromptErrorNotified(error);
267
+ return true;
268
+ } catch (notifyError) {
269
+ logger?.error("telegram", `auth issue notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
270
+ return false;
271
+ }
272
+ }
273
+
235
274
  function getIncomingChatMeta(ctx) {
236
275
  return {
237
276
  languageCode: ctx.from?.language_code || "",
@@ -303,7 +342,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
303
342
  const { session } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
304
343
  let text = "";
305
344
  try {
306
- text = await collectText(session, prompt, { logger, chatId });
345
+ text = await collectText(session, prompt, {
346
+ logger,
347
+ chatId,
348
+ onSlowPrompt: () => bot.api.sendMessage(
349
+ chatId,
350
+ "This is taking longer than 5 minutes, so I will keep the current session running instead of starting over. Send /new if you want to abandon it and start fresh."
351
+ )
352
+ });
307
353
  } catch (error) {
308
354
  agentManager.resetSession(chatId);
309
355
  throw error;
@@ -344,8 +390,9 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
344
390
  logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
345
391
  await processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
346
392
  } catch (error) {
347
- const message = error instanceof Error ? error.message : String(error);
393
+ const message = getErrorMessage(error);
348
394
  logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
395
+ await notifyPiAuthIssueIfNeeded(chatId, error);
349
396
  throw error;
350
397
  } finally {
351
398
  currentCtx = null;
@@ -479,6 +526,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
479
526
  await handleNewCommand(ctx);
480
527
  });
481
528
 
529
+ bot.command("auth", async (ctx) => {
530
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
531
+ if (!auth.ok) return;
532
+ await ctx.reply(buildPiAuthTelegramMessage({ config }));
533
+ });
534
+
482
535
  bot.on("message", async (ctx) => {
483
536
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
484
537
  if (!auth.ok) return;
@@ -491,8 +544,11 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
491
544
  } catch (error) {
492
545
  const chatState = getChatState(ctx.chat.id);
493
546
  chatState.processing = false;
494
- const message = error instanceof Error ? error.message : String(error);
495
- await ctx.reply(message);
547
+ if (wasPromptErrorNotified(error)) return;
548
+ const issue = getPiAuthIssue(error);
549
+ await ctx.reply(issue
550
+ ? buildPiAuthTelegramMessage({ config, issue })
551
+ : getErrorMessage(error));
496
552
  }
497
553
  });
498
554
 
@@ -520,7 +576,8 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
520
576
  }
521
577
  }
522
578
  await bot.api.setMyCommands([
523
- { command: "new", description: "Start a new chat context" }
579
+ { command: "new", description: "Start a new chat context" },
580
+ { command: "auth", description: "Show Pi authentication status" }
524
581
  ]);
525
582
  if (!taskTimer) {
526
583
  taskTimer = setInterval(() => {
@@ -558,6 +615,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
558
615
  try {
559
616
  bot.stop();
560
617
  } catch {}
618
+ },
619
+
620
+ async notifyPiAuthIssue(error) {
621
+ let notified = false;
622
+ for (const chatId of config.telegram.authorizedChatIds || []) {
623
+ notified = await notifyPiAuthIssueIfNeeded(chatId, error) || notified;
624
+ }
625
+ return notified;
561
626
  }
562
627
  };
563
628
  }
@@ -1,49 +0,0 @@
1
- import { DefaultPackageManager, SettingsManager } from "@mariozechner/pi-coding-agent";
2
- import { arisaHomeDir } from "./paths.js";
3
-
4
- function createPackageManager() {
5
- const settingsManager = SettingsManager.create(arisaHomeDir, arisaHomeDir);
6
- const packageManager = new DefaultPackageManager({
7
- cwd: arisaHomeDir,
8
- agentDir: arisaHomeDir,
9
- settingsManager
10
- });
11
-
12
- packageManager.setProgressCallback((event) => {
13
- if (event.type === "start") {
14
- process.stdout.write(`${event.message}\n`);
15
- }
16
- });
17
-
18
- return packageManager;
19
- }
20
-
21
- export async function installPiPackage(source) {
22
- const packageManager = createPackageManager();
23
-
24
- try {
25
- await packageManager.installAndPersist(source, { local: false });
26
- console.log(`Installed ${source}`);
27
- return { ok: true, code: 0 };
28
- } catch (error) {
29
- console.error(error instanceof Error ? error.message : String(error));
30
- return { ok: false, code: 1 };
31
- }
32
- }
33
-
34
- export async function removePiPackage(source) {
35
- const packageManager = createPackageManager();
36
-
37
- try {
38
- const removed = await packageManager.removeAndPersist(source, { local: false });
39
- if (!removed) {
40
- console.error(`No matching package found for ${source}`);
41
- return { ok: false, code: 1 };
42
- }
43
- console.log(`Removed ${source}`);
44
- return { ok: true, code: 0 };
45
- } catch (error) {
46
- console.error(error instanceof Error ? error.message : String(error));
47
- return { ok: false, code: 1 };
48
- }
49
- }
@@ -1,4 +0,0 @@
1
- export default {
2
- OPENAI_API_KEY: "",
3
- MODEL: "gpt-4o-mini-transcribe"
4
- };
@@ -1,108 +0,0 @@
1
- import path from "node:path";
2
- import { readFile, stat } from "node:fs/promises";
3
- import defaults from "./config.js";
4
- import { loadToolConfig } from "../../src/core/tools/tool-config.js";
5
- import { toolError, toolNeedsConfig, toolOk } from "../../src/core/tools/tool-result.js";
6
- import { getToolConfigPath } from "../../src/runtime/paths.js";
7
-
8
- const toolName = "openai-transcribe";
9
- const config = await loadToolConfig(toolName, defaults);
10
-
11
- const supportedUploadExtensions = new Set([
12
- ".flac",
13
- ".m4a",
14
- ".mp3",
15
- ".mp4",
16
- ".mpeg",
17
- ".mpga",
18
- ".ogg",
19
- ".wav",
20
- ".webm"
21
- ]);
22
-
23
- const mimeUploadExtensions = new Map([
24
- ["audio/aac", ".m4a"],
25
- ["audio/flac", ".flac"],
26
- ["audio/m4a", ".m4a"],
27
- ["audio/mp3", ".mp3"],
28
- ["audio/mp4", ".m4a"],
29
- ["audio/mpeg", ".mp3"],
30
- ["audio/mpga", ".mpga"],
31
- ["audio/ogg", ".ogg"],
32
- ["audio/opus", ".ogg"],
33
- ["audio/wav", ".wav"],
34
- ["audio/wave", ".wav"],
35
- ["audio/webm", ".webm"],
36
- ["audio/x-m4a", ".m4a"],
37
- ["audio/x-wav", ".wav"],
38
- ["video/mp4", ".mp4"],
39
- ["video/webm", ".webm"]
40
- ]);
41
-
42
- function baseMimeType(mimeType = "") {
43
- return mimeType.split(";")[0].trim().toLowerCase();
44
- }
45
-
46
- function uploadFileNameForArtifact(artifact) {
47
- const currentName = path.basename(artifact.path);
48
- const currentExtension = path.extname(currentName).toLowerCase();
49
- if (supportedUploadExtensions.has(currentExtension)) return currentName;
50
-
51
- const extension = mimeUploadExtensions.get(baseMimeType(artifact.mimeType));
52
- if (!extension) return currentName;
53
-
54
- const parsed = path.parse(currentName);
55
- return `${parsed.name || "audio"}${extension}`;
56
- }
57
-
58
- function printHelp() {
59
- console.log(`openai-transcribe\n\nUsage:\n node index.js --help\n node index.js run --request-file <json>\n\nExpected input:\n {\n "artifact": { "path": "/abs/media.ogg", "mimeType": "audio/ogg" },\n "args": {}\n }\n\nSupported upload formats include flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, and webm.\n\nConfig at ${getToolConfigPath(toolName)}:\n OPENAI_API_KEY\n MODEL\n`);
60
- }
61
-
62
- async function run(requestFile) {
63
- if (!config.OPENAI_API_KEY) {
64
- console.log(JSON.stringify(toolNeedsConfig({
65
- tool: toolName,
66
- missingConfig: ["OPENAI_API_KEY"],
67
- configPath: getToolConfigPath(toolName)
68
- })));
69
- return;
70
- }
71
-
72
- const request = JSON.parse(await readFile(requestFile, "utf8"));
73
- const artifact = request.artifact;
74
- if (!artifact?.path) {
75
- console.log(JSON.stringify(toolError("artifact.path is required")));
76
- return;
77
- }
78
-
79
- await stat(artifact.path);
80
- const form = new FormData();
81
- const data = await readFile(artifact.path);
82
- form.append("file", new Blob([data], { type: baseMimeType(artifact.mimeType) || "application/octet-stream" }), uploadFileNameForArtifact(artifact));
83
- form.append("model", config.MODEL);
84
-
85
- const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
86
- method: "POST",
87
- headers: { Authorization: `Bearer ${config.OPENAI_API_KEY}` },
88
- body: form
89
- });
90
-
91
- const payload = await response.json();
92
- if (!response.ok) {
93
- console.log(JSON.stringify(toolError(payload.error?.message || "OpenAI transcription failed")));
94
- return;
95
- }
96
-
97
- console.log(JSON.stringify(toolOk({ text: payload.text || "" })));
98
- }
99
-
100
- const args = process.argv.slice(2);
101
- if (!args.length || args.includes("--help") || args[0] === "help") {
102
- printHelp();
103
- } else if (args[0] === "run") {
104
- const fileIndex = args.indexOf("--request-file");
105
- await run(args[fileIndex + 1]);
106
- } else {
107
- printHelp();
108
- }
@@ -1,6 +0,0 @@
1
- {
2
- "name": "openai-transcribe-cli",
3
- "private": true,
4
- "type": "module",
5
- "version": "1.0.0"
6
- }
@@ -1,32 +0,0 @@
1
- {
2
- "name": "openai-transcribe",
3
- "description": "Transcribe audio files and video audio tracks with OpenAI audio transcription API.",
4
- "entry": "index.js",
5
- "input": [
6
- "audio/aac",
7
- "audio/flac",
8
- "audio/m4a",
9
- "audio/mp3",
10
- "audio/mp4",
11
- "audio/mpeg",
12
- "audio/mpga",
13
- "audio/ogg",
14
- "audio/opus",
15
- "audio/wav",
16
- "audio/wave",
17
- "audio/webm",
18
- "audio/x-m4a",
19
- "audio/x-wav",
20
- "video/mp4",
21
- "video/webm"
22
- ],
23
- "output": ["text/plain"],
24
- "configSchema": {
25
- "OPENAI_API_KEY": {
26
- "type": "string",
27
- "required": true,
28
- "secret": true,
29
- "prompt": "I need your OPENAI_API_KEY to transcribe audio."
30
- }
31
- }
32
- }
@@ -1,5 +0,0 @@
1
- export default {
2
- OPENAI_API_KEY: "",
3
- MODEL: "gpt-4o-mini-tts",
4
- VOICE: "alloy"
5
- };
@@ -1,76 +0,0 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import defaults from "./config.js";
4
- import { loadToolConfig } from "../../src/core/tools/tool-config.js";
5
- import { toolError, toolNeedsConfig, toolOk } from "../../src/core/tools/tool-result.js";
6
- import { getChatToolTmpDir, getToolConfigPath, getToolTmpDir } from "../../src/runtime/paths.js";
7
-
8
- const toolName = "openai-tts";
9
- const config = await loadToolConfig(toolName, defaults);
10
-
11
- function printHelp() {
12
- console.log(`openai-tts\n\nUsage:\n node index.js --help\n node index.js run --request-file <json>\n\nExpected input:\n {\n \"text\": \"hello\",\n \"artifact\": { \"text\": \"hello\" },\n \"args\": { \"voice\": \"alloy\" }\n }\n\nOutput:\n - generates OGG/Opus audio\n - suggests Telegram voice-note delivery via output.delivery.method = \"voice\"\n\nConfig at ${getToolConfigPath(toolName)}:\n OPENAI_API_KEY\n MODEL\n VOICE\n`);
13
- }
14
-
15
- async function run(requestFile) {
16
- if (!config.OPENAI_API_KEY) {
17
- console.log(JSON.stringify(toolNeedsConfig({
18
- tool: toolName,
19
- missingConfig: ["OPENAI_API_KEY"],
20
- configPath: getToolConfigPath(toolName)
21
- })));
22
- return;
23
- }
24
-
25
- const request = JSON.parse(await readFile(requestFile, "utf8"));
26
- const inputText = request.text || request.artifact?.text;
27
- if (!inputText) {
28
- console.log(JSON.stringify(toolError("text or artifact.text is required")));
29
- return;
30
- }
31
-
32
- const response = await fetch("https://api.openai.com/v1/audio/speech", {
33
- method: "POST",
34
- headers: {
35
- Authorization: `Bearer ${config.OPENAI_API_KEY}`,
36
- "Content-Type": "application/json"
37
- },
38
- body: JSON.stringify({
39
- model: config.MODEL,
40
- voice: request.args?.voice || config.VOICE,
41
- input: inputText,
42
- format: "opus"
43
- })
44
- });
45
-
46
- if (!response.ok) {
47
- const payload = await response.text();
48
- console.log(JSON.stringify(toolError(payload)));
49
- return;
50
- }
51
-
52
- const outDir = request.chatId != null
53
- ? getChatToolTmpDir(request.chatId, toolName)
54
- : getToolTmpDir(toolName);
55
- await mkdir(outDir, { recursive: true });
56
- const filePath = path.join(outDir, `speech-${Date.now()}.ogg`);
57
- const buffer = Buffer.from(await response.arrayBuffer());
58
- await writeFile(filePath, buffer);
59
- console.log(JSON.stringify(toolOk({
60
- filePath,
61
- fileName: path.basename(filePath),
62
- mimeType: "audio/ogg",
63
- kind: "audio",
64
- delivery: { method: "voice" }
65
- })));
66
- }
67
-
68
- const args = process.argv.slice(2);
69
- if (!args.length || args.includes("--help") || args[0] === "help") {
70
- printHelp();
71
- } else if (args[0] === "run") {
72
- const fileIndex = args.indexOf("--request-file");
73
- await run(args[fileIndex + 1]);
74
- } else {
75
- printHelp();
76
- }
@@ -1,6 +0,0 @@
1
- {
2
- "name": "openai-tts-cli",
3
- "private": true,
4
- "type": "module",
5
- "version": "1.0.0"
6
- }
@@ -1,20 +0,0 @@
1
- {
2
- "name": "openai-tts",
3
- "description": "Convert text into OGG/Opus speech audio using the OpenAI speech API.",
4
- "entry": "index.js",
5
- "input": ["text/plain"],
6
- "output": ["audio/ogg"],
7
- "configSchema": {
8
- "OPENAI_API_KEY": {
9
- "type": "string",
10
- "required": true,
11
- "secret": true,
12
- "prompt": "I need your OPENAI_API_KEY to generate speech audio."
13
- },
14
- "VOICE": {
15
- "type": "string",
16
- "required": false,
17
- "prompt": "Voice to use, for example alloy."
18
- }
19
- }
20
- }
@@ -1 +0,0 @@
1
- export default {};
@@ -1,68 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { toolError, toolOk } from "../../src/core/tools/tool-result.js";
3
-
4
- function printHelp() {
5
- console.log(`schedule-agent-task\n\nUsage:\n node index.js --help\n node index.js run --request-file <json>\n\nExpected input:\n {\n "text": "tell me the temperature in Toronto",\n "artifact": { "text": "tell me the temperature in Toronto" },\n "args": {\n "prompt": "tell me the temperature in Toronto",\n "runAt": "2026-04-07T14:00:00.000Z",\n "delaySeconds": "30",\n "intervalSeconds": "3600"\n }\n }\n\nBehavior:\n - schedules a future agent task for the current chat\n - provide either args.runAt or args.delaySeconds\n - optional args.intervalSeconds makes the task recurring\n`);
6
- }
7
-
8
- function firstNonEmpty(...values) {
9
- return values.find((value) => String(value || "").trim()) || "";
10
- }
11
-
12
- function buildRunAt(args = {}) {
13
- const runAtValue = firstNonEmpty(args.runAt, args.at, args.when);
14
- if (runAtValue) {
15
- const parsed = Date.parse(runAtValue);
16
- if (Number.isNaN(parsed)) return "";
17
- return new Date(parsed).toISOString();
18
- }
19
-
20
- const delaySeconds = Number(firstNonEmpty(args.delaySeconds, args.delay, args.seconds));
21
- if (Number.isFinite(delaySeconds) && delaySeconds > 0) {
22
- return new Date(Date.now() + (delaySeconds * 1000)).toISOString();
23
- }
24
-
25
- return "";
26
- }
27
-
28
- async function run(requestFile) {
29
- const request = JSON.parse(await readFile(requestFile, "utf8"));
30
- const args = request.args || {};
31
- const prompt = firstNonEmpty(args.prompt, args.message, args.task, request.text, request.artifact?.text);
32
- const runAt = buildRunAt(args);
33
- const intervalSeconds = Number(firstNonEmpty(args.intervalSeconds, args.interval, args.everySeconds));
34
-
35
- if (!prompt.trim()) {
36
- console.log(JSON.stringify(toolError("prompt/message/task, text, or artifact.text is required")));
37
- return;
38
- }
39
-
40
- if (!runAt) {
41
- console.log(JSON.stringify(toolError("args.runAt/at/when or args.delaySeconds/delay/seconds is required")));
42
- return;
43
- }
44
-
45
- const asyncTask = {
46
- kind: "agent_task",
47
- runAt,
48
- payload: { prompt },
49
- recurrence: Number.isFinite(intervalSeconds) && intervalSeconds > 0
50
- ? { type: "interval", everySeconds: intervalSeconds }
51
- : null
52
- };
53
-
54
- console.log(JSON.stringify(toolOk({ runAt }, {
55
- status: "scheduled",
56
- asyncTask
57
- })));
58
- }
59
-
60
- const args = process.argv.slice(2);
61
- if (!args.length || args.includes("--help") || args[0] === "help") {
62
- printHelp();
63
- } else if (args[0] === "run") {
64
- const fileIndex = args.indexOf("--request-file");
65
- await run(args[fileIndex + 1]);
66
- } else {
67
- printHelp();
68
- }
@@ -1,6 +0,0 @@
1
- {
2
- "name": "schedule-agent-task-cli",
3
- "private": true,
4
- "type": "module",
5
- "version": "1.0.0"
6
- }
@@ -1,8 +0,0 @@
1
- {
2
- "name": "schedule-agent-task",
3
- "description": "Schedule a future Pi Agent task for the current chat.",
4
- "entry": "index.js",
5
- "input": ["text/plain"],
6
- "output": ["application/json"],
7
- "configSchema": {}
8
- }
@@ -1 +0,0 @@
1
- export default {};
@@ -1,147 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { toolError, toolOk } from "../../src/core/tools/tool-result.js";
3
-
4
- function printHelp() {
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`);
6
- }
7
-
8
- function decodeHtml(text = "") {
9
- return text
10
- .replace(/&amp;/g, "&")
11
- .replace(/&quot;/g, '"')
12
- .replace(/&#39;/g, "'")
13
- .replace(/&lt;/g, "<")
14
- .replace(/&gt;/g, ">")
15
- .replace(/&nbsp;/g, " ");
16
- }
17
-
18
- function stripHtml(html = "") {
19
- return decodeHtml(
20
- html
21
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
22
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
23
- .replace(/<[^>]+>/g, " ")
24
- .replace(/\s+/g, " ")
25
- ).trim();
26
- }
27
-
28
- function normalizeUrl(value = "") {
29
- const text = value.trim();
30
- if (!text) return "";
31
- if (/^https?:\/\//i.test(text)) return text;
32
- if (/^[\w.-]+\.[a-z]{2,}(\/|$)/i.test(text)) return `https://${text}`;
33
- return "";
34
- }
35
-
36
- function extractActualUrl(duckUrl) {
37
- try {
38
- const parsed = new URL(duckUrl.startsWith("//") ? `https:${duckUrl}` : duckUrl);
39
- const uddg = parsed.searchParams.get("uddg");
40
- return uddg ? decodeURIComponent(uddg) : parsed.toString();
41
- } catch {
42
- return duckUrl;
43
- }
44
- }
45
-
46
- async function fetchText(url) {
47
- const response = await fetch(url, {
48
- headers: {
49
- "user-agent": "Mozilla/5.0",
50
- "accept-language": "en-US,en;q=0.9"
51
- },
52
- redirect: "follow"
53
- });
54
- return { response, text: await response.text() };
55
- }
56
-
57
- async function searchWeb(query, maxResults = 5) {
58
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
59
- const { response, text: html } = await fetchText(url);
60
- if (!response.ok) throw new Error(`Search failed with status ${response.status}`);
61
-
62
- const results = [];
63
- const blocks = html.split(/<div class="result results_links[\s\S]*?web-result ">/i).slice(1);
64
- for (const block of blocks) {
65
- if (results.length >= maxResults) break;
66
- const titleMatch = block.match(/<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
67
- if (!titleMatch) continue;
68
- const snippetMatch = block.match(/<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i);
69
- const displayUrlMatch = block.match(/<a[^>]*class="result__url"[^>]*>([\s\S]*?)<\/a>/i);
70
- results.push({
71
- title: stripHtml(titleMatch[2]),
72
- url: extractActualUrl(titleMatch[1]),
73
- snippet: stripHtml(snippetMatch?.[1] || ""),
74
- displayUrl: stripHtml(displayUrlMatch?.[1] || "")
75
- });
76
- }
77
-
78
- if (!results.length) {
79
- return `Search: ${query}\n\nNo parseable results were found.`;
80
- }
81
-
82
- return [
83
- `Search: ${query}`,
84
- "",
85
- ...results.flatMap((item, index) => [
86
- `${index + 1}. ${item.title}`,
87
- `URL: ${item.url}`,
88
- `Snippet: ${item.snippet}`,
89
- item.displayUrl ? `Displayed: ${item.displayUrl}` : null,
90
- ""
91
- ].filter(Boolean))
92
- ].join("\n").trim();
93
- }
94
-
95
- async function openWebPage(inputUrl) {
96
- const targetUrl = normalizeUrl(inputUrl);
97
- if (!targetUrl) throw new Error("A valid URL is required");
98
-
99
- const jinaUrl = `https://r.jina.ai/http://${targetUrl.replace(/^https?:\/\//i, "")}`;
100
- let body = "";
101
- let source = "jina-ai";
102
-
103
- try {
104
- const { response, text } = await fetchText(jinaUrl);
105
- if (!response.ok) throw new Error(`r.jina.ai status ${response.status}`);
106
- body = text.trim();
107
- } catch {
108
- const { response, text } = await fetchText(targetUrl);
109
- if (!response.ok) throw new Error(`Open failed with status ${response.status}`);
110
- body = stripHtml(text);
111
- source = "direct-fetch";
112
- }
113
-
114
- const shortened = body.length > 12000 ? `${body.slice(0, 12000)}\n\n[content truncated]` : body;
115
- return [`Page: ${targetUrl}`, `Source: ${source}`, "", shortened].join("\n").trim();
116
- }
117
-
118
- async function run(requestFile) {
119
- const request = JSON.parse(await readFile(requestFile, "utf8"));
120
- const rawInput = request.args?.url || request.text || request.artifact?.text || "";
121
- const mode = request.args?.mode || (normalizeUrl(rawInput) ? "open" : "search");
122
- const maxResults = Number.parseInt(request.args?.maxResults || "5", 10);
123
-
124
- if (!rawInput.trim()) {
125
- console.log(JSON.stringify(toolError("text, artifact.text, or args.url is required")));
126
- return;
127
- }
128
-
129
- try {
130
- const outputText = mode === "open"
131
- ? await openWebPage(rawInput)
132
- : await searchWeb(rawInput, Number.isFinite(maxResults) ? maxResults : 5);
133
- console.log(JSON.stringify(toolOk({ text: outputText })));
134
- } catch (error) {
135
- console.log(JSON.stringify(toolError(error.message || String(error))));
136
- }
137
- }
138
-
139
- const args = process.argv.slice(2);
140
- if (!args.length || args.includes("--help") || args[0] === "help") {
141
- printHelp();
142
- } else if (args[0] === "run") {
143
- const fileIndex = args.indexOf("--request-file");
144
- await run(args[fileIndex + 1]);
145
- } else {
146
- printHelp();
147
- }
@@ -1,6 +0,0 @@
1
- {
2
- "name": "web-browser-cli",
3
- "private": true,
4
- "type": "module",
5
- "version": "1.0.0"
6
- }
@@ -1,8 +0,0 @@
1
- {
2
- "name": "web-browser",
3
- "description": "Search the web and open web pages as readable text.",
4
- "entry": "index.js",
5
- "input": ["text/plain"],
6
- "output": ["text/plain"],
7
- "configSchema": {}
8
- }