@bojackduy/opencode-telescope 0.1.24 → 0.1.25

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,120 @@
1
+ import { Database } from "bun:sqlite"
2
+
3
+ export const SEARCH_INDEX_VERSION = "7"
4
+ export const DOCUMENT_EXTRACTOR_VERSION = "1"
5
+
6
+ export function migrateSearchIndex(db: Database) {
7
+ db.exec(`
8
+ CREATE TABLE IF NOT EXISTS index_meta(
9
+ key TEXT PRIMARY KEY,
10
+ value TEXT NOT NULL
11
+ );
12
+ CREATE TABLE IF NOT EXISTS document(
13
+ rowid INTEGER PRIMARY KEY,
14
+ doc_id TEXT UNIQUE NOT NULL,
15
+ part_id TEXT NOT NULL,
16
+ message_id TEXT NOT NULL,
17
+ session_id TEXT NOT NULL,
18
+ session_title TEXT NOT NULL,
19
+ directory TEXT NOT NULL,
20
+ role TEXT NOT NULL,
21
+ part_type TEXT NOT NULL,
22
+ tool TEXT,
23
+ time_created INTEGER NOT NULL,
24
+ chunk_index INTEGER NOT NULL,
25
+ text TEXT NOT NULL,
26
+ source_hash TEXT NOT NULL,
27
+ extractor_version TEXT NOT NULL,
28
+ indexed_at INTEGER NOT NULL
29
+ );
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS document_fts USING fts5(
31
+ id UNINDEXED,
32
+ message_id UNINDEXED,
33
+ session_id UNINDEXED,
34
+ session_title,
35
+ directory UNINDEXED,
36
+ role UNINDEXED,
37
+ part_type UNINDEXED,
38
+ tool UNINDEXED,
39
+ time_created UNINDEXED,
40
+ text,
41
+ tokenize='unicode61'
42
+ );
43
+ CREATE TABLE IF NOT EXISTS document_index(
44
+ id TEXT PRIMARY KEY,
45
+ message_id TEXT NOT NULL,
46
+ session_id TEXT NOT NULL,
47
+ session_title TEXT NOT NULL,
48
+ directory TEXT NOT NULL,
49
+ role TEXT NOT NULL,
50
+ part_type TEXT NOT NULL,
51
+ tool TEXT,
52
+ time_created INTEGER NOT NULL,
53
+ text TEXT NOT NULL
54
+ );
55
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
56
+ ON document_index(directory, role, time_created DESC);
57
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
58
+ ON document_index(directory, role, part_type, time_created DESC);
59
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
60
+ ON document_index(time_created DESC);
61
+ `)
62
+
63
+ const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
64
+ if (!columns.includes("part_type") || !columns.includes("tool")) {
65
+ db.exec("DROP TABLE document_fts")
66
+ db.exec(`
67
+ CREATE VIRTUAL TABLE document_fts USING fts5(
68
+ id UNINDEXED,
69
+ message_id UNINDEXED,
70
+ session_id UNINDEXED,
71
+ session_title,
72
+ directory UNINDEXED,
73
+ role UNINDEXED,
74
+ part_type UNINDEXED,
75
+ tool UNINDEXED,
76
+ time_created UNINDEXED,
77
+ text,
78
+ tokenize='unicode61'
79
+ );
80
+ `)
81
+ }
82
+
83
+ const indexColumns = db.query<{ name: string }, []>("PRAGMA table_info(document_index)").all().map((column) => column.name)
84
+ if (!["part_type", "tool", "time_created", "text"].every((name) => indexColumns.includes(name))) {
85
+ db.exec("DROP TABLE IF EXISTS document_index")
86
+ db.exec(`
87
+ CREATE TABLE document_index(
88
+ id TEXT PRIMARY KEY,
89
+ message_id TEXT NOT NULL,
90
+ session_id TEXT NOT NULL,
91
+ session_title TEXT NOT NULL,
92
+ directory TEXT NOT NULL,
93
+ role TEXT NOT NULL,
94
+ part_type TEXT NOT NULL,
95
+ tool TEXT,
96
+ time_created INTEGER NOT NULL,
97
+ text TEXT NOT NULL
98
+ );
99
+ `)
100
+ }
101
+ db.exec(`
102
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
103
+ ON document_index(directory, role, time_created DESC);
104
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
105
+ ON document_index(directory, role, part_type, time_created DESC);
106
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
107
+ ON document_index(time_created DESC);
108
+ `)
109
+ }
110
+
111
+ export function getMeta(db: Database, key: string) {
112
+ return db.query<{ value: string }, [string]>("SELECT value FROM index_meta WHERE key = ?").get(key)?.value
113
+ }
114
+
115
+ export function setMeta(db: Database, key: string, value: string) {
116
+ db.query<unknown, [string, string]>(`
117
+ INSERT INTO index_meta(key, value) VALUES (?, ?)
118
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
119
+ `).run(key, value)
120
+ }
package/search/text.ts ADDED
@@ -0,0 +1,348 @@
1
+ import type { IndexSourceRow, Row, SearchResult } from "./types.ts"
2
+
3
+ export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
4
+ const text = row.text.trim()
5
+ const match = findMatch(text, query)
6
+ if (!match) return
7
+ const preview = focusedPreview(text, match.start, match.end)
8
+ return {
9
+ id: row.id,
10
+ messageID: row.message_id,
11
+ sessionID: row.session_id,
12
+ sessionTitle: row.session_title || "Untitled session",
13
+ directory: row.directory,
14
+ role: row.role,
15
+ partType: row.part_type ?? "text",
16
+ tool: row.tool ?? undefined,
17
+ timeCreated: row.time_created,
18
+ snippet: makeSnippet(text, query),
19
+ matchStart: match.start,
20
+ matchEnd: match.end,
21
+ before: match.before,
22
+ match: match.match,
23
+ after: match.after,
24
+ excerpt: match.excerpt,
25
+ previewBefore: preview.before,
26
+ previewMatch: preview.match,
27
+ previewAfter: preview.after,
28
+ previewMode: preview.mode,
29
+ previewHighlight: preview.highlight,
30
+ text,
31
+ isVectorMatch: false,
32
+ semanticScore: 0,
33
+ }
34
+ }
35
+
36
+ export function rowToVectorResult(row: Row, vectorScore = 0): SearchResult | undefined {
37
+ const text = row.text.trim()
38
+ if (!text) return
39
+ const excerpt = text.slice(0, 200)
40
+ return {
41
+ id: row.id,
42
+ messageID: row.message_id,
43
+ sessionID: row.session_id,
44
+ sessionTitle: row.session_title || "Untitled session",
45
+ directory: row.directory,
46
+ role: row.role,
47
+ partType: row.part_type ?? "text",
48
+ tool: row.tool ?? undefined,
49
+ timeCreated: row.time_created,
50
+ snippet: excerpt,
51
+ matchStart: -1,
52
+ matchEnd: -1,
53
+ before: "",
54
+ match: "",
55
+ after: excerpt,
56
+ excerpt,
57
+ previewBefore: text.slice(0, Math.min(text.length, 1400)),
58
+ previewMatch: "",
59
+ previewAfter: "",
60
+ previewMode: "markdown" as const,
61
+ previewHighlight: false,
62
+ text,
63
+ isVectorMatch: true,
64
+ semanticScore: vectorScore,
65
+ }
66
+ }
67
+
68
+ export function makeSnippet(text: string, query: string, radius = 72) {
69
+ const haystack = text.replace(/\s+/g, " ").trim()
70
+ const tokens = query.trim().split(/\s+/)
71
+ const lower = haystack.toLowerCase()
72
+ let searchPos = 0
73
+ let firstIndex = -1
74
+ let lastEnd = 0
75
+ for (const token of tokens) {
76
+ const index = lower.indexOf(token.toLowerCase(), searchPos)
77
+ if (index === -1) return truncate(haystack, radius * 2)
78
+ if (firstIndex === -1) firstIndex = index
79
+ searchPos = index + token.length
80
+ lastEnd = searchPos
81
+ }
82
+ const start = Math.max(0, firstIndex - radius)
83
+ const end = Math.min(haystack.length, lastEnd + radius)
84
+ return `${start > 0 ? "..." : ""}${haystack.slice(start, end)}${end < haystack.length ? "..." : ""}`
85
+ }
86
+
87
+ export function extractSearchText(data: string) {
88
+ try {
89
+ return extractFromValue(JSON.parse(data)).replace(/\s+/g, " ").trim()
90
+ } catch {
91
+ return data.replace(/\s+/g, " ").trim()
92
+ }
93
+ }
94
+
95
+ export function extractIndexText(data: string) {
96
+ try {
97
+ const value = JSON.parse(data) as unknown
98
+ if (!value || typeof value !== "object" || Array.isArray(value)) return ""
99
+ const record = value as Record<string, unknown>
100
+ if (record.type === "text") return typeof record.text === "string" ? record.text.trim() : ""
101
+ if (record.type !== "tool") return ""
102
+ return extractToolIndexText(record).replace(/\s+/g, " ").trim()
103
+ } catch {
104
+ return ""
105
+ }
106
+ }
107
+
108
+ export function indexSourceRowToRows(row: IndexSourceRow): Row[] {
109
+ const text = extractIndexText(row.data)
110
+ if (!text) return []
111
+ return [{ ...row, text }]
112
+ }
113
+
114
+ export function ftsQuery(query: string) {
115
+ const tokens = query.trim().split(/\s+/)
116
+ .map((token) => token.replace(/["*^:()]/g, " ").trim())
117
+ .filter(Boolean)
118
+ if (!tokens.length) return ""
119
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" AND ")
120
+ }
121
+
122
+ function findMatch(text: string, query: string, radius = 96) {
123
+ const needle = query.trim()
124
+ if (!needle) {
125
+ const collapsed = text.replace(/\s+/g, " ").trim()
126
+ const end = Math.min(collapsed.length, radius * 2)
127
+ return {
128
+ start: 0,
129
+ end: 0,
130
+ before: "",
131
+ match: "",
132
+ after: collapsed.slice(0, end),
133
+ excerpt: collapsed.slice(0, end),
134
+ }
135
+ }
136
+ const tokens = needle.split(/\s+/)
137
+ const lowerText = text.toLowerCase()
138
+ let searchPos = 0
139
+ let firstStart = -1
140
+ let lastEnd = -1
141
+ for (const token of tokens) {
142
+ const pos = lowerText.indexOf(token.toLowerCase(), searchPos)
143
+ if (pos === -1) return
144
+ if (firstStart === -1) firstStart = pos
145
+ searchPos = pos + token.length
146
+ lastEnd = searchPos
147
+ }
148
+ const start = firstStart
149
+ const end = lastEnd
150
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1
151
+ const nextLine = text.indexOf("\n", end)
152
+ const lineEnd = nextLine === -1 ? text.length : nextLine
153
+ const line = text.slice(lineStart, lineEnd)
154
+ const matchLen = end - start
155
+ const lineMatchStart = Math.max(0, start - lineStart)
156
+ const lineMatchEnd = lineMatchStart + matchLen
157
+ const excerptStart = Math.max(0, lineMatchStart - radius)
158
+ const excerptEnd = Math.min(line.length, lineMatchEnd + radius)
159
+ const before = normalizeSnippetSegment(line.slice(excerptStart, lineMatchStart))
160
+ const after = normalizeSnippetSegment(line.slice(lineMatchEnd, excerptEnd))
161
+ return {
162
+ start,
163
+ end,
164
+ before: `${excerptStart > 0 ? "..." : ""}${before}`,
165
+ match: text.slice(start, end),
166
+ after: `${after}${excerptEnd < line.length ? "..." : ""}`,
167
+ excerpt: `${excerptStart > 0 ? "..." : ""}${normalizeSnippetSegment(line.slice(excerptStart, excerptEnd))}${excerptEnd < line.length ? "..." : ""}`,
168
+ }
169
+ }
170
+
171
+ function focusedPreview(text: string, matchStart: number, matchEnd: number) {
172
+ if (matchStart === matchEnd) {
173
+ const preview = text.slice(0, Math.min(text.length, 1400))
174
+ return { before: preview, match: "", after: "", mode: "markdown" as const, highlight: false }
175
+ }
176
+
177
+ const lineStart = text.lastIndexOf("\n", matchStart - 1) + 1
178
+ const nextLine = text.indexOf("\n", matchEnd)
179
+ const lineEnd = nextLine === -1 ? text.length : nextLine
180
+ const line = text.slice(lineStart, lineEnd)
181
+
182
+ if (line.length > 260) {
183
+ const beforeStart = Math.max(0, matchStart - 180)
184
+ const afterEnd = Math.min(text.length, matchEnd + 220)
185
+ return {
186
+ before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, matchStart)}`,
187
+ match: text.slice(matchStart, matchEnd),
188
+ after: `${text.slice(matchEnd, afterEnd)}${afterEnd < text.length ? "..." : ""}`,
189
+ mode: "text" as const,
190
+ highlight: true,
191
+ }
192
+ }
193
+
194
+ const window = lineWindow(text, matchStart, matchEnd, 40, 80)
195
+ const windowText = text.slice(window.start, window.end)
196
+ const relativeStart = matchStart - window.start
197
+ const relativeEnd = matchEnd - window.start
198
+
199
+ const insideCodeFence = isInsideCodeFence(text, matchStart)
200
+ return {
201
+ before: `${window.start > 0 ? "...\n" : ""}${windowText.slice(0, relativeStart)}`,
202
+ match: windowText.slice(relativeStart, relativeEnd),
203
+ after: `${windowText.slice(relativeEnd)}${window.end < text.length ? "\n..." : ""}`,
204
+ mode: "markdown" as const,
205
+ highlight: !insideCodeFence,
206
+ }
207
+ }
208
+
209
+ function lineWindow(text: string, matchStart: number, matchEnd: number, before: number, after: number) {
210
+ const starts = [0]
211
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) {
212
+ starts.push(index + 1)
213
+ }
214
+ const matchLine = findLastStartIndex(starts, matchStart)
215
+ const startLine = Math.max(0, matchLine - before)
216
+ const endLine = Math.min(starts.length - 1, matchLine + after)
217
+ const start = starts[startLine] ?? 0
218
+ const end = starts[endLine + 1] ? starts[endLine + 1] - 1 : text.length
219
+ return { start, end: Math.max(end, matchEnd) }
220
+ }
221
+
222
+ function findLastStartIndex(starts: number[], offset: number) {
223
+ for (let index = starts.length - 1; index >= 0; index--) {
224
+ if (starts[index]! <= offset) return index
225
+ }
226
+ return 0
227
+ }
228
+
229
+ function isInsideCodeFence(text: string, offset: number) {
230
+ const before = text.slice(0, offset)
231
+ const fences = before.match(/^```/gm)
232
+ return Boolean(fences && fences.length % 2 === 1)
233
+ }
234
+
235
+ function normalizeSnippetSegment(value: string) {
236
+ return value.replace(/\s+/g, " ")
237
+ }
238
+
239
+ function extractFromValue(value: unknown): string {
240
+ if (typeof value === "string") return value
241
+ if (typeof value === "number" || typeof value === "boolean") return String(value)
242
+ if (!value) return ""
243
+ if (Array.isArray(value)) return value.map(extractFromValue).filter(Boolean).join("\n")
244
+ if (typeof value !== "object") return ""
245
+ return Object.entries(value)
246
+ .filter(([key]) => !["id", "sessionID", "messageID", "time", "timeCreated", "timeUpdated", "tokens", "cost"].includes(key))
247
+ .map(([, item]) => extractFromValue(item))
248
+ .filter(Boolean)
249
+ .join("\n")
250
+ }
251
+
252
+ function extractToolIndexText(part: Record<string, unknown>) {
253
+ const tool = typeof part.tool === "string" ? part.tool : ""
254
+ const state = recordValue(part.state)
255
+ const input = recordValue(state?.input)
256
+ const metadata = recordValue(state?.metadata)
257
+
258
+ if (tool === "apply_patch") {
259
+ const files = Array.isArray(metadata?.files) ? metadata.files : []
260
+ const renderedPatches = files.map(applyPatchFileIndexText).filter(Boolean).join("\n")
261
+ const patchText = stringValue(input?.patchText)
262
+ return [renderedPatches, patchText].filter(Boolean).join("\n")
263
+ }
264
+
265
+ if (tool === "edit") {
266
+ const filediff = recordValue(metadata?.filediff)
267
+ return [
268
+ stringValue(input?.filePath),
269
+ stringValue(metadata?.diff),
270
+ stringValue(filediff?.patch),
271
+ stringValue(input?.oldString),
272
+ stringValue(input?.newString),
273
+ ].filter(Boolean).join("\n")
274
+ }
275
+
276
+ if (tool === "write") {
277
+ return [stringValue(input?.filePath), stringValue(input?.content)].filter(Boolean).join("\n")
278
+ }
279
+
280
+ return ""
281
+ }
282
+
283
+ function applyPatchFileIndexText(value: unknown) {
284
+ const file = recordValue(value)
285
+ if (!file) return ""
286
+ return [
287
+ stringValue(file.filePath),
288
+ stringValue(file.relativePath),
289
+ stringValue(file.patch),
290
+ ].filter(Boolean).join("\n")
291
+ }
292
+
293
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
294
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
295
+ return value as Record<string, unknown>
296
+ }
297
+
298
+ function stringValue(value: unknown) {
299
+ return typeof value === "string" ? value : undefined
300
+ }
301
+
302
+ function truncate(value: string, length: number) {
303
+ if (value.length <= length) return value
304
+ return `${value.slice(0, length - 3)}...`
305
+ }
306
+
307
+ const synonymMap: Record<string, string[]> = {
308
+ greeting: ["hello", "hi", "hey", "howdy", "greetings", "good morning", "good afternoon", "good evening"],
309
+ hello: ["hi", "hey", "howdy", "greeting", "greetings"],
310
+ hi: ["hello", "hey", "howdy", "greeting"],
311
+ hey: ["hello", "hi", "howdy", "greeting"],
312
+ thanks: ["thank you", "thx", "ty", "appreciate", "grateful"],
313
+ goodbye: ["bye", "see you", "farewell", "later", "cya"],
314
+ bye: ["goodbye", "see you", "farewell", "later"],
315
+ error: ["bug", "issue", "failed", "failure", "problem", "exception"],
316
+ bug: ["error", "issue", "problem", "defect"],
317
+ fix: ["repair", "patch", "resolve", "correct", "solve", "bugfix"],
318
+ help: ["assist", "support", "guide", "how to", "tutorial"],
319
+ explain: ["describe", "clarify", "elaborate", "what is", "how does"],
320
+ code: ["program", "source", "implementation", "script", "function"],
321
+ test: ["spec", "unit test", "assertion", "verify", "check"],
322
+ refactor: ["restructure", "rewrite", "improve", "reorganize", "clean up"],
323
+ optimize: ["improve", "speed up", "performance", "efficient", "fast"],
324
+ config: ["configuration", "setting", "option", "setup"],
325
+ deploy: ["release", "publish", "ship", "rollout", "launch"],
326
+ security: ["auth", "permission", "access", "vulnerability", "secure"],
327
+ database: ["db", "sql", "query", "schema", "storage", "persist"],
328
+ }
329
+
330
+ export function expandQuery(query: string): string {
331
+ const trimmed = query.trim()
332
+ const tokens = trimmed.toLowerCase().split(/\s+/).filter(Boolean)
333
+ if (tokens.length > 1) return trimmed
334
+ const word = tokens[0]
335
+ if (!word) return trimmed
336
+ const synonyms = synonymMap[word]
337
+ if (!synonyms) return trimmed
338
+ const seen = new Set([word])
339
+ const expanded = [trimmed]
340
+ for (const syn of synonyms) {
341
+ const key = syn.toLowerCase()
342
+ if (!seen.has(key)) {
343
+ expanded.push(syn)
344
+ seen.add(key)
345
+ }
346
+ }
347
+ return expanded.join(" ")
348
+ }
@@ -0,0 +1,131 @@
1
+ export type SearchResult = {
2
+ id: string
3
+ messageID: string
4
+ sessionID: string
5
+ sessionTitle: string
6
+ directory: string
7
+ role: "user" | "assistant"
8
+ partType: "text" | "reasoning" | "tool"
9
+ tool?: string
10
+ timeCreated: number
11
+ snippet: string
12
+ matchStart: number
13
+ matchEnd: number
14
+ before: string
15
+ match: string
16
+ after: string
17
+ excerpt: string
18
+ previewBefore: string
19
+ previewMatch: string
20
+ previewAfter: string
21
+ previewMode: "markdown" | "text"
22
+ previewHighlight: boolean
23
+ text: string
24
+ isVectorMatch: boolean
25
+ semanticScore: number
26
+ }
27
+
28
+ export type SearchRole = "user" | "assistant"
29
+
30
+ export type ConversationPreviewPart = {
31
+ id: string
32
+ messageID: string
33
+ sessionID: string
34
+ role: "user" | "assistant"
35
+ type: "text" | "reasoning" | "tool"
36
+ timeCreated: number
37
+ text: string
38
+ tool?: string
39
+ state?: ToolState
40
+ target: boolean
41
+ }
42
+
43
+ export type ConversationPreviewPage = {
44
+ parts: ConversationPreviewPart[]
45
+ hasMoreBefore: boolean
46
+ hasMoreAfter: boolean
47
+ }
48
+
49
+ export type ConversationPreviewCursor = {
50
+ id: string
51
+ timeCreated: number
52
+ }
53
+
54
+ export type ToolState = {
55
+ status: "pending" | "running" | "completed" | "error"
56
+ input?: unknown
57
+ metadata?: unknown
58
+ output?: string
59
+ error?: string
60
+ }
61
+
62
+ export type SemanticConfig = {
63
+ embedBaseUrl: string
64
+ embedModel?: string
65
+ disableVector: boolean
66
+ sqliteLibPath?: string
67
+ sqliteVecExtension?: string
68
+ hybridAlpha: number
69
+ documentPrefix: string
70
+ queryPrefix: string
71
+ }
72
+
73
+ export type VectorState = "enabled" | "disabled" | "unavailable" | "stale"
74
+
75
+ export type ScoredRow = Row & {
76
+ score: number
77
+ keywordScore: number
78
+ vectorScore: number
79
+ }
80
+
81
+ export type HybridSearchOptions = {
82
+ limit?: number
83
+ offset?: number
84
+ directory?: string
85
+ role?: SearchRole
86
+ dbPath?: string
87
+ }
88
+
89
+ export type DocumentRow = {
90
+ doc_id: string
91
+ part_id: string
92
+ message_id: string
93
+ session_id: string
94
+ session_title: string
95
+ directory: string
96
+ role: string
97
+ part_type: string
98
+ tool: string | null
99
+ time_created: number
100
+ chunk_index: number
101
+ text: string
102
+ source_hash: string
103
+ extractor_version: string
104
+ }
105
+
106
+ export type Row = {
107
+ id: string
108
+ message_id: string
109
+ session_id: string
110
+ session_title: string | null
111
+ directory: string
112
+ role: SearchRole
113
+ part_type?: SearchResult["partType"]
114
+ tool?: string | null
115
+ time_created: number
116
+ text: string
117
+ }
118
+
119
+ export type IndexSourceRow = Omit<Row, "text"> & {
120
+ data: string
121
+ }
122
+
123
+ export type ConversationRow = {
124
+ id: string
125
+ message_id: string
126
+ session_id: string
127
+ role: SearchRole
128
+ type: "text" | "reasoning" | "tool"
129
+ time_created: number
130
+ data: string
131
+ }