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
@@ -0,0 +1,592 @@
1
+ // ─── Hank Chat Routes ───────────────────────────────────────────────────────
2
+ // POST /api/hank/chat — SSE streaming chat with server-side tool loop
3
+ // GET /api/hank/chat/config — Returns available providers + tool declarations
4
+
5
+ import type { Hono } from "hono";
6
+ import { streamSSE } from "hono/streaming";
7
+ import { getSettings } from "../settings-manager.js";
8
+ import { buildSystemPrompt, getToolDeclarationsOpenAI, getToolDeclarationsGemini } from "../hank-tools.js";
9
+ import type { AgentInfo } from "../hank-tools.js";
10
+ import { executeHankTool } from "../hank-tool-executor.js";
11
+ import { streamClaude, streamOpenAI, streamGeminiText } from "../llm-providers-streaming.js";
12
+ import type { ChatMessage, ContentPart, StreamEvent, StreamProviderConfig } from "../llm-providers-streaming.js";
13
+ import { listAgents } from "../agent-store.js";
14
+ import * as assistantStore from "../assistant-store.js";
15
+ import { nodeManager } from "../federation/node-manager.js";
16
+ import { getContextForMessage, detectMemorableFacts, addMemory } from "../memory-service.js";
17
+ import { callLLM } from "../llm-providers.js";
18
+ import { heyHankBus } from "../event-bus.js";
19
+ import { listPending, markConsumed, getById } from "../hank-notifications-store.js";
20
+ import { randomUUID } from "node:crypto";
21
+ import { isAllowedBaseUrl } from "../url-validator.js";
22
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
23
+ import { join, basename } from "node:path";
24
+ import { homedir } from "node:os";
25
+
26
+ const MAX_TOOL_ROUNDS = 10;
27
+
28
+ export function registerHankChatRoutes(api: Hono): void {
29
+
30
+ // GET /hank/chat/config — Provider list + current settings
31
+ api.get("/hank/chat/config", async (c) => {
32
+ const settings = getSettings();
33
+ return c.json({
34
+ currentProvider: settings.hankChatProvider || "gemini-live",
35
+ currentModel: settings.hankChatModel || "",
36
+ providers: [
37
+ { id: "gemini-live", name: "Gemini Live", type: "voice", requiresKey: "geminiApiKey" },
38
+ { id: "claude", name: "Claude", type: "text", requiresKey: "anthropicApiKey" },
39
+ { id: "openai", name: "OpenAI", type: "text", requiresKey: "openaiApiKey" },
40
+ { id: "ollama", name: "Ollama", type: "text", requiresKey: null },
41
+ { id: "openrouter", name: "OpenRouter", type: "text", requiresKey: null },
42
+ { id: "gemini-text", name: "Gemini", type: "text", requiresKey: "geminiApiKey" },
43
+ ],
44
+ toolDeclarationsGemini: getToolDeclarationsGemini(),
45
+ toolDeclarationsOpenAI: getToolDeclarationsOpenAI(),
46
+ });
47
+ });
48
+
49
+ // ─── File Upload for HankChat ─────────────────────────────────────────────
50
+
51
+ const UPLOADS_DIR = join(homedir(), ".heyhank", "uploads");
52
+
53
+ // POST /hank/chat/upload — Accept file upload, return URL + metadata
54
+ api.post("/hank/chat/upload", async (c) => {
55
+ try {
56
+ const body = await c.req.parseBody();
57
+ const file = body["file"];
58
+ if (!file || typeof file === "string") {
59
+ return c.json({ error: "file field required" }, 400);
60
+ }
61
+ mkdirSync(UPLOADS_DIR, { recursive: true });
62
+ const ext = (file.name || "file").split(".").pop() || "bin";
63
+ const id = randomUUID();
64
+ const filename = `${id}.${ext}`;
65
+ const filepath = join(UPLOADS_DIR, filename);
66
+ const buffer = Buffer.from(await file.arrayBuffer());
67
+ writeFileSync(filepath, buffer);
68
+ return c.json({
69
+ url: `/api/hank/chat/media/${id}.${ext}`,
70
+ absolutePath: filepath,
71
+ mimeType: file.type || "application/octet-stream",
72
+ name: file.name || filename,
73
+ size: buffer.byteLength,
74
+ });
75
+ } catch (err) {
76
+ return c.json({ error: err instanceof Error ? err.message : "Upload failed" }, 500);
77
+ }
78
+ });
79
+
80
+ // GET /hank/chat/media/:filename — Serve uploaded files
81
+ api.get("/hank/chat/media/:filename", (c) => {
82
+ const filename = basename(c.req.param("filename"));
83
+ const filepath = join(UPLOADS_DIR, filename);
84
+ if (!existsSync(filepath)) {
85
+ return c.json({ error: "File not found" }, 404);
86
+ }
87
+ const data = readFileSync(filepath);
88
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
89
+ const mimeMap: Record<string, string> = {
90
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
91
+ webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
92
+ mp4: "video/mp4", webm: "video/webm", txt: "text/plain",
93
+ };
94
+ const contentType = mimeMap[ext] || "application/octet-stream";
95
+ return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=86400" } });
96
+ });
97
+
98
+ // ─── Notifications (async events for HankChat, e.g. call-ended) ──────────
99
+
100
+ // GET /hank/notifications/pending — returns unconsumed notifications
101
+ api.get("/hank/notifications/pending", (c) => {
102
+ return c.json({ notifications: listPending() });
103
+ });
104
+
105
+ // GET /hank/notifications/:id — full notification incl. transcript
106
+ api.get("/hank/notifications/:id", (c) => {
107
+ const id = c.req.param("id");
108
+ const n = getById(id);
109
+ if (!n) return c.json({ error: "Notification not found" }, 404);
110
+ return c.json(n);
111
+ });
112
+
113
+ // POST /hank/notifications/:id/consume — mark as consumed
114
+ api.post("/hank/notifications/:id/consume", (c) => {
115
+ const id = c.req.param("id");
116
+ const ok = markConsumed(id);
117
+ if (!ok) return c.json({ error: "Notification not found" }, 404);
118
+ return c.json({ success: true });
119
+ });
120
+
121
+ // GET /hank/notifications/stream — SSE push channel for live notifications
122
+ api.get("/hank/notifications/stream", (c) => {
123
+ return streamSSE(c, async (stream) => {
124
+ // Send any currently pending notifications immediately on connect
125
+ for (const n of listPending()) {
126
+ await stream.writeSSE({ event: "pending", data: JSON.stringify(n) });
127
+ }
128
+
129
+ // Subscribe to future events
130
+ const queue: unknown[] = [];
131
+ let resolveNext: (() => void) | null = null;
132
+ const unsubscribe = heyHankBus.on("telephony:call-ended", (payload) => {
133
+ queue.push({ type: "call-ended", ...payload });
134
+ if (resolveNext) { resolveNext(); resolveNext = null; }
135
+ });
136
+
137
+ const abort = c.req.raw.signal;
138
+ const onAbort = () => {
139
+ if (resolveNext) { resolveNext(); resolveNext = null; }
140
+ };
141
+ abort.addEventListener("abort", onAbort);
142
+
143
+ try {
144
+ while (!abort.aborted) {
145
+ if (queue.length === 0) {
146
+ await new Promise<void>((resolve) => { resolveNext = resolve; });
147
+ continue;
148
+ }
149
+ const ev = queue.shift();
150
+ await stream.writeSSE({ event: "call-ended", data: JSON.stringify(ev) });
151
+ }
152
+ } finally {
153
+ unsubscribe();
154
+ abort.removeEventListener("abort", onAbort);
155
+ }
156
+ });
157
+ });
158
+
159
+ // POST /hank/chat — SSE streaming with server-side tool loop
160
+ api.post("/hank/chat", async (c) => {
161
+ const body = await c.req.json().catch(() => ({} as any));
162
+ const {
163
+ messages: clientMessages,
164
+ provider: providerName,
165
+ model: requestedModel,
166
+ apiKey: requestedApiKey,
167
+ baseUrl: requestedBaseUrl,
168
+ } = body as {
169
+ messages: Array<{ role: string; content: string | ContentPart[] }>;
170
+ provider: string;
171
+ model?: string;
172
+ apiKey?: string;
173
+ baseUrl?: string;
174
+ };
175
+
176
+ if (!clientMessages || !Array.isArray(clientMessages)) {
177
+ return c.json({ error: "messages array required" }, 400);
178
+ }
179
+
180
+ if (requestedBaseUrl && !isAllowedBaseUrl(requestedBaseUrl)) {
181
+ return c.json({ error: "baseUrl points to a disallowed internal address" }, 400);
182
+ }
183
+
184
+ const settings = getSettings();
185
+ const authHeader = c.req.header("Authorization") || "";
186
+
187
+ // Build system prompt with current context
188
+ const agents: AgentInfo[] = listAgents().map(a => ({
189
+ id: a.id, name: a.name, description: a.description, backend: a.backendType,
190
+ }));
191
+ const remoteAgents = nodeManager.getRemoteAgents().map(ra => ({
192
+ id: ra.id, name: ra.name, description: ra.description || "", backend: ra.backendType || "claude",
193
+ }));
194
+ const recentNotes = assistantStore.listNotes("gemini-live").slice(-3);
195
+
196
+ let activeSessions: Array<{ sessionId: string; state: string; model?: string; agentName?: string; cwd?: string }> = [];
197
+ try {
198
+ const port = process.env.PORT || 3100;
199
+ const sessResp = await fetch(`http://127.0.0.1:${port}/api/sessions`, {
200
+ headers: authHeader ? { Authorization: authHeader } : {},
201
+ });
202
+ if (sessResp.ok) {
203
+ const all = await sessResp.json() as any[];
204
+ activeSessions = all.filter(s => s.state !== "exited").map(s => ({
205
+ sessionId: s.sessionId, state: s.state, model: s.model, agentName: s.agentName, cwd: s.cwd,
206
+ }));
207
+ }
208
+ } catch { /* ignore */ }
209
+
210
+ let contacts: Array<{ name: string; phone: string; notes?: string }> = [];
211
+ try {
212
+ const telStore = await import("../telephony/telephony-store.js");
213
+ contacts = telStore.getContacts().map(c => ({ name: c.name, phone: c.phone, notes: c.notes }));
214
+ } catch { /* ignore */ }
215
+
216
+ // Inject memory context from Mem0 or local fallback
217
+ let memoryContext = "";
218
+ try {
219
+ const lastUserMsg = clientMessages.filter(m => m.role === "user").pop();
220
+ const lastUserText = typeof lastUserMsg?.content === "string"
221
+ ? lastUserMsg.content
222
+ : lastUserMsg?.content?.filter(p => p.type === "text").map(p => p.text || "").join("") || "";
223
+ if (lastUserText) {
224
+ memoryContext = await getContextForMessage(lastUserText);
225
+ }
226
+ } catch { /* ignore memory errors */ }
227
+
228
+ let systemPrompt = buildSystemPrompt(
229
+ settings.assistantName || "",
230
+ [...agents, ...remoteAgents],
231
+ recentNotes.map(n => ({ title: n.title, content: n.content })),
232
+ activeSessions,
233
+ settings.userName || "",
234
+ contacts,
235
+ settings.obsidianVaultPath || undefined,
236
+ );
237
+
238
+ if (memoryContext) {
239
+ systemPrompt += `\n\nUSER MEMORY CONTEXT:\n${memoryContext}`;
240
+ }
241
+
242
+ // Track uploaded files from conversation for agent sessions
243
+ const uploadedFiles: Array<{ name: string; path: string }> = [];
244
+ for (const msg of clientMessages) {
245
+ if (typeof msg.content !== "string" && Array.isArray(msg.content)) {
246
+ for (const part of msg.content) {
247
+ if (part.type === "image_url" && part.image_url?.url?.startsWith("/api/hank/chat/media/")) {
248
+ const filename = part.image_url.url.split("/").pop() || "";
249
+ const absPath = join(UPLOADS_DIR, filename);
250
+ if (existsSync(absPath)) {
251
+ uploadedFiles.push({ name: filename, path: absPath });
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ if (uploadedFiles.length > 0) {
258
+ systemPrompt += `\n\nUPLOADED FILES:\nThe user has uploaded the following files: ${uploadedFiles.map(f => `${f.name} (accessible at ${f.path})`).join(", ")}. Reference these when relevant.`;
259
+ }
260
+
261
+ // ─── Active Skill Re-injection ────────────────────────────────────────
262
+ // Tool calls and tool_results are NOT persisted across HTTP turns
263
+ // (clientMessages only carries role+content). When a prior turn invoked
264
+ // run_skill, the SKILL.md content is gone from context on the next turn,
265
+ // so the LLM forgets the workflow and stalls on continuation phrases like
266
+ // "mache weiter". We mandate that every stage output begins with a
267
+ // marker [skill:<slug> stage:N/TOTAL] (see hank-tool-executor.ts run_skill
268
+ // instruction), and re-inject the skill content here when found.
269
+ try {
270
+ const markerRe = /\[skill:([a-zA-Z0-9._-]+)\s+stage:(\d+)\/(\d+)\]/;
271
+ let activeSlug = "";
272
+ let lastStage = 0;
273
+ let totalStages = 0;
274
+ // Walk assistant messages from newest to oldest; first marker wins.
275
+ for (let i = clientMessages.length - 1; i >= 0; i--) {
276
+ const m = clientMessages[i];
277
+ if (m.role !== "assistant") continue;
278
+ const text = typeof m.content === "string"
279
+ ? m.content
280
+ : (m.content?.filter(p => p.type === "text").map(p => p.text || "").join("\n") || "");
281
+ const match = text.match(markerRe);
282
+ if (match) {
283
+ activeSlug = match[1];
284
+ lastStage = parseInt(match[2], 10);
285
+ totalStages = parseInt(match[3], 10);
286
+ break;
287
+ }
288
+ }
289
+ if (activeSlug) {
290
+ const { readSkillContent } = await import("../skill-discovery.js");
291
+ const skillContent = readSkillContent(activeSlug);
292
+ if (skillContent) {
293
+ const nextStage = lastStage + 1;
294
+ systemPrompt += `\n\nACTIVE SKILL CONTEXT (re-injected — do not call run_skill again):\n`
295
+ + `You are currently executing the skill "${activeSlug}". `
296
+ + `The most recent stage produced was ${lastStage}/${totalStages}.\n\n`
297
+ + `If the user's latest message is a continuation signal (weiter / mache weiter / next / continue / fortfahren / ja / proceed / ok), `
298
+ + `IMMEDIATELY produce stage ${nextStage}/${totalStages}'s output following the skill instructions below. `
299
+ + `Begin the response with the marker line "[skill:${activeSlug} stage:${nextStage}/${totalStages}]" exactly, then a blank line, then the stage content.\n\n`
300
+ + `If the user asks to revise the previous stage, redo stage ${lastStage}/${totalStages} with the same marker.\n\n`
301
+ + `If the user asks something off-topic, answer naturally (the skill is paused, not aborted).\n\n`
302
+ + `DO NOT call run_skill again — the skill is already loaded. The skill's full SKILL.md follows:\n\n`
303
+ + `--- BEGIN SKILL.md (${activeSlug}) ---\n`
304
+ + skillContent
305
+ + `\n--- END SKILL.md ---`;
306
+ console.log(`[hank-chat] Re-injected skill context: ${activeSlug} (last stage ${lastStage}/${totalStages})`);
307
+ }
308
+ }
309
+ } catch (err) {
310
+ console.log(`[hank-chat] Skill re-injection failed: ${err}`);
311
+ }
312
+
313
+ // Resolve provider config
314
+ const providerConfig: StreamProviderConfig = {
315
+ provider: providerName as any,
316
+ model: requestedModel || settings.hankChatModel || getDefaultModel(providerName),
317
+ apiKey: requestedApiKey || getApiKey(providerName, settings),
318
+ baseUrl: requestedBaseUrl,
319
+ temperature: 0.7,
320
+ maxTokens: 4096,
321
+ };
322
+
323
+ const tools = getToolDeclarationsOpenAI();
324
+
325
+ // Build initial messages with system prompt
326
+ const chatMessages: ChatMessage[] = [
327
+ { role: "system", content: systemPrompt },
328
+ ...clientMessages.map(m => ({
329
+ role: m.role as ChatMessage["role"],
330
+ content: m.content,
331
+ })),
332
+ ];
333
+
334
+ // SSE streaming response with tool loop
335
+ return streamSSE(c, async (stream) => {
336
+ // Subscribe to session lifecycle events and forward them to the SSE stream
337
+ const unsubPhase = heyHankBus.on("session:phase-changed", async (payload) => {
338
+ try {
339
+ await stream.writeSSE({ data: JSON.stringify({
340
+ type: "session_event",
341
+ sessionId: payload.sessionId,
342
+ event: "phase_changed",
343
+ from: payload.from,
344
+ to: payload.to,
345
+ }) });
346
+ } catch { /* stream may be closed */ }
347
+ });
348
+ const unsubExited = heyHankBus.on("session:exited", async (payload) => {
349
+ try {
350
+ await stream.writeSSE({ data: JSON.stringify({
351
+ type: "session_event",
352
+ sessionId: payload.sessionId,
353
+ event: "exited",
354
+ exitCode: payload.exitCode,
355
+ }) });
356
+ } catch { /* stream may be closed */ }
357
+ });
358
+
359
+ // Ensure cleanup when stream closes
360
+ stream.onAbort(() => { unsubPhase(); unsubExited(); });
361
+
362
+ let messages = [...chatMessages];
363
+ let toolRound = 0;
364
+
365
+ while (toolRound < MAX_TOOL_ROUNDS) {
366
+ const streamFn = getStreamFunction(providerName);
367
+ const events: StreamEvent[] = [];
368
+ let hasToolCalls = false;
369
+
370
+ try {
371
+ for await (const event of streamFn(messages, tools, providerConfig)) {
372
+ events.push(event);
373
+
374
+ if (event.type === "text") {
375
+ await stream.writeSSE({ data: JSON.stringify(event) });
376
+ } else if (event.type === "tool_call") {
377
+ hasToolCalls = true;
378
+ await stream.writeSSE({ data: JSON.stringify(event) });
379
+ } else if (event.type === "error") {
380
+ await stream.writeSSE({ data: JSON.stringify(event) });
381
+ return;
382
+ }
383
+ }
384
+ } catch (err) {
385
+ await stream.writeSSE({ data: JSON.stringify({
386
+ type: "error",
387
+ error: err instanceof Error ? err.message : String(err),
388
+ }) });
389
+ return;
390
+ }
391
+
392
+ if (!hasToolCalls) {
393
+ console.log(`[hank-chat] Round ${toolRound}: No tool calls — LLM responded with text only`);
394
+
395
+ // Fallback: detect if user wanted agent delegation but LLM didn't call the tool
396
+ if (toolRound === 0) {
397
+ const lastUserMsg = clientMessages.filter(m => m.role === "user").pop();
398
+ const lastUserText = typeof lastUserMsg?.content === "string"
399
+ ? lastUserMsg.content.toLowerCase()
400
+ : (lastUserMsg?.content?.filter(p => p.type === "text").map(p => (p.text || "").toLowerCase()).join(" ") || "");
401
+
402
+ // Check if the conversation context implies agent delegation
403
+ const prevAssistantMsgs = clientMessages.filter(m => m.role === "assistant");
404
+ const prevAssistantText = prevAssistantMsgs.length > 0
405
+ ? (typeof prevAssistantMsgs[prevAssistantMsgs.length - 1].content === "string"
406
+ ? (prevAssistantMsgs[prevAssistantMsgs.length - 1].content as string).toLowerCase()
407
+ : "")
408
+ : "";
409
+
410
+ const userWantsAgent = /\bagent\b|\bbeauftrag/.test(lastUserText);
411
+ const contextSuggestsPost = /\bpost\b|\bdraft\b|\bcontent\b|\bsocial\b|\bentwu?r?f/.test(lastUserText) ||
412
+ /\bagent.*beauftrag|\bbeauftrag.*agent|\bselbst.*erstellen.*agent/.test(prevAssistantText);
413
+
414
+ if (userWantsAgent && (contextSuggestsPost || prevAssistantText.includes("agent"))) {
415
+ console.log(`[hank-chat] Fallback: User wants agent delegation but LLM didn't call run_agent — triggering manually`);
416
+
417
+ // Gather conversation context for the agent task
418
+ const allUserTexts = clientMessages
419
+ .filter(m => m.role === "user")
420
+ .map(m => typeof m.content === "string" ? m.content : m.content?.filter(p => p.type === "text").map(p => p.text || "").join(" ") || "")
421
+ .join("\n\n");
422
+
423
+ const taskDescription = `Erstelle Social Media Posts basierend auf folgendem Kontext:\n\n${allUserTexts}\n\nErstelle plattform-optimierte Drafts (Facebook, Instagram). Generiere passende Bilder mit imagen. Speichere alles als Drafts.`;
424
+
425
+ try {
426
+ // Keep-alive during long-running agent execution
427
+ const fallbackKeepAlive = setInterval(async () => {
428
+ try { await stream.writeSSE({ data: JSON.stringify({ type: "keep_alive" }) }); } catch {}
429
+ }, 15_000);
430
+ let result: unknown;
431
+ try {
432
+ result = await executeHankTool("run_agent", { agent: "Content Agent", task: taskDescription }, authHeader);
433
+ } finally {
434
+ clearInterval(fallbackKeepAlive);
435
+ }
436
+ await stream.writeSSE({ data: JSON.stringify({
437
+ type: "tool_call",
438
+ name: "run_agent",
439
+ args: { agent: "Content Agent", task: taskDescription },
440
+ tool_call_id: "fallback_agent_0",
441
+ }) });
442
+ await stream.writeSSE({ data: JSON.stringify({
443
+ type: "tool_result",
444
+ name: "run_agent",
445
+ tool_call_id: "fallback_agent_0",
446
+ result,
447
+ }) });
448
+
449
+ // Send a follow-up text message
450
+ await stream.writeSSE({ data: JSON.stringify({
451
+ type: "text",
452
+ content: "\n\nIch habe den Content Agent gestartet. Er erstellt jetzt die Posts und generiert Bilder. Du kannst den Fortschritt auf der Agents-Seite verfolgen.",
453
+ }) });
454
+ } catch (err) {
455
+ console.error(`[hank-chat] Fallback agent call failed:`, err);
456
+ await stream.writeSSE({ data: JSON.stringify({
457
+ type: "text",
458
+ content: "\n\nIch konnte den Agent leider nicht starten. Soll ich die Posts stattdessen selbst als Drafts erstellen?",
459
+ }) });
460
+ }
461
+ }
462
+ }
463
+
464
+ // Auto-detect memorable facts
465
+ try {
466
+ const geminiKey = settings.geminiApiKey || process.env.GEMINI_API_KEY;
467
+ if (geminiKey) {
468
+ const llmCallFn = async (sys: string, usr: string) => {
469
+ const r = await callLLM(
470
+ [{ role: "system", content: sys }, { role: "user", content: usr }],
471
+ { provider: "gemini", model: "gemini-2.5-flash", apiKey: geminiKey, temperature: 0.3, maxTokens: 1024 },
472
+ );
473
+ return r.content;
474
+ };
475
+ const textOnlyMessages = clientMessages.map(m => ({
476
+ role: m.role,
477
+ content: typeof m.content === "string" ? m.content : m.content.filter(p => p.type === "text").map(p => p.text || "").join(""),
478
+ }));
479
+ const facts = await detectMemorableFacts(textOnlyMessages, llmCallFn);
480
+ for (const fact of facts) {
481
+ const memory = await addMemory(fact.fact, { category: fact.category, source: "auto-detect" });
482
+ await stream.writeSSE({ data: JSON.stringify({ type: "memory_added", id: memory.id, fact: fact.fact, category: fact.category }) });
483
+ }
484
+ }
485
+ } catch (err) {
486
+ console.log(`[hank-chat] Memory detection failed: ${err}`);
487
+ }
488
+ await stream.writeSSE({ data: JSON.stringify({ type: "done" }) });
489
+ return;
490
+ }
491
+
492
+ // Execute tool calls and feed results back
493
+ const toolCallEvents = events.filter(e => e.type === "tool_call");
494
+ console.log(`[hank-chat] Round ${toolRound}: ${toolCallEvents.length} tool call(s): ${toolCallEvents.map(t => t.name).join(", ")}`);
495
+ const textEvents = events.filter(e => e.type === "text");
496
+ const assistantText = textEvents.map(e => e.content || "").join("");
497
+
498
+ // Add assistant message with tool calls
499
+ const assistantToolCalls = toolCallEvents.map((tc, i) => ({
500
+ id: tc.tool_call_id || `call_${toolRound}_${i}`,
501
+ type: "function" as const,
502
+ function: {
503
+ name: tc.name || "",
504
+ arguments: JSON.stringify(tc.args || {}),
505
+ },
506
+ }));
507
+
508
+ messages.push({
509
+ role: "assistant",
510
+ content: assistantText,
511
+ tool_calls: assistantToolCalls,
512
+ });
513
+
514
+ // Execute each tool call
515
+ for (const tc of toolCallEvents) {
516
+ const toolName = tc.name || "";
517
+ const toolArgs = tc.args || {};
518
+ const toolId = tc.tool_call_id || `call_${toolRound}`;
519
+
520
+ // For long-running tools (run_agent), send SSE keep-alives to prevent browser timeout
521
+ let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
522
+ if (toolName === "run_agent") {
523
+ keepAliveInterval = setInterval(async () => {
524
+ try {
525
+ await stream.writeSSE({ data: JSON.stringify({ type: "keep_alive" }) });
526
+ } catch { /* stream closed */ }
527
+ }, 15_000); // every 15s
528
+ }
529
+
530
+ let result: unknown;
531
+ try {
532
+ result = await executeHankTool(toolName, toolArgs, authHeader);
533
+ } finally {
534
+ if (keepAliveInterval) clearInterval(keepAliveInterval);
535
+ }
536
+
537
+ await stream.writeSSE({ data: JSON.stringify({
538
+ type: "tool_result",
539
+ name: toolName,
540
+ tool_call_id: toolId,
541
+ result,
542
+ }) });
543
+
544
+ messages.push({
545
+ role: "tool",
546
+ content: JSON.stringify(result),
547
+ tool_call_id: toolId,
548
+ name: toolName,
549
+ });
550
+ }
551
+
552
+ toolRound++;
553
+ }
554
+
555
+ // Max rounds reached
556
+ await stream.writeSSE({ data: JSON.stringify({ type: "done" }) });
557
+ });
558
+ });
559
+ }
560
+
561
+ function getDefaultModel(provider: string): string {
562
+ switch (provider) {
563
+ case "claude": return "claude-sonnet-4-20250514";
564
+ case "openai": return "gpt-4o";
565
+ case "ollama": return "llama3.2";
566
+ case "openrouter": return "anthropic/claude-sonnet-4-20250514";
567
+ case "gemini-text": return "gemini-2.5-flash";
568
+ default: return "";
569
+ }
570
+ }
571
+
572
+ function getApiKey(provider: string, settings: any): string {
573
+ switch (provider) {
574
+ case "claude": return settings.anthropicApiKey || process.env.ANTHROPIC_API_KEY || "";
575
+ case "openai": return settings.openaiApiKey || process.env.OPENAI_API_KEY || "";
576
+ case "gemini-text": return settings.geminiApiKey || process.env.GEMINI_API_KEY || "";
577
+ case "openrouter": return process.env.OPENROUTER_API_KEY || "";
578
+ default: return "";
579
+ }
580
+ }
581
+
582
+ function getStreamFunction(provider: string) {
583
+ switch (provider) {
584
+ case "claude": return streamClaude;
585
+ case "gemini-text": return streamGeminiText;
586
+ case "openai":
587
+ case "openrouter":
588
+ case "ollama":
589
+ default:
590
+ return streamOpenAI;
591
+ }
592
+ }
@@ -10,6 +10,7 @@ import {
10
10
  pullOllamaModel,
11
11
  } from "../llm-providers.js";
12
12
  import type { LLMProviderConfig, LLMMessage } from "../llm-providers.js";
13
+ import { isAllowedBaseUrl } from "../url-validator.js";
13
14
 
14
15
  export function registerLLMRoutes(api: Hono): void {
15
16
  /** Call an LLM provider (non-streaming) */
@@ -23,6 +24,10 @@ export function registerLLMRoutes(api: Hono): void {
23
24
  );
24
25
  }
25
26
 
27
+ if (body.baseUrl && !isAllowedBaseUrl(body.baseUrl)) {
28
+ return c.json({ error: "baseUrl points to a disallowed internal address" }, 400);
29
+ }
30
+
26
31
  const config: LLMProviderConfig = {
27
32
  provider: body.provider,
28
33
  model: body.model,
@@ -59,6 +64,13 @@ export function registerLLMRoutes(api: Hono): void {
59
64
  return;
60
65
  }
61
66
 
67
+ if (body.baseUrl && !isAllowedBaseUrl(body.baseUrl as string)) {
68
+ await stream.writeSSE({
69
+ data: JSON.stringify({ error: "baseUrl points to a disallowed internal address" }),
70
+ });
71
+ return;
72
+ }
73
+
62
74
  const config: LLMProviderConfig = {
63
75
  provider: (body.provider as LLMProviderConfig["provider"]) || "ollama",
64
76
  model: body.model as string,
@@ -0,0 +1,63 @@
1
+ import type { Hono } from "hono";
2
+ import {
3
+ BUILTIN_SOURCES,
4
+ getSource,
5
+ installSkill,
6
+ isValidSlug,
7
+ listSkills,
8
+ readInstalledMeta,
9
+ } from "../marketplace.js";
10
+
11
+ export function registerMarketplaceRoutes(api: Hono): void {
12
+ // List all configured marketplace sources.
13
+ api.get("/marketplace/sources", (c) => {
14
+ return c.json(
15
+ BUILTIN_SOURCES.map((s) => ({
16
+ id: s.id,
17
+ name: s.name,
18
+ owner: s.owner,
19
+ url: s.url,
20
+ description: s.description ?? "",
21
+ })),
22
+ );
23
+ });
24
+
25
+ // List skills available in a given source (live fetch from GitHub).
26
+ api.get("/marketplace/sources/:id/skills", async (c) => {
27
+ const source = getSource(c.req.param("id"));
28
+ if (!source) return c.json({ error: "Source not found" }, 404);
29
+ try {
30
+ const skills = await listSkills(source);
31
+ return c.json(skills);
32
+ } catch (e) {
33
+ return c.json({ error: String(e instanceof Error ? e.message : e) }, 502);
34
+ }
35
+ });
36
+
37
+ // Install a skill from a source into ~/.claude/skills/<slug>/.
38
+ api.post("/marketplace/install", async (c) => {
39
+ const body = await c.req.json().catch(() => ({}));
40
+ const sourceId = typeof body.sourceId === "string" ? body.sourceId : "";
41
+ const slug = typeof body.slug === "string" ? body.slug : "";
42
+ const overwrite = body.overwrite === true;
43
+ const source = getSource(sourceId);
44
+ if (!source) return c.json({ error: "Source not found" }, 404);
45
+ if (!isValidSlug(slug)) return c.json({ error: "Invalid slug" }, 400);
46
+ try {
47
+ const result = await installSkill(source, slug, { overwrite });
48
+ return c.json({ ok: true, ...result });
49
+ } catch (e) {
50
+ const msg = e instanceof Error ? e.message : String(e);
51
+ const status = /already installed/i.test(msg) ? 409 : 500;
52
+ return c.json({ error: msg }, status);
53
+ }
54
+ });
55
+
56
+ // Returns marketplace metadata for an installed skill (or null if not from a marketplace).
57
+ api.get("/marketplace/installed/:slug", async (c) => {
58
+ const slug = c.req.param("slug");
59
+ if (!isValidSlug(slug)) return c.json({ error: "Invalid slug" }, 400);
60
+ const meta = await readInstalledMeta(slug);
61
+ return c.json(meta ?? null);
62
+ });
63
+ }