@bojackduy/opencode-telescope 0.1.24 → 0.1.26

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.
@@ -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">match: {props.query.trim()}</text>
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={<text fg={props.theme.text}>{props.part.text}</text>}>
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.24",
4
+ "version": "0.1.26",
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,68 @@
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), { signal: AbortSignal.timeout(1000) })
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
+ signal: AbortSignal.timeout(15_000),
43
+ headers: { "content-type": "application/json" },
44
+ body: JSON.stringify({
45
+ model: this.config.model ?? "local-embedding",
46
+ input: inputs,
47
+ encoding_format: "float",
48
+ }),
49
+ })
50
+ if (!response.ok) throw new Error(`Embedding request failed: ${response.status}`)
51
+
52
+ const payload = (await response.json()) as EmbeddingResponse
53
+ if (!payload.data || payload.data.length !== inputs.length) {
54
+ throw new Error("Embedding response did not contain one vector per input")
55
+ }
56
+
57
+ return [...payload.data]
58
+ .sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
59
+ .map((item) => {
60
+ if (!Array.isArray(item.embedding)) throw new Error("Embedding response contained a non-array embedding")
61
+ const values = item.embedding.map((value: unknown) => Number(value))
62
+ if (values.some((value) => !Number.isFinite(value))) {
63
+ throw new Error("Embedding response contained non-finite values")
64
+ }
65
+ return new Float32Array(values)
66
+ })
67
+ }
68
+ }