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.
- package/INSTALLATION_METHODS.md +181 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/npm-wrapper.js +171 -0
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +159 -0
- package/package.json +42 -0
- package/requirements.txt +7 -0
- package/scripts/install.js +132 -0
- package/setup.bat +114 -0
- package/setup.sh +135 -0
- package/src/__init__.py +4 -0
- package/src/core/__init__.py +1 -0
- package/src/core/agentic.py +2342 -0
- package/src/core/chat_manager.py +1201 -0
- package/src/core/config_manager.py +269 -0
- package/src/core/init.py +161 -0
- package/src/core/sub_agent.py +174 -0
- package/src/exceptions.py +75 -0
- package/src/llm/__init__.py +1 -0
- package/src/llm/client.py +149 -0
- package/src/llm/config.py +445 -0
- package/src/llm/prompts.py +569 -0
- package/src/llm/providers.py +402 -0
- package/src/llm/token_tracker.py +220 -0
- package/src/ui/__init__.py +1 -0
- package/src/ui/banner.py +103 -0
- package/src/ui/commands.py +489 -0
- package/src/ui/displays.py +167 -0
- package/src/ui/main.py +351 -0
- package/src/ui/prompt_utils.py +162 -0
- package/src/utils/__init__.py +1 -0
- package/src/utils/editor.py +158 -0
- package/src/utils/gitignore_filter.py +149 -0
- package/src/utils/logger.py +254 -0
- package/src/utils/markdown.py +32 -0
- package/src/utils/settings.py +94 -0
- package/src/utils/tools/__init__.py +55 -0
- package/src/utils/tools/command_executor.py +217 -0
- package/src/utils/tools/create_file.py +143 -0
- package/src/utils/tools/definitions.py +193 -0
- package/src/utils/tools/directory.py +374 -0
- package/src/utils/tools/file_editor.py +345 -0
- package/src/utils/tools/file_helpers.py +109 -0
- package/src/utils/tools/file_reader.py +331 -0
- package/src/utils/tools/formatters.py +458 -0
- package/src/utils/tools/parallel_executor.py +195 -0
- package/src/utils/validation.py +117 -0
- package/src/utils/web_search.py +71 -0
- package/vmcode-proxy/.env.example +5 -0
- package/vmcode-proxy/README.md +235 -0
- package/vmcode-proxy/package-lock.json +947 -0
- package/vmcode-proxy/package.json +20 -0
- package/vmcode-proxy/server.js +248 -0
- 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
|