skyloom 1.23.0 → 1.25.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/.github/workflows/ci.yml +3 -0
- package/README.md +6 -1
- package/dist/cli/main.js +71 -22
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +0 -6
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +10 -0
- package/dist/core/commands.js.map +1 -1
- package/dist/core/diff.d.ts.map +1 -1
- package/dist/core/diff.js +0 -1
- package/dist/core/diff.js.map +1 -1
- package/dist/core/evolve.d.ts.map +1 -1
- package/dist/core/evolve.js +0 -9
- package/dist/core/evolve.js.map +1 -1
- package/dist/core/graph.d.ts +1 -1
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +1 -1
- package/dist/core/graph.js.map +1 -1
- package/dist/core/llm.d.ts +3 -0
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +4 -27
- package/dist/core/llm.js.map +1 -1
- package/dist/core/sandbox.d.ts.map +1 -1
- package/dist/core/sandbox.js +0 -2
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/schemas.d.ts +1 -1
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +1 -23
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +0 -1
- package/dist/core/skill.js.map +1 -1
- package/dist/gateway/qr.d.ts +8 -0
- package/dist/gateway/qr.d.ts.map +1 -0
- package/dist/gateway/qr.js +22 -0
- package/dist/gateway/qr.js.map +1 -0
- package/dist/gateway/setup.d.ts +57 -0
- package/dist/gateway/setup.d.ts.map +1 -0
- package/dist/gateway/setup.js +127 -0
- package/dist/gateway/setup.js.map +1 -0
- package/dist/tools/computer.d.ts.map +1 -1
- package/dist/tools/computer.js.map +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +0 -2
- package/dist/web/server.js.map +1 -1
- package/eslint.config.js +62 -0
- package/package.json +2 -1
- package/src/cli/main.ts +65 -22
- package/src/cli/tui.ts +0 -2
- package/src/core/commands.ts +10 -0
- package/src/core/diff.ts +0 -1
- package/src/core/evolve.ts +0 -6
- package/src/core/graph.ts +1 -1
- package/src/core/llm.ts +4 -33
- package/src/core/sandbox.ts +1 -4
- package/src/core/schemas.ts +1 -25
- package/src/core/skill.ts +0 -1
- package/src/gateway/qr.ts +20 -0
- package/src/gateway/setup.ts +145 -0
- package/src/tools/computer.ts +2 -2
- package/src/web/server.ts +0 -3
- package/tests/channel_setup.test.ts +88 -0
- package/tests/factory.test.ts +56 -0
- package/tests/pipelines.test.ts +118 -0
package/src/cli/main.ts
CHANGED
|
@@ -10,9 +10,8 @@ import { createSystemContext, orchestrateTask } from "../core/factory";
|
|
|
10
10
|
import { loadConfig, USER_CONFIG_DIR } from "../core/config";
|
|
11
11
|
import { listProviders, modelsFor, providerLabel, validateModel } from "../core/catalog";
|
|
12
12
|
import { agentTheme } from "../core/theme";
|
|
13
|
-
import { classify } from "../core/router";
|
|
14
13
|
import { InteractiveMode, ModeController } from "./mode";
|
|
15
|
-
import { readLine, renderPalette, StreamRenderer
|
|
14
|
+
import { readLine, renderPalette, StreamRenderer } from "./tui";
|
|
16
15
|
import { loomChat } from "./loom_chat";
|
|
17
16
|
|
|
18
17
|
const MODE = new ModeController();
|
|
@@ -38,6 +37,8 @@ program.command("mcp").action(() => { import("../core/mcp_server").then(m => m.s
|
|
|
38
37
|
program.command("gateway").description("Run the channel gateway (Feishu / WeCom / QQ)")
|
|
39
38
|
.option("-p,--port <p>", "port", "8848")
|
|
40
39
|
.action((o: { port?: string }) => { import("../gateway/gateway").then(m => m.startGateway({ port: parseInt(o.port || "8848") })); });
|
|
40
|
+
program.command("channels").description("Configure a chat channel (Feishu / WeCom / QQ) with QR shortcuts")
|
|
41
|
+
.action(async () => { await channelsWizard(); });
|
|
41
42
|
program.command("config").action(() => { const c = loadConfig(); process.stdout.write(chalk.cyan("\nConfig: ") + USER_CONFIG_DIR + "\n"); for (const [n, a] of Object.entries(c.agents || {})) process.stdout.write(` ${chalk.bold(n)}: ${(a as any).model || "default"}\n`); });
|
|
42
43
|
program.command("init").action(() => { if (!fs.existsSync(USER_CONFIG_DIR)) fs.mkdirSync(USER_CONFIG_DIR, { recursive: true }); process.stdout.write(chalk.green("✓ ") + USER_CONFIG_DIR + "\n"); });
|
|
43
44
|
program.command("apikey").description("Manage API keys (persisted to ~/.skyloom/config.yaml)")
|
|
@@ -71,18 +72,6 @@ function welcome(agent: any) {
|
|
|
71
72
|
process.stdout.write(chalk.dim(" /help for commands · /quit to exit\n\n"));
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function statusBar(agent: any, ctx: any): string {
|
|
75
|
-
try {
|
|
76
|
-
const cu = agent.contextUsage();
|
|
77
|
-
const pct = cu.pct || 0;
|
|
78
|
-
const bar = pct < 50 ? chalk.green : pct < 80 ? chalk.yellow : chalk.red;
|
|
79
|
-
const filled = Math.round(pct / 10);
|
|
80
|
-
const ctxBar = `${bar("█".repeat(filled) + "░".repeat(10 - filled))} ${pct}%`;
|
|
81
|
-
const cost = formatCost(ctx.llm.getTotalCost());
|
|
82
|
-
return chalk.dim(`${ctxBar} · ${cost} · ${cu.model || "?"}`);
|
|
83
|
-
} catch { return ""; }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
75
|
function formatCost(c: number): string {
|
|
87
76
|
if (c >= 1) return chalk.yellow(`$${c.toFixed(2)}`);
|
|
88
77
|
if (c >= 0.01) return chalk.yellow(`$${c.toFixed(4)}`);
|
|
@@ -117,11 +106,6 @@ async function streamResponse(agent: any, input: string): Promise<void> {
|
|
|
117
106
|
const header = () => { if (!headerShown) { out.write("\n " + chalk.bold.hex(theme.hex)(`${theme.symbol} ${theme.kanji}`) + chalk.hex(theme.hex)(` ${theme.name}`) + "\n\n"); headerShown = true; } };
|
|
118
107
|
const endBlock = () => { if (renderer) { renderer.flush(); renderer = null; out.write("\n"); } };
|
|
119
108
|
|
|
120
|
-
// All content passes through this cleaner before display
|
|
121
|
-
const writeClean = (text: string) => {
|
|
122
|
-
if (renderer) renderer.write(text);
|
|
123
|
-
};
|
|
124
|
-
|
|
125
109
|
// ── Ctrl-C interrupts this turn (keeps partial output); a 2nd Ctrl-C exits. ──
|
|
126
110
|
const controller = new AbortController();
|
|
127
111
|
let interrupted = false;
|
|
@@ -275,6 +259,65 @@ async function setupWizard(): Promise<{ provider: string; key: string; model: st
|
|
|
275
259
|
return { provider: prov.id, key: key.trim(), model };
|
|
276
260
|
}
|
|
277
261
|
|
|
262
|
+
/* ═══════════════════════════════════════
|
|
263
|
+
Channel setup wizard (sky channels)
|
|
264
|
+
═══════════════════════════════════════ */
|
|
265
|
+
async function channelsWizard(): Promise<void> {
|
|
266
|
+
const { CHANNEL_SETUP, SETUP_CHANNEL_IDS, callbackUrl, saveChannelConfig, missingRequired } = require("../gateway/setup");
|
|
267
|
+
const { renderQR } = require("../gateway/qr");
|
|
268
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
269
|
+
const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
process.stdout.write("\n" + chalk.cyan(" ✦ 渠道接入向导 · sky channels ✦\n\n"));
|
|
273
|
+
process.stdout.write(chalk.dim(" 选择要配置的聊天软件:\n\n"));
|
|
274
|
+
SETUP_CHANNEL_IDS.forEach((id: string, i: number) => {
|
|
275
|
+
process.stdout.write(chalk.dim(` ${i + 1}. ${CHANNEL_SETUP[id].name}\n`));
|
|
276
|
+
});
|
|
277
|
+
const choice = await ask(chalk.cyan(`\n 编号 (1-${SETUP_CHANNEL_IDS.length}, q 退出): `));
|
|
278
|
+
if (choice.trim().toLowerCase() === "q") return;
|
|
279
|
+
const idx = parseInt(choice) - 1;
|
|
280
|
+
if (isNaN(idx) || idx < 0 || idx >= SETUP_CHANNEL_IDS.length) { process.stdout.write(chalk.dim(" 已取消\n")); return; }
|
|
281
|
+
const spec = CHANNEL_SETUP[SETUP_CHANNEL_IDS[idx]];
|
|
282
|
+
|
|
283
|
+
// Steps + QR to the platform console.
|
|
284
|
+
process.stdout.write("\n" + chalk.bold(` 配置 ${spec.name}\n\n`));
|
|
285
|
+
spec.steps.forEach((s: string, i: number) => process.stdout.write(chalk.dim(` ${i + 1}. ${s}\n`)));
|
|
286
|
+
process.stdout.write(chalk.dim(`\n 📱 扫码打开管理后台: `) + chalk.cyan(spec.consoleUrl) + "\n");
|
|
287
|
+
const consoleQR = renderQR(spec.consoleUrl);
|
|
288
|
+
if (consoleQR) process.stdout.write("\n" + consoleQR.split("\n").map((l: string) => " " + l).join("\n") + "\n");
|
|
289
|
+
if (spec.docsUrl) process.stdout.write(chalk.dim(` 📖 文档: ${spec.docsUrl}\n`));
|
|
290
|
+
|
|
291
|
+
// Collect credential fields.
|
|
292
|
+
process.stdout.write("\n" + chalk.dim(" 逐项填入凭据(回车跳过可选项):\n\n"));
|
|
293
|
+
const values: Record<string, string> = {};
|
|
294
|
+
for (const f of spec.fields) {
|
|
295
|
+
const req = f.required ? chalk.red("*") : chalk.dim("(可选)");
|
|
296
|
+
if (f.hint) process.stdout.write(chalk.dim(` ↳ ${f.hint}\n`));
|
|
297
|
+
const v = await ask(chalk.cyan(` ${f.label} ${req}: `));
|
|
298
|
+
if (v.trim()) values[f.key] = v.trim();
|
|
299
|
+
}
|
|
300
|
+
const missing = missingRequired(spec.id, values);
|
|
301
|
+
if (missing.length) {
|
|
302
|
+
process.stdout.write(chalk.yellow(`\n ⚠ 缺少必填项: ${missing.join(", ")} — 已保存现有项,可再次运行补全。\n`));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const cfgPath = saveChannelConfig(spec.id, values);
|
|
306
|
+
process.stdout.write(chalk.green(`\n ✓ 已保存到 ${cfgPath} 的 channels.${spec.id}\n`));
|
|
307
|
+
|
|
308
|
+
// Callback URL + QR to paste into the platform console.
|
|
309
|
+
const base = (await ask(chalk.cyan("\n 你的网关公网地址(如 https://bot.example.com,回车用 http://localhost:8848): "))).trim()
|
|
310
|
+
|| "http://localhost:8848";
|
|
311
|
+
const cb = callbackUrl(base, spec.id);
|
|
312
|
+
process.stdout.write(chalk.dim("\n 把下面的回调 URL 填入平台后台的事件/接收配置:\n ") + chalk.cyan(cb) + "\n");
|
|
313
|
+
const cbQR = renderQR(cb);
|
|
314
|
+
if (cbQR) process.stdout.write("\n" + cbQR.split("\n").map((l: string) => " " + l).join("\n") + "\n");
|
|
315
|
+
process.stdout.write(chalk.dim(`\n 完成后运行 sky gateway 启动网关。\n\n`));
|
|
316
|
+
} finally {
|
|
317
|
+
rl.close();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
278
321
|
async function chat(agentName: string, modelOverride?: string, classic?: boolean): Promise<void> {
|
|
279
322
|
const haveKey = checkApiKeys();
|
|
280
323
|
if (!haveKey) {
|
|
@@ -313,7 +356,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
313
356
|
|
|
314
357
|
// Wire up security approval — prompt user for HIGH/CRITICAL operations
|
|
315
358
|
try {
|
|
316
|
-
const { getSecurity,
|
|
359
|
+
const { getSecurity, PERMISSION_MODE_ALIASES } = require("../core/security");
|
|
317
360
|
const sec = getSecurity();
|
|
318
361
|
// Honor a configured permission mode (config.yaml cli.approvalMode), mapped
|
|
319
362
|
// through the same aliases as /perm.
|
|
@@ -344,7 +387,6 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
344
387
|
return; // loomChat exits the process itself
|
|
345
388
|
}
|
|
346
389
|
|
|
347
|
-
// eslint-disable-next-line prefer-const
|
|
348
390
|
let currentAgent = agent; // mutable for agent switching
|
|
349
391
|
let lastSessions: any[] = []; // index→session map for /resume <n>
|
|
350
392
|
welcome(agent);
|
|
@@ -508,6 +550,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
508
550
|
}
|
|
509
551
|
if (cmdL.startsWith("/task ")) { const g = inp.slice(6); process.stdout.write(chalk.cyan("\n ✦ " + g + "\n\n")); await runTask(g); continue; }
|
|
510
552
|
if (cmdL === "/setup") { const r = await setupWizard(); if (r) process.stdout.write(chalk.green(` ${r.provider} · ${r.model} — Ready!\n`)); continue; }
|
|
553
|
+
if (cmdL === "/channels") { await channelsWizard(); continue; }
|
|
511
554
|
if (cmdL === "/model" || cmdL.startsWith("/model ")) {
|
|
512
555
|
const { setAgentModel, setUnifiedModel, clearAgentModel, setAgentApiKey, describeAgentLLM } = require("../core/model_config");
|
|
513
556
|
const cfg = (ctx as any).config;
|
|
@@ -537,7 +580,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
537
580
|
continue;
|
|
538
581
|
}
|
|
539
582
|
if (cmdL === "/models" || cmdL.startsWith("/models ")) {
|
|
540
|
-
const { listProviders, modelsFor, providerLabel
|
|
583
|
+
const { listProviders, modelsFor, providerLabel } = require("../core/catalog");
|
|
541
584
|
const args = inp.split(/\s+/).slice(1);
|
|
542
585
|
const filter = args[0]?.toLowerCase() || "";
|
|
543
586
|
process.stdout.write(chalk.bold("\n ✦ 模型目录 · Model Catalog\n"));
|
package/src/cli/tui.ts
CHANGED
|
@@ -14,8 +14,6 @@ import chalk from "chalk";
|
|
|
14
14
|
import { agentTheme, PALETTE } from "../core/theme";
|
|
15
15
|
import { registry } from "../core/commands";
|
|
16
16
|
|
|
17
|
-
const TUI_VERSION = (() => { try { return require("../../package.json").version; } catch { return ""; } })();
|
|
18
|
-
|
|
19
17
|
export interface TUIContext {
|
|
20
18
|
agent: any;
|
|
21
19
|
agents: Map<string, any>;
|
package/src/core/commands.ts
CHANGED
|
@@ -482,6 +482,16 @@ export const BUILTIN_COMMANDS: CommandInfo[] = [
|
|
|
482
482
|
takesArgs: false,
|
|
483
483
|
source: 'builtin',
|
|
484
484
|
},
|
|
485
|
+
{
|
|
486
|
+
name: 'channels',
|
|
487
|
+
aliases: [],
|
|
488
|
+
description: 'Configure a chat channel (Feishu / WeCom / QQ) with QR shortcuts',
|
|
489
|
+
label: '渠道接入向导(飞书/企业微信/QQ · 含二维码)',
|
|
490
|
+
category: 'config',
|
|
491
|
+
hints: [],
|
|
492
|
+
takesArgs: false,
|
|
493
|
+
source: 'builtin',
|
|
494
|
+
},
|
|
485
495
|
{
|
|
486
496
|
name: 'apikey',
|
|
487
497
|
aliases: [],
|
package/src/core/diff.ts
CHANGED
|
@@ -60,7 +60,6 @@ export function unifiedDiff(oldStr: string, newStr: string, opts: DiffOptions =
|
|
|
60
60
|
// Context window bounds.
|
|
61
61
|
const ctxStart = Math.max(0, pre - context);
|
|
62
62
|
const oldCtxAfterStart = oldLines.length - suf;
|
|
63
|
-
const newCtxAfterStart = newLines.length - suf;
|
|
64
63
|
const oldCtxAfter = oldLines.slice(oldCtxAfterStart, oldCtxAfterStart + context);
|
|
65
64
|
const leading = oldLines.slice(ctxStart, pre);
|
|
66
65
|
|
package/src/core/evolve.ts
CHANGED
|
@@ -121,12 +121,6 @@ export function analyzeFailures(
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// General rule: if failure rate > 20%, suggest self-review
|
|
125
|
-
const recentExperiences = recent.filter(e => {
|
|
126
|
-
try { return new Date(e.lastSeen).getTime() > Date.now() - 3 * 86400000; }
|
|
127
|
-
catch { return false; }
|
|
128
|
-
});
|
|
129
|
-
|
|
130
124
|
// Deduplicate suggestions
|
|
131
125
|
const seen = new Set<string>();
|
|
132
126
|
const uniqueDiffs = suggestedDiffs.filter(d => {
|
package/src/core/graph.ts
CHANGED
|
@@ -140,7 +140,7 @@ const RELATION_PATTERNS: Array<[RegExp, string]> = [
|
|
|
140
140
|
[/(\w+) (?:file|path|文件|路径) (?:在|为|at) (.+?)(?:[。,,.\n]|$)/gi, "located_at"],
|
|
141
141
|
];
|
|
142
142
|
|
|
143
|
-
export function extractFacts(text: string,
|
|
143
|
+
export function extractFacts(text: string, _agent: string): Array<[string, string, string]> {
|
|
144
144
|
const facts: Array<[string, string, string]> = [];
|
|
145
145
|
for (const [pattern, pred] of RELATION_PATTERNS) {
|
|
146
146
|
let match;
|
package/src/core/llm.ts
CHANGED
|
@@ -355,37 +355,6 @@ const FALLBACK_CHAINS: Map<string, string[]> = new Map([
|
|
|
355
355
|
/**
|
|
356
356
|
* HTTP status codes that are considered transient errors (worth retrying).
|
|
357
357
|
*/
|
|
358
|
-
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Check if an exception is worth retrying.
|
|
362
|
-
*/
|
|
363
|
-
function isTransientError(err: unknown): boolean {
|
|
364
|
-
if (err instanceof Error) {
|
|
365
|
-
const status =
|
|
366
|
-
(err as any).status_code || (err as any).http_status || 0;
|
|
367
|
-
if (status && RETRYABLE_STATUSES.has(status)) {
|
|
368
|
-
return true;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (err.name === "TimeoutError") {
|
|
372
|
-
return true;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const errName = err.constructor.name.toLowerCase();
|
|
376
|
-
return [
|
|
377
|
-
"ratelimiterror",
|
|
378
|
-
"apitimeouterror",
|
|
379
|
-
"apiconnectionerror",
|
|
380
|
-
"serviceunavailableerror",
|
|
381
|
-
"internalservererror",
|
|
382
|
-
"timeout",
|
|
383
|
-
].includes(errName);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
358
|
/**
|
|
390
359
|
* Estimate cost for LLM API call.
|
|
391
360
|
*/
|
|
@@ -704,7 +673,7 @@ export class LLMClient {
|
|
|
704
673
|
messages: Record<string, unknown>[],
|
|
705
674
|
agentName?: string,
|
|
706
675
|
tools?: string[],
|
|
707
|
-
|
|
676
|
+
_stream: boolean = false,
|
|
708
677
|
overrides?: Record<string, unknown>
|
|
709
678
|
): Promise<LLMResponse> {
|
|
710
679
|
const temperature = (overrides?.temperature as number) ?? 0.7;
|
|
@@ -867,7 +836,9 @@ export class LLMClient {
|
|
|
867
836
|
private async *callOpenAIStream(
|
|
868
837
|
m: string, messages: Record<string, unknown>[], tools?: string[], temp?: number, maxTok?: number, signal?: AbortSignal, agentName?: string
|
|
869
838
|
): AsyncGenerator<StreamEvent> {
|
|
870
|
-
|
|
839
|
+
// Honor a per-agent API key override (agents.<name>.api_key) on the
|
|
840
|
+
// streaming path too — previously only the non-streaming path passed it.
|
|
841
|
+
const apiKey = this.getApiKey(m, agentName);
|
|
871
842
|
const baseUrl = this.getBaseUrl(m);
|
|
872
843
|
const body: Record<string, unknown> = {
|
|
873
844
|
model: m, messages, temperature: temp ?? 0.7, max_tokens: maxTok ?? 4096,
|
package/src/core/sandbox.ts
CHANGED
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
* limits, and dangerous command detection BEFORE execution.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execSync
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
10
|
import * as fs from "fs";
|
|
11
11
|
import * as path from "path";
|
|
12
12
|
import * as os from "os";
|
|
13
|
-
import { getLogger } from "./logger";
|
|
14
13
|
import { REDLINE_PATTERNS, REDLINE_COMMANDS } from "./security";
|
|
15
14
|
|
|
16
|
-
const log = getLogger("sandbox");
|
|
17
|
-
|
|
18
15
|
/* ═══════════════════════════════════════
|
|
19
16
|
Configuration
|
|
20
17
|
═══════════════════════════════════════ */
|
package/src/core/schemas.ts
CHANGED
|
@@ -78,30 +78,6 @@ export interface MessageSchema {
|
|
|
78
78
|
tool_call_id?: string;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
* Coerce a value to a target type with best-effort conversion
|
|
83
|
-
*/
|
|
84
|
-
function coerceType(value: unknown, targetType: string): unknown {
|
|
85
|
-
if (value === null || value === undefined) {
|
|
86
|
-
return value;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
switch (targetType) {
|
|
90
|
-
case "string":
|
|
91
|
-
return String(value);
|
|
92
|
-
case "number":
|
|
93
|
-
return Number(value);
|
|
94
|
-
case "boolean":
|
|
95
|
-
return Boolean(value);
|
|
96
|
-
case "array":
|
|
97
|
-
return Array.isArray(value) ? value : [];
|
|
98
|
-
case "object":
|
|
99
|
-
return typeof value === "object" ? value : {};
|
|
100
|
-
default:
|
|
101
|
-
return value;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
81
|
/**
|
|
106
82
|
* Extract JSON object/array from a potentially malformed string
|
|
107
83
|
*/
|
|
@@ -170,7 +146,7 @@ function repairJSON(text: string): string {
|
|
|
170
146
|
*/
|
|
171
147
|
export function parseSchema<T extends Record<string, unknown>>(
|
|
172
148
|
raw: string,
|
|
173
|
-
|
|
149
|
+
_schemaType?: new () => T
|
|
174
150
|
): T {
|
|
175
151
|
if (!raw || !raw.trim()) {
|
|
176
152
|
throw new SchemaValidationError("empty response", raw);
|
package/src/core/skill.ts
CHANGED
|
@@ -245,7 +245,6 @@ function extractSkillHead(body: string, maxChars: number): string {
|
|
|
245
245
|
// Patterns for auto-deriving triggers
|
|
246
246
|
const TRIGGER_QUOTED = /["'"'""]([^"'""\n]{1,40})["'""']/g;
|
|
247
247
|
const TRIGGER_EXT = /(?<![A-Za-z0-9])\.[A-Za-z0-9]{2,6}\b/g;
|
|
248
|
-
const TRIGGER_STRIP = " \t,.;:!?,。、;:!?、。";
|
|
249
248
|
|
|
250
249
|
/**
|
|
251
250
|
* Pull candidate trigger phrases out of a skill description.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal QR rendering — a thin, dependency-isolated wrapper over
|
|
3
|
+
* qrcode-terminal (single file, zero transitive deps). Returns the QR as a
|
|
4
|
+
* string so callers control where it's written (and so it's testable).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// qrcode-terminal ships no types; declare the tiny surface we use.
|
|
8
|
+
const qrcode: { generate: (text: string, opts: { small?: boolean }, cb: (s: string) => void) => void } =
|
|
9
|
+
require('qrcode-terminal');
|
|
10
|
+
|
|
11
|
+
/** Render `text` as a scannable QR code (compact) into a string. */
|
|
12
|
+
export function renderQR(text: string): string {
|
|
13
|
+
let out = '';
|
|
14
|
+
try {
|
|
15
|
+
qrcode.generate(text, { small: true }, (s: string) => { out = s; });
|
|
16
|
+
} catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel setup metadata + persistence — the data behind the `sky channels`
|
|
3
|
+
* wizard. Each channel declares the credential fields it needs, where to create
|
|
4
|
+
* the bot (a platform console URL we render as a QR for quick mobile access),
|
|
5
|
+
* and a short how-to. Kept pure/testable; the interactive prompts live in the
|
|
6
|
+
* CLI, and QR rendering is a thin wrapper over qrcode-terminal.
|
|
7
|
+
*
|
|
8
|
+
* Note: Feishu / WeCom / QQ are all official-bot APIs — credentials are created
|
|
9
|
+
* in each platform's developer console, there is no "scan to log in" the way
|
|
10
|
+
* personal WeChat works. So the QR here is a convenience link to the console
|
|
11
|
+
* (scan on your phone → open the console → create the bot → copy the keys), plus
|
|
12
|
+
* a QR of the gateway callback URL to paste back into the console.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ChannelField {
|
|
16
|
+
/** Config key under channels.<id>. */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Human label shown in the wizard. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Whether the wizard must collect it (some are optional). */
|
|
21
|
+
required: boolean;
|
|
22
|
+
/** Treat as a secret (mask input / store as env-ref suggestion). */
|
|
23
|
+
secret?: boolean;
|
|
24
|
+
/** Env var that also supplies this value. */
|
|
25
|
+
env?: string;
|
|
26
|
+
/** One-line hint on where to find it. */
|
|
27
|
+
hint?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChannelSetupSpec {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/** Platform console where the bot/app is created (rendered as a QR). */
|
|
34
|
+
consoleUrl: string;
|
|
35
|
+
/** Docs link for the full setup walkthrough. */
|
|
36
|
+
docsUrl?: string;
|
|
37
|
+
/** Webhook path the platform must call back. */
|
|
38
|
+
webhookPath: string;
|
|
39
|
+
/** Ordered credential fields to collect. */
|
|
40
|
+
fields: ChannelField[];
|
|
41
|
+
/** Short, numbered how-to shown before collecting fields. */
|
|
42
|
+
steps: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const CHANNEL_SETUP: Record<string, ChannelSetupSpec> = {
|
|
46
|
+
feishu: {
|
|
47
|
+
id: 'feishu',
|
|
48
|
+
name: '飞书 / Lark',
|
|
49
|
+
consoleUrl: 'https://open.feishu.cn/app',
|
|
50
|
+
docsUrl: 'https://open.feishu.cn/document/home/index',
|
|
51
|
+
webhookPath: '/webhook/feishu',
|
|
52
|
+
fields: [
|
|
53
|
+
{ key: 'appId', label: 'App ID', required: true, env: 'FEISHU_APP_ID', hint: '开发者后台 → 凭证与基础信息 → App ID' },
|
|
54
|
+
{ key: 'appSecret', label: 'App Secret', required: true, secret: true, env: 'FEISHU_APP_SECRET', hint: '同页 App Secret' },
|
|
55
|
+
{ key: 'verificationToken', label: 'Verification Token', required: false, secret: true, env: 'FEISHU_VERIFICATION_TOKEN', hint: '事件订阅 → Verification Token(可选)' },
|
|
56
|
+
{ key: 'encryptKey', label: 'Encrypt Key', required: false, secret: true, env: 'FEISHU_ENCRYPT_KEY', hint: '事件订阅 → Encrypt Key(开启加密时填)' },
|
|
57
|
+
],
|
|
58
|
+
steps: [
|
|
59
|
+
'扫码或打开 https://open.feishu.cn/app 创建「企业自建应用」',
|
|
60
|
+
'在「凭证与基础信息」复制 App ID / App Secret',
|
|
61
|
+
'开启「机器人」能力,在「权限管理」添加 im:message 等权限',
|
|
62
|
+
'「事件订阅」填入下方回调 URL,订阅 im.message.receive_v1',
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
wecom: {
|
|
66
|
+
id: 'wecom',
|
|
67
|
+
name: '企业微信 WeCom',
|
|
68
|
+
consoleUrl: 'https://work.weixin.qq.com/wework_admin/frame',
|
|
69
|
+
docsUrl: 'https://developer.work.weixin.qq.com/document/path/90664',
|
|
70
|
+
webhookPath: '/webhook/wecom',
|
|
71
|
+
fields: [
|
|
72
|
+
{ key: 'corpId', label: 'CorpID(企业ID)', required: true, env: 'WECOM_CORP_ID', hint: '管理后台 → 我的企业 → 企业ID' },
|
|
73
|
+
{ key: 'corpSecret', label: 'Secret(应用Secret)', required: true, secret: true, env: 'WECOM_CORP_SECRET', hint: '应用管理 → 自建应用 → Secret' },
|
|
74
|
+
{ key: 'agentId', label: 'AgentId', required: true, env: 'WECOM_AGENT_ID', hint: '同应用页 AgentId' },
|
|
75
|
+
{ key: 'token', label: 'Token', required: true, secret: true, env: 'WECOM_TOKEN', hint: '应用 → 接收消息 → API 接收 → Token' },
|
|
76
|
+
{ key: 'encodingAesKey', label: 'EncodingAESKey', required: true, secret: true, env: 'WECOM_AES_KEY', hint: '同页 EncodingAESKey(43 位)' },
|
|
77
|
+
],
|
|
78
|
+
steps: [
|
|
79
|
+
'扫码或打开企业微信管理后台,进入「应用管理 → 自建 → 创建应用」',
|
|
80
|
+
'复制企业ID、应用 Secret、AgentId',
|
|
81
|
+
'「接收消息」选 API 接收,设置 Token 与 EncodingAESKey',
|
|
82
|
+
'把下方回调 URL 填入「URL」,保存时企业微信会回调验证',
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
qq: {
|
|
86
|
+
id: 'qq',
|
|
87
|
+
name: 'QQ 机器人',
|
|
88
|
+
consoleUrl: 'https://q.qq.com/#/app/bot',
|
|
89
|
+
docsUrl: 'https://bot.q.qq.com/wiki/',
|
|
90
|
+
webhookPath: '/webhook/qq',
|
|
91
|
+
fields: [
|
|
92
|
+
{ key: 'appId', label: 'AppID(机器人ID)', required: true, env: 'QQ_BOT_APPID', hint: 'QQ 开放平台 → 机器人 → 开发设置 → AppID' },
|
|
93
|
+
{ key: 'secret', label: 'AppSecret', required: true, secret: true, env: 'QQ_BOT_SECRET', hint: '同页 AppSecret' },
|
|
94
|
+
],
|
|
95
|
+
steps: [
|
|
96
|
+
'扫码或打开 https://q.qq.com 创建机器人,完成开发者认证',
|
|
97
|
+
'在「开发设置」复制 AppID 与 AppSecret',
|
|
98
|
+
'「回调配置」选择 Webhook,填入下方回调 URL',
|
|
99
|
+
'在沙箱里把机器人加为好友 / 拉进群进行测试',
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const SETUP_CHANNEL_IDS = Object.keys(CHANNEL_SETUP);
|
|
105
|
+
|
|
106
|
+
/** Build the full webhook callback URL for a channel from a public base. */
|
|
107
|
+
export function callbackUrl(base: string, channelId: string): string {
|
|
108
|
+
const spec = CHANNEL_SETUP[channelId];
|
|
109
|
+
if (!spec) return '';
|
|
110
|
+
const trimmed = base.replace(/\/+$/, '');
|
|
111
|
+
return `${trimmed}${spec.webhookPath}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persist a channel's collected values into ~/.skyloom/config.yaml under
|
|
116
|
+
* channels.<id>, merging with any existing block. Secret-looking values are
|
|
117
|
+
* stored as-is (the file is chmod 0600); callers may instead keep secrets in
|
|
118
|
+
* env and store an { source: env, id } ref. Returns the config path written.
|
|
119
|
+
*/
|
|
120
|
+
export function saveChannelConfig(
|
|
121
|
+
channelId: string,
|
|
122
|
+
values: Record<string, string>,
|
|
123
|
+
opts?: { configPath?: string },
|
|
124
|
+
): string {
|
|
125
|
+
const path = require('path');
|
|
126
|
+
const fs = require('fs');
|
|
127
|
+
const yaml = require('yaml');
|
|
128
|
+
const cfgPath = opts?.configPath || path.join(require('os').homedir(), '.skyloom', 'config.yaml');
|
|
129
|
+
const dir = path.dirname(cfgPath);
|
|
130
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
131
|
+
let cfg: any = {};
|
|
132
|
+
if (fs.existsSync(cfgPath)) { try { cfg = yaml.parse(fs.readFileSync(cfgPath, 'utf-8')) || {}; } catch { cfg = {}; } }
|
|
133
|
+
if (!cfg.channels) cfg.channels = {};
|
|
134
|
+
cfg.channels[channelId] = { ...(cfg.channels[channelId] || {}), ...values, enabled: true };
|
|
135
|
+
fs.writeFileSync(cfgPath, yaml.stringify(cfg), { encoding: 'utf-8', mode: 0o600 });
|
|
136
|
+
try { fs.chmodSync(cfgPath, 0o600); } catch { /* best-effort on Windows */ }
|
|
137
|
+
return cfgPath;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Which required fields are still missing from a values map. */
|
|
141
|
+
export function missingRequired(channelId: string, values: Record<string, string>): string[] {
|
|
142
|
+
const spec = CHANNEL_SETUP[channelId];
|
|
143
|
+
if (!spec) return [];
|
|
144
|
+
return spec.fields.filter((f) => f.required && !values[f.key]?.trim()).map((f) => f.key);
|
|
145
|
+
}
|
package/src/tools/computer.ts
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* processes and services, and install/uninstall software.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { execSync, execFileSync
|
|
8
|
+
import { execSync, execFileSync } from 'child_process';
|
|
9
9
|
import * as os from 'os';
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
|
-
import type { ToolRegistry
|
|
12
|
+
import type { ToolRegistry } from '../core/tool';
|
|
13
13
|
|
|
14
14
|
const MAX_OUT = 8000;
|
|
15
15
|
|
package/src/web/server.ts
CHANGED
|
@@ -8,11 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { createServer, IncomingMessage, ServerResponse } from "http";
|
|
10
10
|
import { createSystemContext } from "../core/factory";
|
|
11
|
-
import { getLogger } from "../core/logger";
|
|
12
11
|
import { renderInkWashUI, SKYLOOM_FAVICON_SVG } from "./ui";
|
|
13
12
|
|
|
14
|
-
const log = getLogger("web-server");
|
|
15
|
-
|
|
16
13
|
/* ──────────────────────────────────────────────
|
|
17
14
|
Server
|
|
18
15
|
────────────────────────────────────────────── */
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import {
|
|
6
|
+
CHANNEL_SETUP,
|
|
7
|
+
SETUP_CHANNEL_IDS,
|
|
8
|
+
callbackUrl,
|
|
9
|
+
saveChannelConfig,
|
|
10
|
+
missingRequired,
|
|
11
|
+
} from "../src/gateway/setup";
|
|
12
|
+
import { renderQR } from "../src/gateway/qr";
|
|
13
|
+
|
|
14
|
+
describe("channel setup · metadata", () => {
|
|
15
|
+
it("covers the three channels with console URLs and webhook paths", () => {
|
|
16
|
+
expect(SETUP_CHANNEL_IDS.sort()).toEqual(["feishu", "qq", "wecom"]);
|
|
17
|
+
for (const id of SETUP_CHANNEL_IDS) {
|
|
18
|
+
const s = CHANNEL_SETUP[id];
|
|
19
|
+
expect(s.consoleUrl).toMatch(/^https:\/\//);
|
|
20
|
+
expect(s.webhookPath).toBe(`/webhook/${id}`);
|
|
21
|
+
expect(s.fields.length).toBeGreaterThan(0);
|
|
22
|
+
expect(s.steps.length).toBeGreaterThan(0);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("each channel marks its credential fields with env fallbacks", () => {
|
|
27
|
+
const feishu = CHANNEL_SETUP.feishu;
|
|
28
|
+
const appId = feishu.fields.find((f) => f.key === "appId")!;
|
|
29
|
+
expect(appId.required).toBe(true);
|
|
30
|
+
expect(appId.env).toBe("FEISHU_APP_ID");
|
|
31
|
+
const secret = feishu.fields.find((f) => f.key === "appSecret")!;
|
|
32
|
+
expect(secret.secret).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("channel setup · callbackUrl", () => {
|
|
37
|
+
it("joins base + webhook path, trimming trailing slash", () => {
|
|
38
|
+
expect(callbackUrl("https://bot.example.com", "feishu")).toBe("https://bot.example.com/webhook/feishu");
|
|
39
|
+
expect(callbackUrl("https://bot.example.com/", "wecom")).toBe("https://bot.example.com/webhook/wecom");
|
|
40
|
+
expect(callbackUrl("http://localhost:8848", "qq")).toBe("http://localhost:8848/webhook/qq");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("channel setup · missingRequired", () => {
|
|
45
|
+
it("reports unfilled required fields, ignores optional", () => {
|
|
46
|
+
expect(missingRequired("feishu", { appId: "a" })).toEqual(["appSecret"]);
|
|
47
|
+
expect(missingRequired("feishu", { appId: "a", appSecret: "s" })).toEqual([]);
|
|
48
|
+
// optional fields (verificationToken/encryptKey) never reported
|
|
49
|
+
expect(missingRequired("feishu", { appId: "a", appSecret: "s" })).not.toContain("encryptKey");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("channel setup · saveChannelConfig", () => {
|
|
54
|
+
let cfgPath: string;
|
|
55
|
+
beforeEach(() => { cfgPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "sky-chcfg-")), "config.yaml"); });
|
|
56
|
+
afterEach(() => { try { fs.rmSync(path.dirname(cfgPath), { recursive: true, force: true }); } catch {} });
|
|
57
|
+
|
|
58
|
+
it("writes channels.<id> and merges on re-save", () => {
|
|
59
|
+
saveChannelConfig("feishu", { appId: "a", appSecret: "s" }, { configPath: cfgPath });
|
|
60
|
+
const yaml = require("yaml");
|
|
61
|
+
let cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
62
|
+
expect(cfg.channels.feishu).toMatchObject({ appId: "a", appSecret: "s", enabled: true });
|
|
63
|
+
|
|
64
|
+
// re-save adds a field without dropping the old ones
|
|
65
|
+
saveChannelConfig("feishu", { encryptKey: "k" }, { configPath: cfgPath });
|
|
66
|
+
cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
67
|
+
expect(cfg.channels.feishu).toMatchObject({ appId: "a", appSecret: "s", encryptKey: "k" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("preserves other top-level config when writing a channel", () => {
|
|
71
|
+
const yaml = require("yaml");
|
|
72
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
73
|
+
fs.writeFileSync(cfgPath, yaml.stringify({ default_model: "gpt-4o", api_keys: { openai: "sk" } }));
|
|
74
|
+
saveChannelConfig("qq", { appId: "1", secret: "x" }, { configPath: cfgPath });
|
|
75
|
+
const cfg = yaml.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
76
|
+
expect(cfg.default_model).toBe("gpt-4o");
|
|
77
|
+
expect(cfg.api_keys.openai).toBe("sk");
|
|
78
|
+
expect(cfg.channels.qq.appId).toBe("1");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("channel setup · QR rendering", () => {
|
|
83
|
+
it("renders a non-empty scannable block for a URL", () => {
|
|
84
|
+
const qr = renderQR("https://open.feishu.cn/app");
|
|
85
|
+
expect(qr.length).toBeGreaterThan(50);
|
|
86
|
+
expect(qr).toMatch(/[█▀▄ ]/); // block-drawing characters
|
|
87
|
+
});
|
|
88
|
+
});
|