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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DEFAULT_EXCLUDE = [
|
|
8
|
+
"__pycache__",
|
|
9
|
+
"*.pyc",
|
|
10
|
+
".git",
|
|
11
|
+
".svn",
|
|
12
|
+
".hg",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"venv",
|
|
15
|
+
".venv",
|
|
16
|
+
"env",
|
|
17
|
+
".env",
|
|
18
|
+
"dist",
|
|
19
|
+
"build",
|
|
20
|
+
"*.egg-info",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".mypy_cache",
|
|
23
|
+
".ruff_cache",
|
|
24
|
+
".tox",
|
|
25
|
+
".nox",
|
|
26
|
+
"migrations",
|
|
27
|
+
"*.min.js",
|
|
28
|
+
"*.min.css",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def should_exclude(
|
|
33
|
+
file_path: Path,
|
|
34
|
+
root_path: Path,
|
|
35
|
+
user_patterns: Optional[list[str]] = None,
|
|
36
|
+
) -> bool:
|
|
37
|
+
patterns = list(DEFAULT_EXCLUDE)
|
|
38
|
+
if user_patterns:
|
|
39
|
+
patterns.extend(user_patterns)
|
|
40
|
+
|
|
41
|
+
rel_path = _relative_path(file_path, root_path)
|
|
42
|
+
|
|
43
|
+
for pattern in patterns:
|
|
44
|
+
if _matches_pattern(rel_path, pattern):
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _matches_pattern(path: str, pattern: str) -> bool:
|
|
51
|
+
if pattern.startswith("*."):
|
|
52
|
+
return path.endswith(pattern[1:])
|
|
53
|
+
|
|
54
|
+
if pattern.endswith("/"):
|
|
55
|
+
return pattern.rstrip("/") in path.split("/")
|
|
56
|
+
|
|
57
|
+
if "*" not in pattern:
|
|
58
|
+
return pattern in path.split("/")
|
|
59
|
+
|
|
60
|
+
if pattern.startswith("*") and pattern.endswith("*"):
|
|
61
|
+
mid = pattern[1:-1]
|
|
62
|
+
return mid in path
|
|
63
|
+
elif pattern.startswith("*"):
|
|
64
|
+
return path.endswith(pattern[1:])
|
|
65
|
+
elif pattern.endswith("*"):
|
|
66
|
+
return path.startswith(pattern[:-1])
|
|
67
|
+
|
|
68
|
+
return pattern in path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _relative_path(file_path: Path, root_path: Path) -> str:
|
|
72
|
+
try:
|
|
73
|
+
return str(file_path.relative_to(root_path)).replace("\\", "/")
|
|
74
|
+
except ValueError:
|
|
75
|
+
return file_path.name
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ctxgraph.analyzers.python.importer import analyze_imports
|
|
8
|
+
from ctxgraph.analyzers.python.semantic import enrich_node_summary
|
|
9
|
+
from ctxgraph.analyzers.python.symbols import analyze_symbols
|
|
10
|
+
from ctxgraph.exclude.patterns import should_exclude
|
|
11
|
+
from ctxgraph.graph.models import Graph
|
|
12
|
+
from ctxgraph.graph.storage import Storage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_graph(
|
|
16
|
+
repo_path: str | Path,
|
|
17
|
+
db_path: Optional[str | Path] = None,
|
|
18
|
+
exclude_patterns: Optional[list[str]] = None,
|
|
19
|
+
) -> dict:
|
|
20
|
+
repo_path = Path(repo_path).resolve()
|
|
21
|
+
if db_path is None:
|
|
22
|
+
db_path = repo_path / ".ctxgraph" / "graph.db"
|
|
23
|
+
|
|
24
|
+
db_path = Path(db_path)
|
|
25
|
+
start = time.time()
|
|
26
|
+
|
|
27
|
+
storage = Storage(db_path)
|
|
28
|
+
storage.connect()
|
|
29
|
+
combined = Graph()
|
|
30
|
+
stats = {"files_analyzed": 0, "files_skipped": 0, "errors": 0}
|
|
31
|
+
|
|
32
|
+
python_files = list(repo_path.rglob("*.py"))
|
|
33
|
+
for file_path in python_files:
|
|
34
|
+
if should_exclude(file_path, repo_path, exclude_patterns):
|
|
35
|
+
stats["files_skipped"] += 1
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
import_graph = analyze_imports(file_path, repo_path)
|
|
40
|
+
combined.merge(import_graph)
|
|
41
|
+
|
|
42
|
+
symbol_graph = analyze_symbols(file_path, repo_path)
|
|
43
|
+
combined.merge(symbol_graph)
|
|
44
|
+
|
|
45
|
+
for node in combined.nodes.values():
|
|
46
|
+
if node.path and node.path in str(file_path):
|
|
47
|
+
if not node.summary:
|
|
48
|
+
summary = enrich_node_summary(node, file_path)
|
|
49
|
+
if summary:
|
|
50
|
+
node.summary = summary
|
|
51
|
+
|
|
52
|
+
stats["files_analyzed"] += 1
|
|
53
|
+
except Exception:
|
|
54
|
+
stats["errors"] += 1
|
|
55
|
+
|
|
56
|
+
storage.save_graph(combined)
|
|
57
|
+
storage.save_metadata("build_time", str(time.time()))
|
|
58
|
+
storage.save_metadata("repo_path", str(repo_path))
|
|
59
|
+
storage.save_metadata("file_count", str(stats["files_analyzed"]))
|
|
60
|
+
storage.close()
|
|
61
|
+
|
|
62
|
+
elapsed = time.time() - start
|
|
63
|
+
stats["elapsed_seconds"] = round(elapsed, 2)
|
|
64
|
+
stats["total_nodes"] = len(combined.nodes)
|
|
65
|
+
stats["total_edges"] = len(combined.edges)
|
|
66
|
+
|
|
67
|
+
return stats
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_storage(repo_path: str | Path) -> Optional[Storage]:
|
|
71
|
+
db_path = Path(repo_path) / ".ctxgraph" / "graph.db"
|
|
72
|
+
if not db_path.exists():
|
|
73
|
+
return None
|
|
74
|
+
storage = Storage(db_path)
|
|
75
|
+
storage.connect()
|
|
76
|
+
return storage
|
ctxgraph/graph/models.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Node:
|
|
9
|
+
id: str
|
|
10
|
+
type: str
|
|
11
|
+
name: str
|
|
12
|
+
path: Optional[str] = None
|
|
13
|
+
parent_id: Optional[str] = None
|
|
14
|
+
summary: Optional[str] = None
|
|
15
|
+
importance: float = 0.5
|
|
16
|
+
size_bytes: int = 0
|
|
17
|
+
lineno: int = 0
|
|
18
|
+
|
|
19
|
+
def __hash__(self):
|
|
20
|
+
return hash(self.id)
|
|
21
|
+
|
|
22
|
+
def __eq__(self, other):
|
|
23
|
+
return isinstance(other, Node) and self.id == other.id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Edge:
|
|
28
|
+
source_id: str
|
|
29
|
+
target_id: str
|
|
30
|
+
relation: str
|
|
31
|
+
weight: float = 1.0
|
|
32
|
+
|
|
33
|
+
def __hash__(self):
|
|
34
|
+
return hash((self.source_id, self.target_id, self.relation))
|
|
35
|
+
|
|
36
|
+
def __eq__(self, other):
|
|
37
|
+
return (
|
|
38
|
+
isinstance(other, Edge)
|
|
39
|
+
and self.source_id == other.source_id
|
|
40
|
+
and self.target_id == other.target_id
|
|
41
|
+
and self.relation == other.relation
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Graph:
|
|
47
|
+
nodes: dict[str, Node] = field(default_factory=dict)
|
|
48
|
+
edges: list[Edge] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
def add_node(self, node: Node):
|
|
51
|
+
self.nodes[node.id] = node
|
|
52
|
+
|
|
53
|
+
def add_edge(self, edge: Edge):
|
|
54
|
+
self.edges.append(edge)
|
|
55
|
+
|
|
56
|
+
def get_node(self, node_id: str) -> Optional[Node]:
|
|
57
|
+
return self.nodes.get(node_id)
|
|
58
|
+
|
|
59
|
+
def get_edges_from(self, source_id: str) -> list[Edge]:
|
|
60
|
+
return [e for e in self.edges if e.source_id == source_id]
|
|
61
|
+
|
|
62
|
+
def get_edges_to(self, target_id: str) -> list[Edge]:
|
|
63
|
+
return [e for e in self.edges if e.target_id == target_id]
|
|
64
|
+
|
|
65
|
+
def get_neighbors(self, node_id: str) -> list[str]:
|
|
66
|
+
result = set()
|
|
67
|
+
for e in self.edges:
|
|
68
|
+
if e.source_id == node_id:
|
|
69
|
+
result.add(e.target_id)
|
|
70
|
+
if e.target_id == node_id:
|
|
71
|
+
result.add(e.source_id)
|
|
72
|
+
return list(result)
|
|
73
|
+
|
|
74
|
+
def merge(self, other: Graph):
|
|
75
|
+
for node_id, node in other.nodes.items():
|
|
76
|
+
if node_id not in self.nodes:
|
|
77
|
+
self.nodes[node_id] = node
|
|
78
|
+
existing = {(e.source_id, e.target_id, e.relation) for e in self.edges}
|
|
79
|
+
for e in other.edges:
|
|
80
|
+
key = (e.source_id, e.target_id, e.relation)
|
|
81
|
+
if key not in existing:
|
|
82
|
+
self.edges.append(e)
|
|
83
|
+
existing.add(key)
|
ctxgraph/graph/query.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ctxgraph.graph.models import Node
|
|
8
|
+
from ctxgraph.graph.storage import Storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def search_relevant_nodes(
|
|
12
|
+
storage: Storage,
|
|
13
|
+
query: str,
|
|
14
|
+
max_nodes: int = 15,
|
|
15
|
+
max_depth: int = 2,
|
|
16
|
+
) -> list[tuple[Node, float]]:
|
|
17
|
+
tokens = _tokenize(query)
|
|
18
|
+
if not tokens:
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
scored: dict[str, float] = {}
|
|
22
|
+
seen_ids: set[str] = set()
|
|
23
|
+
|
|
24
|
+
matched_nodes = storage.search_nodes(query)
|
|
25
|
+
for node in matched_nodes:
|
|
26
|
+
score = _compute_relevance(node, tokens)
|
|
27
|
+
if score > 0:
|
|
28
|
+
scored[node.id] = score
|
|
29
|
+
seen_ids.add(node.id)
|
|
30
|
+
|
|
31
|
+
if not scored:
|
|
32
|
+
for token in tokens:
|
|
33
|
+
token_nodes = storage.search_nodes(token)
|
|
34
|
+
for node in token_nodes:
|
|
35
|
+
if node.id not in seen_ids:
|
|
36
|
+
seen_ids.add(node.id)
|
|
37
|
+
score = _compute_relevance(node, tokens)
|
|
38
|
+
if score > 0:
|
|
39
|
+
scored[node.id] = score
|
|
40
|
+
|
|
41
|
+
if not scored:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
seed_ids = set(scored.keys())
|
|
45
|
+
edge_ids = set()
|
|
46
|
+
|
|
47
|
+
for _ in range(max_depth):
|
|
48
|
+
edges = storage.get_edges_for_nodes(seed_ids | edge_ids)
|
|
49
|
+
new_ids = set()
|
|
50
|
+
for e in edges:
|
|
51
|
+
if e.source_id in (seed_ids | edge_ids):
|
|
52
|
+
new_ids.add(e.target_id)
|
|
53
|
+
if e.target_id in (seed_ids | edge_ids):
|
|
54
|
+
new_ids.add(e.source_id)
|
|
55
|
+
edge_ids |= new_ids
|
|
56
|
+
|
|
57
|
+
all_ids = seed_ids | edge_ids
|
|
58
|
+
for nid in edge_ids:
|
|
59
|
+
if nid not in scored:
|
|
60
|
+
node = storage.get_node(nid)
|
|
61
|
+
if node:
|
|
62
|
+
neighbors = _count_matched_neighbors(nid, storage, seed_ids)
|
|
63
|
+
scored[nid] = 0.1 * neighbors
|
|
64
|
+
|
|
65
|
+
ranked = sorted(scored.items(), key=lambda x: x[1], reverse=True)
|
|
66
|
+
ranked = ranked[:max_nodes]
|
|
67
|
+
|
|
68
|
+
result = []
|
|
69
|
+
for nid, score in ranked:
|
|
70
|
+
node = storage.get_node(nid)
|
|
71
|
+
if node:
|
|
72
|
+
result.append((node, round(score, 3)))
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate_context_subgraph(
|
|
78
|
+
storage: Storage,
|
|
79
|
+
query: str,
|
|
80
|
+
max_nodes: int = 15,
|
|
81
|
+
) -> tuple[list[Node], list[tuple[str, str, str]]]:
|
|
82
|
+
ranked = search_relevant_nodes(storage, query, max_nodes)
|
|
83
|
+
nodes = [n for n, _ in ranked]
|
|
84
|
+
node_ids = {n.id for n in nodes}
|
|
85
|
+
|
|
86
|
+
edges = storage.get_edges_for_nodes(node_ids)
|
|
87
|
+
edge_list = [
|
|
88
|
+
(e.source_id, e.target_id, e.relation)
|
|
89
|
+
for e in edges
|
|
90
|
+
if e.source_id in node_ids and e.target_id in node_ids
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
return nodes, edge_list
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tokenize(text: str) -> list[str]:
|
|
97
|
+
text = text.lower()
|
|
98
|
+
tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", text)
|
|
99
|
+
stopwords = {
|
|
100
|
+
"the", "a", "an", "in", "on", "at", "to", "for", "of", "is",
|
|
101
|
+
"fix", "bug", "implement", "add", "change", "update", "remove",
|
|
102
|
+
"need", "want", "please", "can", "how", "what", "where", "why",
|
|
103
|
+
"this", "that", "with", "from", "by", "be", "has", "have", "do",
|
|
104
|
+
"does", "did", "will", "would", "could", "should", "may", "might",
|
|
105
|
+
"file", "function", "class", "code", "issue", "problem", "error",
|
|
106
|
+
"work", "make", "get", "set",
|
|
107
|
+
}
|
|
108
|
+
return [t for t in tokens if t not in stopwords and len(t) > 1]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _compute_relevance(node: Node, tokens: list[str]) -> float:
|
|
112
|
+
score = 0.0
|
|
113
|
+
text = f"{node.name} {node.summary or ''} {node.path or ''}".lower()
|
|
114
|
+
|
|
115
|
+
for token in tokens:
|
|
116
|
+
if token in node.name.lower():
|
|
117
|
+
score += 2.0
|
|
118
|
+
count = text.count(token)
|
|
119
|
+
score += count * 0.5
|
|
120
|
+
|
|
121
|
+
if node.importance:
|
|
122
|
+
score *= (0.5 + node.importance)
|
|
123
|
+
|
|
124
|
+
return score
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _count_matched_neighbors(
|
|
128
|
+
node_id: str, storage: Storage, matched_ids: set[str]
|
|
129
|
+
) -> int:
|
|
130
|
+
edges = storage.get_edges_for_nodes({node_id})
|
|
131
|
+
count = 0
|
|
132
|
+
for e in edges:
|
|
133
|
+
if e.source_id in matched_ids or e.target_id in matched_ids:
|
|
134
|
+
count += 1
|
|
135
|
+
return count
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ctxgraph.graph.models import Edge, Graph, Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _table_schema() -> str:
|
|
11
|
+
return """
|
|
12
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
type TEXT NOT NULL,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
path TEXT,
|
|
17
|
+
parent_id TEXT,
|
|
18
|
+
summary TEXT,
|
|
19
|
+
importance REAL DEFAULT 0.5,
|
|
20
|
+
size_bytes INTEGER DEFAULT 0,
|
|
21
|
+
lineno INTEGER DEFAULT 0
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
25
|
+
source_id TEXT NOT NULL,
|
|
26
|
+
target_id TEXT NOT NULL,
|
|
27
|
+
relation TEXT NOT NULL,
|
|
28
|
+
weight REAL DEFAULT 1.0,
|
|
29
|
+
PRIMARY KEY (source_id, target_id, relation)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_path ON nodes(path);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
38
|
+
key TEXT PRIMARY KEY,
|
|
39
|
+
value TEXT
|
|
40
|
+
);
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Storage:
|
|
45
|
+
def __init__(self, db_path: str | Path):
|
|
46
|
+
self.db_path = Path(db_path)
|
|
47
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
49
|
+
|
|
50
|
+
def connect(self):
|
|
51
|
+
self._conn = sqlite3.connect(str(self.db_path))
|
|
52
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
53
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
54
|
+
self._conn.row_factory = sqlite3.Row
|
|
55
|
+
self._init_schema()
|
|
56
|
+
|
|
57
|
+
def _init_schema(self):
|
|
58
|
+
self._conn.executescript(_table_schema())
|
|
59
|
+
self._conn.commit()
|
|
60
|
+
|
|
61
|
+
def close(self):
|
|
62
|
+
if self._conn:
|
|
63
|
+
self._conn.close()
|
|
64
|
+
self._conn = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def conn(self) -> sqlite3.Connection:
|
|
68
|
+
if self._conn is None:
|
|
69
|
+
raise RuntimeError("Storage not connected. Call connect() first.")
|
|
70
|
+
return self._conn
|
|
71
|
+
|
|
72
|
+
def update_node_summary(self, node_id: str, summary: str):
|
|
73
|
+
self.conn.execute(
|
|
74
|
+
"UPDATE nodes SET summary = ? WHERE id = ?", (summary, node_id)
|
|
75
|
+
)
|
|
76
|
+
self.conn.commit()
|
|
77
|
+
|
|
78
|
+
def save_node(self, node: Node):
|
|
79
|
+
self.conn.execute(
|
|
80
|
+
"""INSERT OR REPLACE INTO nodes
|
|
81
|
+
(id, type, name, path, parent_id, summary, importance, size_bytes, lineno)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
83
|
+
(
|
|
84
|
+
node.id,
|
|
85
|
+
node.type,
|
|
86
|
+
node.name,
|
|
87
|
+
node.path,
|
|
88
|
+
node.parent_id,
|
|
89
|
+
node.summary,
|
|
90
|
+
node.importance,
|
|
91
|
+
node.size_bytes,
|
|
92
|
+
node.lineno,
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def save_edge(self, edge: Edge):
|
|
97
|
+
self.conn.execute(
|
|
98
|
+
"""INSERT OR REPLACE INTO edges
|
|
99
|
+
(source_id, target_id, relation, weight)
|
|
100
|
+
VALUES (?, ?, ?, ?)""",
|
|
101
|
+
(edge.source_id, edge.target_id, edge.relation, edge.weight),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def save_graph(self, graph: Graph):
|
|
105
|
+
for node in graph.nodes.values():
|
|
106
|
+
self.save_node(node)
|
|
107
|
+
for edge in graph.edges:
|
|
108
|
+
self.save_edge(edge)
|
|
109
|
+
self.conn.commit()
|
|
110
|
+
|
|
111
|
+
def get_node(self, node_id: str) -> Optional[Node]:
|
|
112
|
+
row = self.conn.execute(
|
|
113
|
+
"SELECT * FROM nodes WHERE id = ?", (node_id,)
|
|
114
|
+
).fetchone()
|
|
115
|
+
if row is None:
|
|
116
|
+
return None
|
|
117
|
+
return Node(
|
|
118
|
+
id=row["id"],
|
|
119
|
+
type=row["type"],
|
|
120
|
+
name=row["name"],
|
|
121
|
+
path=row["path"],
|
|
122
|
+
parent_id=row["parent_id"],
|
|
123
|
+
summary=row["summary"],
|
|
124
|
+
importance=row["importance"],
|
|
125
|
+
size_bytes=row["size_bytes"],
|
|
126
|
+
lineno=row["lineno"],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def search_nodes(self, text: str) -> list[Node]:
|
|
130
|
+
query = f"%{text}%"
|
|
131
|
+
rows = self.conn.execute(
|
|
132
|
+
"""SELECT * FROM nodes WHERE
|
|
133
|
+
name LIKE ? OR summary LIKE ? OR path LIKE ?
|
|
134
|
+
ORDER BY importance DESC
|
|
135
|
+
LIMIT 50""",
|
|
136
|
+
(query, query, query),
|
|
137
|
+
).fetchall()
|
|
138
|
+
return [
|
|
139
|
+
Node(
|
|
140
|
+
id=r["id"],
|
|
141
|
+
type=r["type"],
|
|
142
|
+
name=r["name"],
|
|
143
|
+
path=r["path"],
|
|
144
|
+
parent_id=r["parent_id"],
|
|
145
|
+
summary=r["summary"],
|
|
146
|
+
importance=r["importance"],
|
|
147
|
+
size_bytes=r["size_bytes"],
|
|
148
|
+
lineno=r["lineno"],
|
|
149
|
+
)
|
|
150
|
+
for r in rows
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def get_edges_for_nodes(self, node_ids: set[str]) -> list[Edge]:
|
|
154
|
+
if not node_ids:
|
|
155
|
+
return []
|
|
156
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
157
|
+
rows = self.conn.execute(
|
|
158
|
+
f"""SELECT * FROM edges WHERE
|
|
159
|
+
source_id IN ({placeholders}) OR target_id IN ({placeholders})""",
|
|
160
|
+
list(node_ids) + list(node_ids),
|
|
161
|
+
).fetchall()
|
|
162
|
+
return [
|
|
163
|
+
Edge(
|
|
164
|
+
source_id=r["source_id"],
|
|
165
|
+
target_id=r["target_id"],
|
|
166
|
+
relation=r["relation"],
|
|
167
|
+
weight=r["weight"],
|
|
168
|
+
)
|
|
169
|
+
for r in rows
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
def get_all_nodes(self) -> list[Node]:
|
|
173
|
+
rows = self.conn.execute("SELECT * FROM nodes").fetchall()
|
|
174
|
+
return [
|
|
175
|
+
Node(
|
|
176
|
+
id=r["id"],
|
|
177
|
+
type=r["type"],
|
|
178
|
+
name=r["name"],
|
|
179
|
+
path=r["path"],
|
|
180
|
+
parent_id=r["parent_id"],
|
|
181
|
+
summary=r["summary"],
|
|
182
|
+
importance=r["importance"],
|
|
183
|
+
size_bytes=r["size_bytes"],
|
|
184
|
+
lineno=r["lineno"],
|
|
185
|
+
)
|
|
186
|
+
for r in rows
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
def get_all_edges(self) -> list[Edge]:
|
|
190
|
+
rows = self.conn.execute("SELECT * FROM edges").fetchall()
|
|
191
|
+
return [
|
|
192
|
+
Edge(
|
|
193
|
+
source_id=r["source_id"],
|
|
194
|
+
target_id=r["target_id"],
|
|
195
|
+
relation=r["relation"],
|
|
196
|
+
weight=r["weight"],
|
|
197
|
+
)
|
|
198
|
+
for r in rows
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def stats(self) -> dict:
|
|
202
|
+
node_count = self.conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
|
|
203
|
+
edge_count = self.conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
|
204
|
+
type_counts = self.conn.execute(
|
|
205
|
+
"SELECT type, COUNT(*) as cnt FROM nodes GROUP BY type"
|
|
206
|
+
).fetchall()
|
|
207
|
+
return {
|
|
208
|
+
"nodes": node_count,
|
|
209
|
+
"edges": edge_count,
|
|
210
|
+
"types": {r["type"]: r["cnt"] for r in type_counts},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def save_metadata(self, key: str, value: str):
|
|
214
|
+
self.conn.execute(
|
|
215
|
+
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
|
|
216
|
+
(key, value),
|
|
217
|
+
)
|
|
218
|
+
self.conn.commit()
|
|
219
|
+
|
|
220
|
+
def get_metadata(self, key: str) -> Optional[str]:
|
|
221
|
+
row = self.conn.execute(
|
|
222
|
+
"SELECT value FROM metadata WHERE key = ?", (key,)
|
|
223
|
+
).fetchone()
|
|
224
|
+
return row["value"] if row else None
|
ctxgraph/mcp/__init__.py
ADDED
|
File without changes
|