@johpaz/hive-core 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +43 -0
  2. package/src/agent/compaction.ts +161 -0
  3. package/src/agent/context-guard.ts +91 -0
  4. package/src/agent/context.ts +148 -0
  5. package/src/agent/ethics.ts +102 -0
  6. package/src/agent/hooks.ts +166 -0
  7. package/src/agent/index.ts +67 -0
  8. package/src/agent/providers/index.ts +278 -0
  9. package/src/agent/providers.ts +1 -0
  10. package/src/agent/soul.ts +89 -0
  11. package/src/agent/stuck-loop.ts +133 -0
  12. package/src/agent/user.ts +86 -0
  13. package/src/channels/base.ts +91 -0
  14. package/src/channels/discord.ts +185 -0
  15. package/src/channels/index.ts +7 -0
  16. package/src/channels/manager.ts +204 -0
  17. package/src/channels/slack.ts +209 -0
  18. package/src/channels/telegram.ts +177 -0
  19. package/src/channels/webchat.ts +83 -0
  20. package/src/channels/whatsapp.ts +305 -0
  21. package/src/config/index.ts +1 -0
  22. package/src/config/loader.ts +508 -0
  23. package/src/gateway/index.ts +5 -0
  24. package/src/gateway/lane-queue.ts +169 -0
  25. package/src/gateway/router.ts +124 -0
  26. package/src/gateway/server.ts +347 -0
  27. package/src/gateway/session.ts +131 -0
  28. package/src/gateway/slash-commands.ts +176 -0
  29. package/src/heartbeat/index.ts +157 -0
  30. package/src/index.ts +21 -0
  31. package/src/memory/index.ts +1 -0
  32. package/src/memory/notes.ts +170 -0
  33. package/src/multi-agent/bindings.ts +171 -0
  34. package/src/multi-agent/index.ts +4 -0
  35. package/src/multi-agent/manager.ts +182 -0
  36. package/src/multi-agent/sandbox.ts +130 -0
  37. package/src/multi-agent/subagents.ts +302 -0
  38. package/src/security/index.ts +187 -0
  39. package/src/tools/cron.ts +156 -0
  40. package/src/tools/exec.ts +105 -0
  41. package/src/tools/index.ts +6 -0
  42. package/src/tools/memory.ts +176 -0
  43. package/src/tools/notify.ts +53 -0
  44. package/src/tools/read.ts +154 -0
  45. package/src/tools/registry.ts +115 -0
  46. package/src/tools/web.ts +186 -0
  47. package/src/utils/crypto.ts +73 -0
  48. package/src/utils/index.ts +3 -0
  49. package/src/utils/logger.ts +254 -0
  50. package/src/utils/retry.ts +70 -0
@@ -0,0 +1,124 @@
1
+ import type { Config, Binding } from "../config/loader.ts";
2
+
3
+ export interface RoutingContext {
4
+ channel: string;
5
+ accountId?: string;
6
+ peerKind?: "direct" | "group";
7
+ peerId?: string;
8
+ guildId?: string;
9
+ teamId?: string;
10
+ roles?: string[];
11
+ }
12
+
13
+ export function matchBinding(binding: Binding, ctx: RoutingContext): number {
14
+ const match = binding.match;
15
+ let score = 0;
16
+
17
+ if (match.peer?.id && match.peer?.kind) {
18
+ if (ctx.peerId === match.peer.id && ctx.peerKind === match.peer.kind) {
19
+ score += 1000;
20
+ } else {
21
+ return 0;
22
+ }
23
+ } else if (match.peer?.id) {
24
+ if (ctx.peerId === match.peer.id) {
25
+ score += 900;
26
+ } else {
27
+ return 0;
28
+ }
29
+ } else if (match.peer?.kind) {
30
+ if (ctx.peerKind === match.peer.kind) {
31
+ score += 50;
32
+ } else {
33
+ return 0;
34
+ }
35
+ }
36
+
37
+ if (match.guildId && match.roles && match.roles.length > 0) {
38
+ if (ctx.guildId === match.guildId && ctx.roles?.some((r) => match.roles?.includes(r))) {
39
+ score += 800;
40
+ } else {
41
+ return 0;
42
+ }
43
+ } else if (match.guildId) {
44
+ if (ctx.guildId === match.guildId) {
45
+ score += 200;
46
+ } else {
47
+ return 0;
48
+ }
49
+ }
50
+
51
+ if (match.teamId) {
52
+ if (ctx.teamId === match.teamId) {
53
+ score += 300;
54
+ } else {
55
+ return 0;
56
+ }
57
+ }
58
+
59
+ if (match.accountId) {
60
+ if (ctx.accountId === match.accountId) {
61
+ score += 400;
62
+ } else {
63
+ return 0;
64
+ }
65
+ }
66
+
67
+ if (match.channel) {
68
+ if (ctx.channel === match.channel) {
69
+ score += 100;
70
+ } else {
71
+ return 0;
72
+ }
73
+ }
74
+
75
+ return score || 1;
76
+ }
77
+
78
+ export function resolveAgent(
79
+ config: Config,
80
+ ctx: RoutingContext
81
+ ): string {
82
+ const bindings = config.bindings ?? [];
83
+
84
+ if (bindings.length === 0) {
85
+ return config.agent?.defaultAgentId ?? "main";
86
+ }
87
+
88
+ let bestMatch: { agentId: string; score: number } | null = null;
89
+
90
+ for (const binding of bindings) {
91
+ const score = matchBinding(binding, ctx);
92
+ if (score > 0 && (!bestMatch || score > bestMatch.score)) {
93
+ bestMatch = { agentId: binding.agentId, score };
94
+ }
95
+ }
96
+
97
+ if (bestMatch) {
98
+ return bestMatch.agentId;
99
+ }
100
+
101
+ return config.agent?.defaultAgentId ?? "main";
102
+ }
103
+
104
+ export class Router {
105
+ constructor(private config: Config) {}
106
+
107
+ route(ctx: RoutingContext): string {
108
+ return resolveAgent(this.config, ctx);
109
+ }
110
+
111
+ getAgentWorkspace(agentId: string): string {
112
+ const agents = this.config.agents?.list ?? [];
113
+ const agent = agents.find((a) => a.id === agentId);
114
+
115
+ if (agent?.workspace) {
116
+ return agent.workspace.replace(/^~/, process.env.HOME ?? "");
117
+ }
118
+
119
+ const baseDir = this.config.agent?.baseDir?.replace(/^~/, process.env.HOME ?? "")
120
+ ?? `${process.env.HOME}/.hive/agents`;
121
+
122
+ return `${baseDir}/${agentId}/workspace`;
123
+ }
124
+ }
@@ -0,0 +1,347 @@
1
+ import type { Config } from "../config/loader.ts";
2
+ import { logger } from "../utils/logger.ts";
3
+ import { sessionManager } from "./session.ts";
4
+ import { laneQueue } from "./lane-queue.ts";
5
+ import {
6
+ type InboundMessage,
7
+ type OutboundMessage,
8
+ isSlashCommand,
9
+ executeSlashCommand,
10
+ } from "./slash-commands.ts";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+
14
+ function expandPath(p: string): string {
15
+ if (p.startsWith("~")) {
16
+ return path.join(process.env.HOME ?? "", p.slice(1));
17
+ }
18
+ return p;
19
+ }
20
+
21
+ const UI_HTML = `<!DOCTYPE html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="UTF-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
26
+ <title>Hive Control UI</title>
27
+ <style>
28
+ * { box-sizing: border-box; margin: 0; padding: 0; }
29
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; height: 100vh; display: flex; flex-direction: column; }
30
+ .header { background: #16213e; padding: 1rem; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
31
+ .header h1 { font-size: 1.2rem; color: #e94560; }
32
+ .status { font-size: 0.8rem; color: #4ecca3; }
33
+ .container { display: flex; flex: 1; overflow: hidden; }
34
+ .sidebar { width: 250px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; }
35
+ .sidebar-header { padding: 1rem; border-bottom: 1px solid #0f3460; font-weight: bold; }
36
+ .session-list { flex: 1; overflow-y: auto; padding: 0.5rem; }
37
+ .session-item { padding: 0.5rem; cursor: pointer; border-radius: 4px; margin-bottom: 4px; }
38
+ .session-item:hover { background: #0f3460; }
39
+ .session-item.active { background: #e94560; }
40
+ .main { flex: 1; display: flex; flex-direction: column; }
41
+ .chat { flex: 1; overflow-y: auto; padding: 1rem; }
42
+ .message { margin-bottom: 1rem; padding: 0.75rem; border-radius: 8px; max-width: 80%; }
43
+ .message.user { background: #0f3460; margin-left: auto; }
44
+ .message.assistant { background: #16213e; }
45
+ .message .role { font-size: 0.7rem; color: #888; margin-bottom: 0.25rem; }
46
+ .input-area { padding: 1rem; background: #16213e; border-top: 1px solid #0f3460; }
47
+ .input-form { display: flex; gap: 0.5rem; }
48
+ .input { flex: 1; padding: 0.75rem; border: 1px solid #0f3460; border-radius: 8px; background: #1a1a2e; color: #eee; }
49
+ .input:focus { outline: none; border-color: #e94560; }
50
+ .send-btn { padding: 0.75rem 1.5rem; background: #e94560; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
51
+ .send-btn:hover { background: #c23a51; }
52
+ .connecting { text-align: center; padding: 2rem; color: #888; }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <div class="header">
57
+ <h1>🤖 Hive</h1>
58
+ <span class="status" id="status">Connecting...</span>
59
+ </div>
60
+ <div class="container">
61
+ <div class="sidebar">
62
+ <div class="sidebar-header">Sessions</div>
63
+ <div class="session-list" id="sessionList"></div>
64
+ </div>
65
+ <div class="main">
66
+ <div class="chat" id="chat">
67
+ <div class="connecting">Connecting to gateway...</div>
68
+ </div>
69
+ <div class="input-area">
70
+ <form class="input-form" id="form">
71
+ <input type="text" class="input" id="input" placeholder="Type a message or /help for commands" autocomplete="off">
72
+ <button type="submit" class="send-btn">Send</button>
73
+ </form>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ <script>
78
+ const ws = new WebSocket('ws://' + location.host + '/ws');
79
+ const chat = document.getElementById('chat');
80
+ const form = document.getElementById('form');
81
+ const input = document.getElementById('input');
82
+ const status = document.getElementById('status');
83
+ const sessionList = document.getElementById('sessionList');
84
+
85
+ let currentSession = 'agent:main:webchat:dm:default';
86
+ let messages = [];
87
+
88
+ ws.onopen = () => {
89
+ status.textContent = 'Connected';
90
+ status.style.color = '#4ecca3';
91
+ chat.innerHTML = '';
92
+ addMessage('system', 'Connected to Hive. Type a message to start.');
93
+
94
+ ws.send(JSON.stringify({ type: 'command', sessionId: currentSession, command: 'join' }));
95
+ };
96
+
97
+ ws.onclose = () => {
98
+ status.textContent = 'Disconnected';
99
+ status.style.color = '#e94560';
100
+ };
101
+
102
+ ws.onerror = () => {
103
+ status.textContent = 'Error';
104
+ status.style.color = '#e94560';
105
+ };
106
+
107
+ ws.onmessage = (event) => {
108
+ const msg = JSON.parse(event.data);
109
+
110
+ if (msg.type === 'message' && msg.sessionId === currentSession) {
111
+ addMessage('assistant', msg.content);
112
+ } else if (msg.type === 'stream' && msg.sessionId === currentSession) {
113
+ addStreamChunk(msg.chunk, msg.isLast);
114
+ } else if (msg.type === 'status') {
115
+ updateStatus(msg.status);
116
+ } else if (msg.type === 'command_result') {
117
+ addMessage('system', JSON.stringify(msg.result, null, 2));
118
+ } else if (msg.type === 'error') {
119
+ addMessage('error', msg.error);
120
+ }
121
+ };
122
+
123
+ function addMessage(role, content) {
124
+ const div = document.createElement('div');
125
+ div.className = 'message ' + role;
126
+ div.innerHTML = '<div class="role">' + role.toUpperCase() + '</div>' + escapeHtml(content);
127
+ chat.appendChild(div);
128
+ chat.scrollTop = chat.scrollHeight;
129
+ }
130
+
131
+ function addStreamChunk(chunk, isLast) {
132
+ let last = chat.lastElementChild;
133
+ if (!last || !last.classList.contains('streaming')) {
134
+ last = document.createElement('div');
135
+ last.className = 'message assistant streaming';
136
+ last.innerHTML = '<div class="role">ASSISTANT</div><div class="content"></div>';
137
+ chat.appendChild(last);
138
+ }
139
+ const content = last.querySelector('.content');
140
+ content.textContent += chunk || '';
141
+ chat.scrollTop = chat.scrollHeight;
142
+ if (isLast) last.classList.remove('streaming');
143
+ }
144
+
145
+ function updateStatus(s) {
146
+ if (s.model) status.textContent = s.model + ' | ' + (s.tokens || 0) + ' tokens';
147
+ }
148
+
149
+ function escapeHtml(text) {
150
+ const div = document.createElement('div');
151
+ div.textContent = text;
152
+ return div.innerHTML;
153
+ }
154
+
155
+ form.onsubmit = (e) => {
156
+ e.preventDefault();
157
+ const content = input.value.trim();
158
+ if (!content) return;
159
+
160
+ addMessage('user', content);
161
+ ws.send(JSON.stringify({ type: 'message', sessionId: currentSession, content }));
162
+ input.value = '';
163
+ };
164
+
165
+ input.focus();
166
+ </script>
167
+ </body>
168
+ </html>`;
169
+
170
+ interface WebSocketData {
171
+ sessionId: string;
172
+ authenticatedAt: number;
173
+ }
174
+
175
+ export async function startGateway(config: Config): Promise<void> {
176
+ const host = config.gateway?.host ?? "127.0.0.1";
177
+ const port = config.gateway?.port ?? 18790;
178
+ const token = config.gateway?.authToken;
179
+
180
+ const log = logger.child("gateway");
181
+
182
+ log.info(`Starting gateway on ${host}:${port}`);
183
+
184
+ if (host === "0.0.0.0" && config.security?.warnOnInsecureConfig !== false) {
185
+ log.warn("Gateway binding to 0.0.0.0 exposes server to all network interfaces!");
186
+ }
187
+
188
+ const pidFile = expandPath(config.gateway?.pidFile ?? "~/.hive/gateway.pid");
189
+ try {
190
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
191
+ fs.writeFileSync(pidFile, process.pid.toString());
192
+ } catch (error) {
193
+ log.warn(`Could not write PID file: ${(error as Error).message}`);
194
+ }
195
+
196
+ const server = Bun.serve<WebSocketData>({
197
+ port,
198
+ hostname: host,
199
+
200
+ async fetch(req, server) {
201
+ const url = new URL(req.url);
202
+
203
+ if (url.pathname === "/ws" || url.pathname === "/ws/") {
204
+ const authHeader = req.headers.get("authorization");
205
+ if (token) {
206
+ const providedToken = authHeader?.replace(/^Bearer\s+/i, "") ?? url.searchParams.get("token");
207
+ if (providedToken !== token) {
208
+ return new Response("Unauthorized", { status: 401 });
209
+ }
210
+ }
211
+
212
+ const sessionId = url.searchParams.get("session") ?? "agent:main:webchat:dm:default";
213
+
214
+ const success = server.upgrade(req, {
215
+ data: { sessionId, authenticatedAt: Date.now() },
216
+ });
217
+
218
+ if (success) return undefined;
219
+ return new Response("WebSocket upgrade failed", { status: 400 });
220
+ }
221
+
222
+ if (url.pathname === "/ui" || url.pathname === "/ui/") {
223
+ return new Response(UI_HTML, {
224
+ headers: { "Content-Type": "text/html" },
225
+ });
226
+ }
227
+
228
+ if (url.pathname === "/health" || url.pathname === "/health/") {
229
+ return Response.json({ status: "ok", pid: process.pid });
230
+ }
231
+
232
+ if (url.pathname === "/status" || url.pathname === "/status/") {
233
+ return Response.json({
234
+ sessions: sessionManager.list().map((s) => ({
235
+ id: s.id,
236
+ createdAt: s.createdAt,
237
+ messageCount: s.messageCount,
238
+ })),
239
+ queue: {
240
+ activeSessions: 0,
241
+ },
242
+ });
243
+ }
244
+
245
+ return new Response("Not Found", { status: 404 });
246
+ },
247
+
248
+ websocket: {
249
+ open(ws) {
250
+ const data = ws.data;
251
+ log.debug(`WebSocket connected: ${data.sessionId}`);
252
+
253
+ sessionManager.create(data.sessionId, ws);
254
+
255
+ ws.send(JSON.stringify({
256
+ type: "status",
257
+ sessionId: data.sessionId,
258
+ status: { state: "connected" },
259
+ } as OutboundMessage));
260
+ },
261
+
262
+ async message(ws, message) {
263
+ const data = ws.data;
264
+
265
+ let msg: InboundMessage;
266
+ try {
267
+ msg = JSON.parse(message.toString()) as InboundMessage;
268
+ } catch {
269
+ ws.send(JSON.stringify({
270
+ type: "error",
271
+ sessionId: data.sessionId,
272
+ error: "Invalid JSON message",
273
+ } as OutboundMessage));
274
+ return;
275
+ }
276
+
277
+ msg.sessionId = msg.sessionId ?? data.sessionId;
278
+ sessionManager.touch(msg.sessionId);
279
+
280
+ if (msg.type === "ping") {
281
+ ws.send(JSON.stringify({ type: "pong", sessionId: msg.sessionId } as OutboundMessage));
282
+ return;
283
+ }
284
+
285
+ if (msg.type === "command" || (msg.content && isSlashCommand(msg.content))) {
286
+ const result = await executeSlashCommand(msg.sessionId, msg.content ?? `/${msg.command}`, ws);
287
+ ws.send(JSON.stringify(result));
288
+ return;
289
+ }
290
+
291
+ if (msg.type === "message" && msg.content) {
292
+ log.debug(`Message received for session ${msg.sessionId}`, { content: msg.content.substring(0, 100) });
293
+
294
+ laneQueue.enqueue(msg.sessionId, async (_task, signal) => {
295
+ if (signal.aborted) {
296
+ ws.send(JSON.stringify({
297
+ type: "error",
298
+ sessionId: msg.sessionId,
299
+ error: "Task cancelled",
300
+ } as OutboundMessage));
301
+ return;
302
+ }
303
+
304
+ ws.send(JSON.stringify({
305
+ type: "message",
306
+ sessionId: msg.sessionId,
307
+ content: `Echo: ${msg.content}`,
308
+ } as OutboundMessage));
309
+ });
310
+
311
+ return;
312
+ }
313
+
314
+ ws.send(JSON.stringify({
315
+ type: "error",
316
+ sessionId: msg.sessionId,
317
+ error: "Unknown message type",
318
+ } as OutboundMessage));
319
+ },
320
+
321
+ close(ws) {
322
+ const data = ws.data;
323
+ log.debug(`WebSocket disconnected: ${data.sessionId}`);
324
+ laneQueue.cancel(data.sessionId);
325
+ },
326
+ },
327
+ });
328
+
329
+ log.info(`Gateway started successfully`);
330
+ log.info(`Control UI: http://${host}:${port}/ui`);
331
+ log.info(`WebSocket: ws://${host}:${port}/ws`);
332
+
333
+ process.on("SIGTERM", () => {
334
+ log.info("Received SIGTERM, shutting down gracefully...");
335
+ server.stop();
336
+ try {
337
+ fs.unlinkSync(pidFile);
338
+ } catch {
339
+ // Ignore
340
+ }
341
+ process.exit(0);
342
+ });
343
+
344
+ process.on("SIGHUP", () => {
345
+ log.info("Received SIGHUP, reloading configuration...");
346
+ });
347
+ }
@@ -0,0 +1,131 @@
1
+ import type { ServerWebSocket } from "bun";
2
+
3
+ export interface SessionId {
4
+ agentId: string;
5
+ channel: string;
6
+ kind: "main" | "dm" | "group";
7
+ identifier: string;
8
+ }
9
+
10
+ export function parseSessionId(sessionId: string): SessionId | null {
11
+ const parts = sessionId.split(":");
12
+
13
+ if (parts.length < 3) {
14
+ return null;
15
+ }
16
+
17
+ const [prefix, agentId, ...rest] = parts;
18
+
19
+ if (prefix !== "agent" || !agentId) {
20
+ return null;
21
+ }
22
+
23
+ if (rest.length === 0) {
24
+ return { agentId: agentId!, channel: "internal", kind: "main", identifier: "main" };
25
+ }
26
+
27
+ if (rest.length === 1 && rest[0] === "main") {
28
+ return { agentId: agentId!, channel: "internal", kind: "main", identifier: "main" };
29
+ }
30
+
31
+ if (rest.length >= 2) {
32
+ const channel = rest[0];
33
+ const kind = rest[1];
34
+ const identifier = rest.slice(2).join(":") || "unknown";
35
+
36
+ if (!channel || !kind) return null;
37
+ if (kind !== "dm" && kind !== "group") {
38
+ return null;
39
+ }
40
+
41
+ return { agentId: agentId!, channel, kind, identifier };
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ export function formatSessionId(session: SessionId): string {
48
+ if (session.kind === "main" && session.channel === "internal") {
49
+ return `agent:${session.agentId}:main`;
50
+ }
51
+ return `agent:${session.agentId}:${session.channel}:${session.kind}:${session.identifier}`;
52
+ }
53
+
54
+ export interface Session {
55
+ id: string;
56
+ parsed: SessionId;
57
+ createdAt: Date;
58
+ lastActivityAt: Date;
59
+ messageCount: number;
60
+ ws?: ServerWebSocket<unknown>;
61
+ }
62
+
63
+ export class SessionManager {
64
+ private sessions: Map<string, Session> = new Map();
65
+
66
+ create(sessionId: string, ws?: ServerWebSocket<unknown>): Session {
67
+ const parsed = parseSessionId(sessionId);
68
+ if (!parsed) {
69
+ throw new Error(`Invalid session ID: ${sessionId}`);
70
+ }
71
+
72
+ const existing = this.sessions.get(sessionId);
73
+ if (existing) {
74
+ existing.lastActivityAt = new Date();
75
+ if (ws) {
76
+ existing.ws = ws;
77
+ }
78
+ return existing;
79
+ }
80
+
81
+ const session: Session = {
82
+ id: sessionId,
83
+ parsed,
84
+ createdAt: new Date(),
85
+ lastActivityAt: new Date(),
86
+ messageCount: 0,
87
+ };
88
+ if (ws !== undefined) {
89
+ session.ws = ws;
90
+ }
91
+
92
+ this.sessions.set(sessionId, session);
93
+ return session;
94
+ }
95
+
96
+ get(sessionId: string): Session | undefined {
97
+ return this.sessions.get(sessionId);
98
+ }
99
+
100
+ touch(sessionId: string): void {
101
+ const session = this.sessions.get(sessionId);
102
+ if (session) {
103
+ session.lastActivityAt = new Date();
104
+ session.messageCount++;
105
+ }
106
+ }
107
+
108
+ delete(sessionId: string): boolean {
109
+ return this.sessions.delete(sessionId);
110
+ }
111
+
112
+ list(): Session[] {
113
+ return Array.from(this.sessions.values());
114
+ }
115
+
116
+ prune(maxAgeMs: number): number {
117
+ const now = Date.now();
118
+ let pruned = 0;
119
+
120
+ for (const [id, session] of this.sessions) {
121
+ if (now - session.lastActivityAt.getTime() > maxAgeMs) {
122
+ this.sessions.delete(id);
123
+ pruned++;
124
+ }
125
+ }
126
+
127
+ return pruned;
128
+ }
129
+ }
130
+
131
+ export const sessionManager = new SessionManager();