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
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
|
+
}
|
package/lib/tokenize.js
ADDED
|
@@ -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
|
+
}
|