浏览代码

Merge pull request #10 from cadenmackenzie/main

updating to main
Caden MacKenzie 8 月之前
父节点
当前提交
5968a93b6f

+ 80 - 17
.circleci/config.yml

@@ -27,12 +27,23 @@ commands:
             fi
             fi
 
 
             # Start first instance
             # Start first instance
-            HF_HOME="$(pwd)/.hf_cache_node1" DEBUG_DISCOVERY=7 DEBUG=7 exo --inference-engine <<parameters.inference_engine>> --node-id "node1" --listen-port 5678 --broadcast-port 5679 --chatgpt-api-port 8000 --chatgpt-api-response-timeout 900 --disable-tui 2>&1 | tee output1.log &
+            HF_HOME="$(pwd)/.hf_cache_node1" DEBUG_DISCOVERY=7 DEBUG=7 exo --inference-engine <<parameters.inference_engine>> \
+              --node-id "node1" --listen-port 5678 --broadcast-port 5679 --chatgpt-api-port 8000 \
+              --chatgpt-api-response-timeout 900 --disable-tui > output1.log &
             PID1=$!
             PID1=$!
+            tail -f output1.log &
+            TAIL1=$!
 
 
             # Start second instance
             # Start second instance
-            HF_HOME="$(pwd)/.hf_cache_node2" DEBUG_DISCOVERY=7 DEBUG=7 exo --inference-engine <<parameters.inference_engine>> --node-id "node2" --listen-port 5679 --broadcast-port 5678 --chatgpt-api-port 8001 --chatgpt-api-response-timeout 900 --disable-tui 2>&1 | tee output2.log &
+            HF_HOME="$(pwd)/.hf_cache_node2" DEBUG_DISCOVERY=7 DEBUG=7 exo --inference-engine <<parameters.inference_engine>> \
+              --node-id "node2" --listen-port 5679 --broadcast-port 5678 --chatgpt-api-port 8001 \
+              --chatgpt-api-response-timeout 900 --disable-tui > output2.log &
             PID2=$!
             PID2=$!
+            tail -f output2.log &
+            TAIL2=$!
+
+            # Remember to kill the tail processes at the end
+            trap 'kill $TAIL1 $TAIL2' EXIT
 
 
             # Wait for discovery
             # Wait for discovery
             sleep 10
             sleep 10
@@ -84,18 +95,22 @@ commands:
             kill $PID1 $PID2
             kill $PID1 $PID2
 
 
             echo ""
             echo ""
-            if ! echo "$response_1" | grep -q "<<parameters.expected_output>>" || ! echo "$response_2" | grep -q "<<parameters.expected_output>>"; then
-              echo "Test failed: Response does not contain '<<parameters.expected_output>>'"
-              echo "Response 1: $response_1"
+            # Extract content using jq and check if it contains expected output
+            content1=$(echo "$response_1" | jq -r '.choices[0].message.content')
+            content2=$(echo "$response_2" | jq -r '.choices[0].message.content')
+
+            if [[ "$content1" != *"<<parameters.expected_output>>"* ]] || [[ "$content2" != *"<<parameters.expected_output>>"* ]]; then
+              echo "Test failed: Response does not match '<<parameters.expected_output>>'"
+              echo "Response 1 content: $content1"
               echo ""
               echo ""
-              echo "Response 2: $response_2"
+              echo "Response 2 content: $content2"
               echo "Output of first instance:"
               echo "Output of first instance:"
               cat output1.log
               cat output1.log
               echo "Output of second instance:"
               echo "Output of second instance:"
               cat output2.log
               cat output2.log
               exit 1
               exit 1
             else
             else
-              echo "Test passed: Response from both nodes contains '<<parameters.expected_output>>'"
+              echo "Test passed: Response from both nodes matches '<<parameters.expected_output>>'"
             fi
             fi
 
 
 jobs:
 jobs:
@@ -211,18 +226,10 @@ jobs:
             pip install .
             pip install .
       - run_chatgpt_api_test:
       - run_chatgpt_api_test:
           inference_engine: dummy
           inference_engine: dummy
-          model_id: dummy-model
+          model_id: dummy
           prompt: "Dummy prompt."
           prompt: "Dummy prompt."
           expected_output: "dummy"
           expected_output: "dummy"
 
 
-  test_macos_m1:
-    macos:
-      xcode: "16.0.0"
-    resource_class: m2pro.large
-    steps:
-      - checkout
-      - run: system_profiler SPHardwareDataType
-
   chatgpt_api_integration_test_tinygrad:
   chatgpt_api_integration_test_tinygrad:
     macos:
     macos:
       xcode: "16.0.0"
       xcode: "16.0.0"
@@ -269,15 +276,71 @@ jobs:
           path: ./pipsize.json
           path: ./pipsize.json
           destination: pip-sizes.json
           destination: pip-sizes.json
 
 
+  check_line_count:
+    docker:
+      - image: cimg/python:3.10
+    steps:
+      - checkout
+
+      - run:
+          name: Setup git for PR comparison
+          command: |
+            if [[ -n "$CIRCLE_PULL_REQUEST" ]]; then
+              PR_NUMBER=$(echo $CIRCLE_PULL_REQUEST | rev | cut -d'/' -f1 | rev)
+              BASE_BRANCH=$(curl -s -H "Circle-Token: $CIRCLE_TOKEN" \
+                "https://circleci.com/api/v2/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pipeline/$CIRCLE_WORKFLOW_ID" \
+                | jq -r '.target_branch')
+
+              git clone -b $BASE_BRANCH --single-branch \
+                https://github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME.git \
+                base_branch
+            fi
+
+      - run:
+          name: Install dependencies
+          command: |
+            python -m pip install --upgrade pip
+            pip install tabulate
+
+      - run:
+          name: Run line count check
+          command: |
+            if [[ -n "$CIRCLE_PULL_REQUEST" ]]; then
+              python extra/line_counter.py base_branch .
+            else
+              python extra/line_counter.py .
+            fi
+
+      - store_artifacts:
+          path: line-count-snapshot.json
+          destination: line-count-snapshot.json
+
+      - store_artifacts:
+          path: line-count-diff.json
+          destination: line-count-diff.json
+
+      - run:
+          name: Create test results directory
+          command: |
+            mkdir -p test-results/line-count
+            cp line-count-*.json test-results/line-count/
+
+      - store_test_results:
+          path: test-results
 
 
 workflows:
 workflows:
   version: 2
   version: 2
   build_and_test:
   build_and_test:
     jobs:
     jobs:
+      - check_line_count:
+          filters:
+            branches:
+              only: /.*/
+            tags:
+              only: /.*/
       - unit_test
       - unit_test
       - discovery_integration_test
       - discovery_integration_test
       - chatgpt_api_integration_test_mlx
       - chatgpt_api_integration_test_mlx
       - chatgpt_api_integration_test_tinygrad
       - chatgpt_api_integration_test_tinygrad
       - chatgpt_api_integration_test_dummy
       - chatgpt_api_integration_test_dummy
-      - test_macos_m1
       - measure_pip_sizes
       - measure_pip_sizes

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+*.mp3 filter=lfs diff=lfs merge=lfs -text

+ 2 - 2
README.md

@@ -182,10 +182,10 @@ curl http://localhost:52415/v1/chat/completions \
 #### Device 1 (MacOS):
 #### Device 1 (MacOS):
 
 
 ```sh
 ```sh
-exo --inference-engine tinygrad
+exo
 ```
 ```
 
 
-Here we explicitly tell exo to use the **tinygrad** inference engine.
+Note: We don't need to explicitly tell exo to use the **tinygrad** inference engine. **MLX** and **tinygrad** are interoperable!
 
 
 #### Device 2 (Linux):
 #### Device 2 (Linux):
 ```sh
 ```sh

+ 9 - 18
exo/inference/dummy_inference_engine.py

@@ -1,14 +1,8 @@
 from typing import Optional, Tuple, TYPE_CHECKING
 from typing import Optional, Tuple, TYPE_CHECKING
 import numpy as np
 import numpy as np
-import random
-import string
-import asyncio
-import json
 from exo.inference.inference_engine import InferenceEngine
 from exo.inference.inference_engine import InferenceEngine
 from exo.inference.shard import Shard
 from exo.inference.shard import Shard
-def random_string(length: int):
-  return ''.join([random.choice(string.ascii_lowercase) for i in range(length)])
-  
+from exo.inference.tokenizers import DummyTokenizer
 
 
 class DummyInferenceEngine(InferenceEngine):
 class DummyInferenceEngine(InferenceEngine):
   def __init__(self):
   def __init__(self):
@@ -18,26 +12,23 @@ class DummyInferenceEngine(InferenceEngine):
     self.eos_token_id = 0
     self.eos_token_id = 0
     self.latency_mean = 0.1
     self.latency_mean = 0.1
     self.latency_stddev = 0.02
     self.latency_stddev = 0.02
+    self.num_generate_dummy_tokens = 10
+    self.tokenizer = DummyTokenizer()
 
 
   async def encode(self, shard: Shard, prompt: str) -> np.ndarray:
   async def encode(self, shard: Shard, prompt: str) -> np.ndarray:
-    return np.random.randint(1, self.vocab_size, size=(1, len(prompt.split())))
+    return np.array(self.tokenizer.encode(prompt))
   
   
   async def sample(self, x: np.ndarray) -> np.ndarray:
   async def sample(self, x: np.ndarray) -> np.ndarray:
-    return np.random.randint(1, self.vocab_size)
+    if x[0] > self.num_generate_dummy_tokens: return np.array([self.tokenizer.eos_token_id])
+    return x
 
 
   async def decode(self, shard: Shard, tokens: np.ndarray) -> str:
   async def decode(self, shard: Shard, tokens: np.ndarray) -> str:
-    return ' '.join([random_string(np.random.randint(1, 34)) for token in tokens])
+    return self.tokenizer.decode(tokens)
 
 
   async def infer_tensor(self, request_id: str, shard: Shard, input_data: np.ndarray) -> np.ndarray:
   async def infer_tensor(self, request_id: str, shard: Shard, input_data: np.ndarray) -> np.ndarray:
     await self.ensure_shard(shard)
     await self.ensure_shard(shard)
-    sequence_length = input_data.shape[0 if self.shard.is_first_layer() else 1]
-    output = np.random.random(size=(1, sequence_length, self.vocab_size if self.shard.is_last_layer() else self.hidden_size))
-    return output
+    return input_data + 1 if self.shard.is_last_layer() else input_data
 
 
   async def ensure_shard(self, shard: Shard):
   async def ensure_shard(self, shard: Shard):
-    if self.shard == shard:
-      return
-    # Simulate shard loading without making any API calls
-    await asyncio.sleep(0.1)  # Simulate a short delay
+    if self.shard == shard: return
     self.shard = shard
     self.shard = shard
-    print(f"DummyInferenceEngine: Simulated loading of shard {shard.model_id}")

+ 3 - 2
exo/inference/mlx/sharded_inference_engine.py

@@ -1,15 +1,16 @@
 import numpy as np
 import numpy as np
 import mlx.core as mx
 import mlx.core as mx
 import mlx.nn as nn
 import mlx.nn as nn
+from mlx_lm.sample_utils import top_p_sampling
 from ..inference_engine import InferenceEngine
 from ..inference_engine import InferenceEngine
 from .stateful_model import StatefulModel
 from .stateful_model import StatefulModel
-from .sharded_utils import load_shard, get_image_from_str
+from .sharded_utils import load_shard
 from ..shard import Shard
 from ..shard import Shard
 from typing import Dict, Optional, Tuple
 from typing import Dict, Optional, Tuple
 from exo.download.shard_download import ShardDownloader
 from exo.download.shard_download import ShardDownloader
 import asyncio
 import asyncio
 from concurrent.futures import ThreadPoolExecutor
 from concurrent.futures import ThreadPoolExecutor
-from functools import partial
+
 def sample_logits(
 def sample_logits(
   logits: mx.array,
   logits: mx.array,
   temp: float = 0.0,
   temp: float = 0.0,

+ 8 - 3
exo/inference/tokenizers.py

@@ -4,19 +4,24 @@ from os import PathLike
 from pathlib import Path
 from pathlib import Path
 from typing import Union
 from typing import Union
 from transformers import AutoTokenizer, AutoProcessor
 from transformers import AutoTokenizer, AutoProcessor
+import numpy as np
 from exo.download.hf.hf_helpers import get_local_snapshot_dir
 from exo.download.hf.hf_helpers import get_local_snapshot_dir
 from exo.helpers import DEBUG
 from exo.helpers import DEBUG
 
 
 
 
 class DummyTokenizer:
 class DummyTokenizer:
   def __init__(self):
   def __init__(self):
-    self.eos_token_id = 0
+    self.eos_token_id = 69
+    self.vocab_size = 1000
 
 
   def apply_chat_template(self, messages, tokenize=True, add_generation_prompt=True):
   def apply_chat_template(self, messages, tokenize=True, add_generation_prompt=True):
-    return [1, 2, 3]
+    return "dummy_tokenized_prompt"
+
+  def encode(self, text):
+    return np.array([1])
 
 
   def decode(self, tokens):
   def decode(self, tokens):
-    return "dummy"
+    return "dummy" * len(tokens)
 
 
 
 
 async def resolve_tokenizer(model_id: str):
 async def resolve_tokenizer(model_id: str):

+ 1 - 0
exo/orchestration/standard_node.py

@@ -360,6 +360,7 @@ class StandardNode(Node):
     return len(peers_added) > 0 or len(peers_removed) > 0 or len(peers_updated) > 0
     return len(peers_added) > 0 or len(peers_removed) > 0 or len(peers_updated) > 0
 
 
   async def select_best_inference_engine(self):
   async def select_best_inference_engine(self):
+    if self.inference_engine.__class__.__name__ == 'DummyInferenceEngine': return
     supported_engines = self.get_supported_inference_engines()
     supported_engines = self.get_supported_inference_engines()
     await self.broadcast_supported_engines(supported_engines)
     await self.broadcast_supported_engines(supported_engines)
     if len(self.get_topology_inference_engines()):
     if len(self.get_topology_inference_engines()):

+ 1101 - 0
extra/dashboard/dashboard.py

@@ -0,0 +1,1101 @@
+import os
+import json
+import logging
+import asyncio
+import aiohttp
+import pandas as pd
+import plotly.express as px
+from typing import List, Dict, Optional
+from pathlib import Path
+from plotly.subplots import make_subplots
+import plotly.graph_objects as go
+import time
+import pygame.mixer
+from datetime import datetime
+
+class AsyncCircleCIClient:
+    def __init__(self, token: str, project_slug: str):
+        self.token = token
+        self.project_slug = project_slug
+        self.base_url = "https://circleci.com/api/v2"
+        self.headers = {
+            "Circle-Token": token,
+            "Accept": "application/json"
+        }
+        self.logger = logging.getLogger("CircleCI")
+
+    async def get_json(self, session: aiohttp.ClientSession, url: str, params: Dict = None) -> Dict:
+        async with session.get(url, params=params) as response:
+            response.raise_for_status()
+            return await response.json()
+
+    async def get_recent_pipelines(
+        self,
+        session: aiohttp.ClientSession,
+        org_slug: str = None,
+        page_token: str = None,
+        limit: int = None,
+        branch: str = None
+    ):
+        """
+        Get recent pipelines for a project with pagination support
+
+        Args:
+            session: aiohttp client session
+            org_slug: Organization slug
+            page_token: Token for pagination
+            limit: Maximum number of pipelines to return
+            branch: Specific branch to fetch pipelines from
+        """
+
+        params = {
+            "branch": branch,
+            "page-token": page_token
+        }
+
+        # Remove None values
+        params = {k: v for k, v in params.items() if v is not None}
+
+        url = f"{self.base_url}/project/{self.project_slug}/pipeline"
+        data = await self.get_json(session, url, params)
+        pipelines = data["items"]
+
+        next_page_token = data.get("next_page_token")
+
+        # If there are more pages and we haven't hit the limit, recursively get them
+        if next_page_token and (limit is None or len(pipelines) < limit):
+            next_pipelines = await self.get_recent_pipelines(
+                session,
+                org_slug,
+                page_token=next_page_token,
+                limit=limit,
+                branch=branch
+            )
+            pipelines.extend(next_pipelines)
+
+        return pipelines
+
+    async def get_workflow_jobs(self, session: aiohttp.ClientSession, pipeline_id: str) -> List[Dict]:
+        self.logger.debug(f"Fetching workflows for pipeline {pipeline_id}")
+        url = f"{self.base_url}/pipeline/{pipeline_id}/workflow"
+        workflows_data = await self.get_json(session, url)
+        workflows = workflows_data["items"]
+
+        # Fetch all jobs for all workflows in parallel
+        jobs_tasks = []
+        for workflow in workflows:
+            url = f"{self.base_url}/workflow/{workflow['id']}/job"
+            jobs_tasks.append(self.get_json(session, url))
+
+        jobs_responses = await asyncio.gather(*jobs_tasks, return_exceptions=True)
+
+        all_jobs = []
+        for jobs_data in jobs_responses:
+            if isinstance(jobs_data, Exception):
+                continue
+            all_jobs.extend(jobs_data["items"])
+
+        return all_jobs
+
+    async def get_artifacts(self, session: aiohttp.ClientSession, job_number: str) -> List[Dict]:
+        url = f"{self.base_url}/project/{self.project_slug}/{job_number}/artifacts"
+        data = await self.get_json(session, url)
+        return data["items"]
+
+class PackageSizeTracker:
+    def __init__(self, token: str, project_slug: str, debug: bool = False):
+        self.setup_logging(debug)
+        self.client = AsyncCircleCIClient(token, project_slug)
+        self.logger = logging.getLogger("PackageSizeTracker")
+        self.last_data_hash = None
+        self.debug = debug
+
+        # Initialize pygame mixer
+        pygame.mixer.init()
+
+        # Sound file paths - can use MP3 files with pygame
+        sounds_dir = Path(__file__).parent / "sounds"
+        self.sounds = {
+            'lines_up': sounds_dir / "gta5_wasted.mp3",
+            'lines_down': sounds_dir / "pokemon_evolve.mp3",
+            'tokens_up': sounds_dir / "pokemon_evolve.mp3",
+            'tokens_down': sounds_dir / "gta5_wasted.mp3",
+            'size_up': sounds_dir / "gta5_wasted.mp3",
+            'size_down': sounds_dir / "pokemon_evolve.mp3"
+        }
+
+    def test_sound_effects(self):
+        """Test all sound effects with a small delay between each"""
+        self.logger.info("Testing sound effects...")
+        for sound_key in self.sounds:
+            self.logger.info(f"Playing {sound_key}")
+            self._play_sound(sound_key)
+            time.sleep(1)  # Wait 1 second between sounds
+
+    def setup_logging(self, debug: bool):
+        level = logging.DEBUG if debug else logging.INFO
+        logging.basicConfig(
+            level=level,
+            format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+            datefmt='%H:%M:%S'
+        )
+
+    def extract_commit_info(self, pipeline: Dict) -> Optional[Dict]:
+        try:
+            # Extract from github_app first (preferred)
+            if 'trigger_parameters' in pipeline and 'github_app' in pipeline['trigger_parameters']:
+                github_app = pipeline['trigger_parameters']['github_app']
+                return {
+                    'commit_hash': github_app.get('checkout_sha'),
+                    'web_url': f"{github_app.get('repo_url')}/commit/{github_app.get('checkout_sha')}",
+                    'branch': github_app.get('branch', 'unknown'),
+                    'author': {
+                        'name': github_app.get('commit_author_name'),
+                        'email': github_app.get('commit_author_email'),
+                        'username': github_app.get('user_username')
+                    },
+                    'message': github_app.get('commit_message')
+                }
+
+            # Fallback to git parameters
+            if 'trigger_parameters' in pipeline and 'git' in pipeline['trigger_parameters']:
+                git = pipeline['trigger_parameters']['git']
+                return {
+                    'commit_hash': git.get('checkout_sha'),
+                    'web_url': f"{git.get('repo_url')}/commit/{git.get('checkout_sha')}",
+                    'branch': git.get('branch', 'unknown'),
+                    'author': {
+                        'name': git.get('commit_author_name'),
+                        'email': git.get('commit_author_email'),
+                        'username': git.get('author_login')
+                    },
+                    'message': git.get('commit_message')
+                }
+
+            self.logger.warning(f"Could not find commit info in pipeline {pipeline['id']}")
+            return None
+
+        except Exception as e:
+            self.logger.error(f"Error extracting commit info: {str(e)}")
+            return None
+
+    async def process_pipeline(self, session: aiohttp.ClientSession, pipeline: Dict) -> Optional[Dict]:
+        try:
+            commit_info = self.extract_commit_info(pipeline)
+            if not commit_info:
+                return None
+
+            data_point = {
+                "commit_hash": commit_info['commit_hash'],
+                "commit_url": commit_info['web_url'],
+                "timestamp": pipeline.get("created_at", pipeline.get("updated_at")),
+                "pipeline_status": pipeline.get("state", "unknown"),
+                "branch": commit_info['branch'],
+                "author": commit_info['author'],
+                "commit_message": commit_info['message']
+            }
+
+            jobs = await self.client.get_workflow_jobs(session, pipeline["id"])
+
+            # Get package size data
+            size_job = next(
+                (j for j in jobs if j["name"] == "measure_pip_sizes" and j["status"] == "success"),
+                None
+            )
+
+            # Get line count data
+            linecount_job = next(
+                (j for j in jobs if j["name"] == "check_line_count" and j["status"] == "success"),
+                None
+            )
+
+            # Get benchmark data from runner job
+            benchmark_job = next(
+                (j for j in jobs if j["name"] == "runner" and j["status"] == "success"),
+                None
+            )
+
+            # Return None if no relevant jobs found
+            if not size_job and not linecount_job and not benchmark_job:
+                self.logger.debug(f"No relevant jobs found for pipeline {pipeline['id']}")
+                return None
+
+            # Process benchmark data if available
+            if benchmark_job:
+                benchmark_artifacts = await self.client.get_artifacts(session, benchmark_job["job_number"])
+                benchmark_report = next(
+                    (a for a in benchmark_artifacts if a["path"].endswith("benchmark.json")),
+                    None
+                )
+                if benchmark_report:
+                    benchmark_data = await self.client.get_json(session, benchmark_report["url"])
+                    data_point.update({
+                        "tokens_per_second": benchmark_data["tokens_per_second"],
+                        "time_to_first_token": benchmark_data.get("time_to_first_token", 0)
+                    })
+                    self.logger.info(
+                        f"Processed benchmark data for pipeline {pipeline['id']}: "
+                        f"commit {commit_info['commit_hash'][:7]}, "
+                        f"tokens/s {benchmark_data['tokens_per_second']:.2f}"
+                    )
+
+            # Process size data if available
+            if size_job:
+                size_artifacts = await self.client.get_artifacts(session, size_job["job_number"])
+                size_report = next(
+                    (a for a in size_artifacts if a["path"].endswith("pip-sizes.json")),
+                    None
+                )
+                if size_report:
+                    size_data = await self.client.get_json(session, size_report["url"])
+                    data_point.update({
+                        "total_size_mb": size_data["total_size_mb"],
+                        "packages": size_data["packages"]
+                    })
+                    self.logger.info(
+                        f"Processed size data for pipeline {pipeline['id']}: "
+                        f"commit {commit_info['commit_hash'][:7]}, "
+                        f"size {size_data['total_size_mb']:.2f}MB"
+                    )
+
+            # Process linecount data if available
+            if linecount_job:
+                linecount_artifacts = await self.client.get_artifacts(session, linecount_job["job_number"])
+                linecount_report = next(
+                    (a for a in linecount_artifacts if a["path"].endswith("line-count-snapshot.json")),
+                    None
+                )
+                if linecount_report:
+                    linecount_data = await self.client.get_json(session, linecount_report["url"])
+                    data_point.update({
+                        "total_lines": linecount_data["total_lines"],
+                        "total_files": linecount_data["total_files"],
+                        "files": linecount_data["files"]
+                    })
+                    self.logger.info(
+                        f"Processed line count data for pipeline {pipeline['id']}: "
+                        f"commit {commit_info['commit_hash'][:7]}, "
+                        f"lines {linecount_data['total_lines']:,}"
+                    )
+
+            return data_point
+
+        except Exception as e:
+            self.logger.error(f"Error processing pipeline {pipeline['id']}: {str(e)}")
+            return None
+
+    async def collect_data(self) -> List[Dict]:
+        self.logger.info("Starting data collection...")
+        async with aiohttp.ClientSession(headers=self.client.headers) as session:
+            # Get pipelines from both main and circleci branches
+            main_pipelines = await self.client.get_recent_pipelines(
+                session,
+                org_slug=self.client.project_slug,
+                limit=20,
+                branch="main"
+            )
+            circleci_pipelines = await self.client.get_recent_pipelines(
+                session,
+                org_slug=self.client.project_slug,
+                limit=20,
+                branch="circleci"
+            )
+
+            pipelines = main_pipelines + circleci_pipelines
+            # Sort pipelines by created_at date
+            pipelines.sort(key=lambda x: x.get("created_at", x.get("updated_at")), reverse=True)
+
+            self.logger.info(f"Found {len(pipelines)} recent pipelines")
+
+            # Process all pipelines in parallel
+            tasks = [self.process_pipeline(session, pipeline) for pipeline in pipelines]
+            results = await asyncio.gather(*tasks)
+
+            # Filter out None results
+            data_points = [r for r in results if r is not None]
+
+        return data_points
+
+    def generate_report(self, data: List[Dict], output_dir: str = "reports") -> Optional[str]:
+        self.logger.info("Generating report...")
+        if not data:
+            self.logger.error("No data to generate report from!")
+            return None
+
+        # Get latest pipeline status based on errors
+        latest_main_pipeline = next((d for d in data if d.get('branch') == 'main'), None)
+        latest_pipeline_status = 'success' if latest_main_pipeline and not latest_main_pipeline.get('errors') else 'failure'
+
+        # Log the pipeline status
+        if latest_main_pipeline:
+            self.logger.info(
+                f"Latest main branch pipeline status: {latest_pipeline_status} "
+                f"(commit: {latest_main_pipeline['commit_hash'][:7]})"
+            )
+        else:
+            self.logger.warning("No pipeline data found for main branch")
+
+        # Convert output_dir to Path object
+        output_dir = Path(output_dir)
+
+        # Create output directory if it doesn't exist
+        output_dir.mkdir(parents=True, exist_ok=True)
+
+        # Create separate dataframes for each metric
+        df_size = pd.DataFrame([d for d in data if 'total_size_mb' in d])
+        df_lines = pd.DataFrame([d for d in data if 'total_lines' in d])
+        df_benchmark = pd.DataFrame([d for d in data if 'tokens_per_second' in d])
+
+        # Create a single figure with subplots
+        fig = make_subplots(
+            rows=3, cols=2,
+            subplot_titles=('', 'Package Size', '', 'Line Count', '', 'Tokens per Second'),
+            vertical_spacing=0.2,
+            column_widths=[0.2, 0.8],
+            specs=[[{"type": "indicator"}, {"type": "scatter"}],
+                   [None, {"type": "scatter"}],
+                   [None, {"type": "scatter"}]]
+        )
+
+        # Add package size trace if we have data
+        if not df_size.empty:
+            df_size['timestamp'] = pd.to_datetime(df_size['timestamp'])
+            df_size = df_size.sort_values('timestamp')
+
+            fig.add_trace(
+                go.Scatter(
+                    x=df_size['timestamp'],
+                    y=df_size['total_size_mb'],
+                    mode='lines+markers',
+                    name='Package Size',
+                    customdata=df_size[['commit_hash', 'commit_url']].values,
+                    hovertemplate="<br>".join([
+                        "Size: %{y:.2f}MB",
+                        "Date: %{x}",
+                        "Commit: %{customdata[0]}",
+                        "<extra></extra>"
+                    ])
+                ),
+                row=1, col=2
+            )
+            fig.update_yaxes(title_text="Size (MB)", row=1, col=2)
+
+        # Add line count trace if we have data
+        if not df_lines.empty:
+            df_lines['timestamp'] = pd.to_datetime(df_lines['timestamp'])
+            df_lines = df_lines.sort_values('timestamp')
+
+            fig.add_trace(
+                go.Scatter(
+                    x=df_lines['timestamp'],
+                    y=df_lines['total_lines'],
+                    mode='lines+markers',
+                    name='Line Count',
+                    customdata=df_lines[['commit_hash', 'commit_url']].values,
+                    hovertemplate="<br>".join([
+                        "Lines: %{y:,.0f}",
+                        "Date: %{x}",
+                        "Commit: %{customdata[0]}",
+                        "<extra></extra>"
+                    ])
+                ),
+                row=2, col=2
+            )
+            fig.update_yaxes(title_text="Total Lines", row=2, col=2)
+
+        # Add tokens per second trace if we have data
+        if not df_benchmark.empty:
+            df_benchmark['timestamp'] = pd.to_datetime(df_benchmark['timestamp'])
+            df_benchmark = df_benchmark.sort_values('timestamp')
+
+            fig.add_trace(
+                go.Scatter(
+                    x=df_benchmark['timestamp'],
+                    y=df_benchmark['tokens_per_second'],
+                    mode='lines+markers',
+                    name='Tokens/Second',
+                    customdata=df_benchmark[['commit_hash', 'commit_url']].values,
+                    hovertemplate="<br>".join([
+                        "Tokens/s: %{y:.2f}",
+                        "Date: %{x}",
+                        "Commit: %{customdata[0]}",
+                        "<extra></extra>"
+                    ])
+                ),
+                row=3, col=2
+            )
+            fig.update_yaxes(title_text="Tokens per Second", row=3, col=2)
+
+        # Update layout
+        fig.update_layout(
+            height=800,
+            showlegend=False,
+            title_text="Package Metrics Dashboard",
+            title_x=0.5,
+            plot_bgcolor='white',
+            paper_bgcolor='white',
+            font=dict(size=12),
+            hovermode='x unified'
+        )
+
+        # Update the dashboard HTML with date range picker
+        dashboard_html = f"""
+        <html>
+        <head>
+            <title>Package Metrics Dashboard</title>
+            <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.css" />
+            <style>
+                body {{
+                    background-color: #f5f6fa;
+                    margin: 0;
+                    padding: 20px;
+                    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+                }}
+
+                .date-picker-container {{
+                    background: white;
+                    padding: 15px;
+                    border-radius: 12px;
+                    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+                    margin: 20px auto;
+                    width: fit-content;
+                }}
+
+                #daterange {{
+                    padding: 8px 12px;
+                    border: 1px solid #ddd;
+                    border-radius: 8px;
+                    font-size: 14px;
+                    width: 300px;
+                    cursor: pointer;
+                }}
+
+                .quick-ranges {{
+                    margin-top: 10px;
+                    display: flex;
+                    gap: 8px;
+                    justify-content: center;
+                }}
+
+                .quick-ranges button {{
+                    padding: 8px 16px;
+                    border: 1px solid #e1e4e8;
+                    border-radius: 8px;
+                    background: white;
+                    cursor: pointer;
+                    font-size: 13px;
+                    transition: all 0.2s ease;
+                }}
+
+                .quick-ranges button:hover {{
+                    background: #f0f0f0;
+                    transform: translateY(-1px);
+                }}
+
+                .dashboard-grid {{
+                    display: grid;
+                    grid-template-columns: 300px 1fr;
+                    gap: 20px;
+                    margin-top: 20px;
+                }}
+
+                .chart-container {{
+                    background: white;
+                    border-radius: 12px;
+                    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+                    padding: 20px;
+                    height: 350px;
+                }}
+
+                .chart-row {{
+                    display: grid;
+                    grid-template-columns: repeat(2, 1fr);
+                    gap: 20px;
+                }}
+
+                .chart-row-full {{
+                    grid-column: 2 / -1;
+                }}
+
+                .chart-box {{
+                    background: white;
+                    border-radius: 12px;
+                    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+                    padding: 20px;
+                    display: flex;
+                    flex-direction: column;
+                }}
+
+                .chart-title {{
+                    font-size: 16px;
+                    font-weight: 600;
+                    color: #2c3e50;
+                    margin-bottom: 15px;
+                    padding-bottom: 10px;
+                    border-bottom: 1px solid #eee;
+                }}
+
+                .status-container {{
+                    background: white;
+                    border-radius: 12px;
+                    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+                    padding: 20px;
+                    height: 350px;
+                    display: flex;
+                    flex-direction: column;
+                    align-items: center;
+                    justify-content: center;
+                }}
+
+                .traffic-light {{
+                    width: 150px;
+                    height: 150px;
+                    border-radius: 50%;
+                    margin: 20px;
+                    box-shadow: 0 0 20px rgba(0,0,0,0.2);
+                    position: relative;
+                }}
+
+                .traffic-light.success {{
+                    background: #2ecc71;  /* Bright green */
+                    border: 8px solid #27ae60;  /* Darker green border */
+                }}
+
+                .traffic-light.failure {{
+                    background: #e74c3c;  /* Bright red */
+                    border: 8px solid #c0392b;  /* Darker red border */
+                }}
+
+                .status-text {{
+                    font-size: 24px;
+                    font-weight: bold;
+                    margin-top: 20px;
+                    color: #2c3e50;
+                }}
+
+                /* Override Plotly's default margins */
+                .js-plotly-plot .plotly {{
+                    margin: 0 !important;
+                }}
+            </style>
+        </head>
+        <body>
+            <div class="date-picker-container">
+                <input type="text" id="daterange" />
+                <div class="quick-ranges">
+                    <button onclick="setQuickRange('1h')">Last Hour</button>
+                    <button onclick="setQuickRange('6h')">Last 6 Hours</button>
+                    <button onclick="setQuickRange('1d')">Last 24 Hours</button>
+                    <button onclick="setQuickRange('7d')">Last 7 Days</button>
+                    <button onclick="setQuickRange('30d')">Last 30 Days</button>
+                    <button onclick="setQuickRange('all')">All Time</button>
+                </div>
+            </div>
+
+            <div class="dashboard-grid">
+                <div class="status-container">
+                    <div class="chart-title">Pipeline Status</div>
+                    <div class="traffic-light {'success' if latest_pipeline_status == 'success' else 'failure'}"></div>
+                    <div class="status-text">
+                        {'✓ Pipeline Passing' if latest_pipeline_status == 'success' else '✗ Pipeline Failing'}
+                    </div>
+                </div>
+                <div class="chart-row">
+                    <div class="chart-box">
+                        <div class="chart-title">Package Size</div>
+                        <div id="size-chart"></div>
+                    </div>
+                    <div class="chart-box">
+                        <div class="chart-title">Line Count</div>
+                        <div id="lines-chart"></div>
+                    </div>
+                </div>
+                <div class="chart-row chart-row-full">
+                    <div class="chart-box">
+                        <div class="chart-title">Tokens per Second</div>
+                        <div id="tokens-chart"></div>
+                    </div>
+                </div>
+            </div>
+
+            <script type="text/javascript" src="https://cdn.jsdelivr.net/jquery/latest/jquery.min.js"></script>
+            <script type="text/javascript" src="https://cdn.jsdelivr.net/momentjs/latest/moment.min.js"></script>
+            <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.min.js"></script>
+            <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
+            <script>
+                let globalMinDate = null;
+                let globalMaxDate = null;
+
+                // Split the original figure into separate charts
+                const originalData = {fig.to_json()};
+
+                function initializeCharts() {{
+                    // Create the size trend chart
+                    const sizeTrace = originalData.data.find(trace => trace.name === 'Package Size');
+                    if (sizeTrace) {{
+                        Plotly.newPlot('size-chart',
+                            [sizeTrace],
+                            {{
+                                showlegend: false,
+                                height: 280,
+                                margin: {{ t: 10, b: 40, l: 50, r: 20 }},
+                                yaxis: {{ title: 'Size (MB)' }},
+                                xaxis: {{
+                                    type: 'date',
+                                    title: null,
+                                    range: [sizeTrace.x[0], sizeTrace.x[sizeTrace.x.length - 1]]
+                                }}
+                            }}
+                        );
+                    }}
+
+                    // Create the line count chart
+                    const lineTrace = originalData.data.find(trace => trace.name === 'Line Count');
+                    if (lineTrace) {{
+                        Plotly.newPlot('lines-chart',
+                            [lineTrace],
+                            {{
+                                showlegend: false,
+                                height: 280,
+                                margin: {{ t: 10, b: 40, l: 50, r: 20 }},
+                                yaxis: {{ title: 'Total Lines' }},
+                                xaxis: {{
+                                    type: 'date',
+                                    title: null,
+                                    range: [lineTrace.x[0], lineTrace.x[lineTrace.x.length - 1]]
+                                }}
+                            }}
+                        );
+                    }}
+
+                    // Create the tokens per second chart
+                    const tokensTrace = originalData.data.find(trace => trace.name === 'Tokens/Second');
+                    if (tokensTrace) {{
+                        Plotly.newPlot('tokens-chart',
+                            [tokensTrace],
+                            {{
+                                showlegend: false,
+                                height: 280,
+                                margin: {{ t: 10, b: 40, l: 50, r: 20 }},
+                                yaxis: {{ title: 'Tokens/Second' }},
+                                xaxis: {{
+                                    type: 'date',
+                                    title: null,
+                                    range: [tokensTrace.x[0], tokensTrace.x[tokensTrace.x.length - 1]]
+                                }}
+                            }}
+                        );
+                    }}
+
+                    // Add debug logs to check axis names
+                    console.log('Size Chart Layout:', document.getElementById('size-chart').layout);
+                    console.log('Lines Chart Layout:', document.getElementById('lines-chart').layout);
+                    console.log('Tokens Chart Layout:', document.getElementById('tokens-chart').layout);
+                }}
+
+                function setQuickRange(range) {{
+                    let start, end = moment();
+
+                    switch(range) {{
+                        case '1h':
+                            start = moment().subtract(1, 'hours');
+                            break;
+                        case '6h':
+                            start = moment().subtract(6, 'hours');
+                            break;
+                        case '1d':
+                            start = moment().subtract(1, 'days');
+                            break;
+                        case '7d':
+                            start = moment().subtract(7, 'days');
+                            break;
+                        case '30d':
+                            start = moment().subtract(30, 'days');
+                            break;
+                        case 'all':
+                            start = moment(globalMinDate);
+                            end = moment(globalMaxDate);
+                            break;
+                    }}
+
+                    $('#daterange').data('daterangepicker').setStartDate(start);
+                    $('#daterange').data('daterangepicker').setEndDate(end);
+                    updatePlotRange(start.toISOString(), end.toISOString());
+                }}
+
+                function updatePlotRange(startDate, endDate) {{
+                    console.log('Updating range:', startDate, endDate);
+
+                    // Get the actual x-axis names from the chart layouts
+                    const sizeChartLayout = document.getElementById('size-chart').layout;
+                    const sizeXAxisName = Object.keys(sizeChartLayout).find(key => key.startsWith('xaxis'));
+
+                    const linesChartLayout = document.getElementById('lines-chart').layout;
+                    const linesXAxisName = Object.keys(linesChartLayout).find(key => key.startsWith('xaxis'));
+
+                    const tokensChartLayout = document.getElementById('tokens-chart').layout;
+                    const tokensXAxisName = Object.keys(tokensChartLayout).find(key => key.startsWith('xaxis'));
+
+                    // Update the ranges
+                    const sizeUpdateLayout = {{}};
+                    sizeUpdateLayout[`{{sizeXAxisName}}.range`] = [startDate, endDate];
+
+                    const linesUpdateLayout = {{}};
+                    linesUpdateLayout[`{{linesXAxisName}}.range`] = [startDate, endDate];
+
+                    const tokensUpdateLayout = {{}};
+                    tokensUpdateLayout[`{{tokensXAxisName}}.range`] = [startDate, endDate];
+
+                    // Update both charts
+                    Plotly.relayout('size-chart', sizeUpdateLayout)
+                        .catch(err => console.error('Error updating size chart:', err));
+
+                    Plotly.relayout('lines-chart', linesUpdateLayout)
+                        .catch(err => console.error('Error updating lines chart:', err));
+
+                    Plotly.relayout('tokens-chart', tokensUpdateLayout)
+                        .catch(err => console.error('Error updating tokens chart:', err));
+                }}
+
+                function findDateRange(data) {{
+                    let minDate = null;
+                    let maxDate = null;
+
+                    data.forEach(trace => {{
+                        if (trace.x && trace.x.length > 0) {{
+                            const dates = trace.x.map(d => new Date(d));
+                            const traceMin = new Date(Math.min(...dates));
+                            const traceMax = new Date(Math.max(...dates));
+
+                            if (!minDate || traceMin < minDate) minDate = traceMin;
+                            if (!maxDate || traceMax > maxDate) maxDate = traceMax;
+                        }}
+                    }});
+
+                    return {{ minDate, maxDate }};
+                }}
+
+                // Initialize everything when document is ready
+                $(document).ready(function() {{
+                    // Initialize charts
+                    initializeCharts();
+
+                    // Find date range from data
+                    const {{ minDate, maxDate }} = findDateRange(originalData.data);
+                    globalMinDate = minDate;
+                    globalMaxDate = maxDate;
+
+                    // Initialize daterangepicker
+                    $('#daterange').daterangepicker({{
+                        startDate: minDate,
+                        endDate: maxDate,
+                        minDate: minDate,
+                        maxDate: maxDate,
+                        timePicker: true,
+                        timePicker24Hour: true,
+                        timePickerIncrement: 1,
+                        opens: 'center',
+                        locale: {{
+                            format: 'YYYY-MM-DD HH:mm',
+                            applyLabel: "Apply",
+                            cancelLabel: "Cancel",
+                            customRangeLabel: "Custom Range"
+                        }},
+                        ranges: {{
+                            'Last Hour': [moment().subtract(1, 'hours'), moment()],
+                            'Last 6 Hours': [moment().subtract(6, 'hours'), moment()],
+                            'Last 24 Hours': [moment().subtract(1, 'days'), moment()],
+                            'Last 7 Days': [moment().subtract(7, 'days'), moment()],
+                            'Last 30 Days': [moment().subtract(30, 'days'), moment()],
+                            'All Time': [moment(minDate), moment(maxDate)]
+                        }}
+                    }});
+
+                    // Update plots when date range changes
+                    $('#daterange').on('apply.daterangepicker', function(ev, picker) {{
+                        console.log('Date range changed:', picker.startDate.toISOString(), picker.endDate.toISOString());
+                        updatePlotRange(picker.startDate.toISOString(), picker.endDate.toISOString());
+                    }});
+
+                    // Add click handlers for charts
+                    ['size-chart', 'lines-chart', 'tokens-chart'].forEach(chartId => {{
+                        const chart = document.getElementById(chartId);
+                        if (chart) {{
+                            chart.on('plotly_click', function(data) {{
+                                const point = data.points[0];
+                                if (point.customdata && point.customdata[1]) {{
+                                    window.open(point.customdata[1], '_blank');
+                                }}
+                            }});
+                        }}
+                    }});
+
+                    // Add debug logging for chart initialization
+                    console.log('Size Chart:', document.getElementById('size-chart'));
+                    console.log('Lines Chart:', document.getElementById('lines-chart'));
+                    console.log('Tokens Chart:', document.getElementById('tokens-chart'));
+                }});
+            </script>
+        </body>
+        </html>
+        """
+
+        # Write the dashboard
+        dashboard_path = output_dir / "dashboard.html"
+        with open(dashboard_path, "w") as f:
+            f.write(dashboard_html)
+
+        # Generate summary with available metrics
+        latest_data = {}
+
+        if not df_size.empty:
+            latest = df_size.iloc[-1]
+            previous = df_size.iloc[-2] if len(df_size) > 1 else latest
+            size_change = float(latest['total_size_mb'] - previous['total_size_mb'])
+            latest_data.update({
+                'timestamp': latest['timestamp'].isoformat(),
+                'commit_hash': latest['commit_hash'],
+                'commit_url': latest['commit_url'],
+                'total_size_mb': float(latest['total_size_mb']),
+                'size_change_mb': size_change,
+                'packages': latest.get('packages', [])
+            })
+
+        if not df_lines.empty:
+            latest = df_lines.iloc[-1]
+            previous = df_lines.iloc[-2] if len(df_lines) > 1 else latest
+            linecount_change = int(latest['total_lines'] - previous['total_lines'])
+            if not latest_data:  # Only add timestamp and commit info if not already added
+                latest_data.update({
+                    'timestamp': latest['timestamp'].isoformat(),
+                    'commit_hash': latest['commit_hash'],
+                    'commit_url': latest['commit_url'],
+                })
+            latest_data.update({
+                'total_lines': int(latest['total_lines']),
+                'linecount_change': linecount_change
+            })
+
+        if not df_benchmark.empty:
+            latest = df_benchmark.iloc[-1]
+            previous = df_benchmark.iloc[-2] if len(df_benchmark) > 1 else latest
+            tokens_change = float(latest['tokens_per_second'] - previous['tokens_per_second'])
+            if not latest_data:  # Only add timestamp and commit info if not already added
+                latest_data.update({
+                    'timestamp': latest['timestamp'].isoformat(),
+                    'commit_hash': latest['commit_hash'],
+                    'commit_url': latest['commit_url'],
+                })
+            latest_data.update({
+                'tokens_per_second': float(latest['tokens_per_second']),
+                'tokens_change': tokens_change
+            })
+
+        if latest_data:
+            with open(output_dir / 'latest_data.json', 'w') as f:
+                json.dump(latest_data, f, indent=2)
+
+            self._print_summary(latest_data)
+            self.logger.info(f"Report generated in {output_dir}")
+            return str(output_dir)
+
+        return None
+
+    def _print_summary(self, latest_data: Dict):
+        print("\n=== Package Size Summary ===")
+        print(f"Timestamp: {latest_data['timestamp']}")
+        print(f"Commit: {latest_data['commit_hash'][:7]}")
+
+        if 'total_size_mb' in latest_data:
+            print(f"Total Size: {latest_data['total_size_mb']:.2f}MB")
+            change = latest_data['size_change_mb']
+            change_symbol = "↓" if change <= 0 else "↑"
+            print(f"Change: {change_symbol} {abs(change):.2f}MB")
+
+            if latest_data.get('packages'):
+                print("\nTop 5 Largest Packages:")
+                sorted_packages = sorted(latest_data['packages'], key=lambda x: x['size_mb'], reverse=True)
+                for pkg in sorted_packages[:5]:
+                    print(f"- {pkg['name']}: {pkg['size_mb']:.2f}MB")
+
+        if 'total_lines' in latest_data:
+            print("\nLine Count Stats:")
+            print(f"Total Lines: {latest_data['total_lines']:,}")
+            change = latest_data['linecount_change']
+            change_symbol = "↓" if change <= 0 else "↑"
+            print(f"Change: {change_symbol} {abs(change):,}")
+
+        if 'tokens_per_second' in latest_data:
+            print("\nBenchmark Stats:")
+            print(f"Tokens per Second: {latest_data['tokens_per_second']:.2f}")
+            if 'time_to_first_token' in latest_data:
+                print(f"Time to First Token: {latest_data['time_to_first_token']:.3f}s")
+
+        print("\n")
+
+    def _calculate_data_hash(self, data: List[Dict]) -> str:
+        """Calculate a hash of the data to detect changes"""
+        return hash(str(sorted([
+            (d.get('commit_hash'), d.get('timestamp'))
+            for d in data
+        ])))
+
+    def _play_sound(self, sound_key: str):
+        """Play a specific notification sound using pygame"""
+        try:
+            sound_path = self.sounds.get(sound_key)
+            if sound_path and sound_path.exists():
+                sound = pygame.mixer.Sound(str(sound_path))
+                sound.play()
+                # Wait for the sound to finish playing
+                pygame.time.wait(int(sound.get_length() * 1000))
+            else:
+                self.logger.warning(f"Sound file not found: {sound_key} at {sound_path}")
+        except Exception as e:
+            self.logger.error(f"Failed to play sound {sound_key}: {e}")
+
+    def _check_metrics_changes(self, current_data: List[Dict], previous_data: List[Dict]):
+        # Sort data by timestamp in descending order (most recent first)
+        def sort_by_timestamp(data):
+            return sorted(
+                data,
+                key=lambda x: x.get('timestamp', ''),
+                reverse=True  # Most recent first
+            )
+
+        current_data = sort_by_timestamp(current_data)
+        previous_data = sort_by_timestamp(previous_data)
+
+        # Helper to find latest entry with a specific metric
+        def find_latest_with_metric(data: List[Dict], metric: str) -> Optional[Dict]:
+            return next((d for d in data if metric in d), None)
+
+        # Check line count changes
+        current_lines = find_latest_with_metric(current_data, 'total_lines')
+        previous_lines = find_latest_with_metric(previous_data, 'total_lines')
+
+        if current_lines and previous_lines:
+            diff = current_lines['total_lines'] - previous_lines['total_lines']
+            self.logger.debug(f"Lines of code diff: {diff}")
+            if diff > 0:
+                self.logger.info(f"Lines of code increased by {diff:,}")
+                self._play_sound('lines_up')
+            elif diff < 0:
+                self.logger.info(f"Lines of code decreased by {abs(diff):,}")
+                self._play_sound('lines_down')
+        else:
+            self.logger.debug("No lines of code data found")
+
+        # Check tokens per second changes
+        current_tokens = find_latest_with_metric(current_data, 'tokens_per_second')
+        previous_tokens = find_latest_with_metric(previous_data, 'tokens_per_second')
+
+        if current_tokens and previous_tokens:
+            diff = current_tokens['tokens_per_second'] - previous_tokens['tokens_per_second']
+            self.logger.debug(f"Tokens per second diff: {diff}")
+            if diff > 0:
+                self.logger.info(f"Tokens per second increased by {diff:.2f}")
+                self._play_sound('tokens_up')
+            elif diff < 0:
+                self.logger.info(f"Tokens per second decreased by {abs(diff):.2f}")
+                self._play_sound('tokens_down')
+        else:
+            self.logger.debug("No tokens per second data found")
+
+        # Check package size changes
+        current_size = find_latest_with_metric(current_data, 'total_size_mb')
+        previous_size = find_latest_with_metric(previous_data, 'total_size_mb')
+
+        if current_size and previous_size:
+            diff = current_size['total_size_mb'] - previous_size['total_size_mb']
+            self.logger.debug(f"Package size diff: {diff:.2f}MB")
+            if diff > 0:
+                self.logger.info(f"Package size increased by {diff:.2f}MB")
+                self._play_sound('size_up')
+            elif diff < 0:
+                self.logger.info(f"Package size decreased by {abs(diff):.2f}MB")
+                self._play_sound('size_down')
+        else:
+            self.logger.debug("No package size data found")
+
+    async def run_dashboard(self, update_interval: int = 10):
+        """Run the dashboard with periodic updates"""
+        try:
+            update_interval = float(update_interval)
+            self.logger.debug(f"Update interval type: {type(update_interval)}, value: {update_interval}")
+        except ValueError as e:
+            self.logger.error(f"Failed to convert update_interval to float: {update_interval}")
+            raise
+
+        self.logger.info(f"Starting real-time dashboard with {update_interval}s updates")
+        previous_data = None
+
+        while True:
+            try:
+                start_time = time.time()
+
+                # Collect new data
+                current_data = await self.collect_data()
+                if not current_data:
+                    self.logger.warning("No data collected")
+                    await asyncio.sleep(update_interval)
+                    continue
+
+                # Generate report
+                report_path = self.generate_report(current_data)
+                if report_path:
+                    self.logger.info(
+                        f"Dashboard updated at {datetime.now().strftime('%H:%M:%S')}"
+                    )
+
+                    print("Curr:", len(current_data))
+                    print("Prev:", len(previous_data) if previous_data else "None")
+                    if previous_data:
+                        # Check for metric changes and play appropriate sounds
+                        self.logger.debug(f"Checking metrics changes between {len(current_data)} current and {len(previous_data)} previous data points")
+                        self._check_metrics_changes(current_data, previous_data)
+
+                # Update previous data
+                previous_data = current_data.copy()  # Make a copy to prevent reference issues
+
+                # Calculate sleep time
+                elapsed = float(time.time() - start_time)
+                sleep_time = max(0.0, update_interval - elapsed)
+                await asyncio.sleep(sleep_time)
+
+            except Exception as e:
+                self.logger.error(f"Error in dashboard update loop: {e}", exc_info=True)
+                if self.debug:
+                    raise
+                await asyncio.sleep(update_interval)
+
+async def main():
+    token = os.getenv("CIRCLECI_TOKEN")
+    project_slug = os.getenv("CIRCLECI_PROJECT_SLUG")
+    debug = os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
+
+    try:
+        # Get update interval from environment or use default
+        update_interval = float(os.getenv("UPDATE_INTERVAL", "10"))
+        print(f"Update interval type: {type(update_interval)}, value: {update_interval}")  # Debug print
+    except ValueError as e:
+        print(f"Error converting UPDATE_INTERVAL to float: {os.getenv('UPDATE_INTERVAL')}")
+        update_interval = 10.0
+
+    if not token or not project_slug:
+        print("Error: Please set CIRCLECI_TOKEN and CIRCLECI_PROJECT_SLUG environment variables")
+        return
+
+    tracker = PackageSizeTracker(token, project_slug, debug)
+
+    try:
+        await tracker.run_dashboard(update_interval)
+    except KeyboardInterrupt:
+        print("\nDashboard stopped by user")
+    except Exception as e:
+        logging.error(f"Error: {str(e)}", exc_info=True)
+        if debug:
+            raise
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 5 - 0
extra/dashboard/requirements.txt

@@ -0,0 +1,5 @@
+plotly
+pandas
+requests
+aiohttp
+pygame

+ 3 - 0
extra/dashboard/sounds/gta5_wasted.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fb3fb66dd02827fbff86ef1ce3bc6438371c823aed7d4c3803ed522f008e4947
+size 206399

+ 3 - 0
extra/dashboard/sounds/pokemon_evolve.mp3

@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d99cc9bdab4a4639d50f439b424547000e7c79f195b5b121734ad4ead435911c
+size 633345

+ 210 - 0
extra/line_counter.py

@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+import os
+import sys
+import json
+import token
+import tokenize
+from datetime import datetime, timezone
+
+TOKEN_WHITELIST = [token.OP, token.NAME, token.NUMBER, token.STRING]
+
+def is_docstring(t):
+    return t.type == token.STRING and t.string.startswith('"""') and t.line.strip().startswith('"""')
+
+def gen_stats(base_path="."):
+    table = []
+    exo_path = os.path.join(base_path, "exo")
+    if not os.path.exists(exo_path):
+        print(f"Warning: {exo_path} directory not found")
+        return table
+
+    for path, _, files in os.walk(exo_path):
+        for name in files:
+            if not name.endswith(".py"):
+                continue
+
+            filepath = os.path.join(path, name)
+            relfilepath = os.path.relpath(filepath, base_path).replace('\\', '/')
+
+            try:
+                with tokenize.open(filepath) as file_:
+                    tokens = [t for t in tokenize.generate_tokens(file_.readline)
+                            if t.type in TOKEN_WHITELIST and not is_docstring(t)]
+                    token_count = len(tokens)
+                    line_count = len(set([x for t in tokens
+                                        for x in range(t.start[0], t.end[0]+1)]))
+                    if line_count > 0:
+                        table.append([relfilepath, line_count, token_count/line_count])
+            except Exception as e:
+                print(f"Error processing {filepath}: {e}")
+                continue
+
+    return table
+
+def gen_diff(table_old, table_new):
+    table = []
+    files_new = set([x[0] for x in table_new])
+    files_old = set([x[0] for x in table_old])
+
+    added = files_new - files_old
+    deleted = files_old - files_new
+    unchanged = files_new & files_old
+
+    for file in added:
+        file_stat = [stats for stats in table_new if file in stats][0]
+        table.append([file_stat[0], file_stat[1], file_stat[1], file_stat[2], file_stat[2]])
+
+    for file in deleted:
+        file_stat = [stats for stats in table_old if file in stats][0]
+        table.append([file_stat[0], 0, -file_stat[1], 0, -file_stat[2]])
+
+    for file in unchanged:
+        file_stat_old = [stats for stats in table_old if file in stats][0]
+        file_stat_new = [stats for stats in table_new if file in stats][0]
+        if file_stat_new[1] != file_stat_old[1] or file_stat_new[2] != file_stat_old[2]:
+            table.append([
+                file_stat_new[0],
+                file_stat_new[1],
+                file_stat_new[1] - file_stat_old[1],
+                file_stat_new[2],
+                file_stat_new[2] - file_stat_old[2]
+            ])
+
+    return table
+
+def create_json_report(table, is_diff=False):
+    timestamp = datetime.now(timezone.utc).isoformat()
+    commit_sha = os.environ.get('CIRCLE_SHA1', 'unknown')
+    branch = os.environ.get('CIRCLE_BRANCH', 'unknown')
+    pr_number = os.environ.get('CIRCLE_PR_NUMBER', '')
+
+    if is_diff:
+        files = [{
+            'name': row[0],
+            'current_lines': row[1],
+            'line_diff': row[2],
+            'current_tokens_per_line': row[3],
+            'tokens_per_line_diff': row[4]
+        } for row in table]
+
+        report = {
+            'type': 'diff',
+            'timestamp': timestamp,
+            'commit_sha': commit_sha,
+            'branch': branch,
+            'pr_number': pr_number,
+            'files': files,
+            'total_line_changes': sum(row[2] for row in table),
+            'total_files_changed': len(files)
+        }
+    else:
+        files = [{
+            'name': row[0],
+            'lines': row[1],
+            'tokens_per_line': row[2]
+        } for row in table]
+
+        report = {
+            'type': 'snapshot',
+            'timestamp': timestamp,
+            'commit_sha': commit_sha,
+            'branch': branch,
+            'files': files,
+            'total_lines': sum(row[1] for row in table),
+            'total_files': len(files)
+        }
+
+    return report
+
+def display_diff(diff):
+    return "+" + str(diff) if diff > 0 else str(diff)
+
+def format_table(rows, headers, floatfmt):
+    if not rows:
+        return ""
+
+    # Add headers as first row
+    all_rows = [headers] + rows
+
+    # Calculate column widths
+    col_widths = []
+    for col in range(len(headers)):
+        col_width = max(len(str(row[col])) for row in all_rows)
+        col_widths.append(col_width)
+
+    # Format rows
+    output = []
+    for row_idx, row in enumerate(all_rows):
+        formatted_cols = []
+        for col_idx, (value, width) in enumerate(zip(row, col_widths)):
+            if isinstance(value, float):
+                # Handle float formatting based on floatfmt
+                fmt = floatfmt[col_idx]
+                if fmt.startswith('+'):
+                    value = f"{value:+.1f}"
+                else:
+                    value = f"{value:.1f}"
+            elif isinstance(value, int) and col_idx > 0:  # Skip filename column
+                # Handle integer formatting based on floatfmt
+                fmt = floatfmt[col_idx]
+                if fmt.startswith('+'):
+                    value = f"{value:+d}"
+                else:
+                    value = f"{value:d}"
+            formatted_cols.append(str(value).ljust(width))
+        output.append("  ".join(formatted_cols))
+
+        # Add separator line after headers
+        if row_idx == 0:
+            separator = []
+            for width in col_widths:
+                separator.append("-" * width)
+            output.append("  ".join(separator))
+
+    return "\n".join(output)
+
+if __name__ == "__main__":
+    if len(sys.argv) == 3:
+        # Comparing two directories
+        headers = ["File", "Lines", "Diff", "Tokens/Line", "Diff"]
+        table = gen_diff(gen_stats(sys.argv[1]), gen_stats(sys.argv[2]))
+
+        if table:
+            # Print table output
+            print("### Code Changes in 'exo' Directory")
+            print("```")
+            print(format_table(
+                sorted(table, key=lambda x: abs(x[2]) if len(x) > 2 else 0, reverse=True),
+                headers,
+                (".1f", "d", "+d", ".1f", "+.1f")
+            ))
+            total_changes = sum(row[2] for row in table)
+            print(f"\nTotal line changes: {display_diff(total_changes)}")
+            print("```")
+
+            # Generate JSON report
+            report = create_json_report(table, is_diff=True)
+            with open('line-count-diff.json', 'w') as f:
+                json.dump(report, f, indent=2)
+    else:
+        # Single directory analysis
+        headers = ["File", "Lines", "Tokens/Line"]
+        table = gen_stats(sys.argv[1] if len(sys.argv) > 1 else ".")
+
+        if table:
+            # Print table output
+            print("### Code Statistics for 'exo' Directory")
+            print("```")
+            print(format_table(
+                sorted(table, key=lambda x: x[1], reverse=True),
+                headers,
+                (".1f", "d", ".1f")
+            ))
+            total_lines = sum(row[1] for row in table)
+            print(f"\nTotal lines: {total_lines}")
+            print("```")
+
+            # Generate JSON report
+            report = create_json_report(table, is_diff=False)
+            with open('line-count-snapshot.json', 'w') as f:
+                json.dump(report, f, indent=2)

+ 7 - 1
install.sh

@@ -1,5 +1,11 @@
 #!/bin/bash
 #!/bin/bash
 
 
-python3 -m venv .venv
+if command -v python3.12 &>/dev/null; then
+    echo "Python 3.12 is installed, proceeding with python3.12..."
+    python3.12 -m venv .venv
+else
+    echo "The recommended version of Python to run exo with is Python 3.12, but $(python3 --version) is installed. Proceeding with $(python3 --version)"
+    python3 -m venv .venv
+fi
 source .venv/bin/activate
 source .venv/bin/activate
 pip install -e .
 pip install -e .