agent-session-analytics 0.1.0__py3-none-any.whl
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.
- agent_session_analytics/__init__.py +5 -0
- agent_session_analytics/__main__.py +5 -0
- agent_session_analytics/_cli.py +57 -0
- agent_session_analytics/_db.py +158 -0
- agent_session_analytics/_ingest.py +1190 -0
- agent_session_analytics/_query.py +59 -0
- agent_session_analytics/py.typed +0 -0
- agent_session_analytics/references/canonical-schema.md +149 -0
- agent_session_analytics/references/harness-coverage.md +182 -0
- agent_session_analytics/references/query-conventions.md +91 -0
- agent_session_analytics-0.1.0.dist-info/METADATA +122 -0
- agent_session_analytics-0.1.0.dist-info/RECORD +15 -0
- agent_session_analytics-0.1.0.dist-info/WHEEL +4 -0
- agent_session_analytics-0.1.0.dist-info/entry_points.txt +2 -0
- agent_session_analytics-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The `agent-session-analytics` command line: JSON in, JSON out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
import fromargs
|
|
8
|
+
|
|
9
|
+
from agent_session_analytics._db import DB_PATH, DatabaseError
|
|
10
|
+
from agent_session_analytics._ingest import ingest as run_ingest
|
|
11
|
+
from agent_session_analytics._query import run_query
|
|
12
|
+
|
|
13
|
+
app = fromargs.App(
|
|
14
|
+
"agent-session-analytics",
|
|
15
|
+
help="Ingest coding-agent session logs into DuckDB and query them read-only.",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SCHEMA_REFERENCE = "canonical-schema.md"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cli_error(error: DatabaseError) -> fromargs.CliError:
|
|
22
|
+
return fromargs.CliError(str(error), exit_code=error.exit_code)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command
|
|
26
|
+
def ingest(*, force: bool = False) -> dict[str, object]:
|
|
27
|
+
"""Build or refresh the session database. A database under one hour old keeps its data."""
|
|
28
|
+
try:
|
|
29
|
+
return run_ingest(force=force)
|
|
30
|
+
except DatabaseError as error:
|
|
31
|
+
raise _cli_error(error) from error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command
|
|
35
|
+
def db_path() -> dict[str, str]:
|
|
36
|
+
"""Print the resolved database path. SESSIONS_DB and XDG_CACHE_HOME apply."""
|
|
37
|
+
return {"path": DB_PATH}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command
|
|
41
|
+
def sql(query: str) -> list[dict[str, object]]:
|
|
42
|
+
"""Run one read-only query and return its rows. The default database ingests itself when stale."""
|
|
43
|
+
try:
|
|
44
|
+
return run_query(query)
|
|
45
|
+
except DatabaseError as error:
|
|
46
|
+
raise _cli_error(error) from error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command
|
|
50
|
+
def schema() -> dict[str, str]:
|
|
51
|
+
"""Print the canonical schema reference that ships with the package."""
|
|
52
|
+
reference = resources.files("agent_session_analytics").joinpath("references", SCHEMA_REFERENCE)
|
|
53
|
+
return {"reference": SCHEMA_REFERENCE, "content": reference.read_text(encoding="utf-8")}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
return app.run(argv)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The session database: its paths, exit codes, errors, DuckDB runner, and freshness checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING, cast
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from typing import TypeAlias
|
|
14
|
+
|
|
15
|
+
_JsonValue: TypeAlias = "None | bool | int | float | str | list[_JsonValue] | dict[str, _JsonValue]"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def configured_path(name: str, default: str) -> str:
|
|
19
|
+
# SESSIONS_DB: a set value gets no `~` expansion, and a
|
|
20
|
+
# relative value joins to cwd. The adapter log roots use the same rule by
|
|
21
|
+
# choice. Only the built-in default expands `~` to HOME.
|
|
22
|
+
value = os.environ.get(name) or os.path.expanduser(default)
|
|
23
|
+
return os.path.abspath(value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _xdg_cache_home() -> str:
|
|
27
|
+
# The XDG Base Directory spec requires ignoring a relative value.
|
|
28
|
+
# No `~` expansion; only an absolute value counts.
|
|
29
|
+
value = os.environ.get("XDG_CACHE_HOME")
|
|
30
|
+
if value and value.startswith("/"):
|
|
31
|
+
return os.path.abspath(value)
|
|
32
|
+
return os.path.abspath(os.path.expanduser("~/.cache"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_DB_DIR = os.path.join(_xdg_cache_home(), "dotfiles", "session-analytics")
|
|
36
|
+
DB_PATH = configured_path("SESSIONS_DB", os.path.join(DEFAULT_DB_DIR, "sessions.duckdb"))
|
|
37
|
+
DB_DIR = os.path.dirname(DB_PATH)
|
|
38
|
+
DB_TMP_PATH = f"{DB_PATH}.tmp"
|
|
39
|
+
# Derived from the database file name, not DB_DIR, so a SESSIONS_DB pointed
|
|
40
|
+
# into an existing directory never aliases (and rmtree's) that directory's
|
|
41
|
+
# own "stage" subdir.
|
|
42
|
+
STAGE_DIR = f"{DB_PATH}.stage"
|
|
43
|
+
TTL_SECONDS = 3600 # 1 hour
|
|
44
|
+
|
|
45
|
+
# The schema version is an integer stamped in the one-row `schema_info` table.
|
|
46
|
+
# Raise it on every change to a table or column; a database with another
|
|
47
|
+
# version is stale, so ingest rebuilds it even inside the freshness window.
|
|
48
|
+
SCHEMA_VERSION = 1
|
|
49
|
+
|
|
50
|
+
EXIT_NO_DUCKDB = 3
|
|
51
|
+
EXIT_INGEST = 4
|
|
52
|
+
EXIT_QUERY = 5
|
|
53
|
+
|
|
54
|
+
DUCKDB_TIMEOUT_SECONDS = 600
|
|
55
|
+
|
|
56
|
+
DUCKDB_MISSING = (
|
|
57
|
+
"DuckDB CLI not found. Install it with one of: "
|
|
58
|
+
"`uv tool install duckdb-cli`, `brew install duckdb`, `mise use -g duckdb`, "
|
|
59
|
+
"or `curl https://install.duckdb.org | sh`. Nothing is downloaded automatically."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DatabaseError(Exception):
|
|
64
|
+
"""A database step failed; ``exit_code`` is the CLI exit status."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, message: str, *, exit_code: int = EXIT_INGEST) -> None:
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
self.exit_code: int = exit_code
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class IngestError(DatabaseError):
|
|
72
|
+
"""An ingest step failed. The default exit status is 4."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class QueryError(DatabaseError):
|
|
76
|
+
"""A query step failed. The default exit status is 5."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, *, exit_code: int = EXIT_QUERY) -> None:
|
|
79
|
+
super().__init__(message, exit_code=exit_code)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def require_duckdb() -> None:
|
|
83
|
+
if not shutil.which("duckdb"):
|
|
84
|
+
raise DatabaseError(DUCKDB_MISSING, exit_code=EXIT_NO_DUCKDB)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _duckdb(sql: str, *, as_json: bool = False) -> str:
|
|
88
|
+
"""Run SQL in the DuckDB CLI.
|
|
89
|
+
|
|
90
|
+
The JSON mode reads the database file in read-only safe mode. Safe mode locks
|
|
91
|
+
the configuration, so the memory limit applies first. The other mode writes
|
|
92
|
+
the temporary database that ingest builds.
|
|
93
|
+
"""
|
|
94
|
+
limit = os.environ.get("SESSIONS_DUCKDB_MEMORY_LIMIT") or "8GB"
|
|
95
|
+
argv = ["duckdb", "-init", "/dev/null"]
|
|
96
|
+
if as_json:
|
|
97
|
+
argv += ["-readonly", "-json"]
|
|
98
|
+
argv += [DB_PATH if as_json else DB_TMP_PATH, "-cmd", f"SET memory_limit='{limit}'"]
|
|
99
|
+
if as_json:
|
|
100
|
+
argv += ["-cmd", ".safe_mode"]
|
|
101
|
+
argv += ["-c", sql]
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run(
|
|
104
|
+
argv,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
timeout=DUCKDB_TIMEOUT_SECONDS,
|
|
108
|
+
check=False,
|
|
109
|
+
)
|
|
110
|
+
except subprocess.TimeoutExpired as error:
|
|
111
|
+
raise DatabaseError(f"DuckDB timed out after {DUCKDB_TIMEOUT_SECONDS} seconds.") from error
|
|
112
|
+
if result.returncode != 0:
|
|
113
|
+
raise DatabaseError(f"DuckDB failed: {result.stderr[:500].strip()}")
|
|
114
|
+
return result.stdout.strip()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_sql(sql: str) -> None:
|
|
118
|
+
_ = _duckdb(sql)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def query_rows(sql: str) -> list[dict[str, object]]:
|
|
122
|
+
"""Run one read-only query against the database and return its rows."""
|
|
123
|
+
output = _duckdb(sql, as_json=True)
|
|
124
|
+
if not output:
|
|
125
|
+
return []
|
|
126
|
+
try:
|
|
127
|
+
rows = cast(_JsonValue, json.loads(output))
|
|
128
|
+
except json.JSONDecodeError as error:
|
|
129
|
+
raise DatabaseError(f"DuckDB returned unreadable output: {error}") from error
|
|
130
|
+
if not isinstance(rows, list):
|
|
131
|
+
raise DatabaseError("DuckDB returned a non-list result")
|
|
132
|
+
return [dict(row) for row in rows if isinstance(row, dict)]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def stored_schema_version() -> int | None:
|
|
136
|
+
"""The schema version stamped in the database, or None when `schema_info` is missing.
|
|
137
|
+
|
|
138
|
+
Every other DuckDB failure, such as a lock conflict or an invalid file, raises.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
rows = query_rows("SELECT version FROM schema_info")
|
|
142
|
+
except DatabaseError as error:
|
|
143
|
+
if "Table with name schema_info does not exist" in str(error):
|
|
144
|
+
return None
|
|
145
|
+
raise
|
|
146
|
+
version = rows[0]["version"] if rows else None
|
|
147
|
+
return version if isinstance(version, int) else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def db_is_stamped() -> bool:
|
|
151
|
+
return os.path.exists(DB_PATH) and stored_schema_version() == SCHEMA_VERSION
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def db_is_fresh() -> bool:
|
|
155
|
+
if not os.path.exists(DB_PATH):
|
|
156
|
+
return False
|
|
157
|
+
age = time.time() - os.path.getmtime(DB_PATH)
|
|
158
|
+
return age < TTL_SECONDS and stored_schema_version() == SCHEMA_VERSION
|