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

672 lines
17 KiB
Go

package parser
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/tidwall/gjson"
)
var _ SourceSet = (*openClaudeSourceSet)(nil)
type openClaudeSource struct {
Root string
Path string
}
type openClaudeSourceSet struct {
roots []string
}
func newOpenClaudeProviderFactory(def AgentDef) ProviderFactory {
return NewSourceSetFactory(
def,
openClaudeProviderCapabilities(),
func(cfg ProviderConfig) SourceSet {
return newOpenClaudeSourceSet(cfg.Roots)
},
)
}
func openClaudeProviderCapabilities() Capabilities {
return Capabilities{
Source: SourceCapabilities{
DiscoverSources: CapabilitySupported,
WatchSources: CapabilitySupported,
ClassifyChangedPath: CapabilitySupported,
FindSource: CapabilitySupported,
CompositeFingerprint: CapabilitySupported,
IncrementalAppend: CapabilityNotApplicable,
MultiSessionSource: CapabilityNotApplicable,
PerSessionErrors: CapabilityNotApplicable,
ExcludedSessions: CapabilityNotApplicable,
ForceReplaceOnParse: CapabilitySupported,
},
Content: ContentCapabilities{
FirstMessage: CapabilitySupported,
SessionName: CapabilitySupported,
Cwd: CapabilitySupported,
GitBranch: CapabilitySupported,
Relationships: CapabilitySupported,
Thinking: CapabilitySupported,
ToolCalls: CapabilitySupported,
ToolResults: CapabilitySupported,
PerMessageTokenUsage: CapabilitySupported,
TerminationStatus: CapabilitySupported,
MalformedLineCount: CapabilitySupported,
Model: CapabilitySupported,
StopReason: CapabilitySupported,
},
}
}
func newOpenClaudeSourceSet(roots []string) openClaudeSourceSet {
return openClaudeSourceSet{roots: cleanJSONLRoots(roots)}
}
func (s openClaudeSourceSet) Discover(ctx context.Context) ([]SourceRef, error) {
var sources []SourceRef
seen := make(map[string]struct{})
for _, root := range s.roots {
if err := ctx.Err(); err != nil {
return nil, err
}
for _, file := range ClaudeProjectSessionFiles(root) {
source, ok := s.discoveredSourceRef(root, file)
if !ok {
continue
}
addJSONLSource(source, &sources, seen)
}
}
sortJSONLSources(sources)
return sources, nil
}
func (s openClaudeSourceSet) discoveredSourceRef(
root string, file DiscoveredFile,
) (SourceRef, bool) {
return s.sourceRef(root, file.Path)
}
func (s openClaudeSourceSet) WatchPlan(context.Context) (WatchPlan, error) {
roots := make([]WatchRoot, 0, len(s.roots))
for _, root := range s.roots {
roots = append(roots, WatchRoot{
Path: root,
Recursive: true,
IncludeGlobs: []string{"*.jsonl"},
DebounceKey: string(AgentOpenClaude) + ":projects:" + root,
})
}
return WatchPlan{Roots: roots}, nil
}
func (s openClaudeSourceSet) SourcesForChangedPath(
ctx context.Context,
req ChangedPathRequest,
) ([]SourceRef, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
allowMissing := jsonlMissingPathFallbackAllowed(req) ||
claudeChangedPathPresentButUnstatable(req.Path)
if req.WatchRoot != "" {
root := filepath.Clean(req.WatchRoot)
if !s.hasRoot(root) {
return nil, nil
}
source, ok := s.sourceForChangedPath(root, req.Path, allowMissing)
if !ok {
return nil, nil
}
return []SourceRef{source}, nil
}
for _, root := range s.roots {
source, ok := s.sourceForChangedPath(root, req.Path, allowMissing)
if ok {
return []SourceRef{source}, nil
}
}
return nil, nil
}
func (s openClaudeSourceSet) FindSource(
ctx context.Context,
req FindSourceRequest,
) (SourceRef, bool, error) {
if err := ctx.Err(); err != nil {
return SourceRef{}, false, err
}
for _, path := range []string{req.StoredFilePath, req.FingerprintKey} {
if path == "" {
continue
}
for _, root := range s.roots {
if source, ok := s.sourceForPath(root, path); ok {
return source, true, nil
}
}
}
if req.RawSessionID == "" {
return SourceRef{}, false, nil
}
for _, root := range s.roots {
path := claudeFindSourceFile(root, req.RawSessionID)
if path == "" {
continue
}
if source, ok := s.sourceRef(root, path); ok {
return source, true, nil
}
}
return SourceRef{}, false, nil
}
func (s openClaudeSourceSet) Fingerprint(
ctx context.Context,
source SourceRef,
) (SourceFingerprint, error) {
if err := ctx.Err(); err != nil {
return SourceFingerprint{}, err
}
path, ok := s.pathFromSource(source)
if !ok {
return SourceFingerprint{}, fmt.Errorf("openclaude source path unavailable")
}
info, err := os.Stat(path)
if err != nil {
return SourceFingerprint{}, fmt.Errorf("stat %s: %w", path, err)
}
if info.IsDir() {
return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", path)
}
hash, err := hashJSONLSourceFile(path)
if err != nil {
return SourceFingerprint{}, err
}
inode, device := sourceFileIdentity(info)
return SourceFingerprint{
Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path),
Size: info.Size(),
MTimeNS: info.ModTime().UnixNano(),
Inode: inode,
Device: device,
Hash: hash,
}, nil
}
func (s openClaudeSourceSet) Parse(
ctx context.Context,
req ParseRequest,
) (ParseOutcome, error) {
if err := ctx.Err(); err != nil {
return ParseOutcome{}, err
}
path, ok := s.pathFromSource(req.Source)
if !ok {
return ParseOutcome{}, fmt.Errorf("openclaude source path unavailable")
}
machine := firstNonEmptyJSONLString(req.Machine)
project := GetProjectName(firstNonEmptyJSONLString(
req.Source.ProjectHint,
filepath.Base(filepath.Dir(path)),
))
sess, msgs, err := parseOpenClaudeSession(path, project, machine)
if err != nil {
return ParseOutcome{}, err
}
if sess == nil {
return ParseOutcome{
ResultSetComplete: true,
SkipReason: SkipNoSession,
}, nil
}
if req.Fingerprint.Hash != "" {
sess.File.Hash = req.Fingerprint.Hash
}
return ParseOutcome{
Results: []ParseResultOutcome{{
Result: ParseResult{
Session: *sess,
Messages: msgs,
},
DataVersion: DataVersionCurrent,
}},
ResultSetComplete: true,
ForceReplace: true,
}, nil
}
func (s openClaudeSourceSet) pathFromSource(source SourceRef) (string, bool) {
switch src := source.Opaque.(type) {
case openClaudeSource:
return src.Path, src.Path != ""
case *openClaudeSource:
if src != nil && src.Path != "" {
return src.Path, true
}
case MaterializedFileSource:
return src.Path, src.Path != ""
}
for _, candidate := range []string{
source.DisplayPath,
source.FingerprintKey,
source.Key,
} {
for _, root := range s.roots {
if ref, ok := s.sourceForPath(root, candidate); ok {
src := ref.Opaque.(openClaudeSource)
return src.Path, true
}
}
}
return "", false
}
func (s openClaudeSourceSet) sourceForPath(root, path string) (SourceRef, bool) {
return s.sourceForChangedPath(root, path, false)
}
func (s openClaudeSourceSet) sourceForChangedPath(
root, path string, allowMissing bool,
) (SourceRef, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
if allowMissing {
return s.sourceRefFromPath(root, path)
}
return s.sourceRef(root, path)
}
func (s openClaudeSourceSet) sourceRef(root, path string) (SourceRef, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
if !IsRegularFile(path) {
return SourceRef{}, false
}
return s.sourceRefFromPath(root, path)
}
func (s openClaudeSourceSet) sourceRefFromPath(
root, path string,
) (SourceRef, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
project, ok := claudeProjectHintFromPath(root, path)
if !ok {
return SourceRef{}, false
}
return SourceRef{
Provider: AgentOpenClaude,
Key: path,
DisplayPath: path,
FingerprintKey: path,
ProjectHint: project,
Opaque: openClaudeSource{
Root: root,
Path: path,
},
}, true
}
func (s openClaudeSourceSet) hasRoot(root string) bool {
for _, configured := range s.roots {
if samePath(root, configured) {
return true
}
}
return false
}
func parseOpenClaudeSession(
path, project, machine string,
) (*ParsedSession, []ParsedMessage, error) {
info, err := os.Stat(path)
if err != nil {
return nil, nil, fmt.Errorf("stat %s: %w", path, err)
}
f, err := os.Open(path)
if err != nil {
return nil, nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
lr := newLineReader(f, maxLineSize)
defer releaseLineReader(lr)
lastLine := ""
malformedLines := 0
ordinal := 0
var (
messages []ParsedMessage
queuedCommands []claudeQueuedCommand
startedAt time.Time
endedAt time.Time
firstUser string
userCount int
sessionID = strings.TrimSuffix(filepath.Base(path), ".jsonl")
sessionName string
customTitle string
aiTitle string
cwd string
gitBranch string
)
for {
line, ok := lr.next()
if !ok {
break
}
lastLine = line
if !gjson.Valid(line) {
malformedLines++
continue
}
switch strings.TrimSpace(gjson.Get(line, "type").Str) {
case "attachment":
if qc, ok := extractOpenClaudeQueuedCommand(line); ok {
queuedCommands = append(queuedCommands, qc)
}
continue
case "custom-title":
if v := strings.TrimSpace(gjson.Get(line, "customTitle").Str); v != "" {
customTitle = v
}
continue
case "ai-title":
if v := strings.TrimSpace(gjson.Get(line, "aiTitle").Str); v != "" {
aiTitle = v
}
continue
}
if ts := extractTimestamp(line); !ts.IsZero() {
if startedAt.IsZero() || ts.Before(startedAt) {
startedAt = ts
}
if ts.After(endedAt) {
endedAt = ts
}
}
if sessionName == "" {
if v := strings.TrimSpace(gjson.Get(line, "sessionName").Str); v != "" {
sessionName = v
}
}
if cwd == "" {
if v := strings.TrimSpace(gjson.Get(line, "cwd").Str); v != "" {
cwd = v
}
}
if gitBranch == "" {
if v := strings.TrimSpace(gjson.Get(line, "gitBranch").Str); v != "" {
gitBranch = v
}
}
if isOpenClaudeCompactBoundary(line) {
content, _, _, _, _, _ := ExtractTextContent(
gjson.Get(line, "message.content"),
)
messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleAssistant,
Content: content,
Timestamp: extractTimestamp(line),
IsSystem: true,
ContentLength: len(content),
SourceType: "system",
SourceSubtype: "compact_boundary",
SourceUUID: gjson.Get(line, "uuid").Str,
SourceParentUUID: gjson.Get(line, "parentUuid").Str,
IsCompactBoundary: true,
IsSidechain: gjson.Get(line, "isSidechain").Bool(),
})
ordinal++
continue
}
role := strings.TrimSpace(gjson.Get(line, "message.role").Str)
if role == "" {
role = strings.TrimSpace(gjson.Get(line, "type").Str)
}
switch role {
case "user", "assistant", "system":
default:
continue
}
if role == "user" && gjson.Get(line, "isMeta").Bool() {
continue
}
content := gjson.Get(line, "message.content")
text, thinkingText, hasThinking, hasToolUse, toolCalls, toolResults :=
ExtractTextContent(content)
if strings.TrimSpace(text) == "" && len(toolResults) == 0 &&
len(toolCalls) == 0 && role != "system" {
continue
}
if role == "system" {
messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleSystem,
Content: text,
ThinkingText: thinkingText,
Timestamp: extractTimestamp(line),
HasThinking: hasThinking,
HasToolUse: hasToolUse,
IsSystem: true,
ContentLength: len(text),
ToolCalls: toolCalls,
ToolResults: toolResults,
SourceType: "system",
SourceSubtype: strings.TrimSpace(gjson.Get(line, "subtype").Str),
SourceUUID: gjson.Get(line, "uuid").Str,
SourceParentUUID: gjson.Get(line, "parentUuid").Str,
IsSidechain: gjson.Get(line, "isSidechain").Bool(),
})
ordinal++
continue
}
if role == "user" && strings.TrimSpace(text) == "" &&
len(toolResults) == 0 {
continue
}
msg := ParsedMessage{
Ordinal: ordinal,
Role: RoleType(role),
Content: text,
ThinkingText: thinkingText,
Timestamp: extractTimestamp(line),
HasThinking: hasThinking,
HasToolUse: hasToolUse,
ContentLength: len(text),
ToolCalls: toolCalls,
ToolResults: toolResults,
SourceType: role,
SourceUUID: gjson.Get(line, "uuid").Str,
SourceParentUUID: gjson.Get(line, "parentUuid").Str,
IsSidechain: gjson.Get(line, "isSidechain").Bool(),
tokenPresenceKnown: role == "assistant",
}
if role == "assistant" {
extractOpenClaudeTokenFields(&msg, line)
msg.StopReason = gjson.Get(line, "message.stop_reason").Str
}
messages = append(messages, msg)
ordinal++
if role == "user" && strings.TrimSpace(text) != "" {
userCount++
if firstUser == "" {
firstUser = truncate(strings.ReplaceAll(text, "\n", " "), 300)
}
}
}
if err := lr.Err(); err != nil {
return nil, nil, fmt.Errorf("reading %s: %w", path, err)
}
isTruncated := lastLine != "" &&
strings.TrimSpace(lastLine) != "" &&
!gjson.Valid(lastLine) &&
!fileEndsWithNewline(f, info.Size())
if len(messages) == 0 && len(queuedCommands) == 0 {
return nil, nil, nil
}
if len(queuedCommands) > 0 {
messages = mergeQueuedCommands(messages, queuedCommands, 0)
firstUser, userCount = firstMessageAndUserCount(messages)
for _, qc := range queuedCommands {
if qc.timestamp.After(endedAt) {
endedAt = qc.timestamp
}
if !qc.timestamp.IsZero() &&
(startedAt.IsZero() || qc.timestamp.Before(startedAt)) {
startedAt = qc.timestamp
}
}
}
if customTitle != "" {
sessionName = customTitle
} else if aiTitle != "" {
sessionName = aiTitle
}
project = firstNonEmptyJSONLString(project, GetProjectName(filepath.Base(filepath.Dir(path))))
if project == "" {
project = "unknown"
}
parentSessionID, relationshipType := openClaudeRelationship(path, sessionID)
sess := &ParsedSession{
ID: openClaudeSessionID(sessionID),
Project: project,
Machine: machine,
Agent: AgentOpenClaude,
ParentSessionID: parentSessionID,
RelationshipType: relationshipType,
Cwd: cwd,
GitBranch: gitBranch,
FirstMessage: firstUser,
SessionName: sessionName,
StartedAt: startedAt,
EndedAt: endedAt,
MessageCount: len(messages),
UserMessageCount: userCount,
MalformedLines: malformedLines,
IsTruncated: isTruncated,
File: FileInfo{
Path: path,
Size: info.Size(),
Mtime: info.ModTime().UnixNano(),
},
}
accumulateMessageTokenUsage(sess, messages)
sess.TerminationStatus = Classify(
openClaudeSemanticMessages(messages),
lastAssistantStopReason(openClaudeSemanticMessages(messages)),
isTruncated,
)
return sess, messages, nil
}
func openClaudeRelationship(
path, sessionID string,
) (string, RelationshipType) {
parent := claudeCompanionParentSessionID(path, sessionID)
if parent == "" {
return "", RelNone
}
return openClaudeSessionID(parent), RelSubagent
}
func openClaudeSemanticMessages(messages []ParsedMessage) []ParsedMessage {
filtered := make([]ParsedMessage, 0, len(messages))
for _, msg := range messages {
if msg.IsSystem {
continue
}
filtered = append(filtered, msg)
}
return filtered
}
func extractOpenClaudeQueuedCommand(line string) (claudeQueuedCommand, bool) {
attachment := gjson.Get(line, "attachment")
if attachment.Get("type").Str != "queued_command" {
return claudeQueuedCommand{}, false
}
if attachment.Get("commandMode").Str != "prompt" {
return claudeQueuedCommand{}, false
}
if attachment.Get("isMeta").Bool() || attachment.Get("origin").Exists() {
return claudeQueuedCommand{}, false
}
prompt, _, _, _, _, _ := ExtractTextContent(attachment.Get("prompt"))
if strings.TrimSpace(prompt) == "" {
return claudeQueuedCommand{}, false
}
return claudeQueuedCommand{
prompt: prompt,
timestamp: extractTimestamp(line),
}, true
}
func openClaudeSessionID(id string) string {
id = strings.TrimSpace(id)
if id == "" || strings.HasPrefix(id, "openclaude:") {
return id
}
return "openclaude:" + id
}
func isOpenClaudeCompactBoundary(line string) bool {
if gjson.Get(line, "isCompactSummary").Bool() {
return true
}
if gjson.Get(line, "compact_boundary").Bool() {
return true
}
switch strings.TrimSpace(gjson.Get(line, "subtype").Str) {
case "compact_boundary", "compact-summary", "compact_summary":
return true
}
return strings.TrimSpace(gjson.Get(line, "type").Str) == "compact_boundary"
}
func extractOpenClaudeTokenFields(msg *ParsedMessage, line string) {
msg.Model = gjson.Get(line, "message.model").String()
usageResult := gjson.Get(line, "message.usage")
if usageResult.Exists() {
msg.TokenUsage = json.RawMessage(usageResult.Raw)
msg.HasOutputTokens = usageResult.Get("output_tokens").Exists()
msg.HasContextTokens = usageResult.Get("input_tokens").Exists() ||
usageResult.Get("cache_creation_input_tokens").Exists() ||
usageResult.Get("cache_read_input_tokens").Exists()
input := int(usageResult.Get("input_tokens").Int())
cacheCreation := int(usageResult.Get("cache_creation_input_tokens").Int())
cacheRead := int(usageResult.Get("cache_read_input_tokens").Int())
msg.OutputTokens = int(usageResult.Get("output_tokens").Int())
msg.ContextTokens = input + cacheCreation + cacheRead
}
}