diff --git a/Sensor/adr_sensor/observer.py b/Sensor/adr_sensor/observer.py index 97e2109..1424d38 100644 --- a/Sensor/adr_sensor/observer.py +++ b/Sensor/adr_sensor/observer.py @@ -71,7 +71,7 @@ def __init__(self, output_dir: Optional[Path] = None, max_age_days: Optional[int self.claude_desktop_parser = ( ClaudeDesktopParser(max_age_days=max_age_days) if max_age_days is not None else ClaudeDesktopParser() ) - self.codex_parser = CodexParser() + self.codex_parser = CodexParser(max_age_days=max_age_days) if max_age_days is not None else CodexParser() self.cline_parser = ClineParser() self.warp_parser = WarpParser(max_age_days=max_age_days) if max_age_days is not None else WarpParser() self.opencode_parser = ( diff --git a/Sensor/adr_sensor/parsers/codex_parser.py b/Sensor/adr_sensor/parsers/codex_parser.py index 4987c95..18c16e6 100644 --- a/Sensor/adr_sensor/parsers/codex_parser.py +++ b/Sensor/adr_sensor/parsers/codex_parser.py @@ -1,11 +1,13 @@ """ Parser for OpenAI Codex CLI logs. Reads JSONL files from ~/.codex/sessions/ + +Performance-optimized: Skips log files older than 2 weeks by default. """ import json import traceback -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional @@ -14,12 +16,15 @@ from ..utils.timestamp_utils import normalize_timestamp from .base_parser import BaseParser +MAX_LOG_AGE_DAYS = 14 + class CodexParser(BaseParser): """Parser for OpenAI Codex CLI JSONL log files.""" - def __init__(self): + def __init__(self, max_age_days: int = MAX_LOG_AGE_DAYS): self.base_path = Path.home() / ".codex/sessions" + self.max_age_days = max_age_days def parse_all(self) -> List[AgentEvent]: """Parse all available Codex logs.""" @@ -32,7 +37,26 @@ def parse_all(self) -> List[AgentEvent]: jsonl_files = list(self.base_path.glob("**/*.jsonl")) print(f"[CODEX] Found {len(jsonl_files)} JSONL files") + cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.max_age_days) + filtered_files = [] + skipped_count = 0 + for jsonl_file in jsonl_files: + try: + mtime = datetime.fromtimestamp(jsonl_file.stat().st_mtime, tz=timezone.utc) + if mtime >= cutoff_time: + filtered_files.append(jsonl_file) + else: + skipped_count += 1 + except (OSError, PermissionError): + skipped_count += 1 + + if skipped_count > 0: + print(f"[CODEX] Skipped {skipped_count} files older than {self.max_age_days} days") + + print(f"[CODEX] Processing {len(filtered_files)} recent files") + + for jsonl_file in filtered_files: try: entry = self.parse_jsonl_file(jsonl_file) if entry and entry.has_meaningful_content(): diff --git a/Sensor/tests/test_observer.py b/Sensor/tests/test_observer.py index 2854b0f..f7e9b1e 100644 --- a/Sensor/tests/test_observer.py +++ b/Sensor/tests/test_observer.py @@ -17,6 +17,11 @@ def test_init_default(self, tmp_path): observer = AgentObserver(output_dir=tmp_path) assert observer.output_dir == tmp_path + def test_init_forwards_max_age_days_to_codex(self, tmp_path): + observer = AgentObserver(output_dir=tmp_path, max_age_days=3) + + assert observer.codex_parser.max_age_days == 3 + def test_display_summary_empty(self, tmp_path, capsys): """Test display summary with no data.""" observer = AgentObserver(output_dir=tmp_path) diff --git a/Sensor/tests/test_parsers.py b/Sensor/tests/test_parsers.py index 4112a14..344db91 100644 --- a/Sensor/tests/test_parsers.py +++ b/Sensor/tests/test_parsers.py @@ -157,6 +157,23 @@ def test_parse_no_directory(self): class TestCodexParser: + def _write_session(self, path: Path, created_at: str = "2020-01-01T00:00:00Z") -> None: + events = [ + { + "type": "session_meta", + "payload": {"id": path.stem, "timestamp": created_at, "cwd": "/tmp"}, + }, + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Review this project"}], + }, + }, + ] + path.write_text("".join(json.dumps(event) + "\n" for event in events)) + def test_parse_jsonl_file(self, tmp_path): """Test parsing a Codex CLI JSONL file.""" jsonl_file = tmp_path / "rollout-001.jsonl" @@ -427,6 +444,46 @@ def test_event_msg_records_are_ignored(self, tmp_path): assert len(entry.chat_history) == 2 # user message + assistant tool turn assert len([t for m in entry.chat_history for t in m.tools]) == 1 + def test_default_max_age_days(self): + assert CodexParser().max_age_days == 14 + + def test_parse_all_skips_old_files_before_parsing(self, tmp_path): + jsonl_file = tmp_path / "old-session.jsonl" + self._write_session(jsonl_file) + old_time = (datetime.now(timezone.utc) - timedelta(days=30)).timestamp() + os.utime(jsonl_file, (old_time, old_time)) + + parser = CodexParser(max_age_days=14) + parser.base_path = tmp_path + with patch.object(parser, "parse_jsonl_file") as parse_file: + assert parser.parse_all() == [] + parse_file.assert_not_called() + + def test_parse_all_includes_recently_modified_session(self, tmp_path): + jsonl_file = tmp_path / "resumed-session.jsonl" + self._write_session(jsonl_file, created_at="2020-01-01T00:00:00Z") + recent_time = (datetime.now(timezone.utc) - timedelta(hours=1)).timestamp() + os.utime(jsonl_file, (recent_time, recent_time)) + + parser = CodexParser(max_age_days=14) + parser.base_path = tmp_path + + entries = parser.parse_all() + + assert len(entries) == 1 + assert entries[0].session_id == "codex_resumed-session" + + def test_parse_all_honors_larger_age_window(self, tmp_path): + jsonl_file = tmp_path / "historical-session.jsonl" + self._write_session(jsonl_file) + old_time = (datetime.now(timezone.utc) - timedelta(days=30)).timestamp() + os.utime(jsonl_file, (old_time, old_time)) + + parser = CodexParser(max_age_days=10000) + parser.base_path = tmp_path + + assert len(parser.parse_all()) == 1 + def test_parse_no_directory(self): """Test parse_all when directory doesn't exist.""" parser = CodexParser()