@cremini/skillpack 1.0.7 → 1.0.9-im.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/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # SkillPack.sh - Pack AI Skills into Standalone Apps
2
2
 
3
+ Skillpack by Cremini is built on the idea of distributed intelligence, much like cremini mushrooms that grow from a vast, interconnected mycelial network.
4
+
3
5
  Go to [skillpack.sh](https://skillpack.sh) to pack skills and try existing skill packs.
4
6
 
5
- One command to orchestrate [Skills](https://skills.sh), tools, mcps into a standalone app users can download and use on their own computer!
7
+ One command to orchestrate [Skills](https://skills.sh), tools, mcps into a standalone app users can download and use on their own computer to address a well-defined problem or complete specific tasks!
6
8
 
7
9
  ```bash
8
10
  npx @cremini/skillpack create
@@ -84,7 +86,7 @@ skillpack/
84
86
 
85
87
  ```bash
86
88
  # macOS / Linux
87
- chmod +x start.sh && ./start.sh
89
+ ./start.sh
88
90
 
89
91
  # Windows
90
92
  start.bat
package/dist/cli.js CHANGED
@@ -317,6 +317,13 @@ import fs3 from "fs";
317
317
  import path3 from "path";
318
318
  import { fileURLToPath } from "url";
319
319
  var __dirname = path3.dirname(fileURLToPath(import.meta.url));
320
+ var EXECUTABLE_RUNTIME_FILES = /* @__PURE__ */ new Set(["start.sh", "start.bat"]);
321
+ function isExecutableRuntimeFile(relativePath) {
322
+ return EXECUTABLE_RUNTIME_FILES.has(relativePath);
323
+ }
324
+ function withExecuteBits(mode) {
325
+ return mode | 73;
326
+ }
320
327
  function getRuntimeDir() {
321
328
  const projectRoot = path3.resolve(__dirname, "..");
322
329
  return path3.join(projectRoot, "runtime");
@@ -335,6 +342,10 @@ function collectRuntimeTemplateEntries(runtimeDir) {
335
342
  if (dirEntry.name === "node_modules") {
336
343
  continue;
337
344
  }
345
+ const currentRelative = relativeDir ? path3.posix.join(relativeDir, dirEntry.name) : dirEntry.name;
346
+ if (currentRelative === "server/src" || currentRelative === "server/tsconfig.json") {
347
+ continue;
348
+ }
338
349
  const absolutePath = path3.join(currentDir, dirEntry.name);
339
350
  const relativePath = relativeDir ? path3.posix.join(relativeDir, dirEntry.name) : dirEntry.name;
340
351
  const stats = fs3.statSync(absolutePath);
@@ -374,6 +385,16 @@ function copyRuntimeTemplate(runtimeDir, workDir) {
374
385
  fs3.chmodSync(destinationPath, entry.stats.mode);
375
386
  }
376
387
  }
388
+ function ensureRuntimeLaunchersExecutable(workDir) {
389
+ for (const relativePath of EXECUTABLE_RUNTIME_FILES) {
390
+ const filePath = path3.join(workDir, relativePath);
391
+ if (!fs3.existsSync(filePath)) {
392
+ continue;
393
+ }
394
+ const currentMode = fs3.statSync(filePath).mode;
395
+ fs3.chmodSync(filePath, withExecuteBits(currentMode));
396
+ }
397
+ }
377
398
  function addRuntimeFiles(archive, runtimeDir, prefix) {
378
399
  const entries = collectRuntimeTemplateEntries(runtimeDir);
379
400
  for (const entry of entries) {
@@ -387,7 +408,7 @@ function addRuntimeFiles(archive, runtimeDir, prefix) {
387
408
  }
388
409
  archive.file(entry.absolutePath, {
389
410
  name: archivePath,
390
- mode: entry.stats.mode
411
+ mode: isExecutableRuntimeFile(entry.relativePath) ? withExecuteBits(entry.stats.mode) : entry.stats.mode
391
412
  });
392
413
  }
393
414
  }
@@ -433,6 +454,25 @@ async function bundle(workDir) {
433
454
  function parseSkillNames(value) {
434
455
  return value.split(",").map((name) => name.trim()).filter(Boolean);
435
456
  }
457
+ function normalizeSourceInput(value) {
458
+ return value.trim().replace(/^npx\s+skills\s+add\s+/u, "");
459
+ }
460
+ function parseSourceInput(value) {
461
+ const trimmedValue = normalizeSourceInput(value);
462
+ const skillFlagIndex = trimmedValue.indexOf(" --skill ");
463
+ if (skillFlagIndex === -1) {
464
+ return {
465
+ source: trimmedValue,
466
+ inlineSkillNames: []
467
+ };
468
+ }
469
+ const source = trimmedValue.slice(0, skillFlagIndex).trim();
470
+ const inlineSkillValue = trimmedValue.slice(skillFlagIndex + " --skill ".length).trim();
471
+ return {
472
+ source,
473
+ inlineSkillNames: inlineSkillValue.split(/[,\s]+/).map((name) => name.trim()).filter(Boolean)
474
+ };
475
+ }
436
476
  async function createCommand(directory) {
437
477
  const workDir = directory ? path5.resolve(directory) : process.cwd();
438
478
  if (directory) {
@@ -476,7 +516,10 @@ async function createCommand(directory) {
476
516
  chalk3.dim(" Supported formats: owner/repo, GitHub URL, or local path")
477
517
  );
478
518
  console.log(chalk3.dim(" Example source: vercel-labs/agent-skills"));
479
- console.log(chalk3.dim(" Example skill names: frontend-design, skill-creator\n"));
519
+ console.log(
520
+ chalk3.dim(" Example inline skill: vercel-labs/agent-skills --skill find-skills")
521
+ );
522
+ console.log();
480
523
  while (true) {
481
524
  const { source } = await inquirer.prompt([
482
525
  {
@@ -488,16 +531,24 @@ async function createCommand(directory) {
488
531
  if (!source.trim()) {
489
532
  break;
490
533
  }
491
- const { skillNames } = await inquirer.prompt([
492
- {
493
- type: "input",
494
- name: "skillNames",
495
- message: "Skill names (comma-separated):",
496
- validate: (value) => parseSkillNames(value).length > 0 ? true : "Enter at least one skill name"
497
- }
498
- ]);
499
- const nextSkills = parseSkillNames(skillNames).map((skillName) => ({
500
- source: source.trim(),
534
+ const parsedSource = parseSourceInput(source);
535
+ let skillNames = parsedSource.inlineSkillNames;
536
+ if (skillNames.length === 0) {
537
+ console.log(
538
+ chalk3.dim(" Example skill names: frontend-design, skill-creator")
539
+ );
540
+ const promptResult = await inquirer.prompt([
541
+ {
542
+ type: "input",
543
+ name: "skillNames",
544
+ message: "Skill names (comma-separated):",
545
+ validate: (value) => parseSkillNames(value).length > 0 ? true : "Enter at least one skill name"
546
+ }
547
+ ]);
548
+ skillNames = parseSkillNames(promptResult.skillNames);
549
+ }
550
+ const nextSkills = skillNames.map((skillName) => ({
551
+ source: parsedSource.source,
501
552
  name: skillName,
502
553
  description: ""
503
554
  }));
@@ -610,6 +661,7 @@ async function initCommand(directory, options) {
610
661
  installConfiguredSkills(workDir, config);
611
662
  refreshDescriptionsAndSave(workDir, config);
612
663
  copyRuntimeTemplate(getRuntimeDir(), workDir);
664
+ ensureRuntimeLaunchersExecutable(workDir);
613
665
  if (options.bundle) {
614
666
  await bundle(workDir);
615
667
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cremini/skillpack",
3
- "version": "1.0.7",
3
+ "version": "1.0.9-im.0",
4
4
  "description": "Turn Skills into a Standalone App with UI",
5
5
  "type": "module",
6
6
  "repository": {
@@ -24,7 +24,8 @@
24
24
  "node": ">=20"
25
25
  },
26
26
  "scripts": {
27
- "build": "tsup",
27
+ "build": "npm run build:runtime && tsup",
28
+ "build:runtime": "cd runtime/server && npx tsc",
28
29
  "dev": "tsup --watch",
29
30
  "check": "tsc --noEmit",
30
31
  "format": "prettier --write .",
@@ -12,6 +12,34 @@ const DEBUG = true;
12
12
  const log = (...args) => DEBUG && console.log(...args);
13
13
  const write = (data) => DEBUG && process.stdout.write(data);
14
14
 
15
+ function getAssistantDiagnostics(message) {
16
+ if (!message || message.role !== "assistant") {
17
+ return null;
18
+ }
19
+
20
+ const stopReason = message.stopReason;
21
+ const errorMessage =
22
+ message.errorMessage ||
23
+ (stopReason === "error" || stopReason === "aborted"
24
+ ? `Request ${stopReason}`
25
+ : "");
26
+
27
+ const content = Array.isArray(message.content) ? message.content : [];
28
+ const text = content
29
+ .filter((item) => item?.type === "text")
30
+ .map((item) => item.text || "")
31
+ .join("")
32
+ .trim();
33
+ const toolCalls = content.filter((item) => item?.type === "toolCall").length;
34
+
35
+ return {
36
+ stopReason,
37
+ errorMessage,
38
+ hasText: text.length > 0,
39
+ toolCalls,
40
+ };
41
+ }
42
+
15
43
  /**
16
44
  * Handle incoming WebSocket connection using pi-coding-agent
17
45
  * @param {import("ws").WebSocket} ws
@@ -24,6 +52,8 @@ export async function handleWsConnection(
24
52
  { apiKey, rootDir, provider = "openai", modelId = "gpt-5.4" },
25
53
  ) {
26
54
  try {
55
+ let turnHadVisibleOutput = false;
56
+
27
57
  // Create an in-memory auth storage to avoid touching disk
28
58
  const authStorage = AuthStorage.inMemory({
29
59
  [provider]: { type: "api_key", key: apiKey },
@@ -78,6 +108,7 @@ export async function handleWsConnection(
78
108
 
79
109
  case "message_update":
80
110
  if (event.assistantMessageEvent?.type === "text_delta") {
111
+ turnHadVisibleOutput = true;
81
112
  write(event.assistantMessageEvent.delta);
82
113
  ws.send(
83
114
  JSON.stringify({
@@ -86,6 +117,7 @@ export async function handleWsConnection(
86
117
  }),
87
118
  );
88
119
  } else if (event.assistantMessageEvent?.type === "thinking_delta") {
120
+ turnHadVisibleOutput = true;
89
121
  ws.send(
90
122
  JSON.stringify({
91
123
  type: "thinking_delta",
@@ -97,6 +129,17 @@ export async function handleWsConnection(
97
129
 
98
130
  case "message_end":
99
131
  log(`\n--- [Message End: ${event.message?.role}] ---`);
132
+ if (event.message?.role === "assistant") {
133
+ const diagnostics = getAssistantDiagnostics(event.message);
134
+ if (diagnostics) {
135
+ log(
136
+ `[Assistant Diagnostics] stopReason=${diagnostics.stopReason || "unknown"} text=${diagnostics.hasText ? "yes" : "no"} toolCalls=${diagnostics.toolCalls}`,
137
+ );
138
+ if (diagnostics.errorMessage) {
139
+ log(`[Assistant Error] ${diagnostics.errorMessage}`);
140
+ }
141
+ }
142
+ }
100
143
  ws.send(
101
144
  JSON.stringify({
102
145
  type: "message_end",
@@ -106,6 +149,7 @@ export async function handleWsConnection(
106
149
  break;
107
150
 
108
151
  case "tool_execution_start":
152
+ turnHadVisibleOutput = true;
109
153
  log(`\n>>> [Tool Execution Start: ${event.toolName}] >>>`);
110
154
  log("Args:", JSON.stringify(event.args, null, 2));
111
155
  ws.send(
@@ -118,6 +162,7 @@ export async function handleWsConnection(
118
162
  break;
119
163
 
120
164
  case "tool_execution_end":
165
+ turnHadVisibleOutput = true;
121
166
  log(`<<< [Tool Execution End: ${event.toolName}] <<<`);
122
167
  log(`Error: ${event.isError ? "Yes" : "No"}`);
123
168
  ws.send(
@@ -142,8 +187,31 @@ export async function handleWsConnection(
142
187
  try {
143
188
  const payload = JSON.parse(data.toString());
144
189
  if (payload.text) {
190
+ turnHadVisibleOutput = false;
191
+
145
192
  // Send prompt to the agent, the session will handle message history natively
146
193
  await session.prompt(payload.text);
194
+
195
+ const lastMessage = session.state.messages.at(-1);
196
+ const diagnostics = getAssistantDiagnostics(lastMessage);
197
+ if (diagnostics?.errorMessage) {
198
+ ws.send(JSON.stringify({ error: diagnostics.errorMessage }));
199
+ return;
200
+ }
201
+
202
+ if (
203
+ diagnostics &&
204
+ !diagnostics.hasText &&
205
+ diagnostics.toolCalls === 0 &&
206
+ !turnHadVisibleOutput
207
+ ) {
208
+ const emptyResponseError =
209
+ "Assistant returned no visible output. Check the server logs for stopReason/provider details.";
210
+ log(`[Assistant Warning] ${emptyResponseError}`);
211
+ ws.send(JSON.stringify({ error: emptyResponseError }));
212
+ return;
213
+ }
214
+
147
215
  ws.send(JSON.stringify({ done: true }));
148
216
  }
149
217
  } catch (err) {
@@ -0,0 +1,200 @@
1
+ import TelegramBot from "node-telegram-bot-api";
2
+ const COMMANDS = {
3
+ "/clear": "clear",
4
+ "/restart": "restart",
5
+ "/shutdown": "shutdown",
6
+ };
7
+ const MAX_MESSAGE_LENGTH = 4096;
8
+ // ---------------------------------------------------------------------------
9
+ // Markdown → Telegram MarkdownV2 escaping
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Escape special characters for Telegram MarkdownV2.
13
+ * Reference: https://core.telegram.org/bots/api#markdownv2-style
14
+ */
15
+ function escapeMarkdownV2(text) {
16
+ return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
17
+ }
18
+ /**
19
+ * Attempt basic conversion from standard markdown to Telegram MarkdownV2.
20
+ * Falls back to plain text on complex formatting.
21
+ */
22
+ function toTelegramFormat(text) {
23
+ try {
24
+ // For now, just escape the text for MarkdownV2.
25
+ // Complex markdown conversion can be enhanced later.
26
+ return escapeMarkdownV2(text);
27
+ }
28
+ catch {
29
+ return text;
30
+ }
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // TelegramAdapter
34
+ // ---------------------------------------------------------------------------
35
+ export class TelegramAdapter {
36
+ name = "telegram";
37
+ bot = null;
38
+ agent = null;
39
+ options;
40
+ constructor(options) {
41
+ this.options = options;
42
+ }
43
+ async start(ctx) {
44
+ this.agent = ctx.agent;
45
+ this.bot = new TelegramBot(this.options.token, { polling: true });
46
+ this.bot.on("message", (msg) => {
47
+ this.handleTelegramMessage(msg).catch((err) => {
48
+ console.error("[Telegram] Error handling message:", err);
49
+ });
50
+ });
51
+ // Register bot commands with Telegram
52
+ await this.bot.setMyCommands([
53
+ { command: "clear", description: "Clear current session and start new" },
54
+ { command: "restart", description: "Restart the server process" },
55
+ { command: "shutdown", description: "Shut down the server process" },
56
+ ]);
57
+ const me = await this.bot.getMe();
58
+ console.log(`[TelegramAdapter] Started as @${me.username}`);
59
+ }
60
+ async stop() {
61
+ if (this.bot) {
62
+ await this.bot.stopPolling();
63
+ this.bot = null;
64
+ }
65
+ console.log("[TelegramAdapter] Stopped");
66
+ }
67
+ // -------------------------------------------------------------------------
68
+ // Message handler
69
+ // -------------------------------------------------------------------------
70
+ async handleTelegramMessage(msg) {
71
+ if (!this.bot || !this.agent)
72
+ return;
73
+ const chatId = msg.chat.id;
74
+ const text = msg.text?.trim();
75
+ if (!text)
76
+ return;
77
+ const channelId = `telegram-${chatId}`;
78
+ // --- Command handling ---
79
+ const commandKey = text.split(/\s/)[0].toLowerCase();
80
+ const command = COMMANDS[commandKey];
81
+ if (command) {
82
+ const result = await this.agent.handleCommand(command, channelId);
83
+ await this.sendSafe(chatId, result.message || `/${command} executed.`);
84
+ return;
85
+ }
86
+ // --- Regular message → agent ---
87
+ // Send a "thinking" indicator
88
+ await this.bot.sendChatAction(chatId, "typing");
89
+ let finalText = "";
90
+ let hasError = false;
91
+ let errorMessage = "";
92
+ const onEvent = (event) => {
93
+ // Only collect final text; skip thinking/tool intermediate events
94
+ switch (event.type) {
95
+ case "text_delta":
96
+ finalText += event.delta;
97
+ break;
98
+ // We intentionally ignore thinking_delta, tool_start, tool_end
99
+ }
100
+ };
101
+ try {
102
+ const result = await this.agent.handleMessage(channelId, text, onEvent);
103
+ if (result.errorMessage) {
104
+ hasError = true;
105
+ errorMessage = result.errorMessage;
106
+ }
107
+ }
108
+ catch (err) {
109
+ hasError = true;
110
+ errorMessage = String(err);
111
+ }
112
+ // --- Send response ---
113
+ if (hasError) {
114
+ await this.sendSafe(chatId, `❌ Error: ${errorMessage}`);
115
+ return;
116
+ }
117
+ if (!finalText.trim()) {
118
+ await this.sendSafe(chatId, "(No response generated)");
119
+ return;
120
+ }
121
+ // Split and send the final text
122
+ await this.sendLongMessage(chatId, finalText);
123
+ }
124
+ // -------------------------------------------------------------------------
125
+ // Send helpers
126
+ // -------------------------------------------------------------------------
127
+ /**
128
+ * Send a message, splitting into chunks if too long.
129
+ */
130
+ async sendLongMessage(chatId, text) {
131
+ // Try to send as plain text first (more reliable than MarkdownV2 for complex content)
132
+ const chunks = this.splitMessage(text);
133
+ for (const chunk of chunks) {
134
+ await this.sendWithRetry(chatId, chunk);
135
+ }
136
+ }
137
+ /**
138
+ * Split text into chunks respecting Telegram's message length limit.
139
+ * Tries to split at paragraph boundaries.
140
+ */
141
+ splitMessage(text) {
142
+ if (text.length <= MAX_MESSAGE_LENGTH) {
143
+ return [text];
144
+ }
145
+ const chunks = [];
146
+ let remaining = text;
147
+ while (remaining.length > 0) {
148
+ if (remaining.length <= MAX_MESSAGE_LENGTH) {
149
+ chunks.push(remaining);
150
+ break;
151
+ }
152
+ // Find a good split point (paragraph break, then line break, then space)
153
+ let splitAt = remaining.lastIndexOf("\n\n", MAX_MESSAGE_LENGTH);
154
+ if (splitAt < MAX_MESSAGE_LENGTH * 0.5) {
155
+ splitAt = remaining.lastIndexOf("\n", MAX_MESSAGE_LENGTH);
156
+ }
157
+ if (splitAt < MAX_MESSAGE_LENGTH * 0.3) {
158
+ splitAt = remaining.lastIndexOf(" ", MAX_MESSAGE_LENGTH);
159
+ }
160
+ if (splitAt < 1) {
161
+ splitAt = MAX_MESSAGE_LENGTH;
162
+ }
163
+ chunks.push(remaining.slice(0, splitAt));
164
+ remaining = remaining.slice(splitAt).trimStart();
165
+ }
166
+ return chunks;
167
+ }
168
+ /**
169
+ * Send a message with automatic retry on 429 (rate limit).
170
+ */
171
+ async sendWithRetry(chatId, text, maxRetries = 3) {
172
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
173
+ try {
174
+ await this.bot.sendMessage(chatId, text);
175
+ return;
176
+ }
177
+ catch (err) {
178
+ if (err?.response?.statusCode === 429 &&
179
+ attempt < maxRetries) {
180
+ const retryAfter = err.response?.body?.parameters?.retry_after || 5;
181
+ console.log(`[Telegram] Rate limited, retrying after ${retryAfter}s...`);
182
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
183
+ continue;
184
+ }
185
+ throw err;
186
+ }
187
+ }
188
+ }
189
+ /**
190
+ * Safe send that catches and logs errors.
191
+ */
192
+ async sendSafe(chatId, text) {
193
+ try {
194
+ await this.sendWithRetry(chatId, text);
195
+ }
196
+ catch (err) {
197
+ console.error("[Telegram] Failed to send message:", err);
198
+ }
199
+ }
200
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { WebSocketServer } from "ws";
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function getPackConfig(rootDir) {
8
+ const raw = fs.readFileSync(path.join(rootDir, "skillpack.json"), "utf-8");
9
+ return JSON.parse(raw);
10
+ }
11
+ const COMMANDS = {
12
+ "/clear": "clear",
13
+ "/restart": "restart",
14
+ "/shutdown": "shutdown",
15
+ };
16
+ function parseCommand(text) {
17
+ const trimmed = text.trim().toLowerCase();
18
+ return COMMANDS[trimmed] ?? null;
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // WebAdapter
22
+ // ---------------------------------------------------------------------------
23
+ export class WebAdapter {
24
+ name = "web";
25
+ wss = null;
26
+ agent = null;
27
+ async start(ctx) {
28
+ const { agent, server, app, rootDir } = ctx;
29
+ this.agent = agent;
30
+ // -- API key & provider (in-memory, can be overridden by frontend) ------
31
+ // Read from data/config.json first
32
+ let apiKey = "";
33
+ let currentProvider = "openai";
34
+ const configPath = path.join(rootDir, "data", "config.json");
35
+ if (fs.existsSync(configPath)) {
36
+ try {
37
+ const dataConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
38
+ if (dataConfig.apiKey)
39
+ apiKey = dataConfig.apiKey;
40
+ if (dataConfig.provider)
41
+ currentProvider = dataConfig.provider;
42
+ }
43
+ catch {
44
+ // ignore malformed config
45
+ }
46
+ }
47
+ // Environment variables override config file
48
+ if (process.env.OPENAI_API_KEY) {
49
+ apiKey = process.env.OPENAI_API_KEY;
50
+ currentProvider = "openai";
51
+ }
52
+ else if (process.env.ANTHROPIC_API_KEY) {
53
+ apiKey = process.env.ANTHROPIC_API_KEY;
54
+ currentProvider = "anthropic";
55
+ }
56
+ // -- HTTP API routes ----------------------------------------------------
57
+ app.get("/api/config", (_req, res) => {
58
+ const config = getPackConfig(rootDir);
59
+ res.json({
60
+ name: config.name,
61
+ description: config.description,
62
+ prompts: config.prompts || [],
63
+ skills: config.skills || [],
64
+ hasApiKey: !!apiKey,
65
+ provider: currentProvider,
66
+ });
67
+ });
68
+ app.get("/api/skills", (_req, res) => {
69
+ const config = getPackConfig(rootDir);
70
+ res.json(config.skills || []);
71
+ });
72
+ app.post("/api/config/key", (req, res) => {
73
+ const { key, provider } = req.body;
74
+ if (!key) {
75
+ res.status(400).json({ error: "API key is required" });
76
+ return;
77
+ }
78
+ apiKey = key;
79
+ if (provider)
80
+ currentProvider = provider;
81
+ res.json({ success: true, provider: currentProvider });
82
+ });
83
+ app.delete("/api/chat", (_req, res) => {
84
+ res.json({ success: true });
85
+ });
86
+ // -- Reserved: session history endpoints (stub) -------------------------
87
+ app.get("/api/sessions", (_req, res) => {
88
+ const sessions = agent.listSessions();
89
+ res.json(sessions);
90
+ });
91
+ app.get("/api/sessions/:id", (_req, res) => {
92
+ // TODO: restore session by id
93
+ res.status(501).json({ error: "Not implemented yet" });
94
+ });
95
+ // -- WebSocket ----------------------------------------------------------
96
+ this.wss = new WebSocketServer({ noServer: true });
97
+ server.on("upgrade", (request, socket, head) => {
98
+ if (request.url?.startsWith("/api/chat")) {
99
+ this.wss.handleUpgrade(request, socket, head, (ws) => {
100
+ this.wss.emit("connection", ws, request);
101
+ });
102
+ }
103
+ else {
104
+ socket.destroy();
105
+ }
106
+ });
107
+ this.wss.on("connection", (ws, request) => {
108
+ const url = new URL(request.url ?? "/", `http://${request.headers.host || "127.0.0.1"}`);
109
+ const _reqProvider = url.searchParams.get("provider") || currentProvider;
110
+ if (!apiKey) {
111
+ ws.send(JSON.stringify({ error: "Please set an API key first" }));
112
+ ws.close();
113
+ return;
114
+ }
115
+ // Each WebSocket connection maps to a unique channel
116
+ const channelId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
117
+ this.handleWsConnection(ws, channelId, agent);
118
+ });
119
+ console.log("[WebAdapter] Started");
120
+ }
121
+ async stop() {
122
+ if (this.wss) {
123
+ for (const client of this.wss.clients) {
124
+ client.close();
125
+ }
126
+ this.wss.close();
127
+ this.wss = null;
128
+ }
129
+ console.log("[WebAdapter] Stopped");
130
+ }
131
+ // -------------------------------------------------------------------------
132
+ // WebSocket message handler
133
+ // -------------------------------------------------------------------------
134
+ handleWsConnection(ws, channelId, agent) {
135
+ ws.on("message", async (data) => {
136
+ try {
137
+ const payload = JSON.parse(data.toString());
138
+ if (!payload.text)
139
+ return;
140
+ const text = payload.text;
141
+ // Check for bot commands
142
+ const command = parseCommand(text);
143
+ if (command) {
144
+ const result = await agent.handleCommand(command, channelId);
145
+ ws.send(JSON.stringify({
146
+ type: "command_result",
147
+ command,
148
+ ...result,
149
+ }));
150
+ if (command === "clear") {
151
+ ws.send(JSON.stringify({ done: true }));
152
+ }
153
+ return;
154
+ }
155
+ // Regular message → stream events via WebSocket
156
+ const onEvent = (event) => {
157
+ if (ws.readyState !== ws.OPEN)
158
+ return;
159
+ ws.send(JSON.stringify(event));
160
+ };
161
+ const result = await agent.handleMessage(channelId, text, onEvent);
162
+ if (result.errorMessage) {
163
+ ws.send(JSON.stringify({ error: result.errorMessage }));
164
+ return;
165
+ }
166
+ ws.send(JSON.stringify({ done: true }));
167
+ }
168
+ catch (err) {
169
+ ws.send(JSON.stringify({ error: String(err) }));
170
+ }
171
+ });
172
+ ws.on("close", () => {
173
+ agent.dispose(channelId);
174
+ });
175
+ }
176
+ }