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.
- plug_play_memory/__init__.py +23 -0
- plug_play_memory/audit.py +209 -0
- plug_play_memory/auth.py +155 -0
- plug_play_memory/cli.py +413 -0
- plug_play_memory/consolidation.py +172 -0
- plug_play_memory/emitter.py +337 -0
- plug_play_memory/events.py +36 -0
- plug_play_memory/judge.py +461 -0
- plug_play_memory/policies/builtin/_base.yaml +18 -0
- plug_play_memory/policies/builtin/coding.yaml +53 -0
- plug_play_memory/policies/builtin/financial.yaml +50 -0
- plug_play_memory/policies/builtin/study.yaml +50 -0
- plug_play_memory/policy.py +353 -0
- plug_play_memory/server.py +374 -0
- plug_play_memory/storage.py +495 -0
- plug_play_memory-0.2.0.dist-info/METADATA +487 -0
- plug_play_memory-0.2.0.dist-info/RECORD +21 -0
- plug_play_memory-0.2.0.dist-info/WHEEL +5 -0
- plug_play_memory-0.2.0.dist-info/entry_points.txt +2 -0
- plug_play_memory-0.2.0.dist-info/licenses/LICENSE +201 -0
- plug_play_memory-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|