empathy-framework 3.8.2__py3-none-any.whl → 3.9.0__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 (51) hide show
  1. {empathy_framework-3.8.2.dist-info → empathy_framework-3.9.0.dist-info}/METADATA +55 -16
  2. {empathy_framework-3.8.2.dist-info → empathy_framework-3.9.0.dist-info}/RECORD +51 -40
  3. {empathy_framework-3.8.2.dist-info → empathy_framework-3.9.0.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/__init__.py +1 -1
  8. empathy_os/cli.py +372 -13
  9. empathy_os/cli_unified.py +111 -0
  10. empathy_os/config/xml_config.py +45 -3
  11. empathy_os/config.py +46 -2
  12. empathy_os/memory/control_panel.py +128 -8
  13. empathy_os/memory/long_term.py +26 -4
  14. empathy_os/memory/short_term.py +110 -0
  15. empathy_os/models/token_estimator.py +25 -0
  16. empathy_os/pattern_library.py +81 -8
  17. empathy_os/patterns/debugging/all_patterns.json +81 -0
  18. empathy_os/patterns/debugging/workflow_20260107_1770825e.json +77 -0
  19. empathy_os/patterns/refactoring_memory.json +89 -0
  20. empathy_os/telemetry/__init__.py +11 -0
  21. empathy_os/telemetry/cli.py +451 -0
  22. empathy_os/telemetry/usage_tracker.py +475 -0
  23. empathy_os/tier_recommender.py +422 -0
  24. empathy_os/workflows/base.py +220 -23
  25. empathy_os/workflows/config.py +50 -5
  26. empathy_os/workflows/tier_tracking.py +408 -0
  27. {empathy_framework-3.8.2.dist-info → empathy_framework-3.9.0.dist-info}/WHEEL +0 -0
  28. {empathy_framework-3.8.2.dist-info → empathy_framework-3.9.0.dist-info}/entry_points.txt +0 -0
  29. {empathy_framework-3.8.2.dist-info → empathy_framework-3.9.0.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}/generator.py +0 -0
  45. {test_generator → empathy_os/test_generator}/risk_analyzer.py +0 -0
  46. {workflow_patterns → empathy_os/workflow_patterns}/__init__.py +0 -0
  47. {workflow_patterns → empathy_os/workflow_patterns}/behavior.py +0 -0
  48. {workflow_patterns → empathy_os/workflow_patterns}/core.py +0 -0
  49. {workflow_patterns → empathy_os/workflow_patterns}/output.py +0 -0
  50. {workflow_patterns → empathy_os/workflow_patterns}/registry.py +0 -0
  51. {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,14 @@ 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
+ TELEMETRY_AVAILABLE = True
61
+ except ImportError:
62
+ TELEMETRY_AVAILABLE = False
63
+ UsageTracker = None # type: ignore
64
+
56
65
  if TYPE_CHECKING:
57
66
  from .config import WorkflowConfig
58
67
  from .step_config import WorkflowStepConfig
@@ -376,6 +385,7 @@ class BaseWorkflow(ABC):
376
385
  progress_callback: ProgressCallback | None = None,
377
386
  cache: BaseCache | None = None,
378
387
  enable_cache: bool = True,
388
+ enable_tier_tracking: bool = True,
379
389
  ):
380
390
  """Initialize workflow with optional cost tracker, provider, and config.
381
391
 
@@ -394,6 +404,7 @@ class BaseWorkflow(ABC):
394
404
  cache: Optional cache instance. If None and enable_cache=True,
395
405
  auto-creates cache with one-time setup prompt.
396
406
  enable_cache: Whether to enable caching (default True).
407
+ enable_tier_tracking: Whether to enable automatic tier tracking (default True).
397
408
 
398
409
  """
399
410
  from .config import WorkflowConfig
@@ -416,6 +427,25 @@ class BaseWorkflow(ABC):
416
427
  self._enable_cache = enable_cache
417
428
  self._cache_setup_attempted = False
418
429
 
430
+ # Tier tracking support
431
+ self._enable_tier_tracking = enable_tier_tracking
432
+ self._tier_tracker = None
433
+
434
+ # Telemetry tracking (singleton instance)
435
+ self._telemetry_tracker: UsageTracker | None = None
436
+ self._enable_telemetry = True # Enable by default
437
+ if TELEMETRY_AVAILABLE and UsageTracker is not None:
438
+ try:
439
+ self._telemetry_tracker = UsageTracker.get_instance()
440
+ except (OSError, PermissionError) as e:
441
+ # File system errors - log but disable telemetry
442
+ logger.debug(f"Failed to initialize telemetry tracker (file system error): {e}")
443
+ self._enable_telemetry = False
444
+ except (AttributeError, TypeError, ValueError) as e:
445
+ # Configuration or initialization errors
446
+ logger.debug(f"Failed to initialize telemetry tracker (config error): {e}")
447
+ self._enable_telemetry = False
448
+
419
449
  # Load config if not provided
420
450
  self._config = config or WorkflowConfig.load()
421
451
 
@@ -475,15 +505,19 @@ class BaseWorkflow(ABC):
475
505
  auto_setup_cache()
476
506
  self._cache = create_cache()
477
507
  logger.info(f"Cache initialized for workflow: {self.name}")
478
- except ImportError:
508
+ except ImportError as e:
479
509
  # Hybrid cache dependencies not available, fall back to hash-only
480
510
  logger.info(
481
- "Using hash-only cache (install empathy-framework[cache] for semantic caching)"
511
+ f"Using hash-only cache (install empathy-framework[cache] for semantic caching): {e}"
482
512
  )
483
513
  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")
514
+ except (OSError, PermissionError) as e:
515
+ # File system errors - disable cache
516
+ logger.warning(f"Cache setup failed (file system error): {e}, continuing without cache")
517
+ self._enable_cache = False
518
+ except (ValueError, TypeError, AttributeError) as e:
519
+ # Configuration errors - disable cache
520
+ logger.warning(f"Cache setup failed (config error): {e}, continuing without cache")
487
521
  self._enable_cache = False
488
522
 
489
523
  async def _call_llm(
@@ -500,6 +534,7 @@ class BaseWorkflow(ABC):
500
534
  that respect the configured provider (anthropic, openai, google, etc.).
501
535
 
502
536
  Supports automatic caching to reduce API costs and latency.
537
+ Tracks telemetry for usage analysis and cost savings measurement.
503
538
 
504
539
  Args:
505
540
  tier: Model tier to use (CHEAP, CAPABLE, PREMIUM)
@@ -514,9 +549,13 @@ class BaseWorkflow(ABC):
514
549
  """
515
550
  from .step_config import WorkflowStepConfig
516
551
 
552
+ # Start timing for telemetry
553
+ start_time = time.time()
554
+
517
555
  # Determine stage name for cache key
518
556
  stage = stage_name or f"llm_call_{tier.value}"
519
557
  model = self.get_model_for_tier(tier)
558
+ cache_type = None
520
559
 
521
560
  # Try cache lookup if enabled
522
561
  if self._enable_cache and self._cache is not None:
@@ -527,15 +566,43 @@ class BaseWorkflow(ABC):
527
566
 
528
567
  if cached_response is not None:
529
568
  logger.debug(f"Cache hit for {self.name}:{stage}")
569
+ # Determine cache type
570
+ if hasattr(self._cache, "cache_type"):
571
+ ct = self._cache.cache_type # type: ignore
572
+ # Ensure it's a string (not a Mock object)
573
+ cache_type = str(ct) if ct and isinstance(ct, str) else "hash"
574
+ else:
575
+ cache_type = "hash" # Default assumption
576
+
577
+ # Track telemetry for cache hit
578
+ duration_ms = int((time.time() - start_time) * 1000)
579
+ in_tokens = cached_response["input_tokens"]
580
+ out_tokens = cached_response["output_tokens"]
581
+ cost = self._calculate_cost(tier, in_tokens, out_tokens)
582
+
583
+ self._track_telemetry(
584
+ stage=stage,
585
+ tier=tier,
586
+ model=model,
587
+ cost=cost,
588
+ tokens={"input": in_tokens, "output": out_tokens},
589
+ cache_hit=True,
590
+ cache_type=cache_type,
591
+ duration_ms=duration_ms,
592
+ )
593
+
530
594
  # Cached response is dict with content, input_tokens, output_tokens
531
595
  return (
532
596
  cached_response["content"],
533
597
  cached_response["input_tokens"],
534
598
  cached_response["output_tokens"],
535
599
  )
536
- except Exception:
537
- # Cache lookup failed - continue with LLM call
538
- logger.debug("Cache lookup failed, continuing with LLM call")
600
+ except (KeyError, TypeError, ValueError) as e:
601
+ # Malformed cache data - continue with LLM call
602
+ logger.debug(f"Cache lookup failed (malformed data): {e}, continuing with LLM call")
603
+ except (OSError, PermissionError) as e:
604
+ # File system errors - continue with LLM call
605
+ logger.debug(f"Cache lookup failed (file system error): {e}, continuing with LLM call")
539
606
 
540
607
  # Create a step config for this call
541
608
  step = WorkflowStepConfig(
@@ -547,12 +614,27 @@ class BaseWorkflow(ABC):
547
614
  )
548
615
 
549
616
  try:
550
- content, in_tokens, out_tokens, _cost = await self.run_step_with_executor(
617
+ content, in_tokens, out_tokens, cost = await self.run_step_with_executor(
551
618
  step=step,
552
619
  prompt=user_message,
553
620
  system=system,
554
621
  )
555
622
 
623
+ # Calculate duration
624
+ duration_ms = int((time.time() - start_time) * 1000)
625
+
626
+ # Track telemetry for actual LLM call
627
+ self._track_telemetry(
628
+ stage=stage,
629
+ tier=tier,
630
+ model=model,
631
+ cost=cost,
632
+ tokens={"input": in_tokens, "output": out_tokens},
633
+ cache_hit=False,
634
+ cache_type=None,
635
+ duration_ms=duration_ms,
636
+ )
637
+
556
638
  # Store in cache if enabled
557
639
  if self._enable_cache and self._cache is not None:
558
640
  try:
@@ -564,21 +646,78 @@ class BaseWorkflow(ABC):
564
646
  }
565
647
  self._cache.put(self.name, stage, full_prompt, model, response_data)
566
648
  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")
649
+ except (OSError, PermissionError) as e:
650
+ # File system errors - log but continue
651
+ logger.debug(f"Failed to cache response (file system error): {e}")
652
+ except (ValueError, TypeError, KeyError) as e:
653
+ # Data serialization errors - log but continue
654
+ logger.debug(f"Failed to cache response (serialization error): {e}")
570
655
 
571
656
  return content, in_tokens, out_tokens
572
657
  except (ValueError, TypeError, KeyError) as e:
573
658
  # Invalid input or configuration errors
659
+ logger.warning(f"LLM call failed (invalid input): {e}")
574
660
  return f"Error calling LLM (invalid input): {e}", 0, 0
575
- except (TimeoutError, RuntimeError) as e:
576
- # Timeout or API errors
661
+ except (TimeoutError, RuntimeError, ConnectionError) as e:
662
+ # Timeout, API errors, or connection failures
663
+ logger.warning(f"LLM call failed (timeout/API/connection error): {e}")
577
664
  return f"Error calling LLM (timeout/API error): {e}", 0, 0
578
- except Exception:
665
+ except (OSError, PermissionError) as e:
666
+ # File system or permission errors
667
+ logger.warning(f"LLM call failed (file system error): {e}")
668
+ return f"Error calling LLM (file system error): {e}", 0, 0
669
+ except Exception as e:
579
670
  # 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
671
+ logger.exception(f"Unexpected error calling LLM: {e}")
672
+ return f"Error calling LLM: {type(e).__name__}", 0, 0
673
+
674
+ def _track_telemetry(
675
+ self,
676
+ stage: str,
677
+ tier: ModelTier,
678
+ model: str,
679
+ cost: float,
680
+ tokens: dict[str, int],
681
+ cache_hit: bool,
682
+ cache_type: str | None,
683
+ duration_ms: int,
684
+ ) -> None:
685
+ """Track telemetry for an LLM call.
686
+
687
+ Args:
688
+ stage: Stage name
689
+ tier: Model tier used
690
+ model: Model ID used
691
+ cost: Cost in USD
692
+ tokens: Dictionary with "input" and "output" token counts
693
+ cache_hit: Whether this was a cache hit
694
+ cache_type: Cache type if cache hit
695
+ duration_ms: Duration in milliseconds
696
+
697
+ """
698
+ if not self._enable_telemetry or self._telemetry_tracker is None:
699
+ return
700
+
701
+ try:
702
+ provider_str = getattr(self, "_provider_str", "unknown")
703
+ self._telemetry_tracker.track_llm_call(
704
+ workflow=self.name,
705
+ stage=stage,
706
+ tier=tier.value.upper(),
707
+ model=model,
708
+ provider=provider_str,
709
+ cost=cost,
710
+ tokens=tokens,
711
+ cache_hit=cache_hit,
712
+ cache_type=cache_type,
713
+ duration_ms=duration_ms,
714
+ )
715
+ except (AttributeError, TypeError, ValueError) as e:
716
+ # INTENTIONAL: Telemetry tracking failures should never crash workflows
717
+ logger.debug(f"Failed to track telemetry (config/data error): {e}")
718
+ except (OSError, PermissionError) as e:
719
+ # File system errors - log but never crash workflow
720
+ logger.debug(f"Failed to track telemetry (file system error): {e}")
582
721
 
583
722
  def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
584
723
  """Calculate cost for a stage."""
@@ -709,6 +848,20 @@ class BaseWorkflow(ABC):
709
848
  # Set run ID for telemetry correlation
710
849
  self._run_id = str(uuid.uuid4())
711
850
 
851
+ # Auto tier recommendation
852
+ if self._enable_tier_tracking:
853
+ try:
854
+ from .tier_tracking import WorkflowTierTracker
855
+
856
+ self._tier_tracker = WorkflowTierTracker(self.name, self.description)
857
+ files_affected = kwargs.get("files_affected") or kwargs.get("path")
858
+ if files_affected and not isinstance(files_affected, list):
859
+ files_affected = [str(files_affected)]
860
+ self._tier_tracker.show_recommendation(files_affected)
861
+ except Exception as e:
862
+ logger.debug(f"Tier tracking disabled: {e}")
863
+ self._enable_tier_tracking = False
864
+
712
865
  started_at = datetime.now()
713
866
  self._stages_run = []
714
867
  current_data = kwargs
@@ -793,6 +946,18 @@ class BaseWorkflow(ABC):
793
946
  task_type=f"workflow:{self.name}:{stage_name}",
794
947
  )
795
948
 
949
+ # Track telemetry for this stage
950
+ self._track_telemetry(
951
+ stage=stage_name,
952
+ tier=tier,
953
+ model=model_id,
954
+ cost=cost,
955
+ tokens={"input": input_tokens, "output": output_tokens},
956
+ cache_hit=False,
957
+ cache_type=None,
958
+ duration_ms=duration_ms,
959
+ )
960
+
796
961
  # Pass output to next stage
797
962
  current_data = output if isinstance(output, dict) else {"result": output}
798
963
 
@@ -802,16 +967,22 @@ class BaseWorkflow(ABC):
802
967
  logger.error(error)
803
968
  if self._progress_tracker:
804
969
  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}"
970
+ except (TimeoutError, RuntimeError, ConnectionError) as e:
971
+ # Timeout, API errors, or connection failures
972
+ error = f"Workflow execution error (timeout/API/connection): {e}"
808
973
  logger.error(error)
809
974
  if self._progress_tracker:
810
975
  self._progress_tracker.fail_workflow(error)
811
- except Exception:
976
+ except (OSError, PermissionError) as e:
977
+ # File system or permission errors
978
+ error = f"Workflow execution error (file system): {e}"
979
+ logger.error(error)
980
+ if self._progress_tracker:
981
+ self._progress_tracker.fail_workflow(error)
982
+ except Exception as e:
812
983
  # 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"
984
+ logger.exception(f"Unexpected error in workflow execution: {type(e).__name__}")
985
+ error = f"Workflow execution failed: {type(e).__name__}"
815
986
  if self._progress_tracker:
816
987
  self._progress_tracker.fail_workflow(error)
817
988
 
@@ -881,6 +1052,32 @@ class BaseWorkflow(ABC):
881
1052
  # Emit workflow telemetry to backend
882
1053
  self._emit_workflow_telemetry(result)
883
1054
 
1055
+ # Auto-save tier progression
1056
+ if self._enable_tier_tracking and self._tier_tracker:
1057
+ try:
1058
+ files_affected = kwargs.get("files_affected") or kwargs.get("path")
1059
+ if files_affected and not isinstance(files_affected, list):
1060
+ files_affected = [str(files_affected)]
1061
+
1062
+ # Determine bug type from workflow name
1063
+ bug_type_map = {
1064
+ "code-review": "code_quality",
1065
+ "bug-predict": "bug_prediction",
1066
+ "security-audit": "security_issue",
1067
+ "test-gen": "test_coverage",
1068
+ "refactor-plan": "refactoring",
1069
+ "health-check": "health_check",
1070
+ }
1071
+ bug_type = bug_type_map.get(self.name, "workflow_run")
1072
+
1073
+ self._tier_tracker.save_progression(
1074
+ workflow_result=result,
1075
+ files_affected=files_affected,
1076
+ bug_type=bug_type,
1077
+ )
1078
+ except Exception as e:
1079
+ logger.debug(f"Failed to save tier progression: {e}")
1080
+
884
1081
  return result
885
1082
 
886
1083
  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