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.
- agent_hour_tracker/__init__.py +6 -0
- agent_hour_tracker/__main__.py +3 -0
- agent_hour_tracker/cli.py +281 -0
- agent_hour_tracker/metrics.py +159 -0
- agent_hour_tracker/report.py +82 -0
- agent_hour_tracker/scanner.py +984 -0
- codex_agent_hour_tracker-0.1.0.dist-info/METADATA +123 -0
- codex_agent_hour_tracker-0.1.0.dist-info/RECORD +11 -0
- codex_agent_hour_tracker-0.1.0.dist-info/WHEEL +4 -0
- codex_agent_hour_tracker-0.1.0.dist-info/entry_points.txt +3 -0
- codex_agent_hour_tracker-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|