@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/catalog.ts ADDED
@@ -0,0 +1,305 @@
1
+ // Builds the three-level tool directory: category -> group -> tool.
2
+ //
3
+ // The core knows no rules. Placement comes from, in order:
4
+ // 1. override classifiers (priority >= OVERRIDE_PRIORITY), e.g. user config
5
+ // 2. annotations declared by plugins (package.json `dsh.toolCatalog`, annotate())
6
+ // 3. ordinary classifiers, highest priority first
7
+ // 4. a structural fallback: group from the tool name, category `uncategorized`
8
+ //
9
+ // Grouping never decides visibility; a misplaced tool is only listed in the
10
+ // wrong place, never lost.
11
+
12
+ import type { Annotation, Catalog, CatalogNode, Category, Classifier, Group, GroupProbe, RegisteredResolver, ToolEntry, ToolMeta, ToolProbe, ToolSchema } from './types.js'
13
+
14
+ export const UNCATEGORIZED = 'uncategorized'
15
+ export const MISC_GROUP = 'misc'
16
+ export const OVERRIDE_PRIORITY = 1000
17
+
18
+ // The fixed category vocabulary. Plugins may add more with defineCategory().
19
+ export const DEFAULT_CATEGORIES: Record<string, string> = {
20
+ device: 'Phones, Android, ADB and desktop control',
21
+ code: 'Source hosting, repositories and package tooling',
22
+ media: 'Video, image and audio services',
23
+ maps: 'Maps, geocoding and routing',
24
+ browser: 'Headless browser automation',
25
+ memory: 'Long-term memory and knowledge stores',
26
+ collaboration: 'Bots, group chats, teammates and other sessions',
27
+ automation: 'Schedules and automated runs',
28
+ connectors: 'MCP connector administration',
29
+ search: 'Web and social search',
30
+ system: 'Host, OS and secrets',
31
+ [UNCATEGORIZED]: 'Tools no classifier has placed yet',
32
+ }
33
+
34
+ // Leading words that describe an action rather than an owner; a shared verb
35
+ // prefix ("list_", "get_") is not a meaningful group.
36
+ const VERB_PREFIXES = new Set([
37
+ 'add', 'ask', 'attach', 'create', 'delete', 'detach', 'edit', 'exit', 'get',
38
+ 'interrupt', 'list', 'message', 'open', 'post', 'read', 'remove', 'request',
39
+ 'run', 'save', 'send', 'set', 'spawn', 'stop', 'update', 'wait', 'write',
40
+ ])
41
+
42
+ /**
43
+ * Structural group for a tool name, used when nothing else places it:
44
+ * `mcp__server__tool` -> server, `mcp__server__sub__tool` -> server-sub,
45
+ * `prefix_rest` -> prefix when at least two tools share a non-verb prefix, else misc.
46
+ */
47
+ export function structuralGroup(name: string, prefixCounts: Map<string, number>): string {
48
+ if (name.startsWith('mcp__')) {
49
+ const parts = name.slice(5).split('__')
50
+ if (parts.length >= 3) return `${parts[0]}-${parts[1]}`
51
+ return parts[0]
52
+ }
53
+ const prefix = name.split('_')[0]
54
+ if (prefix !== name && !VERB_PREFIXES.has(prefix) && (prefixCounts.get(prefix) ?? 0) >= 2) return prefix
55
+ return MISC_GROUP
56
+ }
57
+
58
+ /** Short form of a tool name inside its group (drops the MCP routing prefix). */
59
+ export function shortName(name: string): string {
60
+ if (!name.startsWith('mcp__')) return name
61
+ const parts = name.slice(5).split('__')
62
+ return parts[parts.length - 1] ?? name
63
+ }
64
+
65
+ /** First sentence of a description, capped for one-line listings. */
66
+ export function summary(text: string | undefined, max = 80): string {
67
+ const flat = String(text ?? '').replace(/\s+/g, ' ').trim()
68
+ const first = flat.split(/(?<=[.!?。!?])\s/)[0] ?? ''
69
+ return first.length > max ? `${first.slice(0, max - 1)}…` : first
70
+ }
71
+
72
+ /** Rough token cost of one schema as the model receives it. */
73
+ export function estimateTokens(schema: unknown): number {
74
+ const text = JSON.stringify(schema)
75
+ // CJK and other wide characters cost roughly one token each, not a quarter.
76
+ let wide = 0
77
+ for (const ch of text) if ((ch.codePointAt(0) ?? 0) > 0x2e7f) wide += 1
78
+ return Math.ceil((text.length - wide) / 4 + wide)
79
+ }
80
+
81
+ /** Merge every annotation that applies to `name`; later annotations win per field. */
82
+ function annotationFor(name: string, annotations: readonly Annotation[]): ToolMeta {
83
+ let merged: ToolMeta | undefined
84
+ for (const a of annotations) {
85
+ const hit = typeof a.match === 'string' ? a.match === name : a.match.test(name)
86
+ if (hit) merged = { ...merged, ...a.meta }
87
+ }
88
+ return merged ?? {}
89
+ }
90
+
91
+ /**
92
+ * A group a classifier renamed (e.g. attribution-scan: `phone` -> `phone-control`)
93
+ * is unknown to category rules written for the old name. Ask the classifiers
94
+ * about the structural groups its tools came from instead; the category covering
95
+ * the most tools wins.
96
+ */
97
+ function inheritedCategory(group: Group, classifiers: readonly Classifier[]): string | undefined {
98
+ const byFallback = new Map<string, ToolEntry[]>()
99
+ for (const tool of group.tools) {
100
+ if (tool.fallbackGroup === group.name) continue
101
+ let list = byFallback.get(tool.fallbackGroup)
102
+ if (!list) byFallback.set(tool.fallbackGroup, (list = []))
103
+ list.push(tool)
104
+ }
105
+ const votes = new Map<string, number>()
106
+ for (const [name, tools] of byFallback) {
107
+ const probe: GroupProbe = { name, tools: tools.map(({ name: n, description, annotation }) => ({ name: n, description, annotation })) }
108
+ const category = firstResult(classifiers, 'categorize', probe)
109
+ if (category && category !== UNCATEGORIZED) votes.set(category, (votes.get(category) ?? 0) + tools.length)
110
+ }
111
+ let best: [string, number] | undefined
112
+ for (const [category, count] of votes) if (!best || count > best[1]) best = [category, count]
113
+ return best?.[0]
114
+ }
115
+
116
+ function firstResult(classifiers: readonly Classifier[], method: 'group', arg: ToolProbe): string | undefined
117
+ function firstResult(classifiers: readonly Classifier[], method: 'categorize', arg: GroupProbe): string | undefined
118
+ function firstResult(classifiers: readonly Classifier[], method: 'group' | 'categorize', arg: ToolProbe | GroupProbe): string | undefined {
119
+ for (const c of classifiers) {
120
+ const fn = c[method] as ((arg: ToolProbe | GroupProbe) => unknown) | undefined
121
+ if (typeof fn !== 'function') continue
122
+ try {
123
+ const value = fn.call(c, arg)
124
+ if (typeof value === 'string' && value) return value
125
+ } catch {
126
+ // A failing classifier is skipped, never fatal.
127
+ }
128
+ }
129
+ return undefined
130
+ }
131
+
132
+ export interface BuildOptions {
133
+ /** Placement rules; priority >= OVERRIDE_PRIORITY beats plugin declarations. */
134
+ classifiers?: readonly Classifier[]
135
+ annotations?: readonly Annotation[]
136
+ /** Category vocabulary (name -> description). */
137
+ categories?: Record<string, string>
138
+ /** Tool names configured as high risk. */
139
+ highRisk?: readonly string[]
140
+ }
141
+
142
+ /**
143
+ * Build the directory index.
144
+ * @param schemas - catalog tools (pinned already removed)
145
+ */
146
+ export function buildCatalog(schemas: readonly ToolSchema[], { classifiers = [], annotations = [], categories = DEFAULT_CATEGORIES, highRisk = [] }: BuildOptions = {}): Catalog {
147
+ const sorted = [...classifiers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
148
+ const overrides = sorted.filter((c) => (c.priority ?? 0) >= OVERRIDE_PRIORITY)
149
+ const ordinary = sorted.filter((c) => (c.priority ?? 0) < OVERRIDE_PRIORITY)
150
+
151
+ const prefixCounts = new Map<string, number>()
152
+ for (const { name } of schemas) {
153
+ const prefix = name.split('_')[0]
154
+ if (prefix !== name) prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1)
155
+ }
156
+
157
+ const risky = new Set(highRisk)
158
+ const tools = new Map<string, ToolEntry>()
159
+ const groups = new Map<string, Group>()
160
+ for (const schema of schemas) {
161
+ const meta = annotationFor(schema.name, annotations)
162
+ const probe: ToolProbe = { name: schema.name, description: schema.description ?? '', annotation: meta }
163
+ const group = firstResult(overrides, 'group', probe)
164
+ ?? meta.group
165
+ ?? firstResult(ordinary, 'group', probe)
166
+ ?? structuralGroup(schema.name, prefixCounts)
167
+ const entry: ToolEntry = {
168
+ name: schema.name,
169
+ short: shortName(schema.name),
170
+ group,
171
+ // Where the structural fallback alone would have put it; a renamed group
172
+ // inherits that group's category (see below).
173
+ fallbackGroup: structuralGroup(schema.name, prefixCounts),
174
+ category: UNCATEGORIZED,
175
+ summary: meta.summary ? summary(meta.summary) : summary(schema.description),
176
+ description: schema.description ?? '',
177
+ tokens: estimateTokens(schema),
178
+ highRisk: risky.has(schema.name) || meta.risk === 'high',
179
+ annotation: meta,
180
+ }
181
+ tools.set(schema.name, entry)
182
+ let bucket = groups.get(group)
183
+ if (!bucket) groups.set(group, (bucket = { name: group, category: UNCATEGORIZED, tools: [] }))
184
+ bucket.tools.push(entry)
185
+ }
186
+
187
+ for (const group of groups.values()) {
188
+ group.tools.sort((a, b) => a.short.localeCompare(b.short))
189
+ const probe: GroupProbe = { name: group.name, tools: group.tools.map(({ name, description, annotation }) => ({ name, description, annotation })) }
190
+ const annotated = group.tools.map((t) => t.annotation.category).find(Boolean)
191
+ group.category = firstResult(overrides, 'categorize', probe)
192
+ ?? annotated
193
+ ?? firstResult(ordinary, 'categorize', probe)
194
+ ?? inheritedCategory(group, [...overrides, ...ordinary])
195
+ ?? UNCATEGORIZED
196
+ for (const tool of group.tools) tool.category = group.category
197
+ }
198
+
199
+ const categoryIndex = new Map<string, Category>()
200
+ for (const [name, description] of Object.entries(categories)) categoryIndex.set(name, { name, description, groups: [] })
201
+ for (const group of groups.values()) {
202
+ let category = categoryIndex.get(group.category)
203
+ if (!category) categoryIndex.set(group.category, (category = { name: group.category, description: '', groups: [] }))
204
+ category.groups.push(group)
205
+ }
206
+ for (const category of categoryIndex.values()) category.groups.sort((a, b) => a.name.localeCompare(b.name))
207
+
208
+ return { tools, groups, categories: categoryIndex }
209
+ }
210
+
211
+ /**
212
+ * Resolve a target into catalog nodes.
213
+ * Accepts "/", "/category", "/category/group", "/category/group/tool",
214
+ * a bare category or group name, a full tool name, or a unique short tool name.
215
+ */
216
+ /**
217
+ * Resolve a target string to a node.
218
+ *
219
+ * `resolvers` lets a plugin own a prefix (`cap:`, say): each is consulted before
220
+ * the built-in rules and returns a list of target strings to resolve instead, or
221
+ * undefined to pass. A plugin can therefore add a target vocabulary without the
222
+ * core knowing anything about it.
223
+ */
224
+ // Path segments are lowercased, but names keep their original case (an MCP
225
+ // server called "GitHub"), so lookups must not depend on it. The names stay as
226
+ // they are because persisted state stores them.
227
+ const findCI = <T extends { name: string }>(map: Map<string, T>, key: string): T | undefined =>
228
+ map.get(key) ?? [...map.values()].find((v) => v.name.toLowerCase() === key)
229
+
230
+ export function resolveTarget(catalog: Catalog, raw: unknown, resolvers: readonly Pick<RegisteredResolver, 'prefix' | 'resolve'>[] = []): CatalogNode {
231
+ const target = String(raw ?? '').trim()
232
+ for (const { prefix, resolve } of resolvers) {
233
+ if (prefix && !target.startsWith(prefix)) continue
234
+ let expanded: string | string[] | undefined | null
235
+ try {
236
+ expanded = resolve(target)
237
+ } catch {
238
+ continue
239
+ }
240
+ if (expanded === undefined || expanded === null) continue
241
+ const list = Array.isArray(expanded) ? expanded : [expanded]
242
+ const nodes = list.map((one) => resolveTarget(catalog, one, resolvers))
243
+ if (nodes.length === 0) return { kind: 'missing', target }
244
+ if (nodes.length === 1) return nodes[0]!
245
+ // Several targets behind one alias: expose them as a synthetic category so
246
+ // loading, listing and unloading all work through the existing paths.
247
+ const tools = nodes.flatMap((node) => toolsUnder(catalog, node))
248
+ // `aliasTools` is the exact tool list. The node's groups are copies, so
249
+ // loading must use this list rather than subscribe to the group names —
250
+ // otherwise a shared group would drag in every sibling tool.
251
+ return {
252
+ kind: 'category',
253
+ category: { name: target, description: `Alias for ${list.join(', ')}`, groups: groupsOf(tools), alias: list, aliasTools: tools },
254
+ }
255
+ }
256
+ if (target === '' || target === '/') return { kind: 'root' }
257
+ const exact = catalog.tools.get(target)
258
+ if (exact) return { kind: 'tool', tool: exact }
259
+ const parts = target.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).map((p) => p.toLowerCase())
260
+ if (parts.length === 1) {
261
+ const only = parts[0]!
262
+ const category = findCI(catalog.categories, only)
263
+ if (category) return { kind: 'category', category }
264
+ const group = findCI(catalog.groups, only)
265
+ if (group) return { kind: 'group', group }
266
+ const byShort = [...catalog.tools.values()].filter((t) => t.short.toLowerCase() === only)
267
+ if (byShort.length === 1) return { kind: 'tool', tool: byShort[0]! }
268
+ return { kind: 'missing', target }
269
+ }
270
+ const [cat = '', grp, tool] = parts
271
+ const category = findCI(catalog.categories, cat)
272
+ if (!category) return { kind: 'missing', target }
273
+ const group = category.groups.find((g) => g.name.toLowerCase() === grp)
274
+ if (!group) return { kind: 'missing', target }
275
+ if (parts.length === 2) return { kind: 'group', group }
276
+ const entry = group.tools.find((t) => t.short.toLowerCase() === tool || t.name.toLowerCase() === tool)
277
+ if (!entry || parts.length > 3) return { kind: 'missing', target }
278
+ return { kind: 'tool', tool: entry }
279
+ }
280
+
281
+ /**
282
+ * Regroup an exact tool list by its original group, for synthetic nodes.
283
+ *
284
+ * The groups are copies holding only the given tools: reusing the catalog's own
285
+ * group objects would drag in every sibling tool, so an alias would load far
286
+ * more than it declares.
287
+ */
288
+ function groupsOf(tools: readonly ToolEntry[]): Group[] {
289
+ const byName = new Map<string, Group>()
290
+ for (const tool of tools) {
291
+ let group = byName.get(tool.group)
292
+ if (!group) byName.set(tool.group, (group = { name: tool.group, category: tool.category, tools: [] }))
293
+ if (!group.tools.includes(tool)) group.tools.push(tool)
294
+ }
295
+ return [...byName.values()]
296
+ }
297
+
298
+ /** Tools under a node (whole tree for root). */
299
+ export function toolsUnder(catalog: Catalog, node: CatalogNode): ToolEntry[] {
300
+ if (node.kind === 'root') return [...catalog.tools.values()]
301
+ if (node.kind === 'category') return node.category.groups.flatMap((g) => g.tools)
302
+ if (node.kind === 'group') return node.group.tools
303
+ if (node.kind === 'tool') return [node.tool]
304
+ return []
305
+ }