arisa 4.2.8 → 4.3.0

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
@@ -47,6 +47,12 @@ Each tool declares in `tool.manifest.json`:
47
47
  - `keywords`: optional intent tags for capability discovery
48
48
  - `skillHints`: optional skills to apply when using or editing the tool
49
49
 
50
+ ## Text encoding
51
+ All textual content generated or sent by Arisa or its tools must use UTF-8. This includes text files, assistant-created attachments, tool exports, email bodies, messages, HTTP responses, and API payloads.
52
+
53
+ - Text files must start with a UTF-8 byte-order mark (BOM).
54
+ - Protocol payloads must declare UTF-8 through the protocol's standard mechanism and encode their bytes as UTF-8. For example, email and HTTP text content must use a `Content-Type` with `charset=UTF-8`.
55
+
50
56
  ## Tool-to-Arisa IPC
51
57
  Tools that expose a web UI or HTTP endpoint own that server, usually through the shared daemon runtime; Arisa core does not mount tool routes or proxies. Use Arisa IPC when a tool needs registered tools, artifacts, tasks, agent events, or runtime paths.
52
58
 
@@ -121,6 +127,15 @@ When such a tool is built, implement it with the shared daemon runtime instead o
121
127
  - keep daemon tools headless/server-safe by default when they are meant to run on VPS machines
122
128
  - if the daemon exposes an HTTP server, keep that server inside the tool; Arisa core does not discover or mount tool routes
123
129
 
130
+ Every managed daemon must also follow the scoped health contract:
131
+ - declare `daemon.scope`, `daemon.autoStart`, and `daemon.health: "internal"` in `tool.manifest.json`
132
+ - use `{ type: "global" }` for shared infrastructure, or `{ type: "chat", chatId }` for one isolated process per chat
133
+ - implement `workLoop({ processJob, healthCheck, recover })`; `healthCheck` must exercise the real capability without human input, and `recover` is optional
134
+ - never write `ready` directly: the shared runtime sets it only after the health operation succeeds through the normal command queue
135
+ - keep daemon infrastructure for a chat-scoped process under `getChatToolStateDir(chatId, toolName)/daemon`; keep user data such as sessions, inboxes, and databases outside that subdirectory
136
+ - persist only non-secret launch identity in `startupContext`; reload global or chat-scoped tool config after every process restart
137
+ - use only the standard daemon states: `starting`, `ready`, `degraded`, `unhealthy`, `restarting`, `stopped`, `failed`
138
+
124
139
  ## Manual pipe behavior
125
140
  To run a pipe, the agent should:
126
141
  1. understand whether the needed pipe belongs to pre-reasoning normalization or post-reasoning tool chaining
package/README.md CHANGED
@@ -96,8 +96,11 @@ Per chat (`~/.arisa/chats/<chatId>/`):
96
96
  - the artifact index is stored in `state/artifacts.json`
97
97
  - the Pi session lives under `state/pi-sessions/`
98
98
  - chat-scoped tool config overrides live in `config/tools/<tool>/config.js`
99
+ - chat-scoped daemon infrastructure lives in `state/tools/<tool>/daemon/`; persistent tool data stays beside it
99
100
  - ephemeral scratch lives under `tmp/`
100
101
 
102
+ Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context.
103
+
101
104
  Pi authentication can use either:
102
105
  - an API key entered during bootstrap
103
106
  - or Pi's existing OAuth login when supported, such as `openai-codex`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.2.8",
3
+ "version": "4.3.0",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,15 +3,45 @@ import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../runtime/paths.js";
5
5
 
6
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
7
+
6
8
  function id() {
7
9
  return crypto.randomUUID();
8
10
  }
9
11
 
12
+ function withUtf8Bom(content) {
13
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
14
+ const body = bytes.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)
15
+ ? bytes.subarray(UTF8_BOM.length)
16
+ : bytes;
17
+ return Buffer.concat([UTF8_BOM, body]);
18
+ }
19
+
20
+ function isTextMimeType(mimeType = "") {
21
+ const type = mimeType.split(";", 1)[0].trim().toLowerCase();
22
+ return type.startsWith("text/")
23
+ || type === "application/json"
24
+ || type.endsWith("+json")
25
+ || type === "application/xml"
26
+ || type.endsWith("+xml")
27
+ || type === "application/javascript"
28
+ || type === "application/ecmascript"
29
+ || type === "image/svg+xml";
30
+ }
31
+
10
32
  function writeArtifactFile(filePath, content) {
11
- if (typeof content === "string") return writeFile(filePath, content, "utf8");
33
+ if (typeof content === "string") return writeFile(filePath, withUtf8Bom(content));
12
34
  return writeFile(filePath, content);
13
35
  }
14
36
 
37
+ async function copyArtifactFile(originalPath, destPath, mimeType) {
38
+ if (!isTextMimeType(mimeType)) return copyFile(originalPath, destPath);
39
+
40
+ const content = await readFile(originalPath);
41
+ new TextDecoder("utf-8", { fatal: true }).decode(content);
42
+ return writeFile(destPath, withUtf8Bom(content));
43
+ }
44
+
15
45
  class ChatArtifactStore {
16
46
  constructor(chatId) {
17
47
  this.chatId = String(chatId);
@@ -63,7 +93,7 @@ class ChatArtifactStore {
63
93
  const dir = path.join(this.rootDir, artifactId);
64
94
  await mkdir(dir, { recursive: true });
65
95
  const destPath = path.join(dir, fileName);
66
- await copyFile(originalPath, destPath);
96
+ await copyArtifactFile(originalPath, destPath, mimeType);
67
97
  const artifact = {
68
98
  id: artifactId,
69
99
  chatId: this.chatId,
@@ -0,0 +1,25 @@
1
+ export const daemonConfigDefaults = Object.freeze({
2
+ supervisorIntervalMs: 5_000,
3
+ heartbeatIntervalMs: 5_000,
4
+ heartbeatStaleMs: 20_000,
5
+ healthIntervalMs: 30_000,
6
+ healthTimeoutMs: 120_000,
7
+ healthRetryLimit: 2,
8
+ healthRetryBackoffMs: 1_000,
9
+ restartLimit: 3,
10
+ restartBackoffMs: 2_000,
11
+ restartBackoffMaxMs: 60_000,
12
+ startupTimeoutMs: 120_000,
13
+ stopTimeoutMs: 3_000,
14
+ queuePollIntervalMs: 250
15
+ });
16
+
17
+ export function applyConfigDefaults(config) {
18
+ return {
19
+ ...config,
20
+ daemons: {
21
+ ...daemonConfigDefaults,
22
+ ...(config.daemons || {})
23
+ }
24
+ };
25
+ }
@@ -1,15 +1,16 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { configFile } from "../../runtime/bootstrap.js";
3
+ import { configFile } from "../../runtime/paths.js";
4
+ import { applyConfigDefaults } from "./config-defaults.js";
4
5
 
5
6
  export async function loadConfig() {
6
7
  const raw = await readFile(configFile, "utf8");
7
- return JSON.parse(raw);
8
+ return applyConfigDefaults(JSON.parse(raw));
8
9
  }
9
10
 
10
11
  export async function saveConfig(config) {
11
12
  await mkdir(path.dirname(configFile), { recursive: true });
12
- await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
13
+ await writeFile(configFile, `${JSON.stringify(applyConfigDefaults(config), null, 2)}\n`, "utf8");
13
14
  }
14
15
 
15
16
  export async function updateConfig(mutator) {
@@ -0,0 +1,185 @@
1
+ import {
2
+ daemonPaths,
3
+ isProcessAlive,
4
+ readJson,
5
+ startManagedDaemon,
6
+ stopManagedDaemon,
7
+ writeDaemonStatus
8
+ } from "./daemon-processes.js";
9
+ import { retryDelay } from "./daemon-policy.js";
10
+ import { submitDaemonControl } from "./daemon-runtime.js";
11
+
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ function errorRecord(phase, error) {
17
+ return {
18
+ at: new Date().toISOString(),
19
+ phase,
20
+ message: error?.message || String(error)
21
+ };
22
+ }
23
+
24
+ function isTimeout(error) {
25
+ return ["DAEMON_JOB_TIMEOUT", "DAEMON_OPERATION_TIMEOUT"].includes(error?.code);
26
+ }
27
+
28
+ function healthDue(status, policy, now = Date.now()) {
29
+ const heartbeatAt = new Date(status.heartbeatAt || 0).getTime();
30
+ const healthAt = new Date(status.lastHealthCheckAt || 0).getTime();
31
+ return status.state !== "ready"
32
+ || !heartbeatAt
33
+ || now - heartbeatAt > policy.heartbeatStaleMs
34
+ || !healthAt
35
+ || now - healthAt >= policy.healthIntervalMs;
36
+ }
37
+
38
+ async function probeWithRetries(record, paths, policy) {
39
+ let lastError;
40
+ for (let attempt = 0; attempt <= policy.healthRetryLimit; attempt += 1) {
41
+ if (attempt > 0) {
42
+ await sleep(policy.healthRetryBackoffMs * (2 ** (attempt - 1)));
43
+ }
44
+ try {
45
+ return await submitDaemonControl(record, "health", {
46
+ timeoutMs: policy.healthTimeoutMs
47
+ });
48
+ } catch (error) {
49
+ lastError = error;
50
+ const status = await readJson(paths.statusFile, {});
51
+ await writeDaemonStatus(paths, {
52
+ state: isTimeout(error) ? "unhealthy" : "degraded",
53
+ ...(error?.code === "DAEMON_JOB_TIMEOUT"
54
+ ? { consecutiveHealthFailures: Number(status.consecutiveHealthFailures || 0) + 1 }
55
+ : {}),
56
+ lastError: errorRecord("health", error),
57
+ message: error?.message || String(error)
58
+ });
59
+ if (isTimeout(error)) throw error;
60
+ }
61
+ }
62
+ throw lastError;
63
+ }
64
+
65
+ async function tryInternalRecovery(record, paths, policy) {
66
+ const status = await readJson(paths.statusFile, {});
67
+ if (!status.supportsRecovery) return false;
68
+ await writeDaemonStatus(paths, {
69
+ state: "restarting",
70
+ message: "Attempting tool-specific recovery"
71
+ });
72
+ try {
73
+ const result = await submitDaemonControl(record, "recover", {
74
+ timeoutMs: policy.healthTimeoutMs
75
+ });
76
+ if (result.recovered === false) return false;
77
+ await probeWithRetries(record, paths, policy);
78
+ return true;
79
+ } catch (error) {
80
+ await writeDaemonStatus(paths, {
81
+ state: "unhealthy",
82
+ lastError: errorRecord("recovery", error),
83
+ message: error?.message || String(error)
84
+ });
85
+ return false;
86
+ }
87
+ }
88
+
89
+ async function scheduleRestart(record, paths, status, policy, reason) {
90
+ const current = await readJson(paths.statusFile, status);
91
+ const restartAttempts = Number(current.restartAttempts || 0) + 1;
92
+ if (restartAttempts > policy.restartLimit) {
93
+ await stopManagedDaemon(
94
+ { toolName: record.toolName, scope: record.scope },
95
+ { state: null }
96
+ );
97
+ await writeDaemonStatus(paths, {
98
+ state: "failed",
99
+ pid: null,
100
+ heartbeatAt: null,
101
+ restartAttempts,
102
+ restartRequested: false,
103
+ nextRestartAt: null,
104
+ lastError: errorRecord("restart", reason),
105
+ message: `Daemon restart limit reached: ${reason?.message || reason}`
106
+ });
107
+ return "failed";
108
+ }
109
+
110
+ const delayMs = retryDelay(restartAttempts, policy);
111
+ await stopManagedDaemon(
112
+ { toolName: record.toolName, scope: record.scope },
113
+ { state: null }
114
+ );
115
+ await writeDaemonStatus(paths, {
116
+ state: "restarting",
117
+ pid: null,
118
+ heartbeatAt: null,
119
+ restartAttempts,
120
+ restartRequested: true,
121
+ nextRestartAt: new Date(Date.now() + delayMs).toISOString(),
122
+ lastError: errorRecord("restart", reason),
123
+ message: `Daemon will restart after ${delayMs}ms`
124
+ });
125
+ return "restart-scheduled";
126
+ }
127
+
128
+ async function restartIfDue(record, paths, status, policy) {
129
+ if (!record.autoStart && !status.restartRequested) return "stopped";
130
+ if (status.state === "failed") return "failed";
131
+ const nextRestartAt = new Date(status.nextRestartAt || 0).getTime();
132
+ if (nextRestartAt && Date.now() < nextRestartAt) return "backoff";
133
+ if (Number(status.restartAttempts || 0) > policy.restartLimit) {
134
+ await writeDaemonStatus(paths, {
135
+ state: "failed",
136
+ restartRequested: false,
137
+ nextRestartAt: null,
138
+ message: "Daemon restart limit reached"
139
+ });
140
+ return "failed";
141
+ }
142
+ await startManagedDaemon({
143
+ toolName: record.toolName,
144
+ entryPath: record.entryPath,
145
+ scope: record.scope,
146
+ startupContext: record.startupContext,
147
+ autoStart: record.autoStart
148
+ });
149
+ return "started";
150
+ }
151
+
152
+ export async function superviseDaemon(record, policy) {
153
+ const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
154
+ const { pid } = await readJson(paths.pidFile, {});
155
+ const status = await readJson(paths.statusFile, {});
156
+ const alive = isProcessAlive(pid);
157
+
158
+ if (!alive) {
159
+ if (!record.autoStart && !status.restartRequested && ["stopped", "unhealthy", "failed"].includes(status.state)) {
160
+ return status.state;
161
+ }
162
+ if (!status.state || status.state === "stopped") {
163
+ return restartIfDue(record, paths, status, policy);
164
+ }
165
+ if (status.state === "restarting") {
166
+ return restartIfDue(record, paths, status, policy);
167
+ }
168
+ return scheduleRestart(record, paths, status, policy, new Error("Daemon process exited"));
169
+ }
170
+
171
+ if (!healthDue(status, policy)) return "healthy";
172
+
173
+ try {
174
+ await probeWithRetries(record, paths, policy);
175
+ return "healthy";
176
+ } catch (error) {
177
+ if (!isTimeout(error) && await tryInternalRecovery(record, paths, policy)) return "recovered";
178
+ await writeDaemonStatus(paths, {
179
+ state: "unhealthy",
180
+ lastError: errorRecord("health", error),
181
+ message: error?.message || String(error)
182
+ });
183
+ return scheduleRestart(record, paths, status, policy, error);
184
+ }
185
+ }
@@ -0,0 +1,10 @@
1
+ import { loadConfig } from "../config/config-store.js";
2
+
3
+ export async function loadDaemonPolicy() {
4
+ const config = await loadConfig();
5
+ return config.daemons;
6
+ }
7
+
8
+ export function retryDelay(attempt, { restartBackoffMs, restartBackoffMaxMs }) {
9
+ return Math.min(restartBackoffMaxMs, restartBackoffMs * (2 ** Math.max(0, attempt - 1)));
10
+ }