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

commit 896d8b88398babf43eb5de3d0451c3deb845cb57
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 12:05:29 2026 +0800

    feat(sessions): read Cursor's chats from its state database
---
 README.md               |   2 +-
 README_zh.md            |   2 +-
 agenthistory/cursor.go  | 144 ++++++++++++++++++++++++++++++++++++++++++++++++
 agenthistory/history.go |   1 +
 4 files changed, 147 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index de12238..52872ac 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +92,7 @@
 | <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=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** | ✅ | — | ✅ | ✅ | — | ✅ | 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 |
diff --git a/README_zh.md b/README_zh.md
index b8e845a..ff47ae6 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -92,7 +92,7 @@
 | <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=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** | ✅ | — | ✅ | ✅ | — | ✅ | 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 |
diff --git a/agenthistory/cursor.go b/agenthistory/cursor.go
new file mode 100644
index 0000000..4062a9b
--- /dev/null
+++ b/agenthistory/cursor.go
@@ -0,0 +1,144 @@
+// 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"
+       "time"
+
+       _ "modernc.org/sqlite" // read another agent's session database
+)
+
+const cursorAgent = "cursor"
+
+// scanCursor reads the chats Cursor keeps in the editor's own state database.
+// A chat is a "composer" there: one row of metadata, and one row per message
+// under a key of its own.
+func scanCursor(home string) []Session {
+       path := cursorStatePath(home)
+       if path == "" {
+               return nil
+       }
+
+       db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
+       if err != nil {
+               return nil
+       }
+       defer db.Close()
+
+       // The rows are keyed rather than columned, so the ordering and the 
filter
+       // read the JSON: there are a few hundred chats beside the tens of 
thousands
+       // of message rows the key prefix keeps this off. Empty chats are 
dropped
+       // here rather than after reading, so the limit counts sessions 
somebody had
+       // rather than boxes they opened.
+       rows, err := db.Query(`SELECT key, value FROM cursorDiskKV WHERE key 
LIKE 'composerData:%'
+               AND json_array_length(json_extract(value, 
'$.fullConversationHeadersOnly')) > 0
+               ORDER BY COALESCE(json_extract(value, '$.lastUpdatedAt'), 
json_extract(value, '$.createdAt')) DESC
+               LIMIT ?`, maxTranscripts)
+       if err != nil {
+               return nil
+       }
+       defer rows.Close()
+
+       sessions := []Session{}
+       for rows.Next() {
+               var key, value string
+               if err := rows.Scan(&key, &value); err != nil {
+                       return sessions
+               }
+               if session, ok := parseCursorComposer(key, value, path); ok {
+                       sessions = append(sessions, session)
+               }
+       }
+       return sessions
+}
+
+// cursorComposer is one chat as the editor stores it. The messages themselves
+// are rows of their own; the headers here are what says how many there are and
+// who sent each.
+type cursorComposer struct {
+       ComposerId    string `json:"composerId"`
+       Name          string `json:"name"`
+       CreatedAt     int64  `json:"createdAt"`
+       LastUpdatedAt int64  `json:"lastUpdatedAt"`
+       Headers       []struct {
+               BubbleId string `json:"bubbleId"`
+       } `json:"fullConversationHeadersOnly"`
+}
+
+func parseCursorComposer(key, value, path string) (Session, bool) {
+       var composer cursorComposer
+       if err := json.Unmarshal([]byte(value), &composer); err != nil {
+               return Session{}, false
+       }
+       // A composer with no messages is a chat box that was opened and left, 
of
+       // which the editor keeps a great many.
+       if len(composer.Headers) == 0 {
+               return Session{}, false
+       }
+
+       id := composer.ComposerId
+       if id == "" {
+               id = key[len("composerData:"):]
+       }
+       session := Session{
+               Agent:       cursorAgent,
+               SessionKey:  id,
+               Title:       trimTitle(composer.Name),
+               RecordCount: len(composer.Headers),
+               FirstTime:   cursorTime(composer.CreatedAt),
+               LastTime:    cursorTime(composer.LastUpdatedAt),
+               Path:        path,
+               Historical:  true,
+       }
+       if session.LastTime == "" {
+               session.LastTime = session.FirstTime
+       }
+       if session.FirstTime == "" {
+               session.FirstTime = session.LastTime
+       }
+       // Cursor bills its own subscription and records no token counts to 
read:
+       // the message rows carry the fields, always at zero. A session with no
+       // usage is listed for what it was, without inventing an amount for it.
+       return session, session.SessionKey != ""
+}
+
+// cursorStatePath is the editor's global state database, in the application
+// directory each platform keeps it under.
+func cursorStatePath(home string) string {
+       candidates := [][]string{
+               {"AppData", "Roaming", "Cursor", "User", "globalStorage", 
"state.vscdb"},
+               {"Library", "Application Support", "Cursor", "User", 
"globalStorage", "state.vscdb"},
+               {".config", "Cursor", "User", "globalStorage", "state.vscdb"},
+       }
+       for _, parts := range candidates {
+               path := filepath.Join(append([]string{home}, parts...)...)
+               if info, err := os.Stat(path); err == nil && !info.IsDir() {
+                       return path
+               }
+       }
+       return ""
+}
+
+// cursorTime reads the epoch milliseconds the editor stores.
+func cursorTime(value int64) string {
+       if value <= 0 {
+               return ""
+       }
+       return time.UnixMilli(value).Local().Format(time.RFC3339)
+}
diff --git a/agenthistory/history.go b/agenthistory/history.go
index e0c30e9..942a978 100644
--- a/agenthistory/history.go
+++ b/agenthistory/history.go
@@ -146,6 +146,7 @@ func scan(home string) []Session {
        // compressed one its own reader has to open.
        sessions = append(sessions, scanOpencode(home)...)
        sessions = append(sessions, scanDsh(home)...)
+       sessions = append(sessions, scanCursor(home)...)
 
        sort.SliceStable(sessions, func(left, right int) bool {
                return sessions[left].LastTime > sessions[right].LastTime


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to