@hybridaione/hybridclaw 0.2.1 → 0.2.2

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 (105) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +47 -15
  3. package/container/package-lock.json +2 -2
  4. package/container/package.json +1 -1
  5. package/container/src/browser-tools.ts +1 -1
  6. package/container/src/index.ts +243 -14
  7. package/container/src/token-usage.ts +18 -2
  8. package/container/src/tools.ts +339 -1
  9. package/container/src/types.ts +28 -2
  10. package/dist/agent.d.ts +2 -2
  11. package/dist/agent.d.ts.map +1 -1
  12. package/dist/agent.js +2 -2
  13. package/dist/agent.js.map +1 -1
  14. package/dist/channels/discord/attachments.d.ts +9 -0
  15. package/dist/channels/discord/attachments.d.ts.map +1 -0
  16. package/dist/channels/discord/attachments.js +245 -0
  17. package/dist/channels/discord/attachments.js.map +1 -0
  18. package/dist/channels/discord/delivery.d.ts +31 -0
  19. package/dist/channels/discord/delivery.d.ts.map +1 -0
  20. package/dist/channels/discord/delivery.js +60 -0
  21. package/dist/channels/discord/delivery.js.map +1 -0
  22. package/dist/channels/discord/inbound.d.ts +20 -0
  23. package/dist/channels/discord/inbound.d.ts.map +1 -0
  24. package/dist/channels/discord/inbound.js +44 -0
  25. package/dist/channels/discord/inbound.js.map +1 -0
  26. package/dist/channels/discord/mentions.d.ts +14 -0
  27. package/dist/channels/discord/mentions.d.ts.map +1 -0
  28. package/dist/channels/discord/mentions.js +118 -0
  29. package/dist/channels/discord/mentions.js.map +1 -0
  30. package/dist/channels/discord/runtime.d.ts +22 -0
  31. package/dist/channels/discord/runtime.d.ts.map +1 -0
  32. package/dist/channels/discord/runtime.js +972 -0
  33. package/dist/channels/discord/runtime.js.map +1 -0
  34. package/dist/channels/discord/stream.d.ts +32 -0
  35. package/dist/channels/discord/stream.d.ts.map +1 -0
  36. package/dist/channels/discord/stream.js +196 -0
  37. package/dist/channels/discord/stream.js.map +1 -0
  38. package/dist/channels/discord/tool-actions.d.ts +31 -0
  39. package/dist/channels/discord/tool-actions.d.ts.map +1 -0
  40. package/dist/channels/discord/tool-actions.js +268 -0
  41. package/dist/channels/discord/tool-actions.js.map +1 -0
  42. package/dist/container-runner.d.ts +2 -2
  43. package/dist/container-runner.d.ts.map +1 -1
  44. package/dist/container-runner.js +12 -2
  45. package/dist/container-runner.js.map +1 -1
  46. package/dist/discord.basic.test.d.ts +2 -0
  47. package/dist/discord.basic.test.d.ts.map +1 -0
  48. package/dist/discord.basic.test.js +38 -0
  49. package/dist/discord.basic.test.js.map +1 -0
  50. package/dist/discord.d.ts +5 -44
  51. package/dist/discord.d.ts.map +1 -1
  52. package/dist/discord.js +3 -1468
  53. package/dist/discord.js.map +1 -1
  54. package/dist/gateway-service.d.ts +7 -1
  55. package/dist/gateway-service.d.ts.map +1 -1
  56. package/dist/gateway-service.js +111 -2
  57. package/dist/gateway-service.js.map +1 -1
  58. package/dist/gateway-service.media-routing.test.d.ts +2 -0
  59. package/dist/gateway-service.media-routing.test.d.ts.map +1 -0
  60. package/dist/gateway-service.media-routing.test.js +29 -0
  61. package/dist/gateway-service.media-routing.test.js.map +1 -0
  62. package/dist/gateway-types.d.ts +8 -0
  63. package/dist/gateway-types.d.ts.map +1 -1
  64. package/dist/gateway-types.js.map +1 -1
  65. package/dist/gateway.js +5 -2
  66. package/dist/gateway.js.map +1 -1
  67. package/dist/health.d.ts.map +1 -1
  68. package/dist/health.js +1 -1
  69. package/dist/health.js.map +1 -1
  70. package/dist/heartbeat.d.ts.map +1 -1
  71. package/dist/heartbeat.js +2 -0
  72. package/dist/heartbeat.js.map +1 -1
  73. package/dist/token-efficiency.basic.test.d.ts +2 -0
  74. package/dist/token-efficiency.basic.test.d.ts.map +1 -0
  75. package/dist/token-efficiency.basic.test.js +29 -0
  76. package/dist/token-efficiency.basic.test.js.map +1 -0
  77. package/dist/token-efficiency.d.ts.map +1 -1
  78. package/dist/token-efficiency.js +18 -1
  79. package/dist/token-efficiency.js.map +1 -1
  80. package/dist/types.d.ts +23 -1
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +10 -2
  83. package/src/agent.ts +11 -1
  84. package/src/channels/discord/attachments.ts +282 -0
  85. package/src/channels/discord/delivery.ts +99 -0
  86. package/src/channels/discord/inbound.ts +72 -0
  87. package/src/channels/discord/mentions.ts +130 -0
  88. package/src/{discord.ts → channels/discord/runtime.ts} +77 -615
  89. package/src/{discord-stream.ts → channels/discord/stream.ts} +2 -2
  90. package/src/channels/discord/tool-actions.ts +332 -0
  91. package/src/container-runner.ts +24 -1
  92. package/src/gateway-service.ts +125 -1
  93. package/src/gateway-types.ts +8 -0
  94. package/src/gateway.ts +5 -5
  95. package/src/health.ts +2 -1
  96. package/src/heartbeat.ts +2 -0
  97. package/src/token-efficiency.ts +17 -1
  98. package/src/types.ts +27 -1
  99. package/tests/discord.basic.test.ts +43 -0
  100. package/tests/gateway-service.media-routing.test.ts +33 -0
  101. package/tests/token-efficiency.basic.test.ts +32 -0
  102. package/vitest.e2e.config.ts +15 -0
  103. package/vitest.integration.config.ts +15 -0
  104. package/vitest.live.config.ts +16 -0
  105. package/vitest.unit.config.ts +15 -0
@@ -3,8 +3,8 @@ import {
3
3
  type Message as DiscordMessage,
4
4
  } from 'discord.js';
5
5
 
6
- import { chunkMessage } from './chunk.js';
7
- import { logger } from './logger.js';
6
+ import { chunkMessage } from '../../chunk.js';
7
+ import { logger } from '../../logger.js';
8
8
 
9
9
  interface DiscordSendChannel {
10
10
  send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<DiscordMessage>;
@@ -0,0 +1,332 @@
1
+ import type { Client, GuildMember } from 'discord.js';
2
+
3
+ export type DiscordToolAction = 'read' | 'member-info' | 'channel-info';
4
+
5
+ export interface DiscordToolActionRequest {
6
+ action: DiscordToolAction;
7
+ channelId?: string;
8
+ guildId?: string;
9
+ userId?: string;
10
+ username?: string;
11
+ user?: string;
12
+ memberId?: string;
13
+ limit?: number;
14
+ before?: string;
15
+ after?: string;
16
+ around?: string;
17
+ }
18
+
19
+ export interface CachedDiscordPresenceActivity {
20
+ type: number;
21
+ name: string;
22
+ state: string | null;
23
+ details: string | null;
24
+ }
25
+
26
+ export interface CachedDiscordPresence {
27
+ status: string;
28
+ activities: CachedDiscordPresenceActivity[];
29
+ }
30
+
31
+ export interface DiscordToolActionDependencies {
32
+ requireDiscordClientReady: () => Client;
33
+ getDiscordPresence: (userId: string) => CachedDiscordPresence | undefined;
34
+ }
35
+
36
+ function sanitizeDiscordId(rawValue: string | undefined, label: string): string {
37
+ const value = (rawValue || '').trim();
38
+ if (!/^\d{16,22}$/.test(value)) {
39
+ throw new Error(`${label} must be a Discord snowflake id.`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function normalizeDiscordUserLookupQuery(rawValue: string | undefined): string {
45
+ const trimmed = (rawValue || '').trim();
46
+ if (!trimmed) return '';
47
+
48
+ const mentionMatch = trimmed.match(/^<@!?(\d{16,22})>$/);
49
+ if (mentionMatch) return mentionMatch[1];
50
+ const prefixedId = trimmed.match(/^(?:user:|discord:)?(\d{16,22})$/i);
51
+ if (prefixedId) return prefixedId[1];
52
+
53
+ return trimmed.replace(/^@+/, '').trim();
54
+ }
55
+
56
+ function scoreGuildMemberForLookup(member: GuildMember, query: string): number {
57
+ const q = query.toLowerCase();
58
+ const username = member.user.username?.toLowerCase() || '';
59
+ const globalName = member.user.globalName?.toLowerCase() || '';
60
+ const nickname = member.nickname?.toLowerCase() || '';
61
+ const displayName = member.displayName?.toLowerCase() || '';
62
+ const candidates = [username, globalName, nickname, displayName].filter(Boolean);
63
+
64
+ let score = 0;
65
+ if (candidates.some((value) => value === q)) score += 3;
66
+ if (candidates.some((value) => value.includes(q))) score += 1;
67
+ if (!member.user.bot) score += 1;
68
+ return score;
69
+ }
70
+
71
+ async function resolveGuildMemberIdFromLookup(params: {
72
+ requireDiscordClientReady: () => Client;
73
+ guildId: string;
74
+ rawUser: string;
75
+ }): Promise<{ userId: string; note?: string }> {
76
+ const activeClient = params.requireDiscordClientReady();
77
+ const guildId = sanitizeDiscordId(params.guildId, 'guildId');
78
+ const normalized = normalizeDiscordUserLookupQuery(params.rawUser);
79
+ if (!normalized) {
80
+ throw new Error('userId or username is required.');
81
+ }
82
+ if (/^\d{16,22}$/.test(normalized)) {
83
+ return { userId: normalized };
84
+ }
85
+
86
+ const guild = await activeClient.guilds.fetch(guildId);
87
+ const searchQuery = normalized.slice(0, 32);
88
+ if (!searchQuery) {
89
+ throw new Error('username query is empty after normalization.');
90
+ }
91
+
92
+ let members: Map<string, GuildMember>;
93
+ try {
94
+ members = await guild.members.search({ query: searchQuery, limit: 25 });
95
+ } catch {
96
+ const fetched = await guild.members.fetch({ query: searchQuery, limit: 25 });
97
+ members = fetched;
98
+ }
99
+ let best: GuildMember | null = null;
100
+ let bestScore = 0;
101
+ let matchCount = 0;
102
+ for (const member of members.values()) {
103
+ const score = scoreGuildMemberForLookup(member, searchQuery);
104
+ if (score <= 0) continue;
105
+ matchCount += 1;
106
+ if (!best || score > bestScore) {
107
+ best = member;
108
+ bestScore = score;
109
+ }
110
+ }
111
+
112
+ if (!best) {
113
+ throw new Error(`No guild member matched username "${searchQuery}".`);
114
+ }
115
+
116
+ return {
117
+ userId: best.id,
118
+ note: matchCount > 1 ? 'multiple matches; chose best' : undefined,
119
+ };
120
+ }
121
+
122
+ function normalizeDate(value: Date | null | undefined): string | null {
123
+ if (!value) return null;
124
+ const ms = value.getTime();
125
+ if (!Number.isFinite(ms)) return null;
126
+ return value.toISOString();
127
+ }
128
+
129
+ async function runDiscordReadAction(
130
+ request: DiscordToolActionRequest,
131
+ deps: DiscordToolActionDependencies,
132
+ ): Promise<Record<string, unknown>> {
133
+ const activeClient = deps.requireDiscordClientReady();
134
+ const channelId = sanitizeDiscordId(request.channelId, 'channelId');
135
+ const channel = await activeClient.channels.fetch(channelId);
136
+ if (!channel || !('messages' in channel)) {
137
+ throw new Error('Channel does not support message reads.');
138
+ }
139
+
140
+ const requestedLimit =
141
+ typeof request.limit === 'number' && Number.isFinite(request.limit)
142
+ ? Math.floor(request.limit)
143
+ : 20;
144
+ const limit = Math.max(1, Math.min(100, requestedLimit));
145
+ const before = request.before?.trim();
146
+ const after = request.after?.trim();
147
+ const around = request.around?.trim();
148
+
149
+ const query: { limit: number; before?: string; after?: string; around?: string } = { limit };
150
+ if (before) query.before = before;
151
+ if (after) query.after = after;
152
+ if (around) query.around = around;
153
+
154
+ const fetched = await channel.messages.fetch(query);
155
+ const messages = Array.from(fetched.values())
156
+ .sort((a, b) => a.createdTimestamp - b.createdTimestamp || a.id.localeCompare(b.id))
157
+ .map((message) => ({
158
+ id: message.id,
159
+ channelId: message.channelId,
160
+ guildId: message.guildId ?? null,
161
+ content: message.content || '',
162
+ createdAt: new Date(message.createdTimestamp).toISOString(),
163
+ editedAt: normalizeDate(message.editedAt),
164
+ author: {
165
+ id: message.author?.id || 'unknown',
166
+ username: message.author?.username || 'unknown',
167
+ handle: message.author?.username ? `@${message.author.username}` : null,
168
+ globalName: message.author?.globalName || null,
169
+ bot: Boolean(message.author?.bot),
170
+ },
171
+ member: message.member
172
+ ? {
173
+ id: message.member.id,
174
+ nickname: message.member.nickname || null,
175
+ displayName: message.member.displayName || null,
176
+ }
177
+ : null,
178
+ attachments: Array.from(message.attachments.values()).map((attachment) => ({
179
+ id: attachment.id,
180
+ name: attachment.name || null,
181
+ url: attachment.url,
182
+ contentType: attachment.contentType || null,
183
+ size: attachment.size,
184
+ })),
185
+ mentions: {
186
+ users: Array.from(message.mentions.users.values()).map((user) => ({
187
+ id: user.id,
188
+ username: user.username,
189
+ bot: Boolean(user.bot),
190
+ })),
191
+ roles: Array.from(message.mentions.roles.values()).map((role) => ({
192
+ id: role.id,
193
+ name: role.name,
194
+ })),
195
+ channels: Array.from(message.mentions.channels.values()).map((mentionedChannel) => ({
196
+ id: mentionedChannel.id,
197
+ name: 'name' in mentionedChannel && typeof mentionedChannel.name === 'string'
198
+ ? mentionedChannel.name
199
+ : null,
200
+ })),
201
+ },
202
+ }));
203
+
204
+ return {
205
+ ok: true,
206
+ action: 'read',
207
+ channelId,
208
+ count: messages.length,
209
+ messages,
210
+ };
211
+ }
212
+
213
+ async function runDiscordMemberInfoAction(
214
+ request: DiscordToolActionRequest,
215
+ deps: DiscordToolActionDependencies,
216
+ ): Promise<Record<string, unknown>> {
217
+ const activeClient = deps.requireDiscordClientReady();
218
+ const guildId = sanitizeDiscordId(request.guildId, 'guildId');
219
+ const userLookupRaw =
220
+ request.userId
221
+ || request.memberId
222
+ || request.user
223
+ || request.username;
224
+ const resolvedUser = await resolveGuildMemberIdFromLookup({
225
+ requireDiscordClientReady: deps.requireDiscordClientReady,
226
+ guildId,
227
+ rawUser: userLookupRaw || '',
228
+ });
229
+ const userId = sanitizeDiscordId(resolvedUser.userId, 'userId');
230
+
231
+ const guild = await activeClient.guilds.fetch(guildId);
232
+ const member = await guild.members.fetch(userId);
233
+ const presence = deps.getDiscordPresence(userId);
234
+
235
+ const roles = member.roles.cache
236
+ .filter((role) => role.id !== guild.id)
237
+ .map((role) => ({
238
+ id: role.id,
239
+ name: role.name,
240
+ color: role.hexColor,
241
+ position: role.position,
242
+ }))
243
+ .sort((a, b) => b.position - a.position || a.name.localeCompare(b.name));
244
+
245
+ return {
246
+ ok: true,
247
+ action: 'member-info',
248
+ guildId,
249
+ userId,
250
+ ...(resolvedUser.note ? { note: resolvedUser.note } : {}),
251
+ member: {
252
+ id: member.id,
253
+ username: member.user.username,
254
+ handle: member.user.username ? `@${member.user.username}` : null,
255
+ globalName: member.user.globalName || null,
256
+ bot: Boolean(member.user.bot),
257
+ displayName: member.displayName,
258
+ nickname: member.nickname || null,
259
+ joinedAt: normalizeDate(member.joinedAt),
260
+ premiumSince: normalizeDate(member.premiumSince),
261
+ communicationDisabledUntil: normalizeDate(member.communicationDisabledUntil),
262
+ roles,
263
+ },
264
+ ...(presence
265
+ ? {
266
+ status: presence.status,
267
+ activities: presence.activities,
268
+ }
269
+ : {}),
270
+ };
271
+ }
272
+
273
+ async function runDiscordChannelInfoAction(
274
+ request: DiscordToolActionRequest,
275
+ deps: DiscordToolActionDependencies,
276
+ ): Promise<Record<string, unknown>> {
277
+ const activeClient = deps.requireDiscordClientReady();
278
+ const channelId = sanitizeDiscordId(request.channelId, 'channelId');
279
+ const channel = await activeClient.channels.fetch(channelId);
280
+ if (!channel) {
281
+ throw new Error('Channel not found.');
282
+ }
283
+
284
+ const channelData: Record<string, unknown> = {
285
+ id: channel.id,
286
+ type: channel.type,
287
+ guildId: 'guildId' in channel ? channel.guildId || null : null,
288
+ name: 'name' in channel && typeof channel.name === 'string' ? channel.name : null,
289
+ parentId: 'parentId' in channel ? channel.parentId || null : null,
290
+ topic: 'topic' in channel && typeof channel.topic === 'string' ? channel.topic : null,
291
+ nsfw: 'nsfw' in channel && typeof channel.nsfw === 'boolean' ? channel.nsfw : null,
292
+ rateLimitPerUser:
293
+ 'rateLimitPerUser' in channel && typeof channel.rateLimitPerUser === 'number'
294
+ ? channel.rateLimitPerUser
295
+ : null,
296
+ isTextBased: typeof channel.isTextBased === 'function' ? channel.isTextBased() : false,
297
+ isDMBased: typeof channel.isDMBased === 'function' ? channel.isDMBased() : false,
298
+ isThread: typeof channel.isThread === 'function' ? channel.isThread() : false,
299
+ lastMessageId: 'lastMessageId' in channel ? channel.lastMessageId || null : null,
300
+ };
301
+
302
+ if (typeof channel.isThread === 'function' && channel.isThread()) {
303
+ channelData.archived =
304
+ 'archived' in channel && typeof channel.archived === 'boolean' ? channel.archived : null;
305
+ channelData.locked =
306
+ 'locked' in channel && typeof channel.locked === 'boolean' ? channel.locked : null;
307
+ channelData.ownerId = 'ownerId' in channel ? channel.ownerId || null : null;
308
+ }
309
+
310
+ return {
311
+ ok: true,
312
+ action: 'channel-info',
313
+ channel: channelData,
314
+ };
315
+ }
316
+
317
+ export function createDiscordToolActionRunner(deps: DiscordToolActionDependencies): (
318
+ request: DiscordToolActionRequest,
319
+ ) => Promise<Record<string, unknown>> {
320
+ return async (request: DiscordToolActionRequest) => {
321
+ switch (request.action) {
322
+ case 'read':
323
+ return await runDiscordReadAction(request, deps);
324
+ case 'member-info':
325
+ return await runDiscordMemberInfoAction(request, deps);
326
+ case 'channel-info':
327
+ return await runDiscordChannelInfoAction(request, deps);
328
+ default:
329
+ throw new Error(`Unsupported Discord action: ${request.action as string}`);
330
+ }
331
+ };
332
+ }
@@ -3,6 +3,7 @@
3
3
  * Containers stay alive between requests and exit after an idle timeout.
4
4
  */
5
5
  import { ChildProcess, spawn } from 'child_process';
6
+ import fs from 'fs';
6
7
  import path from 'path';
7
8
 
8
9
  import {
@@ -11,6 +12,7 @@ import {
11
12
  CONTAINER_IMAGE,
12
13
  CONTAINER_MEMORY,
13
14
  CONTAINER_TIMEOUT,
15
+ DATA_DIR,
14
16
  GATEWAY_API_TOKEN,
15
17
  GATEWAY_BASE_URL,
16
18
  HYBRIDAI_BASE_URL,
@@ -26,7 +28,16 @@ import {
26
28
  import { cleanupIpc, ensureAgentDirs, ensureSessionDirs, getSessionPaths, readOutput, writeInput } from './ipc.js';
27
29
  import { logger } from './logger.js';
28
30
  import { validateAdditionalMounts } from './mount-security.js';
29
- import type { AdditionalMount, ArtifactMetadata, ChatMessage, ContainerInput, ContainerOutput, ScheduledTask, ToolProgressEvent } from './types.js';
31
+ import type {
32
+ AdditionalMount,
33
+ ArtifactMetadata,
34
+ ChatMessage,
35
+ ContainerInput,
36
+ ContainerOutput,
37
+ MediaContextItem,
38
+ ScheduledTask,
39
+ ToolProgressEvent,
40
+ } from './types.js';
30
41
 
31
42
  const IDLE_TIMEOUT_MS = 300_000; // 5 minutes — matches container-side default
32
43
 
@@ -45,6 +56,11 @@ const TOOL_RESULT_RE = /^\[tool\]\s+([a-zA-Z0-9_.-]+)\s+result\s+\((\d+)ms\):\s*
45
56
  const TOOL_START_RE = /^\[tool\]\s+([a-zA-Z0-9_.-]+):\s*(.*)$/;
46
57
  const STREAM_DELTA_RE = /^\[stream\]\s+([A-Za-z0-9+/=]+)$/;
47
58
  const CONTAINER_WORKSPACE_ROOT = '/workspace';
59
+ const CONTAINER_DISCORD_MEDIA_CACHE_ROOT = '/discord-media-cache';
60
+
61
+ function resolveDiscordMediaCacheHostDir(): string {
62
+ return path.resolve(path.join(DATA_DIR, 'discord-media-cache'));
63
+ }
48
64
 
49
65
  function emitTextDelta(entry: PoolEntry, line: string): void {
50
66
  const callback = entry.onTextDelta;
@@ -184,6 +200,8 @@ function getOrSpawnContainer(sessionId: string, agentId: string): PoolEntry {
184
200
  ensureSessionDirs(sessionId);
185
201
  ensureAgentDirs(agentId);
186
202
  const { ipcPath, workspacePath } = getSessionPaths(sessionId, agentId);
203
+ const mediaCacheHostPath = resolveDiscordMediaCacheHostDir();
204
+ fs.mkdirSync(mediaCacheHostPath, { recursive: true });
187
205
  const containerName = `hybridclaw-${sessionId.replace(/[^a-zA-Z0-9-]/g, '-')}-${Date.now()}`;
188
206
 
189
207
  const args = [
@@ -197,6 +215,7 @@ function getOrSpawnContainer(sessionId: string, agentId: string): PoolEntry {
197
215
  '--tmpfs', '/tmp',
198
216
  '-v', `${workspacePath}:/workspace:rw`,
199
217
  '-v', `${ipcPath}:/ipc:rw`,
218
+ '-v', `${mediaCacheHostPath}:${CONTAINER_DISCORD_MEDIA_CACHE_ROOT}:ro`,
200
219
  '-e', `HYBRIDAI_BASE_URL=${HYBRIDAI_BASE_URL}`,
201
220
  '-e', `HYBRIDAI_MODEL=${HYBRIDAI_MODEL}`,
202
221
  '-e', `CONTAINER_IDLE_TIMEOUT=${IDLE_TIMEOUT_MS}`,
@@ -292,9 +311,11 @@ export async function runContainer(
292
311
  channelId: string = '',
293
312
  scheduledTasks?: ScheduledTask[],
294
313
  allowedTools?: string[],
314
+ blockedTools?: string[],
295
315
  onTextDelta?: (delta: string) => void,
296
316
  onToolProgress?: (event: ToolProgressEvent) => void,
297
317
  abortSignal?: AbortSignal,
318
+ media?: MediaContextItem[],
298
319
  ): Promise<ContainerOutput> {
299
320
  const { workspacePath } = getSessionPaths(sessionId, agentId);
300
321
  // Enforce concurrent container limit
@@ -349,6 +370,8 @@ export async function runContainer(
349
370
  createdAt: t.created_at,
350
371
  })),
351
372
  allowedTools,
373
+ blockedTools,
374
+ media,
352
375
  };
353
376
 
354
377
  entry.onTextDelta = onTextDelta;
@@ -59,6 +59,7 @@ import type {
59
59
  ChatMessage,
60
60
  DelegationSideEffect,
61
61
  DelegationTaskSpec,
62
+ MediaContextItem,
62
63
  ScheduledTask,
63
64
  StructuredAuditEntry,
64
65
  StoredMessage,
@@ -95,6 +96,8 @@ const BASE_SUBAGENT_ALLOWED_TOOLS = [
95
96
  'browser_screenshot',
96
97
  'browser_pdf',
97
98
  'browser_vision',
99
+ 'vision_analyze',
100
+ 'image',
98
101
  'browser_get_images',
99
102
  'browser_console',
100
103
  'browser_network',
@@ -104,6 +107,10 @@ const ORCHESTRATOR_SUBAGENT_ALLOWED_TOOLS = [...BASE_SUBAGENT_ALLOWED_TOOLS, 'de
104
107
  const MAX_DELEGATION_TASKS = 6;
105
108
  const MAX_DELEGATION_USER_CHARS = 500;
106
109
  const MAX_RALPH_ITERATIONS = 64;
110
+ const IMAGE_QUESTION_RE =
111
+ /(what(?:'s| is)? on (?:the )?(?:image|picture|photo|screenshot)|describe (?:this|the) (?:image|picture|photo)|image|picture|photo|screenshot|ocr|diagram|chart|grafik|bild|foto|was steht|was ist auf dem bild)/i;
112
+ const BROWSER_TAB_RE =
113
+ /(browser|tab|current tab|web page|website|seite im browser|aktuellen tab)/i;
107
114
  const TRANSIENT_DELEGATION_ERROR_PATTERNS: RegExp[] = [
108
115
  /econnreset/i,
109
116
  /etimedout/i,
@@ -177,6 +184,7 @@ export interface GatewayChatRequest {
177
184
  userId: GatewayChatRequestBody['userId'];
178
185
  username: GatewayChatRequestBody['username'];
179
186
  content: GatewayChatRequestBody['content'];
187
+ media?: GatewayChatRequestBody['media'];
180
188
  chatbotId?: GatewayChatRequestBody['chatbotId'];
181
189
  model?: GatewayChatRequestBody['model'];
182
190
  enableRag?: GatewayChatRequestBody['enableRag'];
@@ -213,6 +221,102 @@ function parseIntOrNull(raw: string | undefined): number | null {
213
221
  return Number.isNaN(parsed) ? null : parsed;
214
222
  }
215
223
 
224
+ function normalizeMediaContextItems(raw: GatewayChatRequestBody['media']): MediaContextItem[] {
225
+ if (!Array.isArray(raw) || raw.length === 0) return [];
226
+ const normalized: MediaContextItem[] = [];
227
+ for (const item of raw) {
228
+ if (!item || typeof item !== 'object') continue;
229
+ const path = typeof item.path === 'string' && item.path.trim() ? item.path.trim() : null;
230
+ const url = typeof item.url === 'string' ? item.url.trim() : '';
231
+ const originalUrl = typeof item.originalUrl === 'string' ? item.originalUrl.trim() : '';
232
+ const filename = typeof item.filename === 'string' ? item.filename.trim() : '';
233
+ if (!url || !originalUrl || !filename) continue;
234
+ const sizeBytes = typeof item.sizeBytes === 'number' && Number.isFinite(item.sizeBytes)
235
+ ? Math.max(0, Math.floor(item.sizeBytes))
236
+ : 0;
237
+ const mimeType = typeof item.mimeType === 'string' && item.mimeType.trim()
238
+ ? item.mimeType.trim().toLowerCase()
239
+ : null;
240
+ normalized.push({
241
+ path,
242
+ url,
243
+ originalUrl,
244
+ mimeType,
245
+ sizeBytes,
246
+ filename,
247
+ });
248
+ }
249
+ return normalized;
250
+ }
251
+
252
+ function buildMediaPromptContext(media: MediaContextItem[]): string {
253
+ if (media.length === 0) return '';
254
+ const mediaPaths = media.map((item) => item.path).filter((path): path is string => Boolean(path));
255
+ const mediaUrls = media.map((item) => item.url);
256
+ const mediaTypes = media.map((item) => item.mimeType || 'unknown');
257
+ const payload = media.map((item, index) => ({
258
+ order: index + 1,
259
+ path: item.path,
260
+ mime: item.mimeType || 'unknown',
261
+ size: item.sizeBytes,
262
+ filename: item.filename,
263
+ original_url: item.originalUrl,
264
+ url: item.url,
265
+ }));
266
+ return [
267
+ '[MediaContext]',
268
+ `MediaPaths: ${JSON.stringify(mediaPaths)}`,
269
+ `MediaUrls: ${JSON.stringify(mediaUrls)}`,
270
+ `MediaTypes: ${JSON.stringify(mediaTypes)}`,
271
+ `MediaItems: ${JSON.stringify(payload)}`,
272
+ 'When the user asks about these Discord files, use `vision_analyze` with `image_url` from MediaPaths first.',
273
+ 'Use MediaUrls as fallback when a local path is missing or fails to open.',
274
+ 'Use `browser_vision` only for questions about the active browser tab/page.',
275
+ '',
276
+ '',
277
+ ].join('\n');
278
+ }
279
+
280
+ function isImageQuestion(content: string): boolean {
281
+ const normalized = content.trim();
282
+ if (!normalized) return false;
283
+ return IMAGE_QUESTION_RE.test(normalized);
284
+ }
285
+
286
+ function isExplicitBrowserTabQuestion(content: string): boolean {
287
+ const normalized = content.trim();
288
+ if (!normalized) return false;
289
+ return BROWSER_TAB_RE.test(normalized);
290
+ }
291
+
292
+ export interface MediaToolPolicy {
293
+ blockedTools?: string[];
294
+ prioritizeVisionTool: boolean;
295
+ }
296
+
297
+ export function resolveMediaToolPolicy(content: string, media: MediaContextItem[]): MediaToolPolicy {
298
+ if (media.length === 0) {
299
+ return {
300
+ blockedTools: undefined,
301
+ prioritizeVisionTool: false,
302
+ };
303
+ }
304
+
305
+ const imageQuestion = isImageQuestion(content);
306
+ const explicitBrowserTab = isExplicitBrowserTabQuestion(content);
307
+ if (imageQuestion && !explicitBrowserTab) {
308
+ return {
309
+ blockedTools: ['browser_vision'],
310
+ prioritizeVisionTool: true,
311
+ };
312
+ }
313
+
314
+ return {
315
+ blockedTools: undefined,
316
+ prioritizeVisionTool: false,
317
+ };
318
+ }
319
+
216
320
  function resolveGitCommitShort(): string | null {
217
321
  if (cachedGitCommitShort !== undefined) return cachedGitCommitShort;
218
322
  try {
@@ -1098,6 +1202,7 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1098
1202
  const enableRag = req.enableRag ?? session.enable_rag === 1;
1099
1203
  const model = req.model ?? session.model ?? HYBRIDAI_MODEL;
1100
1204
  const turnIndex = session.message_count + 1;
1205
+ const media = normalizeMediaContextItems(req.media);
1101
1206
 
1102
1207
  recordAuditEvent({
1103
1208
  sessionId: req.sessionId,
@@ -1119,6 +1224,7 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1119
1224
  turnIndex,
1120
1225
  userInput: req.content,
1121
1226
  username: req.username,
1227
+ mediaCount: media.length,
1122
1228
  },
1123
1229
  });
1124
1230
 
@@ -1216,9 +1322,25 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1216
1322
  historyEstimatedTokens: estimateTokenCountFromMessages(messages.slice(historyStart)),
1217
1323
  },
1218
1324
  });
1325
+ const mediaPolicy = resolveMediaToolPolicy(req.content, media);
1326
+ if (mediaPolicy.prioritizeVisionTool) {
1327
+ logger.info(
1328
+ {
1329
+ sessionId: req.sessionId,
1330
+ mediaCount: media.length,
1331
+ blockedTools: mediaPolicy.blockedTools || [],
1332
+ },
1333
+ 'Routing Discord image question to vision_analyze tool',
1334
+ );
1335
+ }
1336
+ const mediaContextBlock = buildMediaPromptContext(media);
1337
+ const expandedUserContent = expandSkillInvocation(req.content, skills);
1338
+ const agentUserContent = mediaContextBlock
1339
+ ? `${expandedUserContent}\n\n${mediaContextBlock}`
1340
+ : expandedUserContent;
1219
1341
  messages.push({
1220
1342
  role: 'user',
1221
- content: expandSkillInvocation(req.content, skills),
1343
+ content: agentUserContent,
1222
1344
  });
1223
1345
 
1224
1346
  try {
@@ -1233,9 +1355,11 @@ export async function handleGatewayMessage(req: GatewayChatRequest): Promise<Gat
1233
1355
  req.channelId,
1234
1356
  scheduledTasks,
1235
1357
  undefined,
1358
+ mediaPolicy.blockedTools,
1236
1359
  req.onTextDelta,
1237
1360
  req.onToolProgress,
1238
1361
  req.abortSignal,
1362
+ media,
1239
1363
  );
1240
1364
  const toolExecutions = output.toolExecutions || [];
1241
1365
  emitToolExecutionAuditEvents({
@@ -55,6 +55,14 @@ export interface GatewayChatRequestBody {
55
55
  userId: string;
56
56
  username: string | null;
57
57
  content: string;
58
+ media?: Array<{
59
+ path: string | null;
60
+ url: string;
61
+ originalUrl: string;
62
+ mimeType: string | null;
63
+ sizeBytes: number;
64
+ filename: string;
65
+ }>;
58
66
  chatbotId?: string | null;
59
67
  model?: string | null;
60
68
  enableRag?: boolean;
package/src/gateway.ts CHANGED
@@ -25,20 +25,18 @@ import {
25
25
  renderGatewayCommand,
26
26
  runGatewayScheduledTask,
27
27
  } from './gateway-service.js';
28
+ import { buildResponseText, formatError, formatInfo } from './channels/discord/delivery.js';
29
+ import { rewriteUserMentionsForMessage } from './channels/discord/mentions.js';
28
30
  import { startHealthServer } from './health.js';
29
31
  import { startHeartbeat, stopHeartbeat } from './heartbeat.js';
30
32
  import { logger } from './logger.js';
31
33
  import { startObservabilityIngest, stopObservabilityIngest } from './observability-ingest.js';
32
34
  import { startScheduler, stopScheduler } from './scheduler.js';
33
35
  import {
34
- buildResponseText,
35
- formatError,
36
- formatInfo,
37
36
  initDiscord,
38
- rewriteUserMentionsForMessage,
39
37
  sendToChannel,
40
38
  type ReplyFn,
41
- } from './discord.js';
39
+ } from './channels/discord/runtime.js';
42
40
  import { isWithinActiveHours, proactiveWindowLabel } from './proactive-policy.js';
43
41
  import type { ArtifactMetadata } from './types.js';
44
42
 
@@ -199,6 +197,7 @@ async function startDiscordIntegration(): Promise<void> {
199
197
  userId: string,
200
198
  username: string,
201
199
  content: string,
200
+ media,
202
201
  _reply: ReplyFn,
203
202
  context,
204
203
  ) => {
@@ -210,6 +209,7 @@ async function startDiscordIntegration(): Promise<void> {
210
209
  userId,
211
210
  username,
212
211
  content,
212
+ media,
213
213
  onTextDelta: (delta) => {
214
214
  void context.stream.append(delta);
215
215
  },
package/src/health.ts CHANGED
@@ -12,9 +12,10 @@ import {
12
12
  type GatewayChatRequest,
13
13
  } from './gateway-service.js';
14
14
  import { type GatewayChatRequestBody } from './gateway-types.js';
15
+ import { type DiscordToolActionRequest } from './channels/discord/tool-actions.js';
16
+ import { runDiscordToolAction } from './channels/discord/runtime.js';
15
17
  import { type ToolProgressEvent } from './types.js';
16
18
  import { logger } from './logger.js';
17
- import { runDiscordToolAction, type DiscordToolActionRequest } from './discord.js';
18
19
 
19
20
  const SITE_DIR = path.resolve(process.cwd(), 'docs');
20
21
  const MAX_REQUEST_BYTES = 1_000_000; // 1MB
package/src/heartbeat.ts CHANGED
@@ -41,6 +41,8 @@ const HEARTBEAT_ALLOWED_TOOLS = [
41
41
  'browser_screenshot',
42
42
  'browser_pdf',
43
43
  'browser_vision',
44
+ 'vision_analyze',
45
+ 'image',
44
46
  'browser_get_images',
45
47
  'browser_console',
46
48
  'browser_network',