@bojackduy/opencode-telescope 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # opencode-telescope
2
2
 
3
- OpenCode TUI plugin for fuzzy-searching local conversation history, session transcripts, and past AI coding chats.
3
+ OpenCode TUI plugin for fuzzy and semantic search across local conversation history, session transcripts, and past AI coding chats.
4
4
 
5
- Install the npm package `@bojackduy/opencode-telescope` to grep through OpenCode chat history, find old code snippets, and jump back to any session instantly.
5
+ Install the npm package `@bojackduy/opencode-telescope` to grep OpenCode chat history, find old code snippets, search by meaning with local embeddings, and jump back to any session instantly.
6
6
 
7
7
  > Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) — a fuzzy finder for your conversation history.
8
8
 
@@ -18,6 +18,7 @@ Install the npm package `@bojackduy/opencode-telescope` to grep through OpenCode
18
18
  ## Use cases
19
19
 
20
20
  - **"I know I discussed this somewhere"** — grep all your sessions by keyword
21
+ - **"What was that auth caching idea?"** — semantic search can find related chats even when the exact words differ
21
22
  - **"Find that code snippet"** — search for code you saw in a past LLM response
22
23
  - **"Revisit a decision"** — find the conversation where you chose approach X
23
24
  - **"Session journal"** — browse your entire conversation history like a timeline
@@ -25,6 +26,8 @@ Install the npm package `@bojackduy/opencode-telescope` to grep through OpenCode
25
26
  ## Features
26
27
 
27
28
  - **Fuzzy grep** — search across all session messages by text
29
+ - **Semantic memory search** — optional local vector search with `llama-server` embeddings and `sqlite-vec`
30
+ - **Hybrid ranking** — blends keyword/FTS matches with semantic vector results when vector search is ready
28
31
  - **Live preview** — preview the matched conversation result before opening
29
32
  - **Find & jump** — select any result and jump straight to that session
30
33
  - **Neovim Telescope-style UX** — familiar `<leader>f` keybind and `/telescope` command
@@ -59,6 +62,53 @@ Add the plugin to your `tui.json`:
59
62
  | Open | Press `Enter` to jump to the selected session |
60
63
  | Owner filter | Press `o` to cycle `all` / `you` / `assistant` |
61
64
 
65
+ ## Semantic Search
66
+
67
+ Semantic search is optional. Keyword and fuzzy search work out of the box; when vector dependencies are available, Telescope builds a local sidecar vector index and uses hybrid ranking so meaning-based matches can appear even when the query does not share exact words with the original chat.
68
+
69
+ The semantic path stays local-first:
70
+
71
+ - OpenCode's SQLite database is opened read-only.
72
+ - Derived keyword and vector indexes are stored in Telescope's sidecar database.
73
+ - Embeddings are generated through your local `llama-server` endpoint.
74
+ - If embeddings or `sqlite-vec` are unavailable, Telescope falls back to keyword search.
75
+
76
+ Quick setup:
77
+
78
+ ```bash
79
+ mkdir -p "$HOME/.local/share/opencode-telescope/models"
80
+
81
+ huggingface-cli download \
82
+ nomic-ai/nomic-embed-text-v1.5-GGUF \
83
+ nomic-embed-text-v1.5.f16.gguf \
84
+ --local-dir "$HOME/.local/share/opencode-telescope/models" \
85
+ --local-dir-use-symlinks false
86
+
87
+ llama-server \
88
+ -m "$HOME/.local/share/opencode-telescope/models/nomic-embed-text-v1.5.f16.gguf" \
89
+ --embedding \
90
+ --pooling mean \
91
+ -c 8192 \
92
+ -ub 8192 \
93
+ --host 127.0.0.1 \
94
+ --port 8081
95
+ ```
96
+
97
+ Then launch OpenCode normally. Telescope uses `http://127.0.0.1:8081` by default and will rebuild the vector sidecar from your local session history.
98
+
99
+ Useful environment variables:
100
+
101
+ ```bash
102
+ OPENCODE_TELESCOPE_EMBED_BASE_URL=http://127.0.0.1:8081
103
+ OPENCODE_TELESCOPE_EMBED_MODEL=nomic-embed-text-v1.5
104
+ OPENCODE_TELESCOPE_HYBRID_ALPHA=0.45
105
+ OPENCODE_TELESCOPE_DISABLE_VECTOR=1
106
+ OPENCODE_TELESCOPE_SQLITE_LIB=/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib
107
+ OPENCODE_TELESCOPE_SQLITE_VEC_EXT=/path/to/vec0
108
+ ```
109
+
110
+ See the full tutorial in [`docs/semantic-search.md`](./docs/semantic-search.md).
111
+
62
112
  ## Configuration
63
113
 
64
114
  Telescope reads optional plugin-specific config from:
@@ -98,10 +148,10 @@ Key strings support simple names like `j`, `k`, `down`, `up`, `enter`, `return`,
98
148
 
99
149
  ## How it works
100
150
 
101
- Reads the OpenCode local SQLite session database in read-only mode, parses conversations into searchable text, and opens the selected session through the existing TUI route.
151
+ Reads the OpenCode local SQLite session database in read-only mode, parses conversations into searchable text, builds a Telescope-owned sidecar index, and opens the selected session through the existing TUI route. Optional semantic search adds local embeddings and `sqlite-vec` vector retrieval on top of the same sidecar.
102
152
 
103
153
  ## Keywords
104
154
 
105
- OpenCode plugin, OpenCode TUI, fuzzy finder, conversation search, chat history search, AI coding session search, LLM history, local session search, Telescope-style picker.
155
+ OpenCode plugin, OpenCode TUI, fuzzy finder, semantic search, vector search, embeddings, sqlite-vec, llama-server, conversation search, chat history search, AI coding session search, LLM history, local session search, Telescope-style picker.
106
156
 
107
157
  ![Demo animation](./assets/demo.gif)
@@ -0,0 +1,298 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>opencode-telescope - Fuzzy and Semantic Search for OpenCode Chat History</title>
7
+ <meta
8
+ name="description"
9
+ content="opencode-telescope is an OpenCode TUI plugin for fuzzy and semantic search across local conversation history, session transcripts, AI coding chats, and old code snippets."
10
+ />
11
+ <meta
12
+ name="keywords"
13
+ content="OpenCode, OpenCode plugin, OpenCode TUI, fuzzy search, semantic search, vector search, embeddings, sqlite-vec, conversation search, chat history search, AI coding session search, LLM history, telescope"
14
+ />
15
+ <link rel="canonical" href="https://bojackduy.github.io/opencode-telescope/" />
16
+ <meta property="og:title" content="opencode-telescope" />
17
+ <meta
18
+ property="og:description"
19
+ content="Fuzzy and semantic search for local OpenCode conversations, session transcripts, and past AI coding chats from the TUI."
20
+ />
21
+ <meta property="og:type" content="website" />
22
+ <meta property="og:url" content="https://bojackduy.github.io/opencode-telescope/" />
23
+ <meta
24
+ property="og:image"
25
+ content="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.png"
26
+ />
27
+ <meta name="twitter:card" content="summary_large_image" />
28
+ <meta
29
+ name="twitter:image"
30
+ content="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.png"
31
+ />
32
+ <style>
33
+ :root {
34
+ color-scheme: dark;
35
+ --bg: #090b10;
36
+ --panel: #111722;
37
+ --text: #eaf0ff;
38
+ --muted: #a8b3cf;
39
+ --accent: #8bd5ff;
40
+ --accent-2: #c6a0ff;
41
+ --border: #283247;
42
+ }
43
+
44
+ * {
45
+ box-sizing: border-box;
46
+ }
47
+
48
+ body {
49
+ margin: 0;
50
+ background:
51
+ radial-gradient(circle at top left, rgba(139, 213, 255, 0.18), transparent 30rem),
52
+ radial-gradient(circle at bottom right, rgba(198, 160, 255, 0.14), transparent 28rem),
53
+ var(--bg);
54
+ color: var(--text);
55
+ font-family:
56
+ ui-sans-serif,
57
+ system-ui,
58
+ -apple-system,
59
+ BlinkMacSystemFont,
60
+ "Segoe UI",
61
+ sans-serif;
62
+ line-height: 1.6;
63
+ }
64
+
65
+ main {
66
+ width: min(72rem, calc(100% - 2rem));
67
+ margin: 0 auto;
68
+ padding: 4rem 0;
69
+ }
70
+
71
+ .hero {
72
+ display: grid;
73
+ grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr);
74
+ gap: 2rem;
75
+ align-items: center;
76
+ }
77
+
78
+ .eyebrow {
79
+ color: var(--accent);
80
+ font-weight: 700;
81
+ letter-spacing: 0.12em;
82
+ text-transform: uppercase;
83
+ }
84
+
85
+ h1 {
86
+ margin: 0.5rem 0 1rem;
87
+ font-size: clamp(2.6rem, 8vw, 5.8rem);
88
+ line-height: 0.92;
89
+ letter-spacing: -0.07em;
90
+ }
91
+
92
+ h2 {
93
+ margin-top: 0;
94
+ font-size: 1.35rem;
95
+ }
96
+
97
+ p {
98
+ color: var(--muted);
99
+ font-size: 1.08rem;
100
+ }
101
+
102
+ a {
103
+ color: var(--accent);
104
+ }
105
+
106
+ .actions {
107
+ display: flex;
108
+ flex-wrap: wrap;
109
+ gap: 0.75rem;
110
+ margin-top: 1.5rem;
111
+ }
112
+
113
+ .button {
114
+ border: 1px solid var(--border);
115
+ border-radius: 999px;
116
+ color: var(--text);
117
+ display: inline-flex;
118
+ font-weight: 700;
119
+ padding: 0.8rem 1rem;
120
+ text-decoration: none;
121
+ }
122
+
123
+ .button.primary {
124
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
125
+ color: #07101a;
126
+ }
127
+
128
+ .card,
129
+ pre {
130
+ background: rgba(17, 23, 34, 0.86);
131
+ border: 1px solid var(--border);
132
+ border-radius: 1.25rem;
133
+ box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.28);
134
+ }
135
+
136
+ .card {
137
+ padding: 1.25rem;
138
+ }
139
+
140
+ .demo-card {
141
+ padding: 0.8rem;
142
+ }
143
+
144
+ .demo-card img {
145
+ border: 1px solid var(--border);
146
+ border-radius: 0.9rem;
147
+ display: block;
148
+ height: auto;
149
+ width: 100%;
150
+ }
151
+
152
+ .caption {
153
+ color: var(--muted);
154
+ font-size: 0.92rem;
155
+ margin: 0.8rem 0 0.2rem;
156
+ }
157
+
158
+ pre {
159
+ color: #d9e7ff;
160
+ overflow-x: auto;
161
+ padding: 1rem;
162
+ }
163
+
164
+ code {
165
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
166
+ }
167
+
168
+ .grid {
169
+ display: grid;
170
+ grid-template-columns: repeat(3, minmax(0, 1fr));
171
+ gap: 1rem;
172
+ margin-top: 2rem;
173
+ }
174
+
175
+ .links {
176
+ display: flex;
177
+ flex-wrap: wrap;
178
+ gap: 1rem;
179
+ margin-top: 2rem;
180
+ }
181
+
182
+ .showcase {
183
+ display: grid;
184
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
185
+ gap: 1rem;
186
+ margin-top: 2rem;
187
+ }
188
+
189
+ @media (max-width: 760px) {
190
+ main {
191
+ padding: 2.5rem 0;
192
+ }
193
+
194
+ .hero,
195
+ .grid,
196
+ .showcase {
197
+ grid-template-columns: 1fr;
198
+ }
199
+ }
200
+ </style>
201
+ </head>
202
+ <body>
203
+ <main>
204
+ <section class="hero">
205
+ <div>
206
+ <div class="eyebrow">OpenCode TUI plugin</div>
207
+ <h1>Find the chat where it happened.</h1>
208
+ <p>
209
+ <strong>opencode-telescope</strong> adds a Telescope-style fuzzy finder
210
+ to OpenCode so you can search local conversation history by keyword
211
+ or meaning, recover old code snippets, and jump back to the exact chat.
212
+ </p>
213
+ <div class="actions">
214
+ <a class="button primary" href="https://www.npmjs.com/package/@bojackduy/opencode-telescope">Install from npm</a>
215
+ <a class="button" href="https://github.com/bojackduy/opencode-telescope">View on GitHub</a>
216
+ </div>
217
+ </div>
218
+ <div class="card">
219
+ <h2>Package</h2>
220
+ <pre><code>@bojackduy/opencode-telescope</code></pre>
221
+ <p>
222
+ Search OpenCode sessions by keyword or semantic similarity, preview
223
+ matches, and jump back into the exact conversation without leaving the TUI.
224
+ </p>
225
+ </div>
226
+ </section>
227
+
228
+ <section class="showcase" aria-label="Demo screenshots">
229
+ <div class="card demo-card">
230
+ <img
231
+ src="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.png"
232
+ alt="opencode-telescope fuzzy search UI preview inside the OpenCode terminal"
233
+ loading="lazy"
234
+ />
235
+ <p class="caption">Search across OpenCode sessions with a Telescope-style result list and preview pane.</p>
236
+ </div>
237
+ <div class="card demo-card">
238
+ <img
239
+ src="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.gif"
240
+ alt="Animated opencode-telescope demo showing chat history search and session navigation"
241
+ loading="lazy"
242
+ />
243
+ <p class="caption">Filter conversation history, inspect matches, and jump back to the exact chat.</p>
244
+ </div>
245
+ </section>
246
+
247
+ <section class="grid" aria-label="Features">
248
+ <div class="card">
249
+ <h2>Fuzzy + semantic</h2>
250
+ <p>Grep exact words or use local embeddings to find conversations by meaning.</p>
251
+ </div>
252
+ <div class="card">
253
+ <h2>Hybrid ranking</h2>
254
+ <p>Blend SQLite FTS matches with vector results from a local sidecar index.</p>
255
+ </div>
256
+ <div class="card">
257
+ <h2>Local first</h2>
258
+ <p>Reads OpenCode's SQLite database in read-only mode and uses your local embedding server.</p>
259
+ </div>
260
+ </section>
261
+
262
+ <section style="margin-top: 2rem">
263
+ <h2>Semantic memory search</h2>
264
+ <p>
265
+ Run a local <code>llama-server</code> embedding endpoint and Telescope can build a
266
+ <code>sqlite-vec</code> sidecar index for meaning-based recall. If vector dependencies
267
+ are missing, keyword search keeps working.
268
+ </p>
269
+ <pre><code>OPENCODE_TELESCOPE_EMBED_BASE_URL=http://127.0.0.1:8081
270
+ OPENCODE_TELESCOPE_EMBED_MODEL=nomic-embed-text-v1.5
271
+ OPENCODE_TELESCOPE_HYBRID_ALPHA=0.45</code></pre>
272
+ <p>
273
+ <a href="https://github.com/bojackduy/opencode-telescope/blob/main/docs/semantic-search.md">Read the semantic search setup guide</a>
274
+ </p>
275
+ </section>
276
+
277
+ <section style="margin-top: 2rem">
278
+ <h2>Install</h2>
279
+ <pre><code>{
280
+ "plugin": ["@bojackduy/opencode-telescope"]
281
+ }</code></pre>
282
+ <p>
283
+ Open search with <code>&lt;leader&gt;f</code> or <code>/telescope</code>, type
284
+ to filter conversation text or describe the idea you remember, then press
285
+ <code>Enter</code> to jump to the selected OpenCode session.
286
+ </p>
287
+ </section>
288
+
289
+ <nav class="links" aria-label="Project links">
290
+ <a href="https://github.com/bojackduy/opencode-telescope">GitHub repository</a>
291
+ <a href="https://www.npmjs.com/package/@bojackduy/opencode-telescope">npm package</a>
292
+ <a href="https://github.com/bojackduy/opencode-telescope/blob/main/docs/semantic-search.md">Semantic search guide</a>
293
+ <a href="https://github.com/bojackduy/opencode-telescope/issues">Issues</a>
294
+ <a href="https://bojackduy.github.io/opencode-telescope/sitemap.xml">Sitemap</a>
295
+ </nav>
296
+ </main>
297
+ </body>
298
+ </html>
@@ -0,0 +1,92 @@
1
+ # Semantic Search Setup
2
+
3
+ opencode-telescope can search OpenCode chat history by meaning, not only by exact keywords. The default keyword/fuzzy search still works without extra setup. Semantic search turns on automatically when the local vector dependencies are ready.
4
+
5
+ ## What It Adds
6
+
7
+ - Hybrid search across keyword FTS results and vector semantic matches.
8
+ - Vector-only results when a query is related by meaning but not exact wording.
9
+ - Local embeddings through a `llama-server` endpoint.
10
+ - A Telescope-owned sidecar index so OpenCode's source database stays read-only.
11
+
12
+ ## Requirements
13
+
14
+ - `@bojackduy/opencode-telescope` installed in OpenCode.
15
+ - A local `llama-server` with an embedding model.
16
+ - `sqlite-vec`, provided by the package dependency or by `OPENCODE_TELESCOPE_SQLITE_VEC_EXT`.
17
+ - On macOS, an extension-capable SQLite library. Telescope tries Homebrew SQLite automatically, or you can set `OPENCODE_TELESCOPE_SQLITE_LIB`.
18
+
19
+ ## Recommended Model
20
+
21
+ Use `nomic-ai/nomic-embed-text-v1.5-GGUF` for local embedding search.
22
+
23
+ ```bash
24
+ mkdir -p "$HOME/.local/share/opencode-telescope/models"
25
+
26
+ huggingface-cli download \
27
+ nomic-ai/nomic-embed-text-v1.5-GGUF \
28
+ nomic-embed-text-v1.5.f16.gguf \
29
+ --local-dir "$HOME/.local/share/opencode-telescope/models" \
30
+ --local-dir-use-symlinks false
31
+ ```
32
+
33
+ ## Start The Embedding Server
34
+
35
+ ```bash
36
+ llama-server \
37
+ -m "$HOME/.local/share/opencode-telescope/models/nomic-embed-text-v1.5.f16.gguf" \
38
+ --embedding \
39
+ --pooling mean \
40
+ -c 8192 \
41
+ -ub 8192 \
42
+ --host 127.0.0.1 \
43
+ --port 8081
44
+ ```
45
+
46
+ Smoke test the server:
47
+
48
+ ```bash
49
+ curl http://127.0.0.1:8081/health
50
+
51
+ curl http://127.0.0.1:8081/v1/embeddings \
52
+ -H "Content-Type: application/json" \
53
+ -d '{"input":"search_query: find the auth caching discussion","model":"nomic-embed-text-v1.5","encoding_format":"float"}'
54
+ ```
55
+
56
+ ## Configure Telescope
57
+
58
+ Telescope defaults to `http://127.0.0.1:8081`, so most local setups only need the server running before opening OpenCode.
59
+
60
+ Optional environment variables:
61
+
62
+ ```bash
63
+ OPENCODE_TELESCOPE_EMBED_BASE_URL=http://127.0.0.1:8081
64
+ OPENCODE_TELESCOPE_EMBED_MODEL=nomic-embed-text-v1.5
65
+ OPENCODE_TELESCOPE_HYBRID_ALPHA=0.45
66
+ OPENCODE_TELESCOPE_DISABLE_VECTOR=1
67
+ OPENCODE_TELESCOPE_SQLITE_LIB=/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib
68
+ OPENCODE_TELESCOPE_SQLITE_VEC_EXT=/path/to/vec0
69
+ ```
70
+
71
+ `OPENCODE_TELESCOPE_HYBRID_ALPHA` controls the blend between keyword and vector ranking. `0` favors keyword search; `1` favors semantic vector search. The default is `0.45`.
72
+
73
+ Use `OPENCODE_TELESCOPE_DISABLE_VECTOR=1` to force keyword-only search.
74
+
75
+ ## How It Works
76
+
77
+ 1. Telescope reads OpenCode's SQLite session database in read-only mode.
78
+ 2. It extracts searchable conversation documents into a sidecar index.
79
+ 3. Keyword search uses local SQLite FTS.
80
+ 4. Semantic search embeds documents with `search_document: ` and queries with `search_query: ` prefixes.
81
+ 5. Vector rows are stored with `sqlite-vec` in the sidecar database.
82
+ 6. Results are merged and ranked with hybrid keyword/vector scoring.
83
+
84
+ If the embedding server or vector extension is unavailable, Telescope keeps keyword search working and marks vector search unavailable internally.
85
+
86
+ ## Troubleshooting
87
+
88
+ - If semantic results do not appear, confirm `curl http://127.0.0.1:8081/health` returns success.
89
+ - On macOS, install Homebrew SQLite if extension loading fails: `brew install sqlite`.
90
+ - Set `OPENCODE_TELESCOPE_SQLITE_LIB` if SQLite is installed somewhere other than Homebrew's default path.
91
+ - Set `OPENCODE_TELESCOPE_SQLITE_VEC_EXT` only when you need to load a specific `vec0` extension file.
92
+ - Set `OPENCODE_TELESCOPE_DISABLE_VECTOR=1` when you want fast keyword-only search or are debugging local embedding setup.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.22",
5
- "description": "OpenCode TUI plugin for fuzzy-searching local conversation history, session transcripts, and past AI coding chats",
4
+ "version": "0.1.23",
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",
8
8
  "repository": {
@@ -22,6 +22,10 @@
22
22
  "session",
23
23
  "conversation",
24
24
  "conversation-search",
25
+ "semantic-search",
26
+ "vector-search",
27
+ "embeddings",
28
+ "sqlite-vec",
25
29
  "history",
26
30
  "ai-chat-history",
27
31
  "llm-history",
@@ -43,6 +47,8 @@
43
47
  },
44
48
  "files": [
45
49
  "assets",
50
+ "docs/index.html",
51
+ "docs/semantic-search.md",
46
52
  "README.md",
47
53
  "LICENSE",
48
54
  "tui.tsx",
@@ -60,6 +66,9 @@
60
66
  "import": "./tui.tsx"
61
67
  }
62
68
  },
69
+ "dependencies": {
70
+ "sqlite-vec": "^0.1.9"
71
+ },
63
72
  "peerDependencies": {
64
73
  "@opencode-ai/plugin": "*",
65
74
  "@opentui/core": "*",
package/search.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Database } from "bun:sqlite"
2
+ import { createHash } from "node:crypto"
2
3
  import { existsSync, readdirSync, statSync } from "node:fs"
3
4
  import { homedir } from "node:os"
4
5
  import path from "node:path"
5
6
  import { debug } from "./ui/debug.ts"
7
+ import { LlamaEmbeddingClient } from "./search/embedding.ts"
6
8
 
7
9
  export type SearchResult = {
8
10
  id: string
@@ -63,6 +65,46 @@ export type ToolState = {
63
65
  error?: string
64
66
  }
65
67
 
68
+ export type SemanticConfig = {
69
+ embedBaseUrl: string
70
+ embedModel?: string
71
+ disableVector: boolean
72
+ sqliteLibPath?: string
73
+ sqliteVecExtension?: string
74
+ hybridAlpha: number
75
+ documentPrefix: string
76
+ queryPrefix: string
77
+ }
78
+
79
+ export type VectorState = "enabled" | "disabled" | "unavailable" | "stale"
80
+
81
+ export const DEFAULT_EMBED_BASE_URL = "http://127.0.0.1:8081"
82
+ export const DEFAULT_DOCUMENT_PREFIX = "search_document: "
83
+ export const DEFAULT_QUERY_PREFIX = "search_query: "
84
+ export const DEFAULT_HYBRID_ALPHA = 0.45
85
+
86
+ export function parseSemanticConfig(env: Record<string, string | undefined> = process.env): SemanticConfig {
87
+ return {
88
+ embedBaseUrl: env.OPENCODE_TELESCOPE_EMBED_BASE_URL ?? DEFAULT_EMBED_BASE_URL,
89
+ embedModel: env.OPENCODE_TELESCOPE_EMBED_MODEL || undefined,
90
+ disableVector: env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "1" || env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "true",
91
+ sqliteLibPath: env.OPENCODE_TELESCOPE_SQLITE_LIB || undefined,
92
+ sqliteVecExtension: env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT || undefined,
93
+ hybridAlpha: parseAlpha(env.OPENCODE_TELESCOPE_HYBRID_ALPHA),
94
+ documentPrefix: DEFAULT_DOCUMENT_PREFIX,
95
+ queryPrefix: DEFAULT_QUERY_PREFIX,
96
+ }
97
+ }
98
+
99
+ function parseAlpha(value: string | undefined) {
100
+ if (!value) return DEFAULT_HYBRID_ALPHA
101
+ const parsed = Number(value)
102
+ if (!Number.isFinite(parsed)) return DEFAULT_HYBRID_ALPHA
103
+ if (parsed < 0) return 0
104
+ if (parsed > 1) return 1
105
+ return parsed
106
+ }
107
+
66
108
  type Row = {
67
109
  id: string
68
110
  message_id: string
@@ -80,6 +122,23 @@ type IndexSourceRow = Omit<Row, "text"> & {
80
122
  data: string
81
123
  }
82
124
 
125
+ export type DocumentRow = {
126
+ doc_id: string
127
+ part_id: string
128
+ message_id: string
129
+ session_id: string
130
+ session_title: string
131
+ directory: string
132
+ role: string
133
+ part_type: string
134
+ tool: string | null
135
+ time_created: number
136
+ chunk_index: number
137
+ text: string
138
+ source_hash: string
139
+ extractor_version: string
140
+ }
141
+
83
142
  type ConversationRow = {
84
143
  id: string
85
144
  message_id: string
@@ -734,6 +793,7 @@ function ensureSearchIndex(source: Database, sourcePath: string, options?: { reb
734
793
  const indexPath = searchIndexPath(sourcePath)
735
794
  if (!_indexDb || _indexDbPath !== indexPath) {
736
795
  _indexDb?.close()
796
+ configureCustomSQLite()
737
797
  _indexDb = new Database(indexPath)
738
798
  _indexDbPath = indexPath
739
799
  migrateSearchIndex(_indexDb)
@@ -773,7 +833,8 @@ function scheduleBackgroundIndexRebuild(dbPath: string) {
773
833
  ;(timer as { unref?: () => void }).unref?.()
774
834
  }
775
835
 
776
- const SEARCH_INDEX_VERSION = "6"
836
+ const SEARCH_INDEX_VERSION = "7"
837
+ const DOCUMENT_EXTRACTOR_VERSION = "1"
777
838
 
778
839
  function searchIndexPath(sourcePath: string) {
779
840
  const parsed = path.parse(sourcePath)
@@ -786,6 +847,24 @@ function migrateSearchIndex(db: Database) {
786
847
  key TEXT PRIMARY KEY,
787
848
  value TEXT NOT NULL
788
849
  );
850
+ CREATE TABLE IF NOT EXISTS document(
851
+ rowid INTEGER PRIMARY KEY,
852
+ doc_id TEXT UNIQUE NOT NULL,
853
+ part_id TEXT NOT NULL,
854
+ message_id TEXT NOT NULL,
855
+ session_id TEXT NOT NULL,
856
+ session_title TEXT NOT NULL,
857
+ directory TEXT NOT NULL,
858
+ role TEXT NOT NULL,
859
+ part_type TEXT NOT NULL,
860
+ tool TEXT,
861
+ time_created INTEGER NOT NULL,
862
+ chunk_index INTEGER NOT NULL,
863
+ text TEXT NOT NULL,
864
+ source_hash TEXT NOT NULL,
865
+ extractor_version TEXT NOT NULL,
866
+ indexed_at INTEGER NOT NULL
867
+ );
789
868
  CREATE VIRTUAL TABLE IF NOT EXISTS document_fts USING fts5(
790
869
  id UNINDEXED,
791
870
  message_id UNINDEXED,
@@ -895,7 +974,13 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
895
974
  AND json_extract(m.data, '$.role') IN ('user', 'assistant')
896
975
  ORDER BY p.time_created DESC
897
976
  `).all()
898
- const insert = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
977
+ const now = Date.now()
978
+ const config = parseSemanticConfig()
979
+ const insertDoc = index.query<unknown, [string, string, string, string, string, string, string, string, string | null, number, number, string, string, string, number]>(`
980
+ 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)
981
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
982
+ `)
983
+ const insertFts = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
899
984
  INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
900
985
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
901
986
  `)
@@ -905,10 +990,30 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
905
990
  `)
906
991
  index.exec("BEGIN IMMEDIATE")
907
992
  try {
993
+ index.exec("DELETE FROM document")
908
994
  index.exec("DELETE FROM document_fts")
909
995
  index.exec("DELETE FROM document_index")
910
996
  for (const row of rows.flatMap(indexSourceRowToRows)) {
911
- insert.run(
997
+ const docID = `telescope:${row.session_id}:${row.message_id}:${row.id}:0`
998
+ const sourceHash = hashPartData({ session_id: row.session_id, message_id: row.message_id, part_id: row.id })
999
+ insertDoc.run(
1000
+ docID,
1001
+ row.id,
1002
+ row.message_id,
1003
+ row.session_id,
1004
+ row.session_title ?? "Untitled session",
1005
+ row.directory,
1006
+ row.role,
1007
+ row.part_type ?? "text",
1008
+ row.tool ?? null,
1009
+ row.time_created,
1010
+ 0,
1011
+ row.text,
1012
+ sourceHash,
1013
+ DOCUMENT_EXTRACTOR_VERSION,
1014
+ now,
1015
+ )
1016
+ insertFts.run(
912
1017
  row.id,
913
1018
  row.message_id,
914
1019
  row.session_id,
@@ -937,6 +1042,13 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
937
1042
  setMeta(index, "source_data_version", String(state.dataVersion))
938
1043
  setMeta(index, "source_mtime_ms", String(state.mtimeMs))
939
1044
  setMeta(index, "index_version", SEARCH_INDEX_VERSION)
1045
+ setMeta(index, "schema_version", "2")
1046
+ setMeta(index, "extractor_version", DOCUMENT_EXTRACTOR_VERSION)
1047
+ setMeta(index, "ranking_version", "1")
1048
+ setMeta(index, "embedding_base_url", config.embedBaseUrl)
1049
+ setMeta(index, "document_prefix", config.documentPrefix)
1050
+ setMeta(index, "query_prefix", config.queryPrefix)
1051
+ if (config.embedModel) setMeta(index, "embedding_model", config.embedModel)
940
1052
  index.exec("COMMIT")
941
1053
  } catch (err) {
942
1054
  index.exec("ROLLBACK")
@@ -944,6 +1056,321 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
944
1056
  } finally {
945
1057
  debug.timeEnd("fts:rebuild")
946
1058
  }
1059
+
1060
+ if (config.disableVector) {
1061
+ setMeta(index, "vector_state", "disabled")
1062
+ } else {
1063
+ setupVectorTable(index, config, searchIndexPath(sourcePath))
1064
+ }
1065
+ }
1066
+
1067
+ function hashPartData(value: { session_id: string; message_id: string; part_id: string }) {
1068
+ return createHash("sha256").update(`${value.session_id}:${value.message_id}:${value.part_id}`).digest("hex")
1069
+ }
1070
+
1071
+ let customSQLiteConfigured = false
1072
+
1073
+ function configureCustomSQLite() {
1074
+ if (customSQLiteConfigured) return
1075
+ const config = parseSemanticConfig()
1076
+ if (config.disableVector) return
1077
+ customSQLiteConfigured = true
1078
+
1079
+ const candidates = [
1080
+ config.sqliteLibPath,
1081
+ process.platform === "darwin" ? "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib" : undefined,
1082
+ process.platform === "darwin" ? "/usr/local/opt/sqlite/lib/libsqlite3.dylib" : undefined,
1083
+ ].filter((item): item is string => Boolean(item))
1084
+
1085
+ for (const candidate of candidates) {
1086
+ if (existsSync(candidate)) {
1087
+ try {
1088
+ Database.setCustomSQLite(candidate)
1089
+ debug.log("custom-sqlite:set", { path: candidate })
1090
+ return
1091
+ } catch (err) {
1092
+ debug.log("custom-sqlite:error", { path: candidate, error: err instanceof Error ? err.message : String(err) })
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ function importPackage(specifier: string) {
1099
+ return new Function("specifier", "return import(specifier)")(specifier) as Promise<{ default?: { load?: (db: Database) => void } }>
1100
+ }
1101
+
1102
+ async function loadVecExtension(db: Database): Promise<boolean> {
1103
+ try {
1104
+ const sqliteVec = await importPackage("sqlite-vec").catch(() => undefined)
1105
+ if (sqliteVec?.default?.load) {
1106
+ sqliteVec.default.load(db)
1107
+ debug.log("vector:extension:loaded", { source: "npm" })
1108
+ return true
1109
+ }
1110
+ } catch {
1111
+ debug.log("vector:extension:npm-failed")
1112
+ }
1113
+
1114
+ const config = parseSemanticConfig()
1115
+ const explicitPath = config.sqliteVecExtension || process.env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT
1116
+ if (explicitPath && existsSync(explicitPath)) {
1117
+ try {
1118
+ db.loadExtension(explicitPath)
1119
+ debug.log("vector:extension:loaded", { source: "path", path: explicitPath })
1120
+ return true
1121
+ } catch (err) {
1122
+ debug.log("vector:extension:path-failed", { path: explicitPath, error: err instanceof Error ? err.message : String(err) })
1123
+ }
1124
+ }
1125
+
1126
+ return false
1127
+ }
1128
+
1129
+ function setupVectorTable(index: Database, config: SemanticConfig, indexPath: string) {
1130
+ setMeta(index, "vector_state", "stale")
1131
+ rebuildVectorIndex(indexPath, config).catch((err: Error) => {
1132
+ debug.log("vector:rebuild:error", err instanceof Error ? err.message : String(err))
1133
+ })
1134
+ }
1135
+
1136
+ async function rebuildVectorIndex(indexPath: string, config: SemanticConfig) {
1137
+ configureCustomSQLite()
1138
+ const db = new Database(indexPath)
1139
+ try {
1140
+ const loaded = await loadVecExtension(db)
1141
+ if (!loaded) {
1142
+ setMeta(db, "vector_state", "unavailable")
1143
+ return
1144
+ }
1145
+
1146
+ const client = new LlamaEmbeddingClient({
1147
+ baseUrl: config.embedBaseUrl,
1148
+ model: config.embedModel,
1149
+ documentPrefix: config.documentPrefix,
1150
+ queryPrefix: config.queryPrefix,
1151
+ })
1152
+ const healthy = await client.health()
1153
+ if (!healthy) {
1154
+ setMeta(db, "vector_state", "unavailable")
1155
+ return
1156
+ }
1157
+
1158
+ const docs = db.query<{ rowid: number; text: string }, []>("SELECT rowid, text FROM document ORDER BY rowid ASC").all()
1159
+ if (!docs.length) {
1160
+ setMeta(db, "vector_state", "enabled")
1161
+ return
1162
+ }
1163
+
1164
+ debug.log("vector:embed:start", { count: docs.length })
1165
+ const embeddings = await client.embedDocuments(docs.map((d) => d.text))
1166
+ const dims = embeddings[0]?.length
1167
+ if (!dims) {
1168
+ setMeta(db, "vector_state", "unavailable")
1169
+ return
1170
+ }
1171
+
1172
+ db.exec("DROP TABLE IF EXISTS document_vec")
1173
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS document_vec USING vec0(embedding float[${dims}])`)
1174
+ debug.log("vector:table:recreated", { dimensions: dims })
1175
+
1176
+ const insert = db.prepare<unknown, [number, Float32Array]>("INSERT INTO document_vec(rowid, embedding) VALUES (?, vec_f32(?))")
1177
+ const transaction = db.transaction(() => {
1178
+ for (const [i, doc] of docs.entries()) {
1179
+ insert.run(doc.rowid, embeddings[i])
1180
+ }
1181
+ })
1182
+ transaction()
1183
+
1184
+ setMeta(db, "vector_state", "enabled")
1185
+ setMeta(db, "embedding_dimensions", String(dims))
1186
+ if (config.embedModel) setMeta(db, "embedding_model", config.embedModel)
1187
+ debug.log("vector:rebuild:done", { vectors: docs.length, dimensions: dims })
1188
+ } catch (err) {
1189
+ setMeta(db, "vector_state", "unavailable")
1190
+ debug.log("vector:rebuild:error", err instanceof Error ? err.message : String(err))
1191
+ } finally {
1192
+ db.close()
1193
+ }
1194
+ }
1195
+
1196
+ export type ScoredRow = Row & {
1197
+ score: number
1198
+ keywordScore: number
1199
+ vectorScore: number
1200
+ }
1201
+
1202
+ function searchVector(index: Database, embedding: Float32Array, limit: number): Row[] {
1203
+ const count = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_vec").get()?.count ?? 0
1204
+ if (!count) return []
1205
+ const k = Math.min(count, Math.max(limit * 4, 200))
1206
+ return index.query<Row, [Float32Array, number]>(`
1207
+ SELECT d.part_id AS id, d.message_id, d.session_id, d.session_title, d.directory, d.role,
1208
+ d.part_type, d.tool, CAST(d.time_created AS INTEGER) AS time_created, d.text
1209
+ FROM document_vec v
1210
+ JOIN document d ON d.rowid = v.rowid
1211
+ WHERE v.embedding MATCH vec_f32(?) AND k = ?
1212
+ ORDER BY v.distance
1213
+ `).all(embedding, k)
1214
+ }
1215
+
1216
+ export function hybridBlend(keyword: Row[], vector: Row[], alpha: number): ScoredRow[] {
1217
+ const merged = new Map<string, ScoredRow>()
1218
+ const defaultScore = 1 / Math.max(keyword.length, 1)
1219
+
1220
+ for (const [i, row] of keyword.entries()) {
1221
+ merged.set(row.id, {
1222
+ ...row,
1223
+ score: 0,
1224
+ keywordScore: keyword.length > 1 ? 1 - i / (keyword.length - 1) : 1,
1225
+ vectorScore: 0,
1226
+ })
1227
+ }
1228
+
1229
+ for (const [i, row] of vector.entries()) {
1230
+ const existing = merged.get(row.id)
1231
+ const vectorScore = vector.length > 1 ? 1 - i / (vector.length - 1) : 1
1232
+ if (existing) {
1233
+ existing.vectorScore = vectorScore
1234
+ } else {
1235
+ merged.set(row.id, {
1236
+ ...row,
1237
+ score: 0,
1238
+ keywordScore: defaultScore,
1239
+ vectorScore,
1240
+ })
1241
+ }
1242
+ }
1243
+
1244
+ const keywordValues = [...merged.values()].map((r) => r.keywordScore)
1245
+ const vectorValues = [...merged.values()].map((r) => r.vectorScore)
1246
+ const kwMin = Math.min(...keywordValues)
1247
+ const kwMax = Math.max(...keywordValues)
1248
+ const vecMin = Math.min(...vectorValues)
1249
+ const vecMax = Math.max(...vectorValues)
1250
+
1251
+ const normalized = [...merged.values()].map((r) => {
1252
+ const kn = kwMax === kwMin ? 0.5 : (r.keywordScore - kwMin) / (kwMax - kwMin)
1253
+ const vn = vecMax === vecMin ? 0.5 : (r.vectorScore - vecMin) / (vecMax - vecMin)
1254
+ return {
1255
+ ...r,
1256
+ keywordScore: kn,
1257
+ vectorScore: vn,
1258
+ score: (1 - alpha) * kn + alpha * vn,
1259
+ }
1260
+ })
1261
+
1262
+ return normalized.sort((a, b) => b.score - a.score)
1263
+ }
1264
+
1265
+ export type HybridSearchOptions = {
1266
+ limit?: number
1267
+ offset?: number
1268
+ directory?: string
1269
+ role?: SearchRole
1270
+ dbPath?: string
1271
+ }
1272
+
1273
+ export async function performSearch(query: string, options?: HybridSearchOptions): Promise<SearchResult[]> {
1274
+ if (!query.trim()) return searchSessionMessages(query, options)
1275
+ const config = parseSemanticConfig()
1276
+ if (!config.disableVector) {
1277
+ try {
1278
+ return await semanticSearchSessionMessages(query, options)
1279
+ } catch {
1280
+ debug.log("query:hybrid:fallback", { message: "hybrid search failed, falling back to keyword" })
1281
+ }
1282
+ }
1283
+ return searchSessionMessages(query, options)
1284
+ }
1285
+
1286
+ export async function semanticSearchSessionMessages(query: string, options?: HybridSearchOptions): Promise<SearchResult[]> {
1287
+ const term = query.trim()
1288
+ if (!term) return []
1289
+ if (options?.dbPath === ":memory:") return []
1290
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
1291
+ const db = getDb(dbPath)
1292
+ const limit = options?.limit ?? 80
1293
+ const dir = options?.directory
1294
+ const role = options?.role
1295
+
1296
+ const index = ensureSearchIndex(db, dbPath)
1297
+ if (!index) return []
1298
+
1299
+ const keyword = indexedTextRows(db, dbPath, limit, term, dir, options?.offset, role) ?? []
1300
+ const config = parseSemanticConfig()
1301
+
1302
+ let vector: Row[] = []
1303
+ if (!config.disableVector) {
1304
+ const vecState = getMeta(index, "vector_state")
1305
+ if (vecState === "enabled") {
1306
+ try {
1307
+ const client = new LlamaEmbeddingClient({
1308
+ baseUrl: config.embedBaseUrl,
1309
+ model: config.embedModel,
1310
+ documentPrefix: config.documentPrefix,
1311
+ queryPrefix: config.queryPrefix,
1312
+ })
1313
+ const embedding = await client.embedQuery(term)
1314
+ vector = searchVector(index, embedding, limit)
1315
+ debug.log("query:vector:results", { count: vector.length })
1316
+ } catch (err) {
1317
+ debug.log("query:vector:error", err instanceof Error ? err.message : String(err))
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ const merged = keyword.length || vector.length
1323
+ ? hybridBlend(keyword, vector, config.hybridAlpha)
1324
+ : []
1325
+
1326
+ const results: SearchResult[] = []
1327
+ const seen = new Set<string>()
1328
+ for (const row of merged) {
1329
+ const result = rowToSearchResult(row, term) ?? rowToVectorResult(row)
1330
+ if (result) {
1331
+ seen.add(row.id)
1332
+ results.push(result)
1333
+ }
1334
+ }
1335
+
1336
+ for (const row of vector) {
1337
+ if (!seen.has(row.id)) {
1338
+ const result = rowToSearchResult(row, term) ?? rowToVectorResult(row)
1339
+ if (result) results.push(result)
1340
+ }
1341
+ }
1342
+
1343
+ return results
1344
+ }
1345
+
1346
+ export function rowToVectorResult(row: Row): SearchResult | undefined {
1347
+ const text = row.text.trim()
1348
+ if (!text) return
1349
+ const excerpt = text.slice(0, 200)
1350
+ return {
1351
+ id: row.id,
1352
+ messageID: row.message_id,
1353
+ sessionID: row.session_id,
1354
+ sessionTitle: row.session_title || "Untitled session",
1355
+ directory: row.directory,
1356
+ role: row.role,
1357
+ partType: row.part_type ?? "text",
1358
+ tool: row.tool ?? undefined,
1359
+ timeCreated: row.time_created,
1360
+ snippet: excerpt,
1361
+ matchStart: -1,
1362
+ matchEnd: -1,
1363
+ before: "",
1364
+ match: "",
1365
+ after: excerpt,
1366
+ excerpt,
1367
+ previewBefore: text.slice(0, Math.min(text.length, 1400)),
1368
+ previewMatch: "",
1369
+ previewAfter: "",
1370
+ previewMode: "markdown" as const,
1371
+ previewHighlight: false,
1372
+ text,
1373
+ }
947
1374
  }
948
1375
 
949
1376
  function ftsQuery(query: string) {
package/telescope.tsx CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  loadConversationAfter,
10
10
  loadConversationAround,
11
11
  loadConversationBefore,
12
+ parseSemanticConfig,
13
+ performSearch,
12
14
  recentSessionMessages,
13
15
  resolveDatabasePath,
14
16
  searchSessionMessages,
@@ -35,6 +37,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
35
37
  const [nextResultOffset, setNextResultOffset] = createSignal(0)
36
38
  const [busy, setBusy] = createSignal(false)
37
39
  const [error, setError] = createSignal("")
40
+ const [searchMode, setSearchMode] = createSignal<"keyword" | "hybrid">("keyword")
38
41
  const [mode, setMode] = createSignal<"normal" | "insert">("normal")
39
42
  const [loading, setLoading] = createSignal(true)
40
43
  const [hasMore, setHasMore] = createSignal(true)
@@ -196,18 +199,25 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
196
199
  })
197
200
  })
198
201
 
199
- const executeSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
202
+ const detectSearchMode = () => {
203
+ if (!query().trim()) return "keyword" as const
204
+ const config = parseSemanticConfig()
205
+ return config.disableVector ? "keyword" as const : "hybrid" as const
206
+ }
207
+
208
+ const executeSearch = async (q: string, role: SearchRole | undefined, db: string, dir: string) => {
200
209
  lastFiredQuery = q
201
210
  const limit = q ? Math.min(searchBatchSize(), 80) : recentBatchSize()
202
211
  setLoading(true)
203
212
  if (q) setBusy(true)
213
+ setSearchMode(detectSearchMode())
204
214
  debug.time("query:search")
205
215
  try {
206
216
  debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q || "(all recent)" })
207
217
  const batch = q
208
- ? searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
218
+ ? await performSearch(q, { limit, offset: 0, dbPath: db, directory: dir, role })
209
219
  : recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
210
- debug.log("bootstrap:search:done", { rows: batch.length, limit })
220
+ debug.log("bootstrap:search:done", { rows: batch.length, limit, mode: searchMode() })
211
221
  solidBatch(() => {
212
222
  setResults(batch)
213
223
  setResultBaseOffset(0)
@@ -266,7 +276,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
266
276
  })
267
277
  })
268
278
 
269
- const loadMoreResults = (advance = false) => {
279
+ const loadMoreResults = async (advance = false) => {
270
280
  if (advance) advanceSelectionAfterLoad = true
271
281
  if (!hasMore()) {
272
282
  advanceSelectionAfterLoad = false
@@ -285,7 +295,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
285
295
  debug.time("query:load-more")
286
296
  try {
287
297
  const batch = q
288
- ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
298
+ ? await performSearch(q, { limit, offset, dbPath: db, directory: dir, role })
289
299
  : recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
290
300
  const nextHasMore = batch.length >= limit
291
301
  const nextLoadedUntil = offset + batch.length
@@ -1248,7 +1258,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
1248
1258
  }}
1249
1259
  flexGrow={1}
1250
1260
  />
1251
- <text fg={theme().textMuted}>{busy() ? `searching ${ownerLabel()}` : loading() ? `loading ${ownerLabel()}` : query().trim() ? (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} hits` : `${ownerLabel()} 0 hits`) : (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} recent` : `${ownerLabel()} 0 recent`)}</text>
1261
+ <text fg={theme().textMuted}>{busy() ? `searching ${ownerLabel()}` : loading() ? `loading ${ownerLabel()}` : query().trim() ? (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} hits` : `${ownerLabel()} 0 hits`) + (query().trim() ? ` [${searchMode()}]` : "") : (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} recent` : `${ownerLabel()} 0 recent`)}</text>
1252
1262
  </box>
1253
1263
  </box>
1254
1264