arisa 4.3.5 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +21 -19
  2. package/README.md +30 -9
  3. package/package.json +6 -2
  4. package/pnpm-workspace.yaml +1 -0
  5. package/src/core/agent/agent-manager.js +288 -29
  6. package/src/core/agent/auth-flow.js +12 -8
  7. package/src/core/agent/model-selection.js +54 -14
  8. package/src/core/agent/model-speed.js +59 -0
  9. package/src/core/config/config-defaults.js +56 -4
  10. package/src/core/config/config-store.js +5 -1
  11. package/src/core/conversation/conversation-history-store.js +142 -0
  12. package/src/core/tasks/task-store.js +16 -0
  13. package/src/core/tools/daemon-health.js +11 -2
  14. package/src/core/tools/daemon-processes.js +92 -2
  15. package/src/core/tools/daemon-runtime.js +4 -2
  16. package/src/core/tools/ipc-client.js +15 -3
  17. package/src/core/tools/tool-registry.js +42 -2
  18. package/src/core/tools/tool-usage-store.js +59 -0
  19. package/src/index.js +61 -6
  20. package/src/runtime/arisa-capabilities.js +45 -1
  21. package/src/runtime/bootstrap.js +3 -2
  22. package/src/runtime/create-app.js +49 -11
  23. package/src/runtime/doctor.js +365 -0
  24. package/src/runtime/log-viewer.js +165 -0
  25. package/src/runtime/paths.js +8 -1
  26. package/src/runtime/report-format.js +51 -0
  27. package/src/runtime/service-manager.js +106 -8
  28. package/src/runtime/tool-process-supervisor.js +107 -10
  29. package/src/runtime/tool-usage-report.js +10 -0
  30. package/src/runtime/update-manager.js +206 -0
  31. package/src/transport/telegram/bot.js +633 -91
  32. package/src/transport/telegram/model-picker.js +28 -2
  33. package/test/agent-tool-policy.test.js +26 -1
  34. package/test/auth-flow.test.js +28 -2
  35. package/test/capabilities-security.test.js +37 -0
  36. package/test/context-and-task-bounds.test.js +280 -0
  37. package/test/daemon-runtime.test.js +130 -2
  38. package/test/dependency-warnings.test.js +17 -0
  39. package/test/doctor.test.js +111 -0
  40. package/test/log-viewer.test.js +90 -0
  41. package/test/model-selection.test.js +125 -2
  42. package/test/paths.test.js +16 -0
  43. package/test/pi-compaction.test.js +43 -0
  44. package/test/service-manager.test.js +237 -0
  45. package/test/task-store.test.js +31 -0
  46. package/test/telegram-text-artifact.test.js +36 -1
  47. package/test/tool-registry-run.test.js +21 -0
  48. package/test/tool-usage.test.js +37 -0
  49. package/test/update-manager.test.js +87 -0
@@ -0,0 +1,365 @@
1
+ import { execFile } from "node:child_process";
2
+ import { statfs } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import process from "node:process";
5
+ import { promisify } from "node:util";
6
+ import { stopManagedDaemon, unregisterManagedDaemon } from "../core/tools/daemon-processes.js";
7
+ import { getServiceStatus, serviceEntryFile } from "./service-manager.js";
8
+ import { arisaHomeDir } from "./paths.js";
9
+ import { renderTextReport, reportRow, wrapReportText } from "./report-format.js";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ function parsePosixProcesses(output) {
14
+ return String(output || "")
15
+ .split("\n")
16
+ .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
17
+ .filter(Boolean)
18
+ .map((match) => ({ pid: Number(match[1]), command: match[2] }));
19
+ }
20
+
21
+ function parseWindowsProcesses(output) {
22
+ const parsed = JSON.parse(output || "[]");
23
+ const records = Array.isArray(parsed) ? parsed : [parsed];
24
+ return records
25
+ .filter((record) => record?.ProcessId && record?.CommandLine)
26
+ .map((record) => ({ pid: Number(record.ProcessId), command: String(record.CommandLine) }));
27
+ }
28
+
29
+ export async function listSystemProcesses({ timeoutMs, platform = process.platform } = {}) {
30
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
31
+ throw new Error("Doctor process inspection requires a positive timeoutMs");
32
+ }
33
+ if (platform === "win32") {
34
+ const { stdout } = await execFileAsync("powershell.exe", [
35
+ "-NoProfile",
36
+ "-Command",
37
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
38
+ ], { timeout: timeoutMs, windowsHide: true });
39
+ return parseWindowsProcesses(stdout);
40
+ }
41
+ const { stdout } = await execFileAsync("ps", ["-ww", "-axo", "pid=,command="], { timeout: timeoutMs });
42
+ return parsePosixProcesses(stdout);
43
+ }
44
+
45
+ function isArisaServiceProcess(record) {
46
+ return record.command.includes(serviceEntryFile)
47
+ && /(?:^|\s)--service-runner(?:\s|$)/.test(record.command);
48
+ }
49
+
50
+ function isDaemonProcess(record, daemon) {
51
+ return record.command.includes(daemon.entryPath)
52
+ && /(?:^|\s)daemon(?:\s|$)/.test(record.command);
53
+ }
54
+
55
+ function sleep(ms) {
56
+ return new Promise((resolve) => setTimeout(resolve, ms));
57
+ }
58
+
59
+ function isAlive(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export async function terminateProcess(pid, { forceAfterMs } = {}) {
69
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
70
+ throw new Error(`Refusing to terminate invalid doctor target: ${pid}`);
71
+ }
72
+ if (!Number.isFinite(forceAfterMs) || forceAfterMs <= 0) {
73
+ throw new Error("Doctor process cleanup requires a positive forceAfterMs");
74
+ }
75
+ if (!isAlive(pid)) return false;
76
+ process.kill(pid, "SIGTERM");
77
+ const startedAt = Date.now();
78
+ while (Date.now() - startedAt < forceAfterMs) {
79
+ if (!isAlive(pid)) return true;
80
+ await sleep(Math.min(100, forceAfterMs));
81
+ }
82
+ if (isAlive(pid)) process.kill(pid, "SIGKILL");
83
+ return true;
84
+ }
85
+
86
+ function daemonLabel(record) {
87
+ return `${record.toolName} (${record.instanceId || "global"})`;
88
+ }
89
+
90
+ function daemonResultSummary(results) {
91
+ const states = new Map();
92
+ for (const result of results) {
93
+ const state = result.diagnostic?.state || result.outcome;
94
+ states.set(state, (states.get(state) || 0) + 1);
95
+ }
96
+ return [...states.entries()]
97
+ .sort(([left], [right]) => left.localeCompare(right))
98
+ .map(([state, count]) => `${count} ${state}`)
99
+ .join(", ");
100
+ }
101
+
102
+ function formatTokenCount(tokens) {
103
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(tokens);
104
+ }
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
+
149
+ function assertDoctorPolicy(policy) {
150
+ const positiveValues = [
151
+ "contextInspectionTimeoutMs",
152
+ "contextWarningPercent",
153
+ "contextCriticalPercent",
154
+ "contextInefficientMinTokens",
155
+ "contextToolResultWarningPercent",
156
+ "contextSingleMessageWarningPercent"
157
+ ];
158
+ for (const name of positiveValues) {
159
+ if (!Number.isFinite(policy?.[name]) || policy[name] <= 0) {
160
+ throw new Error(`Doctor configuration requires a positive ${name}`);
161
+ }
162
+ }
163
+ if (policy.contextCriticalPercent <= policy.contextWarningPercent) {
164
+ throw new Error("Doctor contextCriticalPercent must be greater than contextWarningPercent");
165
+ }
166
+ }
167
+
168
+ function evaluateContext(context, policy) {
169
+ if (context.error) return { ...context, level: "unknown", inefficiencies: [] };
170
+ const percent = context.percent;
171
+ const level = !Number.isFinite(percent)
172
+ ? "unknown"
173
+ : percent >= policy.contextCriticalPercent
174
+ ? "critical"
175
+ : percent >= policy.contextWarningPercent ? "warning" : "healthy";
176
+ const inefficiencies = [];
177
+ if (context.estimatedTokens >= policy.contextInefficientMinTokens) {
178
+ if (context.toolResultPercent >= policy.contextToolResultWarningPercent) {
179
+ inefficiencies.push(`tool results occupy ${context.toolResultPercent.toFixed(1)}% of retained content`);
180
+ }
181
+ if (context.largestMessagePercent >= policy.contextSingleMessageWarningPercent) {
182
+ inefficiencies.push(`one message occupies ${context.largestMessagePercent.toFixed(1)}% of retained content`);
183
+ }
184
+ }
185
+ return { ...context, level, inefficiencies };
186
+ }
187
+
188
+ function addContextAttention(report) {
189
+ for (const context of report.contexts) {
190
+ const label = `Chat ${context.chatId}`;
191
+ if (context.error) {
192
+ report.attention.push(`${label} context inspection failed: ${context.error}`);
193
+ continue;
194
+ }
195
+ if (context.level === "warning" || context.level === "critical") {
196
+ const size = context.tokens == null || context.contextWindow == null
197
+ ? `${context.percent.toFixed(1)}% of its context window`
198
+ : `${formatTokenCount(context.tokens)}/${formatTokenCount(context.contextWindow)} tokens (${context.percent.toFixed(1)}%)`;
199
+ const severity = context.level === "critical" ? "critically large" : "large";
200
+ report.attention.push(`${label} context is ${severity}: ${size}. Use /new to carry durable context into a fresh session.`);
201
+ }
202
+ if (context.inefficiencies.length) {
203
+ report.attention.push(`${label} context may be inefficient: ${formatTokenCount(context.estimatedTokens)} estimated retained tokens; ${context.inefficiencies.join("; ")}.`);
204
+ }
205
+ }
206
+ }
207
+
208
+ export function formatDoctorReport(report) {
209
+ const status = report.attention.length
210
+ ? "attention needed"
211
+ : report.repairs.length ? "repaired" : "healthy";
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}`));
241
+ }
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);
247
+ }
248
+
249
+ export async function runDoctor({
250
+ agentManager,
251
+ toolProcessSupervisor,
252
+ daemonPolicy,
253
+ doctorPolicy,
254
+ logger,
255
+ listProcesses = listSystemProcesses,
256
+ stopProcess = terminateProcess,
257
+ serviceStatus = getServiceStatus,
258
+ stopDaemon = stopManagedDaemon,
259
+ unregisterDaemon = unregisterManagedDaemon,
260
+ inspectResources = inspectSystemResources
261
+ }) {
262
+ assertDoctorPolicy(doctorPolicy);
263
+ const runtime = await agentManager.getRuntimeDiagnostic({
264
+ contextInspectionTimeoutMs: doctorPolicy.contextInspectionTimeoutMs
265
+ });
266
+ const report = {
267
+ runtime,
268
+ contexts: runtime.contexts.map((context) => evaluateContext(context, doctorPolicy)),
269
+ daemons: [],
270
+ repairs: [],
271
+ attention: [],
272
+ system: null,
273
+ systemError: null
274
+ };
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
+ }
282
+ let processes = [];
283
+ try {
284
+ processes = await listProcesses({ timeoutMs: daemonPolicy.healthTimeoutMs });
285
+ } catch (error) {
286
+ report.attention.push(`Process inspection failed: ${error?.message || error}`);
287
+ }
288
+
289
+ const processByPid = new Map(processes.map((record) => [record.pid, record]));
290
+ const currentService = await serviceStatus();
291
+ if (currentService.running && currentService.pid !== process.pid) {
292
+ const registered = processByPid.get(currentService.pid);
293
+ if (registered && isArisaServiceProcess(registered)) {
294
+ try {
295
+ await stopProcess(currentService.pid, { forceAfterMs: daemonPolicy.stopTimeoutMs });
296
+ report.repairs.push(`Stopped duplicate Arisa service process ${currentService.pid}.`);
297
+ } catch (error) {
298
+ report.attention.push(`Duplicate Arisa service process ${currentService.pid} could not be stopped: ${error?.message || error}`);
299
+ }
300
+ } else {
301
+ report.attention.push(`Registered service process ${currentService.pid} could not be verified and was left running.`);
302
+ }
303
+ }
304
+
305
+ try {
306
+ report.daemons = await toolProcessSupervisor.repair();
307
+ } catch (error) {
308
+ report.attention.push(`Daemon reconciliation failed: ${error?.message || error}`);
309
+ }
310
+ for (const result of report.daemons) {
311
+ const label = daemonLabel(result.record);
312
+ if (result.outcome === "stale-registration") {
313
+ const pid = result.diagnostic?.pid;
314
+ const registered = pid ? processByPid.get(pid) : null;
315
+ if (pid && (!registered || !isDaemonProcess(registered, result.record))) {
316
+ report.attention.push(`${label} has a stale registration, but its live process identity could not be verified.`);
317
+ continue;
318
+ }
319
+ try {
320
+ if (pid) {
321
+ await stopProcess(pid, { forceAfterMs: daemonPolicy.stopTimeoutMs });
322
+ await stopDaemon(
323
+ { toolName: result.record.toolName, scope: result.record.scope },
324
+ { state: null }
325
+ );
326
+ }
327
+ await unregisterDaemon({ toolName: result.record.toolName, scope: result.record.scope });
328
+ report.repairs.push(`Removed stale daemon registration ${label}: ${result.reason}.`);
329
+ } catch (error) {
330
+ report.attention.push(`${label} stale registration could not be removed: ${error?.message || error}`);
331
+ }
332
+ continue;
333
+ }
334
+ if (result.outcome === "missing-entry") {
335
+ const pid = result.diagnostic?.pid;
336
+ const registered = pid ? processByPid.get(pid) : null;
337
+ if (registered && isDaemonProcess(registered, result.record)) {
338
+ try {
339
+ await stopProcess(pid, { forceAfterMs: daemonPolicy.stopTimeoutMs });
340
+ await stopDaemon(
341
+ { toolName: result.record.toolName, scope: result.record.scope },
342
+ { state: "failed", message: "Orphaned daemon stopped because its tool entry is missing" }
343
+ );
344
+ report.repairs.push(`Stopped orphaned daemon ${label}.`);
345
+ } catch (error) {
346
+ report.attention.push(`${label} could not be stopped: ${error?.message || error}`);
347
+ }
348
+ } else {
349
+ report.attention.push(`${label} has a missing entry${pid ? "; its process identity could not be verified" : ""}.`);
350
+ }
351
+ continue;
352
+ }
353
+ if (["started", "recovered", "restart-scheduled"].includes(result.outcome)) {
354
+ report.repairs.push(`${label}: ${result.outcome}.`);
355
+ }
356
+ if (result.outcome === "error") {
357
+ report.attention.push(`${label}: daemon reconciliation failed.`);
358
+ } else if (result.diagnostic?.disposition === "requires-attention") {
359
+ report.attention.push(`${label}: ${result.diagnostic.message || result.diagnostic.state}.`);
360
+ }
361
+ }
362
+
363
+ logger?.log("doctor", `completed with ${report.repairs.length} repair(s) and ${report.attention.length} attention item(s)`);
364
+ return report;
365
+ }
@@ -0,0 +1,165 @@
1
+ import { open, readFile, stat } from "node:fs/promises";
2
+ import { cliLogConfig } from "../core/config/config-defaults.js";
3
+ import { ensureArisaHome, serviceLogFile } from "./paths.js";
4
+
5
+ const readChunkSize = 64 * 1024;
6
+
7
+ async function getFileState(logFile) {
8
+ try {
9
+ const details = await stat(logFile);
10
+ return { size: details.size, ino: details.ino };
11
+ } catch (error) {
12
+ if (error?.code === "ENOENT") return { size: 0, ino: null };
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ export async function readRecentLogLines(logFile, lineCount) {
18
+ if (!Number.isSafeInteger(lineCount) || lineCount < 1) {
19
+ throw new Error("Log line count must be a positive integer");
20
+ }
21
+
22
+ const state = await getFileState(logFile);
23
+ if (state.size === 0) return { text: "", endsWithNewline: false, ...state };
24
+
25
+ const handle = await open(logFile, "r");
26
+ const chunks = [];
27
+ let position = state.size;
28
+ let newlineCount = 0;
29
+
30
+ try {
31
+ while (position > 0 && newlineCount <= lineCount) {
32
+ const length = Math.min(readChunkSize, position);
33
+ position -= length;
34
+ const chunk = Buffer.allocUnsafe(length);
35
+ const { bytesRead } = await handle.read(chunk, 0, length, position);
36
+ const content = chunk.subarray(0, bytesRead);
37
+ chunks.unshift(content);
38
+ for (const byte of content) {
39
+ if (byte === 0x0a) newlineCount += 1;
40
+ }
41
+ }
42
+ } finally {
43
+ await handle.close();
44
+ }
45
+
46
+ const content = Buffer.concat(chunks).toString("utf8");
47
+ const endsWithNewline = content.endsWith("\n");
48
+ const lines = content.split("\n");
49
+ if (endsWithNewline) lines.pop();
50
+ return {
51
+ text: lines.slice(-lineCount).join("\n"),
52
+ endsWithNewline,
53
+ ...state
54
+ };
55
+ }
56
+
57
+ async function readAppendedBytes(logFile, position, size) {
58
+ if (size <= position) return "";
59
+ const handle = await open(logFile, "r");
60
+ const chunks = [];
61
+ let cursor = position;
62
+
63
+ try {
64
+ while (cursor < size) {
65
+ const length = Math.min(readChunkSize, size - cursor);
66
+ const chunk = Buffer.allocUnsafe(length);
67
+ const { bytesRead } = await handle.read(chunk, 0, length, cursor);
68
+ if (bytesRead === 0) break;
69
+ chunks.push(chunk.subarray(0, bytesRead));
70
+ cursor += bytesRead;
71
+ }
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+
76
+ return Buffer.concat(chunks).toString("utf8");
77
+ }
78
+
79
+ function waitForNextPoll(intervalMs, signal) {
80
+ return new Promise((resolve) => {
81
+ if (signal?.aborted) {
82
+ resolve();
83
+ return;
84
+ }
85
+ const finish = () => {
86
+ clearTimeout(timer);
87
+ signal?.removeEventListener("abort", finish);
88
+ resolve();
89
+ };
90
+ const timer = setTimeout(finish, intervalMs);
91
+ signal?.addEventListener("abort", finish, { once: true });
92
+ });
93
+ }
94
+
95
+ export async function followLogFile({
96
+ logFile,
97
+ initialSize,
98
+ initialIno,
99
+ write,
100
+ signal,
101
+ pollIntervalMs = cliLogConfig.followPollIntervalMs
102
+ }) {
103
+ let position = initialSize;
104
+ let ino = initialIno;
105
+
106
+ while (!signal?.aborted) {
107
+ await waitForNextPoll(pollIntervalMs, signal);
108
+ if (signal?.aborted) break;
109
+
110
+ const state = await getFileState(logFile);
111
+ if (state.ino !== ino || state.size < position) {
112
+ position = 0;
113
+ ino = state.ino;
114
+ }
115
+ if (state.size === position) continue;
116
+
117
+ try {
118
+ const appended = await readAppendedBytes(logFile, position, state.size);
119
+ if (appended) write(appended);
120
+ position = state.size;
121
+ } catch (error) {
122
+ if (error?.code !== "ENOENT") throw error;
123
+ position = 0;
124
+ ino = null;
125
+ }
126
+ }
127
+ }
128
+
129
+ export async function showServiceLogs({
130
+ version,
131
+ follow = true,
132
+ output = process.stdout,
133
+ signal
134
+ } = {}) {
135
+ if (!version) throw new Error("Arisa version is required");
136
+ await ensureArisaHome();
137
+
138
+ const snapshot = await readRecentLogLines(serviceLogFile, cliLogConfig.recentLines);
139
+ output.write(`Arisa v${version} | Recent logs\n`);
140
+ output.write(`${follow ? "Following new logs; press Ctrl+C to exit." : "Log follow disabled."}\n\n`);
141
+ if (snapshot.text) {
142
+ output.write(snapshot.text);
143
+ if (snapshot.endsWithNewline) output.write("\n");
144
+ } else {
145
+ output.write("No logs yet.\n");
146
+ }
147
+
148
+ if (!follow) return;
149
+ await followLogFile({
150
+ logFile: serviceLogFile,
151
+ initialSize: snapshot.size,
152
+ initialIno: snapshot.ino,
153
+ write: (content) => output.write(content),
154
+ signal
155
+ });
156
+ }
157
+
158
+ export async function readPackageVersion() {
159
+ const packageFile = new URL("../../package.json", import.meta.url);
160
+ const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
161
+ if (typeof packageJson.version !== "string" || !packageJson.version) {
162
+ throw new Error("Arisa package version is missing");
163
+ }
164
+ return packageJson.version;
165
+ }
@@ -39,6 +39,14 @@ export function getChatArtifactsIndexFile(chatId) {
39
39
  return path.join(getChatDir(chatId), "state", "artifacts.json");
40
40
  }
41
41
 
42
+ export function getChatConversationHistoryFile(chatId) {
43
+ return path.join(getChatDir(chatId), "state", "conversation.jsonl");
44
+ }
45
+
46
+ export function getChatToolUsageFile(chatId) {
47
+ return path.join(getChatDir(chatId), "state", "tool-usage.json");
48
+ }
49
+
42
50
  export function getChatToolStateDir(chatId, toolName) {
43
51
  return path.join(getChatDir(chatId), "state", "tools", toolName);
44
52
  }
@@ -119,4 +127,3 @@ export async function ensureArisaHome() {
119
127
  await mkdir(toolsDir, { recursive: true });
120
128
  await mkdir(chatsDir, { recursive: true });
121
129
  }
122
-
@@ -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
+ }