codex-agent-hour-tracker 0.1.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.
@@ -0,0 +1,6 @@
1
+ """Privacy-preserving duration tracking for Codex session logs."""
2
+
3
+ __all__ = ["METHODOLOGY_VERSION", "__version__"]
4
+
5
+ __version__ = "0.1.0"
6
+ METHODOLOGY_VERSION = "1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,281 @@
1
+ """Command-line interface for the local agent-hour report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import date, datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
12
+
13
+ import tzlocal
14
+
15
+ from . import METHODOLOGY_VERSION, __version__
16
+ from .metrics import build_report_metrics
17
+ from .report import render_csv, render_share, render_text
18
+ from .scanner import ScanDiagnostics, scan_sessions
19
+
20
+ __all__ = ["main"]
21
+
22
+ _MAX_MALFORMED_FILE_PATHS = 20
23
+ _MAX_DISPLAYED_PATH_LENGTH = 120
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ """Run the agent-hour tracker CLI and return a process exit code."""
28
+
29
+ parser = _build_parser()
30
+ try:
31
+ arguments = parser.parse_args(argv)
32
+ except SystemExit as error:
33
+ return int(error.code)
34
+
35
+ if arguments.share and (
36
+ arguments.start is not None
37
+ or arguments.end is not None
38
+ or arguments.format is not None
39
+ ):
40
+ print(
41
+ "error: --share cannot be combined with --start, --end, or --format",
42
+ file=sys.stderr,
43
+ )
44
+ return 2
45
+
46
+ timezone = _resolve_timezone(arguments.timezone, sys.stderr)
47
+ if timezone is None:
48
+ return 2
49
+ report_range = _resolve_report_range(
50
+ None if arguments.share else arguments.start,
51
+ None if arguments.share else arguments.end,
52
+ _today_in_timezone(timezone),
53
+ sys.stderr,
54
+ )
55
+ if report_range is None:
56
+ return 2
57
+ start, end = report_range
58
+
59
+ sessions_dir = arguments.sessions_dir
60
+ if not sessions_dir.is_dir():
61
+ if arguments.share:
62
+ print(
63
+ "error: sessions directory is missing or not a directory",
64
+ file=sys.stderr,
65
+ )
66
+ else:
67
+ print(
68
+ f"error: sessions directory is missing or not a directory: {sessions_dir}",
69
+ file=sys.stderr,
70
+ )
71
+ return 2
72
+
73
+ scan_result = scan_sessions(sessions_dir)
74
+ report = build_report_metrics(scan_result.turns, start, end, timezone)
75
+ if arguments.share:
76
+ sys.stdout.write(render_share(report, __version__, METHODOLOGY_VERSION))
77
+ elif arguments.format == "csv":
78
+ sys.stdout.write(render_csv(report))
79
+ else:
80
+ sys.stdout.write(render_text(report))
81
+ _write_diagnostics(scan_result.diagnostics, sys.stderr, share_safe=arguments.share)
82
+ return 0
83
+
84
+
85
+ def _build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(
87
+ prog="agent-hours",
88
+ description="Report cumulative Codex agent-hours from local session metadata.",
89
+ )
90
+ parser.add_argument(
91
+ "--sessions-dir",
92
+ type=Path,
93
+ default=Path.home() / ".codex" / "sessions",
94
+ help="Codex sessions directory (default: ~/.codex/sessions)",
95
+ )
96
+ parser.add_argument(
97
+ "--start",
98
+ default=None,
99
+ metavar="YYYY-MM-DD",
100
+ help="first report date, inclusive (default: 30 completed days)",
101
+ )
102
+ parser.add_argument(
103
+ "--end",
104
+ default=None,
105
+ metavar="YYYY-MM-DD",
106
+ help="last report date, inclusive (default: yesterday)",
107
+ )
108
+ parser.add_argument(
109
+ "--format",
110
+ choices=("text", "csv"),
111
+ default=None,
112
+ help="report format (default: text; unavailable with --share)",
113
+ )
114
+ parser.add_argument(
115
+ "--share",
116
+ action="store_true",
117
+ help="render a sanitized Archive Score card for sharing (canonical 30-day range)",
118
+ )
119
+ parser.add_argument(
120
+ "--timezone",
121
+ default=None,
122
+ metavar="IANA_ZONE",
123
+ help="IANA timezone (default: discovered local timezone)",
124
+ )
125
+ return parser
126
+
127
+
128
+ def _parse_date(value: str, label: str, stderr: TextIO) -> date | None:
129
+ try:
130
+ return date.fromisoformat(value)
131
+ except ValueError:
132
+ print(f"error: invalid {label} date: {value!r}", file=stderr)
133
+ return None
134
+
135
+
136
+ def _resolve_report_range(
137
+ start_value: str | None,
138
+ end_value: str | None,
139
+ today: date,
140
+ stderr: TextIO,
141
+ ) -> tuple[date, date] | None:
142
+ start = (
143
+ _parse_date(start_value, "start", stderr)
144
+ if start_value is not None
145
+ else None
146
+ )
147
+ if start_value is not None and start is None:
148
+ return None
149
+
150
+ end = (
151
+ _parse_date(end_value, "end", stderr)
152
+ if end_value is not None
153
+ else None
154
+ )
155
+ if end_value is not None and end is None:
156
+ return None
157
+
158
+ if end is None:
159
+ end = today - timedelta(days=1)
160
+ if start is None:
161
+ start = end - timedelta(days=29)
162
+
163
+ if end < start:
164
+ print("error: end date must not precede start date", file=stderr)
165
+ return None
166
+ return start, end
167
+
168
+
169
+ def _resolve_timezone(value: str | None, stderr: TextIO) -> ZoneInfo | None:
170
+ if value is not None:
171
+ try:
172
+ return ZoneInfo(value)
173
+ except (ZoneInfoNotFoundError, ValueError):
174
+ print(f"error: invalid timezone: {value!r}", file=stderr)
175
+ return None
176
+
177
+ timezone_name, warning = _discover_timezone_name()
178
+ if warning:
179
+ print(
180
+ f"warning: using {timezone_name} for the local timezone",
181
+ file=stderr,
182
+ )
183
+ try:
184
+ return ZoneInfo(timezone_name)
185
+ except (ZoneInfoNotFoundError, TypeError, ValueError):
186
+ print("warning: local timezone is unavailable; using UTC", file=stderr)
187
+ return ZoneInfo("UTC")
188
+
189
+
190
+ def _discover_timezone_name() -> tuple[str, bool]:
191
+ try:
192
+ timezone_name = tzlocal.get_localzone_name()
193
+ except Exception:
194
+ return "UTC", True
195
+ if not isinstance(timezone_name, str) or not timezone_name:
196
+ return "UTC", True
197
+ return timezone_name, False
198
+
199
+
200
+ def _today_in_timezone(timezone: ZoneInfo) -> date:
201
+ return datetime.now(timezone).date()
202
+
203
+
204
+ def _write_diagnostics(
205
+ diagnostics: ScanDiagnostics,
206
+ stderr: TextIO,
207
+ share_safe: bool = False,
208
+ ) -> None:
209
+ if share_safe:
210
+ if diagnostics.malformed_lines:
211
+ print(f"Malformed lines: {diagnostics.malformed_lines}", file=stderr)
212
+ if diagnostics.malformed_files:
213
+ print(f"Malformed files: {len(diagnostics.malformed_files)}", file=stderr)
214
+ if diagnostics.incomplete_turns:
215
+ print(f"Incomplete turns: {diagnostics.incomplete_turns}", file=stderr)
216
+ if diagnostics.unmatched_completions:
217
+ print(
218
+ f"Unmatched completions: {diagnostics.unmatched_completions}",
219
+ file=stderr,
220
+ )
221
+ if diagnostics.duration_fallbacks:
222
+ print(f"Duration fallbacks: {diagnostics.duration_fallbacks}", file=stderr)
223
+ if diagnostics.event_timing_fallbacks:
224
+ print(
225
+ f"Event timing fallbacks: {diagnostics.event_timing_fallbacks}",
226
+ file=stderr,
227
+ )
228
+ if diagnostics.duplicate_turns:
229
+ print(f"Duplicate turns: {diagnostics.duplicate_turns}", file=stderr)
230
+ if diagnostics.excluded_batch_turns:
231
+ print(
232
+ f"Excluded batch turns: {diagnostics.excluded_batch_turns}",
233
+ file=stderr,
234
+ )
235
+ return
236
+
237
+ if diagnostics.malformed_lines:
238
+ print(f"Malformed lines: {diagnostics.malformed_lines}", file=stderr)
239
+ if diagnostics.malformed_files:
240
+ malformed_files = sorted(diagnostics.malformed_files)
241
+ print(f"Malformed files: {len(malformed_files)}", file=stderr)
242
+ for path in malformed_files[:_MAX_MALFORMED_FILE_PATHS]:
243
+ print(f" {_display_path(path)}", file=stderr)
244
+ omitted = len(malformed_files) - _MAX_MALFORMED_FILE_PATHS
245
+ if omitted > 0:
246
+ print(
247
+ f" ... {omitted} malformed file paths omitted",
248
+ file=stderr,
249
+ )
250
+ if diagnostics.incomplete_turns:
251
+ print(f"Incomplete turns: {diagnostics.incomplete_turns}", file=stderr)
252
+ if diagnostics.unmatched_completions:
253
+ print(
254
+ f"Unmatched completions: {diagnostics.unmatched_completions}",
255
+ file=stderr,
256
+ )
257
+ if diagnostics.duration_fallbacks:
258
+ print(f"Duration fallbacks: {diagnostics.duration_fallbacks}", file=stderr)
259
+ if diagnostics.event_timing_fallbacks:
260
+ print(
261
+ f"Event timing fallbacks: {diagnostics.event_timing_fallbacks}",
262
+ file=stderr,
263
+ )
264
+ if diagnostics.duplicate_turns:
265
+ print(f"Duplicate turns: {diagnostics.duplicate_turns}", file=stderr)
266
+ if diagnostics.excluded_batch_turns:
267
+ print(
268
+ f"Excluded batch turns: {diagnostics.excluded_batch_turns}",
269
+ file=stderr,
270
+ )
271
+ if diagnostics.unknown_sources:
272
+ print("Unknown sources:", file=stderr)
273
+ for source, count in sorted(diagnostics.unknown_sources.items()):
274
+ print(f" {source}: {count}", file=stderr)
275
+
276
+
277
+ def _display_path(path: Path) -> str:
278
+ escaped = json.dumps(str(path), ensure_ascii=True)
279
+ if len(escaped) <= _MAX_DISPLAYED_PATH_LENGTH:
280
+ return escaped
281
+ return escaped[: _MAX_DISPLAYED_PATH_LENGTH - 4] + '..."'
@@ -0,0 +1,159 @@
1
+ """Aggregate completed turn durations into daily report metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from dataclasses import dataclass
7
+ from datetime import date, datetime, timedelta
8
+ from math import ceil
9
+ from statistics import median
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from .scanner import CompletedTurn
13
+
14
+ __all__ = ["DailyStat", "ReportMetrics", "build_report_metrics"]
15
+
16
+ HISTOGRAM_LABELS = (
17
+ "0",
18
+ ">0 to <1",
19
+ "1 to <5",
20
+ "5 to <10",
21
+ "10 to <15",
22
+ "15 to <30",
23
+ "30 to <60",
24
+ "60+",
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class DailyStat:
30
+ """Agent-hours and completed-turn count for one calendar date."""
31
+
32
+ date: date
33
+ agent_hours: float
34
+ completed_turns: int
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ReportMetrics:
39
+ """Daily rows and summary statistics for an inclusive report range."""
40
+
41
+ days: tuple[DailyStat, ...]
42
+ total_agent_hours: float
43
+ mean_per_calendar_day: float
44
+ mean_per_active_day: float
45
+ median_agent_hours: float
46
+ p95_agent_hours: float
47
+ max_agent_hours: float
48
+ active_days: int
49
+ zero_days: int
50
+ days_above_15_hours: int
51
+ days_above_60_hours: int
52
+ histogram: OrderedDict[str, int]
53
+
54
+
55
+ def build_report_metrics(
56
+ turns: list[CompletedTurn],
57
+ start: date,
58
+ end: date,
59
+ timezone: ZoneInfo,
60
+ ) -> ReportMetrics:
61
+ """Build daily and distribution metrics for an inclusive date range.
62
+
63
+ Each turn's full duration is assigned to the local date of its start,
64
+ independently of any other turn. Turns whose local start date falls
65
+ outside the requested range are ignored.
66
+
67
+ Raises:
68
+ ValueError: If ``end`` precedes ``start``.
69
+ """
70
+
71
+ if end < start:
72
+ raise ValueError("end date must not precede start date")
73
+
74
+ hours_by_date: dict[date, float] = {}
75
+ turns_by_date: dict[date, int] = {}
76
+ current = start
77
+ while current <= end:
78
+ hours_by_date[current] = 0.0
79
+ turns_by_date[current] = 0
80
+ if current == end:
81
+ break
82
+ current += timedelta(days=1)
83
+
84
+ for turn in turns:
85
+ try:
86
+ local_date = datetime.fromtimestamp(turn.started_at, timezone).date()
87
+ except (OSError, OverflowError, ValueError):
88
+ continue
89
+ if local_date not in hours_by_date:
90
+ continue
91
+ hours_by_date[local_date] += turn.duration_seconds / 3_600.0
92
+ turns_by_date[local_date] += 1
93
+
94
+ days = tuple(
95
+ DailyStat(day, hours_by_date[day], turns_by_date[day])
96
+ for day in hours_by_date
97
+ )
98
+ values = [day.agent_hours for day in days]
99
+ total_agent_hours = sum(values)
100
+ active_days = sum(value > 0.0 for value in values)
101
+ zero_days = sum(value == 0.0 for value in values)
102
+ day_count = len(values)
103
+
104
+ return ReportMetrics(
105
+ days=days,
106
+ total_agent_hours=total_agent_hours,
107
+ mean_per_calendar_day=total_agent_hours / day_count,
108
+ mean_per_active_day=(
109
+ total_agent_hours / active_days if active_days else 0.0
110
+ ),
111
+ median_agent_hours=float(median(values)),
112
+ p95_agent_hours=_percentile_95(values),
113
+ max_agent_hours=max(values),
114
+ active_days=active_days,
115
+ zero_days=zero_days,
116
+ days_above_15_hours=sum(value > 15.0 for value in values),
117
+ days_above_60_hours=sum(value > 60.0 for value in values),
118
+ histogram=_build_histogram(values),
119
+ )
120
+
121
+
122
+ def _percentile_95(values: list[float]) -> float:
123
+ ordered = sorted(values)
124
+ if len(ordered) == 1:
125
+ return ordered[0]
126
+ position = (len(ordered) - 1) * 0.95
127
+ lower_index = int(position)
128
+ upper_index = ceil(position)
129
+ if lower_index == upper_index:
130
+ return ordered[lower_index]
131
+ weight = position - lower_index
132
+ return ordered[lower_index] + weight * (
133
+ ordered[upper_index] - ordered[lower_index]
134
+ )
135
+
136
+
137
+ def _build_histogram(values: list[float]) -> OrderedDict[str, int]:
138
+ histogram: OrderedDict[str, int] = OrderedDict(
139
+ (label, 0) for label in HISTOGRAM_LABELS
140
+ )
141
+ for value in values:
142
+ if value == 0.0:
143
+ label = "0"
144
+ elif value < 1.0:
145
+ label = ">0 to <1"
146
+ elif value < 5.0:
147
+ label = "1 to <5"
148
+ elif value < 10.0:
149
+ label = "5 to <10"
150
+ elif value < 15.0:
151
+ label = "10 to <15"
152
+ elif value < 30.0:
153
+ label = "15 to <30"
154
+ elif value < 60.0:
155
+ label = "30 to <60"
156
+ else:
157
+ label = "60+"
158
+ histogram[label] += 1
159
+ return histogram
@@ -0,0 +1,82 @@
1
+ """Deterministic text and CSV renderers for agent-hour metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+
8
+ from .metrics import ReportMetrics
9
+
10
+ __all__ = ["render_csv", "render_share", "render_text"]
11
+
12
+
13
+ def render_text(report: ReportMetrics) -> str:
14
+ """Render the summary, daily rows, and histogram as plain text."""
15
+
16
+ days = sorted(report.days, key=lambda day: day.date)
17
+ lines = [
18
+ "AGENT-HOUR SUMMARY",
19
+ f"Calendar days:{len(days):>16}",
20
+ f"Total agent-hours:{report.total_agent_hours:>12.2f}",
21
+ f"Mean / calendar day:{report.mean_per_calendar_day:>10.2f} h",
22
+ f"Mean / active day:{report.mean_per_active_day:>13.2f} h",
23
+ f"Median daily agent-hours:{report.median_agent_hours:>5.2f} h",
24
+ f"P95 daily agent-hours:{report.p95_agent_hours:>8.2f} h",
25
+ f"Maximum daily agent-hours:{report.max_agent_hours:>5.2f} h",
26
+ f"Active days:{report.active_days:>21}",
27
+ f"Zero days:{report.zero_days:>23}",
28
+ f"Days above 15 hours:{report.days_above_15_hours:>12}",
29
+ f"Days above 60 hours:{report.days_above_60_hours:>12}",
30
+ "",
31
+ "DAILY AGENT-HOURS",
32
+ f"{'Date':<28}{'Agent-hours':>5}{'Completed turns':>18}",
33
+ ]
34
+ lines.extend(
35
+ f"{day.date.isoformat():<28}{day.agent_hours:.2f}{day.completed_turns:>11}"
36
+ for day in days
37
+ )
38
+ lines.extend(("", "DAILY DISTRIBUTION"))
39
+ lines.extend(f"{label:<28}{count}" for label, count in report.histogram.items())
40
+ return "\n".join(lines) + "\n"
41
+
42
+
43
+ def render_share(
44
+ report: ReportMetrics, tracker_version: str, methodology_version: str
45
+ ) -> str:
46
+ """Render a deterministic, conversation-free Archive Score card."""
47
+
48
+ days = sorted(report.days, key=lambda day: day.date)
49
+ if not days:
50
+ raise ValueError("share report requires at least one calendar day")
51
+ day_count = len(days)
52
+ completed_turns = sum(day.completed_turns for day in days)
53
+ lines = [
54
+ "CODEX AGENT-HOUR SCORE",
55
+ "",
56
+ f"{day_count} complete calendar days | "
57
+ f"{days[0].date.isoformat()} to {days[-1].date.isoformat()}",
58
+ "-" * 53,
59
+ f"Agent-hours/day: {report.mean_per_calendar_day:.2f}",
60
+ f"Total agent-hours: {report.total_agent_hours:.2f}",
61
+ f"Peak day: {report.max_agent_hours:.2f}",
62
+ f"Completed turns: {completed_turns}",
63
+ f"Active days: {report.active_days}/{day_count}",
64
+ "",
65
+ f"Archive Score | methodology v{methodology_version} | "
66
+ f"tracker v{tracker_version}",
67
+ "Calculated locally. No conversation content uploaded.",
68
+ ]
69
+ return "\n".join(lines) + "\n"
70
+
71
+
72
+ def render_csv(report: ReportMetrics) -> str:
73
+ """Render one CSV row per calendar date with stable numeric precision."""
74
+
75
+ buffer = io.StringIO(newline="")
76
+ writer = csv.writer(buffer, lineterminator="\n")
77
+ writer.writerow(("date", "agent_hours", "completed_turns"))
78
+ for day in sorted(report.days, key=lambda day: day.date):
79
+ writer.writerow(
80
+ (day.date.isoformat(), f"{day.agent_hours:.6f}", day.completed_turns)
81
+ )
82
+ return buffer.getvalue()