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
config.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Hardened secret / environment loading
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Only environment variable names matching this pattern may be set from a
|
|
10
|
+
# .env file. This blocks injection of arbitrary (or framework-critical)
|
|
11
|
+
# variables such as PYTHONPATH, LD_PRELOAD, or PATH via a malicious .env.
|
|
12
|
+
_ENV_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$")
|
|
13
|
+
|
|
14
|
+
# Values that must never be overridden from a .env file.
|
|
15
|
+
_PROTECTED_ENV_NAMES = frozenset({
|
|
16
|
+
"PATH", "PYTHONPATH", "PYTHONHOME", "LD_PRELOAD", "LD_LIBRARY_PATH",
|
|
17
|
+
"SHELL", "HOME", "USER", "IFS", "PS1",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
# Substrings that mark a value as a secret for redaction purposes.
|
|
21
|
+
_SECRET_HINTS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_secret_name(name: str) -> bool:
|
|
25
|
+
return any(hint in name.upper() for hint in _SECRET_HINTS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _redact(name: str, value: str) -> str:
|
|
29
|
+
"""Return a redacted representation of a secret value for logs/errors."""
|
|
30
|
+
if not _is_secret_name(name) or not value:
|
|
31
|
+
return "<unset>" if not value else value
|
|
32
|
+
if len(value) <= 8:
|
|
33
|
+
return "***"
|
|
34
|
+
return value[:4] + "***" + value[-4:]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def redact_secret(name: str, value: str) -> str:
|
|
38
|
+
"""Public redaction helper: safe to call on any name/value pair."""
|
|
39
|
+
return _redact(name, value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def redact_mapping(mapping: dict) -> dict:
|
|
43
|
+
"""
|
|
44
|
+
Return a copy of a mapping with all secret-looking values redacted.
|
|
45
|
+
|
|
46
|
+
Useful for logging environment snapshots, request payloads, or debug
|
|
47
|
+
dumps without leaking credentials.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(mapping, dict):
|
|
50
|
+
return {}
|
|
51
|
+
return {
|
|
52
|
+
str(k): _redact(str(k), v) if isinstance(v, str) else v
|
|
53
|
+
for k, v in mapping.items()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def safe_log(message: str, **kwargs) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Format a log message with redacted keyword arguments.
|
|
60
|
+
|
|
61
|
+
Any kwarg whose name looks secret-like (KEY/TOKEN/SECRET/PASSWORD/
|
|
62
|
+
CREDENTIAL) is redacted before interpolation. Returns the formatted
|
|
63
|
+
string; the caller decides how to emit it.
|
|
64
|
+
"""
|
|
65
|
+
safe_kwargs = redact_mapping(kwargs)
|
|
66
|
+
try:
|
|
67
|
+
return message.format(**safe_kwargs)
|
|
68
|
+
except (KeyError, IndexError, ValueError):
|
|
69
|
+
return message
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _safe_load_env_file(env_path: str) -> dict:
|
|
73
|
+
"""
|
|
74
|
+
Parse a .env file without eval/exec/shell expansion.
|
|
75
|
+
|
|
76
|
+
Rules:
|
|
77
|
+
- Only KEY=VALUE lines (and # comments) are accepted.
|
|
78
|
+
- KEY must match _ENV_NAME_RE and must not be protected.
|
|
79
|
+
- Values are taken literally; no interpolation, no backtick/$(...)
|
|
80
|
+
expansion, no escape interpretation beyond stripping matching
|
|
81
|
+
surrounding quotes.
|
|
82
|
+
- Malformed lines are skipped (never raise, never log the value).
|
|
83
|
+
"""
|
|
84
|
+
loaded = {}
|
|
85
|
+
path = Path(env_path)
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
return loaded
|
|
88
|
+
try:
|
|
89
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
90
|
+
except OSError:
|
|
91
|
+
return loaded
|
|
92
|
+
|
|
93
|
+
for lineno, raw_line in enumerate(text.splitlines(), start=1):
|
|
94
|
+
line = raw_line.strip()
|
|
95
|
+
if not line or line.startswith("#"):
|
|
96
|
+
continue
|
|
97
|
+
if "=" not in line:
|
|
98
|
+
continue
|
|
99
|
+
name, _, value = line.partition("=")
|
|
100
|
+
name = name.strip()
|
|
101
|
+
value = value.strip()
|
|
102
|
+
if not _ENV_NAME_RE.match(name) or name in _PROTECTED_ENV_NAMES:
|
|
103
|
+
continue
|
|
104
|
+
# Reject lines attempting shell-style expansion or command injection.
|
|
105
|
+
lowered = value.lower()
|
|
106
|
+
if any(tok in lowered for tok in ("$(", "`", "eval", "exec", "import ")):
|
|
107
|
+
continue
|
|
108
|
+
# Strip one pair of matching surrounding quotes.
|
|
109
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
110
|
+
value = value[1:-1]
|
|
111
|
+
if not value:
|
|
112
|
+
continue
|
|
113
|
+
loaded[name] = value
|
|
114
|
+
os.environ.setdefault(name, value)
|
|
115
|
+
return loaded
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Load .env from the module directory only (never cwd-relative, which could
|
|
119
|
+
# pick up an attacker-controlled file from a writable directory).
|
|
120
|
+
_ENV_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
|
121
|
+
_safe_load_env_file(_ENV_FILE)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_secret(name: str) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Fetch a secret from the environment with strict validation.
|
|
127
|
+
|
|
128
|
+
Returns an empty string when unset. Never raises with the raw value;
|
|
129
|
+
errors reference the redacted form only.
|
|
130
|
+
"""
|
|
131
|
+
value = os.environ.get(name, "")
|
|
132
|
+
if not value:
|
|
133
|
+
return ""
|
|
134
|
+
if len(value) > 512:
|
|
135
|
+
# Implausibly long secrets are rejected outright.
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Environment variable {name} has an invalid length "
|
|
138
|
+
f"(>512 chars); refusing to use it."
|
|
139
|
+
)
|
|
140
|
+
if any(ch in value for ch in ("\n", "\r", "\x00")):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Environment variable {name} contains control characters; "
|
|
143
|
+
f"refusing to use it."
|
|
144
|
+
)
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
OPENROUTER_API_KEY = _get_secret("OPENROUTER_API_KEY")
|
|
149
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Secret-bearing key registry and strict validation rules
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Each entry: kind of secret -> validation rule. Rules are applied by
|
|
155
|
+
# validate_secret() below. A secret that fails validation is never used.
|
|
156
|
+
_SECRET_KEY_RULES = {
|
|
157
|
+
"openrouter_api_key": {
|
|
158
|
+
"env_names": ("OPENROUTER_API_KEY",),
|
|
159
|
+
"min_length": 20,
|
|
160
|
+
"max_length": 512,
|
|
161
|
+
# OpenRouter keys are `sk-or-v1-<hex/base62>`.
|
|
162
|
+
"required_prefix": "sk-or-",
|
|
163
|
+
"charset_re": re.compile(r"^[A-Za-z0-9_\-]+$"),
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Flat registry: every env var name that holds a secret, mapped to its rule.
|
|
168
|
+
SECRET_BEARING_KEYS = {
|
|
169
|
+
env_name: rule
|
|
170
|
+
for rule in _SECRET_KEY_RULES.values()
|
|
171
|
+
for env_name in rule["env_names"]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def validate_secret(name: str, value: str) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Strictly validate a secret-bearing env var against its registered rule.
|
|
178
|
+
|
|
179
|
+
Returns the value when it conforms. Raises ValueError (message never
|
|
180
|
+
contains the raw value) when the value is malformed, in which case the
|
|
181
|
+
caller must treat the secret as unset/refuse to use it.
|
|
182
|
+
"""
|
|
183
|
+
rule = SECRET_BEARING_KEYS.get(name)
|
|
184
|
+
if rule is None:
|
|
185
|
+
# Unknown names have no strict rule; fall back to basic sanity only.
|
|
186
|
+
return value
|
|
187
|
+
if not value:
|
|
188
|
+
return value
|
|
189
|
+
if not (rule["min_length"] <= len(value) <= rule["max_length"]):
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Secret '{name}' failed validation: invalid length "
|
|
192
|
+
f"(expected {rule['min_length']}..{rule['max_length']})."
|
|
193
|
+
)
|
|
194
|
+
prefix = rule.get("required_prefix")
|
|
195
|
+
if prefix and not value.startswith(prefix):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"Secret '{name}' failed validation: unexpected shape "
|
|
198
|
+
f"(missing required prefix)."
|
|
199
|
+
)
|
|
200
|
+
charset_re = rule.get("charset_re")
|
|
201
|
+
if charset_re is not None and not charset_re.match(value):
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Secret '{name}' failed validation: disallowed characters present."
|
|
204
|
+
)
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def validate_env_secret(name: str) -> str:
|
|
209
|
+
"""
|
|
210
|
+
Strictly validate a secret-bearing environment variable at load time.
|
|
211
|
+
|
|
212
|
+
Reads the variable from the environment, applies the hardened
|
|
213
|
+
_get_secret() sanity checks (length, control characters), then applies
|
|
214
|
+
the registered strict rule via validate_secret().
|
|
215
|
+
|
|
216
|
+
Returns the validated value, or an empty string when the variable is
|
|
217
|
+
unset. Raises ValueError (message never contains the raw value) when
|
|
218
|
+
the value is malformed — the caller must treat the secret as unset
|
|
219
|
+
and refuse to use it.
|
|
220
|
+
"""
|
|
221
|
+
if name not in SECRET_BEARING_KEYS:
|
|
222
|
+
# Not a registered secret-bearing key; nothing strict to enforce.
|
|
223
|
+
return os.environ.get(name, "")
|
|
224
|
+
value = _get_secret(name)
|
|
225
|
+
return validate_secret(name, value)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def validate_env_secrets(names=None) -> dict:
|
|
229
|
+
"""
|
|
230
|
+
Batch-validate secret-bearing environment variables at startup.
|
|
231
|
+
|
|
232
|
+
Iterates the secret-bearing key registry (or the provided iterable of
|
|
233
|
+
names, intersected with the registry), calls validate_env_secret()
|
|
234
|
+
per key, and aggregates all failures into a single structured error
|
|
235
|
+
so misconfiguration fails fast with one clear report.
|
|
236
|
+
|
|
237
|
+
Returns a dict {name: validated_value} for every key that passed
|
|
238
|
+
(unset keys map to ""). Raises RuntimeError with a redacted,
|
|
239
|
+
structured summary when any key fails validation. Error messages
|
|
240
|
+
never contain raw secret values.
|
|
241
|
+
"""
|
|
242
|
+
registry = SECRET_BEARING_KEYS
|
|
243
|
+
if names is None:
|
|
244
|
+
targets = sorted(registry)
|
|
245
|
+
else:
|
|
246
|
+
targets = [n for n in names if n in registry]
|
|
247
|
+
|
|
248
|
+
validated = {}
|
|
249
|
+
failures = {}
|
|
250
|
+
for name in targets:
|
|
251
|
+
try:
|
|
252
|
+
validated[name] = validate_env_secret(name)
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
# Store the redacted form of the current value for diagnostics.
|
|
255
|
+
failures[name] = {
|
|
256
|
+
"error": str(exc),
|
|
257
|
+
"value": redact_secret(name, os.environ.get(name, "")),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if failures:
|
|
261
|
+
# Use safe_log so no secret material can leak into the summary.
|
|
262
|
+
summary = safe_log(
|
|
263
|
+
"Batch secret validation failed for {count} key(s): {keys}",
|
|
264
|
+
count=len(failures),
|
|
265
|
+
keys=", ".join(sorted(failures)),
|
|
266
|
+
)
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
summary + " :: " + json.dumps(failures, sort_keys=True)
|
|
269
|
+
)
|
|
270
|
+
return validated
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Import-time batch validation
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Run the batch validator once at module import against the full
|
|
277
|
+
# secret-bearing key registry. A malformed secret fails fast here with a
|
|
278
|
+
# redacted, structured report (no raw values ever reach the traceback).
|
|
279
|
+
try:
|
|
280
|
+
VALIDATED_SECRETS = validate_env_secrets()
|
|
281
|
+
except RuntimeError as _secret_error:
|
|
282
|
+
# Re-raise with a clear, non-leaking message; the RuntimeError from
|
|
283
|
+
# validate_env_secrets already contains only redacted diagnostics.
|
|
284
|
+
raise RuntimeError(
|
|
285
|
+
"config.py: refusing to initialize with invalid secrets. "
|
|
286
|
+
f"{_secret_error}"
|
|
287
|
+
) from None
|
|
288
|
+
|
|
289
|
+
WORKSPACE_DIR = os.path.abspath(os.path.dirname(__file__))
|
|
290
|
+
|
|
291
|
+
# Исправлено: заменена несуществующая google/gemini-2.0-flash-001 на рабочую deepseek/deepseek-chat
|
|
292
|
+
DEFAULT_MODELS = {
|
|
293
|
+
"researcher": "deepseek/deepseek-chat",
|
|
294
|
+
"editor": "deepseek/deepseek-chat",
|
|
295
|
+
"reviewer": "deepseek/deepseek-chat",
|
|
296
|
+
}
|
|
297
|
+
DEFAULT_PRICING = {
|
|
298
|
+
"prompt_price_per_1m": 0.14,
|
|
299
|
+
"completion_price_per_1m": 0.28,
|
|
300
|
+
"context_length": 1_000_000,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Исправлен путь импорта: из папки tools/
|
|
304
|
+
try:
|
|
305
|
+
from tools.llm_selector import get_agent_bindings as _get_agent_bindings, get_model_details as _get_model_details
|
|
306
|
+
except ImportError:
|
|
307
|
+
try:
|
|
308
|
+
from llm_selector import get_agent_bindings as _get_agent_bindings, get_model_details as _get_model_details
|
|
309
|
+
except ImportError:
|
|
310
|
+
_get_agent_bindings = None
|
|
311
|
+
_get_model_details = None
|
|
312
|
+
|
|
313
|
+
ACTIVE_MODELS_FILE = os.path.join(WORKSPACE_DIR, "active_models.json")
|
|
314
|
+
MODELS_CACHE_FILE = os.path.join(WORKSPACE_DIR, "models_cache.json")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# Model IDs must be provider/name form with a safe charset; this blocks
|
|
318
|
+
# path traversal or header injection through a poisoned models cache.
|
|
319
|
+
_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_valid_model_id(model_id) -> bool:
|
|
323
|
+
return (
|
|
324
|
+
isinstance(model_id, str)
|
|
325
|
+
and bool(_MODEL_ID_RE.match(model_id))
|
|
326
|
+
and ".." not in model_id
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _load_legacy_active_models() -> dict:
|
|
331
|
+
if os.path.exists(ACTIVE_MODELS_FILE):
|
|
332
|
+
try:
|
|
333
|
+
with open(ACTIVE_MODELS_FILE, "r", encoding="utf-8") as f:
|
|
334
|
+
data = json.load(f)
|
|
335
|
+
if isinstance(data, dict):
|
|
336
|
+
return {
|
|
337
|
+
agent: data.get(agent, DEFAULT_MODELS[agent])
|
|
338
|
+
for agent in DEFAULT_MODELS
|
|
339
|
+
if _is_valid_model_id(data.get(agent, DEFAULT_MODELS[agent]))
|
|
340
|
+
}
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
return dict(DEFAULT_MODELS)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _load_legacy_model_specs(model_id: str) -> dict:
|
|
347
|
+
if os.path.exists(MODELS_CACHE_FILE):
|
|
348
|
+
try:
|
|
349
|
+
with open(MODELS_CACHE_FILE, "r", encoding="utf-8") as f:
|
|
350
|
+
cached_data = json.load(f)
|
|
351
|
+
data = cached_data.get("data", cached_data) if isinstance(cached_data, dict) else cached_data
|
|
352
|
+
if isinstance(data, list):
|
|
353
|
+
for item in data:
|
|
354
|
+
if not isinstance(item, dict) or item.get("id") != model_id:
|
|
355
|
+
continue
|
|
356
|
+
if not _is_valid_model_id(item.get("id")):
|
|
357
|
+
continue
|
|
358
|
+
pricing = item.get("pricing", {}) or {}
|
|
359
|
+
if not isinstance(pricing, dict):
|
|
360
|
+
pricing = {}
|
|
361
|
+
try:
|
|
362
|
+
prompt_str = str(pricing.get("prompt", "0") or "0")
|
|
363
|
+
completion_str = str(pricing.get("completion", "0") or "0")
|
|
364
|
+
prompt_price = float(prompt_str) * 1_000_000
|
|
365
|
+
completion_price = float(completion_str) * 1_000_000
|
|
366
|
+
# Reject negative or absurd prices (poisoned cache).
|
|
367
|
+
if not (0 <= prompt_price <= 10_000) or not (0 <= completion_price <= 10_000):
|
|
368
|
+
raise ValueError
|
|
369
|
+
context_length = item.get("context_length", DEFAULT_PRICING["context_length"])
|
|
370
|
+
if not isinstance(context_length, int) or context_length <= 0:
|
|
371
|
+
context_length = DEFAULT_PRICING["context_length"]
|
|
372
|
+
except (TypeError, ValueError):
|
|
373
|
+
continue
|
|
374
|
+
return {
|
|
375
|
+
"prompt_price_per_1m": prompt_price if prompt_str not in ("", "0") else DEFAULT_PRICING["prompt_price_per_1m"],
|
|
376
|
+
"completion_price_per_1m": completion_price if completion_str not in ("", "0") else DEFAULT_PRICING["completion_price_per_1m"],
|
|
377
|
+
"context_length": context_length,
|
|
378
|
+
}
|
|
379
|
+
except Exception:
|
|
380
|
+
pass
|
|
381
|
+
return dict(DEFAULT_PRICING)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_active_model(agent: str) -> str:
|
|
385
|
+
"""Динамический поиск активной модели для агента."""
|
|
386
|
+
if not isinstance(agent, str) or not agent:
|
|
387
|
+
return "deepseek/deepseek-chat"
|
|
388
|
+
if _get_agent_bindings is not None:
|
|
389
|
+
try:
|
|
390
|
+
bindings = _get_agent_bindings()
|
|
391
|
+
if isinstance(bindings, dict) and agent in bindings:
|
|
392
|
+
candidate = bindings[agent]
|
|
393
|
+
if _is_valid_model_id(candidate):
|
|
394
|
+
return candidate
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
# Безопасный откат
|
|
398
|
+
fallback = _load_legacy_active_models().get(agent, DEFAULT_MODELS.get(agent, "deepseek/deepseek-chat"))
|
|
399
|
+
return fallback if _is_valid_model_id(fallback) else "deepseek/deepseek-chat"
|
|
400
|
+
|
|
401
|
+
get_agent_model = get_active_model
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_model_specs(model_id: str) -> dict:
|
|
405
|
+
if not _is_valid_model_id(model_id):
|
|
406
|
+
return dict(DEFAULT_PRICING)
|
|
407
|
+
if _get_model_details is not None:
|
|
408
|
+
try:
|
|
409
|
+
details = _get_model_details(model_id)
|
|
410
|
+
if isinstance(details, dict):
|
|
411
|
+
def _safe_price(key: str) -> float:
|
|
412
|
+
try:
|
|
413
|
+
val = float(details.get(key, DEFAULT_PRICING[key]))
|
|
414
|
+
return val if 0 <= val <= 10_000 else DEFAULT_PRICING[key]
|
|
415
|
+
except (TypeError, ValueError):
|
|
416
|
+
return DEFAULT_PRICING[key]
|
|
417
|
+
|
|
418
|
+
context_length = details.get("context_length", DEFAULT_PRICING["context_length"])
|
|
419
|
+
if not isinstance(context_length, int) or context_length <= 0:
|
|
420
|
+
context_length = DEFAULT_PRICING["context_length"]
|
|
421
|
+
return {
|
|
422
|
+
"prompt_price_per_1m": _safe_price("price_input_1m"),
|
|
423
|
+
"completion_price_per_1m": _safe_price("price_output_1m"),
|
|
424
|
+
"context_length": context_length,
|
|
425
|
+
}
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
return _load_legacy_model_specs(model_id)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def get_current_price(agent: str) -> tuple:
|
|
432
|
+
model_id = get_active_model(agent)
|
|
433
|
+
specs = get_model_specs(model_id)
|
|
434
|
+
return specs["prompt_price_per_1m"], specs["completion_price_per_1m"], specs["context_length"]
|
|
435
|
+
|
|
436
|
+
# Константы бюджета и итераций
|
|
437
|
+
MAX_BUDGET_USD = 5
|
|
438
|
+
MAX_ITERATIONS = 8 # вместо 10
|
|
439
|
+
HITL_EVERY_N_CYCLES = 3
|
|
440
|
+
PLAN_CHUNK_THRESHOLD = 100
|
|
441
|
+
|
|
442
|
+
# ============================================================================
|
|
443
|
+
# Lesson Memory Bridge (VegeX memory subsystem)
|
|
444
|
+
# ============================================================================
|
|
445
|
+
# The bridge syncs JSON lesson files into a SQLite A-Score index at runtime.
|
|
446
|
+
# JSON remains the source of truth for content; SQLite holds ranking signals.
|
|
447
|
+
|
|
448
|
+
LESSONS_MEMORY_ENABLED = os.getenv("VEGEX_LESSONS_MEMORY", "1") == "1"
|
|
449
|
+
LESSONS_ROOT = os.path.join(WORKSPACE_DIR, "lessons")
|
|
450
|
+
LESSONS_MEMORY_DIR = os.path.join(WORKSPACE_DIR, ".vegex")
|
|
451
|
+
LESSONS_MEMORY_DB_PATH = os.path.join(LESSONS_MEMORY_DIR, "lessons.db")
|
|
452
|
+
def _safe_int_env(name: str, default: int, minimum: int, maximum: int) -> int:
|
|
453
|
+
"""Parse an integer env var with bounds; falls back to default on abuse."""
|
|
454
|
+
raw = os.getenv(name, "")
|
|
455
|
+
try:
|
|
456
|
+
val = int(raw)
|
|
457
|
+
except (TypeError, ValueError):
|
|
458
|
+
return default
|
|
459
|
+
if val < minimum or val > maximum:
|
|
460
|
+
return default
|
|
461
|
+
return val
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
LESSONS_EMBEDDING_DIM = _safe_int_env("VEGEX_LESSON_EMBED_DIM", 256, 1, 4096)
|
|
465
|
+
|
|
466
|
+
# Retrieval defaults
|
|
467
|
+
LESSONS_RETRIEVAL_LIMIT = 5
|
|
468
|
+
LESSONS_RETRIEVAL_STAGES_RESEARCHER = ("promoted", "candidates", "observations")
|
|
469
|
+
LESSONS_RETRIEVAL_STAGES_CONSUMER = ("promoted",)
|
|
470
|
+
|
|
471
|
+
# A-Score hyperparameters for the lesson ranker (see memory/ranker.py)
|
|
472
|
+
LESSONS_RANKING_ALPHA = 1.0
|
|
473
|
+
LESSONS_RANKING_BETA_PROTECTION = 2.0
|
|
474
|
+
LESSONS_RANKING_LAMBDA_BASE = 0.01
|
|
475
|
+
LESSONS_RANKING_EPSILON = 1e-5
|
|
476
|
+
|
|
477
|
+
# RSI mode: active project is DifferiX itself. Enables diff-based
|
|
478
|
+
# security scanning instead of full-content scanning.
|
|
479
|
+
RSI_MODE = os.getenv("VEGEX_RSI_MODE", "0") == "1"
|
|
480
|
+
|
|
481
|
+
# ============================================================================
|
|
482
|
+
# Embedding provider (VegeX memory subsystem)
|
|
483
|
+
# ============================================================================
|
|
484
|
+
# Provider selection: "auto" | "openrouter" | "local" | "hash"
|
|
485
|
+
# auto → OpenRouter if OPENROUTER_API_KEY is set, else local, else hash
|
|
486
|
+
# openrouter → require OPENROUTER_API_KEY (same key as chat completions)
|
|
487
|
+
# local → require fastembed installed (offline)
|
|
488
|
+
# hash → zero-dependency bag-of-words (low quality; emergency only)
|
|
489
|
+
EMBEDDING_PROVIDER = os.getenv("VEGEX_EMBEDDING_PROVIDER", "auto")
|
|
490
|
+
EMBEDDING_PREFER_LOCAL = os.getenv("VEGEX_EMBEDDING_PREFER_LOCAL", "0") == "1"
|
|
491
|
+
|
|
492
|
+
# Target vector dimension. text-embedding-3-small supports the `dimensions`
|
|
493
|
+
# parameter (truncation); 512 balances quality and storage. Local MiniLM
|
|
494
|
+
# is fixed at 384 and ignores this.
|
|
495
|
+
EMBEDDING_DIM = _safe_int_env("VEGEX_EMBEDDING_DIM", 512, 1, 4096)
|
|
496
|
+
|
|
497
|
+
# OpenRouter embedding model ID (provider-prefixed).
|
|
498
|
+
OPENROUTER_EMBEDDING_MODEL = os.getenv(
|
|
499
|
+
"VEGEX_OPENROUTER_EMBED_MODEL", "openai/text-embedding-3-small"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# Reflection — periodic self-analysis of recent failures
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
REFLECTION_INTERVAL = _safe_int_env("VEGEX_REFLECTION_INTERVAL", 5, 1, 1000)
|
|
506
|
+
REFLECTION_MODEL = os.getenv("VEGEX_REFLECTION_MODEL", "") # empty → top-tier
|
|
507
|
+
if REFLECTION_MODEL and not _is_valid_model_id(REFLECTION_MODEL):
|
|
508
|
+
REFLECTION_MODEL = ""
|
|
509
|
+
REFLECTION_MAX_OBSERVATIONS = 3
|
|
510
|
+
REFLECTION_MIN_OCCURRENCES = 2
|
|
511
|
+
|
|
512
|
+
# Note: no separate key is needed. The OpenRouterEmbeddingProvider uses
|
|
513
|
+
# OPENROUTER_API_KEY (defined at the top of this module).
|
|
514
|
+
|
|
515
|
+
# ============================================================================
|
|
516
|
+
# Tiered Context Assembly (TCA)
|
|
517
|
+
# ============================================================================
|
|
518
|
+
# Context is assembled with a fixed token budget per role. Tiers are packed
|
|
519
|
+
# in priority order (T0_STATIC -> T5_FEEDBACK). When the budget is exhausted,
|
|
520
|
+
# lower-priority content is degraded to its fallback or dropped entirely.
|
|
521
|
+
#
|
|
522
|
+
# See tools/context_assembler.py for the tier hierarchy.
|
|
523
|
+
#
|
|
524
|
+
# Setting a budget to a huge value effectively disables TCA truncation for
|
|
525
|
+
# that role (all content is included as-is).
|
|
526
|
+
TCA_ENABLED = os.getenv("VEGEX_TCA_ENABLED", "1") == "1"
|
|
527
|
+
|
|
528
|
+
TOKEN_BUDGET_EDITOR = _safe_int_env("VEGEX_BUDGET_EDITOR", 18000, 2000, 200000)
|
|
529
|
+
TOKEN_BUDGET_REVIEWER = _safe_int_env("VEGEX_BUDGET_REVIEWER", 14000, 2000, 200000)
|
|
530
|
+
TOKEN_BUDGET_RESEARCHER = _safe_int_env("VEGEX_BUDGET_RESEARCHER", 12000, 2000, 200000)
|
|
531
|
+
|
|
532
|
+
# Fraction of budget actually used (headroom for token estimate error).
|
|
533
|
+
TCA_SAFETY_MARGIN = 0.9
|
|
534
|
+
|
|
535
|
+
# Include feedback (past errors) on iteration 0? Default False — nothing
|
|
536
|
+
# useful on the first try, saves ~1-2k tokens per cycle.
|
|
537
|
+
TCA_INCLUDE_FEEDBACK_ON_FIRST_CYCLE = False
|
|
538
|
+
|
|
539
|
+
# Print one-line TCA summary after every assemble(). Set to 0 in production
|
|
540
|
+
# once budgets are tuned.
|
|
541
|
+
TCA_LOG_SUMMARY = True
|
|
542
|
+
|
|
543
|
+
# ============================================================================
|
|
544
|
+
# RSI Mode Safety (Phase 1.3)
|
|
545
|
+
# ============================================================================
|
|
546
|
+
# These files are the "brain" and "immune system" of DifferiX. In RSI mode,
|
|
547
|
+
# the agent can *propose* changes to them, but they will NOT be applied
|
|
548
|
+
# automatically. They are saved to .vegex/pending_critical_changes/ for human review.
|
|
549
|
+
RSI_CRITICAL_FILES = {
|
|
550
|
+
"main.py",
|
|
551
|
+
"config.py",
|
|
552
|
+
"tools/security_filter.py",
|
|
553
|
+
"tools/file_manager.py",
|
|
554
|
+
"tools/state_manager.py",
|
|
555
|
+
"tools/security/verification_firewall.py",
|
|
556
|
+
"tools/security/behavior_guard.py",
|
|
557
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: differix
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: The Deterministic, Local-First OS for Autonomous AI Agents.
|
|
5
|
+
Author: Tdahqu8
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://differix.dev
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/Tdahqu8/DifferiX/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/Tdahqu8/DifferiX
|
|
10
|
+
Keywords: ai,llm,agents,multi-agent,openai,claude,autonomous,developer-tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: openai>=1.0.0
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: rich>=13.0.0
|
|
27
|
+
Requires-Dist: GitPython>=3.1.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
30
|
+
Provides-Extra: full
|
|
31
|
+
Requires-Dist: fastembed>=0.3.0; extra == "full"
|
|
32
|
+
Requires-Dist: PyYAML>=6.0; extra == "full"
|
|
33
|
+
Requires-Dist: playwright>=1.40.0; extra == "full"
|
|
34
|
+
|
|
35
|
+
<div align="center">
|
|
36
|
+
<h1>DifferiX</h1>
|
|
37
|
+
<p><strong>The Deterministic, Local-First OS for Autonomous AI Agents.</strong></p>
|
|
38
|
+
<p>
|
|
39
|
+
<a href="#-features">Features</a> •
|
|
40
|
+
<a href="#-quick-start">Quick Start</a> •
|
|
41
|
+
<a href="#-architecture">Architecture</a> •
|
|
42
|
+
<a href="#-plugins">Plugins</a>
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
DifferiX is an open, model-agnostic multi-agent runtime. It doesn't just let LLMs write code; it forces them to write **verified, executable code** inside a deterministic Python shell.
|
|
49
|
+
|
|
50
|
+
Most "autonomous agents" fail because they hallucinate success and loop endlessly. DifferiX solves this with **Zero Trust Architecture**, **Tiered Context Assembly**, and a **Local Experience Engine**.
|
|
51
|
+
|
|
52
|
+
## 🧠 Core Architectural Patterns
|
|
53
|
+
|
|
54
|
+
1. **Zero Trust to Agent:** A stochastic LLM is never trusted with state mutation. Success is verified by compilers, ASTs, and fs-hashes, not by the agent's words.
|
|
55
|
+
2. **Probabilistic in a Deterministic Shell:** The LLM acts purely as a translator. Python scripts act as the supreme judge, sandbox, and state machine.
|
|
56
|
+
3. **Scarcity by Design (TCA):** Artificial token budgets force the LLM to focus on surgical context (reducing input tokens by 80%).
|
|
57
|
+
4. **Evolutionary Memory (A-Score):** Experience is stored as abstract math. Unhelpful lessons fade out; helpful ones propagate without retraining the LLM.
|
|
58
|
+
|
|
59
|
+
## ✨ Features
|
|
60
|
+
|
|
61
|
+
* **Verification Firewall:** Blocks agents from faking success. If the file on disk didn't change, the patch is rejected.
|
|
62
|
+
* **Behavior Guard:** Detects prompt-context bloat and repeated actions in real-time. Kills the loop before it burns your API budget.
|
|
63
|
+
* **Tiered Context Assembly (TCA):** Waterfall token budgeting. AST-slicing for Python, Class Inventory extraction for CSS.
|
|
64
|
+
* **Dynamic Skill Injection:** The Researcher analyzes the task and injects *only* the relevant skills (e.g., `style_brutalist` or `style_3d_webgl`).
|
|
65
|
+
* **Recursive Self-Improvement (RSI):** The framework can safely modify its own source code, protected by a critical-file gate.
|
|
66
|
+
* **Model Agnostic:** Works with OpenRouter, Anthropic, Google, or local Ollama models.
|
|
67
|
+
|
|
68
|
+
## 🚀 Quick Start
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Clone the repository
|
|
72
|
+
git clone https://github.com/Tdahqu8/DifferiX.git
|
|
73
|
+
cd DifferiX
|
|
74
|
+
|
|
75
|
+
# Setup environment
|
|
76
|
+
python3 -m venv venv
|
|
77
|
+
source venv/bin/activate
|
|
78
|
+
pip install -r requirements.txt
|
|
79
|
+
|
|
80
|
+
# Add your API key
|
|
81
|
+
cp .env.example .env
|
|
82
|
+
# Edit .env and add OPENROUTER_API_KEY=sk-or-v1-...
|
|
83
|
+
|
|
84
|
+
# Run DifferiX
|
|
85
|
+
export VEGEX_RSI_MODE=1
|
|
86
|
+
python main.py
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 🎨 Showcase (WebDev Pro Plugin)
|
|
90
|
+
|
|
91
|
+
DifferiX dynamically injects design skills to generate production-ready UIs.
|
|
92
|
+
|
|
93
|
+
> *"Create a portfolio landing page for a Web3 graffiti artist. Make it look raw and bold."* — Generated in 4 cycles using the `style_brutalist` skill.
|
|
94
|
+
|
|
95
|
+
## 🏗️ Architecture
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
[ User ] <--> [ LLM Interpreter ]
|
|
99
|
+
|
|
|
100
|
+
+--------------+--------------+
|
|
101
|
+
| |
|
|
102
|
+
[ Symbolic Verifier ] [ Experience Engine ]
|
|
103
|
+
(Python / AST / Compilers) (A-Score / SQLite / Math)
|
|
104
|
+
| |
|
|
105
|
+
+--------------+--------------+
|
|
106
|
+
|
|
|
107
|
+
[ Verified Code ]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 🔌 Ecosystem & Plugins
|
|
111
|
+
|
|
112
|
+
DifferiX supports `.differixplugin` files. Install plugins to give agents new superpowers (WebDev, EdTech, Crypto Auditing).
|
|
113
|
+
|
|
114
|
+
> **Note:** Executable code is strictly forbidden inside plugins to prevent RCE. Plugins are data-only (`.md`, `.json`, `.css`).
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Install the official WebDev Pro plugin
|
|
118
|
+
differix plugin install https://github.com/Tdahqu8/DifferiX/releases/download/v0.1.0-alpha/webdev_pro.differixplugin
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 📜 License
|
|
122
|
+
|
|
123
|
+
MIT License. Build, fork, and create.
|