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,237 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createTelegramRestartHandler, telegramCommands } from "../src/transport/telegram/bot.js";
4
+ import { handoffServiceRestart, restartService, serviceEntryFile, waitForServiceStop } from "../src/runtime/service-manager.js";
5
+
6
+ test("registers maintenance as native Telegram commands", () => {
7
+ assert.equal(
8
+ telegramCommands.some((command) => command.command === "restart"),
9
+ true
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));
14
+ assert.equal(telegramCommands.some((command) => command.command === "harness"), false);
15
+ assert.equal(telegramCommands.some((command) => command.command === "login"), false);
16
+ });
17
+
18
+ test("replies before handing restart to a detached CLI process", async () => {
19
+ const calls = [];
20
+ let unreferenced = false;
21
+ let logClosed = false;
22
+ const environment = { ARISA_TEST: "restart" };
23
+
24
+ const result = await handoffServiceRestart({
25
+ verbose: false,
26
+ cliArgs: ["--pi.model", "example/model"]
27
+ }, {
28
+ ensureHome: async () => { calls.push("ensure-home"); },
29
+ getStatus: async () => {
30
+ calls.push("get-status");
31
+ return { running: true, pid: 41 };
32
+ },
33
+ openLog: async (file, mode) => {
34
+ calls.push(["open-log", file, mode]);
35
+ return {
36
+ fd: 17,
37
+ close: async () => { logClosed = true; }
38
+ };
39
+ },
40
+ spawnProcess: (command, args, options) => {
41
+ calls.push(["spawn", command, args, options]);
42
+ return {
43
+ pid: 84,
44
+ unref: () => { unreferenced = true; }
45
+ };
46
+ },
47
+ environment,
48
+ currentPid: 41
49
+ });
50
+
51
+ assert.equal(calls[0], "ensure-home");
52
+ assert.equal(calls[1], "get-status");
53
+ assert.equal(calls[2][0], "open-log");
54
+ assert.equal(calls[2][2], "a");
55
+ assert.equal(calls[3][0], "spawn");
56
+ assert.equal(calls[3][1], process.execPath);
57
+ assert.deepEqual(calls[3][2], [
58
+ serviceEntryFile,
59
+ "restart",
60
+ "--pi.model",
61
+ "example/model",
62
+ "--silent"
63
+ ]);
64
+ assert.deepEqual(calls[3][3], {
65
+ detached: true,
66
+ stdio: ["ignore", 17, 17],
67
+ env: environment
68
+ });
69
+ assert.equal(unreferenced, true);
70
+ assert.equal(logClosed, true);
71
+ assert.equal(result.pid, 84);
72
+
73
+ const handlerCalls = [];
74
+ const handler = createTelegramRestartHandler({
75
+ authorize: async () => ({ ok: true }),
76
+ requestRestart: async () => {
77
+ handlerCalls.push("handoff");
78
+ return result;
79
+ }
80
+ });
81
+ const ctx = {
82
+ reply: async (text) => { handlerCalls.push(["reply", text]); }
83
+ };
84
+
85
+ await handler(ctx);
86
+ await handler(ctx);
87
+
88
+ assert.deepEqual(handlerCalls, [
89
+ ["reply", "Arisa is restarting. I'll be back shortly."],
90
+ "handoff",
91
+ ["reply", "An Arisa restart is already in progress."]
92
+ ]);
93
+ });
94
+
95
+ test("refuses Telegram restart handoff outside the active background service", async () => {
96
+ let spawned = false;
97
+
98
+ await assert.rejects(
99
+ handoffServiceRestart({}, {
100
+ ensureHome: async () => {},
101
+ getStatus: async () => ({ running: true, pid: 99 }),
102
+ spawnProcess: () => {
103
+ spawned = true;
104
+ },
105
+ currentPid: 41
106
+ }),
107
+ /requires the active background service process/
108
+ );
109
+
110
+ assert.equal(spawned, false);
111
+ });
112
+
113
+ test("reports a failed Telegram restart handoff and permits retry", async () => {
114
+ const replies = [];
115
+ let attempts = 0;
116
+ const handler = createTelegramRestartHandler({
117
+ authorize: async () => ({ ok: true }),
118
+ requestRestart: async () => {
119
+ attempts += 1;
120
+ if (attempts === 1) throw new Error("synthetic handoff failure");
121
+ return { pid: 84 };
122
+ }
123
+ });
124
+ const ctx = { reply: async (text) => { replies.push(text); } };
125
+
126
+ await handler(ctx);
127
+ await handler(ctx);
128
+
129
+ assert.equal(attempts, 2);
130
+ assert.deepEqual(replies, [
131
+ "Arisa is restarting. I'll be back shortly.",
132
+ "Arisa could not be restarted: synthetic handoff failure",
133
+ "Arisa is restarting. I'll be back shortly."
134
+ ]);
135
+ });
136
+
137
+ test("waits until the stopped service releases its PID before restarting", async () => {
138
+ const calls = [];
139
+ const statuses = [
140
+ { running: true, pid: 41 },
141
+ { running: true, pid: 41 },
142
+ { running: false, pid: null }
143
+ ];
144
+
145
+ const result = await restartService({
146
+ verbose: false,
147
+ cliArgs: ["--pi.model", "example/model"],
148
+ shutdownTimeoutMs: 1_000,
149
+ shutdownPollIntervalMs: 1
150
+ }, {
151
+ stop: async () => {
152
+ calls.push("stop");
153
+ return { ok: true, pid: 41 };
154
+ },
155
+ getStatus: async () => {
156
+ calls.push("status");
157
+ return statuses.shift();
158
+ },
159
+ start: async (options) => {
160
+ calls.push("start");
161
+ assert.deepEqual(options, {
162
+ verbose: false,
163
+ cliArgs: ["--pi.model", "example/model"]
164
+ });
165
+ return { ok: true, pid: 84, logFile: "/tmp/arisa.log" };
166
+ },
167
+ sleep: async () => {
168
+ calls.push("sleep");
169
+ }
170
+ });
171
+
172
+ assert.deepEqual(calls, ["stop", "status", "sleep", "status", "sleep", "status", "start"]);
173
+ assert.deepEqual(result, {
174
+ ok: true,
175
+ pid: 84,
176
+ previousPid: 41,
177
+ wasRunning: true,
178
+ logFile: "/tmp/arisa.log"
179
+ });
180
+ });
181
+
182
+ test("restart starts Arisa when it is not running", async () => {
183
+ let statusChecks = 0;
184
+ const result = await restartService({
185
+ shutdownTimeoutMs: 1_000,
186
+ shutdownPollIntervalMs: 1
187
+ }, {
188
+ stop: async () => ({ ok: false, reason: "not-running", pid: null }),
189
+ getStatus: async () => {
190
+ statusChecks += 1;
191
+ return { running: false, pid: null };
192
+ },
193
+ start: async () => ({ ok: true, pid: 84, logFile: "/tmp/arisa.log" })
194
+ });
195
+
196
+ assert.equal(statusChecks, 0);
197
+ assert.deepEqual(result, {
198
+ ok: true,
199
+ pid: 84,
200
+ previousPid: null,
201
+ wasRunning: false,
202
+ logFile: "/tmp/arisa.log"
203
+ });
204
+ });
205
+
206
+ test("restart does not start a second service when shutdown times out", async () => {
207
+ let started = false;
208
+
209
+ await assert.rejects(
210
+ restartService({
211
+ shutdownTimeoutMs: 2,
212
+ shutdownPollIntervalMs: 1
213
+ }, {
214
+ stop: async () => ({ ok: true, pid: 41 }),
215
+ getStatus: async () => ({ running: true, pid: 41 }),
216
+ start: async () => {
217
+ started = true;
218
+ return { ok: true, pid: 84 };
219
+ },
220
+ sleep: async () => {}
221
+ }),
222
+ /did not stop within 2ms/
223
+ );
224
+
225
+ assert.equal(started, false);
226
+ });
227
+
228
+ test("requires explicit positive shutdown timing policy", async () => {
229
+ await assert.rejects(
230
+ waitForServiceStop({ timeoutMs: 0, pollIntervalMs: 1 }),
231
+ /positive shutdownTimeoutMs/
232
+ );
233
+ await assert.rejects(
234
+ waitForServiceStop({ timeoutMs: 1, pollIntervalMs: 0 }),
235
+ /positive shutdownPollIntervalMs/
236
+ );
237
+ });
@@ -59,6 +59,37 @@ test("claims only due pending tasks and marks them running", async () => {
59
59
  assert.equal((await store.get("done")).status, "done");
60
60
  });
61
61
 
62
+ test("recovers interrupted running tasks for retry after restart", async () => {
63
+ await resetHome();
64
+ const store = new TaskStore();
65
+ const runAt = new Date(Date.now() - 1000).toISOString();
66
+
67
+ await store.add({ id: "interrupted-once", kind: "agent_task", runAt, status: "running" });
68
+ await store.add({
69
+ id: "interrupted-recurring",
70
+ kind: "poll_tool",
71
+ runAt,
72
+ status: "running",
73
+ recurrence: { type: "interval", everySeconds: 60 }
74
+ });
75
+ await store.add({ id: "still-pending", kind: "agent_task", runAt });
76
+ await store.add({ id: "already-done", kind: "agent_task", runAt, status: "done" });
77
+
78
+ const recovered = await store.recoverInterrupted();
79
+
80
+ assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "interrupted-recurring"]);
81
+ assert.ok(recovered.every((task) => task.status === "pending"));
82
+ assert.ok(recovered.every((task) => task.runAt === runAt));
83
+ assert.equal((await store.get("still-pending")).status, "pending");
84
+ assert.equal((await store.get("already-done")).status, "done");
85
+
86
+ const restartedStore = new TaskStore();
87
+ assert.deepEqual(
88
+ (await restartedStore.claimDue()).map((task) => task.id),
89
+ ["interrupted-once", "interrupted-recurring", "still-pending"]
90
+ );
91
+ });
92
+
62
93
  test("completes one-off tasks and re-schedules recurring interval tasks", async () => {
63
94
  await resetHome();
64
95
  const store = new TaskStore();
@@ -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
+ });