@bojackduy/opencode-telescope 0.1.24 → 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 +2 -2
- 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/components/preview.tsx
CHANGED
|
@@ -14,6 +14,17 @@ import {
|
|
|
14
14
|
toolLabel,
|
|
15
15
|
truncate,
|
|
16
16
|
} from "../ui/format.ts"
|
|
17
|
+
import {
|
|
18
|
+
clippedText,
|
|
19
|
+
containsOrderedTokens,
|
|
20
|
+
conversationMatch,
|
|
21
|
+
filetype,
|
|
22
|
+
matchExcerpt,
|
|
23
|
+
parseApplyPatchFiles,
|
|
24
|
+
recordValue,
|
|
25
|
+
shortPath,
|
|
26
|
+
stringValue,
|
|
27
|
+
} from "../ui/preview-utils.ts"
|
|
17
28
|
|
|
18
29
|
export const PreviewHeader = (props: { item: SearchResult | undefined; query: string; theme: TuiThemeCurrent }) => (
|
|
19
30
|
<box
|
|
@@ -37,7 +48,9 @@ export const PreviewHeader = (props: { item: SearchResult | undefined; query: st
|
|
|
37
48
|
</box>
|
|
38
49
|
<Show when={props.query.trim()}>
|
|
39
50
|
<box width="100%" flexShrink={0}>
|
|
40
|
-
<text fg={props.theme.textMuted} wrapMode="none" overflow="hidden">
|
|
51
|
+
<text fg={props.theme.textMuted} wrapMode="none" overflow="hidden">
|
|
52
|
+
{item().isVectorMatch ? "~semantic: " : "match: "}{props.query.trim()}
|
|
53
|
+
</text>
|
|
41
54
|
</box>
|
|
42
55
|
</Show>
|
|
43
56
|
</>
|
|
@@ -305,10 +318,10 @@ const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle;
|
|
|
305
318
|
const TargetMarker = (props: { part: ConversationPreviewPart; item?: SearchResult; role: string; time: number; theme: TuiThemeCurrent }) => (
|
|
306
319
|
<box flexDirection="column" flexShrink={0}>
|
|
307
320
|
<text fg={props.theme.warning} wrapMode="none" overflow="hidden">
|
|
308
|
-
<span>match</span>
|
|
321
|
+
<span>{props.item?.isVectorMatch ? "~semantic" : "match"}</span>
|
|
309
322
|
<span style={{ fg: props.theme.textMuted }}> · {props.role} · {compactTime(props.time)}</span>
|
|
310
323
|
</text>
|
|
311
|
-
<Show when={props.item && matchExcerpt(props.part.text, props.item.match)}>
|
|
324
|
+
<Show when={props.item && !props.item.isVectorMatch && matchExcerpt(props.part.text, props.item.match)}>
|
|
312
325
|
{(excerpt) => (
|
|
313
326
|
<text fg={props.theme.textMuted} wrapMode="none" overflow="hidden">
|
|
314
327
|
<span>{excerpt().before}</span>
|
|
@@ -317,13 +330,24 @@ const TargetMarker = (props: { part: ConversationPreviewPart; item?: SearchResul
|
|
|
317
330
|
</text>
|
|
318
331
|
)}
|
|
319
332
|
</Show>
|
|
333
|
+
<Show when={props.item?.isVectorMatch && props.item}>
|
|
334
|
+
{(item) => (
|
|
335
|
+
<text fg={props.theme.textMuted} wrapMode="none" overflow="hidden">
|
|
336
|
+
{item().text.slice(0, 200)}
|
|
337
|
+
</text>
|
|
338
|
+
)}
|
|
339
|
+
</Show>
|
|
320
340
|
</box>
|
|
321
341
|
)
|
|
322
342
|
|
|
323
343
|
const HighlightedConversationText = (props: { part: ConversationPreviewPart; item: SearchResult; theme: TuiThemeCurrent }) => {
|
|
324
|
-
const match = createMemo(() => conversationMatch(props.part, props.item))
|
|
344
|
+
const match = createMemo(() => conversationMatch(props.part.text, props.part.target, props.item.match))
|
|
325
345
|
return (
|
|
326
|
-
<Show when={match()} fallback={
|
|
346
|
+
<Show when={match()} fallback={
|
|
347
|
+
<text fg={props.item.isVectorMatch ? props.theme.textMuted : props.theme.text}>
|
|
348
|
+
{props.item.isVectorMatch ? `~ ${props.part.text}` : props.part.text}
|
|
349
|
+
</text>
|
|
350
|
+
}>
|
|
327
351
|
{(hit) => (
|
|
328
352
|
<text fg={props.theme.text}>
|
|
329
353
|
<span>{props.part.text.slice(0, hit().start)}</span>
|
|
@@ -371,152 +395,15 @@ function searchResultPreviewPart(item: SearchResult): ConversationPreviewPart {
|
|
|
371
395
|
}
|
|
372
396
|
}
|
|
373
397
|
|
|
374
|
-
function conversationMatch(part: ConversationPreviewPart, item: SearchResult) {
|
|
375
|
-
if (!part.target || !item.match) return
|
|
376
|
-
const tokens = item.match.split(/\s+/)
|
|
377
|
-
const lowerText = part.text.toLowerCase()
|
|
378
|
-
let searchPos = 0
|
|
379
|
-
let firstStart = -1
|
|
380
|
-
let lastEnd = -1
|
|
381
|
-
for (const token of tokens) {
|
|
382
|
-
const index = lowerText.indexOf(token.toLowerCase(), searchPos)
|
|
383
|
-
if (index === -1) return
|
|
384
|
-
if (firstStart === -1) firstStart = index
|
|
385
|
-
searchPos = index + token.length
|
|
386
|
-
lastEnd = searchPos
|
|
387
|
-
}
|
|
388
|
-
return { start: firstStart, end: lastEnd }
|
|
389
|
-
}
|
|
390
|
-
|
|
391
398
|
function conversationMarkdown(part: ConversationPreviewPart, item: SearchResult) {
|
|
392
|
-
const hit = conversationMatch(part, item)
|
|
399
|
+
const hit = conversationMatch(part.text, part.target, item.match)
|
|
393
400
|
if (!hit || !item.previewHighlight) return part.text
|
|
394
401
|
return markdownWithMatch(part.text.slice(0, hit.start), part.text.slice(hit.start, hit.end), part.text.slice(hit.end), true)
|
|
395
402
|
}
|
|
396
403
|
|
|
397
|
-
function matchExcerpt(text: string, query: string, radius = 80) {
|
|
398
|
-
const needle = query.trim()
|
|
399
|
-
if (!needle) return
|
|
400
|
-
const tokens = needle.split(/\s+/)
|
|
401
|
-
const lowerText = text.toLowerCase()
|
|
402
|
-
let searchPos = 0
|
|
403
|
-
let firstStart = -1
|
|
404
|
-
let lastEnd = -1
|
|
405
|
-
for (const token of tokens) {
|
|
406
|
-
const start = lowerText.indexOf(token.toLowerCase(), searchPos)
|
|
407
|
-
if (start === -1) return
|
|
408
|
-
if (firstStart === -1) firstStart = start
|
|
409
|
-
searchPos = start + token.length
|
|
410
|
-
lastEnd = searchPos
|
|
411
|
-
}
|
|
412
|
-
const beforeStart = Math.max(0, firstStart - radius)
|
|
413
|
-
const afterEnd = Math.min(text.length, lastEnd + radius)
|
|
414
|
-
return {
|
|
415
|
-
before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, firstStart).replace(/\s+/g, " ")}`,
|
|
416
|
-
match: text.slice(firstStart, lastEnd),
|
|
417
|
-
after: `${text.slice(lastEnd, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function clippedText(text: string, query: string, radiusLines: number) {
|
|
422
|
-
const lines = text.split("\n")
|
|
423
|
-
const tooLarge = text.length > 30000 || lines.length > 420
|
|
424
|
-
if (!tooLarge) return { text, clipped: false }
|
|
425
|
-
|
|
426
|
-
const matchLine = findOrderedTokenLine(lines, query)
|
|
427
|
-
if (matchLine === -1) {
|
|
428
|
-
return { text: lines.slice(0, radiusLines * 2).join("\n"), clipped: true }
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const start = Math.max(0, matchLine - radiusLines)
|
|
432
|
-
const end = Math.min(lines.length, matchLine + radiusLines + 1)
|
|
433
|
-
return {
|
|
434
|
-
text: [
|
|
435
|
-
start > 0 ? `... ${start} lines omitted ...` : undefined,
|
|
436
|
-
...lines.slice(start, end),
|
|
437
|
-
end < lines.length ? `... ${lines.length - end} lines omitted ...` : undefined,
|
|
438
|
-
].filter(Boolean).join("\n"),
|
|
439
|
-
clipped: true,
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function findOrderedTokenLine(lines: string[], query: string) {
|
|
444
|
-
const tokens = query.trim().split(/\s+/).filter(Boolean)
|
|
445
|
-
if (tokens.length === 0) return -1
|
|
446
|
-
for (let index = 0; index < lines.length; index++) {
|
|
447
|
-
if (containsOrderedTokens(lines[index]!, query)) return index
|
|
448
|
-
}
|
|
449
|
-
return -1
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function containsOrderedTokens(text: string, query: string) {
|
|
453
|
-
const tokens = query.trim().split(/\s+/).filter(Boolean)
|
|
454
|
-
if (tokens.length === 0) return false
|
|
455
|
-
const lower = text.toLowerCase()
|
|
456
|
-
let searchPos = 0
|
|
457
|
-
for (const token of tokens) {
|
|
458
|
-
const index = lower.indexOf(token.toLowerCase(), searchPos)
|
|
459
|
-
if (index === -1) return false
|
|
460
|
-
searchPos = index + token.length
|
|
461
|
-
}
|
|
462
|
-
return true
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function parseApplyPatchFiles(metadata: unknown) {
|
|
466
|
-
const files = recordValue(metadata)?.files
|
|
467
|
-
if (!Array.isArray(files)) return []
|
|
468
|
-
return files.flatMap((item) => {
|
|
469
|
-
const file = recordValue(item)
|
|
470
|
-
const filePath = stringValue(file?.filePath)
|
|
471
|
-
const relativePath = stringValue(file?.relativePath) ?? filePath
|
|
472
|
-
const patch = stringValue(file?.patch)
|
|
473
|
-
const type = stringValue(file?.type) ?? "update"
|
|
474
|
-
const deletions = numberValue(file?.deletions) ?? 0
|
|
475
|
-
if (!filePath || !relativePath || patch === undefined) return []
|
|
476
|
-
return [{ filePath, relativePath, patch, type, deletions }]
|
|
477
|
-
})
|
|
478
|
-
}
|
|
479
|
-
|
|
480
404
|
function patchTitle(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
|
481
405
|
if (file.type === "delete") return `# Deleted ${file.relativePath}`
|
|
482
406
|
if (file.type === "add") return `# Created ${file.relativePath}`
|
|
483
407
|
if (file.type === "move") return `# Moved ${shortPath(file.filePath)} -> ${file.relativePath}`
|
|
484
408
|
return `← Patched ${file.relativePath}`
|
|
485
409
|
}
|
|
486
|
-
|
|
487
|
-
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|
488
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
489
|
-
return value as Record<string, unknown>
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function stringValue(value: unknown) {
|
|
493
|
-
return typeof value === "string" ? value : undefined
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function numberValue(value: unknown) {
|
|
497
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function shortPath(value: string) {
|
|
501
|
-
if (!value) return "file"
|
|
502
|
-
const parts = value.split(/[\\/]/)
|
|
503
|
-
return parts.slice(-3).join("/")
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function filetype(input: string) {
|
|
507
|
-
const ext = input.split(".").at(-1)?.toLowerCase()
|
|
508
|
-
if (!ext || ext === input.toLowerCase()) return "none"
|
|
509
|
-
if (["ts", "tsx", "js", "jsx", "mts", "cts"].includes(ext)) return "typescript"
|
|
510
|
-
if (ext === "py") return "python"
|
|
511
|
-
if (ext === "go") return "go"
|
|
512
|
-
if (ext === "rs") return "rust"
|
|
513
|
-
if (ext === "rb") return "ruby"
|
|
514
|
-
if (ext === "java") return "java"
|
|
515
|
-
if (ext === "json") return "json"
|
|
516
|
-
if (ext === "md") return "markdown"
|
|
517
|
-
if (ext === "yml" || ext === "yaml") return "yaml"
|
|
518
|
-
if (ext === "sql") return "sql"
|
|
519
|
-
if (ext === "sh" || ext === "bash" || ext === "zsh") return "shellscript"
|
|
520
|
-
if (ext === "diff" || ext === "patch") return "diff"
|
|
521
|
-
return ext
|
|
522
|
-
}
|
|
@@ -30,6 +30,9 @@ export const ResultRow = (props: {
|
|
|
30
30
|
<span style={{ fg: props.active ? props.theme.accent : props.theme.text, bold: true }}>
|
|
31
31
|
{truncate(props.item.sessionTitle, sessionTitleWidth(props.width))}
|
|
32
32
|
</span>
|
|
33
|
+
<Show when={props.item.isVectorMatch}>
|
|
34
|
+
<span style={{ fg: props.theme.warning }}> ~</span>
|
|
35
|
+
</Show>
|
|
33
36
|
<Show when={props.width >= 48}>
|
|
34
37
|
<span style={{ fg: props.theme.textMuted }}> </span>
|
|
35
38
|
<span style={{ fg: roleColor(props.item.role, props.theme), bold: true }}>{roleLabel(props.item.role)}</span>
|
|
@@ -46,10 +49,12 @@ export const ResultRow = (props: {
|
|
|
46
49
|
before={props.item.before}
|
|
47
50
|
match={props.item.match}
|
|
48
51
|
after={props.item.after}
|
|
52
|
+
excerpt={props.item.excerpt}
|
|
49
53
|
query={props.query}
|
|
50
54
|
active={props.active}
|
|
51
55
|
theme={props.theme}
|
|
52
56
|
maxWidth={props.width}
|
|
57
|
+
isVectorMatch={props.item.isVectorMatch}
|
|
53
58
|
/>
|
|
54
59
|
</box>
|
|
55
60
|
)
|
|
@@ -86,17 +91,28 @@ const HighlightedText = (props: {
|
|
|
86
91
|
before: string
|
|
87
92
|
match: string
|
|
88
93
|
after: string
|
|
94
|
+
excerpt: string
|
|
89
95
|
query: string
|
|
90
96
|
active: boolean
|
|
91
97
|
theme: TuiThemeCurrent
|
|
92
98
|
maxWidth: number
|
|
99
|
+
isVectorMatch: boolean
|
|
93
100
|
}) => {
|
|
94
101
|
const textMax = Math.max(10, props.maxWidth - 2)
|
|
95
102
|
const sideMax = Math.floor(textMax * 0.35)
|
|
96
103
|
const matchMax = textMax - sideMax * 2
|
|
97
104
|
const before = truncate(props.before, sideMax)
|
|
98
|
-
const match = truncate(props.match || props.query, matchMax)
|
|
99
105
|
const after = truncate(props.after, sideMax)
|
|
106
|
+
if (props.isVectorMatch) {
|
|
107
|
+
return (
|
|
108
|
+
<text wrapMode="none" overflow="hidden">
|
|
109
|
+
<span style={{ fg: props.theme.textMuted }}> </span>
|
|
110
|
+
<span style={{ fg: props.theme.warning }}>~ </span>
|
|
111
|
+
<span style={{ fg: props.active ? props.theme.text : props.theme.textMuted }}>{truncate(props.excerpt, textMax - 2)}</span>
|
|
112
|
+
</text>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
const match = truncate(props.match || props.query, matchMax)
|
|
100
116
|
return (
|
|
101
117
|
<text wrapMode="none" overflow="hidden">
|
|
102
118
|
<span style={{ fg: props.theme.textMuted }}> </span>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-telescope",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.25",
|
|
5
5
|
"description": "OpenCode TUI plugin for fuzzy and semantic search across local conversation history, session transcripts, and AI coding chats",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"tui.tsx",
|
|
55
55
|
"telescope.tsx",
|
|
56
56
|
"search.ts",
|
|
57
|
+
"search",
|
|
57
58
|
"components",
|
|
58
59
|
"ui",
|
|
59
60
|
"tsconfig.json"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
export function resolveDatabasePath() {
|
|
6
|
+
if (cachedDbPath) return cachedDbPath
|
|
7
|
+
if (process.env.OPENCODE_DB) {
|
|
8
|
+
if (process.env.OPENCODE_DB === ":memory:" || path.isAbsolute(process.env.OPENCODE_DB)) return cachedDbPath = process.env.OPENCODE_DB
|
|
9
|
+
return cachedDbPath = path.join(candidateDataDirs()[0] ?? defaultDataDir(), process.env.OPENCODE_DB)
|
|
10
|
+
}
|
|
11
|
+
if (process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "true") {
|
|
12
|
+
return cachedDbPath = requireExistingDatabase(["opencode.db"])
|
|
13
|
+
}
|
|
14
|
+
const stable = candidateDatabasePaths(["opencode.db"]).find(existsSync)
|
|
15
|
+
if (stable) return cachedDbPath = stable
|
|
16
|
+
if (process.env.OPENCODE_CHANNEL) {
|
|
17
|
+
const channel = process.env.OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
18
|
+
const candidate = candidateDatabasePaths([`opencode-${channel}.db`]).find(existsSync)
|
|
19
|
+
if (candidate) return cachedDbPath = candidate
|
|
20
|
+
}
|
|
21
|
+
const fallback = candidateDatabasePaths(["opencode.db"])[0] ?? path.join(defaultDataDir(), "opencode.db")
|
|
22
|
+
try {
|
|
23
|
+
const discovered = candidateDataDirs()
|
|
24
|
+
.flatMap((dir) =>
|
|
25
|
+
readdirSync(dir, { withFileTypes: true })
|
|
26
|
+
.filter((entry) => entry.isFile() && /^opencode-.+\.db$/.test(entry.name))
|
|
27
|
+
.map((entry) => path.join(dir, entry.name)),
|
|
28
|
+
)
|
|
29
|
+
.at(0)
|
|
30
|
+
return cachedDbPath = discovered ?? fallback
|
|
31
|
+
} catch {
|
|
32
|
+
return cachedDbPath = fallback
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function searchIndexPath(sourcePath: string) {
|
|
37
|
+
const parsed = path.parse(sourcePath)
|
|
38
|
+
return path.join(parsed.dir, `${parsed.name}-telescope-search.db`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function candidateDataDirs() {
|
|
42
|
+
return [
|
|
43
|
+
defaultDataDir(),
|
|
44
|
+
path.join(homedir(), ".local", "share", "opencode"),
|
|
45
|
+
process.platform === "darwin" ? path.join(homedir(), "Library", "Application Support", "opencode") : undefined,
|
|
46
|
+
process.platform === "win32" ? path.join(process.env.APPDATA ?? path.join(homedir(), "AppData", "Roaming"), "opencode") : undefined,
|
|
47
|
+
].filter((item, index, list): item is string => Boolean(item) && list.indexOf(item) === index)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function defaultDataDir() {
|
|
51
|
+
if (process.env.XDG_DATA_HOME) return path.join(process.env.XDG_DATA_HOME, "opencode")
|
|
52
|
+
return path.join(homedir(), ".local", "share", "opencode")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function candidateDatabasePaths(names: string[]) {
|
|
56
|
+
return candidateDataDirs().flatMap((dir) => names.map((name) => path.join(dir, name)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireExistingDatabase(names: string[]) {
|
|
60
|
+
return candidateDatabasePaths(names).find(existsSync) ?? candidateDatabasePaths(names)[0]!
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let cachedDbPath: string | undefined
|
|
64
|
+
|
|
65
|
+
export function clearDbPathCache() {
|
|
66
|
+
cachedDbPath = undefined
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { checkEmbeddingServer, checkSqliteVec, checkCustomSqlite } from "./dependencies.ts"
|
|
3
|
+
|
|
4
|
+
describe("dependency checks", () => {
|
|
5
|
+
test("checkEmbeddingServer returns unavailable when fetch fails", async () => {
|
|
6
|
+
const originalFetch = globalThis.fetch
|
|
7
|
+
globalThis.fetch = (async () => { throw new Error("fetch failed") }) as unknown as typeof fetch
|
|
8
|
+
try {
|
|
9
|
+
const result = await checkEmbeddingServer("http://localhost:1")
|
|
10
|
+
expect(result.state).toBe("unavailable")
|
|
11
|
+
} finally {
|
|
12
|
+
globalThis.fetch = originalFetch
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("checkSqliteVec returns unavailable without a path", () => {
|
|
17
|
+
const result = checkSqliteVec()
|
|
18
|
+
expect(result.state).toBe("unavailable")
|
|
19
|
+
expect(result.message).toContain("sqlite-vec")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("checkSqliteVec returns unavailable for nonexistent path", () => {
|
|
23
|
+
const result = checkSqliteVec("/nonexistent/vec.so")
|
|
24
|
+
expect(result.state).toBe("unavailable")
|
|
25
|
+
expect(result.message).toContain("/nonexistent/vec.so")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("checkCustomSqlite returns unavailable without a path", () => {
|
|
29
|
+
const result = checkCustomSqlite()
|
|
30
|
+
expect(result.state).toBe("unavailable")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("checkCustomSqlite returns unavailable for nonexistent path", () => {
|
|
34
|
+
const result = checkCustomSqlite("/nonexistent/libsqlite.dylib")
|
|
35
|
+
expect(result.state).toBe("unavailable")
|
|
36
|
+
expect(result.message).toContain("/nonexistent/libsqlite.dylib")
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
|
|
4
|
+
export type DependencyState = "available" | "unavailable" | "disabled"
|
|
5
|
+
|
|
6
|
+
export type DependencyStatus = {
|
|
7
|
+
state: DependencyState
|
|
8
|
+
message: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function checkEmbeddingServer(baseUrl: string): Promise<DependencyStatus> {
|
|
12
|
+
return checkEmbeddingServerInner(baseUrl)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function checkEmbeddingServerInner(baseUrl: string): Promise<DependencyStatus> {
|
|
16
|
+
for (const endpoint of ["/health", "/v1/health"]) {
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(new URL(endpoint, baseUrl), { signal: AbortSignal.timeout(3000) })
|
|
19
|
+
if (response.ok) return { state: "available", message: "embedding server is reachable" }
|
|
20
|
+
} catch {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { state: "unavailable", message: "embedding server did not respond on any health endpoint" }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkSqliteVec(sqliteVecPath?: string): DependencyStatus {
|
|
28
|
+
if (sqliteVecPath || process.env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT) {
|
|
29
|
+
const resolved = sqliteVecPath ?? process.env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT!
|
|
30
|
+
if (!existsSync(resolved)) {
|
|
31
|
+
return { state: "unavailable", message: `vec0 extension not found at: ${resolved}` }
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const db = new Database(":memory:")
|
|
35
|
+
db.loadExtension(resolved)
|
|
36
|
+
db.close()
|
|
37
|
+
return { state: "available", message: `vec0 extension loaded from: ${resolved}` }
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return { state: "unavailable", message: `vec0 extension failed to load: ${err instanceof Error ? err.message : String(err)}` }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { state: "unavailable", message: "sqlite-vec npm package or explicit extension path required" }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function checkCustomSqlite(libPath?: string): DependencyStatus {
|
|
46
|
+
const candidate = libPath
|
|
47
|
+
?? process.env.OPENCODE_TELESCOPE_SQLITE_LIB
|
|
48
|
+
?? (process.platform === "darwin" ? "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib" : undefined)
|
|
49
|
+
if (!candidate) {
|
|
50
|
+
return { state: "unavailable", message: "no custom SQLite library path available" }
|
|
51
|
+
}
|
|
52
|
+
if (!existsSync(candidate)) {
|
|
53
|
+
return { state: "unavailable", message: `custom SQLite library not found at: ${candidate}` }
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
if (Database.setCustomSQLite(candidate)) {
|
|
57
|
+
return { state: "available", message: `custom SQLite set to: ${candidate}` }
|
|
58
|
+
}
|
|
59
|
+
return { state: "unavailable", message: "Database.setCustomSQLite returned false" }
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return { state: "unavailable", message: `Database.setCustomSQLite failed: ${err instanceof Error ? err.message : String(err)}` }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { LlamaEmbeddingClient } from "./embedding.ts"
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
globalThis.fetch = originalFetch
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe("LlamaEmbeddingClient", () => {
|
|
10
|
+
const client = new LlamaEmbeddingClient({
|
|
11
|
+
baseUrl: "http://localhost:9999",
|
|
12
|
+
documentPrefix: "search_document: ",
|
|
13
|
+
queryPrefix: "search_query: ",
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("health returns false for unreachable server", async () => {
|
|
17
|
+
const result = await client.health()
|
|
18
|
+
expect(result).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("embedQuery prefixes with query prefix", async () => {
|
|
22
|
+
const inputs: string[] = []
|
|
23
|
+
globalThis.fetch = (async (url: RequestInfo | URL) => {
|
|
24
|
+
if (typeof url === "string" && url.includes("/v1/embeddings")) {
|
|
25
|
+
inputs.push("called")
|
|
26
|
+
}
|
|
27
|
+
return new Response(null, { status: 500 })
|
|
28
|
+
}) as unknown as typeof fetch
|
|
29
|
+
|
|
30
|
+
await expect(client.embedQuery("test query")).rejects.toThrow()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("embedDocuments prefixes with document prefix", async () => {
|
|
34
|
+
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
35
|
+
const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}")
|
|
36
|
+
expect(body.input).toEqual(["search_document: doc1", "search_document: doc2"])
|
|
37
|
+
return new Response(JSON.stringify({
|
|
38
|
+
data: [
|
|
39
|
+
{ index: 0, embedding: [0.1, 0.2, 0.3] },
|
|
40
|
+
{ index: 1, embedding: [0.4, 0.5, 0.6] },
|
|
41
|
+
],
|
|
42
|
+
}), { status: 200 })
|
|
43
|
+
}) as unknown as typeof fetch
|
|
44
|
+
|
|
45
|
+
const result = await client.embedDocuments(["doc1", "doc2"])
|
|
46
|
+
expect(result).toHaveLength(2)
|
|
47
|
+
expect(result[0]).toBeInstanceOf(Float32Array)
|
|
48
|
+
expect(result[0][0]).toBeCloseTo(0.1, 5)
|
|
49
|
+
expect(result[1][2]).toBeCloseTo(0.6, 5)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("embedQuery returns single vector", async () => {
|
|
53
|
+
globalThis.fetch = (async () => {
|
|
54
|
+
return new Response(JSON.stringify({
|
|
55
|
+
data: [{ index: 0, embedding: [0.5, 0.5] }],
|
|
56
|
+
}), { status: 200 })
|
|
57
|
+
}) as unknown as typeof fetch
|
|
58
|
+
|
|
59
|
+
const result = await client.embedQuery("hello")
|
|
60
|
+
expect(result).toBeInstanceOf(Float32Array)
|
|
61
|
+
expect(result).toHaveLength(2)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("rejects non-array embedding", async () => {
|
|
65
|
+
globalThis.fetch = (async () => {
|
|
66
|
+
return new Response(JSON.stringify({
|
|
67
|
+
data: [{ index: 0, embedding: "not-an-array" }],
|
|
68
|
+
}), { status: 200 })
|
|
69
|
+
}) as unknown as typeof fetch
|
|
70
|
+
|
|
71
|
+
await expect(client.embedQuery("test")).rejects.toThrow()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("rejects embedding with non-finite values", async () => {
|
|
75
|
+
globalThis.fetch = (async () => {
|
|
76
|
+
return new Response(JSON.stringify({
|
|
77
|
+
data: [{ index: 0, embedding: [1, "not-a-number", 3] }],
|
|
78
|
+
}), { status: 200 })
|
|
79
|
+
}) as unknown as typeof fetch
|
|
80
|
+
|
|
81
|
+
await expect(client.embedQuery("test")).rejects.toThrow()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type EmbeddingConfig = {
|
|
2
|
+
baseUrl: string
|
|
3
|
+
model?: string
|
|
4
|
+
documentPrefix: string
|
|
5
|
+
queryPrefix: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type EmbeddingResponse = {
|
|
9
|
+
data?: Array<{
|
|
10
|
+
index?: number
|
|
11
|
+
embedding?: unknown
|
|
12
|
+
}>
|
|
13
|
+
model?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class LlamaEmbeddingClient {
|
|
17
|
+
constructor(private readonly config: EmbeddingConfig) {}
|
|
18
|
+
|
|
19
|
+
async health() {
|
|
20
|
+
for (const endpoint of ["/health", "/v1/health"]) {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(new URL(endpoint, this.config.baseUrl))
|
|
23
|
+
if (response.ok) return true
|
|
24
|
+
} catch {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async embedQuery(query: string) {
|
|
32
|
+
return this.embed([this.config.queryPrefix + query]).then((items) => items[0])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async embedDocuments(documents: string[]) {
|
|
36
|
+
return this.embed(documents.map((document) => this.config.documentPrefix + document))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async embed(inputs: string[]) {
|
|
40
|
+
const response = await fetch(new URL("/v1/embeddings", this.config.baseUrl), {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "content-type": "application/json" },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
model: this.config.model ?? "local-embedding",
|
|
45
|
+
input: inputs,
|
|
46
|
+
encoding_format: "float",
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
if (!response.ok) throw new Error(`Embedding request failed: ${response.status}`)
|
|
50
|
+
|
|
51
|
+
const payload = (await response.json()) as EmbeddingResponse
|
|
52
|
+
if (!payload.data || payload.data.length !== inputs.length) {
|
|
53
|
+
throw new Error("Embedding response did not contain one vector per input")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [...payload.data]
|
|
57
|
+
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
58
|
+
.map((item) => {
|
|
59
|
+
if (!Array.isArray(item.embedding)) throw new Error("Embedding response contained a non-array embedding")
|
|
60
|
+
const values = item.embedding.map((value: unknown) => Number(value))
|
|
61
|
+
if (values.some((value) => !Number.isFinite(value))) {
|
|
62
|
+
throw new Error("Embedding response contained non-finite values")
|
|
63
|
+
}
|
|
64
|
+
return new Float32Array(values)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|