diff --git a/DESIGN.md b/DESIGN.md index 7a8b545..b73cb1e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -393,6 +393,15 @@ price". ## Query interface +A session id can be in several accounts: each is a copy, its own +transcript, and the index keys sessions by account and id. Commands that +read one session (`show`, `log`, `export`, `view`) take `:` or +`-a`, and otherwise use the copy with the latest activity and name the +others on stderr (`query.DB.CopiesOf`). `Turns` marks each turn whose prompt +lies in a byte range the ledger says ccli copied in (`TurnRow.CopiedFrom`: +the source account, or `account:id` for a fork). Marks that belong to the +id (hide, tag) apply to every copy. + Every command reads through `query.DB`; the terminal renderer only formats its results, and every result serializes as JSON. The main calls are `Accounts(filter)`, `Sessions(filter)`, `Session(ref)`, `Overview(filter)` diff --git a/README.md b/README.md index b608d9e..e5ea654 100644 --- a/README.md +++ b/README.md @@ -489,8 +489,44 @@ and per-day figures) on `sessions`, `show`, `log`, `stats`, `quota`, `accounts` and `view`. On `view` it starts with tool calls expanded. Both can sit before the command or after it. -`ccli -h` lists a command's flags. Session ids can be shortened to -any unique prefix, or written `account:id` to pick one account's copy. +`ccli -h` lists a command's flags. + +### Sessions, copies and accounts + +| Thing | What it is | How you name it | +| --- | --- | --- | +| account | a folder a tool keeps its sessions in: `~/.claude` is `default`, `~/.claude-work` is `work`, and Codex, pi and Cursor homes | its name, after `-a` | +| session | one conversation, known by its id | the id, or any unique prefix of at least 4 characters | +| copy | the same session, with the same id, in another account; `copy`, `move` and `resume -a` make one. Each copy is its own transcript. Copies share their beginning, and each counts only the work done in it | `:`, or the id with `-a ` | +| fork | a new session with a new id, made from a session, whole or through one turn | its own id | +| turn | a prompt and the work it led to, numbered in `ccli log` | `ccli log `, `ccli fork --turn ` | +| agent | a subagent's transcript; it belongs to the session that started it | `ccli log --agent ` | + +When a session is in more than one account and you name no account, ccli +uses the copy used last and says so on stderr: + +``` +ccli: 1087b979 is also in default; this is the copy in talcum, the one used last (-a default picks another) +``` + +`-a` always names the account you are looking at or working in: + +| Commands | Work on | `-a` means | +| --- | --- | --- | +| `sessions`, `prompts`, `projects`, `stats`, `quota`, `clean` | many sessions | only these accounts (repeat it or separate with commas) | +| `show`, `log`, `export`, `view ` | one copy | read the copy in this account | +| `view` without an id | the session list | list only this account | +| `resume` | one copy | resume in this account, after bringing its copy up to date | +| `fork` | a copy, into a new session | put the new session in this account; `--from` or `:` picks the copy to fork | +| `copy`, `move` | a copy, into another account | not used: `--from` and `--to` name both ends | +| `diff` | two accounts | not used: name the two accounts | +| `hide`, `unhide`, `tag` | every copy of the id | not used: the mark belongs to the session id | + +In a copy's `ccli log`, the turns that came in with the copy show `copied` +in the COST column, and a line under the table says where they came from +and where their usage counts. ccli knows this for the copies it made; a +transcript copied by hand has no such record, and its copied turns simply +show no cost. ## How it counts diff --git a/docs/tutorial.md b/docs/tutorial.md index 10603b4..633641d 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -392,6 +392,30 @@ To bring work's sessions into default: ccli copy --from work --to default (prin "work is ahead" means default's transcript is an exact prefix of work's: the session went on in work after default last saw it. +### Which copy you are looking at + +`746cbfa4` is in both accounts under the same id. `show`, `log`, `export` +and `view` read one copy: the one you name with `-a` (or as +`default:746cbfa4`), or else the one used last, and they say which on +stderr: + +``` +$ ccli log 746cbfa4 +ccli: 746cbfa4 is also in work; this is the copy in default, the one used last (-a work picks another) +Why does the nightly export take forty minutes now? +746cbfa4 · default · 25 prompts typed · $0 +``` + +In a copy's turn list, the turns that came in with a copy show `copied` in +the COST column, and a line under the table explains them: + +``` +turns 18 to 25 came with a copy from work; their usage counts in work (ccli log 746cbfa4 -a work) +``` + +The README's "Sessions, copies and accounts" table lists what every command +works on and what `-a` means for it. + ### Continue a session in the other account, and come back Say `746cbfa4` went on in work, and now you want to continue it in default. diff --git a/internal/cli/export.go b/internal/cli/export.go index ed4c9cf..bcaa084 100644 --- a/internal/cli/export.go +++ b/internal/cli/export.go @@ -27,22 +27,27 @@ func cmdExport(ctx context.Context, a *app, args []string) error { thinking := fs.Bool("thinking", false, "include the model's thinking") withContext := fs.Bool("context", false, "include text the tool added (skills, reminders, peer messages)") all := fs.Bool("all", false, "include turns started by tasks, other sessions and the system") + in := copyFlag(fs) pos, err := parse(fs, args) if err != nil { return err } if len(pos) < 1 || len(pos) > 2 { - return errors.New("usage: ccli export [turn | from-to | last] [--out file] [--format md|jsonl]") + return errors.New("usage: ccli export [-a account] [turn | from-to | last] [--out file] [--format md|jsonl]") } - svc, err := a.prepare(ctx, c, nil) + if pos[0], err = withAccount(pos[0], *in); err != nil { + return err + } + svc, err := a.prepare(ctx, c, accountsOf(*in)) if err != nil { return err } defer a.close() - acct, _, err := svc.Resolve(ctx, pos[0]) + acct, id, err := svc.Resolve(ctx, pos[0]) if err != nil { return err } + a.noteCopy(ctx, svc, pos[0], acct, id) a.refreshCursorFor(ctx, []string{acct}, nil) ref := pos[0] list, err := svc.Turns(ctx, ref) diff --git a/internal/cli/log.go b/internal/cli/log.go index 0a9df13..eae6a46 100644 --- a/internal/cli/log.go +++ b/internal/cli/log.go @@ -41,12 +41,16 @@ func cmdLog(ctx context.Context, a *app, args []string) error { agent := fs.String("agent", "", "print a subagent's transcript (agent id or prefix)") run := fs.String("run", "", "list a workflow run's agents (run id or prefix)") noPager := fs.Bool("no-pager", false, "do not page long output") + in := copyFlag(fs) pos, err := parse(fs, args) if err != nil { return err } if len(pos) < 1 || len(pos) > 2 { - return errors.New("usage: ccli log [turn | from-to | last] [--detail full|reply] [--tools] [--thinking] [--agent id] [--run id]") + return errors.New("usage: ccli log [-a account] [turn | from-to | last] [--detail full|reply] [--tools] [--thinking] [--agent id] [--run id]") + } + if pos[0], err = withAccount(pos[0], *in); err != nil { + return err } switch strings.ToLower(strings.TrimSpace(*detail)) { case "", "full", "reply": @@ -58,12 +62,13 @@ func cmdLog(ctx context.Context, a *app, args []string) error { if a.verbose && !reply { opt.Tools = true } - svc, err := a.prepare(ctx, c, nil) + svc, err := a.prepare(ctx, c, accountsOf(*in)) if err != nil { return err } defer a.close() - if acct, _, err := svc.Resolve(ctx, pos[0]); err == nil { + if acct, id, err := svc.Resolve(ctx, pos[0]); err == nil { + a.noteCopy(ctx, svc, pos[0], acct, id) a.refreshCursorFor(ctx, []string{acct}, nil) } ref := pos[0] diff --git a/internal/cli/transfer_test.go b/internal/cli/transfer_test.go index d3013db..ebe28f7 100644 --- a/internal/cli/transfer_test.go +++ b/internal/cli/transfer_test.go @@ -139,3 +139,31 @@ func TestResumeRejectsBothSyncFlags(t *testing.T) { t.Fatalf("exit %d\n%s", code, errw.String()) } } + +// With the session in two accounts, the read commands say which copy they +// show, and -a (or account:id) picks the other; log marks the turns that +// came in with the copy. +func TestReadCommandsPickACopy(t *testing.T) { + twoAccounts(t) + runCLI(t, "copy", roundTrip, "--to", "work", "--apply") + for _, cmd := range [][]string{{"show", roundTrip}, {"log", roundTrip}, {"export", roundTrip}} { + var out, errw bytes.Buffer + if code := Main(cmd, &out, &errw); code != 0 || !strings.Contains(errw.String(), "is also in") { + t.Fatalf("%v: exit %d, stderr %q", cmd, code, errw.String()) + } + } + work := string(runCLI(t, "log", roundTrip, "-a", "work")) + if !strings.Contains(work, "· work ·") || !strings.Contains(work, "turns 1 to 2 came with a copy from default") || !strings.Contains(work, "copied") { + t.Fatalf("log -a work:\n%s", work) + } + if def := string(runCLI(t, "log", "default:"+roundTrip)); !strings.Contains(def, "· default ·") || strings.Contains(def, "came with a copy") { + t.Fatalf("log default:id:\n%s", def) + } + var out, errw bytes.Buffer + if code := Main([]string{"log", "default:" + roundTrip, "-a", "work"}, &out, &errw); code == 0 || !strings.Contains(errw.String(), "different accounts") { + t.Fatalf("conflicting accounts: exit %d\n%s", code, errw.String()) + } + if code := Main([]string{"show", roundTrip, "-a", "nosuch"}, &out, &errw); code == 0 { + t.Fatal("an unknown account was accepted") + } +} diff --git a/internal/cli/view.go b/internal/cli/view.go index 7d362fc..6cbda0b 100644 --- a/internal/cli/view.go +++ b/internal/cli/view.go @@ -24,12 +24,20 @@ func cmdView(ctx context.Context, a *app, args []string) error { dirFlag(fs, &scope) toolFlag(fs, &scope) tools := fs.Bool("tools", false, "expand tool calls inside each turn") + in := fs.String("a", "", "with a session: its copy in this account (same as :); without one: list only this account") pos, err := parse(fs, args) if err != nil { return err } if len(pos) > 1 { - return errors.New("usage: ccli view [session]") + return errors.New("usage: ccli view [-a account] [session]") + } + if len(pos) == 1 { + if pos[0], err = withAccount(pos[0], *in); err != nil { + return err + } + } else if *in != "" { + scope.Accounts = []string{*in} } if len(pos) == 1 && (len(scope.Dirs) > 0 || len(scope.Tools) > 0) { return errors.New("-dir and -tool narrow the session list; leave them out when opening one session") @@ -37,7 +45,7 @@ func cmdView(ctx context.Context, a *app, args []string) error { if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { return errors.New("ccli view needs a terminal; ccli log prints the same turns") } - svc, err := a.prepare(ctx, c, nil) + svc, err := a.prepare(ctx, c, accountsOf(*in)) if err != nil { return err } @@ -48,6 +56,7 @@ func cmdView(ctx context.Context, a *app, args []string) error { if err != nil { return err } + a.noteCopy(ctx, svc, pos[0], acct, id) ref = acct + ":" + id a.refreshCursorFor(ctx, []string{acct}, nil) } else { diff --git a/internal/cli/views.go b/internal/cli/views.go index 54f537b..c4e5c47 100644 --- a/internal/cli/views.go +++ b/internal/cli/views.go @@ -290,9 +290,8 @@ func cmdSessions(ctx context.Context, a *app, args []string) error { func cmdShow(ctx context.Context, a *app, args []string) error { var c common - var accts accountsFlag fs := newFlags("show", a, &c) - fs.Var(&accts, "a", "account, when the session exists in more than one") + in := copyFlag(fs) all := fs.Bool("all", false, "list every subagent") pos, err := parse(fs, args) if err != nil { @@ -301,22 +300,20 @@ func cmdShow(ctx context.Context, a *app, args []string) error { if len(pos) != 1 { return errors.New("usage: ccli show [-a account] ") } - if len(accts) > 1 { - return errors.New("show takes one account") - } - ref := pos[0] - if len(accts) == 1 && !strings.Contains(ref, ":") { - ref = accts[0] + ":" + ref + ref, err := withAccount(pos[0], *in) + if err != nil { + return err } - svc, err := a.prepare(ctx, c, accts) + svc, err := a.prepare(ctx, c, accountsOf(*in)) if err != nil { return err } defer a.close() - acct, _, err := svc.Resolve(ctx, ref) + acct, id, err := svc.Resolve(ctx, ref) if err != nil { return err } + a.noteCopy(ctx, svc, ref, acct, id) a.refreshCursorFor(ctx, []string{acct}, nil) d, err := svc.Session(ctx, ref) if err != nil { @@ -469,6 +466,63 @@ func withoutVar(env []string, name string) []string { return out } +// copyFlag adds -a to a command that reads one session: its copy in that +// account, when the session is in more than one. It means the same as +// writing the reference as :. +func copyFlag(fs *flag.FlagSet) *string { + return fs.String("a", "", "the session's copy in this account, when it is in more than one (same as :)") +} + +// withAccount writes an -a account into a session reference. It refuses a +// reference that already names another account. +func withAccount(ref, acct string) (string, error) { + if acct == "" { + return ref, nil + } + if i := strings.IndexByte(ref, ':'); i > 0 { + if ref[:i] != acct { + return "", fmt.Errorf("-a %s and %s name different accounts; give one", acct, ref) + } + return ref, nil + } + return acct + ":" + ref, nil +} + +// accountsOf is the account list prepare checks: the -a account, if any. +func accountsOf(acct string) []string { + if acct == "" { + return nil + } + return []string{acct} +} + +// noteCopy says on stderr which copy a reference that names no account +// resolved to, when the session is in more than one account. +func (a *app) noteCopy(ctx context.Context, svc *query.DB, ref, acct, id string) { + if strings.Contains(ref, ":") { + return + } + all, err := svc.CopiesOf(ctx, id) + if err != nil { + return + } + var others []string + for _, o := range all { + if o != acct { + others = append(others, o) + } + } + if len(others) == 0 { + return + } + pick := others[0] + if len(others) > 1 { + pick = "" + } + fmt.Fprintf(a.errw, "ccli: %s is also in %s; this is the copy in %s, the one used last (-a %s picks another)\n", + query.ShortID(id), strings.Join(others, ", "), acct, pick) +} + // flagSet reports whether the command line gave the flag. func flagSet(fs *flag.FlagSet, name string) bool { set := false diff --git a/internal/query/detail.go b/internal/query/detail.go index f011295..6bcc3d1 100644 --- a/internal/query/detail.go +++ b/internal/query/detail.go @@ -116,6 +116,24 @@ type Resume struct { // ErrNotFound is returned when no session matches a reference. var ErrNotFound = errors.New("no session matches") +// CopiesOf lists the accounts that hold a session, by name. +func (d *DB) CopiesOf(ctx context.Context, id string) ([]string, error) { + rows, err := d.sql().QueryContext(ctx, `SELECT DISTINCT account FROM files WHERE session_id = ? AND kind = 'main' ORDER BY account`, id) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var a string + if err := rows.Scan(&a); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + // Resolve finds the session for a reference: a full id or a unique prefix, // optionally "account:" in front. func (d *DB) Resolve(ctx context.Context, ref string) (account, id string, err error) { diff --git a/internal/query/turns.go b/internal/query/turns.go index 237c041..4aab160 100644 --- a/internal/query/turns.go +++ b/internal/query/turns.go @@ -38,6 +38,10 @@ type TurnRow struct { AgentCost float64 `json:"agent_cost"` // subagents and workflow runs it started Offset int64 `json:"-"` Next int64 `json:"-"` // where the next turn starts; 0 = end of file + // CopiedFrom names the account a turn came in from with a copy, or for + // a fork the session it was forked from as account:id. Its usage + // counts there. + CopiedFrom string `json:"copied_from,omitempty"` } // Total is the turn's cost with what its agents spent. @@ -324,10 +328,39 @@ func (d *DB) Turns(ctx context.Context, ref string) (*TurnList, error) { if err := d.sql().QueryRowContext(ctx, `SELECT f.offset FROM files f WHERE f.id = ?`, g.main.ID).Scan(&l.Read); err != nil { return nil, err } + if err := d.markCopied(ctx, l); err != nil { + return nil, err + } } return l, nil } +// markCopied sets CopiedFrom on the turns whose prompt lies in a byte range +// ccli copied into the transcript. +func (d *DB) markCopied(ctx context.Context, l *TurnList) error { + rows, err := d.sql().QueryContext(ctx, `SELECT mode, session_id, from_account, from_offset, to_offset FROM transfers WHERE path = ?`, l.Path) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var mode, sid, from string + var lo, hi int64 + if err := rows.Scan(&mode, &sid, &from, &lo, &hi); err != nil { + return err + } + if mode == "fork" { + from += ":" + sid + } + for i := range l.Turns { + if t := &l.Turns[i]; t.Offset >= lo && t.Offset < hi { + t.CopiedFrom = from + } + } + } + return rows.Err() +} + // ErrNoTranscript is returned when a turn's transcript is gone from disk. var ErrNoTranscript = errors.New("the transcript is gone from disk (ccli kept only the totals and prompts)") diff --git a/internal/render/log.go b/internal/render/log.go index aaceb33..654d68f 100644 --- a/internal/render/log.go +++ b/internal/render/log.go @@ -88,6 +88,8 @@ func (t Term) Turns(l *query.TurnList, all bool) { } else { cost = t.money(cost) } + } else if tr.CopiedFrom != "" { + cost = t.dim("copied") // counted where it came from; see the note below the table } agents := "" if tr.Agents > 0 { @@ -127,6 +129,9 @@ func (t Term) Turns(l *query.TurnList, all bool) { if hidden > 0 { t.println(t.dim(fmt.Sprintf("%d turns started by tasks, other sessions or the system are hidden (--all shows them)", hidden))) } + for _, n := range copiedNotes(l) { + t.println(t.dim(n)) + } t.println(t.dim(fmt.Sprintf("ccli log %s prints a turn in full · ccli view %s browses them", id, id))) if s.Tool == account.Claude { t.println(t.dim(fmt.Sprintf("ccli fork %s --turn -a continues the conversation from a turn as a new session", id))) @@ -604,3 +609,36 @@ func shortPaths(s string) string { } return s } + +// copiedNotes explains the turns that came in with a copy or a fork, one +// line for each run of turns from the same place. +func copiedNotes(l *query.TurnList) []string { + var turns []query.TurnRow + for _, tr := range l.Turns { + if tr.Seq > 0 { + turns = append(turns, tr) + } + } + var out []string + for i := 0; i < len(turns); { + from, j := turns[i].CopiedFrom, i + for j+1 < len(turns) && turns[j+1].CopiedFrom == from { + j++ + } + if from != "" { + span := fmt.Sprintf("turns %d to %d", turns[i].Seq, turns[j].Seq) + if i == j { + span = fmt.Sprintf("turn %d", turns[i].Seq) + } + if acct, sid, fork := strings.Cut(from, ":"); fork { + out = append(out, fmt.Sprintf("%s came from %s in %s when this session was forked from it; their usage counts there (ccli log %s -a %s)", + span, query.ShortID(sid), acct, query.ShortID(sid), acct)) + } else { + out = append(out, fmt.Sprintf("%s came with a copy from %s; their usage counts in %s (ccli log %s -a %s)", + span, acct, acct, query.ShortID(l.Session.ID), acct)) + } + } + i = j + 1 + } + return out +} diff --git a/internal/render/log_test.go b/internal/render/log_test.go index 75e6b0e..0af5f8d 100644 --- a/internal/render/log_test.go +++ b/internal/render/log_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/lr00rl/ccli/internal/conv" + "github.com/lr00rl/ccli/internal/query" ) func TestConversationReplyDropsSteps(t *testing.T) { @@ -38,3 +39,23 @@ func TestConversationDropsEscapes(t *testing.T) { t.Fatalf("escapes reached the output: %q", got) } } + +// A fork's copied turns point at the session they came from; a copy's at +// the same session in the account it came from. +func TestCopiedNotes(t *testing.T) { + l := &query.TurnList{Session: query.SessionRow{ID: "bbbbbbbb-0000-4000-8000-000000000001"}, Turns: []query.TurnRow{ + {Seq: 0, CopiedFrom: "work:aaaaaaaa-0000-4000-8000-000000000001"}, + {Seq: 1, CopiedFrom: "work:aaaaaaaa-0000-4000-8000-000000000001"}, + {Seq: 2, CopiedFrom: "work:aaaaaaaa-0000-4000-8000-000000000001"}, + {Seq: 3}, + {Seq: 4, CopiedFrom: "default"}, + }} + got := copiedNotes(l) + want := []string{ + "turns 1 to 2 came from aaaaaaaa in work when this session was forked from it; their usage counts there (ccli log aaaaaaaa -a work)", + "turn 4 came with a copy from default; their usage counts in default (ccli log bbbbbbbb -a default)", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("notes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} diff --git a/internal/render/views.go b/internal/render/views.go index d39da84..d8dfb0d 100644 --- a/internal/render/views.go +++ b/internal/render/views.go @@ -392,6 +392,7 @@ func (t Term) Session(d *query.SessionDetail, all bool) { } t.println(line + t.dim(fmt.Sprintf(" %s in %s", humanSize(m.Bytes), plural(m.Files, "file")))) } + t.println(t.dim(" what a copy brought in counts toward the account it came from; this copy counts only the work done in it")) } if len(d.Skills) > 0 || len(d.Tools) > 0 || len(d.Commands) > 0 { diff --git a/internal/tui/app.go b/internal/tui/app.go index 31497d4..cdb3fbb 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -77,7 +77,7 @@ func newScreen(title string, r role) *screen { type viewer struct { ctx context.Context src Source - scope query.Filter // Dirs and Tools narrow the session list + scope query.Filter // Accounts, Dirs and Tools narrow the session list loc *time.Location stack []*screen w, h int @@ -117,7 +117,7 @@ type Options struct { } // Run starts the browser at the sessions list, or at a session's turns when -// ref names one. scope's Dirs and Tools narrow the sessions list. +// ref names one. scope's Accounts, Dirs and Tools narrow the sessions list. func Run(ctx context.Context, src Source, ref string, scope query.Filter, opt Options) error { v := &viewer{ctx: ctx, src: src, loc: time.Local, rendered: map[string]string{}, scope: scope, opt: render.LogOptions{Tools: opt.Tools}} var first *screen diff --git a/internal/tui/screens.go b/internal/tui/screens.go index 88b37a6..9e06e3e 100644 --- a/internal/tui/screens.go +++ b/internal/tui/screens.go @@ -28,9 +28,12 @@ func (v *viewer) sessionsScreen() (*screen, error) { } // scopeText names what the session list is narrowed to, for its title: -// " in ~/code/app" and " (codex)", or nothing. +// " in talcum", " in ~/code/app" and " (codex)", or nothing. func (v *viewer) scopeText() string { s := "" + if len(v.scope.Accounts) > 0 { + s = " in " + strings.Join(v.scope.Accounts, ", ") + } if len(v.scope.Dirs) > 0 { s = " in " + render.ShortHome(v.scope.Dirs[0]) } @@ -46,7 +49,7 @@ func (v *viewer) scopeText() string { // sessionFilter selects the hidden sessions, or the others. func (v *viewer) sessionFilter(hidden bool) query.Filter { - f := query.Filter{Loc: v.loc, Sort: "last", Dirs: v.scope.Dirs, Tools: v.scope.Tools} + f := query.Filter{Loc: v.loc, Sort: "last", Dirs: v.scope.Dirs, Tools: v.scope.Tools, Accounts: v.scope.Accounts} if hidden { f.Show = query.HiddenOnly }