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

1416 lines
39 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 "<name>" skill...]\n\n
// ---\n<yaml frontmatter>\n---\n<skill body>\n\n
// [optional setup/supporting-files notes]\n\n
// The user has provided the following instruction alongside the skill invocation: <message>
//
// We extract the user instruction when present, otherwise return
// "[Skill: <name>]" 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
}