cli-consumption 0.0.6__tar.gz → 0.0.7__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 (56) hide show
  1. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/PKG-INFO +17 -3
  2. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/README.md +16 -2
  3. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/architecture.md +2 -2
  4. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/provider-support.md +18 -0
  5. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/roadmap.md +1 -0
  6. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/pyproject.toml +1 -1
  7. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/__init__.py +2 -0
  8. cli_consumption-0.0.7/src/cli_consumption/adapters/gemini.py +397 -0
  9. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/cli.py +3 -0
  10. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_cli.py +29 -0
  11. cli_consumption-0.0.7/tests/test_gemini_adapter.py +275 -0
  12. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/uv.lock +1 -1
  13. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
  14. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
  15. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
  16. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
  17. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
  18. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
  19. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yeet-github/SKILL.md +0 -0
  20. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
  21. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yolo/SKILL.md +0 -0
  22. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yolo/agents/openai.yaml +0 -0
  23. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.github/workflows/ci.yml +0 -0
  24. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.github/workflows/release.yaml +0 -0
  25. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.gitignore +0 -0
  26. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.pre-commit-config.yaml +0 -0
  27. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.python-version +0 -0
  28. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/AGENTS.md +0 -0
  29. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/CONTRIBUTING.md +0 -0
  30. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/LICENSE +0 -0
  31. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/NOTICE +0 -0
  32. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/privacy.md +0 -0
  33. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/__init__.py +0 -0
  34. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/__main__.py +0 -0
  35. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/base.py +0 -0
  36. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/claude.py +0 -0
  37. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/codex.py +0 -0
  38. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/kilo.py +0 -0
  39. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/opencode.py +0 -0
  40. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/pi.py +0 -0
  41. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/api.py +0 -0
  42. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/dashboard.py +0 -0
  43. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/exporting.py +0 -0
  44. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/models.py +0 -0
  45. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/py.typed +0 -0
  46. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/storage.py +0 -0
  47. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/sync.py +0 -0
  48. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/conftest.py +0 -0
  49. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_api.py +0 -0
  50. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_claude_adapter.py +0 -0
  51. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_codex_adapter.py +0 -0
  52. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_kilo_adapter.py +0 -0
  53. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_opencode_adapter.py +0 -0
  54. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_pi_adapter.py +0 -0
  55. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_storage_and_exports.py +0 -0
  56. {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.0.6
3
+ Version: 0.0.7
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
@@ -33,8 +33,8 @@ models, tokens, tools, conversations, and turns. It can analyze one workstation,
33
33
  consolidate copied data from several machines, or send metadata-only snapshots to a
34
34
  central collector.
35
35
 
36
- Codex, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript format are
37
- supported.
36
+ Codex, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript
37
+ format are supported.
38
38
 
39
39
  The collector deliberately excludes prompts, responses, tool arguments, and
40
40
  credentials. See [Privacy](docs/privacy.md) before sharing a database or export.
@@ -120,6 +120,12 @@ Select Pi to read `~/.pi/agent/sessions/` instead:
120
120
  uv run cli-consumption collect --provider pi --database usage.sqlite
121
121
  ```
122
122
 
123
+ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
124
+
125
+ ```bash
126
+ uv run cli-consumption collect --provider gemini --database usage.sqlite
127
+ ```
128
+
123
129
  Use `all` to detect and collect every supported provider present on the machine:
124
130
 
125
131
  ```bash
@@ -184,6 +190,14 @@ uv run cli-consumption collect --provider pi \
184
190
  --source laptop=/data/pi/laptop
185
191
  ```
186
192
 
193
+ Copied Gemini CLI sources point to the configuration directory containing `tmp/`:
194
+
195
+ ```bash
196
+ uv run cli-consumption collect --provider gemini \
197
+ --source desktop=/data/gemini/desktop \
198
+ --source laptop=/data/gemini/laptop
199
+ ```
200
+
187
201
  ## SQLite and PostgreSQL
188
202
 
189
203
  A file path selects SQLite. A SQLAlchemy URL selects PostgreSQL:
@@ -5,8 +5,8 @@ models, tokens, tools, conversations, and turns. It can analyze one workstation,
5
5
  consolidate copied data from several machines, or send metadata-only snapshots to a
6
6
  central collector.
7
7
 
8
- Codex, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript format are
9
- supported.
8
+ Codex, Gemini CLI, Kilo Code, Pi, OpenCode, and the core Claude Code local transcript
9
+ format are supported.
10
10
 
11
11
  The collector deliberately excludes prompts, responses, tool arguments, and
12
12
  credentials. See [Privacy](docs/privacy.md) before sharing a database or export.
@@ -92,6 +92,12 @@ Select Pi to read `~/.pi/agent/sessions/` instead:
92
92
  uv run cli-consumption collect --provider pi --database usage.sqlite
93
93
  ```
94
94
 
95
+ Select Gemini CLI to read `~/.gemini/tmp/*/chats/` instead:
96
+
97
+ ```bash
98
+ uv run cli-consumption collect --provider gemini --database usage.sqlite
99
+ ```
100
+
95
101
  Use `all` to detect and collect every supported provider present on the machine:
96
102
 
97
103
  ```bash
@@ -156,6 +162,14 @@ uv run cli-consumption collect --provider pi \
156
162
  --source laptop=/data/pi/laptop
157
163
  ```
158
164
 
165
+ Copied Gemini CLI sources point to the configuration directory containing `tmp/`:
166
+
167
+ ```bash
168
+ uv run cli-consumption collect --provider gemini \
169
+ --source desktop=/data/gemini/desktop \
170
+ --source laptop=/data/gemini/laptop
171
+ ```
172
+
159
173
  ## SQLite and PostgreSQL
160
174
 
161
175
  A file path selects SQLite. A SQLAlchemy URL selects PostgreSQL:
@@ -17,8 +17,8 @@ provider files -> adapter -> metadata-only snapshot -> SQL storage -> dashboard/
17
17
  - `adapters`: parse a CLI's local data into conversations, turns, model calls, tool
18
18
  calls, context-pressure samples, bounded turn settings, compactions, and content-free
19
19
  work-item intervals. Codex exposes the complete analytics contract; Claude Code,
20
- Kilo Code, OpenCode, and Pi expose the core dimensions available in their local
21
- stores.
20
+ Gemini CLI, Kilo Code, OpenCode, and Pi expose the core dimensions available in
21
+ their local stores.
22
22
  - `models`: define the transport boundary shared by offline and API ingestion.
23
23
  - `storage`: owns the normalized schema, idempotent replacement rules, SQLite, and
24
24
  PostgreSQL engine creation.
@@ -4,6 +4,7 @@
4
4
  | --- | --- | --- |
5
5
  | Codex | Supported | Local rollout JSONL and optional metadata-only subagent state |
6
6
  | Claude Code | Supported (core) | Local project transcript JSONL |
7
+ | Gemini CLI | Supported (core) | Local automatic chat history JSON and JSONL |
7
8
  | Kilo Code | Supported (core) | Local SQLite session store |
8
9
  | OpenCode | Supported (core) | Local SQLite v2 session store |
9
10
  | Pi | Supported (core) | Local session JSONL v1-v3 |
@@ -36,6 +37,23 @@ is not billing data. This first increment does not collect subagent transcripts,
36
37
  context-window sizes, effort/service-tier settings, TTFT, provider-reported duration,
37
38
  or technical work-item intervals.
38
39
 
40
+ Gemini CLI reads automatic session history from
41
+ `~/.gemini/tmp/<project-hash>/chats/session-*.jsonl` and the legacy `.json` form. It
42
+ replays append-only message updates, metadata checkpoints, and rewinds before extracting
43
+ user turns, Gemini model calls, token usage, and tool names. Prompts, responses, thoughts,
44
+ summaries, memory scratchpads, tool arguments/results/status details, directories, and
45
+ project hashes are discarded.
46
+
47
+ Gemini reports prompt, cached prompt, visible candidate, thought, tool-prompt, and total
48
+ token counters. Normalized input is the prompt count, cached input is its bounded subset,
49
+ and normalized output combines candidate and thought tokens. Provider totals above the
50
+ attributed input/output sum remain unattributed; tool-prompt tokens are treated as a
51
+ prompt subset rather than added twice. This adapter does not derive a project name from
52
+ Gemini's one-way project hash, collect nested subagent sessions, compaction timestamps,
53
+ context-window sizes, configuration labels, tool outcomes, or provider-reported
54
+ durations. Gemini CLI's internal history schema can change without notice, and local
55
+ token events are not billing data.
56
+
39
57
  OpenCode reads `opencode.db` from its XDG data directory (normally
40
58
  `~/.local/share/opencode/`). It extracts v2 session messages, model references, token
41
59
  usage, tool names, and compaction timestamps while discarding message text, reasoning,
@@ -6,6 +6,7 @@
6
6
  - Core Claude Code local transcript collector
7
7
  - Core Pi local session collector
8
8
  - Core Kilo Code local SQLite collector
9
+ - Core Gemini CLI local chat-history collector
9
10
  - Offline multi-machine deduplication
10
11
  - SQLite and PostgreSQL storage
11
12
  - Metadata-only central collection API
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.0.6"
7
+ version = "0.0.7"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -1,5 +1,6 @@
1
1
  from cli_consumption.adapters.claude import ClaudeAdapter
2
2
  from cli_consumption.adapters.codex import CodexAdapter
3
+ from cli_consumption.adapters.gemini import GeminiAdapter
3
4
  from cli_consumption.adapters.kilo import KiloAdapter
4
5
  from cli_consumption.adapters.opencode import OpenCodeAdapter
5
6
  from cli_consumption.adapters.pi import PiAdapter
@@ -7,6 +8,7 @@ from cli_consumption.adapters.pi import PiAdapter
7
8
  __all__ = [
8
9
  "ClaudeAdapter",
9
10
  "CodexAdapter",
11
+ "GeminiAdapter",
10
12
  "KiloAdapter",
11
13
  "OpenCodeAdapter",
12
14
  "PiAdapter",
@@ -0,0 +1,397 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cli_consumption.models import Snapshot, empty_tokens
13
+
14
+ MAX_BIGINT = 9_223_372_036_854_775_807
15
+ SAFE_LABEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+-]*")
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class _Candidate:
20
+ machine: str
21
+ path: Path
22
+ external_id: str
23
+ event_count: int
24
+ digest: str
25
+
26
+
27
+ class GeminiAdapter:
28
+ """Read Gemini CLI chat metadata while discarding conversation content."""
29
+
30
+ name = "gemini"
31
+
32
+ def collect(
33
+ self,
34
+ sources: list[tuple[str, Path]],
35
+ project_mappings: list[tuple[str, str]] | None = None,
36
+ ) -> Snapshot:
37
+ del project_mappings # Gemini stores only a one-way project hash.
38
+ selected: dict[str, _Candidate] = {}
39
+ duplicates = malformed = 0
40
+ for machine, home in sources:
41
+ temporary = home / "tmp"
42
+ if not temporary.is_dir():
43
+ raise ValueError(f"Missing Gemini CLI temporary directory: {temporary}")
44
+ paths = sorted(temporary.glob("*/chats/session-*.json"))
45
+ paths.extend(sorted(temporary.glob("*/chats/session-*.jsonl")))
46
+ for path in paths:
47
+ metadata, _, invalid, event_count, digest = _read_session(path)
48
+ malformed += invalid
49
+ external_id = _label(metadata.get("sessionId"), 512)
50
+ if external_id is None:
51
+ malformed += 1
52
+ continue
53
+ candidate = _Candidate(
54
+ machine=machine,
55
+ path=path,
56
+ external_id=external_id,
57
+ event_count=event_count,
58
+ digest=digest,
59
+ )
60
+ previous = selected.get(external_id)
61
+ if previous is None:
62
+ selected[external_id] = candidate
63
+ continue
64
+ duplicates += 1
65
+ if _rank(candidate) > _rank(previous):
66
+ selected[external_id] = candidate
67
+
68
+ snapshot = Snapshot(
69
+ provider=self.name,
70
+ duplicate_conversations=duplicates,
71
+ malformed_records=malformed,
72
+ )
73
+ for candidate in sorted(selected.values(), key=lambda item: item.external_id):
74
+ metadata, messages, _, _, _ = _read_session(candidate.path)
75
+ self._normalize(snapshot, candidate, metadata, messages)
76
+ return snapshot
77
+
78
+ def _normalize(
79
+ self,
80
+ snapshot: Snapshot,
81
+ source: _Candidate,
82
+ metadata: dict[str, Any],
83
+ messages: list[dict[str, Any]],
84
+ ) -> None:
85
+ conversation_id = f"gemini:{source.external_id}"
86
+ turns: dict[str, dict[str, Any]] = {}
87
+ turn_models: dict[str, set[str]] = {}
88
+ active: str | None = None
89
+ calls: list[tuple[str | None, datetime | None, str, dict[str, int]]] = []
90
+ tools: list[tuple[str | None, datetime | None, str]] = []
91
+ timestamps = [
92
+ timestamp
93
+ for value in (metadata.get("startTime"), metadata.get("lastUpdated"))
94
+ if (timestamp := _timestamp(value)) is not None
95
+ ]
96
+
97
+ for message in messages:
98
+ timestamp = _timestamp(message.get("timestamp"))
99
+ if timestamp is not None:
100
+ timestamps.append(timestamp)
101
+ message_type = message.get("type")
102
+ if message_type == "user":
103
+ if active is not None:
104
+ _finish_turn(turns[active], timestamp)
105
+ external_id = _label(message.get("id"), 512) or (
106
+ f"turn-{len(turns) + 1}"
107
+ )
108
+ if external_id in turns:
109
+ external_id = f"turn-{len(turns) + 1}"
110
+ active = external_id
111
+ turns[active] = {
112
+ "id": f"{conversation_id}:{active}",
113
+ "conversation_id": conversation_id,
114
+ "external_id": active,
115
+ "started_at": _iso(timestamp),
116
+ "ended_at": None,
117
+ "status": "in-progress",
118
+ "duration_ms": None,
119
+ "time_to_first_token_ms": None,
120
+ "model_calls": 0,
121
+ "tool_calls": 0,
122
+ **empty_tokens(),
123
+ }
124
+ turn_models[active] = set()
125
+ continue
126
+
127
+ if message_type in {"error", "warning"} and active is not None:
128
+ turns[active]["status"] = "aborted"
129
+ turns[active]["ended_at"] = _iso(timestamp)
130
+ continue
131
+ if message_type != "gemini":
132
+ continue
133
+
134
+ model = _label(message.get("model"), 255) or "unknown"
135
+ tokens = _usage(message.get("tokens"))
136
+ calls.append((active, timestamp, model, tokens))
137
+ if active is not None:
138
+ turns[active]["status"] = "completed"
139
+ turns[active]["ended_at"] = _iso(timestamp)
140
+
141
+ tool_calls = message.get("toolCalls")
142
+ if not isinstance(tool_calls, list):
143
+ continue
144
+ for tool_call in tool_calls:
145
+ if not isinstance(tool_call, dict):
146
+ continue
147
+ name = _label(tool_call.get("name"), 512)
148
+ if name:
149
+ tools.append(
150
+ (
151
+ active,
152
+ _timestamp(tool_call.get("timestamp")) or timestamp,
153
+ name,
154
+ )
155
+ )
156
+
157
+ ended_at = max(timestamps, default=None)
158
+ if active is not None:
159
+ _finish_turn(turns[active], ended_at)
160
+
161
+ totals = empty_tokens()
162
+ models: set[str] = set()
163
+ for sequence, (turn_key, timestamp, model, tokens) in enumerate(calls, 1):
164
+ models.add(model)
165
+ _add_tokens(totals, tokens)
166
+ turn = turns.get(turn_key or "")
167
+ if turn:
168
+ turn["model_calls"] += 1
169
+ turn_models[turn_key or ""].add(model)
170
+ _add_tokens(turn, tokens)
171
+ snapshot.model_calls.append(
172
+ {
173
+ "id": f"{conversation_id}:model:{sequence}",
174
+ "conversation_id": conversation_id,
175
+ "turn_id": turn["id"] if turn else None,
176
+ "sequence": sequence,
177
+ "timestamp": _iso(timestamp),
178
+ "model": model,
179
+ **tokens,
180
+ }
181
+ )
182
+
183
+ for sequence, (turn_key, timestamp, name) in enumerate(tools, 1):
184
+ turn = turns.get(turn_key or "")
185
+ if turn:
186
+ turn["tool_calls"] += 1
187
+ snapshot.tool_calls.append(
188
+ {
189
+ "id": f"{conversation_id}:tool:{sequence}",
190
+ "conversation_id": conversation_id,
191
+ "turn_id": turn["id"] if turn else None,
192
+ "sequence": sequence,
193
+ "timestamp": _iso(timestamp),
194
+ "tool_name": name,
195
+ "outer_tool_name": name,
196
+ }
197
+ )
198
+
199
+ for key, turn in turns.items():
200
+ snapshot.turns.append(turn)
201
+ observed_models = turn_models[key]
202
+ snapshot.turn_settings.append(
203
+ {
204
+ "id": f"{conversation_id}:settings:{key}",
205
+ "conversation_id": conversation_id,
206
+ "turn_id": turn["id"],
207
+ "model": next(iter(observed_models))
208
+ if len(observed_models) == 1
209
+ else None,
210
+ "effort": None,
211
+ "collaboration_mode": None,
212
+ "service_tier": None,
213
+ "context_window_tokens": None,
214
+ }
215
+ )
216
+
217
+ started_at = min(timestamps, default=None)
218
+ snapshot.conversations.append(
219
+ {
220
+ "id": conversation_id,
221
+ "provider": self.name,
222
+ "external_id": source.external_id,
223
+ "source_machine": source.machine,
224
+ "project": "outside-project",
225
+ "project_source": "none",
226
+ "started_at": _iso(started_at),
227
+ "ended_at": _iso(ended_at),
228
+ "duration_seconds": (
229
+ (ended_at - started_at).total_seconds()
230
+ if started_at and ended_at
231
+ else None
232
+ ),
233
+ "source": "local-jsonl"
234
+ if source.path.suffix == ".jsonl"
235
+ else "local-json",
236
+ "models": sorted(models),
237
+ "iterations": len(turns),
238
+ "model_calls": len(calls),
239
+ "tool_calls": len(tools),
240
+ "compactions": 0,
241
+ "event_count": source.event_count,
242
+ "content_hash": source.digest,
243
+ **totals,
244
+ }
245
+ )
246
+
247
+
248
+ def _read_session(
249
+ path: Path,
250
+ ) -> tuple[dict[str, Any], list[dict[str, Any]], int, int, str]:
251
+ digest_builder = hashlib.sha256()
252
+ with path.open("rb") as handle:
253
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
254
+ digest_builder.update(chunk)
255
+ digest = digest_builder.hexdigest()
256
+ records: list[dict[str, Any]] = []
257
+ malformed = 0
258
+ if path.suffix == ".jsonl":
259
+ with path.open("rb") as handle:
260
+ for line in handle:
261
+ if not line.strip():
262
+ continue
263
+ try:
264
+ record = json.loads(line)
265
+ except (json.JSONDecodeError, UnicodeDecodeError):
266
+ malformed += 1
267
+ continue
268
+ if isinstance(record, dict):
269
+ records.append(record)
270
+ else:
271
+ malformed += 1
272
+ else:
273
+ try:
274
+ record = json.loads(path.read_bytes())
275
+ except (json.JSONDecodeError, UnicodeDecodeError):
276
+ return {}, [], 1, 0, digest
277
+ if not isinstance(record, dict):
278
+ return {}, [], 1, 0, digest
279
+ records.append(record)
280
+
281
+ metadata: dict[str, Any] = {}
282
+ messages: dict[str, dict[str, Any]] = {}
283
+ for record in records:
284
+ rewind_to = _label(record.get("$rewindTo"), 512)
285
+ if rewind_to is not None:
286
+ identifiers = list(messages)
287
+ try:
288
+ start = identifiers.index(rewind_to)
289
+ except ValueError:
290
+ messages.clear()
291
+ else:
292
+ for identifier in identifiers[start:]:
293
+ del messages[identifier]
294
+ continue
295
+
296
+ updates = record.get("$set")
297
+ if isinstance(updates, dict):
298
+ checkpoint = updates.get("messages")
299
+ if isinstance(checkpoint, list):
300
+ messages.clear()
301
+ for message in checkpoint:
302
+ if isinstance(message, dict) and (
303
+ identifier := _label(message.get("id"), 512)
304
+ ):
305
+ messages[identifier] = message
306
+ metadata.update(
307
+ {key: value for key, value in updates.items() if key != "messages"}
308
+ )
309
+ continue
310
+
311
+ identifier = _label(record.get("id"), 512)
312
+ if identifier is not None:
313
+ messages[identifier] = record
314
+ continue
315
+
316
+ if isinstance(record.get("sessionId"), str):
317
+ metadata.update(
318
+ {key: value for key, value in record.items() if key != "messages"}
319
+ )
320
+ legacy_messages = record.get("messages")
321
+ if isinstance(legacy_messages, list):
322
+ for message in legacy_messages:
323
+ if isinstance(message, dict) and (
324
+ identifier := _label(message.get("id"), 512)
325
+ ):
326
+ messages[identifier] = message
327
+ return metadata, list(messages.values()), malformed, len(records), digest
328
+
329
+
330
+ def _usage(value: object) -> dict[str, int]:
331
+ usage = value if isinstance(value, dict) else {}
332
+ input_tokens = _counter(usage.get("input"))
333
+ cached = min(input_tokens, _counter(usage.get("cached")))
334
+ visible = _counter(usage.get("output"))
335
+ reasoning = _counter(usage.get("thoughts"))
336
+ output_tokens = _sum(visible, reasoning)
337
+ attributed = _sum(input_tokens, output_tokens)
338
+ total = max(attributed, _counter(usage.get("total")))
339
+ return {
340
+ "input_tokens": input_tokens,
341
+ "cached_input_tokens": cached,
342
+ "cache_write_input_tokens": 0,
343
+ "output_tokens": output_tokens,
344
+ "reasoning_output_tokens": reasoning,
345
+ "total_tokens": total,
346
+ "uncached_input_tokens": input_tokens - cached,
347
+ "visible_output_tokens": visible,
348
+ "unattributed_tokens": max(0, total - attributed),
349
+ }
350
+
351
+
352
+ def _rank(value: _Candidate) -> tuple[int, str]:
353
+ return value.event_count, value.digest
354
+
355
+
356
+ def _finish_turn(turn: dict[str, Any], fallback: datetime | None) -> None:
357
+ turn["ended_at"] = turn["ended_at"] or _iso(fallback)
358
+ start, end = _timestamp(turn["started_at"]), _timestamp(turn["ended_at"])
359
+ if start and end:
360
+ turn["duration_ms"] = max(0, int((end - start).total_seconds() * 1000))
361
+
362
+
363
+ def _timestamp(value: object) -> datetime | None:
364
+ if not isinstance(value, str) or not value:
365
+ return None
366
+ try:
367
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
368
+ except ValueError:
369
+ return None
370
+
371
+
372
+ def _iso(value: object) -> str | None:
373
+ return value.isoformat() if isinstance(value, datetime) else None
374
+
375
+
376
+ def _label(value: object, maximum: int) -> str | None:
377
+ if not isinstance(value, str):
378
+ return None
379
+ value = value.strip()
380
+ return value if 0 < len(value) <= maximum and SAFE_LABEL.fullmatch(value) else None
381
+
382
+
383
+ def _counter(value: object) -> int:
384
+ if isinstance(value, bool) or not isinstance(value, int | float):
385
+ return 0
386
+ if isinstance(value, float) and not math.isfinite(value):
387
+ return 0
388
+ return min(MAX_BIGINT, max(0, int(value)))
389
+
390
+
391
+ def _sum(*values: int) -> int:
392
+ return min(MAX_BIGINT, sum(values))
393
+
394
+
395
+ def _add_tokens(target: dict[str, Any], tokens: dict[str, int]) -> None:
396
+ for field, value in tokens.items():
397
+ target[field] = _sum(int(target[field]), value)
@@ -11,6 +11,7 @@ from cli_consumption import __version__
11
11
  from cli_consumption.adapters import (
12
12
  ClaudeAdapter,
13
13
  CodexAdapter,
14
+ GeminiAdapter,
14
15
  KiloAdapter,
15
16
  OpenCodeAdapter,
16
17
  PiAdapter,
@@ -54,6 +55,7 @@ def providers() -> None:
54
55
  """Show implemented and planned CLI adapters."""
55
56
  typer.echo("all auto-detect supported providers")
56
57
  typer.echo("codex supported")
58
+ typer.echo("gemini supported")
57
59
  typer.echo("claude supported")
58
60
  typer.echo("kilo supported")
59
61
  typer.echo("opencode supported")
@@ -230,6 +232,7 @@ def _collect_snapshots(
230
232
  provider = "claude" if provider == "claude-code" else provider
231
233
  adapters = {
232
234
  "codex": (CodexAdapter, ".codex", "sessions"),
235
+ "gemini": (GeminiAdapter, ".gemini", "tmp"),
233
236
  "claude": (ClaudeAdapter, ".claude", "projects"),
234
237
  "kilo": (KiloAdapter, ".local/share/kilo", "kilo.db"),
235
238
  "opencode": (
@@ -23,6 +23,7 @@ def test_provider_status_is_explicit() -> None:
23
23
  assert result.exit_code == 0
24
24
  assert "all auto-detect" in result.stdout
25
25
  assert "codex supported" in result.stdout
26
+ assert "gemini supported" in result.stdout
26
27
  assert "claude supported" in result.stdout
27
28
  assert "kilo supported" in result.stdout
28
29
  assert "opencode supported" in result.stdout
@@ -198,6 +199,34 @@ def test_collects_pi(tmp_path: Path) -> None:
198
199
  assert "1 written" in result.stdout
199
200
 
200
201
 
202
+ def test_collects_gemini_cli(tmp_path: Path) -> None:
203
+ home = tmp_path / "gemini"
204
+ path = home / "tmp" / "project" / "chats" / "session-main.jsonl"
205
+ path.parent.mkdir(parents=True)
206
+ path.write_text(
207
+ '{"sessionId":"session","projectHash":"hash",'
208
+ '"startTime":"2026-08-25T10:00:00Z"}\n'
209
+ '{"id":"prompt","type":"user",'
210
+ '"timestamp":"2026-08-25T10:00:01Z","content":"synthetic"}\n'
211
+ )
212
+ result = runner.invoke(
213
+ app,
214
+ [
215
+ "collect",
216
+ "--provider",
217
+ "gemini",
218
+ "--source",
219
+ f"desktop={home}",
220
+ "--database",
221
+ str(tmp_path / "gemini.sqlite"),
222
+ ],
223
+ )
224
+
225
+ assert result.exit_code == 0, result.output
226
+ assert "Ingestion gemini" in result.stdout
227
+ assert "1 written" in result.stdout
228
+
229
+
201
230
  def test_collects_all_detected_providers(tmp_path: Path, rollout_factory) -> None:
202
231
  codex_home = tmp_path / "codex"
203
232
  claude_home = tmp_path / "claude"
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from cli_consumption.adapters.gemini import GeminiAdapter
10
+ from cli_consumption.dashboard import generate_dashboard
11
+ from cli_consumption.exporting import export_csv
12
+ from cli_consumption.storage import (
13
+ TABLES,
14
+ create_database_engine,
15
+ ingest_snapshot,
16
+ read_table,
17
+ )
18
+
19
+ CANARY = "gemini privacy canary secret"
20
+
21
+
22
+ def session(home: Path, *, extra: bool = False) -> Path:
23
+ records: list[dict[str, Any]] = [
24
+ {
25
+ "sessionId": "session-1",
26
+ "projectHash": "private-project-hash",
27
+ "startTime": "2026-08-25T10:00:00Z",
28
+ "lastUpdated": "2026-08-25T10:00:30Z",
29
+ },
30
+ {
31
+ "id": "prompt-1",
32
+ "timestamp": "2026-08-25T10:00:01Z",
33
+ "type": "user",
34
+ "content": CANARY,
35
+ },
36
+ {
37
+ "id": "response-1",
38
+ "timestamp": "2026-08-25T10:00:02Z",
39
+ "type": "gemini",
40
+ "content": CANARY,
41
+ "model": "gemini-2.5-pro",
42
+ },
43
+ {
44
+ "id": "response-1",
45
+ "timestamp": "2026-08-25T10:00:02Z",
46
+ "type": "gemini",
47
+ "content": CANARY,
48
+ "thoughts": [{"subject": CANARY, "description": CANARY}],
49
+ "model": "gemini-2.5-pro",
50
+ "tokens": {
51
+ "input": 100,
52
+ "output": 20,
53
+ "cached": 40,
54
+ "thoughts": 5,
55
+ "tool": 10,
56
+ "total": 130,
57
+ },
58
+ "toolCalls": [
59
+ {
60
+ "id": "tool-1",
61
+ "name": "run_shell_command",
62
+ "args": {"command": CANARY},
63
+ "result": CANARY,
64
+ "description": CANARY,
65
+ "status": "success",
66
+ "timestamp": "2026-08-25T10:00:03Z",
67
+ }
68
+ ],
69
+ },
70
+ {
71
+ "$set": {
72
+ "lastUpdated": "2026-08-25T10:00:20Z",
73
+ "summary": CANARY,
74
+ "directories": [f"/private/{CANARY}"],
75
+ "memoryScratchpad": {"workflowSummary": CANARY},
76
+ }
77
+ },
78
+ {
79
+ "id": "prompt-2",
80
+ "timestamp": "2026-08-25T10:00:10Z",
81
+ "type": "user",
82
+ "content": CANARY,
83
+ },
84
+ {
85
+ "id": "response-2",
86
+ "timestamp": "2026-08-25T10:00:11Z",
87
+ "type": "gemini",
88
+ "content": CANARY,
89
+ "model": "gemini-2.5-flash",
90
+ "tokens": {
91
+ "input": 20,
92
+ "output": 4,
93
+ "cached": 30,
94
+ "thoughts": 2,
95
+ "total": 27,
96
+ },
97
+ },
98
+ {
99
+ "id": "discarded-prompt",
100
+ "timestamp": "2026-08-25T10:00:12Z",
101
+ "type": "user",
102
+ "content": CANARY,
103
+ },
104
+ {
105
+ "id": "discarded-error",
106
+ "timestamp": "2026-08-25T10:00:13Z",
107
+ "type": "error",
108
+ "content": CANARY,
109
+ },
110
+ {"$rewindTo": "discarded-prompt"},
111
+ {
112
+ "id": "prompt-3",
113
+ "timestamp": "2026-08-25T10:00:14Z",
114
+ "type": "user",
115
+ "content": CANARY,
116
+ },
117
+ {
118
+ "id": "response-3",
119
+ "timestamp": "2026-08-25T10:00:15Z",
120
+ "type": "gemini",
121
+ "content": CANARY,
122
+ "model": "gemini-2.5-flash",
123
+ "tokens": {"input": 3, "output": 1, "total": 4},
124
+ },
125
+ ]
126
+ if extra:
127
+ records.append({"$set": {"lastUpdated": "2026-08-25T10:00:30Z"}})
128
+ path = home / "tmp" / "project-hash" / "chats" / "session-main.jsonl"
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ path.write_text(
131
+ "".join(json.dumps(record) + "\n" for record in records), encoding="utf-8"
132
+ )
133
+ return path
134
+
135
+
136
+ def test_collects_current_usage_tools_turns_and_rewinds(tmp_path: Path) -> None:
137
+ home = tmp_path / "gemini"
138
+ session(home)
139
+ snapshot = GeminiAdapter().collect(
140
+ [("laptop", home)], [("must-not-map", "/private")]
141
+ )
142
+
143
+ conversation = snapshot.conversations[0]
144
+ assert conversation["id"] == "gemini:session-1"
145
+ assert conversation["project"] == "outside-project"
146
+ assert conversation["models"] == ["gemini-2.5-flash", "gemini-2.5-pro"]
147
+ assert conversation["model_calls"] == 3
148
+ assert conversation["tool_calls"] == 1
149
+ assert conversation["input_tokens"] == 123
150
+ assert conversation["uncached_input_tokens"] == 63
151
+ assert conversation["cached_input_tokens"] == 60
152
+ assert conversation["output_tokens"] == 32
153
+ assert conversation["reasoning_output_tokens"] == 7
154
+ assert conversation["visible_output_tokens"] == 25
155
+ assert conversation["total_tokens"] == 161
156
+ assert conversation["unattributed_tokens"] == 6
157
+ assert [turn["status"] for turn in snapshot.turns] == [
158
+ "completed",
159
+ "completed",
160
+ "completed",
161
+ ]
162
+ assert snapshot.tool_calls[0]["tool_name"] == "run_shell_command"
163
+ assert snapshot.tool_calls[0]["turn_id"] == "gemini:session-1:prompt-1"
164
+ assert CANARY not in str(snapshot.to_dict())
165
+ assert "private-project-hash" not in str(snapshot.to_dict())
166
+
167
+
168
+ def test_supports_legacy_json_and_current_checkpoints(tmp_path: Path) -> None:
169
+ home = tmp_path / "gemini"
170
+ path = home / "tmp" / "project" / "chats" / "session-legacy.json"
171
+ path.parent.mkdir(parents=True)
172
+ path.write_text(
173
+ json.dumps(
174
+ {
175
+ "sessionId": "legacy-session",
176
+ "startTime": "2026-08-25T10:00:00Z",
177
+ "lastUpdated": "2026-08-25T10:00:01Z",
178
+ "messages": [
179
+ {
180
+ "id": "prompt",
181
+ "timestamp": "2026-08-25T10:00:00Z",
182
+ "type": "user",
183
+ "content": CANARY,
184
+ },
185
+ {
186
+ "id": "response",
187
+ "timestamp": "2026-08-25T10:00:01Z",
188
+ "type": "gemini",
189
+ "content": CANARY,
190
+ "model": "gemini-1.5-pro",
191
+ "tokens": {"input": 2, "output": 1, "total": 3},
192
+ },
193
+ ],
194
+ }
195
+ ),
196
+ encoding="utf-8",
197
+ )
198
+ snapshot = GeminiAdapter().collect([("machine", home)])
199
+ assert snapshot.conversations[0]["source"] == "local-json"
200
+ assert snapshot.conversations[0]["total_tokens"] == 3
201
+ assert CANARY not in str(snapshot.to_dict())
202
+
203
+ path.unlink()
204
+ current = home / "tmp" / "project" / "chats" / "session-checkpoint.jsonl"
205
+ messages = [
206
+ {
207
+ "id": "prompt",
208
+ "timestamp": "2026-08-25T10:00:00Z",
209
+ "type": "user",
210
+ "content": CANARY,
211
+ }
212
+ ]
213
+ current.write_text(
214
+ json.dumps({"sessionId": "checkpoint-session", "projectHash": "hash"})
215
+ + "\n"
216
+ + json.dumps({"$set": {"messages": messages}})
217
+ + "\n",
218
+ encoding="utf-8",
219
+ )
220
+ snapshot = GeminiAdapter().collect([("machine", home)])
221
+ assert len(snapshot.turns) == 1
222
+ assert snapshot.turns[0]["external_id"] == "prompt"
223
+
224
+
225
+ def test_deduplicates_copies_and_rejects_malformed_fields(tmp_path: Path) -> None:
226
+ first, second = tmp_path / "first", tmp_path / "second"
227
+ session(first)
228
+ path = session(second, extra=True)
229
+ with path.open("a", encoding="utf-8") as handle:
230
+ handle.write("not-json\n[]\n")
231
+ handle.write(
232
+ '{"id":"bad","type":"gemini","model":"'
233
+ + CANARY
234
+ + '","tokens":{"input":Infinity,"output":-1},'
235
+ + '"toolCalls":[{"name":"'
236
+ + CANARY
237
+ + '","args":{"secret":"'
238
+ + CANARY
239
+ + '"}}]}\n'
240
+ )
241
+ snapshot = GeminiAdapter().collect([("desktop", first), ("laptop", second)])
242
+
243
+ assert snapshot.duplicate_conversations == 1
244
+ assert snapshot.malformed_records == 2
245
+ assert snapshot.conversations[0]["source_machine"] == "laptop"
246
+ assert snapshot.model_calls[-1]["model"] == "unknown"
247
+ assert snapshot.model_calls[-1]["total_tokens"] == 0
248
+ assert len(snapshot.tool_calls) == 1
249
+ assert CANARY not in str(snapshot.to_dict())
250
+ assert (
251
+ snapshot.to_dict()
252
+ == GeminiAdapter().collect([("desktop", first), ("laptop", second)]).to_dict()
253
+ )
254
+
255
+ with pytest.raises(ValueError, match="Missing Gemini CLI temporary directory"):
256
+ GeminiAdapter().collect([("machine", tmp_path / "missing")])
257
+
258
+
259
+ def test_privacy_canary_is_absent_from_storage_and_exports(tmp_path: Path) -> None:
260
+ home = tmp_path / "gemini"
261
+ session(home)
262
+ snapshot = GeminiAdapter().collect([("machine", home)])
263
+ engine = create_database_engine(tmp_path / "usage.sqlite")
264
+ try:
265
+ ingest_snapshot(engine, snapshot)
266
+ rows = {name: read_table(engine, name) for name in TABLES}
267
+ assert CANARY not in json.dumps(rows)
268
+ output = tmp_path / "reports"
269
+ paths = export_csv(engine, output)
270
+ dashboard = output / "dashboard.html"
271
+ generate_dashboard(engine, dashboard)
272
+ assert all(CANARY not in path.read_text() for path in paths)
273
+ assert CANARY not in dashboard.read_text()
274
+ finally:
275
+ engine.dispose()
@@ -56,7 +56,7 @@ wheels = [
56
56
 
57
57
  [[package]]
58
58
  name = "cli-consumption"
59
- version = "0.0.6"
59
+ version = "0.0.7"
60
60
  source = { editable = "." }
61
61
  dependencies = [
62
62
  { name = "fastapi" },
File without changes
File without changes