memorytrace 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.
- engram/__init__.py +8 -0
- engram/__main__.py +6 -0
- engram/cli/__init__.py +1 -0
- engram/cli/app.py +291 -0
- engram/cli/formatters.py +90 -0
- engram/cli/simple.py +267 -0
- engram/config.py +72 -0
- engram/engine.py +612 -0
- engram/exceptions.py +41 -0
- engram/extraction/__init__.py +6 -0
- engram/extraction/base.py +20 -0
- engram/extraction/llm_extractor.py +197 -0
- engram/extraction/ner/__init__.py +7 -0
- engram/extraction/ner/cjk.py +63 -0
- engram/extraction/ner/english.py +109 -0
- engram/extraction/ner/korean.py +106 -0
- engram/extraction/regex_extractor.py +188 -0
- engram/integrations/__init__.py +1 -0
- engram/integrations/mcp_server.py +213 -0
- engram/integrations/sdk.py +194 -0
- engram/models/__init__.py +19 -0
- engram/models/entity.py +72 -0
- engram/models/fact.py +58 -0
- engram/models/quality.py +61 -0
- engram/models/relation.py +26 -0
- engram/models/search.py +96 -0
- engram/models/session.py +53 -0
- engram/models/source.py +73 -0
- engram/quality/__init__.py +8 -0
- engram/quality/confidence.py +38 -0
- engram/quality/conflict.py +79 -0
- engram/quality/decay.py +28 -0
- engram/quality/gate.py +120 -0
- engram/quality/pii.py +80 -0
- engram/search/__init__.py +13 -0
- engram/search/base.py +20 -0
- engram/search/fts5_search.py +210 -0
- engram/search/hybrid.py +99 -0
- engram/search/semantic.py +186 -0
- engram/search/tokenizer.py +85 -0
- engram/session/__init__.py +6 -0
- engram/session/context.py +87 -0
- engram/session/manager.py +152 -0
- engram/session/working_memory.py +57 -0
- engram/storage/__init__.py +6 -0
- engram/storage/base.py +63 -0
- engram/storage/markdown_export.py +144 -0
- engram/storage/migrations.py +30 -0
- engram/storage/sqlite_store.py +615 -0
- memorytrace-0.1.0.dist-info/METADATA +138 -0
- memorytrace-0.1.0.dist-info/RECORD +54 -0
- memorytrace-0.1.0.dist-info/WHEEL +4 -0
- memorytrace-0.1.0.dist-info/entry_points.txt +3 -0
- memorytrace-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""SQLite + FTS5 storage backend — the heart of Engram."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import threading
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from engram.exceptions import EntityAlreadyExistsError, EntityNotFoundError, StorageError
|
|
14
|
+
from engram.models.entity import Entity, EntityState, Tier
|
|
15
|
+
from engram.models.fact import Fact, FactStatus
|
|
16
|
+
from engram.models.relation import Relation
|
|
17
|
+
from engram.models.session import Session, SessionEvent
|
|
18
|
+
from engram.models.source import Source, SourceType
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "1"
|
|
21
|
+
|
|
22
|
+
_SCHEMA_SQL = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
24
|
+
key TEXT PRIMARY KEY,
|
|
25
|
+
value TEXT
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
name TEXT NOT NULL UNIQUE,
|
|
31
|
+
entity_type TEXT NOT NULL DEFAULT 'person',
|
|
32
|
+
tier TEXT NOT NULL DEFAULT 'recall' CHECK(tier IN ('core','recall','archival')),
|
|
33
|
+
summary TEXT DEFAULT '',
|
|
34
|
+
aliases TEXT DEFAULT '[]',
|
|
35
|
+
state TEXT DEFAULT '{}',
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
updated_at TEXT NOT NULL,
|
|
38
|
+
access_count INTEGER DEFAULT 0,
|
|
39
|
+
last_accessed TEXT
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
45
|
+
subject TEXT NOT NULL,
|
|
46
|
+
predicate TEXT NOT NULL,
|
|
47
|
+
object TEXT NOT NULL,
|
|
48
|
+
raw_text TEXT NOT NULL,
|
|
49
|
+
source_type TEXT NOT NULL,
|
|
50
|
+
source_author TEXT DEFAULT '',
|
|
51
|
+
source_channel TEXT DEFAULT '',
|
|
52
|
+
source_timestamp TEXT,
|
|
53
|
+
source_url TEXT,
|
|
54
|
+
source_session_id TEXT,
|
|
55
|
+
source_confidence REAL DEFAULT 1.0,
|
|
56
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
57
|
+
status TEXT NOT NULL DEFAULT 'unverified',
|
|
58
|
+
valid_from TEXT,
|
|
59
|
+
valid_to TEXT,
|
|
60
|
+
superseded_by TEXT,
|
|
61
|
+
created_at TEXT NOT NULL
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
from_entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
67
|
+
to_entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
68
|
+
relation_type TEXT NOT NULL,
|
|
69
|
+
metadata TEXT DEFAULT '{}',
|
|
70
|
+
valid_from TEXT,
|
|
71
|
+
valid_to TEXT,
|
|
72
|
+
created_at TEXT NOT NULL
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
76
|
+
session_id TEXT PRIMARY KEY,
|
|
77
|
+
agent_id TEXT NOT NULL,
|
|
78
|
+
started_at TEXT NOT NULL,
|
|
79
|
+
ended_at TEXT,
|
|
80
|
+
parent_session_id TEXT,
|
|
81
|
+
entities_accessed TEXT DEFAULT '[]',
|
|
82
|
+
entities_modified TEXT DEFAULT '[]',
|
|
83
|
+
facts_added TEXT DEFAULT '[]',
|
|
84
|
+
summary TEXT,
|
|
85
|
+
metadata TEXT DEFAULT '{}'
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
89
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
90
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
91
|
+
event_type TEXT NOT NULL,
|
|
92
|
+
target TEXT,
|
|
93
|
+
detail TEXT DEFAULT '{}',
|
|
94
|
+
timestamp TEXT NOT NULL
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
CREATE TABLE IF NOT EXISTS conflicts (
|
|
98
|
+
id TEXT PRIMARY KEY,
|
|
99
|
+
existing_fact_id TEXT,
|
|
100
|
+
new_fact_id TEXT,
|
|
101
|
+
conflict_type TEXT NOT NULL,
|
|
102
|
+
suggested_resolution TEXT,
|
|
103
|
+
resolved_at TEXT,
|
|
104
|
+
resolved_by TEXT,
|
|
105
|
+
resolution TEXT
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS idx_entities_tier ON entities(tier);
|
|
110
|
+
CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity_id);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_facts_status ON facts(status);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_facts_predicate ON facts(entity_id, predicate);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_entity_id);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_entity_id);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id, started_at);
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
_FTS_SQL = """
|
|
119
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
120
|
+
entity_id,
|
|
121
|
+
entity_name,
|
|
122
|
+
entity_type,
|
|
123
|
+
fact_text,
|
|
124
|
+
state_text,
|
|
125
|
+
summary,
|
|
126
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
127
|
+
);
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _dt_to_str(dt: Optional[datetime]) -> Optional[str]:
|
|
132
|
+
return dt.isoformat() if dt else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _str_to_dt(s: Optional[str]) -> Optional[datetime]:
|
|
136
|
+
return datetime.fromisoformat(s) if s else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SQLiteStorage:
|
|
140
|
+
"""SQLite + FTS5 storage backend with WAL mode and atomic writes."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, db_path: Path):
|
|
143
|
+
self.db_path = Path(db_path).resolve()
|
|
144
|
+
self._local = threading.local()
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def _conn(self) -> sqlite3.Connection:
|
|
148
|
+
if not hasattr(self._local, "conn") or self._local.conn is None:
|
|
149
|
+
self._local.conn = self._create_connection()
|
|
150
|
+
return self._local.conn
|
|
151
|
+
|
|
152
|
+
def _create_connection(self) -> sqlite3.Connection:
|
|
153
|
+
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
|
154
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
155
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
156
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
157
|
+
conn.row_factory = sqlite3.Row
|
|
158
|
+
return conn
|
|
159
|
+
|
|
160
|
+
def initialize(self) -> None:
|
|
161
|
+
"""Create tables and FTS index if they don't exist."""
|
|
162
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
is_new = not self.db_path.exists()
|
|
164
|
+
conn = self._conn
|
|
165
|
+
conn.executescript(_SCHEMA_SQL)
|
|
166
|
+
conn.executescript(_FTS_SQL)
|
|
167
|
+
# Set schema version if not exists
|
|
168
|
+
conn.execute(
|
|
169
|
+
"INSERT OR IGNORE INTO _meta (key, value) VALUES (?, ?)",
|
|
170
|
+
("schema_version", SCHEMA_VERSION),
|
|
171
|
+
)
|
|
172
|
+
conn.commit()
|
|
173
|
+
# Restrict DB file permissions to owner only (0o600)
|
|
174
|
+
if is_new and self.db_path.exists():
|
|
175
|
+
try:
|
|
176
|
+
os.chmod(self.db_path, 0o600)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass # Windows or restricted filesystem
|
|
179
|
+
|
|
180
|
+
def close(self) -> None:
|
|
181
|
+
if hasattr(self._local, "conn") and self._local.conn is not None:
|
|
182
|
+
self._local.conn.close()
|
|
183
|
+
self._local.conn = None
|
|
184
|
+
|
|
185
|
+
# ── Entity CRUD ──
|
|
186
|
+
|
|
187
|
+
def create_entity(self, entity: Entity) -> Entity:
|
|
188
|
+
now = datetime.now()
|
|
189
|
+
entity.created_at = now
|
|
190
|
+
entity.updated_at = now
|
|
191
|
+
try:
|
|
192
|
+
self._conn.execute(
|
|
193
|
+
"""INSERT INTO entities
|
|
194
|
+
(id, name, entity_type, tier, summary, aliases, state, created_at, updated_at, access_count, last_accessed)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
196
|
+
(
|
|
197
|
+
entity.id, entity.name, entity.entity_type, entity.tier.value,
|
|
198
|
+
entity.summary, json.dumps(entity.aliases, ensure_ascii=False),
|
|
199
|
+
json.dumps(entity.state.to_dict(), ensure_ascii=False),
|
|
200
|
+
_dt_to_str(entity.created_at), _dt_to_str(entity.updated_at),
|
|
201
|
+
entity.access_count, _dt_to_str(entity.last_accessed),
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
# Add to FTS index
|
|
205
|
+
self._index_entity(entity)
|
|
206
|
+
self._conn.commit()
|
|
207
|
+
except sqlite3.IntegrityError:
|
|
208
|
+
self._conn.rollback()
|
|
209
|
+
raise EntityAlreadyExistsError(f"Entity '{entity.name}' already exists")
|
|
210
|
+
except Exception:
|
|
211
|
+
self._conn.rollback()
|
|
212
|
+
raise
|
|
213
|
+
return entity
|
|
214
|
+
|
|
215
|
+
def get_entity(self, entity_id: str) -> Optional[Entity]:
|
|
216
|
+
row = self._conn.execute(
|
|
217
|
+
"SELECT * FROM entities WHERE id = ?", (entity_id,)
|
|
218
|
+
).fetchone()
|
|
219
|
+
return self._row_to_entity(row) if row else None
|
|
220
|
+
|
|
221
|
+
def get_entity_by_name(self, name: str) -> Optional[Entity]:
|
|
222
|
+
# Primary name lookup
|
|
223
|
+
row = self._conn.execute(
|
|
224
|
+
"SELECT * FROM entities WHERE name = ? COLLATE NOCASE", (name,)
|
|
225
|
+
).fetchone()
|
|
226
|
+
if row:
|
|
227
|
+
return self._row_to_entity(row)
|
|
228
|
+
# Alias lookup (search within JSON array)
|
|
229
|
+
row = self._conn.execute(
|
|
230
|
+
"SELECT * FROM entities WHERE aliases LIKE ? COLLATE NOCASE",
|
|
231
|
+
(f'%"{name}"%',),
|
|
232
|
+
).fetchone()
|
|
233
|
+
return self._row_to_entity(row) if row else None
|
|
234
|
+
|
|
235
|
+
def update_entity(self, entity: Entity) -> Entity:
|
|
236
|
+
entity.updated_at = datetime.now()
|
|
237
|
+
try:
|
|
238
|
+
self._conn.execute(
|
|
239
|
+
"""UPDATE entities SET
|
|
240
|
+
name=?, entity_type=?, tier=?, summary=?, aliases=?, state=?,
|
|
241
|
+
updated_at=?, access_count=?, last_accessed=?
|
|
242
|
+
WHERE id=?""",
|
|
243
|
+
(
|
|
244
|
+
entity.name, entity.entity_type, entity.tier.value,
|
|
245
|
+
entity.summary, json.dumps(entity.aliases, ensure_ascii=False),
|
|
246
|
+
json.dumps(entity.state.to_dict(), ensure_ascii=False),
|
|
247
|
+
_dt_to_str(entity.updated_at), entity.access_count,
|
|
248
|
+
_dt_to_str(entity.last_accessed), entity.id,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
self._reindex_entity(entity)
|
|
252
|
+
self._conn.commit()
|
|
253
|
+
except Exception:
|
|
254
|
+
self._conn.rollback()
|
|
255
|
+
raise
|
|
256
|
+
return entity
|
|
257
|
+
|
|
258
|
+
def delete_entity(self, entity_id: str) -> bool:
|
|
259
|
+
try:
|
|
260
|
+
cursor = self._conn.execute("DELETE FROM entities WHERE id = ?", (entity_id,))
|
|
261
|
+
self._conn.execute(
|
|
262
|
+
"DELETE FROM memory_fts WHERE entity_id = ?", (entity_id,)
|
|
263
|
+
)
|
|
264
|
+
self._conn.commit()
|
|
265
|
+
return cursor.rowcount > 0
|
|
266
|
+
except Exception:
|
|
267
|
+
self._conn.rollback()
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
def list_entities(
|
|
271
|
+
self,
|
|
272
|
+
tier: Optional[Tier] = None,
|
|
273
|
+
entity_type: Optional[str] = None,
|
|
274
|
+
limit: int = 100,
|
|
275
|
+
offset: int = 0,
|
|
276
|
+
) -> list[Entity]:
|
|
277
|
+
query = "SELECT * FROM entities WHERE 1=1"
|
|
278
|
+
params: list = []
|
|
279
|
+
if tier:
|
|
280
|
+
query += " AND tier = ?"
|
|
281
|
+
params.append(tier.value)
|
|
282
|
+
if entity_type:
|
|
283
|
+
query += " AND entity_type = ?"
|
|
284
|
+
params.append(entity_type)
|
|
285
|
+
query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
|
|
286
|
+
params.extend([limit, offset])
|
|
287
|
+
rows = self._conn.execute(query, params).fetchall()
|
|
288
|
+
return [self._row_to_entity(r) for r in rows]
|
|
289
|
+
|
|
290
|
+
# ── Fact CRUD ──
|
|
291
|
+
|
|
292
|
+
def add_fact(self, fact: Fact, reindex: bool = True) -> Fact:
|
|
293
|
+
fact.created_at = datetime.now()
|
|
294
|
+
try:
|
|
295
|
+
self._conn.execute(
|
|
296
|
+
"""INSERT INTO facts
|
|
297
|
+
(id, entity_id, subject, predicate, object, raw_text,
|
|
298
|
+
source_type, source_author, source_channel, source_timestamp,
|
|
299
|
+
source_url, source_session_id, source_confidence,
|
|
300
|
+
confidence, status,
|
|
301
|
+
valid_from, valid_to, superseded_by, created_at)
|
|
302
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
303
|
+
(
|
|
304
|
+
fact.id, fact.entity_id, fact.subject, fact.predicate, fact.object,
|
|
305
|
+
fact.raw_text, fact.source.type.value, fact.source.author,
|
|
306
|
+
fact.source.channel, _dt_to_str(fact.source.timestamp),
|
|
307
|
+
fact.source.url, fact.source.session_id,
|
|
308
|
+
fact.source.confidence, fact.confidence, fact.status.value,
|
|
309
|
+
_dt_to_str(fact.valid_from), _dt_to_str(fact.valid_to),
|
|
310
|
+
fact.superseded_by, _dt_to_str(fact.created_at),
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
except sqlite3.IntegrityError as e:
|
|
314
|
+
self._conn.rollback()
|
|
315
|
+
raise StorageError(f"Failed to add fact: {e}") from e
|
|
316
|
+
except Exception:
|
|
317
|
+
self._conn.rollback()
|
|
318
|
+
raise
|
|
319
|
+
# Update FTS index with new fact text (skippable for batch operations)
|
|
320
|
+
if reindex:
|
|
321
|
+
try:
|
|
322
|
+
entity = self.get_entity(fact.entity_id)
|
|
323
|
+
if entity:
|
|
324
|
+
self._reindex_entity(entity)
|
|
325
|
+
except Exception:
|
|
326
|
+
self._conn.rollback()
|
|
327
|
+
raise
|
|
328
|
+
self._conn.commit()
|
|
329
|
+
return fact
|
|
330
|
+
|
|
331
|
+
def get_facts(
|
|
332
|
+
self, entity_id: str, status: Optional[FactStatus] = None
|
|
333
|
+
) -> list[Fact]:
|
|
334
|
+
if status:
|
|
335
|
+
rows = self._conn.execute(
|
|
336
|
+
"SELECT * FROM facts WHERE entity_id = ? AND status = ? ORDER BY created_at DESC",
|
|
337
|
+
(entity_id, status.value),
|
|
338
|
+
).fetchall()
|
|
339
|
+
else:
|
|
340
|
+
rows = self._conn.execute(
|
|
341
|
+
"SELECT * FROM facts WHERE entity_id = ? ORDER BY created_at DESC",
|
|
342
|
+
(entity_id,),
|
|
343
|
+
).fetchall()
|
|
344
|
+
return [self._row_to_fact(r) for r in rows]
|
|
345
|
+
|
|
346
|
+
def get_current_facts(self, entity_id: str) -> list[Fact]:
|
|
347
|
+
rows = self._conn.execute(
|
|
348
|
+
"""SELECT * FROM facts
|
|
349
|
+
WHERE entity_id = ? AND valid_to IS NULL AND superseded_by IS NULL
|
|
350
|
+
AND status NOT IN ('expired', 'retracted')
|
|
351
|
+
ORDER BY created_at DESC""",
|
|
352
|
+
(entity_id,),
|
|
353
|
+
).fetchall()
|
|
354
|
+
return [self._row_to_fact(r) for r in rows]
|
|
355
|
+
|
|
356
|
+
def update_fact(self, fact: Fact) -> Fact:
|
|
357
|
+
self._conn.execute(
|
|
358
|
+
"""UPDATE facts SET
|
|
359
|
+
subject=?, predicate=?, object=?, raw_text=?,
|
|
360
|
+
confidence=?, status=?, valid_from=?, valid_to=?,
|
|
361
|
+
superseded_by=?
|
|
362
|
+
WHERE id=?""",
|
|
363
|
+
(
|
|
364
|
+
fact.subject, fact.predicate, fact.object, fact.raw_text,
|
|
365
|
+
fact.confidence, fact.status.value,
|
|
366
|
+
_dt_to_str(fact.valid_from), _dt_to_str(fact.valid_to),
|
|
367
|
+
fact.superseded_by, fact.id,
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
self._conn.commit()
|
|
371
|
+
return fact
|
|
372
|
+
|
|
373
|
+
# ── Relations ──
|
|
374
|
+
|
|
375
|
+
def add_relation(self, relation: Relation) -> Relation:
|
|
376
|
+
relation.created_at = datetime.now()
|
|
377
|
+
self._conn.execute(
|
|
378
|
+
"""INSERT INTO relations
|
|
379
|
+
(id, from_entity_id, to_entity_id, relation_type, metadata,
|
|
380
|
+
valid_from, valid_to, created_at)
|
|
381
|
+
VALUES (?,?,?,?,?,?,?,?)""",
|
|
382
|
+
(
|
|
383
|
+
relation.id, relation.from_entity_id, relation.to_entity_id,
|
|
384
|
+
relation.relation_type,
|
|
385
|
+
json.dumps(relation.metadata, ensure_ascii=False),
|
|
386
|
+
_dt_to_str(relation.valid_from), _dt_to_str(relation.valid_to),
|
|
387
|
+
_dt_to_str(relation.created_at),
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
self._conn.commit()
|
|
391
|
+
return relation
|
|
392
|
+
|
|
393
|
+
def get_relations(self, entity_id: str, direction: str = "both") -> list[Relation]:
|
|
394
|
+
if direction == "outgoing":
|
|
395
|
+
rows = self._conn.execute(
|
|
396
|
+
"SELECT * FROM relations WHERE from_entity_id = ?", (entity_id,)
|
|
397
|
+
).fetchall()
|
|
398
|
+
elif direction == "incoming":
|
|
399
|
+
rows = self._conn.execute(
|
|
400
|
+
"SELECT * FROM relations WHERE to_entity_id = ?", (entity_id,)
|
|
401
|
+
).fetchall()
|
|
402
|
+
else:
|
|
403
|
+
rows = self._conn.execute(
|
|
404
|
+
"SELECT * FROM relations WHERE from_entity_id = ? OR to_entity_id = ?",
|
|
405
|
+
(entity_id, entity_id),
|
|
406
|
+
).fetchall()
|
|
407
|
+
return [self._row_to_relation(r) for r in rows]
|
|
408
|
+
|
|
409
|
+
# ── Sessions ──
|
|
410
|
+
|
|
411
|
+
def save_session(self, session: Session) -> None:
|
|
412
|
+
self._conn.execute(
|
|
413
|
+
"""INSERT OR REPLACE INTO sessions
|
|
414
|
+
(session_id, agent_id, started_at, ended_at, parent_session_id,
|
|
415
|
+
entities_accessed, entities_modified, facts_added, summary, metadata)
|
|
416
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
|
417
|
+
(
|
|
418
|
+
session.session_id, session.agent_id,
|
|
419
|
+
_dt_to_str(session.started_at), _dt_to_str(session.ended_at),
|
|
420
|
+
session.parent_session_id,
|
|
421
|
+
json.dumps(session.entities_accessed),
|
|
422
|
+
json.dumps(session.entities_modified),
|
|
423
|
+
json.dumps(session.facts_added),
|
|
424
|
+
session.summary,
|
|
425
|
+
json.dumps(session.metadata, ensure_ascii=False),
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
self._conn.commit()
|
|
429
|
+
|
|
430
|
+
def get_session(self, session_id: str) -> Optional[Session]:
|
|
431
|
+
row = self._conn.execute(
|
|
432
|
+
"SELECT * FROM sessions WHERE session_id = ?", (session_id,)
|
|
433
|
+
).fetchone()
|
|
434
|
+
return self._row_to_session(row) if row else None
|
|
435
|
+
|
|
436
|
+
def get_recent_sessions(self, agent_id: str, limit: int = 5) -> list[Session]:
|
|
437
|
+
rows = self._conn.execute(
|
|
438
|
+
"SELECT * FROM sessions WHERE agent_id = ? ORDER BY started_at DESC LIMIT ?",
|
|
439
|
+
(agent_id, limit),
|
|
440
|
+
).fetchall()
|
|
441
|
+
return [self._row_to_session(r) for r in rows]
|
|
442
|
+
|
|
443
|
+
def add_session_event(self, event: SessionEvent) -> None:
|
|
444
|
+
self._conn.execute(
|
|
445
|
+
"""INSERT INTO session_events (session_id, event_type, target, detail, timestamp)
|
|
446
|
+
VALUES (?,?,?,?,?)""",
|
|
447
|
+
(
|
|
448
|
+
event.session_id, event.event_type, event.target,
|
|
449
|
+
json.dumps(event.detail, ensure_ascii=False),
|
|
450
|
+
_dt_to_str(event.timestamp),
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
self._conn.commit()
|
|
454
|
+
|
|
455
|
+
# ── Conflicts ──
|
|
456
|
+
|
|
457
|
+
def add_conflict(self, conflict: dict) -> None:
|
|
458
|
+
# Validate required keys
|
|
459
|
+
if "id" not in conflict or "conflict_type" not in conflict:
|
|
460
|
+
raise StorageError("Conflict dict must contain 'id' and 'conflict_type' keys")
|
|
461
|
+
self._conn.execute(
|
|
462
|
+
"""INSERT INTO conflicts
|
|
463
|
+
(id, existing_fact_id, new_fact_id, conflict_type, suggested_resolution)
|
|
464
|
+
VALUES (?,?,?,?,?)""",
|
|
465
|
+
(
|
|
466
|
+
conflict["id"], conflict.get("existing_fact_id"),
|
|
467
|
+
conflict.get("new_fact_id"), conflict["conflict_type"],
|
|
468
|
+
conflict.get("suggested_resolution"),
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
self._conn.commit()
|
|
472
|
+
|
|
473
|
+
def get_pending_conflicts(self) -> list[dict]:
|
|
474
|
+
rows = self._conn.execute(
|
|
475
|
+
"SELECT * FROM conflicts WHERE resolved_at IS NULL"
|
|
476
|
+
).fetchall()
|
|
477
|
+
return [dict(r) for r in rows]
|
|
478
|
+
|
|
479
|
+
def resolve_conflict(self, conflict_id: str, resolution: str, resolved_by: str) -> None:
|
|
480
|
+
self._conn.execute(
|
|
481
|
+
"""UPDATE conflicts SET resolution=?, resolved_at=?, resolved_by=? WHERE id=?""",
|
|
482
|
+
(resolution, _dt_to_str(datetime.now()), resolved_by, conflict_id),
|
|
483
|
+
)
|
|
484
|
+
self._conn.commit()
|
|
485
|
+
|
|
486
|
+
# ── Stats ──
|
|
487
|
+
|
|
488
|
+
def entity_count(self) -> int:
|
|
489
|
+
row = self._conn.execute("SELECT COUNT(*) FROM entities").fetchone()
|
|
490
|
+
return row[0] if row else 0
|
|
491
|
+
|
|
492
|
+
def fact_count(self) -> int:
|
|
493
|
+
row = self._conn.execute("SELECT COUNT(*) FROM facts").fetchone()
|
|
494
|
+
return row[0] if row else 0
|
|
495
|
+
|
|
496
|
+
# ── FTS Indexing ──
|
|
497
|
+
|
|
498
|
+
def _index_entity(self, entity: Entity, facts: Optional[list[Fact]] = None) -> None:
|
|
499
|
+
if facts is None:
|
|
500
|
+
facts = self.get_current_facts(entity.id)
|
|
501
|
+
# Only index top 50 most recent/confident facts to prevent FTS bloat
|
|
502
|
+
relevant = sorted(facts, key=lambda f: (f.confidence, f.created_at.isoformat()), reverse=True)[:50]
|
|
503
|
+
fact_texts = " ".join(f.raw_text for f in relevant)
|
|
504
|
+
|
|
505
|
+
# Include relation targets so searching "Hashed" also finds "Simon Kim (CEO_OF Hashed)"
|
|
506
|
+
relations = self.get_relations(entity.id)
|
|
507
|
+
relation_parts: list[str] = []
|
|
508
|
+
for r in relations[:20]:
|
|
509
|
+
other_id = r.to_entity_id if r.from_entity_id == entity.id else r.from_entity_id
|
|
510
|
+
other = self.get_entity(other_id)
|
|
511
|
+
if other:
|
|
512
|
+
relation_parts.append(f"{r.relation_type} {other.name}")
|
|
513
|
+
if relation_parts:
|
|
514
|
+
fact_texts = f"{fact_texts} {' '.join(relation_parts)}"
|
|
515
|
+
|
|
516
|
+
state_text = " ".join(f"{k}: {v}" for k, v in entity.state.to_dict().items() if isinstance(v, str))
|
|
517
|
+
# Include aliases in searchable text
|
|
518
|
+
alias_text = " ".join(entity.aliases) if entity.aliases else ""
|
|
519
|
+
if alias_text:
|
|
520
|
+
fact_texts = f"{fact_texts} {alias_text}"
|
|
521
|
+
|
|
522
|
+
self._conn.execute(
|
|
523
|
+
"INSERT INTO memory_fts (entity_id, entity_name, entity_type, fact_text, state_text, summary) VALUES (?,?,?,?,?,?)",
|
|
524
|
+
(entity.id, entity.name, entity.entity_type, fact_texts, state_text, entity.summary),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
def _reindex_entity(self, entity: Entity) -> None:
|
|
528
|
+
self._conn.execute("DELETE FROM memory_fts WHERE entity_id = ?", (entity.id,))
|
|
529
|
+
facts = self.get_facts(entity.id)
|
|
530
|
+
self._index_entity(entity, facts)
|
|
531
|
+
|
|
532
|
+
def reindex_all(self) -> int:
|
|
533
|
+
self._conn.execute("DELETE FROM memory_fts")
|
|
534
|
+
count = 0
|
|
535
|
+
offset = 0
|
|
536
|
+
batch_size = 500
|
|
537
|
+
while True:
|
|
538
|
+
batch = self.list_entities(limit=batch_size, offset=offset)
|
|
539
|
+
if not batch:
|
|
540
|
+
break
|
|
541
|
+
for entity in batch:
|
|
542
|
+
self._index_entity(entity)
|
|
543
|
+
count += len(batch)
|
|
544
|
+
offset += batch_size
|
|
545
|
+
self._conn.commit()
|
|
546
|
+
return count
|
|
547
|
+
|
|
548
|
+
# ── Row → Model Conversion ──
|
|
549
|
+
|
|
550
|
+
def _row_to_entity(self, row: sqlite3.Row) -> Entity:
|
|
551
|
+
state_data = json.loads(row["state"]) if row["state"] else {}
|
|
552
|
+
return Entity(
|
|
553
|
+
id=row["id"],
|
|
554
|
+
name=row["name"],
|
|
555
|
+
entity_type=row["entity_type"],
|
|
556
|
+
tier=Tier(row["tier"]),
|
|
557
|
+
summary=row["summary"] or "",
|
|
558
|
+
aliases=json.loads(row["aliases"]) if row["aliases"] else [],
|
|
559
|
+
state=EntityState.from_dict(state_data),
|
|
560
|
+
created_at=_str_to_dt(row["created_at"]) or datetime.now(),
|
|
561
|
+
updated_at=_str_to_dt(row["updated_at"]) or datetime.now(),
|
|
562
|
+
access_count=row["access_count"] or 0,
|
|
563
|
+
last_accessed=_str_to_dt(row["last_accessed"]),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _row_to_fact(self, row: sqlite3.Row) -> Fact:
|
|
567
|
+
return Fact(
|
|
568
|
+
id=row["id"],
|
|
569
|
+
entity_id=row["entity_id"],
|
|
570
|
+
subject=row["subject"],
|
|
571
|
+
predicate=row["predicate"],
|
|
572
|
+
object=row["object"],
|
|
573
|
+
raw_text=row["raw_text"],
|
|
574
|
+
source=Source(
|
|
575
|
+
type=SourceType(row["source_type"]),
|
|
576
|
+
author=row["source_author"] or "",
|
|
577
|
+
channel=row["source_channel"] or "",
|
|
578
|
+
timestamp=_str_to_dt(row["source_timestamp"]) or datetime.now(),
|
|
579
|
+
confidence=row["source_confidence"] if row["source_confidence"] is not None else 1.0,
|
|
580
|
+
url=row["source_url"],
|
|
581
|
+
session_id=row["source_session_id"],
|
|
582
|
+
),
|
|
583
|
+
confidence=row["confidence"],
|
|
584
|
+
status=FactStatus(row["status"]),
|
|
585
|
+
valid_from=_str_to_dt(row["valid_from"]),
|
|
586
|
+
valid_to=_str_to_dt(row["valid_to"]),
|
|
587
|
+
superseded_by=row["superseded_by"],
|
|
588
|
+
created_at=_str_to_dt(row["created_at"]) or datetime.now(),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def _row_to_relation(self, row: sqlite3.Row) -> Relation:
|
|
592
|
+
return Relation(
|
|
593
|
+
id=row["id"],
|
|
594
|
+
from_entity_id=row["from_entity_id"],
|
|
595
|
+
to_entity_id=row["to_entity_id"],
|
|
596
|
+
relation_type=row["relation_type"],
|
|
597
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
|
598
|
+
valid_from=_str_to_dt(row["valid_from"]),
|
|
599
|
+
valid_to=_str_to_dt(row["valid_to"]),
|
|
600
|
+
created_at=_str_to_dt(row["created_at"]) or datetime.now(),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def _row_to_session(self, row: sqlite3.Row) -> Session:
|
|
604
|
+
return Session(
|
|
605
|
+
session_id=row["session_id"],
|
|
606
|
+
agent_id=row["agent_id"],
|
|
607
|
+
started_at=_str_to_dt(row["started_at"]) or datetime.now(),
|
|
608
|
+
ended_at=_str_to_dt(row["ended_at"]),
|
|
609
|
+
parent_session_id=row["parent_session_id"],
|
|
610
|
+
entities_accessed=json.loads(row["entities_accessed"]) if row["entities_accessed"] else [],
|
|
611
|
+
entities_modified=json.loads(row["entities_modified"]) if row["entities_modified"] else [],
|
|
612
|
+
facts_added=json.loads(row["facts_added"]) if row["facts_added"] else [],
|
|
613
|
+
summary=row["summary"],
|
|
614
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
|
615
|
+
)
|