@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
package/src/token-efficiency.ts
CHANGED
|
@@ -73,6 +73,22 @@ export function estimateTokenCountFromText(text: string | null | undefined): num
|
|
|
73
73
|
return Math.max(1, Math.ceil(normalized.length / DEFAULT_CHARS_PER_TOKEN));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function normalizeMessageContentToText(content: ChatMessage['content']): string {
|
|
77
|
+
if (typeof content === 'string') return content;
|
|
78
|
+
if (!Array.isArray(content)) return '';
|
|
79
|
+
const chunks: string[] = [];
|
|
80
|
+
for (const part of content) {
|
|
81
|
+
if (part?.type === 'text' && typeof part.text === 'string') {
|
|
82
|
+
chunks.push(part.text);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (part?.type === 'image_url' && part.image_url?.url) {
|
|
86
|
+
chunks.push('[image]');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return chunks.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
export function estimateTokenCountFromMessages(
|
|
77
93
|
messages: Array<Pick<ChatMessage, 'role' | 'content'>>,
|
|
78
94
|
): number {
|
|
@@ -82,7 +98,7 @@ export function estimateTokenCountFromMessages(
|
|
|
82
98
|
for (const message of messages) {
|
|
83
99
|
total += 4; // Approximate per-message framing overhead.
|
|
84
100
|
total += estimateTokenCountFromText(message.role);
|
|
85
|
-
total += estimateTokenCountFromText(message.content);
|
|
101
|
+
total += estimateTokenCountFromText(normalizeMessageContentToText(message.content));
|
|
86
102
|
}
|
|
87
103
|
return total;
|
|
88
104
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
// --- HybridAI API types ---
|
|
2
2
|
|
|
3
|
+
export interface ChatContentTextPart {
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ChatContentImageUrlPart {
|
|
9
|
+
type: 'image_url';
|
|
10
|
+
image_url: {
|
|
11
|
+
url: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ChatContentPart = ChatContentTextPart | ChatContentImageUrlPart;
|
|
16
|
+
export type ChatMessageContent = string | ChatContentPart[] | null;
|
|
17
|
+
|
|
3
18
|
export interface ChatMessage {
|
|
4
19
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
5
|
-
content:
|
|
20
|
+
content: ChatMessageContent;
|
|
6
21
|
tool_calls?: ToolCall[];
|
|
7
22
|
tool_call_id?: string;
|
|
8
23
|
}
|
|
@@ -24,6 +39,15 @@ export interface HybridAIBot {
|
|
|
24
39
|
|
|
25
40
|
// --- Container IPC types ---
|
|
26
41
|
|
|
42
|
+
export interface MediaContextItem {
|
|
43
|
+
path: string | null;
|
|
44
|
+
url: string;
|
|
45
|
+
originalUrl: string;
|
|
46
|
+
mimeType: string | null;
|
|
47
|
+
sizeBytes: number;
|
|
48
|
+
filename: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
export interface ContainerInput {
|
|
28
52
|
sessionId: string;
|
|
29
53
|
messages: ChatMessage[];
|
|
@@ -37,6 +61,8 @@ export interface ContainerInput {
|
|
|
37
61
|
channelId: string;
|
|
38
62
|
scheduledTasks?: { id: number; cronExpr: string; runAt: string | null; everyMs: number | null; prompt: string; enabled: number; lastRun: string | null; createdAt: string }[];
|
|
39
63
|
allowedTools?: string[];
|
|
64
|
+
blockedTools?: string[];
|
|
65
|
+
media?: MediaContextItem[];
|
|
40
66
|
}
|
|
41
67
|
|
|
42
68
|
export interface ToolExecution {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildResponseText } from '../src/channels/discord/delivery.js';
|
|
4
|
+
import { rewriteUserMentions, type MentionLookup } from '../src/channels/discord/mentions.js';
|
|
5
|
+
|
|
6
|
+
function createLookup(entries: Record<string, string[]>): MentionLookup {
|
|
7
|
+
const byAlias = new Map<string, Set<string>>();
|
|
8
|
+
for (const [alias, ids] of Object.entries(entries)) {
|
|
9
|
+
byAlias.set(alias, new Set(ids));
|
|
10
|
+
}
|
|
11
|
+
return { byAlias };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('rewriteUserMentions rewrites a uniquely-resolved @alias', () => {
|
|
15
|
+
const lookup = createLookup({ alice: ['123456789012345678'] });
|
|
16
|
+
const output = rewriteUserMentions('Ping @alice please.', lookup);
|
|
17
|
+
expect(output).toBe('Ping <@123456789012345678> please.');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('rewriteUserMentions does not rewrite ambiguous aliases', () => {
|
|
21
|
+
const lookup = createLookup({ bob: ['111111111111111111', '222222222222222222'] });
|
|
22
|
+
const output = rewriteUserMentions('hi @bob', lookup);
|
|
23
|
+
expect(output).toBe('hi @bob');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('rewriteUserMentions keeps @everyone and @here untouched', () => {
|
|
27
|
+
const lookup = createLookup({
|
|
28
|
+
everyone: ['333333333333333333'],
|
|
29
|
+
here: ['444444444444444444'],
|
|
30
|
+
});
|
|
31
|
+
const output = rewriteUserMentions('notify @everyone and @here', lookup);
|
|
32
|
+
expect(output).toBe('notify @everyone and @here');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('buildResponseText appends tool footer when tools were used', () => {
|
|
36
|
+
const output = buildResponseText('Done.', ['vision_analyze', 'message']);
|
|
37
|
+
expect(output).toBe('Done.\n*Tools: vision_analyze, message*');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('buildResponseText leaves text unchanged when no tools were used', () => {
|
|
41
|
+
const output = buildResponseText('Done.');
|
|
42
|
+
expect(output).toBe('Done.');
|
|
43
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolveMediaToolPolicy } from '../src/gateway-service.js';
|
|
4
|
+
import type { MediaContextItem } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
const SAMPLE_MEDIA: MediaContextItem[] = [
|
|
7
|
+
{
|
|
8
|
+
path: '/discord-media-cache/2026-03-04/sample.png',
|
|
9
|
+
url: 'https://cdn.discordapp.com/attachments/1/2/sample.png',
|
|
10
|
+
originalUrl: 'https://cdn.discordapp.com/attachments/1/2/sample.png',
|
|
11
|
+
mimeType: 'image/png',
|
|
12
|
+
sizeBytes: 1234,
|
|
13
|
+
filename: 'sample.png',
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
test('Discord image question blocks browser_vision and prioritizes vision_analyze', () => {
|
|
18
|
+
const policy = resolveMediaToolPolicy('Was steht auf dem Bild?', SAMPLE_MEDIA);
|
|
19
|
+
expect(policy.prioritizeVisionTool).toBe(true);
|
|
20
|
+
expect(policy.blockedTools).toEqual(['browser_vision']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('Explicit browser-tab question does not block browser_vision', () => {
|
|
24
|
+
const policy = resolveMediaToolPolicy('What is on the current browser tab?', SAMPLE_MEDIA);
|
|
25
|
+
expect(policy.prioritizeVisionTool).toBe(false);
|
|
26
|
+
expect(policy.blockedTools).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('No media context leaves tool policy unchanged', () => {
|
|
30
|
+
const policy = resolveMediaToolPolicy('Was steht auf dem Bild?', []);
|
|
31
|
+
expect(policy.prioritizeVisionTool).toBe(false);
|
|
32
|
+
expect(policy.blockedTools).toBeUndefined();
|
|
33
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { estimateTokenCountFromMessages, estimateTokenCountFromText } from '../src/token-efficiency.js';
|
|
4
|
+
import type { ChatMessage } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
test('estimateTokenCountFromText uses simple chars-per-token heuristic', () => {
|
|
7
|
+
expect(estimateTokenCountFromText('')).toBe(0);
|
|
8
|
+
expect(estimateTokenCountFromText('abcd')).toBe(1);
|
|
9
|
+
expect(estimateTokenCountFromText('abcde')).toBe(2);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('estimateTokenCountFromMessages supports multimodal content arrays', () => {
|
|
13
|
+
const messages: Array<Pick<ChatMessage, 'role' | 'content'>> = [
|
|
14
|
+
{
|
|
15
|
+
role: 'user',
|
|
16
|
+
content: [
|
|
17
|
+
{ type: 'text', text: 'hello' },
|
|
18
|
+
{ type: 'image_url', image_url: { url: '/discord-media-cache/example.png' } },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
const tokenCount = estimateTokenCountFromMessages(messages);
|
|
23
|
+
expect(tokenCount).toBe(11);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('estimateTokenCountFromMessages handles null message content', () => {
|
|
27
|
+
const messages: Array<Pick<ChatMessage, 'role' | 'content'>> = [
|
|
28
|
+
{ role: 'assistant', content: null },
|
|
29
|
+
];
|
|
30
|
+
const tokenCount = estimateTokenCountFromMessages(messages);
|
|
31
|
+
expect(tokenCount).toBe(9);
|
|
32
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import unitConfig from './vitest.unit.config.js';
|
|
3
|
+
|
|
4
|
+
const base = unitConfig as unknown as Record<string, unknown>;
|
|
5
|
+
const baseTest = (unitConfig as { test?: { exclude?: string[] } }).test ?? {};
|
|
6
|
+
const exclude = (baseTest.exclude ?? []).filter((entry) => entry !== 'tests/**/*.e2e.test.ts');
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
...base,
|
|
10
|
+
test: {
|
|
11
|
+
...baseTest,
|
|
12
|
+
include: ['tests/**/*.e2e.test.ts'],
|
|
13
|
+
exclude,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import unitConfig from './vitest.unit.config.js';
|
|
3
|
+
|
|
4
|
+
const base = unitConfig as unknown as Record<string, unknown>;
|
|
5
|
+
const baseTest = (unitConfig as { test?: { exclude?: string[] } }).test ?? {};
|
|
6
|
+
const exclude = (baseTest.exclude ?? []).filter((entry) => entry !== 'tests/**/*.integration.test.ts');
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
...base,
|
|
10
|
+
test: {
|
|
11
|
+
...baseTest,
|
|
12
|
+
include: ['tests/**/*.integration.test.ts'],
|
|
13
|
+
exclude,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import unitConfig from './vitest.unit.config.js';
|
|
3
|
+
|
|
4
|
+
const base = unitConfig as unknown as Record<string, unknown>;
|
|
5
|
+
const baseTest = (unitConfig as { test?: { exclude?: string[] } }).test ?? {};
|
|
6
|
+
const exclude = (baseTest.exclude ?? []).filter((entry) => entry !== 'tests/**/*.live.test.ts');
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
...base,
|
|
10
|
+
test: {
|
|
11
|
+
...baseTest,
|
|
12
|
+
include: ['tests/**/*.live.test.ts'],
|
|
13
|
+
exclude,
|
|
14
|
+
maxWorkers: 1,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
include: ['tests/**/*.test.ts'],
|
|
6
|
+
exclude: [
|
|
7
|
+
'tests/**/*.integration.test.ts',
|
|
8
|
+
'tests/**/*.e2e.test.ts',
|
|
9
|
+
'tests/**/*.live.test.ts',
|
|
10
|
+
'node_modules/**',
|
|
11
|
+
'dist/**',
|
|
12
|
+
'container/**',
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
});
|