@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
package/src/health.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import http, { type IncomingMessage, type ServerResponse } from 'http';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { HEALTH_HOST, HEALTH_PORT, WEB_API_TOKEN } from './config.js';
|
|
6
|
+
import {
|
|
7
|
+
getGatewayHistory,
|
|
8
|
+
getGatewayStatus,
|
|
9
|
+
handleGatewayCommand,
|
|
10
|
+
handleGatewayMessage,
|
|
11
|
+
type GatewayCommandRequest,
|
|
12
|
+
type GatewayChatRequest,
|
|
13
|
+
} from './gateway-service.js';
|
|
14
|
+
import { logger } from './logger.js';
|
|
15
|
+
|
|
16
|
+
const SITE_DIR = path.resolve(process.cwd(), 'docs');
|
|
17
|
+
const MAX_REQUEST_BYTES = 1_000_000; // 1MB
|
|
18
|
+
|
|
19
|
+
const MIME_TYPES: Record<string, string> = {
|
|
20
|
+
'.css': 'text/css; charset=utf-8',
|
|
21
|
+
'.html': 'text/html; charset=utf-8',
|
|
22
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
23
|
+
'.json': 'application/json; charset=utf-8',
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.svg': 'image/svg+xml',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function isLoopbackAddress(address: string | undefined): boolean {
|
|
29
|
+
if (!address) return false;
|
|
30
|
+
const normalized = address.replace(/^::ffff:/, '');
|
|
31
|
+
return normalized === '127.0.0.1' || normalized === '::1';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasApiAuth(req: IncomingMessage): boolean {
|
|
35
|
+
if (!WEB_API_TOKEN) {
|
|
36
|
+
return isLoopbackAddress(req.socket.remoteAddress);
|
|
37
|
+
}
|
|
38
|
+
const authHeader = req.headers.authorization || '';
|
|
39
|
+
return authHeader === `Bearer ${WEB_API_TOKEN}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
|
|
43
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
44
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendText(res: ServerResponse, statusCode: number, text: string): void {
|
|
48
|
+
res.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
49
|
+
res.end(text);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
53
|
+
const chunks: Buffer[] = [];
|
|
54
|
+
let total = 0;
|
|
55
|
+
for await (const chunk of req) {
|
|
56
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
57
|
+
total += buffer.length;
|
|
58
|
+
if (total > MAX_REQUEST_BYTES) {
|
|
59
|
+
throw new Error('Request body too large.');
|
|
60
|
+
}
|
|
61
|
+
chunks.push(buffer);
|
|
62
|
+
}
|
|
63
|
+
if (chunks.length === 0) return {};
|
|
64
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
65
|
+
if (!raw.trim()) return {};
|
|
66
|
+
return JSON.parse(raw) as unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveSiteFile(pathname: string): string | null {
|
|
70
|
+
const cleanPath = pathname === '/' ? '/index.html' : pathname;
|
|
71
|
+
const normalized = path.normalize(cleanPath).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
72
|
+
const candidate = path.resolve(SITE_DIR, `.${normalized}`);
|
|
73
|
+
if (!candidate.startsWith(SITE_DIR)) return null;
|
|
74
|
+
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) return null;
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function serveStatic(pathname: string, res: ServerResponse): boolean {
|
|
79
|
+
const filePath = resolveSiteFile(pathname === '/chat' ? '/chat.html' : pathname);
|
|
80
|
+
if (!filePath) return false;
|
|
81
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
82
|
+
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
83
|
+
res.writeHead(200, { 'Content-Type': mimeType });
|
|
84
|
+
res.end(fs.readFileSync(filePath));
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleApiChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
89
|
+
const body = await readJsonBody(req) as Partial<GatewayChatRequest>;
|
|
90
|
+
|
|
91
|
+
const content = body.content?.trim();
|
|
92
|
+
if (!content) {
|
|
93
|
+
sendJson(res, 400, { error: 'Missing `content` in request body.' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const chatRequest: GatewayChatRequest = {
|
|
98
|
+
sessionId: body.sessionId || 'web:default',
|
|
99
|
+
guildId: body.guildId ?? null,
|
|
100
|
+
channelId: body.channelId || 'web',
|
|
101
|
+
userId: body.userId || 'web-user',
|
|
102
|
+
username: body.username ?? 'web',
|
|
103
|
+
content,
|
|
104
|
+
chatbotId: body.chatbotId,
|
|
105
|
+
enableRag: body.enableRag,
|
|
106
|
+
model: body.model,
|
|
107
|
+
};
|
|
108
|
+
const result = await handleGatewayMessage(chatRequest);
|
|
109
|
+
sendJson(res, result.status === 'success' ? 200 : 500, result);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleApiCommand(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
113
|
+
const body = await readJsonBody(req) as Partial<GatewayCommandRequest>;
|
|
114
|
+
const args = Array.isArray(body.args) ? body.args.map((value) => String(value)) : [];
|
|
115
|
+
if (args.length === 0) {
|
|
116
|
+
sendJson(res, 400, { error: 'Missing command. Provide non-empty `args` array.' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const commandRequest: GatewayCommandRequest = {
|
|
121
|
+
sessionId: body.sessionId || 'web:default',
|
|
122
|
+
guildId: body.guildId ?? null,
|
|
123
|
+
channelId: body.channelId || 'web',
|
|
124
|
+
args,
|
|
125
|
+
};
|
|
126
|
+
const result = await handleGatewayCommand(commandRequest);
|
|
127
|
+
sendJson(res, result.kind === 'error' ? 400 : 200, result);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleApiHistory(res: ServerResponse, url: URL): void {
|
|
131
|
+
const sessionId = url.searchParams.get('sessionId') || 'web:default';
|
|
132
|
+
const parsedLimit = parseInt(url.searchParams.get('limit') || '40', 10);
|
|
133
|
+
const limit = Number.isNaN(parsedLimit) ? 40 : parsedLimit;
|
|
134
|
+
const history = getGatewayHistory(sessionId, limit);
|
|
135
|
+
sendJson(res, 200, { sessionId, history });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function startHealthServer(): void {
|
|
139
|
+
const server = http.createServer((req, res) => {
|
|
140
|
+
const method = req.method || 'GET';
|
|
141
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
142
|
+
const pathname = url.pathname;
|
|
143
|
+
|
|
144
|
+
if (pathname === '/health' && method === 'GET') {
|
|
145
|
+
sendJson(res, 200, getGatewayStatus());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (pathname.startsWith('/api/')) {
|
|
150
|
+
if (!hasApiAuth(req)) {
|
|
151
|
+
sendJson(res, 401, { error: 'Unauthorized. Set `Authorization: Bearer <WEB_API_TOKEN>`.' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
void (async () => {
|
|
156
|
+
try {
|
|
157
|
+
if (pathname === '/api/status' && method === 'GET') {
|
|
158
|
+
sendJson(res, 200, getGatewayStatus());
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (pathname === '/api/history' && method === 'GET') {
|
|
162
|
+
handleApiHistory(res, url);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (pathname === '/api/chat' && method === 'POST') {
|
|
166
|
+
await handleApiChat(req, res);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (pathname === '/api/command' && method === 'POST') {
|
|
170
|
+
await handleApiCommand(req, res);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
sendJson(res, 404, { error: 'Not Found' });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const errorText = err instanceof Error ? err.message : String(err);
|
|
176
|
+
sendJson(res, 500, { error: errorText });
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (serveStatic(pathname, res)) return;
|
|
183
|
+
sendText(res, 404, 'Not Found');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
server.listen(HEALTH_PORT, HEALTH_HOST, () => {
|
|
187
|
+
logger.info({ host: HEALTH_HOST, port: HEALTH_PORT }, 'Gateway HTTP server started');
|
|
188
|
+
});
|
|
189
|
+
}
|
package/src/heartbeat.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat — periodic poll so the agent can proactively check tasks,
|
|
3
|
+
* maintain memory, and reach out when needed.
|
|
4
|
+
*/
|
|
5
|
+
import { HEARTBEAT_CHANNEL, HEARTBEAT_ENABLED, HYBRIDAI_CHATBOT_ID, HYBRIDAI_ENABLE_RAG, HYBRIDAI_MODEL } from './config.js';
|
|
6
|
+
import { runAgent } from './agent.js';
|
|
7
|
+
import { getConversationHistory, getOrCreateSession, getTasksForSession, storeMessage } from './db.js';
|
|
8
|
+
import { logger } from './logger.js';
|
|
9
|
+
import { processSideEffects } from './side-effects.js';
|
|
10
|
+
import { maybeCompactSession } from './session-maintenance.js';
|
|
11
|
+
import { appendSessionTranscript } from './session-transcripts.js';
|
|
12
|
+
import { buildConversationContext } from './conversation.js';
|
|
13
|
+
|
|
14
|
+
const HEARTBEAT_PROMPT =
|
|
15
|
+
'[Heartbeat poll] Check HEARTBEAT.md for periodic tasks. If nothing needs attention, reply HEARTBEAT_OK.';
|
|
16
|
+
|
|
17
|
+
const MAX_HEARTBEAT_HISTORY = 5;
|
|
18
|
+
|
|
19
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
let running = false;
|
|
21
|
+
|
|
22
|
+
function isHeartbeatOk(text: string): boolean {
|
|
23
|
+
const normalized = text.trim().replace(/[^a-z_]/gi, '').toUpperCase();
|
|
24
|
+
return normalized === 'HEARTBEATOK' || normalized.startsWith('HEARTBEATOK');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function startHeartbeat(
|
|
28
|
+
agentId: string,
|
|
29
|
+
interval: number,
|
|
30
|
+
onMessage: (text: string) => void,
|
|
31
|
+
): void {
|
|
32
|
+
if (!HEARTBEAT_ENABLED) {
|
|
33
|
+
logger.info('Heartbeat disabled via HEARTBEAT_ENABLED=false');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.info({ interval }, 'Heartbeat started');
|
|
38
|
+
|
|
39
|
+
timer = setInterval(async () => {
|
|
40
|
+
if (running) {
|
|
41
|
+
logger.debug('Heartbeat skipped — previous still running');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
running = true;
|
|
45
|
+
|
|
46
|
+
const sessionId = `heartbeat:${agentId}`;
|
|
47
|
+
const channelId = 'heartbeat';
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const session = getOrCreateSession(sessionId, null, channelId);
|
|
51
|
+
|
|
52
|
+
const history = getConversationHistory(sessionId, MAX_HEARTBEAT_HISTORY);
|
|
53
|
+
const { messages } = buildConversationContext({
|
|
54
|
+
agentId,
|
|
55
|
+
sessionSummary: session.session_summary,
|
|
56
|
+
history,
|
|
57
|
+
});
|
|
58
|
+
messages.push({ role: 'user', content: HEARTBEAT_PROMPT });
|
|
59
|
+
|
|
60
|
+
const chatbotId = HYBRIDAI_CHATBOT_ID || agentId;
|
|
61
|
+
const heartbeatChannelId = HEARTBEAT_CHANNEL || 'heartbeat';
|
|
62
|
+
const scheduledTasks = getTasksForSession(sessionId);
|
|
63
|
+
const output = await runAgent(sessionId, messages, chatbotId, HYBRIDAI_ENABLE_RAG, HYBRIDAI_MODEL, agentId, heartbeatChannelId, scheduledTasks);
|
|
64
|
+
processSideEffects(output, sessionId, heartbeatChannelId);
|
|
65
|
+
|
|
66
|
+
if (output.status === 'error') {
|
|
67
|
+
logger.warn({ error: output.error }, 'Heartbeat agent error');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = (output.result || '').trim();
|
|
72
|
+
|
|
73
|
+
if (isHeartbeatOk(result)) {
|
|
74
|
+
logger.debug('Heartbeat: HEARTBEAT_OK — nothing to do');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Real content — persist and deliver
|
|
79
|
+
storeMessage(sessionId, 'heartbeat', 'heartbeat', 'user', HEARTBEAT_PROMPT);
|
|
80
|
+
storeMessage(sessionId, 'assistant', null, 'assistant', result);
|
|
81
|
+
appendSessionTranscript(agentId, {
|
|
82
|
+
sessionId,
|
|
83
|
+
channelId: heartbeatChannelId,
|
|
84
|
+
role: 'user',
|
|
85
|
+
userId: 'heartbeat',
|
|
86
|
+
username: 'heartbeat',
|
|
87
|
+
content: HEARTBEAT_PROMPT,
|
|
88
|
+
});
|
|
89
|
+
appendSessionTranscript(agentId, {
|
|
90
|
+
sessionId,
|
|
91
|
+
channelId: heartbeatChannelId,
|
|
92
|
+
role: 'assistant',
|
|
93
|
+
userId: 'assistant',
|
|
94
|
+
username: null,
|
|
95
|
+
content: result,
|
|
96
|
+
});
|
|
97
|
+
await maybeCompactSession({
|
|
98
|
+
sessionId,
|
|
99
|
+
agentId,
|
|
100
|
+
chatbotId,
|
|
101
|
+
enableRag: HYBRIDAI_ENABLE_RAG,
|
|
102
|
+
model: HYBRIDAI_MODEL,
|
|
103
|
+
channelId: heartbeatChannelId,
|
|
104
|
+
});
|
|
105
|
+
logger.info({ length: result.length }, 'Heartbeat: agent has something to say');
|
|
106
|
+
onMessage(result);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.error({ err }, 'Heartbeat failed');
|
|
109
|
+
} finally {
|
|
110
|
+
running = false;
|
|
111
|
+
}
|
|
112
|
+
}, interval);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function stopHeartbeat(): void {
|
|
116
|
+
if (timer) {
|
|
117
|
+
clearInterval(timer);
|
|
118
|
+
timer = null;
|
|
119
|
+
logger.info('Heartbeat stopped');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { HYBRIDAI_API_KEY, HYBRIDAI_BASE_URL } from './config.js';
|
|
2
|
+
import type { HybridAIBot } from './types.js';
|
|
3
|
+
|
|
4
|
+
interface BotCacheEntry {
|
|
5
|
+
bots: HybridAIBot[];
|
|
6
|
+
fetchedAtMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let botCache: BotCacheEntry | null = null;
|
|
10
|
+
|
|
11
|
+
function normalizeBots(payload: unknown): HybridAIBot[] {
|
|
12
|
+
const data = payload as
|
|
13
|
+
| { data?: Record<string, unknown>[]; bots?: Record<string, unknown>[]; items?: Record<string, unknown>[] }
|
|
14
|
+
| Record<string, unknown>[];
|
|
15
|
+
const raw = Array.isArray(data) ? data : (data.data || data.bots || data.items || []);
|
|
16
|
+
|
|
17
|
+
return raw.map((item) => ({
|
|
18
|
+
id: String(item.id ?? item._id ?? item.chatbot_id ?? item.bot_id ?? ''),
|
|
19
|
+
name: String(item.bot_name ?? item.name ?? 'Unnamed'),
|
|
20
|
+
description: item.description != null ? String(item.description) : undefined,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchHybridAIBots(options?: { cacheTtlMs?: number }): Promise<HybridAIBot[]> {
|
|
25
|
+
const cacheTtlMs = Math.max(0, options?.cacheTtlMs ?? 0);
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
|
|
28
|
+
if (cacheTtlMs > 0 && botCache && now - botCache.fetchedAtMs < cacheTtlMs) {
|
|
29
|
+
return botCache.bots;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const url = `${HYBRIDAI_BASE_URL}/api/v1/bot-management/bots`;
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
headers: { Authorization: `Bearer ${HYBRIDAI_API_KEY}` },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(`Failed to fetch bots: ${res.status} ${res.statusText}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const bots = normalizeBots(await res.json());
|
|
42
|
+
if (cacheTtlMs > 0) {
|
|
43
|
+
botCache = { bots, fetchedAtMs: now };
|
|
44
|
+
} else {
|
|
45
|
+
botCache = null;
|
|
46
|
+
}
|
|
47
|
+
return bots;
|
|
48
|
+
}
|
package/src/ipc.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { CONTAINER_MAX_OUTPUT_SIZE, DATA_DIR } from './config.js';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
import type { ContainerInput, ContainerOutput } from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get session directory, creating it if needed.
|
|
10
|
+
*/
|
|
11
|
+
function sessionDir(sessionId: string): string {
|
|
12
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
13
|
+
const dir = path.join(DATA_DIR, 'sessions', safe);
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ipcDir(sessionId: string): string {
|
|
18
|
+
return path.join(sessionDir(sessionId), 'ipc');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function agentDir(agentId: string): string {
|
|
22
|
+
const safe = agentId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
23
|
+
return path.join(DATA_DIR, 'agents', safe);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function agentWorkspaceDir(agentId: string): string {
|
|
27
|
+
return path.join(agentDir(agentId), 'workspace');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure session directories exist (IPC only).
|
|
32
|
+
*/
|
|
33
|
+
export function ensureSessionDirs(sessionId: string): void {
|
|
34
|
+
fs.mkdirSync(ipcDir(sessionId), { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure agent workspace directory exists.
|
|
39
|
+
*/
|
|
40
|
+
export function ensureAgentDirs(agentId: string): void {
|
|
41
|
+
fs.mkdirSync(agentWorkspaceDir(agentId), { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write input for the container agent.
|
|
46
|
+
* When omitApiKey is set, the apiKey field is excluded from the file on disk
|
|
47
|
+
* (the container already has the key in memory from the initial stdin payload).
|
|
48
|
+
*/
|
|
49
|
+
export function writeInput(sessionId: string, input: ContainerInput, opts?: { omitApiKey?: boolean }): string {
|
|
50
|
+
const dir = ipcDir(sessionId);
|
|
51
|
+
const inputPath = path.join(dir, 'input.json');
|
|
52
|
+
const toWrite = opts?.omitApiKey ? { ...input, apiKey: '' } : input;
|
|
53
|
+
fs.writeFileSync(inputPath, JSON.stringify(toWrite, null, 2));
|
|
54
|
+
logger.debug({ sessionId, path: inputPath }, 'Wrote IPC input');
|
|
55
|
+
return inputPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read output from the container agent. Polls until file appears or timeout.
|
|
60
|
+
*/
|
|
61
|
+
function interruptedOutput(): ContainerOutput {
|
|
62
|
+
return {
|
|
63
|
+
status: 'error',
|
|
64
|
+
result: null,
|
|
65
|
+
toolsUsed: [],
|
|
66
|
+
error: 'Interrupted by user.',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
71
|
+
if (!signal) {
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (signal.aborted) return true;
|
|
76
|
+
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
signal.removeEventListener('abort', onAbort);
|
|
80
|
+
resolve(false);
|
|
81
|
+
}, ms);
|
|
82
|
+
const onAbort = () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
signal.removeEventListener('abort', onAbort);
|
|
85
|
+
resolve(true);
|
|
86
|
+
};
|
|
87
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function readOutput(
|
|
92
|
+
sessionId: string,
|
|
93
|
+
timeoutMs: number,
|
|
94
|
+
opts?: { signal?: AbortSignal },
|
|
95
|
+
): Promise<ContainerOutput> {
|
|
96
|
+
const dir = ipcDir(sessionId);
|
|
97
|
+
const outputPath = path.join(dir, 'output.json');
|
|
98
|
+
const signal = opts?.signal;
|
|
99
|
+
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const pollInterval = 250;
|
|
102
|
+
|
|
103
|
+
if (signal?.aborted) return interruptedOutput();
|
|
104
|
+
|
|
105
|
+
while (Date.now() - start < timeoutMs) {
|
|
106
|
+
if (signal?.aborted) return interruptedOutput();
|
|
107
|
+
|
|
108
|
+
if (fs.existsSync(outputPath)) {
|
|
109
|
+
const stat = fs.statSync(outputPath);
|
|
110
|
+
if (stat.size > CONTAINER_MAX_OUTPUT_SIZE) {
|
|
111
|
+
fs.unlinkSync(outputPath);
|
|
112
|
+
logger.warn({ sessionId, size: stat.size, limit: CONTAINER_MAX_OUTPUT_SIZE }, 'Container output exceeded size limit');
|
|
113
|
+
return { status: 'error', result: null, toolsUsed: [], error: `Output too large (${stat.size} bytes, limit ${CONTAINER_MAX_OUTPUT_SIZE})` };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const raw = fs.readFileSync(outputPath, 'utf-8');
|
|
117
|
+
const output: ContainerOutput = JSON.parse(raw);
|
|
118
|
+
// Clean up output file after reading
|
|
119
|
+
fs.unlinkSync(outputPath);
|
|
120
|
+
logger.debug({ sessionId }, 'Read IPC output');
|
|
121
|
+
return output;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// File might be partially written, wait and retry
|
|
124
|
+
logger.debug({ sessionId, err }, 'Output file not ready, retrying');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const aborted = await sleepWithAbort(pollInterval, signal);
|
|
128
|
+
if (aborted) return interruptedOutput();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
status: 'error',
|
|
133
|
+
result: null,
|
|
134
|
+
toolsUsed: [],
|
|
135
|
+
error: `Timeout waiting for container output after ${timeoutMs}ms`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Clean up IPC files for a session.
|
|
141
|
+
*/
|
|
142
|
+
export function cleanupIpc(sessionId: string): void {
|
|
143
|
+
const dir = ipcDir(sessionId);
|
|
144
|
+
for (const file of ['input.json', 'output.json', 'history.json']) {
|
|
145
|
+
const filePath = path.join(dir, file);
|
|
146
|
+
if (fs.existsSync(filePath)) {
|
|
147
|
+
fs.unlinkSync(filePath);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get host paths for container mounting.
|
|
154
|
+
*/
|
|
155
|
+
export function getSessionPaths(sessionId: string, agentId: string): {
|
|
156
|
+
ipcPath: string;
|
|
157
|
+
workspacePath: string;
|
|
158
|
+
} {
|
|
159
|
+
return {
|
|
160
|
+
ipcPath: path.resolve(ipcDir(sessionId)),
|
|
161
|
+
workspacePath: path.resolve(agentWorkspaceDir(agentId)),
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
|
|
3
|
+
import { getRuntimeConfig, onRuntimeConfigChange } from './runtime-config.js';
|
|
4
|
+
|
|
5
|
+
const initialLevel = getRuntimeConfig().ops.logLevel;
|
|
6
|
+
|
|
7
|
+
export const logger = pino({
|
|
8
|
+
level: initialLevel,
|
|
9
|
+
transport: { target: 'pino-pretty', options: { colorize: true } },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
onRuntimeConfigChange((next, prev) => {
|
|
13
|
+
if (next.ops.logLevel !== prev.ops.logLevel) {
|
|
14
|
+
logger.level = next.ops.logLevel;
|
|
15
|
+
logger.info({ level: next.ops.logLevel }, 'Logger level updated from config.json');
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
process.on('uncaughtException', (err) => {
|
|
20
|
+
logger.fatal({ err }, 'Uncaught exception');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
process.on('unhandledRejection', (reason) => {
|
|
25
|
+
logger.error({ err: reason }, 'Unhandled rejection');
|
|
26
|
+
});
|