R-Kentaren commited on
Commit
24ceec5
Β·
verified Β·
1 Parent(s): 82b44e2

SoniCoder v2: Optimized code agent with Gemini CLI / Claude Code patterns, MCP support, multiple 1B models

Browse files
app.py CHANGED
@@ -1,40 +1,97 @@
1
- """SoniCoder β€” entry point.
2
-
3
- Uses MiniCPM5-1B (text) or MiniCPM-V-4.6 (vision+text) for local inference.
4
- No external APIs required.
5
- Supports generating fullstack applications in any language.
6
- Can push generated projects to HuggingFace Hub.
7
- Web search via Google scraping (no API keys needed).
8
- Gradio app support for Python.
9
- Image understanding with MiniCPM-V-4.6.
10
-
11
- Project structure:
12
- code/
13
- β”œβ”€β”€ config/constants.py App constants, model configs, system prompt
14
- β”œβ”€β”€ model/loader.py Dual model loading & switching
15
- β”œβ”€β”€ model/inference.py Streaming inference (text + VLM)
16
- β”œβ”€β”€ execution/code_extractor.py Code extraction & language normalization
17
- β”œβ”€β”€ execution/python_runner.py Sandboxed Python execution
18
- β”œβ”€β”€ execution/gradio_runner.py Gradio app subprocess runner
19
- β”œβ”€β”€ websearch/google_scraper.py Web search scraping (no API)
20
- β”œβ”€β”€ huggingface/push.py HuggingFace Hub push & ZIP packaging
21
- β”œβ”€β”€ server/chat_helpers.py Chat history & prompt building
22
- └── server/routes.py FastAPI / Gradio server routes
23
  """
24
 
25
  from __future__ import annotations
26
 
 
27
  import logging
 
28
 
29
- from code.model.loader import start_background_load
30
- from code.server.routes import get_app
 
 
31
 
32
- logging.basicConfig(level=logging.INFO)
 
 
 
 
33
  logger = logging.getLogger(__name__)
34
 
35
- # Start loading default model in background
36
- start_background_load()
37
 
38
- # Launch the server
39
- application = get_app()
40
- application.launch(show_error=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SoniCoder v2 β€” Entry point.
2
+
3
+ Optimized AI code writer agent with:
4
+ - Event-driven agent protocol (from Gemini CLI)
5
+ - ToolBuilder/ToolInvocation pattern (from Gemini CLI)
6
+ - MCP support (from both Gemini CLI & Claude Code)
7
+ - Multiple smart 1B models (Qwen2.5-Coder-1.5B, DeepSeek-Coder-1.3B, etc.)
8
+ - Policy/permission engine (from Gemini CLI)
9
+ - Context management
10
+ - Professional web UI
11
+
12
+ Usage:
13
+ python app.py
 
 
 
 
 
 
 
 
 
14
  """
15
 
16
  from __future__ import annotations
17
 
18
+ import json
19
  import logging
20
+ from pathlib import Path
21
 
22
+ # Ensure workspace exists
23
+ from sonicoder.config import WORKSPACE_ROOT, CONFIG_DIR
24
+ WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
25
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
26
 
27
+ logging.basicConfig(
28
+ level=logging.INFO,
29
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
30
+ datefmt="%H:%M:%S",
31
+ )
32
  logger = logging.getLogger(__name__)
33
 
 
 
34
 
35
+ def main():
36
+ logger.info("SoniCoder v2 starting...")
37
+
38
+ # Start loading default model in background
39
+ from sonicoder.model.loader import start_background_load
40
+ start_background_load()
41
+ logger.info("Background model loading started")
42
+
43
+ # Set up MCP servers from config
44
+ from sonicoder.mcp import get_mcp_manager
45
+ from sonicoder.config import load_settings
46
+ settings = load_settings()
47
+ if settings.mcp_servers:
48
+ mcp = get_mcp_manager()
49
+ mcp.configure(settings.mcp_servers)
50
+ results = mcp.connect_all()
51
+ for name, error in results.items():
52
+ if error:
53
+ logger.warning(f"MCP server '{name}' failed: {error}")
54
+ else:
55
+ logger.info(f"MCP server '{name}' connected")
56
+
57
+ # Create the Gradio app
58
+ from sonicoder.server import get_app, api_get_config
59
+ application = get_app()
60
+
61
+ # Inject runtime config into index.html and serve via custom route
62
+ config_json = api_get_config()
63
+ config_data = json.loads(config_json)
64
+ config_str = json.dumps(config_data, ensure_ascii=False)
65
+
66
+ index_path = Path(__file__).parent / "static" / "index.html"
67
+ if index_path.exists():
68
+ html = index_path.read_text(encoding="utf-8")
69
+ html = html.replace(
70
+ "__RUNTIME_CONFIG__ || {}",
71
+ config_str + " || {}"
72
+ )
73
+
74
+ # Register a custom root route on the underlying FastAPI app
75
+ from fastapi import Response
76
+ from fastapi.responses import HTMLResponse
77
+
78
+ if hasattr(application, "app") and hasattr(application.app, "get"):
79
+ # Gradio 4.x: application.app is the FastAPI app (or Starlette)
80
+ fastapi_app = application.app
81
+ if hasattr(fastapi_app, "routes"):
82
+ @fastapi_app.get("/", response_class=HTMLResponse, include_in_schema=False)
83
+ async def custom_root():
84
+ return Response(content=html, media_type="text/html")
85
+ logger.info("Custom root route registered")
86
+
87
+ logger.info("Launching web interface on http://0.0.0.0:7860")
88
+ application.launch(
89
+ show_error=True,
90
+ server_name="0.0.0.0",
91
+ server_port=7860,
92
+ share=False,
93
+ )
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
requirements.txt CHANGED
@@ -1,15 +1,17 @@
1
- gradio[oauth]==6.14.0
 
 
 
2
  transformers>=4.45.0
3
  torch>=2.1.0
4
  accelerate>=0.25.0
5
  huggingface_hub>=0.20.0
6
- matplotlib>=3.8
7
  requests>=2.31.0
8
  beautifulsoup4>=4.12.0
9
  Pillow>=10.0
10
- torchvision>=0.16.0
11
- # New deps for agent features (most are stdlib in 3.11+)
12
- # (No new external deps required β€” agent/skills/hooks/commands use stdlib only)
13
- # `gradio[oauth]` extras pull in authlib + itsdangerous for the
14
- # /login/huggingface + /login/callback OAuth flow. Required for the
15
- # Sign-in-with-HF button to work in the Deploy tab.
 
1
+ # SoniCoder v2 β€” Optimized AI Code Agent
2
+ # Architecture inspired by Gemini CLI and Claude Code
3
+
4
+ gradio>=4.0.0
5
  transformers>=4.45.0
6
  torch>=2.1.0
7
  accelerate>=0.25.0
8
  huggingface_hub>=0.20.0
 
9
  requests>=2.31.0
10
  beautifulsoup4>=4.12.0
11
  Pillow>=10.0
12
+
13
+ # Optional: MCP support
14
+ # mcp>=1.0.0
15
+
16
+ # Optional: VLM support
17
+ # torchvision>=0.16.0
sonicoder/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SoniCoder v2 β€” Optimized AI Code Writer Agent.
2
+
3
+ Architecture inspired by Gemini CLI and Claude Code.
4
+ - Event-driven agent protocol
5
+ - ToolBuilder/ToolInvocation pattern
6
+ - MCP (Model Context Protocol) support
7
+ - Multiple smart 1B models
8
+ - Policy/permission engine
9
+ - Context window management with compression
10
+ - Plugin system
11
+ """
12
+ __version__ = "2.0.0"
sonicoder/agent/__init__.py ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent loop β€” the core agentic turn cycle.
2
+
3
+ Implements the full agent loop inspired by both Gemini CLI and Claude Code:
4
+ 1. Build context (system prompt + tool schemas + history)
5
+ 2. Call model
6
+ 3. Parse tool calls from response
7
+ 4. Check policy
8
+ 5. Execute tools
9
+ 6. Feed results back to model
10
+ 7. Repeat until done or max iterations
11
+
12
+ Tool call format: ```tool\nname\nkey: value\nkey: value\n```
13
+ This is simpler than JSON function calling and works well with 1B models.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import re
21
+ import time
22
+ from typing import Any, Generator
23
+
24
+ from ..config import Settings, get_model_config
25
+ from ..inference import call_model_stream, TOOL_CALL_RE, THINKING_BLOCK_RE
26
+ from ..tools import ToolRegistry, ToolResult, get_tool_registry
27
+ from ..tools.fs import BUILTIN_TOOLS as FS_TOOLS
28
+ from ..tools.bash import BUILTIN_TOOLS as BASH_TOOLS
29
+ from ..tools.web import BUILTIN_TOOLS as WEB_TOOLS
30
+ from ..tools.todos import BUILTIN_TOOLS as TODO_TOOLS
31
+ from .protocol import AgentProtocol, AgentEvent, AgentEventType
32
+ from ..policy import get_policy_engine, ApprovalDecision
33
+ from ..mcp import get_mcp_manager
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # ── System Prompt ──────────────────────────────────────────────────────
38
+
39
+ SYSTEM_PROMPT = """You are SoniCoder v2, an expert AI coding agent. You write clean, well-structured code and help users build projects.
40
+
41
+ ## Capabilities
42
+ - Read, write, and edit files in the workspace
43
+ - Execute shell commands (bash)
44
+ - Search the web for information
45
+ - Manage task lists
46
+ - Use MCP tools when available
47
+
48
+ ## Rules
49
+ 1. Always use tools to interact with the file system. Never guess file contents.
50
+ 2. When creating multi-file projects, write files one at a time with write_file.
51
+ 3. When editing existing files, use edit_file (search & replace) instead of rewriting.
52
+ 4. Run commands to verify your code works (e.g., python file.py, npm test).
53
+ 5. Think step-by-step. Plan before coding complex features.
54
+ 6. Use the todo tool to track multi-step tasks.
55
+ 7. If a tool fails, read the error and try to fix it.
56
+ 8. When done, summarize what you built.
57
+
58
+ ## File Output Format
59
+ You can write code in two ways:
60
+ 1. Use the write_file tool for each file (preferred)
61
+ 2. Use fenced code blocks with language tags for display
62
+
63
+ ## Tool Call Format
64
+ To use a tool, output a fenced code block with "tool" language:
65
+ ```tool
66
+ tool_name
67
+ key: value
68
+ key2: value2
69
+ ```
70
+
71
+ Example:
72
+ ```tool
73
+ write_file
74
+ path: hello.py
75
+ content: print("Hello, World!")
76
+ ```
77
+
78
+ ## Multi-File Projects
79
+ For projects with multiple files, write each file separately using write_file.
80
+ Start with the main entry point, then supporting files.
81
+
82
+ ## Best Practices
83
+ - Include error handling in your code
84
+ - Add comments for complex logic
85
+ - Use meaningful variable names
86
+ - Follow the language's conventions and idioms
87
+ - Test your code after writing it
88
+ """
89
+
90
+
91
+ # ── Simple YAML Parser (no PyYAML dependency) ──────────────────────────
92
+
93
+ def _parse_yaml_block(text: str) -> dict[str, Any]:
94
+ """Parse a simple YAML-like block into a dictionary.
95
+
96
+ Supports: key: value, key: | (block scalar), - item (lists)
97
+ """
98
+ result: dict[str, Any] = {}
99
+ current_key: str | None = None
100
+ current_lines: list[str] = []
101
+ in_block = False
102
+
103
+ for line in text.strip().splitlines():
104
+ # Check for list items
105
+ if line.strip().startswith("- "):
106
+ if in_block and current_key:
107
+ result[current_key] = "\n".join(current_lines).strip()
108
+ current_lines = []
109
+ in_block = False
110
+ item = line.strip()[2:].strip()
111
+ if current_key:
112
+ result.setdefault(current_key, [])
113
+ if isinstance(result[current_key], str):
114
+ result[current_key] = [result[current_key]]
115
+ result[current_key].append(item)
116
+ continue
117
+
118
+ # Check for block scalar
119
+ if line.endswith("|"):
120
+ current_key = line[:-1].strip().rstrip(":")
121
+ in_block = True
122
+ current_lines = []
123
+ continue
124
+
125
+ # Check for key: value
126
+ match = re.match(r"^(\w[\w\s]*?)\s*:\s*(.*)", line)
127
+ if match and not in_block:
128
+ # Save previous block
129
+ if current_key and in_block and current_lines:
130
+ result[current_key] = "\n".join(current_lines).strip()
131
+
132
+ key = match.group(1).strip()
133
+ value = match.group(2).strip()
134
+ current_key = key
135
+
136
+ # Type coercion
137
+ if value.lower() in ("true", "yes"):
138
+ result[key] = True
139
+ elif value.lower() in ("false", "no"):
140
+ result[key] = False
141
+ elif value.isdigit():
142
+ result[key] = int(value)
143
+ else:
144
+ try:
145
+ result[key] = float(value)
146
+ except ValueError:
147
+ result[key] = value
148
+ in_block = False
149
+ current_lines = []
150
+ continue
151
+
152
+ if in_block and current_key:
153
+ current_lines.append(line)
154
+
155
+ # Save remaining block
156
+ if in_block and current_key and current_lines:
157
+ result[current_key] = "\n".join(current_lines).strip()
158
+
159
+ return result
160
+
161
+
162
+ def _parse_tool_calls(text: str) -> list[dict[str, Any]]:
163
+ """Parse all tool calls from model output.
164
+
165
+ Format:
166
+ ```tool
167
+ tool_name
168
+ key: value
169
+ ```
170
+ """
171
+ calls = []
172
+ for match in TOOL_CALL_RE.finditer(text):
173
+ block = match.group(1).strip()
174
+ lines = block.split("\n", 1)
175
+
176
+ tool_name = lines[0].strip()
177
+ args_raw = lines[1] if len(lines) > 1 else ""
178
+ args = _parse_yaml_block(args_raw)
179
+
180
+ calls.append({
181
+ "tool_name": tool_name,
182
+ "args": args,
183
+ "raw": block,
184
+ })
185
+
186
+ return calls
187
+
188
+
189
+ # ── Agent Session ──────────────────────────────────────────────────────
190
+
191
+ class AgentSession(AgentProtocol):
192
+ """Full agent session with tool execution loop.
193
+
194
+ This is the main class that runs the agentic loop:
195
+ user message β†’ model β†’ tool calls β†’ execute β†’ feed back β†’ model β†’ ... β†’ done
196
+ """
197
+
198
+ def __init__(
199
+ self,
200
+ settings: Settings | None = None,
201
+ tool_registry: ToolRegistry | None = None,
202
+ ):
203
+ super().__init__()
204
+ self._settings = settings or Settings()
205
+ self._registry = tool_registry or get_tool_registry()
206
+ self._policy = get_policy_engine()
207
+ self._mcp = get_mcp_manager()
208
+ self._history: list[dict[str, str]] = []
209
+ self._total_tokens = 0
210
+ self._tool_calls = 0
211
+ self._start_time = 0.0
212
+
213
+ def run(
214
+ self,
215
+ message: str,
216
+ session_id: str = "",
217
+ image_url: str | None = None,
218
+ ) -> Generator[dict, None, None]:
219
+ """Run the agent loop. Yields event dicts for streaming to the client."""
220
+ self._start_time = time.time()
221
+ self._tool_calls = 0
222
+
223
+ # Initialize
224
+ stream_id = self.send(message)
225
+ self._emit(AgentEventType.STATUS, {"status": "starting", "message": "Starting agent..."})
226
+
227
+ # Add user message to history
228
+ self._history.append({"role": "user", "content": message})
229
+
230
+ # Build tool schemas
231
+ tool_schemas = self._build_tool_schemas()
232
+
233
+ # Build full system prompt
234
+ system_prompt = self._build_system_prompt(tool_schemas)
235
+
236
+ # Agent loop
237
+ max_iterations = self._settings.max_iterations
238
+ model_config = get_model_config(self._settings.default_model)
239
+
240
+ for iteration in range(max_iterations):
241
+ if self.is_aborted:
242
+ self._emit(AgentEventType.COMPLETE, {"reason": "aborted"})
243
+ yield {"type": "complete", "reason": "aborted"}
244
+ return
245
+
246
+ self._emit(AgentEventType.STATUS, {
247
+ "status": "thinking",
248
+ "iteration": iteration + 1,
249
+ "max_iterations": max_iterations,
250
+ "message": f"Thinking (iteration {iteration + 1}/{max_iterations})...",
251
+ })
252
+
253
+ # Call model
254
+ try:
255
+ response_text = ""
256
+ for chunk in call_model_stream(
257
+ messages=self._history,
258
+ system_prompt=system_prompt,
259
+ max_new_tokens=self._settings.max_tokens,
260
+ temperature=self._settings.temperature,
261
+ image_url=image_url,
262
+ ):
263
+ response_text += chunk
264
+ # Strip thinking blocks for display
265
+ display = THINKING_BLOCK_RE.sub("", response_text)
266
+ # Strip tool calls for display
267
+ display_clean = TOOL_CALL_RE.sub("", display).strip()
268
+ yield {"type": "streaming", "content": display_clean, "full_text": response_text}
269
+
270
+ except Exception as e:
271
+ self._emit(AgentEventType.ERROR, {"error": str(e)})
272
+ yield {"type": "error", "error": str(e)}
273
+ return
274
+
275
+ # Add assistant response to history
276
+ self._history.append({"role": "assistant", "content": response_text})
277
+
278
+ # Parse tool calls
279
+ tool_calls = _parse_tool_calls(response_text)
280
+
281
+ if not tool_calls:
282
+ # No tool calls β€” agent is done
283
+ display = THINKING_BLOCK_RE.sub("", response_text).strip()
284
+ self._emit(AgentEventType.MESSAGE, {"content": display})
285
+ self._emit(AgentEventType.COMPLETE, {"reason": "done"})
286
+ elapsed = time.time() - self._start_time
287
+ yield {
288
+ "type": "complete",
289
+ "reason": "done",
290
+ "content": display,
291
+ "iterations": iteration + 1,
292
+ "tool_calls": self._tool_calls,
293
+ "elapsed_seconds": round(elapsed, 1),
294
+ }
295
+ return
296
+
297
+ # Process tool calls
298
+ for tc in tool_calls:
299
+ if self.is_aborted:
300
+ yield {"type": "complete", "reason": "aborted"}
301
+ return
302
+
303
+ tool_name = tc["tool_name"]
304
+ args = tc["args"]
305
+
306
+ self._emit(AgentEventType.TOOL_REQUEST, {
307
+ "tool": tool_name,
308
+ "args": args,
309
+ })
310
+
311
+ yield {
312
+ "type": "tool_call",
313
+ "tool": tool_name,
314
+ "args": args,
315
+ }
316
+
317
+ # Check policy
318
+ policy_result = self._policy.check(tool_name, args)
319
+ if policy_result.decision == ApprovalDecision.DENY:
320
+ result = ToolResult(
321
+ success=False,
322
+ error=f"Blocked by policy: {policy_result.reason}",
323
+ )
324
+ else:
325
+ # Execute tool
326
+ result = self._execute_tool(tool_name, args, session_id)
327
+
328
+ self._tool_calls += 1
329
+ self._emit(AgentEventType.TOOL_RESPONSE, {
330
+ "tool": tool_name,
331
+ "result": result.to_dict(),
332
+ })
333
+
334
+ yield {
335
+ "type": "tool_result",
336
+ "tool": tool_name,
337
+ "result": result.to_dict(),
338
+ }
339
+
340
+ # Feed result back to model
341
+ result_content = result.to_json()
342
+ self._history.append({
343
+ "role": "tool",
344
+ "content": f"[{tool_name}] {result_content}",
345
+ })
346
+
347
+ # Max iterations reached
348
+ self._emit(AgentEventType.COMPLETE, {"reason": "max_iterations"})
349
+ yield {
350
+ "type": "complete",
351
+ "reason": "max_iterations",
352
+ "iterations": max_iterations,
353
+ "tool_calls": self._tool_calls,
354
+ "elapsed_seconds": round(time.time() - self._start_time, 1),
355
+ }
356
+
357
+ def _execute_tool(
358
+ self, tool_name: str, args: dict[str, Any], session_id: str
359
+ ) -> ToolResult:
360
+ """Execute a single tool call."""
361
+ # Check if it's an MCP tool
362
+ if tool_name.startswith("mcp_"):
363
+ return self._execute_mcp_tool(tool_name, args)
364
+
365
+ # Look up in registry
366
+ tool_builder = self._registry.get_tool(tool_name)
367
+ if not tool_builder:
368
+ return ToolResult(success=False, error=f"Unknown tool: {tool_name}")
369
+
370
+ # Validate
371
+ errors = tool_builder.validate(args)
372
+ if errors:
373
+ return ToolResult(success=False, error=f"Validation error: {'; '.join(errors)}")
374
+
375
+ # Create invocation
376
+ invocation = tool_builder.create_invocation(args)
377
+
378
+ # Inject session_id for todo tool
379
+ if tool_name == "todo":
380
+ invocation.session_id = session_id
381
+
382
+ # Execute
383
+ try:
384
+ return invocation.execute()
385
+ except Exception as e:
386
+ logger.error(f"Tool execution error ({tool_name}): {e}")
387
+ return ToolResult(success=False, error=f"Tool error: {e}")
388
+
389
+ def _execute_mcp_tool(self, tool_name: str, args: dict[str, Any]) -> ToolResult:
390
+ """Execute an MCP tool."""
391
+ bridge = self._mcp.get_tool(tool_name)
392
+ if not bridge:
393
+ return ToolResult(success=False, error=f"MCP tool not found: {tool_name}")
394
+
395
+ try:
396
+ result = self._mcp.call_tool(bridge.server_name, bridge.tool_name, args)
397
+ if "error" in result:
398
+ return ToolResult(success=False, error=result["error"])
399
+ return ToolResult(success=True, output=result.get("output", str(result)))
400
+ except Exception as e:
401
+ return ToolResult(success=False, error=f"MCP error: {e}")
402
+
403
+ def _build_tool_schemas(self) -> str:
404
+ """Build tool schema documentation for the system prompt."""
405
+ active = self._registry.get_active_tools()
406
+ mcp_tools = self._mcp.get_tools()
407
+
408
+ schema_parts = []
409
+ for name, tool in active.items():
410
+ schema_parts.append(f"### {name}\n{tool.description}")
411
+ if hasattr(tool, "get_schema"):
412
+ params = tool.get_schema()
413
+ props = params.get("properties", {})
414
+ required = params.get("required", [])
415
+ if props:
416
+ schema_parts.append("Parameters:")
417
+ for pname, pinfo in props.items():
418
+ req = " (required)" if pname in required else ""
419
+ ptype = pinfo.get("type", "any")
420
+ desc = pinfo.get("description", "")
421
+ schema_parts.append(f" - {pname}: {ptype}{req} β€” {desc}")
422
+ schema_parts.append("")
423
+
424
+ # Add MCP tools
425
+ for name, bridge in mcp_tools.items():
426
+ schema_parts.append(f"### {name}\n{bridge.description}")
427
+ params = bridge._schema.get("inputSchema", {}).get("properties", {})
428
+ for pname, pinfo in params.items():
429
+ desc = pinfo.get("description", "")
430
+ schema_parts.append(f" - {pname}: {desc}")
431
+ schema_parts.append("")
432
+
433
+ if not schema_parts:
434
+ return "No tools available."
435
+
436
+ return "## Available Tools\n\n" + "\n".join(schema_parts)
437
+
438
+ def _build_system_prompt(self, tool_schemas: str) -> str:
439
+ """Build the full system prompt."""
440
+ parts = [SYSTEM_PROMPT, "\n", tool_schemas]
441
+
442
+ # Add model-specific tips
443
+ model_key = self._settings.default_model
444
+ if "qwen" in model_key.lower():
445
+ parts.append(
446
+ "\n## Tips for Qwen Models\n"
447
+ "- Be explicit and structured in your responses\n"
448
+ "- Use complete file paths in tool calls\n"
449
+ "- Break complex tasks into clear steps"
450
+ )
451
+ elif "deepseek" in model_key.lower():
452
+ parts.append(
453
+ "\n## Tips for DeepSeek Models\n"
454
+ "- Think carefully before acting\n"
455
+ "- Use comments to explain complex code\n"
456
+ "- Verify code correctness with test commands"
457
+ )
458
+
459
+ return "\n".join(parts)
460
+
461
+ def clear_history(self) -> None:
462
+ """Clear conversation history."""
463
+ self._history.clear()
464
+ self._total_tokens = 0
465
+ self._tool_calls = 0
466
+
467
+
468
+ # ── Register Built-in Tools ────────────────────────────────────────────
469
+
470
+ def register_builtin_tools(registry: ToolRegistry) -> None:
471
+ """Register all built-in tools into the registry."""
472
+ for tools_module in [FS_TOOLS, BASH_TOOLS, WEB_TOOLS, TODO_TOOLS]:
473
+ for tool in tools_module:
474
+ registry.register(tool)
sonicoder/agent/protocol.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent protocol β€” event-driven architecture from Gemini CLI.
2
+
3
+ The agent is purely event-driven. All communication flows through typed events.
4
+ This is the core architectural pattern borrowed from Gemini CLI's AgentProtocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any, Callable
14
+
15
+
16
+ # ── Event Types ────────────────────────────────────────────────────────
17
+
18
+ class AgentEventType(Enum):
19
+ """All possible agent events β€” discriminated union pattern from Gemini CLI."""
20
+ INITIALIZE = "initialize"
21
+ STATUS = "status"
22
+ MESSAGE = "message" # Agent reasoning/text output
23
+ TOOL_REQUEST = "tool_request" # Agent wants to call a tool
24
+ TOOL_RESPONSE = "tool_response" # Tool result returned
25
+ STREAMING = "streaming" # Incremental token
26
+ COMPLETE = "complete" # Agent finished (success)
27
+ ERROR = "error" # Agent finished (error)
28
+ USAGE = "usage" # Token usage stats
29
+
30
+
31
+ @dataclass
32
+ class AgentEvent:
33
+ """A single event in the agent event stream."""
34
+ type: AgentEventType
35
+ data: dict[str, Any] = field(default_factory=dict)
36
+ timestamp: float = field(default_factory=time.time)
37
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "type": self.type.value,
42
+ "data": self.data,
43
+ "timestamp": self.timestamp,
44
+ "event_id": self.event_id,
45
+ }
46
+
47
+
48
+ # ── Agent Protocol Interface ───────────────────────────────────────────
49
+
50
+ class AgentProtocol:
51
+ """Event-driven agent interface.
52
+
53
+ Inspired by Gemini CLI's AgentProtocol:
54
+ - send(): Submit a user message
55
+ - subscribe(): Listen for events
56
+ - abort(): Cancel current operation
57
+ """
58
+
59
+ def __init__(self):
60
+ self._subscribers: list[Callable[[AgentEvent], None]] = []
61
+ self._events: list[AgentEvent] = []
62
+ self._aborted = False
63
+ self._stream_id: str | None = None
64
+
65
+ def send(self, message: str, **kwargs) -> str:
66
+ """Submit a user message. Returns stream_id."""
67
+ self._aborted = False
68
+ self._stream_id = str(uuid.uuid4())[:12]
69
+ self._emit(AgentEventType.INITIALIZE, {
70
+ "stream_id": self._stream_id,
71
+ "message": message,
72
+ })
73
+ return self._stream_id
74
+
75
+ def subscribe(self, callback: Callable[[AgentEvent], None]) -> Callable:
76
+ """Subscribe to events. Returns unsubscribe function."""
77
+ self._subscribers.append(callback)
78
+ def unsubscribe():
79
+ self._subscribers.remove(callback)
80
+ return unsubscribe
81
+
82
+ def abort(self) -> None:
83
+ """Cancel the current operation."""
84
+ self._aborted = True
85
+
86
+ @property
87
+ def is_aborted(self) -> bool:
88
+ return self._aborted
89
+
90
+ def _emit(self, event_type: AgentEventType, data: dict[str, Any] | None = None) -> AgentEvent:
91
+ """Emit an event to all subscribers."""
92
+ event = AgentEvent(type=event_type, data=data or {})
93
+ self._events.append(event)
94
+ for sub in self._subscribers:
95
+ try:
96
+ sub(event)
97
+ except Exception:
98
+ pass
99
+ return event
100
+
101
+ @property
102
+ def events(self) -> list[AgentEvent]:
103
+ return list(self._events)
104
+
105
+ def clear_history(self) -> None:
106
+ self._events.clear()
sonicoder/config/__init__.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration system β€” hierarchical settings like Claude Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ # ── Defaults ──────────────────────────────────────────────────────────
12
+
13
+ WORKSPACE_ROOT = Path(os.environ.get("SONICODER_WORKSPACE", "./workspace"))
14
+ CONFIG_DIR = Path(os.environ.get("SONICODER_CONFIG", ".sonicoder"))
15
+
16
+ DEFAULT_TEMPERATURE = 0.35
17
+ DEFAULT_MAX_TOKENS = 4096
18
+ DEFAULT_MAX_ITERATIONS = 12
19
+ AGENT_LOOP_TIMEOUT = 300 # seconds
20
+ BASH_TIMEOUT = 120
21
+ PYTHON_TIMEOUT = 30
22
+
23
+
24
+ @dataclass
25
+ class ModelConfig:
26
+ """Configuration for a single model."""
27
+ name: str
28
+ model_id: str
29
+ model_type: str # "text" | "vlm"
30
+ size_gb: float
31
+ description: str
32
+ dtype: str = "float16"
33
+ trust_remote_code: bool = True
34
+ device: str = "auto"
35
+
36
+
37
+ @dataclass
38
+ class MCPServerConfig:
39
+ """Configuration for an MCP server."""
40
+ name: str
41
+ transport: str # "stdio" | "sse"
42
+ command: str | None = None
43
+ args: list[str] = field(default_factory=list)
44
+ env: dict[str, str] = field(default_factory=dict)
45
+ url: str | None = None
46
+ enabled: bool = True
47
+
48
+
49
+ @dataclass
50
+ class PolicyRule:
51
+ """A policy rule for tool approval."""
52
+ tool_name: str # supports "*" wildcard
53
+ args_pattern: str | None = None
54
+ approval: str = "ask" # "allow" | "ask" | "deny"
55
+ priority: int = 0
56
+ description: str = ""
57
+
58
+
59
+ @dataclass
60
+ class Settings:
61
+ """Hierarchical settings β€” cascades from global β†’ project β†’ local."""
62
+ # Model
63
+ default_model: str = "qwen25-coder-1.5b"
64
+ temperature: float = DEFAULT_TEMPERATURE
65
+ max_tokens: int = DEFAULT_MAX_TOKENS
66
+
67
+ # Agent
68
+ max_iterations: int = DEFAULT_MAX_ITERATIONS
69
+ agent_timeout: int = AGENT_LOOP_TIMEOUT
70
+
71
+ # Tools
72
+ enabled_tools: list[str] = field(default_factory=lambda: ["*"])
73
+ disabled_tools: list[str] = field(default_factory=list)
74
+
75
+ # Safety
76
+ sandbox_enabled: bool = True
77
+ bash_timeout: int = BASH_TIMEOUT
78
+ python_timeout: int = PYTHON_TIMEOUT
79
+
80
+ # MCP
81
+ mcp_servers: list[MCPServerConfig] = field(default_factory=list)
82
+
83
+ # Policy
84
+ policy_rules: list[PolicyRule] = field(default_factory=list)
85
+
86
+ # Context
87
+ max_context_tokens: int = 32768
88
+ auto_compact: bool = True
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> Settings:
92
+ """Create settings from a dictionary, ignoring unknown fields."""
93
+ known_fields = {f.name for f in cls.__dataclass_fields__.values()}
94
+ filtered = {k: v for k, v in data.items() if k in known_fields}
95
+ return cls(**filtered)
96
+
97
+ def merge(self, other: Settings) -> Settings:
98
+ """Merge another settings object (other takes precedence for non-default values)."""
99
+ result = Settings(
100
+ default_model=other.default_model or self.default_model,
101
+ temperature=other.temperature if other.temperature != DEFAULT_TEMPERATURE else self.temperature,
102
+ max_tokens=other.max_tokens if other.max_tokens != DEFAULT_MAX_TOKENS else self.max_tokens,
103
+ max_iterations=other.max_iterations if other.max_iterations != DEFAULT_MAX_ITERATIONS else self.max_iterations,
104
+ agent_timeout=other.agent_timeout if other.agent_timeout != AGENT_LOOP_TIMEOUT else self.agent_timeout,
105
+ sandbox_enabled=other.sandbox_enabled,
106
+ bash_timeout=other.bash_timeout,
107
+ python_timeout=other.python_timeout,
108
+ auto_compact=other.auto_compact,
109
+ )
110
+ # Merge lists
111
+ result.enabled_tools = other.enabled_tools if other.enabled_tools != ["*"] else self.enabled_tools
112
+ result.disabled_tools = list(set(self.disabled_tools + other.disabled_tools))
113
+ result.mcp_servers = self.mcp_servers + other.mcp_servers
114
+ result.policy_rules = other.policy_rules + self.policy_rules
115
+ return result
116
+
117
+
118
+ # ── Model Registry ────────────────────────────────────────────────────
119
+
120
+ MODEL_REGISTRY: dict[str, ModelConfig] = {
121
+ "qwen25-coder-1.5b": ModelConfig(
122
+ name="Qwen2.5-Coder-1.5B",
123
+ model_id="Qwen/Qwen2.5-Coder-1.5B-Instruct",
124
+ model_type="text",
125
+ size_gb=2.9,
126
+ description="Best-in-class 1.5B code model. Excellent at Python, JS, TypeScript, and multi-file generation.",
127
+ ),
128
+ "deepseek-coder-1.3b": ModelConfig(
129
+ name="DeepSeek-Coder-1.3B",
130
+ model_id="deepseek-ai/deepseek-coder-1.3b-instruct",
131
+ model_type="text",
132
+ size_gb=2.6,
133
+ description="Strong code understanding and generation. Good at multi-language coding tasks.",
134
+ ),
135
+ "qwen25-1.5b": ModelConfig(
136
+ name="Qwen2.5-1.5B-Instruct",
137
+ model_id="Qwen/Qwen2.5-1.5B-Instruct",
138
+ model_type="text",
139
+ size_gb=2.9,
140
+ description="General-purpose 1.5B model. Good for reasoning, analysis, and non-code tasks.",
141
+ ),
142
+ "smolvlm-2.2b": ModelConfig(
143
+ name="SmolVLM-2.2B",
144
+ model_id="HuggingFaceTB/SmolVLM2-2.2B-Instruct",
145
+ model_type="vlm",
146
+ size_gb=4.4,
147
+ description="Vision-language model. Can understand screenshots, diagrams, and generate code from images.",
148
+ ),
149
+ "minicpm5-1b": ModelConfig(
150
+ name="MiniCPM5-1B",
151
+ model_id="openbmb/MiniCPM5-1B",
152
+ model_type="text",
153
+ size_gb=2.17,
154
+ description="Compact text model. Fast inference, decent code generation.",
155
+ ),
156
+ "tinyllama-1.1b": ModelConfig(
157
+ name="TinyLlama-1.1B-Chat",
158
+ model_id="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
159
+ model_type="text",
160
+ size_gb=2.2,
161
+ description="Ultra-compact chat model. Good for simple tasks, fastest inference.",
162
+ ),
163
+ }
164
+
165
+
166
+ def get_available_models() -> dict[str, ModelConfig]:
167
+ """Return all registered models."""
168
+ return MODEL_REGISTRY.copy()
169
+
170
+
171
+ def get_model_config(model_key: str) -> ModelConfig | None:
172
+ """Get a model config by key."""
173
+ return MODEL_REGISTRY.get(model_key)
174
+
175
+
176
+ def load_settings(project_dir: Path | None = None) -> Settings:
177
+ """Load settings with hierarchical cascading.
178
+
179
+ Priority (highest β†’ lowest):
180
+ 1. Environment variables
181
+ 2. .sonicoder/settings.local.json (local overrides, gitignored)
182
+ 3. .sonicoder/settings.json (project settings)
183
+ 4. Defaults
184
+ """
185
+ settings = Settings()
186
+
187
+ search_dirs = [Path.cwd()]
188
+ if project_dir:
189
+ search_dirs.insert(0, project_dir)
190
+
191
+ # Load project-level settings
192
+ for d in search_dirs:
193
+ local_path = d / CONFIG_DIR / "settings.local.json"
194
+ proj_path = d / CONFIG_DIR / "settings.json"
195
+
196
+ for path in [local_path, proj_path]:
197
+ if path.exists():
198
+ try:
199
+ data = json.loads(path.read_text(encoding="utf-8"))
200
+ # Parse MCP server configs
201
+ if "mcp_servers" in data:
202
+ data["mcp_servers"] = [
203
+ MCPServerConfig(**s) for s in data["mcp_servers"]
204
+ ]
205
+ if "policy_rules" in data:
206
+ data["policy_rules"] = [
207
+ PolicyRule(**r) for r in data["policy_rules"]
208
+ ]
209
+ settings = settings.merge(Settings.from_dict(data))
210
+ except (json.JSONDecodeError, TypeError) as e:
211
+ print(f"[config] Warning: Failed to parse {path}: {e}")
212
+
213
+ # Environment variable overrides
214
+ env_model = os.environ.get("SONICODER_MODEL")
215
+ if env_model and env_model in MODEL_REGISTRY:
216
+ settings.default_model = env_model
217
+
218
+ env_temp = os.environ.get("SONICODER_TEMPERATURE")
219
+ if env_temp:
220
+ try:
221
+ settings.temperature = float(env_temp)
222
+ except ValueError:
223
+ pass
224
+
225
+ return settings
sonicoder/context/__init__.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Context window manager β€” conversation history with compression.
2
+
3
+ Inspired by Gemini CLI's graph-based context pipeline and Claude Code's auto-compaction.
4
+ For 1B models, context window is small (~32K tokens), so compression is critical.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class ContextNode:
18
+ """A single node in the context graph."""
19
+ role: str # "user" | "assistant" | "tool" | "system"
20
+ content: str
21
+ tokens: int = 0
22
+ compressed: bool = False
23
+ protected: bool = False # Can't be GC'd
24
+ turn_index: int = 0
25
+
26
+
27
+ class ContextManager:
28
+ """Manages conversation context within a token budget.
29
+
30
+ Strategies (inspired by both Gemini CLI and Claude Code):
31
+ 1. Clear old tool outputs first (biggest token consumers)
32
+ 2. Compress old conversation turns
33
+ 3. Protect recent turns and system messages
34
+ 4. Lazy tool schema loading (deferred definitions like Claude Code)
35
+ """
36
+
37
+ def __init__(self, max_tokens: int = 32768, auto_compact: bool = True):
38
+ self.max_tokens = max_tokens
39
+ self.auto_compact = auto_compact
40
+ self._nodes: list[ContextNode] = []
41
+ self._turn_count = 0
42
+
43
+ def add_message(self, role: str, content: str, protected: bool = False) -> None:
44
+ """Add a message to the context."""
45
+ tokens = self._estimate_tokens(content)
46
+ node = ContextNode(
47
+ role=role,
48
+ content=content,
49
+ tokens=tokens,
50
+ protected=protected,
51
+ turn_index=self._turn_count,
52
+ )
53
+ self._nodes.append(node)
54
+ if role == "user":
55
+ self._turn_count += 1
56
+
57
+ def get_messages(self) -> list[dict[str, str]]:
58
+ """Get all messages for the LLM, with auto-compaction if needed."""
59
+ if self.auto_compact:
60
+ self._maybe_compact()
61
+
62
+ return [
63
+ {"role": n.role, "content": n.content}
64
+ for n in self._nodes
65
+ if n.content # Skip empty nodes
66
+ ]
67
+
68
+ def total_tokens(self) -> int:
69
+ """Estimate total tokens in context."""
70
+ return sum(n.tokens for n in self._nodes)
71
+
72
+ def clear(self) -> None:
73
+ """Clear all context."""
74
+ self._nodes.clear()
75
+ self._turn_count = 0
76
+
77
+ def _maybe_compact(self) -> None:
78
+ """Auto-compact if over budget."""
79
+ total = self.total_tokens()
80
+ if total <= self.max_tokens:
81
+ return
82
+
83
+ logger.info(f"Context over budget ({total} > {self.max_tokens}). Compacting...")
84
+
85
+ # Phase 1: Remove old tool results (biggest savings)
86
+ budget_remaining = self.max_tokens * 0.8 # Target 80% utilization
87
+ for node in self._nodes:
88
+ if self.total_tokens() <= budget_remaining:
89
+ break
90
+ if node.role == "tool" and not node.protected:
91
+ # Compress tool output
92
+ if len(node.content) > 200:
93
+ node.content = node.content[:200] + f"\n... [truncated, was {len(node.content)} chars]"
94
+ node.tokens = self._estimate_tokens(node.content)
95
+ node.compressed = True
96
+
97
+ # Phase 2: Summarize old conversation turns
98
+ if self.total_tokens() > self.max_tokens:
99
+ # Find oldest non-protected assistant/user pairs to compress
100
+ pairs = self._find_compactable_pairs()
101
+ for user_node, assistant_node in pairs:
102
+ if self.total_tokens() <= budget_remaining:
103
+ break
104
+ # Replace with summary
105
+ summary = self._summarize_pair(user_node, assistant_node)
106
+ user_node.content = summary
107
+ user_node.tokens = self._estimate_tokens(summary)
108
+ user_node.compressed = True
109
+ # Remove assistant node
110
+ self._nodes.remove(assistant_node)
111
+
112
+ # Phase 3: If still over budget, remove oldest non-protected nodes
113
+ if self.total_tokens() > self.max_tokens:
114
+ to_remove = []
115
+ for node in self._nodes:
116
+ if not node.protected and not node.compressed:
117
+ to_remove.append(node)
118
+ if self.total_tokens() - sum(n.tokens for n in to_remove) <= budget_remaining:
119
+ break
120
+ for node in to_remove:
121
+ self._nodes.remove(node)
122
+
123
+ logger.info(f"Compaction done. Context now ~{self.total_tokens()} tokens")
124
+
125
+ def _find_compactable_pairs(self) -> list[tuple[ContextNode, ContextNode]]:
126
+ """Find user+assistant pairs that can be compressed."""
127
+ pairs = []
128
+ i = 0
129
+ while i < len(self._nodes) - 1:
130
+ if (self._nodes[i].role == "user" and
131
+ self._nodes[i + 1].role == "assistant" and
132
+ not self._nodes[i].protected):
133
+ pairs.append((self._nodes[i], self._nodes[i + 1]))
134
+ i += 2
135
+ else:
136
+ i += 1
137
+ return pairs
138
+
139
+ def _summarize_pair(self, user: ContextNode, assistant: ContextNode) -> str:
140
+ """Create a brief summary of a conversation pair."""
141
+ u_preview = user.content[:100].replace("\n", " ")
142
+ a_preview = assistant.content[:150].replace("\n", " ")
143
+ return f"[Earlier: User asked about '{u_preview}...' and assistant responded with '{a_preview}...']"
144
+
145
+ @staticmethod
146
+ def _estimate_tokens(text: str) -> int:
147
+ """Rough token estimation (1 token ~ 4 chars for code, ~3.5 for English)."""
148
+ if not text:
149
+ return 0
150
+ # Detect if mostly code
151
+ code_chars = sum(1 for c in text if c in " \t{}[]();:=<>+-*/!&|\\")
152
+ ratio = code_chars / max(len(text), 1)
153
+ chars_per_token = 3.5 + ratio * 1.5 # 3.5 for English, 5 for code
154
+ return int(len(text) / chars_per_token)
sonicoder/execution/__init__.py ADDED
File without changes
sonicoder/mcp/__init__.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP (Model Context Protocol) support β€” full client implementation.
2
+
3
+ Architecture from Gemini CLI's McpClientManager:
4
+ - Manages lifecycle of multiple MCP servers
5
+ - Bridges MCP tools into the ToolRegistry as ToolBuilder instances
6
+ - Supports stdio and SSE transports
7
+ - Lazy initialization (connects on first use)
8
+
9
+ Based on the MCP Python SDK.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ import threading
18
+ from typing import Any, Callable
19
+
20
+ from ..config import MCPServerConfig
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ── MCP Tool Bridge ────────────────────────────────────────────────────
26
+
27
+ class MCPToolBridge:
28
+ """Wraps an MCP tool as a ToolBuilder, making it indistinguishable from built-in tools.
29
+
30
+ This is the key insight from Gemini CLI: MCP tools use the same ToolBuilder
31
+ interface so they get the same policy, confirmation, and scheduling treatment.
32
+ """
33
+
34
+ def __init__(self, server_name: str, tool_name: str, tool_schema: dict, call_fn: Callable):
35
+ self.server_name = server_name
36
+ self.tool_name = tool_name
37
+ self._schema = tool_schema
38
+ self._call_fn = call_fn
39
+
40
+ # Generate MCP-prefixed name
41
+ self.name = f"mcp_{server_name}_{tool_name}"
42
+ self.description = tool_schema.get("description", f"MCP tool: {tool_name}")
43
+ self.kind = "other"
44
+ self.is_read_only = tool_schema.get("annotations", {}).get("readOnlyHint", False)
45
+
46
+ def get_tool_definition(self) -> dict[str, Any]:
47
+ return {
48
+ "name": self.name,
49
+ "description": self.description,
50
+ "parameters": self._schema.get("inputSchema", {"type": "object", "properties": {}}),
51
+ "kind": self.kind,
52
+ "mcp_server": self.server_name,
53
+ "mcp_tool": self.tool_name,
54
+ }
55
+
56
+ def call(self, arguments: dict[str, Any]) -> dict[str, Any]:
57
+ """Execute the MCP tool call."""
58
+ return self._call_fn(self.tool_name, arguments)
59
+
60
+
61
+ # ── MCP Client Manager ─────────────────────────────────────────────────
62
+
63
+ class MCPClientManager:
64
+ """Manages multiple MCP server connections.
65
+
66
+ Lifecycle: configure β†’ connect (lazy) β†’ use β†’ disconnect
67
+ """
68
+
69
+ def __init__(self):
70
+ self._configs: dict[str, MCPServerConfig] = {}
71
+ self._clients: dict[str, Any] = {} # server_name β†’ MCP client/session
72
+ self._tools: dict[str, MCPToolBridge] = {}
73
+ self._connected: set[str] = set()
74
+ self._lock = threading.Lock()
75
+
76
+ def configure(self, configs: list[MCPServerConfig]) -> None:
77
+ """Set MCP server configurations."""
78
+ self._configs = {c.name: c for c in configs if c.enabled}
79
+
80
+ def add_server(self, config: MCPServerConfig) -> None:
81
+ """Add or update a single MCP server config."""
82
+ self._configs[config.name] = config
83
+ # If already connected to this server, disconnect and reconnect
84
+ if config.name in self._connected:
85
+ self._disconnect(config.name)
86
+
87
+ def remove_server(self, name: str) -> None:
88
+ """Remove an MCP server."""
89
+ self._configs.pop(name, None)
90
+ self._disconnect(name)
91
+
92
+ def connect_all(self) -> dict[str, str | None]:
93
+ """Connect to all configured MCP servers. Returns {name: error_or_None}."""
94
+ results = {}
95
+ for name, config in self._configs.items():
96
+ if config.enabled:
97
+ try:
98
+ error = self._connect(name, config)
99
+ results[name] = error
100
+ except Exception as e:
101
+ results[name] = str(e)
102
+ logger.error(f"MCP connect failed for {name}: {e}")
103
+ return results
104
+
105
+ def disconnect_all(self) -> None:
106
+ """Disconnect all MCP servers."""
107
+ for name in list(self._connected):
108
+ self._disconnect(name)
109
+
110
+ def get_tools(self) -> dict[str, MCPToolBridge]:
111
+ """Get all discovered MCP tools."""
112
+ return dict(self._tools)
113
+
114
+ def get_tool(self, name: str) -> MCPToolBridge | None:
115
+ """Get a specific MCP tool by its full name (mcp_server_tool)."""
116
+ return self._tools.get(name)
117
+
118
+ def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
119
+ """Call an MCP tool on a specific server."""
120
+ client = self._clients.get(server_name)
121
+ if not client:
122
+ # Try lazy connect
123
+ config = self._configs.get(server_name)
124
+ if not config:
125
+ return {"error": f"MCP server '{server_name}' not configured"}
126
+ error = self._connect(server_name, config)
127
+ if error:
128
+ return {"error": f"Failed to connect to '{server_name}': {error}"}
129
+ client = self._clients.get(server_name)
130
+ if not client:
131
+ return {"error": f"MCP server '{server_name}' connection failed"}
132
+
133
+ try:
134
+ if hasattr(client, "call_tool"):
135
+ result = client.call_tool(tool_name, arguments)
136
+ if hasattr(result, "content"):
137
+ # Extract text content from MCP result
138
+ texts = []
139
+ for item in result.content:
140
+ if hasattr(item, "text"):
141
+ texts.append(item.text)
142
+ else:
143
+ texts.append(str(item))
144
+ return {"output": "\n".join(texts)}
145
+ return {"output": str(result)}
146
+ return {"output": str(client)}
147
+ except Exception as e:
148
+ return {"error": f"MCP tool call failed: {e}"}
149
+
150
+ def list_resources(self, server_name: str | None = None) -> list[dict]:
151
+ """List available MCP resources."""
152
+ resources = []
153
+ servers = [server_name] if server_name else list(self._connected)
154
+ for name in servers:
155
+ client = self._clients.get(name)
156
+ if client and hasattr(client, "list_resources"):
157
+ try:
158
+ result = client.list_resources()
159
+ for r in (result.resources if hasattr(result, "resources") else []):
160
+ resources.append({
161
+ "server": name,
162
+ "uri": getattr(r, "uri", str(r)),
163
+ "name": getattr(r, "name", ""),
164
+ "description": getattr(r, "description", ""),
165
+ })
166
+ except Exception as e:
167
+ logger.warning(f"Failed to list resources from {name}: {e}")
168
+ return resources
169
+
170
+ def get_status(self) -> dict[str, Any]:
171
+ """Get status of all MCP servers."""
172
+ status = {}
173
+ for name, config in self._configs.items():
174
+ status[name] = {
175
+ "enabled": config.enabled,
176
+ "connected": name in self._connected,
177
+ "transport": config.transport,
178
+ "tool_count": sum(1 for t in self._tools.values() if t.server_name == name),
179
+ }
180
+ return status
181
+
182
+ # ── Internal ───────────────────────────────────────────────────────
183
+
184
+ def _connect(self, name: str, config: MCPServerConfig) -> str | None:
185
+ """Connect to a single MCP server. Returns error message or None."""
186
+ if name in self._connected:
187
+ return None
188
+
189
+ try:
190
+ # Try to import MCP SDK
191
+ try:
192
+ from mcp import ClientSession, StdioServerParameters
193
+ from mcp.client.stdio import stdio_client
194
+ except ImportError:
195
+ logger.info("MCP SDK not installed. Skipping MCP server %s", name)
196
+ return "mcp SDK not installed (pip install mcp)"
197
+
198
+ if config.transport == "stdio":
199
+ server_params = StdioServerParameters(
200
+ command=config.command or "",
201
+ args=config.args,
202
+ env=config.env or None,
203
+ )
204
+
205
+ # Run async connect in a new thread with event loop
206
+ client_session, error = self._run_async_connect(name, server_params)
207
+ if error or not client_session:
208
+ return error or f"Failed to connect to {name}"
209
+
210
+ self._clients[name] = client_session
211
+ self._connected.add(name)
212
+
213
+ # Discover tools
214
+ self._discover_tools(name, client_session)
215
+ logger.info(f"MCP server '{name}' connected with {sum(1 for t in self._tools.values() if t.server_name == name)} tools")
216
+ return None
217
+
218
+ elif config.transport == "sse":
219
+ return f"SSE transport not yet supported for {name}"
220
+
221
+ return f"Unknown transport: {config.transport}"
222
+
223
+ except Exception as e:
224
+ logger.error(f"MCP connect error for {name}: {e}")
225
+ return str(e)
226
+
227
+ def _run_async_connect(self, name: str, server_params):
228
+ """Run async MCP connection in a sync context."""
229
+ from mcp import ClientSession, StdioServerParameters
230
+ from mcp.client.stdio import stdio_client
231
+
232
+ result = {"session": None, "error": None}
233
+
234
+ async def _connect_async():
235
+ try:
236
+ async with stdio_client(server_params) as (read, write):
237
+ async with ClientSession(read, write) as session:
238
+ await session.initialize()
239
+ result["session"] = _AsyncSessionWrapper(session)
240
+ # Keep the context managers alive by blocking
241
+ await asyncio.Future() # Block forever
242
+ except Exception as e:
243
+ result["error"] = str(e)
244
+
245
+ loop = asyncio.new_event_loop()
246
+ thread = threading.Thread(target=loop.run_until_complete, args=(_connect_async(),), daemon=True)
247
+ thread.start()
248
+ thread.join(timeout=10)
249
+
250
+ if result["error"] and not result["session"]:
251
+ return None, result["error"]
252
+ if result["session"]:
253
+ return result["session"], None
254
+ return None, "Connection timeout"
255
+
256
+ def _discover_tools(self, server_name: str, client) -> None:
257
+ """Discover and register tools from an MCP server."""
258
+ # Remove old tools for this server
259
+ old_prefix = f"mcp_{server_name}_"
260
+ for k in list(self._tools.keys()):
261
+ if k.startswith(old_prefix):
262
+ del self._tools[k]
263
+
264
+ # Discover new tools (sync wrapper)
265
+ try:
266
+ tools_result = client.list_tools() if hasattr(client, "list_tools") else None
267
+ tools = tools_result.tools if hasattr(tools_result, "tools") else []
268
+
269
+ for tool in tools:
270
+ schema = {
271
+ "description": getattr(tool, "description", ""),
272
+ "inputSchema": getattr(tool, "inputSchema", {"type": "object", "properties": {}}),
273
+ "annotations": getattr(tool, "annotations", {}),
274
+ }
275
+ tool_name = getattr(tool, "name", "unknown")
276
+
277
+ bridge = MCPToolBridge(
278
+ server_name=server_name,
279
+ tool_name=tool_name,
280
+ tool_schema=schema,
281
+ call_fn=lambda sn=server_name, tn=tool_name, args=None: self.call_tool(sn, tn, args or {}),
282
+ )
283
+ self._tools[bridge.name] = bridge
284
+ logger.debug(f"Discovered MCP tool: {bridge.name}")
285
+
286
+ except Exception as e:
287
+ logger.warning(f"Failed to discover tools from {server_name}: {e}")
288
+
289
+ def _disconnect(self, name: str) -> None:
290
+ """Disconnect from an MCP server."""
291
+ self._clients.pop(name, None)
292
+ self._connected.discard(name)
293
+ # Remove tools
294
+ prefix = f"mcp_{name}_"
295
+ for k in list(self._tools.keys()):
296
+ if k.startswith(prefix):
297
+ del self._tools[k]
298
+
299
+
300
+ class _AsyncSessionWrapper:
301
+ """Wraps an MCP ClientSession for sync access."""
302
+
303
+ def __init__(self, session):
304
+ self._session = session
305
+
306
+ def __getattr__(self, name):
307
+ attr = getattr(self._session, name)
308
+ if asyncio.iscoroutinefunction(attr):
309
+ def sync_wrapper(*args, **kwargs):
310
+ try:
311
+ loop = asyncio.get_event_loop()
312
+ if loop.is_running():
313
+ # Can't await in running loop, return a placeholder
314
+ import concurrent.futures
315
+ with concurrent.futures.ThreadPoolExecutor() as pool:
316
+ future = pool.submit(asyncio.run, attr(*args, **kwargs))
317
+ return future.result(timeout=30)
318
+ else:
319
+ return loop.run_until_complete(attr(*args, **kwargs))
320
+ except RuntimeError:
321
+ return asyncio.run(attr(*args, **kwargs))
322
+ return sync_wrapper
323
+ return attr
324
+
325
+
326
+ # ── Global Manager ─────────────────────────────────────────────────────
327
+
328
+ _global_manager: MCPClientManager | None = None
329
+
330
+
331
+ def get_mcp_manager() -> MCPClientManager:
332
+ """Get or create the global MCP client manager."""
333
+ global _global_manager
334
+ if _global_manager is None:
335
+ _global_manager = MCPClientManager()
336
+ return _global_manager
sonicoder/mcp/client_manager.py ADDED
File without changes
sonicoder/model/__init__.py ADDED
File without changes
sonicoder/model/inference.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Streaming inference engine β€” supports both text and VLM models.
2
+
3
+ Optimized for 1B models with proper streaming via TextIteratorStreamer.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ import threading
11
+ from typing import Any, Generator
12
+
13
+ from transformers import TextIteratorStreamer
14
+
15
+ from ..config import MODEL_REGISTRY
16
+ from .loader import get_model_and_tokenizer, get_model_status
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ── Regex Patterns ─────────────────────────────────────────────────────
21
+
22
+ THINKING_BLOCK_RE = re.compile(r"<think>(.*?)</think>", re.DOTALL)
23
+ CODE_BLOCK_RE = re.compile(r"```(\w*)\n(.*?)```", re.DOTALL)
24
+ FILE_BLOCK_RE = re.compile(r"@@FILE:(.+?)@@\n(.*?)@@END@@", re.DOTALL)
25
+ TOOL_CALL_RE = re.compile(r"```tool\s*\n(.*?)```", re.DOTALL)
26
+
27
+
28
+ def _build_text_prompt(messages: list[dict[str, str]], system_prompt: str) -> str:
29
+ """Build a chat-style prompt for text models.
30
+
31
+ Uses a simple but effective template that works well with Qwen and DeepSeek models.
32
+ """
33
+ parts = [f"System: {system_prompt}\n"]
34
+
35
+ for msg in messages:
36
+ role = msg.get("role", "user")
37
+ content = msg.get("content", "")
38
+ if role == "system":
39
+ parts.append(f"System: {content}\n")
40
+ elif role == "user":
41
+ parts.append(f"User: {content}\n")
42
+ elif role == "assistant":
43
+ parts.append(f"Assistant: {content}\n")
44
+ elif role == "tool":
45
+ parts.append(f"Tool Result: {content}\n")
46
+
47
+ parts.append("Assistant:")
48
+ return "\n".join(parts)
49
+
50
+
51
+ def call_model_stream(
52
+ messages: list[dict[str, str]],
53
+ system_prompt: str = "",
54
+ max_new_tokens: int = 4096,
55
+ temperature: float = 0.35,
56
+ image_url: str | None = None,
57
+ ) -> Generator[str, None, None]:
58
+ """Stream tokens from the model.
59
+
60
+ Yields partial text strings as they're generated.
61
+ Falls back to non-streaming if TextIteratorStreamer isn't available.
62
+ """
63
+ status = get_model_status()
64
+ if status["status"] != "ready":
65
+ raise RuntimeError(f"Model not ready: {status}")
66
+
67
+ model_key = status["model_key"]
68
+ config = MODEL_REGISTRY[model_key]
69
+
70
+ if config.model_type == "vlm" and image_url:
71
+ yield from _call_vlm_stream(messages, system_prompt, max_new_tokens, temperature, image_url)
72
+ else:
73
+ yield from _call_text_stream(messages, system_prompt, max_new_tokens, temperature)
74
+
75
+
76
+ def _call_text_stream(
77
+ messages: list[dict[str, str]],
78
+ system_prompt: str,
79
+ max_new_tokens: int,
80
+ temperature: float,
81
+ ) -> Generator[str, None, None]:
82
+ """Stream from a text-only model."""
83
+ model, tokenizer = get_model_and_tokenizer()
84
+
85
+ prompt = _build_text_prompt(messages, system_prompt)
86
+ inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=32768)
87
+ inputs = {k: v.to(model.device) for k, v in inputs.items()}
88
+
89
+ # Try streaming first
90
+ try:
91
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
92
+
93
+ generation_kwargs = {
94
+ **inputs,
95
+ "max_new_tokens": max_new_tokens,
96
+ "temperature": temperature,
97
+ "do_sample": temperature > 0,
98
+ "top_p": 0.9,
99
+ "top_k": 50,
100
+ "repetition_penalty": 1.1,
101
+ "streamer": streamer,
102
+ "pad_token_id": tokenizer.eos_token_id,
103
+ }
104
+
105
+ thread = threading.Thread(
106
+ target=model.generate, kwargs=generation_kwargs, daemon=True
107
+ )
108
+ thread.start()
109
+
110
+ for token in streamer:
111
+ if token:
112
+ yield token
113
+
114
+ thread.join(timeout=60)
115
+
116
+ except Exception as e:
117
+ logger.warning(f"Streaming failed, falling back to non-streaming: {e}")
118
+ # Non-streaming fallback
119
+ with torch.no_grad():
120
+ outputs = model.generate(
121
+ **inputs,
122
+ max_new_tokens=max_new_tokens,
123
+ temperature=temperature,
124
+ do_sample=temperature > 0,
125
+ top_p=0.9,
126
+ top_k=50,
127
+ repetition_penalty=1.1,
128
+ pad_token_id=tokenizer.eos_token_id,
129
+ )
130
+ text = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
131
+ yield text
132
+
133
+
134
+ def _call_vlm_stream(
135
+ messages: list[dict[str, str]],
136
+ system_prompt: str,
137
+ max_new_tokens: int,
138
+ temperature: float,
139
+ image_url: str,
140
+ ) -> Generator[str, None, None]:
141
+ """Stream from a vision-language model."""
142
+ import torch
143
+ from PIL import Image
144
+ import requests
145
+
146
+ model, processor = get_model_and_tokenizer()
147
+
148
+ # Build messages for VLM
149
+ vlm_messages = []
150
+ if system_prompt:
151
+ vlm_messages.append({"role": "system", "content": system_prompt})
152
+
153
+ for msg in messages:
154
+ if msg["role"] == "user" and msg is messages[-1]:
155
+ # Add image to last user message
156
+ vlm_messages.append({
157
+ "role": "user",
158
+ "content": [
159
+ {"type": "image", "url": image_url},
160
+ {"type": "text", "text": msg["content"]},
161
+ ],
162
+ })
163
+ else:
164
+ vlm_messages.append(msg)
165
+
166
+ # Apply chat template
167
+ try:
168
+ prompt = processor.apply_chat_template(
169
+ vlm_messages, tokenize=False, add_generation_prompt=True
170
+ )
171
+ except Exception:
172
+ prompt = _build_text_prompt(messages, system_prompt)
173
+
174
+ # Process inputs
175
+ image = None
176
+ if image_url:
177
+ try:
178
+ if image_url.startswith(("http://", "https://")):
179
+ resp = requests.get(image_url, timeout=10)
180
+ image = Image.open(__import__("io").BytesIO(resp.content)).convert("RGB")
181
+ else:
182
+ image = Image.open(image_url).convert("RGB")
183
+ except Exception as e:
184
+ logger.warning(f"Failed to load image: {e}")
185
+
186
+ inputs = processor(text=prompt, images=image, return_tensors="pt", truncation=True)
187
+ inputs = {k: v.to(model.device) for k, v in inputs.items()}
188
+
189
+ # Non-streaming for VLM (most 1B VLMs don't support TextIteratorStreamer well)
190
+ with torch.no_grad():
191
+ outputs = model.generate(
192
+ **inputs,
193
+ max_new_tokens=max_new_tokens,
194
+ temperature=temperature,
195
+ do_sample=temperature > 0,
196
+ pad_token_id=processor.tokenizer.eos_token_id if hasattr(processor, "tokenizer") else 2,
197
+ )
198
+
199
+ # Decode
200
+ if hasattr(processor, "tokenizer"):
201
+ text = processor.tokenizer.decode(
202
+ outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
203
+ )
204
+ elif hasattr(processor, "decode"):
205
+ text = processor.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
206
+ else:
207
+ text = str(outputs[0])
208
+
209
+ yield text
210
+
211
+
212
+ def call_model_sync(
213
+ messages: list[dict[str, str]],
214
+ system_prompt: str = "",
215
+ max_new_tokens: int = 4096,
216
+ temperature: float = 0.35,
217
+ image_url: str | None = None,
218
+ ) -> str:
219
+ """Non-streaming call β€” collects all tokens and returns the full text."""
220
+ chunks = list(call_model_stream(messages, system_prompt, max_new_tokens, temperature, image_url))
221
+ return "".join(chunks)
222
+
223
+
224
+ # Import torch here to avoid issues
225
+ import torch
sonicoder/model/loader.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model loader β€” multi-model management with hot swapping.
2
+
3
+ Supports multiple smart 1B models. Only one model loaded at a time
4
+ to conserve memory (important for HF Spaces free tier).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import gc
10
+ import logging
11
+ import threading
12
+ from typing import Any, Callable
13
+
14
+ import torch
15
+ from transformers import AutoConfig
16
+
17
+ from ..config import MODEL_REGISTRY, ModelConfig
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # ── Module State ───────────────────────────────────────────────────────
22
+
23
+ _model = None
24
+ _processor = None
25
+ _tokenizer = None
26
+ _model_key: str | None = None
27
+ _model_loaded = False
28
+ _model_loading = False
29
+ _load_error: str | None = None
30
+ _lock = threading.Lock()
31
+ _on_status_change: list[Callable] = []
32
+
33
+
34
+ def _get_device() -> str:
35
+ """Auto-detect best device."""
36
+ if torch.cuda.is_available():
37
+ return "cuda"
38
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
39
+ return "mps"
40
+ return "cpu"
41
+
42
+
43
+ def _get_dtype(device: str) -> torch.dtype:
44
+ """Select dtype based on device."""
45
+ return torch.float16 if device == "cuda" else torch.float32
46
+
47
+
48
+ def _unload_model() -> None:
49
+ """Unload current model and free memory."""
50
+ global _model, _processor, _tokenizer, _model_loaded
51
+ if _model is not None:
52
+ del _model
53
+ _model = None
54
+ if _processor is not None:
55
+ del _processor
56
+ _processor = None
57
+ if _tokenizer is not None:
58
+ del _tokenizer
59
+ _tokenizer = None
60
+ _model_loaded = False
61
+ gc.collect()
62
+ if torch.cuda.is_available():
63
+ torch.cuda.empty_cache()
64
+
65
+
66
+ def load_model(model_key: str) -> None:
67
+ """Load a model by key. Blocks until loaded."""
68
+ global _model, _processor, _tokenizer, _model_key, _model_loaded, _model_loading, _load_error
69
+
70
+ config_entry = MODEL_REGISTRY.get(model_key)
71
+ if not config_entry:
72
+ raise ValueError(f"Unknown model: {model_key}. Available: {list(MODEL_REGISTRY.keys())}")
73
+
74
+ with _lock:
75
+ if _model_key == model_key and _model_loaded:
76
+ return # Already loaded
77
+
78
+ _model_loading = True
79
+ _load_error = None
80
+ _notify_status()
81
+
82
+ try:
83
+ _unload_model()
84
+
85
+ device = _get_device()
86
+ dtype = _get_dtype(device)
87
+ logger.info(f"Loading {config_entry.name} ({config_entry.model_id}) on {device} ({dtype})...")
88
+
89
+ model_kwargs = {
90
+ "trust_remote_code": config_entry.trust_remote_code,
91
+ "low_cpu_mem_usage": True,
92
+ "torch_dtype": dtype,
93
+ }
94
+
95
+ if device == "cuda":
96
+ model_kwargs["device_map"] = "auto"
97
+ else:
98
+ model_kwargs["device_map"] = None
99
+
100
+ if config_entry.model_type == "vlm":
101
+ from transformers import AutoModelForImageTextToText, AutoProcessor
102
+ _model = AutoModelForImageTextToText.from_pretrained(
103
+ config_entry.model_id, **model_kwargs
104
+ )
105
+ _processor = AutoProcessor.from_pretrained(
106
+ config_entry.model_id, trust_remote_code=True
107
+ )
108
+ _tokenizer = None
109
+ else:
110
+ from transformers import AutoModelForCausalLM, AutoTokenizer
111
+ _model = AutoModelForCausalLM.from_pretrained(
112
+ config_entry.model_id, **model_kwargs
113
+ )
114
+ _tokenizer = AutoTokenizer.from_pretrained(
115
+ config_entry.model_id, trust_remote_code=True
116
+ )
117
+ _processor = None
118
+
119
+ if not device == "cuda" and hasattr(_model, "to"):
120
+ _model = _model.to(device)
121
+
122
+ _model.eval()
123
+ _model_key = model_key
124
+ _model_loaded = True
125
+ logger.info(f"Model loaded: {config_entry.name}")
126
+
127
+ except Exception as e:
128
+ _load_error = str(e)
129
+ logger.error(f"Failed to load model: {e}")
130
+ raise
131
+ finally:
132
+ _model_loading = False
133
+ _notify_status()
134
+
135
+
136
+ def start_background_load(model_key: str | None = None) -> None:
137
+ """Start loading a model in a background thread."""
138
+ key = model_key or "qwen25-coder-1.5b"
139
+ t = threading.Thread(target=load_model, args=(key,), daemon=True)
140
+ t.start()
141
+
142
+
143
+ def switch_model(model_key: str) -> None:
144
+ """Switch to a different model (loads in background)."""
145
+ start_background_load(model_key)
146
+
147
+
148
+ def get_model_status() -> dict[str, Any]:
149
+ """Get current model status."""
150
+ config = MODEL_REGISTRY.get(_model_key) if _model_key else None
151
+ status = "error" if _load_error else ("ready" if _model_loaded else ("loading" if _model_loading else "unknown"))
152
+ return {
153
+ "status": status,
154
+ "model_key": _model_key,
155
+ "model_name": config.name if config else None,
156
+ "model_id": config.model_id if config else None,
157
+ "model_type": config.model_type if config else None,
158
+ "error": _load_error,
159
+ }
160
+
161
+
162
+ def get_model_and_tokenizer() -> tuple:
163
+ """Get the current model and tokenizer/processor. Returns (model, tokenizer_or_processor)."""
164
+ if not _model_loaded or _model is None:
165
+ raise RuntimeError("Model not loaded")
166
+ return _model, _tokenizer or _processor
167
+
168
+
169
+ def on_status_change(callback: Callable) -> None:
170
+ """Register a callback for model status changes."""
171
+ _on_status_change.append(callback)
172
+
173
+
174
+ def _notify_status() -> None:
175
+ """Notify all status change listeners."""
176
+ status = get_model_status()
177
+ for cb in _on_status_change:
178
+ try:
179
+ cb(status)
180
+ except Exception:
181
+ pass
sonicoder/plugins/__init__.py ADDED
File without changes
sonicoder/policy/__init__.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy engine β€” rule-based tool approval system.
2
+
3
+ Inspired by Gemini CLI's PolicyEngine with priority-sorted rules
4
+ and pattern matching. Also incorporates Claude Code's allow/ask/deny model.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import re
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+
16
+ class ApprovalDecision(Enum):
17
+ ALLOW = "allow"
18
+ ASK = "ask"
19
+ DENY = "deny"
20
+
21
+
22
+ @dataclass
23
+ class PolicyCheckResult:
24
+ """Result of a policy check."""
25
+ decision: ApprovalDecision
26
+ reason: str = ""
27
+ matched_rule: str | None = None
28
+
29
+
30
+ class PolicyEngine:
31
+ """Rule-based policy engine with priority ordering.
32
+
33
+ Rules are evaluated in priority order (highest first).
34
+ First matching rule wins.
35
+ Default: ASK for interactive, DENY for non-interactive.
36
+ """
37
+
38
+ # Built-in safety rules (always evaluated, highest priority)
39
+ BUILTIN_RULES = [
40
+ {
41
+ "tool_name": "bash",
42
+ "args_pattern": r"rm\s+-rf\s+(/|~|\*)",
43
+ "approval": "deny",
44
+ "priority": 1000,
45
+ "description": "Block recursive delete of root/home",
46
+ },
47
+ {
48
+ "tool_name": "bash",
49
+ "args_pattern": r"(shutdown|reboot|halt|poweroff)",
50
+ "approval": "deny",
51
+ "priority": 1000,
52
+ "description": "Block system shutdown commands",
53
+ },
54
+ {
55
+ "tool_name": "bash",
56
+ "args_pattern": r"(mkfs|fdisk|parted|dd\s+if=/dev/zero)",
57
+ "approval": "deny",
58
+ "priority": 1000,
59
+ "description": "Block disk destruction commands",
60
+ },
61
+ {
62
+ "tool_name": "bash",
63
+ "args_pattern": r"(chmod\s+-R\s+777|chown\s+-R)",
64
+ "approval": "deny",
65
+ "priority": 900,
66
+ "description": "Block dangerous permission changes",
67
+ },
68
+ {
69
+ "tool_name": "write_file",
70
+ "args_pattern": None,
71
+ "approval": "allow",
72
+ "priority": -100,
73
+ "description": "Allow file writes in sandboxed workspace",
74
+ },
75
+ {
76
+ "tool_name": "edit_file",
77
+ "args_pattern": None,
78
+ "approval": "allow",
79
+ "priority": -100,
80
+ "description": "Allow file edits in sandboxed workspace",
81
+ },
82
+ {
83
+ "tool_name": "read_file",
84
+ "args_pattern": None,
85
+ "approval": "allow",
86
+ "priority": -200,
87
+ "description": "Allow file reads",
88
+ },
89
+ {
90
+ "tool_name": "glob",
91
+ "args_pattern": None,
92
+ "approval": "allow",
93
+ "priority": -200,
94
+ "description": "Allow glob searches",
95
+ },
96
+ {
97
+ "tool_name": "grep",
98
+ "args_pattern": None,
99
+ "approval": "allow",
100
+ "priority": -200,
101
+ "description": "Allow grep searches",
102
+ },
103
+ {
104
+ "tool_name": "list_dir",
105
+ "args_pattern": None,
106
+ "approval": "allow",
107
+ "priority": -200,
108
+ "description": "Allow directory listing",
109
+ },
110
+ {
111
+ "tool_name": "web_search",
112
+ "args_pattern": None,
113
+ "approval": "allow",
114
+ "priority": -200,
115
+ "description": "Allow web search",
116
+ },
117
+ {
118
+ "tool_name": "web_fetch",
119
+ "args_pattern": None,
120
+ "approval": "allow",
121
+ "priority": -200,
122
+ "description": "Allow web fetch",
123
+ },
124
+ {
125
+ "tool_name": "todo",
126
+ "args_pattern": None,
127
+ "approval": "allow",
128
+ "priority": -200,
129
+ "description": "Allow todo management",
130
+ },
131
+ {
132
+ "tool_name": "snapshot_workspace",
133
+ "args_pattern": None,
134
+ "approval": "allow",
135
+ "priority": -200,
136
+ "description": "Allow workspace snapshot",
137
+ },
138
+ ]
139
+
140
+ def __init__(self, interactive: bool = True):
141
+ self._rules: list[dict] = list(self.BUILTIN_RULES)
142
+ self._interactive = interactive
143
+ self._approval_mode: str = "default" # default | auto_edit | yolo
144
+
145
+ @property
146
+ def approval_mode(self) -> str:
147
+ return self._approval_mode
148
+
149
+ @approval_mode.setter
150
+ def approval_mode(self, mode: str) -> None:
151
+ if mode in ("default", "auto_edit", "yolo"):
152
+ self._approval_mode = mode
153
+
154
+ def add_rule(self, rule: dict) -> None:
155
+ """Add a custom policy rule."""
156
+ rule.setdefault("priority", 0)
157
+ rule.setdefault("approval", "ask")
158
+ self._rules.append(rule)
159
+ self._rules.sort(key=lambda r: r["priority"], reverse=True)
160
+
161
+ def remove_rule(self, description: str) -> bool:
162
+ """Remove a rule by description."""
163
+ for i, r in enumerate(self._rules):
164
+ if r.get("description") == description:
165
+ self._rules.pop(i)
166
+ return True
167
+ return False
168
+
169
+ def set_rules(self, rules: list[dict]) -> None:
170
+ """Replace all custom rules (keeps built-in rules)."""
171
+ self._rules = list(self.BUILTIN_RULES)
172
+ for r in rules:
173
+ self.add_rule(r)
174
+
175
+ def check(self, tool_name: str, args: dict[str, Any] | None = None) -> PolicyCheckResult:
176
+ """Check if a tool call is allowed.
177
+
178
+ Returns the decision and reason from the highest-priority matching rule.
179
+ """
180
+ args_str = str(args) if args else ""
181
+
182
+ for rule in self._rules:
183
+ # Check tool name match (supports wildcards)
184
+ if not self._match_tool_name(tool_name, rule["tool_name"]):
185
+ continue
186
+
187
+ # Check args pattern if specified
188
+ if rule.get("args_pattern"):
189
+ try:
190
+ if not re.search(rule["args_pattern"], args_str, re.IGNORECASE):
191
+ continue
192
+ except re.error:
193
+ continue
194
+
195
+ # Match found β€” return this rule's decision
196
+ approval = rule["approval"]
197
+
198
+ # YOLO mode: upgrade DENY to ASK (except built-in high-priority)
199
+ if self._approval_mode == "yolo" and rule["priority"] < 900:
200
+ if approval == "deny":
201
+ approval = "ask"
202
+
203
+ # Auto-edit mode: auto-approve file writes/edits
204
+ if self._approval_mode == "auto_edit" and tool_name in ("write_file", "edit_file"):
205
+ approval = "allow"
206
+
207
+ return PolicyCheckResult(
208
+ decision=ApprovalDecision(approval),
209
+ reason=rule.get("description", f"Rule matched: {rule['tool_name']}"),
210
+ matched_rule=rule.get("description"),
211
+ )
212
+
213
+ # Default: ASK for interactive, DENY for non-interactive
214
+ default = ApprovalDecision.ASK if self._interactive else ApprovalDecision.DENY
215
+ return PolicyCheckResult(
216
+ decision=default,
217
+ reason=f"No matching rule for {tool_name} (default: {default.value})",
218
+ )
219
+
220
+ def _match_tool_name(self, name: str, pattern: str) -> bool:
221
+ """Match tool name against pattern (supports * wildcards)."""
222
+ if pattern == "*":
223
+ return True
224
+ if "*" in pattern:
225
+ # Convert glob to regex
226
+ regex = pattern.replace("*", ".*")
227
+ return bool(re.match(f"^{regex}$", name))
228
+ return name == pattern
229
+
230
+ def get_rules(self) -> list[dict]:
231
+ """Get all rules for display."""
232
+ return list(self._rules)
233
+
234
+
235
+ # ── Global Engine ───────────────────────────────────────────────────────
236
+
237
+ _global_engine: PolicyEngine | None = None
238
+
239
+
240
+ def get_policy_engine() -> PolicyEngine:
241
+ """Get or create the global policy engine."""
242
+ global _global_engine
243
+ if _global_engine is None:
244
+ _global_engine = PolicyEngine()
245
+ return _global_engine
sonicoder/server/__init__.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server routes β€” FastAPI + Gradio API endpoints.
2
+
3
+ All HTTP and Gradio API endpoints for the application.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any, Generator
13
+
14
+ from ..config import (
15
+ Settings, get_available_models, load_settings,
16
+ WORKSPACE_ROOT, CONFIG_DIR,
17
+ )
18
+ from ..model.loader import (
19
+ get_model_status, start_background_load, switch_model,
20
+ )
21
+ from ..tools import get_tool_registry, ToolRegistry
22
+ from ..tools.fs import BUILTIN_TOOLS as FS_TOOLS
23
+ from ..tools.bash import BUILTIN_TOOLS as BASH_TOOLS
24
+ from ..tools.web import BUILTIN_TOOLS as WEB_TOOLS
25
+ from ..tools.todos import BUILTIN_TOOLS as TODO_TOOLS
26
+ from ..agent import AgentSession, register_builtin_tools
27
+ from ..mcp import get_mcp_manager
28
+ from ..policy import get_policy_engine
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # ── Global State ───────────────────────────────────────────────────────
33
+
34
+ _settings: Settings | None = None
35
+ _registry: ToolRegistry | None = None
36
+ _sessions: dict[str, AgentSession] = {}
37
+
38
+
39
+ def _get_settings() -> Settings:
40
+ global _settings
41
+ if _settings is None:
42
+ _settings = load_settings()
43
+ return _settings
44
+
45
+
46
+ def _get_registry() -> ToolRegistry:
47
+ global _registry
48
+ if _registry is None:
49
+ _registry = get_tool_registry()
50
+ register_builtin_tools(_registry)
51
+ # Apply filters
52
+ settings = _get_settings()
53
+ _registry.set_filters(settings.enabled_tools, settings.disabled_tools)
54
+ return _registry
55
+
56
+
57
+ def _get_or_create_session(session_id: str = "") -> tuple[str, AgentSession]:
58
+ """Get existing session or create new one."""
59
+ if not session_id:
60
+ session_id = str(uuid.uuid4())[:12]
61
+ if session_id not in _sessions:
62
+ _sessions[session_id] = AgentSession(
63
+ settings=_get_settings(),
64
+ tool_registry=_get_registry(),
65
+ )
66
+ return session_id, _sessions[session_id]
67
+
68
+
69
+ # ── Gradio API Functions ──────────────────────────────────────────────
70
+
71
+ def api_chat(
72
+ message: str,
73
+ session_id: str = "",
74
+ image_url: str | None = None,
75
+ ) -> Generator[str, None, None]:
76
+ """Main chat/agent endpoint β€” streams events as JSON lines."""
77
+ if not message or not message.strip():
78
+ yield json.dumps({"type": "error", "error": "Empty message"})
79
+ return
80
+
81
+ sid, session = _get_or_create_session(session_id)
82
+
83
+ try:
84
+ for event in session.run(message, session_id=sid, image_url=image_url):
85
+ yield json.dumps(event, ensure_ascii=False)
86
+ except Exception as e:
87
+ logger.error(f"Chat error: {e}", exc_info=True)
88
+ yield json.dumps({"type": "error", "error": str(e)})
89
+
90
+
91
+ def api_chat_sync(message: str, session_id: str = "") -> str:
92
+ """Non-streaming chat for simple calls."""
93
+ events = list(api_chat(message, session_id))
94
+ if not events:
95
+ return json.dumps({"type": "error", "error": "No response"})
96
+ return events[-1] # Return last event (complete)
97
+
98
+
99
+ def api_model_status() -> str:
100
+ """Get model loading status."""
101
+ return json.dumps(get_model_status())
102
+
103
+
104
+ def api_switch_model(model_key: str) -> str:
105
+ """Switch to a different model."""
106
+ switch_model(model_key)
107
+ return json.dumps({"status": "switching", "model": model_key})
108
+
109
+
110
+ def api_list_models() -> str:
111
+ """List all available models."""
112
+ models = get_available_models()
113
+ current = get_model_status().get("model_key", "")
114
+ result = []
115
+ for key, config in models.items():
116
+ result.append({
117
+ "key": key,
118
+ "name": config.name,
119
+ "model_type": config.model_type,
120
+ "size_gb": config.size_gb,
121
+ "description": config.description,
122
+ "current": key == current,
123
+ })
124
+ return json.dumps(result, ensure_ascii=False)
125
+
126
+
127
+ def api_workspace_tree(max_depth: int = 3) -> str:
128
+ """Get workspace directory tree."""
129
+ import os
130
+ tree = []
131
+
132
+ def walk(path: Path, prefix: str = "", depth: int = 0):
133
+ if depth >= max_depth:
134
+ return
135
+ try:
136
+ items = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
137
+ except PermissionError:
138
+ return
139
+
140
+ skip = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".sonicoder"}
141
+ for i, item in enumerate(items):
142
+ if item.name.startswith(".") or item.name in skip:
143
+ continue
144
+ is_last = i == len(items) - 1
145
+ connector = "└── " if is_last else "β”œβ”€β”€ "
146
+ if item.is_dir():
147
+ tree.append(f"{prefix}{connector}{item.name}/")
148
+ extension = " " if is_last else "β”‚ "
149
+ walk(item, prefix + extension, depth + 1)
150
+ else:
151
+ tree.append(f"{prefix}{connector}{item.name}")
152
+
153
+ if WORKSPACE_ROOT.exists():
154
+ walk(WORKSPACE_ROOT)
155
+
156
+ return json.dumps({
157
+ "tree": "\n".join(tree) if tree else "(empty workspace)",
158
+ "root": str(WORKSPACE_ROOT),
159
+ })
160
+
161
+
162
+ def api_workspace_read(path: str, offset: int = 1, limit: int = 500) -> str:
163
+ """Read a workspace file."""
164
+ from ..tools.fs import _resolve_safe
165
+ try:
166
+ resolved = _resolve_safe(path)
167
+ if not resolved.exists():
168
+ return json.dumps({"error": f"File not found: {path}"})
169
+ content = resolved.read_text(encoding="utf-8", errors="replace")
170
+ lines = content.splitlines()
171
+ total = len(lines)
172
+ start = max(0, offset - 1)
173
+ end = min(total, start + limit)
174
+ selected = lines[start:end]
175
+ numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
176
+ return json.dumps({
177
+ "content": "\n".join(numbered),
178
+ "total_lines": total,
179
+ "truncated": end < total,
180
+ })
181
+ except Exception as e:
182
+ return json.dumps({"error": str(e)})
183
+
184
+
185
+ def api_workspace_write(path: str, content: str) -> str:
186
+ """Write a workspace file."""
187
+ from ..tools.fs import _resolve_safe
188
+ try:
189
+ resolved = _resolve_safe(path)
190
+ resolved.parent.mkdir(parents=True, exist_ok=True)
191
+ resolved.write_text(content, encoding="utf-8")
192
+ return json.dumps({"success": True, "path": path})
193
+ except Exception as e:
194
+ return json.dumps({"error": str(e)})
195
+
196
+
197
+ def api_workspace_bash(command: str) -> str:
198
+ """Execute a bash command in workspace."""
199
+ from ..tools.bash import BashBuilder
200
+ builder = BashBuilder()
201
+ errors = builder.validate({"command": command})
202
+ if errors:
203
+ return json.dumps({"error": "; ".join(errors)})
204
+ invocation = builder.create_invocation({"command": command})
205
+ result = invocation.execute()
206
+ return json.dumps(result.to_dict())
207
+
208
+
209
+ def api_web_search(query: str) -> str:
210
+ """Search the web."""
211
+ from ..tools.web import WebSearchBuilder
212
+ builder = WebSearchBuilder()
213
+ invocation = builder.create_invocation({"query": query})
214
+ result = invocation.execute()
215
+ return json.dumps(result.to_dict(), ensure_ascii=False)
216
+
217
+
218
+ def api_mcp_status() -> str:
219
+ """Get MCP server status."""
220
+ manager = get_mcp_manager()
221
+ return json.dumps(manager.get_status())
222
+
223
+
224
+ def api_policy_status() -> str:
225
+ """Get policy engine status."""
226
+ engine = get_policy_engine()
227
+ return json.dumps({
228
+ "mode": engine.approval_mode,
229
+ "rules": engine.get_rules(),
230
+ })
231
+
232
+
233
+ def api_reset_session(session_id: str = "") -> str:
234
+ """Reset a chat session."""
235
+ if session_id in _sessions:
236
+ _sessions[session_id].clear_history()
237
+ return json.dumps({"success": True})
238
+
239
+
240
+ def api_get_config() -> str:
241
+ """Get runtime configuration for the frontend."""
242
+ models = get_available_models()
243
+ model_list = []
244
+ for key, config in models.items():
245
+ model_list.append({
246
+ "key": key,
247
+ "name": config.name,
248
+ "model_type": config.model_type,
249
+ "size_gb": config.size_gb,
250
+ "description": config.description,
251
+ })
252
+
253
+ return json.dumps({
254
+ "models": model_list,
255
+ "default_model": _get_settings().default_model,
256
+ "workspace": str(WORKSPACE_ROOT),
257
+ "version": "2.0.0",
258
+ }, ensure_ascii=False)
259
+
260
+
261
+ # ── Create Gradio App ──────────────────────────────────────────────────
262
+
263
+ def get_app():
264
+ """Create and return the Gradio application."""
265
+ import gradio as gr
266
+ from fastapi.responses import FileResponse
267
+ from pathlib import Path
268
+
269
+ # Create the Gradio app
270
+ app = gr.Blocks(
271
+ title="SoniCoder v2 β€” AI Code Agent",
272
+ theme=gr.themes.Soft(),
273
+ )
274
+
275
+ with app:
276
+ # Hidden components for API
277
+ api_chat_output = gr.Textbox(visible=False)
278
+ api_status_output = gr.Textbox(visible=False)
279
+ api_json_output = gr.Textbox(visible=False)
280
+
281
+ # API endpoints
282
+ gr.Interface(
283
+ fn=api_chat,
284
+ inputs=[
285
+ gr.Textbox(label="message"),
286
+ gr.Textbox(label="session_id", default=""),
287
+ gr.Textbox(label="image_url", default=""),
288
+ ],
289
+ outputs=[api_chat_output],
290
+ api_name="chat",
291
+ )
292
+
293
+ gr.Interface(
294
+ fn=api_model_status,
295
+ inputs=[],
296
+ outputs=[api_json_output],
297
+ api_name="model_status",
298
+ )
299
+
300
+ gr.Interface(
301
+ fn=api_switch_model,
302
+ inputs=[gr.Textbox(label="model_key")],
303
+ outputs=[api_json_output],
304
+ api_name="switch_model",
305
+ )
306
+
307
+ gr.Interface(
308
+ fn=api_list_models,
309
+ inputs=[],
310
+ outputs=[api_json_output],
311
+ api_name="list_models",
312
+ )
313
+
314
+ gr.Interface(
315
+ fn=api_workspace_tree,
316
+ inputs=[gr.Number(label="max_depth", value=3)],
317
+ outputs=[api_json_output],
318
+ api_name="workspace_tree",
319
+ )
320
+
321
+ gr.Interface(
322
+ fn=api_workspace_read,
323
+ inputs=[
324
+ gr.Textbox(label="path"),
325
+ gr.Number(label="offset", value=1),
326
+ gr.Number(label="limit", value=500),
327
+ ],
328
+ outputs=[api_json_output],
329
+ api_name="workspace_read",
330
+ )
331
+
332
+ gr.Interface(
333
+ fn=api_workspace_write,
334
+ inputs=[gr.Textbox(label="path"), gr.Textbox(label="content", lines=10)],
335
+ outputs=[api_json_output],
336
+ api_name="workspace_write",
337
+ )
338
+
339
+ gr.Interface(
340
+ fn=api_workspace_bash,
341
+ inputs=[gr.Textbox(label="command")],
342
+ outputs=[api_json_output],
343
+ api_name="workspace_bash",
344
+ )
345
+
346
+ gr.Interface(
347
+ fn=api_web_search,
348
+ inputs=[gr.Textbox(label="query")],
349
+ outputs=[api_json_output],
350
+ api_name="web_search",
351
+ )
352
+
353
+ gr.Interface(
354
+ fn=api_mcp_status,
355
+ inputs=[],
356
+ outputs=[api_json_output],
357
+ api_name="mcp_status",
358
+ )
359
+
360
+ gr.Interface(
361
+ fn=api_policy_status,
362
+ inputs=[],
363
+ outputs=[api_json_output],
364
+ api_name="policy_status",
365
+ )
366
+
367
+ gr.Interface(
368
+ fn=api_reset_session,
369
+ inputs=[gr.Textbox(label="session_id", default="")],
370
+ outputs=[api_json_output],
371
+ api_name="reset_session",
372
+ )
373
+
374
+ gr.Interface(
375
+ fn=api_get_config,
376
+ inputs=[],
377
+ outputs=[api_json_output],
378
+ api_name="get_config",
379
+ )
380
+
381
+ return app
sonicoder/skills/__init__.py ADDED
File without changes
sonicoder/tools/__init__.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool system β€” ToolBuilder/ToolInvocation pattern from Gemini CLI.
2
+
3
+ Every tool follows:
4
+ 1. Define a class extending ToolBuilder
5
+ 2. Implement get_schema(), validate(), create_invocation()
6
+ 3. The invocation's execute() does the actual work
7
+ 4. Register in ToolRegistry
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from typing import Any, Callable
17
+
18
+
19
+ # ── Tool Kinds ────────────────────────────────────────────────────────
20
+
21
+ class ToolKind(Enum):
22
+ READ = "read"
23
+ WRITE = "write"
24
+ EDIT = "edit"
25
+ SHELL = "shell"
26
+ OTHER = "other"
27
+
28
+
29
+ # ── Result Types ──────────────────────────────────────────────────────
30
+
31
+ @dataclass
32
+ class ToolResult:
33
+ """Result of a tool execution."""
34
+ success: bool
35
+ output: str = ""
36
+ error: str | None = None
37
+ metadata: dict[str, Any] = field(default_factory=dict)
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ d: dict[str, Any] = {"success": self.success}
41
+ if self.output:
42
+ d["output"] = self.output
43
+ if self.error:
44
+ d["error"] = self.error
45
+ if self.metadata:
46
+ d["metadata"] = self.metadata
47
+ return d
48
+
49
+ def to_json(self) -> str:
50
+ return json.dumps(self.to_dict(), ensure_ascii=False)
51
+
52
+
53
+ # ── Tool Builder (Schema + Validation + Invocation Factory) ───────────
54
+
55
+ class ToolBuilder(ABC):
56
+ """Base class for all tools β€” Gemini CLI pattern.
57
+
58
+ Tools are defined by:
59
+ - name: LLM-visible identifier
60
+ - description: What the tool does
61
+ - kind: READ/WRITE/EDIT/SHELL/OTHER
62
+ - get_schema(): JSON Schema for parameters
63
+ - validate(): Parameter validation
64
+ - create_invocation(): Factory for ToolInvocation
65
+ """
66
+
67
+ name: str = ""
68
+ description: str = ""
69
+ kind: ToolKind = ToolKind.OTHER
70
+ is_read_only: bool = False
71
+
72
+ @abstractmethod
73
+ def get_schema(self) -> dict[str, Any]:
74
+ """Return JSON Schema for this tool's parameters."""
75
+ ...
76
+
77
+ def get_display_name(self) -> str:
78
+ return self.name.replace("_", " ").title()
79
+
80
+ def get_tool_definition(self) -> dict[str, Any]:
81
+ """Full tool definition for the LLM."""
82
+ return {
83
+ "name": self.name,
84
+ "description": self.description,
85
+ "parameters": self.get_schema(),
86
+ "kind": self.kind.value,
87
+ }
88
+
89
+ def validate(self, params: dict[str, Any]) -> list[str]:
90
+ """Validate params. Returns list of error messages (empty = valid)."""
91
+ return []
92
+
93
+ @abstractmethod
94
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
95
+ """Create an invocation with validated params."""
96
+ ...
97
+
98
+
99
+ # ── Tool Invocation (Ready-to-Execute) ────────────────────────────────
100
+
101
+ class ToolInvocation(ABC):
102
+ """A validated, ready-to-execute tool call β€” Gemini CLI pattern."""
103
+
104
+ def __init__(self, params: dict[str, Any]):
105
+ self.params = params
106
+
107
+ def get_description(self) -> str:
108
+ """Human-readable description of this invocation."""
109
+ return f"Execute {self.__class__.__name__}"
110
+
111
+ def get_affected_paths(self) -> list[str]:
112
+ """Return file paths affected by this invocation."""
113
+ return []
114
+
115
+ @abstractmethod
116
+ def execute(self) -> ToolResult:
117
+ """Execute the tool and return a result."""
118
+ ...
119
+
120
+
121
+ # ── Tool Registry ─────────────────────────────────────────────────────
122
+
123
+ class ToolRegistry:
124
+ """Central registry for all tools β€” manages discovery, filtering, and lookup.
125
+
126
+ Inspired by Gemini CLI's ToolRegistry with enable/disable support.
127
+ """
128
+
129
+ def __init__(self):
130
+ self._tools: dict[str, ToolBuilder] = {}
131
+ self._mcp_tools: dict[str, ToolBuilder] = {}
132
+ self._enabled_patterns: list[str] = ["*"]
133
+ self._disabled_patterns: list[str] = []
134
+
135
+ def register(self, tool: ToolBuilder) -> None:
136
+ """Register a built-in tool."""
137
+ self._tools[tool.name] = tool
138
+
139
+ def register_mcp(self, tool: ToolBuilder) -> None:
140
+ """Register an MCP-discovered tool (prefixed with mcp_)."""
141
+ self._mcp_tools[tool.name] = tool
142
+
143
+ def unregister_mcp(self, server_name: str) -> None:
144
+ """Unregister all MCP tools from a specific server."""
145
+ prefix = f"mcp_{server_name}_"
146
+ to_remove = [k for k in self._mcp_tools if k.startswith(prefix)]
147
+ for k in to_remove:
148
+ del self._mcp_tools[k]
149
+
150
+ def get_tool(self, name: str) -> ToolBuilder | None:
151
+ """Look up a tool by name."""
152
+ if name in self._mcp_tools:
153
+ return self._mcp_tools[name]
154
+ return self._tools.get(name)
155
+
156
+ def get_all_tools(self) -> dict[str, ToolBuilder]:
157
+ """Get all registered tools (built-in + MCP)."""
158
+ return {**self._tools, **self._mcp_tools}
159
+
160
+ def get_active_tools(self) -> dict[str, ToolBuilder]:
161
+ """Get tools that pass the enable/disable filters."""
162
+ all_tools = self.get_all_tools()
163
+ active = {}
164
+ for name, tool in all_tools.items():
165
+ if self._is_tool_enabled(name):
166
+ active[name] = tool
167
+ return active
168
+
169
+ def get_tool_schemas(self) -> list[dict[str, Any]]:
170
+ """Get schemas for all active tools (for LLM context)."""
171
+ active = self.get_active_tools()
172
+ return [tool.get_tool_definition() for tool in active.values()]
173
+
174
+ def set_filters(self, enabled: list[str], disabled: list[str]) -> None:
175
+ """Set enable/disable patterns."""
176
+ self._enabled_patterns = enabled
177
+ self._disabled_patterns = disabled
178
+
179
+ def _is_tool_enabled(self, name: str) -> bool:
180
+ """Check if a tool passes the filters."""
181
+ # Check explicit disable first
182
+ for pattern in self._disabled_patterns:
183
+ if self._match_pattern(name, pattern):
184
+ return False
185
+ # Check enable patterns
186
+ if "*" in self._enabled_patterns:
187
+ return True
188
+ for pattern in self._enabled_patterns:
189
+ if self._match_pattern(name, pattern):
190
+ return True
191
+ return False
192
+
193
+ @staticmethod
194
+ def _match_pattern(name: str, pattern: str) -> bool:
195
+ """Simple wildcard matching: * at start/end."""
196
+ if pattern == "*":
197
+ return True
198
+ if pattern.endswith("*"):
199
+ return name.startswith(pattern[:-1])
200
+ if pattern.startswith("*"):
201
+ return name.endswith(pattern[1:])
202
+ return name == pattern
203
+
204
+
205
+ # ── Global Registry ───────────────────────────────────────────────────
206
+
207
+ _global_registry: ToolRegistry | None = None
208
+
209
+
210
+ def get_tool_registry() -> ToolRegistry:
211
+ """Get or create the global tool registry."""
212
+ global _global_registry
213
+ if _global_registry is None:
214
+ _global_registry = ToolRegistry()
215
+ return _global_registry
sonicoder/tools/bash.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bash/shell execution tool β€” sandboxed subprocess execution.
2
+
3
+ Inspired by Claude Code's Bash tool and Gemini CLI's run_shell_command.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import subprocess
10
+ import threading
11
+ from typing import Any
12
+
13
+ from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
14
+
15
+ # ── Security ───────────────────────────────────────────────────────────
16
+
17
+ _BLOCKED_PATTERNS = [
18
+ "rm -rf /", "rm -rf ~", "rm -rf /*", "mkfs", "dd if=/dev/zero",
19
+ "shutdown", "reboot", "halt", "poweroff", ":(){ :|:& };:",
20
+ "chmod -R 777 /", "chown -R", "> /dev/sd", "mkswap",
21
+ "fdisk", "parted", "wipefs",
22
+ ]
23
+
24
+ _SENSITIVE_ENV_VARS = [
25
+ "HF_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY",
26
+ "GEMINI_API_KEY", "AWS_SECRET_ACCESS_KEY", "AWS_ACCESS_KEY_ID",
27
+ "AZURE_API_KEY", "DEEPSEEK_API_KEY",
28
+ ]
29
+
30
+
31
+ def _is_blocked(command: str) -> str | None:
32
+ """Check if command matches a blocked pattern. Returns reason or None."""
33
+ cmd_lower = command.strip().lower()
34
+ for pattern in _BLOCKED_PATTERNS:
35
+ if pattern in cmd_lower:
36
+ return f"Blocked: dangerous command pattern '{pattern}'"
37
+ return None
38
+
39
+
40
+ def _scrub_env() -> dict[str, str]:
41
+ """Return a clean environment with sensitive vars removed."""
42
+ env = os.environ.copy()
43
+ for var in _SENSITIVE_ENV_VARS:
44
+ env.pop(var, None)
45
+ return env
46
+
47
+
48
+ # ── Bash Tool ──────────────────────────────────────────────────────────
49
+
50
+ class BashBuilder(ToolBuilder):
51
+ name = "bash"
52
+ description = (
53
+ "Execute a shell command in the workspace. Supports any bash command. "
54
+ "Commands are sandboxed and sensitive environment variables are scrubbed. "
55
+ "Timeout: 120 seconds by default."
56
+ )
57
+ kind = ToolKind.SHELL
58
+
59
+ def get_schema(self) -> dict[str, Any]:
60
+ return {
61
+ "type": "object",
62
+ "properties": {
63
+ "command": {"type": "string", "description": "Shell command to execute"},
64
+ "timeout": {"type": "integer", "description": "Timeout in seconds (max 300)", "default": 120},
65
+ },
66
+ "required": ["command"],
67
+ }
68
+
69
+ def validate(self, params: dict[str, Any]) -> list[str]:
70
+ errors = []
71
+ if not params.get("command"):
72
+ errors.append("command is required")
73
+ blocked = _is_blocked(params.get("command", ""))
74
+ if blocked:
75
+ errors.append(blocked)
76
+ return errors
77
+
78
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
79
+ return BashInvocation(params)
80
+
81
+
82
+ class BashInvocation(ToolInvocation):
83
+ MAX_OUTPUT = 50000
84
+
85
+ def get_description(self) -> str:
86
+ cmd = self.params["command"][:80]
87
+ return f"Bash: {cmd}"
88
+
89
+ def execute(self) -> ToolResult:
90
+ command = self.params["command"]
91
+ timeout = min(self.params.get("timeout", 120) or 120, 300)
92
+
93
+ # Security check
94
+ blocked = _is_blocked(command)
95
+ if blocked:
96
+ return ToolResult(success=False, error=blocked)
97
+
98
+ workspace = os.environ.get("SONICODER_WORKSPACE", "./workspace")
99
+ env = _scrub_env()
100
+ env["SONICODER_WORKSPACE"] = workspace
101
+
102
+ try:
103
+ proc = subprocess.run(
104
+ ["bash", "-c", command],
105
+ capture_output=True,
106
+ text=True,
107
+ timeout=timeout,
108
+ cwd=workspace,
109
+ env=env,
110
+ )
111
+
112
+ stdout = proc.stdout or ""
113
+ stderr = proc.stderr or ""
114
+ combined = stdout
115
+ if stderr:
116
+ combined += ("\n--- stderr ---\n" + stderr) if stdout else stderr
117
+
118
+ # Truncate large output
119
+ if len(combined) > self.MAX_OUTPUT:
120
+ combined = combined[:self.MAX_OUTPUT] + f"\n... [truncated, total {len(proc.stdout + proc.stderr)} chars]"
121
+
122
+ if proc.returncode == 0:
123
+ return ToolResult(
124
+ success=True,
125
+ output=combined or "(no output)",
126
+ metadata={"exit_code": 0},
127
+ )
128
+ else:
129
+ return ToolResult(
130
+ success=False,
131
+ output=combined,
132
+ error=f"Exit code: {proc.returncode}",
133
+ metadata={"exit_code": proc.returncode},
134
+ )
135
+ except subprocess.TimeoutExpired:
136
+ return ToolResult(success=False, error=f"Command timed out after {timeout}s")
137
+ except Exception as e:
138
+ return ToolResult(success=False, error=f"Execution error: {e}")
139
+
140
+
141
+ # ── Register ───────────────────────────────────────────────────────────
142
+
143
+ BUILTIN_TOOLS = [BashBuilder()]
sonicoder/tools/fs.py ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File system tools β€” read, write, edit, glob, grep, list_dir.
2
+
3
+ Inspired by Claude Code's FileRead/FileWrite/FileEdit and Gemini CLI's file tools.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import fnmatch
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
15
+
16
+ WORKSPACE = Path(os.environ.get("SONICODER_WORKSPACE", "./workspace")).resolve()
17
+
18
+
19
+ def _resolve_safe(path: str) -> Path:
20
+ """Resolve a path and ensure it stays within the workspace sandbox."""
21
+ if not path:
22
+ path = "."
23
+ p = Path(path)
24
+ if not p.is_absolute():
25
+ p = WORKSPACE / p
26
+ try:
27
+ p = p.resolve()
28
+ except (OSError, ValueError):
29
+ p = WORKSPACE / path
30
+ # Security: must be under workspace
31
+ try:
32
+ p.relative_to(WORKSPACE)
33
+ except ValueError:
34
+ raise ValueError(f"Path '{path}' escapes workspace")
35
+ return p
36
+
37
+
38
+ # ── Read File ──────────────────────────────────────────────────────────
39
+
40
+ class ReadFileBuilder(ToolBuilder):
41
+ name = "read_file"
42
+ description = (
43
+ "Read file contents with line numbers. Supports offset/limit for pagination. "
44
+ "Returns content, line count, and truncation info."
45
+ )
46
+ kind = ToolKind.READ
47
+ is_read_only = True
48
+
49
+ def get_schema(self) -> dict[str, Any]:
50
+ return {
51
+ "type": "object",
52
+ "properties": {
53
+ "path": {"type": "string", "description": "File path relative to workspace root"},
54
+ "offset": {"type": "integer", "description": "Start line (1-indexed)", "default": 1},
55
+ "limit": {"type": "integer", "description": "Max lines to return", "default": 500},
56
+ },
57
+ "required": ["path"],
58
+ }
59
+
60
+ def validate(self, params: dict[str, Any]) -> list[str]:
61
+ errors = []
62
+ if not params.get("path"):
63
+ errors.append("path is required")
64
+ return errors
65
+
66
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
67
+ return ReadFileInvocation(params)
68
+
69
+
70
+ class ReadFileInvocation(ToolInvocation):
71
+ def get_description(self) -> str:
72
+ return f"Read file: {self.params['path']}"
73
+
74
+ def get_affected_paths(self) -> list[str]:
75
+ return [self.params.get("path", "")]
76
+
77
+ def execute(self) -> ToolResult:
78
+ try:
79
+ path = _resolve_safe(self.params["path"])
80
+ if not path.exists():
81
+ return ToolResult(success=False, error=f"File not found: {self.params['path']}")
82
+ if not path.is_file():
83
+ return ToolResult(success=False, error=f"Not a file: {self.params['path']}")
84
+
85
+ offset = self.params.get("offset", 1) or 1
86
+ limit = self.params.get("limit", 500) or 500
87
+
88
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
89
+ total = len(lines)
90
+ start = max(0, offset - 1)
91
+ end = min(total, start + limit)
92
+ selected = lines[start:end]
93
+
94
+ # Add line numbers
95
+ numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
96
+ content = "\n".join(numbered)
97
+ truncated = end < total
98
+
99
+ meta = {"total_lines": total, "returned_lines": len(selected), "truncated": truncated}
100
+ if truncated:
101
+ meta["next_offset"] = end + 1
102
+
103
+ return ToolResult(success=True, output=content, metadata=meta)
104
+ except ValueError as e:
105
+ return ToolResult(success=False, error=str(e))
106
+ except Exception as e:
107
+ return ToolResult(success=False, error=f"Read error: {e}")
108
+
109
+
110
+ # ── Write File ─────────────────────────────────────────────────────────
111
+
112
+ class WriteFileBuilder(ToolBuilder):
113
+ name = "write_file"
114
+ description = (
115
+ "Write content to a file. Creates parent directories automatically. "
116
+ "Overwrites existing file content entirely."
117
+ )
118
+ kind = ToolKind.WRITE
119
+
120
+ def get_schema(self) -> dict[str, Any]:
121
+ return {
122
+ "type": "object",
123
+ "properties": {
124
+ "path": {"type": "string", "description": "File path relative to workspace root"},
125
+ "content": {"type": "string", "description": "Full file content to write"},
126
+ },
127
+ "required": ["path", "content"],
128
+ }
129
+
130
+ def validate(self, params: dict[str, Any]) -> list[str]:
131
+ errors = []
132
+ if not params.get("path"):
133
+ errors.append("path is required")
134
+ if "content" not in params:
135
+ errors.append("content is required")
136
+ return errors
137
+
138
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
139
+ return WriteFileInvocation(params)
140
+
141
+
142
+ class WriteFileInvocation(ToolInvocation):
143
+ def get_description(self) -> str:
144
+ return f"Write file: {self.params['path']}"
145
+
146
+ def get_affected_paths(self) -> list[str]:
147
+ return [self.params.get("path", "")]
148
+
149
+ def execute(self) -> ToolResult:
150
+ try:
151
+ path = _resolve_safe(self.params["path"])
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ path.write_text(self.params["content"], encoding="utf-8")
154
+ line_count = self.params["content"].count("\n") + 1
155
+ return ToolResult(
156
+ success=True,
157
+ output=f"Written {line_count} lines to {self.params['path']}",
158
+ metadata={"path": str(path), "lines": line_count},
159
+ )
160
+ except ValueError as e:
161
+ return ToolResult(success=False, error=str(e))
162
+ except Exception as e:
163
+ return ToolResult(success=False, error=f"Write error: {e}")
164
+
165
+
166
+ # ── Edit File (Search & Replace) ──────────────────────────────────────
167
+
168
+ class EditFileBuilder(ToolBuilder):
169
+ name = "edit_file"
170
+ description = (
171
+ "Edit a file by finding exact old_string and replacing with new_string. "
172
+ "Claude Code pattern: exact match, not line-based diffs. "
173
+ "Set replace_all=true to replace every occurrence."
174
+ )
175
+ kind = ToolKind.EDIT
176
+
177
+ def get_schema(self) -> dict[str, Any]:
178
+ return {
179
+ "type": "object",
180
+ "properties": {
181
+ "path": {"type": "string", "description": "File path relative to workspace root"},
182
+ "old_string": {"type": "string", "description": "Exact text to find"},
183
+ "new_string": {"type": "string", "description": "Replacement text"},
184
+ "replace_all": {"type": "boolean", "description": "Replace all occurrences", "default": False},
185
+ },
186
+ "required": ["path", "old_string", "new_string"],
187
+ }
188
+
189
+ def validate(self, params: dict[str, Any]) -> list[str]:
190
+ errors = []
191
+ if not params.get("path"):
192
+ errors.append("path is required")
193
+ if not params.get("old_string"):
194
+ errors.append("old_string is required")
195
+ if "new_string" not in params:
196
+ errors.append("new_string is required")
197
+ if params.get("old_string") == params.get("new_string"):
198
+ errors.append("old_string and new_string must differ")
199
+ return errors
200
+
201
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
202
+ return EditFileInvocation(params)
203
+
204
+
205
+ class EditFileInvocation(ToolInvocation):
206
+ def get_description(self) -> str:
207
+ p = self.params["path"]
208
+ old = self.params["old_string"][:50].replace("\n", "\\n")
209
+ return f"Edit {p}: '{old}...'"
210
+
211
+ def get_affected_paths(self) -> list[str]:
212
+ return [self.params.get("path", "")]
213
+
214
+ def execute(self) -> ToolResult:
215
+ try:
216
+ path = _resolve_safe(self.params["path"])
217
+ if not path.exists():
218
+ return ToolResult(success=False, error=f"File not found: {self.params['path']}")
219
+
220
+ content = path.read_text(encoding="utf-8")
221
+ old = self.params["old_string"]
222
+ new = self.params["new_string"]
223
+ replace_all = self.params.get("replace_all", False)
224
+
225
+ if old not in content:
226
+ # Fuzzy hint
227
+ return ToolResult(
228
+ success=False,
229
+ error=f"old_string not found in {self.params['path']}. "
230
+ f"Make sure the text matches the file exactly (including whitespace).",
231
+ )
232
+
233
+ count = content.count(old)
234
+ if replace_all:
235
+ new_content = content.replace(old, new)
236
+ else:
237
+ if count > 1:
238
+ return ToolResult(
239
+ success=False,
240
+ error=f"Found {count} occurrences. Use replace_all=true to replace all, "
241
+ f"or make old_string more specific.",
242
+ )
243
+ new_content = content.replace(old, new, 1)
244
+
245
+ path.write_text(new_content, encoding="utf-8")
246
+ return ToolResult(
247
+ success=True,
248
+ output=f"Replaced {count if replace_all else 1} occurrence(s) in {self.params['path']}",
249
+ metadata={"path": str(path), "replacements": count if replace_all else 1},
250
+ )
251
+ except ValueError as e:
252
+ return ToolResult(success=False, error=str(e))
253
+ except Exception as e:
254
+ return ToolResult(success=False, error=f"Edit error: {e}")
255
+
256
+
257
+ # ── Glob ───────────────────────────────────────────────────────────────
258
+
259
+ class GlobBuilder(ToolBuilder):
260
+ name = "glob"
261
+ description = (
262
+ "Find files matching a glob pattern (e.g., '**/*.py', 'src/**/*.ts'). "
263
+ "Searches recursively within the workspace."
264
+ )
265
+ kind = ToolKind.READ
266
+ is_read_only = True
267
+
268
+ def get_schema(self) -> dict[str, Any]:
269
+ return {
270
+ "type": "object",
271
+ "properties": {
272
+ "pattern": {"type": "string", "description": "Glob pattern (e.g., '**/*.py')"},
273
+ "path": {"type": "string", "description": "Subdirectory to search in", "default": "."},
274
+ },
275
+ "required": ["pattern"],
276
+ }
277
+
278
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
279
+ return GlobInvocation(params)
280
+
281
+
282
+ class GlobInvocation(ToolInvocation):
283
+ def get_description(self) -> str:
284
+ return f"Glob: {self.params['pattern']}"
285
+
286
+ def execute(self) -> ToolResult:
287
+ try:
288
+ base = _resolve_safe(self.params.get("path", "."))
289
+ pattern = self.params["pattern"]
290
+ if not base.exists():
291
+ return ToolResult(success=True, output="Directory not found", metadata={"matches": []})
292
+
293
+ matches = []
294
+ for root, dirs, files in os.walk(base):
295
+ # Skip hidden and common heavy dirs
296
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in
297
+ ("node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".git")]
298
+ for f in files:
299
+ full = os.path.join(root, f)
300
+ rel = os.path.relpath(full, WORKSPACE)
301
+ if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(f, pattern):
302
+ matches.append(rel)
303
+
304
+ matches.sort()
305
+ output = "\n".join(matches) if matches else "No matches found."
306
+ return ToolResult(success=True, output=output, metadata={"matches": matches, "count": len(matches)})
307
+ except ValueError as e:
308
+ return ToolResult(success=False, error=str(e))
309
+ except Exception as e:
310
+ return ToolResult(success=False, error=f"Glob error: {e}")
311
+
312
+
313
+ # ── Grep ───────────────────────────────────────────────────────────────
314
+
315
+ class GrepBuilder(ToolBuilder):
316
+ name = "grep"
317
+ description = (
318
+ "Search file contents using regex. Returns matching lines with file paths and line numbers. "
319
+ "Like ripgrep but simpler."
320
+ )
321
+ kind = ToolKind.READ
322
+ is_read_only = True
323
+
324
+ def get_schema(self) -> dict[str, Any]:
325
+ return {
326
+ "type": "object",
327
+ "properties": {
328
+ "pattern": {"type": "string", "description": "Regex pattern to search for"},
329
+ "path": {"type": "string", "description": "Subdirectory to search in", "default": "."},
330
+ "include": {"type": "string", "description": "File glob to include (e.g., '*.py')"},
331
+ "ignore_case": {"type": "boolean", "description": "Case insensitive", "default": False},
332
+ "max_results": {"type": "integer", "description": "Max matches to return", "default": 50},
333
+ },
334
+ "required": ["pattern"],
335
+ }
336
+
337
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
338
+ return GrepInvocation(params)
339
+
340
+
341
+ class GrepInvocation(ToolInvocation):
342
+ def get_description(self) -> str:
343
+ return f"Grep: {self.params['pattern']}"
344
+
345
+ def execute(self) -> ToolResult:
346
+ try:
347
+ base = _resolve_safe(self.params.get("path", "."))
348
+ pattern = self.params["pattern"]
349
+ include = self.params.get("include", "*")
350
+ ignore_case = self.params.get("ignore_case", False)
351
+ max_results = self.params.get("max_results", 50) or 50
352
+
353
+ flags = re.IGNORECASE if ignore_case else 0
354
+ regex = re.compile(pattern, flags)
355
+ results = []
356
+ count = 0
357
+
358
+ for root, dirs, files in os.walk(base):
359
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in
360
+ ("node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".git")]
361
+ for f in files:
362
+ if not fnmatch.fnmatch(f, include):
363
+ continue
364
+ try:
365
+ filepath = os.path.join(root, f)
366
+ rel = os.path.relpath(filepath, WORKSPACE)
367
+ lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines()
368
+ for i, line in enumerate(lines, 1):
369
+ if regex.search(line):
370
+ results.append(f"{rel}:{i}: {line.strip()}")
371
+ count += 1
372
+ if count >= max_results:
373
+ output = "\n".join(results)
374
+ output += f"\n\n... truncated at {max_results} matches"
375
+ return ToolResult(
376
+ success=True, output=output,
377
+ metadata={"matches": results, "count": count, "truncated": True},
378
+ )
379
+ except (OSError, UnicodeDecodeError):
380
+ continue
381
+
382
+ output = "\n".join(results) if results else "No matches found."
383
+ return ToolResult(
384
+ success=True, output=output,
385
+ metadata={"matches": results, "count": count, "truncated": False},
386
+ )
387
+ except ValueError as e:
388
+ return ToolResult(success=False, error=str(e))
389
+ except re.error as e:
390
+ return ToolResult(success=False, error=f"Invalid regex: {e}")
391
+ except Exception as e:
392
+ return ToolResult(success=False, error=f"Grep error: {e}")
393
+
394
+
395
+ # ── List Directory ─────────────────────────────────────────────────────
396
+
397
+ class ListDirBuilder(ToolBuilder):
398
+ name = "list_dir"
399
+ description = "List files and directories. Returns sorted listing with type indicators."
400
+ kind = ToolKind.READ
401
+ is_read_only = True
402
+
403
+ def get_schema(self) -> dict[str, Any]:
404
+ return {
405
+ "type": "object",
406
+ "properties": {
407
+ "path": {"type": "string", "description": "Directory path (default: workspace root)", "default": "."},
408
+ },
409
+ }
410
+
411
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
412
+ return ListDirInvocation(params)
413
+
414
+
415
+ class ListDirInvocation(ToolInvocation):
416
+ def get_description(self) -> str:
417
+ return f"List dir: {self.params.get('path', '.')}"
418
+
419
+ def execute(self) -> ToolResult:
420
+ try:
421
+ path = _resolve_safe(self.params.get("path", "."))
422
+ if not path.exists():
423
+ return ToolResult(success=False, error=f"Directory not found: {self.params.get('path', '.')}")
424
+ if not path.is_dir():
425
+ return ToolResult(success=False, error=f"Not a directory: {self.params.get('path', '.')}")
426
+
427
+ entries = []
428
+ for item in sorted(path.iterdir()):
429
+ if item.name.startswith("."):
430
+ continue
431
+ if item.is_dir():
432
+ entries.append(f" [DIR] {item.name}/")
433
+ else:
434
+ size = item.stat().st_size
435
+ if size > 1024 * 1024:
436
+ size_str = f"{size / 1024 / 1024:.1f}MB"
437
+ elif size > 1024:
438
+ size_str = f"{size / 1024:.1f}KB"
439
+ else:
440
+ size_str = f"{size}B"
441
+ entries.append(f" [FILE] {item.name} ({size_str})")
442
+
443
+ output = "\n".join(entries) if entries else "(empty directory)"
444
+ return ToolResult(success=True, output=output, metadata={"entries": len(entries)})
445
+ except ValueError as e:
446
+ return ToolResult(success=False, error=str(e))
447
+ except Exception as e:
448
+ return ToolResult(success=False, error=f"List error: {e}")
449
+
450
+
451
+ # ── Workspace Snapshot ─────────────────────────────────────────────────
452
+
453
+ class SnapshotBuilder(ToolBuilder):
454
+ name = "snapshot_workspace"
455
+ description = "Get a snapshot of all workspace files and their contents. Useful for getting full project state."
456
+ kind = ToolKind.READ
457
+ is_read_only = True
458
+
459
+ def get_schema(self) -> dict[str, Any]:
460
+ return {
461
+ "type": "object",
462
+ "properties": {},
463
+ }
464
+
465
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
466
+ return SnapshotInvocation(params)
467
+
468
+
469
+ class SnapshotInvocation(ToolInvocation):
470
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
471
+ ".sonicoder"}
472
+
473
+ def execute(self) -> ToolResult:
474
+ try:
475
+ files = {}
476
+ for root, dirs, filenames in os.walk(WORKSPACE):
477
+ dirs[:] = [d for d in dirs if d not in self.SKIP_DIRS and not d.startswith(".")]
478
+ for f in filenames:
479
+ if f.startswith("."):
480
+ continue
481
+ fp = os.path.join(root, f)
482
+ rel = os.path.relpath(fp, WORKSPACE)
483
+ try:
484
+ files[rel] = fp if os.path.getsize(fp) > 500000 else \
485
+ Path(fp).read_text(encoding="utf-8", errors="replace")
486
+ except (OSError, UnicodeDecodeError):
487
+ files[rel] = f"[binary file, {os.path.getsize(fp)} bytes]"
488
+
489
+ output = "\n".join(f" {k}" for k in sorted(files.keys()))
490
+ return ToolResult(
491
+ success=True, output=output,
492
+ metadata={"files": files, "count": len(files)},
493
+ )
494
+ except Exception as e:
495
+ return ToolResult(success=False, error=f"Snapshot error: {e}")
496
+
497
+
498
+ # ── Register All ───────────────────────────────────────────────────────
499
+
500
+ BUILTIN_TOOLS = [
501
+ ReadFileBuilder(),
502
+ WriteFileBuilder(),
503
+ EditFileBuilder(),
504
+ GlobBuilder(),
505
+ GrepBuilder(),
506
+ ListDirBuilder(),
507
+ SnapshotBuilder(),
508
+ ]
sonicoder/tools/todos.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Todo management tool β€” per-session task tracking.
2
+
3
+ Inspired by Claude Code's TodoWrite/TaskCreate.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import threading
9
+ from typing import Any
10
+
11
+ from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
12
+
13
+ # ── In-Memory Store ────────────────────────────────────────────────────
14
+
15
+ _store: dict[str, list[dict]] = {}
16
+ _lock = threading.Lock()
17
+
18
+
19
+ def _get_todos(session_id: str) -> list[dict]:
20
+ with _lock:
21
+ return _store.setdefault(session_id, [])
22
+
23
+
24
+ # ── Todo Tool ──────────────────────────────────────────────────────────
25
+
26
+ class TodoBuilder(ToolBuilder):
27
+ name = "todo"
28
+ description = (
29
+ "Manage a task todo list. Actions: 'read' (list all), 'write' (create), "
30
+ "'update' (change status/content), 'clear' (remove all). "
31
+ "Each todo has: id, content, status (pending/in_progress/completed)."
32
+ )
33
+ kind = ToolKind.OTHER
34
+
35
+ def get_schema(self) -> dict[str, Any]:
36
+ return {
37
+ "type": "object",
38
+ "properties": {
39
+ "action": {
40
+ "type": "string",
41
+ "enum": ["read", "write", "update", "clear"],
42
+ "description": "Action to perform",
43
+ },
44
+ "id": {"type": "string", "description": "Todo ID (for update)"},
45
+ "content": {"type": "string", "description": "Todo content (for write/update)"},
46
+ "status": {"type": "string", "description": "Status: pending/in_progress/completed"},
47
+ },
48
+ "required": ["action"],
49
+ }
50
+
51
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
52
+ return TodoInvocation(params, session_id="")
53
+
54
+
55
+ class TodoInvocation(ToolInvocation):
56
+ def __init__(self, params: dict[str, Any], session_id: str = ""):
57
+ super().__init__(params)
58
+ self.session_id = session_id
59
+
60
+ def get_description(self) -> str:
61
+ return f"Todo: {self.params['action']}"
62
+
63
+ def execute(self) -> ToolResult:
64
+ action = self.params["action"]
65
+ todos = _get_todos(self.session_id)
66
+
67
+ if action == "read":
68
+ if not todos:
69
+ return ToolResult(success=True, output="No todos.")
70
+ lines = []
71
+ for t in todos:
72
+ status_icon = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[ ]")
73
+ lines.append(f" {status_icon} {t['id']}: {t['content']}")
74
+ return ToolResult(success=True, output="\n".join(lines), metadata={"todos": todos})
75
+
76
+ elif action == "write":
77
+ content = self.params.get("content", "")
78
+ if not content:
79
+ return ToolResult(success=False, error="content is required for write action")
80
+ new_id = str(len(todos) + 1)
81
+ todo = {"id": new_id, "content": content, "status": "pending"}
82
+ todos.append(todo)
83
+ return ToolResult(success=True, output=f"Added todo {new_id}: {content}", metadata={"todo": todo})
84
+
85
+ elif action == "update":
86
+ tid = self.params.get("id", "")
87
+ if not tid:
88
+ return ToolResult(success=False, error="id is required for update action")
89
+ for t in todos:
90
+ if t["id"] == tid:
91
+ if "content" in self.params and self.params["content"]:
92
+ t["content"] = self.params["content"]
93
+ if "status" in self.params and self.params["status"]:
94
+ if self.params["status"] not in ("pending", "in_progress", "completed"):
95
+ return ToolResult(success=False, error="Invalid status")
96
+ t["status"] = self.params["status"]
97
+ return ToolResult(success=True, output=f"Updated todo {tid}", metadata={"todo": t})
98
+ return ToolResult(success=False, error=f"Todo {tid} not found")
99
+
100
+ elif action == "clear":
101
+ todos.clear()
102
+ return ToolResult(success=True, output="All todos cleared.")
103
+
104
+ return ToolResult(success=False, error=f"Unknown action: {action}")
105
+
106
+
107
+ # ── Register ───────────────────────────────────────────────────────────
108
+
109
+ BUILTIN_TOOLS = [TodoBuilder()]
sonicoder/tools/web.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web tools β€” search and fetch.
2
+
3
+ Inspired by Claude Code's WebSearch/WebFetch and Gemini CLI's google_web_search/web_fetch.
4
+ No API keys needed β€” uses HTML scraping like original SoniCoder.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+ from urllib.parse import quote_plus, urljoin
12
+
13
+ import requests
14
+ from bs4 import BeautifulSoup
15
+
16
+ from . import ToolBuilder, ToolInvocation, ToolKind, ToolResult
17
+
18
+ HEADERS = {
19
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
20
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
21
+ }
22
+
23
+
24
+ # ── Web Search ─────────────────────────────────────────────────────────
25
+
26
+ class WebSearchBuilder(ToolBuilder):
27
+ name = "web_search"
28
+ description = (
29
+ "Search the web using DuckDuckGo. No API key needed. "
30
+ "Returns numbered results with titles, URLs, and snippets."
31
+ )
32
+ kind = ToolKind.OTHER
33
+ is_read_only = True
34
+
35
+ def get_schema(self) -> dict[str, Any]:
36
+ return {
37
+ "type": "object",
38
+ "properties": {
39
+ "query": {"type": "string", "description": "Search query"},
40
+ "max_results": {"type": "integer", "description": "Max results (default 8)", "default": 8},
41
+ },
42
+ "required": ["query"],
43
+ }
44
+
45
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
46
+ return WebSearchInvocation(params)
47
+
48
+
49
+ class WebSearchInvocation(ToolInvocation):
50
+ def get_description(self) -> str:
51
+ return f"Web search: {self.params['query'][:60]}"
52
+
53
+ def execute(self) -> ToolResult:
54
+ query = self.params["query"]
55
+ max_results = self.params.get("max_results", 8) or 8
56
+
57
+ try:
58
+ # DuckDuckGo HTML search
59
+ url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
60
+ resp = requests.get(url, headers=HEADERS, timeout=15)
61
+ resp.raise_for_status()
62
+
63
+ soup = BeautifulSoup(resp.text, "html.parser")
64
+ results = []
65
+
66
+ for result in soup.select(".result"):
67
+ title_el = result.select_one(".result__title a, .result__a")
68
+ snippet_el = result.select_one(".result__snippet")
69
+ if title_el:
70
+ title = title_el.get_text(strip=True)
71
+ href = title_el.get("href", "")
72
+ # Clean DuckDuckGo redirect URL
73
+ if "uddg=" in href:
74
+ import urllib.parse
75
+ parsed = urllib.parse.parse_qs(urllib.parse.urlparse(href).query)
76
+ href = parsed.get("uddg", [href])[0]
77
+ snippet = snippet_el.get_text(strip=True) if snippet_el else ""
78
+ results.append(f"{len(results) + 1}. {title}\n {href}\n {snippet}")
79
+ if len(results) >= max_results:
80
+ break
81
+
82
+ if not results:
83
+ return ToolResult(success=True, output="No results found.", metadata={"results": []})
84
+
85
+ output = "\n\n".join(results)
86
+ return ToolResult(
87
+ success=True, output=output,
88
+ metadata={"results": results, "count": len(results)},
89
+ )
90
+ except requests.RequestException as e:
91
+ return ToolResult(success=False, error=f"Search failed: {e}")
92
+ except Exception as e:
93
+ return ToolResult(success=False, error=f"Search error: {e}")
94
+
95
+
96
+ # ── Web Fetch ──────────────────────────────────────────────────────────
97
+
98
+ class WebFetchBuilder(ToolBuilder):
99
+ name = "web_fetch"
100
+ description = (
101
+ "Fetch and extract text content from a URL. "
102
+ "Returns the main text content (strips HTML tags, scripts, styles)."
103
+ )
104
+ kind = ToolKind.OTHER
105
+ is_read_only = True
106
+
107
+ def get_schema(self) -> dict[str, Any]:
108
+ return {
109
+ "type": "object",
110
+ "properties": {
111
+ "url": {"type": "string", "description": "URL to fetch"},
112
+ },
113
+ "required": ["url"],
114
+ }
115
+
116
+ def create_invocation(self, params: dict[str, Any]) -> ToolInvocation:
117
+ return WebFetchInvocation(params)
118
+
119
+
120
+ class WebFetchInvocation(ToolInvocation):
121
+ MAX_CONTENT = 30000
122
+
123
+ def get_description(self) -> str:
124
+ return f"Fetch: {self.params['url'][:80]}"
125
+
126
+ def execute(self) -> ToolResult:
127
+ url = self.params["url"]
128
+ if not url.startswith(("http://", "https://")):
129
+ url = "https://" + url
130
+
131
+ try:
132
+ resp = requests.get(url, headers=HEADERS, timeout=20)
133
+ resp.raise_for_status()
134
+
135
+ soup = BeautifulSoup(resp.text, "html.parser")
136
+
137
+ # Remove scripts, styles, nav, footer
138
+ for tag in soup.select("script, style, nav, footer, header, aside, iframe"):
139
+ tag.decompose()
140
+
141
+ # Extract text
142
+ text = soup.get_text(separator="\n", strip=True)
143
+
144
+ # Clean up whitespace
145
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
146
+ text = "\n".join(lines)
147
+
148
+ if len(text) > self.MAX_CONTENT:
149
+ text = text[:self.MAX_CONTENT] + f"\n... [truncated at {self.MAX_CONTENT} chars]"
150
+
151
+ return ToolResult(
152
+ success=True, output=text,
153
+ metadata={"url": url, "chars": len(text), "title": soup.title.string if soup.title else ""},
154
+ )
155
+ except requests.RequestException as e:
156
+ return ToolResult(success=False, error=f"Fetch failed: {e}")
157
+ except Exception as e:
158
+ return ToolResult(success=False, error=f"Fetch error: {e}")
159
+
160
+
161
+ # ── Register ───────────────────────────────────────────────────────────
162
+
163
+ BUILTIN_TOOLS = [WebSearchBuilder(), WebFetchBuilder()]
static/index.html ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SoniCoder v2 β€” AI Code Agent</title>
7
+ <style>
8
+ :root {
9
+ --bg-primary: #0f0f13;
10
+ --bg-secondary: #16161d;
11
+ --bg-tertiary: #1e1e2a;
12
+ --bg-hover: #252535;
13
+ --text-primary: #e4e4f0;
14
+ --text-secondary: #9393a8;
15
+ --text-muted: #6b6b80;
16
+ --accent: #7c6ef0;
17
+ --accent-hover: #6b5de0;
18
+ --accent-light: rgba(124,110,240,0.12);
19
+ --success: #4ade80;
20
+ --warning: #fbbf24;
21
+ --error: #f87171;
22
+ --info: #60a5fa;
23
+ --border: #2a2a3a;
24
+ --code-bg: #1a1a28;
25
+ --radius: 12px;
26
+ --radius-sm: 8px;
27
+ --shadow: 0 4px 24px rgba(0,0,0,0.4);
28
+ }
29
+ * { margin:0; padding:0; box-sizing:border-box; }
30
+ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:var(--bg-primary); color:var(--text-primary); height:100vh; overflow:hidden; }
31
+
32
+ /* Layout */
33
+ .app { display:flex; height:100vh; }
34
+ .sidebar { width:280px; background:var(--bg-secondary); border-right:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; }
35
+ .main { flex:1; display:flex; flex-direction:column; min-width:0; }
36
+ .workspace-panel { width:300px; background:var(--bg-secondary); border-left:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; transition:width 0.3s; overflow:hidden; }
37
+ .workspace-panel.collapsed { width:0; border-left:none; }
38
+
39
+ /* Sidebar */
40
+ .sidebar-header { padding:20px; border-bottom:1px solid var(--border); }
41
+ .logo { font-size:20px; font-weight:700; background:linear-gradient(135deg, #7c6ef0, #a78bfa, #60a5fa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
42
+ .logo-sub { font-size:11px; color:var(--text-muted); margin-top:2px; letter-spacing:1px; text-transform:uppercase; }
43
+ .model-selector { padding:16px 20px; border-bottom:1px solid var(--border); }
44
+ .model-selector label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.5px; display:block; margin-bottom:8px; }
45
+ .model-selector select { width:100%; padding:8px 12px; background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; outline:none; }
46
+ .model-selector select:focus { border-color:var(--accent); }
47
+ .model-status { padding:8px 20px; border-bottom:1px solid var(--border); }
48
+ .status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
49
+ .status-dot.ready { background:var(--success); box-shadow:0 0 6px var(--success); }
50
+ .status-dot.loading { background:var(--warning); animation:pulse 1.5s infinite; }
51
+ .status-dot.error { background:var(--error); }
52
+ .status-dot.unknown { background:var(--text-muted); }
53
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
54
+ .status-text { font-size:12px; color:var(--text-secondary); }
55
+
56
+ .sidebar-section { padding:16px 20px; }
57
+ .sidebar-section h3 { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:12px; }
58
+ .sidebar-info { font-size:12px; color:var(--text-secondary); line-height:1.6; }
59
+ .sidebar-info code { background:var(--bg-tertiary); padding:2px 6px; border-radius:4px; font-size:11px; color:var(--accent); }
60
+
61
+ .new-chat-btn { margin:16px 20px; padding:10px; background:var(--accent); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; }
62
+ .new-chat-btn:hover { background:var(--accent-hover); transform:translateY(-1px); }
63
+
64
+ /* Chat Area */
65
+ .chat-header { padding:16px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
66
+ .chat-header h2 { font-size:15px; font-weight:600; }
67
+ .header-actions { display:flex; gap:8px; }
68
+ .header-btn { padding:6px 12px; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:12px; cursor:pointer; transition:all 0.2s; }
69
+ .header-btn:hover { background:var(--bg-hover); color:var(--text-primary); }
70
+
71
+ .chat-messages { flex:1; overflow-y:auto; padding:24px; scroll-behavior:smooth; }
72
+ .chat-messages::-webkit-scrollbar { width:6px; }
73
+ .chat-messages::-webkit-scrollbar-track { background:transparent; }
74
+ .chat-messages::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
75
+
76
+ .message { margin-bottom:24px; animation:fadeIn 0.3s ease; }
77
+ @keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
78
+ .message-header { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
79
+ .message-avatar { width:28px; height:28px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:700; }
80
+ .message-avatar.user { background:var(--accent); color:white; }
81
+ .message-avatar.agent { background:var(--bg-tertiary); color:var(--accent); border:1px solid var(--border); }
82
+ .message-role { font-size:13px; font-weight:600; }
83
+ .message-time { font-size:11px; color:var(--text-muted); }
84
+
85
+ .message-body { padding-left:36px; line-height:1.7; font-size:14px; color:var(--text-primary); }
86
+ .message-body p { margin-bottom:8px; }
87
+ .message-body code { background:var(--code-bg); padding:2px 6px; border-radius:4px; font-size:13px; font-family:'JetBrains Mono','Fira Code',monospace; }
88
+ .message-body pre { background:var(--code-bg); padding:16px; border-radius:var(--radius-sm); overflow-x:auto; margin:12px 0; border:1px solid var(--border); }
89
+ .message-body pre code { background:none; padding:0; font-size:13px; line-height:1.5; }
90
+ .message-body ul, .message-body ol { padding-left:20px; margin:8px 0; }
91
+ .message-body li { margin:4px 0; }
92
+
93
+ /* Tool Calls */
94
+ .tool-call { background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius-sm); margin:12px 0; padding:12px 16px; }
95
+ .tool-call-header { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
96
+ .tool-icon { font-size:14px; }
97
+ .tool-name { font-size:13px; font-weight:600; color:var(--accent); }
98
+ .tool-status { font-size:11px; padding:2px 8px; border-radius:10px; font-weight:600; }
99
+ .tool-status.success { background:rgba(74,222,128,0.12); color:var(--success); }
100
+ .tool-status.error { background:rgba(248,113,113,0.12); color:var(--error); }
101
+ .tool-status.running { background:rgba(251,191,36,0.12); color:var(--warning); }
102
+ .tool-args { font-size:12px; color:var(--text-muted); margin-bottom:8px; }
103
+ .tool-result { font-size:12px; background:var(--bg-primary); border-radius:6px; padding:8px 12px; max-height:300px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; font-family:'JetBrains Mono',monospace; line-height:1.5; color:var(--text-secondary); }
104
+ .tool-result::-webkit-scrollbar { width:4px; }
105
+ .tool-result::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
106
+
107
+ /* Iteration Badge */
108
+ .iteration-badge { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:var(--text-muted); background:var(--bg-tertiary); padding:2px 10px; border-radius:10px; margin-bottom:8px; }
109
+
110
+ /* Input Area */
111
+ .chat-input { padding:16px 24px 24px; border-top:1px solid var(--border); }
112
+ .input-wrapper { display:flex; align-items:flex-end; gap:12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:12px 16px; transition:border-color 0.2s; }
113
+ .input-wrapper:focus-within { border-color:var(--accent); }
114
+ .input-wrapper textarea { flex:1; background:none; border:none; outline:none; color:var(--text-primary); font-size:14px; font-family:inherit; resize:none; max-height:200px; line-height:1.5; }
115
+ .input-wrapper textarea::placeholder { color:var(--text-muted); }
116
+ .send-btn { padding:8px 20px; background:var(--accent); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; white-space:nowrap; }
117
+ .send-btn:hover { background:var(--accent-hover); }
118
+ .send-btn:disabled { opacity:0.5; cursor:not-allowed; }
119
+ .stop-btn { padding:8px 20px; background:var(--error); color:white; border:none; border-radius:var(--radius-sm); font-size:13px; font-weight:600; cursor:pointer; }
120
+ .input-hint { font-size:11px; color:var(--text-muted); margin-top:8px; text-align:center; }
121
+
122
+ /* Workspace Panel */
123
+ .ws-header { padding:16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
124
+ .ws-header h3 { font-size:13px; font-weight:600; }
125
+ .ws-tree { flex:1; overflow-y:auto; padding:12px 16px; font-family:'JetBrains Mono',monospace; font-size:12px; line-height:1.8; color:var(--text-secondary); white-space:pre; }
126
+ .ws-tree::-webkit-scrollbar { width:4px; }
127
+ .ws-tree::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
128
+
129
+ /* Typing indicator */
130
+ .typing-indicator { display:flex; gap:4px; padding:8px 0; }
131
+ .typing-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); animation:typingBounce 1.4s infinite; }
132
+ .typing-dot:nth-child(2) { animation-delay:0.2s; }
133
+ .typing-dot:nth-child(3) { animation-delay:0.4s; }
134
+ @keyframes typingBounce { 0%,60%,100%{transform:translateY(0)} 30%{transform:translateY(-6px)} }
135
+
136
+ /* Example prompts */
137
+ .examples { padding:16px 20px; }
138
+ .example-btn { display:block; width:100%; text-align:left; padding:10px 12px; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border); border-radius:var(--radius-sm); font-size:12px; margin-bottom:8px; cursor:pointer; transition:all 0.2s; line-height:1.4; }
139
+ .example-btn:hover { background:var(--bg-hover); color:var(--text-primary); border-color:var(--accent); }
140
+
141
+ /* Responsive */
142
+ @media (max-width:900px) {
143
+ .sidebar { display:none; }
144
+ .workspace-panel { display:none; }
145
+ }
146
+ </style>
147
+ </head>
148
+ <body>
149
+ <div class="app">
150
+ <!-- Sidebar -->
151
+ <aside class="sidebar">
152
+ <div class="sidebar-header">
153
+ <div class="logo">SoniCoder v2</div>
154
+ <div class="logo-sub">AI Code Agent</div>
155
+ </div>
156
+
157
+ <div class="model-selector">
158
+ <label>Model</label>
159
+ <select id="modelSelect"></select>
160
+ </div>
161
+
162
+ <div class="model-status">
163
+ <span class="status-dot unknown" id="statusDot"></span>
164
+ <span class="status-text" id="statusText">Initializing...</span>
165
+ </div>
166
+
167
+ <button class="new-chat-btn" onclick="newChat()">+ New Chat</button>
168
+
169
+ <div class="sidebar-section">
170
+ <h3>Quick Start</h3>
171
+ <div class="sidebar-info">
172
+ Ask me to build anything β€” a web app, API, script, or full-stack project. I'll create the files and verify they work.
173
+ </div>
174
+ </div>
175
+
176
+ <div class="examples" id="examplesPanel">
177
+ <div class="example-btn" onclick="useExample(this)">Build a Flask REST API with user CRUD endpoints</div>
178
+ <div class="example-btn" onclick="useExample(this)">Create a React todo app with localStorage persistence</div>
179
+ <div class="example-btn" onclick="useExample(this)">Write a Python web scraper with error handling</div>
180
+ <div class="example-btn" onclick="useExample(this)">Build a CLI tool that converts Markdown to HTML</div>
181
+ <div class="example-btn" onclick="useExample(this)">Create a FastAPI backend with JWT authentication</div>
182
+ </div>
183
+ </aside>
184
+
185
+ <!-- Main Chat -->
186
+ <div class="main">
187
+ <div class="chat-header">
188
+ <h2>Chat</h2>
189
+ <div class="header-actions">
190
+ <button class="header-btn" onclick="toggleWorkspace()">Workspace</button>
191
+ <button class="header-btn" onclick="refreshWorkspace()">Refresh</button>
192
+ </div>
193
+ </div>
194
+
195
+ <div class="chat-messages" id="chatMessages">
196
+ <div class="message" style="text-align:center; padding:60px 20px;">
197
+ <div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>
198
+ <div style="color:var(--text-secondary); font-size:14px; max-width:500px; margin:0 auto; line-height:1.7;">
199
+ AI-powered code agent that writes, runs, and debugs code. Built with patterns from Gemini CLI and Claude Code. Supports MCP tools and multiple 1B models.
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <div class="chat-input">
205
+ <div class="input-wrapper">
206
+ <textarea id="chatInput" placeholder="Describe what you want to build..." rows="1" onkeydown="handleKeyDown(event)" oninput="autoResize(this)"></textarea>
207
+ <button class="send-btn" id="sendBtn" onclick="sendMessage()">Send</button>
208
+ </div>
209
+ <div class="input-hint">Press Enter to send, Shift+Enter for new line</div>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- Workspace Panel -->
214
+ <div class="workspace-panel" id="workspacePanel">
215
+ <div class="ws-header">
216
+ <h3>Workspace</h3>
217
+ </div>
218
+ <div class="ws-tree" id="wsTree">Loading...</div>
219
+ </div>
220
+ </div>
221
+
222
+ <script>
223
+ const __CONFIG__ = __RUNTIME_CONFIG__ || {};
224
+
225
+ // ── State ──
226
+ let sessionId = '';
227
+ let isStreaming = false;
228
+ let abortController = null;
229
+ let currentToolCalls = {};
230
+
231
+ // ── Init ──
232
+ document.addEventListener('DOMContentLoaded', () => {
233
+ loadConfig();
234
+ pollModelStatus();
235
+ refreshWorkspace();
236
+ });
237
+
238
+ async function loadConfig() {
239
+ try {
240
+ const resp = await fetch('/api/get_config');
241
+ const config = await resp.json();
242
+ const parsed = typeof config === 'string' ? JSON.parse(config) : config;
243
+
244
+ const select = document.getElementById('modelSelect');
245
+ select.innerHTML = '';
246
+ (parsed.models || []).forEach(m => {
247
+ const opt = document.createElement('option');
248
+ opt.value = m.key;
249
+ opt.textContent = `${m.name} (${m.size_gb}GB)`;
250
+ if (m.key === parsed.default_model) opt.selected = true;
251
+ select.appendChild(opt);
252
+ });
253
+
254
+ select.addEventListener('change', () => switchModel(select.value));
255
+ } catch(e) { console.error('Config load failed:', e); }
256
+ }
257
+
258
+ async function pollModelStatus() {
259
+ try {
260
+ const resp = await fetch('/api/model_status');
261
+ const status = JSON.parse(await resp.json());
262
+ const dot = document.getElementById('statusDot');
263
+ const text = document.getElementById('statusText');
264
+
265
+ dot.className = 'status-dot ' + (status.status || 'unknown');
266
+ const names = { ready:'Ready', loading:'Loading...', error:'Error', unknown:'Unknown' };
267
+ text.textContent = (status.model_name || names[status.status] || 'Unknown');
268
+
269
+ if (status.status === 'loading') {
270
+ setTimeout(pollModelStatus, 3000);
271
+ }
272
+ } catch(e) { setTimeout(pollModelStatus, 5000); }
273
+ }
274
+
275
+ async function switchModel(key) {
276
+ try {
277
+ await fetch('/api/switch_model', { method:'POST', body: new URLSearchParams({data: JSON.stringify([key])}), headers:{'Content-Type':'application/x-www-form-urlencoded'} });
278
+ document.getElementById('statusDot').className = 'status-dot loading';
279
+ document.getElementById('statusText').textContent = 'Switching...';
280
+ setTimeout(pollModelStatus, 2000);
281
+ } catch(e) { console.error(e); }
282
+ }
283
+
284
+ async function refreshWorkspace() {
285
+ try {
286
+ const resp = await fetch('/api/workspace_tree', { method:'POST', body: new URLSearchParams({data: JSON.stringify([3])}), headers:{'Content-Type':'application/x-www-form-urlencoded'} });
287
+ const data = JSON.parse(await resp.json());
288
+ document.getElementById('wsTree').textContent = data.tree || '(empty)';
289
+ } catch(e) { console.error(e); }
290
+ }
291
+
292
+ function toggleWorkspace() {
293
+ document.getElementById('workspacePanel').classList.toggle('collapsed');
294
+ }
295
+
296
+ function newChat() {
297
+ if (sessionId) {
298
+ fetch('/api/reset_session', { method:'POST', body: new URLSearchParams({data: JSON.stringify([sessionId])}), headers:{'Content-Type':'application/x-www-form-urlencoded'} });
299
+ }
300
+ sessionId = '';
301
+ document.getElementById('chatMessages').innerHTML = `
302
+ <div class="message" style="text-align:center; padding:60px 20px;">
303
+ <div style="font-size:28px; margin-bottom:12px; opacity:0.6;">SoniCoder v2</div>
304
+ <div style="color:var(--text-secondary); font-size:14px;">New session started. What would you like to build?</div>
305
+ </div>`;
306
+ }
307
+
308
+ function useExample(btn) {
309
+ document.getElementById('chatInput').value = btn.textContent.trim();
310
+ sendMessage();
311
+ }
312
+
313
+ function handleKeyDown(e) {
314
+ if (e.key === 'Enter' && !e.shiftKey) {
315
+ e.preventDefault();
316
+ sendMessage();
317
+ }
318
+ }
319
+
320
+ function autoResize(el) {
321
+ el.style.height = 'auto';
322
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px';
323
+ }
324
+
325
+ // ── Message Rendering ──
326
+ function appendMessage(role, content, extra={}) {
327
+ const container = document.getElementById('chatMessages');
328
+ const div = document.createElement('div');
329
+ div.className = 'message';
330
+ div.id = extra.msgId || '';
331
+
332
+ const time = new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
333
+ const avatarClass = role === 'user' ? 'user' : 'agent';
334
+ const avatarText = role === 'user' ? 'Y' : 'SC';
335
+
336
+ div.innerHTML = `
337
+ <div class="message-header">
338
+ <div class="message-avatar ${avatarClass}">${avatarText}</div>
339
+ <span class="message-role">${role === 'user' ? 'You' : 'SoniCoder'}</span>
340
+ <span class="message-time">${time}</span>
341
+ ${extra.iteration ? `<span class="iteration-badge">Iteration ${extra.iteration}</span>` : ''}
342
+ </div>
343
+ <div class="message-body">${formatContent(content)}</div>`;
344
+
345
+ container.appendChild(div);
346
+ container.scrollTop = container.scrollHeight;
347
+ return div;
348
+ }
349
+
350
+ function formatContent(text) {
351
+ if (!text) return '';
352
+ // Code blocks
353
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
354
+ return `<pre><code class="language-${lang}">${escapeHtml(code.trim())}</code></pre>`;
355
+ });
356
+ // Inline code
357
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
358
+ // Bold
359
+ text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
360
+ // Lists
361
+ text = text.replace(/^[-β€’] (.+)$/gm, '<li>$1</li>');
362
+ text = text.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
363
+ // Line breaks
364
+ text = text.replace(/\n/g, '<br>');
365
+ return text;
366
+ }
367
+
368
+ function escapeHtml(text) {
369
+ const d = document.createElement('div');
370
+ d.textContent = text;
371
+ return d.innerHTML;
372
+ }
373
+
374
+ function addToolCall(toolName, args) {
375
+ const container = document.getElementById('chatMessages');
376
+ const id = 'tc_' + Date.now();
377
+ const div = document.createElement('div');
378
+ div.className = 'tool-call';
379
+ div.id = id;
380
+ div.innerHTML = `
381
+ <div class="tool-call-header">
382
+ <span class="tool-icon">⚑</span>
383
+ <span class="tool-name">${escapeHtml(toolName)}</span>
384
+ <span class="tool-status running">Running</span>
385
+ </div>
386
+ <div class="tool-args">${formatToolArgs(args)}</div>
387
+ <div class="tool-result" style="display:none;"></div>`;
388
+ container.appendChild(div);
389
+ container.scrollTop = container.scrollHeight;
390
+ currentToolCalls[id] = div;
391
+ return id;
392
+ }
393
+
394
+ function updateToolCall(id, success, output) {
395
+ const div = currentToolCalls[id];
396
+ if (!div) return;
397
+ const status = div.querySelector('.tool-status');
398
+ const result = div.querySelector('.tool-result');
399
+ status.className = 'tool-status ' + (success ? 'success' : 'error');
400
+ status.textContent = success ? 'Done' : 'Error';
401
+ if (output) {
402
+ result.textContent = output;
403
+ result.style.display = 'block';
404
+ }
405
+ document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
406
+ }
407
+
408
+ function formatToolArgs(args) {
409
+ if (!args) return '';
410
+ const lines = Object.entries(args).map(([k, v]) => {
411
+ const val = typeof v === 'string' ? (v.length > 60 ? v.slice(0, 60) + '...' : v) : JSON.stringify(v);
412
+ return `${k}: ${val}`;
413
+ });
414
+ return escapeHtml(lines.join('\n'));
415
+ }
416
+
417
+ // ── Streaming ──
418
+ let currentStreamDiv = null;
419
+ let currentStreamContent = '';
420
+
421
+ function startStreaming() {
422
+ currentStreamContent = '';
423
+ const container = document.getElementById('chatMessages');
424
+ // Remove welcome message if present
425
+ const welcome = container.querySelector('.message[style*="text-align:center"]');
426
+ if (welcome) welcome.remove();
427
+
428
+ currentStreamDiv = appendMessage('agent', '', {});
429
+ const body = currentStreamDiv.querySelector('.message-body');
430
+ body.innerHTML = '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
431
+ }
432
+
433
+ function updateStreaming(text) {
434
+ if (!currentStreamDiv) return;
435
+ const body = currentStreamDiv.querySelector('.message-body');
436
+ body.innerHTML = formatContent(text) || '<div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
437
+ document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
438
+ }
439
+
440
+ function finalizeStream(text) {
441
+ if (!currentStreamDiv) return;
442
+ const body = currentStreamDiv.querySelector('.message-body');
443
+ body.innerHTML = formatContent(text);
444
+ currentStreamDiv = null;
445
+ }
446
+
447
+ // ── Send Message ──
448
+ async function sendMessage() {
449
+ const input = document.getElementById('chatInput');
450
+ const message = input.value.trim();
451
+ if (!message || isStreaming) return;
452
+
453
+ input.value = '';
454
+ input.style.height = 'auto';
455
+ isStreaming = true;
456
+
457
+ const sendBtn = document.getElementById('sendBtn');
458
+ sendBtn.textContent = 'Stop';
459
+ sendBtn.className = 'stop-btn';
460
+ sendBtn.onclick = stopStreaming;
461
+
462
+ appendMessage('user', message);
463
+ startStreaming();
464
+
465
+ try {
466
+ const resp = await fetch('/api/chat', {
467
+ method: 'POST',
468
+ body: new URLSearchParams({
469
+ data: JSON.stringify([message, sessionId, '']),
470
+ }),
471
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
472
+ });
473
+
474
+ const reader = resp.body.getReader();
475
+ const decoder = new TextDecoder();
476
+ let buffer = '';
477
+
478
+ while (true) {
479
+ const { done, value } = await reader.read();
480
+ if (done) break;
481
+
482
+ buffer += decoder.decode(value, { stream: true });
483
+ const lines = buffer.split('\n');
484
+ buffer = lines.pop() || '';
485
+
486
+ for (const line of lines) {
487
+ if (!line.trim()) continue;
488
+ try {
489
+ const event = JSON.parse(line);
490
+
491
+ switch (event.type) {
492
+ case 'streaming':
493
+ const display = (event.content || '').replace(/```tool[\s\S]*?```/g, '').trim();
494
+ updateStreaming(display);
495
+ break;
496
+
497
+ case 'tool_call':
498
+ addToolCall(event.tool, event.args);
499
+ break;
500
+
501
+ case 'tool_result':
502
+ // Find the latest tool call for this tool name
503
+ const tcIds = Object.keys(currentToolCalls).reverse();
504
+ for (const tcId of tcIds) {
505
+ const tcDiv = currentToolCalls[tcId];
506
+ if (tcDiv && tcDiv.querySelector('.tool-status.running')) {
507
+ updateToolCall(tcId, event.result.success, event.result.output || event.result.error);
508
+ break;
509
+ }
510
+ }
511
+ break;
512
+
513
+ case 'status':
514
+ if (event.iteration) {
515
+ const header = currentStreamDiv?.querySelector('.message-header');
516
+ if (header) {
517
+ let badge = header.querySelector('.iteration-badge');
518
+ if (!badge) {
519
+ badge = document.createElement('span');
520
+ badge.className = 'iteration-badge';
521
+ header.appendChild(badge);
522
+ }
523
+ badge.textContent = `Iteration ${event.iteration}/${event.max_iterations}`;
524
+ }
525
+ }
526
+ break;
527
+
528
+ case 'complete':
529
+ const finalText = event.content || currentStreamContent;
530
+ finalizeStream(finalText || 'Done.');
531
+ if (event.iterations) {
532
+ setTimeout(refreshWorkspace, 500);
533
+ }
534
+ break;
535
+
536
+ case 'error':
537
+ finalizeStream(`Error: ${event.error}`);
538
+ break;
539
+ }
540
+ } catch (e) { /* skip malformed lines */ }
541
+ }
542
+ }
543
+ } catch (e) {
544
+ if (e.name !== 'AbortError') {
545
+ finalizeStream(`Connection error: ${e.message}`);
546
+ }
547
+ }
548
+
549
+ isStreaming = false;
550
+ sendBtn.textContent = 'Send';
551
+ sendBtn.className = 'send-btn';
552
+ sendBtn.onclick = sendMessage;
553
+ }
554
+
555
+ function stopStreaming() {
556
+ // The streaming will naturally end when we close
557
+ isStreaming = false;
558
+ const sendBtn = document.getElementById('sendBtn');
559
+ sendBtn.textContent = 'Send';
560
+ sendBtn.className = 'send-btn';
561
+ sendBtn.onclick = sendMessage;
562
+ if (currentStreamDiv) {
563
+ finalizeStream(currentStreamContent || '(stopped)');
564
+ }
565
+ }
566
+ </script>
567
+ </body>
568
+ </html>