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