// Package sessiontool provides list_sessions and read_session tools that let // the AI discover and read past conversation sessions, enabling cross-session // AI context sharing. The tools reuse agent.ListSessionOrder, agent.LoadSession, // and agent.IsCleanupPending — the same infrastructure used by the history // tool and session picker — to avoid duplicating session-file logic. package sessiontool import ( "context" "encoding/json" "fmt" "path/filepath" "strings" "reasonix/internal/agent" "reasonix/internal/provider" "reasonix/internal/textutil" ) // ---- list_sessions tool ----------------------------------------------------- type listSessionsTool struct { sessionDir string } // NewListSessionsTool creates a tool that lists saved sessions. func NewListSessionsTool(sessionDir string) *listSessionsTool { return &listSessionsTool{sessionDir: sessionDir} } func (t *listSessionsTool) Name() string { return "list_sessions" } func (t *listSessionsTool) ReadOnly() bool { return true } func (t *listSessionsTool) Description() string { return "List saved AI conversation sessions. Returns timestamp, model, turn count, preview, and file for each visible session, newest first. Uses session metadata (branch sidecar timestamps). Use read_session to view a session's conversation." } func (t *listSessionsTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{},"required":[]}`) } func (t *listSessionsTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { ordered, err := agent.ListSessionOrder(t.sessionDir) if err != nil { return "", fmt.Errorf("list_sessions: %w", err) } if len(ordered) == 0 { return "No sessions found.\n", nil } var b strings.Builder fmt.Fprintf(&b, "# Saved Sessions (%d total)\n\n", len(ordered)) b.WriteString("| # | Timestamp | Model | Turns | Preview | File\n") b.WriteString("|---|-----------|-------|-------|-----------------|-----\n") for i, s := range ordered { ts := s.LastActivityAt.Format("2006-01-02 15:04") model := modelFromPath(s.Path) preview, turns := agent.SessionPreview(s.Path) fmt.Fprintf(&b, "| %d | %s | %s | %d | %s | `%s`\n", i+1, ts, model, turns, preview, filepath.Base(s.Path)) } b.WriteString("\nUse `read_session` with the filename under \"File\" to view the session.\n") return b.String(), nil } // ---- read_session tool ------------------------------------------------------ type readSessionTool struct { sessionDir string } // NewReadSessionTool creates a tool that reads saved sessions. func NewReadSessionTool(sessionDir string) *readSessionTool { return &readSessionTool{sessionDir: sessionDir} } func (t *readSessionTool) Name() string { return "read_session" } func (t *readSessionTool) ReadOnly() bool { return true } func (t *readSessionTool) Description() string { return `Read a saved AI conversation session by file name (e.g. "20260618-231556.000000000-gpt-4.jsonl"). Returns a bounded, privacy-safe view: each message truncated to 2000 runes, no reasoning content, no system prompts, no tool result content (opt-in via show_tool_results). Use list_sessions to discover available sessions.` } func (t *readSessionTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "session": { "type": "string", "description": "Session file name (e.g. \"20260618-231556.000000000-gpt-4.jsonl\") or full path. Use list_sessions to see available sessions." }, "max_turns": { "type": "integer", "description": "Maximum user-assistant turns to return (default 50). 0 = no limit." }, "show_tool_results": { "type": "boolean", "description": "When true, include tool result content (default false). Tool results may contain secrets, command output, environment data, or private file contents." } }, "required": ["session"] }`) } func (t *readSessionTool) Execute(_ context.Context, args json.RawMessage) (string, error) { var params struct { Session string `json:"session"` MaxTurns *int `json:"max_turns"` ShowToolResults bool `json:"show_tool_results"` } if err := json.Unmarshal(args, ¶ms); err != nil { return "", fmt.Errorf("read_session: invalid args: %w", err) } if params.Session == "" { return "", fmt.Errorf("read_session: 'session' argument is required") } sessionPath := params.Session // If it's just a filename (no path separator), resolve relative to sessionDir if !strings.Contains(sessionPath, string(filepath.Separator)) && !strings.Contains(sessionPath, "/") { sessionPath = filepath.Join(t.sessionDir, sessionPath) } // Guard against path traversal sessionPath = filepath.Clean(sessionPath) dir := filepath.Clean(t.sessionDir) if !strings.HasPrefix(sessionPath, dir+string(filepath.Separator)) && sessionPath != dir { return "", fmt.Errorf("read_session: path %q is outside the session directory", params.Session) } // Reject cleanup-pending sessions (reuses agent.IsCleanupPending directly). if agent.IsCleanupPending(sessionPath) { return "", fmt.Errorf("read_session: session %q is pending cleanup", filepath.Base(sessionPath)) } // Reuse agent.LoadSession for JSONL decoding. ses, err := agent.LoadSession(sessionPath) if err != nil { return "", fmt.Errorf("read_session: %w", err) } msgs := ses.Snapshot() if len(msgs) == 0 { return "Session is empty.\n", nil } // Parse max_turns: default 50, 0 means no limit. maxTurns := 50 if params.MaxTurns != nil { if *params.MaxTurns == 0 { maxTurns = 0 } else if *params.MaxTurns > 0 { maxTurns = *params.MaxTurns } } var b strings.Builder fmt.Fprintf(&b, "# Session: %s\n", filepath.Base(sessionPath)) turnCount := 0 loop: for _, m := range msgs { switch m.Role { case provider.RoleSystem: // System prompts excluded for privacy (matching history tool). case provider.RoleUser: turnCount++ if maxTurns > 0 && turnCount > maxTurns { b.WriteString("... (truncated, use max_turns to increase limit)\n") break loop } fmt.Fprintf(&b, "## User (turn %d)\n", turnCount) b.WriteString(truncateRunes(m.Content, 2000)) b.WriteString("\n\n") case provider.RoleAssistant: if m.Content != "" { fmt.Fprintf(&b, "## Assistant (turn %d)\n", max(turnCount, 1)) b.WriteString(truncateRunes(m.Content, 2000)) b.WriteString("\n\n") } if len(m.ToolCalls) > 0 { b.WriteString("### Tool Calls\n\n") for _, tc := range m.ToolCalls { fmt.Fprintf(&b, "- `%s(%s)`\n", tc.Name, truncateRunes(string(tc.Arguments), 1200)) } b.WriteString("\n") } case provider.RoleTool: fmt.Fprintf(&b, "### Tool Result: %s\n\n", m.Name) if params.ShowToolResults && m.Content != "" { b.WriteString(truncateRunes(m.Content, 2000)) b.WriteString("\n\n") } } } return b.String(), nil } // ---- helpers ---------------------------------------------------------------- // truncateRunes preserves the historical name but truncates by grapheme // clusters so previews do not split combined emoji or other visible characters. func truncateRunes(s string, max int) string { s = strings.TrimSpace(s) return textutil.TruncateGraphemes(s, max, "...") } // modelFromPath extracts the model name from a session file path. // Filename format: "20060102-150405.000000000-model-name.jsonl" func modelFromPath(path string) string { name := filepath.Base(path) name = strings.TrimSuffix(name, ".jsonl") firstDash := strings.Index(name, "-") if firstDash < 0 { return "(unknown)" } rest := name[firstDash+1:] secondDash := strings.Index(rest, "-") if secondDash < 0 { return rest } return rest[secondDash+1:] }