differix 0.1.0a1__py3-none-any.whl

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