package api import ( "bufio" "encoding/json" "errors" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" ) // registerSessionRoutes binds session list and detail endpoints to the ServeMux. func (h *Handler) registerSessionRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/sessions", h.handleListSessions) mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession) mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession) } // sessionFile mirrors the on-disk session JSON structure from pkg/session. type sessionFile struct { Key string `json:"key"` Messages []providers.Message `json:"messages"` Summary string `json:"summary,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } // sessionListItem is a lightweight summary returned by GET /api/sessions. type sessionListItem struct { ID string `json:"id"` Title string `json:"title"` Preview string `json:"preview"` MessageCount int `json:"message_count"` Created string `json:"created"` Updated string `json:"updated"` } // legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL // sessions before structured scope metadata existed. const ( legacyPicoSessionPrefix = "agent:main:pico:direct:pico:" maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB maxSessionTitleRunes = 60 ) // extractLegacyPicoSessionID extracts the session UUID from an old Pico key. // Returns the UUID and true if the key matches the Pico session pattern. func extractLegacyPicoSessionID(key string) (string, bool) { if strings.HasPrefix(key, legacyPicoSessionPrefix) { return strings.TrimPrefix(key, legacyPicoSessionPrefix), true } return "", false } func sanitizeSessionKey(key string) string { key = strings.ReplaceAll(key, ":", "_") key = strings.ReplaceAll(key, "/", "_") key = strings.ReplaceAll(key, "\\", "_") return key } func (h *Handler) readLegacySession(path string) (sessionFile, error) { data, err := os.ReadFile(path) if err != nil { return sessionFile{}, err } var sess sessionFile if err := json.Unmarshal(data, &sess); err != nil { return sessionFile{}, err } return sess, nil } func (h *Handler) readSessionMeta(path, sessionKey string) (memory.SessionMeta, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return memory.SessionMeta{Key: sessionKey}, nil } if err != nil { return memory.SessionMeta{}, err } var meta memory.SessionMeta if err := json.Unmarshal(data, &meta); err != nil { return memory.SessionMeta{}, err } if meta.Key == "" { meta.Key = sessionKey } return meta, nil } func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() msgs := make([]providers.Message, 0) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize) seen := 0 for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } seen++ if seen <= skip { continue } var msg providers.Message if err := json.Unmarshal(line, &msg); err != nil { continue } msgs = append(msgs, msg) } if err := scanner.Err(); err != nil { return nil, err } return msgs, nil } func (h *Handler) readJSONLSession(dir, sessionKey string) (sessionFile, error) { base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) jsonlPath := base + ".jsonl" metaPath := base + ".meta.json" meta, err := h.readSessionMeta(metaPath, sessionKey) if err != nil { return sessionFile{}, err } messages, err := h.readSessionMessages(jsonlPath, meta.Skip) if err != nil { return sessionFile{}, err } updated := meta.UpdatedAt created := meta.CreatedAt if created.IsZero() || updated.IsZero() { if info, statErr := os.Stat(jsonlPath); statErr == nil { if created.IsZero() { created = info.ModTime() } if updated.IsZero() { updated = info.ModTime() } } } return sessionFile{ Key: meta.Key, Messages: messages, Summary: meta.Summary, Created: created, Updated: updated, }, nil } type picoJSONLSessionRef struct { ID string Key string } type picoLegacySessionRef struct { ID string Path string } func extractPicoSessionIDFromScope(scope session.SessionScope) (string, bool) { if !strings.EqualFold(strings.TrimSpace(scope.Channel), "pico") { return "", false } candidates := []string{ strings.TrimSpace(scope.Values["sender"]), strings.TrimSpace(scope.Values["chat"]), } for _, candidate := range candidates { if candidate == "" { continue } if idx := strings.Index(candidate, "pico:"); idx >= 0 { sessionID := strings.TrimSpace(candidate[idx+len("pico:"):]) if sessionID != "" { return sessionID, true } } } return "", false } func sessionRefFromMeta(meta memory.SessionMeta) (picoJSONLSessionRef, bool) { if len(meta.Scope) == 0 { if sessionID, ok := extractLegacyPicoSessionID(meta.Key); ok { return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true } for _, alias := range meta.Aliases { if sessionID, ok := extractLegacyPicoSessionID(alias); ok { return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true } } return picoJSONLSessionRef{}, false } var scope session.SessionScope if err := json.Unmarshal(meta.Scope, &scope); err != nil { return picoJSONLSessionRef{}, false } sessionID, ok := extractPicoSessionIDFromScope(scope) if !ok { if legacySessionID, ok := extractLegacyPicoSessionID(meta.Key); ok { return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true } for _, alias := range meta.Aliases { if legacySessionID, ok := extractLegacyPicoSessionID(alias); ok { return picoJSONLSessionRef{ID: legacySessionID, Key: meta.Key}, true } } return picoJSONLSessionRef{}, false } return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true } func (h *Handler) findPicoJSONLSessions(dir string) ([]picoJSONLSessionRef, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } refs := make([]picoJSONLSessionRef, 0) seen := make(map[string]struct{}) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { continue } metaPath := filepath.Join(dir, entry.Name()) meta, err := h.readSessionMeta(metaPath, "") if err != nil { continue } ref, ok := sessionRefFromMeta(meta) if !ok || ref.Key == "" || ref.ID == "" { continue } if _, exists := seen[ref.ID]; exists { continue } seen[ref.ID] = struct{}{} refs = append(refs, ref) } return refs, nil } func (h *Handler) findPicoJSONLSession(dir, sessionID string) (picoJSONLSessionRef, error) { refs, err := h.findPicoJSONLSessions(dir) if err != nil { return picoJSONLSessionRef{}, err } for _, ref := range refs { if ref.ID == sessionID { return ref, nil } } return picoJSONLSessionRef{}, os.ErrNotExist } func (h *Handler) findLegacyPicoSessions(dir string) ([]picoLegacySessionRef, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } refs := make([]picoLegacySessionRef, 0) seen := make(map[string]struct{}) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } path := filepath.Join(dir, entry.Name()) sess, err := h.readLegacySession(path) if err != nil || isEmptySession(sess) { continue } sessionID, ok := extractLegacyPicoSessionID(sess.Key) if !ok || sessionID == "" { continue } if _, exists := seen[sessionID]; exists { continue } seen[sessionID] = struct{}{} refs = append(refs, picoLegacySessionRef{ID: sessionID, Path: path}) } return refs, nil } func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessionRef, error) { refs, err := h.findLegacyPicoSessions(dir) if err != nil { return picoLegacySessionRef{}, err } for _, ref := range refs { if ref.ID == sessionID { return ref, nil } } return picoLegacySessionRef{}, os.ErrNotExist } func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { preview := "" for _, msg := range sess.Messages { if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { preview = msg.Content break } } title := strings.TrimSpace(sess.Summary) if title == "" { title = preview } title = truncateRunes(title, maxSessionTitleRunes) preview = truncateRunes(preview, maxSessionTitleRunes) if preview == "" { preview = "(empty)" } if title == "" { title = preview } validMessageCount := 0 for _, msg := range sess.Messages { if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { validMessageCount++ } } return sessionListItem{ ID: sessionID, Title: title, Preview: preview, MessageCount: validMessageCount, Created: sess.Created.Format(time.RFC3339), Updated: sess.Updated.Format(time.RFC3339), } } func isEmptySession(sess sessionFile) bool { return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == "" } func truncateRunes(s string, maxLen int) string { if maxLen <= 0 { return "" } runes := []rune(strings.TrimSpace(s)) if len(runes) <= maxLen { return string(runes) } return string(runes[:maxLen]) + "..." } // sessionsDir resolves the path to the gateway's session storage directory. // It reads the workspace from config, falling back to ~/.picoclaw/workspace. func (h *Handler) sessionsDir() (string, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return "", err } workspace := cfg.Agents.Defaults.Workspace if workspace == "" { home, _ := os.UserHomeDir() workspace = filepath.Join(home, ".picoclaw", "workspace") } // Expand ~ prefix if len(workspace) > 0 && workspace[0] == '~' { home, _ := os.UserHomeDir() if len(workspace) > 1 && workspace[1] == '/' { workspace = home + workspace[1:] } else { workspace = home } } return filepath.Join(workspace, "sessions"), nil } // handleListSessions returns a list of Pico session summaries. // // GET /api/sessions func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { dir, err := h.sessionsDir() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } if _, err := os.ReadDir(dir); err != nil { // Directory doesn't exist yet = no sessions w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]sessionListItem{}) return } items := []sessionListItem{} seen := make(map[string]struct{}) if refs, findErr := h.findPicoJSONLSessions(dir); findErr == nil { for _, ref := range refs { sess, loadErr := h.readJSONLSession(dir, ref.Key) if loadErr != nil || isEmptySession(sess) { continue } seen[ref.ID] = struct{}{} items = append(items, buildSessionListItem(ref.ID, sess)) } } if legacyRefs, findErr := h.findLegacyPicoSessions(dir); findErr == nil { for _, ref := range legacyRefs { if _, exists := seen[ref.ID]; exists { continue } sess, loadErr := h.readLegacySession(ref.Path) if loadErr != nil || isEmptySession(sess) { continue } seen[ref.ID] = struct{}{} items = append(items, buildSessionListItem(ref.ID, sess)) } } // Sort by updated descending (most recent first) sort.Slice(items, func(i, j int) bool { return items[i].Updated > items[j].Updated }) // Pagination parameters offsetStr := r.URL.Query().Get("offset") limitStr := r.URL.Query().Get("limit") offset := 0 limit := 20 // Default limit if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 { offset = val } if val, err := strconv.Atoi(limitStr); err == nil && val > 0 { limit = val } totalItems := len(items) end := offset + limit if offset >= totalItems { items = []sessionListItem{} // Out of bounds, return empty } else { if end > totalItems { end = totalItems } items = items[offset:end] } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(items) } // handleGetSession returns the full message history for a specific session. // // GET /api/sessions/{id} func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("id") if sessionID == "" { http.Error(w, "missing session id", http.StatusBadRequest) return } dir, err := h.sessionsDir() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } ref, refErr := h.findPicoJSONLSession(dir, sessionID) var sess sessionFile err = refErr if refErr == nil { sess, err = h.readJSONLSession(dir, ref.Key) } if err == nil && isEmptySession(sess) { err = os.ErrNotExist } if err != nil { if errors.Is(err, os.ErrNotExist) { if legacyRef, legacyErr := h.findLegacyPicoSession(dir, sessionID); legacyErr == nil { sess, err = h.readLegacySession(legacyRef.Path) } if err == nil && isEmptySession(sess) { err = os.ErrNotExist } } if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "session not found", http.StatusNotFound) } else { http.Error(w, "failed to parse session", http.StatusInternalServerError) } return } } // Convert to a simpler format for the frontend type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } messages := make([]chatMessage, 0, len(sess.Messages)) for _, msg := range sess.Messages { // Only include user and assistant messages that have actual content if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { messages = append(messages, chatMessage{ Role: msg.Role, Content: msg.Content, }) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "id": sessionID, "messages": messages, "summary": sess.Summary, "created": sess.Created.Format(time.RFC3339), "updated": sess.Updated.Format(time.RFC3339), }) } // handleDeleteSession deletes a specific session. // // DELETE /api/sessions/{id} func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("id") if sessionID == "" { http.Error(w, "missing session id", http.StatusBadRequest) return } dir, err := h.sessionsDir() if err != nil { http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) return } removed := false if ref, err := h.findPicoJSONLSession(dir, sessionID); err == nil { base := filepath.Join(dir, sanitizeSessionKey(ref.Key)) for _, path := range []string{base + ".jsonl", base + ".meta.json"} { if err := os.Remove(path); err != nil { if os.IsNotExist(err) { continue } http.Error(w, "failed to delete session", http.StatusInternalServerError) return } removed = true } } if legacyRef, err := h.findLegacyPicoSession(dir, sessionID); err == nil { if err := os.Remove(legacyRef.Path); err != nil { if !os.IsNotExist(err) { http.Error(w, "failed to delete session", http.StatusInternalServerError) return } } else { removed = true } } if !removed { http.Error(w, "session not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) }