Files
magpie/internal/sessions/content.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

339 lines
9.5 KiB
Go

package sessions
// What was said in a call — what the agent was given and what came back —
// read from the session file the call is in when it is asked for, from the place
// the call has in it (Call.File, From, To); magpie keeps no copy.
import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"time"
"unicode/utf8"
)
// Part is one thing said in a call.
type Part struct {
Role string `json:"role"` // user, assistant or tool
Kind string `json:"kind"` // text, thinking, tool_use, tool_result, context or image
Name string `json:"name,omitempty"` // the tool's, of its call
Text string `json:"text"`
Cut int `json:"cut,omitempty"` // the characters left off its end
}
// Content is what a call was given and what it said, in the order of the file.
type Content struct {
Input []Part `json:"input"`
Output []Part `json:"output"`
Cut bool `json:"cut,omitempty"` // there was more than is kept here
}
const (
partMax = 8000 // characters of one part
contentMax = 160_000 // of all of them
)
var errNoPlace = errors.New("the call has no place in a file")
// add keeps a part, cut to what one may be, unless the content is full; it
// says whether there is room for more.
func (c *Content) add(input bool, p Part) bool {
size := 0
for _, x := range c.Input {
size += len(x.Text)
}
for _, x := range c.Output {
size += len(x.Text)
}
if size >= contentMax {
c.Cut = true
return false
}
p, ok := keep(p)
if !ok {
return true
}
if input {
c.Input = append(c.Input, p)
} else {
c.Output = append(c.Output, p)
}
return true
}
// keep is a part trimmed and cut to what one may be, unless it says nothing.
func keep(p Part) (Part, bool) {
p.Text = strings.TrimSpace(p.Text)
if p.Text == "" {
return p, false
}
if n := utf8.RuneCountInString(p.Text); n > partMax {
r := []rune(p.Text)
p.Text, p.Cut = string(r[:partMax]), n-partMax
}
return p, true
}
// ContentOf reads what was said in a call from its file.
func ContentOf(c Call) (Content, error) {
out := Content{Input: []Part{}, Output: []Part{}}
if c.File == "" || c.Agent == "opencode" { // OpenCode's are rows, not lines of a file
return out, errNoPlace
}
var err error
if c.Agent == "codex" {
err = codexContent(c, out.add)
} else {
err = claudeContent(c, out.add)
}
return out, err
}
// FindCall is the call of a session made between two times that ended
// nearest at (a call's Time is its last line's), if the session files have
// one. A gateway request's window can hold the next calls of a quick tool
// loop too; the newest of them would be another request's.
func FindCall(session string, from, to, at time.Time) (Call, bool) {
if session == "" {
return Call{}, false
}
var best Call
found := false
dist := func(t time.Time) time.Duration { return max(t.Sub(at), at.Sub(t)) }
for _, c := range callsFor(from.Add(-time.Minute), session) {
if c.Session == session && !c.Time.Before(from) && !c.Time.After(to) && (!found || dist(c.Time) < dist(best.Time)) {
best, found = c, true
}
}
return best, found
}
// ---- Claude Code
type ccBlock struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
Content json.RawMessage `json:"content"`
}
// claudeContent reads the lines from where the call was asked to where it ended:
// the user lines — a prompt, a tool's result — are what it was given, and the
// lines of its own message what it said. add is told each part, and says
// whether to read on.
func claudeContent(c Call, add func(input bool, p Part) bool) error {
_, err := scanAt(c.File, c.From, nil, func(b []byte, start, end int64) bool {
if start >= c.To {
return false
}
user := bytes.Contains(b, ccUserLine)
asst := bytes.Contains(b, ccAssistant)
if !user && !asst {
return true
}
var l struct {
Type string `json:"type"`
IsMeta bool `json:"isMeta"`
Message struct {
ID string `json:"id"`
Content json.RawMessage `json:"content"`
} `json:"message"`
}
if json.Unmarshal(b, &l) != nil {
return true
}
more := true
switch {
case l.Type == "user" && !l.IsMeta:
for _, p := range claudeParts(l.Message.Content, "user") {
more = add(true, p) && more
}
case l.Type == "assistant" && (c.Msg == "" || l.Message.ID == c.Msg):
for _, p := range claudeParts(l.Message.Content, "assistant") {
more = add(false, p) && more
}
}
return more
})
return err
}
// claudeParts are the parts of a message's content, which is words, or blocks.
func claudeParts(raw json.RawMessage, role string) []Part {
var text string
if json.Unmarshal(raw, &text) == nil {
return []Part{textPart(role, text)}
}
var blocks []ccBlock
if json.Unmarshal(raw, &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 "tool_use":
parts = append(parts, Part{Role: role, Kind: "tool_use", Name: b.Name, Text: pretty(b.Input)})
case "tool_result":
parts = append(parts, Part{Role: "tool", Kind: "tool_result", Text: resultText(b.Content)})
case "image":
parts = append(parts, Part{Role: role, Kind: "image", Text: "[image]"})
}
}
return parts
}
// textPart is words said, or, of what the agent put in itself (a reminder, a
// command's note), its context.
func textPart(role, text string) Part {
t := strings.TrimSpace(text)
if strings.HasPrefix(t, "<") || strings.HasPrefix(t, "# AGENTS.md") {
return Part{Role: role, Kind: "context", Text: text}
}
return Part{Role: role, Kind: "text", Text: text}
}
// resultText is what a tool answered: words, or blocks of them.
func resultText(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if json.Unmarshal(raw, &blocks) != nil {
return string(raw)
}
var out []string
for _, b := range blocks {
switch b.Type {
case "text":
out = append(out, b.Text)
case "image":
out = append(out, "[image]")
}
}
return strings.Join(out, "\n")
}
// pretty is JSON laid out for reading, when it is short.
func pretty(raw json.RawMessage) string {
if len(raw) == 0 || len(raw) > 4000 {
return string(raw)
}
var b bytes.Buffer
if json.Indent(&b, raw, "", " ") != nil {
return string(raw)
}
return b.String()
}
// ---- Codex
var cxItem = []byte(`"type":"response_item"`)
// codexContent reads the items between the call before and this one: what the
// model was given (the prompt, a tool's output) and what it said (words,
// reasoning, a call of a tool). A rollout the Codex app has compressed (or
// unpacked) since the call was read is read in its new form: its lines and
// their places are the same. add is as claudeContent's.
func codexContent(c Call, add func(input bool, p Part) bool) error {
path := c.File
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
if _, err := os.Stat(rolloutTwin(path)); err == nil {
path = rolloutTwin(path)
}
}
_, err := scanAt(path, c.From, nil, func(b []byte, start, end int64) bool {
if start >= c.To {
return false
}
if !bytes.Contains(b, cxItem) {
return true
}
var l struct {
Payload struct {
Type string `json:"type"`
Role string `json:"role"`
Name string `json:"name"`
Arguments string `json:"arguments"`
Input string `json:"input"`
Output json.RawMessage `json:"output"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
Summary []struct {
Text string `json:"text"`
} `json:"summary"`
Action struct {
Command []string `json:"command"`
} `json:"action"`
} `json:"payload"`
}
if json.Unmarshal(b, &l) != nil {
return true
}
p := &l.Payload
words := func() string {
var s []string
for _, x := range p.Content {
s = append(s, x.Text)
}
return strings.Join(s, "\n")
}
switch p.Type {
case "message":
switch p.Role {
case "user":
return add(true, textPart("user", words()))
case "assistant":
return add(false, textPart("assistant", words()))
}
case "reasoning":
var s []string
for _, x := range p.Summary {
s = append(s, x.Text)
}
return add(false, Part{Role: "assistant", Kind: "thinking", Text: strings.Join(s, "\n\n")})
case "function_call":
return add(false, Part{Role: "assistant", Kind: "tool_use", Name: p.Name, Text: pretty(json.RawMessage(p.Arguments))})
case "custom_tool_call":
return add(false, Part{Role: "assistant", Kind: "tool_use", Name: p.Name, Text: p.Input})
case "local_shell_call":
return add(false, Part{Role: "assistant", Kind: "tool_use", Name: "shell", Text: strings.Join(p.Action.Command, " ")})
case "function_call_output", "custom_tool_call_output":
return add(true, Part{Role: "tool", Kind: "tool_result", Text: codexOutput(p.Output)})
}
return true
})
return err
}
// codexOutput is what a tool answered: words, or an object with them in.
func codexOutput(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var o struct {
Content string `json:"content"`
Output string `json:"output"`
}
if json.Unmarshal(raw, &o) == nil && (o.Content != "" || o.Output != "") {
if o.Content != "" {
return o.Content
}
return o.Output
}
return string(raw)
}