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.
- package/AGENTS.md +18 -17
- package/README.md +30 -9
- package/package.json +6 -2
- package/pnpm-workspace.yaml +1 -0
- package/src/core/agent/agent-manager.js +288 -29
- package/src/core/agent/auth-flow.js +12 -8
- package/src/core/agent/model-selection.js +54 -14
- package/src/core/agent/model-speed.js +59 -0
- package/src/core/config/config-defaults.js +56 -4
- package/src/core/config/config-store.js +5 -1
- package/src/core/conversation/conversation-history-store.js +142 -0
- package/src/core/tasks/task-store.js +16 -0
- package/src/core/tools/daemon-health.js +11 -2
- package/src/core/tools/daemon-processes.js +92 -2
- package/src/core/tools/daemon-runtime.js +4 -2
- package/src/core/tools/ipc-client.js +15 -3
- package/src/core/tools/tool-registry.js +27 -0
- package/src/index.js +61 -6
- package/src/runtime/arisa-capabilities.js +45 -1
- package/src/runtime/bootstrap.js +3 -2
- package/src/runtime/create-app.js +47 -11
- package/src/runtime/doctor.js +307 -0
- package/src/runtime/log-viewer.js +165 -0
- package/src/runtime/paths.js +4 -1
- package/src/runtime/service-manager.js +106 -8
- package/src/runtime/tool-process-supervisor.js +107 -10
- package/src/transport/telegram/bot.js +533 -99
- package/src/transport/telegram/model-picker.js +28 -2
- package/test/agent-tool-policy.test.js +26 -1
- package/test/auth-flow.test.js +28 -2
- package/test/capabilities-security.test.js +37 -0
- package/test/context-and-task-bounds.test.js +279 -0
- package/test/daemon-runtime.test.js +130 -2
- package/test/dependency-warnings.test.js +17 -0
- package/test/doctor.test.js +90 -0
- package/test/log-viewer.test.js +90 -0
- package/test/model-selection.test.js +125 -2
- package/test/paths.test.js +8 -0
- package/test/pi-compaction.test.js +43 -0
- package/test/service-manager.test.js +234 -0
- 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
|
|
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
|
|
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({
|
|
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({
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -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(
|
|
57
|
-
const modelOverride = normalizeString(
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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({
|
|
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 ${
|
|
145
|
+
logger?.log("app", `validating Pi model ${activeConfig.provider}/${activeConfig.model}`);
|
|
115
146
|
let skipAgentStartupPrompts = false;
|
|
116
147
|
try {
|
|
117
|
-
await agentManager.
|
|
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
|
+
}
|