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
agents/reviewer.py ADDED
@@ -0,0 +1,374 @@
1
+ import os
2
+ import json
3
+ import re
4
+ import config
5
+ from tools.lesson_manager import get_promoted_lessons_prompt, create_observation
6
+ from tools.skill_manager import get_combined_agent_skill
7
+
8
+ try:
9
+ from tools.llm_client import safe_chat_completion
10
+ except ImportError:
11
+ from llm_client import safe_chat_completion
12
+
13
+
14
+ def get_reviewer_model() -> str:
15
+ if hasattr(config, "get_active_model") and callable(getattr(config, "get_active_model")):
16
+ return config.get_active_model("reviewer")
17
+ if hasattr(config, "get_agent_model") and callable(getattr(config, "get_agent_model")):
18
+ return config.get_agent_model("reviewer")
19
+ return getattr(config, "MODEL_REVIEWER", "google/gemini-2.5-pro")
20
+
21
+
22
+ def resolve_sandboxed_path(target_file: str, project_dir: str = ".") -> str:
23
+ base_path = os.path.abspath(project_dir)
24
+ target_path = os.path.abspath(os.path.join(base_path, target_file))
25
+ try:
26
+ contained = os.path.commonpath([base_path, target_path]) == base_path
27
+ except ValueError:
28
+ contained = False
29
+ if not contained:
30
+ raise PermissionError(f"SANDBOX VIOLATION: Reviewer path '{target_file}' escapes boundary.")
31
+ return target_path
32
+
33
+
34
+ def _try_partial_json_extract(text: str) -> dict:
35
+ result: dict = {}
36
+ m = re.search(r'"is_approved"\s*:\s*(true|false)', text, re.IGNORECASE)
37
+ if m:
38
+ result["is_approved"] = m.group(1).lower() == "true"
39
+ m = re.search(r'"verdict"\s*:\s*"(ACCEPT|REJECT)"', text, re.IGNORECASE)
40
+ if m:
41
+ result["verdict"] = m.group(1).upper()
42
+ m = re.search(r'"reason"\s*:\s*"(.*)$', text, re.DOTALL)
43
+ if m:
44
+ raw = m.group(1)
45
+ cleaned = re.split(r'(?<!\\)"', raw, maxsplit=1)[0]
46
+ result["reason"] = cleaned.strip()
47
+ return result
48
+
49
+
50
+ def parse_reviewer_payload(raw_text: str) -> dict:
51
+ if not raw_text or not isinstance(raw_text, str):
52
+ return {
53
+ "is_approved": False,
54
+ "verdict": "REJECT",
55
+ "root_cause": "Empty model output",
56
+ "reason": "Reviewer received empty text from LLM."
57
+ }
58
+
59
+ text = raw_text.strip()
60
+
61
+ fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
62
+ if fence_match:
63
+ try:
64
+ return json.loads(fence_match.group(1))
65
+ except json.JSONDecodeError:
66
+ pass
67
+
68
+ start = text.find("{")
69
+ end = text.rfind("}")
70
+ if start != -1 and end != -1 and end > start:
71
+ try:
72
+ return json.loads(text[start:end + 1])
73
+ except json.JSONDecodeError:
74
+ pass
75
+
76
+ try:
77
+ return json.loads(text)
78
+ except json.JSONDecodeError:
79
+ pass
80
+
81
+ if text.startswith("{") or text.startswith("```"):
82
+ partial = _try_partial_json_extract(text)
83
+ if "is_approved" in partial or "verdict" in partial:
84
+ return {
85
+ "is_approved": partial.get("is_approved", False),
86
+ "verdict": partial.get("verdict", "REJECT"),
87
+ "reason": partial.get("reason", text[:200]),
88
+ "_partial_parse": True,
89
+ }
90
+
91
+ upper_text = text.upper()
92
+ is_accept = any(kw in upper_text for kw in [
93
+ "STATUS:** APPROVED",
94
+ "STATUS: APPROVED",
95
+ "VERDICT: ACCEPT",
96
+ "VERDICT: APPROVED",
97
+ '"IS_APPROVED": TRUE',
98
+ "\"VERDICT\": \"ACCEPT\""
99
+ ]) or (
100
+ "ACCEPT" in upper_text
101
+ and "REJECT" not in upper_text
102
+ and "DO NOT ACCEPT" not in upper_text
103
+ and "NOT ACCEPT" not in upper_text
104
+ and "CANNOT ACCEPT" not in upper_text
105
+ and "REFUSE" not in upper_text
106
+ )
107
+
108
+ reason_match = re.search(r"\*\*Analysis:\*\*\s*(.+)", text, re.DOTALL)
109
+ clean_reason = reason_match.group(1).strip() if reason_match else text[:300]
110
+
111
+ return {
112
+ "is_approved": is_accept,
113
+ "verdict": "ACCEPT" if is_accept else "REJECT",
114
+ "root_cause": "Parsed from Markdown text response." if is_accept else "Reviewer rejected changes.",
115
+ "reason": clean_reason
116
+ }
117
+
118
+ def run_reviewer(state, project_dir=".", session_context="", coherence_report="", visual_context=""):
119
+ """
120
+ Run the reviewer agent.
121
+
122
+ Uses Tiered Context Assembly to bound token usage. The reviewed code
123
+ is rendered as a unified diff when possible (10-30x smaller than the
124
+ full file for small edits), falling back to a sliced view when no
125
+ prior content is available (new files).
126
+
127
+ Also fixes a stray `task_spec=...` no-op assignment that previously
128
+ appeared after the safe_chat_completion call.
129
+ """
130
+ from tools.context_assembler import (
131
+ ContextBundle,
132
+ Tier,
133
+ TierItem,
134
+ ContextAssembler,
135
+ smart_slice_code,
136
+ dedupe_logs,
137
+ render_code_diff,
138
+ )
139
+
140
+ # ---- Lessons (only when session_context is missing) ----
141
+ if session_context:
142
+ promoted_lessons = ""
143
+ else:
144
+ from tools.lesson_manager import get_ranked_lessons_for_role
145
+
146
+ retrieval_query = " ".join(filter(None, [
147
+ str(state.get("active_subtask") or ""),
148
+ str(state.get("research_analysis") or ""),
149
+ str(state.get("goal") or ""),
150
+ str(state.get("target_file") or ""),
151
+ ])).strip() or "code review"
152
+
153
+ _, lessons_block, _ = get_ranked_lessons_for_role(
154
+ role="reviewer",
155
+ query=retrieval_query,
156
+ limit=getattr(config, "LESSONS_RETRIEVAL_LIMIT", 5),
157
+ )
158
+ promoted_lessons = lessons_block or get_promoted_lessons_prompt()
159
+
160
+ iteration_logs = state.get("iteration_logs") or state.get("compiler_output", "") or ""
161
+ skill_prompt = get_combined_agent_skill("reviewer", project_dir)
162
+
163
+ # System prompt — STRICT JSON only
164
+ coherence_rule = ""
165
+ if coherence_report:
166
+ coherence_rule = (
167
+ "\n- If the COHERENCE REPORT below contains any ERROR-level finding, "
168
+ "you MUST set is_approved to false. Cross-file coherence violations "
169
+ "are blocking and cannot be overridden by style or completeness."
170
+ )
171
+
172
+ system_prompt = (
173
+ f"{skill_prompt}\n\n"
174
+ f"CRITICAL REQUIREMENT:\n"
175
+ f"You MUST respond ONLY with a raw, valid JSON object matching this "
176
+ f"schema:\n"
177
+ f'{{"is_approved": true, "verdict": "ACCEPT", "reason": "Short summary."}}\n'
178
+ f"Rules:\n"
179
+ f"- reason MUST be under 150 characters.\n"
180
+ f"- Do NOT write Markdown headers or prose outside the JSON.\n"
181
+ f"- Do NOT nest JSON inside fields.\n"
182
+ f"- The first character of your response MUST be {{ and the last MUST be }}."
183
+ f"{coherence_rule}"
184
+ )
185
+
186
+ compiler_log = str(state.get("compiler_output", ""))
187
+ target_file = state.get("active_target_file") or state.get("target_file")
188
+
189
+ generated_data = state.get("generated_code")
190
+ code_content = (
191
+ generated_data.get("code")
192
+ if isinstance(generated_data, dict) else generated_data
193
+ )
194
+ apply_result = (
195
+ generated_data.get("apply_result")
196
+ if isinstance(generated_data, dict) else None
197
+ )
198
+
199
+ if target_file and not code_content:
200
+ try:
201
+ safe_target_path = resolve_sandboxed_path(target_file, project_dir)
202
+ if os.path.exists(safe_target_path):
203
+ with open(safe_target_path, "r", encoding="utf-8") as f:
204
+ code_content = f.read()
205
+ except PermissionError as pe:
206
+ code_content = f"SANDBOX REJECTION: {pe}"
207
+
208
+ # Pruned state — NO code here (code lives in T2)
209
+ pruned_state = {
210
+ "iteration": state.get("iteration_count", 0),
211
+ "active_subtask": state.get("active_subtask", ""),
212
+ "target_file": target_file,
213
+ }
214
+ if isinstance(apply_result, dict):
215
+ pruned_state["patch_apply_status"] = {
216
+ "ok": apply_result.get("ok"),
217
+ "mode": apply_result.get("mode"),
218
+ "matched": apply_result.get("matched"),
219
+ "failed": apply_result.get("failed"),
220
+ }
221
+ injected_ids = state.get("active_lesson_node_ids") or []
222
+ if injected_ids:
223
+ pruned_state["injected_lesson_node_ids"] = list(injected_ids)
224
+
225
+ # ---- Build TCA bundle ----
226
+ bundle = ContextBundle()
227
+
228
+ # T0
229
+ bundle.add_text(Tier.T0_STATIC, "skills", system_prompt)
230
+
231
+ # T1: pruned state (spec, never cut)
232
+ bundle.add_text(
233
+ Tier.T1_ACTION_CORE,
234
+ "state",
235
+ f"=== STATE TO REVIEW ===\n{json.dumps(pruned_state, ensure_ascii=False)}",
236
+ )
237
+
238
+ # T2: the code under review — DIFF if possible, else sliced code
239
+ if code_content:
240
+ old_code = state.get("old_code_backup", "") or ""
241
+ diff_view = render_code_diff(old_code, code_content, target_file or "file")
242
+
243
+ if diff_view:
244
+ bundle.add_text(Tier.T2_TARGET_SLICE, "diff", diff_view)
245
+ else:
246
+ hints = " ".join(filter(None, [
247
+ str(state.get("active_subtask") or ""),
248
+ str(state.get("research_analysis") or "")[:400],
249
+ ]))
250
+ sliced = smart_slice_code(
251
+ code_content, target_file or "file", max_lines=200, hints=hints
252
+ )
253
+ tightly = smart_slice_code(
254
+ code_content, target_file or "file", max_lines=60, hints=hints
255
+ )
256
+ bundle.add(TierItem(
257
+ tier=Tier.T2_TARGET_SLICE,
258
+ key="code",
259
+ content=(
260
+ f"=== CODE TO REVIEW ({target_file}) ===\n{sliced}"
261
+ ),
262
+ fallback_content=(
263
+ f"=== CODE TO REVIEW ({target_file}) [HEAVILY TRUNCATED] ===\n{tightly}"
264
+ if tightly != sliced else ""
265
+ ),
266
+ optional=False,
267
+ ))
268
+
269
+ # T3: coherence (blocking) > visual > session
270
+ if coherence_report:
271
+ bundle.add_text(
272
+ Tier.T3_INTERFACE,
273
+ "coherence",
274
+ "=== COHERENCE REPORT (machine-generated, authoritative) ===\n"
275
+ + coherence_report,
276
+ )
277
+ if visual_context:
278
+ bundle.add_text(
279
+ Tier.T3_INTERFACE,
280
+ "visual",
281
+ "=== VISUAL REVIEW (machine-generated) ===\n" + visual_context,
282
+ )
283
+ if session_context:
284
+ bundle.add_text(Tier.T3_INTERFACE, "session", session_context)
285
+
286
+ # T4: lessons
287
+ if promoted_lessons:
288
+ bundle.add_text(
289
+ Tier.T4_ENRICHMENT,
290
+ "lessons",
291
+ f"=== RELEVANT LESSONS (A-SCORE RANKED) ===\n{promoted_lessons}",
292
+ )
293
+
294
+ # T5: deduped logs
295
+ if iteration_logs.strip():
296
+ deduped = dedupe_logs(iteration_logs, max_errors=5)
297
+ if deduped:
298
+ bundle.add_text(
299
+ Tier.T5_FEEDBACK,
300
+ "logs",
301
+ f"=== RECENT EXECUTION LOGS (deduplicated) ===\n{deduped}",
302
+ optional=True,
303
+ )
304
+
305
+ # ---- Assemble ----
306
+ budget = getattr(config, "TOKEN_BUDGET_REVIEWER", 14000)
307
+ assembled = ContextAssembler(
308
+ budget=budget,
309
+ safety_margin=getattr(config, "TCA_SAFETY_MARGIN", 0.9),
310
+ ).assemble(bundle)
311
+
312
+ if getattr(config, "TCA_LOG_SUMMARY", True):
313
+ try:
314
+ print(assembled.log_summary())
315
+ except Exception:
316
+ pass
317
+
318
+ # ---- Call LLM ----
319
+ active_model = get_reviewer_model()
320
+
321
+ response, provider = safe_chat_completion(
322
+ role_or_model="reviewer",
323
+ messages=[
324
+ {"role": "system", "content": assembled.system_prompt},
325
+ {"role": "user", "content": assembled.user_prompt},
326
+ ],
327
+ temperature=0.1,
328
+ max_tokens=4096,
329
+ task_spec=str(state.get("active_subtask") or state.get("target_file") or ""),
330
+ )
331
+
332
+ if not response or not getattr(response, "choices", None) or not response.choices:
333
+ raise RuntimeError("Reviewer: Empty response from model.")
334
+
335
+ result_text = (response.choices[0].message.content or "").strip()
336
+ result = parse_reviewer_payload(result_text)
337
+
338
+ raw_approved = result.get("is_approved")
339
+ if isinstance(raw_approved, str):
340
+ is_approved = raw_approved.lower() in ("true", "1", "yes")
341
+ elif isinstance(raw_approved, bool):
342
+ is_approved = raw_approved
343
+ else:
344
+ is_approved = str(result.get("verdict", "")).upper() == "ACCEPT"
345
+
346
+ result["is_approved"] = is_approved
347
+ result["verdict"] = "ACCEPT" if is_approved else "REJECT"
348
+
349
+ if not result.get("reason"):
350
+ result["reason"] = (
351
+ result.get("root_cause")
352
+ or result.get("fix_instruction")
353
+ or ("Edits approved successfully." if is_approved else "Edits rejected by reviewer.")
354
+ )
355
+
356
+ if not is_approved and result.get("create_observation"):
357
+ obs_file = create_observation(
358
+ root_cause=result.get("root_cause", "Reviewer rejected implementation"),
359
+ details={"fix_instruction": result.get("fix_instruction", "")},
360
+ )
361
+ print(f"[Lesson Manager] Observation created by Reviewer: {obs_file}")
362
+
363
+ state["review_result"] = result
364
+
365
+ usage = getattr(response, "usage", None)
366
+ usage_meta = type("Meta", (), {
367
+ "prompt_token_count": getattr(usage, "prompt_tokens", 0) or 0,
368
+ "candidates_token_count": getattr(usage, "completion_tokens", 0) or 0,
369
+ "raw_response": result_text,
370
+ "provider": provider,
371
+ "model_id": active_model,
372
+ })
373
+
374
+ return state, usage_meta