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
@@ -0,0 +1,372 @@
1
+ import { formatLocationText } from "./media.js";
2
+ import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
3
+
4
+ const slowPromptNoticeMs = 300_000;
5
+
6
+ function quotedMessageSummary(message) {
7
+ if (!message) return [];
8
+
9
+ const fromName = message.from?.username
10
+ ? `@${message.from.username}`
11
+ : [message.from?.first_name, message.from?.last_name].filter(Boolean).join(" ") || "unknown";
12
+
13
+ const parts = [
14
+ `quotedMessageId: ${message.message_id}`,
15
+ `quotedFrom: ${fromName}`
16
+ ];
17
+
18
+ if (message.text) parts.push(`quotedText: ${message.text}`);
19
+ if (message.caption) parts.push(`quotedCaption: ${message.caption}`);
20
+ if (message.voice) parts.push(`quotedKind: voice`);
21
+ if (message.audio) parts.push(`quotedKind: audio`);
22
+ if (message.photo?.length) parts.push(`quotedKind: image`);
23
+ if (message.document) parts.push(`quotedKind: document`);
24
+ if (message.video) parts.push(`quotedKind: video`);
25
+ if (message.sticker) parts.push(`quotedKind: sticker`);
26
+ if (message.location) parts.push(`quotedKind: location`, `quotedLocation: ${formatLocationText(message)}`);
27
+
28
+ if (!message.text && !message.caption) {
29
+ parts.push(`Important: this message replies to a Telegram message with no textual body available in the update. Use the quoted kind and metadata as context.`);
30
+ }
31
+
32
+ return parts;
33
+ }
34
+
35
+ export function getIncomingMessageText(message) {
36
+ return message?.text || message?.caption || formatLocationText(message) || "";
37
+ }
38
+
39
+ function telegramDisplayName(entity = {}) {
40
+ if (entity.username) return `@${entity.username}`;
41
+ return [entity.first_name, entity.last_name].filter(Boolean).join(" ") || entity.title || "unknown";
42
+ }
43
+
44
+ function forwardedMessageSummary(message) {
45
+ const origin = message?.forward_origin;
46
+ if (!origin) return [];
47
+
48
+ const parts = ["forwarded: true", `forwardedOriginType: ${origin.type}`];
49
+ if (origin.type === "user") parts.push(`forwardedFrom: ${telegramDisplayName(origin.sender_user)}`);
50
+ if (origin.type === "hidden_user") parts.push(`forwardedFrom: ${origin.sender_user_name}`);
51
+ if (origin.type === "chat" || origin.type === "channel") {
52
+ parts.push(`forwardedFrom: ${telegramDisplayName(origin.chat)}`);
53
+ }
54
+ if (origin.type === "channel" && origin.message_id) parts.push(`forwardedMessageId: ${origin.message_id}`);
55
+ if (origin.author_signature) parts.push(`forwardedAuthorSignature: ${origin.author_signature}`);
56
+ if (origin.date) parts.push(`forwardedAt: ${new Date(origin.date * 1000).toISOString()}`);
57
+ return parts;
58
+ }
59
+
60
+ function reactionLabel(reaction = {}) {
61
+ if (reaction.type === "emoji") return reaction.emoji || "emoji";
62
+ if (reaction.type === "custom_emoji") return `custom:${reaction.custom_emoji_id || "unknown"}`;
63
+ if (reaction.type === "paid") return "paid";
64
+ return reaction.type || "unknown";
65
+ }
66
+
67
+ function reactionDifference(left = [], right = []) {
68
+ const remaining = right.map(reactionLabel);
69
+ return left.map(reactionLabel).filter((label) => {
70
+ const index = remaining.indexOf(label);
71
+ if (index < 0) return true;
72
+ remaining.splice(index, 1);
73
+ return false;
74
+ });
75
+ }
76
+
77
+ export function buildReactionPrompt({ reaction, reactedMessageText = "" }) {
78
+ const oldReactions = reaction.old_reaction || [];
79
+ const newReactions = reaction.new_reaction || [];
80
+ const added = reactionDifference(newReactions, oldReactions);
81
+ const removed = reactionDifference(oldReactions, newReactions);
82
+ const actor = reaction.user || reaction.actor_chat || {};
83
+ const actorId = reaction.user?.id || reaction.actor_chat?.id || "unknown";
84
+
85
+ return [
86
+ "Incoming Telegram reaction.",
87
+ `chatId: ${reaction.chat.id}`,
88
+ `userId: ${actorId}`,
89
+ `username: ${reaction.user?.username || "(no username)"}`,
90
+ `reactedMessageId: ${reaction.message_id}`,
91
+ reactedMessageText ? `reactedMessageText: ${reactedMessageText}` : null,
92
+ added.length ? `addedReactions: ${added.join(" ")}` : null,
93
+ removed.length ? `removedReactions: ${removed.join(" ")}` : null,
94
+ `currentReactions: ${newReactions.map(reactionLabel).join(" ") || "none"}`,
95
+ `actor: ${telegramDisplayName(actor)}`,
96
+ "Treat this as lightweight feedback on the referenced message. Respond only if the reaction clearly requests action; otherwise stay silent."
97
+ ].filter(Boolean).join("\n");
98
+ }
99
+
100
+ function baseMimeType(mimeType = "") {
101
+ return mimeType.split(";")[0].trim().toLowerCase();
102
+ }
103
+
104
+ function isInlineTextArtifact(artifact, messageText) {
105
+ return artifact?.kind === "text"
106
+ && baseMimeType(artifact.mimeType) === "text/plain"
107
+ && typeof artifact.text === "string"
108
+ && artifact.text === messageText;
109
+ }
110
+
111
+ export function shouldIncludeArtifactReference({ artifact, messageText = "" } = {}) {
112
+ if (!artifact) return false;
113
+ return !isInlineTextArtifact(artifact, messageText);
114
+ }
115
+
116
+ export function buildPrompt({ ctx, artifact, transcript, toolResult }) {
117
+ const parts = [
118
+ `Incoming Telegram message.`,
119
+ `chatId: ${ctx.chat.id}`,
120
+ `userId: ${ctx.from.id}`,
121
+ `username: ${ctx.from.username || "(no username)"}`,
122
+ `messageId: ${ctx.msg.message_id}`
123
+ ];
124
+
125
+ const messageText = getIncomingMessageText(ctx.message);
126
+ if (messageText) parts.push(`text: ${messageText}`);
127
+ parts.push(...forwardedMessageSummary(ctx.message));
128
+ parts.push(...quotedMessageSummary(ctx.message?.reply_to_message));
129
+ if (shouldIncludeArtifactReference({ artifact, messageText })) {
130
+ if (artifact?.path) parts.push(`artifactPath: ${artifact.path}`);
131
+ if (artifact?.id) parts.push(`artifactId: ${artifact.id}`);
132
+ if (artifact?.mimeType) parts.push(`mimeType: ${artifact.mimeType}`);
133
+ if (artifact?.kind) parts.push(`kind: ${artifact.kind}`);
134
+ }
135
+ if (transcript) {
136
+ parts.push(`transcriptArtifactId: ${transcript.id}`);
137
+ parts.push(`transcriptText: ${transcript.text}`);
138
+ parts.push(`Important: the incoming media has already been transcribed. Use the transcript as the user message content. Do not answer with a raw transcription unless the user explicitly asked for one.`);
139
+ }
140
+ if (shouldNormalizeArtifactToText(artifact) && !transcript && toolResult) {
141
+ parts.push(`mediaNormalizationResult: ${JSON.stringify(toolResult)}`);
142
+ parts.push(`Important: pre-reasoning media normalization could not be completed, so you do not have a transcript for this audio/video message.`);
143
+ }
144
+
145
+ parts.push(`Use read/write/edit for file work in the active workspace, bash for bash-compatible commands, and system_shell for native system commands such as PowerShell on Windows.`);
146
+ parts.push(`If you need an Arisa modular CLI tool, use list_tools/tool_help/run_tool.`);
147
+ parts.push(`If a tool config is missing, ask the user naturally and then use set_tool_config.`);
148
+ parts.push(`To deliver a file to the chat: run_tool with deliver:true to generate and send in one step, or send_artifact with an existing artifactId (e.g. an inbound file).`);
149
+ return parts.join("\n");
150
+ }
151
+
152
+ export function buildNewSessionPrompt(ctx) {
153
+ return [
154
+ "System event: /new requested.",
155
+ "Session was reset.",
156
+ `preferredTelegramLanguageCode: ${ctx.from?.language_code || "unknown"}`,
157
+ "Reply with a brief, warm confirmation in the user's language."
158
+ ].join("\n");
159
+ }
160
+
161
+ export function buildStartupMessage(chatMeta = {}) {
162
+ const languageCode = String(chatMeta.languageCode || "").toLowerCase();
163
+ if (languageCode.startsWith("es")) return "Arisa esta en linea de nuevo.";
164
+ if (languageCode.startsWith("pt")) return "Arisa esta online de novo.";
165
+ return "Arisa is back online.";
166
+ }
167
+
168
+ export function isScheduledTaskPrompt(prompt) {
169
+ return String(prompt || "").startsWith("Scheduled task fired.\n");
170
+ }
171
+
172
+ export async function withPromptSpeed({ speedController, speed, restoreSpeed }, work) {
173
+ if (!speedController || speed === undefined) return work();
174
+ speedController.setSpeed(speed);
175
+ try {
176
+ return await work();
177
+ } finally {
178
+ speedController.setSpeed(restoreSpeed());
179
+ }
180
+ }
181
+
182
+ export async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }) {
183
+ const taskText = task.payload.prompt || "";
184
+ const resourceId = String(task.source?.resourceId || task.payload?.resourceId || "").trim();
185
+ const resourceNote = resourceId && task.source?.toolName
186
+ ? await resourceNotes.get(task.payload.chatId, task.source.toolName, resourceId)
187
+ : "";
188
+ const parts = [
189
+ "Scheduled task fired.",
190
+ `taskId: ${task.id}`,
191
+ `chatId: ${task.payload.chatId}`,
192
+ resourceNote ? `resourceNote: ${resourceNote}` : null,
193
+ taskText ? `text: ${taskText}` : null
194
+ ];
195
+
196
+ if (task.payload.artifactId) {
197
+ const chatArtifactStore = artifactStore.forChat(task.payload.chatId);
198
+ const artifact = await chatArtifactStore.get(task.payload.artifactId);
199
+ if (artifact) {
200
+ if (shouldIncludeArtifactReference({ artifact, messageText: taskText })) {
201
+ parts.push(`artifactPath: ${artifact.path || ""}`);
202
+ parts.push(`artifactId: ${artifact.id}`);
203
+ parts.push(`mimeType: ${artifact.mimeType}`);
204
+ parts.push(`kind: ${artifact.kind}`);
205
+ }
206
+
207
+ const { normalizedArtifact, toolResult } = await normalizeArtifactForReasoning({
208
+ artifact,
209
+ desiredMimeType: "text/plain",
210
+ toolRegistry,
211
+ chatArtifactStore,
212
+ chatId: task.payload.chatId
213
+ });
214
+
215
+ if (normalizedArtifact) {
216
+ logger?.log("tasks", `artifact ${artifact.id} normalized to ${normalizedArtifact.id}`);
217
+ parts.push(`transcriptArtifactId: ${normalizedArtifact.id}`);
218
+ parts.push(`transcriptText: ${normalizedArtifact.text}`);
219
+ parts.push("Important: the attached media artifact has already been normalized for reasoning. Use the transcript as the message content.");
220
+ } else if (shouldNormalizeArtifactToText(artifact) && toolResult) {
221
+ parts.push(`mediaNormalizationResult: ${JSON.stringify(toolResult)}`);
222
+ parts.push("Important: pre-reasoning media normalization could not be completed, so you do not have a transcript for this audio/video artifact.");
223
+ }
224
+ } else {
225
+ parts.push(`artifactId: ${task.payload.artifactId}`);
226
+ parts.push("Important: referenced artifact was not found.");
227
+ }
228
+ }
229
+
230
+ parts.push("Treat this as a new request for the chat and fulfill it now.");
231
+ parts.push("If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool.");
232
+ return parts.filter(Boolean).join("\n");
233
+ }
234
+
235
+ export async function buildAsyncEventPrompt(task, resourceNotes) {
236
+ const resourceId = String(task.source?.resourceId || task.payload?.resourceId || "").trim();
237
+ const resourceNote = resourceId && task.source?.toolName
238
+ ? await resourceNotes.get(task.payload.chatId, task.source.toolName, resourceId)
239
+ : "";
240
+ return [
241
+ "External event arrived.",
242
+ `taskId: ${task.id}`,
243
+ `chatId: ${task.payload.chatId}`,
244
+ resourceNote ? `resourceNote: ${resourceNote}` : null,
245
+ task.payload.prompt ? `event: ${task.payload.prompt}` : null,
246
+ "A polling checker detected this external event. Evaluate it and decide the next action.",
247
+ "If it warrants no action, return exactly NO_REPLY so the transport suppresses the response.",
248
+ "If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool."
249
+ ].filter(Boolean).join("\n");
250
+ }
251
+
252
+ export async function normalizeIncomingArtifact({ artifact, toolRegistry, chatArtifactStore, chatId }) {
253
+ const normalizationRequired = Boolean(shouldNormalizeArtifactToText(artifact));
254
+ if (!artifact) return { transcript: null, toolResult: null, normalizationRequired };
255
+ const { normalizedArtifact, toolResult } = await normalizeArtifactForReasoning({
256
+ artifact,
257
+ desiredMimeType: "text/plain",
258
+ toolRegistry,
259
+ chatArtifactStore,
260
+ chatId
261
+ });
262
+ return { transcript: normalizedArtifact, toolResult, normalizationRequired };
263
+ }
264
+
265
+ function sessionEventLogMessage(event) {
266
+ if (event.type === "tool_execution_start") {
267
+ return `tool ${event.toolName} started`;
268
+ }
269
+ if (event.type === "tool_execution_end") {
270
+ return `tool ${event.toolName} ${event.isError ? "failed" : "finished"}`;
271
+ }
272
+ if (event.type === "auto_retry_start") {
273
+ return `auto retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms: ${event.errorMessage}`;
274
+ }
275
+ if (event.type === "auto_retry_end") {
276
+ return event.success
277
+ ? `auto retry succeeded after ${event.attempt} attempt(s)`
278
+ : `auto retry failed after ${event.attempt} attempt(s): ${event.finalError || "unknown error"}`;
279
+ }
280
+ if (event.type === "compaction_start") {
281
+ return `compaction started (${event.reason})`;
282
+ }
283
+ if (event.type === "compaction_end") {
284
+ return `compaction ${event.aborted ? "aborted" : "finished"} (${event.reason})`;
285
+ }
286
+ if (event.type === "message_end" && event.message?.stopReason === "error") {
287
+ return `assistant message ended with error: ${event.message.errorMessage || "unknown error"}`;
288
+ }
289
+ return "";
290
+ }
291
+
292
+ export async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {}) {
293
+ const assistantMessages = [];
294
+ let assistantMessage = "";
295
+ let assistantErrorMessage = "";
296
+ let slowPromptTimer = null;
297
+ const finishAssistantMessage = () => {
298
+ if (assistantMessage && !isSilentReply(assistantMessage)) {
299
+ assistantMessages.push(assistantMessage);
300
+ }
301
+ assistantMessage = "";
302
+ };
303
+ const unsubscribe = session.subscribe((event) => {
304
+ if (event.arisaPromptScoped === false) return;
305
+ if (event.type === "message_start" && event.message.role === "assistant") {
306
+ finishAssistantMessage();
307
+ }
308
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
309
+ assistantMessage += event.assistantMessageEvent.delta;
310
+ }
311
+ if (event.type === "message_end" && event.message?.role === "assistant") {
312
+ if (event.message.stopReason === "error") {
313
+ assistantErrorMessage = event.message.errorMessage || "assistant message ended with error";
314
+ } else if (event.message.stopReason !== "aborted") {
315
+ // Auto-compaction and retry can emit a transient error before a successful continuation.
316
+ assistantErrorMessage = "";
317
+ }
318
+ finishAssistantMessage();
319
+ }
320
+ const logMessage = sessionEventLogMessage(event);
321
+ if (logMessage) logger?.log("agent", `chat ${chatId} ${logMessage}`);
322
+ });
323
+
324
+ if (onSlowPrompt) {
325
+ slowPromptTimer = setTimeout(() => {
326
+ logger?.log("telegram", `prompt for chat ${chatId} is still running after ${slowPromptNoticeMs}ms`);
327
+ onSlowPrompt().catch((error) => {
328
+ logger?.error("telegram", `slow prompt notice failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
329
+ });
330
+ }, slowPromptNoticeMs);
331
+ }
332
+
333
+ try {
334
+ await session.prompt(prompt);
335
+ } finally {
336
+ if (slowPromptTimer) clearTimeout(slowPromptTimer);
337
+ unsubscribe();
338
+ }
339
+
340
+ if (assistantErrorMessage) {
341
+ throw new Error(assistantErrorMessage);
342
+ }
343
+
344
+ finishAssistantMessage();
345
+ return assistantMessages.join("\n\n").trim();
346
+ }
347
+
348
+ export function isSilentReply(text) {
349
+ return /^(?:NO_REPLY|SILENT_REPLY|No reply needed\.|No action needed\.)(?:\s+(?:NO_REPLY|SILENT_REPLY|No reply needed\.|No action needed\.))*$/.test(String(text || "").trim());
350
+ }
351
+
352
+ export function buildSessionHandoffPrompt() {
353
+ return [
354
+ "Prepare a concise handoff for the next Arisa session.",
355
+ "Review the entire active session, including any previous compaction summaries and the latest messages.",
356
+ "Keep only durable context: current goals or projects, decisions, user preferences, unresolved tasks, and important facts needed to continue.",
357
+ "Use at most 8 short bullets and at most 1600 characters.",
358
+ "Exclude secrets, tokens, passwords, cookies, API keys, private file paths, full transcripts, and stale chatter.",
359
+ "Do not take actions, call tools, send messages, or explain the process.",
360
+ "Return only the handoff."
361
+ ].join("\n");
362
+ }
363
+
364
+ export function sanitizeSessionHandoff(text) {
365
+ const sanitized = String(text || "")
366
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, "[redacted private key]")
367
+ .replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[opsu]_[A-Za-z0-9_-]{12,}|Bearer\s+[A-Za-z0-9._-]+)\b/gi, "[redacted credential]")
368
+ .replace(/(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|client[_ -]?secret|password|cookie|secret)\s*[:=]\s*[^\s,;]+/gi, "[redacted credential]")
369
+ .trim();
370
+ if (sanitized.length <= 4000) return sanitized;
371
+ return `${sanitized.slice(0, 3997).trim()}...`;
372
+ }
@@ -0,0 +1,94 @@
1
+ import { buildAsyncEventPrompt, buildAsyncTaskPrompt } from "./prompt-builders.js";
2
+
3
+ function errorMessage(error) {
4
+ return error instanceof Error ? error.message : String(error);
5
+ }
6
+
7
+ export function createTelegramTaskDispatcher({
8
+ taskStore,
9
+ sendMessage,
10
+ enqueueAsyncPrompt,
11
+ artifactStore,
12
+ toolRegistry,
13
+ resourceNotes,
14
+ agentManager,
15
+ logger
16
+ }) {
17
+ async function dispatchAgentTask(task, chatId) {
18
+ if (!task.payload.prompt) {
19
+ await taskStore.fail(task.id, "agent_task missing prompt");
20
+ return;
21
+ }
22
+ logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
23
+ await enqueueAsyncPrompt({
24
+ chatId,
25
+ prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
26
+ label: `scheduled task ${task.id}`,
27
+ telegramContext: task.payload.telegramContext
28
+ });
29
+ await taskStore.complete(task.id);
30
+ }
31
+
32
+ async function dispatchAgentEvent(task, chatId) {
33
+ logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
34
+ const acknowledgement = String(task.payload?.acknowledgement || "").trim();
35
+ if (acknowledgement) {
36
+ try {
37
+ await sendMessage(chatId, acknowledgement);
38
+ } catch (error) {
39
+ logger?.log("telegram", `agent event acknowledgement failed for chat ${chatId}: ${errorMessage(error)}`);
40
+ }
41
+ }
42
+ await enqueueAsyncPrompt({
43
+ chatId,
44
+ prompt: await buildAsyncEventPrompt(task, resourceNotes),
45
+ label: `agent event ${task.id}`,
46
+ telegramContext: task.payload.telegramContext
47
+ });
48
+ await taskStore.complete(task.id);
49
+ }
50
+
51
+ async function dispatchPollTool(task, chatId) {
52
+ const toolName = task.payload?.toolName;
53
+ if (!toolName) {
54
+ await taskStore.fail(task.id, "poll_tool missing toolName");
55
+ return;
56
+ }
57
+ logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
58
+ try {
59
+ await agentManager.runTool({
60
+ name: toolName,
61
+ request: { args: task.payload.args || {} },
62
+ chatId
63
+ });
64
+ } catch (error) {
65
+ logger?.log("tasks", `poll_tool ${toolName} failed: ${errorMessage(error)}`);
66
+ }
67
+ await taskStore.complete(task.id);
68
+ }
69
+
70
+ async function dispatchTask(task) {
71
+ const chatId = task.payload?.chatId;
72
+ if (!chatId) {
73
+ await taskStore.fail(task.id, `Task missing chatId: ${task.kind}`);
74
+ return;
75
+ }
76
+ if (task.kind === "agent_task") return dispatchAgentTask(task, chatId);
77
+ if (task.kind === "agent_event") return dispatchAgentEvent(task, chatId);
78
+ if (task.kind === "poll_tool") return dispatchPollTool(task, chatId);
79
+ await taskStore.fail(task.id, `Unsupported task: ${task.kind}`);
80
+ }
81
+
82
+ async function dispatchDueTasks() {
83
+ const tasks = await taskStore.claimDue(10);
84
+ for (const task of tasks) {
85
+ try {
86
+ await dispatchTask(task);
87
+ } catch (error) {
88
+ await taskStore.fail(task.id, errorMessage(error));
89
+ }
90
+ }
91
+ }
92
+
93
+ return { dispatchTask, dispatchDueTasks };
94
+ }
@@ -95,7 +95,7 @@ export function createTelegramUpdateCallbackHandler({ authorize, updateCore, upd
95
95
  `Arisa updated from ${result.previousVersion} to ${result.currentVersion}. Restarting…`
96
96
  );
97
97
  try {
98
- await requestRestart();
98
+ await requestRestart(ctx);
99
99
  } catch (error) {
100
100
  const message = error instanceof Error ? error.message : String(error);
101
101
  logger?.error("update", `restart after update failed: ${message}`);
@@ -0,0 +1,83 @@
1
+ const anonymousGroupBotId = 1087968824;
2
+
3
+ function asInteger(value) {
4
+ const parsed = Number(value);
5
+ return Number.isSafeInteger(parsed) ? parsed : null;
6
+ }
7
+
8
+ export function workspaceGroupConfig(config, chatId) {
9
+ const entry = config.telegram?.ownerWorkspaceGroups?.[String(chatId)];
10
+ if (!entry || typeof entry !== "object") return null;
11
+ const ownerChatId = asInteger(entry.ownerChatId);
12
+ const generalTopicId = asInteger(entry.generalTopicId) ?? 1;
13
+ if (ownerChatId == null || generalTopicId < 1) return null;
14
+ return { ownerChatId, generalTopicId };
15
+ }
16
+
17
+ export function telegramMessageThreadId(ctx) {
18
+ return asInteger(ctx?.message?.message_thread_id ?? ctx?.msg?.message_thread_id) ?? 1;
19
+ }
20
+
21
+ export function topicSessionId({ ownerChatId, groupChatId, threadId, generalTopicId = 1 }) {
22
+ if (threadId === generalTopicId) return String(ownerChatId);
23
+ return `${ownerChatId}--telegram-group-${Math.abs(groupChatId)}--topic-${threadId}`;
24
+ }
25
+
26
+ export async function verifyOwnerWorkspaceGroup({ api, groupChatId, ownerChatId, senderId }) {
27
+ const [memberCount, administrators, me] = await Promise.all([
28
+ api.getChatMemberCount(groupChatId),
29
+ api.getChatAdministrators(groupChatId),
30
+ api.getMe()
31
+ ]);
32
+ const creator = administrators.find((member) => member.status === "creator");
33
+ const bot = administrators.find((member) => member.user?.id === me.id);
34
+ const senderAllowed = senderId === ownerChatId || senderId === me.id || senderId === anonymousGroupBotId;
35
+ if (memberCount !== 2) return { ok: false, reason: "member-count", memberCount };
36
+ if (creator?.user?.id !== ownerChatId) return { ok: false, reason: "owner-mismatch", memberCount };
37
+ if (!bot) return { ok: false, reason: "bot-not-admin", memberCount };
38
+ if (!senderAllowed) return { ok: false, reason: "sender-mismatch", memberCount };
39
+ return { ok: true, memberCount };
40
+ }
41
+
42
+ export async function resolveTelegramWorkspaceRoute({ config, api, ctx }) {
43
+ const groupChatId = asInteger(ctx?.chat?.id);
44
+ const configured = workspaceGroupConfig(config, groupChatId);
45
+ if (!configured) {
46
+ return {
47
+ ok: true,
48
+ workspace: false,
49
+ sessionId: String(groupChatId),
50
+ scopeChatId: groupChatId,
51
+ transportChatId: groupChatId,
52
+ threadId: null
53
+ };
54
+ }
55
+ if (ctx.chat?.type !== "supergroup" || !ctx.chat?.is_forum) {
56
+ return { ok: false, workspace: true, reason: "forum-required" };
57
+ }
58
+ const gate = await verifyOwnerWorkspaceGroup({
59
+ api,
60
+ groupChatId,
61
+ ownerChatId: configured.ownerChatId,
62
+ senderId: ctx?.from?.id
63
+ });
64
+ if (!gate.ok) return { ...gate, workspace: true };
65
+ const topicThreadId = telegramMessageThreadId(ctx);
66
+ const generalTopic = topicThreadId === configured.generalTopicId;
67
+ return {
68
+ ok: true,
69
+ workspace: true,
70
+ ownerChatId: configured.ownerChatId,
71
+ sessionId: topicSessionId({
72
+ ownerChatId: configured.ownerChatId,
73
+ groupChatId,
74
+ threadId: topicThreadId,
75
+ generalTopicId: configured.generalTopicId
76
+ }),
77
+ scopeChatId: configured.ownerChatId,
78
+ transportChatId: groupChatId,
79
+ threadId: generalTopic ? null : topicThreadId,
80
+ topicThreadId,
81
+ generalTopicId: configured.generalTopicId
82
+ };
83
+ }
@@ -6,7 +6,7 @@ import test from "node:test";
6
6
  import { buildPiToolPolicy } from "../src/core/agent/core-tools.js";
7
7
  import { applyConfigDefaults } from "../src/core/config/config-defaults.js";
8
8
  import { appendArisaAgentsFile, arisaAgentsFile } from "../src/core/agent/runtime-context.js";
9
- import { createSystemShellTool } from "../src/core/agent/system-shell-tool.js";
9
+ import { createSystemShellTool, isArisaRestartCommand } from "../src/core/agent/system-shell-tool.js";
10
10
  import { applyRuntimeOverrides } from "../src/runtime/create-app.js";
11
11
  import { arisaHomeDir } from "../src/runtime/paths.js";
12
12
 
@@ -116,6 +116,12 @@ test("system_shell runs commands from the configured workspace", async () => {
116
116
  assert.equal(result.details.stdout, realWorkspaceDir);
117
117
  });
118
118
 
119
+ test("system_shell prepares restart receipts for sequenced arisa restart commands", () => {
120
+ assert.equal(isArisaRestartCommand("arisa restart"), true);
121
+ assert.equal(isArisaRestartCommand("cd /tmp/work && arisa restart"), true);
122
+ assert.equal(isArisaRestartCommand("echo arisa restart"), false);
123
+ });
124
+
119
125
  test("adds Arisa AGENTS.md to Pi context files without duplicating it", () => {
120
126
  const current = {
121
127
  agentsFiles: [
@@ -142,6 +142,27 @@ test("requires chatId for chat-scoped IPC methods", async () => {
142
142
  }
143
143
  });
144
144
 
145
+ test("agent events preserve a bounded immediate acknowledgement", async () => {
146
+ const capabilities = createCapabilities();
147
+ const created = await capabilities.dispatch({
148
+ method: "agent.enqueueEvent",
149
+ toolName: "browser-session-bridge",
150
+ chatId: "chat-a",
151
+ params: {
152
+ prompt: "Continue the pending authorization flow",
153
+ acknowledgement: "Authorization received. Continuing now."
154
+ }
155
+ });
156
+
157
+ assert.equal(created.payload.acknowledgement, "Authorization received. Continuing now.");
158
+ await assert.rejects(() => capabilities.dispatch({
159
+ method: "agent.enqueueEvent",
160
+ toolName: "browser-session-bridge",
161
+ chatId: "chat-a",
162
+ params: { prompt: "Continue", acknowledgement: "x".repeat(501) }
163
+ }), /at most 500 characters/);
164
+ });
165
+
145
166
  test("delivers only artifacts resolved from the requesting chat", async () => {
146
167
  const artifact = { id: "artifact-1", chatId: "chat-a", path: "/safe/chat-a/file.txt" };
147
168
  const deliveries = [];
@@ -2,15 +2,42 @@
2
2
  import test from "node:test";
3
3
  import {
4
4
  collectText,
5
+ ensureQueuedTelegramTyping,
6
+ isSilentReply,
7
+ stopQueuedTelegramTyping
8
+ } from "../src/transport/telegram/bot.js";
9
+ import {
5
10
  createChatStateStore,
6
11
  drainChatPromptQueue,
7
- isSilentReply,
8
12
  queueChatPrompt,
9
13
  resolveTelegramBusyMessageMode,
10
14
  routeBusyPrompt
11
- } from "../src/transport/telegram/bot.js";
15
+ } from "../src/transport/telegram/chat-queue.js";
12
16
  import { selectScheduledTasks } from "../src/core/agent/agent-manager.js";
13
17
 
18
+ test("queued Telegram prompts start typing immediately and share one indicator", async () => {
19
+ let actions = 0;
20
+ const chatState = { stopQueuedTyping: null };
21
+ const ctx = {
22
+ chat: { id: 879964957 },
23
+ api: {
24
+ async sendChatAction(chatId, action) {
25
+ assert.equal(chatId, 879964957);
26
+ assert.equal(action, "typing");
27
+ actions += 1;
28
+ }
29
+ }
30
+ };
31
+
32
+ await ensureQueuedTelegramTyping(chatState, ctx);
33
+ await ensureQueuedTelegramTyping(chatState, ctx);
34
+ assert.equal(actions, 1);
35
+ assert.equal(typeof chatState.stopQueuedTyping, "function");
36
+
37
+ stopQueuedTelegramTyping(chatState);
38
+ assert.equal(chatState.stopQueuedTyping, null);
39
+ });
40
+
14
41
  function createSession(events) {
15
42
  const listeners = new Set();
16
43
  return {
@@ -99,6 +126,7 @@ test("collectText preserves useful text in the same message as a silent marker",
99
126
 
100
127
  test("recognizes standalone silent reply markers", () => {
101
128
  assert.equal(isSilentReply("NO_REPLY"), true);
129
+ assert.equal(isSilentReply("SILENT_REPLY"), true);
102
130
  assert.equal(isSilentReply("No reply needed."), true);
103
131
  assert.equal(isSilentReply("No action needed."), true);
104
132
  assert.equal(isSilentReply("\nNO_REPLY\n\nNO_REPLY\n"), true);
@@ -129,12 +157,13 @@ test("chat state uses one queue for numeric and string chat IDs", () => {
129
157
  assert.deepEqual(resetState, {
130
158
  processing: false,
131
159
  pendingPrompts: [],
160
+ pendingPromptContexts: [],
132
161
  continueAfterClose: false,
133
162
  historyRevision: 0,
134
163
  beforeNextPrompt: null,
135
164
  activeSession: null,
136
- activeSteers: [],
137
- assistantMessages: new Map()
165
+ assistantMessages: new Map(),
166
+ stopQueuedTyping: null
138
167
  });
139
168
  });
140
169
 
@@ -248,7 +277,6 @@ test("steer mode sends text to the active Pi session", async () => {
248
277
 
249
278
  assert.equal(result.disposition, "steered");
250
279
  assert.deepEqual(received, ["change direction"]);
251
- assert.deepEqual(chatState.activeSteers, ["change direction"]);
252
280
  assert.deepEqual(chatState.pendingPrompts, []);
253
281
  });
254
282