arisa 4.1.10 → 4.1.14

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.
@@ -0,0 +1,173 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ normalizeArtifactForReasoning,
5
+ selectPipeTool,
6
+ shouldNormalizeArtifactToText
7
+ } from "../src/core/artifacts/normalize-for-reasoning.js";
8
+
9
+ function createToolRegistry(tools, run = async () => ({ ok: true, output: { text: "transcript" } })) {
10
+ return {
11
+ list: () => tools,
12
+ run
13
+ };
14
+ }
15
+
16
+ test("normalizes only audio and video artifacts to text", () => {
17
+ assert.equal(shouldNormalizeArtifactToText({ mimeType: "audio/ogg; codecs=opus" }), true);
18
+ assert.equal(shouldNormalizeArtifactToText({ mimeType: "video/mp4" }), true);
19
+ assert.equal(shouldNormalizeArtifactToText({ mimeType: "text/plain" }), false);
20
+ assert.equal(shouldNormalizeArtifactToText({ mimeType: "image/png" }), false);
21
+ assert.equal(shouldNormalizeArtifactToText({ mimeType: "audio/ogg" }, "application/json"), false);
22
+ });
23
+
24
+ test("selects an audio transcription pipe by MIME input/output and tool description", () => {
25
+ const toolRegistry = createToolRegistry([
26
+ {
27
+ name: "generic-converter",
28
+ description: "Converts things",
29
+ input: ["audio/*"],
30
+ output: ["text/plain"]
31
+ },
32
+ {
33
+ name: "whisper-transcribe",
34
+ description: "Speech to text for voice notes",
35
+ input: ["audio/*"],
36
+ output: ["text/plain"]
37
+ }
38
+ ]);
39
+
40
+ const tool = selectPipeTool({
41
+ toolRegistry,
42
+ artifact: { mimeType: "audio/ogg; codecs=opus" },
43
+ desiredMimeType: "text/plain"
44
+ });
45
+
46
+ assert.equal(tool.name, "whisper-transcribe");
47
+ });
48
+
49
+ test("returns no pipe when no transcription-like tool can produce the desired output", () => {
50
+ const toolRegistry = createToolRegistry([
51
+ {
52
+ name: "generic-converter",
53
+ description: "Converts audio",
54
+ input: ["audio/*"],
55
+ output: ["application/json"]
56
+ },
57
+ {
58
+ name: "text-maker",
59
+ description: "Writes text",
60
+ input: ["text/plain"],
61
+ output: ["text/plain"]
62
+ }
63
+ ]);
64
+
65
+ const tool = selectPipeTool({
66
+ toolRegistry,
67
+ artifact: { mimeType: "audio/ogg" },
68
+ desiredMimeType: "text/plain"
69
+ });
70
+
71
+ assert.equal(tool, null);
72
+ });
73
+
74
+ test("does not run tools for artifacts that do not need normalization", async () => {
75
+ let runCount = 0;
76
+ const result = await normalizeArtifactForReasoning({
77
+ artifact: { id: "artifact-1", mimeType: "text/plain", text: "hello" },
78
+ toolRegistry: createToolRegistry([], async () => {
79
+ runCount += 1;
80
+ return { ok: true, output: { text: "unused" } };
81
+ }),
82
+ chatArtifactStore: {},
83
+ chatId: "chat-1"
84
+ });
85
+
86
+ assert.deepEqual(result, { normalizedArtifact: null, toolResult: null, toolName: "" });
87
+ assert.equal(runCount, 0);
88
+ });
89
+
90
+ test("returns a failed normalization result when no pipe is registered", async () => {
91
+ const result = await normalizeArtifactForReasoning({
92
+ artifact: { id: "artifact-1", mimeType: "audio/ogg" },
93
+ toolRegistry: createToolRegistry([]),
94
+ chatArtifactStore: {},
95
+ chatId: "chat-1"
96
+ });
97
+
98
+ assert.equal(result.normalizedArtifact, null);
99
+ assert.equal(result.toolName, "");
100
+ assert.equal(result.toolResult.ok, false);
101
+ assert.match(result.toolResult.error, /No registered tool can normalize audio\/ogg to text\/plain/);
102
+ });
103
+
104
+ test("creates a text artifact from successful normalization output", async () => {
105
+ const calls = [];
106
+ const createdArtifacts = [];
107
+ const artifact = { id: "voice-1", mimeType: "audio/ogg" };
108
+ const toolRegistry = createToolRegistry([
109
+ {
110
+ name: "openai-transcribe",
111
+ description: "Transcription for audio",
112
+ input: ["audio/*"],
113
+ output: ["text/plain"]
114
+ }
115
+ ], async (request) => {
116
+ calls.push(request);
117
+ return { ok: true, output: { text: "hello from voice" } };
118
+ });
119
+ const chatArtifactStore = {
120
+ createText: async (request) => {
121
+ const created = { id: "text-1", ...request };
122
+ createdArtifacts.push(created);
123
+ return created;
124
+ }
125
+ };
126
+
127
+ const result = await normalizeArtifactForReasoning({
128
+ artifact,
129
+ toolRegistry,
130
+ chatArtifactStore,
131
+ chatId: "chat-1"
132
+ });
133
+
134
+ assert.equal(calls.length, 1);
135
+ assert.deepEqual(calls[0], {
136
+ name: "openai-transcribe",
137
+ request: { artifact, args: {} },
138
+ chatId: "chat-1"
139
+ });
140
+ assert.deepEqual(result.normalizedArtifact, createdArtifacts[0]);
141
+ assert.equal(result.toolName, "openai-transcribe");
142
+ assert.deepEqual(createdArtifacts[0], {
143
+ id: "text-1",
144
+ text: "hello from voice",
145
+ mimeType: "text/plain",
146
+ source: { type: "tool", toolName: "openai-transcribe" },
147
+ metadata: { fromArtifactId: "voice-1", tool: "openai-transcribe", normalization: true }
148
+ });
149
+ });
150
+
151
+ test("fails normalization when the selected tool returns no text", async () => {
152
+ const result = await normalizeArtifactForReasoning({
153
+ artifact: { id: "voice-1", mimeType: "audio/ogg" },
154
+ toolRegistry: createToolRegistry([
155
+ {
156
+ name: "openai-transcribe",
157
+ description: "Transcription for audio",
158
+ input: ["audio/*"],
159
+ output: ["text/plain"]
160
+ }
161
+ ], async () => ({ ok: true, output: {} })),
162
+ chatArtifactStore: {},
163
+ chatId: "chat-1"
164
+ });
165
+
166
+ assert.equal(result.normalizedArtifact, null);
167
+ assert.equal(result.toolName, "openai-transcribe");
168
+ assert.deepEqual(result.toolResult, {
169
+ ok: false,
170
+ status: "failed",
171
+ error: "Normalization returned no text."
172
+ });
173
+ });
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import {
5
+ chatsDir,
6
+ createIpcSocketPath,
7
+ getChatArtifactsDir,
8
+ getChatToolConfigPath,
9
+ getChatToolStateDir,
10
+ getToolStateDir,
11
+ stateDir
12
+ } from "../src/runtime/paths.js";
13
+
14
+ test("keeps chat artifact paths scoped below the chat directory", () => {
15
+ const artifactsDir = getChatArtifactsDir("chat-1");
16
+
17
+ assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
18
+ });
19
+
20
+ test("keeps chat tool state and config paths scoped below the chat directory for normal names", () => {
21
+ assert.equal(
22
+ getChatToolStateDir("chat-1", "strudel-agent"),
23
+ path.join(chatsDir, "chat-1", "state", "tools", "strudel-agent")
24
+ );
25
+ assert.equal(
26
+ getChatToolConfigPath("chat-1", "strudel-agent"),
27
+ path.join(chatsDir, "chat-1", "config", "tools", "strudel-agent", "config.js")
28
+ );
29
+ });
30
+
31
+ test("documents current traversal behavior for unsanitized tool names", () => {
32
+ const expectedRoot = path.join(chatsDir, "chat-1", "state", "tools");
33
+ const traversed = getChatToolStateDir("chat-1", "../../evil");
34
+
35
+ assert.equal(path.resolve(traversed), path.join(chatsDir, "chat-1", "evil"));
36
+ assert.equal(path.resolve(traversed).startsWith(`${expectedRoot}${path.sep}`), false);
37
+ });
38
+
39
+ test("documents current traversal behavior for global tool state paths", () => {
40
+ const expectedRoot = path.join(stateDir, "tools");
41
+ const traversed = getToolStateDir("../../evil");
42
+
43
+ assert.equal(path.resolve(traversed), path.join(path.dirname(stateDir), "evil"));
44
+ assert.equal(path.resolve(traversed).startsWith(`${expectedRoot}${path.sep}`), false);
45
+ });
46
+
47
+ test("creates POSIX IPC socket paths under the state directory", () => {
48
+ const socketPath = createIpcSocketPath({
49
+ homeDir: "/tmp/arisa-home",
50
+ platform: "darwin"
51
+ });
52
+
53
+ assert.equal(socketPath, path.join("/tmp/arisa-home", "state", "arisa.sock"));
54
+ });
@@ -0,0 +1,119 @@
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
+
7
+ const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-task-store-home-"));
8
+ process.env.HOME = homeDir;
9
+ process.env.USERPROFILE = homeDir;
10
+
11
+ const { TaskStore } = await import("../src/core/tasks/task-store.js");
12
+ const { arisaHomeDir } = await import("../src/runtime/paths.js");
13
+
14
+ async function resetHome() {
15
+ await rm(arisaHomeDir, { recursive: true, force: true });
16
+ }
17
+
18
+ test("adds tasks and lists them by chat, status, and kind", async () => {
19
+ await resetHome();
20
+ const store = new TaskStore();
21
+
22
+ await store.add({ id: "chat-1-pending", kind: "agent_task" }, { payload: { chatId: "chat-1" } });
23
+ await store.add({ id: "chat-1-event", kind: "agent_event" }, { payload: { chatId: "chat-1" } });
24
+ await store.add({ id: "chat-2-pending", kind: "agent_task" }, { payload: { chatId: "chat-2" } });
25
+
26
+ assert.deepEqual(
27
+ (await store.list({ chatId: "chat-1" })).map((task) => task.id),
28
+ ["chat-1-pending", "chat-1-event"]
29
+ );
30
+ assert.deepEqual(
31
+ (await store.list({ kind: "agent_task" })).map((task) => task.id),
32
+ ["chat-1-pending", "chat-2-pending"]
33
+ );
34
+ assert.deepEqual(
35
+ (await store.list({ status: "pending" })).map((task) => task.id),
36
+ ["chat-1-pending", "chat-1-event", "chat-2-pending"]
37
+ );
38
+ });
39
+
40
+ test("claims only due pending tasks and marks them running", async () => {
41
+ await resetHome();
42
+ const store = new TaskStore();
43
+ const past = new Date(Date.now() - 1000).toISOString();
44
+ const future = new Date(Date.now() + 60_000).toISOString();
45
+
46
+ await store.add({ id: "due-1", kind: "agent_task", runAt: past });
47
+ await store.add({ id: "due-2", kind: "agent_task", runAt: past });
48
+ await store.add({ id: "future", kind: "agent_task", runAt: future });
49
+ await store.add({ id: "done", kind: "agent_task", runAt: past, status: "done" });
50
+ await store.add({ id: "invalid", kind: "agent_task", runAt: "not-a-date" });
51
+
52
+ const claimed = await store.claimDue(1);
53
+
54
+ assert.deepEqual(claimed.map((task) => task.id), ["due-1"]);
55
+ assert.equal(claimed[0].status, "running");
56
+ assert.equal((await store.get("due-1")).status, "running");
57
+ assert.equal((await store.get("due-2")).status, "pending");
58
+ assert.equal((await store.get("future")).status, "pending");
59
+ assert.equal((await store.get("done")).status, "done");
60
+ });
61
+
62
+ test("completes one-off tasks and re-schedules recurring interval tasks", async () => {
63
+ await resetHome();
64
+ const store = new TaskStore();
65
+ const past = new Date(Date.now() - 1000).toISOString();
66
+
67
+ await store.add({ id: "once", kind: "agent_task", status: "running" });
68
+ await store.add({
69
+ id: "repeat",
70
+ kind: "poll_tool",
71
+ status: "running",
72
+ runAt: past,
73
+ recurrence: { type: "interval", everySeconds: 60 }
74
+ });
75
+
76
+ const once = await store.complete("once");
77
+ const repeat = await store.complete("repeat");
78
+
79
+ assert.equal(once.status, "done");
80
+ assert.ok(once.completedAt);
81
+ assert.equal(repeat.status, "pending");
82
+ assert.ok(Date.parse(repeat.runAt) > Date.now());
83
+ assert.ok(repeat.lastRunAt);
84
+ });
85
+
86
+ test("fails and cancels tasks by id", async () => {
87
+ await resetHome();
88
+ const store = new TaskStore();
89
+
90
+ await store.add({ id: "will-fail", kind: "agent_task" });
91
+ await store.add({ id: "will-cancel", kind: "agent_task" });
92
+
93
+ const failed = await store.fail("will-fail", "boom");
94
+ const canceled = await store.cancel("will-cancel");
95
+
96
+ assert.equal(failed.status, "failed");
97
+ assert.equal(failed.error, "boom");
98
+ assert.equal(canceled.id, "will-cancel");
99
+ assert.equal(await store.get("will-cancel"), null);
100
+ });
101
+
102
+ test("cancelAll preserves done and failed tasks and respects chat filters", async () => {
103
+ await resetHome();
104
+ const store = new TaskStore();
105
+
106
+ await store.add({ id: "chat-1-pending", kind: "agent_task" }, { payload: { chatId: "chat-1" } });
107
+ await store.add({ id: "chat-2-pending", kind: "agent_task" }, { payload: { chatId: "chat-2" } });
108
+ await store.add({ id: "chat-1-done", kind: "agent_task", status: "done" }, { payload: { chatId: "chat-1" } });
109
+ await store.add({ id: "chat-1-failed", kind: "agent_task", status: "failed" }, { payload: { chatId: "chat-1" } });
110
+
111
+ const removed = await store.cancelAll({ chatId: "chat-1" });
112
+ const remaining = await store.list();
113
+
114
+ assert.deepEqual(removed.map((task) => task.id), ["chat-1-pending"]);
115
+ assert.deepEqual(
116
+ remaining.map((task) => task.id),
117
+ ["chat-2-pending", "chat-1-done", "chat-1-failed"]
118
+ );
119
+ });
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildPrompt, shouldIncludeArtifactReference } from "../src/transport/telegram/bot.js";
4
+ import { captureIncomingArtifact } from "../src/transport/telegram/media.js";
5
+
6
+ function createTextContext(text = "hello") {
7
+ return {
8
+ chat: { id: 123 },
9
+ from: { id: 456, username: "martin" },
10
+ msg: { message_id: 789 },
11
+ message: {
12
+ message_id: 789,
13
+ text
14
+ }
15
+ };
16
+ }
17
+
18
+ test("does not expose inline message text artifacts as prompt attachments", () => {
19
+ const prompt = buildPrompt({
20
+ ctx: createTextContext("hello"),
21
+ artifact: {
22
+ id: "artifact-1",
23
+ kind: "text",
24
+ mimeType: "text/plain",
25
+ text: "hello"
26
+ }
27
+ });
28
+
29
+ assert.match(prompt, /text: hello/);
30
+ assert.doesNotMatch(prompt, /artifactId: artifact-1/);
31
+ assert.doesNotMatch(prompt, /mimeType: text\/plain/);
32
+ assert.doesNotMatch(prompt, /kind: text/);
33
+ });
34
+
35
+ test("keeps distinct artifacts visible to the prompt", () => {
36
+ assert.equal(
37
+ shouldIncludeArtifactReference({
38
+ artifact: {
39
+ id: "artifact-1",
40
+ kind: "document",
41
+ mimeType: "text/plain",
42
+ path: "/tmp/note.txt"
43
+ },
44
+ messageText: "see attached"
45
+ }),
46
+ true
47
+ );
48
+
49
+ assert.equal(
50
+ shouldIncludeArtifactReference({
51
+ artifact: {
52
+ id: "artifact-2",
53
+ kind: "text",
54
+ mimeType: "text/plain",
55
+ text: "different text"
56
+ },
57
+ messageText: "hello"
58
+ }),
59
+ true
60
+ );
61
+ });
62
+
63
+ test("marks incoming Telegram text artifacts as internal inline messages", async () => {
64
+ const calls = [];
65
+ const artifactStore = {
66
+ forChat: (chatId) => ({
67
+ createText: async (request) => {
68
+ calls.push({ chatId, ...request });
69
+ return { id: "artifact-1", chatId, kind: "text", mimeType: "text/plain", ...request };
70
+ }
71
+ })
72
+ };
73
+
74
+ const artifact = await captureIncomingArtifact(createTextContext("hello"), artifactStore);
75
+
76
+ assert.equal(artifact.text, "hello");
77
+ assert.deepEqual(calls[0].metadata, {
78
+ visibility: "internal",
79
+ representation: "inline-message"
80
+ });
81
+ });
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { renderTelegramHtml, splitTelegramText } from "../src/transport/telegram/text-format.js";
4
+
5
+ test("escapes HTML-sensitive characters in inline text", () => {
6
+ const rendered = renderTelegramHtml("<script>alert(\"x\")</script> & done");
7
+
8
+ assert.equal(rendered, "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt; &amp; done");
9
+ });
10
+
11
+ test("escapes inline formatting content before rendering Telegram HTML tags", () => {
12
+ const rendered = renderTelegramHtml("**<b>bold & safe</b>** and `<tag attr=\"x\">`");
13
+
14
+ assert.equal(
15
+ rendered,
16
+ "<b>&lt;b&gt;bold &amp; safe&lt;/b&gt;</b> and <code>&lt;tag attr=&quot;x&quot;&gt;</code>"
17
+ );
18
+ });
19
+
20
+ test("escapes fenced code content", () => {
21
+ const rendered = renderTelegramHtml("before\n```js\nconst x = \"<tag>\" && value;\n```\nafter");
22
+
23
+ assert.equal(
24
+ rendered,
25
+ "before\n<pre><code language=\"js\">const x = &quot;&lt;tag&gt;&quot; &amp;&amp; value;</code></pre>\nafter"
26
+ );
27
+ });
28
+
29
+ test("escapes fenced code language attributes", () => {
30
+ const rendered = renderTelegramHtml("```js\" onmouseover=\"alert(1)\nconsole.log(\"safe\");\n```");
31
+
32
+ assert.match(rendered, /<pre><code language="js&quot; onmouseover=&quot;alert\(1\)">/);
33
+ assert.doesNotMatch(rendered, /onmouseover="/);
34
+ });
35
+
36
+ test("leaves unterminated fences as escaped inline text", () => {
37
+ const rendered = renderTelegramHtml("```html\n<b>not code");
38
+
39
+ assert.equal(rendered, "```html\n&lt;b&gt;not code");
40
+ });
41
+
42
+ test("splits empty or whitespace-only Telegram text into no chunks", () => {
43
+ assert.deepEqual(splitTelegramText(""), []);
44
+ assert.deepEqual(splitTelegramText(" \n\t "), []);
45
+ });
46
+
47
+ test("splits Telegram text at readable boundaries without exceeding the limit", () => {
48
+ const source = "alpha beta gamma delta epsilon zeta";
49
+ const chunks = splitTelegramText(source, 12);
50
+
51
+ assert.deepEqual(chunks, ["alpha beta", "gamma delta", "epsilon zeta"]);
52
+ assert.ok(chunks.every((chunk) => chunk.length <= 12));
53
+ assert.equal(chunks.join(" "), source);
54
+ });
55
+
56
+ test("falls back to hard splits when no readable boundary exists", () => {
57
+ const chunks = splitTelegramText("abcdefghijklmnop", 5);
58
+
59
+ assert.deepEqual(chunks, ["abcde", "fghij", "klmno", "p"]);
60
+ assert.ok(chunks.every((chunk) => chunk.length <= 5));
61
+ });
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-tool-config-home-"));
8
+ process.env.HOME = homeDir;
9
+ process.env.USERPROFILE = homeDir;
10
+
11
+ const {
12
+ loadToolConfig,
13
+ parseConfigModule,
14
+ serializeConfigModule,
15
+ writeToolConfig
16
+ } = await import("../src/core/tools/tool-config.js");
17
+
18
+ test("parses and serializes config modules", () => {
19
+ const config = parseConfigModule("export default {\n apiKey: \"secret\",\n enabled: true\n};\n");
20
+ const serialized = serializeConfigModule(config);
21
+
22
+ assert.deepEqual(config, { apiKey: "secret", enabled: true });
23
+ assert.equal(serialized, "export default {\n apiKey: \"secret\",\n enabled: true\n};\n");
24
+ assert.deepEqual(parseConfigModule(serialized), config);
25
+ });
26
+
27
+ test("loads tool config with defaults, global config, and chat config precedence", async () => {
28
+ await writeToolConfig("demo-tool", {
29
+ apiKey: "global-key",
30
+ mode: "global",
31
+ retries: 3
32
+ });
33
+ await writeToolConfig("demo-tool", {
34
+ mode: "chat",
35
+ chatOnly: true
36
+ }, "chat-1");
37
+
38
+ const config = await loadToolConfig("demo-tool", {
39
+ apiKey: "default-key",
40
+ mode: "default",
41
+ timeoutMs: 1000
42
+ }, "chat-1");
43
+
44
+ assert.deepEqual(config, {
45
+ apiKey: "global-key",
46
+ mode: "chat",
47
+ timeoutMs: 1000,
48
+ retries: 3,
49
+ chatOnly: true
50
+ });
51
+ });
52
+
53
+ test("writes config modules to the selected scope", async () => {
54
+ const { getChatToolConfigPath } = await import("../src/runtime/paths.js");
55
+ const configPath = await writeToolConfig("scoped-tool", { token: "chat-token" }, "chat-2");
56
+ const source = await readFile(configPath, "utf8");
57
+
58
+ assert.equal(configPath, getChatToolConfigPath("chat-2", "scoped-tool"));
59
+ assert.equal(source, "export default {\n token: \"chat-token\"\n};\n");
60
+ });
61
+
62
+ test("documents that config parsing executes JavaScript expressions", () => {
63
+ delete globalThis.__arisaConfigExecuted;
64
+
65
+ const config = parseConfigModule(
66
+ "export default (() => { globalThis.__arisaConfigExecuted = true; return { enabled: true }; })();"
67
+ );
68
+
69
+ assert.deepEqual(config, { enabled: true });
70
+ assert.equal(globalThis.__arisaConfigExecuted, true);
71
+ delete globalThis.__arisaConfigExecuted;
72
+ });