@comfanion/usethis_search 4.3.1 → 4.5.0

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/tools/graph.ts ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Graph Exploration Tool
3
+ *
4
+ * explore — navigate the code knowledge graph from a chunk/file node.
5
+ * Shows all edges (imports, extends, used_by, similar_to, has_method, etc.)
6
+ * and auto-attaches neighbor chunks to workspace.
7
+ */
8
+
9
+ import { tool } from "@opencode-ai/plugin"
10
+
11
+ import { getIndexer, releaseIndexer } from "../vectorizer/index.ts"
12
+ import { workspaceCache } from "../cache/manager.ts"
13
+ import { buildWorkspaceOutput } from "./workspace-state.ts"
14
+ import { isStructuralPredicate } from "../vectorizer/graph-builder.ts"
15
+ import type { Triple } from "../vectorizer/graph-db.ts"
16
+
17
+ // ── explore ─────────────────────────────────────────────────────────────────
18
+
19
+ export const explore = tool({
20
+ description: `Explore code connections from a chunk or file using the knowledge graph.
21
+
22
+ Shows all graph edges (imports, extends, used_by, calls, similar_to, has_method, contains)
23
+ and auto-attaches neighbor chunks to workspace for deeper exploration.
24
+
25
+ USE THIS WHEN:
26
+ - You found a chunk via search() and want to see what it imports/extends/calls
27
+ - You want to understand dependencies of a file or function
28
+ - You need to navigate from a known symbol to related code
29
+ - You want to explore "who uses this?" (incoming edges)
30
+
31
+ The graph contains edges built from:
32
+ - Static analysis: imports, extends, implements, calls
33
+ - Structural: file → class → method hierarchy
34
+ - Semantic similarity: similar_to (high cosine similarity between chunks)
35
+
36
+ Workflow:
37
+ 1. search({ query: "authentication" }) → find relevant chunks
38
+ 2. explore({ node: "chunk:src/auth.ts::AuthService" }) → see imports, methods, callers
39
+ 3. explore({ node: "chunk:src/types/User.ts::User" }) → go deeper into dependencies
40
+ 4. search({ queries: ["chunk:src/auth.ts::login", "src/types/User.ts"] }) → attach specific items
41
+
42
+ The node parameter accepts:
43
+ - Chunk ID from search results: "chunk:src/auth.ts::AuthService"
44
+ - File node: "file:src/auth.ts" (shows all edges for the file)
45
+ - Short file path: "src/auth.ts" (auto-prefixed to "file:src/auth.ts")
46
+
47
+ Examples:
48
+ - explore({ node: "chunk:src/auth.ts::login" })
49
+ - explore({ node: "file:src/auth.ts" })
50
+ - explore({ node: "src/auth.ts" })
51
+ - explore({ node: "chunk:src/auth.ts::AuthService", depth: 2 })`,
52
+
53
+ args: {
54
+ node: tool.schema.string().describe("Chunk ID, file node, or file path to explore"),
55
+ depth: tool.schema.number().optional().describe("Graph depth (default: 1, max: 2)"),
56
+ index: tool.schema.string().optional().default("code").describe("Which index to use (default: 'code')"),
57
+ },
58
+
59
+ async execute(args) {
60
+ const projectRoot = process.cwd()
61
+ const depth = Math.min(args.depth ?? 1, 2)
62
+ const indexName = args.index || "code"
63
+
64
+ // Normalize node ID
65
+ let nodeId = args.node
66
+ if (!nodeId.startsWith("chunk:") && !nodeId.startsWith("file:") && !nodeId.startsWith("meta:")) {
67
+ nodeId = `file:${nodeId}`
68
+ }
69
+
70
+ const indexer = await getIndexer(projectRoot, indexName)
71
+ try {
72
+ const graphDB = (indexer as any).graphDB
73
+ if (!graphDB) {
74
+ return `Graph not available for index "${indexName}". The index may not have been built with graph support.`
75
+ }
76
+
77
+ // Collect edges via BFS at requested depth
78
+ const visited = new Set<string>()
79
+ const allEdges: Array<{
80
+ from: string
81
+ predicate: string
82
+ to: string
83
+ weight: number
84
+ direction: "outgoing" | "incoming"
85
+ depth: number
86
+ }> = []
87
+
88
+ const queue: Array<{ id: string; currentDepth: number }> = [{ id: nodeId, currentDepth: 0 }]
89
+
90
+ while (queue.length > 0) {
91
+ const { id, currentDepth } = queue.shift()!
92
+ if (visited.has(id) || currentDepth >= depth) continue
93
+ visited.add(id)
94
+
95
+ try {
96
+ const [outgoing, incoming]: [Triple[], Triple[]] = await Promise.all([
97
+ graphDB.getOutgoing(id),
98
+ graphDB.getIncoming(id),
99
+ ])
100
+
101
+ for (const t of outgoing) {
102
+ if (t.predicate === "graph_built") continue
103
+ allEdges.push({
104
+ from: id,
105
+ predicate: t.predicate,
106
+ to: t.object,
107
+ weight: t.weight,
108
+ direction: "outgoing",
109
+ depth: currentDepth,
110
+ })
111
+ if (currentDepth + 1 < depth && !isStructuralPredicate(t.predicate) && t.predicate !== "belongs_to") {
112
+ queue.push({ id: t.object, currentDepth: currentDepth + 1 })
113
+ }
114
+ }
115
+
116
+ for (const t of incoming) {
117
+ if (t.predicate === "graph_built") continue
118
+ allEdges.push({
119
+ from: t.subject,
120
+ predicate: t.predicate,
121
+ to: id,
122
+ weight: t.weight,
123
+ direction: "incoming",
124
+ depth: currentDepth,
125
+ })
126
+ if (currentDepth + 1 < depth && !isStructuralPredicate(t.predicate) && t.predicate !== "belongs_to") {
127
+ queue.push({ id: t.subject, currentDepth: currentDepth + 1 })
128
+ }
129
+ }
130
+ } catch {
131
+ // Node may not exist in graph — skip
132
+ }
133
+ }
134
+
135
+ if (allEdges.length === 0) {
136
+ return `No graph edges found for "${nodeId}".\n\nMake sure:\n1. The node ID is correct (use chunk IDs from search results)\n2. The file is indexed with graph support\n3. Try "file:path" format for file-level edges` + buildWorkspaceOutput()
137
+ }
138
+
139
+ // Group edges
140
+ const outgoing = allEdges.filter(e => e.direction === "outgoing" && e.from === nodeId)
141
+ const incoming = allEdges.filter(e => e.direction === "incoming" && e.to === nodeId)
142
+ const deeper = allEdges.filter(e => e.depth > 0)
143
+
144
+ // ── Auto-attach neighbor chunks ────────────────────────────────────
145
+ const attachedChunks: string[] = []
146
+ const neighborChunkIds = new Set<string>()
147
+
148
+ for (const edge of allEdges) {
149
+ const targetId = edge.direction === "outgoing" ? edge.to : edge.from
150
+ if (targetId !== nodeId && targetId.startsWith("chunk:")) {
151
+ neighborChunkIds.add(targetId)
152
+ }
153
+ }
154
+
155
+ for (const chunkId of neighborChunkIds) {
156
+ if (workspaceCache.has(chunkId)) continue
157
+
158
+ try {
159
+ const chunk = await indexer.findChunkById(chunkId)
160
+ if (chunk && chunk.content) {
161
+ workspaceCache.attach({
162
+ chunkId,
163
+ path: chunk.file,
164
+ content: chunk.content,
165
+ chunkIndex: chunk.chunk_index ?? 0,
166
+ role: "search-graph",
167
+ attachedAt: Date.now(),
168
+ attachedBy: `explore(${nodeId})`,
169
+ metadata: {
170
+ language: chunk.language,
171
+ function_name: chunk.function_name,
172
+ class_name: chunk.class_name,
173
+ startLine: chunk.start_line,
174
+ endLine: chunk.end_line,
175
+ relation: "graph_neighbor",
176
+ mainChunkId: nodeId,
177
+ },
178
+ })
179
+ attachedChunks.push(chunkId)
180
+ }
181
+ } catch {
182
+ // Chunk not found — skip
183
+ }
184
+ }
185
+
186
+ if (attachedChunks.length > 0) {
187
+ workspaceCache.save().catch(() => {})
188
+ }
189
+
190
+ // ── Build output ──────────────────────────────────────────────────
191
+ let output = `## Explore: ${nodeId}\n\n`
192
+
193
+ if (outgoing.length > 0) {
194
+ output += `### Outgoing (${nodeId} →)\n\n`
195
+ const byPredicate = new Map<string, typeof outgoing>()
196
+ for (const e of outgoing) {
197
+ const arr = byPredicate.get(e.predicate) || []
198
+ arr.push(e)
199
+ byPredicate.set(e.predicate, arr)
200
+ }
201
+ for (const [predicate, edges] of byPredicate) {
202
+ output += `**${predicate}** (${edges.length}):\n`
203
+ for (const e of edges.sort((a, b) => b.weight - a.weight)) {
204
+ const inWs = workspaceCache.has(e.to) ? " ✓ws" : ""
205
+ output += `- → ${e.to} (weight: ${e.weight.toFixed(2)})${inWs}\n`
206
+ }
207
+ output += `\n`
208
+ }
209
+ }
210
+
211
+ if (incoming.length > 0) {
212
+ output += `### Incoming (→ ${nodeId})\n\n`
213
+ const byPredicate = new Map<string, typeof incoming>()
214
+ for (const e of incoming) {
215
+ const arr = byPredicate.get(e.predicate) || []
216
+ arr.push(e)
217
+ byPredicate.set(e.predicate, arr)
218
+ }
219
+ for (const [predicate, edges] of byPredicate) {
220
+ output += `**${predicate}** (${edges.length}):\n`
221
+ for (const e of edges.sort((a, b) => b.weight - a.weight)) {
222
+ const inWs = workspaceCache.has(e.from) ? " ✓ws" : ""
223
+ output += `- ← ${e.from} (weight: ${e.weight.toFixed(2)})${inWs}\n`
224
+ }
225
+ output += `\n`
226
+ }
227
+ }
228
+
229
+ if (deeper.length > 0) {
230
+ output += `### Depth 2 (${deeper.length} edges)\n\n`
231
+ for (const e of deeper.slice(0, 20)) {
232
+ const arrow = e.direction === "outgoing" ? "→" : "←"
233
+ output += `- ${e.from} ${arrow} **${e.predicate}** ${arrow} ${e.to}\n`
234
+ }
235
+ if (deeper.length > 20) {
236
+ output += `- ... and ${deeper.length - 20} more\n`
237
+ }
238
+ output += `\n`
239
+ }
240
+
241
+ output += `---\n`
242
+ output += `**Summary:** ${outgoing.length} outgoing, ${incoming.length} incoming`
243
+ if (deeper.length > 0) output += `, ${deeper.length} at depth 2`
244
+ output += `\n`
245
+
246
+ if (attachedChunks.length > 0) {
247
+ output += `**Attached ${attachedChunks.length} neighbor chunk(s) to workspace.**\n`
248
+ }
249
+
250
+ output += `\n**Next:**\n`
251
+ if (neighborChunkIds.size > 0) {
252
+ const example = [...neighborChunkIds][0]
253
+ output += `- Go deeper: \`explore({ node: "${example}" })\`\n`
254
+ }
255
+ output += `- Attach chunks: \`search({ queries: ["chunk-id-1", "chunk-id-2"] })\`\n`
256
+ output += `- Clean up: \`forget({ queries: ["old-file.ts", "5"] })\`\n`
257
+
258
+ output += buildWorkspaceOutput()
259
+ return output
260
+
261
+ } finally {
262
+ releaseIndexer(projectRoot, indexName)
263
+ }
264
+ },
265
+ })
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin"
2
2
  import path from "path"
3
3
  import fs from "fs/promises"
4
4
 
5
- import { CodebaseIndexer } from "../vectorizer/index.ts"
5
+ import { getIndexer, releaseIndexer } from "../vectorizer/index.ts"
6
6
 
7
7
  // FR-043: Logging for intercepted Read() calls
8
8
  const DEBUG = process.env.DEBUG?.includes("vectorizer") || process.env.DEBUG === "*"
@@ -48,6 +48,9 @@ async function logReadInterception(projectRoot: string, entry: ReadLogEntry): Pr
48
48
  }
49
49
  }
50
50
 
51
+ // STATUS: DISABLED — not registered in plugin index.ts tool:{} map.
52
+ // The workspace-injection approach (v6) handles read context.
53
+ // Kept for potential future re-enablement.
51
54
  export default tool({
52
55
  description: `Read file with graph-aware context attachment. When available, this tool searches the file in the index and returns content + related context from the graph (imports, links, etc.).
53
56
 
@@ -74,7 +77,7 @@ Use this instead of the standard Read tool for better context awareness.`,
74
77
  let searchFailed = false
75
78
 
76
79
  try {
77
- const indexer = await new CodebaseIndexer(projectRoot, "code").init()
80
+ const indexer = await getIndexer(projectRoot, "code")
78
81
  try {
79
82
  const results = await indexer.search(relPath, 20, false, {})
80
83
  fileChunks = results.filter((r: any) => r.file === relPath)
@@ -87,8 +90,9 @@ Use this instead of the standard Read tool for better context awareness.`,
87
90
  console.log(`[read-interceptor] Search failed for "${relPath}": ${searchErr.message}`)
88
91
  }
89
92
  searchFailed = true
93
+ } finally {
94
+ releaseIndexer(projectRoot, "code")
90
95
  }
91
- await indexer.unloadModel()
92
96
  } catch (initErr: any) {
93
97
  if (DEBUG) {
94
98
  console.log(`[read-interceptor] Indexer init failed: ${initErr.message}`)