Merge pull request #301 from ozymandiashh/feat/mistral-vibe-provider

Support Mistral Vibe sessions
This commit is contained in:
Resham Joshi 2026-05-16 07:35:32 -07:00 committed by GitHub
commit 7777bf80bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 729 additions and 2 deletions

View file

@ -3,6 +3,12 @@
## 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.
- **Kimi Code CLI provider.** CodeBurn now reads Kimi session usage from
`$KIMI_SHARE_DIR/sessions/` or `~/.kimi/sessions/`, including subagent
`wire.jsonl` files. The parser consumes Kimi's official `StatusUpdate`

View file

@ -104,6 +104,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
| <img src="assets/providers/cursor.jpg" width="28" /> | Cursor | Yes | [cursor.md](docs/providers/cursor.md) |
| <img src="assets/providers/cursor-agent.jpg" width="28" /> | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) |
| <img src="assets/providers/gemini.png" width="28" /> | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) |
| <img src="assets/providers/mistral-vibe.svg" width="28" /> | Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) |
| <img src="assets/providers/copilot.jpg" width="28" /> | GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) |
| <img src="assets/providers/ibm-bob.svg" width="28" /> | IBM Bob | Yes | [ibm-bob.md](docs/providers/ibm-bob.md) |
| <img src="assets/providers/kiro.png" width="28" /> | Kiro | Yes | [kiro.md](docs/providers/kiro.md) |
@ -134,6 +135,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.
@ -379,6 +382,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/<project>/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.
**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`.
@ -400,6 +405,7 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) |
| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model |
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
## Sponsoring CodeBurn

View file

@ -0,0 +1,12 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.8147 5.35803H5.35791V8.46914H8.8147V5.35803Z" fill="black"/>
<path d="M22.6419 5.35803H19.1851V8.46914H22.6419V5.35803Z" fill="black"/>
<path d="M15.7283 15.7284H12.2715V18.8395H15.7283V15.7284Z" fill="black"/>
<path d="M8.8147 15.7284H5.35791V18.8395H8.8147V15.7284Z" fill="black"/>
<path d="M22.6419 15.7284H19.1851V18.8395H22.6419V15.7284Z" fill="black"/>
<path d="M12.2715 8.81482H5.35791V11.9259H12.2715V8.81482Z" fill="black"/>
<path d="M12.2718 19.1852H1.90137V22.2963H12.2718V19.1852Z" fill="black"/>
<path d="M26.0989 19.1852H15.7285V22.2963H26.0989V19.1852Z" fill="black"/>
<path d="M22.6419 12.2716H5.35791V15.3827H22.6419V12.2716Z" fill="black"/>
<path d="M22.6421 8.81482H15.7285V11.9259H22.6421V8.81482Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 849 B

View file

@ -20,6 +20,7 @@ For the architectural picture, see `../architecture.md`.
| [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` |
| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.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` |

View file

@ -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:<session_id>`.
## 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.

View file

@ -8,6 +8,7 @@ import { ibmBob } from './ibm-bob.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
import { kimi } from './kimi.js'
import { mistralVibe } from './mistral-vibe.js'
import { openclaw } from './openclaw.js'
import { pi, omp } from './pi.js'
import { qwen } from './qwen.js'
@ -104,7 +105,7 @@ async function loadCrush(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, openclaw, pi, omp, qwen, rooCode]
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise<Provider[]> {
const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()])

View file

@ -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<string, string> = {
'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<string, string> = {
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<string, unknown> | 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<boolean> {
const s = await stat(path).catch(() => null)
return Boolean(s?.isFile())
}
async function isDirectory(path: string): Promise<boolean> {
const s = await stat(path).catch(() => null)
return Boolean(s?.isDirectory())
}
async function hasSessionFiles(dir: string): Promise<boolean> {
const [hasMetadata, hasMessages] = await Promise.all([
isFile(join(dir, METADATA_FILENAME)),
isFile(join(dir, MESSAGES_FILENAME)),
])
return hasMetadata && hasMessages
}
async function readJsonFile<T>(path: string): Promise<T | null> {
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<string[]> {
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<string, unknown> | null | undefined): Record<string, unknown> {
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<string, unknown> : {}
} 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<VibeMessage[]> {
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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const metadataPath = join(source.path, METADATA_FILENAME)
const messagesPath = join(source.path, MESSAGES_FILENAME)
const metadata = await readJsonFile<VibeMetadata>(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<SessionSource[]> {
const dirs = await discoverSessionDirs(dir)
const sources: SessionSource[] = []
for (const sessionDir of dirs) {
const metadata = await readJsonFile<VibeMetadata>(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<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const mistralVibe = createMistralVibeProvider()

View file

@ -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', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
})
it('includes sqlite providers after async load', async () => {

View file

@ -0,0 +1,305 @@
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, unknown> | 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<string, unknown>,
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<ParsedProviderCall[]> {
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<string>()
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('skips sessions with malformed meta.json', async () => {
const sessionDir = join(tmpDir, 'session_20260511_100000_bad')
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'meta.json'), '{{not json')
await writeFile(join(sessionDir, 'messages.jsonl'), JSON.stringify(userMessage()) + '\n')
const provider = createMistralVibeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('returns empty calls when messages.jsonl is malformed', async () => {
const sessionDir = await writeSession('session_20260511_100000_badjsonl', metadata())
await writeFile(join(sessionDir, 'messages.jsonl'), '{{not json\n{{also bad\n')
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual([])
expect(calls[0]!.bashCommands).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')
})
})