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/lib/runtime.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Host-side runtime: load the published index lazily, keep one searcher in
3
+ * memory, and start background builds without blocking a session.
4
+ */
5
+ import { spawn } from 'node:child_process'
6
+ import { appendFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { createSearcher } from './search.js'
9
+ import { detectRuntimeVersion } from './compat.js'
10
+ import { buildIndex, isStale } from './build.js'
11
+ import { defaultIndexDir, buildLogPath } from './paths.js'
12
+ import {
13
+ isLockStale, readBuildLog, readLock, readManifest, readRecords,
14
+ } from './store.js'
15
+
16
+ const WORKER = fileURLToPath(new URL('./build-worker.js', import.meta.url))
17
+
18
+ function readConfig(value) {
19
+ return value && typeof value.get === 'function' ? value.get() : value
20
+ }
21
+
22
+ function resolveIndexDir(configured) {
23
+ const value = readConfig(configured)
24
+ const text = typeof value === 'string' ? value.trim() : ''
25
+ return text === '' ? defaultIndexDir() : text
26
+ }
27
+
28
+ function nodeCommand() {
29
+ if (process.env.DSH_PLUGIN_DISCOVER_NODE) return process.env.DSH_PLUGIN_DISCOVER_NODE
30
+ return process.execPath
31
+ }
32
+
33
+ /** One runtime per plugin activation. */
34
+ export function createRuntime(config = {}) {
35
+ let indexDir = resolveIndexDir(config.indexDir)
36
+ let loaded = null
37
+ let building = false
38
+ let lastError = null
39
+ let child = null
40
+ let inProcess = false
41
+
42
+ /** Apply a live indexDir override between calls, without restarting the Host. */
43
+ function reconfigure(next = {}) {
44
+ const nextDir = resolveIndexDir(next.indexDir)
45
+ if (nextDir === indexDir || building) return
46
+ indexDir = nextDir
47
+ loaded = null
48
+ lastError = null
49
+ }
50
+
51
+ function load() {
52
+ const current = readManifest(indexDir)
53
+ if (!current) return null
54
+ if (loaded && loaded.name === current.name && loaded.mtimeMs === current.mtimeMs) return loaded
55
+ const records = readRecords(indexDir)
56
+ if (!records) return null
57
+ loaded = {
58
+ name: current.name,
59
+ mtimeMs: current.mtimeMs,
60
+ manifest: current.manifest,
61
+ searcher: createSearcher(records.records, { runtimeVersion: detectRuntimeVersion() }),
62
+ }
63
+ return loaded
64
+ }
65
+
66
+ function invalidate() {
67
+ loaded = null
68
+ }
69
+
70
+ function activeLock() {
71
+ const lock = readLock(indexDir)
72
+ if (!lock) return null
73
+ return isLockStale(lock) ? null : lock
74
+ }
75
+
76
+ function status() {
77
+ const manifest = readManifest(indexDir)
78
+ const lock = activeLock()
79
+ return {
80
+ indexDir,
81
+ hasIndex: manifest !== null,
82
+ manifest: manifest?.manifest ?? null,
83
+ name: manifest?.name ?? null,
84
+ building: building || lock !== null,
85
+ lock,
86
+ lastError,
87
+ }
88
+ }
89
+
90
+ function finishBuild(error) {
91
+ building = false
92
+ child = null
93
+ invalidate()
94
+ if (error) lastError = error
95
+ }
96
+
97
+ function startInProcess() {
98
+ if (inProcess) return
99
+ inProcess = true
100
+ building = true
101
+ child = null
102
+ const log = message => {
103
+ try { appendFileSync(buildLogPath(indexDir), `[${new Date().toISOString()}] ${message}\n`) } catch {}
104
+ }
105
+ void buildIndex({ indexDir, onProgress: log })
106
+ .then(() => finishBuild(null))
107
+ .catch(error => finishBuild(error instanceof Error ? error.message : String(error)))
108
+ .finally(() => { inProcess = false })
109
+ }
110
+
111
+ function kickBuild() {
112
+ if (building) return { started: false, reason: 'running' }
113
+ const lock = activeLock()
114
+ if (lock) return { started: false, reason: 'locked', lock }
115
+ building = true
116
+ lastError = null
117
+ try {
118
+ const env = { ...process.env }
119
+ if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = '1'
120
+ child = spawn(nodeCommand(), [WORKER, indexDir], {
121
+ detached: true,
122
+ stdio: 'ignore',
123
+ windowsHide: true,
124
+ env,
125
+ })
126
+ child.once('error', error => {
127
+ lastError = `后台建库启动失败,改为主进程执行:${error.message}`
128
+ startInProcess()
129
+ })
130
+ child.once('exit', code => {
131
+ if (inProcess) return
132
+ if (code && code !== 0) lastError = readBuildLog(indexDir).slice(-1200) || `建库进程退出码 ${code}`
133
+ finishBuild(null)
134
+ })
135
+ child.unref()
136
+ return { started: true }
137
+ } catch (error) {
138
+ lastError = error instanceof Error ? error.message : String(error)
139
+ startInProcess()
140
+ return { started: true, fallback: 'in-process' }
141
+ }
142
+ }
143
+
144
+ function ensureIndex(policy = 'daily') {
145
+ const current = load()
146
+ if (current) {
147
+ if (isStale(current.manifest, policy)) {
148
+ const kick = kickBuild()
149
+ return { current, building: kick.started || kick.reason === 'locked' || kick.reason === 'running', kick }
150
+ }
151
+ return { current, building: building || activeLock() !== null }
152
+ }
153
+ if (policy === 'off') return { current: null, building: false, kick: { started: false, reason: 'off' } }
154
+ const kick = kickBuild()
155
+ return { current: null, building: kick.started || kick.reason === 'locked' || kick.reason === 'running', kick }
156
+ }
157
+
158
+ function indexAgeMs() {
159
+ const current = readManifest(indexDir)
160
+ const builtAt = Date.parse(current?.manifest?.builtAt ?? '')
161
+ return Number.isFinite(builtAt) ? Date.now() - builtAt : Number.POSITIVE_INFINITY
162
+ }
163
+
164
+ function isOlderThan(days) {
165
+ return indexAgeMs() >= days * 86_400_000
166
+ }
167
+
168
+ async function waitForBuild() {
169
+ if (!building && activeLock() === null) return
170
+ await new Promise(resolve => {
171
+ const timer = setInterval(() => {
172
+ if (!building && activeLock() === null) {
173
+ clearInterval(timer)
174
+ resolve()
175
+ }
176
+ }, 200)
177
+ timer.unref?.()
178
+ })
179
+ }
180
+
181
+ /**
182
+ * Blocking freshness gate for a user search. A stale/missing index must be
183
+ * rebuilt before searching; the previous index is only a failure fallback.
184
+ */
185
+ async function ensureFresh(days = 7) {
186
+ const current = load()
187
+ if (current && !isOlderThan(days)) {
188
+ return { current, stale: false, refreshed: false, fallback: false, error: null }
189
+ }
190
+ const previous = current
191
+ kickBuild()
192
+ await waitForBuild()
193
+ const next = load()
194
+ if (next && !isOlderThan(days)) {
195
+ return { current: next, stale: true, refreshed: true, fallback: false, error: null }
196
+ }
197
+ if (previous) {
198
+ return {
199
+ current: previous,
200
+ stale: true,
201
+ refreshed: false,
202
+ fallback: true,
203
+ error: lastError || 'refresh did not produce a fresh index',
204
+ }
205
+ }
206
+ return {
207
+ current: null,
208
+ stale: true,
209
+ refreshed: false,
210
+ fallback: false,
211
+ error: lastError || 'refresh failed and no previous index exists',
212
+ }
213
+ }
214
+
215
+ function dispose() {
216
+ loaded = null
217
+ if (child) {
218
+ try { child.removeAllListeners() } catch {}
219
+ child = null
220
+ }
221
+ building = false
222
+ }
223
+
224
+ return {
225
+ get indexDir() { return indexDir },
226
+ load,
227
+ invalidate,
228
+ status,
229
+ kickBuild,
230
+ ensureIndex,
231
+ ensureFresh,
232
+ waitForBuild,
233
+ reconfigure,
234
+ dispose,
235
+ }
236
+ }
237
+
238
+ /** Coverage block for the tool value. */
239
+ export function coverageOf(manifest) {
240
+ if (!manifest) return null
241
+ const sources = manifest.sources ?? {}
242
+ return {
243
+ sources: sources.catalog?.ok ? ['dsh.so', 'dsh-plugin-catalog'] : ['dsh.so'],
244
+ indexed: manifest.stats?.cards ?? 0,
245
+ generatedAt: sources.dshSo?.generatedAt ?? manifest.generatedAt ?? null,
246
+ }
247
+ }
package/lib/search.js ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * The local retrieval pipeline: normalize, hard-filter, four-way BM25, RRF,
3
+ * repo merge, (optional) vector rerank, quality prior, and a bounded result.
4
+ */
5
+ import { buildBm25 } from './bm25.js'
6
+ import { qualityFactor } from './quality.js'
7
+ import { selectFilter } from './filters.js'
8
+ import { prepareQueries, tokenize } from './tokenize.js'
9
+ import { vectorRerank } from './vector.js'
10
+
11
+ const RRF_K = 60
12
+ const ROUTE_WEIGHT = 1
13
+ const ALT_WEIGHT = 0.7
14
+ const CANDIDATE_POOL = 50
15
+
16
+ /** v2.4 gate1: exact-evidence check used only to decide whether a query has a match. */
17
+ export const GATE1_MIN_STRONG_COVERAGE = 0.55
18
+ export const GATE1_MIN_HITS_COVERAGE = 0.65
19
+ export const GATE1_MAX_HITS = 8
20
+ export const GATE1_MAX_DENOMINATOR = 8
21
+
22
+ /** User-facing irrelevant-result filter strength. */
23
+ export const NOISE_FILTER_LEVELS = Object.freeze({
24
+ none: Object.freeze({ gate1: null, gate2: null }),
25
+ low: Object.freeze({
26
+ gate1: Object.freeze({ strong: 0.45, hits: 0.53 }),
27
+ gate2: 0.815,
28
+ }),
29
+ medium: Object.freeze({
30
+ gate1: Object.freeze({ strong: GATE1_MIN_STRONG_COVERAGE, hits: GATE1_MIN_HITS_COVERAGE }),
31
+ gate2: 0.85,
32
+ }),
33
+ high: Object.freeze({
34
+ gate1: Object.freeze({ strong: 0.60, hits: 0.70 }),
35
+ gate2: 0.87,
36
+ }),
37
+ })
38
+ export const DEFAULT_NOISE_FILTER = 'medium'
39
+ export const NOISE_FILTERS = Object.freeze(Object.keys(NOISE_FILTER_LEVELS))
40
+
41
+ export function noiseFilterConfig(level) {
42
+ return NOISE_FILTER_LEVELS[level] ?? NOISE_FILTER_LEVELS[DEFAULT_NOISE_FILTER]
43
+ }
44
+
45
+ /** Build the per-index search state once and reuse it for every query. */
46
+ export function createSearcher(records, { runtimeVersion = null } = {}) {
47
+ const byKey = new Map()
48
+ for (let index = 0; index < records.length; index += 1) {
49
+ const record = records[index]
50
+ byKey.set(record.id, index)
51
+ byKey.set(record.name, index)
52
+ if (record.alias) byKey.set(record.alias, index)
53
+ if (record.install?.target) byKey.set(record.install.target, index)
54
+ }
55
+ return {
56
+ records,
57
+ bm25: buildBm25(records),
58
+ byKey,
59
+ runtimeVersion,
60
+ }
61
+ }
62
+
63
+ function groupKey(record) {
64
+ return record.repoKey || `name:${record.name.toLowerCase()}`
65
+ }
66
+
67
+ function recordContains(record, term) {
68
+ const fields = [
69
+ record.name,
70
+ record.title,
71
+ (record.topics ?? []).join(' '),
72
+ (record.capability ?? []).join(' '),
73
+ record.description,
74
+ ]
75
+ return fields.some(value => typeof value === 'string' && value.toLowerCase().includes(term))
76
+ }
77
+
78
+ /** Zero out allowed documents that contain a negated term. */
79
+ function applyNegation(records, allowed, terms) {
80
+ if (terms.length === 0) return { allowed, dropped: 0 }
81
+ const masked = Uint8Array.from(allowed)
82
+ let dropped = 0
83
+ for (let index = 0; index < records.length; index += 1) {
84
+ if (masked[index] === 0) continue
85
+ if (terms.some(term => recordContains(records[index], term))) {
86
+ masked[index] = 0
87
+ dropped += 1
88
+ }
89
+ }
90
+ return { allowed: masked, dropped }
91
+ }
92
+
93
+ function routeTermsOf(prepared, from) {
94
+ if (from === 'query') return prepared.original.terms
95
+ const index = Number(String(from).replace('alt', '')) - 1
96
+ return prepared.rewrites[index]?.terms ?? prepared.original.terms
97
+ }
98
+
99
+ function evidenceFor(record, terms, maxHits = 6) {
100
+ const fields = []
101
+ const text = {
102
+ name: record.name,
103
+ title: record.title ?? '',
104
+ topics: (record.topics ?? []).join(' '),
105
+ capability: (record.capability ?? []).join(' '),
106
+ description: record.description ?? '',
107
+ }
108
+ const hits = []
109
+ for (const term of terms) {
110
+ let matched = false
111
+ for (const [field, value] of Object.entries(text)) {
112
+ if (typeof value !== 'string' || value === '') continue
113
+ if (value.toLowerCase().includes(term)) {
114
+ if (!fields.includes(field)) fields.push(field)
115
+ matched = true
116
+ }
117
+ }
118
+ if (matched) hits.push(term)
119
+ if (hits.length >= maxHits) break
120
+ }
121
+ return { fields, terms: hits }
122
+ }
123
+
124
+ /**
125
+ * gate1 evidence check. It is a judgment, not a filter: the caller uses
126
+ * `pass` only to decide whether the query has any matching candidate. It does
127
+ * not reorder or delete individual candidates.
128
+ */
129
+ export function evaluateGate1(record, terms, thresholds = NOISE_FILTER_LEVELS.medium.gate1) {
130
+ const evidence = evidenceFor(record, terms, GATE1_MAX_HITS)
131
+ const denominator = Math.max(1, Math.min(terms.length, GATE1_MAX_DENOMINATOR))
132
+ const coverage = evidence.terms.length / denominator
133
+ const strongField = evidence.fields.some(field => field === 'name' || field === 'topics' || field === 'capability')
134
+ const pass = (strongField && coverage >= thresholds.strong)
135
+ || (evidence.terms.length >= 2 && coverage >= thresholds.hits)
136
+ return {
137
+ pass,
138
+ hits: evidence.terms.length,
139
+ terms: terms.length,
140
+ denominator,
141
+ coverage,
142
+ strongField,
143
+ fields: evidence.fields,
144
+ }
145
+ }
146
+
147
+ function badgesFor(record) {
148
+ const badges = {}
149
+ if (record.verification?.level) badges.verification = `L${record.verification.level}`
150
+ if (record.security?.status === 'audited' && record.security.riskLevel) {
151
+ badges.security = record.security.riskLevel
152
+ } else if (record.security?.status) {
153
+ badges.security = record.security.status
154
+ }
155
+ if (record.updatedAt) badges.updatedAt = record.updatedAt.slice(0, 10)
156
+ return badges
157
+ }
158
+
159
+ function resultFor(record, { from, terms, extraAlternatives = [], evidence = null }) {
160
+ const result = {
161
+ id: record.id,
162
+ name: record.name,
163
+ description: record.description,
164
+ evidence: { ...(evidence ?? evidenceFor(record, terms)), from },
165
+ alternatives: [
166
+ ...new Set([
167
+ ...(record.alternatives ?? []).map(item => item.name),
168
+ ...extraAlternatives,
169
+ ]),
170
+ ].filter(name => name && name !== record.name).slice(0, 6),
171
+ }
172
+ if (typeof record.stars === 'number') result.stars = record.stars
173
+ if (record.title) result.title = record.title
174
+ if (record.install?.target) result.install = { target: record.install.target }
175
+ const badges = badgesFor(record)
176
+ if (Object.keys(badges).length > 0) result.badges = badges
177
+ return result
178
+ }
179
+
180
+ /**
181
+ * Run one search.
182
+ * @param {ReturnType<typeof createSearcher>} searcher
183
+ * @param {object} params
184
+ */
185
+ export async function search(searcher, {
186
+ query,
187
+ queryAlts = [],
188
+ limit = 10,
189
+ preset = 'balanced',
190
+ minVerificationLevel = 4,
191
+ vectorMode = 'off',
192
+ vectorTopN = CANDIDATE_POOL,
193
+ noiseFilter = DEFAULT_NOISE_FILTER,
194
+ readmeOptions = null,
195
+ indexDir = null,
196
+ disposeClient = true,
197
+ coverage = null,
198
+ } = {}) {
199
+ const prepared = prepareQueries(query, queryAlts)
200
+ const routes = [prepared.original, ...prepared.rewrites].filter(route => route.terms.length > 0)
201
+ const selectedNoise = NOISE_FILTERS.includes(noiseFilter) ? noiseFilter : DEFAULT_NOISE_FILTER
202
+ const noise = noiseFilterConfig(selectedNoise)
203
+ const interpreted = {
204
+ terms: prepared.original.terms,
205
+ not: prepared.not,
206
+ filters: { preset },
207
+ }
208
+ const empty = {
209
+ query,
210
+ interpreted,
211
+ filterStats: { droppedBy: { security: 0, compat: 0, verification: 0, negation: 0 } },
212
+ results: [],
213
+ noMatch: true,
214
+ ...(coverage ? { coverage } : {}),
215
+ }
216
+ if (routes.length === 0) return empty
217
+
218
+ const filter = selectFilter(searcher.records, {
219
+ preset,
220
+ minVerificationLevel,
221
+ runtimeVersion: searcher.runtimeVersion,
222
+ })
223
+ interpreted.filters.preset = filter.preset
224
+ if (filter.relaxedFrom) interpreted.filters.relaxedFrom = filter.relaxedFrom
225
+
226
+ const negation = applyNegation(searcher.records, filter.allowed, prepared.not)
227
+
228
+ const fused = new Map()
229
+ routes.forEach((route, routeIndex) => {
230
+ const weight = routeIndex === 0 ? ROUTE_WEIGHT : ALT_WEIGHT
231
+ const hits = searcher.bm25.search(route.terms, { allowed: negation.allowed, limit: 400 })
232
+ hits.forEach((hit, rank) => {
233
+ const contribution = weight / (RRF_K + rank + 1)
234
+ const current = fused.get(hit.doc)
235
+ if (!current) {
236
+ fused.set(hit.doc, {
237
+ doc: hit.doc,
238
+ score: contribution,
239
+ best: contribution,
240
+ from: routeIndex === 0 ? 'query' : `alt${routeIndex}`,
241
+ matched: hit.matched,
242
+ })
243
+ } else {
244
+ current.score += contribution
245
+ if (contribution > current.best) {
246
+ current.best = contribution
247
+ current.from = routeIndex === 0 ? 'query' : `alt${routeIndex}`
248
+ }
249
+ current.matched = Math.max(current.matched, hit.matched)
250
+ }
251
+ })
252
+ })
253
+
254
+ const ranked = [...fused.values()].sort((a, b) => b.score - a.score || a.doc - b.doc)
255
+ const candidatePool = vectorMode === 'off'
256
+ ? CANDIDATE_POOL
257
+ : Math.min(200, Math.max(2, Math.round(Number(vectorTopN) || CANDIDATE_POOL)))
258
+ const groups = new Map()
259
+ for (const entry of ranked) {
260
+ const record = searcher.records[entry.doc]
261
+ const key = groupKey(record)
262
+ const group = groups.get(key)
263
+ if (group) {
264
+ group.extras.push(record.name)
265
+ continue
266
+ }
267
+ groups.set(key, { ...entry, extras: [] })
268
+ if (groups.size >= candidatePool) break
269
+ }
270
+ let candidates = [...groups.values()].slice(0, candidatePool)
271
+ const gate1Total = candidates.length
272
+ const gate1Enabled = Boolean(noise.gate1)
273
+ if (gate1Enabled) {
274
+ for (const entry of candidates) {
275
+ const record = searcher.records[entry.doc]
276
+ entry.gate1 = evaluateGate1(record, routeTermsOf(prepared, entry.from), noise.gate1)
277
+ }
278
+ }
279
+ const gate1PassCount = gate1Enabled
280
+ ? candidates.filter(entry => entry.gate1.pass).length
281
+ : gate1Total
282
+ const gate1Any = gate1Enabled ? gate1PassCount > 0 : true
283
+ const filterStats = {
284
+ droppedBy: {
285
+ security: filter.droppedBy.security,
286
+ compat: filter.droppedBy.compatibility,
287
+ verification: filter.droppedBy.verification,
288
+ negation: negation.dropped,
289
+ },
290
+ }
291
+ const gatesFor = gate2 => ({
292
+ level: selectedNoise,
293
+ gate1: {
294
+ enabled: gate1Enabled,
295
+ passed: gate1Any,
296
+ passCount: gate1PassCount,
297
+ total: gate1Total,
298
+ },
299
+ ...(gate2 ? { gate2 } : {}),
300
+ })
301
+ const noMatchValue = gates => ({
302
+ query,
303
+ interpreted,
304
+ filterStats,
305
+ results: [],
306
+ noMatch: true,
307
+ ...(coverage ? { coverage } : {}),
308
+ gates,
309
+ })
310
+
311
+ // gate1 is a judgment, not a per-candidate filter or reorder. In lexical
312
+ // mode a query with no gate1-passing candidate is a genuine noMatch; a
313
+ // passing candidate keeps the normal BM25 order (including gate1 failures).
314
+ if (gate1Enabled && vectorMode === 'off' && !gate1Any) return noMatchValue(gatesFor())
315
+
316
+ const vector = await vectorRerank(candidates, routes, {
317
+ mode: vectorMode,
318
+ gate2Threshold: noise.gate2,
319
+ indexDir,
320
+ records: searcher.records,
321
+ disposeClient,
322
+ readmeOptions,
323
+ })
324
+ if (vector?.candidates) candidates = vector.candidates
325
+
326
+ // gate2 is also a judgment. Balance/high only become noMatch when neither
327
+ // gate1 nor gate2 has a passing candidate; otherwise the normal vector order
328
+ // is returned, including candidates that fail one or both gates.
329
+ let gate2 = null
330
+ if (vectorMode !== 'off') {
331
+ if (!noise.gate2) {
332
+ gate2 = { enabled: false, passed: true, available: Boolean(vector) }
333
+ } else if (vector?.gate2) {
334
+ gate2 = { ...vector.gate2, enabled: true, available: true }
335
+ } else {
336
+ gate2 = { enabled: true, available: false, passed: false, threshold: noise.gate2 }
337
+ }
338
+ }
339
+ if (gate1Enabled && vectorMode !== 'off' && !gate1Any && gate2 && !gate2.passed) {
340
+ return noMatchValue(gatesFor(gate2))
341
+ }
342
+ const gates = gatesFor(gate2)
343
+
344
+ // Evidence is kept for diagnostics/reporting; it never drops a candidate.
345
+ const rankedWithEvidence = candidates.map(entry => {
346
+ const record = searcher.records[entry.doc]
347
+ const terms = routeTermsOf(prepared, entry.from)
348
+ const evidenceTerms = [...new Set([...terms, ...prepared.original.terms])]
349
+ const evidence = evidenceTerms.length === terms.length
350
+ ? evidenceFor(record, terms)
351
+ : evidenceFor(record, evidenceTerms)
352
+ return { ...entry, evidence, terms: evidenceTerms }
353
+ })
354
+
355
+ const results = rankedWithEvidence
356
+ .map(entry => {
357
+ const record = searcher.records[entry.doc]
358
+ return {
359
+ entry,
360
+ record,
361
+ finalScore: entry.score * qualityFactor(record),
362
+ }
363
+ })
364
+ .sort((a, b) => b.finalScore - a.finalScore || a.entry.doc - b.entry.doc)
365
+ .slice(0, Math.min(20, Math.max(1, limit)))
366
+ .map(({ entry, record }) => resultFor(record, {
367
+ from: entry.from,
368
+ terms: entry.terms,
369
+ extraAlternatives: entry.extras,
370
+ evidence: entry.evidence,
371
+ }))
372
+
373
+ const value = {
374
+ query,
375
+ interpreted,
376
+ filterStats,
377
+ results,
378
+ noMatch: results.length === 0,
379
+ gates,
380
+ ...(coverage ? { coverage } : {}),
381
+ }
382
+ if (vector) {
383
+ value.vector = {
384
+ mode: vector.mode,
385
+ ...(vector.algorithm ? { algorithm: vector.algorithm } : {}),
386
+ covered: vector.covered,
387
+ ...(vector.fieldCovered !== undefined ? { fieldCovered: vector.fieldCovered } : {}),
388
+ ...(vector.degraded ? { degraded: vector.degraded } : {}),
389
+ ...(vector.error ? { error: vector.error } : {}),
390
+ ...(vector.readmeCovered !== undefined ? { readmeCovered: vector.readmeCovered } : {}),
391
+ ...(vector.readmeFallback !== undefined ? { readmeFallback: vector.readmeFallback } : {}),
392
+ ...(vector.readmeSources ? { readmeSources: vector.readmeSources } : {}),
393
+ ...(vector.circuit ? { circuit: vector.circuit } : {}),
394
+ ...(vector.notice ? { notice: vector.notice } : {}),
395
+ }
396
+ }
397
+ return value
398
+ }
399
+
400
+ /** Look up one record without searching. */
401
+ export function explain(searcher, id, { query = '', coverage = null } = {}) {
402
+ const index = searcher.byKey.get(id)
403
+ const value = {
404
+ query,
405
+ noMatch: index === undefined,
406
+ results: [],
407
+ ...(coverage ? { coverage } : {}),
408
+ }
409
+ if (index === undefined) {
410
+ value.explain = { found: false, id }
411
+ return value
412
+ }
413
+ const record = searcher.records[index]
414
+ value.explain = {
415
+ found: true,
416
+ id: record.id,
417
+ name: record.name,
418
+ alias: record.alias,
419
+ title: record.title,
420
+ description: record.description,
421
+ topics: record.topics,
422
+ capability: record.capability,
423
+ stars: record.stars,
424
+ verification: record.verification,
425
+ security: record.security,
426
+ repo: record.repo,
427
+ url: record.url,
428
+ install: record.install,
429
+ dshRange: record.dshRange,
430
+ updatedAt: record.updatedAt,
431
+ kind: record.kind,
432
+ sources: record.sources,
433
+ alternatives: record.alternatives ?? [],
434
+ }
435
+ return value
436
+ }
437
+
438
+ /** Diagnostics helper: the token list a document contributes, for tests. */
439
+ export function documentTokens(record) {
440
+ return [
441
+ ...tokenize(record.name),
442
+ ...tokenize(record.title),
443
+ ...tokenize((record.topics ?? []).join(' ')),
444
+ ...tokenize((record.capability ?? []).join(' ')),
445
+ ...tokenize(record.description),
446
+ ]
447
+ }