arisa 5.0.2 → 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.
- package/AGENTS.md +3 -2
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/core/config/config-defaults.js +1 -1
- package/src/core/tools/tool-registry.js +15 -2
- package/src/core/tools/tool-usage-store.js +59 -0
- package/src/runtime/create-app.js +2 -0
- package/src/runtime/doctor.js +92 -34
- package/src/runtime/paths.js +4 -0
- package/src/runtime/report-format.js +51 -0
- package/src/runtime/tool-usage-report.js +10 -0
- package/src/runtime/update-manager.js +206 -0
- package/src/transport/telegram/bot.js +136 -19
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/doctor.test.js +23 -2
- package/test/paths.test.js +8 -0
- package/test/pi-compaction.test.js +1 -1
- package/test/service-manager.test.js +4 -1
- package/test/telegram-text-artifact.test.js +36 -1
- package/test/tool-registry-run.test.js +21 -0
- package/test/tool-usage.test.js +37 -0
- package/test/update-manager.test.js +87 -0
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.
|
|
52
|
+
All textual content generated or sent by Arisa or its tools must use UTF-8.
|
|
53
53
|
|
|
54
|
-
-
|
|
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":
|
|
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,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
|
});
|
package/src/runtime/doctor.js
CHANGED
|
@@ -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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
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 });
|
package/src/runtime/paths.js
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -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: "
|
|
22
|
-
{ command: "restart", description: "Restart
|
|
23
|
-
{ command: "doctor", description: "Check
|
|
24
|
-
{ command: "
|
|
25
|
-
{ command: "
|
|
26
|
-
{ command: "
|
|
27
|
-
{ command: "
|
|
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
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
await
|
|
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() {
|
package/test/doctor.test.js
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/test/paths.test.js
CHANGED
|
@@ -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:
|
|
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
|
|
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,37 @@
|
|
|
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", () => {
|
|
30
|
+
const report = formatToolUsageReport([
|
|
31
|
+
{ name: "campaign-draft-runner", count: 12 },
|
|
32
|
+
{ name: "gmail-workspace", count: 3 }
|
|
33
|
+
]);
|
|
34
|
+
assert.match(report, /campaign-draft-runner\s+12/);
|
|
35
|
+
assert.match(report, /gmail-workspace\s+3/);
|
|
36
|
+
assert.ok(report.split("\n").every((line) => [...line].length <= 35));
|
|
37
|
+
});
|
|
@@ -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
|
+
});
|