@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 +1 -3
- package/tools/search.ts +22 -16
- package/vectorizer/graph-db.ts +122 -148
- package/vectorizer/index.ts +79 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comfanion/usethis_search",
|
|
3
|
-
"version": "3.0.0-dev.
|
|
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
|
|
137
|
-
await
|
|
138
|
-
|
|
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
|
|
147
|
+
const tempIndexer = await getIndexer(projectRoot, "code")
|
|
148
148
|
const indexes = await tempIndexer.listIndexes()
|
|
149
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
177
|
+
const tempIndexer = await getIndexer(projectRoot, "code")
|
|
175
178
|
const available = await tempIndexer.listIndexes()
|
|
176
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 ──────────────────────────
|
package/vectorizer/graph-db.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
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:
|
|
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
|
-
|
|
23
|
-
this.db
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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),
|
|
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
|
-
|
|
164
|
+
const rows = this._stmtBySubjectPredicate.all(`meta:${filePath}`, "graph_built")
|
|
165
|
+
if (rows.length === 0) return null
|
|
141
166
|
return {
|
|
142
|
-
hash:
|
|
143
|
-
timestamp:
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 =
|
|
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 =
|
|
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())
|
package/vectorizer/index.ts
CHANGED
|
@@ -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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
this.
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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 };
|