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,398 @@
1
+ """
2
+ tools/lesson_memory_bridge.py
3
+ ------------------------------
4
+ Bridges the JSON-file lesson system with the SQLite-backed A-Score memory engine.
5
+
6
+ Design:
7
+ - lessons/*.json → source of truth (content, human-editable, git-tracked)
8
+ - .vegex/lessons.db → regenerable ranking index (vectors, T/H/V stats)
9
+
10
+ Embedding providers are selected via `build_embedding_provider(config)`.
11
+ On bridge initialization, `check_and_migrate` ensures the storage's vector
12
+ dimension and provider signature match the active provider. If they differ,
13
+ existing rows are re-embedded from their `content` field, preserving all
14
+ A-Score signals.
15
+
16
+ Public API:
17
+ - LessonMemoryBridge(lessons_root, db_path, ...)
18
+ .sync_all(force=False) -> SyncReport
19
+ .search(query, limit, stages, scope_type, scope_value, record_trigger) -> List[ScoredMemoryNode]
20
+ .record_helpful(node_ids) -> int
21
+ .record_violation(node_ids) -> int
22
+ .stats() -> dict
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import logging
30
+ import os
31
+ import re
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
36
+
37
+ from memory import (
38
+ BaseEmbeddingProvider,
39
+ MemoryManager,
40
+ MemoryNode,
41
+ MemoryScope,
42
+ RankingWeights,
43
+ ScoredMemoryNode,
44
+ SQLiteMemoryStorage,
45
+ build_embedding_provider,
46
+ check_and_migrate,
47
+ )
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Sync report
54
+ # ---------------------------------------------------------------------------
55
+ @dataclass
56
+ class SyncReport:
57
+ inserted: int = 0
58
+ updated: int = 0
59
+ skipped: int = 0
60
+ deleted: int = 0
61
+ errors: List[str] = field(default_factory=list)
62
+ elapsed_ms: float = 0.0
63
+ migration: str = ""
64
+
65
+ def as_dict(self) -> Dict[str, Any]:
66
+ return {
67
+ "inserted": self.inserted,
68
+ "updated": self.updated,
69
+ "skipped": self.skipped,
70
+ "deleted": self.deleted,
71
+ "errors": list(self.errors),
72
+ "elapsed_ms": round(self.elapsed_ms, 2),
73
+ "migration": self.migration,
74
+ }
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Bridge
79
+ # ---------------------------------------------------------------------------
80
+ class LessonMemoryBridge:
81
+ """Synchronizes lessons/*/*.json into a MemoryManager SQLite index."""
82
+
83
+ STAGES: Tuple[str, ...] = ("observations", "candidates", "promoted", "deprecated")
84
+
85
+ STAGE_TRUST: Dict[str, float] = {
86
+ "observations": 0.3,
87
+ "candidates": 0.5,
88
+ "promoted": 1.0,
89
+ "deprecated": 0.1,
90
+ }
91
+
92
+ STAGE_CRITICAL: Dict[str, bool] = {
93
+ "observations": False,
94
+ "candidates": False,
95
+ "promoted": True,
96
+ "deprecated": False,
97
+ }
98
+
99
+ def __init__(
100
+ self,
101
+ lessons_root: str | os.PathLike,
102
+ db_path: str | os.PathLike,
103
+ embedding_provider: Optional[BaseEmbeddingProvider] = None,
104
+ ranking_weights: Optional[RankingWeights] = None,
105
+ config_module: Optional[Any] = None,
106
+ ) -> None:
107
+ self.lessons_root = Path(lessons_root).resolve()
108
+ self.db_path = Path(db_path).resolve()
109
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
110
+
111
+ # Provider selection: explicit override, else factory.
112
+ if embedding_provider is not None:
113
+ self.embedder = embedding_provider
114
+ else:
115
+ if config_module is None:
116
+ import config as config_module # type: ignore
117
+ self.embedder = build_embedding_provider(config_module)
118
+
119
+ # Storage is created with the active provider's dim.
120
+ storage = SQLiteMemoryStorage(
121
+ db_path=str(self.db_path),
122
+ expected_dim=self.embedder.dim,
123
+ )
124
+
125
+ # Migrate if the on-disk signature differs (re-embeds all rows).
126
+ self._migration_report = check_and_migrate(storage, self.embedder)
127
+ if self._migration_report.action == "migrated":
128
+ logger.info("Bridge migration: %s", self._migration_report)
129
+ elif self._migration_report.action == "aborted":
130
+ logger.error("Bridge migration aborted: %s", self._migration_report.error)
131
+
132
+ self.manager = MemoryManager(
133
+ storage=storage,
134
+ default_weights=ranking_weights or RankingWeights(),
135
+ )
136
+
137
+ # ------------------------------------------------------------------ #
138
+ # Sync
139
+ # ------------------------------------------------------------------ #
140
+ def sync_all(self, force: bool = False) -> SyncReport:
141
+ t0 = time.perf_counter()
142
+ report = SyncReport()
143
+ report.migration = str(self._migration_report)
144
+
145
+ seen_ids: set = set()
146
+ json_by_stage: Dict[str, List[Path]] = {s: [] for s in self.STAGES}
147
+
148
+ for stage in self.STAGES:
149
+ stage_dir = self.lessons_root / stage
150
+ if not stage_dir.is_dir():
151
+ continue
152
+ for fp in sorted(stage_dir.glob("*.json")):
153
+ json_by_stage[stage].append(fp)
154
+
155
+ for stage, paths in json_by_stage.items():
156
+ for path in paths:
157
+ try:
158
+ node = self._sync_one(stage, path, force=force, report=report)
159
+ if node is not None:
160
+ seen_ids.add(node.node_id)
161
+ except Exception as exc:
162
+ report.errors.append(f"{stage}/{path.name}: {exc}")
163
+
164
+ report.deleted = self._purge_orphans(seen_ids)
165
+ report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
166
+ return report
167
+
168
+ def _sync_one(
169
+ self,
170
+ stage: str,
171
+ path: Path,
172
+ force: bool,
173
+ report: SyncReport,
174
+ ) -> Optional[MemoryNode]:
175
+ raw = path.read_bytes()
176
+ content_hash = hashlib.sha256(raw).hexdigest()
177
+
178
+ try:
179
+ data = json.loads(raw.decode("utf-8"))
180
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
181
+ report.errors.append(f"{stage}/{path.name}: invalid JSON ({exc})")
182
+ return None
183
+
184
+ lesson_id = str(data.get("id") or path.stem).strip()
185
+ if not lesson_id:
186
+ report.errors.append(f"{stage}/{path.name}: missing 'id'")
187
+ return None
188
+
189
+ node_id = f"lesson:{lesson_id}"
190
+
191
+ existing: Optional[MemoryNode] = None
192
+ try:
193
+ existing = self.manager.get(node_id)
194
+ except Exception:
195
+ existing = None
196
+
197
+ # Fast path: unchanged content + same stage → skip
198
+ if existing is not None and not force:
199
+ prev_hash = (existing.metadata or {}).get("content_hash")
200
+ prev_stage = (existing.metadata or {}).get("stage")
201
+ if prev_hash == content_hash and prev_stage == stage:
202
+ report.skipped += 1
203
+ return existing
204
+
205
+ node = self._build_node(stage, path, data, content_hash, existing)
206
+ if existing is None:
207
+ self.manager.add(node)
208
+ report.inserted += 1
209
+ else:
210
+ # Delete+reinsert to preserve runtime signals from `node`
211
+ self.manager.storage.delete(node.node_id)
212
+ self.manager.add(node)
213
+ report.updated += 1
214
+ return node
215
+
216
+ def _build_node(
217
+ self,
218
+ stage: str,
219
+ path: Path,
220
+ data: Dict[str, Any],
221
+ content_hash: str,
222
+ existing: Optional[MemoryNode],
223
+ ) -> MemoryNode:
224
+ lesson_id = str(data.get("id") or path.stem).strip()
225
+ content_text = self._render_content_text(data)
226
+ vector = self.embedder.embed(content_text)
227
+
228
+ now = time.time()
229
+ trigger = existing.trigger_count if existing else 0
230
+ helpful = existing.helpful_count if existing else 0
231
+ violation = existing.violation_count if existing else 0
232
+ last_used = existing.last_used_at if existing else now
233
+ created_at = existing.created_at if existing else now
234
+
235
+ rel_path = path.relative_to(self.lessons_root).as_posix()
236
+
237
+ metadata: Dict[str, Any] = {
238
+ "stage": stage,
239
+ "lesson_id": lesson_id,
240
+ "lesson_file": rel_path,
241
+ "content_hash": content_hash,
242
+ "root_cause": str(data.get("root_cause", "")),
243
+ "details": data.get("details", {}),
244
+ "tags": list(data.get("tags", []) or []),
245
+ "timestamp": data.get("timestamp"),
246
+ }
247
+
248
+ return MemoryNode(
249
+ node_id=f"lesson:{lesson_id}",
250
+ vector=vector,
251
+ content=content_text,
252
+ metadata=metadata,
253
+ base_trust=self.STAGE_TRUST.get(stage, 0.3),
254
+ trigger_count=trigger,
255
+ helpful_count=helpful,
256
+ violation_count=violation,
257
+ is_critical=self.STAGE_CRITICAL.get(stage, False),
258
+ scope_type=MemoryScope.PROJECT,
259
+ scope_value="vegex",
260
+ created_at=created_at,
261
+ last_used_at=last_used,
262
+ )
263
+
264
+ @staticmethod
265
+ def _render_content_text(data: Dict[str, Any]) -> str:
266
+ parts: List[str] = []
267
+ root_cause = str(data.get("root_cause", "")).strip()
268
+ if root_cause:
269
+ parts.append(f"Root cause: {root_cause}")
270
+
271
+ details = data.get("details", {})
272
+ if isinstance(details, dict):
273
+ for k, v in details.items():
274
+ sv = str(v).strip()
275
+ if sv:
276
+ parts.append(f"{k}: {sv}")
277
+ elif details:
278
+ parts.append(str(details))
279
+
280
+ tags = data.get("tags", [])
281
+ if tags:
282
+ parts.append("Tags: " + ", ".join(str(t) for t in tags))
283
+
284
+ text = "\n".join(parts).strip()
285
+ return text or str(data.get("id", "empty lesson"))
286
+
287
+ def _purge_orphans(self, live_node_ids: set) -> int:
288
+ deleted = 0
289
+ try:
290
+ all_nodes = self.manager.storage.list_all()
291
+ except Exception as exc:
292
+ logger.warning("Failed to list nodes during purge: %s", exc)
293
+ return 0
294
+ for node in all_nodes:
295
+ if not node.node_id.startswith("lesson:"):
296
+ continue
297
+ if node.node_id not in live_node_ids:
298
+ try:
299
+ self.manager.storage.delete(node.node_id)
300
+ deleted += 1
301
+ except Exception as exc:
302
+ logger.warning("Failed to purge node %s: %s", node.node_id, exc)
303
+ return deleted
304
+
305
+ # ------------------------------------------------------------------ #
306
+ # Retrieval
307
+ # ------------------------------------------------------------------ #
308
+ def search(
309
+ self,
310
+ query: str,
311
+ limit: int = 5,
312
+ stages: Optional[Sequence[str]] = None,
313
+ scope_type: MemoryScope = MemoryScope.PROJECT,
314
+ scope_value: str = "vegex",
315
+ record_trigger: bool = True,
316
+ ) -> List[ScoredMemoryNode]:
317
+ if not query or not query.strip():
318
+ return []
319
+
320
+ try:
321
+ qvec = self.embedder.embed(query)
322
+ except Exception as exc:
323
+ logger.warning("Query embedding failed: %s", exc)
324
+ return []
325
+
326
+ all_nodes = self.manager.storage.list_all()
327
+ if stages is not None:
328
+ stage_set = set(stages)
329
+ candidates = [
330
+ n for n in all_nodes
331
+ if n.node_id.startswith("lesson:")
332
+ and (n.metadata or {}).get("stage") in stage_set
333
+ ]
334
+ else:
335
+ candidates = [n for n in all_nodes if n.node_id.startswith("lesson:")]
336
+
337
+ if not candidates:
338
+ return []
339
+
340
+ from memory import rank_memory_nodes_scored
341
+
342
+ scored = rank_memory_nodes_scored(
343
+ nodes=candidates,
344
+ query_vector=qvec,
345
+ weights=self.manager.weights,
346
+ query_scope=scope_type,
347
+ query_scope_value=scope_value,
348
+ top_k=limit,
349
+ )
350
+
351
+ if record_trigger and scored:
352
+ node_ids = [s.node.node_id for s in scored]
353
+ try:
354
+ self.manager.record_trigger(node_ids)
355
+ for s in scored:
356
+ s.node.trigger_count += 1
357
+ s.node.last_used_at = time.time()
358
+ except Exception as exc:
359
+ logger.warning("Failed to persist trigger feedback: %s", exc)
360
+
361
+ return scored
362
+
363
+ # ------------------------------------------------------------------ #
364
+ # Feedback loop
365
+ # ------------------------------------------------------------------ #
366
+ def record_helpful(self, node_ids: Iterable[str]) -> int:
367
+ ids = [i for i in node_ids if i]
368
+ return self.manager.record_helpful(ids) if ids else 0
369
+
370
+ def record_violation(self, node_ids: Iterable[str]) -> int:
371
+ ids = [i for i in node_ids if i]
372
+ return self.manager.record_violation(ids) if ids else 0
373
+
374
+ # ------------------------------------------------------------------ #
375
+ # Stats
376
+ # ------------------------------------------------------------------ #
377
+ def stats(self) -> Dict[str, Any]:
378
+ all_nodes = self.manager.storage.list_all()
379
+ by_stage: Dict[str, int] = {s: 0 for s in self.STAGES}
380
+ for n in all_nodes:
381
+ if not n.node_id.startswith("lesson:"):
382
+ continue
383
+ stage = (n.metadata or {}).get("stage", "unknown")
384
+ by_stage[stage] = by_stage.get(stage, 0) + 1
385
+ return {
386
+ "db_path": str(self.db_path),
387
+ "embedding_signature": list(self.embedder.signature),
388
+ "embedding_dim": self.embedder.dim,
389
+ "migration_status": str(self._migration_report),
390
+ "total_lesson_nodes": sum(by_stage.values()),
391
+ "by_stage": by_stage,
392
+ }
393
+
394
+ def close(self) -> None:
395
+ self.manager.close()
396
+
397
+
398
+ __all__ = ["LessonMemoryBridge", "SyncReport"]
tools/llm_client.py ADDED
@@ -0,0 +1,103 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import httpx
5
+ import openai
6
+ from openai import OpenAI
7
+ import config
8
+ from tools.llm_selector import (
9
+ get_agent_fallback_chain,
10
+ resolve_model_for_task,
11
+ CONFIG_FILE,
12
+ get_dynamic_routing_enabled,
13
+ get_agent_bindings
14
+ )
15
+
16
+ RECOVERABLE_ERRORS = (
17
+ openai.APIConnectionError,
18
+ openai.APITimeoutError,
19
+ openai.InternalServerError,
20
+ openai.RateLimitError,
21
+ httpx.RequestError,
22
+ )
23
+
24
+ def safe_chat_completion(
25
+ role_or_model: str,
26
+ messages: list,
27
+ temperature: float = 0.2,
28
+ max_tokens: int = 2000,
29
+ extra_body: dict = None,
30
+ retries: int = 3,
31
+ model: str = None,
32
+ task_spec: str = None,
33
+ ):
34
+ api_key = getattr(config, "OPENROUTER_API_KEY", None) or os.getenv("OPENROUTER_API_KEY", "dummy")
35
+ base_url = getattr(config, "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
36
+
37
+ client = OpenAI(base_url=base_url, api_key=api_key, timeout=120.0)
38
+
39
+ primary_model = None
40
+ is_dynamic = get_dynamic_routing_enabled()
41
+ bindings = get_agent_bindings()
42
+
43
+ if model:
44
+ primary_model = model
45
+ elif role_or_model in bindings and os.path.exists(CONFIG_FILE):
46
+ # Manual binding exists in config file
47
+ primary_model = bindings[role_or_model]
48
+ elif is_dynamic and task_spec and str(task_spec).strip() and role_or_model in ["researcher", "editor", "reviewer"]:
49
+ # Dynamic routing is enabled and we have task spec
50
+ try:
51
+ primary_model = resolve_model_for_task(task_spec, role_or_model)
52
+ except Exception as exc:
53
+ print(f"[LLM Client] Dynamic routing failed ({exc}); falling back to bindings.")
54
+ primary_model = bindings.get(role_or_model, role_or_model)
55
+ else:
56
+ # Fallback to bindings
57
+ primary_model = bindings.get(role_or_model, role_or_model)
58
+
59
+ if primary_model:
60
+ models_to_try = [primary_model]
61
+ for fallback in get_agent_fallback_chain(role_or_model):
62
+ if fallback not in models_to_try:
63
+ models_to_try.append(fallback)
64
+ else:
65
+ models_to_try = get_agent_fallback_chain(role_or_model)
66
+
67
+ last_exception = None
68
+
69
+ for model_name in models_to_try:
70
+ for attempt in range(retries):
71
+ try:
72
+ kwargs = {
73
+ "model": model_name,
74
+ "messages": messages,
75
+ "temperature": temperature,
76
+ "max_tokens": max_tokens,
77
+ }
78
+ if extra_body:
79
+ kwargs["extra_body"] = extra_body
80
+
81
+ print(
82
+ f"[LLM Router] role={role_or_model} "
83
+ f"task_spec={(task_spec or '')[:40]!r} "
84
+ f"dynamic={is_dynamic} primary={primary_model} trying={model_name}"
85
+ )
86
+
87
+ response = client.chat.completions.create(**kwargs)
88
+ provider = "OpenRouter" if "openrouter" in base_url.lower() else "Local/Ollama"
89
+ return response, provider
90
+
91
+ except RECOVERABLE_ERRORS as e:
92
+ last_exception = e
93
+ time.sleep(2 ** attempt)
94
+ except openai.APIStatusError as e:
95
+ last_exception = e
96
+ print(f"[LLM Client] Model '{model_name}' returned HTTP {e.status_code}. Switching to fallback...")
97
+ break
98
+ except Exception as e:
99
+ last_exception = e
100
+ print(f"[LLM Client] Unexpected error with '{model_name}': {e}")
101
+ break
102
+
103
+ raise RuntimeError(f"LLM Chat Completion failed for all models {models_to_try}: {last_exception}")