gra-context 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.
- gra/__init__.py +0 -0
- gra/__main__.py +3 -0
- gra/adapters/__init__.py +0 -0
- gra/adapters/symbol_graph_store.py +270 -0
- gra/cli.py +207 -0
- gra/core/__init__.py +0 -0
- gra/core/embedding_models.py +47 -0
- gra/core/env.py +52 -0
- gra/core/file_locks.py +19 -0
- gra/core/storage_paths.py +44 -0
- gra/core/symbol_types.py +62 -0
- gra/mcp_server.py +196 -0
- gra/pipelines/__init__.py +0 -0
- gra/pipelines/code_indexing_pipeline.py +211 -0
- gra/pipelines/context_retrieval_pipeline.py +176 -0
- gra/services/__init__.py +0 -0
- gra/services/context_assembly.py +500 -0
- gra/services/context_filter.py +153 -0
- gra/services/culprits.py +129 -0
- gra/services/python_symbol_extractor.py +394 -0
- gra/services/remote_explainer.py +79 -0
- gra/services/session_memory.py +451 -0
- gra/services/session_transcript.py +258 -0
- gra/services/symbol_explanation.py +238 -0
- gra/services/symbol_paths.py +107 -0
- gra/services/symbol_resolution.py +282 -0
- gra/services/symbol_search.py +334 -0
- gra/services/work_log.py +211 -0
- gra/watcher.py +202 -0
- gra_context-0.1.0.dist-info/METADATA +81 -0
- gra_context-0.1.0.dist-info/RECORD +35 -0
- gra_context-0.1.0.dist-info/WHEEL +5 -0
- gra_context-0.1.0.dist-info/entry_points.txt +2 -0
- gra_context-0.1.0.dist-info/licenses/LICENSE +11 -0
- gra_context-0.1.0.dist-info/top_level.txt +1 -0
gra/__init__.py
ADDED
|
File without changes
|
gra/__main__.py
ADDED
gra/adapters/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
|
9
|
+
|
|
10
|
+
from gra.core.symbol_types import Edge, Symbol
|
|
11
|
+
|
|
12
|
+
# Bump when a table changes shape. The database is a cache of the codebase, so
|
|
13
|
+
# an older one is emptied and rebuilt instead of migrated.
|
|
14
|
+
_SCHEMA_VERSION = 4
|
|
15
|
+
_TABLES = ("files", "symbols", "edges", "explanations", "embeddings")
|
|
16
|
+
|
|
17
|
+
_SCHEMA = """
|
|
18
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
19
|
+
path TEXT PRIMARY KEY,
|
|
20
|
+
content_hash TEXT NOT NULL,
|
|
21
|
+
extractor_version INTEGER NOT NULL,
|
|
22
|
+
extract_json TEXT NOT NULL
|
|
23
|
+
);
|
|
24
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
kind TEXT NOT NULL,
|
|
27
|
+
name TEXT NOT NULL,
|
|
28
|
+
qualname TEXT NOT NULL,
|
|
29
|
+
path TEXT NOT NULL,
|
|
30
|
+
start_line INTEGER NOT NULL,
|
|
31
|
+
end_line INTEGER NOT NULL,
|
|
32
|
+
signature TEXT NOT NULL,
|
|
33
|
+
docstring TEXT NOT NULL,
|
|
34
|
+
content_hash TEXT NOT NULL,
|
|
35
|
+
parent TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS symbols_name ON symbols (name);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS symbols_path ON symbols (path);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
40
|
+
source TEXT NOT NULL,
|
|
41
|
+
target TEXT NOT NULL,
|
|
42
|
+
kind TEXT NOT NULL,
|
|
43
|
+
confidence TEXT NOT NULL,
|
|
44
|
+
line INTEGER NOT NULL,
|
|
45
|
+
PRIMARY KEY (source, target, kind)
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS edges_target ON edges (target, kind);
|
|
48
|
+
-- Kept apart from symbols so rebuilding the map does not throw explanations away.
|
|
49
|
+
-- An explanation is current while its content_hash matches the symbol's.
|
|
50
|
+
CREATE TABLE IF NOT EXISTS explanations (
|
|
51
|
+
symbol_id TEXT PRIMARY KEY,
|
|
52
|
+
content_hash TEXT NOT NULL,
|
|
53
|
+
model TEXT NOT NULL,
|
|
54
|
+
prompt_version INTEGER NOT NULL,
|
|
55
|
+
summary TEXT NOT NULL,
|
|
56
|
+
details TEXT NOT NULL,
|
|
57
|
+
side_effects TEXT NOT NULL,
|
|
58
|
+
created_at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
-- The vector of a symbol's search text. Current while text_hash matches that text.
|
|
61
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
62
|
+
symbol_id TEXT PRIMARY KEY,
|
|
63
|
+
text_hash TEXT NOT NULL,
|
|
64
|
+
model TEXT NOT NULL,
|
|
65
|
+
vector BLOB NOT NULL
|
|
66
|
+
);
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
_SYMBOL_COLUMNS = (
|
|
70
|
+
"id", "kind", "name", "qualname", "path", "start_line", "end_line",
|
|
71
|
+
"signature", "docstring", "content_hash", "parent",
|
|
72
|
+
)
|
|
73
|
+
EXPLANATION_FIELDS = ("summary", "details", "side_effects")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SymbolGraphStore:
|
|
77
|
+
"""The symbol map of one codebase in a single SQLite file."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, db_path: str | Path):
|
|
80
|
+
self.db_path = Path(db_path)
|
|
81
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
with self._connect() as conn:
|
|
83
|
+
if conn.execute("PRAGMA user_version").fetchone()[0] != _SCHEMA_VERSION:
|
|
84
|
+
for table in _TABLES:
|
|
85
|
+
conn.execute(f"DROP TABLE IF EXISTS {table}")
|
|
86
|
+
conn.executescript(_SCHEMA)
|
|
87
|
+
conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
|
|
88
|
+
|
|
89
|
+
@contextmanager
|
|
90
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
91
|
+
conn = sqlite3.connect(self.db_path)
|
|
92
|
+
conn.row_factory = sqlite3.Row
|
|
93
|
+
try:
|
|
94
|
+
with conn: # one transaction: commit on success, roll back on error
|
|
95
|
+
yield conn
|
|
96
|
+
finally:
|
|
97
|
+
conn.close()
|
|
98
|
+
|
|
99
|
+
# -- files -------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def file_states(self) -> Dict[str, tuple[str, int]]:
|
|
102
|
+
"""path -> (content hash, extractor version) of every stored file."""
|
|
103
|
+
with self._connect() as conn:
|
|
104
|
+
rows = conn.execute("SELECT path, content_hash, extractor_version FROM files").fetchall()
|
|
105
|
+
return {row["path"]: (row["content_hash"], row["extractor_version"]) for row in rows}
|
|
106
|
+
|
|
107
|
+
def save_extracts(self, extracts: Iterable[Dict[str, Any]], extractor_version: int) -> None:
|
|
108
|
+
rows = [
|
|
109
|
+
(extract["path"], extract["content_hash"], extractor_version, json.dumps(extract))
|
|
110
|
+
for extract in extracts
|
|
111
|
+
]
|
|
112
|
+
with self._connect() as conn:
|
|
113
|
+
conn.executemany("INSERT OR REPLACE INTO files VALUES (?, ?, ?, ?)", rows)
|
|
114
|
+
|
|
115
|
+
def delete_files(self, paths: Iterable[str]) -> None:
|
|
116
|
+
with self._connect() as conn:
|
|
117
|
+
conn.executemany("DELETE FROM files WHERE path = ?", [(path,) for path in paths])
|
|
118
|
+
|
|
119
|
+
def load_extracts(self) -> List[Dict[str, Any]]:
|
|
120
|
+
with self._connect() as conn:
|
|
121
|
+
rows = conn.execute("SELECT extract_json FROM files ORDER BY path").fetchall()
|
|
122
|
+
return [json.loads(row["extract_json"]) for row in rows]
|
|
123
|
+
|
|
124
|
+
# -- graph -------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def replace_graph(self, symbols: Iterable[Symbol], edges: Iterable[Edge]) -> None:
|
|
127
|
+
"""Swap in a freshly resolved map and drop explanations of symbols that no longer exist."""
|
|
128
|
+
symbol_rows = [tuple(getattr(symbol, column) for column in _SYMBOL_COLUMNS) for symbol in symbols]
|
|
129
|
+
edge_rows = [(edge.source, edge.target, edge.kind, edge.confidence, edge.line) for edge in edges]
|
|
130
|
+
with self._connect() as conn:
|
|
131
|
+
conn.execute("DELETE FROM symbols")
|
|
132
|
+
conn.execute("DELETE FROM edges")
|
|
133
|
+
conn.executemany(f"INSERT INTO symbols VALUES ({', '.join('?' * len(_SYMBOL_COLUMNS))})", symbol_rows)
|
|
134
|
+
conn.executemany("INSERT INTO edges VALUES (?, ?, ?, ?, ?)", edge_rows)
|
|
135
|
+
conn.execute("DELETE FROM explanations WHERE symbol_id NOT IN (SELECT id FROM symbols)")
|
|
136
|
+
conn.execute("DELETE FROM embeddings WHERE symbol_id NOT IN (SELECT id FROM symbols)")
|
|
137
|
+
|
|
138
|
+
def symbols(self) -> List[Symbol]:
|
|
139
|
+
with self._connect() as conn:
|
|
140
|
+
rows = conn.execute(f"SELECT {', '.join(_SYMBOL_COLUMNS)} FROM symbols ORDER BY path, start_line").fetchall()
|
|
141
|
+
return [Symbol(**dict(row)) for row in rows]
|
|
142
|
+
|
|
143
|
+
def find_symbols(self, name: str) -> List[Symbol]:
|
|
144
|
+
"""Symbols whose id, qualified name or name equals `name`."""
|
|
145
|
+
with self._connect() as conn:
|
|
146
|
+
rows = conn.execute(
|
|
147
|
+
f"SELECT {', '.join(_SYMBOL_COLUMNS)} FROM symbols "
|
|
148
|
+
"WHERE id = ? OR qualname = ? OR name = ? ORDER BY path, start_line",
|
|
149
|
+
(name, name, name),
|
|
150
|
+
).fetchall()
|
|
151
|
+
return [Symbol(**dict(row)) for row in rows]
|
|
152
|
+
|
|
153
|
+
def edges(self) -> List[Edge]:
|
|
154
|
+
with self._connect() as conn:
|
|
155
|
+
rows = conn.execute("SELECT source, target, kind, confidence, line FROM edges").fetchall()
|
|
156
|
+
return [Edge(**dict(row)) for row in rows]
|
|
157
|
+
|
|
158
|
+
def edges_from(self, symbol_id: str) -> List[Edge]:
|
|
159
|
+
with self._connect() as conn:
|
|
160
|
+
rows = conn.execute(
|
|
161
|
+
"SELECT source, target, kind, confidence, line FROM edges WHERE source = ? ORDER BY kind, target",
|
|
162
|
+
(symbol_id,),
|
|
163
|
+
).fetchall()
|
|
164
|
+
return [Edge(**dict(row)) for row in rows]
|
|
165
|
+
|
|
166
|
+
def edges_to(self, symbol_id: str) -> List[Edge]:
|
|
167
|
+
with self._connect() as conn:
|
|
168
|
+
rows = conn.execute(
|
|
169
|
+
"SELECT source, target, kind, confidence, line FROM edges WHERE target = ? ORDER BY kind, source",
|
|
170
|
+
(symbol_id,),
|
|
171
|
+
).fetchall()
|
|
172
|
+
return [Edge(**dict(row)) for row in rows]
|
|
173
|
+
|
|
174
|
+
def counts(self) -> Dict[str, Dict[str, int]]:
|
|
175
|
+
with self._connect() as conn:
|
|
176
|
+
symbols = conn.execute("SELECT kind, COUNT(*) AS n FROM symbols GROUP BY kind").fetchall()
|
|
177
|
+
edges = conn.execute(
|
|
178
|
+
"SELECT kind || ' (' || confidence || ')' AS label, COUNT(*) AS n FROM edges GROUP BY label"
|
|
179
|
+
).fetchall()
|
|
180
|
+
return {
|
|
181
|
+
"symbols": {row["kind"]: row["n"] for row in symbols},
|
|
182
|
+
"edges": {row["label"]: row["n"] for row in edges},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# -- explanations ------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def explanations(self) -> Dict[str, Dict[str, str]]:
|
|
188
|
+
"""symbol id -> explanation, current ones only."""
|
|
189
|
+
with self._connect() as conn:
|
|
190
|
+
rows = conn.execute(
|
|
191
|
+
"SELECT e.* FROM explanations e JOIN symbols s "
|
|
192
|
+
"ON s.id = e.symbol_id AND s.content_hash = e.content_hash"
|
|
193
|
+
).fetchall()
|
|
194
|
+
return {row["symbol_id"]: dict(row) for row in rows}
|
|
195
|
+
|
|
196
|
+
def explanation(self, symbol_id: str) -> Optional[Dict[str, str]]:
|
|
197
|
+
return self.explanations().get(symbol_id)
|
|
198
|
+
|
|
199
|
+
def save_explanation(self, symbol: Symbol, model: str, fields: Dict[str, str], prompt_version: int = 0) -> None:
|
|
200
|
+
with self._connect() as conn:
|
|
201
|
+
conn.execute(
|
|
202
|
+
"INSERT OR REPLACE INTO explanations VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
203
|
+
(
|
|
204
|
+
symbol.id,
|
|
205
|
+
symbol.content_hash,
|
|
206
|
+
model,
|
|
207
|
+
prompt_version,
|
|
208
|
+
*(str(fields.get(name, "")) for name in EXPLANATION_FIELDS),
|
|
209
|
+
datetime.now(timezone.utc).isoformat(),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# -- embeddings --------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def embeddings(self, model: str) -> Dict[str, tuple[str, bytes]]:
|
|
216
|
+
"""symbol id -> (hash of the embedded text, vector as float32 bytes) for one embedding model."""
|
|
217
|
+
with self._connect() as conn:
|
|
218
|
+
rows = conn.execute(
|
|
219
|
+
"SELECT symbol_id, text_hash, vector FROM embeddings WHERE model = ?", (model,)
|
|
220
|
+
).fetchall()
|
|
221
|
+
return {row["symbol_id"]: (row["text_hash"], row["vector"]) for row in rows}
|
|
222
|
+
|
|
223
|
+
def save_embeddings(self, rows: Iterable[tuple[str, str, bytes]], model: str) -> None:
|
|
224
|
+
"""Store (symbol id, text hash, vector bytes) rows."""
|
|
225
|
+
with self._connect() as conn:
|
|
226
|
+
conn.executemany(
|
|
227
|
+
"INSERT OR REPLACE INTO embeddings VALUES (?, ?, ?, ?)",
|
|
228
|
+
[(symbol_id, text_hash, model, vector) for symbol_id, text_hash, vector in rows],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def reuse_explanations_from(self, other_db_path: str | Path) -> int:
|
|
232
|
+
"""Copy explanations from another map for symbols whose code is identical there.
|
|
233
|
+
|
|
234
|
+
Used to take explanations back from the main map when an edit is undone,
|
|
235
|
+
so the model is not asked again. Returns how many were copied.
|
|
236
|
+
"""
|
|
237
|
+
if not Path(other_db_path).is_file():
|
|
238
|
+
return 0
|
|
239
|
+
with self._connect() as conn:
|
|
240
|
+
conn.execute("ATTACH DATABASE ? AS other", (str(other_db_path),))
|
|
241
|
+
try:
|
|
242
|
+
has_table = conn.execute(
|
|
243
|
+
"SELECT 1 FROM other.sqlite_master WHERE type = 'table' AND name = 'explanations'"
|
|
244
|
+
).fetchone()
|
|
245
|
+
same_format = conn.execute("PRAGMA other.user_version").fetchone()[0] == _SCHEMA_VERSION
|
|
246
|
+
if not has_table or not same_format:
|
|
247
|
+
return 0 # nothing to copy, or written by an older version of this code
|
|
248
|
+
copied = conn.execute(
|
|
249
|
+
"INSERT OR REPLACE INTO explanations "
|
|
250
|
+
"SELECT o.* FROM other.explanations o JOIN symbols s "
|
|
251
|
+
"ON s.id = o.symbol_id AND s.content_hash = o.content_hash "
|
|
252
|
+
"WHERE NOT EXISTS (SELECT 1 FROM explanations e "
|
|
253
|
+
"WHERE e.symbol_id = o.symbol_id AND e.content_hash = o.content_hash)"
|
|
254
|
+
).rowcount
|
|
255
|
+
conn.commit()
|
|
256
|
+
return copied
|
|
257
|
+
finally:
|
|
258
|
+
conn.execute("DETACH DATABASE other")
|
|
259
|
+
|
|
260
|
+
def copy_to(self, other_db_path: str | Path) -> None:
|
|
261
|
+
"""Overwrite another database file with a consistent copy of this one."""
|
|
262
|
+
target_path = Path(other_db_path)
|
|
263
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
source = sqlite3.connect(self.db_path)
|
|
265
|
+
target = sqlite3.connect(target_path)
|
|
266
|
+
try:
|
|
267
|
+
source.backup(target)
|
|
268
|
+
finally:
|
|
269
|
+
target.close()
|
|
270
|
+
source.close()
|
gra/cli.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from gra.adapters.symbol_graph_store import EXPLANATION_FIELDS, SymbolGraphStore
|
|
12
|
+
from gra.core.env import set_config_value
|
|
13
|
+
from gra.core.storage_paths import code_index_dir_for, code_working_index_path_for
|
|
14
|
+
from gra.pipelines.code_indexing_pipeline import IndexState, index_codebase
|
|
15
|
+
from gra.pipelines.context_retrieval_pipeline import retrieve_context
|
|
16
|
+
from gra.services.context_filter import ContextFilter
|
|
17
|
+
from gra.services.symbol_search import SEARCH_MODES, ServiceRanker
|
|
18
|
+
|
|
19
|
+
from gra.watcher import CodebaseWatcher, explanation_model_problem, install_commit_hook
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _report(state: IndexState) -> str:
|
|
23
|
+
return json.dumps(
|
|
24
|
+
{
|
|
25
|
+
"python_files": len(state["files"]),
|
|
26
|
+
"changed": len(state["changed"]),
|
|
27
|
+
"removed": len(state["removed"]),
|
|
28
|
+
"not_valid_python_yet": state.get("skipped", []),
|
|
29
|
+
**state["counts"],
|
|
30
|
+
"explanations": state.get("explanations"),
|
|
31
|
+
"merged_into_main_map": state.get("merged", False),
|
|
32
|
+
},
|
|
33
|
+
indent=2,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SERVER_NAME = "gra-context"
|
|
38
|
+
CLAUDE_MD_LINE = (
|
|
39
|
+
"Before searching or reading files for a task, call the `get_code_context` tool to find the "
|
|
40
|
+
"code you need and understand it: where it is, what it does and how its parts connect. Then "
|
|
41
|
+
"read only the line ranges you will change. Ask again as new questions come up."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# gra setup [--api-key K] in a codebase: save settings and register the MCP server with Claude Code
|
|
46
|
+
# gra serve [--repo R] the MCP server itself (what Claude Code runs)
|
|
47
|
+
# gra install [repo] first build with explanations, plus a git post-commit hook
|
|
48
|
+
# gra watch [repo] keep the map current without Claude Code open
|
|
49
|
+
# gra index [repo] update the working copy once (--explain to call the model)
|
|
50
|
+
# gra commit [repo] update the working copy and merge it into the main map
|
|
51
|
+
# gra show <name> [repo] a symbol, its explanation and its connections
|
|
52
|
+
# gra query "task" ... [--repo R] what an agent gets back for these tasks
|
|
53
|
+
def main() -> None:
|
|
54
|
+
parser = argparse.ArgumentParser(prog="gra")
|
|
55
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
setup = commands.add_parser("setup")
|
|
58
|
+
setup.add_argument("--api-key", help="Your GRA API key, saved in ~/.config/gra/config.env.")
|
|
59
|
+
setup.add_argument("--api-url", help="The explanation service's address, if not the default.")
|
|
60
|
+
setup.add_argument("--context-filter", help='The model that leaves out noise, on GRA\'s service: "gra:default" (on with a key) or "off".')
|
|
61
|
+
setup.add_argument("--scope", choices=["local", "project"], default="local",
|
|
62
|
+
help="local: only you in this repo. project: everyone, via .mcp.json.")
|
|
63
|
+
setup.add_argument("--claude-md", action="store_true", help="Also add a line to CLAUDE.md telling Claude to use it.")
|
|
64
|
+
|
|
65
|
+
serve = commands.add_parser("serve")
|
|
66
|
+
serve.add_argument("--repo", default=".")
|
|
67
|
+
serve.add_argument("--no-watch", action="store_true", help="Do not keep the map and explanations current.")
|
|
68
|
+
|
|
69
|
+
for name in ("install", "watch", "index", "commit"):
|
|
70
|
+
command = commands.add_parser(name)
|
|
71
|
+
command.add_argument("repo", nargs="?", default=".", help="Codebase. Defaults to the current directory.")
|
|
72
|
+
if name in ("index", "commit"):
|
|
73
|
+
command.add_argument("--explain", action="store_true", help="Explain new and changed symbols.")
|
|
74
|
+
command.add_argument("--explain-limit", type=int, default=None, help="Explain at most this many.")
|
|
75
|
+
else:
|
|
76
|
+
command.add_argument("--no-explain", action="store_true", help="Build the map only; no model calls.")
|
|
77
|
+
if name == "watch":
|
|
78
|
+
command.add_argument("--interval", type=float, default=2.0, help="Seconds between checks.")
|
|
79
|
+
|
|
80
|
+
show = commands.add_parser("show")
|
|
81
|
+
show.add_argument("name", help="Symbol id, qualified name or plain name.")
|
|
82
|
+
show.add_argument("repo", nargs="?", default=".")
|
|
83
|
+
|
|
84
|
+
query = commands.add_parser("query")
|
|
85
|
+
query.add_argument("tasks", nargs="+", help="One or more tasks, each a short sentence.")
|
|
86
|
+
query.add_argument("--repo", default=".")
|
|
87
|
+
query.add_argument("--limit", type=int, default=8, help="Symbols per task.")
|
|
88
|
+
query.add_argument("--mode", choices=SEARCH_MODES, default="both", help="How tasks are matched to symbols.")
|
|
89
|
+
query.add_argument("--no-filter", action="store_true", help="Keep what the context filter would leave out.")
|
|
90
|
+
|
|
91
|
+
args = parser.parse_args()
|
|
92
|
+
|
|
93
|
+
if args.command == "setup":
|
|
94
|
+
_setup(args)
|
|
95
|
+
return
|
|
96
|
+
if args.command == "serve":
|
|
97
|
+
from gra.mcp_server import run
|
|
98
|
+
|
|
99
|
+
run(args.repo, watch=not args.no_watch)
|
|
100
|
+
return
|
|
101
|
+
if args.command == "show":
|
|
102
|
+
_show(args.name, args.repo)
|
|
103
|
+
return
|
|
104
|
+
if args.command == "query":
|
|
105
|
+
# No watcher runs behind a one-off query, so it prepares meaning search fully itself.
|
|
106
|
+
context_filter = None if args.no_filter else ContextFilter.from_settings()
|
|
107
|
+
print(retrieve_context(args.repo, args.tasks, limit=args.limit, mode=args.mode, embed_limit=None,
|
|
108
|
+
context_filter=context_filter, ranker=ServiceRanker.from_settings())["text"])
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if args.command in ("install", "watch"):
|
|
112
|
+
explain = not args.no_explain
|
|
113
|
+
problem = explanation_model_problem() if explain else ""
|
|
114
|
+
if problem:
|
|
115
|
+
print(f"Explanations are off, building the map only. {problem}")
|
|
116
|
+
explain = False
|
|
117
|
+
else:
|
|
118
|
+
explain = args.explain
|
|
119
|
+
|
|
120
|
+
if args.command == "install":
|
|
121
|
+
print(_report(index_codebase(args.repo, explain=explain, merge=True)))
|
|
122
|
+
print(f"Map: {code_index_dir_for(args.repo)}")
|
|
123
|
+
try:
|
|
124
|
+
print(f"Commit hook: {install_commit_hook(args.repo)}")
|
|
125
|
+
except FileExistsError as exc:
|
|
126
|
+
print(f"Commit hook not installed: {exc}")
|
|
127
|
+
except subprocess.CalledProcessError:
|
|
128
|
+
print("Commit hook not installed: not a git repository.")
|
|
129
|
+
elif args.command == "watch":
|
|
130
|
+
watcher = CodebaseWatcher(args.repo, explain=explain)
|
|
131
|
+
print(_report(watcher.start()))
|
|
132
|
+
print(f"Watching {Path(args.repo).resolve()} (Ctrl+C to stop)")
|
|
133
|
+
try:
|
|
134
|
+
while True:
|
|
135
|
+
time.sleep(args.interval)
|
|
136
|
+
state = watcher.tick()
|
|
137
|
+
if state is not None:
|
|
138
|
+
print(_report(state))
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
pass
|
|
141
|
+
else:
|
|
142
|
+
state = index_codebase(
|
|
143
|
+
args.repo,
|
|
144
|
+
explain=explain,
|
|
145
|
+
explain_limit=args.explain_limit,
|
|
146
|
+
merge=args.command == "commit",
|
|
147
|
+
)
|
|
148
|
+
print(_report(state))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _setup(args: argparse.Namespace) -> None:
|
|
152
|
+
for name, value in (("GRA_API_KEY", args.api_key), ("GRA_API_URL", args.api_url),
|
|
153
|
+
("CONTEXT_FILTER", args.context_filter)):
|
|
154
|
+
if value:
|
|
155
|
+
print(f"Saved {name} in {set_config_value(name, value)}")
|
|
156
|
+
problem = explanation_model_problem()
|
|
157
|
+
if problem:
|
|
158
|
+
print(f"Explanations will be off until this is fixed: {problem}")
|
|
159
|
+
|
|
160
|
+
repo = Path.cwd()
|
|
161
|
+
server_command = [sys.executable, "-m", "gra", "serve", "--repo", str(repo)]
|
|
162
|
+
register = ["claude", "mcp", "add", SERVER_NAME, "-s", args.scope, "--", *server_command]
|
|
163
|
+
if shutil.which("claude") is None:
|
|
164
|
+
print("Claude Code was not found. Register the server yourself:\n " + " ".join(register))
|
|
165
|
+
else:
|
|
166
|
+
result = subprocess.run(register, capture_output=True, text=True)
|
|
167
|
+
output = (result.stdout + result.stderr).strip()
|
|
168
|
+
if result.returncode == 0:
|
|
169
|
+
print(f"Registered {SERVER_NAME} with Claude Code for {repo} ({args.scope}).")
|
|
170
|
+
elif "already exists" in output:
|
|
171
|
+
print(f"{SERVER_NAME} is already registered here. To redo it: claude mcp remove {SERVER_NAME} -s {args.scope}")
|
|
172
|
+
else:
|
|
173
|
+
raise SystemExit(f"Registering with Claude Code failed:\n{output}")
|
|
174
|
+
|
|
175
|
+
if args.claude_md:
|
|
176
|
+
claude_md = repo / "CLAUDE.md"
|
|
177
|
+
existing = claude_md.read_text(encoding="utf-8") if claude_md.exists() else ""
|
|
178
|
+
if "get_code_context" not in existing:
|
|
179
|
+
claude_md.write_text(existing + ("\n" if existing and not existing.endswith("\n") else "")
|
|
180
|
+
+ ("\n" if existing else "") + CLAUDE_MD_LINE + "\n", encoding="utf-8")
|
|
181
|
+
print(f"Added a line to {claude_md}")
|
|
182
|
+
print("Start a new Claude Code session here. The map builds in the background on its first start.")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _show(name: str, repo: str) -> None:
|
|
186
|
+
db_path = code_working_index_path_for(repo)
|
|
187
|
+
store = SymbolGraphStore(db_path)
|
|
188
|
+
matches = store.find_symbols(name)
|
|
189
|
+
if not matches:
|
|
190
|
+
raise SystemExit(f"No symbol named {name!r} in {db_path}")
|
|
191
|
+
for symbol in matches:
|
|
192
|
+
print(f"{symbol.kind} {symbol.id} ({symbol.path}:{symbol.start_line}-{symbol.end_line})")
|
|
193
|
+
if symbol.signature:
|
|
194
|
+
print(f" {symbol.signature}")
|
|
195
|
+
explanation = store.explanation(symbol.id)
|
|
196
|
+
for field in EXPLANATION_FIELDS if explanation else ():
|
|
197
|
+
if explanation[field]:
|
|
198
|
+
print(f" {field}: {explanation[field]}")
|
|
199
|
+
for edge in store.edges_from(symbol.id):
|
|
200
|
+
print(f" -> {edge.kind:<10} {edge.target} [{edge.confidence}]")
|
|
201
|
+
for edge in store.edges_to(symbol.id):
|
|
202
|
+
print(f" <- {edge.kind:<10} {edge.source} [{edge.confidence}]")
|
|
203
|
+
print()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
main()
|
gra/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from langchain.embeddings import init_embeddings
|
|
6
|
+
from langchain_core.embeddings import Embeddings
|
|
7
|
+
|
|
8
|
+
from gra.core.env import get_env_value
|
|
9
|
+
|
|
10
|
+
# The model that turns text into vectors for meaning-based search, named
|
|
11
|
+
# "<provider>:<model>". The default runs on this machine: no key, no per-query
|
|
12
|
+
# cost, and the code never leaves it.
|
|
13
|
+
DEFAULT_EMBEDDING_MODEL = "fastembed:sentence-transformers/all-MiniLM-L6-v2"
|
|
14
|
+
|
|
15
|
+
_LOADED: Dict[str, Embeddings] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FastEmbedEmbeddings(Embeddings):
|
|
19
|
+
"""Small ONNX embedding models through fastembed: no PyTorch, a ~100 MB download on first use."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, model: str):
|
|
22
|
+
from fastembed import TextEmbedding
|
|
23
|
+
|
|
24
|
+
self._model = TextEmbedding(model_name=model)
|
|
25
|
+
|
|
26
|
+
# Memory grows steeply with the batch: 256 texts at once ran a 4 GB machine
|
|
27
|
+
# out of memory, 16 stay well under 1 GB at about the same speed.
|
|
28
|
+
BATCH_SIZE = 16
|
|
29
|
+
|
|
30
|
+
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
|
31
|
+
return [vector.tolist() for vector in self._model.embed(texts, batch_size=self.BATCH_SIZE)]
|
|
32
|
+
|
|
33
|
+
def embed_query(self, text: str) -> List[float]:
|
|
34
|
+
# query_embed adds the prefix a model expects on questions, where it has one.
|
|
35
|
+
return next(iter(self._model.query_embed(text))).tolist()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def embedding_model_name() -> str:
|
|
39
|
+
return get_env_value("EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL).strip() or DEFAULT_EMBEDDING_MODEL
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_embedding_model(name: str) -> Embeddings:
|
|
43
|
+
"""Build the embedding model once per process; a local model takes seconds to load."""
|
|
44
|
+
if name not in _LOADED:
|
|
45
|
+
provider, _, model = name.partition(":")
|
|
46
|
+
_LOADED[name] = FastEmbedEmbeddings(model) if provider == "fastembed" else init_embeddings(name)
|
|
47
|
+
return _LOADED[name]
|
gra/core/env.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def config_dir() -> Path:
|
|
8
|
+
"""Where GRA's settings file lives: $XDG_CONFIG_HOME/gra, else ~/.config/gra."""
|
|
9
|
+
base = os.getenv("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
10
|
+
return Path(base) / "gra"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_path() -> Path:
|
|
14
|
+
"""The settings file `gra setup` writes: KEY=value lines."""
|
|
15
|
+
return config_dir() / "config.env"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_env_value(
|
|
19
|
+
name: str,
|
|
20
|
+
default: str = "",
|
|
21
|
+
*,
|
|
22
|
+
env_path: str | Path | None = None,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""A setting: the process environment first, then the settings file, then `default`."""
|
|
25
|
+
value = os.getenv(name)
|
|
26
|
+
if value is not None:
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
env_path = Path(env_path) if env_path is not None else config_path()
|
|
30
|
+
if not env_path.exists():
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
34
|
+
line = raw_line.strip()
|
|
35
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
36
|
+
continue
|
|
37
|
+
key, raw_value = line.split("=", 1)
|
|
38
|
+
if key.strip() != name:
|
|
39
|
+
continue
|
|
40
|
+
return raw_value.strip().strip("'\"")
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def set_config_value(name: str, value: str) -> Path:
|
|
45
|
+
"""Write one setting into the settings file, keeping the others."""
|
|
46
|
+
path = config_path()
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
49
|
+
kept = [line for line in lines if line.split("=", 1)[0].strip() != name]
|
|
50
|
+
path.write_text("\n".join([*kept, f"{name}={value}"]) + "\n", encoding="utf-8")
|
|
51
|
+
path.chmod(0o600) # it can hold an API key
|
|
52
|
+
return path
|
gra/core/file_locks.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@contextmanager
|
|
10
|
+
def exclusive_file_lock(lock_path: str | Path) -> Iterator[None]:
|
|
11
|
+
"""Hold the lock file for the duration of the block; other holders, in any process, wait."""
|
|
12
|
+
path = Path(lock_path)
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
with path.open("a+", encoding="utf-8") as handle:
|
|
15
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
16
|
+
try:
|
|
17
|
+
yield
|
|
18
|
+
finally:
|
|
19
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from gra.core.env import get_env_value
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Everything GRA writes lives under one data folder, outside the codebases it indexes:
|
|
11
|
+
#
|
|
12
|
+
# <DATA_ROOT>/
|
|
13
|
+
# code_index/<repo>-<id>/ symbol map of one indexed codebase: working.sqlite follows the files
|
|
14
|
+
# as they are edited, symbols.sqlite is the map as of the last commit;
|
|
15
|
+
# work_log.sqlite is what was done there, across sessions
|
|
16
|
+
#
|
|
17
|
+
# DATA_ROOT is GRA_DATA_DIR when set, else $XDG_DATA_HOME/gra, else ~/.local/share/gra.
|
|
18
|
+
DATA_ROOT = Path(
|
|
19
|
+
get_env_value("GRA_DATA_DIR")
|
|
20
|
+
or Path(os.getenv("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "gra"
|
|
21
|
+
).expanduser()
|
|
22
|
+
CODE_INDEX_DIR = DATA_ROOT / "code_index"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def code_index_dir_for(repo_root: str | Path) -> Path:
|
|
26
|
+
"""Where the symbol map of a codebase lives, keyed by the codebase's absolute path."""
|
|
27
|
+
root = Path(repo_root).resolve()
|
|
28
|
+
digest = hashlib.sha1(str(root).encode("utf-8")).hexdigest()[:8]
|
|
29
|
+
return CODE_INDEX_DIR / f"{root.name}-{digest}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def code_index_path_for(repo_root: str | Path) -> Path:
|
|
33
|
+
"""The main map: the codebase as of the last commit."""
|
|
34
|
+
return code_index_dir_for(repo_root) / "symbols.sqlite"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def work_log_path_for(repo_root: str | Path) -> Path:
|
|
38
|
+
"""The work log: what Claude did for each task GRA answered, across sessions."""
|
|
39
|
+
return code_index_dir_for(repo_root) / "work_log.sqlite"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def code_working_index_path_for(repo_root: str | Path) -> Path:
|
|
43
|
+
"""The working copy of the map: the codebase as it is on disk right now."""
|
|
44
|
+
return code_index_dir_for(repo_root) / "working.sqlite"
|