Files
yetone 585f7f7dfa sessions, gui: Codex sessions made with another provider are moved to Codex's own from the Sessions page, and back (#887, cjyrainbow) — Codex's history lists only the sessions of the provider it uses now, so sessions made through CC Switch (model_provider "custom") vanished from it once Codex used magpie, though their files were still there and the Sessions page listed them. The Codex tab now reads each session's provider from its session_meta (cached with the rest) and the one Codex in that session's home uses (config.toml's model_provider, or its profile's; "openai" when none), says how many were made with another and which, and tags each. "Move to <provider>…" opens magpie's own dialog listing them, each ticked; Move rewrites only the model_provider string in each session's files (.jsonl and .jsonl.zst, in its own Codex home, so WSL's too, mtime kept) and in Codex's state_*.sqlite threads row: messages, id, title and archive are left as they are. A copy of the files goes to magpie's trash folder (trash/codex-provider, with a move.json) first, a failed rewrite is put back from it, and a session written to in the last minute is left alone and said so. Undo moves each back to the provider it had, byte for byte. Nothing is moved by itself. POST /api/sessions/codex-provider; en/zh/ja/de. TestMoveCodexProvider (listing, bytes, mtime, the DB row and nothing else, backup, byte-identical undo, an active session, a bad id), TestMoveCodexProviderPacked and TestCodexProviderIn fail without the change, as does sessions-codex-provider.test.cjs (WebKit, en and zh: magpie's dialog, Cancel posts nothing, an unticked one stays, Undo, no click moves the page, no left border, translations); sessions-manage, -toolbar, -purge, -folder-delete, -wsl-delete and click-scroll pass. Not tried against a real CC Switch history. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 17:26:19 +08:00

390 lines
11 KiB
Go

package sessions
import (
"bytes"
"encoding/json"
"strings"
"time"
"github.com/tidwall/gjson"
)
// Codex writes a rollout file per session (a session picked up again later
// may go on in another, named with the same thread id): a session_meta line,
// a turn_context per turn naming the model, and token_count events with the
// running total and the last call's usage. Its input counts include the
// cached tokens.
type cxLine struct {
Type string `json:"type"`
Payload struct {
cxCompaction
cxHistorySnapshot
Type string `json:"type"`
SessionID string `json:"session_id"`
TurnID string `json:"turn_id"`
ResponseID string `json:"response_id"`
Usage *cxUsage `json:"usage"`
ThreadUsage *cxUsage `json:"thread_token_usage"`
ID string `json:"id"`
Cwd string `json:"cwd"`
Model string `json:"model"`
// session_meta's
ModelProvider string `json:"model_provider"`
Role string `json:"role"`
Message string `json:"message"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Settings struct {
Model string `json:"model"`
Cwd string `json:"cwd"`
} `json:"thread_settings"`
Info *struct {
Total *cxUsage `json:"total_token_usage"`
Last *cxUsage `json:"last_token_usage"`
} `json:"info"`
} `json:"payload"`
}
type cxUsage struct {
Input int `json:"input_tokens"`
Cached int `json:"cached_input_tokens"`
CacheWrite int `json:"cache_write_input_tokens"`
Output int `json:"output_tokens"`
Reasoning int `json:"reasoning_output_tokens"`
Known uint8 `json:"known,omitempty"`
}
// raw is the usage in Codex's own terms, input with the cache in it.
func (u cxUsage) raw() Tokens {
return Tokens{Input: u.Input, Output: u.Output, CacheRead: u.Cached, CacheWrite: u.CacheWrite}
}
// spent is raw usage with the cache taken out of the input: Codex's
// input_tokens is the whole prompt, what was read from the cache and what
// was written to it (cache_write_input_tokens, which a newer Codex keeps)
// among it, so neither is counted twice (#589).
func spent(t Tokens) Tokens {
t.Input -= t.CacheRead + t.CacheWrite
if t.Input < 0 {
t.Input = 0
}
return t
}
var (
cxMeta = []byte(`"type":"session_meta"`)
cxTurn = []byte(`"type":"turn_context"`)
cxRecord = []byte(`"type":"token_usage_record"`)
cxCompacted = []byte(`"type":"compacted"`)
cxCount = []byte(`"type":"token_count"`)
cxSettings = []byte(`"type":"thread_settings_applied"`)
cxUserMsg = []byte(`"type":"user_message"`)
cxUserRole = []byte(`"role":"user"`)
cxRole = []byte(`"role":"`)
cxType = []byte(`"type":"`)
cxPayload = []byte(`"payload":{"type":"`)
cxText = []byte(`,"text":"`)
)
func codexLine(s *state, b []byte, main bool) {
if codexHead(s, b, main) {
codexBody(s, b, main)
}
}
// codexHead notes the time of a line, from its start, and says whether the
// rest of it is worth reading: from its type and its payload's, which a
// rollout writes first, so irrelevant tool output is never searched.
// Compactions carry usage metadata. A line laid out otherwise is read.
func codexHead(s *state, b []byte, main bool) bool {
at := tsAt(b[:min(len(b), 256)], false)
if at.IsZero() {
at = tsAt(b, false)
}
s.saw(at, main)
h := b[:min(len(b), 1024)]
top := typeAfter(h, cxType)
if s.Codex != nil && top == "response_item" {
payload := typeAfter(h, cxPayload)
if payload == "message" && bytes.Contains(h, cxUserRole) {
s.Codex.Pending = 0
}
}
if s.Codex != nil && top == "event_msg" && typeAfter(h, cxPayload) == "user_message" {
s.Codex.Pending = 0
}
switch top {
case "":
return true
case "session_meta":
return s.ID == ""
case "turn_context", "token_usage_record", "compacted":
return true
case "event_msg", "response_item":
default:
return false
}
payload := typeAfter(h, cxPayload)
switch {
case payload == "":
return true
case top == "event_msg":
return payload == "token_count" || payload == "task_started" || payload == "thread_settings_applied" || payload == "user_message" && s.Title == ""
case cxCalls[payload]:
// a tool call, named in its head
name := typeAfter(h, cxName)
if payload != "function_call" && payload != "custom_tool_call" || name == "" {
name = payload
}
s.tool(at, name, "")
return false
case payload == "message" && typeAfter(h, cxRole) == "assistant":
if main {
s.day(dateOf(at)).Replies++
}
return false
default:
return payload == "message" && (main || s.Title == "") && (!bytes.Contains(h, cxRole) || bytes.Contains(h, cxUserRole))
}
}
// cxCalls are the kinds of response item that call a tool.
var cxCalls = map[string]bool{"function_call": true, "custom_tool_call": true, "local_shell_call": true, "web_search_call": true, "tool_search_call": true, "image_generation_call": true}
var cxName = []byte(`"name":"`)
// typeAfter is the word in quotes after the first key in b, or "".
func typeAfter(b, key []byte) string {
i := bytes.Index(b, key)
if i < 0 {
return ""
}
rest := b[i+len(key):]
j := bytes.IndexByte(rest, '"')
if j < 0 {
return ""
}
return string(rest[:j])
}
// Read compaction identity/usage without decoding its potentially multi-MB
// replacement history. The parser needs none of that conversation content.
func cxCompactionIn(b []byte) cxCompaction {
fields := gjson.GetManyBytes(b, "payload.compaction_response_id", "payload.latest_token_usage_record")
p := cxCompaction{ResponseID: fields[0].String()}
if fields[1].Exists() {
_ = json.Unmarshal([]byte(fields[1].Raw), &p.Record)
}
return p
}
// codexBody reads a line codexHead let through.
func codexBody(s *state, b []byte, main bool) {
at := tsAt(b[:min(len(b), 256)], false)
if at.IsZero() {
at = tsAt(b, false)
}
if typeAfter(b[:min(len(b), 1024)], cxType) == "compacted" {
codexApply(s, codexState(s).compacted(at, s.Model, cxCompactionIn(b)))
return
}
// the two kinds of line most read, each told from its head
if cxFastCount(s, at, b) || cxFastPrompt(s, at, b, main) {
return
}
want := s.ID == "" && bytes.Contains(b, cxMeta) ||
bytes.Contains(b, cxTurn) || bytes.Contains(b, cxCount) || bytes.Contains(b, cxRecord) || bytes.Contains(b, cxCompacted) || bytes.Contains(b, cxStarted) || bytes.Contains(b, cxSettings) ||
s.Title == "" && bytes.Contains(b, cxUserMsg) || (main || s.Title == "") && bytes.Contains(b, cxUserRole)
if !want {
return
}
var l cxLine
if json.Unmarshal(b, &l) != nil {
return
}
p := &l.Payload
switch {
case l.Type == "compacted":
codexApply(s, codexState(s).compacted(at, s.Model, p.cxCompaction))
case l.Type == "token_usage_record":
if p.Usage != nil {
codexApply(s, codexState(s).record(codexContribution{ResponseID: p.ResponseID, Session: p.SessionID, Turn: p.TurnID, Model: s.Model, At: at, Usage: *p.Usage, Total: p.ThreadUsage}))
}
case l.Type == "session_meta":
session := p.SessionID
if session == "" {
session = p.ID
}
codexState(s).context(session, "", "")
codexState(s).beginSnapshot(at, p.ID, p.cxHistorySnapshot)
if s.ID == "" {
s.ID = p.ID
if s.Provider = p.ModelProvider; s.Provider == "" {
s.Provider = "openai"
}
}
if s.Cwd == "" {
s.Cwd = p.Cwd
}
case l.Type == "turn_context":
codexState(s).context("", p.TurnID, p.Model)
if p.Model != "" {
s.Model = p.Model
}
if s.Cwd == "" {
s.Cwd = p.Cwd
}
case l.Type == "event_msg" && p.Type == "task_started":
codexState(s).context("", p.TurnID, "")
codexState(s).Pending = 0
case l.Type == "event_msg" && p.Type == "thread_settings_applied":
if p.Settings.Model != "" {
s.Model = p.Settings.Model
codexState(s).context("", "", p.Settings.Model)
}
case l.Type == "event_msg" && p.Type == "user_message":
if s.Title == "" {
s.Title = cxPrompt(p.Message)
}
if s.Title == "" && s.First == "" {
s.First = untagged(p.Message)
}
case l.Type == "response_item" && p.Type == "message" && p.Role == "user":
typed := false
for _, c := range p.Content {
if c.Type == "input_text" || c.Type == "text" {
t := cxPrompt(c.Text)
typed = typed || t != ""
if s.Title == "" {
s.Title = t
}
}
}
if typed && main {
s.day(dateOf(at)).Prompts++
}
case l.Type == "response_item" && p.Type == "message" && p.Role == "assistant":
if main {
s.day(dateOf(at)).Replies++
}
case l.Type == "event_msg" && p.Type == "token_count":
if p.Info != nil {
cxCounted(s, at, p.Info.Total, p.Info.Last)
}
}
}
// cxFastPrompt reads a prompt's texts out of a user message without decoding
// the images it may carry: a content part's type and text are written in
// that order, and the key can't be in a string, where its quotes would be
// escaped. A message laid out otherwise is left to be read whole.
func cxFastPrompt(s *state, at time.Time, b []byte, main bool) bool {
h := b[:min(len(b), 1024)]
if typeAfter(h, cxType) != "response_item" || typeAfter(h, cxPayload) != "message" || typeAfter(h, cxRole) != "user" {
return false
}
found, typed := false, false
for rest := b; ; {
i := bytes.Index(rest, cxType)
if i < 0 {
if typed && main {
s.day(dateOf(at)).Prompts++
}
// no text at all (an image alone) is read as such too
return found || !bytes.Contains(b, cxText[1:])
}
rest = rest[i+len(cxType):]
t := ""
if j := bytes.IndexByte(rest, '"'); j >= 0 {
t, rest = string(rest[:j]), rest[j+1:]
}
if t != "input_text" && t != "text" {
continue
}
if !bytes.HasPrefix(rest, cxText) {
return false
}
found = true
text := strAt(rest[:len(cxText)+strEnd(rest[len(cxText):])], cxText)
p := cxPrompt(text)
typed = typed || p != ""
if s.Title == "" {
s.Title = p
}
}
}
// strEnd is the length of a JSON string's body and its closing quote.
func strEnd(b []byte) int {
for j := 0; j < len(b); j++ {
switch b[j] {
case '\\':
j++
case '"':
return j + 1
}
}
return len(b)
}
// cxCounted uses the same interval reconciliation as the per-call reader.
func cxCounted(s *state, at time.Time, tot, last *cxUsage) {
codexApply(s, codexState(s).count(at, s.Model, tot, last))
}
var (
cxTotal = []byte(`"total_token_usage":{`)
cxLast = []byte(`"last_token_usage":{`)
cxInfo = []byte(`"info":null`)
)
// cxUsageIn reads the usage object after key in b, which holds no other
// object, without decoding the rest of the line.
func cxUsageIn(b, key []byte) (*cxUsage, bool) {
i := bytes.Index(b, key)
if i < 0 {
return nil, false
}
obj := b[i+len(key)-1:]
j := bytes.IndexByte(obj, '}')
if j < 0 {
return nil, false
}
var u cxUsage
if json.Unmarshal(obj[:j+1], &u) != nil {
return nil, false
}
return &u, true
}
// cxFastCount counts a token_count line from its usage objects alone; false
// when the line is laid out otherwise and wants decoding.
func cxFastCount(s *state, at time.Time, b []byte) bool {
if h := b[:min(len(b), 1024)]; typeAfter(h, cxType) != "event_msg" || typeAfter(h, cxPayload) != "token_count" {
return false
}
if bytes.Contains(b[:min(len(b), 1024)], cxInfo) {
return true
}
tot, ok := cxUsageIn(b, cxTotal)
if !ok {
return false
}
last, _ := cxUsageIn(b, cxLast)
cxCounted(s, at, tot, last)
return true
}
// cxPrompt is the words of a prompt, or "" for what Codex put in itself
// (the environment, AGENTS.md, instructions).
func cxPrompt(text string) string {
t := strings.TrimSpace(text)
if t == "" || strings.HasPrefix(t, "<") || strings.HasPrefix(t, "# AGENTS.md") {
return ""
}
return title(t)
}