Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sensor/adr_sensor/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
28 changes: 26 additions & 2 deletions Sensor/adr_sensor/parsers/codex_parser.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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."""
Expand All @@ -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():
Expand Down
5 changes: 5 additions & 0 deletions Sensor/tests/test_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions Sensor/tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down