runtime-memory 3.0.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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
"""SQLite storage layer for Runtime Memory.
|
|
2
|
+
|
|
3
|
+
Provides async database operations with:
|
|
4
|
+
- Connection pooling
|
|
5
|
+
- CRUD operations
|
|
6
|
+
- Outcome score tracking
|
|
7
|
+
- Soft delete (archival)
|
|
8
|
+
- Migration support
|
|
9
|
+
- Transaction support
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import builtins
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import stat
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
import aiosqlite
|
|
26
|
+
|
|
27
|
+
from runtime_memory.core.logging import get_logger
|
|
28
|
+
from runtime_memory.core.models import (
|
|
29
|
+
OUTCOME_SCORE_ADJUSTMENTS,
|
|
30
|
+
Memory,
|
|
31
|
+
MemoryCategory,
|
|
32
|
+
MemoryScope,
|
|
33
|
+
MemorySource,
|
|
34
|
+
Outcome,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import AsyncGenerator
|
|
39
|
+
|
|
40
|
+
# Type alias to avoid conflict with the list() method
|
|
41
|
+
_List = builtins.list
|
|
42
|
+
|
|
43
|
+
logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
# Schema version for migrations
|
|
46
|
+
SCHEMA_VERSION = 1
|
|
47
|
+
|
|
48
|
+
# SQL statements for schema creation
|
|
49
|
+
SCHEMA_SQL = """
|
|
50
|
+
-- Memories table
|
|
51
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
content TEXT NOT NULL,
|
|
54
|
+
category TEXT NOT NULL,
|
|
55
|
+
outcome_score REAL DEFAULT 0.0,
|
|
56
|
+
confidence REAL DEFAULT 1.0,
|
|
57
|
+
importance REAL DEFAULT 0.5,
|
|
58
|
+
use_count INTEGER DEFAULT 0,
|
|
59
|
+
project TEXT,
|
|
60
|
+
scope TEXT DEFAULT 'project',
|
|
61
|
+
source TEXT DEFAULT 'explicit',
|
|
62
|
+
tags TEXT DEFAULT '[]',
|
|
63
|
+
entities TEXT DEFAULT '[]',
|
|
64
|
+
supersedes TEXT,
|
|
65
|
+
archived INTEGER DEFAULT 0,
|
|
66
|
+
created_at TEXT NOT NULL,
|
|
67
|
+
updated_at TEXT NOT NULL,
|
|
68
|
+
embedding TEXT,
|
|
69
|
+
metadata TEXT DEFAULT '{}'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
-- Indexes for common queries
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_memories_archived ON memories(archived);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_memories_outcome_score ON memories(outcome_score);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_memories_project_category ON memories(project, category);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_memories_project_archived ON memories(project, archived);
|
|
81
|
+
|
|
82
|
+
-- Schema version tracking
|
|
83
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
84
|
+
version INTEGER PRIMARY KEY,
|
|
85
|
+
applied_at TEXT NOT NULL
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
-- Full-text search virtual table
|
|
89
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
90
|
+
id,
|
|
91
|
+
content,
|
|
92
|
+
tags,
|
|
93
|
+
entities,
|
|
94
|
+
content='memories',
|
|
95
|
+
content_rowid='rowid'
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
-- Triggers to keep FTS in sync
|
|
99
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
100
|
+
INSERT INTO memories_fts(rowid, id, content, tags, entities)
|
|
101
|
+
VALUES (new.rowid, new.id, new.content, new.tags, new.entities);
|
|
102
|
+
END;
|
|
103
|
+
|
|
104
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
105
|
+
INSERT INTO memories_fts(memories_fts, rowid, id, content, tags, entities)
|
|
106
|
+
VALUES ('delete', old.rowid, old.id, old.content, old.tags, old.entities);
|
|
107
|
+
END;
|
|
108
|
+
|
|
109
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
110
|
+
INSERT INTO memories_fts(memories_fts, rowid, id, content, tags, entities)
|
|
111
|
+
VALUES ('delete', old.rowid, old.id, old.content, old.tags, old.entities);
|
|
112
|
+
INSERT INTO memories_fts(rowid, id, content, tags, entities)
|
|
113
|
+
VALUES (new.rowid, new.id, new.content, new.tags, new.entities);
|
|
114
|
+
END;
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Migration SQL statements (version -> SQL)
|
|
118
|
+
MIGRATIONS: dict[int, str] = {
|
|
119
|
+
# Future migrations go here
|
|
120
|
+
# 2: "ALTER TABLE memories ADD COLUMN new_field TEXT;",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class StorageStats:
|
|
126
|
+
"""Statistics about the storage."""
|
|
127
|
+
|
|
128
|
+
total_memories: int
|
|
129
|
+
active_memories: int
|
|
130
|
+
archived_memories: int
|
|
131
|
+
by_category: dict[str, int]
|
|
132
|
+
by_scope: dict[str, int]
|
|
133
|
+
by_source: dict[str, int]
|
|
134
|
+
avg_outcome_score: float
|
|
135
|
+
total_uses: int
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class StorageError(Exception):
|
|
139
|
+
"""Base exception for storage errors."""
|
|
140
|
+
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class MemoryNotFoundError(StorageError):
|
|
145
|
+
"""Raised when a memory is not found."""
|
|
146
|
+
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ConnectionError(StorageError):
|
|
151
|
+
"""Raised when database connection fails."""
|
|
152
|
+
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class MemoryStorage:
|
|
157
|
+
"""Async SQLite storage for memories.
|
|
158
|
+
|
|
159
|
+
Provides connection pooling, CRUD operations, and transaction support.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
db_path: str | Path,
|
|
165
|
+
pool_size: int = 5,
|
|
166
|
+
timeout: float = 30.0,
|
|
167
|
+
secure_permissions: bool = True,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Initialize storage.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
db_path: Path to SQLite database file.
|
|
173
|
+
pool_size: Number of connections in the pool.
|
|
174
|
+
timeout: Connection timeout in seconds.
|
|
175
|
+
secure_permissions: Whether to set secure file permissions (0600).
|
|
176
|
+
"""
|
|
177
|
+
self.db_path = Path(db_path)
|
|
178
|
+
self.pool_size = pool_size
|
|
179
|
+
self.timeout = timeout
|
|
180
|
+
self.secure_permissions = secure_permissions
|
|
181
|
+
|
|
182
|
+
self._pool: asyncio.Queue[aiosqlite.Connection] = asyncio.Queue(maxsize=pool_size)
|
|
183
|
+
self._initialized = False
|
|
184
|
+
self._lock = asyncio.Lock()
|
|
185
|
+
|
|
186
|
+
async def initialize(self) -> None:
|
|
187
|
+
"""Initialize the storage and connection pool.
|
|
188
|
+
|
|
189
|
+
Creates the database file, applies schema, and populates connection pool.
|
|
190
|
+
"""
|
|
191
|
+
async with self._lock:
|
|
192
|
+
if self._initialized:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
# Ensure parent directory exists
|
|
196
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
# Create initial connection and schema
|
|
199
|
+
conn = await self._create_connection()
|
|
200
|
+
try:
|
|
201
|
+
await self._apply_schema(conn)
|
|
202
|
+
await self._run_migrations(conn)
|
|
203
|
+
finally:
|
|
204
|
+
await conn.close()
|
|
205
|
+
|
|
206
|
+
# Set secure file permissions
|
|
207
|
+
if self.secure_permissions and self.db_path.exists():
|
|
208
|
+
self._set_secure_permissions()
|
|
209
|
+
|
|
210
|
+
# Populate connection pool
|
|
211
|
+
for _ in range(self.pool_size):
|
|
212
|
+
conn = await self._create_connection()
|
|
213
|
+
await self._pool.put(conn)
|
|
214
|
+
|
|
215
|
+
self._initialized = True
|
|
216
|
+
logger.info(f"Storage initialized at {self.db_path}")
|
|
217
|
+
|
|
218
|
+
async def close(self) -> None:
|
|
219
|
+
"""Close all connections in the pool."""
|
|
220
|
+
while not self._pool.empty():
|
|
221
|
+
try:
|
|
222
|
+
conn = self._pool.get_nowait()
|
|
223
|
+
await conn.close()
|
|
224
|
+
except asyncio.QueueEmpty:
|
|
225
|
+
break
|
|
226
|
+
self._initialized = False
|
|
227
|
+
logger.info("Storage closed")
|
|
228
|
+
|
|
229
|
+
async def _create_connection(self) -> aiosqlite.Connection:
|
|
230
|
+
"""Create a new database connection.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
New aiosqlite connection.
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
conn = await aiosqlite.connect(
|
|
237
|
+
self.db_path,
|
|
238
|
+
timeout=self.timeout,
|
|
239
|
+
)
|
|
240
|
+
# Enable foreign keys and WAL mode for better concurrency
|
|
241
|
+
await conn.execute("PRAGMA foreign_keys = ON")
|
|
242
|
+
await conn.execute("PRAGMA journal_mode = WAL")
|
|
243
|
+
await conn.execute("PRAGMA synchronous = NORMAL")
|
|
244
|
+
conn.row_factory = aiosqlite.Row
|
|
245
|
+
return conn
|
|
246
|
+
except Exception as e:
|
|
247
|
+
raise ConnectionError(f"Failed to connect to database: {e}") from e
|
|
248
|
+
|
|
249
|
+
async def _apply_schema(self, conn: aiosqlite.Connection) -> None:
|
|
250
|
+
"""Apply the database schema.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
conn: Database connection.
|
|
254
|
+
"""
|
|
255
|
+
await conn.executescript(SCHEMA_SQL)
|
|
256
|
+
await conn.commit()
|
|
257
|
+
|
|
258
|
+
async def _run_migrations(self, conn: aiosqlite.Connection) -> None:
|
|
259
|
+
"""Run pending database migrations.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
conn: Database connection.
|
|
263
|
+
"""
|
|
264
|
+
# Get current version
|
|
265
|
+
cursor = await conn.execute(
|
|
266
|
+
"SELECT MAX(version) FROM schema_version"
|
|
267
|
+
)
|
|
268
|
+
row = await cursor.fetchone()
|
|
269
|
+
current_version = row[0] if row and row[0] else 0
|
|
270
|
+
|
|
271
|
+
# Apply pending migrations
|
|
272
|
+
for version in sorted(MIGRATIONS.keys()):
|
|
273
|
+
if version > current_version:
|
|
274
|
+
logger.info(f"Applying migration {version}")
|
|
275
|
+
await conn.executescript(MIGRATIONS[version])
|
|
276
|
+
await conn.execute(
|
|
277
|
+
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
278
|
+
(version, datetime.now(UTC).isoformat()),
|
|
279
|
+
)
|
|
280
|
+
await conn.commit()
|
|
281
|
+
|
|
282
|
+
# Record initial schema version if needed
|
|
283
|
+
if current_version == 0:
|
|
284
|
+
await conn.execute(
|
|
285
|
+
"INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
286
|
+
(SCHEMA_VERSION, datetime.now(UTC).isoformat()),
|
|
287
|
+
)
|
|
288
|
+
await conn.commit()
|
|
289
|
+
|
|
290
|
+
def _set_secure_permissions(self) -> None:
|
|
291
|
+
"""Set secure file permissions (0600) on the database file."""
|
|
292
|
+
try:
|
|
293
|
+
self.db_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
294
|
+
# Also secure the WAL and SHM files if they exist
|
|
295
|
+
for suffix in ["-wal", "-shm"]:
|
|
296
|
+
wal_path = Path(str(self.db_path) + suffix)
|
|
297
|
+
if wal_path.exists():
|
|
298
|
+
wal_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
299
|
+
except OSError as e:
|
|
300
|
+
logger.warning(f"Failed to set secure permissions: {e}")
|
|
301
|
+
|
|
302
|
+
@asynccontextmanager
|
|
303
|
+
async def _get_connection(self) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
304
|
+
"""Get a connection from the pool.
|
|
305
|
+
|
|
306
|
+
Yields:
|
|
307
|
+
Database connection.
|
|
308
|
+
"""
|
|
309
|
+
if not self._initialized:
|
|
310
|
+
await self.initialize()
|
|
311
|
+
|
|
312
|
+
conn = await asyncio.wait_for(self._pool.get(), timeout=self.timeout)
|
|
313
|
+
try:
|
|
314
|
+
yield conn
|
|
315
|
+
finally:
|
|
316
|
+
await self._pool.put(conn)
|
|
317
|
+
|
|
318
|
+
@asynccontextmanager
|
|
319
|
+
async def transaction(self) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
320
|
+
"""Start a transaction.
|
|
321
|
+
|
|
322
|
+
Yields:
|
|
323
|
+
Database connection with active transaction.
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
async with storage.transaction() as conn:
|
|
327
|
+
await storage.create(memory1, conn=conn)
|
|
328
|
+
await storage.create(memory2, conn=conn)
|
|
329
|
+
"""
|
|
330
|
+
async with self._get_connection() as conn:
|
|
331
|
+
await conn.execute("BEGIN IMMEDIATE")
|
|
332
|
+
try:
|
|
333
|
+
yield conn
|
|
334
|
+
await conn.commit()
|
|
335
|
+
except Exception:
|
|
336
|
+
await conn.rollback()
|
|
337
|
+
raise
|
|
338
|
+
|
|
339
|
+
# =========================================================================
|
|
340
|
+
# CRUD Operations
|
|
341
|
+
# =========================================================================
|
|
342
|
+
|
|
343
|
+
async def create(
|
|
344
|
+
self,
|
|
345
|
+
memory: Memory,
|
|
346
|
+
conn: aiosqlite.Connection | None = None,
|
|
347
|
+
) -> Memory:
|
|
348
|
+
"""Create a new memory.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
memory: Memory to create.
|
|
352
|
+
conn: Optional connection for transaction.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
Created memory.
|
|
356
|
+
"""
|
|
357
|
+
sql = """
|
|
358
|
+
INSERT INTO memories (
|
|
359
|
+
id, content, category, outcome_score, confidence, importance,
|
|
360
|
+
use_count, project, scope, source, tags, entities, supersedes,
|
|
361
|
+
archived, created_at, updated_at, embedding, metadata
|
|
362
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
363
|
+
"""
|
|
364
|
+
params = (
|
|
365
|
+
memory.id,
|
|
366
|
+
memory.content,
|
|
367
|
+
memory.category.value,
|
|
368
|
+
memory.outcome_score,
|
|
369
|
+
memory.confidence,
|
|
370
|
+
memory.importance,
|
|
371
|
+
memory.use_count,
|
|
372
|
+
memory.project,
|
|
373
|
+
memory.scope.value,
|
|
374
|
+
memory.source.value,
|
|
375
|
+
json.dumps(memory.tags),
|
|
376
|
+
json.dumps(memory.entities),
|
|
377
|
+
memory.supersedes,
|
|
378
|
+
1 if memory.archived else 0,
|
|
379
|
+
memory.created_at.isoformat(),
|
|
380
|
+
memory.updated_at.isoformat(),
|
|
381
|
+
json.dumps(memory.embedding) if memory.embedding else None,
|
|
382
|
+
json.dumps(memory.metadata),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if conn:
|
|
386
|
+
await conn.execute(sql, params)
|
|
387
|
+
else:
|
|
388
|
+
async with self._get_connection() as c:
|
|
389
|
+
await c.execute(sql, params)
|
|
390
|
+
await c.commit()
|
|
391
|
+
|
|
392
|
+
logger.debug(f"Created memory {memory.id}")
|
|
393
|
+
return memory
|
|
394
|
+
|
|
395
|
+
async def get(self, memory_id: str) -> Memory:
|
|
396
|
+
"""Get a memory by ID.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
memory_id: Memory ID.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Memory instance.
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
MemoryNotFoundError: If memory not found.
|
|
406
|
+
"""
|
|
407
|
+
sql = "SELECT * FROM memories WHERE id = ?"
|
|
408
|
+
async with self._get_connection() as conn:
|
|
409
|
+
cursor = await conn.execute(sql, (memory_id,))
|
|
410
|
+
row = await cursor.fetchone()
|
|
411
|
+
|
|
412
|
+
if not row:
|
|
413
|
+
raise MemoryNotFoundError(f"Memory not found: {memory_id}")
|
|
414
|
+
|
|
415
|
+
return self._row_to_memory(row)
|
|
416
|
+
|
|
417
|
+
async def get_many(self, memory_ids: _List[str]) -> _List[Memory]:
|
|
418
|
+
"""Get multiple memories by ID.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
memory_ids: List of memory IDs.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
List of memories (in same order as IDs, missing ones excluded).
|
|
425
|
+
"""
|
|
426
|
+
if not memory_ids:
|
|
427
|
+
return []
|
|
428
|
+
|
|
429
|
+
placeholders = ",".join("?" * len(memory_ids))
|
|
430
|
+
sql = f"SELECT * FROM memories WHERE id IN ({placeholders})"
|
|
431
|
+
|
|
432
|
+
async with self._get_connection() as conn:
|
|
433
|
+
cursor = await conn.execute(sql, memory_ids)
|
|
434
|
+
rows = await cursor.fetchall()
|
|
435
|
+
|
|
436
|
+
# Create lookup dict and preserve order
|
|
437
|
+
memory_map = {self._row_to_memory(row).id: self._row_to_memory(row) for row in rows}
|
|
438
|
+
return [memory_map[mid] for mid in memory_ids if mid in memory_map]
|
|
439
|
+
|
|
440
|
+
async def update(
|
|
441
|
+
self,
|
|
442
|
+
memory: Memory,
|
|
443
|
+
conn: aiosqlite.Connection | None = None,
|
|
444
|
+
) -> Memory:
|
|
445
|
+
"""Update an existing memory.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
memory: Memory with updated fields.
|
|
449
|
+
conn: Optional connection for transaction.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Updated memory.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
MemoryNotFoundError: If memory not found.
|
|
456
|
+
"""
|
|
457
|
+
# Ensure memory exists
|
|
458
|
+
await self.get(memory.id)
|
|
459
|
+
|
|
460
|
+
sql = """
|
|
461
|
+
UPDATE memories SET
|
|
462
|
+
content = ?, category = ?, outcome_score = ?, confidence = ?,
|
|
463
|
+
importance = ?, use_count = ?, project = ?, scope = ?, source = ?,
|
|
464
|
+
tags = ?, entities = ?, supersedes = ?, archived = ?,
|
|
465
|
+
updated_at = ?, embedding = ?, metadata = ?
|
|
466
|
+
WHERE id = ?
|
|
467
|
+
"""
|
|
468
|
+
params = (
|
|
469
|
+
memory.content,
|
|
470
|
+
memory.category.value,
|
|
471
|
+
memory.outcome_score,
|
|
472
|
+
memory.confidence,
|
|
473
|
+
memory.importance,
|
|
474
|
+
memory.use_count,
|
|
475
|
+
memory.project,
|
|
476
|
+
memory.scope.value,
|
|
477
|
+
memory.source.value,
|
|
478
|
+
json.dumps(memory.tags),
|
|
479
|
+
json.dumps(memory.entities),
|
|
480
|
+
memory.supersedes,
|
|
481
|
+
1 if memory.archived else 0,
|
|
482
|
+
memory.updated_at.isoformat(),
|
|
483
|
+
json.dumps(memory.embedding) if memory.embedding else None,
|
|
484
|
+
json.dumps(memory.metadata),
|
|
485
|
+
memory.id,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if conn:
|
|
489
|
+
await conn.execute(sql, params)
|
|
490
|
+
else:
|
|
491
|
+
async with self._get_connection() as c:
|
|
492
|
+
await c.execute(sql, params)
|
|
493
|
+
await c.commit()
|
|
494
|
+
|
|
495
|
+
logger.debug(f"Updated memory {memory.id}")
|
|
496
|
+
return memory
|
|
497
|
+
|
|
498
|
+
async def delete(
|
|
499
|
+
self,
|
|
500
|
+
memory_id: str,
|
|
501
|
+
hard_delete: bool = False,
|
|
502
|
+
conn: aiosqlite.Connection | None = None,
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Delete a memory.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
memory_id: Memory ID.
|
|
508
|
+
hard_delete: If True, permanently delete. If False, soft delete (archive).
|
|
509
|
+
conn: Optional connection for transaction.
|
|
510
|
+
|
|
511
|
+
Raises:
|
|
512
|
+
MemoryNotFoundError: If memory not found.
|
|
513
|
+
"""
|
|
514
|
+
# Ensure memory exists
|
|
515
|
+
await self.get(memory_id)
|
|
516
|
+
|
|
517
|
+
if hard_delete:
|
|
518
|
+
sql = "DELETE FROM memories WHERE id = ?"
|
|
519
|
+
else:
|
|
520
|
+
sql = "UPDATE memories SET archived = 1, updated_at = ? WHERE id = ?"
|
|
521
|
+
|
|
522
|
+
if conn:
|
|
523
|
+
if hard_delete:
|
|
524
|
+
await conn.execute(sql, (memory_id,))
|
|
525
|
+
else:
|
|
526
|
+
await conn.execute(sql, (datetime.now(UTC).isoformat(), memory_id))
|
|
527
|
+
else:
|
|
528
|
+
async with self._get_connection() as c:
|
|
529
|
+
if hard_delete:
|
|
530
|
+
await c.execute(sql, (memory_id,))
|
|
531
|
+
else:
|
|
532
|
+
await c.execute(sql, (datetime.now(UTC).isoformat(), memory_id))
|
|
533
|
+
await c.commit()
|
|
534
|
+
|
|
535
|
+
action = "deleted" if hard_delete else "archived"
|
|
536
|
+
logger.debug(f"Memory {memory_id} {action}")
|
|
537
|
+
|
|
538
|
+
async def archive(
|
|
539
|
+
self,
|
|
540
|
+
memory_id: str,
|
|
541
|
+
conn: aiosqlite.Connection | None = None,
|
|
542
|
+
) -> Memory:
|
|
543
|
+
"""Archive a memory (soft delete).
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
memory_id: Memory ID.
|
|
547
|
+
conn: Optional connection for transaction.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
Archived memory.
|
|
551
|
+
"""
|
|
552
|
+
memory = await self.get(memory_id)
|
|
553
|
+
memory.archive()
|
|
554
|
+
return await self.update(memory, conn=conn)
|
|
555
|
+
|
|
556
|
+
async def unarchive(
|
|
557
|
+
self,
|
|
558
|
+
memory_id: str,
|
|
559
|
+
conn: aiosqlite.Connection | None = None,
|
|
560
|
+
) -> Memory:
|
|
561
|
+
"""Unarchive a memory.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
memory_id: Memory ID.
|
|
565
|
+
conn: Optional connection for transaction.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
Unarchived memory.
|
|
569
|
+
"""
|
|
570
|
+
memory = await self.get(memory_id)
|
|
571
|
+
memory.archived = False
|
|
572
|
+
memory.updated_at = datetime.now(UTC)
|
|
573
|
+
return await self.update(memory, conn=conn)
|
|
574
|
+
|
|
575
|
+
# =========================================================================
|
|
576
|
+
# Query Operations
|
|
577
|
+
# =========================================================================
|
|
578
|
+
|
|
579
|
+
async def list(
|
|
580
|
+
self,
|
|
581
|
+
project: str | None = None,
|
|
582
|
+
category: MemoryCategory | None = None,
|
|
583
|
+
scope: MemoryScope | None = None,
|
|
584
|
+
source: MemorySource | None = None,
|
|
585
|
+
include_archived: bool = False,
|
|
586
|
+
min_score: float | None = None,
|
|
587
|
+
max_score: float | None = None,
|
|
588
|
+
limit: int = 100,
|
|
589
|
+
offset: int = 0,
|
|
590
|
+
order_by: str = "created_at",
|
|
591
|
+
descending: bool = True,
|
|
592
|
+
) -> _List[Memory]:
|
|
593
|
+
"""List memories with filters.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
project: Filter by project.
|
|
597
|
+
category: Filter by category.
|
|
598
|
+
scope: Filter by scope.
|
|
599
|
+
source: Filter by source.
|
|
600
|
+
include_archived: Whether to include archived memories.
|
|
601
|
+
min_score: Minimum outcome score.
|
|
602
|
+
max_score: Maximum outcome score.
|
|
603
|
+
limit: Maximum results.
|
|
604
|
+
offset: Results offset.
|
|
605
|
+
order_by: Column to order by.
|
|
606
|
+
descending: Whether to sort descending.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
List of memories.
|
|
610
|
+
"""
|
|
611
|
+
conditions = []
|
|
612
|
+
params: list[Any] = []
|
|
613
|
+
|
|
614
|
+
if not include_archived:
|
|
615
|
+
conditions.append("archived = 0")
|
|
616
|
+
|
|
617
|
+
if project is not None:
|
|
618
|
+
conditions.append("project = ?")
|
|
619
|
+
params.append(project)
|
|
620
|
+
|
|
621
|
+
if category is not None:
|
|
622
|
+
conditions.append("category = ?")
|
|
623
|
+
params.append(category.value)
|
|
624
|
+
|
|
625
|
+
if scope is not None:
|
|
626
|
+
conditions.append("scope = ?")
|
|
627
|
+
params.append(scope.value)
|
|
628
|
+
|
|
629
|
+
if source is not None:
|
|
630
|
+
conditions.append("source = ?")
|
|
631
|
+
params.append(source.value)
|
|
632
|
+
|
|
633
|
+
if min_score is not None:
|
|
634
|
+
conditions.append("outcome_score >= ?")
|
|
635
|
+
params.append(min_score)
|
|
636
|
+
|
|
637
|
+
if max_score is not None:
|
|
638
|
+
conditions.append("outcome_score <= ?")
|
|
639
|
+
params.append(max_score)
|
|
640
|
+
|
|
641
|
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
642
|
+
|
|
643
|
+
# Validate order_by to prevent SQL injection
|
|
644
|
+
valid_columns = {
|
|
645
|
+
"created_at", "updated_at", "outcome_score",
|
|
646
|
+
"use_count", "importance", "confidence",
|
|
647
|
+
}
|
|
648
|
+
if order_by not in valid_columns:
|
|
649
|
+
order_by = "created_at"
|
|
650
|
+
|
|
651
|
+
direction = "DESC" if descending else "ASC"
|
|
652
|
+
sql = f"""
|
|
653
|
+
SELECT * FROM memories
|
|
654
|
+
WHERE {where_clause}
|
|
655
|
+
ORDER BY {order_by} {direction}
|
|
656
|
+
LIMIT ? OFFSET ?
|
|
657
|
+
"""
|
|
658
|
+
params.extend([limit, offset])
|
|
659
|
+
|
|
660
|
+
async with self._get_connection() as conn:
|
|
661
|
+
cursor = await conn.execute(sql, params)
|
|
662
|
+
rows = await cursor.fetchall()
|
|
663
|
+
|
|
664
|
+
return [self._row_to_memory(row) for row in rows]
|
|
665
|
+
|
|
666
|
+
async def count(
|
|
667
|
+
self,
|
|
668
|
+
project: str | None = None,
|
|
669
|
+
category: MemoryCategory | None = None,
|
|
670
|
+
include_archived: bool = False,
|
|
671
|
+
) -> int:
|
|
672
|
+
"""Count memories with filters.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
project: Filter by project.
|
|
676
|
+
category: Filter by category.
|
|
677
|
+
include_archived: Whether to include archived memories.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
Count of matching memories.
|
|
681
|
+
"""
|
|
682
|
+
conditions = []
|
|
683
|
+
params: list[Any] = []
|
|
684
|
+
|
|
685
|
+
if not include_archived:
|
|
686
|
+
conditions.append("archived = 0")
|
|
687
|
+
|
|
688
|
+
if project is not None:
|
|
689
|
+
conditions.append("project = ?")
|
|
690
|
+
params.append(project)
|
|
691
|
+
|
|
692
|
+
if category is not None:
|
|
693
|
+
conditions.append("category = ?")
|
|
694
|
+
params.append(category.value)
|
|
695
|
+
|
|
696
|
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
697
|
+
sql = f"SELECT COUNT(*) FROM memories WHERE {where_clause}"
|
|
698
|
+
|
|
699
|
+
async with self._get_connection() as conn:
|
|
700
|
+
cursor = await conn.execute(sql, params)
|
|
701
|
+
row = await cursor.fetchone()
|
|
702
|
+
|
|
703
|
+
return row[0] if row else 0
|
|
704
|
+
|
|
705
|
+
async def search_fts(
|
|
706
|
+
self,
|
|
707
|
+
query: str,
|
|
708
|
+
project: str | None = None,
|
|
709
|
+
include_archived: bool = False,
|
|
710
|
+
limit: int = 20,
|
|
711
|
+
) -> _List[Memory]:
|
|
712
|
+
"""Full-text search for memories.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
query: Search query.
|
|
716
|
+
project: Filter by project.
|
|
717
|
+
include_archived: Whether to include archived.
|
|
718
|
+
limit: Maximum results.
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
List of matching memories.
|
|
722
|
+
"""
|
|
723
|
+
# Build FTS query
|
|
724
|
+
sql = """
|
|
725
|
+
SELECT m.* FROM memories m
|
|
726
|
+
JOIN memories_fts fts ON m.id = fts.id
|
|
727
|
+
WHERE memories_fts MATCH ?
|
|
728
|
+
"""
|
|
729
|
+
params: list[Any] = [query]
|
|
730
|
+
|
|
731
|
+
if not include_archived:
|
|
732
|
+
sql += " AND m.archived = 0"
|
|
733
|
+
|
|
734
|
+
if project is not None:
|
|
735
|
+
sql += " AND m.project = ?"
|
|
736
|
+
params.append(project)
|
|
737
|
+
|
|
738
|
+
sql += " ORDER BY rank LIMIT ?"
|
|
739
|
+
params.append(limit)
|
|
740
|
+
|
|
741
|
+
async with self._get_connection() as conn:
|
|
742
|
+
try:
|
|
743
|
+
cursor = await conn.execute(sql, params)
|
|
744
|
+
rows = await cursor.fetchall()
|
|
745
|
+
return [self._row_to_memory(row) for row in rows]
|
|
746
|
+
except aiosqlite.OperationalError:
|
|
747
|
+
# FTS query syntax error, return empty
|
|
748
|
+
return []
|
|
749
|
+
|
|
750
|
+
# =========================================================================
|
|
751
|
+
# Outcome and Use Count Operations
|
|
752
|
+
# =========================================================================
|
|
753
|
+
|
|
754
|
+
async def record_outcome(
|
|
755
|
+
self,
|
|
756
|
+
memory_id: str,
|
|
757
|
+
outcome: Outcome,
|
|
758
|
+
conn: aiosqlite.Connection | None = None,
|
|
759
|
+
) -> Memory:
|
|
760
|
+
"""Record an outcome for a memory.
|
|
761
|
+
|
|
762
|
+
Updates the outcome_score with clamping to [-1.0, 1.0].
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
memory_id: Memory ID.
|
|
766
|
+
outcome: Outcome to record.
|
|
767
|
+
conn: Optional connection for transaction.
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
Updated memory.
|
|
771
|
+
"""
|
|
772
|
+
memory = await self.get(memory_id)
|
|
773
|
+
adjustment = OUTCOME_SCORE_ADJUSTMENTS[outcome]
|
|
774
|
+
new_score = max(-1.0, min(1.0, memory.outcome_score + adjustment))
|
|
775
|
+
|
|
776
|
+
sql = """
|
|
777
|
+
UPDATE memories
|
|
778
|
+
SET outcome_score = ?, updated_at = ?
|
|
779
|
+
WHERE id = ?
|
|
780
|
+
"""
|
|
781
|
+
params = (new_score, datetime.now(UTC).isoformat(), memory_id)
|
|
782
|
+
|
|
783
|
+
if conn:
|
|
784
|
+
await conn.execute(sql, params)
|
|
785
|
+
else:
|
|
786
|
+
async with self._get_connection() as c:
|
|
787
|
+
await c.execute(sql, params)
|
|
788
|
+
await c.commit()
|
|
789
|
+
|
|
790
|
+
memory.outcome_score = new_score
|
|
791
|
+
memory.updated_at = datetime.now(UTC)
|
|
792
|
+
logger.debug(f"Recorded {outcome.value} for memory {memory_id}, score: {new_score}")
|
|
793
|
+
return memory
|
|
794
|
+
|
|
795
|
+
async def record_outcomes(
|
|
796
|
+
self,
|
|
797
|
+
memory_ids: _List[str],
|
|
798
|
+
outcome: Outcome,
|
|
799
|
+
) -> _List[Memory]:
|
|
800
|
+
"""Record an outcome for multiple memories.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
memory_ids: Memory IDs.
|
|
804
|
+
outcome: Outcome to record.
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
List of updated memories.
|
|
808
|
+
"""
|
|
809
|
+
async with self.transaction() as conn:
|
|
810
|
+
memories = []
|
|
811
|
+
for memory_id in memory_ids:
|
|
812
|
+
memory = await self.record_outcome(memory_id, outcome, conn=conn)
|
|
813
|
+
memories.append(memory)
|
|
814
|
+
return memories
|
|
815
|
+
|
|
816
|
+
async def increment_use_count(
|
|
817
|
+
self,
|
|
818
|
+
memory_id: str,
|
|
819
|
+
conn: aiosqlite.Connection | None = None,
|
|
820
|
+
) -> Memory:
|
|
821
|
+
"""Increment the use count for a memory.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
memory_id: Memory ID.
|
|
825
|
+
conn: Optional connection for transaction.
|
|
826
|
+
|
|
827
|
+
Returns:
|
|
828
|
+
Updated memory.
|
|
829
|
+
"""
|
|
830
|
+
sql = """
|
|
831
|
+
UPDATE memories
|
|
832
|
+
SET use_count = use_count + 1, updated_at = ?
|
|
833
|
+
WHERE id = ?
|
|
834
|
+
"""
|
|
835
|
+
params = (datetime.now(UTC).isoformat(), memory_id)
|
|
836
|
+
|
|
837
|
+
if conn:
|
|
838
|
+
await conn.execute(sql, params)
|
|
839
|
+
else:
|
|
840
|
+
async with self._get_connection() as c:
|
|
841
|
+
await c.execute(sql, params)
|
|
842
|
+
await c.commit()
|
|
843
|
+
|
|
844
|
+
return await self.get(memory_id)
|
|
845
|
+
|
|
846
|
+
async def increment_use_counts(
|
|
847
|
+
self,
|
|
848
|
+
memory_ids: _List[str],
|
|
849
|
+
) -> None:
|
|
850
|
+
"""Increment use counts for multiple memories.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
memory_ids: Memory IDs.
|
|
854
|
+
"""
|
|
855
|
+
if not memory_ids:
|
|
856
|
+
return
|
|
857
|
+
|
|
858
|
+
async with self.transaction() as conn:
|
|
859
|
+
for memory_id in memory_ids:
|
|
860
|
+
await self.increment_use_count(memory_id, conn=conn)
|
|
861
|
+
|
|
862
|
+
# =========================================================================
|
|
863
|
+
# Batch Operations
|
|
864
|
+
# =========================================================================
|
|
865
|
+
|
|
866
|
+
async def create_many(self, memories: _List[Memory]) -> _List[Memory]:
|
|
867
|
+
"""Create multiple memories in a transaction.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
memories: Memories to create.
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
Created memories.
|
|
874
|
+
"""
|
|
875
|
+
async with self.transaction() as conn:
|
|
876
|
+
for memory in memories:
|
|
877
|
+
await self.create(memory, conn=conn)
|
|
878
|
+
return memories
|
|
879
|
+
|
|
880
|
+
async def delete_many(
|
|
881
|
+
self,
|
|
882
|
+
memory_ids: _List[str],
|
|
883
|
+
hard_delete: bool = False,
|
|
884
|
+
) -> None:
|
|
885
|
+
"""Delete multiple memories in a transaction.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
memory_ids: Memory IDs.
|
|
889
|
+
hard_delete: If True, permanently delete.
|
|
890
|
+
"""
|
|
891
|
+
async with self.transaction() as conn:
|
|
892
|
+
for memory_id in memory_ids:
|
|
893
|
+
with contextlib.suppress(MemoryNotFoundError):
|
|
894
|
+
await self.delete(memory_id, hard_delete=hard_delete, conn=conn)
|
|
895
|
+
|
|
896
|
+
async def archive_low_score_memories(
|
|
897
|
+
self,
|
|
898
|
+
threshold: float = -0.5,
|
|
899
|
+
project: str | None = None,
|
|
900
|
+
) -> int:
|
|
901
|
+
"""Archive memories with low outcome scores.
|
|
902
|
+
|
|
903
|
+
Args:
|
|
904
|
+
threshold: Score threshold (archive if below).
|
|
905
|
+
project: Optional project filter.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
Number of archived memories.
|
|
909
|
+
"""
|
|
910
|
+
conditions = ["archived = 0", "outcome_score < ?"]
|
|
911
|
+
params: list[Any] = [threshold]
|
|
912
|
+
|
|
913
|
+
if project is not None:
|
|
914
|
+
conditions.append("project = ?")
|
|
915
|
+
params.append(project)
|
|
916
|
+
|
|
917
|
+
where_clause = " AND ".join(conditions)
|
|
918
|
+
sql = f"""
|
|
919
|
+
UPDATE memories
|
|
920
|
+
SET archived = 1, updated_at = ?
|
|
921
|
+
WHERE {where_clause}
|
|
922
|
+
"""
|
|
923
|
+
params.insert(0, datetime.now(UTC).isoformat())
|
|
924
|
+
|
|
925
|
+
async with self._get_connection() as conn:
|
|
926
|
+
cursor = await conn.execute(sql, params)
|
|
927
|
+
await conn.commit()
|
|
928
|
+
return cursor.rowcount
|
|
929
|
+
|
|
930
|
+
# =========================================================================
|
|
931
|
+
# Statistics
|
|
932
|
+
# =========================================================================
|
|
933
|
+
|
|
934
|
+
async def get_stats(self, project: str | None = None) -> StorageStats:
|
|
935
|
+
"""Get storage statistics.
|
|
936
|
+
|
|
937
|
+
Args:
|
|
938
|
+
project: Optional project filter.
|
|
939
|
+
|
|
940
|
+
Returns:
|
|
941
|
+
Storage statistics.
|
|
942
|
+
"""
|
|
943
|
+
project_filter = "AND project = ?" if project else ""
|
|
944
|
+
params: list[Any] = [project] if project else []
|
|
945
|
+
|
|
946
|
+
async with self._get_connection() as conn:
|
|
947
|
+
# Total and archived counts
|
|
948
|
+
cursor = await conn.execute(
|
|
949
|
+
f"SELECT COUNT(*) FROM memories WHERE 1=1 {project_filter}",
|
|
950
|
+
params,
|
|
951
|
+
)
|
|
952
|
+
row = await cursor.fetchone()
|
|
953
|
+
total = row[0] if row else 0
|
|
954
|
+
|
|
955
|
+
cursor = await conn.execute(
|
|
956
|
+
f"SELECT COUNT(*) FROM memories WHERE archived = 1 {project_filter}",
|
|
957
|
+
params,
|
|
958
|
+
)
|
|
959
|
+
row = await cursor.fetchone()
|
|
960
|
+
archived = row[0] if row else 0
|
|
961
|
+
|
|
962
|
+
# By category
|
|
963
|
+
cursor = await conn.execute(
|
|
964
|
+
f"""
|
|
965
|
+
SELECT category, COUNT(*) FROM memories
|
|
966
|
+
WHERE archived = 0 {project_filter}
|
|
967
|
+
GROUP BY category
|
|
968
|
+
""",
|
|
969
|
+
params,
|
|
970
|
+
)
|
|
971
|
+
by_category: dict[str, int] = {r[0]: r[1] for r in await cursor.fetchall()}
|
|
972
|
+
|
|
973
|
+
# By scope
|
|
974
|
+
cursor = await conn.execute(
|
|
975
|
+
f"""
|
|
976
|
+
SELECT scope, COUNT(*) FROM memories
|
|
977
|
+
WHERE archived = 0 {project_filter}
|
|
978
|
+
GROUP BY scope
|
|
979
|
+
""",
|
|
980
|
+
params,
|
|
981
|
+
)
|
|
982
|
+
by_scope: dict[str, int] = {r[0]: r[1] for r in await cursor.fetchall()}
|
|
983
|
+
|
|
984
|
+
# By source
|
|
985
|
+
cursor = await conn.execute(
|
|
986
|
+
f"""
|
|
987
|
+
SELECT source, COUNT(*) FROM memories
|
|
988
|
+
WHERE archived = 0 {project_filter}
|
|
989
|
+
GROUP BY source
|
|
990
|
+
""",
|
|
991
|
+
params,
|
|
992
|
+
)
|
|
993
|
+
by_source: dict[str, int] = {r[0]: r[1] for r in await cursor.fetchall()}
|
|
994
|
+
|
|
995
|
+
# Average outcome score
|
|
996
|
+
cursor = await conn.execute(
|
|
997
|
+
f"""
|
|
998
|
+
SELECT AVG(outcome_score) FROM memories
|
|
999
|
+
WHERE archived = 0 {project_filter}
|
|
1000
|
+
""",
|
|
1001
|
+
params,
|
|
1002
|
+
)
|
|
1003
|
+
row = await cursor.fetchone()
|
|
1004
|
+
avg_score = row[0] if row and row[0] else 0.0
|
|
1005
|
+
|
|
1006
|
+
# Total uses
|
|
1007
|
+
cursor = await conn.execute(
|
|
1008
|
+
f"""
|
|
1009
|
+
SELECT SUM(use_count) FROM memories
|
|
1010
|
+
WHERE 1=1 {project_filter}
|
|
1011
|
+
""",
|
|
1012
|
+
params,
|
|
1013
|
+
)
|
|
1014
|
+
row = await cursor.fetchone()
|
|
1015
|
+
total_uses = row[0] if row and row[0] else 0
|
|
1016
|
+
|
|
1017
|
+
return StorageStats(
|
|
1018
|
+
total_memories=total,
|
|
1019
|
+
active_memories=total - archived,
|
|
1020
|
+
archived_memories=archived,
|
|
1021
|
+
by_category=by_category,
|
|
1022
|
+
by_scope=by_scope,
|
|
1023
|
+
by_source=by_source,
|
|
1024
|
+
avg_outcome_score=avg_score,
|
|
1025
|
+
total_uses=total_uses,
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
# =========================================================================
|
|
1029
|
+
# Health Check
|
|
1030
|
+
# =========================================================================
|
|
1031
|
+
|
|
1032
|
+
async def health_check(self) -> dict[str, Any]:
|
|
1033
|
+
"""Check storage health.
|
|
1034
|
+
|
|
1035
|
+
Returns:
|
|
1036
|
+
Health status dictionary.
|
|
1037
|
+
"""
|
|
1038
|
+
try:
|
|
1039
|
+
async with self._get_connection() as conn:
|
|
1040
|
+
# Check we can query
|
|
1041
|
+
cursor = await conn.execute("SELECT 1")
|
|
1042
|
+
await cursor.fetchone()
|
|
1043
|
+
|
|
1044
|
+
# Check integrity
|
|
1045
|
+
cursor = await conn.execute("PRAGMA integrity_check")
|
|
1046
|
+
row = await cursor.fetchone()
|
|
1047
|
+
integrity = row[0] if row else "unknown"
|
|
1048
|
+
|
|
1049
|
+
# Get database size
|
|
1050
|
+
cursor = await conn.execute("PRAGMA page_count")
|
|
1051
|
+
row = await cursor.fetchone()
|
|
1052
|
+
page_count = row[0] if row else 0
|
|
1053
|
+
cursor = await conn.execute("PRAGMA page_size")
|
|
1054
|
+
row = await cursor.fetchone()
|
|
1055
|
+
page_size = row[0] if row else 0
|
|
1056
|
+
db_size = page_count * page_size
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
"status": "healthy" if integrity == "ok" else "degraded",
|
|
1060
|
+
"database": str(self.db_path),
|
|
1061
|
+
"size_bytes": db_size,
|
|
1062
|
+
"integrity": integrity,
|
|
1063
|
+
"pool_size": self.pool_size,
|
|
1064
|
+
"initialized": self._initialized,
|
|
1065
|
+
}
|
|
1066
|
+
except Exception as e:
|
|
1067
|
+
return {
|
|
1068
|
+
"status": "unhealthy",
|
|
1069
|
+
"error": str(e),
|
|
1070
|
+
"initialized": self._initialized,
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
# =========================================================================
|
|
1074
|
+
# Helpers
|
|
1075
|
+
# =========================================================================
|
|
1076
|
+
|
|
1077
|
+
def _row_to_memory(self, row: aiosqlite.Row) -> Memory:
|
|
1078
|
+
"""Convert a database row to a Memory object.
|
|
1079
|
+
|
|
1080
|
+
Args:
|
|
1081
|
+
row: Database row.
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
Memory instance.
|
|
1085
|
+
"""
|
|
1086
|
+
return Memory(
|
|
1087
|
+
id=row["id"],
|
|
1088
|
+
content=row["content"],
|
|
1089
|
+
category=MemoryCategory(row["category"]),
|
|
1090
|
+
outcome_score=row["outcome_score"],
|
|
1091
|
+
confidence=row["confidence"],
|
|
1092
|
+
importance=row["importance"],
|
|
1093
|
+
use_count=row["use_count"],
|
|
1094
|
+
project=row["project"],
|
|
1095
|
+
scope=MemoryScope(row["scope"]),
|
|
1096
|
+
source=MemorySource(row["source"]),
|
|
1097
|
+
tags=json.loads(row["tags"]) if row["tags"] else [],
|
|
1098
|
+
entities=json.loads(row["entities"]) if row["entities"] else [],
|
|
1099
|
+
supersedes=row["supersedes"],
|
|
1100
|
+
archived=bool(row["archived"]),
|
|
1101
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
1102
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
1103
|
+
embedding=json.loads(row["embedding"]) if row["embedding"] else None,
|
|
1104
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
|
1105
|
+
)
|