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 == "" {