aloop 0.1.1__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.
- agent/__init__.py +0 -0
- agent/agent.py +182 -0
- agent/base.py +406 -0
- agent/context.py +126 -0
- agent/prompts/__init__.py +1 -0
- agent/todo.py +149 -0
- agent/tool_executor.py +54 -0
- agent/verification.py +135 -0
- aloop-0.1.1.dist-info/METADATA +252 -0
- aloop-0.1.1.dist-info/RECORD +66 -0
- aloop-0.1.1.dist-info/WHEEL +5 -0
- aloop-0.1.1.dist-info/entry_points.txt +2 -0
- aloop-0.1.1.dist-info/licenses/LICENSE +21 -0
- aloop-0.1.1.dist-info/top_level.txt +9 -0
- cli.py +19 -0
- config.py +146 -0
- interactive.py +865 -0
- llm/__init__.py +51 -0
- llm/base.py +26 -0
- llm/compat.py +226 -0
- llm/content_utils.py +309 -0
- llm/litellm_adapter.py +450 -0
- llm/message_types.py +245 -0
- llm/model_manager.py +265 -0
- llm/retry.py +95 -0
- main.py +246 -0
- memory/__init__.py +20 -0
- memory/compressor.py +554 -0
- memory/manager.py +538 -0
- memory/serialization.py +82 -0
- memory/short_term.py +88 -0
- memory/store/__init__.py +6 -0
- memory/store/memory_store.py +100 -0
- memory/store/yaml_file_memory_store.py +414 -0
- memory/token_tracker.py +203 -0
- memory/types.py +51 -0
- tools/__init__.py +6 -0
- tools/advanced_file_ops.py +557 -0
- tools/base.py +51 -0
- tools/calculator.py +50 -0
- tools/code_navigator.py +975 -0
- tools/explore.py +254 -0
- tools/file_ops.py +150 -0
- tools/git_tools.py +791 -0
- tools/notify.py +69 -0
- tools/parallel_execute.py +420 -0
- tools/session_manager.py +205 -0
- tools/shell.py +147 -0
- tools/shell_background.py +470 -0
- tools/smart_edit.py +491 -0
- tools/todo.py +130 -0
- tools/web_fetch.py +673 -0
- tools/web_search.py +61 -0
- utils/__init__.py +15 -0
- utils/logger.py +105 -0
- utils/model_pricing.py +49 -0
- utils/runtime.py +75 -0
- utils/terminal_ui.py +422 -0
- utils/tui/__init__.py +39 -0
- utils/tui/command_registry.py +49 -0
- utils/tui/components.py +306 -0
- utils/tui/input_handler.py +393 -0
- utils/tui/model_ui.py +204 -0
- utils/tui/progress.py +292 -0
- utils/tui/status_bar.py +178 -0
- utils/tui/theme.py +165 -0
memory/store/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Abstract base class for memory persistence backends."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from llm.message_types import LLMMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryStore(ABC):
|
|
10
|
+
"""Abstract interface for memory persistence.
|
|
11
|
+
|
|
12
|
+
All memory storage implementations (YAML files, SQLite, etc.)
|
|
13
|
+
must implement this interface.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def create_session(self, metadata: Optional[Dict[str, Any]] = None) -> str:
|
|
18
|
+
"""Create a new session.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
metadata: Optional session metadata
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Session ID (UUID string)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def save_message(self, session_id: str, message: LLMMessage, tokens: int = 0) -> None:
|
|
29
|
+
"""Save a single message to a session.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
session_id: Session ID
|
|
33
|
+
message: LLMMessage to save
|
|
34
|
+
tokens: Token count for this message
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def save_memory(
|
|
39
|
+
self,
|
|
40
|
+
session_id: str,
|
|
41
|
+
system_messages: List[LLMMessage],
|
|
42
|
+
messages: List[LLMMessage],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Save complete memory state (replaces existing data).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
session_id: Session ID
|
|
48
|
+
system_messages: List of system messages
|
|
49
|
+
messages: List of regular messages
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def load_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
54
|
+
"""Load complete session state.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
session_id: Session ID
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dictionary with session data or None if not found:
|
|
61
|
+
{
|
|
62
|
+
"system_messages": [LLMMessage],
|
|
63
|
+
"messages": [LLMMessage],
|
|
64
|
+
"stats": {"created_at": str, ...}
|
|
65
|
+
}
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def list_sessions(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
|
70
|
+
"""List sessions ordered by most recent first.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
limit: Maximum number of sessions to return
|
|
74
|
+
offset: Offset for pagination
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of session summaries with id, created_at, message_count, etc.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
async def delete_session(self, session_id: str) -> bool:
|
|
82
|
+
"""Delete a session and all its data.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
session_id: Session ID
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if deleted, False if not found
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
async def get_session_stats(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
93
|
+
"""Get session statistics.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
session_id: Session ID
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Session statistics or None if not found
|
|
100
|
+
"""
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""YAML file-based memory persistence backend.
|
|
2
|
+
|
|
3
|
+
Stores each session as a human-readable YAML file under .aloop/sessions/.
|
|
4
|
+
Directory structure: .aloop/sessions/YYYY-MM-DD_<uuid[:8]>/session.yaml
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import aiofiles
|
|
15
|
+
import aiofiles.os
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from llm.message_types import LLMMessage
|
|
19
|
+
from memory.serialization import (
|
|
20
|
+
deserialize_message,
|
|
21
|
+
serialize_message,
|
|
22
|
+
)
|
|
23
|
+
from memory.store.memory_store import MemoryStore
|
|
24
|
+
from utils.runtime import get_sessions_dir
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class YamlFileMemoryStore(MemoryStore):
|
|
30
|
+
"""YAML file-based persistence backend.
|
|
31
|
+
|
|
32
|
+
Each session is stored as a directory containing a session.yaml file.
|
|
33
|
+
An .index.yaml file maps session UUIDs to directory names for fast lookup.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, sessions_dir: Optional[str] = None):
|
|
37
|
+
"""Initialize YAML file backend.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
sessions_dir: Path to sessions directory (default: .aloop/sessions/)
|
|
41
|
+
"""
|
|
42
|
+
self.sessions_dir = sessions_dir or get_sessions_dir()
|
|
43
|
+
self._write_lock = asyncio.Lock()
|
|
44
|
+
self._index: Optional[Dict[str, str]] = None # UUID -> dir_name
|
|
45
|
+
|
|
46
|
+
async def _ensure_dir(self) -> None:
|
|
47
|
+
"""Ensure sessions directory exists."""
|
|
48
|
+
await aiofiles.os.makedirs(self.sessions_dir, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
def _session_dir_name(self, session_id: str, created_at: datetime) -> str:
|
|
51
|
+
"""Generate directory name for a session.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
session_id: Session UUID
|
|
55
|
+
created_at: Session creation time
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Directory name like "2025-01-31_a1b2c3d4"
|
|
59
|
+
"""
|
|
60
|
+
date_str = created_at.strftime("%Y-%m-%d")
|
|
61
|
+
short_id = session_id[:8]
|
|
62
|
+
return f"{date_str}_{short_id}"
|
|
63
|
+
|
|
64
|
+
def _session_yaml_path(self, dir_name: str) -> str:
|
|
65
|
+
"""Get path to session.yaml within a session directory."""
|
|
66
|
+
return os.path.join(self.sessions_dir, dir_name, "session.yaml")
|
|
67
|
+
|
|
68
|
+
def _index_path(self) -> str:
|
|
69
|
+
"""Get path to the index file."""
|
|
70
|
+
return os.path.join(self.sessions_dir, ".index.yaml")
|
|
71
|
+
|
|
72
|
+
async def _load_index(self) -> Dict[str, str]:
|
|
73
|
+
"""Load or rebuild the UUID -> dir_name index.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dict mapping session UUID to directory name
|
|
77
|
+
"""
|
|
78
|
+
if self._index is not None:
|
|
79
|
+
return self._index
|
|
80
|
+
|
|
81
|
+
index_path = self._index_path()
|
|
82
|
+
if await asyncio.to_thread(os.path.exists, index_path):
|
|
83
|
+
try:
|
|
84
|
+
async with aiofiles.open(index_path, encoding="utf-8") as f:
|
|
85
|
+
content = await f.read()
|
|
86
|
+
self._index = yaml.safe_load(content) or {}
|
|
87
|
+
return self._index
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.warning("Failed to load index, rebuilding")
|
|
90
|
+
|
|
91
|
+
# Rebuild index by scanning directories
|
|
92
|
+
self._index = await self._rebuild_index()
|
|
93
|
+
return self._index
|
|
94
|
+
|
|
95
|
+
async def _rebuild_index(self) -> Dict[str, str]:
|
|
96
|
+
"""Rebuild index by scanning session directories.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dict mapping session UUID to directory name
|
|
100
|
+
"""
|
|
101
|
+
index: Dict[str, str] = {}
|
|
102
|
+
if not await asyncio.to_thread(os.path.exists, self.sessions_dir):
|
|
103
|
+
self._index = index
|
|
104
|
+
return index
|
|
105
|
+
|
|
106
|
+
entries = await asyncio.to_thread(os.listdir, self.sessions_dir)
|
|
107
|
+
for entry in entries:
|
|
108
|
+
if entry.startswith("."):
|
|
109
|
+
continue
|
|
110
|
+
yaml_path = self._session_yaml_path(entry)
|
|
111
|
+
if not await asyncio.to_thread(os.path.exists, yaml_path):
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
async with aiofiles.open(yaml_path, encoding="utf-8") as f:
|
|
115
|
+
content = await f.read()
|
|
116
|
+
data = yaml.safe_load(content)
|
|
117
|
+
if data and "id" in data:
|
|
118
|
+
index[data["id"]] = entry
|
|
119
|
+
except Exception:
|
|
120
|
+
logger.warning(f"Failed to read session from {entry}")
|
|
121
|
+
|
|
122
|
+
self._index = index
|
|
123
|
+
await self._save_index(index)
|
|
124
|
+
return index
|
|
125
|
+
|
|
126
|
+
async def _save_index(self, index: Dict[str, str]) -> None:
|
|
127
|
+
"""Save index to disk."""
|
|
128
|
+
index_path = self._index_path()
|
|
129
|
+
tmp_path = index_path + ".tmp"
|
|
130
|
+
content = yaml.dump(index, default_flow_style=False, allow_unicode=True)
|
|
131
|
+
async with aiofiles.open(tmp_path, "w", encoding="utf-8") as f:
|
|
132
|
+
await f.write(content)
|
|
133
|
+
await asyncio.to_thread(os.replace, tmp_path, index_path)
|
|
134
|
+
|
|
135
|
+
async def _load_session_data(self, dir_name: str) -> Optional[Dict[str, Any]]:
|
|
136
|
+
"""Load raw YAML data from a session directory.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
dir_name: Session directory name
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Parsed YAML data or None
|
|
143
|
+
"""
|
|
144
|
+
yaml_path = self._session_yaml_path(dir_name)
|
|
145
|
+
if not await asyncio.to_thread(os.path.exists, yaml_path):
|
|
146
|
+
return None
|
|
147
|
+
async with aiofiles.open(yaml_path, encoding="utf-8") as f:
|
|
148
|
+
content = await f.read()
|
|
149
|
+
return yaml.safe_load(content)
|
|
150
|
+
|
|
151
|
+
async def _save_session_data(self, dir_name: str, data: Dict[str, Any]) -> None:
|
|
152
|
+
"""Atomically write session data to YAML file.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
dir_name: Session directory name
|
|
156
|
+
data: Session data to write
|
|
157
|
+
"""
|
|
158
|
+
session_dir = os.path.join(self.sessions_dir, dir_name)
|
|
159
|
+
await aiofiles.os.makedirs(session_dir, exist_ok=True)
|
|
160
|
+
|
|
161
|
+
yaml_path = self._session_yaml_path(dir_name)
|
|
162
|
+
tmp_path = yaml_path + ".tmp"
|
|
163
|
+
|
|
164
|
+
content = yaml.dump(
|
|
165
|
+
data,
|
|
166
|
+
default_flow_style=False,
|
|
167
|
+
allow_unicode=True,
|
|
168
|
+
sort_keys=False,
|
|
169
|
+
width=120,
|
|
170
|
+
)
|
|
171
|
+
async with aiofiles.open(tmp_path, "w", encoding="utf-8") as f:
|
|
172
|
+
await f.write(content)
|
|
173
|
+
await asyncio.to_thread(os.replace, tmp_path, yaml_path)
|
|
174
|
+
|
|
175
|
+
async def _resolve_session_dir(self, session_id: str) -> Optional[str]:
|
|
176
|
+
"""Resolve a session ID to its directory name.
|
|
177
|
+
|
|
178
|
+
Supports full UUID and prefix matching.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
session_id: Full or prefix of session UUID
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Directory name or None
|
|
185
|
+
"""
|
|
186
|
+
index = await self._load_index()
|
|
187
|
+
|
|
188
|
+
# Exact match
|
|
189
|
+
if session_id in index:
|
|
190
|
+
return index[session_id]
|
|
191
|
+
|
|
192
|
+
# Prefix match
|
|
193
|
+
matches = [(sid, dir_name) for sid, dir_name in index.items() if sid.startswith(session_id)]
|
|
194
|
+
if len(matches) == 1:
|
|
195
|
+
return matches[0][1]
|
|
196
|
+
elif len(matches) > 1:
|
|
197
|
+
logger.warning(f"Ambiguous session prefix '{session_id}', {len(matches)} matches")
|
|
198
|
+
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
async def create_session(self, metadata: Optional[Dict[str, Any]] = None) -> str:
|
|
202
|
+
await self._ensure_dir()
|
|
203
|
+
|
|
204
|
+
session_id = str(uuid.uuid4())
|
|
205
|
+
now = datetime.now()
|
|
206
|
+
dir_name = self._session_dir_name(session_id, now)
|
|
207
|
+
|
|
208
|
+
data = {
|
|
209
|
+
"id": session_id,
|
|
210
|
+
"created_at": now.isoformat(),
|
|
211
|
+
"updated_at": now.isoformat(),
|
|
212
|
+
"system_messages": [],
|
|
213
|
+
"messages": [],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async with self._write_lock:
|
|
217
|
+
await self._save_session_data(dir_name, data)
|
|
218
|
+
index = await self._load_index()
|
|
219
|
+
index[session_id] = dir_name
|
|
220
|
+
await self._save_index(index)
|
|
221
|
+
|
|
222
|
+
logger.info(f"Created session {session_id} in {dir_name}")
|
|
223
|
+
return session_id
|
|
224
|
+
|
|
225
|
+
async def save_message(self, session_id: str, message: LLMMessage, tokens: int = 0) -> None:
|
|
226
|
+
dir_name = await self._resolve_session_dir(session_id)
|
|
227
|
+
if not dir_name:
|
|
228
|
+
logger.warning(f"Session {session_id} not found")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
async with self._write_lock:
|
|
232
|
+
data = await self._load_session_data(dir_name)
|
|
233
|
+
if not data:
|
|
234
|
+
logger.warning(f"Session {session_id} not found")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
field = "system_messages" if message.role == "system" else "messages"
|
|
238
|
+
msg_data = serialize_message(message)
|
|
239
|
+
msg_data["tokens"] = tokens
|
|
240
|
+
data[field].append(msg_data)
|
|
241
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
242
|
+
|
|
243
|
+
await self._save_session_data(dir_name, data)
|
|
244
|
+
|
|
245
|
+
async def save_memory(
|
|
246
|
+
self,
|
|
247
|
+
session_id: str,
|
|
248
|
+
system_messages: List[LLMMessage],
|
|
249
|
+
messages: List[LLMMessage],
|
|
250
|
+
) -> None:
|
|
251
|
+
dir_name = await self._resolve_session_dir(session_id)
|
|
252
|
+
if not dir_name:
|
|
253
|
+
logger.warning(f"Session {session_id} not found")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
async with self._write_lock:
|
|
257
|
+
data = await self._load_session_data(dir_name)
|
|
258
|
+
if not data:
|
|
259
|
+
logger.warning(f"Session {session_id} not found")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
data["system_messages"] = [serialize_message(msg) for msg in system_messages]
|
|
263
|
+
|
|
264
|
+
messages_list = []
|
|
265
|
+
for msg in messages:
|
|
266
|
+
msg_data = serialize_message(msg)
|
|
267
|
+
msg_data["tokens"] = 0
|
|
268
|
+
messages_list.append(msg_data)
|
|
269
|
+
data["messages"] = messages_list
|
|
270
|
+
|
|
271
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
272
|
+
|
|
273
|
+
await self._save_session_data(dir_name, data)
|
|
274
|
+
|
|
275
|
+
logger.debug(
|
|
276
|
+
f"Saved memory for session {session_id}: "
|
|
277
|
+
f"{len(system_messages)} system msgs, "
|
|
278
|
+
f"{len(messages)} messages"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def load_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
282
|
+
dir_name = await self._resolve_session_dir(session_id)
|
|
283
|
+
if not dir_name:
|
|
284
|
+
logger.warning(f"Session {session_id} not found")
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
data = await self._load_session_data(dir_name)
|
|
288
|
+
if not data:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
system_messages = [deserialize_message(msg) for msg in (data.get("system_messages") or [])]
|
|
292
|
+
messages = [deserialize_message(msg) for msg in (data.get("messages") or [])]
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
"config": None,
|
|
296
|
+
"system_messages": system_messages,
|
|
297
|
+
"messages": messages,
|
|
298
|
+
"stats": {
|
|
299
|
+
"created_at": data.get("created_at", ""),
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async def list_sessions(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
|
304
|
+
await self._ensure_dir()
|
|
305
|
+
index = await self._load_index()
|
|
306
|
+
|
|
307
|
+
sessions = []
|
|
308
|
+
for session_id, dir_name in index.items():
|
|
309
|
+
data = await self._load_session_data(dir_name)
|
|
310
|
+
if not data:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
messages_data = data.get("messages") or []
|
|
314
|
+
system_messages_data = data.get("system_messages") or []
|
|
315
|
+
|
|
316
|
+
# Extract first user message as preview
|
|
317
|
+
first_user_msg = ""
|
|
318
|
+
for msg in messages_data:
|
|
319
|
+
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
|
320
|
+
first_user_msg = msg["content"][:100]
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
sessions.append(
|
|
324
|
+
{
|
|
325
|
+
"id": session_id,
|
|
326
|
+
"created_at": data.get("created_at", ""),
|
|
327
|
+
"updated_at": data.get("updated_at", ""),
|
|
328
|
+
"message_count": len(messages_data),
|
|
329
|
+
"system_message_count": len(system_messages_data),
|
|
330
|
+
"preview": first_user_msg,
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Sort by updated_at descending
|
|
335
|
+
sessions.sort(key=lambda s: s.get("updated_at", ""), reverse=True)
|
|
336
|
+
|
|
337
|
+
return sessions[offset : offset + limit]
|
|
338
|
+
|
|
339
|
+
async def delete_session(self, session_id: str) -> bool:
|
|
340
|
+
dir_name = await self._resolve_session_dir(session_id)
|
|
341
|
+
if not dir_name:
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
session_dir = os.path.join(self.sessions_dir, dir_name)
|
|
345
|
+
|
|
346
|
+
async with self._write_lock:
|
|
347
|
+
# Remove files in directory
|
|
348
|
+
if await asyncio.to_thread(os.path.exists, session_dir):
|
|
349
|
+
entries = await asyncio.to_thread(os.listdir, session_dir)
|
|
350
|
+
for entry in entries:
|
|
351
|
+
entry_path = os.path.join(session_dir, entry)
|
|
352
|
+
await aiofiles.os.remove(entry_path)
|
|
353
|
+
await asyncio.to_thread(os.rmdir, session_dir)
|
|
354
|
+
|
|
355
|
+
# Update index
|
|
356
|
+
index = await self._load_index()
|
|
357
|
+
# Find and remove by dir_name (session_id might be prefix)
|
|
358
|
+
to_remove = [sid for sid, dn in index.items() if dn == dir_name]
|
|
359
|
+
for sid in to_remove:
|
|
360
|
+
del index[sid]
|
|
361
|
+
await self._save_index(index)
|
|
362
|
+
|
|
363
|
+
logger.info(f"Deleted session {session_id}")
|
|
364
|
+
return True
|
|
365
|
+
|
|
366
|
+
async def get_session_stats(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
367
|
+
dir_name = await self._resolve_session_dir(session_id)
|
|
368
|
+
if not dir_name:
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
data = await self._load_session_data(dir_name)
|
|
372
|
+
if not data:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
messages_data = data.get("messages") or []
|
|
376
|
+
system_messages_data = data.get("system_messages") or []
|
|
377
|
+
total_message_tokens = sum(m.get("tokens", 0) for m in messages_data)
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
"session_id": session_id,
|
|
381
|
+
"created_at": data.get("created_at", ""),
|
|
382
|
+
"updated_at": data.get("updated_at", ""),
|
|
383
|
+
"message_count": len(messages_data),
|
|
384
|
+
"system_message_count": len(system_messages_data),
|
|
385
|
+
"total_message_tokens": total_message_tokens,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async def find_latest_session(self) -> Optional[str]:
|
|
389
|
+
"""Find the most recently updated session ID.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Session ID or None if no sessions exist
|
|
393
|
+
"""
|
|
394
|
+
sessions = await self.list_sessions(limit=1)
|
|
395
|
+
if sessions:
|
|
396
|
+
return sessions[0]["id"]
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
async def find_session_by_prefix(self, prefix: str) -> Optional[str]:
|
|
400
|
+
"""Find a session by ID prefix.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
prefix: Prefix of session UUID
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Full session ID or None
|
|
407
|
+
"""
|
|
408
|
+
index = await self._load_index()
|
|
409
|
+
matches = [sid for sid in index if sid.startswith(prefix)]
|
|
410
|
+
if len(matches) == 1:
|
|
411
|
+
return matches[0]
|
|
412
|
+
elif len(matches) > 1:
|
|
413
|
+
logger.warning(f"Ambiguous prefix '{prefix}': {len(matches)} matches")
|
|
414
|
+
return None
|