@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
@@ -0,0 +1,149 @@
1
+ // Types shared by the tool-router modules.
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
+ export type Disposer = () => void
8
+
9
+ export interface Logger {
10
+ info?(...args: unknown[]): void
11
+ warn?(...args: unknown[]): void
12
+ debug?(...args: unknown[]): void
13
+ error?(...args: unknown[]): void
14
+ }
15
+
16
+ /** A tool schema as the host lists it (fallback mode, no toolCatalog). */
17
+ export interface ToolSchema {
18
+ name: string
19
+ description?: string
20
+ [key: string]: unknown
21
+ }
22
+
23
+ /** A ref into the tool index, aligned to the index doc id. */
24
+ export interface ToolRef {
25
+ name: string
26
+ path: string
27
+ summary: string
28
+ description: string
29
+ group: string
30
+ category: string
31
+ tokens: number
32
+ highRisk: boolean
33
+ }
34
+
35
+ /** A ref into the skill index, aligned to the index doc id. */
36
+ export interface SkillRef {
37
+ name: string
38
+ summary: string
39
+ whenToUse: string
40
+ source?: string
41
+ provider?: string
42
+ }
43
+
44
+ /** A ranked hit: a ref plus its BM25 score. */
45
+ export interface Hit<T> {
46
+ ref: T
47
+ score: number
48
+ }
49
+
50
+ /** One tool entry as the toolCatalog service lists it. */
51
+ export interface CatalogToolEntry {
52
+ name: string
53
+ short: string
54
+ group: string
55
+ category: string
56
+ summary: string
57
+ description: string
58
+ tokens: number
59
+ highRisk?: boolean
60
+ }
61
+
62
+ /** The toolCatalog service surface this plugin uses. */
63
+ export interface ToolCatalogService {
64
+ get(): { tools: Map<string, CatalogToolEntry> }
65
+ load(agent: unknown, targets: string[]): Promise<string>
66
+ wireTokens?(agent: unknown): number
67
+ }
68
+
69
+ /** One message from the llm stream. */
70
+ export interface LlmChunk {
71
+ type: string
72
+ text?: string
73
+ }
74
+
75
+ /** The llm service surface this plugin uses. */
76
+ export interface LlmService {
77
+ stream(options: {
78
+ provider: string
79
+ model: string
80
+ system?: string
81
+ messages: { role: string; content: { type: string; text: string }[] }[]
82
+ maxTokens?: number
83
+ temperature?: number
84
+ }): AsyncIterable<LlmChunk>
85
+ }
86
+
87
+ /** One skill as the skills service lists it. */
88
+ export interface SkillEntry {
89
+ name: string
90
+ description?: string
91
+ whenToUse?: string
92
+ source?: string
93
+ provider?: string
94
+ invocation?: { modelInvocable?: boolean }
95
+ }
96
+
97
+ /** The skills service surface this plugin uses. */
98
+ export interface SkillsService {
99
+ list(opts?: { cwd?: string; scope?: string }): Promise<SkillEntry[]>
100
+ get(name: string, opts?: { cwd?: string }): Promise<{ content?: string } | undefined>
101
+ }
102
+
103
+ /** A tool definition built by hand (no @deepseek-ai/dsh-tools dependency). */
104
+ export interface ToolDef {
105
+ name: string
106
+ description: string
107
+ parameters: {
108
+ type: 'object'
109
+ properties: Record<string, { type: string; description: string }>
110
+ required: string[]
111
+ additionalProperties: boolean
112
+ }
113
+ output: {
114
+ schema: { type: 'string' }
115
+ render(args: unknown, value: unknown): { type: 'text'; text: string }[]
116
+ }
117
+ execute(args: Record<string, unknown>, exec?: { agent?: unknown }): Promise<string>
118
+ isConcurrencySafe?: () => boolean
119
+ }
120
+
121
+ /** The host context this plugin receives in apply(). */
122
+ export interface HostContext {
123
+ tools: {
124
+ schemas(): ToolSchema[]
125
+ register(tool: ToolDef): void
126
+ }
127
+ toolCatalog?: ToolCatalogService
128
+ logger?: ((name: string) => Logger) | Logger
129
+ get?(name: string): unknown
130
+ on(event: string, handler: (...args: any[]) => unknown): Disposer
131
+ effect(fn: () => Disposer | void, label?: string): void
132
+ }
133
+
134
+ /** Configuration of the tool-router plugin. */
135
+ export interface RouterConfig {
136
+ /** Model used to re-rank tools_route candidates. */
137
+ model?: { provider?: string; model?: string }
138
+ /** Max tokens for the re-rank call. */
139
+ maxTokens?: number
140
+ /** Default number of results. */
141
+ topK?: number
142
+ /** Token budget for loaded tool schemas. */
143
+ maxBudget?: number
144
+ /** BM25 parameters (JS fallback). */
145
+ k1?: number
146
+ b?: number
147
+ /** Sidecar overrides. */
148
+ sidecar?: { bin?: string; socket?: string; timeoutMs?: number }
149
+ }
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
+ }
@@ -0,0 +1,108 @@
1
+ // Persisted settings for dsh-tool-manager.
2
+ //
3
+ // The panel (and any other UI) edits these instead of `cordis.patch.yml`, which
4
+ // a user cannot reach from the browser. The file lives beside the other state
5
+ // in <DSH_HOME>/tool-manager/config.json, so backing up or wiping that one
6
+ // directory covers everything this plugin persists.
7
+ //
8
+ // Priority: the file wins per key, the plugin config fills the keys it omits.
9
+ // A key present in the file as an empty array therefore clears the config value
10
+ // — that is how a UI removes an entry for good.
11
+
12
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import type { Logger, ToolManagerSettings } from './types.js'
16
+
17
+ const KEYS = ['pinned', 'unpin', 'highRisk'] as const
18
+
19
+ /** Where the panel writes settings unless a deployment points elsewhere. */
20
+ export function defaultSettingsFile(): string {
21
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.config', 'deepseek-harness')
22
+ return join(dshHome, 'tool-manager', 'config.json')
23
+ }
24
+
25
+ /**
26
+ * Keep only known keys holding arrays of non-empty strings. Unknown keys and
27
+ * wrong types are dropped rather than trusted: the file may have been hand
28
+ * edited, and a bad value must not reach the catalog.
29
+ */
30
+ export function pickSettings(raw: unknown): ToolManagerSettings {
31
+ const out: ToolManagerSettings = {}
32
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out
33
+ for (const key of KEYS) {
34
+ const value = (raw as Record<string, unknown>)[key]
35
+ // Non-string entries are dropped, not coerced: a number is a typo, not a name.
36
+ if (Array.isArray(value)) out[key] = value.filter((name): name is string => typeof name === 'string' && name.length > 0)
37
+ }
38
+ return out
39
+ }
40
+
41
+ /** Read the settings file; missing or corrupt is "no override", never a crash. */
42
+ export function readSettingsFile(file: string, logger?: Logger): ToolManagerSettings {
43
+ try {
44
+ return pickSettings(JSON.parse(readFileSync(file, 'utf8')))
45
+ } catch (caught) {
46
+ const error = caught as NodeJS.ErrnoException
47
+ // ENOENT is the normal first run: no file means "use the plugin config".
48
+ if (error.code !== 'ENOENT') logger?.warn?.(`tool-manager: ignoring unreadable settings ${file}: ${error.message}`)
49
+ return {}
50
+ }
51
+ }
52
+
53
+ /** Write the file atomically, so a crash cannot leave a half-written override. */
54
+ export function writeSettingsFile(file: string, settings: ToolManagerSettings, logger?: Logger): void {
55
+ mkdirSync(dirname(file), { recursive: true })
56
+ const tmp = `${file}.tmp`
57
+ try {
58
+ writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`)
59
+ renameSync(tmp, file)
60
+ } catch (caught) {
61
+ // Callers report the failure to the UI, so let it through after logging.
62
+ logger?.warn?.(`tool-manager: writing settings ${file} failed: ${(caught as Error).message}`)
63
+ throw caught
64
+ }
65
+ }
66
+
67
+ export interface SettingsStore {
68
+ /** File the store reads and writes. */
69
+ file: string
70
+ /** Values read from the file, before the config fallback fills gaps. */
71
+ stored(): ToolManagerSettings
72
+ /** Effective values: the file wins per key, the config fills the rest. */
73
+ effective(): Required<ToolManagerSettings>
74
+ /** Merge a patch, persist it, and return the new effective values. */
75
+ update(patch: ToolManagerSettings): Required<ToolManagerSettings>
76
+ /** Re-read the file, for after an external edit. */
77
+ reload(): Required<ToolManagerSettings>
78
+ }
79
+
80
+ /** Settings store over one file, with the plugin config as the fallback layer. */
81
+ export function createSettingsStore({ file, base, logger }: { file: string, base?: ToolManagerSettings, logger?: Logger }): SettingsStore {
82
+ const fallback: Required<ToolManagerSettings> = {
83
+ pinned: base?.pinned ?? [],
84
+ unpin: base?.unpin ?? [],
85
+ highRisk: base?.highRisk ?? [],
86
+ }
87
+ let stored = readSettingsFile(file, logger)
88
+ const effective = (): Required<ToolManagerSettings> => ({
89
+ pinned: stored.pinned ?? fallback.pinned,
90
+ unpin: stored.unpin ?? fallback.unpin,
91
+ highRisk: stored.highRisk ?? fallback.highRisk,
92
+ })
93
+ return {
94
+ file,
95
+ stored: () => ({ ...stored }),
96
+ effective,
97
+ update(patch) {
98
+ // Merged, not replaced: a UI saving one list must not drop the others.
99
+ stored = { ...stored, ...pickSettings(patch) }
100
+ writeSettingsFile(file, stored, logger)
101
+ return effective()
102
+ },
103
+ reload() {
104
+ stored = readSettingsFile(file, logger)
105
+ return effective()
106
+ },
107
+ }
108
+ }