cli-consumption 0.4.1__tar.gz → 0.4.3__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.
Files changed (67) hide show
  1. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/CHANGELOG.md +25 -1
  2. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/PKG-INFO +7 -2
  3. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/README.md +6 -1
  4. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/pyproject.toml +1 -1
  5. cli_consumption-0.4.3/src/cli_consumption/INTER_FONT_LICENSE.txt +93 -0
  6. cli_consumption-0.4.3/src/cli_consumption/adapters/base.py +43 -0
  7. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/codex.py +144 -34
  8. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/api.py +23 -3
  9. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/cli.py +327 -24
  10. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/dashboard.py +37 -1
  11. cli_consumption-0.4.3/src/cli_consumption/dashboard_layouts.py +345 -0
  12. cli_consumption-0.4.3/src/cli_consumption/dashboard_react.css +2 -0
  13. cli_consumption-0.4.3/src/cli_consumption/dashboard_react.js +9 -0
  14. cli_consumption-0.4.3/src/cli_consumption/migrations/versions/v0006_dashboard_layouts.py +28 -0
  15. cli_consumption-0.4.3/src/cli_consumption/migrations/versions/v0007_dashboard_layout_revision.py +39 -0
  16. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/reporting_api.py +117 -18
  17. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/schema.py +83 -8
  18. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/storage.py +50 -15
  19. cli_consumption-0.4.1/src/cli_consumption/adapters/base.py +0 -22
  20. cli_consumption-0.4.1/src/cli_consumption/dashboard_react.css +0 -2
  21. cli_consumption-0.4.1/src/cli_consumption/dashboard_react.js +0 -9
  22. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/.gitignore +0 -0
  23. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/LICENSE +0 -0
  24. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/NOTICE +0 -0
  25. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/__init__.py +0 -0
  26. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/__main__.py +0 -0
  27. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/__init__.py +0 -0
  28. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/_shared.py +0 -0
  29. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/aider.py +0 -0
  30. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/amazon_q.py +0 -0
  31. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/amp.py +0 -0
  32. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/claude.py +0 -0
  33. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/cline.py +0 -0
  34. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/continue_cli.py +0 -0
  35. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/copilot.py +0 -0
  36. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/crush.py +0 -0
  37. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/cursor.py +0 -0
  38. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/gemini.py +0 -0
  39. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/goose.py +0 -0
  40. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/grok.py +0 -0
  41. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/kilo.py +0 -0
  42. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/kimi.py +0 -0
  43. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/mistral_vibe.py +0 -0
  44. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/opencode.py +0 -0
  45. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/openhands.py +0 -0
  46. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/pi.py +0 -0
  47. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/plandex.py +0 -0
  48. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/qwen.py +0 -0
  49. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/adapters/registry.py +0 -0
  50. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/exporting.py +0 -0
  51. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/__init__.py +0 -0
  52. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/env.py +0 -0
  53. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/__init__.py +0 -0
  54. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0001_baseline.py +0 -0
  55. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0002_minimize_subagents.py +0 -0
  56. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0003_canonical_timestamps.py +0 -0
  57. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0004_subagent_scope_freshness.py +0 -0
  58. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/migrations/versions/v0005_sync_receipts.py +0 -0
  59. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/models.py +0 -0
  60. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/py.typed +0 -0
  61. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/qualifications.py +0 -0
  62. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/reporting.py +0 -0
  63. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/retention.py +0 -0
  64. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/snapshot_extraction.py +0 -0
  65. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/snapshot_files.py +0 -0
  66. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/sync.py +0 -0
  67. {cli_consumption-0.4.1 → cli_consumption-0.4.3}/src/cli_consumption/timestamps.py +0 -0
@@ -6,6 +6,28 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.3] - 2026-09-05
10
+
11
+ ### Added
12
+
13
+ - Added opt-in `collect --incremental` automatic bounded batching for large Codex
14
+ stores, with restart-safe ingestion, strict metadata-only preflight staging,
15
+ deterministic duplicate convergence, privacy-safe partial results, and unchanged
16
+ per-file and per-line safety limits; authoritative subagent graphs remain untouched.
17
+
18
+ ## [0.4.2] - 2026-09-05
19
+
20
+ ### Added
21
+
22
+ - Added reviewed autonomous web exports that freeze the visible dashboard layout,
23
+ bounded selection, privacy profile, and theme into a CSP-locked standalone file while
24
+ preserving query-only API and CLI export compatibility.
25
+ - Added the provider-neutral `DashboardLayout v1` contract, a bounded widget registry,
26
+ deterministic defaults and retired-widget recovery, plus SQLite/PostgreSQL
27
+ persistence for the single authenticated dashboard operator.
28
+ - Added an explicit, accessible dashboard edit mode with bounded undo/redo, deterministic
29
+ placement, and ETag/`If-Match` conflict protection for concurrent save and reset.
30
+
9
31
  ## [0.4.1] - 2026-09-01
10
32
 
11
33
  ### Changed
@@ -172,7 +194,9 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
172
194
 
173
195
  - Refreshed the provider guide for the first minor release ([#26]).
174
196
 
175
- [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.1...HEAD
197
+ [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.3...HEAD
198
+ [0.4.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.2...v0.4.3
199
+ [0.4.2]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.1...v0.4.2
176
200
  [0.4.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.0...v0.4.1
177
201
  [0.4.0]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.3...v0.4.0
178
202
  [0.3.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.2...v0.3.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Analyze and consolidate AI coding CLI consumption across machines.
5
5
  Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
6
6
  Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
@@ -117,6 +117,10 @@ To collect one provider or select another database:
117
117
  uv run cli-consumption collect --provider codex --database usage.sqlite
118
118
  ```
119
119
 
120
+ For a Codex store that exceeds aggregate collection limits, add `--incremental`.
121
+ The command automatically writes bounded, restart-safe batches to the same database;
122
+ individual file and line safety limits remain enforced.
123
+
120
124
  Use `--source [LABEL=]PATH` for trusted offline copies and repeated
121
125
  `--project NAME=PATH_PREFIX` mappings for stable project labels. See the
122
126
  [usage and operations guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
@@ -145,7 +149,8 @@ uv run cli-consumption providers --json
145
149
 
146
150
  - Provider files are untrusted. Collection enforces discovery, file, line, SQLite row,
147
151
  structured-field, and total normalized-record limits; direct provider-file symlinks
148
- are refused.
152
+ are refused. Opt-in Codex incremental collection resets only aggregate per-batch
153
+ limits and keeps a separate hard batch-count ceiling.
149
154
  - `collect --strict` and `sync --strict` refuse a batch when any provider skipped
150
155
  malformed records. Collection distinguishes `provider_limit_exceeded`,
151
156
  `provider_format_incompatible`, `invalid_snapshot`, and the unexpected-failure
@@ -78,6 +78,10 @@ To collect one provider or select another database:
78
78
  uv run cli-consumption collect --provider codex --database usage.sqlite
79
79
  ```
80
80
 
81
+ For a Codex store that exceeds aggregate collection limits, add `--incremental`.
82
+ The command automatically writes bounded, restart-safe batches to the same database;
83
+ individual file and line safety limits remain enforced.
84
+
81
85
  Use `--source [LABEL=]PATH` for trusted offline copies and repeated
82
86
  `--project NAME=PATH_PREFIX` mappings for stable project labels. See the
83
87
  [usage and operations guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/usage.md)
@@ -106,7 +110,8 @@ uv run cli-consumption providers --json
106
110
 
107
111
  - Provider files are untrusted. Collection enforces discovery, file, line, SQLite row,
108
112
  structured-field, and total normalized-record limits; direct provider-file symlinks
109
- are refused.
113
+ are refused. Opt-in Codex incremental collection resets only aggregate per-batch
114
+ limits and keeps a separate hard batch-count ceiling.
110
115
  - `collect --strict` and `sync --strict` refuse a batch when any provider skipped
111
116
  malformed records. Collection distinguishes `provider_limit_exceeded`,
112
117
  `provider_format_incompatible`, `invalid_snapshot`, and the unexpected-failure
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.4.1"
7
+ version = "0.4.3"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,93 @@
1
+ Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ from cli_consumption.models import Snapshot
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class CollectionBatch:
13
+ """One snapshot plus an optional subagent-scope authority override."""
14
+
15
+ snapshot: Snapshot
16
+ authoritative_subagent_scopes: frozenset[tuple[str, str]] | None = None
17
+
18
+
19
+ class UnsupportedProviderFormat(ValueError):
20
+ """Raised when a detected provider store has an incompatible schema."""
21
+
22
+
23
+ class Adapter(Protocol):
24
+ """Contract implemented by every supported AI CLI."""
25
+
26
+ name: str
27
+
28
+ def collect(
29
+ self,
30
+ sources: list[tuple[str, Path]],
31
+ project_mappings: list[tuple[str, str]],
32
+ ) -> Snapshot: ...
33
+
34
+
35
+ @runtime_checkable
36
+ class IncrementalAdapter(Adapter, Protocol):
37
+ """Optional adapter contract for bounded, independently ingestible batches."""
38
+
39
+ def collect_incrementally(
40
+ self,
41
+ sources: list[tuple[str, Path]],
42
+ project_mappings: list[tuple[str, str]],
43
+ ) -> Iterator[CollectionBatch]: ...
@@ -3,8 +3,10 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import json
5
5
  import math
6
+ import os
6
7
  import re
7
8
  import sqlite3
9
+ from collections.abc import Iterator
8
10
  from datetime import UTC, datetime
9
11
  from pathlib import Path
10
12
  from typing import Any
@@ -13,11 +15,18 @@ from cli_consumption.adapters._shared import (
13
15
  MAX_BIGINT as MAX_BIGINT,
14
16
  )
15
17
  from cli_consumption.adapters._shared import (
18
+ ProviderDataLimitError,
16
19
  ProviderInputBudget,
17
20
  iter_bounded_jsonl_bytes,
18
21
  open_provider_sqlite,
19
22
  )
20
- from cli_consumption.models import TOKEN_FIELDS, Snapshot, empty_tokens
23
+ from cli_consumption.adapters.base import CollectionBatch
24
+ from cli_consumption.models import (
25
+ TOKEN_FIELDS,
26
+ Snapshot,
27
+ SnapshotValidationError,
28
+ empty_tokens,
29
+ )
21
30
 
22
31
  OUTSIDE_PROJECT = "outside-project"
23
32
  TOOL_PATTERN = re.compile(r"(?:tools|collaboration)\.([A-Za-z][A-Za-z0-9_]*)\s*\(")
@@ -85,6 +94,24 @@ AGENT_ROLE_ALIASES = {
85
94
  "worker": "worker",
86
95
  }
87
96
  SAFE_DIMENSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+-]*")
97
+ INCREMENTAL_CANDIDATES_PER_BATCH = 1_000
98
+
99
+
100
+ def _iter_session_files(root: Path) -> Iterator[Path]:
101
+ """Walk a provider tree deterministically without materializing every path."""
102
+ pending = [root]
103
+ while pending:
104
+ directory = pending.pop()
105
+ with os.scandir(directory) as entries:
106
+ ordered = sorted(entries, key=lambda entry: entry.name)
107
+ child_directories: list[Path] = []
108
+ for entry in ordered:
109
+ path = Path(entry.path)
110
+ if entry.is_dir(follow_symlinks=False):
111
+ child_directories.append(path)
112
+ elif entry.name.endswith(".jsonl"):
113
+ yield path
114
+ pending.extend(reversed(child_directories))
88
115
 
89
116
 
90
117
  def parse_timestamp(value: object) -> datetime | None:
@@ -166,6 +193,73 @@ class CodexAdapter:
166
193
  )
167
194
  return snapshot
168
195
 
196
+ def collect_incrementally(
197
+ self,
198
+ sources: list[tuple[str, Path]],
199
+ project_mappings: list[tuple[str, str]] | None = None,
200
+ ) -> Iterator[CollectionBatch]:
201
+ """Yield deterministic, bounded snapshots from arbitrarily many rollouts."""
202
+ mappings = project_mappings or []
203
+ for machine, codex_home in sources:
204
+ sessions = codex_home / "sessions"
205
+ if not sessions.is_dir():
206
+ raise ValueError("Missing Codex sessions directory")
207
+
208
+ yielded_sessions = False
209
+ batch: list[tuple[str, Path]] = []
210
+ for path in _iter_session_files(sessions):
211
+ batch.append((machine, path))
212
+ if len(batch) == INCREMENTAL_CANDIDATES_PER_BATCH:
213
+ yielded_sessions = True
214
+ yield from self._collect_incremental_batch(batch, mappings)
215
+ batch = []
216
+ if batch:
217
+ yielded_sessions = True
218
+ yield from self._collect_incremental_batch(batch, mappings)
219
+
220
+ if not yielded_sessions:
221
+ yield CollectionBatch(Snapshot(provider=self.name), frozenset())
222
+
223
+ def _collect_incremental_batch(
224
+ self,
225
+ candidates: list[tuple[str, Path]],
226
+ mappings: list[tuple[str, str]],
227
+ ) -> Iterator[CollectionBatch]:
228
+ try:
229
+ yield CollectionBatch(
230
+ self._collect_candidates(candidates, mappings), frozenset()
231
+ )
232
+ except ProviderDataLimitError as error:
233
+ if str(error) != "provider_read_limit_exceeded" or len(candidates) == 1:
234
+ raise
235
+ midpoint = len(candidates) // 2
236
+ yield from self._collect_incremental_batch(candidates[:midpoint], mappings)
237
+ yield from self._collect_incremental_batch(candidates[midpoint:], mappings)
238
+ except SnapshotValidationError as error:
239
+ if error.code != "snapshot_too_large" or len(candidates) == 1:
240
+ raise
241
+ midpoint = len(candidates) // 2
242
+ yield from self._collect_incremental_batch(candidates[:midpoint], mappings)
243
+ yield from self._collect_incremental_batch(candidates[midpoint:], mappings)
244
+
245
+ def _collect_candidates(
246
+ self,
247
+ candidates: list[tuple[str, Path]],
248
+ mappings: list[tuple[str, str]],
249
+ ) -> Snapshot:
250
+ budget = ProviderInputBudget()
251
+ selected, duplicates, malformed = self._discover_candidates(candidates, budget)
252
+ snapshot = Snapshot(
253
+ provider=self.name,
254
+ duplicate_conversations=duplicates,
255
+ malformed_records=malformed,
256
+ )
257
+ for machine, path, event_count, digest in selected:
258
+ self._read_rollout(
259
+ snapshot, machine, path, event_count, digest, mappings, budget
260
+ )
261
+ return snapshot
262
+
169
263
  def _read_subagents(
170
264
  self,
171
265
  state_path: Path,
@@ -215,46 +309,62 @@ class CodexAdapter:
215
309
  def _discover(
216
310
  self, sources: list[tuple[str, Path]], budget: ProviderInputBudget
217
311
  ) -> tuple[list[tuple[str, Path, int, str]], int, int]:
218
- selected: dict[str, tuple[str, Path, int, str]] = {}
219
- duplicates = 0
220
- malformed = 0
312
+ candidates: list[tuple[str, Path]] = []
221
313
  for machine, codex_home in sources:
222
314
  sessions = codex_home / "sessions"
223
315
  if not sessions.is_dir():
224
316
  raise ValueError(f"Missing Codex sessions directory: {sessions}")
225
- for path in budget.sorted_paths(sessions.rglob("*.jsonl")):
226
- event_count = 0
227
- conversation_id = ""
228
- digest = hashlib.sha256()
229
- for raw_line in iter_bounded_jsonl_bytes(path, budget):
230
- digest.update(raw_line)
231
- try:
232
- event = json.loads(raw_line)
233
- except (json.JSONDecodeError, UnicodeDecodeError):
234
- malformed += 1
235
- continue
236
- if not isinstance(event, dict):
317
+ candidates.extend(
318
+ (machine, path)
319
+ for path in budget.sorted_paths(sessions.rglob("*.jsonl"))
320
+ )
321
+ return self._discover_candidates(candidates, budget, charge_candidates=False)
322
+
323
+ def _discover_candidates(
324
+ self,
325
+ candidates: list[tuple[str, Path]],
326
+ budget: ProviderInputBudget,
327
+ *,
328
+ charge_candidates: bool = True,
329
+ ) -> tuple[list[tuple[str, Path, int, str]], int, int]:
330
+ selected: dict[str, tuple[str, Path, int, str]] = {}
331
+ duplicates = 0
332
+ malformed = 0
333
+ for machine, path in candidates:
334
+ if charge_candidates:
335
+ budget.item()
336
+ event_count = 0
337
+ conversation_id = ""
338
+ digest = hashlib.sha256()
339
+ for raw_line in iter_bounded_jsonl_bytes(path, budget):
340
+ digest.update(raw_line)
341
+ try:
342
+ event = json.loads(raw_line)
343
+ except (json.JSONDecodeError, UnicodeDecodeError):
344
+ malformed += 1
345
+ continue
346
+ if not isinstance(event, dict):
347
+ malformed += 1
348
+ continue
349
+ event_count += 1
350
+ if event.get("type") == "session_meta":
351
+ payload = event.get("payload")
352
+ if not isinstance(payload, dict):
237
353
  malformed += 1
238
354
  continue
239
- event_count += 1
240
- if event.get("type") == "session_meta":
241
- payload = event.get("payload")
242
- if not isinstance(payload, dict):
243
- malformed += 1
244
- continue
245
- candidate_id = _safe_dimension(payload.get("id"), 512)
246
- if candidate_id and not conversation_id:
247
- conversation_id = candidate_id
248
- content_hash = digest.hexdigest()
249
- conversation_id = conversation_id or f"content-{content_hash}"
250
- candidate = (machine, path, event_count, content_hash)
251
- previous = selected.get(conversation_id)
252
- if previous is None:
355
+ candidate_id = _safe_dimension(payload.get("id"), 512)
356
+ if candidate_id and not conversation_id:
357
+ conversation_id = candidate_id
358
+ content_hash = digest.hexdigest()
359
+ conversation_id = conversation_id or f"content-{content_hash}"
360
+ candidate = (machine, path, event_count, content_hash)
361
+ previous = selected.get(conversation_id)
362
+ if previous is None:
363
+ selected[conversation_id] = candidate
364
+ else:
365
+ duplicates += 1
366
+ if candidate[2:] > previous[2:]:
253
367
  selected[conversation_id] = candidate
254
- else:
255
- duplicates += 1
256
- if candidate[2:] > previous[2:]:
257
- selected[conversation_id] = candidate
258
368
  return list(selected.values()), duplicates, malformed
259
369
 
260
370
  def _read_rollout(
@@ -19,6 +19,10 @@ from sqlalchemy.engine import Engine
19
19
  from starlette.types import ASGIApp, Message, Receive, Scope, Send
20
20
 
21
21
  from cli_consumption import __version__
22
+ from cli_consumption.dashboard_layouts import (
23
+ DASHBOARD_LAYOUT_VERSION,
24
+ MAX_DASHBOARD_LAYOUT_BYTES,
25
+ )
22
26
  from cli_consumption.models import (
23
27
  CURRENT_SNAPSHOT_SCHEMA,
24
28
  MAX_SNAPSHOT_RECORDS,
@@ -29,6 +33,7 @@ from cli_consumption.models import (
29
33
  )
30
34
  from cli_consumption.reporting_api import (
31
35
  CACHE_HEADERS,
36
+ EXPORT_REQUEST_BYTES,
32
37
  EXPORT_TIMEOUT_SECONDS,
33
38
  MAX_CONCURRENT_EXPORTS,
34
39
  MAX_CONCURRENT_REPORTS,
@@ -69,6 +74,7 @@ READINESS_TABLES = (
69
74
  "subagent_scopes",
70
75
  "ingestion_runs",
71
76
  "sync_receipts",
77
+ "dashboard_layouts",
72
78
  )
73
79
  READINESS_RESPONSE_TIMEOUT_SECONDS = 2.0
74
80
  READINESS_CONNECT_TIMEOUT_SECONDS = 2
@@ -94,6 +100,7 @@ SAFE_ROUTES = frozenset(
94
100
  "/api/v1/reporting/conversations",
95
101
  "/api/v1/reporting/conversation",
96
102
  "/api/v1/reporting/export",
103
+ "/api/v1/reporting/layout",
97
104
  }
98
105
  )
99
106
  SAFE_EXCEPTION_TYPES = frozenset(
@@ -354,9 +361,12 @@ class RequestSizeLimitMiddleware:
354
361
  if scope["type"] != "http":
355
362
  await self.app(scope, receive, send)
356
363
  return
364
+ path = str(scope.get("path", ""))
357
365
  maximum = (
358
- REPORTING_REQUEST_BYTES
359
- if str(scope.get("path", "")).startswith("/api/v1/reporting/")
366
+ EXPORT_REQUEST_BYTES
367
+ if path == "/api/v1/reporting/export"
368
+ else REPORTING_REQUEST_BYTES
369
+ if path.startswith("/api/v1/reporting/")
360
370
  else self.maximum
361
371
  )
362
372
  headers = dict(scope.get("headers", []))
@@ -405,6 +415,7 @@ def _configured_credentials(
405
415
  api_token: str | None,
406
416
  read_token: str | None,
407
417
  export_token: str | None,
418
+ layout_token: str | None,
408
419
  ) -> list[tuple[str, frozenset[str]]]:
409
420
  credentials: list[tuple[str, frozenset[str]]] = []
410
421
  configured_values: set[str] = set()
@@ -412,6 +423,7 @@ def _configured_credentials(
412
423
  (api_token, frozenset({"ingest"})),
413
424
  (read_token, frozenset({"read"})),
414
425
  (export_token, frozenset({"read", "export"})),
426
+ (layout_token, frozenset({"layout"})),
415
427
  ):
416
428
  if credential is None:
417
429
  continue
@@ -436,8 +448,11 @@ def create_app(
436
448
  *,
437
449
  read_token: str | None = None,
438
450
  export_token: str | None = None,
451
+ layout_token: str | None = None,
439
452
  ) -> SafeExceptionBoundary:
440
- credentials = _configured_credentials(api_token, read_token, export_token)
453
+ credentials = _configured_credentials(
454
+ api_token, read_token, export_token, layout_token
455
+ )
441
456
  initialize_database(engine)
442
457
  if engine.dialect.name == "postgresql":
443
458
  probe_engine = create_postgresql_readiness_engine(
@@ -553,8 +568,12 @@ def create_app(
553
568
  "idempotent_snapshot_uploads": True,
554
569
  "dashboard_query_versions": [1],
555
570
  "dashboard_dataset_versions": [1],
571
+ "dashboard_layout_versions": [DASHBOARD_LAYOUT_VERSION],
572
+ "dashboard_layout_mutation_scope": "layout",
573
+ "max_dashboard_layout_bytes": MAX_DASHBOARD_LAYOUT_BYTES,
556
574
  "cursor_versions": [1],
557
575
  "max_reporting_request_bytes": REPORTING_REQUEST_BYTES,
576
+ "max_export_request_bytes": EXPORT_REQUEST_BYTES,
558
577
  "max_reporting_filter_values": MAX_FILTER_VALUES,
559
578
  "max_reporting_records": MAX_REPORTING_RECORDS,
560
579
  "max_reporting_scalar_bytes": MAX_REPORTING_SCALAR_BYTES,
@@ -599,6 +618,7 @@ def create_app(
599
618
  engine,
600
619
  authorize_read=require_scopes("read"),
601
620
  authorize_export=require_scopes("read", "export"),
621
+ authorize_layout=require_scopes("layout"),
602
622
  )
603
623
 
604
624
  return SafeExceptionBoundary(app)