@comfanion/usethis_search 3.0.0-dev.24 → 3.0.0-dev.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/usethis_search",
3
- "version": "3.0.0-dev.24",
3
+ "version": "3.0.0-dev.26",
4
4
  "description": "OpenCode plugin: semantic search with graph-based context (v3: graph relations, 1-hop context, LSP + regex analyzers)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -48,8 +48,6 @@
48
48
  "@opencode-ai/plugin": ">=1.1.0",
49
49
  "@xenova/transformers": "^2.17.0",
50
50
  "glob": "^10.3.10",
51
- "level": "^8.0.1",
52
- "levelgraph": "^4.0.0",
53
51
  "vectordb": "^0.4.0"
54
52
  },
55
53
  "peerDependencies": {
package/tools/search.ts CHANGED
@@ -10,7 +10,7 @@ import { tool } from "@opencode-ai/plugin"
10
10
  import path from "path"
11
11
  import fs from "fs/promises"
12
12
 
13
- import { CodebaseIndexer, getSearchConfig } from "../vectorizer/index.ts"
13
+ import { CodebaseIndexer, getSearchConfig, getIndexer, releaseIndexer } from "../vectorizer/index.ts"
14
14
 
15
15
  // ── Extension → language mapping (for filter parsing) ─────────────────────
16
16
  const EXT_TO_LANG: Record<string, string> = {
@@ -133,9 +133,9 @@ Examples:
133
133
  // Freshen from config (default: false — auto_index handles it)
134
134
  if (cfg.freshen) {
135
135
  try {
136
- const tempIndexer = await new CodebaseIndexer(projectRoot, indexName).init()
137
- await tempIndexer.freshen()
138
- await tempIndexer.unloadModel()
136
+ const indexer = await getIndexer(projectRoot, indexName)
137
+ await indexer.freshen()
138
+ releaseIndexer(projectRoot, indexName)
139
139
  } catch {
140
140
  // non-fatal — search can proceed without freshen
141
141
  }
@@ -144,19 +144,22 @@ Examples:
144
144
  let allResults: any[] = []
145
145
 
146
146
  if (args.searchAll) {
147
- const tempIndexer = await new CodebaseIndexer(projectRoot, "code").init()
147
+ const tempIndexer = await getIndexer(projectRoot, "code")
148
148
  const indexes = await tempIndexer.listIndexes()
149
- await tempIndexer.unloadModel()
149
+ releaseIndexer(projectRoot, "code")
150
150
 
151
151
  if (indexes.length === 0) {
152
152
  return `No indexes found. The codebase needs to be indexed first.\n\nRun the CLI: bunx usethis_search reindex`
153
153
  }
154
154
 
155
155
  for (const idx of indexes) {
156
- const indexer = await new CodebaseIndexer(projectRoot, idx).init()
157
- const results = await indexer.search(args.query, limit, includeArchived, searchOptions)
158
- allResults.push(...results.map((r: any) => ({ ...r, _index: idx })))
159
- await indexer.unloadModel()
156
+ const indexer = await getIndexer(projectRoot, idx)
157
+ try {
158
+ const results = await indexer.search(args.query, limit, includeArchived, searchOptions)
159
+ allResults.push(...results.map((r: any) => ({ ...r, _index: idx })))
160
+ } finally {
161
+ releaseIndexer(projectRoot, idx)
162
+ }
160
163
  }
161
164
 
162
165
  allResults.sort((a, b) => {
@@ -171,9 +174,9 @@ Examples:
171
174
  await fs.access(hashesFile)
172
175
  } catch {
173
176
  // Index doesn't exist — check what indexes ARE available
174
- const tempIndexer = await new CodebaseIndexer(projectRoot, "code").init()
177
+ const tempIndexer = await getIndexer(projectRoot, "code")
175
178
  const available = await tempIndexer.listIndexes()
176
- await tempIndexer.unloadModel()
179
+ releaseIndexer(projectRoot, "code")
177
180
 
178
181
  if (available.length > 0) {
179
182
  const list = available.map(i => `"${i}"`).join(", ")
@@ -182,10 +185,13 @@ Examples:
182
185
  return `No indexes found. The codebase needs to be indexed first.\n\nRun the CLI: bunx usethis_search reindex`
183
186
  }
184
187
 
185
- const indexer = await new CodebaseIndexer(projectRoot, indexName).init()
186
- const results = await indexer.search(args.query, limit, includeArchived, searchOptions)
187
- allResults = results.map((r: any) => ({ ...r, _index: indexName }))
188
- await indexer.unloadModel()
188
+ const indexer = await getIndexer(projectRoot, indexName)
189
+ try {
190
+ const results = await indexer.search(args.query, limit, includeArchived, searchOptions)
191
+ allResults = results.map((r: any) => ({ ...r, _index: indexName }))
192
+ } finally {
193
+ releaseIndexer(projectRoot, indexName)
194
+ }
189
195
  }
190
196
 
191
197
  // ── Score cutoff — drop low-relevance results ──────────────────────────
@@ -1,5 +1,4 @@
1
- import levelgraph from "levelgraph"
2
- import { Level } from "level"
1
+ import { Database } from "bun:sqlite"
3
2
  import { filePathFromNodeId, isStructuralPredicate } from "./graph-builder"
4
3
 
5
4
  export interface Triple {
@@ -13,98 +12,137 @@ export interface Triple {
13
12
  }
14
13
 
15
14
  export class GraphDB {
16
- private db: any
15
+ private db: Database | null = null
17
16
  private initialized: boolean = false
18
17
 
18
+ // Prepared statements (cached for performance)
19
+ private _stmtInsert: any = null
20
+ private _stmtBySubject: any = null
21
+ private _stmtByObject: any = null
22
+ private _stmtByFile: any = null
23
+ private _stmtDeleteByFile: any = null
24
+ private _stmtBySubjectPredicate: any = null
25
+ private _stmtByPredicate: any = null
26
+ private _stmtAll: any = null
27
+
19
28
  constructor(private dbPath: string) {}
20
29
 
21
30
  async init(): Promise<this> {
22
- const levelDb = new Level(this.dbPath)
23
- this.db = levelgraph(levelDb)
31
+ // bun:sqlite uses a file path; append .db if not already
32
+ const fullPath = this.dbPath.endsWith(".db") ? this.dbPath : this.dbPath + ".db"
33
+ this.db = new Database(fullPath)
34
+
35
+ // WAL mode for concurrent readers
36
+ this.db.exec("PRAGMA journal_mode = WAL")
37
+ this.db.exec("PRAGMA synchronous = NORMAL") // faster writes, safe with WAL
38
+ this.db.exec("PRAGMA cache_size = -2000") // 2MB cache
39
+
40
+ // Create triples table
41
+ this.db.exec(`
42
+ CREATE TABLE IF NOT EXISTS triples (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ subject TEXT NOT NULL,
45
+ predicate TEXT NOT NULL,
46
+ object TEXT NOT NULL,
47
+ weight REAL NOT NULL DEFAULT 0,
48
+ source TEXT NOT NULL DEFAULT '',
49
+ file TEXT NOT NULL DEFAULT '',
50
+ line INTEGER
51
+ )
52
+ `)
53
+
54
+ // Indexes for fast lookups
55
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_subject ON triples(subject)")
56
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_object ON triples(object)")
57
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_file ON triples(file)")
58
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_predicate ON triples(predicate)")
59
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_subject_predicate ON triples(subject, predicate)")
60
+
61
+ // Prepare statements
62
+ this._stmtInsert = this.db.prepare(
63
+ "INSERT INTO triples (subject, predicate, object, weight, source, file, line) VALUES (?, ?, ?, ?, ?, ?, ?)"
64
+ )
65
+ this._stmtBySubject = this.db.prepare("SELECT * FROM triples WHERE subject = ?")
66
+ this._stmtByObject = this.db.prepare("SELECT * FROM triples WHERE object = ?")
67
+ this._stmtByFile = this.db.prepare("SELECT * FROM triples WHERE file = ?")
68
+ this._stmtDeleteByFile = this.db.prepare("DELETE FROM triples WHERE file = ?")
69
+ this._stmtBySubjectPredicate = this.db.prepare("SELECT * FROM triples WHERE subject = ? AND predicate = ?")
70
+ this._stmtByPredicate = this.db.prepare("SELECT * FROM triples WHERE predicate = ?")
71
+ this._stmtAll = this.db.prepare("SELECT * FROM triples")
72
+
24
73
  this.initialized = true
25
74
  return this
26
75
  }
27
76
 
77
+ private toTriple(row: any): Triple {
78
+ return {
79
+ subject: row.subject,
80
+ predicate: row.predicate,
81
+ object: row.object,
82
+ weight: row.weight,
83
+ source: row.source,
84
+ file: row.file,
85
+ line: row.line ?? undefined,
86
+ }
87
+ }
88
+
28
89
  async putEdges(triples: Triple[]): Promise<void> {
29
- if (!this.initialized) {
90
+ if (!this.initialized || !this.db) {
30
91
  throw new Error("GraphDB not initialized. Call init() first.")
31
92
  }
32
- await new Promise<void>((resolve, reject) => {
33
- this.db.put(triples, (err: Error | undefined) => {
34
- if (err) reject(err)
35
- else resolve()
36
- })
93
+
94
+ // Batch insert in a single transaction — much faster than individual inserts
95
+ const insertMany = this.db.transaction((items: Triple[]) => {
96
+ for (const t of items) {
97
+ this._stmtInsert.run(t.subject, t.predicate, t.object, t.weight, t.source, t.file, t.line ?? null)
98
+ }
37
99
  })
100
+ insertMany(triples)
38
101
  }
39
102
 
40
103
  async getOutgoing(chunkId: string): Promise<Triple[]> {
41
- if (!this.initialized) {
104
+ if (!this.initialized || !this.db) {
42
105
  throw new Error("GraphDB not initialized. Call init() first.")
43
106
  }
44
- return new Promise<Triple[]>((resolve, reject) => {
45
- this.db.get({ subject: chunkId }, (err: Error | undefined, triples: Triple[]) => {
46
- if (err) reject(err)
47
- else resolve(triples || [])
48
- })
49
- })
107
+ const rows = this._stmtBySubject.all(chunkId)
108
+ return rows.map((r: any) => this.toTriple(r))
50
109
  }
51
110
 
52
111
  async getIncoming(chunkId: string): Promise<Triple[]> {
53
- if (!this.initialized) {
112
+ if (!this.initialized || !this.db) {
54
113
  throw new Error("GraphDB not initialized. Call init() first.")
55
114
  }
56
- return new Promise<Triple[]>((resolve, reject) => {
57
- this.db.get({ object: chunkId }, (err: Error | undefined, triples: Triple[]) => {
58
- if (err) reject(err)
59
- else resolve(triples || [])
60
- })
61
- })
115
+ const rows = this._stmtByObject.all(chunkId)
116
+ return rows.map((r: any) => this.toTriple(r))
62
117
  }
63
118
 
64
119
  async deleteByFile(filePath: string): Promise<void> {
65
- if (!this.initialized) {
120
+ if (!this.initialized || !this.db) {
66
121
  throw new Error("GraphDB not initialized. Call init() first.")
67
122
  }
68
- const allTriples = await new Promise<Triple[]>((resolve, reject) => {
69
- this.db.get({}, (err: Error | undefined, triples: Triple[]) => {
70
- if (err) reject(err)
71
- else resolve(triples || [])
72
- })
73
- })
74
-
75
- const toDelete = allTriples.filter(t => t.file === filePath)
76
-
77
- for (const t of toDelete) {
78
- await new Promise<void>((resolve, reject) => {
79
- this.db.del(t, (err: Error | undefined) => {
80
- if (err) reject(err)
81
- else resolve()
82
- })
83
- })
84
- }
123
+ this._stmtDeleteByFile.run(filePath)
85
124
  }
86
125
 
87
126
  async close(): Promise<void> {
88
127
  if (this.initialized && this.db) {
89
- await new Promise<void>((resolve, reject) => {
90
- this.db.close((err: Error | undefined) => {
91
- if (err) reject(err)
92
- else resolve()
93
- })
94
- })
128
+ this.db.close()
129
+ this.db = null
130
+ this._stmtInsert = null
131
+ this._stmtBySubject = null
132
+ this._stmtByObject = null
133
+ this._stmtByFile = null
134
+ this._stmtDeleteByFile = null
135
+ this._stmtBySubjectPredicate = null
136
+ this._stmtByPredicate = null
137
+ this._stmtAll = null
95
138
  this.initialized = false
96
139
  }
97
140
  }
98
141
 
99
142
  // ---- FR-054: File metadata triples for incremental updates -----------------
100
143
 
101
- /**
102
- * Store graph build metadata for a file as a special triple.
103
- * Subject: `meta:<filePath>`, Predicate: `graph_built`, Object: `<hash>`.
104
- * Weight encodes the timestamp (seconds since epoch).
105
- */
106
144
  async setFileMeta(filePath: string, hash: string, timestamp: number): Promise<void> {
107
- if (!this.initialized) throw new Error("GraphDB not initialized. Call init() first.")
145
+ if (!this.initialized || !this.db) throw new Error("GraphDB not initialized. Call init() first.")
108
146
 
109
147
  // Remove old meta triple for this file first
110
148
  await this.deleteFileMeta(filePath)
@@ -113,111 +151,61 @@ export class GraphDB {
113
151
  subject: `meta:${filePath}`,
114
152
  predicate: "graph_built",
115
153
  object: hash,
116
- weight: Math.floor(timestamp / 1000), // seconds since epoch fits in weight
154
+ weight: Math.floor(timestamp / 1000),
117
155
  source: "meta",
118
156
  file: filePath,
119
157
  }
120
158
  await this.putEdges([triple])
121
159
  }
122
160
 
123
- /**
124
- * Get the stored graph build metadata for a file.
125
- * Returns { hash, timestamp } or null if not found.
126
- */
127
161
  async getFileMeta(filePath: string): Promise<{ hash: string; timestamp: number } | null> {
128
- if (!this.initialized) throw new Error("GraphDB not initialized. Call init() first.")
129
-
130
- const triples = await new Promise<Triple[]>((resolve, reject) => {
131
- this.db.get(
132
- { subject: `meta:${filePath}`, predicate: "graph_built" },
133
- (err: Error | undefined, result: Triple[]) => {
134
- if (err) reject(err)
135
- else resolve(result || [])
136
- },
137
- )
138
- })
162
+ if (!this.initialized || !this.db) throw new Error("GraphDB not initialized. Call init() first.")
139
163
 
140
- if (triples.length === 0) return null
164
+ const rows = this._stmtBySubjectPredicate.all(`meta:${filePath}`, "graph_built")
165
+ if (rows.length === 0) return null
141
166
  return {
142
- hash: triples[0].object,
143
- timestamp: triples[0].weight * 1000, // back to ms
167
+ hash: rows[0].object,
168
+ timestamp: rows[0].weight * 1000,
144
169
  }
145
170
  }
146
171
 
147
- /**
148
- * Delete file meta triple.
149
- */
150
172
  async deleteFileMeta(filePath: string): Promise<void> {
151
- if (!this.initialized) throw new Error("GraphDB not initialized. Call init() first.")
173
+ if (!this.initialized || !this.db) throw new Error("GraphDB not initialized. Call init() first.")
152
174
 
153
175
  try {
154
- const triples = await new Promise<Triple[]>((resolve, reject) => {
155
- this.db.get(
156
- { subject: `meta:${filePath}`, predicate: "graph_built" },
157
- (err: Error | undefined, result: Triple[]) => {
158
- if (err) reject(err)
159
- else resolve(result || [])
160
- },
161
- )
162
- })
163
-
164
- for (const t of triples) {
165
- await new Promise<void>((resolve, reject) => {
166
- this.db.del(t, (err: Error | undefined) => {
167
- if (err) reject(err)
168
- else resolve()
169
- })
170
- })
171
- }
172
- } catch (err) {
173
- // Silently ignore errors (e.g., no meta triple exists)
176
+ this.db!.prepare("DELETE FROM triples WHERE subject = ? AND predicate = ?")
177
+ .run(`meta:${filePath}`, "graph_built")
178
+ } catch {
179
+ // Silently ignore errors
174
180
  }
175
181
  }
176
182
 
177
- /**
178
- * Get all file metadata triples (for validation / stats).
179
- */
180
183
  async getAllFileMeta(): Promise<Array<{ filePath: string; hash: string; timestamp: number }>> {
181
- if (!this.initialized) throw new Error("GraphDB not initialized. Call init() first.")
182
-
183
- const triples = await new Promise<Triple[]>((resolve, reject) => {
184
- this.db.get({ predicate: "graph_built" }, (err: Error | undefined, result: Triple[]) => {
185
- if (err) reject(err)
186
- else resolve(result || [])
187
- })
188
- })
184
+ if (!this.initialized || !this.db) throw new Error("GraphDB not initialized. Call init() first.")
189
185
 
190
- return triples.map((t) => ({
191
- filePath: t.subject.replace(/^meta:/, ""),
192
- hash: t.object,
193
- timestamp: t.weight * 1000,
186
+ const rows = this._stmtByPredicate.all("graph_built")
187
+ return rows.map((r: any) => ({
188
+ filePath: r.subject.replace(/^meta:/, ""),
189
+ hash: r.object,
190
+ timestamp: r.weight * 1000,
194
191
  }))
195
192
  }
196
193
 
197
- /**
198
- * Get all triples in the graph (for validation/stats).
199
- * Excludes meta, anchor, and structural triples by default.
200
- * Pass includeStructural=true to also get structural edges.
201
- */
202
194
  async getAllTriples(includeStructural: boolean = false): Promise<Triple[]> {
203
- if (!this.initialized) throw new Error("GraphDB not initialized. Call init() first.")
204
-
205
- const allTriples = await new Promise<Triple[]>((resolve, reject) => {
206
- this.db.get({}, (err: Error | undefined, triples: Triple[]) => {
207
- if (err) reject(err)
208
- else resolve(triples || [])
195
+ if (!this.initialized || !this.db) throw new Error("GraphDB not initialized. Call init() first.")
196
+
197
+ const allRows = this._stmtAll.all()
198
+ return allRows
199
+ .map((r: any) => this.toTriple(r))
200
+ .filter((t: Triple) => {
201
+ if (t.predicate === "graph_built" || t.predicate === "belongs_to") return false
202
+ if (!includeStructural && isStructuralPredicate(t.predicate)) return false
203
+ return true
209
204
  })
210
- })
211
-
212
- return allTriples.filter(t => {
213
- if (t.predicate === "graph_built" || t.predicate === "belongs_to") return false
214
- if (!includeStructural && isStructuralPredicate(t.predicate)) return false
215
- return true
216
- })
217
205
  }
218
206
 
219
207
  async getRelatedFiles(chunkId: string, maxDepth: number = 1): Promise<{path: string, relation: string, weight: number}[]> {
220
- if (!this.initialized) {
208
+ if (!this.initialized || !this.db) {
221
209
  throw new Error("GraphDB not initialized. Call init() first.")
222
210
  }
223
211
 
@@ -225,7 +213,6 @@ export class GraphDB {
225
213
  const visited = new Set<string>()
226
214
  const self = this
227
215
 
228
- // Resolve the caller's file directly from the node ID
229
216
  const callerFile = filePathFromNodeId(chunkId)
230
217
 
231
218
  async function traverse(currentId: string, currentDepth: number, currentRelation: string) {
@@ -236,19 +223,12 @@ export class GraphDB {
236
223
  visited.add(currentId)
237
224
 
238
225
  try {
239
- const outgoing = await new Promise<Triple[]>((resolve, reject) => {
240
- self.db.get({ subject: currentId }, (err: Error | undefined, triples: Triple[]) => {
241
- if (err) reject(err)
242
- else resolve(triples || [])
243
- })
244
- })
226
+ const outgoing = self._stmtBySubject.all(currentId).map((r: any) => self.toTriple(r))
245
227
 
246
228
  for (const triple of outgoing) {
247
- // Skip meta, anchor, and structural-only edges
248
229
  if (triple.predicate === "graph_built" || triple.predicate === "belongs_to") continue
249
230
  if (isStructuralPredicate(triple.predicate)) continue
250
231
 
251
- // Resolve file for the target node directly from its ID
252
232
  const targetFile = filePathFromNodeId(triple.object)
253
233
  if (!targetFile) continue
254
234
 
@@ -267,12 +247,7 @@ export class GraphDB {
267
247
  }
268
248
  }
269
249
 
270
- const incoming = await new Promise<Triple[]>((resolve, reject) => {
271
- self.db.get({ object: currentId }, (err: Error | undefined, triples: Triple[]) => {
272
- if (err) reject(err)
273
- else resolve(triples || [])
274
- })
275
- })
250
+ const incoming = self._stmtByObject.all(currentId).map((r: any) => self.toTriple(r))
276
251
 
277
252
  for (const triple of incoming) {
278
253
  if (triple.predicate === "graph_built" || triple.predicate === "belongs_to") continue
@@ -298,7 +273,6 @@ export class GraphDB {
298
273
 
299
274
  await traverse(chunkId, 0, "")
300
275
 
301
- // Remove the caller's own file from results
302
276
  if (callerFile) relatedFiles.delete(callerFile)
303
277
 
304
278
  return Array.from(relatedFiles.entries())
@@ -460,17 +460,24 @@ class CodebaseIndexer {
460
460
  await this.loadHashes();
461
461
 
462
462
  // Graph DB — only if graph is enabled in config
463
+ // Non-fatal: if LevelDB lock fails (parallel access), search works without graph
463
464
  if (GRAPH_CONFIG.enabled) {
464
- const graphType = this.indexName === "docs" ? "doc_graph" : "code_graph";
465
- const graphPath = path.join(this.root, ".opencode", "graph", graphType);
466
- await fs.mkdir(path.dirname(graphPath), { recursive: true });
467
- this.graphDB = await new GraphDB(graphPath).init();
468
- this.graphBuilder = new GraphBuilder(
469
- this.graphDB,
470
- this.root,
471
- GRAPH_CONFIG.lsp.enabled,
472
- GRAPH_CONFIG.lsp.timeout_ms,
473
- );
465
+ try {
466
+ const graphType = this.indexName === "docs" ? "doc_graph" : "code_graph";
467
+ const graphPath = path.join(this.root, ".opencode", "graph", graphType);
468
+ await fs.mkdir(path.dirname(graphPath), { recursive: true });
469
+ this.graphDB = await new GraphDB(graphPath).init();
470
+ this.graphBuilder = new GraphBuilder(
471
+ this.graphDB,
472
+ this.root,
473
+ GRAPH_CONFIG.lsp.enabled,
474
+ GRAPH_CONFIG.lsp.timeout_ms,
475
+ );
476
+ } catch (e) {
477
+ if (DEBUG) console.log(`[vectorizer] GraphDB init failed (lock?): ${e.message || e}`);
478
+ this.graphDB = null;
479
+ this.graphBuilder = null;
480
+ }
474
481
  }
475
482
 
476
483
  // Usage tracker — provenance & usage stats
@@ -1327,4 +1334,65 @@ function getSearchConfig() {
1327
1334
  return SEARCH_CONFIG;
1328
1335
  }
1329
1336
 
1330
- export { CodebaseIndexer, INDEX_PRESETS, getEmbeddingModel, getSearchConfig };
1337
+ // ── Singleton indexer pool ──────────────────────────────────────────────────
1338
+ // Prevents LevelDB lock conflicts when parallel searches hit the same index.
1339
+ // Each unique (projectRoot, indexName) gets one shared CodebaseIndexer.
1340
+ const _indexerPool = new Map<string, { indexer: CodebaseIndexer; refCount: number; initPromise: Promise<CodebaseIndexer> }>();
1341
+
1342
+ /**
1343
+ * Get or create a shared CodebaseIndexer for the given project + index.
1344
+ * Multiple callers get the same instance — no LevelDB lock conflicts.
1345
+ *
1346
+ * Usage:
1347
+ * const indexer = await getIndexer(projectRoot, "code");
1348
+ * try {
1349
+ * const results = await indexer.search(...);
1350
+ * } finally {
1351
+ * releaseIndexer(projectRoot, "code");
1352
+ * }
1353
+ */
1354
+ async function getIndexer(projectRoot: string, indexName: string = "code"): Promise<CodebaseIndexer> {
1355
+ const key = `${projectRoot}::${indexName}`;
1356
+ const existing = _indexerPool.get(key);
1357
+ if (existing) {
1358
+ existing.refCount++;
1359
+ return existing.initPromise;
1360
+ }
1361
+
1362
+ const indexer = new CodebaseIndexer(projectRoot, indexName);
1363
+ const initPromise = indexer.init();
1364
+ _indexerPool.set(key, { indexer, refCount: 1, initPromise });
1365
+ return initPromise;
1366
+ }
1367
+
1368
+ /**
1369
+ * Release a reference to a shared indexer. When refCount reaches 0,
1370
+ * the indexer is kept alive (for future reuse) but model memory is freed.
1371
+ * Call destroyIndexer() to fully close and remove from pool.
1372
+ */
1373
+ function releaseIndexer(projectRoot: string, indexName: string = "code") {
1374
+ const key = `${projectRoot}::${indexName}`;
1375
+ const entry = _indexerPool.get(key);
1376
+ if (!entry) return;
1377
+ entry.refCount = Math.max(0, entry.refCount - 1);
1378
+ // Keep in pool — don't unload. Next search reuses the same instance.
1379
+ }
1380
+
1381
+ /**
1382
+ * Fully close and remove an indexer from the pool.
1383
+ * Used by CLI clear/reindex operations that need a fresh state.
1384
+ */
1385
+ async function destroyIndexer(projectRoot: string, indexName: string = "code") {
1386
+ const key = `${projectRoot}::${indexName}`;
1387
+ const entry = _indexerPool.get(key);
1388
+ if (!entry) return;
1389
+ _indexerPool.delete(key);
1390
+ try {
1391
+ const indexer = await entry.initPromise;
1392
+ await indexer.unloadModel();
1393
+ } catch {
1394
+ // best effort
1395
+ }
1396
+ }
1397
+
1398
+ export { CodebaseIndexer, INDEX_PRESETS, getEmbeddingModel, getSearchConfig, getIndexer, releaseIndexer, destroyIndexer };