axio-context-sqlite 0.6.1__tar.gz → 0.7.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.
@@ -10,3 +10,5 @@ dist/
10
10
  build/
11
11
  _build/
12
12
  .DS_Store
13
+ *.sqlite
14
+ *.db
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axio-context-sqlite
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
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
5
+ Project-URL: Homepage, https://github.com/mosquito/axio-agent
6
+ Project-URL: Repository, https://github.com/mosquito/axio-agent
7
7
  License: MIT
8
8
  Keywords: agent,ai,context,llm,sqlite,storage
9
9
  Requires-Python: >=3.12
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
  [![Python](https://img.shields.io/pypi/pyversions/axio-context-sqlite)](https://pypi.org/project/axio-context-sqlite/)
18
18
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
19
19
 
20
- SQLite-backed persistent context store for [axio](https://github.com/axio-agent/monorepo).
20
+ SQLite-backed persistent context store for [axio](https://github.com/mosquito/axio-agent).
21
21
 
22
22
  ## Installation
23
23
 
@@ -27,6 +27,32 @@ pip install axio-context-sqlite
27
27
 
28
28
  ## Usage
29
29
 
30
+ ### `connect(db_path)`
31
+
32
+ Open (or create) a SQLite database at `db_path` and initialise the schema.
33
+ Returns an `aiosqlite.Connection`. The caller is responsible for closing it.
34
+
35
+ ```python
36
+ async def main():
37
+ conn = await connect("~/.axio/chat.db")
38
+ # ... use the connection ...
39
+ await conn.close()
40
+ ```
41
+
42
+ The helper enables WAL journal mode and a 5-second busy timeout so concurrent
43
+ readers and a single writer can safely share the same database file.
44
+
45
+ ### `SQLiteContextStore(conn, session_id, project=None, db_name="axio_context")`
46
+
47
+ Create a context store bound to one session.
48
+
49
+ | Parameter | Type | Default | Description |
50
+ |-----------|------|---------|-------------|
51
+ | `conn` | `aiosqlite.Connection` | — | Open database connection (from `connect()`) |
52
+ | `session_id` | `str` | — | Unique identifier for this conversation session |
53
+ | `project` | `str \| None` | `str(Path.cwd().resolve())` | Logical project scope used to group and list sessions. Defaults to the current working directory. |
54
+ | `db_name` | `str` | `"axio_context"` | Prefix used for the database table names (`axio_context_messages`, `axio_context_tokens`). |
55
+
30
56
  Open a connection with `connect()`, then create a `SQLiteContextStore` bound to a
31
57
  session. The caller owns the connection and is responsible for closing it.
32
58
 
@@ -56,6 +82,22 @@ asyncio.run(main())
56
82
  conversation history across process restarts. Multiple sessions can coexist in
57
83
  the same database file, isolated by `session_id` and `project`.
58
84
 
85
+ #### Storage and compression
86
+
87
+ Message content is stored as serialized JSON. Payloads larger than 512 bytes
88
+ are automatically compressed with gzip (compresslevel 6) and stored
89
+ base64-encoded with a `gzip:` prefix. Smaller payloads are stored as-is with a
90
+ `plain:` prefix. Decompression happens transparently on read — callers never
91
+ see the encoded form.
92
+
93
+ #### SQLite performance settings
94
+
95
+ Every connection opened by `connect()` is configured with:
96
+
97
+ - `PRAGMA journal_mode=WAL` — enables concurrent readers alongside one writer
98
+ - `PRAGMA busy_timeout=5000` — waits up to 5 seconds before raising a lock error
99
+ - `PRAGMA synchronous=NORMAL` — balances durability and write throughput
100
+
59
101
  ### Agent integration
60
102
 
61
103
  <!--
@@ -112,6 +154,16 @@ async def main() -> None:
112
154
  asyncio.run(main())
113
155
  ```
114
156
 
157
+ ### Token accounting
158
+
159
+ `add_context_tokens(input_tokens, output_tokens)` atomically increments the
160
+ stored token counts for the current session and project using a SQL UPSERT
161
+ (`INSERT ... ON CONFLICT DO UPDATE SET ... = ... + excluded....`). This is safe
162
+ to call concurrently from multiple coroutines without an application-level lock.
163
+
164
+ `set_context_tokens()` replaces the counts unconditionally, and
165
+ `get_context_tokens()` returns a `(input_tokens, output_tokens)` tuple.
166
+
115
167
  ### Forking
116
168
 
117
169
  `fork()` copies the current session's messages into a new session — useful for
@@ -4,7 +4,7 @@
4
4
  [![Python](https://img.shields.io/pypi/pyversions/axio-context-sqlite)](https://pypi.org/project/axio-context-sqlite/)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
 
7
- SQLite-backed persistent context store for [axio](https://github.com/axio-agent/monorepo).
7
+ SQLite-backed persistent context store for [axio](https://github.com/mosquito/axio-agent).
8
8
 
9
9
  ## Installation
10
10
 
@@ -14,6 +14,32 @@ pip install axio-context-sqlite
14
14
 
15
15
  ## Usage
16
16
 
17
+ ### `connect(db_path)`
18
+
19
+ Open (or create) a SQLite database at `db_path` and initialise the schema.
20
+ Returns an `aiosqlite.Connection`. The caller is responsible for closing it.
21
+
22
+ ```python
23
+ async def main():
24
+ conn = await connect("~/.axio/chat.db")
25
+ # ... use the connection ...
26
+ await conn.close()
27
+ ```
28
+
29
+ The helper enables WAL journal mode and a 5-second busy timeout so concurrent
30
+ readers and a single writer can safely share the same database file.
31
+
32
+ ### `SQLiteContextStore(conn, session_id, project=None, db_name="axio_context")`
33
+
34
+ Create a context store bound to one session.
35
+
36
+ | Parameter | Type | Default | Description |
37
+ |-----------|------|---------|-------------|
38
+ | `conn` | `aiosqlite.Connection` | — | Open database connection (from `connect()`) |
39
+ | `session_id` | `str` | — | Unique identifier for this conversation session |
40
+ | `project` | `str \| None` | `str(Path.cwd().resolve())` | Logical project scope used to group and list sessions. Defaults to the current working directory. |
41
+ | `db_name` | `str` | `"axio_context"` | Prefix used for the database table names (`axio_context_messages`, `axio_context_tokens`). |
42
+
17
43
  Open a connection with `connect()`, then create a `SQLiteContextStore` bound to a
18
44
  session. The caller owns the connection and is responsible for closing it.
19
45
 
@@ -43,6 +69,22 @@ asyncio.run(main())
43
69
  conversation history across process restarts. Multiple sessions can coexist in
44
70
  the same database file, isolated by `session_id` and `project`.
45
71
 
72
+ #### Storage and compression
73
+
74
+ Message content is stored as serialized JSON. Payloads larger than 512 bytes
75
+ are automatically compressed with gzip (compresslevel 6) and stored
76
+ base64-encoded with a `gzip:` prefix. Smaller payloads are stored as-is with a
77
+ `plain:` prefix. Decompression happens transparently on read — callers never
78
+ see the encoded form.
79
+
80
+ #### SQLite performance settings
81
+
82
+ Every connection opened by `connect()` is configured with:
83
+
84
+ - `PRAGMA journal_mode=WAL` — enables concurrent readers alongside one writer
85
+ - `PRAGMA busy_timeout=5000` — waits up to 5 seconds before raising a lock error
86
+ - `PRAGMA synchronous=NORMAL` — balances durability and write throughput
87
+
46
88
  ### Agent integration
47
89
 
48
90
  <!--
@@ -99,6 +141,16 @@ async def main() -> None:
99
141
  asyncio.run(main())
100
142
  ```
101
143
 
144
+ ### Token accounting
145
+
146
+ `add_context_tokens(input_tokens, output_tokens)` atomically increments the
147
+ stored token counts for the current session and project using a SQL UPSERT
148
+ (`INSERT ... ON CONFLICT DO UPDATE SET ... = ... + excluded....`). This is safe
149
+ to call concurrently from multiple coroutines without an application-level lock.
150
+
151
+ `set_context_tokens()` replaces the counts unconditionally, and
152
+ `get_context_tokens()` returns a `(input_tokens, output_tokens)` tuple.
153
+
102
154
  ### Forking
103
155
 
104
156
  `fork()` copies the current session's messages into a new session — useful for
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axio-context-sqlite"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "SQLite-backed context store for Axio"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -9,8 +9,8 @@ keywords = ["llm", "agent", "ai", "sqlite", "context", "storage"]
9
9
  dependencies = ["axio", "aiosqlite>=0.20"]
10
10
 
11
11
  [project.urls]
12
- Homepage = "https://github.com/axio-agent/monorepo"
13
- Repository = "https://github.com/axio-agent/monorepo"
12
+ Homepage = "https://github.com/mosquito/axio-agent"
13
+ Repository = "https://github.com/mosquito/axio-agent"
14
14
 
15
15
  [build-system]
16
16
  requires = ["hatchling"]
@@ -0,0 +1,158 @@
1
+ """Tests for AutoCompactStore wrapping SQLiteContextStore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator, AsyncIterator
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ import aiosqlite
11
+ import pytest
12
+ from axio.blocks import TextBlock
13
+ from axio.compaction import AutoCompactStore
14
+ from axio.events import StreamEvent
15
+ from axio.messages import Message
16
+ from axio.testing import StubTransport, make_text_response
17
+ from axio.tool import Tool
18
+
19
+ from axio_context_sqlite import SQLiteContextStore, connect
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _msg(role: str = "user", text: str = "x") -> Message:
27
+ return Message(role=role, content=[TextBlock(text=text)]) # type: ignore[arg-type]
28
+
29
+
30
+ class FailTransport:
31
+ def stream(self, messages: list[Message], tools: list[Tool[Any]], system: str) -> AsyncIterator[StreamEvent]:
32
+ raise RuntimeError("boom")
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Fixtures
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ @pytest.fixture
41
+ def db_path(tmp_path: Path) -> Path:
42
+ return tmp_path / "autocompact_test.db"
43
+
44
+
45
+ @pytest.fixture
46
+ async def conn(db_path: Path) -> AsyncGenerator[aiosqlite.Connection, None]:
47
+ c = await connect(db_path)
48
+ yield c
49
+ await c.close()
50
+
51
+
52
+ @pytest.fixture
53
+ async def sqlite_store(conn: aiosqlite.Connection) -> SQLiteContextStore:
54
+ return SQLiteContextStore(conn, uuid4().hex, "test-project")
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Tests
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ class TestAutoCompactWithSQLite:
63
+ async def test_history_written_back_to_sqlite(self, sqlite_store: SQLiteContextStore) -> None:
64
+ """After compaction, messages in the DB are the compacted ones."""
65
+ for i in range(14):
66
+ role = "user" if i % 2 == 0 else "assistant"
67
+ await sqlite_store.append(_msg(role, f"msg-{i}"))
68
+
69
+ transport = StubTransport([make_text_response("Summary of earlier work")])
70
+ store = AutoCompactStore(sqlite_store, transport, max_tokens=1, keep_recent=4)
71
+
72
+ await store.add_context_tokens(2, 0)
73
+
74
+ history = await sqlite_store.get_history()
75
+ # 2 (summary pair) + 4 recent
76
+ assert len(history) == 6
77
+ assert history[0].role == "user"
78
+ block0 = history[0].content[0]
79
+ assert isinstance(block0, TextBlock)
80
+ assert "Summary of earlier work" in block0.text
81
+ assert history[1].role == "assistant"
82
+
83
+ async def test_cumulative_tokens_persisted_in_sqlite(self, sqlite_store: SQLiteContextStore) -> None:
84
+ """Cumulative token counts survive compaction in the SQLite table."""
85
+ for i in range(14):
86
+ role = "user" if i % 2 == 0 else "assistant"
87
+ await sqlite_store.append(_msg(role, f"msg-{i}"))
88
+ await sqlite_store.set_context_tokens(400_000, 15_000)
89
+
90
+ transport = StubTransport([make_text_response("summary")])
91
+ store = AutoCompactStore(sqlite_store, transport, max_tokens=1, keep_recent=4)
92
+
93
+ await store.add_context_tokens(2, 0)
94
+
95
+ in_tok, out_tok = await sqlite_store.get_context_tokens()
96
+ assert in_tok == 400_002
97
+ assert out_tok == 15_000
98
+
99
+ async def test_sqlite_session_id_preserved(self, sqlite_store: SQLiteContextStore) -> None:
100
+ """session_id of wrapper matches the inner SQLiteContextStore."""
101
+ store = AutoCompactStore(sqlite_store, StubTransport([]), max_tokens=999_999)
102
+ assert store.session_id == sqlite_store.session_id
103
+
104
+ async def test_compact_does_not_corrupt_on_failure(self, sqlite_store: SQLiteContextStore) -> None:
105
+ """If compaction fails, the original history in the DB is untouched."""
106
+ msgs = [_msg("user" if i % 2 == 0 else "assistant", f"msg-{i}") for i in range(14)]
107
+ for m in msgs:
108
+ await sqlite_store.append(m)
109
+
110
+ store = AutoCompactStore(sqlite_store, FailTransport(), max_tokens=1, keep_recent=4)
111
+ await store.add_context_tokens(2, 0)
112
+
113
+ history = await sqlite_store.get_history()
114
+ assert len(history) == 14
115
+
116
+ async def test_fork_creates_independent_sqlite_session(self, sqlite_store: SQLiteContextStore) -> None:
117
+ """fork() returns an AutoCompactStore with a new session_id; mutations are independent."""
118
+ for i in range(6):
119
+ role = "user" if i % 2 == 0 else "assistant"
120
+ await sqlite_store.append(_msg(role, f"msg-{i}"))
121
+
122
+ store = AutoCompactStore(sqlite_store, StubTransport([]), max_tokens=999_999)
123
+ forked = await store.fork()
124
+
125
+ assert forked.session_id != store.session_id
126
+
127
+ # append to fork — parent unaffected
128
+ await forked.append(_msg(text="extra"))
129
+ assert len(await store.get_history()) == 6
130
+ assert len(await forked.get_history()) == 7
131
+
132
+ async def test_compact_uses_fork_as_snapshot(self, sqlite_store: SQLiteContextStore) -> None:
133
+ """compact_context receives a fork of the store, not the live store itself."""
134
+ for i in range(14):
135
+ role = "user" if i % 2 == 0 else "assistant"
136
+ await sqlite_store.append(_msg(role, f"msg-{i}"))
137
+
138
+ seen_session_ids: list[str] = []
139
+
140
+ import axio.compaction as _mod
141
+
142
+ original = _mod.compact_context
143
+
144
+ async def _capturing(ctx, transport, **kw): # type: ignore[no-untyped-def]
145
+ seen_session_ids.append(ctx.session_id)
146
+ return await original(ctx, StubTransport([make_text_response("summary")]), **kw)
147
+
148
+ _mod.compact_context = _capturing # type: ignore[assignment]
149
+ try:
150
+ store = AutoCompactStore(
151
+ sqlite_store, StubTransport([make_text_response("summary")]), max_tokens=1, keep_recent=4
152
+ )
153
+ await store.add_context_tokens(2, 0)
154
+ finally:
155
+ _mod.compact_context = original
156
+
157
+ assert len(seen_session_ids) == 1
158
+ assert seen_session_ids[0] != sqlite_store.session_id