f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
477 lines
16 KiB
Go
477 lines
16 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// VibeMessage represents a single message in a Mistral Vibe session
|
|
type VibeMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content,omitempty"`
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
ToolCalls []VibeToolCall `json:"tool_calls,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
MessageID string `json:"message_id,omitempty"`
|
|
Injected bool `json:"injected,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
}
|
|
|
|
// VibeToolCall represents a tool invocation in Vibe
|
|
type VibeToolCall struct {
|
|
ID string `json:"id"`
|
|
Index int `json:"index,omitempty"`
|
|
Function VibeToolCallFunction `json:"function"`
|
|
}
|
|
|
|
// VibeToolCallFunction represents the function details of a tool call
|
|
type VibeToolCallFunction struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
|
|
// VibeSessionMetadata represents session-level metadata from meta.json
|
|
type VibeSessionMetadata struct {
|
|
SessionID string `json:"session_id"`
|
|
ParentSessionID *string `json:"parent_session_id"`
|
|
StartTime time.Time `json:"start_time"`
|
|
EndTime time.Time `json:"end_time"`
|
|
GitCommit string `json:"git_commit,omitempty"`
|
|
GitBranch string `json:"git_branch,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
WorkingDir string `json:"working_dir,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Stats VibeStats `json:"stats,omitempty"`
|
|
Environment struct {
|
|
WorkingDirectory string `json:"working_directory,omitempty"`
|
|
} `json:"environment,omitempty"`
|
|
Config struct {
|
|
ActiveModel string `json:"active_model,omitempty"`
|
|
} `json:"config,omitempty"`
|
|
}
|
|
|
|
// VibeStats represents token usage statistics from meta.json
|
|
type VibeStats struct {
|
|
Steps int `json:"steps"`
|
|
SessionPromptTokens int `json:"session_prompt_tokens"`
|
|
SessionCompletionTokens int `json:"session_completion_tokens"`
|
|
ContextTokens int `json:"context_tokens"`
|
|
LastTurnPromptTokens int `json:"last_turn_prompt_tokens"`
|
|
LastTurnCompletionTokens int `json:"last_turn_completion_tokens"`
|
|
SessionTotalLLMTokens int64 `json:"session_total_llm_tokens"`
|
|
LastTurnTotalTokens int `json:"last_turn_total_tokens"`
|
|
}
|
|
|
|
// parseVibeResult parses a Mistral Vibe messages.jsonl file into a ParseResult.
|
|
// It owns the on-disk shape (messages.jsonl plus the sibling meta.json) for the
|
|
// Vibe provider; the package-level entrypoint was folded onto the provider.
|
|
func parseVibeResultFile(path string, fileInfo FileInfo) (ParseResult, error) {
|
|
result := ParseResult{
|
|
Session: ParsedSession{
|
|
Agent: AgentVibe,
|
|
File: fileInfo,
|
|
StartedAt: time.Unix(0, fileInfo.Mtime),
|
|
EndedAt: time.Unix(0, fileInfo.Mtime),
|
|
},
|
|
}
|
|
|
|
// Extract session ID from directory name as a fallback. The "vibe:"
|
|
// prefix is always applied so prefix-based routing (AgentByPrefix,
|
|
// FindSourceFile) works even when meta.json is missing.
|
|
// Path format: .../session/session_YYYYMMDD_HHMMSS_uuid/messages.jsonl
|
|
dir := filepath.Dir(path)
|
|
result.Session.ID = "vibe:" + filepath.Base(dir)
|
|
result.Session.Project = "vibe"
|
|
|
|
// Try to parse meta.json for additional metadata
|
|
var sessionModel string
|
|
var sessionStats VibeStats
|
|
var hasMetaData bool
|
|
metaPath := filepath.Join(dir, "meta.json")
|
|
metaData, metaErr := parseVibeMetadata(metaPath)
|
|
switch {
|
|
case metaErr != nil && errors.Is(metaErr, os.ErrNotExist):
|
|
// meta.json has not been written yet (a freshly created session):
|
|
// keep the directory-name fallback ID set above.
|
|
case metaErr != nil:
|
|
// meta.json exists but the full parse failed: a partial write or a
|
|
// single malformed optional field. Recover the canonical session_id
|
|
// from a tolerant minimal parse so a transient error does not abandon
|
|
// the canonical session row for the directory-name fallback (which
|
|
// would exclude and re-create the row, dropping pins and usage). If
|
|
// even the minimal parse fails, surface the error so the sync retries
|
|
// and leaves the existing row untouched rather than replacing it.
|
|
id, idErr := parseVibeSessionID(metaPath)
|
|
if idErr != nil {
|
|
return result, fmt.Errorf(
|
|
"parsing Vibe meta.json %s: %w", metaPath, metaErr,
|
|
)
|
|
}
|
|
if id != "" {
|
|
result.Session.ID = "vibe:" + id
|
|
result.Session.SourceSessionID = id
|
|
}
|
|
default:
|
|
hasMetaData = true
|
|
sessionStats = metaData.Stats
|
|
// Use the actual session_id from meta.json as the canonical ID
|
|
if metaData.SessionID != "" {
|
|
result.Session.ID = "vibe:" + metaData.SessionID
|
|
result.Session.SourceSessionID = metaData.SessionID
|
|
}
|
|
if metaData.Title != "" {
|
|
result.Session.SessionName = metaData.Title
|
|
}
|
|
if !metaData.StartTime.IsZero() {
|
|
result.Session.StartedAt = metaData.StartTime
|
|
}
|
|
if !metaData.EndTime.IsZero() {
|
|
result.Session.EndedAt = metaData.EndTime
|
|
}
|
|
if metaData.Model != "" {
|
|
sessionModel = metaData.Model
|
|
}
|
|
if metaData.WorkingDir != "" {
|
|
result.Session.Cwd = metaData.WorkingDir
|
|
// Derive a human-readable project name from the working
|
|
// directory (the enclosing git repo, or its basename),
|
|
// matching how Claude sessions are grouped. Falls back to
|
|
// "vibe" when no working directory is recorded.
|
|
if p := ExtractProjectFromCwdWithBranch(
|
|
metaData.WorkingDir, metaData.GitBranch,
|
|
); p != "" {
|
|
result.Session.Project = p
|
|
}
|
|
}
|
|
if metaData.GitBranch != "" {
|
|
result.Session.GitBranch = metaData.GitBranch
|
|
}
|
|
if metaData.GitCommit != "" {
|
|
result.Session.SourceVersion = metaData.GitCommit
|
|
}
|
|
// Extract token usage from stats
|
|
if metaData.Stats.SessionCompletionTokens > 0 {
|
|
result.Session.HasTotalOutputTokens = true
|
|
result.Session.TotalOutputTokens = metaData.Stats.SessionCompletionTokens
|
|
}
|
|
if metaData.Stats.ContextTokens > 0 {
|
|
result.Session.HasPeakContextTokens = true
|
|
result.Session.PeakContextTokens = metaData.Stats.ContextTokens
|
|
} else if metaData.Stats.SessionPromptTokens > 0 {
|
|
result.Session.HasPeakContextTokens = true
|
|
result.Session.PeakContextTokens = metaData.Stats.SessionPromptTokens
|
|
}
|
|
|
|
// Handle parent session relationship. The parent reference is a
|
|
// bare session_id, so prefix it to match the canonical ID scheme.
|
|
if metaData.ParentSessionID != nil && *metaData.ParentSessionID != "" {
|
|
result.Session.ParentSessionID = "vibe:" + *metaData.ParentSessionID
|
|
result.Session.RelationshipType = RelContinuation
|
|
}
|
|
}
|
|
|
|
// Parse messages.jsonl
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return result, fmt.Errorf("failed to open Vibe session file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
lr := newLineReader(file, maxLineSize)
|
|
defer releaseLineReader(lr)
|
|
messageOrdinal := 0
|
|
var firstUserContent string
|
|
|
|
for {
|
|
line, ok := lr.next()
|
|
if !ok {
|
|
break
|
|
}
|
|
|
|
var vibeMsg VibeMessage
|
|
if err := json.Unmarshal([]byte(line), &vibeMsg); err != nil {
|
|
result.Session.MalformedLines++
|
|
continue
|
|
}
|
|
|
|
// Try to extract session model from first assistant message if not set yet
|
|
if sessionModel == "" && vibeMsg.Role == "assistant" && vibeMsg.Model != "" {
|
|
sessionModel = vibeMsg.Model
|
|
}
|
|
|
|
// Tool results are separate "tool" records linked back to the
|
|
// assistant's tool call via tool_call_id. Emit them as an empty
|
|
// RoleUser carrier message (matching the Hermes/QClaw/OpenClaw
|
|
// convention) so the sync engine's pairToolResults can attach the
|
|
// result to the originating tool call by ID; the carrier message
|
|
// itself is filtered out of the visible transcript afterward.
|
|
if vibeMsg.Role == "tool" {
|
|
if vibeMsg.ToolCallID == "" {
|
|
continue
|
|
}
|
|
quoted, err := json.Marshal(vibeMsg.Content)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result.Messages = append(result.Messages, ParsedMessage{
|
|
Ordinal: messageOrdinal,
|
|
Role: RoleUser,
|
|
Content: "",
|
|
ContentLength: len(vibeMsg.Content),
|
|
ToolResults: []ParsedToolResult{{
|
|
ToolUseID: vibeMsg.ToolCallID,
|
|
ContentRaw: string(quoted),
|
|
ContentLength: len(vibeMsg.Content),
|
|
}},
|
|
})
|
|
messageOrdinal++
|
|
continue
|
|
}
|
|
|
|
// Convert Vibe message to AgentsView ParsedMessage
|
|
msg, _ := convertVibeMessage(vibeMsg, messageOrdinal, sessionModel)
|
|
result.Messages = append(result.Messages, msg)
|
|
|
|
// Track first user message content for session metadata. Skip
|
|
// system/injected context so it never becomes the session's first
|
|
// message.
|
|
if firstUserContent == "" && msg.Role == RoleUser &&
|
|
!msg.IsSystem && msg.Content != "" {
|
|
firstUserContent = msg.Content
|
|
}
|
|
|
|
messageOrdinal++
|
|
}
|
|
|
|
if err := lr.Err(); err != nil {
|
|
return result, fmt.Errorf("failed to read Vibe session file: %w", err)
|
|
}
|
|
|
|
// Set session metadata from messages
|
|
if len(result.Messages) > 0 {
|
|
result.Session.MessageCount = len(result.Messages)
|
|
result.Session.FirstMessage = firstUserContent
|
|
}
|
|
|
|
// Count real user messages, excluding system/injected context and the
|
|
// empty tool-result carrier messages emitted for "tool" records (which
|
|
// carry RoleUser to satisfy pairToolResults).
|
|
for _, msg := range result.Messages {
|
|
if msg.Role == RoleUser && !msg.IsSystem && len(msg.ToolResults) == 0 {
|
|
result.Session.UserMessageCount++
|
|
}
|
|
}
|
|
|
|
// Create usage events from session stats if we have stats, a model, and any token data
|
|
if hasMetaData && sessionModel != "" {
|
|
if usageEvents := vibeUsageEvents(
|
|
sessionStats, sessionModel, result.Session.ID,
|
|
result.Session.StartedAt, result.Session.EndedAt,
|
|
); len(usageEvents) > 0 {
|
|
result.UsageEvents = usageEvents
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// parseVibeMetadata parses the meta.json file for session-level metadata
|
|
func parseVibeMetadata(path string) (VibeSessionMetadata, error) {
|
|
var meta VibeSessionMetadata
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return meta, err
|
|
}
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return meta, err
|
|
}
|
|
|
|
// Extract working directory from environment if not at top level
|
|
if meta.WorkingDir == "" && meta.Environment.WorkingDirectory != "" {
|
|
meta.WorkingDir = meta.Environment.WorkingDirectory
|
|
}
|
|
|
|
// Vibe records the model under config.active_model rather than a
|
|
// top-level "model" field, so fall back to it. Without this the
|
|
// session has no model and no usage event is emitted.
|
|
if meta.Model == "" && meta.Config.ActiveModel != "" {
|
|
meta.Model = meta.Config.ActiveModel
|
|
}
|
|
|
|
return meta, nil
|
|
}
|
|
|
|
// parseVibeSessionID extracts only the canonical session_id from meta.json
|
|
// using a minimal tolerant struct. A malformed optional field (for example a
|
|
// bad timestamp) fails the full VibeSessionMetadata parse but must not cost the
|
|
// session its stable identity, so the id is recovered independently. Returns an
|
|
// error when the file cannot be read or is not even minimally valid JSON.
|
|
func parseVibeSessionID(path string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var meta struct {
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return "", err
|
|
}
|
|
return meta.SessionID, nil
|
|
}
|
|
|
|
// convertVibeMessage converts a VibeMessage to a ParsedMessage
|
|
func convertVibeMessage(vibeMsg VibeMessage, ordinal int, defaultModel string) (ParsedMessage, []ParsedToolCall) {
|
|
msg := ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleType(vibeMsg.Role),
|
|
Content: vibeMsg.Content,
|
|
ContentLength: len(vibeMsg.Content),
|
|
Model: vibeMsg.Model,
|
|
SourceUUID: vibeMsg.MessageID,
|
|
}
|
|
|
|
// Use default model if message doesn't have one
|
|
if msg.Model == "" && defaultModel != "" {
|
|
msg.Model = defaultModel
|
|
}
|
|
|
|
// Handle reasoning content as thinking
|
|
if vibeMsg.ReasoningContent != "" {
|
|
msg.ThinkingText = vibeMsg.ReasoningContent
|
|
msg.HasThinking = true
|
|
}
|
|
|
|
// Handle system messages. Injected records carry system context (often
|
|
// under a user role), so mark them system too; they are then excluded from
|
|
// first-message and user-message-count derivation.
|
|
if vibeMsg.Role == "system" || vibeMsg.Injected {
|
|
msg.IsSystem = true
|
|
}
|
|
|
|
var toolCalls []ParsedToolCall
|
|
|
|
// Convert tool calls
|
|
for _, tc := range vibeMsg.ToolCalls {
|
|
toolCall := ParsedToolCall{
|
|
ToolUseID: tc.ID,
|
|
ToolName: tc.Function.Name,
|
|
Category: NormalizeToolCategory(tc.Function.Name),
|
|
InputJSON: vibeToolArguments(tc.Function.Arguments),
|
|
}
|
|
toolCalls = append(toolCalls, toolCall)
|
|
}
|
|
|
|
if len(toolCalls) > 0 {
|
|
msg.HasToolUse = true
|
|
msg.ToolCalls = toolCalls
|
|
}
|
|
|
|
return msg, toolCalls
|
|
}
|
|
|
|
// vibeToolArguments normalizes a tool call's arguments to a raw JSON object.
|
|
// Vibe (like the OpenAI/Mistral wire format) may encode arguments either as a
|
|
// nested JSON object or as a JSON-encoded string; the latter is unwrapped so
|
|
// InputJSON is always the underlying object.
|
|
func vibeToolArguments(args json.RawMessage) string {
|
|
if len(args) > 0 && args[0] == '"' {
|
|
var s string
|
|
if err := json.Unmarshal(args, &s); err == nil {
|
|
return s
|
|
}
|
|
}
|
|
return string(args)
|
|
}
|
|
|
|
// parseSession parses a Vibe session at path and returns the session, messages,
|
|
// and usage events in the shape the provider consumes: (*ParsedSession,
|
|
// []ParsedMessage, []ParsedUsageEvent, error). It stats the file to build
|
|
// FileInfo and optionally overrides the project and machine.
|
|
func parseVibeSession(path, project, machine string) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Size: info.Size(),
|
|
Mtime: info.ModTime().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeResultFile(path, fileInfo)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
// Override project if provided
|
|
if project != "" {
|
|
result.Session.Project = project
|
|
}
|
|
|
|
// Override machine if provided
|
|
if machine != "" {
|
|
result.Session.Machine = machine
|
|
}
|
|
|
|
return &result.Session, result.Messages, result.UsageEvents, nil
|
|
}
|
|
|
|
// vibeUsageEvents creates usage events from Vibe session stats.
|
|
// Vibe stores aggregate token usage in meta.json stats, so we emit a single
|
|
// session-level usage event similar to Hermes.
|
|
func vibeUsageEvents(
|
|
stats VibeStats, model, sessionID string, startedAt, endedAt time.Time,
|
|
) []ParsedUsageEvent {
|
|
// Only emit an event if we have a model and at least some token usage data
|
|
if model == "" {
|
|
return nil
|
|
}
|
|
if stats.SessionPromptTokens == 0 && stats.SessionCompletionTokens == 0 &&
|
|
stats.ContextTokens == 0 && stats.SessionTotalLLMTokens == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Use SessionPromptTokens for input tokens and SessionCompletionTokens for output tokens.
|
|
// SessionTotalLLMTokens appears to be the sum of input + output tokens,
|
|
// so we don't use it as a direct source but it can serve as validation.
|
|
inputTokens := stats.SessionPromptTokens
|
|
outputTokens := stats.SessionCompletionTokens
|
|
|
|
return []ParsedUsageEvent{{
|
|
SessionID: sessionID,
|
|
Source: "session",
|
|
Model: model,
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
// Vibe doesn't currently expose cache token breakdown in meta.json
|
|
CacheCreationInputTokens: 0,
|
|
CacheReadInputTokens: 0,
|
|
ReasoningTokens: 0,
|
|
// Vibe doesn't currently expose cost information
|
|
CostUSD: nil,
|
|
CostStatus: "",
|
|
CostSource: "",
|
|
OccurredAt: vibeTimeString(endedAt, startedAt),
|
|
DedupKey: "session:" + sessionID,
|
|
}}
|
|
}
|
|
|
|
// vibeTimeString returns the RFC3339Nano formatted string for primary time,
|
|
// falling back to fallback if primary is zero.
|
|
func vibeTimeString(primary, fallback time.Time) string {
|
|
if !primary.IsZero() {
|
|
return primary.Format(time.RFC3339Nano)
|
|
}
|
|
if !fallback.IsZero() {
|
|
return fallback.Format(time.RFC3339Nano)
|
|
}
|
|
return ""
|
|
}
|