@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.
- package/CHANGELOG.md +23 -0
- package/README.md +47 -15
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/browser-tools.ts +1 -1
- package/container/src/index.ts +243 -14
- package/container/src/token-usage.ts +18 -2
- package/container/src/tools.ts +339 -1
- package/container/src/types.ts +28 -2
- package/dist/agent.d.ts +2 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +2 -2
- package/dist/agent.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts +9 -0
- package/dist/channels/discord/attachments.d.ts.map +1 -0
- package/dist/channels/discord/attachments.js +245 -0
- package/dist/channels/discord/attachments.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +31 -0
- package/dist/channels/discord/delivery.d.ts.map +1 -0
- package/dist/channels/discord/delivery.js +60 -0
- package/dist/channels/discord/delivery.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +20 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -0
- package/dist/channels/discord/inbound.js +44 -0
- package/dist/channels/discord/inbound.js.map +1 -0
- package/dist/channels/discord/mentions.d.ts +14 -0
- package/dist/channels/discord/mentions.d.ts.map +1 -0
- package/dist/channels/discord/mentions.js +118 -0
- package/dist/channels/discord/mentions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +22 -0
- package/dist/channels/discord/runtime.d.ts.map +1 -0
- package/dist/channels/discord/runtime.js +972 -0
- package/dist/channels/discord/runtime.js.map +1 -0
- package/dist/channels/discord/stream.d.ts +32 -0
- package/dist/channels/discord/stream.d.ts.map +1 -0
- package/dist/channels/discord/stream.js +196 -0
- package/dist/channels/discord/stream.js.map +1 -0
- package/dist/channels/discord/tool-actions.d.ts +31 -0
- package/dist/channels/discord/tool-actions.d.ts.map +1 -0
- package/dist/channels/discord/tool-actions.js +268 -0
- package/dist/channels/discord/tool-actions.js.map +1 -0
- package/dist/container-runner.d.ts +2 -2
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +12 -2
- package/dist/container-runner.js.map +1 -1
- package/dist/discord.basic.test.d.ts +2 -0
- package/dist/discord.basic.test.d.ts.map +1 -0
- package/dist/discord.basic.test.js +38 -0
- package/dist/discord.basic.test.js.map +1 -0
- package/dist/discord.d.ts +5 -44
- package/dist/discord.d.ts.map +1 -1
- package/dist/discord.js +3 -1468
- package/dist/discord.js.map +1 -1
- package/dist/gateway-service.d.ts +7 -1
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +111 -2
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-service.media-routing.test.d.ts +2 -0
- package/dist/gateway-service.media-routing.test.d.ts.map +1 -0
- package/dist/gateway-service.media-routing.test.js +29 -0
- package/dist/gateway-service.media-routing.test.js.map +1 -0
- package/dist/gateway-types.d.ts +8 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +5 -2
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +1 -1
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +2 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/token-efficiency.basic.test.d.ts +2 -0
- package/dist/token-efficiency.basic.test.d.ts.map +1 -0
- package/dist/token-efficiency.basic.test.js +29 -0
- package/dist/token-efficiency.basic.test.js.map +1 -0
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js +18 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -2
- package/src/agent.ts +11 -1
- package/src/channels/discord/attachments.ts +282 -0
- package/src/channels/discord/delivery.ts +99 -0
- package/src/channels/discord/inbound.ts +72 -0
- package/src/channels/discord/mentions.ts +130 -0
- package/src/{discord.ts → channels/discord/runtime.ts} +77 -615
- package/src/{discord-stream.ts → channels/discord/stream.ts} +2 -2
- package/src/channels/discord/tool-actions.ts +332 -0
- package/src/container-runner.ts +24 -1
- package/src/gateway-service.ts +125 -1
- package/src/gateway-types.ts +8 -0
- package/src/gateway.ts +5 -5
- package/src/health.ts +2 -1
- package/src/heartbeat.ts +2 -0
- package/src/token-efficiency.ts +17 -1
- package/src/types.ts +27 -1
- package/tests/discord.basic.test.ts +43 -0
- package/tests/gateway-service.media-routing.test.ts +33 -0
- package/tests/token-efficiency.basic.test.ts +32 -0
- package/vitest.e2e.config.ts +15 -0
- package/vitest.integration.config.ts +15 -0
- package/vitest.live.config.ts +16 -0
- 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 '
|
|
7
|
-
import { logger } from '
|
|
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
|
+
}
|
package/src/container-runner.ts
CHANGED
|
@@ -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 {
|
|
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;
|
package/src/gateway-service.ts
CHANGED
|
@@ -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:
|
|
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({
|
package/src/gateway-types.ts
CHANGED
|
@@ -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
|