vmcode-cli 1.0.0

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 (56) hide show
  1. package/INSTALLATION_METHODS.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/bin/npm-wrapper.js +171 -0
  5. package/bin/rg +0 -0
  6. package/bin/rg.exe +0 -0
  7. package/config.yaml.example +159 -0
  8. package/package.json +42 -0
  9. package/requirements.txt +7 -0
  10. package/scripts/install.js +132 -0
  11. package/setup.bat +114 -0
  12. package/setup.sh +135 -0
  13. package/src/__init__.py +4 -0
  14. package/src/core/__init__.py +1 -0
  15. package/src/core/agentic.py +2342 -0
  16. package/src/core/chat_manager.py +1201 -0
  17. package/src/core/config_manager.py +269 -0
  18. package/src/core/init.py +161 -0
  19. package/src/core/sub_agent.py +174 -0
  20. package/src/exceptions.py +75 -0
  21. package/src/llm/__init__.py +1 -0
  22. package/src/llm/client.py +149 -0
  23. package/src/llm/config.py +445 -0
  24. package/src/llm/prompts.py +569 -0
  25. package/src/llm/providers.py +402 -0
  26. package/src/llm/token_tracker.py +220 -0
  27. package/src/ui/__init__.py +1 -0
  28. package/src/ui/banner.py +103 -0
  29. package/src/ui/commands.py +489 -0
  30. package/src/ui/displays.py +167 -0
  31. package/src/ui/main.py +351 -0
  32. package/src/ui/prompt_utils.py +162 -0
  33. package/src/utils/__init__.py +1 -0
  34. package/src/utils/editor.py +158 -0
  35. package/src/utils/gitignore_filter.py +149 -0
  36. package/src/utils/logger.py +254 -0
  37. package/src/utils/markdown.py +32 -0
  38. package/src/utils/settings.py +94 -0
  39. package/src/utils/tools/__init__.py +55 -0
  40. package/src/utils/tools/command_executor.py +217 -0
  41. package/src/utils/tools/create_file.py +143 -0
  42. package/src/utils/tools/definitions.py +193 -0
  43. package/src/utils/tools/directory.py +374 -0
  44. package/src/utils/tools/file_editor.py +345 -0
  45. package/src/utils/tools/file_helpers.py +109 -0
  46. package/src/utils/tools/file_reader.py +331 -0
  47. package/src/utils/tools/formatters.py +458 -0
  48. package/src/utils/tools/parallel_executor.py +195 -0
  49. package/src/utils/validation.py +117 -0
  50. package/src/utils/web_search.py +71 -0
  51. package/vmcode-proxy/.env.example +5 -0
  52. package/vmcode-proxy/README.md +235 -0
  53. package/vmcode-proxy/package-lock.json +947 -0
  54. package/vmcode-proxy/package.json +20 -0
  55. package/vmcode-proxy/server.js +248 -0
  56. package/vmcode-proxy/server.js.bak +157 -0
@@ -0,0 +1,158 @@
1
+ """External editor integration for vmCode."""
2
+ import os
3
+ import platform
4
+ import subprocess
5
+ import tempfile
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Tuple, Optional
9
+
10
+
11
+ def get_editor() -> str:
12
+ """Get editor command with OS-specific defaults.
13
+
14
+ Priority:
15
+ 1. EDITOR environment variable
16
+ 2. OS defaults: notepad.exe (Windows) | nano/vi/vim (Unix)
17
+
18
+ Returns:
19
+ str: Editor command or path
20
+ """
21
+ # 1. Check environment variable
22
+ editor = os.environ.get("EDITOR")
23
+ if editor and editor.strip():
24
+ return editor.strip()
25
+
26
+ # 2. OS-specific defaults
27
+ if platform.system() == "Windows":
28
+ return "notepad.exe"
29
+ else:
30
+ # Try to find common editors
31
+ for cmd in ["nano", "vi", "vim"]:
32
+ if shutil.which(cmd):
33
+ return cmd
34
+ # Fallback to nano even if not found (will error later)
35
+ return "nano"
36
+
37
+
38
+ def _create_temp_file() -> Tuple[Path, object]:
39
+ """Create temporary file for editing.
40
+
41
+ Returns:
42
+ tuple: (Path object, file handle)
43
+ """
44
+ # Create with .md extension for better syntax highlighting
45
+ temp_fd = tempfile.NamedTemporaryFile(
46
+ mode='w+',
47
+ suffix='.md',
48
+ prefix='vmcode_edit_',
49
+ delete=False,
50
+ encoding='utf-8'
51
+ )
52
+
53
+ temp_fd.flush()
54
+
55
+ return Path(temp_fd.name), temp_fd
56
+
57
+
58
+ def _strip_comment_lines(content: str) -> str:
59
+ """Remove comment lines (starting with #) from content.
60
+
61
+ Args:
62
+ content: Raw content from editor
63
+
64
+ Returns:
65
+ str: Content with comment lines removed
66
+ """
67
+ lines = []
68
+ for line in content.split('\n'):
69
+ stripped = line.strip()
70
+ # Keep empty lines and non-comment lines
71
+ if not stripped.startswith('#'):
72
+ lines.append(line)
73
+
74
+ return '\n'.join(lines).strip()
75
+
76
+
77
+ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Optional[str]]:
78
+ """Open external editor and return user input.
79
+
80
+ Args:
81
+ console: Rich console for output
82
+ debug_mode: Whether to show debug information
83
+
84
+ Returns:
85
+ tuple: (success: bool, content: str or None)
86
+ - (True, content) if successful
87
+ - (False, None) if failed or cancelled
88
+ """
89
+ editor_cmd = get_editor()
90
+ temp_path = None
91
+
92
+ try:
93
+ # Create temporary file
94
+ temp_path, temp_fd = _create_temp_file()
95
+ temp_fd.close()
96
+
97
+ if debug_mode:
98
+ console.print(f"[dim]Temp file: {temp_path}[/dim]")
99
+ console.print(f"[dim]Editor: {editor_cmd}[/dim]")
100
+
101
+ console.print("[cyan]Opening editor...[/cyan]")
102
+ console.print("[dim]Save and close the editor when done[/dim]")
103
+
104
+ # Launch editor and wait for it to close
105
+ # Use shell=True on Windows for notepad, False for better security on Unix
106
+ use_shell = platform.system() == "Windows"
107
+
108
+ result = subprocess.run(
109
+ [editor_cmd, str(temp_path)],
110
+ shell=use_shell,
111
+ check=False # Don't raise on non-zero exit
112
+ )
113
+
114
+ if result.returncode != 0 and debug_mode:
115
+ console.print(f"[yellow]Editor exited with code {result.returncode}[/yellow]")
116
+
117
+ # Read content from temp file
118
+ content = temp_path.read_text(encoding='utf-8')
119
+
120
+ # Strip comment lines
121
+ content = _strip_comment_lines(content)
122
+
123
+ if debug_mode:
124
+ console.print(f"[dim]Read {len(content)} characters[/dim]")
125
+
126
+ return (True, content)
127
+
128
+ except FileNotFoundError:
129
+ console.print(f"[red]Editor '{editor_cmd}' not found[/red]", markup=False)
130
+ console.print("[dim]Set EDITOR as environment variable[/dim]")
131
+ if debug_mode:
132
+ console.print(f"[dim]Tried to run: {editor_cmd}[/dim]")
133
+ return (False, None)
134
+
135
+ except PermissionError as e:
136
+ console.print(f"[red]Permission denied: {e}[/red]", markup=False)
137
+ console.print("[dim]Check permissions on temporary directory[/dim]")
138
+ return (False, None)
139
+
140
+ except Exception as e:
141
+ console.print(f"[red]Failed to open editor: {e}[/red]", markup=False)
142
+ if debug_mode:
143
+ import traceback
144
+ console.print(f"[dim]{traceback.format_exc()}[/dim]")
145
+ return (False, None)
146
+
147
+ finally:
148
+ # Cleanup temp file
149
+ if temp_path and temp_path.exists():
150
+ try:
151
+ temp_path.unlink()
152
+ if debug_mode:
153
+ console.print(f"[dim]Cleaned up temp file[/dim]")
154
+ except Exception as e:
155
+ # Log but don't crash on cleanup failure
156
+ console.print(f"[yellow]Warning: Failed to delete temp file: {e}[/yellow]")
157
+ if debug_mode:
158
+ console.print(f"[dim]Temp file may remain at: {temp_path}[/dim]")
@@ -0,0 +1,149 @@
1
+ """Centralized .gitignore filtering using pathspec library."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional, Tuple
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Always allow .gitignore itself to be read/edited
10
+ ALWAYS_ALLOWED_FILES = {".gitignore"}
11
+
12
+ # Try to import pathspec at module level
13
+ try:
14
+ import pathspec
15
+ except ImportError:
16
+ pathspec = None
17
+ logger.warning("pathspec library not installed - .gitignore filtering disabled")
18
+
19
+
20
+ def load_gitignore_spec(repo_root: Path):
21
+ """Load .gitignore patterns into a PathSpec object.
22
+
23
+ Args:
24
+ repo_root: Repository root directory
25
+
26
+ Returns:
27
+ pathspec.PathSpec or None if .gitignore doesn't exist
28
+ """
29
+ # Return None early if pathspec is not available
30
+ if pathspec is None:
31
+ return None
32
+
33
+ gitignore_path = repo_root / ".gitignore"
34
+
35
+ if not gitignore_path.exists():
36
+ return None
37
+
38
+ try:
39
+ # Read .gitignore patterns
40
+ patterns = gitignore_path.read_text(encoding="utf-8").splitlines()
41
+
42
+ # Create PathSpec with gitwildmatch (git's pattern matching)
43
+ spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
44
+ return spec
45
+
46
+ except Exception as e:
47
+ logger.warning(f"Failed to load .gitignore: {e}")
48
+ return None
49
+
50
+
51
+ def is_path_ignored(
52
+ path: Path, repo_root: Path, gitignore_spec
53
+ ) -> Tuple[bool, Optional[str]]:
54
+ """Check if a path is ignored by .gitignore.
55
+
56
+ Args:
57
+ path: Absolute path to check
58
+ repo_root: Repository root directory
59
+ gitignore_spec: pathspec.PathSpec object (or None)
60
+
61
+ Returns:
62
+ Tuple of (is_ignored, matched_pattern)
63
+ - is_ignored: True if path should be blocked
64
+ - matched_pattern: The pattern that matched (or None)
65
+ """
66
+ # No filtering if no .gitignore
67
+ if gitignore_spec is None:
68
+ return False, None
69
+
70
+ # Always allow .gitignore itself
71
+ if path.name in ALWAYS_ALLOWED_FILES:
72
+ return False, None
73
+
74
+ # Get relative path from repo root (gitignore only applies within repo)
75
+ try:
76
+ rel_path = path.relative_to(repo_root)
77
+ except ValueError:
78
+ # Path is outside repo - gitignore doesn't apply
79
+ return False, None
80
+
81
+ # Convert to forward slashes (git convention)
82
+ rel_path_str = str(rel_path).replace("\\", "/")
83
+
84
+ # Check if path matches any .gitignore pattern
85
+ # pathspec.match_file() returns True if the file should be ignored
86
+ if gitignore_spec.match_file(rel_path_str):
87
+ # Find which pattern matched (for better error messages)
88
+ matched_pattern = _find_matching_pattern(rel_path_str, gitignore_spec)
89
+ return True, matched_pattern
90
+
91
+ return False, None
92
+
93
+
94
+ def _find_matching_pattern(path_str: str, gitignore_spec) -> Optional[str]:
95
+ """Find which .gitignore pattern matched a path.
96
+
97
+ This is for better error messages.
98
+
99
+ Args:
100
+ path_str: Relative path string (with forward slashes)
101
+ gitignore_spec: pathspec.PathSpec object
102
+
103
+ Returns:
104
+ The matching pattern string, or None
105
+ """
106
+ try:
107
+ # PathSpec stores patterns internally
108
+ for pattern in gitignore_spec.patterns:
109
+ if pattern.match_file(path_str):
110
+ # Return the original pattern string
111
+ return pattern.pattern
112
+ except Exception:
113
+ pass
114
+
115
+ return None
116
+
117
+
118
+ def format_gitignore_error(
119
+ path: Path, repo_root: Path, matched_pattern: Optional[str]
120
+ ) -> str:
121
+ """Format a user-friendly error message for .gitignore blocked files.
122
+
123
+ Args:
124
+ path: The blocked file path
125
+ repo_root: Repository root
126
+ matched_pattern: The .gitignore pattern that matched
127
+
128
+ Returns:
129
+ Formatted error message
130
+ """
131
+ try:
132
+ rel_path = path.relative_to(repo_root)
133
+ except ValueError:
134
+ rel_path = path
135
+
136
+ error_msg = (
137
+ f"exit_code=ERROR_GITIGNORE_BLOCKED\n"
138
+ f"File blocked by .gitignore: {rel_path}\n\n"
139
+ )
140
+ error_msg += "This file matches patterns in .gitignore and cannot be accessed.\n"
141
+
142
+ if matched_pattern:
143
+ error_msg += f"Matched pattern: {matched_pattern}\n"
144
+
145
+ error_msg += "\nTo access this file:\n"
146
+ error_msg += "1. Remove it from .gitignore, or\n"
147
+ error_msg += "2. Use git commands directly (git show, git diff)\n"
148
+
149
+ return error_msg
@@ -0,0 +1,254 @@
1
+ """Markdown conversation logging module for saving chat history to readable markdown files."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Optional, Dict, Any, List
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class MarkdownConversationLogger:
13
+ """Logs conversations to Markdown format with tool call details."""
14
+
15
+ MAX_CONTENT_LENGTH = 2000
16
+
17
+ def __init__(self, conversations_dir: str = "conversations"):
18
+ """Initialize markdown conversation logger.
19
+
20
+ Args:
21
+ conversations_dir: Directory to save conversation logs
22
+ """
23
+ self.conversations_dir = Path(conversations_dir)
24
+ self.conversations_dir.mkdir(exist_ok=True)
25
+ self.current_file: Optional[Path] = None
26
+
27
+ def start_session(self):
28
+ """Start a new conversation session."""
29
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
30
+ self.current_file = self.conversations_dir / f"conversation_{timestamp}.md"
31
+ logger.info(f"Started markdown conversation logging to {self.current_file}")
32
+
33
+ # Write header
34
+ with open(self.current_file, 'w', encoding='utf-8') as f:
35
+ f.write(f"# Conversation Log\n\n")
36
+ f.write(f"**Started:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
37
+ f.write("---\n\n")
38
+
39
+ def _format_tool_call(self, tool_call: Dict[str, Any]) -> str:
40
+ """Format a tool call for markdown display.
41
+
42
+ Args:
43
+ tool_call: Tool call dict with id, type, function
44
+
45
+ Returns:
46
+ Formatted markdown string
47
+ """
48
+ fn = tool_call.get("function", {})
49
+ name = fn.get("name", "unknown")
50
+ arguments = fn.get("arguments", "{}")
51
+ args_str = self._format_json_value(arguments)
52
+
53
+ return f"""### {name}
54
+
55
+ ```json
56
+ {args_str}
57
+ ```"""
58
+
59
+ def _format_json_value(self, value: Any) -> str:
60
+ """Format a value as JSON for markdown display.
61
+
62
+ Args:
63
+ value: Value to format (string, dict, or other)
64
+
65
+ Returns:
66
+ Formatted JSON string, or string representation if not JSON-serializable
67
+ """
68
+ try:
69
+ if isinstance(value, str):
70
+ parsed = json.loads(value)
71
+ else:
72
+ parsed = value
73
+ return json.dumps(parsed, indent=2, ensure_ascii=False)
74
+ except (json.JSONDecodeError, TypeError):
75
+ return str(value)
76
+
77
+ def _format_tool_call_inline(self, arguments: Any) -> str:
78
+ """Format tool call arguments as JSON for inline display.
79
+
80
+ Args:
81
+ arguments: Tool call arguments (string or dict)
82
+
83
+ Returns:
84
+ Formatted JSON string
85
+ """
86
+ args_str = self._format_json_value(arguments)
87
+ return f"```json\n{args_str}\n```"
88
+
89
+ def _format_tool_result(self, message: Dict[str, Any]) -> str:
90
+ """Format a tool result for markdown display.
91
+
92
+ Args:
93
+ message: Tool result message with role, tool_call_id, content
94
+
95
+ Returns:
96
+ Formatted markdown string
97
+ """
98
+ content = message.get("content", "")
99
+
100
+ # Truncate very long outputs
101
+ if len(content) > self.MAX_CONTENT_LENGTH:
102
+ content = content[:self.MAX_CONTENT_LENGTH] + "\n\n... (truncated)"
103
+
104
+ # Try to format as code if it looks like structured output
105
+ if content and (content.startswith("{") or content.startswith("[")):
106
+ try:
107
+ parsed = json.loads(content)
108
+ content = json.dumps(parsed, indent=2, ensure_ascii=False)
109
+ return f"```\n{content}\n```\n"
110
+ except json.JSONDecodeError:
111
+ pass
112
+
113
+ return f"```\n{content}\n```\n"
114
+
115
+ def _format_message(self, message: Dict[str, Any], skip_tool_calls: bool = False) -> str:
116
+ """Convert a message dict to markdown format.
117
+
118
+ Args:
119
+ message: Message dict with role, content, tool_calls, etc.
120
+ skip_tool_calls: If True, don't include tool_calls section
121
+
122
+ Returns:
123
+ Formatted markdown string
124
+ """
125
+ role = message.get("role", "unknown")
126
+ content = message.get("content", "")
127
+
128
+ if role == "user":
129
+ emoji = "👤"
130
+ title = "User"
131
+ elif role == "assistant":
132
+ emoji = "🤖"
133
+ title = "Assistant"
134
+ elif role == "tool":
135
+ # Tool results are handled separately with their tool calls
136
+ return None
137
+ elif role == "system":
138
+ emoji = "⚙️"
139
+ title = "System"
140
+ else:
141
+ emoji = "📝"
142
+ title = role.capitalize()
143
+
144
+ md = f"\n## {emoji} {title}\n\n"
145
+
146
+ # Add content if present
147
+ if content:
148
+ md += f"{content}\n\n"
149
+
150
+ # Add tool calls if present (skip when skip_tool_calls=True)
151
+ if not skip_tool_calls and message.get("tool_calls"):
152
+ md += "### 🔧 Tool Calls\n\n"
153
+ for tc in message["tool_calls"]:
154
+ md += self._format_tool_call(tc) + "\n"
155
+
156
+ return md
157
+
158
+ def log_message(self, message: Dict[str, Any]):
159
+ """Append a message to the current markdown file.
160
+
161
+ Args:
162
+ message: Message dict with 'role' and 'content' keys, optionally 'tool_calls'
163
+ """
164
+ if not self.current_file:
165
+ self.start_session()
166
+
167
+ # Check if this is a tool result - we'll handle it differently
168
+ if message.get("role") == "tool":
169
+ # Find the associated tool call by tool_call_id
170
+ tool_call_id = message.get("tool_call_id")
171
+ formatted = f"\n### 📋 Tool Result (ID: `{tool_call_id}`)\n\n"
172
+ formatted += self._format_tool_result(message) + "\n"
173
+ else:
174
+ formatted = self._format_message(message)
175
+
176
+ if formatted:
177
+ with open(self.current_file, 'a', encoding='utf-8') as f:
178
+ f.write(formatted)
179
+
180
+ def rewrite_log(self, messages: List[Dict[str, Any]]):
181
+ """Rewrite the current markdown log to match the provided messages.
182
+
183
+ Args:
184
+ messages: Full message list to persist
185
+ """
186
+ if not self.current_file:
187
+ self.start_session()
188
+
189
+ # Rewrite entire file
190
+ with open(self.current_file, 'w', encoding='utf-8') as f:
191
+ # Write header
192
+ f.write(f"# Conversation Log\n\n")
193
+ f.write(f"**Started:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
194
+ f.write(f"**Last Updated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
195
+ f.write("---\n\n")
196
+
197
+ # Track tool calls to pair with results
198
+ pending_tool_calls = {}
199
+
200
+ for message in messages:
201
+ role = message.get("role")
202
+
203
+ if role == "assistant" and message.get("tool_calls"):
204
+ # Store tool calls for later pairing with results
205
+ for tc in message["tool_calls"]:
206
+ pending_tool_calls[tc["id"]] = tc
207
+
208
+ # Write the assistant message (skip tool_calls section)
209
+ formatted = self._format_message(message, skip_tool_calls=True)
210
+ if formatted:
211
+ f.write(formatted)
212
+
213
+ elif role == "tool":
214
+ # This is a tool result, pair it with the call
215
+ tool_call_id = message.get("tool_call_id")
216
+ tc = pending_tool_calls.get(tool_call_id)
217
+
218
+ if tc:
219
+ # Write tool call with result together
220
+ fn = tc.get("function", {})
221
+ name = fn.get("name", "unknown")
222
+ arguments = fn.get("arguments", "{}")
223
+
224
+ f.write(f"\n### 🔧 Tool Call: {name}\n\n")
225
+ f.write(self._format_tool_call_inline(arguments) + "\n\n")
226
+ f.write(f"**Result:**\n\n")
227
+ else:
228
+ # Orphaned result (no matching call)
229
+ f.write(f"\n### 🔧 Tool Result (ID: `{tool_call_id}`)\n\n")
230
+
231
+ f.write(self._format_tool_result(message) + "\n")
232
+
233
+ else:
234
+ # Regular message (user, system)
235
+ formatted = self._format_message(message)
236
+ if formatted:
237
+ f.write(formatted)
238
+
239
+ # Write footer
240
+ f.write("\n---\n\n")
241
+ f.write(f"*End of conversation*\n")
242
+
243
+ def end_session(self):
244
+ """End the current conversation logging session."""
245
+ if not self.current_file:
246
+ return
247
+
248
+ # Add footer
249
+ with open(self.current_file, 'a', encoding='utf-8') as f:
250
+ f.write("\n---\n\n")
251
+ f.write(f"**Ended:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
252
+
253
+ logger.info(f"Ended markdown conversation session: {self.current_file.name}")
254
+ self.current_file = None
@@ -0,0 +1,32 @@
1
+ """Markdown preprocessing utilities for vmCode."""
2
+
3
+ import re
4
+
5
+
6
+ def left_align_headings(markdown: str) -> str:
7
+ """Convert markdown headings to bold text to avoid Rich's centering.
8
+
9
+ Rich's Markdown renderer centers headings by default. This function
10
+ converts them to bold text for consistent left alignment.
11
+
12
+ Args:
13
+ markdown: Raw markdown string
14
+
15
+ Returns:
16
+ Markdown string with headings converted to bold
17
+ """
18
+ # Replace headings with bold text
19
+ # h1-h6 with optional leading whitespace
20
+ patterns = [
21
+ (r'^###### (.+)$', r'******\1******'), # h6 -> bold
22
+ (r'^##### (.+)$', r'*****\1*****'), # h5 -> bold
23
+ (r'^#### (.+)$', r'****\1****'), # h4 -> bold
24
+ (r'^### (.+)$', r'***\1***'), # h3 -> bold
25
+ (r'^## (.+)$', r'**\1**'), # h2 -> bold
26
+ (r'^# (.+)$', r'**\1**'), # h1 -> bold
27
+ ]
28
+
29
+ for pattern, replacement in patterns:
30
+ markdown = re.sub(pattern, replacement, markdown, flags=re.MULTILINE)
31
+
32
+ return markdown
@@ -0,0 +1,94 @@
1
+ """Centralized configuration for vmCode."""
2
+ from dataclasses import dataclass, field
3
+ from typing import Set
4
+
5
+ # Load config from llm.config
6
+ import sys
7
+ from pathlib import Path
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
9
+ from llm.config import _CONFIG
10
+
11
+ # Styles and themes
12
+ from pygments.styles.monokai import MonokaiStyle
13
+
14
+
15
+ class MonokaiDarkBGStyle(MonokaiStyle):
16
+ """Monokai style with dark background for code highlighting."""
17
+ background_color = "#141414"
18
+
19
+
20
+ @dataclass
21
+ class ServerSettings:
22
+ """Local llama-server configuration."""
23
+ ngl_layers: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("ngl_layers", 30))
24
+ ctx_size: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("ctx_size", 8192))
25
+ n_predict: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("n_predict", 8192))
26
+ rope_scale: float = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("rope_scale", 1.0))
27
+ health_check_timeout_sec: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("health_check_timeout_sec", 120))
28
+ health_check_interval_sec: float = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("health_check_interval_sec", 1.0))
29
+
30
+
31
+ @dataclass
32
+ class ToolSettings:
33
+ """Tool execution limits and defaults."""
34
+ max_tool_calls: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_tool_calls", 100))
35
+ command_timeout_sec: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("command_timeout_sec", 30))
36
+ enable_parallel_execution: bool = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("enable_parallel_execution", True))
37
+ max_parallel_workers: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_parallel_workers", 10))
38
+ max_command_output_lines: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_command_output_lines", 100))
39
+ max_file_preview_lines: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_file_preview_lines", 200))
40
+
41
+
42
+ @dataclass
43
+ class FileSettings:
44
+ """File scanning and reading limits."""
45
+ max_file_bytes: int = field(default_factory=lambda: _CONFIG.get("FILE_SETTINGS", {}).get("max_file_bytes", 200_000))
46
+ max_total_bytes: int = field(default_factory=lambda: _CONFIG.get("FILE_SETTINGS", {}).get("max_total_bytes", 1_500_000))
47
+ exclude_dirs: Set[str] = None
48
+
49
+ def __post_init__(self):
50
+ if self.exclude_dirs is None:
51
+ config_exclude = _CONFIG.get("FILE_SETTINGS", {}).get("exclude_dirs")
52
+ if config_exclude:
53
+ self.exclude_dirs = set(config_exclude)
54
+ else:
55
+ self.exclude_dirs = {".git", ".venv", "llama.cpp", "bin", "__pycache__"}
56
+
57
+
58
+ @dataclass
59
+ class ToolCompactionSettings:
60
+ """Per-message tool result compaction settings."""
61
+ enable_per_message_compaction: bool = field(default_factory=lambda: _CONFIG.get("CONTEXT_SETTINGS", {}).get("tool_compaction", {}).get("enable_per_message_compaction", True))
62
+ keep_recent_tool_blocks: int = field(default_factory=lambda: _CONFIG.get("CONTEXT_SETTINGS", {}).get("tool_compaction", {}).get("keep_recent_tool_blocks", 3))
63
+ compact_failed_tools: bool = field(default_factory=lambda: _CONFIG.get("CONTEXT_SETTINGS", {}).get("tool_compaction", {}).get("compact_failed_tools", True))
64
+
65
+
66
+ @dataclass
67
+ class SubAgentSettings:
68
+ """Sub-agent token limits and behavior configuration."""
69
+ soft_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("soft_limit_tokens", 75_000))
70
+ hard_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("hard_limit_tokens", 300_000))
71
+ enable_compaction: bool = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("enable_compaction", False))
72
+
73
+
74
+ # Context compaction settings
75
+ @dataclass
76
+ class ContextSettings:
77
+ """Context compaction thresholds and defaults."""
78
+ compact_trigger_tokens: int = field(default_factory=lambda: _CONFIG.get("CONTEXT_SETTINGS", {}).get("compact_trigger_tokens", 100_000))
79
+ log_conversations: bool = field(default_factory=lambda: _CONFIG.get("CONTEXT_SETTINGS", {}).get("log_conversations", False))
80
+ conversations_dir: str = field(default_factory=lambda: _CONFIG.get("CONTEXT_SETTINGS", {}).get("conversations_dir", "conversations"))
81
+ tool_compaction: ToolCompactionSettings = field(default_factory=ToolCompactionSettings)
82
+
83
+
84
+ # Global instances
85
+ server_settings = ServerSettings()
86
+ tool_settings = ToolSettings()
87
+ file_settings = FileSettings()
88
+ context_settings = ContextSettings()
89
+ sub_agent_settings = SubAgentSettings()
90
+
91
+ # Tool execution constants
92
+ MAX_TOOL_CALLS = tool_settings.max_tool_calls
93
+ MAX_COMMAND_OUTPUT_LINES = tool_settings.max_command_output_lines
94
+ MAX_FILE_PREVIEW_LINES = tool_settings.max_file_preview_lines