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
683 lines
14 KiB
Go
683 lines
14 KiB
Go
package parser
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const (
|
|
openHandsMessageEvent = "MessageEvent"
|
|
openHandsActionEvent = "ActionEvent"
|
|
openHandsObservationEvent = "ObservationEvent"
|
|
)
|
|
|
|
// OpenHandsSnapshot computes synthetic file metadata for an
|
|
// OpenHands conversation directory by hashing the relevant
|
|
// metadata of base_state.json, TASKS.json, and events/*.json.
|
|
func OpenHandsSnapshot(path string) (FileInfo, error) {
|
|
sessionDir, err := normalizeOpenHandsSessionPath(path)
|
|
if err != nil {
|
|
return FileInfo{}, err
|
|
}
|
|
|
|
rootInfo, err := os.Stat(sessionDir)
|
|
if err != nil {
|
|
return FileInfo{}, fmt.Errorf("stat %s: %w", sessionDir, err)
|
|
}
|
|
|
|
var (
|
|
totalSize int64
|
|
maxMtime = rootInfo.ModTime().UnixNano()
|
|
manifest []string
|
|
)
|
|
|
|
addFile := func(rel string) error {
|
|
full := filepath.Join(sessionDir, rel)
|
|
info, err := os.Stat(full)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
mtime := info.ModTime().UnixNano()
|
|
totalSize += info.Size()
|
|
if mtime > maxMtime {
|
|
maxMtime = mtime
|
|
}
|
|
manifest = append(manifest, fmt.Sprintf(
|
|
"%s:%d:%d", rel, info.Size(), mtime,
|
|
))
|
|
return nil
|
|
}
|
|
|
|
for _, rel := range []string{
|
|
"base_state.json",
|
|
"TASKS.json",
|
|
} {
|
|
if err := addFile(rel); err != nil {
|
|
return FileInfo{}, fmt.Errorf(
|
|
"stat %s: %w", filepath.Join(sessionDir, rel), err,
|
|
)
|
|
}
|
|
}
|
|
|
|
eventsDir := filepath.Join(sessionDir, "events")
|
|
eventEntries, err := os.ReadDir(eventsDir)
|
|
if err != nil {
|
|
return FileInfo{}, fmt.Errorf("read %s: %w", eventsDir, err)
|
|
}
|
|
for _, entry := range eventEntries {
|
|
if entry.IsDir() ||
|
|
!strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
if err := addFile(filepath.Join(
|
|
"events", entry.Name(),
|
|
)); err != nil {
|
|
return FileInfo{}, fmt.Errorf(
|
|
"stat %s: %w",
|
|
filepath.Join(eventsDir, entry.Name()), err,
|
|
)
|
|
}
|
|
}
|
|
|
|
h := sha256.New()
|
|
for _, line := range manifest {
|
|
_, _ = h.Write([]byte(line))
|
|
_, _ = h.Write([]byte{'\n'})
|
|
}
|
|
|
|
return FileInfo{
|
|
Path: sessionDir,
|
|
Size: totalSize,
|
|
Mtime: maxMtime,
|
|
Hash: hex.EncodeToString(h.Sum(nil)),
|
|
}, nil
|
|
}
|
|
|
|
// parseSession parses a single OpenHands CLI conversation
|
|
// directory into a session and messages.
|
|
func (p *openHandsProvider) parseSession(
|
|
path, machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
sessionDir, err := normalizeOpenHandsSessionPath(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
snapshot, err := OpenHandsSnapshot(sessionDir)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
baseState := readOpenHandsJSON(filepath.Join(
|
|
sessionDir, "base_state.json",
|
|
))
|
|
sessionID := baseState.Get("id").Str
|
|
if sessionID == "" {
|
|
sessionID = normalizeOpenHandsSessionID(
|
|
filepath.Base(sessionDir),
|
|
)
|
|
}
|
|
|
|
model := baseState.Get("agent.llm.model").Str
|
|
cwd := openHandsBaseStateCwd(baseState)
|
|
|
|
eventEntries, err := os.ReadDir(
|
|
filepath.Join(sessionDir, "events"),
|
|
)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"read %s: %w",
|
|
filepath.Join(sessionDir, "events"), err,
|
|
)
|
|
}
|
|
|
|
var (
|
|
messages []ParsedMessage
|
|
firstMessage string
|
|
startedAt time.Time
|
|
endedAt time.Time
|
|
ordinal int
|
|
realUserCount int
|
|
)
|
|
|
|
for _, entry := range eventEntries {
|
|
if entry.IsDir() ||
|
|
!strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
|
|
eventPath := filepath.Join(
|
|
sessionDir, "events", entry.Name(),
|
|
)
|
|
data, err := os.ReadFile(eventPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"read %s: %w", eventPath, err,
|
|
)
|
|
}
|
|
if !gjson.ValidBytes(data) {
|
|
return nil, nil, fmt.Errorf(
|
|
"invalid JSON in %s", eventPath,
|
|
)
|
|
}
|
|
|
|
ev := gjson.ParseBytes(data)
|
|
ts := parseHermesTimestamp(
|
|
ev.Get("timestamp").Str,
|
|
)
|
|
if !ts.IsZero() {
|
|
if startedAt.IsZero() || ts.Before(startedAt) {
|
|
startedAt = ts
|
|
}
|
|
if ts.After(endedAt) {
|
|
endedAt = ts
|
|
}
|
|
}
|
|
|
|
var (
|
|
msg ParsedMessage
|
|
ok bool
|
|
discovered string
|
|
)
|
|
switch ev.Get("kind").Str {
|
|
case openHandsMessageEvent:
|
|
msg, ok, discovered = parseOpenHandsMessageEvent(
|
|
ev, ordinal, model, ts,
|
|
)
|
|
if ok && msg.Role == RoleUser &&
|
|
strings.TrimSpace(msg.Content) != "" {
|
|
realUserCount++
|
|
if firstMessage == "" {
|
|
firstMessage = truncate(
|
|
strings.ReplaceAll(
|
|
msg.Content, "\n", " ",
|
|
),
|
|
300,
|
|
)
|
|
}
|
|
}
|
|
case openHandsActionEvent:
|
|
msg, ok, discovered = parseOpenHandsActionEvent(
|
|
ev, ordinal, model, ts,
|
|
)
|
|
case openHandsObservationEvent:
|
|
msg, ok, discovered = parseOpenHandsObservationEvent(
|
|
ev, ordinal, ts,
|
|
)
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cwd == "" && discovered != "" {
|
|
cwd = discovered
|
|
}
|
|
|
|
messages = append(messages, msg)
|
|
ordinal++
|
|
}
|
|
|
|
if len(messages) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
project := ""
|
|
if cwd != "" {
|
|
project = ExtractProjectFromCwd(cwd)
|
|
}
|
|
if project == "" {
|
|
project = "openhands"
|
|
}
|
|
|
|
sess := &ParsedSession{
|
|
ID: "openhands:" + sessionID,
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: AgentOpenHands,
|
|
Cwd: cwd,
|
|
FirstMessage: firstMessage,
|
|
StartedAt: startedAt,
|
|
EndedAt: endedAt,
|
|
MessageCount: len(messages),
|
|
UserMessageCount: realUserCount,
|
|
File: snapshot,
|
|
}
|
|
return sess, messages, nil
|
|
}
|
|
|
|
func parseOpenHandsMessageEvent(
|
|
ev gjson.Result,
|
|
ordinal int,
|
|
model string,
|
|
ts time.Time,
|
|
) (ParsedMessage, bool, string) {
|
|
llmMessage := ev.Get("llm_message")
|
|
role := RoleType(llmMessage.Get("role").Str)
|
|
if role != RoleUser && role != RoleAssistant {
|
|
return ParsedMessage{}, false, ""
|
|
}
|
|
|
|
content, _, _, _, toolCalls, toolResults :=
|
|
ExtractTextContent(llmMessage.Get("content"))
|
|
content, hasThinking := openHandsAppendThinking(
|
|
content, ev,
|
|
)
|
|
content = strings.TrimSpace(content)
|
|
if content == "" &&
|
|
len(toolCalls) == 0 &&
|
|
len(toolResults) == 0 {
|
|
return ParsedMessage{}, false, ""
|
|
}
|
|
|
|
msg := ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: role,
|
|
Content: content,
|
|
Timestamp: ts,
|
|
HasThinking: hasThinking,
|
|
HasToolUse: len(toolCalls) > 0,
|
|
ContentLength: len(content),
|
|
ToolCalls: toolCalls,
|
|
ToolResults: toolResults,
|
|
}
|
|
if role == RoleAssistant {
|
|
msg.Model = model
|
|
}
|
|
return msg, true, ""
|
|
}
|
|
|
|
func parseOpenHandsActionEvent(
|
|
ev gjson.Result,
|
|
ordinal int,
|
|
model string,
|
|
ts time.Time,
|
|
) (ParsedMessage, bool, string) {
|
|
toolName := ev.Get("tool_name").Str
|
|
if toolName == "" {
|
|
toolName = ev.Get("tool_call.name").Str
|
|
}
|
|
if toolName == "" {
|
|
return ParsedMessage{}, false, ""
|
|
}
|
|
|
|
action := ev.Get("action")
|
|
inputJSON := strings.TrimSpace(
|
|
ev.Get("tool_call.arguments").Str,
|
|
)
|
|
if inputJSON == "" {
|
|
inputJSON = action.Raw
|
|
}
|
|
|
|
content := openHandsText(ev.Get("thought"))
|
|
content = joinOpenHandsParts(
|
|
content,
|
|
formatOpenHandsAction(
|
|
toolName, action, ev.Get("summary").Str,
|
|
),
|
|
)
|
|
content, hasThinking := openHandsAppendThinking(
|
|
content, ev,
|
|
)
|
|
content = strings.TrimSpace(content)
|
|
if content == "" {
|
|
return ParsedMessage{}, false, ""
|
|
}
|
|
|
|
msg := ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleAssistant,
|
|
Content: content,
|
|
Timestamp: ts,
|
|
HasThinking: hasThinking,
|
|
HasToolUse: true,
|
|
ContentLength: len(content),
|
|
Model: model,
|
|
ToolCalls: []ParsedToolCall{{
|
|
ToolUseID: ev.Get("tool_call_id").Str,
|
|
ToolName: toolName,
|
|
Category: openHandsToolCategory(toolName, action),
|
|
InputJSON: inputJSON,
|
|
}},
|
|
}
|
|
return msg, true, openHandsActionCwd(toolName, action)
|
|
}
|
|
|
|
func parseOpenHandsObservationEvent(
|
|
ev gjson.Result,
|
|
ordinal int,
|
|
ts time.Time,
|
|
) (ParsedMessage, bool, string) {
|
|
observation := ev.Get("observation")
|
|
raw, display := openHandsObservationContent(
|
|
observation,
|
|
)
|
|
display = strings.TrimSpace(display)
|
|
workingDir := observation.Get(
|
|
"metadata.working_dir",
|
|
).Str
|
|
|
|
toolUseID := ev.Get("tool_call_id").Str
|
|
if toolUseID == "" {
|
|
if display == "" {
|
|
return ParsedMessage{}, false, ""
|
|
}
|
|
return ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
Content: display,
|
|
Timestamp: ts,
|
|
ContentLength: len(display),
|
|
}, true, workingDir
|
|
}
|
|
|
|
if raw == "" {
|
|
b, _ := json.Marshal(display)
|
|
raw = string(b)
|
|
}
|
|
contentLength := len(display)
|
|
if contentLength == 0 {
|
|
contentLength = toolResultContentLength(
|
|
gjson.Parse(raw),
|
|
)
|
|
}
|
|
|
|
return ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
Timestamp: ts,
|
|
ToolResults: []ParsedToolResult{{
|
|
ToolUseID: toolUseID,
|
|
ContentLength: contentLength,
|
|
ContentRaw: raw,
|
|
}},
|
|
}, true, workingDir
|
|
}
|
|
|
|
func openHandsBaseStateCwd(base gjson.Result) string {
|
|
for _, path := range []string{
|
|
"workspace.cwd",
|
|
"workspace.path",
|
|
"workspace.mount_path",
|
|
"workspace.root",
|
|
"workspace.repo_path",
|
|
"workspace.repo_root",
|
|
"workspace.working_dir",
|
|
} {
|
|
if value := strings.TrimSpace(
|
|
base.Get(path).Str,
|
|
); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func openHandsText(content gjson.Result) string {
|
|
text, _, _, _, _, _ := ExtractTextContent(content)
|
|
return strings.TrimSpace(text)
|
|
}
|
|
|
|
func openHandsAppendThinking(
|
|
content string, ev gjson.Result,
|
|
) (string, bool) {
|
|
thinking := openHandsThinkingText(ev)
|
|
if thinking == "" {
|
|
return content, false
|
|
}
|
|
block := "[Thinking]\n" + thinking + "\n[/Thinking]"
|
|
if strings.TrimSpace(content) == "" {
|
|
return block, true
|
|
}
|
|
return content + "\n" + block, true
|
|
}
|
|
|
|
func openHandsThinkingText(ev gjson.Result) string {
|
|
var parts []string
|
|
ev.Get("thinking_blocks").ForEach(func(_, block gjson.Result) bool {
|
|
if t := strings.TrimSpace(
|
|
block.Get("thinking").Str,
|
|
); t != "" {
|
|
parts = append(parts, t)
|
|
}
|
|
return true
|
|
})
|
|
if len(parts) > 0 {
|
|
return strings.Join(parts, "\n\n")
|
|
}
|
|
return strings.TrimSpace(
|
|
ev.Get("reasoning_content").Str,
|
|
)
|
|
}
|
|
|
|
func openHandsToolCategory(
|
|
toolName string, action gjson.Result,
|
|
) string {
|
|
switch toolName {
|
|
case "file_editor":
|
|
switch action.Get("command").Str {
|
|
case "view":
|
|
return "Read"
|
|
case "create", "write":
|
|
return "Write"
|
|
case "str_replace", "insert", "append", "edit":
|
|
return "Edit"
|
|
default:
|
|
return "Tool"
|
|
}
|
|
case "delegate":
|
|
return "Task"
|
|
case "task_tracker":
|
|
return "Tool"
|
|
}
|
|
return NormalizeToolCategory(toolName)
|
|
}
|
|
|
|
func formatOpenHandsAction(
|
|
toolName string,
|
|
action gjson.Result,
|
|
summary string,
|
|
) string {
|
|
switch toolName {
|
|
case "terminal":
|
|
cmd := action.Get("command").Str
|
|
if summary != "" {
|
|
return fmt.Sprintf(
|
|
"[Bash: %s]\n$ %s", summary, cmd,
|
|
)
|
|
}
|
|
return fmt.Sprintf("[Bash]\n$ %s", cmd)
|
|
case "file_editor":
|
|
path := action.Get("path").Str
|
|
switch action.Get("command").Str {
|
|
case "view":
|
|
return fmt.Sprintf("[Read: %s]", path)
|
|
case "create", "write":
|
|
return fmt.Sprintf("[Write: %s]", path)
|
|
case "str_replace", "insert", "append", "edit":
|
|
return fmt.Sprintf("[Edit: %s]", path)
|
|
default:
|
|
return fmt.Sprintf(
|
|
"[FileEditor: %s %s]",
|
|
action.Get("command").Str, path,
|
|
)
|
|
}
|
|
case "delegate":
|
|
cmd := action.Get("command").Str
|
|
ids := openHandsStringArray(action.Get("ids"))
|
|
if len(ids) > 0 {
|
|
return fmt.Sprintf(
|
|
"[Task: %s %s]",
|
|
cmd, strings.Join(ids, ", "),
|
|
)
|
|
}
|
|
if cmd != "" {
|
|
return fmt.Sprintf("[Task: %s]", cmd)
|
|
}
|
|
return "[Task]"
|
|
case "task_tracker":
|
|
if cmd := action.Get("command").Str; cmd != "" {
|
|
return fmt.Sprintf(
|
|
"[TaskTracker: %s]",
|
|
cmd,
|
|
)
|
|
}
|
|
return "[TaskTracker]"
|
|
default:
|
|
if summary != "" {
|
|
return fmt.Sprintf(
|
|
"[%s: %s]",
|
|
toolName, summary,
|
|
)
|
|
}
|
|
return fmt.Sprintf("[%s]", toolName)
|
|
}
|
|
}
|
|
|
|
func openHandsObservationContent(
|
|
observation gjson.Result,
|
|
) (string, string) {
|
|
content := observation.Get("content")
|
|
if content.Exists() {
|
|
raw := content.Raw
|
|
return raw, DecodeContent(raw)
|
|
}
|
|
|
|
parts := []string{}
|
|
if cmd := observation.Get("command").Str; cmd != "" {
|
|
parts = append(parts, cmd)
|
|
}
|
|
if detail := observation.Get("detail").Str; detail != "" {
|
|
parts = append(parts, detail)
|
|
}
|
|
display := strings.TrimSpace(strings.Join(parts, "\n"))
|
|
if display == "" {
|
|
display = strings.TrimSpace(observation.Raw)
|
|
}
|
|
if display == "" {
|
|
return "", ""
|
|
}
|
|
raw, _ := json.Marshal(display)
|
|
return string(raw), display
|
|
}
|
|
|
|
func openHandsActionCwd(
|
|
toolName string, action gjson.Result,
|
|
) string {
|
|
if toolName != "file_editor" {
|
|
return ""
|
|
}
|
|
path := strings.TrimSpace(action.Get("path").Str)
|
|
if path == "" || !filepath.IsAbs(path) {
|
|
return ""
|
|
}
|
|
return filepath.Dir(path)
|
|
}
|
|
|
|
func joinOpenHandsParts(parts ...string) string {
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
func openHandsStringArray(value gjson.Result) []string {
|
|
if !value.IsArray() {
|
|
return nil
|
|
}
|
|
var out []string
|
|
value.ForEach(func(_, item gjson.Result) bool {
|
|
if item.Type == gjson.String && item.Str != "" {
|
|
out = append(out, item.Str)
|
|
}
|
|
return true
|
|
})
|
|
return out
|
|
}
|
|
|
|
func readOpenHandsJSON(path string) gjson.Result {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || !gjson.ValidBytes(data) {
|
|
return gjson.Result{}
|
|
}
|
|
return gjson.ParseBytes(data)
|
|
}
|
|
|
|
func normalizeOpenHandsSessionPath(path string) (string, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.IsDir() {
|
|
return path, nil
|
|
}
|
|
name := filepath.Base(path)
|
|
switch name {
|
|
case "base_state.json", "TASKS.json":
|
|
return filepath.Dir(path), nil
|
|
}
|
|
if filepath.Base(filepath.Dir(path)) == "events" {
|
|
return filepath.Dir(filepath.Dir(path)), nil
|
|
}
|
|
return "", fmt.Errorf("openhands path is not a session dir: %s", path)
|
|
}
|
|
|
|
func isOpenHandsSessionDir(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if err != nil || !info.IsDir() {
|
|
return false
|
|
}
|
|
eventsDir := filepath.Join(path, "events")
|
|
eventsInfo, err := os.Stat(eventsDir)
|
|
if err != nil || eventsInfo == nil {
|
|
return false
|
|
}
|
|
return eventsInfo.IsDir()
|
|
}
|
|
|
|
func normalizeOpenHandsSessionID(id string) string {
|
|
id = strings.TrimSpace(id)
|
|
if len(id) != 32 || !isHexString(id) {
|
|
return id
|
|
}
|
|
return fmt.Sprintf(
|
|
"%s-%s-%s-%s-%s",
|
|
id[0:8], id[8:12], id[12:16], id[16:20], id[20:32],
|
|
)
|
|
}
|
|
|
|
func isHexString(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= '0' && r <= '9':
|
|
case r >= 'a' && r <= 'f':
|
|
case r >= 'A' && r <= 'F':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|