@bojackduy/opencode-telescope 0.1.32 → 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 +164 -244
- 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 +85 -6
|
@@ -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,33 +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
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (sourceRows) return sourceRows.flatMap((row) => rowToSearchResult(row, "") ?? [])
|
|
68
|
-
return indexed?.rows.flatMap((row) => rowToSearchResult(row, "") ?? []) ?? []
|
|
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))
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
|
|
@@ -238,77 +248,55 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
|
|
|
238
248
|
}
|
|
239
249
|
|
|
240
250
|
export async function performSearch(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): Promise<SearchResult[]> {
|
|
241
|
-
|
|
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)
|
|
242
256
|
const config = parseSemanticConfig()
|
|
243
257
|
if (!config.disableVector) {
|
|
244
258
|
try {
|
|
245
|
-
return await
|
|
259
|
+
return await semanticSearchSessionMessagesWithStatus(query, options)
|
|
246
260
|
} catch {
|
|
247
261
|
debug.log("query:hybrid:fallback", { message: "hybrid search failed, falling back to keyword" })
|
|
248
262
|
}
|
|
249
263
|
}
|
|
250
|
-
return
|
|
264
|
+
return searchSessionMessagesWithStatus(query, options)
|
|
251
265
|
}
|
|
252
266
|
|
|
253
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> {
|
|
254
272
|
const term = query.trim()
|
|
255
|
-
if (!term) return []
|
|
256
|
-
if (options?.dbPath === ":memory:") return []
|
|
273
|
+
if (!term) return searchResponse([], "ready")
|
|
274
|
+
if (options?.dbPath === ":memory:") return searchResponse([], "missing")
|
|
257
275
|
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
258
276
|
const db = getDb(dbPath)
|
|
259
277
|
const limit = options?.limit ?? 80
|
|
260
278
|
const dir = options?.directory
|
|
261
279
|
const role = options?.role
|
|
262
280
|
|
|
263
|
-
const index =
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
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) : []
|
|
267
284
|
const config = parseSemanticConfig()
|
|
285
|
+
let vectorState = getVectorState(index)
|
|
268
286
|
|
|
269
287
|
let vector: Row[] = []
|
|
270
|
-
if (!config.disableVector) {
|
|
271
|
-
let vecState = getMeta(index, "vector_state")
|
|
272
|
-
if (vecState !== "enabled" && vecState !== "disabled" && vecState !== "stale") {
|
|
273
|
-
const indexPath = searchIndexPath(dbPath)
|
|
274
|
-
const lastAttempt = lastVectorRebuildAttempt.get(indexPath)
|
|
275
|
-
if (!lastAttempt || Date.now() - lastAttempt > 30_000) {
|
|
276
|
-
setupVectorTable(index, config, indexPath)
|
|
277
|
-
lastVectorRebuildAttempt.set(indexPath, Date.now())
|
|
278
|
-
vecState = getMeta(index, "vector_state") ?? "stale"
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (vecState === "stale" && getMeta(index, "embedding_dimensions")) {
|
|
288
|
+
if (!config.disableVector && isVectorReady(index)) {
|
|
282
289
|
try {
|
|
283
290
|
const indexPath = searchIndexPath(dbPath)
|
|
284
|
-
await vecExtensionLoading.get(indexPath)
|
|
285
|
-
const test = index.query("SELECT COUNT(*) as count FROM document_vec").get() as { count: number } | undefined
|
|
286
|
-
if (test && test.count > 0) {
|
|
287
|
-
setMeta(index, "vector_state", "enabled")
|
|
288
|
-
vecState = "enabled"
|
|
289
|
-
}
|
|
290
|
-
} catch {
|
|
291
|
-
debug.log("vector:stale:recovery-failed")
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (vecState === "enabled") {
|
|
295
|
-
try {
|
|
296
|
-
const indexPath = searchIndexPath(dbPath)
|
|
297
|
-
await vecExtensionLoading.get(indexPath)
|
|
298
|
-
const client = new LlamaEmbeddingClient({
|
|
299
|
-
baseUrl: config.embedBaseUrl,
|
|
300
|
-
model: config.embedModel,
|
|
301
|
-
documentPrefix: config.documentPrefix,
|
|
302
|
-
queryPrefix: config.queryPrefix,
|
|
303
|
-
})
|
|
291
|
+
await withDeadline(vecExtensionLoading.get(indexPath) ?? Promise.resolve(), 250)
|
|
304
292
|
const embedTerm = expandQuery(term)
|
|
305
|
-
const embedding = await
|
|
293
|
+
const embedding = await withDeadline(embedQuery(config, embedTerm), 1200)
|
|
306
294
|
vector = searchVector(index, embedding, limit)
|
|
307
295
|
debug.log("query:vector:results", { count: vector.length, embedTerm: embedTerm !== term ? embedTerm : undefined })
|
|
308
296
|
} catch (err) {
|
|
309
297
|
debug.log("query:vector:error", err instanceof Error ? err.message : String(err))
|
|
298
|
+
vectorState = getVectorState(index)
|
|
310
299
|
}
|
|
311
|
-
}
|
|
312
300
|
}
|
|
313
301
|
|
|
314
302
|
const merged = keyword.length || vector.length
|
|
@@ -332,218 +320,155 @@ export async function semanticSearchSessionMessages(query: string, options?: { l
|
|
|
332
320
|
}
|
|
333
321
|
}
|
|
334
322
|
|
|
335
|
-
return results.slice(0, limit)
|
|
323
|
+
return searchResponse(results.slice(0, limit), status.state, vectorState)
|
|
336
324
|
}
|
|
337
325
|
|
|
338
|
-
function
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
debug.timeEnd("query:map")
|
|
346
|
-
return results
|
|
326
|
+
function searchResponse(results: SearchResult[], keywordState: KeywordIndexState, vectorState?: VectorState): SearchResponse {
|
|
327
|
+
return {
|
|
328
|
+
results,
|
|
329
|
+
keywordState,
|
|
330
|
+
vectorState,
|
|
331
|
+
stale: keywordState === "stale",
|
|
332
|
+
}
|
|
347
333
|
}
|
|
348
334
|
|
|
349
|
-
function
|
|
350
|
-
|
|
351
|
-
|
|
335
|
+
function canQueryKeywordIndex(state: KeywordIndexState) {
|
|
336
|
+
return state === "ready" || state === "stale"
|
|
337
|
+
}
|
|
338
|
+
|
|
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
|
|
352
357
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (
|
|
362
|
-
conditions.push("directory = ?")
|
|
363
|
-
params.push(directory)
|
|
364
|
-
}
|
|
365
|
-
params.push(limit)
|
|
366
|
-
if (offset) params.push(offset)
|
|
367
|
-
const offsetClause = offset ? "OFFSET ?" : ""
|
|
368
|
-
debug.time("query:fts:exec")
|
|
369
|
-
const rows = index.query<Row, (string | number)[]>(`
|
|
370
|
-
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
371
|
-
CAST(time_created AS INTEGER) AS time_created, text
|
|
372
|
-
FROM document_fts
|
|
373
|
-
WHERE ${conditions.join(" AND ")}
|
|
374
|
-
ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
|
|
375
|
-
LIMIT ? ${offsetClause}
|
|
376
|
-
`).all(...params as any[])
|
|
377
|
-
debug.timeEnd("query:fts:exec")
|
|
378
|
-
return rows
|
|
379
|
-
} catch (err) {
|
|
380
|
-
debug.log("fts:fallback", err instanceof Error ? err.message : String(err))
|
|
381
|
-
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)
|
|
382
367
|
}
|
|
383
368
|
}
|
|
384
369
|
|
|
385
|
-
type
|
|
386
|
-
|
|
387
|
-
|
|
370
|
+
type KeywordIndexStatus = {
|
|
371
|
+
state: KeywordIndexState
|
|
372
|
+
rowCount: number
|
|
388
373
|
}
|
|
389
374
|
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
}
|
|
397
387
|
|
|
388
|
+
export function readKeywordIndexState(source: Database, index: Database, sourcePath: string): KeywordIndexStatus {
|
|
389
|
+
try {
|
|
398
390
|
const rowCount = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_index").get()?.count ?? 0
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
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 }
|
|
403
395
|
|
|
404
|
-
const state = sourceState(
|
|
396
|
+
const state = sourceState(source, sourcePath)
|
|
405
397
|
const currentDataVersion = getMeta(index, "source_data_version")
|
|
406
398
|
const currentMtimeMs = getMeta(index, "source_mtime_ms")
|
|
407
399
|
const currentPath = getMeta(index, "source_path")
|
|
408
400
|
const currentIndexVersion = getMeta(index, "index_version")
|
|
409
|
-
const stale = currentPath !==
|
|
410
|
-
|
|
411
|
-
debug.log("query:recent:index:stale", {
|
|
412
|
-
dbPath,
|
|
413
|
-
expectedDataVersion: state.dataVersion,
|
|
414
|
-
actualDataVersion: currentDataVersion,
|
|
415
|
-
})
|
|
416
|
-
scheduleBackgroundIndexRebuild(dbPath)
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const conditions: string[] = ["part_type = 'text'"]
|
|
420
|
-
const params: (string | number)[] = []
|
|
421
|
-
if (role) {
|
|
422
|
-
conditions.push("role = ?")
|
|
423
|
-
params.push(role)
|
|
424
|
-
}
|
|
425
|
-
if (directory) {
|
|
426
|
-
conditions.push("directory = ?")
|
|
427
|
-
params.push(directory)
|
|
428
|
-
}
|
|
429
|
-
params.push(limit)
|
|
430
|
-
if (offset) params.push(offset)
|
|
431
|
-
|
|
432
|
-
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
433
|
-
const offsetClause = offset ? "OFFSET ?" : ""
|
|
434
|
-
debug.time("query:recent:index:exec")
|
|
435
|
-
const rows = index.query<Row, (string | number)[]>(`
|
|
436
|
-
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
437
|
-
CAST(time_created AS INTEGER) AS time_created, text
|
|
438
|
-
FROM document_index
|
|
439
|
-
${where}
|
|
440
|
-
ORDER BY CAST(time_created AS INTEGER) DESC
|
|
441
|
-
LIMIT ? ${offsetClause}
|
|
442
|
-
`).all(...params as any[])
|
|
443
|
-
debug.timeEnd("query:recent:index:exec")
|
|
444
|
-
return { rows, stale }
|
|
401
|
+
const stale = currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION
|
|
402
|
+
return { state: stale ? "stale" : "ready", rowCount }
|
|
445
403
|
} catch (err) {
|
|
446
|
-
debug.log("
|
|
447
|
-
return
|
|
404
|
+
debug.log("keyword:index:state-error", err instanceof Error ? err.message : String(err))
|
|
405
|
+
return { state: "error", rowCount: 0 }
|
|
448
406
|
}
|
|
449
407
|
}
|
|
450
408
|
|
|
451
|
-
function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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)
|
|
459
417
|
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
|
|
463
|
-
const offsetClause = offset ? "OFFSET ?" : ""
|
|
464
|
-
const conditions: string[] = [
|
|
465
|
-
"json_extract(p.data, '$.type') = 'text'",
|
|
466
|
-
role ? "json_extract(m.data, '$.role') = ?" : "json_extract(m.data, '$.role') IN ('user', 'assistant')",
|
|
467
|
-
]
|
|
468
|
-
const params: (string | number)[] = []
|
|
469
|
-
|
|
470
|
-
if (role) params.push(role)
|
|
471
|
-
|
|
472
418
|
if (directory) {
|
|
473
|
-
conditions.push("
|
|
419
|
+
conditions.push("directory = ?")
|
|
474
420
|
params.push(directory)
|
|
475
421
|
}
|
|
476
|
-
|
|
477
|
-
const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
|
|
478
|
-
for (const token of tokens) {
|
|
479
|
-
conditions.push("json_extract(p.data, '$.text') LIKE ?")
|
|
480
|
-
params.push(`%${token}%`)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
422
|
params.push(limit)
|
|
484
423
|
if (offset) params.push(offset)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
FROM part p
|
|
492
|
-
JOIN message m ON m.id = p.message_id
|
|
493
|
-
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
|
|
494
430
|
WHERE ${conditions.join(" AND ")}
|
|
495
|
-
ORDER BY
|
|
431
|
+
ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
|
|
496
432
|
LIMIT ? ${offsetClause}
|
|
497
|
-
`
|
|
498
|
-
debug.
|
|
499
|
-
const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
|
|
500
|
-
debug.timeEnd("query:sql:exec")
|
|
433
|
+
`).all(...params as any[])
|
|
434
|
+
debug.timeEnd("query:fts:exec")
|
|
501
435
|
return rows
|
|
502
436
|
}
|
|
503
437
|
|
|
504
|
-
function
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
_indexDbPath = indexPath
|
|
511
|
-
migrateSearchIndex(_indexDb)
|
|
512
|
-
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)
|
|
513
444
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
|
|
518
|
-
const currentPath = getMeta(_indexDb, "source_path")
|
|
519
|
-
const currentIndexVersion = getMeta(_indexDb, "index_version")
|
|
520
|
-
if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
|
|
521
|
-
if (options?.rebuild === false) {
|
|
522
|
-
if (options?.useStale) return _indexDb
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
rebuildSearchIndex(source, _indexDb, sourcePath, state)
|
|
445
|
+
if (directory) {
|
|
446
|
+
conditions.push("directory = ?")
|
|
447
|
+
params.push(directory)
|
|
526
448
|
}
|
|
527
|
-
|
|
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
|
|
528
465
|
}
|
|
529
466
|
|
|
530
|
-
function
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
debug.time("fts:rebuild:background")
|
|
536
|
-
try {
|
|
537
|
-
const db = getDb(dbPath)
|
|
538
|
-
ensureSearchIndex(db, dbPath)
|
|
539
|
-
} catch (err) {
|
|
540
|
-
debug.log("fts:rebuild:background:error", err instanceof Error ? err.message : String(err))
|
|
541
|
-
} finally {
|
|
542
|
-
backgroundIndexRebuilds.delete(dbPath)
|
|
543
|
-
debug.timeEnd("fts:rebuild:background")
|
|
544
|
-
}
|
|
545
|
-
}, 250)
|
|
546
|
-
;(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)
|
|
547
472
|
}
|
|
548
473
|
|
|
549
474
|
function sourceState(db: Database, sourcePath: string) {
|
|
@@ -556,7 +481,7 @@ function hashPartData(value: { session_id: string; message_id: string; part_id:
|
|
|
556
481
|
return createHash("sha256").update(`${value.session_id}:${value.message_id}:${value.part_id}`).digest("hex")
|
|
557
482
|
}
|
|
558
483
|
|
|
559
|
-
function
|
|
484
|
+
export function rebuildKeywordIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
|
|
560
485
|
debug.time("fts:rebuild")
|
|
561
486
|
const rows = source.query<IndexSourceRow, []>(`
|
|
562
487
|
SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
|
|
@@ -594,6 +519,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
594
519
|
`)
|
|
595
520
|
index.exec("BEGIN IMMEDIATE")
|
|
596
521
|
try {
|
|
522
|
+
setMeta(index, "keyword_index_state", "indexing")
|
|
597
523
|
index.exec("DELETE FROM document")
|
|
598
524
|
index.exec("DELETE FROM document_fts")
|
|
599
525
|
index.exec("DELETE FROM document_index")
|
|
@@ -649,6 +575,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
649
575
|
setMeta(index, "schema_version", "2")
|
|
650
576
|
setMeta(index, "extractor_version", DOCUMENT_EXTRACTOR_VERSION)
|
|
651
577
|
setMeta(index, "ranking_version", "1")
|
|
578
|
+
setMeta(index, "keyword_index_state", "ready")
|
|
652
579
|
setMeta(index, "embedding_base_url", config.embedBaseUrl)
|
|
653
580
|
setMeta(index, "document_prefix", config.documentPrefix)
|
|
654
581
|
setMeta(index, "query_prefix", config.queryPrefix)
|
|
@@ -656,6 +583,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
656
583
|
index.exec("COMMIT")
|
|
657
584
|
} catch (err) {
|
|
658
585
|
index.exec("ROLLBACK")
|
|
586
|
+
setMeta(index, "keyword_index_state", "error")
|
|
659
587
|
throw err
|
|
660
588
|
} finally {
|
|
661
589
|
debug.timeEnd("fts:rebuild")
|
|
@@ -784,14 +712,6 @@ function stringValue(value: unknown) {
|
|
|
784
712
|
return typeof value === "string" ? value : undefined
|
|
785
713
|
}
|
|
786
714
|
|
|
787
|
-
const tableCache = new Map<string, boolean>()
|
|
788
|
-
function tableExists(db: Database, name: string) {
|
|
789
|
-
if (tableCache.has(name)) return tableCache.get(name)!
|
|
790
|
-
const exists = Boolean(db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
|
|
791
|
-
tableCache.set(name, exists)
|
|
792
|
-
return exists
|
|
793
|
-
}
|
|
794
|
-
|
|
795
715
|
function countBy<T>(items: T[], key: (item: T) => string) {
|
|
796
716
|
const counts: Record<string, number> = {}
|
|
797
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)
|
|
@@ -102,6 +108,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
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)
|
|
@@ -345,7 +424,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
345
424
|
debug.log("worker:timeout", { id: request.id, ms: SEARCH_WORKER_TIMEOUT_MS })
|
|
346
425
|
searchWorker?.terminate()
|
|
347
426
|
searchWorker = undefined
|
|
348
|
-
|
|
427
|
+
settleSearchAsIndexing(request, "worker-timeout")
|
|
349
428
|
}, SEARCH_WORKER_TIMEOUT_MS)
|
|
350
429
|
;(searchWatchdogTimer as { unref?: () => void }).unref?.()
|
|
351
430
|
searchWorker!.postMessage(msg)
|
|
@@ -1371,7 +1450,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
1371
1450
|
}
|
|
1372
1451
|
>
|
|
1373
1452
|
<Show when={!loading()}>
|
|
1374
|
-
<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()} />}>
|
|
1375
1454
|
<box height={resultTopSpacerHeight()} flexShrink={0} />
|
|
1376
1455
|
<For each={resultRenderWindow().items}>
|
|
1377
1456
|
{(item, index) => {
|