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
@@ -0,0 +1,204 @@
1
+ # tools/plugin_manager.py
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import zipfile
6
+
7
+ try:
8
+ import yaml
9
+ HAS_YAML = True
10
+ except ImportError:
11
+ yaml = None
12
+ HAS_YAML = False
13
+
14
+ PLUGIN_EXTENSION = ".differixplugin"
15
+ MANIFEST_FILENAME = "plugin.yaml"
16
+
17
+ ALLOWED_EXTENSIONS = {".md", ".json", ".yaml", ".css", ".html", ".glsl", ".txt"}
18
+
19
+ REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "author", "description", "skills", "lessons")
20
+
21
+
22
+ class PluginManager:
23
+ def __init__(self, project_dir: str):
24
+ self.project_dir = os.path.abspath(project_dir)
25
+ self.plugins_root = os.path.join(self.project_dir, ".differix", "plugins")
26
+
27
+ # ------------------------------------------------------------------ #
28
+ # Install
29
+ # ------------------------------------------------------------------ #
30
+ def install(self, plugin_path: str) -> tuple:
31
+ """Install a .differixplugin archive. Returns (True, "Success") or (False, reason)."""
32
+ plugin_path = os.path.abspath(plugin_path)
33
+ if not os.path.isfile(plugin_path):
34
+ return False, f"Plugin file not found: {plugin_path}"
35
+ if not plugin_path.endswith(PLUGIN_EXTENSION):
36
+ return False, f"Invalid plugin file extension. Expected '{PLUGIN_EXTENSION}'"
37
+ if not HAS_YAML:
38
+ return False, "PyYAML is required to install plugins (pip install pyyaml)"
39
+
40
+ temp_dir = None
41
+ try:
42
+ temp_dir = tempfile.mkdtemp(prefix="differix_plugin_")
43
+ ok, reason = self._extract_archive(plugin_path, temp_dir)
44
+ if not ok:
45
+ return False, reason
46
+
47
+ manifest_path = self._locate_manifest(temp_dir)
48
+ if manifest_path is None:
49
+ return False, f"Plugin manifest '{MANIFEST_FILENAME}' not found in archive"
50
+
51
+ ok, reason = self._scan_extensions(temp_dir)
52
+ if not ok:
53
+ return False, reason
54
+
55
+ ok, reason, manifest = self._parse_manifest(manifest_path)
56
+ if not ok:
57
+ return False, reason
58
+
59
+ plugin_id = str(manifest["id"])
60
+ scan_root = os.path.dirname(manifest_path)
61
+
62
+ # Move valid plugin into plugins root
63
+ dest = os.path.join(self.plugins_root, plugin_id)
64
+ if os.path.exists(dest):
65
+ return False, f"Plugin '{plugin_id}' is already installed. Remove it first."
66
+ os.makedirs(self.plugins_root, exist_ok=True)
67
+ shutil.move(scan_root, dest)
68
+ return True, "Success"
69
+ except Exception as e:
70
+ return False, f"Install failed: {e}"
71
+ finally:
72
+ if temp_dir is not None and os.path.isdir(temp_dir):
73
+ shutil.rmtree(temp_dir, ignore_errors=True)
74
+
75
+ # ------------------------------------------------------------------ #
76
+ # Helpers: extraction, scanning, manifest parsing
77
+ # ------------------------------------------------------------------ #
78
+ def _extract_archive(self, plugin_path: str, temp_dir: str) -> tuple:
79
+ """Safely extract a plugin ZIP into temp_dir, guarding against path traversal."""
80
+ try:
81
+ with zipfile.ZipFile(plugin_path, "r") as zf:
82
+ for name in zf.namelist():
83
+ if name.startswith("/") or ".." in name.replace("\\", "/").split("/"):
84
+ return False, f"Security check failed: unsafe path in archive: {name}"
85
+ zf.extractall(temp_dir)
86
+ except zipfile.BadZipFile:
87
+ return False, "Invalid plugin archive: not a valid ZIP file"
88
+ return True, ""
89
+
90
+ def _locate_manifest(self, temp_dir: str):
91
+ """Find plugin.yaml, supporting archives with or without a top-level folder."""
92
+ candidate = os.path.join(temp_dir, MANIFEST_FILENAME)
93
+ if os.path.isfile(candidate):
94
+ return candidate
95
+ for root, dirs, files in os.walk(temp_dir):
96
+ if MANIFEST_FILENAME in files:
97
+ return os.path.join(root, MANIFEST_FILENAME)
98
+ return None
99
+
100
+ def _scan_extensions(self, temp_dir: str) -> tuple:
101
+ """Reject any file whose extension is not in the allowlist."""
102
+ for root, dirs, files in os.walk(temp_dir):
103
+ for fname in files:
104
+ ext = os.path.splitext(fname)[1].lower()
105
+ if ext not in ALLOWED_EXTENSIONS:
106
+ return False, (
107
+ f"Security check failed: executable or disallowed file "
108
+ f"type '{ext or fname}' found: {fname}. Allowed: "
109
+ f"{', '.join(sorted(ALLOWED_EXTENSIONS))}"
110
+ )
111
+ return True, ""
112
+
113
+ def _parse_manifest(self, manifest_path: str) -> tuple:
114
+ """Parse and validate plugin.yaml. Returns (ok, reason, manifest)."""
115
+ try:
116
+ with open(manifest_path, "r", encoding="utf-8") as f:
117
+ manifest = yaml.safe_load(f)
118
+ except Exception as e:
119
+ return False, f"Failed to parse {MANIFEST_FILENAME}: {e}", None
120
+ if not isinstance(manifest, dict):
121
+ return False, f"Invalid {MANIFEST_FILENAME}: expected a mapping at top level", None
122
+
123
+ missing = [f for f in REQUIRED_MANIFEST_FIELDS if f not in manifest or manifest[f] in (None, "")]
124
+ if missing:
125
+ return False, f"Invalid {MANIFEST_FILENAME}: missing required fields: {', '.join(missing)}", None
126
+
127
+ plugin_id = str(manifest["id"])
128
+ if not plugin_id.replace("_", "").replace("-", "").isalnum():
129
+ return False, f"Invalid plugin id: {plugin_id!r} (letters, digits, '-', '_' only)", None
130
+ for key in ("skills", "lessons"):
131
+ if not isinstance(manifest[key], list):
132
+ return False, f"Invalid {MANIFEST_FILENAME}: field '{key}' must be a list", None
133
+ return True, "", manifest
134
+
135
+ # ------------------------------------------------------------------ #
136
+ # List / Remove
137
+ # ------------------------------------------------------------------ #
138
+ def list_plugins(self) -> list:
139
+ """Return the manifests of all installed plugins."""
140
+ plugins = []
141
+ if not os.path.isdir(self.plugins_root):
142
+ return plugins
143
+ for entry in sorted(os.listdir(self.plugins_root)):
144
+ manifest_path = os.path.join(self.plugins_root, entry, MANIFEST_FILENAME)
145
+ if not os.path.isfile(manifest_path):
146
+ continue
147
+ ok, _reason, manifest = self._parse_manifest(manifest_path)
148
+ if ok and manifest:
149
+ plugins.append(manifest)
150
+ return plugins
151
+
152
+ def _plugin_dir(self, plugin_id: str):
153
+ """Return the install directory for a plugin id, or None if the id is unsafe."""
154
+ if not plugin_id or not plugin_id.replace("_", "").replace("-", "").isalnum():
155
+ return None
156
+ return os.path.join(self.plugins_root, plugin_id)
157
+
158
+ def remove(self, plugin_id: str) -> bool:
159
+ """Delete an installed plugin directory. Returns True on success."""
160
+ plugin_dir = self._plugin_dir(plugin_id)
161
+ if plugin_dir is None:
162
+ return False
163
+ if not os.path.isdir(plugin_dir):
164
+ return False
165
+ shutil.rmtree(plugin_dir, ignore_errors=True)
166
+ return os.path.isdir(plugin_dir) is False
167
+
168
+ def is_installed(self, plugin_id: str) -> bool:
169
+ """Return True if a plugin with the given id is installed."""
170
+ plugin_dir = self._plugin_dir(plugin_id)
171
+ return plugin_dir is not None and os.path.isdir(plugin_dir)
172
+
173
+ def get_plugin_path(self, plugin_id: str):
174
+ """Return the install directory of a plugin, or None if not installed."""
175
+ plugin_dir = self._plugin_dir(plugin_id)
176
+ if plugin_dir is not None and os.path.isdir(plugin_dir):
177
+ return plugin_dir
178
+ return None
179
+
180
+ # ------------------------------------------------------------------ #
181
+ # Canonical helper aliases (final part 1/2)
182
+ # ------------------------------------------------------------------ #
183
+ def _safe_extract(self, plugin_path: str, temp_dir: str) -> tuple:
184
+ """Traversal-safe extraction of a plugin archive into temp_dir."""
185
+ return self._extract_archive(plugin_path, temp_dir)
186
+
187
+ def _validate_manifest(self, manifest_path: str) -> tuple:
188
+ """Parse and validate a manifest file. Returns (ok, reason, manifest)."""
189
+ return self._parse_manifest(manifest_path)
190
+
191
+ # ------------------------------------------------------------------ #
192
+ # Canonical helper aliases (final part 2/2)
193
+ # ------------------------------------------------------------------ #
194
+ def _extract_safely(self, plugin_path: str, temp_dir: str) -> tuple:
195
+ """Alias for traversal-safe archive extraction."""
196
+ return self._extract_archive(plugin_path, temp_dir)
197
+
198
+ def _scan_for_executables(self, scan_dir: str) -> tuple:
199
+ """Alias for the extension allowlist security scan."""
200
+ return self._scan_extensions(scan_dir)
201
+
202
+ def _parse_plugin_manifest(self, manifest_path: str) -> tuple:
203
+ """Alias for manifest parsing and validation."""
204
+ return self._parse_manifest(manifest_path)
@@ -0,0 +1,404 @@
1
+ """
2
+ tools/project_context_gatherer.py
3
+ ----------------------------------
4
+ Assembles "related file" context for the editor.
5
+
6
+ When the editor is asked to write styles.css, it needs to know what classes
7
+ exist in index.html. When it's asked to write script.js, it needs to know
8
+ what selectors and DOM hooks exist in both. Without this context, the editor
9
+ invents its own naming and the resulting files silently diverge.
10
+
11
+ To save prompt tokens (which are expensive), we aggressively truncate files.
12
+ We only extract the top lines (imports, signatures, class definitions)
13
+ because that's all that's needed to understand the interface.
14
+
15
+ Public API:
16
+ - gather_related_context(project_root, target_file, plan, budget_tokens=2000)
17
+ -> RelatedContext
18
+ - render_related_context(ctx) -> str
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Dict, List, Optional, Set
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Relationship hints: target extension -> set of extensions to consider related.
33
+ _RELATED_BY_EXT: Dict[str, Set[str]] = {
34
+ ".html": {".css", ".scss", ".js", ".mjs", ".ts", ".tsx"},
35
+ ".htm": {".css", ".scss", ".js", ".mjs", ".ts", ".tsx"},
36
+ ".css": {".html", ".htm"},
37
+ ".scss": {".html", ".htm"},
38
+ ".js": {".html", ".htm", ".css", ".scss", ".jsx", ".tsx"},
39
+ ".mjs": {".html", ".htm", ".css", ".scss"},
40
+ ".ts": {".html", ".htm", ".css", ".scss", ".tsx"},
41
+ ".tsx": {".html", ".htm", ".css", ".scss", ".ts"},
42
+ ".py": {".py"},
43
+ ".cs": {".cs", ".csproj"},
44
+ }
45
+
46
+ # Files we never include as related context.
47
+ _BLOCKED_BASENAMES = {
48
+ "state.json", "projects_registry.json", "skills_config.json",
49
+ "active_models.json", "models_cache.json", "package-lock.json",
50
+ "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "Pipfile.lock",
51
+ ".env", ".env.local", ".env.production",
52
+ "main.py", # Huge file (2000+ lines). Don't feed it to the agent as context.
53
+ }
54
+
55
+ # Directories we never recurse into for related context.
56
+ _BLOCKED_DIRS = {
57
+ ".git", ".vegex", "node_modules", "__pycache__", ".venv", "venv",
58
+ "dist", "build", ".pytest_cache", ".mypy_cache", "iteration_logs",
59
+ ".next", ".nuxt", ".cache", "coverage",
60
+ }
61
+
62
+ # How much of each related file to include. We cap total at the budget.
63
+ _MAX_RELATED_FILES = 4
64
+ _MAX_LINES_PER_FILE = 120
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Data structures
69
+ # ---------------------------------------------------------------------------
70
+ @dataclass
71
+ class RelatedFile:
72
+ rel_path: str
73
+ content: str # possibly truncated
74
+ original_length: int
75
+ truncated: bool
76
+ reason: str # "explicit-plan" | "same-dir" | "extension-match" | "import"
77
+
78
+
79
+ @dataclass
80
+ class RelatedContext:
81
+ target_file: str
82
+ files: List[RelatedFile] = field(default_factory=list)
83
+ total_chars: int = 0
84
+ notes: List[str] = field(default_factory=list)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # File scoring / selection
89
+ # ---------------------------------------------------------------------------
90
+ def _is_blocked(rel_path: str) -> bool:
91
+ base = os.path.basename(rel_path)
92
+ if base in _BLOCKED_BASENAMES:
93
+ return True
94
+ parts = rel_path.replace("\\", "/").split("/")
95
+ return any(p in _BLOCKED_DIRS or p.startswith(".") for p in parts[:-1])
96
+
97
+
98
+ def _read_file(path: str, max_bytes: int = 200_000) -> Optional[str]:
99
+ try:
100
+ size = os.path.getsize(path)
101
+ if size > max_bytes:
102
+ return None
103
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
104
+ return f.read()
105
+ except OSError:
106
+ return None
107
+
108
+
109
+ def _truncate_content(content: str, char_budget: int, rel_path: str) -> tuple:
110
+ """
111
+ Truncate by lines. We only need the top of the file (imports,
112
+ class defs, signatures) to understand the structure.
113
+ """
114
+ lines = content.splitlines()
115
+
116
+ if len(lines) > _MAX_LINES_PER_FILE:
117
+ head = "\n".join(lines[:_MAX_LINES_PER_FILE])
118
+ omitted = len(lines) - _MAX_LINES_PER_FILE
119
+ return head + f"\n\n# ... [{omitted} lines omitted for brevity] ...", True
120
+
121
+ # If the file is small in lines but huge in chars (e.g. minified JS/CSS)
122
+ if len(content) > char_budget:
123
+ return content[:char_budget] + "\n# ... [truncated by chars] ...", True
124
+
125
+ return content, False
126
+
127
+
128
+ def _score_related(
129
+ candidate_rel: str,
130
+ target_rel: str,
131
+ target_dir: str,
132
+ plan_targets: Set[str],
133
+ target_ext: str,
134
+ ) -> Optional[tuple]:
135
+ """
136
+ Return (score, reason) if the candidate is related, else None.
137
+ Higher score = more related.
138
+ """
139
+ if candidate_rel == target_rel:
140
+ return None
141
+ if _is_blocked(candidate_rel):
142
+ return None
143
+
144
+ cand_ext = os.path.splitext(candidate_rel)[1].lower()
145
+ cand_dir = os.path.dirname(candidate_rel)
146
+
147
+ related_exts = _RELATED_BY_EXT.get(target_ext, set())
148
+ if cand_ext not in related_exts:
149
+ return None
150
+
151
+ # Same basename, different extension: index.html ↔ index.css
152
+ target_base = os.path.splitext(os.path.basename(target_rel))[0]
153
+ cand_base = os.path.splitext(os.path.basename(candidate_rel))[0]
154
+
155
+ if candidate_rel in plan_targets:
156
+ return (100, "explicit-plan")
157
+ if cand_base == target_base:
158
+ return (80, "same-basename")
159
+ if cand_dir == target_dir:
160
+ return (50, "same-dir")
161
+ return (10, "extension-match")
162
+
163
+
164
+ def _plan_targets(plan: Optional[list]) -> Set[str]:
165
+ targets: Set[str] = set()
166
+ if not isinstance(plan, list):
167
+ return targets
168
+ for step in plan:
169
+ if isinstance(step, dict):
170
+ tf = step.get("target_file")
171
+ if isinstance(tf, str) and tf:
172
+ targets.add(tf.replace("\\", "/"))
173
+ return targets
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Discovery helpers
178
+ # ---------------------------------------------------------------------------
179
+ def _walk_candidates(project_root: str, max_files: int = 2000) -> List[str]:
180
+ """Return project-relative paths of potential related files, capped."""
181
+ out: List[str] = []
182
+ root = Path(project_root).resolve()
183
+ for dirpath, dirnames, filenames in os.walk(root):
184
+ dirnames[:] = [
185
+ d for d in dirnames
186
+ if d not in _BLOCKED_DIRS and not d.startswith(".")
187
+ ]
188
+ for fname in filenames:
189
+ lower = fname.lower()
190
+ if not lower.endswith((
191
+ ".html", ".htm", ".css", ".scss", ".js", ".mjs", ".ts",
192
+ ".tsx", ".jsx", ".py", ".cs",
193
+ )):
194
+ continue
195
+ rel = os.path.relpath(os.path.join(dirpath, fname), root).replace("\\", "/")
196
+ if _is_blocked(rel):
197
+ continue
198
+ out.append(rel)
199
+ if len(out) >= max_files:
200
+ return out
201
+ return out
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # HTML class inventory (for CSS editing)
205
+ # ---------------------------------------------------------------------------
206
+ _HTML_CLASS_ATTR_RE_DQ = re.compile(r'class\s*=\s*"([^"]*)"', re.IGNORECASE)
207
+ _HTML_CLASS_ATTR_RE_SQ = re.compile(r"class\s*=\s*'([^']*)'", re.IGNORECASE)
208
+ _VALID_CLASS_TOKEN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_-]*$")
209
+
210
+
211
+ def extract_html_class_inventory(
212
+ html: str,
213
+ rel_path: str = "html",
214
+ grouped_threshold: int = 2,
215
+ ) -> str:
216
+ """
217
+ Render only the class names from an HTML file, grouped by prefix.
218
+
219
+ When the editor is writing CSS, it does not need the full HTML body —
220
+ only the class names it must style. This renders a ~200-token inventory
221
+ instead of a ~2000-token HTML file.
222
+
223
+ Example output:
224
+ ### CLASS INVENTORY (from index.html) — for CSS coherence ###
225
+ Use these class names verbatim in your CSS rules. Do NOT rename them.
226
+
227
+ .hero-title, .hero-subtitle, .hero-actions
228
+ .feature-card, .feature-icon
229
+ Ungrouped: .container, .btn
230
+
231
+ [12 total classes in index.html]
232
+ """
233
+ if not html or not html.strip():
234
+ return ""
235
+
236
+ classes: Set[str] = set()
237
+ for pattern in (_HTML_CLASS_ATTR_RE_DQ, _HTML_CLASS_ATTR_RE_SQ):
238
+ for m in pattern.finditer(html):
239
+ for token in m.group(1).split():
240
+ token = token.strip()
241
+ if token and _VALID_CLASS_TOKEN.match(token):
242
+ classes.add(token)
243
+
244
+ if not classes:
245
+ return ""
246
+
247
+ groups: Dict[str, List[str]] = {}
248
+ for cls in classes:
249
+ prefix = cls.split("-", 1)[0] if "-" in cls else ""
250
+ groups.setdefault(prefix, []).append(cls)
251
+
252
+ grouped_rows: List[str] = []
253
+ ungrouped: List[str] = []
254
+ for prefix in sorted(groups):
255
+ members = sorted(groups[prefix])
256
+ if prefix and len(members) >= grouped_threshold:
257
+ grouped_rows.append(" " + ", ".join("." + m for m in members))
258
+ else:
259
+ ungrouped.extend(members)
260
+
261
+ lines = [f"### CLASS INVENTORY (from {rel_path}) — for CSS coherence ###"]
262
+ lines.append(
263
+ "Use these class names verbatim in your CSS rules. Do NOT rename them."
264
+ )
265
+ lines.append("")
266
+ if grouped_rows:
267
+ lines.extend(grouped_rows)
268
+ if ungrouped:
269
+ ungrouped.sort()
270
+ lines.append(" Ungrouped: " + ", ".join("." + u for u in ungrouped))
271
+ lines.append("")
272
+ lines.append(f"[{len(classes)} total classes in {rel_path}]")
273
+ return "\n".join(lines)
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Public entry point
278
+ # ---------------------------------------------------------------------------
279
+ def gather_related_context(
280
+ project_root: str,
281
+ target_file: str,
282
+ plan: Optional[list] = None,
283
+ budget_tokens: int = 2000, # Reduced from 3000
284
+ max_files: int = _MAX_RELATED_FILES,
285
+ ) -> RelatedContext:
286
+ """
287
+ Assemble related files for the editor.
288
+ """
289
+ target_rel = target_file.replace("\\", "/")
290
+ target_ext = os.path.splitext(target_rel)[1].lower()
291
+ target_dir = os.path.dirname(target_rel)
292
+
293
+ ctx = RelatedContext(target_file=target_rel)
294
+
295
+ if target_ext not in _RELATED_BY_EXT:
296
+ ctx.notes.append(f"no related-file rules for extension {target_ext!r}")
297
+ return ctx
298
+
299
+ plan_targets = _plan_targets(plan)
300
+ candidates = _walk_candidates(project_root)
301
+
302
+ scored: List[tuple] = []
303
+ for cand in candidates:
304
+ result = _score_related(cand, target_rel, target_dir, plan_targets, target_ext)
305
+ if result is not None:
306
+ score, reason = result
307
+ scored.append((score, cand, reason))
308
+
309
+ if not scored:
310
+ return ctx
311
+
312
+ scored.sort(key=lambda t: (-t[0], t[1]))
313
+
314
+ char_budget = int(budget_tokens * 4 * 0.9) # 4 chars/token, 10% safety
315
+ per_file_budget = max(500, char_budget // max(1, min(max_files, len(scored))))
316
+
317
+ total = 0
318
+ for score, cand, reason in scored:
319
+ if len(ctx.files) >= max_files:
320
+ break
321
+ if total >= char_budget:
322
+ break
323
+
324
+ abs_path = os.path.join(project_root, cand)
325
+ content = _read_file(abs_path)
326
+ if content is None:
327
+ continue
328
+ if not content.strip():
329
+ continue
330
+
331
+ remaining = char_budget - total
332
+ slice_budget = min(per_file_budget, remaining)
333
+ if slice_budget < 200:
334
+ break
335
+
336
+ # CSS editing needs only class names from HTML, not the HTML body.
337
+ # Emit a compact inventory (~200 tokens) instead of a truncated
338
+ # 120-line slice (~2000 tokens).
339
+ cand_ext = os.path.splitext(cand)[1].lower()
340
+ if target_ext in (".css", ".scss") and cand_ext in (".html", ".htm"):
341
+ inventory = extract_html_class_inventory(content, rel_path=cand)
342
+ if inventory:
343
+ ctx.files.append(RelatedFile(
344
+ rel_path=cand,
345
+ content=inventory,
346
+ original_length=len(content),
347
+ truncated=True,
348
+ reason=f"{reason} (class-inventory)",
349
+ ))
350
+ total += len(inventory)
351
+ continue
352
+ # Empty inventory — fall through to normal truncation.
353
+
354
+ truncated_content, was_truncated = _truncate_content(
355
+ content, slice_budget, cand
356
+ )
357
+
358
+ ctx.files.append(RelatedFile(
359
+ rel_path=cand,
360
+ content=truncated_content,
361
+ original_length=len(content),
362
+ truncated=was_truncated,
363
+ reason=reason,
364
+ ))
365
+ total += len(truncated_content)
366
+
367
+ ctx.total_chars = total
368
+ return ctx
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Rendering
373
+ # ---------------------------------------------------------------------------
374
+ def render_related_context(ctx: RelatedContext) -> str:
375
+ """
376
+ Render the RelatedContext for injection into the editor prompt.
377
+ Empty string if nothing was found.
378
+ """
379
+ if not ctx.files:
380
+ return ""
381
+
382
+ lines = ["### RELATED FILES (context for coherence) ###"]
383
+ lines.append(
384
+ "The following files share the same project. Ensure class names, "
385
+ "imports, and function names you introduce are consistent with them."
386
+ )
387
+ lines.append("")
388
+
389
+ for rf in ctx.files:
390
+ note = " (truncated)" if rf.truncated else ""
391
+ lines.append(f"--- {rf.rel_path} ({rf.reason}){note} ---")
392
+ lines.append(rf.content)
393
+ lines.append("")
394
+
395
+ return "\n".join(lines)
396
+
397
+
398
+ __all__ = [
399
+ "RelatedContext",
400
+ "RelatedFile",
401
+ "gather_related_context",
402
+ "render_related_context",
403
+ "extract_html_class_inventory",
404
+ ]
@@ -0,0 +1,41 @@
1
+ import os
2
+ import re
3
+ from typing import List, Tuple
4
+
5
+ def extract_plan_dependencies(plan_text: str) -> List[str]:
6
+ """
7
+ Извлекает пути к файлам, упомянутым в тексте исследования/плана.
8
+ Поддерживает пути в форматах Linux/macOS (/) и Windows (\\).
9
+ """
10
+ pattern = r'[\w\-\.\/\\]+\.(?:py|json|md|cs|xml|txt)'
11
+ matches = re.findall(pattern, plan_text)
12
+ # Нормализуем разделители и убираем дубликаты
13
+ normalized = [m.replace('\\', '/') for m in matches]
14
+ return list(dict.fromkeys(normalized))
15
+
16
+ def read_file_content(filepath: str, max_lines: int = 150) -> str:
17
+ """Безопасно читает содержимое файла с ограничением по количеству строк."""
18
+ if not os.path.exists(filepath):
19
+ return f"--- FILE: {filepath} (File not found) ---"
20
+ try:
21
+ with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
22
+ lines = f.readlines()
23
+ if len(lines) > max_lines:
24
+ content = "".join(lines[:max_lines]) + f"\n... [TRUNCATED: Total {len(lines)} lines]"
25
+ else:
26
+ content = "".join(lines)
27
+ return f"--- FILE: {filepath} ---\n{content}"
28
+ except Exception as e:
29
+ return f"--- FILE: {filepath} (Error reading: {e}) ---"
30
+
31
+ def get_full_project_context(
32
+ project_dir: str = ".",
33
+ core_files: Tuple[str, ...] = ("config.py", "tools/state_manager.py"),
34
+ max_lines_per_file: int = 150
35
+ ) -> str:
36
+ """Формирует контекст проекта на основе реально существующих базовых модулей."""
37
+ context_parts = []
38
+ for file_path in core_files:
39
+ full_path = os.path.join(project_dir, file_path)
40
+ context_parts.append(read_file_content(full_path, max_lines=max_lines_per_file))
41
+ return "\n\n".join(context_parts)