nimcode 0.3.3__tar.gz → 0.4.0__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.3.3 → nimcode-0.4.0}/PKG-INFO +1 -1
- {nimcode-0.3.3 → nimcode-0.4.0}/README.md +34 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/setup.py +7 -1
- nimcode-0.4.0/src/nimcode/__version__.py +1 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/agent.py +121 -32
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/config.py +15 -1
- nimcode-0.4.0/src/nimcode/model_registry.py +54 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/nim_client.py +30 -14
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/permissions.py +12 -2
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/repl.py +22 -9
- nimcode-0.4.0/src/nimcode/repo_map.py +97 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/tools.py +10 -3
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/updater.py +7 -2
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/PKG-INFO +1 -1
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/SOURCES.txt +7 -1
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_config.py +10 -0
- nimcode-0.4.0/tests/test_model_registry.py +38 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_permissions.py +4 -2
- nimcode-0.4.0/tests/test_repo_map.py +93 -0
- nimcode-0.4.0/tests/test_version.py +24 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/setup.cfg +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/__init__.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/cli.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/lenient_parser.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/mcp_client.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/memory.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/plugin_manager.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/rag.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/watcher.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/dependency_links.txt +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/entry_points.txt +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/requires.txt +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/top_level.txt +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_agent.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_cli.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_lenient_parser.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_mcp_client.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_memory.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_nim_client.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_plugin_manager.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_repl.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_repl_extra.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_repl_trust.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_tools.py +0 -0
- {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_updater.py +0 -0
|
@@ -121,6 +121,40 @@ NimCode creates a `.nimcode` directory inside your projects. This directory acts
|
|
|
121
121
|
- `.nimcode/skills/`: Any custom guidelines, framework rules, or memories you teach the agent.
|
|
122
122
|
- `.nimcode/history/`: File backups for `/undo` capabilities.
|
|
123
123
|
|
|
124
|
+
### Advanced Configuration
|
|
125
|
+
|
|
126
|
+
NimCode's behavior can be fully customized through `~/.nimcode/settings.json` (global) or `.nimcode/settings.json` (per-project). Setting any timeout to `0` disables it (infinite).
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"timeout_command": 1200,
|
|
131
|
+
"timeout_llm": 120,
|
|
132
|
+
"timeout_format": 10,
|
|
133
|
+
"timeout_browser": 15000,
|
|
134
|
+
"timeout_updater": 3,
|
|
135
|
+
"max_turns": 200,
|
|
136
|
+
"max_tokens": 120000,
|
|
137
|
+
"max_retries": 15,
|
|
138
|
+
"retry_base_delay": 2.0,
|
|
139
|
+
"retry_max_delay": 60.0,
|
|
140
|
+
"allow_bash_non_interactive": false
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Setting | Default | Description |
|
|
145
|
+
|---------|---------|-------------|
|
|
146
|
+
| `timeout_command` | 1200 | Max seconds for bash commands (0 = infinite) |
|
|
147
|
+
| `timeout_llm` | 120 | Max seconds for LLM API calls (0 = infinite) |
|
|
148
|
+
| `timeout_format` | 10 | Max seconds for formatters like black/prettier |
|
|
149
|
+
| `timeout_browser` | 15000 | Max ms for browser actions |
|
|
150
|
+
| `timeout_updater` | 3 | Max seconds for update checks |
|
|
151
|
+
| `max_turns` | 200 | Max agent turns per session (0 = unlimited) |
|
|
152
|
+
| `max_tokens` | 120000 | Token budget before auto-compact |
|
|
153
|
+
| `max_retries` | 15 | Max API retry attempts on transient errors |
|
|
154
|
+
| `retry_base_delay` | 2.0 | Base delay for exponential backoff (seconds) |
|
|
155
|
+
| `retry_max_delay` | 60.0 | Max delay cap for backoff (seconds) |
|
|
156
|
+
| `allow_bash_non_interactive` | false | Allow Bash commands in non-interactive/CI mode |
|
|
157
|
+
|
|
124
158
|
## 🛡️ Requirements
|
|
125
159
|
|
|
126
160
|
- Python 3.8+
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
from setuptools import setup, find_packages
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
# Single source of truth for version
|
|
5
|
+
version = {}
|
|
6
|
+
with open(os.path.join("src", "nimcode", "__version__.py")) as f:
|
|
7
|
+
exec(f.read(), version)
|
|
2
8
|
|
|
3
9
|
setup(
|
|
4
10
|
name="nimcode",
|
|
5
|
-
version="
|
|
11
|
+
version=version["__version__"],
|
|
6
12
|
packages=find_packages(where="src"),
|
|
7
13
|
package_dir={"": "src"},
|
|
8
14
|
install_requires=[
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -11,8 +11,13 @@ from .memory import MemoryManager
|
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
import platform
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT_TEMPLATE = """You are nimcode, an autonomous AI coding assistant. You are a senior-level developer who writes production-ready code.
|
|
17
|
+
|
|
18
|
+
OPERATING SYSTEM: {os_name}
|
|
19
|
+
SHELL: {shell_info}
|
|
20
|
+
WORKING DIRECTORY: {cwd}
|
|
16
21
|
|
|
17
22
|
CRITICAL INSTRUCTION FOR TOOL CALLING:
|
|
18
23
|
You must output exactly ONE tool call per turn, formatted exactly as a fenced XML block.
|
|
@@ -20,49 +25,120 @@ DO NOT use Markdown code blocks for the tool call. DO NOT output multiple tool c
|
|
|
20
25
|
|
|
21
26
|
Format:
|
|
22
27
|
<tool_call>
|
|
23
|
-
{"tool": "ToolName", "args": {"arg1": "val1"}}
|
|
28
|
+
{{"tool": "ToolName", "args": {{"arg1": "val1"}}}}
|
|
24
29
|
</tool_call>
|
|
25
30
|
|
|
26
31
|
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.
|
|
35
|
-
For example:
|
|
32
|
+
- Bash: {{"tool": "Bash", "args": {{"command": "string"}}}}
|
|
33
|
+
- Read: {{"tool": "Read", "args": {{"file_path": "string"}}}}
|
|
34
|
+
- Write: {{"tool": "Write", "args": {{"file_path": "string", "content": "string"}}}}
|
|
35
|
+
- Edit: {{"tool": "Edit", "args": {{"file_path": "string", "old_string": "string", "new_string": "string"}}}}
|
|
36
|
+
- Glob: {{"tool": "Glob", "args": {{"pattern": "string"}}}}
|
|
37
|
+
- Grep: {{"tool": "Grep", "args": {{"query": "string", "directory": "string"}}}}
|
|
38
|
+
|
|
39
|
+
Before calling a tool, you may optionally use a <think> block to reason about your plan.
|
|
36
40
|
<think>
|
|
37
41
|
I need to check the file contents first to see where the function is defined.
|
|
38
42
|
</think>
|
|
39
43
|
<tool_call>
|
|
40
|
-
{"tool": "Read", "args": {"file_path": "main.py"}}
|
|
44
|
+
{{"tool": "Read", "args": {{"file_path": "main.py"}}}}
|
|
41
45
|
</tool_call>
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
47
|
+
WORKSPACE STRUCTURE:
|
|
48
|
+
- The `.nimcode/` directory is YOUR workspace for plans, skills, and logs. It is NOT where user project code lives.
|
|
49
|
+
- `.nimcode/plans/` — Store step-by-step markdown plans here.
|
|
50
|
+
- `.nimcode/skills/` — Store custom guidelines, framework rules, or memories as markdown files.
|
|
51
|
+
- `.nimcode/history/` — File backups for /undo capabilities.
|
|
52
|
+
- User's project code lives in the WORKING DIRECTORY root and its subdirectories.
|
|
53
|
+
- NEVER write user's source code files inside `.nimcode/`. That directory is only for your internal data.
|
|
54
|
+
- When the user asks you to create a file like `main.py`, `app.js`, etc., write it to the WORKING DIRECTORY, not `.nimcode/`.
|
|
55
|
+
|
|
56
|
+
{os_specific_instructions}
|
|
57
|
+
|
|
58
|
+
PLANNING MODE INSTRUCTIONS:
|
|
59
|
+
When in /plan mode or asked to create a plan:
|
|
60
|
+
1. FIRST: Read the project structure using Glob and Read tools. Understand what already exists.
|
|
61
|
+
2. SECOND: Analyze the codebase — read key files, understand the architecture, dependencies, and patterns.
|
|
62
|
+
3. THIRD: Write a DETAILED, ACTIONABLE plan that references actual files and code in the project.
|
|
63
|
+
4. Your plan must include:
|
|
64
|
+
- Specific files to create/modify (with full paths)
|
|
65
|
+
- Actual code snippets or pseudocode for each change
|
|
66
|
+
- Dependencies to install
|
|
67
|
+
- Testing strategy
|
|
68
|
+
- Step-by-step execution order
|
|
69
|
+
5. NEVER write generic project management templates (stakeholders, timelines, resources).
|
|
70
|
+
6. NEVER use placeholders like [Insert Date] or [Insert Dependencies].
|
|
71
|
+
7. A good plan is one that another developer could follow to implement the feature without asking questions.
|
|
72
|
+
|
|
73
|
+
CODE EXECUTION INSTRUCTIONS:
|
|
74
|
+
When asked to "execute", "implement", or "build" something:
|
|
75
|
+
1. Write REAL, WORKING code — not echo statements or placeholder scripts.
|
|
76
|
+
2. Create actual source files with production-quality code.
|
|
77
|
+
3. Install dependencies, run tests, and verify your work.
|
|
78
|
+
4. If a plan exists in `.nimcode/plans/`, follow it step by step.
|
|
46
79
|
|
|
47
80
|
When you have completely fulfilled the user's request and have no more tools to run, output the word TASK_COMPLETE.
|
|
48
81
|
"""
|
|
49
82
|
|
|
83
|
+
OS_INSTRUCTIONS_WINDOWS = """WINDOWS-SPECIFIC RULES:
|
|
84
|
+
- Use backslash (\\) for file paths in Bash commands, or use forward slash (/) which also works on Windows.
|
|
85
|
+
- Use PowerShell or cmd syntax: `mkdir .nimcode\\plans` (NOT `mkdir -p .nimcode/plans`).
|
|
86
|
+
- Use `type` instead of `cat`, `dir` instead of `ls`, `copy` instead of `cp`.
|
|
87
|
+
- Use `python` instead of `python3`.
|
|
88
|
+
- For creating nested directories, use: `mkdir .nimcode\\plans 2>nul` or `New-Item -ItemType Directory -Force -Path .nimcode\\plans`
|
|
89
|
+
- DO NOT use Unix-only commands like chmod, grep (use findstr), sed, awk.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
OS_INSTRUCTIONS_UNIX = """UNIX-SPECIFIC RULES:
|
|
93
|
+
- Use forward slash (/) for file paths.
|
|
94
|
+
- Use standard Unix commands: mkdir -p, cat, ls, cp, chmod.
|
|
95
|
+
- Use `python3` if `python` is not available.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_system_prompt(cwd: str) -> str:
|
|
100
|
+
"""Builds the system prompt with OS-aware instructions."""
|
|
101
|
+
os_name = platform.system()
|
|
102
|
+
if os_name == "Windows":
|
|
103
|
+
shell_info = "PowerShell / cmd.exe"
|
|
104
|
+
os_specific = OS_INSTRUCTIONS_WINDOWS
|
|
105
|
+
else:
|
|
106
|
+
shell_info = "bash / zsh"
|
|
107
|
+
os_specific = OS_INSTRUCTIONS_UNIX
|
|
108
|
+
|
|
109
|
+
return SYSTEM_PROMPT_TEMPLATE.format(
|
|
110
|
+
os_name=os_name,
|
|
111
|
+
shell_info=shell_info,
|
|
112
|
+
cwd=cwd,
|
|
113
|
+
os_specific_instructions=os_specific,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
50
117
|
class Agent:
|
|
51
|
-
def __init__(self, api_key: str, model: str = None, max_turns: int =
|
|
118
|
+
def __init__(self, api_key: str, model: str = None, max_turns: int = None, permission_mode: PermissionMode = PermissionMode.DEFAULT, max_tokens: int = None):
|
|
52
119
|
# Load global settings
|
|
53
120
|
self.settings = load_settings()
|
|
54
121
|
self.model = model or self.settings.get("model", "meta/llama-3.1-70b-instruct")
|
|
55
122
|
api_base_url = self.settings.get("api_base_url", "https://integrate.api.nvidia.com/v1")
|
|
56
|
-
self.client = NimClient(
|
|
123
|
+
self.client = NimClient(
|
|
124
|
+
api_key=api_key,
|
|
125
|
+
base_url=api_base_url,
|
|
126
|
+
model=self.model,
|
|
127
|
+
timeout=self.settings.get("timeout_llm", 120.0),
|
|
128
|
+
max_retries=self.settings.get("max_retries", 15),
|
|
129
|
+
retry_base_delay=self.settings.get("retry_base_delay", 2.0),
|
|
130
|
+
retry_max_delay=self.settings.get("retry_max_delay", 60.0),
|
|
131
|
+
)
|
|
57
132
|
|
|
58
133
|
# Initialize MCP Manager
|
|
59
134
|
self.mcp = MCPManager(self.settings)
|
|
60
135
|
|
|
61
|
-
#
|
|
62
|
-
|
|
136
|
+
# Build OS-aware system prompt
|
|
137
|
+
cwd = os.getcwd()
|
|
138
|
+
final_prompt = _build_system_prompt(cwd) + self.mcp.get_system_prompt_additions()
|
|
63
139
|
|
|
64
140
|
# Load skills if present
|
|
65
|
-
skills_dir = os.path.join(
|
|
141
|
+
skills_dir = os.path.join(cwd, ".nimcode", "skills")
|
|
66
142
|
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
|
67
143
|
loaded_skills = []
|
|
68
144
|
for filename in os.listdir(skills_dir):
|
|
@@ -86,9 +162,16 @@ class Agent:
|
|
|
86
162
|
self.messages: List[Dict[str, Any]] = [
|
|
87
163
|
{"role": "system", "content": final_prompt}
|
|
88
164
|
]
|
|
89
|
-
|
|
165
|
+
|
|
166
|
+
# Use config values with parameter overrides
|
|
167
|
+
cfg_max_turns = self.settings.get("max_turns", 200)
|
|
168
|
+
self.max_turns = max_turns if max_turns is not None else (0 if cfg_max_turns == 0 else cfg_max_turns)
|
|
169
|
+
|
|
170
|
+
cfg_max_tokens = self.settings.get("max_tokens", 120000)
|
|
171
|
+
effective_max_tokens = max_tokens if max_tokens is not None else cfg_max_tokens
|
|
172
|
+
self.memory = MemoryManager(max_tokens=effective_max_tokens)
|
|
173
|
+
|
|
90
174
|
self.permission_engine = PermissionEngine(mode=permission_mode)
|
|
91
|
-
self.memory = MemoryManager(max_tokens=max_tokens)
|
|
92
175
|
|
|
93
176
|
def save_history(self):
|
|
94
177
|
"""Saves current conversation to NIMCODE.md"""
|
|
@@ -185,7 +268,8 @@ class Agent:
|
|
|
185
268
|
import json
|
|
186
269
|
total_context_chars = sum(len(str(m.get("content", ""))) for m in self.messages)
|
|
187
270
|
total_est_tokens = total_context_chars // 4
|
|
188
|
-
|
|
271
|
+
from .model_registry import get_context_window
|
|
272
|
+
max_context = get_context_window(self.model)
|
|
189
273
|
if total_est_tokens > max_context * 0.8:
|
|
190
274
|
c.print("[bold yellow]⚠️ Context window is over 80% full. Consider running /compact or /clear.[/bold yellow]")
|
|
191
275
|
|
|
@@ -199,7 +283,9 @@ class Agent:
|
|
|
199
283
|
while turn < max_turns:
|
|
200
284
|
turn += 1
|
|
201
285
|
try:
|
|
202
|
-
|
|
286
|
+
# Build a combined prompt from messages for one-shot call
|
|
287
|
+
combined = "\n".join(f"{m['role'].upper()}: {m.get('content','')}" for m in self.messages)
|
|
288
|
+
response_text = await self.client.chat_one_shot(combined)
|
|
203
289
|
except Exception as e:
|
|
204
290
|
return f"Subagent error: {e}"
|
|
205
291
|
|
|
@@ -228,13 +314,12 @@ class Agent:
|
|
|
228
314
|
if not old_msgs:
|
|
229
315
|
return self.messages
|
|
230
316
|
|
|
231
|
-
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"
|
|
317
|
+
summary_prompt = "You are a summarization AI. Please summarize the following conversation concisely. Focus on the final state, any completed goals, and any context needed for the next steps:\n\n"
|
|
232
318
|
for m in old_msgs:
|
|
233
319
|
summary_prompt += f"{m['role'].upper()}: {str(m['content'])[:500]}...\n"
|
|
234
320
|
|
|
235
|
-
messages = [{"role": "system", "content": "You are a summarization AI."}, {"role": "user", "content": summary_prompt}]
|
|
236
321
|
try:
|
|
237
|
-
summary = await self.client.chat_one_shot(
|
|
322
|
+
summary = await self.client.chat_one_shot(summary_prompt)
|
|
238
323
|
system_msg["content"] += f"\n\n[PREVIOUS MEMORY SUMMARY]\n{summary}"
|
|
239
324
|
except Exception as e:
|
|
240
325
|
logger.error(f"Distillation failed: {e}")
|
|
@@ -251,7 +336,7 @@ class Agent:
|
|
|
251
336
|
|
|
252
337
|
cwd = os.getcwd()
|
|
253
338
|
turn = 0
|
|
254
|
-
while turn < self.max_turns:
|
|
339
|
+
while self.max_turns == 0 or turn < self.max_turns:
|
|
255
340
|
turn += 1
|
|
256
341
|
logger.info(f"--- Turn {turn} ---")
|
|
257
342
|
|
|
@@ -338,8 +423,10 @@ class Agent:
|
|
|
338
423
|
if file_path.endswith(".py"):
|
|
339
424
|
import subprocess
|
|
340
425
|
try:
|
|
341
|
-
|
|
342
|
-
|
|
426
|
+
t_fmt = self.settings.get("timeout_format", 10)
|
|
427
|
+
t_fmt = None if t_fmt == 0 else t_fmt
|
|
428
|
+
subprocess.run(["black", "-q", file_path], capture_output=True, timeout=t_fmt)
|
|
429
|
+
lint = subprocess.run(["flake8", "--select=E9,F821,F822,F823", file_path], capture_output=True, text=True, timeout=t_fmt)
|
|
343
430
|
if lint.returncode != 0:
|
|
344
431
|
import sys
|
|
345
432
|
if sys.stdin.isatty():
|
|
@@ -357,7 +444,9 @@ class Agent:
|
|
|
357
444
|
elif file_path.endswith((".js", ".ts", ".jsx", ".tsx")):
|
|
358
445
|
import subprocess
|
|
359
446
|
try:
|
|
360
|
-
|
|
447
|
+
t_fmt = self.settings.get("timeout_format", 10)
|
|
448
|
+
t_fmt = None if t_fmt == 0 else t_fmt
|
|
449
|
+
subprocess.run(["npx", "--yes", "prettier", "--write", file_path], capture_output=True, timeout=t_fmt)
|
|
361
450
|
except Exception:
|
|
362
451
|
pass
|
|
363
452
|
except KeyboardInterrupt:
|
|
@@ -10,7 +10,21 @@ def load_settings() -> Dict[str, Any]:
|
|
|
10
10
|
settings = {
|
|
11
11
|
"model": "meta/llama-3.1-70b-instruct",
|
|
12
12
|
"api_base_url": "https://integrate.api.nvidia.com/v1",
|
|
13
|
-
"mcp_servers": {}
|
|
13
|
+
"mcp_servers": {},
|
|
14
|
+
# Timeouts (0 = disabled/infinite)
|
|
15
|
+
"timeout_command": 1200, # Timeout for bash commands (seconds)
|
|
16
|
+
"timeout_llm": 120, # Timeout for LLM API calls (seconds)
|
|
17
|
+
"timeout_format": 10, # Timeout for formatting/linting tools (seconds)
|
|
18
|
+
"timeout_browser": 15000, # Timeout for browser actions (milliseconds)
|
|
19
|
+
"timeout_updater": 3, # Timeout for update checks (seconds)
|
|
20
|
+
# Agent behavior
|
|
21
|
+
"max_turns": 200, # Max agent turns per session (0 = unlimited)
|
|
22
|
+
"max_tokens": 120000, # Token budget before auto-compact kicks in
|
|
23
|
+
"max_retries": 15, # Max LLM API retry attempts on transient errors
|
|
24
|
+
"retry_base_delay": 2.0, # Base delay for exponential backoff (seconds)
|
|
25
|
+
"retry_max_delay": 60.0, # Max delay cap for exponential backoff (seconds)
|
|
26
|
+
# Security
|
|
27
|
+
"allow_bash_non_interactive": False, # Allow Bash in non-interactive (CI) mode
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
# Global settings
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model registry with context window sizes and metadata.
|
|
3
|
+
Used for auto-compact thresholds and dynamic model listing.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
# Known NVIDIA NIM models and their context window sizes (in tokens)
|
|
10
|
+
# This acts as a fallback + enrichment layer when API is unavailable
|
|
11
|
+
MODEL_CONTEXT_WINDOWS = {
|
|
12
|
+
# Llama 3.1 family
|
|
13
|
+
"meta/llama-3.1-8b-instruct": 128000,
|
|
14
|
+
"meta/llama-3.1-70b-instruct": 128000,
|
|
15
|
+
"meta/llama-3.1-405b-instruct": 128000,
|
|
16
|
+
# Llama 3.2 family
|
|
17
|
+
"meta/llama-3.2-1b-instruct": 128000,
|
|
18
|
+
"meta/llama-3.2-3b-instruct": 128000,
|
|
19
|
+
"meta/llama-3.2-11b-vision-instruct": 128000,
|
|
20
|
+
"meta/llama-3.2-90b-vision-instruct": 128000,
|
|
21
|
+
# Llama 3.3 family
|
|
22
|
+
"meta/llama-3.3-70b-instruct": 128000,
|
|
23
|
+
# Nemotron
|
|
24
|
+
"nvidia/nemotron-4-340b-instruct": 4096,
|
|
25
|
+
"nvidia/llama-3.1-nemotron-70b-instruct": 128000,
|
|
26
|
+
# Mistral
|
|
27
|
+
"mistralai/mixtral-8x22b-instruct-v0.1": 65536,
|
|
28
|
+
"mistralai/mistral-large-2-instruct": 128000,
|
|
29
|
+
# DeepSeek
|
|
30
|
+
"deepseek-ai/deepseek-r1": 128000,
|
|
31
|
+
# Qwen
|
|
32
|
+
"qwen/qwen2.5-72b-instruct": 128000,
|
|
33
|
+
"qwen/qwq-32b": 128000,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Default context window for unknown models
|
|
37
|
+
DEFAULT_CONTEXT_WINDOW = 8192
|
|
38
|
+
|
|
39
|
+
# Hardcoded fallback model list (used when API is unreachable)
|
|
40
|
+
FALLBACK_MODELS = [
|
|
41
|
+
"meta/llama-3.1-70b-instruct",
|
|
42
|
+
"meta/llama-3.1-8b-instruct",
|
|
43
|
+
"meta/llama-3.1-405b-instruct",
|
|
44
|
+
"meta/llama-3.3-70b-instruct",
|
|
45
|
+
"nvidia/llama-3.1-nemotron-70b-instruct",
|
|
46
|
+
"deepseek-ai/deepseek-r1",
|
|
47
|
+
"mistralai/mixtral-8x22b-instruct-v0.1",
|
|
48
|
+
"qwen/qwen2.5-72b-instruct",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_context_window(model_id: str) -> int:
|
|
53
|
+
"""Returns the context window size for a given model ID."""
|
|
54
|
+
return MODEL_CONTEXT_WINDOWS.get(model_id, DEFAULT_CONTEXT_WINDOW)
|
|
@@ -8,10 +8,14 @@ from typing import List, Dict, Any, Optional, AsyncGenerator
|
|
|
8
8
|
logger = logging.getLogger(__name__)
|
|
9
9
|
|
|
10
10
|
class NimClient:
|
|
11
|
-
def __init__(self, api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", model: str = "meta/llama-3.1-70b-instruct"):
|
|
11
|
+
def __init__(self, api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", model: str = "meta/llama-3.1-70b-instruct", timeout: float = 120.0, max_retries: int = 15, retry_base_delay: float = 2.0, retry_max_delay: float = 60.0):
|
|
12
12
|
self.api_key = api_key
|
|
13
13
|
self.base_url = base_url.rstrip("/")
|
|
14
14
|
self.model = model
|
|
15
|
+
self.timeout = None if timeout == 0 else timeout
|
|
16
|
+
self.max_retries = max_retries
|
|
17
|
+
self.retry_base_delay = retry_base_delay
|
|
18
|
+
self.retry_max_delay = retry_max_delay
|
|
15
19
|
self.headers = {
|
|
16
20
|
"Authorization": f"Bearer {self.api_key}",
|
|
17
21
|
"Content-Type": "application/json",
|
|
@@ -19,13 +23,25 @@ class NimClient:
|
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
async def get_available_models(self) -> List[str]:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
"""Fetches available models from the NIM API. Falls back to known models on error."""
|
|
27
|
+
try:
|
|
28
|
+
async with httpx.AsyncClient() as client:
|
|
29
|
+
response = await client.get(
|
|
30
|
+
f"{self.base_url}/models",
|
|
31
|
+
headers=self.headers,
|
|
32
|
+
timeout=10.0
|
|
33
|
+
)
|
|
34
|
+
if response.status_code == 200:
|
|
35
|
+
data = response.json()
|
|
36
|
+
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
|
37
|
+
if models:
|
|
38
|
+
return sorted(models)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logger.debug(f"Failed to fetch models from API: {e}")
|
|
41
|
+
|
|
42
|
+
# Fallback to known model list
|
|
43
|
+
from .model_registry import FALLBACK_MODELS
|
|
44
|
+
return FALLBACK_MODELS
|
|
29
45
|
|
|
30
46
|
async def chat_one_shot(self, prompt: str) -> str:
|
|
31
47
|
payload = {
|
|
@@ -39,7 +55,7 @@ class NimClient:
|
|
|
39
55
|
f"{self.base_url}/chat/completions",
|
|
40
56
|
headers=self.headers,
|
|
41
57
|
json=payload,
|
|
42
|
-
timeout=
|
|
58
|
+
timeout=self.timeout
|
|
43
59
|
)
|
|
44
60
|
response.raise_for_status()
|
|
45
61
|
data = response.json()
|
|
@@ -70,7 +86,7 @@ class NimClient:
|
|
|
70
86
|
f"{self.base_url}/chat/completions",
|
|
71
87
|
headers=self.headers,
|
|
72
88
|
json=payload,
|
|
73
|
-
timeout=
|
|
89
|
+
timeout=self.timeout
|
|
74
90
|
)
|
|
75
91
|
response.raise_for_status()
|
|
76
92
|
data = response.json()
|
|
@@ -90,15 +106,15 @@ class NimClient:
|
|
|
90
106
|
"stream": stream
|
|
91
107
|
}
|
|
92
108
|
|
|
93
|
-
max_retries =
|
|
94
|
-
base_delay =
|
|
95
|
-
max_delay =
|
|
109
|
+
max_retries = self.max_retries
|
|
110
|
+
base_delay = self.retry_base_delay
|
|
111
|
+
max_delay = self.retry_max_delay
|
|
96
112
|
|
|
97
113
|
for attempt in range(max_retries):
|
|
98
114
|
chunk_yielded = False
|
|
99
115
|
try:
|
|
100
116
|
async with httpx.AsyncClient() as client:
|
|
101
|
-
async with client.stream("POST", f"{self.base_url}/chat/completions", headers=self.headers, json=payload, timeout=
|
|
117
|
+
async with client.stream("POST", f"{self.base_url}/chat/completions", headers=self.headers, json=payload, timeout=self.timeout) as response:
|
|
102
118
|
if response.status_code in [408, 429, 500, 502, 503, 504, 529]:
|
|
103
119
|
raise httpx.HTTPStatusError(f"Temporary server error {response.status_code}", request=response.request, response=response)
|
|
104
120
|
|
|
@@ -39,8 +39,18 @@ class PermissionEngine:
|
|
|
39
39
|
args = tool_call.get("args", {})
|
|
40
40
|
|
|
41
41
|
if not sys.stdin.isatty():
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Non-interactive mode: only auto-approve safe tools + writes.
|
|
43
|
+
# Bash requires explicit opt-in via config.
|
|
44
|
+
from .config import load_settings
|
|
45
|
+
settings = load_settings()
|
|
46
|
+
safe_non_interactive = {"Read", "Glob", "Grep", "Write", "Edit", "ASTReplace"}
|
|
47
|
+
if tool_name in safe_non_interactive:
|
|
48
|
+
return True
|
|
49
|
+
if tool_name == "Bash" and settings.get("allow_bash_non_interactive", False):
|
|
50
|
+
console.print(f"[yellow]Non-interactive: Bash auto-approved via config.[/yellow]")
|
|
51
|
+
return True
|
|
52
|
+
console.print(f"[red]Non-interactive: {tool_name} DENIED (enable 'allow_bash_non_interactive' in settings to allow).[/red]")
|
|
53
|
+
return False
|
|
44
54
|
|
|
45
55
|
while True:
|
|
46
56
|
content = ""
|
|
@@ -531,17 +531,30 @@ class NimcodeREPL:
|
|
|
531
531
|
self.messages.append({"role": "system", "content": "You are the Application Guardian. Your sole priority is preventing regressions. Before making any code changes, ensure you write a test first. If a user asks for a breaking change, warn them aggressively."})
|
|
532
532
|
continue
|
|
533
533
|
elif user_input.strip() == "/thinkback":
|
|
534
|
-
console.print("[bold blue]⏪
|
|
534
|
+
console.print("[bold blue]⏪ Agent Replay (Thinkback)[/bold blue]")
|
|
535
535
|
from rich.table import Table
|
|
536
|
-
table = Table(title="
|
|
536
|
+
table = Table(title="Session History")
|
|
537
537
|
table.add_column("Turn", style="cyan")
|
|
538
|
-
table.add_column("
|
|
539
|
-
table.add_column("
|
|
540
|
-
table.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
538
|
+
table.add_column("Role", style="yellow")
|
|
539
|
+
table.add_column("Tokens (est.)", style="green")
|
|
540
|
+
table.add_column("Preview", style="dim", max_width=60)
|
|
541
|
+
turn_num = 0
|
|
542
|
+
for msg in self.messages[1:]: # Skip system prompt
|
|
543
|
+
role = msg.get("role", "?")
|
|
544
|
+
content = str(msg.get("content", ""))
|
|
545
|
+
est_tokens = len(content) // 4 + 1
|
|
546
|
+
preview = content[:80].replace("\n", " ")
|
|
547
|
+
if len(content) > 80:
|
|
548
|
+
preview += "..."
|
|
549
|
+
if role == "user":
|
|
550
|
+
turn_num += 1
|
|
551
|
+
table.add_row(str(turn_num), role, str(est_tokens), preview)
|
|
552
|
+
if turn_num == 0:
|
|
553
|
+
console.print("[dim]No conversation history yet.[/dim]")
|
|
554
|
+
else:
|
|
555
|
+
console.print(table)
|
|
556
|
+
total_tokens = sum(len(str(m.get("content", ""))) // 4 + 1 for m in self.messages)
|
|
557
|
+
console.print(f"[dim]Total messages: {len(self.messages)} | Est. total tokens: {total_tokens}[/dim]")
|
|
545
558
|
continue
|
|
546
559
|
elif user_input.strip().startswith("/fix"):
|
|
547
560
|
parts = user_input.strip().split(" ", 1)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight repository mapper.
|
|
3
|
+
Generates a tree-style overview of the project structure.
|
|
4
|
+
Used by watcher.py for Live Sync updates.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import logging
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Directories to always skip
|
|
13
|
+
IGNORE_DIRS = {
|
|
14
|
+
".git", ".nimcode", "__pycache__", "node_modules", ".venv", "venv",
|
|
15
|
+
".tox", ".mypy_cache", ".pytest_cache", "dist", "build", ".egg-info",
|
|
16
|
+
".idea", ".vscode", "env", ".env",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# File extensions to include in the map
|
|
20
|
+
CODE_EXTENSIONS = {
|
|
21
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".rb",
|
|
22
|
+
".c", ".cpp", ".h", ".cs", ".php", ".swift", ".kt", ".scala",
|
|
23
|
+
".html", ".css", ".scss", ".vue", ".svelte",
|
|
24
|
+
".json", ".yaml", ".yml", ".toml", ".md", ".txt",
|
|
25
|
+
".sql", ".sh", ".bat", ".ps1", ".dockerfile",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
MAX_FILES = 500 # Safety limit
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RepoMapper:
|
|
32
|
+
def __init__(self, root_dir: str, max_depth: int = 6):
|
|
33
|
+
self.root_dir = os.path.abspath(root_dir)
|
|
34
|
+
self.max_depth = max_depth
|
|
35
|
+
self._file_count = 0
|
|
36
|
+
|
|
37
|
+
def generate_map(self) -> str:
|
|
38
|
+
"""Generates a tree-style string of the repository structure."""
|
|
39
|
+
self._file_count = 0
|
|
40
|
+
lines = [f"📁 {os.path.basename(self.root_dir)}/"]
|
|
41
|
+
self._walk(self.root_dir, lines, prefix="", depth=0)
|
|
42
|
+
|
|
43
|
+
if self._file_count >= MAX_FILES:
|
|
44
|
+
lines.append(f"\n... (truncated at {MAX_FILES} files)")
|
|
45
|
+
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
def _walk(self, directory: str, lines: List[str], prefix: str, depth: int) -> None:
|
|
49
|
+
if depth >= self.max_depth or self._file_count >= MAX_FILES:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
entries = sorted(os.listdir(directory))
|
|
54
|
+
except PermissionError:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Separate dirs and files
|
|
58
|
+
dirs = []
|
|
59
|
+
files = []
|
|
60
|
+
for entry in entries:
|
|
61
|
+
full_path = os.path.join(directory, entry)
|
|
62
|
+
if os.path.isdir(full_path):
|
|
63
|
+
if entry not in IGNORE_DIRS and not entry.startswith("."):
|
|
64
|
+
dirs.append(entry)
|
|
65
|
+
else:
|
|
66
|
+
ext = os.path.splitext(entry)[1].lower()
|
|
67
|
+
if ext in CODE_EXTENSIONS or entry in {"Makefile", "Dockerfile", "Procfile", ".gitignore"}:
|
|
68
|
+
files.append(entry)
|
|
69
|
+
|
|
70
|
+
all_entries = dirs + files
|
|
71
|
+
for i, entry in enumerate(all_entries):
|
|
72
|
+
is_last = (i == len(all_entries) - 1)
|
|
73
|
+
connector = "└── " if is_last else "├── "
|
|
74
|
+
|
|
75
|
+
full_path = os.path.join(directory, entry)
|
|
76
|
+
|
|
77
|
+
if entry in dirs:
|
|
78
|
+
lines.append(f"{prefix}{connector}📁 {entry}/")
|
|
79
|
+
extension = " " if is_last else "│ "
|
|
80
|
+
self._walk(full_path, lines, prefix + extension, depth + 1)
|
|
81
|
+
else:
|
|
82
|
+
self._file_count += 1
|
|
83
|
+
if self._file_count > MAX_FILES:
|
|
84
|
+
return
|
|
85
|
+
size = os.path.getsize(full_path)
|
|
86
|
+
size_str = _format_size(size)
|
|
87
|
+
lines.append(f"{prefix}{connector}{entry} ({size_str})")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_size(size_bytes: int) -> str:
|
|
91
|
+
"""Formats file size in human-readable format."""
|
|
92
|
+
if size_bytes < 1024:
|
|
93
|
+
return f"{size_bytes}B"
|
|
94
|
+
elif size_bytes < 1024 * 1024:
|
|
95
|
+
return f"{size_bytes / 1024:.1f}KB"
|
|
96
|
+
else:
|
|
97
|
+
return f"{size_bytes / (1024 * 1024):.1f}MB"
|
|
@@ -239,6 +239,8 @@ class ToolRegistry:
|
|
|
239
239
|
return f"Started task '{task_id}' in the background. Check logs at {log_file}. Use /tasks to manage."
|
|
240
240
|
|
|
241
241
|
try:
|
|
242
|
+
t_cmd = settings.get("timeout_command", 1200)
|
|
243
|
+
t_cmd = None if t_cmd == 0 else t_cmd
|
|
242
244
|
result = subprocess.run(
|
|
243
245
|
command,
|
|
244
246
|
shell=True,
|
|
@@ -246,7 +248,7 @@ class ToolRegistry:
|
|
|
246
248
|
stdout=subprocess.PIPE,
|
|
247
249
|
stderr=subprocess.PIPE,
|
|
248
250
|
text=True,
|
|
249
|
-
timeout=
|
|
251
|
+
timeout=t_cmd
|
|
250
252
|
)
|
|
251
253
|
out = result.stdout + "\n" + result.stderr
|
|
252
254
|
out = out.strip()
|
|
@@ -257,7 +259,7 @@ class ToolRegistry:
|
|
|
257
259
|
|
|
258
260
|
return out if out else "Command executed successfully with no output."
|
|
259
261
|
except subprocess.TimeoutExpired:
|
|
260
|
-
return "Error: Command timed out after
|
|
262
|
+
return f"Error: Command timed out after {t_cmd} seconds."
|
|
261
263
|
except Exception as e:
|
|
262
264
|
return f"Error running bash: {e}"
|
|
263
265
|
|
|
@@ -565,11 +567,16 @@ class ToolRegistry:
|
|
|
565
567
|
try:
|
|
566
568
|
from playwright.sync_api import sync_playwright
|
|
567
569
|
import base64
|
|
570
|
+
from .config import load_settings
|
|
571
|
+
|
|
572
|
+
settings = load_settings()
|
|
573
|
+
t_browser = settings.get("timeout_browser", 15000)
|
|
574
|
+
t_browser = 0 if t_browser == 0 else t_browser
|
|
568
575
|
|
|
569
576
|
with sync_playwright() as p:
|
|
570
577
|
browser = p.chromium.launch(headless=True)
|
|
571
578
|
page = browser.new_page()
|
|
572
|
-
page.goto(url, wait_until="networkidle", timeout=
|
|
579
|
+
page.goto(url, wait_until="networkidle", timeout=t_browser)
|
|
573
580
|
|
|
574
581
|
screenshot_bytes = page.screenshot(type="jpeg", quality=50)
|
|
575
582
|
b64_img = base64.b64encode(screenshot_bytes).decode('utf-8')
|
|
@@ -3,7 +3,7 @@ import logging
|
|
|
3
3
|
|
|
4
4
|
logger = logging.getLogger(__name__)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
from .__version__ import __version__ as CURRENT_VERSION
|
|
7
7
|
|
|
8
8
|
class AutoUpdater:
|
|
9
9
|
@staticmethod
|
|
@@ -13,7 +13,12 @@ class AutoUpdater:
|
|
|
13
13
|
Returns the latest version string if > CURRENT_VERSION, else None.
|
|
14
14
|
"""
|
|
15
15
|
try:
|
|
16
|
-
|
|
16
|
+
from .config import load_settings
|
|
17
|
+
settings = load_settings()
|
|
18
|
+
t_upd = settings.get("timeout_updater", 3.0)
|
|
19
|
+
t_upd = None if t_upd == 0 else t_upd
|
|
20
|
+
|
|
21
|
+
async with httpx.AsyncClient(timeout=t_upd) as client:
|
|
17
22
|
response = await client.get("https://pypi.org/pypi/nimcode/json")
|
|
18
23
|
if response.status_code == 200:
|
|
19
24
|
data = response.json()
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
setup.py
|
|
3
3
|
src/nimcode/__init__.py
|
|
4
|
+
src/nimcode/__version__.py
|
|
4
5
|
src/nimcode/agent.py
|
|
5
6
|
src/nimcode/cli.py
|
|
6
7
|
src/nimcode/config.py
|
|
7
8
|
src/nimcode/lenient_parser.py
|
|
8
9
|
src/nimcode/mcp_client.py
|
|
9
10
|
src/nimcode/memory.py
|
|
11
|
+
src/nimcode/model_registry.py
|
|
10
12
|
src/nimcode/nim_client.py
|
|
11
13
|
src/nimcode/permissions.py
|
|
12
14
|
src/nimcode/plugin_manager.py
|
|
13
15
|
src/nimcode/rag.py
|
|
14
16
|
src/nimcode/repl.py
|
|
17
|
+
src/nimcode/repo_map.py
|
|
15
18
|
src/nimcode/tools.py
|
|
16
19
|
src/nimcode/updater.py
|
|
17
20
|
src/nimcode/watcher.py
|
|
@@ -27,11 +30,14 @@ tests/test_config.py
|
|
|
27
30
|
tests/test_lenient_parser.py
|
|
28
31
|
tests/test_mcp_client.py
|
|
29
32
|
tests/test_memory.py
|
|
33
|
+
tests/test_model_registry.py
|
|
30
34
|
tests/test_nim_client.py
|
|
31
35
|
tests/test_permissions.py
|
|
32
36
|
tests/test_plugin_manager.py
|
|
33
37
|
tests/test_repl.py
|
|
34
38
|
tests/test_repl_extra.py
|
|
35
39
|
tests/test_repl_trust.py
|
|
40
|
+
tests/test_repo_map.py
|
|
36
41
|
tests/test_tools.py
|
|
37
|
-
tests/test_updater.py
|
|
42
|
+
tests/test_updater.py
|
|
43
|
+
tests/test_version.py
|
|
@@ -9,6 +9,16 @@ def test_load_settings_default():
|
|
|
9
9
|
settings = load_settings()
|
|
10
10
|
assert settings["model"] == "meta/llama-3.1-70b-instruct"
|
|
11
11
|
assert settings["mcp_servers"] == {}
|
|
12
|
+
# New configurable settings
|
|
13
|
+
assert settings["max_turns"] == 200
|
|
14
|
+
assert settings["max_tokens"] == 120000
|
|
15
|
+
assert settings["max_retries"] == 15
|
|
16
|
+
assert settings["retry_base_delay"] == 2.0
|
|
17
|
+
assert settings["retry_max_delay"] == 60.0
|
|
18
|
+
assert settings["allow_bash_non_interactive"] == False
|
|
19
|
+
# Timeouts
|
|
20
|
+
assert settings["timeout_command"] == 1200
|
|
21
|
+
assert settings["timeout_llm"] == 120
|
|
12
22
|
|
|
13
23
|
def test_load_settings_global_only():
|
|
14
24
|
mock_global = '{"model": "global_model", "mcp_servers": {"global": {}}}'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tests for the model_registry module."""
|
|
2
|
+
import pytest
|
|
3
|
+
from nimcode.model_registry import (
|
|
4
|
+
MODEL_CONTEXT_WINDOWS,
|
|
5
|
+
FALLBACK_MODELS,
|
|
6
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
7
|
+
get_context_window,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_get_context_window_known_model():
|
|
12
|
+
"""Known models should return their specific context window size."""
|
|
13
|
+
assert get_context_window("meta/llama-3.1-70b-instruct") == 128000
|
|
14
|
+
assert get_context_window("nvidia/nemotron-4-340b-instruct") == 4096
|
|
15
|
+
assert get_context_window("mistralai/mixtral-8x22b-instruct-v0.1") == 65536
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_get_context_window_unknown_model():
|
|
19
|
+
"""Unknown models should return the default context window."""
|
|
20
|
+
assert get_context_window("some/unknown-model") == DEFAULT_CONTEXT_WINDOW
|
|
21
|
+
assert get_context_window("") == DEFAULT_CONTEXT_WINDOW
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_fallback_models_not_empty():
|
|
25
|
+
"""Fallback model list should have entries."""
|
|
26
|
+
assert len(FALLBACK_MODELS) > 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_fallback_models_all_in_context_windows():
|
|
30
|
+
"""All fallback models should have a known context window entry."""
|
|
31
|
+
for model in FALLBACK_MODELS:
|
|
32
|
+
assert model in MODEL_CONTEXT_WINDOWS, f"Fallback model {model} missing from context windows"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_context_windows_positive():
|
|
36
|
+
"""All context window values should be positive integers."""
|
|
37
|
+
for model, ctx in MODEL_CONTEXT_WINDOWS.items():
|
|
38
|
+
assert isinstance(ctx, int) and ctx > 0, f"Invalid context window for {model}: {ctx}"
|
|
@@ -8,8 +8,10 @@ def test_permission_bypass():
|
|
|
8
8
|
|
|
9
9
|
def test_permission_readonly_blocked():
|
|
10
10
|
engine = PermissionEngine(mode=PermissionMode.AUTO)
|
|
11
|
-
#
|
|
12
|
-
|
|
11
|
+
# In non-interactive mode (tests), Bash is now DENIED by default.
|
|
12
|
+
# Safe tools (Read, Glob, Grep) and Write/Edit are still auto-approved.
|
|
13
|
+
assert engine.check_permission({"tool": "Read", "args": {}}) == True
|
|
14
|
+
assert engine.check_permission({"tool": "Bash", "args": {}}) == False
|
|
13
15
|
|
|
14
16
|
def test_permission_safe_tools():
|
|
15
17
|
engine = PermissionEngine(mode=PermissionMode.DEFAULT)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tests for repo_map module."""
|
|
2
|
+
import os
|
|
3
|
+
import pytest
|
|
4
|
+
import tempfile
|
|
5
|
+
from nimcode.repo_map import RepoMapper, _format_size
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_format_size_bytes():
|
|
9
|
+
assert _format_size(500) == "500B"
|
|
10
|
+
assert _format_size(0) == "0B"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_format_size_kb():
|
|
14
|
+
result = _format_size(2048)
|
|
15
|
+
assert "KB" in result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_format_size_mb():
|
|
19
|
+
result = _format_size(2 * 1024 * 1024)
|
|
20
|
+
assert "MB" in result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_generate_map_basic(tmp_path):
|
|
24
|
+
"""Test basic tree generation with files and directories."""
|
|
25
|
+
# Create a simple project structure
|
|
26
|
+
(tmp_path / "main.py").write_text("print('hello')")
|
|
27
|
+
(tmp_path / "utils.py").write_text("def helper(): pass")
|
|
28
|
+
sub = tmp_path / "src"
|
|
29
|
+
sub.mkdir()
|
|
30
|
+
(sub / "app.py").write_text("class App: pass")
|
|
31
|
+
|
|
32
|
+
mapper = RepoMapper(str(tmp_path))
|
|
33
|
+
result = mapper.generate_map()
|
|
34
|
+
|
|
35
|
+
assert "main.py" in result
|
|
36
|
+
assert "utils.py" in result
|
|
37
|
+
assert "src" in result
|
|
38
|
+
assert "app.py" in result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_generate_map_ignores_pycache(tmp_path):
|
|
42
|
+
"""Test that __pycache__ directories are ignored."""
|
|
43
|
+
(tmp_path / "main.py").write_text("x = 1")
|
|
44
|
+
cache = tmp_path / "__pycache__"
|
|
45
|
+
cache.mkdir()
|
|
46
|
+
(cache / "main.cpython-311.pyc").write_bytes(b"bytecode")
|
|
47
|
+
|
|
48
|
+
mapper = RepoMapper(str(tmp_path))
|
|
49
|
+
result = mapper.generate_map()
|
|
50
|
+
|
|
51
|
+
assert "main.py" in result
|
|
52
|
+
assert "__pycache__" not in result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_generate_map_ignores_node_modules(tmp_path):
|
|
56
|
+
"""Test that node_modules is ignored."""
|
|
57
|
+
(tmp_path / "index.js").write_text("console.log('hi')")
|
|
58
|
+
nm = tmp_path / "node_modules"
|
|
59
|
+
nm.mkdir()
|
|
60
|
+
(nm / "package.json").write_text("{}")
|
|
61
|
+
|
|
62
|
+
mapper = RepoMapper(str(tmp_path))
|
|
63
|
+
result = mapper.generate_map()
|
|
64
|
+
|
|
65
|
+
assert "index.js" in result
|
|
66
|
+
assert "node_modules" not in result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_generate_map_max_depth(tmp_path):
|
|
70
|
+
"""Test that max_depth is respected."""
|
|
71
|
+
# Create a deeply nested structure
|
|
72
|
+
current = tmp_path
|
|
73
|
+
for i in range(10):
|
|
74
|
+
current = current / f"level{i}"
|
|
75
|
+
current.mkdir()
|
|
76
|
+
(current / "file.py").write_text(f"level {i}")
|
|
77
|
+
|
|
78
|
+
mapper = RepoMapper(str(tmp_path), max_depth=3)
|
|
79
|
+
result = mapper.generate_map()
|
|
80
|
+
|
|
81
|
+
assert "level0" in result
|
|
82
|
+
assert "level1" in result
|
|
83
|
+
# Deep levels should NOT appear
|
|
84
|
+
assert "level8" not in result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_generate_map_empty_dir(tmp_path):
|
|
88
|
+
"""Empty directory should still generate valid output."""
|
|
89
|
+
mapper = RepoMapper(str(tmp_path))
|
|
90
|
+
result = mapper.generate_map()
|
|
91
|
+
|
|
92
|
+
# Should at least have the root directory name
|
|
93
|
+
assert tmp_path.name in result
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Tests for __version__ single source of truth."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_version_format():
|
|
6
|
+
"""Version should be a valid semver string."""
|
|
7
|
+
from nimcode.__version__ import __version__
|
|
8
|
+
parts = __version__.split(".")
|
|
9
|
+
assert len(parts) == 3, f"Version should be x.y.z, got {__version__}"
|
|
10
|
+
for part in parts:
|
|
11
|
+
assert part.isdigit(), f"Version part '{part}' is not a digit"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_version_is_0_4_0():
|
|
15
|
+
"""Current version should be 0.4.0."""
|
|
16
|
+
from nimcode.__version__ import __version__
|
|
17
|
+
assert __version__ == "0.4.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_updater_uses_version():
|
|
21
|
+
"""updater.py should use the same version as __version__.py."""
|
|
22
|
+
from nimcode.__version__ import __version__
|
|
23
|
+
from nimcode.updater import CURRENT_VERSION
|
|
24
|
+
assert CURRENT_VERSION == __version__
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|