@bojackduy/opencode-telescope 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,800 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { createHash } from "node:crypto"
3
+ import { statSync } from "node:fs"
4
+ import { debug } from "../ui/debug.ts"
5
+
6
+ import type {
7
+ SearchResult,
8
+ SearchRole,
9
+ ConversationPreviewPage,
10
+ ConversationPreviewCursor,
11
+ ConversationRow,
12
+ ConversationPreviewPart,
13
+ IndexSourceRow,
14
+ Row,
15
+ SemanticConfig,
16
+ } from "./types.ts"
17
+ import { rowToSearchResult, rowToVectorResult, indexSourceRowToRows, ftsQuery, expandQuery } from "./text.ts"
18
+ import { resolveDatabasePath, searchIndexPath } from "./db-path.ts"
19
+ import { LlamaEmbeddingClient } from "./embedding.ts"
20
+ import { migrateSearchIndex, getMeta, setMeta, SEARCH_INDEX_VERSION, DOCUMENT_EXTRACTOR_VERSION } from "./schema.ts"
21
+ import { hybridBlend, searchVector, setupVectorTable, configureCustomSQLite, loadVecExtension } from "./vector.ts"
22
+
23
+ // Load custom SQLite before any Database() constructor runs,
24
+ // otherwise Database.setCustomSQLite() fails with "SQLite already loaded"
25
+ configureCustomSQLite()
26
+
27
+ const backgroundIndexRebuilds = new Set<string>()
28
+ const lastVectorRebuildAttempt = new Map<string, number>()
29
+ const vecExtensionLoading = new Map<string, Promise<void>>()
30
+
31
+ let _db: Database | undefined
32
+ let _dbPath: string | undefined
33
+ let _indexDb: Database | undefined
34
+ let _indexDbPath: string | undefined
35
+
36
+ function getDb(dbPath?: string): Database {
37
+ const resolved = dbPath ?? resolveDatabasePath()
38
+ if (!_db || resolved !== _dbPath) {
39
+ _db?.close()
40
+ _db = new Database(resolved, { readonly: true })
41
+ _dbPath = resolved
42
+ tableCache.clear()
43
+ }
44
+ return _db
45
+ }
46
+
47
+ export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
48
+ const term = query.trim()
49
+ if (!term) return []
50
+ if (options?.dbPath === ":memory:") return []
51
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
52
+ const db = getDb(dbPath)
53
+ return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset, options?.role)
54
+ }
55
+
56
+ export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
57
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
58
+ const db = getDb(dbPath)
59
+ 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
+ debug.log("query:recent:source-fallback", { limit, offset: options?.offset ?? 0, directory: options?.directory, role: options?.role })
63
+ return visibleTextRows(db, limit, undefined, options?.directory, options?.offset, options?.role).flatMap(
64
+ (row) => rowToSearchResult(row, "") ?? [],
65
+ )
66
+ }
67
+
68
+ export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
69
+ debug.time("preview:query:total")
70
+ const db = getDb(options?.dbPath)
71
+ const before = options?.before ?? 3
72
+ const after = options?.after ?? 6
73
+
74
+ debug.time("preview:query:hit")
75
+ const hit = db.query<{ time_created: number }, [string]>(
76
+ "SELECT time_created FROM part WHERE id = ?",
77
+ ).get(result.id)
78
+ debug.timeEnd("preview:query:hit")
79
+ if (!hit) {
80
+ debug.log("preview:window", {
81
+ item: result.id,
82
+ session: result.sessionID,
83
+ before,
84
+ after,
85
+ hit: false,
86
+ })
87
+ debug.timeEnd("preview:query:total")
88
+ return { parts: [], hasMoreBefore: false, hasMoreAfter: false }
89
+ }
90
+
91
+ const fetchBefore = Math.max(before * 4, before + 1, 30)
92
+ const fetchAfter = Math.max((after + 1) * 4, after + 2, 50)
93
+
94
+ debug.time("preview:query:before")
95
+ const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
96
+ SELECT p.id, p.message_id, p.session_id,
97
+ json_extract(m.data, '$.role') AS role,
98
+ json_extract(p.data, '$.type') AS type,
99
+ p.time_created, p.data
100
+ FROM part p
101
+ JOIN message m ON m.id = p.message_id
102
+ WHERE p.session_id = ?
103
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
104
+ ORDER BY p.time_created DESC, p.id DESC
105
+ LIMIT ?
106
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
107
+ debug.timeEnd("preview:query:before")
108
+
109
+ debug.time("preview:query:after")
110
+ const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
111
+ SELECT p.id, p.message_id, p.session_id,
112
+ json_extract(m.data, '$.role') AS role,
113
+ json_extract(p.data, '$.type') AS type,
114
+ p.time_created, p.data
115
+ FROM part p
116
+ JOIN message m ON m.id = p.message_id
117
+ WHERE p.session_id = ?
118
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
119
+ ORDER BY p.time_created ASC, p.id ASC
120
+ LIMIT ?
121
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
122
+ debug.timeEnd("preview:query:after")
123
+
124
+ debug.time("preview:query:parse")
125
+ const validBefore = beforeRows.filter(isPreviewRow)
126
+ const validAfter = afterRows.filter(isPreviewRow)
127
+ const parts = [
128
+ ...validBefore.slice(0, before).reverse(),
129
+ ...validAfter.slice(0, after + 1),
130
+ ].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
131
+ const page = {
132
+ parts,
133
+ hasMoreBefore: validBefore.length > before,
134
+ hasMoreAfter: validAfter.length > after + 1,
135
+ }
136
+ debug.log("preview:window", {
137
+ item: result.id,
138
+ session: result.sessionID,
139
+ mode: "around",
140
+ before,
141
+ after,
142
+ fetchBefore,
143
+ fetchAfter,
144
+ beforeRows: beforeRows.length,
145
+ validBefore: validBefore.length,
146
+ invalidBefore: previewRowBreakdown(beforeRows, validBefore),
147
+ afterRows: afterRows.length,
148
+ validAfter: validAfter.length,
149
+ invalidAfter: previewRowBreakdown(afterRows, validAfter),
150
+ parts: parts.length,
151
+ hasMoreBefore: page.hasMoreBefore,
152
+ hasMoreAfter: page.hasMoreAfter,
153
+ first: parts[0]?.id,
154
+ last: parts.at(-1)?.id,
155
+ })
156
+ debug.timeEnd("preview:query:parse")
157
+ debug.timeEnd("preview:query:total")
158
+ return page
159
+ }
160
+
161
+ export function loadConversationBefore(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
162
+ debug.time("preview:query:before-page")
163
+ const db = getDb(options?.dbPath)
164
+ const limit = options?.limit ?? 20
165
+ const fetchLimit = Math.max(limit * 4, limit + 1, 30)
166
+ const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
167
+ SELECT p.id, p.message_id, p.session_id,
168
+ json_extract(m.data, '$.role') AS role,
169
+ json_extract(p.data, '$.type') AS type,
170
+ p.time_created, p.data
171
+ FROM part p
172
+ JOIN message m ON m.id = p.message_id
173
+ WHERE p.session_id = ?
174
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
175
+ ORDER BY p.time_created DESC, p.id DESC
176
+ LIMIT ?
177
+ `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
178
+ const valid = rows.filter(isPreviewRow)
179
+ const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
180
+ const page = { parts, hasMoreBefore: valid.length > limit }
181
+ debug.log("preview:window", {
182
+ item: result.id,
183
+ session: result.sessionID,
184
+ mode: "before",
185
+ cursor,
186
+ limit,
187
+ rows: rows.length,
188
+ valid: valid.length,
189
+ invalid: previewRowBreakdown(rows, valid),
190
+ parts: parts.length,
191
+ hasMoreBefore: page.hasMoreBefore,
192
+ first: parts[0]?.id,
193
+ last: parts.at(-1)?.id,
194
+ })
195
+ debug.timeEnd("preview:query:before-page")
196
+ return page
197
+ }
198
+
199
+ export function loadConversationAfter(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
200
+ debug.time("preview:query:after-page")
201
+ const db = getDb(options?.dbPath)
202
+ const limit = options?.limit ?? 20
203
+ const fetchLimit = Math.max(limit * 4, limit + 1, 30)
204
+ const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
205
+ SELECT p.id, p.message_id, p.session_id,
206
+ json_extract(m.data, '$.role') AS role,
207
+ json_extract(p.data, '$.type') AS type,
208
+ p.time_created, p.data
209
+ FROM part p
210
+ JOIN message m ON m.id = p.message_id
211
+ WHERE p.session_id = ?
212
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id > ?))
213
+ ORDER BY p.time_created ASC, p.id ASC
214
+ LIMIT ?
215
+ `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
216
+ const valid = rows.filter(isPreviewRow)
217
+ const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
218
+ const page = { parts, hasMoreAfter: valid.length > limit }
219
+ debug.log("preview:window", {
220
+ item: result.id,
221
+ session: result.sessionID,
222
+ mode: "after",
223
+ cursor,
224
+ limit,
225
+ rows: rows.length,
226
+ valid: valid.length,
227
+ invalid: previewRowBreakdown(rows, valid),
228
+ parts: parts.length,
229
+ hasMoreAfter: page.hasMoreAfter,
230
+ first: parts[0]?.id,
231
+ last: parts.at(-1)?.id,
232
+ })
233
+ debug.timeEnd("preview:query:after-page")
234
+ return page
235
+ }
236
+
237
+ export async function performSearch(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): Promise<SearchResult[]> {
238
+ if (!query.trim()) return searchSessionMessages(query, options)
239
+ const config = parseSemanticConfig()
240
+ if (!config.disableVector) {
241
+ try {
242
+ return await semanticSearchSessionMessages(query, options)
243
+ } catch {
244
+ debug.log("query:hybrid:fallback", { message: "hybrid search failed, falling back to keyword" })
245
+ }
246
+ }
247
+ return searchSessionMessages(query, options)
248
+ }
249
+
250
+ export async function semanticSearchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }): Promise<SearchResult[]> {
251
+ const term = query.trim()
252
+ if (!term) return []
253
+ if (options?.dbPath === ":memory:") return []
254
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
255
+ const db = getDb(dbPath)
256
+ const limit = options?.limit ?? 80
257
+ const dir = options?.directory
258
+ const role = options?.role
259
+
260
+ const index = ensureSearchIndex(db, dbPath)
261
+ if (!index) return []
262
+
263
+ const keyword = indexedTextRows(db, dbPath, limit, term, dir, options?.offset, role) ?? []
264
+ const config = parseSemanticConfig()
265
+
266
+ let vector: Row[] = []
267
+ if (!config.disableVector) {
268
+ let vecState = getMeta(index, "vector_state")
269
+ if (vecState !== "enabled" && vecState !== "disabled" && vecState !== "stale") {
270
+ const indexPath = searchIndexPath(dbPath)
271
+ const lastAttempt = lastVectorRebuildAttempt.get(indexPath)
272
+ if (!lastAttempt || Date.now() - lastAttempt > 30_000) {
273
+ setupVectorTable(index, config, indexPath)
274
+ lastVectorRebuildAttempt.set(indexPath, Date.now())
275
+ vecState = getMeta(index, "vector_state") ?? "stale"
276
+ }
277
+ }
278
+ if (vecState === "stale" && getMeta(index, "embedding_dimensions")) {
279
+ try {
280
+ const indexPath = searchIndexPath(dbPath)
281
+ await vecExtensionLoading.get(indexPath)
282
+ const test = index.query("SELECT COUNT(*) as count FROM document_vec").get() as { count: number } | undefined
283
+ if (test && test.count > 0) {
284
+ setMeta(index, "vector_state", "enabled")
285
+ vecState = "enabled"
286
+ }
287
+ } catch {
288
+ debug.log("vector:stale:recovery-failed")
289
+ }
290
+ }
291
+ if (vecState === "enabled") {
292
+ try {
293
+ const indexPath = searchIndexPath(dbPath)
294
+ await vecExtensionLoading.get(indexPath)
295
+ const client = new LlamaEmbeddingClient({
296
+ baseUrl: config.embedBaseUrl,
297
+ model: config.embedModel,
298
+ documentPrefix: config.documentPrefix,
299
+ queryPrefix: config.queryPrefix,
300
+ })
301
+ const embedTerm = expandQuery(term)
302
+ const embedding = await client.embedQuery(embedTerm)
303
+ vector = searchVector(index, embedding, limit)
304
+ debug.log("query:vector:results", { count: vector.length, embedTerm: embedTerm !== term ? embedTerm : undefined })
305
+ } catch (err) {
306
+ debug.log("query:vector:error", err instanceof Error ? err.message : String(err))
307
+ }
308
+ }
309
+ }
310
+
311
+ const merged = keyword.length || vector.length
312
+ ? hybridBlend(keyword, vector, config.hybridAlpha)
313
+ : []
314
+
315
+ const results: SearchResult[] = []
316
+ const seen = new Set<string>()
317
+ for (const row of merged) {
318
+ const result = rowToSearchResult(row, term) ?? rowToVectorResult(row, row.vectorScore)
319
+ if (result) {
320
+ seen.add(row.id)
321
+ results.push(result)
322
+ }
323
+ }
324
+
325
+ for (const row of vector) {
326
+ if (!seen.has(row.id)) {
327
+ const result = rowToSearchResult(row, term) ?? rowToVectorResult(row)
328
+ if (result) results.push(result)
329
+ }
330
+ }
331
+
332
+ return results.slice(0, limit)
333
+ }
334
+
335
+ function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
336
+ if (!tableExists(db, "part") || !tableExists(db, "message")) return []
337
+ debug.time("query:sql")
338
+ const rows = indexedTextRows(db, dbPath, limit, query, directory, offset, role) ?? visibleTextRows(db, limit, query, directory, offset, role)
339
+ debug.timeEnd("query:sql")
340
+ debug.time("query:map")
341
+ const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
342
+ debug.timeEnd("query:map")
343
+ return results
344
+ }
345
+
346
+ function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number, role?: SearchRole) {
347
+ const match = ftsQuery(query)
348
+ if (!match) return []
349
+ try {
350
+ const index = ensureSearchIndex(db, dbPath)
351
+ if (!index) return []
352
+ const conditions = ["document_fts MATCH ?"]
353
+ const params: (string | number)[] = [match]
354
+ if (role) {
355
+ conditions.push("role = ?")
356
+ params.push(role)
357
+ }
358
+ if (directory) {
359
+ conditions.push("directory = ?")
360
+ params.push(directory)
361
+ }
362
+ params.push(limit)
363
+ if (offset) params.push(offset)
364
+ const offsetClause = offset ? "OFFSET ?" : ""
365
+ debug.time("query:fts:exec")
366
+ const rows = index.query<Row, (string | number)[]>(`
367
+ SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
368
+ CAST(time_created AS INTEGER) AS time_created, text
369
+ FROM document_fts
370
+ WHERE ${conditions.join(" AND ")}
371
+ ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
372
+ LIMIT ? ${offsetClause}
373
+ `).all(...params as any[])
374
+ debug.timeEnd("query:fts:exec")
375
+ return rows
376
+ } catch (err) {
377
+ debug.log("fts:fallback", err instanceof Error ? err.message : String(err))
378
+ return
379
+ }
380
+ }
381
+
382
+ function indexedRecentRows(db: Database, dbPath: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
383
+ try {
384
+ const index = ensureSearchIndex(db, dbPath, { rebuild: false, useStale: true })
385
+ if (!index) {
386
+ debug.log("query:recent:index:missing", { dbPath })
387
+ return
388
+ }
389
+
390
+ const rowCount = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_index").get()?.count ?? 0
391
+ if (rowCount === 0) {
392
+ debug.log("query:recent:index:empty", { dbPath })
393
+ return
394
+ }
395
+
396
+ const state = sourceState(db, dbPath)
397
+ const currentDataVersion = getMeta(index, "source_data_version")
398
+ const currentMtimeMs = getMeta(index, "source_mtime_ms")
399
+ const currentPath = getMeta(index, "source_path")
400
+ const currentIndexVersion = getMeta(index, "index_version")
401
+ if (currentPath !== dbPath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
402
+ debug.log("query:recent:index:stale", {
403
+ dbPath,
404
+ expectedDataVersion: state.dataVersion,
405
+ actualDataVersion: currentDataVersion,
406
+ })
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
435
+ } catch (err) {
436
+ debug.log("recent:index:fallback", err instanceof Error ? err.message : String(err))
437
+ return
438
+ }
439
+ }
440
+
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
+
451
+ if (directory) {
452
+ conditions.push("s.directory = ?")
453
+ params.push(directory)
454
+ }
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
+ params.push(limit)
463
+ 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
473
+ WHERE ${conditions.join(" AND ")}
474
+ ORDER BY p.time_created DESC
475
+ 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")
480
+ return rows
481
+ }
482
+
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(() => {}))
492
+ }
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)
505
+ }
506
+ return _indexDb
507
+ }
508
+
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?.()
526
+ }
527
+
528
+ function sourceState(db: Database, sourcePath: string) {
529
+ const stat = statSync(sourcePath)
530
+ const dataVersion = db.query<{ data_version: number }, []>("PRAGMA data_version").get()?.data_version ?? 0
531
+ return { dataVersion, mtimeMs: stat.mtimeMs }
532
+ }
533
+
534
+ function hashPartData(value: { session_id: string; message_id: string; part_id: string }) {
535
+ return createHash("sha256").update(`${value.session_id}:${value.message_id}:${value.part_id}`).digest("hex")
536
+ }
537
+
538
+ function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
539
+ debug.time("fts:rebuild")
540
+ const rows = source.query<IndexSourceRow, []>(`
541
+ SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
542
+ json_extract(m.data, '$.role') AS role,
543
+ json_extract(p.data, '$.type') AS part_type,
544
+ json_extract(p.data, '$.tool') AS tool,
545
+ p.time_created,
546
+ p.data
547
+ FROM part p
548
+ JOIN message m ON m.id = p.message_id
549
+ JOIN session s ON s.id = p.session_id
550
+ WHERE (
551
+ json_extract(p.data, '$.type') = 'text'
552
+ OR (
553
+ json_extract(p.data, '$.type') = 'tool'
554
+ AND json_extract(p.data, '$.tool') IN ('apply_patch', 'edit', 'write')
555
+ )
556
+ )
557
+ AND json_extract(m.data, '$.role') IN ('user', 'assistant')
558
+ ORDER BY p.time_created DESC
559
+ `).all()
560
+ const now = Date.now()
561
+ const config = parseSemanticConfig()
562
+ const insertDoc = index.query<unknown, [string, string, string, string, string, string, string, string, string | null, number, number, string, string, string, number]>(`
563
+ INSERT INTO document(doc_id, part_id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, chunk_index, text, source_hash, extractor_version, indexed_at)
564
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
565
+ `)
566
+ const insertFts = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
567
+ INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
568
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
569
+ `)
570
+ const insertIndex = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
571
+ INSERT INTO document_index(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
572
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
573
+ `)
574
+ index.exec("BEGIN IMMEDIATE")
575
+ try {
576
+ index.exec("DELETE FROM document")
577
+ index.exec("DELETE FROM document_fts")
578
+ index.exec("DELETE FROM document_index")
579
+ for (const row of rows.flatMap(indexSourceRowToRows)) {
580
+ const docID = `telescope:${row.session_id}:${row.message_id}:${row.id}:0`
581
+ const sourceHash = hashPartData({ session_id: row.session_id, message_id: row.message_id, part_id: row.id })
582
+ insertDoc.run(
583
+ docID,
584
+ row.id,
585
+ row.message_id,
586
+ row.session_id,
587
+ row.session_title ?? "Untitled session",
588
+ row.directory,
589
+ row.role,
590
+ row.part_type ?? "text",
591
+ row.tool ?? null,
592
+ row.time_created,
593
+ 0,
594
+ row.text,
595
+ sourceHash,
596
+ DOCUMENT_EXTRACTOR_VERSION,
597
+ now,
598
+ )
599
+ insertFts.run(
600
+ row.id,
601
+ row.message_id,
602
+ row.session_id,
603
+ row.session_title ?? "Untitled session",
604
+ row.directory,
605
+ row.role,
606
+ row.part_type ?? "text",
607
+ row.tool ?? null,
608
+ row.time_created,
609
+ row.text,
610
+ )
611
+ insertIndex.run(
612
+ row.id,
613
+ row.message_id,
614
+ row.session_id,
615
+ row.session_title ?? "Untitled session",
616
+ row.directory,
617
+ row.role,
618
+ row.part_type ?? "text",
619
+ row.tool ?? null,
620
+ row.time_created,
621
+ row.text,
622
+ )
623
+ }
624
+ setMeta(index, "source_path", sourcePath)
625
+ setMeta(index, "source_data_version", String(state.dataVersion))
626
+ setMeta(index, "source_mtime_ms", String(state.mtimeMs))
627
+ setMeta(index, "index_version", SEARCH_INDEX_VERSION)
628
+ setMeta(index, "schema_version", "2")
629
+ setMeta(index, "extractor_version", DOCUMENT_EXTRACTOR_VERSION)
630
+ setMeta(index, "ranking_version", "1")
631
+ setMeta(index, "embedding_base_url", config.embedBaseUrl)
632
+ setMeta(index, "document_prefix", config.documentPrefix)
633
+ setMeta(index, "query_prefix", config.queryPrefix)
634
+ if (config.embedModel) setMeta(index, "embedding_model", config.embedModel)
635
+ index.exec("COMMIT")
636
+ } catch (err) {
637
+ index.exec("ROLLBACK")
638
+ throw err
639
+ } finally {
640
+ debug.timeEnd("fts:rebuild")
641
+ }
642
+
643
+ if (config.disableVector) {
644
+ setMeta(index, "vector_state", "disabled")
645
+ }
646
+ }
647
+
648
+ function isPreviewRow(row: ConversationRow) {
649
+ return (row.role === "user" || row.role === "assistant") &&
650
+ (row.type === "text" || row.type === "reasoning" || row.type === "tool")
651
+ }
652
+
653
+ function previewRowBreakdown(rows: ConversationRow[], validRows: ConversationRow[]) {
654
+ const valid = new Set(validRows.map((row) => row.id))
655
+ const invalidRows = rows.filter((row) => !valid.has(row.id))
656
+ if (invalidRows.length === 0) return undefined
657
+ const byRole = countBy(invalidRows, (row) => String(row.role ?? "unknown"))
658
+ const byType = countBy(invalidRows, (row) => String(row.type ?? "unknown"))
659
+ return { count: invalidRows.length, byRole, byType }
660
+ }
661
+
662
+ function parseConversationPart(row: ConversationRow, target: boolean): ConversationPreviewPart | undefined {
663
+ const data = parsePartData(row.data)
664
+ if (!data) return
665
+ if (row.type === "tool") {
666
+ return {
667
+ id: row.id,
668
+ messageID: row.message_id,
669
+ sessionID: row.session_id,
670
+ role: row.role,
671
+ type: row.type,
672
+ timeCreated: row.time_created,
673
+ text: target ? extractToolIndexText(data).trim() : "",
674
+ tool: typeof data.tool === "string" ? data.tool : "tool",
675
+ state: parseToolState(data.state),
676
+ target,
677
+ }
678
+ }
679
+ const text = typeof data.text === "string" ? data.text.trim() : ""
680
+ if (!text) return
681
+ return {
682
+ id: row.id,
683
+ messageID: row.message_id,
684
+ sessionID: row.session_id,
685
+ role: row.role,
686
+ type: row.type,
687
+ timeCreated: row.time_created,
688
+ text,
689
+ target,
690
+ }
691
+ }
692
+
693
+ function parsePartData(data: string) {
694
+ try {
695
+ const value = JSON.parse(data) as unknown
696
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
697
+ return value as Record<string, unknown>
698
+ } catch {
699
+ return
700
+ }
701
+ }
702
+
703
+ function parseToolState(value: unknown) {
704
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
705
+ const state = value as Record<string, unknown>
706
+ if (!["pending", "running", "completed", "error"].includes(String(state.status))) return
707
+ return {
708
+ status: state.status as "pending" | "running" | "completed" | "error",
709
+ input: state.input,
710
+ metadata: state.metadata,
711
+ output: typeof state.output === "string" ? state.output : undefined,
712
+ error: typeof state.error === "string" ? state.error : undefined,
713
+ }
714
+ }
715
+
716
+ function extractToolIndexText(part: Record<string, unknown>) {
717
+ const tool = typeof part.tool === "string" ? part.tool : ""
718
+ const state = recordValue(part.state)
719
+ const input = recordValue(state?.input)
720
+ const metadata = recordValue(state?.metadata)
721
+
722
+ if (tool === "apply_patch") {
723
+ const files = Array.isArray(metadata?.files) ? metadata.files : []
724
+ const renderedPatches = files.map(applyPatchFileIndexText).filter(Boolean).join("\n")
725
+ const patchText = stringValue(input?.patchText)
726
+ return [renderedPatches, patchText].filter(Boolean).join("\n")
727
+ }
728
+
729
+ if (tool === "edit") {
730
+ const filediff = recordValue(metadata?.filediff)
731
+ return [
732
+ stringValue(input?.filePath),
733
+ stringValue(metadata?.diff),
734
+ stringValue(filediff?.patch),
735
+ stringValue(input?.oldString),
736
+ stringValue(input?.newString),
737
+ ].filter(Boolean).join("\n")
738
+ }
739
+
740
+ if (tool === "write") {
741
+ return [stringValue(input?.filePath), stringValue(input?.content)].filter(Boolean).join("\n")
742
+ }
743
+
744
+ return ""
745
+ }
746
+
747
+ function applyPatchFileIndexText(value: unknown) {
748
+ const file = recordValue(value)
749
+ if (!file) return ""
750
+ return [
751
+ stringValue(file.filePath),
752
+ stringValue(file.relativePath),
753
+ stringValue(file.patch),
754
+ ].filter(Boolean).join("\n")
755
+ }
756
+
757
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
758
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
759
+ return value as Record<string, unknown>
760
+ }
761
+
762
+ function stringValue(value: unknown) {
763
+ return typeof value === "string" ? value : undefined
764
+ }
765
+
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
+ function countBy<T>(items: T[], key: (item: T) => string) {
775
+ const counts: Record<string, number> = {}
776
+ for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1
777
+ return counts
778
+ }
779
+
780
+ export function parseSemanticConfig(env: Record<string, string | undefined> = process.env): SemanticConfig {
781
+ return {
782
+ embedBaseUrl: env.OPENCODE_TELESCOPE_EMBED_BASE_URL ?? "http://127.0.0.1:8081",
783
+ embedModel: env.OPENCODE_TELESCOPE_EMBED_MODEL || undefined,
784
+ disableVector: env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "1" || env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "true",
785
+ sqliteLibPath: env.OPENCODE_TELESCOPE_SQLITE_LIB || undefined,
786
+ sqliteVecExtension: env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT || undefined,
787
+ hybridAlpha: parseAlpha(env.OPENCODE_TELESCOPE_HYBRID_ALPHA),
788
+ documentPrefix: "search_document: ",
789
+ queryPrefix: "search_query: ",
790
+ }
791
+ }
792
+
793
+ function parseAlpha(value: string | undefined) {
794
+ if (!value) return 0.45
795
+ const parsed = Number(value)
796
+ if (!Number.isFinite(parsed)) return 0.45
797
+ if (parsed < 0) return 0
798
+ if (parsed > 1) return 1
799
+ return parsed
800
+ }