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,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()
|