prompture 0.0.35__py3-none-any.whl → 0.0.40.dev1__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 (52) hide show
  1. prompture/__init__.py +132 -3
  2. prompture/_version.py +2 -2
  3. prompture/agent.py +924 -0
  4. prompture/agent_types.py +156 -0
  5. prompture/async_agent.py +880 -0
  6. prompture/async_conversation.py +208 -17
  7. prompture/async_core.py +16 -0
  8. prompture/async_driver.py +63 -0
  9. prompture/async_groups.py +551 -0
  10. prompture/conversation.py +222 -18
  11. prompture/core.py +46 -12
  12. prompture/cost_mixin.py +37 -0
  13. prompture/discovery.py +132 -44
  14. prompture/driver.py +77 -0
  15. prompture/drivers/__init__.py +5 -1
  16. prompture/drivers/async_azure_driver.py +11 -5
  17. prompture/drivers/async_claude_driver.py +184 -9
  18. prompture/drivers/async_google_driver.py +222 -28
  19. prompture/drivers/async_grok_driver.py +11 -5
  20. prompture/drivers/async_groq_driver.py +11 -5
  21. prompture/drivers/async_lmstudio_driver.py +74 -5
  22. prompture/drivers/async_ollama_driver.py +13 -3
  23. prompture/drivers/async_openai_driver.py +162 -5
  24. prompture/drivers/async_openrouter_driver.py +11 -5
  25. prompture/drivers/async_registry.py +5 -1
  26. prompture/drivers/azure_driver.py +10 -4
  27. prompture/drivers/claude_driver.py +17 -1
  28. prompture/drivers/google_driver.py +227 -33
  29. prompture/drivers/grok_driver.py +11 -5
  30. prompture/drivers/groq_driver.py +11 -5
  31. prompture/drivers/lmstudio_driver.py +73 -8
  32. prompture/drivers/ollama_driver.py +16 -5
  33. prompture/drivers/openai_driver.py +26 -11
  34. prompture/drivers/openrouter_driver.py +11 -5
  35. prompture/drivers/vision_helpers.py +153 -0
  36. prompture/group_types.py +147 -0
  37. prompture/groups.py +530 -0
  38. prompture/image.py +180 -0
  39. prompture/ledger.py +252 -0
  40. prompture/model_rates.py +112 -2
  41. prompture/persistence.py +254 -0
  42. prompture/persona.py +482 -0
  43. prompture/serialization.py +218 -0
  44. prompture/settings.py +1 -0
  45. prompture-0.0.40.dev1.dist-info/METADATA +369 -0
  46. prompture-0.0.40.dev1.dist-info/RECORD +78 -0
  47. prompture-0.0.35.dist-info/METADATA +0 -464
  48. prompture-0.0.35.dist-info/RECORD +0 -66
  49. {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/WHEEL +0 -0
  50. {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/entry_points.txt +0 -0
  51. {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/licenses/LICENSE +0 -0
  52. {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,254 @@
1
+ """Conversation persistence — file and SQLite storage backends.
2
+
3
+ Provides:
4
+
5
+ - :func:`save_to_file` / :func:`load_from_file` for simple JSON file storage.
6
+ - :class:`ConversationStore` for SQLite-backed storage with tag search.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ import threading
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ # ------------------------------------------------------------------
19
+ # File-based persistence
20
+ # ------------------------------------------------------------------
21
+
22
+
23
+ def save_to_file(data: dict[str, Any], path: str | Path) -> None:
24
+ """Write a conversation export dict as JSON to *path*.
25
+
26
+ Creates parent directories if they don't exist.
27
+ """
28
+ p = Path(path)
29
+ p.parent.mkdir(parents=True, exist_ok=True)
30
+ p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
31
+
32
+
33
+ def load_from_file(path: str | Path) -> dict[str, Any]:
34
+ """Read a conversation export dict from a JSON file.
35
+
36
+ Raises:
37
+ FileNotFoundError: If *path* does not exist.
38
+ """
39
+ p = Path(path)
40
+ if not p.exists():
41
+ raise FileNotFoundError(f"Conversation file not found: {p}")
42
+ return json.loads(p.read_text(encoding="utf-8"))
43
+
44
+
45
+ # ------------------------------------------------------------------
46
+ # SQLite-backed ConversationStore
47
+ # ------------------------------------------------------------------
48
+
49
+ _DEFAULT_DB_DIR = Path.home() / ".prompture" / "conversations"
50
+ _DEFAULT_DB_PATH = _DEFAULT_DB_DIR / "conversations.db"
51
+
52
+ _SCHEMA_SQL = """
53
+ CREATE TABLE IF NOT EXISTS conversations (
54
+ id TEXT PRIMARY KEY,
55
+ model_name TEXT NOT NULL,
56
+ data TEXT NOT NULL,
57
+ created_at TEXT NOT NULL,
58
+ last_active TEXT NOT NULL,
59
+ turn_count INTEGER NOT NULL DEFAULT 0
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS conversation_tags (
63
+ conversation_id TEXT NOT NULL,
64
+ tag TEXT NOT NULL,
65
+ PRIMARY KEY (conversation_id, tag),
66
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_tags_tag ON conversation_tags(tag);
70
+ CREATE INDEX IF NOT EXISTS idx_conversations_last_active ON conversations(last_active);
71
+ """
72
+
73
+
74
+ class ConversationStore:
75
+ """SQLite-backed conversation storage with tag search.
76
+
77
+ Thread-safe — uses an internal :class:`threading.Lock` for all
78
+ database operations (mirrors the pattern used by ``cache.py``).
79
+
80
+ Args:
81
+ db_path: Path to the SQLite database file. Defaults to
82
+ ``~/.prompture/conversations/conversations.db``.
83
+ """
84
+
85
+ def __init__(self, db_path: str | Path | None = None) -> None:
86
+ self._db_path = Path(db_path) if db_path else _DEFAULT_DB_PATH
87
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
88
+ self._lock = threading.Lock()
89
+ self._init_db()
90
+
91
+ def _init_db(self) -> None:
92
+ with self._lock:
93
+ conn = sqlite3.connect(str(self._db_path))
94
+ try:
95
+ conn.executescript(_SCHEMA_SQL)
96
+ conn.commit()
97
+ finally:
98
+ conn.close()
99
+
100
+ def _connect(self) -> sqlite3.Connection:
101
+ conn = sqlite3.connect(str(self._db_path))
102
+ conn.execute("PRAGMA foreign_keys = ON")
103
+ conn.row_factory = sqlite3.Row
104
+ return conn
105
+
106
+ # ------------------------------------------------------------------ #
107
+ # CRUD
108
+ # ------------------------------------------------------------------ #
109
+
110
+ def save(self, conversation_id: str, data: dict[str, Any]) -> None:
111
+ """Upsert a conversation and replace its tags."""
112
+ meta = data.get("metadata", {})
113
+ model_name = data.get("model_name", "")
114
+ created_at = meta.get("created_at", datetime.now(timezone.utc).isoformat())
115
+ last_active = meta.get("last_active", datetime.now(timezone.utc).isoformat())
116
+ turn_count = meta.get("turn_count", 0)
117
+ tags = meta.get("tags", [])
118
+
119
+ data_json = json.dumps(data, ensure_ascii=False)
120
+
121
+ with self._lock:
122
+ conn = self._connect()
123
+ try:
124
+ conn.execute(
125
+ """
126
+ INSERT INTO conversations (id, model_name, data, created_at, last_active, turn_count)
127
+ VALUES (?, ?, ?, ?, ?, ?)
128
+ ON CONFLICT(id) DO UPDATE SET
129
+ model_name = excluded.model_name,
130
+ data = excluded.data,
131
+ last_active = excluded.last_active,
132
+ turn_count = excluded.turn_count
133
+ """,
134
+ (conversation_id, model_name, data_json, created_at, last_active, turn_count),
135
+ )
136
+ # Replace tags
137
+ conn.execute(
138
+ "DELETE FROM conversation_tags WHERE conversation_id = ?",
139
+ (conversation_id,),
140
+ )
141
+ if tags:
142
+ conn.executemany(
143
+ "INSERT INTO conversation_tags (conversation_id, tag) VALUES (?, ?)",
144
+ [(conversation_id, t) for t in tags],
145
+ )
146
+ conn.commit()
147
+ finally:
148
+ conn.close()
149
+
150
+ def load(self, conversation_id: str) -> dict[str, Any] | None:
151
+ """Load a conversation by ID. Returns ``None`` if not found."""
152
+ with self._lock:
153
+ conn = self._connect()
154
+ try:
155
+ row = conn.execute(
156
+ "SELECT data FROM conversations WHERE id = ?",
157
+ (conversation_id,),
158
+ ).fetchone()
159
+ if row is None:
160
+ return None
161
+ return json.loads(row["data"])
162
+ finally:
163
+ conn.close()
164
+
165
+ def delete(self, conversation_id: str) -> bool:
166
+ """Delete a conversation. Returns *True* if it existed."""
167
+ with self._lock:
168
+ conn = self._connect()
169
+ try:
170
+ cursor = conn.execute(
171
+ "DELETE FROM conversations WHERE id = ?",
172
+ (conversation_id,),
173
+ )
174
+ conn.commit()
175
+ return cursor.rowcount > 0
176
+ finally:
177
+ conn.close()
178
+
179
+ # ------------------------------------------------------------------ #
180
+ # Search / listing
181
+ # ------------------------------------------------------------------ #
182
+
183
+ def find_by_tag(self, tag: str) -> list[dict[str, Any]]:
184
+ """Return summary dicts for all conversations with the given tag."""
185
+ with self._lock:
186
+ conn = self._connect()
187
+ try:
188
+ rows = conn.execute(
189
+ """
190
+ SELECT c.id, c.model_name, c.created_at, c.last_active, c.turn_count
191
+ FROM conversations c
192
+ INNER JOIN conversation_tags ct ON c.id = ct.conversation_id
193
+ WHERE ct.tag = ?
194
+ ORDER BY c.last_active DESC
195
+ """,
196
+ (tag,),
197
+ ).fetchall()
198
+ return [self._row_to_summary(conn, r) for r in rows]
199
+ finally:
200
+ conn.close()
201
+
202
+ def find_by_id(self, conversation_id: str) -> dict[str, Any] | None:
203
+ """Return a summary dict (with tags) for a conversation, or ``None``."""
204
+ with self._lock:
205
+ conn = self._connect()
206
+ try:
207
+ row = conn.execute(
208
+ "SELECT id, model_name, created_at, last_active, turn_count FROM conversations WHERE id = ?",
209
+ (conversation_id,),
210
+ ).fetchone()
211
+ if row is None:
212
+ return None
213
+ return self._row_to_summary(conn, row)
214
+ finally:
215
+ conn.close()
216
+
217
+ def list_all(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
218
+ """Return summary dicts ordered by ``last_active`` descending."""
219
+ with self._lock:
220
+ conn = self._connect()
221
+ try:
222
+ rows = conn.execute(
223
+ """
224
+ SELECT id, model_name, created_at, last_active, turn_count
225
+ FROM conversations
226
+ ORDER BY last_active DESC
227
+ LIMIT ? OFFSET ?
228
+ """,
229
+ (limit, offset),
230
+ ).fetchall()
231
+ return [self._row_to_summary(conn, r) for r in rows]
232
+ finally:
233
+ conn.close()
234
+
235
+ # ------------------------------------------------------------------ #
236
+ # Internal
237
+ # ------------------------------------------------------------------ #
238
+
239
+ @staticmethod
240
+ def _row_to_summary(conn: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]:
241
+ """Build a summary dict from a DB row, including tags."""
242
+ cid = row["id"]
243
+ tag_rows = conn.execute(
244
+ "SELECT tag FROM conversation_tags WHERE conversation_id = ?",
245
+ (cid,),
246
+ ).fetchall()
247
+ return {
248
+ "id": cid,
249
+ "model_name": row["model_name"],
250
+ "created_at": row["created_at"],
251
+ "last_active": row["last_active"],
252
+ "turn_count": row["turn_count"],
253
+ "tags": [tr["tag"] for tr in tag_rows],
254
+ }