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,570 @@
1
+ """
2
+ tools/lesson_manager.py
3
+ -----------------------
4
+ File-backed lesson storage (source of truth) + A-Score retrieval bridge.
5
+
6
+ All existing file APIs are preserved unchanged. New APIs expose ranked
7
+ retrieval and the feedback loop via LessonMemoryBridge.
8
+
9
+ Trigger semantics:
10
+ - Retrieval functions default to `record_trigger=True` (backward-compatible).
11
+ - Pure context-builders (handshake) MUST pass `record_trigger=False`.
12
+ - Callers who build an actual LLM prompt SHOULD call `record_lesson_trigger()`
13
+ explicitly, once, with the lesson node IDs that ended up in the prompt.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import glob
19
+ import json
20
+ import os
21
+ import uuid
22
+ from datetime import datetime
23
+ from difflib import SequenceMatcher
24
+ from typing import Any, Dict, List, Optional, Sequence
25
+
26
+ import config
27
+
28
+ BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
29
+
30
+ LESSONS_DIRS: Dict[str, str] = {
31
+ "observations": os.path.join(BASE_DIR, "lessons", "observations"),
32
+ "candidates": os.path.join(BASE_DIR, "lessons", "candidates"),
33
+ "promoted": os.path.join(BASE_DIR, "lessons", "promoted"),
34
+ "deprecated": os.path.join(BASE_DIR, "lessons", "deprecated"),
35
+ }
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # File layer
40
+ # ---------------------------------------------------------------------------
41
+ def init_lessons_dirs() -> None:
42
+ for path in LESSONS_DIRS.values():
43
+ os.makedirs(path, exist_ok=True)
44
+
45
+
46
+ init_lessons_dirs()
47
+
48
+
49
+ def is_duplicate_observation(
50
+ new_root_cause: str, new_details: dict, threshold: float = 0.85
51
+ ) -> bool:
52
+ obs_dir = LESSONS_DIRS["observations"]
53
+ if not os.path.exists(obs_dir):
54
+ return False
55
+
56
+ new_rule_str = json.dumps(new_details or {}, sort_keys=True).lower()
57
+ new_cause_str = str(new_root_cause).lower()
58
+
59
+ for file_path in glob.glob(os.path.join(obs_dir, "*.json")):
60
+ try:
61
+ with open(file_path, "r", encoding="utf-8") as f:
62
+ existing = json.load(f)
63
+ existing_cause = str(existing.get("root_cause", "")).lower()
64
+ existing_rule_str = json.dumps(
65
+ existing.get("details", {}), sort_keys=True
66
+ ).lower()
67
+ cause_sim = SequenceMatcher(None, new_cause_str, existing_cause).ratio()
68
+ rule_sim = SequenceMatcher(None, new_rule_str, existing_rule_str).ratio()
69
+ if cause_sim > threshold or rule_sim > threshold:
70
+ return True
71
+ except Exception:
72
+ continue
73
+ return False
74
+
75
+
76
+ def create_observation(root_cause: str, details: dict = None) -> Optional[str]:
77
+ if is_duplicate_observation(root_cause, details):
78
+ print(f"[Lesson Manager] Skipped duplicate observation: '{root_cause}'")
79
+ return None
80
+
81
+ init_lessons_dirs()
82
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
83
+ unique_id = uuid.uuid4().hex[:6]
84
+ filename = f"observation_{timestamp}_{unique_id}.json"
85
+ filepath = os.path.join(LESSONS_DIRS["observations"], filename)
86
+
87
+ data = {
88
+ "id": unique_id,
89
+ "timestamp": datetime.now().isoformat(),
90
+ "root_cause": root_cause,
91
+ "details": details or {},
92
+ }
93
+
94
+ with open(filepath, "w", encoding="utf-8") as f:
95
+ json.dump(data, f, indent=2, ensure_ascii=False)
96
+
97
+ _invalidate_bridge(reason=f"observation created: {filename}")
98
+ return filename
99
+
100
+
101
+ def move_lesson(filename: str, source_category: str, target_category: str) -> bool:
102
+ init_lessons_dirs()
103
+ if source_category not in LESSONS_DIRS or target_category not in LESSONS_DIRS:
104
+ raise ValueError(f"Invalid category: {source_category} or {target_category}")
105
+
106
+ src_path = os.path.join(LESSONS_DIRS[source_category], filename)
107
+ dst_path = os.path.join(LESSONS_DIRS[target_category], filename)
108
+
109
+ if not os.path.exists(src_path):
110
+ return False
111
+
112
+ if os.path.exists(dst_path):
113
+ base, ext = os.path.splitext(filename)
114
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
115
+ dst_path = os.path.join(
116
+ LESSONS_DIRS[target_category], f"{base}_{stamp}{ext}"
117
+ )
118
+
119
+ os.rename(src_path, dst_path)
120
+ _invalidate_bridge(reason=f"lesson moved {source_category} → {target_category}")
121
+ return True
122
+
123
+
124
+ def move_to_candidates(filename: str, source_category: str = "observations") -> bool:
125
+ return move_lesson(filename, source_category, "candidates")
126
+
127
+
128
+ def move_to_promoted(filename: str, source_category: str = "candidates") -> bool:
129
+ return move_lesson(filename, source_category, "promoted")
130
+
131
+
132
+ def move_to_deprecated(filename: str, source_category: str = "promoted") -> bool:
133
+ return move_lesson(filename, source_category, "deprecated")
134
+
135
+
136
+ def load_lessons_from_category(category: str) -> List[dict]:
137
+ init_lessons_dirs()
138
+ if category not in LESSONS_DIRS:
139
+ return []
140
+ lessons = []
141
+ target_dir = LESSONS_DIRS[category]
142
+ for filename in sorted(os.listdir(target_dir)):
143
+ if not filename.endswith(".json"):
144
+ continue
145
+ try:
146
+ with open(os.path.join(target_dir, filename), "r", encoding="utf-8") as f:
147
+ data = json.load(f)
148
+ data["_filename"] = filename
149
+ lessons.append(data)
150
+ except Exception as e:
151
+ print(f"[Warning] Failed to parse lesson file {filename}: {e}")
152
+ return lessons
153
+
154
+
155
+ def load_promoted_lessons() -> List[dict]:
156
+ return load_lessons_from_category("promoted")
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Legacy prompt formatting
161
+ # ---------------------------------------------------------------------------
162
+ def format_lessons_category_prompt(category_name: str, header_title: str) -> str:
163
+ lessons = load_lessons_from_category(category_name)
164
+ if not lessons:
165
+ return f"No {category_name} items found."
166
+
167
+ formatted = [f"### {header_title} ###"]
168
+ for idx, item in enumerate(lessons, 1):
169
+ filename = item.get("_filename", "unknown.json")
170
+ cause = item.get("root_cause", "N/A")
171
+ details = item.get("details", {})
172
+ formatted.append(f"{idx}. [{filename}] Root Cause: {cause}")
173
+ if isinstance(details, dict):
174
+ for k, v in details.items():
175
+ formatted.append(f" - **{k}:** {v}")
176
+ elif details:
177
+ formatted.append(f" - {details}")
178
+ return "\n".join(formatted)
179
+
180
+
181
+ def get_promoted_lessons_prompt() -> str:
182
+ return format_lessons_category_prompt(
183
+ "promoted", "SYSTEM LESSONS & ARCHITECTURAL MEMORY (PROMOTED)"
184
+ )
185
+
186
+
187
+ def get_observations_prompt() -> str:
188
+ return format_lessons_category_prompt(
189
+ "observations", "RAW UNPROCESSED OBSERVATIONS"
190
+ )
191
+
192
+
193
+ def get_candidates_prompt() -> str:
194
+ return format_lessons_category_prompt(
195
+ "candidates", "LESSON CANDIDATES (PENDING PROMOTION)"
196
+ )
197
+
198
+
199
+ def get_deprecated_prompt() -> str:
200
+ return format_lessons_category_prompt(
201
+ "deprecated", "DEPRECATED LESSONS (HISTORICAL)"
202
+ )
203
+
204
+
205
+ def get_full_memory_prompt() -> str:
206
+ return (
207
+ f"{get_promoted_lessons_prompt()}\n\n"
208
+ f"{get_candidates_prompt()}\n\n"
209
+ f"{get_observations_prompt()}\n\n"
210
+ f"{get_deprecated_prompt()}"
211
+ )
212
+
213
+
214
+ def get_lessons_context_for_role(role_name: str) -> str:
215
+ role = (role_name or "").strip().lower()
216
+ if role in ("researcher", "superuser", "architect"):
217
+ return get_full_memory_prompt()
218
+ return get_promoted_lessons_prompt()
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Bridge integration
223
+ # ---------------------------------------------------------------------------
224
+ _bridge = None
225
+
226
+
227
+ def _invalidate_bridge(reason: str = "") -> None:
228
+ global _bridge
229
+ if _bridge is not None:
230
+ _bridge._needs_sync = True
231
+
232
+
233
+ def _get_bridge():
234
+ global _bridge
235
+ if not getattr(config, "LESSONS_MEMORY_ENABLED", True):
236
+ return None
237
+ if _bridge is None:
238
+ try:
239
+ from tools.lesson_memory_bridge import LessonMemoryBridge
240
+ except ImportError:
241
+ try:
242
+ from lesson_memory_bridge import LessonMemoryBridge
243
+ except ImportError:
244
+ print(
245
+ "[LessonManager] Bridge module not found — falling back to file mode."
246
+ )
247
+ return None
248
+
249
+ try:
250
+ _bridge = LessonMemoryBridge(
251
+ lessons_root=config.LESSONS_ROOT,
252
+ db_path=config.LESSONS_MEMORY_DB_PATH,
253
+ embedding_provider=None,
254
+ )
255
+ _bridge._needs_sync = True
256
+ except Exception as exc:
257
+ print(f"[LessonManager] Bridge init failed ({exc}) — using file mode.")
258
+ _bridge = None
259
+ return None
260
+
261
+ if getattr(_bridge, "_needs_sync", False):
262
+ try:
263
+ report = _bridge.sync_all()
264
+ if report.errors:
265
+ for err in report.errors:
266
+ print(f"[LessonManager] Sync warning: {err}")
267
+ _bridge._needs_sync = False
268
+ except Exception as exc:
269
+ print(f"[LessonManager] Sync failed ({exc}) — using cached index.")
270
+
271
+ return _bridge
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Ranked retrieval (with explicit record_trigger control)
276
+ # ---------------------------------------------------------------------------
277
+ def get_ranked_lessons_scored(
278
+ query: str,
279
+ limit: int = 5,
280
+ stages: Optional[Sequence[str]] = None,
281
+ scope_type=None,
282
+ scope_value: str = "vegex",
283
+ record_trigger: bool = True,
284
+ ):
285
+ """
286
+ A-Score rank lessons matching `query`, filtered by stage.
287
+
288
+ `record_trigger=True` increments T (trigger_count) and refreshes
289
+ last_used_at for every returned node. Callers that are *previewing*
290
+ context (handshake, debug commands) MUST pass record_trigger=False
291
+ to preserve determinism and avoid inflating T.
292
+ """
293
+ bridge = _get_bridge()
294
+ if bridge is None:
295
+ return []
296
+
297
+ from memory import MemoryScope
298
+
299
+ if stages is None:
300
+ stages = getattr(
301
+ config, "LESSONS_RETRIEVAL_STAGES_CONSUMER", ("promoted",)
302
+ )
303
+
304
+ try:
305
+ return bridge.search(
306
+ query=query,
307
+ limit=limit,
308
+ stages=stages,
309
+ scope_type=scope_type or MemoryScope.PROJECT,
310
+ scope_value=scope_value,
311
+ record_trigger=record_trigger,
312
+ )
313
+ except Exception as exc:
314
+ print(f"[LessonManager] Ranked search failed ({exc}).")
315
+ return []
316
+
317
+
318
+ def get_ranked_lessons_context(
319
+ query: str,
320
+ limit: int = 5,
321
+ stages: Optional[Sequence[str]] = None,
322
+ scope_value: str = "vegex",
323
+ record_trigger: bool = True,
324
+ ) -> str:
325
+ """
326
+ Same as get_ranked_lessons_scored, formatted for LLM injection.
327
+
328
+ Falls back to the legacy prompt formatter if the bridge is unavailable.
329
+ """
330
+ scored = get_ranked_lessons_scored(
331
+ query=query,
332
+ limit=limit,
333
+ stages=stages,
334
+ scope_value=scope_value,
335
+ record_trigger=record_trigger,
336
+ )
337
+
338
+ if not scored:
339
+ if stages and "promoted" in stages and len(stages) == 1:
340
+ return get_promoted_lessons_prompt()
341
+ return get_full_memory_prompt()
342
+
343
+ return format_scored_lessons_context(scored)
344
+
345
+
346
+ def format_scored_lessons_context(
347
+ scored,
348
+ header: str = "RELEVANT LESSONS (A-SCORE RANKED)",
349
+ ) -> str:
350
+ """
351
+ Render a List[ScoredMemoryNode] into the LLM-injection format.
352
+
353
+ Use this when you already have the scored list (e.g. so you can also
354
+ persist node IDs to state) — avoids retrieving lessons twice per cycle.
355
+ """
356
+ if not scored:
357
+ return ""
358
+ lines: List[str] = [
359
+ f"### {header} ###",
360
+ "Format: IF {trigger} THEN avoid {rule} BECAUSE {root_cause}",
361
+ "",
362
+ ]
363
+ for idx, s in enumerate(scored, 1):
364
+ node = s.node
365
+ md = node.metadata or {}
366
+ stage = md.get("stage", "unknown")
367
+ cause = md.get("root_cause", "N/A")
368
+ details = md.get("details", {}) or {}
369
+ rule = details.get("rule") or details.get("fix_instruction") or ""
370
+ trigger = details.get("trigger_condition") or details.get("context") or ""
371
+
372
+ lines.append(
373
+ f"{idx}. [stage={stage} | score={s.score:.4f} | "
374
+ f"sim={s.similarity:.3f} | T={node.trigger_count} "
375
+ f"H={node.helpful_count} V={node.violation_count}]"
376
+ )
377
+ if trigger and rule:
378
+ lines.append(f" IF {trigger}")
379
+ lines.append(f" THEN avoid {rule}")
380
+ lines.append(f" BECAUSE {cause}")
381
+ else:
382
+ lines.append(f" root_cause: {cause}")
383
+ for k, v in details.items():
384
+ lines.append(f" - {k}: {v}")
385
+ lines.append("")
386
+
387
+ return "\n".join(lines)
388
+
389
+
390
+ def get_ranked_lessons_for_role(
391
+ role: str,
392
+ query: str,
393
+ limit: int = 5,
394
+ record_trigger: bool = True,
395
+ ) -> tuple:
396
+ """
397
+ Role-aware ranked retrieval. Returns (scored_list, context_string, node_ids).
398
+
399
+ RBAC (matches get_lessons_context_for_role):
400
+ - researcher / superuser / architect → promoted + candidates + observations
401
+ - anyone else → promoted only
402
+
403
+ Pure context-builders (handshake) MUST pass record_trigger=False.
404
+ """
405
+ role_key = (role or "").strip().lower()
406
+ if role_key in ("researcher", "superuser", "architect"):
407
+ stages = getattr(
408
+ config,
409
+ "LESSONS_RETRIEVAL_STAGES_RESEARCHER",
410
+ ("promoted", "candidates", "observations"),
411
+ )
412
+ else:
413
+ stages = getattr(
414
+ config, "LESSONS_RETRIEVAL_STAGES_CONSUMER", ("promoted",)
415
+ )
416
+
417
+ scored = get_ranked_lessons_scored(
418
+ query=query,
419
+ limit=limit,
420
+ stages=stages,
421
+ record_trigger=record_trigger,
422
+ )
423
+ context = format_scored_lessons_context(scored)
424
+ node_ids = [s.node.node_id for s in scored]
425
+ return scored, context, node_ids
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # Feedback loop
430
+ # ---------------------------------------------------------------------------
431
+ def record_lesson_trigger(node_ids: Sequence[str]) -> int:
432
+ """
433
+ Explicitly increment T (trigger_count) and refresh last_used_at for
434
+ the given lesson node IDs. Call this once per actual LLM injection —
435
+ not from handshake, not from preview commands.
436
+ """
437
+ if not node_ids:
438
+ return 0
439
+ bridge = _get_bridge()
440
+ if bridge is None:
441
+ return 0
442
+ try:
443
+ return bridge.manager.record_trigger(list(node_ids))
444
+ except Exception as exc:
445
+ print(f"[LessonManager] record_trigger failed: {exc}")
446
+ return 0
447
+
448
+
449
+ def record_lesson_helpful(node_ids: Sequence[str]) -> int:
450
+ if not node_ids:
451
+ return 0
452
+ bridge = _get_bridge()
453
+ if bridge is None:
454
+ return 0
455
+ try:
456
+ return bridge.record_helpful(list(node_ids))
457
+ except Exception as exc:
458
+ print(f"[LessonManager] record_helpful failed: {exc}")
459
+ return 0
460
+
461
+
462
+ def record_lesson_violation(node_ids: Sequence[str]) -> int:
463
+ if not node_ids:
464
+ return 0
465
+ bridge = _get_bridge()
466
+ if bridge is None:
467
+ return 0
468
+ try:
469
+ return bridge.record_violation(list(node_ids))
470
+ except Exception as exc:
471
+ print(f"[LessonManager] record_violation failed: {exc}")
472
+ return 0
473
+
474
+
475
+ def analyze_lesson_outcome(
476
+ build_success: bool,
477
+ build_log: str,
478
+ injected_node_ids: Sequence[str],
479
+ keyword_min_hits: int = 2,
480
+ ) -> tuple:
481
+ """
482
+ Determine which injected lessons deserve H (helpful) vs V (violation).
483
+
484
+ Returns (helpful_ids, violated_ids).
485
+
486
+ Semantics per the VegeX blueprint §3B:
487
+ - H increments for every injected lesson when the build passes.
488
+ - V increments only for injected lessons whose distinctive keywords
489
+ appear in the build error output. This prevents penalising a lesson
490
+ that was injected but whose scope was unrelated to the failure.
491
+ """
492
+ if not injected_node_ids:
493
+ return [], []
494
+
495
+ if build_success:
496
+ return list(injected_node_ids), []
497
+
498
+ bridge = _get_bridge()
499
+ if bridge is None:
500
+ return [], []
501
+
502
+ log_lower = (build_log or "").lower()
503
+ if not log_lower.strip():
504
+ return [], []
505
+
506
+ import re as _re
507
+
508
+ violated: List[str] = []
509
+ for nid in injected_node_ids:
510
+ try:
511
+ node = bridge.manager.get(nid)
512
+ except Exception:
513
+ continue
514
+ md = node.metadata or {}
515
+ details = md.get("details", {}) or {}
516
+ text = " ".join([
517
+ str(md.get("root_cause", "")),
518
+ str(details.get("rule", "")),
519
+ str(details.get("fix_instruction", "")),
520
+ str(details.get("trigger_condition", "")),
521
+ ]).lower()
522
+
523
+ keywords = set(_re.findall(r"[a-z][a-z0-9_]{4,}", text))
524
+ if not keywords:
525
+ continue
526
+ hits = sum(1 for kw in keywords if kw in log_lower)
527
+ if hits >= keyword_min_hits:
528
+ violated.append(nid)
529
+
530
+ return [], violated
531
+
532
+
533
+ def lesson_memory_stats() -> Dict[str, Any]:
534
+ bridge = _get_bridge()
535
+ if bridge is None:
536
+ return {"enabled": False}
537
+ try:
538
+ st = bridge.stats()
539
+ st["enabled"] = True
540
+ return st
541
+ except Exception as exc:
542
+ return {"enabled": False, "error": str(exc)}
543
+
544
+
545
+ __all__ = [
546
+ "LESSONS_DIRS",
547
+ "init_lessons_dirs",
548
+ "create_observation",
549
+ "move_lesson",
550
+ "move_to_candidates",
551
+ "move_to_promoted",
552
+ "move_to_deprecated",
553
+ "load_lessons_from_category",
554
+ "load_promoted_lessons",
555
+ "get_promoted_lessons_prompt",
556
+ "get_candidates_prompt",
557
+ "get_observations_prompt",
558
+ "get_deprecated_prompt",
559
+ "get_full_memory_prompt",
560
+ "get_lessons_context_for_role",
561
+ "get_ranked_lessons_scored",
562
+ "get_ranked_lessons_context",
563
+ "get_ranked_lessons_for_role",
564
+ "format_scored_lessons_context",
565
+ "analyze_lesson_outcome",
566
+ "record_lesson_trigger",
567
+ "record_lesson_helpful",
568
+ "record_lesson_violation",
569
+ "lesson_memory_stats",
570
+ ]