differix 0.1.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- differix-0.1.0a1/PKG-INFO +123 -0
- differix-0.1.0a1/README.md +89 -0
- differix-0.1.0a1/agents/editor.py +455 -0
- differix-0.1.0a1/agents/researcher.py +405 -0
- differix-0.1.0a1/agents/reviewer.py +374 -0
- differix-0.1.0a1/cli/model_cli.py +430 -0
- differix-0.1.0a1/config.py +557 -0
- differix-0.1.0a1/differix.egg-info/PKG-INFO +123 -0
- differix-0.1.0a1/differix.egg-info/SOURCES.txt +70 -0
- differix-0.1.0a1/differix.egg-info/dependency_links.txt +1 -0
- differix-0.1.0a1/differix.egg-info/entry_points.txt +2 -0
- differix-0.1.0a1/differix.egg-info/requires.txt +15 -0
- differix-0.1.0a1/differix.egg-info/top_level.txt +7 -0
- differix-0.1.0a1/main.py +2796 -0
- differix-0.1.0a1/memory/__init__.py +59 -0
- differix-0.1.0a1/memory/embeddings/__init__.py +29 -0
- differix-0.1.0a1/memory/embeddings/factory.py +178 -0
- differix-0.1.0a1/memory/embeddings/providers.py +259 -0
- differix-0.1.0a1/memory/manager.py +186 -0
- differix-0.1.0a1/memory/migrator.py +253 -0
- differix-0.1.0a1/memory/ranker.py +203 -0
- differix-0.1.0a1/memory/schemas.py +127 -0
- differix-0.1.0a1/memory/storage.py +552 -0
- differix-0.1.0a1/pyproject.toml +81 -0
- differix-0.1.0a1/setup.cfg +4 -0
- differix-0.1.0a1/tests/test_isolation.py +9 -0
- differix-0.1.0a1/tests/test_reflection.py +77 -0
- differix-0.1.0a1/tests/test_verification_firewall.py +32 -0
- differix-0.1.0a1/tests/test_visual_capture.py +23 -0
- differix-0.1.0a1/tests/test_visual_diff.py +185 -0
- differix-0.1.0a1/tools/agents_chat.py +152 -0
- differix-0.1.0a1/tools/asset_extractor.py +545 -0
- differix-0.1.0a1/tools/asset_manager.py +115 -0
- differix-0.1.0a1/tools/atomic_changeset.py +264 -0
- differix-0.1.0a1/tools/backup_manager.py +122 -0
- differix-0.1.0a1/tools/changelog_engine.py +261 -0
- differix-0.1.0a1/tools/coherence_checker.py +771 -0
- differix-0.1.0a1/tools/compiler.py +531 -0
- differix-0.1.0a1/tools/component_normalizer.py +130 -0
- differix-0.1.0a1/tools/context_assembler.py +631 -0
- differix-0.1.0a1/tools/contract_validator.py +478 -0
- differix-0.1.0a1/tools/cost_predictor.py +141 -0
- differix-0.1.0a1/tools/drift_guard.py +196 -0
- differix-0.1.0a1/tools/file_manager.py +119 -0
- differix-0.1.0a1/tools/handshake.py +351 -0
- differix-0.1.0a1/tools/lesson_manager.py +570 -0
- differix-0.1.0a1/tools/lesson_memory_bridge.py +398 -0
- differix-0.1.0a1/tools/llm_client.py +103 -0
- differix-0.1.0a1/tools/llm_selector.py +362 -0
- differix-0.1.0a1/tools/model_ranker.py +535 -0
- differix-0.1.0a1/tools/palette_extractor.py +1043 -0
- differix-0.1.0a1/tools/plugin_manager.py +204 -0
- differix-0.1.0a1/tools/project_context_gatherer.py +404 -0
- differix-0.1.0a1/tools/project_scanner.py +41 -0
- differix-0.1.0a1/tools/project_structure.py +244 -0
- differix-0.1.0a1/tools/reasoning_logger.py +78 -0
- differix-0.1.0a1/tools/reflection.py +452 -0
- differix-0.1.0a1/tools/sandbox_utils.py +56 -0
- differix-0.1.0a1/tools/security/__init__.py +0 -0
- differix-0.1.0a1/tools/security/behavior_guard.py +83 -0
- differix-0.1.0a1/tools/security/verification_firewall.py +113 -0
- differix-0.1.0a1/tools/security_filter.py +213 -0
- differix-0.1.0a1/tools/session_adr.py +311 -0
- differix-0.1.0a1/tools/skill_manager.py +129 -0
- differix-0.1.0a1/tools/state_manager.py +383 -0
- differix-0.1.0a1/tools/structure_index.py +570 -0
- differix-0.1.0a1/tools/test_schema_consistency.py +19 -0
- differix-0.1.0a1/tools/verification_firewall.py +113 -0
- differix-0.1.0a1/tools/visual_capture.py +65 -0
- differix-0.1.0a1/tools/visual_diff.py +220 -0
- differix-0.1.0a1/tools/visual_reviewer.py +79 -0
- differix-0.1.0a1/tools/web_search.py +371 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: differix
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: The Deterministic, Local-First OS for Autonomous AI Agents.
|
|
5
|
+
Author: Tdahqu8
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://differix.dev
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Tdahqu8/DifferiX/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/Tdahqu8/DifferiX
|
|
10
|
+
Keywords: ai,llm,agents,multi-agent,openai,claude,autonomous,developer-tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: openai>=1.0.0
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: rich>=13.0.0
|
|
27
|
+
Requires-Dist: GitPython>=3.1.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
30
|
+
Provides-Extra: full
|
|
31
|
+
Requires-Dist: fastembed>=0.3.0; extra == "full"
|
|
32
|
+
Requires-Dist: PyYAML>=6.0; extra == "full"
|
|
33
|
+
Requires-Dist: playwright>=1.40.0; extra == "full"
|
|
34
|
+
|
|
35
|
+
<div align="center">
|
|
36
|
+
<h1>DifferiX</h1>
|
|
37
|
+
<p><strong>The Deterministic, Local-First OS for Autonomous AI Agents.</strong></p>
|
|
38
|
+
<p>
|
|
39
|
+
<a href="#-features">Features</a> •
|
|
40
|
+
<a href="#-quick-start">Quick Start</a> •
|
|
41
|
+
<a href="#-architecture">Architecture</a> •
|
|
42
|
+
<a href="#-plugins">Plugins</a>
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
DifferiX is an open, model-agnostic multi-agent runtime. It doesn't just let LLMs write code; it forces them to write **verified, executable code** inside a deterministic Python shell.
|
|
49
|
+
|
|
50
|
+
Most "autonomous agents" fail because they hallucinate success and loop endlessly. DifferiX solves this with **Zero Trust Architecture**, **Tiered Context Assembly**, and a **Local Experience Engine**.
|
|
51
|
+
|
|
52
|
+
## 🧠 Core Architectural Patterns
|
|
53
|
+
|
|
54
|
+
1. **Zero Trust to Agent:** A stochastic LLM is never trusted with state mutation. Success is verified by compilers, ASTs, and fs-hashes, not by the agent's words.
|
|
55
|
+
2. **Probabilistic in a Deterministic Shell:** The LLM acts purely as a translator. Python scripts act as the supreme judge, sandbox, and state machine.
|
|
56
|
+
3. **Scarcity by Design (TCA):** Artificial token budgets force the LLM to focus on surgical context (reducing input tokens by 80%).
|
|
57
|
+
4. **Evolutionary Memory (A-Score):** Experience is stored as abstract math. Unhelpful lessons fade out; helpful ones propagate without retraining the LLM.
|
|
58
|
+
|
|
59
|
+
## ✨ Features
|
|
60
|
+
|
|
61
|
+
* **Verification Firewall:** Blocks agents from faking success. If the file on disk didn't change, the patch is rejected.
|
|
62
|
+
* **Behavior Guard:** Detects prompt-context bloat and repeated actions in real-time. Kills the loop before it burns your API budget.
|
|
63
|
+
* **Tiered Context Assembly (TCA):** Waterfall token budgeting. AST-slicing for Python, Class Inventory extraction for CSS.
|
|
64
|
+
* **Dynamic Skill Injection:** The Researcher analyzes the task and injects *only* the relevant skills (e.g., `style_brutalist` or `style_3d_webgl`).
|
|
65
|
+
* **Recursive Self-Improvement (RSI):** The framework can safely modify its own source code, protected by a critical-file gate.
|
|
66
|
+
* **Model Agnostic:** Works with OpenRouter, Anthropic, Google, or local Ollama models.
|
|
67
|
+
|
|
68
|
+
## 🚀 Quick Start
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Clone the repository
|
|
72
|
+
git clone https://github.com/Tdahqu8/DifferiX.git
|
|
73
|
+
cd DifferiX
|
|
74
|
+
|
|
75
|
+
# Setup environment
|
|
76
|
+
python3 -m venv venv
|
|
77
|
+
source venv/bin/activate
|
|
78
|
+
pip install -r requirements.txt
|
|
79
|
+
|
|
80
|
+
# Add your API key
|
|
81
|
+
cp .env.example .env
|
|
82
|
+
# Edit .env and add OPENROUTER_API_KEY=sk-or-v1-...
|
|
83
|
+
|
|
84
|
+
# Run DifferiX
|
|
85
|
+
export VEGEX_RSI_MODE=1
|
|
86
|
+
python main.py
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 🎨 Showcase (WebDev Pro Plugin)
|
|
90
|
+
|
|
91
|
+
DifferiX dynamically injects design skills to generate production-ready UIs.
|
|
92
|
+
|
|
93
|
+
> *"Create a portfolio landing page for a Web3 graffiti artist. Make it look raw and bold."* — Generated in 4 cycles using the `style_brutalist` skill.
|
|
94
|
+
|
|
95
|
+
## 🏗️ Architecture
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
[ User ] <--> [ LLM Interpreter ]
|
|
99
|
+
|
|
|
100
|
+
+--------------+--------------+
|
|
101
|
+
| |
|
|
102
|
+
[ Symbolic Verifier ] [ Experience Engine ]
|
|
103
|
+
(Python / AST / Compilers) (A-Score / SQLite / Math)
|
|
104
|
+
| |
|
|
105
|
+
+--------------+--------------+
|
|
106
|
+
|
|
|
107
|
+
[ Verified Code ]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 🔌 Ecosystem & Plugins
|
|
111
|
+
|
|
112
|
+
DifferiX supports `.differixplugin` files. Install plugins to give agents new superpowers (WebDev, EdTech, Crypto Auditing).
|
|
113
|
+
|
|
114
|
+
> **Note:** Executable code is strictly forbidden inside plugins to prevent RCE. Plugins are data-only (`.md`, `.json`, `.css`).
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Install the official WebDev Pro plugin
|
|
118
|
+
differix plugin install https://github.com/Tdahqu8/DifferiX/releases/download/v0.1.0-alpha/webdev_pro.differixplugin
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 📜 License
|
|
122
|
+
|
|
123
|
+
MIT License. Build, fork, and create.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>DifferiX</h1>
|
|
3
|
+
<p><strong>The Deterministic, Local-First OS for Autonomous AI Agents.</strong></p>
|
|
4
|
+
<p>
|
|
5
|
+
<a href="#-features">Features</a> •
|
|
6
|
+
<a href="#-quick-start">Quick Start</a> •
|
|
7
|
+
<a href="#-architecture">Architecture</a> •
|
|
8
|
+
<a href="#-plugins">Plugins</a>
|
|
9
|
+
</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
DifferiX is an open, model-agnostic multi-agent runtime. It doesn't just let LLMs write code; it forces them to write **verified, executable code** inside a deterministic Python shell.
|
|
15
|
+
|
|
16
|
+
Most "autonomous agents" fail because they hallucinate success and loop endlessly. DifferiX solves this with **Zero Trust Architecture**, **Tiered Context Assembly**, and a **Local Experience Engine**.
|
|
17
|
+
|
|
18
|
+
## 🧠 Core Architectural Patterns
|
|
19
|
+
|
|
20
|
+
1. **Zero Trust to Agent:** A stochastic LLM is never trusted with state mutation. Success is verified by compilers, ASTs, and fs-hashes, not by the agent's words.
|
|
21
|
+
2. **Probabilistic in a Deterministic Shell:** The LLM acts purely as a translator. Python scripts act as the supreme judge, sandbox, and state machine.
|
|
22
|
+
3. **Scarcity by Design (TCA):** Artificial token budgets force the LLM to focus on surgical context (reducing input tokens by 80%).
|
|
23
|
+
4. **Evolutionary Memory (A-Score):** Experience is stored as abstract math. Unhelpful lessons fade out; helpful ones propagate without retraining the LLM.
|
|
24
|
+
|
|
25
|
+
## ✨ Features
|
|
26
|
+
|
|
27
|
+
* **Verification Firewall:** Blocks agents from faking success. If the file on disk didn't change, the patch is rejected.
|
|
28
|
+
* **Behavior Guard:** Detects prompt-context bloat and repeated actions in real-time. Kills the loop before it burns your API budget.
|
|
29
|
+
* **Tiered Context Assembly (TCA):** Waterfall token budgeting. AST-slicing for Python, Class Inventory extraction for CSS.
|
|
30
|
+
* **Dynamic Skill Injection:** The Researcher analyzes the task and injects *only* the relevant skills (e.g., `style_brutalist` or `style_3d_webgl`).
|
|
31
|
+
* **Recursive Self-Improvement (RSI):** The framework can safely modify its own source code, protected by a critical-file gate.
|
|
32
|
+
* **Model Agnostic:** Works with OpenRouter, Anthropic, Google, or local Ollama models.
|
|
33
|
+
|
|
34
|
+
## 🚀 Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Clone the repository
|
|
38
|
+
git clone https://github.com/Tdahqu8/DifferiX.git
|
|
39
|
+
cd DifferiX
|
|
40
|
+
|
|
41
|
+
# Setup environment
|
|
42
|
+
python3 -m venv venv
|
|
43
|
+
source venv/bin/activate
|
|
44
|
+
pip install -r requirements.txt
|
|
45
|
+
|
|
46
|
+
# Add your API key
|
|
47
|
+
cp .env.example .env
|
|
48
|
+
# Edit .env and add OPENROUTER_API_KEY=sk-or-v1-...
|
|
49
|
+
|
|
50
|
+
# Run DifferiX
|
|
51
|
+
export VEGEX_RSI_MODE=1
|
|
52
|
+
python main.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 🎨 Showcase (WebDev Pro Plugin)
|
|
56
|
+
|
|
57
|
+
DifferiX dynamically injects design skills to generate production-ready UIs.
|
|
58
|
+
|
|
59
|
+
> *"Create a portfolio landing page for a Web3 graffiti artist. Make it look raw and bold."* — Generated in 4 cycles using the `style_brutalist` skill.
|
|
60
|
+
|
|
61
|
+
## 🏗️ Architecture
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
[ User ] <--> [ LLM Interpreter ]
|
|
65
|
+
|
|
|
66
|
+
+--------------+--------------+
|
|
67
|
+
| |
|
|
68
|
+
[ Symbolic Verifier ] [ Experience Engine ]
|
|
69
|
+
(Python / AST / Compilers) (A-Score / SQLite / Math)
|
|
70
|
+
| |
|
|
71
|
+
+--------------+--------------+
|
|
72
|
+
|
|
|
73
|
+
[ Verified Code ]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 🔌 Ecosystem & Plugins
|
|
77
|
+
|
|
78
|
+
DifferiX supports `.differixplugin` files. Install plugins to give agents new superpowers (WebDev, EdTech, Crypto Auditing).
|
|
79
|
+
|
|
80
|
+
> **Note:** Executable code is strictly forbidden inside plugins to prevent RCE. Plugins are data-only (`.md`, `.json`, `.css`).
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Install the official WebDev Pro plugin
|
|
84
|
+
differix plugin install https://github.com/Tdahqu8/DifferiX/releases/download/v0.1.0-alpha/webdev_pro.differixplugin
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 📜 License
|
|
88
|
+
|
|
89
|
+
MIT License. Build, fork, and create.
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import config
|
|
5
|
+
from tools.lesson_manager import (
|
|
6
|
+
get_ranked_lessons_for_role,
|
|
7
|
+
get_promoted_lessons_prompt,
|
|
8
|
+
)
|
|
9
|
+
from tools.skill_manager import get_combined_agent_skill
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from tools.llm_client import safe_chat_completion
|
|
13
|
+
except ImportError:
|
|
14
|
+
from llm_client import safe_chat_completion
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_editor_model() -> str:
|
|
18
|
+
if hasattr(config, "get_active_model") and callable(getattr(config, "get_active_model")):
|
|
19
|
+
return config.get_active_model("editor")
|
|
20
|
+
if hasattr(config, "get_agent_model") and callable(getattr(config, "get_agent_model")):
|
|
21
|
+
return config.get_agent_model("editor")
|
|
22
|
+
return getattr(config, "MODEL_EDITOR", "anthropic/claude-3.7-sonnet")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
VEGEX_FRAMEWORK_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
26
|
+
VEGEX_FRAMEWORK_CORE_FILES = {
|
|
27
|
+
".env", "config.py", "state.json", "deduplicate.py",
|
|
28
|
+
"agents_chat.py", "agents_chat_2.py", "PROJECT_STRUCTURE.md",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_sandboxed_path(target_file: str, project_dir: str = ".") -> str:
|
|
33
|
+
base_path = os.path.abspath(project_dir)
|
|
34
|
+
target_path = os.path.abspath(os.path.join(base_path, target_file))
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
contained = os.path.commonpath([base_path, target_path]) == base_path
|
|
38
|
+
except ValueError:
|
|
39
|
+
contained = False
|
|
40
|
+
if not contained:
|
|
41
|
+
raise PermissionError(
|
|
42
|
+
f"SANDBOX VIOLATION: Path '{target_file}' escapes boundary '{project_dir}'."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
filename = os.path.basename(target_path)
|
|
46
|
+
if filename in VEGEX_FRAMEWORK_CORE_FILES:
|
|
47
|
+
target_dir = os.path.dirname(target_path)
|
|
48
|
+
try:
|
|
49
|
+
is_editing_framework_core = os.path.samefile(target_dir, VEGEX_FRAMEWORK_ROOT)
|
|
50
|
+
except (FileNotFoundError, AttributeError):
|
|
51
|
+
is_editing_framework_core = (target_dir == VEGEX_FRAMEWORK_ROOT)
|
|
52
|
+
|
|
53
|
+
if is_editing_framework_core:
|
|
54
|
+
raise PermissionError(
|
|
55
|
+
f"SANDBOX VIOLATION: Editing core framework file '{filename}' in VeGeX root is forbidden."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return target_path
|
|
59
|
+
|
|
60
|
+
def _looks_like_full_rewrite(diff_text: str) -> bool:
|
|
61
|
+
if re.search(r"<{4,}\s*SEARCH", diff_text):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
fences = re.findall(
|
|
65
|
+
r"```(?:\w+)?\n(.*?)```",
|
|
66
|
+
diff_text,
|
|
67
|
+
re.DOTALL,
|
|
68
|
+
)
|
|
69
|
+
if len(fences) != 1:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
return fences[0].count("\n") >= 30
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply_diff_blocks(original_content: str, diff_text: str, allow_full_rewrite: bool = False, target_file: str = "") -> dict:
|
|
76
|
+
"""Applies SEARCH/REPLACE diff blocks with strict verification."""
|
|
77
|
+
diff_text = diff_text.replace("\r\n", "\n")
|
|
78
|
+
original_content = original_content.replace("\r\n", "\n")
|
|
79
|
+
|
|
80
|
+
is_empty_file = not original_content.strip()
|
|
81
|
+
|
|
82
|
+
# HARD RULE: Never allow full rewrite of an existing HTML file.
|
|
83
|
+
# This prevents the agent from destroying working HTML when confused by a truncated view.
|
|
84
|
+
if target_file.endswith((".html", ".htm")) and len(original_content) > 200:
|
|
85
|
+
allow_full_rewrite = False
|
|
86
|
+
|
|
87
|
+
full_rewrite_intent = is_empty_file or allow_full_rewrite or _looks_like_full_rewrite(diff_text)
|
|
88
|
+
|
|
89
|
+
# Mode A: full rewrite
|
|
90
|
+
if full_rewrite_intent:
|
|
91
|
+
code_match = re.search(
|
|
92
|
+
r"```(?:python|csharp|cs|luau|lua|xml|json|html|css|js|ts|jsx|tsx|yaml|yml|sql|sh|bash|shell)?\n(.*?)```",
|
|
93
|
+
diff_text, re.DOTALL,
|
|
94
|
+
)
|
|
95
|
+
candidate = code_match.group(1).strip() if code_match else diff_text.strip()
|
|
96
|
+
candidate = re.sub(r"^TARGET_FILE:\s*[^\n]+\n?", "", candidate).strip()
|
|
97
|
+
|
|
98
|
+
clean_lines = [
|
|
99
|
+
line for line in candidate.splitlines()
|
|
100
|
+
if not re.match(r"^(<{4,}|={4,}|>{4,}|<<<<<<<|=======|>>>>>>>)", line.strip())
|
|
101
|
+
]
|
|
102
|
+
candidate_code = "\n".join(clean_lines)
|
|
103
|
+
|
|
104
|
+
if original_content.strip().startswith("{"):
|
|
105
|
+
try:
|
|
106
|
+
json.loads(original_content)
|
|
107
|
+
json.loads(candidate_code)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
return {
|
|
110
|
+
"ok": False,
|
|
111
|
+
"code": original_content,
|
|
112
|
+
"matched": 0,
|
|
113
|
+
"failed": [{"search_preview": "JSON_VALIDATION_FAILED",
|
|
114
|
+
"reason": "Candidate output is invalid JSON."}],
|
|
115
|
+
"mode": "rejected",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {"ok": True, "code": candidate_code, "matched": 0,
|
|
119
|
+
"failed": [], "mode": "full_rewrite"}
|
|
120
|
+
|
|
121
|
+
# Mode B: SEARCH/REPLACE
|
|
122
|
+
pattern = r"<{4,}\s*SEARCH[ \t]*\n(.*?)\n?={4,}[ \t]*\n(.*?)\n?>{4,}\s*REPLACE"
|
|
123
|
+
matches = re.findall(pattern, diff_text, re.DOTALL)
|
|
124
|
+
|
|
125
|
+
if not matches:
|
|
126
|
+
return {
|
|
127
|
+
"ok": False,
|
|
128
|
+
"code": original_content,
|
|
129
|
+
"matched": 0,
|
|
130
|
+
"failed": [{"search_preview": "NO_SEARCH_BLOCKS",
|
|
131
|
+
"reason": "No SEARCH/REPLACE blocks found in patch for non-empty file."}],
|
|
132
|
+
"mode": "rejected",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
updated_content = original_content
|
|
136
|
+
failed_blocks = []
|
|
137
|
+
matched_count = 0
|
|
138
|
+
|
|
139
|
+
for idx, (search_block, replace_block) in enumerate(matches, 1):
|
|
140
|
+
count = updated_content.count(search_block)
|
|
141
|
+
|
|
142
|
+
if count == 1:
|
|
143
|
+
updated_content = updated_content.replace(search_block, replace_block, 1)
|
|
144
|
+
matched_count += 1
|
|
145
|
+
elif count > 1:
|
|
146
|
+
failed_blocks.append({
|
|
147
|
+
"block_index": idx,
|
|
148
|
+
"search_preview": search_block[:150] + ("..." if len(search_block) > 150 else ""),
|
|
149
|
+
"reason": f"AMBIGUOUS_MATCH: SEARCH block found {count} times in file.",
|
|
150
|
+
})
|
|
151
|
+
else:
|
|
152
|
+
search_lines_stripped = "\n".join(line.rstrip() for line in search_block.splitlines())
|
|
153
|
+
content_lines_stripped = "\n".join(line.rstrip() for line in updated_content.splitlines())
|
|
154
|
+
|
|
155
|
+
if search_lines_stripped and content_lines_stripped.count(search_lines_stripped) == 1:
|
|
156
|
+
start = content_lines_stripped.find(search_lines_stripped)
|
|
157
|
+
end = start + len(search_lines_stripped)
|
|
158
|
+
before_original = updated_content[:start]
|
|
159
|
+
after_original = updated_content[end:]
|
|
160
|
+
updated_content = before_original + replace_block + after_original
|
|
161
|
+
matched_count += 1
|
|
162
|
+
else:
|
|
163
|
+
failed_blocks.append({
|
|
164
|
+
"block_index": idx,
|
|
165
|
+
"search_preview": search_block[:150] + ("..." if len(search_block) > 150 else ""),
|
|
166
|
+
"reason": "NOT_FOUND: SEARCH block does not match current file content verbatim.",
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
if failed_blocks:
|
|
170
|
+
return {
|
|
171
|
+
"ok": False,
|
|
172
|
+
"code": original_content,
|
|
173
|
+
"matched": matched_count,
|
|
174
|
+
"failed": failed_blocks,
|
|
175
|
+
"mode": "rejected",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {"ok": True, "code": updated_content, "matched": matched_count,
|
|
179
|
+
"failed": [], "mode": "search_replace"}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_retrieval_query(state: dict, target_file: str) -> str:
|
|
183
|
+
parts = [
|
|
184
|
+
str(state.get("active_subtask") or ""),
|
|
185
|
+
str(state.get("research_analysis") or ""),
|
|
186
|
+
str(state.get("goal") or ""),
|
|
187
|
+
str(target_file or ""),
|
|
188
|
+
]
|
|
189
|
+
query = " ".join(p for p in parts if p).strip()
|
|
190
|
+
return query or "general code editing"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_editor(
|
|
194
|
+
state,
|
|
195
|
+
project_files="",
|
|
196
|
+
project_dir=".",
|
|
197
|
+
session_context="",
|
|
198
|
+
related_context="",
|
|
199
|
+
):
|
|
200
|
+
from tools.context_assembler import (
|
|
201
|
+
ContextBundle,
|
|
202
|
+
Tier,
|
|
203
|
+
TierItem,
|
|
204
|
+
ContextAssembler,
|
|
205
|
+
smart_slice_code,
|
|
206
|
+
dedupe_logs,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
target_file = state.get("target_file") or "main.py"
|
|
210
|
+
safe_target_path = resolve_sandboxed_path(target_file, project_dir)
|
|
211
|
+
|
|
212
|
+
target_code = ""
|
|
213
|
+
if os.path.exists(safe_target_path):
|
|
214
|
+
try:
|
|
215
|
+
with open(safe_target_path, "r", encoding="utf-8") as f:
|
|
216
|
+
target_code = f.read()
|
|
217
|
+
except Exception as e:
|
|
218
|
+
target_code = f"# Error reading file: {e}"
|
|
219
|
+
|
|
220
|
+
iteration_logs = state.get("iteration_logs") or state.get("compiler_output", "") or ""
|
|
221
|
+
iteration_number = state.get("iteration_count", 0)
|
|
222
|
+
|
|
223
|
+
lessons_block = ""
|
|
224
|
+
if not session_context:
|
|
225
|
+
retrieval_query = _build_retrieval_query(state, target_file)
|
|
226
|
+
_scored, lessons_block, lesson_node_ids = get_ranked_lessons_for_role(
|
|
227
|
+
role="editor",
|
|
228
|
+
query=retrieval_query,
|
|
229
|
+
limit=getattr(config, "LESSONS_RETRIEVAL_LIMIT", 5),
|
|
230
|
+
)
|
|
231
|
+
if not lessons_block:
|
|
232
|
+
lessons_block = get_promoted_lessons_prompt()
|
|
233
|
+
state["active_lesson_node_ids"] = list(lesson_node_ids or [])
|
|
234
|
+
|
|
235
|
+
# --- DYNAMIC SKILL INJECTION ---
|
|
236
|
+
active_styles = state.get("web_style", "")
|
|
237
|
+
if isinstance(active_styles, str) and active_styles:
|
|
238
|
+
active_styles = [active_styles]
|
|
239
|
+
elif not isinstance(active_styles, list):
|
|
240
|
+
active_styles = []
|
|
241
|
+
|
|
242
|
+
# Always add polish skill if it's a web task
|
|
243
|
+
if any(x.endswith(('.html', '.css', '.js')) for x in [target_file]):
|
|
244
|
+
if "styles/style_polish" not in active_styles:
|
|
245
|
+
active_styles.append("styles/style_polish")
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
system_prompt = get_combined_agent_skill("editor", project_dir, active_styles=active_styles)
|
|
249
|
+
except TypeError:
|
|
250
|
+
# Fallback if skill_manager doesn't support the list yet
|
|
251
|
+
system_prompt = get_combined_agent_skill("editor", project_dir)
|
|
252
|
+
|
|
253
|
+
editor_feedback = state.pop("editor_feedback", None)
|
|
254
|
+
editor_payload = {
|
|
255
|
+
"target_file": target_file,
|
|
256
|
+
"goal": state.get("goal", ""),
|
|
257
|
+
"subtask": state.get("active_subtask", ""),
|
|
258
|
+
"research": state.get("research_analysis")
|
|
259
|
+
or state.get("research_data", {}).get("analysis", ""),
|
|
260
|
+
"style_requirement": ", ".join(active_styles) or "default",
|
|
261
|
+
}
|
|
262
|
+
if editor_feedback:
|
|
263
|
+
editor_payload["REJECTION_FEEDBACK"] = editor_feedback
|
|
264
|
+
|
|
265
|
+
is_new_or_empty = not target_code.strip()
|
|
266
|
+
|
|
267
|
+
file_mode_instruction = (
|
|
268
|
+
"FILE STATUS: NEW OR EMPTY FILE.\n"
|
|
269
|
+
"INSTRUCTION: Output the COMPLETE file code inside a single standard Markdown code block. "
|
|
270
|
+
"Do NOT use SEARCH/REPLACE blocks."
|
|
271
|
+
if is_new_or_empty else
|
|
272
|
+
"FILE STATUS: EXISTING FILE WITH CONTENT.\n"
|
|
273
|
+
"INSTRUCTION: Output one or more SEARCH/REPLACE blocks strictly matching existing code. "
|
|
274
|
+
"Do NOT output the full file. Use the format:\n"
|
|
275
|
+
"<<<<<<< SEARCH\n(verbatim lines from the file)\n=======\n(replacement lines)\n>>>>>>> REPLACE\n"
|
|
276
|
+
"CRITICAL RULE: If the file is truncated and you cannot see the whole thing, "
|
|
277
|
+
"you MUST still use SEARCH/REPLACE targeting the visible lines. Never attempt a full rewrite of an existing HTML file."
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
coherence_note = ""
|
|
281
|
+
if related_context:
|
|
282
|
+
coherence_note = (
|
|
283
|
+
"=== CRITICAL: CROSS-FILE COHERENCE ===\n"
|
|
284
|
+
"The RELATED FILES section below shows other files in this project. "
|
|
285
|
+
"You MUST use the exact class names, CSS variables, and function names "
|
|
286
|
+
"already defined there. Do NOT invent new top-level names. If you need "
|
|
287
|
+
"a new class, derive it from an existing prefix (e.g. .hero-title → "
|
|
288
|
+
".hero-subtitle).\n\n"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# ---- Build TCA bundle ----
|
|
292
|
+
bundle = ContextBundle()
|
|
293
|
+
|
|
294
|
+
# T0: system prompt (cacheable prefix)
|
|
295
|
+
bundle.add_text(Tier.T0_STATIC, "skills", system_prompt)
|
|
296
|
+
|
|
297
|
+
# T1: action core — spec + file mode + coherence note + session brief
|
|
298
|
+
action_core = (
|
|
299
|
+
f"{coherence_note}"
|
|
300
|
+
f"=== FILE WRITING MODE ===\n{file_mode_instruction}\n\n"
|
|
301
|
+
f"=== SPECIFICATION ===\n{json.dumps(editor_payload, ensure_ascii=False)}"
|
|
302
|
+
)
|
|
303
|
+
bundle.add_text(Tier.T1_ACTION_CORE, "spec", action_core)
|
|
304
|
+
|
|
305
|
+
if session_context:
|
|
306
|
+
bundle.add_text(Tier.T1_ACTION_CORE, "session", session_context)
|
|
307
|
+
|
|
308
|
+
# T2: target file, sliced for prompt (full file still used for apply)
|
|
309
|
+
if target_code:
|
|
310
|
+
if is_new_or_empty:
|
|
311
|
+
bundle.add_text(
|
|
312
|
+
Tier.T2_TARGET_SLICE,
|
|
313
|
+
"target",
|
|
314
|
+
f"=== TARGET FILE ({target_file}) ===\n[empty — create new file]",
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
hints = " ".join(filter(None, [
|
|
318
|
+
str(state.get("active_subtask") or ""),
|
|
319
|
+
str(state.get("research_analysis") or "")[:400],
|
|
320
|
+
]))
|
|
321
|
+
sliced = smart_slice_code(
|
|
322
|
+
target_code, target_file, max_lines=150, hints=hints
|
|
323
|
+
)
|
|
324
|
+
tightly = smart_slice_code(
|
|
325
|
+
target_code, target_file, max_lines=50, hints=hints
|
|
326
|
+
)
|
|
327
|
+
bundle.add(TierItem(
|
|
328
|
+
tier=Tier.T2_TARGET_SLICE,
|
|
329
|
+
key="target",
|
|
330
|
+
content=f"=== TARGET FILE ({target_file}) ===\n{sliced}",
|
|
331
|
+
fallback_content=(
|
|
332
|
+
f"=== TARGET FILE ({target_file}) [HEAVILY TRUNCATED] ===\n{tightly}"
|
|
333
|
+
if tightly != sliced else ""
|
|
334
|
+
),
|
|
335
|
+
optional=False,
|
|
336
|
+
))
|
|
337
|
+
|
|
338
|
+
# T3: related files (sibling interfaces)
|
|
339
|
+
if related_context:
|
|
340
|
+
bundle.add_text(Tier.T3_INTERFACE, "related", related_context)
|
|
341
|
+
|
|
342
|
+
# T4: lessons
|
|
343
|
+
if lessons_block:
|
|
344
|
+
bundle.add_text(Tier.T4_ENRICHMENT, "lessons", lessons_block)
|
|
345
|
+
|
|
346
|
+
# T5: deduped logs (skip on first cycle by default)
|
|
347
|
+
if iteration_logs.strip():
|
|
348
|
+
include_logs = (
|
|
349
|
+
iteration_number > 0
|
|
350
|
+
or getattr(config, "TCA_INCLUDE_FEEDBACK_ON_FIRST_CYCLE", False)
|
|
351
|
+
)
|
|
352
|
+
if include_logs:
|
|
353
|
+
deduped = dedupe_logs(iteration_logs, max_errors=5)
|
|
354
|
+
if deduped:
|
|
355
|
+
bundle.add_text(
|
|
356
|
+
Tier.T5_FEEDBACK,
|
|
357
|
+
"logs",
|
|
358
|
+
f"=== RECENT ERRORS (deduplicated) ===\n{deduped}",
|
|
359
|
+
optional=True,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# ---- Assemble ----
|
|
363
|
+
budget = getattr(config, "TOKEN_BUDGET_EDITOR", 18000)
|
|
364
|
+
assembled = ContextAssembler(
|
|
365
|
+
budget=budget,
|
|
366
|
+
safety_margin=getattr(config, "TCA_SAFETY_MARGIN", 0.9),
|
|
367
|
+
).assemble(bundle)
|
|
368
|
+
|
|
369
|
+
if getattr(config, "TCA_LOG_SUMMARY", True):
|
|
370
|
+
try:
|
|
371
|
+
print(assembled.log_summary())
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
# ---- Call LLM ----
|
|
376
|
+
extra_body_params = {"reasoning": {"max_tokens": 8192}}
|
|
377
|
+
active_model = get_editor_model()
|
|
378
|
+
|
|
379
|
+
response, provider = safe_chat_completion(
|
|
380
|
+
active_model,
|
|
381
|
+
messages=[
|
|
382
|
+
{"role": "system", "content": assembled.system_prompt},
|
|
383
|
+
{"role": "user", "content": assembled.user_prompt},
|
|
384
|
+
],
|
|
385
|
+
temperature=0.15,
|
|
386
|
+
max_tokens=8192,
|
|
387
|
+
extra_body=extra_body_params,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if not response or not getattr(response, "choices", None) or len(response.choices) == 0:
|
|
391
|
+
raise RuntimeError(
|
|
392
|
+
f"Error: Editor received no choices from model provider ({active_model})."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
choice = response.choices[0]
|
|
396
|
+
message = getattr(choice, "message", None)
|
|
397
|
+
finish_reason = getattr(choice, "finish_reason", None)
|
|
398
|
+
|
|
399
|
+
result_text = ""
|
|
400
|
+
if message:
|
|
401
|
+
result_text = getattr(message, "content", "") or ""
|
|
402
|
+
if not result_text.strip() and hasattr(message, "reasoning_content"):
|
|
403
|
+
result_text = getattr(message, "reasoning_content", "") or ""
|
|
404
|
+
|
|
405
|
+
result_text = result_text.strip()
|
|
406
|
+
|
|
407
|
+
if not result_text:
|
|
408
|
+
if finish_reason == "length":
|
|
409
|
+
raise RuntimeError(
|
|
410
|
+
f"Error: Editor response was truncated due to max_tokens limit "
|
|
411
|
+
f"(finish_reason='length'). Model: {active_model}"
|
|
412
|
+
)
|
|
413
|
+
raise RuntimeError(
|
|
414
|
+
f"Error: Editor received empty response from model ({active_model}). "
|
|
415
|
+
f"finish_reason: {finish_reason}"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Reload target_code if the model requested a different file.
|
|
419
|
+
target_file_match = re.search(r"TARGET_FILE:\s*([^\n]+)", result_text)
|
|
420
|
+
if target_file_match:
|
|
421
|
+
raw_path = target_file_match.group(1).strip()
|
|
422
|
+
target_file = re.sub(r'[`"\'\s]', '', raw_path)
|
|
423
|
+
safe_target_path = resolve_sandboxed_path(target_file, project_dir)
|
|
424
|
+
if os.path.exists(safe_target_path):
|
|
425
|
+
try:
|
|
426
|
+
with open(safe_target_path, "r", encoding="utf-8") as f:
|
|
427
|
+
target_code = f.read()
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
# Apply against FULL file, passing target_file for the HTML rewrite guard
|
|
432
|
+
apply_res = apply_diff_blocks(
|
|
433
|
+
target_code,
|
|
434
|
+
result_text,
|
|
435
|
+
allow_full_rewrite=state.get("allow_full_rewrite", False),
|
|
436
|
+
target_file=target_file,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
state["generated_code"] = {
|
|
440
|
+
"target_file": target_file,
|
|
441
|
+
"action": "modify",
|
|
442
|
+
"code": apply_res["code"],
|
|
443
|
+
"apply_result": apply_res,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
usage = getattr(response, "usage", None)
|
|
447
|
+
usage_meta = type("Meta", (), {
|
|
448
|
+
"prompt_token_count": getattr(usage, "prompt_tokens", 0) or 0,
|
|
449
|
+
"candidates_token_count": getattr(usage, "completion_tokens", 0) or 0,
|
|
450
|
+
"raw_response": result_text,
|
|
451
|
+
"provider": provider,
|
|
452
|
+
"model_id": active_model,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
return state, usage_meta
|