@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.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/cordis.patch.yml +29 -0
- package/lib/catalog.d.ts +35 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/catalog.js +311 -0
- package/lib/catalog.js.map +1 -0
- package/lib/exempt.d.ts +45 -0
- package/lib/exempt.d.ts.map +1 -0
- package/lib/exempt.js +122 -0
- package/lib/exempt.js.map +1 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +797 -0
- package/lib/index.js.map +1 -0
- package/lib/manifest.d.ts +21 -0
- package/lib/manifest.d.ts.map +1 -0
- package/lib/manifest.js +112 -0
- package/lib/manifest.js.map +1 -0
- package/lib/metadata.d.ts +38 -0
- package/lib/metadata.d.ts.map +1 -0
- package/lib/metadata.js +102 -0
- package/lib/metadata.js.map +1 -0
- package/lib/router/bm25.d.ts +21 -0
- package/lib/router/bm25.d.ts.map +1 -0
- package/lib/router/bm25.js +63 -0
- package/lib/router/bm25.js.map +1 -0
- package/lib/router/index.d.ts +9 -0
- package/lib/router/index.d.ts.map +1 -0
- package/lib/router/index.js +422 -0
- package/lib/router/index.js.map +1 -0
- package/lib/router/sidecar.d.ts +34 -0
- package/lib/router/sidecar.d.ts.map +1 -0
- package/lib/router/sidecar.js +156 -0
- package/lib/router/sidecar.js.map +1 -0
- package/lib/router/types.d.ts +164 -0
- package/lib/router/types.d.ts.map +1 -0
- package/lib/router/types.js +7 -0
- package/lib/router/types.js.map +1 -0
- package/lib/service.d.ts +51 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +199 -0
- package/lib/service.js.map +1 -0
- package/lib/settings.d.ts +32 -0
- package/lib/settings.d.ts.map +1 -0
- package/lib/settings.js +93 -0
- package/lib/settings.js.map +1 -0
- package/lib/types.d.ts +453 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +7 -0
- package/lib/types.js.map +1 -0
- package/lib/visibility.d.ts +33 -0
- package/lib/visibility.d.ts.map +1 -0
- package/lib/visibility.js +305 -0
- package/lib/visibility.js.map +1 -0
- package/package.json +54 -4
- package/sidecar/bin/tool-router-sidecar +0 -0
- package/src/catalog.ts +305 -0
- package/src/exempt.ts +148 -0
- package/src/index.ts +805 -0
- package/src/manifest.ts +116 -0
- package/src/metadata.ts +123 -0
- package/src/router/bm25.ts +68 -0
- package/src/router/index.ts +440 -0
- package/src/router/sidecar.ts +159 -0
- package/src/router/types.ts +149 -0
- package/src/service.ts +207 -0
- package/src/settings.ts +108 -0
- package/src/types.ts +450 -0
- 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
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -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
|
+
}
|