From b6a71c62c4d9033ebce3e6d0ef07419736931d39 Mon Sep 17 00:00:00 2001 From: Rahul Bansal Date: Sat, 21 Feb 2026 10:33:59 +0530 Subject: [PATCH] feat: add picoclaw sessions subcommand for session management Fixes #575: new sessions command with list, show, delete, and clear subcommands. Lists sessions with message count and last modified time. Show displays last messages. Includes 10 tests covering core logic. --- cmd/picoclaw/cmd_sessions.go | 362 ++++++++++++++++++++++++++++++ cmd/picoclaw/cmd_sessions_test.go | 222 ++++++++++++++++++ cmd/picoclaw/main.go | 59 +---- 3 files changed, 588 insertions(+), 55 deletions(-) create mode 100644 cmd/picoclaw/cmd_sessions.go create mode 100644 cmd/picoclaw/cmd_sessions_test.go diff --git a/cmd/picoclaw/cmd_sessions.go b/cmd/picoclaw/cmd_sessions.go new file mode 100644 index 000000000..7e2fe4f9d --- /dev/null +++ b/cmd/picoclaw/cmd_sessions.go @@ -0,0 +1,362 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// sessionData is a minimal struct for reading session JSON files. +// We only need the fields required for display — no dependency on pkg/session. +type sessionData struct { + Key string `json:"key"` + Messages json.RawMessage `json:"messages"` + Summary string `json:"summary,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// sessionMessage is a minimal struct for reading individual messages. +type sessionMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func sessionsCmd() { + args := os.Args[2:] + + if len(args) == 0 { + sessionsHelp() + return + } + + subcommand := args[0] + + if subcommand == "--help" || subcommand == "-h" { + sessionsHelp() + return + } + + sessionsDir := getSessionsDir() + + switch subcommand { + case "list": + sessionsListCmd(sessionsDir) + case "show": + if len(args) < 2 { + fmt.Println("Usage: picoclaw sessions show ") + os.Exit(1) + } + sessionsShowCmd(sessionsDir, args[1]) + case "delete": + if len(args) < 2 { + fmt.Println("Usage: picoclaw sessions delete ") + os.Exit(1) + } + sessionsDeleteCmd(sessionsDir, args[1]) + case "clear": + sessionsClearCmd(sessionsDir) + default: + fmt.Printf("Unknown sessions command: %s\n", subcommand) + sessionsHelp() + os.Exit(1) + } +} + +func sessionsHelp() { + fmt.Println("Usage: picoclaw sessions ") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" list List all sessions") + fmt.Println(" show Show session details") + fmt.Println(" delete Delete a session") + fmt.Println(" clear Delete all sessions") +} + +func getSessionsDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".picoclaw", "workspace", "sessions") +} + +type sessionEntry struct { + id string + messages int + modTime time.Time + size int64 + corrupt bool +} + +func sessionsListCmd(sessionsDir string) { + entries, err := listSessionEntries(sessionsDir) + if err != nil { + fmt.Println(err) + return + } + + if len(entries) == 0 { + fmt.Println("No sessions found") + return + } + + // Sort by modification time, most recent first + sort.Slice(entries, func(i, j int) bool { + return entries[i].modTime.After(entries[j].modTime) + }) + + fmt.Println("Sessions:") + fmt.Printf(" %-30s %8s %s\n", "ID", "Messages", "Last Modified") + for _, e := range entries { + msgStr := fmt.Sprintf("%d", e.messages) + if e.corrupt { + msgStr = "(corrupt)" + } + fmt.Printf(" %-30s %8s %s\n", e.id, msgStr, e.modTime.Format("2006-01-02 15:04")) + } + + fmt.Printf("\n%d session(s) found\n", len(entries)) +} + +func sessionsShowCmd(sessionsDir, id string) { + filePath := findSessionFile(sessionsDir, id) + if filePath == "" { + fmt.Printf("Session '%s' not found\n", id) + os.Exit(1) + } + + info, err := os.Stat(filePath) + if err != nil { + fmt.Printf("Error reading session: %v\n", err) + os.Exit(1) + } + + data, err := os.ReadFile(filePath) + if err != nil { + fmt.Printf("Error reading session: %v\n", err) + os.Exit(1) + } + + var sess sessionData + if err := json.Unmarshal(data, &sess); err != nil { + fmt.Printf("Session: %s\n", id) + fmt.Printf("Size: %s\n", formatSize(info.Size())) + fmt.Printf("Last Modified: %s\n", info.ModTime().Format("2006-01-02 15:04")) + fmt.Println("Status: corrupt (invalid JSON)") + return + } + + var msgs []sessionMessage + _ = json.Unmarshal(sess.Messages, &msgs) + + fmt.Printf("Session: %s\n", sess.Key) + fmt.Printf("Messages: %d\n", len(msgs)) + fmt.Printf("Last Modified: %s\n", info.ModTime().Format("2006-01-02 15:04")) + fmt.Printf("Size: %s\n", formatSize(info.Size())) + + if len(msgs) > 0 { + fmt.Println() + start := len(msgs) - 3 + if start < 0 { + start = 0 + } + fmt.Println("Last messages:") + for _, m := range msgs[start:] { + content := strings.TrimSpace(m.Content) + content = strings.ReplaceAll(content, "\n", " ") + if len(content) > 80 { + content = content[:77] + "..." + } + fmt.Printf(" [%s] %s\n", m.Role, content) + } + } +} + +func sessionsDeleteCmd(sessionsDir, id string) { + filePath := findSessionFile(sessionsDir, id) + if filePath == "" { + fmt.Printf("Session '%s' not found\n", id) + os.Exit(1) + } + + fmt.Printf("Delete session '%s'? (y/n): ", id) + if !confirmPrompt() { + fmt.Println("Cancelled") + return + } + + if err := os.Remove(filePath); err != nil { + fmt.Printf("Error deleting session: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Deleted session %s\n", id) +} + +func sessionsClearCmd(sessionsDir string) { + entries, err := listSessionEntries(sessionsDir) + if err != nil { + fmt.Println(err) + return + } + + if len(entries) == 0 { + fmt.Println("No sessions found") + return + } + + fmt.Printf("Delete all %d sessions? (y/n): ", len(entries)) + if !confirmPrompt() { + fmt.Println("Cancelled") + return + } + + deleted := 0 + for _, e := range entries { + filePath := findSessionFile(sessionsDir, e.id) + if filePath == "" { + continue + } + if err := os.Remove(filePath); err != nil { + fmt.Printf("Error deleting session '%s': %v\n", e.id, err) + continue + } + deleted++ + } + + fmt.Printf("Cleared %d session(s).\n", deleted) +} + +// listSessionEntries reads the sessions directory and returns parsed entries. +func listSessionEntries(sessionsDir string) ([]sessionEntry, error) { + if _, err := os.Stat(sessionsDir); os.IsNotExist(err) { + return nil, fmt.Errorf("No sessions found (sessions directory does not exist)") + } + + files, err := os.ReadDir(sessionsDir) + if err != nil { + return nil, fmt.Errorf("error reading sessions directory: %v", err) + } + + var entries []sessionEntry + for _, f := range files { + if f.IsDir() || filepath.Ext(f.Name()) != ".json" { + continue + } + + filePath := filepath.Join(sessionsDir, f.Name()) + info, err := os.Stat(filePath) + if err != nil { + continue + } + + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + + var sess sessionData + if err := json.Unmarshal(data, &sess); err != nil { + // Corrupt file — derive ID from filename + id := strings.TrimSuffix(f.Name(), ".json") + entries = append(entries, sessionEntry{ + id: id, + modTime: info.ModTime(), + size: info.Size(), + corrupt: true, + }) + continue + } + + // Use the key from the JSON if present, otherwise derive from filename + id := sess.Key + if id == "" { + id = strings.TrimSuffix(f.Name(), ".json") + } + + var msgs []sessionMessage + _ = json.Unmarshal(sess.Messages, &msgs) + + entries = append(entries, sessionEntry{ + id: id, + messages: len(msgs), + modTime: info.ModTime(), + size: info.Size(), + }) + } + + return entries, nil +} + +// findSessionFile locates the session file for a given ID. +// It first tries matching by the key inside the JSON, then falls back +// to matching by filename (with .json extension). +func findSessionFile(sessionsDir string, id string) string { + files, err := os.ReadDir(sessionsDir) + if err != nil { + return "" + } + + for _, f := range files { + if f.IsDir() || filepath.Ext(f.Name()) != ".json" { + continue + } + + filePath := filepath.Join(sessionsDir, f.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + + var sess sessionData + if err := json.Unmarshal(data, &sess); err != nil { + // Corrupt file — match by filename + name := strings.TrimSuffix(f.Name(), ".json") + if name == id { + return filePath + } + continue + } + + if sess.Key == id { + return filePath + } + } + + // Fallback: try direct filename match (id + .json or sanitized id + .json) + direct := filepath.Join(sessionsDir, id+".json") + if _, err := os.Stat(direct); err == nil { + return direct + } + sanitized := filepath.Join(sessionsDir, strings.ReplaceAll(id, ":", "_")+".json") + if _, err := os.Stat(sanitized); err == nil { + return sanitized + } + + return "" +} + +func confirmPrompt() bool { + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes" +} + +func formatSize(bytes int64) string { + const ( + kb = 1024 + mb = kb * 1024 + ) + switch { + case bytes >= mb: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", bytes) + } +} diff --git a/cmd/picoclaw/cmd_sessions_test.go b/cmd/picoclaw/cmd_sessions_test.go new file mode 100644 index 000000000..04770c341 --- /dev/null +++ b/cmd/picoclaw/cmd_sessions_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestListSessionEntries_NoDirectory(t *testing.T) { + entries, err := listSessionEntries("/nonexistent/path/sessions") + if err == nil { + t.Fatal("expected error for nonexistent directory") + } + if entries != nil { + t.Fatalf("expected nil entries, got %d", len(entries)) + } +} + +func TestListSessionEntries_EmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + entries, err := listSessionEntries(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(entries)) + } +} + +func TestListSessionEntries_ValidSessions(t *testing.T) { + tmpDir := t.TempDir() + + // Create two valid session files + writeTestSession(t, tmpDir, "telegram_123456.json", sessionData{ + Key: "telegram:123456", + Messages: json.RawMessage(`[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"}]`), + Created: time.Now().Add(-1 * time.Hour), + Updated: time.Now(), + }) + + writeTestSession(t, tmpDir, "discord_789.json", sessionData{ + Key: "discord:789", + Messages: json.RawMessage(`[{"role":"user","content":"test"}]`), + Created: time.Now().Add(-2 * time.Hour), + Updated: time.Now().Add(-30 * time.Minute), + }) + + entries, err := listSessionEntries(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + // Find the telegram entry + var telegramEntry *sessionEntry + for i := range entries { + if entries[i].id == "telegram:123456" { + telegramEntry = &entries[i] + break + } + } + if telegramEntry == nil { + t.Fatal("telegram:123456 entry not found") + } + if telegramEntry.messages != 2 { + t.Errorf("expected 2 messages, got %d", telegramEntry.messages) + } + if telegramEntry.corrupt { + t.Error("expected non-corrupt session") + } +} + +func TestListSessionEntries_CorruptSession(t *testing.T) { + tmpDir := t.TempDir() + + // Write a corrupt JSON file + err := os.WriteFile(filepath.Join(tmpDir, "bad_session.json"), []byte("{invalid json"), 0644) + if err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + entries, err := listSessionEntries(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if !entries[0].corrupt { + t.Error("expected corrupt flag to be set") + } + if entries[0].id != "bad_session" { + t.Errorf("expected id 'bad_session', got %q", entries[0].id) + } +} + +func TestListSessionEntries_SkipsNonJSON(t *testing.T) { + tmpDir := t.TempDir() + + // Write a non-JSON file + err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("not a session"), 0644) + if err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + // Write a subdirectory + err = os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755) + if err != nil { + t.Fatalf("failed to create test dir: %v", err) + } + + entries, err := listSessionEntries(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(entries)) + } +} + +func TestFindSessionFile_ByKey(t *testing.T) { + tmpDir := t.TempDir() + + writeTestSession(t, tmpDir, "telegram_123456.json", sessionData{ + Key: "telegram:123456", + Messages: json.RawMessage(`[]`), + }) + + path := findSessionFile(tmpDir, "telegram:123456") + if path == "" { + t.Fatal("expected to find session file by key") + } + expected := filepath.Join(tmpDir, "telegram_123456.json") + if path != expected { + t.Errorf("expected path %q, got %q", expected, path) + } +} + +func TestFindSessionFile_BySanitizedName(t *testing.T) { + tmpDir := t.TempDir() + + // Write a session file where the key doesn't match the ID we're looking for, + // but the sanitized filename does + writeTestSession(t, tmpDir, "cli_default.json", sessionData{ + Key: "cli:default", + Messages: json.RawMessage(`[]`), + }) + + // Should find by key + path := findSessionFile(tmpDir, "cli:default") + if path == "" { + t.Fatal("expected to find session file") + } +} + +func TestFindSessionFile_NotFound(t *testing.T) { + tmpDir := t.TempDir() + + path := findSessionFile(tmpDir, "nonexistent") + if path != "" { + t.Fatalf("expected empty path for nonexistent session, got %q", path) + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1.0 KB"}, + {2560, "2.5 KB"}, + {1048576, "1.0 MB"}, + {1572864, "1.5 MB"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := formatSize(tt.bytes) + if got != tt.want { + t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want) + } + }) + } +} + +func TestListSessionEntries_FallbackToFilename(t *testing.T) { + tmpDir := t.TempDir() + + // Session with empty key — should fall back to filename + writeTestSession(t, tmpDir, "orphan.json", sessionData{ + Key: "", + Messages: json.RawMessage(`[{"role":"user","content":"hi"}]`), + }) + + entries, err := listSessionEntries(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].id != "orphan" { + t.Errorf("expected id 'orphan', got %q", entries[0].id) + } +} + +func writeTestSession(t *testing.T, dir, filename string, sess sessionData) { + t.Helper() + data, err := json.Marshal(sess) + if err != nil { + t.Fatalf("failed to marshal session: %v", err) + } + err = os.WriteFile(filepath.Join(dir, filename), data, 0644) + if err != nil { + t.Fatalf("failed to write session file: %v", err) + } +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 566501042..9d6b0490e 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -14,7 +14,6 @@ import ( "runtime" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/skills" ) var ( @@ -117,61 +116,10 @@ func main() { cronCmd() case "doctor": doctorCmd() + case "sessions": + sessionsCmd() case "skills": - if len(os.Args) < 3 { - skillsHelp() - return - } - - subcommand := os.Args[2] - - if subcommand == "--help" || subcommand == "-h" { - skillsHelp() - return - } - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - fmt.Println("Run 'picoclaw doctor' to check for common problems.") - os.Exit(1) - } - - workspace := cfg.WorkspacePath() - installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 - globalDir := filepath.Dir(getConfigPath()) - globalSkillsDir := filepath.Join(globalDir, "skills") - builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") - skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) - - switch subcommand { - case "list": - skillsListCmd(skillsLoader) - case "install": - skillsInstallCmd(installer, cfg) - case "remove", "uninstall": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills remove ") - return - } - skillsRemoveCmd(installer, os.Args[3]) - case "install-builtin": - skillsInstallBuiltinCmd(workspace) - case "list-builtin": - skillsListBuiltinCmd() - case "search": - skillsSearchCmd(installer) - case "show": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills show ") - return - } - skillsShowCmd(skillsLoader, os.Args[3]) - default: - fmt.Printf("Unknown skills command: %s\n", subcommand) - skillsHelp() - } + skillsCmd() case "version", "--version", "-v": printVersion() case "--help", "-h": @@ -196,6 +144,7 @@ func printHelp() { fmt.Println(" cron Manage scheduled tasks") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") fmt.Println(" skills Manage skills (install, list, remove)") + fmt.Println(" sessions Manage sessions (list, show, delete, clear)") fmt.Println(" doctor Diagnose and fix common problems (--fix to auto-repair)") fmt.Println(" version Show version information") }