@lvy-jsd/dsh-tool-manager 0.0.0-stage → 0.4.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/cordis.patch.yml +29 -0
  4. package/lib/catalog.d.ts +35 -0
  5. package/lib/catalog.d.ts.map +1 -0
  6. package/lib/catalog.js +311 -0
  7. package/lib/catalog.js.map +1 -0
  8. package/lib/exempt.d.ts +45 -0
  9. package/lib/exempt.d.ts.map +1 -0
  10. package/lib/exempt.js +122 -0
  11. package/lib/exempt.js.map +1 -0
  12. package/lib/index.d.ts +24 -0
  13. package/lib/index.d.ts.map +1 -0
  14. package/lib/index.js +797 -0
  15. package/lib/index.js.map +1 -0
  16. package/lib/manifest.d.ts +21 -0
  17. package/lib/manifest.d.ts.map +1 -0
  18. package/lib/manifest.js +112 -0
  19. package/lib/manifest.js.map +1 -0
  20. package/lib/metadata.d.ts +38 -0
  21. package/lib/metadata.d.ts.map +1 -0
  22. package/lib/metadata.js +102 -0
  23. package/lib/metadata.js.map +1 -0
  24. package/lib/router/bm25.d.ts +21 -0
  25. package/lib/router/bm25.d.ts.map +1 -0
  26. package/lib/router/bm25.js +63 -0
  27. package/lib/router/bm25.js.map +1 -0
  28. package/lib/router/index.d.ts +9 -0
  29. package/lib/router/index.d.ts.map +1 -0
  30. package/lib/router/index.js +422 -0
  31. package/lib/router/index.js.map +1 -0
  32. package/lib/router/sidecar.d.ts +34 -0
  33. package/lib/router/sidecar.d.ts.map +1 -0
  34. package/lib/router/sidecar.js +156 -0
  35. package/lib/router/sidecar.js.map +1 -0
  36. package/lib/router/types.d.ts +164 -0
  37. package/lib/router/types.d.ts.map +1 -0
  38. package/lib/router/types.js +7 -0
  39. package/lib/router/types.js.map +1 -0
  40. package/lib/service.d.ts +51 -0
  41. package/lib/service.d.ts.map +1 -0
  42. package/lib/service.js +199 -0
  43. package/lib/service.js.map +1 -0
  44. package/lib/settings.d.ts +32 -0
  45. package/lib/settings.d.ts.map +1 -0
  46. package/lib/settings.js +93 -0
  47. package/lib/settings.js.map +1 -0
  48. package/lib/types.d.ts +453 -0
  49. package/lib/types.d.ts.map +1 -0
  50. package/lib/types.js +7 -0
  51. package/lib/types.js.map +1 -0
  52. package/lib/visibility.d.ts +33 -0
  53. package/lib/visibility.d.ts.map +1 -0
  54. package/lib/visibility.js +305 -0
  55. package/lib/visibility.js.map +1 -0
  56. package/package.json +54 -4
  57. package/sidecar/bin/tool-router-sidecar +0 -0
  58. package/src/catalog.ts +305 -0
  59. package/src/exempt.ts +148 -0
  60. package/src/index.ts +805 -0
  61. package/src/manifest.ts +116 -0
  62. package/src/metadata.ts +123 -0
  63. package/src/router/bm25.ts +68 -0
  64. package/src/router/index.ts +440 -0
  65. package/src/router/sidecar.ts +159 -0
  66. package/src/router/types.ts +149 -0
  67. package/src/service.ts +207 -0
  68. package/src/settings.ts +108 -0
  69. package/src/types.ts +450 -0
  70. package/src/visibility.ts +317 -0
package/lib/index.js ADDED
@@ -0,0 +1,797 @@
1
+ // dsh-tool-manager: browse every DSH tool as a directory tree
2
+ // (category -> group -> tool) and load tools on demand per agent.
3
+ //
4
+ // Loaded tools are native: the model receives their full parameter schema.
5
+ // Visibility is enforced with the host's per-agent `tools.restrict({ allow })`.
6
+ //
7
+ // The core ships no placement rules: without a classifier plugin every group
8
+ // is `uncategorized`. Placement, metadata, hooks and extra visibility narrowing
9
+ // are provided by other plugins through the `toolCatalog` service.
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { buildCatalog, resolveTarget, toolsUnder, UNCATEGORIZED } from './catalog.js';
12
+ import { createMetadataStore, defaultMetadataFile } from './metadata.js';
13
+ import { createExemptStore, defaultExemptFile } from './exempt.js';
14
+ import { defaultManifestFile, syncManifest } from './manifest.js';
15
+ import { createSettingsStore, defaultSettingsFile } from './settings.js';
16
+ import { createVisibilityController } from './visibility.js';
17
+ import { createRegistries, readManifestAnnotations, listPluginPackages } from './service.js';
18
+ import { registerRouter } from './router/index.js';
19
+ export { buildCatalog, resolveTarget, toolsUnder, estimateTokens, summary, shortName, structuralGroup, DEFAULT_CATEGORIES, UNCATEGORIZED, MISC_GROUP, OVERRIDE_PRIORITY } from './catalog.js';
20
+ export const name = 'tool-manager';
21
+ export const inject = ['tools'];
22
+ export const ENTRY_TOOLS = ['tools_ls', 'tools_load', 'tools_unload'];
23
+ // Host transports that are not capabilities: never listed, loaded or restricted.
24
+ // `run_code` carries every call in DSH's PTC mode; the host refuses to restrict
25
+ // it and adds it to the view after all restrictions, so treating it as a hidden
26
+ // tool would block PTC mode outright.
27
+ export const HOST_RESERVED = ['run_code'];
28
+ // Always visible and never listed in the directory. Names the host does not
29
+ // have are ignored, so this list survives DSH renames. Config `pinned` adds to
30
+ // it; config `unpin` removes from it.
31
+ export const DEFAULT_PINNED = [
32
+ // DSH core
33
+ 'bash', 'read', 'write', 'edit', 'str_replace_editor', 'glob', 'grep', 'read_image',
34
+ 'ask_user_question', 'ask_user', 'exit_plan_mode', 'workflow', 'todo_write', 'present', 'skill', 'plan',
35
+ 'web_fetch', 'web_search', 'job_list', 'job_output', 'job_kill', 'create_goal', 'get_goal', 'update_goal',
36
+ // delegation
37
+ 'subagent', 'subagent_fork', 'list_subagent_models', 'wait_agent', 'interrupt_agent', 'list_agents',
38
+ // memory lookup is small and frequent
39
+ 'recall',
40
+ // team chat: a Bot's group prompt tells it to answer through these, so they
41
+ // must never sit behind tools_load
42
+ 'group_relay', 'post_to_group', 'read_group_chat', 'read_own_chat', 'message_bot', 'list_bots',
43
+ ];
44
+ const TEXT_OUTPUT = {
45
+ schema: { type: 'string' },
46
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
47
+ };
48
+ function fmtTokens(n) {
49
+ return n >= 1000 ? `~${(n / 1000).toFixed(1)}k tok` : `~${n} tok`;
50
+ }
51
+ function pad(text, width) {
52
+ return text.length >= width ? `${text} ` : text + ' '.repeat(width - text.length);
53
+ }
54
+ export function apply(ctx, config = {}) {
55
+ const logger = (typeof ctx.logger === 'function' ? ctx.logger('tool-manager') : ctx.logger) ?? console;
56
+ // Values a UI may edit live in their own file, which wins per key over the
57
+ // plugin config: an operator editing cordis.patch.yml must not have a stale
58
+ // panel overwrite it on the next click, and a panel edit must not be undone
59
+ // by the next restart.
60
+ const settings = createSettingsStore({
61
+ file: config.settingsFile ?? defaultSettingsFile(),
62
+ base: { pinned: config.pinned, unpin: config.unpin, highRisk: config.highRisk },
63
+ logger,
64
+ });
65
+ // Panel-edited tool metadata (group, category, risk, summary). Applied after
66
+ // every other annotation, so an edit corrects a package declaration instead of
67
+ // being overruled by it.
68
+ const metadata = createMetadataStore({ file: config.metadataFile ?? defaultMetadataFile(), logger });
69
+ // Model routes whose tool list is handed to an external client once per
70
+ // session and never refreshed (Claude Code receives DSH tools through an MCP
71
+ // bridge at startup). Narrowing such a session hides tools it can then never
72
+ // load, and the client already lazy-loads schemas itself (ToolSearch), so
73
+ // these agents are left unrestricted.
74
+ //
75
+ // The list in force comes from the exemption file when it exists, else from
76
+ // config `exemptProviders` (default ["claude-code"]; [] restricts every
77
+ // route). A file entry can also name a single session id, so one session can
78
+ // be exempted without touching the route it uses.
79
+ const exemptions = createExemptStore({
80
+ file: config.exemptFile ?? defaultExemptFile(),
81
+ configProviders: config.exemptProviders ?? ['claude-code'],
82
+ logger,
83
+ });
84
+ /** Session id used to match a per-session exemption, with the agent id as fallback. */
85
+ function sessionIdOf(agent) {
86
+ const id = agent?.session?.id ?? agent?.id;
87
+ return id === undefined || id === null || id === '' ? undefined : String(id);
88
+ }
89
+ /** Why this agent is not narrowed, if it is not. */
90
+ function exemptionOf(agent) {
91
+ return exemptions.reasonFor(providerOf(agent), sessionIdOf(agent));
92
+ }
93
+ /** The route an agent's next request will use: latest model/selection, else its options. */
94
+ const selectionScan = new WeakMap(); // session -> incremental scan
95
+ function providerOf(agent) {
96
+ const session = agent?.session;
97
+ try {
98
+ if (session && typeof session.eventAt === 'function' && Number.isInteger(session.seq)) {
99
+ const memo = selectionScan.get(session) ?? { seq: 0, provider: undefined };
100
+ for (let seq = memo.seq; seq < session.seq; seq += 1) {
101
+ const event = session.eventAt(seq);
102
+ if (event?.type === 'model/selection' && event.data?.provider)
103
+ memo.provider = event.data.provider;
104
+ }
105
+ memo.seq = session.seq;
106
+ selectionScan.set(session, memo);
107
+ if (memo.provider)
108
+ return memo.provider;
109
+ }
110
+ }
111
+ catch {
112
+ // fall back to the agent's options
113
+ }
114
+ return agent?.options?.provider ?? session?.requestHeader?.()?.provider;
115
+ }
116
+ // High-risk tools can be loaded individually only while at least one risk
117
+ // gate (e.g. dsh-tool-manager-risk-gate) is registered; the gate confirms the
118
+ // load before it executes. With no gate, high-risk loads are refused.
119
+ const riskGates = new Set();
120
+ const pinnedSet = () => {
121
+ const current = settings.effective();
122
+ const set = new Set([...DEFAULT_PINNED, ...current.pinned]);
123
+ for (const n of current.unpin)
124
+ set.delete(n);
125
+ for (const n of ENTRY_TOOLS)
126
+ set.add(n);
127
+ return set;
128
+ };
129
+ /**
130
+ * Configuration as one object for UIs. `extraPinned`/`unpinned` show the
131
+ * settings file, so the panel edits exactly what it displays; `pinned` is the
132
+ * resulting always-on set, defaults and entry tools included.
133
+ */
134
+ function configSnapshot() {
135
+ const current = settings.effective();
136
+ return {
137
+ pinned: [...pinnedSet()],
138
+ entryTools: [...ENTRY_TOOLS],
139
+ extraPinned: [...current.pinned],
140
+ unpinned: [...current.unpin],
141
+ highRisk: [...current.highRisk],
142
+ exemptProviders: [...exemptions.effective().providers],
143
+ exemptSessions: [...exemptions.effective().sessions],
144
+ exemptFile: exemptions.file,
145
+ hasExemptFile: exemptions.present(),
146
+ defaults: [...DEFAULT_PINNED],
147
+ settingsFile: settings.file,
148
+ // An empty file is not an override, so count keys rather than existence.
149
+ hasSettings: Object.keys(settings.stored()).length > 0,
150
+ };
151
+ }
152
+ /**
153
+ * One tool's metadata plus every layer behind it, so the panel can show what
154
+ * is declared, what was edited, and what the model actually gets. A tool that
155
+ * is pinned away or not registered is reported as missing rather than absent,
156
+ * so the UI can explain why there is nothing to edit.
157
+ */
158
+ function toolMetaSnapshot(name) {
159
+ const entry = getCatalog().tools.get(name);
160
+ const declared = { ...annotationForLayers(name) };
161
+ return {
162
+ name,
163
+ found: entry !== undefined,
164
+ effective: entry?.annotation ?? declared,
165
+ edited: metadata.get(name) ?? {},
166
+ declared,
167
+ group: entry?.group ?? declared.group ?? '',
168
+ category: entry?.category ?? declared.category ?? '',
169
+ summary: entry?.summary ?? declared.summary ?? '',
170
+ highRisk: entry?.highRisk === true,
171
+ path: entry ? `/${entry.category}/${entry.group}/${entry.short}` : '',
172
+ missing: entry === undefined,
173
+ };
174
+ }
175
+ /**
176
+ * The declared metadata only: package.json first, runtime annotate() after,
177
+ * with panel edits left out so the UI can tell the two apart.
178
+ */
179
+ function annotationForLayers(name) {
180
+ manifestAnnotations ??= readManifestAnnotations(ctx, logger);
181
+ const merged = {};
182
+ for (const annotation of [...manifestAnnotations, ...registries.annotations]) {
183
+ const hit = typeof annotation.match === 'string' ? annotation.match === name : annotation.match.test(name);
184
+ if (hit)
185
+ Object.assign(merged, annotation.meta);
186
+ }
187
+ return merged;
188
+ }
189
+ // --- catalog -----------------------------------------------------------------
190
+ // The directory is built per agent: global tools plus tools registered in the
191
+ // agent's ancestor scopes (its preset). In the DSH web profile most tools
192
+ // live in a preset layer, so a global-only catalog would hide them without
193
+ // listing them. Agents with the same tool set share one cached catalog.
194
+ const catalogs = new Map(); // key (sorted names) -> catalog
195
+ let manifestAnnotations = null;
196
+ const invalidate = () => { catalogs.clear(); };
197
+ const registries = createRegistries(() => {
198
+ invalidate();
199
+ visibility?.refresh();
200
+ });
201
+ function schemaOf(definition, name) {
202
+ try {
203
+ if (typeof ctx.tools.schemaOf === 'function')
204
+ return ctx.tools.schemaOf(definition, false);
205
+ }
206
+ catch {
207
+ // fall through to the public fields
208
+ }
209
+ return { name, description: definition.description ?? '', parameters: definition.parameters ?? {} };
210
+ }
211
+ /** Schemas of tools an agent inherits from its ancestor (preset) layers. */
212
+ function inheritedSchemas(agent) {
213
+ const layers = ctx.tools.layers;
214
+ if (!agent || typeof layers?.chainLayers !== 'function')
215
+ return [];
216
+ const own = layers.peek?.(agent);
217
+ const out = [];
218
+ for (const layer of layers.chainLayers(agent)) {
219
+ if (layer === own)
220
+ continue;
221
+ for (const [name, definition] of layer.tools.entries())
222
+ out.push(schemaOf(definition, name));
223
+ }
224
+ return out;
225
+ }
226
+ /** Catalog for one agent, or for the union of all live agents when omitted. */
227
+ const getCatalog = (agent) => {
228
+ const pinned = pinnedSet();
229
+ const byName = new Map(ctx.tools.schemas().map((s) => [s.name, s]));
230
+ for (const a of agent ? [agent] : visibility.agents()) {
231
+ for (const s of inheritedSchemas(a))
232
+ if (!byName.has(s.name))
233
+ byName.set(s.name, s);
234
+ }
235
+ const schemas = [...byName.values()].filter((s) => !pinned.has(s.name) && !HOST_RESERVED.includes(s.name));
236
+ const key = schemas.map((s) => s.name).sort().join('\n');
237
+ const cached = catalogs.get(key);
238
+ if (cached)
239
+ return cached;
240
+ ctx.emit('tool-catalog/before-rebuild', {});
241
+ manifestAnnotations ??= readManifestAnnotations(ctx, logger);
242
+ const catalog = buildCatalog(schemas, {
243
+ classifiers: [...registries.classifiers],
244
+ // Package declarations first, runtime annotate() after, so runtime wins —
245
+ // and panel edits last of all, so they win over both.
246
+ annotations: [...manifestAnnotations, ...registries.annotations, ...metadata.annotations()],
247
+ categories: Object.fromEntries(registries.categories),
248
+ highRisk: settings.effective().highRisk,
249
+ });
250
+ catalogs.set(key, catalog);
251
+ // A client that receives its tool list once per session cannot discover the
252
+ // directory by asking, so it gets the index as a file instead. Written here,
253
+ // after every rebuild, so it cannot drift from what tools_load would answer.
254
+ // An empty path turns it off.
255
+ const manifestFile = config.manifestFile ?? defaultManifestFile();
256
+ if (manifestFile)
257
+ syncManifest(catalog, manifestFile, logger);
258
+ ctx.emit('tool-catalog/after-rebuild', { catalog });
259
+ return catalog;
260
+ };
261
+ const visibility = createVisibilityController(ctx, {
262
+ pinned: () => [...pinnedSet()],
263
+ entryTools: ENTRY_TOOLS,
264
+ getFilters: () => [...registries.filters],
265
+ getCatalog,
266
+ // The catalog is rebuilt lazily after any registry change.
267
+ invalidate,
268
+ onStateChange: (agent, state) => ctx.emit('tool-catalog/state-changed', { agent, state }),
269
+ isExempt: (agent) => exemptionOf(agent).exempt,
270
+ logger,
271
+ });
272
+ if (typeof ctx.inject === 'function') {
273
+ ctx.inject(['agents'], (agentCtx) => {
274
+ agentCtx.effect(() => visibility.mount(agentCtx), 'tool-manager.visibility()');
275
+ });
276
+ }
277
+ else {
278
+ logger.warn?.('tool-manager: host has no ctx.inject; per-agent visibility is disabled');
279
+ }
280
+ ctx.effect(() => () => visibility.dispose(), 'tool-manager.dispose()');
281
+ // Rescue a direct call of a hidden tool (see rescueHiddenCall). Runs before
282
+ // dispatch resolves the name, so loading here makes the call succeed.
283
+ ctx.effect(() => ctx.on('tools/pre-execute', async (exec, next) => {
284
+ const agent = exec?.agent;
285
+ if (!agent || pinnedSet().has(exec.name) || ENTRY_TOOLS.includes(exec.name))
286
+ return next();
287
+ const refusal = await rescueHiddenCall(agent, exec.name);
288
+ if (refusal !== undefined)
289
+ return { kind: 'deny', reason: refusal };
290
+ return next();
291
+ }), 'tool-manager.rescue-hidden-call()');
292
+ // --- the toolCatalog service ---------------------------------------------------
293
+ const service = {
294
+ registerClassifier: registries.registerClassifier,
295
+ annotate: registries.annotate,
296
+ defineCategory: registries.defineCategory,
297
+ registerVisibilityFilter: registries.registerVisibilityFilter,
298
+ /**
299
+ * Own a target prefix (`"cap:"`) in tools_load / tools_ls / tools_unload.
300
+ * `resolve(target)` returns one or more ordinary targets to resolve
301
+ * instead, or undefined to pass. Returns a disposer.
302
+ */
303
+ registerTargetResolver: registries.registerTargetResolver,
304
+ /**
305
+ * Let loaded state outlive the process (DSH restart). `load(agent)` returns
306
+ * a serialized state or null, `save(agent, state)` receives plain JSON.
307
+ * Returns a disposer. See dsh-tool-manager-persist.
308
+ */
309
+ registerStateProvider: visibility.registerStateProvider,
310
+ /**
311
+ * Declare that a plugin confirms high-risk loads (normally by returning
312
+ * {kind:'ask'} from tools/pre-execute for tools_load). Returns a disposer.
313
+ */
314
+ registerRiskGate(gate = {}) {
315
+ const entry = { name: gate.name ?? 'anonymous' };
316
+ riskGates.add(entry);
317
+ return () => { riskGates.delete(entry); };
318
+ },
319
+ riskGates: () => [...riskGates].map((g) => g.name),
320
+ /** Re-read package declarations and rebuild. */
321
+ refresh() {
322
+ manifestAnnotations = null;
323
+ invalidate();
324
+ visibility.refresh();
325
+ },
326
+ /** Catalog as one agent sees it, or the union over live agents. */
327
+ get: (agent) => getCatalog(agent),
328
+ resolve: (target, agent) => resolveTarget(getCatalog(agent), target, [...registries.resolvers]),
329
+ /** Loaded state of one agent: { loaded, groups, excluded, handoffGroups, handoffTools } (Sets). */
330
+ stateOf: (agent) => visibility.stateOf(agent),
331
+ /** Plain JSON of an agent's loaded state, for persistence plugins. */
332
+ exportState: (agent) => visibility.exportState(agent),
333
+ /** Replace an agent's loaded state (e.g. restored after a restart). */
334
+ importState: (agent, state) => visibility.importState(agent, state),
335
+ allowFor: (agent) => visibility.allowFor(agent),
336
+ deniedFor: (agent) => [...deniedFor(agent)],
337
+ /** True when the agent's model route is in `exemptProviders` (never narrowed). */
338
+ isExempt: (agent) => visibility.isExempt(agent),
339
+ /** Why an agent is exempt, so the panel can label a route-wide exemption. */
340
+ exemptionOf: (agent) => exemptionOf(agent),
341
+ /**
342
+ * Load/unload on behalf of an agent, through the same path as the entry
343
+ * tools (hooks, vetoes, risk gates and persistence all apply). Returns the
344
+ * same text the model would see. Intended for UI plugins such as
345
+ * dsh-tool-manager-panel.
346
+ */
347
+ load: (agent, targets, options) => loadTargets(agent, targets, options),
348
+ unload: (agent, targets) => unloadTargets(agent, targets),
349
+ /** Live agents, each with its parent when it is a subagent. */
350
+ agents: () => {
351
+ // The parent lookup goes through the injected agents API: the plugin ctx
352
+ // itself does not inject `agents`, and reading it there throws.
353
+ return visibility.agents().map((agent) => {
354
+ const parent = visibility.parentOf(agent);
355
+ return { agent, id: String(agent.id), parentId: parent ? String(parent.id) : null };
356
+ });
357
+ },
358
+ /** Effective configuration, for UI display and editing. */
359
+ config: () => configSnapshot(),
360
+ /**
361
+ * Persist UI-edited settings and apply them at once. The catalog is rebuilt
362
+ * because a changed always-on or high-risk list changes every directory.
363
+ */
364
+ updateConfig(patch) {
365
+ settings.update(patch);
366
+ invalidate();
367
+ visibility.refresh();
368
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
369
+ return configSnapshot();
370
+ },
371
+ /** Re-read the settings file, for after an external edit. */
372
+ reloadConfig() {
373
+ settings.reload();
374
+ invalidate();
375
+ visibility.refresh();
376
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
377
+ return configSnapshot();
378
+ },
379
+ /**
380
+ * Exempt one session from narrowing, or stop exempting it. Written to the
381
+ * exemption file, so the choice survives a restart and is reversible by
382
+ * editing or deleting that one file.
383
+ */
384
+ setExemptSession(sessionId, exempt) {
385
+ exemptions.setSession(String(sessionId), exempt);
386
+ // An exemption installs or lifts a restriction, so re-apply it now.
387
+ visibility.refresh();
388
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
389
+ return configSnapshot();
390
+ },
391
+ /** Re-read the exemption file, for after an external edit. */
392
+ reloadExempt() {
393
+ exemptions.reload();
394
+ visibility.refresh();
395
+ ctx.emit('tool-catalog/settings-changed', { config: configSnapshot() });
396
+ return configSnapshot();
397
+ },
398
+ /** Metadata of one tool, with the layers that decide each field. */
399
+ toolMeta: (name) => toolMetaSnapshot(String(name)),
400
+ /**
401
+ * Edit metadata from the panel. The store wins over the package
402
+ * declaration, and the catalog is rebuilt so the Directory tab moves the
403
+ * tool at once.
404
+ */
405
+ updateToolMeta(name, meta) {
406
+ metadata.set(String(name), meta);
407
+ invalidate();
408
+ visibility.refresh();
409
+ ctx.emit('tool-catalog/metadata-changed', { name: String(name) });
410
+ return toolMetaSnapshot(String(name));
411
+ },
412
+ /** Drop every edited field of one tool, falling back to its declaration. */
413
+ clearToolMeta(name) {
414
+ metadata.clear(String(name));
415
+ invalidate();
416
+ visibility.refresh();
417
+ ctx.emit('tool-catalog/metadata-changed', { name: String(name) });
418
+ return toolMetaSnapshot(String(name));
419
+ },
420
+ metadataFile: () => metadata.file,
421
+ /** Enabled plugin packages on this host: `{ name, dir, manifest }[]`. */
422
+ packages: () => listPluginPackages(ctx),
423
+ /** Registered extension points, so a UI can show what is active. */
424
+ extensions: () => ({
425
+ classifiers: [...registries.classifiers].map((c) => ({ name: c.name, priority: c.priority ?? 0 })),
426
+ filters: [...registries.filters].map((f) => f.name),
427
+ annotations: registries.annotations.map((a) => ({ source: a.source, match: String(a.match) })),
428
+ categories: [...registries.categories.keys()],
429
+ riskGates: [...riskGates].map((g) => g.name),
430
+ stateProviders: visibility.stateProviders(),
431
+ }),
432
+ /**
433
+ * Structured cross-directory search for UIs: the same matching rules as
434
+ * tools_ls(query), returned as data instead of text.
435
+ */
436
+ find: (query, path = '/') => {
437
+ const cat = getCatalog();
438
+ const node = resolveTarget(cat, path, [...registries.resolvers]);
439
+ if (node.kind === 'missing')
440
+ return [];
441
+ return matchingTools(cat, node, query).slice(0, 200).map((tool) => ({
442
+ name: tool.name,
443
+ short: tool.short,
444
+ path: `/${tool.category}/${tool.group}/${tool.short}`,
445
+ category: tool.category,
446
+ group: tool.group,
447
+ summary: tool.summary,
448
+ tokens: tool.tokens,
449
+ highRisk: tool.highRisk === true,
450
+ }));
451
+ },
452
+ /** Schema tokens actually sent for an agent: pinned tools plus its loaded set. */
453
+ wireTokens: (agent) => {
454
+ const visible = new Set(visibility.allowFor(agent) ?? []);
455
+ const cat = getCatalog(agent);
456
+ let tokens = 0;
457
+ for (const schema of ctx.tools.schemas(agent)) {
458
+ if (!visible.has(schema.name))
459
+ continue;
460
+ tokens += cat.tools.get(schema.name)?.tokens ?? 0;
461
+ }
462
+ return tokens;
463
+ },
464
+ };
465
+ ctx.provide('toolCatalog', service);
466
+ // --- the retrieval + routing layer ---------------------------------------------
467
+ //
468
+ // The router reuses the toolCatalog service just provided: it indexes the same
469
+ // catalog, loads through the same path, and reports the same token budget. It
470
+ // adds tools_find / tools_route / skills_find / skills_route on top of the
471
+ // directory tools above.
472
+ registerRouter(ctx, config, service);
473
+ // --- direct calls of a hidden tool -----------------------------------------------
474
+ //
475
+ // A tool this plugin hid is still a registered tool: the model may call it by
476
+ // name, and the host would answer "unknown tool" — which reads like a broken
477
+ // plugin and, for a protocol tool a plugin's own prompt tells the model to
478
+ // use, breaks the feature outright (a group Bot replying through group_relay,
479
+ // for instance).
480
+ //
481
+ // So a direct call of a hidden tool is handled here, before dispatch:
482
+ // - an ordinary tool is loaded on the spot and the call proceeds;
483
+ // - a high-risk tool is refused with the exact load instruction, so it still
484
+ // has to go through the risk gate and its approval.
485
+ // The cost is a few tokens when the model skips the directory; the benefit is
486
+ // that forgetting to pin a protocol tool degrades instead of breaking.
487
+ async function rescueHiddenCall(agent, name) {
488
+ // allowFor() is null for an agent this plugin does not track (and for one
489
+ // whose context cannot restrict at all): nothing to rescue there.
490
+ if (visibility.allowFor(agent) === null)
491
+ return undefined;
492
+ const state = visibility.stateOf(agent);
493
+ if (state.loaded.has(name))
494
+ return undefined;
495
+ // Only tools this plugin manages: pinned tools and entry tools are always
496
+ // visible, and anything else is hidden by the host or another plugin, which
497
+ // is not ours to override.
498
+ const catalog = getCatalog(agent);
499
+ const tool = catalog.tools.get(name);
500
+ if (!tool)
501
+ return undefined;
502
+ if (tool.highRisk) {
503
+ return `"${name}" is high risk and is not loaded. Load it first with tools_load(["${name}"]), which asks for approval.`;
504
+ }
505
+ // An auto-load is still a load: a before-load veto must stop it too.
506
+ try {
507
+ await ctx.serial('tool-catalog/before-load', { agent, targets: [name], names: [name], rescue: true });
508
+ }
509
+ catch (error) {
510
+ return `Load of "${name}" refused: ${errorText(error)}`;
511
+ }
512
+ visibility.update(agent, (next) => {
513
+ next.loaded.add(name);
514
+ next.excluded.delete(name);
515
+ });
516
+ // A visibility filter (or a connector deny) may still keep it out; say so
517
+ // rather than letting the host report an unknown tool.
518
+ if (!(visibility.allowFor(agent) ?? []).includes(name)) {
519
+ // Undo the load, or a tool the session cannot see would sit in its state
520
+ // (and be persisted) as if it had been loaded.
521
+ visibility.update(agent, (next) => { next.loaded.delete(name); });
522
+ return `"${name}" is not available in this session. Use tools_ls to see what is.`;
523
+ }
524
+ ctx.emit('tool-catalog/auto-loaded', { agent, name });
525
+ return undefined;
526
+ }
527
+ // --- tools another layer hides ---------------------------------------------------
528
+ // Our allowlist intersects with every other restriction on the agent (MCP
529
+ // connector governance, its per-session injection, other plugins), so a tool
530
+ // can be pinned or loaded here and still never reach the model. The host's
531
+ // final view tells them apart; without it nothing is reported.
532
+ function deniedFor(agent) {
533
+ const allow = agent ? visibility.allowFor(agent) : null;
534
+ if (!agent || !allow || typeof ctx.tools.view !== 'function')
535
+ return new Set();
536
+ let visible;
537
+ try {
538
+ visible = ctx.tools.view(agent).visible;
539
+ }
540
+ catch {
541
+ return new Set();
542
+ }
543
+ if (!visible)
544
+ return new Set();
545
+ const cat = getCatalog(agent);
546
+ return new Set(allow.filter((name) => cat.tools.has(name) && !visible.has(name)));
547
+ }
548
+ const warnedDenied = new Set();
549
+ function warnDenied(names) {
550
+ const fresh = names.filter((name) => !warnedDenied.has(name));
551
+ if (!fresh.length)
552
+ return;
553
+ for (const name of fresh)
554
+ warnedDenied.add(name);
555
+ logger?.warn?.(`tool-manager: loaded but hidden by another restriction: ${fresh.join(', ')}. If these are MCP tools, check the connector's governance rules and set the connection to "always inject" (per-session injection narrows on top of this plugin).`);
556
+ }
557
+ // --- listing -------------------------------------------------------------------
558
+ function visibleSetFor(agent) {
559
+ const allow = agent ? visibility.allowFor(agent) : null;
560
+ return allow ? new Set(allow) : null;
561
+ }
562
+ function toolLine(tool, visible, denied, withPath = false) {
563
+ const marks = `${visible?.has(tool.name) ? '*' : ' '}${tool.highRisk ? '!' : ' '}`;
564
+ const label = withPath ? `/${tool.category}/${tool.group}/${tool.short}` : tool.short;
565
+ return ` ${marks} ${pad(label, withPath ? 48 : 34)}${pad(fmtTokens(tool.tokens), 11)}${denied.has(tool.name) ? '[denied] ' : ''}${tool.summary}`;
566
+ }
567
+ function listing(cat, node, visible, denied = new Set()) {
568
+ const count = (tools) => ({
569
+ n: tools.length,
570
+ loaded: tools.filter((t) => visible?.has(t.name)).length,
571
+ tokens: tools.reduce((s, t) => s + t.tokens, 0),
572
+ });
573
+ const lines = [];
574
+ if (node.kind === 'root') {
575
+ lines.push('/ (categories; * = loaded, ! = high risk, [denied] = loaded but blocked by another plugin; tools_ls with query searches everything)');
576
+ for (const category of cat.categories.values()) {
577
+ const c = count(category.groups.flatMap((g) => g.tools));
578
+ if (c.n === 0 && category.name !== UNCATEGORIZED)
579
+ continue;
580
+ lines.push(` ${pad(`${category.name}/`, 16)}${pad(`${c.n} tools`, 10)}${pad(`${c.loaded} loaded`, 11)}${pad(fmtTokens(c.tokens), 11)}${category.description}`);
581
+ }
582
+ }
583
+ else if (node.kind === 'category') {
584
+ lines.push(`/${node.category.name}/ ${node.category.description}`);
585
+ for (const group of node.category.groups) {
586
+ const c = count(group.tools);
587
+ lines.push(` ${pad(`${group.name}/`, 22)}${pad(`${c.n} tools`, 10)}${pad(`${c.loaded} loaded`, 11)}${fmtTokens(c.tokens)}`);
588
+ }
589
+ }
590
+ else if (node.kind === 'group') {
591
+ lines.push(`/${node.group.category}/${node.group.name}/`);
592
+ for (const tool of node.group.tools)
593
+ lines.push(toolLine(tool, visible, denied));
594
+ }
595
+ else if (node.kind === 'tool') {
596
+ const t = node.tool;
597
+ lines.push(`/${t.category}/${t.group}/${t.short}`, ` name: ${t.name}`, ` ${t.summary}`, ` schema ${fmtTokens(t.tokens)}${t.highRisk ? ' · high risk: load it on its own' : ''}${visible?.has(t.name) ? ' · loaded' : ''}${denied.has(t.name) ? ' · denied by another plugin (e.g. an MCP connector rule): not callable' : ''}`);
598
+ }
599
+ return lines.join('\n');
600
+ }
601
+ /** Tools under `node` matching every word of `query` (name, group, category, description). */
602
+ function matchingTools(cat, node, query) {
603
+ const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
604
+ if (!words.length)
605
+ return [];
606
+ return toolsUnder(cat, node).filter((t) => {
607
+ const hay = `${t.name} ${t.group} ${t.category} ${t.description}`.toLowerCase();
608
+ return words.every((w) => hay.includes(w));
609
+ });
610
+ }
611
+ function search(cat, node, query, visible, denied = new Set()) {
612
+ const hits = matchingTools(cat, node, query);
613
+ if (!hits.length)
614
+ return `No tools match "${query}".`;
615
+ const shown = hits.slice(0, 40);
616
+ return [`${hits.length} match(es) for "${query}" (load by path or full name):`, ...shown.map((t) => toolLine(t, visible, denied, true)), hits.length > shown.length ? ` … ${hits.length - shown.length} more; narrow the query or the path` : ''].filter(Boolean).join('\n');
617
+ }
618
+ function agentOf(exec) {
619
+ if (!exec?.agent)
620
+ throw new Error('tool-manager: this tool must be called by an agent');
621
+ return exec.agent;
622
+ }
623
+ // --- load / unload (shared by the entry tools and the toolCatalog service) -------
624
+ /**
625
+ * Subscribe an agent to targets. Runs the before-load hook (which may veto) and
626
+ * returns the model-facing text; the panel calls this same path, so hooks
627
+ * apply identically; risk-gate approval does not, since a panel click is
628
+ * itself the user's consent.
629
+ */
630
+ async function loadTargets(agent, targets, { forSubagents = false } = {}) {
631
+ const cat = getCatalog(agent);
632
+ const nodes = targets.map((raw) => ({ raw, node: resolveTarget(cat, raw, [...registries.resolvers]) }));
633
+ const names = nodes.flatMap(({ node }) => (node.kind === 'root' || node.kind === 'missing' ? [] : toolsUnder(cat, node).map((t) => t.name)));
634
+ try {
635
+ await ctx.serial('tool-catalog/before-load', { agent, targets, names });
636
+ }
637
+ catch (error) {
638
+ return `Load refused: ${errorText(error)}`;
639
+ }
640
+ const before = new Set(visibility.allowFor(agent) ?? []);
641
+ const notes = [];
642
+ visibility.update(agent, (state) => {
643
+ for (const { raw, node } of nodes) {
644
+ if (node.kind === 'missing' || node.kind === 'root') {
645
+ notes.push(`skipped ${raw}: ${node.kind === 'root' ? 'cannot load the whole tree' : 'not found (try tools_ls with query)'}`);
646
+ continue;
647
+ }
648
+ // An alias (e.g. a capability pack) names exact tools: add those, never
649
+ // the whole group they happen to live in.
650
+ if (node.kind === 'category' && node.category.aliasTools) {
651
+ for (const tool of node.category.aliasTools) {
652
+ if (tool.highRisk && riskGates.size === 0) {
653
+ notes.push(`${tool.name} is high risk and no risk gate is installed to confirm it; not loaded`);
654
+ continue;
655
+ }
656
+ state.loaded.add(tool.name);
657
+ state.excluded.delete(tool.name);
658
+ if (forSubagents)
659
+ state.handoffTools.add(tool.name);
660
+ }
661
+ continue;
662
+ }
663
+ const groups = node.kind === 'category' ? node.category.groups : node.kind === 'group' ? [node.group] : [];
664
+ for (const group of groups) {
665
+ state.groups.add(group.name);
666
+ if (forSubagents)
667
+ state.handoffGroups.add(group.name);
668
+ for (const tool of group.tools)
669
+ state.excluded.delete(tool.name);
670
+ const risky = group.tools.filter((t) => t.highRisk).map((t) => t.short);
671
+ if (risky.length)
672
+ notes.push(`${group.name}: high-risk tools not included (${risky.join(', ')}); load them one by one`);
673
+ }
674
+ if (node.kind === 'tool') {
675
+ if (node.tool.highRisk && riskGates.size === 0) {
676
+ notes.push(`${node.tool.name} is high risk and no risk gate is installed to confirm it; not loaded`);
677
+ continue;
678
+ }
679
+ state.loaded.add(node.tool.name);
680
+ state.excluded.delete(node.tool.name);
681
+ if (forSubagents)
682
+ state.handoffTools.add(node.tool.name);
683
+ }
684
+ }
685
+ });
686
+ const after = visibility.allowFor(agent) ?? [];
687
+ const added = after.filter((n) => !before.has(n));
688
+ ctx.emit('tool-catalog/after-load', { agent, targets, added });
689
+ const denied = deniedFor(agent);
690
+ const blocked = added.filter((n) => denied.has(n));
691
+ const callable = added.filter((n) => !denied.has(n));
692
+ if (blocked.length)
693
+ warnDenied(blocked);
694
+ const tokens = callable.reduce((sum, n) => sum + (cat.tools.get(n)?.tokens ?? 0), 0);
695
+ return [
696
+ callable.length ? `Loaded ${callable.length} tool(s), ${fmtTokens(tokens)}; callable from your next step:` : 'Nothing new loaded.',
697
+ ...callable.map((n) => ` ${n}`),
698
+ ...(blocked.length ? [`Denied by another plugin (e.g. an MCP connector rule), not callable even when loaded: ${blocked.join(', ')}`] : []),
699
+ ...notes,
700
+ ].join('\n');
701
+ }
702
+ /** Hide targets again. Returns the model-facing text. */
703
+ async function unloadTargets(agent, targets) {
704
+ const cat = getCatalog(agent);
705
+ try {
706
+ await ctx.serial('tool-catalog/before-unload', { agent, targets });
707
+ }
708
+ catch (error) {
709
+ return `Unload refused: ${errorText(error)}`;
710
+ }
711
+ const before = new Set(visibility.allowFor(agent) ?? []);
712
+ visibility.update(agent, (state) => {
713
+ for (const raw of targets) {
714
+ if (String(raw).trim().toLowerCase() === 'all') {
715
+ for (const set of Object.values(state))
716
+ set.clear();
717
+ continue;
718
+ }
719
+ const node = resolveTarget(cat, raw, [...registries.resolvers]);
720
+ if (node.kind === 'category' && node.category.aliasTools) {
721
+ for (const tool of node.category.aliasTools) {
722
+ state.loaded.delete(tool.name);
723
+ state.handoffTools.delete(tool.name);
724
+ }
725
+ continue;
726
+ }
727
+ const groups = node.kind === 'category' ? node.category.groups : node.kind === 'group' ? [node.group] : [];
728
+ for (const group of groups) {
729
+ state.groups.delete(group.name);
730
+ state.handoffGroups.delete(group.name);
731
+ for (const tool of group.tools)
732
+ state.loaded.delete(tool.name);
733
+ }
734
+ if (node.kind === 'tool') {
735
+ state.loaded.delete(node.tool.name);
736
+ state.handoffTools.delete(node.tool.name);
737
+ if (state.groups.has(node.tool.group))
738
+ state.excluded.add(node.tool.name);
739
+ }
740
+ }
741
+ });
742
+ const after = new Set(visibility.allowFor(agent) ?? []);
743
+ const removed = [...before].filter((n) => !after.has(n));
744
+ ctx.emit('tool-catalog/after-unload', { agent, targets, removed });
745
+ return removed.length ? `Unloaded ${removed.length} tool(s):\n${removed.map((n) => ` ${n}`).join('\n')}` : 'Nothing was unloaded.';
746
+ }
747
+ // --- tools -----------------------------------------------------------------------
748
+ ctx.tools.register(defineTool({
749
+ name: 'tools_ls',
750
+ description: 'Browse every available tool as a directory: / lists categories, /<category> lists groups (a connector or plugin), /<category>/<group> lists tools with a one-line summary and schema size. Tools marked * are loaded (callable now). Most tools are hidden until loaded with tools_load. Pass query to search names and descriptions across the whole tree (or under path) in one step.',
751
+ parameters: {
752
+ path: { type: 'string', description: 'Directory path such as "/", "/code", "/code/github", or a group or tool name. Default "/".' },
753
+ query: { type: 'string', description: 'Search words matched against tool name, group, category and description; searches everything under path.' },
754
+ },
755
+ output: TEXT_OUTPUT,
756
+ isConcurrencySafe: () => true,
757
+ async execute(args, exec) {
758
+ const cat = getCatalog(exec?.agent);
759
+ const node = resolveTarget(cat, args.path ?? '/', [...registries.resolvers]);
760
+ if (node.kind === 'missing')
761
+ return `No such path: ${node.target}. Start from tools_ls("/") or search with tools_ls({query}).`;
762
+ const agent = exec?.agent;
763
+ const visible = visibleSetFor(agent);
764
+ const denied = deniedFor(agent);
765
+ return args.query ? search(cat, node, args.query, visible, denied) : listing(cat, node, visible, denied);
766
+ },
767
+ }));
768
+ ctx.tools.register(defineTool({
769
+ name: 'tools_load',
770
+ description: 'Make tools callable for the rest of this session. Each target is a group path ("/code/github"), a category ("/maps"), or one tool by path or exact name ("mcp__github__get_file_contents"). Loading a group also includes tools the group gains later. Prefer one group load over many single tools. Loaded tools are available from your next step. Set forSubagents to also hand these targets to subagents you start afterwards.',
771
+ parameters: {
772
+ targets: { type: 'array', items: { type: 'string' }, required: true, description: 'Paths, group names or exact tool names.' },
773
+ forSubagents: { type: 'boolean', description: 'Also give these targets to subagents started from now on.' },
774
+ },
775
+ output: TEXT_OUTPUT,
776
+ async execute(args, exec) {
777
+ return loadTargets(agentOf(exec), args.targets ?? [], { forSubagents: args.forSubagents === true });
778
+ },
779
+ }));
780
+ ctx.tools.register(defineTool({
781
+ name: 'tools_unload',
782
+ description: 'Hide loaded tools again to keep context small. Targets use the same forms as tools_load; "all" returns to the always-on set. Always-on tools cannot be unloaded.',
783
+ parameters: {
784
+ targets: { type: 'array', items: { type: 'string' }, required: true, description: 'Paths, group names, exact tool names, or "all".' },
785
+ },
786
+ output: TEXT_OUTPUT,
787
+ async execute(args, exec) {
788
+ return unloadTargets(agentOf(exec), args.targets ?? []);
789
+ },
790
+ }));
791
+ }
792
+ /** The message of a thrown value, whatever was thrown. */
793
+ function errorText(error) {
794
+ return String(error?.message ?? error);
795
+ }
796
+ export default { name, inject, apply };
797
+ //# sourceMappingURL=index.js.map