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.
- emdash_cli/__init__.py +15 -0
- emdash_cli/client.py +129 -0
- emdash_cli/clipboard.py +123 -0
- emdash_cli/commands/agent.py +526 -34
- emdash_cli/session_store.py +321 -0
- emdash_cli/sse_renderer.py +224 -119
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/METADATA +4 -2
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/RECORD +10 -8
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.25.dist-info → emdash_cli-0.1.35.dist-info}/entry_points.txt +0 -0
|
@@ -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()
|