@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/policy.js
CHANGED
|
@@ -1,28 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Resident / On-demand / Disabled capability projection policy for the DeepSeek
|
|
3
3
|
* Harness.
|
|
4
4
|
*
|
|
5
5
|
* @module @daweifu/capability-menu (policy plugin)
|
|
6
6
|
*/
|
|
7
7
|
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import { escapeText } from '@deepseek-ai/dsh-skill';
|
|
8
9
|
import { serverNameOf } from "./registry.js";
|
|
10
|
+
import { addEntryOverride, findEntry, mutatePatch, setEntryConfig } from "./patch-file.js";
|
|
11
|
+
import { LocationRegistry, defaultLocationConfig, } from "./locations.js";
|
|
9
12
|
/** Validate and default the policy configuration. */
|
|
10
13
|
export const Config = z.object({
|
|
11
14
|
tools: z.object({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
resident: z.array(z.string()).default([]),
|
|
16
|
+
'on-demand': z.array(z.string()).default([]),
|
|
17
|
+
disabled: z.array(z.string()).default([]),
|
|
18
|
+
// Deprecated pre-rename aliases: kept in the schema so a config cast keeps
|
|
19
|
+
// them available for `normalizeSetConfig` instead of stripping them.
|
|
20
|
+
blocked: z.array(z.string()),
|
|
21
|
+
exposed: z.array(z.string()),
|
|
22
|
+
progressive: z.array(z.string()),
|
|
15
23
|
}),
|
|
16
24
|
skills: z.object({
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
resident: z.array(z.string()).default([]),
|
|
26
|
+
'on-demand': z.array(z.string()).default([]),
|
|
27
|
+
disabled: z.array(z.string()).default([]),
|
|
28
|
+
blocked: z.array(z.string()),
|
|
29
|
+
exposed: z.array(z.string()),
|
|
30
|
+
progressive: z.array(z.string()),
|
|
20
31
|
}),
|
|
21
32
|
metaTools: z.array(z.string()).default(['meta_search', 'meta_invoke']),
|
|
22
33
|
// schemastery object properties are optional-by-default; no `.optional()` needed.
|
|
23
|
-
|
|
34
|
+
patchFile: z.string(),
|
|
35
|
+
skillsDir: z.string(),
|
|
36
|
+
/**
|
|
37
|
+
* How long a tier change waits before it is written to the patch file. Long
|
|
38
|
+
* enough that a burst of clicks produces one write, because every write makes
|
|
39
|
+
* dsh hot-reload this plugin and re-run the catalog enumeration.
|
|
40
|
+
*/
|
|
41
|
+
persistDebounceMs: z.number().default(1500),
|
|
24
42
|
});
|
|
25
43
|
export const DEFAULT_META_TOOLS = ['meta_search', 'meta_invoke'];
|
|
44
|
+
/**
|
|
45
|
+
* Map a legacy rule set (old keys `exposed`/`progressive`/`blocked`) onto the
|
|
46
|
+
* current key names (`resident`/`on-demand`/`disabled`), so already-deployed
|
|
47
|
+
* `cordis.patch.yml` profiles keep working without an on-disk rewrite. New
|
|
48
|
+
* keys win when they carry rules; a legacy key fills in when the matching new
|
|
49
|
+
* list is empty.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeSetConfig(set) {
|
|
52
|
+
if (set === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
const next = {};
|
|
55
|
+
const resident = set.resident !== undefined && set.resident.length > 0 ? set.resident : undefined;
|
|
56
|
+
if (resident !== undefined)
|
|
57
|
+
next.resident = resident;
|
|
58
|
+
else if (set.exposed !== undefined && set.exposed.length > 0)
|
|
59
|
+
next.resident = set.exposed;
|
|
60
|
+
const onDemand = set['on-demand'] !== undefined && set['on-demand'].length > 0 ? set['on-demand'] : undefined;
|
|
61
|
+
if (onDemand !== undefined)
|
|
62
|
+
next['on-demand'] = onDemand;
|
|
63
|
+
else if (set.progressive !== undefined && set.progressive.length > 0)
|
|
64
|
+
next['on-demand'] = set.progressive;
|
|
65
|
+
const disabled = set.disabled !== undefined && set.disabled.length > 0 ? set.disabled : undefined;
|
|
66
|
+
if (disabled !== undefined)
|
|
67
|
+
next.disabled = disabled;
|
|
68
|
+
else if (set.blocked !== undefined && set.blocked.length > 0)
|
|
69
|
+
next.disabled = set.blocked;
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
26
72
|
/**
|
|
27
73
|
* Convert a user-facing rule string into a normalized {@link PolicyRule}.
|
|
28
74
|
* - `server:<name>` → server-targeted rule (`target: 'server'`).
|
|
@@ -57,9 +103,9 @@ export function compileGlob(pattern) {
|
|
|
57
103
|
/** Compile a {@link CapabilitySetConfig} into fast-matchable rules. */
|
|
58
104
|
export function compileSet(set = {}) {
|
|
59
105
|
return {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
106
|
+
resident: (set.resident ?? []).map(parseRule),
|
|
107
|
+
onDemand: (set['on-demand'] ?? []).map(parseRule),
|
|
108
|
+
disabled: (set.disabled ?? []).map(parseRule),
|
|
63
109
|
};
|
|
64
110
|
}
|
|
65
111
|
function ruleMatches(rule, target) {
|
|
@@ -68,8 +114,8 @@ function ruleMatches(rule, target) {
|
|
|
68
114
|
if (rule.target === 'id') {
|
|
69
115
|
if (!rule.wildcard) {
|
|
70
116
|
// An exact rule matches the full id OR the bare name (e.g. `debugging`
|
|
71
|
-
// matches `
|
|
72
|
-
// `bash` whose public name is its bare name).
|
|
117
|
+
// matches a skill named `debugging`, `bash` matches the harness-native
|
|
118
|
+
// tool `bash` whose public name is its bare name).
|
|
73
119
|
return rule.pattern === target.id || rule.pattern === target.name;
|
|
74
120
|
}
|
|
75
121
|
return compileGlob(rule.pattern).test(target.id);
|
|
@@ -83,57 +129,61 @@ function ruleMatches(rule, target) {
|
|
|
83
129
|
}
|
|
84
130
|
/**
|
|
85
131
|
* Classify a capability against compiled rules. Priority (hit stops the walk):
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
132
|
+
* disabled-exact > disabled-wildcard > resident-exact > on-demand-exact >
|
|
133
|
+
* resident-wildcard > on-demand-wildcard > default (resident). `disabled` is a
|
|
134
|
+
* control decision, so it beats an explicit `resident` rule. Within
|
|
135
|
+
* resident/on-demand an exact name beats a wildcard, so the management UI can
|
|
136
|
+
* pin a single capability to a class even when a broader wildcard rule says
|
|
137
|
+
* otherwise (e.g. `tools.resident: ['mcp__gongfeng__*']` must not silently win
|
|
138
|
+
* over an explicit per-tool `on-demand` rule).
|
|
89
139
|
*/
|
|
90
140
|
export function classify(compiled, target, metaTools = DEFAULT_META_TOOLS) {
|
|
91
141
|
const meta = metaTools instanceof Set ? metaTools : new Set(metaTools);
|
|
92
142
|
const id = target.id;
|
|
93
|
-
// metaTools can never be
|
|
143
|
+
// metaTools can never be On-demand/Disabled for tools.
|
|
94
144
|
if (target.kind === 'tool' && meta.has(id))
|
|
95
|
-
return '
|
|
96
|
-
for (const rule of compiled.
|
|
145
|
+
return 'resident';
|
|
146
|
+
for (const rule of compiled.disabled) {
|
|
97
147
|
if (!rule.wildcard && ruleMatches(rule, target))
|
|
98
|
-
return '
|
|
148
|
+
return 'disabled';
|
|
99
149
|
}
|
|
100
|
-
for (const rule of compiled.
|
|
150
|
+
for (const rule of compiled.disabled) {
|
|
101
151
|
if (rule.wildcard && ruleMatches(rule, target))
|
|
102
|
-
return '
|
|
152
|
+
return 'disabled';
|
|
103
153
|
}
|
|
104
|
-
for (const rule of compiled.
|
|
154
|
+
for (const rule of compiled.resident) {
|
|
105
155
|
if (!rule.wildcard && ruleMatches(rule, target))
|
|
106
|
-
return '
|
|
107
|
-
}
|
|
108
|
-
for (const rule of compiled.exposed) {
|
|
109
|
-
if (rule.wildcard && ruleMatches(rule, target))
|
|
110
|
-
return 'exposed';
|
|
156
|
+
return 'resident';
|
|
111
157
|
}
|
|
112
|
-
for (const rule of compiled.
|
|
158
|
+
for (const rule of compiled.onDemand) {
|
|
113
159
|
if (!rule.wildcard && ruleMatches(rule, target))
|
|
114
|
-
return '
|
|
160
|
+
return 'on-demand';
|
|
115
161
|
}
|
|
116
|
-
for (const rule of compiled.
|
|
162
|
+
for (const rule of compiled.resident) {
|
|
117
163
|
if (rule.wildcard && ruleMatches(rule, target))
|
|
118
|
-
return '
|
|
164
|
+
return 'resident';
|
|
119
165
|
}
|
|
120
|
-
|
|
166
|
+
for (const rule of compiled.onDemand) {
|
|
167
|
+
if (rule.wildcard && ruleMatches(rule, target))
|
|
168
|
+
return 'on-demand';
|
|
169
|
+
}
|
|
170
|
+
return 'resident';
|
|
121
171
|
}
|
|
122
172
|
/** True when any rule in the list matches the target (used for fail-loud validation). */
|
|
123
173
|
function anyRuleMatches(rules, target) {
|
|
124
174
|
return rules.some(rule => ruleMatches(rule, target));
|
|
125
175
|
}
|
|
126
176
|
const CLASS_LABELS = {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
177
|
+
resident: 'Resident · 常驻(直接调用)',
|
|
178
|
+
'on-demand': 'On-demand · 按需(目录渐进加载)',
|
|
179
|
+
disabled: 'Disabled · 禁用',
|
|
130
180
|
};
|
|
131
181
|
/**
|
|
132
|
-
* Build the projection listener for one assembly: keep only
|
|
182
|
+
* Build the projection listener for one assembly: keep only Resident tools plus
|
|
133
183
|
* the mandatory meta tools.
|
|
134
184
|
*/
|
|
135
185
|
export function projectAssemblyTools(assembly, service) {
|
|
136
|
-
const kept = assembly.tools.filter(tool => service.
|
|
186
|
+
const kept = assembly.tools.filter(tool => service.isResidentTool(tool.name));
|
|
137
187
|
if (kept.length === assembly.tools.length)
|
|
138
188
|
return assembly;
|
|
139
189
|
return { ...assembly, tools: kept };
|
|
@@ -145,71 +195,187 @@ export const name = 'capability-menu-policy';
|
|
|
145
195
|
// the `system-prompt/assemble` chain, not through the registries. The bundle
|
|
146
196
|
// mounts registry before policy, so `capability` is always available in practice.
|
|
147
197
|
export const inject = ['capability', 'tools', 'skills'];
|
|
148
|
-
export function apply(ctx, config = {}) {
|
|
198
|
+
export async function apply(ctx, config = {}) {
|
|
199
|
+
// Accept legacy rule keys (`exposed`/`progressive`) with the same meaning as
|
|
200
|
+
// the current `resident`/`on-demand`, so pre-rename profiles keep working
|
|
201
|
+
// without a disk rewrite. The management UI always writes the new keys.
|
|
202
|
+
const normalized = { ...config };
|
|
203
|
+
const legacyTools = config.tools?.exposed !== undefined || config.tools?.progressive !== undefined || config.tools?.blocked !== undefined;
|
|
204
|
+
const legacySkills = config.skills?.exposed !== undefined || config.skills?.progressive !== undefined || config.skills?.blocked !== undefined;
|
|
205
|
+
normalized.tools = normalizeSetConfig(config.tools);
|
|
206
|
+
normalized.skills = normalizeSetConfig(config.skills);
|
|
207
|
+
if (legacyTools || legacySkills) {
|
|
208
|
+
ctx.logger.warn('capability-policy: legacy rule keys `exposed`/`progressive`/`blocked` were auto-mapped to `resident`/`on-demand`/`disabled`; edit the profile patch to persist the new keys');
|
|
209
|
+
}
|
|
149
210
|
// Mutable runtime state so the management surface (能力菜单) can live-update
|
|
150
211
|
// the policy without a reload.
|
|
151
|
-
let current =
|
|
152
|
-
let metaTools = [...(
|
|
212
|
+
let current = normalized;
|
|
213
|
+
let metaTools = [...(normalized.metaTools ?? DEFAULT_META_TOOLS)];
|
|
153
214
|
let metaToolSet = new Set(metaTools);
|
|
154
215
|
let toolCompiled;
|
|
155
216
|
let skillCompiled;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
217
|
+
// Location registry: the places capabilities come from. Editing the patch
|
|
218
|
+
// file makes dsh hot-reload and mount/unmount the server itself, so this
|
|
219
|
+
// plugin never imports or drives `dsh-mcp-client` directly.
|
|
220
|
+
const defaults = defaultLocationConfig();
|
|
221
|
+
const patchFile = (config.patchFile ?? defaults.patchFile).trim();
|
|
222
|
+
const locations = new LocationRegistry(ctx, {
|
|
223
|
+
patchFile,
|
|
224
|
+
skillsDir: (config.skillsDir ?? defaults.skillsDir).trim(),
|
|
225
|
+
});
|
|
226
|
+
/**
|
|
227
|
+
* Tier rules are persisted into the profile patch, in the entry the bundle
|
|
228
|
+
* already declares for this plugin. Until now that config field was resolved
|
|
229
|
+
* and never read: the surface edited memory only, so every tier change was
|
|
230
|
+
* lost on restart.
|
|
231
|
+
*
|
|
232
|
+
* Persisting is deferred rather than done per click. A click recompiles the
|
|
233
|
+
* rule sets in memory, which is what makes it feel instant; a patch write, by
|
|
234
|
+
* contrast, makes dsh hot-reload this plugin — and its mount awaits a full
|
|
235
|
+
* catalog enumeration. Debouncing puts that cost after the operator stops
|
|
236
|
+
* clicking instead of on every click.
|
|
237
|
+
*/
|
|
238
|
+
const POLICY_ENTRY_ID = 'capability-menu-policy';
|
|
239
|
+
const POLICY_ENTRY_NAME = '@daweifu/capability-menu/policy';
|
|
240
|
+
const persistDebounceMs = config.persistDebounceMs ?? 1500;
|
|
241
|
+
let persistTimer;
|
|
242
|
+
/** Order-insensitive signature of the two rule sets, for the no-op check. */
|
|
243
|
+
const tierSignature = (rules) => {
|
|
244
|
+
if (rules === null || typeof rules !== 'object')
|
|
245
|
+
return '';
|
|
246
|
+
const record = rules;
|
|
247
|
+
return ['resident', 'on-demand', 'disabled']
|
|
248
|
+
.map(tier => Array.isArray(record[tier]) ? [...record[tier]].sort().join('\u0000') : '')
|
|
249
|
+
.join('\u0001');
|
|
250
|
+
};
|
|
251
|
+
/** Write the current rule sets into the patch, when they differ from the file. */
|
|
252
|
+
const persistTiers = async () => {
|
|
253
|
+
if (patchFile.length === 0)
|
|
254
|
+
return;
|
|
255
|
+
try {
|
|
256
|
+
const written = await mutatePatch(patchFile, doc => {
|
|
257
|
+
const existing = findEntry(doc, POLICY_ENTRY_ID);
|
|
258
|
+
// Merge instead of replace: the row can carry keys this surface never
|
|
259
|
+
// edits (`metaTools`, a legacy rule spelling), and patch semantics
|
|
260
|
+
// replace the whole `config:` value.
|
|
261
|
+
const raw = existing?.get('config');
|
|
262
|
+
const prior = raw?.toJSON?.();
|
|
263
|
+
const next = typeof prior === 'object' && prior !== null
|
|
264
|
+
? { ...prior }
|
|
265
|
+
: {};
|
|
266
|
+
next['tools'] = current.tools ?? {};
|
|
267
|
+
next['skills'] = current.skills ?? {};
|
|
268
|
+
if (existing === undefined) {
|
|
269
|
+
// An override row, never an insert: this file is normally not where the
|
|
270
|
+
// policy row comes from — the package's own bundle patch inserts it,
|
|
271
|
+
// and a home/profile layer is applied after that one. Inserting a
|
|
272
|
+
// second row with the same id would compose into a tree the loader
|
|
273
|
+
// rejects (`duplicate loader entry id: capability-menu-policy`), so the
|
|
274
|
+
// config has to be landed on the row that already exists.
|
|
275
|
+
addEntryOverride(doc, { id: POLICY_ENTRY_ID, name: POLICY_ENTRY_NAME, config: next });
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
// Rewriting an unchanged config would still make dsh reload the plugin.
|
|
279
|
+
if (tierSignature(prior) === tierSignature(next))
|
|
280
|
+
return false;
|
|
281
|
+
return setEntryConfig(doc, POLICY_ENTRY_ID, next);
|
|
282
|
+
});
|
|
283
|
+
if (written)
|
|
284
|
+
ctx.logger.info(`capability-policy: persisted tier rules to ${patchFile}`);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
ctx.logger.warn(`capability-policy: cannot persist tier rules to ${patchFile}: ${String(error)}`);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const schedulePersistTiers = () => {
|
|
291
|
+
if (patchFile.length === 0)
|
|
292
|
+
return;
|
|
293
|
+
if (persistTimer !== undefined)
|
|
294
|
+
clearTimeout(persistTimer);
|
|
295
|
+
persistTimer = setTimeout(() => {
|
|
296
|
+
persistTimer = undefined;
|
|
297
|
+
void persistTiers();
|
|
298
|
+
}, persistDebounceMs);
|
|
299
|
+
};
|
|
300
|
+
// A pending write must not be dropped when the plugin is torn down (a hot
|
|
301
|
+
// reload tears it down): flush it, so the file matches the state that was
|
|
302
|
+
// live. The flush finds the config unchanged and writes nothing, so it cannot
|
|
303
|
+
// start a reload loop.
|
|
304
|
+
ctx.effect(() => () => {
|
|
305
|
+
if (persistTimer === undefined)
|
|
306
|
+
return;
|
|
307
|
+
clearTimeout(persistTimer);
|
|
308
|
+
persistTimer = undefined;
|
|
309
|
+
void persistTiers();
|
|
310
|
+
}, 'capability-menu-policy: flush tier persistence');
|
|
311
|
+
/**
|
|
312
|
+
* Compile a candidate config into rule sets without touching live state.
|
|
313
|
+
* Meta tools are the control-plane escape hatch: blocking one is a
|
|
314
|
+
* misconfiguration that must fail loud, never silently disable the surface.
|
|
315
|
+
* Throws before anything is committed when validation fails.
|
|
316
|
+
*/
|
|
317
|
+
const compileConfig = (candidate, meta) => {
|
|
318
|
+
const toolRules = compileSet(candidate.tools);
|
|
319
|
+
const skillRules = compileSet(candidate.skills);
|
|
159
320
|
// Normalize tool/skill rule kinds for matching.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
321
|
+
const tools = {
|
|
322
|
+
resident: toolRules.resident.map(rule => ({ ...rule, kind: 'tool' })),
|
|
323
|
+
onDemand: toolRules.onDemand.map(rule => ({ ...rule, kind: 'tool' })),
|
|
324
|
+
disabled: toolRules.disabled.map(rule => ({ ...rule, kind: 'tool' })),
|
|
164
325
|
};
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
326
|
+
const skills = {
|
|
327
|
+
resident: skillRules.resident.map(rule => ({ ...rule, kind: 'skill' })),
|
|
328
|
+
onDemand: skillRules.onDemand.map(rule => ({ ...rule, kind: 'skill' })),
|
|
329
|
+
disabled: skillRules.disabled.map(rule => ({ ...rule, kind: 'skill' })),
|
|
169
330
|
};
|
|
170
|
-
|
|
171
|
-
// misconfiguration that must fail loud, never silently disable the surface.
|
|
172
|
-
for (const name of metaTools) {
|
|
331
|
+
for (const name of meta) {
|
|
173
332
|
const target = { id: name, name, server: serverNameOf(name), kind: 'tool', ruleKind: 'tool' };
|
|
174
|
-
if (anyRuleMatches(
|
|
175
|
-
throw new Error(`meta tool "${name}" cannot be
|
|
333
|
+
if (anyRuleMatches(tools.disabled, target)) {
|
|
334
|
+
throw new Error(`meta tool "${name}" cannot be disabled; remove it from tools.disabled`);
|
|
176
335
|
}
|
|
177
336
|
}
|
|
337
|
+
return { tools, skills };
|
|
178
338
|
};
|
|
179
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Compile, then commit. Compiling first keeps a rejected update from landing
|
|
341
|
+
* half-applied: `current`, `metaTools` and the rule sets move together or not
|
|
342
|
+
* at all.
|
|
343
|
+
*/
|
|
344
|
+
const applyConfig = (candidate, meta) => {
|
|
345
|
+
const compiled = compileConfig(candidate, meta);
|
|
346
|
+
current = candidate;
|
|
347
|
+
metaTools = [...meta];
|
|
348
|
+
metaToolSet = new Set(metaTools);
|
|
349
|
+
toolCompiled = compiled.tools;
|
|
350
|
+
skillCompiled = compiled.skills;
|
|
351
|
+
};
|
|
352
|
+
applyConfig(normalized, [...(normalized.metaTools ?? DEFAULT_META_TOOLS)]);
|
|
180
353
|
const service = {
|
|
181
354
|
classifyTool(name) {
|
|
182
355
|
const server = serverNameOf(name);
|
|
183
356
|
return classify(toolCompiled, { id: name, name, server, kind: 'tool', ruleKind: 'tool' }, metaToolSet);
|
|
184
357
|
},
|
|
185
358
|
classifySkill(name) {
|
|
186
|
-
return classify(skillCompiled, { id:
|
|
187
|
-
},
|
|
188
|
-
classifyCapability(id) {
|
|
189
|
-
if (id.startsWith('skill:'))
|
|
190
|
-
return service.classifySkill(id.slice('skill:'.length));
|
|
191
|
-
return service.classifyTool(id);
|
|
359
|
+
return classify(skillCompiled, { id: name, name, kind: 'skill', ruleKind: 'skill' }, new Set());
|
|
192
360
|
},
|
|
193
|
-
|
|
194
|
-
|
|
361
|
+
classifyCapability(id, kind) {
|
|
362
|
+
// `kind` disambiguates a bare name shared by a tool and a skill.
|
|
363
|
+
return kind === 'skill' ? service.classifySkill(id) : service.classifyTool(id);
|
|
195
364
|
},
|
|
196
|
-
|
|
197
|
-
return service.
|
|
365
|
+
isResidentTool(name) {
|
|
366
|
+
return service.classifyTool(name) === 'resident';
|
|
198
367
|
},
|
|
199
|
-
|
|
200
|
-
return service.
|
|
368
|
+
isResidentSkill(name) {
|
|
369
|
+
return service.classifySkill(name) === 'resident';
|
|
201
370
|
},
|
|
202
|
-
|
|
203
|
-
return service.
|
|
371
|
+
isDisabledTool(name) {
|
|
372
|
+
return service.classifyTool(name) === 'disabled';
|
|
204
373
|
},
|
|
205
|
-
|
|
206
|
-
return service.
|
|
374
|
+
isDisabledSkill(name) {
|
|
375
|
+
return service.classifySkill(name) === 'disabled';
|
|
207
376
|
},
|
|
208
|
-
|
|
209
|
-
return service.
|
|
210
|
-
},
|
|
211
|
-
isBlockedCapability(id) {
|
|
212
|
-
return service.classifyCapability(id) === 'blocked';
|
|
377
|
+
isDisabledCapability(id, kind) {
|
|
378
|
+
return service.classifyCapability(id, kind) === 'disabled';
|
|
213
379
|
},
|
|
214
380
|
metaTools() {
|
|
215
381
|
return metaTools;
|
|
@@ -223,42 +389,204 @@ export function apply(ctx, config = {}) {
|
|
|
223
389
|
getConfig() {
|
|
224
390
|
return { ...current };
|
|
225
391
|
},
|
|
226
|
-
updateConfig(partial) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
392
|
+
async updateConfig(partial) {
|
|
393
|
+
// Only the materialized On-demand catalog on disk depends on the
|
|
394
|
+
// classification: the model-facing projection is derived from the rules
|
|
395
|
+
// on every assemble, so it needs no rebuild. Cycling a capability
|
|
396
|
+
// between Resident and Disabled changes nothing on disk, so it must not
|
|
397
|
+
// pay for a full re-enumeration.
|
|
398
|
+
const updateStartedAt = Date.now();
|
|
399
|
+
const before = onDemandSignature();
|
|
400
|
+
// Validate first, commit second (see `applyConfig`): a rejected update
|
|
401
|
+
// leaves the live policy exactly as it was.
|
|
402
|
+
applyConfig({ ...current, ...partial }, partial.metaTools !== undefined ? [...(partial.metaTools ?? DEFAULT_META_TOOLS)] : metaTools);
|
|
403
|
+
// The On-demand set moved, so the grep-able YAML is stale (and a
|
|
404
|
+
// capability reclassified to Disabled must disappear from it). Re-emit it
|
|
405
|
+
// from the index we already have and await that: it is a filter plus a
|
|
406
|
+
// small write, so awaiting keeps the file consistent with what the UI is
|
|
407
|
+
// about to read. `requestRefresh` would instead re-enumerate every tool
|
|
408
|
+
// and every agent preset scope — no inventory changed, and the cost of
|
|
409
|
+
// that was exactly what made a tier click feel like a hang.
|
|
410
|
+
const moved = onDemandSignature() !== before;
|
|
411
|
+
if (moved)
|
|
412
|
+
await ctx.capability.rewriteCatalog();
|
|
413
|
+
// One line per tier write: the whole handler's cost, and whether the
|
|
414
|
+
// catalog was re-emitted. A slow click with a fast line here means the
|
|
415
|
+
// time went somewhere else (a queued rebuild, the carrier, the browser).
|
|
416
|
+
ctx.logger.info(`capability-policy: tier write took ${Date.now() - updateStartedAt}ms (catalog rewritten: ${moved})`);
|
|
417
|
+
schedulePersistTiers();
|
|
233
418
|
},
|
|
234
419
|
classifyAll() {
|
|
235
420
|
// The registry default maxResults (20) would truncate the management
|
|
236
421
|
// surface: enumerate every indexed capability, not just the top-20.
|
|
237
422
|
return ctx.capability.search({ maxResults: Number.MAX_SAFE_INTEGER }).map(summary => {
|
|
238
|
-
const cls = service.classifyCapability(summary.id);
|
|
423
|
+
const cls = service.classifyCapability(summary.id, summary.kind);
|
|
239
424
|
const mandatory = summary.kind === 'tool' && metaToolSet.has(summary.id);
|
|
425
|
+
// Summaries carry no directory, so a skill's own is resolved from its
|
|
426
|
+
// record — the same lookup 纳入管理 itself uses to decide what to link.
|
|
427
|
+
const skillPath = summary.kind === 'skill'
|
|
428
|
+
? ctx.capability.get(summary.id, 'skill')?.origin.path
|
|
429
|
+
: undefined;
|
|
240
430
|
return {
|
|
241
431
|
id: summary.id,
|
|
242
432
|
kind: summary.kind,
|
|
243
433
|
name: summary.name,
|
|
244
434
|
...summary.server !== undefined ? { server: summary.server } : {},
|
|
435
|
+
...summary.source !== undefined ? { source: summary.source } : {},
|
|
436
|
+
...skillPath !== undefined ? { path: skillPath } : {},
|
|
245
437
|
class: cls,
|
|
246
438
|
classLabel: CLASS_LABELS[cls],
|
|
247
439
|
mandatory,
|
|
248
440
|
};
|
|
249
441
|
});
|
|
250
442
|
},
|
|
443
|
+
// — location registry —
|
|
444
|
+
listLocations() {
|
|
445
|
+
return locations.listMcp();
|
|
446
|
+
},
|
|
447
|
+
addLocation(input) {
|
|
448
|
+
return locations.addMcp(input);
|
|
449
|
+
},
|
|
450
|
+
removeLocation(id) {
|
|
451
|
+
return locations.removeMcp(id);
|
|
452
|
+
},
|
|
453
|
+
updateLocation(id, input) {
|
|
454
|
+
return locations.updateMcp(id, input);
|
|
455
|
+
},
|
|
456
|
+
listSkillLocations() {
|
|
457
|
+
return locations.listSkills();
|
|
458
|
+
},
|
|
459
|
+
addSkillLocation(dir, projectPath) {
|
|
460
|
+
return locations.addSkill(dir, projectPath);
|
|
461
|
+
},
|
|
462
|
+
removeSkillLocation(name, entryDir) {
|
|
463
|
+
return locations.removeSkill(name, entryDir);
|
|
464
|
+
},
|
|
465
|
+
updateSkillLocation(name, dir, entryDir) {
|
|
466
|
+
return locations.updateSkill(name, dir, entryDir);
|
|
467
|
+
},
|
|
251
468
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Fingerprint of the capability ids currently classified On-demand. This set
|
|
471
|
+
* is the only thing a classification change can invalidate on disk, so it
|
|
472
|
+
* decides whether `updateConfig` needs to request a rebuild at all.
|
|
473
|
+
*/
|
|
474
|
+
const onDemandSignature = () => {
|
|
475
|
+
const ids = ctx.capability
|
|
476
|
+
.search({ maxResults: Number.MAX_SAFE_INTEGER })
|
|
477
|
+
.filter(summary => service.classifyCapability(summary.id, summary.kind) === 'on-demand')
|
|
478
|
+
.map(summary => `${summary.kind}:${summary.id}`);
|
|
479
|
+
ids.sort();
|
|
480
|
+
return ids.join('\u0000');
|
|
481
|
+
};
|
|
482
|
+
// Disabled capabilities are a hard deny at the execution surface, not just a
|
|
483
|
+
// projection concern: a hallucinated direct call to a disabled tool (or to the
|
|
484
|
+
// `skill` loader for a disabled skill) must never reach the underlying server.
|
|
485
|
+
// On-demand tools stay executable — meta_invoke forwards through this same
|
|
486
|
+
// pipeline, so only Disabled is rejected here.
|
|
487
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
488
|
+
if (service.isDisabledTool(exec.name)) {
|
|
489
|
+
return { kind: 'deny', reason: `capability "${exec.name}" is disabled and cannot be executed` };
|
|
490
|
+
}
|
|
491
|
+
if (exec.name === 'skill') {
|
|
492
|
+
const args = exec.arguments;
|
|
493
|
+
const name = typeof args?.name === 'string' ? args.name : '';
|
|
494
|
+
if (name.length > 0 && service.isDisabledSkill(name)) {
|
|
495
|
+
return { kind: 'deny', reason: `skill "${name}" is disabled and cannot be loaded` };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return next();
|
|
499
|
+
});
|
|
500
|
+
// On-demand/Disabled skills must not appear in the model-facing
|
|
501
|
+
// `<available_skills>` catalog injected by `dsh-tool-skill`. The catalog is a
|
|
502
|
+
// user-role message whose `source.kind === 'skill-catalog'`; rewrite it every
|
|
503
|
+
// pre-step to keep only Resident skills. dsh-tool-skill republishes the full
|
|
504
|
+
// catalog earlier on the same chain, so this filter is idempotent: whatever
|
|
505
|
+
// it publishes, only the Resident subset reaches the model.
|
|
506
|
+
ctx.on('agent/pre-step', async (_payload, next) => {
|
|
507
|
+
const decision = await next();
|
|
508
|
+
if (decision.kind !== 'enter')
|
|
509
|
+
return decision;
|
|
510
|
+
let changed = false;
|
|
511
|
+
const messages = decision.messages.map(message => {
|
|
512
|
+
const source = message.source;
|
|
513
|
+
if (source?.kind !== 'skill-catalog')
|
|
514
|
+
return message;
|
|
515
|
+
if (!Array.isArray(source.entries)) {
|
|
516
|
+
// Cannot tell which skills the message lists, so it stays untouched —
|
|
517
|
+
// but say so: silently skipping the filter would leak Disabled skills
|
|
518
|
+
// into the model surface with no trace.
|
|
519
|
+
ctx.logger.warn('capability-policy: skill-catalog message has no entries array; leaving it unfiltered');
|
|
520
|
+
return message;
|
|
521
|
+
}
|
|
522
|
+
const entries = source.entries.filter((entry) => typeof entry === 'object' && entry !== null && typeof entry.name === 'string');
|
|
523
|
+
const kept = entries
|
|
524
|
+
.filter(entry => service.isResidentSkill(entry.name))
|
|
525
|
+
.map(entry => ({ name: entry.name, description: entry.description ?? '' }));
|
|
526
|
+
if (kept.length === entries.length)
|
|
527
|
+
return message;
|
|
528
|
+
changed = true;
|
|
529
|
+
return {
|
|
530
|
+
...message,
|
|
531
|
+
content: [{ type: 'text', text: renderSkillCatalog(kept) }],
|
|
532
|
+
source: { ...message.source, entries: kept },
|
|
533
|
+
};
|
|
534
|
+
});
|
|
535
|
+
if (!changed)
|
|
536
|
+
return decision;
|
|
537
|
+
return { ...decision, messages };
|
|
538
|
+
});
|
|
539
|
+
// Project the model-visible tool list, then append a one-line pointer to the
|
|
540
|
+
// on-demand catalog so the model knows it exists without having to "think of"
|
|
541
|
+
// meta_search first. Skills keep the dsh-native catalog, but filtered to
|
|
542
|
+
// Resident by the `agent/pre-step` hook above.
|
|
258
543
|
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
|
259
544
|
const resolved = await next();
|
|
260
|
-
|
|
545
|
+
const projected = projectAssemblyTools(resolved, service);
|
|
546
|
+
const catalogPath = ctx.capability.catalogPath?.();
|
|
547
|
+
// Skip the pointer when there is nothing On-demand to browse: an empty
|
|
548
|
+
// hint wastes ~77 tokens of context and points at an empty file.
|
|
549
|
+
if (catalogPath === undefined || (ctx.capability.onDemandCount?.() ?? 0) === 0)
|
|
550
|
+
return projected;
|
|
551
|
+
const pointer = {
|
|
552
|
+
name: 'capability-menu-catalog',
|
|
553
|
+
text: [
|
|
554
|
+
'On-demand capabilities are not in the resident tool list above. Their catalog is a YAML file you can browse with grep/read:',
|
|
555
|
+
` ${catalogPath}`,
|
|
556
|
+
'Search it (e.g. grep -n "name:" <path>) or call meta_search with an exact id for a schema, then meta_invoke to run/load the capability.',
|
|
557
|
+
].join('\n'),
|
|
558
|
+
};
|
|
559
|
+
return { ...projected, sections: [...projected.sections, pointer] };
|
|
261
560
|
});
|
|
262
561
|
ctx.provide('capabilityPolicy', service);
|
|
562
|
+
// Emit the on-demand catalog before the plugin finishes mounting. The
|
|
563
|
+
// registry's own startup path (rebuildTools + refreshSkills) bypasses
|
|
564
|
+
// refresh(), so without this the YAML would not exist until the first
|
|
565
|
+
// tools/skills change event. Awaiting here closes the cold-start window
|
|
566
|
+
// where the first assemble could point at a file that does not exist yet.
|
|
567
|
+
await ctx.capability.refresh();
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Rebuild the text body of a skill-catalog user message from a filtered entry
|
|
571
|
+
* list. Mirrors the `<available_skills>` format emitted by `dsh-tool-skill` so
|
|
572
|
+
* the model sees a consistent, complete replacement catalog.
|
|
573
|
+
*/
|
|
574
|
+
function renderSkillCatalog(entries) {
|
|
575
|
+
const guidance = entries.length === 0
|
|
576
|
+
? ['No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.']
|
|
577
|
+
: [
|
|
578
|
+
'If the user names a skill, or the task clearly matches a skill\u2019s description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill\u2019s instructions until it has been loaded.',
|
|
579
|
+
];
|
|
580
|
+
return [
|
|
581
|
+
'<system-reminder>',
|
|
582
|
+
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
|
583
|
+
'',
|
|
584
|
+
'<available_skills>',
|
|
585
|
+
...entries.map(entry => `- \`${escapeText(entry.name)}\`: ${escapeText(entry.description ?? '')}`),
|
|
586
|
+
'</available_skills>',
|
|
587
|
+
'',
|
|
588
|
+
...guidance,
|
|
589
|
+
'</system-reminder>',
|
|
590
|
+
].join('\n');
|
|
263
591
|
}
|
|
264
592
|
//# sourceMappingURL=policy.js.map
|