dsh-plugin-discover 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Index storage: the `current` pointer, manifests, the build lock, and
3
+ * delayed cleanup of superseded index directories.
4
+ */
5
+ import {
6
+ existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync,
7
+ statSync, unlinkSync, writeFileSync,
8
+ } from 'node:fs'
9
+ import { hostname } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import {
12
+ buildLogPath, currentPath, isIndexDirName, lockPath, newIndexDirName, resolveIndexDir,
13
+ } from './paths.js'
14
+
15
+ /** A lock older than this is treated as abandoned even if its PID is alive. */
16
+ export const LOCK_STALE_MS = 30 * 60 * 1000
17
+
18
+ /** Directory names that look like published indexes, newest first. */
19
+ export function listIndexDirs(indexDir) {
20
+ let names
21
+ try {
22
+ names = readdirSync(indexDir, { withFileTypes: true })
23
+ .filter(entry => entry.isDirectory() && isIndexDirName(entry.name))
24
+ .map(entry => entry.name)
25
+ } catch {
26
+ return []
27
+ }
28
+ return names.sort((a, b) => b.localeCompare(a))
29
+ }
30
+
31
+ /** Read the `current` pointer; null when missing or malformed. */
32
+ export function readCurrent(indexDir) {
33
+ try {
34
+ const name = readFileSync(currentPath(indexDir), 'utf8').trim()
35
+ if (!isIndexDirName(name)) return null
36
+ const dir = resolveIndexDir(indexDir, name)
37
+ if (!dir || !existsSync(join(dir, 'manifest.json')) || !existsSync(join(dir, 'records.json'))) return null
38
+ return { name, dir, mtimeMs: statSync(currentPath(indexDir)).mtimeMs }
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ /** Read the manifest behind the current pointer, if both are readable. */
45
+ export function readManifest(indexDir) {
46
+ const current = readCurrent(indexDir)
47
+ if (!current) return null
48
+ try {
49
+ const manifest = JSON.parse(readFileSync(join(current.dir, 'manifest.json'), 'utf8'))
50
+ if (!manifest || typeof manifest !== 'object') return null
51
+ return { ...current, manifest }
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ /** Read the current record set; null when no usable index exists. */
58
+ export function readRecords(indexDir) {
59
+ const current = readCurrent(indexDir)
60
+ if (!current) return null
61
+ try {
62
+ const records = JSON.parse(readFileSync(join(current.dir, 'records.json'), 'utf8'))
63
+ if (!Array.isArray(records)) return null
64
+ return { ...current, records }
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /** Atomically point `current` at a published index directory. */
71
+ export function publishCurrent(indexDir, name) {
72
+ if (!isIndexDirName(name)) throw new Error(`invalid index directory name: ${name}`)
73
+ const target = currentPath(indexDir)
74
+ const temp = `${target}.tmp-${process.pid}`
75
+ writeFileSync(temp, `${name}\n`)
76
+ try {
77
+ renameSync(temp, target)
78
+ } catch (error) {
79
+ // Windows can refuse to replace an existing file held open by a reader.
80
+ if (error && (error.code === 'EPERM' || error.code === 'EEXIST')) {
81
+ try { unlinkSync(target) } catch {}
82
+ renameSync(temp, target)
83
+ } else {
84
+ try { unlinkSync(temp) } catch {}
85
+ throw error
86
+ }
87
+ }
88
+ }
89
+
90
+ /** Create a fresh unpublished index directory. */
91
+ export function createIndexDir(indexDir, now = new Date()) {
92
+ mkdirSync(indexDir, { recursive: true })
93
+ const name = newIndexDirName(now)
94
+ const dir = join(indexDir, name)
95
+ mkdirSync(dir, { recursive: true })
96
+ return { name, dir }
97
+ }
98
+
99
+ /** Read the build lock; null when absent or unreadable. */
100
+ export function readLock(indexDir) {
101
+ try {
102
+ const value = JSON.parse(readFileSync(lockPath(indexDir), 'utf8'))
103
+ if (!value || typeof value !== 'object') return null
104
+ return value
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ /** Whether a lock belongs to a dead or over-age builder. */
111
+ export function isLockStale(lock, now = Date.now()) {
112
+ if (!lock || typeof lock !== 'object') return true
113
+ if (typeof lock.startedAt !== 'number' || typeof lock.pid !== 'number') return true
114
+ if (now - lock.startedAt > LOCK_STALE_MS) return true
115
+ try {
116
+ process.kill(lock.pid, 0)
117
+ return false
118
+ } catch (error) {
119
+ return error && error.code === 'ESRCH'
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Acquire the cross-process build lock. A stale lock is reclaimed once.
125
+ * @returns `{ ok: true, release }` or `{ ok: false, lock }`.
126
+ */
127
+ export function acquireLock(indexDir, { pid = process.pid, now = Date.now() } = {}) {
128
+ mkdirSync(indexDir, { recursive: true })
129
+ const file = lockPath(indexDir)
130
+ const payload = JSON.stringify({ pid, startedAt: now, host: hostname() })
131
+ for (let attempt = 0; attempt < 2; attempt += 1) {
132
+ try {
133
+ writeFileSync(file, payload, { flag: 'wx' })
134
+ return {
135
+ ok: true,
136
+ lock: { pid, startedAt: now, host: hostname() },
137
+ release() {
138
+ const held = readLock(indexDir)
139
+ if (held && held.pid === pid && held.startedAt === now) {
140
+ try { unlinkSync(file) } catch {}
141
+ }
142
+ },
143
+ }
144
+ } catch (error) {
145
+ if (!error || error.code !== 'EEXIST') throw error
146
+ const existing = readLock(indexDir)
147
+ if (existing && isLockStale(existing, now)) {
148
+ try { unlinkSync(file) } catch {}
149
+ continue
150
+ }
151
+ return { ok: false, lock: existing }
152
+ }
153
+ }
154
+ return { ok: false, lock: readLock(indexDir) }
155
+ }
156
+
157
+ /** Remove index directories superseded by `keepName`, keeping the newest two. */
158
+ export function cleanupOldIndexes(indexDir, keepName, { now = Date.now(), maxAgeMs = 7 * 24 * 60 * 60 * 1000 } = {}) {
159
+ const names = listIndexDirs(indexDir)
160
+ const keep = new Set([keepName, ...names.filter(name => name !== keepName).slice(0, 1)])
161
+ const removed = []
162
+ for (const name of names) {
163
+ if (keep.has(name)) continue
164
+ const dir = join(indexDir, name)
165
+ let age = 0
166
+ try { age = now - statSync(dir).mtimeMs } catch { continue }
167
+ if (age < maxAgeMs) continue
168
+ try {
169
+ rmSync(dir, { recursive: true, force: true })
170
+ removed.push(name)
171
+ } catch {}
172
+ }
173
+ return removed
174
+ }
175
+
176
+ /** Read the tail of the build log, for diagnostics in tool output. */
177
+ export function readBuildLog(indexDir, maxChars = 4000) {
178
+ try {
179
+ const text = readFileSync(buildLogPath(indexDir), 'utf8')
180
+ return text.length > maxChars ? text.slice(-maxChars) : text
181
+ } catch {
182
+ return ''
183
+ }
184
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Query and document tokenization. Chinese runs use overlapping bigrams plus
3
+ * the last character — no dictionary, no segmenter. Stopwords and negation
4
+ * apply to queries only; the indexed documents keep every token.
5
+ */
6
+
7
+ const EN_STOPWORDS = new Set([
8
+ 'a', 'an', 'the', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'it', 'its', 'this', 'that',
9
+ 'to', 'for', 'of', 'in', 'on', 'with', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be',
10
+ 'do', 'does', 'did', 'can', 'could', 'should', 'would', 'will', 'want', 'wants', 'need',
11
+ 'needs', 'please', 'help', 'some', 'any', 'how', 'what', 'which', 'when', 'where', 'who',
12
+ 'why', 'plugin', 'plugins', 'dsh', 'deepseek', 'harness', 'tool', 'tools', 'something',
13
+ 'into', 'from', 'at', 'by', 'as', 'about', 'after', 'before', 'over', 'under', 'between',
14
+ 'through', 'during', 'out', 'up', 'down', 'off', 'again', 'more', 'most', 'other', 'such',
15
+ 'only', 'own', 'same', 'too', 'very', 'just', 'now', 'also', 'make', 'makes', 'making',
16
+ 'get', 'gets', 'getting', 'give', 'gives', 'take', 'takes', 'use', 'uses', 'using', 'used',
17
+ 'like', 'well', 'even', 'still', 'back', 'way', 'thing', 'things',
18
+ ])
19
+
20
+ const ZH_STOPWORDS = new Set([
21
+ '帮我', '帮忙', '我想', '我要', '想要', '一个', '这个', '那个', '可以', '能否', '能够', '需要',
22
+ '希望', '请问', '有没有', '什么', '怎么', '如何', '哪些', '哪个', '插件', '工具', '功能',
23
+ '我', '你', '他', '她', '它', '我们', '你们', '他们', '的', '了', '吗', '呢', '吧', '啊',
24
+ '请', '找', '要', '做', '用', '在', '有', '和', '与', '或', '及', '就', '也', '都', '还',
25
+ '不', '没', '没有', '能', '会', '把', '给', '被', '让', '对', '从', '到', '上', '下',
26
+ '但', '但是', '不过', '然而', '而且', '并且', '或者', '还是', '如果', '因为', '所以', '然后',
27
+ ])
28
+
29
+ const NEGATION_RE = /(?:不要|除了|不含|不带|无需|不想|without|except(?:ing)?|exclude)\s*([^,。;;,.!?!?\n]+)/gi
30
+
31
+ function isCjk(code) {
32
+ return (code >= 0x3400 && code <= 0x9fff)
33
+ || (code >= 0xf900 && code <= 0xfaff)
34
+ || (code >= 0x3040 && code <= 0x30ff)
35
+ }
36
+
37
+ function splitCamel(value) {
38
+ return value
39
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
40
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
41
+ }
42
+
43
+ /** Tokenize one string into lowercase words and CJK bigrams. */
44
+ export function tokenize(text) {
45
+ if (typeof text !== 'string' || text === '') return []
46
+ const value = splitCamel(text.normalize('NFKC'))
47
+ const tokens = []
48
+ let latin = ''
49
+ let cjk = ''
50
+ const flushLatin = () => {
51
+ if (latin.length >= 2 && !/^\d+$/.test(latin)) tokens.push(latin)
52
+ latin = ''
53
+ }
54
+ const flushCjk = () => {
55
+ if (cjk.length === 0) return
56
+ if (cjk.length === 1) {
57
+ tokens.push(cjk)
58
+ } else {
59
+ for (let index = 0; index + 1 < cjk.length; index += 1) tokens.push(cjk.slice(index, index + 2))
60
+ tokens.push(cjk.slice(-1))
61
+ }
62
+ cjk = ''
63
+ }
64
+ for (const character of value) {
65
+ const code = character.codePointAt(0)
66
+ if (isCjk(code)) {
67
+ flushLatin()
68
+ cjk += character
69
+ continue
70
+ }
71
+ flushCjk()
72
+ if (/[\p{L}\p{N}]/u.test(character)) latin += character.toLowerCase()
73
+ else flushLatin()
74
+ }
75
+ flushLatin()
76
+ flushCjk()
77
+ return tokens
78
+ }
79
+
80
+ /** Remove query stopwords, preserving order. */
81
+ export function dropStopwords(tokens) {
82
+ return tokens.filter(token => !EN_STOPWORDS.has(token) && !ZH_STOPWORDS.has(token))
83
+ }
84
+
85
+ /** Pull negated clauses out of a query, returning the cleaned text and the exclusions. */
86
+ export function extractNegation(text) {
87
+ if (typeof text !== 'string' || text === '') return { text: '', not: [] }
88
+ const not = []
89
+ const cleaned = text.replace(NEGATION_RE, (_match, clause) => {
90
+ const terms = dropStopwords(tokenize(clause))
91
+ .filter(term => term.length >= 2)
92
+ .slice(0, 3)
93
+ for (const term of terms) if (!not.includes(term)) not.push(term)
94
+ return ' '
95
+ })
96
+ return { text: cleaned, not }
97
+ }
98
+
99
+ /** Prepare one route: cleaned text, positive terms, and exclusions. */
100
+ export function prepareQuery(text) {
101
+ if (typeof text !== 'string' || text.trim() === '') return { text: '', terms: [], not: [] }
102
+ const { text: cleaned, not } = extractNegation(text)
103
+ let terms = dropStopwords(tokenize(cleaned))
104
+ if (terms.length === 0) terms = tokenize(cleaned)
105
+ const excluded = new Set(not)
106
+ terms = [...new Set(terms)].filter(term => !excluded.has(term))
107
+ return { text: cleaned.trim(), terms, not }
108
+ }
109
+
110
+ /**
111
+ * Prepare the query group: the original plus up to three model rewrites.
112
+ * The original's negations apply to every route.
113
+ */
114
+ export function prepareQueries(query, alts = []) {
115
+ const original = prepareQuery(query)
116
+ const rewrites = []
117
+ const seen = new Set()
118
+ for (const alt of Array.isArray(alts) ? alts : []) {
119
+ if (typeof alt !== 'string' || alt.trim() === '') continue
120
+ const packet = prepareQuery(alt)
121
+ if (packet.terms.length === 0) continue
122
+ const key = packet.terms.join(' ')
123
+ if (seen.has(key)) continue
124
+ seen.add(key)
125
+ rewrites.push(packet)
126
+ if (rewrites.length >= 3) break
127
+ }
128
+ const not = [...new Set([...original.not, ...rewrites.flatMap(packet => packet.not)])]
129
+ const excluded = new Set(not)
130
+ const clean = packet => ({ ...packet, terms: packet.terms.filter(term => !excluded.has(term)) })
131
+ return {
132
+ original: clean(original),
133
+ rewrites: rewrites.map(clean),
134
+ not,
135
+ }
136
+ }
137
+
138
+ /** The minimum distinct terms a document must match for one route. */
139
+ export function minHitsFor(terms) {
140
+ return Math.max(1, Math.ceil(terms.length * 0.34))
141
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * fp16 vector and README-excerpt caches outside the immutable index
3
+ * directories, keyed by content hash so a rebuilt index reuses unchanged work.
4
+ */
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { createHash } from 'node:crypto'
8
+
9
+ /** One fp16 value; inputs are normalized to [-1, 1]. */
10
+ export function float32ToFloat16(value) {
11
+ const buffer = new ArrayBuffer(4)
12
+ const floatView = new Float32Array(buffer)
13
+ const intView = new Int32Array(buffer)
14
+ floatView[0] = value
15
+ const bits = intView[0]
16
+ const sign = (bits >> 16) & 0x8000
17
+ let exponent = (bits >> 23) & 0xff
18
+ let mantissa = bits & 0x7fffff
19
+ if (exponent === 0xff) return sign | 0x7c00 | (mantissa ? 0x200 : 0)
20
+ const halfExponent = exponent - 127 + 15
21
+ if (halfExponent >= 31) return sign | 0x7c00
22
+ if (halfExponent <= 0) {
23
+ if (halfExponent < -10) return sign
24
+ mantissa = (mantissa | 0x800000) >> (1 - halfExponent)
25
+ return sign | ((mantissa + 0x1000) >> 13)
26
+ }
27
+ return sign | (halfExponent << 10) | ((mantissa + 0x1000) >> 13)
28
+ }
29
+
30
+ /** Decode one fp16 bit pattern. */
31
+ export function float16ToFloat32(bits) {
32
+ const sign = bits & 0x8000 ? -1 : 1
33
+ const exponent = (bits >> 10) & 0x1f
34
+ const mantissa = bits & 0x3ff
35
+ if (exponent === 0) return sign * mantissa * 2 ** -24
36
+ if (exponent === 31) return mantissa ? Number.NaN : sign * Number.POSITIVE_INFINITY
37
+ return sign * (1 + mantissa / 1024) * 2 ** (exponent - 15)
38
+ }
39
+
40
+ /** Serialize a 384-dimensional float vector as base64 of its fp16 bits. */
41
+ export function encodeVector(vector) {
42
+ const bits = new Uint16Array(vector.length)
43
+ for (let index = 0; index < vector.length; index += 1) bits[index] = float32ToFloat16(vector[index])
44
+ return Buffer.from(bits.buffer).toString('base64')
45
+ }
46
+
47
+ /** Decode a base64 fp16 vector back to float32. */
48
+ export function decodeVector(base64) {
49
+ const buffer = Buffer.from(base64, 'base64')
50
+ const bits = new Uint16Array(buffer.buffer, buffer.byteOffset, Math.floor(buffer.byteLength / 2))
51
+ const vector = new Float32Array(bits.length)
52
+ for (let index = 0; index < bits.length; index += 1) vector[index] = float16ToFloat32(bits[index])
53
+ return vector
54
+ }
55
+
56
+ /** Deterministic short hash used for excerpt and content keys. */
57
+ export function hashText(text) {
58
+ return createHash('sha256').update(String(text)).digest('hex').slice(0, 24)
59
+ }
60
+
61
+ function readJsonLines(file) {
62
+ const map = new Map()
63
+ if (!existsSync(file)) return map
64
+ try {
65
+ const text = readFileSync(file, 'utf8')
66
+ for (const line of text.split('\n')) {
67
+ const trimmed = line.trim()
68
+ if (!trimmed) continue
69
+ try {
70
+ const value = JSON.parse(trimmed)
71
+ if (value && typeof value.k === 'string') map.set(value.k, value)
72
+ } catch {}
73
+ }
74
+ } catch {}
75
+ return map
76
+ }
77
+
78
+ /** One store per index directory; caches survive index rebuilds. */
79
+ export class VectorStore {
80
+ constructor(indexDir) {
81
+ this.indexDir = indexDir
82
+ this.dir = join(indexDir, 'cache')
83
+ this.fields = null
84
+ this.readmes = null
85
+ this.excerpts = null
86
+ this.pending = { fields: [], readmes: [], excerpts: [] }
87
+ }
88
+
89
+ file(name) {
90
+ return join(this.dir, name)
91
+ }
92
+
93
+ ensureDir() {
94
+ mkdirSync(this.dir, { recursive: true })
95
+ }
96
+
97
+ getField(key) {
98
+ this.fields ??= readJsonLines(this.file('field-vectors.jsonl'))
99
+ return this.fields.get(key) ?? null
100
+ }
101
+
102
+ setField(key, vector, contentHash) {
103
+ const value = { k: key, v: encodeVector(vector), at: Date.now(), contentHash }
104
+ this.fields ??= readJsonLines(this.file('field-vectors.jsonl'))
105
+ this.fields.set(key, value)
106
+ this.pending.fields.push(value)
107
+ }
108
+
109
+ getReadme(key) {
110
+ this.readmes ??= readJsonLines(this.file('readme-vectors.jsonl'))
111
+ return this.readmes.get(key) ?? null
112
+ }
113
+
114
+ setReadme(key, vector, excerptHash) {
115
+ const value = { k: key, v: encodeVector(vector), at: Date.now(), excerptHash }
116
+ this.readmes ??= readJsonLines(this.file('readme-vectors.jsonl'))
117
+ this.readmes.set(key, value)
118
+ this.pending.readmes.push(value)
119
+ }
120
+
121
+ getExcerpt(repo) {
122
+ this.excerpts ??= readJsonLines(this.file('readme-excerpts.jsonl'))
123
+ return this.excerpts.get(repo) ?? null
124
+ }
125
+
126
+ setExcerpt(repo, excerpt) {
127
+ const value = { k: repo, ...excerpt, at: Date.now() }
128
+ this.excerpts ??= readJsonLines(this.file('readme-excerpts.jsonl'))
129
+ this.excerpts.set(repo, value)
130
+ this.pending.excerpts.push(value)
131
+ }
132
+
133
+ flush() {
134
+ this.ensureDir()
135
+ for (const [name, values] of Object.entries(this.pending)) {
136
+ if (values.length === 0) continue
137
+ const file = this.file(`${name === 'fields' ? 'field-vectors' : name === 'readmes' ? 'readme-vectors' : 'readme-excerpts'}.jsonl`)
138
+ appendFileSync(file, values.map(value => `${JSON.stringify(value)}\n`).join(''))
139
+ values.length = 0
140
+ }
141
+ }
142
+ }