dsh-plugin-discover 1.0.0 → 1.0.1

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/CHANGELOG.md CHANGED
@@ -1,31 +1,37 @@
1
- # 更新日志
2
-
3
- ## 1.0.0 — 2026-10-25
4
-
5
- 首个发布版本。
6
-
7
- **是什么**:DSH 本地插件检索器。用中文或英文口语找 DeepSeek Harness 插件:本地 BM25 词面检索加可选向量重排,返回排好序的候选和安装目标。不连服务端、查询不调模型、插件永不安装、模型不自动下载。
8
-
9
- **安装**:
10
-
11
- ```bash
12
- dsh plugin --profile web add dsh-plugin-discover
13
- ```
14
-
15
- 装完建一次索引(约 25 MB 下载):`npx dsh-plugin-discover build`,或直接在会话里让 AI 建。详见 README。
16
-
17
- **主要能力**:
18
-
19
- - 中文口语查询,AI 自动补英文改写;4 路 BM25 检索、RRF 融合、同仓库合并
20
- - 质量过滤四档(strict / balanced / loose / off)与无关结果筛选四档(none / low / medium / high)
21
- - 可选向量模式(balance / high):独立模型包显式拉取,默认关闭,不开零开销
22
- - CLI:`build` / `status` / `search` / `model pull`
23
- - 插件详情页配置表单,保存即热生效
24
-
25
- **已知限制**:
26
-
27
- - 仅在 Windows 上验证过;macOS / Linux 理论可用,未实测
28
- - 向量模式需先显式拉取约 420 MB 模型包,不会自动下载
29
- - 中文查询依赖 AI 补英文改写;改写漂移可能带进相邻插件
30
-
31
- **数据**:索引来自 dsh.so(CC-BY-4.0)与 dsh-plugin-catalog(CC0-1.0),仅本地缓存,按更新周期自动刷新。
1
+ # 更新日志
2
+
3
+ ## 1.0.1 — 2026-10-25
4
+
5
+ - **模型包改为 npm 分发**:`dsh-plugin-discover-model@0.1.0` 已发布到 npm,`model pull` 不带参数即可安装;GitHub Release 资产保留为备用渠道。
6
+ - **修复 `model pull` 假成功**:pnpm 失败(404、网络错误等)现在返回非零退出码且不覆盖 `model.json`;只有 `ERR_PNPM_IGNORED_BUILDS` 视为警告。若已装旧版本,会明确提示「仍可用但未更新」。
7
+ - 模型包代码(index/server/verify/manifest 等;权重与 tokenizer 除外)纳入仓库。
8
+
9
+ ## 1.0.0 — 2026-10-25
10
+
11
+ 首个发布版本。
12
+
13
+ **是什么**:DSH 本地插件检索器。用中文或英文口语找 DeepSeek Harness 插件:本地 BM25 词面检索加可选向量重排,返回排好序的候选和安装目标。不连服务端、查询不调模型、插件永不安装、模型不自动下载。
14
+
15
+ **安装**:
16
+
17
+ ```bash
18
+ dsh plugin --profile web add dsh-plugin-discover
19
+ ```
20
+
21
+ 装完建一次索引(约 25 MB 下载):`npx dsh-plugin-discover build`,或直接在会话里让 AI 建。详见 README。
22
+
23
+ **主要能力**:
24
+
25
+ - 中文口语查询,AI 自动补英文改写;4 路 BM25 检索、RRF 融合、同仓库合并
26
+ - 质量过滤四档(strict / balanced / loose / off)与无关结果筛选四档(none / low / medium / high)
27
+ - 可选向量模式(balance / high):独立模型包显式拉取,默认关闭,不开零开销
28
+ - CLI:`build` / `status` / `search` / `model pull`
29
+ - 插件详情页配置表单,保存即热生效
30
+
31
+ **已知限制**:
32
+
33
+ - 仅在 Windows 上验证过;macOS / Linux 理论可用,未实测
34
+ - 向量模式需先显式拉取约 420 MB 模型包,不会自动下载
35
+ - 中文查询依赖 AI 补英文改写;改写漂移可能带进相邻插件
36
+
37
+ **数据**:索引来自 dsh.so(CC-BY-4.0)与 dsh-plugin-catalog(CC0-1.0),仅本地缓存,按更新周期自动刷新。
package/README.md CHANGED
@@ -157,19 +157,20 @@ Lexical search (the default) covers most needs. Vector mode reranks candidates w
157
157
  The cost is a separate model package `dsh-plugin-discover-model` (int8-quantized [multilingual-e5-small](https://huggingface.co/intfloat/multilingual-e5-small), 384 dimensions): about 80 MB to download, roughly 420 MB installed with the ONNX runtime; runtime memory about 400–500 MB. The model runs in a child process, exits after 5 minutes idle, and costs nothing while vector mode is off; ONNX defaults to 4 threads (override with the `DSH_MODEL_THREADS` environment variable). It is not a dependency of the plugin; only `model pull` installs it:
158
158
 
159
159
  ```bash
160
- # from npm (once published)
161
- dsh-plugin-discover model pull --spec dsh-plugin-discover-model@0.1.0
160
+ # default: install from npm
161
+ dsh-plugin-discover model pull
162
162
 
163
- # from a local directory
164
- dsh-plugin-discover model pull --spec "link:D:\dsh-plugin-discover\models\multilingual-e5-small"
163
+ # fallback: install from the GitHub Release asset
164
+ dsh-plugin-discover model pull --spec "https://github.com/apeiria-network/dsh-plugin-discover/releases/download/model-v0.1.0/dsh-plugin-discover-model-0.1.0.tgz"
165
165
 
166
- # from a packed tarball
166
+ # development: install from a local directory or packed tarball
167
+ dsh-plugin-discover model pull --spec "link:D:\dsh-plugin-discover\models\multilingual-e5-small"
167
168
  dsh-plugin-discover model pull --spec "file:D:\path\to\dsh-plugin-discover-model-0.1.0.tgz"
168
169
 
169
170
  dsh-plugin-discover model status
170
171
  ```
171
172
 
172
- `model pull` verifies every file against the manifest (SHA-256 by default) and writes a `model.json` pointer into the index directory. pnpm may exit with an `ERR_PNPM_IGNORED_BUILDS` warning because `onnxruntime-node`'s build script is skipped — the prebuilt CPU binary still works; it is a warning, not a failure.
173
+ `model pull` installs the package into the current DSH profile, verifies every file against the manifest (SHA-256 by default), and writes a `model.json` pointer into the index directory. pnpm may exit with an `ERR_PNPM_IGNORED_BUILDS` warning because `onnxruntime-node`'s build script is skipped — the prebuilt CPU binary still works; it is a warning, not a failure. Other install failures return a non-zero exit code and keep the installed version in place.
173
174
 
174
175
  Once installed, set **Vector search enhancement** on the plugin detail page to:
175
176
 
package/README.zh.md CHANGED
@@ -156,19 +156,20 @@ DSH 插件不能向 `dsh` 命令注册子命令,所以 CLI 作为包自带的
156
156
  代价是一个独立模型包 `dsh-plugin-discover-model`(int8 量化的 [multilingual-e5-small](https://huggingface.co/intfloat/multilingual-e5-small),384 维):下载约 80 MB,连同 ONNX 运行时装完约 420 MB;运行时内存约 400–500 MB。模型跑在子进程,空闲 5 分钟自动退出,不开向量时零开销;ONNX 默认 4 线程(可用环境变量 `DSH_MODEL_THREADS` 覆盖)。它不是主插件的依赖,只有 `model pull` 才装:
157
157
 
158
158
  ```bash
159
- # 从 npm(发布后)
160
- dsh-plugin-discover model pull --spec dsh-plugin-discover-model@0.1.0
159
+ # 默认:从 npm 安装
160
+ dsh-plugin-discover model pull
161
161
 
162
- # 从本地目录
163
- dsh-plugin-discover model pull --spec "link:D:\dsh-plugin-discover\models\multilingual-e5-small"
162
+ # 备用:从 GitHub Release 资产安装
163
+ dsh-plugin-discover model pull --spec "https://github.com/apeiria-network/dsh-plugin-discover/releases/download/model-v0.1.0/dsh-plugin-discover-model-0.1.0.tgz"
164
164
 
165
- # 从 tarball 文件
165
+ # 开发:从本地目录或 tarball 安装
166
+ dsh-plugin-discover model pull --spec "link:D:\dsh-plugin-discover\models\multilingual-e5-small"
166
167
  dsh-plugin-discover model pull --spec "file:D:\path\to\dsh-plugin-discover-model-0.1.0.tgz"
167
168
 
168
169
  dsh-plugin-discover model status
169
170
  ```
170
171
 
171
- `model pull` 按 manifest 逐文件校验(默认 SHA-256),并在索引目录写 `model.json` 指针。安装时 pnpm 可能以 `ERR_PNPM_IGNORED_BUILDS` 警告 `onnxruntime-node` 的构建脚本被跳过——预编译 CPU 二进制仍然可用,这是警告不是失败。
172
+ `model pull` 把模型包装进当前 DSH profile,按 manifest 逐文件校验(默认 SHA-256),并在索引目录写 `model.json` 指针。安装时 pnpm 可能以 `ERR_PNPM_IGNORED_BUILDS` 警告 `onnxruntime-node` 的构建脚本被跳过——预编译 CPU 二进制仍然可用,这是警告不是失败;其他安装失败会返回非零退出码,并保留已安装的版本。
172
173
 
173
174
  装好后,在插件详情页把「向量化检索增强」改为:
174
175
 
package/lib/cli.js CHANGED
@@ -1,495 +1,523 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Command line entry for building, inspecting, and querying the local index.
4
- * This is a standalone package bin; DSH has no plugin-contributed `dsh`
5
- * subcommand, so `dsh plugin add` installs the package and this bin runs
6
- * beside it.
7
- */
8
- import { parseArgs } from 'node:util'
9
- import { createHash } from 'node:crypto'
10
- import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
11
- import { spawnSync } from 'node:child_process'
12
- import { createRequire } from 'node:module'
13
- import { homedir } from 'node:os'
14
- import { dirname, join } from 'node:path'
15
- import { defaultIndexDir } from './paths.js'
16
- import { buildIndex } from './build.js'
17
- import {
18
- isLockStale, readBuildLog, readLock, readManifest, readRecords,
19
- } from './store.js'
20
- import { createSearcher, explain, search } from './search.js'
21
- import { textReport } from './report.js'
22
- import { PRESETS } from './filters.js'
23
- import { detectRuntimeVersion } from './compat.js'
24
-
25
- const USAGE = `dsh-plugin-discover — local DSH plugin index
26
-
27
- Usage:
28
- dsh-plugin-discover build [--index-dir <dir>] [--json] [--quiet]
29
- dsh-plugin-discover status [--index-dir <dir>] [--json]
30
- dsh-plugin-discover search <query> [--alt <text>]... [--limit <n>]
31
- [--preset strict|balanced|loose|off]
32
- [--min-verification <0-5>] [--explain <id>] [--json]
33
- dsh-plugin-discover model status [--index-dir <dir>] [--profile <name>] [--json]
34
- dsh-plugin-discover model pull [--spec <spec>] [--profile <name>]
35
- [--index-dir <dir>] [--dry-run] [--no-verify] [--json]
36
-
37
- Options:
38
- --index-dir <dir> Override the index directory. Must match the plugin's indexDir.
39
- --alt <text> A query rewrite; repeat up to three times.
40
- --json Print machine-readable JSON.
41
- --quiet Suppress build progress.
42
- --spec <spec> Model package spec for model pull, such as
43
- link:D:\\path\\to\\model or file:D:\\path\\model.tgz.
44
- --profile <name> DSH profile for model pull (default: DSH_PROFILE or web).
45
- --dry-run Print the model install command without running it.
46
- --no-verify Skip SHA-256 verification of the model package.
47
- -h, --help Show this help.
48
-
49
- Default index directory: %LOCALAPPDATA%\\dsh-plugin-discover (Windows),
50
- ~/Library/Caches/dsh-plugin-discover (macOS),
51
- \${XDG_CACHE_HOME:-~/.cache}/dsh-plugin-discover (Linux).
52
-
53
- The optional model package is not downloaded automatically. \`model pull\`
54
- installs it explicitly; the tool never installs anything.
55
- `
56
-
57
- function resolveIndexDir(value) {
58
- const configured = typeof value === 'string' && value.trim() !== ''
59
- ? value.trim()
60
- : (process.env.DSH_PLUGIN_DISCOVER_INDEX_DIR || defaultIndexDir())
61
- return configured
62
- }
63
-
64
- function formatBytes(bytes) {
65
- if (!Number.isFinite(bytes)) return '?'
66
- if (bytes < 1024) return `${bytes} B`
67
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
68
- return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
69
- }
70
-
71
- function printStatus(indexDir, json) {
72
- const current = readManifest(indexDir)
73
- const lock = readLock(indexDir)
74
- const activeLock = lock && !isLockStale(lock) ? lock : null
75
- const recordsSize = (() => {
76
- try { return statSync(join(indexDir, current?.name ?? '', 'records.json')).size } catch { return null }
77
- })()
78
- const value = {
79
- indexDir,
80
- hasIndex: current !== null,
81
- current: current?.name ?? null,
82
- builtAt: current?.manifest?.builtAt ?? null,
83
- generatedAt: current?.manifest?.sources?.dshSo?.generatedAt ?? null,
84
- cards: current?.manifest?.stats?.cards ?? 0,
85
- sources: current?.manifest?.sources ?? null,
86
- stats: current?.manifest?.stats ?? null,
87
- recordsBytes: recordsSize,
88
- building: activeLock !== null,
89
- lock: activeLock,
90
- lastBuildLog: readBuildLog(indexDir).slice(-2000),
91
- }
92
- if (json) {
93
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
94
- return
95
- }
96
- const lines = [
97
- `索引目录:${indexDir}`,
98
- `当前索引:${current?.name ?? '(无)'}`,
99
- `卡片数量:${value.cards}`,
100
- `数据截至:${value.generatedAt ? String(value.generatedAt).slice(0, 10) : '(未知)'}`,
101
- `建库时间:${value.builtAt ?? '(无)'}`,
102
- `记录文件:${recordsSize === null ? '(无)' : formatBytes(recordsSize)}`,
103
- `来源:${value.sources ? Object.entries(value.sources).filter(([, source]) => source?.ok).map(([name]) => name).join(', ') : '(无)'}`,
104
- `建库中:${value.building ? `是(PID ${activeLock?.pid ?? '?'})` : '否'}`,
105
- `DSH 版本:${detectRuntimeVersion() ?? '(未检测到)'}`,
106
- ]
107
- process.stdout.write(`${lines.join('\n')}\n`)
108
- }
109
-
110
- function printReport(value, current) {
111
- const report = textReport(value, { manifest: current?.manifest ?? null })
112
- process.stdout.write(`${report}\n`)
113
- }
114
-
115
- function profileDirOf(profile) {
116
- const name = profile || process.env.DSH_PROFILE || 'web'
117
- if (process.env.DSH_PROFILE_DIR && (!process.env.DSH_PROFILE || process.env.DSH_PROFILE === name)) {
118
- return { profile: name, dir: process.env.DSH_PROFILE_DIR }
119
- }
120
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
121
- return { profile: name, dir: join(home, 'profiles', name) }
122
- }
123
-
124
- function resolveInstalledModel(profileDir) {
125
- const manifestFile = join(profileDir, 'package.json')
126
- if (!existsSync(manifestFile)) return null
127
- try {
128
- const require = createRequire(manifestFile)
129
- const manifestPath = require.resolve('dsh-plugin-discover-model/manifest.json')
130
- const packageDir = dirname(manifestPath)
131
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
132
- const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
133
- return { packageDir, version: pkg.version, manifest }
134
- } catch {
135
- return null
136
- }
137
- }
138
-
139
- function readModelPointer(indexDir) {
140
- try {
141
- const value = JSON.parse(readFileSync(join(indexDir, 'model.json'), 'utf8'))
142
- return value && typeof value === 'object' ? value : null
143
- } catch {
144
- return null
145
- }
146
- }
147
-
148
- function runtimeDependency(packageDir, name) {
149
- try {
150
- const require = createRequire(join(packageDir, 'package.json'))
151
- const entry = require.resolve(name)
152
- let version = null
153
- try {
154
- const pkg = JSON.parse(readFileSync(join(dirname(entry), '..', 'package.json'), 'utf8'))
155
- version = pkg.version ?? null
156
- } catch {}
157
- return { installed: true, entry, version }
158
- } catch {
159
- return { installed: false }
160
- }
161
- }
162
-
163
- function modelStatusValue({ profile, profileDir, indexDir }) {
164
- const installed = resolveInstalledModel(profileDir)
165
- const pointer = readModelPointer(indexDir)
166
- const value = {
167
- profile,
168
- profileDir,
169
- indexDir,
170
- pointerPath: join(indexDir, 'model.json'),
171
- installed: installed !== null,
172
- packageDir: installed?.packageDir ?? null,
173
- version: installed?.version ?? null,
174
- manifest: installed?.manifest ?? null,
175
- runtime: installed
176
- ? {
177
- tokenizer: runtimeDependency(installed.packageDir, '@huggingface/tokenizers'),
178
- onnxruntime: runtimeDependency(installed.packageDir, 'onnxruntime-node'),
179
- }
180
- : null,
181
- pointer,
182
- pointerMatches: Boolean(
183
- installed && pointer && typeof pointer.packageDir === 'string'
184
- && pointer.packageDir.toLowerCase() === installed.packageDir.toLowerCase()),
185
- }
186
- if (installed) {
187
- const modelBytes = installed.manifest?.files?.['model_quantized.onnx']?.bytes ?? null
188
- const tokenizerBytes = installed.manifest?.files?.['tokenizer.json']?.bytes ?? null
189
- value.modelBytes = modelBytes
190
- value.tokenizerBytes = tokenizerBytes
191
- }
192
- return value
193
- }
194
-
195
- function printModelStatus(value, json) {
196
- if (json) {
197
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
198
- return
199
- }
200
- const lines = [
201
- `模型包:${value.installed ? '已安装' : '未安装'}`,
202
- `profile:${value.profile}(${value.profileDir})`,
203
- `索引目录:${value.indexDir}`,
204
- `指针文件:${value.pointerPath}${value.pointerMatches ? '(已记录)' : '(未记录或与安装目录不一致)'}`,
205
- ]
206
- if (value.installed) {
207
- lines.push(`包目录:${value.packageDir}`)
208
- lines.push(`版本:${value.version}`)
209
- lines.push(`模型:${value.modelBytes ? formatBytes(value.modelBytes) : '(manifest 无大小)'}`)
210
- lines.push(`tokenizer:${value.tokenizerBytes ? formatBytes(value.tokenizerBytes) : '(manifest 无大小)'}`)
211
- lines.push(`tokenizer 运行时:${value.runtime.tokenizer.installed ? '已安装' : '缺失'}`)
212
- lines.push(`ONNX 运行时:${value.runtime.onnxruntime.installed ? '已安装' : '缺失'}`)
213
- if (value.pointerMatches) lines.push(`安装时间:${value.pointer.installedAt ?? '(未记录)'}`)
214
- } else {
215
- lines.push('拉取:dsh-plugin-discover model pull --spec <模型包 spec>')
216
- }
217
- process.stdout.write(`${lines.join('\n')}\n`)
218
- }
219
-
220
- async function sha256File(file) {
221
- const hash = createHash('sha256')
222
- for await (const chunk of createReadStream(file)) hash.update(chunk)
223
- return hash.digest('hex')
224
- }
225
-
226
- async function verifyModelFiles(packageDir, manifest, verifyHashes) {
227
- const errors = []
228
- const files = manifest?.files && typeof manifest.files === 'object' ? manifest.files : {}
229
- for (const [name, expected] of Object.entries(files)) {
230
- const file = join(packageDir, name)
231
- if (!existsSync(file)) {
232
- errors.push(`${name}: missing`)
233
- continue
234
- }
235
- const size = statSync(file).size
236
- if (Number.isFinite(expected.bytes) && size !== expected.bytes) {
237
- errors.push(`${name}: size ${size} != ${expected.bytes}`)
238
- }
239
- if (verifyHashes && typeof expected.sha256 === 'string') {
240
- const hash = await sha256File(file)
241
- if (hash.toLowerCase() !== expected.sha256.toLowerCase()) {
242
- errors.push(`${name}: sha256 mismatch`)
243
- }
244
- }
245
- }
246
- return { ok: errors.length === 0, errors, hashes: verifyHashes, files: Object.keys(files).length }
247
- }
248
-
249
- function installModelPackage(spec, profileDir, dryRun) {
250
- if (!/^[^\s;&|<>^"]+$/.test(spec)) {
251
- throw new Error('模型包 spec 不能包含空白或 shell 元字符')
252
- }
253
- if (dryRun) return { dryRun: true, display: `pnpm add ${spec}` }
254
- const result = process.platform === 'win32'
255
- ? spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `pnpm add ${spec}`], {
256
- cwd: profileDir, stdio: 'inherit', windowsHide: true,
257
- })
258
- : spawnSync('pnpm', ['add', spec], { cwd: profileDir, stdio: 'inherit' })
259
- return {
260
- dryRun: false,
261
- status: result.status,
262
- signal: result.signal,
263
- error: result.error ? String(result.error.message ?? result.error) : null,
264
- }
265
- }
266
-
267
- function buildModelPointer(packageDir, version, manifest) {
268
- return {
269
- format: 1,
270
- package: 'dsh-plugin-discover-model',
271
- version,
272
- packageDir,
273
- server: join(packageDir, 'server.js'),
274
- manifest: join(packageDir, 'manifest.json'),
275
- model: join(packageDir, 'model_quantized.onnx'),
276
- tokenizer: join(packageDir, 'tokenizer.json'),
277
- dimensions: manifest?.dimensions ?? null,
278
- maxTokens: manifest?.maxTokens ?? null,
279
- installedAt: new Date().toISOString(),
280
- }
281
- }
282
-
283
- function writeModelPointer(indexDir, pointer) {
284
- mkdirSync(indexDir, { recursive: true })
285
- const file = join(indexDir, 'model.json')
286
- writeFileSync(file, `${JSON.stringify(pointer, null, 2)}\n`)
287
- return file
288
- }
289
-
290
-
291
- async function main() {
292
- const argv = process.argv.slice(2)
293
- const command = argv[0]
294
- if (!command || command === '-h' || command === '--help' || command === 'help') {
295
- process.stdout.write(USAGE)
296
- return 0
297
- }
298
-
299
- if (command === 'build') {
300
- const { values } = parseArgs({
301
- args: argv.slice(1),
302
- options: {
303
- 'index-dir': { type: 'string' },
304
- json: { type: 'boolean', default: false },
305
- quiet: { type: 'boolean', default: false },
306
- },
307
- allowPositionals: true,
308
- })
309
- const indexDir = resolveIndexDir(values['index-dir'])
310
- const result = await buildIndex({
311
- indexDir,
312
- onProgress: message => { if (!values.quiet) process.stderr.write(`${message}\n`) },
313
- })
314
- if (values.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
315
- else if (result.busy) process.stdout.write(`跳过:另一个建库进程正在运行(PID ${result.lock?.pid ?? '?'})\n`)
316
- else {
317
- process.stdout.write(`索引已发布:${result.name}\n`)
318
- process.stdout.write(`卡片:${result.stats.cards} · 数据截至:${String(result.manifest.sources.dshSo.generatedAt).slice(0, 10)}\n`)
319
- }
320
- return result.ok || result.busy ? 0 : 1
321
- }
322
-
323
- if (command === 'status') {
324
- const { values } = parseArgs({
325
- args: argv.slice(1),
326
- options: {
327
- 'index-dir': { type: 'string' },
328
- json: { type: 'boolean', default: false },
329
- },
330
- allowPositionals: true,
331
- })
332
- printStatus(resolveIndexDir(values['index-dir']), values.json)
333
- return 0
334
- }
335
-
336
- if (command === 'model') {
337
- const sub = argv[1]
338
- if (sub !== 'status' && sub !== 'pull') {
339
- process.stderr.write('usage: dsh-plugin-discover model status|pull [options]\n')
340
- return 2
341
- }
342
- const { values } = parseArgs({
343
- args: argv.slice(2),
344
- options: {
345
- 'index-dir': { type: 'string' },
346
- profile: { type: 'string' },
347
- spec: { type: 'string' },
348
- 'dry-run': { type: 'boolean', default: false },
349
- 'no-verify': { type: 'boolean', default: false },
350
- json: { type: 'boolean', default: false },
351
- },
352
- allowPositionals: true,
353
- })
354
- const indexDir = resolveIndexDir(values['index-dir'])
355
- const { profile, dir } = profileDirOf(values.profile)
356
-
357
- if (sub === 'status') {
358
- printModelStatus(modelStatusValue({ profile, profileDir: dir, indexDir }), values.json)
359
- return 0
360
- }
361
-
362
- const spec = values.spec
363
- || process.env.DSH_PLUGIN_DISCOVER_MODEL_SPEC
364
- || 'dsh-plugin-discover-model@0.1.0'
365
- if (!existsSync(dir)) {
366
- process.stderr.write(`profile 目录不存在:${dir}\n`)
367
- return 1
368
- }
369
- if (values['dry-run']) {
370
- try {
371
- const dry = installModelPackage(spec, dir, true)
372
- if (values.json) process.stdout.write(`${JSON.stringify({ spec, profile, profileDir: dir, ...dry }, null, 2)}\n`)
373
- else process.stdout.write(`cd ${dir}\n${dry.display}\n`)
374
- return 0
375
- } catch (error) {
376
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
377
- return 1
378
- }
379
- }
380
-
381
- let install
382
- try {
383
- install = installModelPackage(spec, dir, false)
384
- } catch (error) {
385
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
386
- return 1
387
- }
388
- if (install.error) {
389
- process.stderr.write(`模型包安装失败:${install.error}\n`)
390
- return 1
391
- }
392
- const installed = resolveInstalledModel(dir)
393
- if (!installed) {
394
- process.stderr.write(`模型包安装后仍无法解析(pnpm exit ${install.status});检查 pnpm 输出与 profile 依赖。\n`)
395
- return 1
396
- }
397
- const verification = await verifyModelFiles(
398
- installed.packageDir,
399
- installed.manifest,
400
- !values['no-verify'],
401
- )
402
- if (!verification.ok) {
403
- process.stderr.write(`模型包校验失败:\n- ${verification.errors.join('\n- ')}\n`)
404
- return 1
405
- }
406
- // pnpm exits non-zero when it ignores a dependency's build script. The
407
- // design deliberately does not bypass that approval; the prebuilt CPU
408
- // binary works, so a verified package is still a success with a warning.
409
- const warning = install.status !== 0
410
- ? `pnpm exit ${install.status};如果这是 ERR_PNPM_IGNORED_BUILDS,onnxruntime-node 的 postinstall 被跳过,预编译二进制仍可用。`
411
- : null
412
- const pointer = buildModelPointer(installed.packageDir, installed.version, installed.manifest)
413
- let pointerPath
414
- try {
415
- pointerPath = writeModelPointer(indexDir, pointer)
416
- } catch (error) {
417
- process.stderr.write(`写入 model.json 失败:${error instanceof Error ? error.message : String(error)}\n`)
418
- return 1
419
- }
420
- const status = modelStatusValue({ profile, profileDir: dir, indexDir })
421
- if (values.json) {
422
- process.stdout.write(`${JSON.stringify({
423
- spec,
424
- profile,
425
- profileDir: dir,
426
- install,
427
- warning,
428
- verification,
429
- pointerPath,
430
- pointer,
431
- status,
432
- }, null, 2)}\n`)
433
- } else {
434
- process.stdout.write(`模型包已安装:${installed.packageDir}\n`)
435
- process.stdout.write(`校验:${verification.files} 个文件${verification.hashes ? ',SHA-256 通过' : '(仅大小)'}\n`)
436
- if (warning) process.stdout.write(`注意:${warning}\n`)
437
- process.stdout.write(`已记录:${pointerPath}\n`)
438
- printModelStatus(status, false)
439
- }
440
- return 0
441
- }
442
-
443
- if (command === 'search') {
444
- const { values, positionals } = parseArgs({
445
- args: argv.slice(1),
446
- options: {
447
- 'index-dir': { type: 'string' },
448
- alt: { type: 'string', multiple: true, default: [] },
449
- limit: { type: 'string', default: '10' },
450
- preset: { type: 'string', default: 'balanced' },
451
- 'min-verification': { type: 'string', default: '4' },
452
- explain: { type: 'string' },
453
- json: { type: 'boolean', default: false },
454
- },
455
- allowPositionals: true,
456
- })
457
- const query = positionals.join(' ').trim()
458
- if (query === '' && !values.explain) {
459
- process.stderr.write('search requires a query (or --explain <id>)\n')
460
- return 2
461
- }
462
- const indexDir = resolveIndexDir(values['index-dir'])
463
- const records = readRecords(indexDir)
464
- if (!records) {
465
- process.stderr.write(`没有可用索引:${indexDir}\n先执行:dsh-plugin-discover build\n`)
466
- return 1
467
- }
468
- const manifest = readManifest(indexDir)
469
- const preset = PRESETS.includes(values.preset) ? values.preset : 'balanced'
470
- const limit = Math.min(20, Math.max(1, Number(values.limit) || 10))
471
- const searcher = createSearcher(records.records, { runtimeVersion: detectRuntimeVersion() })
472
- const value = values.explain
473
- ? explain(searcher, values.explain, { query })
474
- : await search(searcher, {
475
- query,
476
- queryAlts: values.alt,
477
- limit,
478
- preset,
479
- minVerificationLevel: Number(values['min-verification']) || 0,
480
- indexDir,
481
- })
482
- if (values.json) process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
483
- else printReport(value, manifest)
484
- return 0
485
- }
486
-
487
- process.stderr.write(`unknown command: ${command}\n\n${USAGE}`)
488
- return 2
489
- }
490
-
491
- const code = await main().catch(error => {
492
- process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`)
493
- return 1
494
- })
495
- process.exitCode = code
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Command line entry for building, inspecting, and querying the local index.
4
+ * This is a standalone package bin; DSH has no plugin-contributed `dsh`
5
+ * subcommand, so `dsh plugin add` installs the package and this bin runs
6
+ * beside it.
7
+ */
8
+ import { parseArgs } from 'node:util'
9
+ import { createHash } from 'node:crypto'
10
+ import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
11
+ import { spawn } from 'node:child_process'
12
+ import { createRequire } from 'node:module'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import { defaultIndexDir } from './paths.js'
16
+ import { buildIndex } from './build.js'
17
+ import {
18
+ isLockStale, readBuildLog, readLock, readManifest, readRecords,
19
+ } from './store.js'
20
+ import { createSearcher, explain, search } from './search.js'
21
+ import { textReport } from './report.js'
22
+ import { PRESETS } from './filters.js'
23
+ import { detectRuntimeVersion } from './compat.js'
24
+
25
+ const USAGE = `dsh-plugin-discover — local DSH plugin index
26
+
27
+ Usage:
28
+ dsh-plugin-discover build [--index-dir <dir>] [--json] [--quiet]
29
+ dsh-plugin-discover status [--index-dir <dir>] [--json]
30
+ dsh-plugin-discover search <query> [--alt <text>]... [--limit <n>]
31
+ [--preset strict|balanced|loose|off]
32
+ [--min-verification <0-5>] [--explain <id>] [--json]
33
+ dsh-plugin-discover model status [--index-dir <dir>] [--profile <name>] [--json]
34
+ dsh-plugin-discover model pull [--spec <spec>] [--profile <name>]
35
+ [--index-dir <dir>] [--dry-run] [--no-verify] [--json]
36
+
37
+ Options:
38
+ --index-dir <dir> Override the index directory. Must match the plugin's indexDir.
39
+ --alt <text> A query rewrite; repeat up to three times.
40
+ --json Print machine-readable JSON.
41
+ --quiet Suppress build progress.
42
+ --spec <spec> Model package spec for model pull, such as
43
+ link:D:\\path\\to\\model or file:D:\\path\\model.tgz.
44
+ --profile <name> DSH profile for model pull (default: DSH_PROFILE or web).
45
+ --dry-run Print the model install command without running it.
46
+ --no-verify Skip SHA-256 verification of the model package.
47
+ -h, --help Show this help.
48
+
49
+ Default index directory: %LOCALAPPDATA%\\dsh-plugin-discover (Windows),
50
+ ~/Library/Caches/dsh-plugin-discover (macOS),
51
+ \${XDG_CACHE_HOME:-~/.cache}/dsh-plugin-discover (Linux).
52
+
53
+ The optional model package is not downloaded automatically. \`model pull\`
54
+ installs it explicitly; the tool never installs anything.
55
+ `
56
+
57
+ function resolveIndexDir(value) {
58
+ const configured = typeof value === 'string' && value.trim() !== ''
59
+ ? value.trim()
60
+ : (process.env.DSH_PLUGIN_DISCOVER_INDEX_DIR || defaultIndexDir())
61
+ return configured
62
+ }
63
+
64
+ function formatBytes(bytes) {
65
+ if (!Number.isFinite(bytes)) return '?'
66
+ if (bytes < 1024) return `${bytes} B`
67
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
68
+ return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
69
+ }
70
+
71
+ function printStatus(indexDir, json) {
72
+ const current = readManifest(indexDir)
73
+ const lock = readLock(indexDir)
74
+ const activeLock = lock && !isLockStale(lock) ? lock : null
75
+ const recordsSize = (() => {
76
+ try { return statSync(join(indexDir, current?.name ?? '', 'records.json')).size } catch { return null }
77
+ })()
78
+ const value = {
79
+ indexDir,
80
+ hasIndex: current !== null,
81
+ current: current?.name ?? null,
82
+ builtAt: current?.manifest?.builtAt ?? null,
83
+ generatedAt: current?.manifest?.sources?.dshSo?.generatedAt ?? null,
84
+ cards: current?.manifest?.stats?.cards ?? 0,
85
+ sources: current?.manifest?.sources ?? null,
86
+ stats: current?.manifest?.stats ?? null,
87
+ recordsBytes: recordsSize,
88
+ building: activeLock !== null,
89
+ lock: activeLock,
90
+ lastBuildLog: readBuildLog(indexDir).slice(-2000),
91
+ }
92
+ if (json) {
93
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
94
+ return
95
+ }
96
+ const lines = [
97
+ `索引目录:${indexDir}`,
98
+ `当前索引:${current?.name ?? '(无)'}`,
99
+ `卡片数量:${value.cards}`,
100
+ `数据截至:${value.generatedAt ? String(value.generatedAt).slice(0, 10) : '(未知)'}`,
101
+ `建库时间:${value.builtAt ?? '(无)'}`,
102
+ `记录文件:${recordsSize === null ? '(无)' : formatBytes(recordsSize)}`,
103
+ `来源:${value.sources ? Object.entries(value.sources).filter(([, source]) => source?.ok).map(([name]) => name).join(', ') : '(无)'}`,
104
+ `建库中:${value.building ? `是(PID ${activeLock?.pid ?? '?'})` : '否'}`,
105
+ `DSH 版本:${detectRuntimeVersion() ?? '(未检测到)'}`,
106
+ ]
107
+ process.stdout.write(`${lines.join('\n')}\n`)
108
+ }
109
+
110
+ function printReport(value, current) {
111
+ const report = textReport(value, { manifest: current?.manifest ?? null })
112
+ process.stdout.write(`${report}\n`)
113
+ }
114
+
115
+ function profileDirOf(profile) {
116
+ const name = profile || process.env.DSH_PROFILE || 'web'
117
+ if (process.env.DSH_PROFILE_DIR && (!process.env.DSH_PROFILE || process.env.DSH_PROFILE === name)) {
118
+ return { profile: name, dir: process.env.DSH_PROFILE_DIR }
119
+ }
120
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
121
+ return { profile: name, dir: join(home, 'profiles', name) }
122
+ }
123
+
124
+ function resolveInstalledModel(profileDir) {
125
+ const manifestFile = join(profileDir, 'package.json')
126
+ if (!existsSync(manifestFile)) return null
127
+ try {
128
+ const require = createRequire(manifestFile)
129
+ const manifestPath = require.resolve('dsh-plugin-discover-model/manifest.json')
130
+ const packageDir = dirname(manifestPath)
131
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
132
+ const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
133
+ return { packageDir, version: pkg.version, manifest }
134
+ } catch {
135
+ return null
136
+ }
137
+ }
138
+
139
+ function readModelPointer(indexDir) {
140
+ try {
141
+ const value = JSON.parse(readFileSync(join(indexDir, 'model.json'), 'utf8'))
142
+ return value && typeof value === 'object' ? value : null
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ function runtimeDependency(packageDir, name) {
149
+ try {
150
+ const require = createRequire(join(packageDir, 'package.json'))
151
+ const entry = require.resolve(name)
152
+ let version = null
153
+ try {
154
+ const pkg = JSON.parse(readFileSync(join(dirname(entry), '..', 'package.json'), 'utf8'))
155
+ version = pkg.version ?? null
156
+ } catch {}
157
+ return { installed: true, entry, version }
158
+ } catch {
159
+ return { installed: false }
160
+ }
161
+ }
162
+
163
+ function modelStatusValue({ profile, profileDir, indexDir }) {
164
+ const installed = resolveInstalledModel(profileDir)
165
+ const pointer = readModelPointer(indexDir)
166
+ const value = {
167
+ profile,
168
+ profileDir,
169
+ indexDir,
170
+ pointerPath: join(indexDir, 'model.json'),
171
+ installed: installed !== null,
172
+ packageDir: installed?.packageDir ?? null,
173
+ version: installed?.version ?? null,
174
+ manifest: installed?.manifest ?? null,
175
+ runtime: installed
176
+ ? {
177
+ tokenizer: runtimeDependency(installed.packageDir, '@huggingface/tokenizers'),
178
+ onnxruntime: runtimeDependency(installed.packageDir, 'onnxruntime-node'),
179
+ }
180
+ : null,
181
+ pointer,
182
+ pointerMatches: Boolean(
183
+ installed && pointer && typeof pointer.packageDir === 'string'
184
+ && pointer.packageDir.toLowerCase() === installed.packageDir.toLowerCase()),
185
+ }
186
+ if (installed) {
187
+ const modelBytes = installed.manifest?.files?.['model_quantized.onnx']?.bytes ?? null
188
+ const tokenizerBytes = installed.manifest?.files?.['tokenizer.json']?.bytes ?? null
189
+ value.modelBytes = modelBytes
190
+ value.tokenizerBytes = tokenizerBytes
191
+ }
192
+ return value
193
+ }
194
+
195
+ function printModelStatus(value, json) {
196
+ if (json) {
197
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
198
+ return
199
+ }
200
+ const lines = [
201
+ `模型包:${value.installed ? '已安装' : '未安装'}`,
202
+ `profile:${value.profile}(${value.profileDir})`,
203
+ `索引目录:${value.indexDir}`,
204
+ `指针文件:${value.pointerPath}${value.pointerMatches ? '(已记录)' : '(未记录或与安装目录不一致)'}`,
205
+ ]
206
+ if (value.installed) {
207
+ lines.push(`包目录:${value.packageDir}`)
208
+ lines.push(`版本:${value.version}`)
209
+ lines.push(`模型:${value.modelBytes ? formatBytes(value.modelBytes) : '(manifest 无大小)'}`)
210
+ lines.push(`tokenizer:${value.tokenizerBytes ? formatBytes(value.tokenizerBytes) : '(manifest 无大小)'}`)
211
+ lines.push(`tokenizer 运行时:${value.runtime.tokenizer.installed ? '已安装' : '缺失'}`)
212
+ lines.push(`ONNX 运行时:${value.runtime.onnxruntime.installed ? '已安装' : '缺失'}`)
213
+ if (value.pointerMatches) lines.push(`安装时间:${value.pointer.installedAt ?? '(未记录)'}`)
214
+ } else {
215
+ lines.push('拉取:dsh-plugin-discover model pull --spec <模型包 spec>')
216
+ }
217
+ process.stdout.write(`${lines.join('\n')}\n`)
218
+ }
219
+
220
+ async function sha256File(file) {
221
+ const hash = createHash('sha256')
222
+ for await (const chunk of createReadStream(file)) hash.update(chunk)
223
+ return hash.digest('hex')
224
+ }
225
+
226
+ async function verifyModelFiles(packageDir, manifest, verifyHashes) {
227
+ const errors = []
228
+ const files = manifest?.files && typeof manifest.files === 'object' ? manifest.files : {}
229
+ for (const [name, expected] of Object.entries(files)) {
230
+ const file = join(packageDir, name)
231
+ if (!existsSync(file)) {
232
+ errors.push(`${name}: missing`)
233
+ continue
234
+ }
235
+ const size = statSync(file).size
236
+ if (Number.isFinite(expected.bytes) && size !== expected.bytes) {
237
+ errors.push(`${name}: size ${size} != ${expected.bytes}`)
238
+ }
239
+ if (verifyHashes && typeof expected.sha256 === 'string') {
240
+ const hash = await sha256File(file)
241
+ if (hash.toLowerCase() !== expected.sha256.toLowerCase()) {
242
+ errors.push(`${name}: sha256 mismatch`)
243
+ }
244
+ }
245
+ }
246
+ return { ok: errors.length === 0, errors, hashes: verifyHashes, files: Object.keys(files).length }
247
+ }
248
+
249
+ function installModelPackage(spec, profileDir, dryRun) {
250
+ if (!/^[^\s;&|<>^"]+$/.test(spec)) {
251
+ throw new Error('模型包 spec 不能包含空白或 shell 元字符')
252
+ }
253
+ if (dryRun) return { dryRun: true, display: `pnpm add ${spec}` }
254
+ const command = process.platform === 'win32'
255
+ ? [process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `pnpm add ${spec}`]]
256
+ : ['pnpm', ['add', spec]]
257
+ // Stream pnpm output so a slow download still shows progress, and keep the
258
+ // tail so the caller can tell a skipped build script from a real failure.
259
+ return new Promise(resolve => {
260
+ let output = ''
261
+ const collect = chunk => {
262
+ const text = chunk.toString()
263
+ output = `${output}${text}`.slice(-16384)
264
+ }
265
+ let child
266
+ try {
267
+ child = spawn(command[0], command[1], {
268
+ cwd: profileDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
269
+ })
270
+ } catch (error) {
271
+ resolve({ dryRun: false, status: null, signal: null, error: String(error.message ?? error), output })
272
+ return
273
+ }
274
+ child.stdout.on('data', chunk => { collect(chunk); process.stdout.write(chunk) })
275
+ child.stderr.on('data', chunk => { collect(chunk); process.stderr.write(chunk) })
276
+ child.on('error', error => resolve({
277
+ dryRun: false, status: null, signal: null, error: String(error.message ?? error), output,
278
+ }))
279
+ child.on('close', (status, signal) => resolve({ dryRun: false, status, signal, error: null, output }))
280
+ })
281
+ }
282
+
283
+ /** pnpm exits non-zero when it skips a dependency build script; that is expected. */
284
+ function isIgnoredBuildsFailure(install) {
285
+ return /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/i.test(install.output ?? '')
286
+ }
287
+
288
+ function buildModelPointer(packageDir, version, manifest) {
289
+ return {
290
+ format: 1,
291
+ package: 'dsh-plugin-discover-model',
292
+ version,
293
+ packageDir,
294
+ server: join(packageDir, 'server.js'),
295
+ manifest: join(packageDir, 'manifest.json'),
296
+ model: join(packageDir, 'model_quantized.onnx'),
297
+ tokenizer: join(packageDir, 'tokenizer.json'),
298
+ dimensions: manifest?.dimensions ?? null,
299
+ maxTokens: manifest?.maxTokens ?? null,
300
+ installedAt: new Date().toISOString(),
301
+ }
302
+ }
303
+
304
+ function writeModelPointer(indexDir, pointer) {
305
+ mkdirSync(indexDir, { recursive: true })
306
+ const file = join(indexDir, 'model.json')
307
+ writeFileSync(file, `${JSON.stringify(pointer, null, 2)}\n`)
308
+ return file
309
+ }
310
+
311
+
312
+ async function main() {
313
+ const argv = process.argv.slice(2)
314
+ const command = argv[0]
315
+ if (!command || command === '-h' || command === '--help' || command === 'help') {
316
+ process.stdout.write(USAGE)
317
+ return 0
318
+ }
319
+
320
+ if (command === 'build') {
321
+ const { values } = parseArgs({
322
+ args: argv.slice(1),
323
+ options: {
324
+ 'index-dir': { type: 'string' },
325
+ json: { type: 'boolean', default: false },
326
+ quiet: { type: 'boolean', default: false },
327
+ },
328
+ allowPositionals: true,
329
+ })
330
+ const indexDir = resolveIndexDir(values['index-dir'])
331
+ const result = await buildIndex({
332
+ indexDir,
333
+ onProgress: message => { if (!values.quiet) process.stderr.write(`${message}\n`) },
334
+ })
335
+ if (values.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
336
+ else if (result.busy) process.stdout.write(`跳过:另一个建库进程正在运行(PID ${result.lock?.pid ?? '?'})\n`)
337
+ else {
338
+ process.stdout.write(`索引已发布:${result.name}\n`)
339
+ process.stdout.write(`卡片:${result.stats.cards} · 数据截至:${String(result.manifest.sources.dshSo.generatedAt).slice(0, 10)}\n`)
340
+ }
341
+ return result.ok || result.busy ? 0 : 1
342
+ }
343
+
344
+ if (command === 'status') {
345
+ const { values } = parseArgs({
346
+ args: argv.slice(1),
347
+ options: {
348
+ 'index-dir': { type: 'string' },
349
+ json: { type: 'boolean', default: false },
350
+ },
351
+ allowPositionals: true,
352
+ })
353
+ printStatus(resolveIndexDir(values['index-dir']), values.json)
354
+ return 0
355
+ }
356
+
357
+ if (command === 'model') {
358
+ const sub = argv[1]
359
+ if (sub !== 'status' && sub !== 'pull') {
360
+ process.stderr.write('usage: dsh-plugin-discover model status|pull [options]\n')
361
+ return 2
362
+ }
363
+ const { values } = parseArgs({
364
+ args: argv.slice(2),
365
+ options: {
366
+ 'index-dir': { type: 'string' },
367
+ profile: { type: 'string' },
368
+ spec: { type: 'string' },
369
+ 'dry-run': { type: 'boolean', default: false },
370
+ 'no-verify': { type: 'boolean', default: false },
371
+ json: { type: 'boolean', default: false },
372
+ },
373
+ allowPositionals: true,
374
+ })
375
+ const indexDir = resolveIndexDir(values['index-dir'])
376
+ const { profile, dir } = profileDirOf(values.profile)
377
+
378
+ if (sub === 'status') {
379
+ printModelStatus(modelStatusValue({ profile, profileDir: dir, indexDir }), values.json)
380
+ return 0
381
+ }
382
+
383
+ const spec = values.spec
384
+ || process.env.DSH_PLUGIN_DISCOVER_MODEL_SPEC
385
+ || 'dsh-plugin-discover-model@0.1.0'
386
+ if (!existsSync(dir)) {
387
+ process.stderr.write(`profile 目录不存在:${dir}\n`)
388
+ return 1
389
+ }
390
+ if (values['dry-run']) {
391
+ try {
392
+ const dry = installModelPackage(spec, dir, true)
393
+ if (values.json) process.stdout.write(`${JSON.stringify({ spec, profile, profileDir: dir, ...dry }, null, 2)}\n`)
394
+ else process.stdout.write(`cd ${dir}\n${dry.display}\n`)
395
+ return 0
396
+ } catch (error) {
397
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
398
+ return 1
399
+ }
400
+ }
401
+
402
+ let install
403
+ try {
404
+ install = await installModelPackage(spec, dir, false)
405
+ } catch (error) {
406
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
407
+ return 1
408
+ }
409
+ if (install.error) {
410
+ process.stderr.write(`模型包安装失败:${install.error}\n`)
411
+ return 1
412
+ }
413
+ if (install.status !== 0 && !isIgnoredBuildsFailure(install)) {
414
+ const previous = resolveInstalledModel(dir)
415
+ const reason = install.status === null ? 'pnpm 无法启动' : `pnpm exit ${install.status}`
416
+ process.stderr.write(`模型包安装失败(${reason}):${spec}\n`)
417
+ if (previous) process.stderr.write(`已安装的 ${previous.version} 仍可用,但本次没有更新。\n`)
418
+ return 1
419
+ }
420
+ const installed = resolveInstalledModel(dir)
421
+ if (!installed) {
422
+ process.stderr.write(`模型包安装后仍无法解析(pnpm exit ${install.status});检查 pnpm 输出与 profile 依赖。\n`)
423
+ return 1
424
+ }
425
+ const verification = await verifyModelFiles(
426
+ installed.packageDir,
427
+ installed.manifest,
428
+ !values['no-verify'],
429
+ )
430
+ if (!verification.ok) {
431
+ process.stderr.write(`模型包校验失败:\n- ${verification.errors.join('\n- ')}\n`)
432
+ return 1
433
+ }
434
+ // pnpm exits non-zero when it ignores a dependency's build script. The
435
+ // design deliberately does not bypass that approval; the prebuilt CPU
436
+ // binary works, so a verified package is still a success with a warning.
437
+ const warning = install.status !== 0
438
+ ? `pnpm exit ${install.status}(ERR_PNPM_IGNORED_BUILDS):onnxruntime-node 的 postinstall 被跳过,预编译二进制仍可用。`
439
+ : null
440
+ const pointer = buildModelPointer(installed.packageDir, installed.version, installed.manifest)
441
+ let pointerPath
442
+ try {
443
+ pointerPath = writeModelPointer(indexDir, pointer)
444
+ } catch (error) {
445
+ process.stderr.write(`写入 model.json 失败:${error instanceof Error ? error.message : String(error)}\n`)
446
+ return 1
447
+ }
448
+ const status = modelStatusValue({ profile, profileDir: dir, indexDir })
449
+ if (values.json) {
450
+ process.stdout.write(`${JSON.stringify({
451
+ spec,
452
+ profile,
453
+ profileDir: dir,
454
+ install,
455
+ warning,
456
+ verification,
457
+ pointerPath,
458
+ pointer,
459
+ status,
460
+ }, null, 2)}\n`)
461
+ } else {
462
+ process.stdout.write(`模型包已安装:${installed.packageDir}\n`)
463
+ process.stdout.write(`校验:${verification.files} 个文件${verification.hashes ? ',SHA-256 通过' : '(仅大小)'}\n`)
464
+ if (warning) process.stdout.write(`注意:${warning}\n`)
465
+ process.stdout.write(`已记录:${pointerPath}\n`)
466
+ printModelStatus(status, false)
467
+ }
468
+ return 0
469
+ }
470
+
471
+ if (command === 'search') {
472
+ const { values, positionals } = parseArgs({
473
+ args: argv.slice(1),
474
+ options: {
475
+ 'index-dir': { type: 'string' },
476
+ alt: { type: 'string', multiple: true, default: [] },
477
+ limit: { type: 'string', default: '10' },
478
+ preset: { type: 'string', default: 'balanced' },
479
+ 'min-verification': { type: 'string', default: '4' },
480
+ explain: { type: 'string' },
481
+ json: { type: 'boolean', default: false },
482
+ },
483
+ allowPositionals: true,
484
+ })
485
+ const query = positionals.join(' ').trim()
486
+ if (query === '' && !values.explain) {
487
+ process.stderr.write('search requires a query (or --explain <id>)\n')
488
+ return 2
489
+ }
490
+ const indexDir = resolveIndexDir(values['index-dir'])
491
+ const records = readRecords(indexDir)
492
+ if (!records) {
493
+ process.stderr.write(`没有可用索引:${indexDir}\n先执行:dsh-plugin-discover build\n`)
494
+ return 1
495
+ }
496
+ const manifest = readManifest(indexDir)
497
+ const preset = PRESETS.includes(values.preset) ? values.preset : 'balanced'
498
+ const limit = Math.min(20, Math.max(1, Number(values.limit) || 10))
499
+ const searcher = createSearcher(records.records, { runtimeVersion: detectRuntimeVersion() })
500
+ const value = values.explain
501
+ ? explain(searcher, values.explain, { query })
502
+ : await search(searcher, {
503
+ query,
504
+ queryAlts: values.alt,
505
+ limit,
506
+ preset,
507
+ minVerificationLevel: Number(values['min-verification']) || 0,
508
+ indexDir,
509
+ })
510
+ if (values.json) process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
511
+ else printReport(value, manifest)
512
+ return 0
513
+ }
514
+
515
+ process.stderr.write(`unknown command: ${command}\n\n${USAGE}`)
516
+ return 2
517
+ }
518
+
519
+ const code = await main().catch(error => {
520
+ process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`)
521
+ return 1
522
+ })
523
+ process.exitCode = code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-discover",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Find DeepSeek Harness plugins with a local, model-free index: Chinese natural-language search, quality filters, deterministic ranking, and install targets — never installs anything.",
5
5
  "type": "module",
6
6
  "main": "index.js",