R-Kentaren commited on
Commit
81aa0b5
Β·
verified Β·
1 Parent(s): c4c43f1

feat(agent): add Claude Code-style agent, skills, slash-commands, hooks, todos, sandboxed workspace, and full-stack scaffolding

Browse files

Adds Claude Code-inspired agent capabilities:
- Agent loop with tools (read/write/edit/glob/grep/bash/todos)
- Skills system (markdown skill files, runtime loading)
- Slash commands (/commit /review /feature /design /explain /test /refactor /skill /help)
- Hooks system (block-dangerous-rm, warn-debug-code, warn-secrets-in-code, warn-eval-exec)
- Sandboxed workspace with path-escape protection
- Todo lists (todo_write / todo_read / todo_update)
- Full-stack scaffolding skill
- No external SDK dependencies; no Next.js conversion

CLAUDE.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SoniCoder β€” Project Memory
2
+
3
+ > This file is the project's persistent memory. The agent reads it on every session.
4
+ > Edit it freely β€” it overrides defaults.
5
+
6
+ ## What is SoniCoder?
7
+
8
+ SoniCoder is a local-first AI coding agent that can:
9
+ - Generate complete fullstack applications in any language/framework
10
+ - Read, write, and edit files in a sandboxed workspace
11
+ - Run shell commands (git, npm, pip, tests)
12
+ - Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
13
+ - Respond to slash commands (/commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help)
14
+ - Deploy to HuggingFace Spaces with one click
15
+
16
+ ## Architecture
17
+
18
+ ```
19
+ app.py ← Entry point: launches Gradio Server
20
+ code/
21
+ β”œβ”€β”€ config/constants.py ← App config, system prompt, language options
22
+ β”œβ”€β”€ model/
23
+ β”‚ β”œβ”€β”€ loader.py ← Dual model loading (text + VLM)
24
+ β”‚ └── inference.py ← Streaming inference (text + VLM)
25
+ β”œβ”€β”€ agent/__init__.py ← Agent loop (model ↔ tools)
26
+ β”œβ”€β”€ tools/
27
+ β”‚ β”œβ”€β”€ fs.py ← read_file, write_file, edit_file, glob, grep, list_dir
28
+ β”‚ β”œβ”€β”€ bash.py ← Sandboxed shell execution
29
+ β”‚ └── todos.py ← Todo list management
30
+ β”œβ”€β”€ skills/
31
+ β”‚ β”œβ”€β”€ __init__.py ← Skill discovery + loading
32
+ β”‚ └── builtins/ ← Built-in skills (markdown)
33
+ β”œβ”€β”€ commands/
34
+ β”‚ β”œβ”€β”€ __init__.py ← Slash command parser + expander
35
+ β”‚ └── builtins/ ← Built-in commands (markdown)
36
+ β”œβ”€β”€ hooks/
37
+ β”‚ β”œβ”€β”€ __init__.py ← Hook rule engine
38
+ β”‚ └── builtins/ ← Built-in hook rules (markdown)
39
+ β”œβ”€β”€ execution/
40
+ β”‚ β”œβ”€β”€ code_extractor.py ← Code extraction from model output
41
+ β”‚ β”œβ”€β”€ python_runner.py ← Sandboxed Python execution
42
+ β”‚ └── gradio_runner.py ← Gradio app subprocess runner
43
+ β”œβ”€β”€ huggingface/
44
+ β”‚ β”œβ”€β”€ dockerfile_gen.py ← Auto Dockerfile/package.json for JS
45
+ β”‚ └── push.py ← HF Hub push + ZIP packaging
46
+ β”œβ”€β”€ websearch/google_scraper.py ← DuckDuckGo + Google scraping (no API)
47
+ └── server/
48
+ β”œβ”€β”€ chat_helpers.py ← Chat history + prompt building
49
+ └── routes.py ← All HTTP + API endpoints
50
+ index.html ← Frontend (single-file SPA)
51
+ workspace/ ← Sandboxed agent workspace (auto-created)
52
+ ```
53
+
54
+ ## Conventions
55
+
56
+ - **Python**: 3.11+, type hints everywhere, `from __future__ import annotations`
57
+ - **Style**: Black formatting, 4-space indent, 100 char line limit
58
+ - **Docstrings**: Google style for modules, functions, classes
59
+ - **Error handling**: catch specific exceptions, never bare `except:`
60
+ - **Logging**: use `logging.getLogger(__name__)`, never `print()`
61
+ - **Tests**: pytest, in `tests/` directory, `test_*.py` naming
62
+ - **Frontend**: single-file HTML with inline CSS/JS, no build step
63
+
64
+ ## Server rules
65
+
66
+ - All servers bind to `0.0.0.0` (never `localhost`)
67
+ - Default port: `7860` (HF Spaces convention)
68
+ - Sub-servers use `7861`, `7862`, etc.
69
+
70
+ ## Model
71
+
72
+ - Default: `openbmb/MiniCPM5-1B` (text-only, 2.17 GB)
73
+ - Optional: `openbmb/MiniCPM-V-4.6` (vision + text, 2.8 GB)
74
+ - Loaded in background thread on startup
75
+ - Streaming inference via `TextIteratorStreamer`
76
+
77
+ ## Tool call format
78
+
79
+ The model calls tools by emitting fenced code blocks with `tool` as the language:
80
+
81
+ ```tool
82
+ read_file
83
+ path: src/app.py
84
+ ```
85
+
86
+ Multi-line values use YAML block scalars:
87
+
88
+ ```tool
89
+ write_file
90
+ path: src/new.py
91
+ content: |
92
+ import os
93
+ def main():
94
+ pass
95
+ ```
96
+
97
+ ## Slash commands
98
+
99
+ | Command | Description |
100
+ |---------|-------------|
101
+ | `/commit` | Create a git commit with a generated message |
102
+ | `/review` | Review current changes for bugs and quality |
103
+ | `/feature <desc>` | Guided feature development workflow |
104
+ | `/design <brief>` | Generate a distinctive frontend design |
105
+ | `/explain <target>` | Explain how code works |
106
+ | `/test <target>` | Generate tests |
107
+ | `/refactor <target>` | Refactor code for clarity |
108
+ | `/skill <name>` | Load and apply a skill |
109
+ | `/help` | Show available commands and skills |
110
+
111
+ ## Skills
112
+
113
+ | Skill | Description |
114
+ |-------|-------------|
115
+ | `frontend-design` | Distinctive visual design guidance |
116
+ | `feature-dev` | Guided feature implementation workflow |
117
+ | `code-review` | High-signal code review |
118
+ | `debugging` | Systematic debugging workflow |
119
+ | `fullstack-scaffold` | Project structure scaffolding rules |
120
+ | `commit-workflow` | Git commit best practices |
121
+
122
+ ## Hooks
123
+
124
+ Hooks are markdown rules that fire on events (`bash`, `file`, `prompt`, `stop`).
125
+ They can `warn` (show a message) or `block` (prevent the action).
126
+
127
+ Built-in hooks:
128
+ - `block-dangerous-rm` β€” blocks `rm -rf /`, `~`, `$HOME`, `..`
129
+ - `warn-debug-code` οΏ½οΏ½οΏ½ warns on `console.log`, `debugger`, `print`, `alert`
130
+ - `warn-secrets-in-code` β€” warns on hardcoded API_KEY/SECRET/TOKEN/PASSWORD
131
+ - `warn-eval-exec` β€” warns on `eval()` and `exec()`
132
+
133
+ Users can add custom hooks in `workspace/.sonicoder/hooks/*.local.md`.
134
+
135
+ ## Workspace
136
+
137
+ The agent's sandboxed filesystem lives at `./workspace/` (configurable via
138
+ `SONICODER_WORKSPACE` env var). All file tools refuse paths that escape this root.
139
+
140
+ ## Deploy
141
+
142
+ Generated projects can be pushed to HuggingFace Spaces via the Deploy tab.
143
+ Supported SDKs:
144
+ - `static` β€” HTML/CSS/JS
145
+ - `gradio` β€” Python Gradio apps
146
+ - `streamlit` β€” Python Streamlit apps
147
+ - `docker` β€” JS/TS frameworks (auto-generates Dockerfile + package.json)
README.md CHANGED
@@ -17,23 +17,33 @@ hf_oauth_scopes:
17
 
18
  ## SoniCoder
19
 
20
- An AI-powered fullstack application generator running **entirely locally** with no external API dependencies. Powered by [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) (2.17 GB).
21
 
22
- ### Features
23
 
24
- - **Local Inference**: Uses MiniCPM5-1B running locally via `transformers` β€” no API keys needed
25
- - **Multi-Language Support**: Generate apps in Python, JavaScript, TypeScript, Java, Go, Rust, PHP, Ruby, C#, Swift, Kotlin, and more
26
- - **Framework Support**: Choose from popular frameworks like React, Vue, Flask, Django, Express, Spring Boot, and others
27
- - **Live Preview**: See generated web apps in a sandboxed iframe preview
28
- - **Code Execution**: Run generated Python code and see output
29
- - **Project Download**: Download generated projects as ZIP files
30
- - **HuggingFace Deploy**: Push generated projects directly to HuggingFace Spaces
 
 
 
 
 
 
 
 
 
 
31
 
32
  ### Supported Languages & Frameworks
33
 
34
  | Language | Frameworks |
35
  |----------|-----------|
36
- | Python | Flask, Django, FastAPI, Streamlit, Plain Python |
37
  | JavaScript | React, Vue.js, Next.js, Express.js, Node.js, Vanilla JS |
38
  | TypeScript | React, Next.js, Express.js, NestJS |
39
  | HTML/CSS/JS | Tailwind CSS, Bootstrap, Vanilla |
@@ -46,6 +56,58 @@ An AI-powered fullstack application generator running **entirely locally** with
46
  | Swift | Vapor, SwiftUI |
47
  | Kotlin | Ktor, Spring Boot |
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  ### Local Run
50
 
51
  ```bash
@@ -53,16 +115,20 @@ pip install -r requirements.txt
53
  python app.py
54
  ```
55
 
56
- The model (MiniCPM5-1B, ~2.17 GB) will be automatically downloaded on first run.
 
 
 
 
57
 
58
  ### HuggingFace Deploy
59
 
60
  1. Generate your application
61
- 2. Go to the "Deploy" tab in the output panel
62
- 3. Enter your HuggingFace repository name and token
63
- 4. Select the Space SDK (Static, Gradio, Streamlit, or Docker)
64
  5. Click "Push to HuggingFace"
65
 
66
  ### No External APIs
67
 
68
- This application does not use any external API calls. All model inference runs locally using the `transformers` library with MiniCPM5-1B.
 
17
 
18
  ## SoniCoder
19
 
20
+ An AI-powered **autonomous coding agent** running entirely locally with no external API dependencies. Powered by [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) (2.17 GB).
21
 
22
+ Inspired by [Claude Code](https://github.com/anthropics/claude-code), SoniCoder adds:
23
 
24
+ - πŸ€– **Agent Loop** β€” model calls tools (read/write/edit/glob/grep/bash/todos) in a feedback loop
25
+ - 🎯 **Skills System** β€” load markdown skill files at runtime (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
26
+ - ⚑ **Slash Commands** β€” `/commit`, `/review`, `/feature`, `/design`, `/explain`, `/test`, `/refactor`, `/skill`, `/help`
27
+ - πŸͺ **Hooks System** β€” pre/post tool execution rules (block dangerous commands, warn on debug code/secrets)
28
+ - πŸ“ **Sandboxed Workspace** β€” agent manipulates files in `./workspace/` (path-escape protected)
29
+ - βœ… **Todo Lists** β€” track multi-step tasks Claude Code-style
30
+ - πŸš€ **HuggingFace Deploy** β€” push generated projects directly to HuggingFace Spaces
31
+
32
+ ### Features (original)
33
+
34
+ - **Local Inference**: MiniCPM5-1B via `transformers` β€” no API keys
35
+ - **Multi-Language**: Python, JavaScript, TypeScript, Java, Go, Rust, PHP, Ruby, C#, Swift, Kotlin
36
+ - **Frameworks**: React, Vue, Next.js, Express, Flask, Django, FastAPI, Spring Boot, and more
37
+ - **Live Preview**: sandboxed iframe preview of generated web apps
38
+ - **Code Execution**: run generated Python and see output
39
+ - **Project Download**: ZIP the generated project
40
+ - **HuggingFace Deploy**: one-click push to HF Spaces (Static/Gradio/Streamlit/Docker)
41
 
42
  ### Supported Languages & Frameworks
43
 
44
  | Language | Frameworks |
45
  |----------|-----------|
46
+ | Python | Flask, Django, FastAPI, Streamlit, Gradio, Plain Python |
47
  | JavaScript | React, Vue.js, Next.js, Express.js, Node.js, Vanilla JS |
48
  | TypeScript | React, Next.js, Express.js, NestJS |
49
  | HTML/CSS/JS | Tailwind CSS, Bootstrap, Vanilla |
 
56
  | Swift | Vapor, SwiftUI |
57
  | Kotlin | Ktor, Spring Boot |
58
 
59
+ ### Agent Tools
60
+
61
+ The agent can call these tools (Claude Code-style):
62
+
63
+ | Tool | Description |
64
+ |------|-------------|
65
+ | `read_file` | Read a file from the workspace |
66
+ | `write_file` | Write content to a file |
67
+ | `edit_file` | Replace text in a file (with uniqueness check) |
68
+ | `multi_edit` | Apply multiple edits atomically |
69
+ | `list_dir` | List directory contents |
70
+ | `glob` | Find files matching a pattern |
71
+ | `grep` | Search file contents with regex |
72
+ | `bash` | Run a shell command (sandboxed) |
73
+ | `todo_write` | Replace the todo list |
74
+ | `todo_read` | Read the current todo list |
75
+ | `todo_update` | Update a single todo |
76
+
77
+ ### Slash Commands
78
+
79
+ | Command | Description |
80
+ |---------|-------------|
81
+ | `/commit [msg]` | Create a git commit with a generated message |
82
+ | `/review [file]` | Review changes for bugs and quality |
83
+ | `/feature <desc>` | Guided feature development |
84
+ | `/design <brief>` | Generate a distinctive frontend design |
85
+ | `/explain <target>` | Explain how code works |
86
+ | `/test [target]` | Generate tests |
87
+ | `/refactor <target>` | Refactor code for clarity |
88
+ | `/skill <name>` | Load and apply a skill |
89
+ | `/help` | Show available commands and skills |
90
+
91
+ ### Built-in Skills
92
+
93
+ - **frontend-design** β€” distinctive visual design guidance (palette, typography, signature)
94
+ - **feature-dev** β€” 7-phase guided feature implementation
95
+ - **code-review** β€” high-signal review focusing on bugs and security
96
+ - **debugging** β€” systematic 6-phase debugging workflow
97
+ - **fullstack-scaffold** β€” project structure rules for any framework
98
+ - **commit-workflow** β€” conventional commits best practices
99
+
100
+ Add custom skills in `workspace/.sonicoder/skills/<name>/SKILL.md`.
101
+
102
+ ### Built-in Hooks
103
+
104
+ - **block-dangerous-rm** β€” blocks `rm -rf /`, `~`, `$HOME`, `..`
105
+ - **warn-debug-code** β€” warns on `console.log`, `debugger`, `print`, `alert`
106
+ - **warn-secrets-in-code** β€” warns on hardcoded API_KEY/SECRET/TOKEN/PASSWORD
107
+ - **warn-eval-exec** β€” warns on `eval()` and `exec()`
108
+
109
+ Add custom hooks in `workspace/.sonicoder/hooks/<name>.local.md`.
110
+
111
  ### Local Run
112
 
113
  ```bash
 
115
  python app.py
116
  ```
117
 
118
+ The model (MiniCPM5-1B, ~2.17 GB) downloads automatically on first run.
119
+
120
+ ### Project Memory
121
+
122
+ The `CLAUDE.md` file at the project root is the agent's persistent memory. Edit it freely to override defaults and document project-specific conventions.
123
 
124
  ### HuggingFace Deploy
125
 
126
  1. Generate your application
127
+ 2. Go to the "Deploy" tab
128
+ 3. Sign in with HuggingFace OAuth (or paste a token)
129
+ 4. Select the Space SDK (Auto, Docker, Static, Gradio, Streamlit)
130
  5. Click "Push to HuggingFace"
131
 
132
  ### No External APIs
133
 
134
+ This application does not use any external API calls. All model inference runs locally using `transformers` with MiniCPM5-1B. Web search uses DuckDuckGo/Google HTML scraping (no API key).
code/agent/__init__.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent orchestration β€” Claude Code-style agent loop.
2
+
3
+ The agent:
4
+ 1. Receives a user prompt
5
+ 2. Calls the model with available tools in system prompt
6
+ 3. Parses the model's response for tool calls
7
+ 4. Executes tools (with hooks checking)
8
+ 5. Feeds results back to the model
9
+ 6. Repeats until model stops calling tools or max iterations reached
10
+
11
+ Tool call format (model outputs):
12
+ ```tool
13
+ read_file
14
+ path: src/app.py
15
+ ```
16
+
17
+ Or multi-line:
18
+ ```tool
19
+ write_file
20
+ path: src/new.py
21
+ content: |
22
+ import os
23
+ def main():
24
+ pass
25
+ ```
26
+
27
+ The agent executes the tool, captures output, and feeds back as a
28
+ user-style message in the next iteration.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ import re
36
+ from typing import Any, Iterator
37
+
38
+ from code.commands import expand_command, parse_command_input
39
+ from code.config.constants import SYSTEM_PROMPT
40
+ from code.hooks import check_hook
41
+ from code.skills import build_skills_context
42
+ from code.tools import (
43
+ edit_file,
44
+ glob_paths,
45
+ grep_search,
46
+ list_dir,
47
+ multi_edit,
48
+ read_file,
49
+ run_bash,
50
+ todo_read,
51
+ todo_write,
52
+ todo_update,
53
+ write_file,
54
+ )
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ # ─── Tool registry ──────────────────────────────────────────────────────
59
+
60
+ TOOL_REGISTRY: dict[str, Any] = {
61
+ "read_file": read_file,
62
+ "write_file": write_file,
63
+ "edit_file": edit_file,
64
+ "multi_edit": multi_edit,
65
+ "list_dir": list_dir,
66
+ "glob": glob_paths,
67
+ "grep": grep_search,
68
+ "bash": run_bash,
69
+ "todo_read": todo_read,
70
+ "todo_write": todo_write,
71
+ "todo_update": todo_update,
72
+ }
73
+
74
+
75
+ def _tool_schemas() -> str:
76
+ """Return a description of all available tools for the system prompt."""
77
+ return """## Available Tools
78
+
79
+ You have access to these tools. To call a tool, output a fenced block with `tool` as the language, the tool name on the first line, and parameters as `key: value` pairs (one per line). For multi-line values, use YAML `|` block syntax.
80
+
81
+ ### read_file
82
+ Read a text file from the workspace.
83
+ ```
84
+ read_file
85
+ path: src/app.py
86
+ ```
87
+ Optional: `offset` (1-indexed line to start from), `limit` (max lines).
88
+
89
+ ### write_file
90
+ Write content to a file (creates parent dirs).
91
+ ```
92
+ write_file
93
+ path: src/new.py
94
+ content: |
95
+ import os
96
+ def main():
97
+ print("hello")
98
+ ```
99
+
100
+ ### edit_file
101
+ Replace text in a file.
102
+ ```
103
+ edit_file
104
+ path: src/app.py
105
+ old_str: print("hello")
106
+ new_str: print("goodbye")
107
+ ```
108
+ Optional: `replace_all: true` to replace all occurrences.
109
+
110
+ ### multi_edit
111
+ Apply multiple edits atomically.
112
+ ```
113
+ multi_edit
114
+ path: src/app.py
115
+ edits: |
116
+ - old_str: "foo"
117
+ new_str: "bar"
118
+ - old_str: "baz"
119
+ new_str: "qux"
120
+ ```
121
+
122
+ ### list_dir
123
+ List directory contents.
124
+ ```
125
+ list_dir
126
+ path: src
127
+ ```
128
+
129
+ ### glob
130
+ Find files matching a pattern.
131
+ ```
132
+ glob
133
+ pattern: **/*.py
134
+ path: .
135
+ ```
136
+
137
+ ### grep
138
+ Search file contents with regex.
139
+ ```
140
+ grep
141
+ pattern: def main
142
+ path: .
143
+ include: *.py
144
+ ```
145
+ Optional: `ignore_case: true`, `max_results: 50`.
146
+
147
+ ### bash
148
+ Run a shell command (sandboxed to workspace).
149
+ ```
150
+ bash
151
+ command: npm test
152
+ timeout: 30
153
+ ```
154
+ Optional: `cwd`, `timeout` (default 30s).
155
+
156
+ ### todo_write
157
+ Replace the entire todo list.
158
+ ```
159
+ todo_write
160
+ todos: |
161
+ - id: "1"
162
+ content: "Set up project structure"
163
+ status: "in_progress"
164
+ priority: "high"
165
+ - id: "2"
166
+ content: "Implement API endpoints"
167
+ status: "pending"
168
+ priority: "high"
169
+ ```
170
+
171
+ ### todo_read
172
+ Read the current todo list. No parameters.
173
+
174
+ ### todo_update
175
+ Update a single todo by id.
176
+ ```
177
+ todo_update
178
+ todo_id: "1"
179
+ status: "completed"
180
+ ```
181
+
182
+ ## Rules
183
+
184
+ - Call ONE tool per turn. Wait for the result before calling the next.
185
+ - After tool results come back, summarize what you learned and decide the next step.
186
+ - If you don't need a tool, just respond normally.
187
+ - Use `todo_write` to track multi-step tasks.
188
+ - Always use `read_file` before `edit_file` so you know the exact content.
189
+ - Use `bash` for git, test running, and other shell tasks.
190
+ """
191
+
192
+
193
+ # ─── Tool call parsing ──────────────────────────────────────────────────
194
+
195
+ _TOOL_BLOCK_RE = re.compile(
196
+ r"```tool\s*\n(.*?)```",
197
+ re.DOTALL,
198
+ )
199
+
200
+
201
+ def _parse_yaml_block(text: str) -> dict[str, Any]:
202
+ """Parse a simple YAML-like block into a dict.
203
+
204
+ Supports:
205
+ - key: value (single line)
206
+ - key: | (multi-line block scalar)
207
+ - key: > (folded scalar)
208
+ - Nested lists with - item
209
+ """
210
+ result: dict[str, Any] = {}
211
+ lines = text.split("\n")
212
+ i = 0
213
+ while i < len(lines):
214
+ line = lines[i]
215
+ stripped = line.rstrip()
216
+ if not stripped or stripped.startswith("#"):
217
+ i += 1
218
+ continue
219
+
220
+ # Match key: value or key: | or key: >
221
+ m = re.match(r"^(\w+)\s*:\s*(.*)$", stripped)
222
+ if not m:
223
+ i += 1
224
+ continue
225
+
226
+ key = m.group(1)
227
+ value = m.group(2).strip()
228
+
229
+ if value in ("|", "|-", ">", ">-"):
230
+ # Multi-line block scalar β€” collect indented lines
231
+ collect: list[str] = []
232
+ i += 1
233
+ while i < len(lines):
234
+ next_line = lines[i]
235
+ if next_line.strip() == "" and i + 1 < len(lines) and not lines[i + 1].startswith(" "):
236
+ break
237
+ if next_line.startswith(" ") or next_line.startswith("\t") or next_line.strip() == "":
238
+ collect.append(next_line)
239
+ i += 1
240
+ else:
241
+ break
242
+ # Dedent
243
+ block = "\n".join(collect)
244
+ # Remove common leading whitespace
245
+ block = re.sub(r"^( {2}|\t)", "", block, flags=re.MULTILINE)
246
+ result[key] = block.rstrip()
247
+ else:
248
+ # Try parsing as JSON for complex values
249
+ if value.startswith("[") or value.startswith("{"):
250
+ try:
251
+ result[key] = json.loads(value)
252
+ except json.JSONDecodeError:
253
+ result[key] = value
254
+ else:
255
+ result[key] = value
256
+ i += 1
257
+
258
+ return result
259
+
260
+
261
+ def _parse_tool_call(text: str) -> dict[str, Any] | None:
262
+ """Parse a single tool call block content into {tool, args}."""
263
+ lines = text.strip().split("\n", 1)
264
+ if not lines:
265
+ return None
266
+
267
+ tool_name = lines[0].strip()
268
+ if tool_name not in TOOL_REGISTRY:
269
+ return {"tool": tool_name, "error": f"Unknown tool: {tool_name}"}
270
+
271
+ args_block = lines[1] if len(lines) > 1 else ""
272
+ args = _parse_yaml_block(args_block)
273
+
274
+ # Type coercion for known int/bool fields
275
+ if "timeout" in args:
276
+ try:
277
+ args["timeout"] = int(args["timeout"])
278
+ except (ValueError, TypeError):
279
+ pass
280
+ if "offset" in args:
281
+ try:
282
+ args["offset"] = int(args["offset"])
283
+ except (ValueError, TypeError):
284
+ pass
285
+ if "limit" in args:
286
+ try:
287
+ args["limit"] = int(args["limit"])
288
+ except (ValueError, TypeError):
289
+ pass
290
+ if "replace_all" in args:
291
+ args["replace_all"] = str(args["replace_all"]).lower() in ("true", "1", "yes")
292
+ if "ignore_case" in args:
293
+ args["ignore_case"] = str(args["ignore_case"]).lower() in ("true", "1", "yes")
294
+ if "todos" in args and isinstance(args["todos"], str):
295
+ # Parse YAML list of todos
296
+ todos: list[dict[str, Any]] = []
297
+ for block in re.split(r"\n\s*-\s+", "\n" + args["todos"]):
298
+ if not block.strip():
299
+ continue
300
+ todo: dict[str, Any] = {}
301
+ for line in block.splitlines():
302
+ m = re.match(r"(\w+):\s*(.*)$", line.strip())
303
+ if m:
304
+ val = m.group(2).strip()
305
+ if m.group(1) in {"status", "priority"}:
306
+ todo[m.group(1)] = val
307
+ else:
308
+ todo[m.group(1)] = val
309
+ if todo:
310
+ todos.append(todo)
311
+ args["todos"] = todos
312
+ if "edits" in args and isinstance(args["edits"], str):
313
+ # Parse YAML list of edits
314
+ edits: list[dict[str, str]] = []
315
+ for block in re.split(r"\n\s*-\s+", "\n" + args["edits"]):
316
+ if not block.strip():
317
+ continue
318
+ edit: dict[str, str] = {}
319
+ for line in block.splitlines():
320
+ m = re.match(r"(\w+):\s*(.*)$", line.strip())
321
+ if m:
322
+ edit[m.group(1)] = m.group(2).strip()
323
+ if edit:
324
+ edits.append(edit)
325
+ args["edits"] = edits
326
+
327
+ return {"tool": tool_name, "args": args}
328
+
329
+
330
+ def find_tool_calls(text: str) -> list[dict[str, Any]]:
331
+ """Find all tool call blocks in the model's output."""
332
+ calls: list[dict[str, Any]] = []
333
+ for match in _TOOL_BLOCK_RE.finditer(text):
334
+ parsed = _parse_tool_call(match.group(1))
335
+ if parsed:
336
+ calls.append(parsed)
337
+ return calls
338
+
339
+
340
+ # ─── Tool execution with hooks ──────────────────────────────────────────
341
+
342
+ def execute_tool(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
343
+ """Execute a single tool with hook checks."""
344
+ if tool_name not in TOOL_REGISTRY:
345
+ return {"success": False, "error": f"Unknown tool: {tool_name}"}
346
+
347
+ # Hook check
348
+ if tool_name == "bash":
349
+ hook_context = {"command": str(args.get("command", ""))}
350
+ hook_result = check_hook("bash", hook_context)
351
+ elif tool_name in {"write_file", "edit_file", "multi_edit"}:
352
+ hook_context = {
353
+ "file_path": str(args.get("path", "")),
354
+ "new_text": str(args.get("content", args.get("new_str", ""))),
355
+ }
356
+ hook_result = check_hook("file", hook_context)
357
+ else:
358
+ hook_result = {"blocked": False, "warnings": [], "matched_hooks": []}
359
+
360
+ if hook_result["blocked"]:
361
+ return {
362
+ "success": False,
363
+ "error": "Blocked by hook rule",
364
+ "hook_warnings": hook_result["warnings"],
365
+ "blocked": True,
366
+ }
367
+
368
+ try:
369
+ fn = TOOL_REGISTRY[tool_name]
370
+ result = fn(**args) if args else fn()
371
+ # Attach any warnings
372
+ if hook_result["warnings"]:
373
+ result["hook_warnings"] = hook_result["warnings"]
374
+ return result
375
+ except TypeError as exc:
376
+ return {"success": False, "error": f"Invalid arguments: {exc}"}
377
+ except Exception as exc:
378
+ logger.exception("Tool execution failed: %s", tool_name)
379
+ return {"success": False, "error": str(exc)}
380
+
381
+
382
+ # ─── Agent loop ─────────────────────────────────────────────────────────
383
+
384
+ MAX_ITERATIONS = 8
385
+
386
+
387
+ def build_agent_system_prompt(
388
+ target_language: str = "",
389
+ target_framework: str = "",
390
+ skills: list[str] | None = None,
391
+ ) -> str:
392
+ """Build the system prompt with tool descriptions and skill context."""
393
+ parts = [
394
+ SYSTEM_PROMPT,
395
+ "",
396
+ _tool_schemas(),
397
+ "",
398
+ "## Agent Behavior",
399
+ "",
400
+ "- You are an autonomous coding agent. Use tools to inspect and modify the workspace.",
401
+ "- Always plan first with `todo_write` when given a multi-step task.",
402
+ "- Use `read_file` before `edit_file` to know exact content.",
403
+ "- After each tool result, briefly note what you learned before the next step.",
404
+ "- When done, give a concise summary of what you did and what files changed.",
405
+ "- If a hook warns you, acknowledge it and adjust your approach.",
406
+ ]
407
+
408
+ if target_language or target_framework:
409
+ parts.append("")
410
+ parts.append(f"Target: {target_language}" + (f" / {target_framework}" if target_framework else ""))
411
+
412
+ skills_ctx = build_skills_context(skills)
413
+ if skills_ctx:
414
+ parts.append("")
415
+ parts.append("## Skills Loaded")
416
+ parts.append("")
417
+ parts.append(skills_ctx)
418
+
419
+ return "\n".join(parts)
420
+
421
+
422
+ def run_agent(
423
+ user_input: str,
424
+ history: list[dict[str, str]] | None = None,
425
+ target_language: str = "",
426
+ target_framework: str = "",
427
+ skills: list[str] | None = None,
428
+ search_context: str = "",
429
+ image_url: str | None = None,
430
+ ) -> Iterator[dict[str, Any]]:
431
+ """Run the agent loop. Yields events as dict.
432
+
433
+ Events:
434
+ - {type: "status", status_text, status_state, ...}
435
+ - {type: "tool_call", tool, args, result}
436
+ - {type: "streaming", content, ...}
437
+ - {type: "complete", content, ...}
438
+ - {type: "error", message, ...}
439
+ """
440
+ from code.model.inference import call_model
441
+ from code.model.loader import get_model_status, is_model_loaded
442
+
443
+ history = history or []
444
+
445
+ # Check for slash command
446
+ cmd_name, cmd_args = parse_command_input(user_input)
447
+ if cmd_name:
448
+ expansion = expand_command(cmd_name, cmd_args)
449
+ if expansion.get("success"):
450
+ # Replace user input with expanded command
451
+ user_input = expansion["prompt"]
452
+ yield {
453
+ "type": "status",
454
+ "status_text": f"Running /{cmd_name} command...",
455
+ "status_state": "working",
456
+ }
457
+ else:
458
+ yield {
459
+ "type": "error",
460
+ "message": expansion.get("error", "Unknown command"),
461
+ "available": expansion.get("available", []),
462
+ }
463
+ return
464
+
465
+ # Hook check on user prompt
466
+ prompt_hook = check_hook("prompt", {"user_prompt": user_input})
467
+ if prompt_hook["blocked"]:
468
+ yield {
469
+ "type": "error",
470
+ "message": "Prompt blocked by hook rule",
471
+ "warnings": prompt_hook["warnings"],
472
+ }
473
+ return
474
+
475
+ # Model status
476
+ if not is_model_loaded():
477
+ status = get_model_status()
478
+ yield {
479
+ "type": "error",
480
+ "message": status["message"],
481
+ }
482
+ return
483
+
484
+ # Build system prompt
485
+ system_prompt = build_agent_system_prompt(target_language, target_framework, skills)
486
+
487
+ # Add search context if present
488
+ if search_context:
489
+ user_input = f"{user_input}\n\n--- Web Search Results ---\n{search_context}"
490
+
491
+ # Build messages
492
+ messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
493
+ for h in history:
494
+ role = h.get("role", "user")
495
+ content = str(h.get("content", "")).strip()
496
+ if role in {"user", "assistant"} and content:
497
+ messages.append({"role": role, "content": content})
498
+ messages.append({"role": "user", "content": user_input})
499
+
500
+ # Agent loop
501
+ for iteration in range(MAX_ITERATIONS):
502
+ yield {
503
+ "type": "status",
504
+ "status_text": f"Thinking... (step {iteration + 1})",
505
+ "status_state": "working",
506
+ "iteration": iteration + 1,
507
+ }
508
+
509
+ # Call model
510
+ full_response = ""
511
+ for partial in call_model(messages, image_url=image_url):
512
+ full_response = partial
513
+ yield {
514
+ "type": "streaming",
515
+ "content": partial,
516
+ "iteration": iteration + 1,
517
+ }
518
+
519
+ if not full_response:
520
+ yield {"type": "error", "message": "Empty model response"}
521
+ return
522
+
523
+ # Check for tool calls
524
+ tool_calls = find_tool_calls(full_response)
525
+
526
+ if not tool_calls:
527
+ # No tools called β€” final response
528
+ yield {
529
+ "type": "complete",
530
+ "content": full_response,
531
+ "iterations": iteration + 1,
532
+ }
533
+ return
534
+
535
+ # Execute each tool call in order
536
+ for tc in tool_calls:
537
+ tool_name = tc.get("tool")
538
+ args = tc.get("args", {})
539
+
540
+ if "error" in tc:
541
+ # Unknown tool
542
+ tool_result = {"success": False, "error": tc["error"]}
543
+ else:
544
+ yield {
545
+ "type": "tool_call",
546
+ "tool": tool_name,
547
+ "args": args,
548
+ "iteration": iteration + 1,
549
+ }
550
+ tool_result = execute_tool(tool_name, args)
551
+
552
+ yield {
553
+ "type": "tool_result",
554
+ "tool": tool_name,
555
+ "result": tool_result,
556
+ "iteration": iteration + 1,
557
+ }
558
+
559
+ # Feed result back to model
560
+ result_str = json.dumps(tool_result, indent=2, default=str)
561
+ messages.append({"role": "assistant", "content": full_response})
562
+ messages.append({
563
+ "role": "user",
564
+ "content": f"Tool `{tool_name}` result:\n```json\n{result_str}\n```\n\nContinue with the next step or finish if done.",
565
+ })
566
+
567
+ # Max iterations reached
568
+ yield {
569
+ "type": "complete",
570
+ "content": full_response + "\n\n_(Max iterations reached)_",
571
+ "iterations": MAX_ITERATIONS,
572
+ }
code/commands/__init__.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slash commands system β€” Claude Code-style.
2
+
3
+ Commands are markdown files with YAML frontmatter that define
4
+ prompt templates triggered by `/command` syntax.
5
+
6
+ Built-in commands live in code/commands/builtins/.
7
+ User commands live in workspace's .sonicoder/commands/.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import re
15
+ from typing import Any
16
+
17
+ from code.skills import _parse_frontmatter
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _BUILTIN_COMMANDS_DIR = os.path.join(os.path.dirname(__file__), "builtins")
22
+ _USER_COMMANDS_DIRNAME = ".sonicoder/commands"
23
+
24
+
25
+ def _command_dirs() -> list[str]:
26
+ dirs = [_BUILTIN_COMMANDS_DIR]
27
+ try:
28
+ from code.tools.fs import get_workspace_root
29
+ user_dir = os.path.join(get_workspace_root(), _USER_COMMANDS_DIRNAME)
30
+ if os.path.isdir(user_dir):
31
+ dirs.append(user_dir)
32
+ except Exception:
33
+ pass
34
+ return dirs
35
+
36
+
37
+ def _load_command(filepath: str) -> dict[str, Any] | None:
38
+ try:
39
+ with open(filepath, "r", encoding="utf-8") as f:
40
+ content = f.read()
41
+ except Exception as exc:
42
+ logger.warning("Failed to read %s: %s", filepath, exc)
43
+ return None
44
+
45
+ meta, body = _parse_frontmatter(content)
46
+ name = meta.get("name") or os.path.splitext(os.path.basename(filepath))[0]
47
+ return {
48
+ "name": name,
49
+ "description": meta.get("description", ""),
50
+ "argument_hint": meta.get("argument-hint", ""),
51
+ "allowed_tools": [t.strip() for t in meta.get("allowed-tools", "").split(",") if t.strip()],
52
+ "body": body.strip(),
53
+ "path": filepath,
54
+ }
55
+
56
+
57
+ def list_commands() -> list[dict[str, Any]]:
58
+ """List all available slash commands."""
59
+ commands: list[dict[str, Any]] = []
60
+ seen: set[str] = set()
61
+
62
+ for cmds_dir in _command_dirs():
63
+ if not os.path.isdir(cmds_dir):
64
+ continue
65
+ for entry in sorted(os.listdir(cmds_dir)):
66
+ if not entry.endswith(".md"):
67
+ continue
68
+ filepath = os.path.join(cmds_dir, entry)
69
+ cmd = _load_command(filepath)
70
+ if cmd and cmd["name"] not in seen:
71
+ seen.add(cmd["name"])
72
+ commands.append({
73
+ "name": cmd["name"],
74
+ "description": cmd["description"],
75
+ "argument_hint": cmd["argument_hint"],
76
+ })
77
+ return commands
78
+
79
+
80
+ def get_command(name: str) -> dict[str, Any] | None:
81
+ """Get full command content by name."""
82
+ for cmds_dir in _command_dirs():
83
+ if not os.path.isdir(cmds_dir):
84
+ continue
85
+ # Try name.md and name/something.md
86
+ direct = os.path.join(cmds_dir, f"{name}.md")
87
+ if os.path.isfile(direct):
88
+ return _load_command(direct)
89
+ # Try subdirectory: name/command.md
90
+ if os.path.isdir(os.path.join(cmds_dir, name)):
91
+ for entry in os.listdir(os.path.join(cmds_dir, name)):
92
+ if entry.endswith(".md"):
93
+ return _load_command(os.path.join(cmds_dir, name, entry))
94
+ return None
95
+
96
+
97
+ def parse_command_input(user_input: str) -> tuple[str | None, str]:
98
+ """Parse a user input string for a slash command.
99
+
100
+ Returns (command_name, arguments) or (None, user_input) if not a command.
101
+ """
102
+ stripped = user_input.strip()
103
+ if not stripped.startswith("/"):
104
+ return None, user_input
105
+
106
+ # Match /command-name or /namespace:command
107
+ match = re.match(r"^/([a-zA-Z][\w:-]*)\s*(.*)$", stripped, re.DOTALL)
108
+ if not match:
109
+ return None, user_input
110
+
111
+ return match.group(1), match.group(2).strip()
112
+
113
+
114
+ def expand_command(name: str, arguments: str = "") -> dict[str, Any]:
115
+ """Expand a slash command into a full prompt for the model.
116
+
117
+ Replaces $ARGUMENTS placeholder with the user-provided arguments.
118
+ """
119
+ cmd = get_command(name)
120
+ if not cmd:
121
+ return {
122
+ "success": False,
123
+ "error": f"Unknown command: /{name}",
124
+ "available": [c["name"] for c in list_commands()],
125
+ }
126
+
127
+ body = cmd["body"]
128
+ # Replace $ARGUMENTS
129
+ expanded = body.replace("$ARGUMENTS", arguments)
130
+
131
+ # Also support bash-style $(cmd) execution for context blocks (like Claude Code)
132
+ # e.g. !`git status` becomes the output of `git status`
133
+ from code.tools.bash import run_bash
134
+
135
+ def _exec_bash(match: re.Match) -> str:
136
+ cmd_str = match.group(1)
137
+ result = run_bash(cmd_str, timeout=10)
138
+ return result.get("stdout", "") + result.get("stderr", "")
139
+
140
+ expanded = re.sub(r"!`([^`]+)`", _exec_bash, expanded)
141
+
142
+ return {
143
+ "success": True,
144
+ "name": cmd["name"],
145
+ "description": cmd["description"],
146
+ "prompt": expanded,
147
+ "allowed_tools": cmd["allowed_tools"],
148
+ }
code/commands/builtins/commit.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: commit
3
+ description: Create a git commit with a generated message
4
+ argument-hint: Optional commit message override
5
+ ---
6
+ ## Context
7
+
8
+ - Current git status: !`git status`
9
+ - Current git diff (staged and unstaged changes): !`git diff HEAD`
10
+ - Current branch: !`git branch --show-current`
11
+ - Recent commits: !`git log --oneline -10`
12
+
13
+ ## Your task
14
+
15
+ Based on the above changes, create a single git commit.
16
+
17
+ Write a clean commit message in conventional-commits format:
18
+ - Type: feat, fix, docs, style, refactor, perf, test, chore
19
+ - Subject: imperative mood, lowercase, under 72 chars, no period
20
+ - Optional body explaining WHY (not what)
21
+
22
+ If $ARGUMENTS is provided, use it as the commit message.
23
+
24
+ Stage the relevant files and commit using a heredoc to preserve formatting.
25
+ Do not use any other tools or do anything else. Do not send any other text besides these tool calls.
code/commands/builtins/design.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: design
3
+ description: Generate a distinctive frontend design β€” palette, typography, layout, signature
4
+ argument-hint: Design brief (subject, audience, mood)
5
+ ---
6
+ # Frontend Design
7
+
8
+ Brief: $ARGUMENTS
9
+
10
+ You are the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's.
11
+
12
+ ## Step 1: Pin the subject
13
+
14
+ If the brief doesn't pin down what the product is, do it yourself:
15
+ - Name one concrete subject
16
+ - Its audience
17
+ - The page's single job
18
+
19
+ ## Step 2: Design tokens
20
+
21
+ Output a compact token system:
22
+
23
+ ### Color (4-6 named hex values)
24
+ - Background, foreground, accent, muted, border, etc.
25
+
26
+ ### Typography (2+ faces)
27
+ - Display face (used with restraint)
28
+ - Body face
29
+ - Optional utility face for captions/data
30
+
31
+ ### Layout
32
+ - One-sentence concept
33
+ - ASCII wireframe
34
+
35
+ ### Signature
36
+ - The single unique element this page will be remembered by
37
+
38
+ ## Step 3: Build
39
+
40
+ After the design plan, generate the complete HTML/CSS/JS in a single self-contained file (or multi-file @@FILE: project if appropriate).
41
+
42
+ Rules:
43
+ - Mobile responsive (html/body margin:0, 100% width)
44
+ - Visible keyboard focus states
45
+ - Respect `prefers-reduced-motion`
46
+ - Take ONE real aesthetic risk you can justify
47
+ - Cut any decoration that doesn't serve the brief
48
+
49
+ Output the design plan first, then the code.
code/commands/builtins/explain.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: explain
3
+ description: Explain how the current codebase or a specific file works
4
+ argument-hint: File path or "codebase"
5
+ ---
6
+ # Explain
7
+
8
+ Target: $ARGUMENTS
9
+
10
+ Explain the code clearly and concisely.
11
+
12
+ If target is "codebase" or empty:
13
+ 1. Use `list_dir` to map the project structure
14
+ 2. Identify the entry point (app.py, main.py, index.js, etc.)
15
+ 3. Read the entry point and key modules
16
+ 4. Produce a high-level architecture summary
17
+
18
+ If target is a file path:
19
+ 1. `read_file` the target
20
+ 2. Read any files it imports/requires (one level deep)
21
+ 3. Explain:
22
+ - What the file does
23
+ - Its main functions/classes
24
+ - How it fits into the larger project
25
+ - Any non-obvious patterns or gotchas
26
+
27
+ Format your explanation for a developer who is new to this code. Use:
28
+ - Short paragraphs for prose
29
+ - Code snippets for examples
30
+ - A "Key takeaways" list at the end
31
+
32
+ Don't editorialize β€” describe what's there, not what should be.
code/commands/builtins/feature.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: feature
3
+ description: Guided feature development β€” understand, design, then implement
4
+ argument-hint: Feature description
5
+ ---
6
+ # Feature Development
7
+
8
+ Initial request: $ARGUMENTS
9
+
10
+ You are helping a developer implement a new feature. Follow a systematic approach.
11
+
12
+ ## Phase 1: Discovery
13
+
14
+ If the feature is unclear, ask the user:
15
+ - What problem are they solving?
16
+ - What should the feature do?
17
+ - Any constraints or requirements?
18
+
19
+ ## Phase 2: Codebase Exploration
20
+
21
+ Use `list_dir` and `glob` to map the project, then `grep` and `read_file` to find:
22
+ - Similar existing features
23
+ - Architecture patterns
24
+ - Naming conventions
25
+ - Test patterns
26
+
27
+ ## Phase 3: Clarifying Questions
28
+
29
+ Present ALL clarifying questions to the user before designing. Cover:
30
+ - Edge cases
31
+ - Error handling
32
+ - Integration points
33
+ - Scope boundaries
34
+ - Backward compatibility
35
+
36
+ Wait for answers before proceeding.
37
+
38
+ ## Phase 4: Architecture Design
39
+
40
+ Design the implementation:
41
+ - Files to create/modify
42
+ - Component responsibilities
43
+ - Data flow
44
+ - Build sequence
45
+
46
+ Present to user with trade-offs. **Ask for approval.**
47
+
48
+ ## Phase 5: Implementation
49
+
50
+ After approval:
51
+ 1. Create a todo list with `todo_write`
52
+ 2. Read all relevant files
53
+ 3. Implement following codebase conventions
54
+ 4. Update todos as you progress
55
+
56
+ ## Phase 6: Quality Review
57
+
58
+ Self-review for:
59
+ - Bugs / functional correctness
60
+ - Simplicity / DRY
61
+ - Project conventions
62
+
63
+ ## Phase 7: Summary
64
+
65
+ Summarize what was built, decisions made, files modified, and next steps.
code/commands/builtins/help.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: help
3
+ description: Show available commands and skills
4
+ argument-hint: Optional command name to get help for
5
+ ---
6
+ # Help
7
+
8
+ If $ARGUMENTS is empty, list all available slash commands and skills with brief descriptions.
9
+
10
+ If $ARGUMENTS is a command or skill name, show its full description and usage.
11
+
12
+ Format:
13
+
14
+ ```
15
+ ## Commands
16
+ /command-name β€” Description
17
+ ...
18
+
19
+ ## Skills
20
+ skill-name β€” Description
21
+ ...
22
+
23
+ Type /command for any command above.
24
+ Type /skill <skill-name> to load skill instructions.
25
+ ```
code/commands/builtins/refactor.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: refactor
3
+ description: Refactor code for clarity, simplicity, and maintainability
4
+ argument-hint: File path or "current changes"
5
+ ---
6
+ # Refactor
7
+
8
+ Target: $ARGUMENTS
9
+
10
+ Refactor the specified code without changing its behavior.
11
+
12
+ ## Process
13
+
14
+ 1. `read_file` the target.
15
+ 2. Identify smells:
16
+ - Duplicated code (extract function/component)
17
+ - Long functions (split into smaller ones)
18
+ - Deep nesting (early returns, guard clauses)
19
+ - Unclear names (rename to reveal intent)
20
+ - Magic numbers (extract constants)
21
+ - Mixed concerns (separate responsibilities)
22
+ - Dead code (remove)
23
+ 3. Plan changes β€” show the user before applying if the refactor is significant.
24
+ 4. Apply changes with `edit_file` or `write_file`.
25
+ 5. Verify nothing broke: run tests with `bash` if available.
26
+
27
+ ## Rules
28
+
29
+ - **Behavior-preserving**: the refactored code must produce the same output for the same input.
30
+ - **One refactor at a time**: don't mix 5 changes into one edit.
31
+ - **Match conventions**: follow the surrounding code style.
32
+ - **Update names everywhere**: don't leave stale references.
33
+ - **Test after each significant change**.
34
+
35
+ Don't refactor for refactoring's sake. Only refactor when:
36
+ - The code is hard to read
37
+ - The code is hard to change
38
+ - The code has bugs you're about to fix anyway
39
+ - The user explicitly asked
code/commands/builtins/review.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: review
3
+ description: Review the current changes for bugs and quality issues
4
+ argument-hint: Optional file or PR to review
5
+ ---
6
+ ## Review Task
7
+
8
+ Review the current code changes for quality and correctness.
9
+
10
+ If $ARGUMENTS is provided, review that specific file or changeset.
11
+ Otherwise, review the current `git diff HEAD`.
12
+
13
+ ## Process
14
+
15
+ 1. Use `bash` to get the diff:
16
+ - If arguments given and it's a file: `git diff HEAD -- $ARGUMENTS`
17
+ - Otherwise: `git diff HEAD`
18
+
19
+ 2. Read each changed file fully with `read_file` for context.
20
+
21
+ 3. Apply the code-review skill β€” focus on HIGH SIGNAL issues only:
22
+ - Bugs that will cause incorrect behavior
23
+ - Security issues (hardcoded secrets, injection, path traversal)
24
+ - Syntax/type errors
25
+ - Clear convention violations
26
+
27
+ 4. Do NOT flag:
28
+ - Subjective style preferences
29
+ - Potential issues that depend on specific inputs
30
+ - Pedantic nitpicks
31
+
32
+ 5. Output your review in this format:
33
+
34
+ ```
35
+ ## Code Review
36
+
37
+ ### Critical
38
+ - `file:line` β€” Description and fix
39
+
40
+ ### High
41
+ - `file:line` β€” Description and fix
42
+
43
+ ### Medium
44
+ - `file:line` β€” Description and fix
45
+
46
+ ### Summary
47
+ Brief overall assessment.
48
+ ```
49
+
50
+ If no issues: state "No issues found. Checked for bugs, security, and conventions."
code/commands/builtins/skill.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: skill
3
+ description: Load and apply a specific skill
4
+ argument-hint: Skill name
5
+ ---
6
+ # Skill Invocation
7
+
8
+ Loading skill: $ARGUMENTS
9
+
10
+ The skill instructions have been loaded into context. Apply them to the current task.
11
+
12
+ If the user provided additional context after the skill name, treat it as the task brief.
code/commands/builtins/test.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: test
3
+ description: Generate tests for the current code or a specific file
4
+ argument-hint: File path or test framework
5
+ ---
6
+ # Test Generation
7
+
8
+ Target: $ARGUMENTS
9
+
10
+ Generate tests for the specified file or the current changes.
11
+
12
+ ## Process
13
+
14
+ 1. If a file path is given, `read_file` it. Otherwise use `bash` to get `git diff HEAD` and identify changed files.
15
+
16
+ 2. Identify the test framework in use:
17
+ - Python: pytest (preferred), unittest
18
+ - JavaScript: vitest, jest, mocha
19
+ - Check `package.json` or `requirements.txt` for hints
20
+
21
+ 3. Read existing tests in the `tests/` or `__tests__/` directory to match style.
22
+
23
+ 4. Generate tests that cover:
24
+ - Happy path (typical usage)
25
+ - Edge cases (empty input, boundary values)
26
+ - Error cases (invalid input, network failures)
27
+ - Integration points (if applicable)
28
+
29
+ 5. Write the test file to the appropriate location using `write_file`.
30
+
31
+ 6. Run the tests with `bash` to verify they pass.
32
+
33
+ ## Rules
34
+
35
+ - One test file per source file (e.g. `src/auth.py` β†’ `tests/test_auth.py`)
36
+ - Use descriptive test names: `test_user_can_login_with_valid_credentials`
37
+ - Arrange-Act-Assert pattern
38
+ - Don't test the framework β€” test your code's behavior
39
+ - Mock external dependencies (network, filesystem, time) when flaky
code/config/constants.py CHANGED
@@ -80,22 +80,27 @@ LANGUAGE_MAP: dict[str, list[str]] = {lang: frameworks for lang, frameworks in L
80
 
81
  # ─── System Prompt ───────────────────────────────────────────────────────
82
 
83
- SYSTEM_PROMPT = """You are a code generator. Output ONLY the code. No thinking, no explanation, no commentary.
 
 
 
 
 
 
 
 
84
 
85
  CRITICAL RULES:
86
- - Do NOT use <think> or <thinking> tags. Do NOT reason aloud. Just output code directly.
87
- - Do NOT write explanations before or after code. Just output the code.
88
- - If you must explain something, keep it to ONE short sentence.
89
-
90
- When the user asks you to build an application:
91
- 1. Generate complete, working code - not snippets or pseudocode
92
- 2. Include all necessary files for the project to run
93
- 3. Add proper error handling and comments
94
- 4. For web apps, make the UI responsive and modern
95
- 5. For Gradio apps, use gradio library and create a complete working app with gr.Interface or gr.Blocks
96
-
97
- FILE OUTPUT FORMAT - IMPORTANT:
98
- When generating multi-file projects, wrap each file in this format:
99
  @@FILE: path/to/file.ext@@
100
  (file content here)
101
  @@FILE: path/to/another/file.ext@@
@@ -109,30 +114,37 @@ For single-file code, use standard markdown fenced blocks:
109
  ```typescript for TypeScript
110
  etc.
111
 
112
- JAVASCRIPT / TYPESCRIPT PROJECTS:
113
- For React, Next.js, Vue.js, Express, NestJS, or any JS/TS framework:
114
- - ALWAYS use the @@FILE: multi-file format
115
- - Include a package.json with name, version, scripts, and dependencies
116
- - Include all source files (src/App.jsx, src/index.js, etc.)
117
  - For React+Vite: include vite.config.js and index.html
118
  - For Next.js: include next.config.js with output: 'standalone'
119
- - For Express: main entry is index.js with app.listen(7860)
120
  - Server ports MUST be 7860 and bind to 0.0.0.0
121
  - Do NOT include node_modules or lock files
 
122
 
123
- When generating web apps with HTML/CSS/JS, return a single self-contained HTML document with all CSS and JavaScript inline. Make the page fully responsive: html/body at margin:0 and 100% width/height, use flexbox/grid layouts, and size any canvas to its container.
 
 
 
 
 
124
 
125
- When generating Gradio apps, create a complete app.py with:
126
  - import gradio as gr
127
- - Define the interface using gr.Interface() or gr.Blocks()
128
  - Call iface.launch(server_name="0.0.0.0", server_port=7860) at the end
129
- - Include all necessary processing logic inline
130
-
131
- For Python, prefer standard library or common packages. Do not use network calls, subprocesses, shell commands, or long-running loops in demo code (except Gradio apps which are server-based).
132
 
133
- If web search results are provided in the context, use them to inform your code generation. Incorporate relevant information from the search results into the generated code.
 
 
 
134
 
135
- If the user provides an image, analyze it and generate code based on what you see in the image. For example: replicate a UI from a screenshot, generate code from a wireframe, or build an app described in a document.
 
 
136
  """
137
 
138
  # ─── Example Prompts ────────────────────────────────────────────────────
 
80
 
81
  # ─── System Prompt ───────────────────────────────────────────────────────
82
 
83
+ SYSTEM_PROMPT = """You are SoniCoder β€” an autonomous coding agent that can write full-stack applications, manipulate files, run shell commands, and apply skills.
84
+
85
+ CAPABILITIES:
86
+ - Generate complete, runnable applications in any language/framework
87
+ - Read, write, and edit files in a sandboxed workspace
88
+ - Run shell commands (git, npm, pip, tests) via the bash tool
89
+ - Track multi-step tasks with the todo system
90
+ - Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
91
+ - Respond to slash commands: /commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help
92
 
93
  CRITICAL RULES:
94
+ - Do NOT use <think> or <thinking> tags. Do NOT reason aloud.
95
+ - For multi-step tasks, ALWAYS create a todo list first with `todo_write`.
96
+ - Read files before editing them β€” `read_file` then `edit_file`.
97
+ - Match the codebase's existing style and conventions.
98
+ - After tool calls, briefly note what you learned before the next step.
99
+ - When done, give a concise summary of what changed.
100
+
101
+ WHEN GENERATING CODE (without tools):
102
+
103
+ FILE OUTPUT FORMAT for multi-file projects:
 
 
 
104
  @@FILE: path/to/file.ext@@
105
  (file content here)
106
  @@FILE: path/to/another/file.ext@@
 
114
  ```typescript for TypeScript
115
  etc.
116
 
117
+ FULLSTACK PROJECT RULES:
118
+ - For React, Next.js, Vue.js, Express, NestJS, or any JS/TS framework: ALWAYS use @@FILE: multi-file format
119
+ - Include package.json with name, version, scripts, and dependencies
 
 
120
  - For React+Vite: include vite.config.js and index.html
121
  - For Next.js: include next.config.js with output: 'standalone'
122
+ - For Express: main entry is index.js with app.listen(7860, '0.0.0.0')
123
  - Server ports MUST be 7860 and bind to 0.0.0.0
124
  - Do NOT include node_modules or lock files
125
+ - For Python web apps: use gradio/flask/fastapi/streamlit as appropriate
126
 
127
+ WEB APP RULES (HTML/CSS/JS):
128
+ - Return a single self-contained HTML document with all CSS and JavaScript inline
129
+ - Make the page fully responsive: html/body at margin:0 and 100% width/height
130
+ - Use flexbox/grid layouts; size any canvas to its container
131
+ - Respect prefers-reduced-motion
132
+ - Include visible keyboard focus states
133
 
134
+ GRADIO APP RULES:
135
  - import gradio as gr
136
+ - Use gr.Interface() or gr.Blocks()
137
  - Call iface.launch(server_name="0.0.0.0", server_port=7860) at the end
138
+ - Include all processing logic inline
 
 
139
 
140
+ PYTHON RULES:
141
+ - Prefer standard library or common packages
142
+ - Do not use network calls, subprocesses, or long-running loops in demo code
143
+ - Add proper error handling and comments
144
 
145
+ If web search results are provided, use them to inform your code generation.
146
+ If the user provides an image, analyze it and generate code based on what you see.
147
+ If a hook warns you, acknowledge it and adjust your approach.
148
  """
149
 
150
  # ─── Example Prompts ────────────────────────────────────────────────────
code/hooks/__init__.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hooks system β€” pre/post tool execution rules.
2
+
3
+ Inspired by Claude Code's hookify plugin. Rules are markdown files
4
+ with YAML frontmatter that define:
5
+ - event: bash | file | prompt | stop | all
6
+ - pattern: regex to match
7
+ - action: warn | block
8
+ - message: shown to the user/agent when triggered
9
+
10
+ Rules are discovered from:
11
+ - code/hooks/builtins/ (built-in rules)
12
+ - workspace/.sonicoder/hooks/ (user rules)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import re
20
+ from typing import Any
21
+
22
+ from code.skills import _parse_frontmatter
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _BUILTIN_HOOKS_DIR = os.path.join(os.path.dirname(__file__), "builtins")
27
+ _USER_HOOKS_DIRNAME = ".sonicoder/hooks"
28
+
29
+
30
+ def _hook_dirs() -> list[str]:
31
+ dirs = [_BUILTIN_HOOKS_DIR]
32
+ try:
33
+ from code.tools.fs import get_workspace_root
34
+ user_dir = os.path.join(get_workspace_root(), _USER_HOOKS_DIRNAME)
35
+ if os.path.isdir(user_dir):
36
+ dirs.append(user_dir)
37
+ except Exception:
38
+ pass
39
+ return dirs
40
+
41
+
42
+ def _load_hook(filepath: str) -> dict[str, Any] | None:
43
+ try:
44
+ with open(filepath, "r", encoding="utf-8") as f:
45
+ content = f.read()
46
+ except Exception:
47
+ return None
48
+
49
+ meta, body = _parse_frontmatter(content)
50
+
51
+ name = meta.get("name", os.path.splitext(os.path.basename(filepath))[0])
52
+ enabled = meta.get("enabled", "true").lower() == "true"
53
+ event = meta.get("event", "all").lower()
54
+ action = meta.get("action", "warn").lower()
55
+ pattern = meta.get("pattern", "")
56
+ conditions_raw = meta.get("conditions", "")
57
+
58
+ # Parse conditions (simplified β€” actual hookify uses YAML lists)
59
+ conditions: list[dict[str, str]] = []
60
+ if conditions_raw:
61
+ # Very rough parse: each "- field: x\n operator: y\n pattern: z"
62
+ for block in re.split(r"(?=\n-\s+field:)", "\n" + conditions_raw):
63
+ field_m = re.search(r"field:\s*(\S+)", block)
64
+ op_m = re.search(r"operator:\s*(\S+)", block)
65
+ pat_m = re.search(r"pattern:\s*(.+?)(?=\n\s*$|\n\s*-|\Z)", block, re.DOTALL)
66
+ if field_m and op_m and pat_m:
67
+ conditions.append({
68
+ "field": field_m.group(1),
69
+ "operator": op_m.group(1),
70
+ "pattern": pat_m.group(1).strip(),
71
+ })
72
+
73
+ return {
74
+ "name": name,
75
+ "enabled": enabled,
76
+ "event": event,
77
+ "action": action,
78
+ "pattern": pattern,
79
+ "conditions": conditions,
80
+ "message": body.strip(),
81
+ "path": filepath,
82
+ }
83
+
84
+
85
+ def list_hooks() -> list[dict[str, Any]]:
86
+ """List all hooks (metadata only)."""
87
+ hooks: list[dict[str, Any]] = []
88
+ seen: set[str] = set()
89
+
90
+ for hooks_dir in _hook_dirs():
91
+ if not os.path.isdir(hooks_dir):
92
+ continue
93
+ for entry in sorted(os.listdir(hooks_dir)):
94
+ if not entry.endswith(".md"):
95
+ continue
96
+ filepath = os.path.join(hooks_dir, entry)
97
+ hook = _load_hook(filepath)
98
+ if hook and hook["name"] not in seen:
99
+ seen.add(hook["name"])
100
+ hooks.append({
101
+ "name": hook["name"],
102
+ "enabled": hook["enabled"],
103
+ "event": hook["event"],
104
+ "action": hook["action"],
105
+ "pattern": hook["pattern"],
106
+ })
107
+ return hooks
108
+
109
+
110
+ def _match_condition(condition: dict[str, str], context: dict[str, Any]) -> bool:
111
+ """Check if a single condition matches."""
112
+ field = condition.get("field", "")
113
+ operator = condition.get("operator", "regex_match")
114
+ pattern = condition.get("pattern", "")
115
+
116
+ value = str(context.get(field, ""))
117
+
118
+ if operator == "regex_match":
119
+ return bool(re.search(pattern, value))
120
+ elif operator == "contains":
121
+ return pattern in value
122
+ elif operator == "equals":
123
+ return value == pattern
124
+ elif operator == "not_contains":
125
+ return pattern not in value
126
+ elif operator == "starts_with":
127
+ return value.startswith(pattern)
128
+ elif operator == "ends_with":
129
+ return value.endswith(pattern)
130
+ return False
131
+
132
+
133
+ def _match_hook(hook: dict[str, Any], event: str, context: dict[str, Any]) -> bool:
134
+ """Check if a hook matches the given event and context."""
135
+ if not hook["enabled"]:
136
+ return False
137
+ if hook["event"] != "all" and hook["event"] != event:
138
+ return False
139
+
140
+ # Simple pattern match (single pattern)
141
+ if hook["pattern"]:
142
+ # For bash event, match against command
143
+ target = str(context.get("command", context.get("file_path", context.get("user_prompt", ""))))
144
+ if not re.search(hook["pattern"], target):
145
+ return False
146
+
147
+ # Multi-condition match (all conditions must match)
148
+ if hook["conditions"]:
149
+ for cond in hook["conditions"]:
150
+ if not _match_condition(cond, context):
151
+ return False
152
+
153
+ return True
154
+
155
+
156
+ def check_hook(event: str, context: dict[str, Any]) -> dict[str, Any]:
157
+ """Check all hooks for an event.
158
+
159
+ Args:
160
+ event: One of 'bash', 'file', 'prompt', 'stop', 'all'
161
+ context: Dict with relevant fields (command, file_path, new_text, user_prompt, etc.)
162
+
163
+ Returns:
164
+ dict with:
165
+ - blocked: bool β€” whether the action should be blocked
166
+ - warnings: list of warning messages to show
167
+ - matched_hooks: list of hook names that matched
168
+ """
169
+ warnings: list[str] = []
170
+ matched: list[str] = []
171
+ blocked = False
172
+
173
+ for hooks_dir in _hook_dirs():
174
+ if not os.path.isdir(hooks_dir):
175
+ continue
176
+ for entry in sorted(os.listdir(hooks_dir)):
177
+ if not entry.endswith(".md"):
178
+ continue
179
+ filepath = os.path.join(hooks_dir, entry)
180
+ hook = _load_hook(filepath)
181
+ if not hook:
182
+ continue
183
+ if _match_hook(hook, event, context):
184
+ matched.append(hook["name"])
185
+ if hook["action"] == "block":
186
+ blocked = True
187
+ warnings.append(f"πŸ›‘ BLOCKED by rule '{hook['name']}':\n\n{hook['message']}")
188
+ else:
189
+ warnings.append(f"⚠️ Warning from rule '{hook['name']}':\n\n{hook['message']}")
190
+
191
+ return {
192
+ "blocked": blocked,
193
+ "warnings": warnings,
194
+ "matched_hooks": matched,
195
+ }
196
+
197
+
198
+ def create_hook(
199
+ name: str,
200
+ event: str,
201
+ pattern: str,
202
+ action: str = "warn",
203
+ message: str = "",
204
+ enabled: bool = True,
205
+ ) -> dict[str, Any]:
206
+ """Create a new user hook (saved to workspace/.sonicoder/hooks/)."""
207
+ try:
208
+ from code.tools.fs import get_workspace_root
209
+ hooks_dir = os.path.join(get_workspace_root(), _USER_HOOKS_DIRNAME)
210
+ os.makedirs(hooks_dir, exist_ok=True)
211
+
212
+ filepath = os.path.join(hooks_dir, f"{name}.local.md")
213
+ content = f"""---
214
+ name: {name}
215
+ enabled: {str(enabled).lower()}
216
+ event: {event}
217
+ pattern: {pattern}
218
+ action: {action}
219
+ ---
220
+
221
+ {message}
222
+ """
223
+ with open(filepath, "w", encoding="utf-8") as f:
224
+ f.write(content)
225
+
226
+ return {"success": True, "name": name, "path": filepath}
227
+ except Exception as exc:
228
+ return {"success": False, "error": str(exc)}
229
+
230
+
231
+ def delete_hook(name: str) -> dict[str, Any]:
232
+ """Delete a user hook by name."""
233
+ try:
234
+ from code.tools.fs import get_workspace_root
235
+ hooks_dir = os.path.join(get_workspace_root(), _USER_HOOKS_DIRNAME)
236
+ filepath = os.path.join(hooks_dir, f"{name}.local.md")
237
+ if os.path.exists(filepath):
238
+ os.remove(filepath)
239
+ return {"success": True, "name": name}
240
+ return {"success": False, "error": f"Hook not found: {name}"}
241
+ except Exception as exc:
242
+ return {"success": False, "error": str(exc)}
code/hooks/builtins/block-dangerous-rm.local.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: block-dangerous-rm
3
+ enabled: true
4
+ event: bash
5
+ pattern: rm\s+-rf\s+(/|~|\$HOME|\.\.)
6
+ action: block
7
+ ---
8
+
9
+ πŸ›‘ **Dangerous rm command detected!**
10
+
11
+ This command could delete important files. The operation has been blocked.
12
+
13
+ If this is intentional, please:
14
+ - Verify the path is correct
15
+ - Use a more specific path
16
+ - Make sure you have backups
code/hooks/builtins/warn-debug-code.local.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: warn-debug-code
3
+ enabled: true
4
+ event: file
5
+ pattern: (console\.log\(|debugger;|print\(|alert\()
6
+ action: warn
7
+ ---
8
+
9
+ πŸ› **Debug code detected**
10
+
11
+ Remember to remove debugging statements (console.log, debugger, print, alert) before committing.
code/hooks/builtins/warn-eval-exec.local.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: warn-eval-exec
3
+ enabled: true
4
+ event: bash
5
+ pattern: (^|\s)(eval|exec)\s*\(
6
+ action: warn
7
+ ---
8
+
9
+ ⚠️ **eval()/exec() detected**
10
+
11
+ Using eval() or exec() on untrusted input is a code injection risk.
12
+ Consider safer alternatives:
13
+ - ast.literal_eval() for Python literals
14
+ - json.loads() for JSON
15
+ - Function constructors with explicit scope for JS
code/hooks/builtins/warn-secrets-in-code.local.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: warn-secrets-in-code
3
+ enabled: true
4
+ event: file
5
+ pattern: (API_KEY|SECRET|TOKEN|PASSWORD)\s*=\s*["'][^"']+["']
6
+ action: warn
7
+ ---
8
+
9
+ πŸ” **Possible hardcoded secret detected**
10
+
11
+ Hardcoded credentials are a security risk. Use environment variables instead:
12
+
13
+ ```python
14
+ import os
15
+ api_key = os.environ.get("API_KEY")
16
+ ```
17
+
18
+ ```javascript
19
+ const apiKey = process.env.API_KEY;
20
+ ```
21
+
22
+ Make sure to add the real secret to `.env` (and `.env` to `.gitignore`).
code/server/routes.py CHANGED
@@ -11,6 +11,16 @@ Defines all HTTP and API endpoints:
11
  - API switch_model β†’ switch between loaded models
12
  - API upload_image β†’ upload image for VLM inference
13
  - API hf_auth β†’ get HF OAuth profile & organizations
 
 
 
 
 
 
 
 
 
 
14
  """
15
 
16
  from __future__ import annotations
@@ -24,7 +34,28 @@ from pathlib import Path
24
  from typing import Any
25
 
26
  from fastapi.responses import HTMLResponse, FileResponse
27
- from gradio import Server
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  from code.config.constants import (
30
  APP_TITLE,
@@ -82,6 +113,25 @@ async def homepage():
82
  with open(html_path, "r", encoding="utf-8") as f:
83
  content = f.read()
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  config = json.dumps({
86
  "app_title": APP_TITLE,
87
  "model_id": MODEL_CONFIGS[DEFAULT_MODEL_KEY]["id"],
@@ -93,6 +143,9 @@ async def homepage():
93
  for label, prompt, lang, fw in EXAMPLE_PROMPTS
94
  ],
95
  "default_model": "minicpm5-1b",
 
 
 
96
  })
97
  content = content.replace("__RUNTIME_CONFIG__", config)
98
  return content
@@ -630,3 +683,235 @@ def handle_push_hf(
630
  def get_app() -> Server:
631
  """Return the configured Gradio Server app instance."""
632
  return app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  - API switch_model β†’ switch between loaded models
12
  - API upload_image β†’ upload image for VLM inference
13
  - API hf_auth β†’ get HF OAuth profile & organizations
14
+ - API agent_run β†’ Claude Code-style agent loop with tools
15
+ - API list_skills β†’ list available skills
16
+ - API list_commands→ list available slash commands
17
+ - API list_hooks β†’ list configured hooks
18
+ - API workspace_tree→ list workspace files
19
+ - API workspace_read→ read a workspace file
20
+ - API workspace_write→ write a workspace file
21
+ - API workspace_bash→ run a bash command in workspace
22
+ - API todo_read β†’ read current todo list
23
+ - API todo_write β†’ update todo list
24
  """
25
 
26
  from __future__ import annotations
 
34
  from typing import Any
35
 
36
  from fastapi.responses import HTMLResponse, FileResponse
37
+ try:
38
+ from gradio import Server
39
+ except ImportError:
40
+ # Fallback for older/newer Gradio versions where Server may not be exposed
41
+ # at the top level. We provide a minimal shim so the module can still be
42
+ # imported for testing purposes.
43
+ class Server: # type: ignore
44
+ """Minimal shim for Gradio Server when not available."""
45
+ def __init__(self, *args, **kwargs):
46
+ from fastapi import FastAPI
47
+ self._fastapi = FastAPI()
48
+
49
+ def get(self, path: str, **kwargs):
50
+ return self._fastapi.get(path, **kwargs)
51
+
52
+ def api(self, name: str = None, concurrency_limit: int = 1):
53
+ def decorator(fn):
54
+ # Store as attribute so it can be inspected
55
+ fn._api_name = name
56
+ fn._concurrency_limit = concurrency_limit
57
+ return fn
58
+ return decorator
59
 
60
  from code.config.constants import (
61
  APP_TITLE,
 
113
  with open(html_path, "r", encoding="utf-8") as f:
114
  content = f.read()
115
 
116
+ # Load skills, commands, hooks for the frontend
117
+ try:
118
+ from code.skills import list_skills
119
+ skills_list = list_skills()
120
+ except Exception:
121
+ skills_list = []
122
+
123
+ try:
124
+ from code.commands import list_commands
125
+ commands_list = list_commands()
126
+ except Exception:
127
+ commands_list = []
128
+
129
+ try:
130
+ from code.hooks import list_hooks
131
+ hooks_list = list_hooks()
132
+ except Exception:
133
+ hooks_list = []
134
+
135
  config = json.dumps({
136
  "app_title": APP_TITLE,
137
  "model_id": MODEL_CONFIGS[DEFAULT_MODEL_KEY]["id"],
 
143
  for label, prompt, lang, fw in EXAMPLE_PROMPTS
144
  ],
145
  "default_model": "minicpm5-1b",
146
+ "skills": skills_list,
147
+ "commands": commands_list,
148
+ "hooks": hooks_list,
149
  })
150
  content = content.replace("__RUNTIME_CONFIG__", config)
151
  return content
 
683
  def get_app() -> Server:
684
  """Return the configured Gradio Server app instance."""
685
  return app
686
+
687
+
688
+ # ─── Agent / Skills / Commands / Hooks / Workspace Endpoints ──────────
689
+
690
+
691
+ @app.api(name="agent_run", concurrency_limit=2)
692
+ def handle_agent_run(
693
+ prompt: str,
694
+ target_language: str = "",
695
+ target_framework: str = "",
696
+ history_json: str = "[]",
697
+ skills_json: str = "[]",
698
+ search_enabled: str = "false",
699
+ image_url: str = "",
700
+ ) -> str:
701
+ """Run the Claude Code-style agent loop with tools.
702
+
703
+ Yields JSON events: status, tool_call, tool_result, streaming, complete, error.
704
+ """
705
+ from code.agent import run_agent
706
+
707
+ history = json.loads(history_json) if history_json else []
708
+ skills = json.loads(skills_json) if skills_json else []
709
+
710
+ prompt = (prompt or "").strip()
711
+ if not prompt:
712
+ yield json.dumps({
713
+ "type": "error",
714
+ "message": "Empty prompt",
715
+ })
716
+ return
717
+
718
+ # Optional web search
719
+ search_context = ""
720
+ if search_enabled.lower() == "true":
721
+ try:
722
+ search_results = web_search_google(prompt, num_results=6)
723
+ if search_results:
724
+ search_context = format_search_results(search_results)
725
+ yield json.dumps({
726
+ "type": "search_results",
727
+ "results": search_results,
728
+ "status_text": f"Found {len(search_results)} results, running agent...",
729
+ })
730
+ except Exception as exc:
731
+ logger.warning("Web search failed: %s", exc)
732
+
733
+ try:
734
+ for event in run_agent(
735
+ user_input=prompt,
736
+ history=history,
737
+ target_language=target_language,
738
+ target_framework=target_framework,
739
+ skills=skills,
740
+ search_context=search_context,
741
+ image_url=image_url.strip() or None,
742
+ ):
743
+ yield json.dumps(event, default=str)
744
+ except Exception as exc:
745
+ logger.exception("Agent run failed")
746
+ yield json.dumps({
747
+ "type": "error",
748
+ "message": str(exc),
749
+ })
750
+
751
+
752
+ @app.api(name="list_skills", concurrency_limit=4)
753
+ def handle_list_skills() -> str:
754
+ """List all available skills."""
755
+ from code.skills import list_skills
756
+ skills = list_skills()
757
+ yield json.dumps({"success": True, "skills": skills})
758
+
759
+
760
+ @app.api(name="list_commands", concurrency_limit=4)
761
+ def handle_list_commands() -> str:
762
+ """List all available slash commands."""
763
+ from code.commands import list_commands
764
+ commands = list_commands()
765
+ yield json.dumps({"success": True, "commands": commands})
766
+
767
+
768
+ @app.api(name="list_hooks", concurrency_limit=4)
769
+ def handle_list_hooks() -> str:
770
+ """List all configured hooks."""
771
+ from code.hooks import list_hooks
772
+ hooks = list_hooks()
773
+ yield json.dumps({"success": True, "hooks": hooks})
774
+
775
+
776
+ @app.api(name="workspace_tree", concurrency_limit=4)
777
+ def handle_workspace_tree() -> str:
778
+ """Return the workspace file tree."""
779
+ from code.tools.fs import list_workspace_tree
780
+ result = list_workspace_tree()
781
+ yield json.dumps(result, default=str)
782
+
783
+
784
+ @app.api(name="workspace_read", concurrency_limit=4)
785
+ def handle_workspace_read(path: str, offset: int = 0, limit: int = 0) -> str:
786
+ """Read a file from the workspace."""
787
+ from code.tools.fs import read_file
788
+ args = {"path": path}
789
+ if offset:
790
+ args["offset"] = offset
791
+ if limit:
792
+ args["limit"] = limit
793
+ result = read_file(**args)
794
+ yield json.dumps(result, default=str)
795
+
796
+
797
+ @app.api(name="workspace_write", concurrency_limit=1)
798
+ def handle_workspace_write(path: str, content: str) -> str:
799
+ """Write a file to the workspace."""
800
+ from code.tools.fs import write_file
801
+ result = write_file(path=path, content=content)
802
+ yield json.dumps(result, default=str)
803
+
804
+
805
+ @app.api(name="workspace_bash", concurrency_limit=1)
806
+ def handle_workspace_bash(command: str, timeout: int = 30) -> str:
807
+ """Run a bash command in the workspace."""
808
+ from code.tools.bash import run_bash
809
+ result = run_bash(command=command, timeout=timeout)
810
+ yield json.dumps(result, default=str)
811
+
812
+
813
+ @app.api(name="workspace_edit", concurrency_limit=1)
814
+ def handle_workspace_edit(
815
+ path: str,
816
+ old_str: str,
817
+ new_str: str,
818
+ replace_all: str = "false",
819
+ ) -> str:
820
+ """Edit a file in the workspace."""
821
+ from code.tools.fs import edit_file
822
+ result = edit_file(
823
+ path=path,
824
+ old_str=old_str,
825
+ new_str=new_str,
826
+ replace_all=replace_all.lower() == "true",
827
+ )
828
+ yield json.dumps(result, default=str)
829
+
830
+
831
+ @app.api(name="workspace_glob", concurrency_limit=4)
832
+ def handle_workspace_glob(pattern: str, path: str = ".") -> str:
833
+ """Glob files in the workspace."""
834
+ from code.tools.fs import glob_paths
835
+ result = glob_paths(pattern=pattern, path=path)
836
+ yield json.dumps(result, default=str)
837
+
838
+
839
+ @app.api(name="workspace_grep", concurrency_limit=4)
840
+ def handle_workspace_grep(
841
+ pattern: str,
842
+ path: str = ".",
843
+ include: str = "",
844
+ ignore_case: str = "false",
845
+ ) -> str:
846
+ """Grep file contents in the workspace."""
847
+ from code.tools.fs import grep_search
848
+ result = grep_search(
849
+ pattern=pattern,
850
+ path=path,
851
+ include=include or None,
852
+ ignore_case=ignore_case.lower() == "true",
853
+ )
854
+ yield json.dumps(result, default=str)
855
+
856
+
857
+ @app.api(name="todo_read", concurrency_limit=4)
858
+ def handle_todo_read(session_id: str = "default") -> str:
859
+ """Read the current todo list."""
860
+ from code.tools.todos import todo_read
861
+ result = todo_read(session_id=session_id)
862
+ yield json.dumps(result, default=str)
863
+
864
+
865
+ @app.api(name="todo_write", concurrency_limit=1)
866
+ def handle_todo_write(todos_json: str, session_id: str = "default") -> str:
867
+ """Replace the todo list."""
868
+ from code.tools.todos import todo_write
869
+ todos = json.loads(todos_json) if todos_json else []
870
+ result = todo_write(todos=todos, session_id=session_id)
871
+ yield json.dumps(result, default=str)
872
+
873
+
874
+ @app.api(name="workspace_snapshot", concurrency_limit=2)
875
+ def handle_workspace_snapshot() -> str:
876
+ """Return all workspace files for ZIP/deploy."""
877
+ from code.tools.fs import snapshot_workspace
878
+ files = snapshot_workspace()
879
+ yield json.dumps({"success": True, "files": files, "count": len(files)})
880
+
881
+
882
+ @app.api(name="workspace_reset", concurrency_limit=1)
883
+ def handle_workspace_reset() -> str:
884
+ """Clear the workspace."""
885
+ from code.tools.fs import reset_workspace
886
+ result = reset_workspace()
887
+ yield json.dumps(result, default=str)
888
+
889
+
890
+ @app.api(name="create_hook", concurrency_limit=1)
891
+ def handle_create_hook(
892
+ name: str,
893
+ event: str,
894
+ pattern: str,
895
+ action: str = "warn",
896
+ message: str = "",
897
+ enabled: str = "true",
898
+ ) -> str:
899
+ """Create a new user hook."""
900
+ from code.hooks import create_hook
901
+ result = create_hook(
902
+ name=name,
903
+ event=event,
904
+ pattern=pattern,
905
+ action=action,
906
+ message=message,
907
+ enabled=enabled.lower() == "true",
908
+ )
909
+ yield json.dumps(result, default=str)
910
+
911
+
912
+ @app.api(name="delete_hook", concurrency_limit=1)
913
+ def handle_delete_hook(name: str) -> str:
914
+ """Delete a user hook by name."""
915
+ from code.hooks import delete_hook
916
+ result = delete_hook(name)
917
+ yield json.dumps(result, default=str)
code/skills/__init__.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Skills system β€” load markdown skill files at runtime.
2
+
3
+ Inspired by Claude Code's Skill system. Each skill is a directory with:
4
+ - SKILL.md: the skill instructions (markdown with YAML frontmatter)
5
+ - references/ (optional): supporting docs
6
+ - scripts/ (optional): helper scripts
7
+
8
+ Skills are discovered under code/skills/builtins/ and can also be loaded
9
+ from the workspace's .sonicoder/skills/ directory.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ─── Skill discovery roots ──────────────────────────────────────────────
23
+
24
+ _BUILTIN_SKILLS_DIR = os.path.join(os.path.dirname(__file__), "builtins")
25
+ _USER_SKILLS_DIRNAME = ".sonicoder/skills" # relative to workspace root
26
+
27
+
28
+ def _skill_dirs() -> list[str]:
29
+ """Return all directories to search for skills."""
30
+ dirs = [_BUILTIN_SKILLS_DIR]
31
+ # Add user skills dir from workspace
32
+ try:
33
+ from code.tools.fs import get_workspace_root
34
+ user_dir = os.path.join(get_workspace_root(), _USER_SKILLS_DIRNAME)
35
+ if os.path.isdir(user_dir):
36
+ dirs.append(user_dir)
37
+ except Exception:
38
+ pass
39
+ return dirs
40
+
41
+
42
+ # ─── YAML frontmatter parsing ───────────────────────────────────────────
43
+
44
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
45
+
46
+
47
+ def _parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
48
+ """Parse YAML frontmatter from markdown. Returns (metadata, body)."""
49
+ match = _FRONTMATTER_RE.match(content)
50
+ if not match:
51
+ return {}, content
52
+
53
+ raw_yaml = match.group(1)
54
+ body = match.group(2)
55
+
56
+ # Very simple YAML parser (key: value pairs only)
57
+ meta: dict[str, str] = {}
58
+ for line in raw_yaml.splitlines():
59
+ line = line.strip()
60
+ if not line or line.startswith("#"):
61
+ continue
62
+ if ":" in line:
63
+ key, _, value = line.partition(":")
64
+ meta[key.strip()] = value.strip().strip("\"'")
65
+ return meta, body
66
+
67
+
68
+ # ─── Skill loading ──────────────────────────────────────────────────────
69
+
70
+ def _load_skill(skill_dir: str) -> dict[str, Any] | None:
71
+ """Load a single skill from a directory."""
72
+ skill_md = os.path.join(skill_dir, "SKILL.md")
73
+ if not os.path.isfile(skill_md):
74
+ return None
75
+
76
+ try:
77
+ with open(skill_md, "r", encoding="utf-8") as f:
78
+ content = f.read()
79
+ except Exception as exc:
80
+ logger.warning("Failed to read %s: %s", skill_md, exc)
81
+ return None
82
+
83
+ meta, body = _parse_frontmatter(content)
84
+
85
+ # Load any reference files
86
+ references: dict[str, str] = {}
87
+ refs_dir = os.path.join(skill_dir, "references")
88
+ if os.path.isdir(refs_dir):
89
+ for fname in os.listdir(refs_dir):
90
+ if fname.endswith((".md", ".txt")):
91
+ try:
92
+ with open(os.path.join(refs_dir, fname), "r", encoding="utf-8") as f:
93
+ references[fname] = f.read()
94
+ except Exception:
95
+ pass
96
+
97
+ return {
98
+ "name": meta.get("name", os.path.basename(skill_dir)),
99
+ "description": meta.get("description", ""),
100
+ "language": meta.get("language", ""),
101
+ "tags": [t.strip() for t in meta.get("tags", "").split(",") if t.strip()],
102
+ "body": body.strip(),
103
+ "references": references,
104
+ "path": skill_dir,
105
+ }
106
+
107
+
108
+ def list_skills() -> list[dict[str, Any]]:
109
+ """List all available skills (metadata only, no body)."""
110
+ skills: list[dict[str, Any]] = []
111
+ seen_names: set[str] = set()
112
+
113
+ for skills_dir in _skill_dirs():
114
+ if not os.path.isdir(skills_dir):
115
+ continue
116
+ for entry in sorted(os.listdir(skills_dir)):
117
+ entry_path = os.path.join(skills_dir, entry)
118
+ if not os.path.isdir(entry_path):
119
+ continue
120
+ skill = _load_skill(entry_path)
121
+ if skill and skill["name"] not in seen_names:
122
+ seen_names.add(skill["name"])
123
+ skills.append({
124
+ "name": skill["name"],
125
+ "description": skill["description"],
126
+ "language": skill["language"],
127
+ "tags": skill["tags"],
128
+ })
129
+ return skills
130
+
131
+
132
+ def get_skill(name: str) -> dict[str, Any] | None:
133
+ """Get full skill content by name."""
134
+ for skills_dir in _skill_dirs():
135
+ if not os.path.isdir(skills_dir):
136
+ continue
137
+ # Try directory match
138
+ for entry in os.listdir(skills_dir):
139
+ entry_path = os.path.join(skills_dir, entry)
140
+ if not os.path.isdir(entry_path):
141
+ continue
142
+ skill = _load_skill(entry_path)
143
+ if skill and skill["name"] == name:
144
+ return skill
145
+ return None
146
+
147
+
148
+ def invoke_skill(name: str) -> dict[str, Any]:
149
+ """Invoke a skill by name β€” returns its full body and references."""
150
+ skill = get_skill(name)
151
+ if not skill:
152
+ return {
153
+ "success": False,
154
+ "error": f"Skill not found: {name}",
155
+ "available": [s["name"] for s in list_skills()],
156
+ }
157
+ return {
158
+ "success": True,
159
+ "name": skill["name"],
160
+ "description": skill["description"],
161
+ "body": skill["body"],
162
+ "references": skill["references"],
163
+ }
164
+
165
+
166
+ def build_skills_context(skill_names: list[str] | None = None) -> str:
167
+ """Build a context string with skill bodies to inject into the prompt.
168
+
169
+ If skill_names is None, includes all skills (brief listing only).
170
+ """
171
+ if not skill_names:
172
+ # List all skills briefly
173
+ skills = list_skills()
174
+ if not skills:
175
+ return ""
176
+ lines = ["Available skills (use /skill <name> to load full instructions):"]
177
+ for s in skills:
178
+ desc = s["description"][:120]
179
+ lines.append(f"- {s['name']}: {desc}")
180
+ return "\n".join(lines)
181
+
182
+ # Load full bodies for requested skills
183
+ parts: list[str] = []
184
+ for name in skill_names:
185
+ skill = get_skill(name)
186
+ if skill:
187
+ parts.append(f"# Skill: {skill['name']}\n\n{skill['body']}")
188
+ for ref_name, ref_body in skill["references"].items():
189
+ parts.append(f"\n## Reference: {ref_name}\n\n{ref_body}")
190
+ else:
191
+ parts.append(f"# Skill: {name}\n\n(Skill not found)")
192
+ return "\n\n---\n\n".join(parts)
code/skills/builtins/code-review/SKILL.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: code-review
3
+ description: Review code for bugs, simplicity, DRY violations, and project conventions. Use when the user asks for a review or before committing changes.
4
+ language: any
5
+ tags: review, quality, bugs, refactoring
6
+ ---
7
+ # Code Review
8
+
9
+ Review code with high signal and low noise. We only want HIGH SIGNAL issues.
10
+
11
+ ## What to flag
12
+
13
+ Flag issues where:
14
+ - The code will fail to compile or parse (syntax errors, type errors, missing imports, unresolved references)
15
+ - The code will definitely produce wrong results regardless of inputs (clear logic errors)
16
+ - Clear, unambiguous project convention violations where you can quote the exact rule being broken
17
+ - Security issues (hardcoded secrets, SQL injection, XSS, path traversal, command injection)
18
+ - Resource leaks (unclosed files, connections, etc.)
19
+
20
+ ## What NOT to flag
21
+
22
+ Do NOT flag:
23
+ - Code style or quality concerns (subjective)
24
+ - Potential issues that depend on specific inputs or state
25
+ - Subjective suggestions or improvements
26
+ - Issues a linter will catch
27
+ - Pre-existing issues outside the diff
28
+ - Pedantic nitpicks that a senior engineer would not flag
29
+
30
+ ## Process
31
+
32
+ 1. **Read all changed files** using `read_file`. If the user mentions a diff, focus on the changed lines plus surrounding context.
33
+ 2. **Run parallel reviews** mentally:
34
+ - Review 1: Bugs and functional correctness β€” does it do what it claims?
35
+ - Review 2: Simplicity/DRY/elegance β€” can it be simpler?
36
+ - Review 3: Project conventions β€” does it match the codebase style?
37
+ 3. **Validate each issue**: For each potential issue, check whether it's actually a problem by reading surrounding code.
38
+ 4. **Filter false positives**: Drop anything you're not certain about.
39
+ 5. **Present findings** sorted by severity (critical > high > medium > low). For each issue:
40
+ - File and line number
41
+ - Description of the issue
42
+ - Suggested fix (with code snippet if small)
43
+
44
+ ## Output format
45
+
46
+ If issues found:
47
+ ```
48
+ ## Code Review
49
+
50
+ ### Critical
51
+ - `path/to/file.py:42` β€” Description and fix
52
+
53
+ ### High
54
+ - `path/to/file.py:50` β€” Description and fix
55
+
56
+ ### Medium
57
+ - ...
58
+ ```
59
+
60
+ If no issues:
61
+ ```
62
+ ## Code Review
63
+
64
+ No issues found. Checked for bugs, simplicity, DRY, and project conventions.
65
+ ```
code/skills/builtins/commit-workflow/SKILL.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: commit-workflow
3
+ description: Guided git commit workflow. Use before committing changes β€” generates a clean commit message from the diff.
4
+ language: any
5
+ tags: git, commit, version-control
6
+ ---
7
+ # Commit Workflow
8
+
9
+ When the user asks to commit changes, follow this workflow.
10
+
11
+ ## Step 1: Inspect the changes
12
+
13
+ Run via `bash`:
14
+ - `git status` β€” see what's modified/staged/untracked
15
+ - `git diff HEAD` β€” full diff of staged and unstaged changes
16
+ - `git branch --show-current` β€” current branch
17
+ - `git log --oneline -10` β€” recent commits (to match style)
18
+
19
+ ## Step 2: Analyze
20
+
21
+ Determine:
22
+ - What's the **single logical change** being committed?
23
+ - Are there multiple unrelated changes? If so, suggest splitting into multiple commits.
24
+ - What's the conventional commit type?
25
+ - `feat:` new feature
26
+ - `fix:` bug fix
27
+ - `docs:` documentation only
28
+ - `style:` formatting, no code change
29
+ - `refactor:` code change that neither fixes a bug nor adds a feature
30
+ - `perf:` code change that improves performance
31
+ - `test:` adding tests
32
+ - `chore:` build process, tooling, deps
33
+
34
+ ## Step 3: Write the message
35
+
36
+ Format:
37
+ ```
38
+ <type>(<optional scope>): <imperative subject under 72 chars>
39
+
40
+ <optional body explaining why, wrapped at 72 chars>
41
+
42
+ <optional footer like "Fixes #123">
43
+ ```
44
+
45
+ Rules:
46
+ - Subject in imperative mood: "Add" not "Added" or "Adds"
47
+ - Subject lowercase, no period
48
+ - Body explains **why**, not what (the diff shows what)
49
+ - Reference issues in footer
50
+
51
+ ## Step 4: Stage and commit
52
+
53
+ If specific files should be staged, run `git add <files>` explicitly. Otherwise `git add -A` is fine.
54
+
55
+ Commit with a heredoc to preserve formatting:
56
+ ```bash
57
+ git commit -m "$(cat <<'EOF'
58
+ feat(auth): add OAuth2 login flow
59
+
60
+ Implements the login button, callback handler, and session
61
+ persistence using JWT in httpOnly cookies.
62
+
63
+ Fixes #142
64
+ EOF
65
+ )"
66
+ ```
67
+
68
+ ## Step 5: Verify
69
+
70
+ - `git log -1 --stat` to confirm the commit looks right
71
+ - `git status` to confirm clean working tree (or remaining unrelated changes)
72
+
73
+ ## Anti-patterns
74
+
75
+ - ❌ `git commit -m "fix"` β€” too vague
76
+ - ❌ `git commit -m "Update file.py"` β€” describes what, not why
77
+ - ❌ Mixing unrelated changes in one commit
78
+ - ❌ Committing `node_modules/`, `.env`, build artifacts
79
+ - ❌ Using `--no-verify` to skip hooks without explanation
code/skills/builtins/debugging/SKILL.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: debugging
3
+ description: Systematic debugging workflow for diagnosing and fixing errors. Use when the user reports a bug, crash, or unexpected behavior.
4
+ language: any
5
+ tags: debug, errors, troubleshooting, fix
6
+ ---
7
+ # Debugging
8
+
9
+ Approach debugging systematically. Don't guess β€” investigate.
10
+
11
+ ## Phase 1: Reproduce
12
+
13
+ **Goal**: Make the bug happen reliably.
14
+
15
+ - Ask: what exact steps trigger it?
16
+ - Ask: what was the expected vs actual behavior?
17
+ - Ask: is it consistent or intermittent?
18
+ - If you can reproduce it locally, do so with `bash` and capture the full error output.
19
+
20
+ ## Phase 2: Isolate
21
+
22
+ **Goal**: Find the smallest scope where the bug reproduces.
23
+
24
+ - Read the relevant code with `read_file`
25
+ - Trace the data flow from input to failure point
26
+ - Add temporary print/logging statements if needed
27
+ - Identify the exact line where things go wrong
28
+
29
+ ## Phase 3: Diagnose
30
+
31
+ **Goal**: Understand WHY the bug happens.
32
+
33
+ Common root causes:
34
+ - **Null/None/undefined**: missing null checks
35
+ - **Type confusion**: passing wrong type, silent coercion
36
+ - **Off-by-one**: loop bounds, slicing
37
+ - **Race conditions**: shared state, async ordering
38
+ - **Stale state**: cached data not invalidated
39
+ - **Wrong assumption**: code expects something the caller doesn't guarantee
40
+ - **Environment**: missing env vars, wrong paths, permissions
41
+
42
+ State the root cause in one sentence before fixing.
43
+
44
+ ## Phase 4: Fix
45
+
46
+ **Goal**: Apply the minimal correct fix.
47
+
48
+ - Fix the root cause, not the symptom
49
+ - Don't introduce new patterns β€” match the surrounding code style
50
+ - Add a comment if the fix is non-obvious
51
+ - Consider: are there other places with the same bug?
52
+
53
+ ## Phase 5: Verify
54
+
55
+ **Goal**: Confirm the fix works and doesn't break anything.
56
+
57
+ - Re-run the reproduction steps
58
+ - Run any existing tests with `bash`
59
+ - Test edge cases related to the bug
60
+ - Check that you haven't introduced regressions
61
+
62
+ ## Phase 6: Document
63
+
64
+ **Goal**: Prevent recurrence.
65
+
66
+ - If appropriate, add a regression test
67
+ - Update relevant docs/comments
68
+ - Note the fix in the commit message
69
+
70
+ ## Anti-patterns to avoid
71
+
72
+ - ❌ Shotgun debugging: changing random things hoping it works
73
+ - ❌ Fixing symptoms: papering over the real issue
74
+ - ❌ Adding null checks everywhere: hiding the real bug
75
+ - ❌ "Works on my machine": dismissing environmental factors
76
+ - ❌ Skipping verification: assuming the fix works
code/skills/builtins/feature-dev/SKILL.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: feature-dev
3
+ description: Guided feature development with codebase understanding and architecture focus. Use when the user asks to implement a feature or build something non-trivial.
4
+ language: any
5
+ tags: workflow, architecture, planning, implementation
6
+ ---
7
+ # Feature Development
8
+
9
+ You are helping a developer implement a new feature. Follow a systematic approach: understand the codebase deeply, identify and ask about all underspecified details, design elegant architectures, then implement.
10
+
11
+ ## Core Principles
12
+
13
+ - **Ask clarifying questions**: Identify all ambiguities, edge cases, and underspecified behaviors. Ask specific, concrete questions rather than making assumptions.
14
+ - **Understand before acting**: Read and comprehend existing code patterns first
15
+ - **Simple and elegant**: Prioritize readable, maintainable, architecturally sound code
16
+ - **Use TodoWrite**: Track all progress throughout
17
+
18
+ ## Phase 1: Discovery
19
+
20
+ **Goal**: Understand what needs to be built
21
+
22
+ **Actions**:
23
+ 1. Create a todo list with all phases
24
+ 2. If feature unclear, ask user for:
25
+ - What problem are they solving?
26
+ - What should the feature do?
27
+ - Any constraints or requirements?
28
+ 3. Summarize understanding and confirm with user
29
+
30
+ ## Phase 2: Codebase Exploration
31
+
32
+ **Goal**: Understand relevant existing code and patterns
33
+
34
+ **Actions**:
35
+ 1. Use `list_dir` and `glob` to map the project structure
36
+ 2. Use `grep` to find similar features and patterns
37
+ 3. Use `read_file` on 5-10 key files identified
38
+ 4. Present comprehensive summary of findings and patterns discovered
39
+
40
+ ## Phase 3: Clarifying Questions
41
+
42
+ **Goal**: Fill in gaps and resolve all ambiguities before designing
43
+
44
+ **CRITICAL**: This is one of the most important phases. DO NOT SKIP.
45
+
46
+ **Actions**:
47
+ 1. Review the codebase findings and original feature request
48
+ 2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance needs
49
+ 3. **Present all questions to the user in a clear, organized list**
50
+ 4. **Wait for answers before proceeding to architecture design**
51
+
52
+ If the user says "whatever you think is best", provide your recommendation and get explicit confirmation.
53
+
54
+ ## Phase 4: Architecture Design
55
+
56
+ **Goal**: Design an implementation approach with concrete trade-offs
57
+
58
+ **Actions**:
59
+ 1. Design the implementation: minimal changes (smallest change, maximum reuse), clean architecture (maintainability, elegant abstractions), or pragmatic balance (speed + quality)
60
+ 2. Present to user: brief summary, trade-offs, **your recommendation with reasoning**, concrete implementation differences
61
+ 3. **Ask user to approve the approach**
62
+
63
+ ## Phase 5: Implementation
64
+
65
+ **Goal**: Build the feature
66
+
67
+ **DO NOT START WITHOUT USER APPROVAL**
68
+
69
+ **Actions**:
70
+ 1. Wait for explicit user approval
71
+ 2. Read all relevant files identified in previous phases
72
+ 3. Implement following chosen architecture
73
+ 4. Follow codebase conventions strictly
74
+ 5. Write clean, well-documented code
75
+ 6. Update todos as you progress
76
+
77
+ ## Phase 6: Quality Review
78
+
79
+ **Goal**: Ensure code is simple, DRY, elegant, easy to read, and functionally correct
80
+
81
+ **Actions**:
82
+ 1. Self-review for: simplicity/DRY/elegance, bugs/functional correctness, project conventions/abstractions
83
+ 2. Consolidate findings and identify highest severity issues that you recommend fixing
84
+ 3. **Present findings to user and ask what they want to do** (fix now, fix later, or proceed as-is)
85
+ 4. Address issues based on user decision
86
+
87
+ ## Phase 7: Summary
88
+
89
+ **Goal**: Document what was accomplished
90
+
91
+ **Actions**:
92
+ 1. Mark all todos complete
93
+ 2. Summarize:
94
+ - What was built
95
+ - Key decisions made
96
+ - Files modified
97
+ - Suggested next steps
code/skills/builtins/frontend-design/SKILL.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: frontend-design
3
+ description: Guidance for distinctive, intentional visual design when building UI. Helps with palette, typography, and avoiding templated defaults.
4
+ language: any
5
+ tags: frontend, design, ui, css, html
6
+ ---
7
+ # Frontend Design
8
+
9
+ Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify.
10
+
11
+ ## Ground it in the subject
12
+
13
+ If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from.
14
+
15
+ ## Design principles
16
+
17
+ For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option.
18
+
19
+ Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing.
20
+
21
+ Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it.
22
+
23
+ Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere.
24
+
25
+ ## Process: brainstorm, explore, plan, critique, build, critique again
26
+
27
+ Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature.
28
+
29
+ - **Color**: describe the palette as 4-6 named hex values
30
+ - **Type**: the typefaces for 2+ roles (display + body + utility)
31
+ - **Layout**: a layout concept with one-sentence prose descriptions and ASCII wireframes
32
+ - **Signature**: the single unique element this page will be remembered by
33
+
34
+ Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page rather than a choice made for this specific brief β€” revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code.
35
+
36
+ ## Restraint and self-critique
37
+
38
+ Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected.
code/skills/builtins/fullstack-scaffold/SKILL.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: fullstack-scaffold
3
+ description: Scaffold complete fullstack projects with proper structure. Generates package.json, requirements.txt, Dockerfiles, and config files for any framework.
4
+ language: any
5
+ tags: scaffold, fullstack, project, structure, framework
6
+ ---
7
+ # Fullstack Project Scaffolding
8
+
9
+ When generating a fullstack application, always produce a complete, runnable project β€” not just snippets.
10
+
11
+ ## Project structure rules
12
+
13
+ ### Python (Flask/FastAPI/Django/Streamlit/Gradio)
14
+ ```
15
+ project-name/
16
+ β”œβ”€β”€ app.py # Entry point (HF Spaces expects app.py)
17
+ β”œβ”€β”€ requirements.txt # Pinned dependencies
18
+ β”œβ”€β”€ README.md # With HF Space frontmatter if deploying
19
+ β”œβ”€β”€ .env.example # Document required env vars
20
+ β”œβ”€β”€ src/
21
+ β”‚ β”œβ”€β”€ __init__.py
22
+ β”‚ β”œβ”€β”€ routes/ # API routes
23
+ β”‚ β”œβ”€β”€ models/ # Data models
24
+ β”‚ β”œβ”€β”€ services/ # Business logic
25
+ β”‚ └── utils/ # Helpers
26
+ β”œβ”€β”€ tests/
27
+ β”‚ └── test_app.py
28
+ └── static/ # Static assets (if web framework)
29
+ ```
30
+
31
+ ### Next.js / React (Vite)
32
+ ```
33
+ project-name/
34
+ β”œβ”€β”€ package.json # name, version, scripts, deps
35
+ β”œβ”€β”€ next.config.js # (Next.js only) output: 'standalone'
36
+ β”œβ”€β”€ vite.config.js # (Vite only)
37
+ β”œβ”€β”€ tsconfig.json # (TypeScript)
38
+ β”œβ”€β”€ Dockerfile # Multi-stage build for HF Spaces
39
+ β”œβ”€β”€ .dockerignore
40
+ β”œβ”€β”€ README.md
41
+ β”œβ”€β”€ public/
42
+ β”‚ └── (static assets)
43
+ └── src/
44
+ β”œβ”€β”€ app/ # Next.js App Router
45
+ β”œβ”€β”€ pages/ # Next.js Pages Router (legacy)
46
+ β”œβ”€β”€ components/ # Reusable components
47
+ β”œβ”€β”€ lib/ # Utilities, helpers
48
+ β”œβ”€β”€ hooks/ # Custom hooks
49
+ β”œβ”€β”€ styles/ # Global CSS
50
+ └── types/ # TypeScript types
51
+ ```
52
+
53
+ ### Express / NestJS
54
+ ```
55
+ project-name/
56
+ β”œβ”€β”€ package.json
57
+ β”œβ”€β”€ Dockerfile
58
+ β”œβ”€β”€ .dockerignore
59
+ β”œβ”€β”€ README.md
60
+ β”œβ”€β”€ src/
61
+ β”‚ β”œβ”€β”€ index.js # Entry β€” app.listen(7860, '0.0.0.0')
62
+ β”‚ β”œβ”€β”€ routes/
63
+ β”‚ β”œβ”€β”€ middleware/
64
+ β”‚ β”œβ”€β”€ controllers/ # NestJS only
65
+ β”‚ β”œβ”€β”€ services/
66
+ β”‚ └── models/
67
+ └── tests/
68
+ ```
69
+
70
+ ## Port and host rules (critical for HF Spaces)
71
+
72
+ - All servers MUST listen on `0.0.0.0` (not `localhost` or `127.0.0.1`)
73
+ - All servers MUST use port `7860` (HF Spaces default)
74
+ - For sub-servers (Gradio subprocess), use 7861, 7862, etc.
75
+
76
+ ## Dockerfile rules
77
+
78
+ For JS/TS projects, generate a Dockerfile:
79
+ - Use `node:20-slim` as base
80
+ - Multi-stage build: deps β†’ builder β†’ runner
81
+ - For SPAs (React/Vue), serve with nginx on port 7860
82
+ - For Next.js, use `output: 'standalone'` and copy `.next/standalone`
83
+ - Run as non-root user
84
+
85
+ For Python projects, the HF Space SDK auto-generates the runtime β€” no Dockerfile needed unless using Docker SDK.
86
+
87
+ ## package.json rules
88
+
89
+ - Always include `name`, `version`, `private: true`
90
+ - Scripts: `dev`, `build`, `start` (start must bind 0.0.0.0:7860)
91
+ - Pin major versions: `"react": "^18.3.0"` not `"react": "latest"`
92
+ - Dev dependencies: TypeScript, types, build tools, linters
93
+ - Production dependencies: runtime libraries only
94
+
95
+ ## requirements.txt rules
96
+
97
+ - Pin with `>=` to allow patch updates: `flask>=3.0.0`
98
+ - Group by category (web, db, ml, dev) with comments
99
+ - Always include the framework (flask, fastapi, gradio, etc.)
100
+ - Never include stdlib modules
101
+
102
+ ## README.md rules
103
+
104
+ For HF Spaces, include frontmatter:
105
+ ```yaml
106
+ ---
107
+ title: App Name
108
+ emoji: πŸš€
109
+ colorFrom: blue
110
+ colorTo: purple
111
+ sdk: gradio # or docker, static, streamlit
112
+ app_file: app.py # or index.html, Dockerfile
113
+ pinned: false
114
+ ---
115
+ ```
116
+
117
+ ## Don't include
118
+
119
+ - `node_modules/`
120
+ - `.next/` (build output)
121
+ - `__pycache__/`
122
+ - `.venv/`, `venv/`
123
+ - Lock files (let HF install fresh)
124
+ - `.env` (only `.env.example`)
code/tools/__init__.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File system and shell tools for the agent.
2
+
3
+ Inspired by Claude Code's tool set:
4
+ - read_file / write_file / edit_file
5
+ - glob / grep
6
+ - bash (subprocess)
7
+ - list_dir
8
+ - todo_write / todo_read
9
+ """
10
+ from code.tools.fs import (
11
+ read_file,
12
+ write_file,
13
+ edit_file,
14
+ multi_edit,
15
+ list_dir,
16
+ glob_paths,
17
+ grep_search,
18
+ )
19
+ from code.tools.bash import run_bash
20
+ from code.tools.todos import (
21
+ todo_read,
22
+ todo_write,
23
+ todo_update,
24
+ )
25
+
26
+ __all__ = [
27
+ "read_file",
28
+ "write_file",
29
+ "edit_file",
30
+ "multi_edit",
31
+ "list_dir",
32
+ "glob_paths",
33
+ "grep_search",
34
+ "run_bash",
35
+ "todo_read",
36
+ "todo_write",
37
+ "todo_update",
38
+ ]
code/tools/bash.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bash subprocess tool with timeout and output capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from code.tools.fs import _resolve_safe, get_workspace_root
11
+
12
+ # ─── Safety: commands that are forbidden by default ────────────────────
13
+
14
+ _BLOCKED_PATTERNS = [
15
+ "rm -rf /",
16
+ "rm -rf ~",
17
+ "rm -rf $HOME",
18
+ ":(){:|:&};:",
19
+ "mkfs",
20
+ "dd if=/dev/zero of=/dev/",
21
+ "> /dev/sda",
22
+ "shutdown",
23
+ "reboot",
24
+ "halt",
25
+ ]
26
+
27
+ # Default env vars to scrub for safety
28
+ _ENV_SCRUB = {"HF_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "AWS_SECRET_ACCESS_KEY"}
29
+
30
+
31
+ def _is_safe_command(cmd: str) -> tuple[bool, str]:
32
+ """Check if a command is safe to run."""
33
+ stripped = cmd.strip()
34
+ if not stripped:
35
+ return False, "Empty command"
36
+ for pat in _BLOCKED_PATTERNS:
37
+ if pat in stripped:
38
+ return False, f"Blocked pattern detected: {pat}"
39
+ return True, ""
40
+
41
+
42
+ def run_bash(
43
+ command: str,
44
+ cwd: str | None = None,
45
+ timeout: int = 30,
46
+ env_extra: dict[str, str] | None = None,
47
+ ) -> dict[str, Any]:
48
+ """Run a shell command in the workspace.
49
+
50
+ Args:
51
+ command: Shell command to execute.
52
+ cwd: Working directory (relative to workspace root, defaults to workspace).
53
+ timeout: Max seconds before killing the process.
54
+ env_extra: Extra environment variables.
55
+
56
+ Returns:
57
+ dict with: stdout, stderr, returncode, timed_out
58
+ """
59
+ try:
60
+ safe, reason = _is_safe_command(command)
61
+ if not safe:
62
+ return {
63
+ "success": False,
64
+ "stdout": "",
65
+ "stderr": reason,
66
+ "returncode": -1,
67
+ "timed_out": False,
68
+ }
69
+
70
+ if cwd:
71
+ work_dir = _resolve_safe(cwd)
72
+ else:
73
+ work_dir = get_workspace_root()
74
+
75
+ # Build env: scrub secrets, add extras
76
+ env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB}
77
+ if env_extra:
78
+ env.update(env_extra)
79
+
80
+ # Run with bash -c for full shell semantics
81
+ completed = subprocess.run(
82
+ ["bash", "-c", command],
83
+ cwd=work_dir,
84
+ env=env,
85
+ capture_output=True,
86
+ text=True,
87
+ timeout=timeout,
88
+ check=False,
89
+ )
90
+
91
+ # Truncate huge outputs
92
+ stdout = completed.stdout
93
+ stderr = completed.stderr
94
+ if len(stdout) > 50_000:
95
+ stdout = stdout[:50_000] + f"\n... truncated ({len(stdout) - 50_000} chars) ..."
96
+ if len(stderr) > 50_000:
97
+ stderr = stderr[:50_000] + f"\n... truncated ({len(stderr) - 50_000} chars) ..."
98
+
99
+ return {
100
+ "success": completed.returncode == 0,
101
+ "stdout": stdout,
102
+ "stderr": stderr,
103
+ "returncode": completed.returncode,
104
+ "timed_out": False,
105
+ "command": command,
106
+ "cwd": cwd or ".",
107
+ }
108
+ except subprocess.TimeoutExpired as exc:
109
+ stdout = exc.stdout or "" if isinstance(exc.stdout, str) else (exc.stdout or b"").decode("utf-8", errors="replace")
110
+ stderr = exc.stderr or "" if isinstance(exc.stderr, str) else (exc.stderr or b"").decode("utf-8", errors="replace")
111
+ return {
112
+ "success": False,
113
+ "stdout": stdout,
114
+ "stderr": f"Timeout after {timeout}s\n{stderr}",
115
+ "returncode": -1,
116
+ "timed_out": True,
117
+ "command": command,
118
+ }
119
+ except Exception as exc:
120
+ return {
121
+ "success": False,
122
+ "stdout": "",
123
+ "stderr": str(exc),
124
+ "returncode": -1,
125
+ "timed_out": False,
126
+ "command": command,
127
+ }
code/tools/fs.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File system tools: read, write, edit, list, glob, grep.
2
+
3
+ All tools are sandboxed to a configurable workspace root (default: ./workspace).
4
+ They return JSON-serializable dicts so they can be exposed via the API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import os
11
+ import re
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ # ─── Workspace sandbox ──────────────────────────────────────────────────
17
+
18
+ # Default workspace: ./workspace under the app root
19
+ _DEFAULT_WORKSPACE = os.environ.get(
20
+ "SONICODER_WORKSPACE",
21
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "workspace")),
22
+ )
23
+
24
+
25
+ def get_workspace_root() -> str:
26
+ """Return the absolute path of the agent's workspace root."""
27
+ root = _DEFAULT_WORKSPACE
28
+ os.makedirs(root, exist_ok=True)
29
+ return root
30
+
31
+
32
+ def _resolve_safe(path: str) -> str:
33
+ """Resolve a path safely within the workspace root.
34
+
35
+ Raises ValueError if the resolved path escapes the workspace.
36
+ """
37
+ root = get_workspace_root()
38
+ if os.path.isabs(path):
39
+ full = os.path.abspath(path)
40
+ else:
41
+ full = os.path.abspath(os.path.join(root, path))
42
+
43
+ # Ensure path is within the workspace
44
+ if not (full == root or full.startswith(root + os.sep)):
45
+ raise ValueError(
46
+ f"Path '{path}' resolves outside the workspace root ({root}). "
47
+ "Agent tools are sandboxed."
48
+ )
49
+ return full
50
+
51
+
52
+ # ─── read_file ──────────────────────────────────────────────────────────
53
+
54
+ def read_file(path: str, offset: int = 0, limit: int | None = None) -> dict[str, Any]:
55
+ """Read a text file from the workspace.
56
+
57
+ Args:
58
+ path: Relative path inside the workspace, or absolute within it.
59
+ offset: 1-indexed line to start reading from.
60
+ limit: Maximum number of lines to read.
61
+
62
+ Returns:
63
+ dict with: path, content, line_count, truncated
64
+ """
65
+ try:
66
+ full = _resolve_safe(path)
67
+ if not os.path.exists(full):
68
+ return {"success": False, "error": f"File not found: {path}"}
69
+ if os.path.isdir(full):
70
+ return {"success": False, "error": f"Path is a directory: {path}"}
71
+
72
+ with open(full, "r", encoding="utf-8", errors="replace") as f:
73
+ lines = f.readlines()
74
+
75
+ total = len(lines)
76
+ start = max(0, (offset - 1) if offset > 0 else 0)
77
+ end = (start + limit) if limit else total
78
+ selected = lines[start:end]
79
+
80
+ # Re-number for display
81
+ numbered = "".join(
82
+ f"{start + i + 1:6}\t{line}" for i, line in enumerate(selected)
83
+ )
84
+
85
+ return {
86
+ "success": True,
87
+ "path": path,
88
+ "content": numbered,
89
+ "line_count": total,
90
+ "returned_lines": len(selected),
91
+ "truncated": end < total,
92
+ }
93
+ except Exception as exc:
94
+ return {"success": False, "error": str(exc)}
95
+
96
+
97
+ # ─── write_file ─────────────────────────────────────────────────────────
98
+
99
+ def write_file(path: str, content: str) -> dict[str, Any]:
100
+ """Write content to a file, creating parent directories as needed."""
101
+ try:
102
+ full = _resolve_safe(path)
103
+ os.makedirs(os.path.dirname(full), exist_ok=True)
104
+ with open(full, "w", encoding="utf-8") as f:
105
+ f.write(content)
106
+ return {
107
+ "success": True,
108
+ "path": path,
109
+ "bytes_written": len(content.encode("utf-8")),
110
+ }
111
+ except Exception as exc:
112
+ return {"success": False, "error": str(exc)}
113
+
114
+
115
+ # ─── edit_file ──────────────────────────────────────────────────────────
116
+
117
+ def edit_file(
118
+ path: str,
119
+ old_str: str,
120
+ new_str: str,
121
+ replace_all: bool = False,
122
+ ) -> dict[str, Any]:
123
+ """Replace occurrences of old_str with new_str in a file."""
124
+ try:
125
+ full = _resolve_safe(path)
126
+ if not os.path.exists(full):
127
+ return {"success": False, "error": f"File not found: {path}"}
128
+
129
+ with open(full, "r", encoding="utf-8") as f:
130
+ content = f.read()
131
+
132
+ if old_str not in content:
133
+ return {
134
+ "success": False,
135
+ "error": f"old_str not found in {path}. Edit aborted.",
136
+ }
137
+
138
+ if old_str == new_str:
139
+ return {"success": False, "error": "old_str and new_str are identical."}
140
+
141
+ count = content.count(old_str) if replace_all else 1
142
+ if not replace_all and count > 1:
143
+ return {
144
+ "success": False,
145
+ "error": (
146
+ f"old_str is not unique ({count} matches) in {path}. "
147
+ "Provide more context or use replace_all=true."
148
+ ),
149
+ }
150
+
151
+ new_content = content.replace(old_str, new_str) if replace_all else content.replace(
152
+ old_str, new_str, 1
153
+ )
154
+
155
+ with open(full, "w", encoding="utf-8") as f:
156
+ f.write(new_content)
157
+
158
+ return {
159
+ "success": True,
160
+ "path": path,
161
+ "replacements": count,
162
+ }
163
+ except Exception as exc:
164
+ return {"success": False, "error": str(exc)}
165
+
166
+
167
+ def multi_edit(path: str, edits: list[dict[str, Any]]) -> dict[str, Any]:
168
+ """Apply multiple edits to a file atomically (all-or-nothing)."""
169
+ try:
170
+ full = _resolve_safe(path)
171
+ if not os.path.exists(full):
172
+ return {"success": False, "error": f"File not found: {path}"}
173
+
174
+ with open(full, "r", encoding="utf-8") as f:
175
+ content = f.read()
176
+
177
+ applied = 0
178
+ for edit in edits:
179
+ old_str = edit.get("old_str", "")
180
+ new_str = edit.get("new_str", "")
181
+ replace_all = edit.get("replace_all", False)
182
+ if old_str not in content:
183
+ return {
184
+ "success": False,
185
+ "error": f"old_str not found in {path} for edit #{applied + 1}.",
186
+ "applied": applied,
187
+ }
188
+ if old_str == new_str:
189
+ return {
190
+ "success": False,
191
+ "error": f"old_str and new_str identical in edit #{applied + 1}.",
192
+ "applied": applied,
193
+ }
194
+ content = content.replace(old_str, new_str) if replace_all else content.replace(
195
+ old_str, new_str, 1
196
+ )
197
+ applied += 1
198
+
199
+ with open(full, "w", encoding="utf-8") as f:
200
+ f.write(content)
201
+
202
+ return {"success": True, "path": path, "applied": applied}
203
+ except Exception as exc:
204
+ return {"success": False, "error": str(exc)}
205
+
206
+
207
+ # ─── list_dir ───────────────────────────────────────────────────────────
208
+
209
+ def list_dir(path: str = ".") -> dict[str, Any]:
210
+ """List directory contents."""
211
+ try:
212
+ full = _resolve_safe(path)
213
+ if not os.path.exists(full):
214
+ return {"success": False, "error": f"Path not found: {path}"}
215
+ if not os.path.isdir(full):
216
+ return {"success": False, "error": f"Not a directory: {path}"}
217
+
218
+ entries = []
219
+ for name in sorted(os.listdir(full)):
220
+ entry_path = os.path.join(full, name)
221
+ stat = os.stat(entry_path)
222
+ entries.append({
223
+ "name": name,
224
+ "type": "dir" if os.path.isdir(entry_path) else "file",
225
+ "size": stat.st_size,
226
+ "path": os.path.relpath(entry_path, get_workspace_root()),
227
+ })
228
+
229
+ return {
230
+ "success": True,
231
+ "path": path,
232
+ "entries": entries,
233
+ }
234
+ except Exception as exc:
235
+ return {"success": False, "error": str(exc)}
236
+
237
+
238
+ # ─── glob ───────────────────────────────────────────────────────────────
239
+
240
+ def glob_paths(pattern: str, path: str = ".") -> dict[str, Any]:
241
+ """Glob file paths matching a pattern, recursively."""
242
+ try:
243
+ full = _resolve_safe(path)
244
+ matches: list[str] = []
245
+ for root_dir, _dirs, files in os.walk(full):
246
+ for fname in files:
247
+ if fnmatch.fnmatch(fname, pattern) or fnmatch.fnmatch(
248
+ os.path.relpath(os.path.join(root_dir, fname), full), pattern
249
+ ):
250
+ matches.append(os.path.relpath(os.path.join(root_dir, fname), get_workspace_root()))
251
+ matches.sort()
252
+ return {"success": True, "pattern": pattern, "matches": matches}
253
+ except Exception as exc:
254
+ return {"success": False, "error": str(exc)}
255
+
256
+
257
+ # ─── grep ───────────────────────────────────────────────────────────────
258
+
259
+ def grep_search(
260
+ pattern: str,
261
+ path: str = ".",
262
+ include: str | None = None,
263
+ ignore_case: bool = False,
264
+ max_results: int = 100,
265
+ ) -> dict[str, Any]:
266
+ """Search file contents with a regex pattern."""
267
+ try:
268
+ full = _resolve_safe(path)
269
+ flags = re.IGNORECASE if ignore_case else 0
270
+ regex = re.compile(pattern, flags)
271
+
272
+ matches: list[dict[str, Any]] = []
273
+ for root_dir, _dirs, files in os.walk(full):
274
+ for fname in files:
275
+ if include and not fnmatch.fnmatch(fname, include):
276
+ continue
277
+ fpath = os.path.join(root_dir, fname)
278
+ try:
279
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
280
+ for lineno, line in enumerate(f, 1):
281
+ if regex.search(line):
282
+ matches.append({
283
+ "file": os.path.relpath(fpath, get_workspace_root()),
284
+ "line": lineno,
285
+ "text": line.rstrip()[:500],
286
+ })
287
+ if len(matches) >= max_results:
288
+ return {
289
+ "success": True,
290
+ "pattern": pattern,
291
+ "matches": matches,
292
+ "truncated": True,
293
+ }
294
+ except (UnicodeDecodeError, PermissionError):
295
+ continue
296
+
297
+ return {
298
+ "success": True,
299
+ "pattern": pattern,
300
+ "matches": matches,
301
+ "truncated": False,
302
+ }
303
+ except re.error as exc:
304
+ return {"success": False, "error": f"Invalid regex: {exc}"}
305
+ except Exception as exc:
306
+ return {"success": False, "error": str(exc)}
307
+
308
+
309
+ # ─── Workspace management ───────────────────────────────────────────────
310
+
311
+ def list_workspace_tree(max_depth: int = 3) -> dict[str, Any]:
312
+ """Return a tree view of the workspace."""
313
+ try:
314
+ root = get_workspace_root()
315
+
316
+ def _walk(path: str, depth: int) -> dict[str, Any]:
317
+ if depth > max_depth:
318
+ return {"name": os.path.basename(path), "type": "dir", "truncated": True}
319
+ entries = []
320
+ try:
321
+ for name in sorted(os.listdir(path)):
322
+ full = os.path.join(path, name)
323
+ if os.path.isdir(full):
324
+ entries.append(_walk(full, depth + 1))
325
+ else:
326
+ entries.append({
327
+ "name": name,
328
+ "type": "file",
329
+ "size": os.path.getsize(full),
330
+ })
331
+ except PermissionError:
332
+ pass
333
+ return {"name": os.path.basename(path), "type": "dir", "children": entries}
334
+
335
+ tree = _walk(root, 0)
336
+ return {"success": True, "tree": tree}
337
+ except Exception as exc:
338
+ return {"success": False, "error": str(exc)}
339
+
340
+
341
+ def reset_workspace() -> dict[str, Any]:
342
+ """Clear all files in the workspace (used by /new command)."""
343
+ try:
344
+ root = get_workspace_root()
345
+ if os.path.exists(root):
346
+ for entry in os.listdir(root):
347
+ full = os.path.join(root, entry)
348
+ if os.path.isdir(full):
349
+ shutil.rmtree(full)
350
+ else:
351
+ os.remove(full)
352
+ return {"success": True, "message": "Workspace cleared"}
353
+ except Exception as exc:
354
+ return {"success": False, "error": str(exc)}
355
+
356
+
357
+ def snapshot_workspace() -> dict[str, str]:
358
+ """Return a dict of {relative_path: content} for all text files in the workspace.
359
+
360
+ Used to package workspace files for ZIP/HF deploy.
361
+ """
362
+ root = get_workspace_root()
363
+ files: dict[str, str] = {}
364
+ for dirpath, _dirs, fnames in os.walk(root):
365
+ # Skip hidden dirs and node_modules / __pycache__
366
+ parts = os.path.relpath(dirpath, root).split(os.sep)
367
+ if any(p.startswith(".") or p in {"node_modules", "__pycache__", ".venv", "venv"} for p in parts):
368
+ continue
369
+ for fname in fnames:
370
+ if fname.startswith("."):
371
+ continue
372
+ full = os.path.join(dirpath, fname)
373
+ try:
374
+ with open(full, "r", encoding="utf-8") as f:
375
+ files[os.path.relpath(full, root)] = f.read()
376
+ except (UnicodeDecodeError, PermissionError):
377
+ continue
378
+ return files
code/tools/todos.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Todo list tool β€” Claude Code-style task tracking.
2
+
3
+ Todos are persisted per-session in memory. Each todo has:
4
+ - id (string)
5
+ - content (string)
6
+ - status: pending | in_progress | completed
7
+ - priority: high | medium | low
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from typing import Any
14
+
15
+ # ─── Per-session state ──────────────────────────────────────────────────
16
+
17
+ _sessions: dict[str, list[dict[str, Any]]] = {}
18
+ _lock = threading.Lock()
19
+
20
+
21
+ def _get_session_id(session_id: str | None) -> str:
22
+ return session_id or "default"
23
+
24
+
25
+ def todo_read(session_id: str | None = None) -> dict[str, Any]:
26
+ """Read the current todo list for a session."""
27
+ sid = _get_session_id(session_id)
28
+ with _lock:
29
+ todos = list(_sessions.get(sid, []))
30
+ return {"success": True, "todos": todos}
31
+
32
+
33
+ def todo_write(
34
+ todos: list[dict[str, Any]],
35
+ session_id: str | None = None,
36
+ ) -> dict[str, Any]:
37
+ """Replace the entire todo list for a session.
38
+
39
+ Each todo: {id, content, status, priority}
40
+ """
41
+ sid = _get_session_id(session_id)
42
+
43
+ # Validate and normalize
44
+ normalized: list[dict[str, Any]] = []
45
+ for t in todos:
46
+ if not isinstance(t, dict):
47
+ continue
48
+ normalized.append({
49
+ "id": str(t.get("id", "")),
50
+ "content": str(t.get("content", "")),
51
+ "status": t.get("status", "pending") if t.get("status") in {"pending", "in_progress", "completed"} else "pending",
52
+ "priority": t.get("priority", "medium") if t.get("priority") in {"high", "medium", "low"} else "medium",
53
+ })
54
+
55
+ with _lock:
56
+ _sessions[sid] = normalized
57
+
58
+ return {"success": True, "todos": normalized, "count": len(normalized)}
59
+
60
+
61
+ def todo_update(
62
+ todo_id: str,
63
+ status: str | None = None,
64
+ content: str | None = None,
65
+ session_id: str | None = None,
66
+ ) -> dict[str, Any]:
67
+ """Update a single todo by id."""
68
+ sid = _get_session_id(session_id)
69
+ with _lock:
70
+ todos = _sessions.get(sid, [])
71
+ for t in todos:
72
+ if t["id"] == todo_id:
73
+ if status in {"pending", "in_progress", "completed"}:
74
+ t["status"] = status
75
+ if content is not None:
76
+ t["content"] = content
77
+ return {"success": True, "todo": t}
78
+ return {"success": False, "error": f"Todo not found: {todo_id}"}
79
+
80
+
81
+ def todo_clear(session_id: str | None = None) -> dict[str, Any]:
82
+ """Clear all todos for a session."""
83
+ sid = _get_session_id(session_id)
84
+ with _lock:
85
+ _sessions.pop(sid, None)
86
+ return {"success": True}
index.html CHANGED
@@ -5,9 +5,9 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SoniCoder</title>
7
  <meta name="description" content="AI-powered fullstack app generator with local model inference">
8
- <link rel="preconnect" href="https://fonts.googleapis.com">
9
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
- <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
11
  <style>
12
  /* ═══════════════════════════════════════════════════════
13
  RESET & BASE
@@ -1159,6 +1159,7 @@ body.hide-thinking .think-block { display: none; }
1159
  <button class="output-tab" data-tab="console" onclick="switchTab('console')">Console</button>
1160
  <button class="output-tab" data-tab="code" onclick="switchTab('code')">Code</button>
1161
  <button class="output-tab" data-tab="search" onclick="switchTab('search')">Search</button>
 
1162
  <button class="output-tab" data-tab="deploy" onclick="switchTab('deploy')">Deploy</button>
1163
  </div>
1164
  <div id="output-content">
@@ -1215,6 +1216,75 @@ body.hide-thinking .think-block { display: none; }
1215
  </div>
1216
  </div>
1217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1218
  <!-- Deploy Pane -->
1219
  <div class="tab-pane" id="pane-deploy">
1220
  <div class="deploy-section">
@@ -1328,6 +1398,10 @@ const state = {
1328
  currentModelType: 'text',
1329
  uploadedImageFileUrl: '',
1330
  uploadedImageName: '',
 
 
 
 
1331
  };
1332
 
1333
  // ═══════════════════════════════════════════════════════
@@ -1352,7 +1426,7 @@ document.addEventListener('DOMContentLoaded', () => {
1352
  renderExamples();
1353
 
1354
  // Welcome message
1355
- addSystemMessage('Welcome to SoniCoder. The model is loading locally (no API keys needed). Select a language and framework, then describe the app you want to build.');
1356
 
1357
  // Input auto-resize & keybinding
1358
  const input = document.getElementById('chat-input');
@@ -2195,6 +2269,451 @@ function stopGeneration() {
2195
  onGenerationEnd();
2196
  }
2197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2198
  function resetConversation(announcement) {
2199
  state.history = [];
2200
  state.executionContext = {};
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SoniCoder</title>
7
  <meta name="description" content="AI-powered fullstack app generator with local model inference">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com/">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&amp;display=swap" rel="stylesheet">
11
  <style>
12
  /* ═══════════════════════════════════════════════════════
13
  RESET & BASE
 
1159
  <button class="output-tab" data-tab="console" onclick="switchTab('console')">Console</button>
1160
  <button class="output-tab" data-tab="code" onclick="switchTab('code')">Code</button>
1161
  <button class="output-tab" data-tab="search" onclick="switchTab('search')">Search</button>
1162
+ <button class="output-tab" data-tab="agent" onclick="switchTab('agent')">Agent</button>
1163
  <button class="output-tab" data-tab="deploy" onclick="switchTab('deploy')">Deploy</button>
1164
  </div>
1165
  <div id="output-content">
 
1216
  </div>
1217
  </div>
1218
 
1219
+ <!-- Agent Pane (Claude Code-style) -->
1220
+ <div class="tab-pane" id="pane-agent">
1221
+ <div class="deploy-section">
1222
+ <div class="deploy-title">&#129302; Agent Mode (Claude Code-style)</div>
1223
+
1224
+ <!-- Agent mode toggle -->
1225
+ <div class="deploy-field">
1226
+ <label>Agent Mode</label>
1227
+ <div style="display:flex;align-items:center;gap:12px;">
1228
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400;">
1229
+ <input type="checkbox" id="agent-mode-toggle" onchange="toggleAgentMode()" style="cursor:pointer;">
1230
+ <span>Enable agent loop (model calls tools: read/write/edit/glob/grep/bash/todos)</span>
1231
+ </label>
1232
+ </div>
1233
+ <div class="deploy-hint">When ON, the model can manipulate files in the sandboxed workspace and run shell commands.</div>
1234
+ </div>
1235
+
1236
+ <!-- Slash Commands -->
1237
+ <div class="deploy-field">
1238
+ <label>Slash Commands</label>
1239
+ <div id="commands-list" style="display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:11px;"></div>
1240
+ <div class="deploy-hint">Type the command in chat (e.g. <code>/commit</code>, <code>/review</code>)</div>
1241
+ </div>
1242
+
1243
+ <!-- Skills -->
1244
+ <div class="deploy-field">
1245
+ <label>Skills</label>
1246
+ <div id="skills-list" style="display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:11px;"></div>
1247
+ <div class="deploy-hint">Click a skill to activate it for the next prompt</div>
1248
+ </div>
1249
+
1250
+ <!-- Active skills display -->
1251
+ <div class="deploy-field" id="active-skills-section" style="display:none;">
1252
+ <label>Active Skills</label>
1253
+ <div id="active-skills-display" style="display:flex;flex-wrap:wrap;gap:4px;"></div>
1254
+ <button onclick="clearActiveSkills()" style="margin-top:6px;font-size:10px;color:var(--red);background:none;border:1px solid var(--red);padding:2px 8px;cursor:pointer;border-radius:3px;">Clear all</button>
1255
+ </div>
1256
+
1257
+ <!-- Hooks -->
1258
+ <div class="deploy-field">
1259
+ <label>Hooks (active rules)</label>
1260
+ <div id="hooks-list" style="display:grid;grid-template-columns:1fr;gap:4px;font-size:11px;"></div>
1261
+ <div class="deploy-hint">Rules fire on bash/file/prompt events. Add custom rules in <code>workspace/.sonicoder/hooks/</code></div>
1262
+ </div>
1263
+
1264
+ <!-- Todo List -->
1265
+ <div class="deploy-field">
1266
+ <label>Todo List</label>
1267
+ <div id="todos-display" style="font-size:11px;max-height:200px;overflow-y:auto;">
1268
+ <div style="color:var(--gray-dim);">No todos yet. Use the agent to create some.</div>
1269
+ </div>
1270
+ <button onclick="refreshTodos()" style="margin-top:6px;font-size:10px;background:none;border:1px solid var(--border);padding:2px 8px;cursor:pointer;border-radius:3px;color:var(--gray-light);">Refresh</button>
1271
+ </div>
1272
+
1273
+ <!-- Workspace -->
1274
+ <div class="deploy-field">
1275
+ <label>Workspace Files</label>
1276
+ <div id="workspace-tree" style="font-size:11px;max-height:300px;overflow-y:auto;font-family:var(--font-mono);background:var(--bg-code);padding:8px;border-radius:4px;border:1px solid var(--border);">
1277
+ <div style="color:var(--gray-dim);">Empty. The agent will create files here.</div>
1278
+ </div>
1279
+ <div style="display:flex;gap:6px;margin-top:6px;">
1280
+ <button onclick="refreshWorkspace()" style="font-size:10px;background:none;border:1px solid var(--border);padding:2px 8px;cursor:pointer;border-radius:3px;color:var(--gray-light);">Refresh</button>
1281
+ <button onclick="resetWorkspace()" style="font-size:10px;background:none;border:1px solid var(--red);padding:2px 8px;cursor:pointer;border-radius:3px;color:var(--red);">Reset workspace</button>
1282
+ </div>
1283
+ <div class="deploy-hint">Files live in <code>./workspace/</code> β€” sandboxed, path-escape protected</div>
1284
+ </div>
1285
+ </div>
1286
+ </div>
1287
+
1288
  <!-- Deploy Pane -->
1289
  <div class="tab-pane" id="pane-deploy">
1290
  <div class="deploy-section">
 
1398
  currentModelType: 'text',
1399
  uploadedImageFileUrl: '',
1400
  uploadedImageName: '',
1401
+ // Agent mode (Claude Code-style)
1402
+ agentMode: false,
1403
+ activeSkills: [],
1404
+ todos: [],
1405
  };
1406
 
1407
  // ═══════════════════════════════════════════════════════
 
1426
  renderExamples();
1427
 
1428
  // Welcome message
1429
+ addSystemMessage('Welcome to SoniCoder β€” a Claude Code-style agent running locally. The model is loading (no API keys needed). Open the "Agent" tab to enable tool use (read/write/edit/glob/grep/bash), browse skills and slash commands, or just describe the app you want to build.');
1430
 
1431
  // Input auto-resize & keybinding
1432
  const input = document.getElementById('chat-input');
 
2269
  onGenerationEnd();
2270
  }
2271
 
2272
+ // ═══════════════════════════════════════════════════════
2273
+ // AGENT MODE (Claude Code-style)
2274
+ // ═══════════════════════════════════════════════════════
2275
+
2276
+ async function callAgentApi(name, data) {
2277
+ // Call a Gradio API endpoint and return an event source
2278
+ const resp = await fetch(`/gradio_api/call/${name}`, {
2279
+ method: 'POST',
2280
+ headers: { 'Content-Type': 'application/json' },
2281
+ body: JSON.stringify({ data: data }),
2282
+ });
2283
+ if (!resp.ok) throw new Error(`API ${name} failed: ${resp.status}`);
2284
+ const { event_id } = await resp.json();
2285
+ return new EventSource(`/gradio_api/call/${name}/${event_id}`);
2286
+ }
2287
+
2288
+ function toggleAgentMode() {
2289
+ state.agentMode = document.getElementById('agent-mode-toggle').checked;
2290
+ if (state.agentMode) {
2291
+ addSystemMessage('πŸ€– Agent mode enabled. The model can now call tools (read_file, write_file, edit_file, bash, etc.) and manipulate the workspace. Slash commands like /commit, /review, /feature are also active.');
2292
+ // Refresh workspace + todos + skills display
2293
+ refreshWorkspace();
2294
+ refreshTodos();
2295
+ } else {
2296
+ addSystemMessage('Agent mode disabled. Back to standard chat mode.');
2297
+ }
2298
+ }
2299
+
2300
+ function renderSkillsList() {
2301
+ const container = document.getElementById('skills-list');
2302
+ if (!container) return;
2303
+ const skills = (CONFIG.skills || []);
2304
+ if (skills.length === 0) {
2305
+ container.innerHTML = '<div style="color:var(--gray-dim);grid-column:1/-1;">No skills available.</div>';
2306
+ return;
2307
+ }
2308
+ container.innerHTML = skills.map(s => {
2309
+ const isActive = state.activeSkills.includes(s.name);
2310
+ const bg = isActive ? 'var(--green-dim)' : 'var(--bg-code)';
2311
+ const color = isActive ? 'var(--green)' : 'var(--gray-light)';
2312
+ const border = isActive ? 'var(--green)' : 'var(--border)';
2313
+ return `<div style="padding:6px 8px;background:${bg};color:${color};border:1px solid ${border};border-radius:3px;cursor:pointer;" onclick="toggleSkill('${s.name}')">
2314
+ <div style="font-weight:600;">${s.name}</div>
2315
+ <div style="font-size:10px;color:var(--gray-mid);margin-top:2px;">${(s.description || '').slice(0, 80)}</div>
2316
+ </div>`;
2317
+ }).join('');
2318
+ }
2319
+
2320
+ function toggleSkill(name) {
2321
+ const idx = state.activeSkills.indexOf(name);
2322
+ if (idx >= 0) {
2323
+ state.activeSkills.splice(idx, 1);
2324
+ } else {
2325
+ state.activeSkills.push(name);
2326
+ }
2327
+ renderSkillsList();
2328
+ renderActiveSkills();
2329
+ }
2330
+
2331
+ function renderActiveSkills() {
2332
+ const section = document.getElementById('active-skills-section');
2333
+ const display = document.getElementById('active-skills-display');
2334
+ if (!section || !display) return;
2335
+ if (state.activeSkills.length === 0) {
2336
+ section.style.display = 'none';
2337
+ return;
2338
+ }
2339
+ section.style.display = 'block';
2340
+ display.innerHTML = state.activeSkills.map(name =>
2341
+ `<span style="background:var(--green-dim);color:var(--green);padding:2px 8px;border:1px solid var(--green);border-radius:3px;font-size:10px;">${name} <span style="cursor:pointer;margin-left:4px;" onclick="toggleSkill('${name}')">x</span></span>`
2342
+ ).join('');
2343
+ }
2344
+
2345
+ function clearActiveSkills() {
2346
+ state.activeSkills = [];
2347
+ renderSkillsList();
2348
+ renderActiveSkills();
2349
+ }
2350
+
2351
+ function renderCommandsList() {
2352
+ const container = document.getElementById('commands-list');
2353
+ if (!container) return;
2354
+ const commands = (CONFIG.commands || []);
2355
+ if (commands.length === 0) {
2356
+ container.innerHTML = '<div style="color:var(--gray-dim);grid-column:1/-1;">No commands available.</div>';
2357
+ return;
2358
+ }
2359
+ container.innerHTML = commands.map(c =>
2360
+ `<div style="padding:6px 8px;background:var(--bg-code);border:1px solid var(--border);border-radius:3px;">
2361
+ <div style="font-weight:600;color:var(--cyan);">/${c.name}</div>
2362
+ <div style="font-size:10px;color:var(--gray-mid);margin-top:2px;">${(c.description || '').slice(0, 80)}</div>
2363
+ </div>`
2364
+ ).join('');
2365
+ }
2366
+
2367
+ function renderHooksList() {
2368
+ const container = document.getElementById('hooks-list');
2369
+ if (!container) return;
2370
+ const hooks = (CONFIG.hooks || []);
2371
+ if (hooks.length === 0) {
2372
+ container.innerHTML = '<div style="color:var(--gray-dim);">No hooks configured.</div>';
2373
+ return;
2374
+ }
2375
+ container.innerHTML = hooks.map(h => {
2376
+ const enabledColor = h.enabled ? 'var(--green)' : 'var(--gray-dim)';
2377
+ const actionColor = h.action === 'block' ? 'var(--red)' : 'var(--amber)';
2378
+ return `<div style="padding:6px 8px;background:var(--bg-code);border:1px solid var(--border);border-radius:3px;">
2379
+ <div style="display:flex;justify-content:space-between;align-items:center;">
2380
+ <span style="font-weight:600;color:${enabledColor};">${h.enabled ? 'ON' : 'OFF'} ${h.name}</span>
2381
+ <span style="font-size:10px;color:${actionColor};">[${h.action}] ${h.event}</span>
2382
+ </div>
2383
+ <div style="font-size:10px;color:var(--gray-mid);margin-top:2px;font-family:var(--font-mono);">${(h.pattern || '').slice(0, 80)}</div>
2384
+ </div>`;
2385
+ }).join('');
2386
+ }
2387
+
2388
+ async function refreshTodos() {
2389
+ try {
2390
+ const es = await callAgentApi('todo_read', ['default']);
2391
+ es.addEventListener('complete', (e) => {
2392
+ try {
2393
+ const data = JSON.parse(e.data);
2394
+ const result = JSON.parse(data[0]);
2395
+ state.todos = result.todos || [];
2396
+ renderTodos();
2397
+ } catch (err) { console.error('Todo refresh error:', err); }
2398
+ es.close();
2399
+ });
2400
+ es.addEventListener('error', () => es.close());
2401
+ } catch (err) { console.error('refreshTodos failed:', err); }
2402
+ }
2403
+
2404
+ function renderTodos() {
2405
+ const container = document.getElementById('todos-display');
2406
+ if (!container) return;
2407
+ if (state.todos.length === 0) {
2408
+ container.innerHTML = '<div style="color:var(--gray-dim);">No todos yet. Use the agent to create some.</div>';
2409
+ return;
2410
+ }
2411
+ const statusColor = { pending: 'var(--gray-mid)', in_progress: 'var(--amber)', completed: 'var(--green)' };
2412
+ const statusIcon = { pending: 'β—‹', in_progress: '◐', completed: '●' };
2413
+ container.innerHTML = state.todos.map(t =>
2414
+ `<div style="padding:4px 0;border-bottom:1px solid var(--border);">
2415
+ <span style="color:${statusColor[t.status] || 'var(--gray-mid)'};">${statusIcon[t.status] || 'β—‹'}</span>
2416
+ <span style="margin-left:6px;color:var(--gray-light);">[${t.priority || 'med'}] ${t.content || t.id}</span>
2417
+ </div>`
2418
+ ).join('');
2419
+ }
2420
+
2421
+ async function refreshWorkspace() {
2422
+ try {
2423
+ const es = await callAgentApi('workspace_tree', []);
2424
+ es.addEventListener('complete', (e) => {
2425
+ try {
2426
+ const data = JSON.parse(e.data);
2427
+ const result = JSON.parse(data[0]);
2428
+ if (result.success) {
2429
+ renderWorkspaceTree(result.tree);
2430
+ }
2431
+ } catch (err) { console.error('Workspace refresh error:', err); }
2432
+ es.close();
2433
+ });
2434
+ es.addEventListener('error', () => es.close());
2435
+ } catch (err) { console.error('refreshWorkspace failed:', err); }
2436
+ }
2437
+
2438
+ function renderWorkspaceTree(node, depth = 0) {
2439
+ const container = document.getElementById('workspace-tree');
2440
+ if (!container) return;
2441
+ if (depth === 0) {
2442
+ if (!node || (node.children || []).length === 0) {
2443
+ container.innerHTML = '<div style="color:var(--gray-dim);">Empty. The agent will create files here.</div>';
2444
+ return;
2445
+ }
2446
+ container.innerHTML = renderWorkspaceNode(node, 0);
2447
+ }
2448
+ }
2449
+
2450
+ function renderWorkspaceNode(node, depth) {
2451
+ const indent = '&nbsp;'.repeat(depth * 2);
2452
+ if (node.type === 'dir') {
2453
+ const children = (node.children || []).map(c => renderWorkspaceNode(c, depth + 1)).join('');
2454
+ return `<div>${indent}<span style="color:var(--cyan);">πŸ“ ${node.name}</span></div>${children}`;
2455
+ } else {
2456
+ const size = node.size ? `${(node.size / 1024).toFixed(1)}KB` : '';
2457
+ return `<div>${indent}<span style="color:var(--gray-light);">πŸ“„ ${node.name}</span> <span style="color:var(--gray-dim);font-size:10px;">${size}</span></div>`;
2458
+ }
2459
+ }
2460
+
2461
+ async function resetWorkspace() {
2462
+ if (!confirm('Reset the workspace? All files will be deleted.')) return;
2463
+ try {
2464
+ const es = await callAgentApi('workspace_reset', []);
2465
+ es.addEventListener('complete', (e) => {
2466
+ try {
2467
+ const data = JSON.parse(e.data);
2468
+ const result = JSON.parse(data[0]);
2469
+ addSystemMessage('Workspace cleared.');
2470
+ refreshWorkspace();
2471
+ } catch (err) { console.error(err); }
2472
+ es.close();
2473
+ });
2474
+ es.addEventListener('error', () => es.close());
2475
+ } catch (err) { console.error('resetWorkspace failed:', err); }
2476
+ }
2477
+
2478
+ // Override sendMessage to support agent mode
2479
+ const originalSendMessage = sendMessage;
2480
+
2481
+ async function sendMessageAgent(prompt) {
2482
+ if (state.isGenerating) return;
2483
+ if (!state.modelReady) {
2484
+ addSystemMessage('The model is still loading. Please wait...');
2485
+ return;
2486
+ }
2487
+
2488
+ state.isGenerating = true;
2489
+ toggleInputState(true);
2490
+ addUserMessage(prompt);
2491
+ addAssistantMessage();
2492
+ renderStatus('Running agent...', 'working');
2493
+
2494
+ const historyJSON = JSON.stringify(state.history.slice(0, -2)); // exclude just-added user+assistant placeholders
2495
+ const framework = document.getElementById('framework-select')?.value || state.targetFramework;
2496
+ const skillsJSON = JSON.stringify(state.activeSkills || []);
2497
+
2498
+ try {
2499
+ const resp = await fetch('/gradio_api/call/agent_run', {
2500
+ method: 'POST',
2501
+ headers: { 'Content-Type': 'application/json' },
2502
+ body: JSON.stringify({
2503
+ data: [prompt, state.targetLanguage, framework, historyJSON, skillsJSON, state.searchEnabled ? 'true' : 'false', state.uploadedImageFileUrl || '']
2504
+ })
2505
+ });
2506
+
2507
+ if (!resp.ok) throw new Error(`API error: ${resp.status} ${resp.statusText}`);
2508
+
2509
+ const { event_id } = await resp.json();
2510
+ const eventSource = new EventSource(`/gradio_api/call/agent_run/${event_id}`);
2511
+ state.currentEventSource = eventSource;
2512
+
2513
+ let lastContent = '';
2514
+
2515
+ eventSource.addEventListener('generating', (e) => {
2516
+ try {
2517
+ const dataArray = JSON.parse(e.data);
2518
+ const event = JSON.parse(dataArray[0]);
2519
+ handleAgentEvent(event);
2520
+ } catch (err) { console.error('Parse error (generating):', err); }
2521
+ });
2522
+
2523
+ eventSource.addEventListener('complete', (e) => {
2524
+ try {
2525
+ const dataArray = JSON.parse(e.data);
2526
+ const event = JSON.parse(dataArray[0]);
2527
+ handleAgentEvent(event);
2528
+ } catch (err) { console.error('Parse error (complete):', err); }
2529
+ eventSource.close();
2530
+ onGenerationEnd();
2531
+ // Refresh workspace + todos after agent run
2532
+ refreshWorkspace();
2533
+ refreshTodos();
2534
+ });
2535
+
2536
+ eventSource.addEventListener('error', (e) => {
2537
+ let errorMsg = 'Agent error.';
2538
+ if (e.data) errorMsg = e.data;
2539
+ console.error('SSE error:', errorMsg);
2540
+ finalizeAssistantMessage();
2541
+ addSystemMessage(`Error: ${errorMsg}`);
2542
+ renderStatus('Error', 'error');
2543
+ eventSource.close();
2544
+ onGenerationEnd();
2545
+ });
2546
+
2547
+ } catch (err) {
2548
+ console.error('Agent send error:', err);
2549
+ finalizeAssistantMessage();
2550
+ addSystemMessage(`Error: ${err.message}`);
2551
+ renderStatus('Error', 'error');
2552
+ onGenerationEnd();
2553
+ }
2554
+ }
2555
+
2556
+ function handleAgentEvent(event) {
2557
+ if (!event || typeof event !== 'object') return;
2558
+
2559
+ switch (event.type) {
2560
+ case 'status':
2561
+ renderStatus(event.status_text || 'Working...', event.status_state || 'working');
2562
+ break;
2563
+
2564
+ case 'streaming':
2565
+ // Update the current assistant message with streaming content
2566
+ const msgs = document.querySelectorAll('.chat-message.assistant .message-content');
2567
+ const last = msgs[msgs.length - 1];
2568
+ if (last) {
2569
+ last.innerHTML = window.renderMarkdown ? renderMarkdown(event.content || '') : (event.content || '').replace(/\n/g, '<br>');
2570
+ }
2571
+ // Update history
2572
+ if (state.history.length > 0 && state.history[state.history.length - 1].role === 'assistant') {
2573
+ state.history[state.history.length - 1].content = event.content || '';
2574
+ }
2575
+ renderStatus(`Thinking (step ${event.iteration || 1})...`, 'working');
2576
+ break;
2577
+
2578
+ case 'tool_call':
2579
+ addSystemMessage(`πŸ”§ Calling tool: ${event.tool}` + (event.args && Object.keys(event.args).length ? `(${JSON.stringify(event.args).slice(0, 200)})` : ''));
2580
+ renderStatus(`Running tool: ${event.tool}...`, 'working');
2581
+ break;
2582
+
2583
+ case 'tool_result':
2584
+ const result = event.result || {};
2585
+ const success = result.success !== false;
2586
+ const summary = success ? 'βœ“' : 'βœ—';
2587
+ let resultPreview = '';
2588
+ if (result.stdout) resultPreview = result.stdout.slice(0, 200);
2589
+ else if (result.content) resultPreview = result.content.slice(0, 200);
2590
+ else if (result.error) resultPreview = result.error;
2591
+ else if (result.entries) resultPreview = `${result.entries.length} entries`;
2592
+ else if (result.matches) resultPreview = `${result.matches.length} matches`;
2593
+ else if (result.count !== undefined) resultPreview = `${result.count} items`;
2594
+ else resultPreview = JSON.stringify(result).slice(0, 200);
2595
+
2596
+ // Show hook warnings if any
2597
+ if (result.hook_warnings && result.hook_warnings.length > 0) {
2598
+ for (const w of result.hook_warnings) {
2599
+ addSystemMessage(`⚠️ Hook: ${w.slice(0, 300)}`);
2600
+ }
2601
+ }
2602
+
2603
+ addSystemMessage(`${summary} ${event.tool}: ${resultPreview}${resultPreview.length >= 200 ? '...' : ''}`);
2604
+ break;
2605
+
2606
+ case 'search_results':
2607
+ state.currentSearchResults = event.results || [];
2608
+ renderStatus(`Found ${event.results?.length || 0} results, running agent...`, 'working');
2609
+ break;
2610
+
2611
+ case 'complete':
2612
+ // Finalize the assistant message
2613
+ const content = event.content || '';
2614
+ if (state.history.length > 0 && state.history[state.history.length - 1].role === 'assistant') {
2615
+ state.history[state.history.length - 1].content = content;
2616
+ }
2617
+ finalizeAssistantMessage();
2618
+
2619
+ // Try to extract code from the response
2620
+ tryExtractCodeFromResponse(content);
2621
+
2622
+ renderStatus('Done', 'success');
2623
+ break;
2624
+
2625
+ case 'error':
2626
+ finalizeAssistantMessage();
2627
+ addSystemMessage(`Error: ${event.message || 'Unknown error'}`);
2628
+ if (event.available && event.available.length) {
2629
+ addSystemMessage('Available: ' + event.available.join(', '));
2630
+ }
2631
+ renderStatus('Error', 'error');
2632
+ break;
2633
+ }
2634
+ }
2635
+
2636
+ function tryExtractCodeFromResponse(content) {
2637
+ // Reuse the existing code extraction logic by simulating a chat payload
2638
+ if (!content) return;
2639
+ // Strip tool call blocks for display
2640
+ const cleanContent = content.replace(/```tool\s*\n.*?```/gs, '').trim();
2641
+ if (!cleanContent) return;
2642
+
2643
+ // Try to extract code blocks
2644
+ const codeMatch = cleanContent.match(/```([a-zA-Z0-9_+.#-]*)\s*\n(.*?)```/s);
2645
+ if (codeMatch) {
2646
+ const code = codeMatch[2].trim();
2647
+ const lang = codeMatch[1].toLowerCase();
2648
+ state.lastCode = code;
2649
+ state.lastCodeLang = lang;
2650
+ // Update code tab
2651
+ const codeDisplay = document.getElementById('code-display');
2652
+ if (codeDisplay) {
2653
+ codeDisplay.innerHTML = `<pre><code class="language-${lang}">${escapeHtml(code)}</code></pre>`;
2654
+ }
2655
+ document.getElementById('code-tab-lang').textContent = lang || 'text';
2656
+ document.getElementById('btn-download').style.display = 'inline-block';
2657
+
2658
+ // If HTML, show in preview
2659
+ if (lang === 'html' || /^<!doctype|<html/i.test(code)) {
2660
+ const iframe = document.getElementById('preview-iframe');
2661
+ if (iframe) {
2662
+ iframe.srcdoc = code;
2663
+ document.getElementById('preview-placeholder').style.display = 'none';
2664
+ document.getElementById('preview-image').style.display = 'none';
2665
+ iframe.style.display = 'block';
2666
+ }
2667
+ }
2668
+ }
2669
+
2670
+ // Try multi-file extraction
2671
+ const fileMatch = cleanContent.match(/@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)/s);
2672
+ if (fileMatch) {
2673
+ // Build project files
2674
+ const files = {};
2675
+ const fileRegex = /@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)/gs;
2676
+ let m;
2677
+ while ((m = fileRegex.exec(cleanContent)) !== null) {
2678
+ files[m[1].trim()] = m[2].trim();
2679
+ }
2680
+ if (Object.keys(files).length > 0) {
2681
+ state.executionContext = state.executionContext || {};
2682
+ state.executionContext.project_files = files;
2683
+ // Update project files display in deploy tab
2684
+ const pf = document.getElementById('project-files');
2685
+ if (pf) {
2686
+ pf.innerHTML = '<div style="font-size:11px;color:var(--gray-mid);margin-top:8px;">' +
2687
+ Object.keys(files).map(f => `<div>πŸ“„ ${f}</div>`).join('') +
2688
+ '</div>';
2689
+ }
2690
+ }
2691
+ }
2692
+ }
2693
+
2694
+ function escapeHtml(s) {
2695
+ return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2696
+ }
2697
+
2698
+ // Override sendMessage to route to agent when agentMode is on
2699
+ sendMessage = async function(prompt) {
2700
+ if (state.agentMode) {
2701
+ return sendMessageAgent(prompt);
2702
+ }
2703
+ return originalSendMessage(prompt);
2704
+ };
2705
+
2706
+ // Initialize agent UI on page load
2707
+ document.addEventListener('DOMContentLoaded', () => {
2708
+ // Render skills/commands/hooks after CONFIG is loaded
2709
+ setTimeout(() => {
2710
+ renderSkillsList();
2711
+ renderCommandsList();
2712
+ renderHooksList();
2713
+ renderActiveSkills();
2714
+ }, 100);
2715
+ });
2716
+
2717
  function resetConversation(announcement) {
2718
  state.history = [];
2719
  state.executionContext = {};
requirements.txt CHANGED
@@ -8,3 +8,5 @@ requests>=2.31.0
8
  beautifulsoup4>=4.12.0
9
  Pillow>=10.0
10
  torchvision>=0.16.0
 
 
 
8
  beautifulsoup4>=4.12.0
9
  Pillow>=10.0
10
  torchvision>=0.16.0
11
+ # New deps for agent features (most are stdlib in 3.11+)
12
+ # (No new external deps required β€” agent/skills/hooks/commands use stdlib only)