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.
- package/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- 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
|
+
}
|