arisa 4.3.5 → 5.1.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 (49) hide show
  1. package/AGENTS.md +21 -19
  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 +42 -2
  18. package/src/core/tools/tool-usage-store.js +59 -0
  19. package/src/index.js +61 -6
  20. package/src/runtime/arisa-capabilities.js +45 -1
  21. package/src/runtime/bootstrap.js +3 -2
  22. package/src/runtime/create-app.js +49 -11
  23. package/src/runtime/doctor.js +365 -0
  24. package/src/runtime/log-viewer.js +165 -0
  25. package/src/runtime/paths.js +8 -1
  26. package/src/runtime/report-format.js +51 -0
  27. package/src/runtime/service-manager.js +106 -8
  28. package/src/runtime/tool-process-supervisor.js +107 -10
  29. package/src/runtime/tool-usage-report.js +10 -0
  30. package/src/runtime/update-manager.js +206 -0
  31. package/src/transport/telegram/bot.js +633 -91
  32. package/src/transport/telegram/model-picker.js +28 -2
  33. package/test/agent-tool-policy.test.js +26 -1
  34. package/test/auth-flow.test.js +28 -2
  35. package/test/capabilities-security.test.js +37 -0
  36. package/test/context-and-task-bounds.test.js +280 -0
  37. package/test/daemon-runtime.test.js +130 -2
  38. package/test/dependency-warnings.test.js +17 -0
  39. package/test/doctor.test.js +111 -0
  40. package/test/log-viewer.test.js +90 -0
  41. package/test/model-selection.test.js +125 -2
  42. package/test/paths.test.js +16 -0
  43. package/test/pi-compaction.test.js +43 -0
  44. package/test/service-manager.test.js +237 -0
  45. package/test/task-store.test.js +31 -0
  46. package/test/telegram-text-artifact.test.js +36 -1
  47. package/test/tool-registry-run.test.js +21 -0
  48. package/test/tool-usage.test.js +37 -0
  49. package/test/update-manager.test.js +87 -0
@@ -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
  }
@@ -0,0 +1,10 @@
1
+ import { renderTextReport, reportRow } from "./report-format.js";
2
+
3
+ export function formatToolUsageReport(tools) {
4
+ const lines = ["Arisa tools", "===========", "Usage count"];
5
+ if (!tools.length) lines.push(" (none installed)");
6
+ for (const tool of tools) {
7
+ lines.push(...reportRow(tool.name, tool.count, { labelWidth: 24 }));
8
+ }
9
+ return renderTextReport(lines);
10
+ }
@@ -0,0 +1,206 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, cp, mkdir, readFile, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { arisaPackageDir, getToolDir } from "./paths.js";
6
+ import { renderTextReport, reportRow, wrapReportText } from "./report-format.js";
7
+
8
+ const defaultRepoUrl = "https://github.com/clasen/Arisa.git";
9
+ const defaultBranch = "main";
10
+ const bootstrapToolNames = ["trash", "official-tool-sync"];
11
+
12
+ function exists(target) {
13
+ return access(target).then(() => true, () => false);
14
+ }
15
+
16
+ function runCommand(command, args, { cwd, timeoutMs = 300_000, env = process.env } = {}) {
17
+ return new Promise((resolve, reject) => {
18
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
19
+ let stdout = "";
20
+ let stderr = "";
21
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
22
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
23
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
24
+ child.once("error", (error) => { clearTimeout(timer); reject(error); });
25
+ child.once("close", (code, signal) => {
26
+ clearTimeout(timer);
27
+ if (code === 0) resolve({ stdout, stderr });
28
+ else reject(new Error(`${command} ${args.join(" ")} failed (${signal || code}): ${(stderr || stdout).trim().slice(-2000)}`));
29
+ });
30
+ });
31
+ }
32
+
33
+ function parseSemver(value) {
34
+ const match = String(value || "").trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
35
+ return match ? match.slice(1).map(Number) : null;
36
+ }
37
+
38
+ export function compareVersions(left, right) {
39
+ const a = parseSemver(left);
40
+ const b = parseSemver(right);
41
+ if (!a || !b) return null;
42
+ for (let index = 0; index < 3; index += 1) {
43
+ if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ async function readCurrentVersion() {
49
+ const packageJson = JSON.parse(await readFile(path.join(arisaPackageDir, "package.json"), "utf8"));
50
+ return packageJson.version;
51
+ }
52
+
53
+ async function fetchLatestVersion() {
54
+ const { stdout } = await runCommand("npm", ["view", "arisa", "version", "--json"], { timeoutMs: 60_000 });
55
+ const parsed = JSON.parse(stdout);
56
+ const version = Array.isArray(parsed) ? parsed.at(-1) : parsed;
57
+ if (!parseSemver(version)) throw new Error(`npm returned an invalid Arisa version: ${version}`);
58
+ return version;
59
+ }
60
+
61
+ async function cloneCatalog(scratchRoot) {
62
+ const repoDir = path.join(scratchRoot, "repo");
63
+ await runCommand("git", ["clone", "--depth", "1", "--branch", defaultBranch, "--", defaultRepoUrl, repoDir], { cwd: scratchRoot, timeoutMs: 180_000 });
64
+ return repoDir;
65
+ }
66
+
67
+ async function installDependencies(toolDir) {
68
+ if (!(await exists(path.join(toolDir, "package.json")))) return null;
69
+ try {
70
+ await runCommand("pnpm", ["install", "--lockfile=false"], { cwd: toolDir, timeoutMs: 300_000 });
71
+ return "pnpm";
72
+ } catch {
73
+ await runCommand("npm", ["install", "--no-package-lock"], { cwd: toolDir, timeoutMs: 300_000 });
74
+ return "npm";
75
+ }
76
+ }
77
+
78
+ async function validateTool(toolDir, expectedName) {
79
+ const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
80
+ if (manifest.name !== expectedName) throw new Error(`Bootstrap manifest mismatch for ${expectedName}`);
81
+ const entry = manifest.entry || "index.js";
82
+ const env = { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir };
83
+ await runCommand(process.execPath, ["--check", entry], { cwd: toolDir, timeoutMs: 30_000, env });
84
+ await runCommand(process.execPath, [entry, "--help"], { cwd: toolDir, timeoutMs: 30_000, env });
85
+ }
86
+
87
+ async function stageBootstrapTool(repoDir, scratchRoot, name) {
88
+ const sourceDir = path.join(repoDir, "tools", name);
89
+ const stageDir = path.join(scratchRoot, `stage-${name}`);
90
+ if (!(await exists(path.join(sourceDir, "tool.manifest.json")))) throw new Error(`Official catalog is missing required bootstrap tool: ${name}`);
91
+ await cp(sourceDir, stageDir, { recursive: true });
92
+ await installDependencies(stageDir);
93
+ await validateTool(stageDir, name);
94
+ return { name, stageDir, destination: getToolDir(name) };
95
+ }
96
+
97
+ export async function ensureOfficialUpdateTools({ toolRegistry }) {
98
+ const missing = bootstrapToolNames.filter((name) => !toolRegistry.get(name));
99
+ if (!missing.length) return { installed: [] };
100
+ const scratchRoot = path.join(os.tmpdir(), `arisa-update-${process.pid}-${Date.now()}`);
101
+ await mkdir(scratchRoot, { recursive: true });
102
+ const deployed = [];
103
+ try {
104
+ const repoDir = await cloneCatalog(scratchRoot);
105
+ const staged = [];
106
+ for (const name of missing) staged.push(await stageBootstrapTool(repoDir, scratchRoot, name));
107
+ for (const item of staged) {
108
+ await mkdir(path.dirname(item.destination), { recursive: true });
109
+ await cp(item.stageDir, item.destination, { recursive: true, errorOnExist: true, force: false });
110
+ deployed.push(item.destination);
111
+ }
112
+ await toolRegistry.load();
113
+ return { installed: staged.map((item) => item.name) };
114
+ } catch (error) {
115
+ for (const destination of deployed.reverse()) await rm(destination, { recursive: true, force: true }).catch(() => {});
116
+ await toolRegistry.load().catch(() => {});
117
+ throw error;
118
+ } finally {
119
+ await rm(scratchRoot, { recursive: true, force: true }).catch(() => {});
120
+ }
121
+ }
122
+
123
+ function parseToolSyncOutput(result) {
124
+ if (!result?.ok) throw new Error(result?.error || "official-tool-sync failed");
125
+ const text = result.output?.text;
126
+ if (typeof text !== "string") return result.output?.json || {};
127
+ return JSON.parse(text);
128
+ }
129
+
130
+ function summarizeTools(sync, installedTools) {
131
+ const tools = sync.tools || [];
132
+ const officialNames = new Set(tools.map((tool) => tool.name));
133
+ const counts = {};
134
+ for (const tool of tools) counts[tool.status] = (counts[tool.status] || 0) + 1;
135
+ const updateable = tools.filter((tool) => tool.safeToUpdate && !["up-to-date", "baseline-refresh"].includes(tool.status));
136
+ const blocked = tools.filter((tool) => !tool.safeToUpdate && !["up-to-date", "baseline-refresh"].includes(tool.status));
137
+ return {
138
+ installedOfficial: sync.installedOfficialCount || tools.length,
139
+ official: tools.map(({ name, status }) => ({ name, status })).sort((left, right) => left.name.localeCompare(right.name)),
140
+ nonOfficial: installedTools.map((tool) => tool.name).filter((name) => !officialNames.has(name)).sort(),
141
+ counts,
142
+ updateable: updateable.map((tool) => tool.name),
143
+ blocked: blocked.map((tool) => ({ name: tool.name, status: tool.status }))
144
+ };
145
+ }
146
+
147
+ export async function checkForUpdates({ chatId, toolRegistry }) {
148
+ const [currentVersion, latestVersion] = await Promise.all([readCurrentVersion(), fetchLatestVersion()]);
149
+ const bootstrapped = await ensureOfficialUpdateTools({ toolRegistry });
150
+ const result = await toolRegistry.run({ name: "official-tool-sync", chatId, request: { args: { action: "check" } } });
151
+ const sync = parseToolSyncOutput(result);
152
+ return {
153
+ core: { currentVersion, latestVersion, updateAvailable: compareVersions(currentVersion, latestVersion) === -1 },
154
+ bootstrapInstalled: bootstrapped.installed,
155
+ tools: summarizeTools(sync, toolRegistry.list())
156
+ };
157
+ }
158
+
159
+ function shortToolStatus(status) {
160
+ return ({
161
+ "locally-modified": "local",
162
+ "untracked-difference": "untracked",
163
+ "update-available": "update",
164
+ "baseline-refresh": "refresh",
165
+ "up-to-date": "current"
166
+ })[status] || status;
167
+ }
168
+
169
+ export function formatUpdateReport(report) {
170
+ const lines = ["Arisa update", "============", "Core"];
171
+ lines.push(...reportRow("Current", report.core.currentVersion));
172
+ lines.push(...reportRow("Latest", report.core.latestVersion));
173
+ lines.push(...reportRow("Status", report.core.updateAvailable ? "update available" : "up to date"));
174
+ lines.push("", "Official tools");
175
+ lines.push(...reportRow("Installed", report.tools.installedOfficial));
176
+ for (const [status, count] of Object.entries(report.tools.counts)) {
177
+ lines.push(...reportRow(status, count, { labelWidth: 20 }));
178
+ }
179
+ lines.push("", `Official (${report.tools.official.length})`);
180
+ for (const item of report.tools.official) {
181
+ const status = shortToolStatus(item.status);
182
+ const suffix = status === "current" ? "" : ` [${status}]`;
183
+ lines.push(...wrapReportText(`${item.name}${suffix}`, { firstPrefix: " - ", nextPrefix: " " }));
184
+ }
185
+ lines.push("", `Non-official (${report.tools.nonOfficial.length})`);
186
+ if (!report.tools.nonOfficial.length) lines.push(" (none)");
187
+ for (const name of report.tools.nonOfficial) {
188
+ lines.push(...wrapReportText(name, { firstPrefix: " - ", nextPrefix: " " }));
189
+ }
190
+ if (report.tools.updateable.length) {
191
+ lines.push("", "Safe updates");
192
+ for (const name of report.tools.updateable) lines.push(...wrapReportText(name, { firstPrefix: " - ", nextPrefix: " " }));
193
+ }
194
+ if (report.tools.blocked.length) {
195
+ lines.push("", "Needs review");
196
+ for (const item of report.tools.blocked) {
197
+ lines.push(...wrapReportText(item.name, { firstPrefix: " - ", nextPrefix: " " }));
198
+ lines.push(...wrapReportText(`[${shortToolStatus(item.status)}]`, { firstPrefix: " ", nextPrefix: " " }));
199
+ }
200
+ }
201
+ if (report.bootstrapInstalled.length) {
202
+ lines.push("", "Update support installed");
203
+ for (const name of report.bootstrapInstalled) lines.push(...wrapReportText(name, { firstPrefix: " - ", nextPrefix: " " }));
204
+ }
205
+ return renderTextReport(lines);
206
+ }