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
454 lines
12 KiB
Go
454 lines
12 KiB
Go
package parser
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// ForgeDBFilename is the Forge session store filename inside its data dir.
|
|
const ForgeDBFilename = ".forge.db"
|
|
|
|
// ForgeSessionMeta is lightweight metadata for a session,
|
|
// used to detect changes without parsing messages.
|
|
type ForgeSessionMeta struct {
|
|
SessionID string
|
|
VirtualPath string
|
|
FileMtime int64
|
|
}
|
|
|
|
// forgeDBPath returns the Forge SQLite database path when present.
|
|
func forgeDBPath(dir string) string {
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
path := filepath.Join(dir, ForgeDBFilename)
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.IsDir() {
|
|
return ""
|
|
}
|
|
return path
|
|
}
|
|
|
|
// ListForgeSessionMeta returns lightweight metadata for all
|
|
// conversations without parsing message bodies. Used by the sync
|
|
// engine to detect which sessions have changed.
|
|
func ListForgeSessionMeta(dbPath string) ([]ForgeSessionMeta, error) {
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
|
|
db, err := openForgeDB(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.Query(`
|
|
SELECT conversation_id,
|
|
COALESCE(updated_at, created_at)
|
|
FROM conversations
|
|
WHERE context IS NOT NULL
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing forge conversations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var metas []ForgeSessionMeta
|
|
for rows.Next() {
|
|
var id string
|
|
var updatedAt string
|
|
if err := rows.Scan(&id, &updatedAt); err != nil {
|
|
return nil, fmt.Errorf("scanning forge session meta: %w", err)
|
|
}
|
|
metas = append(metas, ForgeSessionMeta{
|
|
SessionID: id,
|
|
VirtualPath: dbPath + "#" + id,
|
|
FileMtime: parseForgeTimestamp(updatedAt).UnixNano(),
|
|
})
|
|
}
|
|
return metas, rows.Err()
|
|
}
|
|
|
|
// parseForgeSession parses a single conversation by ID from the Forge database.
|
|
func parseForgeSession(dbPath, conversationID, machine string) (*ParsedSession, []ParsedMessage, error) {
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
return nil, nil, fmt.Errorf("forge db not found: %s", dbPath)
|
|
}
|
|
|
|
db, err := openForgeDB(dbPath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer db.Close()
|
|
|
|
c, err := loadOneForgeConversation(db, conversationID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("loading forge conversation %s: %w", conversationID, err)
|
|
}
|
|
return buildForgeSession(c, dbPath, machine)
|
|
}
|
|
|
|
func openForgeDB(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 forge db %s: %w", dbPath, err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
type forgeConversationRow struct {
|
|
id string
|
|
title string
|
|
context string
|
|
createdAt string
|
|
updatedAt string
|
|
metrics string
|
|
}
|
|
|
|
func loadOneForgeConversation(db *sql.DB, conversationID string) (forgeConversationRow, error) {
|
|
row := db.QueryRow(`
|
|
SELECT conversation_id,
|
|
COALESCE(title, ''),
|
|
COALESCE(context, ''),
|
|
created_at,
|
|
COALESCE(updated_at, created_at),
|
|
COALESCE(metrics, '')
|
|
FROM conversations
|
|
WHERE conversation_id = ?
|
|
`, conversationID)
|
|
|
|
var c forgeConversationRow
|
|
err := row.Scan(&c.id, &c.title, &c.context, &c.createdAt, &c.updatedAt, &c.metrics)
|
|
return c, err
|
|
}
|
|
|
|
func buildForgeSession(c forgeConversationRow, dbPath, machine string) (*ParsedSession, []ParsedMessage, error) {
|
|
root := gjson.Parse(c.context)
|
|
messagesRoot := root.Get("messages")
|
|
if !messagesRoot.IsArray() {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
var (
|
|
messages []ParsedMessage
|
|
ordinal int
|
|
realUserCount int
|
|
firstMsg string
|
|
cwd string
|
|
)
|
|
|
|
messagesRoot.ForEach(func(_, item gjson.Result) bool {
|
|
if cwd == "" {
|
|
cwd = extractForgeCurrentWorkingDirectory(item)
|
|
}
|
|
|
|
textMsg := item.Get("message.text")
|
|
if textMsg.Exists() {
|
|
role := strings.ToLower(textMsg.Get("role").Str)
|
|
model := textMsg.Get("model").Str
|
|
ts := parseTimestamp(textMsg.Get("timestamp").Str)
|
|
usageRaw, ctxTokens, outTokens, hasCtx, hasOut := forgeUsageJSON(item.Get("usage"))
|
|
|
|
switch role {
|
|
case "system":
|
|
return true
|
|
case "user":
|
|
content := strings.TrimSpace(textMsg.Get("content").Str)
|
|
if content == "" {
|
|
content = strings.TrimSpace(textMsg.Get("raw_content.Text").Str)
|
|
}
|
|
if content == "" {
|
|
return true
|
|
}
|
|
if firstMsg == "" {
|
|
firstMsg = truncate(strings.ReplaceAll(content, "\n", " "), 300)
|
|
}
|
|
messages = append(messages, ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
Content: content,
|
|
Timestamp: ts,
|
|
ContentLength: len(content),
|
|
Model: model,
|
|
TokenUsage: usageRaw,
|
|
ContextTokens: ctxTokens,
|
|
OutputTokens: outTokens,
|
|
HasContextTokens: hasCtx,
|
|
HasOutputTokens: hasOut,
|
|
tokenPresenceKnown: hasCtx || hasOut,
|
|
})
|
|
ordinal++
|
|
realUserCount++
|
|
case "assistant":
|
|
body := strings.TrimSpace(textMsg.Get("content").Str)
|
|
if body == "" {
|
|
body = strings.TrimSpace(textMsg.Get("raw_content.Text").Str)
|
|
}
|
|
thinking := collectForgeReasoning(textMsg.Get("reasoning_details"))
|
|
hasThinking := thinking != ""
|
|
toolCalls := collectForgeToolCalls(textMsg.Get("tool_calls"))
|
|
display := body
|
|
if hasThinking {
|
|
display = "[Thinking]\n" + thinking + "\n[/Thinking]"
|
|
if body != "" {
|
|
display += "\n" + body
|
|
}
|
|
}
|
|
if display == "" && len(toolCalls) == 0 {
|
|
return true
|
|
}
|
|
messages = append(messages, ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleAssistant,
|
|
Content: display,
|
|
ThinkingText: thinking,
|
|
Timestamp: ts,
|
|
HasThinking: hasThinking,
|
|
HasToolUse: len(toolCalls) > 0,
|
|
ContentLength: len(body) + len(thinking),
|
|
ToolCalls: toolCalls,
|
|
Model: model,
|
|
TokenUsage: usageRaw,
|
|
ContextTokens: ctxTokens,
|
|
OutputTokens: outTokens,
|
|
HasContextTokens: hasCtx,
|
|
HasOutputTokens: hasOut,
|
|
tokenPresenceKnown: hasCtx || hasOut,
|
|
})
|
|
ordinal++
|
|
}
|
|
return true
|
|
}
|
|
|
|
toolMsg := item.Get("message.tool")
|
|
if toolMsg.Exists() {
|
|
callID := toolMsg.Get("call_id").Str
|
|
if callID == "" {
|
|
return true
|
|
}
|
|
content := forgeToolOutputText(toolMsg.Get("output"))
|
|
quoted, _ := json.Marshal(content)
|
|
messages = append(messages, ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: RoleUser,
|
|
Content: "",
|
|
ContentLength: len(content),
|
|
ToolResults: []ParsedToolResult{{
|
|
ToolUseID: callID,
|
|
ContentLength: len(content),
|
|
ContentRaw: string(quoted),
|
|
}},
|
|
})
|
|
ordinal++
|
|
}
|
|
return true
|
|
})
|
|
|
|
if len(messages) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
project := ExtractProjectFromCwd(cwd)
|
|
startedAt := parseForgeTimestamp(c.createdAt)
|
|
endedAt := parseForgeTimestamp(c.updatedAt)
|
|
if endedAt.IsZero() {
|
|
endedAt = startedAt
|
|
}
|
|
|
|
fileInfo := FileInfo{Path: dbPath + "#" + c.id, Mtime: endedAt.UnixNano()}
|
|
if info, err := os.Stat(dbPath); err == nil {
|
|
fileInfo.Size = info.Size()
|
|
if fileInfo.Mtime == 0 {
|
|
fileInfo.Mtime = info.ModTime().UnixNano()
|
|
}
|
|
}
|
|
|
|
sess := &ParsedSession{
|
|
ID: "forge:" + c.id,
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: AgentForge,
|
|
Cwd: cwd,
|
|
SessionName: c.title,
|
|
FirstMessage: firstMsg,
|
|
StartedAt: startedAt,
|
|
EndedAt: endedAt,
|
|
MessageCount: len(messages),
|
|
UserMessageCount: realUserCount,
|
|
File: fileInfo,
|
|
}
|
|
applyForgeMetrics(sess, c.metrics)
|
|
if !sess.aggregateTokenPresenceKnown {
|
|
accumulateMessageTokenUsage(sess, messages)
|
|
}
|
|
return sess, messages, nil
|
|
}
|
|
|
|
func collectForgeReasoning(details gjson.Result) string {
|
|
if !details.IsArray() {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
details.ForEach(func(_, item gjson.Result) bool {
|
|
if text := strings.TrimSpace(item.Get("text").Str); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
return true
|
|
})
|
|
return strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
func collectForgeToolCalls(toolCalls gjson.Result) []ParsedToolCall {
|
|
if !toolCalls.IsArray() {
|
|
return nil
|
|
}
|
|
var parsed []ParsedToolCall
|
|
toolCalls.ForEach(func(_, tc gjson.Result) bool {
|
|
name := tc.Get("name").Str
|
|
if name == "" {
|
|
return true
|
|
}
|
|
ptc := ParsedToolCall{
|
|
ToolUseID: tc.Get("call_id").Str,
|
|
ToolName: name,
|
|
Category: NormalizeToolCategory(name),
|
|
InputJSON: tc.Get("arguments").Raw,
|
|
}
|
|
switch name {
|
|
case "skill":
|
|
ptc.SkillName = tc.Get("arguments.name").Str
|
|
if ptc.SkillName == "" {
|
|
ptc.SkillName = tc.Get("arguments.skill").Str
|
|
}
|
|
case "task":
|
|
if rawSubID := tc.Get("arguments.session_id").Str; rawSubID != "" {
|
|
ptc.SubagentSessionID = "forge:" + rawSubID
|
|
}
|
|
}
|
|
parsed = append(parsed, ptc)
|
|
return true
|
|
})
|
|
return parsed
|
|
}
|
|
|
|
func forgeUsageJSON(usage gjson.Result) ([]byte, int, int, bool, bool) {
|
|
prompt := usage.Get("prompt_tokens.actual")
|
|
completion := usage.Get("completion_tokens.actual")
|
|
cached := usage.Get("cached_tokens.actual")
|
|
|
|
hasPrompt := prompt.Exists()
|
|
hasCompletion := completion.Exists()
|
|
hasCached := cached.Exists()
|
|
hasContext := hasPrompt || hasCached
|
|
hasOutput := hasCompletion
|
|
|
|
if !hasContext && !hasOutput {
|
|
return nil, 0, 0, false, false
|
|
}
|
|
|
|
payload := make(map[string]int)
|
|
if hasPrompt {
|
|
payload["input_tokens"] = int(prompt.Int())
|
|
}
|
|
if hasCached {
|
|
payload["cache_read_input_tokens"] = int(cached.Int())
|
|
}
|
|
if hasCompletion {
|
|
payload["output_tokens"] = int(completion.Int())
|
|
}
|
|
raw, _ := json.Marshal(payload)
|
|
return raw, int(prompt.Int()) + int(cached.Int()), int(completion.Int()), hasContext, hasOutput
|
|
}
|
|
|
|
func applyForgeMetrics(sess *ParsedSession, metricsRaw string) {
|
|
metrics := gjson.Parse(metricsRaw)
|
|
if !metrics.Exists() {
|
|
return
|
|
}
|
|
output := metrics.Get("output_tokens")
|
|
input := metrics.Get("input_tokens")
|
|
cached := metrics.Get("cached_input_tokens")
|
|
if output.Exists() {
|
|
sess.TotalOutputTokens = int(output.Int())
|
|
sess.HasTotalOutputTokens = true
|
|
}
|
|
if input.Exists() || cached.Exists() {
|
|
sess.PeakContextTokens = int(input.Int()) + int(cached.Int())
|
|
sess.HasPeakContextTokens = true
|
|
}
|
|
sess.aggregateTokenPresenceKnown = sess.HasTotalOutputTokens || sess.HasPeakContextTokens
|
|
}
|
|
|
|
func forgeToolOutputText(output gjson.Result) string {
|
|
var parts []string
|
|
values := output.Get("values")
|
|
if values.IsArray() {
|
|
values.ForEach(func(_, item gjson.Result) bool {
|
|
if text := item.Get("text").Str; text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
if len(parts) > 0 {
|
|
return strings.Join(parts, "")
|
|
}
|
|
if text := output.Get("text").Str; text != "" {
|
|
return text
|
|
}
|
|
if raw := strings.TrimSpace(output.Raw); raw != "" && raw != "null" {
|
|
return raw
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractForgeCurrentWorkingDirectory(item gjson.Result) string {
|
|
content := item.Get("message.text.content").Str
|
|
if content == "" {
|
|
content = item.Get("message.text.raw_content.Text").Str
|
|
}
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
startTag := "<current_working_directory>"
|
|
endTag := "</current_working_directory>"
|
|
start := strings.Index(content, startTag)
|
|
if start < 0 {
|
|
return ""
|
|
}
|
|
start += len(startTag)
|
|
end := strings.Index(content[start:], endTag)
|
|
if end < 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(content[start : start+end])
|
|
}
|
|
|
|
func parseForgeTimestamp(raw string) time.Time {
|
|
if raw == "" {
|
|
return time.Time{}
|
|
}
|
|
for _, layout := range []string{
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05.999999999",
|
|
"2006-01-02 15:04:05.999999",
|
|
"2006-01-02 15:04:05",
|
|
} {
|
|
if t, err := time.Parse(layout, raw); err == nil {
|
|
return t.UTC()
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|