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
tools/skill_manager.py ADDED
@@ -0,0 +1,129 @@
1
+ import os
2
+ import json
3
+ from typing import Dict, Any, Optional
4
+
5
+ CONFIG_FILE = "skills_config.json"
6
+
7
+ # Framework-level skills directory (relative to the VegeX install root)
8
+ _FRAMEWORK_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
9
+ _FRAMEWORK_SKILLS_DIR = os.path.join(_FRAMEWORK_ROOT, "skills")
10
+
11
+
12
+ class SkillManager:
13
+ def __init__(self, config_path: str = CONFIG_FILE):
14
+ self.config_path = config_path
15
+ self._config: Optional[Dict[str, Any]] = None
16
+
17
+ def _ensure_loaded(self):
18
+ if self._config is not None:
19
+ return
20
+ if os.path.exists(self.config_path):
21
+ try:
22
+ with open(self.config_path, "r", encoding="utf-8") as f:
23
+ self._config = json.load(f)
24
+ except Exception:
25
+ self._config = self._get_default_config()
26
+ else:
27
+ self._config = self._get_default_config()
28
+
29
+ def _get_default_config(self) -> Dict[str, Any]:
30
+ return {
31
+ "active_skills": {
32
+ "researcher": "python",
33
+ "editor": "python",
34
+ "reviewer": "python",
35
+ }
36
+ }
37
+
38
+ def save_config(self):
39
+ if self._config is None:
40
+ self._ensure_loaded()
41
+ os.makedirs(os.path.dirname(os.path.abspath(self.config_path)), exist_ok=True)
42
+ with open(self.config_path, "w", encoding="utf-8") as f:
43
+ json.dump(self._config, f, indent=2, ensure_ascii=False)
44
+
45
+ def get_skill_for_role(self, role: str) -> str:
46
+ self._ensure_loaded()
47
+ return self._config.get("active_skills", {}).get(role, "")
48
+
49
+ def set_skill_for_role(self, role: str, skill_name: str):
50
+ self._ensure_loaded()
51
+ if "active_skills" not in self._config:
52
+ self._config["active_skills"] = {}
53
+ self._config["active_skills"][role] = skill_name
54
+ self.save_config()
55
+
56
+
57
+ _default_manager: Optional[SkillManager] = None
58
+
59
+
60
+ def get_manager() -> SkillManager:
61
+ global _default_manager
62
+ if _default_manager is None:
63
+ _default_manager = SkillManager()
64
+ return _default_manager
65
+
66
+
67
+ def _read_skill_file(filename: str, search_dirs) -> str:
68
+ """Read a skill file from the first directory where it exists."""
69
+ for base in search_dirs:
70
+ if not base:
71
+ continue
72
+ path = os.path.join(base, filename)
73
+ if os.path.isfile(path):
74
+ try:
75
+ with open(path, "r", encoding="utf-8") as f:
76
+ return f.read().strip()
77
+ except Exception:
78
+ continue
79
+ return ""
80
+
81
+
82
+ def get_combined_agent_skill(role: str, project_dir: str = ".") -> str:
83
+ """
84
+ Return the fully-assembled skill prompt for a role.
85
+
86
+ Combines:
87
+ 1. The role-level rules file: `{role}_rules.md` (e.g. `researcher_rules.md`)
88
+ 2. The domain skill configured in skills_config.json (e.g. `python.md`)
89
+
90
+ Lookup order: project_dir/skills → framework_root/skills.
91
+ Both directories are searched so per-project overrides work.
92
+ """
93
+ manager = get_manager()
94
+ domain_skill_name = manager.get_skill_for_role(role)
95
+
96
+ search_dirs = (
97
+ os.path.join(project_dir, "skills") if project_dir else None,
98
+ _FRAMEWORK_SKILLS_DIR,
99
+ )
100
+
101
+ role_file = f"{role}_rules.md"
102
+ role_text = _read_skill_file(role_file, search_dirs)
103
+
104
+ domain_text = ""
105
+ if domain_skill_name:
106
+ domain_text = _read_skill_file(f"{domain_skill_name}.md", search_dirs)
107
+
108
+ parts = []
109
+ if role_text:
110
+ parts.append(f"=== ROLE RULES ({role}) ===\n{role_text}")
111
+ if domain_text:
112
+ parts.append(
113
+ f"=== DOMAIN SKILL ({domain_skill_name}) ===\n{domain_text}"
114
+ )
115
+
116
+ if not parts:
117
+ return (
118
+ f"Standard skill definition for role: {role} "
119
+ f"(no rules or domain skill file found; configured={domain_skill_name or 'None'})"
120
+ )
121
+
122
+ return "\n\n".join(parts)
123
+
124
+
125
+ __all__ = [
126
+ "SkillManager",
127
+ "get_manager",
128
+ "get_combined_agent_skill",
129
+ ]
tools/state_manager.py ADDED
@@ -0,0 +1,383 @@
1
+ import json
2
+ import math
3
+ import os
4
+ import re
5
+ import uuid
6
+ import tempfile
7
+ from datetime import datetime
8
+ from typing import Any, Dict, List, Literal, Optional, Union
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
10
+
11
+
12
+ class BaseStateModel(BaseModel):
13
+ """Базовая модель с поддержкой сохранения произвольных доп. полей от LLM."""
14
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
15
+
16
+
17
+ class MetaModel(BaseStateModel):
18
+ version: str = "1.1"
19
+ session_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
20
+ created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
21
+ target_hardware: str = "generic"
22
+ language: str = "auto"
23
+
24
+
25
+ class ExecutionControl(BaseStateModel):
26
+ status: Literal["idle", "planning", "in_progress", "paused", "done", "failed", "completed", "reflect"] = (
27
+ "idle"
28
+ )
29
+ current_step: Literal["init", "research", "edit", "review", "done", "idle", "reflect"] = "init"
30
+ active_agent: str = "researcher"
31
+ iteration_count: int = 0
32
+ max_iterations: int = 10
33
+ edit_retries: int = 0
34
+ max_edit_retries: int = 3
35
+
36
+
37
+ class Environment(BaseStateModel):
38
+ build_command: str = ""
39
+ test_command: str = ""
40
+ target_files: List[str] = Field(default_factory=list)
41
+ active_target_file: Optional[str] = None
42
+
43
+
44
+ class TaskSpec(BaseStateModel):
45
+ task_id: str = Field(default_factory=lambda: f"task_{uuid.uuid4().hex[:6]}")
46
+ raw_user_prompt: str = ""
47
+ decomposed_goal: str = ""
48
+ domain: str = ""
49
+ doc_constraints: List[str] = Field(default_factory=list)
50
+
51
+
52
+ class PlanStep(BaseStateModel):
53
+ """
54
+ Универсальный шаг плана.
55
+
56
+ Поля, связанные с cross-file координацией, добавлены для борьбы с
57
+ рассинхронизацией HTML/CSS/JS и больших файлов:
58
+ - estimated_lines: сколько строк кода примерно выдаст этот шаг
59
+ - provides: список новых идентификаторов (классов, функций, файлов)
60
+ - consumes: список идентификаторов из предыдущих шагов
61
+ - part: маркер части (например, "2/3") если шаг разбит
62
+ - depends_on: id шагов, которые должны завершиться раньше
63
+ """
64
+ id: Union[int, str] = Field(default=1)
65
+ title: str
66
+ description: str = ""
67
+ target_file: Optional[str] = None
68
+ assign_to: str = "editor"
69
+ status: Literal["pending", "in_progress", "completed", "failed"] = "pending"
70
+ estimated_lines: int = 0
71
+ provides: List[str] = Field(default_factory=list)
72
+ consumes: List[str] = Field(default_factory=list)
73
+ part: Optional[str] = None
74
+ depends_on: List[Union[int, str]] = Field(default_factory=list)
75
+
76
+ @field_validator("id", mode="before")
77
+ @classmethod
78
+ def coerce_id(cls, v: Any) -> Union[int, str]:
79
+ if v is None:
80
+ return 1
81
+ return v
82
+
83
+
84
+ class FeedbackLoop(BaseStateModel):
85
+ human_feedback: Optional[str] = None
86
+ reviewer_feedback: Optional[str] = None
87
+ last_error: Optional[str] = None
88
+
89
+
90
+ class Metrics(BaseStateModel):
91
+ total_tokens: int = 0
92
+ total_cost_usd: float = 0.0
93
+
94
+
95
+ class AppState(BaseStateModel):
96
+ schema_version: str = "2.0"
97
+ meta: MetaModel = Field(default_factory=MetaModel)
98
+ execution_control: ExecutionControl = Field(default_factory=ExecutionControl)
99
+ environment: Environment = Field(default_factory=Environment)
100
+ system_constraints: List[str] = Field(default_factory=list)
101
+ task_spec: TaskSpec = Field(default_factory=TaskSpec)
102
+ plan: List[PlanStep] = Field(default_factory=list)
103
+ feedback_loop: FeedbackLoop = Field(default_factory=FeedbackLoop)
104
+ metrics: Metrics = Field(default_factory=Metrics)
105
+ findings_and_context: Dict[str, Any] = Field(default_factory=dict)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Plan chunking
110
+ # ---------------------------------------------------------------------------
111
+ _DEFAULT_CHUNK_THRESHOLD = 200
112
+
113
+
114
+ def _chunk_threshold() -> int:
115
+ try:
116
+ import config
117
+ return int(getattr(config, "PLAN_CHUNK_THRESHOLD", _DEFAULT_CHUNK_THRESHOLD))
118
+ except Exception:
119
+ return _DEFAULT_CHUNK_THRESHOLD
120
+
121
+
122
+ def _maybe_chunk_step(step: PlanStep) -> List[PlanStep]:
123
+ """
124
+ Split a PlanStep into sequential parts if its estimated output exceeds
125
+ the chunk threshold. Each part is a separate PlanStep targeting the same
126
+ file, with a "part N/M" title marker.
127
+
128
+ No-op if estimated_lines is unset or below threshold.
129
+ """
130
+ threshold = _chunk_threshold()
131
+ if not step.estimated_lines or step.estimated_lines <= threshold:
132
+ return [step]
133
+
134
+ n_parts = math.ceil(step.estimated_lines / threshold)
135
+ parts: List[PlanStep] = []
136
+ for i in range(n_parts):
137
+ remaining = step.estimated_lines - i * threshold
138
+ chunk_lines = min(threshold, remaining)
139
+ part_title = f"{step.title} (part {i + 1}/{n_parts})"
140
+ p = PlanStep(
141
+ title=part_title,
142
+ description=step.description,
143
+ target_file=step.target_file,
144
+ assign_to=step.assign_to,
145
+ status=step.status,
146
+ estimated_lines=chunk_lines,
147
+ provides=list(step.provides),
148
+ consumes=list(step.consumes),
149
+ part=f"{i + 1}/{n_parts}",
150
+ depends_on=list(step.depends_on),
151
+ )
152
+ parts.append(p)
153
+
154
+ # Chain: part 2 depends on part 1, etc.
155
+ for i in range(1, len(parts)):
156
+ parts[i].depends_on.append(parts[i - 1].id if parts[i - 1].id else i)
157
+ return parts
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # State template
162
+ # ---------------------------------------------------------------------------
163
+ def build_state_template(
164
+ user_prompt: str = "",
165
+ target_hardware: str = "generic",
166
+ system_constraints: Optional[List[str]] = None,
167
+ ) -> AppState:
168
+ is_active = bool(user_prompt and user_prompt.strip())
169
+
170
+ return AppState(
171
+ meta=MetaModel(target_hardware=target_hardware),
172
+ execution_control=ExecutionControl(
173
+ status="planning" if is_active else "idle",
174
+ current_step="research" if is_active else "init",
175
+ active_agent="researcher",
176
+ ),
177
+ system_constraints=system_constraints or [],
178
+ task_spec=TaskSpec(raw_user_prompt=user_prompt),
179
+ )
180
+
181
+
182
+ def load_state(filepath: str) -> AppState:
183
+ if not os.path.exists(filepath):
184
+ return build_state_template()
185
+
186
+ try:
187
+ with open(filepath, "r", encoding="utf-8") as f:
188
+ raw_data = json.load(f)
189
+ return AppState.model_validate(raw_data)
190
+ except Exception as e:
191
+ print(f"[StateManager Warning] Failed to parse state.json ({e}).")
192
+
193
+ backup_path = f"{filepath}.corrupted_{int(datetime.now().timestamp())}"
194
+ try:
195
+ if os.path.exists(filepath):
196
+ os.rename(filepath, backup_path)
197
+ print(f"[StateManager] Backup of broken state saved to: {backup_path}")
198
+ except OSError as err:
199
+ print(f"[StateManager Warning] Could not rename broken state file: {err}")
200
+
201
+ print("[StateManager] Rebuilding clean template.")
202
+ return build_state_template()
203
+
204
+
205
+ def save_state(state: Union[AppState, dict], filepath: str) -> None:
206
+ if isinstance(state, dict):
207
+ if "execution_control" not in state or not isinstance(
208
+ state["execution_control"], dict
209
+ ):
210
+ state["execution_control"] = {}
211
+ if "task_spec" not in state or not isinstance(state["task_spec"], dict):
212
+ state["task_spec"] = {}
213
+
214
+ if "current_step" in state:
215
+ state["execution_control"]["current_step"] = state["current_step"]
216
+ if "status" in state:
217
+ state["execution_control"]["status"] = state["status"]
218
+ if "active_agent" in state:
219
+ state["execution_control"]["active_agent"] = state["active_agent"]
220
+ if "iteration_count" in state:
221
+ state["execution_control"]["iteration_count"] = state["iteration_count"]
222
+
223
+ if "task_prompt" in state:
224
+ state["task_spec"]["raw_user_prompt"] = state["task_prompt"]
225
+ if "goal" in state and not state["task_spec"].get("raw_user_prompt"):
226
+ state["task_spec"]["raw_user_prompt"] = state["goal"]
227
+
228
+ app_state = AppState.model_validate(state)
229
+ else:
230
+ app_state = state
231
+
232
+ target_dir = os.path.dirname(os.path.abspath(filepath))
233
+ os.makedirs(target_dir, exist_ok=True)
234
+
235
+ temp_file = None
236
+ try:
237
+ with tempfile.NamedTemporaryFile(
238
+ "w", dir=target_dir, delete=False, encoding="utf-8"
239
+ ) as tf:
240
+ tf.write(app_state.model_dump_json(indent=2))
241
+ temp_file = tf.name
242
+
243
+ os.replace(temp_file, filepath)
244
+ except Exception as e:
245
+ if temp_file and os.path.exists(temp_file):
246
+ os.remove(temp_file)
247
+ raise e
248
+
249
+
250
+ def extract_json_payload(raw_text: str) -> Union[dict, list]:
251
+ if not isinstance(raw_text, str):
252
+ return raw_text
253
+ match = re.search(r"```(?:json)?\s*([\[\{].*?[\]\}])\s*```", raw_text, re.DOTALL)
254
+ candidate = match.group(1) if match else raw_text.strip()
255
+ start_obj, start_arr = candidate.find("{"), candidate.find("[")
256
+ if start_arr != -1 and (start_obj == -1 or start_arr < start_obj):
257
+ end = candidate.rfind("]")
258
+ if end != -1:
259
+ candidate = candidate[start_arr:end + 1]
260
+ elif start_obj != -1:
261
+ end = candidate.rfind("}")
262
+ if end != -1:
263
+ candidate = candidate[start_obj:end + 1]
264
+ return json.loads(candidate)
265
+
266
+
267
+ def apply_researcher_plan(
268
+ current_state: AppState, researcher_data: Union[dict, list, str]
269
+ ) -> AppState:
270
+ """
271
+ Мерджит вывод LLM-Исследователя в AppState.
272
+
273
+ New in this version:
274
+ - Preserves estimated_lines / provides / consumes from the researcher
275
+ - Chunks steps whose estimated_lines exceed the threshold
276
+ - Preserves completed work by merging on completed-status boundary
277
+ """
278
+ raw_plan = None
279
+
280
+ if isinstance(researcher_data, str):
281
+ try:
282
+ researcher_data = extract_json_payload(researcher_data)
283
+ except Exception:
284
+ pass
285
+
286
+ if isinstance(researcher_data, list):
287
+ raw_plan = researcher_data
288
+ elif isinstance(researcher_data, dict):
289
+ if researcher_data.get("decomposed_goal"):
290
+ current_state.task_spec.decomposed_goal = str(
291
+ researcher_data["decomposed_goal"]
292
+ )
293
+ elif researcher_data.get("analysis") and not current_state.task_spec.decomposed_goal:
294
+ current_state.task_spec.decomposed_goal = str(researcher_data["analysis"])[:500]
295
+
296
+ if isinstance(researcher_data.get("findings_and_context"), dict):
297
+ current_state.findings_and_context = researcher_data["findings_and_context"]
298
+
299
+ for key in ("technical_plan", "plan", "steps", "raw_response"):
300
+ candidate = researcher_data.get(key)
301
+ if isinstance(candidate, list) and candidate:
302
+ raw_plan = candidate
303
+ break
304
+
305
+ if isinstance(raw_plan, list) and raw_plan:
306
+ default_target = (
307
+ current_state.environment.active_target_file
308
+ or (researcher_data.get("target_file") if isinstance(researcher_data, dict) else None)
309
+ )
310
+ valid_status = {"pending", "in_progress", "completed", "failed"}
311
+
312
+ completed = [s for s in current_state.plan if s.status == "completed"]
313
+ new_steps: List[PlanStep] = []
314
+
315
+ for item in raw_plan:
316
+ if isinstance(item, dict):
317
+ title = str(item.get("title") or item.get("description") or "").strip()
318
+ if not title:
319
+ continue
320
+
321
+ status = item.get("status", "pending")
322
+ try:
323
+ est_lines = int(item.get("estimated_lines", 0) or 0)
324
+ except (TypeError, ValueError):
325
+ est_lines = 0
326
+
327
+ provides = [str(p) for p in (item.get("provides") or []) if isinstance(p, (str, int))]
328
+ consumes = [str(p) for p in (item.get("consumes") or []) if isinstance(p, (str, int))]
329
+
330
+ step = PlanStep(
331
+ title=title,
332
+ description=str(item.get("description", "")),
333
+ target_file=item.get("target_file") or default_target,
334
+ assign_to=str(item.get("assign_to", "editor")),
335
+ status=status if status in valid_status else "pending",
336
+ estimated_lines=est_lines,
337
+ provides=provides,
338
+ consumes=consumes,
339
+ )
340
+ new_steps.extend(_maybe_chunk_step(step))
341
+
342
+ elif isinstance(item, str) and item.strip():
343
+ step = PlanStep(
344
+ title=item.strip(),
345
+ description=item.strip(),
346
+ target_file=default_target,
347
+ )
348
+ new_steps.append(step)
349
+
350
+ if new_steps:
351
+ merged = completed + new_steps
352
+ for idx, step in enumerate(merged, start=1):
353
+ step.id = idx
354
+ current_state.plan = merged
355
+
356
+ has_pending = any(s.status == "pending" for s in current_state.plan)
357
+ if has_pending:
358
+ current_state.execution_control.status = "in_progress"
359
+ current_state.execution_control.current_step = "edit"
360
+ current_state.execution_control.active_agent = "editor"
361
+ else:
362
+ current_state.execution_control.status = "planning"
363
+ current_state.execution_control.current_step = "research"
364
+ current_state.execution_control.active_agent = "researcher"
365
+
366
+ return current_state
367
+
368
+
369
+ __all__ = [
370
+ "AppState",
371
+ "ExecutionControl",
372
+ "Environment",
373
+ "FeedbackLoop",
374
+ "MetaModel",
375
+ "Metrics",
376
+ "PlanStep",
377
+ "TaskSpec",
378
+ "apply_researcher_plan",
379
+ "build_state_template",
380
+ "extract_json_payload",
381
+ "load_state",
382
+ "save_state",
383
+ ]