arisa 4.2.8 → 4.3.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.
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.2",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,6 +3,7 @@ import { readFile, stat, unlink } from "node:fs/promises";
3
3
  import { createAgentSession, DefaultResourceLoader, SessionManager, defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
+ import { resolveChatModelSelection } from "./model-selection.js";
6
7
  import { appendArisaAgentsFile, arisaAgentsFile, arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
7
8
  import { withTimeout } from "./prompt-timeout.js";
8
9
  import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
@@ -126,9 +127,9 @@ export class AgentManager {
126
127
  this.sessions.delete(String(chatId));
127
128
  }
128
129
 
129
- createSessionManager(chatId, workspaceDir = arisaInstallDir) {
130
+ createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
130
131
  const sessionKey = String(chatId);
131
- const sessionDir = getChatPiSessionsDir(sessionKey);
132
+ const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
132
133
  if (this.pendingNewSessions.has(sessionKey)) {
133
134
  this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
134
135
  return { sessionManager: SessionManager.create(workspaceDir, sessionDir), isNewSession: true };
@@ -165,14 +166,16 @@ export class AgentManager {
165
166
 
166
167
  async getSessionContext(chatId, telegram) {
167
168
  const sessionKey = String(chatId);
168
- const effectiveModelId = this.config.pi.model;
169
+ const modelSelection = resolveChatModelSelection(this.config, sessionKey);
170
+ const effectiveModelId = modelSelection.model;
171
+ const effectiveModelKey = `${modelSelection.provider}/${effectiveModelId}@${modelSelection.sessionRevision}`;
169
172
  if (this.sessions.has(sessionKey)) {
170
173
  const existing = this.sessions.get(sessionKey);
171
- if (existing?.modelId === effectiveModelId) {
174
+ if (existing?.modelKey === effectiveModelKey) {
172
175
  this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
173
176
  return existing;
174
177
  }
175
- this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelId || "unknown"} -> ${effectiveModelId}; recreating session`);
178
+ this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
176
179
  this.sessions.delete(sessionKey);
177
180
  this.pendingNewSessions.add(sessionKey);
178
181
  }
@@ -192,7 +195,11 @@ export class AgentManager {
192
195
  customToolNames: [...arisaToolNames, "system_shell"]
193
196
  });
194
197
  await assertDirectory(policy.workspaceDir, "pi.workspaceDir");
195
- const { sessionManager, isNewSession } = this.createSessionManager(sessionKey, policy.workspaceDir);
198
+ const { sessionManager, isNewSession } = this.createSessionManager(
199
+ sessionKey,
200
+ policy.workspaceDir,
201
+ modelSelection.sessionRevision
202
+ );
196
203
  const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
197
204
  this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId}`);
198
205
  const customTools = [
@@ -224,7 +231,7 @@ export class AgentManager {
224
231
  })}`);
225
232
  }
226
233
 
227
- const ctx = { session, modelId: effectiveModelId };
234
+ const ctx = { session, modelId: effectiveModelId, modelKey: effectiveModelKey };
228
235
  this.sessions.set(sessionKey, ctx);
229
236
  if (isNewSession) {
230
237
  this.pendingNewSessions.delete(sessionKey);
@@ -0,0 +1,36 @@
1
+ function chatKey(chatId) {
2
+ return String(chatId);
3
+ }
4
+
5
+ export function resolveChatModelSelection(config, chatId) {
6
+ const selection = config.pi.chatModels?.[chatKey(chatId)];
7
+ if (!selection || selection.provider !== config.pi.provider) {
8
+ return {
9
+ provider: config.pi.provider,
10
+ model: config.pi.model,
11
+ sessionRevision: 0
12
+ };
13
+ }
14
+ if (!Number.isSafeInteger(selection.sessionRevision) || selection.sessionRevision <= 0) {
15
+ throw new Error(`Invalid model session revision for chat ${chatId}`);
16
+ }
17
+ return selection;
18
+ }
19
+
20
+ export function resolveChatModel(config, chatId) {
21
+ return resolveChatModelSelection(config, chatId).model;
22
+ }
23
+
24
+ export function selectChatModel(config, chatId, model) {
25
+ if (model.provider !== config.pi.provider) {
26
+ throw new Error(`Cannot select model from provider ${model.provider}; active provider is ${config.pi.provider}`);
27
+ }
28
+ config.pi.chatModels ||= {};
29
+ const key = chatKey(chatId);
30
+ const sessionRevision = (config.pi.chatModels[key]?.sessionRevision || 0) + 1;
31
+ config.pi.chatModels[key] = {
32
+ provider: model.provider,
33
+ model: model.id,
34
+ sessionRevision
35
+ };
36
+ }
@@ -53,6 +53,14 @@ export function listProviderModels(provider, runtime = createPiRuntime()) {
53
53
  .sort((a, b) => compareText(a.name || a.id, b.name || b.id));
54
54
  }
55
55
 
56
+ export function formatPiModelOption(model) {
57
+ const capabilities = [
58
+ model.reasoning ? "reasoning" : null,
59
+ model.input?.includes("image") ? "image" : null
60
+ ].filter(Boolean).join(", ");
61
+ return capabilities ? `${model.id} [${capabilities}]` : model.id;
62
+ }
63
+
56
64
  export function findPiModel({ provider, model, apiKey } = {}) {
57
65
  const runtime = createPiRuntime({ provider, apiKey });
58
66
  return {
@@ -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,33 @@
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 const telegramConfigDefaults = Object.freeze({
18
+ modelPickerPageSize: 8
19
+ });
20
+
21
+ export function applyConfigDefaults(config) {
22
+ return {
23
+ ...config,
24
+ telegram: {
25
+ ...telegramConfigDefaults,
26
+ ...(config.telegram || {})
27
+ },
28
+ daemons: {
29
+ ...daemonConfigDefaults,
30
+ ...(config.daemons || {})
31
+ }
32
+ };
33
+ }
@@ -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
+ }