@js-eyes/protocol 2.4.0 → 2.5.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/package.json +1 -1
- package/skills.js +279 -25
package/package.json
CHANGED
package/skills.js
CHANGED
|
@@ -32,6 +32,46 @@ function hasSkillContract(skillDir) {
|
|
|
32
32
|
return fs.existsSync(path.join(skillDir, SKILL_CONTRACT_FILE));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function safeStat(target) {
|
|
36
|
+
try {
|
|
37
|
+
return fs.statSync(target);
|
|
38
|
+
} catch (_) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 列出某个目录下被视为「候选 skill 目录」的直接子项绝对路径。
|
|
45
|
+
*
|
|
46
|
+
* 与裸 readdirSync 相比多做两件事:
|
|
47
|
+
* 1. 把 symlink-to-directory 也视为目录(Dirent.isDirectory() 对 symlink 为 false)。
|
|
48
|
+
* 2. 对于不存在 / 不可读的目录,返回空数组而不是抛错。
|
|
49
|
+
*
|
|
50
|
+
* 只扫 1 层,不递归。
|
|
51
|
+
*/
|
|
52
|
+
function listSkillDirectories(dir) {
|
|
53
|
+
if (!dir || !fs.existsSync(dir)) return [];
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
} catch (_) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const results = [];
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const full = path.join(dir, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
results.push(full);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (entry.isSymbolicLink()) {
|
|
68
|
+
const stat = safeStat(full);
|
|
69
|
+
if (stat && stat.isDirectory()) results.push(full);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
|
|
35
75
|
function resolveSkillsDir(paths, config = {}) {
|
|
36
76
|
if (config.skillsDir) {
|
|
37
77
|
return path.resolve(config.skillsDir);
|
|
@@ -45,6 +85,67 @@ function resolveSkillsDir(paths, config = {}) {
|
|
|
45
85
|
return path.resolve('skills');
|
|
46
86
|
}
|
|
47
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 归一化多源配置:primary + extras。
|
|
90
|
+
*
|
|
91
|
+
* 入参:
|
|
92
|
+
* - { primary, extras }:primary 是单个目录字符串;extras 是字符串数组或 undefined。
|
|
93
|
+
* - 或 { paths, config }:会自动走 resolveSkillsDir 推导 primary,并读 config.extraSkillDirs。
|
|
94
|
+
*
|
|
95
|
+
* 出参:
|
|
96
|
+
* {
|
|
97
|
+
* primary: '/abs/primary',
|
|
98
|
+
* extras: [{ path: '/abs/x', kind: 'skill' | 'dir' }, ...],
|
|
99
|
+
* invalid: [{ path, reason }] // 不存在或读不到的 extra 条目
|
|
100
|
+
* }
|
|
101
|
+
*
|
|
102
|
+
* kind 判定:自身含 skill.contract.js => 'skill';否则当作父目录 'dir'。
|
|
103
|
+
* 对重复路径做去重(primary 自己也不会出现在 extras 里)。
|
|
104
|
+
*/
|
|
105
|
+
function resolveSkillSources(input = {}) {
|
|
106
|
+
let primary = input.primary;
|
|
107
|
+
let extrasInput = input.extras;
|
|
108
|
+
|
|
109
|
+
if (!primary) {
|
|
110
|
+
primary = resolveSkillsDir(input.paths, input.config || {});
|
|
111
|
+
}
|
|
112
|
+
primary = primary ? path.resolve(primary) : '';
|
|
113
|
+
|
|
114
|
+
if (!extrasInput && input.config && Array.isArray(input.config.extraSkillDirs)) {
|
|
115
|
+
extrasInput = input.config.extraSkillDirs;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rawExtras = Array.isArray(extrasInput) ? extrasInput : [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
if (primary) seen.add(primary);
|
|
121
|
+
|
|
122
|
+
const extras = [];
|
|
123
|
+
const invalid = [];
|
|
124
|
+
for (const raw of rawExtras) {
|
|
125
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
126
|
+
invalid.push({ path: String(raw), reason: 'invalid-type' });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const abs = path.resolve(raw);
|
|
130
|
+
if (seen.has(abs)) continue;
|
|
131
|
+
seen.add(abs);
|
|
132
|
+
|
|
133
|
+
if (!fs.existsSync(abs)) {
|
|
134
|
+
invalid.push({ path: abs, reason: 'not-found' });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const stat = safeStat(abs);
|
|
138
|
+
if (!stat || !stat.isDirectory()) {
|
|
139
|
+
invalid.push({ path: abs, reason: 'not-a-directory' });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const kind = hasSkillContract(abs) ? 'skill' : 'dir';
|
|
143
|
+
extras.push({ path: abs, kind });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { primary, extras, invalid };
|
|
147
|
+
}
|
|
148
|
+
|
|
48
149
|
function getOpenClawConfigPath(options = {}) {
|
|
49
150
|
const env = options.env || process.env;
|
|
50
151
|
const home = options.home || os.homedir();
|
|
@@ -88,11 +189,7 @@ function normalizeSkillMetadata(skillDir) {
|
|
|
88
189
|
}
|
|
89
190
|
|
|
90
191
|
function discoverLocalSkills(skillsDir) {
|
|
91
|
-
|
|
92
|
-
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
93
|
-
return entries
|
|
94
|
-
.filter((entry) => entry.isDirectory())
|
|
95
|
-
.map((entry) => path.join(skillsDir, entry.name))
|
|
192
|
+
return listSkillDirectories(skillsDir)
|
|
96
193
|
.filter((skillDir) => hasSkillContract(skillDir))
|
|
97
194
|
.map((skillDir) => normalizeSkillMetadata(skillDir))
|
|
98
195
|
.filter((skill) => skill && skill.id);
|
|
@@ -104,6 +201,104 @@ function readSkillById(skillsDir, skillId) {
|
|
|
104
201
|
return normalizeSkillMetadata(skillDir);
|
|
105
202
|
}
|
|
106
203
|
|
|
204
|
+
/**
|
|
205
|
+
* 按 sources 发现所有可用 skill,返回带 source / sourcePath 注解的统一列表。
|
|
206
|
+
*
|
|
207
|
+
* sources 由 resolveSkillSources() 生成;也接受简化形态 { primary, extras: [string] }
|
|
208
|
+
* (会内部再跑一遍 resolveSkillSources 归一化)。
|
|
209
|
+
*
|
|
210
|
+
* 冲突策略:同 id 多源命中时 primary 优先;后续 extras 里的同 id 被跳过。
|
|
211
|
+
* 每次跳过会回调 onConflict({ id, winner, loser }),调用方自行决定是否打日志。
|
|
212
|
+
*
|
|
213
|
+
* 返回值:
|
|
214
|
+
* {
|
|
215
|
+
* skills: [{ ...normalizeSkillMetadata, source: 'primary'|'extra', sourcePath }],
|
|
216
|
+
* conflicts: [{ id, winner: { source, path }, loser: { source, path } }],
|
|
217
|
+
* invalid: sources.invalid // 透传,便于 CLI 展示
|
|
218
|
+
* }
|
|
219
|
+
*/
|
|
220
|
+
function discoverSkillsFromSources(sources, options = {}) {
|
|
221
|
+
const normalized = sources && Array.isArray(sources.extras) && sources.extras.every((e) => e && typeof e === 'object')
|
|
222
|
+
? sources
|
|
223
|
+
: resolveSkillSources(sources || {});
|
|
224
|
+
|
|
225
|
+
const { primary, extras, invalid = [] } = normalized;
|
|
226
|
+
const { onConflict } = options;
|
|
227
|
+
|
|
228
|
+
const byId = new Map();
|
|
229
|
+
const conflicts = [];
|
|
230
|
+
|
|
231
|
+
const register = (skill, source, sourcePath) => {
|
|
232
|
+
if (!skill || !skill.id) return;
|
|
233
|
+
if (byId.has(skill.id)) {
|
|
234
|
+
const existing = byId.get(skill.id);
|
|
235
|
+
const conflict = {
|
|
236
|
+
id: skill.id,
|
|
237
|
+
winner: { source: existing.source, path: existing.sourcePath },
|
|
238
|
+
loser: { source, path: sourcePath },
|
|
239
|
+
};
|
|
240
|
+
conflicts.push(conflict);
|
|
241
|
+
if (typeof onConflict === 'function') {
|
|
242
|
+
try { onConflict(conflict); } catch (_) { /* noop */ }
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
byId.set(skill.id, { ...skill, source, sourcePath });
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (primary) {
|
|
250
|
+
for (const skill of discoverLocalSkills(primary)) {
|
|
251
|
+
register(skill, 'primary', primary);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const extra of extras) {
|
|
256
|
+
if (extra.kind === 'skill') {
|
|
257
|
+
if (!hasSkillContract(extra.path)) continue;
|
|
258
|
+
const meta = normalizeSkillMetadata(extra.path);
|
|
259
|
+
if (meta && meta.id) register(meta, 'extra', extra.path);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
for (const skill of discoverLocalSkills(extra.path)) {
|
|
263
|
+
register(skill, 'extra', extra.path);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
skills: Array.from(byId.values()),
|
|
269
|
+
conflicts,
|
|
270
|
+
invalid,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 在 primary + extras 的联合范围内按 id 找 skill。
|
|
276
|
+
* primary 优先;extras 按传入顺序其次。命中返回带 source / sourcePath 的对象,未命中返回 null。
|
|
277
|
+
*/
|
|
278
|
+
function readSkillByIdFromSources(input = {}) {
|
|
279
|
+
const { id } = input;
|
|
280
|
+
if (!id) return null;
|
|
281
|
+
const { primary, extras } = Array.isArray(input.extras) && input.extras.every((e) => e && typeof e === 'object')
|
|
282
|
+
? input
|
|
283
|
+
: resolveSkillSources(input);
|
|
284
|
+
|
|
285
|
+
if (primary) {
|
|
286
|
+
const hit = readSkillById(primary, id);
|
|
287
|
+
if (hit) return { ...hit, source: 'primary', sourcePath: primary };
|
|
288
|
+
}
|
|
289
|
+
for (const extra of extras) {
|
|
290
|
+
if (extra.kind === 'skill') {
|
|
291
|
+
if (!hasSkillContract(extra.path)) continue;
|
|
292
|
+
const meta = normalizeSkillMetadata(extra.path);
|
|
293
|
+
if (meta && meta.id === id) return { ...meta, source: 'extra', sourcePath: extra.path };
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const hit = readSkillById(extra.path, id);
|
|
297
|
+
if (hit) return { ...hit, source: 'extra', sourcePath: extra.path };
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
107
302
|
async function fetchSkillsRegistry(registryUrl) {
|
|
108
303
|
const response = await fetch(registryUrl, {
|
|
109
304
|
headers: { Accept: 'application/json' },
|
|
@@ -188,21 +383,40 @@ function isSkillEnabled(config = {}, skillId, legacyState = {}) {
|
|
|
188
383
|
return false;
|
|
189
384
|
}
|
|
190
385
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
386
|
+
/**
|
|
387
|
+
* 从 adapter.tools 构建 OpenClaw 工具定义列表(不执行 api.registerTool)。
|
|
388
|
+
*
|
|
389
|
+
* 用于 SkillRegistry 等需要"先收集再统一注册"的场景;registerOpenClawTools
|
|
390
|
+
* 的注册循环基于此函数的输出。
|
|
391
|
+
*
|
|
392
|
+
* 返回:
|
|
393
|
+
* {
|
|
394
|
+
* toolDefs: [{ definition, optional, toolName }], // 通过过滤的工具
|
|
395
|
+
* summary: { registered: [], skipped: [{ name, reason }], failed: [] },
|
|
396
|
+
* }
|
|
397
|
+
*
|
|
398
|
+
* 注意:本函数不会写入 registeredNames;由调用方在成功注册后维护。
|
|
399
|
+
*/
|
|
400
|
+
function buildAdapterTools(adapter, options = {}) {
|
|
401
|
+
const logger = options.logger || console;
|
|
194
402
|
const sourceName = options.sourceName || adapter?.id || 'js-eyes-skill';
|
|
195
403
|
const declaredTools = Array.isArray(options.declaredTools) && options.declaredTools.length > 0
|
|
196
404
|
? new Set(options.declaredTools)
|
|
197
405
|
: null;
|
|
406
|
+
const registeredNames = options.registeredNames || null;
|
|
198
407
|
const wrapTool = typeof options.wrapTool === 'function' ? options.wrapTool : null;
|
|
408
|
+
|
|
409
|
+
const toolDefs = [];
|
|
199
410
|
const summary = {
|
|
200
411
|
registered: [],
|
|
201
412
|
skipped: [],
|
|
202
413
|
failed: [],
|
|
203
414
|
};
|
|
415
|
+
// Track names we've already emitted in this batch so intra-batch duplicates
|
|
416
|
+
// are rejected even when registeredNames is not mutated here.
|
|
417
|
+
const localSeen = new Set();
|
|
204
418
|
|
|
205
|
-
for (const tool of adapter.tools || []) {
|
|
419
|
+
for (const tool of (adapter && adapter.tools) || []) {
|
|
206
420
|
if (!tool || !tool.name) {
|
|
207
421
|
summary.skipped.push({ name: '(anonymous)', reason: 'missing-name' });
|
|
208
422
|
logger.warn(`[js-eyes] Skipping tool with missing name from ${sourceName}`);
|
|
@@ -213,29 +427,55 @@ function registerOpenClawTools(api, adapter, options = {}) {
|
|
|
213
427
|
logger.warn(`[js-eyes] Skipping undeclared tool "${tool.name}" from ${sourceName}`);
|
|
214
428
|
continue;
|
|
215
429
|
}
|
|
216
|
-
if (registeredNames && registeredNames.has(tool.name)) {
|
|
430
|
+
if ((registeredNames && registeredNames.has(tool.name)) || localSeen.has(tool.name)) {
|
|
217
431
|
summary.skipped.push({ name: tool.name, reason: 'duplicate-name' });
|
|
218
432
|
logger.warn(`[js-eyes] Skipping duplicate tool "${tool.name}" from ${sourceName}`);
|
|
219
433
|
continue;
|
|
220
434
|
}
|
|
435
|
+
localSeen.add(tool.name);
|
|
436
|
+
|
|
437
|
+
const definition = {
|
|
438
|
+
name: tool.name,
|
|
439
|
+
label: tool.label,
|
|
440
|
+
description: tool.description,
|
|
441
|
+
parameters: tool.parameters,
|
|
442
|
+
execute: tool.execute,
|
|
443
|
+
};
|
|
444
|
+
const wrapped = wrapTool ? wrapTool(definition, { source: sourceName }) : definition;
|
|
445
|
+
|
|
446
|
+
toolDefs.push({
|
|
447
|
+
toolName: tool.name,
|
|
448
|
+
definition: wrapped,
|
|
449
|
+
optional: Boolean(tool.optional),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return { toolDefs, summary };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function registerOpenClawTools(api, adapter, options = {}) {
|
|
457
|
+
const logger = options.logger || api.logger || console;
|
|
458
|
+
const registeredNames = options.registeredNames || null;
|
|
459
|
+
const sourceName = options.sourceName || adapter?.id || 'js-eyes-skill';
|
|
221
460
|
|
|
461
|
+
const { toolDefs, summary } = buildAdapterTools(adapter, {
|
|
462
|
+
logger,
|
|
463
|
+
sourceName,
|
|
464
|
+
declaredTools: options.declaredTools,
|
|
465
|
+
registeredNames,
|
|
466
|
+
wrapTool: options.wrapTool,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
for (const entry of toolDefs) {
|
|
222
470
|
try {
|
|
223
|
-
|
|
224
|
-
name: tool.name,
|
|
225
|
-
label: tool.label,
|
|
226
|
-
description: tool.description,
|
|
227
|
-
parameters: tool.parameters,
|
|
228
|
-
execute: tool.execute,
|
|
229
|
-
};
|
|
230
|
-
const wrapped = wrapTool ? wrapTool(definition, { source: sourceName }) : definition;
|
|
231
|
-
api.registerTool(wrapped, tool.optional ? { optional: true } : undefined);
|
|
471
|
+
api.registerTool(entry.definition, entry.optional ? { optional: true } : undefined);
|
|
232
472
|
if (registeredNames) {
|
|
233
|
-
registeredNames.add(
|
|
473
|
+
registeredNames.add(entry.toolName);
|
|
234
474
|
}
|
|
235
|
-
summary.registered.push(
|
|
475
|
+
summary.registered.push(entry.toolName);
|
|
236
476
|
} catch (error) {
|
|
237
|
-
summary.failed.push({ name:
|
|
238
|
-
logger.warn(`[js-eyes] Failed to register tool "${
|
|
477
|
+
summary.failed.push({ name: entry.toolName, reason: error.message });
|
|
478
|
+
logger.warn(`[js-eyes] Failed to register tool "${entry.toolName}" from ${sourceName}: ${error.message}`);
|
|
239
479
|
}
|
|
240
480
|
}
|
|
241
481
|
|
|
@@ -554,28 +794,42 @@ function runSkillCli(options) {
|
|
|
554
794
|
});
|
|
555
795
|
}
|
|
556
796
|
|
|
557
|
-
module.exports
|
|
797
|
+
// Assign to (rather than replace) module.exports so that modules which have
|
|
798
|
+
// already captured a reference during circular require — notably
|
|
799
|
+
// ./skill-registry — observe the final API once this module finishes loading.
|
|
800
|
+
Object.assign(module.exports, {
|
|
558
801
|
INSTALL_MANIFEST_FILE,
|
|
559
802
|
INTEGRITY_FILE,
|
|
560
803
|
SKILL_CONTRACT_FILE,
|
|
561
804
|
applySkillInstall,
|
|
805
|
+
buildAdapterTools,
|
|
562
806
|
cleanupStaging,
|
|
563
807
|
discoverLocalSkills,
|
|
808
|
+
discoverSkillsFromSources,
|
|
564
809
|
fetchSkillsRegistry,
|
|
565
810
|
getLegacyOpenClawSkillState,
|
|
566
811
|
getOpenClawConfigPath,
|
|
567
812
|
getSkillsState,
|
|
568
813
|
installSkillFromRegistry,
|
|
569
814
|
isSkillEnabled,
|
|
815
|
+
listSkillDirectories,
|
|
570
816
|
loadSkillContract,
|
|
571
817
|
normalizeSkillMetadata,
|
|
572
818
|
planSkillInstall,
|
|
573
819
|
readSkillById,
|
|
820
|
+
readSkillByIdFromSources,
|
|
574
821
|
readSkillIntegrity,
|
|
575
822
|
registerOpenClawTools,
|
|
823
|
+
resolveSkillSources,
|
|
576
824
|
resolveSkillsDir,
|
|
577
825
|
resolveOpenClawPluginEntry,
|
|
578
826
|
runSkillCli,
|
|
579
827
|
verifySkillIntegrity,
|
|
580
828
|
writeIntegrityManifest,
|
|
581
|
-
};
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// After our own exports are populated, pull in the registry factory. This must
|
|
832
|
+
// happen last so skill-registry.js sees a fully-populated skills API.
|
|
833
|
+
const skillRegistry = require('./skill-registry');
|
|
834
|
+
module.exports.createSkillRegistry = skillRegistry.createSkillRegistry;
|
|
835
|
+
module.exports.purgeRequireCacheFor = skillRegistry.purgeRequireCacheFor;
|