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 ADDED
File without changes
gra/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from gra.cli import main
2
+
3
+ main()
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"