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.
- {nimcode-0.1.0 → nimcode-0.1.4}/PKG-INFO +12 -12
- nimcode-0.1.4/README.md +68 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/setup.cfg +4 -4
- {nimcode-0.1.0 → nimcode-0.1.4}/setup.py +1 -1
- nimcode-0.1.4/src/nimcode/agent.py +415 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/cli.py +8 -5
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/lenient_parser.py +15 -9
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/nim_client.py +6 -2
- nimcode-0.1.4/src/nimcode/permissions.py +124 -0
- nimcode-0.1.4/src/nimcode/repl.py +963 -0
- nimcode-0.1.4/src/nimcode/tools.py +672 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/PKG-INFO +12 -12
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/SOURCES.txt +4 -0
- nimcode-0.1.4/tests/test_agent.py +300 -0
- nimcode-0.1.4/tests/test_cli.py +112 -0
- nimcode-0.1.4/tests/test_config.py +67 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_lenient_parser.py +8 -0
- nimcode-0.1.4/tests/test_mcp_client.py +135 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_memory.py +6 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_nim_client.py +45 -37
- nimcode-0.1.4/tests/test_permissions.py +76 -0
- nimcode-0.1.4/tests/test_repl.py +387 -0
- nimcode-0.1.4/tests/test_repl_extra.py +136 -0
- nimcode-0.1.4/tests/test_repl_trust.py +55 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/tests/test_tools.py +135 -0
- nimcode-0.1.0/README.md +0 -47
- nimcode-0.1.0/src/nimcode/agent.py +0 -582
- nimcode-0.1.0/src/nimcode/permissions.py +0 -78
- nimcode-0.1.0/src/nimcode/tools.py +0 -323
- nimcode-0.1.0/tests/test_agent.py +0 -135
- nimcode-0.1.0/tests/test_cli.py +0 -29
- nimcode-0.1.0/tests/test_config.py +0 -32
- nimcode-0.1.0/tests/test_mcp_client.py +0 -24
- nimcode-0.1.0/tests/test_permissions.py +0 -48
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/__init__.py +0 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/config.py +0 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/mcp_client.py +0 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode/memory.py +0 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/dependency_links.txt +0 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/entry_points.txt +0 -0
- {nimcode-0.1.0 → nimcode-0.1.4}/src/nimcode.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
nimcode-0.1.4/README.md
ADDED
|
@@ -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
|
+
[](https://badge.fury.io/py/nimcode)
|
|
8
|
+
[](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
|
+
|
|
@@ -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
|
-
|
|
122
|
-
|
|
121
|
+
from .repl import NimcodeREPL
|
|
122
|
+
repl = NimcodeREPL(agent)
|
|
123
|
+
asyncio.run(repl.start_repl())
|
|
123
124
|
|
|
124
|
-
|
|
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.
|
|
56
|
-
#
|
|
57
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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}")
|