This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 14d844f feat(sessions): read the transcripts of six more agents
14d844f is described below
commit 14d844f40a108d10be100214f852d2e3c0910142
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 11:33:59 2026 +0800
feat(sessions): read the transcripts of six more agents
---
README.md | 12 +--
README_zh.md | 12 +--
agenthistory/history.go | 89 ++++++++++++++---
agenthistory/opencode.go | 195 +++++++++++++++++++++++++++++++++++++
agenthistory/usage.go | 19 ++++
agentmonitor/{store.go => sink.go} | 0
6 files changed, 303 insertions(+), 24 deletions(-)
diff --git a/README.md b/README.md
index 1b7de48..8e820bf 100644
--- a/README.md
+++ b/README.md
@@ -88,17 +88,17 @@
| Agent | Monitoring | Provider | MCP | Skills | Prompt | Sessions | Install |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- |
| <img src="https://www.google.com/s2/favicons?domain=claude.com&sz=64"
width="16" height="16" alt=""> **Claude Code** | ✅ | ✅ Anthropic | ✅ | ✅ | ✅ |
✅ | npm · brew · winget |
-| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64"
width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | — | — |
+| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64"
width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | ✅ | — |
| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64"
width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ |
npm · brew |
-| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64"
width="16" height="16" alt=""> **ChatGPT Desktop (Codex)** | ✅ | ✅ OpenAI | ✅ |
✅ | ✅ | — | — |
-| <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64"
width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | — |
npm |
+| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64"
width="16" height="16" alt=""> **ChatGPT Desktop (Codex)** | ✅ | ✅ OpenAI | ✅ |
✅ | ✅ | ✅ | — |
+| <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64"
width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | ✅ |
npm |
| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64"
width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | — | brew |
| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64"
width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | — |
| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64"
width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew |
-| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | — |
npm |
-| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode Desktop** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅
| — | — |
+| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ |
npm |
+| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode Desktop** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅
| ✅ | — |
| <img src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64"
width="16" height="16" alt=""> **OpenAgent** | ✅ | — | — | — | — | — | — |
-| <img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64"
width="16" height="16" alt=""> **OpenClaw** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | — |
npm |
+| <img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64"
width="16" height="16" alt=""> **OpenClaw** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ |
npm |
| <img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64"
width="16" height="16" alt=""> **Hermes Agent** | ✅ | ✅ OpenAI | — | — | — | —
| — |
| <img src="https://www.google.com/s2/favicons?domain=deepseek.com&sz=64"
width="16" height="16" alt=""> **DeepSeek Harness** | ✅ | ✅ OpenAI | ✅ | ✅ | —
| — | npm |
diff --git a/README_zh.md b/README_zh.md
index 5d80312..3360c58 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -88,17 +88,17 @@
| Agent | 行为监控 | 换供应商 | MCP | 技能 | 提示词 | 会话 | 安装 |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- |
| <img src="https://www.google.com/s2/favicons?domain=claude.com&sz=64"
width="16" height="16" alt=""> **Claude Code** | ✅ | ✅ Anthropic | ✅ | ✅ | ✅ |
✅ | npm · brew · winget |
-| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64"
width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | — | — |
+| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64"
width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | ✅ | — |
| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64"
width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ |
npm · brew |
-| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64"
width="16" height="16" alt=""> **ChatGPT 桌面版(Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ |
✅ | — | — |
-| <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64"
width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | — |
npm |
+| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64"
width="16" height="16" alt=""> **ChatGPT 桌面版(Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ |
✅ | ✅ | — |
+| <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64"
width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | ✅ |
npm |
| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64"
width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | — | brew |
| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64"
width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | — |
| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64"
width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew |
-| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | — |
npm |
-| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode 桌面版** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | —
| — |
+| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ |
npm |
+| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"
width="16" height="16" alt=""> **opencode 桌面版** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅
| — |
| <img src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64"
width="16" height="16" alt=""> **OpenAgent** | ✅ | — | — | — | — | — | — |
-| <img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64"
width="16" height="16" alt=""> **OpenClaw** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | — |
npm |
+| <img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64"
width="16" height="16" alt=""> **OpenClaw** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ |
npm |
| <img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64"
width="16" height="16" alt=""> **Hermes Agent** | ✅ | ✅ OpenAI | — | — | — | —
| — |
| <img src="https://www.google.com/s2/favicons?domain=deepseek.com&sz=64"
width="16" height="16" alt=""> **DeepSeek Harness** | ✅ | ✅ OpenAI | ✅ | ✅ | —
| — | npm |
diff --git a/agenthistory/history.go b/agenthistory/history.go
index c106be1..1d018fb 100644
--- a/agenthistory/history.go
+++ b/agenthistory/history.go
@@ -69,9 +69,22 @@ const (
var transcriptDirs = []struct {
agent string
parts []string
+ // keyOf reads the session id out of a transcript's path, for an agent
that
+ // names the directory holding the file rather than the file itself.
+ keyOf func(path string) string
}{
- {"claude-code", []string{".claude", "projects"}},
- {"codex-cli", []string{".codex", "sessions"}},
+ {agent: "claude-code", parts: []string{".claude", "projects"}},
+ {agent: "codex-cli", parts: []string{".codex", "sessions"}},
+ // The Gemini CLI keeps one directory per project hash, and OpenClaw
one per
+ // agent id; both hold the sessions a level or two further down, which
is
+ // where the walk finds them.
+ {agent: "gemini-cli", parts: []string{".gemini", "tmp"}},
+ {agent: "openclaw", parts: []string{".openclaw", "agents"}},
+ // Claude Desktop writes one directory per Cowork session, named by the
+ // session id, with the transcript inside it under a fixed name.
+ {agent: "claude-desktop", parts: []string{"AppData", "Roaming",
"Claude", "local-agent-mode-sessions"}, keyOf: parentDirName},
+ {agent: "claude-desktop", parts: []string{"AppData", "Local",
"Claude-3p", "local-agent-mode-sessions"}, keyOf: parentDirName},
+ {agent: "claude-desktop", parts: []string{"AppData", "Local",
"Packages", "Claude_pzs8sxrjxfjjc", "LocalCache", "Roaming", "Claude",
"local-agent-mode-sessions"}, keyOf: parentDirName},
}
type cacheKey struct {
@@ -120,11 +133,14 @@ func scan(home string) []Session {
for _, source := range transcriptDirs {
root := filepath.Join(append([]string{home},
source.parts...)...)
for _, file := range newestTranscripts(root) {
- if session, ok := read(source.agent, file); ok {
+ if session, ok := read(source.agent, file,
source.keyOf); ok {
sessions = append(sessions, session)
}
}
}
+ // opencode keeps its sessions in a database rather than a transcript
file,
+ // and already totals what each one spent.
+ sessions = append(sessions, scanOpencode(home)...)
sort.SliceStable(sessions, func(left, right int) bool {
return sessions[left].LastTime > sessions[right].LastTime
@@ -163,7 +179,7 @@ func newestTranscripts(root string) []transcript {
return found
}
-func read(agent string, file transcript) (Session, bool) {
+func read(agent string, file transcript, keyOf func(string) string) (Session,
bool) {
key := cacheKey{path: file.path, size: file.info.Size(), unix:
file.info.ModTime().UnixNano()}
cacheMutex.Lock()
cached, found := cache[key]
@@ -172,7 +188,7 @@ func read(agent string, file transcript) (Session, bool) {
return cached, true
}
- session, ok := parse(agent, file)
+ session, ok := parse(agent, file, keyOf)
if !ok {
return Session{}, false
}
@@ -192,7 +208,25 @@ type line struct {
RequestId string `json:"requestId"`
Cwd string `json:"cwd"`
Message json.RawMessage `json:"message"`
- Payload struct {
+ // Role and Content are how the Cowork audit log and the Gemini CLI
spell a
+ // message: the line is the message rather than carrying one.
AuditTimestamp
+ // and StartTime are the same two files' names for when it happened.
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+ AuditTimestamp string `json:"_audit_timestamp"`
+ StartTime string `json:"startTime"`
+ Model string `json:"model"`
+ // Directories are the roots a Gemini CLI session was started across;
the
+ // first is the one it worked in.
+ Directories []string `json:"directories"`
+ // Tokens is what a Gemini CLI turn spent, on the turn itself.
+ Tokens *struct {
+ Input int `json:"input"`
+ Output int `json:"output"`
+ Cached int `json:"cached"`
+ Thoughts int `json:"thoughts"`
+ } `json:"tokens"`
+ Payload struct {
Id string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
@@ -253,7 +287,7 @@ func eachLine(path string, visit func(data []byte)) (int,
error) {
}
}
-func parse(agent string, file transcript) (Session, bool) {
+func parse(agent string, file transcript, keyOf func(string) string) (Session,
bool) {
session := Session{
Agent: agent,
Path: file.path,
@@ -263,6 +297,9 @@ func parse(agent string, file transcript) (Session, bool) {
// The file name is the session id for Claude Code, and carries it for
Codex;
// the transcript itself overrides this when it names one.
session.SessionKey = sessionKeyFromName(file.info.Name())
+ if keyOf != nil {
+ session.SessionKey = keyOf(file.path)
+ }
// Reading the file is the expensive part, so what it spent is added up
on
// the same pass rather than by walking every transcript a second time.
@@ -282,8 +319,11 @@ func parse(agent string, file transcript) (Session, bool) {
}
if session.Cwd == "" {
session.Cwd = firstNonEmpty(entry.Cwd,
entry.Payload.Cwd)
+ if session.Cwd == "" && len(entry.Directories) > 0 {
+ session.Cwd = entry.Directories[0]
+ }
}
- if when := strings.TrimSpace(entry.Timestamp); when != "" {
+ if when := strings.TrimSpace(firstNonEmpty(entry.Timestamp,
entry.AuditTimestamp, entry.StartTime)); when != "" {
if session.FirstTime == "" {
session.FirstTime = when
}
@@ -316,13 +356,20 @@ func parse(agent string, file transcript) (Session, bool)
{
// the conversation counts: the tool calls and events around it are what
// monitoring is for.
func roleAndContent(entry line) (string, json.RawMessage) {
+ // The Gemini CLI calls the model's turn by its own name.
+ if entry.Type == "gemini" {
+ return "assistant", entry.Content
+ }
+
if entry.Type == "user" || entry.Type == "assistant" {
var message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
- if err := json.Unmarshal(entry.Message, &message); err != nil {
- return entry.Type, nil
+ if err := json.Unmarshal(entry.Message, &message); err != nil
|| len(message.Content) == 0 {
+ // The Cowork audit log and the Gemini CLI put the
content on the
+ // line itself rather than under a message.
+ return entry.Type, entry.Content
}
return firstNonEmpty(message.Role, entry.Type), message.Content
}
@@ -335,6 +382,12 @@ func roleAndContent(entry line) (string, json.RawMessage) {
}
return entry.Payload.Role, entry.Payload.Content
}
+
+ // A line that names a role and carries content is the message itself,
+ // which is how the transcripts that declare no type spell one.
+ if entry.Role != "" && len(entry.Content) > 0 {
+ return entry.Role, entry.Content
+ }
return "", nil
}
@@ -374,12 +427,18 @@ func title(content json.RawMessage) string {
return trimTitle(text)
}
- var parts []struct {
+ type part struct {
Type string `json:"type"`
Text string `json:"text"`
}
+ var parts []part
if err := json.Unmarshal(content, &parts); err != nil {
- return ""
+ // A Gemini CLI message may be one part rather than a list of
them.
+ var single part
+ if err := json.Unmarshal(content, &single); err != nil {
+ return ""
+ }
+ parts = []part{single}
}
for _, part := range parts {
if strings.TrimSpace(part.Text) != "" {
@@ -419,6 +478,12 @@ func sessionKeyFromName(name string) string {
return base
}
+// parentDirName is the name of the directory a transcript sits in, which is
the
+// session id for an agent that writes every session's file under a fixed name.
+func parentDirName(path string) string {
+ return filepath.Base(filepath.Dir(path))
+}
+
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
diff --git a/agenthistory/opencode.go b/agenthistory/opencode.go
new file mode 100644
index 0000000..478e032
--- /dev/null
+++ b/agenthistory/opencode.go
@@ -0,0 +1,195 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agenthistory
+
+import (
+ "database/sql"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite" // read another agent's session database
+)
+
+// opencodeAgent is the id every opencode session is listed under. The CLI and
+// the desktop app share one database, as they share one configuration.
+const opencodeAgent = "opencode"
+
+// opencodeSessions bounds a read the same way maxTranscripts bounds a walk.
+const opencodeSessions = maxTranscripts
+
+// scanOpencode reads the sessions opencode keeps in its own database. It is
+// opened read-only and never written: this is somebody else's file, and it may
+// be open in an agent that is running right now.
+func scanOpencode(home string) []Session {
+ path := filepath.Join(home, ".local", "share", "opencode",
"opencode.db")
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ return nil
+ }
+
+ db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
+ if err != nil {
+ return nil
+ }
+ defer db.Close()
+
+ sessions := readOpencodeSessions(db, path)
+ if len(sessions) == 0 {
+ return nil
+ }
+ counts := countOpencodeMessages(db)
+ for i := range sessions {
+ sessions[i].RecordCount =
counts[sessions[i].SessionKey].messages
+ for bucket := range sessions[i].Usage {
+ sessions[i].Usage[bucket].Requests =
counts[sessions[i].SessionKey].requests
+ }
+ }
+ return sessions
+}
+
+func readOpencodeSessions(db *sql.DB, path string) []Session {
+ // The token and model columns arrived in a later opencode; a database
+ // written before that answers the shorter query.
+ rows, err := db.Query(`SELECT id, directory, title, time_created,
time_updated,
+ model, tokens_input, tokens_output, tokens_reasoning,
tokens_cache_read, tokens_cache_write
+ FROM session ORDER BY time_updated DESC LIMIT ?`,
opencodeSessions)
+ if err != nil {
+ return readOldOpencodeSessions(db, path)
+ }
+ defer rows.Close()
+
+ sessions := []Session{}
+ for rows.Next() {
+ var id, directory, title string
+ var created, updated int64
+ var model sql.NullString
+ var input, output, reasoning, cacheRead, cacheWrite int64
+ if err := rows.Scan(&id, &directory, &title, &created, &updated,
+ &model, &input, &output, &reasoning, &cacheRead,
&cacheWrite); err != nil {
+ return nil
+ }
+
+ session := newOpencodeSession(id, directory, title, created,
updated, path)
+ if input+output+reasoning+cacheRead+cacheWrite > 0 {
+ session.Usage = []UsageBucket{{
+ Model: opencodeModel(model.String),
+ Day: dayOf(session.LastTime),
+ PromptTokens: int(input),
+ CompletionTokens: int(output),
+ ReasoningTokens: int(reasoning),
+ CacheReadTokens: int(cacheRead),
+ CacheWriteTokens: int(cacheWrite),
+ }}
+ }
+ sessions = append(sessions, session)
+ }
+ return sessions
+}
+
+func readOldOpencodeSessions(db *sql.DB, path string) []Session {
+ rows, err := db.Query(`SELECT id, directory, title, time_created,
time_updated
+ FROM session ORDER BY time_updated DESC LIMIT ?`,
opencodeSessions)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ sessions := []Session{}
+ for rows.Next() {
+ var id, directory, title string
+ var created, updated int64
+ if err := rows.Scan(&id, &directory, &title, &created,
&updated); err != nil {
+ return nil
+ }
+ sessions = append(sessions, newOpencodeSession(id, directory,
title, created, updated, path))
+ }
+ return sessions
+}
+
+func newOpencodeSession(id, directory, title string, created, updated int64,
path string) Session {
+ return Session{
+ Agent: opencodeAgent,
+ SessionKey: id,
+ Title: trimTitle(title),
+ Cwd: directory,
+ Path: path,
+ FirstTime: opencodeTime(created),
+ LastTime: opencodeTime(updated),
+ Historical: true,
+ }
+}
+
+type opencodeCounts struct {
+ messages int
+ requests int
+}
+
+// countOpencodeMessages reads the whole message table once rather than once
per
+// session: a busy database holds thousands of rows across a few hundred
+// sessions, and the same walk answers both counts.
+func countOpencodeMessages(db *sql.DB) map[string]opencodeCounts {
+ counts := map[string]opencodeCounts{}
+ rows, err := db.Query(`SELECT session_id, data FROM message`)
+ if err != nil {
+ return counts
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var sessionId, data string
+ if err := rows.Scan(&sessionId, &data); err != nil {
+ return counts
+ }
+ count := counts[sessionId]
+ count.messages++
+ // A request is one call to the model, which is what an
assistant
+ // message is the answer to.
+ var message struct {
+ Role string `json:"role"`
+ }
+ if err := json.Unmarshal([]byte(data), &message); err == nil &&
message.Role == "assistant" {
+ count.requests++
+ }
+ counts[sessionId] = count
+ }
+ return counts
+}
+
+// opencodeModel names the model a session ran on. The column holds the whole
+// reference opencode resolved, which is the provider and the model in it.
+func opencodeModel(value string) string {
+ var model struct {
+ Id string `json:"id"`
+ ProviderId string `json:"providerID"`
+ }
+ if err := json.Unmarshal([]byte(value), &model); err != nil || model.Id
== "" {
+ return strings.TrimSpace(value)
+ }
+ if model.ProviderId == "" {
+ return model.Id
+ }
+ return model.ProviderId + "/" + model.Id
+}
+
+// opencodeTime reads the epoch milliseconds the database stores.
+func opencodeTime(value int64) string {
+ if value <= 0 {
+ return ""
+ }
+ return time.UnixMilli(value).Local().Format(time.RFC3339)
+}
diff --git a/agenthistory/usage.go b/agenthistory/usage.go
index e640d7d..fc02f3c 100644
--- a/agenthistory/usage.go
+++ b/agenthistory/usage.go
@@ -76,9 +76,28 @@ func (reader *usageReader) add(entry line) {
reader.addClaude(entry)
case entry.Type == "event_msg" && entry.Payload.Type == "token_count":
reader.addCodex(entry)
+ case entry.Type == "gemini" && entry.Tokens != nil:
+ reader.addGemini(entry)
}
}
+// addGemini reads what one Gemini CLI turn spent. The counts are on the
model's
+// own message, and thoughts are the reasoning it was billed for.
+func (reader *usageReader) addGemini(entry line) {
+ tokens := entry.Tokens
+ if tokens.Input == 0 && tokens.Output == 0 && tokens.Cached == 0 &&
tokens.Thoughts == 0 {
+ return
+ }
+ reader.put(entry.RequestId, turn{
+ model: entry.Model,
+ day: dayOf(firstNonEmpty(entry.Timestamp,
entry.StartTime)),
+ prompt: tokens.Input,
+ completion: tokens.Output,
+ cacheRead: tokens.Cached,
+ reasoning: tokens.Thoughts,
+ })
+}
+
func (reader *usageReader) addClaude(entry line) {
var message struct {
Id string `json:"id"`
diff --git a/agentmonitor/store.go b/agentmonitor/sink.go
similarity index 100%
rename from agentmonitor/store.go
rename to agentmonitor/sink.go
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]