@daweifu/capability-menu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +206 -0
- package/cordis.patch.yml +87 -0
- package/lib/index.js +20 -0
- package/lib/invariant.js +22 -0
- package/lib/invoke.js +299 -0
- package/lib/policy.js +264 -0
- package/lib/registry.js +509 -0
- package/lib/search.js +127 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invoke.d.ts +80 -0
- package/lib/types/policy.d.ts +181 -0
- package/lib/types/registry.d.ts +191 -0
- package/lib/types/search.d.ts +43 -0
- package/package.json +85 -0
package/lib/registry.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified capability catalog for the DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* @module @daweifu/capability-menu (registry plugin)
|
|
5
|
+
*/
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
import { isModelInvocable } from '@deepseek-ai/dsh-skill';
|
|
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__';
|
|
14
|
+
/** Validate and default the registry configuration. */
|
|
15
|
+
export const Config = z.object({
|
|
16
|
+
summaryMaxChars: z.number().default(160),
|
|
17
|
+
detailIncludesBody: z.boolean().default(false),
|
|
18
|
+
maxResults: z.number().default(20),
|
|
19
|
+
weighting: z.number().default(0.1),
|
|
20
|
+
// schemastery object properties are optional-by-default: a missing key or
|
|
21
|
+
// undefined value is accepted (no `meta.required`), so no `.optional()` needed.
|
|
22
|
+
progressiveSkillCatalog: z.string(),
|
|
23
|
+
});
|
|
24
|
+
function assertPositiveInteger(name, value, min) {
|
|
25
|
+
if (!Number.isInteger(value) || value < min) {
|
|
26
|
+
throw new Error(`${name} must be an integer >= ${min}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function assertWeight(name, value) {
|
|
30
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
31
|
+
throw new Error(`${name} must be a number in [0, 1]`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Trim a description to a model-friendly summary. */
|
|
35
|
+
function toSummary(description, maxChars) {
|
|
36
|
+
const collapsed = description.replace(/\s+/g, ' ').trim();
|
|
37
|
+
if (collapsed.length <= maxChars)
|
|
38
|
+
return collapsed;
|
|
39
|
+
return `${collapsed.slice(0, maxChars - 1)}…`;
|
|
40
|
+
}
|
|
41
|
+
/** BM25-ish keyword score over one record (pure JS, zero dependencies). */
|
|
42
|
+
function keywordScore(record, tokens) {
|
|
43
|
+
const haystack = [
|
|
44
|
+
record.name,
|
|
45
|
+
record.description,
|
|
46
|
+
record.whenToUse ?? '',
|
|
47
|
+
record.origin.provider,
|
|
48
|
+
...record.tags,
|
|
49
|
+
].join(' ').toLowerCase();
|
|
50
|
+
let score = 0;
|
|
51
|
+
for (const token of tokens) {
|
|
52
|
+
if (token.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
if (haystack.includes(token))
|
|
55
|
+
score += 1;
|
|
56
|
+
// Substring hits on the identifier are stronger (exact id match handled separately).
|
|
57
|
+
if (record.id.toLowerCase().includes(token))
|
|
58
|
+
score += 1.5;
|
|
59
|
+
}
|
|
60
|
+
return score;
|
|
61
|
+
}
|
|
62
|
+
function successRate(stats) {
|
|
63
|
+
if (stats.uses === 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
return stats.successes / stats.uses;
|
|
66
|
+
}
|
|
67
|
+
/** Build the registry plugin. */
|
|
68
|
+
export const name = 'capability-menu-registry';
|
|
69
|
+
export const inject = ['tools', 'skills'];
|
|
70
|
+
export function apply(ctx, config = {}) {
|
|
71
|
+
const summaryMaxChars = config.summaryMaxChars ?? 160;
|
|
72
|
+
const detailIncludesBody = config.detailIncludesBody ?? false;
|
|
73
|
+
const maxResults = config.maxResults ?? 20;
|
|
74
|
+
const weighting = config.weighting ?? 0.1;
|
|
75
|
+
assertPositiveInteger('summaryMaxChars', summaryMaxChars, 20);
|
|
76
|
+
assertPositiveInteger('maxResults', maxResults, 1);
|
|
77
|
+
assertWeight('weighting', weighting);
|
|
78
|
+
/** MCP tool records keyed by tool name; skills keyed by `skill:<name>`. */
|
|
79
|
+
let toolRecords = new Map();
|
|
80
|
+
let skillRecords = new Map();
|
|
81
|
+
/** The scope each indexed skill was collected from; undefined = global layer. */
|
|
82
|
+
let skillScopes = new Map();
|
|
83
|
+
const statsOf = (record) => record.stats;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a skill's root directory from the live skill registry. The
|
|
86
|
+
* 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.
|
|
89
|
+
*/
|
|
90
|
+
const skillRootOf = async (id) => {
|
|
91
|
+
const name = skillNameOf(id);
|
|
92
|
+
const scope = skillScopes.get(id);
|
|
93
|
+
const lookup = scope === undefined ? {} : { scope };
|
|
94
|
+
const definition = await ctx.skills.get(name, lookup).catch(() => undefined);
|
|
95
|
+
if (definition !== undefined) {
|
|
96
|
+
if (definition.resourceBase !== undefined && definition.resourceBase.kind === 'directory') {
|
|
97
|
+
return definition.resourceBase.path;
|
|
98
|
+
}
|
|
99
|
+
if (definition.path !== undefined)
|
|
100
|
+
return dirname(definition.path);
|
|
101
|
+
}
|
|
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
|
+
return undefined;
|
|
108
|
+
};
|
|
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;
|
|
137
|
+
};
|
|
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
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return entries;
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Rebuild the skill index asynchronously; resolves when the refresh completes.
|
|
184
|
+
*
|
|
185
|
+
* Skills live in a per-scope registry: the global layer plus one layer per
|
|
186
|
+
* agent preset that mounts skill providers (a preset's `skill-filesystem`
|
|
187
|
+
* registers into that preset's layer, so the host-plane global read alone
|
|
188
|
+
* sees nothing). The management catalog enumerates the global layer and then
|
|
189
|
+
* every mountable preset's standing scope, so preset-scoped skills surface.
|
|
190
|
+
*/
|
|
191
|
+
const refreshSkills = async () => {
|
|
192
|
+
const nextSkills = new Map();
|
|
193
|
+
const nextSkillScopes = new Map();
|
|
194
|
+
const indexSkill = (skill, scope) => {
|
|
195
|
+
if (!isModelInvocable(skill))
|
|
196
|
+
return;
|
|
197
|
+
const id = skillId(skill.name);
|
|
198
|
+
const existing = skillRecords.get(id);
|
|
199
|
+
const stats = existing?.stats ?? { uses: 0, successes: 0, failures: 0, totalMs: 0 };
|
|
200
|
+
nextSkills.set(id, {
|
|
201
|
+
id,
|
|
202
|
+
kind: 'skill',
|
|
203
|
+
actions: ['load'],
|
|
204
|
+
name: skill.name,
|
|
205
|
+
description: skill.description,
|
|
206
|
+
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
|
|
207
|
+
origin: {
|
|
208
|
+
provider: skill.provider,
|
|
209
|
+
},
|
|
210
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
211
|
+
invocation: { modelInvocable: true, userInvocable: skill.invocation.userInvocable },
|
|
212
|
+
tags: [skill.provider, 'skill'],
|
|
213
|
+
stats,
|
|
214
|
+
summary: toSummary(skill.description, summaryMaxChars),
|
|
215
|
+
});
|
|
216
|
+
nextSkillScopes.set(id, scope);
|
|
217
|
+
};
|
|
218
|
+
const collectScoped = async (label, scope) => {
|
|
219
|
+
let skills = [];
|
|
220
|
+
try {
|
|
221
|
+
skills = scope === undefined ? await ctx.skills.list({}) : await ctx.skills.list({ scope });
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
ctx.logger.warn(`meta-registry: skill catalog refresh failed for ${label}: ${String(error)}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const skill of skills)
|
|
228
|
+
indexSkill(skill, scope);
|
|
229
|
+
};
|
|
230
|
+
// Global layer first: the host's own skill providers.
|
|
231
|
+
await collectScoped('global', undefined);
|
|
232
|
+
// Preset layers: agent presets mount skill providers into their own scope
|
|
233
|
+
// (web surface disables the host-plane rows by design). `agentPresets` is
|
|
234
|
+
// optional — headless bundles without it index the global layer only.
|
|
235
|
+
const agentPresets = ctx.get('agentPresets');
|
|
236
|
+
if (agentPresets !== undefined) {
|
|
237
|
+
let presets = [];
|
|
238
|
+
try {
|
|
239
|
+
presets = await agentPresets.list();
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
ctx.logger.warn(`meta-registry: agent-presets enumeration failed: ${String(error)}`);
|
|
243
|
+
}
|
|
244
|
+
for (const preset of presets) {
|
|
245
|
+
if (preset.broken !== undefined)
|
|
246
|
+
continue;
|
|
247
|
+
try {
|
|
248
|
+
const scope = await agentPresets.standingKeyFor(preset.id);
|
|
249
|
+
await collectScoped(`preset "${preset.id}"`, scope);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
ctx.logger.warn(`meta-registry: preset "${preset.id}" skill scope unavailable: ${String(error)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
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
|
+
}
|
|
285
|
+
skillRecords = nextSkills;
|
|
286
|
+
skillScopes = nextSkillScopes;
|
|
287
|
+
};
|
|
288
|
+
/** Refresh the whole catalog: MCP tools synchronously, skills asynchronously. */
|
|
289
|
+
const refresh = async () => {
|
|
290
|
+
rebuildTools();
|
|
291
|
+
await refreshSkills();
|
|
292
|
+
};
|
|
293
|
+
/** Register once; also subscribe to change events. */
|
|
294
|
+
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();
|
|
301
|
+
void refreshSkills();
|
|
302
|
+
ctx.effect(() => () => {
|
|
303
|
+
for (const dispose of disposers)
|
|
304
|
+
dispose();
|
|
305
|
+
});
|
|
306
|
+
const service = {
|
|
307
|
+
search(options = {}) {
|
|
308
|
+
const kind = options.kind ?? 'all';
|
|
309
|
+
const server = options.server;
|
|
310
|
+
const tag = options.tag;
|
|
311
|
+
const query = options.query?.trim();
|
|
312
|
+
const id = options.id?.trim();
|
|
313
|
+
const all = [];
|
|
314
|
+
// 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
|
|
318
|
+
// enforcement lives at the model-facing tools (meta_search/meta_invoke),
|
|
319
|
+
// keeping the management surface able to list Blocked capabilities.
|
|
320
|
+
if (kind === 'all' || kind === 'tool') {
|
|
321
|
+
for (const record of toolRecords.values())
|
|
322
|
+
all.push(record);
|
|
323
|
+
}
|
|
324
|
+
if (kind === 'all' || kind === 'skill') {
|
|
325
|
+
for (const record of skillRecords.values())
|
|
326
|
+
all.push(record);
|
|
327
|
+
}
|
|
328
|
+
const filtered = all.filter(record => {
|
|
329
|
+
if (server !== undefined && record.origin.serverName !== server)
|
|
330
|
+
return false;
|
|
331
|
+
if (tag !== undefined && !record.tags.includes(tag))
|
|
332
|
+
return false;
|
|
333
|
+
if (id !== undefined && record.id !== id)
|
|
334
|
+
return false;
|
|
335
|
+
return true;
|
|
336
|
+
});
|
|
337
|
+
// Rank: exact id/name first, then keyword score, then experience weight.
|
|
338
|
+
const tokens = (query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
339
|
+
const ranked = filtered.map(record => {
|
|
340
|
+
let score = 0;
|
|
341
|
+
if (query !== undefined && query.length > 0) {
|
|
342
|
+
const exact = record.id === query || record.name === query;
|
|
343
|
+
if (exact)
|
|
344
|
+
score += 100;
|
|
345
|
+
score += keywordScore(record, tokens);
|
|
346
|
+
}
|
|
347
|
+
const rate = successRate(record.stats);
|
|
348
|
+
if (rate !== undefined && weighting > 0)
|
|
349
|
+
score += rate * weighting * 10;
|
|
350
|
+
if (record.stats.uses > 0 && weighting > 0)
|
|
351
|
+
score += Math.min(record.stats.uses, 100) * weighting * 0.01;
|
|
352
|
+
return { record, score };
|
|
353
|
+
}).sort((a, b) => b.score - a.score);
|
|
354
|
+
const limit = Math.max(1, options.maxResults ?? maxResults);
|
|
355
|
+
return ranked.slice(0, limit).map(({ record }) => {
|
|
356
|
+
const rate = successRate(record.stats);
|
|
357
|
+
return {
|
|
358
|
+
id: record.id,
|
|
359
|
+
kind: record.kind,
|
|
360
|
+
name: record.name,
|
|
361
|
+
summary: record.summary,
|
|
362
|
+
...record.origin.serverName !== undefined ? { server: record.origin.serverName } : {},
|
|
363
|
+
tags: record.tags,
|
|
364
|
+
...rate !== undefined ? { success_rate: rate } : {},
|
|
365
|
+
uses: record.stats.uses,
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
get(id) {
|
|
370
|
+
const key = id.trim();
|
|
371
|
+
return toolRecords.get(key) ?? skillRecords.get(key);
|
|
372
|
+
},
|
|
373
|
+
async getDetail(id, context = {}) {
|
|
374
|
+
const key = id.trim();
|
|
375
|
+
const record = toolRecords.get(key) ?? skillRecords.get(key);
|
|
376
|
+
if (record === undefined)
|
|
377
|
+
return undefined;
|
|
378
|
+
if (record.kind === 'tool') {
|
|
379
|
+
const definition = ctx.tools.get(record.name, context.scope);
|
|
380
|
+
if (definition === undefined)
|
|
381
|
+
return undefined;
|
|
382
|
+
return {
|
|
383
|
+
id: record.id,
|
|
384
|
+
kind: 'tool',
|
|
385
|
+
actions: record.actions,
|
|
386
|
+
name: record.name,
|
|
387
|
+
description: definition.description,
|
|
388
|
+
parameters: definition.parameters,
|
|
389
|
+
output: definition.output.schema,
|
|
390
|
+
origin: record.origin,
|
|
391
|
+
tags: record.tags,
|
|
392
|
+
stats: statsOf(record),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// Skill: resolve the body if configured.
|
|
396
|
+
const lookup = {
|
|
397
|
+
cwd: context.cwd,
|
|
398
|
+
signal: context.signal,
|
|
399
|
+
scope: context.scope,
|
|
400
|
+
};
|
|
401
|
+
const skill = await ctx.skills.get(record.name, lookup).catch(() => undefined);
|
|
402
|
+
const detail = {
|
|
403
|
+
id: record.id,
|
|
404
|
+
kind: 'skill',
|
|
405
|
+
actions: record.actions,
|
|
406
|
+
name: record.name,
|
|
407
|
+
description: record.description,
|
|
408
|
+
...record.whenToUse !== undefined ? { whenToUse: record.whenToUse } : {},
|
|
409
|
+
parameters: record.parameters,
|
|
410
|
+
origin: record.origin,
|
|
411
|
+
tags: record.tags,
|
|
412
|
+
stats: statsOf(record),
|
|
413
|
+
};
|
|
414
|
+
if (skill !== undefined && detailIncludesBody) {
|
|
415
|
+
return { ...detail, output: { type: 'object', properties: { content: { type: 'string' } } } };
|
|
416
|
+
}
|
|
417
|
+
return detail;
|
|
418
|
+
},
|
|
419
|
+
async listSkillDir(id, relPath = '') {
|
|
420
|
+
const root = await skillRootOf(id);
|
|
421
|
+
if (root === undefined)
|
|
422
|
+
return undefined;
|
|
423
|
+
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}`))
|
|
426
|
+
return undefined;
|
|
427
|
+
try {
|
|
428
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
429
|
+
return entries.map(entry => ({
|
|
430
|
+
name: entry.name,
|
|
431
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
ctx.logger.warn(`meta-registry: skill dir listing failed for "${id}" at ${target}: ${String(error)}`);
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
async readSkillFile(id, relPath) {
|
|
440
|
+
const root = await skillRootOf(id);
|
|
441
|
+
if (root === undefined)
|
|
442
|
+
return undefined;
|
|
443
|
+
const target = resolve(root, relPath);
|
|
444
|
+
// Containment: the resolved path must stay inside the skill root.
|
|
445
|
+
if (target !== root && !target.startsWith(`${root}${sep}`))
|
|
446
|
+
return undefined;
|
|
447
|
+
try {
|
|
448
|
+
const info = await stat(target);
|
|
449
|
+
if (!info.isFile())
|
|
450
|
+
return undefined;
|
|
451
|
+
const text = await readFile(target, 'utf8');
|
|
452
|
+
// NUL marks binary content; do not surface it as a text preview.
|
|
453
|
+
return text.includes('\0') ? undefined : text;
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
ctx.logger.warn(`meta-registry: skill file read failed for "${id}" at ${target}: ${String(error)}`);
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
size() {
|
|
461
|
+
return toolRecords.size + skillRecords.size;
|
|
462
|
+
},
|
|
463
|
+
refresh() {
|
|
464
|
+
return refresh();
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
// Observe tool results to write back objective stats. Nested dispatches
|
|
468
|
+
// (parent set by meta_invoke) attribute to the target capability; the
|
|
469
|
+
// meta_invoke wrapper itself records nothing for any capability.
|
|
470
|
+
ctx.on('tools/result', (exec, result) => {
|
|
471
|
+
const name = exec.name;
|
|
472
|
+
if (!name.startsWith(MCP_ID_PREFIX))
|
|
473
|
+
return;
|
|
474
|
+
const record = toolRecords.get(name);
|
|
475
|
+
if (record === undefined)
|
|
476
|
+
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.
|
|
479
|
+
const durationMs = result.meta !== undefined && typeof result.meta === 'object'
|
|
480
|
+
&& result.meta !== null && 'durationMs' in result.meta
|
|
481
|
+
? result.meta.durationMs
|
|
482
|
+
: undefined;
|
|
483
|
+
const base = statsOf(record);
|
|
484
|
+
const next = {
|
|
485
|
+
uses: base.uses + 1,
|
|
486
|
+
successes: base.successes + (result.isError ? 0 : 1),
|
|
487
|
+
failures: base.failures + (result.isError ? 1 : 0),
|
|
488
|
+
totalMs: base.totalMs + (typeof durationMs === 'number' && Number.isFinite(durationMs) ? durationMs : 0),
|
|
489
|
+
lastUsedAt: Date.now(),
|
|
490
|
+
};
|
|
491
|
+
toolRecords.set(name, { ...record, stats: next });
|
|
492
|
+
});
|
|
493
|
+
ctx.provide('meta', service);
|
|
494
|
+
}
|
|
495
|
+
/** Derive the server namespace from an `mcp__<server>__<raw>` name. */
|
|
496
|
+
export function serverNameOf(publicName) {
|
|
497
|
+
const rest = publicName.startsWith(MCP_ID_PREFIX) ? publicName.slice(MCP_ID_PREFIX.length) : publicName;
|
|
498
|
+
const index = rest.indexOf('__');
|
|
499
|
+
return index === -1 ? rest : rest.slice(0, index);
|
|
500
|
+
}
|
|
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
|
+
//# sourceMappingURL=registry.js.map
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing `meta_search` tool: capability catalog search + detail.
|
|
3
|
+
*
|
|
4
|
+
* @module @daweifu/capability-menu (search plugin)
|
|
5
|
+
*/
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
|
+
export const name = 'capability-menu-search';
|
|
9
|
+
export const inject = ['meta', 'tools', 'skills'];
|
|
10
|
+
/** Validate and default the tool configuration. */
|
|
11
|
+
export const Config = z.object({
|
|
12
|
+
maxResults: z.number().default(20),
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Register the `meta_search` tool.
|
|
16
|
+
*
|
|
17
|
+
* - Mode A (list, default): query by keyword/tag/server, returns id + short summary.
|
|
18
|
+
* - Mode B (detail): pass an exact id (optionally `detail: true`) to get the full schema.
|
|
19
|
+
*
|
|
20
|
+
* Validation rules enforced here:
|
|
21
|
+
* - `query` and `id` are mutually exclusive.
|
|
22
|
+
* - `detail: true` with a fuzzy query (no exact id) is rejected.
|
|
23
|
+
*/
|
|
24
|
+
export function apply(ctx, config = {}) {
|
|
25
|
+
const maxResults = config.maxResults ?? 20;
|
|
26
|
+
if (!Number.isInteger(maxResults) || maxResults < 1) {
|
|
27
|
+
throw new Error('maxResults must be a positive integer');
|
|
28
|
+
}
|
|
29
|
+
const tool = defineTool({
|
|
30
|
+
name: 'meta_search',
|
|
31
|
+
description: 'Search capabilities (tools and skills) by keyword/tag/source, or get full detail for one capability by exact id. Mode A (list, default): returns id + short summary for each hit. Mode B (detail): pass an exact id (optionally detail:true) to get the full schema/description. Use meta_invoke with a returned id to run/load the capability.',
|
|
32
|
+
parameters: {
|
|
33
|
+
query: { type: 'string', description: 'Natural-language or keyword query; mutually exclusive with id.' },
|
|
34
|
+
id: { type: 'string', description: 'Exact capability id (from a previous search results[].id); mutually exclusive with query, takes precedence.' },
|
|
35
|
+
detail: { type: 'boolean', description: 'When true, returns the single capability full schema; only meaningful with an exact id.' },
|
|
36
|
+
kind: { type: 'string', enum: ['tool', 'skill', 'all'], description: 'Filter by capability kind (default all).' },
|
|
37
|
+
server: { type: 'string', description: 'Filter by MCP serverName, e.g. gongfeng/iwiki/km/zhiyan_qci.' },
|
|
38
|
+
tag: { type: 'string', description: 'Filter by tag.' },
|
|
39
|
+
max_results: { type: 'integer', description: 'Maximum results in list mode (default 20, max 50).' },
|
|
40
|
+
},
|
|
41
|
+
output: {
|
|
42
|
+
schema: {
|
|
43
|
+
oneOf: [
|
|
44
|
+
{
|
|
45
|
+
type: 'object',
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
properties: {
|
|
48
|
+
mode: { type: 'string', required: true, const: 'list' },
|
|
49
|
+
total: { type: 'integer', required: true },
|
|
50
|
+
results: { type: 'array', required: true, items: { type: 'json' } },
|
|
51
|
+
hint: { type: 'string', required: true },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: 'object',
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: {
|
|
58
|
+
mode: { type: 'string', required: true, const: 'detail' },
|
|
59
|
+
result: { type: 'json', required: true },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
65
|
+
},
|
|
66
|
+
async execute(args, exec) {
|
|
67
|
+
const query = args.query?.trim() ?? '';
|
|
68
|
+
const id = args.id?.trim() ?? '';
|
|
69
|
+
const detail = args.detail === true;
|
|
70
|
+
const kind = args.kind ?? 'all';
|
|
71
|
+
const server = args.server?.trim() || undefined;
|
|
72
|
+
const tag = args.tag?.trim() || undefined;
|
|
73
|
+
const requestedMax = args.max_results;
|
|
74
|
+
if (query.length > 0 && id.length > 0) {
|
|
75
|
+
throw new Error('meta_search: query and id are mutually exclusive; pass exactly one');
|
|
76
|
+
}
|
|
77
|
+
if (detail && id.length === 0) {
|
|
78
|
+
throw new Error('meta_search: detail:true requires an exact id; pass id to get the full schema of one capability');
|
|
79
|
+
}
|
|
80
|
+
const scope = exec.agent;
|
|
81
|
+
const context = {
|
|
82
|
+
cwd: exec.agent?.session.header.cwd,
|
|
83
|
+
signal: exec.signal,
|
|
84
|
+
scope,
|
|
85
|
+
};
|
|
86
|
+
// Blocked capabilities are not discoverable: the registry keeps them
|
|
87
|
+
// indexed for the management surface, but meta_search never surfaces
|
|
88
|
+
// them to the model.
|
|
89
|
+
const policy = ctx.get('capabilityPolicy');
|
|
90
|
+
const isBlocked = (id) => policy?.isBlockedCapability(id) ?? false;
|
|
91
|
+
if (id.length > 0) {
|
|
92
|
+
if (isBlocked(id)) {
|
|
93
|
+
throw new Error(`meta_search: capability "${id}" is blocked and cannot be inspected`);
|
|
94
|
+
}
|
|
95
|
+
const result = await ctx.meta.getDetail(id, context);
|
|
96
|
+
if (result === undefined) {
|
|
97
|
+
throw new Error(`meta_search: capability "${id}" is unknown or no longer available`);
|
|
98
|
+
}
|
|
99
|
+
return { mode: 'detail', result: result };
|
|
100
|
+
}
|
|
101
|
+
const results = ctx.meta.search({
|
|
102
|
+
query,
|
|
103
|
+
kind,
|
|
104
|
+
server,
|
|
105
|
+
tag,
|
|
106
|
+
maxResults: Math.min(requestedMax ?? maxResults, 50),
|
|
107
|
+
scope,
|
|
108
|
+
}).filter(result => !isBlocked(result.id));
|
|
109
|
+
return {
|
|
110
|
+
mode: 'list',
|
|
111
|
+
total: results.length,
|
|
112
|
+
results: results,
|
|
113
|
+
hint: 'Call meta_search with an exact id for full schema, or meta_invoke to run/load it.',
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
presentCall(args) {
|
|
117
|
+
return {
|
|
118
|
+
card: 'generic',
|
|
119
|
+
title: args.id !== undefined ? `Inspect capability ${args.id}` : `Search capabilities${args.query ? `: ${args.query}` : ''}`,
|
|
120
|
+
kind: 'read',
|
|
121
|
+
rawInput: args.id ?? args.query ?? '',
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
ctx.tools.register(tool);
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @daweifu/capability-menu — consolidated plugin package.
|
|
3
|
+
*
|
|
4
|
+
* One installable source package exposing four cordis plugin factories that the
|
|
5
|
+
* bundle mounts as separate entries via subpath exports (`/registry`, `/search`,
|
|
6
|
+
* `/invoke`, `/policy`):
|
|
7
|
+
*
|
|
8
|
+
* - `registry` (P0): capability catalog + `ctx.meta` service (no model tool).
|
|
9
|
+
* - `search` (P1): registers `meta_search`.
|
|
10
|
+
* - `invoke` (P2): registers `meta_invoke`.
|
|
11
|
+
* - `policy` (P3): Exposed/Progressive/Blocked projection policy + `ctx.capabilityPolicy`.
|
|
12
|
+
*
|
|
13
|
+
* @module @daweifu/capability-menu
|
|
14
|
+
*/
|
|
15
|
+
export * from './registry.ts';
|
|
16
|
+
export * as registry from './registry.ts';
|
|
17
|
+
export * as search from './search.ts';
|
|
18
|
+
export * as invoke from './invoke.ts';
|
|
19
|
+
export * as policy from './policy.ts';
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@daweifu/capability-menu`.
|
|
3
|
+
* @module @daweifu/capability-menu/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "capability-menu-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|