heyhank 0.1.0 → 0.2.0

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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -10
  3. package/bin/cli.ts +7 -7
  4. package/bin/ctl.ts +42 -42
  5. package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
  6. package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
  7. package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
  8. package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
  9. package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
  10. package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
  11. package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
  12. package/dist/assets/MediaPage-C48HTTrt.js +1 -0
  13. package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
  14. package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
  15. package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
  16. package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
  17. package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
  18. package/dist/assets/RunsPage-B9UOyO79.js +1 -0
  19. package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
  20. package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
  21. package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
  22. package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
  23. package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
  24. package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
  25. package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
  26. package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
  27. package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
  28. package/dist/assets/index-BkjSoVgn.css +32 -0
  29. package/dist/assets/sw-register-C7NOHtIu.js +1 -0
  30. package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
  31. package/dist/index.html +2 -2
  32. package/dist/sw.js +1 -1
  33. package/package.json +6 -1
  34. package/server/agent-executor.ts +37 -2
  35. package/server/agent-store.ts +3 -3
  36. package/server/agent-types.ts +11 -0
  37. package/server/assistant-store.ts +232 -6
  38. package/server/auth-manager.ts +9 -0
  39. package/server/cache-headers.ts +1 -1
  40. package/server/calendar-service.ts +10 -0
  41. package/server/ceo/document-store.ts +129 -0
  42. package/server/ceo/finance-store.ts +343 -0
  43. package/server/ceo/kpi-store.ts +208 -0
  44. package/server/ceo/memory-import.ts +277 -0
  45. package/server/ceo/news-store.ts +208 -0
  46. package/server/ceo/template-store.ts +134 -0
  47. package/server/ceo/time-tracking-store.ts +227 -0
  48. package/server/claude-auth-monitor.ts +128 -0
  49. package/server/claude-code-worker.ts +86 -0
  50. package/server/claude-session-discovery.ts +74 -1
  51. package/server/cli-launcher.ts +32 -10
  52. package/server/codex-adapter.ts +2 -2
  53. package/server/codex-ws-proxy.cjs +1 -1
  54. package/server/container-manager.ts +4 -4
  55. package/server/content-intelligence/content-engine.ts +1112 -0
  56. package/server/content-intelligence/platform-knowledge.ts +870 -0
  57. package/server/cron-store.ts +3 -3
  58. package/server/embedding-service.ts +49 -0
  59. package/server/event-bus-types.ts +13 -0
  60. package/server/federation/node-store.ts +5 -4
  61. package/server/fs-utils.ts +28 -1
  62. package/server/hank-notifications-store.ts +91 -0
  63. package/server/hank-tool-executor.ts +1835 -0
  64. package/server/hank-tools.ts +2107 -0
  65. package/server/image-pull-manager.ts +2 -2
  66. package/server/index.ts +25 -2
  67. package/server/llm-providers-streaming.ts +541 -0
  68. package/server/llm-providers.ts +12 -0
  69. package/server/marketplace.ts +249 -0
  70. package/server/mcp-registry.ts +158 -0
  71. package/server/memory-service.ts +296 -0
  72. package/server/obsidian-sync.ts +184 -0
  73. package/server/provider-manager.ts +5 -2
  74. package/server/provider-registry.ts +12 -0
  75. package/server/reminder-scheduler.ts +37 -1
  76. package/server/routes/agent-routes.ts +2 -1
  77. package/server/routes/assistant-routes.ts +198 -5
  78. package/server/routes/ceo-finance-kpi-routes.ts +167 -0
  79. package/server/routes/ceo-news-time-routes.ts +137 -0
  80. package/server/routes/ceo-routes.ts +99 -0
  81. package/server/routes/content-routes.ts +116 -0
  82. package/server/routes/email-routes.ts +147 -0
  83. package/server/routes/env-routes.ts +3 -3
  84. package/server/routes/fs-routes.ts +12 -9
  85. package/server/routes/hank-chat-routes.ts +592 -0
  86. package/server/routes/llm-routes.ts +12 -0
  87. package/server/routes/marketplace-routes.ts +63 -0
  88. package/server/routes/media-routes.ts +1 -1
  89. package/server/routes/memory-routes.ts +127 -0
  90. package/server/routes/platform-routes.ts +14 -675
  91. package/server/routes/sandbox-routes.ts +1 -1
  92. package/server/routes/settings-routes.ts +51 -1
  93. package/server/routes/socialmedia-routes.ts +152 -2
  94. package/server/routes/system-routes.ts +2 -2
  95. package/server/routes/team-routes.ts +71 -0
  96. package/server/routes/telephony-routes.ts +98 -18
  97. package/server/routes.ts +36 -9
  98. package/server/session-creation-service.ts +2 -2
  99. package/server/session-orchestrator.ts +54 -2
  100. package/server/session-types.ts +2 -0
  101. package/server/settings-manager.ts +50 -2
  102. package/server/skill-discovery.ts +68 -0
  103. package/server/socialmedia/adapters/browser-adapter.ts +179 -0
  104. package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
  105. package/server/socialmedia/manager.ts +234 -15
  106. package/server/socialmedia/store.ts +51 -1
  107. package/server/socialmedia/types.ts +35 -2
  108. package/server/socialview/browser-manager.ts +150 -0
  109. package/server/socialview/extractors.ts +1298 -0
  110. package/server/socialview/image-describe.ts +188 -0
  111. package/server/socialview/library.ts +119 -0
  112. package/server/socialview/poster.ts +276 -0
  113. package/server/socialview/routes.ts +371 -0
  114. package/server/socialview/style-analyzer.ts +187 -0
  115. package/server/socialview/style-profiles.ts +67 -0
  116. package/server/socialview/types.ts +166 -0
  117. package/server/socialview/vision.ts +127 -0
  118. package/server/socialview/vnc-manager.ts +110 -0
  119. package/server/style-injector.ts +135 -0
  120. package/server/team-service.ts +239 -0
  121. package/server/team-store.ts +75 -0
  122. package/server/team-types.ts +52 -0
  123. package/server/telephony/audio-bridge.ts +281 -35
  124. package/server/telephony/audio-recorder.ts +132 -0
  125. package/server/telephony/call-manager.ts +803 -104
  126. package/server/telephony/call-types.ts +67 -1
  127. package/server/telephony/esl-client.ts +319 -0
  128. package/server/telephony/freeswitch-sync.ts +155 -0
  129. package/server/telephony/phone-utils.ts +63 -0
  130. package/server/telephony/telephony-store.ts +9 -8
  131. package/server/url-validator.ts +82 -0
  132. package/server/vault-markdown.ts +317 -0
  133. package/server/vault-migration.ts +121 -0
  134. package/server/vault-store.ts +466 -0
  135. package/server/vault-watcher.ts +59 -0
  136. package/server/vector-store.ts +210 -0
  137. package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
  138. package/server/voice-pipeline/greeting-cache.ts +200 -0
  139. package/server/voice-pipeline/manager.ts +249 -0
  140. package/server/voice-pipeline/pipeline.ts +335 -0
  141. package/server/voice-pipeline/providers/index.ts +47 -0
  142. package/server/voice-pipeline/providers/llm-internal.ts +527 -0
  143. package/server/voice-pipeline/providers/stt-google.ts +157 -0
  144. package/server/voice-pipeline/providers/tts-google.ts +126 -0
  145. package/server/voice-pipeline/types.ts +247 -0
  146. package/server/ws-bridge-types.ts +6 -1
  147. package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
  148. package/dist/assets/HelpPage-DMfkzERp.js +0 -1
  149. package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
  150. package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
  151. package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
  152. package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
  153. package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
  154. package/dist/assets/index-C8M_PUmX.css +0 -32
  155. package/dist/assets/sw-register-LSSpj6RU.js +0 -1
  156. package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
@@ -189,7 +189,7 @@ class ImagePullManager {
189
189
  this.markReady(localTag);
190
190
  } else {
191
191
  // Pull failed — try local build for default image
192
- if (localTag === "the-companion:latest") {
192
+ if (localTag === "heyhank:latest") {
193
193
  this.appendProgress(localTag, "Pull failed, falling back to local build...");
194
194
  await this.doLocalBuild(localTag);
195
195
  } else {
@@ -199,7 +199,7 @@ class ImagePullManager {
199
199
  } catch (e) {
200
200
  const reason = e instanceof Error ? e.message : String(e);
201
201
  // Try local build fallback for default image
202
- if (localTag === "the-companion:latest") {
202
+ if (localTag === "heyhank:latest") {
203
203
  this.appendProgress(localTag, `Pull error (${reason}), falling back to local build...`);
204
204
  await this.doLocalBuild(localTag);
205
205
  } else {
package/server/index.ts CHANGED
@@ -300,6 +300,16 @@ const server = Bun.serve<SocketData>({
300
300
  return new Response("WebSocket upgrade failed", { status: 400 });
301
301
  }
302
302
 
303
+ // ── Telephony Listen WebSocket — browser live audio listen ───────
304
+ const telListenMatch = url.pathname.match(/^\/ws\/telephony\/listen\/([a-f0-9-]+)$/);
305
+ if (telListenMatch) {
306
+ const upgraded = server.upgrade(req, {
307
+ data: { kind: "telephony-listen" as const, callId: telListenMatch[1] },
308
+ });
309
+ if (upgraded) return undefined;
310
+ return new Response("WebSocket upgrade failed", { status: 400 });
311
+ }
312
+
303
313
  // ── Federation WebSocket — peer node connections ─────────────────
304
314
  if (url.pathname === "/ws/node") {
305
315
  // Auth is handled inside the federation protocol (first frame)
@@ -333,6 +343,8 @@ const server = Bun.serve<SocketData>({
333
343
  callManager.addFreeSwitchSocket(data.callId, ws);
334
344
  } else if (data.kind === "telephony-transcript") {
335
345
  callManager.addTranscriptSocket(data.callId, ws);
346
+ } else if (data.kind === "telephony-listen") {
347
+ callManager.addListenSocket(data.callId, ws);
336
348
  }
337
349
  },
338
350
  message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
@@ -349,8 +361,8 @@ const server = Bun.serve<SocketData>({
349
361
  const handler = (ws as unknown as Record<string, unknown>).__federationOnMessage as ((data: string | Buffer) => void) | undefined;
350
362
  handler?.(typeof msg === "string" ? msg : msg.toString());
351
363
  } else if (data.kind === "telephony-audio") {
352
- // Binary audio from FreeSWITCH mod_audio_fork
353
- if (msg instanceof Buffer || msg instanceof Uint8Array) {
364
+ // mod_audio_fork: text frames = metadata, binary frames = PCM audio
365
+ if (typeof msg !== "string") {
354
366
  callManager.handleFreeSwitchAudio(data.callId, msg as Buffer);
355
367
  }
356
368
  }
@@ -374,6 +386,8 @@ const server = Bun.serve<SocketData>({
374
386
  callManager.removeFreeSwitchSocket(data.callId, ws);
375
387
  } else if (data.kind === "telephony-transcript") {
376
388
  callManager.removeTranscriptSocket(data.callId, ws);
389
+ } else if (data.kind === "telephony-listen") {
390
+ callManager.removeListenSocket(data.callId, ws);
377
391
  }
378
392
  },
379
393
  },
@@ -440,6 +454,15 @@ startPeriodicCheck();
440
454
 
441
455
  // ── Reminder scheduler ──────────────────────────────────────────────────────
442
456
  startReminderScheduler();
457
+
458
+ // ── Telephony inbound listener ──────────────────────────────────────────────
459
+ // Subscribes to FreeSWITCH ESL CHANNEL_CREATE events and bootstraps inbound calls.
460
+ // No-op if `inboundEnabled` is false in telephony settings.
461
+ try {
462
+ callManager.startInboundListener();
463
+ } catch (err) {
464
+ console.error("[server] Failed to start telephony inbound listener:", err);
465
+ }
443
466
  if (isRunningAsService()) {
444
467
  setServiceMode(true);
445
468
  console.log("[server] Running as background service (auto-update available)");
@@ -0,0 +1,541 @@
1
+ // ─── Streaming LLM Providers with Tool Calling ──────────────────────────────
2
+ // Used by Hank-UI chat endpoint for text-based providers (not Gemini Live voice).
3
+
4
+ export interface ContentPart {
5
+ type: "text" | "image_url" | "document";
6
+ text?: string;
7
+ image_url?: { url: string; detail?: "auto" | "low" | "high" };
8
+ document?: { url: string; mimeType: string; name?: string };
9
+ }
10
+
11
+ export interface ChatMessage {
12
+ role: "system" | "user" | "assistant" | "tool";
13
+ content: string | ContentPart[];
14
+ tool_call_id?: string;
15
+ tool_calls?: ToolCall[];
16
+ /** OpenAI-compatible tool name for role: "tool" messages */
17
+ name?: string;
18
+ }
19
+
20
+ export interface ToolCall {
21
+ id: string;
22
+ type: "function";
23
+ function: {
24
+ name: string;
25
+ arguments: string; // JSON string
26
+ };
27
+ }
28
+
29
+ export interface StreamEvent {
30
+ type: "text" | "tool_call" | "tool_result" | "done" | "error";
31
+ content?: string;
32
+ name?: string;
33
+ args?: Record<string, unknown>;
34
+ tool_call_id?: string;
35
+ result?: unknown;
36
+ error?: string;
37
+ }
38
+
39
+ export interface StreamProviderConfig {
40
+ provider: "claude" | "openai" | "ollama" | "openrouter" | "gemini-text";
41
+ model: string;
42
+ apiKey?: string;
43
+ baseUrl?: string;
44
+ temperature?: number;
45
+ maxTokens?: number;
46
+ }
47
+
48
+ /** Extract plain text from ChatMessage content */
49
+ function getTextContent(content: string | ContentPart[]): string {
50
+ if (typeof content === "string") return content;
51
+ return content.filter(p => p.type === "text").map(p => p.text || "").join("");
52
+ }
53
+
54
+ /** Extract base64 data and mime type from a data URL */
55
+ function parseDataUrl(url: string): { mimeType: string; data: string } | null {
56
+ const match = url.match(/^data:([^;]+);base64,(.+)$/);
57
+ if (!match) return null;
58
+ return { mimeType: match[1], data: match[2] };
59
+ }
60
+
61
+ /** Convert multimodal content to Claude format */
62
+ function toClaudeContent(content: string | ContentPart[]): string | Array<Record<string, unknown>> {
63
+ if (typeof content === "string") return content;
64
+ const parts: Array<Record<string, unknown>> = [];
65
+ for (const p of content) {
66
+ if (p.type === "text" && p.text) {
67
+ parts.push({ type: "text", text: p.text });
68
+ } else if (p.type === "image_url" && p.image_url?.url) {
69
+ const parsed = parseDataUrl(p.image_url.url);
70
+ if (parsed) {
71
+ parts.push({ type: "image", source: { type: "base64", media_type: parsed.mimeType, data: parsed.data } });
72
+ }
73
+ }
74
+ }
75
+ return parts.length > 0 ? parts : getTextContent(content);
76
+ }
77
+
78
+ /** Convert multimodal content to OpenAI format */
79
+ function toOpenAIContent(content: string | ContentPart[]): string | Array<Record<string, unknown>> {
80
+ if (typeof content === "string") return content;
81
+ const parts: Array<Record<string, unknown>> = [];
82
+ for (const p of content) {
83
+ if (p.type === "text" && p.text) {
84
+ parts.push({ type: "text", text: p.text });
85
+ } else if (p.type === "image_url" && p.image_url?.url) {
86
+ parts.push({ type: "image_url", image_url: { url: p.image_url.url, detail: p.image_url.detail || "auto" } });
87
+ }
88
+ }
89
+ return parts.length > 0 ? parts : getTextContent(content);
90
+ }
91
+
92
+ /** Convert multimodal content to Gemini format parts */
93
+ function toGeminiParts(content: string | ContentPart[]): Array<Record<string, unknown>> {
94
+ if (typeof content === "string") return [{ text: content }];
95
+ const parts: Array<Record<string, unknown>> = [];
96
+ for (const p of content) {
97
+ if (p.type === "text" && p.text) {
98
+ parts.push({ text: p.text });
99
+ } else if (p.type === "image_url" && p.image_url?.url) {
100
+ const parsed = parseDataUrl(p.image_url.url);
101
+ if (parsed) {
102
+ parts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
103
+ }
104
+ }
105
+ }
106
+ return parts.length > 0 ? parts : [{ text: getTextContent(content) }];
107
+ }
108
+
109
+ // ─── Claude (Anthropic API) ─────────────────────────────────────────────────
110
+
111
+ export async function* streamClaude(
112
+ messages: ChatMessage[],
113
+ tools: any[], // OpenAI format tools
114
+ config: StreamProviderConfig,
115
+ ): AsyncGenerator<StreamEvent> {
116
+ const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
117
+ if (!apiKey) throw new Error("Anthropic API key required");
118
+
119
+ // Convert OpenAI tool format to Claude tool format
120
+ const claudeTools = tools.map(t => ({
121
+ name: t.function.name,
122
+ description: t.function.description,
123
+ input_schema: t.function.parameters,
124
+ }));
125
+
126
+ // Separate system message
127
+ const systemMsg = messages.filter(m => m.role === "system").map(m => getTextContent(m.content)).join("\n");
128
+ const chatMessages = messages.filter(m => m.role !== "system").map(m => {
129
+ if (m.role === "tool") {
130
+ return {
131
+ role: "user" as const,
132
+ content: [{
133
+ type: "tool_result" as const,
134
+ tool_use_id: m.tool_call_id || "",
135
+ content: getTextContent(m.content),
136
+ }],
137
+ };
138
+ }
139
+ if (m.role === "assistant" && m.tool_calls?.length) {
140
+ return {
141
+ role: "assistant" as const,
142
+ content: m.tool_calls.map(tc => ({
143
+ type: "tool_use" as const,
144
+ id: tc.id,
145
+ name: tc.function.name,
146
+ input: JSON.parse(tc.function.arguments),
147
+ })),
148
+ };
149
+ }
150
+ return { role: m.role as "user" | "assistant", content: toClaudeContent(m.content) };
151
+ });
152
+
153
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
154
+ method: "POST",
155
+ headers: {
156
+ "Content-Type": "application/json",
157
+ "x-api-key": apiKey,
158
+ "anthropic-version": "2023-06-01",
159
+ },
160
+ body: JSON.stringify({
161
+ model: config.model || "claude-sonnet-4-20250514",
162
+ max_tokens: config.maxTokens || 4096,
163
+ system: systemMsg || undefined,
164
+ messages: chatMessages,
165
+ tools: claudeTools.length > 0 ? claudeTools : undefined,
166
+ stream: true,
167
+ }),
168
+ });
169
+
170
+ if (!response.ok) {
171
+ const text = await response.text();
172
+ yield { type: "error", error: `Claude error ${response.status}: ${text}` };
173
+ return;
174
+ }
175
+
176
+ const reader = response.body?.getReader();
177
+ if (!reader) { yield { type: "error", error: "No response body" }; return; }
178
+
179
+ const decoder = new TextDecoder();
180
+ let buffer = "";
181
+ let currentToolUse: { id: string; name: string; argsJson: string } | null = null;
182
+
183
+ while (true) {
184
+ const { done, value } = await reader.read();
185
+ if (done) break;
186
+ buffer += decoder.decode(value, { stream: true });
187
+ const lines = buffer.split("\n");
188
+ buffer = lines.pop() || "";
189
+
190
+ for (const line of lines) {
191
+ if (!line.startsWith("data: ")) continue;
192
+ const data = line.slice(6).trim();
193
+ if (data === "[DONE]") { yield { type: "done" }; return; }
194
+ try {
195
+ const event = JSON.parse(data);
196
+ if (event.type === "content_block_start") {
197
+ if (event.content_block?.type === "tool_use") {
198
+ currentToolUse = { id: event.content_block.id, name: event.content_block.name, argsJson: "" };
199
+ }
200
+ } else if (event.type === "content_block_delta") {
201
+ if (event.delta?.type === "text_delta") {
202
+ yield { type: "text", content: event.delta.text };
203
+ } else if (event.delta?.type === "input_json_delta" && currentToolUse) {
204
+ currentToolUse.argsJson += event.delta.partial_json;
205
+ }
206
+ } else if (event.type === "content_block_stop" && currentToolUse) {
207
+ try {
208
+ const args = JSON.parse(currentToolUse.argsJson || "{}");
209
+ yield { type: "tool_call", name: currentToolUse.name, args, tool_call_id: currentToolUse.id };
210
+ } catch {
211
+ yield { type: "tool_call", name: currentToolUse.name, args: {}, tool_call_id: currentToolUse.id };
212
+ }
213
+ currentToolUse = null;
214
+ } else if (event.type === "message_stop") {
215
+ yield { type: "done" };
216
+ return;
217
+ }
218
+ } catch { /* skip */ }
219
+ }
220
+ }
221
+ yield { type: "done" };
222
+ }
223
+
224
+ // ─── OpenAI-compatible (OpenAI, OpenRouter, Ollama with /v1/chat/completions) ─
225
+
226
+ export async function* streamOpenAI(
227
+ messages: ChatMessage[],
228
+ tools: any[],
229
+ config: StreamProviderConfig,
230
+ ): AsyncGenerator<StreamEvent> {
231
+ let baseUrl: string;
232
+ let headers: Record<string, string> = { "Content-Type": "application/json" };
233
+
234
+ switch (config.provider) {
235
+ case "openai":
236
+ baseUrl = config.baseUrl || "https://api.openai.com/v1";
237
+ headers["Authorization"] = `Bearer ${config.apiKey || process.env.OPENAI_API_KEY}`;
238
+ break;
239
+ case "openrouter":
240
+ baseUrl = "https://openrouter.ai/api/v1";
241
+ headers["Authorization"] = `Bearer ${config.apiKey || process.env.OPENROUTER_API_KEY}`;
242
+ headers["HTTP-Referer"] = "https://heyhank.ai";
243
+ headers["X-Title"] = "HeyHank";
244
+ break;
245
+ case "ollama":
246
+ baseUrl = (config.baseUrl || "http://localhost:11434") + "/v1";
247
+ break;
248
+ default:
249
+ baseUrl = config.baseUrl || "https://api.openai.com/v1";
250
+ if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
251
+ }
252
+
253
+ // Convert messages to OpenAI format (with multimodal support)
254
+ const isOllama = config.provider === "ollama";
255
+ const openaiMessages = messages.map(m => {
256
+ if (isOllama && typeof m.content !== "string" && Array.isArray(m.content)) {
257
+ // Ollama uses `images` field for base64 images
258
+ const text = getTextContent(m.content);
259
+ const images: string[] = [];
260
+ for (const p of m.content) {
261
+ if (p.type === "image_url" && p.image_url?.url) {
262
+ const parsed = parseDataUrl(p.image_url.url);
263
+ if (parsed) images.push(parsed.data);
264
+ }
265
+ }
266
+ const msg: any = { role: m.role, content: text };
267
+ if (images.length > 0) msg.images = images;
268
+ if (m.tool_call_id) msg.tool_call_id = m.tool_call_id;
269
+ if (m.tool_calls) msg.tool_calls = m.tool_calls;
270
+ return msg;
271
+ }
272
+ const msg: any = { role: m.role, content: toOpenAIContent(m.content) };
273
+ if (m.tool_call_id) msg.tool_call_id = m.tool_call_id;
274
+ if (m.tool_calls) msg.tool_calls = m.tool_calls;
275
+ return msg;
276
+ });
277
+
278
+ const body: any = {
279
+ model: config.model,
280
+ messages: openaiMessages,
281
+ stream: true,
282
+ temperature: config.temperature ?? 0.7,
283
+ max_tokens: config.maxTokens ?? 4096,
284
+ };
285
+ if (tools.length > 0) body.tools = tools;
286
+
287
+ const response = await fetch(`${baseUrl}/chat/completions`, {
288
+ method: "POST",
289
+ headers,
290
+ body: JSON.stringify(body),
291
+ });
292
+
293
+ if (!response.ok) {
294
+ const text = await response.text();
295
+ yield { type: "error", error: `${config.provider} error ${response.status}: ${text}` };
296
+ return;
297
+ }
298
+
299
+ const reader = response.body?.getReader();
300
+ if (!reader) { yield { type: "error", error: "No response body" }; return; }
301
+
302
+ const decoder = new TextDecoder();
303
+ let buf = "";
304
+ const toolCalls: Map<number, { id: string; name: string; args: string }> = new Map();
305
+
306
+ while (true) {
307
+ const { done, value } = await reader.read();
308
+ if (done) break;
309
+ buf += decoder.decode(value, { stream: true });
310
+ const lines = buf.split("\n");
311
+ buf = lines.pop() || "";
312
+
313
+ for (const line of lines) {
314
+ if (!line.startsWith("data: ")) continue;
315
+ const data = line.slice(6).trim();
316
+ if (data === "[DONE]") {
317
+ // Emit any accumulated tool calls
318
+ for (const tc of toolCalls.values()) {
319
+ try {
320
+ const args = JSON.parse(tc.args || "{}");
321
+ yield { type: "tool_call", name: tc.name, args, tool_call_id: tc.id };
322
+ } catch {
323
+ yield { type: "tool_call", name: tc.name, args: {}, tool_call_id: tc.id };
324
+ }
325
+ }
326
+ yield { type: "done" };
327
+ return;
328
+ }
329
+ try {
330
+ const parsed = JSON.parse(data);
331
+ const delta = parsed.choices?.[0]?.delta;
332
+ if (!delta) continue;
333
+
334
+ if (delta.content) {
335
+ yield { type: "text", content: delta.content };
336
+ }
337
+
338
+ if (delta.tool_calls) {
339
+ for (const tc of delta.tool_calls) {
340
+ const idx = tc.index ?? 0;
341
+ if (!toolCalls.has(idx)) {
342
+ toolCalls.set(idx, { id: tc.id || `call_${idx}`, name: "", args: "" });
343
+ }
344
+ const entry = toolCalls.get(idx)!;
345
+ if (tc.id) entry.id = tc.id;
346
+ if (tc.function?.name) entry.name = tc.function.name;
347
+ if (tc.function?.arguments) entry.args += tc.function.arguments;
348
+ }
349
+ }
350
+
351
+ // Check finish_reason
352
+ const finishReason = parsed.choices?.[0]?.finish_reason;
353
+ if (finishReason === "tool_calls" || finishReason === "stop") {
354
+ for (const tc of toolCalls.values()) {
355
+ try {
356
+ const args = JSON.parse(tc.args || "{}");
357
+ yield { type: "tool_call", name: tc.name, args, tool_call_id: tc.id };
358
+ } catch {
359
+ yield { type: "tool_call", name: tc.name, args: {}, tool_call_id: tc.id };
360
+ }
361
+ }
362
+ toolCalls.clear();
363
+ if (finishReason === "stop") {
364
+ yield { type: "done" };
365
+ return;
366
+ }
367
+ }
368
+ } catch { /* skip */ }
369
+ }
370
+ }
371
+ yield { type: "done" };
372
+ }
373
+
374
+ // ─── Gemini Text (non-Live, REST streaming) ─────────────────────────────────
375
+
376
+ export async function* streamGeminiText(
377
+ messages: ChatMessage[],
378
+ tools: any[],
379
+ config: StreamProviderConfig,
380
+ ): AsyncGenerator<StreamEvent> {
381
+ const apiKey = config.apiKey || process.env.GEMINI_API_KEY;
382
+ if (!apiKey) throw new Error("Gemini API key required");
383
+
384
+ const model = config.model || "gemini-2.5-flash";
385
+
386
+ // Convert to Gemini format
387
+ const systemInstruction = messages
388
+ .filter(m => m.role === "system")
389
+ .map(m => getTextContent(m.content))
390
+ .join("\n");
391
+
392
+ const contents = messages
393
+ .filter(m => m.role !== "system")
394
+ .map(m => {
395
+ if (m.role === "tool") {
396
+ return {
397
+ role: "function" as const,
398
+ parts: [{
399
+ functionResponse: {
400
+ name: (m as any).name || m.tool_call_id || "unknown",
401
+ response: { result: safeJsonParse(getTextContent(m.content)) },
402
+ },
403
+ }],
404
+ };
405
+ }
406
+ if (m.role === "assistant" && m.tool_calls?.length) {
407
+ return {
408
+ role: "model" as const,
409
+ parts: m.tool_calls.map(tc => ({
410
+ functionCall: {
411
+ name: tc.function.name,
412
+ args: JSON.parse(tc.function.arguments || "{}"),
413
+ },
414
+ })),
415
+ };
416
+ }
417
+ return {
418
+ role: m.role === "assistant" ? "model" as const : "user" as const,
419
+ parts: toGeminiParts(m.content),
420
+ };
421
+ });
422
+
423
+ // Convert OpenAI tools to Gemini format
424
+ const geminiTools = tools.length > 0 ? [{
425
+ functionDeclarations: tools.map(t => ({
426
+ name: t.function.name,
427
+ description: t.function.description,
428
+ parameters: convertToGeminiSchema(t.function.parameters),
429
+ })),
430
+ }] : undefined;
431
+
432
+ const response = await fetch(
433
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}&alt=sse`,
434
+ {
435
+ method: "POST",
436
+ headers: { "Content-Type": "application/json" },
437
+ body: JSON.stringify({
438
+ contents,
439
+ systemInstruction: systemInstruction ? { parts: [{ text: systemInstruction }] } : undefined,
440
+ tools: geminiTools,
441
+ toolConfig: geminiTools ? { functionCallingConfig: { mode: "AUTO" } } : undefined,
442
+ generationConfig: {
443
+ temperature: config.temperature ?? 0.7,
444
+ maxOutputTokens: config.maxTokens ?? 8192,
445
+ },
446
+ }),
447
+ },
448
+ );
449
+
450
+ if (!response.ok) {
451
+ const text = await response.text();
452
+ yield { type: "error", error: `Gemini error ${response.status}: ${text}` };
453
+ return;
454
+ }
455
+
456
+ const reader = response.body?.getReader();
457
+ if (!reader) { yield { type: "error", error: "No response body" }; return; }
458
+
459
+ const decoder = new TextDecoder();
460
+ let buf = "";
461
+ let callCounter = 0;
462
+
463
+ while (true) {
464
+ const { done, value } = await reader.read();
465
+ if (done) break;
466
+ buf += decoder.decode(value, { stream: true });
467
+ const lines = buf.split("\n");
468
+ buf = lines.pop() || "";
469
+
470
+ for (const line of lines) {
471
+ if (!line.startsWith("data: ")) continue;
472
+ const data = line.slice(6).trim();
473
+ if (!data) continue;
474
+ try {
475
+ const parsed = JSON.parse(data);
476
+
477
+ // Check for API-level errors (e.g. safety block, invalid request)
478
+ if (parsed.error) {
479
+ yield { type: "error", error: `Gemini API error: ${parsed.error.message || JSON.stringify(parsed.error)}` };
480
+ return;
481
+ }
482
+
483
+ const candidate = parsed.candidates?.[0];
484
+ const finishReason = candidate?.finishReason;
485
+ const parts = candidate?.content?.parts;
486
+
487
+ // Handle finish reasons that indicate the response is done or blocked
488
+ if (finishReason && finishReason !== "STOP") {
489
+ if (finishReason === "MAX_TOKENS") {
490
+ yield { type: "text", content: "\n\n[Antwort wurde wegen Token-Limit abgeschnitten]" };
491
+ } else if (finishReason === "SAFETY") {
492
+ yield { type: "error", error: "Gemini hat die Antwort aus Sicherheitsgründen blockiert." };
493
+ return;
494
+ } else if (finishReason === "RECITATION") {
495
+ yield { type: "error", error: "Gemini hat die Antwort wegen Urheberrechtsbedenken blockiert." };
496
+ return;
497
+ }
498
+ }
499
+
500
+ if (!parts || parts.length === 0) continue;
501
+ for (const part of parts) {
502
+ if (part.text) {
503
+ yield { type: "text", content: part.text };
504
+ }
505
+ if (part.functionCall) {
506
+ yield {
507
+ type: "tool_call",
508
+ name: part.functionCall.name,
509
+ args: part.functionCall.args || {},
510
+ tool_call_id: `gemini_call_${callCounter++}`,
511
+ };
512
+ }
513
+ }
514
+ } catch (err) {
515
+ console.error("[Gemini stream] Failed to parse chunk:", err, "raw:", data.substring(0, 200));
516
+ }
517
+ }
518
+ }
519
+ yield { type: "done" };
520
+ }
521
+
522
+ function safeJsonParse(s: string): unknown {
523
+ try { return JSON.parse(s); } catch { return s; }
524
+ }
525
+
526
+ function convertToGeminiSchema(schema: any): any {
527
+ if (!schema) return { type: "OBJECT", properties: {} };
528
+ const result: any = {};
529
+ result.type = (schema.type || "object").toUpperCase();
530
+ if (schema.properties) {
531
+ result.properties = {};
532
+ for (const [key, val] of Object.entries(schema.properties)) {
533
+ result.properties[key] = convertToGeminiSchema(val);
534
+ }
535
+ }
536
+ if (schema.required) result.required = schema.required;
537
+ if (schema.description) result.description = schema.description;
538
+ if (schema.enum) result.enum = schema.enum;
539
+ if (schema.items) result.items = convertToGeminiSchema(schema.items);
540
+ return result;
541
+ }
@@ -64,6 +64,12 @@ async function callOllama(
64
64
  config: LLMProviderConfig,
65
65
  ): Promise<LLMResponse> {
66
66
  const baseUrl = config.baseUrl || "http://localhost:11434";
67
+
68
+ // Warn about insecure remote Ollama URLs
69
+ if (baseUrl.startsWith("http://") && !baseUrl.includes("localhost") && !baseUrl.includes("127.0.0.1") && !baseUrl.includes(".ts.net")) {
70
+ console.warn(`[llm-providers] WARNING: Ollama URL "${baseUrl}" uses HTTP over a potentially public network. Consider using Tailscale (.ts.net) for secure remote access.`);
71
+ }
72
+
67
73
  const response = await fetch(`${baseUrl}/api/chat`, {
68
74
  method: "POST",
69
75
  headers: { "Content-Type": "application/json" },
@@ -112,6 +118,12 @@ export async function* streamOllama(
112
118
  config: LLMProviderConfig,
113
119
  ): AsyncGenerator<LLMStreamChunk> {
114
120
  const baseUrl = config.baseUrl || "http://localhost:11434";
121
+
122
+ // Warn about insecure remote Ollama URLs
123
+ if (baseUrl.startsWith("http://") && !baseUrl.includes("localhost") && !baseUrl.includes("127.0.0.1") && !baseUrl.includes(".ts.net")) {
124
+ console.warn(`[llm-providers] WARNING: Ollama URL "${baseUrl}" uses HTTP over a potentially public network. Consider using Tailscale (.ts.net) for secure remote access.`);
125
+ }
126
+
115
127
  const response = await fetch(`${baseUrl}/api/chat`, {
116
128
  method: "POST",
117
129
  headers: { "Content-Type": "application/json" },