R-Kentaren commited on
Commit
e3e7994
·
verified ·
1 Parent(s): f76a56f

fix: consolidate to code/ only, fix bugs, add missing UI options

Browse files

- Removed duplicated sonicoder/ package (paths now only in code/)
- Removed duplicate static/index.html
- Added code/mcp/ and code/policy/ modules (previously missing)
- Added create_app() to code/server/__init__.py
- Added v2 REST endpoints to code/server/routes.py
- Fixed UnboundLocalError in code/model/loader.py switch_model
- Fixed JSONResponse default=str bug in routes.py
- Added UI: language/framework/skill/agent selectors, web search toggle, image upload
- Updated app.py to import from code.* only

app.py CHANGED
@@ -1,18 +1,18 @@
1
  """SoniCoder v2 — Entry point.
2
 
3
- Uses FastAPI directly (not gradio.Server) so our custom index.html
4
- is always served at / without Gradio overriding the root route.
5
 
6
- Model is loaded BLOCKING before server starts — no lazy loading.
7
  """
8
 
9
  from __future__ import annotations
10
 
11
  import logging
12
- import os
13
 
14
- # Ensure workspace exists
15
- from sonicoder.config import WORKSPACE_ROOT, CONFIG_DIR
 
16
  WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
17
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
18
 
@@ -27,39 +27,38 @@ logger = logging.getLogger(__name__)
27
  def main():
28
  logger.info("SoniCoder v2 starting...")
29
 
30
- # ── BLOCKING model load — model must be ready BEFORE server starts ──
31
- from sonicoder.model.loader import load_model
32
- from sonicoder.config import load_settings
33
- settings = load_settings()
34
- model_key = settings.default_model
35
- logger.info(f"Loading model {model_key} (blocking)...")
 
36
  try:
37
- load_model(model_key)
38
- logger.info("Model loaded successfully — ready to serve")
39
- except Exception as e:
40
- logger.error(f"Failed to load model: {e}")
41
- logger.info("Server will start anyway — model can be loaded later via API")
42
-
43
- # ── Set up MCP servers from config ─────────────────────────────────
44
- from sonicoder.mcp import get_mcp_manager
45
- if settings.mcp_servers:
46
- mcp = get_mcp_manager()
47
- mcp.configure(settings.mcp_servers)
48
- results = mcp.connect_all()
49
- for name, error in results.items():
50
- if error:
51
- logger.warning(f"MCP server '{name}' failed: {error}")
52
- else:
53
- logger.info(f"MCP server '{name}' connected")
54
-
55
- # ── Create the FastAPI server ──────────────────────────────────────
56
- from sonicoder.server import create_app
57
  app = create_app()
58
 
59
- logger.info("Launching on 0.0.0.0:7860...")
60
- import uvicorn
61
- uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")
62
 
63
 
64
  if __name__ == "__main__":
65
- main()
 
1
  """SoniCoder v2 — Entry point.
2
 
3
+ gradio.Server (Gradio 6.x) serves index.html from project root
4
+ with Gradio's queuing, concurrency, and API infrastructure.
5
 
6
+ Blog: https://huggingface.co/blog/introducing-gradio-server
7
  """
8
 
9
  from __future__ import annotations
10
 
11
  import logging
 
12
 
13
+ # Ensure workspace + config dirs exist
14
+ from code.config import WORKSPACE_ROOT, CONFIG_DIR
15
+
16
  WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
17
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
18
 
 
27
  def main():
28
  logger.info("SoniCoder v2 starting...")
29
 
30
+ # Start loading default model in background
31
+ from code.model.loader import start_background_load
32
+
33
+ start_background_load()
34
+ logger.info("Background model loading started")
35
+
36
+ # Set up MCP servers from config (best-effort)
37
  try:
38
+ from code.config import load_settings
39
+ from code.mcp import get_mcp_manager
40
+
41
+ settings = load_settings()
42
+ if settings.mcp_servers:
43
+ mcp = get_mcp_manager()
44
+ mcp.configure(settings.mcp_servers)
45
+ results = mcp.connect_all()
46
+ for name, error in results.items():
47
+ if error:
48
+ logger.warning("MCP server '%s' failed: %s", name, error)
49
+ else:
50
+ logger.info("MCP server '%s' connected", name)
51
+ except Exception as exc:
52
+ logger.warning("MCP setup skipped: %s", exc)
53
+
54
+ # Create the server (gradio.Server — custom HTML + Gradio backend)
55
+ from code.server import create_app
56
+
 
57
  app = create_app()
58
 
59
+ logger.info("Launching...")
60
+ app.launch(show_error=True, server_name="0.0.0.0", server_port=7860)
 
61
 
62
 
63
  if __name__ == "__main__":
64
+ main()
code/config/__init__.py CHANGED
@@ -1 +1,274 @@
1
- """Configuration and constants."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration system — hierarchical settings + constants.
2
+
3
+ This module combines:
4
+ - constants.py (app-wide constants, model registry, system prompt, languages)
5
+ - settings (hierarchical Settings, MCP server config, policy rules, load_settings)
6
+
7
+ Path layout:
8
+ - WORKSPACE_ROOT: where the agent's sandboxed filesystem lives
9
+ - CONFIG_DIR: where settings/agents/hooks/skills are stored (default: .sonicoder/)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ # ── Re-export constants for backward compatibility ─────────────────────
21
+ from code.config.constants import (
22
+ APP_TITLE,
23
+ DEFAULT_MAX_TOKENS,
24
+ DEFAULT_MODEL_KEY,
25
+ DEFAULT_TEMPERATURE,
26
+ EXAMPLE_PROMPTS,
27
+ LANGUAGE_MAP,
28
+ LANGUAGE_OPTIONS,
29
+ MODEL_CONFIGS,
30
+ MODEL_ID,
31
+ MODEL_URL,
32
+ SYSTEM_PROMPT,
33
+ )
34
+
35
+ # ── Path Constants ─────────────────────────────────────────────────────
36
+
37
+ WORKSPACE_ROOT = Path(
38
+ os.environ.get(
39
+ "SONICODER_WORKSPACE",
40
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "workspace")),
41
+ )
42
+ )
43
+
44
+ CONFIG_DIR = Path(
45
+ os.environ.get(
46
+ "SONICODER_CONFIG",
47
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".sonicoder")),
48
+ )
49
+ )
50
+
51
+
52
+ # ── Runtime Defaults (override constants) ──────────────────────────────
53
+
54
+ DEFAULT_MAX_ITERATIONS = 12
55
+ AGENT_LOOP_TIMEOUT = 300 # seconds
56
+ BASH_TIMEOUT = 120
57
+ PYTHON_TIMEOUT = 30
58
+
59
+
60
+ # ── Settings Dataclasses ───────────────────────────────────────────────
61
+
62
+
63
+ @dataclass
64
+ class ModelConfig:
65
+ """Configuration for a single model."""
66
+
67
+ name: str
68
+ model_id: str
69
+ model_type: str # "text" | "vlm"
70
+ size_gb: float
71
+ description: str
72
+ dtype: str = "float16"
73
+ trust_remote_code: bool = True
74
+ device: str = "auto"
75
+
76
+
77
+ @dataclass
78
+ class MCPServerConfig:
79
+ """Configuration for an MCP server."""
80
+
81
+ name: str
82
+ transport: str # "stdio" | "sse"
83
+ command: str | None = None
84
+ args: list[str] = field(default_factory=list)
85
+ env: dict[str, str] = field(default_factory=dict)
86
+ url: str | None = None
87
+ enabled: bool = True
88
+
89
+
90
+ @dataclass
91
+ class PolicyRule:
92
+ """A policy rule for tool approval."""
93
+
94
+ tool_name: str # supports "*" wildcard
95
+ args_pattern: str | None = None
96
+ approval: str = "ask" # "allow" | "ask" | "deny"
97
+ priority: int = 0
98
+ description: str = ""
99
+
100
+
101
+ @dataclass
102
+ class Settings:
103
+ """Hierarchical settings — cascades from global → project → local."""
104
+
105
+ # Model
106
+ default_model: str = DEFAULT_MODEL_KEY
107
+ temperature: float = DEFAULT_TEMPERATURE
108
+ max_tokens: int = DEFAULT_MAX_TOKENS
109
+
110
+ # Agent
111
+ max_iterations: int = DEFAULT_MAX_ITERATIONS
112
+ agent_timeout: int = AGENT_LOOP_TIMEOUT
113
+
114
+ # Tools
115
+ enabled_tools: list[str] = field(default_factory=lambda: ["*"])
116
+ disabled_tools: list[str] = field(default_factory=list)
117
+
118
+ # Safety
119
+ sandbox_enabled: bool = True
120
+ bash_timeout: int = BASH_TIMEOUT
121
+ python_timeout: int = PYTHON_TIMEOUT
122
+
123
+ # MCP
124
+ mcp_servers: list[MCPServerConfig] = field(default_factory=list)
125
+
126
+ # Policy
127
+ policy_rules: list[PolicyRule] = field(default_factory=list)
128
+
129
+ # Context
130
+ max_context_tokens: int = 32768
131
+ auto_compact: bool = True
132
+
133
+ @classmethod
134
+ def from_dict(cls, data: dict[str, Any]) -> Settings:
135
+ """Create settings from a dictionary, ignoring unknown fields."""
136
+ known_fields = {f.name for f in cls.__dataclass_fields__.values()}
137
+ filtered = {k: v for k, v in data.items() if k in known_fields}
138
+ return cls(**filtered)
139
+
140
+ def merge(self, other: Settings) -> Settings:
141
+ """Merge another settings object (other takes precedence for non-default values)."""
142
+ result = Settings(
143
+ default_model=other.default_model or self.default_model,
144
+ temperature=(
145
+ other.temperature
146
+ if other.temperature != DEFAULT_TEMPERATURE
147
+ else self.temperature
148
+ ),
149
+ max_tokens=(
150
+ other.max_tokens
151
+ if other.max_tokens != DEFAULT_MAX_TOKENS
152
+ else self.max_tokens
153
+ ),
154
+ max_iterations=(
155
+ other.max_iterations
156
+ if other.max_iterations != DEFAULT_MAX_ITERATIONS
157
+ else self.max_iterations
158
+ ),
159
+ agent_timeout=(
160
+ other.agent_timeout
161
+ if other.agent_timeout != AGENT_LOOP_TIMEOUT
162
+ else self.agent_timeout
163
+ ),
164
+ sandbox_enabled=other.sandbox_enabled,
165
+ bash_timeout=other.bash_timeout,
166
+ python_timeout=other.python_timeout,
167
+ auto_compact=other.auto_compact,
168
+ )
169
+ # Merge lists
170
+ result.enabled_tools = (
171
+ other.enabled_tools if other.enabled_tools != ["*"] else self.enabled_tools
172
+ )
173
+ result.disabled_tools = list(set(self.disabled_tools + other.disabled_tools))
174
+ result.mcp_servers = self.mcp_servers + other.mcp_servers
175
+ result.policy_rules = other.policy_rules + self.policy_rules
176
+ return result
177
+
178
+
179
+ # ── Public Helpers ─────────────────────────────────────────────────────
180
+
181
+
182
+ def get_available_models() -> dict[str, dict[str, Any]]:
183
+ """Return all registered model configs (from constants.MODEL_CONFIGS)."""
184
+ return MODEL_CONFIGS.copy()
185
+
186
+
187
+ def get_model_config(model_key: str) -> dict[str, Any] | None:
188
+ """Get a model config by key."""
189
+ return MODEL_CONFIGS.get(model_key)
190
+
191
+
192
+ def load_settings(project_dir: Path | None = None) -> Settings:
193
+ """Load settings with hierarchical cascading.
194
+
195
+ Priority (highest → lowest):
196
+ 1. Environment variables
197
+ 2. .sonicoder/settings.local.json (local overrides, gitignored)
198
+ 3. .sonicoder/settings.json (project settings)
199
+ 4. Defaults
200
+ """
201
+ settings = Settings()
202
+
203
+ search_dirs = [Path.cwd()]
204
+ if project_dir:
205
+ search_dirs.insert(0, project_dir)
206
+
207
+ # Load project-level settings
208
+ for d in search_dirs:
209
+ local_path = d / CONFIG_DIR / "settings.local.json"
210
+ proj_path = d / CONFIG_DIR / "settings.json"
211
+
212
+ for path in [local_path, proj_path]:
213
+ if path.exists():
214
+ try:
215
+ data = json.loads(path.read_text(encoding="utf-8"))
216
+ # Parse MCP server configs
217
+ if "mcp_servers" in data:
218
+ data["mcp_servers"] = [
219
+ MCPServerConfig(**s) for s in data["mcp_servers"]
220
+ ]
221
+ if "policy_rules" in data:
222
+ data["policy_rules"] = [
223
+ PolicyRule(**r) for r in data["policy_rules"]
224
+ ]
225
+ settings = settings.merge(Settings.from_dict(data))
226
+ except (json.JSONDecodeError, TypeError) as e:
227
+ print(f"[config] Warning: Failed to parse {path}: {e}")
228
+
229
+ # Environment variable overrides
230
+ env_model = os.environ.get("SONICODER_MODEL")
231
+ if env_model and env_model in MODEL_CONFIGS:
232
+ settings.default_model = env_model
233
+
234
+ env_temp = os.environ.get("SONICODER_TEMPERATURE")
235
+ if env_temp:
236
+ try:
237
+ settings.temperature = float(env_temp)
238
+ except ValueError:
239
+ pass
240
+
241
+ return settings
242
+
243
+
244
+ __all__ = [
245
+ # Constants
246
+ "APP_TITLE",
247
+ "DEFAULT_MAX_TOKENS",
248
+ "DEFAULT_MODEL_KEY",
249
+ "DEFAULT_TEMPERATURE",
250
+ "EXAMPLE_PROMPTS",
251
+ "LANGUAGE_MAP",
252
+ "LANGUAGE_OPTIONS",
253
+ "MODEL_CONFIGS",
254
+ "MODEL_ID",
255
+ "MODEL_URL",
256
+ "SYSTEM_PROMPT",
257
+ # Path constants
258
+ "WORKSPACE_ROOT",
259
+ "CONFIG_DIR",
260
+ # Runtime defaults
261
+ "DEFAULT_MAX_ITERATIONS",
262
+ "AGENT_LOOP_TIMEOUT",
263
+ "BASH_TIMEOUT",
264
+ "PYTHON_TIMEOUT",
265
+ # Dataclasses
266
+ "ModelConfig",
267
+ "MCPServerConfig",
268
+ "PolicyRule",
269
+ "Settings",
270
+ # Helpers
271
+ "get_available_models",
272
+ "get_model_config",
273
+ "load_settings",
274
+ ]
{sonicoder → code}/mcp/__init__.py RENAMED
@@ -17,13 +17,14 @@ 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
 
@@ -31,7 +32,13 @@ class MCPToolBridge:
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
@@ -47,7 +54,9 @@ class MCPToolBridge:
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,
@@ -60,6 +69,7 @@ class MCPToolBridge:
60
 
61
  # ── MCP Client Manager ─────────────────────────────────────────────────
62
 
 
63
  class MCPClientManager:
64
  """Manages multiple MCP server connections.
65
 
@@ -99,7 +109,7 @@ class MCPClientManager:
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:
@@ -115,7 +125,9 @@ class MCPClientManager:
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:
@@ -156,15 +168,17 @@ class MCPClientManager:
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]:
@@ -175,7 +189,9 @@ class MCPClientManager:
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
 
@@ -189,8 +205,8 @@ class MCPClientManager:
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)"
@@ -212,7 +228,11 @@ class MCPClientManager:
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":
@@ -221,12 +241,12 @@ class MCPClientManager:
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}
@@ -243,7 +263,9 @@ class MCPClientManager:
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
 
@@ -269,7 +291,9 @@ class MCPClientManager:
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")
@@ -278,13 +302,14 @@ class MCPClientManager:
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."""
@@ -306,12 +331,14 @@ class _AsyncSessionWrapper:
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)
@@ -319,6 +346,7 @@ class _AsyncSessionWrapper:
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
 
@@ -333,4 +361,7 @@ def get_mcp_manager() -> MCPClientManager:
333
  global _global_manager
334
  if _global_manager is None:
335
  _global_manager = MCPClientManager()
336
- return _global_manager
 
 
 
 
17
  import threading
18
  from typing import Any, Callable
19
 
20
+ from code.config import MCPServerConfig
21
 
22
  logger = logging.getLogger(__name__)
23
 
24
 
25
  # ── MCP Tool Bridge ────────────────────────────────────────────────────
26
 
27
+
28
  class MCPToolBridge:
29
  """Wraps an MCP tool as a ToolBuilder, making it indistinguishable from built-in tools.
30
 
 
32
  interface so they get the same policy, confirmation, and scheduling treatment.
33
  """
34
 
35
+ def __init__(
36
+ self,
37
+ server_name: str,
38
+ tool_name: str,
39
+ tool_schema: dict,
40
+ call_fn: Callable,
41
+ ):
42
  self.server_name = server_name
43
  self.tool_name = tool_name
44
  self._schema = tool_schema
 
54
  return {
55
  "name": self.name,
56
  "description": self.description,
57
+ "parameters": self._schema.get(
58
+ "inputSchema", {"type": "object", "properties": {}}
59
+ ),
60
  "kind": self.kind,
61
  "mcp_server": self.server_name,
62
  "mcp_tool": self.tool_name,
 
69
 
70
  # ── MCP Client Manager ─────────────────────────────────────────────────
71
 
72
+
73
  class MCPClientManager:
74
  """Manages multiple MCP server connections.
75
 
 
109
  results[name] = error
110
  except Exception as e:
111
  results[name] = str(e)
112
+ logger.error("MCP connect failed for %s: %s", name, e)
113
  return results
114
 
115
  def disconnect_all(self) -> None:
 
125
  """Get a specific MCP tool by its full name (mcp_server_tool)."""
126
  return self._tools.get(name)
127
 
128
+ def call_tool(
129
+ self, server_name: str, tool_name: str, arguments: dict[str, Any]
130
+ ) -> dict[str, Any]:
131
  """Call an MCP tool on a specific server."""
132
  client = self._clients.get(server_name)
133
  if not client:
 
168
  if client and hasattr(client, "list_resources"):
169
  try:
170
  result = client.list_resources()
171
+ for r in result.resources if hasattr(result, "resources") else []:
172
+ resources.append(
173
+ {
174
+ "server": name,
175
+ "uri": getattr(r, "uri", str(r)),
176
+ "name": getattr(r, "name", ""),
177
+ "description": getattr(r, "description", ""),
178
+ }
179
+ )
180
  except Exception as e:
181
+ logger.warning("Failed to list resources from %s: %s", name, e)
182
  return resources
183
 
184
  def get_status(self) -> dict[str, Any]:
 
189
  "enabled": config.enabled,
190
  "connected": name in self._connected,
191
  "transport": config.transport,
192
+ "tool_count": sum(
193
+ 1 for t in self._tools.values() if t.server_name == name
194
+ ),
195
  }
196
  return status
197
 
 
205
  try:
206
  # Try to import MCP SDK
207
  try:
208
+ from mcp import ClientSession, StdioServerParameters # noqa: F401
209
+ from mcp.client.stdio import stdio_client # noqa: F401
210
  except ImportError:
211
  logger.info("MCP SDK not installed. Skipping MCP server %s", name)
212
  return "mcp SDK not installed (pip install mcp)"
 
228
 
229
  # Discover tools
230
  self._discover_tools(name, client_session)
231
+ logger.info(
232
+ "MCP server '%s' connected with %d tools",
233
+ name,
234
+ sum(1 for t in self._tools.values() if t.server_name == name),
235
+ )
236
  return None
237
 
238
  elif config.transport == "sse":
 
241
  return f"Unknown transport: {config.transport}"
242
 
243
  except Exception as e:
244
+ logger.error("MCP connect error for %s: %s", name, e)
245
  return str(e)
246
 
247
  def _run_async_connect(self, name: str, server_params):
248
  """Run async MCP connection in a sync context."""
249
+ from mcp import ClientSession
250
  from mcp.client.stdio import stdio_client
251
 
252
  result = {"session": None, "error": None}
 
263
  result["error"] = str(e)
264
 
265
  loop = asyncio.new_event_loop()
266
+ thread = threading.Thread(
267
+ target=loop.run_until_complete, args=(_connect_async(),), daemon=True
268
+ )
269
  thread.start()
270
  thread.join(timeout=10)
271
 
 
291
  for tool in tools:
292
  schema = {
293
  "description": getattr(tool, "description", ""),
294
+ "inputSchema": getattr(
295
+ tool, "inputSchema", {"type": "object", "properties": {}}
296
+ ),
297
  "annotations": getattr(tool, "annotations", {}),
298
  }
299
  tool_name = getattr(tool, "name", "unknown")
 
302
  server_name=server_name,
303
  tool_name=tool_name,
304
  tool_schema=schema,
305
+ call_fn=lambda sn=server_name, tn=tool_name, args=None:
306
+ self.call_tool(sn, tn, args or {}),
307
  )
308
  self._tools[bridge.name] = bridge
309
+ logger.debug("Discovered MCP tool: %s", bridge.name)
310
 
311
  except Exception as e:
312
+ logger.warning("Failed to discover tools from %s: %s", server_name, e)
313
 
314
  def _disconnect(self, name: str) -> None:
315
  """Disconnect from an MCP server."""
 
331
  def __getattr__(self, name):
332
  attr = getattr(self._session, name)
333
  if asyncio.iscoroutinefunction(attr):
334
+
335
  def sync_wrapper(*args, **kwargs):
336
  try:
337
  loop = asyncio.get_event_loop()
338
  if loop.is_running():
339
  # Can't await in running loop, return a placeholder
340
  import concurrent.futures
341
+
342
  with concurrent.futures.ThreadPoolExecutor() as pool:
343
  future = pool.submit(asyncio.run, attr(*args, **kwargs))
344
  return future.result(timeout=30)
 
346
  return loop.run_until_complete(attr(*args, **kwargs))
347
  except RuntimeError:
348
  return asyncio.run(attr(*args, **kwargs))
349
+
350
  return sync_wrapper
351
  return attr
352
 
 
361
  global _global_manager
362
  if _global_manager is None:
363
  _global_manager = MCPClientManager()
364
+ return _global_manager
365
+
366
+
367
+ __all__ = ["MCPToolBridge", "MCPClientManager", "get_mcp_manager"]
code/model/loader.py CHANGED
@@ -170,7 +170,7 @@ def start_background_load(model_key: str | None = None) -> threading.Thread:
170
 
171
  def switch_model(model_key: str) -> dict[str, Any]:
172
  """Switch to a different model. Returns status immediately, loads in background."""
173
- global _current_model_key
174
 
175
  if model_key not in MODEL_CONFIGS:
176
  return {"success": False, "message": f"Unknown model: {model_key}"}
 
170
 
171
  def switch_model(model_key: str) -> dict[str, Any]:
172
  """Switch to a different model. Returns status immediately, loads in background."""
173
+ global _current_model_key, _model_loaded
174
 
175
  if model_key not in MODEL_CONFIGS:
176
  return {"success": False, "message": f"Unknown model: {model_key}"}
{sonicoder → code}/policy/__init__.py RENAMED
@@ -6,7 +6,6 @@ and pattern matching. Also incorporates Claude Code's allow/ask/deny model.
6
 
7
  from __future__ import annotations
8
 
9
- import fnmatch
10
  import re
11
  from dataclasses import dataclass
12
  from enum import Enum
@@ -22,6 +21,7 @@ class ApprovalDecision(Enum):
22
  @dataclass
23
  class PolicyCheckResult:
24
  """Result of a policy check."""
 
25
  decision: ApprovalDecision
26
  reason: str = ""
27
  matched_rule: str | None = None
@@ -79,6 +79,13 @@ class PolicyEngine:
79
  "priority": -100,
80
  "description": "Allow file edits in sandboxed workspace",
81
  },
 
 
 
 
 
 
 
82
  {
83
  "tool_name": "read_file",
84
  "args_pattern": None,
@@ -122,11 +129,25 @@ class PolicyEngine:
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",
@@ -172,7 +193,9 @@ class PolicyEngine:
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.
@@ -201,12 +224,17 @@ class PolicyEngine:
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
 
@@ -242,4 +270,12 @@ def get_policy_engine() -> PolicyEngine:
242
  global _global_engine
243
  if _global_engine is None:
244
  _global_engine = PolicyEngine()
245
- return _global_engine
 
 
 
 
 
 
 
 
 
6
 
7
  from __future__ import annotations
8
 
 
9
  import re
10
  from dataclasses import dataclass
11
  from enum import Enum
 
21
  @dataclass
22
  class PolicyCheckResult:
23
  """Result of a policy check."""
24
+
25
  decision: ApprovalDecision
26
  reason: str = ""
27
  matched_rule: str | None = None
 
79
  "priority": -100,
80
  "description": "Allow file edits in sandboxed workspace",
81
  },
82
+ {
83
+ "tool_name": "multi_edit",
84
+ "args_pattern": None,
85
+ "approval": "allow",
86
+ "priority": -100,
87
+ "description": "Allow multi-file edits in sandboxed workspace",
88
+ },
89
  {
90
  "tool_name": "read_file",
91
  "args_pattern": None,
 
129
  "description": "Allow web fetch",
130
  },
131
  {
132
+ "tool_name": "todo_read",
133
+ "args_pattern": None,
134
+ "approval": "allow",
135
+ "priority": -200,
136
+ "description": "Allow todo reads",
137
+ },
138
+ {
139
+ "tool_name": "todo_write",
140
  "args_pattern": None,
141
  "approval": "allow",
142
  "priority": -200,
143
+ "description": "Allow todo writes",
144
+ },
145
+ {
146
+ "tool_name": "todo_update",
147
+ "args_pattern": None,
148
+ "approval": "allow",
149
+ "priority": -200,
150
+ "description": "Allow todo updates",
151
  },
152
  {
153
  "tool_name": "snapshot_workspace",
 
193
  for r in rules:
194
  self.add_rule(r)
195
 
196
+ def check(
197
+ self, tool_name: str, args: dict[str, Any] | None = None
198
+ ) -> PolicyCheckResult:
199
  """Check if a tool call is allowed.
200
 
201
  Returns the decision and reason from the highest-priority matching rule.
 
224
  approval = "ask"
225
 
226
  # Auto-edit mode: auto-approve file writes/edits
227
+ if (
228
+ self._approval_mode == "auto_edit"
229
+ and tool_name in ("write_file", "edit_file", "multi_edit")
230
+ ):
231
  approval = "allow"
232
 
233
  return PolicyCheckResult(
234
  decision=ApprovalDecision(approval),
235
+ reason=rule.get(
236
+ "description", f"Rule matched: {rule['tool_name']}"
237
+ ),
238
  matched_rule=rule.get("description"),
239
  )
240
 
 
270
  global _global_engine
271
  if _global_engine is None:
272
  _global_engine = PolicyEngine()
273
+ return _global_engine
274
+
275
+
276
+ __all__ = [
277
+ "ApprovalDecision",
278
+ "PolicyCheckResult",
279
+ "PolicyEngine",
280
+ "get_policy_engine",
281
+ ]
code/server/__init__.py CHANGED
@@ -1 +1,31 @@
1
- """FastAPI / Gradio server routes."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI / Gradio Server entry point.
2
+
3
+ Provides `create_app()` which returns the configured `gradio.Server` instance
4
+ with all HTTP routes and Gradio API endpoints registered.
5
+
6
+ The actual routes are defined in `code/server/routes.py`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ def create_app() -> Any:
15
+ """Create and return the configured Gradio Server app instance.
16
+
17
+ This is a thin wrapper around `code.server.routes.get_app()` that
18
+ triggers all the route decorators when the routes module is first
19
+ imported.
20
+ """
21
+ from code.server.routes import get_app # noqa: F401 (import has side effects)
22
+
23
+ return get_app()
24
+
25
+
26
+ def get_app() -> Any:
27
+ """Backward-compatible alias for create_app()."""
28
+ return create_app()
29
+
30
+
31
+ __all__ = ["create_app", "get_app"]
code/server/routes.py CHANGED
@@ -33,11 +33,13 @@ import json
33
  import logging
34
  import os
35
  import tempfile
 
36
  from pathlib import Path
37
  from typing import Any, Optional
38
 
39
  import gradio as gr
40
- from fastapi.responses import HTMLResponse, FileResponse
 
41
  try:
42
  from gradio import Server
43
  except ImportError:
@@ -717,6 +719,416 @@ def get_app() -> Server:
717
  return app
718
 
719
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
720
  # ─── Agent / Skills / Commands / Hooks / Workspace Endpoints ──────────
721
 
722
 
 
33
  import logging
34
  import os
35
  import tempfile
36
+ import uuid
37
  from pathlib import Path
38
  from typing import Any, Optional
39
 
40
  import gradio as gr
41
+ from fastapi import Request
42
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, StreamingResponse
43
  try:
44
  from gradio import Server
45
  except ImportError:
 
719
  return app
720
 
721
 
722
+ # ─── V2 REST Endpoints (used by root index.html) ──────────────────────
723
+ # These are plain FastAPI routes that the v2 frontend (index.html at project
724
+ # root) calls with `fetch()`. They live alongside the @app.api Gradio
725
+ # endpoints above; the two API styles don't conflict because they use
726
+ # different URL patterns (/api/<name> for Gradio, /api/<name> for REST
727
+ # with different methods / payloads).
728
+
729
+
730
+ def _json_response(data: Any) -> JSONResponse:
731
+ """JSONResponse that handles non-serializable objects via str() fallback."""
732
+ return JSONResponse(content=json.loads(json.dumps(data, default=str)))
733
+
734
+
735
+ # ─── Session state for v2 streaming chat ──────────────────────────────
736
+
737
+ _v2_sessions: dict[str, dict[str, Any]] = {}
738
+
739
+
740
+ def _v2_get_or_create_session(session_id: str = "") -> tuple[str, dict[str, Any]]:
741
+ """Get or create a per-session state dict (chat history + agent context)."""
742
+ if not session_id:
743
+ session_id = str(uuid.uuid4())[:12]
744
+ if session_id not in _v2_sessions:
745
+ _v2_sessions[session_id] = {
746
+ "history": [],
747
+ "active_agent": None,
748
+ }
749
+ return session_id, _v2_sessions[session_id]
750
+
751
+
752
+ @app.get("/api/get_config")
753
+ async def v2_get_config():
754
+ """Return runtime config: models, default model, workspace, version."""
755
+ models = []
756
+ for key, cfg in MODEL_CONFIGS.items():
757
+ models.append({
758
+ "key": key,
759
+ "name": cfg["name"],
760
+ "model_type": cfg["type"],
761
+ "size_gb": cfg.get("size_gb", 0),
762
+ "description": cfg.get("description", ""),
763
+ })
764
+
765
+ # Best-effort load of skills / commands / hooks / agents
766
+ skills_list: list[Any] = []
767
+ commands_list: list[Any] = []
768
+ hooks_list: list[Any] = []
769
+ agents_list: list[Any] = []
770
+ active_agent: Any = None
771
+
772
+ try:
773
+ from code.skills import list_skills
774
+ skills_list = list_skills()
775
+ except Exception as exc:
776
+ logger.debug("list_skills failed: %s", exc)
777
+
778
+ try:
779
+ from code.commands import list_commands
780
+ commands_list = list_commands()
781
+ except Exception as exc:
782
+ logger.debug("list_commands failed: %s", exc)
783
+
784
+ try:
785
+ from code.hooks import list_hooks
786
+ hooks_list = list_hooks()
787
+ except Exception as exc:
788
+ logger.debug("list_hooks failed: %s", exc)
789
+
790
+ try:
791
+ from code.agents import list_agents, get_active_agent
792
+ agents_list = list_agents()
793
+ active_agent = get_active_agent()
794
+ except Exception as exc:
795
+ logger.debug("list_agents failed: %s", exc)
796
+
797
+ return JSONResponse(content={
798
+ "app_title": APP_TITLE,
799
+ "models": models,
800
+ "model_configs": {
801
+ k: {
802
+ "name": v["name"],
803
+ "type": v["type"],
804
+ "description": v.get("description", ""),
805
+ }
806
+ for k, v in MODEL_CONFIGS.items()
807
+ },
808
+ "default_model": DEFAULT_MODEL_KEY,
809
+ "model_url": MODEL_URL,
810
+ "languages": LANGUAGE_OPTIONS,
811
+ "examples": [
812
+ {"label": label, "prompt": prompt, "language": lang, "framework": fw}
813
+ for label, prompt, lang, fw in EXAMPLE_PROMPTS
814
+ ],
815
+ "skills": skills_list,
816
+ "commands": commands_list,
817
+ "hooks": hooks_list,
818
+ "agents": agents_list,
819
+ "active_agent": active_agent,
820
+ "workspace": str(_get_workspace_root()),
821
+ "version": "2.0.0",
822
+ })
823
+
824
+
825
+ def _get_workspace_root() -> Path:
826
+ """Return the workspace root path (created on demand)."""
827
+ try:
828
+ from code.tools.fs import get_workspace_root
829
+ return Path(get_workspace_root())
830
+ except Exception:
831
+ from code.config import WORKSPACE_ROOT
832
+ WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
833
+ return WORKSPACE_ROOT
834
+
835
+
836
+ @app.get("/api/model_status")
837
+ async def v2_model_status():
838
+ """Return current model loading status (v2 underscore-style)."""
839
+ return JSONResponse(content=get_model_status())
840
+
841
+
842
+ @app.get("/api/list_models")
843
+ async def v2_list_models():
844
+ """List all available models with the `current` flag set."""
845
+ current = get_current_model_key()
846
+ result = []
847
+ for key, cfg in MODEL_CONFIGS.items():
848
+ result.append({
849
+ "key": key,
850
+ "name": cfg["name"],
851
+ "model_type": cfg["type"],
852
+ "size_gb": cfg.get("size_gb", 0),
853
+ "description": cfg.get("description", ""),
854
+ "current": key == current,
855
+ })
856
+ return JSONResponse(content=result)
857
+
858
+
859
+ @app.post("/api/switch_model")
860
+ async def v2_switch_model(request: Request):
861
+ """Switch to a different model. Accepts JSON: {model_key: str}."""
862
+ try:
863
+ body = await request.json()
864
+ except Exception:
865
+ body = {}
866
+ model_key = body.get("model_key", "")
867
+ result = switch_model(model_key)
868
+ return JSONResponse(content={
869
+ "status": "switching" if result.get("success") else "error",
870
+ "model": model_key,
871
+ "message": result.get("message", ""),
872
+ })
873
+
874
+
875
+ @app.get("/api/workspace_tree")
876
+ async def v2_workspace_tree(max_depth: int = 4):
877
+ """Return the workspace file tree as a string."""
878
+ from code.tools.fs import list_workspace_tree
879
+ try:
880
+ result = list_workspace_tree(max_depth=max_depth)
881
+ return _json_response(result)
882
+ except Exception as exc:
883
+ logger.exception("workspace_tree failed")
884
+ return JSONResponse(content={"tree": f"(error: {exc})", "root": str(_get_workspace_root())})
885
+
886
+
887
+ @app.post("/api/workspace_read")
888
+ async def v2_workspace_read(request: Request):
889
+ """Read a file from the workspace."""
890
+ try:
891
+ body = await request.json()
892
+ except Exception:
893
+ body = {}
894
+ path = body.get("path", "")
895
+ offset = int(body.get("offset", 1) or 1)
896
+ limit = int(body.get("limit", 500) or 500)
897
+ from code.tools.fs import read_file
898
+ result = read_file(path=path, offset=offset, limit=limit)
899
+ return _json_response(result)
900
+
901
+
902
+ @app.post("/api/workspace_write")
903
+ async def v2_workspace_write(request: Request):
904
+ """Write a file in the workspace."""
905
+ try:
906
+ body = await request.json()
907
+ except Exception:
908
+ body = {}
909
+ path = body.get("path", "")
910
+ content = body.get("content", "")
911
+ from code.tools.fs import write_file
912
+ result = write_file(path=path, content=content)
913
+ return _json_response(result)
914
+
915
+
916
+ @app.post("/api/workspace_bash")
917
+ async def v2_workspace_bash(request: Request):
918
+ """Run a bash command in the workspace."""
919
+ try:
920
+ body = await request.json()
921
+ except Exception:
922
+ body = {}
923
+ command = body.get("command", "")
924
+ timeout = int(body.get("timeout", 30) or 30)
925
+ from code.tools.bash import run_bash
926
+ result = run_bash(command=command, timeout=timeout)
927
+ return _json_response(result)
928
+
929
+
930
+ @app.get("/api/mcp_status")
931
+ async def v2_mcp_status():
932
+ """Return MCP server connection status."""
933
+ try:
934
+ from code.mcp import get_mcp_manager
935
+ return JSONResponse(content=get_mcp_manager().get_status())
936
+ except Exception as exc:
937
+ logger.debug("mcp_status failed: %s", exc)
938
+ return JSONResponse(content={})
939
+
940
+
941
+ @app.get("/api/policy_status")
942
+ async def v2_policy_status():
943
+ """Return policy engine mode and rules."""
944
+ try:
945
+ from code.policy import get_policy_engine
946
+ engine = get_policy_engine()
947
+ return JSONResponse(content={
948
+ "mode": engine.approval_mode,
949
+ "rules": engine.get_rules(),
950
+ })
951
+ except Exception as exc:
952
+ logger.debug("policy_status failed: %s", exc)
953
+ return JSONResponse(content={"mode": "default", "rules": []})
954
+
955
+
956
+ @app.post("/api/reset_session")
957
+ async def v2_reset_session(request: Request):
958
+ """Clear a chat session's history."""
959
+ try:
960
+ body = await request.json()
961
+ except Exception:
962
+ body = {}
963
+ session_id = body.get("session_id", "")
964
+ if session_id in _v2_sessions:
965
+ _v2_sessions[session_id]["history"] = []
966
+ _v2_sessions[session_id]["active_agent"] = None
967
+ return JSONResponse(content={"success": True})
968
+
969
+
970
+ @app.post("/api/list_skills")
971
+ async def v2_list_skills():
972
+ """List all available skills."""
973
+ try:
974
+ from code.skills import list_skills
975
+ return JSONResponse(content={"success": True, "skills": list_skills()})
976
+ except Exception as exc:
977
+ return JSONResponse(content={"success": False, "skills": [], "error": str(exc)})
978
+
979
+
980
+ @app.post("/api/list_commands")
981
+ async def v2_list_commands():
982
+ """List all available slash commands."""
983
+ try:
984
+ from code.commands import list_commands
985
+ return JSONResponse(content={"success": True, "commands": list_commands()})
986
+ except Exception as exc:
987
+ return JSONResponse(content={"success": False, "commands": [], "error": str(exc)})
988
+
989
+
990
+ @app.post("/api/list_hooks")
991
+ async def v2_list_hooks():
992
+ """List all configured hooks."""
993
+ try:
994
+ from code.hooks import list_hooks
995
+ return JSONResponse(content={"success": True, "hooks": list_hooks()})
996
+ except Exception as exc:
997
+ return JSONResponse(content={"success": False, "hooks": [], "error": str(exc)})
998
+
999
+
1000
+ @app.post("/api/list_agents")
1001
+ async def v2_list_agents():
1002
+ """List all available agents (builtins + user) and the active one."""
1003
+ try:
1004
+ from code.agents import list_agents, get_active_agent
1005
+ return _json_response({
1006
+ "success": True,
1007
+ "agents": list_agents(),
1008
+ "active_agent": get_active_agent(),
1009
+ })
1010
+ except Exception as exc:
1011
+ return JSONResponse(content={"success": False, "agents": [], "error": str(exc)})
1012
+
1013
+
1014
+ @app.post("/api/set_active_agent")
1015
+ async def v2_set_active_agent(request: Request):
1016
+ """Set or clear the active agent for subsequent prompts."""
1017
+ try:
1018
+ body = await request.json()
1019
+ except Exception:
1020
+ body = {}
1021
+ name = body.get("name", "")
1022
+ try:
1023
+ from code.agents import set_active_agent
1024
+ result = set_active_agent(name or None)
1025
+ return _json_response(result)
1026
+ except Exception as exc:
1027
+ return JSONResponse(content={"success": False, "error": str(exc)})
1028
+
1029
+
1030
+ @app.post("/api/todo_read")
1031
+ async def v2_todo_read(request: Request):
1032
+ """Read the current todo list."""
1033
+ try:
1034
+ body = await request.json()
1035
+ except Exception:
1036
+ body = {}
1037
+ session_id = body.get("session_id", "default")
1038
+ from code.tools.todos import todo_read
1039
+ return _json_response(todo_read(session_id=session_id))
1040
+
1041
+
1042
+ @app.post("/api/todo_write")
1043
+ async def v2_todo_write(request: Request):
1044
+ """Replace the todo list."""
1045
+ try:
1046
+ body = await request.json()
1047
+ except Exception:
1048
+ body = {}
1049
+ session_id = body.get("session_id", "default")
1050
+ todos = body.get("todos", [])
1051
+ from code.tools.todos import todo_write
1052
+ return _json_response(todo_write(todos=todos, session_id=session_id))
1053
+
1054
+
1055
+ @app.post("/chat")
1056
+ async def v2_chat_stream(request: Request):
1057
+ """Streaming chat — returns newline-delimited JSON via StreamingResponse.
1058
+
1059
+ Request body: {message, session_id, image_url?, target_language?, target_framework?, search_enabled?, agent_name?}
1060
+
1061
+ This wraps the `run_agent` generator and yields each event as a JSON line.
1062
+ """
1063
+ try:
1064
+ body = await request.json()
1065
+ except Exception:
1066
+ body = {}
1067
+
1068
+ message = (body.get("message") or "").strip()
1069
+ session_id = body.get("session_id", "")
1070
+ image_url = body.get("image_url", "") or ""
1071
+ target_language = body.get("target_language", "") or ""
1072
+ target_framework = body.get("target_framework", "") or ""
1073
+ search_enabled = str(body.get("search_enabled", "false") or "false").lower() == "true"
1074
+ agent_name = body.get("agent_name", "") or ""
1075
+
1076
+ if not message:
1077
+ return StreamingResponse(
1078
+ iter([json.dumps({"type": "error", "error": "Empty message"}) + "\n"]),
1079
+ media_type="text/event-stream",
1080
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
1081
+ )
1082
+
1083
+ sid, session = _v2_get_or_create_session(session_id)
1084
+ history = session.get("history", [])
1085
+
1086
+ # Run web search if enabled
1087
+ search_context = ""
1088
+ if search_enabled:
1089
+ try:
1090
+ results = web_search_google(message, num_results=6)
1091
+ if results:
1092
+ search_context = format_search_results(results)
1093
+ except Exception as exc:
1094
+ logger.warning("Web search failed: %s", exc)
1095
+
1096
+ from code.agent import run_agent
1097
+
1098
+ def event_generator():
1099
+ try:
1100
+ for event in run_agent(
1101
+ user_input=message,
1102
+ history=history,
1103
+ target_language=target_language,
1104
+ target_framework=target_framework,
1105
+ search_context=search_context,
1106
+ image_url=image_url.strip() or None,
1107
+ agent_name=agent_name.strip() or None,
1108
+ ):
1109
+ # Persist final response into session history
1110
+ if event.get("type") == "complete":
1111
+ content = event.get("content", "")
1112
+ session["history"] = history + [
1113
+ {"role": "user", "content": message},
1114
+ {"role": "assistant", "content": content},
1115
+ ]
1116
+ yield json.dumps(event, ensure_ascii=False, default=str) + "\n"
1117
+ except Exception as exc:
1118
+ logger.error("Chat stream error: %s", exc, exc_info=True)
1119
+ yield json.dumps({"type": "error", "error": str(exc)}) + "\n"
1120
+
1121
+ return StreamingResponse(
1122
+ event_generator(),
1123
+ media_type="text/event-stream",
1124
+ headers={
1125
+ "Cache-Control": "no-cache",
1126
+ "X-Accel-Buffering": "no",
1127
+ "Connection": "keep-alive",
1128
+ },
1129
+ )
1130
+
1131
+
1132
  # ─── Agent / Skills / Commands / Hooks / Workspace Endpoints ──────────
1133
 
1134
 
index.html CHANGED
@@ -145,6 +145,26 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
145
  .example-chip{background:rgba(30,42,58,.4);border:1px solid var(--border);border-radius:12px;padding:3px 10px;font-family:var(--font-mono);font-size:11px;color:var(--gray-mid);cursor:pointer;transition:all var(--transition);white-space:nowrap}
146
  .example-chip:hover{border-color:var(--purple);color:var(--purple);background:rgba(168,85,247,.05);text-shadow:var(--glow-purple)}
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  /* ═══ OUTPUT PANEL ═══ */
149
  #output-panel{display:flex;flex-direction:column;width:45%;min-width:340px;max-width:55%;min-height:0;background:var(--bg-panel)}
150
  #output-tabs{display:flex;border-bottom:1px solid var(--border);background:rgba(13,17,23,.6);flex-shrink:0}
@@ -195,46 +215,17 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
195
  #file-viewer pre{margin:0;font-size:11px;line-height:1.5;color:var(--code-text)}
196
  .file-line-num{color:var(--gray-dim);display:inline-block;width:50px;text-align:right;margin-right:12px;user-select:none;border-right:1px solid var(--border);padding-right:8px}
197
 
198
- /* ═══ MCP TAB — Rich Catalog UI ═══ */
199
- #pane-mcp{overflow-y:auto;flex:1}
200
- .mcp-tab-bar{display:flex;border-bottom:1px solid var(--border);background:rgba(13,17,23,.6);flex-shrink:0;padding:0 8px;gap:2px;overflow-x:auto}
201
- .mcp-cat-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--gray-dim);font-family:var(--font-mono);font-size:10px;padding:8px 10px;cursor:pointer;transition:all var(--transition);letter-spacing:.5px;text-transform:uppercase;white-space:nowrap}
202
- .mcp-cat-tab:hover{color:var(--gray-mid)}
203
- .mcp-cat-tab.active{color:var(--purple);border-bottom-color:var(--purple);text-shadow:var(--glow-purple)}
204
- .mcp-catalog{padding:10px;display:flex;flex-direction:column;gap:8px}
205
- .mcp-card{border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-deep);overflow:hidden;transition:border-color var(--transition)}
206
- .mcp-card:hover{border-color:var(--border-focus)}
207
- .mcp-card-head{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:default}
208
- .mcp-card-icon{font-size:18px;flex-shrink:0;width:24px;text-align:center}
209
- .mcp-card-info{flex:1;min-width:0}
210
- .mcp-card-name{font-weight:600;color:var(--gray-light);font-size:12px;margin-bottom:2px}
211
- .mcp-card-desc{color:var(--gray-dim);font-size:11px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
212
- .mcp-card-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
213
- .mcp-card-status{width:7px;height:7px;border-radius:50%;flex-shrink:0}
214
- .mcp-card-status.on{background:var(--success);box-shadow:0 0 6px var(--success)}
215
- .mcp-card-status.off{background:var(--gray-dim)}
216
- .mcp-toggle{position:relative;width:34px;height:18px;flex-shrink:0}
217
- .mcp-toggle input{opacity:0;width:0;height:0;position:absolute}
218
- .mcp-toggle-slider{position:absolute;inset:0;background:var(--border);border-radius:9px;cursor:pointer;transition:background var(--transition)}
219
- .mcp-toggle-slider::before{content:'';position:absolute;left:2px;top:2px;width:14px;height:14px;background:var(--gray-dim);border-radius:50%;transition:all var(--transition)}
220
- .mcp-toggle input:checked+.mcp-toggle-slider{background:rgba(57,255,20,.3);border:1px solid var(--green-dim)}
221
- .mcp-toggle input:checked+.mcp-toggle-slider::before{transform:translateX(16px);background:var(--green);box-shadow:0 0 6px var(--green)}
222
- .mcp-card-details{display:none;padding:0 12px 10px;border-top:1px solid var(--border);margin-top:0;padding-top:8px}
223
- .mcp-card.open .mcp-card-details{display:block}
224
- .mcp-detail-row{display:flex;align-items:flex-start;gap:8px;margin-bottom:4px;font-size:11px}
225
- .mcp-detail-label{color:var(--gray-dim);flex-shrink:0;min-width:60px}
226
- .mcp-detail-value{color:var(--gray-mid);word-break:break-all}
227
- .mcp-card-tools{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
228
- .mcp-tool-chip{padding:2px 8px;background:rgba(0,212,255,.08);border:1px solid rgba(0,212,255,.2);border-radius:10px;font-size:10px;color:var(--cyan)}
229
- .mcp-card-error{color:var(--red);font-size:11px;margin-top:4px}
230
- .mcp-card-expand{background:transparent;border:none;color:var(--gray-dim);font-family:var(--font-mono);font-size:10px;cursor:pointer;padding:2px 6px;border-radius:3px;transition:color var(--transition)}
231
- .mcp-card-expand:hover{color:var(--cyan)}
232
- .mcp-connecting{color:var(--amber);font-size:10px;animation:pulse 1s ease infinite}
233
- .mcp-stats-bar{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border);background:rgba(30,42,58,.3);font-size:11px;flex-shrink:0}
234
- .mcp-stat{color:var(--gray-dim)}
235
- .mcp-stat strong{color:var(--gray-light);font-weight:600}
236
- .mcp-stat .green{color:var(--green)}
237
- .mcp-stat .red{color:var(--red)}
238
 
239
  /* ═══ STATUS BAR ═══ */
240
  #status-bar{display:flex;align-items:center;gap:8px;padding:5px 16px;border-top:1px solid var(--border);background:var(--bg-panel);font-size:11px;flex-shrink:0}
@@ -290,7 +281,7 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
290
  <span id="model-pill-text">Loading...</span>
291
  </div>
292
  <select id="model-select" title="Switch AI model"></select>
293
- <div class="pill mcp-pill" id="mcp-pill" onclick="switchTab('mcp')" style="cursor:pointer" title="Open MCP panel">
294
  <span class="dot disconnected" id="mcp-dot"></span>
295
  <span id="mcp-pill-text">MCP</span>
296
  </div>
@@ -299,7 +290,7 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
299
  </header>
300
 
301
  <div id="banner">
302
- Powered by <a id="banner-model-link" href="https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct" target="_blank" rel="noopener"><strong id="banner-model-name">Qwen2.5-Coder-1.5B</strong></a> running locally &mdash; no external APIs. Supports MCP tools, multiple 1B models, and agentic code generation with Gemini CLI patterns.
303
  </div>
304
 
305
  <div id="main">
@@ -313,6 +304,42 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
313
  <button id="btn-send" onclick="handleSend()" title="Send message">&#10148;</button>
314
  <button id="btn-stop" onclick="stopGeneration()" title="Stop generation">&#9632; STOP</button>
315
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  <div id="examples-row"></div>
317
  </div>
318
  </div>
@@ -367,25 +394,7 @@ a:hover{text-decoration:underline;text-shadow:var(--glow-cyan)}
367
  <div id="file-viewer"><pre id="file-content"></pre></div>
368
  </div>
369
  <div class="tab-pane" id="pane-mcp">
370
- <div class="mcp-stats-bar" id="mcp-stats-bar">
371
- <span class="mcp-stat">Servers: <strong id="mcp-total-count">0</strong></span>
372
- <span class="mcp-stat">Connected: <strong class="green" id="mcp-connected-count">0</strong></span>
373
- <span class="mcp-stat">Tools: <strong id="mcp-tools-count">0</strong></span>
374
- </div>
375
- <div class="mcp-tab-bar" id="mcp-tab-bar">
376
- <button class="mcp-cat-tab active" data-cat="all" onclick="filterMcpCat('all')">All</button>
377
- <button class="mcp-cat-tab" data-cat="core" onclick="filterMcpCat('core')">Core</button>
378
- <button class="mcp-cat-tab" data-cat="reasoning" onclick="filterMcpCat('reasoning')">Reasoning</button>
379
- <button class="mcp-cat-tab" data-cat="dev-tools" onclick="filterMcpCat('dev-tools')">Dev Tools</button>
380
- <button class="mcp-cat-tab" data-cat="database" onclick="filterMcpCat('database')">Database</button>
381
- <button class="mcp-cat-tab" data-cat="search" onclick="filterMcpCat('search')">Search</button>
382
- <button class="mcp-cat-tab" data-cat="browser" onclick="filterMcpCat('browser')">Browser</button>
383
- <button class="mcp-cat-tab" data-cat="integrations" onclick="filterMcpCat('integrations')">Integrations</button>
384
- <button class="mcp-cat-tab" data-cat="utilities" onclick="filterMcpCat('utilities')">Utilities</button>
385
- </div>
386
- <div class="mcp-catalog" id="mcp-catalog">
387
- <div class="mcp-empty" style="color:var(--gray-dim);font-size:12px;text-align:center;padding:40px 20px">Loading MCP catalog...</div>
388
- </div>
389
  </div>
390
  </div>
391
  </div>
@@ -414,7 +423,6 @@ var state = {
414
  isGenerating: false,
415
  modelReady: false,
416
  activeTab: 'preview',
417
- activeMcpCat: 'all',
418
  toolCalls: {},
419
  currentStreamDiv: null,
420
  lastCode: '',
@@ -422,7 +430,8 @@ var state = {
422
  consoleStdout: '',
423
  consoleStderr: '',
424
  generatedFiles: [],
425
- mcpServers: [],
 
426
  };
427
 
428
  // ═══ INIT ═══
@@ -430,13 +439,22 @@ document.addEventListener('DOMContentLoaded', function() {
430
  loadConfig();
431
  pollModelStatus();
432
  refreshWorkspace();
433
- loadMcpCatalog();
434
 
435
  var input = document.getElementById('chat-input');
436
  input.addEventListener('input', autoResize);
437
  input.addEventListener('keydown', function(e) {
438
  if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); handleSend(); }
439
  });
 
 
 
 
 
 
 
 
 
440
  });
441
 
442
  function autoResize() {
@@ -457,9 +475,9 @@ async function loadConfig() {
457
  try {
458
  var resp = await fetch('/api/get_config');
459
  var config = await resp.json();
 
460
  var sel = document.getElementById('model-select');
461
  sel.innerHTML = '';
462
-
463
  (config.models || []).forEach(function(m) {
464
  var opt = document.createElement('option');
465
  opt.value = m.key;
@@ -467,12 +485,115 @@ async function loadConfig() {
467
  if (m.key === config.default_model) opt.selected = true;
468
  sel.appendChild(opt);
469
  });
470
-
471
  sel.addEventListener('change', function() { switchModel(sel.value); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  renderExamples(config);
473
  } catch(e) { console.error('Config load failed:', e); }
474
  }
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  async function pollModelStatus() {
477
  try {
478
  var resp = await fetch('/api/model_status');
@@ -528,150 +649,68 @@ async function refreshWorkspace() {
528
  } catch(e) { console.error(e); }
529
  }
530
 
531
- // ═══ MCP CATALOG (Rich UI) ═══
532
-
533
- async function loadMcpCatalog() {
534
  try {
535
- var resp = await fetch('/api/mcp_catalog');
536
  var data = await resp.json();
537
- state.mcpServers = data.servers || [];
538
- renderMcpCatalog();
539
- updateMcpPill();
540
- } catch(e) {
541
- document.getElementById('mcp-catalog').innerHTML = '<div class="mcp-empty">MCP catalog unavailable.</div>';
542
- }
543
- }
544
-
545
- function updateMcpPill() {
546
- var servers = state.mcpServers;
547
- var connected = servers.filter(function(s) { return s.connected; });
548
- var dot = document.getElementById('mcp-dot');
549
- var text = document.getElementById('mcp-pill-text');
550
- dot.className = connected.length > 0 ? 'dot connected' : 'dot disconnected';
551
- text.textContent = 'MCP ' + (connected.length > 0 ? '(' + connected.length + ')' : '');
552
- }
553
-
554
- function filterMcpCat(cat) {
555
- state.activeMcpCat = cat;
556
- document.querySelectorAll('.mcp-cat-tab').forEach(function(t) {
557
- t.classList.toggle('active', t.dataset.cat === cat);
558
- });
559
- renderMcpCatalog();
560
- }
561
-
562
- function renderMcpCatalog() {
563
- var cat = state.activeMcpCat;
564
- var servers = state.mcpServers.filter(function(s) {
565
- return cat === 'all' || s.category === cat;
566
- });
567
-
568
- // Update stats
569
- var totalServers = state.mcpServers.length;
570
- var connectedServers = state.mcpServers.filter(function(s) { return s.connected; }).length;
571
- var totalTools = state.mcpServers.reduce(function(sum, s) { return sum + (s.tool_count || 0); }, 0);
572
- document.getElementById('mcp-total-count').textContent = totalServers;
573
- document.getElementById('mcp-connected-count').textContent = connectedServers;
574
- document.getElementById('mcp-tools-count').textContent = totalTools;
575
-
576
- if (servers.length === 0) {
577
- document.getElementById('mcp-catalog').innerHTML = '<div class="mcp-empty">No servers in this category.</div>';
578
- return;
579
- }
580
-
581
- var html = '';
582
- servers.forEach(function(s) {
583
- var isOn = s.connected;
584
- html += '<div class="mcp-card" id="mcp-card-' + escHtml(s.key) + '">';
585
- html += '<div class="mcp-card-head">';
586
- html += '<span class="mcp-card-icon">' + (s.icon || '') + '</span>';
587
- html += '<div class="mcp-card-info">';
588
- html += '<div class="mcp-card-name">' + escHtml(s.name) + '</div>';
589
- html += '<div class="mcp-card-desc">' + escHtml(s.description) + '</div>';
590
- html += '</div>';
591
- html += '<div class="mcp-card-actions">';
592
- html += '<span class="mcp-card-status ' + (isOn ? 'on' : 'off') + '" id="mcp-status-' + escHtml(s.key) + '"></span>';
593
- html += '<label class="mcp-toggle" title="' + (isOn ? 'Disable' : 'Enable') + ' ' + escHtml(s.name) + '">';
594
- html += '<input type="checkbox" ' + (isOn ? 'checked' : '') + ' onchange="toggleMcpServer(\'' + escHtml(s.key) + '\', this.checked)">';
595
- html += '<span class="mcp-toggle-slider"></span>';
596
- html += '</label>';
597
- html += '<button class="mcp-card-expand" onclick="toggleMcpCard(\'' + escHtml(s.key) + '\')">&#9660; Info</button>';
598
- html += '</div>';
599
- html += '</div>';
600
- html += '<div class="mcp-card-details" id="mcp-details-' + escHtml(s.key) + '">';
601
- if (s.install_hint) {
602
- html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Install:</span><span class="mcp-detail-value" style="color:var(--cyan)">' + escHtml(s.install_hint) + '</span></div>';
603
- }
604
- html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Transport:</span><span class="mcp-detail-value">' + escHtml(s.transport) + '</span></div>';
605
- if (s.command) {
606
- html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Command:</span><span class="mcp-detail-value">' + escHtml(s.command + ' ' + (s.args || []).join(' ')) + '</span></div>';
607
- }
608
- if (s.repo_url) {
609
- html += '<div class="mcp-detail-row"><span class="mcp-detail-label">Repo:</span><span class="mcp-detail-value"><a href="' + escHtml(s.repo_url) + '" target="_blank" rel="noopener">' + escHtml(s.repo_url) + '</a></span></div>';
610
- }
611
- if (s.tool_count > 0) {
612
- html += '<div class="mcp-card-tools"><span class="mcp-tool-chip">' + s.tool_count + ' tools</span></div>';
613
  }
614
- html += '<div id="mcp-error-' + escHtml(s.key) + '"></div>';
615
- html += '</div>';
616
- html += '</div>';
617
- });
618
-
619
- document.getElementById('mcp-catalog').innerHTML = html;
620
- }
621
-
622
- function toggleMcpCard(key) {
623
- var card = document.getElementById('mcp-card-' + key);
624
- if (card) card.classList.toggle('open');
625
- }
626
-
627
- async function toggleMcpServer(key, enabled) {
628
- var errorEl = document.getElementById('mcp-error-' + key);
629
- var statusEl = document.getElementById('mcp-status-' + key);
630
- if (errorEl) errorEl.innerHTML = '<span class="mcp-connecting">' + (enabled ? 'Connecting...' : 'Disconnecting...') + '</span>';
631
-
632
- try {
633
- var resp = await fetch('/api/mcp_toggle', {
634
- method: 'POST',
635
- headers: {'Content-Type': 'application/json'},
636
- body: JSON.stringify({ server_key: key, enabled: enabled }),
637
- });
638
- var data = await resp.json();
639
- if (data.error) {
640
- if (errorEl) errorEl.innerHTML = '<div class="mcp-card-error">' + escHtml(data.error) + '</div>';
641
- // Revert toggle
642
- var card = document.getElementById('mcp-card-' + key);
643
- if (card) {
644
- var cb = card.querySelector('input[type=checkbox]');
645
- if (cb) cb.checked = !enabled;
646
  }
647
- if (statusEl) statusEl.className = 'mcp-card-status off';
648
- } else {
649
- if (errorEl) errorEl.innerHTML = '';
650
- if (statusEl) statusEl.className = 'mcp-card-status ' + (enabled ? 'on' : 'off');
651
- // Refresh to get updated tool counts
652
- setTimeout(loadMcpCatalog, 1500);
653
- }
654
  } catch(e) {
655
- if (errorEl) errorEl.innerHTML = '<div class="mcp-card-error">Connection failed: ' + escHtml(e.message) + '</div>';
656
- var card = document.getElementById('mcp-card-' + key);
657
- if (card) {
658
- var cb = card.querySelector('input[type=checkbox]');
659
- if (cb) cb.checked = !enabled;
660
- }
661
- if (statusEl) statusEl.className = 'mcp-card-status off';
662
  }
663
- updateMcpPill();
664
  }
665
 
666
  function renderExamples(config) {
667
- var examples = [
668
- 'Build a Flask REST API with user CRUD',
669
- 'Create a todo app with localStorage',
670
- 'Write a Python web scraper',
671
- 'Build a Markdown to HTML CLI tool',
672
- 'Create FastAPI with JWT auth',
673
- 'Make a React calculator component',
674
- ];
 
 
 
 
 
 
 
 
 
 
 
675
  var row = document.getElementById('examples-row');
676
  row.innerHTML = '<span class="examples-label">Try:</span>';
677
  examples.forEach(function(ex) {
@@ -680,6 +719,7 @@ function renderExamples(config) {
680
  chip.textContent = ex;
681
  chip.onclick = function() {
682
  document.getElementById('chat-input').value = ex;
 
683
  handleSend();
684
  };
685
  row.appendChild(chip);
@@ -696,7 +736,6 @@ function switchTab(tab) {
696
  document.querySelectorAll('.tab-pane').forEach(function(p) {
697
  p.classList.toggle('active', p.id === 'pane-' + tab);
698
  });
699
- if (tab === 'mcp') loadMcpCatalog();
700
  }
701
 
702
  // ═══ FULLSCREEN ═══
@@ -732,7 +771,7 @@ function addMessage(role, content, extra) {
732
  var bodyClass = role === 'assistant' ? '<div class="msg-body">' : '';
733
  var bodyClose = role === 'assistant' ? '</div>' : '';
734
  var iterationHtml = extra.iteration ? '<div class="iteration-badge">Iteration ' + extra.iteration + '/' + extra.max_iterations + '</div>' : '';
735
- div.innerHTML = iterationHtml + prefix + '<span class="msg-content">' + renderMd(content) + '</span>' + bodyClass + bodyClose;
736
  container.appendChild(div);
737
  container.scrollTop = container.scrollHeight;
738
  return div;
@@ -741,6 +780,7 @@ function addMessage(role, content, extra) {
741
  function renderMd(text) {
742
  if (!text) return '';
743
  text = escHtml(text);
 
744
  text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
745
  var id = 'cb_' + Date.now() + Math.random().toString(36).substr(2,4);
746
  return '<div class="code-block-wrap"><div class="code-block-header"><span class="code-lang">' + (lang||'code') + '</span><button class="btn-copy" onclick="copyBlock(\'' + id + '\')">&#128203; Copy</button></div><pre><code id="' + id + '">' + code.trim() + '</code></pre></div>';
@@ -784,7 +824,6 @@ function addToolCall(toolName, args) {
784
  var div = document.createElement('div');
785
  div.className = 'tool-block open';
786
  div.id = id;
787
-
788
  var argsStr = '';
789
  if (args) {
790
  Object.entries(args).forEach(function(e) {
@@ -792,7 +831,6 @@ function addToolCall(toolName, args) {
792
  argsStr += escHtml(e[0] + ': ' + v) + '\n';
793
  });
794
  }
795
-
796
  div.innerHTML =
797
  '<div class="tool-header">' +
798
  '<span class="tool-icon">&#9889;</span>' +
@@ -801,7 +839,6 @@ function addToolCall(toolName, args) {
801
  '</div>' +
802
  '<div class="tool-args">' + argsStr + '</div>' +
803
  '<div class="tool-result"></div>';
804
-
805
  container.appendChild(div);
806
  container.scrollTop = container.scrollHeight;
807
  state.toolCalls[id] = div;
@@ -813,7 +850,6 @@ function updateToolCall(id, success, output) {
813
  if (!div) return;
814
  var badge = div.querySelector('.tool-badge');
815
  var result = div.querySelector('.tool-result');
816
-
817
  if (success) {
818
  badge.className = 'tool-badge success';
819
  badge.textContent = 'DONE';
@@ -821,7 +857,6 @@ function updateToolCall(id, success, output) {
821
  badge.className = 'tool-badge error';
822
  badge.textContent = 'ERROR';
823
  }
824
-
825
  if (output) {
826
  result.textContent = output;
827
  result.className = success ? 'tool-result' : 'tool-result error-output';
@@ -868,25 +903,49 @@ async function handleSend() {
868
  startStreaming();
869
  setStatus('status-working', 'GENERATING...');
870
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
871
  try {
872
  var resp = await fetch('/chat', {
873
  method: 'POST',
874
  headers: {'Content-Type': 'application/json'},
875
- body: JSON.stringify({ message: message, session_id: state.sessionId }),
 
 
 
 
 
 
 
 
876
  });
877
  if (!resp.ok) throw new Error('HTTP ' + resp.status);
878
-
879
  var reader = resp.body.getReader();
880
  var decoder = new TextDecoder();
881
  var buffer = '';
882
-
883
  while (true) {
884
  var result = await reader.read();
885
  if (result.done) break;
886
  buffer += decoder.decode(result.value, {stream: true});
887
  var lines = buffer.split('\n');
888
  buffer = lines.pop() || '';
889
-
890
  for (var i = 0; i < lines.length; i++) {
891
  var line = lines[i];
892
  if (!line.trim()) continue;
@@ -909,6 +968,7 @@ async function handleSend() {
909
  break;
910
  }
911
  }
 
912
  if (ev.result.output && (ev.result.output.includes('<!DOCTYPE') || ev.result.output.includes('<html'))) {
913
  showPreview(ev.result.output);
914
  }
@@ -926,6 +986,12 @@ async function handleSend() {
926
  if (ev.iterations) setTimeout(refreshWorkspace, 500);
927
  setStatus('status-success', 'COMPLETE');
928
  setTimeout(function() { if (!state.isGenerating) setStatus('status-idle', 'IDLE'); }, 3000);
 
 
 
 
 
 
929
  break;
930
  case 'error':
931
  finalizeStream('Error: ' + ev.error);
@@ -941,7 +1007,6 @@ async function handleSend() {
941
  setStatus('status-error', 'CONNECTION ERROR');
942
  }
943
  }
944
-
945
  state.isGenerating = false;
946
  document.getElementById('btn-send').style.display = 'flex';
947
  document.getElementById('btn-stop').style.display = 'none';
 
145
  .example-chip{background:rgba(30,42,58,.4);border:1px solid var(--border);border-radius:12px;padding:3px 10px;font-family:var(--font-mono);font-size:11px;color:var(--gray-mid);cursor:pointer;transition:all var(--transition);white-space:nowrap}
146
  .example-chip:hover{border-color:var(--purple);color:var(--purple);background:rgba(168,85,247,.05);text-shadow:var(--glow-purple)}
147
 
148
+ /* ═══ OPTIONS ROW ═══ */
149
+ #options-row{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap}
150
+ .opt-group{display:inline-flex;align-items:center;gap:4px;background:rgba(30,42,58,.3);border:1px solid var(--border);border-radius:var(--radius);padding:2px 4px 2px 6px;transition:border-color var(--transition)}
151
+ .opt-group:hover{border-color:var(--border-focus)}
152
+ .opt-group label{font-size:9px;color:var(--gray-dim);letter-spacing:1px;text-transform:uppercase;flex-shrink:0}
153
+ .opt-group select{background:transparent;border:none;color:var(--cyan);font-family:var(--font-mono);font-size:11px;padding:3px 4px;outline:none;cursor:pointer;max-width:120px}
154
+ .opt-group select option{background:var(--bg-deep);color:var(--gray-light)}
155
+ .opt-toggle{display:inline-flex;align-items:center;gap:5px;background:rgba(30,42,58,.3);border:1px solid var(--border);border-radius:var(--radius);padding:3px 8px;font-size:10px;color:var(--gray-mid);cursor:pointer;transition:all var(--transition);user-select:none;letter-spacing:.5px}
156
+ .opt-toggle:hover{border-color:var(--border-focus);color:var(--gray-light)}
157
+ .opt-toggle input{display:none}
158
+ .opt-toggle .toggle-dot{width:8px;height:8px;border-radius:50%;background:var(--gray-dim);transition:all var(--transition)}
159
+ .opt-toggle.active{color:var(--amber);border-color:rgba(255,179,0,.4);background:rgba(255,179,0,.08);text-shadow:var(--glow-amber)}
160
+ .opt-toggle.active .toggle-dot{background:var(--amber);box-shadow:0 0 6px var(--amber)}
161
+ .opt-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:rgba(30,42,58,.3);border:1px solid var(--border);border-radius:var(--radius);color:var(--gray-mid);cursor:pointer;transition:all var(--transition);font-size:13px;flex-shrink:0}
162
+ .opt-icon-btn:hover{border-color:var(--cyan);color:var(--cyan);text-shadow:var(--glow-cyan)}
163
+ .opt-icon-btn input{display:none}
164
+ .img-preview{display:inline-flex;align-items:center;gap:4px;background:rgba(0,212,255,.08);border:1px solid rgba(0,212,255,.3);border-radius:var(--radius);padding:2px 6px;font-size:10px;color:var(--cyan);cursor:pointer}
165
+ .img-preview img{width:18px;height:18px;border-radius:2px;object-fit:cover}
166
+ .img-preview .remove{cursor:pointer;color:var(--red);margin-left:2px}
167
+
168
  /* ═══ OUTPUT PANEL ═══ */
169
  #output-panel{display:flex;flex-direction:column;width:45%;min-width:340px;max-width:55%;min-height:0;background:var(--bg-panel)}
170
  #output-tabs{display:flex;border-bottom:1px solid var(--border);background:rgba(13,17,23,.6);flex-shrink:0}
 
215
  #file-viewer pre{margin:0;font-size:11px;line-height:1.5;color:var(--code-text)}
216
  .file-line-num{color:var(--gray-dim);display:inline-block;width:50px;text-align:right;margin-right:12px;user-select:none;border-right:1px solid var(--border);padding-right:8px}
217
 
218
+ /* MCP */
219
+ #pane-mcp{padding:16px;gap:12px;overflow-y:auto}
220
+ .mcp-server{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:8px}
221
+ .mcp-server-header{display:flex;align-items:center;gap:8px;padding:8px 12px;background:rgba(30,42,58,.5);border-bottom:1px solid var(--border);font-size:12px}
222
+ .mcp-server-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
223
+ .mcp-server-dot.connected{background:var(--success);box-shadow:0 0 6px var(--success)}
224
+ .mcp-server-dot.disconnected{background:var(--red);box-shadow:0 0 6px var(--red)}
225
+ .mcp-server-name{font-weight:600;color:var(--gray-light);flex:1}
226
+ .mcp-tools{padding:8px 12px;display:flex;flex-wrap:wrap;gap:4px}
227
+ .mcp-tool-tag{padding:2px 8px;background:rgba(0,212,255,.08);border:1px solid rgba(0,212,255,.2);border-radius:10px;font-size:10px;color:var(--cyan)}
228
+ .mcp-empty{color:var(--gray-dim);font-size:12px;text-align:center;padding:40px 20px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  /* ═══ STATUS BAR ═══ */
231
  #status-bar{display:flex;align-items:center;gap:8px;padding:5px 16px;border-top:1px solid var(--border);background:var(--bg-panel);font-size:11px;flex-shrink:0}
 
281
  <span id="model-pill-text">Loading...</span>
282
  </div>
283
  <select id="model-select" title="Switch AI model"></select>
284
+ <div class="pill mcp-pill" id="mcp-pill">
285
  <span class="dot disconnected" id="mcp-dot"></span>
286
  <span id="mcp-pill-text">MCP</span>
287
  </div>
 
290
  </header>
291
 
292
  <div id="banner">
293
+ Powered by <a id="banner-model-link" href="https://huggingface.co/openbmb/MiniCPM5-1B" target="_blank" rel="noopener"><strong id="banner-model-name">MiniCPM5-1B</strong></a> running locally &mdash; no external APIs. Supports MCP tools, multiple 1B models, and agentic code generation with Gemini CLI patterns.
294
  </div>
295
 
296
  <div id="main">
 
304
  <button id="btn-send" onclick="handleSend()" title="Send message">&#10148;</button>
305
  <button id="btn-stop" onclick="stopGeneration()" title="Stop generation">&#9632; STOP</button>
306
  </div>
307
+ <div id="options-row">
308
+ <div class="opt-group">
309
+ <label>Lang</label>
310
+ <select id="opt-language" onchange="updateFrameworkOptions()" title="Target language">
311
+ <option value="">Auto</option>
312
+ </select>
313
+ </div>
314
+ <div class="opt-group">
315
+ <label>FW</label>
316
+ <select id="opt-framework" title="Target framework">
317
+ <option value="">Auto</option>
318
+ </select>
319
+ </div>
320
+ <div class="opt-group">
321
+ <label>Skill</label>
322
+ <select id="opt-skill" title="Apply a skill (use /skill command for multiple)">
323
+ <option value="">None</option>
324
+ </select>
325
+ </div>
326
+ <div class="opt-group">
327
+ <label>Agent</label>
328
+ <select id="opt-agent" title="Activate a custom agent persona" onchange="setActiveAgent(this.value)">
329
+ <option value="">Default</option>
330
+ </select>
331
+ </div>
332
+ <label class="opt-toggle" id="opt-search-toggle" title="Search the web before generating">
333
+ <input type="checkbox" id="opt-search">
334
+ <span class="toggle-dot"></span>
335
+ <span>Web</span>
336
+ </label>
337
+ <label class="opt-icon-btn" title="Attach image (for VLM models)">
338
+ <input type="file" id="opt-image-input" accept="image/*" onchange="handleImageSelect(event)">
339
+ <span>&#128247;</span>
340
+ </label>
341
+ <span id="image-preview-container"></span>
342
+ </div>
343
  <div id="examples-row"></div>
344
  </div>
345
  </div>
 
394
  <div id="file-viewer"><pre id="file-content"></pre></div>
395
  </div>
396
  <div class="tab-pane" id="pane-mcp">
397
+ <div id="mcp-content"><div class="mcp-empty">Loading MCP status...</div></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  </div>
399
  </div>
400
  </div>
 
423
  isGenerating: false,
424
  modelReady: false,
425
  activeTab: 'preview',
 
426
  toolCalls: {},
427
  currentStreamDiv: null,
428
  lastCode: '',
 
430
  consoleStdout: '',
431
  consoleStderr: '',
432
  generatedFiles: [],
433
+ attachedImage: null, // {dataUrl, file_url} or null
434
+ config: null, // last loaded runtime config
435
  };
436
 
437
  // ═══ INIT ═══
 
439
  loadConfig();
440
  pollModelStatus();
441
  refreshWorkspace();
442
+ loadMcpStatus();
443
 
444
  var input = document.getElementById('chat-input');
445
  input.addEventListener('input', autoResize);
446
  input.addEventListener('keydown', function(e) {
447
  if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); handleSend(); }
448
  });
449
+
450
+ // Wire up the Web search toggle
451
+ var searchToggle = document.getElementById('opt-search-toggle');
452
+ searchToggle.addEventListener('click', function(e) {
453
+ e.preventDefault();
454
+ var cb = document.getElementById('opt-search');
455
+ cb.checked = !cb.checked;
456
+ searchToggle.classList.toggle('active', cb.checked);
457
+ });
458
  });
459
 
460
  function autoResize() {
 
475
  try {
476
  var resp = await fetch('/api/get_config');
477
  var config = await resp.json();
478
+ state.config = config;
479
  var sel = document.getElementById('model-select');
480
  sel.innerHTML = '';
 
481
  (config.models || []).forEach(function(m) {
482
  var opt = document.createElement('option');
483
  opt.value = m.key;
 
485
  if (m.key === config.default_model) opt.selected = true;
486
  sel.appendChild(opt);
487
  });
 
488
  sel.addEventListener('change', function() { switchModel(sel.value); });
489
+
490
+ // Populate language selector
491
+ var langSel = document.getElementById('opt-language');
492
+ langSel.innerHTML = '<option value="">Auto</option>';
493
+ (config.languages || []).forEach(function(pair) {
494
+ var lang = Array.isArray(pair) ? pair[0] : (pair.language || pair.name || pair);
495
+ var opt = document.createElement('option');
496
+ opt.value = lang;
497
+ opt.textContent = lang;
498
+ langSel.appendChild(opt);
499
+ });
500
+ updateFrameworkOptions();
501
+
502
+ // Populate skills selector
503
+ var skillSel = document.getElementById('opt-skill');
504
+ skillSel.innerHTML = '<option value="">None</option>';
505
+ (config.skills || []).forEach(function(s) {
506
+ var name = typeof s === 'string' ? s : (s.name || s.id || '');
507
+ if (!name) return;
508
+ var opt = document.createElement('option');
509
+ opt.value = name;
510
+ opt.textContent = name;
511
+ skillSel.appendChild(opt);
512
+ });
513
+
514
+ // Populate agents selector
515
+ var agentSel = document.getElementById('opt-agent');
516
+ agentSel.innerHTML = '<option value="">Default</option>';
517
+ (config.agents || []).forEach(function(a) {
518
+ var name = typeof a === 'string' ? a : (a.name || a.id || '');
519
+ if (!name) return;
520
+ var opt = document.createElement('option');
521
+ opt.value = name;
522
+ opt.textContent = name + (a.active ? ' (active)' : '');
523
+ if (a.active) opt.selected = true;
524
+ agentSel.appendChild(opt);
525
+ });
526
+
527
  renderExamples(config);
528
  } catch(e) { console.error('Config load failed:', e); }
529
  }
530
 
531
+ function updateFrameworkOptions() {
532
+ if (!state.config) return;
533
+ var langSel = document.getElementById('opt-language');
534
+ var fwSel = document.getElementById('opt-framework');
535
+ var lang = langSel.value;
536
+ fwSel.innerHTML = '<option value="">Auto</option>';
537
+ if (!lang) return;
538
+ var match = (state.config.languages || []).find(function(pair) {
539
+ var l = Array.isArray(pair) ? pair[0] : (pair.language || pair.name || pair);
540
+ return l === lang;
541
+ });
542
+ if (!match) return;
543
+ var fws = Array.isArray(match) ? match[1] : (match.frameworks || match.fws || []);
544
+ (fws || []).forEach(function(fw) {
545
+ var opt = document.createElement('option');
546
+ opt.value = fw;
547
+ opt.textContent = fw;
548
+ fwSel.appendChild(opt);
549
+ });
550
+ }
551
+
552
+ async function setActiveAgent(name) {
553
+ try {
554
+ await fetch('/api/set_active_agent', {
555
+ method: 'POST',
556
+ headers: {'Content-Type': 'application/json'},
557
+ body: JSON.stringify({name: name || ''}),
558
+ });
559
+ } catch(e) { console.error('Set agent failed:', e); }
560
+ }
561
+
562
+ function handleImageSelect(event) {
563
+ var file = event.target.files && event.target.files[0];
564
+ if (!file) return;
565
+ var reader = new FileReader();
566
+ reader.onload = function(ev) {
567
+ state.attachedImage = {dataUrl: ev.target.result, name: file.name};
568
+ renderImagePreview();
569
+ };
570
+ reader.readAsDataURL(file);
571
+ }
572
+
573
+ function renderImagePreview() {
574
+ var container = document.getElementById('image-preview-container');
575
+ container.innerHTML = '';
576
+ if (!state.attachedImage) return;
577
+ var div = document.createElement('span');
578
+ div.className = 'img-preview';
579
+ var img = document.createElement('img');
580
+ img.src = state.attachedImage.dataUrl;
581
+ div.appendChild(img);
582
+ var name = document.createElement('span');
583
+ name.textContent = state.attachedImage.name.substring(0, 16);
584
+ div.appendChild(name);
585
+ var rm = document.createElement('span');
586
+ rm.className = 'remove';
587
+ rm.textContent = '×';
588
+ rm.onclick = function() {
589
+ state.attachedImage = null;
590
+ document.getElementById('opt-image-input').value = '';
591
+ renderImagePreview();
592
+ };
593
+ div.appendChild(rm);
594
+ container.appendChild(div);
595
+ }
596
+
597
  async function pollModelStatus() {
598
  try {
599
  var resp = await fetch('/api/model_status');
 
649
  } catch(e) { console.error(e); }
650
  }
651
 
652
+ async function loadMcpStatus() {
 
 
653
  try {
654
+ var resp = await fetch('/api/mcp_status');
655
  var data = await resp.json();
656
+ var dot = document.getElementById('mcp-dot');
657
+ var text = document.getElementById('mcp-pill-text');
658
+ var content = document.getElementById('mcp-content');
659
+ var servers = data.servers || {};
660
+ var keys = Object.keys(servers);
661
+ if (keys.length === 0) {
662
+ dot.className = 'dot disconnected';
663
+ text.textContent = 'MCP';
664
+ content.innerHTML = '<div class="mcp-empty">No MCP servers configured.<br><br>Add MCP servers in your config to extend SoniCoder with external tools.</div>';
665
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  }
667
+ var anyConnected = keys.some(function(k) { return servers[k].status === 'connected'; });
668
+ dot.className = anyConnected ? 'dot connected' : 'dot disconnected';
669
+ text.textContent = 'MCP (' + keys.length + ')';
670
+ var html = '';
671
+ keys.forEach(function(name) {
672
+ var s = servers[name];
673
+ var isConn = s.status === 'connected';
674
+ html += '<div class="mcp-server">';
675
+ html += '<div class="mcp-server-header">';
676
+ html += '<span class="mcp-server-dot ' + (isConn ? 'connected' : 'disconnected') + '"></span>';
677
+ html += '<span class="mcp-server-name">' + escHtml(name) + '</span>';
678
+ html += '<span class="tool-badge ' + (isConn ? 'success' : 'error') + '">' + s.status + '</span>';
679
+ html += '</div>';
680
+ if (s.tools && s.tools.length > 0) {
681
+ html += '<div class="mcp-tools">';
682
+ s.tools.forEach(function(t) { html += '<span class="mcp-tool-tag">' + escHtml(t) + '</span>'; });
683
+ html += '</div>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  }
685
+ html += '</div>';
686
+ });
687
+ content.innerHTML = html;
 
 
 
 
688
  } catch(e) {
689
+ document.getElementById('mcp-dot').className = 'dot disconnected';
690
+ document.getElementById('mcp-content').innerHTML = '<div class="mcp-empty">MCP status unavailable.</div>';
 
 
 
 
 
691
  }
 
692
  }
693
 
694
  function renderExamples(config) {
695
+ // Use the examples from config; fall back to a curated list if missing.
696
+ var examples = [];
697
+ if (config && Array.isArray(config.examples) && config.examples.length) {
698
+ examples = config.examples.map(function(e) {
699
+ return typeof e === 'string' ? e : (e.label || e.prompt || '');
700
+ }).filter(Boolean);
701
+ }
702
+ if (!examples.length) {
703
+ examples = [
704
+ 'Build a Flask REST API with user CRUD',
705
+ 'Create a todo app with localStorage',
706
+ 'Write a Python web scraper',
707
+ 'Build a Markdown to HTML CLI tool',
708
+ 'Create FastAPI with JWT auth',
709
+ 'Make a React calculator component',
710
+ ];
711
+ }
712
+ // Show at most 6 examples so the row doesn't overflow on narrow screens.
713
+ examples = examples.slice(0, 6);
714
  var row = document.getElementById('examples-row');
715
  row.innerHTML = '<span class="examples-label">Try:</span>';
716
  examples.forEach(function(ex) {
 
719
  chip.textContent = ex;
720
  chip.onclick = function() {
721
  document.getElementById('chat-input').value = ex;
722
+ document.getElementById('chat-input').dispatchEvent(new Event('input'));
723
  handleSend();
724
  };
725
  row.appendChild(chip);
 
736
  document.querySelectorAll('.tab-pane').forEach(function(p) {
737
  p.classList.toggle('active', p.id === 'pane-' + tab);
738
  });
 
739
  }
740
 
741
  // ═══ FULLSCREEN ═══
 
771
  var bodyClass = role === 'assistant' ? '<div class="msg-body">' : '';
772
  var bodyClose = role === 'assistant' ? '</div>' : '';
773
  var iterationHtml = extra.iteration ? '<div class="iteration-badge">Iteration ' + extra.iteration + '/' + extra.max_iterations + '</div>' : '';
774
+ div.innerHTML = iterationHtml + prefix + '<span class="msg-content">' + renderMd(content) + '</span>' + bodyClose + bodyClose;
775
  container.appendChild(div);
776
  container.scrollTop = container.scrollHeight;
777
  return div;
 
780
  function renderMd(text) {
781
  if (!text) return '';
782
  text = escHtml(text);
783
+ // Code blocks
784
  text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
785
  var id = 'cb_' + Date.now() + Math.random().toString(36).substr(2,4);
786
  return '<div class="code-block-wrap"><div class="code-block-header"><span class="code-lang">' + (lang||'code') + '</span><button class="btn-copy" onclick="copyBlock(\'' + id + '\')">&#128203; Copy</button></div><pre><code id="' + id + '">' + code.trim() + '</code></pre></div>';
 
824
  var div = document.createElement('div');
825
  div.className = 'tool-block open';
826
  div.id = id;
 
827
  var argsStr = '';
828
  if (args) {
829
  Object.entries(args).forEach(function(e) {
 
831
  argsStr += escHtml(e[0] + ': ' + v) + '\n';
832
  });
833
  }
 
834
  div.innerHTML =
835
  '<div class="tool-header">' +
836
  '<span class="tool-icon">&#9889;</span>' +
 
839
  '</div>' +
840
  '<div class="tool-args">' + argsStr + '</div>' +
841
  '<div class="tool-result"></div>';
 
842
  container.appendChild(div);
843
  container.scrollTop = container.scrollHeight;
844
  state.toolCalls[id] = div;
 
850
  if (!div) return;
851
  var badge = div.querySelector('.tool-badge');
852
  var result = div.querySelector('.tool-result');
 
853
  if (success) {
854
  badge.className = 'tool-badge success';
855
  badge.textContent = 'DONE';
 
857
  badge.className = 'tool-badge error';
858
  badge.textContent = 'ERROR';
859
  }
 
860
  if (output) {
861
  result.textContent = output;
862
  result.className = success ? 'tool-result' : 'tool-result error-output';
 
903
  startStreaming();
904
  setStatus('status-working', 'GENERATING...');
905
 
906
+ // Collect option values
907
+ var targetLanguage = (document.getElementById('opt-language') || {}).value || '';
908
+ var targetFramework = (document.getElementById('opt-framework') || {}).value || '';
909
+ var skill = (document.getElementById('opt-skill') || {}).value || '';
910
+ var agent = (document.getElementById('opt-agent') || {}).value || '';
911
+ var searchEnabled = (document.getElementById('opt-search') || {}).checked || false;
912
+
913
+ // Prepend /skill command if a skill is selected and not already in the message
914
+ var finalMessage = message;
915
+ if (skill && !message.startsWith('/skill')) {
916
+ finalMessage = '/skill ' + skill + '\n\n' + message;
917
+ }
918
+
919
+ // Attach image if present
920
+ var imageUrl = '';
921
+ if (state.attachedImage && state.attachedImage.dataUrl) {
922
+ imageUrl = state.attachedImage.dataUrl;
923
+ }
924
+
925
  try {
926
  var resp = await fetch('/chat', {
927
  method: 'POST',
928
  headers: {'Content-Type': 'application/json'},
929
+ body: JSON.stringify({
930
+ message: finalMessage,
931
+ session_id: state.sessionId,
932
+ target_language: targetLanguage,
933
+ target_framework: targetFramework,
934
+ search_enabled: searchEnabled ? 'true' : 'false',
935
+ agent_name: agent,
936
+ image_url: imageUrl,
937
+ }),
938
  });
939
  if (!resp.ok) throw new Error('HTTP ' + resp.status);
 
940
  var reader = resp.body.getReader();
941
  var decoder = new TextDecoder();
942
  var buffer = '';
 
943
  while (true) {
944
  var result = await reader.read();
945
  if (result.done) break;
946
  buffer += decoder.decode(result.value, {stream: true});
947
  var lines = buffer.split('\n');
948
  buffer = lines.pop() || '';
 
949
  for (var i = 0; i < lines.length; i++) {
950
  var line = lines[i];
951
  if (!line.trim()) continue;
 
968
  break;
969
  }
970
  }
971
+ // Capture code for Code tab
972
  if (ev.result.output && (ev.result.output.includes('<!DOCTYPE') || ev.result.output.includes('<html'))) {
973
  showPreview(ev.result.output);
974
  }
 
986
  if (ev.iterations) setTimeout(refreshWorkspace, 500);
987
  setStatus('status-success', 'COMPLETE');
988
  setTimeout(function() { if (!state.isGenerating) setStatus('status-idle', 'IDLE'); }, 3000);
989
+ // Clear attached image after a successful send
990
+ if (state.attachedImage) {
991
+ state.attachedImage = null;
992
+ document.getElementById('opt-image-input').value = '';
993
+ renderImagePreview();
994
+ }
995
  break;
996
  case 'error':
997
  finalizeStream('Error: ' + ev.error);
 
1007
  setStatus('status-error', 'CONNECTION ERROR');
1008
  }
1009
  }
 
1010
  state.isGenerating = false;
1011
  document.getElementById('btn-send').style.display = 'flex';
1012
  document.getElementById('btn-stop').style.display = 'none';
requirements.txt CHANGED
@@ -1,8 +1,6 @@
1
  # SoniCoder v2 — Optimized AI Code Agent
2
  # Architecture inspired by Gemini CLI and Claude Code
3
 
4
- fastapi>=0.115.0
5
- uvicorn[standard]>=0.30.0
6
  gradio==6.19.0
7
  transformers>=4.45.0
8
  torch>=2.1.0
@@ -12,8 +10,8 @@ requests>=2.31.0
12
  beautifulsoup4>=4.12.0
13
  Pillow>=10.0
14
 
15
- # MCP support (optional — servers work without it)
16
- # mcp>=1.0.0
17
 
18
  # Optional: VLM support
19
  # torchvision>=0.16.0
 
1
  # SoniCoder v2 — Optimized AI Code Agent
2
  # Architecture inspired by Gemini CLI and Claude Code
3
 
 
 
4
  gradio==6.19.0
5
  transformers>=4.45.0
6
  torch>=2.1.0
 
10
  beautifulsoup4>=4.12.0
11
  Pillow>=10.0
12
 
13
+ # MCP support
14
+ mcp>=1.0.0
15
 
16
  # Optional: VLM support
17
  # torchvision>=0.16.0
sonicoder/__init__.py DELETED
@@ -1,12 +0,0 @@
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 DELETED
@@ -1,474 +0,0 @@
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 ..model.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 DELETED
@@ -1,106 +0,0 @@
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 DELETED
@@ -1,455 +0,0 @@
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
- # ── MCP Server Catalog ─────────────────────────────────────────────────
167
- # Pre-configured MCP servers users can enable from the UI.
168
-
169
- MCP_CATALOG: list[dict[str, Any]] = [
170
- {
171
- "key": "filesystem",
172
- "name": "Filesystem",
173
- "description": "Read, write, and manage files on the server. Full file system access with safe path resolution.",
174
- "category": "core",
175
- "icon": "📁",
176
- "transport": "stdio",
177
- "command": "npx",
178
- "args": ["-y", "@anthropic/mcp-filesystem", "."],
179
- "install_hint": "npx -y @anthropic/mcp-filesystem",
180
- "repo_url": "https://github.com/anthropics/mcp-filesystem",
181
- },
182
- {
183
- "key": "fetch",
184
- "name": "Fetch / HTTP",
185
- "description": "Make HTTP requests to any URL. Fetch web pages, APIs, and resources for the agent to read.",
186
- "category": "core",
187
- "icon": "🌐",
188
- "transport": "stdio",
189
- "command": "npx",
190
- "args": ["-y", "@anthropic/mcp-fetch"],
191
- "install_hint": "npx -y @anthropic/mcp-fetch",
192
- "repo_url": "https://github.com/anthropics/mcp-fetch",
193
- },
194
- {
195
- "key": "memory",
196
- "name": "Memory / Knowledge Graph",
197
- "description": "Persistent knowledge graph memory. Store and retrieve facts, entities, and relationships across sessions.",
198
- "category": "core",
199
- "icon": "🧠",
200
- "transport": "stdio",
201
- "command": "npx",
202
- "args": ["-y", "@anthropic/mcp-memory"],
203
- "install_hint": "npx -y @anthropic/mcp-memory",
204
- "repo_url": "https://github.com/anthropics/mcp-memory",
205
- },
206
- {
207
- "key": "sequential-thinking",
208
- "name": "Sequential Thinking",
209
- "description": "Dynamic thinking and reflection. Break down complex problems into ordered reasoning steps.",
210
- "category": "reasoning",
211
- "icon": "💭",
212
- "transport": "stdio",
213
- "command": "npx",
214
- "args": ["-y", "@anthropic/mcp-sequential-thinking"],
215
- "install_hint": "npx -y @anthropic/mcp-sequential-thinking",
216
- "repo_url": "https://github.com/anthropics/mcp-sequential-thinking",
217
- },
218
- {
219
- "key": "github",
220
- "name": "GitHub",
221
- "description": "Search repos, manage issues/PRs, create branches, and interact with the GitHub API directly.",
222
- "category": "dev-tools",
223
- "icon": "🐙",
224
- "transport": "stdio",
225
- "command": "npx",
226
- "args": ["-y", "@anthropic/mcp-github"],
227
- "env": {"GITHUB_TOKEN": ""},
228
- "install_hint": "Set GITHUB_TOKEN env var first, then: npx -y @anthropic/mcp-github",
229
- "repo_url": "https://github.com/anthropics/mcp-github",
230
- },
231
- {
232
- "key": "git",
233
- "name": "Git",
234
- "description": "Git operations: diff, log, status, branch management, and commit history analysis.",
235
- "category": "dev-tools",
236
- "icon": "🔀",
237
- "transport": "stdio",
238
- "command": "npx",
239
- "args": ["-y", "@anthropic/mcp-git"],
240
- "install_hint": "npx -y @anthropic/mcp-git",
241
- "repo_url": "https://github.com/anthropics/mcp-git",
242
- },
243
- {
244
- "key": "docker",
245
- "name": "Docker",
246
- "description": "Manage Docker containers, images, and compose stacks. Build, run, and inspect containers.",
247
- "category": "dev-tools",
248
- "icon": "🐳",
249
- "transport": "stdio",
250
- "command": "npx",
251
- "args": ["-y", "@anthropic/mcp-docker"],
252
- "install_hint": "npx -y @anthropic/mcp-docker (requires Docker daemon)",
253
- "repo_url": "https://github.com/anthropics/mcp-docker",
254
- },
255
- {
256
- "key": "postgres",
257
- "name": "PostgreSQL",
258
- "description": "Read/write PostgreSQL databases. Run queries, inspect schemas, and manage tables.",
259
- "category": "database",
260
- "icon": "🐘",
261
- "transport": "stdio",
262
- "command": "npx",
263
- "args": ["-y", "@anthropic/mcp-postgres"],
264
- "env": {"DATABASE_URL": "postgresql://localhost:5432/mydb"},
265
- "install_hint": "Set DATABASE_URL, then: npx -y @anthropic/mcp-postgres",
266
- "repo_url": "https://github.com/anthropics/mcp-postgres",
267
- },
268
- {
269
- "key": "sqlite",
270
- "name": "SQLite",
271
- "description": "Read/write SQLite databases. Query tables, inspect schemas, run migrations.",
272
- "category": "database",
273
- "icon": "🗃️",
274
- "transport": "stdio",
275
- "command": "npx",
276
- "args": ["-y", "@anthropic/mcp-sqlite", "--db-path", "./workspace/data.db"],
277
- "install_hint": "npx -y @anthropic/mcp-sqlite --db-path ./data.db",
278
- "repo_url": "https://github.com/anthropics/mcp-sqlite",
279
- },
280
- {
281
- "key": "redis",
282
- "name": "Redis",
283
- "description": "Redis cache and data structure operations. Get/set keys, manage lists, sets, and hashes.",
284
- "category": "database",
285
- "icon": "🔴",
286
- "transport": "stdio",
287
- "command": "npx",
288
- "args": ["-y", "@anthropic/mcp-redis"],
289
- "env": {"REDIS_URL": "redis://localhost:6379"},
290
- "install_hint": "Set REDIS_URL, then: npx -y @anthropic/mcp-redis",
291
- "repo_url": "https://github.com/anthropics/mcp-redis",
292
- },
293
- {
294
- "key": "brave-search",
295
- "name": "Brave Search",
296
- "description": "Web search powered by Brave. Search the web, get AI summaries, and fetch page contents.",
297
- "category": "search",
298
- "icon": "🔍",
299
- "transport": "stdio",
300
- "command": "npx",
301
- "args": ["-y", "@anthropic/mcp-brave-search"],
302
- "env": {"BRAVE_API_KEY": ""},
303
- "install_hint": "Set BRAVE_API_KEY, then: npx -y @anthropic/mcp-brave-search",
304
- "repo_url": "https://github.com/anthropics/mcp-brave-search",
305
- },
306
- {
307
- "key": "puppeteer",
308
- "name": "Puppeteer / Browser",
309
- "description": "Headless browser automation. Navigate pages, take screenshots, extract content, and fill forms.",
310
- "category": "browser",
311
- "icon": "🎭",
312
- "transport": "stdio",
313
- "command": "npx",
314
- "args": ["-y", "@anthropic/mcp-puppeteer"],
315
- "install_hint": "npx -y @anthropic/mcp-puppeteer (requires Chromium)",
316
- "repo_url": "https://github.com/anthropics/mcp-puppeteer",
317
- },
318
- {
319
- "key": "slack",
320
- "name": "Slack",
321
- "description": "Send messages, read channels, and manage Slack workspaces via the Slack API.",
322
- "category": "integrations",
323
- "icon": "💬",
324
- "transport": "stdio",
325
- "command": "npx",
326
- "args": ["-y", "@anthropic/mcp-slack"],
327
- "env": {"SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": ""},
328
- "install_hint": "Set SLACK_BOT_TOKEN + SLACK_TEAM_ID, then: npx -y @anthropic/mcp-slack",
329
- "repo_url": "https://github.com/anthropics/mcp-slack",
330
- },
331
- {
332
- "key": "context7",
333
- "name": "Context7 / Docs",
334
- "description": "Look up library documentation. Get up-to-date docs for npm packages, Python libs, and more.",
335
- "category": "search",
336
- "icon": "📚",
337
- "transport": "stdio",
338
- "command": "npx",
339
- "args": ["-y", "@anthropic/mcp-context7"],
340
- "install_hint": "npx -y @anthropic/mcp-context7",
341
- "repo_url": "https://github.com/anthropics/mcp-context7",
342
- },
343
- {
344
- "key": "everything",
345
- "name": "Everything (Meta)",
346
- "description": "Combines multiple MCP tools into one server: filesystem, fetch, memory, and more.",
347
- "category": "core",
348
- "icon": "⚡",
349
- "transport": "stdio",
350
- "command": "npx",
351
- "args": ["-y", "@anthropic/mcp-everything"],
352
- "install_hint": "npx -y @anthropic/mcp-everything",
353
- "repo_url": "https://github.com/anthropics/mcp-everything",
354
- },
355
- {
356
- "key": "sentry",
357
- "name": "Sentry",
358
- "description": "Query Sentry for error tracking. List issues, get stack traces, and manage projects.",
359
- "category": "integrations",
360
- "icon": "🛡️",
361
- "transport": "stdio",
362
- "command": "npx",
363
- "args": ["-y", "@anthropic/mcp-sentry"],
364
- "env": {"SENTRY_TOKEN": "", "SENTRY_ORG": "", "SENTRY_PROJECT": ""},
365
- "install_hint": "Set SENTRY_TOKEN/ORG/PROJECT, then: npx -y @anthropic/mcp-sentry",
366
- "repo_url": "https://github.com/anthropics/mcp-sentry",
367
- },
368
- {
369
- "key": "google-maps",
370
- "name": "Google Maps",
371
- "description": "Search places, get directions, geocode addresses, and embed maps via Google Maps API.",
372
- "category": "integrations",
373
- "icon": "🗺️",
374
- "transport": "stdio",
375
- "command": "npx",
376
- "args": ["-y", "@anthropic/mcp-google-maps"],
377
- "env": {"GOOGLE_MAPS_API_KEY": ""},
378
- "install_hint": "Set GOOGLE_MAPS_API_KEY, then: npx -y @anthropic/mcp-google-maps",
379
- "repo_url": "https://github.com/anthropics/mcp-google-maps",
380
- },
381
- {
382
- "key": "time",
383
- "name": "Time / Timezone",
384
- "description": "Get current time in any timezone. Convert between timezones and calculate time differences.",
385
- "category": "utilities",
386
- "icon": "⏰",
387
- "transport": "stdio",
388
- "command": "npx",
389
- "args": ["-y", "@anthropic/mcp-time"],
390
- "install_hint": "npx -y @anthropic/mcp-time",
391
- "repo_url": "https://github.com/anthropics/mcp-time",
392
- },
393
- ]
394
-
395
-
396
- def get_available_models() -> dict[str, ModelConfig]:
397
- """Return all registered models."""
398
- return MODEL_REGISTRY.copy()
399
-
400
-
401
- def get_model_config(model_key: str) -> ModelConfig | None:
402
- """Get a model config by key."""
403
- return MODEL_REGISTRY.get(model_key)
404
-
405
-
406
- def load_settings(project_dir: Path | None = None) -> Settings:
407
- """Load settings with hierarchical cascading.
408
-
409
- Priority (highest → lowest):
410
- 1. Environment variables
411
- 2. .sonicoder/settings.local.json (local overrides, gitignored)
412
- 3. .sonicoder/settings.json (project settings)
413
- 4. Defaults
414
- """
415
- settings = Settings()
416
-
417
- search_dirs = [Path.cwd()]
418
- if project_dir:
419
- search_dirs.insert(0, project_dir)
420
-
421
- # Load project-level settings
422
- for d in search_dirs:
423
- local_path = d / CONFIG_DIR / "settings.local.json"
424
- proj_path = d / CONFIG_DIR / "settings.json"
425
-
426
- for path in [local_path, proj_path]:
427
- if path.exists():
428
- try:
429
- data = json.loads(path.read_text(encoding="utf-8"))
430
- # Parse MCP server configs
431
- if "mcp_servers" in data:
432
- data["mcp_servers"] = [
433
- MCPServerConfig(**s) for s in data["mcp_servers"]
434
- ]
435
- if "policy_rules" in data:
436
- data["policy_rules"] = [
437
- PolicyRule(**r) for r in data["policy_rules"]
438
- ]
439
- settings = settings.merge(Settings.from_dict(data))
440
- except (json.JSONDecodeError, TypeError) as e:
441
- print(f"[config] Warning: Failed to parse {path}: {e}")
442
-
443
- # Environment variable overrides
444
- env_model = os.environ.get("SONICODER_MODEL")
445
- if env_model and env_model in MODEL_REGISTRY:
446
- settings.default_model = env_model
447
-
448
- env_temp = os.environ.get("SONICODER_TEMPERATURE")
449
- if env_temp:
450
- try:
451
- settings.temperature = float(env_temp)
452
- except ValueError:
453
- pass
454
-
455
- return settings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sonicoder/context/__init__.py DELETED
@@ -1,154 +0,0 @@
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 DELETED
File without changes
sonicoder/mcp/client_manager.py DELETED
File without changes
sonicoder/model/__init__.py DELETED
File without changes
sonicoder/model/inference.py DELETED
@@ -1,224 +0,0 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sonicoder/model/loader.py DELETED
@@ -1,181 +0,0 @@
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
- "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 DELETED
File without changes
sonicoder/server/__init__.py DELETED
@@ -1,413 +0,0 @@
1
- """Server routes — pure FastAPI with custom HTML frontend.
2
-
3
- Uses FastAPI directly (not gradio.Server) to guarantee our custom
4
- index.html is served at / without Gradio's default UI taking over.
5
-
6
- Frontend uses plain fetch() — no @gradio/client needed.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import json
12
- import logging
13
- import uuid
14
- from pathlib import Path
15
- from typing import Any, Generator
16
-
17
- from fastapi import FastAPI, Request
18
- from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
19
-
20
- from ..config import (
21
- Settings, get_available_models, load_settings,
22
- WORKSPACE_ROOT, CONFIG_DIR, MCP_CATALOG,
23
- )
24
- from ..model.loader import (
25
- get_model_status, start_background_load, switch_model as switch_model_fn,
26
- )
27
- from ..tools import get_tool_registry, ToolRegistry
28
- from ..tools.fs import BUILTIN_TOOLS as FS_TOOLS
29
- from ..tools.bash import BUILTIN_TOOLS as BASH_TOOLS
30
- from ..tools.web import BUILTIN_TOOLS as WEB_TOOLS
31
- from ..tools.todos import BUILTIN_TOOLS as TODO_TOOLS
32
- from ..agent import AgentSession, register_builtin_tools
33
- from ..mcp import get_mcp_manager
34
- from ..policy import get_policy_engine
35
-
36
- logger = logging.getLogger(__name__)
37
-
38
- # ── Global State ───────────────────────────────────────────────────────
39
-
40
- _settings: Settings | None = None
41
- _registry: ToolRegistry | None = None
42
- _sessions: dict[str, AgentSession] = {}
43
-
44
-
45
- def _get_settings() -> Settings:
46
- global _settings
47
- if _settings is None:
48
- _settings = load_settings()
49
- return _settings
50
-
51
-
52
- def _get_registry() -> ToolRegistry:
53
- global _registry
54
- if _registry is None:
55
- _registry = get_tool_registry()
56
- register_builtin_tools(_registry)
57
- settings = _get_settings()
58
- _registry.set_filters(settings.enabled_tools, settings.disabled_tools)
59
- return _registry
60
-
61
-
62
- def _get_or_create_session(session_id: str = "") -> tuple[str, AgentSession]:
63
- if not session_id:
64
- session_id = str(uuid.uuid4())[:12]
65
- if session_id not in _sessions:
66
- _sessions[session_id] = AgentSession(
67
- settings=_get_settings(),
68
- tool_registry=_get_registry(),
69
- )
70
- return session_id, _sessions[session_id]
71
-
72
-
73
- # ── JSON helpers ───────────────────────────────────────────────────────
74
-
75
- def _json(data) -> JSONResponse:
76
- return JSONResponse(content=data if isinstance(data, dict | list) else json.loads(data))
77
-
78
-
79
- def _json_str(data) -> JSONResponse:
80
- return JSONResponse(content=json.loads(data) if isinstance(data, str) else data)
81
-
82
-
83
- # ── Create App ────────────────────────────────────────────────────────
84
-
85
- def create_app():
86
- """Create and return a FastAPI app with custom HTML frontend.
87
-
88
- Uses FastAPI directly (not gradio.Server) so our custom index.html
89
- is always served at / without being overridden by Gradio's routes.
90
- """
91
- app = FastAPI(title="SoniCoder v2", docs_url=None, redoc_url=None)
92
-
93
- # ── Serve index.html from project root ─────────────────────────────
94
-
95
- @app.get("/", response_class=HTMLResponse)
96
- async def homepage():
97
- html_path = Path(__file__).parent.parent.parent / "index.html"
98
- if not html_path.exists():
99
- return HTMLResponse(
100
- "<h1>SoniCoder v2</h1><p>index.html not found at project root</p>",
101
- status_code=500,
102
- )
103
- with open(html_path, "r", encoding="utf-8") as f:
104
- return f.read()
105
-
106
- # ── Config ─────────────────────────────────────────────────────────
107
-
108
- @app.get("/api/get_config")
109
- async def get_config():
110
- models = get_available_models()
111
- model_list = []
112
- for key, config in models.items():
113
- model_list.append({
114
- "key": key,
115
- "name": config.name,
116
- "model_type": config.model_type,
117
- "size_gb": config.size_gb,
118
- "description": config.description,
119
- })
120
- return JSONResponse(content={
121
- "models": model_list,
122
- "default_model": _get_settings().default_model,
123
- "workspace": str(WORKSPACE_ROOT),
124
- "version": "2.0.0",
125
- })
126
-
127
- # ── Model status ───────────────────────────────────────────────────
128
-
129
- @app.get("/api/model_status")
130
- async def model_status():
131
- return _json(get_model_status())
132
-
133
- # ── Switch model ────────────��──────────────────────────────────────
134
-
135
- @app.post("/api/switch_model")
136
- async def switch_model(request: Request):
137
- body = await request.json()
138
- model_key = body.get("model_key", "")
139
- switch_model_fn(model_key)
140
- return JSONResponse(content={"status": "switching", "model": model_key})
141
-
142
- # ── List models ────────────────────────────────────────────────────
143
-
144
- @app.get("/api/list_models")
145
- async def list_models():
146
- models = get_available_models()
147
- current = get_model_status().get("model_key", "")
148
- result = []
149
- for key, config in models.items():
150
- result.append({
151
- "key": key,
152
- "name": config.name,
153
- "model_type": config.model_type,
154
- "size_gb": config.size_gb,
155
- "description": config.description,
156
- "current": key == current,
157
- })
158
- return JSONResponse(content=result)
159
-
160
- # ── Workspace tree ─────────────────────────────────────────────────
161
-
162
- @app.get("/api/workspace_tree")
163
- async def workspace_tree(max_depth: int = 3):
164
- tree = []
165
-
166
- def walk(path: Path, prefix: str = "", depth: int = 0):
167
- if depth >= max_depth:
168
- return
169
- try:
170
- items = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
171
- except PermissionError:
172
- return
173
- skip = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".sonicoder"}
174
- for i, item in enumerate(items):
175
- if item.name.startswith(".") or item.name in skip:
176
- continue
177
- is_last = i == len(items) - 1
178
- connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
179
- if item.is_dir():
180
- tree.append(f"{prefix}{connector}{item.name}/")
181
- extension = " " if is_last else "\u2502 "
182
- walk(item, prefix + extension, depth + 1)
183
- else:
184
- tree.append(f"{prefix}{connector}{item.name}")
185
-
186
- if WORKSPACE_ROOT.exists():
187
- walk(WORKSPACE_ROOT)
188
-
189
- return JSONResponse(content={
190
- "tree": "\n".join(tree) if tree else "(empty workspace)",
191
- "root": str(WORKSPACE_ROOT),
192
- })
193
-
194
- # ── Workspace read ─────────────────────────────────────────────────
195
-
196
- @app.post("/api/workspace_read")
197
- async def workspace_read(request: Request):
198
- body = await request.json()
199
- path = body.get("path", "")
200
- offset = body.get("offset", 1)
201
- limit = body.get("limit", 500)
202
- from ..tools.fs import _resolve_safe
203
- try:
204
- resolved = _resolve_safe(path)
205
- if not resolved.exists():
206
- return JSONResponse(content={"error": f"File not found: {path}"})
207
- content = resolved.read_text(encoding="utf-8", errors="replace")
208
- lines = content.splitlines()
209
- total = len(lines)
210
- start = max(0, offset - 1)
211
- end = min(total, start + limit)
212
- selected = lines[start:end]
213
- numbered = [f" {i + start + 1:>6}\t{line}" for i, line in enumerate(selected)]
214
- return JSONResponse(content={
215
- "content": "\n".join(numbered),
216
- "total_lines": total,
217
- "truncated": end < total,
218
- })
219
- except Exception as e:
220
- return JSONResponse(content={"error": str(e)})
221
-
222
- # ── Workspace write ────────────────────────────────────────────────
223
-
224
- @app.post("/api/workspace_write")
225
- async def workspace_write(request: Request):
226
- body = await request.json()
227
- path = body.get("path", "")
228
- content = body.get("content", "")
229
- from ..tools.fs import _resolve_safe
230
- try:
231
- resolved = _resolve_safe(path)
232
- resolved.parent.mkdir(parents=True, exist_ok=True)
233
- resolved.write_text(content, encoding="utf-8")
234
- return JSONResponse(content={"success": True, "path": path})
235
- except Exception as e:
236
- return JSONResponse(content={"error": str(e)})
237
-
238
- # ── Workspace bash ─────────────────────────────────────────────────
239
-
240
- @app.post("/api/workspace_bash")
241
- async def workspace_bash(request: Request):
242
- body = await request.json()
243
- command = body.get("command", "")
244
- from ..tools.bash import BashBuilder
245
- builder = BashBuilder()
246
- errors = builder.validate({"command": command})
247
- if errors:
248
- return JSONResponse(content={"error": "; ".join(errors)})
249
- invocation = builder.create_invocation({"command": command})
250
- result = invocation.execute()
251
- return JSONResponse(content=result.to_dict())
252
-
253
- # ── Web search ─────────────────────────────────────────────────────
254
-
255
- @app.post("/api/web_search")
256
- async def web_search(request: Request):
257
- body = await request.json()
258
- query = body.get("query", "")
259
- from ..tools.web import WebSearchBuilder
260
- builder = WebSearchBuilder()
261
- invocation = builder.create_invocation({"query": query})
262
- result = invocation.execute()
263
- return JSONResponse(content=result.to_dict())
264
-
265
- # ── MCP status ─────────────────────────────────────────────────────
266
-
267
- @app.get("/api/mcp_status")
268
- async def mcp_status():
269
- return _json(get_mcp_manager().get_status())
270
-
271
- # ── MCP catalog (available servers to enable) ──────────────────────
272
-
273
- @app.get("/api/mcp_catalog")
274
- async def mcp_catalog():
275
- """Return all available MCP servers from the catalog, with their current status."""
276
- manager = get_mcp_manager()
277
- status = manager.get_status()
278
- catalog = []
279
- for server in MCP_CATALOG:
280
- key = server["key"]
281
- s = status.get(key, {"connected": False, "tool_count": 0})
282
- catalog.append({
283
- "key": key,
284
- "name": server["name"],
285
- "description": server["description"],
286
- "category": server["category"],
287
- "icon": server.get("icon", ""),
288
- "transport": server["transport"],
289
- "command": server.get("command", ""),
290
- "args": server.get("args", []),
291
- "connected": s.get("connected", False),
292
- "enabled": key in status,
293
- "tool_count": s.get("tool_count", 0),
294
- "install_hint": server.get("install_hint", ""),
295
- "repo_url": server.get("repo_url", ""),
296
- })
297
- return JSONResponse(content={"servers": catalog})
298
-
299
- # ── MCP toggle (enable/disable a server) ───────────────────────────
300
-
301
- @app.post("/api/mcp_toggle")
302
- async def mcp_toggle(request: Request):
303
- body = await request.json()
304
- server_key = body.get("server_key", "")
305
- enabled = body.get("enabled", False)
306
-
307
- # Find in catalog
308
- server_cfg = None
309
- for s in MCP_CATALOG:
310
- if s["key"] == server_key:
311
- server_cfg = s
312
- break
313
-
314
- if not server_cfg:
315
- return JSONResponse(content={"error": f"Unknown MCP server: {server_key}"})
316
-
317
- manager = get_mcp_manager()
318
-
319
- if not enabled:
320
- # Disable: remove from manager
321
- manager.remove_server(server_key)
322
- return JSONResponse(content={"status": "disabled", "server": server_key})
323
- else:
324
- # Enable: add and connect
325
- from ..config import MCPServerConfig
326
- config = MCPServerConfig(
327
- name=server_key,
328
- transport=server_cfg["transport"],
329
- command=server_cfg.get("command"),
330
- args=server_cfg.get("args", []),
331
- env=server_cfg.get("env", {}),
332
- enabled=True,
333
- )
334
- manager.add_server(config)
335
- error = None
336
- if config.transport == "stdio" and config.command:
337
- try:
338
- error = manager.connect_all().get(server_key)
339
- except Exception as e:
340
- error = str(e)
341
- return JSONResponse(content={
342
- "status": "enabled" if not error else "error",
343
- "server": server_key,
344
- "error": error,
345
- })
346
-
347
- # ── MCP connect all ────────────────────────────────────────────────
348
-
349
- @app.post("/api/mcp_connect_all")
350
- async def mcp_connect_all():
351
- manager = get_mcp_manager()
352
- results = manager.connect_all()
353
- return JSONResponse(content={"results": results})
354
-
355
- # ── Policy status ──────────────────────────────────────────────────
356
-
357
- @app.get("/api/policy_status")
358
- async def policy_status():
359
- engine = get_policy_engine()
360
- return JSONResponse(content={"mode": engine.approval_mode, "rules": engine.get_rules()})
361
-
362
- # ── Reset session ──────────────────────────────────────────────────
363
-
364
- @app.post("/api/reset_session")
365
- async def reset_session(request: Request):
366
- body = await request.json()
367
- session_id = body.get("session_id", "")
368
- if session_id in _sessions:
369
- _sessions[session_id].clear_history()
370
- return JSONResponse(content={"success": True})
371
-
372
- # ── Streaming chat ─────────────────────────────────────────────────
373
-
374
- @app.post("/chat")
375
- async def chat_stream(request: Request):
376
- """Streaming chat — returns newline-delimited JSON via StreamingResponse."""
377
- try:
378
- body = await request.json()
379
- except Exception:
380
- body = {}
381
-
382
- message = body.get("message", "")
383
- session_id = body.get("session_id", "")
384
- image_url = body.get("image_url")
385
-
386
- if not message or not message.strip():
387
- return StreamingResponse(
388
- iter([json.dumps({"type": "error", "error": "Empty message"}) + "\n"]),
389
- media_type="text/event-stream",
390
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
391
- )
392
-
393
- sid, session = _get_or_create_session(session_id)
394
-
395
- def event_generator():
396
- try:
397
- for event in session.run(message, session_id=sid, image_url=image_url):
398
- yield json.dumps(event, ensure_ascii=False) + "\n"
399
- except Exception as e:
400
- logger.error(f"Chat stream error: {e}", exc_info=True)
401
- yield json.dumps({"type": "error", "error": str(e)}) + "\n"
402
-
403
- return StreamingResponse(
404
- event_generator(),
405
- media_type="text/event-stream",
406
- headers={
407
- "Cache-Control": "no-cache",
408
- "X-Accel-Buffering": "no",
409
- "Connection": "keep-alive",
410
- },
411
- )
412
-
413
- return app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sonicoder/skills/__init__.py DELETED
File without changes
sonicoder/tools/__init__.py DELETED
@@ -1,215 +0,0 @@
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 DELETED
@@ -1,143 +0,0 @@
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 DELETED
@@ -1,508 +0,0 @@
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 DELETED
@@ -1,109 +0,0 @@
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 DELETED
@@ -1,163 +0,0 @@
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 DELETED
@@ -1,568 +0,0 @@
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>