nimcode 0.1.0__tar.gz → 0.1.4__tar.gz

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.
Files changed (42) hide show
  1. {nimcode-0.1.0 → nimcode-0.1.4}/PKG-INFO +12 -12
  2. nimcode-0.1.4/README.md +68 -0
  3. {nimcode-0.1.0 → nimcode-0.1.4}/setup.cfg +4 -4
  4. {nimcode-0.1.0 → nimcode-0.1.4}/setup.py +1 -1
  5. nimcode-0.1.4/src/nimcode/agent.py +415 -0
  6. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/cli.py +8 -5
  7. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/lenient_parser.py +15 -9
  8. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/nim_client.py +6 -2
  9. nimcode-0.1.4/src/nimcode/permissions.py +124 -0
  10. nimcode-0.1.4/src/nimcode/repl.py +963 -0
  11. nimcode-0.1.4/src/nimcode/tools.py +672 -0
  12. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/PKG-INFO +12 -12
  13. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/SOURCES.txt +4 -0
  14. nimcode-0.1.4/tests/test_agent.py +300 -0
  15. nimcode-0.1.4/tests/test_cli.py +112 -0
  16. nimcode-0.1.4/tests/test_config.py +67 -0
  17. {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_lenient_parser.py +8 -0
  18. nimcode-0.1.4/tests/test_mcp_client.py +135 -0
  19. {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_memory.py +6 -0
  20. {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_nim_client.py +45 -37
  21. nimcode-0.1.4/tests/test_permissions.py +76 -0
  22. nimcode-0.1.4/tests/test_repl.py +387 -0
  23. nimcode-0.1.4/tests/test_repl_extra.py +136 -0
  24. nimcode-0.1.4/tests/test_repl_trust.py +55 -0
  25. {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_tools.py +135 -0
  26. nimcode-0.1.0/README.md +0 -47
  27. nimcode-0.1.0/src/nimcode/agent.py +0 -582
  28. nimcode-0.1.0/src/nimcode/permissions.py +0 -78
  29. nimcode-0.1.0/src/nimcode/tools.py +0 -323
  30. nimcode-0.1.0/tests/test_agent.py +0 -135
  31. nimcode-0.1.0/tests/test_cli.py +0 -29
  32. nimcode-0.1.0/tests/test_config.py +0 -32
  33. nimcode-0.1.0/tests/test_mcp_client.py +0 -24
  34. nimcode-0.1.0/tests/test_permissions.py +0 -48
  35. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/__init__.py +0 -0
  36. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/config.py +0 -0
  37. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/mcp_client.py +0 -0
  38. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/memory.py +0 -0
  39. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/dependency_links.txt +0 -0
  40. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/entry_points.txt +0 -0
  41. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/requires.txt +0 -0
  42. {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/top_level.txt +0 -0
@@ -1,12 +1,12 @@
1
- Metadata-Version: 2.4
2
- Name: nimcode
3
- Version: 0.1.0
4
- Summary: A standalone, robust coding agent for NVIDIA NIM models.
5
- Author: Autonomous Agent
6
- Requires-Dist: httpx>=0.27.0
7
- Requires-Dist: rich>=13.7.0
8
- Requires-Dist: prompt_toolkit>=3.0.0
9
- Requires-Dist: mcp>=1.2.0
10
- Dynamic: author
11
- Dynamic: requires-dist
12
- Dynamic: summary
1
+ Metadata-Version: 2.4
2
+ Name: nimcode
3
+ Version: 0.1.4
4
+ Summary: A standalone, robust coding agent for NVIDIA NIM models.
5
+ Author: Autonomous Agent
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: rich>=13.7.0
8
+ Requires-Dist: prompt_toolkit>=3.0.0
9
+ Requires-Dist: mcp>=1.2.0
10
+ Dynamic: author
11
+ Dynamic: requires-dist
12
+ Dynamic: summary
@@ -0,0 +1,68 @@
1
+ <div align="center">
2
+
3
+ # 🚀 NimCode
4
+
5
+ **The Autonomous AI Coding Assistant for the NVIDIA NIM Ecosystem**
6
+
7
+ [![PyPI version](https://badge.fury.io/py/nimcode.svg)](https://badge.fury.io/py/nimcode)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+
10
+ *A powerful, standalone REPL and CLI agent that writes, edits, and plans code like a senior developer—powered by LLaMa 3.1 70B and the lightning-fast NVIDIA NIM API.*
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## 🌟 Features
17
+
18
+ - 🧠 **Smart Planning (`/plan`)**: Generates comprehensive, step-by-step architectural plans directly into `.nimcode/plans/`.
19
+ - 🛠️ **Autonomous Execution (`/code`)**: Reads, writes, and tests code across your entire workspace.
20
+ - 📸 **Vision Support (`/vision`)**: Analyze screenshots to debug UI issues or write code from design mockups.
21
+ - 🎙️ **Voice Commands (`/voice`)**: Speak your requirements and let the agent do the typing.
22
+ - 💾 **Continuous Learning**: Saves custom rules and instructions to `.nimcode/skills/` so it gets smarter over time.
23
+ - 🤖 **Auto-Commits (`/commit`)**: Automatically writes descriptive, conventional git commits for your changes.
24
+
25
+ ## 📦 Installation
26
+
27
+ NimCode is available globally via PyPI. You can install it anywhere in seconds:
28
+
29
+ ```bash
30
+ pip install nimcode
31
+ ```
32
+
33
+ ## 🚀 Quick Start
34
+
35
+ 1. **Get an API Key**: Grab a free NVIDIA API key from [build.nvidia.com](https://build.nvidia.com/).
36
+ 2. **Login**: Connect your local environment by running:
37
+ ```bash
38
+ nimcode login
39
+ ```
40
+ 3. **Start Coding**:
41
+ - Launch the interactive REPL:
42
+ ```bash
43
+ nimcode
44
+ ```
45
+ - Or run a one-off task directly from the command line:
46
+ ```bash
47
+ nimcode /plan "Build a classic Snake game using HTML5 Canvas"
48
+ ```
49
+
50
+ ## ⚙️ How it Works
51
+
52
+ NimCode creates a `.nimcode` directory inside your projects. This directory acts as the "brain" for that specific workspace:
53
+ - `.nimcode/plans/`: All your generated step-by-step markdown plans live here.
54
+ - `.nimcode/skills/`: Any custom guidelines, framework rules, or memories you teach the agent.
55
+
56
+ ## 🛡️ Requirements
57
+
58
+ - Python 3.8+
59
+ - An NVIDIA NIM API Key (NIM_API_KEY)
60
+
61
+ ## 🤝 Contributing
62
+
63
+ Contributions, issues, and feature requests are welcome! Feel free to check out our [issues page](#).
64
+
65
+ ---
66
+ <div align="center">
67
+ <i>Built with ❤️ for the open-source developer community.</i>
68
+ </div>
@@ -1,4 +1,4 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="nimcode",
5
- version="0.1.0",
5
+ version="0.1.4",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -0,0 +1,415 @@
1
+ from typing import List, Dict, Any, Optional
2
+ import os
3
+ import logging
4
+ from .nim_client import NimClient
5
+ from .lenient_parser import LenientParser
6
+ from .tools import ToolRegistry
7
+ from .permissions import PermissionEngine, PermissionMode
8
+ from .config import load_settings, save_global_setting
9
+ from .mcp_client import MCPManager
10
+ from .memory import MemoryManager
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ SYSTEM_PROMPT = """You are nimcode, an autonomous AI coding assistant.
15
+ You have access to a set of tools to read, write, and execute code.
16
+
17
+ CRITICAL INSTRUCTION FOR TOOL CALLING:
18
+ You must output exactly ONE tool call per turn, formatted exactly as a fenced XML block.
19
+ DO NOT use Markdown code blocks for the tool call. DO NOT output multiple tool calls in a single turn.
20
+
21
+ Format:
22
+ <tool_call>
23
+ {"tool": "ToolName", "args": {"arg1": "val1"}}
24
+ </tool_call>
25
+
26
+ Available Tools:
27
+ - Bash: {"tool": "Bash", "args": {"command": "string"}}
28
+ - Read: {"tool": "Read", "args": {"file_path": "string"}}
29
+ - Write: {"tool": "Write", "args": {"file_path": "string", "content": "string"}}
30
+ - Edit: {"tool": "Edit", "args": {"file_path": "string", "old_string": "string", "new_string": "string"}}
31
+ - Glob: {"tool": "Glob", "args": {"pattern": "string"}}
32
+ - Grep: {"tool": "Grep", "args": {"query": "string", "directory": "string"}}
33
+
34
+ Before calling a tool, you may optionally use a <think> block to reason about your plan. This helps you execute complex tasks correctly.
35
+ For example:
36
+ <think>
37
+ I need to check the file contents first to see where the function is defined.
38
+ </think>
39
+ <tool_call>
40
+ {"tool": "Read", "args": {"file_path": "main.py"}}
41
+ </tool_call>
42
+
43
+ Workspace Guidelines:
44
+ - When creating plans, store them inside the `.nimcode/plans/` directory (e.g., `.nimcode/plans/dino_game_plan.md`). Create this directory if it doesn't exist.
45
+ - When creating or learning new skills/memories, store them as markdown files inside the `.nimcode/skills/` directory.
46
+
47
+ When you have completely fulfilled the user's request and have no more tools to run, output the word TASK_COMPLETE.
48
+ """
49
+
50
+ class Agent:
51
+ def __init__(self, api_key: str, model: str = None, max_turns: int = 30, permission_mode: PermissionMode = PermissionMode.DEFAULT, max_tokens: int = 4000):
52
+ # Load global settings
53
+ self.settings = load_settings()
54
+ self.model = model or self.settings.get("model", "meta/llama-3.1-70b-instruct")
55
+ self.client = NimClient(api_key=api_key, model=self.model)
56
+
57
+ # Initialize MCP Manager
58
+ self.mcp = MCPManager(self.settings)
59
+
60
+ # Base system prompt
61
+ final_prompt = SYSTEM_PROMPT + self.mcp.get_system_prompt_additions()
62
+
63
+ # Load skills if present
64
+ skills_dir = os.path.join(os.getcwd(), ".nimcode", "skills")
65
+ if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
66
+ loaded_skills = []
67
+ for filename in os.listdir(skills_dir):
68
+ if filename.endswith(".md"):
69
+ with open(os.path.join(skills_dir, filename), "r", encoding="utf-8") as f:
70
+ loaded_skills.append(f"--- SKILL: {filename} ---\n{f.read()}\n")
71
+ if loaded_skills:
72
+ final_prompt += "\n\nCRITICAL USER SKILLS & GUIDELINES:\n" + "\n".join(loaded_skills)
73
+ logger.info(f"Loaded {len(loaded_skills)} custom skills from {skills_dir}")
74
+
75
+ # Git context
76
+ if os.path.exists(".git"):
77
+ import subprocess
78
+ try:
79
+ branch = subprocess.check_output(["git", "branch", "--show-current"], text=True).strip()
80
+ status = subprocess.check_output(["git", "status", "-s"], text=True).strip()
81
+ final_prompt += f"\n\nGIT CONTEXT:\nBranch: {branch}\nUncommitted changes:\n{status if status else 'None'}"
82
+ except Exception as e:
83
+ logger.error(f"Failed to load git context: {e}")
84
+
85
+ self.messages: List[Dict[str, Any]] = [
86
+ {"role": "system", "content": final_prompt}
87
+ ]
88
+ self.max_turns = max_turns
89
+ self.permission_engine = PermissionEngine(mode=permission_mode)
90
+ self.memory = MemoryManager(max_tokens=max_tokens)
91
+
92
+ def save_history(self):
93
+ """Saves current conversation to NIMCODE.md"""
94
+ try:
95
+ import json
96
+ with open("NIMCODE.md", "w", encoding="utf-8") as f:
97
+ json.dump(self.messages, f)
98
+ except Exception as e:
99
+ logger.error(f"Failed to save history: {e}")
100
+
101
+ def load_history(self):
102
+ """Loads conversation from NIMCODE.md"""
103
+ try:
104
+ import json
105
+ if os.path.exists("NIMCODE.md"):
106
+ with open("NIMCODE.md", "r", encoding="utf-8") as f:
107
+ self.messages = json.load(f)
108
+ except Exception as e:
109
+ logger.error(f"Failed to load history: {e}")
110
+
111
+ async def _stream_response(self) -> str:
112
+ from rich.live import Live
113
+ from rich.markdown import Markdown
114
+ from rich.console import Console
115
+
116
+ c = Console()
117
+ response_text = ""
118
+ code_theme = self.settings.get("theme", "monokai")
119
+
120
+ first_chunk = None
121
+
122
+ with c.status("[bold cyan]🧠 NimCode is thinking...[/bold cyan]", spinner="dots"):
123
+ iterator = self.client.chat(self.messages).__aiter__()
124
+ try:
125
+ first_chunk = await iterator.__anext__()
126
+ except StopAsyncIteration:
127
+ pass
128
+ except KeyboardInterrupt:
129
+ c.print("\n[yellow]Generation interrupted before starting.[/yellow]")
130
+ return ""
131
+
132
+ if first_chunk is None:
133
+ return ""
134
+
135
+ response_text = first_chunk
136
+
137
+ with Live(Markdown(response_text, code_theme=code_theme), console=c, refresh_per_second=15) as live:
138
+ try:
139
+ async for chunk in iterator:
140
+ response_text += chunk
141
+
142
+ display_text = response_text
143
+
144
+ show_think = self.settings.get("show_thinking", True)
145
+ import re
146
+ def style_think(match):
147
+ if not show_think: return ""
148
+ content = match.group(1).strip()
149
+ if not content: return ""
150
+ return "\n".join(f"> *{line}*" for line in content.split('\n'))
151
+
152
+ display_text = re.sub(r'<think>(.*?)</think>', style_think, display_text, flags=re.DOTALL)
153
+
154
+ if "<think>" in display_text and "</think>" not in display_text:
155
+ parts = display_text.split("<think>", 1)
156
+ content = parts[1].strip()
157
+ if not show_think:
158
+ display_text = parts[0]
159
+ elif content:
160
+ display_text = parts[0] + "\n".join(f"> *{line}*" for line in content.split('\n'))
161
+ else:
162
+ display_text = parts[0]
163
+
164
+ tool_tag_idx = display_text.find("<tool_call>")
165
+ if tool_tag_idx != -1:
166
+ display_text = display_text[:tool_tag_idx] + "\n\n*[dim]⚙️ Preparing tool execution...[/dim]*"
167
+
168
+ live.update(Markdown(display_text, code_theme=code_theme))
169
+ except KeyboardInterrupt:
170
+ live.update(Markdown(response_text + "\n\n*[yellow]Stream interrupted by user.[/yellow]*", code_theme=code_theme))
171
+ c.print("\n[yellow]Generation interrupted.[/yellow]")
172
+
173
+ # Approximate Token Tracker Update
174
+ est_tokens = len(response_text) // 4
175
+ if not hasattr(self, "session_tokens"):
176
+ self.session_tokens = 0
177
+ self.session_tokens += est_tokens
178
+
179
+ # Approximate cost based on 70B typical rates ($3/1M tokens)
180
+ cost = (self.session_tokens / 1000000) * 3.0
181
+ c.print(f"[dim]Output est. tokens: {est_tokens} | Session Cost: ~${cost:.4f}[/dim]")
182
+
183
+ # Context usage warning
184
+ import json
185
+ total_context_chars = sum(len(str(m.get("content", ""))) for m in self.messages)
186
+ total_est_tokens = total_context_chars // 4
187
+ max_context = 128000 # Assume standard Llama-3.1 128k context for now
188
+ if total_est_tokens > max_context * 0.8:
189
+ c.print("[bold yellow]⚠️ Context window is over 80% full. Consider running /compact or /clear.[/bold yellow]")
190
+
191
+ return response_text
192
+
193
+ async def run_headless(self, query: str, max_turns: int = 5) -> str:
194
+ """Runs the agent without terminal streaming, useful for background tasks."""
195
+ self.messages.append({"role": "user", "content": query})
196
+ turn = 0
197
+ from .tools import ToolRegistry
198
+ while turn < max_turns:
199
+ turn += 1
200
+ try:
201
+ response_text = await self.client.chat_one_shot(self.messages)
202
+ except Exception as e:
203
+ return f"Subagent error: {e}"
204
+
205
+ self.messages.append({"role": "assistant", "content": response_text})
206
+
207
+ tool_calls = self.parser.parse(response_text)
208
+ if not tool_calls:
209
+ return response_text # Task complete
210
+
211
+ for tool_call in tool_calls:
212
+ tool_name = tool_call.get("tool")
213
+ if not tool_name: continue
214
+ try:
215
+ result = ToolRegistry.execute(tool_call)
216
+ except Exception as e:
217
+ result = str(e)
218
+ self.messages.append({"role": "user", "content": f"Tool {tool_name} returned:\n{result}"})
219
+
220
+ return "Subagent reached max turns before completing."
221
+
222
+ async def _distill_memory(self) -> list:
223
+ system_msg = self.messages[0]
224
+ recent_msgs = self.messages[-4:] if len(self.messages) > 4 else self.messages[1:]
225
+ old_msgs = self.messages[1:-4] if len(self.messages) > 4 else []
226
+
227
+ if not old_msgs:
228
+ return self.messages
229
+
230
+ summary_prompt = "Please summarize the following conversation concisely. Focus on the final state, any completed goals, and any context needed for the next steps:\n\n"
231
+ for m in old_msgs:
232
+ summary_prompt += f"{m['role'].upper()}: {str(m['content'])[:500]}...\n"
233
+
234
+ messages = [{"role": "system", "content": "You are a summarization AI."}, {"role": "user", "content": summary_prompt}]
235
+ try:
236
+ summary = await self.client.chat_one_shot(messages)
237
+ system_msg["content"] += f"\n\n[PREVIOUS MEMORY SUMMARY]\n{summary}"
238
+ except Exception as e:
239
+ logger.error(f"Distillation failed: {e}")
240
+
241
+ return [system_msg] + recent_msgs
242
+
243
+ async def run(self, initial_prompt: str = None) -> None:
244
+ """Main execution loop for NimCode agent."""
245
+ if hasattr(self.mcp, "connect_all"):
246
+ await self.mcp.connect_all()
247
+
248
+ if initial_prompt:
249
+ self.messages.append({"role": "user", "content": initial_prompt})
250
+
251
+ cwd = os.getcwd()
252
+ turn = 0
253
+ while turn < self.max_turns:
254
+ turn += 1
255
+ logger.info(f"--- Turn {turn} ---")
256
+
257
+ # Compact context before calling API
258
+ from rich.console import Console
259
+ if self.memory.count_messages_tokens(self.messages) > self.memory.max_tokens:
260
+ logger.info("Context full. Distilling memory via LLM...")
261
+ Console().print("[dim italic]🧠 Context full. Distilling memory into a summary to save tokens...[/dim italic]")
262
+ self.messages = await self._distill_memory()
263
+
264
+ # We don't pass tools to the native API for fallback mode.
265
+ # We expect the model to output <tool_call> block in the content.
266
+ full_content = ""
267
+ try:
268
+ full_content = await self._stream_response()
269
+ except Exception as e:
270
+ logger.error(f"Error calling NIM API: {e}")
271
+ break
272
+
273
+ self.messages.append({"role": "assistant", "content": full_content})
274
+
275
+ # Log turn to NIMCODE.md
276
+ try:
277
+ # We log the user's latest prompt, or the tool output
278
+ last_user_msg = self.messages[-2].get("content", "") if len(self.messages) > 1 else ""
279
+ MemoryManager.log_to_nimcode_md(turn, last_user_msg, full_content, cwd)
280
+ except Exception as e:
281
+ logger.error(f"Failed to log to NIMCODE.md: {e}")
282
+
283
+ if "TASK_COMPLETE" in full_content:
284
+ logger.info("Agent finished task.")
285
+ break
286
+
287
+ try:
288
+ prose, tool_calls = LenientParser.process_model_response(full_content)
289
+
290
+ if not tool_calls:
291
+ # Model responded with plain text but didn't say TASK_COMPLETE.
292
+ self.messages.append({"role": "user", "content": "Please continue. Use a tool or output TASK_COMPLETE."})
293
+ continue
294
+
295
+ # We execute the first tool call (ignoring others if it hallucinated multiple)
296
+ tool_call = tool_calls[0]
297
+ tool_name = tool_call.get("tool", "Unknown")
298
+
299
+ logger.info(f"Checking permissions for tool: {tool_name}")
300
+ if not self.permission_engine.check_permission(tool_call):
301
+ self.messages.append({
302
+ "role": "user",
303
+ "content": f"User explicitly denied permission to execute {tool_name}. Please choose another approach."
304
+ })
305
+ continue
306
+
307
+ logger.info(f"Running tool: {tool_name}")
308
+ from rich.console import Console
309
+ c = Console()
310
+ try:
311
+ with c.status(f"[bold cyan]⚙️ Executing {tool_name}...[/bold cyan]", spinner="bouncingBar"):
312
+ if not ToolRegistry.get_tool_schema(tool_name):
313
+ # Attempt MCP
314
+ try:
315
+ mcp_result = await self.mcp.call_tool_by_name(tool_name, tool_call.get("args", {}))
316
+ # mcp_result.content is a list of CallToolResult objects
317
+ result = "\n".join([str(item.text) for item in getattr(mcp_result, 'content', []) if hasattr(item, 'text')])
318
+ if not result:
319
+ result = str(mcp_result)
320
+ except Exception as e:
321
+ result = f"Error executing MCP tool {tool_name}: {e}"
322
+ else:
323
+ result = ToolRegistry.execute(tool_call)
324
+
325
+ # Auto-Linting & Diff Printing
326
+ if tool_name in ["Write", "Edit"] and "Error" not in result:
327
+ if "Diff:\n" in result:
328
+ parts = result.split("Diff:\n", 1)
329
+ diff_text = parts[1]
330
+ from rich.syntax import Syntax
331
+ c.print(Syntax(diff_text, "diff", theme="monokai", background_color="default"))
332
+
333
+ file_path = tool_call.get("args", {}).get("file_path", "")
334
+ if file_path.endswith(".py"):
335
+ import subprocess
336
+ subprocess.run(["black", "-q", file_path], capture_output=True)
337
+ lint = subprocess.run(["flake8", "--select=E9,F821,F822,F823", file_path], capture_output=True, text=True)
338
+ if lint.returncode != 0:
339
+ import sys
340
+ if sys.stdin.isatty():
341
+ from rich.prompt import Confirm
342
+ c.print(f"\n[bold yellow]Linter found errors in {file_path}:[/bold yellow]\n{lint.stdout}")
343
+ if Confirm.ask("Would you like to auto-fix these errors?"):
344
+ result += f"\n\nAuto-Linter found errors:\n{lint.stdout}\nPlease fix them."
345
+ c.print(f"[bold red]Auto-fixing...[/bold red]")
346
+ else:
347
+ c.print("[dim]Ignoring linter errors.[/dim]")
348
+ else:
349
+ result += f"\n\nAuto-Linter found errors:\n{lint.stdout}\nPlease fix them."
350
+ elif file_path.endswith((".js", ".ts", ".jsx", ".tsx")):
351
+ import subprocess
352
+ subprocess.run(["npx", "prettier", "--write", file_path], capture_output=True)
353
+ except KeyboardInterrupt:
354
+ c.print("\n[yellow]Tool execution interrupted by user.[/yellow]")
355
+ result = "Tool execution was interrupted by the user via Ctrl+C. Ask the user what to do next."
356
+
357
+ # Print tool result preview for transparency
358
+ if tool_name not in ["Write", "Edit"] and result:
359
+ preview = str(result)
360
+
361
+ # Try to parse as JSON for Collapsible UI
362
+ import json
363
+ try:
364
+ parsed_json = json.loads(preview)
365
+ if isinstance(parsed_json, (dict, list)):
366
+ formatted_json = json.dumps(parsed_json, indent=2)
367
+ if len(formatted_json) > 800:
368
+ formatted_json = formatted_json[:800] + f"\n\n... [dim]({len(formatted_json)-800} chars omitted)[/dim]"
369
+ preview = formatted_json
370
+ except json.JSONDecodeError:
371
+ # Rich Table Rendering for Tabular Data
372
+ if ("\t" in preview or preview.count(",") > 5) and "\n" in preview:
373
+ from rich.table import Table
374
+ lines = preview.strip().split("\n")
375
+ if 2 <= len(lines) <= 50:
376
+ delim = "\t" if "\t" in lines[0] else ","
377
+ headers = lines[0].split(delim)
378
+ table = Table(*headers)
379
+ valid_table = True
380
+ for row in lines[1:]:
381
+ cols = row.split(delim)
382
+ if len(cols) == len(headers):
383
+ table.add_row(*cols)
384
+ else:
385
+ valid_table = False
386
+ break
387
+ if valid_table:
388
+ preview = table
389
+
390
+ if isinstance(preview, str) and len(preview) > 500:
391
+ preview = preview[:500] + f"\n\n[dim]... (truncated {len(preview) - 500} chars)[/dim]"
392
+
393
+ from rich.panel import Panel
394
+ c.print(Panel(preview, title=f"Output: {tool_name}", border_style="dim", padding=(0, 1)))
395
+
396
+ # We simulate tool messages by adding a user message with the tool result.
397
+ # In native mode this would be a "tool" role message, but for fallback mode,
398
+ # passing it as a user message makes it explicit.
399
+ self.messages.append({
400
+ "role": "user",
401
+ "content": f"Tool {tool_name} returned:\n{result}"
402
+ })
403
+
404
+ except Exception as e:
405
+ logger.error(f"Error parsing/executing tool: {e}")
406
+ self.messages.append({
407
+ "role": "user",
408
+ "content": f"Your tool call was malformed or failed: {e}. Please fix the JSON syntax and try again."
409
+ })
410
+
411
+ if turn >= self.max_turns:
412
+ logger.warning(f"Max turns ({self.max_turns}) reached.")
413
+
414
+ self.save_history()
415
+
@@ -93,8 +93,6 @@ def main():
93
93
  console.print("[bold red]API Key is required to use NimCode. Exiting.[/bold red]")
94
94
  sys.exit(1)
95
95
 
96
- console.print(f"[bold green]Starting NimCode[/bold green] with model [cyan]{args.model}[/cyan]")
97
-
98
96
  agent = Agent(
99
97
  api_key=final_key,
100
98
  model=args.model,
@@ -111,17 +109,22 @@ def main():
111
109
  piped_input = sys.stdin.read().strip()
112
110
 
113
111
  if piped_input:
112
+ console.print(f"[bold green]Starting NimCode[/bold green] with model [cyan]{args.model}[/cyan]")
114
113
  prompt = f"{piped_input}\n\n{args.prompt or ''}".strip()
115
114
  console.print(f"Task (with piped input): {prompt}")
116
115
  asyncio.run(agent.run(prompt))
117
116
  elif args.prompt:
117
+ console.print(f"[bold green]Starting NimCode[/bold green] with model [cyan]{args.model}[/cyan]")
118
118
  console.print(f"Task: {args.prompt}")
119
119
  asyncio.run(agent.run(args.prompt))
120
120
  else:
121
- console.print("[bold yellow]Entering Interactive REPL Mode[/bold yellow]. Type /exit to quit, /plan for planning mode, /code for coding mode.")
122
- asyncio.run(agent.start_repl())
121
+ from .repl import NimcodeREPL
122
+ repl = NimcodeREPL(agent)
123
+ asyncio.run(repl.start_repl())
123
124
 
124
- console.print("[bold green]Done![/bold green]")
125
+ # We don't print "Done!" for REPL to keep it clean on exit
126
+ if args.prompt or piped_input:
127
+ console.print("[bold green]Done![/bold green]")
125
128
 
126
129
  if __name__ == "__main__":
127
130
  main()
@@ -40,21 +40,27 @@ class LenientParser:
40
40
  repaired = re.sub(r",\s*]", "]", repaired)
41
41
 
42
42
  # 2. Fix unescaped newlines within strings.
43
- # A simple approach: we find things that look like string literals and escape newlines.
44
- # We'll use a state machine or regex for simple cases.
45
- # For a robust approach without a full parser, we replace literal newlines that are inside quotes.
46
- # Actually, python's json.loads is quite strict about unescaped newlines.
47
43
  def escape_newlines_in_strings(match):
48
44
  return match.group(0).replace("\n", "\\n")
49
45
 
50
- # Match string literals: " followed by anything except unescaped quote, followed by "
51
- # We need to be careful with escaped quotes inside the string.
52
46
  string_regex = re.compile(r'"(?:[^"\\]|\\.)*"', re.DOTALL)
53
47
  repaired = string_regex.sub(escape_newlines_in_strings, repaired)
54
48
 
55
- # 3. Very rudimentary single quote to double quote conversion for keys/values
56
- # (Only if it fails standard parsing, but we can do it proactively for top-level if needed).
57
- # We will try standard json.loads first, and if it fails, maybe do more aggressive repair.
49
+ # 3. Handle truncated JSON outputs (e.g. model stream cut off)
50
+ # If it seems like an unclosed string, close it.
51
+ open_quotes = len(re.findall(r'(?<!\\)"', repaired))
52
+ if open_quotes % 2 != 0:
53
+ repaired += '"'
54
+
55
+ # Count open brackets and braces to auto-close them
56
+ open_braces = repaired.count("{") - repaired.count("}")
57
+ open_brackets = repaired.count("[") - repaired.count("]")
58
+
59
+ if open_brackets > 0:
60
+ repaired += "]" * open_brackets
61
+ if open_braces > 0:
62
+ repaired += "}" * open_braces
63
+
58
64
  return repaired
59
65
 
60
66
  @classmethod
@@ -105,8 +105,12 @@ class NimClient:
105
105
  except json.JSONDecodeError:
106
106
  pass
107
107
  except httpx.HTTPStatusError as e:
108
- await e.response.aread()
109
- logger.error(f"API HTTP error: {e.response.status_code} - {e.response.text}")
108
+ try:
109
+ await e.response.aread()
110
+ text = e.response.text
111
+ except Exception:
112
+ text = "<unread stream>"
113
+ logger.error(f"API HTTP error: {e.response.status_code} - {text}")
110
114
  yield f"\\n\\n[Error: Model API returned {e.response.status_code}. Please check your NVIDIA API key.]"
111
115
  except Exception as e:
112
116
  logger.error(f"API connection error: {e}")