@lvy-jsd/dsh-tool-manager 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts ADDED
@@ -0,0 +1,207 @@
1
+ // The `toolCatalog` service: the extension surface other plugins build on.
2
+ //
3
+ // registerClassifier({ name, priority, group?(tool), categorize?(group) })
4
+ // annotate(match, { group?, category?, risk?, summary? })
5
+ // defineCategory(name, description)
6
+ // registerVisibilityFilter({ name, filter(agent, names) })
7
+ // refresh(), get(), resolve(target), stateOf(agent)
8
+ //
9
+ // Every register/annotate/define call returns a disposer. Extension plugins
10
+ // should register inside an effect so unloading them undoes the registration:
11
+ // ctx.effect(() => ctx.toolCatalog.registerClassifier({...}))
12
+ //
13
+ // Hooks are plain Cordis events on the host context:
14
+ // tool-catalog/before-load (serial; throw to veto) { agent, targets, names }
15
+ // also fired by the auto-load of a directly called
16
+ // hidden tool, with `rescue: true` in the payload
17
+ // tool-catalog/after-load (emit) { agent, targets, added }
18
+ // tool-catalog/before-unload (serial; throw to veto) { agent, targets }
19
+ // tool-catalog/after-unload (emit) { agent, targets, removed }
20
+ // tool-catalog/before-rebuild (emit) { }
21
+ // tool-catalog/after-rebuild (emit) { catalog }
22
+
23
+ import { readFileSync, existsSync } from 'node:fs'
24
+ import { createRequire } from 'node:module'
25
+ import { dirname, join } from 'node:path'
26
+ import { fileURLToPath, pathToFileURL } from 'node:url'
27
+ import { DEFAULT_CATEGORIES } from './catalog.js'
28
+ import type { Annotation, Classifier, Disposer, HostContext, Loader, Logger, PluginPackage, RegisteredResolver, TargetResolver, ToolMeta, VisibilityFilter } from './types.js'
29
+
30
+ /** A classifier after registration (defaults filled in). */
31
+ export interface RegisteredClassifier extends Classifier {
32
+ name: string
33
+ priority: number
34
+ }
35
+
36
+ /** A visibility filter after registration (defaults filled in). */
37
+ export interface RegisteredFilter {
38
+ name: string
39
+ filter: VisibilityFilter['filter']
40
+ }
41
+
42
+ /** Package name of a loader specifier: "@scope/pkg/sub" -> "@scope/pkg", "pkg/sub" -> "pkg". */
43
+ export function packageNameOf(specifier: string): string {
44
+ const parts = String(specifier).split('/')
45
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier
46
+ }
47
+
48
+ /** Find a package's package.json starting from a resolvable entry file. */
49
+ function findManifest(requireFrom: NodeJS.Require, pkg: string): string | undefined {
50
+ try {
51
+ return requireFrom.resolve(`${pkg}/package.json`)
52
+ } catch {
53
+ // exports may hide package.json; walk up from the main entry instead.
54
+ }
55
+ try {
56
+ let dir = dirname(requireFrom.resolve(pkg))
57
+ while (dir !== dirname(dir)) {
58
+ const candidate = join(dir, 'package.json')
59
+ if (existsSync(candidate)) {
60
+ const json = JSON.parse(readFileSync(candidate, 'utf8'))
61
+ if (json.name === pkg) return candidate
62
+ }
63
+ dir = dirname(dir)
64
+ }
65
+ } catch {
66
+ // unresolvable from here
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ /**
72
+ * Read `dsh.toolCatalog` declarations from every plugin package the host loader
73
+ * knows. Declaration format (package.json):
74
+ *
75
+ * "dsh": { "toolCatalog": {
76
+ * "match": "^adb_", // regex over tool names (optional)
77
+ * "group": "adb", "category": "device",
78
+ * "tools": { "adb_install": { "risk": "high", "summary": "..." } }
79
+ * } }
80
+ *
81
+ * `group`/`category`/`risk`/`summary` at the top level apply to `match`;
82
+ * entries under `tools` apply to exact names and win over the top level.
83
+ */
84
+ export function readManifestAnnotations(ctx: Pick<HostContext, 'get' | 'baseUrl'>, logger?: Logger): Annotation[] {
85
+ const annotations: Annotation[] = []
86
+ for (const { name: pkg, manifest: manifestPath } of listPluginPackages(ctx)) {
87
+ try {
88
+ const declared = JSON.parse(readFileSync(manifestPath, 'utf8'))?.dsh?.toolCatalog
89
+ if (!declared) continue
90
+ const top = pick(declared)
91
+ if (declared.match && Object.keys(top).length) annotations.push({ source: pkg, match: new RegExp(declared.match), meta: top })
92
+ for (const [name, meta] of Object.entries((declared.tools ?? {}) as Record<string, unknown>)) {
93
+ annotations.push({ source: pkg, match: name, meta: { ...top, ...pick(meta) } })
94
+ }
95
+ } catch (error) {
96
+ logger?.warn?.(`tool-manager: bad dsh.toolCatalog in ${pkg}: ${(error as Error).message}`)
97
+ }
98
+ }
99
+ return annotations
100
+ }
101
+
102
+ /**
103
+ * Every enabled plugin package the host loader knows, as
104
+ * `{ name, dir, manifest }`. Relative (local) entries are skipped.
105
+ */
106
+ export function listPluginPackages(ctx: Pick<HostContext, 'get' | 'baseUrl'>): PluginPackage[] {
107
+ const loader = ctx.get?.('loader') as Loader | undefined
108
+ if (!loader?.entries) return []
109
+ const bases = [loader.baseUrl, loader.ctx?.baseUrl, ctx.baseUrl, pathToFileURL(join(process.cwd(), 'noop.js')).href].filter((b): b is string => Boolean(b))
110
+ const seen = new Set<string>()
111
+ const out: PluginPackage[] = []
112
+ for (const entry of loader.entries()) {
113
+ const spec = entry?.options?.name
114
+ if (typeof spec !== 'string' || spec.startsWith('.') || entry.options?.disabled === true) continue
115
+ const pkg = packageNameOf(spec)
116
+ if (seen.has(pkg)) continue
117
+ seen.add(pkg)
118
+ for (const base of bases) {
119
+ const from = createRequire(base.startsWith('file:') ? fileURLToPath(base) : join(base, 'noop.js'))
120
+ const manifest = findManifest(from, pkg)
121
+ if (manifest) {
122
+ out.push({ name: pkg, dir: dirname(manifest), manifest })
123
+ break
124
+ }
125
+ }
126
+ }
127
+ return out
128
+ }
129
+
130
+ const META_KEYS = ['group', 'category', 'risk', 'summary'] as const
131
+
132
+ function pick(meta: unknown = {}): ToolMeta {
133
+ const out: ToolMeta = {}
134
+ if (typeof meta !== 'object' || meta === null) return out
135
+ for (const key of META_KEYS) {
136
+ const value = (meta as Record<string, unknown>)[key]
137
+ if (typeof value === 'string' && value) out[key] = value
138
+ }
139
+ return out
140
+ }
141
+
142
+ /** Registries behind the service. `onChange` rebuilds the catalog. */
143
+ export function createRegistries(onChange: () => void) {
144
+ const classifiers = new Set<RegisteredClassifier>()
145
+ const annotations: Annotation[] = []
146
+ const categories = new Map(Object.entries(DEFAULT_CATEGORIES))
147
+ const filters = new Set<RegisteredFilter>()
148
+ const resolvers = new Set<RegisteredResolver>()
149
+
150
+ const remover = (fn: () => unknown): Disposer => {
151
+ let done = false
152
+ return () => {
153
+ if (done) return
154
+ done = true
155
+ fn()
156
+ onChange()
157
+ }
158
+ }
159
+
160
+ return {
161
+ classifiers,
162
+ annotations,
163
+ categories,
164
+ filters,
165
+ resolvers,
166
+ registerClassifier(classifier: Classifier): Disposer {
167
+ if (!classifier || (typeof classifier.group !== 'function' && typeof classifier.categorize !== 'function')) {
168
+ throw new TypeError('registerClassifier: provide group(tool) and/or categorize(group)')
169
+ }
170
+ const entry: RegisteredClassifier = { name: classifier.name ?? 'anonymous', priority: Number(classifier.priority ?? 0), group: classifier.group, categorize: classifier.categorize }
171
+ classifiers.add(entry)
172
+ onChange()
173
+ return remover(() => classifiers.delete(entry))
174
+ },
175
+ annotate(match: string | RegExp, meta: ToolMeta): Disposer {
176
+ const entry: Annotation = { source: 'runtime', match: typeof match === 'string' ? match : new RegExp(match), meta: pick(meta) }
177
+ annotations.push(entry)
178
+ onChange()
179
+ return remover(() => annotations.splice(annotations.indexOf(entry), 1))
180
+ },
181
+ defineCategory(name: string, description = ''): Disposer {
182
+ const previous = categories.get(name)
183
+ categories.set(name, description)
184
+ onChange()
185
+ return remover(() => (previous === undefined ? categories.delete(name) : categories.set(name, previous)))
186
+ },
187
+ registerVisibilityFilter(filter: VisibilityFilter): Disposer {
188
+ if (typeof filter?.filter !== 'function') throw new TypeError('registerVisibilityFilter: provide filter(agent, names)')
189
+ const entry = { name: filter.name ?? 'anonymous', filter: filter.filter }
190
+ filters.add(entry)
191
+ onChange()
192
+ return remover(() => filters.delete(entry))
193
+ },
194
+ /**
195
+ * Own a target prefix in tools_load / tools_ls / tools_unload. `resolve` gets
196
+ * the raw target and returns one or more ordinary targets (paths, group or
197
+ * tool names), or undefined to pass.
198
+ */
199
+ registerTargetResolver(resolver: TargetResolver): Disposer {
200
+ if (typeof resolver?.resolve !== 'function') throw new TypeError('registerTargetResolver: provide resolve(target)')
201
+ const entry = { name: resolver.name ?? 'anonymous', prefix: String(resolver.prefix ?? ''), resolve: resolver.resolve }
202
+ resolvers.add(entry)
203
+ onChange()
204
+ return remover(() => resolvers.delete(entry))
205
+ },
206
+ }
207
+ }
package/src/types.ts ADDED
@@ -0,0 +1,319 @@
1
+ // Types shared by the core modules and exported for extension plugins.
2
+ //
3
+ // The host surface is described structurally (only what this plugin touches)
4
+ // instead of importing the host's Cordis types, so the package type-checks
5
+ // without the host packages installed next to it.
6
+
7
+ // --- catalog -------------------------------------------------------------------
8
+
9
+ /** A tool schema as the host lists it. */
10
+ export interface ToolSchema {
11
+ name: string
12
+ description?: string
13
+ parameters?: unknown
14
+ [key: string]: unknown
15
+ }
16
+
17
+ /** Metadata a plugin can attach to tools (package.json `dsh.toolCatalog` or annotate()). */
18
+ export interface ToolMeta {
19
+ group?: string
20
+ category?: string
21
+ risk?: string
22
+ summary?: string
23
+ }
24
+
25
+ export interface Annotation {
26
+ source?: string
27
+ match: string | RegExp
28
+ meta: ToolMeta
29
+ }
30
+
31
+ export interface ToolEntry {
32
+ name: string
33
+ /** Name inside its group (MCP routing prefix dropped). */
34
+ short: string
35
+ group: string
36
+ /** Where the structural fallback alone would have put the tool. */
37
+ fallbackGroup: string
38
+ category: string
39
+ summary: string
40
+ description: string
41
+ tokens: number
42
+ highRisk: boolean
43
+ annotation: ToolMeta
44
+ }
45
+
46
+ export interface Group {
47
+ name: string
48
+ category: string
49
+ tools: ToolEntry[]
50
+ }
51
+
52
+ export interface Category {
53
+ name: string
54
+ description: string
55
+ groups: Group[]
56
+ /** Set on a synthetic alias node (several targets behind one name). */
57
+ alias?: string[]
58
+ /** The exact tools an alias stands for; load these, never their whole groups. */
59
+ aliasTools?: ToolEntry[]
60
+ }
61
+
62
+ export interface Catalog {
63
+ tools: Map<string, ToolEntry>
64
+ groups: Map<string, Group>
65
+ categories: Map<string, Category>
66
+ }
67
+
68
+ export type CatalogNode =
69
+ | { kind: 'root' }
70
+ | { kind: 'category', category: Category }
71
+ | { kind: 'group', group: Group }
72
+ | { kind: 'tool', tool: ToolEntry }
73
+ | { kind: 'missing', target: string }
74
+
75
+ /** What a classifier sees of one tool. */
76
+ export interface ToolProbe {
77
+ name: string
78
+ description: string
79
+ annotation: ToolMeta
80
+ }
81
+
82
+ /** What a classifier sees of one group. */
83
+ export interface GroupProbe {
84
+ name: string
85
+ tools: ToolProbe[]
86
+ }
87
+
88
+ export interface Classifier {
89
+ name?: string
90
+ priority?: number
91
+ /** Group name for a tool, or undefined to pass. */
92
+ group?(tool: ToolProbe): string | undefined
93
+ /** Category for a group, or undefined to pass. */
94
+ categorize?(group: GroupProbe): string | undefined
95
+ }
96
+
97
+ export interface VisibilityFilter {
98
+ name?: string
99
+ /** Names to keep out of `names`; may only narrow. */
100
+ filter(agent: Agent, names: string[]): Iterable<string> | undefined | null
101
+ }
102
+
103
+ export interface TargetResolver {
104
+ name?: string
105
+ /** Targets starting with this prefix are offered to `resolve` first. */
106
+ prefix?: string
107
+ /** One or more ordinary targets to resolve instead, or undefined to pass. */
108
+ resolve(target: string): string | string[] | undefined | null
109
+ }
110
+
111
+ /** A registry entry after registration (defaults filled in). */
112
+ export interface RegisteredResolver {
113
+ name: string
114
+ prefix: string
115
+ resolve: TargetResolver['resolve']
116
+ }
117
+
118
+ export type Disposer = () => void
119
+
120
+ // --- per-agent state ---------------------------------------------------------
121
+
122
+ export interface LoadState {
123
+ /** Individual tool names. */
124
+ loaded: Set<string>
125
+ /** Subscribed group names (tools the group gains later included). */
126
+ groups: Set<string>
127
+ /** Tools unloaded from inside a subscribed group. */
128
+ excluded: Set<string>
129
+ /** Groups handed to subagents started later. */
130
+ handoffGroups: Set<string>
131
+ /** Tools handed to subagents started later. */
132
+ handoffTools: Set<string>
133
+ }
134
+
135
+ /** Plain JSON form of a LoadState (sorted arrays). */
136
+ export type SerializedState = { [K in keyof LoadState]: string[] }
137
+
138
+ export interface StateProvider {
139
+ name?: string
140
+ /** A serialized state for the agent, or null when there is none. */
141
+ load?(agent: Agent): Partial<SerializedState> | null | undefined
142
+ save?(agent: Agent, state: SerializedState): void
143
+ }
144
+
145
+ export interface RiskGate {
146
+ name?: string
147
+ }
148
+
149
+ // --- host surface --------------------------------------------------------------
150
+
151
+ export interface Logger {
152
+ debug?(message: string): void
153
+ info?(message: string): void
154
+ warn?(message: string): void
155
+ }
156
+
157
+ export interface SessionEvent {
158
+ type?: string
159
+ data?: { provider?: string, [key: string]: unknown }
160
+ }
161
+
162
+ export interface Session {
163
+ id?: unknown
164
+ seq?: number
165
+ eventAt?(seq: number): SessionEvent | undefined
166
+ requestHeader?(): { provider?: string } | undefined
167
+ }
168
+
169
+ /** An agent as this plugin uses it. */
170
+ export interface Agent {
171
+ id: unknown
172
+ options?: { provider?: string }
173
+ session?: Session
174
+ ctx?: {
175
+ tools?: {
176
+ restrict?(spec: { allow?: string[], deny?: string[] }): Disposer
177
+ }
178
+ }
179
+ }
180
+
181
+ export interface AgentsApi {
182
+ list(): Agent[]
183
+ get?(id: unknown): Agent | undefined
184
+ isOwnedBy?(childId: unknown, candidate: Agent): boolean
185
+ }
186
+
187
+ /** Context a plugin receives once `agents` is injected. */
188
+ export interface AgentContext {
189
+ agents: AgentsApi
190
+ effect(fn: () => Disposer | void, label?: string): void
191
+ // any: each event passes its own argument types, and a handler typed
192
+ // (...args: unknown[]) would reject every concretely typed listener.
193
+ on(event: string, handler: (...args: any[]) => unknown): Disposer
194
+ }
195
+
196
+ export interface ToolLayer {
197
+ tools: Map<string, { description?: string, parameters?: unknown }>
198
+ }
199
+
200
+ export interface HostTools {
201
+ schemas(agent?: Agent): ToolSchema[]
202
+ register(definition: unknown): Disposer | void
203
+ view?(agent: Agent): { restrictableNames: Iterable<string> }
204
+ schemaOf?(definition: unknown, deferred: boolean): ToolSchema
205
+ layers?: {
206
+ chainLayers?(agent: Agent): Iterable<ToolLayer>
207
+ peek?(agent: Agent): ToolLayer | undefined
208
+ }
209
+ }
210
+
211
+ export interface LoaderEntry {
212
+ options?: { name?: unknown, disabled?: boolean }
213
+ }
214
+
215
+ export interface Loader {
216
+ entries?(): Iterable<LoaderEntry>
217
+ baseUrl?: string
218
+ ctx?: { baseUrl?: string }
219
+ }
220
+
221
+ /** The host context the core plugin is applied to. */
222
+ export interface HostContext {
223
+ tools: HostTools
224
+ logger?: ((name: string) => Logger) | Logger
225
+ baseUrl?: string
226
+ get?(name: string): unknown
227
+ emit(event: string, ...args: unknown[]): void
228
+ serial(event: string, ...args: unknown[]): Promise<unknown>
229
+ // any: each event passes its own argument types, and a handler typed
230
+ // (...args: unknown[]) would reject every concretely typed listener.
231
+ on(event: string, handler: (...args: any[]) => unknown): Disposer
232
+ effect(fn: () => Disposer | void, label?: string): void
233
+ inject?(deps: string[], fn: (ctx: AgentContext) => void): void
234
+ provide(name: string, service: unknown): void
235
+ }
236
+
237
+ /** Configuration of the core plugin. */
238
+ export interface CoreConfig {
239
+ /** Extra always-on tools. */
240
+ pinned?: string[]
241
+ /** Tools removed from the default always-on list. */
242
+ unpin?: string[]
243
+ /** Tool names treated as high risk. */
244
+ highRisk?: string[]
245
+ /** Model routes never narrowed (default ["claude-code"]). */
246
+ exemptProviders?: string[]
247
+ }
248
+
249
+ /** A plugin package the host loader knows. */
250
+ export interface PluginPackage {
251
+ name: string
252
+ dir: string
253
+ manifest: string
254
+ }
255
+
256
+ // --- the toolCatalog service -----------------------------------------------------
257
+
258
+ /** A search hit from toolCatalog.find(). */
259
+ export interface FoundTool {
260
+ name: string
261
+ short: string
262
+ path: string
263
+ category: string
264
+ group: string
265
+ summary: string
266
+ tokens: number
267
+ highRisk: boolean
268
+ }
269
+
270
+ /** The service the core provides as `toolCatalog` (inject it to extend the catalog). */
271
+ export interface ToolCatalogService {
272
+ registerClassifier(classifier: Classifier): Disposer
273
+ annotate(match: string | RegExp, meta: ToolMeta): Disposer
274
+ defineCategory(name: string, description?: string): Disposer
275
+ registerVisibilityFilter(filter: VisibilityFilter): Disposer
276
+ /** Own a target prefix (`"cap:"`) in tools_load / tools_ls / tools_unload. */
277
+ registerTargetResolver(resolver: TargetResolver): Disposer
278
+ /** Let loaded state outlive the process (see dsh-tool-manager-persist). */
279
+ registerStateProvider(provider?: StateProvider): Disposer
280
+ /** Declare that a plugin confirms high-risk loads; without one they are refused. */
281
+ registerRiskGate(gate?: RiskGate): Disposer
282
+ riskGates(): string[]
283
+ /** Re-read package declarations and rebuild. */
284
+ refresh(): void
285
+ /** Catalog as one agent sees it, or the union over live agents. */
286
+ get(agent?: Agent): Catalog
287
+ resolve(target: string, agent?: Agent): CatalogNode
288
+ stateOf(agent: Agent): LoadState
289
+ exportState(agent: Agent): SerializedState
290
+ importState(agent: Agent, state: Partial<SerializedState>): void
291
+ /** Names visible to the agent, or null when this plugin does not restrict it. */
292
+ allowFor(agent: Agent): string[] | null
293
+ /** True when the agent's model route is in `exemptProviders` (never narrowed). */
294
+ isExempt(agent: Agent): boolean
295
+ /** Load through the same path as tools_load; returns the model-facing text. */
296
+ load(agent: Agent, targets: readonly string[], options?: { forSubagents?: boolean }): Promise<string>
297
+ unload(agent: Agent, targets: readonly string[]): Promise<string>
298
+ /** Live agents, each with its parent when it is a subagent. */
299
+ agents(): { agent: Agent, id: string, parentId: string | null }[]
300
+ config(): { pinned: string[], entryTools: string[], extraPinned: string[], unpinned: string[], highRisk: string[] }
301
+ packages(): PluginPackage[]
302
+ extensions(): {
303
+ classifiers: { name: string, priority: number }[]
304
+ filters: string[]
305
+ annotations: { source: string | undefined, match: string }[]
306
+ categories: string[]
307
+ riskGates: string[]
308
+ stateProviders: string[]
309
+ }
310
+ /** Structured cross-directory search (same matching as tools_ls with a query). */
311
+ find(query: string, path?: string): FoundTool[]
312
+ /** Schema tokens actually sent for an agent: pinned tools plus its loaded set. */
313
+ wireTokens(agent: Agent): number
314
+ }
315
+
316
+ /** What an extension plugin uses of its context after `inject: ['toolCatalog']`. */
317
+ export interface ExtensionContext extends Pick<HostContext, 'logger' | 'effect' | 'on'> {
318
+ toolCatalog: ToolCatalogService
319
+ }