dsh-plugin-cfr 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-cfr
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 `cfr` 与 `rg` 注册为 DSH 的模型工具,给 Agent 一套完整的 jar 反编译体验:反编译一次、全文检索、按类读取。
4
+
5
+ cfr.jar 已随插件分发(`vendor/cfr.jar`,CFR 0.152,MIT 许可证),rg 由 `@vscode/ripgrep` 依赖提供,系统里只需要有 `java`。
6
+
7
+ ## 工具
8
+
9
+ | 工具 | 作用 |
10
+ | --- | --- |
11
+ | `jar_decompile` | 把整个 jar 反编译到缓存目录,返回目录与 Java 文件数。同一个 jar 只真正编译一次,之后直接命中缓存。 |
12
+ | `jar_search` | 在反编译源码里用 ripgrep 做正则检索,返回 `文件:行号:内容`;jar 未反编译时自动先反编译。 |
13
+ | `jar_read` | 按类名读取源码,支持全限定名、内部名与短名,按行返回并可用 `offset` / `limit` 翻页;缓存缺失时即时反编译单个类。 |
14
+
15
+ 会话工作目录用于解析相对路径,所以 `jar_read { jar: "lib/foo.jar", className: "Bar" }` 直接可用。
16
+
17
+ ## 缓存
18
+
19
+ 反编译结果按 jar 的路径与内容指纹落在 `$DSH_HOME/cache/jar-decompile/<jar 名>-<指纹>/`,跨会话、跨工作区复用。jar 内容变化会生成新目录,旧目录可随时删除。
20
+
21
+ ## 配置
22
+
23
+ 默认无需配置。要换 cfr 版本、调整缓存位置或换 ripgrep 时,在 profile 的 `cordis.patch.yml` 里覆盖 `jar-cfr` 行的 `config`:
24
+
25
+ ```yaml
26
+ - id: jar-cfr
27
+ config:
28
+ cacheDir: D:\cache\jar-decompile
29
+ javaPath: java
30
+ cfrJar: D:\tools\cfr-0.152.jar
31
+ rgPath: ''
32
+ ```
33
+
34
+ | 配置项 | 默认值 | 说明 |
35
+ | --- | --- | --- |
36
+ | `cacheDir` | `$DSH_HOME/cache/jar-decompile` | 反编译缓存根目录 |
37
+ | `javaPath` | `java` | 启动 cfr 用的 java |
38
+ | `cfrJar` | 内置 `vendor/cfr.jar` | 想换版本时给出 cfr.jar 的绝对路径 |
39
+ | `rgPath` | 内置 `@vscode/ripgrep` | 想换 ripgrep 时给出可执行文件路径 |
40
+
41
+ ## 依赖
42
+
43
+ - `java`:PATH 上可用,或用 `javaPath` 指定。
44
+ - `rg`:由 `@vscode/ripgrep` 依赖提供;依赖缺失时回退到 PATH 上的 `rg`。
45
+ - `cfr.jar`:已内置,无需单独安装。
46
+
47
+ ## 安装
48
+
49
+ 先在插件目录安装依赖:
50
+
51
+ ```
52
+ pnpm install
53
+ ```
54
+
55
+ 再在 DSH 里用插件管理安装本目录的 bundle:
56
+
57
+ ```
58
+ plugin_manager action=install_bundle target=<本目录绝对路径>
59
+ ```
@@ -0,0 +1,8 @@
1
+ - insert:
2
+ - id: jar-cfr
3
+ name: dsh-plugin-cfr
4
+ config:
5
+ cacheDir: ''
6
+ javaPath: java
7
+ cfrJar: ''
8
+ rgPath: ''
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Jar decompiler">
2
+ <rect width="64" height="64" rx="14" fill="#0b5cad"/>
3
+ <path d="M23 20h18l-2 30H25z" fill="#ffffff" opacity="0.96"/>
4
+ <rect x="20" y="15" width="24" height="7" rx="2" fill="#ffffff"/>
5
+ <path d="M28 30l-4 5 4 5M36 30l4 5-4 5" fill="none" stroke="#0b5cad" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Host 半入口:把 cfr 与 rg 注册为模型可调用的 jar 工具。
3
+ */
4
+
5
+ import { createCfr } from './src/cfr.js'
6
+ import { normalizeConfig } from './src/config.js'
7
+ import { createRg } from './src/rg.js'
8
+ import { registerJarTools } from './src/tools.js'
9
+
10
+ /** Loader 条目标识,与 patch 里的 id 一致。 */
11
+ export const name = 'jar-cfr'
12
+
13
+ /** 硬依赖:缺工具注册表或子进程服务时插件不加载。 */
14
+ export const inject = ['tools', 'subprocess']
15
+
16
+ /**
17
+ * @param {import('@deepseek-ai/cordis').Context} ctx
18
+ * @param {{ cacheDir?: string, javaPath?: string, cfrJar?: string, rgPath?: string }} [config]
19
+ */
20
+ export function apply(ctx, config = {}) {
21
+ const settings = normalizeConfig(config)
22
+ const cfr = createCfr(ctx, settings)
23
+ const rg = createRg(ctx, settings)
24
+ return ctx.effect(() => registerJarTools(ctx, { cfr, rg }))
25
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Jar decompiler",
4
+ "description": "Wraps cfr and ripgrep to decompile jars, search sources, and read classes."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Jar 反编译",
4
+ "description": "封装 cfr 与 rg,提供反编译、源码检索与按类读取三个工具。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "dsh-plugin-cfr",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "把 cfr 与 rg 注册为 DSH 的 jar 反编译工具。",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./cordis.patch.yml": "./cordis.patch.yml",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "src",
17
+ "vendor",
18
+ "cordis.patch.yml",
19
+ "locale/*.json",
20
+ "icon.svg",
21
+ "README.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "@vscode/ripgrep": "^1.18.0"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ }
34
+ }
package/src/cfr.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * cfr 调用层:解析 java 入口、维护反编译缓存、执行全量与单类反编译。
3
+ */
4
+
5
+ import { createHash } from 'node:crypto'
6
+ import { existsSync } from 'node:fs'
7
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
8
+ import { basename, dirname, join, resolve } from 'node:path'
9
+
10
+ const GRACE_MS = 5000
11
+ const COLLECT_BYTES = 64 * 1024
12
+ const SPILL_BYTES = 1024 * 1024
13
+ const SINGLE_CLASS_BYTES = 4 * 1024 * 1024
14
+
15
+ /**
16
+ * @param {import('@deepseek-ai/cordis').Context} ctx
17
+ * @param {{ cacheDir: string, javaPath: string, cfrJar: string }} config
18
+ */
19
+ export function createCfr(ctx, config) {
20
+ /** 每个缓存目录下已扫描出的 Java 源码路径。 */
21
+ const listings = new Map()
22
+ /** 同一 jar 的并发反编译只跑一次。 */
23
+ const inflight = new Map()
24
+ let javaExecutable
25
+
26
+ async function javaCommand(signal) {
27
+ if (!javaExecutable) {
28
+ javaExecutable = ctx.subprocess.resolveExecutable(config.javaPath, undefined, signal)
29
+ }
30
+ try {
31
+ return await javaExecutable
32
+ } catch (error) {
33
+ javaExecutable = undefined
34
+ throw error
35
+ }
36
+ }
37
+
38
+ /**
39
+ * cfr 的启动 argv:始终用 java 直接拉起 cfr.jar,不经过任何命令行包装。
40
+ * @param {readonly string[]} args
41
+ * @param {AbortSignal} [signal]
42
+ */
43
+ async function cfrArgv(args, signal) {
44
+ if (!config.cfrJar) {
45
+ throw new Error('没有配置 cfrJar:请在插件配置里给出 cfr.jar 的绝对路径。')
46
+ }
47
+ return [await javaCommand(signal), '-jar', config.cfrJar, ...args]
48
+ }
49
+
50
+ /**
51
+ * 运行一次子进程并回收输出;非零退出码属于正常返回。
52
+ */
53
+ async function run(argv, { cwd, signal, maxBytes = COLLECT_BYTES, spill = true }) {
54
+ const stdio = {
55
+ stdin: 'ignore',
56
+ stdout: { maxBytes, ...(spill ? { spill: { maxBytes: SPILL_BYTES } } : {}) },
57
+ stderr: { maxBytes, ...(spill ? { spill: { maxBytes: SPILL_BYTES } } : {}) },
58
+ }
59
+ const handle = ctx.subprocess.spawn({ argv, cwd, stdio, graceMs: GRACE_MS, signal })
60
+ const outcome = await handle.done
61
+ const stdout = readCollected(handle.collected.stdout)
62
+ const stderr = readCollected(handle.collected.stderr)
63
+ return {
64
+ exitCode: outcome.exitCode,
65
+ signal: outcome.signal ?? undefined,
66
+ stdout: stdout.text,
67
+ stderr: stderr.text,
68
+ truncated: stdout.lossy || stderr.lossy,
69
+ spillPath: stdout.spillPath ?? stderr.spillPath,
70
+ }
71
+ }
72
+
73
+ /**
74
+ * 反编译整个 jar 到缓存目录;已完成过的缓存直接复用。
75
+ * @param {string} jarPath 已解析的 jar 绝对路径
76
+ * @param {AbortSignal} [signal]
77
+ * @returns {Promise<{ outDir: string, classCount: number, cached: boolean }>}
78
+ */
79
+ async function decompile(jarPath, signal) {
80
+ const jar = resolve(jarPath)
81
+ const info = await stat(jar)
82
+ if (!info.isFile()) throw new Error(`不是文件:${jar}`)
83
+ const dir = cacheDirectory(jar, info)
84
+ const outDir = join(dir, 'sources')
85
+ const marker = join(dir, 'done.json')
86
+ const done = await readMarker(marker)
87
+ if (done) return { outDir, classCount: done.classCount, cached: true }
88
+ const running = inflight.get(dir)
89
+ if (running) return running
90
+ const task = compile(jar, dir, outDir, marker, info, signal).finally(() => inflight.delete(dir))
91
+ inflight.set(dir, task)
92
+ return task
93
+ }
94
+
95
+ async function compile(jar, dir, outDir, marker, info, signal) {
96
+ await rm(dir, { recursive: true, force: true })
97
+ await mkdir(outDir, { recursive: true })
98
+ listings.delete(outDir)
99
+ const argv = await cfrArgv([jar, '--outputdir', outDir], signal)
100
+ const result = await run(argv, { cwd: dirname(jar), signal })
101
+ if (result.exitCode !== 0) {
102
+ throw new Error(`cfr 退出码 ${result.exitCode}:${tail(result.stderr || result.stdout)}`)
103
+ }
104
+ const classCount = (await listJavaFiles(outDir)).length
105
+ if (classCount === 0) {
106
+ throw new Error(`cfr 没有产出源码,输入可能不是 jar:${tail(result.stderr || result.stdout)}`)
107
+ }
108
+ const record = { jar, classCount, size: info.size, createdAt: new Date().toISOString() }
109
+ await writeFile(marker, JSON.stringify(record), 'utf8')
110
+ return { outDir, classCount, cached: false }
111
+ }
112
+
113
+ /**
114
+ * 读取一个类的源码:缓存优先,缺失时即时单类反编译并写入部分缓存。
115
+ * @param {string} jarPath jar 绝对路径
116
+ * @param {string} className 全限定名、内部名或短名
117
+ * @param {AbortSignal} [signal]
118
+ * @returns {Promise<{ status: 'ok', source: string, path: string, cached: boolean }
119
+ * | { status: 'ambiguous', candidates: string[] }
120
+ * | { status: 'missing' }>}
121
+ */
122
+ async function readClass(jarPath, className, signal) {
123
+ const jar = resolve(jarPath)
124
+ const info = await stat(jar)
125
+ const dir = cacheDirectory(jar, info)
126
+ const found = await findClass(dir, className)
127
+ if (found) return found
128
+ const source = await decompileSingle(jar, className, signal)
129
+ if (source === undefined) return { status: 'missing' }
130
+ const relative = `${normalizeClassPath(className)}.java`
131
+ const target = join(dir, 'partial', ...relative.split('/'))
132
+ await mkdir(dirname(target), { recursive: true })
133
+ await writeFile(target, source, 'utf8')
134
+ listings.delete(join(dir, 'partial'))
135
+ return { status: 'ok', source, path: relative, cached: false }
136
+ }
137
+
138
+ /**
139
+ * 在缓存与部分缓存里查找一个类。
140
+ */
141
+ async function findClass(dir, className) {
142
+ const relative = normalizeClassPath(className)
143
+ if (!relative) return undefined
144
+ const target = `${relative}.java`
145
+ const roots = [join(dir, 'sources'), join(dir, 'partial')].filter((root) => existsSync(root))
146
+ for (const root of roots) {
147
+ if ((await listJavaFiles(root)).includes(target)) {
148
+ return { status: 'ok', source: await readFile(join(root, target), 'utf8'), path: target, cached: true }
149
+ }
150
+ }
151
+ const short = `${relative.split('/').pop().toLowerCase()}.java`
152
+ const named = []
153
+ const fuzzy = []
154
+ for (const root of roots) {
155
+ for (const file of await listJavaFiles(root)) {
156
+ const base = file.split('/').pop().toLowerCase()
157
+ if (base === short) named.push({ root, file })
158
+ else if (base.includes(short.replace(/\.java$/u, ''))) fuzzy.push({ root, file })
159
+ }
160
+ }
161
+ const pool = named.length > 0 ? named : fuzzy
162
+ if (pool.length === 1) {
163
+ const [{ root, file }] = pool
164
+ return { status: 'ok', source: await readFile(join(root, file), 'utf8'), path: file, cached: true }
165
+ }
166
+ if (pool.length > 1) {
167
+ return { status: 'ambiguous', candidates: pool.slice(0, 20).map((entry) => entry.file) }
168
+ }
169
+ return undefined
170
+ }
171
+
172
+ /**
173
+ * 只反编译一个类,源码直接来自 cfr 的标准输出。
174
+ */
175
+ async function decompileSingle(jar, className, signal) {
176
+ const argv = await cfrArgv(
177
+ [jar, normalizeClassPath(className).replace(/\//gu, '.')],
178
+ signal,
179
+ )
180
+ const result = await run(argv, {
181
+ cwd: dirname(jar),
182
+ signal,
183
+ maxBytes: SINGLE_CLASS_BYTES,
184
+ spill: false,
185
+ })
186
+ if (result.exitCode !== 0) return undefined
187
+ if (result.truncated) {
188
+ throw new Error(`单类源码超过 ${SINGLE_CLASS_BYTES} 字节,改为先用 jar_decompile 反编译整个 jar。`)
189
+ }
190
+ const source = result.stdout.trim()
191
+ return source ? `${source}\n` : undefined
192
+ }
193
+
194
+ /**
195
+ * 缓存目录里的全部 Java 源码相对路径。
196
+ */
197
+ async function listJavaFiles(root) {
198
+ const cached = listings.get(root)
199
+ if (cached) return cached
200
+ const files = []
201
+ const stack = ['']
202
+ while (stack.length > 0) {
203
+ const relative = stack.pop()
204
+ const entries = await readdir(join(root, relative), { withFileTypes: true })
205
+ for (const entry of entries) {
206
+ const child = relative ? `${relative}/${entry.name}` : entry.name
207
+ if (entry.isDirectory()) stack.push(child)
208
+ else if (entry.name.endsWith('.java')) files.push(child)
209
+ }
210
+ }
211
+ listings.set(root, files)
212
+ return files
213
+ }
214
+
215
+ /**
216
+ * 缓存目录按 jar 路径与内容指纹区分,jar 更新后自动落到新目录。
217
+ */
218
+ function cacheDirectory(jar, info) {
219
+ const digest = createHash('sha1')
220
+ .update(`${jar}|${info.size}|${info.mtimeMs}`)
221
+ .digest('hex')
222
+ .slice(0, 12)
223
+ const stem = basename(jar).replace(/[^\w.-]+/gu, '_')
224
+ return join(config.cacheDir, `${stem}-${digest}`)
225
+ }
226
+
227
+ return { decompile, readClass }
228
+ }
229
+
230
+ async function readMarker(marker) {
231
+ try {
232
+ const parsed = JSON.parse(await readFile(marker, 'utf8'))
233
+ return Number.isInteger(parsed?.classCount) ? parsed : undefined
234
+ } catch {
235
+ return undefined
236
+ }
237
+ }
238
+
239
+ function readCollected(reader) {
240
+ if (!reader) return { text: '', lossy: false, spillPath: undefined }
241
+ const read = reader.readFrom(0)
242
+ return { text: read.text, lossy: read.lossy, spillPath: read.spillPath }
243
+ }
244
+
245
+ /**
246
+ * 类名统一成源码相对路径形式:`com.foo.Bar` 与 `com/foo/Bar` 都得到 `com/foo/Bar`。
247
+ */
248
+ function normalizeClassPath(className) {
249
+ const trimmed = className.trim().replace(/\\/gu, '/').replace(/\.java$/iu, '')
250
+ if (trimmed.includes('/')) return trimmed
251
+ return trimmed.replace(/\./gu, '/')
252
+ }
253
+
254
+ function tail(text) {
255
+ return text.trim().slice(-2000)
256
+ }
package/src/config.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 插件配置的默认值与规范化。
3
+ *
4
+ * 配置来自 Loader 行的 `config`;缺失字段一律回退默认值。
5
+ */
6
+
7
+ import { homedir } from 'node:os'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ /** 插件包根目录。 */
12
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
13
+
14
+ /** 随包分发的 cfr.jar。 */
15
+ export const BUNDLED_CFR = join(PACKAGE_ROOT, 'vendor', 'cfr.jar')
16
+
17
+ /** 配置缺省值。 */
18
+ export const DEFAULT_CONFIG = Object.freeze({
19
+ cacheDir: '',
20
+ javaPath: 'java',
21
+ cfrJar: BUNDLED_CFR,
22
+ rgPath: '',
23
+ })
24
+
25
+ /**
26
+ * DSH 数据根目录。
27
+ * @returns {string}
28
+ */
29
+ function dshHome() {
30
+ const configured = process.env.DSH_HOME?.trim()
31
+ return configured || join(homedir(), '.dsh')
32
+ }
33
+
34
+ function text(value) {
35
+ if (value === undefined || value === null) return ''
36
+ return String(value).trim()
37
+ }
38
+
39
+ /**
40
+ * 把原始配置规范化成插件内部使用的完整配置。
41
+ * @param {Record<string, unknown>} [raw] Loader 行传入的 config
42
+ * @returns {{ cacheDir: string, javaPath: string, cfrJar: string, rgPath: string }}
43
+ */
44
+ export function normalizeConfig(raw) {
45
+ const source = raw ?? {}
46
+ return {
47
+ cacheDir: text(source.cacheDir) || join(dshHome(), 'cache', 'jar-decompile'),
48
+ javaPath: text(source.javaPath) || DEFAULT_CONFIG.javaPath,
49
+ cfrJar: text(source.cfrJar) || DEFAULT_CONFIG.cfrJar,
50
+ rgPath: text(source.rgPath),
51
+ }
52
+ }
package/src/rg.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * ripgrep 调用层:在反编译源码目录里检索。
3
+ */
4
+
5
+ import { createRequire } from 'node:module'
6
+
7
+ const require = createRequire(import.meta.url)
8
+
9
+ const GRACE_MS = 5000
10
+ const COLLECT_BYTES = 512 * 1024
11
+ const SPILL_BYTES = 2 * 1024 * 1024
12
+ const FALLBACK_COMMAND = 'rg'
13
+
14
+ /**
15
+ * @vscode/ripgrep 随包安装的 rg;依赖缺失时返回 undefined。
16
+ */
17
+ function bundledRg() {
18
+ try {
19
+ const { rgPath } = require('@vscode/ripgrep')
20
+ return typeof rgPath === 'string' ? rgPath : undefined
21
+ } catch {
22
+ return undefined
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {import('@deepseek-ai/cordis').Context} ctx
28
+ * @param {{ rgPath: string }} config
29
+ */
30
+ export function createRg(ctx, config) {
31
+ let rgExecutable
32
+
33
+ async function rgCommand(signal) {
34
+ if (!rgExecutable) {
35
+ const command = config.rgPath || bundledRg() || FALLBACK_COMMAND
36
+ rgExecutable = ctx.subprocess.resolveExecutable(command, undefined, signal)
37
+ }
38
+ try {
39
+ return await rgExecutable
40
+ } catch (error) {
41
+ rgExecutable = undefined
42
+ throw error
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 在目录里按正则检索,返回逐行匹配。
48
+ * @param {{ dir: string, pattern: string, glob?: string, ignoreCase?: boolean, signal?: AbortSignal }} request
49
+ * @returns {Promise<{ matches: Array<{ path: string, line: number, text: string }>, truncated: boolean }>}
50
+ */
51
+ async function search({ dir, pattern, glob, ignoreCase, signal }) {
52
+ const rg = await rgCommand(signal)
53
+ const argv = [
54
+ rg,
55
+ '--line-number',
56
+ '--no-heading',
57
+ '--with-filename',
58
+ '--color=never',
59
+ '--no-config',
60
+ '-e',
61
+ pattern,
62
+ ]
63
+ if (ignoreCase) argv.push('--ignore-case')
64
+ if (glob) argv.push('--glob', glob)
65
+ const stdio = {
66
+ stdin: 'ignore',
67
+ stdout: { maxBytes: COLLECT_BYTES, spill: { maxBytes: SPILL_BYTES } },
68
+ stderr: { maxBytes: COLLECT_BYTES, spill: { maxBytes: SPILL_BYTES } },
69
+ }
70
+ const handle = ctx.subprocess.spawn({ argv, cwd: dir, stdio, graceMs: GRACE_MS, signal })
71
+ const outcome = await handle.done
72
+ const stdout = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false }
73
+ const stderr = handle.collected.stderr?.readFrom(0) ?? { text: '', lossy: false }
74
+ if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
75
+ throw new Error(`rg 退出码 ${outcome.exitCode}:${stderr.text.trim().slice(0, 1000)}`)
76
+ }
77
+ return { matches: parseMatches(stdout.text), truncated: stdout.lossy }
78
+ }
79
+
80
+ return { search }
81
+ }
82
+
83
+ /**
84
+ * 解析 `path:line:text` 形式的匹配行;路径已相对检索目录。
85
+ */
86
+ function parseMatches(text) {
87
+ const matches = []
88
+ for (const line of text.split(/\r?\n/u)) {
89
+ if (!line) continue
90
+ const first = line.indexOf(':')
91
+ if (first <= 0) continue
92
+ const second = line.indexOf(':', first + 1)
93
+ if (second < 0) continue
94
+ const lineNumber = Number(line.slice(first + 1, second))
95
+ if (!Number.isInteger(lineNumber)) continue
96
+ matches.push({
97
+ path: line.slice(0, first).replace(/\\/gu, '/'),
98
+ line: lineNumber,
99
+ text: line.slice(second + 1),
100
+ })
101
+ }
102
+ return matches
103
+ }
package/src/tools.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * 模型可调用的 jar 工具定义。
3
+ */
4
+
5
+ import { isAbsolute, resolve } from 'node:path'
6
+
7
+ const DEFAULT_READ_LIMIT = 400
8
+ const MAX_READ_LIMIT = 2000
9
+ const DEFAULT_MAX_MATCHES = 80
10
+ const MAX_MAX_MATCHES = 500
11
+ const DECOMPILE_TIMEOUT_MS = 900_000
12
+ const READ_TIMEOUT_MS = 120_000
13
+
14
+ /**
15
+ * 注册 jar_decompile、jar_search、jar_read 三个工具。
16
+ *
17
+ * @param {import('@deepseek-ai/cordis').Context} ctx
18
+ * @param {{ cfr: ReturnType<typeof import('./cfr.js').createCfr>, rg: ReturnType<typeof import('./rg.js').createRg> }} deps
19
+ * @returns {() => void} 注销函数
20
+ */
21
+ export function registerJarTools(ctx, { cfr, rg }) {
22
+ const disposers = [
23
+ ctx.tools.register(decompileTool(cfr)),
24
+ ctx.tools.register(searchTool(cfr, rg)),
25
+ ctx.tools.register(readTool(cfr)),
26
+ ]
27
+ return () => {
28
+ for (const dispose of disposers) dispose()
29
+ }
30
+ }
31
+
32
+ function decompileTool(cfr) {
33
+ return {
34
+ name: 'jar_decompile',
35
+ description: '把一个 jar 反编译成 Java 源码并缓存到本地目录,返回缓存目录。'
36
+ + '同一个 jar 只会真正反编译一次,之后命中缓存直接返回。'
37
+ + '需要在整个 jar 内检索时用它预热,或直接调用 jar_search(会自动确保已反编译)。',
38
+ parameters: {
39
+ type: 'object',
40
+ properties: {
41
+ jar: {
42
+ type: 'string',
43
+ description: 'jar 文件路径;相对路径按当前会话工作目录解析。',
44
+ },
45
+ },
46
+ required: ['jar'],
47
+ additionalProperties: false,
48
+ },
49
+ output: {
50
+ schema: {
51
+ type: 'object',
52
+ properties: {
53
+ ok: { type: 'boolean', description: '是否成功' },
54
+ jar: { type: 'string', description: '调用时传入的 jar' },
55
+ outDir: { type: 'string', description: '缓存里的源码目录' },
56
+ classCount: { type: 'number', description: '产出的 Java 文件数' },
57
+ cached: { type: 'boolean', description: '是否直接复用已有缓存' },
58
+ text: { type: 'string', description: '给模型读的正文' },
59
+ },
60
+ },
61
+ render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '(无输出)') }],
62
+ },
63
+ timeoutMs: DECOMPILE_TIMEOUT_MS,
64
+ async execute(args, exec) {
65
+ const jar = requireString(args, 'jar')
66
+ if (!jar) return failure('jar 必须是非空字符串路径。')
67
+ const jarPath = resolveFrom(sessionCwd(exec), jar)
68
+ try {
69
+ const result = await cfr.decompile(jarPath, exec.signal)
70
+ return {
71
+ ok: true,
72
+ jar,
73
+ outDir: result.outDir,
74
+ classCount: result.classCount,
75
+ cached: result.cached,
76
+ text: [
77
+ `${result.cached ? '缓存已存在' : '反编译完成'}:${jar}`,
78
+ `输出目录:${result.outDir}`,
79
+ `Java 文件:${result.classCount}`,
80
+ '后续用 jar_search 检索源码,或用 jar_read 读取单个类。',
81
+ ].join('\n'),
82
+ }
83
+ } catch (error) {
84
+ return failure(`反编译失败:${messageOf(error)}`)
85
+ }
86
+ },
87
+ }
88
+ }
89
+
90
+ function searchTool(cfr, rg) {
91
+ return {
92
+ name: 'jar_search',
93
+ description: '在 jar 的反编译源码里用 ripgrep 做正则检索,返回 `文件:行号:内容`。'
94
+ + 'jar 尚未反编译时会自动先反编译。适合定位类、方法、字符串常量与调用点。'
95
+ + '用 glob 可以只搜某些包,例如 `**/util/*.java`。',
96
+ parameters: {
97
+ type: 'object',
98
+ properties: {
99
+ jar: {
100
+ type: 'string',
101
+ description: 'jar 文件路径;相对路径按当前会话工作目录解析。',
102
+ },
103
+ pattern: {
104
+ type: 'string',
105
+ description: 'ripgrep 正则表达式。',
106
+ },
107
+ glob: {
108
+ type: 'string',
109
+ description: '限定路径的 glob,例如 `**/service/*.java`。',
110
+ },
111
+ ignoreCase: {
112
+ type: 'boolean',
113
+ description: '是否忽略大小写。',
114
+ },
115
+ maxMatches: {
116
+ type: 'number',
117
+ description: `最多返回多少处匹配,默认 ${DEFAULT_MAX_MATCHES},上限 ${MAX_MAX_MATCHES}。`,
118
+ },
119
+ },
120
+ required: ['jar', 'pattern'],
121
+ additionalProperties: false,
122
+ },
123
+ output: {
124
+ schema: {
125
+ type: 'object',
126
+ properties: {
127
+ ok: { type: 'boolean', description: '是否成功' },
128
+ jar: { type: 'string', description: '调用时传入的 jar' },
129
+ total: { type: 'number', description: '匹配总数' },
130
+ shown: { type: 'number', description: '实际返回的匹配数' },
131
+ truncated: { type: 'boolean', description: '结果是否被截断' },
132
+ text: { type: 'string', description: '给模型读的正文' },
133
+ },
134
+ },
135
+ render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '(无输出)') }],
136
+ },
137
+ timeoutMs: DECOMPILE_TIMEOUT_MS,
138
+ async execute(args, exec) {
139
+ const jar = requireString(args, 'jar')
140
+ const pattern = requireString(args, 'pattern')
141
+ if (!jar) return failure('jar 必须是非空字符串路径。')
142
+ if (!pattern) return failure('pattern 必须是非空正则表达式。')
143
+ const jarPath = resolveFrom(sessionCwd(exec), jar)
144
+ const glob = optionalString(args, 'glob')
145
+ try {
146
+ const compiled = await cfr.decompile(jarPath, exec.signal)
147
+ const result = await rg.search({
148
+ dir: compiled.outDir,
149
+ pattern,
150
+ glob,
151
+ ignoreCase: args?.ignoreCase === true,
152
+ signal: exec.signal,
153
+ })
154
+ const maxMatches = clampInt(args?.maxMatches, 1, MAX_MAX_MATCHES, DEFAULT_MAX_MATCHES)
155
+ const shown = result.matches.slice(0, maxMatches)
156
+ const total = result.matches.length
157
+ const lines = [total === 0
158
+ ? `没有匹配:${pattern}`
159
+ : `在 ${jar} 里匹配到 ${total} 处${total > shown.length ? `(显示前 ${shown.length} 处)` : ''}:`]
160
+ for (const match of shown) {
161
+ lines.push(`${match.path}:${match.line}:${match.text}`)
162
+ }
163
+ return {
164
+ ok: true,
165
+ jar,
166
+ total,
167
+ shown: shown.length,
168
+ truncated: result.truncated || total > shown.length,
169
+ text: lines.join('\n'),
170
+ }
171
+ } catch (error) {
172
+ return failure(`检索失败:${messageOf(error)}`)
173
+ }
174
+ },
175
+ }
176
+ }
177
+
178
+ function readTool(cfr) {
179
+ return {
180
+ name: 'jar_read',
181
+ description: '读取 jar 里一个类的反编译源码,按行返回。'
182
+ + '类名可以写全限定名 `com.foo.Bar`、内部名 `com/foo/Bar` 或短名 `Bar`;短名有多个候选时会列出候选。'
183
+ + '缓存里没有该类时会即时反编译这一个类。大文件用 offset 与 limit 翻页。',
184
+ parameters: {
185
+ type: 'object',
186
+ properties: {
187
+ jar: {
188
+ type: 'string',
189
+ description: 'jar 文件路径;相对路径按当前会话工作目录解析。',
190
+ },
191
+ className: {
192
+ type: 'string',
193
+ description: '类名,支持全限定名、内部名或短名。',
194
+ },
195
+ offset: {
196
+ type: 'number',
197
+ description: '起始行,1 基,默认 1。',
198
+ },
199
+ limit: {
200
+ type: 'number',
201
+ description: `读取行数,默认 ${DEFAULT_READ_LIMIT},上限 ${MAX_READ_LIMIT}。`,
202
+ },
203
+ },
204
+ required: ['jar', 'className'],
205
+ additionalProperties: false,
206
+ },
207
+ output: {
208
+ schema: {
209
+ type: 'object',
210
+ properties: {
211
+ ok: { type: 'boolean', description: '是否成功' },
212
+ jar: { type: 'string', description: '调用时传入的 jar' },
213
+ path: { type: 'string', description: '源码相对路径' },
214
+ totalLines: { type: 'number', description: '源码总行数' },
215
+ startLine: { type: 'number', description: '本次返回的起始行' },
216
+ text: { type: 'string', description: '给模型读的正文' },
217
+ },
218
+ },
219
+ render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '(无输出)') }],
220
+ },
221
+ timeoutMs: READ_TIMEOUT_MS,
222
+ async execute(args, exec) {
223
+ const jar = requireString(args, 'jar')
224
+ const className = requireString(args, 'className')
225
+ if (!jar) return failure('jar 必须是非空字符串路径。')
226
+ if (!className) return failure('className 必须是非空类名。')
227
+ const jarPath = resolveFrom(sessionCwd(exec), jar)
228
+ const offset = clampInt(args?.offset, 1, Number.MAX_SAFE_INTEGER, 1)
229
+ const limit = clampInt(args?.limit, 1, MAX_READ_LIMIT, DEFAULT_READ_LIMIT)
230
+ try {
231
+ const result = await cfr.readClass(jarPath, className, exec.signal)
232
+ if (result.status === 'ambiguous') {
233
+ return failure(`类名有多个候选,请改用完整类名:\n${result.candidates.join('\n')}`)
234
+ }
235
+ if (result.status === 'missing') {
236
+ return failure(`没有找到类 ${className}:jar 内可能不存在该类,或类名写法有误。`)
237
+ }
238
+ const lines = result.source.split(/\r?\n/u)
239
+ const total = lines.length
240
+ const start = Math.min(offset, total)
241
+ const end = Math.min(total, start + limit - 1)
242
+ const body = lines
243
+ .slice(start - 1, end)
244
+ .map((line, index) => `${String(start + index).padStart(4, ' ')} ${line}`)
245
+ .join('\n')
246
+ const remaining = total - end
247
+ return {
248
+ ok: true,
249
+ jar,
250
+ path: result.path,
251
+ totalLines: total,
252
+ startLine: start,
253
+ text: [
254
+ `${result.path}(共 ${total} 行,显示 ${start}-${end})`,
255
+ body,
256
+ remaining > 0 ? `\n还有 ${remaining} 行,可增大 offset 继续读。` : '',
257
+ ].filter(Boolean).join('\n'),
258
+ }
259
+ } catch (error) {
260
+ return failure(`读取失败:${messageOf(error)}`)
261
+ }
262
+ },
263
+ }
264
+ }
265
+
266
+ function sessionCwd(exec) {
267
+ const cwd = exec?.agent?.session?.header?.cwd
268
+ return typeof cwd === 'string' && cwd ? cwd : process.cwd()
269
+ }
270
+
271
+ function resolveFrom(cwd, path) {
272
+ return isAbsolute(path) ? path : resolve(cwd, path)
273
+ }
274
+
275
+ function requireString(args, key) {
276
+ const value = args?.[key]
277
+ if (typeof value !== 'string' || value.trim() === '') return undefined
278
+ return value.trim()
279
+ }
280
+
281
+ function optionalString(args, key) {
282
+ const value = args?.[key]
283
+ if (typeof value !== 'string' || value.trim() === '') return undefined
284
+ return value.trim()
285
+ }
286
+
287
+ function clampInt(value, min, max, fallback) {
288
+ if (!Number.isInteger(value)) return fallback
289
+ return Math.min(max, Math.max(min, value))
290
+ }
291
+
292
+ function failure(text) {
293
+ return { ok: false, text }
294
+ }
295
+
296
+ function messageOf(error) {
297
+ return String(error?.message ?? error)
298
+ }
package/vendor/cfr.jar ADDED
Binary file