@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.
- package/README.en.md +238 -0
- package/README.md +127 -63
- package/cordis.patch.yml +37 -46
- package/lib/client.d.ts +224 -19
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +1796 -226
- package/lib/constants.js +33 -0
- package/lib/constants.js.map +1 -0
- package/lib/index.js +1 -1
- package/lib/invoke.js +68 -96
- package/lib/invoke.js.map +1 -1
- package/lib/locations.js +541 -0
- package/lib/locations.js.map +1 -0
- package/lib/patch-file.js +192 -0
- package/lib/patch-file.js.map +1 -0
- package/lib/policy.js +422 -94
- package/lib/policy.js.map +1 -1
- package/lib/registry.js +410 -152
- package/lib/registry.js.map +1 -1
- package/lib/search.js +26 -9
- package/lib/search.js.map +1 -1
- package/lib/server/remote.js +219 -3
- package/lib/server/remote.js.map +1 -1
- package/lib/types/constants.d.ts +33 -0
- package/lib/types/constants.d.ts.map +1 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/invoke.d.ts +7 -13
- package/lib/types/invoke.d.ts.map +1 -1
- package/lib/types/locations.d.ts +142 -0
- package/lib/types/locations.d.ts.map +1 -0
- package/lib/types/patch-file.d.ts +46 -0
- package/lib/types/patch-file.d.ts.map +1 -0
- package/lib/types/policy.d.ts +115 -53
- package/lib/types/policy.d.ts.map +1 -1
- package/lib/types/registry.d.ts +94 -25
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/search.d.ts.map +1 -1
- package/lib/types/server/remote.d.ts +68 -2
- package/lib/types/server/remote.d.ts.map +1 -1
- package/package.json +23 -26
- package/lib/invariant.js +0 -22
- package/lib/invariant.js.map +0 -1
- package/lib/types/invariant.d.ts +0 -16
- 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 {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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)
|
|
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 =
|
|
92
|
-
const scope = skillScopes.get(
|
|
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
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
291
|
-
|
|
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', () =>
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
//
|
|
316
|
-
// so
|
|
317
|
-
// be searchable so `meta_search` can return it for `meta_invoke`.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
445
|
-
if (
|
|
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
|
-
//
|
|
478
|
-
//
|
|
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
|