From b84b203256a3ab45221d227d6ae8400a97dedb3f Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 3 Oct 2026 15:08:38 +0800 Subject: [PATCH] =?UTF-8?q?sessions:=20Droid's=20(Factory=20CLI)=20session?= =?UTF-8?q?s=20are=20listed,=20with=20what=20they=20spent=20and=20`droid?= =?UTF-8?q?=20--resume=20`=20(Discord,=20=E8=B0=9F=E7=AB=AD:=20"magpie?= =?UTF-8?q?=E4=B8=AD=E4=BC=9A=E8=AF=9D=E5=8A=9F=E8=83=BD=E4=B8=AD=E4=B8=8D?= =?UTF-8?q?=E6=98=BE=E7=A4=BAdroid=20=E8=BF=99=E4=B8=AAagent")=20=E2=80=94?= =?UTF-8?q?=20the=20Sessions=20view=20read=20no=20droid=20sessions=20at=20?= =?UTF-8?q?all.=20droid=20keeps=20each=20under=20~/.factory/sessions=20(or?= =?UTF-8?q?=20$FACTORY=5FHOME=5FOVERRIDE/.factory),=20a=20folder=20per=20c?= =?UTF-8?q?wd=20(older=20ones=20in=20sessions/=20itself):=20.jsonl=20s?= =?UTF-8?q?tarts=20with=20a=20session=5Fstart=20(id,=20title,=20cwd/lastCw?= =?UTF-8?q?d,=20parent=20+=20forkedAtMessageId=20for=20a=20fork,=20calling?= =?UTF-8?q?SessionId=20for=20a=20subagent=20or=20/btw),=20then=20message?= =?UTF-8?q?=20events=20with=20an=20ISO=20timestamp=20and=20Anthropic-style?= =?UTF-8?q?=20content;=20.settings.json=20holds=20the=20running=20toke?= =?UTF-8?q?nUsage=20(inputTokens=20without=20the=20cache,=20outputTokens?= =?UTF-8?q?=20with=20thinking)=20for=20its=20model.=20The=20title=20is=20t?= =?UTF-8?q?he=20first=20typed=20prompt=20(tagged=20reminders=20skipped),?= =?UTF-8?q?=20else=20droid's;=20prompts,=20replies=20and=20tool=20calls=20?= =?UTF-8?q?count;=20a=20subagent's=20spend=20counts=20in=20the=20session?= =?UTF-8?q?=20that=20called=20it,=20a=20fork=20skips=20the=20lines=20it=20?= =?UTF-8?q?copied;=20custom:=20models=20are=20priced=20as=20the=20mode?= =?UTF-8?q?l=20their=20customModels=20entry=20asks=20for=20(magpie's=20own?= =?UTF-8?q?=20custom:magpie/=20=E2=86=92=20),=20else=20the=20id?= =?UTF-8?q?=20without=20droid's=20-[x]-N=20tail.=20The=20total=20isn't=20s?= =?UTF-8?q?plit=20by=20turn,=20so=20it=20counts=20on=20the=20day=20of=20th?= =?UTF-8?q?e=20last=20message.=20Format=20read=20from=20droid=200.231.0's?= =?UTF-8?q?=20own=20code.=20TestDroid=20fails=20on=20the=20old=20code=20(0?= =?UTF-8?q?=20sessions)=20and=20passes;=20go=20vet,=20builds=20(darwin,=20?= =?UTF-8?q?GOOS=3Dlinux,=20GOOS=3Dwindows)=20and=20go=20test=20-tags=20nog?= =?UTF-8?q?ui=20-count=3D1=20./...=20under=20a=20temp=20HOME=20pass.=20Not?= =?UTF-8?q?=20tried=20against=20a=20real=20droid=20session=20(no=20Factory?= =?UTF-8?q?=20account=20on=20this=20machine).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/sessions/droid.go | 256 ++++++++++++++++++++++++++++++++ internal/sessions/droid_test.go | 109 ++++++++++++++ internal/sessions/sessions.go | 16 +- 3 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 internal/sessions/droid.go create mode 100644 internal/sessions/droid_test.go diff --git a/internal/sessions/droid.go b/internal/sessions/droid.go new file mode 100644 index 00000000..6e831600 --- /dev/null +++ b/internal/sessions/droid.go @@ -0,0 +1,256 @@ +package sessions + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/tidwall/gjson" + "github.com/tidwall/jsonc" +) + +// Factory's Droid keeps a session under its sessions folder +// (~/.factory/sessions, or $FACTORY_HOME_OVERRIDE/.factory/sessions), in a +// folder per working directory (older ones right in it): .jsonl, the +// transcript, and .settings.json beside it. The transcript's first line +// is a session_start event — the id, droid's title for it, the cwd, the +// session it was forked from (parent, with forkedAtMessageId the last +// message it copied) and, for a subagent's or a /btw side question's, the +// session that called it (callingSessionId). Each line after is an event, +// a "message" one carrying its time (ISO) and the message, role and content +// blocks as Anthropic's. What the session spent is in its settings, one +// running total of its own (tokenUsage: input without the cache, output +// with the thinking in it) for the model it is on (model); a fork starts at +// none, and a subagent's counts in the session that called it. The total +// isn't split by turn, so it counts on the day of the last message. +// `droid --resume ` picks one up again. + +// FactoryDir is Droid's folder: $FACTORY_HOME_OVERRIDE/.factory, else +// ~/.factory. +func FactoryDir() string { + home := strings.TrimSpace(os.Getenv("FACTORY_HOME_OVERRIDE")) + if home == "" { + home, _ = os.UserHomeDir() + } else { + home = expandHome(home) + } + return filepath.Join(home, ".factory") +} + +type droidStart struct { + Type string `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + Cwd string `json:"cwd"` + LastCwd string `json:"lastCwd"` + Parent string `json:"parent"` + Calling string `json:"callingSessionId"` + Forked string `json:"forkedAtMessageId"` +} + +// readDroidStart is a transcript's session_start line. +func readDroidStart(path string) (droidStart, bool) { + var s droidStart + ok := false + scanAt(path, 0, nil, func(b []byte, _, _ int64) bool { + ok = json.Unmarshal(b, &s) == nil && s.Type == "session_start" + return false + }) + return s, ok +} + +func droidFiles() []file { + root := filepath.Join(FactoryDir(), "sessions") + a, _ := filepath.Glob(filepath.Join(root, "*.jsonl")) + b, _ := filepath.Glob(filepath.Join(root, "*", "*.jsonl")) + var out []file + for _, p := range append(a, b...) { + id := strings.TrimSuffix(filepath.Base(p), ".jsonl") + if !safeID.MatchString(id) { + continue + } + f := file{agent: "droid", key: "droid:" + id, path: p, main: true} + if !stat(&f) { + continue + } + // what it spent and on which model are in its settings + m := file{path: strings.TrimSuffix(p, ".jsonl") + ".settings.json"} + if stat(&m) { + f.manifest = m.path + f.size += m.size + if m.mod.After(f.mod) { + f.mod = m.mod + } + } + if s, ok := readDroidStart(p); ok && s.Calling != "" && s.Calling != id && safeID.MatchString(s.Calling) { + f.key, f.main = "droid:"+s.Calling, false + } + out = append(out, f) + } + return out +} + +type droidUsage struct { + Input int `json:"inputTokens"` + Output int `json:"outputTokens"` + CacheWrite int `json:"cacheCreationTokens"` + CacheRead int `json:"cacheReadTokens"` +} + +type droidSettings struct { + Model string `json:"model"` + Usage *droidUsage `json:"tokenUsage"` +} + +type droidLine struct { + Type string `json:"type"` + ID string `json:"id"` + Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"message"` +} + +// droidText is a message's text: its content as a string, or its text +// blocks. +func droidText(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"` + } + json.Unmarshal(raw, &blocks) + var parts []string + for _, b := range blocks { + if b.Type == "text" && strings.TrimSpace(b.Text) != "" { + parts = append(parts, b.Text) + } + } + return strings.Join(parts, "\n") +} + +// droidTools are a message's tool calls, by name. +func droidTools(raw json.RawMessage) []string { + var blocks []struct { + Type string `json:"type"` + Name string `json:"name"` + } + json.Unmarshal(raw, &blocks) + var out []string + for _, b := range blocks { + if b.Type == "tool_use" && b.Name != "" { + out = append(out, b.Name) + } + } + return out +} + +// parseDroid reads a session's transcript whole, with its settings. +func parseDroid(f file) *state { + s := &state{Size: f.size, Mod: f.mod.UnixNano()} + // a fork's lines up to upto are copies of its source's + upto, copied := "", false + first := true + scan(f.path, 0, func(b []byte) { + var l droidLine + if json.Unmarshal(b, &l) != nil { + return + } + if first { + first = false + var st droidStart + if l.Type == "session_start" && json.Unmarshal(b, &st) == nil { + s.ID, s.Cwd, s.Named = st.ID, st.Cwd, title(st.Title) + if st.LastCwd != "" { + s.Cwd = st.LastCwd + } + upto, copied = st.Forked, st.Forked != "" + } + return + } + if l.Type != "message" { + return + } + if copied { + if l.ID == upto { + copied = false + } + return + } + at := tsAt(b, false) + s.saw(at, f.main) + switch l.Message.Role { + case "user": + text := droidText(l.Message.Content) + if strings.TrimSpace(text) == "" || !f.main { + return + } + if strings.HasPrefix(strings.TrimSpace(text), "<") { + if s.First == "" { + s.First = untagged(text) + } + return + } + s.day(dateOf(at)).Prompts++ + if s.Title == "" { + s.Title = title(text) + } + case "assistant": + if f.main { + s.day(dateOf(at)).Replies++ + } + for _, name := range droidTools(l.Message.Content) { + s.tool(at, name, "") + } + } + }) + if f.manifest != "" { + var st droidSettings + if b, err := os.ReadFile(f.manifest); err == nil && json.Unmarshal(b, &st) == nil && st.Usage != nil { + when := s.Last + if when.IsZero() { + when = time.Unix(0, s.Mod) + } + u := st.Usage + s.use(dateOf(when), droidModelName(st.Model), Tokens{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite}) + } + } + return s +} + +// droidSlot is the tail droid gives a custom model's id made of its name +// ("custom:Kimi-K2-[Groq]-0"). +var droidSlot = regexp.MustCompile(`(-\[[^\]]*\])?-\d+$`) + +// droidModelName is a session's model as it is priced: a custom one +// ("custom:") is the model its entry in droid's settings asks for — +// magpie's own are "/" — else its id without droid's +// additions; Factory's own are as they are. +func droidModelName(m string) string { + if m == "" { + return "droid" + } + if !strings.HasPrefix(m, "custom:") { + return m + } + if raw, err := os.ReadFile(filepath.Join(FactoryDir(), "settings.json")); err == nil { + of := "" + gjson.GetBytes(jsonc.ToJSONInPlace(raw), "customModels").ForEach(func(_, v gjson.Result) bool { + if v.Get("id").String() == m { + of = v.Get("model").String() + return false + } + return true + }) + if of != "" { + return of + } + } + return droidSlot.ReplaceAllString(strings.TrimPrefix(m, "custom:"), "") +} diff --git a/internal/sessions/droid_test.go b/internal/sessions/droid_test.go new file mode 100644 index 00000000..6e3b5e24 --- /dev/null +++ b/internal/sessions/droid_test.go @@ -0,0 +1,109 @@ +package sessions + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const ( + droidMain = "d0000000-0000-4000-8000-00000000000a" + droidSub = "d0000000-0000-4000-8000-00000000000b" + droidFork = "d0000000-0000-4000-8000-00000000000c" + droidOld = "d0000000-0000-4000-8000-00000000000d" +) + +// putDroid writes Droid's sessions under home/.factory as droid keeps +// them: a folder per cwd, an older one right in sessions/, each +// transcript's settings beside it. +func putDroid(t *testing.T, home string) { + t.Helper() + root := filepath.Join(home, ".factory") + write := func(rel string, lines ...string) { + p := filepath.Join(root, rel) + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + // magpie's own custom model, as the agent writes it + write("settings.json", `{"customModels":[{"model":"anthropic/claude-sonnet-4-5","id":"custom:magpie/anthropic/claude-sonnet-4-5","provider":"anthropic"}]}`) + start := `{"type":"session_start","id":"` + droidMain + `","title":"Login bug","owner":"me","version":2,"cwd":"/work/droid"}` + m1 := `{"type":"message","id":"m1","timestamp":"2026-09-27T08:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"house rules"}]}}` + m2 := `{"type":"message","id":"m2","timestamp":"2026-09-27T08:00:05.000Z","message":{"role":"user","content":[{"type":"text","text":"Fix the login bug"}]}}` + m3 := `{"type":"message","id":"m3","timestamp":"2026-09-27T08:01:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading"},{"type":"tool_use","id":"t1","name":"Read","input":{}}]}}` + m4 := `{"type":"message","id":"m4","timestamp":"2026-09-27T08:02:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}` + write("sessions/-work-droid/"+droidMain+".jsonl", start, m1, m2, m3, m4) + write("sessions/-work-droid/"+droidMain+".settings.json", `{"model":"custom:magpie/anthropic/claude-sonnet-4-5","tokenUsage":{"inputTokens":100,"outputTokens":50,"cacheCreationTokens":10,"cacheReadTokens":1000,"thinkingTokens":20}}`) + // a subagent it called: its spend is the session's + write("sessions/-work-droid/"+droidSub+".jsonl", + `{"type":"session_start","id":"`+droidSub+`","title":"Explore","owner":"me","cwd":"/work/droid","callingSessionId":"`+droidMain+`"}`, + `{"type":"message","id":"s1","timestamp":"2026-09-27T08:01:10.000Z","message":{"role":"user","content":"Look around"}}`) + write("sessions/-work-droid/"+droidSub+".settings.json", `{"model":"claude-haiku-4-5","tokenUsage":{"inputTokens":5,"outputTokens":2,"cacheCreationTokens":0,"cacheReadTokens":0,"thinkingTokens":0}}`) + // a fork: the lines it copied are its source's + write("sessions/-work-droid/"+droidFork+".jsonl", + `{"type":"session_start","id":"`+droidFork+`","title":"Login bug","owner":"me","cwd":"/work/droid","parent":"`+droidMain+`","forkedAtMessageId":"m4"}`, + m1, m2, m3, m4, + `{"type":"message","id":"f1","timestamp":"2026-09-27T09:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"Try another way"}]}}`, + `{"type":"message","id":"f2","timestamp":"2026-09-27T09:00:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Sure"}]}}`) + write("sessions/-work-droid/"+droidFork+".settings.json", `{"model":"custom:Kimi-K2-[Groq]-0","tokenUsage":{"inputTokens":7,"outputTokens":3,"cacheCreationTokens":0,"cacheReadTokens":0,"thinkingTokens":0}}`) + // an older one, in sessions/ itself, with no settings + write("sessions/"+droidOld+".jsonl", + `{"type":"session_start","id":"`+droidOld+`","title":"New Session","owner":"me"}`, + `{"type":"message","id":"o1","timestamp":"2026-09-20T10:00:00.000Z","message":{"role":"user","content":"hi there"}}`) +} + +func TestDroid(t *testing.T) { + inZone(t, 0) + home := setupAgents(t) + t.Setenv("FACTORY_HOME_OVERRIDE", "") + putDroid(t, home) + Reset() + ss := List(0) + if n := count(ss, "droid"); n != 3 { + t.Fatalf("want 3 Droid sessions (the subagent in its session), got %d", n) + } + d := find(t, ss, "droid", droidMain) + if d.Cwd != "/work/droid" || d.Title != "Fix the login bug" { + t.Fatalf("droid: %+v", d) + } + // magpie's custom model by the model it asks for, the subagent's in + if m := model(d, "anthropic/claude-sonnet-4-5"); m.Tokens != (Tokens{100, 50, 1000, 10}) { + t.Fatalf("sonnet: %+v", d.Models) + } + if m := model(d, "claude-haiku-4-5"); m.Tokens != (Tokens{5, 2, 0, 0}) { + t.Fatalf("haiku, the subagent's in: %+v", d.Models) + } + if !d.Start.Equal(at("2026-09-27T08:00:00Z")) || !d.Last.Equal(at("2026-09-27T08:02:00Z")) { + t.Fatalf("droid times %s %s", d.Start, d.Last) + } + if runtime.GOOS != "windows" { + if want := "cd '/work/droid' && droid --resume " + droidMain; d.Resume != want { + t.Fatalf("resume %q", d.Resume) + } + } + // the fork: what it copied is its source's; a custom model of the + // user's own by its name + f := find(t, ss, "droid", droidFork) + if f.Title != "Try another way" || !f.Start.Equal(at("2026-09-27T09:00:00Z")) || !f.Last.Equal(at("2026-09-27T09:00:30Z")) { + t.Fatalf("fork: %+v", f) + } + if m := model(f, "Kimi-K2"); m.Tokens != (Tokens{7, 3, 0, 0}) { + t.Fatalf("fork models: %+v", f.Models) + } + if o := find(t, ss, "droid", droidOld); o.Title != "hi there" || !o.Tokens.zero() { + t.Fatalf("old: %+v", o) + } + + // $FACTORY_HOME_OVERRIDE + moved := filepath.Join(filepath.Dir(home), "factory-home") + os.MkdirAll(moved, 0o755) + if err := os.Rename(filepath.Join(home, ".factory"), filepath.Join(moved, ".factory")); err != nil { + t.Fatal(err) + } + t.Setenv("FACTORY_HOME_OVERRIDE", moved) + Reset() + find(t, List(0), "droid", droidMain) +} diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index b37fc590..f2c5d72d 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -2,7 +2,7 @@ // files — Claude Code's projects/*/.jsonl (and Qoder's, the same kind), // Codex's rollout files, OpenCode's database (or its older JSON files) and // ZCode's, Pi's session files and omp's, DeepSeek Harness's, Cline's, Grok -// Build's, WorkBuddy's and Cursor CLI's chat stores — with the tokens each spent, what that cost at the +// Build's, WorkBuddy's, Droid's and Cursor CLI's chat stores — with the tokens each spent, what that cost at the // effective price, and the command that resumes it. It only ever reads the // agents' folders. // @@ -70,7 +70,7 @@ type Model struct { // Session is one agent session. type Session struct { ReadOnly bool `json:"read_only,omitempty"` - Agent string `json:"agent"` // magpie agent id: claude, codex, opencode, pi, omp, zcode, dsh, cline, qoder, qoder-cn, grok, workbuddy, cursor, hermes + Agent string `json:"agent"` // magpie agent id: claude, codex, opencode, pi, omp, zcode, dsh, cline, qoder, qoder-cn, grok, workbuddy, droid, cursor, hermes ID string `json:"id"` Cwd string `json:"cwd"` Title string `json:"title"` // the first prompt, else the agent's own title @@ -291,7 +291,8 @@ type file struct { sid string oc ocStore // Cline: the session's manifest, beside its messages; Grok Build: its - // summary.json, beside its updates + // summary.json, beside its updates; Droid: its settings, beside its + // transcript manifest string hermes *hermesDB readOnly bool @@ -365,7 +366,7 @@ func allFiles() []file { var out []file for _, fs := range [][]file{callFiles(), openCodeFiles(), piFiles(), zcodeFiles(), dshFiles(), clineFiles(), ccFiles("qoder", QoderDir("qoder")), ccFiles("qoder-cn", QoderDir("qoder-cn")), - grokFiles(), workbuddyFiles(), ompFiles(), cursorFiles(), hermesFiles()} { + grokFiles(), workbuddyFiles(), droidFiles(), ompFiles(), cursorFiles(), hermesFiles()} { out = append(out, fs...) } return out @@ -386,6 +387,7 @@ func Dirs() []string { {GrokDir(), filepath.Join(GrokDir(), "sessions")}, {WorkBuddyDir(), filepath.Join(WorkBuddyDir(), "projects")}, {OmpDir(), filepath.Join(OmpDir(), "sessions")}, + {FactoryDir(), filepath.Join(FactoryDir(), "sessions")}, {CursorDir(), filepath.Join(CursorDir(), "chats")}, } { if _, err := os.Stat(d.sessions); err == nil { @@ -854,7 +856,7 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) { if first == "" { first = st.First } - if (s.Agent == "codex" || s.Agent == "pi" || s.Agent == "omp" || s.Agent == "dsh" || s.Agent == "grok") && st.ID != "" && f.path == fs[0].path { + if (s.Agent == "codex" || s.Agent == "pi" || s.Agent == "omp" || s.Agent == "dsh" || s.Agent == "grok" || s.Agent == "droid") && st.ID != "" && f.path == fs[0].path { s.ID = st.ID } } @@ -921,6 +923,8 @@ func parse(f file, old *state) *state { return parseCline(f) case "grok": return parseGrok(f) + case "droid": + return parseDroid(f) case "cursor": return parseCursor(f) } @@ -1203,6 +1207,8 @@ func ResumeCommand(agent, id, cwd string) string { run = "grok --resume " + id case "omp": run = "omp --resume " + id + case "droid": + run = "droid --resume " + id case "cursor": // its chats are looked up by the folder it runs in if cwd == "" {