@wzfukui/ani 2026.3.28

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,1070 @@
1
+ import {
2
+ createReplyPrefixContext,
3
+ createTypingCallbacks,
4
+ type RuntimeEnv,
5
+ } from "../sdk-compat.js";
6
+
7
+ import { randomBytes } from "node:crypto";
8
+
9
+ import type { CoreConfig } from "../types.js";
10
+ import {
11
+ sendAniMessage,
12
+ sendAniProgress,
13
+ fetchConversation,
14
+ fetchConversationMemories,
15
+ toggleAniReaction,
16
+ type AniArtifact,
17
+ type AniConversation,
18
+ type AniMemory,
19
+ } from "./send.js";
20
+ import { createInboundDebouncer } from "./debounce.js";
21
+
22
+ export type AniWsMessage = {
23
+ type?: string;
24
+ // ANI wraps payload in `data`
25
+ data?: {
26
+ id?: number;
27
+ conversation_id?: number;
28
+ sender_id?: number;
29
+ sender_type?: string;
30
+ layers?: {
31
+ summary?: string;
32
+ detail?: string;
33
+ data?: unknown;
34
+ };
35
+ created_at?: string;
36
+ // sender entity info (enriched by ANI server)
37
+ sender?: {
38
+ id?: number;
39
+ display_name?: string;
40
+ entity_type?: string;
41
+ };
42
+ // Mentions: entity IDs mentioned in this message
43
+ mentions?: number[];
44
+ // Interaction layer (approval/selection cards)
45
+ // conversation info
46
+ conversation?: {
47
+ id?: number;
48
+ title?: string;
49
+ conv_type?: string;
50
+ };
51
+ attachments?: Array<{
52
+ type?: string;
53
+ url?: string;
54
+ filename?: string;
55
+ mime_type?: string;
56
+ size?: number;
57
+ duration?: number;
58
+ content?: string;
59
+ }>;
60
+ };
61
+ };
62
+
63
+ export type AniHandlerParams = {
64
+ core: ReturnType<typeof import("../runtime.js").getAniRuntime>;
65
+ cfg: CoreConfig;
66
+ runtime: RuntimeEnv;
67
+ logger: {
68
+ info: (message: string | Record<string, unknown>, ...meta: unknown[]) => void;
69
+ warn: (meta: Record<string, unknown>, message: string) => void;
70
+ };
71
+ logVerbose: (message: string) => void;
72
+ serverUrl: string;
73
+ apiKey: string;
74
+ selfEntityId: number;
75
+ selfName: string;
76
+ accountId: string;
77
+ };
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Artifact support: system prompt injection + outbound parsing
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const ANI_ARTIFACT_SYSTEM_PROMPT = `
84
+ ## Artifact Output
85
+
86
+ When you need to produce rich visual or structured content (SVG graphics, HTML pages, diagrams, or substantial code blocks), wrap the output in an <artifact> tag so it can be rendered interactively in the chat UI.
87
+
88
+ Format:
89
+ <artifact type="TYPE" title="TITLE" language="LANG">
90
+ CONTENT
91
+ </artifact>
92
+
93
+ Supported types:
94
+ - html — HTML/SVG content (including inline CSS/JS). Use this for charts, diagrams drawn as SVG, interactive widgets.
95
+ - code — Source code. Set language="python" (or js, go, sql, etc.) for syntax highlighting.
96
+ - mermaid — Mermaid diagram markup (flowchart, sequence, gantt, etc.).
97
+
98
+ Rules:
99
+ - Only use <artifact> for content that benefits from rendering (SVG, HTML, mermaid, long code). Short inline code snippets should stay as normal markdown.
100
+ - Always provide a descriptive title.
101
+ - For SVG, output the full <svg> element inside type="html". Use viewBox (not fixed width/height) so it scales responsively. Ensure all text labels and data values are fully visible with no overlap — add enough vertical spacing between rows (min 40px per row for bar charts).
102
+ - You may include a brief text explanation before or after the artifact tag.
103
+ - Do NOT nest artifact tags.
104
+ `.trim();
105
+
106
+ /**
107
+ * Parse <artifact> tags from model reply text.
108
+ * Returns an array of { before, artifact, after } segments.
109
+ */
110
+ export function parseArtifacts(text: string): Array<{
111
+ textBefore: string;
112
+ artifact?: AniArtifact;
113
+ raw?: string;
114
+ }> {
115
+ const TAG_RE = /<artifact\s+type="(?<type>[^"]+)"(?:\s+title="(?<title>[^"]*)")?(?:\s+language="(?<lang>[^"]*)")?\s*>\n?(?<source>[\s\S]*?)\n?<\/artifact>/g;
116
+
117
+ const segments: Array<{ textBefore: string; artifact?: AniArtifact; raw?: string }> = [];
118
+ let lastIndex = 0;
119
+
120
+ for (const match of text.matchAll(TAG_RE)) {
121
+ const before = text.slice(lastIndex, match.index);
122
+ const artType = match.groups?.type ?? "html";
123
+ const mappedType: AniArtifact["artifact_type"] =
124
+ artType === "mermaid" ? "mermaid" :
125
+ artType === "code" ? "code" :
126
+ artType === "image" ? "image" : "html";
127
+
128
+ segments.push({
129
+ textBefore: before,
130
+ artifact: {
131
+ artifact_type: mappedType,
132
+ source: match.groups?.source ?? "",
133
+ title: match.groups?.title || undefined,
134
+ language: match.groups?.lang || undefined,
135
+ },
136
+ raw: match[0],
137
+ });
138
+ lastIndex = (match.index ?? 0) + match[0].length;
139
+ }
140
+
141
+ // Remaining text after last artifact (or entire text if no artifacts found)
142
+ const trailing = text.slice(lastIndex);
143
+ if (trailing.trim() || segments.length === 0) {
144
+ segments.push({ textBefore: trailing });
145
+ }
146
+
147
+ return segments;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Attachment processing: download text files inline, describe others
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const TEXT_MIME_PREFIXES = ['text/', 'application/json', 'application/xml', 'application/yaml'];
155
+ const TEXT_EXTENSIONS = ['.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.log', '.toml', '.ini', '.cfg', '.conf', '.sh', '.py', '.js', '.ts', '.go', '.rs', '.sql'];
156
+ const MAX_TEXT_FILE_SIZE = 102400; // 100KB
157
+
158
+ export function isTextFile(mimeType?: string, filename?: string): boolean {
159
+ if (mimeType && TEXT_MIME_PREFIXES.some(p => mimeType.startsWith(p))) return true;
160
+ if (filename) {
161
+ const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase();
162
+ return TEXT_EXTENSIONS.includes(ext);
163
+ }
164
+ return false;
165
+ }
166
+
167
+ function formatFileSize(bytes?: number): string {
168
+ if (bytes == null) return 'unknown size';
169
+ if (bytes < 1024) return `${bytes} B`;
170
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
171
+ return `${(bytes / 1048576).toFixed(1)} MB`;
172
+ }
173
+
174
+ /** Classify a MIME type into a human-readable category. */
175
+ function classifyMime(mimeType?: string): { category: string; label: string } {
176
+ if (!mimeType) return { category: "file", label: "unknown type" };
177
+ if (mimeType.startsWith("image/")) {
178
+ const fmt = mimeType.replace("image/", "").toUpperCase();
179
+ return { category: "image", label: `${fmt} image` };
180
+ }
181
+ if (mimeType.startsWith("audio/")) {
182
+ const fmt = mimeType.replace("audio/", "").toUpperCase();
183
+ return { category: "audio", label: `${fmt} audio` };
184
+ }
185
+ if (mimeType.startsWith("video/")) {
186
+ const fmt = mimeType.replace("video/", "").toUpperCase();
187
+ return { category: "video", label: `${fmt} video` };
188
+ }
189
+ if (mimeType === "application/pdf") return { category: "document", label: "PDF document" };
190
+ if (mimeType.includes("word") || mimeType.includes("document")) return { category: "document", label: "Word document" };
191
+ if (mimeType.includes("excel") || mimeType.includes("spreadsheet")) return { category: "document", label: "Excel spreadsheet" };
192
+ if (mimeType.includes("powerpoint") || mimeType.includes("presentation")) return { category: "document", label: "PowerPoint presentation" };
193
+ if (mimeType === "application/zip") return { category: "archive", label: "ZIP archive" };
194
+ if (mimeType.includes("tar") || mimeType.includes("gzip")) return { category: "archive", label: "compressed archive" };
195
+ return { category: "file", label: mimeType };
196
+ }
197
+
198
+ /** Format an attachment description for the model without exposing protected download URLs. */
199
+ function formatAttachmentDescription(
200
+ filename: string,
201
+ mimeType: string | undefined,
202
+ size: number | undefined,
203
+ requiresAuthAccess: boolean,
204
+ duration?: number,
205
+ ): string {
206
+ const { category, label } = classifyMime(mimeType);
207
+ const sizeStr = formatFileSize(size);
208
+ const durationStr = duration ? `, ${duration}s` : "";
209
+ const accessStr = requiresAuthAccess ? " — available via authenticated ANI attachment access" : "";
210
+
211
+ switch (category) {
212
+ case "image":
213
+ return `[Image attached: ${filename} (${label}, ${sizeStr})${accessStr}]`;
214
+ case "document":
215
+ return `[Document attached: ${filename} (${label}, ${sizeStr})${accessStr}]`;
216
+ case "audio":
217
+ return `[Audio attached: ${filename} (${label}${durationStr}, ${sizeStr})${accessStr}]`;
218
+ case "video":
219
+ return `[Video attached: ${filename} (${label}${durationStr}, ${sizeStr})${accessStr}]`;
220
+ case "archive":
221
+ return `[Archive attached: ${filename} (${label}, ${sizeStr})${accessStr}]`;
222
+ default:
223
+ return `[File attached: ${filename} (${label}, ${sizeStr})${accessStr}]`;
224
+ }
225
+ }
226
+
227
+ async function processAttachments(
228
+ attachments: NonNullable<NonNullable<AniWsMessage['data']>['attachments']>,
229
+ serverUrl: string,
230
+ apiKey: string,
231
+ ): Promise<string> {
232
+ const parts: string[] = [];
233
+
234
+ for (const att of attachments) {
235
+ const filename = att.filename || 'unknown';
236
+ const url = att.url;
237
+
238
+ if (!url) {
239
+ parts.push(formatAttachmentDescription(filename, att.mime_type, att.size, false, att.duration));
240
+ continue;
241
+ }
242
+
243
+ // Build full URL (ANI uses relative paths like /files/...)
244
+ const fullUrl = url.startsWith('http') ? url : `${serverUrl}${url}`;
245
+
246
+ // For text files small enough, download and inline the content
247
+ if (isTextFile(att.mime_type, att.filename) && (att.size ?? 0) <= MAX_TEXT_FILE_SIZE) {
248
+ try {
249
+ const res = await fetch(fullUrl, {
250
+ headers: { Authorization: `Bearer ${apiKey}` },
251
+ signal: AbortSignal.timeout(30_000),
252
+ });
253
+ if (res.ok) {
254
+ // Validate actual response size before reading body to prevent DoS
255
+ const contentLength = Number(res.headers.get("content-length") ?? "0");
256
+ if (contentLength > MAX_TEXT_FILE_SIZE) {
257
+ parts.push(formatAttachmentDescription(filename, att.mime_type, contentLength, true, att.duration));
258
+ } else {
259
+ const content = await res.text();
260
+ if (content.length > MAX_TEXT_FILE_SIZE) {
261
+ // Actual body exceeded limit despite header; fall back to description
262
+ parts.push(formatAttachmentDescription(filename, att.mime_type, content.length, true, att.duration));
263
+ } else {
264
+ parts.push(`--- Attached file: ${filename} ---\n${content}\n--- End of file ---`);
265
+ }
266
+ }
267
+ } else {
268
+ parts.push(formatAttachmentDescription(filename, att.mime_type, att.size, true, att.duration));
269
+ }
270
+ } catch {
271
+ parts.push(formatAttachmentDescription(filename, att.mime_type, att.size, true, att.duration));
272
+ }
273
+ } else {
274
+ // Non-text or large files: describe them, but do not leak protected download URLs
275
+ parts.push(formatAttachmentDescription(filename, att.mime_type, att.size, true, att.duration));
276
+ }
277
+ }
278
+
279
+ return parts.join('\n\n');
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Media download: save attachments to disk for OpenClaw media pipeline
284
+ // ---------------------------------------------------------------------------
285
+
286
+ type SavedAttachment = { path: string; contentType?: string };
287
+
288
+ /**
289
+ * Download ANI attachments and save them to OpenClaw's media directory.
290
+ * This enables the media-understanding pipeline (vision, audio transcription, etc.)
291
+ * which requires local file paths via MediaPath/MediaPaths context fields.
292
+ */
293
+ async function downloadAndSaveAttachments(
294
+ attachments: NonNullable<NonNullable<AniWsMessage['data']>['attachments']>,
295
+ serverUrl: string,
296
+ apiKey: string,
297
+ saveFn: (buffer: Buffer, contentType?: string, subdir?: string, maxBytes?: number, originalFilename?: string) => Promise<{ path: string; contentType?: string }>,
298
+ logVerbose: (msg: string) => void,
299
+ ): Promise<SavedAttachment[]> {
300
+ const saved: SavedAttachment[] = [];
301
+
302
+ for (const att of attachments) {
303
+ const url = att.url;
304
+ if (!url) continue;
305
+
306
+ const fullUrl = url.startsWith('http') ? url : `${serverUrl}${url}`;
307
+ try {
308
+ const res = await fetch(fullUrl, {
309
+ headers: { Authorization: `Bearer ${apiKey}` },
310
+ signal: AbortSignal.timeout(30_000),
311
+ });
312
+ if (!res.ok) {
313
+ logVerbose(`ani: media download failed (${res.status}) for ${att.filename ?? url}`);
314
+ continue;
315
+ }
316
+
317
+ const buffer = Buffer.from(await res.arrayBuffer());
318
+ const contentType = att.mime_type ?? res.headers.get("content-type") ?? undefined;
319
+
320
+ const result = await saveFn(
321
+ buffer,
322
+ contentType,
323
+ "inbound",
324
+ 10 * 1024 * 1024, // 10MB limit for save
325
+ att.filename ?? undefined,
326
+ );
327
+
328
+ saved.push({ path: result.path, contentType: result.contentType });
329
+ logVerbose(`ani: saved media ${att.filename ?? "file"} → ${result.path} (${result.contentType})`);
330
+ } catch (err) {
331
+ logVerbose(`ani: media save failed for ${att.filename ?? url}: ${String(err)}`);
332
+ }
333
+ }
334
+
335
+ return saved;
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Streaming helpers
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /** Generate a short random stream ID. */
343
+ function generateStreamId(): string {
344
+ return `stream-${randomBytes(6).toString("hex")}`;
345
+ }
346
+
347
+ /**
348
+ * Creates a handler function for incoming ANI WebSocket messages.
349
+ * Only handles `message_new` events, routes them through the OpenClaw
350
+ * AI agent pipeline, and delivers replies via ANI REST API.
351
+ *
352
+ * Phase 2: Replies are streamed incrementally. On the first chunk a
353
+ * stream_start message is sent with a generated stream_id. Subsequent
354
+ * chunks become stream_delta messages with progress status layers.
355
+ * The final flush sends a stream_end message (persisted by the backend).
356
+ * Artifacts are still buffered and sent only in the final flush.
357
+ */
358
+ export function createAniMessageHandler(params: AniHandlerParams) {
359
+ const {
360
+ core,
361
+ cfg,
362
+ runtime,
363
+ logger,
364
+ logVerbose,
365
+ serverUrl,
366
+ apiKey,
367
+ selfEntityId,
368
+ selfName,
369
+ accountId,
370
+ } = params;
371
+
372
+ const startupMs = Date.now();
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Inbound debouncing: coalesce rapid messages from the same sender
376
+ // ---------------------------------------------------------------------------
377
+ const debouncer = createInboundDebouncer(1500);
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Concurrency limiting: max parallel AI dispatches per conversation
381
+ // ---------------------------------------------------------------------------
382
+ const MAX_CONCURRENT = 2;
383
+ const concurrencyMap = new Map<number, number>();
384
+ const concurrencyQueue = new Map<number, Array<() => void>>();
385
+
386
+ function acquireSlot(convId: number): boolean {
387
+ const current = concurrencyMap.get(convId) || 0;
388
+ if (current >= MAX_CONCURRENT) return false;
389
+ concurrencyMap.set(convId, current + 1);
390
+ return true;
391
+ }
392
+
393
+ function releaseSlot(convId: number) {
394
+ const current = concurrencyMap.get(convId) || 0;
395
+ if (current <= 1) concurrencyMap.delete(convId);
396
+ else concurrencyMap.set(convId, current - 1);
397
+
398
+ // Process queued dispatch if any
399
+ const queue = concurrencyQueue.get(convId);
400
+ if (queue && queue.length > 0) {
401
+ const next = queue.shift()!;
402
+ if (queue.length === 0) concurrencyQueue.delete(convId);
403
+ next();
404
+ }
405
+ }
406
+
407
+ function enqueueOrRun(convId: number, fn: () => Promise<void>): void {
408
+ if (acquireSlot(convId)) {
409
+ fn().finally(() => releaseSlot(convId));
410
+ } else {
411
+ logVerbose(`ani: concurrency limit reached for conv=${convId}, queueing dispatch`);
412
+ const queue = concurrencyQueue.get(convId) ?? [];
413
+ queue.push(() => {
414
+ acquireSlot(convId);
415
+ fn().finally(() => releaseSlot(convId));
416
+ });
417
+ concurrencyQueue.set(convId, queue);
418
+ }
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // Revocation tracking: when a user revokes a message that triggered agent
423
+ // processing, we flag the stream so the deliver callback skips remaining blocks.
424
+ // ---------------------------------------------------------------------------
425
+
426
+ /** Maps trigger messageId → streamId assigned during processing. */
427
+ const messageToStreamMap = new Map<string, string>();
428
+
429
+ /** Set of trigger messageIds whose source messages have been revoked. */
430
+ const revokedMessages = new Set<string>();
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Stream cancellation: AbortController per active stream. When the user sends
434
+ // a task.cancel event, we abort the controller so the deliver callback skips
435
+ // remaining chunks and the dispatch is interrupted as quickly as possible.
436
+ // ---------------------------------------------------------------------------
437
+
438
+ /** Maps streamId → AbortController for in-progress dispatches. */
439
+ const activeStreams = new Map<string, { controller: AbortController; conversationId: number }>();
440
+
441
+ // Cache conversation metadata (refreshed every 5 minutes)
442
+ const convCache = new Map<number, { conv: AniConversation; memories: AniMemory[]; fetchedAt: number }>();
443
+ const CACHE_TTL = 5 * 60 * 1000;
444
+ const CACHE_MAX_SIZE = 100;
445
+
446
+ async function getConversationContext(conversationId: number): Promise<{
447
+ conv: AniConversation | null;
448
+ memories: AniMemory[];
449
+ }> {
450
+ const cached = convCache.get(conversationId);
451
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
452
+ return { conv: cached.conv, memories: cached.memories };
453
+ }
454
+ const [conv, memories] = await Promise.all([
455
+ fetchConversation({ serverUrl, apiKey, conversationId }),
456
+ fetchConversationMemories({ serverUrl, apiKey, conversationId }),
457
+ ]);
458
+ if (conv) {
459
+ // Evict oldest entry if cache is at capacity
460
+ if (convCache.size >= CACHE_MAX_SIZE && !convCache.has(conversationId)) {
461
+ let oldestKey: number | undefined;
462
+ let oldestTime = Infinity;
463
+ for (const [key, entry] of convCache) {
464
+ if (entry.fetchedAt < oldestTime) {
465
+ oldestTime = entry.fetchedAt;
466
+ oldestKey = key;
467
+ }
468
+ }
469
+ if (oldestKey !== undefined) convCache.delete(oldestKey);
470
+ }
471
+ convCache.set(conversationId, { conv, memories, fetchedAt: Date.now() });
472
+ }
473
+ return { conv, memories };
474
+ }
475
+
476
+ function buildConversationSystemPrompt(
477
+ conv: AniConversation | null,
478
+ memories: AniMemory[],
479
+ conversationId: number,
480
+ ): string {
481
+ const parts: string[] = [];
482
+
483
+ // Identity
484
+ parts.push(`You are ${selfName}.`);
485
+
486
+ // Current conversation context
487
+ const convType = conv?.conv_type ?? "group";
488
+ parts.push(`## Current Conversation\n\nConversation ID: ${conversationId}\nType: ${convType}`);
489
+
490
+ // Conversation instructions (set by owner)
491
+ if (conv?.prompt?.trim()) {
492
+ parts.push(`## Instructions\n\n${conv.prompt.trim()}`);
493
+ }
494
+
495
+ // Conversation description
496
+ if (conv?.description?.trim()) {
497
+ parts.push(`## Conversation Description\n\n${conv.description.trim()}`);
498
+ }
499
+
500
+ // Participants (with entity_id for cross-bot mention/collaboration)
501
+ if (conv?.participants && conv.participants.length > 0) {
502
+ const memberLines = conv.participants.map((p) => {
503
+ const name = p.entity?.display_name ?? `entity-${p.entity_id}`;
504
+ const type = p.entity?.entity_type ?? "unknown";
505
+ const role = p.role ?? "member";
506
+ return `- ${name} (${type}, ${role}, id=${p.entity_id})`;
507
+ });
508
+ parts.push(`## Participants\n\n${memberLines.join("\n")}`);
509
+ }
510
+
511
+ // Multi-bot group behavior: teach the agent when to stay silent
512
+ if (conv?.participants && conv.participants.filter((p) => p.entity?.entity_type === "bot").length > 1) {
513
+ parts.push([
514
+ "## Group Behavior",
515
+ "",
516
+ "This is a multi-bot group. Follow these rules strictly:",
517
+ "",
518
+ "**When to respond:**",
519
+ "- A human @mentions you by name with a direct task for you → respond.",
520
+ "- Another bot @mentions you (task handoff) → respond.",
521
+ "",
522
+ "**When to stay silent (reply with exactly [SILENT]):**",
523
+ "- The message @mentions another bot but NOT you → [SILENT].",
524
+ "- Another bot posted a message and did NOT @mention you → [SILENT].",
525
+ "- The message assigns tasks to multiple bots sequentially (e.g. 'Bot_A do X, then Bot_B do Y') and your task depends on another bot finishing first → [SILENT]. Wait for that bot to @mention you with results.",
526
+ "",
527
+ "**How to @mention (IMPORTANT — avoid loops):**",
528
+ "- ONLY use @TheirName when you need them to take action (task handoff).",
529
+ "- If you are just replying, confirming, or acknowledging — write their name WITHOUT the @ prefix.",
530
+ " Example: '胖胖虾 的结果已收到,任务完成。' (no @, no notification)",
531
+ " Example: '@小龙虾 请处理这个数据' (with @, triggers notification)",
532
+ "- NEVER @mention a bot just to say 'received' or 'done' — this causes infinite reply loops.",
533
+ "",
534
+ "[SILENT] means you choose not to speak. The system will suppress the message entirely.",
535
+ ].join("\n"));
536
+ }
537
+
538
+ // Memories
539
+ if (memories.length > 0) {
540
+ const memLines = memories.map((m) => `- **${m.key}**: ${m.content}`);
541
+ parts.push(`## Conversation Memory\n\n${memLines.join("\n")}`);
542
+ }
543
+
544
+ // Artifact support instructions
545
+ parts.push(ANI_ARTIFACT_SYSTEM_PROMPT);
546
+
547
+ // Tool awareness: tell the agent about available ANI tools
548
+ parts.push([
549
+ "## Available Tools",
550
+ "",
551
+ "- **ani_fetch_chat_history_messages**: Retrieve full conversation history from the ANI platform. Use when users reference earlier messages, files, or context — especially messages you were not @mentioned in. Default 5 messages, max 50.",
552
+ "- **ani_send_file**: Create and send a file to this conversation (text content or disk file).",
553
+ "- **ani_list_conversation_tasks**: List the current roadmap/tasks for this conversation, optionally filtered by status.",
554
+ "- **ani_get_task**: Fetch the details of a single task by task ID.",
555
+ "- **ani_create_task**: Create a new conversation task when the user asks you to add work items.",
556
+ "- **ani_update_task**: Update task status, assignee, priority, title, description, due date, or sort order.",
557
+ "- **ani_delete_task**: Delete a task when explicitly requested and allowed by ANI permissions.",
558
+ ].join("\n"));
559
+
560
+ return parts.join("\n\n");
561
+ }
562
+
563
+ /**
564
+ * Build a lighter system prompt for direct (1:1) conversations.
565
+ * Skips participant list and group description since there are only two entities.
566
+ */
567
+ function buildDirectSystemPrompt(
568
+ conv: AniConversation | null,
569
+ memories: AniMemory[],
570
+ conversationId: number,
571
+ ): string {
572
+ const parts: string[] = [];
573
+
574
+ parts.push(`You are ${selfName}.`);
575
+
576
+ // Current conversation context
577
+ parts.push(`## Current Conversation\n\nConversation ID: ${conversationId}\nType: direct`);
578
+
579
+ if (conv?.prompt?.trim()) {
580
+ parts.push(`## Instructions\n\n${conv.prompt.trim()}`);
581
+ }
582
+
583
+ if (memories.length > 0) {
584
+ const memLines = memories.map((m) => `- **${m.key}**: ${m.content}`);
585
+ parts.push(`## Conversation Memory\n\n${memLines.join("\n")}`);
586
+ }
587
+
588
+ parts.push(ANI_ARTIFACT_SYSTEM_PROMPT);
589
+
590
+ parts.push([
591
+ "## Available Tools",
592
+ "",
593
+ "- **ani_fetch_chat_history_messages**: Retrieve full conversation history from the ANI platform. Use when users reference earlier messages, files, or context you don't have. Default 5 messages, max 50.",
594
+ "- **ani_send_file**: Create and send a file to this conversation (text content or disk file).",
595
+ "- **ani_list_conversation_tasks**: List the current roadmap/tasks for this conversation, optionally filtered by status.",
596
+ "- **ani_get_task**: Fetch the details of a single task by task ID.",
597
+ "- **ani_create_task**: Create a new conversation task when the user asks you to add work items.",
598
+ "- **ani_update_task**: Update task status, assignee, priority, title, description, due date, or sort order.",
599
+ "- **ani_delete_task**: Delete a task when explicitly requested and allowed by ANI permissions.",
600
+ ].join("\n"));
601
+
602
+ return parts.join("\n\n");
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Core dispatch logic (called after debounce window or immediately for attachments)
607
+ // ---------------------------------------------------------------------------
608
+
609
+ type DispatchParams = {
610
+ combinedText: string;
611
+ messageIds: string[];
612
+ conversationId: number;
613
+ senderId: number;
614
+ senderName: string;
615
+ senderType: string;
616
+ attachments: NonNullable<NonNullable<AniWsMessage["data"]>["attachments"]>;
617
+ attachmentText: string;
618
+ savedMedia: SavedAttachment[];
619
+ msg: NonNullable<AniWsMessage["data"]>;
620
+ };
621
+
622
+ async function dispatchToAgent(params: DispatchParams): Promise<void> {
623
+ const {
624
+ combinedText: text,
625
+ messageIds,
626
+ conversationId,
627
+ senderId,
628
+ senderName,
629
+ senderType,
630
+ attachments,
631
+ attachmentText,
632
+ savedMedia,
633
+ msg,
634
+ } = params;
635
+ const messageId = messageIds[0] ?? "";
636
+
637
+ return new Promise<void>((resolve, reject) => {
638
+ enqueueOrRun(conversationId, async () => {
639
+ let streamId: string | undefined;
640
+ try {
641
+ // Fetch conversation context (cached, refreshed every 5 min)
642
+ const { conv: convContext, memories } = await getConversationContext(conversationId);
643
+ const conversationTitle = convContext?.title ?? msg.conversation?.title ?? `conv-${conversationId}`;
644
+
645
+ const convType = convContext?.conv_type ?? msg.conversation?.conv_type ?? "group";
646
+ const isDirect = convType === "direct";
647
+
648
+ const groupSystemPrompt = isDirect
649
+ ? buildDirectSystemPrompt(convContext, memories, conversationId)
650
+ : buildConversationSystemPrompt(convContext, memories, conversationId);
651
+
652
+ logger.info(
653
+ `ani: inbound conv=${conversationId} type=${convType} from=${senderName}(${senderId}) attachments=${attachments.length} hasText=${Boolean(text.trim())} msgIds=${messageIds.length}`,
654
+ );
655
+
656
+ // Route through OpenClaw agent pipeline
657
+ const peerKind = isDirect ? "dm" : "channel";
658
+ const route = core.channel.routing.resolveAgentRoute({
659
+ cfg,
660
+ channel: "ani",
661
+ peer: {
662
+ kind: peerKind,
663
+ id: String(conversationId),
664
+ },
665
+ });
666
+
667
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
668
+ agentId: route.agentId,
669
+ });
670
+
671
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
672
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
673
+ storePath,
674
+ sessionKey: route.sessionKey,
675
+ });
676
+
677
+ const rawBody = attachmentText ? `${text}\n\n${attachmentText}` : text;
678
+ logVerbose(`ani: rawBody for envelope (${rawBody.length} chars): ${rawBody.slice(0, 500)}`);
679
+
680
+ const body = core.channel.reply.formatAgentEnvelope({
681
+ channel: "ANI",
682
+ from: senderName,
683
+ timestamp: msg.created_at ? new Date(msg.created_at).getTime() : undefined,
684
+ previousTimestamp,
685
+ envelope: envelopeOptions,
686
+ body: rawBody,
687
+ });
688
+ logVerbose(`ani: formatted body (${body.length} chars): ${body.slice(0, 500)}`);
689
+
690
+ const mediaPaths = savedMedia.map((m) => m.path);
691
+ const mediaTypes = savedMedia.map((m) => m.contentType ?? "application/octet-stream");
692
+
693
+ const chatType = isDirect ? "direct" as const : "channel" as const;
694
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
695
+ Body: body,
696
+ BodyForAgent: rawBody,
697
+ RawBody: text,
698
+ CommandBody: text,
699
+ From: isDirect ? `ani:dm:${senderId}` : `ani:channel:${conversationId}`,
700
+ To: `ani:conv:${conversationId}`,
701
+ SessionKey: route.sessionKey,
702
+ AccountId: route.accountId,
703
+ ChatType: chatType,
704
+ ConversationLabel: senderName,
705
+ SenderName: senderName,
706
+ SenderId: String(senderId),
707
+ ...(mediaPaths.length > 0 ? {
708
+ MediaPath: mediaPaths[0],
709
+ MediaPaths: mediaPaths,
710
+ MediaType: mediaTypes[0],
711
+ MediaTypes: mediaTypes,
712
+ } : {}),
713
+ ...(isDirect
714
+ ? {}
715
+ : {
716
+ GroupSubject: conversationTitle,
717
+ GroupChannel: String(conversationId),
718
+ }),
719
+ GroupSystemPrompt: groupSystemPrompt,
720
+ Provider: "ani" as const,
721
+ Surface: "ani" as const,
722
+ MessageSid: messageId,
723
+ Timestamp: msg.created_at ? new Date(msg.created_at).getTime() : undefined,
724
+ CommandAuthorized: true,
725
+ CommandSource: "text" as const,
726
+ OriginatingChannel: "ani" as const,
727
+ OriginatingTo: `ani:conv:${conversationId}`,
728
+ });
729
+
730
+ await core.channel.session.recordInboundSession({
731
+ storePath,
732
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
733
+ ctx: ctxPayload,
734
+ onRecordError: (err) => {
735
+ logger.warn(
736
+ { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
737
+ "failed updating session meta",
738
+ );
739
+ },
740
+ });
741
+
742
+ const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "ani");
743
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
744
+
745
+ const typingCallbacks = createTypingCallbacks({
746
+ start: () => Promise.resolve(),
747
+ stop: () => Promise.resolve(),
748
+ onStartError: () => {},
749
+ onStopError: () => {},
750
+ });
751
+
752
+ let didSendReply = false;
753
+
754
+ const participantNameMap = new Map<string, number>();
755
+ if (convContext?.participants) {
756
+ for (const p of convContext.participants) {
757
+ const name = p.entity?.display_name;
758
+ if (name && p.entity_id !== selfEntityId) {
759
+ participantNameMap.set(name, p.entity_id);
760
+ }
761
+ }
762
+ }
763
+
764
+ function extractMentionsFromText(text: string): number[] {
765
+ const found: number[] = [];
766
+ for (const [name, id] of participantNameMap) {
767
+ if (text.includes(`@${name}`)) {
768
+ found.push(id);
769
+ }
770
+ }
771
+ return found;
772
+ }
773
+
774
+ streamId = generateStreamId();
775
+ const replyBuffer: string[] = [];
776
+ let totalChars = 0;
777
+
778
+ for (const mid of messageIds) {
779
+ if (mid) messageToStreamMap.set(mid, streamId);
780
+ }
781
+
782
+ const streamAbortController = new AbortController();
783
+ activeStreams.set(streamId, { controller: streamAbortController, conversationId });
784
+
785
+ const { dispatcher, replyOptions, markDispatchIdle } =
786
+ core.channel.reply.createReplyDispatcherWithTyping({
787
+ responsePrefix: prefixContext.responsePrefix,
788
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
789
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
790
+ deliver: async (payload, info) => {
791
+ if (messageIds.some((mid) => mid && revokedMessages.has(mid))) {
792
+ logVerbose(`ani: skipping reply block — trigger message was revoked`);
793
+ return;
794
+ }
795
+
796
+ if (streamAbortController.signal.aborted) {
797
+ logVerbose(`ani: skipping reply block — stream ${streamId} was cancelled`);
798
+ return;
799
+ }
800
+
801
+ const replyText = payload.text ?? "";
802
+ if (!replyText.trim()) return;
803
+
804
+ replyBuffer.push(replyText);
805
+ totalChars += replyText.length;
806
+
807
+ if (replyText.trim() === "[SILENT]" || replyText.trim().startsWith("[SILENT]")) {
808
+ logVerbose(`ani: agent chose [SILENT], suppressing block`);
809
+ return;
810
+ }
811
+
812
+ const autoMentions = extractMentionsFromText(replyText);
813
+ const segments = parseArtifacts(replyText);
814
+ const hasArtifacts = segments.some((s) => s.artifact);
815
+
816
+ if (hasArtifacts) {
817
+ for (const seg of segments) {
818
+ try {
819
+ const plainText = seg.textBefore.trim();
820
+ if (plainText) {
821
+ const chunks = core.channel.text.chunkMarkdownText(plainText, textLimit);
822
+ for (const chunk of chunks.length > 0 ? chunks : [plainText]) {
823
+ const trimmed = chunk.trim();
824
+ if (!trimmed) continue;
825
+ await sendAniMessage({ serverUrl, apiKey, conversationId, text: trimmed, streamId, mentions: autoMentions });
826
+ }
827
+ }
828
+ if (seg.artifact) {
829
+ await sendAniMessage({
830
+ serverUrl, apiKey, conversationId,
831
+ text: seg.artifact.title ?? "Artifact",
832
+ artifact: seg.artifact, streamId, mentions: autoMentions,
833
+ });
834
+ }
835
+ } catch (segErr) {
836
+ logger.warn({ error: String(segErr), conversationId }, "ani: artifact segment send failed");
837
+ }
838
+ }
839
+ } else {
840
+ const chunks = core.channel.text.chunkMarkdownText(replyText, textLimit);
841
+ for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
842
+ const trimmed = chunk.trim();
843
+ if (!trimmed) continue;
844
+ await sendAniMessage({ serverUrl, apiKey, conversationId, text: trimmed, streamId, mentions: autoMentions });
845
+ }
846
+ }
847
+ didSendReply = true;
848
+ logVerbose(`ani: sent block (${replyText.length} chars, kind=${info?.kind ?? "unknown"})`);
849
+ },
850
+ onSkip: (_payload, info) => {
851
+ if (info.reason !== "silent") {
852
+ logVerbose(`ani: skipped reply block (reason=${info.reason})`);
853
+ }
854
+ },
855
+ onError: (err, info) => {
856
+ runtime.error?.(`ani ${info.kind} reply failed: ${String(err)}`);
857
+ },
858
+ onReplyStart: typingCallbacks.onReplyStart,
859
+ onIdle: typingCallbacks.onIdle,
860
+ });
861
+
862
+ const { queuedFinal } = await core.channel.reply.dispatchReplyFromConfig({
863
+ ctx: ctxPayload,
864
+ cfg,
865
+ dispatcher,
866
+ replyOptions: {
867
+ ...replyOptions,
868
+ onModelSelected: prefixContext.onModelSelected,
869
+ },
870
+ });
871
+ markDispatchIdle();
872
+
873
+ if (queuedFinal) didSendReply = true;
874
+
875
+ if (didSendReply) {
876
+ const preview = text.replace(/\s+/g, " ").slice(0, 160);
877
+ core.system.enqueueSystemEvent(`ANI message from ${senderName}: ${preview}`, {
878
+ sessionKey: route.sessionKey,
879
+ contextKey: `ani:message:${conversationId}:${messageId}`,
880
+ });
881
+ logVerbose(`ani: delivered reply to conv=${conversationId} streamId=${streamId} chunks=${replyBuffer.length}`);
882
+ }
883
+
884
+ if (!didSendReply && !queuedFinal) {
885
+ await sendAniMessage({
886
+ serverUrl, apiKey, conversationId,
887
+ text: "No response generated. Please try again.",
888
+ streamId,
889
+ });
890
+ }
891
+
892
+ for (const mid of messageIds) {
893
+ if (mid) {
894
+ messageToStreamMap.delete(mid);
895
+ revokedMessages.delete(mid);
896
+ }
897
+ }
898
+ activeStreams.delete(streamId);
899
+ resolve();
900
+
901
+ } catch (err) {
902
+ if (streamId) {
903
+ activeStreams.delete(streamId);
904
+ for (const mid of messageIds) {
905
+ if (mid) {
906
+ messageToStreamMap.delete(mid);
907
+ revokedMessages.delete(mid);
908
+ }
909
+ }
910
+ }
911
+ runtime.error?.(`ani dispatch error: ${String(err)}`);
912
+ reject(err);
913
+ }
914
+ });
915
+ });
916
+ }
917
+
918
+ const handler = async (wsMsg: AniWsMessage) => {
919
+ try {
920
+ // Handle message revocation: flag the trigger so deliver callback skips remaining blocks
921
+ if (wsMsg.type === "message.revoked") {
922
+ const revokedId = String(wsMsg.data?.id ?? "");
923
+ const conversationId = wsMsg.data?.conversation_id;
924
+ if (revokedId) {
925
+ revokedMessages.add(revokedId);
926
+ logVerbose(`ani: message ${revokedId} revoked in conv=${conversationId ?? "?"}`);
927
+
928
+ // Notify the agent session about the revocation
929
+ const streamId = messageToStreamMap.get(revokedId);
930
+ core.system.enqueueSystemEvent(
931
+ `User revoked message ${revokedId} in conversation ${conversationId ?? "unknown"}`,
932
+ {
933
+ contextKey: `ani:revoked:${conversationId ?? 0}:${revokedId}`,
934
+ ...(streamId ? { sessionKey: streamId } : {}),
935
+ },
936
+ );
937
+ }
938
+ return;
939
+ }
940
+
941
+ // Handle task cancellation: abort the in-progress dispatch for the given stream
942
+ if (wsMsg.type === "task.cancel" || wsMsg.type === "stream.cancel") {
943
+ const cancelStreamId = (wsMsg.data as Record<string, unknown> | undefined)?.stream_id as string | undefined;
944
+ if (cancelStreamId) {
945
+ const entry = activeStreams.get(cancelStreamId);
946
+ if (entry) {
947
+ logger.info(`ani: stream cancelled by user, streamId=${cancelStreamId} conv=${entry.conversationId}`);
948
+ entry.controller.abort();
949
+ activeStreams.delete(cancelStreamId);
950
+
951
+ // Send a cancellation progress event so the frontend knows the stream stopped
952
+ sendAniProgress({
953
+ serverUrl,
954
+ apiKey,
955
+ conversationId: entry.conversationId,
956
+ streamId: cancelStreamId,
957
+ status: { phase: "cancelled", progress: 100, text: "Stopped by user" },
958
+ }).catch((err) => {
959
+ logVerbose(`ani: cancel progress send failed: ${String(err)}`);
960
+ });
961
+ } else {
962
+ logVerbose(`ani: task.cancel for unknown streamId=${cancelStreamId}`);
963
+ }
964
+ }
965
+ return;
966
+ }
967
+
968
+ // Only handle new messages (ANI uses "message.new" with dot)
969
+ if (wsMsg.type !== "message.new") {
970
+ logVerbose(`ani: ignoring ws event type=${wsMsg.type ?? "unknown"}`);
971
+ return;
972
+ }
973
+
974
+ let msg = wsMsg.data;
975
+ if (!msg) return;
976
+
977
+ // Handle enriched WS format (mention_with_context): { message, context_messages }
978
+ if (!msg.layers && (msg as any).message) {
979
+ msg = (msg as any).message;
980
+ }
981
+
982
+ // Skip own messages
983
+ if (msg.sender_id === selfEntityId) return;
984
+
985
+ const conversationId = msg.conversation_id;
986
+ if (!conversationId) return;
987
+
988
+ // Prefer data.body (full content) over summary (may be truncated by frontend)
989
+ const dataBody = typeof msg.layers?.data === "object" && msg.layers?.data !== null
990
+ ? (msg.layers.data as Record<string, unknown>).body
991
+ : undefined;
992
+ const text = (typeof dataBody === "string" ? dataBody : null) ?? msg.layers?.summary ?? msg.layers?.detail ?? "";
993
+
994
+ // Process attachments:
995
+ // 1. Download and save to disk for OpenClaw media pipeline (MediaPath/MediaType)
996
+ // 2. Also produce text descriptions for context (attachmentText)
997
+ const attachments = msg.attachments ?? [];
998
+ logVerbose(`ani: attachments count=${attachments.length} raw=${JSON.stringify(attachments).slice(0, 500)}`);
999
+ let attachmentText = '';
1000
+ let savedMedia: SavedAttachment[] = [];
1001
+ if (attachments.length > 0) {
1002
+ // Save files to disk for media-understanding pipeline (vision, audio, etc.)
1003
+ savedMedia = await downloadAndSaveAttachments(attachments, serverUrl, apiKey, core.media.saveMediaBuffer, logVerbose);
1004
+ // Also generate text descriptions as supplementary context
1005
+ attachmentText = await processAttachments(attachments, serverUrl, apiKey);
1006
+ logVerbose(`ani: attachmentText (${attachmentText.length} chars) savedMedia=${savedMedia.length}: ${attachmentText.slice(0, 300)}`);
1007
+ }
1008
+
1009
+ if (!text.trim() && attachments.length === 0) return;
1010
+
1011
+ const senderId = msg.sender_id ?? 0;
1012
+ const senderName =
1013
+ msg.sender?.display_name ?? `entity-${senderId}`;
1014
+ const senderType = msg.sender?.entity_type ?? msg.sender_type ?? "unknown";
1015
+ const messageId = String(msg.id ?? "");
1016
+
1017
+ // Send ack-reaction if configured (confirms message receipt).
1018
+ const ackEmoji = cfg.messages?.ackReaction?.trim();
1019
+ if (ackEmoji && msg.id) {
1020
+ toggleAniReaction({ serverUrl, apiKey, messageId: msg.id, emoji: ackEmoji }).catch((err) => {
1021
+ logVerbose(`ani: ack-reaction failed for msg=${msg.id}: ${String(err)}`);
1022
+ });
1023
+ }
1024
+
1025
+ // Debounce text-only messages from the same sender in the same conversation.
1026
+ // Messages with attachments bypass debouncing since they need immediate processing.
1027
+ const hasAttachments = attachments.length > 0;
1028
+ if (!hasAttachments && text.trim()) {
1029
+ const debounceKey = `${conversationId}:${senderId}`;
1030
+ debouncer.debounce(debounceKey, text, messageId, (combinedText, messageIds) => {
1031
+ logVerbose(`ani: debounce fired for ${debounceKey}, ${messageIds.length} message(s) coalesced`);
1032
+ dispatchToAgent({
1033
+ combinedText,
1034
+ messageIds,
1035
+ conversationId,
1036
+ senderId,
1037
+ senderName,
1038
+ senderType,
1039
+ attachments: [],
1040
+ attachmentText: "",
1041
+ savedMedia: [],
1042
+ msg,
1043
+ }).catch((err) => {
1044
+ runtime.error?.(`ani debounced handler error: ${String(err)}`);
1045
+ });
1046
+ });
1047
+ return;
1048
+ }
1049
+
1050
+ // Messages with attachments: dispatch immediately (no debouncing)
1051
+ await dispatchToAgent({
1052
+ combinedText: text,
1053
+ messageIds: [messageId],
1054
+ conversationId,
1055
+ senderId,
1056
+ senderName,
1057
+ senderType,
1058
+ attachments,
1059
+ attachmentText,
1060
+ savedMedia,
1061
+ msg,
1062
+ });
1063
+
1064
+ } catch (err) {
1065
+ runtime.error?.(`ani handler error: ${String(err)}`);
1066
+ }
1067
+ };
1068
+
1069
+ return handler;
1070
+ }