nexcoder 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.
nex/config.py ADDED
@@ -0,0 +1,168 @@
1
+ """Configuration management for Nex AI.
2
+
3
+ Settings are loaded from three sources in order of priority:
4
+ 1. Environment variables (highest priority)
5
+ 2. Project-level config: .nex/config.toml
6
+ 3. Global config: ~/.config/nex/config.toml (lowest priority)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tomllib
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from rich.console import Console
18
+
19
+ from nex.exceptions import ConfigError
20
+
21
+ console = Console(stderr=True)
22
+
23
+ _GLOBAL_CONFIG_DIR = Path.home() / ".config" / "nex"
24
+ _GLOBAL_CONFIG_PATH = _GLOBAL_CONFIG_DIR / "config.toml"
25
+
26
+
27
+ @dataclass
28
+ class NexConfig:
29
+ """Nex AI configuration.
30
+
31
+ Attributes:
32
+ api_key: Anthropic API key.
33
+ model: Default model for code generation and reasoning.
34
+ haiku_model: Model for planning and lightweight tasks.
35
+ max_iterations: Maximum tool calls per task.
36
+ dry_run: If True, show planned actions without executing.
37
+ log_level: Logging verbosity (DEBUG, INFO, WARNING, ERROR).
38
+ nex_dir: Path to the .nex directory (relative to project root).
39
+ test_command: Override for auto-detected test command (empty = auto-detect).
40
+ test_timeout: Maximum seconds to wait for test suite to complete.
41
+ """
42
+
43
+ project_dir: Path = field(default_factory=Path.cwd)
44
+ api_key: str = ""
45
+ model: str = "claude-sonnet-4-20250514"
46
+ haiku_model: str = "claude-haiku-4-5-20251001"
47
+ max_iterations: int = 25
48
+ dry_run: bool = False
49
+ log_level: str = "INFO"
50
+ nex_dir: Path = field(default_factory=lambda: Path(".nex"))
51
+ test_command: str = ""
52
+ test_timeout: int = 120
53
+
54
+
55
+ def load_config(project_dir: Path) -> NexConfig:
56
+ """Load configuration from env vars, project config, and global config.
57
+
58
+ Priority: env vars > .nex/config.toml > ~/.config/nex/config.toml
59
+
60
+ Args:
61
+ project_dir: Root directory of the project.
62
+
63
+ Returns:
64
+ A fully resolved NexConfig instance.
65
+
66
+ Raises:
67
+ ConfigError: If the API key is not set anywhere.
68
+ """
69
+ config = NexConfig(project_dir=project_dir)
70
+
71
+ # Layer 1: Global config (lowest priority)
72
+ global_settings = _load_toml(_GLOBAL_CONFIG_PATH)
73
+ _apply_toml(config, global_settings)
74
+
75
+ # Layer 2: Project config
76
+ project_config_path = project_dir / ".nex" / "config.toml"
77
+ project_settings = _load_toml(project_config_path)
78
+ _apply_toml(config, project_settings)
79
+
80
+ # Layer 3: Environment variables (highest priority)
81
+ _apply_env(config)
82
+
83
+ return config
84
+
85
+
86
+ def ensure_api_key(config: NexConfig) -> None:
87
+ """Validate that an API key is configured.
88
+
89
+ Args:
90
+ config: The configuration to validate.
91
+
92
+ Raises:
93
+ ConfigError: If api_key is empty.
94
+ """
95
+ if not config.api_key:
96
+ raise ConfigError(
97
+ "Anthropic API key not found. Set ANTHROPIC_API_KEY environment variable "
98
+ "or run 'nex auth' to configure it."
99
+ )
100
+
101
+
102
+ def save_global_config(key: str, value: str) -> None:
103
+ """Save a key-value pair to the global config file.
104
+
105
+ Args:
106
+ key: Configuration key (e.g. "api_key").
107
+ value: Configuration value.
108
+ """
109
+ _GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
110
+
111
+ settings: dict[str, Any] = {}
112
+ if _GLOBAL_CONFIG_PATH.is_file():
113
+ settings = _load_toml(_GLOBAL_CONFIG_PATH)
114
+
115
+ settings[key] = value
116
+
117
+ lines = [f'{k} = "{v}"' for k, v in settings.items()]
118
+ _GLOBAL_CONFIG_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
119
+ console.print(f"[green]Saved[/green] {key} to {_GLOBAL_CONFIG_PATH}")
120
+
121
+
122
+ def _load_toml(path: Path) -> dict[str, Any]:
123
+ """Load a TOML file, returning an empty dict if missing or invalid."""
124
+ if not path.is_file():
125
+ return {}
126
+ try:
127
+ return tomllib.loads(path.read_text(encoding="utf-8"))
128
+ except (tomllib.TOMLDecodeError, OSError) as exc:
129
+ console.print(f"[yellow]Warning:[/yellow] Could not parse {path}: {exc}")
130
+ return {}
131
+
132
+
133
+ def _apply_toml(config: NexConfig, settings: dict[str, Any]) -> None:
134
+ """Merge TOML settings into a NexConfig (only set non-empty values)."""
135
+ if "api_key" in settings:
136
+ config.api_key = str(settings["api_key"])
137
+ if "model" in settings:
138
+ config.model = str(settings["model"])
139
+ if "haiku_model" in settings:
140
+ config.haiku_model = str(settings["haiku_model"])
141
+ if "max_iterations" in settings:
142
+ config.max_iterations = int(settings["max_iterations"])
143
+ if "dry_run" in settings:
144
+ config.dry_run = bool(settings["dry_run"])
145
+ if "log_level" in settings:
146
+ config.log_level = str(settings["log_level"]).upper()
147
+ if "test_command" in settings:
148
+ config.test_command = str(settings["test_command"])
149
+ if "test_timeout" in settings:
150
+ config.test_timeout = int(settings["test_timeout"])
151
+
152
+
153
+ def _apply_env(config: NexConfig) -> None:
154
+ """Override config with environment variables where set."""
155
+ if api_key := os.environ.get("ANTHROPIC_API_KEY"):
156
+ config.api_key = api_key
157
+ if model := os.environ.get("NEX_MODEL"):
158
+ config.model = model
159
+ if max_iter := os.environ.get("NEX_MAX_ITERATIONS"):
160
+ config.max_iterations = int(max_iter)
161
+ if dry_run := os.environ.get("NEX_DRY_RUN"):
162
+ config.dry_run = dry_run.lower() in ("true", "1", "yes")
163
+ if log_level := os.environ.get("NEX_LOG_LEVEL"):
164
+ config.log_level = log_level.upper()
165
+ if test_cmd := os.environ.get("NEX_TEST_COMMAND"):
166
+ config.test_command = test_cmd
167
+ if test_timeout := os.environ.get("NEX_TEST_TIMEOUT"):
168
+ config.test_timeout = int(test_timeout)
nex/context.py ADDED
@@ -0,0 +1,252 @@
1
+ """Context assembly for API calls with priority-based token budgeting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ from nex.indexer.index import CodeIndex, IndexBuilder
10
+ from nex.memory.errors import ErrorPattern
11
+
12
+ console = Console(stderr=True)
13
+
14
+ _COMMENT_PREFIXES = ("#", "//", "/*", "*", '"""', "'''")
15
+
16
+ _SYSTEM_PROMPT_TEMPLATE = """\
17
+ You are Nex, an AI coding agent that works on the user's codebase. You have access to tools \
18
+ for reading files, writing files, running commands, searching code, and listing directories.
19
+
20
+ ## Project Context
21
+ {project_memory}
22
+
23
+ ## Past Errors to Avoid
24
+ {error_patterns}
25
+
26
+ ## Relevant Code
27
+ {relevant_code}
28
+
29
+ ## Rules
30
+ - Always read existing code before modifying it. Match the project's style.
31
+ - Run tests after making changes. If tests fail, fix them before reporting success.
32
+ - Never execute destructive commands without user approval.
33
+ - When you make an architectural decision, explain why briefly.
34
+ - If you're unsure about something, ask the user rather than guessing.
35
+ - Keep changes minimal — don't refactor code that isn't related to the task.
36
+ """
37
+
38
+
39
+ class ContextAssembler:
40
+ """Assembles the context window for API calls with priority-based budgeting.
41
+
42
+ Token budget: 150K of 200K window reserved for context, 50K for response.
43
+ Priority order: system prompt -> project memory -> error patterns -> relevant code.
44
+ """
45
+
46
+ TOKEN_BUDGET = 150_000
47
+
48
+ def __init__(self, project_dir: Path) -> None:
49
+ """Initialize the context assembler.
50
+
51
+ Args:
52
+ project_dir: Project root directory.
53
+ """
54
+ self._project_dir = project_dir
55
+
56
+ def build_system_prompt(
57
+ self,
58
+ project_memory: str,
59
+ error_patterns: list[ErrorPattern],
60
+ relevant_code: list[tuple[str, str]],
61
+ ) -> str:
62
+ """Assemble the system prompt from all context sources.
63
+
64
+ Args:
65
+ project_memory: Contents of .nex/memory.md.
66
+ error_patterns: Recent similar error patterns.
67
+ relevant_code: (file_path, content) pairs of relevant code.
68
+
69
+ Returns:
70
+ The assembled system prompt string.
71
+ """
72
+ # Format error patterns
73
+ if error_patterns:
74
+ error_text = "\n".join(
75
+ f"- [{ep.error_type}] {ep.what_failed} -> Fix: {ep.what_fixed}"
76
+ + (f" (in {ep.file_path})" if ep.file_path else "")
77
+ for ep in error_patterns
78
+ )
79
+ else:
80
+ error_text = "No relevant past errors found."
81
+
82
+ # Format relevant code
83
+ if relevant_code:
84
+ code_sections = []
85
+ for file_path, content in relevant_code:
86
+ code_sections.append(f"### {file_path}\n```\n{content}\n```")
87
+ code_text = "\n\n".join(code_sections)
88
+ else:
89
+ code_text = "No relevant code indexed yet."
90
+
91
+ return _SYSTEM_PROMPT_TEMPLATE.format(
92
+ project_memory=project_memory or "No project memory found. Run 'nex init' first.",
93
+ error_patterns=error_text,
94
+ relevant_code=code_text,
95
+ )
96
+
97
+ def select_relevant_code(
98
+ self,
99
+ task: str,
100
+ index: CodeIndex | None,
101
+ budget_tokens: int = 100_000,
102
+ ) -> list[tuple[str, str]]:
103
+ """Select relevant code files based on task description.
104
+
105
+ Uses file-level TF-IDF relevance scoring from the index. Applies a
106
+ relevance threshold to filter noise. Two-phase budget: 60% for full
107
+ file content, 40% for signature-only summaries of remaining files.
108
+
109
+ Args:
110
+ task: The user's task description.
111
+ index: Pre-loaded code index.
112
+ budget_tokens: Maximum tokens worth of code to include.
113
+
114
+ Returns:
115
+ List of (file_path, content) pairs.
116
+ """
117
+ if index is None:
118
+ return []
119
+
120
+ builder = IndexBuilder(self._project_dir)
121
+ try:
122
+ ranked_files = builder.search_files(task, index)
123
+ except Exception:
124
+ return []
125
+
126
+ if not ranked_files:
127
+ return []
128
+
129
+ # Apply relevance threshold
130
+ top_score = ranked_files[0][1]
131
+ threshold = self._relevance_threshold(top_score)
132
+ ranked_files = [(fp, sc) for fp, sc in ranked_files if sc >= threshold]
133
+
134
+ # Two-phase budget: 60% full content, 40% signature summaries
135
+ full_budget = int(budget_tokens * 0.60)
136
+ sig_budget = budget_tokens - full_budget
137
+
138
+ results: list[tuple[str, str]] = []
139
+ tokens_used = 0
140
+ remaining_files: list[str] = []
141
+
142
+ # Phase 1: Full file content
143
+ for file_path, _score in ranked_files:
144
+ abs_path = self._project_dir / file_path
145
+ if not abs_path.is_file():
146
+ continue
147
+
148
+ try:
149
+ content = abs_path.read_text(encoding="utf-8", errors="replace")
150
+ except OSError:
151
+ continue
152
+
153
+ file_tokens = self.estimate_tokens(content)
154
+ if tokens_used + file_tokens <= full_budget:
155
+ results.append((file_path, content))
156
+ tokens_used += file_tokens
157
+ continue
158
+
159
+ # Try truncation (first 200 lines)
160
+ lines = content.splitlines()[:200]
161
+ truncated = "\n".join(lines) + "\n... (truncated)"
162
+ trunc_tokens = self.estimate_tokens(truncated)
163
+ if tokens_used + trunc_tokens <= full_budget:
164
+ results.append((file_path, truncated))
165
+ tokens_used += trunc_tokens
166
+ continue
167
+
168
+ # Defer to signature phase
169
+ remaining_files.append(file_path)
170
+
171
+ # Phase 2: Signature-only summaries for remaining files
172
+ sig_tokens_used = 0
173
+ for file_path in remaining_files:
174
+ sig_text = self._extract_signatures(file_path, builder, index)
175
+ if not sig_text:
176
+ continue
177
+
178
+ sig_tokens = self.estimate_tokens(sig_text)
179
+ if sig_tokens_used + sig_tokens > sig_budget:
180
+ break
181
+
182
+ results.append((file_path, f"(signatures only)\n{sig_text}"))
183
+ sig_tokens_used += sig_tokens
184
+
185
+ return results
186
+
187
+ @staticmethod
188
+ def estimate_tokens(text: str) -> int:
189
+ """Estimate token count with code/comment-aware heuristic.
190
+
191
+ Code lines average ~3.5 chars/token due to variable names and syntax.
192
+ Comment/docstring lines average ~4.5 chars/token (more natural language).
193
+
194
+ Args:
195
+ text: Input text.
196
+
197
+ Returns:
198
+ Estimated number of tokens.
199
+ """
200
+ if not text:
201
+ return 0
202
+
203
+ total = 0
204
+ for line in text.splitlines():
205
+ stripped = line.lstrip()
206
+ length = len(line)
207
+ if not stripped:
208
+ total += 1 # blank line ≈ 1 token
209
+ elif stripped.startswith(_COMMENT_PREFIXES):
210
+ total += max(1, int(length / 4.5))
211
+ else:
212
+ total += max(1, int(length / 3.5))
213
+ return total
214
+
215
+ @staticmethod
216
+ def _relevance_threshold(top_score: float) -> float:
217
+ """Compute the minimum relevance score to include a file.
218
+
219
+ Files below this threshold are considered noise.
220
+
221
+ Args:
222
+ top_score: The highest file relevance score.
223
+
224
+ Returns:
225
+ Threshold value (at least 1.0).
226
+ """
227
+ return max(top_score * 0.10, 1.0)
228
+
229
+ @staticmethod
230
+ def _extract_signatures(file_path: str, builder: IndexBuilder, index: CodeIndex) -> str:
231
+ """Extract function/class signatures for a file as a compact summary.
232
+
233
+ Args:
234
+ file_path: Relative file path.
235
+ builder: IndexBuilder instance.
236
+ index: Pre-loaded code index.
237
+
238
+ Returns:
239
+ Formatted string of signatures, or empty string if none found.
240
+ """
241
+ symbols = builder.get_file_symbols(file_path, index)
242
+ if not symbols:
243
+ return ""
244
+
245
+ parts: list[str] = []
246
+ for sym in symbols:
247
+ if sym.kind == "import":
248
+ continue
249
+ parts.append(sym.signature)
250
+ if sym.docstring:
251
+ parts.append(f" {sym.docstring}")
252
+ return "\n".join(parts)
nex/exceptions.py ADDED
@@ -0,0 +1,39 @@
1
+ """Nex AI exception hierarchy.
2
+
3
+ All exceptions inherit from NexError so callers can catch the base
4
+ class when they want to handle any Nex-specific failure uniformly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class NexError(Exception):
11
+ """Base exception for all Nex errors."""
12
+
13
+
14
+ class ConfigError(NexError):
15
+ """Configuration-related errors (missing API key, invalid config, etc.)."""
16
+
17
+
18
+ class APIError(NexError):
19
+ """Errors communicating with the Anthropic API."""
20
+
21
+ def __init__(self, message: str, status_code: int | None = None) -> None:
22
+ super().__init__(message)
23
+ self.status_code = status_code
24
+
25
+
26
+ class ToolError(NexError):
27
+ """Errors during tool execution (file ops, shell commands, etc.)."""
28
+
29
+
30
+ class SafetyError(NexError):
31
+ """Safety layer violations (destructive commands, path traversal, etc.)."""
32
+
33
+
34
+ class IndexerError(NexError):
35
+ """Errors during codebase indexing or AST parsing."""
36
+
37
+
38
+ class NexMemoryError(NexError):
39
+ """Errors in the memory system (project memory, error DB, decision log)."""
@@ -0,0 +1,16 @@
1
+ """Codebase indexer — file discovery, AST parsing, and index building."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nex.indexer.index import CodeIndex, IndexBuilder
6
+ from nex.indexer.parser import ASTParser, Symbol
7
+ from nex.indexer.scanner import FileInfo, FileScanner
8
+
9
+ __all__ = [
10
+ "ASTParser",
11
+ "CodeIndex",
12
+ "FileInfo",
13
+ "FileScanner",
14
+ "IndexBuilder",
15
+ "Symbol",
16
+ ]