arisa 5.1.13 → 5.1.49

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 +4 -0
  2. package/README.md +2 -0
  3. package/package.json +8 -10
  4. package/src/core/agent/agent-manager.js +132 -70
  5. package/src/core/agent/pi-runtime.js +0 -8
  6. package/src/core/agent/system-shell-tool.js +13 -2
  7. package/src/core/artifacts/artifact-store.js +17 -18
  8. package/src/core/config/config-defaults.js +2 -2
  9. package/src/core/conversation/session-seed-store.js +85 -0
  10. package/src/core/tools/ipc-client.js +0 -2
  11. package/src/core/tools/official-tool-installer.js +78 -6
  12. package/src/core/tools/tool-dependencies.js +99 -0
  13. package/src/core/tools/tool-output-materializer.js +41 -0
  14. package/src/core/tools/tool-registry.js +145 -28
  15. package/src/official-tools.lock.json +209 -5
  16. package/src/runtime/arisa-capabilities.js +12 -1
  17. package/src/runtime/create-app.js +1 -0
  18. package/src/runtime/doctor.js +72 -23
  19. package/src/runtime/headless-tool-executor.js +2 -32
  20. package/src/runtime/paths.js +7 -1
  21. package/src/runtime/restart-receipt.js +90 -0
  22. package/src/runtime/tool-usage-report.js +25 -10
  23. package/src/transport/telegram/bot.js +403 -1015
  24. package/src/transport/telegram/chat-queue.js +132 -0
  25. package/src/transport/telegram/media.js +2 -2
  26. package/src/transport/telegram/model-callback.js +211 -0
  27. package/src/transport/telegram/model-controls.js +164 -0
  28. package/src/transport/telegram/prompt-builders.js +372 -0
  29. package/src/transport/telegram/task-dispatcher.js +94 -0
  30. package/src/transport/telegram/update-command.js +1 -1
  31. package/src/transport/telegram/workspace-group.js +83 -0
  32. package/test/agent-tool-policy.test.js +7 -1
  33. package/test/capabilities-security.test.js +21 -0
  34. package/test/context-and-task-bounds.test.js +33 -5
  35. package/test/doctor.test.js +57 -4
  36. package/test/model-selection.test.js +47 -1
  37. package/test/official-tool-dependencies.test.js +25 -0
  38. package/test/official-tool-installer.test.js +37 -9
  39. package/test/paths.test.js +4 -4
  40. package/test/restart-receipt.test.js +39 -0
  41. package/test/session-start-operational-notes.test.js +47 -0
  42. package/test/telegram-prompt-builders.test.js +33 -0
  43. package/test/telegram-task-dispatcher.test.js +102 -0
  44. package/test/telegram-workspace-group.test.js +76 -0
  45. package/test/tool-dependencies.test.js +53 -0
  46. package/test/tool-registry-run.test.js +81 -1
  47. package/test/tool-usage.test.js +26 -4
  48. package/test/topic-initialization.test.js +66 -0
  49. package/src/core/conversation/conversation-history-store.js +0 -142
@@ -89,6 +89,15 @@ for (const frame of frames) {
89
89
  return dir;
90
90
  }
91
91
 
92
+ async function createHangingTool(name = "hanging-tool") {
93
+ const dir = await createFakeTool(name);
94
+ await writeFile(path.join(dir, "index.js"), `
95
+ process.on("SIGTERM", () => {});
96
+ setInterval(() => {}, 1_000);
97
+ `, "utf8");
98
+ return dir;
99
+ }
100
+
92
101
  test("loads and lists installed tools from the user tools directory", async () => {
93
102
  await resetHome();
94
103
  await createFakeTool("fake-tool", {
@@ -104,6 +113,7 @@ test("loads and lists installed tools from the user tools directory", async () =
104
113
  version: null,
105
114
  packageDigest: null,
106
115
  requirements: [],
116
+ toolDependencies: {},
107
117
  description: "Fake test tool",
108
118
  input: ["text/plain"],
109
119
  output: ["text/plain"],
@@ -116,6 +126,28 @@ test("loads and lists installed tools from the user tools directory", async () =
116
126
  }]);
117
127
  });
118
128
 
129
+ test("keeps the previous complete snapshot visible while a reload is in progress", async () => {
130
+ const registry = new ToolRegistry();
131
+ const previous = { name: "stable-tool" };
132
+ const replacement = { name: "replacement-tool" };
133
+ registry.tools = new Map([[previous.name, previous]]);
134
+
135
+ let releaseSnapshot;
136
+ registry.buildSnapshot = () => new Promise((resolve) => {
137
+ releaseSnapshot = () => resolve(new Map([[replacement.name, replacement]]));
138
+ });
139
+
140
+ const loading = registry.load();
141
+ for (let attempt = 0; attempt < 1_000; attempt += 1) {
142
+ assert.equal(registry.get(previous.name), previous);
143
+ }
144
+ releaseSnapshot();
145
+ await loading;
146
+
147
+ assert.equal(registry.get(previous.name), null);
148
+ assert.equal(registry.get(replacement.name), replacement);
149
+ });
150
+
119
151
  test("lists optional semantic metadata with stable defaults", async () => {
120
152
  await resetHome();
121
153
  await createFakeTool("fake-tool");
@@ -144,6 +176,23 @@ test("shows semantic metadata in tool help", async () => {
144
176
  assert.match(help, /Assigned skills:/);
145
177
  });
146
178
 
179
+ test("reports dependency status in help and blocks a tool with a missing dependency", async () => {
180
+ await resetHome();
181
+ await createFakeTool("dependent-tool", {
182
+ version: "1.0.0",
183
+ toolDependencies: { "base-tool": "^1.0.0" }
184
+ });
185
+
186
+ const registry = new ToolRegistry();
187
+ await registry.load();
188
+
189
+ assert.match(await registry.help("dependent-tool"), /base-tool@\^1\.0\.0: missing/);
190
+ await assert.rejects(
191
+ () => registry.run({ name: "dependent-tool", request: { args: {} } }),
192
+ /Tool dependency missing/
193
+ );
194
+ });
195
+
147
196
  test("runs a registered tool process with an enriched request and cleans up request files", async () => {
148
197
  await resetHome();
149
198
  await createFakeTool("fake-tool");
@@ -178,7 +227,7 @@ test("runs a registered tool process with an enriched request and cleans up requ
178
227
  });
179
228
  assert.equal(result.output.env.ARISA_PACKAGE_DIR, arisaPackageDir);
180
229
  assert.equal(result.output.env.ARISA_IPC_SOCKET, arisaIpcSocketFile);
181
- assert.deepEqual(await registry.usage("chat-1"), [{ name: "fake-tool", count: 1 }]);
230
+ assert.deepEqual(await registry.usage("chat-1"), [{ name: "fake-tool", count: 1, official: false }]);
182
231
 
183
232
  const requestFile = result.output.requestFile;
184
233
  await assert.rejects(() => access(requestFile), { code: "ENOENT" });
@@ -216,6 +265,37 @@ test("rejects unknown tools", async () => {
216
265
  );
217
266
  });
218
267
 
268
+ test("terminates timed-out tool runs and requires a status check before retry", async () => {
269
+ await resetHome();
270
+ await createHangingTool();
271
+ const registry = new ToolRegistry({ runTimeoutMs: 20, killGraceMs: 20 });
272
+ await registry.load();
273
+
274
+ const result = await registry.run({
275
+ name: "hanging-tool",
276
+ chatId: "chat-1",
277
+ request: { args: {} }
278
+ });
279
+
280
+ assert.equal(result.ok, false);
281
+ assert.equal(result.status, "outcome_uncertain");
282
+ assert.equal(result.resolution.type, "status_check_required");
283
+ assert.equal(result.resolution.retry, false);
284
+ assert.match(result.error, /timed out after 20ms/);
285
+ });
286
+
287
+ test("terminates timed-out tool help processes", async () => {
288
+ await resetHome();
289
+ await createHangingTool();
290
+ const registry = new ToolRegistry({ helpTimeoutMs: 20, killGraceMs: 20 });
291
+ await registry.load();
292
+
293
+ await assert.rejects(
294
+ () => registry.help("hanging-tool"),
295
+ /Tool help for hanging-tool timed out after 20ms/
296
+ );
297
+ });
298
+
219
299
  test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only", async () => {
220
300
  await resetHome();
221
301
  await createStreamingTool();
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
+ import { ToolRegistry } from "../src/core/tools/tool-registry.js";
6
7
  import { ToolUsageStore } from "../src/core/tools/tool-usage-store.js";
7
8
  import { formatToolUsageReport } from "../src/runtime/tool-usage-report.js";
8
9
 
@@ -26,16 +27,37 @@ test("counts concurrent tool uses per chat", async () => {
26
27
  }
27
28
  });
28
29
 
30
+ test("reports recorded usage for local tools not present in the startup registry", async () => {
31
+ const registry = new ToolRegistry({
32
+ usageStore: {
33
+ counts: async () => ({ "creator-scout": 4 })
34
+ },
35
+ resolveOfficialToolNames: async () => new Set(["gmail-workspace"])
36
+ });
37
+ registry.tools.set("gmail-workspace", {
38
+ name: "gmail-workspace",
39
+ input: ["application/json"],
40
+ output: ["application/json"]
41
+ });
42
+
43
+ assert.deepEqual(await registry.usage("chat-1"), [
44
+ { name: "creator-scout", count: 4, official: false },
45
+ { name: "gmail-workspace", count: 0, official: true }
46
+ ]);
47
+ });
48
+
29
49
  test("formats narrow tool usage counts with bullets and right-aligned numbers", () => {
30
50
  const report = formatToolUsageReport([
31
- { name: "gmail-workspace", count: 3 },
32
- { name: "campaign-draft-runner", count: 12 }
51
+ { name: "gmail-workspace", count: 3, official: true },
52
+ { name: "campaign-draft-runner", count: 12, official: false }
33
53
  ]);
54
+ assert.match(report, /Official\n- gmail-workspace/);
55
+ assert.match(report, /Local\n- campaign-draft-runner/);
34
56
  assert.match(report, /- campaign-draft-runner 12/);
35
57
  assert.match(report, /- gmail-workspace\s+3/);
36
58
  const rows = report.split("\n").filter((line) => line.startsWith("- "));
37
- assert.match(rows[0], /campaign-draft-runner/);
38
- assert.match(rows[1], /gmail-workspace/);
59
+ assert.match(rows[0], /gmail-workspace/);
60
+ assert.match(rows[1], /campaign-draft-runner/);
39
61
  assert.deepEqual(rows.map((line) => line.match(/\d+$/).index + line.match(/\d+$/)[0].length), [27, 27]);
40
62
  assert.deepEqual(rows.map((line) => line.length), [27, 27]);
41
63
  assert.ok(report.split("\n").every((line) => [...line].length <= 35));
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { buildTopicInitializationHandoff, isProcessableTelegramMessage, startTelegramTyping } from "../src/transport/telegram/bot.js";
7
+ import { SessionSeedStore } from "../src/core/conversation/session-seed-store.js";
8
+
9
+ test("Telegram typing action stays inside the message topic", async () => {
10
+ const calls = [];
11
+ const stop = await startTelegramTyping({
12
+ chat: { id: "chat-1" },
13
+ message: { message_thread_id: 42 },
14
+ api: {
15
+ sendChatAction: async (chatId, action, options) => {
16
+ calls.push({ chatId, action, options });
17
+ }
18
+ }
19
+ });
20
+ stop();
21
+
22
+ assert.deepEqual(calls, [
23
+ { chatId: "chat-1", action: "typing", options: { message_thread_id: 42 } }
24
+ ]);
25
+ });
26
+
27
+ test("Telegram service messages do not become empty agent prompts", () => {
28
+ assert.equal(isProcessableTelegramMessage({ forum_topic_created: { name: "Stories" } }), false);
29
+ assert.equal(isProcessableTelegramMessage({ forum_topic_edited: { name: "Stories" } }), false);
30
+ assert.equal(isProcessableTelegramMessage({ text: "hello" }), true);
31
+ assert.equal(isProcessableTelegramMessage({ voice: { file_id: "voice" } }), true);
32
+ });
33
+
34
+ test("topic initialization context is explicit and bounded to the topic", () => {
35
+ const handoff = buildTopicInitializationHandoff({ name: "Stories", context: "Draft first-person development stories for arisa.sh." });
36
+ assert.match(handoff, /Telegram topic: Stories/);
37
+ assert.match(handoff, /isolated conversation session/);
38
+ assert.match(handoff, /Draft first-person development stories/);
39
+ });
40
+
41
+ test("a persisted session seed is consumed exactly once and can be replaced", async () => {
42
+ const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-topic-seed-"));
43
+ const seedFile = (chatId) => path.join(directory, `${chatId}.jsonl`);
44
+ const legacyFile = (chatId) => path.join(directory, `${chatId}.legacy.jsonl`);
45
+ const store = new SessionSeedStore({ seedFile, legacyFile });
46
+ try {
47
+ await store.set("topic", "Stories context");
48
+ assert.equal(await store.consume("topic"), "Stories context");
49
+ assert.equal(await store.consume("topic"), "");
50
+
51
+ await store.set("topic", "Replacement context");
52
+ assert.equal(await store.consume("topic"), "Replacement context");
53
+
54
+ await writeFile(legacyFile("pending-legacy"), `${JSON.stringify({ kind: "seed", history: "Pending legacy context" })}\n`, "utf8");
55
+ assert.equal(await store.consume("pending-legacy"), "Pending legacy context");
56
+
57
+ await writeFile(legacyFile("legacy"), [
58
+ JSON.stringify({ kind: "seed", history: "Old portable context" }),
59
+ JSON.stringify({ kind: "turn", prompt: "stale", response: "stale" }),
60
+ ""
61
+ ].join("\n"), "utf8");
62
+ assert.equal(await store.consume("legacy"), "");
63
+ } finally {
64
+ await rm(directory, { recursive: true, force: true });
65
+ }
66
+ });
@@ -1,142 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { mkdir, open, readFile, writeFile } from "node:fs/promises";
3
- import path from "node:path";
4
- import { getChatConversationHistoryFile } from "../../runtime/paths.js";
5
-
6
- const utf8Bom = "\uFEFF";
7
-
8
- function normalizeText(value) {
9
- return String(value || "").trim();
10
- }
11
-
12
- function parseHistory(contents) {
13
- return String(contents || "")
14
- .replace(/^\uFEFF/, "")
15
- .split(/\r?\n/)
16
- .filter(Boolean)
17
- .map((line) => JSON.parse(line));
18
- }
19
-
20
- function serializeRecord(record) {
21
- return `${JSON.stringify(record)}\n`;
22
- }
23
-
24
- export function formatPortableConversation(records) {
25
- if (!records.length) return "";
26
- const sections = [
27
- "Portable Arisa conversation history.",
28
- "This portable history belongs to the same Telegram chat and is independent of the active agent harness.",
29
- "Use it as prior conversation context. Do not repeat it unless the user asks."
30
- ];
31
-
32
- for (const record of records) {
33
- if (record.kind === "seed") {
34
- sections.push(`Imported earlier conversation:\n${record.history}`);
35
- continue;
36
- }
37
- const parts = [];
38
- if (record.prompt) parts.push(`User or system request:\n${record.prompt}`);
39
- if (record.response) parts.push(`Assistant response:\n${record.response}`);
40
- if (parts.length) sections.push(parts.join("\n\n"));
41
- }
42
- return sections.join("\n\n---\n\n");
43
- }
44
-
45
- export class ConversationHistoryStore {
46
- constructor({ historyFile = getChatConversationHistoryFile } = {}) {
47
- this.locks = new Map();
48
- this.historyFile = historyFile;
49
- }
50
-
51
- async withChatLock(chatId, work) {
52
- const key = String(chatId);
53
- const previous = this.locks.get(key) || Promise.resolve();
54
- const current = previous.catch(() => {}).then(work);
55
- this.locks.set(key, current);
56
- try {
57
- return await current;
58
- } finally {
59
- if (this.locks.get(key) === current) this.locks.delete(key);
60
- }
61
- }
62
-
63
- async read(chatId) {
64
- try {
65
- return parseHistory(await readFile(this.historyFile(chatId), "utf8"));
66
- } catch (error) {
67
- if (error?.code === "ENOENT") return [];
68
- throw error;
69
- }
70
- }
71
-
72
- async hasEntries(chatId) {
73
- return (await this.read(chatId)).length > 0;
74
- }
75
-
76
- async appendRecord(chatId, record) {
77
- const file = this.historyFile(chatId);
78
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
79
- const handle = await open(file, "a+", 0o600);
80
- try {
81
- const stats = await handle.stat();
82
- if (stats.size === 0) await handle.write(utf8Bom);
83
- await handle.write(serializeRecord(record));
84
- } finally {
85
- await handle.close();
86
- }
87
- }
88
-
89
- async ensureSeed(chatId, { runtime, history }) {
90
- const normalizedHistory = normalizeText(history);
91
- if (!normalizedHistory) return false;
92
- return this.withChatLock(chatId, async () => {
93
- if ((await this.read(chatId)).length) return false;
94
- await this.appendRecord(chatId, {
95
- id: crypto.randomUUID(),
96
- kind: "seed",
97
- runtime,
98
- history: normalizedHistory,
99
- createdAt: new Date().toISOString()
100
- });
101
- return true;
102
- });
103
- }
104
-
105
- async appendTurn(chatId, { runtime, prompt, response }) {
106
- const normalizedPrompt = normalizeText(prompt);
107
- const normalizedResponse = normalizeText(response);
108
- if (!normalizedPrompt && !normalizedResponse) return null;
109
- const record = {
110
- id: crypto.randomUUID(),
111
- kind: "turn",
112
- runtime,
113
- prompt: normalizedPrompt,
114
- response: normalizedResponse,
115
- createdAt: new Date().toISOString()
116
- };
117
- await this.withChatLock(chatId, () => this.appendRecord(chatId, record));
118
- return record;
119
- }
120
-
121
- async reset(chatId, { runtime, history = "" } = {}) {
122
- return this.withChatLock(chatId, async () => {
123
- const file = this.historyFile(chatId);
124
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
125
- const normalizedHistory = normalizeText(history);
126
- const seed = normalizedHistory
127
- ? serializeRecord({
128
- id: crypto.randomUUID(),
129
- kind: "seed",
130
- runtime,
131
- history: normalizedHistory,
132
- createdAt: new Date().toISOString()
133
- })
134
- : "";
135
- await writeFile(file, `${utf8Bom}${seed}`, { encoding: "utf8", mode: 0o600 });
136
- });
137
- }
138
-
139
- async buildHandoff(chatId) {
140
- return formatPortableConversation(await this.read(chatId));
141
- }
142
- }