@js-eyes/protocol 2.4.0 → 2.6.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/skills.js +279 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@js-eyes/protocol",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Shared protocol constants for JS Eyes runtime packages",
5
5
  "main": "index.js",
6
6
  "files": [
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
- if (!fs.existsSync(skillsDir)) return [];
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
- function registerOpenClawTools(api, adapter, options = {}) {
192
- const logger = options.logger || api.logger || console;
193
- const registeredNames = options.registeredNames || null;
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
- const definition = {
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(tool.name);
473
+ registeredNames.add(entry.toolName);
234
474
  }
235
- summary.registered.push(tool.name);
475
+ summary.registered.push(entry.toolName);
236
476
  } catch (error) {
237
- summary.failed.push({ name: tool.name, reason: error.message });
238
- logger.warn(`[js-eyes] Failed to register tool "${tool.name}" from ${sourceName}: ${error.message}`);
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;