emdash-cli 0.1.25__py3-none-any.whl → 0.1.35__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ """Session persistence for CLI conversations.
2
+
3
+ Manages saving and loading of conversation sessions to .emdash/sessions/.
4
+ Each session preserves messages, mode, and other state for later restoration.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from dataclasses import dataclass, asdict
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+
15
+ MAX_SESSIONS = 5
16
+ MAX_MESSAGES = 10
17
+ SESSION_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,49}$")
18
+
19
+
20
+ @dataclass
21
+ class SessionMetadata:
22
+ """Metadata for a saved session."""
23
+ name: str
24
+ created_at: str
25
+ updated_at: str
26
+ message_count: int
27
+ model: Optional[str] = None
28
+ mode: str = "code"
29
+ summary: Optional[str] = None
30
+
31
+
32
+ @dataclass
33
+ class SessionData:
34
+ """Full session data including messages."""
35
+ name: str
36
+ messages: list[dict]
37
+ mode: str
38
+ model: Optional[str] = None
39
+ spec: Optional[str] = None
40
+ created_at: str = ""
41
+ updated_at: str = ""
42
+
43
+ def to_dict(self) -> dict:
44
+ return asdict(self)
45
+
46
+ @classmethod
47
+ def from_dict(cls, data: dict) -> "SessionData":
48
+ return cls(
49
+ name=data.get("name", ""),
50
+ messages=data.get("messages", []),
51
+ mode=data.get("mode", "code"),
52
+ model=data.get("model"),
53
+ spec=data.get("spec"),
54
+ created_at=data.get("created_at", ""),
55
+ updated_at=data.get("updated_at", ""),
56
+ )
57
+
58
+
59
+ class SessionStore:
60
+ """File-based session storage.
61
+
62
+ Sessions are stored in .emdash/sessions/ with:
63
+ - index.json: metadata for all sessions
64
+ - {name}.json: individual session data
65
+
66
+ Example:
67
+ store = SessionStore()
68
+ store.save_session("my-feature", messages, "code", None, "gpt-4")
69
+ session = store.load_session("my-feature")
70
+ """
71
+
72
+ def __init__(self, repo_root: Optional[Path] = None):
73
+ """Initialize session store.
74
+
75
+ Args:
76
+ repo_root: Repository root (defaults to cwd)
77
+ """
78
+ self.repo_root = repo_root or Path.cwd()
79
+ self.sessions_dir = self.repo_root / ".emdash" / "sessions"
80
+
81
+ def _ensure_dir(self) -> None:
82
+ """Ensure sessions directory exists."""
83
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ def _index_path(self) -> Path:
86
+ """Get path to index file."""
87
+ return self.sessions_dir / "index.json"
88
+
89
+ def _session_path(self, name: str) -> Path:
90
+ """Get path to session file."""
91
+ return self.sessions_dir / f"{name}.json"
92
+
93
+ def _load_index(self) -> dict:
94
+ """Load session index."""
95
+ index_path = self._index_path()
96
+ if index_path.exists():
97
+ try:
98
+ return json.loads(index_path.read_text())
99
+ except (json.JSONDecodeError, IOError):
100
+ pass
101
+ return {"sessions": [], "active": None}
102
+
103
+ def _save_index(self, index: dict) -> None:
104
+ """Save session index."""
105
+ self._ensure_dir()
106
+ self._index_path().write_text(json.dumps(index, indent=2))
107
+
108
+ def _validate_name(self, name: str) -> bool:
109
+ """Validate session name.
110
+
111
+ Args:
112
+ name: Session name to validate
113
+
114
+ Returns:
115
+ True if valid
116
+ """
117
+ return bool(SESSION_NAME_PATTERN.match(name))
118
+
119
+ def _generate_summary(self, messages: list[dict]) -> str:
120
+ """Generate a brief summary from messages.
121
+
122
+ Args:
123
+ messages: List of message dicts
124
+
125
+ Returns:
126
+ Summary string (first user message truncated)
127
+ """
128
+ for msg in messages:
129
+ if msg.get("role") == "user":
130
+ content = msg.get("content", "")
131
+ if isinstance(content, str) and content:
132
+ return content[:100] + ("..." if len(content) > 100 else "")
133
+ return "No description"
134
+
135
+ def list_sessions(self) -> list[SessionMetadata]:
136
+ """List all saved sessions.
137
+
138
+ Returns:
139
+ List of session metadata, sorted by updated_at (newest first)
140
+ """
141
+ index = self._load_index()
142
+ sessions = []
143
+ for s in index.get("sessions", []):
144
+ sessions.append(SessionMetadata(
145
+ name=s.get("name", ""),
146
+ created_at=s.get("created_at", ""),
147
+ updated_at=s.get("updated_at", ""),
148
+ message_count=s.get("message_count", 0),
149
+ model=s.get("model"),
150
+ mode=s.get("mode", "code"),
151
+ summary=s.get("summary"),
152
+ ))
153
+ # Sort by updated_at descending
154
+ sessions.sort(key=lambda x: x.updated_at, reverse=True)
155
+ return sessions
156
+
157
+ def save_session(
158
+ self,
159
+ name: str,
160
+ messages: list[dict],
161
+ mode: str,
162
+ spec: Optional[str] = None,
163
+ model: Optional[str] = None,
164
+ ) -> tuple[bool, str]:
165
+ """Save a session.
166
+
167
+ Args:
168
+ name: Session name (alphanumeric, hyphens, underscores)
169
+ messages: Conversation messages
170
+ mode: Current mode (plan/code)
171
+ spec: Current spec if any
172
+ model: Model being used
173
+
174
+ Returns:
175
+ Tuple of (success, message)
176
+ """
177
+ # Validate name
178
+ if not self._validate_name(name):
179
+ return False, "Invalid session name. Use letters, numbers, hyphens, underscores (max 50 chars)"
180
+
181
+ # Load index
182
+ index = self._load_index()
183
+ sessions = index.get("sessions", [])
184
+
185
+ # Check if updating existing session
186
+ existing_idx = None
187
+ for i, s in enumerate(sessions):
188
+ if s.get("name") == name:
189
+ existing_idx = i
190
+ break
191
+
192
+ # Check limit for new sessions
193
+ if existing_idx is None and len(sessions) >= MAX_SESSIONS:
194
+ return False, f"Maximum {MAX_SESSIONS} sessions reached. Delete one first with /session delete <name>"
195
+
196
+ now = datetime.utcnow().isoformat() + "Z"
197
+
198
+ # Trim messages to most recent N
199
+ trimmed_messages = messages[-MAX_MESSAGES:] if len(messages) > MAX_MESSAGES else messages
200
+
201
+ # Create session data
202
+ session_data = SessionData(
203
+ name=name,
204
+ messages=trimmed_messages,
205
+ mode=mode,
206
+ model=model,
207
+ spec=spec,
208
+ created_at=sessions[existing_idx]["created_at"] if existing_idx is not None else now,
209
+ updated_at=now,
210
+ )
211
+
212
+ # Save session file
213
+ self._ensure_dir()
214
+ self._session_path(name).write_text(json.dumps(session_data.to_dict(), indent=2))
215
+
216
+ # Update index
217
+ metadata = {
218
+ "name": name,
219
+ "created_at": session_data.created_at,
220
+ "updated_at": session_data.updated_at,
221
+ "message_count": len(trimmed_messages),
222
+ "model": model,
223
+ "mode": mode,
224
+ "summary": self._generate_summary(messages),
225
+ }
226
+
227
+ if existing_idx is not None:
228
+ sessions[existing_idx] = metadata
229
+ else:
230
+ sessions.append(metadata)
231
+
232
+ index["sessions"] = sessions
233
+ self._save_index(index)
234
+
235
+ return True, f"Session '{name}' saved ({len(trimmed_messages)} messages)"
236
+
237
+ def load_session(self, name: str) -> Optional[SessionData]:
238
+ """Load a session by name.
239
+
240
+ Args:
241
+ name: Session name
242
+
243
+ Returns:
244
+ SessionData or None if not found
245
+ """
246
+ session_path = self._session_path(name)
247
+ if not session_path.exists():
248
+ return None
249
+
250
+ try:
251
+ data = json.loads(session_path.read_text())
252
+ return SessionData.from_dict(data)
253
+ except (json.JSONDecodeError, IOError):
254
+ return None
255
+
256
+ def delete_session(self, name: str) -> tuple[bool, str]:
257
+ """Delete a session.
258
+
259
+ Args:
260
+ name: Session name
261
+
262
+ Returns:
263
+ Tuple of (success, message)
264
+ """
265
+ index = self._load_index()
266
+ sessions = index.get("sessions", [])
267
+
268
+ # Find and remove from index
269
+ found = False
270
+ for i, s in enumerate(sessions):
271
+ if s.get("name") == name:
272
+ sessions.pop(i)
273
+ found = True
274
+ break
275
+
276
+ if not found:
277
+ return False, f"Session '{name}' not found"
278
+
279
+ # Delete session file
280
+ session_path = self._session_path(name)
281
+ if session_path.exists():
282
+ session_path.unlink()
283
+
284
+ # Clear active if it was this session
285
+ if index.get("active") == name:
286
+ index["active"] = None
287
+
288
+ index["sessions"] = sessions
289
+ self._save_index(index)
290
+
291
+ return True, f"Session '{name}' deleted"
292
+
293
+ def get_active_session(self) -> Optional[str]:
294
+ """Get the name of the active session.
295
+
296
+ Returns:
297
+ Session name or None
298
+ """
299
+ index = self._load_index()
300
+ return index.get("active")
301
+
302
+ def set_active_session(self, name: Optional[str]) -> None:
303
+ """Set the active session.
304
+
305
+ Args:
306
+ name: Session name or None to clear
307
+ """
308
+ index = self._load_index()
309
+ index["active"] = name
310
+ self._save_index(index)
311
+
312
+ def session_exists(self, name: str) -> bool:
313
+ """Check if a session exists.
314
+
315
+ Args:
316
+ name: Session name
317
+
318
+ Returns:
319
+ True if session exists
320
+ """
321
+ return self._session_path(name).exists()