arisa 5.1.2 → 5.1.8
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 +12 -4
- package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
- package/README.md +25 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +55 -12
- package/src/core/config/config-defaults.js +3 -1
- package/src/core/tools/daemon-processes.js +48 -6
- package/src/core/tools/daemon-runtime.js +370 -138
- package/src/core/tools/ipc-client.js +3 -0
- package/src/core/tools/official-tool-catalog.js +32 -0
- package/src/core/tools/official-tool-installer.js +183 -0
- package/src/core/tools/tool-registry.js +203 -18
- package/src/core/tools/tool-resource-note-store.js +78 -0
- package/src/index.js +25 -2
- package/src/official-tools.lock.json +40 -0
- package/src/runtime/arisa-capabilities.js +43 -3
- package/src/runtime/create-app.js +9 -0
- package/src/runtime/create-headless-app.js +77 -0
- package/src/runtime/doctor.js +27 -2
- package/src/runtime/headless-tool-executor.js +45 -0
- package/src/runtime/paths.js +16 -4
- package/src/runtime/secure-request-file.js +21 -0
- package/src/runtime/slave-bootstrap-url.js +51 -0
- package/src/runtime/slave-cli.js +267 -0
- package/src/runtime/slave-service.js +225 -0
- package/src/runtime/tool-usage-report.js +11 -3
- package/src/transport/telegram/bot.js +37 -7
- package/test/capabilities-security.test.js +29 -0
- package/test/daemon-catalog-conformance.test.js +3 -1
- package/test/daemon-runtime.test.js +58 -2
- package/test/official-tool-installer.test.js +107 -0
- package/test/paths.test.js +6 -12
- package/test/slave-cli.test.js +282 -0
- package/test/telegram-text-artifact.test.js +24 -1
- package/test/tool-capability-search.test.js +55 -0
- package/test/tool-registry-run.test.js +70 -1
- package/test/tool-resource-note.test.js +50 -0
- package/test/tool-usage.test.js +10 -5
- package/test-fixtures/fake-daemon.js +12 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
cp,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
readFile,
|
|
10
|
+
readdir,
|
|
11
|
+
rename,
|
|
12
|
+
rm
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { getToolDir } from "../../runtime/paths.js";
|
|
17
|
+
|
|
18
|
+
const bundledLockFile = new URL("../../official-tools.lock.json", import.meta.url);
|
|
19
|
+
|
|
20
|
+
const TOOL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
21
|
+
const COMMIT_PATTERN = /^[a-f0-9]{40}$/;
|
|
22
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
23
|
+
|
|
24
|
+
function exists(target) {
|
|
25
|
+
return access(target).then(() => true, () => false);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertRelativeFilePath(file) {
|
|
29
|
+
if (typeof file !== "string" || !file || file.includes("\\")) {
|
|
30
|
+
throw new Error(`Invalid official tool file path: ${file || "empty"}`);
|
|
31
|
+
}
|
|
32
|
+
const normalized = path.posix.normalize(file);
|
|
33
|
+
if (normalized !== file || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
|
|
34
|
+
throw new Error(`Invalid official tool file path: ${file}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateOfficialToolLock(lock, toolName) {
|
|
39
|
+
if (!TOOL_NAME_PATTERN.test(String(toolName || ""))) {
|
|
40
|
+
throw new Error(`Invalid official tool name: ${toolName || "empty"}`);
|
|
41
|
+
}
|
|
42
|
+
if (lock?.version !== 1) throw new Error("Unsupported official tool lock version");
|
|
43
|
+
if (typeof lock.repository !== "string" || !lock.repository.startsWith("https://")) {
|
|
44
|
+
throw new Error("Official tool lock requires an HTTPS repository");
|
|
45
|
+
}
|
|
46
|
+
if (!COMMIT_PATTERN.test(String(lock.commit || ""))) {
|
|
47
|
+
throw new Error("Official tool lock requires an immutable 40-character commit");
|
|
48
|
+
}
|
|
49
|
+
const files = lock.tools?.[toolName]?.files;
|
|
50
|
+
if (!files || typeof files !== "object" || Array.isArray(files) || !Object.keys(files).length) {
|
|
51
|
+
throw new Error(`Official tool lock has no files for ${toolName}`);
|
|
52
|
+
}
|
|
53
|
+
for (const [file, digest] of Object.entries(files)) {
|
|
54
|
+
assertRelativeFilePath(file);
|
|
55
|
+
if (!SHA256_PATTERN.test(String(digest || ""))) {
|
|
56
|
+
throw new Error(`Invalid SHA-256 digest for ${toolName}/${file}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { repository: lock.repository, commit: lock.commit, files };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function walkFiles(root, relative = "") {
|
|
63
|
+
const directory = path.join(root, relative);
|
|
64
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
65
|
+
const files = [];
|
|
66
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
67
|
+
const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
68
|
+
const childPath = path.join(root, ...childRelative.split("/"));
|
|
69
|
+
const metadata = await lstat(childPath);
|
|
70
|
+
if (metadata.isSymbolicLink()) {
|
|
71
|
+
throw new Error(`Official tool contains a symbolic link: ${childRelative}`);
|
|
72
|
+
}
|
|
73
|
+
if (metadata.isDirectory()) {
|
|
74
|
+
files.push(...await walkFiles(root, childRelative));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!metadata.isFile()) {
|
|
78
|
+
throw new Error(`Official tool contains an unsupported entry: ${childRelative}`);
|
|
79
|
+
}
|
|
80
|
+
files.push(childRelative);
|
|
81
|
+
}
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function sha256(file) {
|
|
86
|
+
return crypto.createHash("sha256").update(await readFile(file)).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function verifyOfficialToolTree(toolDir, expectedFiles) {
|
|
90
|
+
const actualFiles = (await walkFiles(toolDir)).sort();
|
|
91
|
+
const lockedFiles = Object.keys(expectedFiles).sort();
|
|
92
|
+
if (actualFiles.length !== lockedFiles.length || actualFiles.some((file, index) => file !== lockedFiles[index])) {
|
|
93
|
+
const missing = lockedFiles.filter((file) => !actualFiles.includes(file));
|
|
94
|
+
const unexpected = actualFiles.filter((file) => !lockedFiles.includes(file));
|
|
95
|
+
throw new Error(`Official tool file set mismatch; missing=${missing.join(",") || "none"}; unexpected=${unexpected.join(",") || "none"}`);
|
|
96
|
+
}
|
|
97
|
+
for (const file of lockedFiles) {
|
|
98
|
+
const digest = await sha256(path.join(toolDir, ...file.split("/")));
|
|
99
|
+
if (!crypto.timingSafeEqual(Buffer.from(digest, "hex"), Buffer.from(expectedFiles[file], "hex"))) {
|
|
100
|
+
throw new Error(`Official tool integrity check failed: ${file}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { files: lockedFiles.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runCommand(command, args, { cwd, timeoutMs = 180_000 } = {}) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
109
|
+
let stdout = "";
|
|
110
|
+
let stderr = "";
|
|
111
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
112
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
|
|
113
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
|
|
114
|
+
child.once("error", (error) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
reject(error);
|
|
117
|
+
});
|
|
118
|
+
child.once("close", (code, signal) => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
if (code === 0) resolve({ stdout, stderr });
|
|
121
|
+
else reject(new Error(`${command} failed (${signal || code}): ${(stderr || stdout).trim().slice(-2000)}`));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function checkoutRepository({ repository, commit, checkoutDir }) {
|
|
127
|
+
await mkdir(checkoutDir, { recursive: true });
|
|
128
|
+
await runCommand("git", ["init", "--quiet"], { cwd: checkoutDir });
|
|
129
|
+
await runCommand("git", ["remote", "add", "origin", repository], { cwd: checkoutDir });
|
|
130
|
+
await runCommand("git", ["fetch", "--quiet", "--depth", "1", "origin", commit], { cwd: checkoutDir });
|
|
131
|
+
await runCommand("git", ["checkout", "--quiet", "--detach", "FETCH_HEAD"], { cwd: checkoutDir });
|
|
132
|
+
const resolved = (await runCommand("git", ["rev-parse", "HEAD"], { cwd: checkoutDir })).stdout.trim();
|
|
133
|
+
if (resolved !== commit) throw new Error(`Official tool checkout resolved ${resolved}, expected ${commit}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function validateEntrypoint(toolDir, toolName) {
|
|
137
|
+
const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
|
|
138
|
+
if (manifest.name !== toolName) {
|
|
139
|
+
throw new Error(`Official tool manifest mismatch: expected ${toolName}, got ${manifest.name || "missing"}`);
|
|
140
|
+
}
|
|
141
|
+
const entry = manifest.entry || "index.js";
|
|
142
|
+
const entryPath = path.join(toolDir, entry);
|
|
143
|
+
if (!(await exists(entryPath))) throw new Error(`Official tool entry does not exist: ${entry}`);
|
|
144
|
+
await runCommand(process.execPath, ["--check", entry], { cwd: toolDir, timeoutMs: 30_000 });
|
|
145
|
+
await runCommand(process.execPath, [entry, "--help"], { cwd: toolDir, timeoutMs: 30_000 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function installLockedOfficialTool({
|
|
149
|
+
toolName,
|
|
150
|
+
lock,
|
|
151
|
+
destination,
|
|
152
|
+
scratchRoot = os.tmpdir(),
|
|
153
|
+
checkout = checkoutRepository,
|
|
154
|
+
validate = validateEntrypoint
|
|
155
|
+
}) {
|
|
156
|
+
const locked = validateOfficialToolLock(lock, toolName);
|
|
157
|
+
if (await exists(destination)) {
|
|
158
|
+
throw new Error(`Refusing to overwrite installed tool: ${destination}`);
|
|
159
|
+
}
|
|
160
|
+
const scratch = await mkdtemp(path.join(scratchRoot, `arisa-${toolName}-`));
|
|
161
|
+
const checkoutDir = path.join(scratch, "repository");
|
|
162
|
+
const stageDir = path.join(scratch, "stage");
|
|
163
|
+
try {
|
|
164
|
+
await checkout({ repository: locked.repository, commit: locked.commit, checkoutDir });
|
|
165
|
+
const sourceDir = path.join(checkoutDir, "tools", toolName);
|
|
166
|
+
await verifyOfficialToolTree(sourceDir, locked.files);
|
|
167
|
+
await cp(sourceDir, stageDir, { recursive: true, errorOnExist: true, force: false });
|
|
168
|
+
await validate(stageDir, toolName);
|
|
169
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
170
|
+
await rename(stageDir, destination);
|
|
171
|
+
return { toolName, destination, commit: locked.commit, files: Object.keys(locked.files).length };
|
|
172
|
+
} finally {
|
|
173
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function installBundledOfficialTool(toolName, {
|
|
178
|
+
lockFile = bundledLockFile,
|
|
179
|
+
install = installLockedOfficialTool
|
|
180
|
+
} = {}) {
|
|
181
|
+
const lock = JSON.parse(await readFile(lockFile, "utf8"));
|
|
182
|
+
return install({ toolName, lock, destination: getToolDir(toolName) });
|
|
183
|
+
}
|
|
@@ -6,6 +6,8 @@ import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolTmpDir,
|
|
|
6
6
|
import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
|
|
7
7
|
import { normalizeToolResult } from "./tool-result.js";
|
|
8
8
|
import { readDaemonDiagnostic } from "./daemon-processes.js";
|
|
9
|
+
import { createDaemonRuntime, DAEMON_EVENT_TYPES, DAEMON_PROTOCOL_VERSION } from "./daemon-runtime.js";
|
|
10
|
+
import { daemonConfigDefaults } from "../config/config-defaults.js";
|
|
9
11
|
import { SkillRegistry } from "../skills/skill-registry.js";
|
|
10
12
|
import { ToolUsageStore } from "./tool-usage-store.js";
|
|
11
13
|
|
|
@@ -24,6 +26,129 @@ function runProcess(command, args, options = {}) {
|
|
|
24
26
|
});
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function requirementNames(requirements) {
|
|
30
|
+
if (Array.isArray(requirements)) {
|
|
31
|
+
return requirements.map((item) => typeof item === "string" ? item : item?.name).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
if (requirements && typeof requirements === "object") return Object.keys(requirements);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createToolOutputParser(name, { onEvent, maxFrameBytes = 1_048_576 } = {}) {
|
|
38
|
+
let buffer = "";
|
|
39
|
+
let mode = "unknown";
|
|
40
|
+
let rawOutput = "";
|
|
41
|
+
let terminalResult = null;
|
|
42
|
+
let activeJobId = null;
|
|
43
|
+
let sequence = 0;
|
|
44
|
+
let terminalSeen = false;
|
|
45
|
+
|
|
46
|
+
async function parseEvent(line) {
|
|
47
|
+
let event;
|
|
48
|
+
try {
|
|
49
|
+
event = JSON.parse(line);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`Invalid NDJSON from ${name}`);
|
|
52
|
+
}
|
|
53
|
+
if (event?.version !== DAEMON_PROTOCOL_VERSION || !DAEMON_EVENT_TYPES.includes(event?.type)) {
|
|
54
|
+
throw new Error(`Invalid versioned tool event from ${name}`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof event.jobId !== "string" || !event.jobId) throw new Error(`Tool event from ${name} is missing jobId`);
|
|
57
|
+
if (activeJobId == null) activeJobId = event.jobId;
|
|
58
|
+
if (event.jobId !== activeJobId) throw new Error(`Tool ${name} multiplexed an unexpected jobId`);
|
|
59
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence !== sequence + 1) {
|
|
60
|
+
throw new Error(`Invalid tool event sequence from ${name}: ${event.sequence}`);
|
|
61
|
+
}
|
|
62
|
+
if (terminalSeen) throw new Error(`Tool ${name} emitted more than one terminal event`);
|
|
63
|
+
sequence = event.sequence;
|
|
64
|
+
terminalSeen = event.type === "completed" || event.type === "failed";
|
|
65
|
+
await onEvent?.(event);
|
|
66
|
+
if (terminalSeen) {
|
|
67
|
+
terminalResult = event.type === "completed"
|
|
68
|
+
? event.payload?.result ?? event.payload?.output ?? event.payload
|
|
69
|
+
: { ok: false, error: event.payload?.error || `Tool failed: ${name}`, ...(event.payload?.code ? { code: event.payload.code } : {}) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function consumeLine(line) {
|
|
74
|
+
if (Buffer.byteLength(line, "utf8") > maxFrameBytes) throw new Error(`Tool event from ${name} exceeds ${maxFrameBytes} bytes`);
|
|
75
|
+
if (mode === "unknown") {
|
|
76
|
+
let candidate;
|
|
77
|
+
try {
|
|
78
|
+
candidate = JSON.parse(line);
|
|
79
|
+
} catch {
|
|
80
|
+
mode = "legacy";
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (candidate?.version === DAEMON_PROTOCOL_VERSION && DAEMON_EVENT_TYPES.includes(candidate?.type)) {
|
|
84
|
+
mode = "ndjson";
|
|
85
|
+
rawOutput = "";
|
|
86
|
+
return parseEvent(line);
|
|
87
|
+
}
|
|
88
|
+
mode = "legacy";
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (mode === "legacy") return;
|
|
92
|
+
return parseEvent(line);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
async push(chunk) {
|
|
97
|
+
const text = chunk.toString("utf8");
|
|
98
|
+
if (mode !== "ndjson") rawOutput += text;
|
|
99
|
+
if (mode === "legacy") return;
|
|
100
|
+
buffer += text;
|
|
101
|
+
if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes && !buffer.includes("\n")) {
|
|
102
|
+
throw new Error(`Tool event from ${name} exceeds ${maxFrameBytes} bytes`);
|
|
103
|
+
}
|
|
104
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
105
|
+
while (newlineIndex !== -1) {
|
|
106
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
107
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
108
|
+
if (line) await consumeLine(line);
|
|
109
|
+
newlineIndex = buffer.indexOf("\n");
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
async finish() {
|
|
113
|
+
const tail = buffer.trim();
|
|
114
|
+
buffer = "";
|
|
115
|
+
if (tail) await consumeLine(tail);
|
|
116
|
+
if (mode !== "ndjson") return { mode: "legacy", output: rawOutput };
|
|
117
|
+
if (!terminalSeen) throw new Error(`Tool ${name} ended without a terminal event`);
|
|
118
|
+
return { mode: "ndjson", result: terminalResult };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function runToolProcess(command, args, { onEvent, maxFrameBytes, ...options } = {}) {
|
|
124
|
+
const child = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
125
|
+
const parser = createToolOutputParser(path.basename(args[0] || command), { onEvent, maxFrameBytes });
|
|
126
|
+
const stderrChunks = [];
|
|
127
|
+
let stderrBytes = 0;
|
|
128
|
+
const stdoutTask = (async () => {
|
|
129
|
+
for await (const chunk of child.stdout) await parser.push(chunk);
|
|
130
|
+
return parser.finish();
|
|
131
|
+
})();
|
|
132
|
+
const stderrTask = (async () => {
|
|
133
|
+
for await (const chunk of child.stderr) {
|
|
134
|
+
if (stderrBytes >= maxFrameBytes) continue;
|
|
135
|
+
const accepted = chunk.subarray(0, maxFrameBytes - stderrBytes);
|
|
136
|
+
stderrChunks.push(accepted);
|
|
137
|
+
stderrBytes += accepted.length;
|
|
138
|
+
}
|
|
139
|
+
return Buffer.concat(stderrChunks).toString("utf8");
|
|
140
|
+
})();
|
|
141
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
142
|
+
child.once("error", reject);
|
|
143
|
+
child.once("close", resolve);
|
|
144
|
+
});
|
|
145
|
+
child.stdout.resume();
|
|
146
|
+
child.stderr.resume();
|
|
147
|
+
const code = await exitPromise;
|
|
148
|
+
const [parsed, stderr] = await Promise.all([stdoutTask, stderrTask]);
|
|
149
|
+
return { code, parsed, stderr };
|
|
150
|
+
}
|
|
151
|
+
|
|
27
152
|
function normalizeCategory(category) {
|
|
28
153
|
if (typeof category !== "string") return null;
|
|
29
154
|
const trimmed = category.trim();
|
|
@@ -38,6 +163,38 @@ function normalizeKeywords(keywords) {
|
|
|
38
163
|
.filter(Boolean))];
|
|
39
164
|
}
|
|
40
165
|
|
|
166
|
+
function searchableToolText(tool) {
|
|
167
|
+
return [
|
|
168
|
+
tool.name,
|
|
169
|
+
tool.description,
|
|
170
|
+
tool.category,
|
|
171
|
+
...(tool.keywords || []),
|
|
172
|
+
...(tool.input || []),
|
|
173
|
+
...(tool.output || [])
|
|
174
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function rankToolMatches(tools, query) {
|
|
178
|
+
const terms = String(query || "").toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
|
|
179
|
+
if (!terms.length) return [];
|
|
180
|
+
return tools.map((tool) => {
|
|
181
|
+
const name = String(tool.name || "").toLowerCase();
|
|
182
|
+
const category = String(tool.category || "").toLowerCase();
|
|
183
|
+
const keywords = (tool.keywords || []).map((keyword) => String(keyword).toLowerCase());
|
|
184
|
+
const haystack = searchableToolText(tool);
|
|
185
|
+
let score = 0;
|
|
186
|
+
for (const term of terms) {
|
|
187
|
+
if (keywords.includes(term)) score += 12;
|
|
188
|
+
else if (name === term) score += 10;
|
|
189
|
+
else if (name.includes(term)) score += 7;
|
|
190
|
+
else if (category === term) score += 6;
|
|
191
|
+
else if (haystack.includes(term)) score += 2;
|
|
192
|
+
}
|
|
193
|
+
return { tool, score };
|
|
194
|
+
}).filter((match) => match.score > 0)
|
|
195
|
+
.sort((left, right) => right.score - left.score || left.tool.name.localeCompare(right.tool.name));
|
|
196
|
+
}
|
|
197
|
+
|
|
41
198
|
function formatSemanticMetadata(tool) {
|
|
42
199
|
return [
|
|
43
200
|
"Semantic metadata:",
|
|
@@ -99,6 +256,9 @@ export class ToolRegistry {
|
|
|
99
256
|
list() {
|
|
100
257
|
return [...this.tools.values()].map((tool) => ({
|
|
101
258
|
name: tool.name,
|
|
259
|
+
version: typeof tool.version === "string" ? tool.version : null,
|
|
260
|
+
packageDigest: typeof tool.packageDigest === "string" ? tool.packageDigest : null,
|
|
261
|
+
requirements: requirementNames(tool.requirements),
|
|
102
262
|
description: tool.description,
|
|
103
263
|
input: tool.input,
|
|
104
264
|
output: tool.output,
|
|
@@ -109,6 +269,10 @@ export class ToolRegistry {
|
|
|
109
269
|
}));
|
|
110
270
|
}
|
|
111
271
|
|
|
272
|
+
search(query) {
|
|
273
|
+
return rankToolMatches(this.list(), query).map(({ tool, score }) => ({ ...tool, score }));
|
|
274
|
+
}
|
|
275
|
+
|
|
112
276
|
async listWithRuntime(chatId = null) {
|
|
113
277
|
return Promise.all(this.list().map(async (listedTool) => {
|
|
114
278
|
const daemon = this.get(listedTool.name)?.daemon;
|
|
@@ -203,7 +367,7 @@ export class ToolRegistry {
|
|
|
203
367
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
204
368
|
}
|
|
205
369
|
|
|
206
|
-
async run({ name, request, chatId = null }) {
|
|
370
|
+
async run({ name, request, chatId = null, onEvent = null }) {
|
|
207
371
|
const tool = this.get(name);
|
|
208
372
|
if (!tool) throw new Error(`Tool not found: ${name}`);
|
|
209
373
|
await this.usageStore.record(chatId, name).catch((error) => {
|
|
@@ -215,33 +379,54 @@ export class ToolRegistry {
|
|
|
215
379
|
const requestFile = path.join(tmpDir, `.request-${Date.now()}-${randomUUID()}.json`);
|
|
216
380
|
const skills = await this.resolveSkills(name);
|
|
217
381
|
const enrichedRequest = { ...request, chatId, skills };
|
|
218
|
-
|
|
219
|
-
const result = await runProcess("node", [tool.entry, "run", "--request-file", requestFile], {
|
|
220
|
-
cwd: tool.dir,
|
|
221
|
-
env: toolEnv()
|
|
222
|
-
});
|
|
223
|
-
await unlink(requestFile).catch(() => {});
|
|
224
|
-
await rmdir(tmpDir).catch(() => {});
|
|
225
|
-
if (chatId != null) {
|
|
226
|
-
await rmdir(path.dirname(tmpDir)).catch(() => {});
|
|
227
|
-
await rmdir(path.dirname(path.dirname(tmpDir))).catch(() => {});
|
|
228
|
-
}
|
|
382
|
+
let result;
|
|
229
383
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
384
|
+
if (tool.daemon?.protocol === "arisa-daemon-v1") {
|
|
385
|
+
const scope = tool.daemon.scope === "chat"
|
|
386
|
+
? { type: "chat", chatId }
|
|
387
|
+
: { type: "global" };
|
|
388
|
+
const runtime = createDaemonRuntime({
|
|
389
|
+
toolName: name,
|
|
390
|
+
entryPath: tool.entry,
|
|
391
|
+
scope,
|
|
392
|
+
startupContext: scope.type === "chat" ? { chatId: String(chatId) } : {},
|
|
393
|
+
autoStart: Boolean(tool.daemon.autoStart)
|
|
394
|
+
});
|
|
395
|
+
result = await runtime.submit(enrichedRequest, { onEvent });
|
|
396
|
+
} else {
|
|
397
|
+
await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
|
|
398
|
+
const processResult = await runToolProcess("node", [tool.entry, "run", "--request-file", requestFile], {
|
|
399
|
+
cwd: tool.dir,
|
|
400
|
+
env: toolEnv(),
|
|
401
|
+
onEvent,
|
|
402
|
+
maxFrameBytes: daemonConfigDefaults.ipcFrameBytes
|
|
403
|
+
});
|
|
404
|
+
if (processResult.stderr.trim()) {
|
|
405
|
+
this.logger?.log("tools", `${name} stderr: ${processResult.stderr.trim()}`);
|
|
406
|
+
}
|
|
407
|
+
result = processResult.parsed.mode === "ndjson"
|
|
408
|
+
? processResult.parsed.result
|
|
409
|
+
: JSON.parse(processResult.parsed.output);
|
|
410
|
+
}
|
|
411
|
+
const normalized = normalizeToolResult(name, result);
|
|
232
412
|
if (normalized.ok === false) {
|
|
233
413
|
this.logger?.log("tools", `${name} -> ${normalized.status || "error"}: ${normalized.error || "unknown error"}`);
|
|
234
414
|
} else {
|
|
235
415
|
this.logger?.log("tools", `${name} -> ok`);
|
|
236
416
|
}
|
|
237
417
|
return normalized;
|
|
238
|
-
} catch {
|
|
418
|
+
} catch (error) {
|
|
239
419
|
return normalizeToolResult(name, {
|
|
240
420
|
ok: false,
|
|
241
|
-
error: `Invalid tool response for ${name}
|
|
242
|
-
stdout: result.stdout,
|
|
243
|
-
stderr: result.stderr
|
|
421
|
+
error: error?.message || `Invalid tool response for ${name}`
|
|
244
422
|
});
|
|
423
|
+
} finally {
|
|
424
|
+
await unlink(requestFile).catch(() => {});
|
|
425
|
+
await rmdir(tmpDir).catch(() => {});
|
|
426
|
+
if (chatId != null) {
|
|
427
|
+
await rmdir(path.dirname(tmpDir)).catch(() => {});
|
|
428
|
+
await rmdir(path.dirname(path.dirname(tmpDir))).catch(() => {});
|
|
429
|
+
}
|
|
245
430
|
}
|
|
246
431
|
}
|
|
247
432
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getChatToolResourceNotesFile } from "../../runtime/paths.js";
|
|
4
|
+
|
|
5
|
+
export const maxToolResourceNoteCharacters = 200;
|
|
6
|
+
|
|
7
|
+
function emptyNotes() {
|
|
8
|
+
return { version: 1, tools: {} };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requiredText(value, name) {
|
|
12
|
+
const text = String(value ?? "").trim();
|
|
13
|
+
if (!text) throw new Error(`${name} is required`);
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readNotes(file) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
20
|
+
return parsed?.version === 1 && parsed.tools && typeof parsed.tools === "object"
|
|
21
|
+
? parsed
|
|
22
|
+
: emptyNotes();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error?.code === "ENOENT") return emptyNotes();
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function writeNotes(file, notes) {
|
|
30
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
31
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
32
|
+
await writeFile(temporary, `${JSON.stringify(notes, null, 2)}\n`, "utf8");
|
|
33
|
+
await rename(temporary, file);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ToolResourceNoteStore {
|
|
37
|
+
constructor({ resolveFile = getChatToolResourceNotesFile } = {}) {
|
|
38
|
+
this.resolveFile = resolveFile;
|
|
39
|
+
this.queues = new Map();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async get(chatId, toolName, resourceId) {
|
|
43
|
+
if (chatId == null || !resourceId) return "";
|
|
44
|
+
await (this.queues.get(String(chatId)) || Promise.resolve()).catch(() => {});
|
|
45
|
+
const notes = await readNotes(this.resolveFile(chatId));
|
|
46
|
+
return notes.tools?.[String(toolName)]?.[String(resourceId)]?.note || "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async set(chatId, toolName, resourceId, note) {
|
|
50
|
+
const scopedToolName = requiredText(toolName, "toolName");
|
|
51
|
+
const scopedResourceId = requiredText(resourceId, "resourceId");
|
|
52
|
+
const scopedNote = String(note ?? "").trim();
|
|
53
|
+
if ([...scopedNote].length > maxToolResourceNoteCharacters) {
|
|
54
|
+
throw new Error(`note must be at most ${maxToolResourceNoteCharacters} characters`);
|
|
55
|
+
}
|
|
56
|
+
const key = String(chatId);
|
|
57
|
+
const previous = this.queues.get(key) || Promise.resolve();
|
|
58
|
+
const current = previous.catch(() => {}).then(async () => {
|
|
59
|
+
const file = this.resolveFile(chatId);
|
|
60
|
+
const notes = await readNotes(file);
|
|
61
|
+
notes.tools[scopedToolName] ||= {};
|
|
62
|
+
if (scopedNote) {
|
|
63
|
+
notes.tools[scopedToolName][scopedResourceId] = { note: scopedNote };
|
|
64
|
+
} else {
|
|
65
|
+
delete notes.tools[scopedToolName][scopedResourceId];
|
|
66
|
+
if (!Object.keys(notes.tools[scopedToolName]).length) delete notes.tools[scopedToolName];
|
|
67
|
+
}
|
|
68
|
+
await writeNotes(file, notes);
|
|
69
|
+
});
|
|
70
|
+
this.queues.set(key, current);
|
|
71
|
+
try {
|
|
72
|
+
await current;
|
|
73
|
+
} finally {
|
|
74
|
+
if (this.queues.get(key) === current) this.queues.delete(key);
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, toolName: scopedToolName, resourceId: scopedResourceId, note: scopedNote };
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/index.js
CHANGED
|
@@ -4,10 +4,12 @@ import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
|
|
|
4
4
|
import { applyRuntimeOverrides, createApp } from "./runtime/create-app.js";
|
|
5
5
|
import { loadConfig } from "./core/config/config-store.js";
|
|
6
6
|
import { createLogger } from "./runtime/logger.js";
|
|
7
|
-
import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
|
|
7
|
+
import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, serviceEntryFile, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
|
|
8
8
|
import { flushArisaHome } from "./runtime/flush.js";
|
|
9
9
|
import { readPackageVersion, showServiceLogs } from "./runtime/log-viewer.js";
|
|
10
10
|
import { arisaPackageDir } from "./runtime/paths.js";
|
|
11
|
+
import { runSlaveCli } from "./runtime/slave-cli.js";
|
|
12
|
+
import { unregisterSlaveServiceProcess } from "./runtime/slave-service.js";
|
|
11
13
|
|
|
12
14
|
process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
|
|
13
15
|
|
|
@@ -17,6 +19,8 @@ const command = cli.positionals[0] || "run";
|
|
|
17
19
|
const forceBootstrap = Boolean(cli.flags.bootstrap);
|
|
18
20
|
const verbose = !cli.flags.silent;
|
|
19
21
|
const serviceRunner = Boolean(cli.flags["service-runner"]);
|
|
22
|
+
const slaveCommand = command === "slave";
|
|
23
|
+
const slaveServiceRunner = slaveCommand && serviceRunner;
|
|
20
24
|
const runtimeOverrides = toNestedOverrides(cli.nestedFlags);
|
|
21
25
|
const logger = createLogger({ verbose });
|
|
22
26
|
let activeApp = null;
|
|
@@ -99,7 +103,10 @@ async function shutdown(exitCode = 0) {
|
|
|
99
103
|
logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
104
|
exitCode = exitCode || 1;
|
|
101
105
|
}
|
|
102
|
-
if (
|
|
106
|
+
if (slaveServiceRunner) {
|
|
107
|
+
const slavePaths = activeApp?.slavePaths;
|
|
108
|
+
if (slavePaths) await unregisterSlaveServiceProcess(slavePaths);
|
|
109
|
+
} else if (serviceRunner) {
|
|
103
110
|
await unregisterServiceProcess();
|
|
104
111
|
}
|
|
105
112
|
process.exit(exitCode);
|
|
@@ -208,6 +215,22 @@ async function runForeground() {
|
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
async function main() {
|
|
218
|
+
if (slaveCommand) {
|
|
219
|
+
const result = await runSlaveCli({
|
|
220
|
+
positionals: cli.positionals.slice(1),
|
|
221
|
+
flags: cli.flags,
|
|
222
|
+
logger,
|
|
223
|
+
entryFile: serviceEntryFile
|
|
224
|
+
});
|
|
225
|
+
if (result?.serviceRunner) {
|
|
226
|
+
activeApp = {
|
|
227
|
+
slavePaths: result.paths,
|
|
228
|
+
stop: async () => result.app.stop()
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
211
234
|
if (serviceRunner) {
|
|
212
235
|
await registerServiceProcess();
|
|
213
236
|
await runForeground();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"repository": "https://github.com/clasen/Arisa.git",
|
|
4
|
+
"commit": "5959b7378c94b4d9c4864eba9e1ef9aa564f104a",
|
|
5
|
+
"tools": {
|
|
6
|
+
"master-slave": {
|
|
7
|
+
"files": {
|
|
8
|
+
"README.md": "39a546ca6046e622f92eff2d46f2c441beef43f150758799c68328bffd54efce",
|
|
9
|
+
"batch-runner.js": "be3939efe9c5800d3078c15a094f53db1094ef6e809d7826db7c47e3bec0c31b",
|
|
10
|
+
"chat-state-store.js": "50de39aa33432733bb688eb01968e314a32b169324a66d0f0089b2a19ed9c880",
|
|
11
|
+
"config.js": "ff1c1cb6701ae1da7a6eb1519ba96a416e48daa73c2155a6f93cf5d64f65cef8",
|
|
12
|
+
"index.js": "39dc9bf44f94864bc4fd09ce695cc2e084e59eea48d09764f0fb6f998faf9dd3",
|
|
13
|
+
"lib/bootstrap-url.js": "f05925cc0f0dff0882f94f9908c6f2c80aa8632cd51f63389381dcbb0f73afb3",
|
|
14
|
+
"lib/encrypted-frames.js": "47e1b11fafdbbcdfd86a0993a47a774e4f37d6386edeaf8ec1c8511b109728cf",
|
|
15
|
+
"lib/handshake-crypto.js": "9728b117aad7de53f5f0e255c0fd865ee1a19633368730a7634334d374094578",
|
|
16
|
+
"lib/ip-address.js": "3d5ab42d15fd47e79c5733114f905e995ef2d43226e29c434b250bf37878b0fa",
|
|
17
|
+
"lib/pairing-secret-store.js": "390f5d31d2a75707461147a1ec1b26de5f36da3ec4ec404b6f00a0f10c14065a",
|
|
18
|
+
"lib/profile-catalog.js": "d5cf43a564e45297d2c25642ba53a72a782ac2f3302f086658b3930236e6a154",
|
|
19
|
+
"lib/secure-store.js": "b8f365197f8e60d7f81fde6ed1cfc8851b603f6b0783e4cdc9036bb5c99726e1",
|
|
20
|
+
"master-domain.js": "10b7dfb43eb9939eb5baec54f742076cc73a2add6589a0ff57c51bc79c236faf",
|
|
21
|
+
"network-session.js": "db2d81a7ef47af29236d963b98e21414a11fef68e8b20895126eaad3f696ba3d",
|
|
22
|
+
"package.json": "8e9177238ba878dd4b51e32e0ed58a39c5789186c22b19ad7a43ab6caf9dc469",
|
|
23
|
+
"remote-runtime.js": "92c86adf575f2734c6f2f0d9004018f2019a8ac10288a1810425ce9fe0ffa129",
|
|
24
|
+
"slave-operations.js": "fec2e8addfff98f080284f24f722c1516124e5f6d12d8c03bb3c9cc0c475d1d6",
|
|
25
|
+
"state-store.js": "c56849a3f1b9c990128276e17963034a7836abacedc44f2e500d5732ebf9e451",
|
|
26
|
+
"test/batch-runner.test.js": "f14c6fddff5d6240008eef388df6a153e82490e534d06419d7bb267e4f4693a7",
|
|
27
|
+
"test/bootstrap-url.test.js": "62c112b1a38c59ea2c77f797e32873667b52db32386da661a6ae8410490126fe",
|
|
28
|
+
"test/encrypted-frames.test.js": "cfb3195d3a58df7dc570bc7840a21c1841a53e0abc8b92840e0c24ed3435bcc1",
|
|
29
|
+
"test/handshake-crypto.test.js": "f11d6e95658f2055d8256bd7dfb66b39bedf95bf62a3cec5525b109bb6f9bc95",
|
|
30
|
+
"test/master-domain.test.js": "7298766db3e6b186cca0109f352766e1bf417a8af12a58dd1c2b78fd1a61ff24",
|
|
31
|
+
"test/network-session.test.js": "2e459346cc9364be705bc5f54691245044588b2cd03ae77e2e870dee6dfbcca6",
|
|
32
|
+
"test/pairing-secret-store.test.js": "c51257365fd0b641c24768d710cdff2d1c5f8816bf90f66c2c01a226904cb43f",
|
|
33
|
+
"test/profile-catalog.test.js": "d8e297bee413d53ec13f2e1779c13f2e019cb8304976d567eab0e9f1ca36637c",
|
|
34
|
+
"test/remote-runtime.test.js": "a842c9934a3bf2e37c0a807ad2bc78f2f0d5adb38336c53369ddd92132bfb2b6",
|
|
35
|
+
"test/slave-operations.test.js": "a84526ad7554c7d03192a68655b4637bec18750559a5c3436f7471e912ca74cc",
|
|
36
|
+
"tool.manifest.json": "abf24c67b1d9ef0331ded59e04dcc092772419e74691c0d753ab51b40286d6f6"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|