diff --git a/server/internal/daemon/usage/hermes.go b/server/internal/daemon/usage/hermes.go new file mode 100644 index 000000000..091cd40c5 --- /dev/null +++ b/server/internal/daemon/usage/hermes.go @@ -0,0 +1,173 @@ +package usage + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "time" +) + +// scanHermes reads Hermes JSONL session files from +// ~/.hermes/sessions/*.jsonl +// and extracts token usage from assistant message and usage_update entries. +// +// Hermes communicates via the ACP (Agent Communication Protocol) and logs +// session events as JSONL. Token usage appears in: +// - "assistant" messages with a "usage" field +// - "usage_update" notification entries with cumulative token snapshots +func (s *Scanner) scanHermes() []Record { + root := hermesSessionRoot() + if root == "" { + return nil + } + + // Glob for session files: sessions/*.jsonl + pattern := filepath.Join(root, "*.jsonl") + files, err := filepath.Glob(pattern) + if err != nil { + s.logger.Debug("hermes glob error", "error", err) + return nil + } + + var allRecords []Record + for _, f := range files { + record := s.parseHermesFile(f) + if record != nil { + allRecords = append(allRecords, *record) + } + } + + return mergeRecords(allRecords) +} + +// hermesSessionRoot returns the Hermes sessions directory. +func hermesSessionRoot() string { + if hermesHome := os.Getenv("HERMES_HOME"); hermesHome != "" { + dir := filepath.Join(hermesHome, "sessions") + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + // Check common locations. + candidates := []string{ + filepath.Join(home, ".hermes", "sessions"), + filepath.Join(home, ".local", "share", "hermes", "sessions"), + filepath.Join(home, ".config", "hermes", "sessions"), + } + for _, dir := range candidates { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + } + return "" +} + +// hermesLine represents a line in a Hermes session JSONL file. +// Hermes session logs contain both message events and notification events. +type hermesLine struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` // RFC3339 + Model string `json:"model"` + Usage *struct { + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedReadTokens int64 `json:"cachedReadTokens"` + ThoughtTokens int64 `json:"thoughtTokens"` + } `json:"usage"` +} + +// parseHermesFile extracts the final cumulative token usage from a Hermes session file. +// Hermes usage_update events are cumulative snapshots — the last one in the file +// represents the total usage for the session. Returns nil if no usage data found. +func (s *Scanner) parseHermesFile(path string) *Record { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var lastUsage *struct { + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedReadTokens int64 `json:"cachedReadTokens"` + ThoughtTokens int64 `json:"thoughtTokens"` + } + var lastModel string + var lastTimestamp string + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + + // Fast pre-filter. + if !bytesContains(line, `"usage"`) && !bytesContains(line, `"inputTokens"`) { + continue + } + + var entry hermesLine + if err := json.Unmarshal(line, &entry); err != nil { + continue + } + + if entry.Usage == nil { + continue + } + + // Take the latest usage snapshot (cumulative). + lastUsage = entry.Usage + if entry.Model != "" { + lastModel = entry.Model + } + if entry.Timestamp != "" { + lastTimestamp = entry.Timestamp + } + } + + if lastUsage == nil { + return nil + } + if lastUsage.InputTokens == 0 && lastUsage.OutputTokens == 0 { + return nil + } + + // Parse timestamp for date. + var date string + if lastTimestamp != "" { + if ts, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil { + date = ts.Local().Format("2006-01-02") + } else if ts, err := time.Parse(time.RFC3339, lastTimestamp); err == nil { + date = ts.Local().Format("2006-01-02") + } + } + if date == "" { + // Fall back to file modification time. + if info, err := os.Stat(path); err == nil { + date = info.ModTime().Local().Format("2006-01-02") + } else { + return nil + } + } + + model := lastModel + if model == "" { + model = "unknown" + } + + return &Record{ + Date: date, + Provider: "hermes", + Model: model, + InputTokens: lastUsage.InputTokens, + OutputTokens: lastUsage.OutputTokens + lastUsage.ThoughtTokens, + CacheReadTokens: lastUsage.CachedReadTokens, + } +} diff --git a/server/internal/daemon/usage/hermes_test.go b/server/internal/daemon/usage/hermes_test.go new file mode 100644 index 000000000..94e77a695 --- /dev/null +++ b/server/internal/daemon/usage/hermes_test.go @@ -0,0 +1,99 @@ +package usage + +import ( + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestParseHermesFile(t *testing.T) { + tmp := t.TempDir() + + // Hermes session JSONL with usage_update entries (cumulative snapshots) + content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"claude-sonnet-4-5"} +{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":1000,"outputTokens":200,"cachedReadTokens":500,"thoughtTokens":50}} +{"type":"usage_update","timestamp":"2026-04-10T14:02:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":3000,"outputTokens":600,"cachedReadTokens":1500,"thoughtTokens":100}} +` + + filePath := filepath.Join(tmp, "session-001.jsonl") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + record := s.parseHermesFile(filePath) + + if record == nil { + t.Fatal("expected non-nil record") + } + + if record.Provider != "hermes" { + t.Errorf("provider = %q, want %q", record.Provider, "hermes") + } + if record.Model != "claude-sonnet-4-5" { + t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5") + } + if record.Date != "2026-04-10" { + t.Errorf("date = %q, want %q", record.Date, "2026-04-10") + } + // Should take the last (cumulative) snapshot + if record.InputTokens != 3000 { + t.Errorf("input_tokens = %d, want %d", record.InputTokens, 3000) + } + // output_tokens + thought_tokens + if record.OutputTokens != 700 { + t.Errorf("output_tokens = %d, want %d (600 + 100)", record.OutputTokens, 700) + } + if record.CacheReadTokens != 1500 { + t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 1500) + } +} + +func TestParseHermesFile_NoUsage(t *testing.T) { + tmp := t.TempDir() + + content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"test-model"} +{"type":"message","timestamp":"2026-04-10T14:01:00.000Z","content":"hello"} +` + + filePath := filepath.Join(tmp, "session-empty.jsonl") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + record := s.parseHermesFile(filePath) + + if record != nil { + t.Errorf("expected nil record for no usage data, got %+v", record) + } +} + +func TestParseHermesFile_SingleUsage(t *testing.T) { + tmp := t.TempDir() + + content := `{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"hermes-3","usage":{"inputTokens":500,"outputTokens":100,"cachedReadTokens":0,"thoughtTokens":0}} +` + + filePath := filepath.Join(tmp, "session-single.jsonl") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + record := s.parseHermesFile(filePath) + + if record == nil { + t.Fatal("expected non-nil record") + } + if record.InputTokens != 500 { + t.Errorf("input_tokens = %d, want %d", record.InputTokens, 500) + } + if record.OutputTokens != 100 { + t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 100) + } + if record.Model != "hermes-3" { + t.Errorf("model = %q, want %q", record.Model, "hermes-3") + } +} diff --git a/server/internal/daemon/usage/openclaw.go b/server/internal/daemon/usage/openclaw.go new file mode 100644 index 000000000..1a6a1b6a8 --- /dev/null +++ b/server/internal/daemon/usage/openclaw.go @@ -0,0 +1,154 @@ +package usage + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +// scanOpenClaw reads OpenClaw JSONL session files from +// ~/.openclaw/agents/*/sessions/*.jsonl +// and extracts token usage from assistant message entries. +func (s *Scanner) scanOpenClaw() []Record { + root := openClawSessionRoot() + if root == "" { + return nil + } + + // Glob for session files: agents/*/sessions/*.jsonl + pattern := filepath.Join(root, "*", "sessions", "*.jsonl") + files, err := filepath.Glob(pattern) + if err != nil { + s.logger.Debug("openclaw glob error", "error", err) + return nil + } + + var allRecords []Record + for _, f := range files { + records := s.parseOpenClawFile(f) + allRecords = append(allRecords, records...) + } + + return mergeRecords(allRecords) +} + +// openClawSessionRoot returns the OpenClaw agents directory. +func openClawSessionRoot() string { + if openclawHome := os.Getenv("OPENCLAW_HOME"); openclawHome != "" { + dir := filepath.Join(openclawHome, "agents") + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + dir := filepath.Join(home, ".openclaw", "agents") + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + return "" +} + +// openClawLine represents a line in an OpenClaw JSONL session file. +type openClawLine struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` // RFC3339 + Message *struct { + Role string `json:"role"` + Provider string `json:"provider"` + Model string `json:"model"` + Usage *struct { + Input int64 `json:"input"` + Output int64 `json:"output"` + CacheRead int64 `json:"cacheRead"` + CacheWrite int64 `json:"cacheWrite"` + } `json:"usage"` + } `json:"message"` +} + +// parseOpenClawFile extracts token usage records from an OpenClaw session JSONL file. +func (s *Scanner) parseOpenClawFile(path string) []Record { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var records []Record + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + + // Fast pre-filter: skip lines that don't contain relevant data. + if !bytesContains(line, `"usage"`) { + continue + } + if !bytesContains(line, `"assistant"`) { + continue + } + + var entry openClawLine + if err := json.Unmarshal(line, &entry); err != nil { + continue + } + if entry.Type != "message" || entry.Message == nil || entry.Message.Role != "assistant" || entry.Message.Usage == nil { + continue + } + + u := entry.Message.Usage + if u.Input == 0 && u.Output == 0 { + continue + } + + // Parse timestamp to get date. + ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp) + if err != nil { + ts, err = time.Parse(time.RFC3339, entry.Timestamp) + if err != nil { + continue + } + } + + model := entry.Message.Model + if model == "" { + model = "unknown" + } + + // Construct provider string: if the session has a provider, use "openclaw/" + // for attribution, but the Record.Provider field should be "openclaw". + provider := "openclaw" + _ = entry.Message.Provider // available but not used in provider field + + records = append(records, Record{ + Date: ts.Local().Format("2006-01-02"), + Provider: provider, + Model: normalizeOpenClawModel(entry.Message.Provider, model), + InputTokens: u.Input, + OutputTokens: u.Output, + CacheReadTokens: u.CacheRead, + CacheWriteTokens: u.CacheWrite, + }) + } + + return records +} + +// normalizeOpenClawModel returns a model identifier. If the provider is known, +// it prefixes the model name for clarity (e.g. "deepseek/deepseek-chat"). +func normalizeOpenClawModel(provider, model string) string { + provider = strings.TrimSpace(provider) + model = strings.TrimSpace(model) + if provider != "" && !strings.Contains(model, "/") { + return provider + "/" + model + } + return model +} diff --git a/server/internal/daemon/usage/openclaw_test.go b/server/internal/daemon/usage/openclaw_test.go new file mode 100644 index 000000000..70bc80038 --- /dev/null +++ b/server/internal/daemon/usage/openclaw_test.go @@ -0,0 +1,93 @@ +package usage + +import ( + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestParseOpenClawFile(t *testing.T) { + tmp := t.TempDir() + + // Real OpenClaw session JSONL with session header, model_change, and assistant messages + content := `{"type":"session","version":3,"id":"multica-test","timestamp":"2026-04-11T13:53:05.847Z"} +{"type":"model_change","id":"03c18aae","timestamp":"2026-04-11T13:53:05.855Z","provider":"deepseek","modelId":"deepseek-chat"} +{"type":"message","id":"162ce1b7","parentId":"c90ecabe","timestamp":"2026-04-11T13:53:09.986Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll start by getting the issue details."}],"api":"openai-completions","provider":"deepseek","model":"deepseek-chat","usage":{"input":133,"output":81,"cacheRead":16448,"cacheWrite":0,"totalTokens":16662}}} +{"type":"message","id":"3c063300","parentId":"50e4feb6","timestamp":"2026-04-11T13:53:14.750Z","message":{"role":"assistant","content":[{"type":"text","text":"Let me check the workspace."}],"provider":"deepseek","model":"deepseek-chat","usage":{"input":286,"output":94,"cacheRead":16448,"cacheWrite":0}}} +{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}} +` + + filePath := filepath.Join(tmp, "session.jsonl") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + records := s.parseOpenClawFile(filePath) + + if len(records) != 2 { + t.Fatalf("expected 2 records, got %d", len(records)) + } + + r := records[0] + if r.Provider != "openclaw" { + t.Errorf("provider = %q, want %q", r.Provider, "openclaw") + } + if r.Model != "deepseek/deepseek-chat" { + t.Errorf("model = %q, want %q", r.Model, "deepseek/deepseek-chat") + } + if r.InputTokens != 133 { + t.Errorf("input_tokens = %d, want %d", r.InputTokens, 133) + } + if r.OutputTokens != 81 { + t.Errorf("output_tokens = %d, want %d", r.OutputTokens, 81) + } + if r.CacheReadTokens != 16448 { + t.Errorf("cache_read_tokens = %d, want %d", r.CacheReadTokens, 16448) + } + if r.Date != "2026-04-11" { + t.Errorf("date = %q, want %q", r.Date, "2026-04-11") + } +} + +func TestParseOpenClawFile_NoUsage(t *testing.T) { + tmp := t.TempDir() + + // Session with no assistant messages containing usage + content := `{"type":"session","version":3,"id":"empty-session","timestamp":"2026-04-11T13:53:05.847Z"} +{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}} +` + + filePath := filepath.Join(tmp, "session.jsonl") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + records := s.parseOpenClawFile(filePath) + + if len(records) != 0 { + t.Errorf("expected 0 records, got %d", len(records)) + } +} + +func TestNormalizeOpenClawModel(t *testing.T) { + tests := []struct { + provider string + model string + want string + }{ + {"deepseek", "deepseek-chat", "deepseek/deepseek-chat"}, + {"anthropic", "claude-sonnet-4-5", "anthropic/claude-sonnet-4-5"}, + {"", "gpt-4o", "gpt-4o"}, + {"openai", "openai/gpt-4o", "openai/gpt-4o"}, // already has / + } + + for _, tt := range tests { + got := normalizeOpenClawModel(tt.provider, tt.model) + if got != tt.want { + t.Errorf("normalizeOpenClawModel(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want) + } + } +} diff --git a/server/internal/daemon/usage/opencode.go b/server/internal/daemon/usage/opencode.go new file mode 100644 index 000000000..4a883ab2a --- /dev/null +++ b/server/internal/daemon/usage/opencode.go @@ -0,0 +1,122 @@ +package usage + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// scanOpenCode reads OpenCode message JSON files from +// ~/.local/share/opencode/storage/message/ses_*/*.json +// and extracts token usage from assistant messages. +func (s *Scanner) scanOpenCode() []Record { + root := openCodeStorageRoot() + if root == "" { + return nil + } + + // Glob for message files: storage/message/ses_*/*.json + pattern := filepath.Join(root, "ses_*", "*.json") + files, err := filepath.Glob(pattern) + if err != nil { + s.logger.Debug("opencode glob error", "error", err) + return nil + } + + var allRecords []Record + for _, f := range files { + record := s.parseOpenCodeFile(f) + if record != nil { + allRecords = append(allRecords, *record) + } + } + + return mergeRecords(allRecords) +} + +// openCodeStorageRoot returns the OpenCode message storage directory. +func openCodeStorageRoot() string { + // Check XDG_DATA_HOME first, then fall back to ~/.local/share + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dataHome = filepath.Join(home, ".local", "share") + } + + dir := filepath.Join(dataHome, "opencode", "storage", "message") + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + return "" +} + +// openCodeMessage represents the subset of an OpenCode message JSON file we need. +type openCodeMessage struct { + Role string `json:"role"` + ModelID string `json:"modelID"` + ProviderID string `json:"providerID"` + Time *struct { + Created int64 `json:"created"` // unix milliseconds + } `json:"time"` + Tokens *struct { + Input int64 `json:"input"` + Output int64 `json:"output"` + Reasoning int64 `json:"reasoning"` + Cache *struct { + Read int64 `json:"read"` + Write int64 `json:"write"` + } `json:"cache"` + } `json:"tokens"` +} + +// parseOpenCodeFile reads a single OpenCode message JSON file and returns a Record +// if it contains assistant token usage. Returns nil otherwise. +func (s *Scanner) parseOpenCodeFile(path string) *Record { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var msg openCodeMessage + if err := json.Unmarshal(data, &msg); err != nil { + return nil + } + + // Only count assistant messages with token usage. + if msg.Role != "assistant" || msg.Tokens == nil || msg.Time == nil { + return nil + } + + // Skip messages with no meaningful token usage. + if msg.Tokens.Input == 0 && msg.Tokens.Output == 0 { + return nil + } + + ts := time.UnixMilli(msg.Time.Created) + date := ts.Local().Format("2006-01-02") + + model := msg.ModelID + if model == "" { + model = "unknown" + } + + var cacheRead, cacheWrite int64 + if msg.Tokens.Cache != nil { + cacheRead = msg.Tokens.Cache.Read + cacheWrite = msg.Tokens.Cache.Write + } + + return &Record{ + Date: date, + Provider: "opencode", + Model: model, + InputTokens: msg.Tokens.Input, + OutputTokens: msg.Tokens.Output + msg.Tokens.Reasoning, + CacheReadTokens: cacheRead, + CacheWriteTokens: cacheWrite, + } +} diff --git a/server/internal/daemon/usage/opencode_test.go b/server/internal/daemon/usage/opencode_test.go new file mode 100644 index 000000000..42bbe5258 --- /dev/null +++ b/server/internal/daemon/usage/opencode_test.go @@ -0,0 +1,141 @@ +package usage + +import ( + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestParseOpenCodeFile(t *testing.T) { + tmp := t.TempDir() + + // Real OpenCode message JSON format with token usage + content := `{ + "id": "msg_test001", + "sessionID": "ses_test001", + "role": "assistant", + "time": {"created": 1768332037518, "completed": 1768332039410}, + "modelID": "claude-sonnet-4-5", + "providerID": "anthropic", + "tokens": {"input": 10916, "output": 5, "reasoning": 100, "cache": {"read": 448, "write": 50}} + }` + + filePath := filepath.Join(tmp, "msg_test001.json") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + record := s.parseOpenCodeFile(filePath) + + if record == nil { + t.Fatal("expected non-nil record") + } + + if record.Provider != "opencode" { + t.Errorf("provider = %q, want %q", record.Provider, "opencode") + } + if record.Model != "claude-sonnet-4-5" { + t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5") + } + if record.InputTokens != 10916 { + t.Errorf("input_tokens = %d, want %d", record.InputTokens, 10916) + } + // output_tokens + reasoning + if record.OutputTokens != 105 { + t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 105) + } + if record.CacheReadTokens != 448 { + t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 448) + } + if record.CacheWriteTokens != 50 { + t.Errorf("cache_write_tokens = %d, want %d", record.CacheWriteTokens, 50) + } +} + +func TestParseOpenCodeFile_UserMessage(t *testing.T) { + tmp := t.TempDir() + + // User messages should be ignored (no token usage to report) + content := `{ + "id": "msg_user001", + "sessionID": "ses_test001", + "role": "user", + "time": {"created": 1768332037000} + }` + + filePath := filepath.Join(tmp, "msg_user001.json") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + record := s.parseOpenCodeFile(filePath) + + if record != nil { + t.Errorf("expected nil record for user message, got %+v", record) + } +} + +func TestParseOpenCodeFile_NoCache(t *testing.T) { + tmp := t.TempDir() + + // Message without cache field + content := `{ + "id": "msg_test002", + "sessionID": "ses_test002", + "role": "assistant", + "time": {"created": 1768332037518}, + "modelID": "gpt-4o", + "providerID": "openai", + "tokens": {"input": 500, "output": 200, "reasoning": 0} + }` + + filePath := filepath.Join(tmp, "msg_test002.json") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + record := s.parseOpenCodeFile(filePath) + + if record == nil { + t.Fatal("expected non-nil record") + } + if record.CacheReadTokens != 0 { + t.Errorf("cache_read_tokens = %d, want 0", record.CacheReadTokens) + } + if record.CacheWriteTokens != 0 { + t.Errorf("cache_write_tokens = %d, want 0", record.CacheWriteTokens) + } + if record.Model != "gpt-4o" { + t.Errorf("model = %q, want %q", record.Model, "gpt-4o") + } +} + +func TestParseOpenCodeFile_ZeroTokens(t *testing.T) { + tmp := t.TempDir() + + // Message with zero tokens should return nil + content := `{ + "id": "msg_test003", + "sessionID": "ses_test003", + "role": "assistant", + "time": {"created": 1768332037518}, + "modelID": "test-model", + "tokens": {"input": 0, "output": 0, "reasoning": 0} + }` + + filePath := filepath.Join(tmp, "msg_test003.json") + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewScanner(slog.Default()) + record := s.parseOpenCodeFile(filePath) + + if record != nil { + t.Errorf("expected nil record for zero tokens, got %+v", record) + } +} diff --git a/server/internal/daemon/usage/scanner.go b/server/internal/daemon/usage/scanner.go index d4f015a19..50ce0ab0d 100644 --- a/server/internal/daemon/usage/scanner.go +++ b/server/internal/daemon/usage/scanner.go @@ -25,8 +25,9 @@ func NewScanner(logger *slog.Logger) *Scanner { return &Scanner{logger: logger} } -// Scan reads local JSONL log files for both Claude Code and Codex CLI, -// and returns aggregated usage records keyed by (date, provider, model). +// Scan reads local log files for all supported agent runtimes (Claude Code, +// Codex, OpenCode, OpenClaw, Hermes) and returns aggregated usage records +// keyed by (date, provider, model). func (s *Scanner) Scan() []Record { var records []Record @@ -36,6 +37,15 @@ func (s *Scanner) Scan() []Record { codexRecords := s.scanCodex() records = append(records, codexRecords...) + openCodeRecords := s.scanOpenCode() + records = append(records, openCodeRecords...) + + openClawRecords := s.scanOpenClaw() + records = append(records, openClawRecords...) + + hermesRecords := s.scanHermes() + records = append(records, hermesRecords...) + return records }