axor-memory-sqlite 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,88 @@
1
+ name: CI/CD
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*.*.*"]
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ test:
12
+ name: Test (Python ${{ matrix.python-version }})
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.11", "3.12"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Checkout axor-core
22
+ uses: actions/checkout@v4
23
+ with:
24
+ repository: ${{ github.repository_owner }}/axor-core
25
+ path: axor-core
26
+
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+ cache: pip
31
+
32
+ - name: Install
33
+ run: |
34
+ pip install -e axor-core/
35
+ pip install -e ".[dev]"
36
+
37
+ - name: Run tests
38
+ run: pytest -q
39
+
40
+ publish:
41
+ name: Publish to PyPI
42
+ needs: test
43
+ runs-on: ubuntu-latest
44
+ if: startsWith(github.ref, 'refs/tags/v')
45
+ environment: pypi
46
+
47
+ permissions:
48
+ id-token: write
49
+
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+
53
+ - uses: actions/setup-python@v5
54
+ with:
55
+ python-version: "3.12"
56
+
57
+ - name: Verify tag matches package version
58
+ run: |
59
+ python - << 'EOF'
60
+ import pathlib
61
+ import re
62
+ import sys
63
+ import tomllib
64
+
65
+ ref = "${{ github.ref_name }}"
66
+ m = re.fullmatch(r"v(\d+\.\d+\.\d+)", ref)
67
+ if not m:
68
+ print(f"Tag {ref!r} must match vX.Y.Z")
69
+ sys.exit(1)
70
+
71
+ tag_version = m.group(1)
72
+ data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))
73
+ pkg_version = data["project"]["version"]
74
+
75
+ if tag_version != pkg_version:
76
+ print(f"Version mismatch: tag={tag_version}, pyproject={pkg_version}")
77
+ sys.exit(1)
78
+
79
+ print(f"Version check passed: {pkg_version}")
80
+ EOF
81
+
82
+ - name: Build
83
+ run: |
84
+ pip install hatchling build
85
+ python -m build
86
+
87
+ - name: Publish to PyPI
88
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .coverage
4
+ dist/
5
+ build/
6
+ *.egg-info/
@@ -0,0 +1,272 @@
1
+ Metadata-Version: 2.4
2
+ Name: axor-memory-sqlite
3
+ Version: 0.1.0
4
+ Summary: SQLite memory provider for axor-core
5
+ Project-URL: Repository, https://github.com/Bucha11/axor-memory-sqlite
6
+ Project-URL: Bug Tracker, https://github.com/Bucha11/axor-memory-sqlite/issues
7
+ Project-URL: Changelog, https://github.com/Bucha11/axor-memory-sqlite/releases
8
+ License: MIT
9
+ Keywords: agents,axor,memory,sqlite
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: axor-core>=0.1.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # axor-memory-sqlite
25
+
26
+ [![CI](https://github.com/Bucha11/axor-memory-sqlite/actions/workflows/ci.yml/badge.svg)](https://github.com/Bucha11/axor-memory-sqlite/actions/workflows/ci.yml)
27
+ [![PyPI](https://img.shields.io/pypi/v/axor-memory-sqlite)](https://pypi.org/project/axor-memory-sqlite/)
28
+ [![Python](https://img.shields.io/pypi/pyversions/axor-memory-sqlite)](https://pypi.org/project/axor-memory-sqlite/)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
30
+
31
+ **SQLite memory provider for [axor-core](https://github.com/Bucha11/axor-core).**
32
+
33
+ Persistent cross-session memory for governed agents. Zero extra dependencies — uses Python's built-in `sqlite3`.
34
+
35
+ ---
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install axor-memory-sqlite
41
+ ```
42
+
43
+ Requires `axor-core >= 0.1.0`.
44
+
45
+ ---
46
+
47
+ ## Quick Start
48
+
49
+ ```python
50
+ import axor_claude
51
+ from axor_core import AgentDefinition, AgentDomain, FragmentValue, MemoryFragment
52
+ from axor_memory_sqlite import SQLiteMemoryProvider
53
+
54
+ provider = SQLiteMemoryProvider("~/.axor/memory.db")
55
+
56
+ session = axor_claude.make_session(
57
+ api_key="sk-ant-...",
58
+ agent_def=AgentDefinition(
59
+ name="my-agent",
60
+ domain=AgentDomain.CODING,
61
+ personality="You are an expert Python engineer.",
62
+ memory_namespaces=("my-agent",), # loaded at every session start
63
+ ),
64
+ memory_provider=provider,
65
+ )
66
+
67
+ result = await session.run("refactor the auth module")
68
+
69
+ # save what you want to remember next time
70
+ await provider.save([
71
+ MemoryFragment(
72
+ namespace="my-agent",
73
+ key="auth_module_status",
74
+ content="Auth module refactored to use JWT. Entry point: auth/jwt.py.",
75
+ value=FragmentValue.KNOWLEDGE,
76
+ ),
77
+ ])
78
+ ```
79
+
80
+ ---
81
+
82
+ ## FragmentValue — what gets remembered how
83
+
84
+ Every `MemoryFragment` has a `value` that controls how the compressor treats it when it appears in `ContextView`:
85
+
86
+ | Value | Compressor behavior | Typical use |
87
+ |-------|--------------------|----|
88
+ | `PINNED` | Never touched — survives all turns | User preferences, system rules, agent personality |
89
+ | `KNOWLEDGE` | Dedup + error collapse only — no truncation | Project docs, domain context, API specs |
90
+ | `WORKING` | Normal compression pipeline | Task findings, recent tool results |
91
+ | `EPHEMERAL` | Aggressive compression — evicted first | Debug output, one-turn scratch |
92
+
93
+ Eviction priority: `EPHEMERAL` → `WORKING` → `KNOWLEDGE` → `PINNED` (never evicted).
94
+
95
+ ---
96
+
97
+ ## API
98
+
99
+ ### `SQLiteMemoryProvider(db_path)`
100
+
101
+ ```python
102
+ from axor_memory_sqlite import SQLiteMemoryProvider
103
+
104
+ provider = SQLiteMemoryProvider("~/.axor/memory.db") # persistent
105
+ provider = SQLiteMemoryProvider(":memory:") # in-memory, tests only
106
+ ```
107
+
108
+ All methods are async. I/O runs in a thread pool — async callers are never blocked.
109
+
110
+ ### `save(fragments)`
111
+
112
+ Upsert by `(namespace, key)` — existing fragments are overwritten:
113
+
114
+ ```python
115
+ await provider.save([
116
+ MemoryFragment(
117
+ namespace="my-agent",
118
+ key="project_stack",
119
+ content="FastAPI + async SQLAlchemy + PostgreSQL",
120
+ value=FragmentValue.PINNED,
121
+ tags=["stack", "tech"],
122
+ ),
123
+ ])
124
+ ```
125
+
126
+ ### `load(query)`
127
+
128
+ ```python
129
+ from axor_core import MemoryQuery, FragmentValue
130
+
131
+ # load all from namespace, pinned first
132
+ fragments = await provider.load(MemoryQuery(
133
+ namespaces=("my-agent",),
134
+ max_results=20,
135
+ ))
136
+
137
+ # filter by value
138
+ pinned = await provider.load(MemoryQuery(
139
+ namespaces=("my-agent",),
140
+ values=(FragmentValue.PINNED, FragmentValue.KNOWLEDGE),
141
+ max_results=10,
142
+ ))
143
+ ```
144
+
145
+ Results are ordered by priority (`PINNED` first) then by `accessed_at` descending.
146
+
147
+ ### `delete(namespace, keys)`
148
+
149
+ ```python
150
+ n = await provider.delete("my-agent", ["stale_key_1", "stale_key_2"])
151
+ print(f"deleted {n} fragments")
152
+ ```
153
+
154
+ ### `evict(namespace, values, max_age_seconds)`
155
+
156
+ Remove stale fragments by value and/or age:
157
+
158
+ ```python
159
+ # evict all ephemeral fragments
160
+ await provider.evict("my-agent", values=(FragmentValue.EPHEMERAL,))
161
+
162
+ # evict working fragments older than 24 hours
163
+ await provider.evict(
164
+ "my-agent",
165
+ values=(FragmentValue.WORKING,),
166
+ max_age_seconds=86400,
167
+ )
168
+ ```
169
+
170
+ ### `namespaces()`
171
+
172
+ ```python
173
+ ns = await provider.namespaces()
174
+ # ["my-agent", "shared", "project-x"]
175
+ ```
176
+
177
+ ### `close()`
178
+
179
+ ```python
180
+ await provider.close()
181
+ ```
182
+
183
+ Call on shutdown. The provider can be used as an async context manager in tests:
184
+
185
+ ```python
186
+ async with SQLiteMemoryProvider(":memory:") as provider:
187
+ await provider.save([...])
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Namespaces
193
+
194
+ Namespaces are logical groupings — they do not require explicit creation. A namespace exists as soon as you save a fragment with that name.
195
+
196
+ Recommended pattern:
197
+
198
+ | Namespace | Content |
199
+ |-----------|---------|
200
+ | `{agent-name}` | Agent-specific memory — not shared |
201
+ | `shared` | Shared across all agents in a project |
202
+ | `project:{name}` | Project-specific facts |
203
+ | `user:{id}` | Per-user preferences |
204
+
205
+ ```python
206
+ # agent reads from its own namespace + shared
207
+ agent = AgentDefinition(
208
+ name="billing-agent",
209
+ memory_namespaces=("billing-agent", "shared"),
210
+ )
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Schema
216
+
217
+ ```sql
218
+ CREATE TABLE memory_fragments (
219
+ namespace TEXT NOT NULL,
220
+ key TEXT NOT NULL,
221
+ content TEXT NOT NULL,
222
+ value TEXT NOT NULL DEFAULT 'working',
223
+ token_count INTEGER NOT NULL DEFAULT 0,
224
+ tags TEXT NOT NULL DEFAULT '[]', -- JSON array
225
+ created_at TEXT NOT NULL,
226
+ accessed_at TEXT NOT NULL,
227
+ metadata TEXT NOT NULL DEFAULT '{}', -- JSON object
228
+ PRIMARY KEY (namespace, key)
229
+ );
230
+ ```
231
+
232
+ The database file is a standard SQLite file — readable with any SQLite tool.
233
+
234
+ ---
235
+
236
+ ## Testing
237
+
238
+ Use `:memory:` for tests — no file created, no cleanup needed:
239
+
240
+ ```python
241
+ import pytest
242
+ from axor_memory_sqlite import SQLiteMemoryProvider
243
+ from axor_core import MemoryFragment, FragmentValue, MemoryQuery
244
+
245
+ @pytest.mark.asyncio
246
+ async def test_memory():
247
+ p = SQLiteMemoryProvider(":memory:")
248
+
249
+ await p.save([
250
+ MemoryFragment(namespace="test", key="k1",
251
+ content="hello", value=FragmentValue.WORKING),
252
+ ])
253
+ results = await p.load(MemoryQuery(namespaces=("test",), max_results=5))
254
+ assert len(results) == 1
255
+ assert results[0].content == "hello"
256
+
257
+ await p.close()
258
+ ```
259
+
260
+ ---
261
+
262
+ ## Requirements
263
+
264
+ - Python 3.11+
265
+ - `axor-core >= 0.1.0`
266
+ - No extra dependencies — uses stdlib `sqlite3` + `asyncio`
267
+
268
+ ---
269
+
270
+ ## License
271
+
272
+ MIT
@@ -0,0 +1,249 @@
1
+ # axor-memory-sqlite
2
+
3
+ [![CI](https://github.com/Bucha11/axor-memory-sqlite/actions/workflows/ci.yml/badge.svg)](https://github.com/Bucha11/axor-memory-sqlite/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/axor-memory-sqlite)](https://pypi.org/project/axor-memory-sqlite/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/axor-memory-sqlite)](https://pypi.org/project/axor-memory-sqlite/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
8
+ **SQLite memory provider for [axor-core](https://github.com/Bucha11/axor-core).**
9
+
10
+ Persistent cross-session memory for governed agents. Zero extra dependencies — uses Python's built-in `sqlite3`.
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install axor-memory-sqlite
18
+ ```
19
+
20
+ Requires `axor-core >= 0.1.0`.
21
+
22
+ ---
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ import axor_claude
28
+ from axor_core import AgentDefinition, AgentDomain, FragmentValue, MemoryFragment
29
+ from axor_memory_sqlite import SQLiteMemoryProvider
30
+
31
+ provider = SQLiteMemoryProvider("~/.axor/memory.db")
32
+
33
+ session = axor_claude.make_session(
34
+ api_key="sk-ant-...",
35
+ agent_def=AgentDefinition(
36
+ name="my-agent",
37
+ domain=AgentDomain.CODING,
38
+ personality="You are an expert Python engineer.",
39
+ memory_namespaces=("my-agent",), # loaded at every session start
40
+ ),
41
+ memory_provider=provider,
42
+ )
43
+
44
+ result = await session.run("refactor the auth module")
45
+
46
+ # save what you want to remember next time
47
+ await provider.save([
48
+ MemoryFragment(
49
+ namespace="my-agent",
50
+ key="auth_module_status",
51
+ content="Auth module refactored to use JWT. Entry point: auth/jwt.py.",
52
+ value=FragmentValue.KNOWLEDGE,
53
+ ),
54
+ ])
55
+ ```
56
+
57
+ ---
58
+
59
+ ## FragmentValue — what gets remembered how
60
+
61
+ Every `MemoryFragment` has a `value` that controls how the compressor treats it when it appears in `ContextView`:
62
+
63
+ | Value | Compressor behavior | Typical use |
64
+ |-------|--------------------|----|
65
+ | `PINNED` | Never touched — survives all turns | User preferences, system rules, agent personality |
66
+ | `KNOWLEDGE` | Dedup + error collapse only — no truncation | Project docs, domain context, API specs |
67
+ | `WORKING` | Normal compression pipeline | Task findings, recent tool results |
68
+ | `EPHEMERAL` | Aggressive compression — evicted first | Debug output, one-turn scratch |
69
+
70
+ Eviction priority: `EPHEMERAL` → `WORKING` → `KNOWLEDGE` → `PINNED` (never evicted).
71
+
72
+ ---
73
+
74
+ ## API
75
+
76
+ ### `SQLiteMemoryProvider(db_path)`
77
+
78
+ ```python
79
+ from axor_memory_sqlite import SQLiteMemoryProvider
80
+
81
+ provider = SQLiteMemoryProvider("~/.axor/memory.db") # persistent
82
+ provider = SQLiteMemoryProvider(":memory:") # in-memory, tests only
83
+ ```
84
+
85
+ All methods are async. I/O runs in a thread pool — async callers are never blocked.
86
+
87
+ ### `save(fragments)`
88
+
89
+ Upsert by `(namespace, key)` — existing fragments are overwritten:
90
+
91
+ ```python
92
+ await provider.save([
93
+ MemoryFragment(
94
+ namespace="my-agent",
95
+ key="project_stack",
96
+ content="FastAPI + async SQLAlchemy + PostgreSQL",
97
+ value=FragmentValue.PINNED,
98
+ tags=["stack", "tech"],
99
+ ),
100
+ ])
101
+ ```
102
+
103
+ ### `load(query)`
104
+
105
+ ```python
106
+ from axor_core import MemoryQuery, FragmentValue
107
+
108
+ # load all from namespace, pinned first
109
+ fragments = await provider.load(MemoryQuery(
110
+ namespaces=("my-agent",),
111
+ max_results=20,
112
+ ))
113
+
114
+ # filter by value
115
+ pinned = await provider.load(MemoryQuery(
116
+ namespaces=("my-agent",),
117
+ values=(FragmentValue.PINNED, FragmentValue.KNOWLEDGE),
118
+ max_results=10,
119
+ ))
120
+ ```
121
+
122
+ Results are ordered by priority (`PINNED` first) then by `accessed_at` descending.
123
+
124
+ ### `delete(namespace, keys)`
125
+
126
+ ```python
127
+ n = await provider.delete("my-agent", ["stale_key_1", "stale_key_2"])
128
+ print(f"deleted {n} fragments")
129
+ ```
130
+
131
+ ### `evict(namespace, values, max_age_seconds)`
132
+
133
+ Remove stale fragments by value and/or age:
134
+
135
+ ```python
136
+ # evict all ephemeral fragments
137
+ await provider.evict("my-agent", values=(FragmentValue.EPHEMERAL,))
138
+
139
+ # evict working fragments older than 24 hours
140
+ await provider.evict(
141
+ "my-agent",
142
+ values=(FragmentValue.WORKING,),
143
+ max_age_seconds=86400,
144
+ )
145
+ ```
146
+
147
+ ### `namespaces()`
148
+
149
+ ```python
150
+ ns = await provider.namespaces()
151
+ # ["my-agent", "shared", "project-x"]
152
+ ```
153
+
154
+ ### `close()`
155
+
156
+ ```python
157
+ await provider.close()
158
+ ```
159
+
160
+ Call on shutdown. The provider can be used as an async context manager in tests:
161
+
162
+ ```python
163
+ async with SQLiteMemoryProvider(":memory:") as provider:
164
+ await provider.save([...])
165
+ ```
166
+
167
+ ---
168
+
169
+ ## Namespaces
170
+
171
+ Namespaces are logical groupings — they do not require explicit creation. A namespace exists as soon as you save a fragment with that name.
172
+
173
+ Recommended pattern:
174
+
175
+ | Namespace | Content |
176
+ |-----------|---------|
177
+ | `{agent-name}` | Agent-specific memory — not shared |
178
+ | `shared` | Shared across all agents in a project |
179
+ | `project:{name}` | Project-specific facts |
180
+ | `user:{id}` | Per-user preferences |
181
+
182
+ ```python
183
+ # agent reads from its own namespace + shared
184
+ agent = AgentDefinition(
185
+ name="billing-agent",
186
+ memory_namespaces=("billing-agent", "shared"),
187
+ )
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Schema
193
+
194
+ ```sql
195
+ CREATE TABLE memory_fragments (
196
+ namespace TEXT NOT NULL,
197
+ key TEXT NOT NULL,
198
+ content TEXT NOT NULL,
199
+ value TEXT NOT NULL DEFAULT 'working',
200
+ token_count INTEGER NOT NULL DEFAULT 0,
201
+ tags TEXT NOT NULL DEFAULT '[]', -- JSON array
202
+ created_at TEXT NOT NULL,
203
+ accessed_at TEXT NOT NULL,
204
+ metadata TEXT NOT NULL DEFAULT '{}', -- JSON object
205
+ PRIMARY KEY (namespace, key)
206
+ );
207
+ ```
208
+
209
+ The database file is a standard SQLite file — readable with any SQLite tool.
210
+
211
+ ---
212
+
213
+ ## Testing
214
+
215
+ Use `:memory:` for tests — no file created, no cleanup needed:
216
+
217
+ ```python
218
+ import pytest
219
+ from axor_memory_sqlite import SQLiteMemoryProvider
220
+ from axor_core import MemoryFragment, FragmentValue, MemoryQuery
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_memory():
224
+ p = SQLiteMemoryProvider(":memory:")
225
+
226
+ await p.save([
227
+ MemoryFragment(namespace="test", key="k1",
228
+ content="hello", value=FragmentValue.WORKING),
229
+ ])
230
+ results = await p.load(MemoryQuery(namespaces=("test",), max_results=5))
231
+ assert len(results) == 1
232
+ assert results[0].content == "hello"
233
+
234
+ await p.close()
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Requirements
240
+
241
+ - Python 3.11+
242
+ - `axor-core >= 0.1.0`
243
+ - No extra dependencies — uses stdlib `sqlite3` + `asyncio`
244
+
245
+ ---
246
+
247
+ ## License
248
+
249
+ MIT
@@ -0,0 +1,5 @@
1
+ """axor-memory-sqlite — SQLite MemoryProvider for axor-core."""
2
+ from axor_memory_sqlite.provider import SQLiteMemoryProvider
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["SQLiteMemoryProvider"]
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ SQLite-backed MemoryProvider for axor-core.
5
+
6
+ Zero external dependencies — uses Python's built-in sqlite3.
7
+ Thread-safe via asyncio.Lock + aiosqlite pattern (runs in thread pool).
8
+
9
+ Schema:
10
+
11
+ CREATE TABLE memory_fragments (
12
+ namespace TEXT NOT NULL,
13
+ key TEXT NOT NULL,
14
+ content TEXT NOT NULL,
15
+ value TEXT NOT NULL DEFAULT 'working',
16
+ token_count INTEGER NOT NULL DEFAULT 0,
17
+ tags TEXT NOT NULL DEFAULT '[]', -- JSON array
18
+ created_at TEXT NOT NULL,
19
+ accessed_at TEXT NOT NULL,
20
+ metadata TEXT NOT NULL DEFAULT '{}', -- JSON object
21
+ PRIMARY KEY (namespace, key)
22
+ );
23
+ """
24
+
25
+ import asyncio
26
+ import json
27
+ import sqlite3
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from axor_core.contracts.memory import (
33
+ MemoryFragment,
34
+ MemoryProvider,
35
+ MemoryQuery,
36
+ FragmentValue,
37
+ )
38
+
39
+ _SCHEMA = """
40
+ CREATE TABLE IF NOT EXISTS memory_fragments (
41
+ namespace TEXT NOT NULL,
42
+ key TEXT NOT NULL,
43
+ content TEXT NOT NULL,
44
+ value TEXT NOT NULL DEFAULT 'working',
45
+ token_count INTEGER NOT NULL DEFAULT 0,
46
+ tags TEXT NOT NULL DEFAULT '[]',
47
+ created_at TEXT NOT NULL,
48
+ accessed_at TEXT NOT NULL,
49
+ metadata TEXT NOT NULL DEFAULT '{}',
50
+ PRIMARY KEY (namespace, key)
51
+ );
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_namespace ON memory_fragments(namespace);
54
+ CREATE INDEX IF NOT EXISTS idx_value ON memory_fragments(value);
55
+ CREATE INDEX IF NOT EXISTS idx_accessed ON memory_fragments(accessed_at);
56
+ """
57
+
58
+ _NOW = lambda: datetime.now(timezone.utc).isoformat()
59
+
60
+
61
+ def _row_to_fragment(row: tuple) -> MemoryFragment:
62
+ ns, key, content, value, token_count, tags_json, created_at, accessed_at, meta_json = row
63
+ return MemoryFragment(
64
+ namespace=ns,
65
+ key=key,
66
+ content=content,
67
+ value=FragmentValue(value),
68
+ token_count=token_count,
69
+ tags=json.loads(tags_json),
70
+ created_at=datetime.fromisoformat(created_at),
71
+ accessed_at=datetime.fromisoformat(accessed_at),
72
+ metadata=json.loads(meta_json),
73
+ )
74
+
75
+
76
+ class SQLiteMemoryProvider(MemoryProvider):
77
+ """
78
+ SQLite-backed MemoryProvider.
79
+
80
+ All I/O runs in a thread pool via asyncio.to_thread()
81
+ so async callers are never blocked.
82
+
83
+ Usage:
84
+
85
+ from axor_memory_sqlite import SQLiteMemoryProvider
86
+ from axor_core import GovernedSession, AgentDefinition
87
+
88
+ provider = SQLiteMemoryProvider("~/.axor/memory.db")
89
+
90
+ session = GovernedSession(
91
+ executor=...,
92
+ capability_executor=...,
93
+ agent_def=AgentDefinition(
94
+ name="my-agent",
95
+ memory_namespaces=("my-agent", "shared"),
96
+ ),
97
+ memory_provider=provider,
98
+ )
99
+
100
+ # save memory after a session
101
+ await session.save_memory(
102
+ key="last_project",
103
+ content="Working on axor federation support",
104
+ value=FragmentValue.WORKING,
105
+ )
106
+ """
107
+
108
+ def __init__(self, db_path: str | Path = ":memory:") -> None:
109
+ self._db_path = str(Path(db_path).expanduser()) if db_path != ":memory:" else ":memory:"
110
+ self._lock = asyncio.Lock()
111
+ self._conn: sqlite3.Connection | None = None
112
+
113
+ def _open(self) -> sqlite3.Connection:
114
+ if self._conn is None:
115
+ self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
116
+ self._conn.row_factory = sqlite3.Row
117
+ self._conn.executescript(_SCHEMA)
118
+ self._conn.commit()
119
+ return self._conn
120
+
121
+ async def _run(self, fn):
122
+ """Run a blocking DB call in thread pool."""
123
+ return await asyncio.to_thread(fn)
124
+
125
+ # ── MemoryProvider interface ────────────────────────────────────────────────
126
+
127
+ async def load(self, query: MemoryQuery) -> list[MemoryFragment]:
128
+ def _load():
129
+ conn = self._open()
130
+ parts: list[str] = []
131
+ params: list[Any] = []
132
+
133
+ if query.namespaces:
134
+ placeholders = ",".join("?" * len(query.namespaces))
135
+ parts.append(f"namespace IN ({placeholders})")
136
+ params.extend(query.namespaces)
137
+
138
+ if query.values:
139
+ placeholders = ",".join("?" * len(query.values))
140
+ parts.append(f"value IN ({placeholders})")
141
+ params.extend(v.value for v in query.values)
142
+
143
+ where = ("WHERE " + " AND ".join(parts)) if parts else ""
144
+ sql = f"""
145
+ SELECT namespace, key, content, value, token_count, tags,
146
+ created_at, accessed_at, metadata
147
+ FROM memory_fragments
148
+ {where}
149
+ ORDER BY
150
+ CASE value
151
+ WHEN 'pinned' THEN 0
152
+ WHEN 'knowledge' THEN 1
153
+ WHEN 'working' THEN 2
154
+ WHEN 'ephemeral' THEN 3
155
+ END,
156
+ accessed_at DESC
157
+ LIMIT ?
158
+ """
159
+ params.append(query.max_results)
160
+ rows = conn.execute(sql, params).fetchall()
161
+ return [_row_to_fragment(tuple(r)) for r in rows]
162
+
163
+ try:
164
+ async with self._lock:
165
+ return await self._run(_load)
166
+ except Exception:
167
+ return []
168
+
169
+ async def save(self, fragments: list[MemoryFragment]) -> None:
170
+ def _save():
171
+ conn = self._open()
172
+ now = _NOW()
173
+ rows = [
174
+ (
175
+ f.namespace,
176
+ f.key,
177
+ f.content,
178
+ f.value.value,
179
+ f.token_count or len(f.content) // 4,
180
+ json.dumps(f.tags),
181
+ f.created_at.isoformat() if f.created_at else now,
182
+ now,
183
+ json.dumps(f.metadata),
184
+ )
185
+ for f in fragments
186
+ ]
187
+ conn.executemany("""
188
+ INSERT INTO memory_fragments
189
+ (namespace, key, content, value, token_count, tags,
190
+ created_at, accessed_at, metadata)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
192
+ ON CONFLICT(namespace, key) DO UPDATE SET
193
+ content = excluded.content,
194
+ value = excluded.value,
195
+ token_count = excluded.token_count,
196
+ tags = excluded.tags,
197
+ accessed_at = excluded.accessed_at,
198
+ metadata = excluded.metadata
199
+ """, rows)
200
+ conn.commit()
201
+
202
+ try:
203
+ async with self._lock:
204
+ await self._run(_save)
205
+ except Exception:
206
+ pass
207
+
208
+ async def delete(self, namespace: str, keys: list[str]) -> int:
209
+ def _delete():
210
+ conn = self._open()
211
+ placeholders = ",".join("?" * len(keys))
212
+ cur = conn.execute(
213
+ f"DELETE FROM memory_fragments WHERE namespace=? AND key IN ({placeholders})",
214
+ [namespace, *keys],
215
+ )
216
+ conn.commit()
217
+ return cur.rowcount
218
+
219
+ try:
220
+ async with self._lock:
221
+ return await self._run(_delete)
222
+ except Exception:
223
+ return 0
224
+
225
+ async def evict(
226
+ self,
227
+ namespace: str,
228
+ values: tuple[FragmentValue, ...] = (FragmentValue.EPHEMERAL,),
229
+ max_age_seconds: int | None = None,
230
+ ) -> int:
231
+ def _evict():
232
+ conn = self._open()
233
+ parts: list[str] = ["namespace = ?"]
234
+ params: list[Any] = [namespace]
235
+
236
+ if values:
237
+ placeholders = ",".join("?" * len(values))
238
+ parts.append(f"value IN ({placeholders})")
239
+ params.extend(v.value for v in values)
240
+
241
+ if max_age_seconds is not None:
242
+ parts.append(
243
+ "accessed_at < datetime('now', ?)"
244
+ )
245
+ params.append(f"-{max_age_seconds} seconds")
246
+
247
+ where = " AND ".join(parts)
248
+ cur = conn.execute(
249
+ f"DELETE FROM memory_fragments WHERE {where}",
250
+ params,
251
+ )
252
+ conn.commit()
253
+ return cur.rowcount
254
+
255
+ try:
256
+ async with self._lock:
257
+ return await self._run(_evict)
258
+ except Exception:
259
+ return 0
260
+
261
+ async def namespaces(self) -> list[str]:
262
+ def _ns():
263
+ conn = self._open()
264
+ rows = conn.execute(
265
+ "SELECT DISTINCT namespace FROM memory_fragments ORDER BY namespace"
266
+ ).fetchall()
267
+ return [r[0] for r in rows]
268
+
269
+ try:
270
+ async with self._lock:
271
+ return await self._run(_ns)
272
+ except Exception:
273
+ return []
274
+
275
+ async def close(self) -> None:
276
+ if self._conn is not None:
277
+ self._conn.close()
278
+ self._conn = None
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "axor-memory-sqlite"
7
+ version = "0.1.0"
8
+ description = "SQLite memory provider for axor-core"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ keywords = ["axor", "memory", "sqlite", "agents"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Database",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+ dependencies = ["axor-core>=0.1.0"]
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/Bucha11/axor-memory-sqlite"
29
+ "Bug Tracker" = "https://github.com/Bucha11/axor-memory-sqlite/issues"
30
+ Changelog = "https://github.com/Bucha11/axor-memory-sqlite/releases"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["axor_memory_sqlite"]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ testpaths = ["tests"]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ import pytest
6
+
7
+ from axor_core.contracts.memory import FragmentValue, MemoryFragment, MemoryQuery
8
+ from axor_memory_sqlite import SQLiteMemoryProvider
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_save_and_load_orders_by_value_priority() -> None:
13
+ provider = SQLiteMemoryProvider(":memory:")
14
+ try:
15
+ await provider.save([
16
+ MemoryFragment(namespace="agent", key="working", content="w", value=FragmentValue.WORKING),
17
+ MemoryFragment(namespace="agent", key="pinned", content="p", value=FragmentValue.PINNED),
18
+ ])
19
+
20
+ fragments = await provider.load(MemoryQuery(namespaces=("agent",), max_results=10))
21
+
22
+ assert [fragment.key for fragment in fragments] == ["pinned", "working"]
23
+ finally:
24
+ await provider.close()
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_delete_and_namespaces() -> None:
29
+ provider = SQLiteMemoryProvider(":memory:")
30
+ try:
31
+ await provider.save([
32
+ MemoryFragment(namespace="agent-a", key="one", content="a", value=FragmentValue.KNOWLEDGE),
33
+ MemoryFragment(namespace="agent-b", key="two", content="b", value=FragmentValue.EPHEMERAL),
34
+ ])
35
+
36
+ namespaces = await provider.namespaces()
37
+ deleted = await provider.delete("agent-a", ["one"])
38
+ remaining = await provider.load(MemoryQuery(namespaces=("agent-a", "agent-b"), max_results=10))
39
+
40
+ assert namespaces == ["agent-a", "agent-b"]
41
+ assert deleted == 1
42
+ assert [fragment.namespace for fragment in remaining] == ["agent-b"]
43
+ finally:
44
+ await provider.close()
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_evict_by_value() -> None:
49
+ provider = SQLiteMemoryProvider(":memory:")
50
+ try:
51
+ old = datetime(2000, 1, 1, tzinfo=timezone.utc)
52
+ await provider.save([
53
+ MemoryFragment(namespace="agent", key="old", content="x", value=FragmentValue.EPHEMERAL, created_at=old),
54
+ MemoryFragment(namespace="agent", key="keep", content="y", value=FragmentValue.PINNED, created_at=old),
55
+ ])
56
+
57
+ removed = await provider.evict("agent", values=(FragmentValue.EPHEMERAL,))
58
+ remaining = await provider.load(MemoryQuery(namespaces=("agent",), max_results=10))
59
+
60
+ assert removed == 1
61
+ assert [fragment.key for fragment in remaining] == ["keep"]
62
+ finally:
63
+ await provider.close()