From e2d4e565f84224fa75321b48bbb0e56e13944c8a Mon Sep 17 00:00:00 2001
From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com>
Date: Mon, 11 May 2026 16:54:28 +0300
Subject: [PATCH] Add Mistral Vibe provider
---
CHANGELOG.md | 10 +
README.md | 8 +-
assets/providers/mistral-vibe.svg | 12 +
docs/providers/README.md | 1 +
docs/providers/mistral-vibe.md | 41 ++++
src/providers/index.ts | 3 +-
src/providers/mistral-vibe.ts | 355 +++++++++++++++++++++++++++
tests/provider-registry.test.ts | 2 +-
tests/providers/mistral-vibe.test.ts | 284 +++++++++++++++++++++
9 files changed, 713 insertions(+), 3 deletions(-)
create mode 100644 assets/providers/mistral-vibe.svg
create mode 100644 docs/providers/mistral-vibe.md
create mode 100644 src/providers/mistral-vibe.ts
create mode 100644 tests/providers/mistral-vibe.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7dd43d..7befa86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## Unreleased
+
+### Added (CLI)
+- **Mistral Vibe provider.** CodeBurn now reads Mistral Vibe session folders
+ from `$VIBE_HOME/logs/session/` or `~/.vibe/logs/session/`, using
+ `meta.json` for cumulative prompt/completion tokens, model pricing, and
+ timestamps, and `messages.jsonl` for user prompts and tool calls. Subagent
+ sessions under a parent session's `agents/` folder are tracked separately.
+ Closes #283.
+
## 0.9.8 - 2026-05-10
### Added (CLI)
diff --git a/README.md b/README.md
index b370022..0f593fd 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-CodeBurn tracks token usage, cost, and performance across **18 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
+CodeBurn tracks token usage, cost, and performance across **19 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads session data directly from disk and prices every call using [LiteLLM](https://github.com/BerriAI/litellm).
@@ -103,6 +103,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
|
| Cursor | Yes | [cursor.md](docs/providers/cursor.md) |
|
| cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) |
|
| Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) |
+|
| Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) |
|
| GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) |
|
| Kiro | Yes | [kiro.md](docs/providers/kiro.md) |
|
| OpenCode | Yes | [opencode.md](docs/providers/opencode.md) |
@@ -131,6 +132,8 @@ The `--provider` flag filters any command to a single provider: `codeburn report
**Gemini CLI** stores sessions as single JSON files. Each session embeds real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging.
+**Mistral Vibe** stores sessions as folders under `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`). CodeBurn reads cumulative prompt/completion totals and model pricing from `meta.json`, then reads `messages.jsonl` for the first user prompt and assistant tool calls. Subagent sessions under `agents/` are counted as separate Vibe sessions.
+
**Kiro** stores conversations as `.chat` JSON files. Token counts are estimated from content length. The underlying model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates.
**GitHub Copilot** reads from both `~/.copilot/session-state/` (legacy CLI) and VS Code's `workspaceStorage/*/GitHub.copilot-chat/transcripts/`. The VS Code format has no explicit token counts; tokens are estimated from content length and the model is inferred from tool call ID prefixes.
@@ -376,6 +379,8 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
**Gemini CLI** stores sessions as single JSON files at `~/.gemini/tmp//chats/session-*.json`. Each session embeds real token counts (input, output, cached, thoughts) per message. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging.
+**Mistral Vibe** stores session folders at `~/.vibe/logs/session/`. Each folder contains `meta.json` with cumulative prompt/completion token totals, model pricing, timestamps, and working directory, plus `messages.jsonl` with user prompts and assistant tool calls. CodeBurn emits one record per Vibe session because the source data is cumulative, not per assistant turn.
+
**OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields.
**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts.
@@ -391,6 +396,7 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
| `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) |
| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) |
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
+| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
## Sponsoring CodeBurn
diff --git a/assets/providers/mistral-vibe.svg b/assets/providers/mistral-vibe.svg
new file mode 100644
index 0000000..f70841a
--- /dev/null
+++ b/assets/providers/mistral-vibe.svg
@@ -0,0 +1,12 @@
+
diff --git a/docs/providers/README.md b/docs/providers/README.md
index 05f43db..7cc1efc 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -17,6 +17,7 @@ For the architectural picture, see `../architecture.md`.
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` |
| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` |
+| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` |
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |
diff --git a/docs/providers/mistral-vibe.md b/docs/providers/mistral-vibe.md
new file mode 100644
index 0000000..c7005f7
--- /dev/null
+++ b/docs/providers/mistral-vibe.md
@@ -0,0 +1,41 @@
+# Mistral Vibe
+
+Mistral Vibe CLI.
+
+- **Source:** `src/providers/mistral-vibe.ts`
+- **Loading:** eager (`src/providers/index.ts`)
+- **Test:** `tests/providers/mistral-vibe.test.ts`
+
+## Where it reads from
+
+`$VIBE_HOME/logs/session/` when `VIBE_HOME` is set, otherwise `~/.vibe/logs/session/`.
+
+## Storage format
+
+Vibe 2.x stores each session as a directory:
+
+- `meta.json` contains session metadata, cumulative token totals, active model config, model prices, timestamps, working directory, and available tools.
+- `messages.jsonl` contains non-system messages and assistant `tool_calls`.
+
+Subagent traces are stored under a parent session's `agents/` folder with the same `meta.json` / `messages.jsonl` shape, so CodeBurn scans those one level down as separate sessions.
+
+## Caching
+
+None.
+
+## Deduplication
+
+Per `mistral-vibe:`.
+
+## Quirks
+
+- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn emits one usage record per Vibe session.
+- **Cost prefers Vibe's own model prices.** `meta.json.stats.input_price_per_million` and `output_price_per_million` are used first, with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data.
+- **Project names come from metadata.** Discovery uses `meta.json.environment.working_directory` and falls back to the session directory name if that field is missing.
+- **Tool calls come from messages.** Assistant `tool_calls[*].function.name` is normalized to the standard CodeBurn names (`bash` to `Bash`, `search_replace` to `Edit`, etc.). Bash commands are extracted from `function.arguments.command`.
+
+## When fixing a bug here
+
+1. Reproduce with a fixture that has both `meta.json` and `messages.jsonl`; both files are required for current Vibe sessions.
+2. If the bug is "wrong total", check `meta.json.stats` first. `messages.jsonl` is only for prompts and tool calls.
+3. If a future Vibe release adds per-turn usage, add tests before changing the one-record-per-session behavior so historical sessions continue to parse correctly.
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 38ed490..69326b7 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -5,6 +5,7 @@ import { droid } from './droid.js'
import { gemini } from './gemini.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
+import { mistralVibe } from './mistral-vibe.js'
import { openclaw } from './openclaw.js'
import { pi, omp } from './pi.js'
import { qwen } from './qwen.js'
@@ -101,7 +102,7 @@ async function loadCrush(): Promise {
}
}
-const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
+const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, mistralVibe, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise {
const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()])
diff --git a/src/providers/mistral-vibe.ts b/src/providers/mistral-vibe.ts
new file mode 100644
index 0000000..7feb988
--- /dev/null
+++ b/src/providers/mistral-vibe.ts
@@ -0,0 +1,355 @@
+import { readdir, stat } from 'fs/promises'
+import { basename, join } from 'path'
+import { homedir } from 'os'
+
+import { readSessionFile, readSessionLines } from '../fs-utils.js'
+import { calculateCost } from '../models.js'
+import { extractBashCommands } from '../bash-utils.js'
+import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
+
+const METADATA_FILENAME = 'meta.json'
+const MESSAGES_FILENAME = 'messages.jsonl'
+const DEFAULT_MODEL = 'mistral-medium-3.5'
+
+const modelDisplayNames: Record = {
+ 'mistral-medium-3.5': 'Mistral Medium 3.5',
+ 'mistral-vibe-cli-latest': 'Mistral Vibe CLI',
+ 'devstral-small': 'Devstral Small',
+ 'devstral-small-latest': 'Devstral Small',
+ devstral: 'Devstral',
+ local: 'Local',
+}
+
+const toolNameMap: Record = {
+ bash: 'Bash',
+ read_file: 'Read',
+ write_file: 'Write',
+ search_replace: 'Edit',
+ grep: 'Grep',
+ task: 'Agent',
+ todo: 'TodoWrite',
+ skill: 'Skill',
+ web_fetch: 'WebFetch',
+ web_search: 'WebSearch',
+ ask_user_question: 'AskUser',
+ exit_plan_mode: 'ExitPlanMode',
+}
+
+type VibeStats = {
+ session_prompt_tokens?: number
+ session_completion_tokens?: number
+ input_price_per_million?: number
+ output_price_per_million?: number
+ tokens_per_second?: number
+}
+
+type VibeModelConfig = {
+ name?: string
+ alias?: string
+ input_price?: number
+ output_price?: number
+}
+
+type VibeMetadata = {
+ session_id?: string
+ start_time?: string
+ end_time?: string | null
+ environment?: {
+ working_directory?: string | null
+ }
+ stats?: VibeStats
+ config?: {
+ active_model?: string
+ models?: VibeModelConfig[]
+ }
+ title?: string | null
+}
+
+type VibeToolCall = {
+ function?: {
+ name?: string
+ arguments?: string | Record | null
+ }
+}
+
+type VibeMessage = {
+ role?: string
+ content?: unknown
+ tool_calls?: VibeToolCall[] | null
+}
+
+function getMistralVibeSessionsDir(override?: string): string {
+ if (override) return override
+ const configuredHome = process.env['VIBE_HOME']
+ const vibeHome = configuredHome ? expandHome(configuredHome) : join(homedir(), '.vibe')
+ return join(vibeHome, 'logs', 'session')
+}
+
+function expandHome(path: string): string {
+ if (path === '~') return homedir()
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
+ return path
+}
+
+async function isFile(path: string): Promise {
+ const s = await stat(path).catch(() => null)
+ return Boolean(s?.isFile())
+}
+
+async function isDirectory(path: string): Promise {
+ const s = await stat(path).catch(() => null)
+ return Boolean(s?.isDirectory())
+}
+
+async function hasSessionFiles(dir: string): Promise {
+ const [hasMetadata, hasMessages] = await Promise.all([
+ isFile(join(dir, METADATA_FILENAME)),
+ isFile(join(dir, MESSAGES_FILENAME)),
+ ])
+ return hasMetadata && hasMessages
+}
+
+async function readJsonFile(path: string): Promise {
+ const raw = await readSessionFile(path)
+ if (raw === null) return null
+ try {
+ const parsed = JSON.parse(raw) as unknown
+ return typeof parsed === 'object' && parsed !== null ? parsed as T : null
+ } catch {
+ return null
+ }
+}
+
+async function discoverSessionDirs(root: string): Promise {
+ const sessionDirs: string[] = []
+
+ let entries: string[]
+ try {
+ entries = (await readdir(root)).sort()
+ } catch {
+ return sessionDirs
+ }
+
+ for (const entry of entries) {
+ const dir = join(root, entry)
+ if (!await isDirectory(dir)) continue
+
+ if (await hasSessionFiles(dir)) {
+ sessionDirs.push(dir)
+ }
+
+ const agentsDir = join(dir, 'agents')
+ if (!await isDirectory(agentsDir)) continue
+
+ let agentEntries: string[]
+ try {
+ agentEntries = (await readdir(agentsDir)).sort()
+ } catch {
+ continue
+ }
+
+ for (const agentEntry of agentEntries) {
+ const agentDir = join(agentsDir, agentEntry)
+ if (await isDirectory(agentDir) && await hasSessionFiles(agentDir)) {
+ sessionDirs.push(agentDir)
+ }
+ }
+ }
+
+ return sessionDirs
+}
+
+function activeModelConfig(metadata: VibeMetadata): VibeModelConfig | null {
+ const activeModel = metadata.config?.active_model
+ const models = metadata.config?.models
+ if (!activeModel || !Array.isArray(models)) return null
+ return models.find(m => m.alias === activeModel || m.name === activeModel) ?? null
+}
+
+function resolveModel(metadata: VibeMetadata): string {
+ const activeModel = metadata.config?.active_model
+ if (activeModel) return activeModel
+ const configured = activeModelConfig(metadata)
+ return configured?.alias ?? configured?.name ?? DEFAULT_MODEL
+}
+
+function safeNumber(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
+}
+
+function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number {
+ const stats = metadata.stats ?? {}
+ const configured = activeModelConfig(metadata)
+ const inputPrice = safeNumber(stats.input_price_per_million) || safeNumber(configured?.input_price)
+ const outputPrice = safeNumber(stats.output_price_per_million) || safeNumber(configured?.output_price)
+
+ if (inputPrice > 0 || outputPrice > 0) {
+ return (inputTokens / 1_000_000) * inputPrice + (outputTokens / 1_000_000) * outputPrice
+ }
+
+ return calculateCost(model, inputTokens, outputTokens, 0, 0, 0)
+}
+
+function normalizeContent(content: unknown): string {
+ if (typeof content === 'string') return content
+ if (Array.isArray(content)) {
+ return content
+ .map(part => {
+ if (typeof part === 'string') return part
+ if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') return part.text
+ return ''
+ })
+ .filter(Boolean)
+ .join(' ')
+ }
+ return ''
+}
+
+function parseToolArguments(raw: string | Record | null | undefined): Record {
+ if (!raw) return {}
+ if (typeof raw === 'object') return raw
+ try {
+ const parsed = JSON.parse(raw) as unknown
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}
+ } catch {
+ return {}
+ }
+}
+
+function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[] } {
+ const tools: string[] = []
+ const bashCommands: string[] = []
+
+ for (const message of messages) {
+ if (message.role !== 'assistant') continue
+ for (const toolCall of message.tool_calls ?? []) {
+ const rawName = toolCall.function?.name
+ if (!rawName) continue
+
+ const mappedName = toolNameMap[rawName] ?? rawName
+ tools.push(mappedName)
+
+ if (mappedName !== 'Bash') continue
+ const args = parseToolArguments(toolCall.function?.arguments)
+ const command = args['command']
+ if (typeof command === 'string') {
+ bashCommands.push(...extractBashCommands(command))
+ }
+ }
+ }
+
+ return {
+ tools: [...new Set(tools)],
+ bashCommands: [...new Set(bashCommands)],
+ }
+}
+
+async function readMessages(path: string): Promise {
+ const messages: VibeMessage[] = []
+ for await (const line of readSessionLines(path)) {
+ if (!line.trim()) continue
+ try {
+ const parsed = JSON.parse(line) as unknown
+ if (parsed && typeof parsed === 'object') messages.push(parsed as VibeMessage)
+ } catch {
+ continue
+ }
+ }
+ return messages
+}
+
+function firstUserMessage(messages: VibeMessage[], fallback?: string | null): string {
+ for (const message of messages) {
+ if (message.role !== 'user') continue
+ const text = normalizeContent(message.content).trim()
+ if (text) return text.slice(0, 500)
+ }
+ return (fallback ?? '').slice(0, 500)
+}
+
+function createParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return {
+ async *parse(): AsyncGenerator {
+ const metadataPath = join(source.path, METADATA_FILENAME)
+ const messagesPath = join(source.path, MESSAGES_FILENAME)
+ const metadata = await readJsonFile(metadataPath)
+ if (!metadata) return
+
+ const stats = metadata.stats ?? {}
+ const inputTokens = safeNumber(stats.session_prompt_tokens)
+ const outputTokens = safeNumber(stats.session_completion_tokens)
+ if (inputTokens === 0 && outputTokens === 0) return
+
+ const sessionId = metadata.session_id || basename(source.path)
+ const deduplicationKey = `mistral-vibe:${sessionId}`
+ if (seenKeys.has(deduplicationKey)) return
+ seenKeys.add(deduplicationKey)
+
+ const messages = await readMessages(messagesPath)
+ const model = resolveModel(metadata)
+ const { tools, bashCommands } = extractTools(messages)
+ const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens)
+
+ yield {
+ provider: 'mistral-vibe',
+ model,
+ inputTokens,
+ outputTokens,
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 0,
+ cachedInputTokens: 0,
+ reasoningTokens: 0,
+ webSearchRequests: 0,
+ costUSD,
+ tools,
+ bashCommands,
+ timestamp: metadata.end_time ?? metadata.start_time ?? '',
+ speed: 'standard',
+ deduplicationKey,
+ userMessage: firstUserMessage(messages, metadata.title),
+ sessionId,
+ }
+ },
+ }
+}
+
+export function createMistralVibeProvider(sessionsDir?: string): Provider {
+ const dir = getMistralVibeSessionsDir(sessionsDir)
+
+ return {
+ name: 'mistral-vibe',
+ displayName: 'Mistral Vibe',
+
+ modelDisplayName(model: string): string {
+ return modelDisplayNames[model] ?? model
+ },
+
+ toolDisplayName(rawTool: string): string {
+ return toolNameMap[rawTool] ?? rawTool
+ },
+
+ async discoverSessions(): Promise {
+ const dirs = await discoverSessionDirs(dir)
+ const sources: SessionSource[] = []
+
+ for (const sessionDir of dirs) {
+ const metadata = await readJsonFile(join(sessionDir, METADATA_FILENAME))
+ if (!metadata) continue
+ const cwd = metadata.environment?.working_directory
+ sources.push({
+ path: sessionDir,
+ project: cwd ? basename(cwd) : basename(sessionDir),
+ provider: 'mistral-vibe',
+ })
+ }
+
+ return sources
+ },
+
+ createSessionParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return createParser(source, seenKeys)
+ },
+ }
+}
+
+export const mistralVibe = createMistralVibeProvider()
diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts
index 4497946..7f87747 100644
--- a/tests/provider-registry.test.ts
+++ b/tests/provider-registry.test.ts
@@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
- expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
+ expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
})
it('includes sqlite providers after async load', async () => {
diff --git a/tests/providers/mistral-vibe.test.ts b/tests/providers/mistral-vibe.test.ts
new file mode 100644
index 0000000..b6198d8
--- /dev/null
+++ b/tests/providers/mistral-vibe.test.ts
@@ -0,0 +1,284 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
+import { join } from 'path'
+import { tmpdir } from 'os'
+
+import { createMistralVibeProvider } from '../../src/providers/mistral-vibe.js'
+import type { ParsedProviderCall } from '../../src/providers/types.js'
+
+let tmpDir: string
+let originalVibeHome: string | undefined
+
+beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), 'mistral-vibe-test-'))
+ originalVibeHome = process.env['VIBE_HOME']
+ delete process.env['VIBE_HOME']
+})
+
+afterEach(async () => {
+ if (originalVibeHome === undefined) {
+ delete process.env['VIBE_HOME']
+ } else {
+ process.env['VIBE_HOME'] = originalVibeHome
+ }
+ await rm(tmpDir, { recursive: true, force: true })
+})
+
+function metadata(opts: {
+ sessionId?: string
+ cwd?: string
+ input?: number
+ output?: number
+ inputPrice?: number
+ outputPrice?: number
+ activeModel?: string
+ modelName?: string
+ configInputPrice?: number
+ configOutputPrice?: number
+ endTime?: string | null
+ title?: string
+} = {}) {
+ const activeModel = opts.activeModel ?? 'mistral-medium-3.5'
+ return {
+ session_id: opts.sessionId ?? 'session-abc123',
+ start_time: '2026-05-11T10:00:00+00:00',
+ end_time: Object.hasOwn(opts, 'endTime') ? opts.endTime : '2026-05-11T10:05:00+00:00',
+ environment: {
+ working_directory: opts.cwd ?? '/Users/test/mistral-project',
+ },
+ stats: {
+ session_prompt_tokens: opts.input ?? 2000,
+ session_completion_tokens: opts.output ?? 3000,
+ input_price_per_million: opts.inputPrice ?? 1.5,
+ output_price_per_million: opts.outputPrice ?? 7.5,
+ tokens_per_second: 42,
+ },
+ config: {
+ active_model: activeModel,
+ models: [
+ {
+ alias: activeModel,
+ name: opts.modelName ?? 'mistral-vibe-cli-latest',
+ provider: 'mistral',
+ input_price: opts.configInputPrice ?? 1.5,
+ output_price: opts.configOutputPrice ?? 7.5,
+ },
+ ],
+ },
+ title: opts.title ?? 'implement mistral support',
+ total_messages: 2,
+ }
+}
+
+function userMessage(content: unknown = 'implement mistral support') {
+ return {
+ role: 'user',
+ content,
+ message_id: 'msg-user-1',
+ }
+}
+
+function assistantMessage(toolCalls: Array<{ name: string; args?: Record | string }> = []) {
+ return {
+ role: 'assistant',
+ content: 'Done',
+ message_id: 'msg-assistant-1',
+ tool_calls: toolCalls.map((call, idx) => ({
+ id: `tool-${idx}`,
+ type: 'function',
+ function: {
+ name: call.name,
+ arguments: typeof call.args === 'string' ? call.args : JSON.stringify(call.args ?? {}),
+ },
+ })),
+ }
+}
+
+async function writeSession(
+ name: string,
+ meta: Record,
+ messages = [userMessage(), assistantMessage()],
+ root = tmpDir,
+) {
+ const sessionDir = join(root, name)
+ await mkdir(sessionDir, { recursive: true })
+ await writeFile(join(sessionDir, 'meta.json'), JSON.stringify(meta, null, 2))
+ await writeFile(join(sessionDir, 'messages.jsonl'), messages.map(m => JSON.stringify(m)).join('\n') + '\n')
+ return sessionDir
+}
+
+async function collect(sourcePath: string, provider = createMistralVibeProvider(tmpDir)): Promise {
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser({
+ path: sourcePath,
+ project: 'mistral-project',
+ provider: 'mistral-vibe',
+ }, new Set()).parse()) {
+ calls.push(call)
+ }
+ return calls
+}
+
+describe('mistral-vibe provider - session discovery', () => {
+ it('discovers Vibe session folders and derives project from metadata cwd', async () => {
+ const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
+ sessionId: 'session-a',
+ cwd: '/Users/test/project-a',
+ }))
+ await mkdir(join(tmpDir, 'not-a-session'), { recursive: true })
+
+ const provider = createMistralVibeProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+
+ expect(sessions).toHaveLength(1)
+ expect(sessions[0]).toEqual({
+ path: sessionDir,
+ project: 'project-a',
+ provider: 'mistral-vibe',
+ })
+ })
+
+ it('discovers subagent session folders nested under agents', async () => {
+ const parentDir = await writeSession('session_20260511_100000_parent', metadata({
+ sessionId: 'parent-session',
+ cwd: '/Users/test/parent-project',
+ }))
+ const childDir = await writeSession('session_20260511_100001_child', metadata({
+ sessionId: 'child-session',
+ cwd: '/Users/test/child-project',
+ }), [userMessage('child task'), assistantMessage()], join(parentDir, 'agents'))
+
+ const provider = createMistralVibeProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+
+ expect(sessions.map(s => s.path).sort()).toEqual([childDir, parentDir].sort())
+ expect(sessions.map(s => s.project).sort()).toEqual(['child-project', 'parent-project'])
+ })
+
+ it('returns empty for a missing Vibe sessions directory', async () => {
+ const provider = createMistralVibeProvider('/missing/vibe/logs/session')
+ await expect(provider.discoverSessions()).resolves.toEqual([])
+ })
+
+ it('uses VIBE_HOME when no override directory is provided', async () => {
+ const vibeHome = join(tmpDir, 'vibe-home')
+ process.env['VIBE_HOME'] = vibeHome
+ const sessionsDir = join(vibeHome, 'logs', 'session')
+ await writeSession('session_20260511_100000_sessiona', metadata({
+ sessionId: 'env-session',
+ cwd: '/Users/test/env-project',
+ }), [userMessage(), assistantMessage()], sessionsDir)
+
+ const provider = createMistralVibeProvider()
+ const sessions = await provider.discoverSessions()
+
+ expect(sessions).toHaveLength(1)
+ expect(sessions[0]!.project).toBe('env-project')
+ })
+})
+
+describe('mistral-vibe provider - parsing', () => {
+ it('parses cumulative session usage, tools, bash commands, and first user message', async () => {
+ const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata(), [
+ userMessage([{ type: 'text', text: 'track Mistral Vibe usage' }]),
+ assistantMessage([
+ { name: 'read_file', args: { path: 'src/index.ts' } },
+ { name: 'search_replace', args: { file_path: 'src/index.ts', content: 'patch' } },
+ { name: 'bash', args: { command: 'npm test && git status' } },
+ ]),
+ ])
+
+ const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
+
+ expect(calls).toHaveLength(1)
+ const call = calls[0]!
+ expect(call.provider).toBe('mistral-vibe')
+ expect(call.model).toBe('mistral-medium-3.5')
+ expect(call.inputTokens).toBe(2000)
+ expect(call.outputTokens).toBe(3000)
+ expect(call.costUSD).toBeCloseTo(0.0255, 8)
+ expect(call.tools).toEqual(['Read', 'Edit', 'Bash'])
+ expect(call.bashCommands).toEqual(['npm', 'git'])
+ expect(call.timestamp).toBe('2026-05-11T10:05:00+00:00')
+ expect(call.userMessage).toBe('track Mistral Vibe usage')
+ expect(call.sessionId).toBe('session-abc123')
+ expect(call.deduplicationKey).toBe('mistral-vibe:session-abc123')
+ })
+
+ it('uses configured model prices when stats omit prices', async () => {
+ const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
+ inputPrice: 0,
+ outputPrice: 0,
+ input: 1000,
+ output: 1000,
+ }))
+
+ const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.costUSD).toBeCloseTo(0.009, 8)
+ })
+
+ it('falls back to LiteLLM pricing when Vibe does not provide prices', async () => {
+ const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
+ activeModel: 'claude-sonnet-4-6',
+ modelName: 'claude-sonnet-4-6',
+ input: 1000,
+ output: 1000,
+ inputPrice: 0,
+ outputPrice: 0,
+ configInputPrice: 0,
+ configOutputPrice: 0,
+ }))
+
+ const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.costUSD).toBeCloseTo(0.018, 8)
+ })
+
+ it('falls back to start_time when end_time is missing', async () => {
+ const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
+ endTime: null,
+ }))
+
+ const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
+
+ expect(calls[0]!.timestamp).toBe('2026-05-11T10:00:00+00:00')
+ })
+
+ it('deduplicates by session id', async () => {
+ const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata())
+ const provider = createMistralVibeProvider(tmpDir)
+ const source = { path: sessionDir, project: 'mistral-project', provider: 'mistral-vibe' }
+ const seen = new Set()
+
+ const first: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call)
+ const second: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call)
+
+ expect(first).toHaveLength(1)
+ expect(second).toHaveLength(0)
+ })
+
+ it('skips sessions without cumulative token usage', async () => {
+ const sessionDir = await writeSession('session_20260511_100000_empty', metadata({
+ input: 0,
+ output: 0,
+ }))
+
+ const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
+
+ expect(calls).toEqual([])
+ })
+
+ it('formats model and tool display names', () => {
+ const provider = createMistralVibeProvider(tmpDir)
+
+ expect(provider.modelDisplayName('mistral-medium-3.5')).toBe('Mistral Medium 3.5')
+ expect(provider.modelDisplayName('devstral-small-latest')).toBe('Devstral Small')
+ expect(provider.toolDisplayName('search_replace')).toBe('Edit')
+ expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
+ })
+})