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.
- package/AGENTS.md +4 -0
- package/README.md +2 -0
- package/package.json +8 -10
- package/src/core/agent/agent-manager.js +132 -70
- package/src/core/agent/pi-runtime.js +0 -8
- package/src/core/agent/system-shell-tool.js +13 -2
- package/src/core/artifacts/artifact-store.js +17 -18
- package/src/core/config/config-defaults.js +2 -2
- package/src/core/conversation/session-seed-store.js +85 -0
- package/src/core/tools/ipc-client.js +0 -2
- package/src/core/tools/official-tool-installer.js +78 -6
- package/src/core/tools/tool-dependencies.js +99 -0
- package/src/core/tools/tool-output-materializer.js +41 -0
- package/src/core/tools/tool-registry.js +145 -28
- package/src/official-tools.lock.json +209 -5
- package/src/runtime/arisa-capabilities.js +12 -1
- package/src/runtime/create-app.js +1 -0
- package/src/runtime/doctor.js +72 -23
- package/src/runtime/headless-tool-executor.js +2 -32
- package/src/runtime/paths.js +7 -1
- package/src/runtime/restart-receipt.js +90 -0
- package/src/runtime/tool-usage-report.js +25 -10
- package/src/transport/telegram/bot.js +403 -1015
- package/src/transport/telegram/chat-queue.js +132 -0
- package/src/transport/telegram/media.js +2 -2
- package/src/transport/telegram/model-callback.js +211 -0
- package/src/transport/telegram/model-controls.js +164 -0
- package/src/transport/telegram/prompt-builders.js +372 -0
- package/src/transport/telegram/task-dispatcher.js +94 -0
- package/src/transport/telegram/update-command.js +1 -1
- package/src/transport/telegram/workspace-group.js +83 -0
- package/test/agent-tool-policy.test.js +7 -1
- package/test/capabilities-security.test.js +21 -0
- package/test/context-and-task-bounds.test.js +33 -5
- package/test/doctor.test.js +57 -4
- package/test/model-selection.test.js +47 -1
- package/test/official-tool-dependencies.test.js +25 -0
- package/test/official-tool-installer.test.js +37 -9
- package/test/paths.test.js +4 -4
- package/test/restart-receipt.test.js +39 -0
- package/test/session-start-operational-notes.test.js +47 -0
- package/test/telegram-prompt-builders.test.js +33 -0
- package/test/telegram-task-dispatcher.test.js +102 -0
- package/test/telegram-workspace-group.test.js +76 -0
- package/test/tool-dependencies.test.js +53 -0
- package/test/tool-registry-run.test.js +81 -1
- package/test/tool-usage.test.js +26 -4
- package/test/topic-initialization.test.js +66 -0
- 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/
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|