// ABOUTME: Parses Hermes Agent JSONL session files into structured session data. // ABOUTME: Handles Hermes's OpenAI-style message format with session_meta header, // ABOUTME: user/assistant/tool roles, and function-call tool invocations. package parser import ( "database/sql" "encoding/json" "fmt" "io" "log" "math" "os" "path/filepath" "sort" "strings" "time" _ "github.com/mattn/go-sqlite3" "github.com/tidwall/gjson" ) type hermesStateSession struct { id string source string model string parentSessionID string startedAt time.Time endedAt time.Time messageCount int inputTokens int outputTokens int cacheReadTokens int cacheWriteTokens int reasoningTokens int estimatedCost sql.NullFloat64 actualCost sql.NullFloat64 costStatus string costSource string title string apiCallCount int } type hermesStateMessage struct { role string content string toolCallID string toolCalls string timestamp time.Time finishReason string reasoning string reasoningContent string reasoningDetails string codexReasoningItems string codexMessageItems string } // parseArchive parses a Hermes root directory. If a state.db is present, it // uses that database for session metadata and usage while selecting the richest // available message stream. Without state.db it falls back to the // transcript-file parser. It owns the archive on-disk shape (state.db plus the // sessions transcript directory) for the Hermes provider; the package-level // entrypoint was folded onto the provider. func (p *hermesProvider) parseArchive(root, project, machine string) ([]ParseResult, error) { stateDB, sessionsDir, ok := hermesStatePaths(root) if !ok { return p.parseTranscriptArchive(root, project, machine) } results, err := p.parseStateDB( stateDB, sessionsDir, project, machine, ) if err == nil { return results, nil } log.Printf( "hermes: state db parse failed for %s: %v; falling back to transcripts", stateDB, err, ) return p.parseTranscriptArchive( sessionsDir, project, machine, ) } func (p *hermesProvider) parseTranscriptArchive( root, project, machine string, ) ([]ParseResult, error) { var results []ParseResult for _, file := range discoverHermesTranscriptFiles(root) { fileProject := file.Project if project != "" { fileProject = project } sess, msgs, err := p.parseSession( file.Path, fileProject, machine, ) if err != nil { return nil, err } if sess != nil { results = append(results, ParseResult{ Session: *sess, Messages: msgs, }) } } return results, nil } // parseSession parses a Hermes Agent session file. It owns the on-disk shape // (flat JSONL transcripts plus session_*.json snapshots) for the Hermes // provider; the package-level entrypoint was folded onto the provider. // // Hermes stores sessions as flat JSONL files in ~/.hermes/sessions/ // with filenames like 20260403_153620_5a3e2ff1.jsonl. // // Line format: // - First line: {"role":"session_meta", "tools":[...], "model":"...", "platform":"...", "timestamp":"..."} // - User messages: {"role":"user", "content":"...", "timestamp":"..."} // - Assistant messages: {"role":"assistant", "content":"...", "reasoning":"...", // "finish_reason":"tool_calls|stop", "tool_calls":[...], "timestamp":"..."} // - Tool results: {"role":"tool", "content":"...", "tool_call_id":"...", "timestamp":"..."} func (p *hermesProvider) parseSession(path, project, machine string) (*ParsedSession, []ParsedMessage, error) { if strings.HasSuffix(path, ".json") { return parseHermesJSONSession(path, project, machine) } return parseHermesJSONLSession(path, project, machine) } // parseHermesJSONLSession parses a Hermes Agent JSONL session file. func parseHermesJSONLSession(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) var ( messages []ParsedMessage startedAt time.Time endedAt time.Time ordinal int realUserCount int firstMsg string sessionPlatform string ) // Extract session ID from filename: 20260403_153620_5a3e2ff1.jsonl -> 20260403_153620_5a3e2ff1 sessionID := HermesSessionID(filepath.Base(path)) for { line, ok := lr.next() if !ok { break } if !gjson.Valid(line) { continue } role := gjson.Get(line, "role").Str ts := parseHermesTimestamp(gjson.Get(line, "timestamp").Str) if !ts.IsZero() { if startedAt.IsZero() || ts.Before(startedAt) { startedAt = ts } if ts.After(endedAt) { endedAt = ts } } switch role { case "session_meta": // Extract model and platform from session header. sessionPlatform = gjson.Get(line, "platform").Str continue case "user": content := gjson.Get(line, "content").Str content = strings.TrimSpace(content) if content == "" { continue } // Strip skill injection prefixes for cleaner display. displayContent := stripHermesSkillPrefix(content) isCompact := isHermesCompactBoundary(displayContent) if firstMsg == "" && displayContent != "" && !isCompact { firstMsg = truncate( strings.ReplaceAll(displayContent, "\n", " "), 300, ) } messages = append(messages, ParsedMessage{ Ordinal: ordinal, Role: RoleUser, Content: displayContent, Timestamp: ts, ContentLength: len(content), IsSystem: isCompact, SourceType: sourceTypeIf(isCompact, "system"), SourceSubtype: sourceTypeIf(isCompact, "compact_boundary"), IsCompactBoundary: isCompact, }) ordinal++ if !isCompact { realUserCount++ } case "assistant": content := gjson.Get(line, "content").Str content = strings.TrimSpace(content) reasoning := gjson.Get(line, "reasoning").Str hasThinking := reasoning != "" // Extract tool calls from the assistant message. var toolCalls []ParsedToolCall tcArray := gjson.Get(line, "tool_calls") if tcArray.IsArray() { tcArray.ForEach(func(_, tc gjson.Result) bool { name := tc.Get("function.name").Str if name != "" { toolCalls = append(toolCalls, ParsedToolCall{ ToolUseID: tc.Get("id").Str, ToolName: name, Category: NormalizeToolCategory(name), InputJSON: tc.Get("function.arguments").Str, }) } return true }) } hasToolUse := len(toolCalls) > 0 // Build display content: include reasoning if present. displayContent := content if hasThinking && content == "" { // Assistant message with only reasoning and tool calls. displayContent = "" } if hasThinking { displayContent = "[Thinking]\n" + reasoning + "\n[/Thinking]\n" + displayContent } if displayContent == "" && len(toolCalls) == 0 { continue } messages = append(messages, ParsedMessage{ Ordinal: ordinal, Role: RoleAssistant, Content: displayContent, Timestamp: ts, HasThinking: hasThinking, HasToolUse: hasToolUse, ContentLength: len(content) + len(reasoning), ToolCalls: toolCalls, }) ordinal++ case "tool": // Tool results in Hermes are separate messages with // tool_call_id linking back to the assistant's tool call. toolCallID := gjson.Get(line, "tool_call_id").Str if toolCallID == "" { continue } content := gjson.Get(line, "content").Str contentLen := len(content) // Preserve tool output as JSON-quoted string so // pairToolResults / DecodeContent can surface it in the UI. quoted, _ := json.Marshal(content) messages = append(messages, ParsedMessage{ Ordinal: ordinal, Role: RoleUser, Content: "", Timestamp: ts, ContentLength: contentLen, ToolResults: []ParsedToolResult{{ ToolUseID: toolCallID, ContentRaw: string(quoted), ContentLength: contentLen, }}, }) ordinal++ } } if err := lr.Err(); err != nil { return nil, nil, fmt.Errorf("reading %s: %w", path, err) } if len(messages) == 0 { return nil, nil, nil } fullID := "hermes:" + sessionID // Derive project from the session platform or default. if project == "" { if sessionPlatform != "" { project = "hermes-" + sessionPlatform } else { project = "hermes" } } sess := &ParsedSession{ ID: fullID, Project: project, Machine: machine, Agent: AgentHermes, FirstMessage: firstMsg, StartedAt: startedAt, EndedAt: endedAt, MessageCount: len(messages), UserMessageCount: realUserCount, File: FileInfo{ Path: path, Size: info.Size(), Mtime: info.ModTime().UnixNano(), }, } return sess, messages, nil } // parseHermesJSONSession parses a Hermes CLI-format JSON session file. func parseHermesJSONSession(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) } data, err := os.ReadFile(path) if err != nil { return nil, nil, fmt.Errorf("read %s: %w", path, err) } root := gjson.ParseBytes(data) if !root.IsObject() { return nil, nil, fmt.Errorf("invalid JSON in %s", path) } sessionID := HermesSessionID(filepath.Base(path)) sessionPlatform := root.Get("platform").Str startedAt := parseHermesTimestamp(root.Get("session_start").Str) endedAt := parseHermesTimestamp(root.Get("last_updated").Str) var ( messages []ParsedMessage ordinal int realUserCount int firstMsg string ) root.Get("messages").ForEach(func(_, msg gjson.Result) bool { role := msg.Get("role").Str // Extract per-message timestamp when available. msgTS := parseHermesTimestamp(msg.Get("timestamp").Str) // Reconcile per-message timestamps with session bounds so // StartedAt/EndedAt stay correct even if envelope fields // are missing or stale. if !msgTS.IsZero() { if startedAt.IsZero() || msgTS.Before(startedAt) { startedAt = msgTS } if msgTS.After(endedAt) { endedAt = msgTS } } switch role { case "user": content := strings.TrimSpace(msg.Get("content").Str) if content == "" { return true } displayContent := stripHermesSkillPrefix(content) isCompact := isHermesCompactBoundary(displayContent) if firstMsg == "" && displayContent != "" && !isCompact { firstMsg = truncate( strings.ReplaceAll(displayContent, "\n", " "), 300, ) } messages = append(messages, ParsedMessage{ Ordinal: ordinal, Role: RoleUser, Content: displayContent, Timestamp: msgTS, ContentLength: len(content), IsSystem: isCompact, SourceType: sourceTypeIf(isCompact, "system"), SourceSubtype: sourceTypeIf(isCompact, "compact_boundary"), IsCompactBoundary: isCompact, }) ordinal++ if !isCompact { realUserCount++ } case "assistant": content := strings.TrimSpace(msg.Get("content").Str) reasoning := msg.Get("reasoning").Str if reasoning == "" { reasoning = msg.Get("reasoning_details").Str } hasThinking := reasoning != "" var toolCalls []ParsedToolCall tcArray := msg.Get("tool_calls") if tcArray.IsArray() { tcArray.ForEach(func(_, tc gjson.Result) bool { name := tc.Get("function.name").Str if name != "" { toolCalls = append(toolCalls, ParsedToolCall{ ToolUseID: tc.Get("id").Str, ToolName: name, Category: NormalizeToolCategory(name), InputJSON: tc.Get("function.arguments").Str, }) } return true }) } hasToolUse := len(toolCalls) > 0 displayContent := content if hasThinking && content == "" { displayContent = "" } if hasThinking { displayContent = "[Thinking]\n" + reasoning + "\n[/Thinking]\n" + displayContent } if displayContent == "" && len(toolCalls) == 0 { return true } messages = append(messages, ParsedMessage{ Ordinal: ordinal, Role: RoleAssistant, Content: displayContent, Timestamp: msgTS, HasThinking: hasThinking, HasToolUse: hasToolUse, ContentLength: len(content) + len(reasoning), ToolCalls: toolCalls, }) ordinal++ case "tool": toolCallID := msg.Get("tool_call_id").Str if toolCallID == "" { return true } content := msg.Get("content").Str contentLen := len(content) // Preserve tool output as JSON-quoted string so // pairToolResults / DecodeContent can surface it in the UI. quoted, _ := json.Marshal(content) messages = append(messages, ParsedMessage{ Ordinal: ordinal, Role: RoleUser, Content: "", Timestamp: msgTS, ContentLength: contentLen, ToolResults: []ParsedToolResult{{ ToolUseID: toolCallID, ContentRaw: string(quoted), ContentLength: contentLen, }}, }) ordinal++ } return true }) if len(messages) == 0 { return nil, nil, nil } fullID := "hermes:" + sessionID if project == "" { if sessionPlatform != "" { project = "hermes-" + sessionPlatform } else { project = "hermes" } } sess := &ParsedSession{ ID: fullID, Project: project, Machine: machine, Agent: AgentHermes, FirstMessage: firstMsg, StartedAt: startedAt, EndedAt: endedAt, MessageCount: len(messages), UserMessageCount: realUserCount, File: FileInfo{ Path: path, Size: info.Size(), Mtime: info.ModTime().UnixNano(), }, } return sess, messages, nil } func hermesStatePaths(root string) (stateDB, sessionsDir string, ok bool) { if root == "" { return "", "", false } info, err := os.Stat(root) if err == nil && !info.IsDir() && filepath.Base(root) == "state.db" { dir := filepath.Dir(root) return root, filepath.Join(dir, "sessions"), true } if st := filepath.Join(root, "state.db"); IsRegularFile(st) { return st, filepath.Join(root, "sessions"), true } if filepath.Base(root) == "sessions" { parent := filepath.Dir(root) st := filepath.Join(parent, "state.db") if IsRegularFile(st) { return st, root, true } } return "", "", false } func (p *hermesProvider) parseStateDB( stateDB, sessionsDir, project, machine string, ) ([]ParseResult, error) { conn, err := sql.Open("sqlite3", "file:"+sqliteURIPath(stateDB)+"?mode=ro") if err != nil { return nil, fmt.Errorf("open hermes state db: %w", err) } defer conn.Close() sessions, err := readHermesStateSessions(conn) if err != nil { return nil, err } messages, err := readHermesStateMessages(conn) if err != nil { return nil, err } var results []ParseResult seen := make(map[string]struct{}, len(sessions)) for _, ss := range sessions { res, ok := buildHermesStateResult( ss, messages[ss.id], sessionsDir, stateDB, project, machine, ) if ok { results = append(results, res) seen[ss.id] = struct{}{} } } for _, file := range discoverHermesTranscriptFiles(sessionsDir) { rawID := HermesSessionID(filepath.Base(file.Path)) if _, ok := seen[rawID]; ok { continue } sess, msgs, err := p.parseSession( file.Path, file.Project, machine, ) if err != nil { return nil, err } if sess != nil { results = append(results, ParseResult{ Session: *sess, Messages: msgs, }) } } sort.Slice(results, func(i, j int) bool { return results[i].Session.ID < results[j].Session.ID }) return results, nil } func readHermesStateSessions( conn *sql.DB, ) ([]hermesStateSession, error) { rows, err := conn.Query(` SELECT id, source, COALESCE(model, ''), COALESCE(parent_session_id, ''), started_at, COALESCE(ended_at, 0), COALESCE(message_count, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0), COALESCE(cache_read_tokens, 0), COALESCE(cache_write_tokens, 0), COALESCE(reasoning_tokens, 0), estimated_cost_usd, actual_cost_usd, COALESCE(cost_status, ''), COALESCE(cost_source, ''), COALESCE(title, ''), COALESCE(api_call_count, 0) FROM sessions ORDER BY started_at ASC, id ASC`) if err != nil { return nil, fmt.Errorf("query hermes sessions: %w", err) } defer rows.Close() var out []hermesStateSession for rows.Next() { var ss hermesStateSession var started, ended float64 if err := rows.Scan( &ss.id, &ss.source, &ss.model, &ss.parentSessionID, &started, &ended, &ss.messageCount, &ss.inputTokens, &ss.outputTokens, &ss.cacheReadTokens, &ss.cacheWriteTokens, &ss.reasoningTokens, &ss.estimatedCost, &ss.actualCost, &ss.costStatus, &ss.costSource, &ss.title, &ss.apiCallCount, ); err != nil { return nil, fmt.Errorf("scan hermes session: %w", err) } ss.startedAt = hermesUnixTime(started) ss.endedAt = hermesUnixTime(ended) out = append(out, ss) } return out, rows.Err() } func readHermesStateMessages( conn *sql.DB, ) (map[string][]hermesStateMessage, error) { rows, err := conn.Query(` SELECT session_id, role, COALESCE(content, ''), COALESCE(tool_call_id, ''), COALESCE(tool_calls, ''), timestamp, COALESCE(finish_reason, ''), COALESCE(reasoning, ''), COALESCE(reasoning_content, ''), COALESCE(reasoning_details, ''), COALESCE(codex_reasoning_items, ''), COALESCE(codex_message_items, '') FROM messages ORDER BY session_id ASC, timestamp ASC, id ASC`) if err != nil { return nil, fmt.Errorf("query hermes messages: %w", err) } defer rows.Close() out := make(map[string][]hermesStateMessage) for rows.Next() { var sid string var hm hermesStateMessage var ts float64 if err := rows.Scan( &sid, &hm.role, &hm.content, &hm.toolCallID, &hm.toolCalls, &ts, &hm.finishReason, &hm.reasoning, &hm.reasoningContent, &hm.reasoningDetails, &hm.codexReasoningItems, &hm.codexMessageItems, ); err != nil { return nil, fmt.Errorf("scan hermes message: %w", err) } hm.timestamp = hermesUnixTime(ts) out[sid] = append(out[sid], hm) } return out, rows.Err() } func readHermesStateSession( conn *sql.DB, rawSessionID string, ) (hermesStateSession, bool, error) { row := conn.QueryRow(` SELECT id, source, COALESCE(model, ''), COALESCE(parent_session_id, ''), started_at, COALESCE(ended_at, 0), COALESCE(message_count, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0), COALESCE(cache_read_tokens, 0), COALESCE(cache_write_tokens, 0), COALESCE(reasoning_tokens, 0), estimated_cost_usd, actual_cost_usd, COALESCE(cost_status, ''), COALESCE(cost_source, ''), COALESCE(title, ''), COALESCE(api_call_count, 0) FROM sessions WHERE id = ?`, rawSessionID, ) var ss hermesStateSession var started, ended float64 err := row.Scan( &ss.id, &ss.source, &ss.model, &ss.parentSessionID, &started, &ended, &ss.messageCount, &ss.inputTokens, &ss.outputTokens, &ss.cacheReadTokens, &ss.cacheWriteTokens, &ss.reasoningTokens, &ss.estimatedCost, &ss.actualCost, &ss.costStatus, &ss.costSource, &ss.title, &ss.apiCallCount, ) if err == sql.ErrNoRows { return hermesStateSession{}, false, nil } if err != nil { return hermesStateSession{}, false, fmt.Errorf( "query hermes session %s: %w", rawSessionID, err, ) } ss.startedAt = hermesUnixTime(started) ss.endedAt = hermesUnixTime(ended) return ss, true, nil } func readHermesStateMessagesForSession( conn *sql.DB, rawSessionID string, ) ([]hermesStateMessage, error) { rows, err := conn.Query(` SELECT role, COALESCE(content, ''), COALESCE(tool_call_id, ''), COALESCE(tool_calls, ''), timestamp, COALESCE(finish_reason, ''), COALESCE(reasoning, ''), COALESCE(reasoning_content, ''), COALESCE(reasoning_details, ''), COALESCE(codex_reasoning_items, ''), COALESCE(codex_message_items, '') FROM messages WHERE session_id = ? ORDER BY timestamp ASC, id ASC`, rawSessionID, ) if err != nil { return nil, fmt.Errorf( "query hermes messages for %s: %w", rawSessionID, err, ) } defer rows.Close() var out []hermesStateMessage for rows.Next() { var hm hermesStateMessage var ts float64 if err := rows.Scan( &hm.role, &hm.content, &hm.toolCallID, &hm.toolCalls, &ts, &hm.finishReason, &hm.reasoning, &hm.reasoningContent, &hm.reasoningDetails, &hm.codexReasoningItems, &hm.codexMessageItems, ); err != nil { return nil, fmt.Errorf( "scan hermes message for %s: %w", rawSessionID, err, ) } hm.timestamp = hermesUnixTime(ts) out = append(out, hm) } return out, rows.Err() } func writeHermesStateSessionJSONL( w io.Writer, stateDB, rawSessionID string, ) error { conn, err := sql.Open("sqlite3", "file:"+sqliteURIPath(stateDB)+"?mode=ro") if err != nil { return hermesStateLookupError{ err: fmt.Errorf("open hermes state db: %w", err), } } defer conn.Close() ss, found, err := readHermesStateSession(conn, rawSessionID) if err != nil { return hermesStateLookupError{err: err} } if !found { return fmt.Errorf( "hermes session %s not found in %s: %w", rawSessionID, stateDB, os.ErrNotExist, ) } messages, err := readHermesStateMessagesForSession(conn, rawSessionID) if err != nil { return hermesStateLookupError{err: err} } selectedPath, _, _, _ := chooseHermesStateSessionSource( ss, messages, filepath.Join(filepath.Dir(stateDB), "sessions"), stateDB, "", "", ) if selectedPath != stateDB { return copyHermesTranscriptFile(w, selectedPath) } return encodeHermesStateSessionJSONL(w, ss, messages) } func copyHermesTranscriptFile(w io.Writer, path string) error { f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("open %s: %w", path, os.ErrNotExist) } return fmt.Errorf("open %s: %w", path, err) } defer f.Close() _, err = io.Copy(w, f) return err } func encodeHermesStateSessionJSONL( w io.Writer, ss hermesStateSession, messages []hermesStateMessage, ) error { enc := json.NewEncoder(w) enc.SetEscapeHTML(false) meta := map[string]any{"role": "session_meta"} if ss.model != "" { meta["model"] = ss.model } if ts := timeString(ss.startedAt, time.Time{}); ts != "" { meta["timestamp"] = ts } if err := enc.Encode(meta); err != nil { return fmt.Errorf("encode hermes session meta: %w", err) } for _, hm := range messages { record := map[string]any{"role": hm.role} if hm.content != "" { record["content"] = hm.content } if hm.toolCallID != "" { record["tool_call_id"] = hm.toolCallID } if hm.toolCalls != "" && json.Valid([]byte(hm.toolCalls)) { record["tool_calls"] = json.RawMessage(hm.toolCalls) } if ts := timeString(hm.timestamp, time.Time{}); ts != "" { record["timestamp"] = ts } if hm.finishReason != "" { record["finish_reason"] = hm.finishReason } if hm.reasoning != "" { record["reasoning"] = hm.reasoning } if hm.reasoningContent != "" { record["reasoning_content"] = hm.reasoningContent } if hm.reasoningDetails != "" { record["reasoning_details"] = hm.reasoningDetails } if hm.codexReasoningItems != "" && json.Valid([]byte(hm.codexReasoningItems)) { record["codex_reasoning_items"] = json.RawMessage( hm.codexReasoningItems, ) } if hm.codexMessageItems != "" && json.Valid([]byte(hm.codexMessageItems)) { record["codex_message_items"] = json.RawMessage( hm.codexMessageItems, ) } if err := enc.Encode(record); err != nil { return fmt.Errorf("encode hermes session %s: %w", ss.id, err) } } return nil } func buildHermesStateResult( ss hermesStateSession, stateMessages []hermesStateMessage, sessionsDir, stateDB, project, machine string, ) (ParseResult, bool) { selectedPath, sess, msgs, _ := chooseHermesStateSessionSource( ss, stateMessages, sessionsDir, stateDB, project, machine, ) usageEvents := hermesUsageEvents(ss, "hermes:"+ss.id) if sess == nil { msgs = convertHermesStateMessages(stateMessages) if len(msgs) == 0 && len(usageEvents) == 0 { return ParseResult{}, false } sess = &ParsedSession{ ID: "hermes:" + ss.id, Agent: AgentHermes, Machine: machine, StartedAt: ss.startedAt, EndedAt: ss.endedAt, MessageCount: len(msgs), UserMessageCount: countHermesUsers(msgs), FirstMessage: firstHermesMessage(msgs), } } applyHermesStateMetadata(sess, ss, selectedPath, project) return ParseResult{ Session: *sess, Messages: msgs, UsageEvents: usageEvents, }, true } func chooseHermesStateSessionSource( ss hermesStateSession, stateMessages []hermesStateMessage, sessionsDir, stateDB, project, machine string, ) (selectedPath string, sess *ParsedSession, msgs []ParsedMessage, err error) { selectedPath = stateDB jsonPath := filepath.Join(sessionsDir, "session_"+ss.id+".json") jsonlPath := filepath.Join(sessionsDir, ss.id+".jsonl") if IsRegularFile(jsonPath) { sess, msgs, err = parseHermesJSONSession(jsonPath, project, machine) if err == nil && sess != nil && hermesMessageQuality(msgs) >= hermesStateQuality(stateMessages) { return jsonPath, sess, msgs, nil } } if IsRegularFile(jsonlPath) { sess, msgs, err = parseHermesJSONLSession(jsonlPath, project, machine) if err == nil && sess != nil && (hermesMessageQuality(msgs) >= hermesStateQuality(stateMessages) || len(stateMessages) == 0) { return jsonlPath, sess, msgs, nil } } return selectedPath, nil, nil, nil } func applyHermesStateMetadata( sess *ParsedSession, ss hermesStateSession, selectedPath, project string, ) { sess.ID = "hermes:" + ss.id sess.Agent = AgentHermes if project != "" { sess.Project = project } else if ss.source != "" { sess.Project = "hermes-" + ss.source } else if sess.Project == "" { sess.Project = "hermes" } if !ss.startedAt.IsZero() { sess.StartedAt = ss.startedAt } if !ss.endedAt.IsZero() { sess.EndedAt = ss.endedAt } if ss.parentSessionID != "" { sess.ParentSessionID = "hermes:" + ss.parentSessionID sess.RelationshipType = RelContinuation } sess.SourceSessionID = ss.id sess.SourceVersion = "hermes-state-db" sess.SessionName = ss.title // Populate the session-aggregate token columns from Hermes's own // authoritative state.db accounting. These feed the session list, // session detail, and stats portfolio, which read the aggregate // fields directly and do not fall back to usage_events. The // transcript paths yield 0 here (per-message token_count is 0 in // state.db), so these values are strictly better; set them // unconditionally when present. PeakContextTokens uses the // cumulative input + cache_read approximation (matching forge's // convention); a truer last-prompt peak lives only in sessions.json. if ss.outputTokens > 0 { sess.TotalOutputTokens = ss.outputTokens sess.HasTotalOutputTokens = true } if ctx := ss.inputTokens + ss.cacheReadTokens; ctx > 0 { sess.PeakContextTokens = ctx sess.HasPeakContextTokens = true } sess.aggregateTokenPresenceKnown = sess.HasTotalOutputTokens || sess.HasPeakContextTokens if selectedPath != "" { if info, err := os.Stat(selectedPath); err == nil { sess.File = FileInfo{ Path: selectedPath, Size: info.Size(), Mtime: info.ModTime().UnixNano(), } } } } // hermesHasCostSource reports whether a Hermes cost_source represents a // real cost determination. "none" (and empty) mean Hermes had no basis // for the figure, so a $0 it pairs with cost_status "included" is a // default placeholder rather than a confident free-usage signal. func hermesHasCostSource(costSource string) bool { return costSource != "" && costSource != "none" } func hermesUsageEvents( ss hermesStateSession, sessionID string, ) []ParsedUsageEvent { if ss.model == "" { return nil } if ss.inputTokens == 0 && ss.outputTokens == 0 && ss.cacheReadTokens == 0 && ss.cacheWriteTokens == 0 && ss.reasoningTokens == 0 && !ss.estimatedCost.Valid && !ss.actualCost.Valid { return nil } // Only emit a cost_usd when Hermes actually knows it. Otherwise // leave it nil so agentsview prices the row from its own model // catalog. A "included" cost_status is a genuine known $0 only when // a real cost_source backs it; Hermes also emits "included" with // cost_source "none" (or empty) as a default for models it does not // price (e.g. gpt-5.5), which is NOT a confident $0 and must fall // through to catalog pricing. Likewise "unknown"/empty with a 0 // estimate is not a real figure and must not masquerade as $0. var cost *float64 switch { case ss.actualCost.Valid: v := ss.actualCost.Float64 cost = &v case ss.costStatus == "included" && hermesHasCostSource(ss.costSource): zero := 0.0 cost = &zero case ss.estimatedCost.Valid && ss.estimatedCost.Float64 > 0: v := ss.estimatedCost.Float64 cost = &v } return []ParsedUsageEvent{{ SessionID: sessionID, Source: "session", Model: ss.model, InputTokens: max(ss.inputTokens, 0), OutputTokens: max(ss.outputTokens, 0), CacheCreationInputTokens: max(ss.cacheWriteTokens, 0), CacheReadInputTokens: max(ss.cacheReadTokens, 0), ReasoningTokens: max(ss.reasoningTokens, 0), CostUSD: cost, CostStatus: ss.costStatus, CostSource: ss.costSource, OccurredAt: timeString(ss.endedAt, ss.startedAt), DedupKey: "session:" + sessionID, }} } func convertHermesStateMessages( stateMessages []hermesStateMessage, ) []ParsedMessage { msgs := make([]ParsedMessage, 0, len(stateMessages)) for _, hm := range stateMessages { ordinal := len(msgs) switch hm.role { case "user": content := strings.TrimSpace(hm.content) if content == "" { continue } display := stripHermesSkillPrefix(content) isCompact := isHermesCompactBoundary(display) msgs = append(msgs, ParsedMessage{ Ordinal: ordinal, Role: RoleUser, Content: display, Timestamp: hm.timestamp, ContentLength: len(content), IsSystem: isCompact, SourceType: sourceTypeIf(isCompact, "system"), SourceSubtype: sourceTypeIf(isCompact, "compact_boundary"), IsCompactBoundary: isCompact, }) case "assistant": content := strings.TrimSpace(hm.content) reasoning := firstNonEmptyHermes( hm.reasoning, hm.reasoningContent, hm.reasoningDetails, hm.codexReasoningItems, ) display := content hasThinking := reasoning != "" if hasThinking { display = "[Thinking]\n" + reasoning + "\n[/Thinking]\n" + display } var toolCalls []ParsedToolCall if gjson.Valid(hm.toolCalls) { gjson.Parse(hm.toolCalls).ForEach( func(_, tc gjson.Result) bool { name := tc.Get("function.name").Str if name == "" { name = tc.Get("name").Str } if name != "" { toolCalls = append(toolCalls, ParsedToolCall{ ToolUseID: tc.Get("id").Str, ToolName: name, Category: NormalizeToolCategory(name), InputJSON: tc.Get("function.arguments").Str, }) } return true }, ) } if display == "" && len(toolCalls) == 0 { continue } msgs = append(msgs, ParsedMessage{ Ordinal: ordinal, Role: RoleAssistant, Content: display, Timestamp: hm.timestamp, HasThinking: hasThinking, HasToolUse: len(toolCalls) > 0, ContentLength: len(content) + len(reasoning), ToolCalls: toolCalls, }) case "tool": if hm.toolCallID == "" { continue } quoted, _ := json.Marshal(hm.content) msgs = append(msgs, ParsedMessage{ Ordinal: ordinal, Role: RoleUser, Timestamp: hm.timestamp, ContentLength: len(hm.content), ToolResults: []ParsedToolResult{{ ToolUseID: hm.toolCallID, ContentRaw: string(quoted), ContentLength: len(hm.content), }}, }) } } return msgs } func hermesMessageQuality(msgs []ParsedMessage) int { score := len(msgs) * 1000 for _, msg := range msgs { score += len(msg.Content) if len(msg.ToolCalls) > 0 { score += 100 } if msg.HasThinking { score += 50 } } return score } func hermesStateQuality(msgs []hermesStateMessage) int { score := len(msgs) * 1000 for _, msg := range msgs { score += len(msg.content) if msg.toolCalls != "" { score += 100 } if firstNonEmptyHermes( msg.reasoning, msg.reasoningContent, msg.reasoningDetails, msg.codexReasoningItems, ) != "" { score += 50 } } return score } func hermesUnixTime(v float64) time.Time { if v <= 0 { return time.Time{} } sec, frac := math.Modf(v) return time.Unix(int64(sec), int64(frac*1_000_000_000)).UTC() } func timeString(primary, fallback time.Time) string { if !primary.IsZero() { return primary.Format(time.RFC3339Nano) } if !fallback.IsZero() { return fallback.Format(time.RFC3339Nano) } return "" } func countHermesUsers(msgs []ParsedMessage) int { count := 0 for _, msg := range msgs { if msg.Role == RoleUser && !msg.IsSystem && len(msg.ToolResults) == 0 && strings.TrimSpace(msg.Content) != "" { count++ } } return count } func firstHermesMessage(msgs []ParsedMessage) string { for _, msg := range msgs { if msg.Role == RoleUser && !msg.IsSystem && strings.TrimSpace(msg.Content) != "" { return truncate( strings.ReplaceAll(msg.Content, "\n", " "), 300, ) } } return "" } func isHermesCompactBoundary(s string) bool { s = strings.TrimSpace(s) return strings.HasPrefix(s, "[CONTEXT COMPACTION - REFERENCE ONLY]") || strings.HasPrefix(s, "[CONTEXT COMPACTION – REFERENCE ONLY]") } func sourceTypeIf(ok bool, value string) string { if ok { return value } return "" } func firstNonEmptyHermes(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } // HermesSessionID extracts the session ID from a Hermes filename. // "20260403_153620_5a3e2ff1.jsonl" -> "20260403_153620_5a3e2ff1" func HermesSessionID(name string) string { name = strings.TrimSuffix(name, ".jsonl") name = strings.TrimSuffix(name, ".json") name = strings.TrimPrefix(name, "session_") return name } // discoverHermesSessions finds Hermes session sources under root. When a // sibling state.db exists, it prefers that archive root; otherwise it returns // transcript files from the sessions directory. It is the provider-owned // discovery body folded off the package-level entrypoint. func discoverHermesSessions(sessionsDir string) []DiscoveredFile { if sessionsDir == "" { return nil } if stateDB, _, ok := hermesStatePaths(sessionsDir); ok { return []DiscoveredFile{{ Path: stateDB, Agent: AgentHermes, }} } childSessions := filepath.Join(sessionsDir, "sessions") if info, err := os.Stat(childSessions); err == nil && info.IsDir() { return discoverHermesTranscriptFiles(childSessions) } return discoverHermesTranscriptFiles(sessionsDir) } func discoverHermesTranscriptFiles(sessionsDir string) []DiscoveredFile { entries, err := os.ReadDir(sessionsDir) if err != nil { return nil } var files []DiscoveredFile jsonlIDs := make(map[string]bool) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasSuffix(name, ".jsonl") { continue } jsonlIDs[HermesSessionID(name)] = true files = append(files, DiscoveredFile{ Path: filepath.Join(sessionsDir, name), Agent: AgentHermes, }) } // Second pass: add session_*.json files not already covered by .jsonl for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasSuffix(name, ".json") || !strings.HasPrefix(name, "session_") { continue } sid := HermesSessionID(name) if jsonlIDs[sid] { continue } files = append(files, DiscoveredFile{ Path: filepath.Join(sessionsDir, name), Agent: AgentHermes, }) } sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) return files } // findHermesSourceFile finds a Hermes transcript file by session ID under // sessionsDir. It is the provider-owned find-source body folded off the // package-level entrypoint. func findHermesSourceFile(sessionsDir, sessionID string) string { if !IsValidSessionID(sessionID) { return "" } candidate := filepath.Join(sessionsDir, sessionID+".jsonl") if _, err := os.Stat(candidate); err == nil { return candidate } candidate = filepath.Join(sessionsDir, "session_"+sessionID+".json") if _, err := os.Stat(candidate); err == nil { return candidate } return "" } // parseHermesTimestamp parses timestamps in Hermes format. // Hermes uses ISO 8601 format: "2026-04-03T15:27:21.014566" // Timestamps without an explicit timezone are interpreted as local time // (the server's timezone), since Hermes records wall-clock time without // a UTC offset. func parseHermesTimestamp(s string) time.Time { if s == "" { return time.Time{} } // Try parsing with microseconds (Hermes default). // Use ParseInLocation so naive timestamps are interpreted as local // time rather than UTC — Hermes records local wall-clock time. t, err := time.ParseInLocation("2006-01-02T15:04:05.999999", s, time.Local) if err == nil { return t } // Fallback to standard ISO format (has explicit timezone — Parse is fine). t, err = time.Parse(time.RFC3339, s) if err == nil { return t } // Try without fractional seconds. t, err = time.ParseInLocation("2006-01-02T15:04:05", s, time.Local) if err == nil { return t } return time.Time{} } // stripHermesSkillPrefix removes the skill injection header that // Hermes prepends to user messages when a skill is loaded. // These start with "[SYSTEM: The user has invoked the ..." // // The injected format is: // // [SYSTEM: The user has invoked the "" skill...]\n\n // ---\n\n---\n\n\n // [optional setup/supporting-files notes]\n\n // The user has provided the following instruction alongside the skill invocation: // // We extract the user instruction when present, otherwise return // "[Skill: ]" as a compact placeholder. func stripHermesSkillPrefix(s string) string { const prefix = "[SYSTEM: The user has invoked the \"" if !strings.HasPrefix(s, prefix) { return s } // Extract skill name from the prefix. nameEnd := strings.Index(s[len(prefix):], "\"") skillName := "" if nameEnd > 0 { skillName = s[len(prefix) : len(prefix)+nameEnd] } // Look for the explicit user instruction marker that Hermes // appends after the skill content. const instrMarker = "The user has provided the following instruction alongside the skill invocation: " if _, after, ok := strings.Cut(s, instrMarker); ok { // The user instruction may be followed by an optional // "[Runtime note: ...]" block — strip it. if rtIdx := strings.Index(after, "\n\n[Runtime note:"); rtIdx >= 0 { after = after[:rtIdx] } after = strings.TrimSpace(after) if after != "" { return after } } // No explicit user instruction — return skill name placeholder. if skillName != "" { return "[Skill: " + skillName + "]" } return s }