@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.
@@ -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.trim() ? `No matching ${props.owner} conversation text.` : `No recent ${props.owner} conversation text found.`}</text>
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"
@@ -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.29",
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, setupVectorTable, configureCustomSQLite, loadVecExtension } from "./vector.ts"
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
- return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset, options?.role)
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 indexed = dbPath === ":memory:" ? undefined : indexedRecentRows(db, dbPath, limit, options?.directory, options?.offset, options?.role)
61
- if (indexed) return indexed.flatMap((row) => rowToSearchResult(row, "") ?? [])
62
- if (dbPath !== ":memory:") scheduleBackgroundIndexRebuild(dbPath)
63
- debug.log("query:recent:index-pending", { limit, offset: options?.offset ?? 0, directory: options?.directory, role: options?.role })
64
- return []
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
- if (!query.trim()) return searchSessionMessages(query, options)
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 semanticSearchSessionMessages(query, options)
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 searchSessionMessages(query, options)
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 = ensureSearchIndex(db, dbPath)
260
- if (!index) return []
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 client.embedQuery(embedTerm)
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 searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
335
- if (!tableExists(db, "part") || !tableExists(db, "message")) return []
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 indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number, role?: SearchRole) {
346
- const match = ftsQuery(query)
347
- if (!match) return []
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
- const index = ensureSearchIndex(db, dbPath)
350
- if (!index) return []
351
- const conditions = ["document_fts MATCH ?"]
352
- const params: (string | number)[] = [match]
353
- if (role) {
354
- conditions.push("role = ?")
355
- params.push(role)
356
- }
357
- if (directory) {
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
- function indexedRecentRows(db: Database, dbPath: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
382
- try {
383
- const index = ensureSearchIndex(db, dbPath, { rebuild: false, useStale: true })
384
- if (!index) {
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
- if (rowCount === 0) {
391
- debug.log("query:recent:index:empty", { dbPath })
392
- return
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(db, dbPath)
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
- if (currentPath !== dbPath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
401
- debug.log("query:recent:index:stale", {
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("recent:index:fallback", err instanceof Error ? err.message : String(err))
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 visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
442
- const offsetClause = offset ? "OFFSET ?" : ""
443
- const conditions: string[] = [
444
- "json_extract(p.data, '$.type') = 'text'",
445
- role ? "json_extract(m.data, '$.role') = ?" : "json_extract(m.data, '$.role') IN ('user', 'assistant')",
446
- ]
447
- const params: (string | number)[] = []
448
-
449
- if (role) params.push(role)
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("s.directory = ?")
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
- const sql = `
466
- SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
467
- json_extract(m.data, '$.role') AS role,
468
- p.time_created,
469
- json_extract(p.data, '$.text') AS text
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 p.time_created DESC
431
+ ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
475
432
  LIMIT ? ${offsetClause}
476
- `
477
- debug.time("query:sql:exec")
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 ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean; useStale?: boolean }) {
484
- const indexPath = searchIndexPath(sourcePath)
485
- if (!_indexDb || _indexDbPath !== indexPath) {
486
- _indexDb?.close()
487
- configureCustomSQLite()
488
- _indexDb = new Database(indexPath)
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
- const state = sourceState(source, sourcePath)
495
- const currentDataVersion = getMeta(_indexDb, "source_data_version")
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
- return _indexDb
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 scheduleBackgroundIndexRebuild(dbPath: string) {
510
- if (backgroundIndexRebuilds.has(dbPath)) return
511
- backgroundIndexRebuilds.add(dbPath)
512
- debug.log("fts:rebuild:background-scheduled", { dbPath })
513
- const timer = setTimeout(() => {
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 rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
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
@@ -5,6 +5,8 @@ export const DOCUMENT_EXTRACTOR_VERSION = "1"
5
5
 
6
6
  export function migrateSearchIndex(db: Database) {
7
7
  db.exec(`
8
+ PRAGMA journal_mode = WAL;
9
+ PRAGMA busy_timeout = 1000;
8
10
  CREATE TABLE IF NOT EXISTS index_meta(
9
11
  key TEXT PRIMARY KEY,
10
12
  value TEXT NOT NULL
package/search/types.ts CHANGED
@@ -70,7 +70,16 @@ export type SemanticConfig = {
70
70
  queryPrefix: string
71
71
  }
72
72
 
73
- export type VectorState = "enabled" | "disabled" | "unavailable" | "stale"
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 { performSearch, recentSessionMessages } from "./search"
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 batch = msg.type === "search"
11
- ? await performSearch(msg.query, {
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
- : recentSessionMessages({
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: batch, limit: msg.limit })
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 lastFiredQuery = ""
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 batch = request.q
195
- ? searchSessionMessages(request.q, { limit: request.limit, offset: 0, dbPath: request.db, directory: request.dir, role: request.role })
196
- : recentSessionMessages({ limit: request.limit, offset: 0, dbPath: request.db, directory: request.dir, role: request.role })
197
- commitSearchBatch(request, batch, `fallback:${reason}`)
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
- lastFiredQuery = q
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
- failSearch(request, "Search worker timed out. The conversation index may still be building; try again shortly.")
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 === lastFiredQuery && results().length > 0) return
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) => {