@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/invoke.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing `meta_invoke` tool: unified execution/loading of capabilities.
|
|
3
|
+
*
|
|
4
|
+
* @module @daweifu/capability-menu (invoke plugin)
|
|
5
|
+
*/
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
import { CallId } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
9
|
+
import { isModelInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill';
|
|
10
|
+
import { SKILL_ID_PREFIX, skillNameOf } from "./registry.js";
|
|
11
|
+
export const name = 'capability-menu-invoke';
|
|
12
|
+
export const inject = ['meta', 'tools', 'skills'];
|
|
13
|
+
/** Validate and default the tool configuration. */
|
|
14
|
+
export const Config = z.object({
|
|
15
|
+
forwardMode: z.union(['direct', 'resolve']).default('direct'),
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Register the `meta_invoke` tool.
|
|
19
|
+
*
|
|
20
|
+
* Dispatch is by `capability.kind`, not by id prefix (the id-prefix check is
|
|
21
|
+
* kept only as a defensive guard alongside the kind check).
|
|
22
|
+
*
|
|
23
|
+
* - Tools (`kind: 'tool'`, id `mcp__...`): forwards to the underlying server
|
|
24
|
+
* call via the official `ctx.tools.execute` pipeline, preserving
|
|
25
|
+
* `agent`/`signal`/parent lineage. `forwardMode: 'resolve'` instead returns
|
|
26
|
+
* the target schema so the model can call the tool directly.
|
|
27
|
+
* - Skills (`kind: 'skill'`, id `skill:<name>`): loads the full skill
|
|
28
|
+
* instructions and returns them as `<skill_content>` — no args, no script
|
|
29
|
+
* execution (matches the existing `skill` tool semantics).
|
|
30
|
+
*/
|
|
31
|
+
export function apply(ctx, config = {}) {
|
|
32
|
+
const forwardMode = config.forwardMode ?? 'direct';
|
|
33
|
+
if (forwardMode !== 'direct' && forwardMode !== 'resolve') {
|
|
34
|
+
throw new Error(`forwardMode must be "direct" or "resolve", received "${String(forwardMode)}"`);
|
|
35
|
+
}
|
|
36
|
+
// Session-level dedup for loaded skills. Re-loading an already-injected
|
|
37
|
+
// skill only returns a short reminder instead of re-injecting the full
|
|
38
|
+
// instructions, saving tokens (mirrors synapse's `_loaded_skills`).
|
|
39
|
+
const loadedSkills = new Set();
|
|
40
|
+
/**
|
|
41
|
+
* Load a Progressive skill's full body. Progressive skills are indexed from
|
|
42
|
+
* the independent YAML catalog and may not be registered in `ctx.skills`; read
|
|
43
|
+
* the catalog entry's `path` (SKILL.md) directly and strip frontmatter to
|
|
44
|
+
* produce a `SkillDefinition`-compatible body.
|
|
45
|
+
*/
|
|
46
|
+
const loadProgressiveSkill = async (name, capability, signal) => {
|
|
47
|
+
// If the skill is registered globally, prefer the registry's parser.
|
|
48
|
+
const registered = await ctx.skills.get(name, { signal }).catch(() => undefined);
|
|
49
|
+
if (registered !== undefined)
|
|
50
|
+
return registered;
|
|
51
|
+
const basePath = capability.origin.path;
|
|
52
|
+
if (basePath === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
const { readFile } = await import('node:fs/promises');
|
|
55
|
+
const { resolve } = await import('node:path');
|
|
56
|
+
const markdownPath = resolve(process.cwd(), basePath, 'SKILL.md');
|
|
57
|
+
let text;
|
|
58
|
+
try {
|
|
59
|
+
text = await readFile(markdownPath, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
ctx.logger.warn(`meta_invoke: progressive skill "${name}" body not readable (${markdownPath}): ${String(error)}`);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const content = stripFrontmatter(text);
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
description: capability.description,
|
|
69
|
+
...capability.whenToUse !== undefined ? { whenToUse: capability.whenToUse } : {},
|
|
70
|
+
invocation: { modelInvocable: true, userInvocable: false },
|
|
71
|
+
source: { type: 'local' },
|
|
72
|
+
provider: capability.origin.provider ?? 'progressive-catalog',
|
|
73
|
+
...capability.origin.path !== undefined ? { path: resolve(process.cwd(), capability.origin.path) } : {},
|
|
74
|
+
resourceBase: { kind: 'directory', path: resolve(process.cwd(), basePath) },
|
|
75
|
+
content,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const tool = defineTool({
|
|
79
|
+
name: 'meta_invoke',
|
|
80
|
+
description: 'Execute a capability by its exact id (from meta_search), dispatching by capability kind. For tools (kind "tool", id starts with "mcp__"), forwards to the underlying server call with args. For skills (kind "skill", id starts with "skill:"), loads the full skill instructions and returns them as <skill_content> — no args needed.',
|
|
81
|
+
parameters: {
|
|
82
|
+
id: { type: 'string', required: true, description: 'Capability id from meta_search, e.g. mcp__gongfeng__create_issue or skill:frontend-design.' },
|
|
83
|
+
args: { type: 'json', description: 'Arguments forwarded to a tool; ignored for skills.' },
|
|
84
|
+
},
|
|
85
|
+
output: {
|
|
86
|
+
schema: {
|
|
87
|
+
oneOf: [
|
|
88
|
+
{
|
|
89
|
+
type: 'object',
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
properties: {
|
|
92
|
+
ok: { type: 'boolean', required: true, const: true },
|
|
93
|
+
kind: { type: 'string', required: true, const: 'mcp' },
|
|
94
|
+
id: { type: 'string', required: true },
|
|
95
|
+
detail: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
required: true,
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
properties: {
|
|
100
|
+
forwarded: { type: 'boolean', required: true, const: true },
|
|
101
|
+
target: { type: 'string', required: true },
|
|
102
|
+
content: { type: 'array', required: true, items: { type: 'json' } },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
type: 'object',
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
properties: {
|
|
111
|
+
ok: { type: 'boolean', required: true, const: true },
|
|
112
|
+
kind: { type: 'string', required: true, const: 'skill' },
|
|
113
|
+
id: { type: 'string', required: true },
|
|
114
|
+
detail: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
required: true,
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
properties: {
|
|
119
|
+
name: { type: 'string', required: true },
|
|
120
|
+
provider: { type: 'string', required: true },
|
|
121
|
+
resourceBase: { type: 'json' },
|
|
122
|
+
content: { type: 'string', required: true },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: 'object',
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
properties: {
|
|
131
|
+
ok: { type: 'boolean', required: true, const: true },
|
|
132
|
+
kind: { type: 'string', required: true, const: 'resolve' },
|
|
133
|
+
id: { type: 'string', required: true },
|
|
134
|
+
detail: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
required: true,
|
|
137
|
+
additionalProperties: false,
|
|
138
|
+
properties: {
|
|
139
|
+
target: { type: 'string', required: true },
|
|
140
|
+
kind: { type: 'string', required: true },
|
|
141
|
+
name: { type: 'string', required: true },
|
|
142
|
+
description: { type: 'string', required: true },
|
|
143
|
+
parameters: { type: 'json', required: true },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
render: (_args, value) => {
|
|
151
|
+
const result = value;
|
|
152
|
+
if (result.ok && result.kind === 'skill') {
|
|
153
|
+
return [{ type: 'text', text: renderSkillContent(result.detail) }];
|
|
154
|
+
}
|
|
155
|
+
return [{ type: 'text', text: JSON.stringify(value) }];
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
async execute(args, exec) {
|
|
159
|
+
const id = args.id.trim();
|
|
160
|
+
// Blocked capabilities are a hard deny at the execution surface: the
|
|
161
|
+
// registry keeps them indexed so the management UI can list them, but the
|
|
162
|
+
// model can never reach a blocked capability through meta_invoke.
|
|
163
|
+
const policy = ctx.get('capabilityPolicy');
|
|
164
|
+
if (policy?.isBlockedCapability(id)) {
|
|
165
|
+
throw new Error(`meta_invoke: capability "${id}" is blocked and cannot be invoked`);
|
|
166
|
+
}
|
|
167
|
+
const capability = ctx.meta.get(id);
|
|
168
|
+
if (capability === undefined) {
|
|
169
|
+
throw new Error(`meta_invoke: capability "${id}" is unknown or no longer available`);
|
|
170
|
+
}
|
|
171
|
+
// Tool: forward to the underlying MCP server call (or resolve its schema).
|
|
172
|
+
if (capability.kind === 'tool' && id.startsWith('mcp__')) {
|
|
173
|
+
// Resolve the definition on the GLOBAL view (no agent scope) so a
|
|
174
|
+
// Progressive tool hidden from the caller's exposure is still addressable.
|
|
175
|
+
const definition = ctx.tools.get(capability.name);
|
|
176
|
+
if (definition === undefined) {
|
|
177
|
+
throw new Error(`meta_invoke: tool "${capability.name}" is not available`);
|
|
178
|
+
}
|
|
179
|
+
if (forwardMode === 'resolve') {
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
kind: 'resolve',
|
|
183
|
+
id,
|
|
184
|
+
detail: {
|
|
185
|
+
target: capability.name,
|
|
186
|
+
kind: 'tool',
|
|
187
|
+
name: definition.name,
|
|
188
|
+
description: definition.description,
|
|
189
|
+
parameters: definition.parameters,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// Nested execution through the official pipeline. The parent token marks
|
|
194
|
+
// this as a transport sub-dispatch so code-mode collapse rules treat it
|
|
195
|
+
// like a nested SDK call, and `tools/result` observers can attribute the
|
|
196
|
+
// outcome to the target capability.
|
|
197
|
+
// Do NOT pass `agent` — `ctx.tools.execute` resolves through the
|
|
198
|
+
// caller's `view.visible`, so passing an agent whose projection hid the
|
|
199
|
+
// Progressive tool would surface `UNKNOWN_TOOL`. Executing on the global view
|
|
200
|
+
// keeps Progressive tools runnable; the target tool's own guards still apply
|
|
201
|
+
// (no permission bypass — see README).
|
|
202
|
+
const result = await ctx.tools.execute({
|
|
203
|
+
callId: CallId(`${exec.callId}:meta:${id}`),
|
|
204
|
+
name: capability.name,
|
|
205
|
+
arguments: args.args,
|
|
206
|
+
signal: exec.signal,
|
|
207
|
+
parent: exec.token,
|
|
208
|
+
});
|
|
209
|
+
if (result.isError) {
|
|
210
|
+
const message = result.content.map(block => block.type === 'text' ? block.text : `[${block.type} content]`).join('\n');
|
|
211
|
+
throw new Error(message || `meta_invoke: ${capability.name} failed`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
kind: 'mcp',
|
|
216
|
+
id,
|
|
217
|
+
detail: {
|
|
218
|
+
forwarded: true,
|
|
219
|
+
target: capability.name,
|
|
220
|
+
content: result.content,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
// Skill: load the full instructions.
|
|
225
|
+
if (capability.kind === 'skill' && id.startsWith(SKILL_ID_PREFIX)) {
|
|
226
|
+
const name = skillNameOf(id);
|
|
227
|
+
// Already loaded this session → return a short reminder, no re-injection.
|
|
228
|
+
if (loadedSkills.has(name)) {
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
kind: 'skill',
|
|
232
|
+
id,
|
|
233
|
+
detail: {
|
|
234
|
+
name,
|
|
235
|
+
provider: capability.origin.provider,
|
|
236
|
+
content: `Skill "${name}" is already loaded in this conversation. Read the <skill_content> above and follow it.`,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const lookup = {
|
|
241
|
+
cwd: exec.agent?.session.header.cwd,
|
|
242
|
+
signal: exec.signal,
|
|
243
|
+
scope: exec.agent,
|
|
244
|
+
};
|
|
245
|
+
let skill = await ctx.skills.get(name, lookup).catch(() => undefined);
|
|
246
|
+
// Progressive skills are indexed from the independent YAML catalog; they may
|
|
247
|
+
// not be registered in the session skill registry. Fall back to reading
|
|
248
|
+
// the catalog entry's path (or the catalog-provided name lookup) so the
|
|
249
|
+
// full SKILL.md can still be loaded on demand.
|
|
250
|
+
if (skill === undefined) {
|
|
251
|
+
skill = await loadProgressiveSkill(name, capability, exec.signal);
|
|
252
|
+
}
|
|
253
|
+
if (skill === undefined) {
|
|
254
|
+
throw new Error(`meta_invoke: skill "${name}" is unknown or no longer available`);
|
|
255
|
+
}
|
|
256
|
+
if (!isModelInvocable(skill)) {
|
|
257
|
+
throw new Error(`meta_invoke: skill "${name}" is not available for model invocation`);
|
|
258
|
+
}
|
|
259
|
+
loadedSkills.add(name);
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
kind: 'skill',
|
|
263
|
+
id,
|
|
264
|
+
detail: {
|
|
265
|
+
name: skill.name,
|
|
266
|
+
provider: skill.provider,
|
|
267
|
+
...skill.resourceBase !== undefined ? { resourceBase: { ...skill.resourceBase } } : {},
|
|
268
|
+
content: skill.content,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
throw new Error(`meta_invoke: capability id "${id}" has an unrecognized kind`);
|
|
273
|
+
},
|
|
274
|
+
presentCall(args) {
|
|
275
|
+
return {
|
|
276
|
+
card: 'generic',
|
|
277
|
+
title: `Execute capability ${args.id}`,
|
|
278
|
+
kind: args.id.startsWith('skill:') ? 'read' : 'other',
|
|
279
|
+
rawInput: args.id,
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
ctx.tools.register(tool);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Strip leading YAML frontmatter (`---\n...\n---`) from a raw SKILL.md body,
|
|
287
|
+
* returning the remaining markdown. Falls back to the raw text when no
|
|
288
|
+
* frontmatter delimiter is present.
|
|
289
|
+
*/
|
|
290
|
+
export function stripFrontmatter(markdown) {
|
|
291
|
+
const text = markdown.startsWith('\uFEFF') ? markdown.slice(1) : markdown;
|
|
292
|
+
if (!text.startsWith('---'))
|
|
293
|
+
return text;
|
|
294
|
+
const end = text.indexOf('\n---', 3);
|
|
295
|
+
if (end === -1)
|
|
296
|
+
return text;
|
|
297
|
+
return text.slice(end + 4).replace(/^\n+/, '');
|
|
298
|
+
}
|
|
299
|
+
//# sourceMappingURL=invoke.js.map
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exposed / Progressive / Blocked capability projection policy for the DeepSeek
|
|
3
|
+
* Harness.
|
|
4
|
+
*
|
|
5
|
+
* @module @daweifu/capability-menu (policy plugin)
|
|
6
|
+
*/
|
|
7
|
+
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import { serverNameOf } from "./registry.js";
|
|
9
|
+
/** Validate and default the policy configuration. */
|
|
10
|
+
export const Config = z.object({
|
|
11
|
+
tools: z.object({
|
|
12
|
+
exposed: z.array(z.string()).default([]),
|
|
13
|
+
progressive: z.array(z.string()).default([]),
|
|
14
|
+
blocked: z.array(z.string()).default([]),
|
|
15
|
+
}),
|
|
16
|
+
skills: z.object({
|
|
17
|
+
exposed: z.array(z.string()).default([]),
|
|
18
|
+
progressive: z.array(z.string()).default([]),
|
|
19
|
+
blocked: z.array(z.string()).default([]),
|
|
20
|
+
}),
|
|
21
|
+
metaTools: z.array(z.string()).default(['meta_search', 'meta_invoke']),
|
|
22
|
+
// schemastery object properties are optional-by-default; no `.optional()` needed.
|
|
23
|
+
progressiveSkillCatalog: z.string(),
|
|
24
|
+
});
|
|
25
|
+
export const DEFAULT_META_TOOLS = ['meta_search', 'meta_invoke'];
|
|
26
|
+
/**
|
|
27
|
+
* Convert a user-facing rule string into a normalized {@link PolicyRule}.
|
|
28
|
+
* - `server:<name>` → server-targeted rule (`target: 'server'`).
|
|
29
|
+
* - `server:<name>:*` → server-targeted glob over that server's tools.
|
|
30
|
+
* - contains `*` → glob (target `id`).
|
|
31
|
+
* - otherwise → literal exact name.
|
|
32
|
+
*/
|
|
33
|
+
export function parseRule(rule) {
|
|
34
|
+
const trimmed = rule.trim();
|
|
35
|
+
const serverPrefix = 'server:';
|
|
36
|
+
if (trimmed.startsWith(serverPrefix)) {
|
|
37
|
+
const rest = trimmed.slice(serverPrefix.length);
|
|
38
|
+
// `server:<name>:*` → glob over the whole server.
|
|
39
|
+
if (rest.endsWith(':*')) {
|
|
40
|
+
return { wildcard: true, pattern: rest.slice(0, -2), target: 'server', kind: 'tool' };
|
|
41
|
+
}
|
|
42
|
+
return { wildcard: false, pattern: rest, target: 'server', kind: 'tool' };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
wildcard: trimmed.includes('*'),
|
|
46
|
+
pattern: trimmed,
|
|
47
|
+
target: 'id',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Compile a `*`-glob into a RegExp (escaped, `*` → `.*`). */
|
|
51
|
+
export function compileGlob(pattern) {
|
|
52
|
+
const escaped = pattern
|
|
53
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
54
|
+
.replace(/\\\*/g, '.*');
|
|
55
|
+
return new RegExp(`^${escaped}$`);
|
|
56
|
+
}
|
|
57
|
+
/** Compile a {@link CapabilitySetConfig} into fast-matchable rules. */
|
|
58
|
+
export function compileSet(set = {}) {
|
|
59
|
+
return {
|
|
60
|
+
exposed: (set.exposed ?? []).map(parseRule),
|
|
61
|
+
progressive: (set.progressive ?? []).map(parseRule),
|
|
62
|
+
blocked: (set.blocked ?? []).map(parseRule),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function ruleMatches(rule, target) {
|
|
66
|
+
if (rule.kind !== undefined && rule.kind !== target.ruleKind)
|
|
67
|
+
return false;
|
|
68
|
+
if (rule.target === 'id') {
|
|
69
|
+
if (!rule.wildcard) {
|
|
70
|
+
// An exact rule matches the full id OR the bare name (e.g. `debugging`
|
|
71
|
+
// matches `skill:debugging`, `bash` matches the harness-native tool
|
|
72
|
+
// `bash` whose public name is its bare name).
|
|
73
|
+
return rule.pattern === target.id || rule.pattern === target.name;
|
|
74
|
+
}
|
|
75
|
+
return compileGlob(rule.pattern).test(target.id);
|
|
76
|
+
}
|
|
77
|
+
// server-targeted
|
|
78
|
+
if (target.server === undefined)
|
|
79
|
+
return false;
|
|
80
|
+
if (!rule.wildcard)
|
|
81
|
+
return rule.pattern === target.server;
|
|
82
|
+
return compileGlob(rule.pattern).test(target.server);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Classify a capability against compiled rules. Priority (hit stops the walk):
|
|
86
|
+
* blocked-exact > blocked-wildcard > exposed-exact > exposed-wildcard >
|
|
87
|
+
* progressive-exact > progressive-wildcard > default (exposed). `blocked`
|
|
88
|
+
* is a control decision, so it beats an explicit `exposed` rule.
|
|
89
|
+
*/
|
|
90
|
+
export function classify(compiled, target, metaTools = DEFAULT_META_TOOLS) {
|
|
91
|
+
const meta = metaTools instanceof Set ? metaTools : new Set(metaTools);
|
|
92
|
+
const id = target.id;
|
|
93
|
+
// metaTools can never be Progressive/Blocked for tools.
|
|
94
|
+
if (target.kind === 'tool' && meta.has(id))
|
|
95
|
+
return 'exposed';
|
|
96
|
+
for (const rule of compiled.blocked) {
|
|
97
|
+
if (!rule.wildcard && ruleMatches(rule, target))
|
|
98
|
+
return 'blocked';
|
|
99
|
+
}
|
|
100
|
+
for (const rule of compiled.blocked) {
|
|
101
|
+
if (rule.wildcard && ruleMatches(rule, target))
|
|
102
|
+
return 'blocked';
|
|
103
|
+
}
|
|
104
|
+
for (const rule of compiled.exposed) {
|
|
105
|
+
if (!rule.wildcard && ruleMatches(rule, target))
|
|
106
|
+
return 'exposed';
|
|
107
|
+
}
|
|
108
|
+
for (const rule of compiled.exposed) {
|
|
109
|
+
if (rule.wildcard && ruleMatches(rule, target))
|
|
110
|
+
return 'exposed';
|
|
111
|
+
}
|
|
112
|
+
for (const rule of compiled.progressive) {
|
|
113
|
+
if (!rule.wildcard && ruleMatches(rule, target))
|
|
114
|
+
return 'progressive';
|
|
115
|
+
}
|
|
116
|
+
for (const rule of compiled.progressive) {
|
|
117
|
+
if (rule.wildcard && ruleMatches(rule, target))
|
|
118
|
+
return 'progressive';
|
|
119
|
+
}
|
|
120
|
+
return 'exposed';
|
|
121
|
+
}
|
|
122
|
+
/** True when any rule in the list matches the target (used for fail-loud validation). */
|
|
123
|
+
function anyRuleMatches(rules, target) {
|
|
124
|
+
return rules.some(rule => ruleMatches(rule, target));
|
|
125
|
+
}
|
|
126
|
+
const CLASS_LABELS = {
|
|
127
|
+
exposed: 'Exposed · 常驻(直接调用)',
|
|
128
|
+
progressive: 'Progressive · 按需(目录渐进加载)',
|
|
129
|
+
blocked: 'Blocked · 禁用',
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Build the projection listener for one assembly: keep only Exposed tools plus
|
|
133
|
+
* the mandatory meta tools.
|
|
134
|
+
*/
|
|
135
|
+
export function projectAssemblyTools(assembly, service) {
|
|
136
|
+
const kept = assembly.tools.filter(tool => service.isExposedTool(tool.name));
|
|
137
|
+
if (kept.length === assembly.tools.length)
|
|
138
|
+
return assembly;
|
|
139
|
+
return { ...assembly, tools: kept };
|
|
140
|
+
}
|
|
141
|
+
/** Build the policy plugin. */
|
|
142
|
+
export const name = 'capability-menu-policy';
|
|
143
|
+
// The policy needs `meta` (the registry sibling) for classifyAll(); `tools` /
|
|
144
|
+
// `skills` are injected purely for startup ordering — projection itself runs on
|
|
145
|
+
// the `system-prompt/assemble` chain, not through the registries. The bundle
|
|
146
|
+
// mounts registry before policy, so `meta` is always available in practice.
|
|
147
|
+
export const inject = ['meta', 'tools', 'skills'];
|
|
148
|
+
export function apply(ctx, config = {}) {
|
|
149
|
+
// Mutable runtime state so the management surface (能力菜单) can live-update
|
|
150
|
+
// the policy without a reload.
|
|
151
|
+
let current = { ...config };
|
|
152
|
+
let metaTools = [...(config.metaTools ?? DEFAULT_META_TOOLS)];
|
|
153
|
+
let metaToolSet = new Set(metaTools);
|
|
154
|
+
let toolCompiled;
|
|
155
|
+
let skillCompiled;
|
|
156
|
+
const recompile = () => {
|
|
157
|
+
const toolRules = compileSet(current.tools);
|
|
158
|
+
const skillRules = compileSet(current.skills);
|
|
159
|
+
// Normalize tool/skill rule kinds for matching.
|
|
160
|
+
toolCompiled = {
|
|
161
|
+
exposed: toolRules.exposed.map(rule => ({ ...rule, kind: 'tool' })),
|
|
162
|
+
progressive: toolRules.progressive.map(rule => ({ ...rule, kind: 'tool' })),
|
|
163
|
+
blocked: toolRules.blocked.map(rule => ({ ...rule, kind: 'tool' })),
|
|
164
|
+
};
|
|
165
|
+
skillCompiled = {
|
|
166
|
+
exposed: skillRules.exposed.map(rule => ({ ...rule, kind: 'skill' })),
|
|
167
|
+
progressive: skillRules.progressive.map(rule => ({ ...rule, kind: 'skill' })),
|
|
168
|
+
blocked: skillRules.blocked.map(rule => ({ ...rule, kind: 'skill' })),
|
|
169
|
+
};
|
|
170
|
+
// Meta tools are the control-plane escape hatch: blocking one is a
|
|
171
|
+
// misconfiguration that must fail loud, never silently disable the surface.
|
|
172
|
+
for (const name of metaTools) {
|
|
173
|
+
const target = { id: name, name, server: serverNameOf(name), kind: 'tool', ruleKind: 'tool' };
|
|
174
|
+
if (anyRuleMatches(toolCompiled.blocked, target)) {
|
|
175
|
+
throw new Error(`meta tool "${name}" cannot be blocked; remove it from tools.blocked`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
recompile();
|
|
180
|
+
const service = {
|
|
181
|
+
classifyTool(name) {
|
|
182
|
+
const server = serverNameOf(name);
|
|
183
|
+
return classify(toolCompiled, { id: name, name, server, kind: 'tool', ruleKind: 'tool' }, metaToolSet);
|
|
184
|
+
},
|
|
185
|
+
classifySkill(name) {
|
|
186
|
+
return classify(skillCompiled, { id: `skill:${name}`, name, kind: 'skill', ruleKind: 'skill' }, new Set());
|
|
187
|
+
},
|
|
188
|
+
classifyCapability(id) {
|
|
189
|
+
if (id.startsWith('skill:'))
|
|
190
|
+
return service.classifySkill(id.slice('skill:'.length));
|
|
191
|
+
return service.classifyTool(id);
|
|
192
|
+
},
|
|
193
|
+
isExposedTool(name) {
|
|
194
|
+
return service.classifyTool(name) === 'exposed';
|
|
195
|
+
},
|
|
196
|
+
isExposedSkill(name) {
|
|
197
|
+
return service.classifySkill(name) === 'exposed';
|
|
198
|
+
},
|
|
199
|
+
isProgressiveTool(name) {
|
|
200
|
+
return service.classifyTool(name) === 'progressive';
|
|
201
|
+
},
|
|
202
|
+
isProgressiveSkill(name) {
|
|
203
|
+
return service.classifySkill(name) === 'progressive';
|
|
204
|
+
},
|
|
205
|
+
isBlockedTool(name) {
|
|
206
|
+
return service.classifyTool(name) === 'blocked';
|
|
207
|
+
},
|
|
208
|
+
isBlockedSkill(name) {
|
|
209
|
+
return service.classifySkill(name) === 'blocked';
|
|
210
|
+
},
|
|
211
|
+
isBlockedCapability(id) {
|
|
212
|
+
return service.classifyCapability(id) === 'blocked';
|
|
213
|
+
},
|
|
214
|
+
metaTools() {
|
|
215
|
+
return metaTools;
|
|
216
|
+
},
|
|
217
|
+
toolRules() {
|
|
218
|
+
return toolCompiled;
|
|
219
|
+
},
|
|
220
|
+
skillRules() {
|
|
221
|
+
return skillCompiled;
|
|
222
|
+
},
|
|
223
|
+
getConfig() {
|
|
224
|
+
return { ...current };
|
|
225
|
+
},
|
|
226
|
+
updateConfig(partial) {
|
|
227
|
+
current = { ...current, ...partial };
|
|
228
|
+
if (partial.metaTools !== undefined) {
|
|
229
|
+
metaTools = [...(partial.metaTools ?? DEFAULT_META_TOOLS)];
|
|
230
|
+
metaToolSet = new Set(metaTools);
|
|
231
|
+
}
|
|
232
|
+
recompile();
|
|
233
|
+
},
|
|
234
|
+
classifyAll() {
|
|
235
|
+
// The registry default maxResults (20) would truncate the management
|
|
236
|
+
// surface: enumerate every indexed capability, not just the top-20.
|
|
237
|
+
return ctx.meta.search({ maxResults: Number.MAX_SAFE_INTEGER }).map(summary => {
|
|
238
|
+
const cls = service.classifyCapability(summary.id);
|
|
239
|
+
const mandatory = summary.kind === 'tool' && metaToolSet.has(summary.id);
|
|
240
|
+
return {
|
|
241
|
+
id: summary.id,
|
|
242
|
+
kind: summary.kind,
|
|
243
|
+
name: summary.name,
|
|
244
|
+
...summary.server !== undefined ? { server: summary.server } : {},
|
|
245
|
+
class: cls,
|
|
246
|
+
classLabel: CLASS_LABELS[cls],
|
|
247
|
+
mandatory,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
// Project the model-visible tool list. This runs on the projection chain only
|
|
253
|
+
// (system-prompt/assemble); the execution chain (ctx.tools.execute) is
|
|
254
|
+
// untouched, so Progressive tools remain executable via meta_invoke. Skills
|
|
255
|
+
// keep the dsh-native `<available_skills>` catalog, which is the Exposed
|
|
256
|
+
// surface; catalog-level skill filtering needs an upstream `dsh-tool-skill`
|
|
257
|
+
// filter hook (out of bundle scope).
|
|
258
|
+
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
|
259
|
+
const resolved = await next();
|
|
260
|
+
return projectAssemblyTools(resolved, service);
|
|
261
|
+
});
|
|
262
|
+
ctx.provide('capabilityPolicy', service);
|
|
263
|
+
}
|
|
264
|
+
//# sourceMappingURL=policy.js.map
|