@bojackduy/opencode-telescope 0.1.23 → 0.1.25
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/components/preview.tsx +30 -143
- package/components/result-list.tsx +17 -1
- package/package.json +2 -1
- package/search/db-path.ts +67 -0
- package/search/dependencies.test.ts +38 -0
- package/search/dependencies.ts +63 -0
- package/search/embedding.test.ts +83 -0
- package/search/embedding.ts +67 -0
- package/search/queries.ts +813 -0
- package/search/schema.ts +120 -0
- package/search/text.ts +348 -0
- package/search/types.ts +131 -0
- package/search/vector.ts +223 -0
- package/search.ts +47 -1510
- package/telescope.tsx +71 -42
- package/ui/format.test.ts +120 -0
- package/ui/preview-utils.test.ts +161 -0
- package/ui/preview-utils.ts +136 -0
- package/ui/render-target.test.ts +55 -0
package/search/vector.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import type { Row, ScoredRow, SemanticConfig } from "./types.ts"
|
|
4
|
+
import { LlamaEmbeddingClient } from "./embedding.ts"
|
|
5
|
+
import { setMeta, getMeta } from "./schema.ts"
|
|
6
|
+
import { debug } from "../ui/debug.ts"
|
|
7
|
+
|
|
8
|
+
export function hybridBlend(keyword: Row[], vector: Row[], alpha: number): ScoredRow[] {
|
|
9
|
+
const merged = new Map<string, ScoredRow>()
|
|
10
|
+
const defaultScore = 0.5
|
|
11
|
+
|
|
12
|
+
for (const [i, row] of keyword.entries()) {
|
|
13
|
+
merged.set(row.id, {
|
|
14
|
+
...row,
|
|
15
|
+
score: 0,
|
|
16
|
+
keywordScore: keyword.length > 1 ? 1 - i / (keyword.length - 1) : 1,
|
|
17
|
+
vectorScore: 0,
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const [i, row] of vector.entries()) {
|
|
22
|
+
const existing = merged.get(row.id)
|
|
23
|
+
const vectorScore = vector.length > 1 ? 1 - i / (vector.length - 1) : 1
|
|
24
|
+
if (existing) {
|
|
25
|
+
existing.vectorScore = vectorScore
|
|
26
|
+
} else {
|
|
27
|
+
merged.set(row.id, {
|
|
28
|
+
...row,
|
|
29
|
+
score: 0,
|
|
30
|
+
keywordScore: defaultScore,
|
|
31
|
+
vectorScore,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const keywordValues = [...merged.values()].map((r) => r.keywordScore)
|
|
37
|
+
const vectorValues = [...merged.values()].map((r) => r.vectorScore)
|
|
38
|
+
const kwMin = Math.min(...keywordValues)
|
|
39
|
+
const kwMax = Math.max(...keywordValues)
|
|
40
|
+
const vecMin = Math.min(...vectorValues)
|
|
41
|
+
const vecMax = Math.max(...vectorValues)
|
|
42
|
+
|
|
43
|
+
const normalized = [...merged.values()].map((r) => {
|
|
44
|
+
const kn = kwMax === kwMin ? 0.5 : (r.keywordScore - kwMin) / (kwMax - kwMin)
|
|
45
|
+
const vn = vecMax === vecMin ? 0.5 : (r.vectorScore - vecMin) / (vecMax - vecMin)
|
|
46
|
+
return {
|
|
47
|
+
...r,
|
|
48
|
+
keywordScore: kn,
|
|
49
|
+
vectorScore: vn,
|
|
50
|
+
score: (1 - alpha) * kn + alpha * vn,
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return normalized.sort((a, b) => b.score - a.score)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function searchVector(index: Database, embedding: Float32Array, limit: number): Row[] {
|
|
58
|
+
const count = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_vec").get()?.count ?? 0
|
|
59
|
+
if (!count) return []
|
|
60
|
+
const k = Math.min(count, Math.max(limit * 4, 200))
|
|
61
|
+
return index.query<Row, [Float32Array, number]>(`
|
|
62
|
+
SELECT d.part_id AS id, d.message_id, d.session_id, d.session_title, d.directory, d.role,
|
|
63
|
+
d.part_type, d.tool, CAST(d.time_created AS INTEGER) AS time_created, d.text
|
|
64
|
+
FROM document_vec v
|
|
65
|
+
JOIN document d ON d.rowid = v.rowid
|
|
66
|
+
WHERE v.embedding MATCH vec_f32(?) AND k = ?
|
|
67
|
+
ORDER BY v.distance
|
|
68
|
+
`).all(embedding, k)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function setupVectorTable(index: Database, config: SemanticConfig, indexPath: string): Promise<void> {
|
|
72
|
+
const dims = getMeta(index, "embedding_dimensions")
|
|
73
|
+
if (dims) {
|
|
74
|
+
setMeta(index, "vector_state", "enabled")
|
|
75
|
+
debug.log("vector:already-indexed", { dimensions: dims })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
setMeta(index, "vector_state", "stale")
|
|
79
|
+
try {
|
|
80
|
+
await rebuildVectorIndex(indexPath, config)
|
|
81
|
+
} catch (err) {
|
|
82
|
+
debug.log("vector:rebuild:error", err instanceof Error ? err.message : String(err))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let customSQLiteConfigured = false
|
|
87
|
+
|
|
88
|
+
export function configureCustomSQLite() {
|
|
89
|
+
if (customSQLiteConfigured) return
|
|
90
|
+
const config = parseSemanticConfigForVector()
|
|
91
|
+
if (config.disableVector) return
|
|
92
|
+
customSQLiteConfigured = true
|
|
93
|
+
|
|
94
|
+
const candidates = [
|
|
95
|
+
config.sqliteLibPath,
|
|
96
|
+
process.platform === "darwin" ? "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib" : undefined,
|
|
97
|
+
process.platform === "darwin" ? "/usr/local/opt/sqlite/lib/libsqlite3.dylib" : undefined,
|
|
98
|
+
].filter((item): item is string => Boolean(item))
|
|
99
|
+
|
|
100
|
+
for (const candidate of candidates) {
|
|
101
|
+
if (existsSync(candidate)) {
|
|
102
|
+
try {
|
|
103
|
+
Database.setCustomSQLite(candidate)
|
|
104
|
+
debug.log("custom-sqlite:set", { path: candidate })
|
|
105
|
+
return
|
|
106
|
+
} catch (err) {
|
|
107
|
+
debug.log("custom-sqlite:error", { path: candidate, error: err instanceof Error ? err.message : String(err) })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function rebuildVectorIndex(indexPath: string, config: SemanticConfig) {
|
|
114
|
+
configureCustomSQLite()
|
|
115
|
+
const db = new Database(indexPath)
|
|
116
|
+
try {
|
|
117
|
+
const loaded = await loadVecExtension(db)
|
|
118
|
+
if (!loaded) {
|
|
119
|
+
setMeta(db, "vector_state", "unavailable")
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const client = new LlamaEmbeddingClient({
|
|
124
|
+
baseUrl: config.embedBaseUrl,
|
|
125
|
+
model: config.embedModel,
|
|
126
|
+
documentPrefix: config.documentPrefix,
|
|
127
|
+
queryPrefix: config.queryPrefix,
|
|
128
|
+
})
|
|
129
|
+
const healthy = await client.health()
|
|
130
|
+
if (!healthy) {
|
|
131
|
+
setMeta(db, "vector_state", "unavailable")
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const docs = db.query<{ rowid: number; text: string }, []>("SELECT rowid, text FROM document ORDER BY rowid ASC").all()
|
|
136
|
+
if (!docs.length) {
|
|
137
|
+
setMeta(db, "vector_state", "enabled")
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const truncate = (text: string) => text.length > 800 ? text.slice(0, 800) : text
|
|
142
|
+
|
|
143
|
+
const batchSize = 128
|
|
144
|
+
const embeddings: Float32Array[] = []
|
|
145
|
+
debug.log("vector:embed:start", { count: docs.length, batchSize })
|
|
146
|
+
for (let i = 0; i < docs.length; i += batchSize) {
|
|
147
|
+
const batch = docs.slice(i, i + batchSize)
|
|
148
|
+
const batchEmbeddings = await client.embedDocuments(batch.map((d) => truncate(d.text)))
|
|
149
|
+
embeddings.push(...batchEmbeddings)
|
|
150
|
+
debug.log("vector:embed:progress", { done: Math.min(i + batchSize, docs.length), total: docs.length })
|
|
151
|
+
}
|
|
152
|
+
const dims = embeddings[0]?.length
|
|
153
|
+
if (!dims) {
|
|
154
|
+
setMeta(db, "vector_state", "unavailable")
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
db.exec("DROP TABLE IF EXISTS document_vec")
|
|
159
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS document_vec USING vec0(embedding float[${dims}])`)
|
|
160
|
+
debug.log("vector:table:recreated", { dimensions: dims })
|
|
161
|
+
|
|
162
|
+
const insert = db.prepare<unknown, [number, Float32Array]>("INSERT INTO document_vec(rowid, embedding) VALUES (?, vec_f32(?))")
|
|
163
|
+
const transaction = db.transaction(() => {
|
|
164
|
+
for (const [i, doc] of docs.entries()) {
|
|
165
|
+
insert.run(doc.rowid, embeddings[i])
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
transaction()
|
|
169
|
+
|
|
170
|
+
setMeta(db, "vector_state", "enabled")
|
|
171
|
+
setMeta(db, "embedding_dimensions", String(dims))
|
|
172
|
+
if (config.embedModel) setMeta(db, "embedding_model", config.embedModel)
|
|
173
|
+
debug.log("vector:rebuild:done", { vectors: docs.length, dimensions: dims })
|
|
174
|
+
} catch (err) {
|
|
175
|
+
setMeta(db, "vector_state", "unavailable")
|
|
176
|
+
debug.log("vector:rebuild:error", err instanceof Error ? err.message : String(err))
|
|
177
|
+
} finally {
|
|
178
|
+
db.close()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function loadVecExtension(db: Database): Promise<boolean> {
|
|
183
|
+
try {
|
|
184
|
+
const sqliteVec = await importPackage("sqlite-vec").catch(() => undefined)
|
|
185
|
+
if (sqliteVec?.load) {
|
|
186
|
+
sqliteVec.load(db)
|
|
187
|
+
debug.log("vector:extension:loaded", { source: "npm" })
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
debug.log("vector:extension:npm-failed")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const config = parseSemanticConfigForVector()
|
|
195
|
+
const explicitPath = config.sqliteVecExtension || process.env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT
|
|
196
|
+
if (explicitPath && existsSync(explicitPath)) {
|
|
197
|
+
try {
|
|
198
|
+
db.loadExtension(explicitPath)
|
|
199
|
+
debug.log("vector:extension:loaded", { source: "path", path: explicitPath })
|
|
200
|
+
return true
|
|
201
|
+
} catch (err) {
|
|
202
|
+
debug.log("vector:extension:path-failed", { path: explicitPath, error: err instanceof Error ? err.message : String(err) })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function importPackage(specifier: string) {
|
|
210
|
+
return new Function("specifier", "return import(specifier)")(specifier) as Promise<{ load?: (db: Database) => void; getLoadablePath?: () => string }>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseSemanticConfigForVector(): { disableVector: boolean; sqliteLibPath?: string; sqliteVecExtension?: string; embedBaseUrl: string; embedModel?: string; documentPrefix: string; queryPrefix: string } {
|
|
214
|
+
return {
|
|
215
|
+
disableVector: process.env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "1" || process.env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "true",
|
|
216
|
+
sqliteLibPath: process.env.OPENCODE_TELESCOPE_SQLITE_LIB || undefined,
|
|
217
|
+
sqliteVecExtension: process.env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT || undefined,
|
|
218
|
+
embedBaseUrl: process.env.OPENCODE_TELESCOPE_EMBED_BASE_URL ?? "http://127.0.0.1:8081",
|
|
219
|
+
embedModel: process.env.OPENCODE_TELESCOPE_EMBED_MODEL || undefined,
|
|
220
|
+
documentPrefix: "search_document: ",
|
|
221
|
+
queryPrefix: "search_query: ",
|
|
222
|
+
}
|
|
223
|
+
}
|