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.
Files changed (66) hide show
  1. agent/__init__.py +0 -0
  2. agent/agent.py +182 -0
  3. agent/base.py +406 -0
  4. agent/context.py +126 -0
  5. agent/prompts/__init__.py +1 -0
  6. agent/todo.py +149 -0
  7. agent/tool_executor.py +54 -0
  8. agent/verification.py +135 -0
  9. aloop-0.1.1.dist-info/METADATA +252 -0
  10. aloop-0.1.1.dist-info/RECORD +66 -0
  11. aloop-0.1.1.dist-info/WHEEL +5 -0
  12. aloop-0.1.1.dist-info/entry_points.txt +2 -0
  13. aloop-0.1.1.dist-info/licenses/LICENSE +21 -0
  14. aloop-0.1.1.dist-info/top_level.txt +9 -0
  15. cli.py +19 -0
  16. config.py +146 -0
  17. interactive.py +865 -0
  18. llm/__init__.py +51 -0
  19. llm/base.py +26 -0
  20. llm/compat.py +226 -0
  21. llm/content_utils.py +309 -0
  22. llm/litellm_adapter.py +450 -0
  23. llm/message_types.py +245 -0
  24. llm/model_manager.py +265 -0
  25. llm/retry.py +95 -0
  26. main.py +246 -0
  27. memory/__init__.py +20 -0
  28. memory/compressor.py +554 -0
  29. memory/manager.py +538 -0
  30. memory/serialization.py +82 -0
  31. memory/short_term.py +88 -0
  32. memory/store/__init__.py +6 -0
  33. memory/store/memory_store.py +100 -0
  34. memory/store/yaml_file_memory_store.py +414 -0
  35. memory/token_tracker.py +203 -0
  36. memory/types.py +51 -0
  37. tools/__init__.py +6 -0
  38. tools/advanced_file_ops.py +557 -0
  39. tools/base.py +51 -0
  40. tools/calculator.py +50 -0
  41. tools/code_navigator.py +975 -0
  42. tools/explore.py +254 -0
  43. tools/file_ops.py +150 -0
  44. tools/git_tools.py +791 -0
  45. tools/notify.py +69 -0
  46. tools/parallel_execute.py +420 -0
  47. tools/session_manager.py +205 -0
  48. tools/shell.py +147 -0
  49. tools/shell_background.py +470 -0
  50. tools/smart_edit.py +491 -0
  51. tools/todo.py +130 -0
  52. tools/web_fetch.py +673 -0
  53. tools/web_search.py +61 -0
  54. utils/__init__.py +15 -0
  55. utils/logger.py +105 -0
  56. utils/model_pricing.py +49 -0
  57. utils/runtime.py +75 -0
  58. utils/terminal_ui.py +422 -0
  59. utils/tui/__init__.py +39 -0
  60. utils/tui/command_registry.py +49 -0
  61. utils/tui/components.py +306 -0
  62. utils/tui/input_handler.py +393 -0
  63. utils/tui/model_ui.py +204 -0
  64. utils/tui/progress.py +292 -0
  65. utils/tui/status_bar.py +178 -0
  66. utils/tui/theme.py +165 -0
@@ -0,0 +1,6 @@
1
+ """Memory store implementations for session persistence."""
2
+
3
+ from .memory_store import MemoryStore
4
+ from .yaml_file_memory_store import YamlFileMemoryStore
5
+
6
+ __all__ = ["MemoryStore", "YamlFileMemoryStore"]
@@ -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