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 ADDED
@@ -0,0 +1,6 @@
1
+ """Nex AI — The Coding Agent That Remembers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __app_name__ = "nex"
nex/agent.py ADDED
@@ -0,0 +1,623 @@
1
+ """Core agent loop — the heart of Nex AI.
2
+
3
+ Follows the agentic REPL pattern:
4
+ 1. Assemble context (system prompt + memory + code + errors)
5
+ 2. Call Claude API with tool definitions
6
+ 3. Parse response: if tool_use blocks, execute the tools
7
+ 4. Feed tool results back to Claude
8
+ 5. Repeat until text-only response or max iterations
9
+ 6. Log errors encountered + fixes applied
10
+ 7. If code modified: run tests, show diff, ask to commit
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from rich.console import Console
20
+ from rich.markdown import Markdown
21
+ from rich.panel import Panel
22
+ from rich.prompt import Prompt
23
+ from rich.syntax import Syntax
24
+
25
+ from nex.api_client import AnthropicClient
26
+ from nex.config import NexConfig
27
+ from nex.context import ContextAssembler
28
+ from nex.exceptions import NexError, SafetyError, ToolError
29
+ from nex.memory.errors import ErrorPatternDB
30
+ from nex.memory.project import ProjectMemory
31
+ from nex.safety import SafetyLayer
32
+ from nex.test_runner import TestRunner
33
+ from nex.tools import TOOL_DEFINITIONS, ToolResult
34
+ from nex.tools.file_ops import read_file, write_file
35
+ from nex.tools.git_ops import GitOperations
36
+ from nex.tools.search import search_files
37
+ from nex.tools.shell import run_command
38
+
39
+ console = Console()
40
+
41
+
42
+ @dataclass
43
+ class AgentConfig:
44
+ """Configuration for a single agent run.
45
+
46
+ Attributes:
47
+ project_dir: Project root directory.
48
+ task: The user's task description.
49
+ dry_run: If True, show what would happen without executing.
50
+ max_iterations: Maximum number of tool call iterations.
51
+ """
52
+
53
+ project_dir: Path
54
+ task: str
55
+ dry_run: bool = False
56
+ max_iterations: int = 25
57
+
58
+
59
+ async def execute_tool(
60
+ name: str,
61
+ tool_input: dict[str, Any],
62
+ project_dir: Path,
63
+ safety: SafetyLayer,
64
+ dry_run: bool = False,
65
+ ) -> tuple[ToolResult, bool]:
66
+ """Route a tool call to the correct handler.
67
+
68
+ Shared between Agent (single-task) and ChatSession (interactive REPL).
69
+
70
+ Args:
71
+ name: Name of the tool to execute.
72
+ tool_input: Tool input parameters.
73
+ project_dir: Project root directory.
74
+ safety: Safety layer for command approval.
75
+ dry_run: If True, skip destructive operations.
76
+
77
+ Returns:
78
+ Tuple of (ToolResult, files_modified_flag).
79
+ """
80
+ files_modified = False
81
+ try:
82
+ if name == "read_file":
83
+ return await read_file(tool_input["path"], project_dir), False
84
+
85
+ if name == "write_file":
86
+ path = tool_input["path"]
87
+ check = safety.check_file_write(path, project_dir)
88
+ if not check.is_safe:
89
+ if check.requires_approval:
90
+ approved = await safety.request_approval(f"Write to {path}", check.reason or "")
91
+ if not approved:
92
+ return ToolResult(success=False, output="", error="Write denied"), False
93
+ else:
94
+ reason = check.reason or "Write blocked"
95
+ return ToolResult(success=False, output="", error=reason), False
96
+
97
+ result = await write_file(path, tool_input["content"], project_dir)
98
+ if result.success:
99
+ files_modified = True
100
+ return result, files_modified
101
+
102
+ if name == "run_command":
103
+ try:
104
+ await safety.guard_command(tool_input["command"])
105
+ except SafetyError as exc:
106
+ return ToolResult(success=False, output="", error=str(exc)), False
107
+
108
+ if dry_run:
109
+ return (
110
+ ToolResult(
111
+ success=True,
112
+ output=f"[dry-run] Would execute: {tool_input['command']}",
113
+ ),
114
+ False,
115
+ )
116
+
117
+ return await run_command(tool_input["command"], project_dir), False
118
+
119
+ if name == "search_files":
120
+ result = await search_files(
121
+ tool_input["pattern"],
122
+ tool_input.get("path", "."),
123
+ project_dir,
124
+ )
125
+ return result, False
126
+
127
+ if name == "list_directory":
128
+ result = await list_directory(
129
+ tool_input.get("path", "."),
130
+ tool_input.get("depth", 3),
131
+ project_dir,
132
+ )
133
+ return result, False
134
+
135
+ return ToolResult(success=False, output="", error=f"Unknown tool: {name}"), False
136
+
137
+ except Exception as exc:
138
+ return ToolResult(success=False, output="", error=f"Tool error: {exc}"), False
139
+
140
+
141
+ async def list_directory(path: str, depth: int, project_dir: Path) -> ToolResult:
142
+ """List directory contents recursively.
143
+
144
+ Args:
145
+ path: Directory path relative to project root.
146
+ depth: Maximum recursion depth.
147
+ project_dir: Project root directory.
148
+
149
+ Returns:
150
+ ToolResult with the directory tree listing.
151
+ """
152
+ from nex.tools.file_ops import _resolve_safe_path
153
+
154
+ try:
155
+ resolved = _resolve_safe_path(path, project_dir)
156
+ except ToolError as exc:
157
+ return ToolResult(success=False, output="", error=str(exc))
158
+
159
+ if not resolved.is_dir():
160
+ return ToolResult(success=False, output="", error=f"Not a directory: {path}")
161
+
162
+ lines: list[str] = []
163
+ _walk_tree(resolved, "", depth, lines)
164
+ return ToolResult(success=True, output="\n".join(lines))
165
+
166
+
167
+ def _walk_tree(directory: Path, prefix: str, depth: int, lines: list[str]) -> None:
168
+ """Recursively build a directory tree listing.
169
+
170
+ Args:
171
+ directory: Current directory to list.
172
+ prefix: Line prefix for indentation.
173
+ depth: Remaining recursion depth.
174
+ lines: Accumulator for output lines.
175
+ """
176
+ if depth <= 0:
177
+ return
178
+
179
+ try:
180
+ entries = sorted(directory.iterdir(), key=lambda e: (not e.is_dir(), e.name))
181
+ except PermissionError:
182
+ return
183
+
184
+ for i, entry in enumerate(entries):
185
+ if entry.name.startswith(".") or entry.name in ("node_modules", "__pycache__", ".nex"):
186
+ continue
187
+
188
+ is_last = i == len(entries) - 1
189
+ connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
190
+ lines.append(f"{prefix}{connector}{entry.name}")
191
+
192
+ if entry.is_dir():
193
+ extension = " " if is_last else "\u2502 "
194
+ _walk_tree(entry, prefix + extension, depth - 1, lines)
195
+
196
+
197
+ class Agent:
198
+ """Core agent loop — orchestrates tools, API calls, and memory."""
199
+
200
+ def __init__(
201
+ self,
202
+ config: AgentConfig,
203
+ api_client: AnthropicClient,
204
+ safety: SafetyLayer,
205
+ nex_config: NexConfig | None = None,
206
+ ) -> None:
207
+ """Initialize the agent.
208
+
209
+ Args:
210
+ config: Agent run configuration.
211
+ api_client: Anthropic API client.
212
+ safety: Safety layer for command approval.
213
+ nex_config: Full Nex configuration (for test runner settings).
214
+ """
215
+ self._config = config
216
+ self._nex_config = nex_config
217
+ self._client = api_client
218
+ self._safety = safety
219
+ self._project_dir = config.project_dir
220
+ self._messages: list[dict[str, Any]] = []
221
+ self._iteration = 0
222
+ self._files_modified = False
223
+
224
+ async def run(self) -> str:
225
+ """Execute the agent loop and return the final response.
226
+
227
+ Returns:
228
+ The agent's final text response.
229
+ """
230
+ # Load context
231
+ memory = ProjectMemory(self._project_dir)
232
+ error_db = ErrorPatternDB(self._project_dir)
233
+ assembler = ContextAssembler(self._project_dir)
234
+
235
+ project_memory = memory.load()
236
+ error_patterns = error_db.find_similar(task_summary=self._config.task)
237
+
238
+ # Try to load index for relevant code
239
+ from nex.indexer.index import IndexBuilder
240
+
241
+ builder = IndexBuilder(self._project_dir)
242
+ index = builder.load()
243
+ relevant_code = assembler.select_relevant_code(self._config.task, index)
244
+
245
+ system_prompt = assembler.build_system_prompt(
246
+ project_memory=project_memory,
247
+ error_patterns=error_patterns,
248
+ relevant_code=relevant_code,
249
+ )
250
+
251
+ # Initial user message
252
+ self._messages = [{"role": "user", "content": self._config.task}]
253
+
254
+ console.print(f"\n[bold]Running task:[/bold] {self._config.task}")
255
+ console.print(f"[dim]Max iterations: {self._config.max_iterations}[/dim]\n")
256
+
257
+ final_response = ""
258
+
259
+ try:
260
+ while self._iteration < self._config.max_iterations:
261
+ self._iteration += 1
262
+ console.print(f"[dim]--- Iteration {self._iteration} ---[/dim]")
263
+
264
+ response = await self._client.send_message(
265
+ messages=self._messages,
266
+ system=system_prompt,
267
+ tools=TOOL_DEFINITIONS,
268
+ )
269
+
270
+ # Show token usage
271
+ console.print(
272
+ f"[dim]Tokens: {response.input_tokens} in / "
273
+ f"{response.output_tokens} out | "
274
+ f"Cost: ${self._client.usage.estimated_cost:.4f}[/dim]"
275
+ )
276
+
277
+ # Check for cost warning
278
+ if self._client.usage.estimated_cost > 1.0:
279
+ console.print(
280
+ "[bold yellow]Warning:[/bold yellow] Task has exceeded $1.00 in API costs"
281
+ )
282
+
283
+ # Process response
284
+ has_tool_use = any(block.get("type") == "tool_use" for block in response.content)
285
+
286
+ if not has_tool_use:
287
+ # Task complete — extract text
288
+ for block in response.content:
289
+ if block.get("type") == "text":
290
+ final_response = block.get("text", "")
291
+ console.print()
292
+ console.print(
293
+ Panel(
294
+ Markdown(final_response),
295
+ title="[bold green]Task Complete[/bold green]",
296
+ border_style="green",
297
+ )
298
+ )
299
+ break
300
+
301
+ # Execute tool calls
302
+ assistant_content = response.content
303
+ self._messages.append({"role": "assistant", "content": assistant_content})
304
+
305
+ tool_results: list[dict[str, Any]] = []
306
+ for block in assistant_content:
307
+ if block.get("type") == "tool_use":
308
+ tool_name = block["name"]
309
+ tool_input = block["input"]
310
+ tool_id = block["id"]
311
+
312
+ summary = _summarize_input(tool_input)
313
+ console.print(f" [cyan]Tool:[/cyan] {tool_name}({summary})")
314
+
315
+ result, modified = await execute_tool(
316
+ tool_name,
317
+ tool_input,
318
+ self._project_dir,
319
+ self._safety,
320
+ self._config.dry_run,
321
+ )
322
+ if modified:
323
+ self._files_modified = True
324
+
325
+ if result.success:
326
+ console.print(f" [green]OK[/green] ({len(result.output)} chars)")
327
+ else:
328
+ console.print(f" [red]Error:[/red] {result.error}")
329
+
330
+ content = result.output if result.success else f"Error: {result.error}"
331
+ tool_results.append(
332
+ {
333
+ "type": "tool_result",
334
+ "tool_use_id": tool_id,
335
+ "content": content,
336
+ "is_error": not result.success,
337
+ }
338
+ )
339
+
340
+ self._messages.append({"role": "user", "content": tool_results})
341
+
342
+ else:
343
+ max_iter = self._config.max_iterations
344
+ console.print(f"\n[bold yellow]Reached max iterations ({max_iter})[/bold yellow]")
345
+ final_response = "Task did not complete within the iteration limit."
346
+
347
+ except KeyboardInterrupt:
348
+ console.print("\n[yellow]Agent interrupted by user.[/yellow]")
349
+ final_response = "Task interrupted."
350
+ finally:
351
+ error_db.close()
352
+
353
+ # Post-task: run tests, show diff, and offer to commit
354
+ if self._files_modified:
355
+ tests_passed = await self._run_tests()
356
+ if not tests_passed:
357
+ console.print(
358
+ "[yellow]Tests failed. Review the changes before committing.[/yellow]"
359
+ )
360
+ await self._post_task_git()
361
+
362
+ return final_response
363
+
364
+ async def _run_tests(self) -> bool:
365
+ """Detect and run the project's test suite.
366
+
367
+ Returns:
368
+ True if tests passed or no test runner was detected.
369
+ """
370
+ runner = TestRunner(self._project_dir)
371
+
372
+ # Use config override if available
373
+ command = None
374
+ timeout = 120
375
+ if self._nex_config:
376
+ if self._nex_config.test_command:
377
+ command = self._nex_config.test_command
378
+ timeout = self._nex_config.test_timeout
379
+
380
+ if command is None:
381
+ command = runner.detect()
382
+
383
+ if command is None:
384
+ return True
385
+
386
+ console.print(f"\n[bold]Running tests:[/bold] {command}")
387
+ result = await runner.run(command, timeout)
388
+
389
+ if result.success:
390
+ console.print(
391
+ Panel(
392
+ result.output[:2000] if len(result.output) > 2000 else result.output,
393
+ title="[bold green]Tests Passed[/bold green]",
394
+ border_style="green",
395
+ )
396
+ )
397
+ else:
398
+ console.print(
399
+ Panel(
400
+ result.output[:2000] if len(result.output) > 2000 else result.output,
401
+ title="[bold red]Tests Failed[/bold red]",
402
+ border_style="red",
403
+ )
404
+ )
405
+
406
+ return result.success
407
+
408
+ async def _post_task_git(self) -> None:
409
+ """Show diff and offer to commit after file modifications."""
410
+ try:
411
+ git = GitOperations(self._project_dir)
412
+ if not git.is_repo():
413
+ return
414
+
415
+ diff = git.diff()
416
+ if diff:
417
+ console.print("\n[bold]Changes made:[/bold]")
418
+ console.print(Syntax(diff, "diff", theme="monokai"))
419
+
420
+ answer = Prompt.ask(
421
+ "\n[bold]Commit these changes?[/bold]",
422
+ choices=["y", "n"],
423
+ default="n",
424
+ )
425
+ if answer.lower() == "y":
426
+ msg = Prompt.ask("[bold]Commit message[/bold]")
427
+ if msg:
428
+ git.commit(msg)
429
+ except (ToolError, NexError) as exc:
430
+ console.print(f"[yellow]Git operation failed:[/yellow] {exc}")
431
+
432
+
433
+ class ChatSession:
434
+ """Interactive chat session with persistent message history.
435
+
436
+ Unlike Agent (which runs a single task to completion), ChatSession
437
+ maintains a conversation across multiple user turns, accumulating
438
+ context in its message history.
439
+ """
440
+
441
+ def __init__(
442
+ self,
443
+ api_client: AnthropicClient,
444
+ system_prompt: str,
445
+ project_dir: Path,
446
+ safety: SafetyLayer,
447
+ dry_run: bool = False,
448
+ max_iterations: int = 25,
449
+ ) -> None:
450
+ """Initialize a chat session.
451
+
452
+ Args:
453
+ api_client: Anthropic API client.
454
+ system_prompt: Pre-assembled system prompt.
455
+ project_dir: Project root directory.
456
+ safety: Safety layer for command approval.
457
+ dry_run: If True, skip destructive operations.
458
+ max_iterations: Max tool calls per user turn.
459
+ """
460
+ self._client = api_client
461
+ self._system_prompt = system_prompt
462
+ self._project_dir = project_dir
463
+ self._safety = safety
464
+ self._dry_run = dry_run
465
+ self._max_iterations = max_iterations
466
+ self._messages: list[dict[str, Any]] = []
467
+ self._turn_count = 0
468
+ self._files_modified = False
469
+
470
+ @property
471
+ def messages(self) -> list[dict[str, Any]]:
472
+ """Return the conversation message history."""
473
+ return self._messages
474
+
475
+ @property
476
+ def turn_count(self) -> int:
477
+ """Return the number of user turns processed."""
478
+ return self._turn_count
479
+
480
+ @property
481
+ def files_modified(self) -> bool:
482
+ """Return whether any files have been modified."""
483
+ return self._files_modified
484
+
485
+ async def send(self, user_message: str) -> str:
486
+ """Process a single user turn, executing tools as needed.
487
+
488
+ Args:
489
+ user_message: The user's message text.
490
+
491
+ Returns:
492
+ The assistant's final text response for this turn.
493
+ """
494
+ self._turn_count += 1
495
+ self._messages.append({"role": "user", "content": user_message})
496
+
497
+ iterations = 0
498
+ final_text = ""
499
+
500
+ while iterations < self._max_iterations:
501
+ iterations += 1
502
+
503
+ response = await self._client.send_message(
504
+ messages=self._messages,
505
+ system=self._system_prompt,
506
+ tools=TOOL_DEFINITIONS,
507
+ )
508
+
509
+ # Show token usage
510
+ console.print(
511
+ f"[dim]Tokens: {response.input_tokens} in / "
512
+ f"{response.output_tokens} out | "
513
+ f"Cost: ${self._client.usage.estimated_cost:.4f}[/dim]"
514
+ )
515
+
516
+ # Cost warnings
517
+ cost = self._client.usage.estimated_cost
518
+ if cost > 5.0:
519
+ console.print(
520
+ "[bold red]Warning:[/bold red] Session has exceeded $5.00 in API costs"
521
+ )
522
+ elif cost > 1.0:
523
+ console.print(
524
+ "[bold yellow]Warning:[/bold yellow] Session has exceeded $1.00 in API costs"
525
+ )
526
+
527
+ has_tool_use = any(block.get("type") == "tool_use" for block in response.content)
528
+
529
+ if not has_tool_use:
530
+ # Extract final text
531
+ for block in response.content:
532
+ if block.get("type") == "text":
533
+ final_text = block.get("text", "")
534
+ self._messages.append({"role": "assistant", "content": response.content})
535
+ break
536
+
537
+ # Execute tool calls
538
+ assistant_content = response.content
539
+ self._messages.append({"role": "assistant", "content": assistant_content})
540
+
541
+ tool_results: list[dict[str, Any]] = []
542
+ for block in assistant_content:
543
+ if block.get("type") == "tool_use":
544
+ tool_name = block["name"]
545
+ tool_input = block["input"]
546
+ tool_id = block["id"]
547
+
548
+ summary = _summarize_input(tool_input)
549
+ console.print(f" [cyan]Tool:[/cyan] {tool_name}({summary})")
550
+
551
+ result, modified = await execute_tool(
552
+ tool_name,
553
+ tool_input,
554
+ self._project_dir,
555
+ self._safety,
556
+ self._dry_run,
557
+ )
558
+ if modified:
559
+ self._files_modified = True
560
+
561
+ if result.success:
562
+ console.print(f" [green]OK[/green] ({len(result.output)} chars)")
563
+ else:
564
+ console.print(f" [red]Error:[/red] {result.error}")
565
+
566
+ content = result.output if result.success else f"Error: {result.error}"
567
+ tool_results.append(
568
+ {
569
+ "type": "tool_result",
570
+ "tool_use_id": tool_id,
571
+ "content": content,
572
+ "is_error": not result.success,
573
+ }
574
+ )
575
+
576
+ self._messages.append({"role": "user", "content": tool_results})
577
+
578
+ return final_text
579
+
580
+
581
+ async def run_task(task: str, config: NexConfig) -> None:
582
+ """Entry point called by the CLI to run a task.
583
+
584
+ Args:
585
+ task: The user's task description.
586
+ config: Nex configuration.
587
+ """
588
+ api_key = config.api_key
589
+ client = AnthropicClient(api_key=api_key, default_model=config.model)
590
+ safety = SafetyLayer(dry_run=config.dry_run)
591
+
592
+ agent_config = AgentConfig(
593
+ project_dir=config.project_dir,
594
+ task=task,
595
+ dry_run=config.dry_run,
596
+ max_iterations=config.max_iterations,
597
+ )
598
+
599
+ agent = Agent(config=agent_config, api_client=client, safety=safety, nex_config=config)
600
+
601
+ try:
602
+ await agent.run()
603
+ finally:
604
+ await client.close()
605
+
606
+ # Print final cost summary
607
+ console.print(
608
+ f"\n[dim]Total cost: ${client.usage.estimated_cost:.4f} "
609
+ f"({client.usage.total_input} input + {client.usage.total_output} output tokens)[/dim]"
610
+ )
611
+
612
+
613
+ def _summarize_input(tool_input: dict[str, Any]) -> str:
614
+ """Create a short summary of tool input for display."""
615
+ parts: list[str] = []
616
+ for key, value in tool_input.items():
617
+ if isinstance(value, str) and len(value) > 50:
618
+ parts.append(f'{key}="{value[:47]}..."')
619
+ elif isinstance(value, str):
620
+ parts.append(f'{key}="{value}"')
621
+ else:
622
+ parts.append(f"{key}={value}")
623
+ return ", ".join(parts)