agent-memory-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_memory/__init__.py +33 -0
- agent_memory/cli.py +142 -0
- agent_memory/client.py +355 -0
- agent_memory/config.py +28 -0
- agent_memory/controller/__init__.py +15 -0
- agent_memory/controller/conflict.py +95 -0
- agent_memory/controller/consolidation.py +136 -0
- agent_memory/controller/forgetting.py +29 -0
- agent_memory/controller/router.py +62 -0
- agent_memory/controller/trust.py +31 -0
- agent_memory/embedding/__init__.py +5 -0
- agent_memory/embedding/base.py +11 -0
- agent_memory/embedding/local_provider.py +38 -0
- agent_memory/embedding/openai_provider.py +11 -0
- agent_memory/extraction/__init__.py +5 -0
- agent_memory/extraction/entity_extractor.py +13 -0
- agent_memory/extraction/pipeline.py +123 -0
- agent_memory/extraction/prompts.py +40 -0
- agent_memory/governance/__init__.py +6 -0
- agent_memory/governance/audit.py +14 -0
- agent_memory/governance/export.py +72 -0
- agent_memory/governance/health.py +40 -0
- agent_memory/interfaces/__init__.py +14 -0
- agent_memory/interfaces/mcp_server.py +128 -0
- agent_memory/interfaces/rest_api.py +71 -0
- agent_memory/llm/__init__.py +5 -0
- agent_memory/llm/base.py +23 -0
- agent_memory/llm/ollama_client.py +64 -0
- agent_memory/llm/openai_client.py +94 -0
- agent_memory/models.py +149 -0
- agent_memory/storage/__init__.py +4 -0
- agent_memory/storage/base.py +59 -0
- agent_memory/storage/schema.sql +125 -0
- agent_memory/storage/sqlite_backend.py +762 -0
- agent_memory_engine-0.1.0.dist-info/METADATA +228 -0
- agent_memory_engine-0.1.0.dist-info/RECORD +39 -0
- agent_memory_engine-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_engine-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_engine-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
import importlib
|
|
5
|
+
import json
|
|
6
|
+
from math import sqrt
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agent_memory.models import MemoryItem, MemoryLayer, MemoryType, RelationEdge, RelationType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _utcnow_iso() -> str:
|
|
16
|
+
return datetime.now(timezone.utc).isoformat()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _serialize_datetime(value: datetime | None) -> str | None:
|
|
20
|
+
return value.isoformat() if value else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _deserialize_datetime(value: str | None) -> datetime | None:
|
|
24
|
+
return datetime.fromisoformat(value) if value else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cosine_similarity(left: list[float], right: list[float]) -> float:
|
|
28
|
+
if not left or not right:
|
|
29
|
+
return 0.0
|
|
30
|
+
size = min(len(left), len(right))
|
|
31
|
+
left_trimmed = left[:size]
|
|
32
|
+
right_trimmed = right[:size]
|
|
33
|
+
numerator = sum(a * b for a, b in zip(left_trimmed, right_trimmed, strict=False))
|
|
34
|
+
left_norm = sqrt(sum(a * a for a in left_trimmed))
|
|
35
|
+
right_norm = sqrt(sum(b * b for b in right_trimmed))
|
|
36
|
+
if left_norm == 0 or right_norm == 0:
|
|
37
|
+
return 0.0
|
|
38
|
+
return numerator / (left_norm * right_norm)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalize_embedding(values: list[float]) -> list[float]:
|
|
42
|
+
return [float(value) for value in values]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_fts_query(query: str) -> str:
|
|
46
|
+
terms = re.findall(r"[\w\u4e00-\u9fff-]+", query.lower())
|
|
47
|
+
if not terms:
|
|
48
|
+
return ""
|
|
49
|
+
return " OR ".join(f'"{term}"' for term in terms)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SQLiteBackend:
|
|
53
|
+
def __init__(self, database_path: str = ":memory:", prefer_sqlite_vec: bool = True) -> None:
|
|
54
|
+
self.database_path = database_path
|
|
55
|
+
self.prefer_sqlite_vec = prefer_sqlite_vec
|
|
56
|
+
self.connection = sqlite3.connect(database_path, check_same_thread=False)
|
|
57
|
+
self.connection.row_factory = sqlite3.Row
|
|
58
|
+
self.connection.execute("PRAGMA foreign_keys = ON")
|
|
59
|
+
if database_path != ":memory:":
|
|
60
|
+
self.connection.execute("PRAGMA journal_mode = WAL")
|
|
61
|
+
self._sqlite_vec = None
|
|
62
|
+
self._sqlite_vec_enabled = False
|
|
63
|
+
self._bootstrap()
|
|
64
|
+
if self.prefer_sqlite_vec:
|
|
65
|
+
self._try_enable_sqlite_vec()
|
|
66
|
+
|
|
67
|
+
def _bootstrap(self) -> None:
|
|
68
|
+
schema_path = Path(__file__).with_name("schema.sql")
|
|
69
|
+
self.connection.executescript(schema_path.read_text())
|
|
70
|
+
self.connection.commit()
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self.connection.close()
|
|
74
|
+
|
|
75
|
+
def add_memory(self, item: MemoryItem) -> MemoryItem:
|
|
76
|
+
payload = self._memory_to_row(item)
|
|
77
|
+
cursor = self.connection.execute(
|
|
78
|
+
"""
|
|
79
|
+
INSERT INTO memories (
|
|
80
|
+
id, content, memory_type, created_at, last_accessed, access_count,
|
|
81
|
+
valid_from, valid_until, trust_score, importance, layer, decay_rate,
|
|
82
|
+
source_id, causal_parent_id, supersedes_id, entity_refs_json, tags_json, deleted_at
|
|
83
|
+
) VALUES (
|
|
84
|
+
:id, :content, :memory_type, :created_at, :last_accessed, :access_count,
|
|
85
|
+
:valid_from, :valid_until, :trust_score, :importance, :layer, :decay_rate,
|
|
86
|
+
:source_id, :causal_parent_id, :supersedes_id, :entity_refs_json, :tags_json, :deleted_at
|
|
87
|
+
)
|
|
88
|
+
""",
|
|
89
|
+
payload,
|
|
90
|
+
)
|
|
91
|
+
memory_rowid = int(cursor.lastrowid)
|
|
92
|
+
self.connection.execute(
|
|
93
|
+
"INSERT INTO memory_vectors (memory_id, memory_rowid, embedding_json) VALUES (?, ?, ?)",
|
|
94
|
+
(item.id, memory_rowid, json.dumps(_normalize_embedding(item.embedding))),
|
|
95
|
+
)
|
|
96
|
+
self.connection.executemany(
|
|
97
|
+
"INSERT OR IGNORE INTO entity_index (entity, memory_id) VALUES (?, ?)",
|
|
98
|
+
[(entity.lower(), item.id) for entity in item.entity_refs],
|
|
99
|
+
)
|
|
100
|
+
self._upsert_vec_index_row(item=item, memory_rowid=memory_rowid)
|
|
101
|
+
self._append_evolution(item.id, "created", {"source_id": item.source_id})
|
|
102
|
+
self._append_audit("system", "create", "memory", item.id, {"source_id": item.source_id})
|
|
103
|
+
self.connection.commit()
|
|
104
|
+
return item
|
|
105
|
+
|
|
106
|
+
def get_memory(self, memory_id: str) -> MemoryItem | None:
|
|
107
|
+
row = self.connection.execute(
|
|
108
|
+
"""
|
|
109
|
+
SELECT m.*, v.embedding_json
|
|
110
|
+
FROM memories m
|
|
111
|
+
LEFT JOIN memory_vectors v ON v.memory_id = m.id
|
|
112
|
+
WHERE m.id = ?
|
|
113
|
+
""",
|
|
114
|
+
(memory_id,),
|
|
115
|
+
).fetchone()
|
|
116
|
+
if row is None:
|
|
117
|
+
return None
|
|
118
|
+
return self._row_to_memory(row)
|
|
119
|
+
|
|
120
|
+
def update_memory(self, item: MemoryItem) -> MemoryItem:
|
|
121
|
+
payload = self._memory_to_row(item)
|
|
122
|
+
self.connection.execute(
|
|
123
|
+
"""
|
|
124
|
+
UPDATE memories SET
|
|
125
|
+
content = :content,
|
|
126
|
+
memory_type = :memory_type,
|
|
127
|
+
created_at = :created_at,
|
|
128
|
+
last_accessed = :last_accessed,
|
|
129
|
+
access_count = :access_count,
|
|
130
|
+
valid_from = :valid_from,
|
|
131
|
+
valid_until = :valid_until,
|
|
132
|
+
trust_score = :trust_score,
|
|
133
|
+
importance = :importance,
|
|
134
|
+
layer = :layer,
|
|
135
|
+
decay_rate = :decay_rate,
|
|
136
|
+
source_id = :source_id,
|
|
137
|
+
causal_parent_id = :causal_parent_id,
|
|
138
|
+
supersedes_id = :supersedes_id,
|
|
139
|
+
entity_refs_json = :entity_refs_json,
|
|
140
|
+
tags_json = :tags_json,
|
|
141
|
+
deleted_at = :deleted_at
|
|
142
|
+
WHERE id = :id
|
|
143
|
+
""",
|
|
144
|
+
payload,
|
|
145
|
+
)
|
|
146
|
+
self.connection.execute(
|
|
147
|
+
"UPDATE memory_vectors SET embedding_json = ? WHERE memory_id = ?",
|
|
148
|
+
(json.dumps(_normalize_embedding(item.embedding)), item.id),
|
|
149
|
+
)
|
|
150
|
+
self.connection.execute("DELETE FROM entity_index WHERE memory_id = ?", (item.id,))
|
|
151
|
+
self.connection.executemany(
|
|
152
|
+
"INSERT OR IGNORE INTO entity_index (entity, memory_id) VALUES (?, ?)",
|
|
153
|
+
[(entity.lower(), item.id) for entity in item.entity_refs],
|
|
154
|
+
)
|
|
155
|
+
row = self.connection.execute(
|
|
156
|
+
"SELECT memory_rowid FROM memory_vectors WHERE memory_id = ?",
|
|
157
|
+
(item.id,),
|
|
158
|
+
).fetchone()
|
|
159
|
+
if row is not None:
|
|
160
|
+
self._delete_vec_index_row(int(row["memory_rowid"]))
|
|
161
|
+
self._upsert_vec_index_row(item=item, memory_rowid=int(row["memory_rowid"]))
|
|
162
|
+
self._append_evolution(item.id, "updated", {"source_id": item.source_id})
|
|
163
|
+
self._append_audit("system", "update", "memory", item.id, {"source_id": item.source_id})
|
|
164
|
+
self.connection.commit()
|
|
165
|
+
return item
|
|
166
|
+
|
|
167
|
+
def soft_delete_memory(self, memory_id: str) -> bool:
|
|
168
|
+
deleted_at = _utcnow_iso()
|
|
169
|
+
cursor = self.connection.execute(
|
|
170
|
+
"UPDATE memories SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
|
|
171
|
+
(deleted_at, memory_id),
|
|
172
|
+
)
|
|
173
|
+
if cursor.rowcount == 0:
|
|
174
|
+
return False
|
|
175
|
+
row = self.connection.execute(
|
|
176
|
+
"SELECT memory_rowid FROM memory_vectors WHERE memory_id = ?",
|
|
177
|
+
(memory_id,),
|
|
178
|
+
).fetchone()
|
|
179
|
+
if row is not None:
|
|
180
|
+
self._delete_vec_index_row(int(row["memory_rowid"]))
|
|
181
|
+
self._append_evolution(memory_id, "deleted", {"deleted_at": deleted_at})
|
|
182
|
+
self._append_audit("system", "delete", "memory", memory_id, {"deleted_at": deleted_at})
|
|
183
|
+
self.connection.commit()
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def touch_memory(self, memory_id: str) -> None:
|
|
187
|
+
accessed_at = _utcnow_iso()
|
|
188
|
+
self.connection.execute(
|
|
189
|
+
"""
|
|
190
|
+
UPDATE memories
|
|
191
|
+
SET access_count = access_count + 1,
|
|
192
|
+
last_accessed = ?
|
|
193
|
+
WHERE id = ? AND deleted_at IS NULL
|
|
194
|
+
""",
|
|
195
|
+
(accessed_at, memory_id),
|
|
196
|
+
)
|
|
197
|
+
self.connection.commit()
|
|
198
|
+
|
|
199
|
+
def search_full_text(self, query: str, limit: int = 10, memory_type: str | None = None) -> list[tuple[MemoryItem, float]]:
|
|
200
|
+
fts_query = _build_fts_query(query)
|
|
201
|
+
if not fts_query:
|
|
202
|
+
return []
|
|
203
|
+
params: list[object] = [fts_query]
|
|
204
|
+
memory_type_clause = ""
|
|
205
|
+
if memory_type:
|
|
206
|
+
memory_type_clause = "AND m.memory_type = ?"
|
|
207
|
+
params.append(memory_type)
|
|
208
|
+
params.append(limit)
|
|
209
|
+
rows = self.connection.execute(
|
|
210
|
+
f"""
|
|
211
|
+
SELECT m.*, v.embedding_json, bm25(memories_fts) AS rank_score
|
|
212
|
+
FROM memories_fts
|
|
213
|
+
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
214
|
+
LEFT JOIN memory_vectors v ON v.memory_id = m.id
|
|
215
|
+
WHERE memories_fts MATCH ?
|
|
216
|
+
AND m.deleted_at IS NULL
|
|
217
|
+
{memory_type_clause}
|
|
218
|
+
ORDER BY rank_score
|
|
219
|
+
LIMIT ?
|
|
220
|
+
""",
|
|
221
|
+
params,
|
|
222
|
+
).fetchall()
|
|
223
|
+
return [(self._row_to_memory(row), 1.0 / (1.0 + abs(row["rank_score"]))) for row in rows]
|
|
224
|
+
|
|
225
|
+
def search_by_entities(self, entities: list[str], limit: int = 10, memory_type: str | None = None) -> list[tuple[MemoryItem, float]]:
|
|
226
|
+
if not entities:
|
|
227
|
+
return []
|
|
228
|
+
placeholders = ", ".join("?" for _ in entities)
|
|
229
|
+
params: list[object] = [entity.lower() for entity in entities]
|
|
230
|
+
memory_type_clause = ""
|
|
231
|
+
if memory_type:
|
|
232
|
+
memory_type_clause = "AND m.memory_type = ?"
|
|
233
|
+
params.append(memory_type)
|
|
234
|
+
params.append(limit)
|
|
235
|
+
rows = self.connection.execute(
|
|
236
|
+
f"""
|
|
237
|
+
SELECT m.*, v.embedding_json, COUNT(*) AS entity_hits
|
|
238
|
+
FROM entity_index e
|
|
239
|
+
JOIN memories m ON m.id = e.memory_id
|
|
240
|
+
LEFT JOIN memory_vectors v ON v.memory_id = m.id
|
|
241
|
+
WHERE e.entity IN ({placeholders})
|
|
242
|
+
AND m.deleted_at IS NULL
|
|
243
|
+
{memory_type_clause}
|
|
244
|
+
GROUP BY m.id
|
|
245
|
+
ORDER BY entity_hits DESC, m.created_at DESC
|
|
246
|
+
LIMIT ?
|
|
247
|
+
""",
|
|
248
|
+
params,
|
|
249
|
+
).fetchall()
|
|
250
|
+
return [(self._row_to_memory(row), float(row["entity_hits"])) for row in rows]
|
|
251
|
+
|
|
252
|
+
def search_by_vector(
|
|
253
|
+
self,
|
|
254
|
+
embedding: list[float],
|
|
255
|
+
limit: int = 10,
|
|
256
|
+
memory_type: str | None = None,
|
|
257
|
+
) -> list[tuple[MemoryItem, float]]:
|
|
258
|
+
if self._sqlite_vec_enabled:
|
|
259
|
+
results = self._search_by_vector_sqlite_vec(embedding=embedding, limit=limit, memory_type=memory_type)
|
|
260
|
+
if results:
|
|
261
|
+
return results
|
|
262
|
+
return self._search_by_vector_fallback(embedding=embedding, limit=limit, memory_type=memory_type)
|
|
263
|
+
|
|
264
|
+
def _search_by_vector_fallback(
|
|
265
|
+
self,
|
|
266
|
+
embedding: list[float],
|
|
267
|
+
limit: int = 10,
|
|
268
|
+
memory_type: str | None = None,
|
|
269
|
+
) -> list[tuple[MemoryItem, float]]:
|
|
270
|
+
params: list[object] = []
|
|
271
|
+
memory_type_clause = ""
|
|
272
|
+
if memory_type:
|
|
273
|
+
memory_type_clause = "AND m.memory_type = ?"
|
|
274
|
+
params.append(memory_type)
|
|
275
|
+
rows = self.connection.execute(
|
|
276
|
+
f"""
|
|
277
|
+
SELECT m.*, v.embedding_json
|
|
278
|
+
FROM memories m
|
|
279
|
+
JOIN memory_vectors v ON v.memory_id = m.id
|
|
280
|
+
WHERE m.deleted_at IS NULL
|
|
281
|
+
{memory_type_clause}
|
|
282
|
+
""",
|
|
283
|
+
params,
|
|
284
|
+
).fetchall()
|
|
285
|
+
scored = []
|
|
286
|
+
for row in rows:
|
|
287
|
+
stored_embedding = json.loads(row["embedding_json"])
|
|
288
|
+
score = _cosine_similarity(embedding, stored_embedding)
|
|
289
|
+
scored.append((self._row_to_memory(row), score))
|
|
290
|
+
scored.sort(key=lambda item: item[1], reverse=True)
|
|
291
|
+
return scored[:limit]
|
|
292
|
+
|
|
293
|
+
def _search_by_vector_sqlite_vec(
|
|
294
|
+
self,
|
|
295
|
+
embedding: list[float],
|
|
296
|
+
limit: int = 10,
|
|
297
|
+
memory_type: str | None = None,
|
|
298
|
+
) -> list[tuple[MemoryItem, float]]:
|
|
299
|
+
serialized = self._serialize_embedding(embedding)
|
|
300
|
+
if serialized is None:
|
|
301
|
+
return []
|
|
302
|
+
params: list[Any] = [serialized, limit]
|
|
303
|
+
memory_type_clause = ""
|
|
304
|
+
if memory_type:
|
|
305
|
+
memory_type_clause = "AND v.memory_type = ?"
|
|
306
|
+
params.append(memory_type)
|
|
307
|
+
rows = self.connection.execute(
|
|
308
|
+
f"""
|
|
309
|
+
SELECT
|
|
310
|
+
m.*,
|
|
311
|
+
mv.embedding_json,
|
|
312
|
+
v.distance AS vec_distance
|
|
313
|
+
FROM memory_vec_index v
|
|
314
|
+
JOIN memories m ON m.rowid = v.memory_rowid
|
|
315
|
+
JOIN memory_vectors mv ON mv.memory_id = m.id
|
|
316
|
+
WHERE v.embedding MATCH ?
|
|
317
|
+
AND k = ?
|
|
318
|
+
{memory_type_clause}
|
|
319
|
+
AND m.deleted_at IS NULL
|
|
320
|
+
ORDER BY v.distance ASC
|
|
321
|
+
""",
|
|
322
|
+
params,
|
|
323
|
+
).fetchall()
|
|
324
|
+
return [(self._row_to_memory(row), 1.0 / (1.0 + float(row["vec_distance"]))) for row in rows]
|
|
325
|
+
|
|
326
|
+
def trace_ancestors(self, memory_id: str, max_depth: int = 10) -> list[MemoryItem]:
|
|
327
|
+
rows = self.connection.execute(
|
|
328
|
+
"""
|
|
329
|
+
WITH RECURSIVE ancestors(id, depth) AS (
|
|
330
|
+
SELECT causal_parent_id, 1
|
|
331
|
+
FROM memories
|
|
332
|
+
WHERE id = ? AND causal_parent_id IS NOT NULL
|
|
333
|
+
UNION ALL
|
|
334
|
+
SELECT m.causal_parent_id, a.depth + 1
|
|
335
|
+
FROM ancestors a
|
|
336
|
+
JOIN memories m ON m.id = a.id
|
|
337
|
+
WHERE a.depth < ? AND m.causal_parent_id IS NOT NULL
|
|
338
|
+
)
|
|
339
|
+
SELECT m.*, v.embedding_json, a.depth
|
|
340
|
+
FROM ancestors a
|
|
341
|
+
JOIN memories m ON m.id = a.id
|
|
342
|
+
LEFT JOIN memory_vectors v ON v.memory_id = m.id
|
|
343
|
+
WHERE m.deleted_at IS NULL
|
|
344
|
+
ORDER BY a.depth ASC
|
|
345
|
+
""",
|
|
346
|
+
(memory_id, max_depth),
|
|
347
|
+
).fetchall()
|
|
348
|
+
return [self._row_to_memory(row) for row in rows]
|
|
349
|
+
|
|
350
|
+
def list_memories(self, include_deleted: bool = False) -> list[MemoryItem]:
|
|
351
|
+
deleted_clause = "" if include_deleted else "WHERE m.deleted_at IS NULL"
|
|
352
|
+
rows = self.connection.execute(
|
|
353
|
+
f"""
|
|
354
|
+
SELECT m.*, v.embedding_json
|
|
355
|
+
FROM memories m
|
|
356
|
+
LEFT JOIN memory_vectors v ON v.memory_id = m.id
|
|
357
|
+
{deleted_clause}
|
|
358
|
+
ORDER BY m.created_at DESC
|
|
359
|
+
"""
|
|
360
|
+
).fetchall()
|
|
361
|
+
return [self._row_to_memory(row) for row in rows]
|
|
362
|
+
|
|
363
|
+
def add_relation(self, edge: RelationEdge) -> bool:
|
|
364
|
+
cursor = self.connection.execute(
|
|
365
|
+
"""
|
|
366
|
+
INSERT OR IGNORE INTO relations (source_id, target_id, relation_type, created_at)
|
|
367
|
+
VALUES (?, ?, ?, ?)
|
|
368
|
+
""",
|
|
369
|
+
(
|
|
370
|
+
edge.source_id,
|
|
371
|
+
edge.target_id,
|
|
372
|
+
edge.relation_type.value,
|
|
373
|
+
_serialize_datetime(edge.created_at),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
self.connection.commit()
|
|
377
|
+
return cursor.rowcount > 0
|
|
378
|
+
|
|
379
|
+
def list_relations(self, memory_id: str | None = None) -> list[RelationEdge]:
|
|
380
|
+
if memory_id is None:
|
|
381
|
+
rows = self.connection.execute(
|
|
382
|
+
"SELECT source_id, target_id, relation_type, created_at FROM relations ORDER BY created_at DESC"
|
|
383
|
+
).fetchall()
|
|
384
|
+
else:
|
|
385
|
+
rows = self.connection.execute(
|
|
386
|
+
"""
|
|
387
|
+
SELECT source_id, target_id, relation_type, created_at
|
|
388
|
+
FROM relations
|
|
389
|
+
WHERE source_id = ? OR target_id = ?
|
|
390
|
+
ORDER BY created_at DESC
|
|
391
|
+
""",
|
|
392
|
+
(memory_id, memory_id),
|
|
393
|
+
).fetchall()
|
|
394
|
+
return [
|
|
395
|
+
RelationEdge(
|
|
396
|
+
source_id=row["source_id"],
|
|
397
|
+
target_id=row["target_id"],
|
|
398
|
+
relation_type=RelationType(row["relation_type"]),
|
|
399
|
+
created_at=_deserialize_datetime(row["created_at"]) or datetime.now(timezone.utc),
|
|
400
|
+
)
|
|
401
|
+
for row in rows
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
def trace_descendants(self, memory_id: str, max_depth: int = 10) -> list[MemoryItem]:
|
|
405
|
+
rows = self.connection.execute(
|
|
406
|
+
"""
|
|
407
|
+
WITH RECURSIVE descendants(id, depth) AS (
|
|
408
|
+
SELECT id, 1
|
|
409
|
+
FROM memories
|
|
410
|
+
WHERE causal_parent_id = ?
|
|
411
|
+
UNION ALL
|
|
412
|
+
SELECT m.id, d.depth + 1
|
|
413
|
+
FROM descendants d
|
|
414
|
+
JOIN memories m ON m.causal_parent_id = d.id
|
|
415
|
+
WHERE d.depth < ?
|
|
416
|
+
)
|
|
417
|
+
SELECT m.*, v.embedding_json, d.depth
|
|
418
|
+
FROM descendants d
|
|
419
|
+
JOIN memories m ON m.id = d.id
|
|
420
|
+
LEFT JOIN memory_vectors v ON v.memory_id = m.id
|
|
421
|
+
WHERE m.deleted_at IS NULL
|
|
422
|
+
ORDER BY d.depth ASC, m.created_at ASC
|
|
423
|
+
""",
|
|
424
|
+
(memory_id, max_depth),
|
|
425
|
+
).fetchall()
|
|
426
|
+
return [self._row_to_memory(row) for row in rows]
|
|
427
|
+
|
|
428
|
+
def relation_exists_between(
|
|
429
|
+
self,
|
|
430
|
+
left_id: str,
|
|
431
|
+
right_id: str,
|
|
432
|
+
relation_types: list[str] | None = None,
|
|
433
|
+
) -> bool:
|
|
434
|
+
if relation_types:
|
|
435
|
+
placeholders = ", ".join("?" for _ in relation_types)
|
|
436
|
+
row = self.connection.execute(
|
|
437
|
+
f"""
|
|
438
|
+
SELECT 1
|
|
439
|
+
FROM relations
|
|
440
|
+
WHERE ((source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?))
|
|
441
|
+
AND relation_type IN ({placeholders})
|
|
442
|
+
LIMIT 1
|
|
443
|
+
""",
|
|
444
|
+
(left_id, right_id, right_id, left_id, *relation_types),
|
|
445
|
+
).fetchone()
|
|
446
|
+
else:
|
|
447
|
+
row = self.connection.execute(
|
|
448
|
+
"""
|
|
449
|
+
SELECT 1
|
|
450
|
+
FROM relations
|
|
451
|
+
WHERE ((source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?))
|
|
452
|
+
LIMIT 1
|
|
453
|
+
""",
|
|
454
|
+
(left_id, right_id, right_id, left_id),
|
|
455
|
+
).fetchone()
|
|
456
|
+
return row is not None
|
|
457
|
+
|
|
458
|
+
def get_evolution_events(self, memory_id: str | None = None, limit: int = 100) -> list[dict[str, object]]:
|
|
459
|
+
if memory_id is None:
|
|
460
|
+
rows = self.connection.execute(
|
|
461
|
+
"""
|
|
462
|
+
SELECT memory_id, event_type, payload_json, created_at
|
|
463
|
+
FROM evolution_log
|
|
464
|
+
ORDER BY created_at DESC
|
|
465
|
+
LIMIT ?
|
|
466
|
+
""",
|
|
467
|
+
(limit,),
|
|
468
|
+
).fetchall()
|
|
469
|
+
else:
|
|
470
|
+
rows = self.connection.execute(
|
|
471
|
+
"""
|
|
472
|
+
SELECT memory_id, event_type, payload_json, created_at
|
|
473
|
+
FROM evolution_log
|
|
474
|
+
WHERE memory_id = ?
|
|
475
|
+
ORDER BY created_at DESC
|
|
476
|
+
LIMIT ?
|
|
477
|
+
""",
|
|
478
|
+
(memory_id, limit),
|
|
479
|
+
).fetchall()
|
|
480
|
+
return [
|
|
481
|
+
{
|
|
482
|
+
"memory_id": row["memory_id"],
|
|
483
|
+
"event_type": row["event_type"],
|
|
484
|
+
"payload": json.loads(row["payload_json"]),
|
|
485
|
+
"created_at": row["created_at"],
|
|
486
|
+
}
|
|
487
|
+
for row in rows
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
def get_audit_events(self, limit: int = 100) -> list[dict[str, object]]:
|
|
491
|
+
rows = self.connection.execute(
|
|
492
|
+
"""
|
|
493
|
+
SELECT actor, operation, target_type, target_id, payload_json, created_at
|
|
494
|
+
FROM audit_log
|
|
495
|
+
ORDER BY created_at DESC
|
|
496
|
+
LIMIT ?
|
|
497
|
+
""",
|
|
498
|
+
(limit,),
|
|
499
|
+
).fetchall()
|
|
500
|
+
return [
|
|
501
|
+
{
|
|
502
|
+
"actor": row["actor"],
|
|
503
|
+
"operation": row["operation"],
|
|
504
|
+
"target_type": row["target_type"],
|
|
505
|
+
"target_id": row["target_id"],
|
|
506
|
+
"payload": json.loads(row["payload_json"]),
|
|
507
|
+
"created_at": row["created_at"],
|
|
508
|
+
}
|
|
509
|
+
for row in rows
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
def health_snapshot(self) -> dict[str, float | int]:
|
|
513
|
+
row = self.connection.execute(
|
|
514
|
+
"""
|
|
515
|
+
SELECT
|
|
516
|
+
COUNT(*) AS total_memories,
|
|
517
|
+
SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) AS active_memories,
|
|
518
|
+
AVG(CASE WHEN deleted_at IS NULL THEN trust_score END) AS average_trust_score,
|
|
519
|
+
SUM(
|
|
520
|
+
CASE
|
|
521
|
+
WHEN deleted_at IS NULL
|
|
522
|
+
AND julianday('now') - julianday(last_accessed) > 30
|
|
523
|
+
THEN 1 ELSE 0
|
|
524
|
+
END
|
|
525
|
+
) AS stale_memories
|
|
526
|
+
FROM memories
|
|
527
|
+
"""
|
|
528
|
+
).fetchone()
|
|
529
|
+
orphan_row = self.connection.execute(
|
|
530
|
+
"""
|
|
531
|
+
SELECT COUNT(*) AS orphan_memories
|
|
532
|
+
FROM memories m
|
|
533
|
+
WHERE m.deleted_at IS NULL
|
|
534
|
+
AND NOT EXISTS (
|
|
535
|
+
SELECT 1 FROM relations r
|
|
536
|
+
WHERE r.source_id = m.id OR r.target_id = m.id
|
|
537
|
+
)
|
|
538
|
+
AND m.access_count <= 1
|
|
539
|
+
"""
|
|
540
|
+
).fetchone()
|
|
541
|
+
conflict_row = self.connection.execute(
|
|
542
|
+
"""
|
|
543
|
+
SELECT COUNT(*) AS unresolved_conflicts
|
|
544
|
+
FROM relations
|
|
545
|
+
WHERE relation_type = 'contradicts'
|
|
546
|
+
"""
|
|
547
|
+
).fetchone()
|
|
548
|
+
audit_row = self.connection.execute("SELECT COUNT(*) AS audit_events FROM audit_log").fetchone()
|
|
549
|
+
total = int(row["active_memories"] or 0)
|
|
550
|
+
stale = int(row["stale_memories"] or 0)
|
|
551
|
+
orphan = int(orphan_row["orphan_memories"] or 0)
|
|
552
|
+
return {
|
|
553
|
+
"total_memories": total,
|
|
554
|
+
"stale_ratio": (stale / total) if total else 0.0,
|
|
555
|
+
"orphan_ratio": (orphan / total) if total else 0.0,
|
|
556
|
+
"unresolved_conflicts": int(conflict_row["unresolved_conflicts"] or 0),
|
|
557
|
+
"average_trust_score": float(row["average_trust_score"] or 0.0),
|
|
558
|
+
"audit_events": int(audit_row["audit_events"] or 0),
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
def _append_evolution(self, memory_id: str, event_type: str, payload: dict[str, object]) -> None:
|
|
562
|
+
self.connection.execute(
|
|
563
|
+
"INSERT INTO evolution_log (memory_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?)",
|
|
564
|
+
(memory_id, event_type, json.dumps(payload), _utcnow_iso()),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def _append_audit(
|
|
568
|
+
self,
|
|
569
|
+
actor: str,
|
|
570
|
+
operation: str,
|
|
571
|
+
target_type: str,
|
|
572
|
+
target_id: str,
|
|
573
|
+
payload: dict[str, object],
|
|
574
|
+
) -> None:
|
|
575
|
+
self.connection.execute(
|
|
576
|
+
"""
|
|
577
|
+
INSERT INTO audit_log (actor, operation, target_type, target_id, payload_json, created_at)
|
|
578
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
579
|
+
""",
|
|
580
|
+
(actor, operation, target_type, target_id, json.dumps(payload), _utcnow_iso()),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
def _memory_to_row(self, item: MemoryItem) -> dict[str, object]:
|
|
584
|
+
return {
|
|
585
|
+
"id": item.id,
|
|
586
|
+
"content": item.content,
|
|
587
|
+
"memory_type": item.memory_type.value,
|
|
588
|
+
"created_at": _serialize_datetime(item.created_at),
|
|
589
|
+
"last_accessed": _serialize_datetime(item.last_accessed),
|
|
590
|
+
"access_count": item.access_count,
|
|
591
|
+
"valid_from": _serialize_datetime(item.valid_from),
|
|
592
|
+
"valid_until": _serialize_datetime(item.valid_until),
|
|
593
|
+
"trust_score": item.trust_score,
|
|
594
|
+
"importance": item.importance,
|
|
595
|
+
"layer": item.layer.value,
|
|
596
|
+
"decay_rate": item.decay_rate,
|
|
597
|
+
"source_id": item.source_id,
|
|
598
|
+
"causal_parent_id": item.causal_parent_id,
|
|
599
|
+
"supersedes_id": item.supersedes_id,
|
|
600
|
+
"entity_refs_json": json.dumps(item.entity_refs),
|
|
601
|
+
"tags_json": json.dumps(item.tags),
|
|
602
|
+
"deleted_at": _serialize_datetime(item.deleted_at),
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
def _row_to_memory(self, row: sqlite3.Row) -> MemoryItem:
|
|
606
|
+
embedding_json = row["embedding_json"] if "embedding_json" in row.keys() else "[]"
|
|
607
|
+
return MemoryItem(
|
|
608
|
+
id=row["id"],
|
|
609
|
+
content=row["content"],
|
|
610
|
+
memory_type=MemoryType(row["memory_type"]),
|
|
611
|
+
embedding=json.loads(embedding_json or "[]"),
|
|
612
|
+
created_at=_deserialize_datetime(row["created_at"]) or datetime.now(timezone.utc),
|
|
613
|
+
last_accessed=_deserialize_datetime(row["last_accessed"]) or datetime.now(timezone.utc),
|
|
614
|
+
access_count=row["access_count"],
|
|
615
|
+
valid_from=_deserialize_datetime(row["valid_from"]),
|
|
616
|
+
valid_until=_deserialize_datetime(row["valid_until"]),
|
|
617
|
+
trust_score=row["trust_score"],
|
|
618
|
+
importance=row["importance"],
|
|
619
|
+
layer=MemoryLayer(row["layer"]),
|
|
620
|
+
decay_rate=row["decay_rate"],
|
|
621
|
+
source_id=row["source_id"],
|
|
622
|
+
causal_parent_id=row["causal_parent_id"],
|
|
623
|
+
supersedes_id=row["supersedes_id"],
|
|
624
|
+
entity_refs=json.loads(row["entity_refs_json"]),
|
|
625
|
+
tags=json.loads(row["tags_json"]),
|
|
626
|
+
deleted_at=_deserialize_datetime(row["deleted_at"]),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
def _try_enable_sqlite_vec(self) -> None:
|
|
630
|
+
try:
|
|
631
|
+
sqlite_vec = importlib.import_module("sqlite_vec")
|
|
632
|
+
except ImportError:
|
|
633
|
+
return
|
|
634
|
+
load = getattr(sqlite_vec, "load", None)
|
|
635
|
+
if load is None:
|
|
636
|
+
return
|
|
637
|
+
try:
|
|
638
|
+
load(self.connection)
|
|
639
|
+
except Exception:
|
|
640
|
+
return
|
|
641
|
+
self._sqlite_vec = sqlite_vec
|
|
642
|
+
self._sqlite_vec_enabled = True
|
|
643
|
+
dimension = self._get_backend_meta("sqlite_vec_dimension")
|
|
644
|
+
if dimension:
|
|
645
|
+
self._ensure_vec_index_table(int(dimension))
|
|
646
|
+
self._rebuild_vec_index_if_needed()
|
|
647
|
+
|
|
648
|
+
def _serialize_embedding(self, embedding: list[float]) -> bytes | None:
|
|
649
|
+
if not self._sqlite_vec_enabled or self._sqlite_vec is None:
|
|
650
|
+
return None
|
|
651
|
+
serializer = getattr(self._sqlite_vec, "serialize_float32", None)
|
|
652
|
+
if serializer is None:
|
|
653
|
+
return None
|
|
654
|
+
return serializer(_normalize_embedding(embedding))
|
|
655
|
+
|
|
656
|
+
def _ensure_vec_index_table(self, dimension: int) -> None:
|
|
657
|
+
current_dimension = self._get_backend_meta("sqlite_vec_dimension")
|
|
658
|
+
if current_dimension and int(current_dimension) != dimension:
|
|
659
|
+
raise ValueError(
|
|
660
|
+
f"sqlite-vec index dimension mismatch: existing={current_dimension}, requested={dimension}"
|
|
661
|
+
)
|
|
662
|
+
self.connection.execute(
|
|
663
|
+
f"""
|
|
664
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_vec_index
|
|
665
|
+
USING vec0(
|
|
666
|
+
memory_rowid INTEGER PRIMARY KEY,
|
|
667
|
+
embedding FLOAT[{dimension}],
|
|
668
|
+
memory_type TEXT,
|
|
669
|
+
layer TEXT,
|
|
670
|
+
source_id TEXT,
|
|
671
|
+
trust_score FLOAT,
|
|
672
|
+
created_at TEXT,
|
|
673
|
+
last_accessed TEXT
|
|
674
|
+
)
|
|
675
|
+
"""
|
|
676
|
+
)
|
|
677
|
+
self._set_backend_meta("sqlite_vec_dimension", str(dimension))
|
|
678
|
+
|
|
679
|
+
def _upsert_vec_index_row(self, item: MemoryItem, memory_rowid: int) -> None:
|
|
680
|
+
if not self._sqlite_vec_enabled:
|
|
681
|
+
return
|
|
682
|
+
serialized = self._serialize_embedding(item.embedding)
|
|
683
|
+
if serialized is None:
|
|
684
|
+
return
|
|
685
|
+
self._ensure_vec_index_table(len(item.embedding))
|
|
686
|
+
update_cursor = self.connection.execute(
|
|
687
|
+
"""
|
|
688
|
+
UPDATE memory_vec_index
|
|
689
|
+
SET embedding = ?, memory_type = ?, layer = ?, source_id = ?, trust_score = ?, created_at = ?, last_accessed = ?
|
|
690
|
+
WHERE memory_rowid = ?
|
|
691
|
+
""",
|
|
692
|
+
(
|
|
693
|
+
serialized,
|
|
694
|
+
item.memory_type.value,
|
|
695
|
+
item.layer.value,
|
|
696
|
+
item.source_id,
|
|
697
|
+
item.trust_score,
|
|
698
|
+
_serialize_datetime(item.created_at),
|
|
699
|
+
_serialize_datetime(item.last_accessed),
|
|
700
|
+
memory_rowid,
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
if update_cursor.rowcount > 0:
|
|
704
|
+
return
|
|
705
|
+
self.connection.execute(
|
|
706
|
+
"""
|
|
707
|
+
INSERT INTO memory_vec_index (
|
|
708
|
+
memory_rowid, embedding, memory_type, layer, source_id, trust_score, created_at, last_accessed
|
|
709
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
710
|
+
""",
|
|
711
|
+
(
|
|
712
|
+
memory_rowid,
|
|
713
|
+
serialized,
|
|
714
|
+
item.memory_type.value,
|
|
715
|
+
item.layer.value,
|
|
716
|
+
item.source_id,
|
|
717
|
+
item.trust_score,
|
|
718
|
+
_serialize_datetime(item.created_at),
|
|
719
|
+
_serialize_datetime(item.last_accessed),
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
def _delete_vec_index_row(self, memory_rowid: int) -> None:
|
|
724
|
+
if not self._sqlite_vec_enabled:
|
|
725
|
+
return
|
|
726
|
+
self.connection.execute("DELETE FROM memory_vec_index WHERE memory_rowid = ?", (memory_rowid,))
|
|
727
|
+
|
|
728
|
+
def _rebuild_vec_index_if_needed(self) -> None:
|
|
729
|
+
if not self._sqlite_vec_enabled:
|
|
730
|
+
return
|
|
731
|
+
count_row = self.connection.execute("SELECT COUNT(*) AS count FROM memory_vec_index").fetchone()
|
|
732
|
+
existing_count = int(count_row["count"] or 0)
|
|
733
|
+
memory_rows = self.connection.execute(
|
|
734
|
+
"""
|
|
735
|
+
SELECT
|
|
736
|
+
m.rowid AS memory_rowid,
|
|
737
|
+
m.*,
|
|
738
|
+
mv.embedding_json
|
|
739
|
+
FROM memories m
|
|
740
|
+
JOIN memory_vectors mv ON mv.memory_id = m.id
|
|
741
|
+
WHERE m.deleted_at IS NULL
|
|
742
|
+
"""
|
|
743
|
+
).fetchall()
|
|
744
|
+
if existing_count >= len(memory_rows):
|
|
745
|
+
return
|
|
746
|
+
self.connection.execute("DELETE FROM memory_vec_index")
|
|
747
|
+
for row in memory_rows:
|
|
748
|
+
item = self._row_to_memory(row)
|
|
749
|
+
self._upsert_vec_index_row(item=item, memory_rowid=int(row["memory_rowid"]))
|
|
750
|
+
self.connection.commit()
|
|
751
|
+
|
|
752
|
+
def _get_backend_meta(self, key: str) -> str | None:
|
|
753
|
+
row = self.connection.execute("SELECT value FROM backend_meta WHERE key = ?", (key,)).fetchone()
|
|
754
|
+
if row is None:
|
|
755
|
+
return None
|
|
756
|
+
return str(row["value"])
|
|
757
|
+
|
|
758
|
+
def _set_backend_meta(self, key: str, value: str) -> None:
|
|
759
|
+
self.connection.execute(
|
|
760
|
+
"INSERT OR REPLACE INTO backend_meta (key, value) VALUES (?, ?)",
|
|
761
|
+
(key, value),
|
|
762
|
+
)
|