echostate 0.1.0__tar.gz

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,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: echostate
3
+ Version: 0.1.0
4
+ Summary: Semantic, event-sourced state for intelligent systems
5
+ Author: EchoState Contributors
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: numpy>=1.20.0
19
+ Requires-Dist: sentence-transformers>=2.2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
22
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
23
+ Requires-Dist: black>=23.0.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
25
+
26
+ # EchoState
27
+
28
+ **Semantic, event-sourced state for intelligent systems**
29
+
30
+ EchoState is a Python library that gives your applications **permanent memory** and **semantic search**. Every change you make is saved permanently, and you can search by meaning, not just exact words.
31
+
32
+ > Think of EchoState as **Git + SQLite + Vector Search — for runtime state**.
33
+
34
+ ## Why EchoState?
35
+
36
+ Building AI agents or long-running applications? You need:
37
+ - ✅ **Memory that persists** - Data survives restarts
38
+ - ✅ **Search by meaning** - Find "refund requests" even if stored as "reimbursement"
39
+ - ✅ **Complete history** - See every change that happened
40
+ - ✅ **Auditability** - Prove what happened and when
41
+
42
+ EchoState provides all of this with a simple, Pythonic interface.
43
+
44
+ ## Features (v0.1)
45
+
46
+ - ✅ Event-sourced state with deterministic replay
47
+ - ✅ SQLite-based persistence
48
+ - ✅ Semantic search over explicitly indexed keys
49
+ - ✅ History inspection and time-travel snapshots
50
+ - ✅ Rebuildable vector index
51
+
52
+ ## Quick Start
53
+
54
+ ```python
55
+ from echostate import EchoState
56
+
57
+ # Create a state instance
58
+ state = EchoState(
59
+ name="agent_state",
60
+ persist="sqlite:///state.db",
61
+ index_keys=["notes", "profile"],
62
+ )
63
+
64
+ # Write state
65
+ state.set("profile.tier", "Gold", metadata={"user_id": "u-42"})
66
+ state.append("notes", "Customer requested refund", metadata={"trace_id": "t-123"})
67
+
68
+ # Semantic search
69
+ results = state.search("refund duplicate charge", k=5)
70
+ for hit in results:
71
+ print(f"{hit.path}: {hit.text} (score: {hit.score})")
72
+
73
+ # Inspect history
74
+ events = state.history("notes", filters={"user_id": "u-42"}, limit=100)
75
+
76
+ # Time-travel snapshot
77
+ past_state = state.snapshot(at="2026-01-05T10:30:00Z")
78
+ ```
79
+
80
+ ## Documentation
81
+
82
+ - 📖 **[Complete Manual](MANUAL.md)** - Beginner-friendly guide with examples and use cases
83
+ - 🔧 **[Implementation Plan](echo_state_implementation_plan.md)** - Technical design details
84
+ - 💡 **[Project Idea](echo_state_project_idea.md)** - Vision and motivation
85
+
86
+ ## Installation
87
+
88
+ ```bash
89
+ pip install echostate
90
+ ```
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ # Install with dev dependencies
96
+ pip install -e ".[dev]"
97
+
98
+ # Run tests
99
+ pytest
100
+
101
+ # Format code
102
+ black echostate/
103
+
104
+ # Lint
105
+ ruff check echostate/
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,85 @@
1
+ # EchoState
2
+
3
+ **Semantic, event-sourced state for intelligent systems**
4
+
5
+ EchoState is a Python library that gives your applications **permanent memory** and **semantic search**. Every change you make is saved permanently, and you can search by meaning, not just exact words.
6
+
7
+ > Think of EchoState as **Git + SQLite + Vector Search — for runtime state**.
8
+
9
+ ## Why EchoState?
10
+
11
+ Building AI agents or long-running applications? You need:
12
+ - ✅ **Memory that persists** - Data survives restarts
13
+ - ✅ **Search by meaning** - Find "refund requests" even if stored as "reimbursement"
14
+ - ✅ **Complete history** - See every change that happened
15
+ - ✅ **Auditability** - Prove what happened and when
16
+
17
+ EchoState provides all of this with a simple, Pythonic interface.
18
+
19
+ ## Features (v0.1)
20
+
21
+ - ✅ Event-sourced state with deterministic replay
22
+ - ✅ SQLite-based persistence
23
+ - ✅ Semantic search over explicitly indexed keys
24
+ - ✅ History inspection and time-travel snapshots
25
+ - ✅ Rebuildable vector index
26
+
27
+ ## Quick Start
28
+
29
+ ```python
30
+ from echostate import EchoState
31
+
32
+ # Create a state instance
33
+ state = EchoState(
34
+ name="agent_state",
35
+ persist="sqlite:///state.db",
36
+ index_keys=["notes", "profile"],
37
+ )
38
+
39
+ # Write state
40
+ state.set("profile.tier", "Gold", metadata={"user_id": "u-42"})
41
+ state.append("notes", "Customer requested refund", metadata={"trace_id": "t-123"})
42
+
43
+ # Semantic search
44
+ results = state.search("refund duplicate charge", k=5)
45
+ for hit in results:
46
+ print(f"{hit.path}: {hit.text} (score: {hit.score})")
47
+
48
+ # Inspect history
49
+ events = state.history("notes", filters={"user_id": "u-42"}, limit=100)
50
+
51
+ # Time-travel snapshot
52
+ past_state = state.snapshot(at="2026-01-05T10:30:00Z")
53
+ ```
54
+
55
+ ## Documentation
56
+
57
+ - 📖 **[Complete Manual](MANUAL.md)** - Beginner-friendly guide with examples and use cases
58
+ - 🔧 **[Implementation Plan](echo_state_implementation_plan.md)** - Technical design details
59
+ - 💡 **[Project Idea](echo_state_project_idea.md)** - Vision and motivation
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install echostate
65
+ ```
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ # Install with dev dependencies
71
+ pip install -e ".[dev]"
72
+
73
+ # Run tests
74
+ pytest
75
+
76
+ # Format code
77
+ black echostate/
78
+
79
+ # Lint
80
+ ruff check echostate/
81
+ ```
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,27 @@
1
+ """
2
+ EchoState - Semantic, event-sourced state for intelligent systems
3
+ """
4
+
5
+ from echostate.state import EchoState
6
+ from echostate.event import Event
7
+ from echostate.embeddings import SearchHit
8
+ from echostate.exceptions import (
9
+ EchoStateError,
10
+ EchoStateLockedError,
11
+ EchoStateSerializationError,
12
+ EchoStatePathError,
13
+ EchoStateEmbeddingError,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "EchoState",
20
+ "Event",
21
+ "SearchHit",
22
+ "EchoStateError",
23
+ "EchoStateLockedError",
24
+ "EchoStateSerializationError",
25
+ "EchoStatePathError",
26
+ "EchoStateEmbeddingError",
27
+ ]
@@ -0,0 +1,413 @@
1
+ """SQLite database management for EchoState."""
2
+
3
+ import sqlite3
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional, List, Dict, Any
7
+ from contextlib import contextmanager
8
+
9
+ from echostate.exceptions import EchoStateLockedError
10
+ from echostate.event import Event
11
+
12
+
13
+ class Database:
14
+ """Manages SQLite database for EchoState."""
15
+
16
+ def __init__(self, db_path: str):
17
+ """
18
+ Initialize database connection.
19
+
20
+ Args:
21
+ db_path: SQLite database path (e.g., "sqlite:///state.db" or "state.db")
22
+ """
23
+ # Parse sqlite:/// prefix if present
24
+ if db_path.startswith("sqlite:///"):
25
+ db_path = db_path[10:] # Remove "sqlite:///"
26
+ elif db_path.startswith("sqlite://"):
27
+ db_path = db_path[9:] # Remove "sqlite://"
28
+
29
+ self.db_path = Path(db_path)
30
+ self._conn: Optional[sqlite3.Connection] = None
31
+ self._ensure_schema()
32
+
33
+ def _ensure_schema(self):
34
+ """Create database schema if it doesn't exist."""
35
+ conn = self._get_connection()
36
+ cursor = conn.cursor()
37
+
38
+ # Events table
39
+ cursor.execute("""
40
+ CREATE TABLE IF NOT EXISTS events (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ timestamp INTEGER NOT NULL,
43
+ path TEXT NOT NULL,
44
+ operation TEXT NOT NULL,
45
+ value TEXT,
46
+ event_version INTEGER NOT NULL DEFAULT 1,
47
+ metadata TEXT
48
+ )
49
+ """)
50
+
51
+ # Snapshots table
52
+ cursor.execute("""
53
+ CREATE TABLE IF NOT EXISTS snapshots (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ last_event_id INTEGER NOT NULL,
56
+ snapshot TEXT NOT NULL,
57
+ timestamp INTEGER NOT NULL
58
+ )
59
+ """)
60
+
61
+ # Metadata table (for EchoState instance metadata)
62
+ cursor.execute("""
63
+ CREATE TABLE IF NOT EXISTS metadata (
64
+ key TEXT PRIMARY KEY,
65
+ value TEXT NOT NULL
66
+ )
67
+ """)
68
+
69
+ # Embeddings table (for semantic index)
70
+ cursor.execute("""
71
+ CREATE TABLE IF NOT EXISTS embeddings (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ event_id INTEGER NOT NULL,
74
+ path TEXT NOT NULL,
75
+ text TEXT NOT NULL,
76
+ embedding BLOB NOT NULL,
77
+ model_id TEXT NOT NULL,
78
+ metadata TEXT
79
+ )
80
+ """)
81
+
82
+ # Indexes
83
+ cursor.execute("""
84
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp)
85
+ """)
86
+ cursor.execute("""
87
+ CREATE INDEX IF NOT EXISTS idx_events_path ON events(path)
88
+ """)
89
+ cursor.execute("""
90
+ CREATE INDEX IF NOT EXISTS idx_embeddings_event_id ON embeddings(event_id)
91
+ """)
92
+ cursor.execute("""
93
+ CREATE INDEX IF NOT EXISTS idx_embeddings_path ON embeddings(path)
94
+ """)
95
+
96
+ conn.commit()
97
+
98
+ def _get_connection(self) -> sqlite3.Connection:
99
+ """Get or create database connection."""
100
+ if self._conn is None:
101
+ try:
102
+ self._conn = sqlite3.connect(
103
+ str(self.db_path),
104
+ check_same_thread=False,
105
+ timeout=5.0,
106
+ )
107
+ self._conn.row_factory = sqlite3.Row
108
+ except sqlite3.OperationalError as e:
109
+ if "locked" in str(e).lower():
110
+ raise EchoStateLockedError(
111
+ f"Database is locked: {self.db_path}"
112
+ ) from e
113
+ raise
114
+ return self._conn
115
+
116
+ @contextmanager
117
+ def transaction(self):
118
+ """Context manager for database transactions."""
119
+ conn = self._get_connection()
120
+ try:
121
+ yield conn
122
+ conn.commit()
123
+ except Exception:
124
+ conn.rollback()
125
+ raise
126
+
127
+ def append_event(self, event: Event) -> int:
128
+ """
129
+ Append an event to the event log.
130
+
131
+ Args:
132
+ event: Event to append
133
+
134
+ Returns:
135
+ The event ID assigned by the database
136
+ """
137
+ with self.transaction() as conn:
138
+ cursor = conn.cursor()
139
+ event_dict = event.to_dict()
140
+ cursor.execute("""
141
+ INSERT INTO events (timestamp, path, operation, value, event_version, metadata)
142
+ VALUES (:timestamp, :path, :operation, :value, :event_version, :metadata)
143
+ """, event_dict)
144
+ return cursor.lastrowid
145
+
146
+ def get_events(
147
+ self,
148
+ path: Optional[str] = None,
149
+ since_event_id: Optional[int] = None,
150
+ limit: Optional[int] = None,
151
+ order_desc: bool = False,
152
+ ) -> List[Event]:
153
+ """
154
+ Retrieve events from the event log.
155
+
156
+ Args:
157
+ path: Optional path filter (exact match)
158
+ since_event_id: Only return events with id > since_event_id
159
+ limit: Maximum number of events to return
160
+ order_desc: If True, order by id descending (most recent first)
161
+
162
+ Returns:
163
+ List of events, ordered by id (ascending by default, descending if order_desc=True)
164
+ """
165
+ conn = self._get_connection()
166
+ cursor = conn.cursor()
167
+
168
+ query = "SELECT * FROM events WHERE 1=1"
169
+ params = []
170
+
171
+ if path is not None:
172
+ query += " AND path = ?"
173
+ params.append(path)
174
+
175
+ if since_event_id is not None:
176
+ query += " AND id > ?"
177
+ params.append(since_event_id)
178
+
179
+ query += f" ORDER BY id {'DESC' if order_desc else 'ASC'}"
180
+
181
+ if limit is not None:
182
+ query += " LIMIT ?"
183
+ params.append(limit)
184
+
185
+ cursor.execute(query, params)
186
+ rows = cursor.fetchall()
187
+ return [Event.from_dict(dict(row)) for row in rows]
188
+
189
+ def get_events_with_metadata_filter(
190
+ self,
191
+ path: Optional[str] = None,
192
+ metadata_filters: Optional[Dict[str, Any]] = None,
193
+ limit: Optional[int] = None,
194
+ ) -> List[Event]:
195
+ """
196
+ Retrieve events with metadata filtering (Python-side filtering in v0.1).
197
+
198
+ Args:
199
+ path: Optional path filter (exact match)
200
+ metadata_filters: Dict of metadata fields to match
201
+ limit: Maximum number of events to return (most recent first)
202
+
203
+ Returns:
204
+ List of events matching filters, ordered by id descending
205
+ """
206
+ # Get all events matching path (ordered descending for most recent first)
207
+ events = self.get_events(path=path, limit=None, order_desc=True)
208
+
209
+ # Apply metadata filters in Python (v0.1 approach)
210
+ if metadata_filters:
211
+ filtered = []
212
+ for event in events:
213
+ if event.metadata:
214
+ match = True
215
+ for key, value in metadata_filters.items():
216
+ if event.metadata.get(key) != value:
217
+ match = False
218
+ break
219
+ if match:
220
+ filtered.append(event)
221
+ else:
222
+ # Event has no metadata, skip if filters are specified
223
+ continue
224
+ events = filtered
225
+
226
+ # Apply limit after filtering
227
+ if limit is not None:
228
+ events = events[:limit]
229
+
230
+ return events
231
+
232
+ def get_snapshot_at_event_id(self, event_id: int) -> Optional[Dict[str, Any]]:
233
+ """
234
+ Get the latest snapshot that includes events up to the given event_id.
235
+
236
+ Args:
237
+ event_id: Target event ID
238
+
239
+ Returns:
240
+ Snapshot dict with keys: id, last_event_id, snapshot, timestamp
241
+ or None if no suitable snapshot exists
242
+ """
243
+ conn = self._get_connection()
244
+ cursor = conn.cursor()
245
+ cursor.execute("""
246
+ SELECT * FROM snapshots
247
+ WHERE last_event_id <= ?
248
+ ORDER BY last_event_id DESC
249
+ LIMIT 1
250
+ """, (event_id,))
251
+ row = cursor.fetchone()
252
+ if row is None:
253
+ return None
254
+ result = dict(row)
255
+ result["snapshot"] = json.loads(result["snapshot"])
256
+ return result
257
+
258
+ def get_events_up_to(self, event_id: int, since_event_id: Optional[int] = None) -> List[Event]:
259
+ """
260
+ Get events up to and including a specific event_id.
261
+
262
+ Args:
263
+ event_id: Maximum event ID to include
264
+ since_event_id: Only return events with id > since_event_id
265
+
266
+ Returns:
267
+ List of events ordered by id ascending
268
+ """
269
+ conn = self._get_connection()
270
+ cursor = conn.cursor()
271
+
272
+ query = "SELECT * FROM events WHERE id <= ?"
273
+ params = [event_id]
274
+
275
+ if since_event_id is not None:
276
+ query += " AND id > ?"
277
+ params.append(since_event_id)
278
+
279
+ query += " ORDER BY id ASC"
280
+
281
+ cursor.execute(query, params)
282
+ rows = cursor.fetchall()
283
+ return [Event.from_dict(dict(row)) for row in rows]
284
+
285
+ def store_embedding(
286
+ self,
287
+ event_id: int,
288
+ path: str,
289
+ text: str,
290
+ embedding: bytes,
291
+ model_id: str,
292
+ metadata: Optional[str] = None,
293
+ ) -> int:
294
+ """
295
+ Store an embedding for an event.
296
+
297
+ Args:
298
+ event_id: Associated event ID
299
+ path: Event path
300
+ text: Derived record text
301
+ embedding: Embedding as bytes
302
+ model_id: Model identifier
303
+ metadata: Optional metadata JSON string
304
+
305
+ Returns:
306
+ Embedding ID
307
+ """
308
+ with self.transaction() as conn:
309
+ cursor = conn.cursor()
310
+ cursor.execute("""
311
+ INSERT INTO embeddings (event_id, path, text, embedding, model_id, metadata)
312
+ VALUES (?, ?, ?, ?, ?, ?)
313
+ """, (event_id, path, text, embedding, model_id, metadata))
314
+ return cursor.lastrowid
315
+
316
+ def get_all_embeddings(self, model_id: Optional[str] = None) -> List[Dict[str, Any]]:
317
+ """
318
+ Get all embeddings, optionally filtered by model_id.
319
+
320
+ Args:
321
+ model_id: Optional model ID filter
322
+
323
+ Returns:
324
+ List of embedding records as dicts
325
+ """
326
+ conn = self._get_connection()
327
+ cursor = conn.cursor()
328
+
329
+ if model_id:
330
+ cursor.execute("""
331
+ SELECT * FROM embeddings
332
+ WHERE model_id = ?
333
+ ORDER BY id ASC
334
+ """, (model_id,))
335
+ else:
336
+ cursor.execute("""
337
+ SELECT * FROM embeddings
338
+ ORDER BY id ASC
339
+ """)
340
+
341
+ rows = cursor.fetchall()
342
+ return [dict(row) for row in rows]
343
+
344
+ def truncate_embeddings(self, model_id: Optional[str] = None):
345
+ """
346
+ Delete all embeddings, optionally filtered by model_id.
347
+
348
+ Args:
349
+ model_id: If provided, only delete embeddings for this model
350
+ """
351
+ with self.transaction() as conn:
352
+ cursor = conn.cursor()
353
+ if model_id:
354
+ cursor.execute("DELETE FROM embeddings WHERE model_id = ?", (model_id,))
355
+ else:
356
+ cursor.execute("DELETE FROM embeddings")
357
+
358
+ def get_latest_snapshot(self) -> Optional[Dict[str, Any]]:
359
+ """
360
+ Get the latest snapshot.
361
+
362
+ Returns:
363
+ Dict with keys: id, last_event_id, snapshot (parsed JSON), timestamp
364
+ or None if no snapshot exists
365
+ """
366
+ conn = self._get_connection()
367
+ cursor = conn.cursor()
368
+ cursor.execute("""
369
+ SELECT * FROM snapshots
370
+ ORDER BY last_event_id DESC
371
+ LIMIT 1
372
+ """)
373
+ row = cursor.fetchone()
374
+ if row is None:
375
+ return None
376
+ result = dict(row)
377
+ result["snapshot"] = json.loads(result["snapshot"])
378
+ return result
379
+
380
+ def create_snapshot(self, last_event_id: int, snapshot: Dict[str, Any]) -> int:
381
+ """
382
+ Create a new snapshot.
383
+
384
+ Args:
385
+ last_event_id: The last event ID included in this snapshot
386
+ snapshot: The state snapshot as a dictionary
387
+
388
+ Returns:
389
+ The snapshot ID
390
+ """
391
+ with self.transaction() as conn:
392
+ cursor = conn.cursor()
393
+ import time
394
+ timestamp = int(time.time() * 1000)
395
+ cursor.execute("""
396
+ INSERT INTO snapshots (last_event_id, snapshot, timestamp)
397
+ VALUES (?, ?, ?)
398
+ """, (last_event_id, json.dumps(snapshot), timestamp))
399
+ return cursor.lastrowid
400
+
401
+ def close(self):
402
+ """Close database connection."""
403
+ if self._conn is not None:
404
+ self._conn.close()
405
+ self._conn = None
406
+
407
+ def __enter__(self):
408
+ """Context manager entry."""
409
+ return self
410
+
411
+ def __exit__(self, exc_type, exc_val, exc_tb):
412
+ """Context manager exit."""
413
+ self.close()