@hybridaione/hybridclaw 0.1.5
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/.env.example +14 -0
- package/.github/workflows/pages.yml +41 -0
- package/AGENTS.md +24 -0
- package/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/SECURITY.md +67 -0
- package/config.example.json +62 -0
- package/container/Dockerfile +23 -0
- package/container/package-lock.json +246 -0
- package/container/package.json +17 -0
- package/container/src/hybridai-client.ts +38 -0
- package/container/src/index.ts +198 -0
- package/container/src/ipc.ts +37 -0
- package/container/src/tools.ts +1008 -0
- package/container/src/types.ts +74 -0
- package/container/src/web-fetch.ts +389 -0
- package/container/tsconfig.json +16 -0
- package/dist/agent.d.ts +3 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +21 -0
- package/dist/agent.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +32 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +107 -0
- package/dist/config.js.map +1 -0
- package/dist/container-runner.d.ts +11 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +246 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/conversation.d.ts +18 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +29 -0
- package/dist/conversation.js.map +1 -0
- package/dist/db.d.ts +29 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +205 -0
- package/dist/db.js.map +1 -0
- package/dist/discord.d.ts +17 -0
- package/dist/discord.d.ts.map +1 -0
- package/dist/discord.js +115 -0
- package/dist/discord.js.map +1 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +36 -0
- package/dist/env.js.map +1 -0
- package/dist/gateway-client.d.ts +8 -0
- package/dist/gateway-client.d.ts.map +1 -0
- package/dist/gateway-client.js +57 -0
- package/dist/gateway-client.js.map +1 -0
- package/dist/gateway-service.d.ts +23 -0
- package/dist/gateway-service.d.ts.map +1 -0
- package/dist/gateway-service.js +360 -0
- package/dist/gateway-service.js.map +1 -0
- package/dist/gateway-types.d.ts +40 -0
- package/dist/gateway-types.d.ts.map +1 -0
- package/dist/gateway-types.js +6 -0
- package/dist/gateway-types.js.map +1 -0
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +138 -0
- package/dist/gateway.js.map +1 -0
- package/dist/hatch.d.ts +7 -0
- package/dist/hatch.d.ts.map +1 -0
- package/dist/hatch.js +99 -0
- package/dist/hatch.js.map +1 -0
- package/dist/health.d.ts +2 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +169 -0
- package/dist/health.js.map +1 -0
- package/dist/heartbeat.d.ts +3 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +103 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/hybridai-bots.d.ts +5 -0
- package/dist/hybridai-bots.d.ts.map +1 -0
- package/dist/hybridai-bots.js +34 -0
- package/dist/hybridai-bots.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc.d.ts +33 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +142 -0
- package/dist/ipc.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +21 -0
- package/dist/logger.js.map +1 -0
- package/dist/mount-security.d.ts +28 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +187 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/onboarding.d.ts +7 -0
- package/dist/onboarding.d.ts.map +1 -0
- package/dist/onboarding.js +445 -0
- package/dist/onboarding.js.map +1 -0
- package/dist/prompt-hooks.d.ts +17 -0
- package/dist/prompt-hooks.d.ts.map +1 -0
- package/dist/prompt-hooks.js +83 -0
- package/dist/prompt-hooks.js.map +1 -0
- package/dist/runtime-config.d.ts +78 -0
- package/dist/runtime-config.d.ts.map +1 -0
- package/dist/runtime-config.js +471 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/scheduled-task-runner.d.ts +11 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -0
- package/dist/scheduled-task-runner.js +16 -0
- package/dist/scheduled-task-runner.js.map +1 -0
- package/dist/scheduler.d.ts +11 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +165 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/session-maintenance.d.ts +9 -0
- package/dist/session-maintenance.d.ts.map +1 -0
- package/dist/session-maintenance.js +168 -0
- package/dist/session-maintenance.js.map +1 -0
- package/dist/session-transcripts.d.ts +11 -0
- package/dist/session-transcripts.d.ts.map +1 -0
- package/dist/session-transcripts.js +32 -0
- package/dist/session-transcripts.js.map +1 -0
- package/dist/side-effects.d.ts +3 -0
- package/dist/side-effects.d.ts.map +1 -0
- package/dist/side-effects.js +30 -0
- package/dist/side-effects.js.map +1 -0
- package/dist/skills.d.ts +32 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +376 -0
- package/dist/skills.js.map +1 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +305 -0
- package/dist/tui.js.map +1 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +154 -0
- package/dist/workspace.js.map +1 -0
- package/docs/chat.html +929 -0
- package/docs/hai_logo_free.png +0 -0
- package/docs/hero.png +0 -0
- package/docs/index.html +1213 -0
- package/package.json +34 -0
- package/skills/current-time/SKILL.md +26 -0
- package/skills/iss-position/SKILL.md +46 -0
- package/skills/iss-position/agents/openai.yaml +3 -0
- package/skills/iss-position/scripts/get_iss_position.py +107 -0
- package/skills/repo-orientation/SKILL.md +74 -0
- package/src/agent.ts +35 -0
- package/src/cli.ts +35 -0
- package/src/config.ts +137 -0
- package/src/container-runner.ts +305 -0
- package/src/conversation.ts +49 -0
- package/src/db.ts +290 -0
- package/src/discord.ts +156 -0
- package/src/env.ts +36 -0
- package/src/gateway-client.ts +73 -0
- package/src/gateway-service.ts +456 -0
- package/src/gateway-types.ts +47 -0
- package/src/gateway.ts +199 -0
- package/src/health.ts +189 -0
- package/src/heartbeat.ts +121 -0
- package/src/hybridai-bots.ts +48 -0
- package/src/ipc.ts +163 -0
- package/src/logger.ts +26 -0
- package/src/mount-security.ts +216 -0
- package/src/onboarding.ts +569 -0
- package/src/prompt-hooks.ts +113 -0
- package/src/runtime-config.ts +588 -0
- package/src/scheduled-task-runner.ts +26 -0
- package/src/scheduler.ts +196 -0
- package/src/session-maintenance.ts +263 -0
- package/src/session-transcripts.ts +45 -0
- package/src/side-effects.ts +41 -0
- package/src/skills.ts +429 -0
- package/src/tui.ts +327 -0
- package/src/types.ts +135 -0
- package/src/workspace.ts +171 -0
- package/templates/AGENTS.md +104 -0
- package/templates/BOOT.md +3 -0
- package/templates/BOOTSTRAP.md +54 -0
- package/templates/HEARTBEAT.md +5 -0
- package/templates/IDENTITY.md +18 -0
- package/templates/MEMORY.md +19 -0
- package/templates/SOUL.md +36 -0
- package/templates/TOOLS.md +22 -0
- package/templates/USER.md +17 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
APP_VERSION,
|
|
5
|
+
HYBRIDAI_CHATBOT_ID,
|
|
6
|
+
HYBRIDAI_ENABLE_RAG,
|
|
7
|
+
HYBRIDAI_MODEL,
|
|
8
|
+
HYBRIDAI_MODELS,
|
|
9
|
+
} from './config.js';
|
|
10
|
+
import { runAgent } from './agent.js';
|
|
11
|
+
import { getActiveContainerCount } from './container-runner.js';
|
|
12
|
+
import {
|
|
13
|
+
clearSessionHistory,
|
|
14
|
+
createTask,
|
|
15
|
+
deleteTask,
|
|
16
|
+
getAllSessions,
|
|
17
|
+
getConversationHistory,
|
|
18
|
+
getOrCreateSession,
|
|
19
|
+
getRecentAudit,
|
|
20
|
+
getSessionCount,
|
|
21
|
+
getTasksForSession,
|
|
22
|
+
logAudit,
|
|
23
|
+
storeMessage,
|
|
24
|
+
toggleTask,
|
|
25
|
+
updateSessionChatbot,
|
|
26
|
+
updateSessionModel,
|
|
27
|
+
updateSessionRag,
|
|
28
|
+
} from './db.js';
|
|
29
|
+
import { fetchHybridAIBots } from './hybridai-bots.js';
|
|
30
|
+
import { logger } from './logger.js';
|
|
31
|
+
import { rearmScheduler } from './scheduler.js';
|
|
32
|
+
import { maybeCompactSession } from './session-maintenance.js';
|
|
33
|
+
import { appendSessionTranscript } from './session-transcripts.js';
|
|
34
|
+
import { processSideEffects } from './side-effects.js';
|
|
35
|
+
import { expandSkillInvocation } from './skills.js';
|
|
36
|
+
import {
|
|
37
|
+
renderGatewayCommand,
|
|
38
|
+
type GatewayChatRequestBody,
|
|
39
|
+
type GatewayChatResult,
|
|
40
|
+
type GatewayCommandRequest,
|
|
41
|
+
type GatewayCommandResult,
|
|
42
|
+
type GatewayStatus,
|
|
43
|
+
} from './gateway-types.js';
|
|
44
|
+
import type { ScheduledTask, StoredMessage, ToolProgressEvent } from './types.js';
|
|
45
|
+
import { ensureBootstrapFiles } from './workspace.js';
|
|
46
|
+
import { buildConversationContext } from './conversation.js';
|
|
47
|
+
import { runIsolatedScheduledTask } from './scheduled-task-runner.js';
|
|
48
|
+
|
|
49
|
+
const BOT_CACHE_TTL = 300_000; // 5 minutes
|
|
50
|
+
const MAX_HISTORY_MESSAGES = 40;
|
|
51
|
+
|
|
52
|
+
export interface GatewayChatRequest {
|
|
53
|
+
sessionId: GatewayChatRequestBody['sessionId'];
|
|
54
|
+
guildId: GatewayChatRequestBody['guildId'];
|
|
55
|
+
channelId: GatewayChatRequestBody['channelId'];
|
|
56
|
+
userId: GatewayChatRequestBody['userId'];
|
|
57
|
+
username: GatewayChatRequestBody['username'];
|
|
58
|
+
content: GatewayChatRequestBody['content'];
|
|
59
|
+
chatbotId?: GatewayChatRequestBody['chatbotId'];
|
|
60
|
+
model?: GatewayChatRequestBody['model'];
|
|
61
|
+
enableRag?: GatewayChatRequestBody['enableRag'];
|
|
62
|
+
onToolProgress?: (event: ToolProgressEvent) => void;
|
|
63
|
+
abortSignal?: AbortSignal;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type { GatewayChatResult, GatewayCommandRequest, GatewayCommandResult, GatewayStatus };
|
|
67
|
+
export { renderGatewayCommand };
|
|
68
|
+
|
|
69
|
+
function formatUptime(seconds: number): string {
|
|
70
|
+
const d = Math.floor(seconds / 86400);
|
|
71
|
+
const h = Math.floor((seconds % 86400) / 3600);
|
|
72
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
73
|
+
const s = seconds % 60;
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
if (d > 0) parts.push(`${d}d`);
|
|
76
|
+
if (h > 0) parts.push(`${h}h`);
|
|
77
|
+
if (m > 0) parts.push(`${m}m`);
|
|
78
|
+
parts.push(`${s}s`);
|
|
79
|
+
return parts.join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseIntOrNull(raw: string | undefined): number | null {
|
|
83
|
+
if (!raw) return null;
|
|
84
|
+
const parsed = parseInt(raw, 10);
|
|
85
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function badCommand(title: string, text: string): GatewayCommandResult {
|
|
89
|
+
return { kind: 'error', title, text };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function infoCommand(title: string, text: string): GatewayCommandResult {
|
|
93
|
+
return { kind: 'info', title, text };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function plainCommand(text: string): GatewayCommandResult {
|
|
97
|
+
return { kind: 'plain', text };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getGatewayStatus(): GatewayStatus {
|
|
101
|
+
return {
|
|
102
|
+
status: 'ok',
|
|
103
|
+
version: APP_VERSION,
|
|
104
|
+
uptime: Math.floor(process.uptime()),
|
|
105
|
+
sessions: getSessionCount(),
|
|
106
|
+
activeContainers: getActiveContainerCount(),
|
|
107
|
+
defaultModel: HYBRIDAI_MODEL,
|
|
108
|
+
ragDefault: HYBRIDAI_ENABLE_RAG,
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getGatewayHistory(sessionId: string, limit = MAX_HISTORY_MESSAGES): StoredMessage[] {
|
|
114
|
+
return getConversationHistory(sessionId, Math.max(1, Math.min(limit, 200))).reverse();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function handleGatewayMessage(req: GatewayChatRequest): Promise<GatewayChatResult> {
|
|
118
|
+
const startedAt = Date.now();
|
|
119
|
+
const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
|
|
120
|
+
const chatbotId = req.chatbotId ?? session.chatbot_id ?? HYBRIDAI_CHATBOT_ID;
|
|
121
|
+
const enableRag = req.enableRag ?? session.enable_rag === 1;
|
|
122
|
+
const model = req.model ?? session.model ?? HYBRIDAI_MODEL;
|
|
123
|
+
|
|
124
|
+
if (!chatbotId) {
|
|
125
|
+
return {
|
|
126
|
+
status: 'error',
|
|
127
|
+
result: null,
|
|
128
|
+
toolsUsed: [],
|
|
129
|
+
error: 'No chatbot configured. Set `HYBRIDAI_CHATBOT_ID` or select a bot for this session.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const agentId = chatbotId || 'default';
|
|
134
|
+
ensureBootstrapFiles(agentId);
|
|
135
|
+
|
|
136
|
+
const history = getConversationHistory(req.sessionId, MAX_HISTORY_MESSAGES);
|
|
137
|
+
const { messages, skills } = buildConversationContext({
|
|
138
|
+
agentId,
|
|
139
|
+
sessionSummary: session.session_summary,
|
|
140
|
+
history,
|
|
141
|
+
});
|
|
142
|
+
messages.push({
|
|
143
|
+
role: 'user',
|
|
144
|
+
content: expandSkillInvocation(req.content, skills),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const scheduledTasks: ScheduledTask[] = getTasksForSession(req.sessionId);
|
|
149
|
+
const output = await runAgent(
|
|
150
|
+
req.sessionId,
|
|
151
|
+
messages,
|
|
152
|
+
chatbotId,
|
|
153
|
+
enableRag,
|
|
154
|
+
model,
|
|
155
|
+
agentId,
|
|
156
|
+
req.channelId,
|
|
157
|
+
scheduledTasks,
|
|
158
|
+
undefined,
|
|
159
|
+
req.onToolProgress,
|
|
160
|
+
req.abortSignal,
|
|
161
|
+
);
|
|
162
|
+
processSideEffects(output, req.sessionId, req.channelId);
|
|
163
|
+
|
|
164
|
+
if (output.status === 'error') {
|
|
165
|
+
return {
|
|
166
|
+
status: 'error',
|
|
167
|
+
result: null,
|
|
168
|
+
toolsUsed: output.toolsUsed || [],
|
|
169
|
+
error: output.error || 'Unknown agent error.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const resultText = output.result || 'No response from agent.';
|
|
174
|
+
storeMessage(req.sessionId, req.userId, req.username, 'user', req.content);
|
|
175
|
+
storeMessage(req.sessionId, 'assistant', null, 'assistant', resultText);
|
|
176
|
+
appendSessionTranscript(agentId, {
|
|
177
|
+
sessionId: req.sessionId,
|
|
178
|
+
channelId: req.channelId,
|
|
179
|
+
role: 'user',
|
|
180
|
+
userId: req.userId,
|
|
181
|
+
username: req.username,
|
|
182
|
+
content: req.content,
|
|
183
|
+
});
|
|
184
|
+
appendSessionTranscript(agentId, {
|
|
185
|
+
sessionId: req.sessionId,
|
|
186
|
+
channelId: req.channelId,
|
|
187
|
+
role: 'assistant',
|
|
188
|
+
userId: 'assistant',
|
|
189
|
+
username: null,
|
|
190
|
+
content: resultText,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
void maybeCompactSession({
|
|
194
|
+
sessionId: req.sessionId,
|
|
195
|
+
agentId,
|
|
196
|
+
chatbotId,
|
|
197
|
+
enableRag,
|
|
198
|
+
model,
|
|
199
|
+
channelId: req.channelId,
|
|
200
|
+
}).catch((err) => {
|
|
201
|
+
logger.warn({ sessionId: req.sessionId, err }, 'Background session compaction failed');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
status: 'success',
|
|
206
|
+
result: resultText,
|
|
207
|
+
toolsUsed: output.toolsUsed || [],
|
|
208
|
+
};
|
|
209
|
+
} catch (err) {
|
|
210
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
211
|
+
logAudit('error', req.sessionId, { error: errorMsg }, Date.now() - startedAt);
|
|
212
|
+
logger.error({ sessionId: req.sessionId, err }, 'Gateway message handling failed');
|
|
213
|
+
return {
|
|
214
|
+
status: 'error',
|
|
215
|
+
result: null,
|
|
216
|
+
toolsUsed: [],
|
|
217
|
+
error: errorMsg,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function runGatewayScheduledTask(
|
|
223
|
+
origSessionId: string,
|
|
224
|
+
channelId: string,
|
|
225
|
+
prompt: string,
|
|
226
|
+
taskId: number,
|
|
227
|
+
onResult: (result: string) => Promise<void>,
|
|
228
|
+
onError: (error: unknown) => void,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const session = getOrCreateSession(origSessionId, null, channelId);
|
|
231
|
+
const chatbotId = session.chatbot_id || HYBRIDAI_CHATBOT_ID;
|
|
232
|
+
const model = session.model || HYBRIDAI_MODEL;
|
|
233
|
+
const agentId = chatbotId || 'default';
|
|
234
|
+
if (!chatbotId) return;
|
|
235
|
+
|
|
236
|
+
await runIsolatedScheduledTask({
|
|
237
|
+
taskId,
|
|
238
|
+
prompt,
|
|
239
|
+
channelId,
|
|
240
|
+
chatbotId,
|
|
241
|
+
model,
|
|
242
|
+
agentId,
|
|
243
|
+
onResult,
|
|
244
|
+
onError,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function handleGatewayCommand(req: GatewayCommandRequest): Promise<GatewayCommandResult> {
|
|
249
|
+
const cmd = (req.args[0] || '').toLowerCase();
|
|
250
|
+
const session = getOrCreateSession(req.sessionId, req.guildId, req.channelId);
|
|
251
|
+
|
|
252
|
+
switch (cmd) {
|
|
253
|
+
case 'help': {
|
|
254
|
+
const help = [
|
|
255
|
+
'`bot list` — List available bots',
|
|
256
|
+
'`bot set <id|name>` — Set chatbot for this session',
|
|
257
|
+
'`bot info` — Show current chatbot settings',
|
|
258
|
+
'`model list` — List available models',
|
|
259
|
+
'`model set <name>` — Set model for this session',
|
|
260
|
+
'`model info` — Show current model',
|
|
261
|
+
'`rag [on|off]` — Toggle or set RAG mode',
|
|
262
|
+
'`clear` — Clear session history',
|
|
263
|
+
'`status` — Show runtime status',
|
|
264
|
+
'`sessions` — List active sessions',
|
|
265
|
+
'`audit [n]` — Show recent audit entries',
|
|
266
|
+
'`schedule add "<cron>" <prompt>` — Add scheduled task',
|
|
267
|
+
'`schedule list` — List scheduled tasks',
|
|
268
|
+
'`schedule remove <id>` — Remove a task',
|
|
269
|
+
'`schedule toggle <id>` — Enable/disable a task',
|
|
270
|
+
];
|
|
271
|
+
return infoCommand('HybridClaw Commands', help.join('\n'));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'bot': {
|
|
275
|
+
const sub = req.args[1]?.toLowerCase();
|
|
276
|
+
if (sub === 'list') {
|
|
277
|
+
try {
|
|
278
|
+
const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
|
|
279
|
+
if (bots.length === 0) return plainCommand('No bots available.');
|
|
280
|
+
const list = bots.map((b) =>
|
|
281
|
+
`• ${b.name} (${b.id})${b.description ? ` — ${b.description}` : ''}`
|
|
282
|
+
).join('\n');
|
|
283
|
+
return infoCommand('Available Bots', list);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return badCommand('Error', `Failed to fetch bots: ${err instanceof Error ? err.message : String(err)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (sub === 'set') {
|
|
290
|
+
const requested = req.args.slice(2).join(' ').trim();
|
|
291
|
+
if (!requested) return badCommand('Usage', 'Usage: `bot set <id|name>`');
|
|
292
|
+
let resolvedBotId = requested;
|
|
293
|
+
try {
|
|
294
|
+
const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
|
|
295
|
+
const matched = bots.find((b) =>
|
|
296
|
+
b.id === requested || b.name.toLowerCase() === requested.toLowerCase()
|
|
297
|
+
);
|
|
298
|
+
if (matched) resolvedBotId = matched.id;
|
|
299
|
+
} catch {
|
|
300
|
+
// keep user-supplied value when lookup fails
|
|
301
|
+
}
|
|
302
|
+
updateSessionChatbot(session.id, resolvedBotId);
|
|
303
|
+
return plainCommand(`Chatbot set to \`${resolvedBotId}\` for this session.`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (sub === 'info') {
|
|
307
|
+
const botId = session.chatbot_id || HYBRIDAI_CHATBOT_ID || 'Not set';
|
|
308
|
+
let botLabel = botId;
|
|
309
|
+
try {
|
|
310
|
+
const bots = await fetchHybridAIBots({ cacheTtlMs: BOT_CACHE_TTL });
|
|
311
|
+
const bot = bots.find((b) => b.id === botId);
|
|
312
|
+
if (bot) botLabel = `${bot.name} (${bot.id})`;
|
|
313
|
+
} catch {
|
|
314
|
+
// keep ID fallback
|
|
315
|
+
}
|
|
316
|
+
const model = session.model || HYBRIDAI_MODEL;
|
|
317
|
+
const ragStatus = session.enable_rag ? 'Enabled' : 'Disabled';
|
|
318
|
+
return infoCommand('Bot Info', `Chatbot: ${botLabel}\nModel: ${model}\nRAG: ${ragStatus}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return badCommand('Usage', 'Usage: `bot list|set <id|name>|info`');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case 'model': {
|
|
325
|
+
const sub = req.args[1]?.toLowerCase();
|
|
326
|
+
if (sub === 'list') {
|
|
327
|
+
const current = session.model || HYBRIDAI_MODEL;
|
|
328
|
+
const list = HYBRIDAI_MODELS.map((m) =>
|
|
329
|
+
m === current ? `${m} (current)` : m
|
|
330
|
+
).join('\n');
|
|
331
|
+
return infoCommand('Available Models', list);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (sub === 'set') {
|
|
335
|
+
const modelName = req.args[2];
|
|
336
|
+
if (!modelName) return badCommand('Usage', 'Usage: `model set <name>`');
|
|
337
|
+
if (HYBRIDAI_MODELS.length > 0 && !HYBRIDAI_MODELS.includes(modelName)) {
|
|
338
|
+
return badCommand('Unknown Model', `\`${modelName}\` is not in the available models list.`);
|
|
339
|
+
}
|
|
340
|
+
updateSessionModel(session.id, modelName);
|
|
341
|
+
return plainCommand(`Model set to \`${modelName}\` for this session.`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (sub === 'info') {
|
|
345
|
+
const current = session.model || HYBRIDAI_MODEL;
|
|
346
|
+
return infoCommand('Model Info', `Current model: ${current}\nDefault model: ${HYBRIDAI_MODEL}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return badCommand('Usage', 'Usage: `model list|set <name>|info`');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'rag': {
|
|
353
|
+
const sub = req.args[1]?.toLowerCase();
|
|
354
|
+
if (sub === 'on' || sub === 'off') {
|
|
355
|
+
updateSessionRag(session.id, sub === 'on');
|
|
356
|
+
return plainCommand(`RAG ${sub === 'on' ? 'enabled' : 'disabled'} for this session.`);
|
|
357
|
+
}
|
|
358
|
+
if (!sub) {
|
|
359
|
+
const nextEnabled = session.enable_rag === 0;
|
|
360
|
+
updateSessionRag(session.id, nextEnabled);
|
|
361
|
+
return plainCommand(`RAG ${nextEnabled ? 'enabled' : 'disabled'} for this session.`);
|
|
362
|
+
}
|
|
363
|
+
return badCommand('Usage', 'Usage: `rag [on|off]`');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
case 'clear': {
|
|
367
|
+
const deleted = clearSessionHistory(session.id);
|
|
368
|
+
return infoCommand('Session Cleared', `Deleted ${deleted} messages. Workspace files preserved.`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
case 'status': {
|
|
372
|
+
const status = getGatewayStatus();
|
|
373
|
+
const lines = [
|
|
374
|
+
`Uptime: ${formatUptime(status.uptime)}`,
|
|
375
|
+
`Sessions: ${status.sessions}`,
|
|
376
|
+
`Active Containers: ${status.activeContainers}`,
|
|
377
|
+
`Default Model: ${status.defaultModel}`,
|
|
378
|
+
`RAG Default: ${status.ragDefault ? 'On' : 'Off'}`,
|
|
379
|
+
];
|
|
380
|
+
return infoCommand('Status', lines.join('\n'));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
case 'sessions': {
|
|
384
|
+
const sessions = getAllSessions();
|
|
385
|
+
if (sessions.length === 0) return plainCommand('No active sessions.');
|
|
386
|
+
const list = sessions.slice(0, 20).map((s) =>
|
|
387
|
+
`${s.id} — ${s.message_count} msgs, last active ${s.last_active}`
|
|
388
|
+
).join('\n');
|
|
389
|
+
return infoCommand('Sessions', list);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
case 'audit': {
|
|
393
|
+
const parsedLimit = parseIntOrNull(req.args[1]);
|
|
394
|
+
const limit = Math.min(parsedLimit ?? 10, 25);
|
|
395
|
+
const entries = getRecentAudit(limit);
|
|
396
|
+
if (entries.length === 0) return plainCommand('No audit entries.');
|
|
397
|
+
const list = entries.map((entry) =>
|
|
398
|
+
`${entry.created_at} ${entry.event}${entry.duration_ms ? ` (${entry.duration_ms}ms)` : ''}`
|
|
399
|
+
).join('\n');
|
|
400
|
+
return infoCommand('Recent Audit', list);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case 'schedule': {
|
|
404
|
+
const sub = req.args[1]?.toLowerCase();
|
|
405
|
+
if (sub === 'add') {
|
|
406
|
+
const rest = req.args.slice(2).join(' ');
|
|
407
|
+
const cronMatch = rest.match(/^"([^"]+)"\s+(.+)$/);
|
|
408
|
+
if (!cronMatch) {
|
|
409
|
+
return badCommand('Usage', 'Usage: `schedule add "<cron>" <prompt>`');
|
|
410
|
+
}
|
|
411
|
+
const [, cronExpr, prompt] = cronMatch;
|
|
412
|
+
try {
|
|
413
|
+
CronExpressionParser.parse(cronExpr);
|
|
414
|
+
} catch {
|
|
415
|
+
return badCommand('Invalid Cron', `\`${cronExpr}\` is not a valid cron expression.`);
|
|
416
|
+
}
|
|
417
|
+
const taskId = createTask(session.id, req.channelId, cronExpr, prompt);
|
|
418
|
+
rearmScheduler();
|
|
419
|
+
return plainCommand(`Task #${taskId} created: \`${cronExpr}\` — ${prompt}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (sub === 'list') {
|
|
423
|
+
const tasks = getTasksForSession(session.id);
|
|
424
|
+
if (tasks.length === 0) return plainCommand('No scheduled tasks.');
|
|
425
|
+
const list = tasks.map((task) =>
|
|
426
|
+
`#${task.id} ${task.enabled ? 'enabled' : 'disabled'} \`${task.cron_expr}\` — ${task.prompt.slice(0, 60)}`
|
|
427
|
+
).join('\n');
|
|
428
|
+
return infoCommand('Scheduled Tasks', list);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (sub === 'remove') {
|
|
432
|
+
const taskId = parseIntOrNull(req.args[2]);
|
|
433
|
+
if (!taskId) return badCommand('Usage', 'Usage: `schedule remove <id>`');
|
|
434
|
+
deleteTask(taskId);
|
|
435
|
+
rearmScheduler();
|
|
436
|
+
return plainCommand(`Task #${taskId} removed.`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (sub === 'toggle') {
|
|
440
|
+
const taskId = parseIntOrNull(req.args[2]);
|
|
441
|
+
if (!taskId) return badCommand('Usage', 'Usage: `schedule toggle <id>`');
|
|
442
|
+
const tasks = getTasksForSession(session.id);
|
|
443
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
444
|
+
if (!task) return badCommand('Not Found', `Task #${taskId} was not found in this session.`);
|
|
445
|
+
toggleTask(taskId, !Boolean(task.enabled));
|
|
446
|
+
rearmScheduler();
|
|
447
|
+
return plainCommand(`Task #${taskId} ${task.enabled ? 'disabled' : 'enabled'}.`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return badCommand('Usage', 'Usage: `schedule add|list|remove|toggle`');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
default:
|
|
454
|
+
return badCommand('Unknown Command', `Unknown command: \`${cmd || '(empty)'}\`.`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface GatewayCommandResult {
|
|
2
|
+
kind: 'plain' | 'info' | 'error';
|
|
3
|
+
title?: string;
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface GatewayChatResult {
|
|
8
|
+
status: 'success' | 'error';
|
|
9
|
+
result: string | null;
|
|
10
|
+
toolsUsed: string[];
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GatewayChatRequestBody {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
guildId: string | null;
|
|
17
|
+
channelId: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
username: string | null;
|
|
20
|
+
content: string;
|
|
21
|
+
chatbotId?: string | null;
|
|
22
|
+
model?: string | null;
|
|
23
|
+
enableRag?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GatewayCommandRequest {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
guildId: string | null;
|
|
29
|
+
channelId: string;
|
|
30
|
+
args: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GatewayStatus {
|
|
34
|
+
status: 'ok';
|
|
35
|
+
version: string;
|
|
36
|
+
uptime: number;
|
|
37
|
+
sessions: number;
|
|
38
|
+
activeContainers: number;
|
|
39
|
+
defaultModel: string;
|
|
40
|
+
ragDefault: boolean;
|
|
41
|
+
timestamp: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderGatewayCommand(result: GatewayCommandResult): string {
|
|
45
|
+
if (!result.title) return result.text;
|
|
46
|
+
return `${result.title}\n${result.text}`;
|
|
47
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DISCORD_TOKEN,
|
|
3
|
+
HEARTBEAT_CHANNEL,
|
|
4
|
+
HEARTBEAT_INTERVAL,
|
|
5
|
+
HYBRIDAI_CHATBOT_ID,
|
|
6
|
+
onConfigChange,
|
|
7
|
+
} from './config.js';
|
|
8
|
+
import { stopAllContainers } from './container-runner.js';
|
|
9
|
+
import { initDatabase } from './db.js';
|
|
10
|
+
import {
|
|
11
|
+
getGatewayStatus,
|
|
12
|
+
handleGatewayCommand,
|
|
13
|
+
handleGatewayMessage,
|
|
14
|
+
renderGatewayCommand,
|
|
15
|
+
runGatewayScheduledTask,
|
|
16
|
+
} from './gateway-service.js';
|
|
17
|
+
import { startHealthServer } from './health.js';
|
|
18
|
+
import { startHeartbeat, stopHeartbeat } from './heartbeat.js';
|
|
19
|
+
import { logger } from './logger.js';
|
|
20
|
+
import { startScheduler, stopScheduler } from './scheduler.js';
|
|
21
|
+
import {
|
|
22
|
+
buildResponseText,
|
|
23
|
+
formatError,
|
|
24
|
+
formatInfo,
|
|
25
|
+
initDiscord,
|
|
26
|
+
sendToChannel,
|
|
27
|
+
type ReplyFn,
|
|
28
|
+
} from './discord.js';
|
|
29
|
+
|
|
30
|
+
let detachConfigListener: (() => void) | null = null;
|
|
31
|
+
|
|
32
|
+
function isDiscordChannelId(channelId: string): boolean {
|
|
33
|
+
return /^\d{16,22}$/.test(channelId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function deliverProactiveMessage(channelId: string, text: string, source: string): Promise<void> {
|
|
37
|
+
if (!DISCORD_TOKEN || !isDiscordChannelId(channelId)) {
|
|
38
|
+
logger.info({ source, channelId, text }, 'Proactive message (no Discord delivery)');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await sendToChannel(channelId, text);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
logger.warn({ source, channelId, error }, 'Failed to send proactive message to Discord channel');
|
|
46
|
+
logger.info({ source, channelId, text }, 'Proactive message fallback');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function startDiscordIntegration(): Promise<void> {
|
|
51
|
+
if (!DISCORD_TOKEN) {
|
|
52
|
+
logger.info('DISCORD_TOKEN not set; Discord integration disabled');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
initDiscord(
|
|
57
|
+
async (
|
|
58
|
+
sessionId: string,
|
|
59
|
+
guildId: string | null,
|
|
60
|
+
channelId: string,
|
|
61
|
+
userId: string,
|
|
62
|
+
username: string,
|
|
63
|
+
content: string,
|
|
64
|
+
reply: ReplyFn,
|
|
65
|
+
) => {
|
|
66
|
+
try {
|
|
67
|
+
const result = await handleGatewayMessage({
|
|
68
|
+
sessionId,
|
|
69
|
+
guildId,
|
|
70
|
+
channelId,
|
|
71
|
+
userId,
|
|
72
|
+
username,
|
|
73
|
+
content,
|
|
74
|
+
});
|
|
75
|
+
if (result.status === 'error') {
|
|
76
|
+
await reply(formatError('Agent Error', result.error || 'Unknown error'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await reply(buildResponseText(result.result || 'No response from agent.', result.toolsUsed));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
82
|
+
logger.error({ error, sessionId, channelId }, 'Discord message handling failed');
|
|
83
|
+
await reply(formatError('Gateway Error', text));
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async (
|
|
87
|
+
sessionId: string,
|
|
88
|
+
guildId: string | null,
|
|
89
|
+
channelId: string,
|
|
90
|
+
args: string[],
|
|
91
|
+
reply: ReplyFn,
|
|
92
|
+
) => {
|
|
93
|
+
try {
|
|
94
|
+
const result = await handleGatewayCommand({
|
|
95
|
+
sessionId,
|
|
96
|
+
guildId,
|
|
97
|
+
channelId,
|
|
98
|
+
args,
|
|
99
|
+
});
|
|
100
|
+
if (result.kind === 'error') {
|
|
101
|
+
await reply(formatError(result.title || 'Error', result.text));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (result.kind === 'info') {
|
|
105
|
+
await reply(formatInfo(result.title || 'Info', result.text));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await reply(renderGatewayCommand(result));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
111
|
+
logger.error({ error, sessionId, channelId, args }, 'Discord command handling failed');
|
|
112
|
+
await reply(formatError('Gateway Error', text));
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
logger.info('Discord integration started inside gateway');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function setupShutdown(): void {
|
|
120
|
+
const shutdown = () => {
|
|
121
|
+
logger.info('Shutting down gateway...');
|
|
122
|
+
if (detachConfigListener) {
|
|
123
|
+
detachConfigListener();
|
|
124
|
+
detachConfigListener = null;
|
|
125
|
+
}
|
|
126
|
+
stopHeartbeat();
|
|
127
|
+
stopAllContainers();
|
|
128
|
+
stopScheduler();
|
|
129
|
+
process.exit(0);
|
|
130
|
+
};
|
|
131
|
+
process.on('SIGINT', shutdown);
|
|
132
|
+
process.on('SIGTERM', shutdown);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runScheduledTask(
|
|
136
|
+
sessionId: string,
|
|
137
|
+
channelId: string,
|
|
138
|
+
prompt: string,
|
|
139
|
+
taskId: number,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
await runGatewayScheduledTask(
|
|
142
|
+
sessionId,
|
|
143
|
+
channelId,
|
|
144
|
+
prompt,
|
|
145
|
+
taskId,
|
|
146
|
+
async (result) => {
|
|
147
|
+
await deliverProactiveMessage(channelId, result, `schedule:${taskId}`);
|
|
148
|
+
logger.info({ taskId, channelId, result }, 'Scheduled task completed');
|
|
149
|
+
},
|
|
150
|
+
(error) => {
|
|
151
|
+
logger.error({ taskId, channelId, error }, 'Scheduled task failed');
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function startOrRestartHeartbeat(): void {
|
|
157
|
+
stopHeartbeat();
|
|
158
|
+
const agentId = HYBRIDAI_CHATBOT_ID || 'default';
|
|
159
|
+
startHeartbeat(agentId, HEARTBEAT_INTERVAL, (text) => {
|
|
160
|
+
const channelId = HEARTBEAT_CHANNEL || 'heartbeat';
|
|
161
|
+
void deliverProactiveMessage(channelId, text, 'heartbeat');
|
|
162
|
+
logger.info({ text }, 'Heartbeat message');
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function main(): Promise<void> {
|
|
167
|
+
logger.info('Starting HybridClaw gateway');
|
|
168
|
+
initDatabase();
|
|
169
|
+
startHealthServer();
|
|
170
|
+
setupShutdown();
|
|
171
|
+
await startDiscordIntegration();
|
|
172
|
+
|
|
173
|
+
startOrRestartHeartbeat();
|
|
174
|
+
detachConfigListener = onConfigChange((next, prev) => {
|
|
175
|
+
const shouldRestart =
|
|
176
|
+
next.hybridai.defaultChatbotId !== prev.hybridai.defaultChatbotId
|
|
177
|
+
|| next.heartbeat.intervalMs !== prev.heartbeat.intervalMs
|
|
178
|
+
|| next.heartbeat.enabled !== prev.heartbeat.enabled;
|
|
179
|
+
if (!shouldRestart) return;
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
{
|
|
183
|
+
heartbeatEnabled: next.heartbeat.enabled,
|
|
184
|
+
heartbeatIntervalMs: next.heartbeat.intervalMs,
|
|
185
|
+
heartbeatAgentId: next.hybridai.defaultChatbotId || 'default',
|
|
186
|
+
},
|
|
187
|
+
'Config changed, restarting heartbeat',
|
|
188
|
+
);
|
|
189
|
+
startOrRestartHeartbeat();
|
|
190
|
+
});
|
|
191
|
+
startScheduler(runScheduledTask);
|
|
192
|
+
|
|
193
|
+
logger.info({ ...getGatewayStatus(), discord: !!DISCORD_TOKEN }, 'HybridClaw gateway started');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
main().catch((err) => {
|
|
197
|
+
logger.fatal({ err }, 'Failed to start gateway');
|
|
198
|
+
process.exit(1);
|
|
199
|
+
});
|