codex-skill-analytics 0.1.0

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,484 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ from collections.abc import Iterable, Iterator
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ SKILL_BLOCK_RE = re.compile(r"<skill>.*?</skill>", re.DOTALL)
15
+ SKILL_NAME_RE = re.compile(r"<name>([^<]+)</name>")
16
+ SKILL_PATH_RE = re.compile(r"<path>([^<]+)</path>")
17
+ ROOT_ALIAS_RE = re.compile(r"`(r\d+)`\s*=\s*`([^`]+)`")
18
+ CATALOG_ENTRY_RE = re.compile(
19
+ r"^-\s+(.+?):\s+.*?\(file:\s*([^)]+/SKILL\.md)\)", re.MULTILINE
20
+ )
21
+ FRONTMATTER_NAME_RE = re.compile(r"^name:\s*['\"]?([^'\"\n]+)", re.MULTILINE)
22
+
23
+ READERS = frozenset(
24
+ {
25
+ "cat",
26
+ "head",
27
+ "tail",
28
+ "sed",
29
+ "nl",
30
+ "less",
31
+ "more",
32
+ "bat",
33
+ "grep",
34
+ "rg",
35
+ "awk",
36
+ "cut",
37
+ "wc",
38
+ }
39
+ )
40
+ RUNNERS = frozenset({"python", "python3", "bash", "zsh", "sh", "node", "deno", "ruby", "perl", "pwsh"})
41
+ SCRIPT_EXTENSIONS = (".py", ".sh", ".js", ".ts", ".rb", ".pl", ".ps1")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SkillRef:
46
+ name: str
47
+ path: str
48
+ scope: str
49
+
50
+ @property
51
+ def key(self) -> str:
52
+ return self.path
53
+
54
+ @property
55
+ def identity(self) -> str:
56
+ return f"{self.scope}:{self.name}"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SessionMeta:
61
+ thread_id: str
62
+ started_at: str
63
+ cwd: str
64
+ cli_version: str
65
+ parent_thread_id: str | None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Invocation:
70
+ thread_id: str
71
+ turn_id: str
72
+ timestamp: str
73
+ skill: SkillRef
74
+ invoke_type: str
75
+ evidence_type: str
76
+ source_path: str
77
+ source_line: int
78
+ source_order: int
79
+ call_id: str | None
80
+
81
+ @property
82
+ def invocation_id(self) -> str:
83
+ material = f"{self.thread_id}\0{self.turn_id}\0{self.skill.identity}"
84
+ return hashlib.sha256(material.encode()).hexdigest()
85
+
86
+ @property
87
+ def access_id(self) -> str:
88
+ material = "\0".join(
89
+ (
90
+ self.thread_id,
91
+ self.turn_id,
92
+ self.call_id or self.timestamp,
93
+ self.skill.key,
94
+ self.evidence_type,
95
+ )
96
+ )
97
+ return hashlib.sha256(material.encode()).hexdigest()
98
+
99
+
100
+ def infer_scope(path: str, home: Path) -> str:
101
+ normalized = path.replace("\\", "/")
102
+ home_text = str(home).replace("\\", "/")
103
+ if "/plugins/cache/" in normalized:
104
+ return "plugin"
105
+ if normalized.startswith(f"{home_text}/.codex/skills/.system/"):
106
+ return "system"
107
+ if normalized.startswith(
108
+ (f"{home_text}/.agents/skills/", f"{home_text}/.codex/skills/")
109
+ ):
110
+ return "user"
111
+ if "/.agents/skills/" in normalized or "/.codex/skills/" in normalized:
112
+ return "repo"
113
+ return "unknown"
114
+
115
+
116
+ def normalize_path(path: str | Path) -> str:
117
+ return os.path.realpath(os.path.abspath(os.path.expanduser(str(path))))
118
+
119
+
120
+ def skill_name_from_file(path: Path) -> str:
121
+ try:
122
+ prefix = path.read_text(encoding="utf-8", errors="replace")[:8192]
123
+ except OSError:
124
+ return path.parent.name
125
+ match = FRONTMATTER_NAME_RE.search(prefix)
126
+ return match.group(1).strip() if match else path.parent.name
127
+
128
+
129
+ def discover_installed_skills(home: Path | None = None) -> dict[str, SkillRef]:
130
+ home = (home or Path.home()).expanduser().resolve()
131
+ roots = (home / ".agents" / "skills", home / ".codex" / "skills", home / ".codex" / "plugins" / "cache")
132
+ result: dict[str, SkillRef] = {}
133
+ for root in roots:
134
+ if not root.is_dir():
135
+ continue
136
+ for path in root.rglob("SKILL.md"):
137
+ try:
138
+ canonical = normalize_path(path)
139
+ except OSError:
140
+ canonical = str(path.absolute())
141
+ result[canonical] = SkillRef(
142
+ name=skill_name_from_file(path),
143
+ path=canonical,
144
+ scope=infer_scope(canonical, home),
145
+ )
146
+ return result
147
+
148
+
149
+ def iter_strings(value: Any) -> Iterator[str]:
150
+ if isinstance(value, str):
151
+ yield value
152
+ elif isinstance(value, list):
153
+ for item in value:
154
+ yield from iter_strings(item)
155
+ elif isinstance(value, dict):
156
+ for item in value.values():
157
+ yield from iter_strings(item)
158
+
159
+
160
+ def catalog_skills_from_record(record: dict[str, Any], home: Path) -> list[SkillRef]:
161
+ found: list[SkillRef] = []
162
+ for text in iter_strings(record.get("payload")):
163
+ if "SKILL.md" not in text:
164
+ continue
165
+ aliases = {name: path for name, path in ROOT_ALIAS_RE.findall(text)}
166
+ for name, raw_path in CATALOG_ENTRY_RE.findall(text):
167
+ raw_path = raw_path.strip().strip("`")
168
+ alias, slash, suffix = raw_path.partition("/")
169
+ if slash and alias in aliases:
170
+ raw_path = str(Path(aliases[alias]) / suffix)
171
+ path = normalize_path(raw_path)
172
+ found.append(SkillRef(name=name.strip(), path=path, scope=infer_scope(path, home)))
173
+ return found
174
+
175
+
176
+ def explicit_skills_from_record(record: dict[str, Any], home: Path) -> list[SkillRef]:
177
+ payload = record.get("payload")
178
+ if record.get("type") != "response_item" or not isinstance(payload, dict):
179
+ return []
180
+ if payload.get("type") != "message":
181
+ return []
182
+ found: list[SkillRef] = []
183
+ for text in iter_strings(payload.get("content")):
184
+ for block in SKILL_BLOCK_RE.findall(text):
185
+ name_match = SKILL_NAME_RE.search(block)
186
+ path_match = SKILL_PATH_RE.search(block)
187
+ if not name_match or not path_match:
188
+ continue
189
+ path = normalize_path(path_match.group(1).strip())
190
+ found.append(
191
+ SkillRef(name=name_match.group(1).strip(), path=path, scope=infer_scope(path, home))
192
+ )
193
+ return found
194
+
195
+
196
+ def _skip_js_string(source: str, start: int) -> int:
197
+ quote = source[start]
198
+ i = start + 1
199
+ while i < len(source):
200
+ if source[i] == "\\":
201
+ i += 2
202
+ continue
203
+ if source[i] == quote:
204
+ return i + 1
205
+ i += 1
206
+ return len(source)
207
+
208
+
209
+ def _iter_tool_objects(source: str, tool: str) -> Iterator[str]:
210
+ needle = f"tools.{tool}"
211
+ i = 0
212
+ while i < len(source):
213
+ char = source[i]
214
+ if char in "'\"`":
215
+ i = _skip_js_string(source, i)
216
+ continue
217
+ if source.startswith("//", i):
218
+ end = source.find("\n", i + 2)
219
+ i = len(source) if end < 0 else end + 1
220
+ continue
221
+ if source.startswith("/*", i):
222
+ end = source.find("*/", i + 2)
223
+ i = len(source) if end < 0 else end + 2
224
+ continue
225
+ if not source.startswith(needle, i):
226
+ i += 1
227
+ continue
228
+ open_paren = source.find("(", i + len(needle))
229
+ open_brace = source.find("{", open_paren + 1) if open_paren >= 0 else -1
230
+ if open_brace < 0:
231
+ i += len(needle)
232
+ continue
233
+ depth = 0
234
+ j = open_brace
235
+ while j < len(source):
236
+ if source[j] in "'\"`":
237
+ j = _skip_js_string(source, j)
238
+ continue
239
+ if source[j] == "{":
240
+ depth += 1
241
+ elif source[j] == "}":
242
+ depth -= 1
243
+ if depth == 0:
244
+ yield source[open_brace : j + 1]
245
+ i = j + 1
246
+ break
247
+ j += 1
248
+ else:
249
+ return
250
+
251
+
252
+ def _decode_js_string(source: str, start: int) -> tuple[str | None, int]:
253
+ if start >= len(source) or source[start] not in "'\"`":
254
+ return None, start
255
+ end = _skip_js_string(source, start)
256
+ if end <= start + 1 or end > len(source):
257
+ return None, end
258
+ token = source[start:end]
259
+ if token[0] == "`":
260
+ if "${" in token:
261
+ return None, end
262
+ body = token[1:-1]
263
+ return bytes(body, "utf-8").decode("unicode_escape"), end
264
+ try:
265
+ return ast.literal_eval(token), end
266
+ except (SyntaxError, ValueError):
267
+ if token[0] == '"':
268
+ try:
269
+ return json.loads(token), end
270
+ except json.JSONDecodeError:
271
+ pass
272
+ return None, end
273
+
274
+
275
+ def _object_string_field(source: str, field: str) -> str | None:
276
+ i = 0
277
+ while i < len(source):
278
+ if source[i] in "'\"`":
279
+ i = _skip_js_string(source, i)
280
+ continue
281
+ match = re.match(r"[A-Za-z_$][A-Za-z0-9_$]*", source[i:])
282
+ if not match:
283
+ i += 1
284
+ continue
285
+ name = match.group(0)
286
+ i += len(name)
287
+ if name != field:
288
+ continue
289
+ while i < len(source) and source[i].isspace():
290
+ i += 1
291
+ if i >= len(source) or source[i] != ":":
292
+ continue
293
+ i += 1
294
+ while i < len(source) and source[i].isspace():
295
+ i += 1
296
+ value, _ = _decode_js_string(source, i)
297
+ return value
298
+ return None
299
+
300
+
301
+ def exec_commands_from_input(source: str) -> Iterator[tuple[str, str | None]]:
302
+ for obj in _iter_tool_objects(source, "exec_command"):
303
+ command = _object_string_field(obj, "cmd")
304
+ if command is not None:
305
+ yield command, _object_string_field(obj, "workdir")
306
+
307
+
308
+ def _shell_segments(command: str) -> list[list[str]]:
309
+ try:
310
+ lexer = shlex.shlex(command, posix=True, punctuation_chars="|&;()<>")
311
+ lexer.whitespace_split = True
312
+ lexer.commenters = ""
313
+ tokens = list(lexer)
314
+ except ValueError:
315
+ tokens = command.split()
316
+ segments: list[list[str]] = []
317
+ current: list[str] = []
318
+ for token in tokens:
319
+ if token and all(char in "|&;()<>" for char in token):
320
+ if current:
321
+ segments.append(current)
322
+ current = []
323
+ else:
324
+ current.append(token)
325
+ if current:
326
+ segments.append(current)
327
+ return segments
328
+
329
+
330
+ def _executable_and_args(segment: list[str]) -> tuple[str, list[str]]:
331
+ index = 0
332
+ while index < len(segment) and ("=" in segment[index] and not segment[index].startswith("/")):
333
+ index += 1
334
+ while index < len(segment) and Path(segment[index]).name in {"sudo", "env", "command", "builtin"}:
335
+ index += 1
336
+ while index < len(segment) and segment[index].startswith("-"):
337
+ index += 1
338
+ if index >= len(segment):
339
+ return "", []
340
+ return Path(segment[index]).name.lower(), segment[index + 1 :]
341
+
342
+
343
+ def _resolve_candidate(token: str, workdir: Path) -> str | None:
344
+ if not token or token.startswith("-") or "$" in token or "*" in token:
345
+ return None
346
+ token = token.rstrip(":,")
347
+ path = Path(os.path.expanduser(token))
348
+ if not path.is_absolute():
349
+ path = workdir / path
350
+ return normalize_path(path)
351
+
352
+
353
+ def detect_skill_accesses(
354
+ command: str,
355
+ workdir: str | None,
356
+ catalog: dict[str, SkillRef],
357
+ ) -> list[tuple[SkillRef, str]]:
358
+ cwd = Path(workdir or os.getcwd()).expanduser()
359
+ found: list[tuple[SkillRef, str]] = []
360
+ seen: set[tuple[str, str]] = set()
361
+ script_dirs = [(str(Path(path).parent / "scripts"), skill) for path, skill in catalog.items()]
362
+ for segment in _shell_segments(command):
363
+ executable, args = _executable_and_args(segment)
364
+ if executable in READERS:
365
+ for token in args:
366
+ candidate = _resolve_candidate(token, cwd)
367
+ skill = catalog.get(candidate or "")
368
+ if skill is None:
369
+ continue
370
+ key = (skill.key, "document")
371
+ if key not in seen:
372
+ seen.add(key)
373
+ found.append((skill, "document"))
374
+ runner = executable.removesuffix(".exe")
375
+ if runner not in RUNNERS:
376
+ continue
377
+ script_token = next(
378
+ (token for token in args if token != "--" and not token.startswith("-")), None
379
+ )
380
+ if script_token is None or not script_token.lower().endswith(SCRIPT_EXTENSIONS):
381
+ continue
382
+ candidate = _resolve_candidate(script_token, cwd)
383
+ if candidate is None:
384
+ continue
385
+ for scripts_dir, skill in script_dirs:
386
+ try:
387
+ inside = os.path.commonpath((candidate, scripts_dir)) == scripts_dir
388
+ except ValueError:
389
+ inside = False
390
+ key = (skill.key, "script")
391
+ if inside and key not in seen:
392
+ seen.add(key)
393
+ found.append((skill, "script"))
394
+ break
395
+ return found
396
+
397
+
398
+ def parse_session_meta(record: dict[str, Any]) -> SessionMeta | None:
399
+ if record.get("type") != "session_meta" or not isinstance(record.get("payload"), dict):
400
+ return None
401
+ payload = record["payload"]
402
+ thread_id = str(payload.get("id") or payload.get("session_id") or "")
403
+ if not thread_id:
404
+ return None
405
+ return SessionMeta(
406
+ thread_id=thread_id,
407
+ started_at=str(payload.get("timestamp") or record.get("timestamp") or ""),
408
+ cwd=str(payload.get("cwd") or ""),
409
+ cli_version=str(payload.get("cli_version") or ""),
410
+ parent_thread_id=payload.get("parent_thread_id"),
411
+ )
412
+
413
+
414
+ def parse_record_invocations(
415
+ record: dict[str, Any],
416
+ *,
417
+ session: SessionMeta,
418
+ catalog: dict[str, SkillRef],
419
+ source_path: str,
420
+ source_line: int,
421
+ home: Path,
422
+ ) -> list[Invocation]:
423
+ payload = record.get("payload")
424
+ if not isinstance(payload, dict):
425
+ return []
426
+ timestamp = str(record.get("timestamp") or "")
427
+ metadata = payload.get("internal_chat_message_metadata_passthrough")
428
+ metadata = metadata if isinstance(metadata, dict) else {}
429
+ turn_id = str(metadata.get("turn_id") or payload.get("turn_id") or "")
430
+ if not turn_id:
431
+ return []
432
+
433
+ invocations: list[Invocation] = []
434
+ source_order = 0
435
+ for skill in explicit_skills_from_record(record, home):
436
+ invocations.append(
437
+ Invocation(
438
+ thread_id=session.thread_id,
439
+ turn_id=turn_id,
440
+ timestamp=timestamp,
441
+ skill=skill,
442
+ invoke_type="explicit",
443
+ evidence_type="injected_skill_prompt",
444
+ source_path=source_path,
445
+ source_line=source_line,
446
+ source_order=source_order,
447
+ call_id=None,
448
+ )
449
+ )
450
+ source_order += 1
451
+
452
+ if record.get("type") != "response_item" or payload.get("type") != "custom_tool_call":
453
+ return invocations
454
+ if payload.get("name") != "exec" or not isinstance(payload.get("input"), str):
455
+ return invocations
456
+ call_id = str(payload.get("call_id") or payload.get("id") or "") or None
457
+ for command, workdir in exec_commands_from_input(payload["input"]):
458
+ for skill, access_type in detect_skill_accesses(command, workdir or session.cwd, catalog):
459
+ invocations.append(
460
+ Invocation(
461
+ thread_id=session.thread_id,
462
+ turn_id=turn_id,
463
+ timestamp=timestamp,
464
+ skill=skill,
465
+ invoke_type="implicit",
466
+ evidence_type=f"skill_{access_type}",
467
+ source_path=source_path,
468
+ source_line=source_line,
469
+ source_order=source_order,
470
+ call_id=call_id,
471
+ )
472
+ )
473
+ source_order += 1
474
+ return invocations
475
+
476
+
477
+ def json_records(lines: Iterable[tuple[int, bytes]]) -> Iterator[tuple[int, dict[str, Any]]]:
478
+ for line_number, raw in lines:
479
+ try:
480
+ value = json.loads(raw)
481
+ except (UnicodeDecodeError, json.JSONDecodeError):
482
+ continue
483
+ if isinstance(value, dict):
484
+ yield line_number, value
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .database import AnalyticsDB
9
+ from .parser import (
10
+ SessionMeta,
11
+ catalog_skills_from_record,
12
+ discover_installed_skills,
13
+ parse_record_invocations,
14
+ parse_session_meta,
15
+ )
16
+
17
+ MAX_JSONL_LINE_BYTES = 16 * 1024 * 1024
18
+
19
+
20
+ @dataclass
21
+ class SyncStats:
22
+ files_seen: int = 0
23
+ files_read: int = 0
24
+ lines_read: int = 0
25
+ invocations_added: int = 0
26
+ accesses_added: int = 0
27
+ malformed_lines: int = 0
28
+ oversized_lines: int = 0
29
+
30
+
31
+ def rollout_paths(codex_home: Path) -> Iterator[Path]:
32
+ for directory in (codex_home / "sessions", codex_home / "archived_sessions"):
33
+ if directory.is_dir():
34
+ yield from sorted(directory.rglob("*.jsonl"))
35
+
36
+
37
+ def sync_history(codex_home: Path, db: AnalyticsDB) -> SyncStats:
38
+ codex_home = codex_home.expanduser().resolve()
39
+ home = codex_home.parent
40
+ installed_catalog = discover_installed_skills(home)
41
+ for skill in installed_catalog.values():
42
+ db.upsert_skill(skill)
43
+ stats = SyncStats()
44
+ for path in rollout_paths(codex_home):
45
+ catalog = dict(installed_catalog)
46
+ stats.files_seen += 1
47
+ source_path = str(path.resolve())
48
+ file_stat = path.stat()
49
+ checkpoint = db.checkpoint(source_path)
50
+ offset = int(checkpoint["byte_offset"]) if checkpoint else 0
51
+ line_number = int(checkpoint["line_number"]) if checkpoint else 0
52
+ if file_stat.st_size < offset:
53
+ offset = 0
54
+ line_number = 0
55
+ if file_stat.st_size == offset:
56
+ continue
57
+ session: SessionMeta | None = None
58
+ if offset > 0:
59
+ row = db.connection.execute(
60
+ "SELECT * FROM sessions WHERE source_path=?", (source_path,)
61
+ ).fetchone()
62
+ if row:
63
+ session = SessionMeta(
64
+ thread_id=row["thread_id"],
65
+ started_at=row["started_at"],
66
+ cwd=row["cwd"],
67
+ cli_version=row["cli_version"],
68
+ parent_thread_id=row["parent_thread_id"],
69
+ )
70
+ next_offset = offset
71
+ next_line = line_number
72
+ file_had_lines = False
73
+ with path.open("rb") as handle:
74
+ handle.seek(offset)
75
+ while True:
76
+ line_start = handle.tell()
77
+ raw = handle.readline(MAX_JSONL_LINE_BYTES + 1)
78
+ if not raw:
79
+ next_offset = handle.tell()
80
+ break
81
+ if len(raw) > MAX_JSONL_LINE_BYTES:
82
+ while raw and not raw.endswith(b"\n"):
83
+ raw = handle.readline(MAX_JSONL_LINE_BYTES + 1)
84
+ next_line += 1
85
+ stats.lines_read += 1
86
+ stats.oversized_lines += 1
87
+ file_had_lines = True
88
+ next_offset = handle.tell()
89
+ continue
90
+ if not raw.endswith(b"\n"):
91
+ next_offset = line_start
92
+ break
93
+ next_line += 1
94
+ stats.lines_read += 1
95
+ file_had_lines = True
96
+ next_offset = handle.tell()
97
+ try:
98
+ record = json.loads(raw)
99
+ except (UnicodeDecodeError, json.JSONDecodeError):
100
+ stats.malformed_lines += 1
101
+ continue
102
+ if not isinstance(record, dict):
103
+ stats.malformed_lines += 1
104
+ continue
105
+ parsed_meta = parse_session_meta(record)
106
+ if parsed_meta is not None:
107
+ session = parsed_meta
108
+ db.upsert_session(
109
+ session,
110
+ source_path,
111
+ archived="archived_sessions" in path.parts,
112
+ )
113
+ for skill in catalog_skills_from_record(record, home):
114
+ catalog[skill.path] = skill
115
+ db.upsert_skill(skill)
116
+ if session is None:
117
+ continue
118
+ for invocation in parse_record_invocations(
119
+ record,
120
+ session=session,
121
+ catalog=catalog,
122
+ source_path=source_path,
123
+ source_line=next_line,
124
+ home=home,
125
+ ):
126
+ invocation_added, access_added = db.add_invocation(invocation)
127
+ stats.invocations_added += int(invocation_added)
128
+ stats.accesses_added += int(access_added)
129
+ if not file_had_lines:
130
+ continue
131
+ stats.files_read += 1
132
+ db.save_checkpoint(
133
+ source_path,
134
+ byte_offset=next_offset,
135
+ line_number=next_line,
136
+ file_size=file_stat.st_size,
137
+ mtime_ns=file_stat.st_mtime_ns,
138
+ )
139
+ db.commit()
140
+ db.commit()
141
+ return stats