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.
This commit is contained in:
Rahul Bansal 2026-02-21 10:33:59 +05:30
parent 5582760cec
commit b6a71c62c4
3 changed files with 588 additions and 55 deletions

View file

@ -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 <id>")
os.Exit(1)
}
sessionsShowCmd(sessionsDir, args[1])
case "delete":
if len(args) < 2 {
fmt.Println("Usage: picoclaw sessions delete <id>")
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 <command>")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" list List all sessions")
fmt.Println(" show <id> Show session details")
fmt.Println(" delete <id> 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)
}
}

View file

@ -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)
}
}

View file

@ -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 <skill-name>")
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 <skill-name>")
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")
}