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 +56 -0
- memsmith/_backend.py +62 -0
- memsmith/_errors.py +49 -0
- memsmith/_hashing.py +15 -0
- memsmith/_hooks.py +36 -0
- memsmith/_migrations.py +73 -0
- memsmith/_sqlite.py +261 -0
- memsmith/_store.py +604 -0
- memsmith/_types.py +96 -0
- memsmith/_validators.py +52 -0
- memsmith-0.1.0.dist-info/METADATA +479 -0
- memsmith-0.1.0.dist-info/RECORD +15 -0
- memsmith-0.1.0.dist-info/WHEEL +5 -0
- memsmith-0.1.0.dist-info/licenses/LICENSE +182 -0
- memsmith-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|
memsmith/_migrations.py
ADDED
|
@@ -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]
|