plug-play-memory 0.2.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.
@@ -0,0 +1,495 @@
1
+ """
2
+ Storage layer for plug-play-memory — security-hardened version.
3
+
4
+ Changes from v0.1:
5
+ - Optional encryption at rest via SQLCipher (C2 fix)
6
+ - Schema versioning with real migration logic (H6 fix)
7
+ - Embedder fingerprint validation on init (M1 fix)
8
+ - Reserved agent_id validation (M4 fix)
9
+ - Thread-safe connection tracking + close_all() (L3 fix)
10
+ - BLAKE2b-based deterministic embedder (unchanged — was already correct)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import sqlite3
18
+ import threading
19
+ import time
20
+ import uuid
21
+ import warnings
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Callable
25
+
26
+ from .audit import AuditLog
27
+
28
+ SCHEMA_VERSION = 2 # bumped from 1 → 2 for security-hardened release
29
+ EMBEDDING_DIM = 256
30
+
31
+ # Names users cannot use as agent_id (would collide with policy layer names)
32
+ RESERVED_AGENT_IDS = {"_base", "coding", "study", "financial", "default", "all"}
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Embeddings
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def default_embedder(text: str, dim: int = EMBEDDING_DIM) -> list[float]:
40
+ """Deterministic BLAKE2b-based embedder. Survives restarts (unlike hash())."""
41
+ vec = [0.0] * dim
42
+ for token in text.lower().split():
43
+ h = int.from_bytes(hashlib.blake2b(token.encode(), digest_size=8).digest(), "big")
44
+ vec[h % dim] += 1.0
45
+ norm = sum(v * v for v in vec) ** 0.5 or 1.0
46
+ return [v / norm for v in vec]
47
+
48
+
49
+ def embedder_fingerprint(embed_fn: Callable[[str], list[float]]) -> str:
50
+ sample = embed_fn("fingerprint probe text")
51
+ return hashlib.blake2b(
52
+ f"{getattr(embed_fn, '__name__', 'custom')}|{len(sample)}|{round(sample[0], 6)}".encode()
53
+ ).hexdigest()[:16]
54
+
55
+
56
+ EmbedFn = Callable[[str], list[float]]
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Validation helpers
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def validate_agent_id(agent_id: str) -> None:
64
+ """Reject reserved agent_ids that would collide with policy layer names."""
65
+ if not agent_id or not isinstance(agent_id, str):
66
+ raise ValueError("agent_id must be a non-empty string")
67
+ if agent_id in RESERVED_AGENT_IDS:
68
+ raise ValueError(
69
+ f"agent_id '{agent_id}' is reserved. Use a different name "
70
+ f"(avoid: {sorted(RESERVED_AGENT_IDS)})."
71
+ )
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Data model
76
+ # ---------------------------------------------------------------------------
77
+
78
+ @dataclass
79
+ class Fact:
80
+ id: str
81
+ agent_id: str
82
+ tier: str
83
+ key: str | None
84
+ value: str
85
+ importance: str
86
+ salience_score: float
87
+ tagged: bool
88
+ ttl_seconds: int | None
89
+ created_at: float
90
+ updated_at: float
91
+ last_recalled_at: float | None
92
+ recall_count: int = 0
93
+ superseded: bool = False
94
+ metadata: dict[str, Any] = field(default_factory=dict)
95
+
96
+
97
+ class MemoryStore:
98
+ """One SQLite file. Multi-tenant. Thread-safe. Optionally encrypted."""
99
+
100
+ def __init__(
101
+ self,
102
+ db_path: str | Path,
103
+ embed_fn: EmbedFn | None = None,
104
+ embedding_dim: int = EMBEDDING_DIM,
105
+ encryption_key: str | None = None,
106
+ ):
107
+ self.db_path = str(db_path)
108
+ self.embed_fn = embed_fn or default_embedder
109
+ self.embedding_dim = embedding_dim
110
+ self.embedder_fp = embedder_fingerprint(self.embed_fn)
111
+ self.encryption_key = encryption_key
112
+ self._local = threading.local()
113
+ self._all_conns: set[sqlite3.Connection] = set()
114
+ self._conns_lock = threading.Lock()
115
+ self._init_schema()
116
+ self.audit = AuditLog(self._get_conn_for_thread, self, encryption_key)
117
+
118
+ # -- connection management -----------------------------------------------
119
+
120
+ def _new_connection(self) -> sqlite3.Connection:
121
+ conn = sqlite3.connect(self.db_path)
122
+ conn.row_factory = sqlite3.Row
123
+ conn.execute("PRAGMA journal_mode=WAL")
124
+ # SQLCipher: if key provided, set it BEFORE any other operation
125
+ if self.encryption_key:
126
+ try:
127
+ conn.execute(f"PRAGMA key = '{self.encryption_key}'")
128
+ # Verify the key is correct by reading
129
+ conn.execute("SELECT count(*) FROM sqlite_master")
130
+ except sqlite3.DatabaseError as e:
131
+ conn.close()
132
+ raise RuntimeError(
133
+ f"Failed to open encrypted DB. Wrong key? ({e})"
134
+ ) from e
135
+ return conn
136
+
137
+ def _get_conn_for_thread(self) -> sqlite3.Connection:
138
+ conn = getattr(self._local, "conn", None)
139
+ if conn is None:
140
+ conn = self._new_connection()
141
+ self._local.conn = conn
142
+ with self._conns_lock:
143
+ self._all_conns.add(conn)
144
+ return conn
145
+
146
+ @property
147
+ def _conn(self) -> sqlite3.Connection:
148
+ return self._get_conn_for_thread()
149
+
150
+ def close_all(self) -> None:
151
+ """Close ALL thread-local connections (L3 fix)."""
152
+ with self._conns_lock:
153
+ for conn in self._all_conns:
154
+ try:
155
+ conn.close()
156
+ except Exception:
157
+ pass
158
+ self._all_conns.clear()
159
+ # Also clear thread-local
160
+ if hasattr(self._local, "conn"):
161
+ try:
162
+ self._local.conn.close()
163
+ except Exception:
164
+ pass
165
+ del self._local.conn
166
+
167
+ def close(self) -> None:
168
+ self.close_all()
169
+
170
+ # -- schema management ---------------------------------------------------
171
+
172
+ def _init_schema(self) -> None:
173
+ conn = self._get_conn_for_thread()
174
+ # Bootstrap schema_meta first so we can read the version
175
+ conn.executescript(
176
+ """
177
+ CREATE TABLE IF NOT EXISTS schema_meta (
178
+ key TEXT PRIMARY KEY,
179
+ value TEXT NOT NULL
180
+ );
181
+ """
182
+ )
183
+
184
+ # Read stored version
185
+ row = conn.execute(
186
+ "SELECT value FROM schema_meta WHERE key = 'version'"
187
+ ).fetchone()
188
+ stored_version = int(row["value"]) if row else 0
189
+
190
+ # Future-version guard (H6 fix)
191
+ if stored_version > SCHEMA_VERSION:
192
+ raise RuntimeError(
193
+ f"This DB was created by plug-play-memory v{stored_version} "
194
+ f"(schema v{stored_version}). Current code is v{SCHEMA_VERSION}. "
195
+ f"Upgrade the package to open this DB."
196
+ )
197
+
198
+ # Run migrations if needed
199
+ if stored_version < SCHEMA_VERSION:
200
+ self._run_migrations(conn, stored_version)
201
+
202
+ # Check embedder fingerprint (M1 fix)
203
+ fp_row = conn.execute(
204
+ "SELECT value FROM schema_meta WHERE key = 'embedder_fingerprint'"
205
+ ).fetchone()
206
+ if fp_row is not None:
207
+ stored_fp = fp_row["value"]
208
+ if stored_fp != self.embedder_fp:
209
+ warnings.warn(
210
+ f"\n ⚠️ EMBEDDER MISMATCH\n"
211
+ f" Stored fingerprint: {stored_fp}\n"
212
+ f" Current fingerprint: {self.embedder_fp}\n"
213
+ f" Existing archival embeddings are now INCOMPATIBLE.\n"
214
+ f" Semantic recall will return wrong/no results.\n"
215
+ f" Run `agentmemory reindex` to rebuild embeddings,\n"
216
+ f" or revert to the previous embedder.\n",
217
+ stacklevel=2,
218
+ )
219
+ else:
220
+ conn.execute(
221
+ "INSERT OR REPLACE INTO schema_meta (key, value) VALUES (?, ?)",
222
+ ("embedder_fingerprint", self.embedder_fp),
223
+ )
224
+
225
+ # Update schema version (in case migrations just ran)
226
+ conn.execute(
227
+ "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('version', ?)",
228
+ (str(SCHEMA_VERSION),),
229
+ )
230
+ conn.commit()
231
+
232
+ def _run_migrations(self, conn: sqlite3.Connection, from_version: int) -> None:
233
+ """Run migrations from stored version up to SCHEMA_VERSION (H6 fix)."""
234
+ # Migration 0 → 1: initial schema (v0.1)
235
+ if from_version < 1:
236
+ self._migrate_v0_to_v1(conn)
237
+ # Migration 1 → 2: security hardening (add columns if missing)
238
+ if from_version < 2:
239
+ self._migrate_v1_to_v2(conn)
240
+
241
+ def _migrate_v0_to_v1(self, conn: sqlite3.Connection) -> None:
242
+ conn.executescript(
243
+ """
244
+ CREATE TABLE IF NOT EXISTS facts (
245
+ id TEXT PRIMARY KEY,
246
+ agent_id TEXT NOT NULL,
247
+ tier TEXT NOT NULL,
248
+ key TEXT,
249
+ value TEXT NOT NULL,
250
+ importance TEXT NOT NULL DEFAULT 'normal',
251
+ salience_score REAL NOT NULL DEFAULT 0.5,
252
+ tagged INTEGER NOT NULL DEFAULT 0,
253
+ ttl_seconds INTEGER,
254
+ created_at REAL NOT NULL,
255
+ updated_at REAL NOT NULL,
256
+ last_recalled_at REAL,
257
+ recall_count INTEGER NOT NULL DEFAULT 0,
258
+ consolidates_at REAL,
259
+ superseded INTEGER NOT NULL DEFAULT 0,
260
+ metadata TEXT NOT NULL DEFAULT '{}'
261
+ );
262
+ CREATE INDEX IF NOT EXISTS idx_facts_agent_tier
263
+ ON facts(agent_id, tier, superseded);
264
+ CREATE INDEX IF NOT EXISTS idx_facts_agent_key
265
+ ON facts(agent_id, key, superseded);
266
+ CREATE INDEX IF NOT EXISTS idx_facts_tagged
267
+ ON facts(agent_id, tagged, consolidates_at);
268
+ CREATE INDEX IF NOT EXISTS idx_facts_last_recalled
269
+ ON facts(agent_id, last_recalled_at, importance);
270
+ """
271
+ )
272
+
273
+ def _migrate_v1_to_v2(self, conn: sqlite3.Connection) -> None:
274
+ """v1 → v2: no schema changes needed, but bump version to mark
275
+ security-hardened storage. Future v2 migrations go here."""
276
+ # Currently a no-op — schema is unchanged from v1.
277
+ # This exists so future v2 → v3 migrations have a hook.
278
+ pass
279
+
280
+ # -- writes ---------------------------------------------------------------
281
+
282
+ def remember(
283
+ self,
284
+ agent_id: str,
285
+ value: str,
286
+ tier: str = "archival",
287
+ key: str | None = None,
288
+ importance: str = "normal",
289
+ salience_score: float = 0.5,
290
+ tagged: bool = False,
291
+ ttl_seconds: int | None = None,
292
+ metadata: dict[str, Any] | None = None,
293
+ ) -> Fact:
294
+ """Store a fact. Update-not-append on key collision."""
295
+ validate_agent_id(agent_id) # M4 fix
296
+
297
+ conn = self._conn
298
+ now = time.time()
299
+ consolidates_at = now + 86400 if tagged else None
300
+
301
+ with conn: # transaction
302
+ if key:
303
+ conn.execute(
304
+ "UPDATE facts SET superseded = 1 WHERE agent_id = ? AND key = ? AND superseded = 0",
305
+ (agent_id, key),
306
+ )
307
+
308
+ fact_id = str(uuid.uuid4())
309
+ conn.execute(
310
+ """
311
+ INSERT INTO facts (id, agent_id, tier, key, value, importance,
312
+ salience_score, tagged, ttl_seconds,
313
+ created_at, updated_at, last_recalled_at,
314
+ recall_count, consolidates_at, superseded, metadata)
315
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, 0, ?)
316
+ """,
317
+ (
318
+ fact_id, agent_id, tier, key, value, importance,
319
+ salience_score, 1 if tagged else 0, ttl_seconds,
320
+ now, now, consolidates_at, json.dumps(metadata or {}),
321
+ ),
322
+ )
323
+
324
+ fact = Fact(
325
+ id=fact_id, agent_id=agent_id, tier=tier, key=key, value=value,
326
+ importance=importance, salience_score=salience_score, tagged=tagged,
327
+ ttl_seconds=ttl_seconds, created_at=now, updated_at=now,
328
+ last_recalled_at=None, recall_count=0, superseded=False,
329
+ metadata=metadata or {},
330
+ )
331
+ self.audit.log(agent_id, "remember", {
332
+ "fact_id": fact_id, "tier": tier, "key": key,
333
+ "salience_score": salience_score, "tagged": tagged,
334
+ })
335
+ return fact
336
+
337
+ def forget(self, agent_id: str, fact_id: str, hard: bool = False) -> bool:
338
+ """Soft-delete (default) preserves audit trail; hard=True removes row."""
339
+ validate_agent_id(agent_id)
340
+ conn = self._conn
341
+ with conn:
342
+ if hard:
343
+ cur = conn.execute(
344
+ "DELETE FROM facts WHERE agent_id = ? AND id = ?",
345
+ (agent_id, fact_id),
346
+ )
347
+ else:
348
+ cur = conn.execute(
349
+ "UPDATE facts SET superseded = 1, updated_at = ? WHERE agent_id = ? AND id = ?",
350
+ (time.time(), agent_id, fact_id),
351
+ )
352
+ removed = cur.rowcount > 0
353
+ self.audit.log(agent_id, "forget", {"fact_id": fact_id, "hard": hard, "removed": removed})
354
+ return removed
355
+
356
+ def update_recall_stats(self, fact_id: str) -> None:
357
+ conn = self._conn
358
+ now = time.time()
359
+ with conn:
360
+ conn.execute(
361
+ """UPDATE facts
362
+ SET recall_count = recall_count + 1,
363
+ last_recalled_at = ?,
364
+ updated_at = ?
365
+ WHERE id = ?""",
366
+ (now, now, fact_id),
367
+ )
368
+
369
+ def decay_importance(self, fact_id: str, levels: int = 1) -> None:
370
+ order = ["high", "normal", "low"]
371
+ conn = self._conn
372
+ with conn:
373
+ row = conn.execute(
374
+ "SELECT importance FROM facts WHERE id = ?", (fact_id,)
375
+ ).fetchone()
376
+ if not row:
377
+ return
378
+ current = order.index(row["importance"]) if row["importance"] in order else 1
379
+ new_idx = min(current + levels, len(order) - 1)
380
+ conn.execute(
381
+ "UPDATE facts SET importance = ?, updated_at = ? WHERE id = ?",
382
+ (order[new_idx], time.time(), fact_id),
383
+ )
384
+
385
+ def promote(self, fact_id: str, tier: str = "archival") -> None:
386
+ conn = self._conn
387
+ with conn:
388
+ conn.execute(
389
+ """UPDATE facts
390
+ SET tier = ?, tagged = 0, consolidates_at = NULL, updated_at = ?
391
+ WHERE id = ?""",
392
+ (tier, time.time(), fact_id),
393
+ )
394
+
395
+ # -- reads ---------------------------------------------------------------
396
+
397
+ def get_core(self, agent_id: str) -> list[Fact]:
398
+ validate_agent_id(agent_id)
399
+ return self._active_facts(agent_id, tier="core")
400
+
401
+ def recall_recent(self, agent_id: str, limit: int = 20) -> list[Fact]:
402
+ validate_agent_id(agent_id)
403
+ limit = max(1, min(limit, 1000)) # H2 fix: cap limit
404
+ rows = self._conn.execute(
405
+ """SELECT * FROM facts
406
+ WHERE agent_id = ? AND tier = 'recall' AND superseded = 0
407
+ ORDER BY created_at DESC LIMIT ?""",
408
+ (agent_id, limit),
409
+ ).fetchall()
410
+ return [self._row_to_fact(r) for r in rows]
411
+
412
+ def recall_semantic(self, agent_id: str, query: str, k: int = 5) -> list[Fact]:
413
+ validate_agent_id(agent_id)
414
+ k = max(1, min(k, 100)) # H2 fix: cap limit
415
+ # Cap query length to prevent DoS
416
+ query = (query or "")[:1000]
417
+ tokens = [t.lower() for t in query.split() if len(t) > 2]
418
+ if not tokens:
419
+ return []
420
+ like_clauses = " OR ".join(["LOWER(value) LIKE ?" for _ in tokens])
421
+ params = [agent_id] + [f"%{t}%" for t in tokens] + [k]
422
+ rows = self._conn.execute(
423
+ f"""SELECT * FROM facts
424
+ WHERE agent_id = ? AND tier = 'archival' AND superseded = 0
425
+ AND ({like_clauses})
426
+ ORDER BY salience_score DESC, last_recalled_at DESC NULLS LAST
427
+ LIMIT ?""",
428
+ params,
429
+ ).fetchall()
430
+ return [self._row_to_fact(r) for r in rows]
431
+
432
+ def get_tagged(self, agent_id: str, older_than_hours: int = 0) -> list[Fact]:
433
+ validate_agent_id(agent_id)
434
+ if older_than_hours <= 0:
435
+ rows = self._conn.execute(
436
+ """SELECT * FROM facts
437
+ WHERE agent_id = ? AND tagged = 1 AND superseded = 0
438
+ ORDER BY created_at ASC""",
439
+ (agent_id,),
440
+ ).fetchall()
441
+ else:
442
+ cutoff = time.time() - (older_than_hours * 3600)
443
+ rows = self._conn.execute(
444
+ """SELECT * FROM facts
445
+ WHERE agent_id = ? AND tagged = 1 AND superseded = 0
446
+ AND (consolidates_at IS NULL OR consolidates_at <= ?)
447
+ ORDER BY created_at ASC""",
448
+ (agent_id, cutoff),
449
+ ).fetchall()
450
+ return [self._row_to_fact(r) for r in rows]
451
+
452
+ def get_stale(
453
+ self, agent_id: str, importance: str, days_since_recall: int
454
+ ) -> list[Fact]:
455
+ validate_agent_id(agent_id)
456
+ cutoff = time.time() - (days_since_recall * 86400)
457
+ rows = self._conn.execute(
458
+ """SELECT * FROM facts
459
+ WHERE agent_id = ? AND importance = ? AND superseded = 0
460
+ AND (last_recalled_at IS NULL OR last_recalled_at < ?)
461
+ ORDER BY COALESCE(last_recalled_at, created_at) ASC""",
462
+ (agent_id, importance, cutoff),
463
+ ).fetchall()
464
+ return [self._row_to_fact(r) for r in rows]
465
+
466
+ def all_facts(self, agent_id: str, include_superseded: bool = False) -> list[Fact]:
467
+ validate_agent_id(agent_id)
468
+ sql = "SELECT * FROM facts WHERE agent_id = ?"
469
+ if not include_superseded:
470
+ sql += " AND superseded = 0"
471
+ sql += " ORDER BY created_at ASC"
472
+ rows = self._conn.execute(sql, (agent_id,)).fetchall()
473
+ return [self._row_to_fact(r) for r in rows]
474
+
475
+ def _active_facts(self, agent_id: str, tier: str) -> list[Fact]:
476
+ rows = self._conn.execute(
477
+ """SELECT * FROM facts
478
+ WHERE agent_id = ? AND tier = ? AND superseded = 0
479
+ ORDER BY updated_at DESC""",
480
+ (agent_id, tier),
481
+ ).fetchall()
482
+ return [self._row_to_fact(r) for r in rows]
483
+
484
+ @staticmethod
485
+ def _row_to_fact(row: sqlite3.Row) -> Fact:
486
+ return Fact(
487
+ id=row["id"], agent_id=row["agent_id"], tier=row["tier"], key=row["key"],
488
+ value=row["value"], importance=row["importance"],
489
+ salience_score=row["salience_score"], tagged=bool(row["tagged"]),
490
+ ttl_seconds=row["ttl_seconds"], created_at=row["created_at"],
491
+ updated_at=row["updated_at"],
492
+ last_recalled_at=row["last_recalled_at"],
493
+ recall_count=row["recall_count"], superseded=bool(row["superseded"]),
494
+ metadata=json.loads(row["metadata"]),
495
+ )