sqlsaber 0.12.0__py3-none-any.whl → 0.14.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.

@@ -450,6 +450,16 @@ Guidelines:
450
450
  self._last_query = None
451
451
 
452
452
  try:
453
+ # Ensure conversation is active for persistence
454
+ await self._ensure_conversation()
455
+
456
+ # Store user message in conversation history and persistence
457
+ if use_history:
458
+ self.conversation_history.append(
459
+ {"role": "user", "content": user_query}
460
+ )
461
+ await self._store_user_message(user_query)
462
+
453
463
  # Build messages with history if requested
454
464
  messages = []
455
465
  if use_history:
@@ -461,8 +471,9 @@ Guidelines:
461
471
  instructions = self._get_sql_assistant_instructions()
462
472
  messages.append(Message(MessageRole.USER, instructions))
463
473
 
464
- # Add current user message
465
- messages.append(Message(MessageRole.USER, user_query))
474
+ # Add current user message if not already in messages from history
475
+ if not use_history:
476
+ messages.append(Message(MessageRole.USER, user_query))
466
477
 
467
478
  # Create initial request and get response
468
479
  request = self._create_message_request(messages)
@@ -484,9 +495,12 @@ Guidelines:
484
495
  return
485
496
 
486
497
  # Add assistant's response to conversation
487
- collected_content.append(
488
- {"role": "assistant", "content": response.content}
489
- )
498
+ assistant_content = {"role": "assistant", "content": response.content}
499
+ collected_content.append(assistant_content)
500
+
501
+ # Store the assistant message immediately (not from collected_content)
502
+ if use_history:
503
+ await self._store_assistant_message(response.content)
490
504
 
491
505
  # Execute tools and get results
492
506
  tool_results = []
@@ -499,9 +513,19 @@ Guidelines:
499
513
  tool_results = event
500
514
 
501
515
  # Continue conversation with tool results
502
- collected_content.append({"role": "user", "content": tool_results})
516
+ tool_content = {"role": "user", "content": tool_results}
517
+ collected_content.append(tool_content)
518
+
519
+ # Store the tool message immediately and update history
503
520
  if use_history:
504
- self.conversation_history.extend(collected_content)
521
+ # Only add the NEW messages to history (not the accumulated ones)
522
+ # collected_content has [assistant1, tool1, assistant2, tool2, ...]
523
+ # We only want to add the last 2 items that were just added
524
+ new_messages_for_history = collected_content[
525
+ -2:
526
+ ] # Last assistant + tool pair
527
+ self.conversation_history.extend(new_messages_for_history)
528
+ await self._store_tool_message(tool_results)
505
529
 
506
530
  if cancellation_token is not None and cancellation_token.is_set():
507
531
  return
@@ -541,6 +565,10 @@ Guidelines:
541
565
  {"role": "assistant", "content": response.content}
542
566
  )
543
567
 
568
+ # Store final assistant message in persistence (only if not tool_use)
569
+ if response.stop_reason != "tool_use":
570
+ await self._store_assistant_message(response.content)
571
+
544
572
  except asyncio.CancelledError:
545
573
  return
546
574
  except Exception as e:
sqlsaber/agents/base.py CHANGED
@@ -7,6 +7,7 @@ from typing import Any, AsyncIterator
7
7
 
8
8
  from uniplot import histogram, plot
9
9
 
10
+ from sqlsaber.conversation.manager import ConversationManager
10
11
  from sqlsaber.database.connection import (
11
12
  BaseDatabaseConnection,
12
13
  CSVConnection,
@@ -26,6 +27,11 @@ class BaseSQLAgent(ABC):
26
27
  self.schema_manager = SchemaManager(db_connection)
27
28
  self.conversation_history: list[dict[str, Any]] = []
28
29
 
30
+ # Conversation persistence
31
+ self._conv_manager = ConversationManager()
32
+ self._conversation_id: str | None = None
33
+ self._msg_index: int = 0
34
+
29
35
  @abstractmethod
30
36
  async def query_stream(
31
37
  self,
@@ -42,8 +48,12 @@ class BaseSQLAgent(ABC):
42
48
  """
43
49
  pass
44
50
 
45
- def clear_history(self):
51
+ async def clear_history(self):
46
52
  """Clear conversation history."""
53
+ # End current conversation in storage
54
+ await self._end_conversation()
55
+
56
+ # Clear in-memory history
47
57
  self.conversation_history = []
48
58
 
49
59
  def _get_database_type_name(self) -> str:
@@ -284,3 +294,96 @@ class BaseSQLAgent(ABC):
284
294
 
285
295
  except Exception as e:
286
296
  return json.dumps({"error": f"Error creating plot: {str(e)}"})
297
+
298
+ # Conversation persistence helpers
299
+
300
+ async def _ensure_conversation(self) -> None:
301
+ """Ensure a conversation is active for storing messages."""
302
+ if self._conversation_id is None:
303
+ db_name = getattr(self, "database_name", "unknown")
304
+ self._conversation_id = await self._conv_manager.start_conversation(db_name)
305
+ self._msg_index = 0
306
+
307
+ async def _store_user_message(self, content: str | dict[str, Any]) -> None:
308
+ """Store a user message in conversation history."""
309
+ if self._conversation_id is None:
310
+ return
311
+
312
+ await self._conv_manager.add_user_message(
313
+ self._conversation_id, content, self._msg_index
314
+ )
315
+ self._msg_index += 1
316
+
317
+ async def _store_assistant_message(
318
+ self, content: list[dict[str, Any]] | dict[str, Any]
319
+ ) -> None:
320
+ """Store an assistant message in conversation history."""
321
+ if self._conversation_id is None:
322
+ return
323
+
324
+ await self._conv_manager.add_assistant_message(
325
+ self._conversation_id, content, self._msg_index
326
+ )
327
+ self._msg_index += 1
328
+
329
+ async def _store_tool_message(
330
+ self, content: list[dict[str, Any]] | dict[str, Any]
331
+ ) -> None:
332
+ """Store a tool/system message in conversation history."""
333
+ if self._conversation_id is None:
334
+ return
335
+
336
+ await self._conv_manager.add_tool_message(
337
+ self._conversation_id, content, self._msg_index
338
+ )
339
+ self._msg_index += 1
340
+
341
+ async def _end_conversation(self) -> None:
342
+ """End the current conversation."""
343
+ if self._conversation_id:
344
+ await self._conv_manager.end_conversation(self._conversation_id)
345
+ self._conversation_id = None
346
+ self._msg_index = 0
347
+
348
+ async def restore_conversation(self, conversation_id: str) -> bool:
349
+ """Restore a conversation from storage to in-memory history.
350
+
351
+ Args:
352
+ conversation_id: ID of the conversation to restore
353
+
354
+ Returns:
355
+ True if successfully restored, False otherwise
356
+ """
357
+ success = await self._conv_manager.restore_conversation_to_agent(
358
+ conversation_id, self.conversation_history
359
+ )
360
+
361
+ if success:
362
+ # Set up for continuing this conversation
363
+ self._conversation_id = conversation_id
364
+ self._msg_index = len(self.conversation_history)
365
+
366
+ return success
367
+
368
+ async def list_conversations(self, limit: int = 50) -> list:
369
+ """List conversations for this agent's database.
370
+
371
+ Args:
372
+ limit: Maximum number of conversations to return
373
+
374
+ Returns:
375
+ List of conversation data
376
+ """
377
+ db_name = getattr(self, "database_name", None)
378
+ conversations = await self._conv_manager.list_conversations(db_name, limit)
379
+
380
+ return [
381
+ {
382
+ "id": conv.id,
383
+ "database_name": conv.database_name,
384
+ "started_at": conv.formatted_start_time(),
385
+ "ended_at": conv.formatted_end_time(),
386
+ "duration": conv.duration_seconds(),
387
+ }
388
+ for conv in conversations
389
+ ]
sqlsaber/cli/commands.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import asyncio
4
4
  import sys
5
- from pathlib import Path
6
5
  from typing import Annotated
7
6
 
8
7
  import cyclopts
@@ -17,6 +16,7 @@ from sqlsaber.cli.models import create_models_app
17
16
  from sqlsaber.cli.streaming import StreamingQueryHandler
18
17
  from sqlsaber.config.database import DatabaseConfigManager
19
18
  from sqlsaber.database.connection import DatabaseConnection
19
+ from sqlsaber.database.resolver import DatabaseResolutionError, resolve_database
20
20
 
21
21
 
22
22
  class CLIError(Exception):
@@ -43,7 +43,7 @@ def meta_handler(
43
43
  str | None,
44
44
  cyclopts.Parameter(
45
45
  ["--database", "-d"],
46
- help="Database connection name or direct file path for CSV/SQLite files (uses default if not specified)",
46
+ help="Database connection name, file path (CSV/SQLite), or connection string (postgresql://, mysql://) (uses default if not specified)",
47
47
  ),
48
48
  ] = None,
49
49
  ):
@@ -56,6 +56,8 @@ def meta_handler(
56
56
  saber -d mydb "show me users" # Run a query with specific database
57
57
  saber -d data.csv "show me users" # Run a query with ad-hoc CSV file
58
58
  saber -d data.db "show me users" # Run a query with ad-hoc SQLite file
59
+ saber -d "postgresql://user:pass@host:5432/db" "show users" # PostgreSQL connection string
60
+ saber -d "mysql://user:pass@host:3306/db" "show users" # MySQL connection string
59
61
  echo "show me all users" | saber # Read query from stdin
60
62
  cat query.txt | saber # Read query from file via stdin
61
63
  """
@@ -75,7 +77,7 @@ def query(
75
77
  str | None,
76
78
  cyclopts.Parameter(
77
79
  ["--database", "-d"],
78
- help="Database connection name or direct file path for CSV/SQLite files (uses default if not specified)",
80
+ help="Database connection name, file path (CSV/SQLite), or connection string (postgresql://, mysql://) (uses default if not specified)",
79
81
  ),
80
82
  ] = None,
81
83
  ):
@@ -92,6 +94,8 @@ def query(
92
94
  saber "show me all users" # Run a single query
93
95
  saber -d data.csv "show users" # Run a query with ad-hoc CSV file
94
96
  saber -d data.db "show users" # Run a query with ad-hoc SQLite file
97
+ saber -d "postgresql://user:pass@host:5432/db" "show users" # PostgreSQL connection string
98
+ saber -d "mysql://user:pass@host:3306/db" "show users" # MySQL connection string
95
99
  echo "show me all users" | saber # Read query from stdin
96
100
  """
97
101
 
@@ -105,39 +109,13 @@ def query(
105
109
  # If stdin was empty, fall back to interactive mode
106
110
  actual_query = None
107
111
 
108
- # Get database configuration or handle direct file paths
109
- if database:
110
- # Check if this is a direct CSV file path
111
- if database.endswith(".csv"):
112
- csv_path = Path(database).expanduser().resolve()
113
- if not csv_path.exists():
114
- raise CLIError(f"CSV file '{database}' not found.")
115
- connection_string = f"csv:///{csv_path}"
116
- db_name = csv_path.stem
117
- # Check if this is a direct SQLite file path
118
- elif database.endswith((".db", ".sqlite", ".sqlite3")):
119
- sqlite_path = Path(database).expanduser().resolve()
120
- if not sqlite_path.exists():
121
- raise CLIError(f"SQLite file '{database}' not found.")
122
- connection_string = f"sqlite:///{sqlite_path}"
123
- db_name = sqlite_path.stem
124
- else:
125
- # Look up configured database connection
126
- db_config = config_manager.get_database(database)
127
- if not db_config:
128
- raise CLIError(
129
- f"Database connection '{database}' not found. Use 'sqlsaber db list' to see available connections."
130
- )
131
- connection_string = db_config.to_connection_string()
132
- db_name = db_config.name
133
- else:
134
- db_config = config_manager.get_default_database()
135
- if not db_config:
136
- raise CLIError(
137
- "No database connections configured. Use 'sqlsaber db add <name>' to add a database connection."
138
- )
139
- connection_string = db_config.to_connection_string()
140
- db_name = db_config.name
112
+ # Resolve database from CLI input
113
+ try:
114
+ resolved = resolve_database(database, config_manager)
115
+ connection_string = resolved.connection_string
116
+ db_name = resolved.name
117
+ except DatabaseResolutionError as e:
118
+ raise CLIError(str(e))
141
119
 
142
120
  # Create database connection
143
121
  try:
@@ -136,11 +136,15 @@ class InteractiveSession:
136
136
  if not user_query:
137
137
  continue
138
138
 
139
- if user_query in ["/exit", "/quit"]:
139
+ if (
140
+ user_query in ["/exit", "/quit"]
141
+ or user_query.startswith("/exit")
142
+ or user_query.startswith("/quit")
143
+ ):
140
144
  break
141
145
 
142
146
  if user_query == "/clear":
143
- self.agent.clear_history()
147
+ await self.agent.clear_history()
144
148
  self.console.print("[green]Conversation history cleared.[/green]\n")
145
149
  continue
146
150
 
@@ -0,0 +1,12 @@
1
+ """Conversation history storage for SQLSaber."""
2
+
3
+ from .manager import ConversationManager
4
+ from .models import Conversation, ConversationMessage
5
+ from .storage import ConversationStorage
6
+
7
+ __all__ = [
8
+ "Conversation",
9
+ "ConversationMessage",
10
+ "ConversationStorage",
11
+ "ConversationManager",
12
+ ]
@@ -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 []
@@ -0,0 +1,96 @@
1
+ """Database connection resolution from CLI input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+
9
+ from sqlsaber.config.database import DatabaseConfig, DatabaseConfigManager
10
+
11
+
12
+ class DatabaseResolutionError(Exception):
13
+ """Exception raised when database resolution fails."""
14
+
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class ResolvedDatabase:
20
+ """Result of database resolution containing canonical connection info."""
21
+
22
+ name: str # Human-readable name for display/logging
23
+ connection_string: str # Canonical connection string for DatabaseConnection factory
24
+
25
+
26
+ SUPPORTED_SCHEMES = {"postgresql", "mysql", "sqlite", "csv"}
27
+
28
+
29
+ def _is_connection_string(s: str) -> bool:
30
+ """Check if string looks like a connection string with supported scheme."""
31
+ try:
32
+ scheme = urlparse(s).scheme
33
+ return scheme in SUPPORTED_SCHEMES
34
+ except Exception:
35
+ return False
36
+
37
+
38
+ def resolve_database(
39
+ spec: str | None, config_mgr: DatabaseConfigManager
40
+ ) -> ResolvedDatabase:
41
+ """Turn user CLI input into resolved database connection info.
42
+
43
+ Args:
44
+ spec: User input - None (default), configured name, connection string, or file path
45
+ config_mgr: Database configuration manager for looking up configured connections
46
+
47
+ Returns:
48
+ ResolvedDatabase with name and canonical connection string
49
+
50
+ Raises:
51
+ DatabaseResolutionError: If the spec cannot be resolved to a valid database connection
52
+ """
53
+ if spec is None:
54
+ db_cfg = config_mgr.get_default_database()
55
+ if not db_cfg:
56
+ raise DatabaseResolutionError(
57
+ "No database connections configured. "
58
+ "Use 'sqlsaber db add <name>' to add one."
59
+ )
60
+ return ResolvedDatabase(
61
+ name=db_cfg.name,
62
+ connection_string=db_cfg.to_connection_string(),
63
+ )
64
+
65
+ # 1. Connection string?
66
+ if _is_connection_string(spec):
67
+ scheme = urlparse(spec).scheme
68
+ if scheme in {"postgresql", "mysql"}:
69
+ db_name = urlparse(spec).path.lstrip("/") or "database"
70
+ elif scheme in {"sqlite", "csv"}:
71
+ db_name = Path(urlparse(spec).path).stem
72
+ else: # should not happen because of SUPPORTED_SCHEMES
73
+ db_name = "database"
74
+ return ResolvedDatabase(name=db_name, connection_string=spec)
75
+
76
+ # 2. Raw file path?
77
+ path = Path(spec).expanduser().resolve()
78
+ if path.suffix.lower() == ".csv":
79
+ if not path.exists():
80
+ raise DatabaseResolutionError(f"CSV file '{spec}' not found.")
81
+ return ResolvedDatabase(name=path.stem, connection_string=f"csv:///{path}")
82
+ if path.suffix.lower() in {".db", ".sqlite", ".sqlite3"}:
83
+ if not path.exists():
84
+ raise DatabaseResolutionError(f"SQLite file '{spec}' not found.")
85
+ return ResolvedDatabase(name=path.stem, connection_string=f"sqlite:///{path}")
86
+
87
+ # 3. Must be a configured name
88
+ db_cfg: DatabaseConfig | None = config_mgr.get_database(spec)
89
+ if not db_cfg:
90
+ raise DatabaseResolutionError(
91
+ f"Database connection '{spec}' not found. "
92
+ "Use 'sqlsaber db list' to see available connections."
93
+ )
94
+ return ResolvedDatabase(
95
+ name=db_cfg.name, connection_string=db_cfg.to_connection_string()
96
+ )
@@ -1,6 +1,5 @@
1
1
  """Database schema introspection utilities."""
2
2
 
3
- import time
4
3
  from abc import ABC, abstractmethod
5
4
  from typing import Any
6
5
 
@@ -532,12 +531,10 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
532
531
 
533
532
 
534
533
  class SchemaManager:
535
- """Manages database schema introspection with caching."""
534
+ """Manages database schema introspection."""
536
535
 
537
- def __init__(self, db_connection: BaseDatabaseConnection, cache_ttl: int = 900):
536
+ def __init__(self, db_connection: BaseDatabaseConnection):
538
537
  self.db = db_connection
539
- self.cache_ttl = cache_ttl # Default 15 minutes
540
- self._schema_cache: dict[str, tuple[float, dict[str, Any]]] = {}
541
538
 
542
539
  # Select appropriate introspector based on connection type
543
540
  if isinstance(db_connection, PostgreSQLConnection):
@@ -551,10 +548,6 @@ class SchemaManager:
551
548
  f"Unsupported database connection type: {type(db_connection)}"
552
549
  )
553
550
 
554
- def clear_schema_cache(self):
555
- """Clear the schema cache."""
556
- self._schema_cache.clear()
557
-
558
551
  async def get_schema_info(
559
552
  self, table_pattern: str | None = None
560
553
  ) -> dict[str, SchemaInfo]:
@@ -563,31 +556,6 @@ class SchemaManager:
563
556
  Args:
564
557
  table_pattern: Optional SQL LIKE pattern to filter tables (e.g., 'public.user%')
565
558
  """
566
- # Check cache first
567
- cache_key = f"schema:{table_pattern or 'all'}"
568
- cached_data = self._get_cached_schema(cache_key)
569
- if cached_data is not None:
570
- return cached_data
571
-
572
- # Fetch from database if not cached
573
- schema_info = await self._fetch_schema_from_db(table_pattern)
574
-
575
- # Cache the result
576
- self._schema_cache[cache_key] = (time.time(), schema_info)
577
- return schema_info
578
-
579
- def _get_cached_schema(self, cache_key: str) -> dict[str, SchemaInfo] | None:
580
- """Get schema from cache if available and not expired."""
581
- if cache_key in self._schema_cache:
582
- cached_time, cached_data = self._schema_cache[cache_key]
583
- if time.time() - cached_time < self.cache_ttl:
584
- return cached_data
585
- return None
586
-
587
- async def _fetch_schema_from_db(
588
- self, table_pattern: str | None
589
- ) -> dict[str, SchemaInfo]:
590
- """Fetch schema information from database."""
591
559
  # Get all schema components
592
560
  tables = await self.introspector.get_tables_info(self.db, table_pattern)
593
561
  columns = await self.introspector.get_columns_info(self.db, tables)
@@ -672,13 +640,6 @@ class SchemaManager:
672
640
 
673
641
  async def list_tables(self) -> dict[str, Any]:
674
642
  """Get a list of all tables with basic information."""
675
- # Check cache first
676
- cache_key = "list_tables"
677
- cached_data = self._get_cached_tables(cache_key)
678
- if cached_data is not None:
679
- return cached_data
680
-
681
- # Fetch from database if not cached
682
643
  tables = await self.introspector.list_tables_info(self.db)
683
644
 
684
645
  # Format the result
@@ -694,14 +655,4 @@ class SchemaManager:
694
655
  }
695
656
  )
696
657
 
697
- # Cache the result
698
- self._schema_cache[cache_key] = (time.time(), result)
699
658
  return result
700
-
701
- def _get_cached_tables(self, cache_key: str) -> dict[str, Any] | None:
702
- """Get table list from cache if available and not expired."""
703
- if cache_key in self._schema_cache:
704
- cached_time, cached_data = self._schema_cache[cache_key]
705
- if time.time() - cached_time < self.cache_ttl:
706
- return cached_data
707
- return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlsaber
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: SQLSaber - Agentic SQL assistant like Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,17 +1,17 @@
1
1
  sqlsaber/__init__.py,sha256=HjS8ULtP4MGpnTL7njVY45NKV9Fi4e_yeYuY-hyXWQc,73
2
2
  sqlsaber/__main__.py,sha256=RIHxWeWh2QvLfah-2OkhI5IJxojWfy4fXpMnVEJYvxw,78
3
3
  sqlsaber/agents/__init__.py,sha256=LWeSeEUE4BhkyAYFF3TE-fx8TtLud3oyEtyB8ojFJgo,167
4
- sqlsaber/agents/anthropic.py,sha256=CBHneR5NJhu155d0-D1mSGOcTH7kmbXZSLv2mVQotSM,22128
5
- sqlsaber/agents/base.py,sha256=Cl5ZV4dfgjslOAq8jbrnt5kX-NM_8QmjacWzb0hvbzs,10527
4
+ sqlsaber/agents/anthropic.py,sha256=xH4xY1lq5idDgY8Vklgu5hGLoKLHWQMMfPgjvQjfG7I,23615
5
+ sqlsaber/agents/base.py,sha256=9A-iceb93eHrQHTdLoeSicP3ZqDHOFTx3Fe5OvHvUkg,14093
6
6
  sqlsaber/agents/mcp.py,sha256=FKtXgDrPZ2-xqUYCw2baI5JzrWekXaC5fjkYW1_Mg50,827
7
7
  sqlsaber/agents/streaming.py,sha256=LaSeMTlxuJFRArJVqDly5-_KgcePiCCKPKfMxfB4oGs,521
8
8
  sqlsaber/cli/__init__.py,sha256=qVSLVJLLJYzoC6aj6y9MFrzZvAwc4_OgxU9DlkQnZ4M,86
9
9
  sqlsaber/cli/auth.py,sha256=1yvawtS5NGj9dWMRZ8I5T6sqBiRqZpPsjEPrJSQBJAs,4979
10
- sqlsaber/cli/commands.py,sha256=rhbfpXNrWLQbiEEqn4GDwE175vhOWrfPiTRP32Ui7NM,7156
10
+ sqlsaber/cli/commands.py,sha256=VX7pqQnf-85A9zkjXqzytVNeCG8KO0mB2TyIEzB4sh8,6241
11
11
  sqlsaber/cli/completers.py,sha256=HsUPjaZweLSeYCWkAcgMl8FylQ1xjWBWYTEL_9F6xfU,6430
12
12
  sqlsaber/cli/database.py,sha256=tJ8rqGrafZpg3VgDmSiq7eZoPscoGAW3XLTYGoQw8LE,12910
13
13
  sqlsaber/cli/display.py,sha256=HtXwPe3VPUh2EJpyvpJVWyisCanu9O7w-rkqq7Y4UaY,9791
14
- sqlsaber/cli/interactive.py,sha256=NjrhzXt6YK4WL17DMkJTY5dA1v9QMxLdkI1XgPKK8YA,8017
14
+ sqlsaber/cli/interactive.py,sha256=7RjUMMPJ49RUuUGn-gP6vgiW6Ccvin9mzmRL-9p6eto,8171
15
15
  sqlsaber/cli/memory.py,sha256=OufHFJFwV0_GGn7LvKRTJikkWhV1IwNIUDOxFPHXOaQ,7794
16
16
  sqlsaber/cli/models.py,sha256=HByezaeKqj65GzB_FmWuugjkgTq2Pvab_mzNZnHxya0,7690
17
17
  sqlsaber/cli/streaming.py,sha256=WfhFd5ntq2HStpJZwWJ0C5uyXKc3aU14eo8HdjzW1o0,3767
@@ -28,9 +28,14 @@ sqlsaber/config/database.py,sha256=c6q3l4EvoBch1ckYHA70hf6L7fSOY-sItnLCpvJiPrA,1
28
28
  sqlsaber/config/oauth_flow.py,sha256=A3bSXaBLzuAfXV2ZPA94m9NV33c2MyL6M4ii9oEkswQ,10291
29
29
  sqlsaber/config/oauth_tokens.py,sha256=C9z35hyx-PvSAYdC1LNf3rg9_wsEIY56hkEczelbad0,6015
30
30
  sqlsaber/config/settings.py,sha256=gKhGlErzsBk39RoRSy1b8pb-bN2K7HIaPaBgbJDhY4M,4753
31
+ sqlsaber/conversation/__init__.py,sha256=xa-1gX6NsZpVGg_LDrsZAtDtsDo5FZc1SO8gwtm_IPk,302
32
+ sqlsaber/conversation/manager.py,sha256=LDfmKGIMvTzsL7S0aXGWw6Ve54CHIeTGLU4qwes2NgU,7046
33
+ sqlsaber/conversation/models.py,sha256=fq4wpIB2yxLCQtsXhdpDji4FpscG2ayrOBACrNvgF14,3510
34
+ sqlsaber/conversation/storage.py,sha256=phpGEnZjXVFTmV5PalCKZpiO9VFHubMMfWA9OJCDbwc,11626
31
35
  sqlsaber/database/__init__.py,sha256=a_gtKRJnZVO8-fEZI7g3Z8YnGa6Nio-5Y50PgVp07ss,176
32
36
  sqlsaber/database/connection.py,sha256=sZVGNMzMwiM11GrsLLPwR8A5ugzJ5O0TCdkrt0KVRuI,15123
33
- sqlsaber/database/schema.py,sha256=anlxjr_4-gEWlEjQgZ5h4KGMfbS0-aQH1VwhXIw41Q4,27951
37
+ sqlsaber/database/resolver.py,sha256=RPXF5EoKzvQDDLmPGNHYd2uG_oNICH8qvUjBp6iXmNY,3348
38
+ sqlsaber/database/schema.py,sha256=OC93dnZkijCoVNqb6itSpQ2XsiZ85PjVUW-VZDwrPrk,25989
34
39
  sqlsaber/mcp/__init__.py,sha256=COdWq7wauPBp5Ew8tfZItFzbcLDSEkHBJSMhxzy8C9c,112
35
40
  sqlsaber/mcp/mcp.py,sha256=YH4crygqb5_Y94nsns6d-26FZCTlDPOh3tf-ghihzDM,4440
36
41
  sqlsaber/memory/__init__.py,sha256=GiWkU6f6YYVV0EvvXDmFWe_CxarmDCql05t70MkTEWs,63
@@ -39,8 +44,8 @@ sqlsaber/memory/storage.py,sha256=ne8szLlGj5NELheqLnI7zu21V8YS4rtpYGGC7tOmi-s,57
39
44
  sqlsaber/models/__init__.py,sha256=RJ7p3WtuSwwpFQ1Iw4_DHV2zzCtHqIzsjJzxv8kUjUE,287
40
45
  sqlsaber/models/events.py,sha256=89SXKb5GGpH01yTr2kPEBhzp9xv35RFIYuFdAZSIPoE,721
41
46
  sqlsaber/models/types.py,sha256=w-zk81V2dtveuteej36_o1fDK3So428j3P2rAejU62U,862
42
- sqlsaber-0.12.0.dist-info/METADATA,sha256=k_OjB22bXlyVvH_nPTEIBjLwwzmdsAntYs6_rl7LSzg,6877
43
- sqlsaber-0.12.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
- sqlsaber-0.12.0.dist-info/entry_points.txt,sha256=qEbOB7OffXPFgyJc7qEIJlMEX5RN9xdzLmWZa91zCQQ,162
45
- sqlsaber-0.12.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
46
- sqlsaber-0.12.0.dist-info/RECORD,,
47
+ sqlsaber-0.14.0.dist-info/METADATA,sha256=xAj3wcH-3OJWud9grNykoiHhuVp2LIdMV5bbxNUQOEk,6877
48
+ sqlsaber-0.14.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
49
+ sqlsaber-0.14.0.dist-info/entry_points.txt,sha256=qEbOB7OffXPFgyJc7qEIJlMEX5RN9xdzLmWZa91zCQQ,162
50
+ sqlsaber-0.14.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
51
+ sqlsaber-0.14.0.dist-info/RECORD,,