@lvy-jsd/dsh-tool-manager 0.2.0 → 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 (52) hide show
  1. package/README.md +99 -2
  2. package/cordis.patch.yml +13 -0
  3. package/lib/exempt.d.ts +45 -0
  4. package/lib/exempt.d.ts.map +1 -0
  5. package/lib/exempt.js +122 -0
  6. package/lib/exempt.js.map +1 -0
  7. package/lib/index.d.ts +1 -0
  8. package/lib/index.d.ts.map +1 -1
  9. package/lib/index.js +243 -34
  10. package/lib/index.js.map +1 -1
  11. package/lib/manifest.d.ts +21 -0
  12. package/lib/manifest.d.ts.map +1 -0
  13. package/lib/manifest.js +112 -0
  14. package/lib/manifest.js.map +1 -0
  15. package/lib/metadata.d.ts +38 -0
  16. package/lib/metadata.d.ts.map +1 -0
  17. package/lib/metadata.js +102 -0
  18. package/lib/metadata.js.map +1 -0
  19. package/lib/router/bm25.d.ts +21 -0
  20. package/lib/router/bm25.d.ts.map +1 -0
  21. package/lib/router/bm25.js +63 -0
  22. package/lib/router/bm25.js.map +1 -0
  23. package/lib/router/index.d.ts +9 -0
  24. package/lib/router/index.d.ts.map +1 -0
  25. package/lib/router/index.js +422 -0
  26. package/lib/router/index.js.map +1 -0
  27. package/lib/router/sidecar.d.ts +34 -0
  28. package/lib/router/sidecar.d.ts.map +1 -0
  29. package/lib/router/sidecar.js +156 -0
  30. package/lib/router/sidecar.js.map +1 -0
  31. package/lib/router/types.d.ts +164 -0
  32. package/lib/router/types.d.ts.map +1 -0
  33. package/lib/router/types.js +7 -0
  34. package/lib/router/types.js.map +1 -0
  35. package/lib/settings.d.ts +32 -0
  36. package/lib/settings.d.ts.map +1 -0
  37. package/lib/settings.js +93 -0
  38. package/lib/settings.js.map +1 -0
  39. package/lib/types.d.ts +137 -1
  40. package/lib/types.d.ts.map +1 -1
  41. package/package.json +8 -4
  42. package/sidecar/bin/tool-router-sidecar +0 -0
  43. package/src/exempt.ts +148 -0
  44. package/src/index.ts +245 -35
  45. package/src/manifest.ts +116 -0
  46. package/src/metadata.ts +123 -0
  47. package/src/router/bm25.ts +68 -0
  48. package/src/router/index.ts +440 -0
  49. package/src/router/sidecar.ts +159 -0
  50. package/src/router/types.ts +149 -0
  51. package/src/settings.ts +108 -0
  52. package/src/types.ts +134 -3
@@ -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
+ }
@@ -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
+ }
package/src/types.ts CHANGED
@@ -200,7 +200,8 @@ export interface ToolLayer {
200
200
  export interface HostTools {
201
201
  schemas(agent?: Agent): ToolSchema[]
202
202
  register(definition: unknown): Disposer | void
203
- view?(agent: Agent): { restrictableNames: Iterable<string> }
203
+ /** `visible` is what the agent ends up with after every layer's restrictions. */
204
+ view?(agent: Agent): { restrictableNames: Iterable<string>, visible?: { has(name: string): boolean } }
204
205
  schemaOf?(definition: unknown, deferred: boolean): ToolSchema
205
206
  layers?: {
206
207
  chainLayers?(agent: Agent): Iterable<ToolLayer>
@@ -234,6 +235,19 @@ export interface HostContext {
234
235
  provide(name: string, service: unknown): void
235
236
  }
236
237
 
238
+ /**
239
+ * Settings a UI may edit at runtime. Each key is an independent list, so two
240
+ * writers editing different keys cannot clobber each other.
241
+ */
242
+ export interface ToolManagerSettings {
243
+ /** Extra always-on tools. */
244
+ pinned?: string[]
245
+ /** Tools removed from the default always-on list. */
246
+ unpin?: string[]
247
+ /** Tool names treated as high risk. */
248
+ highRisk?: string[]
249
+ }
250
+
237
251
  /** Configuration of the core plugin. */
238
252
  export interface CoreConfig {
239
253
  /** Extra always-on tools. */
@@ -244,6 +258,43 @@ export interface CoreConfig {
244
258
  highRisk?: string[]
245
259
  /** Model routes never narrowed (default ["claude-code"]). */
246
260
  exemptProviders?: string[]
261
+ /**
262
+ * Where UI-edited settings are persisted (default
263
+ * <DSH_HOME>/tool-manager/config.json). Those values win over the fields
264
+ * above, per key.
265
+ */
266
+ settingsFile?: string
267
+ /**
268
+ * Where UI-edited tool metadata is persisted (default
269
+ * <DSH_HOME>/tool-manager/metadata.json).
270
+ */
271
+ metadataFile?: string
272
+ /**
273
+ * Where the plain-text tool manifest is written (default
274
+ * ~/.claude/dsh-tool-catalog.md). Set to '' to turn it off.
275
+ */
276
+ manifestFile?: string
277
+ /**
278
+ * Where the exemption list is read from (default
279
+ * <DSH_HOME>/tool-manager/exempt.json). When the file exists its `providers`
280
+ * and `sessions` win over `exemptProviders` above; when it does not, the
281
+ * config list is used.
282
+ */
283
+ exemptFile?: string
284
+ // --- retrieval + routing (tools_find / tools_route / skills_find / skills_route) ---
285
+ /** Model used to re-rank tools_route / skills_route candidates. */
286
+ model?: { provider?: string; model?: string }
287
+ /** Max tokens for the re-rank call. */
288
+ maxTokens?: number
289
+ /** Default number of results for the find/route tools. */
290
+ topK?: number
291
+ /** Token budget for loaded tool schemas, reported by tools_route. */
292
+ maxBudget?: number
293
+ /** BM25 parameters (JS fallback index). */
294
+ k1?: number
295
+ b?: number
296
+ /** Rust sidecar overrides. */
297
+ sidecar?: { bin?: string; socket?: string; timeoutMs?: number }
247
298
  }
248
299
 
249
300
  /** A plugin package the host loader knows. */
@@ -290,14 +341,94 @@ export interface ToolCatalogService {
290
341
  importState(agent: Agent, state: Partial<SerializedState>): void
291
342
  /** Names visible to the agent, or null when this plugin does not restrict it. */
292
343
  allowFor(agent: Agent): string[] | null
293
- /** True when the agent's model route is in `exemptProviders` (never narrowed). */
344
+ /**
345
+ * Names this plugin lets the agent see (pinned or loaded) that another layer
346
+ * still hides: an MCP connector governance `deny` rule, the connector's
347
+ * per-session injection, or any other plugin's `restrict()`. Empty when the
348
+ * host cannot report the agent's final view.
349
+ */
350
+ deniedFor(agent: Agent): string[]
351
+ /** True when the agent is never narrowed, by route or by its session id. */
294
352
  isExempt(agent: Agent): boolean
353
+ /**
354
+ * Why the agent is exempt, so the UI can tell a route-wide exemption from one
355
+ * session: { exempt, source } with source 'file:provider' | 'file:session' |
356
+ * 'config:provider' | ''.
357
+ */
358
+ exemptionOf(agent: Agent): { exempt: boolean, source: string }
295
359
  /** Load through the same path as tools_load; returns the model-facing text. */
296
360
  load(agent: Agent, targets: readonly string[], options?: { forSubagents?: boolean }): Promise<string>
297
361
  unload(agent: Agent, targets: readonly string[]): Promise<string>
298
362
  /** Live agents, each with its parent when it is a subagent. */
299
363
  agents(): { agent: Agent, id: string, parentId: string | null }[]
300
- config(): { pinned: string[], entryTools: string[], extraPinned: string[], unpinned: string[], highRisk: string[] }
364
+ /** Effective configuration, for UI display. */
365
+ config(): {
366
+ pinned: string[]
367
+ entryTools: string[]
368
+ extraPinned: string[]
369
+ unpinned: string[]
370
+ highRisk: string[]
371
+ exemptProviders: string[]
372
+ /** Session ids exempted for their own lifetime by the exemption file. */
373
+ exemptSessions: string[]
374
+ defaults: string[]
375
+ /** Where UI edits are persisted. */
376
+ settingsFile: string
377
+ /** True when a settings file exists and overrides the plugin config. */
378
+ hasSettings: boolean
379
+ /** Where the exemption list is read from. */
380
+ exemptFile: string
381
+ /** True when the exemption file exists and overrides `exemptProviders`. */
382
+ hasExemptFile: boolean
383
+ }
384
+ /**
385
+ * Add or remove one session id in the exemption file and apply it at once.
386
+ * The first call creates the file from the lists already in force, so entries
387
+ * visible in the UI are never dropped by a write.
388
+ */
389
+ setExemptSession(sessionId: string, exempt: boolean): ReturnType<ToolCatalogService['config']>
390
+ /** Re-read the exemption file, for after an external edit. */
391
+ reloadExempt(): ReturnType<ToolCatalogService['config']>
392
+ /**
393
+ * Persist UI-edited settings (merged per key) and apply them at once. The
394
+ * values win over the plugin config from the next call on. Returns the new
395
+ * effective configuration, in the same shape as config().
396
+ */
397
+ updateConfig(patch: ToolManagerSettings): ReturnType<ToolCatalogService['config']>
398
+ /** Re-read the settings file, for after an external edit. */
399
+ reloadConfig(): ReturnType<ToolCatalogService['config']>
400
+ /**
401
+ * Metadata of one tool as the catalog currently shows it, including which
402
+ * layer decided each field (`manifest`, `runtime`, `metadata` or `classifier`).
403
+ */
404
+ toolMeta(name: string): {
405
+ name: string
406
+ found: boolean
407
+ /** Effective values after every layer. */
408
+ effective: ToolMeta
409
+ /** Values edited from the panel, which win over the layers above. */
410
+ edited: ToolMeta
411
+ /** Values from package.json and runtime annotate() calls. */
412
+ declared: ToolMeta
413
+ group: string
414
+ category: string
415
+ summary: string
416
+ highRisk: boolean
417
+ /** The tool's directory path, for linking back to the Directory tab. */
418
+ path: string
419
+ /** True when the tool is not in the catalog (unregistered or pinned away). */
420
+ missing: boolean
421
+ }
422
+ /**
423
+ * Replace the editable metadata of one tool. Fields left out are dropped, so
424
+ * an emptied field falls back to the package declaration; an empty object
425
+ * removes the override entirely.
426
+ */
427
+ updateToolMeta(name: string, meta: ToolMeta): ReturnType<ToolCatalogService['toolMeta']>
428
+ /** Drop every edited field of one tool, falling back to its declaration. */
429
+ clearToolMeta(name: string): ReturnType<ToolCatalogService['toolMeta']>
430
+ /** Where edited metadata is persisted. */
431
+ metadataFile(): string
301
432
  packages(): PluginPackage[]
302
433
  extensions(): {
303
434
  classifiers: { name: string, priority: number }[]