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.
@@ -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,5 @@
1
+ """Ingest coding-agent session logs into DuckDB and query them read-only."""
2
+
3
+ from agent_session_analytics._cli import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from agent_session_analytics import main
4
+
5
+ sys.exit(main())
@@ -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