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
package/src/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
4
- import { createApp } from "./runtime/create-app.js";
4
+ import { applyRuntimeOverrides, createApp } from "./runtime/create-app.js";
5
+ import { loadConfig } from "./core/config/config-store.js";
5
6
  import { createLogger } from "./runtime/logger.js";
6
- import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
7
+ import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
7
8
  import { flushArisaHome } from "./runtime/flush.js";
9
+ import { readPackageVersion, showServiceLogs } from "./runtime/log-viewer.js";
8
10
  import { arisaPackageDir } from "./runtime/paths.js";
9
11
 
10
12
  process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
@@ -69,17 +71,18 @@ function toNestedOverrides(nestedFlags) {
69
71
 
70
72
  function toServiceRunnerArgs(nestedFlags) {
71
73
  const args = [];
72
- const serviceSafePiFlags = [
74
+ const serviceSafeAgentFlags = [
73
75
  "pi.provider",
74
76
  "pi.model",
75
77
  "pi.workspaceDir",
76
78
  "pi.tools",
77
79
  "pi.excludeTools",
78
80
  "pi.shellPath",
79
- "pi.shellTimeoutMs"
81
+ "pi.shellTimeoutMs",
82
+ "pi.speed"
80
83
  ];
81
84
 
82
- for (const flag of serviceSafePiFlags) {
85
+ for (const flag of serviceSafeAgentFlags) {
83
86
  if (nestedFlags[flag]) {
84
87
  args.push(`--${flag}`, nestedFlags[flag]);
85
88
  }
@@ -111,12 +114,21 @@ process.once("SIGINT", () => {
111
114
  });
112
115
 
113
116
  async function startRuntimeApp() {
114
- const app = await createApp({ logger, runtimeOverrides });
117
+ const app = await createApp({
118
+ logger,
119
+ runtimeOverrides,
120
+ requestRestart: () => handoffServiceRestart({
121
+ verbose,
122
+ cliArgs: toServiceRunnerArgs(cli.nestedFlags)
123
+ })
124
+ });
115
125
  activeApp = app;
116
126
  await app.start();
117
127
  }
118
128
 
119
129
  async function startBackgroundService() {
130
+ const persistedConfig = await loadConfig();
131
+ applyRuntimeOverrides(persistedConfig, runtimeOverrides);
120
132
  const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
121
133
  if (!result.ok) {
122
134
  console.log(`Arisa is already running in background (pid ${result.pid}).`);
@@ -127,6 +139,31 @@ async function startBackgroundService() {
127
139
  return result;
128
140
  }
129
141
 
142
+ async function restartBackgroundService() {
143
+ const persistedConfig = await loadConfig();
144
+ applyRuntimeOverrides(persistedConfig, runtimeOverrides);
145
+ const result = await restartService({
146
+ verbose,
147
+ cliArgs: toServiceRunnerArgs(cli.nestedFlags),
148
+ shutdownTimeoutMs: persistedConfig.service.shutdownTimeoutMs,
149
+ shutdownPollIntervalMs: persistedConfig.service.shutdownPollIntervalMs
150
+ });
151
+ if (!result.ok) {
152
+ if (result.reason === "already-running") {
153
+ console.log(`Arisa could not be restarted because another instance is running (pid ${result.pid}).`);
154
+ return result;
155
+ }
156
+ throw new Error(`Arisa could not be restarted: ${result.reason || "unknown service error"}`);
157
+ }
158
+ if (result.wasRunning) {
159
+ console.log(`Arisa restarted in background (pid ${result.pid}; previous pid ${result.previousPid}).`);
160
+ } else {
161
+ console.log(`Arisa started in background (pid ${result.pid}); it was not running.`);
162
+ }
163
+ console.log(`Log file: ${result.logFile}`);
164
+ return result;
165
+ }
166
+
130
167
  async function runForeground() {
131
168
  const hasRuntimePiOverrides = Boolean(
132
169
  runtimeOverrides?.pi?.model
@@ -197,6 +234,16 @@ async function main() {
197
234
  return;
198
235
  }
199
236
 
237
+ if (command === "restart") {
238
+ const bootstrapResult = await bootstrapIfNeeded({ force: forceBootstrap });
239
+ if (bootstrapResult.configCreated && bootstrapResult.viaTelegram && !bootstrapResult.startInBackground) {
240
+ console.log("Config saved. Arisa was not started in background.");
241
+ return;
242
+ }
243
+ await restartBackgroundService();
244
+ return;
245
+ }
246
+
200
247
  if (command === "status") {
201
248
  const status = await getServiceStatus();
202
249
  if (!status.running) {
@@ -207,6 +254,14 @@ async function main() {
207
254
  return;
208
255
  }
209
256
 
257
+ if (command === "log") {
258
+ await showServiceLogs({
259
+ version: await readPackageVersion(),
260
+ follow: !cli.flags["no-follow"]
261
+ });
262
+ return;
263
+ }
264
+
210
265
  if (command === "flush") {
211
266
  const status = await getServiceStatus();
212
267
  if (status.running) {
@@ -41,10 +41,35 @@ function normalizeLimit(limit) {
41
41
  return Math.min(value, 100);
42
42
  }
43
43
 
44
- export function createArisaCapabilities({ artifactStore, taskStore, agentManager } = {}) {
44
+ export function createArisaCapabilities({ artifactStore, taskStore, toolRegistry, agentManager } = {}) {
45
45
  async function dispatch({ method, toolName, chatId = null, params = {} } = {}) {
46
46
  const scopedToolName = requireToolName(toolName);
47
47
 
48
+ if (method === "tools.list") {
49
+ await toolRegistry.load();
50
+ return toolRegistry.listWithRuntime(chatId);
51
+ }
52
+
53
+ if (method === "tools.help") {
54
+ await toolRegistry.load();
55
+ return toolRegistry.help(requireString(params.name, "name"));
56
+ }
57
+
58
+ if (method === "tools.skills") {
59
+ await toolRegistry.load();
60
+ return toolRegistry.resolveSkills(requireString(params.name, "name"));
61
+ }
62
+
63
+ if (method === "tools.setConfig") {
64
+ await toolRegistry.load();
65
+ return toolRegistry.setConfig(
66
+ requireString(params.name, "name"),
67
+ requireString(params.field, "field"),
68
+ requireString(params.value, "value"),
69
+ requireChatId(chatId, method)
70
+ );
71
+ }
72
+
48
73
  if (method === "tools.run") {
49
74
  if (!agentManager?.runTool) {
50
75
  throw new Error("tools.run requires agentManager");
@@ -90,6 +115,20 @@ export function createArisaCapabilities({ artifactStore, taskStore, agentManager
90
115
  return artifactStore.forChat(scopedChatId).get(requireString(params.artifactId, "artifactId"));
91
116
  }
92
117
 
118
+ if (method === "artifacts.deliver") {
119
+ const scopedChatId = requireChatId(chatId, method);
120
+ const artifactId = requireString(params.artifactId, "artifactId");
121
+ const artifact = await artifactStore.forChat(scopedChatId).get(artifactId);
122
+ if (!artifact?.path) throw new Error(`Artifact not found or has no file: ${artifactId}`);
123
+ if (!agentManager?.deliverArtifact) throw new Error("artifact delivery is unavailable");
124
+ return agentManager.deliverArtifact({
125
+ chatId: scopedChatId,
126
+ artifact,
127
+ caption: params.caption,
128
+ method: params.method
129
+ });
130
+ }
131
+
93
132
  if (method === "tasks.add") {
94
133
  const scopedChatId = requireChatId(chatId, method);
95
134
  return taskStore.add(params.task || {}, {
@@ -118,6 +157,11 @@ export function createArisaCapabilities({ artifactStore, taskStore, agentManager
118
157
  return taskStore.cancel(taskId);
119
158
  }
120
159
 
160
+ if (method === "tasks.cancelAll") {
161
+ const scopedChatId = requireChatId(chatId, method);
162
+ return taskStore.cancelAll({ chatId: scopedChatId });
163
+ }
164
+
121
165
  if (method === "agent.enqueueEvent") {
122
166
  const scopedChatId = requireChatId(chatId, method);
123
167
  return taskStore.add({
@@ -7,6 +7,7 @@ import { Bot } from "grammy";
7
7
  import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
8
8
  import { createPiRuntime, formatPiModelOption, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
9
  import { applyConfigDefaults, telegramConfigDefaults } from "../core/config/config-defaults.js";
10
+ import { prepareConfigForSave } from "../core/config/config-store.js";
10
11
  import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
11
12
  import { buildPagedInlineKeyboard } from "../transport/telegram/paged-inline-keyboard.js";
12
13
  import { configFile, ensureArisaHome } from "./paths.js";
@@ -29,7 +30,7 @@ async function exists(file) {
29
30
  }
30
31
  }
31
32
 
32
- function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
33
+ export function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
33
34
  return applyConfigDefaults({
34
35
  telegram: {
35
36
  token: telegramApiKey,
@@ -602,7 +603,7 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
602
603
  result = await collectCliBootstrapChoices({ telegramApiKey, rl, ask });
603
604
  }
604
605
 
605
- await writeFile(configFile, `${JSON.stringify(result.config, null, 2)}\n`, "utf8");
606
+ await writeFile(configFile, `${JSON.stringify(prepareConfigForSave(result.config), null, 2)}\n`, "utf8");
606
607
  console.log(`\nConfig saved to ${configFile}\n`);
607
608
  return {
608
609
  configCreated: true,
@@ -8,6 +8,9 @@ import { createTelegramBot } from "../transport/telegram/bot.js";
8
8
  import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
9
9
  import { createArisaCapabilities } from "./arisa-capabilities.js";
10
10
  import { createIpcServer } from "./ipc/ipc-server.js";
11
+ import { getAgentConfig } from "../core/agent/model-selection.js";
12
+ import { normalizeModelSpeed } from "../core/agent/model-speed.js";
13
+ import { runDoctor } from "./doctor.js";
11
14
 
12
15
  function normalizeString(value) {
13
16
  const text = String(value ?? "").trim();
@@ -51,10 +54,14 @@ function splitModelOverride(modelOverride) {
51
54
  }
52
55
 
53
56
  export function applyRuntimeOverrides(config, runtimeOverrides) {
57
+ const unsupportedNamespaces = Object.keys(runtimeOverrides || {}).filter((name) => name !== "pi");
58
+ if (unsupportedNamespaces.length) {
59
+ throw new Error(`Unsupported runtime override namespace: ${unsupportedNamespaces.join(", ")}`);
60
+ }
54
61
  const piRuntimeOverrides = runtimeOverrides?.pi || {};
55
62
  const pi = {};
56
- const providerOverride = normalizeString(runtimeOverrides?.pi?.provider);
57
- const modelOverride = normalizeString(runtimeOverrides?.pi?.model);
63
+ const providerOverride = normalizeString(piRuntimeOverrides.provider);
64
+ const modelOverride = normalizeString(piRuntimeOverrides.model);
58
65
 
59
66
  if (providerOverride || modelOverride) {
60
67
  const splitOverride = modelOverride ? splitModelOverride(modelOverride) : null;
@@ -77,6 +84,7 @@ export function applyRuntimeOverrides(config, runtimeOverrides) {
77
84
 
78
85
  const shellTimeoutMs = normalizePositiveInteger(piRuntimeOverrides.shellTimeoutMs);
79
86
  if (shellTimeoutMs) pi.shellTimeoutMs = shellTimeoutMs;
87
+ if (piRuntimeOverrides.speed !== undefined) pi.speed = normalizeModelSpeed(piRuntimeOverrides.speed);
80
88
 
81
89
  if (!Object.keys(pi).length) return config;
82
90
 
@@ -89,32 +97,55 @@ export function applyRuntimeOverrides(config, runtimeOverrides) {
89
97
  };
90
98
  }
91
99
 
92
- export async function createApp({ logger, runtimeOverrides } = {}) {
100
+ export async function createApp({ logger, runtimeOverrides, requestRestart } = {}) {
101
+ if (typeof requestRestart !== "function") {
102
+ throw new Error("createApp requires a service restart handoff");
103
+ }
93
104
  logger?.log("app", "loading config");
94
105
  const persistedConfig = await loadConfig();
95
- const config = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
96
- if (config.pi.provider !== persistedConfig.pi.provider || config.pi.model !== persistedConfig.pi.model) {
97
- logger?.log("app", `applying runtime model override: ${persistedConfig.pi.provider}/${persistedConfig.pi.model} -> ${config.pi.provider}/${config.pi.model}`);
106
+ const overriddenConfig = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
107
+ const config = overriddenConfig;
108
+ const activeConfig = getAgentConfig(config);
109
+ const persistedActiveConfig = getAgentConfig(persistedConfig);
110
+ if (activeConfig.provider !== persistedActiveConfig.provider || activeConfig.model !== persistedActiveConfig.model) {
111
+ logger?.log("app", `applying runtime model override: ${persistedActiveConfig.provider}/${persistedActiveConfig.model} -> ${activeConfig.provider}/${activeConfig.model}`);
98
112
  }
99
113
 
100
114
  const artifactStore = new ArtifactStore();
101
- const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons });
102
115
  const toolRegistry = new ToolRegistry({ logger });
116
+ const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons, toolRegistry });
103
117
  const taskStore = new TaskStore();
104
118
  await toolRegistry.load();
105
119
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
106
120
 
107
121
  const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
108
- const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore, agentManager });
122
+ const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore, toolRegistry, agentManager });
109
123
  const ipcServer = createIpcServer({ capabilities: arisaCapabilities, logger });
110
- const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger });
124
+ const bot = await createTelegramBot({
125
+ config,
126
+ artifactStore,
127
+ toolRegistry,
128
+ taskStore,
129
+ agentManager,
130
+ saveConfig,
131
+ updateConfig,
132
+ doctor: () => runDoctor({
133
+ agentManager,
134
+ toolProcessSupervisor,
135
+ daemonPolicy: config.daemons,
136
+ doctorPolicy: config.doctor,
137
+ logger
138
+ }),
139
+ requestRestart,
140
+ logger
141
+ });
111
142
 
112
143
  return {
113
144
  async start() {
114
- logger?.log("app", `validating Pi model ${config.pi.provider}/${config.pi.model}`);
145
+ logger?.log("app", `validating Pi model ${activeConfig.provider}/${activeConfig.model}`);
115
146
  let skipAgentStartupPrompts = false;
116
147
  try {
117
- await agentManager.validatePiAgent();
148
+ await agentManager.validateAgent();
118
149
  } catch (error) {
119
150
  const issue = getPiAuthIssue(error);
120
151
  if (!issue) {
@@ -129,6 +160,10 @@ export async function createApp({ logger, runtimeOverrides } = {}) {
129
160
  try {
130
161
  await ipcServer.start();
131
162
  ipcStarted = true;
163
+ const recoveredTasks = await taskStore.recoverInterrupted();
164
+ if (recoveredTasks.length) {
165
+ logger?.log("tasks", `recovered ${recoveredTasks.length} interrupted task(s)`);
166
+ }
132
167
  await toolProcessSupervisor.start();
133
168
  supervisorStarted = true;
134
169
  logger?.log("app", "starting Telegram bot");
@@ -142,6 +177,7 @@ export async function createApp({ logger, runtimeOverrides } = {}) {
142
177
 
143
178
  async stop() {
144
179
  await bot.stop?.();
180
+ await agentManager.close();
145
181
  await toolProcessSupervisor.stop();
146
182
  await ipcServer.stop();
147
183
  }
@@ -0,0 +1,307 @@
1
+ import { execFile } from "node:child_process";
2
+ import process from "node:process";
3
+ import { promisify } from "node:util";
4
+ import { stopManagedDaemon, unregisterManagedDaemon } from "../core/tools/daemon-processes.js";
5
+ import { getServiceStatus, serviceEntryFile } from "./service-manager.js";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ function parsePosixProcesses(output) {
10
+ return String(output || "")
11
+ .split("\n")
12
+ .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
13
+ .filter(Boolean)
14
+ .map((match) => ({ pid: Number(match[1]), command: match[2] }));
15
+ }
16
+
17
+ function parseWindowsProcesses(output) {
18
+ const parsed = JSON.parse(output || "[]");
19
+ const records = Array.isArray(parsed) ? parsed : [parsed];
20
+ return records
21
+ .filter((record) => record?.ProcessId && record?.CommandLine)
22
+ .map((record) => ({ pid: Number(record.ProcessId), command: String(record.CommandLine) }));
23
+ }
24
+
25
+ export async function listSystemProcesses({ timeoutMs, platform = process.platform } = {}) {
26
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
27
+ throw new Error("Doctor process inspection requires a positive timeoutMs");
28
+ }
29
+ if (platform === "win32") {
30
+ const { stdout } = await execFileAsync("powershell.exe", [
31
+ "-NoProfile",
32
+ "-Command",
33
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
34
+ ], { timeout: timeoutMs, windowsHide: true });
35
+ return parseWindowsProcesses(stdout);
36
+ }
37
+ const { stdout } = await execFileAsync("ps", ["-ww", "-axo", "pid=,command="], { timeout: timeoutMs });
38
+ return parsePosixProcesses(stdout);
39
+ }
40
+
41
+ function isArisaServiceProcess(record) {
42
+ return record.command.includes(serviceEntryFile)
43
+ && /(?:^|\s)--service-runner(?:\s|$)/.test(record.command);
44
+ }
45
+
46
+ function isDaemonProcess(record, daemon) {
47
+ return record.command.includes(daemon.entryPath)
48
+ && /(?:^|\s)daemon(?:\s|$)/.test(record.command);
49
+ }
50
+
51
+ function sleep(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
54
+
55
+ function isAlive(pid) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export async function terminateProcess(pid, { forceAfterMs } = {}) {
65
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
66
+ throw new Error(`Refusing to terminate invalid doctor target: ${pid}`);
67
+ }
68
+ if (!Number.isFinite(forceAfterMs) || forceAfterMs <= 0) {
69
+ throw new Error("Doctor process cleanup requires a positive forceAfterMs");
70
+ }
71
+ if (!isAlive(pid)) return false;
72
+ process.kill(pid, "SIGTERM");
73
+ const startedAt = Date.now();
74
+ while (Date.now() - startedAt < forceAfterMs) {
75
+ if (!isAlive(pid)) return true;
76
+ await sleep(Math.min(100, forceAfterMs));
77
+ }
78
+ if (isAlive(pid)) process.kill(pid, "SIGKILL");
79
+ return true;
80
+ }
81
+
82
+ function daemonLabel(record) {
83
+ return `${record.toolName} (${record.instanceId || "global"})`;
84
+ }
85
+
86
+ function daemonResultSummary(results) {
87
+ const states = new Map();
88
+ for (const result of results) {
89
+ const state = result.diagnostic?.state || result.outcome;
90
+ states.set(state, (states.get(state) || 0) + 1);
91
+ }
92
+ return [...states.entries()]
93
+ .sort(([left], [right]) => left.localeCompare(right))
94
+ .map(([state, count]) => `${count} ${state}`)
95
+ .join(", ");
96
+ }
97
+
98
+ function formatTokenCount(tokens) {
99
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(tokens);
100
+ }
101
+
102
+ function assertDoctorPolicy(policy) {
103
+ const positiveValues = [
104
+ "contextInspectionTimeoutMs",
105
+ "contextWarningPercent",
106
+ "contextCriticalPercent",
107
+ "contextInefficientMinTokens",
108
+ "contextToolResultWarningPercent",
109
+ "contextSingleMessageWarningPercent"
110
+ ];
111
+ for (const name of positiveValues) {
112
+ if (!Number.isFinite(policy?.[name]) || policy[name] <= 0) {
113
+ throw new Error(`Doctor configuration requires a positive ${name}`);
114
+ }
115
+ }
116
+ if (policy.contextCriticalPercent <= policy.contextWarningPercent) {
117
+ throw new Error("Doctor contextCriticalPercent must be greater than contextWarningPercent");
118
+ }
119
+ }
120
+
121
+ function evaluateContext(context, policy) {
122
+ if (context.error) return { ...context, level: "unknown", inefficiencies: [] };
123
+ const percent = context.percent;
124
+ const level = !Number.isFinite(percent)
125
+ ? "unknown"
126
+ : percent >= policy.contextCriticalPercent
127
+ ? "critical"
128
+ : percent >= policy.contextWarningPercent ? "warning" : "healthy";
129
+ const inefficiencies = [];
130
+ if (context.estimatedTokens >= policy.contextInefficientMinTokens) {
131
+ if (context.toolResultPercent >= policy.contextToolResultWarningPercent) {
132
+ inefficiencies.push(`tool results occupy ${context.toolResultPercent.toFixed(1)}% of retained content`);
133
+ }
134
+ if (context.largestMessagePercent >= policy.contextSingleMessageWarningPercent) {
135
+ inefficiencies.push(`one message occupies ${context.largestMessagePercent.toFixed(1)}% of retained content`);
136
+ }
137
+ }
138
+ return { ...context, level, inefficiencies };
139
+ }
140
+
141
+ function contextSummary(contexts) {
142
+ if (!contexts.length) return "Contexts: no active contexts.";
143
+ const measured = contexts.filter((context) => Number.isFinite(context.percent));
144
+ const oversized = contexts.filter((context) => context.level === "warning" || context.level === "critical").length;
145
+ const inefficient = contexts.filter((context) => context.inefficiencies.length).length;
146
+ const unavailable = contexts.length - measured.length;
147
+ const details = [
148
+ `${contexts.length} active`,
149
+ `${measured.length} measured`,
150
+ `${oversized} large`,
151
+ `${inefficient} inefficient`
152
+ ];
153
+ if (unavailable) details.push(`${unavailable} unavailable`);
154
+ if (measured.length) {
155
+ details.push(`max ${Math.max(...measured.map((context) => context.percent)).toFixed(1)}%`);
156
+ }
157
+ return `Contexts: ${details.join(", ")}.`;
158
+ }
159
+
160
+ function addContextAttention(report) {
161
+ for (const context of report.contexts) {
162
+ const label = `Chat ${context.chatId}`;
163
+ if (context.error) {
164
+ report.attention.push(`${label} context inspection failed: ${context.error}`);
165
+ continue;
166
+ }
167
+ if (context.level === "warning" || context.level === "critical") {
168
+ const size = context.tokens == null || context.contextWindow == null
169
+ ? `${context.percent.toFixed(1)}% of its context window`
170
+ : `${formatTokenCount(context.tokens)}/${formatTokenCount(context.contextWindow)} tokens (${context.percent.toFixed(1)}%)`;
171
+ const severity = context.level === "critical" ? "critically large" : "large";
172
+ report.attention.push(`${label} context is ${severity}: ${size}. Use /new to carry durable context into a fresh session.`);
173
+ }
174
+ if (context.inefficiencies.length) {
175
+ report.attention.push(`${label} context may be inefficient: ${formatTokenCount(context.estimatedTokens)} estimated retained tokens; ${context.inefficiencies.join("; ")}.`);
176
+ }
177
+ }
178
+ }
179
+
180
+ export function formatDoctorReport(report) {
181
+ const status = report.attention.length
182
+ ? "attention needed"
183
+ : report.repairs.length ? "repaired" : "healthy";
184
+ const lines = [
185
+ `Arisa Doctor: ${status}`,
186
+ `Core: Pi, ${report.runtime.sessions} active session(s), ${report.runtime.closingSessions} closing.`,
187
+ contextSummary(report.contexts),
188
+ `Daemons: ${report.daemons.length} checked${report.daemons.length ? ` (${daemonResultSummary(report.daemons)})` : ""}.`
189
+ ];
190
+ if (!report.repairs.length) lines.push("Processes: no unnecessary managed processes found.");
191
+ if (report.repairs.length) {
192
+ lines.push("", "Repairs:", ...report.repairs.map((item) => `- ${item}`));
193
+ }
194
+ if (report.attention.length) {
195
+ lines.push("", "Attention:", ...report.attention.map((item) => `- ${item}`));
196
+ }
197
+ return lines.join("\n");
198
+ }
199
+
200
+ export async function runDoctor({
201
+ agentManager,
202
+ toolProcessSupervisor,
203
+ daemonPolicy,
204
+ doctorPolicy,
205
+ logger,
206
+ listProcesses = listSystemProcesses,
207
+ stopProcess = terminateProcess,
208
+ serviceStatus = getServiceStatus,
209
+ stopDaemon = stopManagedDaemon,
210
+ unregisterDaemon = unregisterManagedDaemon
211
+ }) {
212
+ assertDoctorPolicy(doctorPolicy);
213
+ const runtime = await agentManager.getRuntimeDiagnostic({
214
+ contextInspectionTimeoutMs: doctorPolicy.contextInspectionTimeoutMs
215
+ });
216
+ const report = {
217
+ runtime,
218
+ contexts: runtime.contexts.map((context) => evaluateContext(context, doctorPolicy)),
219
+ daemons: [],
220
+ repairs: [],
221
+ attention: []
222
+ };
223
+ addContextAttention(report);
224
+ let processes = [];
225
+ try {
226
+ processes = await listProcesses({ timeoutMs: daemonPolicy.healthTimeoutMs });
227
+ } catch (error) {
228
+ report.attention.push(`Process inspection failed: ${error?.message || error}`);
229
+ }
230
+
231
+ const processByPid = new Map(processes.map((record) => [record.pid, record]));
232
+ const currentService = await serviceStatus();
233
+ if (currentService.running && currentService.pid !== process.pid) {
234
+ const registered = processByPid.get(currentService.pid);
235
+ if (registered && isArisaServiceProcess(registered)) {
236
+ try {
237
+ await stopProcess(currentService.pid, { forceAfterMs: daemonPolicy.stopTimeoutMs });
238
+ report.repairs.push(`Stopped duplicate Arisa service process ${currentService.pid}.`);
239
+ } catch (error) {
240
+ report.attention.push(`Duplicate Arisa service process ${currentService.pid} could not be stopped: ${error?.message || error}`);
241
+ }
242
+ } else {
243
+ report.attention.push(`Registered service process ${currentService.pid} could not be verified and was left running.`);
244
+ }
245
+ }
246
+
247
+ try {
248
+ report.daemons = await toolProcessSupervisor.repair();
249
+ } catch (error) {
250
+ report.attention.push(`Daemon reconciliation failed: ${error?.message || error}`);
251
+ }
252
+ for (const result of report.daemons) {
253
+ const label = daemonLabel(result.record);
254
+ if (result.outcome === "stale-registration") {
255
+ const pid = result.diagnostic?.pid;
256
+ const registered = pid ? processByPid.get(pid) : null;
257
+ if (pid && (!registered || !isDaemonProcess(registered, result.record))) {
258
+ report.attention.push(`${label} has a stale registration, but its live process identity could not be verified.`);
259
+ continue;
260
+ }
261
+ try {
262
+ if (pid) {
263
+ await stopProcess(pid, { forceAfterMs: daemonPolicy.stopTimeoutMs });
264
+ await stopDaemon(
265
+ { toolName: result.record.toolName, scope: result.record.scope },
266
+ { state: null }
267
+ );
268
+ }
269
+ await unregisterDaemon({ toolName: result.record.toolName, scope: result.record.scope });
270
+ report.repairs.push(`Removed stale daemon registration ${label}: ${result.reason}.`);
271
+ } catch (error) {
272
+ report.attention.push(`${label} stale registration could not be removed: ${error?.message || error}`);
273
+ }
274
+ continue;
275
+ }
276
+ if (result.outcome === "missing-entry") {
277
+ const pid = result.diagnostic?.pid;
278
+ const registered = pid ? processByPid.get(pid) : null;
279
+ if (registered && isDaemonProcess(registered, result.record)) {
280
+ try {
281
+ await stopProcess(pid, { forceAfterMs: daemonPolicy.stopTimeoutMs });
282
+ await stopDaemon(
283
+ { toolName: result.record.toolName, scope: result.record.scope },
284
+ { state: "failed", message: "Orphaned daemon stopped because its tool entry is missing" }
285
+ );
286
+ report.repairs.push(`Stopped orphaned daemon ${label}.`);
287
+ } catch (error) {
288
+ report.attention.push(`${label} could not be stopped: ${error?.message || error}`);
289
+ }
290
+ } else {
291
+ report.attention.push(`${label} has a missing entry${pid ? "; its process identity could not be verified" : ""}.`);
292
+ }
293
+ continue;
294
+ }
295
+ if (["started", "recovered", "restart-scheduled"].includes(result.outcome)) {
296
+ report.repairs.push(`${label}: ${result.outcome}.`);
297
+ }
298
+ if (result.outcome === "error") {
299
+ report.attention.push(`${label}: daemon reconciliation failed.`);
300
+ } else if (result.diagnostic?.disposition === "requires-attention") {
301
+ report.attention.push(`${label}: ${result.diagnostic.message || result.diagnostic.state}.`);
302
+ }
303
+ }
304
+
305
+ logger?.log("doctor", `completed with ${report.repairs.length} repair(s) and ${report.attention.length} attention item(s)`);
306
+ return report;
307
+ }