Files
multica/server/internal/daemon/usage/openclaw.go
Jiang Bohan 2ea778796a feat(daemon): add token usage log scanning for OpenCode, OpenClaw, and Hermes runtimes
Previously only Claude and Codex had log-scanning-level token usage
reporting (Flow B). This adds scanners for the remaining three runtimes:

- OpenCode: reads JSON message files from ~/.local/share/opencode/storage/message/
- OpenClaw: reads JSONL session files from ~/.openclaw/agents/*/sessions/
- Hermes: reads JSONL session files from ~/.hermes/sessions/

All three are registered in Scanner.Scan() and follow the same
(date, provider, model) aggregation pattern as existing scanners.
2026-04-13 13:42:05 +08:00

155 lines
3.9 KiB
Go

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/<provider>"
// 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
}