package statedir import ( "errors" "fmt" "io" "os" "path/filepath" "strings" ) // Conversations are kept out of the repository, in one directory per working // root under ~/.config/chats (or wherever XDG_CONFIG_HOME puts it). A chat is // yours rather than the project's: it should not be scattered across every // checkout you have ever opened, should not vanish with a `git clean -dfx`, // and should not sit inside a tree that might be copied, archived or shared. // The event log and the agent's scratch space stay in Dir, because those are // about the repository. const ( ChatsDir = "chats" // SessionsSubdir and HistoryFile are what the chats used to be called // inside Dir, before they moved out. They are named here only so the // move can find them. SessionsSubdir = "sessions" HistoryFile = "history" ) // Chats is the directory a working root's saved sessions and prompt history // live in: ~/.config/chats/, with the root's path flattened into one // directory name the way Claude Code names its projects, so the directory // can be found by eye. Two roots that flatten alike would share it, which is // why every session also records its root and is checked against it when it // is listed or resumed. func Chats(root string) (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("chats: no config directory: %w", err) } return filepath.Join(dir, ChatsDir, chatKey(root)), nil } func chatKey(root string) string { return strings.Map(func(r rune) rune { if r == '/' || r == '\\' || r == ':' { return '-' } return r }, filepath.Clean(root)) } // MigrateChats moves the sessions and the prompt history an older build left // in /.codey into dir, reporting how many files it moved. It is called // at startup, after Migrate and before anything opens a session. // // A file already present at the destination wins and the old copy is left // where it was: both existing means both places have been written to, and // overwriting either could only lose a conversation. Nothing to move is the // common case, and must not create dir. func MigrateChats(root, dir string) (int, error) { moved := 0 oldSessions := filepath.Join(root, Dir, SessionsSubdir) entries, err := os.ReadDir(oldSessions) if err != nil && !errors.Is(err, os.ErrNotExist) { return 0, fmt.Errorf("chats: %w", err) } var errs []error for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".json" { continue } ok, err := moveFile(filepath.Join(oldSessions, e.Name()), filepath.Join(dir, e.Name())) if err != nil { errs = append(errs, err) } if ok { moved++ } } // Removed only once empty, so anything left behind stays visible. if len(entries) > 0 { os.Remove(oldSessions) } oldHistory := filepath.Join(root, Dir, HistoryFile) if info, err := os.Lstat(oldHistory); err == nil && info.Mode().IsRegular() { ok, err := moveFile(oldHistory, filepath.Join(dir, HistoryFile)) if err != nil { errs = append(errs, err) } if ok { moved++ } } if err := errors.Join(errs...); err != nil { return moved, fmt.Errorf("chats: %w", err) } return moved, nil } // moveFile renames src to dst unless dst exists, falling back to copy and // remove when the two are on different filesystems — the config directory // and a checkout often are. func moveFile(src, dst string) (bool, error) { if _, err := os.Lstat(dst); err == nil { return false, nil } if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { return false, err } if err := os.Rename(src, dst); err == nil { return true, nil } in, err := os.Open(src) if err != nil { return false, err } defer in.Close() out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return false, err } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(dst) return false, err } if err := out.Close(); err != nil { os.Remove(dst) return false, err } return true, os.Remove(src) }