axio-context-sqlite 0.1.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.
|
File without changes
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""SQLiteContextStore: persistent conversation storage backed by SQLite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import gzip
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import aiosqlite
|
|
13
|
+
from axio.context import ContextStore, SessionInfo
|
|
14
|
+
from axio.messages import Message
|
|
15
|
+
|
|
16
|
+
# Compress content payloads above this size (bytes of UTF-8 JSON).
|
|
17
|
+
COMPRESS_THRESHOLD = 512
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compress_payload(data: str) -> str:
|
|
21
|
+
raw = data.encode()
|
|
22
|
+
if len(raw) < COMPRESS_THRESHOLD:
|
|
23
|
+
return "plain:" + data
|
|
24
|
+
return "gzip:" + base64.b64encode(gzip.compress(raw, compresslevel=6)).decode()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decompress_payload(data: str) -> str:
|
|
28
|
+
if data.startswith("gzip:"):
|
|
29
|
+
return gzip.decompress(base64.b64decode(data[5:])).decode()
|
|
30
|
+
if data.startswith("plain:"):
|
|
31
|
+
return data[6:]
|
|
32
|
+
# raw JSON
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def connect(db_path: str | Path) -> aiosqlite.Connection:
|
|
37
|
+
"""Open (or create) a SQLite database and initialise the schema.
|
|
38
|
+
|
|
39
|
+
The caller is responsible for closing the returned connection.
|
|
40
|
+
"""
|
|
41
|
+
path = Path(db_path)
|
|
42
|
+
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
43
|
+
conn = await aiosqlite.connect(str(path))
|
|
44
|
+
await conn.create_function("compress_payload", 1, compress_payload, deterministic=True)
|
|
45
|
+
await conn.create_function("decompress_payload", 1, decompress_payload, deterministic=True)
|
|
46
|
+
await conn.execute("PRAGMA journal_mode=WAL")
|
|
47
|
+
await conn.execute("PRAGMA busy_timeout=5000")
|
|
48
|
+
await conn.execute("PRAGMA synchronous=NORMAL")
|
|
49
|
+
await conn.execute(
|
|
50
|
+
"CREATE TABLE IF NOT EXISTS axio_context_messages ("
|
|
51
|
+
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
52
|
+
" session_id TEXT NOT NULL,"
|
|
53
|
+
" project TEXT NOT NULL,"
|
|
54
|
+
" position INTEGER NOT NULL,"
|
|
55
|
+
" role TEXT NOT NULL,"
|
|
56
|
+
" content TEXT NOT NULL,"
|
|
57
|
+
" created_at TEXT NOT NULL DEFAULT (datetime('now')),"
|
|
58
|
+
" UNIQUE(session_id, position)"
|
|
59
|
+
")"
|
|
60
|
+
)
|
|
61
|
+
await conn.execute(
|
|
62
|
+
"CREATE INDEX IF NOT EXISTS idx_axio_context_messages_session ON axio_context_messages(session_id)"
|
|
63
|
+
)
|
|
64
|
+
await conn.execute(
|
|
65
|
+
"CREATE INDEX IF NOT EXISTS idx_axio_context_messages_project ON axio_context_messages(project)"
|
|
66
|
+
)
|
|
67
|
+
await conn.execute(
|
|
68
|
+
"CREATE TABLE IF NOT EXISTS axio_context_tokens ("
|
|
69
|
+
" session_id TEXT NOT NULL,"
|
|
70
|
+
" project TEXT NOT NULL,"
|
|
71
|
+
" input_tokens INTEGER NOT NULL DEFAULT 0,"
|
|
72
|
+
" output_tokens INTEGER NOT NULL DEFAULT 0,"
|
|
73
|
+
" PRIMARY KEY(session_id, project)"
|
|
74
|
+
")"
|
|
75
|
+
)
|
|
76
|
+
await conn.commit()
|
|
77
|
+
return conn
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_preview(content_json: str, max_len: int = 80) -> str:
|
|
81
|
+
"""Extract text preview from serialized content JSON."""
|
|
82
|
+
try:
|
|
83
|
+
blocks = json.loads(content_json)
|
|
84
|
+
for b in blocks:
|
|
85
|
+
if b.get("type") == "text":
|
|
86
|
+
text: str = b["text"]
|
|
87
|
+
return text[:max_len] + ("..." if len(text) > max_len else "")
|
|
88
|
+
except (json.JSONDecodeError, KeyError):
|
|
89
|
+
pass
|
|
90
|
+
return "(no preview)"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SQLiteContextStore(ContextStore):
|
|
94
|
+
"""Persistent conversation storage backed by SQLite.
|
|
95
|
+
|
|
96
|
+
The caller owns the connection and is responsible for closing it.
|
|
97
|
+
Use :func:`connect` to open a properly initialized connection.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
conn: aiosqlite.Connection,
|
|
103
|
+
session_id: str,
|
|
104
|
+
project: str | None = None,
|
|
105
|
+
db_name: str = "axio_context",
|
|
106
|
+
) -> None:
|
|
107
|
+
self._conn = conn
|
|
108
|
+
self._db_name = db_name
|
|
109
|
+
self._session_id = session_id
|
|
110
|
+
self._project = project or str(Path.cwd().resolve())
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def session_id(self) -> str:
|
|
114
|
+
return self._session_id
|
|
115
|
+
|
|
116
|
+
async def append(self, message: Message) -> None:
|
|
117
|
+
content_json = json.dumps(message.to_dict()["content"])
|
|
118
|
+
await self._conn.execute(
|
|
119
|
+
"INSERT INTO axio_context_messages (session_id, project, position, role, content)"
|
|
120
|
+
"VALUES (?, ?, (SELECT COUNT(*) FROM axio_context_messages WHERE session_id = ?), ?, compress_payload(?))",
|
|
121
|
+
(self._session_id, self._project, self._session_id, message.role, content_json),
|
|
122
|
+
)
|
|
123
|
+
await self._conn.commit()
|
|
124
|
+
|
|
125
|
+
async def get_history(self) -> list[Message]:
|
|
126
|
+
async with self._conn.execute(
|
|
127
|
+
"SELECT role, decompress_payload(content) FROM axio_context_messages"
|
|
128
|
+
" WHERE session_id = ? ORDER BY position",
|
|
129
|
+
(self._session_id,),
|
|
130
|
+
) as cursor:
|
|
131
|
+
rows = await cursor.fetchall()
|
|
132
|
+
return [Message.from_dict({"role": role, "content": json.loads(content)}) for role, content in rows]
|
|
133
|
+
|
|
134
|
+
async def clear(self) -> None:
|
|
135
|
+
await self._conn.execute("DELETE FROM axio_context_messages WHERE session_id = ?", (self._session_id,))
|
|
136
|
+
await self._conn.execute(
|
|
137
|
+
"DELETE FROM axio_context_tokens WHERE session_id = ? AND project = ?",
|
|
138
|
+
(self._session_id, self._project),
|
|
139
|
+
)
|
|
140
|
+
await self._conn.commit()
|
|
141
|
+
|
|
142
|
+
async def fork(self) -> SQLiteContextStore:
|
|
143
|
+
new_id = uuid4().hex
|
|
144
|
+
await self._conn.execute(
|
|
145
|
+
"INSERT INTO axio_context_messages (session_id, project, position, role, content)"
|
|
146
|
+
"SELECT ?, project, position, role, content FROM axio_context_messages WHERE session_id = ?",
|
|
147
|
+
(new_id, self._session_id),
|
|
148
|
+
)
|
|
149
|
+
await self._conn.execute(
|
|
150
|
+
"INSERT OR IGNORE INTO axio_context_tokens (session_id, project, input_tokens, output_tokens) "
|
|
151
|
+
"SELECT ?, project, input_tokens, output_tokens FROM axio_context_tokens "
|
|
152
|
+
"WHERE session_id = ? AND project = ?",
|
|
153
|
+
(new_id, self._session_id, self._project),
|
|
154
|
+
)
|
|
155
|
+
await self._conn.commit()
|
|
156
|
+
return SQLiteContextStore(self._conn, new_id, self._project)
|
|
157
|
+
|
|
158
|
+
async def set_context_tokens(self, input_tokens: int, output_tokens: int) -> None:
|
|
159
|
+
await self._conn.execute(
|
|
160
|
+
"INSERT INTO axio_context_tokens (session_id, project, input_tokens, output_tokens)"
|
|
161
|
+
"VALUES (?, ?, ?, ?) "
|
|
162
|
+
"ON CONFLICT(session_id, project) DO UPDATE SET input_tokens=?, output_tokens=?",
|
|
163
|
+
(self._session_id, self._project, input_tokens, output_tokens, input_tokens, output_tokens),
|
|
164
|
+
)
|
|
165
|
+
await self._conn.commit()
|
|
166
|
+
|
|
167
|
+
async def add_context_tokens(self, input_tokens: int, output_tokens: int) -> None:
|
|
168
|
+
await self._conn.execute(
|
|
169
|
+
"INSERT INTO axio_context_tokens (session_id, project, input_tokens, output_tokens)"
|
|
170
|
+
"VALUES (?, ?, ?, ?) "
|
|
171
|
+
"ON CONFLICT(session_id, project) DO UPDATE "
|
|
172
|
+
"SET input_tokens = input_tokens + excluded.input_tokens, "
|
|
173
|
+
" output_tokens = output_tokens + excluded.output_tokens",
|
|
174
|
+
(self._session_id, self._project, input_tokens, output_tokens),
|
|
175
|
+
)
|
|
176
|
+
await self._conn.commit()
|
|
177
|
+
|
|
178
|
+
async def get_context_tokens(self) -> tuple[int, int]:
|
|
179
|
+
async with self._conn.execute(
|
|
180
|
+
"SELECT input_tokens, output_tokens FROM axio_context_tokens WHERE session_id = ? AND project = ?",
|
|
181
|
+
(self._session_id, self._project),
|
|
182
|
+
) as cursor:
|
|
183
|
+
row = await cursor.fetchone()
|
|
184
|
+
if row is None:
|
|
185
|
+
return 0, 0
|
|
186
|
+
return int(row[0]), int(row[1])
|
|
187
|
+
|
|
188
|
+
async def list_sessions(self) -> list[SessionInfo]:
|
|
189
|
+
"""List all sessions for a project, newest first."""
|
|
190
|
+
async with self._conn.execute(
|
|
191
|
+
"SELECT m.session_id, COUNT(*) as cnt, "
|
|
192
|
+
"(SELECT decompress_payload(content) FROM axio_context_messages WHERE session_id = m.session_id "
|
|
193
|
+
"AND role = 'user' ORDER BY position LIMIT 1) as first_content, "
|
|
194
|
+
"MIN(m.created_at) as created, "
|
|
195
|
+
"COALESCE(ct.input_tokens, 0), COALESCE(ct.output_tokens, 0) "
|
|
196
|
+
"FROM axio_context_messages m "
|
|
197
|
+
"LEFT JOIN axio_context_tokens ct ON ct.session_id = m.session_id AND ct.project = m.project "
|
|
198
|
+
"WHERE m.project = ? "
|
|
199
|
+
"GROUP BY m.session_id ORDER BY created DESC",
|
|
200
|
+
(self._project,),
|
|
201
|
+
) as cursor:
|
|
202
|
+
rows = await cursor.fetchall()
|
|
203
|
+
result: list[SessionInfo] = []
|
|
204
|
+
for session_id, count, first_content, created_at, in_tok, out_tok in rows:
|
|
205
|
+
preview = _extract_preview(first_content) if first_content else "(no preview)"
|
|
206
|
+
result.append(
|
|
207
|
+
SessionInfo(
|
|
208
|
+
session_id=session_id,
|
|
209
|
+
message_count=count,
|
|
210
|
+
preview=preview,
|
|
211
|
+
created_at=created_at,
|
|
212
|
+
input_tokens=int(in_tok),
|
|
213
|
+
output_tokens=int(out_tok),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return result
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axio-context-sqlite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQLite-backed context store for Axio
|
|
5
|
+
Project-URL: Homepage, https://github.com/axio-agent/monorepo
|
|
6
|
+
Project-URL: Repository, https://github.com/axio-agent/monorepo
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agent,ai,context,llm,sqlite,storage
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: aiosqlite>=0.20
|
|
11
|
+
Requires-Dist: axio
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# axio-context-sqlite
|
|
15
|
+
|
|
16
|
+
[](https://pypi.org/project/axio-context-sqlite/)
|
|
17
|
+
[](https://pypi.org/project/axio-context-sqlite/)
|
|
18
|
+
[](LICENSE)
|
|
19
|
+
|
|
20
|
+
SQLite-backed persistent context store for [axio](https://github.com/axio-agent/monorepo).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install axio-context-sqlite
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Open a connection with `connect()`, then create a `SQLiteContextStore` bound to a
|
|
31
|
+
session. The caller owns the connection and is responsible for closing it.
|
|
32
|
+
|
|
33
|
+
<!-- name: test_readme_usage -->
|
|
34
|
+
```python
|
|
35
|
+
import asyncio
|
|
36
|
+
import tempfile
|
|
37
|
+
import pathlib
|
|
38
|
+
from axio_context_sqlite import connect, SQLiteContextStore
|
|
39
|
+
from axio.messages import Message
|
|
40
|
+
from axio.blocks import TextBlock
|
|
41
|
+
|
|
42
|
+
async def main() -> None:
|
|
43
|
+
conn = await connect(pathlib.Path(tempfile.mkdtemp()) / "chat.db")
|
|
44
|
+
try:
|
|
45
|
+
store = SQLiteContextStore(conn, session_id="my-session")
|
|
46
|
+
await store.append(Message(role="user", content=[TextBlock(text="Hello")]))
|
|
47
|
+
history = await store.get_history()
|
|
48
|
+
assert len(history) == 1
|
|
49
|
+
finally:
|
|
50
|
+
await conn.close()
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`SQLiteContextStore` implements the `axio.context.ContextStore` ABC and persists
|
|
56
|
+
conversation history across process restarts. Multiple sessions can coexist in
|
|
57
|
+
the same database file, isolated by `session_id` and `project`.
|
|
58
|
+
|
|
59
|
+
### Agent integration
|
|
60
|
+
|
|
61
|
+
<!--
|
|
62
|
+
name: test_readme_agent
|
|
63
|
+
```python
|
|
64
|
+
from axio.testing import StubTransport, make_text_response
|
|
65
|
+
transport = StubTransport([make_text_response("Hi!")])
|
|
66
|
+
```
|
|
67
|
+
-->
|
|
68
|
+
<!-- name: test_readme_agent -->
|
|
69
|
+
```python
|
|
70
|
+
import asyncio
|
|
71
|
+
import tempfile
|
|
72
|
+
import pathlib
|
|
73
|
+
from axio.agent import Agent
|
|
74
|
+
from axio_context_sqlite import connect, SQLiteContextStore
|
|
75
|
+
|
|
76
|
+
async def main() -> None:
|
|
77
|
+
conn = await connect(pathlib.Path(tempfile.mkdtemp()) / "chat.db")
|
|
78
|
+
try:
|
|
79
|
+
ctx = SQLiteContextStore(conn, session_id="main")
|
|
80
|
+
agent = Agent(system="You are helpful.", tools=[], transport=transport)
|
|
81
|
+
result = await agent.run("Hello!", ctx)
|
|
82
|
+
assert result == "Hi!"
|
|
83
|
+
finally:
|
|
84
|
+
await conn.close()
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Listing sessions
|
|
90
|
+
|
|
91
|
+
<!-- name: test_readme_list_sessions -->
|
|
92
|
+
```python
|
|
93
|
+
import asyncio
|
|
94
|
+
import tempfile
|
|
95
|
+
import pathlib
|
|
96
|
+
from axio_context_sqlite import connect, SQLiteContextStore
|
|
97
|
+
from axio.messages import Message
|
|
98
|
+
from axio.blocks import TextBlock
|
|
99
|
+
|
|
100
|
+
async def main() -> None:
|
|
101
|
+
conn = await connect(pathlib.Path(tempfile.mkdtemp()) / "chat.db")
|
|
102
|
+
try:
|
|
103
|
+
store = SQLiteContextStore(conn, session_id="main", project="/myproject")
|
|
104
|
+
await store.append(Message(role="user", content=[TextBlock(text="hi")]))
|
|
105
|
+
sessions = await store.list_sessions()
|
|
106
|
+
for s in sessions:
|
|
107
|
+
print(s.session_id, s.preview, s.message_count)
|
|
108
|
+
assert len(sessions) == 1
|
|
109
|
+
finally:
|
|
110
|
+
await conn.close()
|
|
111
|
+
|
|
112
|
+
asyncio.run(main())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Forking
|
|
116
|
+
|
|
117
|
+
`fork()` copies the current session's messages into a new session — useful for
|
|
118
|
+
branching conversations without affecting the original:
|
|
119
|
+
|
|
120
|
+
<!-- name: test_readme_fork -->
|
|
121
|
+
```python
|
|
122
|
+
import asyncio
|
|
123
|
+
import tempfile
|
|
124
|
+
import pathlib
|
|
125
|
+
from axio_context_sqlite import connect, SQLiteContextStore
|
|
126
|
+
from axio.messages import Message
|
|
127
|
+
from axio.blocks import TextBlock
|
|
128
|
+
|
|
129
|
+
async def main() -> None:
|
|
130
|
+
conn = await connect(pathlib.Path(tempfile.mkdtemp()) / "chat.db")
|
|
131
|
+
try:
|
|
132
|
+
store = SQLiteContextStore(conn, session_id="main")
|
|
133
|
+
await store.append(Message(role="user", content=[TextBlock(text="original")]))
|
|
134
|
+
branch = await store.fork()
|
|
135
|
+
assert branch.session_id != store.session_id
|
|
136
|
+
assert len(await branch.get_history()) == 1
|
|
137
|
+
finally:
|
|
138
|
+
await conn.close()
|
|
139
|
+
|
|
140
|
+
asyncio.run(main())
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
axio_context_sqlite/__init__.py,sha256=8yfg7reJVkSbnm8ptVx8XmRGOEr0f6wuXEYMCkfAwU4,177
|
|
2
|
+
axio_context_sqlite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
axio_context_sqlite/store.py,sha256=qW0MU7NEKu-5QsK1lrzaDXf-Hl7y2N1VMW-5wZWzOGk,8780
|
|
4
|
+
axio_context_sqlite-0.1.0.dist-info/METADATA,sha256=fEHoDUoOh8JFfzU7bCHVyBDbfc_71NpecXvL5MsFqjo,4250
|
|
5
|
+
axio_context_sqlite-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
axio_context_sqlite-0.1.0.dist-info/RECORD,,
|