dsh-plugin-discover 0.0.0-stage → 1.0.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/CHANGELOG.md +31 -0
- package/LICENSE +26 -0
- package/README.md +209 -3
- package/README.zh.md +208 -0
- package/client.js +477 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +14 -0
- package/index.js +347 -0
- package/lib/bm25.js +190 -0
- package/lib/build-worker.js +32 -0
- package/lib/build.js +145 -0
- package/lib/cli.js +495 -0
- package/lib/compat.js +157 -0
- package/lib/filters.js +105 -0
- package/lib/model-client.js +163 -0
- package/lib/normalize.js +369 -0
- package/lib/paths.js +49 -0
- package/lib/quality.js +61 -0
- package/lib/readme-clean.js +128 -0
- package/lib/readme-source.js +380 -0
- package/lib/report.js +244 -0
- package/lib/runtime.js +247 -0
- package/lib/search.js +447 -0
- package/lib/store.js +184 -0
- package/lib/tokenize.js +141 -0
- package/lib/vector-store.js +142 -0
- package/lib/vector.js +592 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -6
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* README source resolution for readme-mode vectors.
|
|
3
|
+
*
|
|
4
|
+
* Order: GitHub raw (native network) -> npm CDN (exact published version) ->
|
|
5
|
+
* field-vector fallback in vector.js. GitHub is never sent through a proxy.
|
|
6
|
+
*/
|
|
7
|
+
export const README_REQUEST_TIMEOUT_MS = 30_000
|
|
8
|
+
export const README_TOTAL_BUDGET_MS = 5 * 60_000
|
|
9
|
+
export const README_CONCURRENCY = 4
|
|
10
|
+
export const README_RATE_PER_SECOND = 1
|
|
11
|
+
export const README_RATE_BURST = 1
|
|
12
|
+
export const README_JITTER_MIN_MS = 50
|
|
13
|
+
export const README_JITTER_MAX_MS = 150
|
|
14
|
+
export const CIRCUIT_COOLDOWN_MS = 10 * 60_000
|
|
15
|
+
export const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60_000
|
|
16
|
+
export const GITHUB_HOST = 'raw.githubusercontent.com'
|
|
17
|
+
export const USER_AGENT = 'dsh-plugin-discover/0.1.0'
|
|
18
|
+
|
|
19
|
+
const GITHUB_FILENAMES = ['README.md', 'readme.md']
|
|
20
|
+
const NPM_PROVIDERS = [
|
|
21
|
+
['unpkg', 'https://unpkg.com'],
|
|
22
|
+
['jsdelivr', 'https://cdn.jsdelivr.net/npm'],
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export const README_NOTICES = {
|
|
26
|
+
timeout: 'GitHub README requests timed out repeatedly. GitHub access is paused for 10 minutes; remaining READMEs fall back to the npm CDN or field vectors.',
|
|
27
|
+
'rate-limit': 'GitHub README requests were rate-limited (HTTP 403/429). GitHub access is paused for 10 minutes; remaining READMEs fall back to the npm CDN or field vectors.',
|
|
28
|
+
transport: 'GitHub README requests failed repeatedly. GitHub access is paused for 10 minutes; remaining READMEs fall back to the npm CDN or field vectors.',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultSleep(ms) {
|
|
32
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One token bucket per worker; four workers therefore cap at 4 req/s. */
|
|
36
|
+
export class RateLimiter {
|
|
37
|
+
constructor({
|
|
38
|
+
rate = README_RATE_PER_SECOND,
|
|
39
|
+
burst = README_RATE_BURST,
|
|
40
|
+
now = () => Date.now(),
|
|
41
|
+
sleep = defaultSleep,
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (!(rate > 0)) throw new Error('rate must be positive')
|
|
44
|
+
this.rate = rate
|
|
45
|
+
this.capacity = Math.max(1, burst)
|
|
46
|
+
this.tokens = this.capacity
|
|
47
|
+
this.updated = now()
|
|
48
|
+
this.now = now
|
|
49
|
+
this.sleep = sleep
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
refill(now) {
|
|
53
|
+
const elapsed = (now - this.updated) / 1000
|
|
54
|
+
if (elapsed <= 0) return
|
|
55
|
+
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.rate)
|
|
56
|
+
this.updated = now
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async take() {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const now = this.now()
|
|
62
|
+
this.refill(now)
|
|
63
|
+
if (this.tokens >= 1) {
|
|
64
|
+
this.tokens -= 1
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const wait = Math.max(1, Math.ceil(((1 - this.tokens) / this.rate) * 1000))
|
|
68
|
+
await this.sleep(wait)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Small jitter so four workers do not burst at the same millisecond. */
|
|
74
|
+
export async function jitter(sleep = defaultSleep, min = README_JITTER_MIN_MS, max = README_JITTER_MAX_MS) {
|
|
75
|
+
if (max <= 0) return
|
|
76
|
+
await sleep(min + Math.random() * Math.max(0, max - min))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* GitHub circuit breaker. A success resets the transport-failure run.
|
|
81
|
+
* 403/429 are counted separately per round; the third opens the same cooldown.
|
|
82
|
+
*/
|
|
83
|
+
export class GithubGuard {
|
|
84
|
+
constructor({
|
|
85
|
+
now = () => Date.now(),
|
|
86
|
+
cooldownMs = CIRCUIT_COOLDOWN_MS,
|
|
87
|
+
threshold = 3,
|
|
88
|
+
} = {}) {
|
|
89
|
+
this.now = now
|
|
90
|
+
this.cooldownMs = cooldownMs
|
|
91
|
+
this.threshold = threshold
|
|
92
|
+
this.openUntil = 0
|
|
93
|
+
this.reason = null
|
|
94
|
+
this.transportFailures = 0
|
|
95
|
+
this.rateLimitHits = 0
|
|
96
|
+
this.sawTimeout = false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
beginRound(now = this.now()) {
|
|
100
|
+
this.transportFailures = 0
|
|
101
|
+
this.rateLimitHits = 0
|
|
102
|
+
this.sawTimeout = false
|
|
103
|
+
this.expire(now)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
expire(now) {
|
|
107
|
+
if (this.openUntil && now >= this.openUntil) {
|
|
108
|
+
this.openUntil = 0
|
|
109
|
+
this.reason = null
|
|
110
|
+
this.transportFailures = 0
|
|
111
|
+
this.rateLimitHits = 0
|
|
112
|
+
this.sawTimeout = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
canUse(now = this.now()) {
|
|
117
|
+
this.expire(now)
|
|
118
|
+
return this.openUntil === 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
recordSuccess() {
|
|
122
|
+
this.transportFailures = 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
recordTransportFailure(kind = 'transport', now = this.now()) {
|
|
126
|
+
if (!this.canUse(now)) return
|
|
127
|
+
if (kind === 'timeout') this.sawTimeout = true
|
|
128
|
+
this.transportFailures += 1
|
|
129
|
+
if (this.transportFailures >= this.threshold) {
|
|
130
|
+
this.open(now, this.sawTimeout ? 'timeout' : 'transport')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
recordRateLimit(now = this.now(), retryAfterMs = 0) {
|
|
135
|
+
if (!this.canUse(now)) return
|
|
136
|
+
this.rateLimitHits += 1
|
|
137
|
+
if (this.rateLimitHits >= this.threshold) {
|
|
138
|
+
this.open(now, 'rate-limit', Math.max(this.cooldownMs, retryAfterMs))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
open(now, reason, cooldown = this.cooldownMs) {
|
|
143
|
+
this.openUntil = now + cooldown
|
|
144
|
+
this.reason = reason
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
status(now = this.now()) {
|
|
148
|
+
this.expire(now)
|
|
149
|
+
if (!this.openUntil) return null
|
|
150
|
+
return {
|
|
151
|
+
open: true,
|
|
152
|
+
reason: this.reason,
|
|
153
|
+
host: GITHUB_HOST,
|
|
154
|
+
until: new Date(this.openUntil).toISOString(),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
reset() {
|
|
159
|
+
this.openUntil = 0
|
|
160
|
+
this.reason = null
|
|
161
|
+
this.transportFailures = 0
|
|
162
|
+
this.rateLimitHits = 0
|
|
163
|
+
this.sawTimeout = false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const githubGuard = new GithubGuard()
|
|
168
|
+
|
|
169
|
+
export function githubNotice(reason) {
|
|
170
|
+
return README_NOTICES[reason] ?? README_NOTICES.transport
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stripGit(value) {
|
|
174
|
+
return String(value).replace(/\.git$/i, '')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseGithubTarget(record) {
|
|
178
|
+
const target = typeof record?.install?.target === 'string' ? record.install.target : ''
|
|
179
|
+
const repo = typeof record?.repo === 'string' ? record.repo : ''
|
|
180
|
+
let owner = ''
|
|
181
|
+
let name = ''
|
|
182
|
+
let ref = 'HEAD'
|
|
183
|
+
const direct = /^github:([^/#]+)\/([^#]+?)(?:#(.+))?$/i.exec(target)
|
|
184
|
+
if (direct) {
|
|
185
|
+
owner = direct[1]
|
|
186
|
+
name = stripGit(direct[2])
|
|
187
|
+
ref = direct[3] || 'HEAD'
|
|
188
|
+
} else {
|
|
189
|
+
const match = /github\.com\/([^/]+)\/([^/#?]+)/i.exec(repo)
|
|
190
|
+
|| /github\.com\/([^/]+)\/([^/#?]+)/i.exec(target)
|
|
191
|
+
if (match) {
|
|
192
|
+
owner = match[1]
|
|
193
|
+
name = stripGit(match[2])
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!owner || !name) return null
|
|
197
|
+
return { owner, name, ref, key: `github:${owner}/${name}@${ref}` }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function splitNpmSpec(spec) {
|
|
201
|
+
if (typeof spec !== 'string' || spec === '') return null
|
|
202
|
+
let name = spec
|
|
203
|
+
let version = null
|
|
204
|
+
const at = spec.lastIndexOf('@')
|
|
205
|
+
if (at > 0) {
|
|
206
|
+
name = spec.slice(0, at)
|
|
207
|
+
version = spec.slice(at + 1) || null
|
|
208
|
+
}
|
|
209
|
+
if (!name || name.includes('://') || name.includes(':')) return null
|
|
210
|
+
if (name.includes('/') && !name.startsWith('@')) return null
|
|
211
|
+
if (version === 'latest') version = null
|
|
212
|
+
return { name, version }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function parseNpmTarget(record) {
|
|
216
|
+
const target = typeof record?.install?.target === 'string' ? record.install.target : ''
|
|
217
|
+
const lower = target.toLowerCase()
|
|
218
|
+
let spec = ''
|
|
219
|
+
if (target && !lower.startsWith('github:') && !lower.startsWith('file:') && !lower.startsWith('link:') && !lower.startsWith('http')) {
|
|
220
|
+
spec = target
|
|
221
|
+
} else {
|
|
222
|
+
spec = typeof record?.alias === 'string' && record.alias !== '' ? record.alias : record?.name
|
|
223
|
+
}
|
|
224
|
+
const parsed = splitNpmSpec(spec)
|
|
225
|
+
if (!parsed) return null
|
|
226
|
+
return {
|
|
227
|
+
...parsed,
|
|
228
|
+
key: `npm:${parsed.name}@${parsed.version ?? 'latest'}`,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function readmeTargets(record) {
|
|
233
|
+
return {
|
|
234
|
+
github: parseGithubTarget(record),
|
|
235
|
+
npm: parseNpmTarget(record),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const npmVersionCache = new Map()
|
|
240
|
+
|
|
241
|
+
function cacheFresh(entry, now, ttl) {
|
|
242
|
+
return Boolean(entry && now - entry.at < ttl)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function resolveNpmVersion(name, {
|
|
246
|
+
fetchImpl,
|
|
247
|
+
limiter = null,
|
|
248
|
+
sleep = defaultSleep,
|
|
249
|
+
timeoutMs = README_REQUEST_TIMEOUT_MS,
|
|
250
|
+
now = () => Date.now(),
|
|
251
|
+
etag = null,
|
|
252
|
+
} = {}) {
|
|
253
|
+
const at = now()
|
|
254
|
+
const cached = npmVersionCache.get(name)
|
|
255
|
+
const ttl = cached?.version ? 10 * 60_000 : NEGATIVE_CACHE_TTL_MS
|
|
256
|
+
if (cacheFresh(cached, at, ttl)) return cached?.version ?? null
|
|
257
|
+
const result = await requestText(
|
|
258
|
+
`https://registry.npmjs.org/${encodeURIComponent(name)}`,
|
|
259
|
+
{ fetchImpl, limiter, sleep, timeoutMs, etag, accept: 'application/json' },
|
|
260
|
+
)
|
|
261
|
+
if (result.status === 404) {
|
|
262
|
+
npmVersionCache.set(name, { version: null, at })
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
if (typeof result.text !== 'string') return cached?.version ?? null
|
|
266
|
+
try {
|
|
267
|
+
const pkg = JSON.parse(result.text)
|
|
268
|
+
const version = pkg?.['dist-tags']?.latest
|
|
269
|
+
if (typeof version === 'string' && version !== '') {
|
|
270
|
+
npmVersionCache.set(name, { version, at })
|
|
271
|
+
return version
|
|
272
|
+
}
|
|
273
|
+
} catch {}
|
|
274
|
+
return cached?.version ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function retryAfterMs(response) {
|
|
278
|
+
const value = response?.headers?.get?.('retry-after')
|
|
279
|
+
if (!value) return 0
|
|
280
|
+
const seconds = Number(value)
|
|
281
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
|
282
|
+
const date = Date.parse(value)
|
|
283
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function requestText(url, {
|
|
287
|
+
fetchImpl,
|
|
288
|
+
limiter = null,
|
|
289
|
+
sleep = defaultSleep,
|
|
290
|
+
timeoutMs = README_REQUEST_TIMEOUT_MS,
|
|
291
|
+
etag = null,
|
|
292
|
+
accept = 'text/plain',
|
|
293
|
+
} = {}) {
|
|
294
|
+
if (typeof fetchImpl !== 'function') {
|
|
295
|
+
return { network: true, error: 'fetch-unavailable' }
|
|
296
|
+
}
|
|
297
|
+
if (limiter) await limiter.take()
|
|
298
|
+
await jitter(sleep)
|
|
299
|
+
const headers = { 'user-agent': USER_AGENT, accept }
|
|
300
|
+
if (etag) headers['if-none-match'] = etag
|
|
301
|
+
try {
|
|
302
|
+
const response = await fetchImpl(url, {
|
|
303
|
+
headers,
|
|
304
|
+
redirect: 'follow',
|
|
305
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
306
|
+
})
|
|
307
|
+
if (response.status === 304) return { status: 304, notModified: true }
|
|
308
|
+
if (response.status === 403 || response.status === 429) {
|
|
309
|
+
return { status: response.status, rateLimited: true, retryAfterMs: retryAfterMs(response) }
|
|
310
|
+
}
|
|
311
|
+
if (response.status === 404) return { status: 404 }
|
|
312
|
+
if (!response.ok) return { status: response.status, transport: response.status >= 500 }
|
|
313
|
+
const text = (await response.text()).slice(0, 200_000)
|
|
314
|
+
if (text.trim() === '') return { status: response.status, empty: true }
|
|
315
|
+
return {
|
|
316
|
+
status: response.status,
|
|
317
|
+
text,
|
|
318
|
+
etag: response.headers?.get?.('etag') ?? null,
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
const cause = error?.cause?.code ?? ''
|
|
322
|
+
const timeout = error?.name === 'TimeoutError'
|
|
323
|
+
|| error?.name === 'AbortError'
|
|
324
|
+
|| String(cause).includes('TIMEOUT')
|
|
325
|
+
return { network: true, timeout, error: error instanceof Error ? error.message : String(error) }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function fetchGithubReadme(target, {
|
|
330
|
+
fetchImpl,
|
|
331
|
+
limiter = null,
|
|
332
|
+
sleep = defaultSleep,
|
|
333
|
+
timeoutMs = README_REQUEST_TIMEOUT_MS,
|
|
334
|
+
etag = null,
|
|
335
|
+
} = {}) {
|
|
336
|
+
if (!target) return { status: 404 }
|
|
337
|
+
const ref = String(target.ref)
|
|
338
|
+
.split('/')
|
|
339
|
+
.map(segment => encodeURIComponent(segment))
|
|
340
|
+
.join('/')
|
|
341
|
+
for (const filename of GITHUB_FILENAMES) {
|
|
342
|
+
const url = `https://${GITHUB_HOST}/${target.owner}/${target.name}/${ref}/${filename}`
|
|
343
|
+
const result = await requestText(url, { fetchImpl, limiter, sleep, timeoutMs, etag })
|
|
344
|
+
if (result.status === 200 && typeof result.text === 'string') {
|
|
345
|
+
return { ...result, url, source: 'github-raw', key: target.key }
|
|
346
|
+
}
|
|
347
|
+
if (result.notModified) return { ...result, url, source: 'github-raw', key: target.key }
|
|
348
|
+
if (result.status === 404) continue
|
|
349
|
+
// 403/429/network/5xx: stop the filename loop and report the outcome.
|
|
350
|
+
return { ...result, url, source: 'github-raw', key: target.key }
|
|
351
|
+
}
|
|
352
|
+
return { status: 404, source: 'github-raw', key: target.key }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function fetchNpmReadme(pkg, version, {
|
|
356
|
+
fetchImpl,
|
|
357
|
+
limiter = null,
|
|
358
|
+
sleep = defaultSleep,
|
|
359
|
+
timeoutMs = README_REQUEST_TIMEOUT_MS,
|
|
360
|
+
etag = null,
|
|
361
|
+
} = {}) {
|
|
362
|
+
if (!pkg || !version) return { status: 404 }
|
|
363
|
+
const key = `npm:${pkg}@${version}`
|
|
364
|
+
let last = { status: 404 }
|
|
365
|
+
for (const [provider, base] of NPM_PROVIDERS) {
|
|
366
|
+
for (const filename of GITHUB_FILENAMES) {
|
|
367
|
+
const url = `${base}/${pkg}@${version}/${filename}`
|
|
368
|
+
const result = await requestText(url, { fetchImpl, limiter, sleep, timeoutMs, etag })
|
|
369
|
+
if (result.status === 200 && typeof result.text === 'string') {
|
|
370
|
+
return { ...result, url, source: 'npm-cdn', provider, key }
|
|
371
|
+
}
|
|
372
|
+
if (result.notModified) return { ...result, url, source: 'npm-cdn', provider, key }
|
|
373
|
+
last = { ...result, source: 'npm-cdn', provider, key }
|
|
374
|
+
if (result.status === 404) continue
|
|
375
|
+
if (result.rateLimited) break
|
|
376
|
+
if (result.transport || result.network) break
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return last
|
|
380
|
+
}
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing text report. Two lines per candidate, no summary, no
|
|
3
|
+
* recommendation prose: the model owns expression, this module owns facts.
|
|
4
|
+
*/
|
|
5
|
+
import { ATTRIBUTION } from './normalize.js'
|
|
6
|
+
|
|
7
|
+
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩']
|
|
8
|
+
|
|
9
|
+
function pad(value) {
|
|
10
|
+
return String(value).padStart(2, '0')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function shortDate(value, now = new Date()) {
|
|
14
|
+
if (typeof value !== 'string' || value === '') return ''
|
|
15
|
+
const date = new Date(value)
|
|
16
|
+
if (Number.isNaN(date.getTime())) return value.slice(0, 10)
|
|
17
|
+
if (date.getFullYear() === now.getFullYear()) return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
18
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function verificationLabel(level) {
|
|
22
|
+
if (level === 5) return 'L5 已验证'
|
|
23
|
+
if (typeof level === 'number' && level >= 1 && level <= 5) return `L${level}`
|
|
24
|
+
return ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function securityLabel(security) {
|
|
28
|
+
if (!security) return ''
|
|
29
|
+
const status = security.status
|
|
30
|
+
const risk = security.riskLevel
|
|
31
|
+
if (status === 'failed') return '审计失败'
|
|
32
|
+
if (status === 'pending' || status === 'skipped') return '未审计'
|
|
33
|
+
if (risk === 'low') return '低风险'
|
|
34
|
+
if (risk === 'medium') return '中风险'
|
|
35
|
+
if (risk === 'high') return '高风险'
|
|
36
|
+
if (risk === 'critical') return '严重风险'
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Rough token estimate: one per CJK character, one per four ASCII characters. */
|
|
41
|
+
function estimateTokens(text) {
|
|
42
|
+
let tokens = 0
|
|
43
|
+
for (const character of text) {
|
|
44
|
+
tokens += character.codePointAt(0) >= 0x2e80 ? 1 : 0.25
|
|
45
|
+
}
|
|
46
|
+
return tokens
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Clip a description to a per-item token budget without cutting a surrogate pair. */
|
|
50
|
+
function clipDescription(text, budget = 45) {
|
|
51
|
+
const value = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
52
|
+
if (value === '' || estimateTokens(value) <= budget) return value
|
|
53
|
+
let kept = ''
|
|
54
|
+
let tokens = 0
|
|
55
|
+
for (const character of value) {
|
|
56
|
+
const cost = character.codePointAt(0) >= 0x2e80 ? 1 : 0.25
|
|
57
|
+
if (tokens + cost > budget) break
|
|
58
|
+
tokens += cost
|
|
59
|
+
kept += character
|
|
60
|
+
}
|
|
61
|
+
return `${kept.trimEnd()}…`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function starsLabel(stars) {
|
|
65
|
+
if (typeof stars !== 'number' || !Number.isFinite(stars) || stars <= 0) return ''
|
|
66
|
+
return `${stars >= 1000 ? Math.round(stars).toLocaleString('en-US') : stars}★`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resultLines(result, index, now) {
|
|
70
|
+
const badges = [starsLabel(result.stars)]
|
|
71
|
+
const verification = verificationLabel(result.badges?.verification
|
|
72
|
+
? Number(String(result.badges.verification).replace('L', ''))
|
|
73
|
+
: undefined)
|
|
74
|
+
if (verification) badges.push(verification)
|
|
75
|
+
const security = securityLabel(result.badges?.security
|
|
76
|
+
? { status: result.badges.security, riskLevel: result.badges.security }
|
|
77
|
+
: null)
|
|
78
|
+
if (security) badges.push(security)
|
|
79
|
+
const updated = shortDate(result.badges?.updatedAt, now)
|
|
80
|
+
if (updated) badges.push(`更新 ${updated}`)
|
|
81
|
+
const visible = badges.filter(Boolean)
|
|
82
|
+
const head = `${CIRCLED[index] ?? `${index + 1}.`} ${result.name}${visible.length ? ` · ${visible.join(' · ')}` : ''}`
|
|
83
|
+
const description = clipDescription(result.description)
|
|
84
|
+
const target = result.install?.target
|
|
85
|
+
const suffix = target && target !== result.name ? ` [装: ${target}]` : ''
|
|
86
|
+
return [head, ` ${description}${suffix}`]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function headerLine(value) {
|
|
90
|
+
const parts = [`🔍 ${value.results.length} 个候选`]
|
|
91
|
+
const preset = value.interpreted?.filters?.preset
|
|
92
|
+
if (preset) {
|
|
93
|
+
const dropped = value.filterStats?.droppedBy
|
|
94
|
+
const total = dropped
|
|
95
|
+
? dropped.security + dropped.compat + dropped.verification + (dropped.negation ?? 0)
|
|
96
|
+
: 0
|
|
97
|
+
const relaxed = value.interpreted.filters.relaxedFrom
|
|
98
|
+
parts.push(`过滤 ${preset}${relaxed ? `(已从 ${relaxed} 放宽)` : ''}(排除 ${total} 条)`)
|
|
99
|
+
}
|
|
100
|
+
if (value.vector) {
|
|
101
|
+
const vector = value.vector
|
|
102
|
+
if (vector.degraded) {
|
|
103
|
+
parts.push(`向量 ${vector.mode} 不可用,已降级词面`)
|
|
104
|
+
} else if (vector.mode === 'readme' && vector.readmeCovered !== undefined) {
|
|
105
|
+
const sources = vector.readmeSources
|
|
106
|
+
const detail = sources
|
|
107
|
+
? `(github ${sources.github ?? 0} · npm ${sources.npm ?? 0} · 字段 ${sources.fields ?? 0})`
|
|
108
|
+
: (vector.readmeFallback ? `(字段兜底 ${vector.readmeFallback})` : '')
|
|
109
|
+
parts.push(`向量 readme 覆盖 ${vector.readmeCovered}/50${detail}`)
|
|
110
|
+
} else {
|
|
111
|
+
parts.push(`向量 ${vector.mode} 覆盖 ${vector.covered}/50`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return parts.join(' · ')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function footerLines(manifest, now = new Date()) {
|
|
118
|
+
const lines = []
|
|
119
|
+
const sources = manifest?.manifest?.sources ?? manifest?.sources
|
|
120
|
+
const generatedAt = sources?.dshSo?.generatedAt ?? manifest?.manifest?.generatedAt ?? manifest?.generatedAt
|
|
121
|
+
if (manifest?.manifest?.catalogError || manifest?.catalogError) {
|
|
122
|
+
lines.push('⚠️ catalog 源不可用,本次只用 dsh.so。')
|
|
123
|
+
}
|
|
124
|
+
if (generatedAt) {
|
|
125
|
+
const date = new Date(generatedAt)
|
|
126
|
+
if (!Number.isNaN(date.getTime())) {
|
|
127
|
+
const ageDays = Math.floor((now.getTime() - date.getTime()) / 86_400_000)
|
|
128
|
+
if (ageDays > 7) lines.push(`⚠️ 数据已 ${ageDays} 天未更新,可能未收录新插件。`)
|
|
129
|
+
lines.push(`数据截至 ${date.toISOString().slice(0, 10)} · ${ATTRIBUTION}`)
|
|
130
|
+
return lines
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
lines.push(ATTRIBUTION)
|
|
134
|
+
return lines
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function noMatchLines(value, manifest, now) {
|
|
138
|
+
const lines = [headerLine(value)]
|
|
139
|
+
const dropped = value.filterStats?.droppedBy
|
|
140
|
+
const total = dropped
|
|
141
|
+
? dropped.security + dropped.compat + dropped.verification + (dropped.negation ?? 0)
|
|
142
|
+
: 0
|
|
143
|
+
if (value.indexMissing) {
|
|
144
|
+
lines.push('索引尚未建立。')
|
|
145
|
+
lines.push('先执行:npx dsh-plugin-discover build,或用 refresh: true 触发一次后台建库。')
|
|
146
|
+
} else if (value.explain?.found === false) {
|
|
147
|
+
lines.push(`没有找到 id / 名称「${value.explain.id}」。可以先用一次普通检索拿到准确 id。`)
|
|
148
|
+
} else if (value.gates?.gate1 && value.gates.gate1.passed === false
|
|
149
|
+
&& (!value.gates.gate2 || value.gates.gate2.passed === false)) {
|
|
150
|
+
lines.push('没有候选通过证据检查(词面 gate1 / 语义 gate2),按没有匹配处理。')
|
|
151
|
+
lines.push('建议:换成更具体的英文/术语改写;中文需求请同时给出 queryAlts 的英文改写;或去掉过于宽泛的改写词。')
|
|
152
|
+
} else if (total > 0) {
|
|
153
|
+
lines.push('没有候选同时满足检索词和过滤条件。')
|
|
154
|
+
lines.push('建议:去掉「不要 / 除了」这类排除条件;换一个更具体的说法;或把过滤档位放宽到 loose。')
|
|
155
|
+
} else {
|
|
156
|
+
lines.push('索引里没有匹配这个需求的插件,这是正常结果,不要硬凑。')
|
|
157
|
+
lines.push('建议:换成英文/术语说法再试;中文需求请同时给出 queryAlts 的英文改写;或先 refresh 更新索引。')
|
|
158
|
+
}
|
|
159
|
+
lines.push(...footerLines(manifest, now))
|
|
160
|
+
return lines
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function explainLines(value, manifest, now) {
|
|
164
|
+
const item = value.explain
|
|
165
|
+
if (!item || item.found === false) return noMatchLines(value, manifest, now)
|
|
166
|
+
const badges = [starsLabel(item.stars)]
|
|
167
|
+
const verification = verificationLabel(item.verification?.level)
|
|
168
|
+
if (verification) badges.push(verification)
|
|
169
|
+
const security = securityLabel(item.security)
|
|
170
|
+
if (security) badges.push(security)
|
|
171
|
+
const updated = shortDate(item.updatedAt, now)
|
|
172
|
+
if (updated) badges.push(`更新 ${updated}`)
|
|
173
|
+
const visible = badges.filter(Boolean)
|
|
174
|
+
const lines = [
|
|
175
|
+
`🔎 ${item.name}${visible.length ? ` · ${visible.join(' · ')}` : ''}`,
|
|
176
|
+
` ${String(item.description ?? '').replace(/\s+/g, ' ').trim()}`,
|
|
177
|
+
]
|
|
178
|
+
const tags = [...(item.topics ?? []), ...(item.capability ?? [])]
|
|
179
|
+
if (tags.length > 0) lines.push(` 标签:${[...new Set(tags)].join(', ')}`)
|
|
180
|
+
if (item.install?.target) lines.push(` 安装目标:${item.install.target}`)
|
|
181
|
+
if (item.repo) lines.push(` 仓库:${item.repo}`)
|
|
182
|
+
if (item.dshRange) lines.push(` 兼容范围:${item.dshRange}`)
|
|
183
|
+
if (item.verification) {
|
|
184
|
+
lines.push(` 验证:${item.verification.label ?? `L${item.verification.level}`}`
|
|
185
|
+
+ `${item.verification.lastVerifiedAt ? `(${item.verification.lastVerifiedAt.slice(0, 10)})` : ''}`)
|
|
186
|
+
}
|
|
187
|
+
if (item.security) {
|
|
188
|
+
const counts = item.security.counts
|
|
189
|
+
lines.push(` 安全:${item.security.status} / ${item.security.riskLevel}`
|
|
190
|
+
+ `${counts ? `(critical ${counts.critical} · warning ${counts.warning})` : ''}`)
|
|
191
|
+
}
|
|
192
|
+
if (item.url) lines.push(` 详情:${item.url}`)
|
|
193
|
+
if (item.alternatives?.length) lines.push(` 同仓库其他包:${item.alternatives.map(alt => alt.name).join(', ')}`)
|
|
194
|
+
lines.push(...footerLines(manifest, now))
|
|
195
|
+
return lines
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Render the report.
|
|
200
|
+
* @param {object} value tool value
|
|
201
|
+
* @param {{ manifest?: object, building?: boolean, indexing?: boolean, now?: Date }} state
|
|
202
|
+
*/
|
|
203
|
+
export function textReport(value, state = {}) {
|
|
204
|
+
const now = state.now ?? new Date()
|
|
205
|
+
const circuit = value.vector?.circuit?.open && value.vector?.notice
|
|
206
|
+
? `⚠️ ${value.vector.notice}`
|
|
207
|
+
: ''
|
|
208
|
+
const indexNotice = value.index?.notice ? `⚠️ ${value.index.notice}` : ''
|
|
209
|
+
const notices = [circuit, indexNotice].filter(Boolean)
|
|
210
|
+
if (value.refresh) {
|
|
211
|
+
const refresh = value.refresh
|
|
212
|
+
return [
|
|
213
|
+
refresh.started ? '📚 已开始后台建库,立即返回;建好后重新检索即可。' : '📚 建库没有启动。',
|
|
214
|
+
refresh.reason === 'running' ? '已有一个建库任务在运行。' : '',
|
|
215
|
+
refresh.reason === 'locked' ? '另一个进程持有建库锁。' : '',
|
|
216
|
+
ATTRIBUTION,
|
|
217
|
+
].filter(Boolean).join('\n')
|
|
218
|
+
}
|
|
219
|
+
if (state.indexing) {
|
|
220
|
+
return [
|
|
221
|
+
'🔍 索引尚未建立。',
|
|
222
|
+
'已在后台开始建库(首次约 1–3 分钟,约 25 MB 下载);建好后重新调用即可。',
|
|
223
|
+
'也可以前台执行:npx dsh-plugin-discover build',
|
|
224
|
+
ATTRIBUTION,
|
|
225
|
+
].join('\n')
|
|
226
|
+
}
|
|
227
|
+
if (value.explain) return explainLines(value, state.manifest, now).join('\n')
|
|
228
|
+
if (value.results.length === 0) {
|
|
229
|
+
const lines = noMatchLines(value, state.manifest, now)
|
|
230
|
+
if (notices.length > 0) lines.unshift(...notices)
|
|
231
|
+
return lines.join('\n')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const lines = [headerLine(value), '']
|
|
235
|
+
if (notices.length > 0) lines.unshift(...notices)
|
|
236
|
+
value.results.forEach((result, index) => {
|
|
237
|
+
lines.push(...resultLines(result, index, now))
|
|
238
|
+
})
|
|
239
|
+
lines.push('')
|
|
240
|
+
lines.push(...footerLines(state.manifest, now))
|
|
241
|
+
if (state.building) lines.push('⏳ 索引正在后台更新,当前结果来自上一版索引。')
|
|
242
|
+
if (state.lastError) lines.push('⚠️ 上次建库失败,当前结果来自上一版索引。')
|
|
243
|
+
return lines.join('\n')
|
|
244
|
+
}
|