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/asset_manager.py ADDED
@@ -0,0 +1,115 @@
1
+ import os
2
+ import re
3
+ import httpx
4
+ from ipaddress import ip_address
5
+ from typing import Dict, List, Optional
6
+ from urllib.parse import urlparse
7
+
8
+ class AssetManager:
9
+ """Manages downloading and indexing web assets with security checks."""
10
+
11
+ EXTENSION_MAP = {
12
+ "image": [".png", ".jpg", ".jpeg", ".webp", ".svg"],
13
+ "shader": [".glsl", ".frag", ".vert"],
14
+ "html": [".html"],
15
+ "3d": [".glb", ".gltf"]
16
+ }
17
+
18
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
19
+
20
+ def __init__(self, project_dir: str):
21
+ self.project_dir = os.path.abspath(project_dir)
22
+ self.assets_dir = os.path.join(self.project_dir, ".differix", "assets")
23
+ os.makedirs(self.assets_dir, exist_ok=True)
24
+
25
+ def _validate_extension(self, filename: str, asset_type: str) -> bool:
26
+ """Check if file extension matches allowed types for the asset type."""
27
+ ext = os.path.splitext(filename)[1].lower()
28
+ return ext in self.EXTENSION_MAP.get(asset_type, [])
29
+
30
+ def _is_private_ip(self, host: str) -> bool:
31
+ """Check if host resolves to a private IP address."""
32
+ try:
33
+ ip = ip_address(host)
34
+ return ip.is_private or ip.is_loopback
35
+ except ValueError:
36
+ return False
37
+
38
+ def _validate_url(self, url: str) -> bool:
39
+ """Validate URL security - reject private IPs and invalid schemes."""
40
+ parsed = urlparse(url)
41
+ if parsed.scheme not in ["http", "https"]:
42
+ return False
43
+ if self._is_private_ip(parsed.hostname):
44
+ return False
45
+ return True
46
+
47
+ def download_asset(self, url: str, asset_type: str, filename: str) -> str:
48
+ """Download an asset with security checks and save to assets directory.
49
+
50
+ Args:
51
+ url: Source URL to download from (must pass SSRF checks)
52
+ asset_type: Type of asset (image/shader/html/3d)
53
+ filename: Target filename (must have correct extension for type)
54
+
55
+ Returns:
56
+ Absolute path to saved asset
57
+
58
+ Raises:
59
+ ValueError: For security violations, invalid types, or download failures
60
+ """
61
+ if not self._validate_url(url):
62
+ raise ValueError("Invalid or insecure URL")
63
+
64
+ if not self._validate_extension(filename, asset_type):
65
+ raise ValueError(f"Invalid extension for asset type '{asset_type}'")
66
+
67
+ # Sanitize filename to prevent path traversal
68
+ filename = os.path.basename(filename)
69
+ if not filename or filename.startswith('.'):
70
+ raise ValueError("Invalid filename")
71
+
72
+ target_dir = os.path.join(self.assets_dir, asset_type)
73
+ os.makedirs(target_dir, exist_ok=True)
74
+ target_path = os.path.join(target_dir, filename)
75
+
76
+ with httpx.Client(timeout=15.0, follow_redirects=True) as client:
77
+ try:
78
+ with client.stream("GET", url) as response:
79
+ # Recheck SSRF after following redirects
80
+ final_url = str(response.url)
81
+ parsed = urlparse(final_url)
82
+ if self._is_private_ip(parsed.hostname):
83
+ raise ValueError("Redirect leads to private IP - SSRF blocked")
84
+
85
+ if response.status_code != 200:
86
+ raise ValueError(f"Failed to download asset: HTTP {response.status_code}")
87
+
88
+ # Stream download with size cap
89
+ total_bytes = 0
90
+ chunks = []
91
+ for chunk in response.iter_bytes():
92
+ total_bytes += len(chunk)
93
+ if total_bytes > self.MAX_FILE_SIZE:
94
+ raise ValueError(f"File exceeds {self.MAX_FILE_SIZE//(1024*1024)}MB limit")
95
+ chunks.append(chunk)
96
+
97
+ content = b''.join(chunks)
98
+
99
+ # Verify content type matches asset type
100
+ content_type = response.headers.get('content-type', '')
101
+ if asset_type == 'image' and not content_type.startswith('image/'):
102
+ raise ValueError(f"Expected image content type, got {content_type}")
103
+ elif asset_type == '3d' and not any(x in content_type for x in ['model/gltf', 'model/gltf-binary']):
104
+ raise ValueError(f"Expected 3D model content type, got {content_type}")
105
+
106
+ # Write file atomically
107
+ temp_path = target_path + '.tmp'
108
+ with open(temp_path, 'wb') as f:
109
+ f.write(content)
110
+ os.replace(temp_path, target_path)
111
+
112
+ except httpx.RequestError as e:
113
+ raise ValueError(f"Download failed: {str(e)}")
114
+
115
+ return os.path.abspath(target_path)
@@ -0,0 +1,264 @@
1
+ """
2
+ tools/atomic_changeset.py
3
+ --------------------------
4
+ Atomic multi-file change sets for the VegeX editor pipeline.
5
+
6
+ Root problem this solves: the RSI crash you hit was caused by multi-file
7
+ edits applied non-atomically — file #1 written, file #3 fails sandbox
8
+ validation, repo left in a half-edited state. The editor then re-reads the
9
+ half-applied tree and hallucinates a fix.
10
+
11
+ This module makes N-file edits behave as either fully-applied or
12
+ fully-rolled-back. On POSIX we can't rename N files in a single syscall,
13
+ but we can:
14
+ 1. Stage all content to .vegex/staging/<uuid>/
15
+ 2. Snapshot backups of every file to be touched
16
+ 3. Apply replacements one by one
17
+ 4. On any failure, restore from the backups
18
+
19
+ That gives "best-effort atomicity" — the window between rename #1 and
20
+ rename #N is microseconds, no I/O, no LLM calls. Not bulletproof against
21
+ SIGKILL at the exact wrong moment, but eliminates the failure mode that
22
+ actually hurt.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import errno
28
+ import logging
29
+ import os
30
+ import shutil
31
+ import tempfile
32
+ import time
33
+ import uuid
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from typing import Dict, Iterable, List, Optional
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class ChangeSetError(Exception):
42
+ """Raised when a change set cannot be applied."""
43
+
44
+
45
+ @dataclass
46
+ class ChangeOp:
47
+ """A single file operation inside a ChangeSet."""
48
+
49
+ kind: str # "write" | "delete"
50
+ path: str # relative to project_root, forward slashes
51
+ content: Optional[str] = None # required for kind="write"
52
+
53
+ def __post_init__(self) -> None:
54
+ if self.kind not in ("write", "delete"):
55
+ raise ValueError(f"Invalid kind: {self.kind!r}")
56
+ if self.kind == "write" and self.content is None:
57
+ raise ValueError(f"ChangeOp write requires content (path={self.path!r})")
58
+ if not self.path or self.path.startswith("/") or ".." in Path(self.path).parts:
59
+ raise ValueError(f"Path must be project-relative, no traversal: {self.path!r}")
60
+
61
+
62
+ @dataclass
63
+ class ApplyReport:
64
+ ok: bool = False
65
+ applied: List[str] = field(default_factory=list)
66
+ rolled_back: List[str] = field(default_factory=list)
67
+ error: Optional[str] = None
68
+ elapsed_ms: float = 0.0
69
+
70
+ def as_dict(self) -> Dict:
71
+ return {
72
+ "ok": self.ok,
73
+ "applied": list(self.applied),
74
+ "rolled_back": list(self.rolled_back),
75
+ "error": self.error,
76
+ "elapsed_ms": round(self.elapsed_ms, 3),
77
+ }
78
+
79
+
80
+ class ChangeSet:
81
+ """A list of file operations applied atomically (best-effort)."""
82
+
83
+ def __init__(self, ops: Optional[Iterable[ChangeOp]] = None) -> None:
84
+ self.ops: List[ChangeOp] = list(ops or [])
85
+
86
+ def add_write(self, path: str, content: str) -> "ChangeSet":
87
+ self.ops.append(ChangeOp(kind="write", path=path, content=content))
88
+ return self
89
+
90
+ def add_delete(self, path: str) -> "ChangeSet":
91
+ self.ops.append(ChangeOp(kind="delete", path=path))
92
+ return self
93
+
94
+ def __bool__(self) -> bool:
95
+ return bool(self.ops)
96
+
97
+ def __len__(self) -> int:
98
+ return len(self.ops)
99
+
100
+ # ------------------------------------------------------------------ #
101
+ # Pre-flight
102
+ # ------------------------------------------------------------------ #
103
+ def validate(self, project_root: str) -> None:
104
+ """
105
+ Raise ChangeSetError if any op violates invariants:
106
+ - path escapes project_root
107
+ - duplicate path within the set
108
+ - write content exceeds size cap (2 MB default)
109
+ """
110
+ root = Path(project_root).resolve()
111
+ seen: set = set()
112
+ for op in self.ops:
113
+ target = (root / op.path).resolve()
114
+ try:
115
+ target.relative_to(root)
116
+ except ValueError:
117
+ raise ChangeSetError(
118
+ f"Path {op.path!r} escapes project root {root}"
119
+ )
120
+ if op.path in seen:
121
+ raise ChangeSetError(f"Duplicate path in change set: {op.path!r}")
122
+ seen.add(op.path)
123
+ if op.kind == "write" and op.content is not None and len(op.content) > 2_000_000:
124
+ raise ChangeSetError(
125
+ f"Write content for {op.path!r} exceeds 2MB cap"
126
+ )
127
+
128
+ # ------------------------------------------------------------------ #
129
+ # Dry run — returns what the tree would look like post-apply
130
+ # ------------------------------------------------------------------ #
131
+ def dry_apply(self, project_root: str) -> Dict[str, str]:
132
+ """
133
+ Return {path: hypothetical_content} for every file the set touches.
134
+ Used by the contract validator to check the *intended* post-apply
135
+ state without writing anything to disk.
136
+ """
137
+ root = Path(project_root).resolve()
138
+ result: Dict[str, str] = {}
139
+ for op in self.ops:
140
+ if op.kind == "write":
141
+ result[op.path] = op.content or ""
142
+ else: # delete
143
+ result[op.path] = "" # validators treat empty file as no-op
144
+ return result
145
+
146
+ # ------------------------------------------------------------------ #
147
+ # Apply
148
+ # ------------------------------------------------------------------ #
149
+ def apply(self, project_root: str) -> ApplyReport:
150
+ """Apply all ops. On any failure, rollback previously applied ops."""
151
+ t0 = time.perf_counter()
152
+ report = ApplyReport()
153
+
154
+ try:
155
+ self.validate(project_root)
156
+ except ChangeSetError as exc:
157
+ report.error = f"Pre-flight failed: {exc}"
158
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
159
+ return report
160
+
161
+ if not self.ops:
162
+ report.ok = True
163
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
164
+ return report
165
+
166
+ root = Path(project_root).resolve()
167
+ staging_dir = root / ".vegex" / "staging" / uuid.uuid4().hex
168
+ staging_dir.mkdir(parents=True, exist_ok=True)
169
+
170
+ # back up existing content for every target path
171
+ backups: Dict[str, Optional[str]] = {}
172
+ try:
173
+ for op in self.ops:
174
+ target = root / op.path
175
+ if target.exists() and target.is_file():
176
+ try:
177
+ backups[op.path] = target.read_text(encoding="utf-8")
178
+ except Exception as exc:
179
+ raise ChangeSetError(
180
+ f"Cannot read existing {op.path!r} for backup: {exc}"
181
+ )
182
+ else:
183
+ backups[op.path] = None
184
+ except ChangeSetError as exc:
185
+ report.error = str(exc)
186
+ _safe_rmtree(staging_dir)
187
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
188
+ return report
189
+
190
+ # stage all new content to disk first
191
+ staged_paths: Dict[str, Path] = {}
192
+ try:
193
+ for op in self.ops:
194
+ if op.kind != "write":
195
+ continue
196
+ stage_target = staging_dir / op.path
197
+ stage_target.parent.mkdir(parents=True, exist_ok=True)
198
+ stage_target.write_text(op.content or "", encoding="utf-8")
199
+ staged_paths[op.path] = stage_target
200
+ except Exception as exc:
201
+ report.error = f"Staging failed: {exc}"
202
+ _safe_rmtree(staging_dir)
203
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
204
+ return report
205
+
206
+ # now do the renames in rapid succession
207
+ applied: List[str] = []
208
+ try:
209
+ for op in self.ops:
210
+ target = root / op.path
211
+ if op.kind == "write":
212
+ target.parent.mkdir(parents=True, exist_ok=True)
213
+ os.replace(staged_paths[op.path], target)
214
+ applied.append(op.path)
215
+ else: # delete
216
+ try:
217
+ target.unlink()
218
+ except FileNotFoundError:
219
+ pass
220
+ applied.append(op.path)
221
+ except Exception as exc:
222
+ # Roll back every file we've already touched
223
+ rolled_back: List[str] = []
224
+ for path in applied:
225
+ backup = backups.get(path)
226
+ try:
227
+ target = root / path
228
+ if backup is None:
229
+ # didn't exist before → delete what we wrote
230
+ try:
231
+ target.unlink()
232
+ except FileNotFoundError:
233
+ pass
234
+ else:
235
+ target.parent.mkdir(parents=True, exist_ok=True)
236
+ target.write_text(backup, encoding="utf-8")
237
+ rolled_back.append(path)
238
+ except Exception as rb_exc:
239
+ logger.error("Rollback failed for %s: %s", path, rb_exc)
240
+
241
+ report.ok = False
242
+ report.applied = applied
243
+ report.rolled_back = rolled_back
244
+ report.error = f"Apply failed after {len(applied)} ops: {exc}"
245
+ _safe_rmtree(staging_dir)
246
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
247
+ return report
248
+
249
+ _safe_rmtree(staging_dir)
250
+ report.ok = True
251
+ report.applied = applied
252
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
253
+ return report
254
+
255
+
256
+ def _safe_rmtree(path: Path) -> None:
257
+ try:
258
+ if path.exists():
259
+ shutil.rmtree(path, ignore_errors=True)
260
+ except Exception:
261
+ pass
262
+
263
+
264
+ __all__ = ["ChangeOp", "ChangeSet", "ChangeSetError", "ApplyReport"]
@@ -0,0 +1,122 @@
1
+ import git
2
+ import os
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ REPO_PATH = '.'
7
+ IGNORE_PATTERNS = [
8
+ '.env',
9
+ '*.log',
10
+ 'pycache/',
11
+ '*.tmp',
12
+ ]
13
+
14
+ def get_repo(path_or_repo=None):
15
+ """Repo initialisation with path safety check."""
16
+ target = path_or_repo or REPO_PATH
17
+ if isinstance(target, git.Repo):
18
+ return target
19
+ try:
20
+ return git.Repo(target)
21
+ except git.exc.InvalidGitRepositoryError:
22
+ print(f"Error: path '{target}' is not a valid git repository.")
23
+ print("Run 'git init' inside target project folder.")
24
+ return None
25
+
26
+ def should_ignore(file_path):
27
+ """Checking whether file is ignored"""
28
+ file_name = os.path.basename(file_path)
29
+ for pattern in IGNORE_PATTERNS:
30
+ if pattern.startswith('*'):
31
+ if file_name.endswith(pattern[1:]):
32
+ return True
33
+ elif pattern.endswith('/'):
34
+ if pattern[:-1] in file_path:
35
+ return True
36
+ else:
37
+ if file_name == pattern:
38
+ return True
39
+ return False
40
+
41
+ def make_auto_commit(repo_or_path, agent_name="system", comment=""):
42
+ """Creating commits with file filtration and error processing:"""
43
+ repo = get_repo(repo_or_path)
44
+ if not repo:
45
+ return
46
+ try:
47
+ changed_files = [item.a_path for item in repo.index.diff(None)]
48
+ untracked_files = repo.untracked_files
49
+ all_candidates = changed_files + untracked_files
50
+
51
+ files_to_add = [f for f in all_candidates if not should_ignore(f)]
52
+
53
+ if not files_to_add:
54
+ if not repo.is_dirty() and not untracked_files:
55
+ print("No updates")
56
+ return
57
+ else:
58
+ print("All updated files are ignored.")
59
+ return
60
+
61
+ repo.git.add(files_to_add)
62
+
63
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
64
+ message = f"Auto-save [{agent_name}]: {timestamp}"
65
+ if comment:
66
+ message += f" — {comment[:80]}"
67
+
68
+ try:
69
+ commit = repo.index.commit(message)
70
+ print(f"Commit created: {message} (SHA: {commit.hexsha[:7]})")
71
+ except git.exc.GitCommandError as e:
72
+ print(f"Commit error: {e}")
73
+ return
74
+
75
+ except Exception as e:
76
+ print(f"Fatal error while auto-saving: {e}")
77
+
78
+ def safe_rollback(repo_or_path, target_hash):
79
+ """Safe rollback to target commit."""
80
+ repo = get_repo(repo_or_path)
81
+ if not repo:
82
+ return
83
+ branch_name = f"rollback-to-{target_hash[:7]}"
84
+ try:
85
+ commit = repo.commit(target_hash)
86
+ new_branch = repo.create_head(branch_name, commit)
87
+ repo.heads[branch_name].checkout()
88
+ print(f"Rollbacked successfully: '{branch_name}'.")
89
+ except git.exc.GitError as e:
90
+ print(f"Git error occurred: {e}")
91
+
92
+ def show_all_commits(repo_or_path, limit=15):
93
+ """Display git commit list."""
94
+ repo = get_repo(repo_or_path)
95
+ if not repo:
96
+ return []
97
+ try:
98
+ commits = list(repo.iter_commits())
99
+ if limit is not None:
100
+ commits = commits[:limit]
101
+
102
+ print(f"\n--- Git Commits ({len(commits)}) ---")
103
+ for i, c in enumerate(commits):
104
+ short = c.hexsha[:7]
105
+ msg = c.message.strip().split('\n')[0][:70]
106
+ date = c.committed_datetime.strftime("%Y-%m-%d %H:%M")
107
+ print(f"{i:3}. {short} | {date} | {msg}")
108
+ return commits
109
+ except Exception as e:
110
+ print(f"Error reading commits: {e}")
111
+ return []
112
+
113
+ def create_success_tag(repo_or_path):
114
+ repo = get_repo(repo_or_path)
115
+ if not repo:
116
+ return
117
+ try:
118
+ tag_name = f"success-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
119
+ repo.create_tag(tag_name)
120
+ print(f"Created success tag: {tag_name}")
121
+ except Exception as e:
122
+ print(f"Cannot create tag: {e}")