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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
tools/plugin_manager.py
ADDED
|
@@ -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
|
+
]
|
tools/project_scanner.py
ADDED
|
@@ -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)
|