artificial-intelligence-usage 0.0.1__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,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: artificial-intelligence-usage
3
+ Version: 0.0.1
4
+ Summary: Analyze recent Codex sessions and report usage information.
5
+ Author: Kaizten Analytics
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Artificial Intelligence Usage
13
+
14
+ `artificial-intelligence-usage` analyzes local Codex session rollouts and prints a summary of the
15
+ latest session followed by daily session, turn, token, and rate-limit usage.
16
+
17
+ ## Behavior
18
+
19
+ - Reads rollout files below `~/.codex/sessions`.
20
+ - Shows status details from the most recently modified session.
21
+ - Reports sessions from the last 14 calendar days by default, including today.
22
+ - Counts user turns and cumulative tokens for each session.
23
+ - Shows the remaining five-hour and weekly allowance recorded in each day's
24
+ last session.
25
+ - Ignores malformed JSON lines and incomplete status events.
26
+
27
+ The tool only reads local session files and does not modify them.
28
+
29
+ ## Requirements
30
+
31
+ - Python 3.10 or newer
32
+
33
+ ## Building
34
+
35
+ ### Testing
36
+
37
+ Run the unit tests from the project root:
38
+
39
+ ```bash
40
+ python3 -m unittest discover -s tests -v
41
+ ```
42
+
43
+ ### Package artifact
44
+
45
+ Build and validate the package artifacts:
46
+
47
+ ```bash
48
+ ./build-package.sh
49
+ ```
50
+
51
+ This creates the wheel and source distribution under `dist/` and checks the
52
+ package metadata with Twine.
53
+
54
+ ### Publish artifact
55
+
56
+ Publish the generated artifacts to PyPI:
57
+
58
+ ```bash
59
+ ./publish-pypi.sh
60
+ ```
61
+
62
+ The publish script checks that Twine is available, creates a local `.venv` for
63
+ Twine when needed, verifies that `dist/` contains generated artifacts, and then
64
+ uploads them. If PyPI rejects a version that was already published, increment
65
+ the version in `pyproject.toml`, rebuild, and publish again.
66
+
67
+ ### Install artifact
68
+
69
+ Install the package in a virtual environment:
70
+
71
+ ```bash
72
+ python3 -m venv .venv
73
+ source .venv/bin/activate
74
+ python3 -m pip install artificial-intelligence-usage
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ After installation, run:
80
+
81
+ ```bash
82
+ artificial-intelligence-usage
83
+ ```
84
+
85
+ Choose a different reporting window with `--days`:
86
+
87
+ ```bash
88
+ artificial-intelligence-usage --days 30
89
+ ```
90
+
91
+ From a source checkout, `./artificial-intelligence-usage.py` is the canonical launcher and
92
+ `./script-sessions.py` remains available for compatibility.
93
+
94
+ ### Help
95
+
96
+ ```text
97
+ usage: artificial-intelligence-usage [-h] [--days DAYS]
98
+
99
+ Analyze recent Codex sessions.
100
+
101
+ options:
102
+ -h, --help show this help message and exit
103
+ --days DAYS number of calendar days to analyze, including today (default: 14)
104
+ ```
@@ -0,0 +1,6 @@
1
+ artificial_intelligence_usage.py,sha256=EU0cZYMuPCmiSsxmjCGHeAq_hH1YY1Wm5XLnMXnBIB0,11833
2
+ artificial_intelligence_usage-0.0.1.dist-info/METADATA,sha256=6n7Kzz9ekTTB19s0i8Nacka6d9bSDjygYqwW_ViIDf4,2603
3
+ artificial_intelligence_usage-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ artificial_intelligence_usage-0.0.1.dist-info/entry_points.txt,sha256=fRIVhzZwrPaY8XgzLcvJJzEeE4XIcpieUMVIrGyDNik,85
5
+ artificial_intelligence_usage-0.0.1.dist-info/top_level.txt,sha256=tw7aL5OTKRgpxRL_BKQXOQgb8OHoT7NE9yK6fhpD0kU,30
6
+ artificial_intelligence_usage-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ artificial-intelligence-usage = artificial_intelligence_usage:main
@@ -0,0 +1 @@
1
+ artificial_intelligence_usage
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ """Analyze recent Codex sessions and report usage information."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from collections import Counter, defaultdict
9
+ from datetime import date, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ DEFAULT_DAYS = 14
15
+
16
+
17
+ def positive_int(value: str) -> int:
18
+ """Return value as a positive integer for command-line arguments."""
19
+ try:
20
+ number = int(value)
21
+ except ValueError as error:
22
+ raise argparse.ArgumentTypeError("must be an integer") from error
23
+
24
+ if number < 1:
25
+ raise argparse.ArgumentTypeError("must be at least 1")
26
+
27
+ return number
28
+
29
+
30
+ def format_timestamp(value: str | int | float | None) -> str:
31
+ """Format an ISO or Unix timestamp in the local timezone."""
32
+ try:
33
+ if isinstance(value, str):
34
+ timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
35
+ elif isinstance(value, (int, float)):
36
+ timestamp = datetime.fromtimestamp(value).astimezone()
37
+ else:
38
+ return "unknown"
39
+
40
+ return timestamp.astimezone().strftime("%Y-%m-%d %H:%M %Z")
41
+ except (OverflowError, OSError, ValueError):
42
+ return "unknown"
43
+
44
+
45
+ def format_window(minutes: Any) -> str:
46
+ """Return a compact label for a rate-limit window."""
47
+ if not isinstance(minutes, (int, float)):
48
+ return "Rate limit"
49
+ if minutes % (7 * 24 * 60) == 0:
50
+ return f"{minutes / (7 * 24 * 60):g}w limit"
51
+ if minutes % (24 * 60) == 0:
52
+ return f"{minutes / (24 * 60):g}d limit"
53
+ if minutes % 60 == 0:
54
+ return f"{minutes / 60:g}h limit"
55
+ return f"{minutes:g}m limit"
56
+
57
+
58
+ def read_status(session_file: Path | None) -> dict[str, Any]:
59
+ """Read the latest status-related values from a Codex rollout."""
60
+ status: dict[str, Any] = {
61
+ "session": {},
62
+ "context": {},
63
+ "token_count": {},
64
+ "updated_at": None,
65
+ }
66
+
67
+ if session_file is None:
68
+ return status
69
+
70
+ try:
71
+ with session_file.open("r", encoding="utf-8") as file_handle:
72
+ for line in file_handle:
73
+ try:
74
+ event = json.loads(line)
75
+ except (json.JSONDecodeError, UnicodeDecodeError):
76
+ continue
77
+
78
+ status["updated_at"] = event.get("timestamp") or status["updated_at"]
79
+ payload = event.get("payload") or {}
80
+
81
+ if event.get("type") == "session_meta":
82
+ status["session"] = payload
83
+ elif event.get("type") == "turn_context":
84
+ status["context"] = payload
85
+ elif (
86
+ event.get("type") == "event_msg"
87
+ and payload.get("type") == "token_count"
88
+ ):
89
+ status["token_count"] = payload
90
+ except (OSError, UnicodeError):
91
+ pass
92
+
93
+ return status
94
+
95
+
96
+ def print_rate_limit(limit: Any, fallback_label: str) -> None:
97
+ """Print one rate-limit window from a token-count event."""
98
+ if not isinstance(limit, dict):
99
+ print(f"{fallback_label + ':':<16} unknown")
100
+ return
101
+
102
+ label = format_window(limit.get("window_minutes"))
103
+ used_percent = limit.get("used_percent")
104
+ remaining = (
105
+ max(0.0, 100.0 - float(used_percent))
106
+ if isinstance(used_percent, (int, float))
107
+ else None
108
+ )
109
+ availability = f"{remaining:g}% remaining" if remaining is not None else "unknown"
110
+ reset = format_timestamp(limit.get("resets_at"))
111
+ print(f"{label + ':':<16} {availability} (resets {reset})")
112
+
113
+
114
+ def format_remaining(limit: Any) -> str:
115
+ """Return the remaining allowance for one rate-limit window."""
116
+ if not isinstance(limit, dict):
117
+ return "unknown"
118
+
119
+ used_percent = limit.get("used_percent")
120
+ if not isinstance(used_percent, (int, float)):
121
+ return "unknown"
122
+
123
+ remaining = min(100.0, max(0.0, 100.0 - float(used_percent)))
124
+ return f"{remaining:g}% remaining"
125
+
126
+
127
+ def session_rate_limits(session_file: Path) -> tuple[str, str]:
128
+ """Return the 5-hour and weekly limits recorded by one session."""
129
+ token_count = read_status(session_file)["token_count"]
130
+ rate_limits = token_count.get("rate_limits") or {}
131
+ limits = [rate_limits.get("primary"), rate_limits.get("secondary")]
132
+
133
+ five_hour = next(
134
+ (
135
+ limit
136
+ for limit in limits
137
+ if isinstance(limit, dict) and limit.get("window_minutes") == 5 * 60
138
+ ),
139
+ rate_limits.get("primary"),
140
+ )
141
+ weekly = next(
142
+ (
143
+ limit
144
+ for limit in limits
145
+ if isinstance(limit, dict)
146
+ and limit.get("window_minutes") == 7 * 24 * 60
147
+ ),
148
+ rate_limits.get("secondary"),
149
+ )
150
+
151
+ return format_remaining(five_hour), format_remaining(weekly)
152
+
153
+
154
+ def print_status(session_file: Path | None) -> None:
155
+ """Print a /status-like summary for the latest Codex session."""
156
+ status = read_status(session_file)
157
+ session = status["session"]
158
+ context = status["context"]
159
+ token_count = status["token_count"]
160
+ info = token_count.get("info") or {}
161
+ total_usage = info.get("total_token_usage") or {}
162
+ last_usage = info.get("last_token_usage") or {}
163
+ rate_limits = token_count.get("rate_limits") or {}
164
+
165
+ model = context.get("model") or "unknown"
166
+ effort = context.get("effort")
167
+ if effort:
168
+ model = f"{model} ({effort})"
169
+
170
+ sandbox = (context.get("sandbox_policy") or {}).get("type") or "unknown"
171
+ approval_policy = context.get("approval_policy") or {}
172
+ approval = next(iter(approval_policy), "unknown")
173
+
174
+ current_tokens = last_usage.get("total_tokens")
175
+ context_window = info.get("model_context_window")
176
+ if isinstance(current_tokens, (int, float)) and isinstance(
177
+ context_window, (int, float)
178
+ ):
179
+ context_percent = min(100.0, current_tokens / context_window * 100)
180
+ context_value = (
181
+ f"{int(current_tokens):,} / {int(context_window):,} tokens "
182
+ f"({context_percent:.1f}% used)"
183
+ )
184
+ else:
185
+ context_value = "unknown"
186
+
187
+ total_tokens = total_usage.get("total_tokens")
188
+ total_value = (
189
+ f"{int(total_tokens):,}"
190
+ if isinstance(total_tokens, (int, float))
191
+ else "unknown"
192
+ )
193
+
194
+ print("Codex status (latest session)")
195
+ print("-" * 57)
196
+ print(f"{'Updated:':<16} {format_timestamp(status['updated_at'])}")
197
+ print(
198
+ f"{'Session:':<16} "
199
+ f"{session.get('session_id') or session.get('id') or 'unknown'}"
200
+ )
201
+ print(f"{'Codex CLI:':<16} {session.get('cli_version') or 'unknown'}")
202
+ print(f"{'Model:':<16} {model}")
203
+ print(
204
+ f"{'Workspace:':<16} "
205
+ f"{context.get('cwd') or session.get('cwd') or 'unknown'}"
206
+ )
207
+ print(f"{'Permissions:':<16} {sandbox} (approval: {approval})")
208
+ print(f"{'Context:':<16} {context_value}")
209
+ print(f"{'Session tokens:':<16} {total_value}")
210
+ print(f"{'Plan:':<16} {rate_limits.get('plan_type') or 'unknown'}")
211
+ print_rate_limit(rate_limits.get("primary"), "Primary limit")
212
+ print_rate_limit(rate_limits.get("secondary"), "Secondary limit")
213
+ print()
214
+
215
+
216
+ def analyze_session(session_file: Path) -> tuple[int, int]:
217
+ """Return ``(user_turns, total_tokens)`` for one Codex session."""
218
+ turns = 0
219
+ total_tokens = 0
220
+
221
+ with session_file.open("r", encoding="utf-8") as file_handle:
222
+ for line in file_handle:
223
+ try:
224
+ event = json.loads(line)
225
+ except (json.JSONDecodeError, UnicodeDecodeError):
226
+ continue
227
+
228
+ payload = event.get("payload") or {}
229
+
230
+ if (
231
+ event.get("type") == "response_item"
232
+ and payload.get("type") == "message"
233
+ and payload.get("role") == "user"
234
+ ):
235
+ turns += 1
236
+
237
+ if (
238
+ event.get("type") == "event_msg"
239
+ and payload.get("type") == "token_count"
240
+ ):
241
+ info = payload.get("info") or {}
242
+ usage = info.get("total_token_usage") or {}
243
+ value = usage.get("total_tokens")
244
+
245
+ if isinstance(value, (int, float)):
246
+ total_tokens = max(total_tokens, int(value))
247
+
248
+ return turns, total_tokens
249
+
250
+
251
+ def build_parser() -> argparse.ArgumentParser:
252
+ """Build the command-line argument parser."""
253
+ parser = argparse.ArgumentParser(description="Analyze recent Codex sessions.")
254
+ parser.add_argument(
255
+ "--days",
256
+ type=positive_int,
257
+ default=DEFAULT_DAYS,
258
+ help=f"number of calendar days to analyze, including today (default: {DEFAULT_DAYS})",
259
+ )
260
+ return parser
261
+
262
+
263
+ def run_report(sessions_dir: Path, days: int, today: date | None = None) -> int:
264
+ """Analyze sessions and print the status and daily usage report."""
265
+ current_day = today or date.today()
266
+ first_day = current_day - timedelta(days=days - 1)
267
+ session_counts: Counter[str] = Counter()
268
+ turn_counts: defaultdict[str, list[int]] = defaultdict(list)
269
+ token_counts: Counter[str] = Counter()
270
+ last_session_by_date: dict[str, Path] = {}
271
+
272
+ all_session_files = list(sessions_dir.glob("*/*/*/rollout-*.jsonl"))
273
+ latest_session = (
274
+ max(all_session_files, key=lambda path: path.stat().st_mtime)
275
+ if all_session_files
276
+ else None
277
+ )
278
+ print_status(latest_session)
279
+
280
+ for session_file in all_session_files:
281
+ relative = session_file.relative_to(sessions_dir)
282
+ year, month, day = relative.parts[:3]
283
+ date_label = f"{year}-{month}-{day}"
284
+
285
+ try:
286
+ session_date = date.fromisoformat(date_label)
287
+ except ValueError:
288
+ continue
289
+
290
+ if not first_day <= session_date <= current_day:
291
+ continue
292
+
293
+ turns, tokens = analyze_session(session_file)
294
+ session_counts[date_label] += 1
295
+ turn_counts[date_label].append(turns)
296
+ token_counts[date_label] += tokens
297
+
298
+ previous_last = last_session_by_date.get(date_label)
299
+ if previous_last is None or session_file.name > previous_last.name:
300
+ last_session_by_date[date_label] = session_file
301
+
302
+ if not session_counts:
303
+ print(f"No Codex sessions found in the last {days} days in {sessions_dir}")
304
+ return 0
305
+
306
+ print(
307
+ f"{'Date':<12} "
308
+ f"{'Sessions':>8} "
309
+ f"{'Turns':>8} "
310
+ f"{'Avg turns':>10} "
311
+ f"{'Tokens':>15} "
312
+ f"{'5h last session':>17} "
313
+ f"{'Weekly last session':>20}"
314
+ )
315
+ print("-" * 96)
316
+
317
+ for date_label in sorted(session_counts):
318
+ sessions = session_counts[date_label]
319
+ total_turns = sum(turn_counts[date_label])
320
+ avg_turns = total_turns / sessions
321
+ tokens = token_counts[date_label]
322
+ five_hour, weekly = session_rate_limits(last_session_by_date[date_label])
323
+
324
+ print(
325
+ f"{date_label:<12} "
326
+ f"{sessions:>8} "
327
+ f"{total_turns:>8} "
328
+ f"{avg_turns:>10.1f} "
329
+ f"{tokens:>15,} "
330
+ f"{five_hour:>17} "
331
+ f"{weekly:>20}"
332
+ )
333
+
334
+ total_sessions = sum(session_counts.values())
335
+ total_turns = sum(sum(turns) for turns in turn_counts.values())
336
+ total_tokens = sum(token_counts.values())
337
+ overall_average = total_turns / total_sessions
338
+
339
+ print("-" * 96)
340
+ print(
341
+ f"{'Total':<12} "
342
+ f"{total_sessions:>8} "
343
+ f"{total_turns:>8} "
344
+ f"{overall_average:>10.1f} "
345
+ f"{total_tokens:>15,} "
346
+ f"{'-':>17} "
347
+ f"{'-':>20}"
348
+ )
349
+ return 0
350
+
351
+
352
+ def main(argv: list[str] | None = None) -> int:
353
+ """Run the artificial-intelligence-usage command."""
354
+ args = build_parser().parse_args(argv)
355
+ return run_report(Path.home() / ".codex" / "sessions", args.days)
356
+
357
+
358
+ if __name__ == "__main__":
359
+ raise SystemExit(main())