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/compat.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Minimal semantic-version comparison and DSH runtime detection. The catalog
3
+ * stores ordinary npm ranges; anything this parser does not understand is
4
+ * reported as `unknown`, which the balanced preset deliberately lets through.
5
+ */
6
+ import { createRequire } from 'node:module'
7
+ import { readFileSync } from 'node:fs'
8
+ import { dirname } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
12
+
13
+ /** Parse a semantic version; null when malformed. */
14
+ export function parseVersion(value) {
15
+ if (typeof value !== 'string') return null
16
+ const match = VERSION_RE.exec(value.trim())
17
+ if (!match) return null
18
+ const prerelease = match[4] ? match[4].split('.') : []
19
+ if (prerelease.some(part => part === '')) return null
20
+ return {
21
+ major: Number(match[1]),
22
+ minor: Number(match[2]),
23
+ patch: Number(match[3]),
24
+ prerelease,
25
+ }
26
+ }
27
+
28
+ function compareIdentifier(a, b) {
29
+ const aNumeric = /^\d+$/.test(a)
30
+ const bNumeric = /^\d+$/.test(b)
31
+ if (aNumeric && bNumeric) return Number(a) - Number(b)
32
+ if (aNumeric) return -1
33
+ if (bNumeric) return 1
34
+ return a < b ? -1 : a > b ? 1 : 0
35
+ }
36
+
37
+ /** Compare two semantic versions: <0, 0, or >0. Malformed input sorts first. */
38
+ export function compareVersions(a, b) {
39
+ const left = parseVersion(a)
40
+ const right = parseVersion(b)
41
+ if (!left && !right) return 0
42
+ if (!left) return -1
43
+ if (!right) return 1
44
+ for (const key of ['major', 'minor', 'patch']) {
45
+ if (left[key] !== right[key]) return left[key] - right[key]
46
+ }
47
+ if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0
48
+ if (left.prerelease.length === 0) return 1
49
+ if (right.prerelease.length === 0) return -1
50
+ const length = Math.max(left.prerelease.length, right.prerelease.length)
51
+ for (let index = 0; index < length; index += 1) {
52
+ const l = left.prerelease[index]
53
+ const r = right.prerelease[index]
54
+ if (l === undefined) return -1
55
+ if (r === undefined) return 1
56
+ const compared = compareIdentifier(l, r)
57
+ if (compared !== 0) return compared
58
+ }
59
+ return 0
60
+ }
61
+
62
+ function upperBound(version, kind) {
63
+ const parsed = parseVersion(version)
64
+ if (!parsed) return null
65
+ const { major, minor, patch } = parsed
66
+ if (kind === '^') {
67
+ if (major > 0) return `${major + 1}.0.0-0`
68
+ if (minor > 0) return `0.${minor + 1}.0-0`
69
+ return `0.0.${patch + 1}-0`
70
+ }
71
+ if (kind === '~') return `${major}.${minor + 1}.0-0`
72
+ return null
73
+ }
74
+
75
+ function satisfiesComparator(version, comparator) {
76
+ const match = /^(\^|~|>=|<=|>|<|=)?\s*(.+)$/.exec(comparator.trim())
77
+ if (!match) return null
78
+ const operator = match[1] ?? '='
79
+ const target = match[2].trim()
80
+ if (target === '' || target === '*' || target === 'x' || target === 'X') return true
81
+ if (!parseVersion(target)) return null
82
+ if (operator === '=') return compareVersions(version, target) === 0
83
+ if (operator === '>') return compareVersions(version, target) > 0
84
+ if (operator === '>=') return compareVersions(version, target) >= 0
85
+ if (operator === '<') return compareVersions(version, target) < 0
86
+ if (operator === '<=') return compareVersions(version, target) <= 0
87
+ const upper = upperBound(target, operator)
88
+ if (!upper) return null
89
+ return compareVersions(version, target) >= 0 && compareVersions(version, upper) < 0
90
+ }
91
+
92
+ /**
93
+ * Check a version against a range. Returns `true`, `false`, or `null` for a
94
+ * range this parser does not understand.
95
+ */
96
+ export function satisfiesRange(version, range) {
97
+ if (typeof version !== 'string' || typeof range !== 'string') return null
98
+ if (!parseVersion(version)) return null
99
+ const groups = range.split('||').map(group => group.trim()).filter(Boolean)
100
+ if (groups.length === 0) return null
101
+ let understood = false
102
+ for (const group of groups) {
103
+ const comparators = group.split(/\s+/).filter(Boolean)
104
+ let all = true
105
+ let anyUnknown = false
106
+ for (const comparator of comparators) {
107
+ const result = satisfiesComparator(version, comparator)
108
+ if (result === null) { anyUnknown = true; all = false; break }
109
+ understood = true
110
+ if (!result) { all = false; break }
111
+ }
112
+ if (all && !anyUnknown) return true
113
+ }
114
+ return understood ? false : null
115
+ }
116
+
117
+ let detected
118
+
119
+ /** Guess the running DSH version without importing Host packages. */
120
+ export function detectRuntimeVersion() {
121
+ if (detected !== undefined) return detected
122
+ if (process.env.DSH_VERSION && parseVersion(process.env.DSH_VERSION)) {
123
+ detected = process.env.DSH_VERSION
124
+ return detected
125
+ }
126
+ const roots = new Set()
127
+ const add = value => { if (typeof value === 'string' && value.trim() !== '') roots.add(value) }
128
+ add(process.cwd())
129
+ add(process.env.DSH_PROFILE_DIR)
130
+ add(process.env.DSH_HOME)
131
+ try { add(dirname(fileURLToPath(import.meta.url))) } catch {}
132
+ const require = createRequire(import.meta.url)
133
+ const manifests = [
134
+ '@deepseek-ai/dsh-app-boot/package.json',
135
+ '@deepseek-ai/dsh/package.json',
136
+ '@deepseek-ai/dsh-cli/package.json',
137
+ ]
138
+ for (const root of roots) {
139
+ for (const manifest of manifests) {
140
+ try {
141
+ const file = require.resolve(manifest, { paths: [root] })
142
+ const value = JSON.parse(readFileSync(file, 'utf8'))
143
+ if (value && parseVersion(value.version)) {
144
+ detected = value.version
145
+ return detected
146
+ }
147
+ } catch {}
148
+ }
149
+ }
150
+ detected = null
151
+ return detected
152
+ }
153
+
154
+ /** Reset the detection cache (tests only). */
155
+ export function resetRuntimeVersionCache() {
156
+ detected = undefined
157
+ }
package/lib/filters.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Hard filtering by security, verification, and DSH compatibility. Missing
3
+ * fields are "unknown", not "failed": they pass and stay annotated.
4
+ */
5
+ import { satisfiesRange } from './compat.js'
6
+
7
+ export const PRESETS = ['strict', 'balanced', 'loose', 'off']
8
+ export const DEFAULT_PRESET = 'balanced'
9
+
10
+ function isAudited(record) {
11
+ return record?.security?.status === 'audited'
12
+ }
13
+
14
+ function riskOf(record) {
15
+ return record?.security?.riskLevel ?? 'unknown'
16
+ }
17
+
18
+ /** Security gate for one preset. Unknown security never fails a preset. */
19
+ export function securityAllowed(record, preset) {
20
+ if (preset === 'strict') return isAudited(record) && riskOf(record) === 'low'
21
+ if (preset === 'off') return !(isAudited(record) && riskOf(record) === 'critical')
22
+ if (preset === 'loose') {
23
+ const risk = riskOf(record)
24
+ return !(isAudited(record) && (risk === 'high' || risk === 'critical'))
25
+ }
26
+ if (!isAudited(record)) return true
27
+ const risk = riskOf(record)
28
+ return risk !== 'high' && risk !== 'critical'
29
+ }
30
+
31
+ /** Verification gate. `0` disables the level floor for the balanced preset. */
32
+ export function verificationAllowed(record, preset, minVerificationLevel = 4) {
33
+ if (preset === 'strict') return record?.verification?.level === 5
34
+ if (preset !== 'balanced') return true
35
+ const minimum = Number.isFinite(minVerificationLevel) ? minVerificationLevel : 4
36
+ if (minimum <= 0) return true
37
+ const level = record?.verification?.level
38
+ return typeof level !== 'number' ? true : level >= minimum
39
+ }
40
+
41
+ /** Compatibility state: true, false, or null for unknown. */
42
+ export function compatibilityOf(record, runtimeVersion) {
43
+ const range = record?.dshRange
44
+ if (typeof range !== 'string' || range.trim() === '') return null
45
+ if (typeof runtimeVersion !== 'string' || runtimeVersion === '') return null
46
+ return satisfiesRange(runtimeVersion, range)
47
+ }
48
+
49
+ /** Compatibility gate: balanced admits unknown, strict does not. */
50
+ export function compatibilityAllowed(record, preset, runtimeVersion) {
51
+ if (preset === 'strict') return compatibilityOf(record, runtimeVersion) === true
52
+ if (preset === 'loose' || preset === 'off') return true
53
+ return compatibilityOf(record, runtimeVersion) !== false
54
+ }
55
+
56
+ /** Evaluate one record against one preset. */
57
+ export function evaluateRecord(record, { preset = DEFAULT_PRESET, minVerificationLevel = 4, runtimeVersion = null } = {}) {
58
+ if (!securityAllowed(record, preset)) return { ok: false, droppedBy: 'security' }
59
+ if (!verificationAllowed(record, preset, minVerificationLevel)) return { ok: false, droppedBy: 'verification' }
60
+ if (!compatibilityAllowed(record, preset, runtimeVersion)) return { ok: false, droppedBy: 'compat' }
61
+ return { ok: true }
62
+ }
63
+
64
+ /** Apply one preset to every record. */
65
+ export function filterRecords(records, options = {}) {
66
+ const { preset = DEFAULT_PRESET, minVerificationLevel = 4, runtimeVersion = null } = options
67
+ const allowed = new Uint8Array(records.length)
68
+ const droppedBy = { security: 0, compatibility: 0, verification: 0 }
69
+ let kept = 0
70
+ for (let index = 0; index < records.length; index += 1) {
71
+ const result = evaluateRecord(records[index], { preset, minVerificationLevel, runtimeVersion })
72
+ if (result.ok) {
73
+ allowed[index] = 1
74
+ kept += 1
75
+ } else if (result.droppedBy === 'compat') {
76
+ droppedBy.compatibility += 1
77
+ } else {
78
+ droppedBy[result.droppedBy] += 1
79
+ }
80
+ }
81
+ return { allowed, kept, droppedBy }
82
+ }
83
+
84
+ /**
85
+ * Apply the requested preset, relaxing one step at a time until at least three
86
+ * candidates survive. The caller reports `relaxedFrom` when this happens.
87
+ */
88
+ export function selectFilter(records, options = {}) {
89
+ const { preset = DEFAULT_PRESET, minVerificationLevel = 4, runtimeVersion = null, minCandidates = 3 } = options
90
+ const start = PRESETS.includes(preset) ? preset : DEFAULT_PRESET
91
+ const startIndex = PRESETS.indexOf(start)
92
+ for (let index = startIndex; index < PRESETS.length; index += 1) {
93
+ const candidate = PRESETS[index]
94
+ const result = filterRecords(records, { preset: candidate, minVerificationLevel, runtimeVersion })
95
+ if (result.kept >= minCandidates || candidate === 'off') {
96
+ return {
97
+ preset: candidate,
98
+ relaxedFrom: candidate === start ? null : start,
99
+ ...result,
100
+ }
101
+ }
102
+ }
103
+ const fallback = filterRecords(records, { preset: 'off', minVerificationLevel, runtimeVersion })
104
+ return { preset: 'off', relaxedFrom: start, ...fallback }
105
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Client for the optional model package's line-delimited JSON server. The
3
+ * server owns the ~500 MB ONNX/tokenizer RSS; the Host only holds vectors.
4
+ */
5
+ import { spawn } from 'node:child_process'
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { createRequire } from 'node:module'
8
+ import { join } from 'node:path'
9
+ import { createInterface } from 'node:readline'
10
+
11
+ /** The optional model package is not installed or its server is missing. */
12
+ export class ModelUnavailableError extends Error {
13
+ constructor(message = 'model package is not installed') {
14
+ super(message)
15
+ this.name = 'ModelUnavailableError'
16
+ this.code = 'MODEL_UNAVAILABLE'
17
+ }
18
+ }
19
+
20
+ /** Resolve the server entry through the index pointer, then the profile. */
21
+ export function resolveModelServer(indexDir) {
22
+ try {
23
+ const pointer = JSON.parse(readFileSync(join(indexDir, 'model.json'), 'utf8'))
24
+ if (pointer && typeof pointer.server === 'string' && existsSync(pointer.server)) return pointer.server
25
+ } catch {}
26
+ const profileDir = process.env.DSH_PROFILE_DIR
27
+ if (profileDir) {
28
+ try {
29
+ const require = createRequire(join(profileDir, 'package.json'))
30
+ const server = require.resolve('dsh-plugin-discover-model/server.js')
31
+ if (existsSync(server)) return server
32
+ } catch {}
33
+ }
34
+ return null
35
+ }
36
+
37
+ /** Spawned model server with request/response correlation and idle respawn. */
38
+ export class ModelClient {
39
+ constructor(serverPath, { timeoutMs = 60_000 } = {}) {
40
+ this.serverPath = serverPath
41
+ this.timeoutMs = timeoutMs
42
+ this.child = null
43
+ this.pending = new Map()
44
+ this.nextId = 1
45
+ this.stderr = ''
46
+ this.closed = false
47
+ this.embeddedDocs = 0
48
+ // Recycling is an escape hatch for pathological environments, not the
49
+ // default: the model is capped at four ONNX threads instead of all cores.
50
+ this.recycleDocs = Number(process.env.DSH_MODEL_RECYCLE_DOCS) || 0
51
+ }
52
+
53
+ /** Stop this child after a bounded number of documents; the next request respawns. */
54
+ recycle() {
55
+ this.failAll(new ModelUnavailableError('model client recycled'))
56
+ if (this.child) {
57
+ try { this.child.kill() } catch {}
58
+ this.child = null
59
+ }
60
+ this.embeddedDocs = 0
61
+ }
62
+
63
+ start() {
64
+ if (this.child) return
65
+ if (this.closed) throw new ModelUnavailableError('model client is disposed')
66
+ const env = { ...process.env }
67
+ if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = '1'
68
+ const command = process.env.DSH_PLUGIN_DISCOVER_NODE || process.execPath
69
+ let child
70
+ try {
71
+ child = spawn(command, [this.serverPath], {
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ windowsHide: true,
74
+ env,
75
+ })
76
+ } catch (error) {
77
+ throw new ModelUnavailableError(`cannot start model server: ${error.message}`)
78
+ }
79
+ this.child = child
80
+ createInterface({ input: child.stdout, crlfDelay: Infinity }).on('line', line => {
81
+ const text = line.trim()
82
+ if (text === '') return
83
+ let response
84
+ try {
85
+ response = JSON.parse(text)
86
+ } catch {
87
+ return
88
+ }
89
+ const pending = this.pending.get(response.id)
90
+ if (!pending) return
91
+ this.pending.delete(response.id)
92
+ clearTimeout(pending.timer)
93
+ if (typeof response.error === 'string') pending.reject(new Error(response.error))
94
+ else pending.resolve(response)
95
+ })
96
+ child.stderr.on('data', chunk => {
97
+ this.stderr = `${this.stderr}${chunk.toString()}`.slice(-4000)
98
+ })
99
+ child.on('error', error => {
100
+ if (this.child !== child) return
101
+ this.failAll(new ModelUnavailableError(`model server error: ${error.message}`))
102
+ this.child = null
103
+ })
104
+ child.on('exit', code => {
105
+ if (this.child !== child) return
106
+ this.failAll(new ModelUnavailableError(
107
+ `model server exited (${code})${this.stderr ? `: ${this.stderr.slice(-500)}` : ''}`,
108
+ ))
109
+ this.child = null
110
+ })
111
+ }
112
+
113
+ failAll(error) {
114
+ for (const [id, pending] of this.pending) {
115
+ clearTimeout(pending.timer)
116
+ pending.reject(error)
117
+ this.pending.delete(id)
118
+ }
119
+ }
120
+
121
+ request(op, payload, timeoutMs = this.timeoutMs) {
122
+ this.start()
123
+ const id = this.nextId++
124
+ const line = `${JSON.stringify({ id, op, ...payload })}\n`
125
+ return new Promise((resolve, reject) => {
126
+ const timer = setTimeout(() => {
127
+ this.pending.delete(id)
128
+ reject(new Error(`model server request timed out after ${timeoutMs} ms`))
129
+ }, timeoutMs)
130
+ this.pending.set(id, { resolve, reject, timer })
131
+ try {
132
+ this.child.stdin.write(line)
133
+ } catch (error) {
134
+ clearTimeout(timer)
135
+ this.pending.delete(id)
136
+ reject(new ModelUnavailableError(`cannot write to model server: ${error.message}`))
137
+ }
138
+ })
139
+ }
140
+
141
+ async embed(texts, kind = 'passage') {
142
+ if (this.recycleDocs > 0 && this.embeddedDocs >= this.recycleDocs) this.recycle()
143
+ const response = await this.request('embed', { texts, kind }, Math.max(this.timeoutMs, 120_000))
144
+ if (!Array.isArray(response.vectors)) throw new Error('model server returned no vectors')
145
+ this.embeddedDocs += texts.length
146
+ if (this.recycleDocs > 0 && this.embeddedDocs >= this.recycleDocs) this.recycle()
147
+ return response.vectors.map(vector => Float32Array.from(vector))
148
+ }
149
+
150
+ async truncate(text, maxTokens = 384) {
151
+ const response = await this.request('truncate', { text, maxTokens })
152
+ return { text: String(response.text ?? ''), tokens: Number(response.tokens ?? 0) }
153
+ }
154
+
155
+ dispose() {
156
+ this.closed = true
157
+ this.failAll(new ModelUnavailableError('model client disposed'))
158
+ if (this.child) {
159
+ try { this.child.kill() } catch {}
160
+ this.child = null
161
+ }
162
+ }
163
+ }