arisa 4.0.16 → 4.0.18
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 +19 -22
- package/README.md +1 -1
- package/package.json +7 -8
- package/src/core/tools/ipc-client.js +94 -0
- package/src/core/tools/tool-registry.js +4 -21
- package/src/runtime/arisa-capabilities.js +123 -0
- package/src/runtime/create-app.js +15 -62
- package/src/runtime/ipc/ipc-server.js +109 -0
- package/src/runtime/paths.js +1 -0
- package/test/ipc-server.test.js +154 -0
- package/src/runtime/web/route-validation.js +0 -115
- package/src/runtime/web/web-router.js +0 -167
- package/test/route-validation.test.js +0 -88
- package/test/web-router.test.js +0 -267
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
|
-
|
|
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
|
@@ -162,7 +162,7 @@ Notes:
|
|
|
162
162
|
|
|
163
163
|
- interactive bootstrap remains unchanged when no CLI overrides are provided
|
|
164
164
|
- `--bootstrap` can be combined with overrides to regenerate config non-interactively
|
|
165
|
-
-
|
|
165
|
+
- when `--pi.apiKey` is omitted and the provider supports OAuth, bootstrap can use a temporary auth relay if `PORT` is set; this relay is only for Pi login and is not a tool web route server
|
|
166
166
|
- unknown `--pi.provider` or `--pi.model` values are ignored and replaced by safe defaults
|
|
167
167
|
|
|
168
168
|
Telegram bot tokens can be created with:
|
package/package.json
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arisa",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.18",
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
1
|
import { loadConfig, saveConfig, updateConfig } from "../core/config/config-store.js";
|
|
3
2
|
import { ArtifactStore } from "../core/artifacts/artifact-store.js";
|
|
4
3
|
import { ToolRegistry } from "../core/tools/tool-registry.js";
|
|
@@ -7,14 +6,8 @@ import { AgentManager } from "../core/agent/agent-manager.js";
|
|
|
7
6
|
import { getErrorMessage, getPiAuthIssue } from "../core/agent/auth-flow.js";
|
|
8
7
|
import { createTelegramBot } from "../transport/telegram/bot.js";
|
|
9
8
|
import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
getChatArtifactsDir,
|
|
13
|
-
getChatToolStateDir,
|
|
14
|
-
getChatToolTmpDir,
|
|
15
|
-
getToolStateDir,
|
|
16
|
-
getToolTmpDir
|
|
17
|
-
} from "./paths.js";
|
|
9
|
+
import { createArisaCapabilities } from "./arisa-capabilities.js";
|
|
10
|
+
import { createIpcServer } from "./ipc/ipc-server.js";
|
|
18
11
|
|
|
19
12
|
function normalizeString(value) {
|
|
20
13
|
const text = String(value ?? "").trim();
|
|
@@ -53,29 +46,6 @@ function applyRuntimeOverrides(config, runtimeOverrides) {
|
|
|
53
46
|
};
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
async function ensureWebToken(config, logger) {
|
|
57
|
-
if (config.web?.token) return config.web.token;
|
|
58
|
-
|
|
59
|
-
const generatedToken = crypto.randomBytes(24).toString("hex");
|
|
60
|
-
const persisted = await updateConfig((storedConfig) => {
|
|
61
|
-
storedConfig.web ||= {};
|
|
62
|
-
storedConfig.web.token ||= generatedToken;
|
|
63
|
-
});
|
|
64
|
-
config.web = { ...(config.web || {}), token: persisted.web.token };
|
|
65
|
-
logger?.log("web", "generated shared web route token");
|
|
66
|
-
return config.web.token;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function createToolWebPaths() {
|
|
70
|
-
return {
|
|
71
|
-
getChatArtifactsDir,
|
|
72
|
-
getChatToolStateDir,
|
|
73
|
-
getChatToolTmpDir,
|
|
74
|
-
getToolStateDir,
|
|
75
|
-
getToolTmpDir
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
49
|
export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpRequestHandler } = {}) {
|
|
80
50
|
logger?.log("app", "loading config");
|
|
81
51
|
const persistedConfig = await loadConfig();
|
|
@@ -92,33 +62,9 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
|
|
|
92
62
|
logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
|
|
93
63
|
|
|
94
64
|
const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
getToken: () => config.web?.token || "",
|
|
99
|
-
logger,
|
|
100
|
-
buildContext: ({ toolName, chatId }) => ({
|
|
101
|
-
toolName,
|
|
102
|
-
chatId,
|
|
103
|
-
logger,
|
|
104
|
-
toolRegistry,
|
|
105
|
-
artifactStore,
|
|
106
|
-
taskStore,
|
|
107
|
-
agentManager,
|
|
108
|
-
paths: createToolWebPaths()
|
|
109
|
-
})
|
|
110
|
-
});
|
|
111
|
-
webRouter.registerCoreRoute({
|
|
112
|
-
method: "GET",
|
|
113
|
-
path: "/health",
|
|
114
|
-
handler: (_req, res) => {
|
|
115
|
-
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
116
|
-
res.end("ok\n");
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
setHttpRequestHandler?.(webRouter.dispatch);
|
|
120
|
-
|
|
121
|
-
const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, webRouter: setHttpRequestHandler ? webRouter : null });
|
|
65
|
+
const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore });
|
|
66
|
+
const ipcServer = createIpcServer({ capabilities: arisaCapabilities, logger });
|
|
67
|
+
const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler });
|
|
122
68
|
|
|
123
69
|
return {
|
|
124
70
|
async start() {
|
|
@@ -135,12 +81,18 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
|
|
|
135
81
|
logger?.error("app", `Pi auth validation failed; starting Telegram in auth recovery mode: ${getErrorMessage(error)}`);
|
|
136
82
|
await bot.notifyPiAuthIssue?.(error);
|
|
137
83
|
}
|
|
138
|
-
|
|
139
|
-
|
|
84
|
+
let ipcStarted = false;
|
|
85
|
+
let supervisorStarted = false;
|
|
140
86
|
try {
|
|
87
|
+
await ipcServer.start();
|
|
88
|
+
ipcStarted = true;
|
|
89
|
+
await toolProcessSupervisor.start();
|
|
90
|
+
supervisorStarted = true;
|
|
91
|
+
logger?.log("app", "starting Telegram bot");
|
|
141
92
|
await bot.start({ skipAgentStartupPrompts });
|
|
142
93
|
} catch (error) {
|
|
143
|
-
await toolProcessSupervisor.stop();
|
|
94
|
+
if (supervisorStarted) await toolProcessSupervisor.stop();
|
|
95
|
+
if (ipcStarted) await ipcServer.stop();
|
|
144
96
|
throw error;
|
|
145
97
|
}
|
|
146
98
|
},
|
|
@@ -148,6 +100,7 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
|
|
|
148
100
|
async stop() {
|
|
149
101
|
await bot.stop?.();
|
|
150
102
|
await toolProcessSupervisor.stop();
|
|
103
|
+
await ipcServer.stop();
|
|
151
104
|
}
|
|
152
105
|
};
|
|
153
106
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { chmod, mkdir, unlink } from "node:fs/promises";
|
|
4
|
+
import { arisaIpcSocketFile } from "../paths.js";
|
|
5
|
+
|
|
6
|
+
function writeResponse(socket, response) {
|
|
7
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isStaleSocketError(error) {
|
|
11
|
+
return ["ENOENT", "ECONNREFUSED"].includes(error?.code);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function hasLiveSocket(socketPath) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const client = net.createConnection(socketPath);
|
|
17
|
+
client.once("connect", () => {
|
|
18
|
+
client.end();
|
|
19
|
+
resolve(true);
|
|
20
|
+
});
|
|
21
|
+
client.once("error", (error) => {
|
|
22
|
+
if (isStaleSocketError(error)) {
|
|
23
|
+
resolve(false);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
reject(error);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createIpcServer({ capabilities, socketPath = arisaIpcSocketFile, logger } = {}) {
|
|
32
|
+
let server = null;
|
|
33
|
+
|
|
34
|
+
async function handleLine(socket, line) {
|
|
35
|
+
let request;
|
|
36
|
+
try {
|
|
37
|
+
request = JSON.parse(line);
|
|
38
|
+
} catch {
|
|
39
|
+
writeResponse(socket, { id: null, ok: false, error: "invalid JSON request" });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await capabilities.dispatch(request);
|
|
45
|
+
writeResponse(socket, { id: request.id ?? null, ok: true, result });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
logger?.log?.("ipc", `request failed: ${message}`);
|
|
49
|
+
writeResponse(socket, { id: request.id ?? null, ok: false, error: message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function start() {
|
|
54
|
+
if (server) return { socketPath };
|
|
55
|
+
await mkdir(path.dirname(socketPath), { recursive: true });
|
|
56
|
+
|
|
57
|
+
if (await hasLiveSocket(socketPath)) {
|
|
58
|
+
throw new Error(`Arisa IPC socket already in use: ${socketPath}`);
|
|
59
|
+
}
|
|
60
|
+
await unlink(socketPath).catch((error) => {
|
|
61
|
+
if (error?.code !== "ENOENT") throw error;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
server = net.createServer((socket) => {
|
|
65
|
+
socket.setEncoding("utf8");
|
|
66
|
+
let buffer = "";
|
|
67
|
+
socket.on("data", (chunk) => {
|
|
68
|
+
buffer += chunk;
|
|
69
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
70
|
+
while (newlineIndex !== -1) {
|
|
71
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
72
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
73
|
+
if (line) handleLine(socket, line);
|
|
74
|
+
newlineIndex = buffer.indexOf("\n");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
socket.on("error", (error) => {
|
|
78
|
+
logger?.log?.("ipc", `client socket error: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
server.once("error", reject);
|
|
84
|
+
server.listen(socketPath, resolve);
|
|
85
|
+
});
|
|
86
|
+
await chmod(socketPath, 0o600).catch(() => {});
|
|
87
|
+
logger?.log?.("ipc", `listening on ${socketPath}`);
|
|
88
|
+
return { socketPath };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function stop() {
|
|
92
|
+
if (!server) return;
|
|
93
|
+
const closingServer = server;
|
|
94
|
+
server = null;
|
|
95
|
+
await new Promise((resolve) => closingServer.close(resolve));
|
|
96
|
+
await unlink(socketPath).catch((error) => {
|
|
97
|
+
if (error?.code !== "ENOENT") throw error;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
start,
|
|
103
|
+
stop,
|
|
104
|
+
address: () => server?.address() || null,
|
|
105
|
+
get socketPath() {
|
|
106
|
+
return socketPath;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
package/src/runtime/paths.js
CHANGED
|
@@ -9,6 +9,7 @@ export const stateDir = path.join(arisaHomeDir, "state");
|
|
|
9
9
|
export const configFile = path.join(stateDir, "config.json");
|
|
10
10
|
export const servicePidFile = path.join(stateDir, "arisa.pid");
|
|
11
11
|
export const serviceLogFile = path.join(stateDir, "arisa.log");
|
|
12
|
+
export const arisaIpcSocketFile = path.join(stateDir, "arisa.sock");
|
|
12
13
|
export const tasksFile = path.join(stateDir, "tasks.json");
|
|
13
14
|
export const toolsDir = path.join(arisaHomeDir, "tools");
|
|
14
15
|
export const chatsDir = path.join(arisaHomeDir, "chats");
|