@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 +4 -2
- package/dist/cli.js +64 -12
- package/package.json +3 -2
- package/runtime/server/chat-proxy.js +68 -0
- package/runtime/server/dist/adapters/telegram.js +200 -0
- package/runtime/server/dist/adapters/types.js +1 -0
- package/runtime/server/dist/adapters/web.js +176 -0
- package/runtime/server/dist/agent.js +218 -0
- package/runtime/server/dist/index.js +116 -0
- package/runtime/server/package-lock.json +2311 -41
- package/runtime/server/package.json +11 -2
- package/runtime/start.bat +2 -2
- package/runtime/start.sh +1 -1
- package/runtime/web/app.js +17 -3
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
|
-
|
|
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(
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
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
|
+
}
|