agent-session-analytics 0.1.0__tar.gz
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-0.1.0/.gitignore +20 -0
- agent_session_analytics-0.1.0/CHANGELOG.md +37 -0
- agent_session_analytics-0.1.0/LICENSE +21 -0
- agent_session_analytics-0.1.0/PKG-INFO +122 -0
- agent_session_analytics-0.1.0/README.md +99 -0
- agent_session_analytics-0.1.0/pyproject.toml +60 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/__init__.py +5 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/__main__.py +5 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/_cli.py +57 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/_db.py +158 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/_ingest.py +1190 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/_query.py +59 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/py.typed +0 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/references/canonical-schema.md +149 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/references/harness-coverage.md +182 -0
- agent_session_analytics-0.1.0/src/agent_session_analytics/references/query-conventions.md +91 -0
- agent_session_analytics-0.1.0/tests/__init__.py +0 -0
- agent_session_analytics-0.1.0/tests/conftest.py +100 -0
- agent_session_analytics-0.1.0/tests/test_cli.py +474 -0
- agent_session_analytics-0.1.0/tests/test_codex_ingest.py +603 -0
- agent_session_analytics-0.1.0/tests/test_malformed_rows.py +133 -0
- agent_session_analytics-0.1.0/tests/test_paths.py +166 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.venv/
|
|
5
|
+
|
|
6
|
+
# OS / editor
|
|
7
|
+
.DS_Store
|
|
8
|
+
*.swp
|
|
9
|
+
.idea/
|
|
10
|
+
.vscode/
|
|
11
|
+
|
|
12
|
+
# Workflow scratch (age/cure/press reports, planning notes)
|
|
13
|
+
.cheese/
|
|
14
|
+
.context/
|
|
15
|
+
|
|
16
|
+
# Skill-creator iteration workspaces (eval runs, benchmarks, viewer HTML)
|
|
17
|
+
skills/*-workspace/
|
|
18
|
+
|
|
19
|
+
# Serena MCP — machine-local; not tracked
|
|
20
|
+
.serena/
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
This file records the notable changes to the `agent-session-analytics` distribution.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
The project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
A `0.x` minor release can contain breaking changes.
|
|
7
|
+
|
|
8
|
+
The `publish agent-session-analytics` workflow reads the section for the tagged version.
|
|
9
|
+
It uses that section as the GitHub release notes.
|
|
10
|
+
To release, move the `Unreleased` entries into a new version section with the date.
|
|
11
|
+
Then set the same version in `packages/agent-session-analytics/pyproject.toml`.
|
|
12
|
+
Push a `agent-session-analytics-v<version>` tag after the release commit merges.
|
|
13
|
+
|
|
14
|
+
## [Unreleased]
|
|
15
|
+
|
|
16
|
+
## [0.1.0] - 2026-10-11
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- The `agent-session-analytics` console script with four JSON commands: `ingest`, `db-path`, `sql`, and `schema`.
|
|
21
|
+
- A schema version stamped in the database. `ingest` rebuilds a database whose version differs, even inside the one-hour freshness window.
|
|
22
|
+
- The engine moves out of the `skillz` skill and merges the dotfiles `session-analytics` engine at dotfiles commit `6ce36879`.
|
|
23
|
+
- Codex `token_usage` rows reach `model_turns`. They do not count in `sessions.entry_count`.
|
|
24
|
+
- The columns `model_turns.cache_creation_tokens`, `model_turns.context_tokens`, `sessions.version`, and `sessions.parent_session_id`.
|
|
25
|
+
- The references `canonical-schema.md`, `harness-coverage.md`, and `query-conventions.md` ship as package data.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Codex `model_turns.tool_calls` is NULL, because Codex token rows carry no content.
|
|
30
|
+
- Cursor timestamps are UTC.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
|
|
34
|
+
- The canned `query.sh` reports. Callers write their own SQL with `sql`.
|
|
35
|
+
|
|
36
|
+
[Unreleased]: https://github.com/paulnsorensen/skillz-that-grillz/compare/agent-session-analytics-v0.1.0...HEAD
|
|
37
|
+
[0.1.0]: https://github.com/paulnsorensen/skillz-that-grillz/releases/tag/agent-session-analytics-v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Sorensen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-session-analytics
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ingest coding-agent session logs into DuckDB and query them read-only.
|
|
5
|
+
Project-URL: Homepage, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/agent-session-analytics
|
|
6
|
+
Project-URL: Source, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/agent-session-analytics
|
|
7
|
+
Project-URL: Issues, https://github.com/paulnsorensen/skillz-that-grillz/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/paulnsorensen/skillz-that-grillz/blob/main/packages/agent-session-analytics/CHANGELOG.md
|
|
9
|
+
Author-email: Paul Sorensen <paulnsorensen@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,analytics,duckdb,session-logs
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: fromargs<0.3,>=0.2.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# agent-session-analytics
|
|
25
|
+
|
|
26
|
+
`agent-session-analytics` ingests the session logs of coding agents into one DuckDB
|
|
27
|
+
database. It then answers SQL queries that cannot write or run commands. It
|
|
28
|
+
supports the Claude Code, Codex, Cursor, OMP, and pi harnesses.
|
|
29
|
+
|
|
30
|
+
The package ships engine primitives only. It has no canned reports. A caller
|
|
31
|
+
writes its own SQL against the schema.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install agent-session-analytics
|
|
37
|
+
# or
|
|
38
|
+
pip install agent-session-analytics
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The only runtime dependency is `fromargs`. The package calls the `duckdb`
|
|
42
|
+
command-line tool and does not need the Python `duckdb` module. Install DuckDB
|
|
43
|
+
with one of these commands:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install duckdb-cli
|
|
47
|
+
brew install duckdb
|
|
48
|
+
mise use -g duckdb
|
|
49
|
+
curl https://install.duckdb.org | sh
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The package never downloads DuckDB. When `duckdb` is not on `PATH`, `ingest`
|
|
53
|
+
and `sql` exit 3 and print these options.
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
Every command prints one JSON document on stdout. Every error is one JSON line
|
|
58
|
+
on stderr: `{"error": <message>, "exit_code": <n>}`.
|
|
59
|
+
|
|
60
|
+
| Command | Result |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `ingest [--force]` | Build or refresh the database. A database inside the one-hour freshness window with the current schema version keeps its data. |
|
|
63
|
+
| `db-path` | The resolved database path, as `{"path": "<abs path>"}`. |
|
|
64
|
+
| `sql <query>` | The rows of one query in DuckDB safe mode. |
|
|
65
|
+
| `schema` | The canonical schema reference. |
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
agent-session-analytics ingest
|
|
69
|
+
agent-session-analytics db-path | jq -r .path
|
|
70
|
+
agent-session-analytics sql "SELECT harness, count(*) AS sessions FROM sessions GROUP BY harness"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`sql` opens the database read-only and runs in DuckDB safe mode. Safe mode
|
|
74
|
+
rejects shell commands, file writes, and reads of files outside the database.
|
|
75
|
+
A rejected query exits 5.
|
|
76
|
+
|
|
77
|
+
`sql` ingests an old or absent default database first. It does not ingest a
|
|
78
|
+
`SESSIONS_DB` override. It never queries stale data after a failed ingest.
|
|
79
|
+
|
|
80
|
+
A consumer that queries the database directly runs
|
|
81
|
+
`duckdb -init /dev/null -readonly <database> -json -c "<sql>"`, where
|
|
82
|
+
`<database>` is the `path` field of `db-path`.
|
|
83
|
+
|
|
84
|
+
## Exit codes
|
|
85
|
+
|
|
86
|
+
| Code | Meaning |
|
|
87
|
+
|---|---|
|
|
88
|
+
| 0 | Success |
|
|
89
|
+
| 2 | Bad input |
|
|
90
|
+
| 3 | The `duckdb` command is missing |
|
|
91
|
+
| 4 | Ingest failed or timed out, or the database is missing, unreadable, or has another schema version |
|
|
92
|
+
| 5 | DuckDB rejects the query, or the query times out |
|
|
93
|
+
|
|
94
|
+
## Database path
|
|
95
|
+
|
|
96
|
+
- `SESSIONS_DB` names the database. A relative value joins the current
|
|
97
|
+
directory. The package does not expand `~` in a set value.
|
|
98
|
+
- Otherwise an absolute `XDG_CACHE_HOME` selects
|
|
99
|
+
`<cache>/dotfiles/session-analytics/sessions.duckdb`. A relative or unset
|
|
100
|
+
value uses `~/.cache`.
|
|
101
|
+
- `SESSIONS_DUCKDB_MEMORY_LIMIT` sets the DuckDB memory limit. The default is
|
|
102
|
+
`8GB`.
|
|
103
|
+
- `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and `CURSOR_HOME` move the log roots.
|
|
104
|
+
|
|
105
|
+
Ingest writes the transient `<db>.tmp` and `<db>.stage`, and a persistent
|
|
106
|
+
`<db>.lock`. An `fcntl` lock serializes ingest runs. Ingest uses umask `077`.
|
|
107
|
+
|
|
108
|
+
## Schema version
|
|
109
|
+
|
|
110
|
+
The database holds a one-row `schema_info` table with an integer `version`.
|
|
111
|
+
The package version of that integer is `SCHEMA_VERSION`. `ingest` rebuilds a
|
|
112
|
+
database whose stored version differs, even inside the freshness window. `sql`
|
|
113
|
+
treats a mismatch like a stale database. Only this repository changes the
|
|
114
|
+
schema.
|
|
115
|
+
|
|
116
|
+
## References
|
|
117
|
+
|
|
118
|
+
These files ship as package data in `agent_session_analytics/references/`:
|
|
119
|
+
|
|
120
|
+
- `canonical-schema.md` — tables and columns. `agent-session-analytics schema` prints it.
|
|
121
|
+
- `harness-coverage.md` — what each harness log provides.
|
|
122
|
+
- `query-conventions.md` — rules for writing queries.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# agent-session-analytics
|
|
2
|
+
|
|
3
|
+
`agent-session-analytics` ingests the session logs of coding agents into one DuckDB
|
|
4
|
+
database. It then answers SQL queries that cannot write or run commands. It
|
|
5
|
+
supports the Claude Code, Codex, Cursor, OMP, and pi harnesses.
|
|
6
|
+
|
|
7
|
+
The package ships engine primitives only. It has no canned reports. A caller
|
|
8
|
+
writes its own SQL against the schema.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv tool install agent-session-analytics
|
|
14
|
+
# or
|
|
15
|
+
pip install agent-session-analytics
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The only runtime dependency is `fromargs`. The package calls the `duckdb`
|
|
19
|
+
command-line tool and does not need the Python `duckdb` module. Install DuckDB
|
|
20
|
+
with one of these commands:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install duckdb-cli
|
|
24
|
+
brew install duckdb
|
|
25
|
+
mise use -g duckdb
|
|
26
|
+
curl https://install.duckdb.org | sh
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package never downloads DuckDB. When `duckdb` is not on `PATH`, `ingest`
|
|
30
|
+
and `sql` exit 3 and print these options.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
Every command prints one JSON document on stdout. Every error is one JSON line
|
|
35
|
+
on stderr: `{"error": <message>, "exit_code": <n>}`.
|
|
36
|
+
|
|
37
|
+
| Command | Result |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `ingest [--force]` | Build or refresh the database. A database inside the one-hour freshness window with the current schema version keeps its data. |
|
|
40
|
+
| `db-path` | The resolved database path, as `{"path": "<abs path>"}`. |
|
|
41
|
+
| `sql <query>` | The rows of one query in DuckDB safe mode. |
|
|
42
|
+
| `schema` | The canonical schema reference. |
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
agent-session-analytics ingest
|
|
46
|
+
agent-session-analytics db-path | jq -r .path
|
|
47
|
+
agent-session-analytics sql "SELECT harness, count(*) AS sessions FROM sessions GROUP BY harness"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`sql` opens the database read-only and runs in DuckDB safe mode. Safe mode
|
|
51
|
+
rejects shell commands, file writes, and reads of files outside the database.
|
|
52
|
+
A rejected query exits 5.
|
|
53
|
+
|
|
54
|
+
`sql` ingests an old or absent default database first. It does not ingest a
|
|
55
|
+
`SESSIONS_DB` override. It never queries stale data after a failed ingest.
|
|
56
|
+
|
|
57
|
+
A consumer that queries the database directly runs
|
|
58
|
+
`duckdb -init /dev/null -readonly <database> -json -c "<sql>"`, where
|
|
59
|
+
`<database>` is the `path` field of `db-path`.
|
|
60
|
+
|
|
61
|
+
## Exit codes
|
|
62
|
+
|
|
63
|
+
| Code | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| 0 | Success |
|
|
66
|
+
| 2 | Bad input |
|
|
67
|
+
| 3 | The `duckdb` command is missing |
|
|
68
|
+
| 4 | Ingest failed or timed out, or the database is missing, unreadable, or has another schema version |
|
|
69
|
+
| 5 | DuckDB rejects the query, or the query times out |
|
|
70
|
+
|
|
71
|
+
## Database path
|
|
72
|
+
|
|
73
|
+
- `SESSIONS_DB` names the database. A relative value joins the current
|
|
74
|
+
directory. The package does not expand `~` in a set value.
|
|
75
|
+
- Otherwise an absolute `XDG_CACHE_HOME` selects
|
|
76
|
+
`<cache>/dotfiles/session-analytics/sessions.duckdb`. A relative or unset
|
|
77
|
+
value uses `~/.cache`.
|
|
78
|
+
- `SESSIONS_DUCKDB_MEMORY_LIMIT` sets the DuckDB memory limit. The default is
|
|
79
|
+
`8GB`.
|
|
80
|
+
- `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and `CURSOR_HOME` move the log roots.
|
|
81
|
+
|
|
82
|
+
Ingest writes the transient `<db>.tmp` and `<db>.stage`, and a persistent
|
|
83
|
+
`<db>.lock`. An `fcntl` lock serializes ingest runs. Ingest uses umask `077`.
|
|
84
|
+
|
|
85
|
+
## Schema version
|
|
86
|
+
|
|
87
|
+
The database holds a one-row `schema_info` table with an integer `version`.
|
|
88
|
+
The package version of that integer is `SCHEMA_VERSION`. `ingest` rebuilds a
|
|
89
|
+
database whose stored version differs, even inside the freshness window. `sql`
|
|
90
|
+
treats a mismatch like a stale database. Only this repository changes the
|
|
91
|
+
schema.
|
|
92
|
+
|
|
93
|
+
## References
|
|
94
|
+
|
|
95
|
+
These files ship as package data in `agent_session_analytics/references/`:
|
|
96
|
+
|
|
97
|
+
- `canonical-schema.md` — tables and columns. `agent-session-analytics schema` prints it.
|
|
98
|
+
- `harness-coverage.md` — what each harness log provides.
|
|
99
|
+
- `query-conventions.md` — rules for writing queries.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-session-analytics"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Ingest coding-agent session logs into DuckDB and query them read-only."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Paul Sorensen", email = "paulnsorensen@gmail.com" }]
|
|
10
|
+
keywords = ["duckdb", "agent", "session-logs", "analytics"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["fromargs>=0.2.0,<0.3"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
agent-session-analytics = "agent_session_analytics:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/agent-session-analytics"
|
|
27
|
+
Source = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/agent-session-analytics"
|
|
28
|
+
Issues = "https://github.com/paulnsorensen/skillz-that-grillz/issues"
|
|
29
|
+
Changelog = "https://github.com/paulnsorensen/skillz-that-grillz/blob/main/packages/agent-session-analytics/CHANGELOG.md"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=8", "pytest-xdist>=3", "basedpyright==1.40.1"]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling>=1.27.0"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/agent_session_analytics"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
exclude = [
|
|
43
|
+
".venv",
|
|
44
|
+
"**/__pycache__",
|
|
45
|
+
"**/.pytest_cache",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.uv.sources]
|
|
49
|
+
fromargs = { workspace = true }
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
addopts = "--strict-markers"
|
|
54
|
+
|
|
55
|
+
[tool.basedpyright]
|
|
56
|
+
pythonVersion = "3.11"
|
|
57
|
+
# Workspace members share the root .venv.
|
|
58
|
+
venvPath = "../.."
|
|
59
|
+
venv = ".venv"
|
|
60
|
+
include = ["src", "tests"]
|
|
@@ -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
|