sqlsaber 0.13.0__py3-none-any.whl → 0.15.0__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.

Potentially problematic release.


This version of sqlsaber might be problematic. Click here for more details.

@@ -0,0 +1,224 @@
1
+ """Manager for conversation storage operations."""
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from .models import Conversation, ConversationMessage
8
+ from .storage import ConversationStorage
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ConversationManager:
14
+ """High-level manager for conversation storage operations."""
15
+
16
+ def __init__(self):
17
+ """Initialize conversation manager."""
18
+ self._storage = ConversationStorage()
19
+
20
+ async def start_conversation(self, database_name: str) -> str:
21
+ """Start a new conversation.
22
+
23
+ Args:
24
+ database_name: Name of the database for this conversation
25
+
26
+ Returns:
27
+ Conversation ID
28
+ """
29
+ try:
30
+ return await self._storage.create_conversation(database_name)
31
+ except Exception as e:
32
+ logger.warning(f"Failed to start conversation: {e}")
33
+ return str(uuid.uuid4())
34
+
35
+ async def add_user_message(
36
+ self, conversation_id: str, content: str | dict[str, Any], index: int
37
+ ) -> str:
38
+ """Add a user message to the conversation.
39
+
40
+ Args:
41
+ conversation_id: ID of the conversation
42
+ content: Message content
43
+ index: Sequential index in conversation
44
+
45
+ Returns:
46
+ Message ID
47
+ """
48
+ try:
49
+ return await self._storage.add_message(
50
+ conversation_id, "user", content, index
51
+ )
52
+ except Exception as e:
53
+ logger.warning(f"Failed to add user message: {e}")
54
+ return str(uuid.uuid4())
55
+
56
+ async def add_assistant_message(
57
+ self,
58
+ conversation_id: str,
59
+ content: list[dict[str, Any]] | dict[str, Any],
60
+ index: int,
61
+ ) -> str:
62
+ """Add an assistant message to the conversation.
63
+
64
+ Args:
65
+ conversation_id: ID of the conversation
66
+ content: Message content (typically ContentBlock list)
67
+ index: Sequential index in conversation
68
+
69
+ Returns:
70
+ Message ID
71
+ """
72
+ try:
73
+ return await self._storage.add_message(
74
+ conversation_id, "assistant", content, index
75
+ )
76
+ except Exception as e:
77
+ logger.warning(f"Failed to add assistant message: {e}")
78
+ return str(uuid.uuid4())
79
+
80
+ async def add_tool_message(
81
+ self,
82
+ conversation_id: str,
83
+ content: list[dict[str, Any]] | dict[str, Any],
84
+ index: int,
85
+ ) -> str:
86
+ """Add a tool/system message to the conversation.
87
+
88
+ Args:
89
+ conversation_id: ID of the conversation
90
+ content: Message content (typically tool results)
91
+ index: Sequential index in conversation
92
+
93
+ Returns:
94
+ Message ID
95
+ """
96
+ try:
97
+ return await self._storage.add_message(
98
+ conversation_id, "tool", content, index
99
+ )
100
+ except Exception as e:
101
+ logger.warning(f"Failed to add tool message: {e}")
102
+ return str(uuid.uuid4())
103
+
104
+ async def end_conversation(self, conversation_id: str) -> bool:
105
+ """End a conversation.
106
+
107
+ Args:
108
+ conversation_id: ID of the conversation to end
109
+
110
+ Returns:
111
+ True if successfully ended, False otherwise
112
+ """
113
+ try:
114
+ return await self._storage.end_conversation(conversation_id)
115
+ except Exception as e:
116
+ logger.warning(f"Failed to end conversation: {e}")
117
+ return False
118
+
119
+ async def get_conversation(self, conversation_id: str) -> Conversation | None:
120
+ """Get a conversation by ID.
121
+
122
+ Args:
123
+ conversation_id: ID of the conversation
124
+
125
+ Returns:
126
+ Conversation object or None if not found
127
+ """
128
+ try:
129
+ return await self._storage.get_conversation(conversation_id)
130
+ except Exception as e:
131
+ logger.warning(f"Failed to get conversation: {e}")
132
+ return None
133
+
134
+ async def get_conversation_messages(
135
+ self, conversation_id: str
136
+ ) -> list[ConversationMessage]:
137
+ """Get all messages for a conversation.
138
+
139
+ Args:
140
+ conversation_id: ID of the conversation
141
+
142
+ Returns:
143
+ List of messages ordered by index
144
+ """
145
+ try:
146
+ return await self._storage.get_conversation_messages(conversation_id)
147
+ except Exception as e:
148
+ logger.warning(f"Failed to get conversation messages: {e}")
149
+ return []
150
+
151
+ async def list_conversations(
152
+ self, database_name: str | None = None, limit: int = 50
153
+ ) -> list[Conversation]:
154
+ """List conversations.
155
+
156
+ Args:
157
+ database_name: Optional database name filter
158
+ limit: Maximum number of conversations to return
159
+
160
+ Returns:
161
+ List of conversations ordered by start time (newest first)
162
+ """
163
+ try:
164
+ return await self._storage.list_conversations(database_name, limit)
165
+ except Exception as e:
166
+ logger.warning(f"Failed to list conversations: {e}")
167
+ return []
168
+
169
+ async def delete_conversation(self, conversation_id: str) -> bool:
170
+ """Delete a conversation.
171
+
172
+ Args:
173
+ conversation_id: ID of the conversation to delete
174
+
175
+ Returns:
176
+ True if successfully deleted, False otherwise
177
+ """
178
+ try:
179
+ return await self._storage.delete_conversation(conversation_id)
180
+ except Exception as e:
181
+ logger.warning(f"Failed to delete conversation: {e}")
182
+ return False
183
+
184
+ async def get_database_names(self) -> list[str]:
185
+ """Get list of database names with conversations.
186
+
187
+ Returns:
188
+ List of unique database names
189
+ """
190
+ try:
191
+ return await self._storage.get_database_names()
192
+ except Exception as e:
193
+ logger.warning(f"Failed to get database names: {e}")
194
+ return []
195
+
196
+ async def restore_conversation_to_agent(
197
+ self, conversation_id: str, agent_history: list[dict[str, Any]]
198
+ ) -> bool:
199
+ """Restore a conversation's messages to an agent's in-memory history.
200
+
201
+ Args:
202
+ conversation_id: ID of the conversation to restore
203
+ agent_history: Agent's conversation_history list to populate
204
+
205
+ Returns:
206
+ True if successfully restored, False otherwise
207
+ """
208
+ try:
209
+ messages = await self.get_conversation_messages(conversation_id)
210
+
211
+ # Clear existing history
212
+ agent_history.clear()
213
+
214
+ # Convert messages back to agent format
215
+ for msg in messages:
216
+ if msg.role in ("user", "assistant", "tool"):
217
+ agent_history.append({"role": msg.role, "content": msg.content})
218
+
219
+ logger.debug(f"Restored {len(messages)} messages to agent history")
220
+ return True
221
+
222
+ except Exception as e:
223
+ logger.warning(f"Failed to restore conversation to agent: {e}")
224
+ return False
@@ -0,0 +1,120 @@
1
+ """Data models for conversation storage."""
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Conversation:
11
+ """Represents a conversation session."""
12
+
13
+ id: str
14
+ database_name: str
15
+ started_at: float
16
+ ended_at: float | None = None
17
+
18
+ def to_dict(self) -> dict[str, Any]:
19
+ """Convert to dictionary for JSON serialization."""
20
+ return {
21
+ "id": self.id,
22
+ "database_name": self.database_name,
23
+ "started_at": self.started_at,
24
+ "ended_at": self.ended_at,
25
+ }
26
+
27
+ @classmethod
28
+ def from_dict(cls, data: dict[str, Any]) -> "Conversation":
29
+ """Create from dictionary."""
30
+ return cls(
31
+ id=data["id"],
32
+ database_name=data["database_name"],
33
+ started_at=data["started_at"],
34
+ ended_at=data.get("ended_at"),
35
+ )
36
+
37
+ def formatted_start_time(self) -> str:
38
+ """Get human-readable start timestamp."""
39
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at))
40
+
41
+ def formatted_end_time(self) -> str | None:
42
+ """Get human-readable end timestamp."""
43
+ if self.ended_at is None:
44
+ return None
45
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.ended_at))
46
+
47
+ def duration_seconds(self) -> float | None:
48
+ """Get conversation duration in seconds."""
49
+ if self.ended_at is None:
50
+ return None
51
+ return self.ended_at - self.started_at
52
+
53
+
54
+ @dataclass
55
+ class ConversationMessage:
56
+ """Represents a single message in a conversation."""
57
+
58
+ id: str
59
+ conversation_id: str
60
+ role: str
61
+ content: dict[str, Any] | str
62
+ index_in_conv: int
63
+ created_at: float
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ """Convert to dictionary for JSON serialization."""
67
+ return {
68
+ "id": self.id,
69
+ "conversation_id": self.conversation_id,
70
+ "role": self.role,
71
+ "content": self.content,
72
+ "index_in_conv": self.index_in_conv,
73
+ "created_at": self.created_at,
74
+ }
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict[str, Any]) -> "ConversationMessage":
78
+ """Create from dictionary."""
79
+ return cls(
80
+ id=data["id"],
81
+ conversation_id=data["conversation_id"],
82
+ role=data["role"],
83
+ content=data["content"],
84
+ index_in_conv=data["index_in_conv"],
85
+ created_at=data["created_at"],
86
+ )
87
+
88
+ def formatted_timestamp(self) -> str:
89
+ """Get human-readable timestamp."""
90
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.created_at))
91
+
92
+ def content_json(self) -> str:
93
+ """Get content as JSON string for storage."""
94
+ return json.dumps(self.content)
95
+
96
+ @classmethod
97
+ def from_storage_data(
98
+ cls,
99
+ id_: str,
100
+ conversation_id: str,
101
+ role: str,
102
+ content_json: str,
103
+ index_in_conv: int,
104
+ created_at: float,
105
+ ) -> "ConversationMessage":
106
+ """Create from SQLite storage data."""
107
+ try:
108
+ content = json.loads(content_json)
109
+ except json.JSONDecodeError:
110
+ # Fallback to string content for malformed JSON
111
+ content = content_json
112
+
113
+ return cls(
114
+ id=id_,
115
+ conversation_id=conversation_id,
116
+ role=role,
117
+ content=content,
118
+ index_in_conv=index_in_conv,
119
+ created_at=created_at,
120
+ )
@@ -0,0 +1,362 @@
1
+ """SQLite storage implementation for conversation history."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import aiosqlite
12
+ import platformdirs
13
+
14
+ from .models import Conversation, ConversationMessage
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Database schema
19
+ SCHEMA_SQL = """
20
+ -- Conversations table
21
+ CREATE TABLE IF NOT EXISTS conversations (
22
+ id TEXT PRIMARY KEY,
23
+ database_name TEXT NOT NULL,
24
+ started_at REAL NOT NULL,
25
+ ended_at REAL
26
+ );
27
+
28
+ -- Messages table
29
+ CREATE TABLE IF NOT EXISTS messages (
30
+ id TEXT PRIMARY KEY,
31
+ conversation_id TEXT NOT NULL,
32
+ role TEXT NOT NULL,
33
+ content TEXT NOT NULL,
34
+ index_in_conv INTEGER NOT NULL,
35
+ created_at REAL NOT NULL,
36
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
37
+ );
38
+
39
+ -- Indices for performance
40
+ CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, index_in_conv);
41
+ CREATE INDEX IF NOT EXISTS idx_conv_dbname ON conversations(database_name);
42
+
43
+ -- Store schema version for future migrations
44
+ CREATE TABLE IF NOT EXISTS schema_version (
45
+ version INTEGER PRIMARY KEY
46
+ );
47
+
48
+ INSERT OR IGNORE INTO schema_version (version) VALUES (1);
49
+ """
50
+
51
+
52
+ class ConversationStorage:
53
+ """Handles SQLite storage and retrieval of conversation history."""
54
+
55
+ _DB_VERSION = 1
56
+
57
+ def __init__(self):
58
+ """Initialize conversation storage."""
59
+ self.db_path = (
60
+ Path(platformdirs.user_config_dir("sqlsaber")) / "conversations.db"
61
+ )
62
+ self._lock = asyncio.Lock()
63
+ self._initialized: bool = False
64
+
65
+ async def _init_db(self) -> None:
66
+ """Initialize the database with schema if needed."""
67
+ if self._initialized:
68
+ return
69
+
70
+ try:
71
+ # Ensure parent directory exists
72
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
73
+
74
+ async with aiosqlite.connect(self.db_path) as db:
75
+ await db.executescript(SCHEMA_SQL)
76
+ await db.commit()
77
+
78
+ self._initialized = True
79
+ logger.debug(f"Initialized conversation database at {self.db_path}")
80
+
81
+ except Exception as e:
82
+ logger.warning(f"Failed to initialize conversation database: {e}")
83
+ # Don't raise - let the system continue without persistence
84
+
85
+ async def create_conversation(self, database_name: str) -> str:
86
+ """Create a new conversation record.
87
+
88
+ Args:
89
+ database_name: Name of the database for this conversation
90
+
91
+ Returns:
92
+ Conversation ID (UUID)
93
+ """
94
+ await self._init_db()
95
+
96
+ conversation_id = str(uuid.uuid4())
97
+ started_at = time.time()
98
+
99
+ try:
100
+ async with self._lock, aiosqlite.connect(self.db_path) as db:
101
+ await db.execute(
102
+ """
103
+ INSERT INTO conversations (id, database_name, started_at)
104
+ VALUES (?, ?, ?)
105
+ """,
106
+ (conversation_id, database_name, started_at),
107
+ )
108
+ await db.commit()
109
+
110
+ logger.debug(
111
+ f"Created conversation {conversation_id} for db {database_name}"
112
+ )
113
+ return conversation_id
114
+
115
+ except Exception as e:
116
+ logger.warning(f"Failed to create conversation: {e}")
117
+ return conversation_id # Return ID anyway to allow in-memory operation
118
+
119
+ async def add_message(
120
+ self,
121
+ conversation_id: str,
122
+ role: str,
123
+ content: str | list[dict[str, Any]] | dict[str, Any],
124
+ index_in_conv: int,
125
+ ) -> str:
126
+ """Add a message to a conversation.
127
+
128
+ Args:
129
+ conversation_id: ID of the conversation
130
+ role: Message role (user, assistant, system, tool)
131
+ content: Message content (will be JSON-serialized)
132
+ index_in_conv: Sequential index of message in conversation
133
+
134
+ Returns:
135
+ Message ID (UUID)
136
+ """
137
+ await self._init_db()
138
+
139
+ message_id = str(uuid.uuid4())
140
+ created_at = time.time()
141
+
142
+ try:
143
+ async with self._lock, aiosqlite.connect(self.db_path) as db:
144
+ await db.execute(
145
+ """
146
+ INSERT INTO messages (id, conversation_id, role, content, index_in_conv, created_at)
147
+ VALUES (?, ?, ?, ?, ?, ?)
148
+ """,
149
+ (
150
+ message_id,
151
+ conversation_id,
152
+ role,
153
+ json.dumps(content),
154
+ index_in_conv,
155
+ created_at,
156
+ ),
157
+ )
158
+ await db.commit()
159
+
160
+ logger.debug(
161
+ f"Added message {message_id} to conversation {conversation_id}"
162
+ )
163
+ return message_id
164
+
165
+ except Exception as e:
166
+ logger.warning(f"Failed to add message: {e}")
167
+ return message_id # Return ID anyway
168
+
169
+ async def end_conversation(self, conversation_id: str) -> bool:
170
+ """Mark a conversation as ended.
171
+
172
+ Args:
173
+ conversation_id: ID of the conversation to end
174
+
175
+ Returns:
176
+ True if successfully updated, False otherwise
177
+ """
178
+ await self._init_db()
179
+
180
+ try:
181
+ async with self._lock, aiosqlite.connect(self.db_path) as db:
182
+ await db.execute(
183
+ "UPDATE conversations SET ended_at = ? WHERE id = ?",
184
+ (time.time(), conversation_id),
185
+ )
186
+ await db.commit()
187
+
188
+ logger.debug(f"Ended conversation {conversation_id}")
189
+ return True
190
+
191
+ except Exception as e:
192
+ logger.warning(f"Failed to end conversation: {e}")
193
+ return False
194
+
195
+ async def get_conversation(self, conversation_id: str) -> Conversation | None:
196
+ """Get a conversation by ID.
197
+
198
+ Args:
199
+ conversation_id: ID of the conversation
200
+
201
+ Returns:
202
+ Conversation object or None if not found
203
+ """
204
+ await self._init_db()
205
+
206
+ try:
207
+ async with aiosqlite.connect(self.db_path) as db:
208
+ async with db.execute(
209
+ "SELECT id, database_name, started_at, ended_at FROM conversations WHERE id = ?",
210
+ (conversation_id,),
211
+ ) as cursor:
212
+ row = await cursor.fetchone()
213
+ if not row:
214
+ return None
215
+
216
+ return Conversation(
217
+ id=row[0],
218
+ database_name=row[1],
219
+ started_at=row[2],
220
+ ended_at=row[3],
221
+ )
222
+
223
+ except Exception as e:
224
+ logger.warning(f"Failed to get conversation {conversation_id}: {e}")
225
+ return None
226
+
227
+ async def get_conversation_messages(
228
+ self, conversation_id: str
229
+ ) -> list[ConversationMessage]:
230
+ """Get all messages for a conversation.
231
+
232
+ Args:
233
+ conversation_id: ID of the conversation
234
+
235
+ Returns:
236
+ List of ConversationMessage objects ordered by index
237
+ """
238
+ await self._init_db()
239
+
240
+ try:
241
+ async with aiosqlite.connect(self.db_path) as db:
242
+ async with db.execute(
243
+ """
244
+ SELECT id, conversation_id, role, content, index_in_conv, created_at
245
+ FROM messages
246
+ WHERE conversation_id = ?
247
+ ORDER BY index_in_conv
248
+ """,
249
+ (conversation_id,),
250
+ ) as cursor:
251
+ messages = []
252
+ async for row in cursor:
253
+ message = ConversationMessage.from_storage_data(
254
+ id_=row[0],
255
+ conversation_id=row[1],
256
+ role=row[2],
257
+ content_json=row[3],
258
+ index_in_conv=row[4],
259
+ created_at=row[5],
260
+ )
261
+ messages.append(message)
262
+
263
+ return messages
264
+
265
+ except Exception as e:
266
+ logger.warning(
267
+ f"Failed to get messages for conversation {conversation_id}: {e}"
268
+ )
269
+ return []
270
+
271
+ async def list_conversations(
272
+ self, database_name: str | None = None, limit: int = 50
273
+ ) -> list[Conversation]:
274
+ """List conversations, optionally filtered by database.
275
+
276
+ Args:
277
+ database_name: Optional database name filter
278
+ limit: Maximum number of conversations to return
279
+
280
+ Returns:
281
+ List of Conversation objects ordered by start time (newest first)
282
+ """
283
+ await self._init_db()
284
+
285
+ try:
286
+ query = """
287
+ SELECT id, database_name, started_at, ended_at
288
+ FROM conversations
289
+ """
290
+ params = []
291
+
292
+ if database_name:
293
+ query += " WHERE database_name = ?"
294
+ params.append(database_name)
295
+
296
+ query += " ORDER BY started_at DESC LIMIT ?"
297
+ params.append(limit)
298
+
299
+ async with aiosqlite.connect(self.db_path) as db:
300
+ async with db.execute(query, params) as cursor:
301
+ conversations = []
302
+ async for row in cursor:
303
+ conversation = Conversation(
304
+ id=row[0],
305
+ database_name=row[1],
306
+ started_at=row[2],
307
+ ended_at=row[3],
308
+ )
309
+ conversations.append(conversation)
310
+
311
+ return conversations
312
+
313
+ except Exception as e:
314
+ logger.warning(f"Failed to list conversations: {e}")
315
+ return []
316
+
317
+ async def delete_conversation(self, conversation_id: str) -> bool:
318
+ """Delete a conversation and all its messages.
319
+
320
+ Args:
321
+ conversation_id: ID of the conversation to delete
322
+
323
+ Returns:
324
+ True if successfully deleted, False otherwise
325
+ """
326
+ await self._init_db()
327
+
328
+ try:
329
+ async with self._lock, aiosqlite.connect(self.db_path) as db:
330
+ # Delete conversation (messages will be deleted by CASCADE)
331
+ cursor = await db.execute(
332
+ "DELETE FROM conversations WHERE id = ?", (conversation_id,)
333
+ )
334
+ await db.commit()
335
+
336
+ deleted = cursor.rowcount > 0
337
+ if deleted:
338
+ logger.debug(f"Deleted conversation {conversation_id}")
339
+ return deleted
340
+
341
+ except Exception as e:
342
+ logger.warning(f"Failed to delete conversation {conversation_id}: {e}")
343
+ return False
344
+
345
+ async def get_database_names(self) -> list[str]:
346
+ """Get list of all database names that have conversations.
347
+
348
+ Returns:
349
+ List of unique database names
350
+ """
351
+ await self._init_db()
352
+
353
+ try:
354
+ async with aiosqlite.connect(self.db_path) as db:
355
+ async with db.execute(
356
+ "SELECT DISTINCT database_name FROM conversations ORDER BY database_name"
357
+ ) as cursor:
358
+ return [row[0] async for row in cursor]
359
+
360
+ except Exception as e:
361
+ logger.warning(f"Failed to get database names: {e}")
362
+ return []