arisa 4.3.4 → 5.0.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.
Files changed (41) hide show
  1. package/AGENTS.md +18 -17
  2. package/README.md +30 -9
  3. package/package.json +6 -2
  4. package/pnpm-workspace.yaml +1 -0
  5. package/src/core/agent/agent-manager.js +288 -29
  6. package/src/core/agent/auth-flow.js +12 -8
  7. package/src/core/agent/model-selection.js +54 -14
  8. package/src/core/agent/model-speed.js +59 -0
  9. package/src/core/config/config-defaults.js +56 -4
  10. package/src/core/config/config-store.js +5 -1
  11. package/src/core/conversation/conversation-history-store.js +142 -0
  12. package/src/core/tasks/task-store.js +16 -0
  13. package/src/core/tools/daemon-health.js +11 -2
  14. package/src/core/tools/daemon-processes.js +92 -2
  15. package/src/core/tools/daemon-runtime.js +4 -2
  16. package/src/core/tools/ipc-client.js +15 -3
  17. package/src/core/tools/tool-registry.js +27 -0
  18. package/src/index.js +61 -6
  19. package/src/runtime/arisa-capabilities.js +45 -1
  20. package/src/runtime/bootstrap.js +3 -2
  21. package/src/runtime/create-app.js +47 -11
  22. package/src/runtime/doctor.js +307 -0
  23. package/src/runtime/log-viewer.js +165 -0
  24. package/src/runtime/paths.js +4 -1
  25. package/src/runtime/service-manager.js +106 -8
  26. package/src/runtime/tool-process-supervisor.js +107 -10
  27. package/src/transport/telegram/bot.js +533 -99
  28. package/src/transport/telegram/model-picker.js +28 -2
  29. package/test/agent-tool-policy.test.js +26 -1
  30. package/test/auth-flow.test.js +28 -2
  31. package/test/capabilities-security.test.js +37 -0
  32. package/test/context-and-task-bounds.test.js +279 -0
  33. package/test/daemon-runtime.test.js +130 -2
  34. package/test/dependency-warnings.test.js +17 -0
  35. package/test/doctor.test.js +90 -0
  36. package/test/log-viewer.test.js +90 -0
  37. package/test/model-selection.test.js +125 -2
  38. package/test/paths.test.js +8 -0
  39. package/test/pi-compaction.test.js +43 -0
  40. package/test/service-manager.test.js +234 -0
  41. package/test/task-store.test.js +31 -0
@@ -0,0 +1,165 @@
1
+ import { open, readFile, stat } from "node:fs/promises";
2
+ import { cliLogConfig } from "../core/config/config-defaults.js";
3
+ import { ensureArisaHome, serviceLogFile } from "./paths.js";
4
+
5
+ const readChunkSize = 64 * 1024;
6
+
7
+ async function getFileState(logFile) {
8
+ try {
9
+ const details = await stat(logFile);
10
+ return { size: details.size, ino: details.ino };
11
+ } catch (error) {
12
+ if (error?.code === "ENOENT") return { size: 0, ino: null };
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ export async function readRecentLogLines(logFile, lineCount) {
18
+ if (!Number.isSafeInteger(lineCount) || lineCount < 1) {
19
+ throw new Error("Log line count must be a positive integer");
20
+ }
21
+
22
+ const state = await getFileState(logFile);
23
+ if (state.size === 0) return { text: "", endsWithNewline: false, ...state };
24
+
25
+ const handle = await open(logFile, "r");
26
+ const chunks = [];
27
+ let position = state.size;
28
+ let newlineCount = 0;
29
+
30
+ try {
31
+ while (position > 0 && newlineCount <= lineCount) {
32
+ const length = Math.min(readChunkSize, position);
33
+ position -= length;
34
+ const chunk = Buffer.allocUnsafe(length);
35
+ const { bytesRead } = await handle.read(chunk, 0, length, position);
36
+ const content = chunk.subarray(0, bytesRead);
37
+ chunks.unshift(content);
38
+ for (const byte of content) {
39
+ if (byte === 0x0a) newlineCount += 1;
40
+ }
41
+ }
42
+ } finally {
43
+ await handle.close();
44
+ }
45
+
46
+ const content = Buffer.concat(chunks).toString("utf8");
47
+ const endsWithNewline = content.endsWith("\n");
48
+ const lines = content.split("\n");
49
+ if (endsWithNewline) lines.pop();
50
+ return {
51
+ text: lines.slice(-lineCount).join("\n"),
52
+ endsWithNewline,
53
+ ...state
54
+ };
55
+ }
56
+
57
+ async function readAppendedBytes(logFile, position, size) {
58
+ if (size <= position) return "";
59
+ const handle = await open(logFile, "r");
60
+ const chunks = [];
61
+ let cursor = position;
62
+
63
+ try {
64
+ while (cursor < size) {
65
+ const length = Math.min(readChunkSize, size - cursor);
66
+ const chunk = Buffer.allocUnsafe(length);
67
+ const { bytesRead } = await handle.read(chunk, 0, length, cursor);
68
+ if (bytesRead === 0) break;
69
+ chunks.push(chunk.subarray(0, bytesRead));
70
+ cursor += bytesRead;
71
+ }
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+
76
+ return Buffer.concat(chunks).toString("utf8");
77
+ }
78
+
79
+ function waitForNextPoll(intervalMs, signal) {
80
+ return new Promise((resolve) => {
81
+ if (signal?.aborted) {
82
+ resolve();
83
+ return;
84
+ }
85
+ const finish = () => {
86
+ clearTimeout(timer);
87
+ signal?.removeEventListener("abort", finish);
88
+ resolve();
89
+ };
90
+ const timer = setTimeout(finish, intervalMs);
91
+ signal?.addEventListener("abort", finish, { once: true });
92
+ });
93
+ }
94
+
95
+ export async function followLogFile({
96
+ logFile,
97
+ initialSize,
98
+ initialIno,
99
+ write,
100
+ signal,
101
+ pollIntervalMs = cliLogConfig.followPollIntervalMs
102
+ }) {
103
+ let position = initialSize;
104
+ let ino = initialIno;
105
+
106
+ while (!signal?.aborted) {
107
+ await waitForNextPoll(pollIntervalMs, signal);
108
+ if (signal?.aborted) break;
109
+
110
+ const state = await getFileState(logFile);
111
+ if (state.ino !== ino || state.size < position) {
112
+ position = 0;
113
+ ino = state.ino;
114
+ }
115
+ if (state.size === position) continue;
116
+
117
+ try {
118
+ const appended = await readAppendedBytes(logFile, position, state.size);
119
+ if (appended) write(appended);
120
+ position = state.size;
121
+ } catch (error) {
122
+ if (error?.code !== "ENOENT") throw error;
123
+ position = 0;
124
+ ino = null;
125
+ }
126
+ }
127
+ }
128
+
129
+ export async function showServiceLogs({
130
+ version,
131
+ follow = true,
132
+ output = process.stdout,
133
+ signal
134
+ } = {}) {
135
+ if (!version) throw new Error("Arisa version is required");
136
+ await ensureArisaHome();
137
+
138
+ const snapshot = await readRecentLogLines(serviceLogFile, cliLogConfig.recentLines);
139
+ output.write(`Arisa v${version} | Recent logs\n`);
140
+ output.write(`${follow ? "Following new logs; press Ctrl+C to exit." : "Log follow disabled."}\n\n`);
141
+ if (snapshot.text) {
142
+ output.write(snapshot.text);
143
+ if (snapshot.endsWithNewline) output.write("\n");
144
+ } else {
145
+ output.write("No logs yet.\n");
146
+ }
147
+
148
+ if (!follow) return;
149
+ await followLogFile({
150
+ logFile: serviceLogFile,
151
+ initialSize: snapshot.size,
152
+ initialIno: snapshot.ino,
153
+ write: (content) => output.write(content),
154
+ signal
155
+ });
156
+ }
157
+
158
+ export async function readPackageVersion() {
159
+ const packageFile = new URL("../../package.json", import.meta.url);
160
+ const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
161
+ if (typeof packageJson.version !== "string" || !packageJson.version) {
162
+ throw new Error("Arisa package version is missing");
163
+ }
164
+ return packageJson.version;
165
+ }
@@ -39,6 +39,10 @@ export function getChatArtifactsIndexFile(chatId) {
39
39
  return path.join(getChatDir(chatId), "state", "artifacts.json");
40
40
  }
41
41
 
42
+ export function getChatConversationHistoryFile(chatId) {
43
+ return path.join(getChatDir(chatId), "state", "conversation.jsonl");
44
+ }
45
+
42
46
  export function getChatToolStateDir(chatId, toolName) {
43
47
  return path.join(getChatDir(chatId), "state", "tools", toolName);
44
48
  }
@@ -119,4 +123,3 @@ export async function ensureArisaHome() {
119
123
  await mkdir(toolsDir, { recursive: true });
120
124
  await mkdir(chatsDir, { recursive: true });
121
125
  }
122
-
@@ -4,14 +4,24 @@ import process from "node:process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ensureArisaHome, serviceLogFile, servicePidFile } from "./paths.js";
6
6
 
7
- const entryFile = fileURLToPath(new URL("../index.js", import.meta.url));
7
+ export const serviceEntryFile = fileURLToPath(new URL("../index.js", import.meta.url));
8
8
 
9
9
  function isProcessRunning(pid) {
10
10
  try {
11
11
  process.kill(pid, 0);
12
12
  return true;
13
- } catch {
14
- return false;
13
+ } catch (error) {
14
+ return error?.code !== "ESRCH";
15
+ }
16
+ }
17
+
18
+ function sleep(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+
22
+ function requirePositiveTiming(value, name) {
23
+ if (!Number.isFinite(value) || value <= 0) {
24
+ throw new Error(`Service restart requires a positive ${name}`);
15
25
  }
16
26
  }
17
27
 
@@ -19,7 +29,7 @@ async function readPid() {
19
29
  try {
20
30
  const raw = await readFile(servicePidFile, "utf8");
21
31
  const pid = Number.parseInt(raw.trim(), 10);
22
- return Number.isFinite(pid) ? pid : null;
32
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
23
33
  } catch {
24
34
  return null;
25
35
  }
@@ -44,7 +54,7 @@ export async function startService({ verbose = true, cliArgs = [] } = {}) {
44
54
  }
45
55
 
46
56
  const logHandle = await open(serviceLogFile, "a");
47
- const args = [entryFile, "--service-runner", ...cliArgs];
57
+ const args = [serviceEntryFile, "--service-runner", ...cliArgs];
48
58
  if (!verbose) args.push("--silent");
49
59
 
50
60
  const child = spawn(process.execPath, args, {
@@ -58,6 +68,36 @@ export async function startService({ verbose = true, cliArgs = [] } = {}) {
58
68
  return { ok: true, pid: child.pid, logFile: serviceLogFile };
59
69
  }
60
70
 
71
+ export async function handoffServiceRestart({ verbose = true, cliArgs = [] } = {}, {
72
+ ensureHome = ensureArisaHome,
73
+ getStatus = getServiceStatus,
74
+ openLog = open,
75
+ spawnProcess = spawn,
76
+ environment = process.env,
77
+ currentPid = process.pid
78
+ } = {}) {
79
+ await ensureHome();
80
+ const status = await getStatus();
81
+ if (!status.running || status.pid !== currentPid) {
82
+ throw new Error("Service restart handoff requires the active background service process");
83
+ }
84
+ const logHandle = await openLog(serviceLogFile, "a");
85
+ const args = [serviceEntryFile, "restart", ...cliArgs];
86
+ if (!verbose) args.push("--silent");
87
+
88
+ try {
89
+ const child = spawnProcess(process.execPath, args, {
90
+ detached: true,
91
+ stdio: ["ignore", logHandle.fd, logHandle.fd],
92
+ env: environment
93
+ });
94
+ child.unref();
95
+ return { ok: true, pid: child.pid, logFile: serviceLogFile };
96
+ } finally {
97
+ await logHandle.close();
98
+ }
99
+ }
100
+
61
101
  export async function stopService() {
62
102
  const status = await getServiceStatus();
63
103
  if (!status.running) {
@@ -66,14 +106,72 @@ export async function stopService() {
66
106
 
67
107
  try {
68
108
  process.kill(status.pid, "SIGTERM");
69
- } catch {
70
- await rm(servicePidFile, { force: true }).catch(() => {});
71
- return { ok: false, reason: "not-running", pid: status.pid };
109
+ } catch (error) {
110
+ if (error?.code === "ESRCH") {
111
+ await rm(servicePidFile, { force: true }).catch(() => {});
112
+ return { ok: false, reason: "not-running", pid: status.pid };
113
+ }
114
+ throw error;
72
115
  }
73
116
 
74
117
  return { ok: true, pid: status.pid };
75
118
  }
76
119
 
120
+ export async function waitForServiceStop({
121
+ pid,
122
+ timeoutMs,
123
+ pollIntervalMs,
124
+ getStatus = getServiceStatus,
125
+ wait = sleep
126
+ }) {
127
+ requirePositiveTiming(timeoutMs, "shutdownTimeoutMs");
128
+ requirePositiveTiming(pollIntervalMs, "shutdownPollIntervalMs");
129
+
130
+ const startedAt = Date.now();
131
+ while (Date.now() - startedAt < timeoutMs) {
132
+ const status = await getStatus();
133
+ if (!status.running || status.pid !== pid) return;
134
+ await wait(pollIntervalMs);
135
+ }
136
+
137
+ throw new Error(`Arisa process ${pid} did not stop within ${timeoutMs}ms`);
138
+ }
139
+
140
+ export async function restartService({
141
+ verbose = true,
142
+ cliArgs = [],
143
+ shutdownTimeoutMs,
144
+ shutdownPollIntervalMs
145
+ } = {}, {
146
+ stop = stopService,
147
+ getStatus = getServiceStatus,
148
+ start = startService,
149
+ sleep: wait = sleep
150
+ } = {}) {
151
+ requirePositiveTiming(shutdownTimeoutMs, "shutdownTimeoutMs");
152
+ requirePositiveTiming(shutdownPollIntervalMs, "shutdownPollIntervalMs");
153
+
154
+ const stopped = await stop();
155
+ if (!stopped.ok && stopped.reason !== "not-running") return stopped;
156
+
157
+ if (stopped.ok) {
158
+ await waitForServiceStop({
159
+ pid: stopped.pid,
160
+ timeoutMs: shutdownTimeoutMs,
161
+ pollIntervalMs: shutdownPollIntervalMs,
162
+ getStatus,
163
+ wait
164
+ });
165
+ }
166
+
167
+ const started = await start({ verbose, cliArgs });
168
+ return {
169
+ ...started,
170
+ previousPid: stopped.ok ? stopped.pid : null,
171
+ wasRunning: stopped.ok
172
+ };
173
+ }
174
+
77
175
  export async function unregisterServiceProcess() {
78
176
  await rm(servicePidFile, { force: true }).catch(() => {});
79
177
  }
@@ -1,6 +1,12 @@
1
1
  import { access } from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import { superviseDaemon } from "../core/tools/daemon-health.js";
3
- import { listRegisteredDaemons } from "../core/tools/daemon-processes.js";
4
+ import {
5
+ daemonPaths,
6
+ listRegisteredDaemons,
7
+ readDaemonDiagnostic,
8
+ writeDaemonStatus
9
+ } from "../core/tools/daemon-processes.js";
4
10
  import { loadDaemonPolicy } from "../core/tools/daemon-policy.js";
5
11
  import { ensureArisaHome } from "./paths.js";
6
12
 
@@ -13,41 +19,127 @@ async function fileExists(file) {
13
19
  }
14
20
  }
15
21
 
16
- export function createToolProcessSupervisor({ logger, policy } = {}) {
22
+ function oneLine(value) {
23
+ return String(value || "").replace(/\s+/g, " ").trim();
24
+ }
25
+
26
+ export function formatDaemonOutcome(record, outcome, diagnostic, policy) {
27
+ const details = [`${record.toolName} (${record.instanceId || "global"}): ${outcome}`];
28
+ if (diagnostic.lastError?.message) {
29
+ details.push(`error[${diagnostic.lastError.phase || "unknown"}]=${oneLine(diagnostic.lastError.message)}`);
30
+ if (diagnostic.lastError.code) details.push(`code=${diagnostic.lastError.code}`);
31
+ } else if (diagnostic.message) {
32
+ details.push(`message=${oneLine(diagnostic.message)}`);
33
+ }
34
+ if (diagnostic.restart.attempts > 0) {
35
+ details.push(`restarts=${diagnostic.restart.attempts}/${policy.restartLimit}`);
36
+ }
37
+ if (diagnostic.restart.nextAt) details.push(`next=${diagnostic.restart.nextAt}`);
38
+ details.push(`action=${diagnostic.disposition}`);
39
+ if (diagnostic.disposition === "requires-attention") details.push(`log=${diagnostic.logFile}`);
40
+ return details.join(" | ");
41
+ }
42
+
43
+ function validateRegistration(record, toolRegistry) {
44
+ if (!toolRegistry) return { valid: true, record };
45
+ const tool = toolRegistry.get(record.toolName);
46
+ if (!tool) return { valid: false, reason: "tool is no longer installed" };
47
+ if (!tool.daemon) return { valid: false, reason: "tool no longer declares a daemon" };
48
+ const expectedScope = tool.daemon.scope === "chat" ? "chat" : "global";
49
+ if (record.scope?.type !== expectedScope) {
50
+ return { valid: false, reason: `registered ${record.scope?.type || "unknown"} scope does not match manifest ${expectedScope} scope` };
51
+ }
52
+ if (path.resolve(record.entryPath) !== path.resolve(tool.entry)) {
53
+ return { valid: false, reason: "registered entry does not match the installed tool" };
54
+ }
55
+ return {
56
+ valid: true,
57
+ record: { ...record, autoStart: Boolean(tool.daemon.autoStart) }
58
+ };
59
+ }
60
+
61
+ export function createToolProcessSupervisor({ logger, policy, toolRegistry } = {}) {
17
62
  let running = false;
18
63
  let timer = null;
19
64
  let reconciliation = null;
20
65
  let daemonPolicy = policy;
66
+ const reportedDiagnostics = new Map();
21
67
 
22
68
  function reportLoopError(error) {
23
69
  logger?.error?.("tools", `daemon supervisor loop failed: ${error?.message || error}`);
24
70
  }
25
71
 
26
- async function reconcileDaemons() {
72
+ async function reconcileDaemons({ repairFailed = false } = {}) {
27
73
  await ensureArisaHome();
28
- for (const record of await listRegisteredDaemons()) {
74
+ const results = [];
75
+ for (const registeredRecord of await listRegisteredDaemons()) {
76
+ const validation = validateRegistration(registeredRecord, toolRegistry);
77
+ if (!validation.valid) {
78
+ logger?.log("tools", `skipping stale daemon registration ${registeredRecord.toolName} (${registeredRecord.instanceId || "global"}): ${validation.reason}`);
79
+ results.push({
80
+ record: registeredRecord,
81
+ outcome: "stale-registration",
82
+ reason: validation.reason,
83
+ diagnostic: await readDaemonDiagnostic(registeredRecord)
84
+ });
85
+ continue;
86
+ }
87
+ const record = validation.record;
29
88
  if (!(await fileExists(record.entryPath))) {
30
89
  logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
90
+ results.push({ record, outcome: "missing-entry", diagnostic: await readDaemonDiagnostic(record) });
31
91
  continue;
32
92
  }
33
93
  try {
94
+ const current = await readDaemonDiagnostic(record);
95
+ if (repairFailed && current.state === "failed") {
96
+ await writeDaemonStatus(daemonPaths(record), {
97
+ state: "restarting",
98
+ pid: null,
99
+ heartbeatAt: null,
100
+ restartAttempts: 0,
101
+ restartRequested: true,
102
+ nextRestartAt: null,
103
+ message: "Manual repair requested by /doctor"
104
+ });
105
+ }
34
106
  const outcome = await superviseDaemon(record, daemonPolicy);
35
- if (outcome !== "healthy") {
36
- logger?.log("tools", `${record.toolName} (${record.instanceId || "global"}): ${outcome}`);
107
+ const key = `${record.toolName}:${record.instanceId || "global"}`;
108
+ if (outcome === "healthy") {
109
+ reportedDiagnostics.delete(key);
110
+ results.push({ record, outcome, diagnostic: await readDaemonDiagnostic(record) });
111
+ continue;
37
112
  }
113
+ const diagnostic = await readDaemonDiagnostic(record);
114
+ results.push({ record, outcome, diagnostic });
115
+ const message = formatDaemonOutcome(record, outcome, diagnostic, daemonPolicy);
116
+ if (reportedDiagnostics.get(key) === message) continue;
117
+ reportedDiagnostics.set(key, message);
118
+ logger?.log("tools", message);
38
119
  } catch (error) {
39
120
  logger?.error?.("tools", `daemon supervision failed for ${record.toolName}: ${error?.message || error}`);
121
+ results.push({ record, outcome: "error", error: error?.message || String(error) });
40
122
  }
41
123
  }
124
+ return results;
125
+ }
126
+
127
+ async function runReconciliation(options) {
128
+ while (reconciliation) await reconciliation.catch(() => {});
129
+ const current = reconcileDaemons(options);
130
+ reconciliation = current;
131
+ try {
132
+ return await current;
133
+ } finally {
134
+ if (reconciliation === current) reconciliation = null;
135
+ }
42
136
  }
43
137
 
44
138
  async function runLoop() {
45
- if (!running || reconciliation) return;
46
- reconciliation = reconcileDaemons();
139
+ if (!running) return;
47
140
  try {
48
- await reconciliation;
141
+ await runReconciliation();
49
142
  } finally {
50
- reconciliation = null;
51
143
  if (running) {
52
144
  timer = setTimeout(() => {
53
145
  runLoop().catch(reportLoopError);
@@ -70,6 +162,11 @@ export function createToolProcessSupervisor({ logger, policy } = {}) {
70
162
  clearTimeout(timer);
71
163
  timer = null;
72
164
  await reconciliation?.catch(() => {});
165
+ },
166
+
167
+ async repair() {
168
+ daemonPolicy ||= await loadDaemonPolicy();
169
+ return runReconciliation({ repairFailed: true });
73
170
  }
74
171
  };
75
172
  }