lollmsbot 0.0.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.
@@ -0,0 +1,245 @@
1
+ """
2
+ Storage package for LollmsBot.
3
+
4
+ This package provides persistence layer for conversation history and agent state.
5
+ It includes pluggable storage backends with SQLite as the default implementation,
6
+ allowing easy swapping for PostgreSQL, Redis, or custom storage solutions.
7
+
8
+ The storage layer supports:
9
+ - Conversation persistence per user with full message history
10
+ - Agent state serialization for checkpoint/resume capabilities
11
+ - Multiple backend registration and runtime selection
12
+
13
+ Example:
14
+ >>> from lollmsbot.storage import StorageRegistry, SqliteStore
15
+ >>> # Register default SQLite backend
16
+ >>> StorageRegistry.register("sqlite", SqliteStore("bot.db"))
17
+ >>> # Use in agent or gateway
18
+ >>> store = StorageRegistry.get("sqlite")
19
+ >>> store.save_conversation("user123", messages)
20
+ """
21
+
22
+ from abc import ABC, abstractmethod
23
+ from dataclasses import dataclass
24
+ from datetime import datetime
25
+ from typing import Any, Dict, List, Optional, Protocol
26
+
27
+ from lollmsbot.storage.sqlite_store import SqliteStore
28
+
29
+
30
+ class BaseStorage(ABC):
31
+ """Abstract base class for storage backends.
32
+
33
+ All storage implementations must inherit from this class and implement
34
+ the core persistence methods for conversations and agent state.
35
+
36
+ The storage backend is responsible for:
37
+ - Persisting conversation history per user
38
+ - Saving and loading agent state for resumption
39
+ - Handling connection lifecycle and error conditions
40
+
41
+ Attributes:
42
+ backend_name: Identifier for the storage backend type.
43
+ """
44
+
45
+ backend_name: str = "abstract"
46
+
47
+ @abstractmethod
48
+ async def save_conversation(self, user_id: str, messages: List[Dict[str, Any]]) -> bool:
49
+ """Save or append conversation messages for a user.
50
+
51
+ Args:
52
+ user_id: Unique identifier for the user.
53
+ messages: List of message dictionaries with 'role', 'content', 'timestamp' keys.
54
+
55
+ Returns:
56
+ True if save was successful, False otherwise.
57
+ """
58
+ pass
59
+
60
+ @abstractmethod
61
+ async def get_conversation(self, user_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
62
+ """Retrieve conversation history for a user.
63
+
64
+ Args:
65
+ user_id: Unique identifier for the user.
66
+ limit: Maximum number of recent messages to retrieve (None for all).
67
+
68
+ Returns:
69
+ List of message dictionaries ordered chronologically.
70
+ """
71
+ pass
72
+
73
+ @abstractmethod
74
+ async def save_agent_state(self, agent_id: str, state: Dict[str, Any]) -> bool:
75
+ """Serialize and save agent state for later resumption.
76
+
77
+ Args:
78
+ agent_id: Unique identifier for the agent.
79
+ state: Dictionary containing all serializable agent state.
80
+
81
+ Returns:
82
+ True if save was successful, False otherwise.
83
+ """
84
+ pass
85
+
86
+ @abstractmethod
87
+ async def load_agent_state(self, agent_id: str) -> Optional[Dict[str, Any]]:
88
+ """Load previously saved agent state.
89
+
90
+ Args:
91
+ agent_id: Unique identifier for the agent.
92
+
93
+ Returns:
94
+ State dictionary if found, None otherwise.
95
+ """
96
+ pass
97
+
98
+ @abstractmethod
99
+ async def delete_conversation(self, user_id: str) -> bool:
100
+ """Delete all conversation history for a user.
101
+
102
+ Args:
103
+ user_id: Unique identifier for the user.
104
+
105
+ Returns:
106
+ True if deletion was successful, False otherwise.
107
+ """
108
+ pass
109
+
110
+ @abstractmethod
111
+ async def delete_agent_state(self, agent_id: str) -> bool:
112
+ """Delete saved state for an agent.
113
+
114
+ Args:
115
+ agent_id: Unique identifier for the agent.
116
+
117
+ Returns:
118
+ True if deletion was successful, False otherwise.
119
+ """
120
+ pass
121
+
122
+ @abstractmethod
123
+ async def close(self) -> None:
124
+ """Close storage connections and cleanup resources."""
125
+ pass
126
+
127
+
128
+ class StorageRegistry:
129
+ """Registry for managing multiple storage backends.
130
+
131
+ Provides a centralized registry pattern for storage backend management,
132
+ allowing runtime selection and failover between different storage
133
+ implementations (SQLite, PostgreSQL, Redis, etc.).
134
+
135
+ The registry supports:
136
+ - Named backend registration
137
+ - Runtime backend retrieval
138
+ - Default backend assignment
139
+ - Bulk cleanup on shutdown
140
+
141
+ Example:
142
+ >>> StorageRegistry.register("primary", SqliteStore("primary.db"))
143
+ >>> StorageRegistry.register("cache", RedisStore("localhost:6379"))
144
+ >>> StorageRegistry.set_default("primary")
145
+ >>> store = StorageRegistry.get_default()
146
+ """
147
+
148
+ _backends: Dict[str, BaseStorage] = {}
149
+ _default: Optional[str] = None
150
+
151
+ @classmethod
152
+ def register(cls, name: str, backend: BaseStorage) -> None:
153
+ """Register a storage backend with a unique name.
154
+
155
+ Args:
156
+ name: Unique identifier for this backend instance.
157
+ backend: Configured storage backend instance.
158
+
159
+ Raises:
160
+ ValueError: If name is already registered.
161
+ """
162
+ if name in cls._backends:
163
+ raise ValueError(f"Storage backend '{name}' is already registered")
164
+ cls._backends[name] = backend
165
+
166
+ # Set as default if first registration
167
+ if cls._default is None:
168
+ cls._default = name
169
+
170
+ @classmethod
171
+ def get(cls, name: str) -> Optional[BaseStorage]:
172
+ """Retrieve a registered storage backend by name.
173
+
174
+ Args:
175
+ name: Backend identifier used during registration.
176
+
177
+ Returns:
178
+ Storage backend instance or None if not found.
179
+ """
180
+ return cls._backends.get(name)
181
+
182
+ @classmethod
183
+ def get_default(cls) -> Optional[BaseStorage]:
184
+ """Retrieve the default storage backend.
185
+
186
+ Returns:
187
+ Default backend instance or None if none set.
188
+ """
189
+ if cls._default is None:
190
+ return None
191
+ return cls._backends.get(cls._default)
192
+
193
+ @classmethod
194
+ def set_default(cls, name: str) -> None:
195
+ """Set the default storage backend.
196
+
197
+ Args:
198
+ name: Name of registered backend to set as default.
199
+
200
+ Raises:
201
+ ValueError: If backend is not registered.
202
+ """
203
+ if name not in cls._backends:
204
+ raise ValueError(f"Storage backend '{name}' is not registered")
205
+ cls._default = name
206
+
207
+ @classmethod
208
+ def unregister(cls, name: str) -> Optional[BaseStorage]:
209
+ """Remove a registered backend and cleanup.
210
+
211
+ Args:
212
+ name: Backend identifier to remove.
213
+
214
+ Returns:
215
+ Removed backend instance or None if not found.
216
+ """
217
+ backend = cls._backends.pop(name, None)
218
+ if backend and cls._default == name:
219
+ # Clear default if removed, set to another if available
220
+ cls._default = next(iter(cls._backends), None)
221
+ return backend
222
+
223
+ @classmethod
224
+ def list_backends(cls) -> List[str]:
225
+ """List all registered backend names.
226
+
227
+ Returns:
228
+ List of registered backend identifiers.
229
+ """
230
+ return list(cls._backends.keys())
231
+
232
+ @classmethod
233
+ async def close_all(cls) -> None:
234
+ """Close all registered storage backends."""
235
+ for backend in cls._backends.values():
236
+ await backend.close()
237
+ cls._backends.clear()
238
+ cls._default = None
239
+
240
+
241
+ __all__ = [
242
+ "BaseStorage",
243
+ "SqliteStore",
244
+ "StorageRegistry",
245
+ ]
@@ -0,0 +1,332 @@
1
+ """
2
+ SQLite storage backend for LollmsBot.
3
+
4
+ Provides persistent storage using SQLite with async support via aiosqlite.
5
+ Implements conversation history and agent state persistence.
6
+ """
7
+
8
+ import json
9
+ import sqlite3
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import aiosqlite
15
+
16
+ from lollmsbot.storage import BaseStorage
17
+
18
+
19
+ class SqliteStore(BaseStorage):
20
+ """SQLite-based storage backend.
21
+
22
+ Implements BaseStorage interface using SQLite for persistence.
23
+ Supports async operations and JSON serialization for complex data.
24
+
25
+ Attributes:
26
+ db_path: Path to the SQLite database file.
27
+ _connection: Async database connection (initialized in _init_db).
28
+ """
29
+
30
+ backend_name: str = "sqlite"
31
+
32
+ def __init__(self, db_path: str = "lollmsbot.db") -> None:
33
+ """Initialize SqliteStore with database path.
34
+
35
+ Args:
36
+ db_path: Path to SQLite database file. Defaults to 'lollmsbot.db'.
37
+ """
38
+ self.db_path: str = db_path
39
+ self._connection: Optional[aiosqlite.Connection] = None
40
+
41
+ async def _init_db(self) -> None:
42
+ """Initialize database connection and create tables.
43
+
44
+ Creates required tables if they don't exist:
45
+ - conversations: Stores conversation metadata
46
+ - messages: Stores individual messages with foreign key to conversations
47
+ - agent_states: Stores serialized agent state
48
+ """
49
+ self._connection = await aiosqlite.connect(self.db_path)
50
+
51
+ # Enable foreign keys
52
+ await self._connection.execute("PRAGMA foreign_keys = ON")
53
+
54
+ # Create conversations table
55
+ await self._connection.execute("""
56
+ CREATE TABLE IF NOT EXISTS conversations (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ user_id TEXT NOT NULL,
59
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
60
+ )
61
+ """)
62
+
63
+ # Create messages table
64
+ await self._connection.execute("""
65
+ CREATE TABLE IF NOT EXISTS messages (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ conversation_id INTEGER NOT NULL,
68
+ role TEXT NOT NULL,
69
+ content TEXT NOT NULL,
70
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
71
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
72
+ )
73
+ """)
74
+
75
+ # Create agent_states table
76
+ await self._connection.execute("""
77
+ CREATE TABLE IF NOT EXISTS agent_states (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ agent_id TEXT NOT NULL UNIQUE,
80
+ state_json TEXT NOT NULL,
81
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
82
+ )
83
+ """)
84
+
85
+ # Create indexes for performance
86
+ await self._connection.execute("""
87
+ CREATE INDEX IF NOT EXISTS idx_conversations_user_id ON conversations(user_id)
88
+ """)
89
+ await self._connection.execute("""
90
+ CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id)
91
+ """)
92
+ await self._connection.execute("""
93
+ CREATE INDEX IF NOT EXISTS idx_agent_states_agent_id ON agent_states(agent_id)
94
+ """)
95
+
96
+ await self._connection.commit()
97
+
98
+ async def _ensure_initialized(self) -> aiosqlite.Connection:
99
+ """Ensure database is initialized and return connection.
100
+
101
+ Returns:
102
+ Active database connection.
103
+ """
104
+ if self._connection is None:
105
+ await self._init_db()
106
+ return self._connection
107
+
108
+ async def save_conversation(self, user_id: str, messages: List[Dict[str, Any]]) -> bool:
109
+ """Save or append conversation messages for a user.
110
+
111
+ Creates a new conversation entry and stores all messages.
112
+ Previous conversations for the user are preserved.
113
+
114
+ Args:
115
+ user_id: Unique identifier for the user.
116
+ messages: List of message dictionaries with 'role', 'content', 'timestamp' keys.
117
+
118
+ Returns:
119
+ True if save was successful, False otherwise.
120
+ """
121
+ try:
122
+ conn = await self._ensure_initialized()
123
+
124
+ async with conn.cursor() as cursor:
125
+ # Insert conversation record
126
+ await cursor.execute(
127
+ "INSERT INTO conversations (user_id, created_at) VALUES (?, ?)",
128
+ (user_id, datetime.now().isoformat())
129
+ )
130
+ conversation_id = cursor.lastrowid
131
+
132
+ # Insert all messages
133
+ for msg in messages:
134
+ timestamp = msg.get("timestamp")
135
+ if isinstance(timestamp, datetime):
136
+ timestamp = timestamp.isoformat()
137
+ elif timestamp is None:
138
+ timestamp = datetime.now().isoformat()
139
+
140
+ await cursor.execute(
141
+ "INSERT INTO messages (conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
142
+ (conversation_id, msg.get("role", "unknown"), msg.get("content", ""), timestamp)
143
+ )
144
+
145
+ await conn.commit()
146
+ return True
147
+
148
+ except Exception:
149
+ return False
150
+
151
+ async def get_conversation(self, user_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
152
+ """Retrieve conversation history for a user.
153
+
154
+ Returns messages from the most recent conversation for the user.
155
+
156
+ Args:
157
+ user_id: Unique identifier for the user.
158
+ limit: Maximum number of recent messages to retrieve (None for all).
159
+
160
+ Returns:
161
+ List of message dictionaries ordered chronologically.
162
+ """
163
+ try:
164
+ conn = await self._ensure_initialized()
165
+
166
+ async with conn.cursor() as cursor:
167
+ # Find the most recent conversation for this user
168
+ await cursor.execute(
169
+ "SELECT id FROM conversations WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
170
+ (user_id,)
171
+ )
172
+ row = await cursor.fetchone()
173
+
174
+ if row is None:
175
+ return []
176
+
177
+ conversation_id = row[0]
178
+
179
+ # Build query with optional limit
180
+ query = """
181
+ SELECT role, content, timestamp
182
+ FROM messages
183
+ WHERE conversation_id = ?
184
+ ORDER BY timestamp ASC
185
+ """
186
+ params: List[Any] = [conversation_id]
187
+
188
+ if limit is not None:
189
+ query = f"""
190
+ SELECT role, content, timestamp
191
+ FROM messages
192
+ WHERE conversation_id = ?
193
+ ORDER BY timestamp DESC
194
+ LIMIT ?
195
+ """
196
+ params.append(limit)
197
+
198
+ await cursor.execute(query, params)
199
+ rows = await cursor.fetchall()
200
+
201
+ # Reverse if we used DESC with limit to get chronological order
202
+ if limit is not None:
203
+ rows = list(reversed(rows))
204
+
205
+ messages = []
206
+ for role, content, timestamp in rows:
207
+ msg: Dict[str, Any] = {
208
+ "role": role,
209
+ "content": content,
210
+ }
211
+ if timestamp:
212
+ msg["timestamp"] = timestamp
213
+ messages.append(msg)
214
+
215
+ return messages
216
+
217
+ except Exception:
218
+ return []
219
+
220
+ async def save_agent_state(self, agent_id: str, state: Dict[str, Any]) -> bool:
221
+ """Serialize and save agent state for later resumption.
222
+
223
+ Args:
224
+ agent_id: Unique identifier for the agent.
225
+ state: Dictionary containing all serializable agent state.
226
+
227
+ Returns:
228
+ True if save was successful, False otherwise.
229
+ """
230
+ try:
231
+ conn = await self._ensure_initialized()
232
+
233
+ # Serialize state to JSON
234
+ state_json = json.dumps(state, default=str)
235
+
236
+ async with conn.cursor() as cursor:
237
+ # Use UPSERT pattern for SQLite
238
+ await cursor.execute("""
239
+ INSERT INTO agent_states (agent_id, state_json, updated_at)
240
+ VALUES (?, ?, ?)
241
+ ON CONFLICT(agent_id) DO UPDATE SET
242
+ state_json = excluded.state_json,
243
+ updated_at = excluded.updated_at
244
+ """, (agent_id, state_json, datetime.now().isoformat()))
245
+
246
+ await conn.commit()
247
+ return True
248
+
249
+ except Exception:
250
+ return False
251
+
252
+ async def load_agent_state(self, agent_id: str) -> Optional[Dict[str, Any]]:
253
+ """Load previously saved agent state.
254
+
255
+ Args:
256
+ agent_id: Unique identifier for the agent.
257
+
258
+ Returns:
259
+ State dictionary if found, None otherwise.
260
+ """
261
+ try:
262
+ conn = await self._ensure_initialized()
263
+
264
+ async with conn.cursor() as cursor:
265
+ await cursor.execute(
266
+ "SELECT state_json FROM agent_states WHERE agent_id = ?",
267
+ (agent_id,)
268
+ )
269
+ row = await cursor.fetchone()
270
+
271
+ if row is None:
272
+ return None
273
+
274
+ # Deserialize JSON
275
+ state: Dict[str, Any] = json.loads(row[0])
276
+ return state
277
+
278
+ except Exception:
279
+ return None
280
+
281
+ async def delete_conversation(self, user_id: str) -> bool:
282
+ """Delete all conversation history for a user.
283
+
284
+ Args:
285
+ user_id: Unique identifier for the user.
286
+
287
+ Returns:
288
+ True if deletion was successful, False otherwise.
289
+ """
290
+ try:
291
+ conn = await self._ensure_initialized()
292
+
293
+ async with conn.cursor() as cursor:
294
+ # Delete conversations (cascades to messages via foreign key)
295
+ await cursor.execute(
296
+ "DELETE FROM conversations WHERE user_id = ?",
297
+ (user_id,)
298
+ )
299
+ await conn.commit()
300
+ return True
301
+
302
+ except Exception:
303
+ return False
304
+
305
+ async def delete_agent_state(self, agent_id: str) -> bool:
306
+ """Delete saved state for an agent.
307
+
308
+ Args:
309
+ agent_id: Unique identifier for the agent.
310
+
311
+ Returns:
312
+ True if deletion was successful, False otherwise.
313
+ """
314
+ try:
315
+ conn = await self._ensure_initialized()
316
+
317
+ async with conn.cursor() as cursor:
318
+ await cursor.execute(
319
+ "DELETE FROM agent_states WHERE agent_id = ?",
320
+ (agent_id,)
321
+ )
322
+ await conn.commit()
323
+ return cursor.rowcount > 0
324
+
325
+ except Exception:
326
+ return False
327
+
328
+ async def close(self) -> None:
329
+ """Close storage connections and cleanup resources."""
330
+ if self._connection is not None:
331
+ await self._connection.close()
332
+ self._connection = None