@bojackduy/opencode-telescope 0.1.29 → 0.1.33
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/result-list.tsx +10 -3
- package/index-worker.ts +19 -0
- package/package.json +2 -1
- package/search/queries.ts +167 -226
- package/search/schema.ts +2 -0
- package/search/types.ts +10 -1
- package/search/vector.ts +11 -0
- package/search-worker.ts +5 -5
- package/search.ts +10 -0
- package/telescope.tsx +91 -9
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import { Show } from "solid-js"
|
|
4
|
-
import type { SearchResult } from "../search.ts"
|
|
4
|
+
import type { KeywordIndexState, SearchResult } from "../search.ts"
|
|
5
5
|
import { compactTime, roleColor, roleLabel, truncate } from "../ui/format.ts"
|
|
6
6
|
|
|
7
7
|
export const ResultRow = (props: {
|
|
@@ -59,12 +59,19 @@ export const ResultRow = (props: {
|
|
|
59
59
|
</box>
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
-
export const EmptyState = (props: { query: string; owner: string; theme: TuiThemeCurrent }) => (
|
|
62
|
+
export const EmptyState = (props: { query: string; owner: string; theme: TuiThemeCurrent; keywordIndexState?: KeywordIndexState }) => (
|
|
63
63
|
<box paddingLeft={1} paddingTop={1}>
|
|
64
|
-
<text fg={props.theme.textMuted}>{props.query
|
|
64
|
+
<text fg={props.theme.textMuted}>{emptyMessage(props.query, props.owner, props.keywordIndexState)}</text>
|
|
65
65
|
</box>
|
|
66
66
|
)
|
|
67
67
|
|
|
68
|
+
function emptyMessage(query: string, owner: string, keywordIndexState?: KeywordIndexState) {
|
|
69
|
+
if (keywordIndexState === "indexing" || keywordIndexState === "missing" || keywordIndexState === "empty") return "Indexing conversations in background..."
|
|
70
|
+
if (keywordIndexState === "stale") return "Updating conversation index..."
|
|
71
|
+
if (keywordIndexState === "error") return "Conversation index is unavailable. Try reopening Telescope."
|
|
72
|
+
return query.trim() ? `No matching ${owner} conversation text.` : `No recent ${owner} conversation text found.`
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
export const SkeletonRow = (props: { theme: TuiThemeCurrent }) => (
|
|
69
76
|
<box
|
|
70
77
|
flexDirection="column"
|
package/index-worker.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { rebuildKeywordIndexForDbPath } from "./search"
|
|
2
|
+
|
|
3
|
+
self.onmessage = (event: MessageEvent) => {
|
|
4
|
+
const msg = event.data
|
|
5
|
+
if (msg.type !== "rebuild-index") return
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
self.postMessage({ type: "index-started", id: msg.id, dbPath: msg.dbPath })
|
|
9
|
+
rebuildKeywordIndexForDbPath(msg.dbPath)
|
|
10
|
+
self.postMessage({ type: "index-done", id: msg.id, dbPath: msg.dbPath })
|
|
11
|
+
} catch (err) {
|
|
12
|
+
self.postMessage({
|
|
13
|
+
type: "index-error",
|
|
14
|
+
id: msg.id,
|
|
15
|
+
dbPath: msg.dbPath,
|
|
16
|
+
error: err instanceof Error ? err.message : String(err),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
}
|
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.33",
|
|
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",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"telescope.tsx",
|
|
56
56
|
"search.ts",
|
|
57
57
|
"search-worker.ts",
|
|
58
|
+
"index-worker.ts",
|
|
58
59
|
"search",
|
|
59
60
|
"components",
|
|
60
61
|
"ui",
|
package/search/queries.ts
CHANGED
|
@@ -13,19 +13,20 @@ import type {
|
|
|
13
13
|
IndexSourceRow,
|
|
14
14
|
Row,
|
|
15
15
|
SemanticConfig,
|
|
16
|
+
KeywordIndexState,
|
|
17
|
+
SearchResponse,
|
|
18
|
+
VectorState,
|
|
16
19
|
} from "./types.ts"
|
|
17
20
|
import { rowToSearchResult, rowToVectorResult, indexSourceRowToRows, ftsQuery, expandQuery } from "./text.ts"
|
|
18
21
|
import { resolveDatabasePath, searchIndexPath } from "./db-path.ts"
|
|
19
22
|
import { LlamaEmbeddingClient } from "./embedding.ts"
|
|
20
23
|
import { migrateSearchIndex, getMeta, setMeta, SEARCH_INDEX_VERSION, DOCUMENT_EXTRACTOR_VERSION } from "./schema.ts"
|
|
21
|
-
import { hybridBlend, searchVector,
|
|
24
|
+
import { hybridBlend, searchVector, configureCustomSQLite, loadVecExtension, isVectorReady } from "./vector.ts"
|
|
22
25
|
|
|
23
26
|
// Load custom SQLite before any Database() constructor runs,
|
|
24
27
|
// otherwise Database.setCustomSQLite() fails with "SQLite already loaded"
|
|
25
28
|
configureCustomSQLite()
|
|
26
29
|
|
|
27
|
-
const backgroundIndexRebuilds = new Set<string>()
|
|
28
|
-
const lastVectorRebuildAttempt = new Map<string, number>()
|
|
29
30
|
const vecExtensionLoading = new Map<string, Promise<void>>()
|
|
30
31
|
|
|
31
32
|
let _db: Database | undefined
|
|
@@ -39,29 +40,42 @@ function getDb(dbPath?: string): Database {
|
|
|
39
40
|
_db?.close()
|
|
40
41
|
_db = new Database(resolved, { readonly: true })
|
|
41
42
|
_dbPath = resolved
|
|
42
|
-
tableCache.clear()
|
|
43
43
|
}
|
|
44
44
|
return _db
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
|
|
48
|
+
return searchSessionMessagesWithStatus(query, options).results
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function searchSessionMessagesWithStatus(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): SearchResponse {
|
|
48
52
|
const term = query.trim()
|
|
49
|
-
if (!term) return []
|
|
50
|
-
if (options?.dbPath === ":memory:") return []
|
|
53
|
+
if (!term) return searchResponse([], "ready")
|
|
54
|
+
if (options?.dbPath === ":memory:") return searchResponse([], "missing")
|
|
51
55
|
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
52
56
|
const db = getDb(dbPath)
|
|
53
|
-
|
|
57
|
+
const index = openSearchIndex(dbPath)
|
|
58
|
+
const status = readKeywordIndexState(db, index, dbPath)
|
|
59
|
+
const rows = canQueryKeywordIndex(status.state) ? queryFtsRows(index, term, options?.limit ?? 80, options?.directory, options?.offset, options?.role) : []
|
|
60
|
+
return searchResponse(rows.flatMap((row) => rowToSearchResult(row, term) ?? []), status.state, getVectorState(index))
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
|
|
64
|
+
return recentSessionMessagesWithStatus(options).results
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function recentSessionMessagesWithStatus(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): SearchResponse {
|
|
57
68
|
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
69
|
+
if (dbPath === ":memory:") return searchResponse([], "missing")
|
|
58
70
|
const db = getDb(dbPath)
|
|
59
71
|
const limit = options?.limit ?? 40
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
const index = openSearchIndex(dbPath)
|
|
73
|
+
const status = readKeywordIndexState(db, index, dbPath)
|
|
74
|
+
const rows = canQueryKeywordIndex(status.state) ? queryRecentRows(index, limit, options?.directory, options?.offset, options?.role) : []
|
|
75
|
+
if (!rows.length && (status.state === "missing" || status.state === "empty" || status.state === "indexing")) {
|
|
76
|
+
debug.log("query:recent:index-pending", { state: status.state, limit, offset: options?.offset ?? 0, directory: options?.directory, role: options?.role })
|
|
77
|
+
}
|
|
78
|
+
return searchResponse(rows.flatMap((row) => rowToSearchResult(row, "") ?? []), status.state, getVectorState(index))
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
|
|
@@ -234,77 +248,55 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
|
|
|
234
248
|
}
|
|
235
249
|
|
|
236
250
|
export async function performSearch(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): Promise<SearchResult[]> {
|
|
237
|
-
|
|
251
|
+
return (await performSearchWithStatus(query, options)).results
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function performSearchWithStatus(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): Promise<SearchResponse> {
|
|
255
|
+
if (!query.trim()) return searchSessionMessagesWithStatus(query, options)
|
|
238
256
|
const config = parseSemanticConfig()
|
|
239
257
|
if (!config.disableVector) {
|
|
240
258
|
try {
|
|
241
|
-
return await
|
|
259
|
+
return await semanticSearchSessionMessagesWithStatus(query, options)
|
|
242
260
|
} catch {
|
|
243
261
|
debug.log("query:hybrid:fallback", { message: "hybrid search failed, falling back to keyword" })
|
|
244
262
|
}
|
|
245
263
|
}
|
|
246
|
-
return
|
|
264
|
+
return searchSessionMessagesWithStatus(query, options)
|
|
247
265
|
}
|
|
248
266
|
|
|
249
267
|
export async function semanticSearchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): Promise<SearchResult[]> {
|
|
268
|
+
return (await semanticSearchSessionMessagesWithStatus(query, options)).results
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function semanticSearchSessionMessagesWithStatus(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): Promise<SearchResponse> {
|
|
250
272
|
const term = query.trim()
|
|
251
|
-
if (!term) return []
|
|
252
|
-
if (options?.dbPath === ":memory:") return []
|
|
273
|
+
if (!term) return searchResponse([], "ready")
|
|
274
|
+
if (options?.dbPath === ":memory:") return searchResponse([], "missing")
|
|
253
275
|
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
254
276
|
const db = getDb(dbPath)
|
|
255
277
|
const limit = options?.limit ?? 80
|
|
256
278
|
const dir = options?.directory
|
|
257
279
|
const role = options?.role
|
|
258
280
|
|
|
259
|
-
const index =
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const keyword = indexedTextRows(db, dbPath, limit, term, dir, options?.offset, role) ?? []
|
|
281
|
+
const index = openSearchIndex(dbPath)
|
|
282
|
+
const status = readKeywordIndexState(db, index, dbPath)
|
|
283
|
+
const keyword = canQueryKeywordIndex(status.state) ? queryFtsRows(index, term, limit, dir, options?.offset, role) : []
|
|
263
284
|
const config = parseSemanticConfig()
|
|
285
|
+
let vectorState = getVectorState(index)
|
|
264
286
|
|
|
265
287
|
let vector: Row[] = []
|
|
266
|
-
if (!config.disableVector) {
|
|
267
|
-
let vecState = getMeta(index, "vector_state")
|
|
268
|
-
if (vecState !== "enabled" && vecState !== "disabled" && vecState !== "stale") {
|
|
269
|
-
const indexPath = searchIndexPath(dbPath)
|
|
270
|
-
const lastAttempt = lastVectorRebuildAttempt.get(indexPath)
|
|
271
|
-
if (!lastAttempt || Date.now() - lastAttempt > 30_000) {
|
|
272
|
-
setupVectorTable(index, config, indexPath)
|
|
273
|
-
lastVectorRebuildAttempt.set(indexPath, Date.now())
|
|
274
|
-
vecState = getMeta(index, "vector_state") ?? "stale"
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (vecState === "stale" && getMeta(index, "embedding_dimensions")) {
|
|
288
|
+
if (!config.disableVector && isVectorReady(index)) {
|
|
278
289
|
try {
|
|
279
290
|
const indexPath = searchIndexPath(dbPath)
|
|
280
|
-
await vecExtensionLoading.get(indexPath)
|
|
281
|
-
const test = index.query("SELECT COUNT(*) as count FROM document_vec").get() as { count: number } | undefined
|
|
282
|
-
if (test && test.count > 0) {
|
|
283
|
-
setMeta(index, "vector_state", "enabled")
|
|
284
|
-
vecState = "enabled"
|
|
285
|
-
}
|
|
286
|
-
} catch {
|
|
287
|
-
debug.log("vector:stale:recovery-failed")
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (vecState === "enabled") {
|
|
291
|
-
try {
|
|
292
|
-
const indexPath = searchIndexPath(dbPath)
|
|
293
|
-
await vecExtensionLoading.get(indexPath)
|
|
294
|
-
const client = new LlamaEmbeddingClient({
|
|
295
|
-
baseUrl: config.embedBaseUrl,
|
|
296
|
-
model: config.embedModel,
|
|
297
|
-
documentPrefix: config.documentPrefix,
|
|
298
|
-
queryPrefix: config.queryPrefix,
|
|
299
|
-
})
|
|
291
|
+
await withDeadline(vecExtensionLoading.get(indexPath) ?? Promise.resolve(), 250)
|
|
300
292
|
const embedTerm = expandQuery(term)
|
|
301
|
-
const embedding = await
|
|
293
|
+
const embedding = await withDeadline(embedQuery(config, embedTerm), 1200)
|
|
302
294
|
vector = searchVector(index, embedding, limit)
|
|
303
295
|
debug.log("query:vector:results", { count: vector.length, embedTerm: embedTerm !== term ? embedTerm : undefined })
|
|
304
296
|
} catch (err) {
|
|
305
297
|
debug.log("query:vector:error", err instanceof Error ? err.message : String(err))
|
|
298
|
+
vectorState = getVectorState(index)
|
|
306
299
|
}
|
|
307
|
-
}
|
|
308
300
|
}
|
|
309
301
|
|
|
310
302
|
const merged = keyword.length || vector.length
|
|
@@ -328,201 +320,155 @@ export async function semanticSearchSessionMessages(query: string, options?: { l
|
|
|
328
320
|
}
|
|
329
321
|
}
|
|
330
322
|
|
|
331
|
-
return results.slice(0, limit)
|
|
323
|
+
return searchResponse(results.slice(0, limit), status.state, vectorState)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function searchResponse(results: SearchResult[], keywordState: KeywordIndexState, vectorState?: VectorState): SearchResponse {
|
|
327
|
+
return {
|
|
328
|
+
results,
|
|
329
|
+
keywordState,
|
|
330
|
+
vectorState,
|
|
331
|
+
stale: keywordState === "stale",
|
|
332
|
+
}
|
|
332
333
|
}
|
|
333
334
|
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
debug.time("query:sql")
|
|
337
|
-
const rows = indexedTextRows(db, dbPath, limit, query, directory, offset, role) ?? visibleTextRows(db, limit, query, directory, offset, role)
|
|
338
|
-
debug.timeEnd("query:sql")
|
|
339
|
-
debug.time("query:map")
|
|
340
|
-
const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
|
|
341
|
-
debug.timeEnd("query:map")
|
|
342
|
-
return results
|
|
335
|
+
function canQueryKeywordIndex(state: KeywordIndexState) {
|
|
336
|
+
return state === "ready" || state === "stale"
|
|
343
337
|
}
|
|
344
338
|
|
|
345
|
-
function
|
|
346
|
-
const
|
|
347
|
-
if (
|
|
339
|
+
function getVectorState(index: Database): VectorState {
|
|
340
|
+
const state = getMeta(index, "vector_state")
|
|
341
|
+
if (state === "enabled" || state === "disabled" || state === "unavailable" || state === "stale" || state === "indexing") return state
|
|
342
|
+
return "unavailable"
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function embedQuery(config: SemanticConfig, query: string) {
|
|
346
|
+
const client = new LlamaEmbeddingClient({
|
|
347
|
+
baseUrl: config.embedBaseUrl,
|
|
348
|
+
model: config.embedModel,
|
|
349
|
+
documentPrefix: config.documentPrefix,
|
|
350
|
+
queryPrefix: config.queryPrefix,
|
|
351
|
+
})
|
|
352
|
+
return client.embedQuery(query)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
356
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
348
357
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
conditions.push("directory = ?")
|
|
359
|
-
params.push(directory)
|
|
360
|
-
}
|
|
361
|
-
params.push(limit)
|
|
362
|
-
if (offset) params.push(offset)
|
|
363
|
-
const offsetClause = offset ? "OFFSET ?" : ""
|
|
364
|
-
debug.time("query:fts:exec")
|
|
365
|
-
const rows = index.query<Row, (string | number)[]>(`
|
|
366
|
-
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
367
|
-
CAST(time_created AS INTEGER) AS time_created, text
|
|
368
|
-
FROM document_fts
|
|
369
|
-
WHERE ${conditions.join(" AND ")}
|
|
370
|
-
ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
|
|
371
|
-
LIMIT ? ${offsetClause}
|
|
372
|
-
`).all(...params as any[])
|
|
373
|
-
debug.timeEnd("query:fts:exec")
|
|
374
|
-
return rows
|
|
375
|
-
} catch (err) {
|
|
376
|
-
debug.log("fts:fallback", err instanceof Error ? err.message : String(err))
|
|
377
|
-
return
|
|
358
|
+
return await Promise.race([
|
|
359
|
+
promise,
|
|
360
|
+
new Promise<T>((_, reject) => {
|
|
361
|
+
timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
|
|
362
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
363
|
+
}),
|
|
364
|
+
])
|
|
365
|
+
} finally {
|
|
366
|
+
if (timer) clearTimeout(timer)
|
|
378
367
|
}
|
|
379
368
|
}
|
|
380
369
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
debug.log("query:recent:index:missing", { dbPath })
|
|
386
|
-
return
|
|
387
|
-
}
|
|
370
|
+
type KeywordIndexStatus = {
|
|
371
|
+
state: KeywordIndexState
|
|
372
|
+
rowCount: number
|
|
373
|
+
}
|
|
388
374
|
|
|
375
|
+
export function openSearchIndex(sourcePath: string) {
|
|
376
|
+
const indexPath = searchIndexPath(sourcePath)
|
|
377
|
+
if (!_indexDb || _indexDbPath !== indexPath) {
|
|
378
|
+
_indexDb?.close()
|
|
379
|
+
configureCustomSQLite()
|
|
380
|
+
_indexDb = new Database(indexPath)
|
|
381
|
+
_indexDbPath = indexPath
|
|
382
|
+
migrateSearchIndex(_indexDb)
|
|
383
|
+
vecExtensionLoading.set(indexPath, loadVecExtension(_indexDb).then(() => {}).catch(() => {}))
|
|
384
|
+
}
|
|
385
|
+
return _indexDb
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function readKeywordIndexState(source: Database, index: Database, sourcePath: string): KeywordIndexStatus {
|
|
389
|
+
try {
|
|
389
390
|
const rowCount = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_index").get()?.count ?? 0
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
391
|
+
const storedState = getMeta(index, "keyword_index_state")
|
|
392
|
+
if (storedState === "indexing" && rowCount === 0) return { state: "indexing", rowCount }
|
|
393
|
+
if (storedState === "error" && rowCount === 0) return { state: "error", rowCount }
|
|
394
|
+
if (rowCount === 0) return { state: "empty", rowCount }
|
|
394
395
|
|
|
395
|
-
const state = sourceState(
|
|
396
|
+
const state = sourceState(source, sourcePath)
|
|
396
397
|
const currentDataVersion = getMeta(index, "source_data_version")
|
|
397
398
|
const currentMtimeMs = getMeta(index, "source_mtime_ms")
|
|
398
399
|
const currentPath = getMeta(index, "source_path")
|
|
399
400
|
const currentIndexVersion = getMeta(index, "index_version")
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
dbPath,
|
|
403
|
-
expectedDataVersion: state.dataVersion,
|
|
404
|
-
actualDataVersion: currentDataVersion,
|
|
405
|
-
})
|
|
406
|
-
scheduleBackgroundIndexRebuild(dbPath)
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const conditions: string[] = ["part_type = 'text'"]
|
|
410
|
-
const params: (string | number)[] = []
|
|
411
|
-
if (role) {
|
|
412
|
-
conditions.push("role = ?")
|
|
413
|
-
params.push(role)
|
|
414
|
-
}
|
|
415
|
-
if (directory) {
|
|
416
|
-
conditions.push("directory = ?")
|
|
417
|
-
params.push(directory)
|
|
418
|
-
}
|
|
419
|
-
params.push(limit)
|
|
420
|
-
if (offset) params.push(offset)
|
|
421
|
-
|
|
422
|
-
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
423
|
-
const offsetClause = offset ? "OFFSET ?" : ""
|
|
424
|
-
debug.time("query:recent:index:exec")
|
|
425
|
-
const rows = index.query<Row, (string | number)[]>(`
|
|
426
|
-
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
427
|
-
CAST(time_created AS INTEGER) AS time_created, text
|
|
428
|
-
FROM document_index
|
|
429
|
-
${where}
|
|
430
|
-
ORDER BY CAST(time_created AS INTEGER) DESC
|
|
431
|
-
LIMIT ? ${offsetClause}
|
|
432
|
-
`).all(...params as any[])
|
|
433
|
-
debug.timeEnd("query:recent:index:exec")
|
|
434
|
-
return rows
|
|
401
|
+
const stale = currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION
|
|
402
|
+
return { state: stale ? "stale" : "ready", rowCount }
|
|
435
403
|
} catch (err) {
|
|
436
|
-
debug.log("
|
|
437
|
-
return
|
|
404
|
+
debug.log("keyword:index:state-error", err instanceof Error ? err.message : String(err))
|
|
405
|
+
return { state: "error", rowCount: 0 }
|
|
438
406
|
}
|
|
439
407
|
}
|
|
440
408
|
|
|
441
|
-
function
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
409
|
+
function queryFtsRows(index: Database, query: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
|
|
410
|
+
const match = ftsQuery(query)
|
|
411
|
+
if (!match) return []
|
|
412
|
+
const conditions = ["document_fts MATCH ?"]
|
|
413
|
+
const params: (string | number)[] = [match]
|
|
414
|
+
if (role) {
|
|
415
|
+
conditions.push("role = ?")
|
|
416
|
+
params.push(role)
|
|
417
|
+
}
|
|
451
418
|
if (directory) {
|
|
452
|
-
conditions.push("
|
|
419
|
+
conditions.push("directory = ?")
|
|
453
420
|
params.push(directory)
|
|
454
421
|
}
|
|
455
|
-
|
|
456
|
-
const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
|
|
457
|
-
for (const token of tokens) {
|
|
458
|
-
conditions.push("json_extract(p.data, '$.text') LIKE ?")
|
|
459
|
-
params.push(`%${token}%`)
|
|
460
|
-
}
|
|
461
|
-
|
|
462
422
|
params.push(limit)
|
|
463
423
|
if (offset) params.push(offset)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
FROM part p
|
|
471
|
-
JOIN message m ON m.id = p.message_id
|
|
472
|
-
JOIN session s ON s.id = p.session_id
|
|
424
|
+
const offsetClause = offset ? "OFFSET ?" : ""
|
|
425
|
+
debug.time("query:fts:exec")
|
|
426
|
+
const rows = index.query<Row, (string | number)[]>(`
|
|
427
|
+
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
428
|
+
CAST(time_created AS INTEGER) AS time_created, text
|
|
429
|
+
FROM document_fts
|
|
473
430
|
WHERE ${conditions.join(" AND ")}
|
|
474
|
-
ORDER BY
|
|
431
|
+
ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
|
|
475
432
|
LIMIT ? ${offsetClause}
|
|
476
|
-
`
|
|
477
|
-
debug.
|
|
478
|
-
const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
|
|
479
|
-
debug.timeEnd("query:sql:exec")
|
|
433
|
+
`).all(...params as any[])
|
|
434
|
+
debug.timeEnd("query:fts:exec")
|
|
480
435
|
return rows
|
|
481
436
|
}
|
|
482
437
|
|
|
483
|
-
function
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
_indexDbPath = indexPath
|
|
490
|
-
migrateSearchIndex(_indexDb)
|
|
491
|
-
vecExtensionLoading.set(indexPath, loadVecExtension(_indexDb).then(() => {}).catch(() => {}))
|
|
438
|
+
function queryRecentRows(index: Database, limit: number, directory?: string, offset?: number, role?: SearchRole) {
|
|
439
|
+
const conditions: string[] = ["part_type = 'text'"]
|
|
440
|
+
const params: (string | number)[] = []
|
|
441
|
+
if (role) {
|
|
442
|
+
conditions.push("role = ?")
|
|
443
|
+
params.push(role)
|
|
492
444
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
|
|
497
|
-
const currentPath = getMeta(_indexDb, "source_path")
|
|
498
|
-
const currentIndexVersion = getMeta(_indexDb, "index_version")
|
|
499
|
-
if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
|
|
500
|
-
if (options?.rebuild === false) {
|
|
501
|
-
if (options?.useStale) return _indexDb
|
|
502
|
-
return
|
|
503
|
-
}
|
|
504
|
-
rebuildSearchIndex(source, _indexDb, sourcePath, state)
|
|
445
|
+
if (directory) {
|
|
446
|
+
conditions.push("directory = ?")
|
|
447
|
+
params.push(directory)
|
|
505
448
|
}
|
|
506
|
-
|
|
449
|
+
params.push(limit)
|
|
450
|
+
if (offset) params.push(offset)
|
|
451
|
+
|
|
452
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
453
|
+
const offsetClause = offset ? "OFFSET ?" : ""
|
|
454
|
+
debug.time("query:recent:index:exec")
|
|
455
|
+
const rows = index.query<Row, (string | number)[]>(`
|
|
456
|
+
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
457
|
+
CAST(time_created AS INTEGER) AS time_created, text
|
|
458
|
+
FROM document_index
|
|
459
|
+
${where}
|
|
460
|
+
ORDER BY CAST(time_created AS INTEGER) DESC
|
|
461
|
+
LIMIT ? ${offsetClause}
|
|
462
|
+
`).all(...params as any[])
|
|
463
|
+
debug.timeEnd("query:recent:index:exec")
|
|
464
|
+
return rows
|
|
507
465
|
}
|
|
508
466
|
|
|
509
|
-
function
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
debug.time("fts:rebuild:background")
|
|
515
|
-
try {
|
|
516
|
-
const db = getDb(dbPath)
|
|
517
|
-
ensureSearchIndex(db, dbPath)
|
|
518
|
-
} catch (err) {
|
|
519
|
-
debug.log("fts:rebuild:background:error", err instanceof Error ? err.message : String(err))
|
|
520
|
-
} finally {
|
|
521
|
-
backgroundIndexRebuilds.delete(dbPath)
|
|
522
|
-
debug.timeEnd("fts:rebuild:background")
|
|
523
|
-
}
|
|
524
|
-
}, 250)
|
|
525
|
-
;(timer as { unref?: () => void }).unref?.()
|
|
467
|
+
export function rebuildKeywordIndexForDbPath(dbPath: string) {
|
|
468
|
+
const source = getDb(dbPath)
|
|
469
|
+
const index = openSearchIndex(dbPath)
|
|
470
|
+
const state = sourceState(source, dbPath)
|
|
471
|
+
rebuildKeywordIndex(source, index, dbPath, state)
|
|
526
472
|
}
|
|
527
473
|
|
|
528
474
|
function sourceState(db: Database, sourcePath: string) {
|
|
@@ -535,7 +481,7 @@ function hashPartData(value: { session_id: string; message_id: string; part_id:
|
|
|
535
481
|
return createHash("sha256").update(`${value.session_id}:${value.message_id}:${value.part_id}`).digest("hex")
|
|
536
482
|
}
|
|
537
483
|
|
|
538
|
-
function
|
|
484
|
+
export function rebuildKeywordIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
|
|
539
485
|
debug.time("fts:rebuild")
|
|
540
486
|
const rows = source.query<IndexSourceRow, []>(`
|
|
541
487
|
SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
|
|
@@ -573,6 +519,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
573
519
|
`)
|
|
574
520
|
index.exec("BEGIN IMMEDIATE")
|
|
575
521
|
try {
|
|
522
|
+
setMeta(index, "keyword_index_state", "indexing")
|
|
576
523
|
index.exec("DELETE FROM document")
|
|
577
524
|
index.exec("DELETE FROM document_fts")
|
|
578
525
|
index.exec("DELETE FROM document_index")
|
|
@@ -628,6 +575,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
628
575
|
setMeta(index, "schema_version", "2")
|
|
629
576
|
setMeta(index, "extractor_version", DOCUMENT_EXTRACTOR_VERSION)
|
|
630
577
|
setMeta(index, "ranking_version", "1")
|
|
578
|
+
setMeta(index, "keyword_index_state", "ready")
|
|
631
579
|
setMeta(index, "embedding_base_url", config.embedBaseUrl)
|
|
632
580
|
setMeta(index, "document_prefix", config.documentPrefix)
|
|
633
581
|
setMeta(index, "query_prefix", config.queryPrefix)
|
|
@@ -635,6 +583,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
635
583
|
index.exec("COMMIT")
|
|
636
584
|
} catch (err) {
|
|
637
585
|
index.exec("ROLLBACK")
|
|
586
|
+
setMeta(index, "keyword_index_state", "error")
|
|
638
587
|
throw err
|
|
639
588
|
} finally {
|
|
640
589
|
debug.timeEnd("fts:rebuild")
|
|
@@ -763,14 +712,6 @@ function stringValue(value: unknown) {
|
|
|
763
712
|
return typeof value === "string" ? value : undefined
|
|
764
713
|
}
|
|
765
714
|
|
|
766
|
-
const tableCache = new Map<string, boolean>()
|
|
767
|
-
function tableExists(db: Database, name: string) {
|
|
768
|
-
if (tableCache.has(name)) return tableCache.get(name)!
|
|
769
|
-
const exists = Boolean(db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
|
|
770
|
-
tableCache.set(name, exists)
|
|
771
|
-
return exists
|
|
772
|
-
}
|
|
773
|
-
|
|
774
715
|
function countBy<T>(items: T[], key: (item: T) => string) {
|
|
775
716
|
const counts: Record<string, number> = {}
|
|
776
717
|
for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1
|
package/search/schema.ts
CHANGED
package/search/types.ts
CHANGED
|
@@ -70,7 +70,16 @@ export type SemanticConfig = {
|
|
|
70
70
|
queryPrefix: string
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export type
|
|
73
|
+
export type KeywordIndexState = "ready" | "stale" | "missing" | "empty" | "indexing" | "error"
|
|
74
|
+
|
|
75
|
+
export type VectorState = "enabled" | "disabled" | "unavailable" | "stale" | "indexing"
|
|
76
|
+
|
|
77
|
+
export type SearchResponse = {
|
|
78
|
+
results: SearchResult[]
|
|
79
|
+
keywordState: KeywordIndexState
|
|
80
|
+
vectorState?: VectorState
|
|
81
|
+
stale: boolean
|
|
82
|
+
}
|
|
74
83
|
|
|
75
84
|
export type ScoredRow = Row & {
|
|
76
85
|
score: number
|
package/search/vector.ts
CHANGED
|
@@ -68,6 +68,17 @@ export function searchVector(index: Database, embedding: Float32Array, limit: nu
|
|
|
68
68
|
`).all(embedding, k)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export function isVectorReady(index: Database) {
|
|
72
|
+
if (getMeta(index, "vector_state") !== "enabled") return false
|
|
73
|
+
if (!getMeta(index, "embedding_dimensions")) return false
|
|
74
|
+
try {
|
|
75
|
+
index.query("SELECT 1 FROM document_vec LIMIT 1").get()
|
|
76
|
+
return true
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
const vectorRebuilds = new Map<string, Promise<void>>()
|
|
72
83
|
|
|
73
84
|
export function setupVectorTable(index: Database, config: SemanticConfig, indexPath: string): void {
|
package/search-worker.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { performSearchWithStatus, recentSessionMessagesWithStatus } from "./search"
|
|
2
2
|
|
|
3
3
|
let activeId = -1
|
|
4
4
|
|
|
@@ -7,15 +7,15 @@ self.onmessage = async (event: MessageEvent) => {
|
|
|
7
7
|
if (msg.type === "search" || msg.type === "recent") {
|
|
8
8
|
activeId = msg.id
|
|
9
9
|
try {
|
|
10
|
-
const
|
|
11
|
-
? await
|
|
10
|
+
const response = msg.type === "search"
|
|
11
|
+
? await performSearchWithStatus(msg.query, {
|
|
12
12
|
limit: msg.limit,
|
|
13
13
|
offset: msg.offset ?? 0,
|
|
14
14
|
dbPath: msg.dbPath,
|
|
15
15
|
directory: msg.directory,
|
|
16
16
|
role: msg.role,
|
|
17
17
|
})
|
|
18
|
-
:
|
|
18
|
+
: recentSessionMessagesWithStatus({
|
|
19
19
|
limit: msg.limit,
|
|
20
20
|
offset: msg.offset ?? 0,
|
|
21
21
|
dbPath: msg.dbPath,
|
|
@@ -23,7 +23,7 @@ self.onmessage = async (event: MessageEvent) => {
|
|
|
23
23
|
role: msg.role,
|
|
24
24
|
})
|
|
25
25
|
if (msg.id === activeId) {
|
|
26
|
-
self.postMessage({ type: "search-result", id: msg.id, result:
|
|
26
|
+
self.postMessage({ type: "search-result", id: msg.id, result: response.results, limit: msg.limit, keywordState: response.keywordState, vectorState: response.vectorState, stale: response.stale })
|
|
27
27
|
}
|
|
28
28
|
} catch (err) {
|
|
29
29
|
if (msg.id === activeId) {
|
package/search.ts
CHANGED
|
@@ -7,7 +7,9 @@ export type {
|
|
|
7
7
|
ConversationPreviewCursor,
|
|
8
8
|
ToolState,
|
|
9
9
|
SemanticConfig,
|
|
10
|
+
KeywordIndexState,
|
|
10
11
|
VectorState,
|
|
12
|
+
SearchResponse,
|
|
11
13
|
DocumentRow,
|
|
12
14
|
ScoredRow,
|
|
13
15
|
HybridSearchOptions,
|
|
@@ -16,13 +18,21 @@ export type {
|
|
|
16
18
|
// Re-exported query functions
|
|
17
19
|
export {
|
|
18
20
|
searchSessionMessages,
|
|
21
|
+
searchSessionMessagesWithStatus,
|
|
19
22
|
recentSessionMessages,
|
|
23
|
+
recentSessionMessagesWithStatus,
|
|
20
24
|
loadConversationAround,
|
|
21
25
|
loadConversationBefore,
|
|
22
26
|
loadConversationAfter,
|
|
23
27
|
performSearch,
|
|
28
|
+
performSearchWithStatus,
|
|
24
29
|
semanticSearchSessionMessages,
|
|
30
|
+
semanticSearchSessionMessagesWithStatus,
|
|
25
31
|
parseSemanticConfig,
|
|
32
|
+
openSearchIndex,
|
|
33
|
+
readKeywordIndexState,
|
|
34
|
+
rebuildKeywordIndex,
|
|
35
|
+
rebuildKeywordIndexForDbPath,
|
|
26
36
|
} from "./search/queries.ts"
|
|
27
37
|
|
|
28
38
|
// Re-exported text/snippet functions
|
package/telescope.tsx
CHANGED
|
@@ -12,11 +12,15 @@ import {
|
|
|
12
12
|
parseSemanticConfig,
|
|
13
13
|
performSearch,
|
|
14
14
|
recentSessionMessages,
|
|
15
|
+
recentSessionMessagesWithStatus,
|
|
15
16
|
resolveDatabasePath,
|
|
16
17
|
searchSessionMessages,
|
|
18
|
+
searchSessionMessagesWithStatus,
|
|
17
19
|
type ConversationPreviewPart,
|
|
20
|
+
type KeywordIndexState,
|
|
18
21
|
type SearchResult,
|
|
19
22
|
type SearchRole,
|
|
23
|
+
type VectorState,
|
|
20
24
|
} from "./search.ts"
|
|
21
25
|
import { debug } from "./ui/debug.ts"
|
|
22
26
|
import { syntaxStyle } from "./ui/format.ts"
|
|
@@ -38,6 +42,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
38
42
|
const [busy, setBusy] = createSignal(false)
|
|
39
43
|
const [error, setError] = createSignal("")
|
|
40
44
|
const [searchMode, setSearchMode] = createSignal<"keyword" | "hybrid">("keyword")
|
|
45
|
+
const [keywordIndexState, setKeywordIndexState] = createSignal<KeywordIndexState>("missing")
|
|
46
|
+
const [vectorState, setVectorState] = createSignal<VectorState | undefined>()
|
|
41
47
|
const [mode, setMode] = createSignal<"normal" | "insert">("normal")
|
|
42
48
|
const [loading, setLoading] = createSignal(true)
|
|
43
49
|
const [hasMore, setHasMore] = createSignal(true)
|
|
@@ -98,10 +104,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
98
104
|
let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
|
|
99
105
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
100
106
|
let searchWatchdogTimer: ReturnType<typeof setTimeout> | undefined
|
|
101
|
-
let
|
|
107
|
+
let lastFiredSearchKey = ""
|
|
102
108
|
let searchRequestId = 0
|
|
103
109
|
let fallbackSearchRequestId: number | undefined
|
|
104
110
|
let searchWorker: Worker | undefined
|
|
111
|
+
let indexWorker: Worker | undefined
|
|
112
|
+
let indexJobId = 0
|
|
113
|
+
const activeIndexJobs = new Set<string>()
|
|
105
114
|
const SEARCH_DEBOUNCE_MS = 200
|
|
106
115
|
const SEARCH_WORKER_TIMEOUT_MS = 3000
|
|
107
116
|
type SearchRequest = {
|
|
@@ -184,6 +193,17 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
184
193
|
setLoading(false)
|
|
185
194
|
debug.timeEnd("query:search")
|
|
186
195
|
}
|
|
196
|
+
const settleSearchAsIndexing = (request: SearchRequest, reason: string) => {
|
|
197
|
+
if (request.id !== searchRequestId) return
|
|
198
|
+
clearSearchWatchdog()
|
|
199
|
+
pendingSearchRequest = undefined
|
|
200
|
+
debug.log("bootstrap:search:indexing", { reason, query: request.q || "(all recent)", limit: request.limit })
|
|
201
|
+
setKeywordIndexState("indexing")
|
|
202
|
+
requestIndexRebuild(request.db, "stale")
|
|
203
|
+
setBusy(false)
|
|
204
|
+
setLoading(false)
|
|
205
|
+
debug.timeEnd("query:search")
|
|
206
|
+
}
|
|
187
207
|
const runSearchFallback = async (request: SearchRequest, reason: string) => {
|
|
188
208
|
if (request.id !== searchRequestId) return
|
|
189
209
|
if (fallbackSearchRequestId === request.id) return
|
|
@@ -191,14 +211,67 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
191
211
|
clearSearchWatchdog()
|
|
192
212
|
debug.log("bootstrap:search:fallback", { reason, query: request.q || "(all recent)", limit: request.limit })
|
|
193
213
|
try {
|
|
194
|
-
const
|
|
195
|
-
?
|
|
196
|
-
:
|
|
197
|
-
|
|
214
|
+
const response = request.q
|
|
215
|
+
? searchSessionMessagesWithStatus(request.q, { limit: request.limit, offset: 0, dbPath: request.db, directory: request.dir, role: request.role })
|
|
216
|
+
: recentSessionMessagesWithStatus({ limit: request.limit, offset: 0, dbPath: request.db, directory: request.dir, role: request.role })
|
|
217
|
+
setKeywordIndexState(response.keywordState)
|
|
218
|
+
setVectorState(response.vectorState)
|
|
219
|
+
requestIndexRebuild(request.db, response.keywordState)
|
|
220
|
+
commitSearchBatch(request, response.results, `fallback:${reason}`)
|
|
198
221
|
} catch (err) {
|
|
199
222
|
failSearch(request, err instanceof Error ? err.message : String(err))
|
|
200
223
|
}
|
|
201
224
|
}
|
|
225
|
+
const shouldRebuildIndex = (state: KeywordIndexState) => state === "missing" || state === "empty" || state === "stale" || state === "error"
|
|
226
|
+
const ensureIndexWorker = () => {
|
|
227
|
+
if (indexWorker) return true
|
|
228
|
+
try {
|
|
229
|
+
indexWorker = new Worker(new URL("./index-worker.ts", import.meta.url))
|
|
230
|
+
indexWorker.onmessage = (event: MessageEvent) => {
|
|
231
|
+
const msg = event.data
|
|
232
|
+
if (msg.type === "index-started") {
|
|
233
|
+
debug.log("index-worker:started", { dbPath: msg.dbPath })
|
|
234
|
+
setKeywordIndexState("indexing")
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (msg.type === "index-done") {
|
|
238
|
+
debug.log("index-worker:done", { dbPath: msg.dbPath })
|
|
239
|
+
activeIndexJobs.delete(msg.dbPath)
|
|
240
|
+
setKeywordIndexState("ready")
|
|
241
|
+
if (msg.dbPath === dbPath()) {
|
|
242
|
+
lastFiredSearchKey = ""
|
|
243
|
+
scheduleSearch(query().trim(), ownerRole(), dbPath(), directory)
|
|
244
|
+
}
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (msg.type === "index-error") {
|
|
248
|
+
debug.log("index-worker:error", { dbPath: msg.dbPath, error: msg.error })
|
|
249
|
+
activeIndexJobs.delete(msg.dbPath)
|
|
250
|
+
setKeywordIndexState("error")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
indexWorker.onerror = (err) => {
|
|
254
|
+
debug.log("index-worker:error", err.message)
|
|
255
|
+
activeIndexJobs.clear()
|
|
256
|
+
setKeywordIndexState("error")
|
|
257
|
+
indexWorker?.terminate()
|
|
258
|
+
indexWorker = undefined
|
|
259
|
+
}
|
|
260
|
+
return true
|
|
261
|
+
} catch (err) {
|
|
262
|
+
debug.log("index-worker:create-error", err instanceof Error ? err.message : String(err))
|
|
263
|
+
setKeywordIndexState("error")
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const requestIndexRebuild = (db: string, state: KeywordIndexState) => {
|
|
268
|
+
if (!shouldRebuildIndex(state)) return
|
|
269
|
+
if (activeIndexJobs.has(db)) return
|
|
270
|
+
if (!ensureIndexWorker()) return
|
|
271
|
+
activeIndexJobs.add(db)
|
|
272
|
+
setKeywordIndexState("indexing")
|
|
273
|
+
indexWorker!.postMessage({ type: "rebuild-index", id: ++indexJobId, dbPath: db })
|
|
274
|
+
}
|
|
202
275
|
const ensureSearchWorker = () => {
|
|
203
276
|
if (searchWorker) return true
|
|
204
277
|
try {
|
|
@@ -209,6 +282,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
209
282
|
if (msg.id !== searchRequestId) return
|
|
210
283
|
const request = pendingSearchRequest
|
|
211
284
|
if (!request || request.id !== msg.id) return
|
|
285
|
+
setKeywordIndexState(msg.keywordState ?? "ready")
|
|
286
|
+
setVectorState(msg.vectorState)
|
|
287
|
+
requestIndexRebuild(request.db, msg.keywordState ?? "ready")
|
|
212
288
|
commitSearchBatch(request, msg.result, "worker")
|
|
213
289
|
}
|
|
214
290
|
if (msg.type === "error") {
|
|
@@ -239,6 +315,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
239
315
|
cancelPreviewPrefetch()
|
|
240
316
|
searchWorker?.terminate()
|
|
241
317
|
searchWorker = undefined
|
|
318
|
+
indexWorker?.terminate()
|
|
319
|
+
indexWorker = undefined
|
|
320
|
+
activeIndexJobs.clear()
|
|
242
321
|
})
|
|
243
322
|
|
|
244
323
|
const resultRowHeight = createMemo(() => leftWidth() >= 48 ? 3 : 4)
|
|
@@ -309,8 +388,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
309
388
|
return config.disableVector ? "keyword" as const : "hybrid" as const
|
|
310
389
|
}
|
|
311
390
|
|
|
391
|
+
const searchKey = (q: string, role: SearchRole | undefined, db: string, dir: string) =>
|
|
392
|
+
JSON.stringify([q, role ?? null, db, dir])
|
|
393
|
+
|
|
312
394
|
const executeSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
313
|
-
|
|
395
|
+
lastFiredSearchKey = searchKey(q, role, db, dir)
|
|
314
396
|
const limit = q ? Math.min(searchBatchSize(), 80) : recentBatchSize()
|
|
315
397
|
setError("")
|
|
316
398
|
setHasMore(true)
|
|
@@ -342,7 +424,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
342
424
|
debug.log("worker:timeout", { id: request.id, ms: SEARCH_WORKER_TIMEOUT_MS })
|
|
343
425
|
searchWorker?.terminate()
|
|
344
426
|
searchWorker = undefined
|
|
345
|
-
|
|
427
|
+
settleSearchAsIndexing(request, "worker-timeout")
|
|
346
428
|
}, SEARCH_WORKER_TIMEOUT_MS)
|
|
347
429
|
;(searchWatchdogTimer as { unref?: () => void }).unref?.()
|
|
348
430
|
searchWorker!.postMessage(msg)
|
|
@@ -350,7 +432,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
350
432
|
|
|
351
433
|
const scheduleSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
352
434
|
if (searchTimer) clearTimeout(searchTimer)
|
|
353
|
-
if (q
|
|
435
|
+
if (searchKey(q, role, db, dir) === lastFiredSearchKey) return
|
|
354
436
|
searchTimer = setTimeout(() => {
|
|
355
437
|
searchTimer = undefined
|
|
356
438
|
executeSearch(q, role, db, dir)
|
|
@@ -1368,7 +1450,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
1368
1450
|
}
|
|
1369
1451
|
>
|
|
1370
1452
|
<Show when={!loading()}>
|
|
1371
|
-
<Show when={results().length > 0} fallback={<EmptyState query={query()} owner={ownerLabel()} theme={theme()} />}>
|
|
1453
|
+
<Show when={results().length > 0} fallback={<EmptyState query={query()} owner={ownerLabel()} theme={theme()} keywordIndexState={keywordIndexState()} />}>
|
|
1372
1454
|
<box height={resultTopSpacerHeight()} flexShrink={0} />
|
|
1373
1455
|
<For each={resultRenderWindow().items}>
|
|
1374
1456
|
{(item, index) => {
|