repobrief 0.1.1__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.
repobrief/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RepoBrief — Pack any repo into clean LLM context, then chat with it."""
2
+
3
+ __version__ = "0.1.1"
File without changes
@@ -0,0 +1,70 @@
1
+ """Abstract base class for LLM backends.
2
+
3
+ All backends (cloud, Ollama, future local runtimes) must implement this interface.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class ChatMessage:
15
+ """A single message in a conversation.
16
+
17
+ Attributes:
18
+ role: 'user', 'assistant', or 'system'.
19
+ content: The message text.
20
+ """
21
+
22
+ role: str # "user" | "assistant" | "system"
23
+ content: str
24
+
25
+
26
+ class LLMBackend(ABC):
27
+ """Abstract interface for LLM backends.
28
+
29
+ Implementing classes must provide:
30
+ - generate(): Send a prompt and stream back response tokens
31
+ - validate(): Check that the backend is properly configured and reachable
32
+ """
33
+
34
+ @abstractmethod
35
+ def generate(
36
+ self,
37
+ system_prompt: str,
38
+ user_message: str,
39
+ history: list[ChatMessage] | None = None,
40
+ ) -> Iterator[str]:
41
+ """Send a prompt and yield response tokens as they stream in.
42
+
43
+ Args:
44
+ system_prompt: System-level instructions (includes the repo digest).
45
+ user_message: The user's question.
46
+ history: Previous conversation messages (for REPL mode).
47
+
48
+ Yields:
49
+ String chunks of the response as they arrive.
50
+
51
+ Raises:
52
+ ConnectionError: If the backend is unreachable.
53
+ PermissionError: If authentication fails (bad API key).
54
+ RuntimeError: For other backend errors.
55
+ """
56
+
57
+ @abstractmethod
58
+ def validate(self) -> tuple[bool, str]:
59
+ """Check if the backend is properly configured and reachable.
60
+
61
+ Returns:
62
+ Tuple of (is_valid, message).
63
+ - (True, "Ready") if everything is configured.
64
+ - (False, "Missing API key...") if there's a problem.
65
+ """
66
+
67
+ @property
68
+ @abstractmethod
69
+ def name(self) -> str:
70
+ """Human-readable name of this backend (e.g., 'Anthropic Claude')."""
@@ -0,0 +1,227 @@
1
+ """Cloud LLM backend -- supports Anthropic (Claude) and OpenAI (GPT) APIs.
2
+
3
+ Determines which provider to use based on the model name:
4
+ - Models starting with 'claude' -> Anthropic API
5
+ - Models starting with 'gpt' or 'o1' -> OpenAI API
6
+ - Explicit provider prefix: 'anthropic:model-name' or 'openai:model-name'
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from collections.abc import Iterator
13
+
14
+ from repobrief.backends.base import ChatMessage, LLMBackend
15
+
16
+ # -- Provider Detection --------------------------------------------------------
17
+
18
+
19
+ def _detect_provider(model: str) -> str:
20
+ """Detect the API provider from the model name.
21
+
22
+ Args:
23
+ model: Model name string.
24
+
25
+ Returns:
26
+ 'anthropic' or 'openai'.
27
+
28
+ Raises:
29
+ ValueError: If the provider cannot be determined.
30
+ """
31
+ model_lower = model.lower()
32
+
33
+ # Explicit prefix
34
+ if model_lower.startswith("anthropic:"):
35
+ return "anthropic"
36
+ if model_lower.startswith("openai:"):
37
+ return "openai"
38
+
39
+ # Infer from model name
40
+ if model_lower.startswith("claude"):
41
+ return "anthropic"
42
+ if model_lower.startswith(("gpt", "o1", "o3")):
43
+ return "openai"
44
+
45
+ raise ValueError(
46
+ f"Cannot determine provider for model '{model}'. "
47
+ f"Use a prefix: 'anthropic:{model}' or 'openai:{model}'"
48
+ )
49
+
50
+
51
+ def _strip_provider_prefix(model: str) -> str:
52
+ """Remove the provider prefix from a model name."""
53
+ if ":" in model:
54
+ return model.split(":", 1)[1]
55
+ return model
56
+
57
+
58
+ class CloudBackend(LLMBackend):
59
+ """Cloud LLM backend using Anthropic or OpenAI APIs.
60
+
61
+ Usage:
62
+ backend = CloudBackend(model="claude-sonnet-4-20250514", api_key="sk-...")
63
+ for chunk in backend.generate(system_prompt, question):
64
+ print(chunk, end="")
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ model: str = "claude-sonnet-4-20250514",
70
+ api_key: str | None = None,
71
+ max_output_tokens: int = 4096,
72
+ ):
73
+ """Initialize the cloud backend.
74
+
75
+ Args:
76
+ model: Model identifier (e.g., 'claude-sonnet-4-20250514', 'gpt-4o').
77
+ api_key: API key. If None, reads from environment variables.
78
+ max_output_tokens: Maximum tokens in the response.
79
+ """
80
+ self._model = _strip_provider_prefix(model)
81
+ self._provider = _detect_provider(model)
82
+ self._max_output_tokens = max_output_tokens
83
+
84
+ # Resolve API key
85
+ if api_key:
86
+ self._api_key = api_key
87
+ elif self._provider == "anthropic":
88
+ self._api_key = os.environ.get("ANTHROPIC_API_KEY")
89
+ elif self._provider == "openai":
90
+ self._api_key = os.environ.get("OPENAI_API_KEY")
91
+ else:
92
+ self._api_key = None
93
+
94
+ @property
95
+ def name(self) -> str:
96
+ provider_name = "Anthropic Claude" if self._provider == "anthropic" else "OpenAI"
97
+ return f"{provider_name} ({self._model})"
98
+
99
+ def validate(self) -> tuple[bool, str]:
100
+ """Check that the API key is set and the SDK is installed."""
101
+ if not self._api_key:
102
+ env_var = "ANTHROPIC_API_KEY" if self._provider == "anthropic" else "OPENAI_API_KEY"
103
+ return (
104
+ False,
105
+ f"No API key found. Set the {env_var} environment variable, "
106
+ f"or pass it in .repobrief.yml under 'api_key'.\n"
107
+ f"Example: export {env_var}=sk-your-key-here",
108
+ )
109
+
110
+ # Check if the SDK is installed
111
+ try:
112
+ if self._provider == "anthropic":
113
+ import anthropic # noqa: F401
114
+ else:
115
+ import openai # noqa: F401
116
+ except ImportError:
117
+ sdk = "anthropic" if self._provider == "anthropic" else "openai"
118
+ return (
119
+ False,
120
+ f"The '{sdk}' package is not installed. "
121
+ f"Install it with: pip install repobrief[cloud]",
122
+ )
123
+
124
+ return (True, f"Ready -- using {self.name}")
125
+
126
+ def generate(
127
+ self,
128
+ system_prompt: str,
129
+ user_message: str,
130
+ history: list[ChatMessage] | None = None,
131
+ ) -> Iterator[str]:
132
+ """Send a prompt to the cloud API and stream back response tokens.
133
+
134
+ Args:
135
+ system_prompt: System instructions (includes the repo digest).
136
+ user_message: The user's question.
137
+ history: Previous conversation messages.
138
+
139
+ Yields:
140
+ String chunks of the response.
141
+ """
142
+ if self._provider == "anthropic":
143
+ yield from self._generate_anthropic(system_prompt, user_message, history)
144
+ else:
145
+ yield from self._generate_openai(system_prompt, user_message, history)
146
+
147
+ def _generate_anthropic(
148
+ self,
149
+ system_prompt: str,
150
+ user_message: str,
151
+ history: list[ChatMessage] | None = None,
152
+ ) -> Iterator[str]:
153
+ """Generate using the Anthropic API."""
154
+ import anthropic
155
+
156
+ client = anthropic.Anthropic(api_key=self._api_key)
157
+
158
+ # Build messages list
159
+ messages = []
160
+ if history:
161
+ for msg in history:
162
+ if msg.role in ("user", "assistant"):
163
+ messages.append({"role": msg.role, "content": msg.content})
164
+ messages.append({"role": "user", "content": user_message})
165
+
166
+ try:
167
+ with client.messages.stream(
168
+ model=self._model,
169
+ max_tokens=self._max_output_tokens,
170
+ system=system_prompt,
171
+ messages=messages,
172
+ ) as stream:
173
+ yield from stream.text_stream
174
+ except anthropic.AuthenticationError as err:
175
+ raise PermissionError(
176
+ "Invalid Anthropic API key. Check your ANTHROPIC_API_KEY."
177
+ ) from err
178
+ except anthropic.RateLimitError as err:
179
+ raise RuntimeError(
180
+ "Anthropic API rate limit exceeded. Wait a moment and try again."
181
+ ) from err
182
+ except anthropic.APIConnectionError as err:
183
+ raise ConnectionError(
184
+ "Could not connect to the Anthropic API. Check your internet connection."
185
+ ) from err
186
+
187
+ def _generate_openai(
188
+ self,
189
+ system_prompt: str,
190
+ user_message: str,
191
+ history: list[ChatMessage] | None = None,
192
+ ) -> Iterator[str]:
193
+ """Generate using the OpenAI API."""
194
+ import openai
195
+
196
+ client = openai.OpenAI(api_key=self._api_key)
197
+
198
+ # Build messages list
199
+ messages = [{"role": "system", "content": system_prompt}]
200
+ if history:
201
+ for msg in history:
202
+ if msg.role in ("user", "assistant"):
203
+ messages.append({"role": msg.role, "content": msg.content})
204
+ messages.append({"role": "user", "content": user_message})
205
+
206
+ try:
207
+ stream = client.chat.completions.create(
208
+ model=self._model,
209
+ messages=messages,
210
+ max_tokens=self._max_output_tokens,
211
+ stream=True,
212
+ )
213
+ for chunk in stream:
214
+ if chunk.choices and chunk.choices[0].delta.content:
215
+ yield chunk.choices[0].delta.content
216
+ except openai.AuthenticationError as err:
217
+ raise PermissionError(
218
+ "Invalid OpenAI API key. Check your OPENAI_API_KEY."
219
+ ) from err
220
+ except openai.RateLimitError as err:
221
+ raise RuntimeError(
222
+ "OpenAI API rate limit exceeded. Wait a moment and try again."
223
+ ) from err
224
+ except openai.APIConnectionError as err:
225
+ raise ConnectionError(
226
+ "Could not connect to the OpenAI API. Check your internet connection."
227
+ ) from err
@@ -0,0 +1,209 @@
1
+ """Ollama local backend -- fully offline LLM chat via Ollama's REST API.
2
+
3
+ Communicates with a locally running Ollama server (default: http://localhost:11434).
4
+ No data leaves the user's machine. Requires Ollama to be installed and running
5
+ with at least one model pulled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Iterator
12
+
13
+ import requests
14
+
15
+ from repobrief.backends.base import ChatMessage, LLMBackend
16
+
17
+ # Default Ollama server URL
18
+ DEFAULT_OLLAMA_HOST = "http://localhost:11434"
19
+
20
+ # Timeout for health checks and model listing (seconds)
21
+ CHECK_TIMEOUT = 5
22
+
23
+ # Timeout for generation requests -- set high because local models can be slow
24
+ GENERATE_TIMEOUT = 300 # 5 minutes
25
+
26
+
27
+ class OllamaBackend(LLMBackend):
28
+ """Ollama local backend.
29
+
30
+ Connects to a locally running Ollama server and sends chat requests.
31
+ All processing happens on the user's machine -- no data is sent externally.
32
+
33
+ Usage:
34
+ backend = OllamaBackend(model="llama3.1:8b")
35
+ is_valid, msg = backend.validate()
36
+ if is_valid:
37
+ for chunk in backend.generate(system_prompt, question):
38
+ print(chunk, end="")
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ model: str = "llama3.2:3b",
44
+ host: str = DEFAULT_OLLAMA_HOST,
45
+ ):
46
+ """Initialize the Ollama backend.
47
+
48
+ Args:
49
+ model: Name of the Ollama model to use (e.g., 'llama3.1:8b').
50
+ host: Ollama server URL (default: http://localhost:11434).
51
+ """
52
+ self._model = model
53
+ self._host = host.rstrip("/")
54
+
55
+ @property
56
+ def name(self) -> str:
57
+ return f"Ollama ({self._model})"
58
+
59
+ # -- Server Communication --------------------------------------------------
60
+
61
+ def _is_server_running(self) -> bool:
62
+ """Check if the Ollama server is responding."""
63
+ try:
64
+ resp = requests.get(f"{self._host}/", timeout=CHECK_TIMEOUT)
65
+ return resp.status_code == 200
66
+ except (requests.ConnectionError, requests.Timeout):
67
+ return False
68
+
69
+ def _list_models(self) -> list[str]:
70
+ """Get list of locally available models.
71
+
72
+ Returns:
73
+ List of model name strings (e.g., ['llama3.1:8b', 'phi3:mini']).
74
+ """
75
+ try:
76
+ resp = requests.get(f"{self._host}/api/tags", timeout=CHECK_TIMEOUT)
77
+ if resp.status_code == 200:
78
+ data = resp.json()
79
+ models = data.get("models", [])
80
+ return [m["name"] for m in models]
81
+ except (requests.ConnectionError, requests.Timeout, ValueError, KeyError):
82
+ pass
83
+ return []
84
+
85
+ def _is_model_available(self) -> bool:
86
+ """Check if the configured model is available locally."""
87
+ available = self._list_models()
88
+ # Check exact match and partial match (with/without tag)
89
+ model_base = self._model.split(":")[0]
90
+ return (
91
+ self._model in available
92
+ or any(m.startswith(model_base) for m in available)
93
+ )
94
+
95
+ # -- LLMBackend Interface --------------------------------------------------
96
+
97
+ def validate(self) -> tuple[bool, str]:
98
+ """Check if Ollama is running and the model is available.
99
+
100
+ Returns:
101
+ (True, "Ready") if everything is configured.
102
+ (False, "detailed error message") if there's a problem.
103
+ """
104
+ # Check 1: Is Ollama running?
105
+ if not self._is_server_running():
106
+ return (
107
+ False,
108
+ f"Ollama server is not running at {self._host}.\n"
109
+ f"\n"
110
+ f"To fix this:\n"
111
+ f" 1. Install Ollama: https://ollama.com/download\n"
112
+ f" 2. Start the server: ollama serve\n"
113
+ f" 3. Pull a model: ollama pull {self._model}\n"
114
+ f" 4. Try again",
115
+ )
116
+
117
+ # Check 2: Is the model available?
118
+ if not self._is_model_available():
119
+ available = self._list_models()
120
+ available_str = ", ".join(available[:5]) if available else "(none)"
121
+ return (
122
+ False,
123
+ f"Model '{self._model}' is not available locally.\n"
124
+ f"\n"
125
+ f"Available models: {available_str}\n"
126
+ f"\n"
127
+ f"To fix this:\n"
128
+ f" Pull the model: ollama pull {self._model}\n"
129
+ f"\n"
130
+ f"Recommended models for limited RAM:\n"
131
+ f" - llama3.2:3b (~2GB RAM) -- fast, good quality\n"
132
+ f" - phi3:mini (~2.3GB RAM) -- fast, good for code\n"
133
+ f" - llama3.1:8b (~4.5GB RAM) -- better quality, needs more RAM",
134
+ )
135
+
136
+ return (True, f"Ready -- using {self.name} (local, fully offline)")
137
+
138
+ def generate(
139
+ self,
140
+ system_prompt: str,
141
+ user_message: str,
142
+ history: list[ChatMessage] | None = None,
143
+ ) -> Iterator[str]:
144
+ """Send a prompt to the local Ollama server and stream back response tokens.
145
+
146
+ Args:
147
+ system_prompt: System instructions (includes the repo digest).
148
+ user_message: The user's question.
149
+ history: Previous conversation messages.
150
+
151
+ Yields:
152
+ String chunks of the response as they arrive.
153
+ """
154
+ # Build messages array
155
+ messages: list[dict[str, str]] = [
156
+ {"role": "system", "content": system_prompt},
157
+ ]
158
+ if history:
159
+ for msg in history:
160
+ if msg.role in ("user", "assistant"):
161
+ messages.append({"role": msg.role, "content": msg.content})
162
+ messages.append({"role": "user", "content": user_message})
163
+
164
+ # Send request with streaming
165
+ payload = {
166
+ "model": self._model,
167
+ "messages": messages,
168
+ "stream": True,
169
+ }
170
+
171
+ try:
172
+ resp = requests.post(
173
+ f"{self._host}/api/chat",
174
+ json=payload,
175
+ stream=True,
176
+ timeout=GENERATE_TIMEOUT,
177
+ )
178
+
179
+ if resp.status_code != 200:
180
+ error_text = resp.text[:500]
181
+ raise RuntimeError(
182
+ f"Ollama returned HTTP {resp.status_code}: {error_text}"
183
+ )
184
+
185
+ # Parse streaming response (newline-delimited JSON)
186
+ for line in resp.iter_lines(decode_unicode=True):
187
+ if not line:
188
+ continue
189
+ try:
190
+ data = json.loads(line)
191
+ content = data.get("message", {}).get("content", "")
192
+ if content:
193
+ yield content
194
+ if data.get("done", False):
195
+ break
196
+ except json.JSONDecodeError:
197
+ continue # Skip malformed lines
198
+
199
+ except requests.ConnectionError as err:
200
+ raise ConnectionError(
201
+ f"Lost connection to Ollama server at {self._host}. "
202
+ f"Make sure Ollama is still running."
203
+ ) from err
204
+ except requests.Timeout as err:
205
+ raise RuntimeError(
206
+ f"Ollama request timed out after {GENERATE_TIMEOUT} seconds. "
207
+ f"The model may be too large for your system, or the server "
208
+ f"is overloaded."
209
+ ) from err
@@ -0,0 +1,45 @@
1
+ """System prompt templates for chat mode.
2
+
3
+ The system prompt includes the packed repo digest and instructions
4
+ for how the LLM should behave when answering questions about the codebase.
5
+ """
6
+
7
+ SYSTEM_PROMPT_TEMPLATE = """\
8
+ You are RepoBrief, an expert code assistant. You have been given the \
9
+ contents of a software repository and your job is to answer questions \
10
+ about it accurately and helpfully.
11
+
12
+ ## Instructions
13
+
14
+ 1. **Answer based on the code provided.** Only reference files and code \
15
+ that appear in the digest below. If the answer isn't in the provided \
16
+ code, say so clearly -- do not guess or make up code that isn't there.
17
+
18
+ 2. **Be specific.** When referencing code, mention the exact file path \
19
+ and relevant line content. Use code blocks for code snippets.
20
+
21
+ 3. **Be concise but thorough.** Give a direct answer first, then explain \
22
+ the reasoning if needed.
23
+
24
+ 4. **Suggest improvements when relevant.** If you notice bugs, \
25
+ anti-patterns, or potential improvements, mention them briefly.
26
+
27
+ 5. **Respect scope.** You can see only the files included in the digest. \
28
+ There may be other files excluded due to token budget limits.
29
+
30
+ ## Repository Digest
31
+
32
+ {digest}
33
+ """
34
+
35
+
36
+ def build_system_prompt(digest: str) -> str:
37
+ """Build the complete system prompt with the repo digest embedded.
38
+
39
+ Args:
40
+ digest: The packed repository digest (from the packer module).
41
+
42
+ Returns:
43
+ Complete system prompt string.
44
+ """
45
+ return SYSTEM_PROMPT_TEMPLATE.format(digest=digest)