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
923 lines
23 KiB
Go
923 lines
23 KiB
Go
package parser
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const devinDBFilename = "sessions.db"
|
|
|
|
// DevinSessionMeta is lightweight metadata for a Devin session row.
|
|
type DevinSessionMeta struct {
|
|
RawSessionID string
|
|
VirtualPath string
|
|
Title string
|
|
CWD string
|
|
Model string
|
|
CreatedAt time.Time
|
|
LastActivity time.Time
|
|
UpdatedAt time.Time
|
|
FileMtime int64
|
|
}
|
|
|
|
// devinDBPath returns <root>/cli/sessions.db when present.
|
|
func devinDBPath(root string) string {
|
|
if root == "" {
|
|
return ""
|
|
}
|
|
path := filepath.Join(root, "cli", devinDBFilename)
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.IsDir() {
|
|
return ""
|
|
}
|
|
return path
|
|
}
|
|
|
|
// ListDevinSessionMeta returns lightweight metadata for all non-hidden Devin
|
|
// sessions without parsing transcripts.
|
|
func ListDevinSessionMeta(dbPath string) ([]DevinSessionMeta, error) {
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
|
|
db, err := openDevinDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.Query(`
|
|
SELECT id,
|
|
COALESCE(title, ''),
|
|
COALESCE(working_directory, ''),
|
|
COALESCE(model, ''),
|
|
COALESCE(created_at, 0),
|
|
last_activity_at,
|
|
COALESCE(last_activity_at, created_at, 0)
|
|
FROM sessions
|
|
WHERE COALESCE(hidden, 0) <> 1
|
|
ORDER BY COALESCE(last_activity_at, created_at, 0) DESC, id DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing devin sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var metas []DevinSessionMeta
|
|
for rows.Next() {
|
|
var meta DevinSessionMeta
|
|
var createdAtMS int64
|
|
var lastActivityMS sql.NullInt64
|
|
var updatedAtMS int64
|
|
if err := rows.Scan(
|
|
&meta.RawSessionID,
|
|
&meta.Title,
|
|
&meta.CWD,
|
|
&meta.Model,
|
|
&createdAtMS,
|
|
&lastActivityMS,
|
|
&updatedAtMS,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning devin session meta: %w", err)
|
|
}
|
|
meta.VirtualPath = VirtualSourcePath(dbPath, meta.RawSessionID)
|
|
meta.CreatedAt = devinUnixMilli(createdAtMS)
|
|
if lastActivityMS.Valid {
|
|
meta.LastActivity = devinUnixMilli(lastActivityMS.Int64)
|
|
}
|
|
meta.UpdatedAt = devinUnixMilli(updatedAtMS)
|
|
meta.FileMtime = updatedAtMS * 1_000_000
|
|
metas = append(metas, meta)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating devin sessions: %w", err)
|
|
}
|
|
return metas, nil
|
|
}
|
|
|
|
func openDevinDB(dbPath string) (*sql.DB, error) {
|
|
dsn := "file:" + sqliteURIPath(dbPath) + "?mode=ro&_busy_timeout=3000"
|
|
db, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening devin db %s: %w", dbPath, err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func getDevinSessionMeta(
|
|
dbPath, rawSessionID string,
|
|
) (*DevinSessionMeta, error) {
|
|
db, err := openDevinDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
var meta DevinSessionMeta
|
|
var createdAtMS int64
|
|
var lastActivityMS sql.NullInt64
|
|
var updatedAtMS int64
|
|
err = db.QueryRow(`
|
|
SELECT id,
|
|
COALESCE(title, ''),
|
|
COALESCE(working_directory, ''),
|
|
COALESCE(model, ''),
|
|
COALESCE(created_at, 0),
|
|
last_activity_at,
|
|
COALESCE(last_activity_at, created_at, 0)
|
|
FROM sessions
|
|
WHERE COALESCE(hidden, 0) <> 1
|
|
AND id = ?
|
|
`, rawSessionID).Scan(
|
|
&meta.RawSessionID,
|
|
&meta.Title,
|
|
&meta.CWD,
|
|
&meta.Model,
|
|
&createdAtMS,
|
|
&lastActivityMS,
|
|
&updatedAtMS,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("loading devin session meta: %w", err)
|
|
}
|
|
|
|
meta.VirtualPath = VirtualSourcePath(dbPath, meta.RawSessionID)
|
|
meta.CreatedAt = devinUnixMilli(createdAtMS)
|
|
if lastActivityMS.Valid {
|
|
meta.LastActivity = devinUnixMilli(lastActivityMS.Int64)
|
|
}
|
|
meta.UpdatedAt = devinUnixMilli(updatedAtMS)
|
|
meta.FileMtime = updatedAtMS * 1_000_000
|
|
return &meta, nil
|
|
}
|
|
|
|
func devinUnixMilli(ms int64) time.Time {
|
|
if ms <= 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.UnixMilli(ms).UTC()
|
|
}
|
|
|
|
type devinTranscriptError struct {
|
|
op string
|
|
cause error
|
|
}
|
|
|
|
func (e *devinTranscriptError) Error() string {
|
|
msg := fmt.Sprintf(
|
|
"%s devin transcript %s for session %s",
|
|
e.op,
|
|
devinRedactedTranscriptPath(),
|
|
devinRedactedSessionID(),
|
|
)
|
|
if e.cause != nil {
|
|
return msg + ": " + devinTranscriptCauseMessage(e.cause)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func (e *devinTranscriptError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.cause
|
|
}
|
|
|
|
func newDevinTranscriptError(op string, cause error) error {
|
|
return &devinTranscriptError{op: op, cause: cause}
|
|
}
|
|
|
|
func devinTranscriptCauseMessage(cause error) string {
|
|
var pathErr *os.PathError
|
|
if errors.As(cause, &pathErr) {
|
|
if pathErr.Op != "" && pathErr.Err != nil {
|
|
return pathErr.Op + ": " + pathErr.Err.Error()
|
|
}
|
|
if pathErr.Err != nil {
|
|
return pathErr.Err.Error()
|
|
}
|
|
return pathErr.Op
|
|
}
|
|
return cause.Error()
|
|
}
|
|
|
|
func devinRedactedTranscriptPath() string {
|
|
return filepath.Join("cli", "transcripts", "<redacted-session-id>.json")
|
|
}
|
|
|
|
func devinRedactedSessionID() string {
|
|
return "<redacted-session-id>"
|
|
}
|
|
|
|
func parseDevinSession(dbPath, rawSessionID, machine string) (*ParsedSession, []ParsedMessage, error) {
|
|
meta, err := getDevinSessionMeta(dbPath, rawSessionID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if meta == nil {
|
|
return nil, nil, sql.ErrNoRows
|
|
}
|
|
|
|
transcriptPath := filepath.Join(filepath.Dir(dbPath), "transcripts", rawSessionID+".json")
|
|
info, err := os.Stat(transcriptPath)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return nil, nil, newDevinTranscriptError("stat", err)
|
|
}
|
|
fallbackErr := newDevinTranscriptError("missing", nil)
|
|
sess, msgs, ok, err := parseDevinSessionFromMessageNodes(dbPath, rawSessionID, machine, meta)
|
|
if err == nil && ok {
|
|
return sess, msgs, nil
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fallbackErr
|
|
}
|
|
return nil, nil, fallbackErr
|
|
}
|
|
|
|
data, err := os.ReadFile(transcriptPath)
|
|
if err != nil {
|
|
return nil, nil, newDevinTranscriptError("read", err)
|
|
}
|
|
if !gjson.ValidBytes(data) {
|
|
return nil, nil, newDevinTranscriptError("invalid", nil)
|
|
}
|
|
|
|
root := gjson.ParseBytes(data)
|
|
steps := root.Get("steps")
|
|
if !steps.IsArray() {
|
|
return nil, nil, newDevinTranscriptError("missing steps array in", nil)
|
|
}
|
|
|
|
model := firstNonEmpty(root.Get("agent.model_name").Str, metaValue(meta, func(m *DevinSessionMeta) string { return m.Model }))
|
|
cwd := firstNonEmpty(
|
|
metaValue(meta, func(m *DevinSessionMeta) string { return m.CWD }),
|
|
devinTranscriptCWD(root),
|
|
)
|
|
|
|
var (
|
|
messages []ParsedMessage
|
|
firstMessage string
|
|
firstStepAt time.Time
|
|
lastStepAt time.Time
|
|
rootStartedAt = parseTimestamp(root.Get("created_at").Str)
|
|
rootEndedAt = parseTimestamp(root.Get("updated_at").Str)
|
|
userMsgCount int
|
|
stepOrdinal int
|
|
)
|
|
|
|
steps.ForEach(func(_, step gjson.Result) bool {
|
|
msg, ok := parseDevinStep(step, stepOrdinal, model)
|
|
stepOrdinal++
|
|
if !ok {
|
|
return true
|
|
}
|
|
messages = append(messages, msg)
|
|
if firstStepAt.IsZero() && !msg.Timestamp.IsZero() {
|
|
firstStepAt = msg.Timestamp
|
|
}
|
|
if msg.Timestamp.After(lastStepAt) {
|
|
lastStepAt = msg.Timestamp
|
|
}
|
|
if msg.Role == RoleUser && strings.TrimSpace(msg.Content) != "" {
|
|
userMsgCount++
|
|
if firstMessage == "" {
|
|
firstMessage = truncate(strings.ReplaceAll(msg.Content, "\n", " "), 300)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
startedAt := firstNonZeroTime(
|
|
metaTime(meta, func(m *DevinSessionMeta) time.Time { return m.CreatedAt }),
|
|
rootStartedAt,
|
|
firstStepAt,
|
|
)
|
|
endedAt := firstNonZeroTime(
|
|
metaTime(meta, func(m *DevinSessionMeta) time.Time { return m.LastActivity }),
|
|
lastStepAt,
|
|
rootEndedAt,
|
|
startedAt,
|
|
)
|
|
|
|
fileInfo := devinBaseFileInfo(dbPath, rawSessionID)
|
|
fileInfo.Size = info.Size()
|
|
fileInfo.Mtime = info.ModTime().UnixNano()
|
|
devinApplyFileInfoTimes(&fileInfo, meta, endedAt)
|
|
|
|
sess := buildDevinParsedSession(meta, rawSessionID, machine, cwd, firstMessage, startedAt, endedAt, userMsgCount, messages, fileInfo)
|
|
accumulateMessageTokenUsage(sess, messages)
|
|
applyDevinFinalMetrics(sess, root.Get("final_metrics"))
|
|
return sess, messages, nil
|
|
}
|
|
|
|
func parseDevinSessionFromMessageNodes(
|
|
dbPath, rawSessionID, machine string,
|
|
meta *DevinSessionMeta,
|
|
) (*ParsedSession, []ParsedMessage, bool, error) {
|
|
rows, err := listDevinMessageNodes(dbPath, rawSessionID)
|
|
if err != nil {
|
|
return nil, nil, false, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil, false, nil
|
|
}
|
|
|
|
model := metaValue(meta, func(m *DevinSessionMeta) string { return m.Model })
|
|
cwd := metaValue(meta, func(m *DevinSessionMeta) string { return m.CWD })
|
|
|
|
var (
|
|
messages []ParsedMessage
|
|
firstMessage string
|
|
firstStepAt time.Time
|
|
lastStepAt time.Time
|
|
userMsgCount int
|
|
)
|
|
for _, row := range rows {
|
|
msg, ok, err := parseDevinDBMessageNode(row, len(messages), model)
|
|
if err != nil {
|
|
return nil, nil, false, err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
messages = append(messages, msg)
|
|
if firstStepAt.IsZero() && !msg.Timestamp.IsZero() {
|
|
firstStepAt = msg.Timestamp
|
|
}
|
|
if msg.Timestamp.After(lastStepAt) {
|
|
lastStepAt = msg.Timestamp
|
|
}
|
|
if msg.Role == RoleUser && !msg.IsSystem && strings.TrimSpace(msg.Content) != "" {
|
|
userMsgCount++
|
|
if firstMessage == "" {
|
|
firstMessage = truncate(strings.ReplaceAll(msg.Content, "\n", " "), 300)
|
|
}
|
|
}
|
|
}
|
|
if len(messages) == 0 {
|
|
return nil, nil, false, nil
|
|
}
|
|
|
|
startedAt := firstNonZeroTime(
|
|
metaTime(meta, func(m *DevinSessionMeta) time.Time { return m.CreatedAt }),
|
|
firstStepAt,
|
|
)
|
|
endedAt := firstNonZeroTime(
|
|
metaTime(meta, func(m *DevinSessionMeta) time.Time { return m.LastActivity }),
|
|
lastStepAt,
|
|
startedAt,
|
|
)
|
|
|
|
fileInfo := devinBaseFileInfo(dbPath, rawSessionID)
|
|
devinApplyFileInfoTimes(&fileInfo, meta, endedAt)
|
|
sess := buildDevinParsedSession(meta, rawSessionID, machine, cwd, firstMessage, startedAt, endedAt, userMsgCount, messages, fileInfo)
|
|
return sess, messages, true, nil
|
|
}
|
|
|
|
type devinMessageNodeRow struct {
|
|
RowID int64
|
|
NodeID int64
|
|
ParentNodeID sql.NullInt64
|
|
ChatMessage string
|
|
CreatedAtMS int64
|
|
}
|
|
|
|
func listDevinMessageNodes(dbPath, rawSessionID string) ([]devinMessageNodeRow, error) {
|
|
db, err := openDevinDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.Query(`
|
|
SELECT row_id,
|
|
node_id,
|
|
parent_node_id,
|
|
chat_message,
|
|
created_at
|
|
FROM message_nodes
|
|
WHERE session_id = ?
|
|
ORDER BY created_at ASC, row_id ASC
|
|
`, rawSessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var nodes []devinMessageNodeRow
|
|
for rows.Next() {
|
|
var row devinMessageNodeRow
|
|
if err := rows.Scan(&row.RowID, &row.NodeID, &row.ParentNodeID, &row.ChatMessage, &row.CreatedAtMS); err != nil {
|
|
return nil, err
|
|
}
|
|
nodes = append(nodes, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return nodes, nil
|
|
}
|
|
|
|
func parseDevinDBMessageNode(
|
|
row devinMessageNodeRow,
|
|
ordinal int,
|
|
model string,
|
|
) (ParsedMessage, bool, error) {
|
|
if !gjson.Valid(row.ChatMessage) {
|
|
return ParsedMessage{}, false, errors.New("invalid devin message_nodes chat_message")
|
|
}
|
|
root := gjson.Parse(row.ChatMessage)
|
|
role, isSystem, ok := devinDBRole(root.Get("role").Str)
|
|
if !ok {
|
|
return ParsedMessage{}, false, nil
|
|
}
|
|
|
|
content, thinking, hasThinking, hasToolUse, toolCalls, toolResults := ExtractTextContent(root.Get("content"))
|
|
topThinking := strings.TrimSpace(root.Get("thinking").Str)
|
|
if topThinking != "" && topThinking != thinking {
|
|
thinking = joinNonEmpty(thinking, topThinking)
|
|
content = joinNonEmpty(content, "[Thinking]\n"+topThinking+"\n[/Thinking]")
|
|
hasThinking = true
|
|
}
|
|
|
|
topLevelToolCalls, topLevelToolText := parseDevinDBToolCalls(root.Get("tool_calls"))
|
|
if len(topLevelToolCalls) > 0 {
|
|
toolCalls = append(toolCalls, topLevelToolCalls...)
|
|
hasToolUse = true
|
|
content = joinNonEmpty(content, topLevelToolText)
|
|
}
|
|
|
|
if role == RoleTool {
|
|
if toolResult, ok := parseDevinDBToolResult(root.Get("tool_call_id"), root.Get("content")); ok {
|
|
toolResults = append(toolResults, toolResult)
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(content) == "" && len(toolCalls) == 0 && len(toolResults) == 0 {
|
|
return ParsedMessage{}, false, nil
|
|
}
|
|
|
|
msg := ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: role,
|
|
Content: content,
|
|
ThinkingText: thinking,
|
|
Timestamp: devinUnixMilli(row.CreatedAtMS),
|
|
HasThinking: hasThinking,
|
|
HasToolUse: hasToolUse || len(toolCalls) > 0,
|
|
IsSystem: isSystem,
|
|
ContentLength: len(content),
|
|
ToolCalls: toolCalls,
|
|
ToolResults: toolResults,
|
|
Model: model,
|
|
SourceUUID: fmt.Sprintf("%d", row.NodeID),
|
|
}
|
|
if row.ParentNodeID.Valid {
|
|
msg.SourceParentUUID = fmt.Sprintf("%d", row.ParentNodeID.Int64)
|
|
}
|
|
return msg, true, nil
|
|
}
|
|
|
|
func parseDevinDBToolCalls(toolCalls gjson.Result) ([]ParsedToolCall, string) {
|
|
if !toolCalls.IsArray() {
|
|
return nil, ""
|
|
}
|
|
var (
|
|
parsed []ParsedToolCall
|
|
parts []string
|
|
)
|
|
toolCalls.ForEach(func(_, tc gjson.Result) bool {
|
|
parsedCall, ok := parseDevinDBToolCall(tc)
|
|
if ok {
|
|
parsed = append(parsed, parsedCall)
|
|
if text := formatDevinDBToolCall(parsedCall); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return parsed, strings.Join(parts, "\n")
|
|
}
|
|
|
|
func parseDevinDBToolCall(tc gjson.Result) (ParsedToolCall, bool) {
|
|
if parsed, ok := parseToolCall(tc); ok {
|
|
return parsed, true
|
|
}
|
|
name := firstNonEmpty(tc.Get("function.name").Str, tc.Get("name").Str)
|
|
if name == "" {
|
|
return ParsedToolCall{}, false
|
|
}
|
|
input := tc.Get("function.arguments")
|
|
inputJSON := input.Raw
|
|
if input.Type == gjson.String {
|
|
inputJSON = input.Str
|
|
}
|
|
if inputJSON == "" {
|
|
input = toolCallInput(tc)
|
|
inputJSON = input.Raw
|
|
}
|
|
return ParsedToolCall{
|
|
ToolUseID: tc.Get("id").Str,
|
|
ToolName: name,
|
|
Category: NormalizeToolCategory(name),
|
|
InputJSON: inputJSON,
|
|
}, true
|
|
}
|
|
|
|
func formatDevinDBToolCall(tc ParsedToolCall) string {
|
|
block := map[string]any{"name": tc.ToolName}
|
|
if strings.TrimSpace(tc.InputJSON) != "" {
|
|
var input any
|
|
if json.Unmarshal([]byte(tc.InputJSON), &input) == nil {
|
|
block["input"] = input
|
|
}
|
|
}
|
|
if raw, err := json.Marshal(block); err == nil {
|
|
return strings.TrimSpace(formatToolUse(gjson.ParseBytes(raw)))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseDevinDBToolResult(toolCallID, content gjson.Result) (ParsedToolResult, bool) {
|
|
id := strings.TrimSpace(toolCallID.Str)
|
|
if id == "" {
|
|
return ParsedToolResult{}, false
|
|
}
|
|
return ParsedToolResult{
|
|
ToolUseID: id,
|
|
ContentLength: toolResultContentLength(content),
|
|
ContentRaw: content.Raw,
|
|
}, true
|
|
}
|
|
|
|
func devinDBRole(role string) (RoleType, bool, bool) {
|
|
switch role {
|
|
case "user":
|
|
return RoleUser, false, true
|
|
case "assistant", "agent":
|
|
return RoleAssistant, false, true
|
|
case "system":
|
|
return RoleSystem, true, true
|
|
case "tool":
|
|
return RoleTool, false, true
|
|
default:
|
|
return "", false, false
|
|
}
|
|
}
|
|
|
|
func devinBaseFileInfo(dbPath, rawSessionID string) FileInfo {
|
|
return FileInfo{Path: VirtualSourcePath(dbPath, rawSessionID)}
|
|
}
|
|
|
|
func devinApplyFileInfoTimes(fileInfo *FileInfo, meta *DevinSessionMeta, endedAt time.Time) {
|
|
if fileInfo == nil {
|
|
return
|
|
}
|
|
if meta != nil && meta.FileMtime > 0 && meta.FileMtime > fileInfo.Mtime {
|
|
fileInfo.Mtime = meta.FileMtime
|
|
}
|
|
if !endedAt.IsZero() && endedAt.UnixNano() > fileInfo.Mtime {
|
|
fileInfo.Mtime = endedAt.UnixNano()
|
|
}
|
|
}
|
|
|
|
func buildDevinParsedSession(
|
|
meta *DevinSessionMeta,
|
|
rawSessionID, machine, cwd, firstMessage string,
|
|
startedAt, endedAt time.Time,
|
|
userMsgCount int,
|
|
messages []ParsedMessage,
|
|
fileInfo FileInfo,
|
|
) *ParsedSession {
|
|
sessionName := firstNonEmpty(
|
|
metaValue(meta, func(m *DevinSessionMeta) string { return m.Title }),
|
|
firstMessage,
|
|
)
|
|
return &ParsedSession{
|
|
ID: "devin:" + rawSessionID,
|
|
Project: ExtractProjectFromCwd(cwd),
|
|
Machine: machine,
|
|
Agent: AgentDevin,
|
|
Cwd: cwd,
|
|
SourceSessionID: rawSessionID,
|
|
SessionName: sessionName,
|
|
FirstMessage: firstMessage,
|
|
StartedAt: startedAt,
|
|
EndedAt: endedAt,
|
|
MessageCount: len(messages),
|
|
UserMessageCount: userMsgCount,
|
|
File: fileInfo,
|
|
}
|
|
}
|
|
|
|
func metaValue[T any](meta *DevinSessionMeta, fn func(*DevinSessionMeta) T) T {
|
|
var zero T
|
|
if meta == nil {
|
|
return zero
|
|
}
|
|
return fn(meta)
|
|
}
|
|
|
|
func metaTime(meta *DevinSessionMeta, fn func(*DevinSessionMeta) time.Time) time.Time {
|
|
if meta == nil {
|
|
return time.Time{}
|
|
}
|
|
return fn(meta)
|
|
}
|
|
|
|
func firstNonZeroTime(times ...time.Time) time.Time {
|
|
for _, ts := range times {
|
|
if !ts.IsZero() {
|
|
return ts
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func devinTranscriptCWD(root gjson.Result) string {
|
|
if cwd := firstNonEmpty(
|
|
root.Get("working_directory").Str,
|
|
root.Get("cwd").Str,
|
|
root.Get("agent.working_directory").Str,
|
|
root.Get("agent.cwd").Str,
|
|
); cwd != "" {
|
|
return cwd
|
|
}
|
|
|
|
workspaceDirs := root.Get("workspace_dirs")
|
|
if !workspaceDirs.IsArray() {
|
|
return ""
|
|
}
|
|
first := workspaceDirs.Array()
|
|
if len(first) == 0 {
|
|
return ""
|
|
}
|
|
return firstNonEmpty(first[0].Str, first[0].Get("root_path").Str, first[0].Get("path").Str)
|
|
}
|
|
|
|
func applyDevinFinalMetrics(sess *ParsedSession, metrics gjson.Result) {
|
|
if !metrics.Exists() || metrics.Type != gjson.JSON {
|
|
return
|
|
}
|
|
|
|
if output, ok := firstPositiveGJSONInt(metrics,
|
|
"output_tokens", "total_completion_tokens"); ok {
|
|
sess.TotalOutputTokens = output
|
|
sess.HasTotalOutputTokens = true
|
|
}
|
|
|
|
if !sess.HasPeakContextTokens {
|
|
context := 0
|
|
hasContext := false
|
|
for _, key := range []string{
|
|
"input_tokens",
|
|
"cache_creation_input_tokens",
|
|
"cache_read_input_tokens",
|
|
} {
|
|
if value, ok := positiveGJSONInt(metrics.Get(key)); ok {
|
|
hasContext = true
|
|
context += value
|
|
}
|
|
}
|
|
if !hasContext {
|
|
if value, ok := positiveGJSONInt(metrics.Get("total_prompt_tokens")); ok {
|
|
hasContext = true
|
|
context = value
|
|
}
|
|
}
|
|
if !hasContext {
|
|
if value, ok := positiveGJSONInt(metrics.Get("total_cached_tokens")); ok {
|
|
hasContext = true
|
|
context = value
|
|
}
|
|
}
|
|
if !hasContext {
|
|
if value, ok := positiveGJSONInt(metrics.Get("context_tokens")); ok {
|
|
hasContext = true
|
|
context = value
|
|
}
|
|
}
|
|
if hasContext {
|
|
sess.PeakContextTokens = context
|
|
sess.HasPeakContextTokens = true
|
|
}
|
|
}
|
|
sess.aggregateTokenPresenceKnown =
|
|
sess.HasTotalOutputTokens || sess.HasPeakContextTokens
|
|
}
|
|
|
|
func firstPositiveGJSONInt(root gjson.Result, keys ...string) (int, bool) {
|
|
for _, key := range keys {
|
|
if value, ok := positiveGJSONInt(root.Get(key)); ok {
|
|
return value, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func positiveGJSONInt(value gjson.Result) (int, bool) {
|
|
if !value.Exists() {
|
|
return 0, false
|
|
}
|
|
if n := int(value.Int()); n > 0 {
|
|
return n, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func parseDevinStep(step gjson.Result, ordinal int, model string) (ParsedMessage, bool) {
|
|
role, isSystem, ok := devinRoleForSource(step.Get("source").Str)
|
|
if !ok {
|
|
return ParsedMessage{}, false
|
|
}
|
|
|
|
content, thinking, hasThinking, hasToolUse, toolCalls, toolResults :=
|
|
ExtractTextContent(step.Get("message"))
|
|
topLevelToolText, topLevelToolCalls := formatTopLevelToolUses(step.Get("tool_use"))
|
|
if topLevelToolText != "" {
|
|
content = joinNonEmpty(content, topLevelToolText)
|
|
hasToolUse = true
|
|
}
|
|
toolCalls = append(toolCalls, topLevelToolCalls...)
|
|
toolResults = append(toolResults, extractTopLevelToolResults(step.Get("tool_result"))...)
|
|
|
|
if strings.TrimSpace(content) == "" && len(toolCalls) == 0 && len(toolResults) == 0 {
|
|
return ParsedMessage{}, false
|
|
}
|
|
if role != RoleUser && strings.TrimSpace(content) == "" && len(toolCalls) == 0 && len(toolResults) > 0 {
|
|
role = RoleTool
|
|
isSystem = false
|
|
}
|
|
tokenUsage, contextTokens, outputTokens, hasContextTokens, hasOutputTokens :=
|
|
devinTokenUsageFromMetrics(step.Get("metrics"))
|
|
messageModel := firstNonEmpty(
|
|
step.Get("extra.generation_model").Str,
|
|
step.Get("model_name").Str,
|
|
model,
|
|
)
|
|
|
|
return ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: role,
|
|
Content: content,
|
|
ThinkingText: thinking,
|
|
Timestamp: devinStepTimestamp(step),
|
|
HasThinking: hasThinking,
|
|
HasToolUse: hasToolUse || len(toolCalls) > 0,
|
|
IsSystem: isSystem,
|
|
ContentLength: len(content),
|
|
ToolCalls: toolCalls,
|
|
ToolResults: toolResults,
|
|
Model: messageModel,
|
|
TokenUsage: tokenUsage,
|
|
ContextTokens: contextTokens,
|
|
OutputTokens: outputTokens,
|
|
HasContextTokens: hasContextTokens,
|
|
HasOutputTokens: hasOutputTokens,
|
|
SourceUUID: devinStepID(step.Get("step_id")),
|
|
}, true
|
|
}
|
|
|
|
func devinTokenUsageFromMetrics(metrics gjson.Result) (
|
|
json.RawMessage, int, int, bool, bool,
|
|
) {
|
|
if !metrics.Exists() || metrics.Type != gjson.JSON {
|
|
return nil, 0, 0, false, false
|
|
}
|
|
|
|
prompt, hasPrompt := nonNegativeGJSONInt(metrics.Get("prompt_tokens"))
|
|
completion, hasCompletion := nonNegativeGJSONInt(metrics.Get("completion_tokens"))
|
|
cached, hasCached := nonNegativeGJSONInt(metrics.Get("cached_tokens"))
|
|
if !hasPrompt && !hasCompletion && !hasCached {
|
|
return nil, 0, 0, false, false
|
|
}
|
|
|
|
input := prompt
|
|
if hasPrompt && hasCached {
|
|
input -= cached
|
|
}
|
|
if input < 0 {
|
|
input = 0
|
|
}
|
|
|
|
payload := make(map[string]int, 3)
|
|
if hasPrompt {
|
|
payload["input_tokens"] = input
|
|
}
|
|
if hasCompletion {
|
|
payload["output_tokens"] = completion
|
|
}
|
|
if hasCached {
|
|
payload["cache_read_input_tokens"] = cached
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, 0, 0, false, false
|
|
}
|
|
|
|
context := input + cached
|
|
return raw, context, completion, hasPrompt || hasCached, hasCompletion
|
|
}
|
|
|
|
func nonNegativeGJSONInt(value gjson.Result) (int, bool) {
|
|
if !value.Exists() {
|
|
return 0, false
|
|
}
|
|
n := int(value.Int())
|
|
if n < 0 {
|
|
return 0, true
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
func devinStepID(stepID gjson.Result) string {
|
|
switch stepID.Type {
|
|
case gjson.String:
|
|
return stepID.Str
|
|
case gjson.Number, gjson.True, gjson.False, gjson.JSON:
|
|
return stepID.Raw
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func formatTopLevelToolUses(toolUses gjson.Result) (string, []ParsedToolCall) {
|
|
if !toolUses.IsArray() {
|
|
return "", nil
|
|
}
|
|
var (
|
|
parts []string
|
|
calls []ParsedToolCall
|
|
)
|
|
toolUses.ForEach(func(_, toolUse gjson.Result) bool {
|
|
if text := strings.TrimSpace(formatToolUse(toolUse)); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
if tc, ok := parseToolCall(toolUse); ok {
|
|
calls = append(calls, tc)
|
|
}
|
|
return true
|
|
})
|
|
return strings.Join(parts, "\n"), calls
|
|
}
|
|
|
|
func extractTopLevelToolResults(toolResults gjson.Result) []ParsedToolResult {
|
|
if !toolResults.IsArray() {
|
|
return nil
|
|
}
|
|
var parsed []ParsedToolResult
|
|
toolResults.ForEach(func(_, toolResult gjson.Result) bool {
|
|
if tr, ok := parseToolResult(toolResult); ok {
|
|
parsed = append(parsed, tr)
|
|
}
|
|
return true
|
|
})
|
|
return parsed
|
|
}
|
|
|
|
func devinRoleForSource(source string) (RoleType, bool, bool) {
|
|
switch source {
|
|
case "user":
|
|
return RoleUser, false, true
|
|
case "agent":
|
|
return RoleAssistant, false, true
|
|
case "system":
|
|
return RoleSystem, true, true
|
|
default:
|
|
return "", false, false
|
|
}
|
|
}
|
|
|
|
func devinStepTimestamp(step gjson.Result) time.Time {
|
|
return parseTimestamp(firstNonEmpty(
|
|
step.Get("timestamp").Str,
|
|
step.Get("created_at").Str,
|
|
step.Get("createdAt").Str,
|
|
step.Get("updated_at").Str,
|
|
step.Get("updatedAt").Str,
|
|
))
|
|
}
|
|
|
|
func joinNonEmpty(parts ...string) string {
|
|
filtered := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if strings.TrimSpace(part) != "" {
|
|
filtered = append(filtered, part)
|
|
}
|
|
}
|
|
return strings.Join(filtered, "\n")
|
|
}
|