@mokuyoaxis/dsh-iris 0.1.1 → 0.1.3
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/CHANGELOG.md +75 -1
- package/README.en.md +259 -0
- package/README.md +70 -7
- package/bin/dsh-iris.js +23 -0
- package/cordis.patch.yml +1 -1
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +25 -8
- package/docs/SECURITY.md +53 -0
- package/docs/TASK_SEMANTICS.md +147 -0
- package/docs/assets/diagrams/iris-architecture.drawio +1 -0
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +63 -0
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +1 -0
- package/docs/assets/diagrams/iris-task-lifecycle.png +0 -0
- package/docs/assets/diagrams/iris-task-lifecycle.svg +64 -0
- package/docs/assets/diagrams/iris-workflow-compose.drawio +1 -0
- package/docs/assets/diagrams/iris-workflow-compose.png +0 -0
- package/docs/assets/diagrams/iris-workflow-compose.svg +41 -0
- package/docs/assets/examples/iris-flower-generated.webp +0 -0
- package/docs/assets/screenshots/image-task-succeeded.webp +0 -0
- package/docs/assets/screenshots/prompt-general-after.webp +0 -0
- package/docs/assets/screenshots/prompt-general-before.webp +0 -0
- package/docs/assets/screenshots/prompt-general-preview.webp +0 -0
- package/docs/assets/screenshots/prompt-image-before.webp +0 -0
- package/docs/assets/screenshots/prompt-image-preview.webp +0 -0
- package/docs/releases/0.1.2.md +26 -0
- package/docs/releases/0.1.3.md +30 -0
- package/docs/screenshots.md +34 -0
- package/lib/actions.js +609 -175
- package/lib/api.js +122 -18
- package/lib/bundled-skills.js +62 -0
- package/lib/client.js +460 -22
- package/lib/doctor.js +243 -0
- package/lib/index.js +57 -46
- package/lib/prompt-optimizer-config.js +170 -0
- package/lib/prompt-optimizer.js +244 -0
- package/lib/provider-contract.js +234 -0
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +473 -29
- package/package.json +13 -4
- package/screenshots.json +6 -0
- package/user_guide.md +84 -3
package/lib/api.js
CHANGED
|
@@ -12,12 +12,21 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
15
16
|
import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.js';
|
|
16
17
|
import { mimeOf, mediaUrl } from './media.js';
|
|
17
18
|
import * as tasks from './tasks.js';
|
|
18
19
|
import { runAction } from './actions.js';
|
|
19
20
|
import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
|
|
20
21
|
import { inferMediaProtocol } from './provider-protocol.js';
|
|
22
|
+
import { deriveUserState } from './task-semantics.js';
|
|
23
|
+
import { optimizePrompt } from './prompt-optimizer.js';
|
|
24
|
+
import {
|
|
25
|
+
importPromptOptimizerConfig,
|
|
26
|
+
loadPromptOptimizerConfig,
|
|
27
|
+
resetPromptOptimizerConfig,
|
|
28
|
+
setPromptOptimizerEnabled
|
|
29
|
+
} from './prompt-optimizer-config.js';
|
|
21
30
|
|
|
22
31
|
function readJsonSafe(file) {
|
|
23
32
|
try {
|
|
@@ -68,6 +77,21 @@ function mediaOf(task) {
|
|
|
68
77
|
}));
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
const V2_FACT_FIELDS = ['phase', 'acceptance', 'watchState', 'outcome', 'deliveryState', 'cancelState'];
|
|
81
|
+
|
|
82
|
+
function isTaskV2(t) {
|
|
83
|
+
return Boolean(t && t.schemaVersion === 2 && V2_FACT_FIELDS.every((field) => Object.prototype.hasOwnProperty.call(t, field)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 旧记录保持原四态;只有完整 v2 记录才使用事实轴派生用户态。 */
|
|
87
|
+
function taskUserState(t) {
|
|
88
|
+
if (isTaskV2(t)) return deriveUserState(t);
|
|
89
|
+
if (t && t.status === 'succeeded') return 'succeeded';
|
|
90
|
+
if (t && t.status === 'failed') return 'failed';
|
|
91
|
+
if (t && t.status === 'canceled') return 'canceled';
|
|
92
|
+
return 'running';
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
function taskPublic(t) {
|
|
72
96
|
const elapsedMs =
|
|
73
97
|
t.elapsedMs ||
|
|
@@ -76,6 +100,9 @@ function taskPublic(t) {
|
|
|
76
100
|
id: t.id,
|
|
77
101
|
cap: t.cap || '',
|
|
78
102
|
status: t.status || 'running',
|
|
103
|
+
userState: taskUserState(t),
|
|
104
|
+
schemaVersion: Number(t.schemaVersion || 1),
|
|
105
|
+
revision: Number(t.revision || 0),
|
|
79
106
|
progress: t.progress || '',
|
|
80
107
|
model: t.model || '',
|
|
81
108
|
providerName: t.providerName || '',
|
|
@@ -87,6 +114,8 @@ function taskPublic(t) {
|
|
|
87
114
|
saved: !!t.saved,
|
|
88
115
|
expired: !!t.expired,
|
|
89
116
|
mode: t.mode || '',
|
|
117
|
+
retryOf: t.retryOf || '',
|
|
118
|
+
attentionDisposition: tasks.attentionDisposition(t),
|
|
90
119
|
files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
|
|
91
120
|
media: mediaOf(t)
|
|
92
121
|
};
|
|
@@ -103,21 +132,32 @@ function loadProviders() {
|
|
|
103
132
|
return Array.isArray(cache && cache.providers) ? cache.providers : [];
|
|
104
133
|
}
|
|
105
134
|
|
|
106
|
-
|
|
135
|
+
const STATE_EPOCH = crypto.randomUUID();
|
|
136
|
+
let stateRevision = 0;
|
|
137
|
+
|
|
138
|
+
/** 构建完整工作台状态(纯 JSON,可序列化);序号用于客户端拒绝迟到快照。 */
|
|
107
139
|
export function buildState() {
|
|
108
|
-
const
|
|
140
|
+
const taskRecords = loadTasks();
|
|
109
141
|
const providers = loadProviders();
|
|
110
142
|
|
|
111
|
-
const all = [...
|
|
112
|
-
const
|
|
113
|
-
const
|
|
143
|
+
const all = [...taskRecords].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
144
|
+
const stateOf = (t) => taskUserState(t);
|
|
145
|
+
const needsAttention = (t) => ['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(stateOf(t));
|
|
146
|
+
const acknowledged = (t) => tasks.attentionDisposition(t).status === 'acknowledged';
|
|
147
|
+
const running = all.filter((t) => ['queued', 'running'].includes(stateOf(t)) && !t.expired);
|
|
148
|
+
const attention = all.filter((t) => needsAttention(t) && !acknowledged(t) && !t.expired);
|
|
149
|
+
const terminal = all.filter((t) => ['succeeded', 'failed', 'canceled'].includes(stateOf(t))
|
|
150
|
+
|| (needsAttention(t) && acknowledged(t)) || t.expired);
|
|
114
151
|
const recent = terminal.slice(0, 100).map(taskPublic);
|
|
115
152
|
|
|
116
153
|
return {
|
|
154
|
+
stateEpoch: STATE_EPOCH,
|
|
155
|
+
stateRevision: ++stateRevision,
|
|
117
156
|
providers: providers.map(providerPublic),
|
|
118
157
|
tasks: {
|
|
119
158
|
recent,
|
|
120
159
|
recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
|
|
160
|
+
attention: attention.slice(0, 20).map(taskPublic),
|
|
121
161
|
running: running.slice(0, 10).map(taskPublic)
|
|
122
162
|
}
|
|
123
163
|
};
|
|
@@ -131,15 +171,44 @@ export function sseClientCount() {
|
|
|
131
171
|
}
|
|
132
172
|
|
|
133
173
|
const sseClients = new Set(); // 活跃 res(长连接)
|
|
174
|
+
const sseBlocked = new Set(); // 背压中的连接
|
|
175
|
+
const sseLatest = new WeakMap(); // 每个慢连接只保留最新状态
|
|
176
|
+
const sseDrainBound = new WeakSet();
|
|
134
177
|
let sseHeartbeat = null; // 心跳定时器(15s,防代理/浏览器空闲断连)
|
|
135
178
|
let sseThrottleTimer = null; // 推送节流(400ms 合并,防盯守高频 update 刷屏)
|
|
136
179
|
let ssePending = false;
|
|
137
180
|
let sseBound = false;
|
|
138
181
|
|
|
182
|
+
function bindSseDrain(res) {
|
|
183
|
+
sseBlocked.add(res);
|
|
184
|
+
if (sseDrainBound.has(res)) return;
|
|
185
|
+
sseDrainBound.add(res);
|
|
186
|
+
res.once('drain', () => {
|
|
187
|
+
sseDrainBound.delete(res);
|
|
188
|
+
sseBlocked.delete(res);
|
|
189
|
+
const latest = sseLatest.get(res);
|
|
190
|
+
sseLatest.delete(res);
|
|
191
|
+
if (latest !== undefined) pushSse(res, latest);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
139
195
|
function pushSse(res, data) {
|
|
140
196
|
if (!res || res.writableEnded || res.destroyed) return;
|
|
197
|
+
if (sseBlocked.has(res)) {
|
|
198
|
+
sseLatest.set(res, data);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
if (res.write(`data: ${data}\n\n`) === false) bindSseDrain(res);
|
|
203
|
+
} catch (_) {
|
|
204
|
+
/* 连接已死,close 事件会清理 */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pushSseComment(res, comment) {
|
|
209
|
+
if (!res || res.writableEnded || res.destroyed || sseBlocked.has(res)) return;
|
|
141
210
|
try {
|
|
142
|
-
res.write(
|
|
211
|
+
if (res.write(`: ${comment}\n\n`) === false) bindSseDrain(res);
|
|
143
212
|
} catch (_) {
|
|
144
213
|
/* 连接已死,close 事件会清理 */
|
|
145
214
|
}
|
|
@@ -170,7 +239,7 @@ function scheduleBroadcast() {
|
|
|
170
239
|
function ensureHeartbeat() {
|
|
171
240
|
if (sseHeartbeat) return;
|
|
172
241
|
sseHeartbeat = setInterval(() => {
|
|
173
|
-
for (const res of [...sseClients])
|
|
242
|
+
for (const res of [...sseClients]) pushSseComment(res, 'ping'); // 注释帧不覆盖背压期间的最新状态
|
|
174
243
|
if (!sseClients.size) stopHeartbeat();
|
|
175
244
|
}, 15000);
|
|
176
245
|
if (sseHeartbeat && sseHeartbeat.unref) sseHeartbeat.unref();
|
|
@@ -232,6 +301,8 @@ function serveSse(res) {
|
|
|
232
301
|
pushSse(res, JSON.stringify(buildState())); // 立即推当前状态
|
|
233
302
|
const onClose = () => {
|
|
234
303
|
sseClients.delete(res);
|
|
304
|
+
sseBlocked.delete(res);
|
|
305
|
+
sseLatest.delete(res);
|
|
235
306
|
if (!sseClients.size) {
|
|
236
307
|
stopHeartbeat();
|
|
237
308
|
if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
|
|
@@ -244,7 +315,7 @@ function serveSse(res) {
|
|
|
244
315
|
res.on('error', onClose);
|
|
245
316
|
}
|
|
246
317
|
|
|
247
|
-
/** webServer 前缀路由 handler
|
|
318
|
+
/** webServer 前缀路由 handler:状态、任务、动作与提示词优化器 API。 */
|
|
248
319
|
export function serveApi(req, res, ctx) {
|
|
249
320
|
try {
|
|
250
321
|
const method = String(req.method || 'GET').toUpperCase();
|
|
@@ -256,6 +327,8 @@ export function serveApi(req, res, ctx) {
|
|
|
256
327
|
if (urlPath === '/iris/api/upload') {
|
|
257
328
|
return handleUpload(req, res);
|
|
258
329
|
}
|
|
330
|
+
const promptMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
|
|
331
|
+
if (promptMatch) return handlePromptOptimizer(ctx, promptMatch[1], req, res);
|
|
259
332
|
const actionMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
|
|
260
333
|
if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
|
|
261
334
|
return handleAction(ctx, actionMatch[1], req, res);
|
|
@@ -269,6 +342,9 @@ export function serveApi(req, res, ctx) {
|
|
|
269
342
|
if (method === 'HEAD') return sendJson(res, 405, { error: 'method not allowed' });
|
|
270
343
|
return serveSse(res);
|
|
271
344
|
}
|
|
345
|
+
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
346
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
347
|
+
}
|
|
272
348
|
const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
|
|
273
349
|
if (taskMatch) {
|
|
274
350
|
return serveTaskDetail(taskMatch[1], method, res);
|
|
@@ -297,10 +373,12 @@ function sendJson(res, status, obj) {
|
|
|
297
373
|
|
|
298
374
|
/** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
|
|
299
375
|
const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
|
|
300
|
-
function
|
|
376
|
+
function handleJsonPost(req, res, execute) {
|
|
301
377
|
const chunks = [];
|
|
302
378
|
const controller = new AbortController();
|
|
303
379
|
let completed = false;
|
|
380
|
+
let totalBytes = 0;
|
|
381
|
+
let overflow = false;
|
|
304
382
|
const finish = (status, body) => {
|
|
305
383
|
if (completed) return;
|
|
306
384
|
completed = true;
|
|
@@ -309,32 +387,49 @@ function handleAction(ctx, name, req, res) {
|
|
|
309
387
|
const disconnect = () => { if (!completed) controller.abort(); };
|
|
310
388
|
if (typeof req.on === 'function') req.on('aborted', disconnect);
|
|
311
389
|
if (typeof res.on === 'function') res.on('close', disconnect);
|
|
312
|
-
|
|
313
|
-
let overflow = false;
|
|
314
|
-
req.on('data', (c) => {
|
|
390
|
+
req.on('data', (chunk) => {
|
|
315
391
|
if (overflow) return;
|
|
316
|
-
totalBytes +=
|
|
392
|
+
totalBytes += chunk.length;
|
|
317
393
|
if (totalBytes > MAX_BODY_BYTES) {
|
|
318
394
|
overflow = true;
|
|
319
|
-
chunks.length = 0;
|
|
395
|
+
chunks.length = 0;
|
|
320
396
|
return;
|
|
321
397
|
}
|
|
322
|
-
chunks.push(
|
|
398
|
+
chunks.push(chunk);
|
|
323
399
|
});
|
|
324
400
|
req.on('end', async () => {
|
|
325
401
|
if (overflow) return finish(413, { error: 'request body too large (limit 1MB)' });
|
|
326
402
|
try {
|
|
327
403
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
328
|
-
const
|
|
329
|
-
const result = await
|
|
404
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
405
|
+
const result = await execute(body, controller.signal);
|
|
330
406
|
finish(200, result);
|
|
331
407
|
} catch (err) {
|
|
332
|
-
finish(controller.signal.aborted ? 499 : 400, {
|
|
408
|
+
finish(controller.signal.aborted ? 499 : 400, {
|
|
409
|
+
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err)
|
|
410
|
+
});
|
|
333
411
|
}
|
|
334
412
|
});
|
|
335
413
|
req.on('error', () => finish(400, { error: 'bad request' }));
|
|
336
414
|
}
|
|
337
415
|
|
|
416
|
+
function handleAction(ctx, name, req, res) {
|
|
417
|
+
return handleJsonPost(req, res, (args, signal) => runAction(ctx, name, args, { signal }));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function handlePromptOptimizer(ctx, operation, req, res) {
|
|
421
|
+
return handleJsonPost(req, res, async (body, signal) => {
|
|
422
|
+
if (operation === 'optimize') return optimizePrompt(ctx, body, { signal });
|
|
423
|
+
if (operation === 'import') {
|
|
424
|
+
const imported = body && body.config !== undefined ? body.config : body;
|
|
425
|
+
return { ok: true, ...importPromptOptimizerConfig(imported) };
|
|
426
|
+
}
|
|
427
|
+
if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
|
|
428
|
+
if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled) };
|
|
429
|
+
throw new Error('unknown prompt optimizer operation');
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
338
433
|
/* ---------- 文件选择器 L2:本地文件上传(阶段 10) ---------- */
|
|
339
434
|
export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
340
435
|
export const UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -462,6 +557,10 @@ function serveTaskDetail(taskId, method, res) {
|
|
|
462
557
|
id: t.id,
|
|
463
558
|
cap: t.cap || '',
|
|
464
559
|
status: t.status || '',
|
|
560
|
+
userState: taskUserState(t),
|
|
561
|
+
schemaVersion: Number(t.schemaVersion || 1),
|
|
562
|
+
revision: Number(t.revision || 0),
|
|
563
|
+
...(isTaskV2(t) ? Object.fromEntries(V2_FACT_FIELDS.map((field) => [field, t[field]])) : {}),
|
|
465
564
|
progress: t.progress || '',
|
|
466
565
|
model: t.model || '',
|
|
467
566
|
providerName: t.providerName || '',
|
|
@@ -472,6 +571,11 @@ function serveTaskDetail(taskId, method, res) {
|
|
|
472
571
|
error: t.error || '',
|
|
473
572
|
mode: t.mode || '',
|
|
474
573
|
remoteTaskId: t.remoteTaskId || '',
|
|
574
|
+
retryOf: t.retryOf || '',
|
|
575
|
+
manualRetries: Array.isArray(t.manualRetries) ? t.manualRetries.filter((item) => item && item.taskId).map((item) => ({
|
|
576
|
+
taskId: String(item.taskId), createdAt: item.createdAt || ''
|
|
577
|
+
})) : [],
|
|
578
|
+
attentionDisposition: tasks.attentionDisposition(t),
|
|
475
579
|
files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
|
|
476
580
|
media: mediaOf(t),
|
|
477
581
|
attachments: Array.isArray(t.attachments) ? t.attachments.map((a) => ({
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const SKILL_NAMES = ['iris-verify-ui', 'iris-compose-media'];
|
|
8
|
+
const SKILL_ROOT = fileURLToPath(new URL('../.dsh/skills/', import.meta.url));
|
|
9
|
+
|
|
10
|
+
function parseScalar(value, file) {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (trimmed.startsWith('"')) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(trimmed);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
throw new Error(`随包 Skill frontmatter 字符串无效:${file}: ${error.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
20
|
+
return trimmed.slice(1, -1).replaceAll("''", "'");
|
|
21
|
+
}
|
|
22
|
+
return trimmed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 读取由 Iris 自己发布的固定 Skill;不把此解析器当作通用 YAML 解析器。 */
|
|
26
|
+
export function loadBundledSkills() {
|
|
27
|
+
return SKILL_NAMES.map((expectedName) => {
|
|
28
|
+
const directory = path.join(SKILL_ROOT, expectedName);
|
|
29
|
+
const file = path.join(directory, 'SKILL.md');
|
|
30
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
31
|
+
const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
32
|
+
if (!frontmatter) throw new Error(`随包 Skill 缺少 frontmatter:${file}`);
|
|
33
|
+
|
|
34
|
+
const nameLine = frontmatter[1].match(/^name:\s*(.+)$/m);
|
|
35
|
+
const descriptionLine = frontmatter[1].match(/^description:\s*(.+)$/m);
|
|
36
|
+
const name = nameLine ? parseScalar(nameLine[1], file) : '';
|
|
37
|
+
const description = descriptionLine ? parseScalar(descriptionLine[1], file) : '';
|
|
38
|
+
if (name !== expectedName) throw new Error(`随包 Skill 名称不匹配:${file}`);
|
|
39
|
+
if (!description) throw new Error(`随包 Skill 缺少 description:${file}`);
|
|
40
|
+
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
name,
|
|
43
|
+
description,
|
|
44
|
+
content: source.slice(frontmatter[0].length).replace(/^\r?\n/, ''),
|
|
45
|
+
source: 'bundled',
|
|
46
|
+
provider: 'iris-bundled',
|
|
47
|
+
path: file,
|
|
48
|
+
resourceBase: Object.freeze({ kind: 'directory', path: directory })
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 注册为宿主级 runtime Skill;DSH 项目目录中的同名 Skill 仍按原生优先级胜出。 */
|
|
54
|
+
export function registerBundledSkills(ctx) {
|
|
55
|
+
const registry = ctx && (ctx.skills || (typeof ctx.get === 'function' && ctx.get('skills')));
|
|
56
|
+
if (!registry || typeof registry.register !== 'function') {
|
|
57
|
+
throw new Error('DSH Skill registry 不可用');
|
|
58
|
+
}
|
|
59
|
+
const skills = loadBundledSkills();
|
|
60
|
+
for (const skill of skills) registry.register(skill);
|
|
61
|
+
return skills.map((skill) => skill.name);
|
|
62
|
+
}
|