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.
Files changed (72) hide show
  1. differix-0.1.0a1/PKG-INFO +123 -0
  2. differix-0.1.0a1/README.md +89 -0
  3. differix-0.1.0a1/agents/editor.py +455 -0
  4. differix-0.1.0a1/agents/researcher.py +405 -0
  5. differix-0.1.0a1/agents/reviewer.py +374 -0
  6. differix-0.1.0a1/cli/model_cli.py +430 -0
  7. differix-0.1.0a1/config.py +557 -0
  8. differix-0.1.0a1/differix.egg-info/PKG-INFO +123 -0
  9. differix-0.1.0a1/differix.egg-info/SOURCES.txt +70 -0
  10. differix-0.1.0a1/differix.egg-info/dependency_links.txt +1 -0
  11. differix-0.1.0a1/differix.egg-info/entry_points.txt +2 -0
  12. differix-0.1.0a1/differix.egg-info/requires.txt +15 -0
  13. differix-0.1.0a1/differix.egg-info/top_level.txt +7 -0
  14. differix-0.1.0a1/main.py +2796 -0
  15. differix-0.1.0a1/memory/__init__.py +59 -0
  16. differix-0.1.0a1/memory/embeddings/__init__.py +29 -0
  17. differix-0.1.0a1/memory/embeddings/factory.py +178 -0
  18. differix-0.1.0a1/memory/embeddings/providers.py +259 -0
  19. differix-0.1.0a1/memory/manager.py +186 -0
  20. differix-0.1.0a1/memory/migrator.py +253 -0
  21. differix-0.1.0a1/memory/ranker.py +203 -0
  22. differix-0.1.0a1/memory/schemas.py +127 -0
  23. differix-0.1.0a1/memory/storage.py +552 -0
  24. differix-0.1.0a1/pyproject.toml +81 -0
  25. differix-0.1.0a1/setup.cfg +4 -0
  26. differix-0.1.0a1/tests/test_isolation.py +9 -0
  27. differix-0.1.0a1/tests/test_reflection.py +77 -0
  28. differix-0.1.0a1/tests/test_verification_firewall.py +32 -0
  29. differix-0.1.0a1/tests/test_visual_capture.py +23 -0
  30. differix-0.1.0a1/tests/test_visual_diff.py +185 -0
  31. differix-0.1.0a1/tools/agents_chat.py +152 -0
  32. differix-0.1.0a1/tools/asset_extractor.py +545 -0
  33. differix-0.1.0a1/tools/asset_manager.py +115 -0
  34. differix-0.1.0a1/tools/atomic_changeset.py +264 -0
  35. differix-0.1.0a1/tools/backup_manager.py +122 -0
  36. differix-0.1.0a1/tools/changelog_engine.py +261 -0
  37. differix-0.1.0a1/tools/coherence_checker.py +771 -0
  38. differix-0.1.0a1/tools/compiler.py +531 -0
  39. differix-0.1.0a1/tools/component_normalizer.py +130 -0
  40. differix-0.1.0a1/tools/context_assembler.py +631 -0
  41. differix-0.1.0a1/tools/contract_validator.py +478 -0
  42. differix-0.1.0a1/tools/cost_predictor.py +141 -0
  43. differix-0.1.0a1/tools/drift_guard.py +196 -0
  44. differix-0.1.0a1/tools/file_manager.py +119 -0
  45. differix-0.1.0a1/tools/handshake.py +351 -0
  46. differix-0.1.0a1/tools/lesson_manager.py +570 -0
  47. differix-0.1.0a1/tools/lesson_memory_bridge.py +398 -0
  48. differix-0.1.0a1/tools/llm_client.py +103 -0
  49. differix-0.1.0a1/tools/llm_selector.py +362 -0
  50. differix-0.1.0a1/tools/model_ranker.py +535 -0
  51. differix-0.1.0a1/tools/palette_extractor.py +1043 -0
  52. differix-0.1.0a1/tools/plugin_manager.py +204 -0
  53. differix-0.1.0a1/tools/project_context_gatherer.py +404 -0
  54. differix-0.1.0a1/tools/project_scanner.py +41 -0
  55. differix-0.1.0a1/tools/project_structure.py +244 -0
  56. differix-0.1.0a1/tools/reasoning_logger.py +78 -0
  57. differix-0.1.0a1/tools/reflection.py +452 -0
  58. differix-0.1.0a1/tools/sandbox_utils.py +56 -0
  59. differix-0.1.0a1/tools/security/__init__.py +0 -0
  60. differix-0.1.0a1/tools/security/behavior_guard.py +83 -0
  61. differix-0.1.0a1/tools/security/verification_firewall.py +113 -0
  62. differix-0.1.0a1/tools/security_filter.py +213 -0
  63. differix-0.1.0a1/tools/session_adr.py +311 -0
  64. differix-0.1.0a1/tools/skill_manager.py +129 -0
  65. differix-0.1.0a1/tools/state_manager.py +383 -0
  66. differix-0.1.0a1/tools/structure_index.py +570 -0
  67. differix-0.1.0a1/tools/test_schema_consistency.py +19 -0
  68. differix-0.1.0a1/tools/verification_firewall.py +113 -0
  69. differix-0.1.0a1/tools/visual_capture.py +65 -0
  70. differix-0.1.0a1/tools/visual_diff.py +220 -0
  71. differix-0.1.0a1/tools/visual_reviewer.py +79 -0
  72. 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