@daweifu/capability-menu 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.
Files changed (44) hide show
  1. package/README.en.md +238 -0
  2. package/README.md +127 -63
  3. package/cordis.patch.yml +37 -46
  4. package/lib/client.d.ts +224 -19
  5. package/lib/client.d.ts.map +1 -1
  6. package/lib/client.js +1796 -226
  7. package/lib/constants.js +33 -0
  8. package/lib/constants.js.map +1 -0
  9. package/lib/index.js +1 -1
  10. package/lib/invoke.js +68 -96
  11. package/lib/invoke.js.map +1 -1
  12. package/lib/locations.js +541 -0
  13. package/lib/locations.js.map +1 -0
  14. package/lib/patch-file.js +192 -0
  15. package/lib/patch-file.js.map +1 -0
  16. package/lib/policy.js +422 -94
  17. package/lib/policy.js.map +1 -1
  18. package/lib/registry.js +410 -152
  19. package/lib/registry.js.map +1 -1
  20. package/lib/search.js +26 -9
  21. package/lib/search.js.map +1 -1
  22. package/lib/server/remote.js +219 -3
  23. package/lib/server/remote.js.map +1 -1
  24. package/lib/types/constants.d.ts +33 -0
  25. package/lib/types/constants.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +1 -1
  27. package/lib/types/invoke.d.ts +7 -13
  28. package/lib/types/invoke.d.ts.map +1 -1
  29. package/lib/types/locations.d.ts +142 -0
  30. package/lib/types/locations.d.ts.map +1 -0
  31. package/lib/types/patch-file.d.ts +46 -0
  32. package/lib/types/patch-file.d.ts.map +1 -0
  33. package/lib/types/policy.d.ts +115 -53
  34. package/lib/types/policy.d.ts.map +1 -1
  35. package/lib/types/registry.d.ts +94 -25
  36. package/lib/types/registry.d.ts.map +1 -1
  37. package/lib/types/search.d.ts.map +1 -1
  38. package/lib/types/server/remote.d.ts +68 -2
  39. package/lib/types/server/remote.d.ts.map +1 -1
  40. package/package.json +23 -26
  41. package/lib/invariant.js +0 -22
  42. package/lib/invariant.js.map +0 -1
  43. package/lib/types/invariant.d.ts +0 -16
  44. package/lib/types/invariant.d.ts.map +0 -1
package/lib/registry.js CHANGED
@@ -6,11 +6,24 @@
6
6
  import z from '@deepseek-ai/schemastery';
7
7
  import { isModelInvocable } from '@deepseek-ai/dsh-skill';
8
8
  import yaml from 'js-yaml';
9
- import { readFile, readdir, stat } from 'node:fs/promises';
10
- import { dirname, resolve, sep } from 'node:path';
11
- /** Stable identifier prefixes: MCP tools keep `mcp__...`, skills use `skill:<name>`. */
12
- export const SKILL_ID_PREFIX = 'skill:';
13
- export const MCP_ID_PREFIX = 'mcp__';
9
+ import { readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join, resolve, sep } from 'node:path';
12
+ import { BUILT_IN_SERVER, MCP_ID_PREFIX } from "./constants.js";
13
+ // Re-exported for the package's public surface (`/registry` subpath).
14
+ export { BUILT_IN_SERVER, MCP_ID_PREFIX };
15
+ /**
16
+ * Tool names that never enter the capability catalog: this plugin's own
17
+ * control plane (`meta_search`/`meta_invoke`, always Resident) and the
18
+ * reserved Code Mode presentation transport (`run_code`).
19
+ *
20
+ * Tool ids (the key everything else uses) are the REAL registered tool name:
21
+ * MCP tools are registered as `mcp__<server>__<raw>` by the harness MCP client
22
+ * and natives keep their bare name (`bash`/`read`/…). Skills have no
23
+ * name-level namespace: a skill's id IS its bare name and `kind` disambiguates
24
+ * it from a same-named tool.
25
+ */
26
+ export const CATALOG_EXCLUDED_TOOLS = new Set(['meta_search', 'meta_invoke', 'run_code']);
14
27
  /** Validate and default the registry configuration. */
15
28
  export const Config = z.object({
16
29
  summaryMaxChars: z.number().default(160),
@@ -19,7 +32,8 @@ export const Config = z.object({
19
32
  weighting: z.number().default(0.1),
20
33
  // schemastery object properties are optional-by-default: a missing key or
21
34
  // undefined value is accepted (no `meta.required`), so no `.optional()` needed.
22
- progressiveSkillCatalog: z.string(),
35
+ catalogFile: z.string(),
36
+ refreshDebounceMs: z.number().default(200),
23
37
  });
24
38
  function assertPositiveInteger(name, value, min) {
25
39
  if (!Number.isInteger(value) || value < min) {
@@ -31,6 +45,26 @@ function assertWeight(name, value) {
31
45
  throw new Error(`${name} must be a number in [0, 1]`);
32
46
  }
33
47
  }
48
+ /** True when `target` is `root` itself or sits below it. */
49
+ function isUnder(root, target) {
50
+ return target === root || target.startsWith(`${root}${sep}`);
51
+ }
52
+ /**
53
+ * Containment check for skill-directory access: `target` must stay inside
54
+ * `root`, both lexically (`resolve` already collapsed `..`) and physically —
55
+ * a symlink inside the skill directory must not lead out of it. Paths that do
56
+ * not exist yet cannot be resolved physically; those stay governed by the
57
+ * lexical check and the caller's own stat/read failure handling.
58
+ */
59
+ async function isInside(root, target) {
60
+ if (!isUnder(root, target))
61
+ return false;
62
+ const realRoot = await realpath(root).catch(() => undefined);
63
+ const realTarget = await realpath(target).catch(() => undefined);
64
+ if (realRoot === undefined || realTarget === undefined)
65
+ return true;
66
+ return isUnder(realRoot, realTarget);
67
+ }
34
68
  /** Trim a description to a model-friendly summary. */
35
69
  function toSummary(description, maxChars) {
36
70
  const collapsed = description.replace(/\s+/g, ' ').trim();
@@ -72,24 +106,46 @@ export function apply(ctx, config = {}) {
72
106
  const detailIncludesBody = config.detailIncludesBody ?? false;
73
107
  const maxResults = config.maxResults ?? 20;
74
108
  const weighting = config.weighting ?? 0.1;
109
+ const catalogFile = (config.catalogFile ?? join(homedir(), '.dsh', 'capability-catalog.yaml')).trim();
110
+ const refreshDebounceMs = config.refreshDebounceMs ?? 200;
75
111
  assertPositiveInteger('summaryMaxChars', summaryMaxChars, 20);
76
112
  assertPositiveInteger('maxResults', maxResults, 1);
113
+ assertPositiveInteger('refreshDebounceMs', refreshDebounceMs, 0);
77
114
  assertWeight('weighting', weighting);
78
- /** MCP tool records keyed by tool name; skills keyed by `skill:<name>`. */
115
+ /** Tool records keyed by tool name (MCP tools plus native tools under `built-in`); skills keyed by bare skill name. */
79
116
  let toolRecords = new Map();
80
117
  let skillRecords = new Map();
81
118
  /** The scope each indexed skill was collected from; undefined = global layer. */
82
119
  let skillScopes = new Map();
120
+ /** On-demand count of the latest catalog emission (0 until first write). */
121
+ let onDemandCount = 0;
122
+ /** Last emitted catalog text, so an unchanged rebuild skips the write. */
123
+ let lastCatalogContent;
83
124
  const statsOf = (record) => record.stats;
125
+ /**
126
+ * Kind-aware record lookup. With `kind` only that table is consulted; without
127
+ * it a name present in BOTH tables is ambiguous and resolves to undefined
128
+ * (never silently pick one kind over the other).
129
+ */
130
+ const resolveRecord = (key, kind) => {
131
+ if (kind === 'tool')
132
+ return toolRecords.get(key);
133
+ if (kind === 'skill')
134
+ return skillRecords.get(key);
135
+ const tool = toolRecords.get(key);
136
+ const skill = skillRecords.get(key);
137
+ if (tool !== undefined && skill !== undefined)
138
+ return undefined;
139
+ return tool ?? skill;
140
+ };
84
141
  /**
85
142
  * Resolve a skill's root directory from the live skill registry. The
86
143
  * directory comes from the skill provider's own locator (`resourceBase` for
87
- * bundle skills, the SKILL.md parent for flat files) or the indexed origin
88
- * path for progressive-catalog entries — never from caller input.
144
+ * bundle skills, the SKILL.md parent for flat files) — never from caller input.
89
145
  */
90
146
  const skillRootOf = async (id) => {
91
- const name = skillNameOf(id);
92
- const scope = skillScopes.get(id);
147
+ const name = id;
148
+ const scope = skillScopes.get(name);
93
149
  const lookup = scope === undefined ? {} : { scope };
94
150
  const definition = await ctx.skills.get(name, lookup).catch(() => undefined);
95
151
  if (definition !== undefined) {
@@ -99,85 +155,84 @@ export function apply(ctx, config = {}) {
99
155
  if (definition.path !== undefined)
100
156
  return dirname(definition.path);
101
157
  }
102
- // Progressive-catalog skills are not registered with a provider; fall
103
- // back to the catalog-declared path (a directory holding the SKILL.md).
104
- const record = skillRecords.get(id);
105
- if (record?.origin.path !== undefined)
106
- return record.origin.path;
107
158
  return undefined;
108
159
  };
109
- /** Rebuild the MCP tool index synchronously from the visible tool registry. */
110
- const rebuildTools = () => {
111
- const next = new Map();
112
- for (const schema of ctx.tools.schemas()) {
113
- // 只编目 mcp__ 工具:原生工具(bash/read 等非 mcp__ 前缀)不进能力目录,
114
- // 因此不被 meta_search/meta_invoke 覆盖、也不在能力管理(classifyAll)
115
- // 枚举中;它们由 dsh 原生暴露面直连,仅受投影链可见性裁剪,须在
116
- // tools.exposed 保活。与 invoke 的 id 前缀守卫保持一致。
117
- if (!schema.name.startsWith(MCP_ID_PREFIX))
118
- continue;
119
- const existing = toolRecords.get(schema.name);
120
- const stats = existing?.stats ?? { uses: 0, successes: 0, failures: 0, totalMs: 0 };
121
- const serverName = serverNameOf(schema.name);
122
- next.set(schema.name, {
123
- id: schema.name,
124
- kind: 'tool',
125
- actions: ['execute'],
126
- name: schema.name,
127
- description: schema.description,
128
- origin: { provider: serverName, serverName },
129
- parameters: schema.parameters,
130
- invocation: { modelInvocable: true, userInvocable: false },
131
- tags: [serverName, 'tool'],
132
- stats,
133
- summary: toSummary(schema.description, summaryMaxChars),
134
- });
135
- }
136
- toolRecords = next;
160
+ /**
161
+ * Index one visible tool schema into `next`, the accumulator owned by the
162
+ * current rebuild pass, deduped by name.
163
+ * 全部可见工具都进编目,仅排除 meta_search/meta_invoke(本插件控制面,
164
+ * 恒常驻)与 run_code(Code Mode 保留传输层)。mcp__ 工具按真实 server
165
+ * 分组;原生工具(无 mcp__ 前缀)统一归入保留的 built-in server,使能力
166
+ * 菜单能统一按 server 分组、三档管理,meta_invoke 也能派发它们。
167
+ * A schema may surface from several preset scope views; the first wins.
168
+ */
169
+ const indexToolSchema = (next, schema) => {
170
+ if (CATALOG_EXCLUDED_TOOLS.has(schema.name))
171
+ return;
172
+ if (next.has(schema.name))
173
+ return;
174
+ const existing = toolRecords.get(schema.name);
175
+ const stats = existing?.stats ?? { uses: 0, successes: 0, failures: 0, totalMs: 0 };
176
+ const isMcp = schema.name.startsWith(MCP_ID_PREFIX);
177
+ const serverName = isMcp ? serverNameOf(schema.name) : BUILT_IN_SERVER;
178
+ next.set(schema.name, {
179
+ id: schema.name,
180
+ kind: 'tool',
181
+ actions: ['execute'],
182
+ name: schema.name,
183
+ description: schema.description,
184
+ origin: { provider: serverName, serverName },
185
+ parameters: schema.parameters,
186
+ invocation: { modelInvocable: true, userInvocable: false },
187
+ tags: [serverName, 'tool'],
188
+ stats,
189
+ summary: toSummary(schema.description, summaryMaxChars),
190
+ });
137
191
  };
138
- /** Read and parse the Progressive-skill catalog YAML into entries (empty when unconfigured/unreadable). */
139
- const loadProgressiveSkills = async () => {
140
- const file = config.progressiveSkillCatalog;
141
- if (file === undefined || file.length === 0)
142
- return [];
143
- const path = resolve(process.cwd(), file);
144
- let text;
145
- try {
146
- text = await readFile(path, 'utf8');
147
- }
148
- catch (error) {
149
- ctx.logger.warn(`meta-registry: progressive skill catalog not readable (${path}): ${String(error)}`);
150
- return [];
151
- }
152
- let parsed;
153
- try {
154
- parsed = yaml.load(text);
155
- }
156
- catch (error) {
157
- ctx.logger.warn(`meta-registry: progressive skill catalog parse failed (${path}): ${String(error)}`);
158
- return [];
159
- }
160
- if (parsed === null || typeof parsed !== 'object')
161
- return [];
162
- const list = parsed.skills;
163
- if (!Array.isArray(list))
164
- return [];
165
- const entries = [];
166
- for (const raw of list) {
167
- if (raw === null || typeof raw !== 'object')
168
- continue;
169
- const item = raw;
170
- if (typeof item.name !== 'string' || item.name.length === 0)
171
- continue;
172
- const description = typeof item.description === 'string' ? item.description : '';
173
- entries.push({
174
- name: item.name,
175
- description,
176
- ...typeof item.whenToUse === 'string' ? { whenToUse: item.whenToUse } : {},
177
- ...typeof item.path === 'string' ? { path: item.path } : {},
178
- });
192
+ /**
193
+ * Rebuild the tool index from the visible tool registries.
194
+ *
195
+ * Harness-native tools are registered on the agent plane (per agent preset's
196
+ * standing scope), not the root/global layer the plugin's own `ctx.tools`
197
+ * view sees — exactly the layout the skills side enumerates below. Mirror
198
+ * that: index the global view first, then every mountable preset's standing
199
+ * scope, so natives land under the reserved `built-in` pseudo-server.
200
+ */
201
+ // Rebuilds can overlap (eager mount-time run vs. a change-event refresh);
202
+ // the epoch guard drops a superseded run so its older snapshot never
203
+ // clobbers a newer one. The accumulator is local to each run for the same
204
+ // reason: a shared one would be reset by the newer run mid-flight, letting
205
+ // the older run publish a partial catalog when it wins the epoch check.
206
+ let toolIndexEpoch = 0;
207
+ const rebuildTools = async () => {
208
+ const epoch = ++toolIndexEpoch;
209
+ const nextToolRecords = new Map();
210
+ for (const schema of ctx.tools.schemas())
211
+ indexToolSchema(nextToolRecords, schema);
212
+ const agentPresets = ctx.get('agentPresets');
213
+ if (agentPresets !== undefined) {
214
+ let presets = [];
215
+ try {
216
+ presets = await agentPresets.list();
217
+ }
218
+ catch (error) {
219
+ ctx.logger.warn(`meta-registry: agent-presets enumeration failed: ${String(error)}`);
220
+ }
221
+ for (const preset of presets) {
222
+ if (preset.broken !== undefined)
223
+ continue;
224
+ try {
225
+ const scope = await agentPresets.standingKeyFor(preset.id);
226
+ for (const schema of ctx.tools.schemas(scope))
227
+ indexToolSchema(nextToolRecords, schema);
228
+ }
229
+ catch (error) {
230
+ ctx.logger.warn(`meta-registry: preset "${preset.id}" tool scope unavailable: ${String(error)}`);
231
+ }
232
+ }
179
233
  }
180
- return entries;
234
+ if (epoch === toolIndexEpoch)
235
+ toolRecords = nextToolRecords;
181
236
  };
182
237
  /**
183
238
  * Rebuild the skill index asynchronously; resolves when the refresh completes.
@@ -187,14 +242,19 @@ export function apply(ctx, config = {}) {
187
242
  * registers into that preset's layer, so the host-plane global read alone
188
243
  * sees nothing). The management catalog enumerates the global layer and then
189
244
  * every mountable preset's standing scope, so preset-scoped skills surface.
245
+ *
246
+ * Same epoch guard as the tool side: overlapping refreshes resolve out of
247
+ * order, and a stale snapshot must not overwrite a newer one.
190
248
  */
249
+ let skillIndexEpoch = 0;
191
250
  const refreshSkills = async () => {
251
+ const epoch = ++skillIndexEpoch;
192
252
  const nextSkills = new Map();
193
253
  const nextSkillScopes = new Map();
194
254
  const indexSkill = (skill, scope) => {
195
255
  if (!isModelInvocable(skill))
196
256
  return;
197
- const id = skillId(skill.name);
257
+ const id = skill.name;
198
258
  const existing = skillRecords.get(id);
199
259
  const stats = existing?.stats ?? { uses: 0, successes: 0, failures: 0, totalMs: 0 };
200
260
  nextSkills.set(id, {
@@ -206,9 +266,13 @@ export function apply(ctx, config = {}) {
206
266
  ...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
207
267
  origin: {
208
268
  provider: skill.provider,
269
+ ...typeof skill.source === 'string' ? { source: skill.source } : {},
270
+ // The skill's own directory, when its provider has one. It is what lets
271
+ // the location manager address project-scoped skills for edit/remove.
272
+ ...skill.resourceBase?.kind === 'directory' ? { path: skill.resourceBase.path } : {},
209
273
  },
210
274
  parameters: { type: 'object', properties: {}, additionalProperties: false },
211
- invocation: { modelInvocable: true, userInvocable: skill.invocation.userInvocable },
275
+ invocation: { modelInvocable: skill.invocation.modelInvocable, userInvocable: skill.invocation.userInvocable },
212
276
  tags: [skill.provider, 'skill'],
213
277
  stats,
214
278
  summary: toSummary(skill.description, summaryMaxChars),
@@ -253,53 +317,227 @@ export function apply(ctx, config = {}) {
253
317
  }
254
318
  }
255
319
  }
256
- // Additionally index Progressive skills from the independent YAML catalog.
257
- // Exposed skills (from ctx.skills) win on a name collision so an Exposed
258
- // skill is never shadowed by a stale Progressive catalog entry.
259
- const progressive = await loadProgressiveSkills();
260
- for (const entry of progressive) {
261
- const id = skillId(entry.name);
262
- if (nextSkills.has(id))
263
- continue;
264
- const existing = skillRecords.get(id);
265
- const stats = existing?.stats ?? { uses: 0, successes: 0, failures: 0, totalMs: 0 };
266
- nextSkills.set(id, {
267
- id,
268
- kind: 'skill',
269
- actions: ['load'],
270
- name: entry.name,
271
- description: entry.description,
272
- ...entry.whenToUse !== undefined ? { whenToUse: entry.whenToUse } : {},
273
- origin: {
274
- provider: 'progressive-catalog',
275
- ...entry.path !== undefined ? { path: entry.path } : {},
276
- },
277
- parameters: { type: 'object', properties: {}, additionalProperties: false },
278
- invocation: { modelInvocable: true, userInvocable: false },
279
- tags: ['progressive-catalog', 'skill'],
280
- stats,
281
- summary: toSummary(entry.description, summaryMaxChars),
282
- });
283
- nextSkillScopes.set(id, undefined);
284
- }
320
+ if (epoch !== skillIndexEpoch)
321
+ return;
285
322
  skillRecords = nextSkills;
286
323
  skillScopes = nextSkillScopes;
287
324
  };
288
- /** Refresh the whole catalog: MCP tools synchronously, skills asynchronously. */
325
+ /**
326
+ * Emit the on-demand capability catalog as a YAML file the model can browse
327
+ * with grep/read. Only On-demand capabilities are written; Resident ones are
328
+ * already in the model surface and Disabled ones must stay undiscoverable.
329
+ * Skips emission when the policy plugin is not mounted (no classification) or
330
+ * the file path is disabled.
331
+ */
332
+ const writeCatalog = async () => {
333
+ onDemandCount = 0;
334
+ if (catalogFile.length === 0)
335
+ return;
336
+ const policy = ctx.get('capabilityPolicy');
337
+ if (policy === undefined)
338
+ return;
339
+ const onDemand = [...toolRecords.values(), ...skillRecords.values()]
340
+ .filter(record => policy.classifyCapability(record.id, record.kind) === 'on-demand')
341
+ .map(record => ({
342
+ id: record.id,
343
+ kind: record.kind,
344
+ name: record.name,
345
+ // Use the trimmed summary (160 chars), not the full description: the
346
+ // file is for grep/read discovery, and a few hundred tools would
347
+ // otherwise balloon to tens of KB of context.
348
+ description: record.summary,
349
+ ...record.whenToUse !== undefined ? { whenToUse: record.whenToUse } : {},
350
+ ...record.origin.serverName !== undefined ? { server: record.origin.serverName } : {},
351
+ }));
352
+ onDemandCount = onDemand.length;
353
+ const content = yaml.dump({ capabilities: onDemand });
354
+ // A rebuild that changed nothing must not touch the disk: at debounce rate
355
+ // this was rewriting the file several times a second.
356
+ if (content === lastCatalogContent)
357
+ return;
358
+ try {
359
+ await writeFile(catalogFile, content, 'utf8');
360
+ lastCatalogContent = content;
361
+ }
362
+ catch (error) {
363
+ ctx.logger.warn(`capability-registry: on-demand catalog write failed (${catalogFile}): ${String(error)}`);
364
+ }
365
+ };
366
+ // --- Refresh scheduling --------------------------------------------------
367
+ // Change events (`tools/change` / `skills/change`) arrive in bursts: the
368
+ // skill/preset lifecycle invalidates caches and re-registers providers in
369
+ // quick succession, and a rebuild's own registry access can emit further
370
+ // change events. Rebuilding once per event produced an unbounded CPU storm
371
+ // (a full preset re-scan per event), so the scheduler:
372
+ // 1. runs at most one rebuild at a time (single flight);
373
+ // 2. coalesces every event landing during a run into at most one
374
+ // debounced follow-up rebuild;
375
+ // 3. lets an explicit `refresh()` await the rebuild chain covering its own
376
+ // call, so callers never observe a stale (or empty) catalog.
377
+ // The epoch guards inside `rebuildTools`/`refreshSkills` stay as the last
378
+ // line of defence for concurrent correctness.
379
+ /** Monotonic count of rebuild requests received (events + explicit calls). */
380
+ let eventSeq = 0;
381
+ /** The `eventSeq` value fully applied by the last completed rebuild. */
382
+ let appliedSeq = 0;
383
+ let rebuilding = false;
384
+ let stopped = false;
385
+ let timer;
386
+ const waiters = [];
387
+ /** Resolve every waiter whose snapshot is already covered by `appliedSeq`. */
388
+ const settleWaiters = () => {
389
+ if (waiters.length === 0)
390
+ return;
391
+ const ready = waiters.filter(waiter => waiter.seq <= appliedSeq);
392
+ if (ready.length === 0)
393
+ return;
394
+ for (const waiter of ready)
395
+ waiters.splice(waiters.indexOf(waiter), 1);
396
+ for (const waiter of ready)
397
+ waiter.resolve();
398
+ };
399
+ /**
400
+ * Consecutive rebuilds that reproduced the previous catalog. A noisy event
401
+ * stream that never changes anything (session re-registrations, the skill
402
+ * watcher) would otherwise keep the scheduler at the debounce rate, and each
403
+ * rebuild re-enumerates every preset's tools and skills — measured at ~1.5s
404
+ * CPU each, enough to starve the event loop and stall unrelated UI calls.
405
+ */
406
+ let idleStreak = 0;
407
+ /** Upper bound for the backoff, so a real change is never pending long. */
408
+ const BACKOFF_MAX_MS = 10_000;
409
+ const currentDelay = () => {
410
+ // The ceiling can never fall below the configured base, or a large
411
+ // `refreshDebounceMs` would be clamped *down* into a faster schedule.
412
+ const ceiling = Math.max(BACKOFF_MAX_MS, refreshDebounceMs);
413
+ return Math.min(refreshDebounceMs * 2 ** idleStreak, ceiling);
414
+ };
415
+ /** Queue a coalesced rebuild after the (possibly backed-off) window. */
416
+ const schedule = () => {
417
+ if (stopped || rebuilding || timer !== undefined)
418
+ return;
419
+ timer = setTimeout(() => {
420
+ timer = undefined;
421
+ if (stopped || rebuilding)
422
+ return;
423
+ void runRebuild();
424
+ }, currentDelay());
425
+ };
426
+ /**
427
+ * Fingerprint of the indexed catalog — capability ids only, order-insensitive.
428
+ * Used to tell a rebuild that found something new from one that merely
429
+ * reproduced the previous catalog.
430
+ */
431
+ const catalogSignature = () => {
432
+ const ids = [];
433
+ for (const id of toolRecords.keys())
434
+ ids.push(`t:${id}`);
435
+ for (const id of skillRecords.keys())
436
+ ids.push(`s:${id}`);
437
+ ids.sort();
438
+ return ids.join('\u0000');
439
+ };
440
+ let lastSignature;
441
+ /** Consecutive follow-up runs that reproduced the previous catalog. */
442
+ let unchangedChain = 0;
443
+ /** One full rebuild; never rejects (a failed run must not hang waiters). */
444
+ const runRebuild = async () => {
445
+ rebuilding = true;
446
+ // Every request received so far is covered by this run.
447
+ const seqAtStart = eventSeq;
448
+ const startedAt = Date.now();
449
+ try {
450
+ await rebuildTools();
451
+ await refreshSkills();
452
+ await writeCatalog();
453
+ }
454
+ catch (error) {
455
+ ctx.logger.warn(`capability-registry: catalog rebuild failed: ${String(error)}`);
456
+ }
457
+ finally {
458
+ rebuilding = false;
459
+ if (seqAtStart > appliedSeq)
460
+ appliedSeq = seqAtStart;
461
+ settleWaiters();
462
+ // Requests that landed mid-run are not covered yet, so chain a
463
+ // follow-up — but never indefinitely. Two runs in a row that reproduce
464
+ // the same catalog mean the pending events carried no new capability:
465
+ // our own registry reads can emit change events, and dsh's skill watcher
466
+ // can stream them, so chaining on would spin at the debounce rate
467
+ // forever. One follow-up is still allowed before stopping, because a
468
+ // real change that lands mid-run is only visible to the *next* run.
469
+ const signature = catalogSignature();
470
+ const changed = signature !== lastSignature;
471
+ lastSignature = signature;
472
+ // One line per full rebuild. These are the expensive runs (every tool plus
473
+ // every agent-preset skill scope); if they are repeating, this is where it
474
+ // shows — and a caller blocked behind one sees that as the UI stalling.
475
+ ctx.logger.info(`capability-registry: rebuild took ${Date.now() - startedAt}ms (catalog changed: ${changed}, ${toolRecords.size} tools, ${skillRecords.size} skills)`);
476
+ if (changed)
477
+ idleStreak = 0;
478
+ else if (idleStreak < 10)
479
+ idleStreak += 1;
480
+ if (!stopped && eventSeq > appliedSeq) {
481
+ if (changed) {
482
+ unchangedChain = 0;
483
+ schedule();
484
+ }
485
+ else if (unchangedChain === 0) {
486
+ unchangedChain = 1;
487
+ schedule();
488
+ }
489
+ }
490
+ }
491
+ };
492
+ /** Start a rebuild immediately, skipping the debounce window. */
493
+ const runNow = () => {
494
+ if (timer !== undefined) {
495
+ clearTimeout(timer);
496
+ timer = undefined;
497
+ }
498
+ if (!stopped && !rebuilding)
499
+ void runRebuild();
500
+ };
501
+ /**
502
+ * Refresh the whole catalog, resolving once the rebuild chain covering this
503
+ * call has converged (including any coalesced follow-up).
504
+ */
289
505
  const refresh = async () => {
290
- rebuildTools();
291
- await refreshSkills();
506
+ if (stopped)
507
+ return;
508
+ const seq = ++eventSeq;
509
+ runNow();
510
+ if (seq <= appliedSeq)
511
+ return;
512
+ await new Promise(resolve => {
513
+ waiters.push({ seq, resolve });
514
+ });
292
515
  };
293
516
  /** Register once; also subscribe to change events. */
294
517
  const disposers = [];
295
- disposers.push(ctx.on('tools/change', () => void refresh()));
296
- disposers.push(ctx.on('skills/change', () => void refresh()));
297
- // Build the synchronous tool index eagerly; the skill index is left to
298
- // the first explicit `refresh()` (or a change event) so an eager load never
299
- // snapshots — and caches inside the skill registry — an incomplete catalog.
300
- rebuildTools();
518
+ disposers.push(ctx.on('tools/change', () => {
519
+ eventSeq++;
520
+ schedule();
521
+ }));
522
+ disposers.push(ctx.on('skills/change', () => {
523
+ eventSeq++;
524
+ schedule();
525
+ }));
526
+ // Index the global tool view eagerly (the synchronous part of
527
+ // `rebuildTools`); preset standing scopes and skills are enumerated by the
528
+ // first explicit `refresh()` (policy mounts it before the surface is used)
529
+ // or a change event, so an eager load never snapshots — and caches inside the
530
+ // tool/skill registries — an incomplete catalog.
531
+ void rebuildTools();
301
532
  void refreshSkills();
302
533
  ctx.effect(() => () => {
534
+ stopped = true;
535
+ if (timer !== undefined)
536
+ clearTimeout(timer);
537
+ // Unblock any in-flight `refresh()`: the plugin is going away and no
538
+ // further rebuild will run.
539
+ for (const waiter of waiters.splice(0))
540
+ waiter.resolve();
303
541
  for (const dispose of disposers)
304
542
  dispose();
305
543
  });
@@ -312,11 +550,11 @@ export function apply(ctx, config = {}) {
312
550
  const id = options.id?.trim();
313
551
  const all = [];
314
552
  // Index/source is the GLOBAL registry — no visibility filter here.
315
- // Exposed/Progressive is a projection-layer concern (`dsh-capability-policy`),
316
- // so a Progressive tool hidden from the model's exposure surface must still
317
- // be searchable so `meta_search` can return it for `meta_invoke`. Blocked
553
+ // Resident/On-demand is a projection-layer concern (`dsh-capability-policy`),
554
+ // so an On-demand tool hidden from the model's exposure surface must still
555
+ // be searchable so `meta_search` can return it for `meta_invoke`. Disabled
318
556
  // enforcement lives at the model-facing tools (meta_search/meta_invoke),
319
- // keeping the management surface able to list Blocked capabilities.
557
+ // keeping the management surface able to list Disabled capabilities.
320
558
  if (kind === 'all' || kind === 'tool') {
321
559
  for (const record of toolRecords.values())
322
560
  all.push(record);
@@ -360,19 +598,18 @@ export function apply(ctx, config = {}) {
360
598
  name: record.name,
361
599
  summary: record.summary,
362
600
  ...record.origin.serverName !== undefined ? { server: record.origin.serverName } : {},
601
+ ...record.origin.source !== undefined ? { source: record.origin.source } : {},
363
602
  tags: record.tags,
364
603
  ...rate !== undefined ? { success_rate: rate } : {},
365
604
  uses: record.stats.uses,
366
605
  };
367
606
  });
368
607
  },
369
- get(id) {
370
- const key = id.trim();
371
- return toolRecords.get(key) ?? skillRecords.get(key);
608
+ get(id, kind) {
609
+ return resolveRecord(id.trim(), kind);
372
610
  },
373
- async getDetail(id, context = {}) {
374
- const key = id.trim();
375
- const record = toolRecords.get(key) ?? skillRecords.get(key);
611
+ async getDetail(id, kind, context = {}) {
612
+ const record = resolveRecord(id.trim(), kind);
376
613
  if (record === undefined)
377
614
  return undefined;
378
615
  if (record.kind === 'tool') {
@@ -421,8 +658,10 @@ export function apply(ctx, config = {}) {
421
658
  if (root === undefined)
422
659
  return undefined;
423
660
  const target = relPath.length === 0 ? root : resolve(root, relPath);
424
- // Containment: the resolved path must stay inside the skill root.
425
- if (target !== root && !target.startsWith(`${root}${sep}`))
661
+ // Containment: the resolved path must stay inside the skill root, both
662
+ // lexically (`..`) and after resolving symlinks (a link inside the skill
663
+ // directory must not lead out of it).
664
+ if (!(await isInside(root, target)))
426
665
  return undefined;
427
666
  try {
428
667
  const entries = await readdir(target, { withFileTypes: true });
@@ -441,8 +680,8 @@ export function apply(ctx, config = {}) {
441
680
  if (root === undefined)
442
681
  return undefined;
443
682
  const target = resolve(root, relPath);
444
- // Containment: the resolved path must stay inside the skill root.
445
- if (target !== root && !target.startsWith(`${root}${sep}`))
683
+ // Containment: see `listSkillDir` — lexical `..` and symlinks both checked.
684
+ if (!(await isInside(root, target)))
446
685
  return undefined;
447
686
  try {
448
687
  const info = await stat(target);
@@ -457,25 +696,52 @@ export function apply(ctx, config = {}) {
457
696
  return undefined;
458
697
  }
459
698
  },
699
+ skillDirs() {
700
+ const out = [];
701
+ for (const record of skillRecords.values()) {
702
+ const skillDir = record.origin.path;
703
+ if (skillDir === undefined)
704
+ continue;
705
+ out.push({
706
+ name: record.name,
707
+ skillDir,
708
+ ...record.origin.source !== undefined ? { source: record.origin.source } : {},
709
+ });
710
+ }
711
+ return out;
712
+ },
460
713
  size() {
461
714
  return toolRecords.size + skillRecords.size;
462
715
  },
716
+ catalogPath() {
717
+ return catalogFile.length === 0 ? undefined : catalogFile;
718
+ },
719
+ onDemandCount() {
720
+ return onDemandCount;
721
+ },
463
722
  refresh() {
464
723
  return refresh();
465
724
  },
725
+ requestRefresh() {
726
+ if (stopped)
727
+ return;
728
+ eventSeq++;
729
+ schedule();
730
+ },
731
+ rewriteCatalog() {
732
+ return writeCatalog();
733
+ },
466
734
  };
467
735
  // Observe tool results to write back objective stats. Nested dispatches
468
736
  // (parent set by meta_invoke) attribute to the target capability; the
469
737
  // meta_invoke wrapper itself records nothing for any capability.
470
738
  ctx.on('tools/result', (exec, result) => {
471
739
  const name = exec.name;
472
- if (!name.startsWith(MCP_ID_PREFIX))
473
- return;
474
740
  const record = toolRecords.get(name);
475
741
  if (record === undefined)
476
742
  return;
477
- // meta_invoke never carries the `mcp__` prefix (filtered above), so a nested
478
- // dispatch from meta_invoke attributes stats only to the target capability.
743
+ // Nested dispatches (a meta_invoke call or a native-tool forward) carry the
744
+ // target tool's own name, so stats always attribute to the target capability.
479
745
  const durationMs = result.meta !== undefined && typeof result.meta === 'object'
480
746
  && result.meta !== null && 'durationMs' in result.meta
481
747
  ? result.meta.durationMs
@@ -498,12 +764,4 @@ export function serverNameOf(publicName) {
498
764
  const index = rest.indexOf('__');
499
765
  return index === -1 ? rest : rest.slice(0, index);
500
766
  }
501
- /** Build the stable skill identifier `skill:<name>`. */
502
- export function skillId(name) {
503
- return `${SKILL_ID_PREFIX}${name}`;
504
- }
505
- /** Strip the `skill:` prefix from a capability id. */
506
- export function skillNameOf(id) {
507
- return id.startsWith(SKILL_ID_PREFIX) ? id.slice(SKILL_ID_PREFIX.length) : id;
508
- }
509
767
  //# sourceMappingURL=registry.js.map