@daweifu/capability-menu 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.en.md +238 -0
  2. package/README.md +127 -63
  3. package/cordis.patch.yml +37 -46
  4. package/lib/client.d.ts +224 -19
  5. package/lib/client.d.ts.map +1 -1
  6. package/lib/client.js +1796 -226
  7. package/lib/constants.js +33 -0
  8. package/lib/constants.js.map +1 -0
  9. package/lib/index.js +1 -1
  10. package/lib/invoke.js +68 -96
  11. package/lib/invoke.js.map +1 -1
  12. package/lib/locations.js +541 -0
  13. package/lib/locations.js.map +1 -0
  14. package/lib/patch-file.js +192 -0
  15. package/lib/patch-file.js.map +1 -0
  16. package/lib/policy.js +422 -94
  17. package/lib/policy.js.map +1 -1
  18. package/lib/registry.js +410 -152
  19. package/lib/registry.js.map +1 -1
  20. package/lib/search.js +26 -9
  21. package/lib/search.js.map +1 -1
  22. package/lib/server/remote.js +219 -3
  23. package/lib/server/remote.js.map +1 -1
  24. package/lib/types/constants.d.ts +33 -0
  25. package/lib/types/constants.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +1 -1
  27. package/lib/types/invoke.d.ts +7 -13
  28. package/lib/types/invoke.d.ts.map +1 -1
  29. package/lib/types/locations.d.ts +142 -0
  30. package/lib/types/locations.d.ts.map +1 -0
  31. package/lib/types/patch-file.d.ts +46 -0
  32. package/lib/types/patch-file.d.ts.map +1 -0
  33. package/lib/types/policy.d.ts +115 -53
  34. package/lib/types/policy.d.ts.map +1 -1
  35. package/lib/types/registry.d.ts +94 -25
  36. package/lib/types/registry.d.ts.map +1 -1
  37. package/lib/types/search.d.ts.map +1 -1
  38. package/lib/types/server/remote.d.ts +68 -2
  39. package/lib/types/server/remote.d.ts.map +1 -1
  40. package/package.json +23 -26
  41. package/lib/invariant.js +0 -22
  42. package/lib/invariant.js.map +0 -1
  43. package/lib/types/invariant.d.ts +0 -16
  44. package/lib/types/invariant.d.ts.map +0 -1
package/lib/policy.js CHANGED
@@ -1,28 +1,74 @@
1
1
  /**
2
- * Exposed / Progressive / Blocked capability projection policy for the DeepSeek
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
- exposed: z.array(z.string()).default([]),
13
- progressive: z.array(z.string()).default([]),
14
- blocked: z.array(z.string()).default([]),
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
- exposed: z.array(z.string()).default([]),
18
- progressive: z.array(z.string()).default([]),
19
- blocked: z.array(z.string()).default([]),
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
- progressiveSkillCatalog: z.string(),
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
- exposed: (set.exposed ?? []).map(parseRule),
61
- progressive: (set.progressive ?? []).map(parseRule),
62
- blocked: (set.blocked ?? []).map(parseRule),
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 `skill:debugging`, `bash` matches the harness-native tool
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
- * 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.
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 Progressive/Blocked for tools.
143
+ // metaTools can never be On-demand/Disabled for tools.
94
144
  if (target.kind === 'tool' && meta.has(id))
95
- return 'exposed';
96
- for (const rule of compiled.blocked) {
145
+ return 'resident';
146
+ for (const rule of compiled.disabled) {
97
147
  if (!rule.wildcard && ruleMatches(rule, target))
98
- return 'blocked';
148
+ return 'disabled';
99
149
  }
100
- for (const rule of compiled.blocked) {
150
+ for (const rule of compiled.disabled) {
101
151
  if (rule.wildcard && ruleMatches(rule, target))
102
- return 'blocked';
152
+ return 'disabled';
103
153
  }
104
- for (const rule of compiled.exposed) {
154
+ for (const rule of compiled.resident) {
105
155
  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';
156
+ return 'resident';
111
157
  }
112
- for (const rule of compiled.progressive) {
158
+ for (const rule of compiled.onDemand) {
113
159
  if (!rule.wildcard && ruleMatches(rule, target))
114
- return 'progressive';
160
+ return 'on-demand';
115
161
  }
116
- for (const rule of compiled.progressive) {
162
+ for (const rule of compiled.resident) {
117
163
  if (rule.wildcard && ruleMatches(rule, target))
118
- return 'progressive';
164
+ return 'resident';
119
165
  }
120
- return 'exposed';
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
- exposed: 'Exposed · 常驻(直接调用)',
128
- progressive: 'Progressive · 按需(目录渐进加载)',
129
- blocked: 'Blocked · 禁用',
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 Exposed tools plus
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.isExposedTool(tool.name));
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 = { ...config };
152
- let metaTools = [...(config.metaTools ?? DEFAULT_META_TOOLS)];
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
- const recompile = () => {
157
- const toolRules = compileSet(current.tools);
158
- const skillRules = compileSet(current.skills);
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
- 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' })),
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
- 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' })),
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
- // 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) {
331
+ for (const name of meta) {
173
332
  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`);
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
- recompile();
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: `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);
359
+ return classify(skillCompiled, { id: name, name, kind: 'skill', ruleKind: 'skill' }, new Set());
192
360
  },
193
- isExposedTool(name) {
194
- return service.classifyTool(name) === 'exposed';
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
- isExposedSkill(name) {
197
- return service.classifySkill(name) === 'exposed';
365
+ isResidentTool(name) {
366
+ return service.classifyTool(name) === 'resident';
198
367
  },
199
- isProgressiveTool(name) {
200
- return service.classifyTool(name) === 'progressive';
368
+ isResidentSkill(name) {
369
+ return service.classifySkill(name) === 'resident';
201
370
  },
202
- isProgressiveSkill(name) {
203
- return service.classifySkill(name) === 'progressive';
371
+ isDisabledTool(name) {
372
+ return service.classifyTool(name) === 'disabled';
204
373
  },
205
- isBlockedTool(name) {
206
- return service.classifyTool(name) === 'blocked';
374
+ isDisabledSkill(name) {
375
+ return service.classifySkill(name) === 'disabled';
207
376
  },
208
- isBlockedSkill(name) {
209
- return service.classifySkill(name) === 'blocked';
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
- current = { ...current, ...partial };
228
- if (partial.metaTools !== undefined) {
229
- metaTools = [...(partial.metaTools ?? DEFAULT_META_TOOLS)];
230
- metaToolSet = new Set(metaTools);
231
- }
232
- recompile();
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
- // 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).
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
- return projectAssemblyTools(resolved, service);
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