mempalace-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mempalace/README.md +40 -0
- mempalace/__init__.py +6 -0
- mempalace/__main__.py +5 -0
- mempalace/cli.py +811 -0
- mempalace/config.py +149 -0
- mempalace/convo_miner.py +415 -0
- mempalace/dialect.py +1075 -0
- mempalace/entity_detector.py +853 -0
- mempalace/entity_registry.py +639 -0
- mempalace/export.py +378 -0
- mempalace/general_extractor.py +521 -0
- mempalace/knowledge_graph.py +410 -0
- mempalace/layers.py +515 -0
- mempalace/mcp_server.py +873 -0
- mempalace/migrate.py +153 -0
- mempalace/miner.py +1285 -0
- mempalace/normalize.py +328 -0
- mempalace/onboarding.py +489 -0
- mempalace/palace_graph.py +225 -0
- mempalace/py.typed +0 -0
- mempalace/room_detector_local.py +310 -0
- mempalace/searcher.py +305 -0
- mempalace/spellcheck.py +269 -0
- mempalace/split_mega_files.py +309 -0
- mempalace/storage.py +807 -0
- mempalace/version.py +3 -0
- mempalace_code-1.0.0.dist-info/METADATA +489 -0
- mempalace_code-1.0.0.dist-info/RECORD +32 -0
- mempalace_code-1.0.0.dist-info/WHEEL +4 -0
- mempalace_code-1.0.0.dist-info/entry_points.txt +2 -0
- mempalace_code-1.0.0.dist-info/licenses/LICENSE +192 -0
- mempalace_code-1.0.0.dist-info/licenses/NOTICE +17 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""
|
|
2
|
+
knowledge_graph.py — Temporal Entity-Relationship Graph for MemPalace
|
|
3
|
+
=====================================================================
|
|
4
|
+
|
|
5
|
+
Real knowledge graph with:
|
|
6
|
+
- Entity nodes (people, projects, tools, concepts)
|
|
7
|
+
- Typed relationship edges (daughter_of, does, loves, works_on, etc.)
|
|
8
|
+
- Temporal validity (valid_from → valid_to — knows WHEN facts are true)
|
|
9
|
+
- Closet references (links back to the verbatim memory)
|
|
10
|
+
|
|
11
|
+
Storage: SQLite (local, no dependencies, no subscriptions)
|
|
12
|
+
Query: entity-first traversal with time filtering
|
|
13
|
+
|
|
14
|
+
This is what competes with Zep's temporal knowledge graph.
|
|
15
|
+
Zep uses Neo4j in the cloud ($25/mo+). We use SQLite locally (free).
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
from mempalace.knowledge_graph import KnowledgeGraph
|
|
19
|
+
|
|
20
|
+
kg = KnowledgeGraph()
|
|
21
|
+
kg.add_triple("Max", "child_of", "Alice", valid_from="2015-04-01")
|
|
22
|
+
kg.add_triple("Max", "does", "swimming", valid_from="2025-01-01")
|
|
23
|
+
kg.add_triple("Max", "loves", "chess", valid_from="2025-10-01")
|
|
24
|
+
|
|
25
|
+
# Query: everything about Max
|
|
26
|
+
kg.query_entity("Max")
|
|
27
|
+
|
|
28
|
+
# Query: what was true about Max in January 2026?
|
|
29
|
+
kg.query_entity("Max", as_of="2026-01-15")
|
|
30
|
+
|
|
31
|
+
# Query: who is connected to Alice?
|
|
32
|
+
kg.query_entity("Alice", direction="both")
|
|
33
|
+
|
|
34
|
+
# Invalidate: Max's sports injury resolved
|
|
35
|
+
kg.invalidate("Max", "has_issue", "sports_injury", ended="2026-02-15")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import hashlib
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import sqlite3
|
|
42
|
+
from datetime import date, datetime
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class KnowledgeGraph:
|
|
50
|
+
def __init__(self, db_path: str = None):
|
|
51
|
+
self.db_path = db_path or DEFAULT_KG_PATH
|
|
52
|
+
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
self._init_db()
|
|
54
|
+
|
|
55
|
+
def _init_db(self):
|
|
56
|
+
conn = self._conn()
|
|
57
|
+
conn.executescript("""
|
|
58
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
name TEXT NOT NULL,
|
|
61
|
+
type TEXT DEFAULT 'unknown',
|
|
62
|
+
properties TEXT DEFAULT '{}',
|
|
63
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS triples (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
subject TEXT NOT NULL,
|
|
69
|
+
predicate TEXT NOT NULL,
|
|
70
|
+
object TEXT NOT NULL,
|
|
71
|
+
valid_from TEXT,
|
|
72
|
+
valid_to TEXT,
|
|
73
|
+
confidence REAL DEFAULT 1.0,
|
|
74
|
+
source_closet TEXT,
|
|
75
|
+
source_file TEXT,
|
|
76
|
+
extracted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
77
|
+
FOREIGN KEY (subject) REFERENCES entities(id),
|
|
78
|
+
FOREIGN KEY (object) REFERENCES entities(id)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
|
|
85
|
+
""")
|
|
86
|
+
conn.commit()
|
|
87
|
+
conn.close()
|
|
88
|
+
|
|
89
|
+
def _conn(self):
|
|
90
|
+
conn = sqlite3.connect(self.db_path, timeout=10)
|
|
91
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
92
|
+
return conn
|
|
93
|
+
|
|
94
|
+
def _entity_id(self, name: str) -> str:
|
|
95
|
+
return name.lower().replace(" ", "_").replace("'", "")
|
|
96
|
+
|
|
97
|
+
# ── Write operations ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def add_entity(self, name: str, entity_type: str = "unknown", properties: dict = None):
|
|
100
|
+
"""Add or update an entity node."""
|
|
101
|
+
eid = self._entity_id(name)
|
|
102
|
+
props = json.dumps(properties or {})
|
|
103
|
+
conn = self._conn()
|
|
104
|
+
conn.execute(
|
|
105
|
+
"INSERT OR REPLACE INTO entities (id, name, type, properties) VALUES (?, ?, ?, ?)",
|
|
106
|
+
(eid, name, entity_type, props),
|
|
107
|
+
)
|
|
108
|
+
conn.commit()
|
|
109
|
+
conn.close()
|
|
110
|
+
return eid
|
|
111
|
+
|
|
112
|
+
def add_triple(
|
|
113
|
+
self,
|
|
114
|
+
subject: str,
|
|
115
|
+
predicate: str,
|
|
116
|
+
obj: str,
|
|
117
|
+
valid_from: str = None,
|
|
118
|
+
valid_to: str = None,
|
|
119
|
+
confidence: float = 1.0,
|
|
120
|
+
source_closet: str = None,
|
|
121
|
+
source_file: str = None,
|
|
122
|
+
):
|
|
123
|
+
"""
|
|
124
|
+
Add a relationship triple: subject → predicate → object.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
add_triple("Max", "child_of", "Alice", valid_from="2015-04-01")
|
|
128
|
+
add_triple("Max", "does", "swimming", valid_from="2025-01-01")
|
|
129
|
+
add_triple("Alice", "worried_about", "Max injury", valid_from="2026-01", valid_to="2026-02")
|
|
130
|
+
"""
|
|
131
|
+
sub_id = self._entity_id(subject)
|
|
132
|
+
obj_id = self._entity_id(obj)
|
|
133
|
+
pred = predicate.lower().replace(" ", "_")
|
|
134
|
+
|
|
135
|
+
# Auto-create entities if they don't exist
|
|
136
|
+
conn = self._conn()
|
|
137
|
+
conn.execute("INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (sub_id, subject))
|
|
138
|
+
conn.execute("INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (obj_id, obj))
|
|
139
|
+
|
|
140
|
+
# Check for existing identical triple
|
|
141
|
+
existing = conn.execute(
|
|
142
|
+
"SELECT id FROM triples WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
|
143
|
+
(sub_id, pred, obj_id),
|
|
144
|
+
).fetchone()
|
|
145
|
+
|
|
146
|
+
if existing:
|
|
147
|
+
conn.close()
|
|
148
|
+
return existing[0] # Already exists and still valid
|
|
149
|
+
|
|
150
|
+
triple_id = f"t_{sub_id}_{pred}_{obj_id}_{hashlib.md5(f'{valid_from}{datetime.now().isoformat()}'.encode()).hexdigest()[:8]}"
|
|
151
|
+
|
|
152
|
+
conn.execute(
|
|
153
|
+
"""INSERT INTO triples (id, subject, predicate, object, valid_from, valid_to, confidence, source_closet, source_file)
|
|
154
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
155
|
+
(
|
|
156
|
+
triple_id,
|
|
157
|
+
sub_id,
|
|
158
|
+
pred,
|
|
159
|
+
obj_id,
|
|
160
|
+
valid_from,
|
|
161
|
+
valid_to,
|
|
162
|
+
confidence,
|
|
163
|
+
source_closet,
|
|
164
|
+
source_file,
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
conn.commit()
|
|
168
|
+
conn.close()
|
|
169
|
+
return triple_id
|
|
170
|
+
|
|
171
|
+
def invalidate(self, subject: str, predicate: str, obj: str, ended: str = None):
|
|
172
|
+
"""Mark a relationship as no longer valid (set valid_to date)."""
|
|
173
|
+
sub_id = self._entity_id(subject)
|
|
174
|
+
obj_id = self._entity_id(obj)
|
|
175
|
+
pred = predicate.lower().replace(" ", "_")
|
|
176
|
+
ended = ended or date.today().isoformat()
|
|
177
|
+
|
|
178
|
+
conn = self._conn()
|
|
179
|
+
conn.execute(
|
|
180
|
+
"UPDATE triples SET valid_to=? WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
|
181
|
+
(ended, sub_id, pred, obj_id),
|
|
182
|
+
)
|
|
183
|
+
conn.commit()
|
|
184
|
+
conn.close()
|
|
185
|
+
|
|
186
|
+
# ── Query operations ──────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def query_entity(self, name: str, as_of: str = None, direction: str = "outgoing"):
|
|
189
|
+
"""
|
|
190
|
+
Get all relationships for an entity.
|
|
191
|
+
|
|
192
|
+
direction: "outgoing" (entity → ?), "incoming" (? → entity), "both"
|
|
193
|
+
as_of: date string — only return facts valid at that time
|
|
194
|
+
"""
|
|
195
|
+
eid = self._entity_id(name)
|
|
196
|
+
conn = self._conn()
|
|
197
|
+
|
|
198
|
+
results = []
|
|
199
|
+
|
|
200
|
+
if direction in ("outgoing", "both"):
|
|
201
|
+
query = "SELECT t.*, e.name as obj_name FROM triples t JOIN entities e ON t.object = e.id WHERE t.subject = ?"
|
|
202
|
+
params = [eid]
|
|
203
|
+
if as_of:
|
|
204
|
+
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
|
|
205
|
+
params.extend([as_of, as_of])
|
|
206
|
+
for row in conn.execute(query, params).fetchall():
|
|
207
|
+
results.append(
|
|
208
|
+
{
|
|
209
|
+
"direction": "outgoing",
|
|
210
|
+
"subject": name,
|
|
211
|
+
"predicate": row[2],
|
|
212
|
+
"object": row[10], # obj_name
|
|
213
|
+
"valid_from": row[4],
|
|
214
|
+
"valid_to": row[5],
|
|
215
|
+
"confidence": row[6],
|
|
216
|
+
"source_closet": row[7],
|
|
217
|
+
"current": row[5] is None,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if direction in ("incoming", "both"):
|
|
222
|
+
query = "SELECT t.*, e.name as sub_name FROM triples t JOIN entities e ON t.subject = e.id WHERE t.object = ?"
|
|
223
|
+
params = [eid]
|
|
224
|
+
if as_of:
|
|
225
|
+
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
|
|
226
|
+
params.extend([as_of, as_of])
|
|
227
|
+
for row in conn.execute(query, params).fetchall():
|
|
228
|
+
results.append(
|
|
229
|
+
{
|
|
230
|
+
"direction": "incoming",
|
|
231
|
+
"subject": row[10], # sub_name
|
|
232
|
+
"predicate": row[2],
|
|
233
|
+
"object": name,
|
|
234
|
+
"valid_from": row[4],
|
|
235
|
+
"valid_to": row[5],
|
|
236
|
+
"confidence": row[6],
|
|
237
|
+
"source_closet": row[7],
|
|
238
|
+
"current": row[5] is None,
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
conn.close()
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
def query_relationship(self, predicate: str, as_of: str = None):
|
|
246
|
+
"""Get all triples with a given relationship type."""
|
|
247
|
+
pred = predicate.lower().replace(" ", "_")
|
|
248
|
+
conn = self._conn()
|
|
249
|
+
query = """
|
|
250
|
+
SELECT t.*, s.name as sub_name, o.name as obj_name
|
|
251
|
+
FROM triples t
|
|
252
|
+
JOIN entities s ON t.subject = s.id
|
|
253
|
+
JOIN entities o ON t.object = o.id
|
|
254
|
+
WHERE t.predicate = ?
|
|
255
|
+
"""
|
|
256
|
+
params = [pred]
|
|
257
|
+
if as_of:
|
|
258
|
+
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
|
|
259
|
+
params.extend([as_of, as_of])
|
|
260
|
+
|
|
261
|
+
results = []
|
|
262
|
+
for row in conn.execute(query, params).fetchall():
|
|
263
|
+
results.append(
|
|
264
|
+
{
|
|
265
|
+
"subject": row[10],
|
|
266
|
+
"predicate": pred,
|
|
267
|
+
"object": row[11],
|
|
268
|
+
"valid_from": row[4],
|
|
269
|
+
"valid_to": row[5],
|
|
270
|
+
"current": row[5] is None,
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
conn.close()
|
|
274
|
+
return results
|
|
275
|
+
|
|
276
|
+
def timeline(self, entity_name: str = None):
|
|
277
|
+
"""Get all facts in chronological order, optionally filtered by entity."""
|
|
278
|
+
conn = self._conn()
|
|
279
|
+
if entity_name:
|
|
280
|
+
eid = self._entity_id(entity_name)
|
|
281
|
+
rows = conn.execute(
|
|
282
|
+
"""
|
|
283
|
+
SELECT t.*, s.name as sub_name, o.name as obj_name
|
|
284
|
+
FROM triples t
|
|
285
|
+
JOIN entities s ON t.subject = s.id
|
|
286
|
+
JOIN entities o ON t.object = o.id
|
|
287
|
+
WHERE (t.subject = ? OR t.object = ?)
|
|
288
|
+
ORDER BY t.valid_from ASC NULLS LAST
|
|
289
|
+
LIMIT 100
|
|
290
|
+
""",
|
|
291
|
+
(eid, eid),
|
|
292
|
+
).fetchall()
|
|
293
|
+
else:
|
|
294
|
+
rows = conn.execute("""
|
|
295
|
+
SELECT t.*, s.name as sub_name, o.name as obj_name
|
|
296
|
+
FROM triples t
|
|
297
|
+
JOIN entities s ON t.subject = s.id
|
|
298
|
+
JOIN entities o ON t.object = o.id
|
|
299
|
+
ORDER BY t.valid_from ASC NULLS LAST
|
|
300
|
+
LIMIT 100
|
|
301
|
+
""").fetchall()
|
|
302
|
+
|
|
303
|
+
conn.close()
|
|
304
|
+
return [
|
|
305
|
+
{
|
|
306
|
+
"subject": r[10],
|
|
307
|
+
"predicate": r[2],
|
|
308
|
+
"object": r[11],
|
|
309
|
+
"valid_from": r[4],
|
|
310
|
+
"valid_to": r[5],
|
|
311
|
+
"current": r[5] is None,
|
|
312
|
+
}
|
|
313
|
+
for r in rows
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
def iter_all_triples(self, batch_size=500):
|
|
317
|
+
"""Yield batches of triple dicts without the LIMIT 100 cap.
|
|
318
|
+
|
|
319
|
+
Each dict contains: id, subject, predicate, object, valid_from, valid_to,
|
|
320
|
+
confidence, source_closet, source_file.
|
|
321
|
+
"""
|
|
322
|
+
conn = self._conn()
|
|
323
|
+
cursor = conn.execute("""
|
|
324
|
+
SELECT t.id, s.name AS subject, t.predicate, o.name AS object,
|
|
325
|
+
t.valid_from, t.valid_to, t.confidence, t.source_closet, t.source_file
|
|
326
|
+
FROM triples t
|
|
327
|
+
JOIN entities s ON t.subject = s.id
|
|
328
|
+
JOIN entities o ON t.object = o.id
|
|
329
|
+
ORDER BY t.extracted_at ASC
|
|
330
|
+
""")
|
|
331
|
+
cols = [d[0] for d in cursor.description]
|
|
332
|
+
while True:
|
|
333
|
+
rows = cursor.fetchmany(batch_size)
|
|
334
|
+
if not rows:
|
|
335
|
+
break
|
|
336
|
+
yield [dict(zip(cols, r)) for r in rows]
|
|
337
|
+
conn.close()
|
|
338
|
+
|
|
339
|
+
# ── Stats ─────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
def stats(self):
|
|
342
|
+
conn = self._conn()
|
|
343
|
+
entities = conn.execute("SELECT COUNT(*) FROM entities").fetchone()[0]
|
|
344
|
+
triples = conn.execute("SELECT COUNT(*) FROM triples").fetchone()[0]
|
|
345
|
+
current = conn.execute("SELECT COUNT(*) FROM triples WHERE valid_to IS NULL").fetchone()[0]
|
|
346
|
+
expired = triples - current
|
|
347
|
+
predicates = [
|
|
348
|
+
r[0]
|
|
349
|
+
for r in conn.execute(
|
|
350
|
+
"SELECT DISTINCT predicate FROM triples ORDER BY predicate"
|
|
351
|
+
).fetchall()
|
|
352
|
+
]
|
|
353
|
+
conn.close()
|
|
354
|
+
return {
|
|
355
|
+
"entities": entities,
|
|
356
|
+
"triples": triples,
|
|
357
|
+
"current_facts": current,
|
|
358
|
+
"expired_facts": expired,
|
|
359
|
+
"relationship_types": predicates,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
# ── Seed from known facts ─────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
def seed_from_entity_facts(self, entity_facts: dict):
|
|
365
|
+
"""
|
|
366
|
+
Seed the knowledge graph from fact_checker.py ENTITY_FACTS.
|
|
367
|
+
This bootstraps the graph with known ground truth.
|
|
368
|
+
"""
|
|
369
|
+
for key, facts in entity_facts.items():
|
|
370
|
+
name = facts.get("full_name", key.capitalize())
|
|
371
|
+
etype = facts.get("type", "person")
|
|
372
|
+
self.add_entity(
|
|
373
|
+
name,
|
|
374
|
+
etype,
|
|
375
|
+
{
|
|
376
|
+
"gender": facts.get("gender", ""),
|
|
377
|
+
"birthday": facts.get("birthday", ""),
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Relationships
|
|
382
|
+
parent = facts.get("parent")
|
|
383
|
+
if parent:
|
|
384
|
+
self.add_triple(
|
|
385
|
+
name, "child_of", parent.capitalize(), valid_from=facts.get("birthday")
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
partner = facts.get("partner")
|
|
389
|
+
if partner:
|
|
390
|
+
self.add_triple(name, "married_to", partner.capitalize())
|
|
391
|
+
|
|
392
|
+
relationship = facts.get("relationship", "")
|
|
393
|
+
if relationship == "daughter":
|
|
394
|
+
self.add_triple(
|
|
395
|
+
name,
|
|
396
|
+
"is_child_of",
|
|
397
|
+
facts.get("parent", "").capitalize() or name,
|
|
398
|
+
valid_from=facts.get("birthday"),
|
|
399
|
+
)
|
|
400
|
+
elif relationship == "husband":
|
|
401
|
+
self.add_triple(name, "is_partner_of", facts.get("partner", name).capitalize())
|
|
402
|
+
elif relationship == "brother":
|
|
403
|
+
self.add_triple(name, "is_sibling_of", facts.get("sibling", name).capitalize())
|
|
404
|
+
elif relationship == "dog":
|
|
405
|
+
self.add_triple(name, "is_pet_of", facts.get("owner", name).capitalize())
|
|
406
|
+
self.add_entity(name, "animal")
|
|
407
|
+
|
|
408
|
+
# Interests
|
|
409
|
+
for interest in facts.get("interests", []):
|
|
410
|
+
self.add_triple(name, "loves", interest.capitalize(), valid_from="2025-01-01")
|