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 441127c  feat(sessions): read dsh's zstd-framed transcripts
441127c is described below

commit 441127c1034cb98ce07501d0733f279a1a4322b7
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 11:57:54 2026 +0800

    feat(sessions): read dsh's zstd-framed transcripts
---
 README.md               |   2 +-
 README_zh.md            |   2 +-
 agenthistory/dsh.go     | 231 ++++++++++++++++++++++++++++++++++++++++++++++++
 agenthistory/history.go |  36 ++++++--
 go.mod                  |   1 +
 go.sum                  |   2 +
 6 files changed, 263 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md
index 8e820bf..de12238 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@
 | <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=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 |
+| <img src="https://www.google.com/s2/favicons?domain=deepseek.com&sz=64"; 
width="16" height="16" alt=""> **DeepSeek Harness** | ✅ | ✅ OpenAI | ✅ | ✅ | — 
| ✅ | npm |
 
 - **Monitoring** — audit-only records of what the agent did: prompts, tool 
calls, permission prompts. Nothing an agent does waits on Gateway, and no 
answer changes because monitoring is on.
 - **Provider** — Gateway writes the agent's own configuration to point it at a 
bound provider, in the wire format that agent's client speaks. An agent without 
it still reaches Gateway through the environment variables the UI shows.
diff --git a/README_zh.md b/README_zh.md
index 3360c58..b8e845a 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -100,7 +100,7 @@
 | <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=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 |
+| <img src="https://www.google.com/s2/favicons?domain=deepseek.com&sz=64"; 
width="16" height="16" alt=""> **DeepSeek Harness** | ✅ | ✅ OpenAI | ✅ | ✅ | — 
| ✅ | npm |
 
 - **行为监控** —— 只读地记录 Agent 做了什么:提示词、工具调用、权限询问。Agent 的任何动作都不会等待 
Gateway,开着监控也不会改变任何一次回答。
 - **换供应商** —— Gateway 按该 Agent 客户端自己说的协议格式,改写它的配置文件,把它指向绑定的供应商。不支持的 Agent 
仍可以用界面上给出的环境变量接进来。
diff --git a/agenthistory/dsh.go b/agenthistory/dsh.go
new file mode 100644
index 0000000..54f3ce6
--- /dev/null
+++ b/agenthistory/dsh.go
@@ -0,0 +1,231 @@
+// 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 (
+       "encoding/json"
+       "io"
+       "os"
+       "path/filepath"
+       "strings"
+       "time"
+
+       "github.com/klauspost/compress/zstd"
+)
+
+const dshAgent = "dsh"
+
+// dshTranscript is the fixed name every dsh session log is written under, in a
+// directory of its own. The default encoding is a concatenation of Zstandard
+// frames; a harness configured with compression off writes the same lines raw.
+const (
+       dshTranscript       = "session.jsonl"
+       dshCompressedSuffix = ".zstd"
+)
+
+// scanDsh reads the sessions dsh keeps under its own home. The profile the
+// harness ships points its persistence at $DSH_HOME/sessions, and every 
session
+// is one directory under the project it ran in.
+func scanDsh(home string) []Session {
+       root := filepath.Join(home, ".dsh", "sessions")
+       sessions := []Session{}
+       for _, file := range newestTranscripts(root, isDshTranscript) {
+               if session, ok := read(file, parseDsh); ok {
+                       sessions = append(sessions, session)
+               }
+       }
+       return sessions
+}
+
+func isDshTranscript(name string) bool {
+       return name == dshTranscript || name == 
dshTranscript+dshCompressedSuffix
+}
+
+// dshLine is the union of the two kinds of line a session log holds: the 
header
+// that opens it, and the events appended after it. A packed chunk row carries
+// neither an id nor an event type this reads, and is passed over.
+type dshLine struct {
+       Type      string          `json:"type"`
+       Id        string          `json:"id"`
+       Cwd       string          `json:"cwd"`
+       CreatedAt json.RawMessage `json:"createdAt"`
+       Time      int64           `json:"time"`
+       Data      json.RawMessage `json:"data"`
+}
+
+func parseDsh(file transcript) (Session, bool) {
+       session := Session{
+               Agent:      dshAgent,
+               Path:       file.path,
+               LastTime:   file.info.ModTime().Local().Format(time.RFC3339),
+               Historical: true,
+       }
+       usage := newUsageReader()
+
+       source, closer, err := openDshTranscript(file.path)
+       if err != nil {
+               return Session{}, false
+       }
+       defer closer()
+
+       // A session being written right now ends in a frame the encoder has not
+       // closed, which is what the harness's own reader expects to find. The
+       // records read before it are kept rather than thrown away with the 
error.
+       _, _ = eachLineIn(source, func(data []byte) {
+               var entry dshLine
+               if err := json.Unmarshal(data, &entry); err != nil {
+                       return
+               }
+
+               switch entry.Type {
+               case "session":
+                       session.SessionKey = entry.Id
+                       session.Cwd = entry.Cwd
+                       if when := dshHeaderTime(entry.CreatedAt); when != "" {
+                               session.FirstTime = when
+                       }
+                       return
+               case "user/message", "assistant/message":
+                       session.RecordCount++
+               default:
+                       return
+               }
+
+               if when := dshEventTime(entry.Time); when != "" {
+                       if session.FirstTime == "" {
+                               session.FirstTime = when
+                       }
+                       session.LastTime = when
+               }
+               if entry.Type == "user/message" {
+                       if session.Title == "" {
+                               session.Title = 
usableTitle(dshContent(entry.Data))
+                       }
+                       return
+               }
+               addDshUsage(usage, entry)
+       })
+
+       if session.SessionKey == "" {
+               return Session{}, false
+       }
+       if session.FirstTime == "" {
+               session.FirstTime = session.LastTime
+       }
+       session.Usage = usage.buckets(dayOf(session.LastTime))
+       return session, true
+}
+
+// openDshTranscript reads the log as it was written. Zstandard frames are
+// decoded on the way through, so a truncated tail ends the read with what came
+// before it rather than failing the session.
+func openDshTranscript(path string) (io.Reader, func(), error) {
+       handle, err := os.Open(path)
+       if err != nil {
+               return nil, nil, err
+       }
+       if !strings.HasSuffix(path, dshCompressedSuffix) {
+               return handle, func() { handle.Close() }, nil
+       }
+
+       decoder, err := zstd.NewReader(handle)
+       if err != nil {
+               handle.Close()
+               return nil, nil, err
+       }
+       return decoder.IOReadCloser(), func() {
+               decoder.Close()
+               handle.Close()
+       }, nil
+}
+
+// dshContent is the model-facing blocks of one message, which is what the 
event
+// data is for a user turn.
+func dshContent(data json.RawMessage) json.RawMessage {
+       var message struct {
+               Content json.RawMessage `json:"content"`
+       }
+       if err := json.Unmarshal(data, &message); err != nil {
+               return nil
+       }
+       return message.Content
+}
+
+// addDshUsage reads what one model call spent. The counts ride the assistant
+// message the call produced, and the model that produced it is named there 
too.
+func addDshUsage(reader *usageReader, entry dshLine) {
+       var event struct {
+               Message struct {
+                       Id     string `json:"id"`
+                       Source struct {
+                               Provider string `json:"provider"`
+                               Model    string `json:"model"`
+                       } `json:"source"`
+               } `json:"message"`
+               Usage *struct {
+                       InputTokens      int `json:"inputTokens"`
+                       OutputTokens     int `json:"outputTokens"`
+                       CacheReadTokens  int `json:"cacheReadTokens"`
+                       CacheWriteTokens int `json:"cacheWriteTokens"`
+                       ReasoningTokens  int `json:"reasoningTokens"`
+               } `json:"usage"`
+       }
+       if err := json.Unmarshal(entry.Data, &event); err != nil || event.Usage 
== nil {
+               return
+       }
+
+       spent := event.Usage
+       if spent.InputTokens == 0 && spent.OutputTokens == 0 && 
spent.CacheReadTokens == 0 && spent.CacheWriteTokens == 0 {
+               return
+       }
+       model := event.Message.Source.Model
+       if provider := event.Message.Source.Provider; provider != "" && model 
!= "" {
+               model = provider + "/" + model
+       }
+       reader.put(event.Message.Id, turn{
+               model:      model,
+               day:        dayOf(dshEventTime(entry.Time)),
+               prompt:     spent.InputTokens,
+               completion: spent.OutputTokens,
+               cacheRead:  spent.CacheReadTokens,
+               cacheWrite: spent.CacheWriteTokens,
+               reasoning:  spent.ReasoningTokens,
+       })
+}
+
+// dshEventTime reads the epoch milliseconds an event is stamped with.
+func dshEventTime(value int64) string {
+       if value <= 0 {
+               return ""
+       }
+       return time.UnixMilli(value).Local().Format(time.RFC3339)
+}
+
+// dshHeaderTime reads when the session opened, which the header spells either
+// as epoch milliseconds or as a timestamp.
+func dshHeaderTime(value json.RawMessage) string {
+       if len(value) == 0 {
+               return ""
+       }
+       var milliseconds int64
+       if err := json.Unmarshal(value, &milliseconds); err == nil {
+               return dshEventTime(milliseconds)
+       }
+       var text string
+       if err := json.Unmarshal(value, &text); err == nil {
+               return strings.TrimSpace(text)
+       }
+       return ""
+}
diff --git a/agenthistory/history.go b/agenthistory/history.go
index 1d018fb..e0c30e9 100644
--- a/agenthistory/history.go
+++ b/agenthistory/history.go
@@ -132,15 +132,20 @@ func scan(home string) []Session {
        sessions := []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, 
source.keyOf); ok {
+               agent, keyOf := source.agent, source.keyOf
+               for _, file := range newestTranscripts(root, isJSONL) {
+                       if session, ok := read(file, func(file transcript) 
(Session, bool) {
+                               return parse(agent, file, 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.
+       // and already totals what each one spent. dsh writes a transcript, but 
a
+       // compressed one its own reader has to open.
        sessions = append(sessions, scanOpencode(home)...)
+       sessions = append(sessions, scanDsh(home)...)
 
        sort.SliceStable(sessions, func(left, right int) bool {
                return sessions[left].LastTime > sessions[right].LastTime
@@ -153,13 +158,18 @@ type transcript struct {
        info os.FileInfo
 }
 
-// newestTranscripts finds the .jsonl files under root, newest first and 
capped.
-func newestTranscripts(root string) []transcript {
+// isJSONL is the transcript name rule of an agent that writes plain JSONL.
+func isJSONL(name string) bool {
+       return strings.HasSuffix(name, ".jsonl")
+}
+
+// newestTranscripts finds the transcripts under root, newest first and capped.
+func newestTranscripts(root string, match func(name string) bool) []transcript 
{
        found := []transcript{}
        // A missing directory means the agent was never installed here, which 
is not
        // an error worth reporting from a page that lists whatever it finds.
        filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) 
error {
-               if err != nil || entry.IsDir() || 
!strings.HasSuffix(entry.Name(), ".jsonl") {
+               if err != nil || entry.IsDir() || !match(entry.Name()) {
                        return nil
                }
                info, err := entry.Info()
@@ -179,7 +189,9 @@ func newestTranscripts(root string) []transcript {
        return found
 }
 
-func read(agent string, file transcript, keyOf func(string) string) (Session, 
bool) {
+// read is one transcript, from the cache when the file has not changed since 
it
+// was last parsed.
+func read(file transcript, parse func(transcript) (Session, bool)) (Session, 
bool) {
        key := cacheKey{path: file.path, size: file.info.Size(), unix: 
file.info.ModTime().UnixNano()}
        cacheMutex.Lock()
        cached, found := cache[key]
@@ -188,7 +200,7 @@ func read(agent string, file transcript, keyOf func(string) 
string) (Session, bo
                return cached, true
        }
 
-       session, ok := parse(agent, file, keyOf)
+       session, ok := parse(file)
        if !ok {
                return Session{}, false
        }
@@ -253,7 +265,13 @@ func eachLine(path string, visit func(data []byte)) (int, 
error) {
        }
        defer handle.Close()
 
-       reader := bufio.NewReaderSize(handle, readBufferBytes)
+       return eachLineIn(handle, visit)
+}
+
+// eachLineIn is eachLine over an open stream, for a transcript that is decoded
+// on the way in rather than read from the file as it lies.
+func eachLineIn(source io.Reader, visit func(data []byte)) (int, error) {
+       reader := bufio.NewReaderSize(source, readBufferBytes)
        buffer := []byte{}
        skipping := false
        skipped := 0
diff --git a/go.mod b/go.mod
index 0e995c1..a3f7392 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
        github.com/go-mysql-org/go-mysql v1.7.0
        github.com/go-sql-driver/mysql v1.6.0
        github.com/google/uuid v1.6.0
+       github.com/klauspost/compress v1.17.9
        github.com/lib/pq v1.10.2
        github.com/siddontang/go-log v0.0.0-20190221022429-1e957dd83bed
        github.com/xorm-io/core v0.7.4
diff --git a/go.sum b/go.sum
index 8a6d250..56a41dd 100644
--- a/go.sum
+++ b/go.sum
@@ -113,6 +113,8 @@ github.com/json-iterator/go v1.1.11/go.mod 
h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
 github.com/julienschmidt/httprouter v1.2.0/go.mod 
h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 
h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod 
h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/klauspost/compress v1.17.9 
h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod 
h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod 
h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod 
h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod 
h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=


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

Reply via email to