codex-skill-report 0.1.0__tar.gz
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.
- codex_skill_report-0.1.0/LICENSE +12 -0
- codex_skill_report-0.1.0/PKG-INFO +72 -0
- codex_skill_report-0.1.0/README.md +60 -0
- codex_skill_report-0.1.0/pyproject.toml +50 -0
- codex_skill_report-0.1.0/src/codex_skill_report/__init__.py +5 -0
- codex_skill_report-0.1.0/src/codex_skill_report/cli.py +198 -0
- codex_skill_report-0.1.0/src/codex_skill_report/models.py +60 -0
- codex_skill_report-0.1.0/src/codex_skill_report/reporting.py +295 -0
- codex_skill_report-0.1.0/src/codex_skill_report/scanner.py +526 -0
|
@@ -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.
|
|
@@ -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,60 @@
|
|
|
1
|
+
# Codex Skill Report
|
|
2
|
+
|
|
3
|
+
Analyze retained Codex session transcripts and estimate how often each skill was
|
|
4
|
+
self-invoked, explicitly requested, or ambiguously mentioned.
|
|
5
|
+
|
|
6
|
+
## Run with `uvx`
|
|
7
|
+
|
|
8
|
+
From anywhere after publishing to PyPI:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uvx codex-skill-report
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
From a local checkout:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uvx --from . codex-skill-report
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Useful output modes:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uvx codex-skill-report --details
|
|
24
|
+
uvx codex-skill-report --json --output report.json
|
|
25
|
+
uvx codex-skill-report --csv --output report.csv
|
|
26
|
+
uvx codex-skill-report --markdown --output report.md
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use `--codex-home PATH` to scan a non-default Codex data directory. The default
|
|
30
|
+
is `$CODEX_HOME`, falling back to `~/.codex`.
|
|
31
|
+
|
|
32
|
+
## How counting works
|
|
33
|
+
|
|
34
|
+
A `SKILL.md` read is the strongest event available in retained transcripts. It
|
|
35
|
+
is counted once per skill, agent, and turn, then classified as:
|
|
36
|
+
|
|
37
|
+
- **Self**: the active request did not name the skill.
|
|
38
|
+
- **Requested**: the active request explicitly asked Codex to use the skill.
|
|
39
|
+
- **Ambiguous**: the request mentioned the skill without clearly asking to use it.
|
|
40
|
+
|
|
41
|
+
The report separates self-invocations by root agents and subagents. Deleted,
|
|
42
|
+
ephemeral, compacted-away, or unsynced sessions cannot be recovered. Wildcard
|
|
43
|
+
reads that cannot be attributed to one skill are reported separately. Human
|
|
44
|
+
reports show the most recent invocation as a compact relative age; JSON and CSV
|
|
45
|
+
retain its exact timestamp. In terminal output, red names identify skills that
|
|
46
|
+
previously came from a managed user, system, or plugin root but no longer exist.
|
|
47
|
+
Repository-local skill folders are not scanned and remain uncolored when their
|
|
48
|
+
availability cannot be determined. When forked transcripts replay an earlier
|
|
49
|
+
turn, the report keeps only the earliest occurrence of each skill read.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv sync --all-groups
|
|
55
|
+
uv run pytest
|
|
56
|
+
uv run ruff format --check .
|
|
57
|
+
uv run ruff check .
|
|
58
|
+
uv run ty check src
|
|
59
|
+
uv build
|
|
60
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "codex-skill-report"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Analyze inferred skill invocations in retained Codex sessions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "0BSD"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "egornomic", email = "git@egornomic.xyz" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"rich>=15.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
codex-skill-report = "codex_skill_report.cli:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.11.30,<0.12.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=9.1.1",
|
|
26
|
+
"ruff>=0.16.0",
|
|
27
|
+
"ty>=0.0.64",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
addopts = "-q"
|
|
32
|
+
pythonpath = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 100
|
|
36
|
+
target-version = "py311"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["ALL"]
|
|
40
|
+
ignore = ["COM812", "CPY001", "D", "ISC001"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint.per-file-ignores]
|
|
43
|
+
"tests/**/*.py" = ["PLR2004", "S101"]
|
|
44
|
+
|
|
45
|
+
[tool.ty.environment]
|
|
46
|
+
python-version = "3.11"
|
|
47
|
+
|
|
48
|
+
[tool.ty.rules]
|
|
49
|
+
possibly-unresolved-reference = "error"
|
|
50
|
+
unused-ignore-comment = "warn"
|
|
@@ -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
|
+
)
|