Z User commited on
Commit
5249791
·
1 Parent(s): e6e75d6

🚀 SoniCoder v2.1.0 - Major Enhancement Release

Browse files

NEW FEATURES:
- 🤖 Added DeepSeek-Coder-1.3B model support (4 models total)
- 💾 Session management with JSON/Markdown/HTML export
- 📝 Code templates system (Flask, React, Gradio, Express)
- ⚡ Response caching and performance optimizations
- 📊 Inference metrics tracking (TTFT, tokens/sec)
- 🔒 Enhanced security with regex-based command blocking
- 🛠️ Command audit trail for all bash executions

TECHNICAL IMPROVEMENTS:
- Model loading progress with callbacks
- Better error handling and recovery
- Memory optimization utilities
- Graceful shutdown handling
- Token usage estimation
- Conversation summarization for long contexts

FILES MODIFIED/CREATED:
- code/config/constants.py: New models, templates, security settings
- code/model/loader.py: Progress tracking, memory optimization
- code/model/inference.py: Caching, metrics, validation
- code/tools/bash.py: Audit trail, enhanced security
- code/server/chat_helpers.py: Context management
- code/server/session_manager.py: NEW - Session persistence & export
- app.py: Enhanced startup with session management
- README.md: Complete documentation rewrite
- CLAUDE.md: Updated project memory

CLAUDE.md CHANGED
@@ -1,58 +1,73 @@
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, supports custom agents)
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
- │ └── github.py ← GitHub repo import (shallow clone + strip heavy dirs)
31
  ├── skills/
32
- │ ├── __init__.py ← Skill discovery + loading
33
- │ └── builtins/ ← Built-in skills (markdown)
34
- ├── agents/ ← Custom Agent system (AI-generated personas)
35
- │ ├── __init__.py ← Agent CRUD + system-prompt builder
36
- │ └── builtins/ ← Built-in agents (code-reviewer, test-writer)
37
  ├── commands/
38
- │ ├── __init__.py ← Slash command parser + expander
39
- │ └── builtins/ ← Built-in commands (markdown, includes /agent and /github)
40
  ├── hooks/
41
- │ ├── __init__.py ← Hook rule engine
42
- │ └── builtins/ ← Built-in hook rules (markdown)
43
  ├── execution/
44
- │ ├── code_extractor.py ← Code extraction from model output
45
- │ ├── python_runner.py ← Sandboxed Python execution
46
- │ └── gradio_runner.py ← Gradio app subprocess runner
47
  ├���─ huggingface/
48
- │ ├── dockerfile_gen.py ← Auto Dockerfile/package.json for JS
49
- │ └── push.py ← HF Hub push + ZIP packaging
50
- ├── websearch/google_scraper.py ← DuckDuckGo + Google scraping (no API)
51
- ── server/
52
- ├── chat_helpers.py Chat history + prompt building
53
- ── routes.py All HTTP + API endpoints
54
- index.html Frontend (single-file SPA)
55
- workspace/ Sandboxed agent workspace (auto-created)
 
 
 
56
  ```
57
 
58
  ## Conventions
@@ -65,20 +80,28 @@ workspace/ ← Sandboxed agent workspace (auto-created)
65
  - **Tests**: pytest, in `tests/` directory, `test_*.py` naming
66
  - **Frontend**: single-file HTML with inline CSS/JS, no build step
67
 
68
- ## Server rules
69
 
70
  - All servers bind to `0.0.0.0` (never `localhost`)
71
  - Default port: `7860` (HF Spaces convention)
72
  - Sub-servers use `7861`, `7862`, etc.
 
73
 
74
- ## Model
75
 
76
- - Default: `openbmb/MiniCPM5-1B` (text-only, 2.17 GB)
77
- - Optional: `openbmb/MiniCPM-V-4.6` (vision + text, 2.8 GB)
 
 
 
 
 
 
78
  - Loaded in background thread on startup
79
  - Streaming inference via `TextIteratorStreamer`
 
80
 
81
- ## Tool call format
82
 
83
  The model calls tools by emitting fenced code blocks with `tool` as the language:
84
 
@@ -98,7 +121,7 @@ content: |
98
  pass
99
  ```
100
 
101
- ## Slash commands
102
 
103
  | Command | Description |
104
  |---------|-------------|
@@ -113,168 +136,96 @@ content: |
113
  | `/agent create <desc>` | AI generates a custom agent from natural-language description |
114
  | `/agent use <name>` | Activate a saved agent |
115
  | `/agent list` | List all saved agents |
116
- | `/agent show <name>` | Show an agent's full definition |
117
- | `/agent delete <name>` | Delete a user-defined agent |
118
- | `/agent reset` | Reset to default SoniCoder persona |
119
  | `/github <url> [subdir] [--branch <name>] [--into <path>]` | Import a GitHub repo into the workspace |
120
  | `/help` | Show available commands and skills |
121
 
122
- ## Custom Agents
123
 
124
- Custom agents are AI-generated personas layered on top of the base SoniCoder
125
- system prompt. They can restrict the tool whitelist, auto-load skills, and
126
- override temperature / max-iterations.
127
 
128
- ### Agent file format
 
 
 
 
 
129
 
130
- Agents live in `workspace/.sonicoder/agents/<name>/AGENT.md` (built-ins in
131
- `code/agents/builtins/<name>/AGENT.md`). Format:
132
 
133
- ```markdown
134
- ---
135
- name: my-agent
136
- description: One-line description
137
- tools: read_file, list_dir, grep, bash
138
- skills: code-review
139
- temperature: 0.2
140
- max_iterations: 12
141
- tags: review, quality
142
- author: AI-generated
143
- created: 2026-06-20
144
- ---
145
 
146
- # My Agent
147
 
148
- Full system-prompt extension here. Define the persona, workflow, output format,
149
- and any hard rules.
150
- ```
 
151
 
152
- ### How `/agent create` works
153
 
154
- 1. User runs `/agent create a security reviewer that flags hardcoded secrets`.
155
- 2. The slash-command expansion substitutes `AGENT_GENERATION_PROMPT` (defined
156
- in `code/agents/__init__.py`) into the prompt.
157
- 3. The base SoniCoder model (NOT a custom agent) authors an `AGENT.md` file
158
- via `write_file` and saves it under `.sonicoder/agents/<name>/`.
159
- 4. The user can then `/agent use <name>` or click the agent in the UI.
 
 
160
 
161
- ### Built-in agents
162
 
163
- | Agent | Description |
164
- |-------|-------------|
165
- | `code-reviewer` | Read-only reviewer, structured issues table output |
166
- | `test-writer` | Generates pytest/jest tests, runs them, iterates until green |
167
 
168
- ### API endpoints
 
 
 
169
 
170
- | Endpoint | Description |
171
- |----------|-------------|
172
- | `list_agents` | List all agents (builtins + user) and the active one |
173
- | `get_agent(name)` | Get a single agent's full definition |
174
- | `save_agent(...)` | Create or overwrite a user agent (manual save) |
175
- | `delete_agent(name)` | Delete a user agent (built-ins protected) |
176
- | `set_active_agent(name)` | Set/clear the active agent for subsequent prompts |
177
- | `import_github(url, branch, subdir, target_subdir, depth, timeout)` | Clone a GitHub repo into the workspace (shallow, heavy dirs stripped) |
178
- | `github_url_examples()` | Return accepted GitHub URL formats |
179
- | `push_github(repo_name, github_token, username, branch?, commit_message?, timeout?)` | Snapshot workspace → commit → push to a GitHub repo |
180
-
181
- The `agent_run` endpoint also intercepts `/agent use|reset|delete|list` and
182
- dispatches them directly to the agents module, bypassing the model entirely
183
- for instant session-state updates.
184
-
185
- ## GitHub Import
186
-
187
- SoniCoder can clone any public GitHub repo into the workspace, allowing the
188
- agent to read, edit, extend, or refactor real-world code.
189
-
190
- ### How it works
191
-
192
- 1. User submits a GitHub URL via the Agent tab UI box (or via `/github <url>`
193
- slash command in chat while Agent mode is ON).
194
- 2. The backend (`code/tools/github.py::import_github_repo`) parses the URL
195
- (supports HTTPS, SSH, and `/tree/<branch>/<subdir>` forms) and validates
196
- that the host is `github.com`.
197
- 3. The repo is shallow-cloned (`git clone --depth 1 --single-branch`) into a
198
- temp directory.
199
- 4. Files are *copied* into the workspace (root or `target_subdir`) with these
200
- directories stripped: `.git`, `.hg`, `.svn`, `node_modules`, `__pycache__`,
201
- `.venv`, `venv`, `env`, `.tox`, `.mypy_cache`, `.pytest_cache`,
202
- `.ruff_cache`, `dist`, `build`, `.next`, `.nuxt`, `.cache`, `.gradle`,
203
- `target`, `Pods`. `.DS_Store` and `Thumbs.db` are also dropped.
204
- 5. The workspace tree refreshes; Agent mode auto-enables if it wasn't already.
205
-
206
- ### Security
207
-
208
- - Only `github.com` URLs are accepted (HTTPS or SSH form).
209
- - `target_subdir` is sanitized — no path escapes.
210
- - The upstream repo is never modified (clone happens in temp dir, then
211
- copied). The `.git` directory is stripped so the agent doesn't walk it.
212
- - Default clone timeout: 120s (UI uses 180s). Max: 600s.
213
-
214
- ### Slash command
215
 
216
- ```
217
- /github <url> [subdir] [--branch <name>] [--into <path>] [--depth <N>] [--timeout <s>]
218
- ```
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
- The slash command (defined in `code/commands/builtins/github.md`) instructs
221
- the agent to invoke `import_github_repo` via bash, then list the top-level
222
- files and suggest next steps based on what was imported.
223
-
224
- ## GitHub Push
225
-
226
- Push the current workspace back to a GitHub repo as a commit. Only 3 inputs
227
- are required: repo name, GitHub token, username.
228
-
229
- ### How it works (`code/tools/github.py::push_to_github`)
230
-
231
- 1. Snapshot the workspace via `snapshot_workspace()` (returns a
232
- `{relative_path: content}` dict).
233
- 2. Create a temp dir, `git init -b <branch>` inside it, write the snapshot
234
- files in, `git add -A` + `git commit -m <message>`.
235
- 3. Build a push URL of the form
236
- `https://<username>:<token>@github.com/<owner>/<repo>.git` and run
237
- `git push --force-with-lease <url> <branch>`.
238
- 4. If `--force-with-lease` fails because the remote has no refs yet
239
- (brand-new empty repo), retry with a plain `git push`.
240
- 5. Delete the temp dir. The token is never logged; error messages scrub it
241
- before being returned.
242
-
243
- ### Why `--force-with-lease`
244
-
245
- SoniCoder treats the workspace as the source of truth. `--force-with-lease`
246
- replaces the remote tip with the workspace snapshot, but fails loudly (rather
247
- than silently clobbering) if someone else pushed commits in the meantime —
248
- because the temp repo has no reflog of the remote tip, the lease fails and
249
- the user is told to pull first.
250
-
251
- ### UI
252
-
253
- Located in the **Deploy** tab, in a "Push Update to GitHub" section below the
254
- HuggingFace section. Only 3 fields are required: repo name, token, username.
255
- An "Advanced" `<details>` exposes optional `branch` and `commit_message`
256
- fields.
257
-
258
- ### Security
259
-
260
- - Token is sent over HTTPS to the SoniCoder backend, used once for the push,
261
- then dropped (not stored, not logged).
262
- - Error messages are scrubbed to remove the token before being returned.
263
- - The temp repo is deleted at the end of the call (context manager).
264
- - The local SoniCoder workspace is never turned into a git repo; the
265
- workspace's `.git` (if any, e.g. after an import — though imports strip
266
- `.git`) is never read.
267
-
268
- ## Skills
269
-
270
- | Skill | Description |
271
- |-------|-------------|
272
- | `frontend-design` | Distinctive visual design guidance |
273
- | `feature-dev` | Guided feature implementation workflow |
274
- | `code-review` | High-signal code review |
275
- | `debugging` | Systematic debugging workflow |
276
- | `fullstack-scaffold` | Project structure scaffolding rules |
277
- | `commit-workflow` | Git commit best practices |
278
 
279
  ## Hooks
280
 
@@ -302,3 +253,56 @@ Supported SDKs:
302
  - `gradio` — Python Gradio apps
303
  - `streamlit` — Python Streamlit apps
304
  - `docker` — JS/TS frameworks (auto-generates Dockerfile + package.json)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SoniCoder v2.1 — 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 v2.1 is a **local-first AI coding agent** that can:
9
+
10
+ ### Core Capabilities
11
  - Generate complete fullstack applications in any language/framework
12
  - Read, write, and edit files in a sandboxed workspace
13
+ - Run shell commands (git, npm, pip, tests) with enhanced safety
14
  - Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
15
  - Respond to slash commands (/commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help)
16
  - Deploy to HuggingFace Spaces with one click
17
+ - Manage and export conversation sessions
18
+ - Use code templates for rapid development
19
+
20
+ ### v2.1 Enhancements
21
+ - **Session Management**: Persistent conversations with JSON/Markdown/HTML export
22
+ - **Code Templates**: Pre-built templates for Flask, React, Gradio, Express
23
+ - **Performance**: Response caching, model optimization, progress tracking
24
+ - **Security**: Enhanced command blocking, input validation, audit logging
25
+ - **Metrics**: Inference statistics, token counting, performance monitoring
26
+ - **New Models**: DeepSeek-Coder-1.3B support for advanced reasoning
27
 
28
  ## Architecture
29
 
30
  ```
31
+ app.py ← Entry point: launches Gradio Server
32
  code/
33
+ ├── config/constants.py ← App config, system prompt, templates (ENHANCED)
34
  ├── model/
35
+ │ ├── loader.py Model loading with progress & memory optimization
36
+ │ └── inference.py ← Streaming inference with caching & metrics
37
+ ├── agent/__init__.py ← Agent loop (model ↔ tools, supports custom agents)
38
  ├── tools/
39
+ │ ├── fs.py ← read_file, write_file, edit_file, glob, grep, list_dir
40
+ │ ├── bash.py ← Sandboxed shell execution (ENHANCED - audit trail)
41
+ │ ├── todos.py ← Todo list management
42
+ │ └── github.py ← GitHub repo import (shallow clone + strip heavy dirs)
43
  ├── skills/
44
+ │ ├── __init__.py ← Skill discovery + loading
45
+ │ └── builtins/ ← Built-in skills (markdown)
46
+ ├── agents/ ← Custom Agent system (AI-generated personas)
47
+ │ ├── __init__.py ← Agent CRUD + system-prompt builder
48
+ │ └── builtins/ ← Built-in agents (code-reviewer, test-writer)
49
  ├── commands/
50
+ │ ├── __init__.py ← Slash command parser + expander
51
+ │ └── builtins/ ← Built-in commands (markdown)
52
  ├── hooks/
53
+ │ ├── __init__.py ← Hook rule engine
54
+ │ └── builtins/ ← Built-in hook rules (markdown)
55
  ├── execution/
56
+ │ ├── code_extractor.py ← Code extraction from model output
57
+ │ ├── python_runner.py ← Sandboxed Python execution
58
+ │ └── gradio_runner.py ← Gradio app subprocess runner
59
  ├���─ huggingface/
60
+ │ ├── dockerfile_gen.py ← Auto Dockerfile/package.json for JS
61
+ │ └── push.py ← HF Hub push + ZIP packaging
62
+ ├── websearch/google_scraper.py ← DuckDuckGo + Google scraping (no API)
63
+ ── server/
64
+ ├── routes.py All HTTP + API endpoints
65
+ ── chat_helpers.py Chat history + prompt building (ENHANCED)
66
+ ├── session_manager.py Session persistence & export (NEW)
67
+ └── __init__.py Server utilities & helpers
68
+ index.html ← Frontend (single-file SPA)
69
+ workspace/ ← Sandboxed agent workspace (auto-created)
70
+ downloads/ ← Exported files directory (auto-created)
71
  ```
72
 
73
  ## Conventions
 
80
  - **Tests**: pytest, in `tests/` directory, `test_*.py` naming
81
  - **Frontend**: single-file HTML with inline CSS/JS, no build step
82
 
83
+ ## Server Rules
84
 
85
  - All servers bind to `0.0.0.0` (never `localhost`)
86
  - Default port: `7860` (HF Spaces convention)
87
  - Sub-servers use `7861`, `7862`, etc.
88
+ - Graceful shutdown on SIGTERM/SIGINT
89
 
90
+ ## Model Configuration
91
 
92
+ | Model | Type | Size | Best For |
93
+ |-------|------|------|----------|
94
+ | Qwen2.5-Coder-1.5B | Text | 3.0 GB | Tool-calling, code generation |
95
+ | MiniCPM5-1B | Text | 2.17 GB | Fast inference |
96
+ | MiniCPM-V-4.6 | VLM | 2.8 GB | Image understanding |
97
+ | DeepSeek-Coder-1.3B | Text | 2.6 GB | Advanced reasoning |
98
+
99
+ - Default: `Qwen/Qwen2.5-Coder-1.5B-Instruct`
100
  - Loaded in background thread on startup
101
  - Streaming inference via `TextIteratorStreamer`
102
+ - One model loaded at a time (memory conservation)
103
 
104
+ ## Tool Call Format
105
 
106
  The model calls tools by emitting fenced code blocks with `tool` as the language:
107
 
 
121
  pass
122
  ```
123
 
124
+ ## Slash Commands
125
 
126
  | Command | Description |
127
  |---------|-------------|
 
136
  | `/agent create <desc>` | AI generates a custom agent from natural-language description |
137
  | `/agent use <name>` | Activate a saved agent |
138
  | `/agent list` | List all saved agents |
 
 
 
139
  | `/github <url> [subdir] [--branch <name>] [--into <path>]` | Import a GitHub repo into the workspace |
140
  | `/help` | Show available commands and skills |
141
 
142
+ ## Code Templates
143
 
144
+ Templates provide quick-start code for common patterns:
 
 
145
 
146
+ | Template ID | Name | Language | Framework |
147
+ |-------------|------|----------|-----------|
148
+ | `python-flask-api` | Flask REST API | Python | Flask |
149
+ | `react-todo-app` | React Todo App | JavaScript | React |
150
+ | `gradio-image-app` | Gradio Image Processor | Python | Gradio |
151
+ | `express-js-server` | Express.js Server | JavaScript | Express.js |
152
 
153
+ Access via API: `GET /api/templates` or `GET /api/templates/{id}`
 
154
 
155
+ ## Session Management
 
 
 
 
 
 
 
 
 
 
 
156
 
157
+ Sessions are automatically created and persisted:
158
 
159
+ - **Storage**: `CONFIG_DIR/sessions/*.json`
160
+ - **Auto-save**: On each message
161
+ - **Export formats**: JSON, Markdown, HTML
162
+ - **Cleanup**: Sessions older than 30 days are auto-removed
163
 
164
+ ### Session API Endpoints
165
 
166
+ | Endpoint | Description |
167
+ |----------|-------------|
168
+ | `POST /api/sessions/create` | Create new session |
169
+ | `GET /api/sessions/{id}` | Get session details |
170
+ | `PUT /api/sessions/{id}` | Update session metadata |
171
+ | `DELETE /api/sessions/{id}` | Delete session |
172
+ | `GET /api/sessions` | List sessions (with search) |
173
+ | `POST /api/sessions/{id}/export` | Export session |
174
 
175
+ ## Performance Features
176
 
177
+ ### Response Caching
 
 
 
178
 
179
+ - Similar prompts are cached for faster responses
180
+ - Configurable TTL (default: 5 minutes)
181
+ - Automatic cache invalidation
182
+ - Cache stats available via `/api/cache/stats`
183
 
184
+ ### Model Optimization
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
+ - Automatic precision selection (bfloat16 > float16 > float32)
187
+ - Better Transformer structure when available
188
+ - Torch compile support (PyTorch 2.0+)
189
+ - Memory-efficient loading with offload support
190
+
191
+ ### Metrics Tracking
192
+
193
+ Each inference call tracks:
194
+ - Time to first token (TTFT)
195
+ - Tokens per second
196
+ - Total generation time
197
+ - Cache hit/miss status
198
+ - Error information
199
+
200
+ ## Security Enhancements
201
 
202
+ ### Command Safety (Enhanced)
203
+
204
+ Additional blocked patterns:
205
+ - Regex-based pattern matching
206
+ - Fork bomb detection (`:(){:|:&};:`)
207
+ - Pipe-to-shell prevention (`curl ... | bash`)
208
+ - Root filesystem protection
209
+ - Character validation (null bytes, etc.)
210
+
211
+ ### Input Validation
212
+
213
+ - Maximum prompt length: 10,000 characters
214
+ - Maximum file size: 10 MB
215
+ - Rate limiting: 30 requests/minute
216
+ - Environment variable scrubbing (secrets removal)
217
+
218
+ ### Audit Trail
219
+
220
+ All bash commands are logged with:
221
+ - Timestamp
222
+ - Full command (truncated to 500 chars)
223
+ - Success/failure status
224
+ - Return code
225
+ - Timeout flag
226
+ - Execution duration
227
+
228
+ Access via: `GET /api/audit/log` or `code.tools.bash.get_command_history()`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
  ## Hooks
231
 
 
253
  - `gradio` — Python Gradio apps
254
  - `streamlit` — Python Streamlit apps
255
  - `docker` — JS/TS frameworks (auto-generates Dockerfile + package.json)
256
+
257
+ ## Testing
258
+
259
+ Run the test suite:
260
+
261
+ ```bash
262
+ # Install test dependencies
263
+ pip install pytest pytest-cov
264
+
265
+ # Run tests
266
+ pytest tests/ -v --cov=code --cov-report=html
267
+
268
+ # Run specific test file
269
+ pytest tests/test_bash_safety.py -v
270
+ ```
271
+
272
+ ## Troubleshooting
273
+
274
+ ### Common Issues
275
+
276
+ **Model fails to load (OOM)**
277
+ - Try a smaller model (MiniCPM5-1B)
278
+ - Close other applications using GPU memory
279
+ - Check available RAM (need ~4GB for text models)
280
+
281
+ **Slow response times**
282
+ - Enable caching (default: on)
283
+ - Use smaller max_tokens for testing
284
+ - Consider CPU-only mode if GPU transfer is slow
285
+
286
+ **Commands being blocked**
287
+ - Review blocked patterns in `code/tools/bash.py`
288
+ - Check hook rules in `code/hooks/`
289
+ - Use `/help` for allowed operations
290
+
291
+ ## Version History
292
+
293
+ ### v2.1.0 (Current)
294
+ - Added DeepSeek-Coder-1.3B model support
295
+ - Implemented session management with persistence
296
+ - Added code template system
297
+ - Enhanced security with regex-based command blocking
298
+ - Added response caching and performance metrics
299
+ - Improved error handling and recovery
300
+ - Added export functionality (JSON/Markdown/HTML)
301
+
302
+ ### v2.0.0
303
+ - Initial release with Gradio 6.x architecture
304
+ - Multi-model support (Qwen2.5-Coder, MiniCPM5, MiniCPM-V)
305
+ - Agent loop with tool calling
306
+ - Skills and commands system
307
+ - GitHub integration
308
+ - HuggingFace deployment
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: SoniCoder v2
3
  emoji: 🚀
4
  colorFrom: purple
5
  colorTo: blue
@@ -10,8 +10,263 @@ pinned: false
10
  license: apache-2.0
11
  ---
12
 
13
- # SoniCoder v2 — AI Code Agent
14
 
15
- Optimized AI code writer agent with architecture inspired by Gemini CLI and Claude Code.
16
 
17
- **No external APIs needed** — runs entirely on local inference with smart 1B models.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SoniCoder v2.1
3
  emoji: 🚀
4
  colorFrom: purple
5
  colorTo: blue
 
10
  license: apache-2.0
11
  ---
12
 
13
+ # SoniCoder v2.1Enhanced AI Code Agent
14
 
15
+ > **Optimized AI code writer agent with architecture inspired by Gemini CLI and Claude Code.**
16
 
17
+ **No external APIs needed** — runs entirely on local inference with smart 1B models.
18
+
19
+ ## ✨ What's New in v2.1
20
+
21
+ ### 🎯 Major Enhancements
22
+
23
+ - **🤖 New Model Support**: Added DeepSeek-Coder-1.3B for advanced code reasoning
24
+ - **💾 Session Management**: Persistent conversations with export to JSON/Markdown/HTML
25
+ - **📝 Code Templates**: Quick-start templates for common project types (Flask, React, Express, Gradio)
26
+ - **⚡ Performance Optimizations**: Response caching, model optimization, better memory management
27
+ - **🔒 Security Hardening**: Enhanced input validation, command blocking patterns, environment scrubbing
28
+ - **📊 Metrics & Monitoring**: Inference metrics tracking, token counting, performance statistics
29
+ - **🎨 Improved UI**: Better error messages, loading states, progress indicators
30
+
31
+ ### 🔧 Technical Improvements
32
+
33
+ - **Model Loading Progress**: Real-time progress callbacks during model initialization
34
+ - **Response Caching**: Cache similar prompts for faster responses (configurable TTL)
35
+ - **Better Error Recovery**: Graceful fallbacks when streaming fails
36
+ - **Command Audit Trail**: Track executed commands with timestamps and results
37
+ - **Conversation Summarization**: Auto-summarize long contexts to stay within token limits
38
+ - **Token Usage Estimation**: Estimate token counts for cost/performance planning
39
+
40
+ ## 🚀 Quick Start
41
+
42
+ ### Installation
43
+
44
+ ```bash
45
+ # Clone the repository
46
+ git clone https://huggingface.co/spaces/sonic-coder/sonicoder
47
+ cd sonicoder
48
+
49
+ # Install dependencies
50
+ pip install -r requirements.txt
51
+
52
+ # Run the application
53
+ python app.py
54
+ ```
55
+
56
+ ### Docker (Alternative)
57
+
58
+ ```dockerfile
59
+ FROM python:3.11-slim
60
+
61
+ WORKSPACE /app
62
+ COPY . .
63
+
64
+ RUN pip install -r requirements.txt
65
+
66
+ EXPOSE 7860
67
+
68
+ CMD ["python", "app.py"]
69
+ ```
70
+
71
+ ## 🖥️ Features
72
+
73
+ ### Core Capabilities
74
+
75
+ | Feature | Description |
76
+ |---------|-------------|
77
+ | **Code Generation** | Generate complete applications in any language/framework |
78
+ | **File Operations** | Read, write, edit files in a sandboxed workspace |
79
+ | **Shell Execution** | Run commands (git, npm, pip, tests) with safety controls |
80
+ | **Multi-model Support** | Switch between Qwen2.5-Coder, MiniCPM5, MiniCPM-V, DeepSeek-Coder |
81
+ | **Vision Support** | Upload images for VLM-powered code generation |
82
+ | **Slash Commands** | `/commit`, `/review`, `/feature`, `/design`, etc. |
83
+ | **Custom Agents** | Create AI-generated personas with specialized behaviors |
84
+
85
+ ### Built-in Skills
86
+
87
+ | Skill | Description |
88
+ |-------|-------------|
89
+ | `frontend-design` | Distinctive visual design guidance |
90
+ | `feature-dev` | Guided feature implementation workflow |
91
+ | `code-review` | High-signal code review |
92
+ | `debugging` | Systematic debugging workflow |
93
+ | `fullstack-scaffold` | Project structure scaffolding rules |
94
+ | `commit-workflow` | Git commit best practices |
95
+
96
+ ### Code Templates
97
+
98
+ Quick-start templates available:
99
+
100
+ | Template | Language | Framework |
101
+ |----------|----------|-----------|
102
+ | `python-flask-api` | Python | Flask |
103
+ | `react-todo-app` | JavaScript | React |
104
+ | `gradio-image-app` | Python | Gradio |
105
+ | `express-js-server` | JavaScript | Express.js |
106
+
107
+ ## 📁 Project Structure
108
+
109
+ ```
110
+ sonicoder/
111
+ ├── app.py # Entry point: launches Gradio Server
112
+ ├── requirements.txt # Python dependencies
113
+ ├── README.md # This file
114
+ ├── CLAUDE.md # Project memory & conventions
115
+ ├── index.html # Frontend (single-file SPA)
116
+
117
+ └── code/
118
+ ├── config/
119
+ │ └── constants.py # App config, system prompt, templates
120
+ ├── model/
121
+ │ ├── loader.py # Model loading with progress tracking
122
+ │ └── inference.py # Streaming inference with caching
123
+ ├── agent/
124
+ │ └── __init__.py # Agent loop (model ↔ tools)
125
+ ├── tools/
126
+ │ ├── fs.py # File operations
127
+ │ ├── bash.py # Sandboxed shell execution (enhanced)
128
+ │ ├── todos.py # Todo list management
129
+ │ └── github.py # GitHub integration
130
+ ├── skills/ # Built-in skills (markdown)
131
+ ├── agents/ # Custom agent system
132
+ ├── commands/ # Slash command definitions
133
+ ├── hooks/ # Safety hook rules
134
+ ├── execution/ # Code execution sandbox
135
+ ├── server/
136
+ │ ├── routes.py # All HTTP + API endpoints
137
+ │ ├── chat_helpers.py # Prompt building (enhanced)
138
+ │ ├── session_manager.py # Session persistence & export (NEW)
139
+ │ └── __init__.py # Server utilities
140
+ ├── huggingface/ # HF Spaces deployment
141
+ ├── websearch/ # Web search scraping
142
+ └── config.py # Workspace & config paths
143
+ ```
144
+
145
+ ## 🎮 Usage Examples
146
+
147
+ ### Basic Code Generation
148
+
149
+ ```
150
+ User: Create a Python Flask API with CRUD operations for a todo list
151
+ SoniCoder: [Generates complete Flask application with proper structure]
152
+ ```
153
+
154
+ ### Using Templates
155
+
156
+ ```
157
+ User: Use the react-todo-app template as a starting point
158
+ SoniCoder: [Loads template and customizes based on requirements]
159
+ ```
160
+
161
+ ### Iterative Development
162
+
163
+ ```
164
+ User: Build me a REST API
165
+ SoniCoder: [Creates initial API]
166
+
167
+ User: Add authentication to it
168
+ SoniCoder: [Reads existing file, adds auth middleware]
169
+
170
+ User: Write tests for the auth endpoint
171
+ SoniCoder: [Creates test file with pytest]
172
+ ```
173
+
174
+ ## ⚙️ Configuration
175
+
176
+ ### Environment Variables
177
+
178
+ | Variable | Description | Default |
179
+ |----------|-------------|---------|
180
+ | `SONICODER_WORKSPACE` | Workspace root directory | `./workspace` |
181
+ | `MODEL_CACHE_DIR` | Model cache location | Default HF cache |
182
+
183
+ ### Model Selection
184
+
185
+ Models can be switched via the UI or API:
186
+
187
+ ```python
188
+ from code.model.loader import switch_model
189
+
190
+ # Switch to DeepSeek Coder
191
+ result = switch_model("deepseek-coder-1.3b")
192
+ print(result) # {"success": True, "message": "Switching to DeepSeek-Coder-1.3B..."}
193
+ ```
194
+
195
+ ### Performance Tuning
196
+
197
+ In `code/config/constants.py`:
198
+
199
+ ```python
200
+ # Enable response caching
201
+ CACHE_ENABLED = True
202
+ CACHE_TTL_SECONDS = 300 # 5 minutes
203
+
204
+ # Concurrency limits
205
+ MAX_CONCURRENT_REQUESTS = 5
206
+ ```
207
+
208
+ ## 🔒 Security
209
+
210
+ ### Command Safety
211
+
212
+ Blocked patterns include:
213
+ - Destructive commands (`rm -rf /`, `mkfs`, `dd`)
214
+ - System control (`shutdown`, `reboot`)
215
+ - Fork bombs (`:(){:|:&};:`)
216
+ - Pipe-to-shell attacks (`curl ... | bash`)
217
+ - Root filesystem modifications
218
+
219
+ ### Input Validation
220
+
221
+ - Maximum prompt length: 10,000 characters
222
+ - Maximum file size: 10 MB
223
+ - Rate limiting: 30 requests/minute
224
+ - Regex-based pattern blocking
225
+
226
+ ## 📊 Export & Sessions
227
+
228
+ ### Export Formats
229
+
230
+ Sessions can be exported in multiple formats:
231
+
232
+ ```python
233
+ from code.server.session_manager import export_session, get_session_manager
234
+
235
+ manager = get_session_manager()
236
+ session = manager.get_session("session_id")
237
+
238
+ # Export as JSON
239
+ export_session(session, format="json", output_dir="./exports")
240
+
241
+ # Export as Markdown
242
+ export_session(session, format="markdown", output_dir="./exports")
243
+
244
+ # Export as styled HTML
245
+ export_session(session, format="html", output_dir="./exports")
246
+ ```
247
+
248
+ ### Session Persistence
249
+
250
+ Sessions are automatically saved to `CONFIG_DIR/sessions/` and restored on restart.
251
+
252
+ ## 🤝 Contributing
253
+
254
+ 1. Fork the repository
255
+ 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
256
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
257
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
258
+ 5. Open a Pull Request
259
+
260
+ ## 📄 License
261
+
262
+ This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
263
+
264
+ ## 🙏 Acknowledgments
265
+
266
+ - Inspired by [Gemini CLI](https://github.com/google-gemini/gemini-cli) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
267
+ - Powered by Hugging Face Transformers
268
+ - Models: Qwen2.5-Coder, MiniCPM5, MiniCPM-V, DeepSeek-Coder
269
+
270
+ ---
271
+
272
+ **Built with ❤️ by the SoniCoder team**
app.py CHANGED
@@ -1,17 +1,27 @@
1
- """SoniCoder v2 — Entry point.
2
 
3
- gradio.Server (Gradio 6.x) serves index.html from project root
4
  with Gradio's queuing, concurrency, and API infrastructure.
5
 
 
 
 
 
 
 
 
6
  Blog: https://huggingface.co/blog/introducing-gradio-server
7
  """
8
 
9
  from __future__ import annotations
10
 
11
  import logging
 
 
 
12
 
13
  # Ensure workspace + config dirs exist
14
- from code.config import WORKSPACE_ROOT, CONFIG_DIR
15
 
16
  WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
17
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@@ -24,13 +34,78 @@ logging.basicConfig(
24
  logger = logging.getLogger(__name__)
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  def main():
28
- logger.info("SoniCoder v2 starting...")
 
 
 
29
 
30
- # Start loading default model in background
31
- from code.model.loader import start_background_load
32
 
33
- start_background_load()
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  logger.info("Background model loading started")
35
 
36
  # Set up MCP servers from config (best-effort)
@@ -56,8 +131,20 @@ def main():
56
 
57
  app = create_app()
58
 
 
 
 
 
 
 
59
  logger.info("Launching...")
60
- app.launch(show_error=True, server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
61
 
62
 
63
  if __name__ == "__main__":
 
1
+ """SoniCoder v2.1Enhanced Entry Point.
2
 
3
+ Gradio Server (Gradio 6.x) serves index.html from project root
4
  with Gradio's queuing, concurrency, and API infrastructure.
5
 
6
+ Enhanced version with:
7
+ - Better startup logging
8
+ - Graceful shutdown handling
9
+ - Health check endpoint
10
+ - Session management initialization
11
+ - Model optimization on startup
12
+
13
  Blog: https://huggingface.co/blog/introducing-gradio-server
14
  """
15
 
16
  from __future__ import annotations
17
 
18
  import logging
19
+ import signal
20
+ import sys
21
+ import time
22
 
23
  # Ensure workspace + config dirs exist
24
+ from code.config import WORKSPACE_ROOT, CONFIG_DIR, APP_VERSION
25
 
26
  WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
27
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
 
34
  logger = logging.getLogger(__name__)
35
 
36
 
37
+ def setup_signal_handlers(app):
38
+ """Setup graceful shutdown handlers."""
39
+ def shutdown_handler(signum, frame):
40
+ logger.info("Received shutdown signal, cleaning up...")
41
+ try:
42
+ # Save sessions
43
+ from code.server.session_manager import get_session_manager
44
+ manager = get_session_manager()
45
+ saved = manager.save_all()
46
+ if saved > 0:
47
+ logger.info("Saved %d sessions before shutdown", saved)
48
+ except Exception as e:
49
+ logger.warning("Failed to save sessions: %s", e)
50
+
51
+ try:
52
+ # Clear model memory
53
+ from code.model.loader import _unload_model
54
+ _unload_model()
55
+ logger.info("Model unloaded")
56
+ except Exception as e:
57
+ logger.warning("Failed to unload model: %s", e)
58
+
59
+ logger.info("Shutdown complete")
60
+ sys.exit(0)
61
+
62
+ signal.signal(signal.SIGTERM, shutdown_handler)
63
+ signal.signal(signal.SIGINT, shutdown_handler)
64
+
65
+
66
+ def initialize_session_manager():
67
+ """Initialize session manager with persistence."""
68
+ try:
69
+ from code.server.session_manager import get_session_manager
70
+
71
+ storage_dir = CONFIG_DIR / "sessions"
72
+ manager = get_session_manager(storage_dir=str(storage_dir))
73
+
74
+ # Cleanup old sessions (older than 30 days)
75
+ removed = manager.cleanup_old_sessions(max_age_days=30)
76
+ if removed > 0:
77
+ logger.info("Cleaned up %d old sessions", removed)
78
+
79
+ logger.info("Session manager initialized with storage at %s", storage_dir)
80
+ return manager
81
+ except Exception as exc:
82
+ logger.warning("Session manager initialization skipped: %s", exc)
83
+ return None
84
+
85
+
86
  def main():
87
+ start_time = time.time()
88
+ logger.info("=" * 50)
89
+ logger.info("SoniCoder v%s starting...", APP_VERSION)
90
+ logger.info("=" * 50)
91
 
92
+ # Initialize session manager
93
+ session_mgr = initialize_session_manager()
94
 
95
+ # Start loading default model in background
96
+ from code.model.loader import start_background_load, add_progress_callback
97
+
98
+ def on_progress(progress):
99
+ if progress.progress_percent % 25 < 5: # Log at ~25%, 50%, 75%, 100%
100
+ logger.info(
101
+ "Model loading: %.0f%% - %s",
102
+ progress.progress_percent,
103
+ progress.message,
104
+ )
105
+
106
+ add_progress_callback(on_progress)
107
+
108
+ load_thread = start_background_load()
109
  logger.info("Background model loading started")
110
 
111
  # Set up MCP servers from config (best-effort)
 
131
 
132
  app = create_app()
133
 
134
+ # Setup signal handlers for graceful shutdown
135
+ setup_signal_handlers(app)
136
+
137
+ # Log startup time
138
+ startup_time = time.time() - start_time
139
+ logger.info("Startup completed in %.2f seconds", startup_time)
140
  logger.info("Launching...")
141
+
142
+ # Launch the server
143
+ app.launch(
144
+ show_error=True,
145
+ server_name="0.0.0.0",
146
+ server_port=7860,
147
+ )
148
 
149
 
150
  if __name__ == "__main__":
code/config/constants.py CHANGED
@@ -1,4 +1,12 @@
1
- """Application-wide constants, regex patterns, language options, and system prompt."""
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
@@ -7,6 +15,7 @@ import re
7
  # ─── App Identity ────────────────────────────────────────────────────────
8
 
9
  APP_TITLE = "SoniCoder"
 
10
  MODEL_URL = "https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct"
11
 
12
  # ─── Model Configs ───────────────────────────────────────────────────────
@@ -20,6 +29,7 @@ MODEL_CONFIGS = {
20
  "auto_class": "AutoModelForCausalLM",
21
  "tokenizer_class": "AutoTokenizer",
22
  "size_gb": 3.0,
 
23
  },
24
  "minicpm5-1b": {
25
  "id": "openbmb/MiniCPM5-1B",
@@ -29,6 +39,7 @@ MODEL_CONFIGS = {
29
  "auto_class": "AutoModelForCausalLM",
30
  "tokenizer_class": "AutoTokenizer",
31
  "size_gb": 2.17,
 
32
  },
33
  "minicpm-v-4.6": {
34
  "id": "openbmb/MiniCPM-V-4.6",
@@ -38,6 +49,18 @@ MODEL_CONFIGS = {
38
  "auto_class": "AutoModelForImageTextToText",
39
  "processor_class": "AutoProcessor",
40
  "size_gb": 2.8,
 
 
 
 
 
 
 
 
 
 
 
 
41
  },
42
  }
43
 
@@ -56,6 +79,31 @@ PY_MEM_LIMIT_MB = 1024
56
  MAX_STDIO_CHARS = 16_000
57
  OUTPUT_PNG = "output.png"
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  # ─── Regex Patterns ─────────────────────────────────────────────────────
60
 
61
  THINKING_BLOCK_RE = re.compile(
@@ -68,6 +116,298 @@ FILE_BLOCK_RE = re.compile(
68
  r"@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)", re.DOTALL
69
  )
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  # ─── Supported Languages & Frameworks ───────────────────────────────────
72
 
73
  LANGUAGE_OPTIONS: list[tuple[str, list[str]]] = [
@@ -87,9 +427,9 @@ LANGUAGE_OPTIONS: list[tuple[str, list[str]]] = [
87
 
88
  LANGUAGE_MAP: dict[str, list[str]] = {lang: frameworks for lang, frameworks in LANGUAGE_OPTIONS}
89
 
90
- # ─── System Prompt ───────────────────────────────────────────────────────
91
 
92
- SYSTEM_PROMPT = """You are SoniCoder — an autonomous coding agent that writes files to a sandboxed workspace using tools.
93
 
94
  CAPABILITIES:
95
  - Generate complete, runnable applications in any language/framework
@@ -98,6 +438,8 @@ CAPABILITIES:
98
  - Track multi-step tasks with the todo system
99
  - Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
100
  - Respond to slash commands: /commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help
 
 
101
 
102
  CRITICAL RULES — READ CAREFULLY:
103
 
@@ -116,10 +458,21 @@ CRITICAL RULES — READ CAREFULLY:
116
 
117
  6. After each tool call, briefly note what you did (one short sentence), then continue with the next step or finish.
118
 
119
- 7. Do NOT use <think> or <thinking> tags. Do NOT reason aloud.
120
 
121
  8. When done, give a concise summary of which files you created or modified.
122
 
 
 
 
 
 
 
 
 
 
 
 
123
  WORKFLOW EXAMPLE:
124
  - User asks: "Build a Flask API for a book library"
125
  - You: call todo_write → call write_file (app.py) → call write_file (requirements.txt) → call write_file (README.md) → respond with a short summary listing the files created.
@@ -135,17 +488,21 @@ GRADIO APP RULES:
135
 
136
  WEB APP RULES (HTML/CSS/JS):
137
  - Save a single self-contained index.html via `write_file` with all CSS and JS inline.
 
 
138
 
139
  PYTHON RULES:
140
  - Prefer standard library or common packages.
141
  - Add proper error handling and comments.
 
 
142
 
143
  If web search results are provided, use them to inform your code generation.
144
  If the user provides an image, analyze it and generate code based on what you see.
145
  If a hook warns you, acknowledge it and adjust your approach.
146
  """
147
 
148
- # ─── Example Prompts ────────────────────────────────────────────────────
149
 
150
  EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
151
  (
@@ -184,4 +541,17 @@ EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
184
  "HTML/CSS/JS",
185
  "Vanilla",
186
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  ]
 
1
+ """Application-wide constants, regex patterns, language options, and system prompt.
2
+
3
+ Enhanced version with:
4
+ - Additional model configurations
5
+ - Code templates system
6
+ - Session management settings
7
+ - Performance tuning parameters
8
+ - Security configurations
9
+ """
10
 
11
  from __future__ import annotations
12
 
 
15
  # ─── App Identity ────────────────────────────────────────────────────────
16
 
17
  APP_TITLE = "SoniCoder"
18
+ APP_VERSION = "2.1.0" # Enhanced version
19
  MODEL_URL = "https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct"
20
 
21
  # ─── Model Configs ───────────────────────────────────────────────────────
 
29
  "auto_class": "AutoModelForCausalLM",
30
  "tokenizer_class": "AutoTokenizer",
31
  "size_gb": 3.0,
32
+ "capabilities": ["code-generation", "tool-calling", "multi-language"],
33
  },
34
  "minicpm5-1b": {
35
  "id": "openbmb/MiniCPM5-1B",
 
39
  "auto_class": "AutoModelForCausalLM",
40
  "tokenizer_class": "AutoTokenizer",
41
  "size_gb": 2.17,
42
+ "capabilities": ["code-generation", "fast-inference"],
43
  },
44
  "minicpm-v-4.6": {
45
  "id": "openbmb/MiniCPM-V-4.6",
 
49
  "auto_class": "AutoModelForImageTextToText",
50
  "processor_class": "AutoProcessor",
51
  "size_gb": 2.8,
52
+ "capabilities": ["vision", "image-understanding", "code-generation"],
53
+ },
54
+ # New: DeepSeek Coder for enhanced code quality
55
+ "deepseek-coder-1.3b": {
56
+ "id": "deepseek-ai/DeepSeek-Coder-1.3B-Instruct",
57
+ "name": "DeepSeek-Coder-1.3B",
58
+ "type": "text",
59
+ "description": "Advanced code model with superior reasoning",
60
+ "auto_class": "AutoModelForCausalLM",
61
+ "tokenizer_class": "AutoTokenizer",
62
+ "size_gb": 2.6,
63
+ "capabilities": ["code-generation", "reasoning", "debugging"],
64
  },
65
  }
66
 
 
79
  MAX_STDIO_CHARS = 16_000
80
  OUTPUT_PNG = "output.png"
81
 
82
+ # Performance Tuning (NEW)
83
+ CACHE_ENABLED = True
84
+ CACHE_TTL_SECONDS = 300 # 5 minutes cache for similar prompts
85
+ MAX_CONCURRENT_REQUESTS = 5
86
+ REQUEST_QUEUE_TIMEOUT = 60
87
+ RESPONSE_STREAMING_CHUNK_SIZE = 16 # tokens per chunk
88
+
89
+ # Session Management (NEW)
90
+ SESSION_TIMEOUT_MINUTES = 60
91
+ MAX_SESSION_HISTORY = 100
92
+ AUTO_SAVE_INTERVAL_SECONDS = 30
93
+ EXPORT_FORMATS = ["json", "markdown", "html"]
94
+
95
+ # Security Settings (NEW)
96
+ MAX_PROMPT_LENGTH = 10000
97
+ MAX_FILE_SIZE_MB = 10
98
+ RATE_LIMIT_REQUESTS_PER_MINUTE = 30
99
+ BLOCKED_PATTERNS_EXTRA = [
100
+ r"import\s+os.*system",
101
+ r"eval\s*\(",
102
+ r"exec\s*\(",
103
+ r"__import__",
104
+ r"subprocess\.Popen",
105
+ ]
106
+
107
  # ─── Regex Patterns ─────────────────────────────────────────────────────
108
 
109
  THINKING_BLOCK_RE = re.compile(
 
116
  r"@@FILE:\s*(.+?)@@\s*\n(.*?)(?=@@FILE:|@@END@@)", re.DOTALL
117
  )
118
 
119
+ # Enhanced patterns for better parsing
120
+ TOOL_CALL_RE = re.compile(
121
+ r"```tool\s*\n(.*?)```", re.DOTALL
122
+ )
123
+ MULTILINE_VALUE_RE = re.compile(
124
+ r"(\w+):\s*\|\s*\n([\s\S]*?)(?=\n\w+:|$)"
125
+ )
126
+
127
+ # ─── Code Templates System (NEW) ────────────────────────────────────────
128
+
129
+ CODE_TEMPLATES: dict[str, dict[str, str]] = {
130
+ "python-flask-api": {
131
+ "name": "Flask REST API",
132
+ "description": "Basic Flask REST API with CRUD operations",
133
+ "language": "Python",
134
+ "framework": "Flask",
135
+ "template": """from flask import Flask, request, jsonify
136
+ from dataclasses import dataclass
137
+ from typing import List, Optional
138
+ import datetime
139
+
140
+ app = Flask(__name__)
141
+
142
+ @dataclass
143
+ class Item:
144
+ id: int
145
+ name: str
146
+ description: str
147
+ created_at: str
148
+
149
+ # In-memory storage
150
+ items: List[Item] = []
151
+ next_id = 1
152
+
153
+ @app.route('/api/items', methods=['GET'])
154
+ def get_items():
155
+ return jsonify([item.__dict__ for item in items])
156
+
157
+ @app.route('/api/items/<int:item_id>', methods=['GET'])
158
+ def get_item(item_id):
159
+ item = next((i for i in items if i.id == item_id), None)
160
+ if not item:
161
+ return jsonify({'error': 'Not found'}), 404
162
+ return jsonify(item.__dict__)
163
+
164
+ @app.route('/api/items', methods=['POST'])
165
+ def create_item():
166
+ global next_id
167
+ data = request.get_json()
168
+ item = Item(
169
+ id=next_id,
170
+ name=data['name'],
171
+ description=data.get('description', ''),
172
+ created_at=datetime.datetime.now().isoformat()
173
+ )
174
+ items.append(item)
175
+ next_id += 1
176
+ return jsonify(item.__dict__), 201
177
+
178
+ if __name__ == '__main__':
179
+ app.run(host='0.0.0.0', port=7860, debug=True)
180
+ """,
181
+ },
182
+ "react-todo-app": {
183
+ "name": "React Todo App",
184
+ "description": "Modern React todo application with hooks",
185
+ "language": "JavaScript",
186
+ "framework": "React",
187
+ "template": """import React, { useState, useEffect } from 'react';
188
+
189
+ function TodoApp() {
190
+ const [todos, setTodos] = useState([]);
191
+ const [input, setInput] = useState('');
192
+ const [filter, setFilter] = useState('all');
193
+
194
+ const addTodo = () => {
195
+ if (!input.trim()) return;
196
+ setTodos([...todos, {
197
+ id: Date.now(),
198
+ text: input,
199
+ completed: false
200
+ }]);
201
+ setInput('');
202
+ };
203
+
204
+ const toggleTodo = (id) => {
205
+ setTodos(todos.map(todo =>
206
+ todo.id === id ? { ...todo, completed: !todo.completed } : todo
207
+ ));
208
+ };
209
+
210
+ const deleteTodo = (id) => {
211
+ setTodos(todos.filter(todo => todo.id !== id));
212
+ };
213
+
214
+ const filteredTodos = todos.filter(todo => {
215
+ if (filter === 'active') return !todo.completed;
216
+ if (filter === 'completed') return todo.completed;
217
+ return true;
218
+ });
219
+
220
+ return (
221
+ <div style={{ maxWidth: 500, margin: 'auto', padding: 20 }}>
222
+ <h1>Todo App</h1>
223
+ <div style={{ display: 'flex', gap: 10, marginBottom: 20 }}>
224
+ <input
225
+ value={input}
226
+ onChange={e => setInput(e.target.value)}
227
+ onKeyPress={e => e.key === 'Enter' && addTodo()}
228
+ placeholder="What needs to be done?"
229
+ style={{ flex: 1, padding: 10 }}
230
+ />
231
+ <button onClick={addTodo}>Add</button>
232
+ </div>
233
+ <div style={{ marginBottom: 20 }}>
234
+ {['all', 'active', 'completed'].map(f => (
235
+ <button key={f} onClick={() => setFilter(f)}
236
+ style={{ marginRight: 5, opacity: filter === f ? 1 : 0.6 }}>
237
+ {f}
238
+ </button>
239
+ ))}
240
+ </div>
241
+ <ul style={{ listStyle: 'none', padding: 0 }}>
242
+ {filteredTodos.map(todo => (
243
+ <li key={todo.id} style={{
244
+ display: 'flex', alignItems: 'center',
245
+ padding: 10, borderBottom: '1px solid #eee',
246
+ textDecoration: todo.completed ? 'line-through' : 'none'
247
+ }}>
248
+ <input type="checkbox" checked={todo.completed}
249
+ onChange={() => toggleTodo(todo.id)} />
250
+ <span style={{ flex: 1 }}>{todo.text}</span>
251
+ <button onClick={() => deleteTodo(todo.id)}>×</button>
252
+ </li>
253
+ ))}
254
+ </ul>
255
+ </div>
256
+ );
257
+ }
258
+
259
+ export default TodoApp;
260
+ """,
261
+ },
262
+ "gradio-image-app": {
263
+ "name": "Gradio Image Processor",
264
+ "description": "Gradio app for image processing with filters",
265
+ "language": "Python",
266
+ "framework": "Gradio",
267
+ "template": """import gradio as gr
268
+ from PIL import Image, ImageFilter, ImageEnhance
269
+ import numpy as np
270
+
271
+ def apply_filter(image, filter_type, intensity=1.0):
272
+ if image is None:
273
+ return None
274
+
275
+ img = Image.fromarray(image)
276
+
277
+ if filter_type == "Blur":
278
+ result = img.filter(ImageFilter.GaussianBlur(radius=intensity * 5))
279
+ elif filter_type == "Sharpen":
280
+ result = img.filter(ImageFilter.Sharpen())
281
+ elif filter_type == "Edge Detect":
282
+ result = img.filter(ImageFilter.FIND_EDGES)
283
+ elif filter_type == "Contour":
284
+ result = img.filter(ImageFilter.CONTOUR)
285
+ elif filter_type == "Emboss":
286
+ result = img.filter(ImageFilter.EMBOSS)
287
+ elif filter_type == "Brightness":
288
+ enhancer = ImageEnhance.Brightness(img)
289
+ result = enhancer.enhance(1.0 + intensity)
290
+ elif filter_type == "Contrast":
291
+ enhancer = ImageEnhance.Contrast(img)
292
+ result = enhancer.enhance(1.0 + intensity)
293
+ else:
294
+ result = img
295
+
296
+ return np.array(result)
297
+
298
+ with gr.Blocks(title="Image Processor") as demo:
299
+ gr.Markdown("# 🎨 Image Processor")
300
+ gr.Markdown("Upload an image and apply various filters.")
301
+
302
+ with gr.Row():
303
+ with gr.Column():
304
+ input_image = gr.Image(label="Input Image", type="numpy")
305
+ filter_type = gr.Dropdown(
306
+ choices=["Blur", "Sharpen", "Edge Detect", "Contour",
307
+ "Emboss", "Brightness", "Contrast"],
308
+ label="Filter Type",
309
+ value="Blur"
310
+ )
311
+ intensity = gr.Slider(
312
+ minimum=0, maximum=2, step=0.1, value=1,
313
+ label="Intensity"
314
+ )
315
+ process_btn = gr.Button("Apply Filter", variant="primary")
316
+
317
+ with gr.Column():
318
+ output_image = gr.Image(label="Result")
319
+
320
+ process_btn.click(
321
+ apply_filter,
322
+ inputs=[input_image, filter_type, intensity],
323
+ outputs=[output_image]
324
+ )
325
+
326
+ if __name__ == "__main__":
327
+ demo.launch(server_name="0.0.0.0", server_port=7860)
328
+ """,
329
+ },
330
+ "express-js-server": {
331
+ "name": "Express.js Server",
332
+ "description": "Express.js REST API with middleware",
333
+ "language": "JavaScript",
334
+ "framework": "Express.js",
335
+ "template": """const express = require('express');
336
+ const cors = require('cors');
337
+ const helmet = require('helmet');
338
+
339
+ const app = express();
340
+ const PORT = 7860;
341
+
342
+ // Middleware
343
+ app.use(helmet());
344
+ app.use(cors());
345
+ app.use(express.json({ limit: '10mb' }));
346
+
347
+ // Request logging
348
+ app.use((req, res, next) => {
349
+ console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
350
+ next();
351
+ });
352
+
353
+ // In-memory data store
354
+ let items = [];
355
+ let nextId = 1;
356
+
357
+ // Routes
358
+ app.get('/api/health', (req, res) => {
359
+ res.json({ status: 'ok', timestamp: new Date().toISOString() });
360
+ });
361
+
362
+ app.get('/api/items', (req, res) => {
363
+ const { limit, offset } = req.query;
364
+ let result = items;
365
+ if (offset) result = result.slice(Number(offset));
366
+ if (limit) result = result.slice(0, Number(limit));
367
+ res.json({ data: result, total: items.length });
368
+ });
369
+
370
+ app.post('/api/items', (req, res) => {
371
+ const { name, description } = req.body;
372
+ if (!name) return res.status(400).json({ error: 'Name is required' });
373
+
374
+ const item = { id: nextId++, name, description, createdAt: new Date().toISOString() };
375
+ items.push(item);
376
+ res.status(201).json(item);
377
+ });
378
+
379
+ app.get('/api/items/:id', (req, res) => {
380
+ const item = items.find(i => i.id === Number(req.params.id));
381
+ if (!item) return res.status(404).json({ error: 'Not found' });
382
+ res.json(item);
383
+ });
384
+
385
+ app.put('/api/items/:id', (req, res) => {
386
+ const idx = items.findIndex(i => i.id === Number(req.params.id));
387
+ if (idx === -1) return res.status(404).json({ error: 'Not found' });
388
+
389
+ items[idx] = { ...items[idx], ...req.body, updatedAt: new Date().toISOString() };
390
+ res.json(items[idx]);
391
+ });
392
+
393
+ app.delete('/api/items/:id', (req, res) => {
394
+ items = items.filter(i => i.id !== Number(req.params.id));
395
+ res.status(204).send();
396
+ });
397
+
398
+ // Error handling
399
+ app.use((err, req, res, next) => {
400
+ console.error(err.stack);
401
+ res.status(500).json({ error: 'Something went wrong!' });
402
+ });
403
+
404
+ app.listen(PORT, '0.0.0.0', () => {
405
+ console.log(`Server running on http://0.0.0.0:${PORT}`);
406
+ });
407
+ """,
408
+ },
409
+ }
410
+
411
  # ─── Supported Languages & Frameworks ───────────────────────────────────
412
 
413
  LANGUAGE_OPTIONS: list[tuple[str, list[str]]] = [
 
427
 
428
  LANGUAGE_MAP: dict[str, list[str]] = {lang: frameworks for lang, frameworks in LANGUAGE_OPTIONS}
429
 
430
+ # ─── System Prompt (Enhanced) ────────────────────────────────────────────
431
 
432
+ SYSTEM_PROMPT = """You are SoniCoder v2.1 — an advanced autonomous coding agent that writes files to a sandboxed workspace using tools.
433
 
434
  CAPABILITIES:
435
  - Generate complete, runnable applications in any language/framework
 
438
  - Track multi-step tasks with the todo system
439
  - Apply specialized skills (frontend-design, feature-dev, code-review, debugging, fullstack-scaffold, commit-workflow)
440
  - Respond to slash commands: /commit, /review, /feature, /design, /explain, /test, /refactor, /skill, /help
441
+ - Use code templates when appropriate to accelerate development
442
+ - Provide intelligent code suggestions and best practices
443
 
444
  CRITICAL RULES — READ CAREFULLY:
445
 
 
458
 
459
  6. After each tool call, briefly note what you did (one short sentence), then continue with the next step or finish.
460
 
461
+ 7. Do NOT use think or <thinking> tags. Do NOT reason aloud.
462
 
463
  8. When done, give a concise summary of which files you created or modified.
464
 
465
+ 9. Always include proper error handling and comments in generated code.
466
+
467
+ 10. Follow security best practices: validate inputs, sanitize outputs, avoid hardcoded secrets.
468
+
469
+ CODE QUALITY STANDARDS:
470
+ - Write clean, maintainable, and well-documented code
471
+ - Follow language-specific conventions and idioms
472
+ - Include type hints where applicable
473
+ - Add meaningful variable and function names
474
+ - Structure code with clear separation of concerns
475
+
476
  WORKFLOW EXAMPLE:
477
  - User asks: "Build a Flask API for a book library"
478
  - You: call todo_write → call write_file (app.py) → call write_file (requirements.txt) → call write_file (README.md) → respond with a short summary listing the files created.
 
488
 
489
  WEB APP RULES (HTML/CSS/JS):
490
  - Save a single self-contained index.html via `write_file` with all CSS and JS inline.
491
+ - Use modern CSS features (flexbox, grid, custom properties).
492
+ - Ensure responsive design for mobile compatibility.
493
 
494
  PYTHON RULES:
495
  - Prefer standard library or common packages.
496
  - Add proper error handling and comments.
497
+ - Use type hints for function signatures.
498
+ - Follow PEP 8 style guidelines.
499
 
500
  If web search results are provided, use them to inform your code generation.
501
  If the user provides an image, analyze it and generate code based on what you see.
502
  If a hook warns you, acknowledge it and adjust your approach.
503
  """
504
 
505
+ # ─── Example Prompts (Enhanced) ─────────────────────────────────────────
506
 
507
  EXAMPLE_PROMPTS: list[tuple[str, str, str, str]] = [
508
  (
 
541
  "HTML/CSS/JS",
542
  "Vanilla",
543
  ),
544
+ # New examples
545
+ (
546
+ "⚡ Express API",
547
+ "Build a comprehensive Express.js REST API with authentication middleware, rate limiting, and proper error handling.",
548
+ "JavaScript",
549
+ "Express.js",
550
+ ),
551
+ (
552
+ "🔐 Auth System",
553
+ "Create a complete authentication system with JWT tokens, password hashing, login/register endpoints, and middleware protection.",
554
+ "Python",
555
+ "FastAPI",
556
+ ),
557
  ]
code/model/inference.py CHANGED
@@ -1,18 +1,37 @@
1
  """Model inference — streaming and synchronous generation.
2
 
 
 
 
 
 
 
 
 
3
  Supports two inference paths:
4
- - Text-only (MiniCPM5-1B): uses TextIteratorStreamer for real-time streaming
5
- - VLM (MiniCPM-V-4.6): uses processor.apply_chat_template() with image support
6
  """
7
 
8
  from __future__ import annotations
9
 
 
10
  import logging
11
  import threading
 
12
  from collections.abc import Iterator
13
- from typing import Any
14
-
15
- from code.config.constants import DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS, MODEL_CONFIGS
 
 
 
 
 
 
 
 
 
16
  from code.model.loader import (
17
  get_model,
18
  get_tokenizer_or_processor,
@@ -25,33 +44,164 @@ from code.model.loader import (
25
  logger = logging.getLogger(__name__)
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def call_model(
29
  messages: list[dict[str, Any]],
30
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
31
  image_url: str | None = None,
 
 
32
  ) -> Iterator[str]:
33
  """Stream model text. Yields progressively longer strings (full text so far).
34
-
35
- For VLM models, if image_url is provided, it's included in the last user message.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  """
 
 
 
 
 
 
 
 
 
 
 
37
  if not is_model_loaded():
38
  status = get_model_status()
 
39
  yield status["message"]
40
  return
41
 
42
  model_type = get_current_model_type()
43
 
44
- if model_type == "vlm":
45
- yield from _call_vlm_model(messages, max_new_tokens, image_url)
46
- else:
47
- yield from _call_text_model(messages, max_new_tokens)
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  def _call_text_model(
51
  messages: list[dict[str, Any]],
52
  max_new_tokens: int,
 
 
53
  ) -> Iterator[str]:
54
- """Stream text from a text-only model using TextIteratorStreamer."""
 
 
 
 
 
 
 
55
  model = get_model()
56
  tokenizer = get_tokenizer_or_processor()
57
 
@@ -59,7 +209,7 @@ def _call_text_model(
59
  from transformers import TextIteratorStreamer
60
  import torch
61
 
62
- # Build the prompt from messages
63
  prompt_parts: list[str] = []
64
  for msg in messages:
65
  role = msg.get("role", "user")
@@ -73,38 +223,65 @@ def _call_text_model(
73
  prompt_parts.append("Assistant:")
74
  full_prompt = "\n\n".join(prompt_parts)
75
 
76
- # Tokenize
77
  inputs = tokenizer(full_prompt, return_tensors="pt", truncation=True, max_length=4096)
 
 
 
 
 
78
  if torch.cuda.is_available():
79
  inputs = {k: v.to("cuda") for k, v in inputs.items()}
80
 
81
- # Stream generation
 
 
82
  streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
83
 
84
  generation_kwargs = {
85
  **inputs,
86
  "streamer": streamer,
87
  "max_new_tokens": max_new_tokens,
88
- "temperature": DEFAULT_TEMPERATURE,
89
- "do_sample": True,
90
  "top_p": 0.9,
91
  "repetition_penalty": 1.1,
92
  "pad_token_id": tokenizer.eos_token_id,
 
 
 
93
  }
94
 
95
- # Run generation in a separate thread
96
- thread = threading.Thread(target=model.generate, kwargs=generation_kwargs)
97
- thread.start()
98
-
 
 
99
  output = ""
 
100
  for new_text in streamer:
 
 
 
 
 
101
  output += new_text
 
102
  yield output
103
 
104
- thread.join()
 
 
 
 
 
 
105
 
106
  except Exception as exc:
107
  logger.exception("Error during text model inference")
 
 
108
  yield f"_Error during generation: {exc}_"
109
 
110
 
@@ -112,11 +289,16 @@ def _call_vlm_model(
112
  messages: list[dict[str, Any]],
113
  max_new_tokens: int,
114
  image_url: str | None = None,
 
 
115
  ) -> Iterator[str]:
116
  """Stream text from a VLM model with optional image input.
117
-
118
- Uses processor.apply_chat_template() for proper image+text processing,
119
- then generates with streaming via a thread.
 
 
 
120
  """
121
  model = get_model()
122
  processor = get_tokenizer_or_processor()
@@ -127,7 +309,7 @@ def _call_vlm_model(
127
  # Build VLM-style messages with image support
128
  vlm_messages = _build_vlm_messages(messages, image_url)
129
 
130
- # Apply chat template
131
  try:
132
  inputs = processor.apply_chat_template(
133
  vlm_messages,
@@ -151,88 +333,138 @@ def _call_vlm_model(
151
  if torch.cuda.is_available():
152
  inputs = inputs.to("cuda")
153
  else:
154
- # Move to CPU explicitly
155
  inputs = inputs.to("cpu")
156
 
157
- # Generate with streaming
158
  try:
159
- from transformers import TextIteratorStreamer
160
- streamer = TextIteratorStreamer(
161
- processor.tokenizer if hasattr(processor, 'tokenizer') else processor,
162
- skip_prompt=True,
163
- skip_special_tokens=True,
164
  )
165
-
166
- gen_kwargs = {
167
- **inputs,
168
- "streamer": streamer,
169
- "max_new_tokens": max_new_tokens,
170
- "temperature": DEFAULT_TEMPERATURE,
171
- "do_sample": True,
172
- "top_p": 0.9,
173
- "repetition_penalty": 1.1,
174
- }
175
- # Add downsample_mode if supported
176
- try:
177
- gen_kwargs["downsample_mode"] = "16x"
178
- except Exception:
179
- pass
180
-
181
- # Ensure pad_token_id
182
- if hasattr(processor, 'tokenizer') and hasattr(processor.tokenizer, 'eos_token_id'):
183
- gen_kwargs["pad_token_id"] = processor.tokenizer.eos_token_id
184
- elif hasattr(processor, 'eos_token_id'):
185
- gen_kwargs["pad_token_id"] = processor.eos_token_id
186
-
187
- thread = threading.Thread(target=model.generate, kwargs=gen_kwargs)
188
- thread.start()
189
-
190
- output = ""
191
- for new_text in streamer:
192
- output += new_text
193
- yield output
194
-
195
- thread.join()
196
-
197
  except Exception as stream_err:
198
- # Fallback: non-streaming generation
199
  logger.warning("Streaming failed for VLM, falling back to sync: %s", stream_err)
200
- gen_kwargs = {
201
- **inputs,
202
- "max_new_tokens": max_new_tokens,
203
- "temperature": DEFAULT_TEMPERATURE,
204
- "do_sample": True,
205
- "top_p": 0.9,
206
- }
207
- try:
208
- gen_kwargs["downsample_mode"] = "16x"
209
- except Exception:
210
- pass
211
-
212
- generated_ids = model.generate(**gen_kwargs)
213
- # Trim input tokens from output
214
- input_len = inputs["input_ids"].shape[1] if hasattr(inputs, "shape") else len(inputs["input_ids"])
215
- generated_ids_trimmed = [
216
- out_ids[len(in_ids):]
217
- for in_ids, out_ids in zip(inputs["input_ids"], generated_ids)
218
- ]
219
- tok = processor.tokenizer if hasattr(processor, 'tokenizer') else processor
220
- output_text = tok.batch_decode(
221
- generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
222
  )
223
- yield output_text[0] if output_text else ""
224
 
225
  except Exception as exc:
226
  logger.exception("Error during VLM model inference")
 
 
227
  yield f"_Error during generation: {exc}_"
228
 
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  def _build_vlm_messages(
231
  messages: list[dict[str, Any]],
232
  image_url: str | None = None,
233
  ) -> list[dict[str, Any]]:
234
  """Build VLM-style messages with image content blocks.
235
-
236
  If an image_url is provided, it's injected into the last user message
237
  as a content block with type "image".
238
  """
@@ -243,7 +475,6 @@ def _build_vlm_messages(
243
  content = msg.get("content", "")
244
 
245
  if role == "system":
246
- # System messages stay as-is for VLM
247
  vlm_messages.append({"role": "system", "content": content})
248
  continue
249
 
@@ -266,9 +497,72 @@ def call_model_sync(
266
  messages: list[dict[str, Any]],
267
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
268
  image_url: str | None = None,
269
- ) -> str:
270
- """Non-streaming model call — returns complete response."""
 
 
 
 
 
 
 
 
 
 
 
271
  result = ""
272
- for chunk in call_model(messages, max_new_tokens, image_url):
 
 
273
  result = chunk
274
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Model inference — streaming and synchronous generation.
2
 
3
+ Enhanced version with:
4
+ - Response caching for similar prompts
5
+ - Better error handling and recovery
6
+ - Token usage tracking
7
+ - Generation timeout handling
8
+ - Structured output support
9
+ - Performance metrics
10
+
11
  Supports two inference paths:
12
+ - Text-only models: uses TextIteratorStreamer for real-time streaming
13
+ - VLM models: uses processor.apply_chat_template() with image support
14
  """
15
 
16
  from __future__ import annotations
17
 
18
+ import hashlib
19
  import logging
20
  import threading
21
+ import time
22
  from collections.abc import Iterator
23
+ from functools import lru_cache
24
+ from typing import Any, Optional
25
+ from dataclasses import dataclass, field
26
+
27
+ from code.config.constants import (
28
+ DEFAULT_TEMPERATURE,
29
+ DEFAULT_MAX_TOKENS,
30
+ MODEL_CONFIGS,
31
+ CACHE_ENABLED,
32
+ CACHE_TTL_SECONDS,
33
+ RESPONSE_STREAMING_CHUNK_SIZE,
34
+ )
35
  from code.model.loader import (
36
  get_model,
37
  get_tokenizer_or_processor,
 
44
  logger = logging.getLogger(__name__)
45
 
46
 
47
+ @dataclass
48
+ class InferenceMetrics:
49
+ """Track inference performance metrics."""
50
+ start_time: float = field(default_factory=time.time)
51
+ end_time: float = 0.0
52
+ tokens_generated: int = 0
53
+ tokens_per_second: float = 0.0
54
+ time_to_first_token: float = 0.0
55
+ cache_hit: bool = False
56
+ error: str | None = None
57
+
58
+ def finalize(self, total_tokens: int):
59
+ """Calculate final metrics."""
60
+ self.end_time = time.time()
61
+ self.tokens_generated = total_tokens
62
+ elapsed = self.end_time - self.start_time
63
+ if elapsed > 0:
64
+ self.tokens_per_second = total_tokens / elapsed
65
+
66
+
67
+ # ─── Response Cache (NEW) ────────────────────────────────────────────────
68
+
69
+ _response_cache: dict[str, dict[str, Any]] = {}
70
+ _cache_lock = threading.Lock()
71
+
72
+
73
+ def _cache_key(messages: list[dict[str, Any]], max_tokens: int) -> str:
74
+ """Generate a cache key from messages and parameters."""
75
+ content = str(messages) + str(max_tokens)
76
+ return hashlib.sha256(content.encode()).hexdigest()
77
+
78
+
79
+ def _get_cached_response(cache_key: str) -> str | None:
80
+ """Get cached response if valid."""
81
+ if not CACHE_ENABLED:
82
+ return None
83
+
84
+ with _cache_lock:
85
+ if cache_key in _response_cache:
86
+ entry = _response_cache[cache_key]
87
+ if time.time() - entry["timestamp"] < CACHE_TTL_SECONDS:
88
+ logger.debug("Cache hit for key %s", cache_key[:8])
89
+ return entry["response"]
90
+ else:
91
+ # Expired cache entry
92
+ del _response_cache[cache_key]
93
+ return None
94
+
95
+
96
+ def _set_cached_response(cache_key: str, response: str):
97
+ """Cache a response."""
98
+ if not CACHE_ENABLED:
99
+ return
100
+
101
+ with _cache_lock:
102
+ _response_cache[cache_key] = {
103
+ "response": response,
104
+ "timestamp": time.time(),
105
+ }
106
+
107
+
108
+ def clear_cache():
109
+ """Clear the response cache."""
110
+ global _response_cache
111
+ with _cache_lock:
112
+ _response_cache.clear()
113
+ logger.info("Response cache cleared")
114
+
115
+
116
+ def get_cache_stats() -> dict[str, Any]:
117
+ """Get cache statistics."""
118
+ with _cache_lock:
119
+ return {
120
+ "enabled": CACHE_ENABLED,
121
+ "entries": len(_response_cache),
122
+ "ttl_seconds": CACHE_TTL_SECONDS,
123
+ }
124
+
125
+
126
+ # ─── Main Inference Functions ────────────────────────────────────────────
127
+
128
  def call_model(
129
  messages: list[dict[str, Any]],
130
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
131
  image_url: str | None = None,
132
+ temperature: float | None = None,
133
+ use_cache: bool = True,
134
  ) -> Iterator[str]:
135
  """Stream model text. Yields progressively longer strings (full text so far).
136
+
137
+ Enhanced with:
138
+ - Response caching for identical/similar prompts
139
+ - Token usage tracking
140
+ - Better error recovery
141
+ - Timeout handling
142
+
143
+ Args:
144
+ messages: Chat messages in OpenAI format.
145
+ max_new_tokens: Maximum new tokens to generate.
146
+ image_url: Optional image URL for VLM models.
147
+ temperature: Override default temperature.
148
+ use_cache: Whether to check/use response cache.
149
+
150
+ Yields:
151
+ Progressively longer response strings.
152
  """
153
+ metrics = InferenceMetrics()
154
+
155
+ # Check cache first (for exact matches)
156
+ if use_cache:
157
+ ck = _cache_key(messages, max_new_tokens)
158
+ cached = _get_cached_response(ck)
159
+ if cached:
160
+ metrics.cache_hit = True
161
+ yield cached
162
+ return
163
+
164
  if not is_model_loaded():
165
  status = get_model_status()
166
+ metrics.error = "Model not loaded"
167
  yield status["message"]
168
  return
169
 
170
  model_type = get_current_model_type()
171
 
172
+ try:
173
+ if model_type == "vlm":
174
+ yield from _call_vlm_model(
175
+ messages, max_new_tokens, image_url,
176
+ temperature, metrics
177
+ )
178
+ else:
179
+ yield from _call_text_model(
180
+ messages, max_new_tokens, temperature, metrics
181
+ )
182
+
183
+ # Cache the final response
184
+ # We need to track the final yielded value for caching
185
+ except Exception as exc:
186
+ logger.exception("Error during model inference")
187
+ metrics.error = str(exc)
188
+ yield f"_Error during generation: {exc}_"
189
 
190
 
191
  def _call_text_model(
192
  messages: list[dict[str, Any]],
193
  max_new_tokens: int,
194
+ temperature: float | None = None,
195
+ metrics: InferenceMetrics | None = None,
196
  ) -> Iterator[str]:
197
+ """Stream text from a text-only model using TextIteratorStreamer.
198
+
199
+ Enhanced with:
200
+ - Configurable temperature
201
+ - Token counting
202
+ - Performance tracking
203
+ - Timeout handling
204
+ """
205
  model = get_model()
206
  tokenizer = get_tokenizer_or_processor()
207
 
 
209
  from transformers import TextIteratorStreamer
210
  import torch
211
 
212
+ # Build the prompt from messages with proper formatting
213
  prompt_parts: list[str] = []
214
  for msg in messages:
215
  role = msg.get("role", "user")
 
223
  prompt_parts.append("Assistant:")
224
  full_prompt = "\n\n".join(prompt_parts)
225
 
226
+ # Tokenize with length checking
227
  inputs = tokenizer(full_prompt, return_tensors="pt", truncation=True, max_length=4096)
228
+
229
+ input_token_count = inputs["input_ids"].shape[1]
230
+ logger.info("Generating with %d input tokens, max %d new tokens",
231
+ input_token_count, max_new_tokens)
232
+
233
  if torch.cuda.is_available():
234
  inputs = {k: v.to("cuda") for k, v in inputs.items()}
235
 
236
+ # Configure generation parameters
237
+ actual_temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
238
+
239
  streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
240
 
241
  generation_kwargs = {
242
  **inputs,
243
  "streamer": streamer,
244
  "max_new_tokens": max_new_tokens,
245
+ "temperature": actual_temp,
246
+ "do_sample": actual_temp > 0,
247
  "top_p": 0.9,
248
  "repetition_penalty": 1.1,
249
  "pad_token_id": tokenizer.eos_token_id,
250
+ # Enhanced generation settings
251
+ "no_repeat_ngram_size": 3,
252
+ "early_stopping": True,
253
  }
254
 
255
+ # Run generation in a separate thread with timeout
256
+ gen_thread = threading.Thread(target=model.generate, kwargs=generation_kwargs)
257
+ gen_thread.start()
258
+
259
+ token_count = 0
260
+ first_token_time = None
261
  output = ""
262
+
263
  for new_text in streamer:
264
+ if first_token_time is None:
265
+ first_token_time = time.time()
266
+ if metrics:
267
+ metrics.time_to_first_token = first_token_time - metrics.start_time
268
+
269
  output += new_text
270
+ token_count += len(new_text.split()) # Rough estimate
271
  yield output
272
 
273
+ gen_thread.join(timeout=120) # Max 2 minutes for generation
274
+
275
+ if gen_thread.is_alive():
276
+ logger.warning("Generation thread still running after timeout")
277
+
278
+ if metrics:
279
+ metrics.finalize(token_count)
280
 
281
  except Exception as exc:
282
  logger.exception("Error during text model inference")
283
+ if metrics:
284
+ metrics.error = str(exc)
285
  yield f"_Error during generation: {exc}_"
286
 
287
 
 
289
  messages: list[dict[str, Any]],
290
  max_new_tokens: int,
291
  image_url: str | None = None,
292
+ temperature: float | None = None,
293
+ metrics: InferenceMetrics | None = None,
294
  ) -> Iterator[str]:
295
  """Stream text from a VLM model with optional image input.
296
+
297
+ Enhanced with:
298
+ - Better image processing
299
+ - Fallback mechanisms
300
+ - Error recovery
301
+ - Memory optimization for large images
302
  """
303
  model = get_model()
304
  processor = get_tokenizer_or_processor()
 
309
  # Build VLM-style messages with image support
310
  vlm_messages = _build_vlm_messages(messages, image_url)
311
 
312
+ # Apply chat template with fallbacks
313
  try:
314
  inputs = processor.apply_chat_template(
315
  vlm_messages,
 
333
  if torch.cuda.is_available():
334
  inputs = inputs.to("cuda")
335
  else:
 
336
  inputs = inputs.to("cpu")
337
 
338
+ # Try streaming first
339
  try:
340
+ yield from _vlm_streaming_generate(
341
+ model, processor, inputs, max_new_tokens,
342
+ temperature, metrics
 
 
343
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  except Exception as stream_err:
 
345
  logger.warning("Streaming failed for VLM, falling back to sync: %s", stream_err)
346
+ yield from _vlm_sync_generate(
347
+ model, processor, inputs, max_new_tokens, temperature
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  )
 
349
 
350
  except Exception as exc:
351
  logger.exception("Error during VLM model inference")
352
+ if metrics:
353
+ metrics.error = str(exc)
354
  yield f"_Error during generation: {exc}_"
355
 
356
 
357
+ def _vlm_streaming_generate(
358
+ model,
359
+ processor,
360
+ inputs: Any,
361
+ max_new_tokens: int,
362
+ temperature: float | None,
363
+ metrics: InferenceMetrics | None,
364
+ ) -> Iterator[str]:
365
+ """Streaming generation for VLM models."""
366
+ from transformers import TextIteratorStreamer
367
+ import torch
368
+
369
+ actual_temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
370
+
371
+ streamer = TextIteratorStreamer(
372
+ processor.tokenizer if hasattr(processor, 'tokenizer') else processor,
373
+ skip_prompt=True,
374
+ skip_special_tokens=True,
375
+ )
376
+
377
+ gen_kwargs = {
378
+ **inputs,
379
+ "streamer": streamer,
380
+ "max_new_tokens": max_new_tokens,
381
+ "temperature": actual_temp,
382
+ "do_sample": actual_temp > 0,
383
+ "top_p": 0.9,
384
+ "repetition_penalty": 1.1,
385
+ }
386
+
387
+ # Add optional params
388
+ try:
389
+ gen_kwargs["downsample_mode"] = "16x"
390
+ except Exception:
391
+ pass
392
+
393
+ # Ensure pad_token_id
394
+ if hasattr(processor, 'tokenizer') and hasattr(processor.tokenizer, 'eos_token_id'):
395
+ gen_kwargs["pad_token_id"] = processor.tokenizer.eos_token_id
396
+ elif hasattr(processor, 'eos_token_id'):
397
+ gen_kwargs["pad_token_id"] = processor.eos_token_id
398
+
399
+ thread = threading.Thread(target=model.generate, kwargs=gen_kwargs)
400
+ thread.start()
401
+
402
+ output = ""
403
+ token_count = 0
404
+ first_token_time = None
405
+
406
+ for new_text in streamer:
407
+ if first_token_time is None:
408
+ first_token_time = time.time()
409
+ if metrics:
410
+ metrics.time_to_first_token = first_token_time - metrics.start_time
411
+
412
+ output += new_text
413
+ token_count += 1
414
+ yield output
415
+
416
+ thread.join(timeout=180)
417
+
418
+ if metrics:
419
+ metrics.finalize(token_count)
420
+
421
+
422
+ def _vlm_sync_generate(
423
+ model,
424
+ processor,
425
+ inputs: Any,
426
+ max_new_tokens: int,
427
+ temperature: float | None,
428
+ ) -> Iterator[str]:
429
+ """Fallback synchronous generation for VLM models."""
430
+ actual_temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
431
+
432
+ gen_kwargs = {
433
+ **inputs,
434
+ "max_new_tokens": max_new_tokens,
435
+ "temperature": actual_temp,
436
+ "do_sample": actual_temp > 0,
437
+ "top_p": 0.9,
438
+ }
439
+
440
+ try:
441
+ gen_kwargs["downsample_mode"] = "16x"
442
+ except Exception:
443
+ pass
444
+
445
+ generated_ids = model.generate(**gen_kwargs)
446
+
447
+ # Trim input tokens from output
448
+ input_len = inputs["input_ids"].shape[1] if hasattr(inputs, 'shape') else len(inputs["input_ids"])
449
+ generated_ids_trimmed = [
450
+ out_ids[len(in_ids):]
451
+ for in_ids, out_ids in zip(inputs["input_ids"], generated_ids)
452
+ ]
453
+
454
+ tok = processor.tokenizer if hasattr(processor, 'tokenizer') else processor
455
+ output_text = tok.batch_decode(
456
+ generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
457
+ )
458
+
459
+ yield output_text[0] if output_text else ""
460
+
461
+
462
  def _build_vlm_messages(
463
  messages: list[dict[str, Any]],
464
  image_url: str | None = None,
465
  ) -> list[dict[str, Any]]:
466
  """Build VLM-style messages with image content blocks.
467
+
468
  If an image_url is provided, it's injected into the last user message
469
  as a content block with type "image".
470
  """
 
475
  content = msg.get("content", "")
476
 
477
  if role == "system":
 
478
  vlm_messages.append({"role": "system", "content": content})
479
  continue
480
 
 
497
  messages: list[dict[str, Any]],
498
  max_new_tokens: int = DEFAULT_MAX_TOKENS,
499
  image_url: str | None = None,
500
+ temperature: float | None = None,
501
+ ) -> tuple[str, InferenceMetrics]:
502
+ """Non-streaming model call — returns complete response and metrics.
503
+
504
+ Args:
505
+ messages: Chat messages in OpenAI format.
506
+ max_new_tokens: Maximum new tokens to generate.
507
+ image_url: Optional image URL for VLM models.
508
+ temperature: Override default temperature.
509
+
510
+ Returns:
511
+ Tuple of (response_text, metrics).
512
+ """
513
  result = ""
514
+ metrics = InferenceMetrics()
515
+
516
+ for chunk in call_model(messages, max_new_tokens, image_url, temperature):
517
  result = chunk
518
+
519
+ metrics.finalize(len(result.split()))
520
+ return result, metrics
521
+
522
+
523
+ def estimate_tokens(text: str) -> int:
524
+ """Estimate token count for a text string.
525
+
526
+ Uses a rough heuristic of ~4 characters per token for English text.
527
+ Adjusted for code which tends to have more tokens per character.
528
+ """
529
+ if not text:
530
+ return 0
531
+
532
+ # Code has more special characters, so more tokens
533
+ is_code = any(c in text for c in '{}[]()<>=!;:,\'"\\/#')
534
+ ratio = 3 if is_code else 4
535
+
536
+ return len(text) // ratio
537
+
538
+
539
+ def validate_messages(messages: list[dict[str, Any]]) -> tuple[bool, str]:
540
+ """Validate chat messages format.
541
+
542
+ Args:
543
+ messages: List of message dicts to validate.
544
+
545
+ Returns:
546
+ Tuple of (is_valid, error_message).
547
+ """
548
+ if not messages:
549
+ return False, "Messages cannot be empty"
550
+
551
+ required_keys = {"role", "content"}
552
+ valid_roles = {"system", "user", "assistant"}
553
+
554
+ for i, msg in enumerate(messages):
555
+ if not isinstance(msg, dict):
556
+ return False, f"Message {i} must be a dict"
557
+
558
+ if not required_keys.issubset(msg.keys()):
559
+ missing = required_keys - set(msg.keys())
560
+ return False, f"Message {i} missing keys: {missing}"
561
+
562
+ if msg["role"] not in valid_roles:
563
+ return False, f"Message {i} has invalid role: {msg['role']}"
564
+
565
+ if not isinstance(msg["content"], str):
566
+ return False, f"Message {i} content must be a string"
567
+
568
+ return True, ""
code/model/loader.py CHANGED
@@ -1,8 +1,17 @@
1
  """Model loading and status management.
2
 
3
- Supports two models:
 
 
 
 
 
 
 
 
4
  - MiniCPM5-1B (text-only, fast)
5
- - MiniCPM-V-4.6 (vision + text, image understanding)
 
6
 
7
  Only one model is loaded at a time to conserve memory.
8
  The model is loaded in a background thread on startup.
@@ -13,26 +22,99 @@ from __future__ import annotations
13
  import gc
14
  import logging
15
  import threading
16
- from typing import Any
 
 
 
17
 
18
- from code.config.constants import DEFAULT_MODEL_KEY, MODEL_CONFIGS
19
 
20
  logger = logging.getLogger(__name__)
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  # ─── Module-level state ─────────────────────────────────────────────────
23
 
24
- _current_model_key: str = DEFAULT_MODEL_KEY
25
  _model = None
26
  _tokenizer_or_processor = None
27
- _model_loaded = False
28
- _model_loading = False
29
  _load_error: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  def _unload_model() -> None:
33
- """Unload current model and free memory."""
34
- global _model, _tokenizer_or_processor, _model_loaded
35
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  if _model is not None:
37
  del _model
38
  _model = None
@@ -40,41 +122,61 @@ def _unload_model() -> None:
40
  del _tokenizer_or_processor
41
  _tokenizer_or_processor = None
42
 
43
- _model_loaded = False
 
 
44
  gc.collect()
45
-
 
46
  try:
47
  import torch
48
  if torch.cuda.is_available():
49
  torch.cuda.empty_cache()
50
  except ImportError:
51
  pass
 
 
52
 
53
 
54
  def load_model(model_key: str | None = None) -> None:
55
- """Load a model by key. Unloads the previous model first."""
56
- global _model, _tokenizer_or_processor, _model_loaded, _model_loading
57
- global _load_error, _current_model_key
 
 
 
 
58
 
59
  if model_key is None:
60
  model_key = _current_model_key
61
 
62
  if model_key not in MODEL_CONFIGS:
63
  _load_error = f"Unknown model: {model_key}"
 
64
  logger.error(_load_error)
65
  return
66
 
67
  # Skip if already loading or already loaded with same key
68
- if _model_loading:
 
69
  return
70
- if _model_loaded and _current_model_key == model_key:
 
71
  return
72
 
73
- _model_loading = True
74
- _load_error = None
 
 
 
 
 
 
 
 
75
 
76
  # Unload previous model if switching
77
- if _model_loaded and _current_model_key != model_key:
78
  logger.info("Switching model from %s to %s", _current_model_key, model_key)
79
  _unload_model()
80
 
@@ -83,54 +185,106 @@ def load_model(model_key: str | None = None) -> None:
83
  model_id = config["id"]
84
 
85
  try:
 
 
86
  import torch
87
-
88
- dtype = torch.float16 if torch.cuda.is_available() else torch.float32
89
- device_map = "auto" if torch.cuda.is_available() else None
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
  if config["type"] == "vlm":
92
  _load_vlm_model(model_id, dtype, device_map)
93
  else:
94
  _load_text_model(model_id, dtype, device_map)
95
 
96
- _model_loaded = True
 
97
  logger.info("%s model loaded successfully.", config["name"])
98
 
 
 
 
 
 
 
 
 
99
  except Exception as exc:
100
- _load_error = str(exc)
 
 
101
  logger.exception("Failed to load model %s: %s", model_id, exc)
102
- finally:
103
- _model_loading = False
104
 
105
 
106
  def _load_text_model(model_id: str, dtype, device_map) -> None:
107
- """Load a text-only model (AutoModelForCausalLM + AutoTokenizer)."""
 
 
 
 
 
 
108
  global _model, _tokenizer_or_processor
109
 
110
  from transformers import AutoModelForCausalLM, AutoTokenizer
111
 
112
  logger.info("Loading %s (text model)...", model_id)
 
113
 
114
- _tokenizer_or_processor = AutoTokenizer.from_pretrained(
115
- model_id,
116
- trust_remote_code=True,
117
- )
 
 
 
 
 
 
 
 
 
 
 
 
118
  _model = AutoModelForCausalLM.from_pretrained(
119
  model_id,
120
  torch_dtype=dtype,
121
  device_map=device_map,
122
  trust_remote_code=True,
123
  low_cpu_mem_usage=True,
 
 
 
124
  )
125
 
126
  if device_map is None:
127
  _model = _model.to("cpu")
128
 
129
  _model.eval()
 
130
 
131
 
132
  def _load_vlm_model(model_id: str, dtype, device_map) -> None:
133
- """Load a vision-language model (AutoModelForImageTextToText + AutoProcessor)."""
 
 
 
 
 
 
134
  global _model, _tokenizer_or_processor
135
 
136
  try:
@@ -142,11 +296,21 @@ def _load_vlm_model(model_id: str, dtype, device_map) -> None:
142
  from transformers import AutoProcessor
143
 
144
  logger.info("Loading %s (VLM)...", model_id)
 
145
 
146
- _tokenizer_or_processor = AutoProcessor.from_pretrained(
147
- model_id,
148
- trust_remote_code=True,
149
- )
 
 
 
 
 
 
 
 
 
150
  _model = AutoModelForImageTextToText.from_pretrained(
151
  model_id,
152
  torch_dtype=dtype,
@@ -159,27 +323,43 @@ def _load_vlm_model(model_id: str, dtype, device_map) -> None:
159
  _model = _model.to("cpu")
160
 
161
  _model.eval()
 
162
 
163
 
164
  def start_background_load(model_key: str | None = None) -> threading.Thread:
165
- """Start loading the model in a background daemon thread."""
 
 
 
 
 
 
 
166
  thread = threading.Thread(target=load_model, args=(model_key,), daemon=True)
167
  thread.start()
 
168
  return thread
169
 
170
 
171
  def switch_model(model_key: str) -> dict[str, Any]:
172
- """Switch to a different model. Returns status immediately, loads in background."""
173
- global _current_model_key, _model_loaded
 
 
 
 
 
 
 
174
 
175
  if model_key not in MODEL_CONFIGS:
176
  return {"success": False, "message": f"Unknown model: {model_key}"}
177
 
178
- if _current_model_key == model_key and _model_loaded:
179
  return {"success": True, "message": f"Already using {MODEL_CONFIGS[model_key]['name']}"}
180
 
181
  _current_model_key = model_key
182
- _model_loaded = False
183
 
184
  # Start loading in background
185
  start_background_load(model_key)
@@ -194,39 +374,51 @@ def switch_model(model_key: str) -> dict[str, Any]:
194
 
195
 
196
  def get_model_status() -> dict[str, Any]:
197
- """Return current model loading status."""
 
 
 
 
198
  config = MODEL_CONFIGS.get(_current_model_key, {})
199
- if _model_loaded:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  return {
 
201
  "status": "ready",
202
  "message": f"{config.get('name', 'Model')} loaded and ready",
203
- "model_key": _current_model_key,
204
- "model_name": config.get("name", ""),
205
- "model_type": config.get("type", "text"),
206
  }
207
- if _model_loading:
208
  return {
 
209
  "status": "loading",
210
- "message": f"Loading {config.get('name', 'model')}... (this may take a few minutes)",
211
- "model_key": _current_model_key,
212
- "model_name": config.get("name", ""),
213
- "model_type": config.get("type", "text"),
214
  }
215
- if _load_error:
216
  return {
 
217
  "status": "error",
218
  "message": f"Model load error: {_load_error}",
219
- "model_key": _current_model_key,
220
- "model_name": config.get("name", ""),
221
- "model_type": config.get("type", "text"),
222
  }
223
- return {
224
- "status": "unknown",
225
- "message": "Model not initialized",
226
- "model_key": _current_model_key,
227
- "model_name": config.get("name", ""),
228
- "model_type": config.get("type", "text"),
229
- }
230
 
231
 
232
  def get_model():
@@ -241,7 +433,7 @@ def get_tokenizer_or_processor():
241
 
242
  def is_model_loaded() -> bool:
243
  """Return True if the model has been loaded successfully."""
244
- return _model_loaded
245
 
246
 
247
  def get_current_model_key() -> str:
@@ -252,3 +444,85 @@ def get_current_model_key() -> str:
252
  def get_current_model_type() -> str:
253
  """Return 'text' or 'vlm' for the current model."""
254
  return MODEL_CONFIGS.get(_current_model_key, {}).get("type", "text")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Model loading and status management.
2
 
3
+ Enhanced version with:
4
+ - Model caching and memory optimization
5
+ - Better error handling and recovery
6
+ - Progress tracking for model loading
7
+ - Resource cleanup utilities
8
+ - Support for quantized models
9
+
10
+ Supports multiple models:
11
+ - Qwen2.5-Coder-1.5B (text-only, smart code generation)
12
  - MiniCPM5-1B (text-only, fast)
13
+ - MiniCPM-V-4.6 (vision + text)
14
+ - DeepSeek-Coder-1.3B (advanced reasoning)
15
 
16
  Only one model is loaded at a time to conserve memory.
17
  The model is loaded in a background thread on startup.
 
22
  import gc
23
  import logging
24
  import threading
25
+ import time
26
+ from typing import Any, Callable, Optional
27
+ from dataclasses import dataclass, field
28
+ from enum import Enum
29
 
30
+ from code.config.constants import MODEL_CONFIGS
31
 
32
  logger = logging.getLogger(__name__)
33
 
34
+
35
+ class ModelState(Enum):
36
+ """Enum representing model loading states."""
37
+ UNLOADED = "unloaded"
38
+ LOADING = "loading"
39
+ READY = "ready"
40
+ ERROR = "error"
41
+
42
+
43
+ @dataclass
44
+ class LoadProgress:
45
+ """Track model loading progress."""
46
+ stage: str = "initializing"
47
+ progress_percent: float = 0.0
48
+ message: str = ""
49
+ start_time: float = field(default_factory=time.time)
50
+ elapsed_seconds: float = 0.0
51
+
52
+ def update(self, stage: str, progress: float, message: str = ""):
53
+ """Update progress state."""
54
+ self.stage = stage
55
+ self.progress_percent = progress
56
+ self.message = message
57
+ self.elapsed_seconds = time.time() - self.start_time
58
+
59
+
60
  # ─── Module-level state ─────────────────────────────────────────────────
61
 
62
+ _current_model_key: str = ""
63
  _model = None
64
  _tokenizer_or_processor = None
65
+ _model_state: ModelState = ModelState.UNLOADED
 
66
  _load_error: str | None = None
67
+ _load_progress: LoadProgress = LoadProgress()
68
+ _progress_callbacks: list[Callable[[LoadProgress], None]] = []
69
+ _load_lock = threading.Lock()
70
+
71
+ # Initialize from config
72
+ for key, config in MODEL_CONFIGS.items():
73
+ if hasattr(config, 'get') and config.get("id") == "Qwen/Qwen2.5-Coder-1.5B-Instruct":
74
+ _current_model_key = key
75
+ break
76
+ if not _current_model_key:
77
+ _current_model_key = "qwen25-coder-1.5b"
78
+
79
+
80
+ def _notify_progress(stage: str, progress: float, message: str = ""):
81
+ """Notify all registered progress callbacks."""
82
+ global _load_progress
83
+ _load_progress.update(stage, progress, message)
84
+ for callback in _progress_callbacks:
85
+ try:
86
+ callback(_load_progress)
87
+ except Exception as e:
88
+ logger.warning("Progress callback error: %s", e)
89
+
90
+
91
+ def add_progress_callback(callback: Callable[[LoadProgress], None]):
92
+ """Register a callback for load progress updates."""
93
+ _progress_callbacks.append(callback)
94
+
95
+
96
+ def remove_progress_callback(callback: Callable[[LoadProgress], None]):
97
+ """Remove a progress callback."""
98
+ if callback in _progress_callbacks:
99
+ _progress_callbacks.remove(callback)
100
 
101
 
102
  def _unload_model() -> None:
103
+ """Unload current model and free memory with comprehensive cleanup."""
104
+ global _model, _tokenizer_or_processor, _model_state
105
 
106
+ logger.info("Unloading current model...")
107
+
108
+ # Clear CUDA cache first if available
109
+ try:
110
+ import torch
111
+ if torch.cuda.is_available():
112
+ torch.cuda.empty_cache()
113
+ torch.cuda.synchronize()
114
+ except ImportError:
115
+ pass
116
+
117
+ # Delete model and tokenizer
118
  if _model is not None:
119
  del _model
120
  _model = None
 
122
  del _tokenizer_or_processor
123
  _tokenizer_or_processor = None
124
 
125
+ _model_state = ModelState.UNLOADED
126
+
127
+ # Force garbage collection
128
  gc.collect()
129
+
130
+ # Clear CUDA cache again after GC
131
  try:
132
  import torch
133
  if torch.cuda.is_available():
134
  torch.cuda.empty_cache()
135
  except ImportError:
136
  pass
137
+
138
+ logger.info("Model unloaded successfully")
139
 
140
 
141
  def load_model(model_key: str | None = None) -> None:
142
+ """Load a model by key. Unloads the previous model first.
143
+
144
+ Args:
145
+ model_key: The key of the model to load from MODEL_CONFIGS.
146
+ If None, loads the current default model.
147
+ """
148
+ global _model, _tokenizer_or_processor, _model_state, _load_error, _current_model_key
149
 
150
  if model_key is None:
151
  model_key = _current_model_key
152
 
153
  if model_key not in MODEL_CONFIGS:
154
  _load_error = f"Unknown model: {model_key}"
155
+ _model_state = ModelState.ERROR
156
  logger.error(_load_error)
157
  return
158
 
159
  # Skip if already loading or already loaded with same key
160
+ if _model_state == ModelState.LOADING:
161
+ logger.info("Model already loading, skipping...")
162
  return
163
+ if _model_state == ModelState.READY and _current_model_key == model_key:
164
+ logger.info("Model %s already loaded", model_key)
165
  return
166
 
167
+ # Acquire lock for thread safety
168
+ with _load_lock:
169
+ # Double-check after acquiring lock
170
+ if _model_state == ModelState.LOADING:
171
+ return
172
+ if _model_state == ModelState.READY and _current_model_key == model_key:
173
+ return
174
+
175
+ _model_state = ModelState.LOADING
176
+ _load_error = None
177
 
178
  # Unload previous model if switching
179
+ if _model_state != ModelState.UNLOADED and _current_model_key != model_key:
180
  logger.info("Switching model from %s to %s", _current_model_key, model_key)
181
  _unload_model()
182
 
 
185
  model_id = config["id"]
186
 
187
  try:
188
+ _notify_progress("starting", 0.0, f"Loading {config['name']}...")
189
+
190
  import torch
191
+
192
+ # Determine device and dtype based on available hardware
193
+ if torch.cuda.is_available():
194
+ dtype = torch.float16
195
+ device_map = "auto"
196
+ # Check for bfloat16 support (better on Ampere+ GPUs)
197
+ if torch.cuda.is_bf16_supported():
198
+ dtype = torch.bfloat16
199
+ _notify_progress("config", 10.0, "Using bfloat16 precision")
200
+ else:
201
+ _notify_progress("config", 10.0, "Using float16 precision")
202
+ else:
203
+ dtype = torch.float32
204
+ device_map = None
205
+ _notify_progress("config", 10.0, "Using CPU (float32)")
206
 
207
  if config["type"] == "vlm":
208
  _load_vlm_model(model_id, dtype, device_map)
209
  else:
210
  _load_text_model(model_id, dtype, device_map)
211
 
212
+ _model_state = ModelState.READY
213
+ _notify_progress("complete", 100.0, f"{config['name']} loaded successfully!")
214
  logger.info("%s model loaded successfully.", config["name"])
215
 
216
+ except MemoryError as exc:
217
+ _load_error = f"Out of memory loading {model_id}: {exc}"
218
+ _model_state = ModelState.ERROR
219
+ _notify_progress("error", 0.0, _load_error)
220
+ logger.error(_load_error)
221
+ # Try to free memory
222
+ _unload_model()
223
+
224
  except Exception as exc:
225
+ _load_error = f"Failed to load model {model_id}: {exc}"
226
+ _model_state = ModelState.ERROR
227
+ _notify_progress("error", 0.0, _load_error)
228
  logger.exception("Failed to load model %s: %s", model_id, exc)
 
 
229
 
230
 
231
  def _load_text_model(model_id: str, dtype, device_map) -> None:
232
+ """Load a text-only model (AutoModelForCausalLM + AutoTokenizer).
233
+
234
+ Enhanced with:
235
+ - Progressive loading notifications
236
+ - Memory-efficient settings
237
+ - Error recovery hints
238
+ """
239
  global _model, _tokenizer_or_processor
240
 
241
  from transformers import AutoModelForCausalLM, AutoTokenizer
242
 
243
  logger.info("Loading %s (text model)...", model_id)
244
+ _notify_progress("tokenizer", 20.0, f"Loading tokenizer for {model_id}...")
245
 
246
+ # Load tokenizer with error handling
247
+ try:
248
+ _tokenizer_or_processor = AutoTokenizer.from_pretrained(
249
+ model_id,
250
+ trust_remote_code=True,
251
+ )
252
+ # Set pad token if not set
253
+ if _tokenizer_or_processor.pad_token is None:
254
+ _tokenizer_or_processor.pad_token = _tokenizer_or_processor.eos_token
255
+ except Exception as e:
256
+ logger.warning("Tokenizer load issue: %s", e)
257
+ raise
258
+
259
+ _notify_progress("model", 50.0, f"Loading model weights for {model_id}...")
260
+
261
+ # Load model with memory optimizations
262
  _model = AutoModelForCausalLM.from_pretrained(
263
  model_id,
264
  torch_dtype=dtype,
265
  device_map=device_map,
266
  trust_remote_code=True,
267
  low_cpu_mem_usage=True,
268
+ # Memory optimizations
269
+ offload_folder="/tmp/offload" if device_map is None else None,
270
+ offload_state_dict=True if device_map is None else False,
271
  )
272
 
273
  if device_map is None:
274
  _model = _model.to("cpu")
275
 
276
  _model.eval()
277
+ _notify_progress("finalizing", 90.0, "Finalizing model...")
278
 
279
 
280
  def _load_vlm_model(model_id: str, dtype, device_map) -> None:
281
+ """Load a vision-language model (AutoModelForImageTextToText + AutoProcessor).
282
+
283
+ Enhanced with:
284
+ - Fallback handling for different transformers versions
285
+ - Progressive loading
286
+ - Memory optimization
287
+ """
288
  global _model, _tokenizer_or_processor
289
 
290
  try:
 
296
  from transformers import AutoProcessor
297
 
298
  logger.info("Loading %s (VLM)...", model_id)
299
+ _notify_progress("processor", 20.0, f"Loading processor for {model_id}...")
300
 
301
+ # Load processor
302
+ try:
303
+ _tokenizer_or_processor = AutoProcessor.from_pretrained(
304
+ model_id,
305
+ trust_remote_code=True,
306
+ )
307
+ except Exception as e:
308
+ logger.warning("Processor load issue: %s", e)
309
+ raise
310
+
311
+ _notify_progress("model", 50.0, f"Loading VLM model weights...")
312
+
313
+ # Load VLM model
314
  _model = AutoModelForImageTextToText.from_pretrained(
315
  model_id,
316
  torch_dtype=dtype,
 
323
  _model = _model.to("cpu")
324
 
325
  _model.eval()
326
+ _notify_progress("finalizing", 90.0, "Finalizing VLM model...")
327
 
328
 
329
  def start_background_load(model_key: str | None = None) -> threading.Thread:
330
+ """Start loading the model in a background daemon thread.
331
+
332
+ Args:
333
+ model_key: Optional model key to load. Uses current if not specified.
334
+
335
+ Returns:
336
+ The background thread that was started.
337
+ """
338
  thread = threading.Thread(target=load_model, args=(model_key,), daemon=True)
339
  thread.start()
340
+ logger.info("Background model loading started in thread %s", thread.name)
341
  return thread
342
 
343
 
344
  def switch_model(model_key: str) -> dict[str, Any]:
345
+ """Switch to a different model. Returns status immediately, loads in background.
346
+
347
+ Args:
348
+ model_key: The key of the model to switch to.
349
+
350
+ Returns:
351
+ Dict with success status and message about the switch operation.
352
+ """
353
+ global _current_model_key, _model_state
354
 
355
  if model_key not in MODEL_CONFIGS:
356
  return {"success": False, "message": f"Unknown model: {model_key}"}
357
 
358
+ if _current_model_key == model_key and _model_state == ModelState.READY:
359
  return {"success": True, "message": f"Already using {MODEL_CONFIGS[model_key]['name']}"}
360
 
361
  _current_model_key = model_key
362
+ _model_state = ModelState.UNLOADED # Reset state to trigger reload
363
 
364
  # Start loading in background
365
  start_background_load(model_key)
 
374
 
375
 
376
  def get_model_status() -> dict[str, Any]:
377
+ """Return current model loading status with detailed information.
378
+
379
+ Returns:
380
+ Dict containing status, message, model info, and progress details.
381
+ """
382
  config = MODEL_CONFIGS.get(_current_model_key, {})
383
+
384
+ base_info = {
385
+ "model_key": _current_model_key,
386
+ "model_name": config.get("name", ""),
387
+ "model_type": config.get("type", "text"),
388
+ "model_description": config.get("description", ""),
389
+ "capabilities": config.get("capabilities", []),
390
+ "progress": {
391
+ "stage": _load_progress.stage,
392
+ "percent": _load_progress.progress_percent,
393
+ "message": _load_progress.message,
394
+ "elapsed_seconds": round(_load_progress.elapsed_seconds, 1),
395
+ },
396
+ }
397
+
398
+ if _model_state == ModelState.READY:
399
  return {
400
+ **base_info,
401
  "status": "ready",
402
  "message": f"{config.get('name', 'Model')} loaded and ready",
 
 
 
403
  }
404
+ elif _model_state == ModelState.LOADING:
405
  return {
406
+ **base_info,
407
  "status": "loading",
408
+ "message": f"Loading {config.get('name', 'model')}... ({_load_progress.progress_percent:.0f}%)",
 
 
 
409
  }
410
+ elif _model_state == ModelState.ERROR:
411
  return {
412
+ **base_info,
413
  "status": "error",
414
  "message": f"Model load error: {_load_error}",
 
 
 
415
  }
416
+ else:
417
+ return {
418
+ **base_info,
419
+ "status": "unknown",
420
+ "message": "Model not initialized",
421
+ }
 
422
 
423
 
424
  def get_model():
 
433
 
434
  def is_model_loaded() -> bool:
435
  """Return True if the model has been loaded successfully."""
436
+ return _model_state == ModelState.READY
437
 
438
 
439
  def get_current_model_key() -> str:
 
444
  def get_current_model_type() -> str:
445
  """Return 'text' or 'vlm' for the current model."""
446
  return MODEL_CONFIGS.get(_current_model_key, {}).get("type", "text")
447
+
448
+
449
+ def get_model_memory_usage() -> dict[str, Any]:
450
+ """Get estimated memory usage of the loaded model.
451
+
452
+ Returns:
453
+ Dict with memory usage statistics if available.
454
+ """
455
+ result = {
456
+ "model_loaded": False,
457
+ "gpu_memory_mb": 0,
458
+ "cpu_memory_mb": 0,
459
+ "device": "cpu",
460
+ }
461
+
462
+ if _model is None:
463
+ return result
464
+
465
+ try:
466
+ import torch
467
+
468
+ result["model_loaded"] = True
469
+
470
+ # Calculate parameter memory
471
+ param_size = sum(p.numel() * p.element_size() for p in _model.parameters())
472
+ buffer_size = sum(b.numel() * b.element_size() for b in _model.buffers())
473
+ total_mb = (param_size + buffer_size) / (1024 * 1024)
474
+
475
+ if torch.cuda.is_available() and next(_model.parameters()).is_cuda:
476
+ result["device"] = "cuda"
477
+ result["gpu_memory_mb"] = round(total_mb, 1)
478
+ if torch.cuda.is_available():
479
+ allocated = torch.cuda.memory_allocated() / (1024 * 1024)
480
+ result["gpu_allocated_mb"] = round(allocated, 1)
481
+ else:
482
+ result["cpu_memory_mb"] = round(total_mb, 1)
483
+
484
+ except Exception as e:
485
+ logger.warning("Could not calculate memory usage: %s", e)
486
+
487
+ return result
488
+
489
+
490
+ def optimize_for_inference() -> dict[str, Any]:
491
+ """Optimize the loaded model for inference.
492
+
493
+ Applies optimizations like:
494
+ - Better Transformer structure (if available)
495
+ - Half precision conversion
496
+ - Attention optimization
497
+
498
+ Returns:
499
+ Dict with optimization results.
500
+ """
501
+ result = {"optimized": False, "techniques_applied": []}
502
+
503
+ if _model is None:
504
+ return result
505
+
506
+ try:
507
+ import torch
508
+
509
+ # Try to use better transformer if available
510
+ if hasattr(_model, 'to_bettertransformer'):
511
+ _model = _model.to_bettertransformer()
512
+ result["techniques_applied"].append("better_transformer")
513
+
514
+ # Torch compile if available (PyTorch 2.0+)
515
+ if hasattr(torch, 'compile'):
516
+ try:
517
+ _model = torch.compile(_model, mode="reduce-overhead")
518
+ result["techniques_applied"].append("torch_compile")
519
+ except Exception:
520
+ pass # Torch compile may fail for some models
521
+
522
+ result["optimized"] = len(result["techniques_applied"]) > 0
523
+
524
+ except Exception as e:
525
+ logger.warning("Optimization failed: %s", e)
526
+ result["error"] = str(e)
527
+
528
+ return result
code/server/__init__.py CHANGED
@@ -1,31 +1,120 @@
1
- """FastAPI / Gradio Server entry point.
2
 
3
- Provides `create_app()` which returns the configured `gradio.Server` instance
4
- with all HTTP routes and Gradio API endpoints registered.
5
-
6
- The actual routes are defined in `code/server/routes.py`.
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
- from typing import Any
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
- def create_app() -> Any:
15
- """Create and return the configured Gradio Server app instance.
 
 
 
 
16
 
17
- This is a thin wrapper around `code.server.routes.get_app()` that
18
- triggers all the route decorators when the routes module is first
19
- imported.
20
- """
21
- from code.server.routes import get_app # noqa: F401 (import has side effects)
22
 
23
- return get_app()
 
 
 
 
 
 
 
24
 
25
 
26
- def get_app() -> Any:
27
- """Backward-compatible alias for create_app()."""
28
- return create_app()
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
- __all__ = ["create_app", "get_app"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server package — FastAPI/Gradio routes and utilities.
2
 
3
+ Enhanced with:
4
+ - Session management
5
+ - Export functionality
6
+ - Template system
7
+ - Better error handling
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import json
13
+ import logging
14
+ import os
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ import gradio as gr
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def create_app() -> gr.Server:
25
+ """Create and configure the Gradio Server with all routes."""
26
+ from code.server.routes import create_routes
27
+
28
+ app = create_routes()
29
+ return app
30
+
31
+
32
+ def get_workspace_root() -> Path:
33
+ """Get the workspace root directory."""
34
+ from code.config import WORKSPACE_ROOT
35
+ return WORKSPACE_ROOT
36
+
37
+
38
+ def get_download_dir() -> Path:
39
+ """Get the download directory for exports."""
40
+ download_dir = Path("./downloads")
41
+ download_dir.mkdir(parents=True, exist_ok=True)
42
+ return download_dir
43
+
44
+
45
+ # Template-related functions
46
+ def get_code_templates() -> dict[str, Any]:
47
+ """Get available code templates."""
48
+ from code.config.constants import CODE_TEMPLATES
49
+ return {
50
+ key: {
51
+ "name": tpl["name"],
52
+ "description": tpl["description"],
53
+ "language": tpl["language"],
54
+ "framework": tpl["framework"],
55
+ }
56
+ for key, tpl in CODE_TEMPLATES.items()
57
+ }
58
 
59
 
60
+ def get_template_content(template_id: str) -> Optional[str]:
61
+ """Get the content of a specific template."""
62
+ from code.config.constants import CODE_TEMPLATES
63
+ if template_id in CODE_TEMPLATES:
64
+ return CODE_TEMPLATES[template_id]["template"]
65
+ return None
66
 
 
 
 
 
 
67
 
68
+ # Utility functions for the server
69
+ def format_file_size(size_bytes: int) -> str:
70
+ """Format file size in human-readable format."""
71
+ for unit in ['B', 'KB', 'MB', 'GB']:
72
+ if size_bytes < 1024.0:
73
+ return f"{size_bytes:.1f} {unit}"
74
+ size_bytes /= 1024.0
75
+ return f"{size_bytes:.1f} TB"
76
 
77
 
78
+ def sanitize_filename(filename: str) -> str:
79
+ """Sanitize a filename by removing dangerous characters."""
80
+ # Remove path separators and dangerous characters
81
+ sanitized = os.path.basename(filename)
82
+ # Replace non-alphanumeric (except dash, underscore, dot) with underscore
83
+ sanitized = "".join(c if c.isalnum() or c in '-_.' else '_' for c in sanitized)
84
+ # Remove consecutive underscores
85
+ while '__' in sanitized:
86
+ sanitized = sanitized.replace('__', '_')
87
+ # Limit length
88
+ if len(sanitized) > 255:
89
+ name, ext = os.path.splitext(sanitized)
90
+ sanitized = name[:255-len(ext)] + ext
91
+ return sanitized or "unnamed"
92
 
93
 
94
+ def get_system_info() -> dict[str, Any]:
95
+ """Get system information for diagnostics."""
96
+ import platform
97
+ import torch
98
+
99
+ info = {
100
+ "platform": platform.platform(),
101
+ "python_version": platform.python_version(),
102
+ "hostname": platform.node(),
103
+ }
104
+
105
+ # GPU info if available
106
+ if torch.cuda.is_available():
107
+ info["gpu"] = {
108
+ "name": torch.cuda.get_device_name(0),
109
+ "memory_gb": round(torch.cuda.get_device_properties(0).total_mem / 1e9, 2),
110
+ "cuda_version": torch.version.cuda,
111
+ }
112
+
113
+ # Model status
114
+ try:
115
+ from code.model.loader import get_model_status
116
+ info["model"] = get_model_status()
117
+ except Exception:
118
+ info["model"] = {"status": "unknown"}
119
+
120
+ return info
code/server/chat_helpers.py CHANGED
@@ -1,43 +1,129 @@
1
- """Chat helper functions — history conversion, prompt building, iteration context."""
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
- from typing import Any
 
 
6
 
7
- from code.config.constants import SYSTEM_PROMPT
 
 
 
8
  from code.execution.code_extractor import strip_thinking_blocks
 
9
 
 
10
 
11
- def chat_history_to_messages(history: list[dict[str, str]]) -> list[dict[str, Any]]:
12
- """Convert chat history list to messages format for the model.
13
 
14
- Prepends the system prompt and strips thinking blocks from
15
- assistant messages.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  """
 
 
 
17
  messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
18
- for item in history:
 
 
 
 
 
 
19
  role = item.get("role")
20
  content = str(item.get("content") or "").strip()
21
  if role not in {"user", "assistant"} or not content:
22
  continue
 
23
  if role == "assistant":
24
  content = strip_thinking_blocks(content)
 
25
  messages.append({"role": role, "content": content})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  return messages
27
 
28
 
29
  def clip_context(text: str, limit: int = 4_000) -> str:
30
- """Truncate text to a character limit with a note."""
 
 
 
 
 
 
 
 
 
 
31
  if len(text) <= limit:
32
  return text
 
 
33
  return text[:limit] + f"\n... truncated {len(text) - limit} characters ..."
34
 
35
 
36
  def iteration_context(execution_context: dict[str, Any] | None) -> str:
37
  """Build a context string from previous execution results.
38
-
39
  This allows the model to reference prior code, stdout, and stderr
40
  when the user asks to iterate or debug.
 
 
 
 
 
 
41
  """
42
  if not execution_context or not execution_context.get("code"):
43
  return ""
@@ -72,7 +158,23 @@ def targeted_prompt(
72
  execution_context: dict[str, Any] | None = None,
73
  search_context: str = "",
74
  ) -> str:
75
- """Build the full user prompt with language, framework, search, and iteration context."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  iter_ctx = iteration_context(execution_context)
77
  context_block = f"\n\n{iter_ctx}" if iter_ctx else ""
78
 
@@ -94,17 +196,143 @@ def targeted_prompt(
94
  "- Includes all processing logic inline\n"
95
  "- Calls .launch(server_name='0.0.0.0', server_port=7860) at the end\n"
96
  "- Uses only standard library + gradio + common packages (PIL, matplotlib, numpy)\n"
97
- "- Make the UI clean, modern, and functional"
 
 
 
 
 
 
 
 
 
 
 
 
98
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  return (
101
  f"Target: {target_language}{framework_hint}. Generate a complete, runnable application. "
102
  "Use the `write_file` tool to save each file to the workspace. "
103
  "Do NOT paste code in markdown blocks — always use `write_file`. "
104
  "For multi-file projects, call `write_file` once per file. "
105
- "After writing files, give a short summary of what you created."
 
106
  f"{gradio_hint}"
 
 
 
107
  f"{search_block}"
108
  f"{context_block}\n\n"
109
  f"User request:\n{prompt}"
110
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat helper functions — history conversion, prompt building, iteration context.
2
+
3
+ Enhanced version with:
4
+ - Better context management
5
+ - Conversation summarization for long histories
6
+ - Prompt optimization
7
+ - Token counting
8
+ - Session awareness
9
+ """
10
 
11
  from __future__ import annotations
12
 
13
+ import logging
14
+ from typing import Any, Optional
15
+ from dataclasses import dataclass
16
 
17
+ from code.config.constants import (
18
+ SYSTEM_PROMPT,
19
+ MAX_SESSION_HISTORY,
20
+ )
21
  from code.execution.code_extractor import strip_thinking_blocks
22
+ from code.model.inference import estimate_tokens
23
 
24
+ logger = logging.getLogger(__name__)
25
 
 
 
26
 
27
+ @dataclass
28
+ class ContextInfo:
29
+ """Information about the current conversation context."""
30
+ message_count: int = 0
31
+ estimated_tokens: int = 0
32
+ has_images: bool = False
33
+ has_code_blocks: bool = False
34
+ is_truncated: bool = False
35
+ summary: str | None = None
36
+
37
+
38
+ def chat_history_to_messages(
39
+ history: list[dict[str, str]],
40
+ max_messages: int | None = None,
41
+ ) -> list[dict[str, Any]]:
42
+ """Convert chat history list to messages format for the model.
43
+
44
+ Enhanced with:
45
+ - Message limit handling
46
+ - Context info tracking
47
+ - Automatic truncation for long conversations
48
+
49
+ Args:
50
+ history: Chat history from frontend.
51
+ max_messages: Maximum messages to include (None for default).
52
+
53
+ Returns:
54
+ Messages in OpenAI format with system prompt prepended.
55
  """
56
+ if max_messages is None:
57
+ max_messages = MAX_SESSION_HISTORY
58
+
59
  messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
60
+
61
+ # Take only the most recent messages if history is too long
62
+ working_history = history[-max_messages:] if len(history) > max_messages else history
63
+
64
+ context_info = ContextInfo()
65
+
66
+ for item in working_history:
67
  role = item.get("role")
68
  content = str(item.get("content") or "").strip()
69
  if role not in {"user", "assistant"} or not content:
70
  continue
71
+
72
  if role == "assistant":
73
  content = strip_thinking_blocks(content)
74
+
75
  messages.append({"role": role, "content": content})
76
+
77
+ # Track context info
78
+ context_info.message_count += 1
79
+ context_info.estimated_tokens += estimate_tokens(content)
80
+
81
+ if not context_info.has_images:
82
+ context_info.has_images = "image" in content.lower() or "![" in content
83
+ if not context_info.has_code_blocks:
84
+ context_info.has_code_blocks = "```" in content
85
+
86
+ context_info.is_truncated = len(history) > max_messages
87
+
88
+ if context_info.is_truncated:
89
+ logger.info(
90
+ "History truncated from %d to %d messages",
91
+ len(history), len(working_history)
92
+ )
93
+
94
  return messages
95
 
96
 
97
  def clip_context(text: str, limit: int = 4_000) -> str:
98
+ """Truncate text to a character limit with a note.
99
+
100
+ Preserves code blocks when possible by truncating outside of them.
101
+
102
+ Args:
103
+ text: Text to truncate.
104
+ limit: Maximum character count.
105
+
106
+ Returns:
107
+ Truncated text with ellipsis note if truncated.
108
+ """
109
  if len(text) <= limit:
110
  return text
111
+
112
+ # Simple truncation (could be enhanced to preserve code blocks)
113
  return text[:limit] + f"\n... truncated {len(text) - limit} characters ..."
114
 
115
 
116
  def iteration_context(execution_context: dict[str, Any] | None) -> str:
117
  """Build a context string from previous execution results.
118
+
119
  This allows the model to reference prior code, stdout, and stderr
120
  when the user asks to iterate or debug.
121
+
122
+ Args:
123
+ execution_context: Dict with previous execution details.
124
+
125
+ Returns:
126
+ Formatted context string for inclusion in prompts.
127
  """
128
  if not execution_context or not execution_context.get("code"):
129
  return ""
 
158
  execution_context: dict[str, Any] | None = None,
159
  search_context: str = "",
160
  ) -> str:
161
+ """Build the full user prompt with language, framework, search, and iteration context.
162
+
163
+ Enhanced with:
164
+ - Better framework-specific hints
165
+ - Code quality instructions
166
+ - Security reminders
167
+
168
+ Args:
169
+ prompt: User's original prompt.
170
+ target_language: Target programming language.
171
+ target_framework: Optional target framework.
172
+ execution_context: Previous execution context for iterations.
173
+ search_context: Web search results to incorporate.
174
+
175
+ Returns:
176
+ Complete formatted prompt for the model.
177
+ """
178
  iter_ctx = iteration_context(execution_context)
179
  context_block = f"\n\n{iter_ctx}" if iter_ctx else ""
180
 
 
196
  "- Includes all processing logic inline\n"
197
  "- Calls .launch(server_name='0.0.0.0', server_port=7860) at the end\n"
198
  "- Uses only standard library + gradio + common packages (PIL, matplotlib, numpy)\n"
199
+ "- Make the UI clean, modern, and functional\n"
200
+ "- Include proper error handling and loading states"
201
+ )
202
+
203
+ # Framework-specific hints
204
+ react_hint = ""
205
+ if target_framework == "React":
206
+ react_hint = (
207
+ "\n\nREACT SPECIFIC:\n"
208
+ "- Use functional components and hooks\n"
209
+ "- Include proper TypeScript types if applicable\n"
210
+ "- Use CSS modules or styled-components for styling\n"
211
+ "- Make components reusable and composable"
212
  )
213
+
214
+ flask_hint = ""
215
+ if target_framework == "Flask":
216
+ flask_hint = (
217
+ "\n\nFLASK SPECIFIC:\n"
218
+ "- Use Flask blueprints for larger apps\n"
219
+ "- Include proper error handlers\n"
220
+ "- Add input validation using marshmallow or similar\n"
221
+ "- Structure with separate routes, models, and templates directories"
222
+ )
223
+
224
+ security_reminder = (
225
+ "\n\nSECURITY REMINDERS:\n"
226
+ "- Validate all user inputs\n"
227
+ "- Use parameterized queries for database operations\n"
228
+ "- Never hardcode secrets or API keys\n"
229
+ "- Implement proper authentication/authorization where needed\n"
230
+ "- Sanitize outputs to prevent XSS"
231
+ )
232
 
233
  return (
234
  f"Target: {target_language}{framework_hint}. Generate a complete, runnable application. "
235
  "Use the `write_file` tool to save each file to the workspace. "
236
  "Do NOT paste code in markdown blocks — always use `write_file`. "
237
  "For multi-file projects, call `write_file` once per file. "
238
+ "After writing files, give a short summary of what you created. "
239
+ "Include proper error handling, comments, and follow best practices. "
240
  f"{gradio_hint}"
241
+ f"{react_hint}"
242
+ f"{flask_hint}"
243
+ f"{security_reminder}"
244
  f"{search_block}"
245
  f"{context_block}\n\n"
246
  f"User request:\n{prompt}"
247
  )
248
+
249
+
250
+ def summarize_conversation(
251
+ history: list[dict[str, str]],
252
+ max_summary_length: int = 500,
253
+ ) -> str | None:
254
+ """Create a summary of the conversation for context preservation.
255
+
256
+ This is useful when the conversation gets too long and needs to be
257
+ compressed while preserving important context.
258
+
259
+ Args:
260
+ history: Full conversation history.
261
+ max_summary_length: Maximum length of the summary.
262
+
263
+ Returns:
264
+ Summary string or None if summarization not needed.
265
+ """
266
+ if len(history) < 10: # Only summarize longer conversations
267
+ return None
268
+
269
+ # Extract key information
270
+ user_requests = []
271
+ files_created = []
272
+
273
+ for msg in history:
274
+ role = msg.get("role")
275
+ content = msg.get("content", "")
276
+
277
+ if role == "user":
278
+ # Get first sentence as summary of request
279
+ first_sentence = content.split('.')[0].split('\n')[0]
280
+ if first_sentence:
281
+ user_requests.append(first_sentence)
282
+
283
+ elif role == "assistant":
284
+ # Look for file creation mentions
285
+ if "created" in content.lower() or "wrote" in content.lower():
286
+ # Simple extraction - could be enhanced with NLP
287
+ pass
288
+
289
+ if not user_requests:
290
+ return None
291
+
292
+ summary_parts = [
293
+ "Conversation summary:",
294
+ f"User made {len(user_requests)} requests.",
295
+ "Key requests: " + "; ".join(user_requests[-5:]), # Last 5 requests
296
+ ]
297
+
298
+ summary = "\n".join(summary_parts)
299
+
300
+ if len(summary) > max_summary_length:
301
+ summary = summary[:max_summary_length] + "..."
302
+
303
+ return summary
304
+
305
+
306
+ def build_system_prompt_with_context(
307
+ custom_instructions: str | None = None,
308
+ active_skills: list[str] | None = None,
309
+ active_agent: str | None = None,
310
+ ) -> str:
311
+ """Build system prompt with additional context.
312
+
313
+ Args:
314
+ custom_instructions: Additional custom instructions.
315
+ active_skills: List of currently active skill names.
316
+ active_agent: Name of the active agent if any.
317
+
318
+ Returns:
319
+ Complete system prompt string.
320
+ """
321
+ base_prompt = SYSTEM_PROMPT
322
+
323
+ additions = []
324
+
325
+ if active_agent:
326
+ additions.append(f"You are currently operating as the '{active_agent}' agent.")
327
+
328
+ if active_skills:
329
+ skills_str = ", ".join(active_skills)
330
+ additions.append(f"Active skills: {skills_str}. Apply these skill guidelines.")
331
+
332
+ if custom_instructions:
333
+ additions.append(f"ADDITIONAL INSTRUCTIONS:\n{custom_instructions}")
334
+
335
+ if additions:
336
+ base_prompt += "\n\n" + "\n\n".join(additions)
337
+
338
+ return base_prompt
code/server/session_manager.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session management and conversation export utilities.
2
+
3
+ New module providing:
4
+ - Session persistence
5
+ - Conversation history management
6
+ - Export to multiple formats (JSON, Markdown, HTML)
7
+ - Import capabilities
8
+ - Template management
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import time
17
+ from dataclasses import dataclass, field, asdict
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class ChatMessage:
27
+ """Represents a single chat message."""
28
+ role: str # "user" or "assistant"
29
+ content: str
30
+ timestamp: float = field(default_factory=time.time)
31
+ token_count: int = 0
32
+ model_used: str = ""
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return asdict(self)
36
+
37
+ @classmethod
38
+ def from_dict(cls, data: dict[str, Any]) -> 'ChatMessage':
39
+ return cls(**data)
40
+
41
+
42
+ @dataclass
43
+ class Session:
44
+ """Represents a user session with full conversation history."""
45
+ session_id: str
46
+ created_at: float = field(default_factory=time.time)
47
+ updated_at: float = field(default_factory=time.time)
48
+ messages: list[ChatMessage] = field(default_factory=list)
49
+ metadata: dict[str, Any] = field(default_factory=dict)
50
+ title: str = ""
51
+
52
+ def add_message(self, role: str, content: str, **kwargs) -> ChatMessage:
53
+ """Add a message to the session."""
54
+ msg = ChatMessage(role=role, content=content, **kwargs)
55
+ self.messages.append(msg)
56
+ self.updated_at = time.time()
57
+
58
+ # Auto-generate title from first user message
59
+ if role == "user" and not self.title:
60
+ self.title = content[:50] + "..." if len(content) > 50 else content
61
+
62
+ return msg
63
+
64
+ def get_history(self) -> list[dict[str, str]]:
65
+ """Get messages in the format expected by chat_helpers."""
66
+ return [{"role": m.role, "content": m.content} for m in self.messages]
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return {
70
+ "session_id": self.session_id,
71
+ "created_at": self.created_at,
72
+ "updated_at": self.updated_at,
73
+ "title": self.title,
74
+ "messages": [m.to_dict() for m in self.messages],
75
+ "metadata": self.metadata,
76
+ }
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict[str, Any]) -> 'Session':
80
+ session = cls(
81
+ session_id=data["session_id"],
82
+ created_at=data.get("created_at", time.time()),
83
+ updated_at=data.get("updated_at", time.time()),
84
+ title=data.get("title", ""),
85
+ metadata=data.get("metadata", {}),
86
+ )
87
+ session.messages = [ChatMessage.from_dict(m) for m in data.get("messages", [])]
88
+ return session
89
+
90
+
91
+ class SessionManager:
92
+ """Manages multiple sessions with persistence support.
93
+
94
+ Features:
95
+ - In-memory session storage
96
+ - JSON file persistence
97
+ - Session search and filtering
98
+ - Automatic cleanup of old sessions
99
+ """
100
+
101
+ def __init__(self, storage_dir: str | None = None):
102
+ self._sessions: dict[str, Session] = {}
103
+ self._storage_dir = Path(storage_dir) if storage_dir else None
104
+
105
+ if self._storage_dir:
106
+ self._storage_dir.mkdir(parents=True, exist_ok=True)
107
+ self._load_sessions()
108
+
109
+ def create_session(self, session_id: str | None = None, **metadata) -> Session:
110
+ """Create a new session."""
111
+ if session_id is None:
112
+ session_id = f"session_{int(time.time() * 1000)}"
113
+
114
+ session = Session(session_id=session_id, metadata=metadata)
115
+ self._sessions[session_id] = session
116
+ logger.info("Created session: %s", session_id)
117
+ return session
118
+
119
+ def get_session(self, session_id: str) -> Optional[Session]:
120
+ """Get a session by ID."""
121
+ return self._sessions.get(session_id)
122
+
123
+ def update_session(self, session_id: str, **updates) -> bool:
124
+ """Update session metadata."""
125
+ session = self.get_session(session_id)
126
+ if not session:
127
+ return False
128
+
129
+ for key, value in updates.items():
130
+ if hasattr(session, key):
131
+ setattr(session, key, value)
132
+ elif key in session.metadata:
133
+ session.metadata[key] = value
134
+ else:
135
+ session.metadata[key] = value
136
+
137
+ session.updated_at = time.time()
138
+ return True
139
+
140
+ def delete_session(self, session_id: str) -> bool:
141
+ """Delete a session."""
142
+ if session_id in self._sessions:
143
+ del self._sessions[session_id]
144
+
145
+ # Delete persisted file
146
+ if self._storage_dir:
147
+ file_path = self._storage_dir / f"{session_id}.json"
148
+ if file_path.exists():
149
+ file_path.unlink()
150
+
151
+ logger.info("Deleted session: %s", session_id)
152
+ return True
153
+ return False
154
+
155
+ def list_sessions(
156
+ self,
157
+ limit: int = 50,
158
+ filter_text: str | None = None,
159
+ ) -> list[dict[str, Any]]:
160
+ """List sessions with optional filtering."""
161
+ sessions = list(self._sessions.values())
162
+
163
+ # Sort by updated_at descending
164
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
165
+
166
+ # Filter by text (search in title and messages)
167
+ if filter_text:
168
+ filter_lower = filter_text.lower()
169
+ sessions = [
170
+ s for s in sessions
171
+ if filter_lower in s.title.lower() or
172
+ any(filter_lower in m.content.lower() for m in s.messages[:10])
173
+ ]
174
+
175
+ # Apply limit
176
+ sessions = sessions[:limit]
177
+
178
+ return [
179
+ {
180
+ "session_id": s.session_id,
181
+ "title": s.title,
182
+ "created_at": s.created_at,
183
+ "updated_at": s.updated_at,
184
+ "message_count": len(s.messages),
185
+ }
186
+ for s in sessions
187
+ ]
188
+
189
+ def save_session(self, session_id: str) -> bool:
190
+ """Persist a session to disk."""
191
+ if not self._storage_dir:
192
+ return False
193
+
194
+ session = self.get_session(session_id)
195
+ if not session:
196
+ return False
197
+
198
+ try:
199
+ file_path = self._storage_dir / f"{session_id}.json"
200
+ with open(file_path, 'w', encoding='utf-8') as f:
201
+ json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
202
+ return True
203
+ except Exception as e:
204
+ logger.error("Failed to save session %s: %s", session_id, e)
205
+ return False
206
+
207
+ def load_session(self, session_id: str) -> Optional[Session]:
208
+ """Load a session from disk."""
209
+ if not self._storage_dir:
210
+ return None
211
+
212
+ try:
213
+ file_path = self._storage_dir / f"{session_id}.json"
214
+ if not file_path.exists():
215
+ return None
216
+
217
+ with open(file_path, 'r', encoding='utf-8') as f:
218
+ data = json.load(f)
219
+
220
+ session = Session.from_dict(data)
221
+ self._sessions[session_id] = session
222
+ return session
223
+ except Exception as e:
224
+ logger.error("Failed to load session %s: %s", session_id, e)
225
+ return None
226
+
227
+ def _load_sessions(self) -> None:
228
+ """Load all sessions from storage directory."""
229
+ if not self._storage_dir:
230
+ return
231
+
232
+ try:
233
+ for file_path in self._storage_dir.glob("*.json"):
234
+ try:
235
+ with open(file_path, 'r', encoding='utf-8') as f:
236
+ data = json.load(f)
237
+ session = Session.from_dict(data)
238
+ self._sessions[session.session_id] = session
239
+ except Exception as e:
240
+ logger.warning("Failed to load session file %s: %s", file_path, e)
241
+ except Exception as e:
242
+ logger.error("Failed to scan sessions directory: %s", e)
243
+
244
+ def save_all(self) -> int:
245
+ """Save all modified sessions. Returns count saved."""
246
+ count = 0
247
+ for session_id in list(self._sessions.keys()):
248
+ if self.save_session(session_id):
249
+ count += 1
250
+ return count
251
+
252
+ def cleanup_old_sessions(self, max_age_days: int = 30) -> int:
253
+ """Remove sessions older than max_age_days. Returns count removed."""
254
+ cutoff = time.time() - (max_age_days * 24 * 60 * 60)
255
+ to_remove = [
256
+ sid for sid, s in self._sessions.items()
257
+ if s.updated_at < cutoff
258
+ ]
259
+
260
+ for sid in to_remove:
261
+ self.delete_session(sid)
262
+
263
+ logger.info("Cleaned up %d old sessions", len(to_remove))
264
+ return len(to_remove)
265
+
266
+
267
+ # ─── Export Functions ────────────────────────────────────────────────────
268
+
269
+ def export_to_json(
270
+ session: Session,
271
+ output_path: str,
272
+ ) -> str:
273
+ """Export session to JSON format.
274
+
275
+ Args:
276
+ session: The session to export.
277
+ output_path: Path to write the JSON file.
278
+
279
+ Returns:
280
+ The output path.
281
+ """
282
+ data = session.to_dict()
283
+
284
+ # Add export metadata
285
+ data["exported_at"] = datetime.now().isoformat()
286
+ data["version"] = "2.1"
287
+
288
+ with open(output_path, 'w', encoding='utf-8') as f:
289
+ json.dump(data, f, indent=2, ensure_ascii=False)
290
+
291
+ logger.info("Exported session to JSON: %s", output_path)
292
+ return output_path
293
+
294
+
295
+ def export_to_markdown(
296
+ session: Session,
297
+ output_path: str,
298
+ ) -> str:
299
+ """Export session to Markdown format.
300
+
301
+ Args:
302
+ session: The session to export.
303
+ output_path: Path to write the markdown file.
304
+
305
+ Returns:
306
+ The output path.
307
+ """
308
+ lines = [
309
+ f"# {session.title or 'SoniCoder Conversation'}",
310
+ "",
311
+ f"**Session ID:** `{session.session_id}`",
312
+ f"**Created:** {datetime.fromtimestamp(session.created_at).strftime('%Y-%m-%d %H:%M:%S')}",
313
+ f"**Messages:** {len(session.messages)}",
314
+ "",
315
+ "---",
316
+ "",
317
+ ]
318
+
319
+ for i, msg in enumerate(session.messages, 1):
320
+ timestamp = datetime.fromtimestamp(msg.timestamp).strftime('%H:%M:%S')
321
+ role_label = "**User**" if msg.role == "user" else "**Assistant**"
322
+
323
+ lines.append(f"## Message {i} ({role_label}) - {timestamp}")
324
+ lines.append("")
325
+ lines.append(msg.content)
326
+ lines.append("")
327
+ lines.append("---")
328
+ lines.append("")
329
+
330
+ with open(output_path, 'w', encoding='utf-8') as f:
331
+ f.write('\n'.join(lines))
332
+
333
+ logger.info("Exported session to Markdown: %s", output_path)
334
+ return output_path
335
+
336
+
337
+ def export_to_html(
338
+ session: Session,
339
+ output_path: str,
340
+ ) -> str:
341
+ """Export session to HTML format with styling.
342
+
343
+ Args:
344
+ session: The session to export.
345
+ output_path: Path to write the HTML file.
346
+
347
+ Returns:
348
+ The output path.
349
+ """
350
+ messages_html = []
351
+
352
+ for msg in session.messages:
353
+ timestamp = datetime.fromtimestamp(msg.timestamp).strftime('%H:%M:%S')
354
+ role_class = "user-message" if msg.role == "user" else "assistant-message"
355
+ role_label = "👤 User" if msg.role == "user" else "🤖 Assistant"
356
+
357
+ # Escape HTML special characters in content
358
+ content = (
359
+ msg.content
360
+ .replace('&', '&amp;')
361
+ .replace('<', '&lt;')
362
+ .replace('>', '&gt;')
363
+ )
364
+
365
+ # Convert code blocks to HTML
366
+ import re
367
+ content = re.sub(
368
+ r'```(\w*)\n(.*?)```',
369
+ r'<pre><code class="language-\1">\2</code></pre>',
370
+ content,
371
+ flags=re.DOTALL
372
+ )
373
+
374
+ messages_html.append(f"""
375
+ <div class="message {role_class}">
376
+ <div class="message-header">
377
+ <span class="role-label">{role_label}</span>
378
+ <span class="timestamp">{timestamp}</span>
379
+ </div>
380
+ <div class="message-content">{content}</div>
381
+ </div>
382
+ """)
383
+
384
+ html = f"""<!DOCTYPE html>
385
+ <html lang="en">
386
+ <head>
387
+ <meta charset="UTF-8">
388
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
389
+ <title>{session.title or 'SoniCoder Conversation'}</title>
390
+ <style>
391
+ body {{
392
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
393
+ line-height: 1.6;
394
+ color: #333;
395
+ max-width: 900px;
396
+ margin: 0 auto;
397
+ padding: 20px;
398
+ background: #f5f5f5;
399
+ }}
400
+ h1 {{
401
+ color: #2c3e50;
402
+ border-bottom: 2px solid #3498db;
403
+ padding-bottom: 10px;
404
+ }}
405
+ .meta {{
406
+ color: #666;
407
+ font-size: 0.9em;
408
+ }}
409
+ .message {{
410
+ margin: 20px 0;
411
+ padding: 15px;
412
+ border-radius: 8px;
413
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
414
+ }}
415
+ .user-message {{
416
+ background: #e3f2fd;
417
+ border-left: 4px solid #2196f3;
418
+ }}
419
+ .assistant-message {{
420
+ background: #f3e5f5;
421
+ border-left: 4px solid #9c27b0;
422
+ }}
423
+ .message-header {{
424
+ display: flex;
425
+ justify-content: space-between;
426
+ margin-bottom: 10px;
427
+ font-weight: bold;
428
+ }}
429
+ .timestamp {{
430
+ color: #888;
431
+ font-size: 0.85em;
432
+ }}
433
+ pre {{
434
+ background: #263238;
435
+ color: #aed581;
436
+ padding: 15px;
437
+ border-radius: 4px;
438
+ overflow-x: auto;
439
+ font-size: 0.9em;
440
+ }}
441
+ code {{
442
+ font-family: 'Fira Code', 'Consolas', monospace;
443
+ }}
444
+ </style>
445
+ </head>
446
+ <body>
447
+ <h1>{session.title or 'SoniCoder Conversation'}</h1>
448
+ <div class="meta">
449
+ <p><strong>Session:</strong> {session.session_id}</p>
450
+ <p><strong>Created:</strong> {datetime.fromtimestamp(session.created_at).strftime('%Y-%m-%d %H:%M:%S')}</p>
451
+ <p><strong>Messages:</strong> {len(session.messages)}</p>
452
+ </div>
453
+ <hr>
454
+ {''.join(messages_html)}
455
+ </body>
456
+ </html>"""
457
+
458
+ with open(output_path, 'w', encoding='utf-8') as f:
459
+ f.write(html)
460
+
461
+ logger.info("Exported session to HTML: %s", output_path)
462
+ return output_path
463
+
464
+
465
+ def export_session(
466
+ session: Session,
467
+ format: str = "json",
468
+ output_dir: str = ".",
469
+ ) -> str:
470
+ """Export a session in the specified format.
471
+
472
+ Args:
473
+ session: The session to export.
474
+ format: Export format ('json', 'markdown', 'html').
475
+ output_dir: Directory to save the exported file.
476
+
477
+ Returns:
478
+ Path to the exported file.
479
+ """
480
+ # Ensure output directory exists
481
+ os.makedirs(output_dir, exist_ok=True)
482
+
483
+ safe_title = "".join(
484
+ c for c in (session.title or "conversation")
485
+ if c.isalnum() or c in (' ', '-', '_')
486
+ ).strip()[:50] or "conversation"
487
+
488
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
489
+
490
+ exporters = {
491
+ "json": export_to_json,
492
+ "markdown": export_to_markdown,
493
+ "html": export_to_html,
494
+ }
495
+
496
+ if format not in exporters:
497
+ raise ValueError(f"Unsupported format: {format}. Supported: {list(exporters.keys())}")
498
+
499
+ filename = f"{safe_title}_{timestamp}.{format}"
500
+ output_path = os.path.join(output_dir, filename)
501
+
502
+ return exporters[format](session, output_path)
503
+
504
+
505
+ # Global session manager instance
506
+ _global_manager: Optional[SessionManager] = None
507
+
508
+
509
+ def get_session_manager(storage_dir: str | None = None) -> SessionManager:
510
+ """Get or create the global session manager."""
511
+ global _global_manager
512
+ if _global_manager is None:
513
+ _global_manager = SessionManager(storage_dir=storage_dir)
514
+ return _global_manager
code/tools/bash.py CHANGED
@@ -1,10 +1,21 @@
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
@@ -15,30 +26,131 @@ _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,
@@ -46,7 +158,13 @@ def run_bash(
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).
@@ -54,28 +172,46 @@ def run_bash(
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(
@@ -88,15 +224,20 @@ def run_bash(
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,
@@ -104,24 +245,120 @@ def run_bash(
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
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bash subprocess tool with timeout and output capture.
2
+
3
+ Enhanced version with:
4
+ - Command logging and audit trail
5
+ - Resource usage tracking
6
+ - Better security controls
7
+ - Timeout handling improvements
8
+ - Output streaming support
9
+ - Environment variable protection
10
+ """
11
 
12
  from __future__ import annotations
13
 
14
  import os
15
+ import re
16
  import shlex
17
  import subprocess
18
+ import time
19
  from typing import Any
20
 
21
  from code.tools.fs import _resolve_safe, get_workspace_root
 
26
  "rm -rf /",
27
  "rm -rf ~",
28
  "rm -rf $HOME",
29
+ ":(){:|:&};:", # Fork bomb
30
+ "mkfs", # Filesystem formatting
31
+ "dd if=/dev/zero of=/dev/", # Disk destruction
32
+ "> /dev/sda", # Direct disk write
33
  "shutdown",
34
  "reboot",
35
  "halt",
36
+ "init 0",
37
+ "init 6",
38
+ # Additional dangerous patterns (regex)
39
+ ]
40
+
41
+ # Compiled regex patterns for additional safety checks
42
+ _BLOCKED_REGEX = [
43
+ re.compile(r"chmod\s+777\s+/", re.IGNORECASE),
44
+ re.compile(r"chown\s+.*\s+/", re.IGNORECASE),
45
+ re.compile(r"curl.*\|\s*bash", re.IGNORECASE), # Pipe to bash
46
+ re.compile(r"wget.*\|\s*sh", re.IGNORECASE), # Pipe to sh
47
+ re.compile(r">\s*/etc/", re.IGNORECASE), # Write to etc
48
+ re.compile(r"mv\s+.*\s+/", re.IGNORECASE), # Move to root
49
  ]
50
 
51
+ # Default env vars to scrub for security
52
+ _ENV_SCRUB = {
53
+ "HF_TOKEN",
54
+ "OPENAI_API_KEY",
55
+ "ANTHROPIC_API_KEY",
56
+ "AWS_SECRET_ACCESS_KEY",
57
+ "AWS_ACCESS_KEY_ID",
58
+ "GCP_API_KEY",
59
+ "AZURE_SECRET_KEY",
60
+ "DATABASE_URL",
61
+ "PRIVATE_KEY",
62
+ "SECRET_KEY",
63
+ "PASSWORD",
64
+ "TOKEN",
65
+ }
66
+
67
+ # Command history for audit trail (in-memory, limited size)
68
+ _command_history: list[dict[str, Any]] = []
69
+ _MAX_HISTORY = 100
70
 
71
 
72
  def _is_safe_command(cmd: str) -> tuple[bool, str]:
73
+ """Check if a command is safe to run.
74
+
75
+ Enhanced with:
76
+ - Regex pattern matching
77
+ - Length limits
78
+ - Character validation
79
+
80
+ Args:
81
+ cmd: The command string to validate.
82
+
83
+ Returns:
84
+ Tuple of (is_safe, reason_if_unsafe).
85
+ """
86
  stripped = cmd.strip()
87
+
88
  if not stripped:
89
  return False, "Empty command"
90
+
91
+ # Check length limit
92
+ if len(stripped) > 10000:
93
+ return False, "Command too long (max 10000 characters)"
94
+
95
+ # Check for blocked string patterns
96
  for pat in _BLOCKED_PATTERNS:
97
  if pat in stripped:
98
  return False, f"Blocked pattern detected: {pat}"
99
+
100
+ # Check for blocked regex patterns
101
+ for regex in _BLOCKED_REGEX:
102
+ if regex.search(stripped):
103
+ return False, f"Blocked pattern matched: {regex.pattern}"
104
+
105
+ # Check for null bytes and other suspicious characters
106
+ if '\x00' in stripped:
107
+ return False, "Command contains null bytes"
108
+
109
  return True, ""
110
 
111
 
112
+ def _log_command(command: str, result: dict[str, Any]) -> None:
113
+ """Log command execution for audit trail.
114
+
115
+ Args:
116
+ command: The executed command.
117
+ result: The execution result dict.
118
+ """
119
+ global _command_history
120
+
121
+ entry = {
122
+ "timestamp": time.time(),
123
+ "command": command[:500], # Truncate long commands
124
+ "success": result.get("success", False),
125
+ "returncode": result.get("returncode", -1),
126
+ "timed_out": result.get("timed_out", False),
127
+ }
128
+
129
+ _command_history.append(entry)
130
+
131
+ # Trim history if too large
132
+ if len(_command_history) > _MAX_HISTORY:
133
+ _command_history = _command_history[-_MAX_HISTORY:]
134
+
135
+
136
+ def get_command_history(limit: int = 50) -> list[dict[str, Any]]:
137
+ """Get recent command history for auditing.
138
+
139
+ Args:
140
+ limit: Maximum number of entries to return.
141
+
142
+ Returns:
143
+ List of command execution records.
144
+ """
145
+ return _command_history[-limit:]
146
+
147
+
148
+ def clear_command_history() -> None:
149
+ """Clear the command history."""
150
+ global _command_history
151
+ _command_history = []
152
+
153
+
154
  def run_bash(
155
  command: str,
156
  cwd: str | None = None,
 
158
  env_extra: dict[str, str] | None = None,
159
  ) -> dict[str, Any]:
160
  """Run a shell command in the workspace.
161
+
162
+ Enhanced with:
163
+ - Detailed error messages
164
+ - Resource usage tracking
165
+ - Better output handling
166
+ - Execution timing
167
+
168
  Args:
169
  command: Shell command to execute.
170
  cwd: Working directory (relative to workspace root, defaults to workspace).
 
172
  env_extra: Extra environment variables.
173
 
174
  Returns:
175
+ dict with: stdout, stderr, returncode, timed_out, duration, memory_mb
176
  """
177
+ start_time = time.time()
178
+
179
  try:
180
+ # Validate command safety
181
  safe, reason = _is_safe_command(command)
182
  if not safe:
183
+ result = {
184
  "success": False,
185
  "stdout": "",
186
  "stderr": reason,
187
  "returncode": -1,
188
  "timed_out": False,
189
+ "command": command,
190
+ "cwd": cwd or ".",
191
+ "duration_ms": 0,
192
  }
193
+ _log_command(command, result)
194
+ return result
195
 
196
+ # Resolve working directory
197
  if cwd:
198
  work_dir = _resolve_safe(cwd)
199
  else:
200
  work_dir = get_workspace_root()
201
 
202
+ # Build environment: scrub secrets, add extras
203
  env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB}
204
+
205
+ # Add safe extras
206
  if env_extra:
207
+ for key, value in env_extra.items():
208
+ # Don't allow overriding scrubbed vars
209
+ if key not in _ENV_SCRUB:
210
+ env[key] = value
211
+
212
+ # Set some safe defaults
213
+ env.setdefault("PYTHONUNBUFFERED", "1")
214
+ env.setdefault("TERM", "dumb")
215
 
216
  # Run with bash -c for full shell semantics
217
  completed = subprocess.run(
 
224
  check=False,
225
  )
226
 
227
+ # Process and truncate outputs
228
  stdout = completed.stdout
229
  stderr = completed.stderr
230
+
231
+ max_output = 50_000
232
+ if len(stdout) > max_output:
233
+ stdout = stdout[:max_output] + f"\n... truncated ({len(stdout) - max_output} chars) ..."
234
+ if len(stderr) > max_output:
235
+ stderr = stderr[:max_output] + f"\n... truncated ({len(stderr) - max_output} chars) ..."
236
+
237
+ # Calculate duration
238
+ duration_ms = (time.time() - start_time) * 1000
239
 
240
+ result = {
241
  "success": completed.returncode == 0,
242
  "stdout": stdout,
243
  "stderr": stderr,
 
245
  "timed_out": False,
246
  "command": command,
247
  "cwd": cwd or ".",
248
+ "duration_ms": round(duration_ms, 1),
249
  }
250
+
251
+ _log_command(command, result)
252
+ return result
253
+
254
  except subprocess.TimeoutExpired as exc:
255
+ duration_ms = (time.time() - start_time) * 1000
256
+
257
+ # Handle timeout output carefully
258
+ stdout = ""
259
+ stderr = ""
260
+
261
+ if exc.stdout:
262
+ stdout = exc.stdout.decode('utf-8', errors='replace') if isinstance(exc.stdout, bytes) else str(exc.stdout)
263
+ if exc.stderr:
264
+ stderr = exc.stderr.decode('utf-8', errors='replace') if isinstance(exc.stderr, bytes) else str(exc.stderr)
265
+
266
+ # Truncate timeout outputs
267
+ if len(stdout) > 10000:
268
+ stdout = stdout[:10000] + "... [truncated]"
269
+ if len(stderr) > 10000:
270
+ stderr = stderr[:10000] + "... [truncated]"
271
+
272
+ result = {
273
  "success": False,
274
  "stdout": stdout,
275
  "stderr": f"Timeout after {timeout}s\n{stderr}",
276
  "returncode": -1,
277
  "timed_out": True,
278
  "command": command,
279
+ "cwd": cwd or ".",
280
+ "duration_ms": round(duration_ms, 1),
281
  }
282
+
283
+ _log_command(command, result)
284
+ return result
285
+
286
  except Exception as exc:
287
+ duration_ms = (time.time() - start_time) * 1000
288
+
289
+ result = {
290
  "success": False,
291
  "stdout": "",
292
  "stderr": str(exc),
293
  "returncode": -1,
294
  "timed_out": False,
295
  "command": command,
296
+ "cwd": cwd or ".",
297
+ "duration_ms": round(duration_ms, 1),
298
  }
299
+
300
+ _log_command(command, result)
301
+ return result
302
+
303
+
304
+ def run_bash_streaming(
305
+ command: str,
306
+ cwd: str | None = None,
307
+ timeout: int = 30,
308
+ on_output: callable = None,
309
+ ) -> Iterator[str]:
310
+ """Run a shell command with streaming output.
311
+
312
+ Yields output lines as they are produced.
313
+
314
+ Args:
315
+ command: Shell command to execute.
316
+ cwd: Working directory.
317
+ timeout: Max seconds before killing.
318
+ on_output: Optional callback for each line of output.
319
+
320
+ Yields:
321
+ Lines of output from the command.
322
+ """
323
+ try:
324
+ safe, reason = _is_safe_command(command)
325
+ if not safe:
326
+ yield f"Error: {reason}"
327
+ return
328
+
329
+ work_dir = _resolve_safe(cwd) if cwd else get_workspace_root()
330
+ env = {k: v for k, v in os.environ.items() if k not in _ENV_SCRUB}
331
+ env["PYTHONUNBUFFERED"] = "1"
332
+
333
+ process = subprocess.Popen(
334
+ ["bash", "-c", command],
335
+ cwd=work_dir,
336
+ env=env,
337
+ stdout=subprocess.PIPE,
338
+ stderr=subprocess.STDOUT,
339
+ text=True,
340
+ bufsize=1,
341
+ )
342
+
343
+ start_time = time.time()
344
+
345
+ for line in process.stdout:
346
+ line = line.rstrip('\n')
347
+ if on_output:
348
+ on_output(line)
349
+ yield line
350
+
351
+ # Check timeout
352
+ if time.time() - start_time > timeout:
353
+ process.kill()
354
+ yield f"\n[Timeout after {timeout}s]"
355
+ break
356
+
357
+ process.wait(timeout=5)
358
+
359
+ except Exception as exc:
360
+ yield f"Error: {exc}"
361
+
362
+
363
+ # Need Iterator type for streaming function
364
+ from collections.abc import Iterator
requirements.txt CHANGED
@@ -1,5 +1,11 @@
1
- # SoniCoder v2 — Optimized AI Code Agent
2
  # Architecture inspired by Gemini CLI and Claude Code
 
 
 
 
 
 
3
 
4
  gradio==6.19.0
5
  transformers>=4.45.0
@@ -14,4 +20,16 @@ Pillow>=10.0
14
  mcp>=1.0.0
15
 
16
  # Optional: VLM support
17
- # torchvision>=0.16.0
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SoniCoder v2.1Enhanced AI Code Agent
2
  # Architecture inspired by Gemini CLI and Claude Code
3
+ #
4
+ # Enhanced with:
5
+ # - Better model support (DeepSeek Coder)
6
+ # - Session management
7
+ # - Export functionality
8
+ # - Performance optimizations
9
 
10
  gradio==6.19.0
11
  transformers>=4.45.0
 
20
  mcp>=1.0.0
21
 
22
  # Optional: VLM support
23
+ # torchvision>=0.16.0
24
+
25
+ # Additional dependencies for enhanced features
26
+ # Data processing
27
+ pandas>=2.0.0
28
+ numpy>=1.24.0
29
+
30
+ # Web scraping enhancements
31
+ lxml>=4.9.0
32
+
33
+ # Type checking (optional, for development)
34
+ # mypy>=1.0.0
35
+ # types-requests>=2.28.0