@js-eyes/protocol 2.8.1 → 2.8.2

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/skill-registry.js CHANGED
@@ -1,745 +1,745 @@
1
- 'use strict';
2
-
3
- /**
4
- * SkillRegistry — js-eyes 技能运行时注册表
5
- *
6
- * 通过两种绑定模式实现零重启热加载:
7
- * - routerMode=true(OpenClaw 单工具模式):只维护 actionBindings,由 `js-eyes`
8
- * 总线工具按 `skill/<skillId>/<action>` 委派执行,不向宿主注册子技能工具;
9
- * - routerMode=false(兼容/测试模式):保留工具级 dispatcher 间接层。
10
- *
11
- * 兼容 dispatcher 模式:
12
- * - 插件启动时 api.registerTool(name, dispatcher) 仅对每个 tool name 注册一次稳定闭包;
13
- * - 每个 dispatcher 在调用时从 toolBindings.get(name) 查当前实现并委派;
14
- * - 热加载只改 toolBindings 映射,不触碰宿主注册表。
15
- *
16
- * 对「全新 tool name 的首次出现」(init 之后)会尝试 api.registerTool;
17
- * 若宿主在运行时拒绝新工具名,降级为记录 warning,其余变更仍然 0 重启。
18
- *
19
- * Schema 透传:dispatcher 对象会携带 skill contract 的真实 `label`/`description`/`parameters`,
20
- * 让 OpenClaw / LLM 看到正确的入参约束(required / properties 等)。热加载时若 contract
21
- * 改了 schema,会按**引用** mutate 已注册的 dispatcher 对象;若某些 OpenClaw 宿主对 tool
22
- * 对象做了深拷贝/快照,则 mutate 不会生效,但首次注册的 schema 仍然是正确的。
23
- * routerMode 下 OpenClaw 只看到 `js-eyes` 的总线 schema,子技能 schema 作为内部 definition
24
- * 保留给后续 introspection / 文档输出使用。
25
- */
26
-
27
- const fs = require('fs');
28
- const path = require('path');
29
-
30
- // Use a lazy module reference to avoid a circular require hazard: skills.js also
31
- // re-exports factories from this module.
32
- const skillsApi = require('./skills');
33
- function buildAdapterTools(...args) { return skillsApi.buildAdapterTools(...args); }
34
- function discoverSkillsFromSources(...args) { return skillsApi.discoverSkillsFromSources(...args); }
35
- function getLegacyOpenClawSkillState(...args) { return skillsApi.getLegacyOpenClawSkillState(...args); }
36
- function isSkillEnabled(...args) { return skillsApi.isSkillEnabled(...args); }
37
- function loadSkillContract(...args) { return skillsApi.loadSkillContract(...args); }
38
- function readSkillIntegrity(...args) { return skillsApi.readSkillIntegrity(...args); }
39
- function resolveSkillSources(...args) { return skillsApi.resolveSkillSources(...args); }
40
- function skillToolActionName(...args) { return skillsApi.skillToolActionName(...args); }
41
- function verifySkillIntegrity(...args) { return skillsApi.verifySkillIntegrity(...args); }
42
-
43
- const { verifyExtraDir: verifyExtraSkillDir } = require('./extra-integrity');
44
-
45
- const DEFAULT_UNAVAILABLE_MESSAGE = (name) =>
46
- `Tool "${name}" is not currently loaded (skill disabled, removed, or reloading).`;
47
-
48
- function noopLogger() {
49
- const fn = () => {};
50
- return { info: fn, warn: fn, error: fn };
51
- }
52
-
53
- function makeLogger(candidate) {
54
- if (!candidate) return noopLogger();
55
- return {
56
- info: typeof candidate.info === 'function' ? candidate.info.bind(candidate) : () => {},
57
- warn: typeof candidate.warn === 'function' ? candidate.warn.bind(candidate) : () => {},
58
- error: typeof candidate.error === 'function' ? candidate.error.bind(candidate) : () => {},
59
- };
60
- }
61
-
62
- function isBundledPrimarySkill(skill) {
63
- if (!skill || skill.source !== 'primary') return false;
64
- const sourcePath = skill.sourcePath || '';
65
- if (!sourcePath || path.basename(sourcePath) !== 'skills') return false;
66
- const bundleRoot = path.dirname(sourcePath);
67
- return (
68
- fs.existsSync(path.join(bundleRoot, 'openclaw-plugin'))
69
- && fs.existsSync(path.join(bundleRoot, 'skills'))
70
- );
71
- }
72
-
73
- /**
74
- * 计算 skillDir 内"驱动热更"的关键文件的指纹(mtime 组合)。
75
- * 任一文件缺失按 0 处理;出错时退化为空字符串(此时 reload 语义保守:会认为"没变")。
76
- * 只用 mtime 是因为 chokidar 已经有 awaitWriteFinish 保护;要更强隔离可改 sha1。
77
- */
78
- function computeSkillFingerprint(skillDir) {
79
- if (!skillDir) return '';
80
- const targets = ['skill.contract.js', 'package.json'];
81
- const parts = [];
82
- for (const name of targets) {
83
- try {
84
- const st = fs.statSync(path.join(skillDir, name));
85
- parts.push(`${name}:${st.mtimeMs || 0}:${st.size || 0}`);
86
- } catch (_) {
87
- parts.push(`${name}:0:0`);
88
- }
89
- }
90
- return parts.join('|');
91
- }
92
-
93
- /**
94
- * 深度清理 require.cache:删除所有位于 skillDir 下(排除 node_modules)
95
- * 的已缓存模块,避免热加载时沿用旧模块实例。
96
- */
97
- function purgeRequireCacheFor(skillDir) {
98
- if (!skillDir) return 0;
99
- let normalized;
100
- try {
101
- normalized = fs.realpathSync(skillDir);
102
- } catch (_) {
103
- normalized = path.resolve(skillDir);
104
- }
105
- const prefix = normalized.endsWith(path.sep) ? normalized : normalized + path.sep;
106
- let purged = 0;
107
- for (const key of Object.keys(require.cache)) {
108
- if (!key) continue;
109
- if (key === normalized || key.startsWith(prefix)) {
110
- if (key.includes(`${path.sep}node_modules${path.sep}`)) continue;
111
- delete require.cache[key];
112
- purged++;
113
- }
114
- }
115
- return purged;
116
- }
117
-
118
- /**
119
- * 创建 SkillRegistry。
120
- *
121
- * options:
122
- * - api: OpenClaw Plugin API(必需),需要 api.registerTool / api.logger
123
- * - sources: 初始 sources(resolveSkillSources 的结果)。init/reload 时会重新解析。
124
- * - pluginConfig: 传给 createOpenClawAdapter 的 plugin 配置
125
- * - configLoader: () => hostConfig;默认从 @js-eyes/config 加载
126
- * - setConfigValue: (key, value) => void;写入 host config(extras 默认 enable 用)
127
- * - skillsDir: primary 目录(用于 resolveSkillSources 回退)
128
- * - extrasProvider: () => string[],每次 reload 重新取 extras 列表
129
- * - wrapSensitiveTool: 复用插件的敏感工具包装器
130
- * - logger: 日志通道
131
- * - builtinToolNames: 禁止被技能覆盖的内置工具名集合
132
- * - onConflict: 冲突回调
133
- */
134
- function createSkillRegistry(options = {}) {
135
- const {
136
- api,
137
- pluginConfig = {},
138
- wrapSensitiveTool = null,
139
- builtinToolNames = [],
140
- routerMode = false,
141
- // When true (default when a watcher is active), we set suppressNextReload
142
- // after our own setConfigValue writes so the chokidar echo doesn't cause a
143
- // duplicate reload. Leave this false in test harnesses that drive reload()
144
- // manually without a watcher.
145
- suppressSelfWrites = true,
146
- } = options;
147
-
148
- if (!routerMode && (!api || typeof api.registerTool !== 'function')) {
149
- throw new Error('createSkillRegistry: api.registerTool is required');
150
- }
151
-
152
- const logger = makeLogger(options.logger || (api && api.logger));
153
- const configLoader = typeof options.configLoader === 'function'
154
- ? options.configLoader
155
- : () => ({});
156
- const setConfigValue = typeof options.setConfigValue === 'function'
157
- ? options.setConfigValue
158
- : () => {};
159
- const extrasProvider = typeof options.extrasProvider === 'function'
160
- ? options.extrasProvider
161
- : () => {
162
- const cfg = configLoader();
163
- return Array.isArray(cfg.extraSkillDirs) ? cfg.extraSkillDirs : [];
164
- };
165
- const skillsDir = options.skillsDir || '';
166
-
167
- // id -> { source, sourcePath, skillDir, contract, adapter, toolNames, enabled, dispose }
168
- const skills = new Map();
169
- // toolName -> { skillId, definition, optional }
170
- const toolBindings = new Map();
171
- // action -> { skillId, toolName, definition, optional }
172
- const actionBindings = new Map();
173
- // toolName -> dispatcher object (registered once per name; kept by reference so we can
174
- // mutate `description`/`parameters`/`label` on hot-reload to keep OpenClaw-visible schema in sync)
175
- const dispatchers = new Map();
176
- // Reserved names (built-ins + sensitive wrapper fixed names); skills cannot override.
177
- const reservedToolNames = new Set(builtinToolNames);
178
-
179
- let reloadInFlight = null;
180
- let suppressNextReload = false;
181
- let disposed = false;
182
- // Once-per-path dedup sets so hot reloads don't flood the log with the same
183
- // "Ignoring extra skill dir" / "Skipped extra skill" warnings on every tick.
184
- const warnedInvalidPaths = new Set();
185
- const warnedConflictKeys = new Set();
186
-
187
- function getState(skillId) {
188
- return skills.get(skillId) || null;
189
- }
190
-
191
- function deriveDispatcherMeta(toolName, definition) {
192
- const label = (definition && typeof definition.label === 'string' && definition.label)
193
- || toolName;
194
- const description = (definition && typeof definition.description === 'string' && definition.description)
195
- || `[js-eyes dispatcher] ${toolName}`;
196
- const parameters = (definition && definition.parameters && typeof definition.parameters === 'object')
197
- ? definition.parameters
198
- : { type: 'object', properties: {} };
199
- return { label, description, parameters };
200
- }
201
-
202
- function safeStringify(value) {
203
- try { return JSON.stringify(value); } catch (_) { return null; }
204
- }
205
-
206
- function ensureDispatcher(toolName, optional, definition) {
207
- const existing = dispatchers.get(toolName);
208
- const meta = deriveDispatcherMeta(toolName, definition);
209
-
210
- if (existing) {
211
- // Hot-reload path: the dispatcher was already registered with OpenClaw.
212
- // Mutate in place so hosts that keep the tool object by reference (e.g.
213
- // captured-registration's `tools.push(tool)`) surface the new schema; hosts that
214
- // snapshotted at registration time will keep the first-load schema, which is
215
- // already accurate for the common case.
216
- const changed =
217
- existing.label !== meta.label
218
- || existing.description !== meta.description
219
- || safeStringify(existing.parameters) !== safeStringify(meta.parameters);
220
- if (changed) {
221
- existing.label = meta.label;
222
- existing.description = meta.description;
223
- existing.parameters = meta.parameters;
224
- logger.info(
225
- `[js-eyes] Refreshed dispatcher schema for tool "${toolName}" (hot-reload; a one-time OpenClaw restart may be required if the host snapshots tool metadata)`,
226
- );
227
- }
228
- return { ok: true };
229
- }
230
-
231
- const dispatcher = {
232
- name: toolName,
233
- label: meta.label,
234
- description: meta.description,
235
- parameters: meta.parameters,
236
- async execute(toolCallId, params) {
237
- const binding = toolBindings.get(toolName);
238
- if (!binding) {
239
- return { content: [{ type: 'text', text: DEFAULT_UNAVAILABLE_MESSAGE(toolName) }] };
240
- }
241
- // Delegate via the current binding; execute is the authoritative behavior.
242
- // label/description/parameters on `dispatcher` are kept in sync via ensureDispatcher
243
- // on every applyBindings() so OpenClaw-visible metadata follows the latest contract.
244
- const def = binding.definition;
245
- return def.execute(toolCallId, params);
246
- },
247
- };
248
- try {
249
- api.registerTool(dispatcher, optional ? { optional: true } : undefined);
250
- dispatchers.set(toolName, dispatcher);
251
- return { ok: true };
252
- } catch (error) {
253
- logger.warn(
254
- `[js-eyes] Failed to register dispatcher for tool "${toolName}": ${error.message}. `
255
- + `A one-time OpenClaw restart may be required to expose this new tool.`,
256
- );
257
- return { ok: false, error };
258
- }
259
- }
260
-
261
- async function callDispose(state) {
262
- if (!state || typeof state.dispose !== 'function') return;
263
- try {
264
- await state.dispose();
265
- } catch (error) {
266
- logger.warn(
267
- `[js-eyes] dispose() for skill "${state.id}" threw: ${error.message}`,
268
- );
269
- }
270
- }
271
-
272
- function removeBindingsFor(skillId) {
273
- const removed = [];
274
- for (const [name, binding] of toolBindings) {
275
- if (binding.skillId === skillId) {
276
- toolBindings.delete(name);
277
- removed.push(name);
278
- }
279
- }
280
- for (const [action, binding] of actionBindings) {
281
- if (binding.skillId === skillId) {
282
- actionBindings.delete(action);
283
- }
284
- }
285
- return removed;
286
- }
287
-
288
- async function disposeSkill(skillId) {
289
- const state = skills.get(skillId);
290
- if (!state) return false;
291
- removeBindingsFor(skillId);
292
- await callDispose(state);
293
- skills.delete(skillId);
294
- return true;
295
- }
296
-
297
- function loadSkillState(skill, effectiveConfig) {
298
- const sourceName = skill.id;
299
- const declaredTools = (() => {
300
- const installManifest = skill.source === 'primary'
301
- ? readSkillIntegrity(skill.skillDir)
302
- : null;
303
- return installManifest?.declaredTools || skill.tools || [];
304
- })();
305
-
306
- // Invariant: callers (_reloadCore) must `await disposeSkill(prev)` before we
307
- // purge the require cache below. Otherwise stale timers/listeners inside the
308
- // old contract module would outlive the purge and leak on every hot-reload.
309
- if (skills.has(skill.id)) {
310
- logger.warn(
311
- `[js-eyes] loadSkillState invoked for "${skill.id}" while an old state is still registered; caller must dispose it first to avoid leaks`,
312
- );
313
- }
314
-
315
- let contract;
316
- try {
317
- const purged = purgeRequireCacheFor(skill.skillDir);
318
- if (purged > 0) {
319
- logger.info(`[js-eyes] Purged ${purged} cached module(s) under "${skill.id}" skillDir before reload`);
320
- }
321
- contract = skill.contract || loadSkillContract(skill.skillDir);
322
- } catch (error) {
323
- logger.warn(`[js-eyes] Failed to load contract for "${skill.id}": ${error.message}`);
324
- return null;
325
- }
326
- if (!contract || typeof contract.createOpenClawAdapter !== 'function') {
327
- logger.warn(
328
- `[js-eyes] Skipping local skill "${skill.id}" because createOpenClawAdapter() is missing`,
329
- );
330
- return null;
331
- }
332
-
333
- let adapter;
334
- try {
335
- adapter = contract.createOpenClawAdapter(pluginConfig, api.logger);
336
- } catch (error) {
337
- logger.warn(`[js-eyes] createOpenClawAdapter threw for "${skill.id}": ${error.message}`);
338
- return null;
339
- }
340
-
341
- const { toolDefs, summary } = buildAdapterTools(adapter, {
342
- logger,
343
- sourceName,
344
- declaredTools,
345
- registeredNames: reservedToolNames,
346
- wrapTool: wrapSensitiveTool,
347
- });
348
-
349
- if (summary.skipped.length > 0) {
350
- for (const item of summary.skipped) {
351
- logger.warn(
352
- `[js-eyes] Skill "${skill.id}" skipped tool "${item.name}" (${item.reason})`,
353
- );
354
- }
355
- }
356
-
357
- const state = {
358
- id: skill.id,
359
- source: skill.source,
360
- sourcePath: skill.sourcePath,
361
- skillDir: skill.skillDir,
362
- fingerprint: computeSkillFingerprint(skill.skillDir),
363
- contract,
364
- adapter,
365
- toolNames: toolDefs.map((t) => t.toolName),
366
- actionNames: toolDefs.map((t) => skillToolActionName(skill.id, t.toolName)),
367
- // runtime.dispose is used by hot-unload to drain WS, clear intervals, etc.
368
- dispose: async () => {
369
- const runtime = adapter && adapter.runtime;
370
- if (runtime && typeof runtime.dispose === 'function') {
371
- await runtime.dispose();
372
- } else if (contract && contract.runtime && typeof contract.runtime.dispose === 'function') {
373
- // Allow module-level runtime.dispose override as a convenience.
374
- await contract.runtime.dispose();
375
- }
376
- },
377
- toolDefs,
378
- };
379
- void effectiveConfig;
380
- return state;
381
- }
382
-
383
- function applyBindings(state) {
384
- const failedDispatchers = [];
385
- const localActions = new Set();
386
- for (const entry of state.toolDefs) {
387
- if (!routerMode) {
388
- const dispatched = ensureDispatcher(entry.toolName, entry.optional, entry.definition);
389
- if (!dispatched.ok) {
390
- failedDispatchers.push(entry.toolName);
391
- continue;
392
- }
393
- }
394
- const actionName = skillToolActionName(state.id, entry.toolName);
395
- if (localActions.has(actionName)) {
396
- logger.warn(
397
- `[js-eyes] Skill "${state.id}" skipped tool "${entry.toolName}" because action "${actionName}" duplicates another tool after slug normalization`,
398
- );
399
- failedDispatchers.push(entry.toolName);
400
- continue;
401
- }
402
- localActions.add(actionName);
403
- toolBindings.set(entry.toolName, {
404
- skillId: state.id,
405
- definition: entry.definition,
406
- optional: entry.optional,
407
- });
408
- actionBindings.set(actionName, {
409
- skillId: state.id,
410
- toolName: entry.toolName,
411
- definition: entry.definition,
412
- optional: entry.optional,
413
- });
414
- }
415
- return { failedDispatchers };
416
- }
417
-
418
- function runDiscover() {
419
- const cfg = configLoader();
420
- const extras = extrasProvider();
421
- const sources = resolveSkillSources({
422
- primary: skillsDir,
423
- extras,
424
- });
425
-
426
- const invalid = (sources && sources.invalid) || [];
427
- for (const item of invalid) {
428
- const key = `${item.path}|${item.reason}`;
429
- if (warnedInvalidPaths.has(key)) continue;
430
- warnedInvalidPaths.add(key);
431
- logger.warn(`[js-eyes] Ignoring extra skill dir "${item.path}" (${item.reason})`);
432
- }
433
-
434
- const { skills: discovered, conflicts } = discoverSkillsFromSources(sources, {
435
- onConflict: ({ id, winner, loser }) => {
436
- const key = `${id}|${loser.path}|${winner.path}`;
437
- if (warnedConflictKeys.has(key)) return;
438
- warnedConflictKeys.add(key);
439
- logger.warn(
440
- `[js-eyes] Skipped extra skill "${id}" at ${loser.path} (conflicts with ${winner.source} at ${winner.path})`,
441
- );
442
- },
443
- });
444
-
445
- return { sources, discovered, conflicts, config: cfg };
446
- }
447
-
448
- function ensureEnabledDefaults(discovered, hostConfig) {
449
- const legacyState = getLegacyOpenClawSkillState({
450
- skillIds: discovered.map((s) => s.id),
451
- });
452
- let mutated = 0;
453
- const enabledMap = (hostConfig && hostConfig.skillsEnabled) || {};
454
- for (const skill of discovered) {
455
- if (Object.prototype.hasOwnProperty.call(enabledMap, skill.id)) continue;
456
- if (skill.source === 'extra') {
457
- // Extras are trusted project directories; enable on first discovery.
458
- try {
459
- if (suppressSelfWrites) suppressNextReload = true;
460
- setConfigValue(`skillsEnabled.${skill.id}`, true);
461
- mutated++;
462
- } catch (error) {
463
- logger.warn(`[js-eyes] Failed to default-enable extra skill "${skill.id}": ${error.message}`);
464
- }
465
- } else {
466
- // Primary skills keep the "opt-in by default" security stance.
467
- const legacyValue = Object.prototype.hasOwnProperty.call(legacyState, skill.id)
468
- ? legacyState[skill.id]
469
- : false;
470
- try {
471
- if (suppressSelfWrites) suppressNextReload = true;
472
- setConfigValue(`skillsEnabled.${skill.id}`, legacyValue === true);
473
- mutated++;
474
- if (legacyValue !== true) {
475
- logger.warn(
476
- `[js-eyes] Skill "${skill.id}" left disabled by default; run \`js-eyes skills enable ${skill.id}\` to opt-in`,
477
- );
478
- }
479
- } catch (error) {
480
- logger.warn(`[js-eyes] Failed to seed skillsEnabled for "${skill.id}": ${error.message}`);
481
- }
482
- }
483
- }
484
- return { mutated, legacyState };
485
- }
486
-
487
- function checkIntegrity(skill, cfg = null) {
488
- if (skill.source === 'extra') {
489
- const effectiveCfg = cfg || configLoader();
490
- const verifyEnabled = Boolean(
491
- effectiveCfg
492
- && effectiveCfg.security
493
- && effectiveCfg.security.verifyExtraSkillDirs,
494
- );
495
- if (!verifyEnabled) return { ok: true, skipped: true };
496
- // `sourcePath` for extras is the root that was passed via `extraSkillDirs`
497
- // (either a skill dir or a parent dir). Verify that root — that matches
498
- // what `snapshotExtraDir(abs-path)` was asked to capture.
499
- const extraRoot = skill.sourcePath || skill.skillDir;
500
- const result = verifyExtraSkillDir(extraRoot);
501
- if (!result.hasSnapshot) {
502
- logger.warn(
503
- `[js-eyes] Refused extra skill "${skill.id}": no integrity snapshot for ${extraRoot}, run \`js-eyes skills relink ${extraRoot}\``,
504
- );
505
- return { ok: false };
506
- }
507
- if (!result.ok) {
508
- logger.warn(
509
- `[js-eyes] Refused extra skill "${skill.id}": integrity drift at ${extraRoot} (${result.drifted.length} changed, ${result.missing.length} missing, ${result.extra.length} new), run \`js-eyes skills relink ${extraRoot}\``,
510
- );
511
- return { ok: false };
512
- }
513
- return { ok: true };
514
- }
515
- if (skill.source !== 'primary') return { ok: true, skipped: true };
516
- const integrity = verifySkillIntegrity(skill.skillDir);
517
- if (integrity.hasIntegrity && !integrity.ok) {
518
- logger.warn(
519
- `[js-eyes] Refusing to load tampered skill "${skill.id}": ${integrity.mismatches.length} mismatched, ${integrity.missing.length} missing`,
520
- );
521
- return { ok: false };
522
- }
523
- if (!integrity.hasIntegrity) {
524
- if (isBundledPrimarySkill(skill)) {
525
- logger.info(
526
- `[js-eyes] Skill "${skill.id}" has no .integrity.json because it is loaded from the bundled/source primary skills directory (${skill.sourcePath}); load allowed. Registry-installed primary skills should carry .integrity.json for tamper checks.`,
527
- );
528
- } else {
529
- logger.warn(
530
- `[js-eyes] Skill "${skill.id}" has no .integrity.json (legacy primary install); load allowed, but reinstall via \`js-eyes skills install ${skill.id}\` to restore tamper-check metadata`,
531
- );
532
- }
533
- }
534
- return { ok: true };
535
- }
536
-
537
- async function init() {
538
- if (disposed) throw new Error('SkillRegistry disposed');
539
- return _reloadCore({ isInit: true });
540
- }
541
-
542
- async function reload(reason = 'manual') {
543
- if (disposed) return { added: [], removed: [], reloaded: [], toggled: [], conflicts: [], reason: 'disposed' };
544
- if (suppressNextReload) {
545
- suppressNextReload = false;
546
- logger.info(`[js-eyes] reload suppressed (${reason}) — internal config write`);
547
- return { added: [], removed: [], reloaded: [], toggled: [], conflicts: [], reason: 'suppressed' };
548
- }
549
- if (reloadInFlight) return reloadInFlight;
550
- reloadInFlight = (async () => {
551
- try {
552
- return await _reloadCore({ isInit: false, reason });
553
- } finally {
554
- reloadInFlight = null;
555
- }
556
- })();
557
- return reloadInFlight;
558
- }
559
-
560
- async function _reloadCore({ isInit, reason }) {
561
- const { sources, discovered, conflicts } = runDiscover();
562
- const primary = sources && sources.primary ? sources.primary : '';
563
- const extras = sources && Array.isArray(sources.extras) ? sources.extras : [];
564
-
565
- if (isInit) {
566
- logger.info(
567
- `[js-eyes] Skill sources: primary=${primary || '(none)'} extras=${extras.length}`,
568
- );
569
- }
570
-
571
- const { legacyState } = ensureEnabledDefaults(discovered, configLoader());
572
- // Reload config after defaults seeding so isSkillEnabled sees fresh values.
573
- const effectiveConfig = configLoader();
574
-
575
- // Compute enabled map for diff.
576
- const nextById = new Map();
577
- for (const s of discovered) nextById.set(s.id, s);
578
-
579
- const prevIds = new Set(skills.keys());
580
- const nextIds = new Set(nextById.keys());
581
-
582
- const summary = {
583
- added: [],
584
- removed: [],
585
- reloaded: [],
586
- toggledOn: [],
587
- toggledOff: [],
588
- conflicts: conflicts || [],
589
- failedDispatchers: [],
590
- reason: reason || (isInit ? 'init' : 'reload'),
591
- };
592
-
593
- // Removed: present before, gone now.
594
- for (const id of prevIds) {
595
- if (!nextIds.has(id)) {
596
- await disposeSkill(id);
597
- summary.removed.push(id);
598
- }
599
- }
600
-
601
- for (const skill of discovered) {
602
- const enabled = isSkillEnabled(effectiveConfig, skill.id, legacyState);
603
- const existing = skills.get(skill.id);
604
- const integrity = checkIntegrity(skill, effectiveConfig);
605
- if (!integrity.ok) {
606
- if (existing) {
607
- await disposeSkill(skill.id);
608
- summary.removed.push(skill.id);
609
- }
610
- continue;
611
- }
612
-
613
- if (!enabled) {
614
- if (existing) {
615
- await disposeSkill(skill.id);
616
- summary.toggledOff.push(skill.id);
617
- logger.info(`[js-eyes] Skill "${skill.id}" toggled off`);
618
- } else if (isInit) {
619
- logger.info(`[js-eyes] Skipping disabled local skill "${skill.id}"`);
620
- }
621
- continue;
622
- }
623
-
624
- // Detect if reload is required: new, sourcePath changed, or the on-disk
625
- // contract/package.json fingerprint changed (true content hot-reload).
626
- const nextFingerprint = computeSkillFingerprint(skill.skillDir);
627
- const changed = !existing
628
- || existing.source !== skill.source
629
- || existing.sourcePath !== skill.sourcePath
630
- || existing.skillDir !== skill.skillDir
631
- || existing.fingerprint !== nextFingerprint;
632
-
633
- if (existing && !changed) {
634
- // Still alive; nothing to do unless explicit reload is requested.
635
- continue;
636
- }
637
-
638
- if (existing) {
639
- await disposeSkill(skill.id);
640
- }
641
-
642
- const state = loadSkillState(skill, effectiveConfig);
643
- if (!state) {
644
- continue;
645
- }
646
- const { failedDispatchers } = applyBindings(state);
647
- if (failedDispatchers.length > 0) {
648
- summary.failedDispatchers.push({ skillId: skill.id, toolNames: failedDispatchers });
649
- }
650
- skills.set(skill.id, state);
651
-
652
- if (!existing) {
653
- if (isInit) {
654
- logger.info(
655
- `[js-eyes] Loaded local skill "${skill.id}" with ${state.toolNames.length} tool(s)`,
656
- );
657
- summary.added.push(skill.id);
658
- } else {
659
- logger.info(
660
- `[js-eyes] Hot-loaded skill "${skill.id}" with ${state.toolNames.length} tool(s)`,
661
- );
662
- summary.added.push(skill.id);
663
- }
664
- } else {
665
- summary.reloaded.push(skill.id);
666
- }
667
- }
668
-
669
- // Toggled-on: skills that were previously disabled but are now enabled
670
- // (prev didn't have them, discovered does, and we loaded them). Already in added.
671
- // For clarity rename "added" when it was simply a toggle:
672
- // But callers primarily care about "what's now active", so we keep flat lists.
673
-
674
- if (isInit) {
675
- const active = skills.size;
676
- logger.info(
677
- `[js-eyes] Discovered ${discovered.length} skill(s): ${active} active`
678
- + (summary.conflicts.length > 0 ? `, ${summary.conflicts.length} conflict(s) resolved` : ''),
679
- );
680
- }
681
-
682
- return summary;
683
- }
684
-
685
- async function disposeAll() {
686
- disposed = true;
687
- const ids = Array.from(skills.keys());
688
- for (const id of ids) {
689
- await disposeSkill(id);
690
- }
691
- }
692
-
693
- function snapshot() {
694
- return {
695
- skills: Array.from(skills.values()).map((s) => ({
696
- id: s.id,
697
- source: s.source,
698
- sourcePath: s.sourcePath,
699
- skillDir: s.skillDir,
700
- tools: s.toolNames.slice(),
701
- actions: s.actionNames.slice(),
702
- })),
703
- toolBindings: Array.from(toolBindings.keys()),
704
- actionBindings: Array.from(actionBindings.keys()),
705
- dispatchersRegistered: Array.from(dispatchers.keys()),
706
- };
707
- }
708
-
709
- async function executeAction(action, toolCallId, params) {
710
- const binding = actionBindings.get(action);
711
- if (!binding) {
712
- return { content: [{ type: 'text', text: DEFAULT_UNAVAILABLE_MESSAGE(action) }] };
713
- }
714
- return binding.definition.execute(toolCallId, params);
715
- }
716
-
717
- function getActionDefinition(action) {
718
- const binding = actionBindings.get(action);
719
- return binding ? binding.definition : null;
720
- }
721
-
722
- return {
723
- init,
724
- reload,
725
- disposeSkill,
726
- disposeAll,
727
- snapshot,
728
- getState,
729
- executeAction,
730
- getActionDefinition,
731
- // Testing / plugin integration helpers
732
- _internals: {
733
- toolBindings,
734
- actionBindings,
735
- skills,
736
- dispatchers,
737
- setSuppressNextReload(v) { suppressNextReload = Boolean(v); },
738
- },
739
- };
740
- }
741
-
742
- module.exports = {
743
- createSkillRegistry,
744
- purgeRequireCacheFor,
745
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * SkillRegistry — js-eyes 技能运行时注册表
5
+ *
6
+ * 通过两种绑定模式实现零重启热加载:
7
+ * - routerMode=true(OpenClaw 单工具模式):只维护 actionBindings,由 `js-eyes`
8
+ * 总线工具按 `skill/<skillId>/<action>` 委派执行,不向宿主注册子技能工具;
9
+ * - routerMode=false(兼容/测试模式):保留工具级 dispatcher 间接层。
10
+ *
11
+ * 兼容 dispatcher 模式:
12
+ * - 插件启动时 api.registerTool(name, dispatcher) 仅对每个 tool name 注册一次稳定闭包;
13
+ * - 每个 dispatcher 在调用时从 toolBindings.get(name) 查当前实现并委派;
14
+ * - 热加载只改 toolBindings 映射,不触碰宿主注册表。
15
+ *
16
+ * 对「全新 tool name 的首次出现」(init 之后)会尝试 api.registerTool;
17
+ * 若宿主在运行时拒绝新工具名,降级为记录 warning,其余变更仍然 0 重启。
18
+ *
19
+ * Schema 透传:dispatcher 对象会携带 skill contract 的真实 `label`/`description`/`parameters`,
20
+ * 让 OpenClaw / LLM 看到正确的入参约束(required / properties 等)。热加载时若 contract
21
+ * 改了 schema,会按**引用** mutate 已注册的 dispatcher 对象;若某些 OpenClaw 宿主对 tool
22
+ * 对象做了深拷贝/快照,则 mutate 不会生效,但首次注册的 schema 仍然是正确的。
23
+ * routerMode 下 OpenClaw 只看到 `js-eyes` 的总线 schema,子技能 schema 作为内部 definition
24
+ * 保留给后续 introspection / 文档输出使用。
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ // Use a lazy module reference to avoid a circular require hazard: skills.js also
31
+ // re-exports factories from this module.
32
+ const skillsApi = require('./skills');
33
+ function buildAdapterTools(...args) { return skillsApi.buildAdapterTools(...args); }
34
+ function discoverSkillsFromSources(...args) { return skillsApi.discoverSkillsFromSources(...args); }
35
+ function getLegacyOpenClawSkillState(...args) { return skillsApi.getLegacyOpenClawSkillState(...args); }
36
+ function isSkillEnabled(...args) { return skillsApi.isSkillEnabled(...args); }
37
+ function loadSkillContract(...args) { return skillsApi.loadSkillContract(...args); }
38
+ function readSkillIntegrity(...args) { return skillsApi.readSkillIntegrity(...args); }
39
+ function resolveSkillSources(...args) { return skillsApi.resolveSkillSources(...args); }
40
+ function skillToolActionName(...args) { return skillsApi.skillToolActionName(...args); }
41
+ function verifySkillIntegrity(...args) { return skillsApi.verifySkillIntegrity(...args); }
42
+
43
+ const { verifyExtraDir: verifyExtraSkillDir } = require('./extra-integrity');
44
+
45
+ const DEFAULT_UNAVAILABLE_MESSAGE = (name) =>
46
+ `Tool "${name}" is not currently loaded (skill disabled, removed, or reloading).`;
47
+
48
+ function noopLogger() {
49
+ const fn = () => {};
50
+ return { info: fn, warn: fn, error: fn };
51
+ }
52
+
53
+ function makeLogger(candidate) {
54
+ if (!candidate) return noopLogger();
55
+ return {
56
+ info: typeof candidate.info === 'function' ? candidate.info.bind(candidate) : () => {},
57
+ warn: typeof candidate.warn === 'function' ? candidate.warn.bind(candidate) : () => {},
58
+ error: typeof candidate.error === 'function' ? candidate.error.bind(candidate) : () => {},
59
+ };
60
+ }
61
+
62
+ function isBundledPrimarySkill(skill) {
63
+ if (!skill || skill.source !== 'primary') return false;
64
+ const sourcePath = skill.sourcePath || '';
65
+ if (!sourcePath || path.basename(sourcePath) !== 'skills') return false;
66
+ const bundleRoot = path.dirname(sourcePath);
67
+ return (
68
+ fs.existsSync(path.join(bundleRoot, 'openclaw-plugin'))
69
+ && fs.existsSync(path.join(bundleRoot, 'skills'))
70
+ );
71
+ }
72
+
73
+ /**
74
+ * 计算 skillDir 内"驱动热更"的关键文件的指纹(mtime 组合)。
75
+ * 任一文件缺失按 0 处理;出错时退化为空字符串(此时 reload 语义保守:会认为"没变")。
76
+ * 只用 mtime 是因为 chokidar 已经有 awaitWriteFinish 保护;要更强隔离可改 sha1。
77
+ */
78
+ function computeSkillFingerprint(skillDir) {
79
+ if (!skillDir) return '';
80
+ const targets = ['skill.contract.js', 'package.json'];
81
+ const parts = [];
82
+ for (const name of targets) {
83
+ try {
84
+ const st = fs.statSync(path.join(skillDir, name));
85
+ parts.push(`${name}:${st.mtimeMs || 0}:${st.size || 0}`);
86
+ } catch (_) {
87
+ parts.push(`${name}:0:0`);
88
+ }
89
+ }
90
+ return parts.join('|');
91
+ }
92
+
93
+ /**
94
+ * 深度清理 require.cache:删除所有位于 skillDir 下(排除 node_modules)
95
+ * 的已缓存模块,避免热加载时沿用旧模块实例。
96
+ */
97
+ function purgeRequireCacheFor(skillDir) {
98
+ if (!skillDir) return 0;
99
+ let normalized;
100
+ try {
101
+ normalized = fs.realpathSync(skillDir);
102
+ } catch (_) {
103
+ normalized = path.resolve(skillDir);
104
+ }
105
+ const prefix = normalized.endsWith(path.sep) ? normalized : normalized + path.sep;
106
+ let purged = 0;
107
+ for (const key of Object.keys(require.cache)) {
108
+ if (!key) continue;
109
+ if (key === normalized || key.startsWith(prefix)) {
110
+ if (key.includes(`${path.sep}node_modules${path.sep}`)) continue;
111
+ delete require.cache[key];
112
+ purged++;
113
+ }
114
+ }
115
+ return purged;
116
+ }
117
+
118
+ /**
119
+ * 创建 SkillRegistry。
120
+ *
121
+ * options:
122
+ * - api: OpenClaw Plugin API(必需),需要 api.registerTool / api.logger
123
+ * - sources: 初始 sources(resolveSkillSources 的结果)。init/reload 时会重新解析。
124
+ * - pluginConfig: 传给 createOpenClawAdapter 的 plugin 配置
125
+ * - configLoader: () => hostConfig;默认从 @js-eyes/config 加载
126
+ * - setConfigValue: (key, value) => void;写入 host config(extras 默认 enable 用)
127
+ * - skillsDir: primary 目录(用于 resolveSkillSources 回退)
128
+ * - extrasProvider: () => string[],每次 reload 重新取 extras 列表
129
+ * - wrapSensitiveTool: 复用插件的敏感工具包装器
130
+ * - logger: 日志通道
131
+ * - builtinToolNames: 禁止被技能覆盖的内置工具名集合
132
+ * - onConflict: 冲突回调
133
+ */
134
+ function createSkillRegistry(options = {}) {
135
+ const {
136
+ api,
137
+ pluginConfig = {},
138
+ wrapSensitiveTool = null,
139
+ builtinToolNames = [],
140
+ routerMode = false,
141
+ // When true (default when a watcher is active), we set suppressNextReload
142
+ // after our own setConfigValue writes so the chokidar echo doesn't cause a
143
+ // duplicate reload. Leave this false in test harnesses that drive reload()
144
+ // manually without a watcher.
145
+ suppressSelfWrites = true,
146
+ } = options;
147
+
148
+ if (!routerMode && (!api || typeof api.registerTool !== 'function')) {
149
+ throw new Error('createSkillRegistry: api.registerTool is required');
150
+ }
151
+
152
+ const logger = makeLogger(options.logger || (api && api.logger));
153
+ const configLoader = typeof options.configLoader === 'function'
154
+ ? options.configLoader
155
+ : () => ({});
156
+ const setConfigValue = typeof options.setConfigValue === 'function'
157
+ ? options.setConfigValue
158
+ : () => {};
159
+ const extrasProvider = typeof options.extrasProvider === 'function'
160
+ ? options.extrasProvider
161
+ : () => {
162
+ const cfg = configLoader();
163
+ return Array.isArray(cfg.extraSkillDirs) ? cfg.extraSkillDirs : [];
164
+ };
165
+ const skillsDir = options.skillsDir || '';
166
+
167
+ // id -> { source, sourcePath, skillDir, contract, adapter, toolNames, enabled, dispose }
168
+ const skills = new Map();
169
+ // toolName -> { skillId, definition, optional }
170
+ const toolBindings = new Map();
171
+ // action -> { skillId, toolName, definition, optional }
172
+ const actionBindings = new Map();
173
+ // toolName -> dispatcher object (registered once per name; kept by reference so we can
174
+ // mutate `description`/`parameters`/`label` on hot-reload to keep OpenClaw-visible schema in sync)
175
+ const dispatchers = new Map();
176
+ // Reserved names (built-ins + sensitive wrapper fixed names); skills cannot override.
177
+ const reservedToolNames = new Set(builtinToolNames);
178
+
179
+ let reloadInFlight = null;
180
+ let suppressNextReload = false;
181
+ let disposed = false;
182
+ // Once-per-path dedup sets so hot reloads don't flood the log with the same
183
+ // "Ignoring extra skill dir" / "Skipped extra skill" warnings on every tick.
184
+ const warnedInvalidPaths = new Set();
185
+ const warnedConflictKeys = new Set();
186
+
187
+ function getState(skillId) {
188
+ return skills.get(skillId) || null;
189
+ }
190
+
191
+ function deriveDispatcherMeta(toolName, definition) {
192
+ const label = (definition && typeof definition.label === 'string' && definition.label)
193
+ || toolName;
194
+ const description = (definition && typeof definition.description === 'string' && definition.description)
195
+ || `[js-eyes dispatcher] ${toolName}`;
196
+ const parameters = (definition && definition.parameters && typeof definition.parameters === 'object')
197
+ ? definition.parameters
198
+ : { type: 'object', properties: {} };
199
+ return { label, description, parameters };
200
+ }
201
+
202
+ function safeStringify(value) {
203
+ try { return JSON.stringify(value); } catch (_) { return null; }
204
+ }
205
+
206
+ function ensureDispatcher(toolName, optional, definition) {
207
+ const existing = dispatchers.get(toolName);
208
+ const meta = deriveDispatcherMeta(toolName, definition);
209
+
210
+ if (existing) {
211
+ // Hot-reload path: the dispatcher was already registered with OpenClaw.
212
+ // Mutate in place so hosts that keep the tool object by reference (e.g.
213
+ // captured-registration's `tools.push(tool)`) surface the new schema; hosts that
214
+ // snapshotted at registration time will keep the first-load schema, which is
215
+ // already accurate for the common case.
216
+ const changed =
217
+ existing.label !== meta.label
218
+ || existing.description !== meta.description
219
+ || safeStringify(existing.parameters) !== safeStringify(meta.parameters);
220
+ if (changed) {
221
+ existing.label = meta.label;
222
+ existing.description = meta.description;
223
+ existing.parameters = meta.parameters;
224
+ logger.info(
225
+ `[js-eyes] Refreshed dispatcher schema for tool "${toolName}" (hot-reload; a one-time OpenClaw restart may be required if the host snapshots tool metadata)`,
226
+ );
227
+ }
228
+ return { ok: true };
229
+ }
230
+
231
+ const dispatcher = {
232
+ name: toolName,
233
+ label: meta.label,
234
+ description: meta.description,
235
+ parameters: meta.parameters,
236
+ async execute(toolCallId, params) {
237
+ const binding = toolBindings.get(toolName);
238
+ if (!binding) {
239
+ return { content: [{ type: 'text', text: DEFAULT_UNAVAILABLE_MESSAGE(toolName) }] };
240
+ }
241
+ // Delegate via the current binding; execute is the authoritative behavior.
242
+ // label/description/parameters on `dispatcher` are kept in sync via ensureDispatcher
243
+ // on every applyBindings() so OpenClaw-visible metadata follows the latest contract.
244
+ const def = binding.definition;
245
+ return def.execute(toolCallId, params);
246
+ },
247
+ };
248
+ try {
249
+ api.registerTool(dispatcher, optional ? { optional: true } : undefined);
250
+ dispatchers.set(toolName, dispatcher);
251
+ return { ok: true };
252
+ } catch (error) {
253
+ logger.warn(
254
+ `[js-eyes] Failed to register dispatcher for tool "${toolName}": ${error.message}. `
255
+ + `A one-time OpenClaw restart may be required to expose this new tool.`,
256
+ );
257
+ return { ok: false, error };
258
+ }
259
+ }
260
+
261
+ async function callDispose(state) {
262
+ if (!state || typeof state.dispose !== 'function') return;
263
+ try {
264
+ await state.dispose();
265
+ } catch (error) {
266
+ logger.warn(
267
+ `[js-eyes] dispose() for skill "${state.id}" threw: ${error.message}`,
268
+ );
269
+ }
270
+ }
271
+
272
+ function removeBindingsFor(skillId) {
273
+ const removed = [];
274
+ for (const [name, binding] of toolBindings) {
275
+ if (binding.skillId === skillId) {
276
+ toolBindings.delete(name);
277
+ removed.push(name);
278
+ }
279
+ }
280
+ for (const [action, binding] of actionBindings) {
281
+ if (binding.skillId === skillId) {
282
+ actionBindings.delete(action);
283
+ }
284
+ }
285
+ return removed;
286
+ }
287
+
288
+ async function disposeSkill(skillId) {
289
+ const state = skills.get(skillId);
290
+ if (!state) return false;
291
+ removeBindingsFor(skillId);
292
+ await callDispose(state);
293
+ skills.delete(skillId);
294
+ return true;
295
+ }
296
+
297
+ function loadSkillState(skill, effectiveConfig) {
298
+ const sourceName = skill.id;
299
+ const declaredTools = (() => {
300
+ const installManifest = skill.source === 'primary'
301
+ ? readSkillIntegrity(skill.skillDir)
302
+ : null;
303
+ return installManifest?.declaredTools || skill.tools || [];
304
+ })();
305
+
306
+ // Invariant: callers (_reloadCore) must `await disposeSkill(prev)` before we
307
+ // purge the require cache below. Otherwise stale timers/listeners inside the
308
+ // old contract module would outlive the purge and leak on every hot-reload.
309
+ if (skills.has(skill.id)) {
310
+ logger.warn(
311
+ `[js-eyes] loadSkillState invoked for "${skill.id}" while an old state is still registered; caller must dispose it first to avoid leaks`,
312
+ );
313
+ }
314
+
315
+ let contract;
316
+ try {
317
+ const purged = purgeRequireCacheFor(skill.skillDir);
318
+ if (purged > 0) {
319
+ logger.info(`[js-eyes] Purged ${purged} cached module(s) under "${skill.id}" skillDir before reload`);
320
+ }
321
+ contract = skill.contract || loadSkillContract(skill.skillDir);
322
+ } catch (error) {
323
+ logger.warn(`[js-eyes] Failed to load contract for "${skill.id}": ${error.message}`);
324
+ return null;
325
+ }
326
+ if (!contract || typeof contract.createOpenClawAdapter !== 'function') {
327
+ logger.warn(
328
+ `[js-eyes] Skipping local skill "${skill.id}" because createOpenClawAdapter() is missing`,
329
+ );
330
+ return null;
331
+ }
332
+
333
+ let adapter;
334
+ try {
335
+ adapter = contract.createOpenClawAdapter(pluginConfig, api.logger);
336
+ } catch (error) {
337
+ logger.warn(`[js-eyes] createOpenClawAdapter threw for "${skill.id}": ${error.message}`);
338
+ return null;
339
+ }
340
+
341
+ const { toolDefs, summary } = buildAdapterTools(adapter, {
342
+ logger,
343
+ sourceName,
344
+ declaredTools,
345
+ registeredNames: reservedToolNames,
346
+ wrapTool: wrapSensitiveTool,
347
+ });
348
+
349
+ if (summary.skipped.length > 0) {
350
+ for (const item of summary.skipped) {
351
+ logger.warn(
352
+ `[js-eyes] Skill "${skill.id}" skipped tool "${item.name}" (${item.reason})`,
353
+ );
354
+ }
355
+ }
356
+
357
+ const state = {
358
+ id: skill.id,
359
+ source: skill.source,
360
+ sourcePath: skill.sourcePath,
361
+ skillDir: skill.skillDir,
362
+ fingerprint: computeSkillFingerprint(skill.skillDir),
363
+ contract,
364
+ adapter,
365
+ toolNames: toolDefs.map((t) => t.toolName),
366
+ actionNames: toolDefs.map((t) => skillToolActionName(skill.id, t.toolName)),
367
+ // runtime.dispose is used by hot-unload to drain WS, clear intervals, etc.
368
+ dispose: async () => {
369
+ const runtime = adapter && adapter.runtime;
370
+ if (runtime && typeof runtime.dispose === 'function') {
371
+ await runtime.dispose();
372
+ } else if (contract && contract.runtime && typeof contract.runtime.dispose === 'function') {
373
+ // Allow module-level runtime.dispose override as a convenience.
374
+ await contract.runtime.dispose();
375
+ }
376
+ },
377
+ toolDefs,
378
+ };
379
+ void effectiveConfig;
380
+ return state;
381
+ }
382
+
383
+ function applyBindings(state) {
384
+ const failedDispatchers = [];
385
+ const localActions = new Set();
386
+ for (const entry of state.toolDefs) {
387
+ if (!routerMode) {
388
+ const dispatched = ensureDispatcher(entry.toolName, entry.optional, entry.definition);
389
+ if (!dispatched.ok) {
390
+ failedDispatchers.push(entry.toolName);
391
+ continue;
392
+ }
393
+ }
394
+ const actionName = skillToolActionName(state.id, entry.toolName);
395
+ if (localActions.has(actionName)) {
396
+ logger.warn(
397
+ `[js-eyes] Skill "${state.id}" skipped tool "${entry.toolName}" because action "${actionName}" duplicates another tool after slug normalization`,
398
+ );
399
+ failedDispatchers.push(entry.toolName);
400
+ continue;
401
+ }
402
+ localActions.add(actionName);
403
+ toolBindings.set(entry.toolName, {
404
+ skillId: state.id,
405
+ definition: entry.definition,
406
+ optional: entry.optional,
407
+ });
408
+ actionBindings.set(actionName, {
409
+ skillId: state.id,
410
+ toolName: entry.toolName,
411
+ definition: entry.definition,
412
+ optional: entry.optional,
413
+ });
414
+ }
415
+ return { failedDispatchers };
416
+ }
417
+
418
+ function runDiscover() {
419
+ const cfg = configLoader();
420
+ const extras = extrasProvider();
421
+ const sources = resolveSkillSources({
422
+ primary: skillsDir,
423
+ extras,
424
+ });
425
+
426
+ const invalid = (sources && sources.invalid) || [];
427
+ for (const item of invalid) {
428
+ const key = `${item.path}|${item.reason}`;
429
+ if (warnedInvalidPaths.has(key)) continue;
430
+ warnedInvalidPaths.add(key);
431
+ logger.warn(`[js-eyes] Ignoring extra skill dir "${item.path}" (${item.reason})`);
432
+ }
433
+
434
+ const { skills: discovered, conflicts } = discoverSkillsFromSources(sources, {
435
+ onConflict: ({ id, winner, loser }) => {
436
+ const key = `${id}|${loser.path}|${winner.path}`;
437
+ if (warnedConflictKeys.has(key)) return;
438
+ warnedConflictKeys.add(key);
439
+ logger.warn(
440
+ `[js-eyes] Skipped extra skill "${id}" at ${loser.path} (conflicts with ${winner.source} at ${winner.path})`,
441
+ );
442
+ },
443
+ });
444
+
445
+ return { sources, discovered, conflicts, config: cfg };
446
+ }
447
+
448
+ function ensureEnabledDefaults(discovered, hostConfig) {
449
+ const legacyState = getLegacyOpenClawSkillState({
450
+ skillIds: discovered.map((s) => s.id),
451
+ });
452
+ let mutated = 0;
453
+ const enabledMap = (hostConfig && hostConfig.skillsEnabled) || {};
454
+ for (const skill of discovered) {
455
+ if (Object.prototype.hasOwnProperty.call(enabledMap, skill.id)) continue;
456
+ if (skill.source === 'extra') {
457
+ // Extras are trusted project directories; enable on first discovery.
458
+ try {
459
+ if (suppressSelfWrites) suppressNextReload = true;
460
+ setConfigValue(`skillsEnabled.${skill.id}`, true);
461
+ mutated++;
462
+ } catch (error) {
463
+ logger.warn(`[js-eyes] Failed to default-enable extra skill "${skill.id}": ${error.message}`);
464
+ }
465
+ } else {
466
+ // Primary skills keep the "opt-in by default" security stance.
467
+ const legacyValue = Object.prototype.hasOwnProperty.call(legacyState, skill.id)
468
+ ? legacyState[skill.id]
469
+ : false;
470
+ try {
471
+ if (suppressSelfWrites) suppressNextReload = true;
472
+ setConfigValue(`skillsEnabled.${skill.id}`, legacyValue === true);
473
+ mutated++;
474
+ if (legacyValue !== true) {
475
+ logger.warn(
476
+ `[js-eyes] Skill "${skill.id}" left disabled by default; run \`js-eyes skills enable ${skill.id}\` to opt-in`,
477
+ );
478
+ }
479
+ } catch (error) {
480
+ logger.warn(`[js-eyes] Failed to seed skillsEnabled for "${skill.id}": ${error.message}`);
481
+ }
482
+ }
483
+ }
484
+ return { mutated, legacyState };
485
+ }
486
+
487
+ function checkIntegrity(skill, cfg = null) {
488
+ if (skill.source === 'extra') {
489
+ const effectiveCfg = cfg || configLoader();
490
+ const verifyEnabled = Boolean(
491
+ effectiveCfg
492
+ && effectiveCfg.security
493
+ && effectiveCfg.security.verifyExtraSkillDirs,
494
+ );
495
+ if (!verifyEnabled) return { ok: true, skipped: true };
496
+ // `sourcePath` for extras is the root that was passed via `extraSkillDirs`
497
+ // (either a skill dir or a parent dir). Verify that root — that matches
498
+ // what `snapshotExtraDir(abs-path)` was asked to capture.
499
+ const extraRoot = skill.sourcePath || skill.skillDir;
500
+ const result = verifyExtraSkillDir(extraRoot);
501
+ if (!result.hasSnapshot) {
502
+ logger.warn(
503
+ `[js-eyes] Refused extra skill "${skill.id}": no integrity snapshot for ${extraRoot}, run \`js-eyes skills relink ${extraRoot}\``,
504
+ );
505
+ return { ok: false };
506
+ }
507
+ if (!result.ok) {
508
+ logger.warn(
509
+ `[js-eyes] Refused extra skill "${skill.id}": integrity drift at ${extraRoot} (${result.drifted.length} changed, ${result.missing.length} missing, ${result.extra.length} new), run \`js-eyes skills relink ${extraRoot}\``,
510
+ );
511
+ return { ok: false };
512
+ }
513
+ return { ok: true };
514
+ }
515
+ if (skill.source !== 'primary') return { ok: true, skipped: true };
516
+ const integrity = verifySkillIntegrity(skill.skillDir);
517
+ if (integrity.hasIntegrity && !integrity.ok) {
518
+ logger.warn(
519
+ `[js-eyes] Refusing to load tampered skill "${skill.id}": ${integrity.mismatches.length} mismatched, ${integrity.missing.length} missing`,
520
+ );
521
+ return { ok: false };
522
+ }
523
+ if (!integrity.hasIntegrity) {
524
+ if (isBundledPrimarySkill(skill)) {
525
+ logger.info(
526
+ `[js-eyes] Skill "${skill.id}" has no .integrity.json because it is loaded from the bundled/source primary skills directory (${skill.sourcePath}); load allowed. Registry-installed primary skills should carry .integrity.json for tamper checks.`,
527
+ );
528
+ } else {
529
+ logger.warn(
530
+ `[js-eyes] Skill "${skill.id}" has no .integrity.json (legacy primary install); load allowed, but reinstall via \`js-eyes skills install ${skill.id}\` to restore tamper-check metadata`,
531
+ );
532
+ }
533
+ }
534
+ return { ok: true };
535
+ }
536
+
537
+ async function init() {
538
+ if (disposed) throw new Error('SkillRegistry disposed');
539
+ return _reloadCore({ isInit: true });
540
+ }
541
+
542
+ async function reload(reason = 'manual') {
543
+ if (disposed) return { added: [], removed: [], reloaded: [], toggled: [], conflicts: [], reason: 'disposed' };
544
+ if (suppressNextReload) {
545
+ suppressNextReload = false;
546
+ logger.info(`[js-eyes] reload suppressed (${reason}) — internal config write`);
547
+ return { added: [], removed: [], reloaded: [], toggled: [], conflicts: [], reason: 'suppressed' };
548
+ }
549
+ if (reloadInFlight) return reloadInFlight;
550
+ reloadInFlight = (async () => {
551
+ try {
552
+ return await _reloadCore({ isInit: false, reason });
553
+ } finally {
554
+ reloadInFlight = null;
555
+ }
556
+ })();
557
+ return reloadInFlight;
558
+ }
559
+
560
+ async function _reloadCore({ isInit, reason }) {
561
+ const { sources, discovered, conflicts } = runDiscover();
562
+ const primary = sources && sources.primary ? sources.primary : '';
563
+ const extras = sources && Array.isArray(sources.extras) ? sources.extras : [];
564
+
565
+ if (isInit) {
566
+ logger.info(
567
+ `[js-eyes] Skill sources: primary=${primary || '(none)'} extras=${extras.length}`,
568
+ );
569
+ }
570
+
571
+ const { legacyState } = ensureEnabledDefaults(discovered, configLoader());
572
+ // Reload config after defaults seeding so isSkillEnabled sees fresh values.
573
+ const effectiveConfig = configLoader();
574
+
575
+ // Compute enabled map for diff.
576
+ const nextById = new Map();
577
+ for (const s of discovered) nextById.set(s.id, s);
578
+
579
+ const prevIds = new Set(skills.keys());
580
+ const nextIds = new Set(nextById.keys());
581
+
582
+ const summary = {
583
+ added: [],
584
+ removed: [],
585
+ reloaded: [],
586
+ toggledOn: [],
587
+ toggledOff: [],
588
+ conflicts: conflicts || [],
589
+ failedDispatchers: [],
590
+ reason: reason || (isInit ? 'init' : 'reload'),
591
+ };
592
+
593
+ // Removed: present before, gone now.
594
+ for (const id of prevIds) {
595
+ if (!nextIds.has(id)) {
596
+ await disposeSkill(id);
597
+ summary.removed.push(id);
598
+ }
599
+ }
600
+
601
+ for (const skill of discovered) {
602
+ const enabled = isSkillEnabled(effectiveConfig, skill.id, legacyState);
603
+ const existing = skills.get(skill.id);
604
+ const integrity = checkIntegrity(skill, effectiveConfig);
605
+ if (!integrity.ok) {
606
+ if (existing) {
607
+ await disposeSkill(skill.id);
608
+ summary.removed.push(skill.id);
609
+ }
610
+ continue;
611
+ }
612
+
613
+ if (!enabled) {
614
+ if (existing) {
615
+ await disposeSkill(skill.id);
616
+ summary.toggledOff.push(skill.id);
617
+ logger.info(`[js-eyes] Skill "${skill.id}" toggled off`);
618
+ } else if (isInit) {
619
+ logger.info(`[js-eyes] Skipping disabled local skill "${skill.id}"`);
620
+ }
621
+ continue;
622
+ }
623
+
624
+ // Detect if reload is required: new, sourcePath changed, or the on-disk
625
+ // contract/package.json fingerprint changed (true content hot-reload).
626
+ const nextFingerprint = computeSkillFingerprint(skill.skillDir);
627
+ const changed = !existing
628
+ || existing.source !== skill.source
629
+ || existing.sourcePath !== skill.sourcePath
630
+ || existing.skillDir !== skill.skillDir
631
+ || existing.fingerprint !== nextFingerprint;
632
+
633
+ if (existing && !changed) {
634
+ // Still alive; nothing to do unless explicit reload is requested.
635
+ continue;
636
+ }
637
+
638
+ if (existing) {
639
+ await disposeSkill(skill.id);
640
+ }
641
+
642
+ const state = loadSkillState(skill, effectiveConfig);
643
+ if (!state) {
644
+ continue;
645
+ }
646
+ const { failedDispatchers } = applyBindings(state);
647
+ if (failedDispatchers.length > 0) {
648
+ summary.failedDispatchers.push({ skillId: skill.id, toolNames: failedDispatchers });
649
+ }
650
+ skills.set(skill.id, state);
651
+
652
+ if (!existing) {
653
+ if (isInit) {
654
+ logger.info(
655
+ `[js-eyes] Loaded local skill "${skill.id}" with ${state.toolNames.length} tool(s)`,
656
+ );
657
+ summary.added.push(skill.id);
658
+ } else {
659
+ logger.info(
660
+ `[js-eyes] Hot-loaded skill "${skill.id}" with ${state.toolNames.length} tool(s)`,
661
+ );
662
+ summary.added.push(skill.id);
663
+ }
664
+ } else {
665
+ summary.reloaded.push(skill.id);
666
+ }
667
+ }
668
+
669
+ // Toggled-on: skills that were previously disabled but are now enabled
670
+ // (prev didn't have them, discovered does, and we loaded them). Already in added.
671
+ // For clarity rename "added" when it was simply a toggle:
672
+ // But callers primarily care about "what's now active", so we keep flat lists.
673
+
674
+ if (isInit) {
675
+ const active = skills.size;
676
+ logger.info(
677
+ `[js-eyes] Discovered ${discovered.length} skill(s): ${active} active`
678
+ + (summary.conflicts.length > 0 ? `, ${summary.conflicts.length} conflict(s) resolved` : ''),
679
+ );
680
+ }
681
+
682
+ return summary;
683
+ }
684
+
685
+ async function disposeAll() {
686
+ disposed = true;
687
+ const ids = Array.from(skills.keys());
688
+ for (const id of ids) {
689
+ await disposeSkill(id);
690
+ }
691
+ }
692
+
693
+ function snapshot() {
694
+ return {
695
+ skills: Array.from(skills.values()).map((s) => ({
696
+ id: s.id,
697
+ source: s.source,
698
+ sourcePath: s.sourcePath,
699
+ skillDir: s.skillDir,
700
+ tools: s.toolNames.slice(),
701
+ actions: s.actionNames.slice(),
702
+ })),
703
+ toolBindings: Array.from(toolBindings.keys()),
704
+ actionBindings: Array.from(actionBindings.keys()),
705
+ dispatchersRegistered: Array.from(dispatchers.keys()),
706
+ };
707
+ }
708
+
709
+ async function executeAction(action, toolCallId, params) {
710
+ const binding = actionBindings.get(action);
711
+ if (!binding) {
712
+ return { content: [{ type: 'text', text: DEFAULT_UNAVAILABLE_MESSAGE(action) }] };
713
+ }
714
+ return binding.definition.execute(toolCallId, params);
715
+ }
716
+
717
+ function getActionDefinition(action) {
718
+ const binding = actionBindings.get(action);
719
+ return binding ? binding.definition : null;
720
+ }
721
+
722
+ return {
723
+ init,
724
+ reload,
725
+ disposeSkill,
726
+ disposeAll,
727
+ snapshot,
728
+ getState,
729
+ executeAction,
730
+ getActionDefinition,
731
+ // Testing / plugin integration helpers
732
+ _internals: {
733
+ toolBindings,
734
+ actionBindings,
735
+ skills,
736
+ dispatchers,
737
+ setSuppressNextReload(v) { suppressNextReload = Boolean(v); },
738
+ },
739
+ };
740
+ }
741
+
742
+ module.exports = {
743
+ createSkillRegistry,
744
+ purgeRequireCacheFor,
745
+ };