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/__init__.py +6 -0
- nex/agent.py +623 -0
- nex/api_client.py +194 -0
- nex/cli.py +506 -0
- nex/config.py +168 -0
- nex/context.py +252 -0
- nex/exceptions.py +39 -0
- nex/indexer/__init__.py +16 -0
- nex/indexer/index.py +332 -0
- nex/indexer/parser.py +352 -0
- nex/indexer/scanner.py +191 -0
- nex/memory/__init__.py +15 -0
- nex/memory/decisions.py +131 -0
- nex/memory/errors.py +257 -0
- nex/memory/project.py +158 -0
- nex/planner.py +122 -0
- nex/py.typed +0 -0
- nex/reviewer.py +111 -0
- nex/safety.py +235 -0
- nex/test_runner.py +201 -0
- nex/tools/__init__.py +114 -0
- nex/tools/file_ops.py +89 -0
- nex/tools/git_ops.py +183 -0
- nex/tools/search.py +156 -0
- nex/tools/shell.py +72 -0
- nexcoder-0.1.0.dist-info/METADATA +170 -0
- nexcoder-0.1.0.dist-info/RECORD +30 -0
- nexcoder-0.1.0.dist-info/WHEEL +4 -0
- nexcoder-0.1.0.dist-info/entry_points.txt +2 -0
- nexcoder-0.1.0.dist-info/licenses/LICENSE +21 -0
nex/api_client.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Async wrapper around the Anthropic API with retry logic and token tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from nex.exceptions import APIError
|
|
12
|
+
|
|
13
|
+
console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
# Pricing per million tokens (approximate)
|
|
16
|
+
_PRICING: dict[str, tuple[float, float]] = {
|
|
17
|
+
"claude-sonnet-4-20250514": (3.0, 15.0),
|
|
18
|
+
"claude-haiku-4-5-20251001": (0.80, 4.0),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class APIResponse:
|
|
24
|
+
"""Structured response from the Anthropic API.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
content: Content blocks from the API response.
|
|
28
|
+
model: Model used for the response.
|
|
29
|
+
input_tokens: Number of input tokens consumed.
|
|
30
|
+
output_tokens: Number of output tokens generated.
|
|
31
|
+
stop_reason: Why the response stopped (end_turn, tool_use, etc.).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
content: list[dict[str, Any]]
|
|
35
|
+
model: str
|
|
36
|
+
input_tokens: int
|
|
37
|
+
output_tokens: int
|
|
38
|
+
stop_reason: str | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TokenUsage:
|
|
43
|
+
"""Cumulative token usage across API calls.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
total_input: Total input tokens consumed.
|
|
47
|
+
total_output: Total output tokens generated.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
total_input: int = 0
|
|
51
|
+
total_output: int = 0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def estimated_cost(self) -> float:
|
|
55
|
+
"""Estimate cost in USD based on Sonnet pricing."""
|
|
56
|
+
input_cost = (self.total_input / 1_000_000) * 3.0
|
|
57
|
+
output_cost = (self.total_output / 1_000_000) * 15.0
|
|
58
|
+
return input_cost + output_cost
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AnthropicClient:
|
|
62
|
+
"""Async wrapper around the Anthropic API.
|
|
63
|
+
|
|
64
|
+
Handles retries with exponential backoff for rate limits (429) and
|
|
65
|
+
server errors (500+). Tracks cumulative token usage.
|
|
66
|
+
|
|
67
|
+
Usage::
|
|
68
|
+
|
|
69
|
+
client = AnthropicClient(api_key="sk-ant-...")
|
|
70
|
+
response = await client.send_message(messages=[...], system="...")
|
|
71
|
+
print(client.usage.estimated_cost)
|
|
72
|
+
await client.close()
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str,
|
|
78
|
+
default_model: str = "claude-sonnet-4-20250514",
|
|
79
|
+
max_retries: int = 3,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Initialize the Anthropic client.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
api_key: Anthropic API key.
|
|
85
|
+
default_model: Default model for requests.
|
|
86
|
+
max_retries: Maximum number of retries for transient errors.
|
|
87
|
+
"""
|
|
88
|
+
from anthropic import AsyncAnthropic
|
|
89
|
+
|
|
90
|
+
self._client = AsyncAnthropic(api_key=api_key)
|
|
91
|
+
self._default_model = default_model
|
|
92
|
+
self._max_retries = max_retries
|
|
93
|
+
self._usage = TokenUsage()
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def usage(self) -> TokenUsage:
|
|
97
|
+
"""Return cumulative token usage."""
|
|
98
|
+
return self._usage
|
|
99
|
+
|
|
100
|
+
async def send_message(
|
|
101
|
+
self,
|
|
102
|
+
messages: list[dict[str, Any]],
|
|
103
|
+
system: str = "",
|
|
104
|
+
tools: list[dict[str, Any]] | None = None,
|
|
105
|
+
model: str | None = None,
|
|
106
|
+
max_tokens: int = 8192,
|
|
107
|
+
) -> APIResponse:
|
|
108
|
+
"""Send a message to the Anthropic API.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
messages: Conversation messages.
|
|
112
|
+
system: System prompt.
|
|
113
|
+
tools: Tool definitions for tool_use.
|
|
114
|
+
model: Override default model.
|
|
115
|
+
max_tokens: Maximum response tokens.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Structured APIResponse.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
APIError: If the request fails after all retries.
|
|
122
|
+
"""
|
|
123
|
+
use_model = model or self._default_model
|
|
124
|
+
kwargs: dict[str, Any] = {
|
|
125
|
+
"model": use_model,
|
|
126
|
+
"max_tokens": max_tokens,
|
|
127
|
+
"messages": messages,
|
|
128
|
+
}
|
|
129
|
+
if system:
|
|
130
|
+
kwargs["system"] = system
|
|
131
|
+
if tools:
|
|
132
|
+
kwargs["tools"] = tools
|
|
133
|
+
|
|
134
|
+
last_error: Exception | None = None
|
|
135
|
+
for attempt in range(self._max_retries + 1):
|
|
136
|
+
try:
|
|
137
|
+
response = await self._client.messages.create(**kwargs)
|
|
138
|
+
|
|
139
|
+
# Track usage
|
|
140
|
+
self._usage.total_input += response.usage.input_tokens
|
|
141
|
+
self._usage.total_output += response.usage.output_tokens
|
|
142
|
+
|
|
143
|
+
# Convert content blocks to dicts
|
|
144
|
+
content_dicts: list[dict[str, Any]] = []
|
|
145
|
+
for block in response.content:
|
|
146
|
+
if block.type == "text":
|
|
147
|
+
content_dicts.append({"type": "text", "text": block.text})
|
|
148
|
+
elif block.type == "tool_use":
|
|
149
|
+
content_dicts.append(
|
|
150
|
+
{
|
|
151
|
+
"type": "tool_use",
|
|
152
|
+
"id": block.id,
|
|
153
|
+
"name": block.name,
|
|
154
|
+
"input": block.input,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return APIResponse(
|
|
159
|
+
content=content_dicts,
|
|
160
|
+
model=response.model,
|
|
161
|
+
input_tokens=response.usage.input_tokens,
|
|
162
|
+
output_tokens=response.usage.output_tokens,
|
|
163
|
+
stop_reason=response.stop_reason,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
last_error = exc
|
|
168
|
+
status_code = getattr(exc, "status_code", None)
|
|
169
|
+
|
|
170
|
+
# Retry on rate limit or server errors
|
|
171
|
+
if status_code in (429, 500, 502, 503, 529) and attempt < self._max_retries:
|
|
172
|
+
wait = 2**attempt
|
|
173
|
+
retry_after = getattr(exc, "retry_after", None)
|
|
174
|
+
if retry_after:
|
|
175
|
+
wait = max(wait, float(retry_after))
|
|
176
|
+
console.print(
|
|
177
|
+
f"[yellow]API error {status_code}, retrying in {wait}s "
|
|
178
|
+
f"(attempt {attempt + 1}/{self._max_retries})...[/yellow]"
|
|
179
|
+
)
|
|
180
|
+
await asyncio.sleep(wait)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
raise APIError(
|
|
184
|
+
f"Anthropic API error: {exc}",
|
|
185
|
+
status_code=status_code,
|
|
186
|
+
) from exc
|
|
187
|
+
|
|
188
|
+
raise APIError(
|
|
189
|
+
f"Failed after {self._max_retries} retries: {last_error}",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def close(self) -> None:
|
|
193
|
+
"""Close the underlying HTTP client."""
|
|
194
|
+
await self._client.close()
|
nex/cli.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Typer CLI entry point for Nex AI.
|
|
2
|
+
|
|
3
|
+
Bridges the synchronous Typer world to the async agent internals via asyncio.run().
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markdown import Markdown
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.prompt import Prompt
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from nex import __version__
|
|
24
|
+
from nex.config import NexConfig, load_config, save_global_config
|
|
25
|
+
from nex.exceptions import NexError
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="nex",
|
|
29
|
+
help="Nex AI — The coding agent that remembers.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
rich_markup_mode="rich",
|
|
32
|
+
)
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
_MEMORY_TEMPLATE = """\
|
|
36
|
+
# Project Overview
|
|
37
|
+
|
|
38
|
+
<!-- Describe what this project is and what it does. -->
|
|
39
|
+
|
|
40
|
+
## Tech Stack
|
|
41
|
+
|
|
42
|
+
<!-- List the main technologies, frameworks, and tools used. -->
|
|
43
|
+
|
|
44
|
+
## Architecture
|
|
45
|
+
|
|
46
|
+
<!-- High-level architecture notes. -->
|
|
47
|
+
|
|
48
|
+
## Conventions
|
|
49
|
+
|
|
50
|
+
<!-- Coding conventions, naming patterns, commit style. -->
|
|
51
|
+
|
|
52
|
+
## Notes
|
|
53
|
+
|
|
54
|
+
<!-- Anything else the agent should remember between sessions. -->
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_CONFIG_TEMPLATE = """\
|
|
58
|
+
# Nex project configuration
|
|
59
|
+
# model = "claude-sonnet-4-20250514"
|
|
60
|
+
# max_iterations = 25
|
|
61
|
+
# dry_run = false
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _error_exit(message: str, hint: str | None = None) -> None:
|
|
66
|
+
"""Print a styled error and exit."""
|
|
67
|
+
console.print(f"[bold red]Error:[/bold red] {message}")
|
|
68
|
+
if hint:
|
|
69
|
+
console.print(f"[dim]Hint: {hint}[/dim]")
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command()
|
|
74
|
+
def main(
|
|
75
|
+
task: Annotated[str, typer.Argument(help="The coding task to execute")],
|
|
76
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show plan without executing")] = False,
|
|
77
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose output")] = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Run a coding task with Nex AI."""
|
|
80
|
+
try:
|
|
81
|
+
config = load_config(Path.cwd())
|
|
82
|
+
|
|
83
|
+
if dry_run:
|
|
84
|
+
config.dry_run = True
|
|
85
|
+
if verbose:
|
|
86
|
+
config.log_level = "DEBUG"
|
|
87
|
+
|
|
88
|
+
if not config.api_key:
|
|
89
|
+
_error_exit(
|
|
90
|
+
"Anthropic API key not found.",
|
|
91
|
+
hint="Run 'nex auth' or set ANTHROPIC_API_KEY environment variable.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
nex_dir = Path.cwd() / ".nex"
|
|
95
|
+
if not nex_dir.is_dir():
|
|
96
|
+
_error_exit("Project not initialized.", hint="Run 'nex init' first.")
|
|
97
|
+
|
|
98
|
+
console.print(
|
|
99
|
+
Panel(
|
|
100
|
+
f"[bold]Task:[/bold] {task}",
|
|
101
|
+
title=f"[bold cyan]Nex AI[/bold cyan] v{__version__}",
|
|
102
|
+
border_style="cyan",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if config.dry_run:
|
|
107
|
+
console.print("[yellow]Running in dry-run mode.[/yellow]\n")
|
|
108
|
+
|
|
109
|
+
from nex.agent import run_task
|
|
110
|
+
|
|
111
|
+
asyncio.run(run_task(task, config))
|
|
112
|
+
|
|
113
|
+
except KeyboardInterrupt:
|
|
114
|
+
console.print("\n[yellow]Interrupted.[/yellow]")
|
|
115
|
+
raise typer.Exit(code=130)
|
|
116
|
+
except NexError as exc:
|
|
117
|
+
_error_exit(str(exc))
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
console.print_exception(show_locals=False)
|
|
120
|
+
_error_exit(f"Unexpected error: {exc}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def init() -> None:
|
|
125
|
+
"""Initialize .nex/ directory for this project."""
|
|
126
|
+
project_dir = Path.cwd().resolve()
|
|
127
|
+
nex_dir = project_dir / ".nex"
|
|
128
|
+
|
|
129
|
+
if nex_dir.is_dir():
|
|
130
|
+
console.print(f"[yellow]Already initialized:[/yellow] .nex/ exists at {nex_dir}")
|
|
131
|
+
raise typer.Exit(code=0)
|
|
132
|
+
|
|
133
|
+
nex_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
(nex_dir / "memory.md").write_text(_MEMORY_TEMPLATE, encoding="utf-8")
|
|
136
|
+
(nex_dir / "decisions.md").write_text("# Decision Log\n\n", encoding="utf-8")
|
|
137
|
+
(nex_dir / "config.toml").write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
|
138
|
+
(nex_dir / ".gitignore").write_text("errors.db\nindex.json\n", encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
console.print(
|
|
141
|
+
Panel(
|
|
142
|
+
Text.assemble(
|
|
143
|
+
("Initialized Nex AI in ", "green"),
|
|
144
|
+
(str(nex_dir), "bold green"),
|
|
145
|
+
("\n\nCreated:\n", "white"),
|
|
146
|
+
(" memory.md ", "cyan"),
|
|
147
|
+
("- project memory (edit this!)\n", "dim"),
|
|
148
|
+
(" decisions.md ", "cyan"),
|
|
149
|
+
("- decision log\n", "dim"),
|
|
150
|
+
(" config.toml ", "cyan"),
|
|
151
|
+
("- project settings\n", "dim"),
|
|
152
|
+
(" .gitignore ", "cyan"),
|
|
153
|
+
("- ignores errors.db, index.json", "dim"),
|
|
154
|
+
),
|
|
155
|
+
title="[bold green]Project Initialized[/bold green]",
|
|
156
|
+
border_style="green",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
console.print(
|
|
161
|
+
"\n[dim]Next steps:[/dim]\n"
|
|
162
|
+
" 1. Edit [cyan].nex/memory.md[/cyan] with your project details\n"
|
|
163
|
+
" 2. Run [cyan]nex auth[/cyan] to configure your API key\n"
|
|
164
|
+
' 3. Run [cyan]nex "your first task"[/cyan]\n'
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def index() -> None:
|
|
170
|
+
"""Build the codebase index (.nex/index.json)."""
|
|
171
|
+
nex_dir = Path.cwd() / ".nex"
|
|
172
|
+
if not nex_dir.is_dir():
|
|
173
|
+
_error_exit("Project not initialized.", hint="Run 'nex init' first.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
from nex.indexer.index import IndexBuilder
|
|
177
|
+
|
|
178
|
+
builder = IndexBuilder(Path.cwd())
|
|
179
|
+
|
|
180
|
+
start = time.perf_counter()
|
|
181
|
+
idx = builder.build()
|
|
182
|
+
elapsed = time.perf_counter() - start
|
|
183
|
+
|
|
184
|
+
if not idx.files:
|
|
185
|
+
console.print(
|
|
186
|
+
"[yellow]No source files found.[/yellow]\n"
|
|
187
|
+
"[dim]Hint: Make sure your project has .py, .js, .ts, .go, .rs, or other "
|
|
188
|
+
"supported source files.[/dim]"
|
|
189
|
+
)
|
|
190
|
+
raise typer.Exit(code=0)
|
|
191
|
+
|
|
192
|
+
console.print(
|
|
193
|
+
Panel(
|
|
194
|
+
Text.assemble(
|
|
195
|
+
("Files indexed: ", "cyan"),
|
|
196
|
+
(str(len(idx.files)), "bold"),
|
|
197
|
+
("\n", ""),
|
|
198
|
+
("Symbols found: ", "cyan"),
|
|
199
|
+
(str(len(idx.symbols)), "bold"),
|
|
200
|
+
("\n", ""),
|
|
201
|
+
("Time: ", "cyan"),
|
|
202
|
+
(f"{elapsed:.2f}s", "bold"),
|
|
203
|
+
("\n", ""),
|
|
204
|
+
("Saved to: ", "cyan"),
|
|
205
|
+
(str(builder.index_path), "dim"),
|
|
206
|
+
),
|
|
207
|
+
title="[bold green]Index Built[/bold green]",
|
|
208
|
+
border_style="green",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.command()
|
|
214
|
+
def status() -> None:
|
|
215
|
+
"""Show project memory, error count, and index stats."""
|
|
216
|
+
nex_dir = Path.cwd() / ".nex"
|
|
217
|
+
if not nex_dir.is_dir():
|
|
218
|
+
_error_exit("Project not initialized.", hint="Run 'nex init' first.")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
config = load_config(Path.cwd())
|
|
222
|
+
|
|
223
|
+
table = Table(title="Nex AI Status", border_style="cyan", header_style="bold cyan")
|
|
224
|
+
table.add_column("Property", style="bold")
|
|
225
|
+
table.add_column("Value")
|
|
226
|
+
|
|
227
|
+
table.add_row("Project", str(Path.cwd()))
|
|
228
|
+
|
|
229
|
+
# Memory
|
|
230
|
+
memory_path = nex_dir / "memory.md"
|
|
231
|
+
if memory_path.is_file():
|
|
232
|
+
lines = len(memory_path.read_text(encoding="utf-8").splitlines())
|
|
233
|
+
table.add_row("Memory", f"{lines} lines")
|
|
234
|
+
else:
|
|
235
|
+
table.add_row("Memory", "[red]Not found[/red]")
|
|
236
|
+
|
|
237
|
+
# Error DB
|
|
238
|
+
errors_path = nex_dir / "errors.db"
|
|
239
|
+
if errors_path.is_file():
|
|
240
|
+
import sqlite3
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
conn = sqlite3.connect(str(errors_path))
|
|
244
|
+
count = conn.execute("SELECT COUNT(*) FROM error_patterns").fetchone()[0]
|
|
245
|
+
conn.close()
|
|
246
|
+
table.add_row("Error patterns", str(count))
|
|
247
|
+
except sqlite3.Error:
|
|
248
|
+
table.add_row("Error patterns", "[yellow]Unreadable[/yellow]")
|
|
249
|
+
else:
|
|
250
|
+
table.add_row("Error patterns", "[dim]None yet[/dim]")
|
|
251
|
+
|
|
252
|
+
# Index
|
|
253
|
+
index_path = nex_dir / "index.json"
|
|
254
|
+
if index_path.is_file():
|
|
255
|
+
import json
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
259
|
+
table.add_row(
|
|
260
|
+
"Index",
|
|
261
|
+
f"{len(data.get('files', []))} files, {len(data.get('symbols', []))} symbols",
|
|
262
|
+
)
|
|
263
|
+
except (json.JSONDecodeError, KeyError):
|
|
264
|
+
table.add_row("Index", "[yellow]Malformed[/yellow]")
|
|
265
|
+
else:
|
|
266
|
+
table.add_row("Index", "[dim]Not built[/dim]")
|
|
267
|
+
|
|
268
|
+
# Config
|
|
269
|
+
table.add_row("Model", config.model)
|
|
270
|
+
table.add_row("Max iterations", str(config.max_iterations))
|
|
271
|
+
table.add_row("API key", "[green]Set[/green]" if config.api_key else "[red]Missing[/red]")
|
|
272
|
+
|
|
273
|
+
console.print()
|
|
274
|
+
console.print(table)
|
|
275
|
+
console.print()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def auth() -> None:
|
|
280
|
+
"""Set up Anthropic API key."""
|
|
281
|
+
console.print(
|
|
282
|
+
Panel(
|
|
283
|
+
"Configure your Anthropic API key.\n"
|
|
284
|
+
"Get one at [link=https://console.anthropic.com/]console.anthropic.com[/link]",
|
|
285
|
+
title="[bold cyan]API Key Setup[/bold cyan]",
|
|
286
|
+
border_style="cyan",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
api_key = Prompt.ask("\n[bold]Anthropic API key[/bold]")
|
|
291
|
+
if not api_key.strip():
|
|
292
|
+
_error_exit("No key provided.")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
api_key = api_key.strip()
|
|
296
|
+
if not api_key.startswith("sk-ant-"):
|
|
297
|
+
console.print("[yellow]Warning:[/yellow] Key doesn't start with 'sk-ant-'.")
|
|
298
|
+
proceed = Prompt.ask("Save anyway?", choices=["y", "n"], default="n")
|
|
299
|
+
if proceed.lower() != "y":
|
|
300
|
+
raise typer.Exit(code=0)
|
|
301
|
+
|
|
302
|
+
save_global_config("api_key", api_key)
|
|
303
|
+
console.print("\n[green]API key saved.[/green]")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command()
|
|
307
|
+
def rollback() -> None:
|
|
308
|
+
"""Undo the last agent change (git revert on nex/* branch)."""
|
|
309
|
+
try:
|
|
310
|
+
result = subprocess.run(
|
|
311
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
312
|
+
capture_output=True,
|
|
313
|
+
text=True,
|
|
314
|
+
check=False,
|
|
315
|
+
)
|
|
316
|
+
branch = result.stdout.strip()
|
|
317
|
+
|
|
318
|
+
if not branch.startswith("nex/"):
|
|
319
|
+
_error_exit(
|
|
320
|
+
f"Current branch is '{branch}', not a nex/* branch.",
|
|
321
|
+
hint="Rollback only works on nex/* branches.",
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
log = subprocess.run(
|
|
326
|
+
["git", "log", "-1", "--oneline"],
|
|
327
|
+
capture_output=True,
|
|
328
|
+
text=True,
|
|
329
|
+
check=False,
|
|
330
|
+
)
|
|
331
|
+
console.print(f"[bold]Last commit:[/bold] {log.stdout.strip()}")
|
|
332
|
+
|
|
333
|
+
confirm = Prompt.ask("[bold]Revert?[/bold]", choices=["y", "n"], default="n")
|
|
334
|
+
if confirm.lower() != "y":
|
|
335
|
+
raise typer.Exit(code=0)
|
|
336
|
+
|
|
337
|
+
revert = subprocess.run(
|
|
338
|
+
["git", "revert", "HEAD", "--no-edit"],
|
|
339
|
+
capture_output=True,
|
|
340
|
+
text=True,
|
|
341
|
+
check=False,
|
|
342
|
+
)
|
|
343
|
+
if revert.returncode != 0:
|
|
344
|
+
_error_exit(f"Revert failed: {revert.stderr.strip()}")
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
console.print("[green]Reverted last commit.[/green]")
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
_error_exit("git not found.", hint="Install git.")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command(name="memory")
|
|
353
|
+
def memory_cmd(
|
|
354
|
+
action: Annotated[str, typer.Argument(help="Action: show, edit")] = "show",
|
|
355
|
+
) -> None:
|
|
356
|
+
"""View or edit project memory."""
|
|
357
|
+
nex_dir = Path.cwd() / ".nex"
|
|
358
|
+
if not nex_dir.is_dir():
|
|
359
|
+
_error_exit("Project not initialized.", hint="Run 'nex init' first.")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
memory_path = nex_dir / "memory.md"
|
|
363
|
+
|
|
364
|
+
if action == "show":
|
|
365
|
+
if not memory_path.is_file():
|
|
366
|
+
_error_exit("No memory.md found.")
|
|
367
|
+
return
|
|
368
|
+
content = memory_path.read_text(encoding="utf-8")
|
|
369
|
+
console.print(
|
|
370
|
+
Panel(
|
|
371
|
+
Markdown(content),
|
|
372
|
+
title="[bold cyan]Project Memory[/bold cyan]",
|
|
373
|
+
border_style="cyan",
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
elif action == "edit":
|
|
378
|
+
if not memory_path.is_file():
|
|
379
|
+
_error_exit("No memory.md found.")
|
|
380
|
+
return
|
|
381
|
+
editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
|
|
382
|
+
if editor:
|
|
383
|
+
subprocess.run([editor, str(memory_path)], check=False)
|
|
384
|
+
else:
|
|
385
|
+
console.print(
|
|
386
|
+
"[yellow]No $EDITOR set.[/yellow] Edit .nex/memory.md directly in your IDE."
|
|
387
|
+
)
|
|
388
|
+
else:
|
|
389
|
+
_error_exit(f"Unknown action '{action}'.", hint="Valid: show, edit")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@app.command()
|
|
393
|
+
def chat(
|
|
394
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose output")] = False,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Start an interactive chat session with Nex AI."""
|
|
397
|
+
nex_dir = Path.cwd() / ".nex"
|
|
398
|
+
if not nex_dir.is_dir():
|
|
399
|
+
_error_exit("Project not initialized.", hint="Run 'nex init' first.")
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
config = load_config(Path.cwd())
|
|
404
|
+
except NexError as exc:
|
|
405
|
+
_error_exit(str(exc))
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
if verbose:
|
|
409
|
+
config.log_level = "DEBUG"
|
|
410
|
+
|
|
411
|
+
if not config.api_key:
|
|
412
|
+
_error_exit(
|
|
413
|
+
"Anthropic API key not found.",
|
|
414
|
+
hint="Run 'nex auth' or set ANTHROPIC_API_KEY environment variable.",
|
|
415
|
+
)
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
console.print(
|
|
419
|
+
Panel(
|
|
420
|
+
"Interactive chat mode. The agent remembers your conversation.\n"
|
|
421
|
+
'Type [bold]"exit"[/bold] or [bold]"quit"[/bold] to end the session.',
|
|
422
|
+
title=f"[bold cyan]Nex AI Chat[/bold cyan] v{__version__}",
|
|
423
|
+
border_style="cyan",
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
asyncio.run(_run_chat(config))
|
|
429
|
+
except KeyboardInterrupt:
|
|
430
|
+
console.print("\n[yellow]Chat ended.[/yellow]")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
async def _run_chat(config: NexConfig) -> None:
|
|
434
|
+
"""Run the interactive chat REPL.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
config: Nex configuration.
|
|
438
|
+
"""
|
|
439
|
+
from nex.agent import ChatSession
|
|
440
|
+
from nex.api_client import AnthropicClient
|
|
441
|
+
from nex.context import ContextAssembler
|
|
442
|
+
from nex.indexer.index import IndexBuilder
|
|
443
|
+
from nex.memory.errors import ErrorPatternDB
|
|
444
|
+
from nex.memory.project import ProjectMemory
|
|
445
|
+
from nex.safety import SafetyLayer
|
|
446
|
+
|
|
447
|
+
# Load context once at startup
|
|
448
|
+
memory = ProjectMemory(config.project_dir)
|
|
449
|
+
error_db = ErrorPatternDB(config.project_dir)
|
|
450
|
+
assembler = ContextAssembler(config.project_dir)
|
|
451
|
+
builder = IndexBuilder(config.project_dir)
|
|
452
|
+
|
|
453
|
+
project_memory = memory.load()
|
|
454
|
+
error_patterns = error_db.find_similar(task_summary="interactive chat session")
|
|
455
|
+
idx = builder.load()
|
|
456
|
+
relevant_code = assembler.select_relevant_code("interactive chat session", idx)
|
|
457
|
+
|
|
458
|
+
system_prompt = assembler.build_system_prompt(
|
|
459
|
+
project_memory=project_memory,
|
|
460
|
+
error_patterns=error_patterns,
|
|
461
|
+
relevant_code=relevant_code,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
client = AnthropicClient(api_key=config.api_key, default_model=config.model)
|
|
465
|
+
safety = SafetyLayer(dry_run=config.dry_run)
|
|
466
|
+
|
|
467
|
+
session = ChatSession(
|
|
468
|
+
api_client=client,
|
|
469
|
+
system_prompt=system_prompt,
|
|
470
|
+
project_dir=config.project_dir,
|
|
471
|
+
safety=safety,
|
|
472
|
+
dry_run=config.dry_run,
|
|
473
|
+
max_iterations=config.max_iterations,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
while True:
|
|
478
|
+
try:
|
|
479
|
+
user_input = Prompt.ask("[bold cyan]You[/bold cyan]")
|
|
480
|
+
except EOFError:
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
if not user_input.strip():
|
|
484
|
+
continue
|
|
485
|
+
if user_input.strip().lower() in ("exit", "quit"):
|
|
486
|
+
break
|
|
487
|
+
|
|
488
|
+
response = await session.send(user_input)
|
|
489
|
+
if response:
|
|
490
|
+
console.print(
|
|
491
|
+
Panel(
|
|
492
|
+
Markdown(response),
|
|
493
|
+
title="[bold green]Nex[/bold green]",
|
|
494
|
+
border_style="green",
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
finally:
|
|
498
|
+
error_db.close()
|
|
499
|
+
await client.close()
|
|
500
|
+
|
|
501
|
+
console.print(
|
|
502
|
+
f"\n[dim]Session: {session.turn_count} turns | "
|
|
503
|
+
f"Cost: ${client.usage.estimated_cost:.4f} "
|
|
504
|
+
f"({client.usage.total_input} in + {client.usage.total_output} out tokens)[/dim]"
|
|
505
|
+
)
|
|
506
|
+
console.print("[dim]Chat ended.[/dim]")
|