| 1 | // Package sessiontool provides list_sessions and read_session tools that let |
| 2 | // the AI discover and read past conversation sessions, enabling cross-session |
| 3 | // AI context sharing. The tools reuse agent.ListSessionOrder, agent.LoadSession, |
| 4 | // and agent.IsCleanupPending — the same infrastructure used by the history |
| 5 | // tool and session picker — to avoid duplicating session-file logic. |
| 6 | package sessiontool |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "fmt" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | |
| 15 | "reasonix/internal/agent" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/textutil" |
| 18 | ) |
| 19 | |
| 20 | // ---- list_sessions tool ----------------------------------------------------- |
| 21 | |
| 22 | type listSessionsTool struct { |
| 23 | sessionDir string |
| 24 | } |
| 25 | |
| 26 | // NewListSessionsTool creates a tool that lists saved sessions. |
| 27 | func NewListSessionsTool(sessionDir string) *listSessionsTool { |
| 28 | return &listSessionsTool{sessionDir: sessionDir} |
| 29 | } |
| 30 | |
| 31 | func (t *listSessionsTool) Name() string { return "list_sessions" } |
| 32 | func (t *listSessionsTool) ReadOnly() bool { return true } |
| 33 | |
| 34 | func (t *listSessionsTool) Description() string { |
| 35 | 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." |
| 36 | } |
| 37 | |
| 38 | func (t *listSessionsTool) Schema() json.RawMessage { |
| 39 | return json.RawMessage(`{"type":"object","properties":{},"required":[]}`) |
| 40 | } |
| 41 | |
| 42 | func (t *listSessionsTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { |
| 43 | ordered, err := agent.ListSessionOrder(t.sessionDir) |
| 44 | if err != nil { |
| 45 | return "", fmt.Errorf("list_sessions: %w", err) |
| 46 | } |
| 47 | if len(ordered) == 0 { |
| 48 | return "No sessions found.\n", nil |
| 49 | } |
| 50 | |
| 51 | var b strings.Builder |
| 52 | fmt.Fprintf(&b, "# Saved Sessions (%d total)\n\n", len(ordered)) |
| 53 | b.WriteString("| # | Timestamp | Model | Turns | Preview | File\n") |
| 54 | b.WriteString("|---|-----------|-------|-------|-----------------|-----\n") |
| 55 | for i, s := range ordered { |
| 56 | ts := s.LastActivityAt.Format("2006-01-02 15:04") |
| 57 | model := modelFromPath(s.Path) |
| 58 | preview, turns := agent.SessionPreview(s.Path) |
| 59 | fmt.Fprintf(&b, "| %d | %s | %s | %d | %s | `%s`\n", |
| 60 | i+1, ts, model, turns, preview, filepath.Base(s.Path)) |
| 61 | } |
| 62 | b.WriteString("\nUse `read_session` with the filename under \"File\" to view the session.\n") |
| 63 | return b.String(), nil |
| 64 | } |
| 65 | |
| 66 | // ---- read_session tool ------------------------------------------------------ |
| 67 | |
| 68 | type readSessionTool struct { |
| 69 | sessionDir string |
| 70 | } |
| 71 | |
| 72 | // NewReadSessionTool creates a tool that reads saved sessions. |
| 73 | func NewReadSessionTool(sessionDir string) *readSessionTool { |
| 74 | return &readSessionTool{sessionDir: sessionDir} |
| 75 | } |
| 76 | |
| 77 | func (t *readSessionTool) Name() string { return "read_session" } |
| 78 | func (t *readSessionTool) ReadOnly() bool { return true } |
| 79 | |
| 80 | func (t *readSessionTool) Description() string { |
| 81 | 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.` |
| 82 | } |
| 83 | |
| 84 | func (t *readSessionTool) Schema() json.RawMessage { |
| 85 | return json.RawMessage(`{ |
| 86 | "type": "object", |
| 87 | "properties": { |
| 88 | "session": { |
| 89 | "type": "string", |
| 90 | "description": "Session file name (e.g. \"20260618-231556.000000000-gpt-4.jsonl\") or full path. Use list_sessions to see available sessions." |
| 91 | }, |
| 92 | "max_turns": { |
| 93 | "type": "integer", |
| 94 | "description": "Maximum user-assistant turns to return (default 50). 0 = no limit." |
| 95 | }, |
| 96 | "show_tool_results": { |
| 97 | "type": "boolean", |
| 98 | "description": "When true, include tool result content (default false). Tool results may contain secrets, command output, environment data, or private file contents." |
| 99 | } |
| 100 | }, |
| 101 | "required": ["session"] |
| 102 | }`) |
| 103 | } |
| 104 | |
| 105 | func (t *readSessionTool) Execute(_ context.Context, args json.RawMessage) (string, error) { |
| 106 | var params struct { |
| 107 | Session string `json:"session"` |
| 108 | MaxTurns *int `json:"max_turns"` |
| 109 | ShowToolResults bool `json:"show_tool_results"` |
| 110 | } |
| 111 | if err := json.Unmarshal(args, ¶ms); err != nil { |
| 112 | return "", fmt.Errorf("read_session: invalid args: %w", err) |
| 113 | } |
| 114 | if params.Session == "" { |
| 115 | return "", fmt.Errorf("read_session: 'session' argument is required") |
| 116 | } |
| 117 | |
| 118 | sessionPath := params.Session |
| 119 | // If it's just a filename (no path separator), resolve relative to sessionDir |
| 120 | if !strings.Contains(sessionPath, string(filepath.Separator)) && !strings.Contains(sessionPath, "/") { |
| 121 | sessionPath = filepath.Join(t.sessionDir, sessionPath) |
| 122 | } |
| 123 | // Guard against path traversal |
| 124 | sessionPath = filepath.Clean(sessionPath) |
| 125 | dir := filepath.Clean(t.sessionDir) |
| 126 | if !strings.HasPrefix(sessionPath, dir+string(filepath.Separator)) && sessionPath != dir { |
| 127 | return "", fmt.Errorf("read_session: path %q is outside the session directory", params.Session) |
| 128 | } |
| 129 | |
| 130 | // Reject cleanup-pending sessions (reuses agent.IsCleanupPending directly). |
| 131 | if agent.IsCleanupPending(sessionPath) { |
| 132 | return "", fmt.Errorf("read_session: session %q is pending cleanup", filepath.Base(sessionPath)) |
| 133 | } |
| 134 | |
| 135 | // Reuse agent.LoadSession for JSONL decoding. |
| 136 | ses, err := agent.LoadSession(sessionPath) |
| 137 | if err != nil { |
| 138 | return "", fmt.Errorf("read_session: %w", err) |
| 139 | } |
| 140 | msgs := ses.Snapshot() |
| 141 | if len(msgs) == 0 { |
| 142 | return "Session is empty.\n", nil |
| 143 | } |
| 144 | |
| 145 | // Parse max_turns: default 50, 0 means no limit. |
| 146 | maxTurns := 50 |
| 147 | if params.MaxTurns != nil { |
| 148 | if *params.MaxTurns == 0 { |
| 149 | maxTurns = 0 |
| 150 | } else if *params.MaxTurns > 0 { |
| 151 | maxTurns = *params.MaxTurns |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | var b strings.Builder |
| 156 | fmt.Fprintf(&b, "# Session: %s\n", filepath.Base(sessionPath)) |
| 157 | |
| 158 | turnCount := 0 |
| 159 | loop: |
| 160 | for _, m := range msgs { |
| 161 | switch m.Role { |
| 162 | case provider.RoleSystem: |
| 163 | // System prompts excluded for privacy (matching history tool). |
| 164 | |
| 165 | case provider.RoleUser: |
| 166 | turnCount++ |
| 167 | if maxTurns > 0 && turnCount > maxTurns { |
| 168 | b.WriteString("... (truncated, use max_turns to increase limit)\n") |
| 169 | break loop |
| 170 | } |
| 171 | fmt.Fprintf(&b, "## User (turn %d)\n", turnCount) |
| 172 | b.WriteString(truncateRunes(m.Content, 2000)) |
| 173 | b.WriteString("\n\n") |
| 174 | |
| 175 | case provider.RoleAssistant: |
| 176 | if m.Content != "" { |
| 177 | fmt.Fprintf(&b, "## Assistant (turn %d)\n", max(turnCount, 1)) |
| 178 | b.WriteString(truncateRunes(m.Content, 2000)) |
| 179 | b.WriteString("\n\n") |
| 180 | } |
| 181 | if len(m.ToolCalls) > 0 { |
| 182 | b.WriteString("### Tool Calls\n\n") |
| 183 | for _, tc := range m.ToolCalls { |
| 184 | fmt.Fprintf(&b, "- `%s(%s)`\n", tc.Name, truncateRunes(string(tc.Arguments), 1200)) |
| 185 | } |
| 186 | b.WriteString("\n") |
| 187 | } |
| 188 | |
| 189 | case provider.RoleTool: |
| 190 | fmt.Fprintf(&b, "### Tool Result: %s\n\n", m.Name) |
| 191 | if params.ShowToolResults && m.Content != "" { |
| 192 | b.WriteString(truncateRunes(m.Content, 2000)) |
| 193 | b.WriteString("\n\n") |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | return b.String(), nil |
| 199 | } |
| 200 | |
| 201 | // ---- helpers ---------------------------------------------------------------- |
| 202 | |
| 203 | // truncateRunes preserves the historical name but truncates by grapheme |
| 204 | // clusters so previews do not split combined emoji or other visible characters. |
| 205 | func truncateRunes(s string, max int) string { |
| 206 | s = strings.TrimSpace(s) |
| 207 | return textutil.TruncateGraphemes(s, max, "...") |
| 208 | } |
| 209 | |
| 210 | // modelFromPath extracts the model name from a session file path. |
| 211 | // Filename format: "20060102-150405.000000000-model-name.jsonl" |
| 212 | func modelFromPath(path string) string { |
| 213 | name := filepath.Base(path) |
| 214 | name = strings.TrimSuffix(name, ".jsonl") |
| 215 | firstDash := strings.Index(name, "-") |
| 216 | if firstDash < 0 { |
| 217 | return "(unknown)" |
| 218 | } |
| 219 | rest := name[firstDash+1:] |
| 220 | secondDash := strings.Index(rest, "-") |
| 221 | if secondDash < 0 { |
| 222 | return rest |
| 223 | } |
| 224 | return rest[secondDash+1:] |
| 225 | } |
| 226 |