Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

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 ""
}