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.
- echostate-0.1.0/PKG-INFO +110 -0
- echostate-0.1.0/README.md +85 -0
- echostate-0.1.0/echostate/__init__.py +27 -0
- echostate-0.1.0/echostate/database.py +413 -0
- echostate-0.1.0/echostate/embeddings.py +143 -0
- echostate-0.1.0/echostate/event.py +43 -0
- echostate-0.1.0/echostate/exceptions.py +31 -0
- echostate-0.1.0/echostate/path_utils.py +103 -0
- echostate-0.1.0/echostate/state.py +605 -0
- echostate-0.1.0/echostate.egg-info/PKG-INFO +110 -0
- echostate-0.1.0/echostate.egg-info/SOURCES.txt +20 -0
- echostate-0.1.0/echostate.egg-info/dependency_links.txt +1 -0
- echostate-0.1.0/echostate.egg-info/requires.txt +8 -0
- echostate-0.1.0/echostate.egg-info/top_level.txt +1 -0
- echostate-0.1.0/pyproject.toml +50 -0
- echostate-0.1.0/setup.cfg +4 -0
- echostate-0.1.0/tests/test_basic.py +158 -0
- echostate-0.1.0/tests/test_errors.py +75 -0
- echostate-0.1.0/tests/test_history.py +111 -0
- echostate-0.1.0/tests/test_integration.py +183 -0
- echostate-0.1.0/tests/test_search.py +128 -0
- echostate-0.1.0/tests/test_snapshots.py +146 -0
echostate-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|