Skip to content

Commit c5ceb31

Browse files
Vadim MitroshkinVadim Mitroshkin
authored andcommitted
Add Session Persistence logic
1 parent 81ac52f commit c5ceb31

5 files changed

Lines changed: 326 additions & 11 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ Uses `tree-sitter` and `networkx` to generate a high-level map of your repositor
2929
- **Token Counter**: Real-time monitoring of context usage.
3030
- **Smart Compaction**: Use `/compact` to summarize conversation history and free up token space without losing key context.
3131

32+
### 💾 Session Persistence
33+
- **Auto-Save**: Your conversation is automatically saved after each message exchange.
34+
- **Resume Sessions**: Use `/continue` to pick up where you left off after closing SuperCoder.
35+
- **Session History**: Up to 10 sessions are stored in `.supercoder/sessions/`.
36+
- **Compact Integration**: When you `/compact`, the session file is also updated with the summary.
37+
3238
---
3339

3440
## 🚀 Getting Started
@@ -113,6 +119,8 @@ supercoder --no-repo-map # Disable RepoMap
113119
| Command | Description |
114120
|---------|-------------|
115121
| `/help` | Show available commands |
122+
| `/continue` | Resume a previous session |
123+
| `/sessions` | List saved sessions |
116124
| `/tools` | List active tools and their descriptions |
117125
| `/compact` | Summarize history to save context tokens |
118126
| `/stats` | View current token usage and context status |
@@ -130,13 +138,14 @@ supercoder --no-repo-map # Disable RepoMap
130138
```text
131139
supercoder/
132140
├── agent/ # CoderAgent logic and prompts
133-
├── context/ # Token counting and context window management
141+
├── context/ # Token counting, context window, and session management
134142
├── llm/ # LLM providers (OpenAI-compatible endpoints)
135143
├── repomap/ # Repository mapping logic (tree-sitter)
136144
├── tools/ # Core tools (Search, Edit, Structure, Exec)
137145
├── rules_loader.py # Supercoder Rules loading logic
138146
├── config.py # Configuration management
139147
├── logging.py # Conversation logging
148+
├── repl.py # Interactive REPL interface
140149
└── main.py # CLI entry point
141150
```
142151

supercoder/agent/coder_agent.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import re
44
import json
5+
from datetime import datetime
56
from pathlib import Path
67
from rich.console import Console
78

89
from ..llm.base import BaseLLM, Message
910
from ..tools.base import BaseTool
1011
from ..context.window_manager import ContextWindowManager, ContextConfig
12+
from ..context.session_manager import SessionManager, ChatSession
1113
from ..repomap import RepoMap
1214
from ..rules_loader import SupercoderRulesLoader
1315
from ..logging import get_logger
@@ -52,6 +54,10 @@ def __init__(
5254
# Multi-format tool call parser
5355
self.tool_parser = ToolCallParser(debug=False)
5456

57+
# Session management
58+
self.session_manager = SessionManager(self.repo_root)
59+
self.current_session: ChatSession | None = None
60+
5561
self.debug = False
5662

5763
def _update_system_prompt(self):
@@ -116,6 +122,8 @@ def chat_stream(self, user_message: str):
116122
self.context.add_message(Message("assistant", response_text))
117123
# Log model response
118124
get_logger().log_model_response(response_text, self.llm.model)
125+
# Auto-save session
126+
self._save_current_session()
119127

120128
# Check for tool calls (may be multiple)
121129
tool_calls = self._extract_all_tool_calls(response_text)
@@ -185,6 +193,32 @@ def set_debug(self, enabled: bool) -> None:
185193
self.debug = enabled
186194
self.tool_parser.debug = enabled
187195

196+
# Session management methods
197+
def start_new_session(self) -> None:
198+
"""Create and activate a new session."""
199+
self.current_session = self.session_manager.create_new_session()
200+
201+
def load_session(self, session_id: str) -> bool:
202+
"""Load an existing session and restore context.
203+
204+
Returns True if session was loaded successfully.
205+
"""
206+
session = self.session_manager.load_session(session_id)
207+
if session:
208+
self.current_session = session
209+
# Clear existing context and restore from session
210+
self.context.clear()
211+
for msg in session.messages:
212+
self.context.add_message(msg)
213+
return True
214+
return False
215+
216+
def _save_current_session(self) -> None:
217+
"""Save current session state."""
218+
if self.current_session:
219+
self.current_session.messages = self.context.get_messages()
220+
self.session_manager.save_session(self.current_session)
221+
188222
def compact_context(self) -> tuple[str, "ContextStats", "ContextStats"]:
189223
"""Compact the current context by summarizing it.
190224
@@ -230,6 +264,12 @@ def compact_context(self) -> tuple[str, "ContextStats", "ContextStats"]:
230264
# Clear history and set summary as initial context
231265
self.context.set_initial_summary(summary)
232266

267+
# Update session with compacted state
268+
if self.current_session:
269+
self.session_manager.update_session_after_compact(
270+
self.current_session, summary
271+
)
272+
233273
# Get stats after compaction
234274
stats_after = self.context.get_stats()
235275

supercoder/context/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .token_counter import TokenCounter, count_tokens, get_token_counter
44
from .window_manager import ContextWindowManager, ContextConfig, ContextStats
5+
from .session_manager import SessionManager, ChatSession
56

67
__all__ = [
78
"TokenCounter",
@@ -10,4 +11,6 @@
1011
"ContextWindowManager",
1112
"ContextConfig",
1213
"ContextStats",
14+
"SessionManager",
15+
"ChatSession",
1316
]
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Chat session management for persistence across restarts."""
2+
3+
import json
4+
import uuid
5+
from dataclasses import dataclass, field, asdict
6+
from datetime import datetime
7+
from pathlib import Path
8+
from typing import Any
9+
10+
from ..llm.base import Message
11+
12+
13+
@dataclass
14+
class ChatSession:
15+
"""Represents a saved chat session."""
16+
id: str
17+
title: str
18+
created_at: str # ISO format
19+
last_modified: str # ISO format
20+
messages: list[Message] = field(default_factory=list)
21+
is_compacted: bool = False
22+
23+
def to_dict(self) -> dict[str, Any]:
24+
"""Convert session to dictionary for JSON serialization."""
25+
return {
26+
"id": self.id,
27+
"title": self.title,
28+
"created_at": self.created_at,
29+
"last_modified": self.last_modified,
30+
"is_compacted": self.is_compacted,
31+
"messages": [
32+
{"role": msg.role, "content": msg.content}
33+
for msg in self.messages
34+
]
35+
}
36+
37+
@classmethod
38+
def from_dict(cls, data: dict[str, Any]) -> "ChatSession":
39+
"""Create session from dictionary."""
40+
messages = [
41+
Message(role=m["role"], content=m["content"])
42+
for m in data.get("messages", [])
43+
]
44+
return cls(
45+
id=data["id"],
46+
title=data.get("title", "Untitled"),
47+
created_at=data.get("created_at", ""),
48+
last_modified=data.get("last_modified", ""),
49+
messages=messages,
50+
is_compacted=data.get("is_compacted", False)
51+
)
52+
53+
54+
class SessionManager:
55+
"""Manages chat session persistence.
56+
57+
Sessions are stored as JSON files in .supercoder/sessions/ directory.
58+
Maximum of 10 sessions are kept; oldest sessions are automatically deleted.
59+
"""
60+
61+
MAX_SESSIONS = 10
62+
SESSIONS_DIR = "sessions"
63+
64+
def __init__(self, project_root: Path):
65+
self.project_root = Path(project_root)
66+
self.sessions_dir = self.project_root / ".supercoder" / self.SESSIONS_DIR
67+
self._ensure_sessions_dir()
68+
69+
def _ensure_sessions_dir(self) -> None:
70+
"""Create sessions directory if it doesn't exist."""
71+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
72+
73+
def _get_session_path(self, session_id: str) -> Path:
74+
"""Get path to session file."""
75+
return self.sessions_dir / f"{session_id}.json"
76+
77+
def create_new_session(self) -> ChatSession:
78+
"""Create a new empty session."""
79+
now = datetime.now().isoformat()
80+
session = ChatSession(
81+
id=str(uuid.uuid4())[:8], # Short UUID for readability
82+
title="New Session",
83+
created_at=now,
84+
last_modified=now,
85+
messages=[],
86+
is_compacted=False
87+
)
88+
return session
89+
90+
def save_session(self, session: ChatSession) -> None:
91+
"""Save session to JSON file.
92+
93+
Also triggers cleanup if MAX_SESSIONS is exceeded.
94+
"""
95+
session.last_modified = datetime.now().isoformat()
96+
97+
# Update title from last user message
98+
user_messages = [m for m in session.messages if m.role == "user" and not m.content.startswith("<@TOOL_RESULT>")]
99+
if user_messages:
100+
last_msg = user_messages[-1].content
101+
# Truncate and clean title
102+
session.title = (last_msg[:50] + "...") if len(last_msg) > 50 else last_msg
103+
session.title = session.title.replace("\n", " ").strip()
104+
105+
# Save to file
106+
session_path = self._get_session_path(session.id)
107+
with open(session_path, "w", encoding="utf-8") as f:
108+
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2)
109+
110+
# Cleanup old sessions
111+
self._cleanup_old_sessions()
112+
113+
def load_session(self, session_id: str) -> ChatSession | None:
114+
"""Load session from JSON file."""
115+
session_path = self._get_session_path(session_id)
116+
117+
if not session_path.exists():
118+
return None
119+
120+
try:
121+
with open(session_path, "r", encoding="utf-8") as f:
122+
data = json.load(f)
123+
return ChatSession.from_dict(data)
124+
except (json.JSONDecodeError, KeyError) as e:
125+
# Corrupted session file
126+
return None
127+
128+
def list_sessions(self) -> list[dict[str, Any]]:
129+
"""List all available sessions with metadata.
130+
131+
Returns list of dicts with id, title, last_modified, is_compacted.
132+
Sorted by last_modified (newest first).
133+
"""
134+
sessions = []
135+
136+
for session_file in self.sessions_dir.glob("*.json"):
137+
try:
138+
with open(session_file, "r", encoding="utf-8") as f:
139+
data = json.load(f)
140+
sessions.append({
141+
"id": data.get("id", session_file.stem),
142+
"title": data.get("title", "Untitled"),
143+
"created_at": data.get("created_at", ""),
144+
"last_modified": data.get("last_modified", ""),
145+
"is_compacted": data.get("is_compacted", False),
146+
"message_count": len(data.get("messages", []))
147+
})
148+
except (json.JSONDecodeError, KeyError):
149+
# Skip corrupted files
150+
continue
151+
152+
# Sort by last_modified (newest first)
153+
sessions.sort(key=lambda s: s.get("last_modified", ""), reverse=True)
154+
155+
return sessions
156+
157+
def delete_session(self, session_id: str) -> bool:
158+
"""Delete a session file."""
159+
session_path = self._get_session_path(session_id)
160+
161+
if session_path.exists():
162+
session_path.unlink()
163+
return True
164+
return False
165+
166+
def update_session_after_compact(self, session: ChatSession, summary: str) -> None:
167+
"""Update session after context compaction.
168+
169+
Replaces all messages with the summary and marks as compacted.
170+
"""
171+
session.is_compacted = True
172+
session.messages = [Message("user", f"[Previous Context Summary]\\n\\n{summary}")]
173+
session.last_modified = datetime.now().isoformat()
174+
175+
# Save updated session
176+
session_path = self._get_session_path(session.id)
177+
with open(session_path, "w", encoding="utf-8") as f:
178+
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2)
179+
180+
def _cleanup_old_sessions(self) -> None:
181+
"""Remove oldest sessions if we exceed MAX_SESSIONS."""
182+
sessions = self.list_sessions()
183+
184+
if len(sessions) <= self.MAX_SESSIONS:
185+
return
186+
187+
# Sessions are sorted newest first, so remove from the end
188+
sessions_to_delete = sessions[self.MAX_SESSIONS:]
189+
190+
for session in sessions_to_delete:
191+
self.delete_session(session["id"])

0 commit comments

Comments
 (0)