@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.
- package/README.md +99 -2
- package/cordis.patch.yml +13 -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 +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +243 -34
- package/lib/index.js.map +1 -1
- 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/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 +137 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +8 -4
- package/sidecar/bin/tool-router-sidecar +0 -0
- package/src/exempt.ts +148 -0
- package/src/index.ts +245 -35
- 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/settings.ts +108 -0
- package/src/types.ts +134 -3
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// A tool manifest on disk, so a client that cannot lazy-load can still find tools.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code (driven by dsh-oh-my-claude) receives dsh tools once per session
|
|
4
|
+
// over an MCP bridge, built from what the session's agent can see at that
|
|
5
|
+
// moment. The directory keeps ~240 tools out of every request, so what that
|
|
6
|
+
// bridge lists is the always-on set plus `tools_ls`/`tools_load` — and nothing
|
|
7
|
+
// tells Claude the directory exists, so it never asks.
|
|
8
|
+
//
|
|
9
|
+
// This file answers "what is there" without a call: the catalog as text, plus
|
|
10
|
+
// the two tool names that load the rest. It is written next to Claude Code's
|
|
11
|
+
// own instructions and pulled in with an `@` line, so every Claude session reads
|
|
12
|
+
// it the same way it reads CLAUDE.md.
|
|
13
|
+
//
|
|
14
|
+
// Written after every catalog rebuild, and only when the text changed: the file
|
|
15
|
+
// is part of a prompt, so a rewrite that changes nothing still costs a re-read
|
|
16
|
+
// and a cache miss.
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import type { Catalog, Logger } from './types.js'
|
|
22
|
+
|
|
23
|
+
/** Where the manifest goes unless a deployment points elsewhere. */
|
|
24
|
+
export function defaultManifestFile(): string {
|
|
25
|
+
return join(homedir(), '.claude', 'dsh-tool-catalog.md')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One line describing what to do with this file, so a model reads it as instructions. */
|
|
29
|
+
const HEADER = [
|
|
30
|
+
'# dsh tool catalog',
|
|
31
|
+
'',
|
|
32
|
+
'dsh keeps most of its tools out of every request and hands them out on demand.',
|
|
33
|
+
'The tools listed below are NOT callable yet — this file is the index, not the tools.',
|
|
34
|
+
'',
|
|
35
|
+
'To use one:',
|
|
36
|
+
'',
|
|
37
|
+
'- `tools_ls` browses the same tree as text: no argument lists categories, a',
|
|
38
|
+
' category name lists its groups, a group lists its tools. `tools_ls({ query })`',
|
|
39
|
+
' searches every name and description at once.',
|
|
40
|
+
'- `tools_load` makes tools callable for the rest of the session. Pass group',
|
|
41
|
+
' paths (`/code/github`), a category (`/maps`), or exact tool names.',
|
|
42
|
+
'- Load the smallest thing that covers the task. A whole category costs more',
|
|
43
|
+
' context than the one group you need.',
|
|
44
|
+
'- High-risk tools (marked `!`) need a risk gate to confirm them; with none',
|
|
45
|
+
' installed, loading them is refused.',
|
|
46
|
+
'',
|
|
47
|
+
'This file is regenerated whenever the catalog changes; do not edit it.',
|
|
48
|
+
'',
|
|
49
|
+
].join('\n')
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The catalog as markdown: category, then group, then tool names with their
|
|
53
|
+
* summaries. Summaries are cut to one line so a group of 30 tools stays
|
|
54
|
+
* scannable, and the counts let a reader judge a load before making it.
|
|
55
|
+
*/
|
|
56
|
+
export function renderManifest(catalog: Catalog): string {
|
|
57
|
+
const out: string[] = [HEADER]
|
|
58
|
+
const categories = [...catalog.categories.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
59
|
+
for (const category of categories) {
|
|
60
|
+
const tools = category.groups.flatMap((group) => group.tools)
|
|
61
|
+
if (tools.length === 0) continue
|
|
62
|
+
out.push(`## ${category.name} (${tools.length} tools, ${category.description || 'no description'})`, '')
|
|
63
|
+
for (const group of [...category.groups].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
64
|
+
out.push(`### ${group.name} — load with \`tools_load({ targets: ["/${category.name}/${group.name}"] })\``)
|
|
65
|
+
for (const tool of group.tools) {
|
|
66
|
+
const risk = tool.highRisk ? ' !' : ''
|
|
67
|
+
const summary = tool.summary ? ` — ${tool.summary}` : ''
|
|
68
|
+
out.push(`- ${tool.name}${risk}${summary}`)
|
|
69
|
+
}
|
|
70
|
+
out.push('')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
out.push('## Also always available', '')
|
|
74
|
+
out.push('- `tools_ls`, `tools_load`, `tools_unload` — the directory itself.', '')
|
|
75
|
+
return out.join('\n')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The smallest change worth a rewrite: identical text means no write at all. */
|
|
79
|
+
function sameFile(file: string, text: string): boolean {
|
|
80
|
+
try {
|
|
81
|
+
return readFileSync(file, 'utf8') === text
|
|
82
|
+
} catch {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Write atomically; a crash cannot leave a half-written prompt. */
|
|
88
|
+
export function writeManifest(file: string, text: string, logger?: Logger): boolean {
|
|
89
|
+
if (sameFile(file, text)) return false
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
92
|
+
const temporary = `${file}.tmp`
|
|
93
|
+
writeFileSync(temporary, text, 'utf8')
|
|
94
|
+
renameSync(temporary, file)
|
|
95
|
+
return true
|
|
96
|
+
} catch (caught) {
|
|
97
|
+
// A read-only home is not a reason to break the catalog; the panel still works.
|
|
98
|
+
logger?.warn?.(`tool-manager: writing the tool manifest ${file} failed: ${(caught as Error).message}`)
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Write the manifest for this catalog, if it changed. */
|
|
104
|
+
export function syncManifest(catalog: Catalog, file: string, logger?: Logger): boolean {
|
|
105
|
+
return writeManifest(file, renderManifest(catalog), logger)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The `@` line a CLAUDE.md needs to pull the manifest in.
|
|
110
|
+
*
|
|
111
|
+
* `@` has to open the line or follow whitespace, and a path with spaces is
|
|
112
|
+
* escaped — the same rules Claude Code applies to its own imports.
|
|
113
|
+
*/
|
|
114
|
+
export function manifestImport(file: string): string {
|
|
115
|
+
return `@${file.replace(/ /g, '\\ ')}`
|
|
116
|
+
}
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Persisted tool metadata for dsh-tool-manager.
|
|
2
|
+
//
|
|
3
|
+
// A package declares its tools in package.json (`dsh.toolCatalog`), which a user
|
|
4
|
+
// cannot edit from the browser. This file holds the same kind of metadata keyed
|
|
5
|
+
// by tool name, written by the panel, and is applied after every other
|
|
6
|
+
// annotation so an edit always wins over the declaration it corrects.
|
|
7
|
+
//
|
|
8
|
+
// Only metadata is stored here. A tool's parameters and description belong to
|
|
9
|
+
// the host and its plugins; rewriting them for the model is not something a
|
|
10
|
+
// directory plugin can do, and pretending otherwise would drift from what the
|
|
11
|
+
// model actually receives.
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import type { Annotation, Logger, ToolMeta } from './types.js'
|
|
17
|
+
|
|
18
|
+
const META_KEYS = ['group', 'category', 'risk', 'summary'] as const
|
|
19
|
+
|
|
20
|
+
/** Where edited metadata is stored unless a deployment points elsewhere. */
|
|
21
|
+
export function defaultMetadataFile(): string {
|
|
22
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.config', 'deepseek-harness')
|
|
23
|
+
return join(dshHome, 'tool-manager', 'metadata.json')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keep only known string fields; a blank value means "no override here". */
|
|
27
|
+
export function pickMeta(raw: unknown): ToolMeta {
|
|
28
|
+
const out: ToolMeta = {}
|
|
29
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out
|
|
30
|
+
for (const key of META_KEYS) {
|
|
31
|
+
const value = (raw as Record<string, unknown>)[key]
|
|
32
|
+
if (typeof value === 'string' && value.trim()) out[key] = value.trim()
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The whole file: tool name -> metadata. Unknown shapes are dropped. */
|
|
38
|
+
export function pickMetadata(raw: unknown): Record<string, ToolMeta> {
|
|
39
|
+
const out: Record<string, ToolMeta> = {}
|
|
40
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out
|
|
41
|
+
for (const [name, meta] of Object.entries(raw as Record<string, unknown>)) {
|
|
42
|
+
const picked = pickMeta(meta)
|
|
43
|
+
if (name && Object.keys(picked).length) out[name] = picked
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read the metadata file; missing or corrupt is "no overrides", never a crash. */
|
|
49
|
+
export function readMetadataFile(file: string, logger?: Logger): Record<string, ToolMeta> {
|
|
50
|
+
try {
|
|
51
|
+
return pickMetadata(JSON.parse(readFileSync(file, 'utf8')))
|
|
52
|
+
} catch (caught) {
|
|
53
|
+
const error = caught as NodeJS.ErrnoException
|
|
54
|
+
// ENOENT is the normal first run: no file means nothing has been edited.
|
|
55
|
+
if (error.code !== 'ENOENT') logger?.warn?.(`tool-manager: ignoring unreadable metadata ${file}: ${error.message}`)
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Write the file atomically, so a crash cannot leave a half-written override. */
|
|
61
|
+
export function writeMetadataFile(file: string, entries: Record<string, ToolMeta>, logger?: Logger): void {
|
|
62
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
63
|
+
const tmp = `${file}.tmp`
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(tmp, `${JSON.stringify(entries, null, 2)}\n`)
|
|
66
|
+
renameSync(tmp, file)
|
|
67
|
+
} catch (caught) {
|
|
68
|
+
logger?.warn?.(`tool-manager: writing metadata ${file} failed: ${(caught as Error).message}`)
|
|
69
|
+
throw caught
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface MetadataStore {
|
|
74
|
+
/** File the store reads and writes. */
|
|
75
|
+
file: string
|
|
76
|
+
/** Every override, keyed by tool name. */
|
|
77
|
+
all(): Record<string, ToolMeta>
|
|
78
|
+
/** The override for one tool, or undefined. */
|
|
79
|
+
get(name: string): ToolMeta | undefined
|
|
80
|
+
/**
|
|
81
|
+
* Replace the override for one tool. Fields left out are dropped rather than
|
|
82
|
+
* kept, because the panel editor submits the whole form: an emptied field
|
|
83
|
+
* must fall back to the package declaration, not silently keep its old value.
|
|
84
|
+
* An empty result removes the override.
|
|
85
|
+
*/
|
|
86
|
+
set(name: string, meta: ToolMeta): ToolMeta | undefined
|
|
87
|
+
/** Drop the override for one tool. */
|
|
88
|
+
clear(name: string): void
|
|
89
|
+
/** Re-read the file, for after an external edit. */
|
|
90
|
+
reload(): Record<string, ToolMeta>
|
|
91
|
+
/** The overrides as annotations, for buildCatalog(). */
|
|
92
|
+
annotations(): Annotation[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Metadata store over one file. */
|
|
96
|
+
export function createMetadataStore({ file, logger }: { file: string, logger?: Logger }): MetadataStore {
|
|
97
|
+
let entries = readMetadataFile(file, logger)
|
|
98
|
+
return {
|
|
99
|
+
file,
|
|
100
|
+
all: () => ({ ...entries }),
|
|
101
|
+
get: (name) => entries[name],
|
|
102
|
+
set(name, meta) {
|
|
103
|
+
const picked = pickMeta(meta)
|
|
104
|
+
// A blank form is how the UI removes an override: leaving an empty object
|
|
105
|
+
// behind would shadow nothing while still showing up as "edited".
|
|
106
|
+
if (Object.keys(picked).length) entries[name] = picked
|
|
107
|
+
else delete entries[name]
|
|
108
|
+
writeMetadataFile(file, entries, logger)
|
|
109
|
+
return entries[name]
|
|
110
|
+
},
|
|
111
|
+
clear(name) {
|
|
112
|
+
delete entries[name]
|
|
113
|
+
writeMetadataFile(file, entries, logger)
|
|
114
|
+
},
|
|
115
|
+
reload() {
|
|
116
|
+
entries = readMetadataFile(file, logger)
|
|
117
|
+
return { ...entries }
|
|
118
|
+
},
|
|
119
|
+
annotations() {
|
|
120
|
+
return Object.entries(entries).map(([name, meta]) => ({ source: 'tool-manager:metadata', match: name, meta }))
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// A small, dependency-free BM25 index. It is the always-available baseline:
|
|
2
|
+
// ranked retrieval that needs no model, no network, and no sidecar process.
|
|
3
|
+
|
|
4
|
+
import type { Hit } from './types.js'
|
|
5
|
+
|
|
6
|
+
export function tokenize(text: string): string[] {
|
|
7
|
+
const out: string[] = []
|
|
8
|
+
const flat = String(text ?? '').toLowerCase()
|
|
9
|
+
for (const m of flat.matchAll(/[a-z0-9_]+/g)) out.push(m[0])
|
|
10
|
+
for (const ch of flat) if ((ch.codePointAt(0) ?? 0) > 0x2e7f) out.push(ch)
|
|
11
|
+
return out
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Doc {
|
|
15
|
+
id: number
|
|
16
|
+
ref: unknown
|
|
17
|
+
tf: Map<string, number>
|
|
18
|
+
len: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Bm25<T> {
|
|
22
|
+
private k1: number
|
|
23
|
+
private b: number
|
|
24
|
+
private docs: Doc[] = []
|
|
25
|
+
private df = new Map<string, number>()
|
|
26
|
+
private avgLen = 0
|
|
27
|
+
private n = 0
|
|
28
|
+
|
|
29
|
+
constructor({ k1 = 1.5, b = 0.75 }: { k1?: number; b?: number } = {}) {
|
|
30
|
+
this.k1 = k1
|
|
31
|
+
this.b = b
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
build(docs: { ref: T; text: string }[]): void {
|
|
35
|
+
this.docs = docs.map((d, i) => {
|
|
36
|
+
const tokens = tokenize(d.text)
|
|
37
|
+
const tf = new Map<string, number>()
|
|
38
|
+
for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1)
|
|
39
|
+
return { id: i, ref: d.ref, tf, len: tokens.length }
|
|
40
|
+
})
|
|
41
|
+
this.df = new Map()
|
|
42
|
+
for (const doc of this.docs) for (const term of doc.tf.keys()) this.df.set(term, (this.df.get(term) ?? 0) + 1)
|
|
43
|
+
this.n = this.docs.length
|
|
44
|
+
this.avgLen = this.n ? this.docs.reduce((s, d) => s + d.len, 0) / this.n : 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private idf(term: string): number {
|
|
48
|
+
const df = this.df.get(term) ?? 0
|
|
49
|
+
return Math.log((this.n - df + 0.5) / (df + 0.5) + 1)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
search(query: string, limit = 8): Hit<T>[] {
|
|
53
|
+
const terms = [...new Set(tokenize(query))]
|
|
54
|
+
if (!terms.length || !this.n) return []
|
|
55
|
+
const scored: Hit<T>[] = []
|
|
56
|
+
for (const doc of this.docs) {
|
|
57
|
+
let score = 0
|
|
58
|
+
for (const term of terms) {
|
|
59
|
+
const tf = doc.tf.get(term) ?? 0
|
|
60
|
+
if (!tf) continue
|
|
61
|
+
score += this.idf(term) * (tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + this.b * (doc.len / (this.avgLen || 1))))
|
|
62
|
+
}
|
|
63
|
+
if (score > 0) scored.push({ ref: doc.ref as T, score })
|
|
64
|
+
}
|
|
65
|
+
scored.sort((a, b) => b.score - a.score)
|
|
66
|
+
return scored.slice(0, limit)
|
|
67
|
+
}
|
|
68
|
+
}
|