codex-skill-report 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,5 @@
1
+ """Inspect retained Codex sessions for inferred skill invocations."""
2
+
3
+ from codex_skill_report.cli import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,198 @@
1
+ """Command-line interface for codex-skill-report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, TextIO
11
+
12
+ from rich.console import Console
13
+
14
+ from codex_skill_report.reporting import (
15
+ render_csv,
16
+ render_json,
17
+ render_markdown,
18
+ render_terminal,
19
+ )
20
+ from codex_skill_report.scanner import CodexHomeNotFoundError, scan_codex_home
21
+
22
+ if TYPE_CHECKING:
23
+ from codex_skill_report.models import ScanReport
24
+
25
+ PACKAGE_NAME = "codex-skill-report"
26
+ MIN_TERMINAL_WIDTH = 60
27
+
28
+
29
+ def package_version() -> str:
30
+ """Return the installed distribution version."""
31
+ try:
32
+ return version(PACKAGE_NAME)
33
+ except PackageNotFoundError:
34
+ return "0.1.0"
35
+
36
+
37
+ def positive_width(value: str) -> int:
38
+ """Parse and validate a useful terminal width."""
39
+ width = int(value)
40
+ if width < MIN_TERMINAL_WIDTH:
41
+ message = f"width must be at least {MIN_TERMINAL_WIDTH} columns"
42
+ raise argparse.ArgumentTypeError(message)
43
+ return width
44
+
45
+
46
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
47
+ """Parse the public command-line interface."""
48
+ default_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
49
+ parser = argparse.ArgumentParser(
50
+ prog=PACKAGE_NAME,
51
+ description=(
52
+ "Infer self-selected and requested skill invocations from retained "
53
+ "Codex session transcripts."
54
+ ),
55
+ epilog=(
56
+ "examples:\n"
57
+ " codex-skill-report\n"
58
+ " codex-skill-report --details\n"
59
+ " codex-skill-report --json --output report.json\n"
60
+ " codex-skill-report --markdown > report.md"
61
+ ),
62
+ formatter_class=argparse.RawDescriptionHelpFormatter,
63
+ )
64
+ parser.add_argument(
65
+ "--codex-home",
66
+ type=Path,
67
+ default=default_home,
68
+ metavar="PATH",
69
+ help=f"Codex data directory (default: {default_home})",
70
+ )
71
+
72
+ format_group = parser.add_mutually_exclusive_group()
73
+ format_group.add_argument(
74
+ "--json",
75
+ dest="output_format",
76
+ action="store_const",
77
+ const="json",
78
+ help="write the complete report as JSON",
79
+ )
80
+ format_group.add_argument(
81
+ "--csv",
82
+ dest="output_format",
83
+ action="store_const",
84
+ const="csv",
85
+ help="write aggregate skill counts as CSV",
86
+ )
87
+ format_group.add_argument(
88
+ "--markdown",
89
+ dest="output_format",
90
+ action="store_const",
91
+ const="markdown",
92
+ help="write a Markdown report",
93
+ )
94
+ parser.set_defaults(output_format="terminal")
95
+
96
+ parser.add_argument(
97
+ "--details",
98
+ action="store_true",
99
+ help="include individual invocation evidence in human-readable output",
100
+ )
101
+ parser.add_argument(
102
+ "-o",
103
+ "--output",
104
+ default="-",
105
+ metavar="PATH",
106
+ help="write output to PATH; use - for stdout (default: -)",
107
+ )
108
+ parser.add_argument(
109
+ "--width",
110
+ type=positive_width,
111
+ metavar="COLUMNS",
112
+ help="override terminal table width (minimum: 60)",
113
+ )
114
+ parser.add_argument(
115
+ "--no-color",
116
+ action="store_true",
117
+ help="disable terminal colors",
118
+ )
119
+ parser.add_argument(
120
+ "--version",
121
+ action="version",
122
+ version=f"%(prog)s {package_version()}",
123
+ )
124
+ return parser.parse_args(argv)
125
+
126
+
127
+ def color_is_disabled(args: argparse.Namespace) -> bool:
128
+ """Honor explicit and conventional terminal color controls."""
129
+ return bool(
130
+ args.no_color or "NO_COLOR" in os.environ or os.environ.get("TERM", "").casefold() == "dumb"
131
+ )
132
+
133
+
134
+ def scan_with_feedback(args: argparse.Namespace) -> ScanReport:
135
+ """Scan with transient progress feedback only in an interactive terminal."""
136
+ show_status = (
137
+ args.output_format == "terminal"
138
+ and args.output == "-"
139
+ and sys.stdout.isatty()
140
+ and sys.stderr.isatty()
141
+ )
142
+ if not show_status:
143
+ return scan_codex_home(args.codex_home)
144
+
145
+ error_console = Console(stderr=True, no_color=color_is_disabled(args))
146
+ with error_console.status("Scanning retained Codex sessions…", spinner="dots"):
147
+ return scan_codex_home(args.codex_home)
148
+
149
+
150
+ def open_output(path: str) -> tuple[TextIO, bool]:
151
+ """Open the primary output destination and report ownership."""
152
+ if path == "-":
153
+ return sys.stdout, False
154
+ return Path(path).expanduser().open("w", encoding="utf-8", newline=""), True
155
+
156
+
157
+ def write_report(args: argparse.Namespace, stream: TextIO, report: ScanReport) -> None:
158
+ """Render the selected output contract to a text stream."""
159
+ if args.output_format == "terminal":
160
+ output_is_terminal = stream is sys.stdout and sys.stdout.isatty()
161
+ console = Console(
162
+ file=stream,
163
+ force_terminal=output_is_terminal,
164
+ no_color=color_is_disabled(args) or not output_is_terminal,
165
+ width=args.width,
166
+ )
167
+ render_terminal(report, console, details=args.details)
168
+ elif args.output_format == "json":
169
+ stream.write(render_json(report))
170
+ elif args.output_format == "csv":
171
+ stream.write(render_csv(report))
172
+ else:
173
+ stream.write(render_markdown(report, details=args.details))
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ """Run the CLI and map expected failures to stable exit codes."""
178
+ args = parse_args(argv)
179
+ error_console = Console(stderr=True, no_color=color_is_disabled(args))
180
+
181
+ try:
182
+ report = scan_with_feedback(args)
183
+ stream, should_close = open_output(args.output)
184
+ try:
185
+ write_report(args, stream, report)
186
+ finally:
187
+ if should_close:
188
+ stream.close()
189
+ except CodexHomeNotFoundError as error:
190
+ error_console.print(f"[red]error:[/red] {error}")
191
+ return 2
192
+ except OSError as error:
193
+ error_console.print(f"[red]error:[/red] {error}")
194
+ return 1
195
+ except KeyboardInterrupt:
196
+ error_console.print("[yellow]Interrupted.[/yellow]")
197
+ return 130
198
+ return 0
@@ -0,0 +1,60 @@
1
+ """Data models for Codex skill invocation reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ Classification = Literal["self", "requested", "ambiguous"]
9
+ AgentKind = Literal["root", "subagent"]
10
+ Availability = Literal["installed", "removed", "unknown"]
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Invocation:
15
+ """One inferred skill invocation in a Codex turn."""
16
+
17
+ skill: str
18
+ classification: Classification
19
+ agent: AgentKind
20
+ session_id: str
21
+ turn_id: str
22
+ timestamp: str
23
+ transcript: str
24
+ evidence: str
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class ScanStats:
29
+ """Observable results and recoverable errors from a transcript scan."""
30
+
31
+ sessions_scanned: int = 0
32
+ malformed_lines: int = 0
33
+ unreadable_sessions: int = 0
34
+ unattributed_skill_reads: int = 0
35
+ replayed_invocations_deduplicated: int = 0
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class SkillSummary:
40
+ """Aggregated invocation counts for one canonical skill name."""
41
+
42
+ skill: str
43
+ self_invocations: int
44
+ requested_invocations: int
45
+ ambiguous_invocations: int
46
+ root_self_invocations: int
47
+ subagent_self_invocations: int
48
+ sessions: int
49
+ last_used: str
50
+ availability: Availability
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class ScanReport:
55
+ """Complete report produced from all discovered transcripts."""
56
+
57
+ codex_home: str
58
+ stats: ScanStats
59
+ skills: tuple[SkillSummary, ...]
60
+ invocations: tuple[Invocation, ...]
@@ -0,0 +1,295 @@
1
+ """Human and machine renderers for skill invocation reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from dataclasses import asdict
8
+ from datetime import UTC, datetime
9
+ from io import StringIO
10
+ from typing import TYPE_CHECKING
11
+
12
+ from rich import box
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ if TYPE_CHECKING:
17
+ from rich.console import Console
18
+
19
+ from codex_skill_report.models import ScanReport
20
+
21
+ DAYS_PER_YEAR = 365
22
+ FULL_TABLE_MIN_WIDTH = 120
23
+
24
+
25
+ def render_terminal(report: ScanReport, console: Console, *, details: bool) -> None:
26
+ """Render a width-aware terminal report with a real Unicode table."""
27
+ stats = report.stats
28
+ console.print()
29
+ console.print("[bold]Codex skill invocation report[/bold]")
30
+
31
+ summary = Text(style="dim")
32
+ summary.append(f"{stats.sessions_scanned:,} sessions")
33
+ summary.append(" • ")
34
+ summary.append(f"{len(report.invocations):,} invocation events")
35
+ if stats.unattributed_skill_reads:
36
+ summary.append(" • ")
37
+ summary.append(
38
+ f"{stats.unattributed_skill_reads:,} unattributed wildcard reads",
39
+ style="yellow",
40
+ )
41
+ if stats.replayed_invocations_deduplicated:
42
+ summary.append(" • ")
43
+ summary.append(f"{stats.replayed_invocations_deduplicated:,} replayed events deduplicated")
44
+ console.print(summary)
45
+ console.print()
46
+
47
+ if not report.skills:
48
+ console.print("[yellow]No skill instruction reads were found.[/yellow]")
49
+ return
50
+
51
+ table, compact = build_skill_table(report, console.width)
52
+ console.print(table)
53
+ if compact:
54
+ console.print(
55
+ f"[dim]Compact view hides Root self and Subagent self. "
56
+ f"Use --width {FULL_TABLE_MIN_WIDTH} for the full table.[/dim]"
57
+ )
58
+
59
+ recoverable_errors = stats.malformed_lines + stats.unreadable_sessions
60
+ if recoverable_errors:
61
+ console.print(
62
+ f"[yellow]Skipped {stats.malformed_lines:,} malformed lines and "
63
+ f"{stats.unreadable_sessions:,} unreadable sessions.[/yellow]"
64
+ )
65
+
66
+ legend = Table.grid(padding=(0, 1))
67
+ legend.add_column(style="bold dim", no_wrap=True)
68
+ legend.add_column(style="dim")
69
+ legend.add_row(
70
+ "Self",
71
+ "SKILL.md read without an explicit request for that skill in the active turn.",
72
+ )
73
+ legend.add_row(
74
+ "Requested",
75
+ "SKILL.md read after the active request explicitly asks to use that skill.",
76
+ )
77
+ legend.add_row(
78
+ "Ambiguous",
79
+ "SKILL.md read when the active request mentions the skill but does not "
80
+ "clearly ask to use it.",
81
+ )
82
+ legend.add_row("Root self", "Self invocation by the primary Codex agent.")
83
+ legend.add_row("Subagent self", "Self invocation by a child agent.")
84
+ legend.add_row(
85
+ "Sessions",
86
+ "Unique sessions containing at least one detected invocation of that skill.",
87
+ )
88
+ legend.add_row(
89
+ "Last used",
90
+ "Elapsed time since the most recent detected invocation of that skill.",
91
+ )
92
+ legend.add_row(
93
+ Text("Red skill", style="bold red"),
94
+ "Previously managed skill no longer found. Project skills are not checked.",
95
+ )
96
+ console.print(legend)
97
+ console.print(
98
+ "[dim]Repeated reads count once per skill, agent, and turn. "
99
+ "Self = Root self + Subagent self.[/dim]"
100
+ )
101
+
102
+ if details:
103
+ render_terminal_details(report, console)
104
+
105
+
106
+ def build_skill_table(report: ScanReport, width: int) -> tuple[Table, bool]:
107
+ """Build the full or compact aggregate table for the available width."""
108
+ table = Table(
109
+ box=box.ROUNDED,
110
+ header_style="bold",
111
+ highlight=True,
112
+ expand=True,
113
+ )
114
+ compact = width < FULL_TABLE_MIN_WIDTH
115
+ table.add_column("Skill", ratio=4 if compact else 3, overflow="fold")
116
+ table.add_column("Self", justify="right", style="bold cyan", no_wrap=True)
117
+ table.add_column("Req" if compact else "Requested", justify="right", no_wrap=True)
118
+ table.add_column("Amb" if compact else "Ambiguous", justify="right", no_wrap=True)
119
+ if not compact:
120
+ table.add_column("Root self", justify="right", no_wrap=True)
121
+ table.add_column("Subagent self", justify="right", no_wrap=True)
122
+ table.add_column("Sess" if compact else "Sessions", justify="right", no_wrap=True)
123
+ table.add_column("Last" if compact else "Last used", justify="right", no_wrap=True)
124
+
125
+ now = datetime.now(UTC)
126
+ for row in report.skills:
127
+ values = (
128
+ Text(row.skill, style="bold red" if row.availability == "removed" else ""),
129
+ f"{row.self_invocations:,}",
130
+ f"{row.requested_invocations:,}",
131
+ f"{row.ambiguous_invocations:,}",
132
+ f"{row.root_self_invocations:,}",
133
+ f"{row.subagent_self_invocations:,}",
134
+ f"{row.sessions:,}",
135
+ format_relative_age(row.last_used, now=now),
136
+ )
137
+ if compact:
138
+ table.add_row(values[0], values[1], values[2], values[3], values[6], values[7])
139
+ else:
140
+ table.add_row(*values)
141
+ return table, compact
142
+
143
+
144
+ def render_terminal_details(report: ScanReport, console: Console) -> None:
145
+ """Render individual invocation evidence after the aggregate table."""
146
+ console.print()
147
+ detail_table = Table(
148
+ title="Invocation evidence",
149
+ box=box.SIMPLE_HEAVY,
150
+ header_style="bold",
151
+ expand=True,
152
+ )
153
+ detail_table.add_column("Timestamp", no_wrap=True)
154
+ detail_table.add_column("Skill", ratio=2, overflow="fold")
155
+ detail_table.add_column("Kind", no_wrap=True)
156
+ detail_table.add_column("Agent", no_wrap=True)
157
+ detail_table.add_column("Session", ratio=2, overflow="ellipsis")
158
+ detail_table.add_column("Evidence", ratio=3, overflow="fold")
159
+
160
+ styles = {"self": "cyan", "requested": "yellow", "ambiguous": "magenta"}
161
+ for invocation in sorted(report.invocations, key=lambda item: item.timestamp):
162
+ detail_table.add_row(
163
+ invocation.timestamp,
164
+ invocation.skill,
165
+ Text(invocation.classification, style=styles[invocation.classification]),
166
+ invocation.agent,
167
+ invocation.session_id,
168
+ invocation.evidence,
169
+ )
170
+ console.print(detail_table)
171
+
172
+
173
+ def render_markdown(report: ScanReport, *, details: bool) -> str:
174
+ """Return a portable Markdown report."""
175
+ stats = report.stats
176
+ lines = [
177
+ "# Codex skill invocation report",
178
+ "",
179
+ f"- Sessions scanned: {stats.sessions_scanned}",
180
+ f"- Unique invocation events: {len(report.invocations)}",
181
+ f"- Malformed transcript lines skipped: {stats.malformed_lines}",
182
+ f"- Unreadable sessions skipped: {stats.unreadable_sessions}",
183
+ f"- Wildcard reads that could not be attributed: {stats.unattributed_skill_reads}",
184
+ f"- Replayed invocation events deduplicated: {stats.replayed_invocations_deduplicated}",
185
+ "",
186
+ (
187
+ "A self-invocation is a `SKILL.md` read without an explicit request for "
188
+ "that skill in the active turn. Repeated reads are counted once per "
189
+ "skill, agent, and turn."
190
+ ),
191
+ "",
192
+ ]
193
+
194
+ if not report.skills:
195
+ lines.append("No skill instruction reads were found.")
196
+ return "\n".join(lines) + "\n"
197
+
198
+ lines.extend(
199
+ (
200
+ (
201
+ "| Skill | Self | Requested | Ambiguous | Root self | "
202
+ "Subagent self | Sessions | Last used |"
203
+ ),
204
+ "|---|---:|---:|---:|---:|---:|---:|---:|",
205
+ )
206
+ )
207
+ now = datetime.now(UTC)
208
+ lines.extend(
209
+ (
210
+ f"| {markdown_escape(row.skill)} | {row.self_invocations} | "
211
+ f"{row.requested_invocations} | {row.ambiguous_invocations} | "
212
+ f"{row.root_self_invocations} | {row.subagent_self_invocations} | "
213
+ f"{row.sessions} | {format_relative_age(row.last_used, now=now)} |"
214
+ )
215
+ for row in report.skills
216
+ )
217
+
218
+ if details:
219
+ lines.extend(
220
+ (
221
+ "",
222
+ "## Invocation evidence",
223
+ "",
224
+ "| Timestamp | Skill | Classification | Agent | Session | Evidence |",
225
+ "|---|---|---|---|---|---|",
226
+ )
227
+ )
228
+ lines.extend(
229
+ (
230
+ f"| {markdown_escape(invocation.timestamp)} | "
231
+ f"{markdown_escape(invocation.skill)} | {invocation.classification} | "
232
+ f"{invocation.agent} | {markdown_escape(invocation.session_id)} | "
233
+ f"{markdown_escape(invocation.evidence)} |"
234
+ )
235
+ for invocation in sorted(report.invocations, key=lambda item: item.timestamp)
236
+ )
237
+ return "\n".join(lines) + "\n"
238
+
239
+
240
+ def render_json(report: ScanReport) -> str:
241
+ """Return the complete report as structured JSON."""
242
+ return (
243
+ json.dumps(
244
+ {
245
+ "codex_home": report.codex_home,
246
+ "stats": asdict(report.stats),
247
+ "skills": [asdict(skill) for skill in report.skills],
248
+ "invocations": [asdict(invocation) for invocation in report.invocations],
249
+ },
250
+ indent=2,
251
+ ensure_ascii=False,
252
+ )
253
+ + "\n"
254
+ )
255
+
256
+
257
+ def render_csv(report: ScanReport) -> str:
258
+ """Return aggregate skill counts as CSV."""
259
+ output = StringIO(newline="")
260
+ fieldnames = (
261
+ "skill",
262
+ "self_invocations",
263
+ "requested_invocations",
264
+ "ambiguous_invocations",
265
+ "root_self_invocations",
266
+ "subagent_self_invocations",
267
+ "sessions",
268
+ "last_used",
269
+ "availability",
270
+ )
271
+ writer = csv.DictWriter(output, fieldnames=fieldnames)
272
+ writer.writeheader()
273
+ writer.writerows(asdict(skill) for skill in report.skills)
274
+ return output.getvalue()
275
+
276
+
277
+ def markdown_escape(value: str) -> str:
278
+ """Escape cell delimiters and newlines for Markdown tables."""
279
+ return value.replace("|", "\\|").replace("\n", " ")
280
+
281
+
282
+ def format_relative_age(timestamp: str, *, now: datetime) -> str:
283
+ """Format an RFC 3339 timestamp as a compact age in days or years."""
284
+ if not timestamp:
285
+ return "—"
286
+
287
+ used_at = datetime.fromisoformat(timestamp)
288
+ if used_at.tzinfo is None:
289
+ used_at = used_at.replace(tzinfo=UTC)
290
+ days = max(0, (now - used_at).days)
291
+ if days < 1:
292
+ return "<1d"
293
+ if days < DAYS_PER_YEAR:
294
+ return f"{days}d"
295
+ return f"{days // DAYS_PER_YEAR}y"
@@ -0,0 +1,526 @@
1
+ """Discover Codex transcripts and infer skill invocations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from codex_skill_report.models import (
13
+ AgentKind,
14
+ Availability,
15
+ Classification,
16
+ Invocation,
17
+ ScanReport,
18
+ ScanStats,
19
+ SkillSummary,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterable, Iterator
24
+
25
+ SKILL_REFERENCE_RE = re.compile(
26
+ r"(?:skill|file)://[^\s\"'`]+(?:/|\\)SKILL\.md"
27
+ r"|(?:[A-Za-z]:[\\/]|/|~[\\/]|\.{1,2}[\\/])"
28
+ r"(?:[^\"'\r\n;|&<>])*?(?:/|\\)SKILL\.md",
29
+ re.IGNORECASE,
30
+ )
31
+ READ_COMMAND_RE = re.compile(
32
+ r"\b(?:cat|sed|head|tail|less|more|bat|awk|perl|rg|grep|"
33
+ r"get-content|read_file|read_text_file|open_text_file)\b",
34
+ re.IGNORECASE,
35
+ )
36
+ CONTENT_READ_API_RE = re.compile(
37
+ r"(?:\.read_text\s*\(|\bFile\.read\s*\(|\breadFile(?:Sync)?\s*\()",
38
+ re.IGNORECASE,
39
+ )
40
+ EXPLICIT_VERB_RE = re.compile(
41
+ r"\b(?:use|using|invoke|invoking|run|running|apply|applying|load|loading|"
42
+ r"activate|activating)\b",
43
+ re.IGNORECASE,
44
+ )
45
+ EXPLICIT_VERB_DISTANCE = 35
46
+
47
+
48
+ class CodexHomeNotFoundError(FileNotFoundError):
49
+ """Raised when the selected Codex home directory does not exist."""
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class _TranscriptState:
54
+ session_id: str
55
+ agent: AgentKind = "root"
56
+ current_turn: str = "unknown"
57
+ current_user_text: str = ""
58
+ pending_user_messages: list[str] = field(default_factory=list)
59
+ seen: set[tuple[str, str]] = field(default_factory=set)
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class _SummaryAccumulator:
64
+ display_name: str
65
+ self_invocations: int = 0
66
+ requested_invocations: int = 0
67
+ ambiguous_invocations: int = 0
68
+ root_self_invocations: int = 0
69
+ subagent_self_invocations: int = 0
70
+ sessions: set[str] = field(default_factory=set)
71
+ last_used: str = ""
72
+ managed_source: bool = False
73
+
74
+
75
+ def discover_transcripts(codex_home: Path) -> list[Path]:
76
+ """Find retained active, archived, and catalogued transcript files."""
77
+ paths: set[Path] = set()
78
+ for directory_name in ("sessions", "archived_sessions"):
79
+ directory = codex_home / directory_name
80
+ if directory.is_dir():
81
+ paths.update(path.resolve() for path in directory.rglob("*.jsonl"))
82
+
83
+ sqlite_dir = codex_home / "sqlite"
84
+ if sqlite_dir.is_dir():
85
+ for database in sqlite_dir.glob("state_*.sqlite"):
86
+ try:
87
+ uri = f"{database.resolve().as_uri()}?mode=ro"
88
+ with sqlite3.connect(uri, uri=True) as connection:
89
+ rows = connection.execute("SELECT rollout_path FROM threads").fetchall()
90
+ except (sqlite3.Error, OSError):
91
+ continue
92
+ for (raw_path,) in rows:
93
+ if not raw_path:
94
+ continue
95
+ path = Path(raw_path).expanduser()
96
+ if not path.is_absolute():
97
+ path = codex_home / path
98
+ if path.is_file():
99
+ paths.add(path.resolve())
100
+
101
+ return sorted(paths)
102
+
103
+
104
+ def managed_skill_roots(
105
+ codex_home: Path,
106
+ *,
107
+ user_home: Path | None = None,
108
+ ) -> tuple[Path, ...]:
109
+ """Return roots managed by the user or Codex, excluding project folders."""
110
+ home = user_home or Path.home()
111
+ return (
112
+ home / ".agents" / "skills",
113
+ codex_home / "skills",
114
+ codex_home / "plugins" / "cache",
115
+ )
116
+
117
+
118
+ def discover_installed_skills(roots: Iterable[Path]) -> set[str]:
119
+ """Return canonical skill names installed under managed roots."""
120
+ return {
121
+ canonical_skill_name(str(skill_file)).casefold()
122
+ for root in roots
123
+ if root.is_dir()
124
+ for skill_file in root.rglob("SKILL.md")
125
+ }
126
+
127
+
128
+ def reference_is_managed(reference: str, roots: Iterable[Path]) -> bool:
129
+ """Return whether historical evidence points inside a managed skill root."""
130
+ normalized = reference.removeprefix("file://").replace("\\", "/")
131
+ return any(normalized.startswith(f"{root.as_posix().rstrip('/')}/") for root in roots)
132
+
133
+
134
+ def text_from_message(payload: dict[str, Any]) -> str:
135
+ content = payload.get("content")
136
+ if isinstance(content, str):
137
+ return content
138
+ if not isinstance(content, list):
139
+ return ""
140
+
141
+ parts: list[str] = []
142
+ for item in content:
143
+ if isinstance(item, str):
144
+ parts.append(item)
145
+ elif isinstance(item, dict):
146
+ text = item.get("text")
147
+ if isinstance(text, str):
148
+ parts.append(text)
149
+ return "\n".join(parts)
150
+
151
+
152
+ def stringify_call_input(payload: dict[str, Any]) -> str:
153
+ value = payload.get("arguments")
154
+ if value is None:
155
+ value = payload.get("input")
156
+ if isinstance(value, str):
157
+ return value
158
+ if value is None:
159
+ return ""
160
+ try:
161
+ return json.dumps(value, ensure_ascii=False)
162
+ except (TypeError, ValueError):
163
+ return str(value)
164
+
165
+
166
+ def is_read_call(tool_name: str, call_input: str) -> bool:
167
+ name = tool_name.casefold()
168
+ input_lower = call_input.casefold()
169
+ if "apply_patch" in name or "apply_patch" in input_lower or name.endswith("write_file"):
170
+ return False
171
+ if "skill" in name and any(word in name for word in ("read", "fetch", "load")):
172
+ return True
173
+ if any(word in name for word in ("read_file", "read_text", "open_file")):
174
+ return True
175
+ return bool(READ_COMMAND_RE.search(call_input) or CONTENT_READ_API_RE.search(call_input))
176
+
177
+
178
+ def extract_skill_references(call_input: str) -> set[str]:
179
+ references: set[str] = set()
180
+ for match in SKILL_REFERENCE_RE.finditer(call_input.replace("\\/", "/")):
181
+ reference = match.group(0).strip()
182
+ reference = re.sub(r"^.*?((?:skill|file)://)", r"\1", reference)
183
+ references.add(reference)
184
+ return references
185
+
186
+
187
+ def canonical_skill_name(reference: str) -> str:
188
+ normalized = reference.replace("\\", "/").rstrip("/ ")
189
+
190
+ plugin_match = re.search(
191
+ r"/plugins/cache/[^/]+/([^/]+)/[^/]+/skills/([^/]+)/SKILL\.md$",
192
+ normalized,
193
+ re.IGNORECASE,
194
+ )
195
+ if plugin_match:
196
+ return f"{plugin_match.group(1)}:{plugin_match.group(2)}"
197
+
198
+ plugin_match = re.search(
199
+ r"/plugins/([^/]+)/skills/([^/]+)/SKILL\.md$",
200
+ normalized,
201
+ re.IGNORECASE,
202
+ )
203
+ if plugin_match:
204
+ return f"{plugin_match.group(1)}:{plugin_match.group(2)}"
205
+
206
+ skill_match = re.search(
207
+ r"/(?:\.agents|\.codex|vendor_imports)/skills/(?:\.system/)?([^/]+)/SKILL\.md$",
208
+ normalized,
209
+ re.IGNORECASE,
210
+ )
211
+ if skill_match:
212
+ return skill_match.group(1)
213
+
214
+ generic_match = re.search(r"/skills/([^/]+)/SKILL\.md$", normalized, re.IGNORECASE)
215
+ if generic_match:
216
+ return generic_match.group(1)
217
+
218
+ return normalized.rsplit("/", maxsplit=2)[-2]
219
+
220
+
221
+ def expand_skill_reference(reference: str) -> list[str]:
222
+ brace_match = re.search(r"/\{([^{}]+)\}/SKILL\.md$", reference, re.IGNORECASE)
223
+ if brace_match:
224
+ alternatives = [item.strip() for item in brace_match.group(1).split(",")]
225
+ return [
226
+ reference[: brace_match.start(1) - 1]
227
+ + alternative
228
+ + reference[brace_match.end(1) + 1 :]
229
+ for alternative in alternatives
230
+ if alternative
231
+ ]
232
+ if any(character in reference for character in ("*", "?", "[")):
233
+ return []
234
+ return [reference]
235
+
236
+
237
+ def alias_pattern(alias: str) -> re.Pattern[str]:
238
+ return re.compile(rf"(?<![\w-]){re.escape(alias)}(?![\w-])", re.IGNORECASE)
239
+
240
+
241
+ def classify_invocation(user_text: str, skill: str) -> Classification:
242
+ aliases = {skill, skill.rsplit(":", 1)[-1]}
243
+ text = user_text.casefold()
244
+ mentioned = False
245
+
246
+ for alias in aliases:
247
+ alias_lower = alias.casefold()
248
+ if re.search(rf"(?:\$|/skills?:){re.escape(alias_lower)}(?![\w-])", text):
249
+ return "requested"
250
+
251
+ for match in alias_pattern(alias_lower).finditer(text):
252
+ mentioned = True
253
+ before = text[max(0, match.start() - 80) : match.start()]
254
+ after = text[match.end() : match.end() + 24]
255
+ last_verb = None
256
+ for verb_match in EXPLICIT_VERB_RE.finditer(before):
257
+ last_verb = verb_match
258
+ if last_verb and len(before) - last_verb.end() <= EXPLICIT_VERB_DISTANCE:
259
+ return "requested"
260
+ if re.match(r"\s+skill\b", after) and EXPLICIT_VERB_RE.search(before[-60:]):
261
+ return "requested"
262
+
263
+ return "ambiguous" if mentioned else "self"
264
+
265
+
266
+ def session_is_subagent(payload: dict[str, Any]) -> bool:
267
+ if payload.get("thread_source") == "subagent" or payload.get("parent_thread_id"):
268
+ return True
269
+ source = payload.get("source")
270
+ return isinstance(source, dict) and "subagent" in source
271
+
272
+
273
+ def joined_user_messages(messages: Iterable[str]) -> str:
274
+ unique: list[str] = []
275
+ for message in messages:
276
+ if message and message not in unique:
277
+ unique.append(message)
278
+ return "\n".join(unique)
279
+
280
+
281
+ def transcript_records(path: Path, stats: ScanStats) -> Iterator[tuple[int, dict[str, Any]]]:
282
+ try:
283
+ lines = path.open(encoding="utf-8")
284
+ except OSError:
285
+ stats.unreadable_sessions += 1
286
+ return
287
+
288
+ with lines:
289
+ for line_number, line in enumerate(lines, start=1):
290
+ try:
291
+ record = json.loads(line)
292
+ except (json.JSONDecodeError, UnicodeDecodeError):
293
+ stats.malformed_lines += 1
294
+ continue
295
+ if isinstance(record, dict):
296
+ yield line_number, record
297
+
298
+
299
+ def apply_context_event(
300
+ record: dict[str, Any],
301
+ payload: dict[str, Any],
302
+ state: _TranscriptState,
303
+ line_number: int,
304
+ ) -> bool:
305
+ record_type = record.get("type")
306
+ payload_type = payload.get("type")
307
+
308
+ if record_type == "session_meta":
309
+ state.session_id = str(payload.get("id") or payload.get("session_id") or state.session_id)
310
+ state.agent = "subagent" if session_is_subagent(payload) else "root"
311
+ return True
312
+
313
+ if record_type == "response_item" and payload_type == "message":
314
+ if payload.get("role") == "user":
315
+ message = text_from_message(payload)
316
+ if message:
317
+ state.pending_user_messages.append(message)
318
+ return True
319
+
320
+ if record_type == "event_msg" and payload_type == "user_message":
321
+ message = payload.get("message")
322
+ if isinstance(message, str) and message:
323
+ state.pending_user_messages.append(message)
324
+ return True
325
+
326
+ if record_type == "event_msg" and payload_type == "task_started":
327
+ state.current_turn = str(payload.get("turn_id") or f"line-{line_number}")
328
+ state.current_user_text = joined_user_messages(state.pending_user_messages)
329
+ state.pending_user_messages.clear()
330
+ return True
331
+
332
+ if record_type == "turn_context":
333
+ state.current_turn = str(payload.get("turn_id") or state.current_turn)
334
+ if state.pending_user_messages:
335
+ state.current_user_text = joined_user_messages(state.pending_user_messages)
336
+ state.pending_user_messages.clear()
337
+ return True
338
+
339
+ return False
340
+
341
+
342
+ def invocations_from_tool_call(
343
+ record: dict[str, Any],
344
+ payload: dict[str, Any],
345
+ state: _TranscriptState,
346
+ path: Path,
347
+ stats: ScanStats,
348
+ ) -> list[Invocation]:
349
+ if record.get("type") != "response_item" or payload.get("type") not in {
350
+ "function_call",
351
+ "custom_tool_call",
352
+ }:
353
+ return []
354
+
355
+ call_input = stringify_call_input(payload)
356
+ if not is_read_call(str(payload.get("name") or ""), call_input):
357
+ return []
358
+
359
+ if state.pending_user_messages:
360
+ state.current_user_text = joined_user_messages(state.pending_user_messages)
361
+ state.pending_user_messages.clear()
362
+
363
+ invocations: list[Invocation] = []
364
+ for raw_reference in extract_skill_references(call_input):
365
+ references = expand_skill_reference(raw_reference)
366
+ if not references:
367
+ stats.unattributed_skill_reads += 1
368
+ continue
369
+ for reference in references:
370
+ skill = canonical_skill_name(reference)
371
+ dedupe_key = (state.current_turn, skill.casefold())
372
+ if dedupe_key in state.seen:
373
+ continue
374
+ state.seen.add(dedupe_key)
375
+ invocations.append(
376
+ Invocation(
377
+ skill=skill,
378
+ classification=classify_invocation(state.current_user_text, skill),
379
+ agent=state.agent,
380
+ session_id=state.session_id,
381
+ turn_id=state.current_turn,
382
+ timestamp=str(record.get("timestamp") or ""),
383
+ transcript=str(path),
384
+ evidence=raw_reference,
385
+ )
386
+ )
387
+ return invocations
388
+
389
+
390
+ def scan_transcript(path: Path, stats: ScanStats) -> list[Invocation]:
391
+ """Scan one JSONL transcript and return deduplicated skill invocations."""
392
+ stats.sessions_scanned += 1
393
+ state = _TranscriptState(session_id=path.stem)
394
+ invocations: list[Invocation] = []
395
+
396
+ for line_number, record in transcript_records(path, stats):
397
+ payload = record.get("payload")
398
+ if not isinstance(payload, dict):
399
+ continue
400
+ if apply_context_event(record, payload, state, line_number):
401
+ continue
402
+ invocations.extend(invocations_from_tool_call(record, payload, state, path, stats))
403
+
404
+ return invocations
405
+
406
+
407
+ def deduplicate_replayed_invocations(
408
+ invocations: Iterable[Invocation],
409
+ ) -> tuple[tuple[Invocation, ...], int]:
410
+ """Collapse forked transcript replays of the same session turn and skill."""
411
+ unique: dict[tuple[str, str, str], Invocation] = {}
412
+ duplicates = 0
413
+
414
+ for invocation in invocations:
415
+ key = (
416
+ invocation.session_id,
417
+ invocation.turn_id.casefold(),
418
+ invocation.skill.casefold(),
419
+ )
420
+ existing = unique.get(key)
421
+ if existing is None:
422
+ unique[key] = invocation
423
+ continue
424
+
425
+ duplicates += 1
426
+ if invocation.timestamp and (
427
+ not existing.timestamp or invocation.timestamp < existing.timestamp
428
+ ):
429
+ unique[key] = invocation
430
+
431
+ return tuple(unique.values()), duplicates
432
+
433
+
434
+ def aggregate(
435
+ invocations: Iterable[Invocation],
436
+ installed_skills: set[str],
437
+ roots: tuple[Path, ...],
438
+ ) -> tuple[SkillSummary, ...]:
439
+ """Aggregate invocation events by case-insensitive canonical skill name."""
440
+ rows: dict[str, _SummaryAccumulator] = {}
441
+
442
+ for invocation in invocations:
443
+ key = invocation.skill.casefold()
444
+ row = rows.setdefault(key, _SummaryAccumulator(display_name=invocation.skill))
445
+ if invocation.classification == "self":
446
+ row.self_invocations += 1
447
+ if invocation.agent == "root":
448
+ row.root_self_invocations += 1
449
+ else:
450
+ row.subagent_self_invocations += 1
451
+ elif invocation.classification == "requested":
452
+ row.requested_invocations += 1
453
+ else:
454
+ row.ambiguous_invocations += 1
455
+ row.sessions.add(invocation.session_id)
456
+ row.last_used = max(row.last_used, invocation.timestamp)
457
+ row.managed_source = row.managed_source or reference_is_managed(
458
+ invocation.evidence,
459
+ roots,
460
+ )
461
+
462
+ result = [
463
+ SkillSummary(
464
+ skill=row.display_name,
465
+ self_invocations=row.self_invocations,
466
+ requested_invocations=row.requested_invocations,
467
+ ambiguous_invocations=row.ambiguous_invocations,
468
+ root_self_invocations=row.root_self_invocations,
469
+ subagent_self_invocations=row.subagent_self_invocations,
470
+ sessions=len(row.sessions),
471
+ last_used=row.last_used,
472
+ availability=skill_availability(
473
+ row.display_name,
474
+ installed_skills,
475
+ managed_source=row.managed_source,
476
+ ),
477
+ )
478
+ for row in rows.values()
479
+ ]
480
+
481
+ return tuple(
482
+ sorted(
483
+ result,
484
+ key=lambda row: (
485
+ -row.self_invocations,
486
+ -row.requested_invocations,
487
+ -row.ambiguous_invocations,
488
+ row.skill.casefold(),
489
+ ),
490
+ )
491
+ )
492
+
493
+
494
+ def skill_availability(
495
+ skill: str,
496
+ installed_skills: set[str],
497
+ *,
498
+ managed_source: bool,
499
+ ) -> Availability:
500
+ """Classify availability without inspecting repository-local folders."""
501
+ if skill.casefold() in installed_skills:
502
+ return "installed"
503
+ return "removed" if managed_source else "unknown"
504
+
505
+
506
+ def scan_codex_home(codex_home: Path, *, user_home: Path | None = None) -> ScanReport:
507
+ """Scan every retained transcript reachable from a Codex home directory."""
508
+ resolved_home = codex_home.expanduser().resolve()
509
+ if not resolved_home.is_dir():
510
+ message = f"Codex home does not exist: {resolved_home}"
511
+ raise CodexHomeNotFoundError(message)
512
+
513
+ stats = ScanStats()
514
+ invocations: list[Invocation] = []
515
+ for transcript in discover_transcripts(resolved_home):
516
+ invocations.extend(scan_transcript(transcript, stats))
517
+
518
+ unique_invocations, duplicates = deduplicate_replayed_invocations(invocations)
519
+ stats.replayed_invocations_deduplicated = duplicates
520
+ roots = managed_skill_roots(resolved_home, user_home=user_home)
521
+ return ScanReport(
522
+ codex_home=str(resolved_home),
523
+ stats=stats,
524
+ skills=aggregate(unique_invocations, discover_installed_skills(roots), roots),
525
+ invocations=unique_invocations,
526
+ )
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-skill-report
3
+ Version: 0.1.0
4
+ Summary: Analyze inferred skill invocations in retained Codex sessions
5
+ Author: egornomic
6
+ Author-email: egornomic <git@egornomic.xyz>
7
+ License-Expression: 0BSD
8
+ License-File: LICENSE
9
+ Requires-Dist: rich>=15.0.0
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Codex Skill Report
14
+
15
+ Analyze retained Codex session transcripts and estimate how often each skill was
16
+ self-invoked, explicitly requested, or ambiguously mentioned.
17
+
18
+ ## Run with `uvx`
19
+
20
+ From anywhere after publishing to PyPI:
21
+
22
+ ```bash
23
+ uvx codex-skill-report
24
+ ```
25
+
26
+ From a local checkout:
27
+
28
+ ```bash
29
+ uvx --from . codex-skill-report
30
+ ```
31
+
32
+ Useful output modes:
33
+
34
+ ```bash
35
+ uvx codex-skill-report --details
36
+ uvx codex-skill-report --json --output report.json
37
+ uvx codex-skill-report --csv --output report.csv
38
+ uvx codex-skill-report --markdown --output report.md
39
+ ```
40
+
41
+ Use `--codex-home PATH` to scan a non-default Codex data directory. The default
42
+ is `$CODEX_HOME`, falling back to `~/.codex`.
43
+
44
+ ## How counting works
45
+
46
+ A `SKILL.md` read is the strongest event available in retained transcripts. It
47
+ is counted once per skill, agent, and turn, then classified as:
48
+
49
+ - **Self**: the active request did not name the skill.
50
+ - **Requested**: the active request explicitly asked Codex to use the skill.
51
+ - **Ambiguous**: the request mentioned the skill without clearly asking to use it.
52
+
53
+ The report separates self-invocations by root agents and subagents. Deleted,
54
+ ephemeral, compacted-away, or unsynced sessions cannot be recovered. Wildcard
55
+ reads that cannot be attributed to one skill are reported separately. Human
56
+ reports show the most recent invocation as a compact relative age; JSON and CSV
57
+ retain its exact timestamp. In terminal output, red names identify skills that
58
+ previously came from a managed user, system, or plugin root but no longer exist.
59
+ Repository-local skill folders are not scanned and remain uncolored when their
60
+ availability cannot be determined. When forked transcripts replay an earlier
61
+ turn, the report keeps only the earliest occurrence of each skill read.
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ uv sync --all-groups
67
+ uv run pytest
68
+ uv run ruff format --check .
69
+ uv run ruff check .
70
+ uv run ty check src
71
+ uv build
72
+ ```
@@ -0,0 +1,10 @@
1
+ codex_skill_report/__init__.py,sha256=EyvuCLBV2bc9tkKxeMKtocktxrMOVOJ94IYgmiqFhPo,131
2
+ codex_skill_report/cli.py,sha256=PjpCC8RWPeZZZTd993F5Dux-fK6W_kkQiFj6IYpSEC0,6190
3
+ codex_skill_report/models.py,sha256=sdc4Lyui8CJywl9VHV_r8QpSpfYbO87l0TAJo-KoPuE,1493
4
+ codex_skill_report/reporting.py,sha256=1nmVzRK9CnFfsxDYM-goMum_zXUnTWkWH61qdzyocuQ,10489
5
+ codex_skill_report/scanner.py,sha256=Fb1T7mnzjs6pclUsAbFu9Guoi1So5wAV65xCg9dQJog,17712
6
+ codex_skill_report-0.1.0.dist-info/licenses/LICENSE,sha256=c13lbQbpK2EK07mN4GjqFmLbhcjoIO71nvBKdgYrObw,637
7
+ codex_skill_report-0.1.0.dist-info/WHEEL,sha256=l3MmIxu8qaet7ng2J9fFoJnYGj8IREj7jXTbgsuzmy4,81
8
+ codex_skill_report-0.1.0.dist-info/entry_points.txt,sha256=BVbcB9JCvUjm-swWN9-N5mMoBaVZmBSHeDsMUztvdgI,68
9
+ codex_skill_report-0.1.0.dist-info/METADATA,sha256=jzEJ88xXhJwn9Hm2QA69lPsxmmeN5-rE7exlz8gdIZ0,2238
10
+ codex_skill_report-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.32
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ codex-skill-report = codex_skill_report.cli:main
3
+
@@ -0,0 +1,12 @@
1
+ Copyright (C) 2026 egornomic
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted.
5
+
6
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
7
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
8
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
9
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
10
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
11
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
12
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.