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