Files
magpie/internal/sessions/transcript.go
T
yetone b96d55fc99 sessions: a session's conversation can be read in magpie, and a Pi session carried on in oh-my-pi (sxwedo, #845) — the Sessions page could resume a session only in the agent that wrote it, and showed nothing of what was said. A session's details now have Show conversation: magpie reads the agent's own session file (Claude Code, Qoder, Qoder CN, Codex, Pi, oh-my-pi; found by agent and id, never a path from the page; read only, no copy kept, 400 KB at most with "There was more than is shown here") and lists the user's words, the model's, each tool call by name and its result, thinking and summaries folded. For Pi and oh-my-pi it follows the branch the session is on (parentId from the last entry), not the turns left on an abandoned one. A Pi session also has Continue in, the app's menu (no <select>): oh-my-pi's omp --fork <file> in the session's folder, copied or opened in the session terminal (the server makes the command from the session it finds; the page sends agent, id, in), and the details say it. omp --fork loads the Pi file, migrates it and writes a new oh-my-pi session, leaving Pi's untouched; with the owner's Pi sessions copied into a sandbox HOME, oh-my-pi's own SessionManager forked them whole (4→5 and 46→47 messages, its system line added; text, thinking and tool calls kept) and the source files' sha256 didn't change. Only that pair is offered: Pi can't read an oh-my-pi file (it wants the session header first, oh-my-pi writes a title line first), oh-my-pi takes Claude Code and Codex only from its own import picker, not a given session, and the other agents' formats differ, so carrying them on would mean converting them, which isn't done. zh, ja, de strings. TestTranscriptClaude, TestTranscriptCodex, TestTranscriptPiBranch, TestTranscriptOmp, TestTranscriptUnsupported, TestTranscriptCut, TestCarryPiToOmp; sessions-talk.test.cjs (WebKit, en and zh) fails on the old code. sessions-*, click-scroll, gui-ja and gui-de pass on WebKit; session-terminal and session-identity fail the same on origin/main. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-05 10:08:35 +08:00

179 lines
4.7 KiB
Go

package sessions
// A session's whole conversation, read from the agent's own file when it is
// asked for: what was said, in order, by the user, the model and its tools.
// The file is only read; magpie keeps no copy.
import (
"bytes"
"encoding/json"
"errors"
"math"
)
// Transcript is what was said in a session, in the order it was said.
type Transcript struct {
Parts []Part `json:"parts"`
Cut bool `json:"cut,omitempty"` // there was more than is kept here
}
// transcriptMax is the characters of all of a transcript's parts.
const transcriptMax = 400_000
// ErrNoTranscript is a session whose conversation magpie can't read: an
// agent that keeps it in a database, or in a form not read here.
var ErrNoTranscript = errors.New("this agent's conversations can't be shown")
// HasTranscript says whether an agent's sessions can be read as a
// transcript.
func HasTranscript(agent string) bool {
switch agent {
case "claude", "qoder", "qoder-cn", "codex", "pi", "omp":
return true
}
return false
}
func (t *Transcript) add(_ bool, p Part) bool {
size := 0
for _, x := range t.Parts {
size += len(x.Text)
}
if size >= transcriptMax {
t.Cut = true
return false
}
if p, ok := keep(p); ok {
t.Parts = append(t.Parts, p)
}
return true
}
// TranscriptOf reads a session's conversation from its (main) file.
func TranscriptOf(s Session) (Transcript, error) {
out := Transcript{Parts: []Part{}}
if !HasTranscript(s.Agent) || s.Path == "" {
return out, ErrNoTranscript
}
whole := Call{Agent: s.Agent, File: s.Path, To: math.MaxInt64}
var err error
switch s.Agent {
case "codex":
err = codexContent(whole, out.add)
case "pi", "omp":
err = piTranscript(s.Path, out.add)
default:
err = claudeContent(whole, out.add)
}
return out, err
}
// piEntry is a line of a Pi (or oh-my-pi) session: an entry of its tree.
type piEntry struct {
Type string `json:"type"`
ID string `json:"id"`
ParentID string `json:"parentId"`
Summary string `json:"summary"` // compaction, branch_summary
Message *struct {
Role string `json:"role"`
ToolName string `json:"toolName"`
Content json.RawMessage `json:"content"`
} `json:"message"`
}
var piMessage = []byte(`"type":"message"`)
// piTranscript reads the branch the session is on: a Pi session is a tree,
// each entry naming its parent, and the last entry written is where it
// stands (an earlier branch, left by /tree or a retry, isn't said again).
func piTranscript(path string, add func(bool, Part) bool) error {
byID := map[string]piEntry{}
last := ""
_, err := scanAt(path, 0, nil, func(b []byte, _, _ int64) bool {
var e piEntry
if json.Unmarshal(b, &e) != nil || e.ID == "" || e.Type == "session" || e.Type == "title" {
return true
}
if !bytes.Contains(b, piMessage) && e.Type != "compaction" && e.Type != "branch_summary" {
// keep the tree whole without holding what isn't shown
e = piEntry{Type: e.Type, ID: e.ID, ParentID: e.ParentID}
}
byID[e.ID], last = e, e.ID
return true
})
if err != nil {
return err
}
var branch []piEntry
for id, n := last, 0; id != "" && n <= len(byID); n++ {
e, ok := byID[id]
if !ok {
break
}
branch = append(branch, e)
id = e.ParentID
}
for i := len(branch) - 1; i >= 0; i-- {
for _, p := range piParts(branch[i]) {
if !add(p.Role != "assistant", p) {
return nil
}
}
}
return nil
}
// piParts are what an entry said.
func piParts(e piEntry) []Part {
switch e.Type {
case "compaction", "branch_summary":
return []Part{{Role: "user", Kind: "context", Text: e.Summary}}
case "message":
default:
return nil
}
if e.Message == nil {
return nil
}
m := e.Message
var role string
switch m.Role {
case "user":
role = "user"
case "assistant":
role = "assistant"
case "toolResult":
return []Part{{Role: "tool", Kind: "tool_result", Name: m.ToolName, Text: resultText(m.Content)}}
default:
return nil
}
var s string
if json.Unmarshal(m.Content, &s) == nil {
return []Part{textPart(role, s)}
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if json.Unmarshal(m.Content, &blocks) != nil {
return nil
}
var parts []Part
for _, b := range blocks {
switch b.Type {
case "text":
parts = append(parts, textPart(role, b.Text))
case "thinking":
parts = append(parts, Part{Role: role, Kind: "thinking", Text: b.Thinking})
case "toolCall":
parts = append(parts, Part{Role: role, Kind: "tool_use", Name: b.Name, Text: pretty(b.Arguments)})
case "image":
parts = append(parts, Part{Role: role, Kind: "image", Text: "[image]"})
}
}
return parts
}