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.
Files changed (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. 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
+ )