arisa 4.0.16 → 4.0.20

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
@@ -31,7 +31,24 @@ Each tool declares in `tool.manifest.json`:
31
31
  - `output`: produced output types
32
32
  - `configSchema`: required config fields
33
33
  - `skillHints`: optional skills to apply when using or editing the tool
34
- - `web.routes`: optional HTTP routes exposed through Arisa's main server
34
+
35
+ ## Tool-to-Arisa IPC
36
+ Arisa does not mount tool-provided web routes in core. If a tool needs to expose a web UI or HTTP endpoint, the tool owns that server, usually through the shared daemon runtime. The tool's server handles its own requests and uses Arisa IPC when it needs artifacts, tasks, agent events, or runtime paths.
37
+
38
+ Installed tools can import the IPC client through `ARISA_PACKAGE_DIR`:
39
+
40
+ ```js
41
+ import path from "node:path";
42
+ import { pathToFileURL } from "node:url";
43
+
44
+ const importCore = (relativePath) => import(pathToFileURL(path.join(process.env.ARISA_PACKAGE_DIR, "src", relativePath)).href);
45
+ const { createArisaClient } = await importCore("core/tools/ipc-client.js");
46
+
47
+ const arisa = createArisaClient({ toolName: "example-tool", chatId });
48
+ await arisa.artifacts.createText({ text: "hello" });
49
+ ```
50
+
51
+ The IPC channel is a local socket under `~/.arisa/state`. Every request must include `toolName`; chat-scoped capabilities also require `chatId`. Exposed capabilities are explicit: artifacts (`createText`, `listRecent`, `get`), tasks (`add`, `list`, `cancel`), agent events (`enqueueEvent`), and runtime paths (`getChatToolStateDir`, `getToolStateDir`, `getChatToolTmpDir`, `getToolTmpDir`, `getChatArtifactsDir`). Do not add raw access to `agentManager`, `taskStore`, `artifactStore`, or `toolRegistry`.
35
52
 
36
53
  ## Conceptual pipe model
37
54
  There are two different moments where pipes can happen:
@@ -72,27 +89,6 @@ Every CLI must support (the entrypoint comes from `manifest.entry`, currently al
72
89
  - `node index.js --help`
73
90
  - `node index.js run --request-file <json>`
74
91
 
75
- ## Tool-provided web routes
76
- Tools may expose small web pages or HTTP endpoints through Arisa's main HTTP server by declaring `web.routes` in `tool.manifest.json`. See `tools/README.md` and `tools/web-hello` for the full contract and minimal example.
77
-
78
- ```json
79
- {
80
- "name": "example-tool",
81
- "web": {
82
- "routes": [
83
- {
84
- "path": "/example",
85
- "handler": "web/index.js",
86
- "methods": ["GET"],
87
- "public": false
88
- }
89
- ]
90
- }
91
- }
92
- ```
93
-
94
- Handlers run in-process and export `handleWebRequest(req, res, context)`. Routes are protected by default with `config.web.token`; use `public: true` only for intentional public GET endpoints. Keep handlers inside the installed tool directory, avoid reserved paths (`/`, `/health`, `/api*`, `/auth*`, `/telegram-*`), and persist data only through `context.paths` runtime helpers such as `getChatToolStateDir(chatId, toolName)`.
95
-
96
92
  ### Tools that need daemons
97
93
  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).
98
94
  When such a tool is built, implement it with the shared daemon runtime instead of custom ad hoc process management:
@@ -103,6 +99,7 @@ When such a tool is built, implement it with the shared daemon runtime instead o
103
99
  - keep one daemon owner per tool/session and avoid opening a second client over the same resource
104
100
  - use `beforeStart` only for tool-specific cleanup such as stale browser locks, without deleting persistent session/model data
105
101
  - keep daemon tools headless/server-safe by default when they are meant to run on VPS machines
102
+ - if the daemon exposes an HTTP server, keep that server inside the tool; Arisa core does not discover or mount tool routes
106
103
 
107
104
  ## Manual pipe behavior
108
105
  To run a pipe, the agent should:
package/README.md CHANGED
@@ -114,6 +114,7 @@ arisa start # start in background
114
114
  arisa stop # stop background service
115
115
  arisa status # show background service status
116
116
  arisa flush # remove ~/.arisa
117
+ arisa --silent # run without verbose logs
117
118
  ```
118
119
 
119
120
  Runtime model override (current process only):
@@ -138,32 +139,7 @@ On first run, Arisa will:
138
139
  6. validate that Pi Agent works
139
140
  7. only then start listening to Telegram
140
141
 
141
- ### Non-interactive bootstrap (CLI overrides)
142
-
143
- You can skip the interactive questions by providing `--telegram.token` and optional overrides:
144
-
145
- ```bash
146
- node src/index.js --telegram.token <token>
147
- ```
148
-
149
- With this mode, Arisa creates `~/.arisa/state/config.json` without prompts and applies these defaults when not provided:
150
-
151
- - `pi.provider`: `openai-codex` when available, otherwise first provider from the current Pi provider list
152
- - `pi.model`: first model after bootstrap sorting (currently prioritizes `openai-codex/gpt-5.5`)
153
- - `telegram.maxChatIds`: `1`
154
-
155
- Supported overrides:
156
-
157
- ```bash
158
- node src/index.js --telegram.token <token> --telegram.maxChatIds 3 --pi.provider openai-codex --pi.model gpt-5.5 --pi.apiKey <optional-provider-key>
159
- ```
160
-
161
- Notes:
162
-
163
- - interactive bootstrap remains unchanged when no CLI overrides are provided
164
- - `--bootstrap` can be combined with overrides to regenerate config non-interactively
165
- - Arisa's HTTP server listens on `ARISA_HTTP_PORT` (default `11970`); bootstrap OAuth pages and tool-provided web routes use this server
166
- - unknown `--pi.provider` or `--pi.model` values are ignored and replaced by safe defaults
142
+ Arisa does not run a persistent HTTP health server or Telegram webhook; Telegram uses long polling.
167
143
 
168
144
  Telegram bot tokens can be created with:
169
145
 
package/package.json CHANGED
@@ -1,17 +1,12 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.0.16",
3
+ "version": "4.0.20",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
8
  "arisa": "bin/arisa.js"
9
9
  },
10
- "scripts": {
11
- "start": "node src/index.js",
12
- "bootstrap": "node src/index.js --bootstrap",
13
- "test": "node --test"
14
- },
15
10
  "keywords": [
16
11
  "telegram",
17
12
  "pi-agent",
@@ -41,10 +36,14 @@
41
36
  "url": "https://github.com/clasen/Arisa/issues"
42
37
  },
43
38
  "homepage": "https://github.com/clasen/Arisa#readme",
44
- "packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
45
39
  "dependencies": {
46
40
  "@earendil-works/pi-coding-agent": "^0.79.9",
47
41
  "@sinclair/typebox": "^0.34.41",
48
42
  "grammy": "^1.42.0"
43
+ },
44
+ "scripts": {
45
+ "start": "node src/index.js",
46
+ "bootstrap": "node src/index.js --bootstrap",
47
+ "test": "node --test"
49
48
  }
50
- }
49
+ }
@@ -0,0 +1,94 @@
1
+ import crypto from "node:crypto";
2
+ import net from "node:net";
3
+ import { arisaIpcSocketFile } from "../../runtime/paths.js";
4
+
5
+ const DEFAULT_TIMEOUT_MS = 10_000;
6
+
7
+ function requestIpc({ socketPath, request, timeoutMs = DEFAULT_TIMEOUT_MS }) {
8
+ return new Promise((resolve, reject) => {
9
+ const socket = net.createConnection(socketPath);
10
+ let buffer = "";
11
+ let settled = false;
12
+
13
+ function finish(fn, value) {
14
+ if (settled) return;
15
+ settled = true;
16
+ clearTimeout(timeout);
17
+ socket.end();
18
+ fn(value);
19
+ }
20
+
21
+ const timeout = setTimeout(() => {
22
+ socket.destroy();
23
+ finish(reject, new Error("Arisa IPC request timed out"));
24
+ }, timeoutMs);
25
+ timeout.unref?.();
26
+
27
+ socket.setEncoding("utf8");
28
+ socket.once("connect", () => {
29
+ socket.write(`${JSON.stringify(request)}\n`);
30
+ });
31
+ socket.on("data", (chunk) => {
32
+ buffer += chunk;
33
+ const newlineIndex = buffer.indexOf("\n");
34
+ if (newlineIndex === -1) return;
35
+ const line = buffer.slice(0, newlineIndex);
36
+ try {
37
+ const response = JSON.parse(line);
38
+ if (!response.ok) {
39
+ finish(reject, new Error(response.error || "Arisa IPC request failed"));
40
+ return;
41
+ }
42
+ finish(resolve, response.result);
43
+ } catch (error) {
44
+ finish(reject, error);
45
+ }
46
+ });
47
+ socket.once("error", (error) => {
48
+ finish(reject, error);
49
+ });
50
+ socket.once("close", () => {
51
+ finish(reject, new Error("Arisa IPC connection closed before response"));
52
+ });
53
+ });
54
+ }
55
+
56
+ export function createArisaClient({ toolName, chatId = null, socketPath = process.env.ARISA_IPC_SOCKET || arisaIpcSocketFile } = {}) {
57
+ if (typeof toolName !== "string" || !toolName.trim()) {
58
+ throw new Error("toolName is required");
59
+ }
60
+
61
+ const call = (method, params = {}) => requestIpc({
62
+ socketPath,
63
+ request: {
64
+ id: crypto.randomUUID(),
65
+ method,
66
+ toolName,
67
+ chatId,
68
+ params
69
+ }
70
+ });
71
+
72
+ return {
73
+ artifacts: {
74
+ createText: (params) => call("artifacts.createText", params),
75
+ listRecent: (params) => call("artifacts.listRecent", params),
76
+ get: (params) => call("artifacts.get", params)
77
+ },
78
+ tasks: {
79
+ add: (params) => call("tasks.add", params),
80
+ list: (params) => call("tasks.list", params),
81
+ cancel: (params) => call("tasks.cancel", params)
82
+ },
83
+ agent: {
84
+ enqueueEvent: (params) => call("agent.enqueueEvent", params)
85
+ },
86
+ paths: {
87
+ getChatToolStateDir: () => call("paths.getChatToolStateDir"),
88
+ getToolStateDir: () => call("paths.getToolStateDir"),
89
+ getChatToolTmpDir: () => call("paths.getChatToolTmpDir"),
90
+ getToolTmpDir: () => call("paths.getToolTmpDir"),
91
+ getChatArtifactsDir: () => call("paths.getChatArtifactsDir")
92
+ }
93
+ };
94
+ }
@@ -1,14 +1,13 @@
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 { arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
- import { validateToolWebRoutes } from "../../runtime/web/route-validation.js";
4
+ import { arisaIpcSocketFile, 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
9
  function toolEnv() {
11
- return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir };
10
+ return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
12
11
  }
13
12
 
14
13
  function runProcess(command, args, options = {}) {
@@ -26,14 +25,11 @@ export class ToolRegistry {
26
25
  constructor({ logger } = {}) {
27
26
  this.logger = logger;
28
27
  this.tools = new Map();
29
- this.webRoutes = [];
30
28
  this.skillRegistry = new SkillRegistry();
31
29
  }
32
30
 
33
31
  async load() {
34
32
  this.tools.clear();
35
- this.webRoutes = [];
36
- const claimedWebPaths = new Map();
37
33
 
38
34
  let entries = [];
39
35
  try {
@@ -54,7 +50,7 @@ export class ToolRegistry {
54
50
  const defaults = parseConfigModule(configSource);
55
51
  const config = await loadToolConfig(manifest.name, defaults);
56
52
  const skillHints = this.skillRegistry.normalizeHints(manifest);
57
- const tool = {
53
+ this.tools.set(manifest.name, {
58
54
  ...manifest,
59
55
  skillHints,
60
56
  dir: toolDir,
@@ -63,13 +59,7 @@ export class ToolRegistry {
63
59
  configPath: getToolConfigPath(manifest.name),
64
60
  defaults,
65
61
  config
66
- };
67
- this.tools.set(manifest.name, tool);
68
- const { routes, errors } = validateToolWebRoutes(manifest, toolDir, claimedWebPaths);
69
- this.webRoutes.push(...routes);
70
- for (const error of errors) {
71
- this.logger?.log("web", `skipping route for ${manifest.name}: ${error.error}`);
72
- }
62
+ });
73
63
  } catch {
74
64
  // ignore invalid tool dirs in v1
75
65
  }
@@ -89,13 +79,6 @@ export class ToolRegistry {
89
79
  }));
90
80
  }
91
81
 
92
- listWebRoutes() {
93
- return this.webRoutes.map((route) => ({
94
- ...route,
95
- methods: [...route.methods]
96
- }));
97
- }
98
-
99
82
  get(name) {
100
83
  return this.tools.get(name) || null;
101
84
  }
package/src/index.js CHANGED
@@ -1,14 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createServer } from "node:http";
4
- import { execFile } from "node:child_process";
5
3
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
6
4
  import { createApp } from "./runtime/create-app.js";
7
5
  import { createLogger } from "./runtime/logger.js";
8
6
  import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
9
7
  import { flushArisaHome } from "./runtime/flush.js";
10
8
  import { arisaPackageDir } from "./runtime/paths.js";
11
- import { parseProcessIds } from "./runtime/process-ids.js";
12
9
 
13
10
  process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
14
11
 
@@ -16,136 +13,13 @@ const args = process.argv.slice(2);
16
13
  const cli = parseCliArgs(args);
17
14
  const command = cli.positionals[0] || "run";
18
15
  const forceBootstrap = Boolean(cli.flags.bootstrap);
19
- const verbose = Boolean(cli.flags.verbose);
16
+ const verbose = !cli.flags.silent;
20
17
  const serviceRunner = Boolean(cli.flags["service-runner"]);
21
- const bootstrapOverrides = toBootstrapOverrides(cli.nestedFlags);
22
- const runtimeOverrides = toRuntimeOverrides(cli.nestedFlags);
18
+ const runtimeOverrides = toNestedOverrides(cli.nestedFlags);
23
19
  const logger = createLogger({ verbose });
24
20
  let activeApp = null;
25
21
  let shuttingDown = false;
26
22
 
27
- const defaultHttpPort = 11970;
28
- const httpPort = Number(process.env.ARISA_HTTP_PORT || defaultHttpPort);
29
- const shouldStartHttpServer = Boolean(httpPort && !["stop", "status", "flush"].includes(command));
30
- let httpRequestHandler = null;
31
- let httpServerListening = false;
32
- let httpServer = null;
33
-
34
- function setHttpRequestHandler(handler) {
35
- httpRequestHandler = handler;
36
- }
37
-
38
- const httpServerReady = shouldStartHttpServer
39
- ? startHttpServer()
40
- : Promise.resolve(false);
41
-
42
- async function runCommand(commandName, args = []) {
43
- return new Promise((resolve) => {
44
- execFile(commandName, args, (error, stdout = "") => {
45
- resolve(error ? "" : stdout.trim());
46
- });
47
- });
48
- }
49
-
50
- async function getListeningPids(port) {
51
- const lsofOutput = await runCommand("lsof", ["-tiTCP", `-sTCP:LISTEN`, `-iTCP:${port}`]);
52
- if (lsofOutput) {
53
- return parseProcessIds(lsofOutput);
54
- }
55
-
56
- const fuserOutput = await runCommand("fuser", ["-n", "tcp", String(port)]);
57
- return parseProcessIds(fuserOutput);
58
- }
59
-
60
- async function getProcessCommand(pid) {
61
- return runCommand("ps", ["-p", String(pid), "-o", "command="]);
62
- }
63
-
64
- function looksLikeArisaProcess(commandText) {
65
- return /\barisa\b/.test(commandText) || /\/arisa\/src\/index\.js\b/i.test(commandText);
66
- }
67
-
68
- function isProcessRunning(pid) {
69
- try {
70
- process.kill(pid, 0);
71
- return true;
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- async function waitForProcessExit(pid, timeoutMs = 3000) {
78
- const deadline = Date.now() + timeoutMs;
79
- while (Date.now() < deadline) {
80
- if (!isProcessRunning(pid)) return true;
81
- await new Promise((resolve) => setTimeout(resolve, 100));
82
- }
83
- return !isProcessRunning(pid);
84
- }
85
-
86
- async function stopStaleArisaListeners(port) {
87
- const pids = await getListeningPids(port);
88
- for (const pid of pids) {
89
- if (pid === process.pid) continue;
90
- const commandText = await getProcessCommand(pid);
91
- if (!looksLikeArisaProcess(commandText)) {
92
- logger.error("http", `port ${port} is already used by pid ${pid}; not killing non-Arisa process`);
93
- continue;
94
- }
95
-
96
- logger.log("http", `stopping stale Arisa listener on port ${port} (pid ${pid})`);
97
- try {
98
- process.kill(pid, "SIGTERM");
99
- if (!await waitForProcessExit(pid)) {
100
- process.kill(pid, "SIGKILL");
101
- await waitForProcessExit(pid, 1000);
102
- }
103
- } catch (error) {
104
- const message = error instanceof Error ? error.message : String(error);
105
- logger.error("http", `failed to stop stale Arisa listener pid ${pid}: ${message}`);
106
- }
107
- }
108
- }
109
-
110
- async function startHttpServer() {
111
- await stopStaleArisaListeners(httpPort);
112
- return new Promise((resolve) => {
113
- const server = createServer((req, res) => {
114
- if (httpRequestHandler) return httpRequestHandler(req, res);
115
- res.writeHead(200, { "Content-Type": "text/plain" });
116
- res.end("ok");
117
- return undefined;
118
- });
119
- httpServer = server;
120
- server.on("error", (error) => {
121
- const message = error instanceof Error ? error.message : String(error);
122
- logger.error("http", `server failed on port ${httpPort}: ${message}`);
123
- resolve(false);
124
- });
125
- server.listen(httpPort)
126
- .on("listening", () => {
127
- httpServerListening = true;
128
- logger.log("http", `health server on port ${httpPort}`);
129
- resolve(true);
130
- });
131
- });
132
- }
133
-
134
- async function getHttpOptions() {
135
- await httpServerReady;
136
- return httpServerListening ? { httpPort, setHttpRequestHandler } : {};
137
- }
138
-
139
- async function stopHttpServer() {
140
- if (!httpServerListening || !httpServer) return;
141
- await new Promise((resolve) => {
142
- httpServer.close(() => resolve());
143
- });
144
- httpServer = null;
145
- httpServerListening = false;
146
- httpRequestHandler = null;
147
- }
148
-
149
23
  function parseCliArgs(rawArgs) {
150
24
  const flags = {};
151
25
  const nestedFlags = {};
@@ -176,7 +50,7 @@ function parseCliArgs(rawArgs) {
176
50
  return { flags, nestedFlags, positionals };
177
51
  }
178
52
 
179
- function toBootstrapOverrides(nestedFlags) {
53
+ function toNestedOverrides(nestedFlags) {
180
54
  const overrides = {};
181
55
  for (const [flatKey, value] of Object.entries(nestedFlags)) {
182
56
  const parts = flatKey.split(".");
@@ -193,10 +67,6 @@ function toBootstrapOverrides(nestedFlags) {
193
67
  return overrides;
194
68
  }
195
69
 
196
- function toRuntimeOverrides(nestedFlags) {
197
- return toBootstrapOverrides(nestedFlags);
198
- }
199
-
200
70
  function toServiceRunnerArgs(nestedFlags) {
201
71
  const args = [];
202
72
  if (nestedFlags["pi.model"]) {
@@ -205,14 +75,11 @@ function toServiceRunnerArgs(nestedFlags) {
205
75
  return args;
206
76
  }
207
77
 
208
- const webhookUrl = bootstrapOverrides.webhook?.url || "";
209
-
210
78
  async function shutdown(exitCode = 0) {
211
79
  if (shuttingDown) return;
212
80
  shuttingDown = true;
213
81
  try {
214
82
  await activeApp?.stop?.();
215
- await stopHttpServer();
216
83
  } catch (error) {
217
84
  logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
218
85
  exitCode = exitCode || 1;
@@ -231,8 +98,8 @@ process.once("SIGINT", () => {
231
98
  shutdown(0);
232
99
  });
233
100
 
234
- async function startRuntimeApp(httpOptions = {}) {
235
- const app = await createApp({ logger, runtimeOverrides, webhookUrl, ...httpOptions });
101
+ async function startRuntimeApp() {
102
+ const app = await createApp({ logger, runtimeOverrides });
236
103
  activeApp = app;
237
104
  await app.start();
238
105
  }
@@ -244,10 +111,9 @@ async function runForeground() {
244
111
  || runtimeOverrides?.pi?.apiKey
245
112
  );
246
113
  logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
247
- const httpOptions = await getHttpOptions();
248
- await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
114
+ await bootstrapIfNeeded({ force: forceBootstrap });
249
115
  try {
250
- await startRuntimeApp(httpOptions);
116
+ await startRuntimeApp();
251
117
  } catch (error) {
252
118
  const message = error instanceof Error ? error.message : String(error);
253
119
  if (message.includes("No auth found")) {
@@ -260,8 +126,8 @@ async function runForeground() {
260
126
  throw error;
261
127
  }
262
128
  console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
263
- await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
264
- await startRuntimeApp(httpOptions);
129
+ await bootstrapIfNeeded({ force: true });
130
+ await startRuntimeApp();
265
131
  return;
266
132
  }
267
133
  throw error;
@@ -276,9 +142,7 @@ async function main() {
276
142
  }
277
143
 
278
144
  if (command === "start") {
279
- const httpOptions = await getHttpOptions();
280
- await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...httpOptions });
281
- await stopHttpServer();
145
+ await bootstrapIfNeeded({ force: forceBootstrap });
282
146
  const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
283
147
  if (!result.ok) {
284
148
  console.log(`Arisa is already running in background (pid ${result.pid}).`);
@@ -0,0 +1,123 @@
1
+ import {
2
+ getChatArtifactsDir,
3
+ getChatToolStateDir,
4
+ getChatToolTmpDir,
5
+ getToolStateDir,
6
+ getToolTmpDir
7
+ } from "./paths.js";
8
+
9
+ function requireToolName(toolName) {
10
+ if (typeof toolName !== "string" || !toolName.trim()) {
11
+ throw new Error("toolName is required");
12
+ }
13
+ return toolName.trim();
14
+ }
15
+
16
+ function requireChatId(chatId, method) {
17
+ if (chatId == null || chatId === "") {
18
+ throw new Error(`${method} requires chatId`);
19
+ }
20
+ return chatId;
21
+ }
22
+
23
+ function requireString(value, fieldName) {
24
+ if (typeof value !== "string" || !value.trim()) {
25
+ throw new Error(`${fieldName} is required`);
26
+ }
27
+ return value;
28
+ }
29
+
30
+ function normalizeLimit(limit) {
31
+ const value = Number(limit);
32
+ if (!Number.isInteger(value) || value <= 0) return 20;
33
+ return Math.min(value, 100);
34
+ }
35
+
36
+ export function createArisaCapabilities({ artifactStore, taskStore } = {}) {
37
+ async function dispatch({ method, toolName, chatId = null, params = {} } = {}) {
38
+ const scopedToolName = requireToolName(toolName);
39
+
40
+ if (method === "artifacts.createText") {
41
+ const scopedChatId = requireChatId(chatId, method);
42
+ return artifactStore.forChat(scopedChatId).createText({
43
+ text: requireString(params.text, "text"),
44
+ mimeType: params.mimeType || "text/plain",
45
+ source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId },
46
+ metadata: params.metadata || {}
47
+ });
48
+ }
49
+
50
+ if (method === "artifacts.listRecent") {
51
+ const scopedChatId = requireChatId(chatId, method);
52
+ return artifactStore.forChat(scopedChatId).listRecent(normalizeLimit(params.limit));
53
+ }
54
+
55
+ if (method === "artifacts.get") {
56
+ const scopedChatId = requireChatId(chatId, method);
57
+ return artifactStore.forChat(scopedChatId).get(requireString(params.artifactId, "artifactId"));
58
+ }
59
+
60
+ if (method === "tasks.add") {
61
+ const scopedChatId = requireChatId(chatId, method);
62
+ return taskStore.add(params.task || {}, {
63
+ payload: { chatId: scopedChatId },
64
+ source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId }
65
+ });
66
+ }
67
+
68
+ if (method === "tasks.list") {
69
+ const scopedChatId = requireChatId(chatId, method);
70
+ return taskStore.list({
71
+ chatId: scopedChatId,
72
+ status: params.status || undefined,
73
+ kind: params.kind || undefined
74
+ });
75
+ }
76
+
77
+ if (method === "tasks.cancel") {
78
+ const scopedChatId = requireChatId(chatId, method);
79
+ const taskId = requireString(params.taskId, "taskId");
80
+ const task = await taskStore.get(taskId);
81
+ if (!task) return null;
82
+ if (String(task.payload?.chatId) !== String(scopedChatId)) {
83
+ throw new Error("task does not belong to chatId");
84
+ }
85
+ return taskStore.cancel(taskId);
86
+ }
87
+
88
+ if (method === "agent.enqueueEvent") {
89
+ const scopedChatId = requireChatId(chatId, method);
90
+ return taskStore.add({
91
+ kind: "agent_event",
92
+ payload: { prompt: requireString(params.prompt, "prompt") }
93
+ }, {
94
+ payload: { chatId: scopedChatId },
95
+ source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId }
96
+ });
97
+ }
98
+
99
+ if (method === "paths.getChatToolStateDir") {
100
+ return getChatToolStateDir(requireChatId(chatId, method), scopedToolName);
101
+ }
102
+
103
+ if (method === "paths.getToolStateDir") {
104
+ return getToolStateDir(scopedToolName);
105
+ }
106
+
107
+ if (method === "paths.getChatToolTmpDir") {
108
+ return getChatToolTmpDir(requireChatId(chatId, method), scopedToolName);
109
+ }
110
+
111
+ if (method === "paths.getToolTmpDir") {
112
+ return getToolTmpDir(scopedToolName);
113
+ }
114
+
115
+ if (method === "paths.getChatArtifactsDir") {
116
+ return getChatArtifactsDir(requireChatId(chatId, method));
117
+ }
118
+
119
+ throw new Error(`unknown IPC method: ${method}`);
120
+ }
121
+
122
+ return { dispatch };
123
+ }