emdash-cli 0.1.30__py3-none-any.whl → 0.1.46__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 +156 -0
- emdash_cli/clipboard.py +30 -61
- emdash_cli/commands/agent/__init__.py +14 -0
- emdash_cli/commands/agent/cli.py +100 -0
- emdash_cli/commands/agent/constants.py +53 -0
- emdash_cli/commands/agent/file_utils.py +178 -0
- emdash_cli/commands/agent/handlers/__init__.py +41 -0
- emdash_cli/commands/agent/handlers/agents.py +421 -0
- emdash_cli/commands/agent/handlers/auth.py +69 -0
- emdash_cli/commands/agent/handlers/doctor.py +319 -0
- emdash_cli/commands/agent/handlers/hooks.py +121 -0
- emdash_cli/commands/agent/handlers/mcp.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +200 -0
- emdash_cli/commands/agent/handlers/rules.py +394 -0
- emdash_cli/commands/agent/handlers/sessions.py +168 -0
- emdash_cli/commands/agent/handlers/setup.py +582 -0
- emdash_cli/commands/agent/handlers/skills.py +440 -0
- emdash_cli/commands/agent/handlers/todos.py +98 -0
- emdash_cli/commands/agent/handlers/verify.py +648 -0
- emdash_cli/commands/agent/interactive.py +657 -0
- emdash_cli/commands/agent/menus.py +728 -0
- emdash_cli/commands/agent.py +7 -856
- emdash_cli/commands/server.py +99 -40
- emdash_cli/server_manager.py +70 -10
- emdash_cli/session_store.py +321 -0
- emdash_cli/sse_renderer.py +256 -110
- {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
- emdash_cli-0.1.46.dist-info/RECORD +49 -0
- emdash_cli-0.1.30.dist-info/RECORD +0 -29
- {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/entry_points.txt +0 -0
emdash_cli/commands/server.py
CHANGED
|
@@ -10,6 +10,9 @@ from rich.console import Console
|
|
|
10
10
|
|
|
11
11
|
console = Console()
|
|
12
12
|
|
|
13
|
+
# Per-repo servers directory
|
|
14
|
+
SERVERS_DIR = Path.home() / ".emdash" / "servers"
|
|
15
|
+
|
|
13
16
|
|
|
14
17
|
@click.group()
|
|
15
18
|
def server():
|
|
@@ -26,23 +29,41 @@ def server_killall():
|
|
|
26
29
|
"""
|
|
27
30
|
killed = 0
|
|
28
31
|
|
|
29
|
-
# Kill by PID
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
# Kill servers by PID files in servers directory
|
|
33
|
+
if SERVERS_DIR.exists():
|
|
34
|
+
for pid_file in SERVERS_DIR.glob("*.pid"):
|
|
35
|
+
try:
|
|
36
|
+
pid = int(pid_file.read_text().strip())
|
|
37
|
+
os.kill(pid, signal.SIGTERM)
|
|
38
|
+
console.print(f"[green]Killed server process {pid}[/green]")
|
|
39
|
+
killed += 1
|
|
40
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
41
|
+
pass
|
|
42
|
+
finally:
|
|
43
|
+
# Clean up all files for this server
|
|
44
|
+
hash_prefix = pid_file.stem
|
|
45
|
+
for ext in [".port", ".pid", ".repo"]:
|
|
46
|
+
server_file = SERVERS_DIR / f"{hash_prefix}{ext}"
|
|
47
|
+
if server_file.exists():
|
|
48
|
+
server_file.unlink(missing_ok=True)
|
|
49
|
+
|
|
50
|
+
# Also check legacy location
|
|
51
|
+
legacy_pid_file = Path.home() / ".emdash" / "server.pid"
|
|
52
|
+
if legacy_pid_file.exists():
|
|
32
53
|
try:
|
|
33
|
-
pid = int(
|
|
54
|
+
pid = int(legacy_pid_file.read_text().strip())
|
|
34
55
|
os.kill(pid, signal.SIGTERM)
|
|
35
|
-
console.print(f"[green]Killed server process {pid}[/green]")
|
|
56
|
+
console.print(f"[green]Killed legacy server process {pid}[/green]")
|
|
36
57
|
killed += 1
|
|
37
58
|
except (ValueError, ProcessLookupError, PermissionError):
|
|
38
59
|
pass
|
|
39
60
|
finally:
|
|
40
|
-
|
|
61
|
+
legacy_pid_file.unlink(missing_ok=True)
|
|
41
62
|
|
|
42
|
-
# Clean up port file
|
|
43
|
-
|
|
44
|
-
if
|
|
45
|
-
|
|
63
|
+
# Clean up legacy port file
|
|
64
|
+
legacy_port_file = Path.home() / ".emdash" / "server.port"
|
|
65
|
+
if legacy_port_file.exists():
|
|
66
|
+
legacy_port_file.unlink(missing_ok=True)
|
|
46
67
|
|
|
47
68
|
# Kill any remaining emdash_core.server processes
|
|
48
69
|
try:
|
|
@@ -77,41 +98,79 @@ def server_killall():
|
|
|
77
98
|
|
|
78
99
|
@server.command("status")
|
|
79
100
|
def server_status():
|
|
80
|
-
"""Show
|
|
101
|
+
"""Show status of all running servers.
|
|
81
102
|
|
|
82
103
|
Example:
|
|
83
104
|
emdash server status
|
|
84
105
|
"""
|
|
85
|
-
|
|
86
|
-
pid_file = Path.home() / ".emdash" / "server.pid"
|
|
106
|
+
import httpx
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
console.print("[yellow]No server running[/yellow]")
|
|
90
|
-
return
|
|
108
|
+
servers_found = []
|
|
91
109
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
# Check per-repo servers directory
|
|
111
|
+
if SERVERS_DIR.exists():
|
|
112
|
+
for port_file in SERVERS_DIR.glob("*.port"):
|
|
113
|
+
try:
|
|
114
|
+
port = int(port_file.read_text().strip())
|
|
115
|
+
hash_prefix = port_file.stem
|
|
97
116
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
# Get repo path if available
|
|
118
|
+
repo_file = SERVERS_DIR / f"{hash_prefix}.repo"
|
|
119
|
+
repo_path = repo_file.read_text().strip() if repo_file.exists() else "unknown"
|
|
120
|
+
|
|
121
|
+
# Get PID if available
|
|
122
|
+
pid_file = SERVERS_DIR / f"{hash_prefix}.pid"
|
|
123
|
+
pid = pid_file.read_text().strip() if pid_file.exists() else "unknown"
|
|
124
|
+
|
|
125
|
+
# Check health
|
|
105
126
|
try:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
|
|
128
|
+
healthy = response.status_code == 200
|
|
129
|
+
except (httpx.RequestError, httpx.TimeoutException):
|
|
130
|
+
healthy = False
|
|
131
|
+
|
|
132
|
+
servers_found.append({
|
|
133
|
+
"port": port,
|
|
134
|
+
"pid": pid,
|
|
135
|
+
"repo": repo_path,
|
|
136
|
+
"healthy": healthy,
|
|
137
|
+
})
|
|
138
|
+
except (ValueError, IOError):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# Check legacy location
|
|
142
|
+
legacy_port_file = Path.home() / ".emdash" / "server.port"
|
|
143
|
+
if legacy_port_file.exists():
|
|
144
|
+
try:
|
|
145
|
+
port = int(legacy_port_file.read_text().strip())
|
|
146
|
+
legacy_pid_file = Path.home() / ".emdash" / "server.pid"
|
|
147
|
+
pid = legacy_pid_file.read_text().strip() if legacy_pid_file.exists() else "unknown"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
|
|
151
|
+
healthy = response.status_code == 200
|
|
152
|
+
except (httpx.RequestError, httpx.TimeoutException):
|
|
153
|
+
healthy = False
|
|
154
|
+
|
|
155
|
+
servers_found.append({
|
|
156
|
+
"port": port,
|
|
157
|
+
"pid": pid,
|
|
158
|
+
"repo": "(legacy)",
|
|
159
|
+
"healthy": healthy,
|
|
160
|
+
})
|
|
161
|
+
except (ValueError, IOError):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
if not servers_found:
|
|
165
|
+
console.print("[yellow]No servers running[/yellow]")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
console.print(f"[bold]Found {len(servers_found)} server(s):[/bold]\n")
|
|
169
|
+
for srv in servers_found:
|
|
170
|
+
status = "[green]healthy[/green]" if srv["healthy"] else "[red]unhealthy[/red]"
|
|
171
|
+
console.print(f" {status}")
|
|
172
|
+
console.print(f" Port: {srv['port']}")
|
|
173
|
+
console.print(f" PID: {srv['pid']}")
|
|
174
|
+
console.print(f" Repo: {srv['repo']}")
|
|
175
|
+
console.print(f" URL: http://localhost:{srv['port']}")
|
|
176
|
+
console.print()
|
emdash_cli/server_manager.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Server lifecycle management for emdash-core."""
|
|
2
2
|
|
|
3
3
|
import atexit
|
|
4
|
+
import hashlib
|
|
4
5
|
import os
|
|
5
6
|
import signal
|
|
6
7
|
import socket
|
|
@@ -17,15 +18,15 @@ class ServerManager:
|
|
|
17
18
|
"""Manages FastAPI server lifecycle for CLI.
|
|
18
19
|
|
|
19
20
|
The ServerManager handles:
|
|
21
|
+
- Per-repo server instances (each repo gets its own server)
|
|
20
22
|
- Discovering running servers via port file
|
|
21
23
|
- Starting new servers when needed
|
|
22
24
|
- Health checking servers
|
|
23
25
|
- Graceful shutdown on CLI exit
|
|
26
|
+
- Cleanup of stale servers
|
|
24
27
|
"""
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
PORT_FILE = Path.home() / ".emdash" / "server.port"
|
|
28
|
-
PID_FILE = Path.home() / ".emdash" / "server.pid"
|
|
29
|
+
SERVERS_DIR = Path.home() / ".emdash" / "servers"
|
|
29
30
|
STARTUP_TIMEOUT = 30.0 # seconds
|
|
30
31
|
HEALTH_TIMEOUT = 2.0 # seconds
|
|
31
32
|
|
|
@@ -40,8 +41,35 @@ class ServerManager:
|
|
|
40
41
|
self.port: Optional[int] = None
|
|
41
42
|
self._started_by_us = False
|
|
42
43
|
|
|
44
|
+
# Create servers directory
|
|
45
|
+
self.SERVERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
# Cleanup stale servers on init
|
|
48
|
+
self._cleanup_stale_servers()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def _repo_hash(self) -> str:
|
|
52
|
+
"""Get a short hash of the repo root path for unique file naming."""
|
|
53
|
+
path_str = str(self.repo_root.resolve())
|
|
54
|
+
return hashlib.sha256(path_str.encode()).hexdigest()[:12]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def _port_file(self) -> Path:
|
|
58
|
+
"""Get the port file path for this repo."""
|
|
59
|
+
return self.SERVERS_DIR / f"{self._repo_hash}.port"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def _pid_file(self) -> Path:
|
|
63
|
+
"""Get the PID file path for this repo."""
|
|
64
|
+
return self.SERVERS_DIR / f"{self._repo_hash}.pid"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def _repo_file(self) -> Path:
|
|
68
|
+
"""Get the repo path file for this repo (for debugging)."""
|
|
69
|
+
return self.SERVERS_DIR / f"{self._repo_hash}.repo"
|
|
70
|
+
|
|
43
71
|
def get_server_url(self) -> str:
|
|
44
|
-
"""Get URL of running server, starting one if needed.
|
|
72
|
+
"""Get URL of running server for this repo, starting one if needed.
|
|
45
73
|
|
|
46
74
|
Returns:
|
|
47
75
|
Base URL of the running server (e.g., "http://localhost:8765")
|
|
@@ -49,17 +77,19 @@ class ServerManager:
|
|
|
49
77
|
Raises:
|
|
50
78
|
RuntimeError: If server fails to start
|
|
51
79
|
"""
|
|
52
|
-
# Check if server already running
|
|
53
|
-
if self.
|
|
80
|
+
# Check if server already running for THIS repo
|
|
81
|
+
if self._port_file.exists():
|
|
54
82
|
try:
|
|
55
|
-
port = int(self.
|
|
83
|
+
port = int(self._port_file.read_text().strip())
|
|
56
84
|
if self._check_health(port):
|
|
57
85
|
self.port = port
|
|
58
86
|
return f"http://localhost:{port}"
|
|
59
87
|
except (ValueError, IOError):
|
|
60
88
|
pass
|
|
89
|
+
# Server not healthy, clean up stale files
|
|
90
|
+
self._cleanup_files()
|
|
61
91
|
|
|
62
|
-
# Start new server
|
|
92
|
+
# Start new server for this repo
|
|
63
93
|
self.port = self._find_free_port()
|
|
64
94
|
self._spawn_server()
|
|
65
95
|
return f"http://localhost:{self.port}"
|
|
@@ -133,6 +163,11 @@ class ServerManager:
|
|
|
133
163
|
|
|
134
164
|
self._started_by_us = True
|
|
135
165
|
|
|
166
|
+
# Write port, PID, and repo files
|
|
167
|
+
self._port_file.write_text(str(self.port))
|
|
168
|
+
self._pid_file.write_text(str(self.process.pid))
|
|
169
|
+
self._repo_file.write_text(str(self.repo_root))
|
|
170
|
+
|
|
136
171
|
# Register cleanup for normal exit
|
|
137
172
|
atexit.register(self.shutdown)
|
|
138
173
|
|
|
@@ -203,14 +238,39 @@ class ServerManager:
|
|
|
203
238
|
)
|
|
204
239
|
|
|
205
240
|
def _cleanup_files(self) -> None:
|
|
206
|
-
"""Clean up port and
|
|
207
|
-
for file in [self.
|
|
241
|
+
"""Clean up port, PID, and repo files for this repo."""
|
|
242
|
+
for file in [self._port_file, self._pid_file, self._repo_file]:
|
|
208
243
|
try:
|
|
209
244
|
if file.exists():
|
|
210
245
|
file.unlink()
|
|
211
246
|
except IOError:
|
|
212
247
|
pass
|
|
213
248
|
|
|
249
|
+
def _cleanup_stale_servers(self) -> None:
|
|
250
|
+
"""Clean up stale server files where process no longer exists."""
|
|
251
|
+
if not self.SERVERS_DIR.exists():
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
for pid_file in self.SERVERS_DIR.glob("*.pid"):
|
|
255
|
+
try:
|
|
256
|
+
pid = int(pid_file.read_text().strip())
|
|
257
|
+
# Check if process exists
|
|
258
|
+
try:
|
|
259
|
+
os.kill(pid, 0) # Signal 0 checks if process exists
|
|
260
|
+
except OSError:
|
|
261
|
+
# Process doesn't exist, clean up files
|
|
262
|
+
hash_prefix = pid_file.stem
|
|
263
|
+
for ext in [".port", ".pid", ".repo"]:
|
|
264
|
+
stale_file = self.SERVERS_DIR / f"{hash_prefix}{ext}"
|
|
265
|
+
if stale_file.exists():
|
|
266
|
+
stale_file.unlink()
|
|
267
|
+
except (ValueError, IOError):
|
|
268
|
+
# Invalid PID file, remove it
|
|
269
|
+
try:
|
|
270
|
+
pid_file.unlink()
|
|
271
|
+
except IOError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
214
274
|
|
|
215
275
|
# Global singleton for CLI commands
|
|
216
276
|
_server_manager: Optional[ServerManager] = None
|
|
@@ -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()
|