codegraph-voyage 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ """Document construction from CodeGraph node metadata.
2
+
3
+ Builds symbol-level documents from indexed node metadata and source line
4
+ ranges read from the CodeGraph DB.
5
+ """
6
+
7
+ import hashlib
8
+ import sqlite3
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ class DocumentConstructionError(Exception):
14
+ """Raised when document construction fails."""
15
+
16
+
17
+ def _read_source_lines(root: Path, file_path: str, start_line: int, end_line: int) -> str:
18
+ """Read source lines from a file within the project root.
19
+
20
+ Returns empty string if the file cannot be read or is out of range.
21
+ """
22
+ # Resolve both paths so traversal (../) and symlink escapes cannot read
23
+ # arbitrary host files into an embedding document.
24
+ root_resolved = root.resolve()
25
+ try:
26
+ full = (root_resolved / file_path).resolve()
27
+ full.relative_to(root_resolved)
28
+ text = full.read_text(encoding="utf-8", errors="replace")
29
+ except (FileNotFoundError, IsADirectoryError, OSError, ValueError):
30
+ return ""
31
+ lines = text.splitlines()
32
+ # start_line/end_line are 1-indexed from CodeGraph
33
+ start = max(0, start_line - 1)
34
+ end = min(len(lines), end_line)
35
+ if start >= end:
36
+ return ""
37
+ return "\n".join(lines[start:end])
38
+
39
+
40
+ def build_document(
41
+ node: dict[str, Any],
42
+ root: Path,
43
+ *,
44
+ include_source: bool = True,
45
+ max_source_lines: int = 200,
46
+ ) -> str:
47
+ """Build a single symbol-level document from a CodeGraph node.
48
+
49
+ The document is a structured text block that combines metadata, docstring,
50
+ signature, and (optionally) source lines.
51
+
52
+ Args:
53
+ node: A dict with keys matching the CodeGraph nodes table columns.
54
+ root: Absolute project root path for reading source files.
55
+ include_source: Whether to include source lines in the document.
56
+ max_source_lines: Maximum number of source lines to include (prevents
57
+ giant documents from bloating embeddings).
58
+
59
+ Returns:
60
+ A plain-text document string suitable for embedding.
61
+ """
62
+ parts: list[str] = []
63
+
64
+ name = node.get("name", "").strip()
65
+ qualified_name = node.get("qualified_name", "").strip()
66
+ kind = node.get("kind", "").strip()
67
+ file_path = node.get("file_path", "").strip()
68
+ language = node.get("language", "").strip()
69
+ docstring = (node.get("docstring") or "").strip()
70
+ signature = (node.get("signature") or "").strip()
71
+ visibility = (node.get("visibility") or "").strip()
72
+ return_type = (node.get("return_type") or "").strip()
73
+ start_line = node.get("start_line")
74
+ end_line = node.get("end_line")
75
+
76
+ # --- Header ---
77
+ parts.append(f"Symbol: {name}")
78
+ if qualified_name and qualified_name != name:
79
+ parts.append(f"Qualified Name: {qualified_name}")
80
+ parts.append(f"Kind: {kind}")
81
+ parts.append(f"File: {file_path}")
82
+ if language:
83
+ parts.append(f"Language: {language}")
84
+ if start_line and end_line:
85
+ parts.append(f"Lines: {start_line}-{end_line}")
86
+ if visibility:
87
+ parts.append(f"Visibility: {visibility}")
88
+ if return_type:
89
+ parts.append(f"Return Type: {return_type}")
90
+
91
+ # --- Signature ---
92
+ if signature:
93
+ parts.append(f"\nSignature:\n{signature}")
94
+
95
+ # --- Docstring ---
96
+ if docstring:
97
+ parts.append(f"\nDocstring:\n{docstring}")
98
+
99
+ # --- Source lines ---
100
+ if include_source and start_line and end_line and file_path:
101
+ source = _read_source_lines(root, file_path, start_line, end_line)
102
+ if source:
103
+ src_lines = source.splitlines()
104
+ if len(src_lines) > max_source_lines:
105
+ src_lines = src_lines[:max_source_lines]
106
+ src_lines.append(
107
+ f"# ... truncated at {max_source_lines} lines for embedding"
108
+ )
109
+ parts.append("\nSource:\n" + "\n".join(src_lines))
110
+
111
+ return "\n".join(parts)
112
+
113
+
114
+ def compute_content_hash(document: str) -> str:
115
+ """Return SHA-256 hex digest of the document text."""
116
+ return hashlib.sha256(document.encode("utf-8")).hexdigest()
117
+
118
+
119
+ def build_documents_from_db(
120
+ codegraph_db: Path,
121
+ root: Path,
122
+ *,
123
+ include_source: bool = True,
124
+ max_source_lines: int = 200,
125
+ node_kinds: tuple[str, ...] | None = None,
126
+ file_filter: str | None = None,
127
+ ) -> list[dict[str, Any]]:
128
+ """Build documents for all (or filtered) nodes in the CodeGraph DB.
129
+
130
+ Returns a list of dicts with keys:
131
+ node_id, node_kind, name, qualified_name, file_path, document,
132
+ content_hash, language, start_line, end_line
133
+ """
134
+ if not codegraph_db.is_file():
135
+ raise DocumentConstructionError(
136
+ f"CodeGraph DB not found: {codegraph_db}"
137
+ )
138
+
139
+ conn = sqlite3.connect(f"file://{codegraph_db.resolve()}?mode=ro", uri=True)
140
+ try:
141
+ query = """
142
+ SELECT id, kind, name, qualified_name, file_path, language,
143
+ start_line, end_line, docstring, signature, visibility,
144
+ return_type
145
+ FROM nodes
146
+ WHERE 1=1
147
+ """
148
+ params: list[Any] = []
149
+ if node_kinds:
150
+ placeholders = ",".join("?" for _ in node_kinds)
151
+ query += f" AND kind IN ({placeholders})"
152
+ params.extend(node_kinds)
153
+ if file_filter:
154
+ query += " AND file_path LIKE ?"
155
+ params.append(f"%{file_filter}%")
156
+
157
+ rows = conn.execute(query, params).fetchall()
158
+ columns = [
159
+ "id", "kind", "name", "qualified_name", "file_path", "language",
160
+ "start_line", "end_line", "docstring", "signature", "visibility",
161
+ "return_type",
162
+ ]
163
+ finally:
164
+ conn.close()
165
+
166
+ results: list[dict[str, Any]] = []
167
+ for row in rows:
168
+ node = dict(zip(columns, row))
169
+ if not node.get("name") and not node.get("qualified_name"):
170
+ continue
171
+ doc = build_document(
172
+ node, root,
173
+ include_source=include_source,
174
+ max_source_lines=max_source_lines,
175
+ )
176
+ results.append({
177
+ "node_id": node["id"],
178
+ "node_kind": node["kind"],
179
+ "name": node["name"],
180
+ "qualified_name": node["qualified_name"],
181
+ "file_path": node["file_path"],
182
+ "language": node["language"],
183
+ "start_line": node["start_line"],
184
+ "end_line": node["end_line"],
185
+ "document": doc,
186
+ "content_hash": compute_content_hash(doc),
187
+ })
188
+
189
+ return results
190
+
191
+
192
+ def build_document_for_node_id(
193
+ codegraph_db: Path,
194
+ root: Path,
195
+ node_id: str,
196
+ *,
197
+ include_source: bool = True,
198
+ max_source_lines: int = 200,
199
+ ) -> dict[str, Any] | None:
200
+ """Build a document for a single node by its ID."""
201
+ conn = sqlite3.connect(f"file://{codegraph_db.resolve()}?mode=ro", uri=True)
202
+ try:
203
+ row = conn.execute(
204
+ """SELECT id, kind, name, qualified_name, file_path, language,
205
+ start_line, end_line, docstring, signature, visibility,
206
+ return_type
207
+ FROM nodes WHERE id = ?""",
208
+ (node_id,),
209
+ ).fetchone()
210
+ finally:
211
+ conn.close()
212
+
213
+ if row is None:
214
+ return None
215
+
216
+ columns = [
217
+ "id", "kind", "name", "qualified_name", "file_path", "language",
218
+ "start_line", "end_line", "docstring", "signature", "visibility",
219
+ "return_type",
220
+ ]
221
+ node = dict(zip(columns, row))
222
+ doc = build_document(
223
+ node, root,
224
+ include_source=include_source,
225
+ max_source_lines=max_source_lines,
226
+ )
227
+ return {
228
+ "node_id": node["id"],
229
+ "node_kind": node["kind"],
230
+ "name": node["name"],
231
+ "qualified_name": node["qualified_name"],
232
+ "file_path": node["file_path"],
233
+ "language": node["language"],
234
+ "start_line": node["start_line"],
235
+ "end_line": node["end_line"],
236
+ "document": doc,
237
+ "content_hash": compute_content_hash(doc),
238
+ }
@@ -0,0 +1,148 @@
1
+ """Integration with `codegraph explore` — invokes the codegraph CLI with semantic
2
+ candidates as the query input.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .ranking import RankingResult
15
+
16
+
17
+ def codegraph_explore(
18
+ candidates: list[RankingResult],
19
+ project_path: str | Path | None = None,
20
+ max_files: int = 12,
21
+ codegraph_bin: str = "codegraph",
22
+ *,
23
+ dry_run: bool = False,
24
+ timeout: int = 60,
25
+ ) -> dict[str, Any]:
26
+ """Invoke `codegraph explore` with semantic candidate symbols.
27
+
28
+ Builds a query string from the top-ranked candidate names and qualified
29
+ names, then runs `codegraph explore <query>`.
30
+
31
+ Args:
32
+ candidates: Ranked results from hybrid search.
33
+ project_path: Project root path (for -p flag).
34
+ max_files: Max files for codegraph explore.
35
+ codegraph_bin: Path to the codegraph binary.
36
+ dry_run: If True, return the command string without running.
37
+ timeout: Timeout in seconds for the subprocess call.
38
+
39
+ Returns:
40
+ Dict with keys: command, stdout, stderr, returncode, error.
41
+ """
42
+ # Build query from top candidates — use names and qualified names
43
+ query_parts: list[str] = []
44
+ seen: set[str] = set()
45
+ for c in candidates:
46
+ for part in [c.qualified_name, c.name]:
47
+ if part and part not in seen:
48
+ query_parts.append(part)
49
+ seen.add(part)
50
+ if len(query_parts) >= 20:
51
+ break
52
+ if len(query_parts) >= 20:
53
+ break
54
+
55
+ if not query_parts:
56
+ return {
57
+ "command": "",
58
+ "stdout": "",
59
+ "stderr": "No candidate symbols to explore.",
60
+ "returncode": 1,
61
+ "error": "No candidates",
62
+ }
63
+
64
+ query_str = " ".join(query_parts)
65
+
66
+ cmd = [codegraph_bin, "explore"]
67
+ if project_path:
68
+ cmd.extend(["-p", str(project_path)])
69
+ cmd.append(query_str)
70
+ if max_files:
71
+ cmd.extend(["--max-files", str(max_files)])
72
+
73
+ if dry_run:
74
+ return {
75
+ "command": " ".join(cmd),
76
+ "stdout": "",
77
+ "stderr": "",
78
+ "returncode": 0,
79
+ "error": None,
80
+ "query_symbols": query_parts,
81
+ }
82
+
83
+ try:
84
+ result = subprocess.run(
85
+ cmd,
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=timeout,
89
+ )
90
+ return {
91
+ "command": " ".join(cmd),
92
+ "stdout": result.stdout,
93
+ "stderr": result.stderr,
94
+ "returncode": result.returncode,
95
+ "error": None if result.returncode == 0 else result.stderr.strip(),
96
+ "query_symbols": query_parts,
97
+ }
98
+ except FileNotFoundError:
99
+ return {
100
+ "command": " ".join(cmd),
101
+ "stdout": "",
102
+ "stderr": f"codegraph binary not found: {codegraph_bin}",
103
+ "returncode": -1,
104
+ "error": f"Binary not found: {codegraph_bin}",
105
+ }
106
+ except subprocess.TimeoutExpired:
107
+ return {
108
+ "command": " ".join(cmd),
109
+ "stdout": "",
110
+ "stderr": f"codegraph explore timed out after {timeout}s",
111
+ "returncode": -1,
112
+ "error": "Timeout",
113
+ }
114
+ except Exception as exc:
115
+ return {
116
+ "command": " ".join(cmd),
117
+ "stdout": "",
118
+ "stderr": str(exc),
119
+ "returncode": -1,
120
+ "error": str(exc),
121
+ }
122
+
123
+
124
+ def build_explore_query(
125
+ candidates: list[RankingResult],
126
+ max_symbols: int = 20,
127
+ ) -> str:
128
+ """Build a query string from ranked candidates for codegraph explore.
129
+
130
+ Uses the top-ranked candidates, preferring qualified names over simple
131
+ names, and deduplicating.
132
+ """
133
+ parts: list[str] = []
134
+ seen: set[str] = set()
135
+ for c in candidates:
136
+ for field in [c.qualified_name, c.name, c.file_path]:
137
+ if not field:
138
+ continue
139
+ # Transform paths first, then deduplicate the transformed value.
140
+ value = Path(field).stem if field == c.file_path else field
141
+ if value not in seen:
142
+ parts.append(value)
143
+ seen.add(value)
144
+ if len(parts) >= max_symbols:
145
+ break
146
+ if len(parts) >= max_symbols:
147
+ break
148
+ return " ".join(parts[:max_symbols])
@@ -0,0 +1,78 @@
1
+
2
+ import json
3
+ import subprocess
4
+ import os
5
+ import sys
6
+ from typing import Any
7
+ from mcp.server.mcpserver import MCPServer
8
+
9
+ # Initialize MCPServer server
10
+ mcp = MCPServer("codegraph-voyage")
11
+
12
+ def _run_cli(*args: str) -> subprocess.CompletedProcess:
13
+ """Helper to run the codegraph_voyage CLI as a subprocess."""
14
+ cmd = ["codegraph-voyage"] + list(args)
15
+ return subprocess.run(
16
+ cmd,
17
+ capture_output=True,
18
+ text=True,
19
+ check=False
20
+ )
21
+
22
+ @mcp.tool()
23
+ def search_codebase(query: str, top_k: int = 10) -> str:
24
+ """
25
+ Perform a hybrid semantic search to find symbols in the codebase related to the query.
26
+ This is extremely useful to pinpoint where specific features, concepts, or terms are implemented.
27
+ Returns a JSON string of ranked results.
28
+ """
29
+ try:
30
+ # Default to voyage provider instead of fake
31
+ result = _run_cli("search", query, "--top-k", str(top_k), "--json", "--provider", "voyage")
32
+ if result.returncode != 0:
33
+ return json.dumps({"error": result.stderr.strip()})
34
+ return result.stdout.strip()
35
+ except Exception as e:
36
+ return json.dumps({"error": str(e)})
37
+
38
+ @mcp.tool()
39
+ def explore_codebase(query: str, max_files: int = 12) -> str:
40
+ """
41
+ Perform a semantic search to find symbols related to the query, and then walk the dependency graph
42
+ (using `codegraph explore`) to gather the full codebase context around those symbols.
43
+ Use this to get 'wide-sprawl' context for complex features or dependencies.
44
+ """
45
+ try:
46
+ # The explore command outputs text (not json currently) which is perfectly readable context
47
+ result = _run_cli("explore", query, "--max-files", str(max_files), "--provider", "voyage")
48
+ if result.returncode != 0:
49
+ return json.dumps({"error": result.stderr.strip()})
50
+ return result.stdout.strip()
51
+ except Exception as e:
52
+ return json.dumps({"error": str(e)})
53
+
54
+ @mcp.tool()
55
+ def index_codebase(kind: str = "function,class", file_filter: str = "") -> str:
56
+ """
57
+ Rebuild the codebase embedding index.
58
+ Call this if the codebase has changed significantly and you need fresh embeddings.
59
+ """
60
+ try:
61
+ args = ["index", "--provider", "voyage"]
62
+ if kind:
63
+ args.extend(["--kind", kind])
64
+ if file_filter:
65
+ args.extend(["--file-filter", file_filter])
66
+
67
+ result = _run_cli(*args)
68
+ if result.returncode != 0:
69
+ return json.dumps({"error": result.stderr.strip()})
70
+ return result.stdout.strip()
71
+ except Exception as e:
72
+ return json.dumps({"error": str(e)})
73
+
74
+ def main():
75
+ mcp.run()
76
+
77
+ if __name__ == "__main__":
78
+ main()