bmsdna-devtools 0.42.0__tar.gz → 0.43.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.
- bmsdna_devtools-0.42.0/README.md → bmsdna_devtools-0.43.0/PKG-INFO +52 -0
- bmsdna_devtools-0.42.0/PKG-INFO → bmsdna_devtools-0.43.0/README.md +35 -14
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli.py +3 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/__init__.py +5 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/cli.py +122 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pipeline.py +255 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/pricing.py +59 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/report.py +334 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/00_schema.sql +18 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_claude.sql +83 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/01_ingest_codex.sql +92 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/02_messages.sql +105 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/03_markers.sql +67 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/04_facts.sql +78 -0
- bmsdna_devtools-0.43.0/bmsdna/devtools/usage/sql/05_views.sql +102 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/pyproject.toml +5 -1
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/skills/bmsdna-devtools/SKILL.md +7 -0
- bmsdna_devtools-0.43.0/tests/test_usage.py +474 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/uv.lock +45 -2
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.gitignore +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/.python-version +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/agent_mentions.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_show.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/issue_watch.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_choice.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/justfile +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_agent_mentions.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_for_me.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_show.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_issue_watch.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_choice.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_description_not_truncated.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_wait_timeout.py +0 -0
- {bmsdna_devtools-0.42.0 → bmsdna_devtools-0.43.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bmsdna-devtools
|
|
3
|
+
Version: 0.43.0
|
|
4
|
+
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
+
Requires-Dist: requests>=2.32.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: tomli-w>=1.0.0
|
|
10
|
+
Requires-Dist: typer>=0.26.7
|
|
11
|
+
Provides-Extra: lint
|
|
12
|
+
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
|
|
13
|
+
Provides-Extra: usage
|
|
14
|
+
Requires-Dist: duckdb>=1.3.0; extra == 'usage'
|
|
15
|
+
Requires-Dist: plotly>=5.24.0; extra == 'usage'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
1
18
|
# bmsdna-devtools
|
|
2
19
|
|
|
3
20
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
@@ -29,6 +46,8 @@ uv tool install bmsdna-devtools
|
|
|
29
46
|
|
|
30
47
|
Or as a project dependency: `uv add bmsdna-devtools`.
|
|
31
48
|
|
|
49
|
+
Optional extras: `bmsdna-devtools[lint]` (`bdt lint` SQL rules), `bmsdna-devtools[usage]` (`bdt usage`, below).
|
|
50
|
+
|
|
32
51
|
## `bdt pr status`
|
|
33
52
|
|
|
34
53
|
Find the PR opened from the current branch and report build/check status
|
|
@@ -1024,6 +1043,39 @@ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproj
|
|
|
1024
1043
|
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
1025
1044
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
1026
1045
|
|
|
1046
|
+
## `bdt usage analyze` / `bdt usage sql`
|
|
1047
|
+
|
|
1048
|
+
Where do the tokens of your Claude Code and codex sessions go, per step of the `dev-workflow`? Needs the extra:
|
|
1049
|
+
`uv tool install 'bmsdna-devtools[usage]'` (DuckDB reads the logs, plotly draws the report; neither is imported by other commands).
|
|
1050
|
+
|
|
1051
|
+
```bash
|
|
1052
|
+
bdt usage analyze [--scope workflow|all] [--since 2026-10-01] [--price gpt-6-sol=1.25,10,0.125] [--no-codex] [--rebuild]
|
|
1053
|
+
bdt usage sql "select label, round(pct_cost, 1) from v_steps" # ad hoc SQL on the last analysis
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
It reads `~/.claude/projects` (`--logs`) and `~/.codex/sessions` (`--codex-logs`, skipped if missing), prints a summary and
|
|
1057
|
+
writes `report.html` (charts plus a table view, light/dark) with CSV / Parquet exports next to the database
|
|
1058
|
+
(`~/.cache/bdt/usage/`, or `--db` / `--out`). The report is self-contained and contains your session titles and agent
|
|
1059
|
+
descriptions, so look at it before sharing it.
|
|
1060
|
+
|
|
1061
|
+
- **Steps:** setup, plan, plan review, implement, test, screenshots, design review, quick review, full review, publish / CI.
|
|
1062
|
+
Every model turn is assigned to one by markers in its tool calls: skill calls (`bms-code-review --quick|--full`,
|
|
1063
|
+
`design-review`, `bms-plan-review`; for codex, reading the skill's `SKILL.md`), `bdt` commands, edits (`apply_patch` for
|
|
1064
|
+
codex), pytest / lint, screenshot tooling, image reads. A step stays until the next marker; test runs only count for
|
|
1065
|
+
the turn that runs them. Subagents inherit the step of the turn that spawned them. Only threads that follow the workflow
|
|
1066
|
+
(and what they spawn) are counted, `--scope all` takes every session. It is a heuristic: read differences of a few percent
|
|
1067
|
+
as noise. The rules are SQL in `bmsdna/devtools/usage/sql/03_markers.sql`.
|
|
1068
|
+
- **Numbers:** cost is the API list price (cache writes 1.25x / 2x, cache reads, output, per model), not your actual bill;
|
|
1069
|
+
"% tokens" counts cache reads too, "% fresh tokens" does not. The report also estimates what screenshots cost by sitting in
|
|
1070
|
+
the context of every later turn.
|
|
1071
|
+
- **codex:** a codex run that Claude started with `codex exec` / `codex review` (same directory, within 15 minutes) is counted
|
|
1072
|
+
in that Claude session and step, so reviews run by codex show up under "Review" even though Claude's own log doesn't contain
|
|
1073
|
+
them; codex subagents follow their parent thread. codex models have no built-in price: give one with
|
|
1074
|
+
`--price MODEL=INPUT,OUTPUT,CACHED` (USD per million tokens), repeatable. Turns without a price are counted in tokens
|
|
1075
|
+
but missing from every cost number, and the summary says how many.
|
|
1076
|
+
- **Cache:** parsed records are kept in the database, so the next run only parses new or changed log files (a few seconds).
|
|
1077
|
+
`--rebuild` re-reads everything. Message text is not stored.
|
|
1078
|
+
|
|
1027
1079
|
## Releasing
|
|
1028
1080
|
|
|
1029
1081
|
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.42.0
|
|
4
|
-
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
|
-
Requires-Python: >=3.14
|
|
6
|
-
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
7
|
-
Requires-Dist: requests>=2.32.0
|
|
8
|
-
Requires-Dist: rich>=13.0.0
|
|
9
|
-
Requires-Dist: tomli-w>=1.0.0
|
|
10
|
-
Requires-Dist: typer>=0.26.7
|
|
11
|
-
Provides-Extra: lint
|
|
12
|
-
Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# bmsdna-devtools
|
|
16
2
|
|
|
17
3
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
@@ -43,6 +29,8 @@ uv tool install bmsdna-devtools
|
|
|
43
29
|
|
|
44
30
|
Or as a project dependency: `uv add bmsdna-devtools`.
|
|
45
31
|
|
|
32
|
+
Optional extras: `bmsdna-devtools[lint]` (`bdt lint` SQL rules), `bmsdna-devtools[usage]` (`bdt usage`, below).
|
|
33
|
+
|
|
46
34
|
## `bdt pr status`
|
|
47
35
|
|
|
48
36
|
Find the PR opened from the current branch and report build/check status
|
|
@@ -1038,6 +1026,39 @@ such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproj
|
|
|
1038
1026
|
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
1039
1027
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
1040
1028
|
|
|
1029
|
+
## `bdt usage analyze` / `bdt usage sql`
|
|
1030
|
+
|
|
1031
|
+
Where do the tokens of your Claude Code and codex sessions go, per step of the `dev-workflow`? Needs the extra:
|
|
1032
|
+
`uv tool install 'bmsdna-devtools[usage]'` (DuckDB reads the logs, plotly draws the report; neither is imported by other commands).
|
|
1033
|
+
|
|
1034
|
+
```bash
|
|
1035
|
+
bdt usage analyze [--scope workflow|all] [--since 2026-10-01] [--price gpt-6-sol=1.25,10,0.125] [--no-codex] [--rebuild]
|
|
1036
|
+
bdt usage sql "select label, round(pct_cost, 1) from v_steps" # ad hoc SQL on the last analysis
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
It reads `~/.claude/projects` (`--logs`) and `~/.codex/sessions` (`--codex-logs`, skipped if missing), prints a summary and
|
|
1040
|
+
writes `report.html` (charts plus a table view, light/dark) with CSV / Parquet exports next to the database
|
|
1041
|
+
(`~/.cache/bdt/usage/`, or `--db` / `--out`). The report is self-contained and contains your session titles and agent
|
|
1042
|
+
descriptions, so look at it before sharing it.
|
|
1043
|
+
|
|
1044
|
+
- **Steps:** setup, plan, plan review, implement, test, screenshots, design review, quick review, full review, publish / CI.
|
|
1045
|
+
Every model turn is assigned to one by markers in its tool calls: skill calls (`bms-code-review --quick|--full`,
|
|
1046
|
+
`design-review`, `bms-plan-review`; for codex, reading the skill's `SKILL.md`), `bdt` commands, edits (`apply_patch` for
|
|
1047
|
+
codex), pytest / lint, screenshot tooling, image reads. A step stays until the next marker; test runs only count for
|
|
1048
|
+
the turn that runs them. Subagents inherit the step of the turn that spawned them. Only threads that follow the workflow
|
|
1049
|
+
(and what they spawn) are counted, `--scope all` takes every session. It is a heuristic: read differences of a few percent
|
|
1050
|
+
as noise. The rules are SQL in `bmsdna/devtools/usage/sql/03_markers.sql`.
|
|
1051
|
+
- **Numbers:** cost is the API list price (cache writes 1.25x / 2x, cache reads, output, per model), not your actual bill;
|
|
1052
|
+
"% tokens" counts cache reads too, "% fresh tokens" does not. The report also estimates what screenshots cost by sitting in
|
|
1053
|
+
the context of every later turn.
|
|
1054
|
+
- **codex:** a codex run that Claude started with `codex exec` / `codex review` (same directory, within 15 minutes) is counted
|
|
1055
|
+
in that Claude session and step, so reviews run by codex show up under "Review" even though Claude's own log doesn't contain
|
|
1056
|
+
them; codex subagents follow their parent thread. codex models have no built-in price: give one with
|
|
1057
|
+
`--price MODEL=INPUT,OUTPUT,CACHED` (USD per million tokens), repeatable. Turns without a price are counted in tokens
|
|
1058
|
+
but missing from every cost number, and the summary says how many.
|
|
1059
|
+
- **Cache:** parsed records are kept in the database, so the next run only parses new or changed log files (a few seconds).
|
|
1060
|
+
`--rebuild` re-reads everything. Message text is not stored.
|
|
1061
|
+
|
|
1041
1062
|
## Releasing
|
|
1042
1063
|
|
|
1043
1064
|
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
@@ -35,6 +35,7 @@ from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull a
|
|
|
35
35
|
from .ado_auth import auth_header
|
|
36
36
|
from .bdt_config import find_pyproject, load_bdt_table
|
|
37
37
|
from .cli_tools import CLI_TIMEOUT_SECS, EXIT_WAIT_TIMEOUT, detect_agent_session, require_az, require_gh
|
|
38
|
+
from .usage.cli import usage_app
|
|
38
39
|
from .gitrepo import (
|
|
39
40
|
AdoRemote,
|
|
40
41
|
GitHubRemote,
|
|
@@ -103,6 +104,8 @@ issue_app.add_typer(issue_comment_app, name="comment")
|
|
|
103
104
|
logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics queries")
|
|
104
105
|
app.add_typer(logs_app, name="logs")
|
|
105
106
|
|
|
107
|
+
app.add_typer(usage_app, name="usage")
|
|
108
|
+
|
|
106
109
|
cleanup_app = typer.Typer(
|
|
107
110
|
name="cleanup",
|
|
108
111
|
help="Prune merged git worktrees and their orphaned pgdevkit Postgres test databases",
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""`bdt usage`: where do the tokens of Claude Code / codex sessions go, per step of the dev-workflow?
|
|
2
|
+
|
|
3
|
+
Reads the local session logs with DuckDB, attributes every model turn to a workflow step and prices it.
|
|
4
|
+
The heavy dependencies (duckdb, plotly) are the optional `usage` extra and only imported when a command runs.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`bdt usage`: profile Claude Code / codex session logs per step of the dev-workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from . import pricing
|
|
13
|
+
|
|
14
|
+
usage_app = typer.Typer(
|
|
15
|
+
name="usage",
|
|
16
|
+
help="Where do the tokens of Claude Code / codex sessions go, per step of the dev-workflow? "
|
|
17
|
+
"(needs the `usage` extra: `uv tool install 'bmsdna-devtools\\[usage]'`)",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
INSTALL_HINT = "bdt usage needs duckdb and plotly: install the extra, e.g. `uv tool install --reinstall 'bmsdna-devtools[usage]'`"
|
|
21
|
+
DEFAULT_CLAUDE_LOGS = "~/.claude/projects"
|
|
22
|
+
DEFAULT_CODEX_LOGS = "~/.codex/sessions"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_data_dir() -> Path:
|
|
26
|
+
cache = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache").expanduser()
|
|
27
|
+
return cache / "bdt" / "usage"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_duckdb():
|
|
31
|
+
try:
|
|
32
|
+
import duckdb
|
|
33
|
+
except ImportError:
|
|
34
|
+
print(INSTALL_HINT, file=sys.stderr)
|
|
35
|
+
raise typer.Exit(1) from None
|
|
36
|
+
return duckdb
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@usage_app.command("analyze")
|
|
40
|
+
def analyze(
|
|
41
|
+
logs: str = typer.Option(DEFAULT_CLAUDE_LOGS, "--logs", help="Claude Code projects directory"),
|
|
42
|
+
codex_logs: str = typer.Option(DEFAULT_CODEX_LOGS, "--codex-logs", help="codex sessions directory (skipped if it does not exist)"),
|
|
43
|
+
no_codex: bool = typer.Option(False, "--no-codex", help="Ignore codex logs"),
|
|
44
|
+
db: str | None = typer.Option(None, "--db", help="DuckDB file (default: <cache dir>/bdt/usage/profile.duckdb). Doubles as the parse cache"),
|
|
45
|
+
out: str | None = typer.Option(None, "--out", help="Directory for report.html and the CSV / Parquet exports (default: next to the database)"),
|
|
46
|
+
scope: str = typer.Option("workflow", "--scope", help="workflow: only threads that follow dev-workflow (and what they spawn); all: every session"),
|
|
47
|
+
since: str = typer.Option("1970-01-01", "--since", help="Only messages from this date on (YYYY-MM-DD)"),
|
|
48
|
+
rebuild: bool = typer.Option(False, "--rebuild", help="Ignore the parse cache in the database and re-read every log file"),
|
|
49
|
+
no_report: bool = typer.Option(False, "--no-report", help="Only print the summary"),
|
|
50
|
+
price: list[str] = typer.Option(
|
|
51
|
+
[], "--price", metavar="MODEL=IN,OUT,CACHED",
|
|
52
|
+
help="USD per million tokens for a model without a built-in price (codex / OpenAI models), repeatable. "
|
|
53
|
+
"Example: --price gpt-6-sol=1.25,10,0.125",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Load the session logs, attribute every model turn to a workflow step, price it, and print / render the result.
|
|
57
|
+
|
|
58
|
+
The first run parses every log file; later runs only parse new or changed ones. Costs are API list prices
|
|
59
|
+
(not your actual bill); turns of a model without a price are counted in tokens but not in cost.
|
|
60
|
+
"""
|
|
61
|
+
if scope not in ("workflow", "all"):
|
|
62
|
+
raise typer.BadParameter("Must be 'workflow' or 'all'", param_hint="--scope")
|
|
63
|
+
try:
|
|
64
|
+
date.fromisoformat(since)
|
|
65
|
+
except ValueError:
|
|
66
|
+
raise typer.BadParameter("Must be a date like 2026-10-01", param_hint="--since") from None
|
|
67
|
+
try:
|
|
68
|
+
prices = dict(pricing.parse_price(p) for p in price)
|
|
69
|
+
except ValueError as e:
|
|
70
|
+
raise typer.BadParameter(str(e), param_hint="--price") from None
|
|
71
|
+
duckdb = require_duckdb()
|
|
72
|
+
from . import pipeline
|
|
73
|
+
|
|
74
|
+
data_dir = default_data_dir()
|
|
75
|
+
db_path = Path(db).expanduser() if db else data_dir / "profile.duckdb"
|
|
76
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
con = duckdb.connect(str(db_path))
|
|
78
|
+
print(f"Reading {logs}" + ("" if no_codex else f" and {codex_logs}") + " ...", file=sys.stderr)
|
|
79
|
+
stats = pipeline.build(
|
|
80
|
+
con, Path(logs), None if no_codex else Path(codex_logs), scope=scope, since=since, rebuild=rebuild, prices=prices
|
|
81
|
+
)
|
|
82
|
+
print(f"{stats.files} log files, {stats.ingested} parsed ({stats.files - stats.ingested} from cache), {stats.removed} removed", file=sys.stderr)
|
|
83
|
+
|
|
84
|
+
summary = con.execute("SELECT * FROM v_summary").fetchone()
|
|
85
|
+
if not summary or not summary[2]:
|
|
86
|
+
print("No sessions in scope. Nothing used dev-workflow in these logs (try --scope all)?", file=sys.stderr)
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
con.sql("SELECT * EXCLUDE (unpriced_msgs, unpriced_models) FROM v_summary").show(max_width=200)
|
|
89
|
+
con.sql("""
|
|
90
|
+
SELECT label AS step, msgs AS turns, sessions, round(cost, 2) AS usd,
|
|
91
|
+
round(pct_cost, 1) AS "% cost", round(pct_tokens, 1) AS "% tokens",
|
|
92
|
+
round(pct_fresh_tokens, 1) AS "% fresh tokens",
|
|
93
|
+
round(pct_of_step_via_subagents) AS "% via subagents"
|
|
94
|
+
FROM v_steps""").show(max_width=200)
|
|
95
|
+
con.sql("SELECT provider, msgs AS turns, tokens, round(cost, 2) AS usd, round(pct_tokens, 1) AS \"% tokens\" FROM v_providers").show(max_width=200)
|
|
96
|
+
unpriced = con.execute("SELECT unpriced_msgs, unpriced_models FROM v_summary").fetchone()
|
|
97
|
+
if unpriced and unpriced[0]:
|
|
98
|
+
print(
|
|
99
|
+
f"Note: {unpriced[0]} turns of {unpriced[1]} have no price, so they are missing from the cost columns. "
|
|
100
|
+
"Add one with --price MODEL=IN,OUT,CACHED (USD per million tokens).",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
if not no_report:
|
|
104
|
+
from . import report
|
|
105
|
+
|
|
106
|
+
out_dir = Path(out).expanduser() if out else db_path.parent
|
|
107
|
+
print(f"Report: {report.write_report(con, out_dir)}", file=sys.stderr)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@usage_app.command("sql")
|
|
111
|
+
def sql(
|
|
112
|
+
query: str = typer.Argument(..., help="SQL to run against a database built by `bdt usage analyze` (tables: facts, msgs, threads, v_*)"),
|
|
113
|
+
db: str | None = typer.Option(None, "--db", help="DuckDB file (default: the one `analyze` writes)"),
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Run ad-hoc SQL on the last analysis, e.g. `bdt usage sql "select label, round(pct_cost, 1) from v_steps"`."""
|
|
116
|
+
duckdb = require_duckdb()
|
|
117
|
+
db_path = Path(db).expanduser() if db else default_data_dir() / "profile.duckdb"
|
|
118
|
+
if not db_path.exists():
|
|
119
|
+
print(f"{db_path} does not exist yet: run `bdt usage analyze` first.", file=sys.stderr)
|
|
120
|
+
raise typer.Exit(1)
|
|
121
|
+
con = duckdb.connect(str(db_path), read_only=True)
|
|
122
|
+
con.sql(query).show(max_width=200, max_rows=100)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Build the usage database: load the session logs with DuckDB, assign workflow steps, derive views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from . import pricing
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import duckdb
|
|
17
|
+
|
|
18
|
+
REVIEW_PHASES = {"review_quick", "review_full"}
|
|
19
|
+
KIND_RANK = {"start": 0, "skill": 1, "review_agent": 2, "cmd": 3, "edit": 4, "soft": 5, "once": 6}
|
|
20
|
+
|
|
21
|
+
# Bump when the rec_* tables or their parsing change: caches with another version are rebuilt.
|
|
22
|
+
CACHE_VERSION = "2"
|
|
23
|
+
REC_TABLES = ("rec_assistant", "rec_images", "rec_sessions", "rec_cmds", "rec_threads")
|
|
24
|
+
|
|
25
|
+
# provider -> (ingest script, glob below the log directory)
|
|
26
|
+
PROVIDERS = {
|
|
27
|
+
"claude": ("01_ingest_claude.sql", "*.jsonl"),
|
|
28
|
+
"codex": ("01_ingest_codex.sql", "*.jsonl"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BuildStats:
|
|
34
|
+
files: int
|
|
35
|
+
ingested: int
|
|
36
|
+
removed: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sql(name: str, **subs: str) -> str:
|
|
40
|
+
text = resources.files("bmsdna.devtools.usage").joinpath("sql", name).read_text(encoding="utf-8")
|
|
41
|
+
for key, value in subs.items():
|
|
42
|
+
text = text.replace(f"__{key}__", value)
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _quote(value: str) -> str:
|
|
47
|
+
return value.replace("'", "''")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sql_list(values: list[str]) -> str:
|
|
51
|
+
return "[" + ", ".join(f"'{_quote(v)}'" for v in values) + "]"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def assign_phases(con: duckdb.DuckDBPyConnection, scope: str) -> None:
|
|
55
|
+
"""Walk each thread in time order and give every message a workflow phase.
|
|
56
|
+
|
|
57
|
+
A thread that follows the workflow (invoked dev-workflow, or scope='all') starts in 'setup' and
|
|
58
|
+
switches phase on the next marker; the phase stays "sticky" until the next one (test runs are the
|
|
59
|
+
exception: they only count for the turn that runs them). Other spawned threads inherit the phase of
|
|
60
|
+
the message that spawned them and only react to strong markers (review/design/plan skills).
|
|
61
|
+
"""
|
|
62
|
+
msgs_by_thread: dict[str, list[tuple[str, datetime]]] = defaultdict(list)
|
|
63
|
+
thread_of_mid: dict[str, str] = {}
|
|
64
|
+
for tid, mid, ts in con.execute("SELECT thread_id, mid, ts FROM msgs ORDER BY thread_id, ts, mid").fetchall():
|
|
65
|
+
msgs_by_thread[tid].append((mid, ts))
|
|
66
|
+
thread_of_mid[mid] = tid
|
|
67
|
+
|
|
68
|
+
markers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
69
|
+
for mid, phase, kind in con.execute("SELECT mid, phase, kind FROM tool_markers ORDER BY tool_use_id").fetchall():
|
|
70
|
+
markers[mid].append((phase, kind))
|
|
71
|
+
|
|
72
|
+
meta = {r[0]: (r[1], r[2] or "") for r in con.execute("SELECT thread_id, parent_mid, agent_type FROM threads").fetchall()}
|
|
73
|
+
|
|
74
|
+
start_ts: dict[str, datetime] = {}
|
|
75
|
+
for tid, ts in con.execute(
|
|
76
|
+
"SELECT m.thread_id, min(m.ts) FROM msgs m JOIN tool_markers k USING (mid) WHERE k.kind = 'start' GROUP BY 1"
|
|
77
|
+
).fetchall():
|
|
78
|
+
start_ts[tid] = ts
|
|
79
|
+
for tid, ts in con.execute("SELECT thread_id, ts FROM workflow_commands").fetchall():
|
|
80
|
+
if tid in msgs_by_thread:
|
|
81
|
+
start_ts[tid] = min(start_ts.get(tid, ts), ts)
|
|
82
|
+
# Spawned threads that run the whole workflow themselves: told so in the spawn prompt (Claude), or seen doing it.
|
|
83
|
+
for tid, ts in con.execute(
|
|
84
|
+
"""
|
|
85
|
+
SELECT th.thread_id, min(m.ts)
|
|
86
|
+
FROM threads th
|
|
87
|
+
JOIN msgs m ON m.thread_id = th.thread_id
|
|
88
|
+
WHERE th.thread_id IN (SELECT m2.thread_id FROM msgs m2 JOIN tool_markers k USING (mid) WHERE k.wf_signal)
|
|
89
|
+
OR th.thread_id IN (
|
|
90
|
+
SELECT am.thread_id
|
|
91
|
+
FROM agent_meta am
|
|
92
|
+
JOIN tool_uses tu ON tu.tool_use_id = am.parent_tool_use_id
|
|
93
|
+
WHERE tu.name IN ('Agent', 'Task')
|
|
94
|
+
AND regexp_matches(lower(json_extract_string(tu.input, '$.prompt')),
|
|
95
|
+
'follow (the )?[`~/.\\w-]{0,40}dev-workflow|follow the workflow')
|
|
96
|
+
AND coalesce(json_extract_string(tu.input, '$.subagent_type'), '') NOT LIKE '%bms-review-%')
|
|
97
|
+
GROUP BY 1
|
|
98
|
+
"""
|
|
99
|
+
).fetchall():
|
|
100
|
+
start_ts.setdefault(tid, ts)
|
|
101
|
+
if scope == "all":
|
|
102
|
+
for tid, items in msgs_by_thread.items():
|
|
103
|
+
if tid not in meta:
|
|
104
|
+
start_ts[tid] = items[0][1]
|
|
105
|
+
|
|
106
|
+
phase_of: dict[str, str] = {}
|
|
107
|
+
sticky_of: dict[str, str] = {} # the phase a spawned thread inherits
|
|
108
|
+
pending = set(msgs_by_thread)
|
|
109
|
+
|
|
110
|
+
def run_thread(tid: str, inherited: str) -> None:
|
|
111
|
+
agent_type = meta.get(tid, (None, ""))[1]
|
|
112
|
+
if "bms-review-" in agent_type and inherited not in REVIEW_PHASES and inherited != "outside":
|
|
113
|
+
inherited = "review_full"
|
|
114
|
+
phase, started, begin = inherited, False, start_ts.get(tid)
|
|
115
|
+
for mid, ts in msgs_by_thread[tid]:
|
|
116
|
+
if begin is not None and not started and ts >= begin:
|
|
117
|
+
phase, started = "setup", True
|
|
118
|
+
once = None
|
|
119
|
+
for ph, kind in sorted(markers.get(mid, []), key=lambda m: KIND_RANK[m[1]]):
|
|
120
|
+
if kind == "skill" and phase != "outside":
|
|
121
|
+
phase = ph
|
|
122
|
+
elif kind == "review_agent" and phase != "outside" and phase not in REVIEW_PHASES:
|
|
123
|
+
phase = ph
|
|
124
|
+
elif kind in ("cmd", "edit") and started:
|
|
125
|
+
phase = ph
|
|
126
|
+
elif kind == "soft" and started and phase != "design_review":
|
|
127
|
+
phase = ph
|
|
128
|
+
elif kind == "once" and started:
|
|
129
|
+
once = ph
|
|
130
|
+
phase_of[mid] = once or phase
|
|
131
|
+
sticky_of[mid] = phase
|
|
132
|
+
|
|
133
|
+
while pending:
|
|
134
|
+
progressed = False
|
|
135
|
+
for tid in sorted(pending):
|
|
136
|
+
parent_mid = meta.get(tid, (None, None))[0]
|
|
137
|
+
parent_thread = thread_of_mid.get(parent_mid) if parent_mid else None
|
|
138
|
+
if parent_thread in pending and parent_thread != tid:
|
|
139
|
+
continue
|
|
140
|
+
inherited = sticky_of.get(parent_mid, "outside") if parent_mid else "outside"
|
|
141
|
+
run_thread(tid, inherited)
|
|
142
|
+
pending.discard(tid)
|
|
143
|
+
progressed = True
|
|
144
|
+
if not progressed:
|
|
145
|
+
for tid in sorted(pending):
|
|
146
|
+
run_thread(tid, "outside")
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
con.execute("CREATE OR REPLACE TABLE msg_phase (mid VARCHAR, phase VARCHAR)")
|
|
150
|
+
con.execute("INSERT INTO msg_phase SELECT unnest(?), unnest(?)", [list(phase_of), list(phase_of.values())])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def ingest(
|
|
154
|
+
con: duckdb.DuckDBPyConnection, sources: dict[str, Path], rebuild: bool = False
|
|
155
|
+
) -> BuildStats:
|
|
156
|
+
"""Bring the rec_* cache tables up to date, parsing only new or changed log files.
|
|
157
|
+
|
|
158
|
+
`sources` maps a provider ("claude", "codex") to the directory holding its logs; a provider that is not
|
|
159
|
+
listed is left alone (its cached rows stay, they are just not analysed, see `build`).
|
|
160
|
+
"""
|
|
161
|
+
con.execute(_sql("00_schema.sql"))
|
|
162
|
+
row = con.execute("SELECT value FROM cache_meta WHERE key = 'version'").fetchone()
|
|
163
|
+
if rebuild or (row and row[0] != CACHE_VERSION):
|
|
164
|
+
for table in (*REC_TABLES, "src_files"):
|
|
165
|
+
con.execute(f"DELETE FROM {table}")
|
|
166
|
+
con.execute("DELETE FROM cache_meta WHERE key = 'version'")
|
|
167
|
+
con.execute("INSERT INTO cache_meta VALUES ('version', ?)", [CACHE_VERSION])
|
|
168
|
+
|
|
169
|
+
files = ingested = removed = 0
|
|
170
|
+
for provider, root in sources.items():
|
|
171
|
+
script, pattern = PROVIDERS[provider]
|
|
172
|
+
on_disk: dict[str, tuple[int, int]] = {}
|
|
173
|
+
for path in root.rglob(pattern):
|
|
174
|
+
st = path.stat()
|
|
175
|
+
on_disk[str(path)] = (st.st_size, st.st_mtime_ns)
|
|
176
|
+
prefix = str(root) + "/"
|
|
177
|
+
known = {
|
|
178
|
+
f: (size, mtime)
|
|
179
|
+
for f, size, mtime in con.execute(
|
|
180
|
+
"SELECT filename, size, mtime_ns FROM src_files WHERE starts_with(filename, ?)", [prefix]
|
|
181
|
+
).fetchall()
|
|
182
|
+
}
|
|
183
|
+
changed = sorted(f for f, stat in on_disk.items() if known.get(f) != stat)
|
|
184
|
+
gone = sorted(set(known) - set(on_disk))
|
|
185
|
+
|
|
186
|
+
stale = changed + gone
|
|
187
|
+
if stale:
|
|
188
|
+
con.execute("CREATE OR REPLACE TEMP TABLE stale AS SELECT unnest(?) AS filename", [stale])
|
|
189
|
+
for table in (*REC_TABLES, "src_files"):
|
|
190
|
+
con.execute(f"DELETE FROM {table} WHERE filename IN (SELECT filename FROM stale)")
|
|
191
|
+
if changed:
|
|
192
|
+
con.execute(_sql(script, FILES=_sql_list(changed)))
|
|
193
|
+
con.execute(
|
|
194
|
+
"INSERT INTO src_files SELECT unnest(?), unnest(?), unnest(?)",
|
|
195
|
+
[changed, [on_disk[f][0] for f in changed], [on_disk[f][1] for f in changed]],
|
|
196
|
+
)
|
|
197
|
+
files += len(on_disk)
|
|
198
|
+
ingested += len(changed)
|
|
199
|
+
removed += len(gone)
|
|
200
|
+
return BuildStats(files=files, ingested=ingested, removed=removed)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_META_COLUMNS = {"agentType": "VARCHAR", "description": "VARCHAR", "toolUseId": "VARCHAR",
|
|
204
|
+
"spawnDepth": "INTEGER", "model": "VARCHAR"}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _meta_source(claude_dir: Path | None) -> str:
|
|
208
|
+
"""FROM-clause for the Claude subagent meta files (an empty table if there are none: read_json errors on no match)."""
|
|
209
|
+
cols = ", ".join(f"{k}: '{v}'" for k, v in _META_COLUMNS.items())
|
|
210
|
+
files = sorted(str(p) for p in claude_dir.rglob("subagents/*.meta.json")) if claude_dir else []
|
|
211
|
+
if files:
|
|
212
|
+
return (f"read_json({_sql_list(files)}, union_by_name = true, ignore_errors = true, filename = true, "
|
|
213
|
+
f"columns = {{{cols}}})")
|
|
214
|
+
empty = ", ".join(f"NULL::{v} AS {k}" for k, v in _META_COLUMNS.items())
|
|
215
|
+
return f"(SELECT NULL::VARCHAR AS filename, {empty} WHERE false)"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def build(
|
|
219
|
+
con: duckdb.DuckDBPyConnection,
|
|
220
|
+
claude_dir: Path | None,
|
|
221
|
+
codex_dir: Path | None = None,
|
|
222
|
+
scope: str = "workflow",
|
|
223
|
+
since: str = "1970-01-01",
|
|
224
|
+
rebuild: bool = False,
|
|
225
|
+
prices: dict[str, pricing.Price] | None = None,
|
|
226
|
+
) -> BuildStats:
|
|
227
|
+
"""Ingest the given log directories (None = skip that provider) and derive everything from the cache."""
|
|
228
|
+
sources = {
|
|
229
|
+
provider: directory.expanduser()
|
|
230
|
+
for provider, directory in (("claude", claude_dir), ("codex", codex_dir))
|
|
231
|
+
if directory is not None and directory.expanduser().is_dir()
|
|
232
|
+
}
|
|
233
|
+
# The JSON reader keeps a buffer of `maximum_object_size` (16 MB in the ingest scripts) per thread: on a busy,
|
|
234
|
+
# many-core machine that is gigabytes for no gain, since the log files are read in a few seconds anyway.
|
|
235
|
+
con.execute(f"SET threads = {min(os.cpu_count() or 4, 8)}")
|
|
236
|
+
stats = ingest(con, sources, rebuild=rebuild)
|
|
237
|
+
con.execute(_sql(
|
|
238
|
+
"02_messages.sql",
|
|
239
|
+
META_SOURCE=_meta_source(sources.get("claude")),
|
|
240
|
+
SINCE=_quote(since),
|
|
241
|
+
PROVIDERS=", ".join(f"'{p}'" for p in sources) or "''",
|
|
242
|
+
))
|
|
243
|
+
con.execute(_sql("03_markers.sql"))
|
|
244
|
+
assign_phases(con, scope)
|
|
245
|
+
rows = ", ".join(f"('{_quote(m)}', {i}, {o}, {c})" for m, i, o, c in pricing.price_rows(prices))
|
|
246
|
+
con.execute(_sql(
|
|
247
|
+
"04_facts.sql",
|
|
248
|
+
SINCE=_quote(since),
|
|
249
|
+
PRICE_ROWS=rows,
|
|
250
|
+
FALLBACK_MODEL=pricing.FALLBACK_MODEL,
|
|
251
|
+
CW5=str(pricing.CACHE_WRITE_5M_FACTOR),
|
|
252
|
+
CW1H=str(pricing.CACHE_WRITE_1H_FACTOR),
|
|
253
|
+
))
|
|
254
|
+
con.execute(_sql("05_views.sql"))
|
|
255
|
+
return stats
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""USD per million tokens. Edit freely, or pass `--price`. An unknown Claude model falls back to FALLBACK_MODEL;
|
|
2
|
+
any other model (codex / OpenAI) has no built-in price and stays unpriced until you give it one.
|
|
3
|
+
|
|
4
|
+
Cache writes are priced relative to input: 1.25x for the 5 minute TTL, 2x for the 1 hour TTL.
|
|
5
|
+
Prices are API list prices, so the result is an "API-equivalent cost" (useful for relative
|
|
6
|
+
shares even if you are on a flat subscription).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
CACHE_WRITE_5M_FACTOR = 1.25
|
|
14
|
+
CACHE_WRITE_1H_FACTOR = 2.0
|
|
15
|
+
FALLBACK_MODEL = "claude-sonnet-5-5"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Price:
|
|
20
|
+
input: float
|
|
21
|
+
output: float
|
|
22
|
+
cache_read: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PRICES: dict[str, Price] = {
|
|
26
|
+
"claude-fable-5-1": Price(10.0, 50.0, 0.25),
|
|
27
|
+
"claude-fable-5": Price(10.0, 50.0, 1.0),
|
|
28
|
+
"claude-opus-5-5": Price(4.0, 20.0, 0.20),
|
|
29
|
+
"claude-opus-5": Price(5.0, 25.0, 0.50),
|
|
30
|
+
"claude-opus-4-8": Price(5.0, 25.0, 0.50),
|
|
31
|
+
"claude-opus-4-7": Price(5.0, 25.0, 0.50),
|
|
32
|
+
"claude-opus-4-6": Price(5.0, 25.0, 0.50),
|
|
33
|
+
"claude-sonnet-5-5": Price(2.0, 10.0, 0.20),
|
|
34
|
+
"claude-sonnet-5": Price(2.0, 10.0, 0.20),
|
|
35
|
+
"claude-sonnet-4-6": Price(3.0, 15.0, 0.30),
|
|
36
|
+
"claude-haiku-4-5": Price(1.0, 5.0, 0.10),
|
|
37
|
+
"claude-haiku-4-5-20251001": Price(1.0, 5.0, 0.10),
|
|
38
|
+
"<synthetic>": Price(0.0, 0.0, 0.0),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_price(option: str) -> tuple[str, Price]:
|
|
43
|
+
"""`--price MODEL=INPUT,OUTPUT,CACHE_READ` (USD per million tokens), e.g. `gpt-6-sol=1.25,10,0.125`."""
|
|
44
|
+
model, sep, rest = option.partition("=")
|
|
45
|
+
parts = rest.split(",")
|
|
46
|
+
try:
|
|
47
|
+
if not (sep and model.strip() and len(parts) == 3):
|
|
48
|
+
raise ValueError
|
|
49
|
+
inp, out, cached = (float(x) for x in parts)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise ValueError(f"Expected MODEL=INPUT,OUTPUT,CACHE_READ (USD per million tokens), got {option!r}") from None
|
|
52
|
+
if min(inp, out, cached) < 0:
|
|
53
|
+
raise ValueError(f"Prices must not be negative: {option!r}")
|
|
54
|
+
return model.strip(), Price(inp, out, cached)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def price_rows(extra: dict[str, Price] | None = None) -> list[tuple[str, float, float, float]]:
|
|
58
|
+
"""Rows for the `pricing` table: the built-in prices, overridden / extended by `extra`."""
|
|
59
|
+
return [(m, p.input, p.output, p.cache_read) for m, p in {**PRICES, **(extra or {})}.items()]
|