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/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]")