@johpaz/hive-sdk 0.0.15 → 0.0.16

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 (94) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +179 -57
  3. package/docs/API-AGENTS.md +9 -9
  4. package/docs/API-CONTEXT-COMPILER.md +13 -13
  5. package/docs/API-DAG-SCHEDULER.md +8 -8
  6. package/docs/API-TOOLS-SKILLS-CHANNELS.md +150 -93
  7. package/docs/API-WORKERS-EVENTS.md +206 -59
  8. package/docs/INDEX.md +99 -50
  9. package/docs/README.md +117 -24
  10. package/docs/TEMPLATE-HIVE-APP.md +360 -0
  11. package/package.json +13 -6
  12. package/packages/cli/bin/hive +2 -0
  13. package/packages/cli/src/commands/add-skill.ts +42 -0
  14. package/packages/cli/src/commands/add-tool.ts +45 -0
  15. package/packages/cli/src/commands/add-worker.ts +49 -0
  16. package/packages/cli/src/commands/create-app-utils.ts +32 -0
  17. package/packages/cli/src/commands/create-app.test.ts +151 -0
  18. package/packages/cli/src/commands/create-app.ts +35 -0
  19. package/packages/cli/src/index.ts +21 -5
  20. package/packages/cli/templates/hive-app/.env.example +17 -0
  21. package/packages/cli/templates/hive-app/docker-compose.yml +20 -0
  22. package/packages/cli/templates/hive-app/hive.config.ts +19 -0
  23. package/packages/cli/templates/hive-app/package.json +16 -0
  24. package/packages/cli/templates/hive-app/src/agents/coordinator.ts +9 -0
  25. package/packages/cli/templates/hive-app/src/main.ts +56 -0
  26. package/packages/core/src/auth/auth.ts +108 -0
  27. package/packages/core/src/auth/index.ts +1 -0
  28. package/packages/core/src/canvas/canvas.test.ts +32 -0
  29. package/packages/core/src/canvas/emitter.ts +1 -1
  30. package/packages/core/src/canvas/index.ts +3 -6
  31. package/packages/core/src/channels/base.ts +154 -0
  32. package/packages/core/src/channels/channels.test.ts +18 -0
  33. package/packages/core/src/channels/discord.ts +273 -0
  34. package/packages/core/src/channels/index.ts +7 -0
  35. package/packages/core/src/channels/manager.ts +450 -0
  36. package/packages/core/src/channels/slack.ts +323 -0
  37. package/packages/core/src/channels/telegram.ts +612 -0
  38. package/packages/core/src/channels/webchat.ts +139 -0
  39. package/packages/core/src/channels/whatsapp.ts +548 -0
  40. package/packages/core/src/events/agent-bus.ts +460 -0
  41. package/packages/core/src/events/event-bus.ts +169 -0
  42. package/packages/core/src/gateway/channel-notify.ts +32 -7
  43. package/packages/core/src/gateway/gateway.test.ts +38 -0
  44. package/packages/core/src/gateway/index.ts +2 -1
  45. package/packages/core/src/gateway/server.ts +139 -0
  46. package/packages/core/src/heartbeat/index.ts +157 -0
  47. package/packages/core/src/index.ts +44 -0
  48. package/packages/core/src/multimodal/index.ts +2 -2
  49. package/packages/core/src/multimodal/vision-service.ts +283 -0
  50. package/packages/core/src/plugins/api.ts +128 -0
  51. package/packages/core/src/plugins/index.ts +2 -0
  52. package/packages/core/src/plugins/loader.ts +365 -0
  53. package/packages/core/src/resilience/circuit-breaker.ts +225 -0
  54. package/packages/core/src/scheduler/CronScheduler.ts +699 -0
  55. package/packages/core/src/scheduler/dag/AgentExecutor.ts +53 -0
  56. package/packages/core/src/scheduler/dag/DAGScheduler.ts +250 -0
  57. package/packages/core/src/scheduler/dag/EventBridge.ts +122 -0
  58. package/packages/core/src/scheduler/dag/TaskGraph.ts +192 -0
  59. package/packages/core/src/scheduler/dag/TaskNode.ts +97 -0
  60. package/packages/core/src/scheduler/dag/TaskResult.ts +22 -0
  61. package/packages/core/src/scheduler/dag/errors.ts +37 -0
  62. package/packages/core/src/scheduler/dag/index.ts +26 -0
  63. package/packages/core/src/scheduler/dag/presets/ResearchPreset.ts +97 -0
  64. package/packages/core/src/scheduler/dag/strategies/ParallelStrategy.ts +21 -0
  65. package/packages/core/src/scheduler/dag/strategies/PriorityStrategy.ts +46 -0
  66. package/packages/core/src/scheduler/index.ts +22 -0
  67. package/packages/core/src/scheduler/integration.ts +237 -0
  68. package/packages/core/src/scheduler/scheduler.test.ts +19 -0
  69. package/packages/core/src/scheduler/types.ts +164 -0
  70. package/packages/core/src/security/google-chat.ts +269 -0
  71. package/packages/core/src/security/index.ts +192 -4
  72. package/packages/core/src/security/rate-limit.ts +270 -0
  73. package/packages/core/src/security/signal.ts +321 -0
  74. package/packages/core/src/storage/crypto.ts +198 -66
  75. package/packages/core/src/storage/storage.test.ts +37 -0
  76. package/packages/core/src/swarm/swarm.test.ts +24 -0
  77. package/packages/core/src/tool-runtime/index.ts +522 -0
  78. package/packages/core/src/tool-runtime/tool-runtime.test.ts +91 -0
  79. package/packages/core/src/tool-runtime/tool-worker.ts +125 -0
  80. package/packages/core/src/voice/index.ts +5 -18
  81. package/packages/core/src/workers/WorkerPool.ts +167 -0
  82. package/packages/core/src/workers/agent.worker.ts +68 -0
  83. package/packages/core/src/workers/createWorker.ts +144 -0
  84. package/packages/core/src/workers/index.ts +5 -0
  85. package/packages/core/src/workers/workers.test.ts +48 -0
  86. package/test/setup-db.ts +2 -2
  87. package/tsconfig.json +2 -1
  88. package/.github/CODEOWNERS +0 -9
  89. package/.github/workflows/publish.yml +0 -89
  90. package/.github/workflows/version-bump.yml +0 -102
  91. package/bun.lock +0 -543
  92. package/bunfig.toml +0 -7
  93. package/packages/core/src/agent/providers.ts +0 -1
  94. package/packages/core/src/gateway/channel-notify.test.ts +0 -14
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Hive Gateway — simplified HTTP/WebSocket server for the SDK harness.
3
+ *
4
+ * Provides:
5
+ * - POST /chat — chat with an agent
6
+ * - GET /status — health check
7
+ * - WebSocket /ws — real-time streaming
8
+ */
9
+
10
+ import { logger } from "../utils/logger";
11
+ import { runAgent } from "../agent/AgentRunner";
12
+ import type { MCPClientManager } from "../mcp/index";
13
+
14
+ const log = logger.child("gateway");
15
+
16
+ export interface GatewayConfig {
17
+ host?: string;
18
+ port?: number;
19
+ agentId?: string;
20
+ mcpManager?: MCPClientManager | null;
21
+ }
22
+
23
+ export async function startGateway(config: GatewayConfig = {}) {
24
+ const host = config.host ?? "127.0.0.1";
25
+ const port = config.port ?? 18790;
26
+ const agentId = config.agentId ?? "main";
27
+
28
+ log.info(`Starting gateway on ${host}:${port}`);
29
+
30
+ const server = Bun.serve({
31
+ hostname: host,
32
+ port,
33
+ fetch(req, server) {
34
+ const url = new URL(req.url);
35
+
36
+ // WebSocket upgrade
37
+ if (url.pathname === "/ws") {
38
+ const success = server.upgrade(req);
39
+ if (success) return undefined as any;
40
+ }
41
+
42
+ // Health check
43
+ if (url.pathname === "/status" && req.method === "GET") {
44
+ return Response.json({
45
+ status: "ok",
46
+ gateway: true,
47
+ agentId,
48
+ uptime: process.uptime(),
49
+ });
50
+ }
51
+
52
+ // Chat endpoint
53
+ if (url.pathname === "/chat" && req.method === "POST") {
54
+ return handleChat(req, agentId, config.mcpManager);
55
+ }
56
+
57
+ return new Response("Not Found", { status: 404 });
58
+ },
59
+ websocket: {
60
+ async message(ws, message) {
61
+ try {
62
+ const data = JSON.parse(String(message));
63
+ const threadId = data.threadId ?? crypto.randomUUID();
64
+
65
+ const stream = runAgent({
66
+ agentId,
67
+ userMessage: data.message ?? "",
68
+ threadId,
69
+ channel: "webchat",
70
+ mcpManager: config.mcpManager,
71
+ });
72
+
73
+ for await (const chunk of stream) {
74
+ ws.send(JSON.stringify(chunk));
75
+ }
76
+ ws.send(JSON.stringify({ done: true }));
77
+ } catch (err) {
78
+ log.error("WebSocket error:", err);
79
+ ws.send(JSON.stringify({ error: (err as Error).message }));
80
+ }
81
+ },
82
+ open(ws) {
83
+ log.info("WebSocket client connected");
84
+ },
85
+ close(ws, code, reason) {
86
+ log.info("WebSocket client disconnected");
87
+ },
88
+ },
89
+ });
90
+
91
+ log.info(`Gateway ready at http://${host}:${port}`);
92
+ return server;
93
+ }
94
+
95
+ async function handleChat(
96
+ req: Request,
97
+ agentId: string,
98
+ mcpManager?: MCPClientManager | null
99
+ ): Promise<Response> {
100
+ try {
101
+ const body = await req.json();
102
+ const message = body.message ?? "";
103
+ const threadId = body.threadId ?? crypto.randomUUID();
104
+
105
+ const stream = runAgent({
106
+ agentId,
107
+ userMessage: message,
108
+ threadId,
109
+ channel: "webchat",
110
+ mcpManager,
111
+ });
112
+
113
+ const chunks: any[] = [];
114
+ for await (const chunk of stream) {
115
+ chunks.push(chunk);
116
+ }
117
+
118
+ // Extract text from the last agent message
119
+ let responseText = "";
120
+ for (const chunk of chunks) {
121
+ if (chunk.agent?.messages) {
122
+ for (const msg of chunk.agent.messages) {
123
+ if (msg.content && typeof msg.content === "string") {
124
+ responseText = msg.content;
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ return Response.json({
131
+ response: responseText,
132
+ threadId,
133
+ chunks,
134
+ });
135
+ } catch (err) {
136
+ log.error("Chat error:", err);
137
+ return Response.json({ error: (err as Error).message }, { status: 500 });
138
+ }
139
+ }
@@ -0,0 +1,157 @@
1
+ import type { Config } from "../config/loader.ts";
2
+ import { logger } from "../utils/logger.ts";
3
+
4
+ export interface HealthStatus {
5
+ status: "healthy" | "degraded" | "unhealthy";
6
+ checks: Record<string, {
7
+ status: "ok" | "warning" | "error";
8
+ message?: string;
9
+ latency?: number;
10
+ }>;
11
+ uptime: number;
12
+ lastCheck: Date;
13
+ }
14
+
15
+ export interface HeartbeatOptions {
16
+ intervalMs?: number;
17
+ onHealthChange?: (status: HealthStatus) => void;
18
+ }
19
+
20
+ type HealthCheck = () => Promise<{
21
+ status: "ok" | "warning" | "error";
22
+ message?: string;
23
+ latency?: number;
24
+ }>;
25
+
26
+ export class Heartbeat {
27
+ private intervalMs: number;
28
+ private checks: Map<string, HealthCheck> = new Map();
29
+ private intervalId: Timer | null = null;
30
+ private startTime: Date;
31
+ private lastStatus: HealthStatus | null = null;
32
+ private onHealthChange?: (status: HealthStatus) => void;
33
+ private log = logger.child("heartbeat");
34
+
35
+ constructor(_config: Config, options: HeartbeatOptions = {}) {
36
+ this.intervalMs = options.intervalMs ?? 30000;
37
+ this.onHealthChange = options.onHealthChange;
38
+ this.startTime = new Date();
39
+ }
40
+
41
+ registerCheck(name: string, check: HealthCheck): void {
42
+ this.checks.set(name, check);
43
+ this.log.debug(`Registered health check: ${name}`);
44
+ }
45
+
46
+ removeCheck(name: string): boolean {
47
+ return this.checks.delete(name);
48
+ }
49
+
50
+ async runChecks(): Promise<HealthStatus> {
51
+ const checks: HealthStatus["checks"] = {};
52
+ let overallStatus: "healthy" | "degraded" | "unhealthy" = "healthy";
53
+
54
+ for (const [name, check] of this.checks) {
55
+ try {
56
+ const start = Date.now();
57
+ const result = await check();
58
+ const latency = Date.now() - start;
59
+
60
+ checks[name] = {
61
+ status: result.status,
62
+ message: result.message,
63
+ latency: result.latency ?? latency,
64
+ };
65
+
66
+ if (result.status === "warning" && overallStatus === "healthy") {
67
+ overallStatus = "degraded";
68
+ } else if (result.status === "error") {
69
+ overallStatus = "unhealthy";
70
+ }
71
+ } catch (error) {
72
+ checks[name] = {
73
+ status: "error",
74
+ message: (error as Error).message,
75
+ };
76
+ overallStatus = "unhealthy";
77
+ }
78
+ }
79
+
80
+ const status: HealthStatus = {
81
+ status: overallStatus,
82
+ checks,
83
+ uptime: Date.now() - this.startTime.getTime(),
84
+ lastCheck: new Date(),
85
+ };
86
+
87
+ const prevStatus = this.lastStatus?.status;
88
+ if (prevStatus && prevStatus !== overallStatus) {
89
+ this.log.info(`Health status changed: ${prevStatus} -> ${overallStatus}`);
90
+ this.onHealthChange?.(status);
91
+ }
92
+
93
+ this.lastStatus = status;
94
+ return status;
95
+ }
96
+
97
+ start(): void {
98
+ if (this.intervalId) {
99
+ this.log.warn("Heartbeat already running");
100
+ return;
101
+ }
102
+
103
+ this.runChecks();
104
+
105
+ this.intervalId = setInterval(async () => {
106
+ await this.runChecks();
107
+ }, this.intervalMs);
108
+
109
+ this.log.info(`Heartbeat started (interval: ${this.intervalMs}ms)`);
110
+ }
111
+
112
+ stop(): void {
113
+ if (this.intervalId) {
114
+ clearInterval(this.intervalId);
115
+ this.intervalId = null;
116
+ this.log.info("Heartbeat stopped");
117
+ }
118
+ }
119
+
120
+ getStatus(): HealthStatus | null {
121
+ return this.lastStatus;
122
+ }
123
+
124
+ isRunning(): boolean {
125
+ return this.intervalId !== null;
126
+ }
127
+ }
128
+
129
+ export function createHeartbeat(config: Config, options?: HeartbeatOptions): Heartbeat {
130
+ const heartbeat = new Heartbeat(config, options);
131
+
132
+ heartbeat.registerCheck("memory", async () => {
133
+ const memUsage = process.memoryUsage();
134
+ const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
135
+ const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
136
+ const ratio = memUsage.heapUsed / memUsage.heapTotal;
137
+
138
+ if (ratio > 0.9) {
139
+ return {
140
+ status: "error",
141
+ message: `Memory critically high: ${heapUsedMB}/${heapTotalMB}MB`,
142
+ };
143
+ } else if (ratio > 0.75) {
144
+ return {
145
+ status: "warning",
146
+ message: `Memory usage high: ${heapUsedMB}/${heapTotalMB}MB`,
147
+ };
148
+ }
149
+
150
+ return {
151
+ status: "ok",
152
+ message: `${heapUsedMB}/${heapTotalMB}MB used`,
153
+ };
154
+ });
155
+
156
+ return heartbeat;
157
+ }
@@ -1,37 +1,81 @@
1
+ // ─── API ─────────────────────────────────────────────────────────────────────
1
2
  export { createAgent } from "./api/index.ts";
2
3
  export type { AgentConfig, Agent, AgentEvent } from "./api/index.ts";
3
4
 
5
+ // ─── Tools ───────────────────────────────────────────────────────────────────
4
6
  export { defineTool } from "./tools/ToolRegistry.ts";
5
7
  export type { ToolDefinition } from "./tools/ToolRegistry.ts";
6
8
  export { ToolRegistry } from "./tools/ToolRegistry.ts";
7
9
  export { ToolExecutor } from "./tools/ToolExecutor.ts";
8
10
  export type { ToolExecutionResult } from "./tools/ToolExecutor.ts";
9
11
 
12
+ // ─── Skills ──────────────────────────────────────────────────────────────────
10
13
  export { defineSkill } from "./skills/defineSkill.ts";
11
14
  export type { SkillDefinition } from "./skills/defineSkill.ts";
12
15
  export { SkillLoader } from "./skills/index.ts";
13
16
  export type { Skill, SkillStep, OutputFormat, SkillsConfig } from "./skills/index.ts";
14
17
 
18
+ // ─── Agent ───────────────────────────────────────────────────────────────────
15
19
  export { AgentLoop, runAgent, runAgentIsolated, buildAgentLoop, getAgentLoop, rebuildAgentLoop } from "./agent/index.ts";
16
20
  export type { AgentLoopOptions, StepEvent, StreamChunk } from "./agent/index.ts";
17
21
  export type { Tool, ToolParameter, ToolResult } from "./agent/index.ts";
22
+
23
+ // ─── Swarm / Scheduler ───────────────────────────────────────────────────────
18
24
  export { DAGScheduler } from "./swarm/index.ts";
19
25
  export type { DAGSchedulerOptions, IAgentExecutor } from "./swarm/index.ts";
20
26
  export { TaskGraph, TaskNode } from "./swarm/index.ts";
21
27
  export type { TaskNodeConfig, NodeStatus, DAGResult, NodeSummary } from "./swarm/index.ts";
28
+ export { CronScheduler } from "./scheduler/index.ts";
29
+ export type { CronJob } from "./scheduler/index.ts";
22
30
 
31
+ // ─── MCP ─────────────────────────────────────────────────────────────────────
23
32
  export { MCPClientManager } from "./mcp/index.ts";
24
33
  export type { MCPTool, MCPResource, MCPPrompt, MCPConfig, MCPServerConfig } from "./mcp/index.ts";
25
34
 
35
+ // ─── Ethics ──────────────────────────────────────────────────────────────────
26
36
  export { EthicsGuard } from "./ethics/index.ts";
27
37
  export type { EthicsRule } from "./ethics/index.ts";
28
38
 
39
+ // ─── Memory ──────────────────────────────────────────────────────────────────
29
40
  export { Scratchpad } from "./memory/index.ts";
30
41
  export type { IStorage } from "./memory/index.ts";
31
42
 
43
+ // ─── Storage ─────────────────────────────────────────────────────────────────
32
44
  export { initializeDatabase, dbService } from "./storage/index.ts";
33
45
 
46
+ // ─── Config ──────────────────────────────────────────────────────────────────
34
47
  export { loadConfig, loadEnv, getHiveDir } from "./config/index.ts";
35
48
  export type { Config } from "./config/index.ts";
36
49
 
50
+ // ─── Gateway ─────────────────────────────────────────────────────────────────
51
+ export { startGateway } from "./gateway/index.ts";
52
+ export type { GatewayConfig } from "./gateway/index.ts";
53
+
54
+ // ─── Channels ────────────────────────────────────────────────────────────────
55
+ export { ChannelManager } from "./channels/manager.ts";
56
+ export { BaseChannel } from "./channels/base.ts";
57
+ export { TelegramChannel } from "./channels/telegram.ts";
58
+ export { DiscordChannel } from "./channels/discord.ts";
59
+ export { WhatsAppChannel } from "./channels/whatsapp.ts";
60
+ export { SlackChannel } from "./channels/slack.ts";
61
+ export { WebChatChannel } from "./channels/webchat.ts";
62
+
63
+ // ─── Canvas ──────────────────────────────────────────────────────────────────
64
+ export { CanvasManager } from "./canvas/CanvasManager.ts";
65
+ export { emitCanvas, subscribeCanvas, unsubscribeCanvas } from "./canvas/emitter.ts";
66
+
67
+ // ─── Tool Runtime ────────────────────────────────────────────────────────────
68
+ export { executeToolBatch } from "./tool-runtime/index.ts";
69
+ export type { ToolBatchResult, ExecuteToolBatchOptions } from "./tool-runtime/index.ts";
70
+
71
+ // ─── Events ──────────────────────────────────────────────────────────────────
72
+ export { eventBus } from "./events/event-bus.ts";
73
+ export { agentBus } from "./events/agent-bus.ts";
74
+
75
+ // ─── Workers ─────────────────────────────────────────────────────────────────
76
+ export { createWorker, WorkerPool } from "./workers/index.ts";
77
+ export type { WorkerConfig, WorkerInstance, WorkerChunk, WorkerPoolConfig, PoolTask, PoolTaskResult } from "./workers/index.ts";
78
+
79
+ // ─── Utils ───────────────────────────────────────────────────────────────────
37
80
  export { logger } from "./utils/index.ts";
81
+ export { retry } from "./utils/retry.ts";
@@ -1,2 +1,2 @@
1
- export type { ContentPart, ImageInput, DocumentInput, VisionConfig, MultimodalMessageType } from "./types.ts";
2
- export { multimodalService } from "./VisionService.ts";
1
+ export type { ContentPart, ImageInput, DocumentInput, VisionConfig, MultimodalMessageType } from "./types"
2
+ export { multimodalService } from "./vision-service"
@@ -0,0 +1,283 @@
1
+ import { getDb } from "../storage/SQLiteStorage"
2
+ import { loadProviderApiKey } from "../storage/crypto"
3
+ import { logger } from "../utils/logger"
4
+ import type { ImageInput, DocumentInput, VisionConfig } from "./types"
5
+ import type { ContentPart } from "./types"
6
+
7
+ const log = logger.child("multimodal")
8
+
9
+ class MultimodalService {
10
+ private static instance: MultimodalService
11
+
12
+ private constructor() {}
13
+
14
+ static getInstance(): MultimodalService {
15
+ if (!MultimodalService.instance) {
16
+ MultimodalService.instance = new MultimodalService()
17
+ }
18
+ return MultimodalService.instance
19
+ }
20
+
21
+ getChannelVisionConfig(channelId: string): VisionConfig {
22
+ const db = getDb()
23
+ const result = db.query(`
24
+ SELECT vision_enabled, ocr_provider, vision_provider, vision_model_id
25
+ FROM channels WHERE id = ?
26
+ `).get(channelId) as {
27
+ vision_enabled: number
28
+ ocr_provider: string | null
29
+ vision_provider: string | null
30
+ vision_model_id: string | null
31
+ } | undefined
32
+
33
+ if (!result) {
34
+ return { visionEnabled: false, ocrProvider: null, visionProvider: null, visionModelId: null }
35
+ }
36
+
37
+ return {
38
+ visionEnabled: result.vision_enabled === 1,
39
+ ocrProvider: result.ocr_provider,
40
+ visionProvider: result.vision_provider,
41
+ visionModelId: result.vision_model_id,
42
+ }
43
+ }
44
+
45
+ async processImage(image: ImageInput, visionModelId?: string): Promise<ContentPart[]> {
46
+ const parts: ContentPart[] = []
47
+
48
+ if (image.caption) {
49
+ parts.push({ type: "text", text: image.caption })
50
+ }
51
+
52
+ if (image.type === "url") {
53
+ parts.push({ type: "image_url", image_url: { url: image.data as string } })
54
+ } else if (image.type === "base64") {
55
+ parts.push({
56
+ type: "image_base64",
57
+ base64: image.data as string,
58
+ mimeType: image.mimeType || "image/jpeg",
59
+ })
60
+ } else if (image.type === "buffer") {
61
+ const base64 = Buffer.from(image.data as Buffer).toString("base64")
62
+ parts.push({
63
+ type: "image_base64",
64
+ base64,
65
+ mimeType: image.mimeType || "image/jpeg",
66
+ })
67
+ }
68
+
69
+ return parts
70
+ }
71
+
72
+ async ocrImage(image: ImageInput, providerId?: string): Promise<string> {
73
+ const resolved = providerId || "openai"
74
+
75
+ if (resolved === "openai") {
76
+ return this.ocrWithOpenAI(image)
77
+ } else if (resolved === "gemini") {
78
+ return this.ocrWithGemini(image)
79
+ } else if (resolved === "anthropic") {
80
+ return this.ocrWithAnthropic(image)
81
+ }
82
+
83
+ log.warn(`Unknown OCR provider ${resolved}, defaulting to OpenAI`)
84
+ return this.ocrWithOpenAI(image)
85
+ }
86
+
87
+ normalizeImageFromChannel(channelType: string, imageData: unknown): ImageInput {
88
+ const data = imageData as { url?: string; base64?: string; buffer?: Buffer; mimeType?: string; caption?: string }
89
+
90
+ if (data.url) {
91
+ return { type: "url", data: data.url, mimeType: data.mimeType, caption: data.caption }
92
+ }
93
+ if (data.base64) {
94
+ return { type: "base64", data: data.base64, mimeType: data.mimeType || "image/jpeg", caption: data.caption }
95
+ }
96
+ if (data.buffer) {
97
+ return { type: "buffer", data: data.buffer, mimeType: data.mimeType || "image/jpeg", caption: data.caption }
98
+ }
99
+
100
+ throw new Error(`${channelType} image missing url, base64, or buffer`)
101
+ }
102
+
103
+ normalizeDocumentFromChannel(channelType: string, docData: unknown): DocumentInput {
104
+ const data = docData as { url?: string; base64?: string; buffer?: Buffer; mimeType?: string; fileName?: string }
105
+
106
+ if (data.url) {
107
+ return { type: "url", data: data.url, mimeType: data.mimeType || "application/pdf", fileName: data.fileName }
108
+ }
109
+ if (data.base64) {
110
+ return { type: "base64", data: data.base64, mimeType: data.mimeType || "application/pdf", fileName: data.fileName }
111
+ }
112
+ if (data.buffer) {
113
+ return { type: "buffer", data: data.buffer, mimeType: data.mimeType || "application/pdf", fileName: data.fileName }
114
+ }
115
+
116
+ throw new Error(`${channelType} document missing url, base64, or buffer`)
117
+ }
118
+
119
+ async resolveImageUrl(image: ImageInput): Promise<string> {
120
+ if (image.type === "url") return image.data as string
121
+ if (image.type === "base64") {
122
+ const mime = image.mimeType || "image/jpeg"
123
+ return `data:${mime};base64,${image.data as string}`
124
+ }
125
+ if (image.type === "buffer") {
126
+ const base64 = Buffer.from(image.data as Buffer).toString("base64")
127
+ const mime = image.mimeType || "image/jpeg"
128
+ return `data:${mime};base64,${base64}`
129
+ }
130
+ throw new Error("Cannot resolve image URL")
131
+ }
132
+
133
+ private async getProviderApiKey(providerId: string): Promise<string | null> {
134
+ const db = getDb()
135
+ const apiKey = await loadProviderApiKey(providerId)
136
+ return apiKey || null
137
+ }
138
+
139
+ private async ocrWithOpenAI(image: ImageInput): Promise<string> {
140
+ const key = await this.getProviderApiKey("openai") || process.env.OPENAI_API_KEY
141
+ if (!key) throw new Error("OPENAI_API_KEY not configured for OCR")
142
+
143
+ const imageUrl = await this.resolveImageUrl(image)
144
+
145
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
148
+ body: JSON.stringify({
149
+ model: "gpt-4o-mini",
150
+ messages: [{
151
+ role: "user",
152
+ content: [
153
+ { type: "text", text: "Describe el contenido de esta imagen en detalle. Si hay texto, transcríbelo exactamente." },
154
+ { type: "image_url", image_url: { url: imageUrl } },
155
+ ],
156
+ }],
157
+ max_tokens: 1000,
158
+ }),
159
+ })
160
+
161
+ if (!response.ok) {
162
+ const error = await response.text()
163
+ throw new Error(`OpenAI OCR failed: ${error}`)
164
+ }
165
+
166
+ const data = await response.json() as { choices: Array<{ message: { content: string } }> }
167
+ return data.choices[0]?.message?.content || ""
168
+ }
169
+
170
+ private async ocrWithGemini(image: ImageInput): Promise<string> {
171
+ const key = await this.getProviderApiKey("gemini") || process.env.GEMINI_API_KEY
172
+ if (!key) throw new Error("GEMINI_API_KEY not configured for OCR")
173
+
174
+ let imagePart: any
175
+ if (image.type === "url") {
176
+ const imgResponse = await fetch(image.data as string)
177
+ const buffer = Buffer.from(await imgResponse.arrayBuffer())
178
+ imagePart = { inlineData: { data: buffer.toString("base64"), mimeType: image.mimeType || "image/jpeg" } }
179
+ } else if (image.type === "base64") {
180
+ imagePart = { inlineData: { data: image.data as string, mimeType: image.mimeType || "image/jpeg" } }
181
+ } else {
182
+ imagePart = { inlineData: { data: Buffer.from(image.data as Buffer).toString("base64"), mimeType: image.mimeType || "image/jpeg" } }
183
+ }
184
+
185
+ const response = await fetch(
186
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`,
187
+ {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json" },
190
+ body: JSON.stringify({
191
+ contents: [{ parts: [{ text: "Describe el contenido de esta imagen en detalle. Si hay texto, transcríbelo exactamente." }, imagePart] }],
192
+ }),
193
+ },
194
+ )
195
+
196
+ if (!response.ok) {
197
+ const error = await response.text()
198
+ throw new Error(`Gemini OCR failed: ${error}`)
199
+ }
200
+
201
+ const data = await response.json() as { candidates: Array<{ content: { parts: Array<{ text?: string }> } }> }
202
+ return data.candidates?.[0]?.content?.parts?.[0]?.text || ""
203
+ }
204
+
205
+ private async ocrWithAnthropic(image: ImageInput): Promise<string> {
206
+ const key = await this.getProviderApiKey("anthropic") || process.env.ANTHROPIC_API_KEY
207
+ if (!key) throw new Error("ANTHROPIC_API_KEY not configured for OCR")
208
+
209
+ const imageUrl = await this.resolveImageUrl(image)
210
+
211
+ let source: any
212
+ if (imageUrl.startsWith("data:")) {
213
+ const match = imageUrl.match(/^data:([^;]+);base64,(.+)$/)
214
+ if (match) {
215
+ source = { type: "base64", media_type: match[1], data: match[2] }
216
+ } else {
217
+ throw new Error("Invalid base64 data URL")
218
+ }
219
+ } else {
220
+ source = { type: "url", url: imageUrl }
221
+ }
222
+
223
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
224
+ method: "POST",
225
+ headers: {
226
+ "Content-Type": "application/json",
227
+ "x-api-key": key,
228
+ "anthropic-version": "2023-06-01",
229
+ "anthropic-dangerous-direct-browser-access": "true",
230
+ },
231
+ body: JSON.stringify({
232
+ model: "claude-haiku-4-5-20251001",
233
+ max_tokens: 1000,
234
+ messages: [{
235
+ role: "user",
236
+ content: [
237
+ { type: "image", source },
238
+ { type: "text", text: "Describe el contenido de esta imagen en detalle. Si hay texto, transcríbelo exactamente." },
239
+ ],
240
+ }],
241
+ }),
242
+ })
243
+
244
+ if (!response.ok) {
245
+ const error = await response.text()
246
+ throw new Error(`Anthropic OCR failed: ${error}`)
247
+ }
248
+
249
+ const data = await response.json() as { content: Array<{ type: string; text?: string }> }
250
+ const textBlock = data.content?.find(b => b.type === "text" && b.text)
251
+ return textBlock?.text || ""
252
+ }
253
+
254
+ getConfiguredVisionProviders(): Record<string, boolean> {
255
+ const db = getDb()
256
+ const hasDbKey = (providerId: string): boolean => {
257
+ const row = db.query(
258
+ `SELECT api_key_encrypted FROM providers WHERE id = ? AND api_key_encrypted IS NOT NULL AND api_key_encrypted != ''`
259
+ ).get(providerId) as { api_key_encrypted: string } | undefined
260
+ return !!row
261
+ }
262
+
263
+ return {
264
+ openai: hasDbKey("openai") || !!(process.env.OPENAI_API_KEY),
265
+ gemini: hasDbKey("gemini") || !!(process.env.GEMINI_API_KEY),
266
+ anthropic: hasDbKey("anthropic") || !!(process.env.ANTHROPIC_API_KEY),
267
+ }
268
+ }
269
+
270
+ modelSupportsVision(providerId: string, modelId: string): boolean {
271
+ const db = getDb()
272
+ const model = db.query(`SELECT capabilities FROM models WHERE id = ? AND provider_id = ?`).get(modelId, providerId) as { capabilities: string } | undefined
273
+ if (!model?.capabilities) return false
274
+ try {
275
+ const caps = JSON.parse(model.capabilities) as string[]
276
+ return caps.includes("vision")
277
+ } catch {
278
+ return false
279
+ }
280
+ }
281
+ }
282
+
283
+ export const multimodalService = MultimodalService.getInstance()