empathy-framework 3.8.3__py3-none-any.whl → 3.9.1__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 (50) hide show
  1. {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/METADATA +67 -7
  2. {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/RECORD +50 -39
  3. {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/top_level.txt +0 -4
  4. empathy_os/.empathy/costs.json +60 -0
  5. empathy_os/.empathy/discovery_stats.json +15 -0
  6. empathy_os/.empathy/workflow_runs.json +45 -0
  7. empathy_os/cli.py +372 -13
  8. empathy_os/cli_unified.py +111 -0
  9. empathy_os/config/xml_config.py +45 -3
  10. empathy_os/config.py +46 -2
  11. empathy_os/memory/control_panel.py +128 -8
  12. empathy_os/memory/long_term.py +26 -4
  13. empathy_os/memory/short_term.py +110 -0
  14. empathy_os/models/token_estimator.py +25 -0
  15. empathy_os/pattern_library.py +81 -8
  16. empathy_os/patterns/debugging/all_patterns.json +81 -0
  17. empathy_os/patterns/debugging/workflow_20260107_1770825e.json +77 -0
  18. empathy_os/patterns/refactoring_memory.json +89 -0
  19. empathy_os/telemetry/__init__.py +11 -0
  20. empathy_os/telemetry/cli.py +451 -0
  21. empathy_os/telemetry/usage_tracker.py +475 -0
  22. {test_generator → empathy_os/test_generator}/generator.py +1 -0
  23. empathy_os/tier_recommender.py +422 -0
  24. empathy_os/workflows/base.py +223 -23
  25. empathy_os/workflows/config.py +50 -5
  26. empathy_os/workflows/tier_tracking.py +408 -0
  27. {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/WHEEL +0 -0
  28. {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/entry_points.txt +0 -0
  29. {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/licenses/LICENSE +0 -0
  30. {hot_reload → empathy_os/hot_reload}/README.md +0 -0
  31. {hot_reload → empathy_os/hot_reload}/__init__.py +0 -0
  32. {hot_reload → empathy_os/hot_reload}/config.py +0 -0
  33. {hot_reload → empathy_os/hot_reload}/integration.py +0 -0
  34. {hot_reload → empathy_os/hot_reload}/reloader.py +0 -0
  35. {hot_reload → empathy_os/hot_reload}/watcher.py +0 -0
  36. {hot_reload → empathy_os/hot_reload}/websocket.py +0 -0
  37. {scaffolding → empathy_os/scaffolding}/README.md +0 -0
  38. {scaffolding → empathy_os/scaffolding}/__init__.py +0 -0
  39. {scaffolding → empathy_os/scaffolding}/__main__.py +0 -0
  40. {scaffolding → empathy_os/scaffolding}/cli.py +0 -0
  41. {test_generator → empathy_os/test_generator}/__init__.py +0 -0
  42. {test_generator → empathy_os/test_generator}/__main__.py +0 -0
  43. {test_generator → empathy_os/test_generator}/cli.py +0 -0
  44. {test_generator → empathy_os/test_generator}/risk_analyzer.py +0 -0
  45. {workflow_patterns → empathy_os/workflow_patterns}/__init__.py +0 -0
  46. {workflow_patterns → empathy_os/workflow_patterns}/behavior.py +0 -0
  47. {workflow_patterns → empathy_os/workflow_patterns}/core.py +0 -0
  48. {workflow_patterns → empathy_os/workflow_patterns}/output.py +0 -0
  49. {workflow_patterns → empathy_os/workflow_patterns}/registry.py +0 -0
  50. {workflow_patterns → empathy_os/workflow_patterns}/structural.py +0 -0
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
 
18
18
  import json
19
19
  import logging
20
+ import time
20
21
  import uuid
21
22
  from abc import ABC, abstractmethod
22
23
  from dataclasses import dataclass, field
@@ -53,6 +54,15 @@ from empathy_os.models import ModelTier as UnifiedModelTier
53
54
  # Import progress tracking
54
55
  from .progress import ProgressCallback, ProgressTracker
55
56
 
57
+ # Import telemetry tracking
58
+ try:
59
+ from empathy_os.telemetry import UsageTracker
60
+
61
+ TELEMETRY_AVAILABLE = True
62
+ except ImportError:
63
+ TELEMETRY_AVAILABLE = False
64
+ UsageTracker = None # type: ignore
65
+
56
66
  if TYPE_CHECKING:
57
67
  from .config import WorkflowConfig
58
68
  from .step_config import WorkflowStepConfig
@@ -376,6 +386,7 @@ class BaseWorkflow(ABC):
376
386
  progress_callback: ProgressCallback | None = None,
377
387
  cache: BaseCache | None = None,
378
388
  enable_cache: bool = True,
389
+ enable_tier_tracking: bool = True,
379
390
  ):
380
391
  """Initialize workflow with optional cost tracker, provider, and config.
381
392
 
@@ -394,6 +405,7 @@ class BaseWorkflow(ABC):
394
405
  cache: Optional cache instance. If None and enable_cache=True,
395
406
  auto-creates cache with one-time setup prompt.
396
407
  enable_cache: Whether to enable caching (default True).
408
+ enable_tier_tracking: Whether to enable automatic tier tracking (default True).
397
409
 
398
410
  """
399
411
  from .config import WorkflowConfig
@@ -416,6 +428,25 @@ class BaseWorkflow(ABC):
416
428
  self._enable_cache = enable_cache
417
429
  self._cache_setup_attempted = False
418
430
 
431
+ # Tier tracking support
432
+ self._enable_tier_tracking = enable_tier_tracking
433
+ self._tier_tracker = None
434
+
435
+ # Telemetry tracking (singleton instance)
436
+ self._telemetry_tracker: UsageTracker | None = None
437
+ self._enable_telemetry = True # Enable by default
438
+ if TELEMETRY_AVAILABLE and UsageTracker is not None:
439
+ try:
440
+ self._telemetry_tracker = UsageTracker.get_instance()
441
+ except (OSError, PermissionError) as e:
442
+ # File system errors - log but disable telemetry
443
+ logger.debug(f"Failed to initialize telemetry tracker (file system error): {e}")
444
+ self._enable_telemetry = False
445
+ except (AttributeError, TypeError, ValueError) as e:
446
+ # Configuration or initialization errors
447
+ logger.debug(f"Failed to initialize telemetry tracker (config error): {e}")
448
+ self._enable_telemetry = False
449
+
419
450
  # Load config if not provided
420
451
  self._config = config or WorkflowConfig.load()
421
452
 
@@ -475,15 +506,19 @@ class BaseWorkflow(ABC):
475
506
  auto_setup_cache()
476
507
  self._cache = create_cache()
477
508
  logger.info(f"Cache initialized for workflow: {self.name}")
478
- except ImportError:
509
+ except ImportError as e:
479
510
  # Hybrid cache dependencies not available, fall back to hash-only
480
511
  logger.info(
481
- "Using hash-only cache (install empathy-framework[cache] for semantic caching)"
512
+ f"Using hash-only cache (install empathy-framework[cache] for semantic caching): {e}"
482
513
  )
483
514
  self._cache = create_cache(cache_type="hash")
484
- except Exception:
485
- # Graceful degradation - disable cache if setup fails
486
- logger.warning("Cache setup failed, continuing without cache")
515
+ except (OSError, PermissionError) as e:
516
+ # File system errors - disable cache
517
+ logger.warning(f"Cache setup failed (file system error): {e}, continuing without cache")
518
+ self._enable_cache = False
519
+ except (ValueError, TypeError, AttributeError) as e:
520
+ # Configuration errors - disable cache
521
+ logger.warning(f"Cache setup failed (config error): {e}, continuing without cache")
487
522
  self._enable_cache = False
488
523
 
489
524
  async def _call_llm(
@@ -500,6 +535,7 @@ class BaseWorkflow(ABC):
500
535
  that respect the configured provider (anthropic, openai, google, etc.).
501
536
 
502
537
  Supports automatic caching to reduce API costs and latency.
538
+ Tracks telemetry for usage analysis and cost savings measurement.
503
539
 
504
540
  Args:
505
541
  tier: Model tier to use (CHEAP, CAPABLE, PREMIUM)
@@ -514,9 +550,13 @@ class BaseWorkflow(ABC):
514
550
  """
515
551
  from .step_config import WorkflowStepConfig
516
552
 
553
+ # Start timing for telemetry
554
+ start_time = time.time()
555
+
517
556
  # Determine stage name for cache key
518
557
  stage = stage_name or f"llm_call_{tier.value}"
519
558
  model = self.get_model_for_tier(tier)
559
+ cache_type = None
520
560
 
521
561
  # Try cache lookup if enabled
522
562
  if self._enable_cache and self._cache is not None:
@@ -527,15 +567,45 @@ class BaseWorkflow(ABC):
527
567
 
528
568
  if cached_response is not None:
529
569
  logger.debug(f"Cache hit for {self.name}:{stage}")
570
+ # Determine cache type
571
+ if hasattr(self._cache, "cache_type"):
572
+ ct = self._cache.cache_type # type: ignore
573
+ # Ensure it's a string (not a Mock object)
574
+ cache_type = str(ct) if ct and isinstance(ct, str) else "hash"
575
+ else:
576
+ cache_type = "hash" # Default assumption
577
+
578
+ # Track telemetry for cache hit
579
+ duration_ms = int((time.time() - start_time) * 1000)
580
+ in_tokens = cached_response["input_tokens"]
581
+ out_tokens = cached_response["output_tokens"]
582
+ cost = self._calculate_cost(tier, in_tokens, out_tokens)
583
+
584
+ self._track_telemetry(
585
+ stage=stage,
586
+ tier=tier,
587
+ model=model,
588
+ cost=cost,
589
+ tokens={"input": in_tokens, "output": out_tokens},
590
+ cache_hit=True,
591
+ cache_type=cache_type,
592
+ duration_ms=duration_ms,
593
+ )
594
+
530
595
  # Cached response is dict with content, input_tokens, output_tokens
531
596
  return (
532
597
  cached_response["content"],
533
598
  cached_response["input_tokens"],
534
599
  cached_response["output_tokens"],
535
600
  )
536
- except Exception:
537
- # Cache lookup failed - continue with LLM call
538
- logger.debug("Cache lookup failed, continuing with LLM call")
601
+ except (KeyError, TypeError, ValueError) as e:
602
+ # Malformed cache data - continue with LLM call
603
+ logger.debug(f"Cache lookup failed (malformed data): {e}, continuing with LLM call")
604
+ except (OSError, PermissionError) as e:
605
+ # File system errors - continue with LLM call
606
+ logger.debug(
607
+ f"Cache lookup failed (file system error): {e}, continuing with LLM call"
608
+ )
539
609
 
540
610
  # Create a step config for this call
541
611
  step = WorkflowStepConfig(
@@ -547,12 +617,27 @@ class BaseWorkflow(ABC):
547
617
  )
548
618
 
549
619
  try:
550
- content, in_tokens, out_tokens, _cost = await self.run_step_with_executor(
620
+ content, in_tokens, out_tokens, cost = await self.run_step_with_executor(
551
621
  step=step,
552
622
  prompt=user_message,
553
623
  system=system,
554
624
  )
555
625
 
626
+ # Calculate duration
627
+ duration_ms = int((time.time() - start_time) * 1000)
628
+
629
+ # Track telemetry for actual LLM call
630
+ self._track_telemetry(
631
+ stage=stage,
632
+ tier=tier,
633
+ model=model,
634
+ cost=cost,
635
+ tokens={"input": in_tokens, "output": out_tokens},
636
+ cache_hit=False,
637
+ cache_type=None,
638
+ duration_ms=duration_ms,
639
+ )
640
+
556
641
  # Store in cache if enabled
557
642
  if self._enable_cache and self._cache is not None:
558
643
  try:
@@ -564,21 +649,78 @@ class BaseWorkflow(ABC):
564
649
  }
565
650
  self._cache.put(self.name, stage, full_prompt, model, response_data)
566
651
  logger.debug(f"Cached response for {self.name}:{stage}")
567
- except Exception:
568
- # Cache storage failed - log but continue
569
- logger.debug("Failed to cache response")
652
+ except (OSError, PermissionError) as e:
653
+ # File system errors - log but continue
654
+ logger.debug(f"Failed to cache response (file system error): {e}")
655
+ except (ValueError, TypeError, KeyError) as e:
656
+ # Data serialization errors - log but continue
657
+ logger.debug(f"Failed to cache response (serialization error): {e}")
570
658
 
571
659
  return content, in_tokens, out_tokens
572
660
  except (ValueError, TypeError, KeyError) as e:
573
661
  # Invalid input or configuration errors
662
+ logger.warning(f"LLM call failed (invalid input): {e}")
574
663
  return f"Error calling LLM (invalid input): {e}", 0, 0
575
- except (TimeoutError, RuntimeError) as e:
576
- # Timeout or API errors
664
+ except (TimeoutError, RuntimeError, ConnectionError) as e:
665
+ # Timeout, API errors, or connection failures
666
+ logger.warning(f"LLM call failed (timeout/API/connection error): {e}")
577
667
  return f"Error calling LLM (timeout/API error): {e}", 0, 0
578
- except Exception:
668
+ except (OSError, PermissionError) as e:
669
+ # File system or permission errors
670
+ logger.warning(f"LLM call failed (file system error): {e}")
671
+ return f"Error calling LLM (file system error): {e}", 0, 0
672
+ except Exception as e:
579
673
  # INTENTIONAL: Graceful degradation - return error message rather than crashing workflow
580
- logger.exception("Unexpected error calling LLM")
581
- return "Error calling LLM: unexpected error", 0, 0
674
+ logger.exception(f"Unexpected error calling LLM: {e}")
675
+ return f"Error calling LLM: {type(e).__name__}", 0, 0
676
+
677
+ def _track_telemetry(
678
+ self,
679
+ stage: str,
680
+ tier: ModelTier,
681
+ model: str,
682
+ cost: float,
683
+ tokens: dict[str, int],
684
+ cache_hit: bool,
685
+ cache_type: str | None,
686
+ duration_ms: int,
687
+ ) -> None:
688
+ """Track telemetry for an LLM call.
689
+
690
+ Args:
691
+ stage: Stage name
692
+ tier: Model tier used
693
+ model: Model ID used
694
+ cost: Cost in USD
695
+ tokens: Dictionary with "input" and "output" token counts
696
+ cache_hit: Whether this was a cache hit
697
+ cache_type: Cache type if cache hit
698
+ duration_ms: Duration in milliseconds
699
+
700
+ """
701
+ if not self._enable_telemetry or self._telemetry_tracker is None:
702
+ return
703
+
704
+ try:
705
+ provider_str = getattr(self, "_provider_str", "unknown")
706
+ self._telemetry_tracker.track_llm_call(
707
+ workflow=self.name,
708
+ stage=stage,
709
+ tier=tier.value.upper(),
710
+ model=model,
711
+ provider=provider_str,
712
+ cost=cost,
713
+ tokens=tokens,
714
+ cache_hit=cache_hit,
715
+ cache_type=cache_type,
716
+ duration_ms=duration_ms,
717
+ )
718
+ except (AttributeError, TypeError, ValueError) as e:
719
+ # INTENTIONAL: Telemetry tracking failures should never crash workflows
720
+ logger.debug(f"Failed to track telemetry (config/data error): {e}")
721
+ except (OSError, PermissionError) as e:
722
+ # File system errors - log but never crash workflow
723
+ logger.debug(f"Failed to track telemetry (file system error): {e}")
582
724
 
583
725
  def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
584
726
  """Calculate cost for a stage."""
@@ -709,6 +851,20 @@ class BaseWorkflow(ABC):
709
851
  # Set run ID for telemetry correlation
710
852
  self._run_id = str(uuid.uuid4())
711
853
 
854
+ # Auto tier recommendation
855
+ if self._enable_tier_tracking:
856
+ try:
857
+ from .tier_tracking import WorkflowTierTracker
858
+
859
+ self._tier_tracker = WorkflowTierTracker(self.name, self.description)
860
+ files_affected = kwargs.get("files_affected") or kwargs.get("path")
861
+ if files_affected and not isinstance(files_affected, list):
862
+ files_affected = [str(files_affected)]
863
+ self._tier_tracker.show_recommendation(files_affected)
864
+ except Exception as e:
865
+ logger.debug(f"Tier tracking disabled: {e}")
866
+ self._enable_tier_tracking = False
867
+
712
868
  started_at = datetime.now()
713
869
  self._stages_run = []
714
870
  current_data = kwargs
@@ -793,6 +949,18 @@ class BaseWorkflow(ABC):
793
949
  task_type=f"workflow:{self.name}:{stage_name}",
794
950
  )
795
951
 
952
+ # Track telemetry for this stage
953
+ self._track_telemetry(
954
+ stage=stage_name,
955
+ tier=tier,
956
+ model=model_id,
957
+ cost=cost,
958
+ tokens={"input": input_tokens, "output": output_tokens},
959
+ cache_hit=False,
960
+ cache_type=None,
961
+ duration_ms=duration_ms,
962
+ )
963
+
796
964
  # Pass output to next stage
797
965
  current_data = output if isinstance(output, dict) else {"result": output}
798
966
 
@@ -802,16 +970,22 @@ class BaseWorkflow(ABC):
802
970
  logger.error(error)
803
971
  if self._progress_tracker:
804
972
  self._progress_tracker.fail_workflow(error)
805
- except (TimeoutError, RuntimeError) as e:
806
- # Timeout or API errors
807
- error = f"Workflow execution error (timeout/API): {e}"
973
+ except (TimeoutError, RuntimeError, ConnectionError) as e:
974
+ # Timeout, API errors, or connection failures
975
+ error = f"Workflow execution error (timeout/API/connection): {e}"
808
976
  logger.error(error)
809
977
  if self._progress_tracker:
810
978
  self._progress_tracker.fail_workflow(error)
811
- except Exception:
979
+ except (OSError, PermissionError) as e:
980
+ # File system or permission errors
981
+ error = f"Workflow execution error (file system): {e}"
982
+ logger.error(error)
983
+ if self._progress_tracker:
984
+ self._progress_tracker.fail_workflow(error)
985
+ except Exception as e:
812
986
  # INTENTIONAL: Workflow orchestration - catch all errors to report failure gracefully
813
- logger.exception("Unexpected error in workflow execution")
814
- error = "Workflow execution failed: unexpected error"
987
+ logger.exception(f"Unexpected error in workflow execution: {type(e).__name__}")
988
+ error = f"Workflow execution failed: {type(e).__name__}"
815
989
  if self._progress_tracker:
816
990
  self._progress_tracker.fail_workflow(error)
817
991
 
@@ -881,6 +1055,32 @@ class BaseWorkflow(ABC):
881
1055
  # Emit workflow telemetry to backend
882
1056
  self._emit_workflow_telemetry(result)
883
1057
 
1058
+ # Auto-save tier progression
1059
+ if self._enable_tier_tracking and self._tier_tracker:
1060
+ try:
1061
+ files_affected = kwargs.get("files_affected") or kwargs.get("path")
1062
+ if files_affected and not isinstance(files_affected, list):
1063
+ files_affected = [str(files_affected)]
1064
+
1065
+ # Determine bug type from workflow name
1066
+ bug_type_map = {
1067
+ "code-review": "code_quality",
1068
+ "bug-predict": "bug_prediction",
1069
+ "security-audit": "security_issue",
1070
+ "test-gen": "test_coverage",
1071
+ "refactor-plan": "refactoring",
1072
+ "health-check": "health_check",
1073
+ }
1074
+ bug_type = bug_type_map.get(self.name, "workflow_run")
1075
+
1076
+ self._tier_tracker.save_progression(
1077
+ workflow_result=result,
1078
+ files_affected=files_affected,
1079
+ bug_type=bug_type,
1080
+ )
1081
+ except Exception as e:
1082
+ logger.debug(f"Failed to save tier progression: {e}")
1083
+
884
1084
  return result
885
1085
 
886
1086
  def describe(self) -> str:
@@ -38,6 +38,48 @@ except ImportError:
38
38
  YAML_AVAILABLE = False
39
39
 
40
40
 
41
+ def _validate_file_path(path: str, allowed_dir: str | None = None) -> Path:
42
+ """Validate file path to prevent path traversal and arbitrary writes.
43
+
44
+ Args:
45
+ path: File path to validate
46
+ allowed_dir: Optional directory to restrict writes to
47
+
48
+ Returns:
49
+ Validated Path object
50
+
51
+ Raises:
52
+ ValueError: If path is invalid or unsafe
53
+ """
54
+ if not path or not isinstance(path, str):
55
+ raise ValueError("path must be a non-empty string")
56
+
57
+ # Check for null bytes
58
+ if "\x00" in path:
59
+ raise ValueError("path contains null bytes")
60
+
61
+ try:
62
+ resolved = Path(path).resolve()
63
+ except (OSError, RuntimeError) as e:
64
+ raise ValueError(f"Invalid path: {e}")
65
+
66
+ # Check if within allowed directory
67
+ if allowed_dir:
68
+ try:
69
+ allowed = Path(allowed_dir).resolve()
70
+ resolved.relative_to(allowed)
71
+ except ValueError:
72
+ raise ValueError(f"path must be within {allowed_dir}")
73
+
74
+ # Check for dangerous system paths
75
+ dangerous_paths = ["/etc", "/sys", "/proc", "/dev"]
76
+ for dangerous in dangerous_paths:
77
+ if str(resolved).startswith(dangerous):
78
+ raise ValueError(f"Cannot write to system directory: {dangerous}")
79
+
80
+ return resolved
81
+
82
+
41
83
  # Re-export for backward compatibility
42
84
  __all__ = [
43
85
  "DEFAULT_MODELS",
@@ -371,7 +413,10 @@ class WorkflowConfig:
371
413
 
372
414
  def save(self, path: str | Path) -> None:
373
415
  """Save configuration to file."""
374
- path = Path(path)
416
+ # Validate path first (convert Path to string for validation)
417
+ path_str = str(path)
418
+ validated_path = _validate_file_path(path_str)
419
+
375
420
  data = {
376
421
  "default_provider": self.default_provider,
377
422
  "workflow_providers": self.workflow_providers,
@@ -387,15 +432,15 @@ class WorkflowConfig:
387
432
  "audit_level": self.audit_level,
388
433
  }
389
434
 
390
- path.parent.mkdir(parents=True, exist_ok=True)
435
+ validated_path.parent.mkdir(parents=True, exist_ok=True)
391
436
 
392
- if path.suffix in (".yaml", ".yml"):
437
+ if validated_path.suffix in (".yaml", ".yml"):
393
438
  if not YAML_AVAILABLE:
394
439
  raise ImportError("PyYAML required for YAML config")
395
- with open(path, "w") as f:
440
+ with open(validated_path, "w") as f:
396
441
  yaml.dump(data, f, default_flow_style=False)
397
442
  else:
398
- with open(path, "w") as f:
443
+ with open(validated_path, "w") as f:
399
444
  json.dump(data, f, indent=2)
400
445
 
401
446