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.
@@ -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
+ }