memsmith 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.
memsmith/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """Memsmith — Enterprise memory lifecycle management for AI agents."""
2
+
3
+ from memsmith._types import (
4
+ Accepted,
5
+ AuditEntry,
6
+ Memory,
7
+ MemoryStatus,
8
+ PendingConfirmation,
9
+ ProposalInput,
10
+ ProposalResult,
11
+ Rejected,
12
+ ValidationResult,
13
+ ValidatorFn,
14
+ ValidatorSpec,
15
+ )
16
+ from memsmith._errors import (
17
+ ConfidenceMonotonicityError,
18
+ InvalidTransitionError,
19
+ MemsmithError,
20
+ NotFoundError,
21
+ ValidationError,
22
+ )
23
+ from memsmith._store import MemoryStore
24
+ from memsmith._validators import (
25
+ ConfidenceThreshold,
26
+ SchemaCheck,
27
+ Validator,
28
+ )
29
+ from memsmith._sqlite import SQLiteBackend
30
+ from memsmith._hooks import HookRegistry, TransitionHandler
31
+
32
+ __all__ = [
33
+ "Accepted",
34
+ "AuditEntry",
35
+ "ConfidenceMonotonicityError",
36
+ "ConfidenceThreshold",
37
+ "HookRegistry",
38
+ "InvalidTransitionError",
39
+ "Memory",
40
+ "MemoryStatus",
41
+ "MemoryStore",
42
+ "MemsmithError",
43
+ "NotFoundError",
44
+ "PendingConfirmation",
45
+ "ProposalInput",
46
+ "ProposalResult",
47
+ "Rejected",
48
+ "SchemaCheck",
49
+ "SQLiteBackend",
50
+ "TransitionHandler",
51
+ "ValidationError",
52
+ "ValidationResult",
53
+ "Validator",
54
+ "ValidatorFn",
55
+ "ValidatorSpec",
56
+ ]
memsmith/_backend.py ADDED
@@ -0,0 +1,62 @@
1
+ import contextvars
2
+ from collections.abc import AsyncIterator
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+ from memsmith._types import AuditEntry, Memory, MemoryStatus
7
+
8
+
9
+ _tx_conn: contextvars.ContextVar = contextvars.ContextVar("_tx_conn")
10
+
11
+
12
+ def _get_tx_conn() -> Any:
13
+ return _tx_conn.get(None)
14
+
15
+
16
+ @runtime_checkable
17
+ class StorageBackend(Protocol):
18
+ async def initialize(self) -> None: ...
19
+
20
+ async def close(self) -> None: ...
21
+
22
+ @asynccontextmanager
23
+ async def transaction(self) -> AsyncIterator[None]:
24
+ yield
25
+
26
+ async def insert_memory(self, memory: Memory, /) -> str: ...
27
+
28
+ async def get_memory(self, memory_id: str, /) -> Memory | None: ...
29
+
30
+ async def update_status(
31
+ self, memory_id: str, /, new_status: MemoryStatus, **extra: Any
32
+ ) -> None: ...
33
+
34
+ async def update_content(
35
+ self, memory_id: str, /, new_content: Any, new_hash: str
36
+ ) -> None: ...
37
+
38
+ async def update_confidence(
39
+ self, memory_id: str, /, new_value: float
40
+ ) -> None: ...
41
+
42
+ async def list_memories(
43
+ self,
44
+ status: MemoryStatus | None = None,
45
+ session_id: str | None = None,
46
+ field_filters: dict[str, Any] | None = None,
47
+ limit: int = 50,
48
+ offset: int = 0,
49
+ order_by: str = "created_at",
50
+ ) -> list[Memory]: ...
51
+
52
+ async def find_by_hash(
53
+ self, content_hash: str, session_id: str, /
54
+ ) -> Memory | None: ...
55
+
56
+ async def expire_batch(self) -> int: ...
57
+
58
+ async def log_audit(self, entry: AuditEntry, /) -> str: ...
59
+
60
+ async def get_audit(
61
+ self, memory_id: str | None = None, /, limit: int = 50
62
+ ) -> list[AuditEntry]: ...
memsmith/_errors.py ADDED
@@ -0,0 +1,49 @@
1
+ from memsmith._types import MemoryStatus
2
+
3
+
4
+ class MemsmithError(Exception):
5
+ pass
6
+
7
+
8
+ class InvalidTransitionError(MemsmithError):
9
+ def __init__(
10
+ self,
11
+ memory_id: str,
12
+ from_status: MemoryStatus,
13
+ to_status: MemoryStatus,
14
+ ) -> None:
15
+ self.memory_id = memory_id
16
+ self.from_status = from_status
17
+ self.to_status = to_status
18
+ super().__init__(
19
+ f"Invalid transition: memory {memory_id} "
20
+ f"cannot go from {from_status.value} to {to_status.value}"
21
+ )
22
+
23
+
24
+ class NotFoundError(MemsmithError):
25
+ def __init__(self, memory_id: str) -> None:
26
+ self.memory_id = memory_id
27
+ super().__init__(f"Memory not found: {memory_id}")
28
+
29
+
30
+ class ConfidenceMonotonicityError(MemsmithError):
31
+ def __init__(self, memory_id: str, current: float, attempted: float) -> None:
32
+ self.memory_id = memory_id
33
+ self.current = current
34
+ self.attempted = attempted
35
+ super().__init__(
36
+ f"Confidence must increase monotonically: "
37
+ f"memory {memory_id} has {current}, attempted {attempted}"
38
+ )
39
+
40
+
41
+ class ValidationError(MemsmithError):
42
+ def __init__(self, errors: list[str], memory_id: str | None = None) -> None:
43
+ self.memory_id = memory_id
44
+ self.errors = errors
45
+ joined = "; ".join(errors)
46
+ if memory_id:
47
+ super().__init__(f"Validation failed for memory {memory_id}: {joined}")
48
+ else:
49
+ super().__init__(f"Validation failed: {joined}")
memsmith/_hashing.py ADDED
@@ -0,0 +1,15 @@
1
+ import hashlib
2
+ import json
3
+ from typing import Any
4
+
5
+
6
+ def compute_content_hash(content: Any) -> str:
7
+ """SHA-256 of the JSON-serialized content, sorted keys for determinism."""
8
+ serialized = json.dumps(
9
+ content,
10
+ sort_keys=True,
11
+ ensure_ascii=False,
12
+ default=str,
13
+ separators=(",", ":"),
14
+ )
15
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
memsmith/_hooks.py ADDED
@@ -0,0 +1,36 @@
1
+ import logging
2
+ from collections.abc import Awaitable, Callable
3
+
4
+ from memsmith._types import MemoryStatus
5
+
6
+ logger = logging.getLogger("memsmith")
7
+
8
+ TransitionHandler = Callable[[str, MemoryStatus, MemoryStatus], Awaitable[None]]
9
+
10
+
11
+ class HookRegistry:
12
+ """Ordered list of post-commit transition callbacks. Fire-and-forget errors."""
13
+
14
+ def __init__(self) -> None:
15
+ self._callbacks: list[TransitionHandler] = []
16
+
17
+ def register(self, callback: TransitionHandler) -> None:
18
+ self._callbacks.append(callback)
19
+
20
+ async def emit(
21
+ self,
22
+ memory_id: str,
23
+ from_status: MemoryStatus,
24
+ to_status: MemoryStatus,
25
+ ) -> None:
26
+ for cb in self._callbacks:
27
+ try:
28
+ await cb(memory_id, from_status, to_status)
29
+ except Exception:
30
+ logger.exception(
31
+ "Hook %r raised for memory %s transition %s->%s",
32
+ cb,
33
+ memory_id,
34
+ from_status.value,
35
+ to_status.value,
36
+ )
@@ -0,0 +1,73 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime, timezone
3
+
4
+
5
+ @dataclass
6
+ class Migration:
7
+ version: int
8
+ description: str
9
+ sql: str
10
+
11
+
12
+ MIGRATIONS: list[Migration] = [
13
+ Migration(
14
+ version=1,
15
+ description="Initial schema: memories and audit_log tables",
16
+ sql="""
17
+ CREATE TABLE IF NOT EXISTS memories (
18
+ id TEXT PRIMARY KEY,
19
+ content TEXT NOT NULL,
20
+ session_id TEXT NOT NULL,
21
+ thread_id TEXT,
22
+ source TEXT,
23
+ confidence REAL NOT NULL,
24
+ content_hash TEXT NOT NULL,
25
+ status TEXT NOT NULL,
26
+ depends_on TEXT DEFAULT '[]',
27
+ created_at TEXT NOT NULL,
28
+ updated_at TEXT NOT NULL,
29
+ expires_at TEXT,
30
+ retracted_at TEXT,
31
+ retraction_reason TEXT
32
+ );
33
+ CREATE TABLE IF NOT EXISTS audit_log (
34
+ id TEXT PRIMARY KEY,
35
+ memory_id TEXT NOT NULL,
36
+ action TEXT NOT NULL,
37
+ from_status TEXT,
38
+ to_status TEXT,
39
+ timestamp TEXT NOT NULL,
40
+ metadata TEXT DEFAULT '{}'
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
43
+ CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
44
+ CREATE INDEX IF NOT EXISTS idx_memories_session_status ON memories(session_id, status);
45
+ CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
46
+ CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at);
47
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_hash_session ON memories(content_hash, session_id)
48
+ WHERE status IN ('active', 'proposed', 'pending_confirmation');
49
+ CREATE INDEX IF NOT EXISTS idx_audit_memory ON audit_log(memory_id);
50
+ """,
51
+ ),
52
+ ]
53
+
54
+
55
+ async def run_migrations(conn, backend_type: str) -> None:
56
+ """Idempotent. Tracks applied versions in _migrations table."""
57
+ await conn.execute(
58
+ "CREATE TABLE IF NOT EXISTS _migrations "
59
+ "(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
60
+ )
61
+ rows = await conn.execute_fetchall(
62
+ "SELECT version FROM _migrations ORDER BY version"
63
+ )
64
+ applied = {row[0] for row in rows}
65
+
66
+ for migration in MIGRATIONS:
67
+ if migration.version not in applied:
68
+ await conn.executescript(migration.sql)
69
+ await conn.execute(
70
+ "INSERT INTO _migrations (version, applied_at) VALUES (?, ?)",
71
+ (migration.version, datetime.now(timezone.utc).isoformat()),
72
+ )
73
+ await conn.execute("COMMIT")
memsmith/_sqlite.py ADDED
@@ -0,0 +1,261 @@
1
+ import json
2
+ from collections.abc import AsyncIterator
3
+ from contextlib import asynccontextmanager
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ import aiosqlite
8
+
9
+ from memsmith._backend import _get_tx_conn, _tx_conn
10
+ from memsmith._migrations import run_migrations
11
+ from memsmith._types import AuditEntry, Memory, MemoryStatus
12
+
13
+ _ORDER_BY_WHITELIST = frozenset(
14
+ {"created_at", "updated_at", "confidence", "status", "session_id"}
15
+ )
16
+
17
+
18
+ class SQLiteBackend:
19
+ def __init__(self, path: str = ":memory:") -> None:
20
+ self._path = path
21
+ self._conn: aiosqlite.Connection | None = None
22
+
23
+ def _get_conn(self) -> aiosqlite.Connection:
24
+ tx_conn = _get_tx_conn()
25
+ if tx_conn is not None:
26
+ return tx_conn
27
+ if self._conn is None:
28
+ raise RuntimeError("SQLiteBackend not initialized")
29
+ return self._conn
30
+
31
+ @staticmethod
32
+ def _row_to_memory(row: aiosqlite.Row) -> Memory:
33
+ return Memory(
34
+ id=row["id"],
35
+ content=json.loads(row["content"]),
36
+ session_id=row["session_id"],
37
+ thread_id=row["thread_id"],
38
+ source=row["source"],
39
+ confidence=row["confidence"],
40
+ content_hash=row["content_hash"],
41
+ status=MemoryStatus(row["status"]),
42
+ depends_on=json.loads(row["depends_on"]),
43
+ created_at=datetime.fromisoformat(row["created_at"]),
44
+ updated_at=datetime.fromisoformat(row["updated_at"]),
45
+ expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
46
+ retracted_at=datetime.fromisoformat(row["retracted_at"]) if row["retracted_at"] else None,
47
+ retraction_reason=row["retraction_reason"],
48
+ )
49
+
50
+ @staticmethod
51
+ def _row_to_audit_entry(row: aiosqlite.Row) -> AuditEntry:
52
+ return AuditEntry(
53
+ id=row["id"],
54
+ memory_id=row["memory_id"],
55
+ action=row["action"],
56
+ from_status=MemoryStatus(row["from_status"]) if row["from_status"] else None,
57
+ to_status=MemoryStatus(row["to_status"]) if row["to_status"] else None,
58
+ timestamp=datetime.fromisoformat(row["timestamp"]),
59
+ metadata=json.loads(row["metadata"]),
60
+ )
61
+
62
+ async def initialize(self) -> None:
63
+ self._conn = await aiosqlite.connect(self._path)
64
+ self._conn.row_factory = aiosqlite.Row
65
+ await self._conn.execute("PRAGMA journal_mode=WAL")
66
+ await self._conn.execute("PRAGMA foreign_keys=ON")
67
+ await run_migrations(self._conn, "sqlite")
68
+
69
+ async def close(self) -> None:
70
+ if self._conn is not None:
71
+ await self._conn.close()
72
+ self._conn = None
73
+
74
+ @asynccontextmanager
75
+ async def transaction(self) -> AsyncIterator[None]:
76
+ token = _tx_conn.set(self._conn)
77
+ await self._conn.execute("SAVEPOINT memsmith_tx")
78
+ try:
79
+ yield
80
+ except BaseException:
81
+ await self._conn.execute("ROLLBACK TO memsmith_tx")
82
+ raise
83
+ finally:
84
+ await self._conn.execute("RELEASE memsmith_tx")
85
+ _tx_conn.reset(token)
86
+
87
+ async def insert_memory(self, memory: Memory, /) -> str:
88
+ conn = self._get_conn()
89
+ await conn.execute(
90
+ "INSERT INTO memories "
91
+ "(id, content, session_id, thread_id, source, confidence, content_hash, "
92
+ "status, depends_on, created_at, updated_at, expires_at, retracted_at, "
93
+ "retraction_reason) "
94
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
95
+ (
96
+ memory.id,
97
+ json.dumps(memory.content, ensure_ascii=False, default=str, separators=(",", ":")),
98
+ memory.session_id,
99
+ memory.thread_id,
100
+ memory.source,
101
+ memory.confidence,
102
+ memory.content_hash,
103
+ memory.status.value,
104
+ json.dumps(memory.depends_on),
105
+ memory.created_at.isoformat(),
106
+ memory.updated_at.isoformat(),
107
+ memory.expires_at.isoformat() if memory.expires_at else None,
108
+ memory.retracted_at.isoformat() if memory.retracted_at else None,
109
+ memory.retraction_reason,
110
+ ),
111
+ )
112
+ return memory.id
113
+
114
+ async def get_memory(self, memory_id: str, /) -> Memory | None:
115
+ conn = self._get_conn()
116
+ cursor = await conn.execute(
117
+ "SELECT * FROM memories WHERE id = ?", (memory_id,)
118
+ )
119
+ row = await cursor.fetchone()
120
+ return self._row_to_memory(row) if row else None
121
+
122
+ async def update_status(
123
+ self, memory_id: str, /, new_status: MemoryStatus, **extra: Any
124
+ ) -> None:
125
+ conn = self._get_conn()
126
+ now = datetime.now(timezone.utc).isoformat()
127
+ if extra:
128
+ set_clause = ", ".join(f"{k} = ?" for k in extra)
129
+ await conn.execute(
130
+ f"UPDATE memories SET status = ?, updated_at = ?, {set_clause} WHERE id = ?",
131
+ (new_status.value, now, *extra.values(), memory_id),
132
+ )
133
+ else:
134
+ await conn.execute(
135
+ "UPDATE memories SET status = ?, updated_at = ? WHERE id = ?",
136
+ (new_status.value, now, memory_id),
137
+ )
138
+
139
+ async def update_content(
140
+ self, memory_id: str, /, new_content: Any, new_hash: str
141
+ ) -> None:
142
+ conn = self._get_conn()
143
+ now = datetime.now(timezone.utc).isoformat()
144
+ await conn.execute(
145
+ "UPDATE memories SET content = ?, content_hash = ?, updated_at = ? WHERE id = ?",
146
+ (
147
+ json.dumps(new_content, ensure_ascii=False, default=str, separators=(",", ":")),
148
+ new_hash,
149
+ now,
150
+ memory_id,
151
+ ),
152
+ )
153
+
154
+ async def update_confidence(
155
+ self, memory_id: str, /, new_value: float
156
+ ) -> None:
157
+ conn = self._get_conn()
158
+ now = datetime.now(timezone.utc).isoformat()
159
+ await conn.execute(
160
+ "UPDATE memories SET confidence = ?, updated_at = ? WHERE id = ?",
161
+ (new_value, now, memory_id),
162
+ )
163
+
164
+ async def list_memories(
165
+ self,
166
+ status: MemoryStatus | None = None,
167
+ session_id: str | None = None,
168
+ field_filters: dict[str, Any] | None = None,
169
+ limit: int = 50,
170
+ offset: int = 0,
171
+ order_by: str = "created_at",
172
+ ) -> list[Memory]:
173
+ conn = self._get_conn()
174
+ conditions: list[str] = []
175
+ params: list[Any] = []
176
+
177
+ if status is not None:
178
+ conditions.append("status = ?")
179
+ params.append(status.value)
180
+ if session_id is not None:
181
+ conditions.append("session_id = ?")
182
+ params.append(session_id)
183
+ if field_filters:
184
+ for key, value in field_filters.items():
185
+ conditions.append(f"{key} = ?")
186
+ params.append(value)
187
+
188
+ where_clause = ""
189
+ if conditions:
190
+ where_clause = "WHERE " + " AND ".join(conditions)
191
+
192
+ if order_by not in _ORDER_BY_WHITELIST:
193
+ order_by = "created_at"
194
+
195
+ sql = f"SELECT * FROM memories {where_clause} ORDER BY {order_by} LIMIT ? OFFSET ?"
196
+ params.extend([limit, offset])
197
+
198
+ cursor = await conn.execute(sql, tuple(params))
199
+ rows = await cursor.fetchall()
200
+ return [self._row_to_memory(row) for row in rows]
201
+
202
+ async def find_by_hash(
203
+ self, content_hash: str, session_id: str, /
204
+ ) -> Memory | None:
205
+ conn = self._get_conn()
206
+ cursor = await conn.execute(
207
+ "SELECT * FROM memories WHERE content_hash = ? AND session_id = ? "
208
+ "AND status IN ('active', 'proposed', 'pending_confirmation') LIMIT 1",
209
+ (content_hash, session_id),
210
+ )
211
+ row = await cursor.fetchone()
212
+ return self._row_to_memory(row) if row else None
213
+
214
+ async def expire_batch(self) -> int:
215
+ conn = self._get_conn()
216
+ now = datetime.now(timezone.utc).isoformat()
217
+ cursor = await conn.execute(
218
+ "UPDATE memories SET status = ?, updated_at = ? "
219
+ "WHERE status = ? AND expires_at IS NOT NULL AND expires_at <= ?",
220
+ (
221
+ MemoryStatus.EXPIRED.value,
222
+ now,
223
+ MemoryStatus.ACTIVE.value,
224
+ now,
225
+ ),
226
+ )
227
+ return cursor.rowcount
228
+
229
+ async def log_audit(self, entry: AuditEntry, /) -> str:
230
+ conn = self._get_conn()
231
+ await conn.execute(
232
+ "INSERT INTO audit_log (id, memory_id, action, from_status, to_status, timestamp, metadata) "
233
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
234
+ (
235
+ entry.id,
236
+ entry.memory_id,
237
+ entry.action,
238
+ entry.from_status.value if entry.from_status else None,
239
+ entry.to_status.value if entry.to_status else None,
240
+ entry.timestamp.isoformat(),
241
+ json.dumps(entry.metadata),
242
+ ),
243
+ )
244
+ return entry.id
245
+
246
+ async def get_audit(
247
+ self, memory_id: str | None = None, /, limit: int = 50
248
+ ) -> list[AuditEntry]:
249
+ conn = self._get_conn()
250
+ if memory_id:
251
+ cursor = await conn.execute(
252
+ "SELECT * FROM audit_log WHERE memory_id = ? ORDER BY timestamp DESC LIMIT ?",
253
+ (memory_id, limit),
254
+ )
255
+ else:
256
+ cursor = await conn.execute(
257
+ "SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?",
258
+ (limit,),
259
+ )
260
+ rows = await cursor.fetchall()
261
+ return [self._row_to_audit_entry(row) for row in rows]