cli-consumption 0.1.0__tar.gz → 0.1.1__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 (87) hide show
  1. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/PKG-INFO +2 -1
  2. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/README.md +1 -0
  3. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/docs/provider-support.md +24 -0
  4. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/pyproject.toml +1 -1
  5. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/__init__.py +2 -0
  6. cli_consumption-0.1.1/src/cli_consumption/adapters/mistral_vibe.py +281 -0
  7. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/cli.py +7 -0
  8. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_cli.py +38 -0
  9. cli_consumption-0.1.1/tests/test_mistral_vibe_adapter.py +207 -0
  10. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/uv.lock +1 -1
  11. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
  12. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
  13. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
  14. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
  15. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
  16. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
  17. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/yeet-github/SKILL.md +0 -0
  18. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
  19. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/yolo/SKILL.md +0 -0
  20. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.agents/skills/yolo/agents/openai.yaml +0 -0
  21. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.github/workflows/ci.yml +0 -0
  22. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.github/workflows/release.yaml +0 -0
  23. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.gitignore +0 -0
  24. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.pre-commit-config.yaml +0 -0
  25. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/.python-version +0 -0
  26. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/AGENTS.md +0 -0
  27. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/CONTRIBUTING.md +0 -0
  28. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/LICENSE +0 -0
  29. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/NOTICE +0 -0
  30. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/docs/architecture.md +0 -0
  31. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/docs/privacy.md +0 -0
  32. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/docs/roadmap.md +0 -0
  33. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/__init__.py +0 -0
  34. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/__main__.py +0 -0
  35. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/_shared.py +0 -0
  36. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/aider.py +0 -0
  37. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/amazon_q.py +0 -0
  38. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/amp.py +0 -0
  39. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/base.py +0 -0
  40. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/claude.py +0 -0
  41. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/cline.py +0 -0
  42. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/codex.py +0 -0
  43. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/continue_cli.py +0 -0
  44. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/copilot.py +0 -0
  45. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/crush.py +0 -0
  46. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/cursor.py +0 -0
  47. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/gemini.py +0 -0
  48. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/goose.py +0 -0
  49. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/grok.py +0 -0
  50. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/kilo.py +0 -0
  51. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/kimi.py +0 -0
  52. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/opencode.py +0 -0
  53. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/openhands.py +0 -0
  54. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/pi.py +0 -0
  55. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/plandex.py +0 -0
  56. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/adapters/qwen.py +0 -0
  57. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/api.py +0 -0
  58. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/dashboard.py +0 -0
  59. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/exporting.py +0 -0
  60. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/models.py +0 -0
  61. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/py.typed +0 -0
  62. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/storage.py +0 -0
  63. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/src/cli_consumption/sync.py +0 -0
  64. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/conftest.py +0 -0
  65. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_aider_adapter.py +0 -0
  66. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_amazon_q_adapter.py +0 -0
  67. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_amp_adapter.py +0 -0
  68. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_api.py +0 -0
  69. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_claude_adapter.py +0 -0
  70. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_cline_adapter.py +0 -0
  71. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_codex_adapter.py +0 -0
  72. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_continue_adapter.py +0 -0
  73. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_copilot_adapter.py +0 -0
  74. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_crush_adapter.py +0 -0
  75. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_cursor_adapter.py +0 -0
  76. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_gemini_adapter.py +0 -0
  77. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_goose_adapter.py +0 -0
  78. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_grok_adapter.py +0 -0
  79. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_kilo_adapter.py +0 -0
  80. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_kimi_adapter.py +0 -0
  81. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_opencode_adapter.py +0 -0
  82. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_openhands_adapter.py +0 -0
  83. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_pi_adapter.py +0 -0
  84. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_plandex_adapter.py +0 -0
  85. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_qwen_adapter.py +0 -0
  86. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_storage_and_exports.py +0 -0
  87. {cli_consumption-0.1.0 → cli_consumption-0.1.1}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -95,6 +95,7 @@ Use the provider name below with `--provider` to select one CLI explicitly.
95
95
  | Grok Build | `grok` | `~/.grok/sessions/` | Per-prompt aggregates, reasoning effort, TTFT, and auto-compactions; no costs or subagent relationships. |
96
96
  | Kilo Code | `kilo` | `~/.local/share/kilo/kilo.db` | CLI SQLite store only; excludes legacy IDE tasks, cloud sessions, subagents, context windows, and costs. |
97
97
  | Kimi Code CLI | `kimi` | `~/.kimi/sessions/` | Wire v1 events, context windows, and compactions; selected model is not persisted and is reported as `unknown`. |
98
+ | Mistral Vibe CLI | `mistral-vibe` | `~/.vibe/logs/session/` | Session-level token aggregates, user turns, tools, and compactions; no per-message timestamps or historical model attribution. |
98
99
  | OpenCode | `opencode` | `~/.local/share/opencode/opencode.db` | SQLite v2 only; no legacy storage, child sessions, context windows, or costs. |
99
100
  | OpenHands CLI | `openhands` | `~/.openhands/conversations/` | SDK persistence with context windows, reasoning effort, and condensations; excludes cloud-only conversations and delegates. |
100
101
  | Pi | `pi` | `~/.pi/agent/sessions/` | Counts all persisted branches; no branch relationships, custom-directory auto-detection, context windows, or provider-reported durations. |
@@ -67,6 +67,7 @@ Use the provider name below with `--provider` to select one CLI explicitly.
67
67
  | Grok Build | `grok` | `~/.grok/sessions/` | Per-prompt aggregates, reasoning effort, TTFT, and auto-compactions; no costs or subagent relationships. |
68
68
  | Kilo Code | `kilo` | `~/.local/share/kilo/kilo.db` | CLI SQLite store only; excludes legacy IDE tasks, cloud sessions, subagents, context windows, and costs. |
69
69
  | Kimi Code CLI | `kimi` | `~/.kimi/sessions/` | Wire v1 events, context windows, and compactions; selected model is not persisted and is reported as `unknown`. |
70
+ | Mistral Vibe CLI | `mistral-vibe` | `~/.vibe/logs/session/` | Session-level token aggregates, user turns, tools, and compactions; no per-message timestamps or historical model attribution. |
70
71
  | OpenCode | `opencode` | `~/.local/share/opencode/opencode.db` | SQLite v2 only; no legacy storage, child sessions, context windows, or costs. |
71
72
  | OpenHands CLI | `openhands` | `~/.openhands/conversations/` | SDK persistence with context windows, reasoning effort, and condensations; excludes cloud-only conversations and delegates. |
72
73
  | Pi | `pi` | `~/.pi/agent/sessions/` | Counts all persisted branches; no branch relationships, custom-directory auto-detection, context windows, or provider-reported durations. |
@@ -17,6 +17,7 @@
17
17
  | Grok Build | Supported (core) | Local session summary and update/event JSONL |
18
18
  | Kilo Code | Supported (core) | Local SQLite session store |
19
19
  | Kimi Code CLI | Supported (core) | Local Wire event JSONL |
20
+ | Mistral Vibe CLI | Supported (core) | Local session metadata JSON and message JSONL |
20
21
  | OpenCode | Supported (core) | Local SQLite v2 session store |
21
22
  | OpenHands CLI | Supported (core) | Local SDK conversation state and event JSON |
22
23
  | Pi | Supported (core) | Local session JSONL v1-v3 |
@@ -31,6 +32,29 @@ It does not mean that token counters are equivalent to invoices.
31
32
  ingests each metadata-only snapshot independently. With no explicit source it checks
32
33
  the local default homes; repeated `--source` paths are filtered by detected format.
33
34
 
35
+ ## Mistral Vibe CLI
36
+
37
+ Mistral Vibe CLI reads top-level session directories under
38
+ `~/.vibe/logs/session/`, when interaction logging is enabled. The adapter was
39
+ qualified against Mistral Vibe 2.24.5 and its current `meta.json` plus
40
+ `messages.jsonl` format in August 2026. It extracts stable session and user-message
41
+ identifiers, session timestamps, the latest active model alias, cumulative token
42
+ usage, function names from assistant tool calls, and compaction markers. Working
43
+ directories are inspected only for explicit project mappings. Titles, prompts,
44
+ responses, reasoning, tool arguments/results, system instructions, usernames, Git
45
+ metadata, environment values other than the mapped working directory, prices,
46
+ arbitrary configuration, and raw messages are discarded.
47
+
48
+ Vibe persists prompt, cached-prompt, and completion counters only as cumulative
49
+ session statistics. The adapter therefore emits one unattributed aggregate model
50
+ call, subtracts cached prompt tokens from uncached input, and does not assign token
51
+ usage to individual turns. The persisted active model is the latest selection and is
52
+ not attributed historically to turns. Message records have no timestamps, so turns,
53
+ tools, and compactions do not expose event times or durations. Child-agent sessions,
54
+ model changes, cache writes, reasoning tokens, context-window samples, costs, tool
55
+ outcomes, and provider-reported latency are not collected. Vibe's internal session
56
+ format can change without notice, and local token events are not billing data.
57
+
34
58
  ## Codex
35
59
 
36
60
  Codex additionally exposes provider-reported turn duration and TTFT, model context
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -13,6 +13,7 @@ from cli_consumption.adapters.goose import GooseAdapter
13
13
  from cli_consumption.adapters.grok import GrokAdapter
14
14
  from cli_consumption.adapters.kilo import KiloAdapter
15
15
  from cli_consumption.adapters.kimi import KimiAdapter
16
+ from cli_consumption.adapters.mistral_vibe import MistralVibeAdapter
16
17
  from cli_consumption.adapters.opencode import OpenCodeAdapter
17
18
  from cli_consumption.adapters.openhands import OpenHandsAdapter
18
19
  from cli_consumption.adapters.pi import PiAdapter
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "GrokAdapter",
36
37
  "KiloAdapter",
37
38
  "KimiAdapter",
39
+ "MistralVibeAdapter",
38
40
  "OpenCodeAdapter",
39
41
  "OpenHandsAdapter",
40
42
  "PiAdapter",
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from cli_consumption.adapters._shared import (
11
+ add_tokens,
12
+ counter,
13
+ iso,
14
+ label,
15
+ mapping,
16
+ project,
17
+ timestamp,
18
+ tokens,
19
+ )
20
+ from cli_consumption.models import Snapshot, empty_tokens
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class _Candidate:
25
+ machine: str
26
+ external_id: str
27
+ metadata: dict[str, Any]
28
+ messages: list[dict[str, Any]]
29
+ event_count: int
30
+ digest: str
31
+
32
+
33
+ class MistralVibeAdapter:
34
+ """Read Mistral Vibe session logs without retaining conversation content."""
35
+
36
+ name = "mistral-vibe"
37
+
38
+ def collect(
39
+ self,
40
+ sources: list[tuple[str, Path]],
41
+ project_mappings: list[tuple[str, str]] | None = None,
42
+ ) -> Snapshot:
43
+ selected: dict[str, _Candidate] = {}
44
+ duplicates = malformed = 0
45
+ for machine, home in sources:
46
+ sessions = home / "logs" / "session"
47
+ if not sessions.is_dir():
48
+ raise ValueError(
49
+ f"Missing Mistral Vibe session log directory: {sessions}"
50
+ )
51
+ for metadata_path in sorted(sessions.glob("*/meta.json")):
52
+ candidate, invalid = _read_candidate(machine, metadata_path)
53
+ malformed += invalid
54
+ if candidate is None:
55
+ continue
56
+ previous = selected.get(candidate.external_id)
57
+ if previous is None:
58
+ selected[candidate.external_id] = candidate
59
+ continue
60
+ duplicates += 1
61
+ if _rank(candidate) > _rank(previous):
62
+ selected[candidate.external_id] = candidate
63
+
64
+ snapshot = Snapshot(
65
+ provider=self.name,
66
+ duplicate_conversations=duplicates,
67
+ malformed_records=malformed,
68
+ )
69
+ for candidate in sorted(selected.values(), key=lambda item: item.external_id):
70
+ self._normalize(snapshot, candidate, project_mappings or [])
71
+ return snapshot
72
+
73
+ def _normalize(
74
+ self,
75
+ snapshot: Snapshot,
76
+ source: _Candidate,
77
+ mappings: list[tuple[str, str]],
78
+ ) -> None:
79
+ conversation_id = f"mistral-vibe:{source.external_id}"
80
+ started_at = timestamp(source.metadata.get("start_time"))
81
+ ended_at = timestamp(source.metadata.get("end_time"))
82
+ active_turn: dict[str, Any] | None = None
83
+ turn_number = 0
84
+ tool_number = 0
85
+ compaction_number = 0
86
+
87
+ for message in source.messages:
88
+ role = message.get("role")
89
+ if role == "user" and message.get("injected") is not True:
90
+ if active_turn is not None:
91
+ active_turn["status"] = "completed"
92
+ turn_number += 1
93
+ active_turn = _turn(conversation_id, message, turn_number)
94
+ snapshot.turns.append(active_turn)
95
+ snapshot.turn_settings.append(
96
+ {
97
+ "id": f"{conversation_id}:settings:{turn_number}",
98
+ "conversation_id": conversation_id,
99
+ "turn_id": active_turn["id"],
100
+ "model": None,
101
+ "effort": None,
102
+ "collaboration_mode": None,
103
+ "service_tier": None,
104
+ "context_window_tokens": None,
105
+ }
106
+ )
107
+
108
+ if message.get("context_boundary") == "compaction":
109
+ compaction_number += 1
110
+ snapshot.compaction_events.append(
111
+ {
112
+ "id": f"{conversation_id}:compaction:{compaction_number}",
113
+ "conversation_id": conversation_id,
114
+ "turn_id": active_turn["id"] if active_turn else None,
115
+ "sequence": compaction_number,
116
+ "timestamp": None,
117
+ }
118
+ )
119
+
120
+ if role != "assistant":
121
+ continue
122
+ if active_turn is not None:
123
+ active_turn["status"] = "completed"
124
+ raw_tool_calls = message.get("tool_calls")
125
+ if not isinstance(raw_tool_calls, list):
126
+ continue
127
+ for raw_call in raw_tool_calls:
128
+ function = mapping(mapping(raw_call).get("function"))
129
+ tool_name = label(function.get("name"))
130
+ if tool_name is None:
131
+ snapshot.malformed_records += 1
132
+ continue
133
+ tool_number += 1
134
+ if active_turn is not None:
135
+ active_turn["tool_calls"] += 1
136
+ snapshot.tool_calls.append(
137
+ {
138
+ "id": f"{conversation_id}:tool:{tool_number}",
139
+ "conversation_id": conversation_id,
140
+ "turn_id": active_turn["id"] if active_turn else None,
141
+ "sequence": tool_number,
142
+ "timestamp": None,
143
+ "tool_name": tool_name,
144
+ "outer_tool_name": tool_name,
145
+ }
146
+ )
147
+
148
+ stats = mapping(source.metadata.get("stats"))
149
+ prompt_tokens = counter(stats.get("session_prompt_tokens"))
150
+ cached_tokens = min(prompt_tokens, counter(stats.get("session_cached_tokens")))
151
+ completion_tokens = counter(stats.get("session_completion_tokens"))
152
+ usage = tokens(
153
+ uncached=prompt_tokens - cached_tokens,
154
+ cached=cached_tokens,
155
+ visible=completion_tokens,
156
+ )
157
+ config = mapping(source.metadata.get("config"))
158
+ model = label(config.get("active_model"), 255) or "unknown"
159
+ model_calls = 0
160
+ totals = empty_tokens()
161
+ if usage["total_tokens"] > 0 or counter(stats.get("steps")) > 0:
162
+ model_calls = 1
163
+ add_tokens(totals, usage)
164
+ snapshot.model_calls.append(
165
+ {
166
+ "id": f"{conversation_id}:model:aggregate",
167
+ "conversation_id": conversation_id,
168
+ "turn_id": None,
169
+ "sequence": 1,
170
+ "timestamp": iso(ended_at),
171
+ "model": model,
172
+ **usage,
173
+ }
174
+ )
175
+
176
+ directory = mapping(source.metadata.get("environment")).get("working_directory")
177
+ project_name, project_source = project(
178
+ directory if isinstance(directory, str) else None, mappings
179
+ )
180
+ snapshot.conversations.append(
181
+ {
182
+ "id": conversation_id,
183
+ "provider": self.name,
184
+ "external_id": source.external_id,
185
+ "source_machine": source.machine,
186
+ "project": project_name,
187
+ "project_source": project_source,
188
+ "started_at": iso(started_at),
189
+ "ended_at": iso(ended_at),
190
+ "duration_seconds": _duration(started_at, ended_at),
191
+ "source": "local-session-json",
192
+ "models": [model] if model_calls else [],
193
+ "iterations": turn_number,
194
+ "model_calls": model_calls,
195
+ "tool_calls": tool_number,
196
+ "compactions": compaction_number,
197
+ "event_count": source.event_count,
198
+ "content_hash": source.digest,
199
+ **totals,
200
+ }
201
+ )
202
+
203
+
204
+ def _read_candidate(machine: str, metadata_path: Path) -> tuple[_Candidate | None, int]:
205
+ digest = hashlib.sha256()
206
+ try:
207
+ metadata_bytes = metadata_path.read_bytes()
208
+ digest.update(metadata_bytes)
209
+ metadata = json.loads(metadata_bytes)
210
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
211
+ return None, 1
212
+ if not isinstance(metadata, dict):
213
+ return None, 1
214
+ external_id = label(metadata.get("session_id"), 512)
215
+ if external_id is None:
216
+ return None, 1
217
+
218
+ messages: list[dict[str, Any]] = []
219
+ malformed = 0
220
+ messages_path = metadata_path.parent / "messages.jsonl"
221
+ try:
222
+ with messages_path.open("rb") as handle:
223
+ for line in handle:
224
+ digest.update(line)
225
+ if not line.strip():
226
+ continue
227
+ try:
228
+ message = json.loads(line)
229
+ except (json.JSONDecodeError, UnicodeDecodeError):
230
+ malformed += 1
231
+ continue
232
+ if not isinstance(message, dict) or not isinstance(
233
+ message.get("role"), str
234
+ ):
235
+ malformed += 1
236
+ continue
237
+ messages.append(message)
238
+ except OSError:
239
+ malformed += 1
240
+
241
+ return (
242
+ _Candidate(
243
+ machine=machine,
244
+ external_id=external_id,
245
+ metadata=metadata,
246
+ messages=messages,
247
+ event_count=len(messages),
248
+ digest=digest.hexdigest(),
249
+ ),
250
+ malformed,
251
+ )
252
+
253
+
254
+ def _turn(
255
+ conversation_id: str, message: dict[str, Any], sequence: int
256
+ ) -> dict[str, Any]:
257
+ external_id = label(message.get("message_id"), 512) or f"turn-{sequence}"
258
+ return {
259
+ "id": f"{conversation_id}:{external_id}",
260
+ "conversation_id": conversation_id,
261
+ "external_id": external_id,
262
+ "started_at": None,
263
+ "ended_at": None,
264
+ "status": "in-progress",
265
+ "duration_ms": None,
266
+ "time_to_first_token_ms": None,
267
+ "model_calls": 0,
268
+ "tool_calls": 0,
269
+ **empty_tokens(),
270
+ }
271
+
272
+
273
+ def _duration(started_at: datetime | None, ended_at: datetime | None) -> float | None:
274
+ if started_at is None or ended_at is None:
275
+ return None
276
+ return max(0.0, (ended_at - started_at).total_seconds())
277
+
278
+
279
+ def _rank(candidate: _Candidate) -> tuple[int, str, str]:
280
+ ended_at = timestamp(candidate.metadata.get("end_time"))
281
+ return candidate.event_count, iso(ended_at) or "", candidate.digest
@@ -24,6 +24,7 @@ from cli_consumption.adapters import (
24
24
  GrokAdapter,
25
25
  KiloAdapter,
26
26
  KimiAdapter,
27
+ MistralVibeAdapter,
27
28
  OpenCodeAdapter,
28
29
  OpenHandsAdapter,
29
30
  PiAdapter,
@@ -83,6 +84,7 @@ def providers() -> None:
83
84
  typer.echo("cline supported")
84
85
  typer.echo("kilo supported")
85
86
  typer.echo("kimi supported")
87
+ typer.echo("mistral-vibe supported")
86
88
  typer.echo("opencode supported")
87
89
  typer.echo("openhands supported")
88
90
  typer.echo("pi supported")
@@ -282,6 +284,11 @@ def _collect_snapshots(
282
284
  "cline": (ClineAdapter, ".cline/data", "sessions/sessions.db"),
283
285
  "kilo": (KiloAdapter, ".local/share/kilo", "kilo.db"),
284
286
  "kimi": (KimiAdapter, ".kimi", "sessions/*/*/wire.jsonl"),
287
+ "mistral-vibe": (
288
+ MistralVibeAdapter,
289
+ ".vibe",
290
+ "logs/session/*/meta.json",
291
+ ),
285
292
  "opencode": (
286
293
  OpenCodeAdapter,
287
294
  ".local/share/opencode",
@@ -37,6 +37,7 @@ def test_provider_status_is_explicit() -> None:
37
37
  assert "cline supported" in result.stdout
38
38
  assert "kilo supported" in result.stdout
39
39
  assert "kimi supported" in result.stdout
40
+ assert "mistral-vibe supported" in result.stdout
40
41
  assert "opencode supported" in result.stdout
41
42
  assert "openhands supported" in result.stdout
42
43
  assert "pi supported" in result.stdout
@@ -172,6 +173,43 @@ def test_collects_continue_cli(tmp_path: Path) -> None:
172
173
  assert "1 written" in result.stdout
173
174
 
174
175
 
176
+ def test_collects_mistral_vibe_cli(tmp_path: Path) -> None:
177
+ home = tmp_path / "vibe"
178
+ session_dir = home / "logs" / "session" / "session_cli"
179
+ session_dir.mkdir(parents=True)
180
+ (session_dir / "meta.json").write_text(
181
+ json.dumps(
182
+ {
183
+ "session_id": "vibe-cli",
184
+ "start_time": "2026-08-27T10:00:00Z",
185
+ "stats": {"steps": 0},
186
+ }
187
+ ),
188
+ encoding="utf-8",
189
+ )
190
+ (session_dir / "messages.jsonl").write_text(
191
+ json.dumps({"role": "user", "message_id": "message-cli"}) + "\n",
192
+ encoding="utf-8",
193
+ )
194
+
195
+ result = runner.invoke(
196
+ app,
197
+ [
198
+ "collect",
199
+ "--provider",
200
+ "mistral-vibe",
201
+ "--source",
202
+ f"desktop={home}",
203
+ "--database",
204
+ str(tmp_path / "mistral-vibe.sqlite"),
205
+ ],
206
+ )
207
+
208
+ assert result.exit_code == 0, result.output
209
+ assert "Ingestion mistral-vibe" in result.stdout
210
+ assert "1 written" in result.stdout
211
+
212
+
175
213
  def test_collects_openhands_cli(tmp_path: Path) -> None:
176
214
  home = tmp_path / "openhands"
177
215
  conversation = home / "conversations" / "openhands-cli"
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from cli_consumption.adapters.mistral_vibe import MistralVibeAdapter
9
+ from cli_consumption.dashboard import generate_dashboard
10
+ from cli_consumption.exporting import export_csv
11
+ from cli_consumption.storage import (
12
+ TABLES,
13
+ create_database_engine,
14
+ ingest_snapshot,
15
+ read_table,
16
+ )
17
+
18
+ CANARY = "mistral vibe privacy canary secret"
19
+
20
+
21
+ def session(home: Path, *, extra: bool = False) -> Path:
22
+ session_dir = home / "logs" / "session" / "session_20260827_vibe0001"
23
+ session_dir.mkdir(parents=True)
24
+ metadata = {
25
+ "session_id": "vibe-session-1",
26
+ "start_time": "2026-08-27T10:00:00Z",
27
+ "end_time": "2026-08-27T10:05:00Z",
28
+ "git_commit": CANARY,
29
+ "git_branch": CANARY,
30
+ "username": CANARY,
31
+ "environment": {
32
+ "working_directory": f"/srv/work/acme/{CANARY}",
33
+ "secret": CANARY,
34
+ },
35
+ "title": CANARY,
36
+ "stats": {
37
+ "steps": 3,
38
+ "session_prompt_tokens": 100,
39
+ "session_completion_tokens": 20,
40
+ "session_cached_tokens": 40,
41
+ "input_price_per_million": 99,
42
+ },
43
+ "config": {
44
+ "active_model": "devstral-medium-latest",
45
+ "api_key": CANARY,
46
+ "system_prompt": CANARY,
47
+ },
48
+ "system_prompt": {"role": "system", "content": CANARY},
49
+ "tools_available": [{"function": {"name": CANARY}}],
50
+ }
51
+ (session_dir / "meta.json").write_text(json.dumps(metadata), encoding="utf-8")
52
+ messages = [
53
+ {
54
+ "role": "user",
55
+ "message_id": "user-1",
56
+ "content": CANARY,
57
+ "resources": [{"path": CANARY}],
58
+ },
59
+ {
60
+ "role": "assistant",
61
+ "message_id": "assistant-1",
62
+ "content": CANARY,
63
+ "reasoning_content": CANARY,
64
+ "tool_calls": [
65
+ {
66
+ "id": "call-1",
67
+ "function": {
68
+ "name": "bash",
69
+ "arguments": json.dumps({"secret": CANARY}),
70
+ },
71
+ }
72
+ ],
73
+ },
74
+ {
75
+ "role": "tool",
76
+ "tool_call_id": "call-1",
77
+ "name": "bash",
78
+ "content": CANARY,
79
+ "tool_result": {"output": CANARY},
80
+ },
81
+ {
82
+ "role": "assistant",
83
+ "injected": True,
84
+ "context_boundary": "compaction",
85
+ "content": CANARY,
86
+ },
87
+ {"role": "user", "message_id": "user-2", "content": CANARY},
88
+ {
89
+ "role": "assistant",
90
+ "content": CANARY,
91
+ "tool_calls": [{"function": {"name": CANARY, "arguments": CANARY}}],
92
+ },
93
+ ]
94
+ if extra:
95
+ messages.append(
96
+ {"role": "assistant", "message_id": "assistant-3", "content": CANARY}
97
+ )
98
+ (session_dir / "messages.jsonl").write_text(
99
+ "".join(json.dumps(message) + "\n" for message in messages),
100
+ encoding="utf-8",
101
+ )
102
+ return session_dir
103
+
104
+
105
+ def test_collects_session_aggregate_usage_turns_tools_and_compactions(
106
+ tmp_path: Path,
107
+ ) -> None:
108
+ home = tmp_path / "vibe"
109
+ session(home)
110
+
111
+ snapshot = MistralVibeAdapter().collect(
112
+ [("laptop", home)], [("acme", "/srv/work/acme")]
113
+ )
114
+
115
+ conversation = snapshot.conversations[0]
116
+ assert conversation["id"] == "mistral-vibe:vibe-session-1"
117
+ assert conversation["project"] == "acme"
118
+ assert conversation["project_source"] == "mapping"
119
+ assert conversation["models"] == ["devstral-medium-latest"]
120
+ assert conversation["iterations"] == 2
121
+ assert conversation["model_calls"] == 1
122
+ assert conversation["tool_calls"] == 1
123
+ assert conversation["compactions"] == 1
124
+ assert conversation["duration_seconds"] == 300
125
+ assert conversation["input_tokens"] == 100
126
+ assert conversation["uncached_input_tokens"] == 60
127
+ assert conversation["cached_input_tokens"] == 40
128
+ assert conversation["output_tokens"] == 20
129
+ assert conversation["visible_output_tokens"] == 20
130
+ assert conversation["total_tokens"] == 120
131
+ assert [turn["status"] for turn in snapshot.turns] == [
132
+ "completed",
133
+ "completed",
134
+ ]
135
+ assert all(turn["model_calls"] == 0 for turn in snapshot.turns)
136
+ assert snapshot.model_calls[0]["turn_id"] is None
137
+ assert snapshot.tool_calls[0]["tool_name"] == "bash"
138
+ assert snapshot.compaction_events[0]["turn_id"] == snapshot.turns[0]["id"]
139
+ assert all(setting["model"] is None for setting in snapshot.turn_settings)
140
+ assert snapshot.malformed_records == 1
141
+ assert CANARY not in json.dumps(snapshot.to_dict())
142
+
143
+
144
+ def test_deduplicates_and_tolerates_malformed_or_partial_sessions(
145
+ tmp_path: Path,
146
+ ) -> None:
147
+ first, second = tmp_path / "first", tmp_path / "second"
148
+ session(first)
149
+ path = session(second, extra=True) / "messages.jsonl"
150
+ with path.open("a", encoding="utf-8") as handle:
151
+ handle.write("not-json\n[]\n")
152
+
153
+ snapshot = MistralVibeAdapter().collect([("desktop", first), ("laptop", second)])
154
+
155
+ assert snapshot.duplicate_conversations == 1
156
+ assert snapshot.malformed_records == 3
157
+ assert snapshot.conversations[0]["source_machine"] == "laptop"
158
+ assert CANARY not in json.dumps(snapshot.to_dict())
159
+ assert (
160
+ snapshot.to_dict()
161
+ == MistralVibeAdapter()
162
+ .collect([("desktop", first), ("laptop", second)])
163
+ .to_dict()
164
+ )
165
+
166
+ partial = tmp_path / "partial"
167
+ partial_dir = partial / "logs" / "session" / "session_partial"
168
+ partial_dir.mkdir(parents=True)
169
+ (partial_dir / "meta.json").write_text(
170
+ json.dumps(
171
+ {
172
+ "session_id": "partial-session",
173
+ "start_time": "2026-08-27T11:00:00Z",
174
+ }
175
+ ),
176
+ encoding="utf-8",
177
+ )
178
+ partial_snapshot = MistralVibeAdapter().collect([("machine", partial)])
179
+ assert partial_snapshot.malformed_records == 1
180
+ assert partial_snapshot.conversations[0]["model_calls"] == 0
181
+
182
+ with pytest.raises(ValueError, match="Missing Mistral Vibe session log directory"):
183
+ MistralVibeAdapter().collect([("machine", tmp_path / "missing")])
184
+
185
+
186
+ def test_privacy_canary_is_absent_and_ingestion_is_idempotent(tmp_path: Path) -> None:
187
+ home = tmp_path / "vibe"
188
+ session(home)
189
+ snapshot = MistralVibeAdapter().collect([("machine", home)])
190
+ assert CANARY not in json.dumps(snapshot.to_dict())
191
+
192
+ engine = create_database_engine(tmp_path / "usage.sqlite")
193
+ try:
194
+ first = ingest_snapshot(engine, snapshot)
195
+ second = ingest_snapshot(engine, snapshot)
196
+ assert (first.written, first.skipped) == (1, 0)
197
+ assert (second.written, second.skipped) == (0, 1)
198
+ rows = {name: read_table(engine, name) for name in TABLES}
199
+ assert CANARY not in json.dumps(rows)
200
+ output = tmp_path / "reports"
201
+ paths = export_csv(engine, output)
202
+ dashboard = output / "dashboard.html"
203
+ generate_dashboard(engine, dashboard)
204
+ assert all(CANARY not in path.read_text() for path in paths)
205
+ assert CANARY not in dashboard.read_text()
206
+ finally:
207
+ engine.dispose()
@@ -56,7 +56,7 @@ wheels = [
56
56
 
57
57
  [[package]]
58
58
  name = "cli-consumption"
59
- version = "0.1.0"
59
+ version = "0.1.1"
60
60
  source = { editable = "." }
61
61
  dependencies = [
62
62
  { name = "fastapi" },
File without changes
File without changes