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.
- package/DESIGN.md +279 -0
- package/PRODUCT.md +54 -0
- package/README.md +128 -0
- package/npm/cli.mjs +89 -0
- package/package.json +43 -0
- package/src/codex_skill_analytics/__init__.py +4 -0
- package/src/codex_skill_analytics/cli.py +130 -0
- package/src/codex_skill_analytics/database.py +359 -0
- package/src/codex_skill_analytics/graph.py +193 -0
- package/src/codex_skill_analytics/parser.py +484 -0
- package/src/codex_skill_analytics/sync.py +141 -0
- package/src/codex_skill_analytics/web.py +151 -0
- package/src/codex_skill_analytics/web_templates.py +61 -0
|
@@ -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
|