arisa 5.0.2 → 5.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -49,9 +49,10 @@ Each tool declares in `tool.manifest.json`:
49
49
  - `skillHints`: optional skills to apply when using or editing the tool
50
50
 
51
51
  ## Text encoding
52
- All textual content generated or sent by Arisa or its tools must use UTF-8. This includes text files, assistant-created attachments, tool exports, email bodies, messages, HTTP responses, and API payloads.
52
+ All textual content generated or sent by Arisa or its tools must use UTF-8.
53
53
 
54
- - Text files must start with a UTF-8 byte-order mark (BOM).
54
+ - User-facing text documents and exports in `.txt`, `.md`, or `.csv` format must start with a UTF-8 byte-order mark (BOM).
55
+ - Source code, repository documentation, configuration, manifests, and structured data must use UTF-8 without BOM.
55
56
  - Protocol payloads must declare UTF-8 through the protocol's standard mechanism and encode their bytes as UTF-8. For example, email and HTTP text content must use a `Content-Type` with `charset=UTF-8`.
56
57
 
57
58
  ## Tool-to-Arisa IPC
package/README.md CHANGED
@@ -114,14 +114,14 @@ Automatic context compaction uses Pi's native implementation and can be tuned in
114
114
  "pi": {
115
115
  "compaction": {
116
116
  "enabled": true,
117
- "reserveTokens": 16384,
117
+ "reserveTokens": 120000,
118
118
  "keepRecentTokens": 20000
119
119
  }
120
120
  }
121
121
  }
122
122
  ```
123
123
 
124
- Pi compacts when the context exceeds the model's context window minus `reserveTokens`. Arisa does not add Telegram commands or compaction notifications.
124
+ Pi compacts when the context exceeds the model's context window minus `reserveTokens`. The default keeps a large reserve so compaction occurs before Arisa Doctor's context warning on the default model. Set a smaller reserve when using models with substantially smaller context windows. Arisa does not add Telegram commands or compaction notifications.
125
125
 
126
126
  ## Install globally
127
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.0.2",
3
+ "version": "5.1.4",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -43,7 +43,7 @@ export const piConfigDefaults = Object.freeze({
43
43
  speed: 1,
44
44
  compaction: Object.freeze({
45
45
  enabled: true,
46
- reserveTokens: 16_384,
46
+ reserveTokens: 120_000,
47
47
  keepRecentTokens: 20_000
48
48
  })
49
49
  });
@@ -1,11 +1,13 @@
1
1
  import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
5
6
  import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
6
7
  import { normalizeToolResult } from "./tool-result.js";
7
8
  import { readDaemonDiagnostic } from "./daemon-processes.js";
8
9
  import { SkillRegistry } from "../skills/skill-registry.js";
10
+ import { ToolUsageStore } from "./tool-usage-store.js";
9
11
 
10
12
  function toolEnv() {
11
13
  return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
@@ -45,10 +47,11 @@ function formatSemanticMetadata(tool) {
45
47
  }
46
48
 
47
49
  export class ToolRegistry {
48
- constructor({ logger } = {}) {
50
+ constructor({ logger, usageStore = new ToolUsageStore() } = {}) {
49
51
  this.logger = logger;
50
52
  this.tools = new Map();
51
53
  this.skillRegistry = new SkillRegistry();
54
+ this.usageStore = usageStore;
52
55
  }
53
56
 
54
57
  async load() {
@@ -193,13 +196,23 @@ export class ToolRegistry {
193
196
  return { ok: true, tool: name, field, configPath };
194
197
  }
195
198
 
199
+ async usage(chatId) {
200
+ const counts = await this.usageStore.counts(chatId);
201
+ return this.list()
202
+ .map((tool) => ({ name: tool.name, count: counts[tool.name] || 0 }))
203
+ .sort((left, right) => left.name.localeCompare(right.name));
204
+ }
205
+
196
206
  async run({ name, request, chatId = null }) {
197
207
  const tool = this.get(name);
198
208
  if (!tool) throw new Error(`Tool not found: ${name}`);
209
+ await this.usageStore.record(chatId, name).catch((error) => {
210
+ this.logger?.error("tools", `could not record ${name} usage: ${error?.message || String(error)}`);
211
+ });
199
212
  this.logger?.log("tools", `running ${name}`);
200
213
  const tmpDir = chatId != null ? getChatToolTmpDir(chatId, name) : getToolTmpDir(name);
201
214
  await mkdir(tmpDir, { recursive: true });
202
- const requestFile = path.join(tmpDir, `.request-${Date.now()}.json`);
215
+ const requestFile = path.join(tmpDir, `.request-${Date.now()}-${randomUUID()}.json`);
203
216
  const skills = await this.resolveSkills(name);
204
217
  const enrichedRequest = { ...request, chatId, skills };
205
218
  await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
@@ -0,0 +1,59 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getChatToolUsageFile } from "../../runtime/paths.js";
4
+
5
+ function emptyUsage() {
6
+ return { version: 1, tools: {} };
7
+ }
8
+
9
+ async function readUsage(file) {
10
+ try {
11
+ const parsed = JSON.parse(await readFile(file, "utf8"));
12
+ return parsed?.version === 1 && parsed.tools && typeof parsed.tools === "object"
13
+ ? parsed
14
+ : emptyUsage();
15
+ } catch (error) {
16
+ if (error?.code === "ENOENT") return emptyUsage();
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ async function writeUsage(file, usage) {
22
+ await mkdir(path.dirname(file), { recursive: true });
23
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
24
+ await writeFile(temporary, `${JSON.stringify(usage, null, 2)}\n`, "utf8");
25
+ await rename(temporary, file);
26
+ }
27
+
28
+ export class ToolUsageStore {
29
+ constructor({ resolveFile = getChatToolUsageFile } = {}) {
30
+ this.resolveFile = resolveFile;
31
+ this.queues = new Map();
32
+ }
33
+
34
+ async record(chatId, toolName) {
35
+ if (chatId == null || chatId === "") return;
36
+ const key = String(chatId);
37
+ const previous = this.queues.get(key) || Promise.resolve();
38
+ const current = previous.catch(() => {}).then(async () => {
39
+ const file = this.resolveFile(chatId);
40
+ const usage = await readUsage(file);
41
+ const count = Number(usage.tools[toolName]?.count) || 0;
42
+ usage.tools[toolName] = { count: count + 1 };
43
+ await writeUsage(file, usage);
44
+ });
45
+ this.queues.set(key, current);
46
+ try {
47
+ await current;
48
+ } finally {
49
+ if (this.queues.get(key) === current) this.queues.delete(key);
50
+ }
51
+ }
52
+
53
+ async counts(chatId) {
54
+ if (chatId == null || chatId === "") return {};
55
+ await (this.queues.get(String(chatId)) || Promise.resolve()).catch(() => {});
56
+ const usage = await readUsage(this.resolveFile(chatId));
57
+ return Object.fromEntries(Object.entries(usage.tools).map(([name, value]) => [name, Number(value?.count) || 0]));
58
+ }
59
+ }
@@ -11,6 +11,7 @@ import { createIpcServer } from "./ipc/ipc-server.js";
11
11
  import { getAgentConfig } from "../core/agent/model-selection.js";
12
12
  import { normalizeModelSpeed } from "../core/agent/model-speed.js";
13
13
  import { runDoctor } from "./doctor.js";
14
+ import { checkForUpdates, formatUpdateReport } from "./update-manager.js";
14
15
 
15
16
  function normalizeString(value) {
16
17
  const text = String(value ?? "").trim();
@@ -136,6 +137,7 @@ export async function createApp({ logger, runtimeOverrides, requestRestart } = {
136
137
  doctorPolicy: config.doctor,
137
138
  logger
138
139
  }),
140
+ checkUpdates: async (chatId) => formatUpdateReport(await checkForUpdates({ chatId, toolRegistry })),
139
141
  requestRestart,
140
142
  logger
141
143
  });
@@ -1,8 +1,12 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { statfs } from "node:fs/promises";
3
+ import os from "node:os";
2
4
  import process from "node:process";
3
5
  import { promisify } from "node:util";
4
6
  import { stopManagedDaemon, unregisterManagedDaemon } from "../core/tools/daemon-processes.js";
5
7
  import { getServiceStatus, serviceEntryFile } from "./service-manager.js";
8
+ import { arisaHomeDir } from "./paths.js";
9
+ import { renderTextReport, reportRow, wrapReportText } from "./report-format.js";
6
10
 
7
11
  const execFileAsync = promisify(execFile);
8
12
 
@@ -99,6 +103,49 @@ function formatTokenCount(tokens) {
99
103
  return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(tokens);
100
104
  }
101
105
 
106
+ function formatBytes(bytes) {
107
+ if (!Number.isFinite(bytes) || bytes < 0) return "unknown";
108
+ const units = ["B", "KB", "MB", "GB", "TB"];
109
+ let value = bytes;
110
+ let index = 0;
111
+ while (value >= 1024 && index < units.length - 1) {
112
+ value /= 1024;
113
+ index += 1;
114
+ }
115
+ return `${value.toFixed(index < 2 ? 0 : 1)} ${units[index]}`;
116
+ }
117
+
118
+ function formatUptime(seconds) {
119
+ if (!Number.isFinite(seconds) || seconds < 0) return "unknown";
120
+ const days = Math.floor(seconds / 86400);
121
+ const hours = Math.floor((seconds % 86400) / 3600);
122
+ const minutes = Math.floor((seconds % 3600) / 60);
123
+ return [days ? `${days}d` : "", hours ? `${hours}h` : "", `${minutes}m`].filter(Boolean).join(" ");
124
+ }
125
+
126
+ export async function inspectSystemResources({ diskPath = arisaHomeDir } = {}) {
127
+ const [load1, load5, load15] = os.loadavg();
128
+ const memoryTotal = os.totalmem();
129
+ const memoryFree = os.freemem();
130
+ const filesystem = await statfs(diskPath);
131
+ const blockSize = Number(filesystem.bsize);
132
+ const diskTotal = Number(filesystem.blocks) * blockSize;
133
+ const diskFree = Number(filesystem.bavail) * blockSize;
134
+ return {
135
+ platform: `${os.platform()} ${os.arch()}`,
136
+ cpuCores: os.cpus().length,
137
+ loadAverage: [load1, load5, load15],
138
+ memoryTotal,
139
+ memoryFree,
140
+ memoryUsed: memoryTotal - memoryFree,
141
+ diskTotal,
142
+ diskFree,
143
+ diskUsed: diskTotal - diskFree,
144
+ uptimeSeconds: os.uptime(),
145
+ processRss: process.memoryUsage().rss
146
+ };
147
+ }
148
+
102
149
  function assertDoctorPolicy(policy) {
103
150
  const positiveValues = [
104
151
  "contextInspectionTimeoutMs",
@@ -138,25 +185,6 @@ function evaluateContext(context, policy) {
138
185
  return { ...context, level, inefficiencies };
139
186
  }
140
187
 
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
188
  function addContextAttention(report) {
161
189
  for (const context of report.contexts) {
162
190
  const label = `Chat ${context.chatId}`;
@@ -181,20 +209,41 @@ export function formatDoctorReport(report) {
181
209
  const status = report.attention.length
182
210
  ? "attention needed"
183
211
  : 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}`));
212
+ const measured = report.contexts.filter((context) => Number.isFinite(context.percent));
213
+ const large = report.contexts.filter((context) => context.level === "warning" || context.level === "critical").length;
214
+ const inefficient = report.contexts.filter((context) => context.inefficiencies.length).length;
215
+ const lines = ["Arisa Doctor", "============"];
216
+ lines.push(...reportRow("Status", status));
217
+ lines.push("", "Core");
218
+ lines.push(...reportRow("Runtime", "Pi"));
219
+ lines.push(...reportRow("Sessions", `${report.runtime.sessions} active / ${report.runtime.closingSessions} closing`));
220
+ lines.push(...reportRow("Contexts", `${report.contexts.length} active / ${measured.length} measured`));
221
+ lines.push(...reportRow("Large", large));
222
+ lines.push(...reportRow("Ineff.", inefficient));
223
+ if (measured.length) lines.push(...reportRow("Max", `${Math.max(...measured.map((context) => context.percent)).toFixed(1)}%`));
224
+ lines.push("", "Daemons");
225
+ lines.push(...reportRow("Checked", report.daemons.length));
226
+ if (report.daemons.length) lines.push(...reportRow("Status", daemonResultSummary(report.daemons)));
227
+ if (report.system) {
228
+ const memoryPercent = report.system.memoryTotal ? (report.system.memoryUsed / report.system.memoryTotal) * 100 : 0;
229
+ const diskPercent = report.system.diskTotal ? (report.system.diskUsed / report.system.diskTotal) * 100 : 0;
230
+ lines.push("", "System");
231
+ lines.push(...reportRow("Host", report.system.platform));
232
+ lines.push(...reportRow("Uptime", formatUptime(report.system.uptimeSeconds)));
233
+ lines.push(...reportRow("CPU", `${report.system.cpuCores} cores`));
234
+ lines.push(...reportRow("Load", report.system.loadAverage.map((value) => value.toFixed(2)).join(" / ")));
235
+ lines.push(...reportRow("Memory", `${memoryPercent.toFixed(1)}% / ${formatBytes(report.system.memoryFree)} free`));
236
+ lines.push(...reportRow("Disk", `${diskPercent.toFixed(1)}% / ${formatBytes(report.system.diskFree)} free`));
237
+ lines.push(...reportRow("Arisa RSS", formatBytes(report.system.processRss)));
238
+ } else if (report.systemError) {
239
+ lines.push("", "System");
240
+ lines.push(...reportRow("Status", `unavailable: ${report.systemError}`));
193
241
  }
194
- if (report.attention.length) {
195
- lines.push("", "Attention:", ...report.attention.map((item) => `- ${item}`));
196
- }
197
- return lines.join("\n");
242
+ lines.push("", `Repairs (${report.repairs.length})`);
243
+ for (const item of report.repairs) lines.push(...wrapReportText(item, { firstPrefix: " - ", nextPrefix: " " }));
244
+ lines.push("", `Attention (${report.attention.length})`);
245
+ for (const item of report.attention) lines.push(...wrapReportText(item, { firstPrefix: " - ", nextPrefix: " " }));
246
+ return renderTextReport(lines);
198
247
  }
199
248
 
200
249
  export async function runDoctor({
@@ -207,7 +256,8 @@ export async function runDoctor({
207
256
  stopProcess = terminateProcess,
208
257
  serviceStatus = getServiceStatus,
209
258
  stopDaemon = stopManagedDaemon,
210
- unregisterDaemon = unregisterManagedDaemon
259
+ unregisterDaemon = unregisterManagedDaemon,
260
+ inspectResources = inspectSystemResources
211
261
  }) {
212
262
  assertDoctorPolicy(doctorPolicy);
213
263
  const runtime = await agentManager.getRuntimeDiagnostic({
@@ -218,9 +268,17 @@ export async function runDoctor({
218
268
  contexts: runtime.contexts.map((context) => evaluateContext(context, doctorPolicy)),
219
269
  daemons: [],
220
270
  repairs: [],
221
- attention: []
271
+ attention: [],
272
+ system: null,
273
+ systemError: null
222
274
  };
223
275
  addContextAttention(report);
276
+ try {
277
+ report.system = await inspectResources();
278
+ } catch (error) {
279
+ report.systemError = error?.message || String(error);
280
+ report.attention.push(`System resource inspection failed: ${report.systemError}`);
281
+ }
224
282
  let processes = [];
225
283
  try {
226
284
  processes = await listProcesses({ timeoutMs: daemonPolicy.healthTimeoutMs });
@@ -43,6 +43,10 @@ export function getChatConversationHistoryFile(chatId) {
43
43
  return path.join(getChatDir(chatId), "state", "conversation.jsonl");
44
44
  }
45
45
 
46
+ export function getChatToolUsageFile(chatId) {
47
+ return path.join(getChatDir(chatId), "state", "tool-usage.json");
48
+ }
49
+
46
50
  export function getChatToolStateDir(chatId, toolName) {
47
51
  return path.join(getChatDir(chatId), "state", "tools", toolName);
48
52
  }
@@ -0,0 +1,51 @@
1
+ export const reportWidth = 35;
2
+
3
+ function splitLongWord(word, width) {
4
+ const parts = [];
5
+ for (let index = 0; index < word.length; index += width) parts.push(word.slice(index, index + width));
6
+ return parts;
7
+ }
8
+
9
+ export function wrapReportText(text, { firstPrefix = "", nextPrefix = firstPrefix } = {}) {
10
+ const words = String(text ?? "").trim().split(/\s+/).filter(Boolean);
11
+ if (!words.length) return [firstPrefix.trimEnd()];
12
+ const lines = [];
13
+ let prefix = firstPrefix;
14
+ let content = "";
15
+ for (const originalWord of words) {
16
+ const width = Math.max(1, reportWidth - prefix.length);
17
+ const parts = originalWord.length > width ? splitLongWord(originalWord, width) : [originalWord];
18
+ for (const word of parts) {
19
+ if (content && content.length + 1 + word.length > width) {
20
+ lines.push(prefix + content);
21
+ prefix = nextPrefix;
22
+ content = "";
23
+ }
24
+ if (!content && word.length > Math.max(1, reportWidth - prefix.length)) {
25
+ const fragments = splitLongWord(word, Math.max(1, reportWidth - prefix.length));
26
+ lines.push(prefix + fragments.shift());
27
+ prefix = nextPrefix;
28
+ content = fragments.join("");
29
+ } else {
30
+ content += `${content ? " " : ""}${word}`;
31
+ }
32
+ }
33
+ }
34
+ if (content) lines.push(prefix + content);
35
+ return lines;
36
+ }
37
+
38
+ export function reportRow(label, value, { indent = " ", labelWidth = 10 } = {}) {
39
+ const firstPrefix = `${indent}${String(label).padEnd(labelWidth)} `;
40
+ return wrapReportText(value, {
41
+ firstPrefix,
42
+ nextPrefix: " ".repeat(firstPrefix.length)
43
+ });
44
+ }
45
+
46
+ export function renderTextReport(lines) {
47
+ for (const line of lines) {
48
+ if ([...line].length > reportWidth) throw new Error(`Report line exceeds ${reportWidth} characters: ${line}`);
49
+ }
50
+ return `\`\`\`text\n${lines.join("\n")}\n\`\`\``;
51
+ }
@@ -0,0 +1,18 @@
1
+ import { renderTextReport } 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
+
7
+ const sortedTools = [...tools].sort((left, right) =>
8
+ Number(right.count) - Number(left.count) || String(left.name).localeCompare(String(right.name))
9
+ );
10
+ const nameWidth = Math.max(0, ...sortedTools.map((tool) => String(tool.name).length));
11
+ const countWidth = Math.max(1, ...sortedTools.map((tool) => String(tool.count).length));
12
+ for (const tool of sortedTools) {
13
+ const name = String(tool.name).padEnd(nameWidth);
14
+ const count = String(tool.count).padStart(countWidth);
15
+ lines.push(`- ${name} ${count}`);
16
+ }
17
+ return renderTextReport(lines);
18
+ }
@@ -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
+ }
@@ -14,17 +14,20 @@ import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "..
14
14
  import { formatPortableSessionHistory } from "../../core/agent/agent-manager.js";
15
15
  import { ConversationHistoryStore } from "../../core/conversation/conversation-history-store.js";
16
16
  import { formatDoctorReport } from "../../runtime/doctor.js";
17
+ import { formatToolUsageReport } from "../../runtime/tool-usage-report.js";
17
18
 
18
19
  const slowPromptNoticeMs = 300_000;
19
20
 
20
21
  export const telegramCommands = Object.freeze([
21
- { command: "new", description: "Start a new chat context" },
22
- { command: "restart", description: "Restart the Arisa service" },
23
- { command: "doctor", description: "Check and repair Arisa runtime health" },
24
- { command: "model", description: "Choose the model for this chat" },
25
- { command: "effort", description: "Choose reasoning effort for this chat" },
26
- { command: "speed", description: "Choose model speed for this chat" },
27
- { command: "auth", description: "Show authentication status" }
22
+ { command: "new", description: "New chat context" },
23
+ { command: "restart", description: "Restart Arisa" },
24
+ { command: "doctor", description: "Check runtime health" },
25
+ { command: "update", description: "Check for updates" },
26
+ { command: "tools", description: "Tool usage counts" },
27
+ { command: "model", description: "Choose chat model" },
28
+ { command: "effort", description: "Choose reasoning effort" },
29
+ { command: "speed", description: "Choose model speed" },
30
+ { command: "auth", description: "Authentication status" }
28
31
  ]);
29
32
 
30
33
  export function createTelegramRestartHandler({ authorize, requestRestart, logger }) {
@@ -95,6 +98,67 @@ function getIncomingMessageText(message) {
95
98
  return message?.text || message?.caption || formatLocationText(message) || "";
96
99
  }
97
100
 
101
+ function telegramDisplayName(entity = {}) {
102
+ if (entity.username) return `@${entity.username}`;
103
+ return [entity.first_name, entity.last_name].filter(Boolean).join(" ") || entity.title || "unknown";
104
+ }
105
+
106
+ function forwardedMessageSummary(message) {
107
+ const origin = message?.forward_origin;
108
+ if (!origin) return [];
109
+
110
+ const parts = ["forwarded: true", `forwardedOriginType: ${origin.type}`];
111
+ if (origin.type === "user") parts.push(`forwardedFrom: ${telegramDisplayName(origin.sender_user)}`);
112
+ if (origin.type === "hidden_user") parts.push(`forwardedFrom: ${origin.sender_user_name}`);
113
+ if (origin.type === "chat" || origin.type === "channel") {
114
+ parts.push(`forwardedFrom: ${telegramDisplayName(origin.chat)}`);
115
+ }
116
+ if (origin.type === "channel" && origin.message_id) parts.push(`forwardedMessageId: ${origin.message_id}`);
117
+ if (origin.author_signature) parts.push(`forwardedAuthorSignature: ${origin.author_signature}`);
118
+ if (origin.date) parts.push(`forwardedAt: ${new Date(origin.date * 1000).toISOString()}`);
119
+ return parts;
120
+ }
121
+
122
+ function reactionLabel(reaction = {}) {
123
+ if (reaction.type === "emoji") return reaction.emoji || "emoji";
124
+ if (reaction.type === "custom_emoji") return `custom:${reaction.custom_emoji_id || "unknown"}`;
125
+ if (reaction.type === "paid") return "paid";
126
+ return reaction.type || "unknown";
127
+ }
128
+
129
+ function reactionDifference(left = [], right = []) {
130
+ const remaining = right.map(reactionLabel);
131
+ return left.map(reactionLabel).filter((label) => {
132
+ const index = remaining.indexOf(label);
133
+ if (index < 0) return true;
134
+ remaining.splice(index, 1);
135
+ return false;
136
+ });
137
+ }
138
+
139
+ export function buildReactionPrompt({ reaction, reactedMessageText = "" }) {
140
+ const oldReactions = reaction.old_reaction || [];
141
+ const newReactions = reaction.new_reaction || [];
142
+ const added = reactionDifference(newReactions, oldReactions);
143
+ const removed = reactionDifference(oldReactions, newReactions);
144
+ const actor = reaction.user || reaction.actor_chat || {};
145
+ const actorId = reaction.user?.id || reaction.actor_chat?.id || "unknown";
146
+
147
+ return [
148
+ "Incoming Telegram reaction.",
149
+ `chatId: ${reaction.chat.id}`,
150
+ `userId: ${actorId}`,
151
+ `username: ${reaction.user?.username || "(no username)"}`,
152
+ `reactedMessageId: ${reaction.message_id}`,
153
+ reactedMessageText ? `reactedMessageText: ${reactedMessageText}` : null,
154
+ added.length ? `addedReactions: ${added.join(" ")}` : null,
155
+ removed.length ? `removedReactions: ${removed.join(" ")}` : null,
156
+ `currentReactions: ${newReactions.map(reactionLabel).join(" ") || "none"}`,
157
+ `actor: ${telegramDisplayName(actor)}`,
158
+ "Treat this as lightweight feedback on the referenced message. Respond only if the reaction clearly requests action; otherwise stay silent."
159
+ ].filter(Boolean).join("\n");
160
+ }
161
+
98
162
  function baseMimeType(mimeType = "") {
99
163
  return mimeType.split(";")[0].trim().toLowerCase();
100
164
  }
@@ -122,6 +186,7 @@ export function buildPrompt({ ctx, artifact, transcript, toolResult }) {
122
186
 
123
187
  const messageText = getIncomingMessageText(ctx.message);
124
188
  if (messageText) parts.push(`text: ${messageText}`);
189
+ parts.push(...forwardedMessageSummary(ctx.message));
125
190
  parts.push(...quotedMessageSummary(ctx.message?.reply_to_message));
126
191
  if (shouldIncludeArtifactReference({ artifact, messageText })) {
127
192
  if (artifact?.path) parts.push(`artifactPath: ${artifact.path}`);
@@ -363,7 +428,8 @@ export function createChatStateStore() {
363
428
  historyRevision: 0,
364
429
  beforeNextPrompt: null,
365
430
  activeSession: null,
366
- activeSteers: []
431
+ activeSteers: [],
432
+ assistantMessages: new Map()
367
433
  };
368
434
  states.set(String(chatId), state);
369
435
  return state;
@@ -477,7 +543,7 @@ export async function closeModelPicker(ctx, { messageText, callbackText }) {
477
543
  await ctx.answerCallbackQuery({ text: callbackText });
478
544
  }
479
545
 
480
- export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, doctor, requestRestart, logger }) {
546
+ export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, doctor, checkUpdates, requestRestart, logger }) {
481
547
  const bot = new Bot(config.telegram.token);
482
548
  const perChatState = createChatStateStore();
483
549
  const conversationHistory = new ConversationHistoryStore();
@@ -810,7 +876,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
810
876
  }
811
877
 
812
878
  logger?.log("telegram", `sending text reply for chat ${chatId}`);
813
- await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
879
+ const sent = await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
880
+ if (sent?.message_id) {
881
+ const messages = getChatState(chatId).assistantMessages;
882
+ messages.set(sent.message_id, text);
883
+ while (messages.size > 50) messages.delete(messages.keys().next().value);
884
+ }
814
885
  }
815
886
 
816
887
  function createTelegramSessionBridge(chatId) {
@@ -1152,14 +1223,41 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1152
1223
  bot.command("doctor", async (ctx) => {
1153
1224
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1154
1225
  if (!auth.ok) return;
1155
- await withTyping(ctx, async () => {
1156
- try {
1157
- await ctx.reply(formatDoctorReport(await doctor()));
1158
- } catch (error) {
1159
- logger?.error("doctor", `doctor command failed: ${getErrorMessage(error)}`);
1160
- await ctx.reply(`Arisa Doctor failed: ${getErrorMessage(error)}`);
1161
- }
1162
- });
1226
+ const pending = await ctx.reply(renderTelegramHtml("```text\nRunning Arisa Doctor…\n```"), { parse_mode: "HTML" });
1227
+ try {
1228
+ await ctx.api.editMessageText(
1229
+ ctx.chat.id,
1230
+ pending.message_id,
1231
+ renderTelegramHtml(formatDoctorReport(await doctor())),
1232
+ { parse_mode: "HTML" }
1233
+ );
1234
+ } catch (error) {
1235
+ logger?.error("doctor", `doctor command failed: ${getErrorMessage(error)}`);
1236
+ await ctx.api.editMessageText(ctx.chat.id, pending.message_id, `Arisa Doctor failed: ${getErrorMessage(error)}`);
1237
+ }
1238
+ });
1239
+
1240
+ bot.command("update", async (ctx) => {
1241
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1242
+ if (!auth.ok) return;
1243
+ const pending = await ctx.reply(renderTelegramHtml("```text\nChecking Arisa and official tool updates…\n```"), { parse_mode: "HTML" });
1244
+ try {
1245
+ await ctx.api.editMessageText(
1246
+ ctx.chat.id,
1247
+ pending.message_id,
1248
+ renderTelegramHtml(await checkUpdates(ctx.chat.id)),
1249
+ { parse_mode: "HTML" }
1250
+ );
1251
+ } catch (error) {
1252
+ logger?.error("update", `update check failed: ${getErrorMessage(error)}`);
1253
+ await ctx.api.editMessageText(ctx.chat.id, pending.message_id, `Arisa update check failed: ${getErrorMessage(error)}`);
1254
+ }
1255
+ });
1256
+
1257
+ bot.command("tools", async (ctx) => {
1258
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1259
+ if (!auth.ok) return;
1260
+ await ctx.reply(renderTelegramHtml(formatToolUsageReport(await toolRegistry.usage(ctx.chat.id))), { parse_mode: "HTML" });
1163
1261
  });
1164
1262
 
1165
1263
  bot.command("model", async (ctx) => {
@@ -1430,6 +1528,25 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1430
1528
  }
1431
1529
  });
1432
1530
 
1531
+ bot.on("message_reaction", async (ctx) => {
1532
+ const reaction = ctx.messageReaction;
1533
+ const chatId = reaction.chat.id;
1534
+ const auth = await authorizeChat({ config, chatId, saveConfig });
1535
+ if (!auth.ok || piAuthIssue) return;
1536
+
1537
+ const reactedMessageText = getChatState(chatId).assistantMessages.get(reaction.message_id) || "";
1538
+ const prompt = buildReactionPrompt({ reaction, reactedMessageText });
1539
+ enqueuePrompt({
1540
+ chatId,
1541
+ prompt,
1542
+ label: `reaction to message ${reaction.message_id}`,
1543
+ busyMessageMode: "queue"
1544
+ }).catch((error) => {
1545
+ getChatState(chatId).processing = false;
1546
+ logger?.error("telegram", `reaction handling failed for chat ${chatId}: ${getErrorMessage(error)}`);
1547
+ });
1548
+ });
1549
+
1433
1550
  bot.on("message", async (ctx) => {
1434
1551
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
1435
1552
  if (!auth.ok) return;
@@ -1477,7 +1594,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1477
1594
  await bot.api.deleteWebhook({ drop_pending_updates: true });
1478
1595
  logger?.log("telegram", "bot polling started");
1479
1596
  scheduleStartupMessages({ skipAgentStartupPrompts });
1480
- await bot.start();
1597
+ await bot.start({ allowed_updates: ["message", "callback_query", "message_reaction"] });
1481
1598
  },
1482
1599
 
1483
1600
  async stop() {
@@ -95,7 +95,8 @@ test("chat state uses one queue for numeric and string chat IDs", () => {
95
95
  historyRevision: 0,
96
96
  beforeNextPrompt: null,
97
97
  activeSession: null,
98
- activeSteers: []
98
+ activeSteers: [],
99
+ assistantMessages: new Map()
99
100
  });
100
101
  });
101
102
 
@@ -28,6 +28,20 @@ function runtime(overrides = {}) {
28
28
  };
29
29
  }
30
30
 
31
+ const system = {
32
+ platform: "linux x64",
33
+ cpuCores: 4,
34
+ loadAverage: [0.25, 0.5, 0.75],
35
+ memoryTotal: 8 * 1024 ** 3,
36
+ memoryFree: 3 * 1024 ** 3,
37
+ memoryUsed: 5 * 1024 ** 3,
38
+ diskTotal: 100 * 1024 ** 3,
39
+ diskFree: 40 * 1024 ** 3,
40
+ diskUsed: 60 * 1024 ** 3,
41
+ uptimeSeconds: 90061,
42
+ processRss: 256 * 1024 ** 2
43
+ };
44
+
31
45
  async function run({ diagnostic = runtime(), processes = [], service = { running: false }, repairs = [] } = {}) {
32
46
  const stopped = [];
33
47
  const report = await runDoctor({
@@ -39,7 +53,8 @@ async function run({ diagnostic = runtime(), processes = [], service = { running
39
53
  serviceStatus: async () => service,
40
54
  stopProcess: async (pid) => { stopped.push(pid); },
41
55
  stopDaemon: async () => {},
42
- unregisterDaemon: async () => {}
56
+ unregisterDaemon: async () => {},
57
+ inspectResources: async () => system
43
58
  });
44
59
  return { report, stopped };
45
60
  }
@@ -63,7 +78,13 @@ test("reports Pi context size and retained-content inefficiency", async () => {
63
78
  assert.equal(report.contexts[0].level, "warning");
64
79
  assert.match(report.attention.join("\n"), /80,000\/100,000 tokens/);
65
80
  assert.match(report.attention.join("\n"), /tool results occupy 70\.0%/);
66
- assert.match(formatDoctorReport(report), /Core: Pi, 1 active session/);
81
+ const formatted = formatDoctorReport(report);
82
+ assert.match(formatted, /Core\n Runtime Pi/);
83
+ assert.match(formatted, /CPU 4 cores/);
84
+ assert.match(formatted, /Load 0\.25 \/ 0\.50 \/ 0\.75/);
85
+ assert.match(formatted, /Memory 62\.5% \/ 3\.0 GB free/);
86
+ assert.match(formatted, /Disk 60\.0% \/ 40\.0 GB free/);
87
+ assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
67
88
  });
68
89
 
69
90
  test("stops only a registered duplicate Arisa service with verified identity", async () => {
@@ -11,6 +11,7 @@ import {
11
11
  getChatArtifactsDir,
12
12
  getChatConversationHistoryFile,
13
13
  getChatToolConfigPath,
14
+ getChatToolUsageFile,
14
15
  getChatToolStateDir,
15
16
  getToolStateDir,
16
17
  stateDir
@@ -31,6 +32,13 @@ test("keeps portable conversation history scoped below the chat state directory"
31
32
  );
32
33
  });
33
34
 
35
+ test("keeps tool usage scoped below the chat state directory", () => {
36
+ assert.equal(
37
+ getChatToolUsageFile("chat-1"),
38
+ path.join(chatsDir, "chat-1", "state", "tool-usage.json")
39
+ );
40
+ });
41
+
34
42
  test("keeps chat tool state and config paths scoped below the chat directory for normal names", () => {
35
43
  assert.equal(
36
44
  getChatToolStateDir("chat-1", "strudel-agent"),
@@ -8,7 +8,7 @@ test("provides Pi compaction defaults through Arisa config", () => {
8
8
 
9
9
  assert.deepEqual(config.pi.compaction, {
10
10
  enabled: true,
11
- reserveTokens: 16_384,
11
+ reserveTokens: 120_000,
12
12
  keepRecentTokens: 20_000
13
13
  });
14
14
  assert.deepEqual(config.pi.compaction, piConfigDefaults.compaction);
@@ -3,11 +3,14 @@ import test from "node:test";
3
3
  import { createTelegramRestartHandler, telegramCommands } from "../src/transport/telegram/bot.js";
4
4
  import { handoffServiceRestart, restartService, serviceEntryFile, waitForServiceStop } from "../src/runtime/service-manager.js";
5
5
 
6
- test("registers /restart as a native Telegram command", () => {
6
+ test("registers maintenance as native Telegram commands", () => {
7
7
  assert.equal(
8
8
  telegramCommands.some((command) => command.command === "restart"),
9
9
  true
10
10
  );
11
+ assert.equal(telegramCommands.some((command) => command.command === "update"), true);
12
+ assert.equal(telegramCommands.some((command) => command.command === "tools"), true);
13
+ assert.ok(telegramCommands.every((command) => command.description.length <= 24));
11
14
  assert.equal(telegramCommands.some((command) => command.command === "harness"), false);
12
15
  assert.equal(telegramCommands.some((command) => command.command === "login"), false);
13
16
  });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { buildPrompt, shouldIncludeArtifactReference } from "../src/transport/telegram/bot.js";
3
+ import { buildPrompt, buildReactionPrompt, shouldIncludeArtifactReference } from "../src/transport/telegram/bot.js";
4
4
  import { captureIncomingArtifact } from "../src/transport/telegram/media.js";
5
5
 
6
6
  function createTextContext(text = "hello") {
@@ -60,6 +60,41 @@ test("keeps distinct artifacts visible to the prompt", () => {
60
60
  );
61
61
  });
62
62
 
63
+ test("surfaces Telegram forwarding provenance in the prompt", () => {
64
+ const ctx = createTextContext("forwarded text");
65
+ ctx.message.forward_origin = {
66
+ type: "user",
67
+ sender_user: { id: 999, username: "source_user", first_name: "Source" },
68
+ date: 1_786_570_000
69
+ };
70
+
71
+ const prompt = buildPrompt({ ctx });
72
+
73
+ assert.match(prompt, /forwarded: true/);
74
+ assert.match(prompt, /forwardedOriginType: user/);
75
+ assert.match(prompt, /forwardedFrom: @source_user/);
76
+ assert.match(prompt, /forwardedAt: 2026-/);
77
+ });
78
+
79
+ test("formats Telegram reaction changes as lightweight feedback", () => {
80
+ const prompt = buildReactionPrompt({
81
+ reaction: {
82
+ chat: { id: 123 },
83
+ user: { id: 456, username: "martin", first_name: "Martin" },
84
+ message_id: 321,
85
+ old_reaction: [{ type: "emoji", emoji: "👍" }],
86
+ new_reaction: [{ type: "emoji", emoji: "❤️" }]
87
+ },
88
+ reactedMessageText: "Updated draft intro"
89
+ });
90
+
91
+ assert.match(prompt, /reactedMessageId: 321/);
92
+ assert.match(prompt, /reactedMessageText: Updated draft intro/);
93
+ assert.match(prompt, /addedReactions: ❤️/);
94
+ assert.match(prompt, /removedReactions: 👍/);
95
+ assert.match(prompt, /otherwise stay silent/);
96
+ });
97
+
63
98
  test("marks incoming Telegram text artifacts as internal inline messages", async () => {
64
99
  const calls = [];
65
100
  const artifactStore = {
@@ -155,12 +155,33 @@ test("runs a registered tool process with an enriched request and cleans up requ
155
155
  });
156
156
  assert.equal(result.output.env.ARISA_PACKAGE_DIR, arisaPackageDir);
157
157
  assert.equal(result.output.env.ARISA_IPC_SOCKET, arisaIpcSocketFile);
158
+ assert.deepEqual(await registry.usage("chat-1"), [{ name: "fake-tool", count: 1 }]);
158
159
 
159
160
  const requestFile = result.output.requestFile;
160
161
  await assert.rejects(() => access(requestFile), { code: "ENOENT" });
161
162
  await assert.rejects(() => access(path.dirname(requestFile)), { code: "ENOENT" });
162
163
  });
163
164
 
165
+ test("keeps concurrent requests to the same tool isolated", async () => {
166
+ await resetHome();
167
+ await createFakeTool("fake-tool");
168
+
169
+ const registry = new ToolRegistry();
170
+ await registry.load();
171
+
172
+ const results = await Promise.all(Array.from({ length: 12 }, (_, index) => registry.run({
173
+ name: "fake-tool",
174
+ chatId: "chat-1",
175
+ request: { text: `request-${index}`, args: { index } }
176
+ })));
177
+
178
+ assert.deepEqual(
179
+ results.map((result) => result.output.request.text).sort(),
180
+ Array.from({ length: 12 }, (_, index) => `request-${index}`).sort()
181
+ );
182
+ assert.equal(new Set(results.map((result) => result.output.requestFile)).size, 12);
183
+ });
184
+
164
185
  test("rejects unknown tools", async () => {
165
186
  await resetHome();
166
187
  const registry = new ToolRegistry();
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { ToolUsageStore } from "../src/core/tools/tool-usage-store.js";
7
+ import { formatToolUsageReport } from "../src/runtime/tool-usage-report.js";
8
+
9
+ test("counts concurrent tool uses per chat", async () => {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "arisa-tool-usage-"));
11
+ const store = new ToolUsageStore({ resolveFile: (chatId) => path.join(root, String(chatId), "usage.json") });
12
+ try {
13
+ await Promise.all([
14
+ store.record("chat-1", "gmail-workspace"),
15
+ store.record("chat-1", "gmail-workspace"),
16
+ store.record("chat-1", "x-reader"),
17
+ store.record("chat-2", "gmail-workspace")
18
+ ]);
19
+ assert.deepEqual(await store.counts("chat-1"), {
20
+ "gmail-workspace": 2,
21
+ "x-reader": 1
22
+ });
23
+ assert.deepEqual(await store.counts("chat-2"), { "gmail-workspace": 1 });
24
+ } finally {
25
+ await rm(root, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ test("formats narrow tool usage counts with bullets and right-aligned numbers", () => {
30
+ const report = formatToolUsageReport([
31
+ { name: "gmail-workspace", count: 3 },
32
+ { name: "campaign-draft-runner", count: 12 }
33
+ ]);
34
+ assert.match(report, /- campaign-draft-runner 12/);
35
+ assert.match(report, /- gmail-workspace\s+3/);
36
+ const rows = report.split("\n").filter((line) => line.startsWith("- "));
37
+ assert.match(rows[0], /campaign-draft-runner/);
38
+ assert.match(rows[1], /gmail-workspace/);
39
+ assert.deepEqual(rows.map((line) => line.match(/\d+$/).index + line.match(/\d+$/)[0].length), [27, 27]);
40
+ assert.deepEqual(rows.map((line) => line.length), [27, 27]);
41
+ assert.ok(report.split("\n").every((line) => [...line].length <= 35));
42
+ });
@@ -0,0 +1,87 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { compareVersions, formatUpdateReport } from "../src/runtime/update-manager.js";
4
+
5
+ test("compares semantic versions", () => {
6
+ assert.equal(compareVersions("5.0.2", "5.0.3"), -1);
7
+ assert.equal(compareVersions("5.1.0", "5.0.9"), 1);
8
+ assert.equal(compareVersions("5.0.2", "5.0.2"), 0);
9
+ assert.equal(compareVersions("invalid", "5.0.2"), null);
10
+ });
11
+
12
+ test("formats core and official tool update status", () => {
13
+ assert.equal(formatUpdateReport({
14
+ core: { currentVersion: "5.0.2", latestVersion: "5.1.0", updateAvailable: true },
15
+ bootstrapInstalled: ["official-tool-sync"],
16
+ tools: {
17
+ installedOfficial: 3,
18
+ official: [
19
+ { name: "context-vault", status: "up-to-date" },
20
+ { name: "customized", status: "diverged" },
21
+ { name: "gmail-workspace", status: "upstream-update" }
22
+ ],
23
+ nonOfficial: ["private-helper"],
24
+ counts: { "up-to-date": 1, "upstream-update": 1, diverged: 1 },
25
+ updateable: ["context-vault"],
26
+ blocked: [{ name: "customized", status: "diverged" }]
27
+ }
28
+ }), [
29
+ "```text",
30
+ "Arisa update",
31
+ "============",
32
+ "Core",
33
+ " Current 5.0.2",
34
+ " Latest 5.1.0",
35
+ " Status update available",
36
+ "",
37
+ "Official tools",
38
+ " Installed 3",
39
+ " up-to-date 1",
40
+ " upstream-update 1",
41
+ " diverged 1",
42
+ "",
43
+ "Official (3)",
44
+ " - context-vault",
45
+ " - customized [diverged]",
46
+ " - gmail-workspace",
47
+ " [upstream-update]",
48
+ "",
49
+ "Non-official (1)",
50
+ " - private-helper",
51
+ "",
52
+ "Safe updates",
53
+ " - context-vault",
54
+ "",
55
+ "Needs review",
56
+ " - customized",
57
+ " [diverged]",
58
+ "",
59
+ "Update support installed",
60
+ " - official-tool-sync",
61
+ "```"
62
+ ].join("\n"));
63
+ });
64
+
65
+ test("shortens long review status labels", () => {
66
+ const report = formatUpdateReport({
67
+ core: { currentVersion: "5.0.2", latestVersion: "5.0.2", updateAvailable: false },
68
+ bootstrapInstalled: [],
69
+ tools: {
70
+ installedOfficial: 2,
71
+ official: [
72
+ { name: "audio-extractor", status: "locally-modified" },
73
+ { name: "campaign-draft-runner", status: "untracked-difference" }
74
+ ],
75
+ nonOfficial: [],
76
+ counts: {},
77
+ updateable: [],
78
+ blocked: [
79
+ { name: "audio-extractor", status: "locally-modified" },
80
+ { name: "campaign-draft-runner", status: "untracked-difference" }
81
+ ]
82
+ }
83
+ });
84
+ assert.match(report, /audio-extractor\n \[local\]/);
85
+ assert.match(report, /campaign-draft-runner\n \[untracked\]/);
86
+ assert.ok(report.split("\n").every((line) => [...line].length <= 35));
87
+ });