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,130 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sqlite3
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from pathlib import Path
8
+
9
+ from .database import AnalyticsDB
10
+ from .graph import build_graph_data, write_graph_html
11
+ from .sync import sync_history
12
+ from .web import serve_dashboard
13
+
14
+ DEFAULT_DB = Path.home() / ".local" / "share" / "codex-skill-analytics" / "analytics.sqlite3"
15
+
16
+
17
+ def _rows_as_dicts(rows: Iterable[sqlite3.Row]) -> list[dict[str, object]]:
18
+ return [dict(row) for row in rows]
19
+
20
+
21
+ def _print_table(rows: Sequence[Mapping[str, object]]) -> None:
22
+ if not rows:
23
+ print("No data.")
24
+ return
25
+ columns = list(rows[0].keys())
26
+ widths = {
27
+ column: max(len(column), *(len(str(row[column] if row[column] is not None else "")) for row in rows))
28
+ for column in columns
29
+ }
30
+ print(" ".join(column.ljust(widths[column]) for column in columns))
31
+ print(" ".join("-" * widths[column] for column in columns))
32
+ for row in rows:
33
+ print(
34
+ " ".join(
35
+ str(row[column] if row[column] is not None else "").ljust(widths[column])
36
+ for column in columns
37
+ )
38
+ )
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(
43
+ prog="codex-skill-analytics",
44
+ description="Local deterministic analytics for Codex Skill usage.",
45
+ )
46
+ parser.add_argument("--db", type=Path, default=DEFAULT_DB)
47
+ subparsers = parser.add_subparsers(dest="command", required=True)
48
+
49
+ sync_parser = subparsers.add_parser("sync", help="Incrementally import Codex rollout JSONL files")
50
+ sync_parser.add_argument("--codex-home", type=Path, default=Path.home() / ".codex")
51
+
52
+ summary_parser = subparsers.add_parser("summary", help="Show Skill usage totals")
53
+ summary_parser.add_argument("--days", type=int, default=30)
54
+ summary_parser.add_argument("--json", action="store_true")
55
+
56
+ events_parser = subparsers.add_parser("events", help="Show when individual Skills were invoked")
57
+ events_parser.add_argument("--days", type=int, default=30)
58
+ events_parser.add_argument("--limit", type=int, default=100)
59
+ events_parser.add_argument("--json", action="store_true")
60
+
61
+ trend_parser = subparsers.add_parser("trend", help="Show daily Skill invocation counts")
62
+ trend_parser.add_argument("--days", type=int, default=30)
63
+ trend_parser.add_argument("--json", action="store_true")
64
+
65
+ relation_parser = subparsers.add_parser("relations", help="Show Skill relationship weights")
66
+ relation_parser.add_argument("--days", type=int, default=30)
67
+ relation_parser.add_argument(
68
+ "--type", choices=("same-turn", "sequence", "next-in-thread"), default="same-turn"
69
+ )
70
+ relation_parser.add_argument("--json", action="store_true")
71
+
72
+ graph_parser = subparsers.add_parser(
73
+ "graph", help="Generate a standalone interactive Skill dependency graph"
74
+ )
75
+ graph_parser.add_argument("--days", type=int, default=30)
76
+ graph_parser.add_argument(
77
+ "--type", choices=("same-turn", "sequence", "next-in-thread"), default="sequence"
78
+ )
79
+ graph_parser.add_argument("--min-weight", type=int, default=2)
80
+ graph_parser.add_argument("--limit", type=int, default=100, help="Maximum number of edges")
81
+ graph_parser.add_argument(
82
+ "--output", type=Path, default=Path("skill-dependency-graph.html")
83
+ )
84
+
85
+ serve_parser = subparsers.add_parser("serve", help="Run the local two-page analytics Web")
86
+ serve_parser.add_argument("--codex-home", type=Path, default=Path.home() / ".codex")
87
+ serve_parser.add_argument("--host", default="127.0.0.1")
88
+ serve_parser.add_argument("--port", type=int, default=8765)
89
+ serve_parser.add_argument("--open", action="store_true", help="Open the Web in a browser")
90
+ return parser
91
+
92
+
93
+ def main(argv: Sequence[str] | None = None) -> int:
94
+ args = build_parser().parse_args(argv)
95
+ if args.command == "serve":
96
+ serve_dashboard(args.db, args.codex_home, args.host, args.port, args.open)
97
+ return 0
98
+ with AnalyticsDB(args.db) as db:
99
+ if args.command == "sync":
100
+ stats = sync_history(args.codex_home, db)
101
+ print(json.dumps(stats.__dict__, ensure_ascii=False, indent=2))
102
+ return 0
103
+ if args.command == "graph":
104
+ data = build_graph_data(
105
+ db,
106
+ days=max(1, args.days),
107
+ relation_type=args.type,
108
+ min_weight=max(1, args.min_weight),
109
+ limit=max(1, args.limit),
110
+ )
111
+ output = write_graph_html(data, args.output)
112
+ print(output)
113
+ return 0
114
+ if args.command == "summary":
115
+ rows = _rows_as_dicts(db.summary(args.days))
116
+ elif args.command == "events":
117
+ rows = _rows_as_dicts(db.events(args.days, args.limit))
118
+ elif args.command == "trend":
119
+ rows = _rows_as_dicts(db.trend(args.days))
120
+ else:
121
+ rows = _rows_as_dicts(db.relations(args.days, args.type))
122
+ if args.json:
123
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
124
+ else:
125
+ _print_table(rows)
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ raise SystemExit(main())
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+ from .parser import Invocation, SessionMeta, SkillRef
8
+
9
+ SCHEMA = """
10
+ PRAGMA journal_mode=WAL;
11
+ PRAGMA foreign_keys=ON;
12
+
13
+ CREATE TABLE IF NOT EXISTS sources (
14
+ path TEXT PRIMARY KEY,
15
+ byte_offset INTEGER NOT NULL DEFAULT 0,
16
+ line_number INTEGER NOT NULL DEFAULT 0,
17
+ file_size INTEGER NOT NULL DEFAULT 0,
18
+ mtime_ns INTEGER NOT NULL DEFAULT 0,
19
+ last_synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS sessions (
23
+ thread_id TEXT PRIMARY KEY,
24
+ source_path TEXT NOT NULL,
25
+ started_at TEXT NOT NULL,
26
+ cwd TEXT NOT NULL,
27
+ cli_version TEXT NOT NULL,
28
+ parent_thread_id TEXT,
29
+ archived INTEGER NOT NULL DEFAULT 0
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS skills (
33
+ skill_key TEXT PRIMARY KEY,
34
+ name TEXT NOT NULL,
35
+ path TEXT NOT NULL,
36
+ scope TEXT NOT NULL
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS invocations (
40
+ invocation_id TEXT PRIMARY KEY,
41
+ thread_id TEXT NOT NULL,
42
+ turn_id TEXT NOT NULL,
43
+ invoked_at TEXT NOT NULL,
44
+ skill_key TEXT NOT NULL,
45
+ skill_name TEXT NOT NULL,
46
+ skill_scope TEXT NOT NULL,
47
+ invoke_type TEXT NOT NULL,
48
+ first_evidence_type TEXT NOT NULL,
49
+ source_path TEXT NOT NULL,
50
+ source_line INTEGER NOT NULL,
51
+ source_order INTEGER NOT NULL DEFAULT 0,
52
+ FOREIGN KEY(thread_id) REFERENCES sessions(thread_id),
53
+ FOREIGN KEY(skill_key) REFERENCES skills(skill_key)
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS accesses (
57
+ access_id TEXT PRIMARY KEY,
58
+ invocation_id TEXT NOT NULL,
59
+ accessed_at TEXT NOT NULL,
60
+ evidence_type TEXT NOT NULL,
61
+ source_path TEXT NOT NULL,
62
+ source_line INTEGER NOT NULL,
63
+ call_id TEXT,
64
+ FOREIGN KEY(invocation_id) REFERENCES invocations(invocation_id)
65
+ );
66
+
67
+ CREATE INDEX IF NOT EXISTS invocations_time_idx ON invocations(invoked_at);
68
+ CREATE INDEX IF NOT EXISTS invocations_turn_idx ON invocations(turn_id, invoked_at);
69
+ CREATE INDEX IF NOT EXISTS invocations_skill_idx ON invocations(skill_key, invoked_at);
70
+ CREATE INDEX IF NOT EXISTS accesses_invocation_idx ON accesses(invocation_id);
71
+ """
72
+
73
+
74
+ class AnalyticsDB:
75
+ def __init__(self, path: Path) -> None:
76
+ self.path = path.expanduser().resolve()
77
+ self.path.parent.mkdir(parents=True, exist_ok=True)
78
+ self.connection = sqlite3.connect(self.path)
79
+ self.connection.row_factory = sqlite3.Row
80
+ self.connection.executescript(SCHEMA)
81
+ self._migrate_columns()
82
+
83
+ def _migrate_columns(self) -> None:
84
+ columns = {
85
+ row["name"] for row in self.connection.execute("PRAGMA table_info(invocations)")
86
+ }
87
+ additions = {
88
+ "skill_name": "TEXT NOT NULL DEFAULT ''",
89
+ "skill_scope": "TEXT NOT NULL DEFAULT ''",
90
+ "source_order": "INTEGER NOT NULL DEFAULT 0",
91
+ }
92
+ for name, declaration in additions.items():
93
+ if name not in columns:
94
+ self.connection.execute(
95
+ f"ALTER TABLE invocations ADD COLUMN {name} {declaration}"
96
+ )
97
+ self.connection.execute(
98
+ """
99
+ UPDATE invocations
100
+ SET skill_name=COALESCE(NULLIF(skill_name, ''),
101
+ (SELECT name FROM skills WHERE skills.skill_key=invocations.skill_key)),
102
+ skill_scope=COALESCE(NULLIF(skill_scope, ''),
103
+ (SELECT scope FROM skills WHERE skills.skill_key=invocations.skill_key))
104
+ WHERE skill_name='' OR skill_scope=''
105
+ """
106
+ )
107
+ self.connection.commit()
108
+
109
+ def close(self) -> None:
110
+ self.connection.close()
111
+
112
+ def __enter__(self) -> Self:
113
+ return self
114
+
115
+ def __exit__(self, *_args: object) -> None:
116
+ self.close()
117
+
118
+ def checkpoint(self, path: str) -> sqlite3.Row | None:
119
+ return self.connection.execute("SELECT * FROM sources WHERE path = ?", (path,)).fetchone()
120
+
121
+ def save_checkpoint(
122
+ self, path: str, *, byte_offset: int, line_number: int, file_size: int, mtime_ns: int
123
+ ) -> None:
124
+ self.connection.execute(
125
+ """
126
+ INSERT INTO sources(path, byte_offset, line_number, file_size, mtime_ns)
127
+ VALUES (?, ?, ?, ?, ?)
128
+ ON CONFLICT(path) DO UPDATE SET
129
+ byte_offset=excluded.byte_offset,
130
+ line_number=excluded.line_number,
131
+ file_size=excluded.file_size,
132
+ mtime_ns=excluded.mtime_ns,
133
+ last_synced_at=CURRENT_TIMESTAMP
134
+ """,
135
+ (path, byte_offset, line_number, file_size, mtime_ns),
136
+ )
137
+
138
+ def upsert_session(self, session: SessionMeta, source_path: str, archived: bool) -> None:
139
+ self.connection.execute(
140
+ """
141
+ INSERT INTO sessions(thread_id, source_path, started_at, cwd, cli_version, parent_thread_id, archived)
142
+ VALUES (?, ?, ?, ?, ?, ?, ?)
143
+ ON CONFLICT(thread_id) DO UPDATE SET
144
+ source_path=excluded.source_path,
145
+ cwd=excluded.cwd,
146
+ cli_version=excluded.cli_version,
147
+ parent_thread_id=excluded.parent_thread_id,
148
+ archived=excluded.archived
149
+ """,
150
+ (
151
+ session.thread_id,
152
+ source_path,
153
+ session.started_at,
154
+ session.cwd,
155
+ session.cli_version,
156
+ session.parent_thread_id,
157
+ int(archived),
158
+ ),
159
+ )
160
+
161
+ def upsert_skill(self, skill: SkillRef) -> None:
162
+ self.connection.execute(
163
+ """
164
+ INSERT INTO skills(skill_key, name, path, scope) VALUES (?, ?, ?, ?)
165
+ ON CONFLICT(skill_key) DO UPDATE SET
166
+ name=excluded.name, path=excluded.path, scope=excluded.scope
167
+ """,
168
+ (skill.key, skill.name, skill.path, skill.scope),
169
+ )
170
+
171
+ def add_invocation(self, invocation: Invocation) -> tuple[bool, bool]:
172
+ self.upsert_skill(invocation.skill)
173
+ before = self.connection.total_changes
174
+ self.connection.execute(
175
+ """
176
+ INSERT OR IGNORE INTO invocations(
177
+ invocation_id, thread_id, turn_id, invoked_at, skill_key, skill_name, skill_scope, invoke_type,
178
+ first_evidence_type, source_path, source_line, source_order
179
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
180
+ """,
181
+ (
182
+ invocation.invocation_id,
183
+ invocation.thread_id,
184
+ invocation.turn_id,
185
+ invocation.timestamp,
186
+ invocation.skill.key,
187
+ invocation.skill.name,
188
+ invocation.skill.scope,
189
+ invocation.invoke_type,
190
+ invocation.evidence_type,
191
+ invocation.source_path,
192
+ invocation.source_line,
193
+ invocation.source_order,
194
+ ),
195
+ )
196
+ invocation_added = self.connection.total_changes > before
197
+ before = self.connection.total_changes
198
+ self.connection.execute(
199
+ """
200
+ INSERT OR IGNORE INTO accesses(
201
+ access_id, invocation_id, accessed_at, evidence_type, source_path, source_line, call_id
202
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
203
+ """,
204
+ (
205
+ invocation.access_id,
206
+ invocation.invocation_id,
207
+ invocation.timestamp,
208
+ invocation.evidence_type,
209
+ invocation.source_path,
210
+ invocation.source_line,
211
+ invocation.call_id,
212
+ ),
213
+ )
214
+ return invocation_added, self.connection.total_changes > before
215
+
216
+ def commit(self) -> None:
217
+ self.connection.commit()
218
+
219
+ def summary(self, days: int) -> list[sqlite3.Row]:
220
+ return self.connection.execute(
221
+ """
222
+ SELECT i.skill_name AS name, i.skill_scope AS scope,
223
+ COUNT(*) AS invocations,
224
+ COUNT(DISTINCT i.thread_id) AS threads,
225
+ COUNT(DISTINCT i.turn_id) AS turns,
226
+ SUM((SELECT COUNT(*) FROM accesses a WHERE a.invocation_id=i.invocation_id)) AS accesses,
227
+ MIN(i.invoked_at) AS first_used_at,
228
+ MAX(i.invoked_at) AS last_used_at
229
+ FROM invocations i
230
+ WHERE datetime(i.invoked_at) >= datetime('now', ?)
231
+ GROUP BY i.skill_name, i.skill_scope
232
+ ORDER BY invocations DESC, accesses DESC, name
233
+ """,
234
+ (f"-{max(1, days)} days",),
235
+ ).fetchall()
236
+
237
+ def volume_totals(self, days: int) -> sqlite3.Row:
238
+ row = self.connection.execute(
239
+ """
240
+ SELECT COUNT(*) AS invocations,
241
+ COUNT(DISTINCT skill_scope || ':' || skill_name) AS skills,
242
+ COUNT(DISTINCT thread_id) AS threads,
243
+ COUNT(DISTINCT turn_id) AS turns,
244
+ COALESCE(SUM((SELECT COUNT(*) FROM accesses a
245
+ WHERE a.invocation_id=i.invocation_id)), 0) AS accesses,
246
+ MIN(invoked_at) AS first_used_at,
247
+ MAX(invoked_at) AS last_used_at
248
+ FROM invocations i
249
+ WHERE datetime(invoked_at) >= datetime('now', ?)
250
+ """,
251
+ (f"-{max(1, days)} days",),
252
+ ).fetchone()
253
+ assert row is not None
254
+ return row
255
+
256
+ def volume_by_scope(self, days: int) -> list[sqlite3.Row]:
257
+ return self.connection.execute(
258
+ """
259
+ SELECT skill_scope AS scope,
260
+ COUNT(*) AS invocations,
261
+ COUNT(DISTINCT skill_name) AS skills,
262
+ COUNT(DISTINCT thread_id) AS threads,
263
+ COUNT(DISTINCT turn_id) AS turns,
264
+ COALESCE(SUM((SELECT COUNT(*) FROM accesses a
265
+ WHERE a.invocation_id=i.invocation_id)), 0) AS accesses
266
+ FROM invocations i
267
+ WHERE datetime(invoked_at) >= datetime('now', ?)
268
+ GROUP BY skill_scope
269
+ ORDER BY invocations DESC, scope
270
+ """,
271
+ (f"-{max(1, days)} days",),
272
+ ).fetchall()
273
+
274
+ def relations(self, days: int, relation_type: str) -> list[sqlite3.Row]:
275
+ modifier = f"-{max(1, days)} days"
276
+ if relation_type == "same-turn":
277
+ return self.connection.execute(
278
+ """
279
+ WITH pairs AS (
280
+ SELECT
281
+ CASE WHEN (a.skill_scope || ':' || a.skill_name) < (b.skill_scope || ':' || b.skill_name)
282
+ THEN a.skill_name ELSE b.skill_name END AS source,
283
+ CASE WHEN (a.skill_scope || ':' || a.skill_name) < (b.skill_scope || ':' || b.skill_name)
284
+ THEN a.skill_scope ELSE b.skill_scope END AS source_scope,
285
+ CASE WHEN (a.skill_scope || ':' || a.skill_name) < (b.skill_scope || ':' || b.skill_name)
286
+ THEN b.skill_name ELSE a.skill_name END AS target,
287
+ CASE WHEN (a.skill_scope || ':' || a.skill_name) < (b.skill_scope || ':' || b.skill_name)
288
+ THEN b.skill_scope ELSE a.skill_scope END AS target_scope,
289
+ a.turn_id,
290
+ a.thread_id
291
+ FROM invocations a
292
+ JOIN invocations b ON b.turn_id=a.turn_id AND b.invocation_id>a.invocation_id
293
+ WHERE datetime(a.invoked_at) >= datetime('now', ?)
294
+ AND (a.skill_name<>b.skill_name OR a.skill_scope<>b.skill_scope)
295
+ )
296
+ SELECT source, source_scope, target, target_scope,
297
+ COUNT(DISTINCT turn_id) AS weight,
298
+ COUNT(DISTINCT thread_id) AS threads
299
+ FROM pairs
300
+ GROUP BY source, source_scope, target, target_scope
301
+ ORDER BY weight DESC, source, target
302
+ """,
303
+ (modifier,),
304
+ ).fetchall()
305
+ partition = "thread_id, turn_id" if relation_type == "sequence" else "thread_id"
306
+ return self.connection.execute(
307
+ f"""
308
+ WITH ordered AS (
309
+ SELECT thread_id, turn_id, skill_key, skill_name, skill_scope, invoked_at,
310
+ LEAD(skill_name) OVER (
311
+ PARTITION BY {partition}
312
+ ORDER BY invoked_at, source_line, source_order, invocation_id
313
+ ) AS next_skill,
314
+ LEAD(skill_scope) OVER (
315
+ PARTITION BY {partition}
316
+ ORDER BY invoked_at, source_line, source_order, invocation_id
317
+ ) AS next_scope
318
+ FROM invocations
319
+ WHERE datetime(invoked_at) >= datetime('now', ?)
320
+ )
321
+ SELECT skill_name AS source, skill_scope AS source_scope,
322
+ next_skill AS target, next_scope AS target_scope,
323
+ COUNT(*) AS weight,
324
+ COUNT(DISTINCT ordered.thread_id) AS threads
325
+ FROM ordered
326
+ WHERE ordered.next_skill IS NOT NULL
327
+ AND (skill_name<>next_skill OR skill_scope<>next_scope)
328
+ GROUP BY skill_name, skill_scope, next_skill, next_scope
329
+ ORDER BY weight DESC, source, target
330
+ """,
331
+ (modifier,),
332
+ ).fetchall()
333
+
334
+ def events(self, days: int, limit: int) -> list[sqlite3.Row]:
335
+ return self.connection.execute(
336
+ """
337
+ SELECT i.invoked_at, i.skill_name AS skill, i.skill_scope AS scope, i.invoke_type,
338
+ i.first_evidence_type AS evidence, i.thread_id, i.turn_id,
339
+ i.source_path, i.source_line
340
+ FROM invocations i
341
+ WHERE datetime(i.invoked_at) >= datetime('now', ?)
342
+ ORDER BY i.invoked_at DESC, i.source_line DESC, i.source_order DESC
343
+ LIMIT ?
344
+ """,
345
+ (f"-{max(1, days)} days", max(1, limit)),
346
+ ).fetchall()
347
+
348
+ def trend(self, days: int) -> list[sqlite3.Row]:
349
+ return self.connection.execute(
350
+ """
351
+ SELECT date(i.invoked_at) AS day, i.skill_name AS skill,
352
+ i.skill_scope AS scope, COUNT(*) AS invocations
353
+ FROM invocations i
354
+ WHERE datetime(i.invoked_at) >= datetime('now', ?)
355
+ GROUP BY date(i.invoked_at), i.skill_name, i.skill_scope
356
+ ORDER BY day DESC, invocations DESC, skill
357
+ """,
358
+ (f"-{max(1, days)} days",),
359
+ ).fetchall()