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.
@@ -0,0 +1,5 @@
1
+ """axio-context-sqlite: SQLite-backed context store for Axio."""
2
+
3
+ from axio_context_sqlite.store import SQLiteContextStore, connect
4
+
5
+ __all__ = ["SQLiteContextStore", "connect"]
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
+ [![PyPI](https://img.shields.io/pypi/v/axio-context-sqlite)](https://pypi.org/project/axio-context-sqlite/)
17
+ [![Python](https://img.shields.io/pypi/pyversions/axio-context-sqlite)](https://pypi.org/project/axio-context-sqlite/)
18
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any