f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
526 lines
13 KiB
Go
526 lines
13 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// reasonixMessage represents a single line in a Reasonix JSONL
|
|
// transcript.
|
|
type reasonixMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
ToolCalls []reasonixToolCall `json:"tool_calls"`
|
|
ToolCallID string `json:"tool_call_id"`
|
|
}
|
|
|
|
// reasonixToolCall represents a tool call in a Reasonix message.
|
|
type reasonixToolCall struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
}
|
|
|
|
// reasonixMetadata represents the .jsonl.meta sidecar file format.
|
|
type reasonixMetadata struct {
|
|
ID string `json:"id"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Scope string `json:"scope"`
|
|
WorkspaceRoot string `json:"workspace_root"`
|
|
TopicTitle string `json:"topic_title"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
// reasonixSessionBuilder accumulates state while scanning a
|
|
// Reasonix JSONL session file line by line.
|
|
type reasonixSessionBuilder struct {
|
|
messages []ParsedMessage
|
|
firstMessage string
|
|
startedAt time.Time
|
|
endedAt time.Time
|
|
sessionID string
|
|
ordinal int
|
|
model string
|
|
}
|
|
|
|
func newReasonixSessionBuilder() *reasonixSessionBuilder {
|
|
return &reasonixSessionBuilder{}
|
|
}
|
|
|
|
// processLine handles a single non-empty, valid JSON line.
|
|
func (b *reasonixSessionBuilder) processLine(line string) error {
|
|
var msg reasonixMessage
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
return nil // skip invalid lines silently
|
|
}
|
|
|
|
if msg.Role == "" {
|
|
return nil
|
|
}
|
|
|
|
roleName := strings.ToLower(msg.Role)
|
|
if roleName == "tool" {
|
|
return b.processToolResult(msg)
|
|
}
|
|
|
|
// Extract and normalize role
|
|
var role RoleType
|
|
switch roleName {
|
|
case "user":
|
|
role = RoleUser
|
|
case "assistant":
|
|
role = RoleAssistant
|
|
default:
|
|
// Skip unsupported roles (system, tool, etc.)
|
|
return nil
|
|
}
|
|
|
|
// Normalize content
|
|
content := strings.TrimSpace(msg.Content)
|
|
|
|
// For assistant messages, prepend reasoning content if present
|
|
if role == RoleAssistant && msg.ReasoningContent != "" {
|
|
thinkBlock := "[Thinking]\n" + strings.TrimSpace(msg.ReasoningContent) + "\n[/Thinking]"
|
|
if content != "" {
|
|
content = thinkBlock + "\n\n" + content
|
|
} else {
|
|
content = thinkBlock
|
|
}
|
|
}
|
|
|
|
// Extract tool calls
|
|
hasToolUse := len(msg.ToolCalls) > 0
|
|
var toolCalls []ParsedToolCall
|
|
for _, tc := range msg.ToolCalls {
|
|
toolCalls = append(toolCalls, ParsedToolCall{
|
|
ToolUseID: tc.ID,
|
|
ToolName: tc.Name,
|
|
Category: NormalizeToolCategory(tc.Name),
|
|
InputJSON: tc.Arguments,
|
|
})
|
|
}
|
|
|
|
// Skip messages with no content and no tool calls
|
|
if content == "" && !hasToolUse {
|
|
return nil
|
|
}
|
|
|
|
// Update first message for user messages
|
|
if role == RoleUser && b.firstMessage == "" && content != "" {
|
|
b.firstMessage = truncate(
|
|
strings.ReplaceAll(content, "\n", " "), 300,
|
|
)
|
|
}
|
|
|
|
// Message ordering is tracked via ordinal; timestamps are
|
|
// set from metadata or file mtime after parsing completes.
|
|
|
|
b.messages = append(b.messages, ParsedMessage{
|
|
Ordinal: b.ordinal,
|
|
Role: role,
|
|
Content: content,
|
|
ContentLength: len(content),
|
|
HasThinking: msg.ReasoningContent != "",
|
|
HasToolUse: hasToolUse,
|
|
ToolCalls: toolCalls,
|
|
Model: b.model,
|
|
})
|
|
b.ordinal++
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *reasonixSessionBuilder) processToolResult(
|
|
msg reasonixMessage,
|
|
) error {
|
|
if msg.ToolCallID == "" {
|
|
return nil
|
|
}
|
|
|
|
content := msg.Content
|
|
quoted, err := json.Marshal(content)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
b.messages = append(b.messages, ParsedMessage{
|
|
Ordinal: b.ordinal,
|
|
Role: RoleUser,
|
|
Content: "",
|
|
ContentLength: len(content),
|
|
ToolResults: []ParsedToolResult{{
|
|
ToolUseID: msg.ToolCallID,
|
|
ContentRaw: string(quoted),
|
|
ContentLength: len(content),
|
|
}},
|
|
})
|
|
b.ordinal++
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseReasonixSession parses a Reasonix JSONL session file.
|
|
// Returns (nil, nil, nil, nil) if the file doesn't exist or
|
|
// contains no user/assistant messages.
|
|
func parseReasonixSession(
|
|
path, machine string,
|
|
) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil, nil, nil
|
|
}
|
|
return nil, nil, nil, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
lr := newLineReader(f, maxLineSize)
|
|
defer releaseLineReader(lr)
|
|
b := newReasonixSessionBuilder()
|
|
|
|
// Extract session ID from path
|
|
b.sessionID = sessionIDFromReasonixPath(path)
|
|
|
|
// Load metadata from .jsonl.meta sidecar if present.
|
|
meta, err := loadReasonixMetadata(path)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
sessionName := ""
|
|
cwd := ""
|
|
project := ""
|
|
if meta != nil {
|
|
if meta.Model != "" {
|
|
b.model = meta.Model
|
|
}
|
|
sessionName = strings.TrimSpace(meta.TopicTitle)
|
|
cwd = strings.TrimSpace(meta.WorkspaceRoot)
|
|
if cwd != "" {
|
|
project = ExtractProjectFromCwd(cwd)
|
|
}
|
|
var gotStart, gotEnd bool
|
|
var metaStart, metaEnd time.Time
|
|
if meta.CreatedAt != "" {
|
|
if t, err := time.Parse(time.RFC3339Nano, meta.CreatedAt); err == nil {
|
|
metaStart = t
|
|
gotStart = true
|
|
}
|
|
}
|
|
if meta.UpdatedAt != "" {
|
|
if t, err := time.Parse(time.RFC3339Nano, meta.UpdatedAt); err == nil {
|
|
metaEnd = t
|
|
gotEnd = true
|
|
}
|
|
}
|
|
if gotStart && gotEnd {
|
|
b.startedAt = metaStart
|
|
b.endedAt = metaEnd
|
|
}
|
|
}
|
|
|
|
// Parse JSONL lines
|
|
for {
|
|
line, ok := lr.next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
_ = b.processLine(line)
|
|
}
|
|
|
|
if err := lr.Err(); err != nil {
|
|
return nil, nil, nil,
|
|
fmt.Errorf("reading reasonix %s: %w", path, err)
|
|
}
|
|
|
|
// Filter: require at least one message with content.
|
|
hasContent := false
|
|
for _, m := range b.messages {
|
|
if m.Content != "" {
|
|
hasContent = true
|
|
break
|
|
}
|
|
}
|
|
if !hasContent {
|
|
return nil, nil, nil, nil
|
|
}
|
|
|
|
// Fall back to file mtime for sessions without metadata timestamps
|
|
if b.startedAt.IsZero() {
|
|
b.startedAt = info.ModTime()
|
|
}
|
|
if b.endedAt.IsZero() {
|
|
b.endedAt = info.ModTime()
|
|
}
|
|
|
|
sessionID := "reasonix:" + b.sessionID
|
|
|
|
userCount := 0
|
|
for _, m := range b.messages {
|
|
if m.Role == RoleUser && m.Content != "" {
|
|
userCount++
|
|
}
|
|
}
|
|
|
|
sess := &ParsedSession{
|
|
ID: sessionID,
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: AgentReasonix,
|
|
Cwd: cwd,
|
|
FirstMessage: b.firstMessage,
|
|
SessionName: sessionName,
|
|
StartedAt: b.startedAt,
|
|
EndedAt: b.endedAt,
|
|
MessageCount: len(b.messages),
|
|
UserMessageCount: userCount,
|
|
File: FileInfo{
|
|
Path: path,
|
|
Size: info.Size(),
|
|
Mtime: info.ModTime().UnixNano(),
|
|
},
|
|
}
|
|
|
|
accumulateMessageTokenUsage(sess, b.messages)
|
|
|
|
return sess, b.messages, nil, nil
|
|
}
|
|
|
|
// sessionIDFromReasonixPath extracts a session ID from a Reasonix
|
|
// file path. Handles project sessions, global sessions, subagents, and archive.
|
|
func sessionIDFromReasonixPath(path string) string {
|
|
base := filepath.Base(path)
|
|
|
|
// Strip .jsonl extension and .meta suffix if present
|
|
if cut, ok := strings.CutSuffix(base, ".jsonl"); ok {
|
|
base = cut
|
|
if cut2, ok2 := strings.CutSuffix(base, ".meta"); ok2 {
|
|
base = cut2
|
|
}
|
|
}
|
|
|
|
// For project/global sessions, base is already the session ID
|
|
// For subagents, base is like sa_20260612_105316_000000000_<hash>
|
|
return base
|
|
}
|
|
|
|
// loadReasonixMetadata reads the .jsonl.meta sidecar file if it exists. A
|
|
// missing sidecar is valid, but an unreadable or malformed sidecar is returned
|
|
// as an error so sync does not overwrite previously parsed metadata with empty
|
|
// fields during partial writes.
|
|
func loadReasonixMetadata(transcriptPath string) (*reasonixMetadata, error) {
|
|
metaPath := transcriptPath + ".meta"
|
|
data, err := os.ReadFile(metaPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("read reasonix metadata %s: %w", metaPath, err)
|
|
}
|
|
|
|
var meta reasonixMetadata
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return nil, fmt.Errorf("parse reasonix metadata %s: %w", metaPath, err)
|
|
}
|
|
|
|
return &meta, nil
|
|
}
|
|
|
|
// discoverReasonixSessions discovers Reasonix sessions across
|
|
// four layouts: project sessions, global sessions, global subagents,
|
|
// and archive sessions.
|
|
func discoverReasonixSessions(reasonixDir string) []DiscoveredFile {
|
|
if reasonixDir == "" {
|
|
return nil
|
|
}
|
|
|
|
var files []DiscoveredFile
|
|
|
|
// 1. Project sessions: {reasonixDir}/projects/{project}/sessions/*.jsonl
|
|
projectsDir := filepath.Join(reasonixDir, "projects")
|
|
if entries, err := os.ReadDir(projectsDir); err == nil {
|
|
for _, projectEntry := range entries {
|
|
if !projectEntry.IsDir() {
|
|
continue
|
|
}
|
|
projectName := projectEntry.Name()
|
|
sessionsDir := filepath.Join(
|
|
projectsDir, projectName, "sessions",
|
|
)
|
|
if sessEntries, err := os.ReadDir(sessionsDir); err == nil {
|
|
for _, sessEntry := range sessEntries {
|
|
if sessEntry.IsDir() {
|
|
// Check for {id}/{id}.jsonl inside
|
|
sessionID := sessEntry.Name()
|
|
candidate := filepath.Join(
|
|
sessionsDir, sessionID,
|
|
sessionID+".jsonl",
|
|
)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
files = append(files, DiscoveredFile{
|
|
Path: candidate,
|
|
Project: projectName,
|
|
Agent: AgentReasonix,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasSuffix(sessEntry.Name(), ".jsonl") &&
|
|
!strings.HasSuffix(sessEntry.Name(), ".meta") {
|
|
files = append(files, DiscoveredFile{
|
|
Path: filepath.Join(sessionsDir, sessEntry.Name()),
|
|
Project: projectName,
|
|
Agent: AgentReasonix,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Global sessions: {reasonixDir}/sessions/*.jsonl
|
|
globalSessDir := filepath.Join(reasonixDir, "sessions")
|
|
if entries, err := os.ReadDir(globalSessDir); err == nil {
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
// Skip subagents directory here (handled separately)
|
|
if entry.Name() == "subagents" {
|
|
continue
|
|
}
|
|
// If it's a directory, it should contain {id}.jsonl
|
|
sessionID := entry.Name()
|
|
candidate := filepath.Join(
|
|
globalSessDir, sessionID, sessionID+".jsonl",
|
|
)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
files = append(files, DiscoveredFile{
|
|
Path: candidate,
|
|
Agent: AgentReasonix,
|
|
})
|
|
}
|
|
} else if strings.HasSuffix(entry.Name(), ".jsonl") &&
|
|
!strings.HasSuffix(entry.Name(), ".meta") {
|
|
// Bare {id}.jsonl files
|
|
path := filepath.Join(globalSessDir, entry.Name())
|
|
files = append(files, DiscoveredFile{
|
|
Path: path,
|
|
Agent: AgentReasonix,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Global subagents: {reasonixDir}/sessions/subagents/*.jsonl
|
|
subagentsDir := filepath.Join(globalSessDir, "subagents")
|
|
if entries, err := os.ReadDir(subagentsDir); err == nil {
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() &&
|
|
strings.HasSuffix(entry.Name(), ".jsonl") &&
|
|
!strings.HasSuffix(entry.Name(), ".meta") {
|
|
path := filepath.Join(subagentsDir, entry.Name())
|
|
files = append(files, DiscoveredFile{
|
|
Path: path,
|
|
Agent: AgentReasonix,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Archive sessions: {reasonixDir}/archive/*.jsonl
|
|
archiveDir := filepath.Join(reasonixDir, "archive")
|
|
if entries, err := os.ReadDir(archiveDir); err == nil {
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() &&
|
|
strings.HasSuffix(entry.Name(), ".jsonl") &&
|
|
!strings.HasSuffix(entry.Name(), ".meta") {
|
|
path := filepath.Join(archiveDir, entry.Name())
|
|
files = append(files, DiscoveredFile{
|
|
Path: path,
|
|
Agent: AgentReasonix,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].Path < files[j].Path
|
|
})
|
|
return files
|
|
}
|
|
|
|
// findReasonixSourceFile locates a Reasonix session file by
|
|
// session ID. Searches project, global, subagent, and archive layouts.
|
|
func findReasonixSourceFile(reasonixDir, rawID string) string {
|
|
if reasonixDir == "" || rawID == "" {
|
|
return ""
|
|
}
|
|
|
|
// Try project sessions: {reasonixDir}/projects/{project}/sessions/{id}/{id}.jsonl
|
|
projectsDir := filepath.Join(reasonixDir, "projects")
|
|
if entries, err := os.ReadDir(projectsDir); err == nil {
|
|
for _, projectEntry := range entries {
|
|
if !projectEntry.IsDir() {
|
|
continue
|
|
}
|
|
candidate := filepath.Join(
|
|
projectsDir, projectEntry.Name(), "sessions",
|
|
rawID, rawID+".jsonl",
|
|
)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
candidate = filepath.Join(
|
|
projectsDir, projectEntry.Name(), "sessions",
|
|
rawID+".jsonl",
|
|
)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try global sessions: {reasonixDir}/sessions/{id}/{id}.jsonl
|
|
candidate := filepath.Join(
|
|
reasonixDir, "sessions", rawID, rawID+".jsonl",
|
|
)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
|
|
// Try bare global: {reasonixDir}/sessions/{id}.jsonl
|
|
candidate = filepath.Join(reasonixDir, "sessions", rawID+".jsonl")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
|
|
// Try subagents: {reasonixDir}/sessions/subagents/{id}.jsonl
|
|
candidate = filepath.Join(
|
|
reasonixDir, "sessions", "subagents", rawID+".jsonl",
|
|
)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
|
|
// Try archive: {reasonixDir}/archive/{id}.jsonl
|
|
candidate = filepath.Join(reasonixDir, "archive", rawID+".jsonl")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
|
|
return ""
|
|
}
|