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.
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/PKG-INFO +17 -3
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/README.md +16 -2
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/architecture.md +2 -2
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/provider-support.md +18 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/roadmap.md +1 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/pyproject.toml +1 -1
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/__init__.py +2 -0
- cli_consumption-0.0.7/src/cli_consumption/adapters/gemini.py +397 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/cli.py +3 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_cli.py +29 -0
- cli_consumption-0.0.7/tests/test_gemini_adapter.py +275 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/uv.lock +1 -1
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yeet-github/SKILL.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yolo/SKILL.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yolo/agents/openai.yaml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.github/workflows/ci.yml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.github/workflows/release.yaml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.gitignore +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.pre-commit-config.yaml +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/.python-version +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/AGENTS.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/CONTRIBUTING.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/LICENSE +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/NOTICE +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/docs/privacy.md +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/__init__.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/__main__.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/base.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/claude.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/codex.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/kilo.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/opencode.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/adapters/pi.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/api.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/dashboard.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/exporting.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/models.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/py.typed +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/storage.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/src/cli_consumption/sync.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/conftest.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_api.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_claude_adapter.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_codex_adapter.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_kilo_adapter.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_opencode_adapter.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_pi_adapter.py +0 -0
- {cli_consumption-0.0.6 → cli_consumption-0.0.7}/tests/test_storage_and_exports.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -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()
|
|
File without changes
|
{cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/add-cli-adapter/agents/openai.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/evolve-storage-schema/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cli_consumption-0.0.6 → cli_consumption-0.0.7}/.agents/skills/yeet-github/agents/openai.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|