ctxgraph 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.
- ctxgraph/__init__.py +0 -0
- ctxgraph/analyzers/__init__.py +0 -0
- ctxgraph/analyzers/python/__init__.py +0 -0
- ctxgraph/analyzers/python/importer.py +140 -0
- ctxgraph/analyzers/python/semantic.py +75 -0
- ctxgraph/analyzers/python/symbols.py +221 -0
- ctxgraph/capsule/__init__.py +0 -0
- ctxgraph/capsule/renderer.py +183 -0
- ctxgraph/cli/__init__.py +0 -0
- ctxgraph/cli/main.py +253 -0
- ctxgraph/clients/__init__.py +0 -0
- ctxgraph/clients/models.py +44 -0
- ctxgraph/config/__init__.py +0 -0
- ctxgraph/config/providers.py +150 -0
- ctxgraph/config/settings.py +232 -0
- ctxgraph/exclude/__init__.py +0 -0
- ctxgraph/exclude/patterns.py +75 -0
- ctxgraph/graph/__init__.py +0 -0
- ctxgraph/graph/builder.py +76 -0
- ctxgraph/graph/models.py +83 -0
- ctxgraph/graph/query.py +135 -0
- ctxgraph/graph/storage.py +224 -0
- ctxgraph/mcp/__init__.py +0 -0
- ctxgraph/mcp/server.py +216 -0
- ctxgraph/view/__init__.py +0 -0
- ctxgraph/view/visualizer.py +282 -0
- ctxgraph/wrapper/__init__.py +0 -0
- ctxgraph/wrapper/claude.py +139 -0
- ctxgraph-0.1.0.dist-info/METADATA +448 -0
- ctxgraph-0.1.0.dist-info/RECORD +33 -0
- ctxgraph-0.1.0.dist-info/WHEEL +5 -0
- ctxgraph-0.1.0.dist-info/entry_points.txt +3 -0
- ctxgraph-0.1.0.dist-info/top_level.txt +1 -0
ctxgraph/cli/main.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ctxgraph.capsule.renderer import render_capsule, render_project_overview
|
|
12
|
+
from ctxgraph.clients.models import ModelMode, get_mode_config
|
|
13
|
+
from ctxgraph.graph.builder import build_graph, get_storage
|
|
14
|
+
from ctxgraph.graph.query import search_relevant_nodes
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="ctx", help="Context graph engine for AI coding assistants")
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.callback()
|
|
21
|
+
def callback():
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def build(
|
|
27
|
+
repo_path: Optional[str] = typer.Argument(
|
|
28
|
+
None, help="Path to repository (default: current directory)"
|
|
29
|
+
),
|
|
30
|
+
repo: Optional[str] = typer.Option(
|
|
31
|
+
None, "--repo", "-r", help="Repository path (synonym for positional)"
|
|
32
|
+
),
|
|
33
|
+
exclude: Optional[list[str]] = typer.Option(
|
|
34
|
+
None, "--exclude", "-e", help="Additional exclude patterns"
|
|
35
|
+
),
|
|
36
|
+
db_path: Optional[str] = typer.Option(
|
|
37
|
+
None, "--db", "-d", help="Custom database path"
|
|
38
|
+
),
|
|
39
|
+
):
|
|
40
|
+
"""Build the knowledge graph for a repository."""
|
|
41
|
+
effective = repo or repo_path
|
|
42
|
+
path = Path(effective).resolve() if effective else Path.cwd()
|
|
43
|
+
|
|
44
|
+
if not (path / ".ctxgraph").exists():
|
|
45
|
+
(path / ".ctxgraph").mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
with console.status(f"Analyzing {path}..."):
|
|
48
|
+
stats = build_graph(path, db_path, exclude)
|
|
49
|
+
|
|
50
|
+
table = Table(title="Graph Build Complete")
|
|
51
|
+
table.add_column("Metric", style="cyan")
|
|
52
|
+
table.add_column("Value", style="green")
|
|
53
|
+
|
|
54
|
+
table.add_row("Files Analyzed", str(stats["files_analyzed"]))
|
|
55
|
+
table.add_row("Files Skipped", str(stats.get("files_skipped", 0)))
|
|
56
|
+
table.add_row("Errors", str(stats.get("errors", 0)))
|
|
57
|
+
table.add_row("Total Nodes", str(stats.get("total_nodes", 0)))
|
|
58
|
+
table.add_row("Total Edges", str(stats.get("total_edges", 0)))
|
|
59
|
+
table.add_row("Time", f"{stats.get('elapsed_seconds', 0)}s")
|
|
60
|
+
|
|
61
|
+
console.print(table)
|
|
62
|
+
console.print(f"\nGraph stored in: [bold]{path / '.ctxgraph' / 'graph.db'}[/bold]")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def capsule(
|
|
67
|
+
query: str = typer.Argument(..., help="Task description"),
|
|
68
|
+
repo_path: Optional[str] = typer.Option(
|
|
69
|
+
None, "--repo", "-r", help="Repository path"
|
|
70
|
+
),
|
|
71
|
+
max_nodes: Optional[int] = typer.Option(
|
|
72
|
+
None, "--max-nodes", "-n", help="Maximum nodes in capsule"
|
|
73
|
+
),
|
|
74
|
+
mode: str = typer.Option(
|
|
75
|
+
"balanced", "--mode", "-m", help="Model mode: fast, balanced, deep"
|
|
76
|
+
),
|
|
77
|
+
overview: bool = typer.Option(
|
|
78
|
+
False, "--overview", "-o", help="Generate project overview instead"
|
|
79
|
+
),
|
|
80
|
+
):
|
|
81
|
+
"""Generate a context capsule for Claude."""
|
|
82
|
+
path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
83
|
+
model_mode = ModelMode.from_str(mode)
|
|
84
|
+
mode_cfg = get_mode_config(model_mode)
|
|
85
|
+
|
|
86
|
+
storage = get_storage(path)
|
|
87
|
+
if storage is None:
|
|
88
|
+
console.print(
|
|
89
|
+
"[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
|
|
90
|
+
)
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
|
|
93
|
+
if overview:
|
|
94
|
+
result = render_project_overview(storage)
|
|
95
|
+
else:
|
|
96
|
+
result = render_capsule(
|
|
97
|
+
storage,
|
|
98
|
+
query,
|
|
99
|
+
max_nodes=max_nodes or mode_cfg["max_nodes"],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
console.print(result)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def query(
|
|
107
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
108
|
+
repo_path: Optional[str] = typer.Option(
|
|
109
|
+
None, "--repo", "-r", help="Repository path"
|
|
110
|
+
),
|
|
111
|
+
mode: str = typer.Option(
|
|
112
|
+
"balanced", "--mode", "-m", help="Model mode: fast, balanced, deep"
|
|
113
|
+
),
|
|
114
|
+
):
|
|
115
|
+
"""Search the knowledge graph."""
|
|
116
|
+
path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
117
|
+
model_mode = ModelMode.from_str(mode)
|
|
118
|
+
mode_cfg = get_mode_config(model_mode)
|
|
119
|
+
|
|
120
|
+
storage = get_storage(path)
|
|
121
|
+
if storage is None:
|
|
122
|
+
console.print(
|
|
123
|
+
"[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
|
|
124
|
+
)
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
|
|
127
|
+
results = search_relevant_nodes(
|
|
128
|
+
storage,
|
|
129
|
+
query,
|
|
130
|
+
max_nodes=mode_cfg["max_nodes"],
|
|
131
|
+
max_depth=mode_cfg["max_depth"],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not results:
|
|
135
|
+
console.print("[yellow]No matches found.[/yellow]")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
table = Table(title=f"Search Results: {query}")
|
|
139
|
+
table.add_column("Type", style="cyan")
|
|
140
|
+
table.add_column("Name", style="green")
|
|
141
|
+
table.add_column("Path", style="blue")
|
|
142
|
+
table.add_column("Relevance", style="yellow")
|
|
143
|
+
|
|
144
|
+
for node, score in results:
|
|
145
|
+
type_tag = {"file": "F", "class": "C", "function": "M", "module": "M"}
|
|
146
|
+
table.add_row(
|
|
147
|
+
type_tag.get(node.type, "?"),
|
|
148
|
+
node.name,
|
|
149
|
+
node.path or "-",
|
|
150
|
+
str(score),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
console.print(table)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def view(
|
|
158
|
+
repo_path: Optional[str] = typer.Option(
|
|
159
|
+
None, "--repo", "-r", help="Repository path"
|
|
160
|
+
),
|
|
161
|
+
port: Optional[int] = typer.Option(None, "--port", "-p", help="Port for server"),
|
|
162
|
+
output: Optional[str] = typer.Option(
|
|
163
|
+
None, "--output", "-o", help="Save HTML to file"
|
|
164
|
+
),
|
|
165
|
+
open_browser: bool = typer.Option(
|
|
166
|
+
True, "--open/--no-open", help="Open in browser automatically"
|
|
167
|
+
),
|
|
168
|
+
):
|
|
169
|
+
"""Visualize the dependency graph in a browser."""
|
|
170
|
+
from ctxgraph.view.visualizer import render_view
|
|
171
|
+
|
|
172
|
+
path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
173
|
+
storage = get_storage(path)
|
|
174
|
+
if storage is None:
|
|
175
|
+
console.print(
|
|
176
|
+
"[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
|
|
177
|
+
)
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
html = render_view(storage)
|
|
181
|
+
|
|
182
|
+
if output:
|
|
183
|
+
out_path = Path(output)
|
|
184
|
+
out_path.write_text(html, encoding="utf-8")
|
|
185
|
+
console.print(f"Saved to [bold]{out_path}[/bold]")
|
|
186
|
+
else:
|
|
187
|
+
out_path = path / ".ctxgraph" / "graph.html"
|
|
188
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
out_path.write_text(html, encoding="utf-8")
|
|
190
|
+
console.print(f"Saved to [bold]{out_path}[/bold]")
|
|
191
|
+
|
|
192
|
+
if open_browser:
|
|
193
|
+
import webbrowser
|
|
194
|
+
|
|
195
|
+
webbrowser.open(f"file://{out_path.absolute()}")
|
|
196
|
+
console.print("Opened in browser.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def serve(
|
|
201
|
+
repo_path: Optional[str] = typer.Option(
|
|
202
|
+
None, "--repo", "-r", help="Repository path"
|
|
203
|
+
),
|
|
204
|
+
port: Optional[int] = typer.Option(
|
|
205
|
+
None, "--port", "-p", help="Port for SSE mode (default: stdio mode)"
|
|
206
|
+
),
|
|
207
|
+
):
|
|
208
|
+
"""Start MCP server for dynamic graph queries (MCP protocol)."""
|
|
209
|
+
from ctxgraph.mcp.server import run_server
|
|
210
|
+
|
|
211
|
+
if port:
|
|
212
|
+
console.print(f"[yellow]SSE mode on port {port} - not yet supported, falling back to stdio[/yellow]")
|
|
213
|
+
run_server(repo_path)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command()
|
|
217
|
+
def info(
|
|
218
|
+
repo_path: Optional[str] = typer.Option(
|
|
219
|
+
None, "--repo", "-r", help="Repository path"
|
|
220
|
+
),
|
|
221
|
+
):
|
|
222
|
+
"""Show graph statistics."""
|
|
223
|
+
path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
224
|
+
storage = get_storage(path)
|
|
225
|
+
if storage is None:
|
|
226
|
+
console.print(
|
|
227
|
+
"[red]No graph found. Run [bold]ctx build[/bold] first.[/red]"
|
|
228
|
+
)
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
|
|
231
|
+
stats = storage.stats()
|
|
232
|
+
build_time = storage.get_metadata("build_time")
|
|
233
|
+
|
|
234
|
+
table = Table(title="Graph Info")
|
|
235
|
+
table.add_column("Metric", style="cyan")
|
|
236
|
+
table.add_column("Value", style="green")
|
|
237
|
+
|
|
238
|
+
table.add_row("Total Nodes", str(stats["nodes"]))
|
|
239
|
+
table.add_row("Total Edges", str(stats["edges"]))
|
|
240
|
+
|
|
241
|
+
plural_map = {"file": "files", "class": "classes", "function": "functions", "module": "modules"}
|
|
242
|
+
for t, cnt in stats.get("types", {}).items():
|
|
243
|
+
label = plural_map.get(t, t + "s")
|
|
244
|
+
table.add_row(f" {label}", str(cnt))
|
|
245
|
+
|
|
246
|
+
if build_time:
|
|
247
|
+
table.add_row("Last Build", build_time)
|
|
248
|
+
|
|
249
|
+
console.print(table)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelMode(str, Enum):
|
|
8
|
+
FAST = "fast"
|
|
9
|
+
BALANCED = "balanced"
|
|
10
|
+
DEEP = "deep"
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_str(cls, value: str) -> "ModelMode":
|
|
14
|
+
value = value.lower().strip()
|
|
15
|
+
for mode in cls:
|
|
16
|
+
if mode.value == value:
|
|
17
|
+
return mode
|
|
18
|
+
return cls.BALANCED
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MODE_CONFIG = {
|
|
22
|
+
ModelMode.FAST: {
|
|
23
|
+
"description": "Quick responses, minimal analysis (Sonnet-class)",
|
|
24
|
+
"max_nodes": 10,
|
|
25
|
+
"max_depth": 1,
|
|
26
|
+
"summary_length": 100,
|
|
27
|
+
},
|
|
28
|
+
ModelMode.BALANCED: {
|
|
29
|
+
"description": "Balanced speed and depth (default)",
|
|
30
|
+
"max_nodes": 20,
|
|
31
|
+
"max_depth": 2,
|
|
32
|
+
"summary_length": 200,
|
|
33
|
+
},
|
|
34
|
+
ModelMode.DEEP: {
|
|
35
|
+
"description": "Deep analysis, full context (Opus-class)",
|
|
36
|
+
"max_nodes": 40,
|
|
37
|
+
"max_depth": 3,
|
|
38
|
+
"summary_length": 500,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_mode_config(mode: ModelMode) -> dict:
|
|
44
|
+
return MODE_CONFIG[mode]
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ctxgraph.config.settings import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def chat_completion(
|
|
12
|
+
settings: Settings,
|
|
13
|
+
system_prompt: str,
|
|
14
|
+
user_prompt: str,
|
|
15
|
+
) -> Optional[str]:
|
|
16
|
+
provider = settings.provider
|
|
17
|
+
|
|
18
|
+
if provider == "ollama":
|
|
19
|
+
return _ollama_chat(settings, system_prompt, user_prompt)
|
|
20
|
+
elif provider == "claude":
|
|
21
|
+
return _claude_chat(settings, system_prompt, user_prompt)
|
|
22
|
+
elif provider == "openai":
|
|
23
|
+
return _openai_chat(settings, system_prompt, user_prompt)
|
|
24
|
+
else:
|
|
25
|
+
return _custom_chat(settings, system_prompt, user_prompt)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_summary(
|
|
29
|
+
settings: Settings,
|
|
30
|
+
code: str,
|
|
31
|
+
context: str = "",
|
|
32
|
+
) -> Optional[str]:
|
|
33
|
+
system = "You are a code analysis assistant. Summarize the following code concisely in 1-2 sentences."
|
|
34
|
+
user = f"Context: {context}\n\nCode:\n```python\n{code[:2000]}\n```"
|
|
35
|
+
|
|
36
|
+
return chat_completion(settings, system, user)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ollama_chat(settings: Settings, system: str, user: str) -> Optional[str]:
|
|
40
|
+
url = settings.get_chat_url()
|
|
41
|
+
payload = {
|
|
42
|
+
"model": settings.model,
|
|
43
|
+
"messages": [
|
|
44
|
+
{"role": "system", "content": system},
|
|
45
|
+
{"role": "user", "content": user},
|
|
46
|
+
],
|
|
47
|
+
"stream": False,
|
|
48
|
+
"temperature": settings.temperature,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
data = json.dumps(payload).encode()
|
|
53
|
+
req = urllib.request.Request(
|
|
54
|
+
url, data=data, headers={"Content-Type": "application/json"}
|
|
55
|
+
)
|
|
56
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
57
|
+
result = json.loads(resp.read())
|
|
58
|
+
return result.get("message", {}).get("content", "")
|
|
59
|
+
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _claude_chat(settings: Settings, system: str, user: str) -> Optional[str]:
|
|
64
|
+
api_key = settings.api_key
|
|
65
|
+
if not api_key:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
url = settings.get_chat_url()
|
|
69
|
+
payload = {
|
|
70
|
+
"model": settings.model,
|
|
71
|
+
"system": system,
|
|
72
|
+
"messages": [{"role": "user", "content": user}],
|
|
73
|
+
"max_tokens": settings.max_tokens,
|
|
74
|
+
"temperature": settings.temperature,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
data = json.dumps(payload).encode()
|
|
79
|
+
req = urllib.request.Request(
|
|
80
|
+
url,
|
|
81
|
+
data=data,
|
|
82
|
+
headers={
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"x-api-key": api_key,
|
|
85
|
+
"anthropic-version": "2023-06-01",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
89
|
+
result = json.loads(resp.read())
|
|
90
|
+
return result.get("content", [{}])[0].get("text", "")
|
|
91
|
+
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _openai_chat(settings: Settings, system: str, user: str) -> Optional[str]:
|
|
96
|
+
api_key = settings.api_key
|
|
97
|
+
if not api_key:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
url = settings.get_chat_url()
|
|
101
|
+
payload = {
|
|
102
|
+
"model": settings.model,
|
|
103
|
+
"messages": [
|
|
104
|
+
{"role": "system", "content": system},
|
|
105
|
+
{"role": "user", "content": user},
|
|
106
|
+
],
|
|
107
|
+
"max_tokens": settings.max_tokens,
|
|
108
|
+
"temperature": settings.temperature,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
data = json.dumps(payload).encode()
|
|
113
|
+
req = urllib.request.Request(
|
|
114
|
+
url,
|
|
115
|
+
data=data,
|
|
116
|
+
headers={
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"Authorization": f"Bearer {api_key}",
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
122
|
+
result = json.loads(resp.read())
|
|
123
|
+
return result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
124
|
+
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _custom_chat(settings: Settings, system: str, user: str) -> Optional[str]:
|
|
129
|
+
url = settings.get_chat_url()
|
|
130
|
+
payload = {
|
|
131
|
+
"model": settings.model,
|
|
132
|
+
"messages": [
|
|
133
|
+
{"role": "system", "content": system},
|
|
134
|
+
{"role": "user", "content": user},
|
|
135
|
+
],
|
|
136
|
+
"temperature": settings.temperature,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
headers = {"Content-Type": "application/json"}
|
|
140
|
+
if settings.api_key:
|
|
141
|
+
headers["Authorization"] = f"Bearer {settings.api_key}"
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
data = json.dumps(payload).encode()
|
|
145
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
146
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
147
|
+
result = json.loads(resp.read())
|
|
148
|
+
return json.dumps(result)
|
|
149
|
+
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
|
150
|
+
return None
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG = {
|
|
10
|
+
"graph": {
|
|
11
|
+
"exclude": [],
|
|
12
|
+
"follow_symlinks": False,
|
|
13
|
+
"max_file_size_mb": 5,
|
|
14
|
+
},
|
|
15
|
+
"ai": {
|
|
16
|
+
"provider": "ollama",
|
|
17
|
+
"model": "qwen2.5-coder:7b",
|
|
18
|
+
"endpoint": "http://localhost:11434",
|
|
19
|
+
"api_key": None,
|
|
20
|
+
"temperature": 0.1,
|
|
21
|
+
"max_tokens": 4096,
|
|
22
|
+
},
|
|
23
|
+
"context": {
|
|
24
|
+
"mode": "balanced",
|
|
25
|
+
"max_nodes": 20,
|
|
26
|
+
"max_depth": 2,
|
|
27
|
+
"max_tokens": 4000,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
PROVIDER_CONFIGS = {
|
|
32
|
+
"ollama": {
|
|
33
|
+
"endpoint_default": "http://localhost:11434",
|
|
34
|
+
"api_key_required": False,
|
|
35
|
+
"models": ["qwen2.5-coder:7b", "llama3.2", "llama3.1", "mistral", "codellama", "deepseek-coder"],
|
|
36
|
+
"chat_endpoint": "/api/chat",
|
|
37
|
+
"generate_endpoint": "/api/generate",
|
|
38
|
+
},
|
|
39
|
+
"claude": {
|
|
40
|
+
"endpoint_default": "https://api.anthropic.com/v1",
|
|
41
|
+
"api_key_required": True,
|
|
42
|
+
"models": ["claude-sonnet-4-20250514", "claude-haiku-3-5-20241022"],
|
|
43
|
+
"chat_endpoint": "/messages",
|
|
44
|
+
"api_key_env": "ANTHROPIC_API_KEY",
|
|
45
|
+
},
|
|
46
|
+
"openai": {
|
|
47
|
+
"endpoint_default": "https://api.openai.com/v1",
|
|
48
|
+
"api_key_required": True,
|
|
49
|
+
"models": ["gpt-4o", "gpt-4o-mini", "o3-mini"],
|
|
50
|
+
"chat_endpoint": "/chat/completions",
|
|
51
|
+
"api_key_env": "OPENAI_API_KEY",
|
|
52
|
+
},
|
|
53
|
+
"custom": {
|
|
54
|
+
"endpoint_default": None,
|
|
55
|
+
"api_key_required": False,
|
|
56
|
+
"models": [],
|
|
57
|
+
"chat_endpoint": "/v1/chat/completions",
|
|
58
|
+
"api_key_env": None,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Settings:
|
|
64
|
+
def __init__(self, repo_path: Optional[Path] = None):
|
|
65
|
+
self.repo_path = Path(repo_path).resolve() if repo_path else Path.cwd()
|
|
66
|
+
self._data = dict(DEFAULT_CONFIG)
|
|
67
|
+
self._load()
|
|
68
|
+
|
|
69
|
+
def _load(self):
|
|
70
|
+
config_paths = [
|
|
71
|
+
self.repo_path / ".ctxgraph" / "config.toml",
|
|
72
|
+
self.repo_path / ".ctxgraph" / "config.json",
|
|
73
|
+
self.repo_path / "ctxgraph.toml",
|
|
74
|
+
self.repo_path / "ctxgraph.json",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
for path in config_paths:
|
|
78
|
+
if path.exists():
|
|
79
|
+
self._load_file(path)
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
self._apply_env_overrides()
|
|
83
|
+
|
|
84
|
+
def _load_file(self, path: Path):
|
|
85
|
+
text = path.read_text(encoding="utf-8")
|
|
86
|
+
if path.suffix == ".json":
|
|
87
|
+
parsed = json.loads(text)
|
|
88
|
+
self._deep_merge(self._data, parsed)
|
|
89
|
+
elif path.suffix == ".toml":
|
|
90
|
+
parsed = self._parse_toml(text)
|
|
91
|
+
self._deep_merge(self._data, parsed)
|
|
92
|
+
|
|
93
|
+
def _apply_env_overrides(self):
|
|
94
|
+
provider = self._data["ai"]["provider"]
|
|
95
|
+
pconfig = PROVIDER_CONFIGS.get(provider, {})
|
|
96
|
+
|
|
97
|
+
api_key_env = pconfig.get("api_key_env")
|
|
98
|
+
if api_key_env:
|
|
99
|
+
env_key = os.environ.get(api_key_env)
|
|
100
|
+
if env_key:
|
|
101
|
+
self._data["ai"]["api_key"] = env_key
|
|
102
|
+
|
|
103
|
+
env_endpoint = os.environ.get("CTXGRAPH_ENDPOINT")
|
|
104
|
+
if env_endpoint:
|
|
105
|
+
self._data["ai"]["endpoint"] = env_endpoint
|
|
106
|
+
|
|
107
|
+
env_model = os.environ.get("CTXGRAPH_MODEL")
|
|
108
|
+
if env_model:
|
|
109
|
+
self._data["ai"]["model"] = env_model
|
|
110
|
+
|
|
111
|
+
env_provider = os.environ.get("CTXGRAPH_PROVIDER")
|
|
112
|
+
if env_provider:
|
|
113
|
+
self._data["ai"]["provider"] = env_provider
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def provider(self) -> str:
|
|
117
|
+
return self._data["ai"]["provider"]
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def model(self) -> str:
|
|
121
|
+
return self._data["ai"]["model"]
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def endpoint(self) -> str:
|
|
125
|
+
return self._data["ai"]["endpoint"]
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def api_key(self) -> Optional[str]:
|
|
129
|
+
return self._data["ai"].get("api_key")
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def temperature(self) -> float:
|
|
133
|
+
return self._data["ai"].get("temperature", 0.1)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def max_tokens(self) -> int:
|
|
137
|
+
return self._data["ai"].get("max_tokens", 4096)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def exclude_patterns(self) -> list[str]:
|
|
141
|
+
return self._data["graph"].get("exclude", [])
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def context_mode(self) -> str:
|
|
145
|
+
return self._data["context"].get("mode", "balanced")
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def max_nodes(self) -> int:
|
|
149
|
+
return self._data["context"].get("max_nodes", 20)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def max_depth(self) -> int:
|
|
153
|
+
return self._data["context"].get("max_depth", 2)
|
|
154
|
+
|
|
155
|
+
def get_provider_config(self) -> dict:
|
|
156
|
+
return PROVIDER_CONFIGS.get(self.provider, PROVIDER_CONFIGS["custom"])
|
|
157
|
+
|
|
158
|
+
def get_chat_url(self) -> str:
|
|
159
|
+
pconfig = self.get_provider_config()
|
|
160
|
+
endpoint = self.endpoint.rstrip("/")
|
|
161
|
+
return f"{endpoint}{pconfig.get('chat_endpoint', '/v1/chat/completions')}"
|
|
162
|
+
|
|
163
|
+
def to_dict(self) -> dict:
|
|
164
|
+
return dict(self._data)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _parse_toml(text: str) -> dict:
|
|
168
|
+
result = {}
|
|
169
|
+
current_section = result
|
|
170
|
+
|
|
171
|
+
for line in text.split("\n"):
|
|
172
|
+
line = line.strip()
|
|
173
|
+
if not line or line.startswith("#"):
|
|
174
|
+
continue
|
|
175
|
+
if line.startswith("[") and line.endswith("]"):
|
|
176
|
+
section_name = line[1:-1].strip()
|
|
177
|
+
current_section = result.setdefault(section_name, {})
|
|
178
|
+
elif "=" in line:
|
|
179
|
+
key, _, value = line.partition("=")
|
|
180
|
+
key = key.strip()
|
|
181
|
+
value = value.strip()
|
|
182
|
+
value = value.strip('"').strip("'")
|
|
183
|
+
current_section[key] = value
|
|
184
|
+
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _deep_merge(base: dict, override: dict):
|
|
189
|
+
for key, value in override.items():
|
|
190
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
191
|
+
Settings._deep_merge(base[key], value)
|
|
192
|
+
else:
|
|
193
|
+
base[key] = value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def create_default_config(repo_path: Path):
|
|
197
|
+
config_dir = repo_path / ".ctxgraph"
|
|
198
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
|
|
200
|
+
config_path = config_dir / "config.toml"
|
|
201
|
+
if config_path.exists():
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
config_path.write_text(
|
|
205
|
+
"""# ctxgraph configuration
|
|
206
|
+
# https://github.com/anomalyco/ctxgraph
|
|
207
|
+
|
|
208
|
+
[graph]
|
|
209
|
+
# Additional exclude patterns (gitignore is used automatically)
|
|
210
|
+
exclude = []
|
|
211
|
+
|
|
212
|
+
[ai]
|
|
213
|
+
# Provider: ollama, claude, openai, or custom
|
|
214
|
+
provider = "ollama"
|
|
215
|
+
# Model name
|
|
216
|
+
model = "qwen2.5-coder:7b"
|
|
217
|
+
# API endpoint
|
|
218
|
+
endpoint = "http://localhost:11434"
|
|
219
|
+
# API key (or set env var: ANTHROPIC_API_KEY, OPENAI_API_KEY)
|
|
220
|
+
api_key = ""
|
|
221
|
+
|
|
222
|
+
[context]
|
|
223
|
+
# Mode: fast, balanced, deep
|
|
224
|
+
mode = "balanced"
|
|
225
|
+
# Max nodes to include in context capsule
|
|
226
|
+
max_nodes = 20
|
|
227
|
+
# Max BFS depth for dependency traversal
|
|
228
|
+
max_depth = 2
|
|
229
|
+
""",
|
|
230
|
+
encoding="utf-8",
|
|
231
|
+
)
|
|
232
|
+
return config_path
|
|
File without changes
|