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]

Reply via email to