cli-consumption 0.0.4__tar.gz → 0.0.5__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 (52) hide show
  1. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/PKG-INFO +17 -3
  2. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/README.md +16 -2
  3. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/docs/provider-support.md +16 -1
  4. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/docs/roadmap.md +2 -1
  5. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/pyproject.toml +1 -1
  6. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/adapters/__init__.py +2 -1
  7. cli_consumption-0.0.5/src/cli_consumption/adapters/pi.py +454 -0
  8. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/cli.py +9 -2
  9. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/tests/test_cli.py +30 -0
  10. cli_consumption-0.0.5/tests/test_pi_adapter.py +287 -0
  11. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/uv.lock +1 -1
  12. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
  13. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
  14. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
  15. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
  16. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
  17. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
  18. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/yeet-github/SKILL.md +0 -0
  19. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
  20. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/yolo/SKILL.md +0 -0
  21. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.agents/skills/yolo/agents/openai.yaml +0 -0
  22. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.github/workflows/ci.yml +0 -0
  23. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.github/workflows/release.yaml +0 -0
  24. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.gitignore +0 -0
  25. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.pre-commit-config.yaml +0 -0
  26. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/.python-version +0 -0
  27. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/AGENTS.md +0 -0
  28. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/CONTRIBUTING.md +0 -0
  29. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/LICENSE +0 -0
  30. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/NOTICE +0 -0
  31. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/docs/architecture.md +0 -0
  32. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/docs/privacy.md +0 -0
  33. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/__init__.py +0 -0
  34. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/__main__.py +0 -0
  35. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/adapters/base.py +0 -0
  36. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/adapters/claude.py +0 -0
  37. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/adapters/codex.py +0 -0
  38. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/adapters/opencode.py +0 -0
  39. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/api.py +0 -0
  40. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/dashboard.py +0 -0
  41. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/exporting.py +0 -0
  42. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/models.py +0 -0
  43. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/py.typed +0 -0
  44. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/storage.py +0 -0
  45. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/src/cli_consumption/sync.py +0 -0
  46. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/tests/conftest.py +0 -0
  47. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/tests/test_api.py +0 -0
  48. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/tests/test_claude_adapter.py +0 -0
  49. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/tests/test_codex_adapter.py +0 -0
  50. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/tests/test_opencode_adapter.py +0 -0
  51. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/tests/test_storage_and_exports.py +0 -0
  52. {cli_consumption-0.0.4 → cli_consumption-0.0.5}/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.4
3
+ Version: 0.0.5
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, OpenCode, and the core Claude Code local transcript format are supported. Kilo
37
- Code and Pi are planned behind the same provider-neutral adapter contract.
36
+ Codex, Pi, OpenCode, and the core Claude Code local transcript format are supported.
37
+ Kilo Code is planned behind the same provider-neutral adapter contract.
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.
@@ -108,6 +108,12 @@ Select OpenCode to read `~/.local/share/opencode/opencode.db` instead:
108
108
  uv run cli-consumption collect --provider opencode --database usage.sqlite
109
109
  ```
110
110
 
111
+ Select Pi to read `~/.pi/agent/sessions/` instead:
112
+
113
+ ```bash
114
+ uv run cli-consumption collect --provider pi --database usage.sqlite
115
+ ```
116
+
111
117
  Use `all` to detect and collect every supported provider present on the machine:
112
118
 
113
119
  ```bash
@@ -156,6 +162,14 @@ uv run cli-consumption collect --provider opencode \
156
162
  --source laptop=/data/opencode/laptop
157
163
  ```
158
164
 
165
+ Copied Pi sources point to the agent directory containing `sessions/`:
166
+
167
+ ```bash
168
+ uv run cli-consumption collect --provider pi \
169
+ --source desktop=/data/pi/desktop \
170
+ --source laptop=/data/pi/laptop
171
+ ```
172
+
159
173
  ## SQLite and PostgreSQL
160
174
 
161
175
  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, OpenCode, and the core Claude Code local transcript format are supported. Kilo
9
- Code and Pi are planned behind the same provider-neutral adapter contract.
8
+ Codex, Pi, OpenCode, and the core Claude Code local transcript format are supported.
9
+ Kilo Code is planned behind the same provider-neutral adapter contract.
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.
@@ -80,6 +80,12 @@ Select OpenCode to read `~/.local/share/opencode/opencode.db` instead:
80
80
  uv run cli-consumption collect --provider opencode --database usage.sqlite
81
81
  ```
82
82
 
83
+ Select Pi to read `~/.pi/agent/sessions/` instead:
84
+
85
+ ```bash
86
+ uv run cli-consumption collect --provider pi --database usage.sqlite
87
+ ```
88
+
83
89
  Use `all` to detect and collect every supported provider present on the machine:
84
90
 
85
91
  ```bash
@@ -128,6 +134,14 @@ uv run cli-consumption collect --provider opencode \
128
134
  --source laptop=/data/opencode/laptop
129
135
  ```
130
136
 
137
+ Copied Pi sources point to the agent directory containing `sessions/`:
138
+
139
+ ```bash
140
+ uv run cli-consumption collect --provider pi \
141
+ --source desktop=/data/pi/desktop \
142
+ --source laptop=/data/pi/laptop
143
+ ```
144
+
131
145
  ## SQLite and PostgreSQL
132
146
 
133
147
  A file path selects SQLite. A SQLAlchemy URL selects PostgreSQL:
@@ -5,8 +5,8 @@
5
5
  | Codex | Supported | Local rollout JSONL and optional metadata-only subagent state |
6
6
  | Claude Code | Supported (core) | Local project transcript JSONL |
7
7
  | OpenCode | Supported (core) | Local SQLite v2 session store |
8
+ | Pi | Supported (core) | Local session JSONL v1-v3 |
8
9
  | Kilo Code | Planned | To be verified before implementation |
9
- | Pi | Planned | To be verified before implementation |
10
10
 
11
11
  “Supported” means the adapter has synthetic fixtures, extracts conversations, turns,
12
12
  models, token usage, and tool names when available, and passes privacy regression tests.
@@ -49,6 +49,21 @@ components. The adapter does not currently read pre-v2 JSON storage, legacy
49
49
  provider-reported cost. The SQLite schema is internal and may change without notice;
50
50
  local token events are not billing data.
51
51
 
52
+ Pi reads session JSONL files under `~/.pi/agent/sessions/` (or copied agent
53
+ directories). It extracts user turns, assistant and compaction model calls, provider
54
+ and model identifiers, token usage, tool names, thinking-level changes, and compaction
55
+ timestamps while discarding prompts, responses, thinking, tool arguments/results,
56
+ commands/output, summaries, errors, costs, paths, extension data, and arbitrary
57
+ metadata. All persisted branches are counted because Pi retains branch history in the
58
+ same session file.
59
+
60
+ Pi reports uncached input, cache reads, cache writes, output, and optional reasoning
61
+ tokens. Normalized input includes all three input components; reasoning is retained as
62
+ a subset of output rather than added twice. This adapter does not collect custom
63
+ session directories automatically, context-window sizes, costs, branch relationships,
64
+ or provider-reported durations. Pi's JSONL schema can change without notice, and its
65
+ local token events are not billing data.
66
+
52
67
  Provider formats can change without notice. Unknown fields are ignored; malformed
53
68
  provider records are counted and skipped. Compatibility fixes should add a fixture for
54
69
  both the old and new format whenever possible.
@@ -4,6 +4,7 @@
4
4
 
5
5
  - Complete Codex local collector
6
6
  - Core Claude Code local transcript collector
7
+ - Core Pi local session collector
7
8
  - Offline multi-machine deduplication
8
9
  - SQLite and PostgreSQL storage
9
10
  - Metadata-only central collection API
@@ -14,7 +15,7 @@
14
15
 
15
16
  1. Extend Claude Code support only where local metadata has reliable semantics.
16
17
  2. Add OpenCode with the same normalized contract.
17
- 3. Add Kilo Code and Pi after format and licensing review.
18
+ 3. Add Kilo Code after format and licensing review.
18
19
  4. Add cross-provider comparison views once at least two adapters expose reliable model
19
20
  and token dimensions.
20
21
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.0.4"
7
+ version = "0.0.5"
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
3
  from cli_consumption.adapters.opencode import OpenCodeAdapter
4
+ from cli_consumption.adapters.pi import PiAdapter
4
5
 
5
- __all__ = ["ClaudeAdapter", "CodexAdapter", "OpenCodeAdapter"]
6
+ __all__ = ["ClaudeAdapter", "CodexAdapter", "OpenCodeAdapter", "PiAdapter"]
@@ -0,0 +1,454 @@
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
+ THINKING_LEVELS = {"off", "minimal", "low", "medium", "high", "xhigh", "max"}
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class _Session:
21
+ machine: str
22
+ external_id: str
23
+ cwd: str | None
24
+ entries: list[dict[str, Any]]
25
+ event_count: int
26
+ digest: str
27
+
28
+
29
+ class PiAdapter:
30
+ """Read Pi session metadata while discarding conversation content."""
31
+
32
+ name = "pi"
33
+
34
+ def collect(
35
+ self,
36
+ sources: list[tuple[str, Path]],
37
+ project_mappings: list[tuple[str, str]] | None = None,
38
+ ) -> Snapshot:
39
+ selected: dict[str, _Session] = {}
40
+ duplicates = malformed = 0
41
+ for machine, home in sources:
42
+ sessions = home / "sessions"
43
+ if not sessions.is_dir():
44
+ raise ValueError(f"Missing Pi sessions directory: {sessions}")
45
+ for path in sorted(sessions.rglob("*.jsonl")):
46
+ candidate, invalid = _read_session(path, machine)
47
+ malformed += invalid
48
+ if candidate is None:
49
+ continue
50
+ previous = selected.get(candidate.external_id)
51
+ if previous is None:
52
+ selected[candidate.external_id] = candidate
53
+ continue
54
+ duplicates += 1
55
+ if _rank(candidate) > _rank(previous):
56
+ selected[candidate.external_id] = candidate
57
+
58
+ snapshot = Snapshot(
59
+ provider=self.name,
60
+ duplicate_conversations=duplicates,
61
+ malformed_records=malformed,
62
+ )
63
+ for session in sorted(selected.values(), key=lambda value: value.external_id):
64
+ self._normalize(snapshot, session, project_mappings or [])
65
+ return snapshot
66
+
67
+ def _normalize(
68
+ self,
69
+ snapshot: Snapshot,
70
+ source: _Session,
71
+ mappings: list[tuple[str, str]],
72
+ ) -> None:
73
+ conversation_id = f"pi:{source.external_id}"
74
+ entry_by_id = {
75
+ identifier: entry
76
+ for entry in source.entries
77
+ if (identifier := _label(entry.get("id"), 512)) is not None
78
+ }
79
+ turns: dict[str, dict[str, Any]] = {}
80
+ turn_models: dict[str, set[str]] = {}
81
+ turn_efforts: dict[str, str | None] = {}
82
+ active: str | None = None
83
+ calls: list[tuple[str | None, datetime | None, str, dict[str, int]]] = []
84
+ tools: list[tuple[str | None, datetime | None, str]] = []
85
+ compactions: list[tuple[str | None, datetime | None]] = []
86
+ timestamps: list[datetime | None] = []
87
+
88
+ for entry in source.entries:
89
+ timestamp = _timestamp(entry.get("timestamp"))
90
+ timestamps.append(timestamp)
91
+ message = _mapping(entry.get("message"))
92
+ role = message.get("role") if entry.get("type") == "message" else None
93
+
94
+ if role == "user":
95
+ if active is not None:
96
+ _finish_turn(turns[active], timestamp)
97
+ external_id = _label(entry.get("id"), 512) or f"turn-{len(turns) + 1}"
98
+ if external_id in turns:
99
+ external_id = f"turn-{len(turns) + 1}"
100
+ active = external_id
101
+ turns[external_id] = {
102
+ "id": f"{conversation_id}:{external_id}",
103
+ "conversation_id": conversation_id,
104
+ "external_id": external_id,
105
+ "started_at": _iso(timestamp),
106
+ "ended_at": None,
107
+ "status": "in-progress",
108
+ "duration_ms": None,
109
+ "time_to_first_token_ms": None,
110
+ "model_calls": 0,
111
+ "tool_calls": 0,
112
+ **empty_tokens(),
113
+ }
114
+ turn_models[external_id] = set()
115
+ turn_efforts[external_id] = _ancestral_effort(entry, entry_by_id)
116
+ continue
117
+
118
+ turn_key = _ancestral_turn(entry, entry_by_id) or active
119
+ if entry.get("type") == "compaction":
120
+ compactions.append((turn_key, timestamp))
121
+
122
+ if role == "assistant":
123
+ model = (
124
+ _model(message) or _ancestral_model(entry, entry_by_id) or "unknown"
125
+ )
126
+ tokens = _usage(message.get("usage"))
127
+ calls.append((turn_key, timestamp, model, tokens))
128
+ turn = turns.get(turn_key or "")
129
+ if turn:
130
+ turn["ended_at"] = _iso(timestamp) or turn["ended_at"]
131
+ stop_reason = message.get("stopReason")
132
+ if stop_reason in {"error", "aborted"}:
133
+ turn["status"] = "aborted"
134
+ elif stop_reason in {"stop", "length"}:
135
+ turn["status"] = "completed"
136
+
137
+ content = message.get("content")
138
+ if isinstance(content, list):
139
+ for block in content:
140
+ if (
141
+ not isinstance(block, dict)
142
+ or block.get("type") != "toolCall"
143
+ ):
144
+ continue
145
+ name = _label(block.get("name"), 512)
146
+ if name:
147
+ tools.append((turn_key, timestamp, name))
148
+
149
+ if entry.get("type") in {"compaction", "branch_summary"} and isinstance(
150
+ entry.get("usage"), dict
151
+ ):
152
+ calls.append(
153
+ (
154
+ turn_key,
155
+ timestamp,
156
+ _ancestral_model(entry, entry_by_id) or "unknown",
157
+ _usage(entry.get("usage")),
158
+ )
159
+ )
160
+
161
+ ended_at = max(
162
+ (value for value in timestamps if value is not None), default=None
163
+ )
164
+ for turn in turns.values():
165
+ _finish_turn(turn, ended_at)
166
+
167
+ totals = empty_tokens()
168
+ models: set[str] = set()
169
+ for sequence, (turn_key, timestamp, model, tokens) in enumerate(calls, 1):
170
+ models.add(model)
171
+ _add_tokens(totals, tokens)
172
+ turn = turns.get(turn_key or "")
173
+ if turn:
174
+ turn["model_calls"] += 1
175
+ turn_models[turn_key or ""].add(model)
176
+ _add_tokens(turn, tokens)
177
+ snapshot.model_calls.append(
178
+ {
179
+ "id": f"{conversation_id}:model:{sequence}",
180
+ "conversation_id": conversation_id,
181
+ "turn_id": turn["id"] if turn else None,
182
+ "sequence": sequence,
183
+ "timestamp": _iso(timestamp),
184
+ "model": model,
185
+ **tokens,
186
+ }
187
+ )
188
+
189
+ for sequence, (turn_key, timestamp, name) in enumerate(tools, 1):
190
+ turn = turns.get(turn_key or "")
191
+ if turn:
192
+ turn["tool_calls"] += 1
193
+ snapshot.tool_calls.append(
194
+ {
195
+ "id": f"{conversation_id}:tool:{sequence}",
196
+ "conversation_id": conversation_id,
197
+ "turn_id": turn["id"] if turn else None,
198
+ "sequence": sequence,
199
+ "timestamp": _iso(timestamp),
200
+ "tool_name": name,
201
+ "outer_tool_name": name,
202
+ }
203
+ )
204
+
205
+ for sequence, (turn_key, timestamp) in enumerate(compactions, 1):
206
+ turn = turns.get(turn_key or "")
207
+ snapshot.compaction_events.append(
208
+ {
209
+ "id": f"{conversation_id}:compaction:{sequence}",
210
+ "conversation_id": conversation_id,
211
+ "turn_id": turn["id"] if turn else None,
212
+ "sequence": sequence,
213
+ "timestamp": _iso(timestamp),
214
+ }
215
+ )
216
+
217
+ for key, turn in turns.items():
218
+ snapshot.turns.append(turn)
219
+ observed = turn_models[key]
220
+ snapshot.turn_settings.append(
221
+ {
222
+ "id": f"{conversation_id}:settings:{key}",
223
+ "conversation_id": conversation_id,
224
+ "turn_id": turn["id"],
225
+ "model": next(iter(observed)) if len(observed) == 1 else None,
226
+ "effort": turn_efforts[key],
227
+ "collaboration_mode": None,
228
+ "service_tier": None,
229
+ "context_window_tokens": None,
230
+ }
231
+ )
232
+
233
+ started_at = min(
234
+ (value for value in timestamps if value is not None), default=None
235
+ )
236
+ project, project_source = _project(source.cwd, mappings)
237
+ snapshot.conversations.append(
238
+ {
239
+ "id": conversation_id,
240
+ "provider": self.name,
241
+ "external_id": source.external_id,
242
+ "source_machine": source.machine,
243
+ "project": project,
244
+ "project_source": project_source,
245
+ "started_at": _iso(started_at),
246
+ "ended_at": _iso(ended_at),
247
+ "duration_seconds": (
248
+ (ended_at - started_at).total_seconds()
249
+ if started_at and ended_at
250
+ else None
251
+ ),
252
+ "source": "local-jsonl",
253
+ "models": sorted(models),
254
+ "iterations": len(turns),
255
+ "model_calls": len(calls),
256
+ "tool_calls": len(tools),
257
+ "compactions": len(compactions),
258
+ "event_count": source.event_count,
259
+ "content_hash": source.digest,
260
+ **totals,
261
+ }
262
+ )
263
+
264
+
265
+ def _read_session(path: Path, machine: str) -> tuple[_Session | None, int]:
266
+ digest = hashlib.sha256()
267
+ entries: list[dict[str, Any]] = []
268
+ malformed = 0
269
+ with path.open("rb") as handle:
270
+ for line in handle:
271
+ digest.update(line)
272
+ try:
273
+ entry = json.loads(line)
274
+ except (json.JSONDecodeError, UnicodeDecodeError):
275
+ malformed += 1
276
+ continue
277
+ if not isinstance(entry, dict):
278
+ malformed += 1
279
+ continue
280
+ entries.append(entry)
281
+
282
+ header = next((entry for entry in entries if entry.get("type") == "session"), None)
283
+ if header is None or (external_id := _label(header.get("id"), 512)) is None:
284
+ return None, malformed + 1
285
+ return (
286
+ _Session(
287
+ machine=machine,
288
+ external_id=external_id,
289
+ cwd=header.get("cwd") if isinstance(header.get("cwd"), str) else None,
290
+ entries=entries,
291
+ event_count=len(entries),
292
+ digest=digest.hexdigest(),
293
+ ),
294
+ malformed,
295
+ )
296
+
297
+
298
+ def _rank(value: _Session) -> tuple[int, str]:
299
+ return value.event_count, value.digest
300
+
301
+
302
+ def _ancestral_turn(
303
+ entry: dict[str, Any], entry_by_id: dict[str, dict[str, Any]]
304
+ ) -> str | None:
305
+ for ancestor in _ancestors(entry, entry_by_id):
306
+ message = _mapping(ancestor.get("message"))
307
+ if ancestor.get("type") == "message" and message.get("role") == "user":
308
+ return _label(ancestor.get("id"), 512)
309
+ return None
310
+
311
+
312
+ def _ancestral_model(
313
+ entry: dict[str, Any], entry_by_id: dict[str, dict[str, Any]]
314
+ ) -> str | None:
315
+ for ancestor in _ancestors(entry, entry_by_id):
316
+ if ancestor.get("type") == "model_change":
317
+ return _model_change(ancestor)
318
+ message = _mapping(ancestor.get("message"))
319
+ if (
320
+ ancestor.get("type") == "message"
321
+ and message.get("role") == "assistant"
322
+ and (model := _model(message))
323
+ ):
324
+ return model
325
+ return None
326
+
327
+
328
+ def _ancestral_effort(
329
+ entry: dict[str, Any], entry_by_id: dict[str, dict[str, Any]]
330
+ ) -> str | None:
331
+ for ancestor in _ancestors(entry, entry_by_id):
332
+ if ancestor.get("type") == "thinking_level_change":
333
+ value = ancestor.get("thinkingLevel")
334
+ return value if value in THINKING_LEVELS else None
335
+ return None
336
+
337
+
338
+ def _ancestors(
339
+ entry: dict[str, Any], entry_by_id: dict[str, dict[str, Any]]
340
+ ) -> list[dict[str, Any]]:
341
+ result: list[dict[str, Any]] = []
342
+ seen: set[str] = set()
343
+ parent_id = _label(entry.get("parentId"), 512)
344
+ while parent_id and parent_id not in seen:
345
+ seen.add(parent_id)
346
+ parent = entry_by_id.get(parent_id)
347
+ if parent is None:
348
+ break
349
+ result.append(parent)
350
+ parent_id = _label(parent.get("parentId"), 512)
351
+ return result
352
+
353
+
354
+ def _usage(value: object) -> dict[str, int]:
355
+ usage = _mapping(value)
356
+ uncached = _counter(usage.get("input"))
357
+ cached = _counter(usage.get("cacheRead"))
358
+ cache_write = _counter(usage.get("cacheWrite"))
359
+ output = _counter(usage.get("output"))
360
+ reasoning = min(output, _counter(usage.get("reasoning")))
361
+ input_tokens = _sum(uncached, cached, cache_write)
362
+ attributed = _sum(input_tokens, output)
363
+ total = max(attributed, _counter(usage.get("totalTokens")))
364
+ return {
365
+ "input_tokens": input_tokens,
366
+ "cached_input_tokens": cached,
367
+ "cache_write_input_tokens": cache_write,
368
+ "output_tokens": output,
369
+ "reasoning_output_tokens": reasoning,
370
+ "total_tokens": total,
371
+ "uncached_input_tokens": uncached,
372
+ "visible_output_tokens": output - reasoning,
373
+ "unattributed_tokens": max(0, total - attributed),
374
+ }
375
+
376
+
377
+ def _model(message: dict[str, Any]) -> str | None:
378
+ provider = _label(message.get("provider"), 127)
379
+ identifier = _label(message.get("model"), 127)
380
+ if provider and identifier:
381
+ return f"{provider}/{identifier}"
382
+ return identifier
383
+
384
+
385
+ def _model_change(entry: dict[str, Any]) -> str | None:
386
+ provider = _label(entry.get("provider"), 127)
387
+ identifier = _label(entry.get("modelId"), 127)
388
+ if provider and identifier:
389
+ return f"{provider}/{identifier}"
390
+ return identifier
391
+
392
+
393
+ def _project(cwd: str | None, mappings: list[tuple[str, str]]) -> tuple[str, str]:
394
+ if cwd:
395
+ normalized = cwd.replace("\\", "/").rstrip("/")
396
+ for name, prefix in sorted(
397
+ mappings, key=lambda item: len(item[1]), reverse=True
398
+ ):
399
+ prefix = prefix.replace("\\", "/").rstrip("/")
400
+ if normalized == prefix or normalized.startswith(prefix + "/"):
401
+ return name, "mapping"
402
+ return "outside-project", "none"
403
+
404
+
405
+ def _finish_turn(turn: dict[str, Any], fallback: datetime | None) -> None:
406
+ turn["ended_at"] = turn["ended_at"] or _iso(fallback)
407
+ start, end = _timestamp(turn["started_at"]), _timestamp(turn["ended_at"])
408
+ if start and end:
409
+ turn["duration_ms"] = max(0, int((end - start).total_seconds() * 1000))
410
+
411
+
412
+ def _mapping(value: object) -> dict[str, Any]:
413
+ return value if isinstance(value, dict) else {}
414
+
415
+
416
+ def _timestamp(value: object) -> datetime | None:
417
+ try:
418
+ if isinstance(value, bool):
419
+ return None
420
+ if isinstance(value, int | float) and math.isfinite(value):
421
+ return datetime.fromtimestamp(value / 1000, UTC)
422
+ if isinstance(value, str) and value:
423
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
424
+ except (OSError, OverflowError, ValueError):
425
+ pass
426
+ return None
427
+
428
+
429
+ def _iso(value: object) -> str | None:
430
+ return value.isoformat() if isinstance(value, datetime) else None
431
+
432
+
433
+ def _label(value: object, maximum: int) -> str | None:
434
+ if not isinstance(value, str):
435
+ return None
436
+ value = value.strip()
437
+ return value if 0 < len(value) <= maximum and SAFE_LABEL.fullmatch(value) else None
438
+
439
+
440
+ def _counter(value: object) -> int:
441
+ if isinstance(value, bool) or not isinstance(value, int | float):
442
+ return 0
443
+ if isinstance(value, float) and not math.isfinite(value):
444
+ return 0
445
+ return min(MAX_BIGINT, max(0, int(value)))
446
+
447
+
448
+ def _sum(*values: int) -> int:
449
+ return min(MAX_BIGINT, sum(values))
450
+
451
+
452
+ def _add_tokens(target: dict[str, Any], tokens: dict[str, int]) -> None:
453
+ for field, value in tokens.items():
454
+ target[field] = _sum(int(target[field]), value)
@@ -8,7 +8,12 @@ from typing import Annotated
8
8
  import typer
9
9
 
10
10
  from cli_consumption import __version__
11
- from cli_consumption.adapters import ClaudeAdapter, CodexAdapter, OpenCodeAdapter
11
+ from cli_consumption.adapters import (
12
+ ClaudeAdapter,
13
+ CodexAdapter,
14
+ OpenCodeAdapter,
15
+ PiAdapter,
16
+ )
12
17
  from cli_consumption.api import create_app
13
18
  from cli_consumption.dashboard import generate_dashboard
14
19
  from cli_consumption.exporting import export_csv
@@ -50,7 +55,8 @@ def providers() -> None:
50
55
  typer.echo("codex supported")
51
56
  typer.echo("claude supported")
52
57
  typer.echo("opencode supported")
53
- for provider in ("kilo", "pi"):
58
+ typer.echo("pi supported")
59
+ for provider in ("kilo",):
54
60
  typer.echo(f"{provider:<8} planned")
55
61
 
56
62
 
@@ -230,6 +236,7 @@ def _collect_snapshots(
230
236
  ".local/share/opencode",
231
237
  "opencode.db",
232
238
  ),
239
+ "pi": (PiAdapter, ".pi/agent", "sessions"),
233
240
  }
234
241
  if provider != "all" and provider not in adapters:
235
242
  raise typer.BadParameter(
@@ -25,6 +25,7 @@ def test_provider_status_is_explicit() -> None:
25
25
  assert "codex supported" in result.stdout
26
26
  assert "claude supported" in result.stdout
27
27
  assert "opencode supported" in result.stdout
28
+ assert "pi supported" in result.stdout
28
29
 
29
30
 
30
31
  def test_version_and_unsupported_provider_are_explicit(tmp_path: Path) -> None:
@@ -113,6 +114,35 @@ def test_collects_opencode(tmp_path: Path) -> None:
113
114
  assert "1 written" in result.stdout
114
115
 
115
116
 
117
+ def test_collects_pi(tmp_path: Path) -> None:
118
+ home = tmp_path / "pi"
119
+ path = home / "sessions" / "--project--" / "session.jsonl"
120
+ path.parent.mkdir(parents=True)
121
+ path.write_text(
122
+ '{"type":"session","version":3,"id":"session-cli",'
123
+ '"timestamp":"2026-08-25T10:00:00Z","cwd":"/project"}\n'
124
+ '{"type":"message","id":"prompt","parentId":null,'
125
+ '"timestamp":"2026-08-25T10:00:01Z",'
126
+ '"message":{"role":"user","content":"synthetic"}}\n'
127
+ )
128
+ result = runner.invoke(
129
+ app,
130
+ [
131
+ "collect",
132
+ "--provider",
133
+ "pi",
134
+ "--source",
135
+ f"desktop={home}",
136
+ "--database",
137
+ str(tmp_path / "pi.sqlite"),
138
+ ],
139
+ )
140
+
141
+ assert result.exit_code == 0, result.output
142
+ assert "Ingestion pi" in result.stdout
143
+ assert "1 written" in result.stdout
144
+
145
+
116
146
  def test_collects_all_detected_providers(tmp_path: Path, rollout_factory) -> None:
117
147
  codex_home = tmp_path / "codex"
118
148
  claude_home = tmp_path / "claude"
@@ -0,0 +1,287 @@
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.pi import PiAdapter
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 = "privacy canary secret"
20
+
21
+
22
+ def session(home: Path, *, extra: bool = False) -> Path:
23
+ entries: list[dict[str, Any]] = [
24
+ {
25
+ "type": "session",
26
+ "version": 3,
27
+ "id": "session-1",
28
+ "timestamp": "2026-08-25T10:00:00Z",
29
+ "cwd": "/srv/work/acme/service",
30
+ "parentSession": CANARY,
31
+ },
32
+ {
33
+ "type": "thinking_level_change",
34
+ "id": "thinking-1",
35
+ "parentId": None,
36
+ "timestamp": "2026-08-25T10:00:00.500Z",
37
+ "thinkingLevel": "high",
38
+ },
39
+ {
40
+ "type": "message",
41
+ "id": "prompt-1",
42
+ "parentId": "thinking-1",
43
+ "timestamp": "2026-08-25T10:00:01Z",
44
+ "message": {"role": "user", "content": CANARY},
45
+ },
46
+ {
47
+ "type": "message",
48
+ "id": "assistant-1",
49
+ "parentId": "prompt-1",
50
+ "timestamp": "2026-08-25T10:00:02Z",
51
+ "message": {
52
+ "role": "assistant",
53
+ "provider": "anthropic",
54
+ "model": "claude-sonnet-4-6",
55
+ "content": [
56
+ {"type": "text", "text": CANARY},
57
+ {
58
+ "type": "toolCall",
59
+ "id": "tool-1",
60
+ "name": "bash",
61
+ "arguments": {"command": CANARY},
62
+ },
63
+ ],
64
+ "usage": {
65
+ "input": 100,
66
+ "cacheRead": 40,
67
+ "cacheWrite": 10,
68
+ "output": 20,
69
+ "reasoning": 5,
70
+ "totalTokens": 170,
71
+ "cost": {"total": 1, "secret": CANARY},
72
+ },
73
+ "stopReason": "toolUse",
74
+ "errorMessage": CANARY,
75
+ },
76
+ },
77
+ {
78
+ "type": "message",
79
+ "id": "result-1",
80
+ "parentId": "assistant-1",
81
+ "timestamp": "2026-08-25T10:00:03Z",
82
+ "message": {
83
+ "role": "toolResult",
84
+ "toolCallId": "tool-1",
85
+ "toolName": "bash",
86
+ "content": [{"type": "text", "text": CANARY}],
87
+ "details": {"output": CANARY},
88
+ "isError": False,
89
+ },
90
+ },
91
+ {
92
+ "type": "message",
93
+ "id": "assistant-2",
94
+ "parentId": "result-1",
95
+ "timestamp": "2026-08-25T10:00:04Z",
96
+ "message": {
97
+ "role": "assistant",
98
+ "provider": "anthropic",
99
+ "model": "claude-sonnet-4-6",
100
+ "content": [{"type": "text", "text": CANARY}],
101
+ "usage": {
102
+ "input": 5,
103
+ "cacheRead": 100,
104
+ "cacheWrite": 0,
105
+ "output": 10,
106
+ "totalTokens": 115,
107
+ },
108
+ "stopReason": "stop",
109
+ },
110
+ },
111
+ {
112
+ "type": "compaction",
113
+ "id": "compaction-1",
114
+ "parentId": "assistant-2",
115
+ "timestamp": "2026-08-25T10:00:05Z",
116
+ "summary": CANARY,
117
+ "tokensBefore": 50000,
118
+ "details": {"readFiles": [CANARY]},
119
+ "usage": {
120
+ "input": 10,
121
+ "cacheRead": 0,
122
+ "cacheWrite": 0,
123
+ "output": 2,
124
+ "totalTokens": 12,
125
+ },
126
+ },
127
+ {
128
+ "type": "model_change",
129
+ "id": "model-1",
130
+ "parentId": "compaction-1",
131
+ "timestamp": "2026-08-25T10:00:06Z",
132
+ "provider": "openai",
133
+ "modelId": "gpt-5",
134
+ },
135
+ {
136
+ "type": "message",
137
+ "id": "prompt-2",
138
+ "parentId": "model-1",
139
+ "timestamp": "2026-08-25T10:00:10Z",
140
+ "message": {"role": "user", "content": CANARY},
141
+ },
142
+ {
143
+ "type": "message",
144
+ "id": "assistant-3",
145
+ "parentId": "prompt-2",
146
+ "timestamp": "2026-08-25T10:00:11Z",
147
+ "message": {
148
+ "role": "assistant",
149
+ "provider": "openai",
150
+ "model": "gpt-5",
151
+ "content": [{"type": "thinking", "thinking": CANARY}],
152
+ "usage": {
153
+ "input": 2,
154
+ "cacheRead": 0,
155
+ "cacheWrite": 0,
156
+ "output": 1,
157
+ "reasoning": 1,
158
+ "totalTokens": 3,
159
+ },
160
+ "stopReason": "error",
161
+ "errorMessage": CANARY,
162
+ },
163
+ },
164
+ {
165
+ "type": "message",
166
+ "id": "prompt-branch",
167
+ "parentId": "prompt-1",
168
+ "timestamp": "2026-08-25T10:00:12Z",
169
+ "message": {"role": "user", "content": CANARY},
170
+ },
171
+ {
172
+ "type": "message",
173
+ "id": "assistant-branch",
174
+ "parentId": "prompt-branch",
175
+ "timestamp": "2026-08-25T10:00:13Z",
176
+ "message": {
177
+ "role": "assistant",
178
+ "provider": "google",
179
+ "model": "gemini-2.5-pro",
180
+ "content": [{"type": "text", "text": CANARY}],
181
+ "usage": {
182
+ "input": 1,
183
+ "cacheRead": 0,
184
+ "cacheWrite": 0,
185
+ "output": 1,
186
+ "totalTokens": 2,
187
+ },
188
+ "stopReason": "stop",
189
+ },
190
+ },
191
+ ]
192
+ if extra:
193
+ entries.append(
194
+ {
195
+ "type": "custom",
196
+ "id": "custom-1",
197
+ "parentId": "assistant-branch",
198
+ "timestamp": "2026-08-25T10:00:14Z",
199
+ "customType": "fixture",
200
+ "data": {"secret": CANARY},
201
+ }
202
+ )
203
+ path = home / "sessions" / "--srv-work-acme-service--" / "session.jsonl"
204
+ path.parent.mkdir(parents=True, exist_ok=True)
205
+ path.write_text(
206
+ "".join(json.dumps(entry) + "\n" for entry in entries), encoding="utf-8"
207
+ )
208
+ return path
209
+
210
+
211
+ def test_collects_usage_tools_branches_settings_and_compactions(tmp_path: Path) -> None:
212
+ home = tmp_path / "pi"
213
+ session(home)
214
+
215
+ snapshot = PiAdapter().collect([("laptop", home)], [("acme", "/srv/work/acme")])
216
+
217
+ conversation = snapshot.conversations[0]
218
+ assert conversation["id"] == "pi:session-1"
219
+ assert conversation["project"] == "acme"
220
+ assert conversation["models"] == [
221
+ "anthropic/claude-sonnet-4-6",
222
+ "google/gemini-2.5-pro",
223
+ "openai/gpt-5",
224
+ ]
225
+ assert conversation["model_calls"] == 5
226
+ assert conversation["tool_calls"] == 1
227
+ assert conversation["input_tokens"] == 268
228
+ assert conversation["uncached_input_tokens"] == 118
229
+ assert conversation["cached_input_tokens"] == 140
230
+ assert conversation["cache_write_input_tokens"] == 10
231
+ assert conversation["output_tokens"] == 34
232
+ assert conversation["reasoning_output_tokens"] == 6
233
+ assert conversation["visible_output_tokens"] == 28
234
+ assert conversation["total_tokens"] == 302
235
+ assert [turn["status"] for turn in snapshot.turns] == [
236
+ "completed",
237
+ "aborted",
238
+ "completed",
239
+ ]
240
+ assert snapshot.tool_calls[0]["tool_name"] == "bash"
241
+ assert snapshot.tool_calls[0]["turn_id"] == "pi:session-1:prompt-1"
242
+ assert snapshot.compaction_events[0]["turn_id"] == "pi:session-1:prompt-1"
243
+ assert {setting["effort"] for setting in snapshot.turn_settings} == {"high"}
244
+ assert CANARY not in str(snapshot.to_dict())
245
+
246
+
247
+ def test_deduplicates_copies_and_handles_malformed_sessions(tmp_path: Path) -> None:
248
+ first, second = tmp_path / "first", tmp_path / "second"
249
+ path = session(first)
250
+ session(second, extra=True)
251
+ with path.open("a", encoding="utf-8") as handle:
252
+ handle.write("not-json\n[]\n")
253
+ invalid = first / "sessions" / "invalid.jsonl"
254
+ invalid.write_text(json.dumps({"type": "message", "content": CANARY}) + "\n")
255
+
256
+ snapshot = PiAdapter().collect([("desktop", first), ("laptop", second)])
257
+
258
+ assert snapshot.duplicate_conversations == 1
259
+ assert snapshot.malformed_records == 3
260
+ assert snapshot.conversations[0]["source_machine"] == "laptop"
261
+ assert snapshot.conversations[0]["event_count"] == 13
262
+ assert (
263
+ snapshot.to_dict()
264
+ == PiAdapter().collect([("desktop", first), ("laptop", second)]).to_dict()
265
+ )
266
+
267
+ with pytest.raises(ValueError, match="Missing Pi sessions directory"):
268
+ PiAdapter().collect([("machine", tmp_path / "missing")])
269
+
270
+
271
+ def test_privacy_canary_is_absent_from_storage_and_exports(tmp_path: Path) -> None:
272
+ home = tmp_path / "pi"
273
+ session(home, extra=True)
274
+ snapshot = PiAdapter().collect([("machine", home)])
275
+ engine = create_database_engine(tmp_path / "usage.sqlite")
276
+ try:
277
+ ingest_snapshot(engine, snapshot)
278
+ rows = {name: read_table(engine, name) for name in TABLES}
279
+ assert CANARY not in json.dumps(rows)
280
+ output = tmp_path / "reports"
281
+ paths = export_csv(engine, output)
282
+ dashboard = output / "dashboard.html"
283
+ generate_dashboard(engine, dashboard)
284
+ assert all(CANARY not in path.read_text() for path in paths)
285
+ assert CANARY not in dashboard.read_text()
286
+ finally:
287
+ engine.dispose()
@@ -56,7 +56,7 @@ wheels = [
56
56
 
57
57
  [[package]]
58
58
  name = "cli-consumption"
59
- version = "0.0.4"
59
+ version = "0.0.5"
60
60
  source = { editable = "." }
61
61
  dependencies = [
62
62
  { name = "fastapi" },
File without changes
File without changes