arisa 3.0.14 → 3.1.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.
@@ -56,6 +56,10 @@ export class TaskStore {
56
56
  if (!this.tasks) this.tasks = await loadTasksFile();
57
57
  }
58
58
 
59
+ async reload() {
60
+ this.tasks = await loadTasksFile();
61
+ }
62
+
59
63
  async save() {
60
64
  await saveTasksFile(this.tasks || []);
61
65
  }
@@ -77,7 +81,7 @@ export class TaskStore {
77
81
  }
78
82
 
79
83
  async claimDue(limit = 10) {
80
- await this.init();
84
+ await this.reload();
81
85
  const now = Date.now();
82
86
  const due = [];
83
87
 
@@ -126,7 +130,7 @@ export class TaskStore {
126
130
  }
127
131
 
128
132
  async list(filter = {}) {
129
- await this.init();
133
+ await this.reload();
130
134
  return this.tasks.filter((task) => {
131
135
  if (filter.chatId && task.payload?.chatId !== filter.chatId) return false;
132
136
  if (filter.status && task.status !== filter.status) return false;
@@ -136,12 +140,12 @@ export class TaskStore {
136
140
  }
137
141
 
138
142
  async get(taskId) {
139
- await this.init();
143
+ await this.reload();
140
144
  return this.tasks.find((item) => item.id === taskId) || null;
141
145
  }
142
146
 
143
147
  async cancel(taskId) {
144
- await this.init();
148
+ await this.reload();
145
149
  const index = this.tasks.findIndex((item) => item.id === taskId);
146
150
  if (index === -1) return null;
147
151
  const [task] = this.tasks.splice(index, 1);
@@ -150,7 +154,7 @@ export class TaskStore {
150
154
  }
151
155
 
152
156
  async cancelAll(filter = {}) {
153
- await this.init();
157
+ await this.reload();
154
158
  const removed = [];
155
159
  this.tasks = this.tasks.filter((task) => {
156
160
  if (filter.chatId && task.payload?.chatId !== filter.chatId) return true;
@@ -0,0 +1,167 @@
1
+ import crypto from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { openSync } from "node:fs";
4
+ import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { stateDir } from "../../runtime/paths.js";
7
+
8
+ export function daemonPaths(toolName) {
9
+ const root = path.join(stateDir, toolName);
10
+ return {
11
+ root,
12
+ commandsDir: path.join(root, "commands"),
13
+ pidFile: path.join(root, "daemon.pid"),
14
+ statusFile: path.join(root, "status.json"),
15
+ logFile: path.join(root, "daemon.log")
16
+ };
17
+ }
18
+
19
+ export async function readJson(file, fallback = {}) {
20
+ try { return JSON.parse(await readFile(file, "utf8")); } catch { return fallback; }
21
+ }
22
+
23
+ export async function writeJson(file, value) {
24
+ await mkdir(path.dirname(file), { recursive: true });
25
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
26
+ }
27
+
28
+ export function isProcessAlive(pid) {
29
+ if (!pid) return false;
30
+ try { process.kill(pid, 0); return true; } catch { return false; }
31
+ }
32
+
33
+ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null }) {
34
+ const paths = daemonPaths(toolName);
35
+
36
+ async function ensure() {
37
+ await mkdir(paths.commandsDir, { recursive: true });
38
+ }
39
+
40
+ function jobPaths(id) {
41
+ return {
42
+ request: path.join(paths.commandsDir, `${id}.request.json`),
43
+ processing: path.join(paths.commandsDir, `${id}.processing.json`),
44
+ result: path.join(paths.commandsDir, `${id}.result.json`)
45
+ };
46
+ }
47
+
48
+ async function getPid() {
49
+ return (await readJson(paths.pidFile, {})).pid;
50
+ }
51
+
52
+ async function writeStatus(patch) {
53
+ const current = await readJson(paths.statusFile, {});
54
+ await writeJson(paths.statusFile, { ...current, ...patch, updatedAt: new Date().toISOString() });
55
+ }
56
+
57
+ async function clearJobs() {
58
+ await rm(paths.commandsDir, { recursive: true, force: true });
59
+ await mkdir(paths.commandsDir, { recursive: true });
60
+ }
61
+
62
+ async function start() {
63
+ await ensure();
64
+ const pid = await getPid();
65
+ if (isProcessAlive(pid)) return pid;
66
+
67
+ await clearJobs();
68
+ if (beforeStart) await beforeStart();
69
+ const out = openSync(paths.logFile, "a");
70
+ const child = spawn(process.execPath, [entryPath, "daemon"], {
71
+ detached: true,
72
+ stdio: ["ignore", out, out],
73
+ env: process.env
74
+ });
75
+ child.unref();
76
+ await writeJson(paths.pidFile, { pid: child.pid, startedAt: new Date().toISOString() });
77
+ return child.pid;
78
+ }
79
+
80
+ async function stop() {
81
+ const pid = await getPid();
82
+ if (isProcessAlive(pid)) process.kill(pid, "SIGTERM");
83
+ await rm(paths.pidFile, { force: true });
84
+ }
85
+
86
+ async function waitReady({ timeoutMs = 120000, readyStates = ["ready"] } = {}) {
87
+ const startTime = Date.now();
88
+ while (Date.now() - startTime < timeoutMs) {
89
+ const status = await readJson(paths.statusFile, {});
90
+ const pid = await getPid();
91
+ if (readyStates.includes(status.state) && isProcessAlive(pid)) return status;
92
+ if (status.state === "error") throw new Error(status.message || `${toolName} daemon failed`);
93
+ await new Promise((resolve) => setTimeout(resolve, 500));
94
+ }
95
+ throw new Error(`${toolName} daemon was not ready after ${timeoutMs}ms`);
96
+ }
97
+
98
+ async function submit(payload, { timeoutMs = 180000, readyTimeoutMs = 120000 } = {}) {
99
+ await start();
100
+ await waitReady({ timeoutMs: readyTimeoutMs });
101
+ const id = crypto.randomUUID();
102
+ const files = jobPaths(id);
103
+ await writeJson(files.request, { id, ...payload });
104
+
105
+ const startTime = Date.now();
106
+ while (Date.now() - startTime < timeoutMs) {
107
+ const result = await readJson(files.result, null);
108
+ if (result) {
109
+ await unlink(files.result).catch(() => {});
110
+ if (!result.ok) throw new Error(result.error || `${toolName} job failed`);
111
+ return result.output || {};
112
+ }
113
+ await new Promise((resolve) => setTimeout(resolve, 250));
114
+ }
115
+ throw new Error(`${toolName} job timed out after ${timeoutMs}ms`);
116
+ }
117
+
118
+ async function claimNext() {
119
+ await ensure();
120
+ const files = (await readdir(paths.commandsDir)).filter((file) => file.endsWith(".request.json"));
121
+ for (const file of files) {
122
+ const id = file.replace(/\.request\.json$/, "");
123
+ const item = jobPaths(id);
124
+ try {
125
+ await rename(item.request, item.processing);
126
+ return { id, ...item, payload: await readJson(item.processing, null) };
127
+ } catch {}
128
+ }
129
+ return null;
130
+ }
131
+
132
+ async function complete(job, output) {
133
+ await writeJson(job.result, { ok: true, output });
134
+ await unlink(job.processing).catch(() => {});
135
+ }
136
+
137
+ async function fail(job, error) {
138
+ await writeJson(job.result, { ok: false, error: error?.message || String(error) });
139
+ await unlink(job.processing).catch(() => {});
140
+ }
141
+
142
+ async function workLoop({ processJob, idleTimeoutMs = 0, intervalMs = 250 }) {
143
+ let lastActivity = Date.now();
144
+ setInterval(async () => {
145
+ try {
146
+ const job = await claimNext();
147
+ if (job) {
148
+ lastActivity = Date.now();
149
+ try {
150
+ await complete(job, await processJob(job.payload));
151
+ } catch (error) {
152
+ await fail(job, error);
153
+ }
154
+ lastActivity = Date.now();
155
+ }
156
+ if (idleTimeoutMs > 0 && Date.now() - lastActivity > idleTimeoutMs) {
157
+ await writeStatus({ state: "stopped", message: "Idle timeout reached" });
158
+ process.exit(0);
159
+ }
160
+ } catch (error) {
161
+ await writeStatus({ state: "error", message: error?.message || String(error) });
162
+ }
163
+ }, intervalMs);
164
+ }
165
+
166
+ return { paths, ensure, getPid, writeStatus, start, stop, waitReady, submit, workLoop };
167
+ }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { getToolConfigPath } from "../../runtime/paths.js";
3
+ import { getToolConfigPath, getChatToolConfigPath } from "../../runtime/paths.js";
4
4
 
5
5
  export function parseConfigModule(source) {
6
6
  const normalized = source.replace(/^export\s+default/, "return");
@@ -21,14 +21,22 @@ export async function readConfigModule(filePath, fallback = {}) {
21
21
  }
22
22
  }
23
23
 
24
- export async function loadToolConfig(toolName, defaults = {}) {
25
- const configPath = getToolConfigPath(toolName);
26
- const stored = await readConfigModule(configPath, {});
27
- return { ...defaults, ...stored };
24
+ export async function loadToolConfig(toolName, defaults = {}, chatId = null) {
25
+ const globalPath = getToolConfigPath(toolName);
26
+ const globalStored = await readConfigModule(globalPath, {});
27
+ const merged = { ...defaults, ...globalStored };
28
+
29
+ if (chatId == null) return merged;
30
+
31
+ const chatPath = getChatToolConfigPath(chatId, toolName);
32
+ const chatStored = await readConfigModule(chatPath, {});
33
+ return { ...merged, ...chatStored };
28
34
  }
29
35
 
30
- export async function writeToolConfig(toolName, config) {
31
- const configPath = getToolConfigPath(toolName);
36
+ export async function writeToolConfig(toolName, config, chatId = null) {
37
+ const configPath = chatId != null
38
+ ? getChatToolConfigPath(chatId, toolName)
39
+ : getToolConfigPath(toolName);
32
40
  await mkdir(path.dirname(configPath), { recursive: true });
33
41
  await writeFile(configPath, serializeConfigModule(config), "utf8");
34
42
  return configPath;
@@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
- import { getToolConfigPath, getToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
+ import { getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
6
6
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
7
7
  import { normalizeToolResult } from "./tool-result.js";
8
8
 
@@ -92,22 +92,33 @@ export class ToolRegistry {
92
92
  return result.stdout || result.stderr;
93
93
  }
94
94
 
95
- async setConfig(name, field, value) {
95
+ async resolveConfigForChat(name, chatId) {
96
96
  const tool = this.get(name);
97
97
  if (!tool) throw new Error(`Tool not found: ${name}`);
98
- const config = { ...(tool.config || {}) };
99
- config[field] = value;
100
- const configPath = await writeToolConfig(name, config);
101
- tool.config = config;
102
- tool.configPath = configPath;
98
+ if (chatId == null) return tool.config || {};
99
+ return loadToolConfig(name, tool.defaults || {}, chatId);
100
+ }
101
+
102
+ async setConfig(name, field, value, chatId = null) {
103
+ const tool = this.get(name);
104
+ if (!tool) throw new Error(`Tool not found: ${name}`);
105
+ const current = chatId != null
106
+ ? await this.resolveConfigForChat(name, chatId)
107
+ : { ...(tool.config || {}) };
108
+ current[field] = value;
109
+ const configPath = await writeToolConfig(name, current, chatId);
110
+ if (chatId == null) {
111
+ tool.config = current;
112
+ tool.configPath = configPath;
113
+ }
103
114
  return { ok: true, tool: name, field, configPath };
104
115
  }
105
116
 
106
- async run({ name, request }) {
117
+ async run({ name, request, chatId = null }) {
107
118
  const tool = this.get(name);
108
119
  if (!tool) throw new Error(`Tool not found: ${name}`);
109
120
  this.logger?.log("tools", `running ${name}`);
110
- const tmpDir = getToolTmpDir(name);
121
+ const tmpDir = chatId != null ? getChatToolTmpDir(chatId, name) : getToolTmpDir(name);
111
122
  await mkdir(tmpDir, { recursive: true });
112
123
  const requestFile = path.join(tmpDir, `.request-${Date.now()}.json`);
113
124
  await writeFile(requestFile, `${JSON.stringify(request, null, 2)}\n`, "utf8");
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createServer } from "node:http";
3
4
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
4
5
  import { createApp } from "./runtime/create-app.js";
5
6
  import { createLogger } from "./runtime/logger.js";
@@ -8,25 +9,117 @@ import { flushArisaHome } from "./runtime/flush.js";
8
9
  import { installPiPackage, removePiPackage } from "./runtime/pi-package-manager.js";
9
10
 
10
11
  const args = process.argv.slice(2);
11
- const command = args.find((arg) => !arg.startsWith("--")) || "run";
12
- const forceBootstrap = args.includes("--bootstrap");
13
- const verbose = args.includes("--verbose");
14
- const serviceRunner = args.includes("--service-runner");
12
+ const cli = parseCliArgs(args);
13
+ const command = cli.positionals[0] || "run";
14
+ const forceBootstrap = Boolean(cli.flags.bootstrap);
15
+ const verbose = Boolean(cli.flags.verbose);
16
+ const serviceRunner = Boolean(cli.flags["service-runner"]);
17
+ const bootstrapOverrides = toBootstrapOverrides(cli.nestedFlags);
18
+ const runtimeOverrides = toRuntimeOverrides(cli.nestedFlags);
15
19
  const logger = createLogger({ verbose });
16
20
 
21
+ const httpPort = Number(process.env.PORT);
22
+ let httpRequestHandler = null;
23
+
24
+ function setHttpRequestHandler(handler) {
25
+ httpRequestHandler = handler;
26
+ }
27
+
28
+ if (httpPort && bootstrapOverrides.telegram) {
29
+ createServer((req, res) => {
30
+ if (httpRequestHandler) return httpRequestHandler(req, res);
31
+ res.writeHead(200, { "Content-Type": "text/plain" });
32
+ res.end("ok");
33
+ }).listen(httpPort)
34
+ .on("listening", () => logger.log("http", `health server on port ${httpPort}`));
35
+ }
36
+
37
+ function parseCliArgs(rawArgs) {
38
+ const flags = {};
39
+ const nestedFlags = {};
40
+ const positionals = [];
41
+
42
+ for (let index = 0; index < rawArgs.length; index += 1) {
43
+ const token = rawArgs[index];
44
+ if (!token.startsWith("--")) {
45
+ positionals.push(token);
46
+ continue;
47
+ }
48
+
49
+ const flagName = token.slice(2);
50
+ if (!flagName) continue;
51
+ if (flagName.includes(".")) {
52
+ const next = rawArgs[index + 1];
53
+ const hasValue = typeof next === "string" && !next.startsWith("--");
54
+ if (hasValue) {
55
+ nestedFlags[flagName] = next;
56
+ index += 1;
57
+ }
58
+ continue;
59
+ }
60
+
61
+ flags[flagName] = true;
62
+ }
63
+
64
+ return { flags, nestedFlags, positionals };
65
+ }
66
+
67
+ function toBootstrapOverrides(nestedFlags) {
68
+ const overrides = {};
69
+ for (const [flatKey, value] of Object.entries(nestedFlags)) {
70
+ const parts = flatKey.split(".");
71
+ let cursor = overrides;
72
+ for (let index = 0; index < parts.length - 1; index += 1) {
73
+ const key = parts[index];
74
+ if (!cursor[key] || typeof cursor[key] !== "object") {
75
+ cursor[key] = {};
76
+ }
77
+ cursor = cursor[key];
78
+ }
79
+ cursor[parts[parts.length - 1]] = value;
80
+ }
81
+ return overrides;
82
+ }
83
+
84
+ function toRuntimeOverrides(nestedFlags) {
85
+ return toBootstrapOverrides(nestedFlags);
86
+ }
87
+
88
+ function toServiceRunnerArgs(nestedFlags) {
89
+ const args = [];
90
+ if (nestedFlags["pi.model"]) {
91
+ args.push("--pi.model", nestedFlags["pi.model"]);
92
+ }
93
+ return args;
94
+ }
95
+
96
+ const bootstrapHttpOptions = httpPort ? { httpPort, setHttpRequestHandler } : {};
97
+ const webhookUrl = bootstrapOverrides.webhook?.url || "";
98
+ const appHttpOptions = httpPort ? { webhookUrl, setHttpRequestHandler } : {};
99
+
17
100
  async function runForeground() {
101
+ const hasRuntimePiOverrides = Boolean(
102
+ runtimeOverrides?.pi?.model
103
+ || runtimeOverrides?.pi?.provider
104
+ || runtimeOverrides?.pi?.apiKey
105
+ );
18
106
  logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
19
- await bootstrapIfNeeded({ force: forceBootstrap });
107
+ await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
20
108
  try {
21
- const app = await createApp({ logger });
109
+ const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
22
110
  await app.start();
23
111
  } catch (error) {
24
112
  const message = error instanceof Error ? error.message : String(error);
25
113
  if (message.includes("No auth found")) {
26
114
  console.log(`\n${message}\n`);
115
+ if (hasRuntimePiOverrides) {
116
+ console.log("Skipping automatic bootstrap because Pi runtime overrides were provided.");
117
+ console.log("Keeping existing Telegram config. Run `arisa --bootstrap` manually if you want to update persisted auth/config.\n");
118
+ throw error;
119
+ }
27
120
  console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
28
- await bootstrapIfNeeded({ force: true });
29
- const app = await createApp({ logger });
121
+ await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
122
+ const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
30
123
  await app.start();
31
124
  return;
32
125
  }
@@ -42,8 +135,8 @@ async function main() {
42
135
  }
43
136
 
44
137
  if (command === "start") {
45
- await bootstrapIfNeeded({ force: forceBootstrap });
46
- const result = await startService({ verbose });
138
+ await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
139
+ const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
47
140
  if (!result.ok) {
48
141
  console.log(`Arisa is already running in background (pid ${result.pid}).`);
49
142
  return;
@@ -85,7 +178,7 @@ async function main() {
85
178
  }
86
179
 
87
180
  if (command === "install") {
88
- const source = args.filter((arg) => !arg.startsWith("--")).slice(1)[0];
181
+ const source = cli.positionals[1];
89
182
  if (!source) {
90
183
  console.log("Usage: arisa install <pi-package-source>");
91
184
  return;
@@ -96,7 +189,7 @@ async function main() {
96
189
  }
97
190
 
98
191
  if (command === "remove") {
99
- const source = args.filter((arg) => !arg.startsWith("--")).slice(1)[0];
192
+ const source = cli.positionals[1];
100
193
  if (!source) {
101
194
  console.log("Usage: arisa remove <pi-package-source>");
102
195
  return;